This commit is contained in:
2026-04-10 01:46:57 +09:00
commit ddd6ecccda
15 changed files with 1370 additions and 0 deletions

324
internal/tui/view.go Normal file
View File

@@ -0,0 +1,324 @@
package tui
import (
"tailscale-vpn/internal/styles"
tea "charm.land/bubbletea/v2"
lipgloss "charm.land/lipgloss/v2"
)
func footerHint(key, desc string) string {
return styles.FooterKeyStyle.Render(key) +
" " +
styles.FooterDescStyle.Render(desc)
}
func footerSep() string {
return styles.FooterSepStyle.Render(" · ")
}
func (m TUIInterface) subtitle() string {
switch m.Page {
case pageHome:
return "Secure vpn"
case pageSettings:
return "Settings"
case pageSelectServer:
return "Select Server"
default:
return "Secure vpn"
}
}
func (m TUIInterface) View() tea.View {
if m.Quitting {
return tea.NewView("")
}
w := m.WindowWidth
h := m.WindowHeight
if w == 0 {
w = 80
}
if h == 0 {
h = 24
}
var body string
switch m.Page {
case pageHome:
body = m.viewHomePage()
case pageSettings:
body = m.viewSettingsPage()
case pageSelectServer:
body = m.viewSelectServerPage()
default:
body = m.viewHomePage()
}
header := lipgloss.JoinVertical(lipgloss.Left,
styles.CardTitleStyle.Render("✦ tailscale vpn"),
styles.CardSubtitleStyle.Render(m.subtitle()),
)
topContent := styles.CardInnerStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, header, body),
)
var footerStr string
switch m.Page {
case pageHome:
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("r", "refresh") +
footerSep() +
footerHint("esc", "quit")
case pageSettings:
if m.SettingsEditMode == "" {
if m.SettingsConfirmDelete {
footerStr = footerHint("enter", "confirm") +
footerSep() +
footerHint("esc", "cancel")
} else {
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("a", "add") +
footerSep() +
footerHint("d", "delete") +
footerSep() +
footerHint("esc", "back")
}
} else {
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "cancel")
}
case pageSelectServer:
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "cancel")
default:
footerStr = footerHint("↑↓", "navigate") +
footerSep() +
footerHint("enter", "select") +
footerSep() +
footerHint("esc", "quit")
}
footer := styles.FooterStyle.Render(footerStr)
card := styles.CardStyle.Render(
lipgloss.JoinVertical(lipgloss.Left, topContent, footer),
)
cardHeight := lipgloss.Height(card)
topPad := max((h-cardHeight)/2, 0)
centeredCard := lipgloss.NewStyle().
Width(w).
Align(lipgloss.Center).
PaddingTop(topPad).
Render(card)
v := tea.NewView(centeredCard)
v.AltScreen = true
return v
}
func (m TUIInterface) viewHomePage() string {
var status string
if m.VPNLoading {
status = styles.VPNStatusLoadingStyle.Render("Checking...")
} else if m.VPNConnected {
status = styles.VPNStatusConnectedStyle.Render("● VPN Connected")
} else {
status = styles.VPNStatusDisconnectedStyle.Render("○ VPN Disconnected")
}
var serverStatus string
if m.HasSelectedServer {
serverStatus = styles.ServerSelectedStyle.Render("Server: " + m.SelectedServer.Name)
} else if m.HasServers {
serverStatus = styles.ServerNotSelectedStyle.Render("No server selected")
}
var menu string
if !m.VPNLoading && !m.VPNToggleLoading {
menu = m.renderMenu()
} else {
menu = styles.ButtonLoadingStyle.Render("...")
}
var errorStr string
if m.VPNError != nil {
errorStr = styles.VPNErrorStyle.Render("✗ " + m.VPNError.Error())
}
content := lipgloss.JoinVertical(lipgloss.Left, status, serverStatus, menu, errorStr)
return content
}
func (m TUIInterface) renderMenu() string {
if len(m.MenuItems) == 0 {
return ""
}
var rows []string
for i, item := range m.MenuItems {
if i == m.Selected {
prefix := "▸ "
rows = append(rows, styles.ButtonActiveStyle.Render(prefix+item.Label))
} else {
prefix := " "
rows = append(rows, styles.ButtonInactiveStyle.Render(prefix+item.Label))
}
}
return lipgloss.JoinVertical(lipgloss.Left, rows...)
}
func (m TUIInterface) viewSettingsPage() string {
if m.SettingsEditMode != "" {
return m.renderSettingsForm()
}
if m.SettingsConfirmDelete {
return m.renderSettingsDeleteConfirm()
}
return m.renderSettingsList()
}
func (m TUIInterface) renderSettingsList() string {
if len(m.SettingsServers) == 0 {
return styles.EmptyListStyle.Render("No servers configured")
}
var rows []string
for i, srv := range m.SettingsServers {
if i == m.SettingsSelected {
name := styles.ServerItemActiveStyle.Render(srv.Name)
host := styles.ServerItemActiveStyle.Render(srv.Host)
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, "▸ ", name, styles.ServerItemInactiveStyle.Render(" · "), host))
} else {
name := styles.ServerItemInactiveStyle.Render(srv.Name)
host := styles.ServerItemInactiveStyle.Render(srv.Host)
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, " ", name, styles.ServerItemInactiveStyle.Render(" · "), host))
}
}
return styles.ServerListStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...))
}
func (m TUIInterface) renderSettingsForm() string {
title := styles.FormTitleStyle.Render(
func() string {
if m.SettingsEditMode == "add" {
return "Add Server"
}
return "Edit Server"
}(),
)
nameLabel := styles.FormLabelStyle.Render("Name:")
nameInput := func() string {
if m.SettingsFormField == 0 {
return styles.FormInputActiveStyle.Render(m.SettingsFormName + "_")
}
return styles.FormInputInactiveStyle.Render(m.SettingsFormName)
}()
hostLabel := styles.FormLabelStyle.Render("Host:")
hostInput := func() string {
if m.SettingsFormField == 1 {
return styles.FormInputActiveStyle.Render(m.SettingsFormHost + "_")
}
return styles.FormInputInactiveStyle.Render(m.SettingsFormHost)
}()
saveBtn := func() string {
if m.SettingsFormField == 2 {
return styles.FormButtonActiveStyle.Render("Save")
}
return styles.FormButtonInactiveStyle.Render("Save")
}()
cancelBtn := func() string {
if m.SettingsFormField == 3 {
return styles.FormButtonActiveStyle.Render("Cancel")
}
return styles.FormButtonInactiveStyle.Render("Cancel")
}()
buttons := lipgloss.JoinHorizontal(lipgloss.Left, saveBtn, " ", cancelBtn)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
lipgloss.JoinHorizontal(lipgloss.Top, nameLabel, " ", nameInput),
lipgloss.JoinHorizontal(lipgloss.Top, hostLabel, " ", hostInput),
"",
buttons,
)
return styles.CardInnerStyle.Render(content)
}
func (m TUIInterface) renderSettingsDeleteConfirm() string {
if m.SettingsSelected < 0 || m.SettingsSelected >= len(m.SettingsServers) {
return ""
}
srv := m.SettingsServers[m.SettingsSelected]
title := styles.ConfirmTitleStyle.Render("Delete Server?")
message := styles.ConfirmStyle.Render("Delete \"" + srv.Name + "\"?")
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
"",
message,
)
return styles.CardInnerStyle.Render(content)
}
func (m TUIInterface) viewSelectServerPage() string {
return m.renderSelectServerList()
}
func (m TUIInterface) renderSelectServerList() string {
if len(m.SelectServerServers) == 0 {
return styles.EmptyListStyle.Render("No servers available")
}
var rows []string
for i, srv := range m.SelectServerServers {
isSelected := m.HasSelectedServer && srv.ID == m.SelectedServer.ID
if i == m.SelectServerSelected {
prefix := "▸ "
row := prefix + srv.Name
if isSelected {
row += " " + styles.SelectedMarkerStyle.Render("✓")
}
rows = append(rows, styles.SelectItemActiveStyle.Render(row))
} else {
prefix := " "
row := prefix + srv.Name
if isSelected {
row += " " + styles.SelectedMarkerStyle.Render("✓")
}
rows = append(rows, styles.SelectItemInactiveStyle.Render(row))
}
}
return styles.SelectListStyle.Render(lipgloss.JoinVertical(lipgloss.Left, rows...))
}