mirror of
https://github.com/anotherhadi/proton-vpn-tui.git
synced 2026-10-05 11:18:27 +02:00
init
This commit is contained in:
@@ -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, " ")
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/bubbles"
|
||||
"github.com/anotherhadi/ilovetui/drawer"
|
||||
"github.com/anotherhadi/ilovetui/notification"
|
||||
|
||||
"github.com/anotherhadi/proton-vpn-tui/internal/backend"
|
||||
"github.com/anotherhadi/proton-vpn-tui/internal/config"
|
||||
)
|
||||
|
||||
type item struct {
|
||||
key string
|
||||
setting backend.Setting
|
||||
}
|
||||
|
||||
func (i item) FilterValue() string { return i.key }
|
||||
|
||||
type (
|
||||
loadedMsg backend.Config
|
||||
errMsg struct{ err error }
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
list list.Model
|
||||
cfg backend.Config
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func New(width, height int) tea.Model {
|
||||
l := bubbles.NewList(nil, 0, 0)
|
||||
l.SetDelegate(delegate{})
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetShowTitle(false)
|
||||
l.DisableQuitKeybindings()
|
||||
l.SetFilteringEnabled(false)
|
||||
|
||||
gk := config.Global.Keybindings.Global
|
||||
l.KeyMap.CursorUp = gk.Up
|
||||
l.KeyMap.CursorDown = gk.Down
|
||||
l.KeyMap.GoToStart = gk.GotoTop
|
||||
l.KeyMap.GoToEnd = gk.GotoBottom
|
||||
|
||||
m := &Model{list: l}
|
||||
m.applySize(width, height)
|
||||
return m
|
||||
}
|
||||
|
||||
const maxDrawerWidth = 80
|
||||
|
||||
func (m *Model) applySize(width, height int) {
|
||||
m.width, m.height = width, height
|
||||
|
||||
boxWidth := min(int(float64(width)*0.8), maxDrawerWidth)
|
||||
inner := boxWidth - 4
|
||||
if floor := min(24, max(width-4, 1)); inner < floor {
|
||||
inner = floor
|
||||
}
|
||||
|
||||
listH := height - 3
|
||||
if listH < 3 {
|
||||
listH = 3
|
||||
}
|
||||
m.list.SetSize(inner, listH)
|
||||
}
|
||||
|
||||
func (m *Model) rebuildItems() tea.Cmd {
|
||||
keys := make([]string, 0, len(m.cfg))
|
||||
for k := range m.cfg {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
items := make([]list.Item, len(keys))
|
||||
for i, k := range keys {
|
||||
items[i] = item{key: k, setting: m.cfg[k]}
|
||||
}
|
||||
return m.list.SetItems(items)
|
||||
}
|
||||
|
||||
func manualSetupNotice(it item) (tea.Cmd, bool) {
|
||||
if it.key != "custom-dns" || it.setting.Value != "off" {
|
||||
return nil, false
|
||||
}
|
||||
return notification.Show(
|
||||
"Settings",
|
||||
"Custom DNS needs a server address, set it via the CLI: "+
|
||||
"protonvpn config set custom-dns on --dns <ip[,ip...]>",
|
||||
notification.Info,
|
||||
), true
|
||||
}
|
||||
|
||||
func (m Model) selectedSetting() (item, bool) {
|
||||
if it := m.list.SelectedItem(); it != nil {
|
||||
return it.(item), true
|
||||
}
|
||||
return item{}, false
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return tea.Batch(loadCache, loadFresh)
|
||||
}
|
||||
|
||||
func loadCache() tea.Msg {
|
||||
cfg, err := backend.LoadConfigCache()
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
return loadedMsg(cfg)
|
||||
}
|
||||
|
||||
func loadFresh() tea.Msg {
|
||||
cfg, err := backend.LoadConfig()
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
return loadedMsg(cfg)
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.applySize(msg.Width, msg.Height)
|
||||
return m, nil
|
||||
|
||||
case loadedMsg:
|
||||
m.cfg = backend.Config(msg)
|
||||
cmd := m.rebuildItems()
|
||||
return m, cmd
|
||||
|
||||
case errMsg:
|
||||
return m, notification.Show("Settings", msg.err.Error(), notification.Error)
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
gk := config.Global.Keybindings.Global
|
||||
sk := config.Global.Keybindings.Settings
|
||||
switch {
|
||||
case key.Matches(msg, gk.Escape), key.Matches(msg, gk.Quit):
|
||||
return m, drawer.Close()
|
||||
|
||||
case key.Matches(msg, sk.NextOption), key.Matches(msg, sk.ToggleSetting):
|
||||
if it, ok := m.selectedSetting(); ok {
|
||||
if cmd, handled := manualSetupNotice(it); handled {
|
||||
return m, cmd
|
||||
}
|
||||
if err := m.cfg.CycleSettingUp(it.key); err != nil {
|
||||
return m, notification.Show("Settings", err.Error(), notification.Error)
|
||||
}
|
||||
cmd := m.rebuildItems()
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case key.Matches(msg, sk.PreviousOption):
|
||||
if it, ok := m.selectedSetting(); ok {
|
||||
if cmd, handled := manualSetupNotice(it); handled {
|
||||
return m, cmd
|
||||
}
|
||||
if err := m.cfg.CycleSettingDown(it.key); err != nil {
|
||||
return m, notification.Show("Settings", err.Error(), notification.Error)
|
||||
}
|
||||
cmd := m.rebuildItems()
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) View() tea.View {
|
||||
listView := strings.TrimRight(m.list.View(), "\n")
|
||||
return tea.NewView(strings.Join([]string{"", listView}, "\n"))
|
||||
}
|
||||
Reference in New Issue
Block a user