This commit is contained in:
Hadi
2026-08-27 23:14:56 +02:00
commit e9c2a59ee5
41 changed files with 3680 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
package app
import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
)
type (
tickMsg time.Time
serversMsg []backend.LogicalServer
serversRefreshedMsg []backend.LogicalServer
serversErrMsg struct{ err error }
statusMsg backend.ConnectionStatus
statusErrMsg struct{ err error }
serversCacheErrMsg struct{ err error }
quickStatusMsg bool
quickStatusErrMsg struct{ err error }
connectResultMsg struct {
err error
target string
disconnect bool
freeFallback bool
}
)
func tickCmd() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func fetchServersCache() tea.Msg {
servers, err := backend.ParseCache()
if err != nil {
return serversCacheErrMsg{err: err}
}
return serversMsg(servers)
}
func refreshServers() tea.Msg {
if err := backend.RefreshServerList(); err != nil {
return serversErrMsg{err: err}
}
servers, err := parseCacheAfterRefresh()
if err != nil {
return serversErrMsg{err: err}
}
return serversMsg(servers)
}
func refreshServersManual() tea.Msg {
if err := backend.RefreshServerList(); err != nil {
return serversErrMsg{err: err}
}
servers, err := parseCacheAfterRefresh()
if err != nil {
return serversErrMsg{err: err}
}
return serversRefreshedMsg(servers)
}
func parseCacheAfterRefresh() ([]backend.LogicalServer, error) {
servers, err := backend.ParseCache()
if err == nil {
return servers, nil
}
time.Sleep(300 * time.Millisecond)
return backend.ParseCache()
}
func fetchStatus() tea.Msg {
status, err := backend.Status()
if err != nil {
return statusErrMsg{err: err}
}
return statusMsg(status)
}
func fetchQuickStatus() tea.Msg {
connected, err := backend.IsConnected()
if err != nil {
return quickStatusErrMsg{err: err}
}
return quickStatusMsg(connected)
}
func connectGroup(country, city, prefer string, f filters) tea.Cmd {
return func() tea.Msg {
target := backend.CountryName(country)
if city != "" {
target = city + ", " + target
}
err, fallback := connectWithFreeFallback(func() error {
if prefer == "random" {
return backend.ConnectRandom(country, city, f.P2P, f.SecureCore, f.Tor)
}
return backend.ConnectFastest(country, city, f.P2P, f.SecureCore, f.Tor)
})
if fallback {
target = "an available free server"
}
return connectResultMsg{err: err, target: target, freeFallback: fallback}
}
}
func connectServer(s backend.LogicalServer) tea.Cmd {
return func() tea.Msg {
target := s.Name
err, fallback := connectWithFreeFallback(func() error { return backend.ConnectId(s.Name) })
if fallback {
target = "an available free server"
}
return connectResultMsg{err: err, target: target, freeFallback: fallback}
}
}
func connectWithFreeFallback(attempt func() error) (err error, fallback bool) {
err = attempt()
if !backend.IsFreePlanRestricted(err) {
return err, false
}
return backend.ConnectAny(), true
}
func disconnectCmd() tea.Cmd {
return func() tea.Msg {
err := backend.Disconnect()
return connectResultMsg{err: err, disconnect: true}
}
}
func errorToast(err error) tea.Cmd {
return notification.Show("Error", err.Error(), notification.Error)
}
+180
View File
@@ -0,0 +1,180 @@
package app
import (
"fmt"
"image/color"
"io"
"strings"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
zone "github.com/lrstanley/bubblezone/v2"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
)
const (
infoColWidth = 14
loadColWidth = 8
minNameWidth = 6
boxOverhead = 4
serversColumnMinWidth = 45
)
type rowDelegate struct {
showInfo bool
}
func (d rowDelegate) Height() int { return 3 }
func (d rowDelegate) Spacing() int { return 1 }
func (d rowDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d rowDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
r, ok := item.(row)
if !ok {
return
}
selected := index == m.Index()
indent := strings.Repeat(" ", int(r.kind))
expandIcon := " "
if r.kind == rowCountry || r.kind == rowCity {
if r.expanded {
expandIcon = icons.I.Expanded
} else {
expandIcon = icons.I.Collapsed
}
}
label, info, load := d.columns(r)
nameStyle := lipgloss.NewStyle().Foreground(style.S.Text)
metaStyle := lipgloss.NewStyle().Foreground(style.S.Muted)
if selected {
nameStyle = nameStyle.Foreground(style.S.Primary).Bold(true)
metaStyle = metaStyle.Foreground(style.S.Primary)
}
prefixWidth := lipgloss.Width(indent) + lipgloss.Width(expandIcon) + 1
infoW, gaps := infoColumnLayout(d.showInfo)
nameWidth := max(m.Width()-boxOverhead-prefixWidth-infoW-loadColWidth-gaps, minNameWidth)
nameCol := nameStyle.Render(fixedWidth(label, nameWidth))
loadCol := metaStyle.Render(fixedWidth(load, loadColWidth))
borderType := style.S.BorderType
if !selected && r.kind != rowCountry {
borderType = lipgloss.HiddenBorder()
}
boxStyle := lipgloss.NewStyle().
Border(borderType).
BorderForeground(rowBorderColor(r, selected)).
Padding(0, 1)
line := indent + expandIcon + " " + nameCol
if d.showInfo {
line += " " + metaStyle.Render(fixedWidth(info, infoColWidth))
}
line += " " + loadCol
rendered := boxStyle.Render(clampToWidth(line, m.Width()-boxOverhead))
fmt.Fprint(w, zone.Mark(rowZoneID(index), rendered))
}
func infoColumnLayout(showInfo bool) (width, gaps int) {
if !showInfo {
return 0, 1
}
return infoColWidth, 2
}
func fixedWidth(s string, width int) string {
if width <= 0 {
return ""
}
return lipgloss.NewStyle().Width(width).MaxWidth(width).Inline(true).
Render(ansi.Truncate(s, width, "…"))
}
func rowBorderColor(r row, selected bool) color.Color {
if selected {
return style.S.Primary
}
if r.kind == rowCountry {
return style.S.Subtle
}
return style.S.Background
}
func (d rowDelegate) columns(r row) (name, info, load string) {
switch r.kind {
case rowCountry:
countryName := backend.CountryName(r.country)
mark := ""
switch {
case config.Global.App.ShowFlags:
mark = backend.FlagEmoji(r.country) + " "
case icons.I.Country != "":
mark = icons.I.Country + " "
}
fav := ""
if r.favorite {
fav = icons.I.Favorite + " "
}
name = fav + mark + countryName
info = fmt.Sprintf("%d servers", r.serverCount)
load = fmt.Sprintf("%s %d%%", icons.I.Load, r.avgLoad)
case rowCity:
cityName := r.city
if cityName == "" {
cityName = "Other"
}
cityIcon := ""
if icons.I.City != "" {
cityIcon = icons.I.City + " "
}
fav := ""
if r.favorite {
fav = icons.I.Favorite + " "
}
name = fav + cityIcon + cityName
info = fmt.Sprintf("%d servers", r.serverCount)
load = fmt.Sprintf("%s %d%%", icons.I.Load, r.avgLoad)
default:
serverIcon := ""
if icons.I.Server != "" {
serverIcon = icons.I.Server + " "
}
fav := ""
if r.favorite {
fav = icons.I.Favorite + " "
}
name = fav + serverIcon + r.server.Name
var badges []string
if r.server.SecureCore {
badges = append(badges, icons.I.SecureCore)
}
if r.server.Tor {
badges = append(badges, icons.I.Tor)
}
if r.server.P2P {
badges = append(badges, icons.I.P2p)
}
info = strings.Join(badges, " ")
load = fmt.Sprintf("%s %d%%", icons.I.Load, r.server.Load)
}
return
}
+121
View File
@@ -0,0 +1,121 @@
package app
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
zone "github.com/lrstanley/bubblezone/v2"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
)
func (m Model) renderHeader() string {
title := headerStyle.Render("ProtonVPN")
if style.S.NerdFonts {
title = icons.I.Vpn + title
}
lines := []string{
title,
"",
m.renderStatusLine(),
m.renderModesLine(),
}
if searchLine := m.renderSearchLine(); searchLine != "" {
lines = append(lines, searchLine)
}
lines = append(lines, "", m.renderColumnHeader())
for i, l := range lines {
lines[i] = clampToWidth(l, m.width)
}
return strings.Join(lines, "\n")
}
func (m Model) renderColumnHeader() string {
indent, expandIcon := "", " "
prefixWidth := lipgloss.Width(indent) + lipgloss.Width(expandIcon) + 1
infoW, gaps := infoColumnLayout(m.showServersColumn())
nameWidth := max(m.width-boxOverhead-prefixWidth-infoW-loadColWidth-gaps, minNameWidth)
hdrStyle := lipgloss.NewStyle().Foreground(style.S.Subtle).Bold(true)
line := " " + indent + expandIcon + " " + hdrStyle.Render(fixedWidth("Name", nameWidth))
if m.showServersColumn() {
line += " " + hdrStyle.Render(fixedWidth("Servers", infoColWidth))
}
return line + " " + hdrStyle.Render(fixedWidth("Load", loadColWidth))
}
func oneLine(s string) string {
return strings.Join(strings.Fields(s), " ")
}
func clampToWidth(s string, width int) string {
if width <= 0 {
return s
}
return ansi.Truncate(s, width, "…")
}
func (m Model) renderStatusLine() string {
label := infoLabelStyle.Render("Status")
if m.connecting {
return label + primaryStyle.Render(icons.I.Connecting+" connecting…")
}
if m.disconnecting {
return label + mutedStyle.Render(icons.I.Loading+" loading…")
}
if m.statusErr != nil {
return label + warnStyle.Render(oneLine(m.statusErr.Error()))
}
if !m.statusLoaded {
if m.quickConnected {
return label + mutedStyle.Render(icons.I.Connected+" connected, "+icons.I.Loading+" loading…")
}
return label + mutedStyle.Render(icons.I.Loading+" loading…")
}
if !m.status.Connected {
return label + mutedStyle.Render(icons.I.Disconnected+" disconnected")
}
loc := m.status.Server
if m.status.Location != "" {
loc = fmt.Sprintf("%s (%s)", m.status.Server, m.status.Location)
}
text := fmt.Sprintf("%s connected: %s", icons.I.Connected, loc)
if m.status.Load > 0 {
text += fmt.Sprintf(" %d%% load", m.status.Load)
}
return label + connectedStyle.Render(text)
}
func (m Model) renderSearchLine() string {
if !m.searching && strings.TrimSpace(m.searchInput.Value()) == "" {
return ""
}
return infoLabelStyle.Render("Search") + m.searchInput.View()
}
func (m Model) renderModesLine() string {
label := infoLabelStyle.Render("Mode")
parts := []string{
zone.Mark(zoneBadgeFastest, modeBadge(icons.I.Fastest+" fastest", m.prefer == "fastest")),
zone.Mark(zoneBadgeRandom, modeBadge(icons.I.Random+" random", m.prefer == "random")),
zone.Mark(zoneBadgeSecureCore, modeBadge(icons.I.SecureCore+" secure core", m.filters.SecureCore)),
zone.Mark(zoneBadgeTor, modeBadge(icons.I.Tor+" tor", m.filters.Tor)),
zone.Mark(zoneBadgeP2P, modeBadge(icons.I.P2p+" p2p", m.filters.P2P)),
zone.Mark(zoneBadgeFree, modeBadge(icons.I.Free+" free", m.filters.Free)),
}
return label + strings.Join(parts, mutedStyle.Render(" "))
}
func modeBadge(text string, active bool) string {
if active {
return primaryStyle.Bold(true).Render(text)
}
return mutedStyle.Render(text)
}
+495
View File
@@ -0,0 +1,495 @@
package app
import (
"fmt"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/app"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/helpbar"
"github.com/anotherhadi/ilovetui/modal"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
"github.com/anotherhadi/proton-vpn-tui/internal/ui/settings"
)
type filters struct {
Tor bool
SecureCore bool
P2P bool
Free bool
}
type Model struct {
list list.Model
help helpbar.Model
width, height int
servers []backend.LogicalServer
tree []*countryGroup
serversLoaded bool
expandedCountries map[string]bool
expandedCities map[string]bool
filters filters
prefer string
status backend.ConnectionStatus
statusErr error
statusLoaded bool
quickConnected bool
connecting bool
disconnecting bool
searching bool
searchInput textinput.Model
Overlay bool
}
func New() Model {
l := bubbles.NewList(nil, 0, 0)
l.SetDelegate(rowDelegate{})
l.SetShowHelp(false)
l.SetFilteringEnabled(false)
l.SetShowStatusBar(false)
l.SetShowTitle(false)
l.DisableQuitKeybindings()
gk := config.Global.Keybindings.Global
l.KeyMap.CursorUp = gk.Up
l.KeyMap.CursorDown = gk.Down
l.KeyMap.GoToStart = gk.GotoTop
l.KeyMap.GoToEnd = gk.GotoBottom
h := helpbar.New(helpbar.WithToggle(gk.Help))
si := bubbles.NewTextInput()
si.Prompt = icons.I.Search + " "
si.Placeholder = "country, city, or server id…"
focusSearch := config.Global.App.FocusSearchOnLaunch
if focusSearch {
si.Focus()
}
def := config.Global.App.Default
return Model{
list: l,
help: h,
searchInput: si,
searching: focusSearch,
expandedCountries: map[string]bool{},
expandedCities: map[string]bool{},
filters: filters{Tor: def.Tor, SecureCore: def.SecureCore, P2P: def.P2P, Free: def.Free},
prefer: def.Prefer,
}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
fetchServersCache, refreshServers,
fetchQuickStatus, fetchStatus,
tickCmd(),
)
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m, cmd := m.update(msg)
m.resizeList()
return m, cmd
}
func (m Model) update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.help.SetWidth(msg.Width)
m.searchInput.SetWidth(max(msg.Width-14, 10))
return m, nil
case tickMsg:
return m, tea.Batch(fetchStatus, tickCmd())
case serversMsg:
m.servers = msg
m.serversLoaded = true
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case serversRefreshedMsg:
m.servers = msg
m.serversLoaded = true
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, tea.Batch(cmd, notification.Show("ProtonVPN", "Server list refreshed from cache", notification.Success))
case serversErrMsg:
m.serversLoaded = true
if backend.IsAuthRequired(msg.err) {
return m, modal.Show("Not signed in", notSignedInModal{})
}
return m, errorToast(msg.err)
case serversCacheErrMsg:
return m, nil
case quickStatusMsg:
if !m.statusLoaded {
m.quickConnected = bool(msg)
}
return m, nil
case quickStatusErrMsg:
return m, nil
case statusMsg:
m.status = backend.ConnectionStatus(msg)
m.statusErr = nil
m.statusLoaded = true
return m, nil
case statusErrMsg:
m.statusErr = msg.err
m.status = backend.ConnectionStatus{}
m.statusLoaded = true
return m, nil
case connectResultMsg:
m.connecting = false
m.disconnecting = false
if msg.err != nil {
return m, tea.Batch(errorToast(msg.err), fetchStatus)
}
title, text := "ProtonVPN", fmt.Sprintf("Connected to %s", msg.target)
switch {
case msg.disconnect:
text = "Disconnected"
case msg.freeFallback:
title = "Free plan"
text = "Free accounts can't pick a server or location. Connected you to an available free server instead."
}
return m, tea.Batch(fetchStatus, notification.Show(title, text, notification.Success))
case tea.KeyPressMsg:
if m.Overlay {
return m, nil
}
if m.searching {
return m.updateSearch(msg)
}
return m.updateList(msg)
case tea.MouseWheelMsg:
if m.Overlay {
return m, nil
}
switch msg.Button {
case tea.MouseWheelUp:
m.list.CursorUp()
case tea.MouseWheelDown:
m.list.CursorDown()
}
return m, nil
case tea.MouseClickMsg:
if m.Overlay {
return m, nil
}
return m.updateMouseClick(msg)
}
if m.Overlay {
return m, nil
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) updateList(msg tea.KeyPressMsg) (Model, tea.Cmd) {
gk := config.Global.Keybindings.Global
hk := config.Global.Keybindings.Home
switch {
case key.Matches(msg, gk.Quit):
return m, app.Quit()
case key.Matches(msg, hk.OpenSettings):
title := "Settings"
if icons.I.Settings != "" {
title = icons.I.Settings + " " + title
}
return m, drawer.Show(title, settings.New(m.width, m.drawerContentHeight()), drawer.WithSide(drawer.Right))
case key.Matches(msg, hk.Search):
m.searching = true
return m, m.searchInput.Focus()
case key.Matches(msg, gk.Escape):
if strings.TrimSpace(m.searchInput.Value()) == "" {
return m, nil
}
m.searchInput.Reset()
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.Refresh):
return m, refreshServersManual
case key.Matches(msg, hk.Disconnect):
m.disconnecting = true
return m, disconnectCmd()
case key.Matches(msg, hk.ToggleTor):
m.filters.Tor = !m.filters.Tor
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.ToggleSecureCore):
m.filters.SecureCore = !m.filters.SecureCore
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.ToggleP2P):
m.filters.P2P = !m.filters.P2P
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.ToggleFree):
m.filters.Free = !m.filters.Free
m.tree = buildTree(m.servers, m.filters)
cmd := m.refreshList()
return m, cmd
case key.Matches(msg, hk.TogglePrefer):
if m.prefer == "random" {
m.prefer = "fastest"
} else {
m.prefer = "random"
}
return m, nil
case key.Matches(msg, hk.GotoFavorite):
m.gotoFavorite()
return m, nil
case key.Matches(msg, gk.Right):
return m.expandSelected()
case key.Matches(msg, gk.Left):
return m.collapseSelected()
case key.Matches(msg, hk.Connect):
return m.connectSelected()
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m Model) selectedRow() (row, bool) {
if item := m.list.SelectedItem(); item != nil {
return item.(row), true
}
return row{}, false
}
func (m Model) expandSelected() (Model, tea.Cmd) {
r, ok := m.selectedRow()
if !ok {
return m, nil
}
switch r.kind {
case rowCountry:
m.expandedCountries[r.country] = true
case rowCity:
m.expandedCities[cityKey(r.country, r.city)] = true
default:
return m, nil
}
cmd := m.refreshList()
return m, cmd
}
func (m Model) collapseSelected() (Model, tea.Cmd) {
r, ok := m.selectedRow()
if !ok {
return m, nil
}
switch r.kind {
case rowCountry:
m.expandedCountries[r.country] = false
case rowCity, rowServer:
m.expandedCities[cityKey(r.country, r.city)] = false
}
cmd := m.refreshList()
return m, cmd
}
func (m *Model) gotoFavorite() {
for i, item := range m.list.Items() {
if r, ok := item.(row); ok && r.kind == rowCountry && r.favorite {
m.list.Select(i)
return
}
}
}
func (m Model) connectSelected() (Model, tea.Cmd) {
r, ok := m.selectedRow()
if !ok {
return m, nil
}
m.connecting = true
if r.kind == rowServer {
return m, connectServer(r.server)
}
return m, connectGroup(r.country, r.city, m.prefer, m.filters)
}
func (m Model) favoritesSet() (countries, servers map[string]bool) {
countries = map[string]bool{}
servers = map[string]bool{}
for _, raw := range config.Global.App.Favorites {
f := strings.TrimSpace(raw)
if f == "" {
continue
}
if id := strings.ToUpper(f); backend.IsServerID(id) {
servers[id] = true
continue
}
if code, ok := backend.ResolveCountryCode(f); ok {
countries[code] = true
}
}
return
}
func (m *Model) refreshList() tea.Cmd {
q := strings.TrimSpace(m.searchInput.Value())
favCountries, favServers := m.favoritesSet()
var rows []row
if q != "" {
rows = searchRows(m.tree, q, m.expandedCountries, m.expandedCities, favCountries, favServers)
} else {
rows = flattenRows(m.tree, m.expandedCountries, m.expandedCities, favCountries, favServers)
}
return m.setListItems(rows)
}
func (m *Model) setListItems(rows []row) tea.Cmd {
items := make([]list.Item, len(rows))
for i, r := range rows {
items[i] = r
}
cmd := m.list.SetItems(items)
m.resizeList()
if idx := m.list.Index(); len(items) > 0 && (idx < 0 || idx >= len(items)) {
m.list.Select(len(items) - 1)
}
return cmd
}
func (m *Model) resizeList() {
m.list.SetDelegate(rowDelegate{showInfo: m.showServersColumn()})
m.list.SetSize(m.width, m.listHeight())
}
func (m Model) showServersColumn() bool {
return m.width > serversColumnMinWidth
}
func (m Model) listHeight() int {
headerH := lipgloss.Height(m.renderHeader())
helpH := m.help.Height(m.helpBindings()...)
return m.height - headerH - helpH
}
func (m Model) View() string {
header := m.renderHeader()
listView := strings.TrimRight(m.list.View(), "\n")
if !m.serversLoaded && len(m.list.Items()) == 0 {
listView = m.loadingView()
}
return strings.Join([]string{header, listView}, "\n")
}
func (m Model) loadingView() string {
text := mutedStyle.Render(icons.I.Loading + " loading…")
return lipgloss.Place(m.width, max(m.listHeight(), 1), lipgloss.Center, lipgloss.Center, text)
}
func (m Model) WindowTitle() string {
const base = "ProtonVPN TUI"
switch {
case m.statusLoaded && m.status.Connected:
return base + " - Connected"
case m.statusLoaded:
return base + " - Disconnected"
case m.quickConnected:
return base + " - Connected"
default:
return base
}
}
func (m Model) HelpView() string {
return strings.TrimRight(m.help.View(m.helpBindings()...), "\n")
}
func (m *Model) ToggleHelp() {
m.help.ShowAll = !m.help.ShowAll
m.resizeList()
}
func (m Model) settingsHelpHeight() int {
pages := config.Help(config.Global.Keybindings.Global, config.Global.Keybindings.Settings)
return m.help.Height(m.bindingsFor(pages)...)
}
func (m Model) drawerContentHeight() int {
return m.height - m.settingsHelpHeight()
}
func (m Model) DrawerWindowSizeMsg() tea.WindowSizeMsg {
return tea.WindowSizeMsg{Width: m.width, Height: m.drawerContentHeight()}
}
func (m Model) bindingsFor(pages config.HelpKeyMap) []key.Binding {
if !m.help.ShowAll {
return pages.ShortHelp()
}
var all []key.Binding
for _, col := range pages.FullHelp() {
all = append(all, col...)
}
return all
}
func (m Model) helpBindings() []key.Binding {
pages := config.Help(config.Global.Keybindings.Global, config.Global.Keybindings.Home)
if m.Overlay {
pages = config.Help(config.Global.Keybindings.Global, config.Global.Keybindings.Settings)
}
return m.bindingsFor(pages)
}
+88
View File
@@ -0,0 +1,88 @@
package app
import (
"fmt"
tea "charm.land/bubbletea/v2"
zone "github.com/lrstanley/bubblezone/v2"
)
const (
zoneBadgeFastest = "badge-fastest"
zoneBadgeRandom = "badge-random"
zoneBadgeSecureCore = "badge-securecore"
zoneBadgeTor = "badge-tor"
zoneBadgeP2P = "badge-p2p"
zoneBadgeFree = "badge-free"
)
func rowZoneID(index int) string {
return fmt.Sprintf("row-%d", index)
}
func (m Model) updateMouseClick(msg tea.MouseClickMsg) (Model, tea.Cmd) {
if msg.Button != tea.MouseLeft {
return m, nil
}
switch {
case zone.Get(zoneBadgeFastest).InBounds(msg):
m.prefer = "fastest"
return m, nil
case zone.Get(zoneBadgeRandom).InBounds(msg):
m.prefer = "random"
return m, nil
case zone.Get(zoneBadgeSecureCore).InBounds(msg):
m.filters.SecureCore = !m.filters.SecureCore
m.tree = buildTree(m.servers, m.filters)
return m, m.refreshList()
case zone.Get(zoneBadgeTor).InBounds(msg):
m.filters.Tor = !m.filters.Tor
m.tree = buildTree(m.servers, m.filters)
return m, m.refreshList()
case zone.Get(zoneBadgeP2P).InBounds(msg):
m.filters.P2P = !m.filters.P2P
m.tree = buildTree(m.servers, m.filters)
return m, m.refreshList()
case zone.Get(zoneBadgeFree).InBounds(msg):
m.filters.Free = !m.filters.Free
m.tree = buildTree(m.servers, m.filters)
return m, m.refreshList()
}
return m.clickRow(msg)
}
func (m Model) clickRow(msg tea.MouseClickMsg) (Model, tea.Cmd) {
items := m.list.Items()
for i := range items {
if !zone.Get(rowZoneID(i)).InBounds(msg) {
continue
}
if i != m.list.Index() {
m.list.Select(i)
return m, nil
}
return m.activateSelected()
}
return m, nil
}
func (m Model) activateSelected() (Model, tea.Cmd) {
r, ok := m.selectedRow()
if !ok {
return m, nil
}
if r.kind == rowServer {
return m.connectSelected()
}
if r.expanded {
return m.collapseSelected()
}
return m.expandSelected()
}
+22
View File
@@ -0,0 +1,22 @@
package app
import (
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/app"
)
type notSignedInModal struct{}
func (notSignedInModal) Init() tea.Cmd { return nil }
func (notSignedInModal) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(tea.KeyPressMsg); ok {
return notSignedInModal{}, app.Quit()
}
return notSignedInModal{}, nil
}
func (notSignedInModal) View() tea.View {
return tea.NewView("You're not signed in.\n\nRun `protonvpn signin` to sign in,\nthen restart proton-vpn-tui.\n\nPress any key to quit.")
}
+318
View File
@@ -0,0 +1,318 @@
package app
import (
"sort"
"strings"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
)
type rowKind int
const (
rowCountry rowKind = iota
rowCity
rowServer
)
type row struct {
kind rowKind
country string
city string
server backend.LogicalServer
expanded bool
favorite bool
serverCount int
avgLoad int
}
func (r row) FilterValue() string {
switch r.kind {
case rowServer:
return r.server.Name + " " + r.city + " " + backend.CountryName(r.country)
case rowCity:
return r.city + " " + backend.CountryName(r.country)
default:
return r.country + " " + backend.CountryName(r.country)
}
}
func cityKey(country, city string) string { return country + "\x00" + city }
type countryGroup struct {
code string
cities map[string][]backend.LogicalServer
cityOrder []string
servers []backend.LogicalServer
}
func buildTree(servers []backend.LogicalServer, f filters) []*countryGroup {
groups := make(map[string]*countryGroup)
var order []string
for _, s := range servers {
if s.Tor != f.Tor || s.SecureCore != f.SecureCore {
continue
}
if f.P2P && !s.P2P {
continue
}
if f.Free && !s.IsFree() {
continue
}
code := s.EntryCountry
if s.SecureCore {
code = s.ExitCountry
}
g, ok := groups[code]
if !ok {
g = &countryGroup{code: code, cities: make(map[string][]backend.LogicalServer)}
groups[code] = g
order = append(order, code)
}
city := ""
if s.City != nil {
city = *s.City
}
if _, ok := g.cities[city]; !ok {
g.cityOrder = append(g.cityOrder, city)
}
g.cities[city] = append(g.cities[city], s)
g.servers = append(g.servers, s)
}
result := make([]*countryGroup, 0, len(order))
for _, code := range order {
result = append(result, groups[code])
}
sort.Slice(result, func(i, j int) bool {
return backend.CountryName(result[i].code) < backend.CountryName(result[j].code)
})
for _, g := range result {
sort.Slice(g.cityOrder, func(i, j int) bool {
a, b := g.cityOrder[i], g.cityOrder[j]
if a == "" {
return false
}
if b == "" {
return true
}
return a < b
})
for _, servers := range g.cities {
sort.Slice(servers, func(i, j int) bool { return servers[i].Name < servers[j].Name })
}
}
return result
}
func avgLoad(servers []backend.LogicalServer) int {
if len(servers) == 0 {
return 0
}
total := 0
for _, s := range servers {
total += s.Load
}
return total / len(servers)
}
func serverIsFavorite(s backend.LogicalServer, favServers map[string]bool) bool {
return favServers[strings.ToUpper(s.Name)]
}
func serversHaveFavorite(servers []backend.LogicalServer, favServers map[string]bool) bool {
for _, s := range servers {
if serverIsFavorite(s, favServers) {
return true
}
}
return false
}
func countryIsFavorite(g *countryGroup, favCountries, favServers map[string]bool) bool {
return favCountries[g.code] || serversHaveFavorite(g.servers, favServers)
}
func orderWithFavoritesFirst(tree []*countryGroup, favCountries, favServers map[string]bool) []*countryGroup {
ordered := append([]*countryGroup(nil), tree...)
sort.SliceStable(ordered, func(i, j int) bool {
return countryIsFavorite(ordered[i], favCountries, favServers) &&
!countryIsFavorite(ordered[j], favCountries, favServers)
})
return ordered
}
func orderCitiesWithFavoritesFirst(g *countryGroup, favServers map[string]bool) []string {
cities := append([]string(nil), g.cityOrder...)
sort.SliceStable(cities, func(i, j int) bool {
return serversHaveFavorite(g.cities[cities[i]], favServers) &&
!serversHaveFavorite(g.cities[cities[j]], favServers)
})
return cities
}
func orderServersWithFavoritesFirst(servers []backend.LogicalServer, favServers map[string]bool) []backend.LogicalServer {
ordered := append([]backend.LogicalServer(nil), servers...)
sort.SliceStable(ordered, func(i, j int) bool {
return serverIsFavorite(ordered[i], favServers) && !serverIsFavorite(ordered[j], favServers)
})
return ordered
}
func flattenRows(tree []*countryGroup, expandedCountries, expandedCities, favCountries, favServers map[string]bool) []row {
ordered := orderWithFavoritesFirst(tree, favCountries, favServers)
var rows []row
for _, g := range ordered {
rows = append(rows, row{
kind: rowCountry,
country: g.code,
expanded: expandedCountries[g.code],
favorite: countryIsFavorite(g, favCountries, favServers),
serverCount: len(g.servers),
avgLoad: avgLoad(g.servers),
})
if !expandedCountries[g.code] {
continue
}
for _, city := range orderCitiesWithFavoritesFirst(g, favServers) {
servers := orderServersWithFavoritesFirst(g.cities[city], favServers)
key := cityKey(g.code, city)
rows = append(rows, row{
kind: rowCity,
country: g.code,
city: city,
expanded: expandedCities[key],
favorite: serversHaveFavorite(servers, favServers),
serverCount: len(servers),
avgLoad: avgLoad(servers),
})
if !expandedCities[key] {
continue
}
for _, s := range servers {
rows = append(rows, row{
kind: rowServer,
country: g.code,
city: city,
server: s,
favorite: serverIsFavorite(s, favServers),
})
}
}
}
return rows
}
type cityMatch struct {
city string
servers []backend.LogicalServer
}
func searchRows(tree []*countryGroup, query string, expandedCountries, expandedCities, favCountries, favServers map[string]bool) []row {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return nil
}
ordered := orderWithFavoritesFirst(tree, favCountries, favServers)
matchCities := len([]rune(q)) > 3
var rows []row
for _, g := range ordered {
countryMatch := strings.Contains(strings.ToLower(backend.CountryName(g.code)), q) ||
strings.Contains(strings.ToLower(g.code), q)
var matches []cityMatch
if matchCities {
for _, city := range g.cityOrder {
if servers := matchingCityServers(city, g.cities[city], q); len(servers) > 0 {
matches = append(matches, cityMatch{city: city, servers: servers})
}
}
}
if !countryMatch && len(matches) == 0 {
continue
}
expanded := len(matches) > 0
if v, ok := expandedCountries[g.code]; ok {
expanded = v
}
rows = append(rows, row{
kind: rowCountry,
country: g.code,
expanded: expanded,
favorite: countryIsFavorite(g, favCountries, favServers),
serverCount: len(g.servers),
avgLoad: avgLoad(g.servers),
})
if !expanded {
continue
}
if len(matches) == 0 {
for _, city := range g.cityOrder {
matches = append(matches, cityMatch{city: city, servers: g.cities[city]})
}
}
sort.SliceStable(matches, func(i, j int) bool {
return serversHaveFavorite(matches[i].servers, favServers) &&
!serversHaveFavorite(matches[j].servers, favServers)
})
for _, cm := range matches {
cityExpanded := true
if v, ok := expandedCities[cityKey(g.code, cm.city)]; ok {
cityExpanded = v
}
servers := orderServersWithFavoritesFirst(cm.servers, favServers)
rows = append(rows, row{
kind: rowCity,
country: g.code,
city: cm.city,
expanded: cityExpanded,
favorite: serversHaveFavorite(servers, favServers),
serverCount: len(servers),
avgLoad: avgLoad(servers),
})
if !cityExpanded {
continue
}
for _, s := range servers {
rows = append(rows, row{
kind: rowServer,
country: g.code,
city: cm.city,
server: s,
favorite: serverIsFavorite(s, favServers),
})
}
}
}
return rows
}
func matchingCityServers(city string, servers []backend.LogicalServer, q string) []backend.LogicalServer {
label := city
if label == "" {
label = "other"
}
if strings.Contains(strings.ToLower(label), q) {
return servers
}
var matched []backend.LogicalServer
for _, s := range servers {
if strings.Contains(strings.ToLower(s.Name), q) {
matched = append(matched, s)
}
}
return matched
}
+66
View File
@@ -0,0 +1,66 @@
package app
import (
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
)
var searchEnterKey = key.NewBinding(key.WithKeys("enter"))
func (m Model) updateSearch(msg tea.KeyPressMsg) (Model, tea.Cmd) {
gk := config.Global.Keybindings.Global
switch {
case key.Matches(msg, gk.Escape):
return m.exitSearch(true)
case key.Matches(msg, searchEnterKey):
return m.submitSearch()
}
prev := m.searchInput.Value()
var cmd tea.Cmd
m.searchInput, cmd = m.searchInput.Update(msg)
if m.searchInput.Value() != prev {
m.expandedCountries = map[string]bool{}
m.expandedCities = map[string]bool{}
}
return m, tea.Batch(cmd, m.refreshList())
}
func (m Model) submitSearch() (Model, tea.Cmd) {
q := strings.TrimSpace(m.searchInput.Value())
if q == "" {
return m.exitSearch(true)
}
m.searching = false
m.searchInput.Blur()
if id := strings.ToUpper(q); backend.IsServerID(id) {
m.selectServerByID(id)
}
return m, nil
}
func (m *Model) selectServerByID(id string) {
for i, it := range m.list.Items() {
if r, ok := it.(row); ok && r.kind == rowServer && strings.EqualFold(r.server.Name, id) {
m.list.Select(i)
return
}
}
}
func (m Model) exitSearch(clear bool) (Model, tea.Cmd) {
m.searching = false
m.searchInput.Blur()
if clear {
m.searchInput.Reset()
}
cmd := m.refreshList()
return m, cmd
}
+149
View File
@@ -0,0 +1,149 @@
package settings
import (
"fmt"
"image/color"
"io"
"strings"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/style"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/icons"
)
func clampToWidth(s string, width int) string {
if width <= 0 {
return s
}
return ansi.Truncate(s, width, "…")
}
type delegate struct{}
func (d delegate) Height() int { return 2 }
func (d delegate) Spacing() int { return 1 }
func (d delegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
func (d delegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
it, ok := listItem.(item)
if !ok {
return
}
selected := index == m.Index()
var titleStyle, descStyle, valueStyle lipgloss.Style
if selected {
titleStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(style.S.Primary).
Foreground(style.S.Primary).
Bold(true).
PaddingLeft(1)
descStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(style.S.Primary).
Foreground(style.S.Muted).
PaddingLeft(1)
valueStyle = lipgloss.NewStyle().Foreground(style.S.Success).Bold(true)
} else {
titleStyle = lipgloss.NewStyle().Foreground(style.S.Text).PaddingLeft(2)
descStyle = lipgloss.NewStyle().Foreground(style.S.Muted).PaddingLeft(2)
valueStyle = lipgloss.NewStyle().Foreground(style.S.Success)
}
value := renderCycleValue(valueStyle.Render(humanizeKebab(it.setting.Value)))
if isOnOffSetting(it.setting) {
value = renderToggle(it.setting.Value == "on")
}
width := m.Width()
title := titleStyle.Render(humanizeKebab(it.key))
line1 := padLine(justifyBetween(title, value, width), width)
line2 := padLine(descStyle.Render(it.setting.Description), width)
fmt.Fprintf(w, "%s\n%s", line1, line2)
}
func justifyBetween(left, right string, width int) string {
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return left + strings.Repeat(" ", gap) + right
}
func padLine(s string, width int) string {
s = clampToWidth(s, width)
if pad := width - lipgloss.Width(s); pad > 0 {
s += strings.Repeat(" ", pad)
}
return s
}
func isOnOffSetting(s backend.Setting) bool {
if len(s.Options) != 2 {
return false
}
var hasOn, hasOff bool
for _, o := range s.Options {
hasOn = hasOn || o.ID == "on"
hasOff = hasOff || o.ID == "off"
}
return hasOn && hasOff
}
func renderCycleValue(value string) string {
arrow := lipgloss.NewStyle().Foreground(style.S.Muted)
return arrow.Render("< ") + value + arrow.Render(" >")
}
func renderToggle(on bool) string {
if on {
return renderBadge(" ●", style.S.Success)
}
return renderBadge("● ", style.S.Subtle)
}
func renderBadge(label string, fill color.Color) string {
body := lipgloss.NewStyle().
Background(fill).
Foreground(style.S.Background).
Bold(true).
Render(label)
if icons.I.PillLeft == "" && icons.I.PillRight == "" {
return body
}
cap := lipgloss.NewStyle().Foreground(fill).Background(style.S.Background)
return cap.Render(icons.I.PillLeft) + body + cap.Render(icons.I.PillRight)
}
var settingWordOverrides = map[string]string{
"nat": "NAT",
"dns": "DNS",
"vpn": "VPN",
"ip": "IP",
"ipv6": "IPv6",
"netshield": "NetShield",
}
func humanizeKebab(s string) string {
words := strings.Split(s, "-")
for i, w := range words {
if override, ok := settingWordOverrides[strings.ToLower(w)]; ok {
words[i] = override
continue
}
if w == "" {
continue
}
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
return strings.Join(words, " ")
}
+186
View File
@@ -0,0 +1,186 @@
package settings
import (
"sort"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/ilovetui/bubbles"
"github.com/anotherhadi/ilovetui/drawer"
"github.com/anotherhadi/ilovetui/notification"
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
"github.com/anotherhadi/proton-vpn-tui/internal/config"
)
type item struct {
key string
setting backend.Setting
}
func (i item) FilterValue() string { return i.key }
type (
loadedMsg backend.Config
errMsg struct{ err error }
)
type Model struct {
list list.Model
cfg backend.Config
width int
height int
}
func New(width, height int) tea.Model {
l := bubbles.NewList(nil, 0, 0)
l.SetDelegate(delegate{})
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetShowTitle(false)
l.DisableQuitKeybindings()
l.SetFilteringEnabled(false)
gk := config.Global.Keybindings.Global
l.KeyMap.CursorUp = gk.Up
l.KeyMap.CursorDown = gk.Down
l.KeyMap.GoToStart = gk.GotoTop
l.KeyMap.GoToEnd = gk.GotoBottom
m := &Model{list: l}
m.applySize(width, height)
return m
}
const maxDrawerWidth = 80
func (m *Model) applySize(width, height int) {
m.width, m.height = width, height
boxWidth := min(int(float64(width)*0.8), maxDrawerWidth)
inner := boxWidth - 4
if floor := min(24, max(width-4, 1)); inner < floor {
inner = floor
}
listH := height - 3
if listH < 3 {
listH = 3
}
m.list.SetSize(inner, listH)
}
func (m *Model) rebuildItems() tea.Cmd {
keys := make([]string, 0, len(m.cfg))
for k := range m.cfg {
keys = append(keys, k)
}
sort.Strings(keys)
items := make([]list.Item, len(keys))
for i, k := range keys {
items[i] = item{key: k, setting: m.cfg[k]}
}
return m.list.SetItems(items)
}
func manualSetupNotice(it item) (tea.Cmd, bool) {
if it.key != "custom-dns" || it.setting.Value != "off" {
return nil, false
}
return notification.Show(
"Settings",
"Custom DNS needs a server address, set it via the CLI: "+
"protonvpn config set custom-dns on --dns <ip[,ip...]>",
notification.Info,
), true
}
func (m Model) selectedSetting() (item, bool) {
if it := m.list.SelectedItem(); it != nil {
return it.(item), true
}
return item{}, false
}
func (m *Model) Init() tea.Cmd {
return tea.Batch(loadCache, loadFresh)
}
func loadCache() tea.Msg {
cfg, err := backend.LoadConfigCache()
if err != nil {
return errMsg{err: err}
}
return loadedMsg(cfg)
}
func loadFresh() tea.Msg {
cfg, err := backend.LoadConfig()
if err != nil {
return errMsg{err: err}
}
return loadedMsg(cfg)
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.applySize(msg.Width, msg.Height)
return m, nil
case loadedMsg:
m.cfg = backend.Config(msg)
cmd := m.rebuildItems()
return m, cmd
case errMsg:
return m, notification.Show("Settings", msg.err.Error(), notification.Error)
case tea.KeyPressMsg:
gk := config.Global.Keybindings.Global
sk := config.Global.Keybindings.Settings
switch {
case key.Matches(msg, gk.Escape), key.Matches(msg, gk.Quit):
return m, drawer.Close()
case key.Matches(msg, sk.NextOption), key.Matches(msg, sk.ToggleSetting):
if it, ok := m.selectedSetting(); ok {
if cmd, handled := manualSetupNotice(it); handled {
return m, cmd
}
if err := m.cfg.CycleSettingUp(it.key); err != nil {
return m, notification.Show("Settings", err.Error(), notification.Error)
}
cmd := m.rebuildItems()
return m, cmd
}
return m, nil
case key.Matches(msg, sk.PreviousOption):
if it, ok := m.selectedSetting(); ok {
if cmd, handled := manualSetupNotice(it); handled {
return m, cmd
}
if err := m.cfg.CycleSettingDown(it.key); err != nil {
return m, notification.Show("Settings", err.Error(), notification.Error)
}
cmd := m.rebuildItems()
return m, cmd
}
return m, nil
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func (m *Model) View() tea.View {
listView := strings.TrimRight(m.list.View(), "\n")
return tea.NewView(strings.Join([]string{"", listView}, "\n"))
}
+24
View File
@@ -0,0 +1,24 @@
package app
import (
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
var (
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(style.S.Primary).
PaddingLeft(1)
mutedStyle = lipgloss.NewStyle().Foreground(style.S.Muted)
warnStyle = lipgloss.NewStyle().Foreground(style.S.Warning)
connectedStyle = lipgloss.NewStyle().Foreground(style.S.Success)
primaryStyle = lipgloss.NewStyle().Foreground(style.S.Primary)
infoLabelStyle = lipgloss.NewStyle().
Foreground(style.S.Muted).
PaddingLeft(1).
Width(10)
)