commit e9c2a59ee53b277ad570a270237f1d95bfbe0faa
Author: Hadi <112569860+anotherhadi@users.noreply.github.com>
Date: Thu Aug 27 23:14:56 2026 +0200
init
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.github/.goreleaser.yaml b/.github/.goreleaser.yaml
new file mode 100644
index 0000000..1209073
--- /dev/null
+++ b/.github/.goreleaser.yaml
@@ -0,0 +1,34 @@
+version: 2
+
+before:
+ hooks:
+ - go mod tidy
+
+builds:
+ - binary: proton-vpn-tui
+ main: ./cmd/proton-vpn-tui
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ goarch:
+ - amd64
+ - arm64
+ ldflags:
+ - -s -w -X main.version={{.Version}}
+
+archives:
+ - formats:
+ - tar.gz
+ name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
+
+checksum:
+ name_template: checksums.txt
+
+changelog:
+ sort: asc
+ filters:
+ exclude:
+ - "^docs:"
+ - "^test:"
+ - "^ci:"
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
new file mode 100644
index 0000000..7b8ae86
--- /dev/null
+++ b/.github/CONTRIBUTING.md
@@ -0,0 +1,10 @@
+# Contributing
+
+Everybody is invited and welcome to contribute. There is a lot to do... Check the issues!
+
+The process is straight-forward.
+
+- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1)
+- Fork this git repository
+- Write your changes (bug fixes, new features, ...).
+- Create a Pull Request against the main branch.
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..b1c5749
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+ko_fi: anotherhadi
diff --git a/.github/assets/demo.gif b/.github/assets/demo.gif
new file mode 100644
index 0000000..2922448
Binary files /dev/null and b/.github/assets/demo.gif differ
diff --git a/.github/assets/demo.tape b/.github/assets/demo.tape
new file mode 100644
index 0000000..241ce00
--- /dev/null
+++ b/.github/assets/demo.tape
@@ -0,0 +1,29 @@
+Output ./.github/assets/demo.gif
+Require proton-vpn-tui
+
+Set Shell "zsh"
+Set FontSize 32
+Set Width 1600
+Set Height 1900
+
+Type "proton-vpn-tui"
+Sleep 800ms
+Enter
+Sleep 3s
+
+Down@800ms 3
+Sleep 2s
+Ctrl+F
+Type "france"
+Sleep 1s
+Enter
+Sleep 2s
+Right
+Sleep 1s
+Down
+Sleep 1s
+Left
+Sleep 1s
+Down@900ms 2
+Enter
+Sleep 5s
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..bf265c5
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,28 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+
+ - uses: goreleaser/goreleaser-action@v6
+ with:
+ version: "~> v2"
+ args: release --clean --config .github/.goreleaser.yaml
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..87e2f4b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.claude/
+CLAUDE.md
+result/
+.direnv/
+.pre-commit-config.yaml
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7ce7478
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Hadi
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..df5bd27
--- /dev/null
+++ b/README.md
@@ -0,0 +1,78 @@
+```
+ ____ _ __ ______ _ _ _____ _ _ ___
+| _ \ _ __ ___ | |_ ___ _ _\ \ / / _ \| \ | | |_ _| | | |_ _|
+| |_) | '__/ _ \| __/ _ \| '_ \ \ / /| |_) | \| | | | | | | || |
+| __/| | | (_) | || (_) | | | \ V / | __/| |\ | | | | |_| || |
+|_| |_| \___/ \__\___/|_| |_|\_/ |_| |_| \_| |_| \___/|___|
+```
+
+# ProtonVPN TUI
+
+> A minimal, TUI and keyboard friendly wrapper for proton-vpn-cli.
+
+
+
+
+[](https://ko-fi.com/anotherhadi)
+
+
+
+
+- [Features](#features)
+- [Installation](#installation)
+- [Configuration](#configuration)
+
+
+
+
+
+> **⚠️ DISCLAIMER**
+> This project is **NOT affiliated with**, endorsed by, or connected to Proton AG or ProtonVPN in any way. It is an unofficial, community-driven tool.
+
+## Features
+
+- **Server selection**: Connect to any ProtonVPN server
+- **Filters**: Filter servers by Secure Core, P2P, Tor, and more
+- **Free account compatible**: Works with free ProtonVPN accounts
+- **Mouse support**: Navigate and interact using your mouse
+- **Settings editor**: Edit settings such as Kill Switch, NetShield, and more
+
+## Installation
+
+
+Go install
+
+```sh
+go install github.com/anotherhadi/proton-vpn-tui/cmd/proton-vpn-tui@latest
+```
+
+Requires Go 1.22+. The binary will be placed in `$GOPATH/bin` (or `~/go/bin`).
+
+
+
+
+NUR (Nix/NixOS)
+
+Available via [NUR](https://github.com/nix-community/NUR), under the `anotherhadi` repo:
+
+```nix
+# configuration.nix / home.nix
+environment.systemPackages = [ nur.repos.anotherhadi.proton-vpn-tui ];
+```
+
+
+
+## Configuration
+
+Proton-VPN-TUI is fully configured via a YAML file at `~/.config/proton-vpn-tui/config.yaml`.
+Check the default configuration with all the options [here](./internal/config/default_config.yaml)
+
+Colors and styles can be customized using [ilovetui](https://github.com/anotherhadi/ilovetui), which applies theme changes across all compatible TUI applications at once.
+
+---
+
+
diff --git a/cmd/proton-vpn-tui/app.go b/cmd/proton-vpn-tui/app.go
new file mode 100644
index 0000000..93a4ffa
--- /dev/null
+++ b/cmd/proton-vpn-tui/app.go
@@ -0,0 +1,98 @@
+package main
+
+import (
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+
+ ilovetuiapp "github.com/anotherhadi/ilovetui/app"
+ "github.com/anotherhadi/ilovetui/drawer"
+ "github.com/anotherhadi/ilovetui/minsize"
+ "github.com/anotherhadi/ilovetui/modal"
+ "github.com/anotherhadi/ilovetui/notification"
+ zone "github.com/lrstanley/bubblezone/v2"
+
+ "github.com/anotherhadi/proton-vpn-tui/internal/config"
+ "github.com/anotherhadi/proton-vpn-tui/internal/ui"
+)
+
+const (
+ minWidth = 30
+ minHeight = 16
+)
+
+type appModel struct {
+ core app.Model
+ drawers drawer.Model
+ modals modal.Model
+ notif notification.Model
+ minSize minsize.Model
+
+ width, height int
+}
+
+func newApp() appModel {
+ return appModel{
+ core: app.New(),
+ drawers: drawer.New(drawer.WithMaxWidth(100000)),
+ modals: modal.New(),
+ notif: notification.New(),
+ minSize: minsize.New(minWidth, minHeight),
+ }
+}
+
+func (a appModel) Init() tea.Cmd {
+ return tea.Batch(a.core.Init(), a.drawers.Init(), a.modals.Init(), a.notif.Init())
+}
+
+func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ if _, ok := msg.(ilovetuiapp.QuitMsg); ok {
+ return a, tea.Quit
+ }
+
+ if sizeMsg, ok := msg.(tea.WindowSizeMsg); ok {
+ a.width, a.height = sizeMsg.Width, sizeMsg.Height
+ }
+
+ var drawerCmd, modalCmd tea.Cmd
+ a.drawers, drawerCmd = a.drawers.Update(msg)
+ a.modals, modalCmd = a.modals.Update(msg)
+ a.core.Overlay = a.drawers.Open() || a.modals.Open()
+
+ helpToggled := false
+ if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, config.Global.Keybindings.Global.Help) {
+ a.core.ToggleHelp()
+ helpToggled = true
+ }
+
+ var coreCmd, notifCmd tea.Cmd
+ a.core, coreCmd = a.core.Update(msg)
+ a.notif, notifCmd = a.notif.Update(msg)
+
+ if _, isResize := msg.(tea.WindowSizeMsg); a.drawers.Open() && (isResize || helpToggled) {
+ var cmd tea.Cmd
+ a.drawers, cmd = a.drawers.Update(a.core.DrawerWindowSizeMsg())
+ drawerCmd = tea.Batch(drawerCmd, cmd)
+ }
+
+ return a, tea.Batch(coreCmd, drawerCmd, modalCmd, notifCmd)
+}
+
+func (a appModel) View() tea.View {
+ bg := a.core.View()
+ bg = zone.Scan(bg)
+ bg = a.drawers.Render(bg)
+ bg = a.modals.Render(bg)
+ bg = a.notif.Render(bg)
+ bg = strings.Join([]string{bg, a.core.HelpView()}, "\n")
+ if !a.minSize.Fits(a.width, a.height) {
+ bg = a.minSize.View(a.width, a.height)
+ }
+ return tea.View{
+ Content: bg,
+ AltScreen: true,
+ WindowTitle: a.core.WindowTitle(),
+ MouseMode: tea.MouseModeCellMotion,
+ }
+}
diff --git a/cmd/proton-vpn-tui/main.go b/cmd/proton-vpn-tui/main.go
new file mode 100644
index 0000000..de94d12
--- /dev/null
+++ b/cmd/proton-vpn-tui/main.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "runtime/debug"
+
+ tea "charm.land/bubbletea/v2"
+ 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"
+ "github.com/spf13/cobra"
+)
+
+var version = "dev"
+
+func main() {
+ var (
+ flagConfig string
+ flagAddDefaultConfig bool
+ )
+
+ rootCmd := &cobra.Command{
+ Use: "proton-vpn-tui",
+ Short: "A minimal, TUI and keyboard friendly wrapper for proton-vpn-cli.",
+ Version: version,
+ SilenceUsage: true,
+ SilenceErrors: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if flagAddDefaultConfig {
+ home, _ := os.UserHomeDir()
+ cfgPath := filepath.Join(home, ".config", "proton-vpn-tui", "config.yaml")
+ if flagConfig != "" {
+ cfgPath = flagConfig
+ }
+ if err := config.WriteDefaultConfig(cfgPath); err != nil {
+ return fmt.Errorf("add-default-config: %w", err)
+ }
+ fmt.Printf("default config written to %s\n", cfgPath)
+ return nil
+ }
+
+ home, _ := os.UserHomeDir()
+ cfgPath := filepath.Join(home, ".config", "proton-vpn-tui", "config.yaml")
+ if flagConfig != "" {
+ cfgPath = flagConfig
+ }
+
+ if err := config.Load(cfgPath); err != nil {
+ return fmt.Errorf("config: %w", err)
+ }
+ config.Global.Version = version
+
+ style.Init()
+ icons.Init()
+ zone.NewGlobal()
+
+ if err := backend.Available(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ p := tea.NewProgram(newApp())
+ if _, err := p.Run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+
+ return nil
+ },
+ }
+
+ rootCmd.Flags().StringVarP(&flagConfig, "config", "c", "", "path to config file")
+ rootCmd.Flags().BoolVar(&flagAddDefaultConfig, "add-default-config", false, "copy the default config file to the config path and exit")
+
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func init() {
+ if version != "dev" {
+ return
+ }
+ if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
+ version = info.Main.Version
+ }
+}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..305e988
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,65 @@
+{
+ "nodes": {
+ "flake-compat": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1767039857,
+ "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
+ "owner": "NixOS",
+ "repo": "flake-compat",
+ "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "repo": "flake-compat",
+ "type": "github"
+ }
+ },
+ "git-hooks": {
+ "inputs": {
+ "flake-compat": "flake-compat",
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1787424939,
+ "narHash": "sha256-O2tBn84NNuHrnqNVxx/XqsXwfYvS1YwBh+7CBnbCYsk=",
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "rev": "809414f0cdadf82cf11b06c2b29ba9b3168b3297",
+ "type": "github"
+ },
+ "original": {
+ "owner": "cachix",
+ "repo": "git-hooks.nix",
+ "type": "github"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1787360063,
+ "narHash": "sha256-dt4WdcvsA8/RCe+VZZwqU0X+XMM3wBbGCWA0/sFWzGo=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "2c423e03bbafcff28bfadc6781a4a8257f205cb5",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "git-hooks": "git-hooks",
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..895931b
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,30 @@
+{
+ description = "ProtonVPN TUI: A minimal, TUI and keyboard friendly wrapper for proton-vpn-cli.";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
+ git-hooks = {
+ url = "github:cachix/git-hooks.nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs = {
+ self,
+ nixpkgs,
+ git-hooks,
+ }: let
+ supportedSystems = ["x86_64-linux" "aarch64-linux"];
+
+ forAllSystems = f:
+ nixpkgs.lib.genAttrs supportedSystems
+ (system: f system (import nixpkgs {inherit system;}));
+ in {
+ devShells = forAllSystems (system: pkgs: {
+ default = import ./nix/shell.nix {
+ inherit pkgs;
+ gitHooksLib = git-hooks.lib.${system};
+ };
+ });
+ };
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0c53509
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,58 @@
+module github.com/anotherhadi/proton-vpn-tui
+
+go 1.26.5
+
+require (
+ charm.land/bubbles/v2 v2.1.0
+ charm.land/bubbletea/v2 v2.0.2
+ charm.land/lipgloss/v2 v2.0.3
+ github.com/anotherhadi/ilovetui v0.2.3
+ github.com/charmbracelet/x/ansi v0.11.7
+ github.com/go-viper/mapstructure/v2 v2.4.0
+ github.com/lrstanley/bubblezone/v2 v2.0.0
+ github.com/spf13/cobra v1.10.2
+ github.com/spf13/viper v1.21.0
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ charm.land/glamour/v2 v2.0.0 // indirect
+ github.com/alecthomas/chroma/v2 v2.14.0 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymerick/douceur v0.2.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.3 // indirect
+ github.com/charmbracelet/harmonica v0.2.0 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 // indirect
+ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/dlclark/regexp2 v1.11.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
+ github.com/mattn/go-runewidth v0.0.23 // indirect
+ github.com/microcosm-cc/bluemonday v1.0.27 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sagikazarmark/locafero v0.11.0 // indirect
+ github.com/sahilm/fuzzy v0.1.1 // indirect
+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ github.com/yuin/goldmark v1.7.8 // indirect
+ github.com/yuin/goldmark-emoji v1.0.5 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/net v0.39.0 // indirect
+ golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.28.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..a2162f6
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,137 @@
+charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
+charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
+charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
+charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
+charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U=
+charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w=
+charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
+charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
+github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
+github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
+github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
+github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
+github.com/anotherhadi/ilovetui v0.2.3 h1:0xTHMMyQqN0FtQ4u9Uix6xl5YzcVugLqC62aLV4AoXU=
+github.com/anotherhadi/ilovetui v0.2.3/go.mod h1:gAn69amLM3z0x8PV4obyf5bJntL/PTZusEuhu/xdFIY=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
+github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73 h1:Af/L28Xh+pddhouT/6lJ7IAIYfu5tWJOB0iqt+mXsYM=
+github.com/charmbracelet/ultraviolet v0.0.0-20260223171050-89c142e4aa73/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
+github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
+github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
+github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/lrstanley/bubblezone/v2 v2.0.0 h1:pMb9fHKs0slJF6OrzQ2hEgWusqyl9VU/S0UZ5hyh7ZA=
+github.com/lrstanley/bubblezone/v2 v2.0.0/go.mod h1:yV/QTjcm4Zu5cqvGvdHi7xVUfnB36w/SafOuDp57dgY=
+github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
+github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
+github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
+github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/backend/config.go b/internal/backend/config.go
new file mode 100644
index 0000000..0d25444
--- /dev/null
+++ b/internal/backend/config.go
@@ -0,0 +1,229 @@
+package backend
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "unicode"
+)
+
+type Option struct {
+ ID string
+ Description string
+}
+
+type Setting struct {
+ Description string
+ Options []Option
+ Value string
+}
+
+type Config map[string]Setting
+
+func LoadConfig() (c Config, err error) {
+ c = make(Config)
+ ctx := context.Background()
+ res, err := Run(ctx, "config", "list")
+ if err != nil {
+ return
+ }
+
+ inOptions := false
+ for line := range strings.SplitSeq(res.Stdout, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ inOptions = false
+ continue
+ }
+ if strings.HasPrefix(fields[0], "---") {
+ inOptions = true
+ continue
+ }
+ if !inOptions {
+ continue
+ }
+
+ c[fields[0]] = Setting{Value: strings.Join(fields[1:], " ")}
+ }
+
+ for setting := range c {
+ desc, options, e := getInfo(setting)
+ if e != nil {
+ err = e
+ return
+ }
+ entry := c[setting]
+ entry.Description = desc
+ entry.Options = options
+ c[setting] = entry
+ }
+
+ if err = c.SaveCache(); err != nil {
+ return
+ }
+
+ return
+}
+
+func LoadConfigCache() (c Config, err error) {
+ path, err := configCachePath()
+ if err != nil {
+ return
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return
+ }
+
+ err = json.Unmarshal(data, &c)
+ return
+}
+
+func (c Config) SaveCache() error {
+ path, err := configCachePath()
+ if err != nil {
+ return err
+ }
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+
+ data, err := json.Marshal(c)
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(path, data, 0o600)
+}
+
+func configCachePath() (string, error) {
+ cacheHome, err := xdgCacheHome()
+ if err != nil {
+ return "", err
+ }
+
+ return filepath.Join(cacheHome, "proton-vpn-tui", "config.json"), nil
+}
+
+func (c Config) CycleSettingUp(setting string) (err error) {
+ entry, ok := c[setting]
+ if !ok || len(entry.Options) == 0 {
+ return errors.New("setting unknown")
+ }
+
+ idx := -1
+ for i, opt := range entry.Options {
+ if opt.ID == entry.Value {
+ idx = i
+ break
+ }
+ }
+
+ next := idx + 1
+ if next >= len(entry.Options) {
+ next = 0
+ }
+
+ return c.setSetting(setting, entry.Options[next].ID)
+}
+
+func (c Config) CycleSettingDown(setting string) (err error) {
+ entry, ok := c[setting]
+ if !ok || len(entry.Options) == 0 {
+ return errors.New("setting unknown")
+ }
+
+ idx := -1
+ for i, opt := range entry.Options {
+ if opt.ID == entry.Value {
+ idx = i
+ break
+ }
+ }
+
+ prev := idx - 1
+ if prev < 0 {
+ prev = len(entry.Options) - 1
+ }
+
+ return c.setSetting(setting, entry.Options[prev].ID)
+}
+
+func (c Config) setSetting(setting string, value string) error {
+ ctx := context.Background()
+ _, err := Run(ctx, "config", "set", setting, value)
+ if err != nil {
+ return err
+ }
+ entry := c[setting]
+ entry.Value = value
+ c[setting] = entry
+ return c.SaveCache()
+}
+
+func getInfo(setting string) (description string, options []Option, err error) {
+ ctx := context.Background()
+ res, err := Run(ctx, "config", "set", setting, "--help")
+ if err != nil {
+ return
+ }
+
+ inUsage := false
+ inDescription := false
+ inOptions := false
+ descDone := false
+ var descLines []string
+ for line := range strings.SplitSeq(res.Stdout, "\n") {
+ line = strings.TrimSpace(stripControl(line))
+ if strings.HasPrefix(line, "Usage:") {
+ inUsage = true
+ continue
+ }
+ if line == "Values:" {
+ inOptions = true
+ continue
+ }
+ if line == "" {
+ if inUsage {
+ inUsage = false
+ inDescription = !descDone
+ } else if len(descLines) > 0 {
+ descDone = true
+ inDescription = false
+ }
+ inOptions = false
+ continue
+ }
+ if inUsage {
+ continue
+ }
+ if inOptions {
+ fields := strings.Fields(line)
+ if len(fields) < 2 {
+ continue
+ }
+ options = append(options, Option{ID: fields[0], Description: strings.Join(fields[1:], " ")})
+ continue
+ }
+ if inDescription {
+ descLines = append(descLines, line)
+ }
+ }
+ description = strings.Join(descLines, " ")
+
+ return
+}
+
+func stripControl(s string) string {
+ return strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) {
+ return -1
+ }
+ return r
+ }, s)
+}
diff --git a/internal/backend/connect.go b/internal/backend/connect.go
new file mode 100644
index 0000000..9a0a328
--- /dev/null
+++ b/internal/backend/connect.go
@@ -0,0 +1,74 @@
+package backend
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+func connectArgs(country, city string, p2p, secureCore, tor bool) []string {
+ args := []string{"connect"}
+ if country != "" {
+ args = append(args, "--country", country)
+ }
+ if city != "" {
+ args = append(args, "--city", city)
+ }
+ if p2p {
+ args = append(args, "--p2p")
+ }
+ if secureCore {
+ args = append(args, "--securecore")
+ }
+ if tor {
+ args = append(args, "--tor")
+ }
+ return args
+}
+
+func ConnectFastest(country, city string, p2p, secureCore, tor bool) (err error) {
+ _, err = Run(context.Background(), connectArgs(country, city, p2p, secureCore, tor)...)
+ return
+}
+
+func ConnectRandom(country, city string, p2p, secureCore, tor bool) (err error) {
+ args := append(connectArgs(country, city, p2p, secureCore, tor), "--random")
+ _, err = Run(context.Background(), args...)
+ return
+}
+
+func ConnectId(id string) (err error) {
+ if !isValidServerId(id) {
+ return fmt.Errorf("protonvpn: invalid server id %q", id)
+ }
+ _, err = Run(context.Background(), "connect", id)
+ return
+}
+
+func ConnectAny() (err error) {
+ _, err = Run(context.Background(), "connect")
+ return
+}
+
+func Disconnect() (err error) {
+ res, err := Run(context.Background(), "disconnect")
+ if err != nil && res != nil && strings.HasPrefix(res.Stdout, "Disconnected") {
+ // The official CLI sometimes reports a successful disconnect and
+ // then, in a separate step (e.g. cleanup/telemetry), fails and
+ // exits non-zero with the generic error banner. The VPN is
+ // already disconnected at that point, so treat it as success.
+ return nil
+ }
+ return err
+}
+
+var serverIdPattern = regexp.MustCompile(`^[A-Z]{2}(-[A-Z0-9]{2,4})?#[0-9]+(-TOR)?$`)
+
+func isValidServerId(id string) bool {
+ return serverIdPattern.MatchString(id)
+}
+
+func IsServerID(s string) bool {
+ return isValidServerId(s)
+}
diff --git a/internal/backend/countries.go b/internal/backend/countries.go
new file mode 100644
index 0000000..2a83b53
--- /dev/null
+++ b/internal/backend/countries.go
@@ -0,0 +1,108 @@
+package backend
+
+import "strings"
+
+var countryNames = map[string]string{
+ "AD": "Andorra", "AE": "United Arab Emirates", "AF": "Afghanistan",
+ "AG": "Antigua and Barbuda", "AI": "Anguilla", "AL": "Albania", "AM": "Armenia",
+ "AO": "Angola", "AQ": "Antarctica", "AR": "Argentina", "AS": "American Samoa",
+ "AT": "Austria", "AU": "Australia", "AW": "Aruba", "AX": "Åland Islands",
+ "AZ": "Azerbaijan", "BA": "Bosnia and Herzegovina", "BB": "Barbados",
+ "BD": "Bangladesh", "BE": "Belgium", "BF": "Burkina Faso", "BG": "Bulgaria",
+ "BH": "Bahrain", "BI": "Burundi", "BJ": "Benin", "BL": "Saint Barthélemy",
+ "BM": "Bermuda", "BN": "Brunei", "BO": "Bolivia", "BQ": "Caribbean Netherlands",
+ "BR": "Brazil", "BS": "Bahamas", "BT": "Bhutan", "BV": "Bouvet Island",
+ "BW": "Botswana", "BY": "Belarus", "BZ": "Belize", "CA": "Canada",
+ "CC": "Cocos Islands", "CD": "DR Congo", "CF": "Central African Republic",
+ "CG": "Congo", "CH": "Switzerland", "CI": "Ivory Coast", "CK": "Cook Islands",
+ "CL": "Chile", "CM": "Cameroon", "CN": "China", "CO": "Colombia",
+ "CR": "Costa Rica", "CU": "Cuba", "CV": "Cabo Verde", "CW": "Curaçao",
+ "CX": "Christmas Island", "CY": "Cyprus", "CZ": "Czech Republic",
+ "DE": "Germany", "DJ": "Djibouti", "DK": "Denmark", "DM": "Dominica",
+ "DO": "Dominican Republic", "DZ": "Algeria", "EC": "Ecuador", "EE": "Estonia",
+ "EG": "Egypt", "EH": "Western Sahara", "ER": "Eritrea", "ES": "Spain",
+ "ET": "Ethiopia", "FI": "Finland", "FJ": "Fiji", "FK": "Falkland Islands",
+ "FM": "Micronesia", "FO": "Faroe Islands", "FR": "France", "GA": "Gabon",
+ "GB": "United Kingdom", "GD": "Grenada", "GE": "Georgia",
+ "GF": "French Guiana", "GG": "Guernsey", "GH": "Ghana", "GI": "Gibraltar",
+ "GL": "Greenland", "GM": "Gambia", "GN": "Guinea", "GP": "Guadeloupe",
+ "GQ": "Equatorial Guinea", "GR": "Greece",
+ "GS": "South Georgia and the South Sandwich Islands", "GT": "Guatemala",
+ "GU": "Guam", "GW": "Guinea-Bissau", "GY": "Guyana", "HK": "Hong Kong",
+ "HM": "Heard Island and McDonald Islands", "HN": "Honduras", "HR": "Croatia",
+ "HT": "Haiti", "HU": "Hungary", "ID": "Indonesia", "IE": "Ireland",
+ "IL": "Israel", "IM": "Isle of Man", "IN": "India",
+ "IO": "British Indian Ocean Territory", "IQ": "Iraq", "IR": "Iran",
+ "IS": "Iceland", "IT": "Italy", "JE": "Jersey", "JM": "Jamaica",
+ "JO": "Jordan", "JP": "Japan", "KE": "Kenya", "KG": "Kyrgyzstan",
+ "KH": "Cambodia", "KI": "Kiribati", "KM": "Comoros",
+ "KN": "Saint Kitts and Nevis", "KP": "North Korea", "KR": "South Korea",
+ "KW": "Kuwait", "KY": "Cayman Islands", "KZ": "Kazakhstan", "LA": "Laos",
+ "LB": "Lebanon", "LC": "Saint Lucia", "LI": "Liechtenstein",
+ "LK": "Sri Lanka", "LR": "Liberia", "LS": "Lesotho", "LT": "Lithuania",
+ "LU": "Luxembourg", "LV": "Latvia", "LY": "Libya", "MA": "Morocco",
+ "MC": "Monaco", "MD": "Moldova", "ME": "Montenegro", "MF": "Saint Martin",
+ "MG": "Madagascar", "MH": "Marshall Islands", "MK": "North Macedonia",
+ "ML": "Mali", "MM": "Myanmar", "MN": "Mongolia", "MO": "Macao",
+ "MP": "Northern Mariana Islands", "MQ": "Martinique", "MR": "Mauritania",
+ "MS": "Montserrat", "MT": "Malta", "MU": "Mauritius", "MV": "Maldives",
+ "MW": "Malawi", "MX": "Mexico", "MY": "Malaysia", "MZ": "Mozambique",
+ "NA": "Namibia", "NC": "New Caledonia", "NE": "Niger",
+ "NF": "Norfolk Island", "NG": "Nigeria", "NI": "Nicaragua",
+ "NL": "Netherlands", "NO": "Norway", "NP": "Nepal", "NR": "Nauru",
+ "NU": "Niue", "NZ": "New Zealand", "OM": "Oman", "PA": "Panama",
+ "PE": "Peru", "PF": "French Polynesia", "PG": "Papua New Guinea",
+ "PH": "Philippines", "PK": "Pakistan", "PL": "Poland",
+ "PM": "Saint Pierre and Miquelon", "PN": "Pitcairn Islands",
+ "PR": "Puerto Rico", "PS": "Palestine", "PT": "Portugal", "PW": "Palau",
+ "PY": "Paraguay", "QA": "Qatar", "RE": "Réunion", "RO": "Romania",
+ "RS": "Serbia", "RU": "Russia", "RW": "Rwanda", "SA": "Saudi Arabia",
+ "SB": "Solomon Islands", "SC": "Seychelles", "SD": "Sudan", "SE": "Sweden",
+ "SG": "Singapore", "SH": "Saint Helena", "SI": "Slovenia",
+ "SJ": "Svalbard and Jan Mayen", "SK": "Slovakia", "SL": "Sierra Leone",
+ "SM": "San Marino", "SN": "Senegal", "SO": "Somalia", "SR": "Suriname",
+ "SS": "South Sudan", "ST": "São Tomé and Príncipe", "SV": "El Salvador",
+ "SX": "Sint Maarten", "SY": "Syria", "SZ": "Eswatini",
+ "TC": "Turks and Caicos Islands", "TD": "Chad",
+ "TF": "French Southern Territories", "TG": "Togo", "TH": "Thailand",
+ "TJ": "Tajikistan", "TK": "Tokelau", "TL": "Timor-Leste",
+ "TM": "Turkmenistan", "TN": "Tunisia", "TO": "Tonga", "TR": "Turkey",
+ "TT": "Trinidad and Tobago", "TV": "Tuvalu", "TW": "Taiwan",
+ "TZ": "Tanzania", "UA": "Ukraine", "UG": "Uganda",
+ "UM": "United States Minor Outlying Islands", "US": "United States",
+ "UY": "Uruguay", "UZ": "Uzbekistan", "VA": "Vatican City",
+ "VC": "Saint Vincent and the Grenadines", "VE": "Venezuela",
+ "VG": "British Virgin Islands", "VI": "United States Virgin Islands",
+ "VN": "Vietnam", "VU": "Vanuatu", "WF": "Wallis and Futuna",
+ "WS": "Samoa", "YE": "Yemen", "YT": "Mayotte", "ZA": "South Africa",
+ "ZM": "Zambia", "ZW": "Zimbabwe",
+}
+
+func CountryName(countryCode string) string {
+ code := strings.ToUpper(countryCode)
+ if code == "UK" {
+ code = "GB"
+ }
+ if name, ok := countryNames[code]; ok {
+ return name
+ }
+ return countryCode
+}
+
+func ResolveCountryCode(s string) (string, bool) {
+ code := strings.ToUpper(strings.TrimSpace(s))
+ if code == "UK" {
+ code = "GB"
+ }
+ if _, ok := countryNames[code]; ok {
+ return code, true
+ }
+
+ name := strings.ToLower(strings.TrimSpace(s))
+ for c, n := range countryNames {
+ if strings.ToLower(n) == name {
+ return c, true
+ }
+ }
+ return "", false
+}
diff --git a/internal/backend/exec.go b/internal/backend/exec.go
new file mode 100644
index 0000000..19c13ac
--- /dev/null
+++ b/internal/backend/exec.go
@@ -0,0 +1,109 @@
+package backend
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+ "strings"
+ "time"
+)
+
+const DefaultTimeout = 15 * time.Second
+
+var BinaryName = "protonvpn"
+
+var ErrTimeout = errors.New("protonvpn: command timeout")
+
+var ErrNotFound = errors.New("protonvpn: binary not found in PATH")
+
+type Result struct {
+ Stdout string
+ Stderr string
+}
+
+type CommandError struct {
+ Result *Result
+ Err error
+}
+
+const unexpectedErrorBanner = "An unexpected error occurred. Please try again."
+
+func (e *CommandError) Error() string {
+ if strings.HasPrefix(e.Result.Stderr, unexpectedErrorBanner) ||
+ strings.HasPrefix(e.Result.Stdout, unexpectedErrorBanner) {
+ return unexpectedErrorBanner
+ }
+ if e.Result.Stderr != "" {
+ return fmt.Sprintf("%v (stderr: %q)", e.Err, e.Result.Stderr)
+ }
+ if e.Result.Stdout != "" {
+ return e.Result.Stdout
+ }
+ return e.Err.Error()
+}
+
+func (e *CommandError) Unwrap() error {
+ return e.Err
+}
+
+func IsAuthRequired(err error) bool {
+ var cmdErr *CommandError
+ if !errors.As(err, &cmdErr) {
+ return false
+ }
+ return strings.Contains(cmdErr.Result.Stderr, "protonvpn signin") ||
+ strings.Contains(cmdErr.Result.Stdout, "protonvpn signin")
+}
+
+func IsFreePlanRestricted(err error) bool {
+ var cmdErr *CommandError
+ if !errors.As(err, &cmdErr) {
+ return false
+ }
+ return strings.Contains(cmdErr.Result.Stderr, "not available on the free plan") ||
+ strings.Contains(cmdErr.Result.Stdout, "not available on the free plan")
+}
+
+func Available() error {
+ if _, err := exec.LookPath(BinaryName); err != nil {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func Run(ctx context.Context, args ...string) (*Result, error) {
+ if ctx == nil {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancel()
+ }
+
+ cmd := exec.CommandContext(ctx, BinaryName, args...)
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ runErr := cmd.Run()
+
+ res := &Result{
+ Stdout: strings.TrimSpace(stdout.String()),
+ Stderr: strings.TrimSpace(stderr.String()),
+ }
+
+ if ctx.Err() == context.DeadlineExceeded {
+ return res, &CommandError{Result: res, Err: ErrTimeout}
+ }
+
+ if runErr != nil {
+ return res, &CommandError{Result: res, Err: runErr}
+ }
+
+ if strings.HasPrefix(res.Stdout, "Error:") {
+ return res, &CommandError{Result: res, Err: errors.New(res.Stdout)}
+ }
+
+ return res, nil
+}
diff --git a/internal/backend/flag.go b/internal/backend/flag.go
new file mode 100644
index 0000000..29e28e4
--- /dev/null
+++ b/internal/backend/flag.go
@@ -0,0 +1,34 @@
+package backend
+
+import "strings"
+
+func countryCodeFromServerId(id string) string {
+ if i := strings.IndexAny(id, "-#"); i != -1 {
+ return id[:i]
+ }
+ return id
+}
+
+func FlagEmoji(countryCode string) string {
+ code := strings.ToUpper(countryCode)
+ if code == "UK" {
+ code = "GB"
+ }
+ if len(code) != 2 {
+ return "🏳️"
+ }
+
+ var flag strings.Builder
+ for _, r := range code {
+ if r < 'A' || r > 'Z' {
+ return "🏳️"
+ }
+ flag.WriteRune(r + 127397)
+ }
+
+ return flag.String()
+}
+
+func FlagEmojiFromServerId(serverId string) string {
+ return FlagEmoji(countryCodeFromServerId(serverId))
+}
diff --git a/internal/backend/servers.go b/internal/backend/servers.go
new file mode 100644
index 0000000..d56bb9c
--- /dev/null
+++ b/internal/backend/servers.go
@@ -0,0 +1,113 @@
+package backend
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+)
+
+const (
+ featureSecureCore = 1 << iota
+ featureTor
+ featureP2P
+ featureStreaming
+ featureIPv6
+)
+
+type LogicalServer struct {
+ Name string
+ EntryCountry string
+ ExitCountry string
+ City *string
+ Tier int
+ SecureCore bool
+ Tor bool
+ P2P bool
+ Streaming bool
+ IPv6 bool
+ Score float64
+ ID string
+ Status int
+ Load int
+}
+
+func (s LogicalServer) IsFree() bool {
+ return s.Tier == 0
+}
+
+func (s *LogicalServer) UnmarshalJSON(data []byte) error {
+ var raw struct {
+ Name string
+ EntryCountry string
+ ExitCountry string
+ City *string
+ Tier int
+ Features int
+ Score float64
+ ID string
+ Status int
+ Load int
+ }
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return err
+ }
+
+ *s = LogicalServer{
+ Name: raw.Name,
+ EntryCountry: raw.EntryCountry,
+ ExitCountry: raw.ExitCountry,
+ City: raw.City,
+ Tier: raw.Tier,
+ SecureCore: raw.Features&featureSecureCore != 0,
+ Tor: raw.Features&featureTor != 0,
+ P2P: raw.Features&featureP2P != 0,
+ Streaming: raw.Features&featureStreaming != 0,
+ IPv6: raw.Features&featureIPv6 != 0,
+ Score: raw.Score,
+ ID: raw.ID,
+ Status: raw.Status,
+ Load: raw.Load,
+ }
+ return nil
+}
+
+func RefreshServerList() error {
+ _, err := Run(context.Background(), "cities", "list", "France")
+ return err
+}
+
+func ParseCache() ([]LogicalServer, error) {
+ cacheHome, err := xdgCacheHome()
+ if err != nil {
+ return nil, err
+ }
+
+ path := filepath.Join(cacheHome, "Proton", "VPN", "serverlist.json")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var cache struct {
+ LogicalServers []LogicalServer
+ }
+ if err := json.Unmarshal(data, &cache); err != nil {
+ return nil, err
+ }
+
+ return cache.LogicalServers, nil
+}
+
+func xdgCacheHome() (string, error) {
+ if dir := os.Getenv("XDG_CACHE_HOME"); dir != "" {
+ return dir, nil
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+
+ return filepath.Join(home, ".cache"), nil
+}
diff --git a/internal/backend/status.go b/internal/backend/status.go
new file mode 100644
index 0000000..57744a1
--- /dev/null
+++ b/internal/backend/status.go
@@ -0,0 +1,103 @@
+package backend
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+ "strconv"
+ "strings"
+)
+
+const protonInterface = "proton0"
+
+var ErrNmcliNotFound = errors.New("nmcli: binary not found in PATH")
+
+type ConnectionStatus struct {
+ Connected bool
+ Server string
+ Location string
+ Load int
+ Protocol string
+}
+
+func Status() (ConnectionStatus, error) {
+ connected, err := isConnectedViaNetworkManager()
+ if err != nil {
+ return ConnectionStatus{}, err
+ }
+ if !connected {
+ return ConnectionStatus{Connected: false}, nil
+ }
+
+ res, err := Run(context.Background(), "status")
+ if err != nil {
+ return ConnectionStatus{}, err
+ }
+
+ status := parseStatus(res.Stdout)
+ status.Connected = true
+ return status, nil
+}
+
+func IsConnected() (bool, error) {
+ return isConnectedViaNetworkManager()
+}
+
+func isConnectedViaNetworkManager() (bool, error) {
+ if _, err := exec.LookPath("nmcli"); err != nil {
+ return false, ErrNmcliNotFound
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "nmcli", "-t", "-f", "DEVICE", "connection", "show", "--active")
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return false, fmt.Errorf("nmcli: %w (stderr: %q)", err, strings.TrimSpace(stderr.String()))
+ }
+
+ for device := range strings.SplitSeq(strings.TrimSpace(stdout.String()), "\n") {
+ if device == protonInterface {
+ return true, nil
+ }
+ }
+
+ return false, nil
+}
+
+func parseStatus(output string) ConnectionStatus {
+ var status ConnectionStatus
+
+ for line := range strings.SplitSeq(output, "\n") {
+ key, value, found := strings.Cut(strings.TrimSpace(line), ": ")
+ if !found {
+ continue
+ }
+
+ switch key {
+ case "Status":
+ status.Connected = value == "Connected"
+ case "Server":
+ if server, location, ok := strings.Cut(value, " in "); ok {
+ status.Server = server
+ status.Location = location
+ } else {
+ status.Server = value
+ }
+ case "Load":
+ if load, err := strconv.Atoi(strings.TrimSuffix(value, "%")); err == nil {
+ status.Load = load
+ }
+ case "Protocol":
+ status.Protocol = value
+ }
+ }
+
+ return status
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..83a7990
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,31 @@
+package config
+
+import (
+ _ "embed"
+)
+
+//go:embed default_config.yaml
+var defaultConfig []byte
+
+type Config struct {
+ Version string `mapstructure:"-"`
+
+ App struct {
+ ShowFlags bool `mapstructure:"show_flags"`
+ FocusSearchOnLaunch bool `mapstructure:"focus_search_on_launch"`
+
+ Default struct {
+ Tor bool `mapstructure:"tor"`
+ P2P bool `mapstructure:"p2p"`
+ SecureCore bool `mapstructure:"securecore"`
+ Free bool `mapstructure:"free"`
+ Prefer string `mapstructure:"prefer"`
+ } `mapstructure:"default"`
+
+ Favorites []string `mapstructure:"favorites"`
+ } `mapstructure:"app"`
+
+ Keybindings Keybindings `mapstructure:"keybindings"`
+}
+
+var Global *Config
diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml
new file mode 100644
index 0000000..c6d8de0
--- /dev/null
+++ b/internal/config/default_config.yaml
@@ -0,0 +1,43 @@
+app:
+ show_flags: true
+ focus_search_on_launch: false
+ default:
+ tor: false # Enable Tor over VPN
+ p2p: false # Only show P2P-optimized servers
+ securecore: false # Use an entry node and an exit node
+ free: false # Show only free servers
+ prefer: "fastest" # 'fastest' or 'random'
+
+ # Country code (e.g. "CH"), country name (e.g. "Switzerland"), or a
+ # specific server ID (e.g. "CH#20"). Case-insensitive.
+ favorites: []
+
+keybindings:
+ global:
+ quit: "q,ctrl+c"
+ escape: "esc,ctrl+c"
+ help: "?"
+ up: "up,k"
+ down: "down,j"
+ left: "left,h"
+ right: "right,l"
+ goto_top: "g"
+ goto_bottom: "G,end"
+
+ home:
+ open_settings: "s"
+ connect: "enter,c"
+ disconnect: "d"
+ goto_favorite: "f"
+ search: "/,ctrl+f"
+ refresh: "r"
+ toggle_tor: "t"
+ toggle_securecore: "S"
+ toggle_p2p: "p"
+ toggle_free: "F"
+ toggle_prefer: "P"
+
+ settings:
+ toggle_setting: "space"
+ previous_option: "left,h"
+ next_option: "right,l"
diff --git a/internal/config/func_config.go b/internal/config/func_config.go
new file mode 100644
index 0000000..79c6890
--- /dev/null
+++ b/internal/config/func_config.go
@@ -0,0 +1,78 @@
+package config
+
+import (
+ _ "embed"
+ "errors"
+ "fmt"
+ "maps"
+ "os"
+ "path/filepath"
+ "reflect"
+
+ "github.com/go-viper/mapstructure/v2"
+ "github.com/spf13/viper"
+ "gopkg.in/yaml.v3"
+)
+
+func DefaultPath() string {
+ dir, err := os.UserConfigDir()
+ if err != nil {
+ dir = "."
+ }
+ return filepath.Join(dir, "proton-vpn-tui", "config.yaml")
+}
+
+func Load(path string) error {
+ var defaults map[string]any
+ if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
+ return fmt.Errorf("default config: %w", err)
+ }
+ for k, v := range flatten("", defaults) {
+ viper.SetDefault(k, v)
+ }
+
+ viper.SetConfigType("yaml")
+ viper.SetConfigFile(path)
+ if err := viper.ReadInConfig(); err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ }
+
+ Global = &Config{}
+ hook := viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
+ stringToKeyBindingHook,
+ mapstructure.StringToTimeDurationHookFunc(),
+ ))
+ if err := viper.Unmarshal(Global, hook); err != nil {
+ return err
+ }
+ fillHelp(reflect.ValueOf(&Global.Keybindings))
+ return nil
+}
+
+func WriteDefaultConfig(path string) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+ return fmt.Errorf("create config dir: %w", err)
+ }
+ if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
+ return fmt.Errorf("write config: %w", err)
+ }
+ return nil
+}
+
+func flatten(prefix string, m map[string]any) map[string]any {
+ out := make(map[string]any)
+ for k, v := range m {
+ key := k
+ if prefix != "" {
+ key = prefix + "." + k
+ }
+ if nested, ok := v.(map[string]any); ok {
+ maps.Copy(out, flatten(key, nested))
+ } else {
+ out[key] = v
+ }
+ }
+ return out
+}
diff --git a/internal/config/func_keybindings.go b/internal/config/func_keybindings.go
new file mode 100644
index 0000000..0a89471
--- /dev/null
+++ b/internal/config/func_keybindings.go
@@ -0,0 +1,113 @@
+package config
+
+import (
+ "fmt"
+ "reflect"
+ "slices"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+)
+
+func ShortHelp(keymap any) []key.Binding {
+ return bindingsWithHelp(keymap, "short")
+}
+
+func pageFullHelp(keymap any) []key.Binding {
+ return bindingsWithHelp(keymap, "short", "full")
+}
+
+func bindingsWithHelp(keymap any, levels ...string) []key.Binding {
+ v := reflect.ValueOf(keymap)
+ if v.Kind() == reflect.Pointer {
+ v = v.Elem()
+ }
+ t := v.Type()
+
+ var out []key.Binding
+ for i := range v.NumField() {
+ field := t.Field(i)
+ if field.Type != bindingType {
+ continue
+ }
+ if slices.Contains(levels, field.Tag.Get("help")) {
+ out = append(out, v.Field(i).Interface().(key.Binding))
+ }
+ }
+ return out
+}
+
+type HelpKeyMap struct {
+ pages []any
+}
+
+func Help(pages ...any) HelpKeyMap {
+ return HelpKeyMap{pages: pages}
+}
+
+func (h HelpKeyMap) ShortHelp() []key.Binding {
+ var out []key.Binding
+ for _, p := range h.pages {
+ out = append(out, ShortHelp(p)...)
+ }
+ return out
+}
+
+func (h HelpKeyMap) FullHelp() [][]key.Binding {
+ cols := make([][]key.Binding, 0, len(h.pages))
+ for _, p := range h.pages {
+ if col := pageFullHelp(p); len(col) > 0 {
+ cols = append(cols, col)
+ }
+ }
+ return cols
+}
+
+var bindingType = reflect.TypeFor[key.Binding]()
+
+var validHelpLevels = map[string]bool{"none": true, "short": true, "full": true}
+
+func stringToKeyBindingHook(from reflect.Type, to reflect.Type, data any) (any, error) {
+ if to != bindingType || from.Kind() != reflect.String {
+ return data, nil
+ }
+
+ var keys []string
+ for part := range strings.SplitSeq(data.(string), ",") {
+ if k := strings.TrimSpace(part); k != "" {
+ keys = append(keys, k)
+ }
+ }
+ return key.NewBinding(key.WithKeys(keys...)), nil
+}
+
+func fillHelp(v reflect.Value) {
+ if v.Kind() == reflect.Pointer {
+ v = v.Elem()
+ }
+ if v.Kind() != reflect.Struct {
+ return
+ }
+
+ t := v.Type()
+ for i := range t.NumField() {
+ field := t.Field(i)
+ fv := v.Field(i)
+
+ if field.Type == bindingType {
+ b := fv.Addr().Interface().(*key.Binding)
+ b.SetHelp(strings.Join(b.Keys(), ","), field.Tag.Get("desc"))
+
+ if level := field.Tag.Get("help"); !validHelpLevels[level] {
+ panic(fmt.Sprintf(
+ "config: %s.%s has invalid `help` tag %q, want \"none\", \"short\" or \"full\"",
+ t.Name(), field.Name, level,
+ ))
+ }
+ continue
+ }
+ if field.Type.Kind() == reflect.Struct {
+ fillHelp(fv)
+ }
+ }
+}
diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go
new file mode 100644
index 0000000..ea105e9
--- /dev/null
+++ b/internal/config/keybindings.go
@@ -0,0 +1,44 @@
+package config
+
+import "charm.land/bubbles/v2/key"
+
+type GlobalKeys struct {
+ Quit key.Binding `mapstructure:"quit" desc:"quit" help:"short"`
+ Escape key.Binding `mapstructure:"escape" desc:"close" help:"none"`
+ Help key.Binding `mapstructure:"help" desc:"help" help:"none"`
+
+ Up key.Binding `mapstructure:"up" desc:"up" help:"full"`
+ Down key.Binding `mapstructure:"down" desc:"down" help:"full"`
+ Left key.Binding `mapstructure:"left" desc:"left" help:"full"`
+ Right key.Binding `mapstructure:"right" desc:"right" help:"full"`
+
+ GotoTop key.Binding `mapstructure:"goto_top" desc:"go to top" help:"full"`
+ GotoBottom key.Binding `mapstructure:"goto_bottom" desc:"go to bottom" help:"full"`
+}
+
+type HomeKeys struct {
+ OpenSettings key.Binding `mapstructure:"open_settings" desc:"settings" help:"full"`
+ Connect key.Binding `mapstructure:"connect" desc:"connect" help:"short"`
+ Disconnect key.Binding `mapstructure:"disconnect" desc:"disconnect" help:"short"`
+ GotoFavorite key.Binding `mapstructure:"goto_favorite" desc:"go to favorites" help:"full"`
+ Search key.Binding `mapstructure:"search" desc:"search" help:"short"`
+ Refresh key.Binding `mapstructure:"refresh" desc:"refresh" help:"full"`
+
+ ToggleTor key.Binding `mapstructure:"toggle_tor" desc:"toggle tor" help:"full"`
+ ToggleSecureCore key.Binding `mapstructure:"toggle_securecore" desc:"toggle secure core" help:"full"`
+ ToggleP2P key.Binding `mapstructure:"toggle_p2p" desc:"toggle p2p" help:"full"`
+ ToggleFree key.Binding `mapstructure:"toggle_free" desc:"toggle free" help:"full"`
+ TogglePrefer key.Binding `mapstructure:"toggle_prefer" desc:"fastest/random" help:"full"`
+}
+
+type SettingsKeys struct {
+ PreviousOption key.Binding `mapstructure:"previous_option" desc:"decrease" help:"full"`
+ NextOption key.Binding `mapstructure:"next_option" desc:"increase" help:"full"`
+ ToggleSetting key.Binding `mapstructure:"toggle_setting" desc:"toggle" help:"short"`
+}
+
+type Keybindings struct {
+ Global GlobalKeys `mapstructure:"global"`
+ Home HomeKeys `mapstructure:"home"`
+ Settings SettingsKeys `mapstructure:"settings"`
+}
diff --git a/internal/icons/icons.go b/internal/icons/icons.go
new file mode 100644
index 0000000..af3d2b0
--- /dev/null
+++ b/internal/icons/icons.go
@@ -0,0 +1,87 @@
+package icons
+
+import (
+ "github.com/anotherhadi/ilovetui/style"
+)
+
+type Icons struct {
+ Settings string
+ Vpn string
+ SecureCore string
+ Tor string
+ P2p string
+ Free string
+ Fastest string
+ Random string
+
+ Connected string
+ Disconnected string
+ Connecting string
+ Loading string
+
+ Country string
+ City string
+ Server string
+
+ Expanded string
+ Collapsed string
+
+ Favorite string
+
+ Search string
+ Load string
+
+ PillLeft string
+ PillRight string
+}
+
+var I *Icons
+
+func Init() {
+ if style.S.NerdFonts {
+ I = &Icons{
+ Settings: "",
+ Vpn: "",
+ SecureCore: "",
+ Tor: "",
+ P2p: "",
+ Free: "",
+ Fastest: "",
+ Random: "",
+
+ Connected: "",
+ Disconnected: "",
+ Connecting: "",
+ Loading: "",
+
+ Country: "",
+ City: "",
+ Server: "",
+
+ Expanded: "",
+ Collapsed: "",
+
+ Favorite: "",
+
+ Search: "",
+ Load: "",
+
+ PillLeft: "",
+ PillRight: "",
+ }
+ } else {
+ I = &Icons{
+ Connected: "●",
+ Disconnected: "○",
+ Connecting: "…",
+ Loading: "…",
+
+ Expanded: "v",
+ Collapsed: ">",
+
+ Favorite: "*",
+
+ Search: "/",
+ }
+ }
+}
diff --git a/internal/ui/connect.go b/internal/ui/connect.go
new file mode 100644
index 0000000..5afdc49
--- /dev/null
+++ b/internal/ui/connect.go
@@ -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)
+}
diff --git a/internal/ui/delegate.go b/internal/ui/delegate.go
new file mode 100644
index 0000000..d596b6f
--- /dev/null
+++ b/internal/ui/delegate.go
@@ -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
+}
diff --git a/internal/ui/header.go b/internal/ui/header.go
new file mode 100644
index 0000000..bcaa9e6
--- /dev/null
+++ b/internal/ui/header.go
@@ -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)
+}
diff --git a/internal/ui/model.go b/internal/ui/model.go
new file mode 100644
index 0000000..efa72ca
--- /dev/null
+++ b/internal/ui/model.go
@@ -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)
+}
diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go
new file mode 100644
index 0000000..89bca2a
--- /dev/null
+++ b/internal/ui/mouse.go
@@ -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()
+}
diff --git a/internal/ui/notsignedin.go b/internal/ui/notsignedin.go
new file mode 100644
index 0000000..c59acdc
--- /dev/null
+++ b/internal/ui/notsignedin.go
@@ -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.")
+}
diff --git a/internal/ui/rows.go b/internal/ui/rows.go
new file mode 100644
index 0000000..53f31e1
--- /dev/null
+++ b/internal/ui/rows.go
@@ -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
+}
diff --git a/internal/ui/search.go b/internal/ui/search.go
new file mode 100644
index 0000000..1b632c9
--- /dev/null
+++ b/internal/ui/search.go
@@ -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
+}
diff --git a/internal/ui/settings/delegate.go b/internal/ui/settings/delegate.go
new file mode 100644
index 0000000..7833363
--- /dev/null
+++ b/internal/ui/settings/delegate.go
@@ -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, " ")
+}
diff --git a/internal/ui/settings/model.go b/internal/ui/settings/model.go
new file mode 100644
index 0000000..04b71ec
--- /dev/null
+++ b/internal/ui/settings/model.go
@@ -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 ",
+ 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"))
+}
diff --git a/internal/ui/styles.go b/internal/ui/styles.go
new file mode 100644
index 0000000..f2d7cd6
--- /dev/null
+++ b/internal/ui/styles.go
@@ -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)
+)
diff --git a/nix/shell.nix b/nix/shell.nix
new file mode 100644
index 0000000..c93b672
--- /dev/null
+++ b/nix/shell.nix
@@ -0,0 +1,33 @@
+{
+ pkgs,
+ gitHooksLib,
+}: let
+ hooks = gitHooksLib.run {
+ src = ../.;
+ hooks = {
+ gofmt.enable = true;
+ govet.enable = true;
+
+ doctoc = {
+ enable = true;
+ name = "doctoc";
+ entry = "doctoc --notitle README.md";
+ language = "system";
+ files = "(README\\.md|cmd/)";
+ pass_filenames = false;
+ };
+ };
+ };
+in
+ pkgs.mkShell {
+ packages = with pkgs;
+ [
+ go
+ gosec
+ govulncheck
+ doctoc
+ ]
+ ++ hooks.enabledPackages;
+
+ shellHook = hooks.shellHook;
+ }