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) }