From f154c7ff691890ce82d2f9f9be8bfac5292354bc Mon Sep 17 00:00:00 2001 From: Calmcacil Date: Mon, 12 Jan 2026 23:41:07 +0100 Subject: [PATCH] Standardize TUI formatting and styling across all screens --- internal/tui/components/delete-confirm.go | 58 ++++++-------------- internal/tui/components/search.go | 23 ++++---- internal/tui/screens/help.go | 36 ++++-------- internal/tui/screens/list.go | 67 ++++++++++++++++++----- internal/tui/screens/qr.go | 26 ++------- internal/tui/screens/restore.go | 51 ++++------------- internal/tui/theme/theme.go | 48 ++++++++++++++++ internal/wireguard/status.go | 2 +- 8 files changed, 159 insertions(+), 152 deletions(-) diff --git a/internal/tui/components/delete-confirm.go b/internal/tui/components/delete-confirm.go index 21ffa14..4d0b45c 100644 --- a/internal/tui/components/delete-confirm.go +++ b/internal/tui/components/delete-confirm.go @@ -3,6 +3,7 @@ package components import ( "fmt" + "github.com/calmcacil/wg-admin/internal/tui/theme" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -18,37 +19,12 @@ type DeleteConfirmModel struct { showErrorMessage bool } -// Styles +// Local styles for modal var ( - deleteModalStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("196")). - Padding(1, 3). - Background(lipgloss.Color("235")) - deleteTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("226")). - Bold(true) - deleteMessageStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("255")). - Width(60) - deleteWarningStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). - Bold(true). - MarginTop(1) - deleteInputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("255")). - Width(60) - deleteHelpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - MarginTop(1) - deleteErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). - Bold(true). - MarginTop(1) - deleteSuccessStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("46")). - Bold(true). - MarginTop(1) + modalBaseStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("196")).Background(lipgloss.Color("235")) + modalTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true) + modalMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) + modalHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) ) // NewDeleteConfirm creates a new deletion confirmation modal @@ -108,12 +84,12 @@ func (m *DeleteConfirmModel) View() string { matches := m.input.Value() == m.clientName // Build warning section - warningText := deleteWarningStyle.Render( + warningText := theme.StyleError.Bold(true).MarginTop(1).Render( fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName), ) // Build message section - messageText := deleteMessageStyle.Render( + messageText := modalMessageStyle.Width(60).Render( fmt.Sprintf("This action cannot be undone. Please type the client name to confirm deletion."), ) @@ -121,29 +97,29 @@ func (m *DeleteConfirmModel) View() string { inputSection := lipgloss.JoinVertical( lipgloss.Left, "", - deleteInputStyle.Render("Client name:"), + theme.StyleValue.Width(60).Render("Client name:"), m.input.View(), ) // Build status section var statusText string if matches { - statusText = deleteSuccessStyle.Render("✓ Client name matches. Press Enter to confirm deletion.") + statusText = theme.StyleSuccess.Bold(true).MarginTop(1).Render("✓ Client name matches. Press Enter to confirm deletion.") } else if m.showErrorMessage { - statusText = deleteErrorStyle.Render("✗ Client name does not match. Please try again.") + statusText = theme.StyleError.Bold(true).MarginTop(1).Render("✗ Client name does not match. Please try again.") } else if m.input.Value() != "" { - statusText = deleteHelpStyle.Render("Client name does not match yet...") + statusText = modalHelpStyle.Render("Client name does not match yet...") } else { - statusText = deleteHelpStyle.Render("Type the client name to enable confirmation.") + statusText = modalHelpStyle.Render("Type the client name to enable confirmation.") } // Build help section - helpText := deleteHelpStyle.Render("Esc to cancel • Enter to confirm (when name matches)") + helpText := modalHelpStyle.Render("Esc to cancel • Enter to confirm (when name matches)") // Build modal content content := lipgloss.JoinVertical( lipgloss.Left, - deleteTitleStyle.Render("🗑️ Delete Client"), + modalTitleStyle.Render("🗑️ Delete Client"), "", warningText, "", @@ -155,7 +131,7 @@ func (m *DeleteConfirmModel) View() string { ) // Apply modal style - modal := deleteModalStyle.Render(content) + modal := modalBaseStyle.Padding(1, 3).Render(content) // Center modal on screen modalWidth := lipgloss.Width(modal) @@ -174,7 +150,7 @@ func (m *DeleteConfirmModel) View() string { lipgloss.Left, lipgloss.Top, modal, lipgloss.WithWhitespaceChars(" "), - lipgloss.WithWhitespaceForeground(lipgloss.Color("235")), + lipgloss.WithWhitespaceForeground(theme.StyleBackground), ) } diff --git a/internal/tui/components/search.go b/internal/tui/components/search.go index 608a6c0..c684c3c 100644 --- a/internal/tui/components/search.go +++ b/internal/tui/components/search.go @@ -1,6 +1,7 @@ package components import ( + "github.com/calmcacil/wg-admin/internal/tui/theme" "strconv" "strings" @@ -30,22 +31,24 @@ type SearchModel struct { } // Styles + +// Styles (using theme package) var ( searchBarStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("255")). - Background(lipgloss.Color("235")). - Padding(0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")) + Foreground(lipgloss.Color("255")). + Background(theme.StyleBackground). + Padding(0, 1). + Border(lipgloss.RoundedBorder()). + BorderForeground(theme.StyleBorder) searchPromptStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("226")). - Bold(true) + Foreground(lipgloss.Color("226")). + Bold(true) searchFilterStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("147")) + Foreground(lipgloss.Color("147")) searchCountStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) + Foreground(lipgloss.Color("241")) searchHelpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("243")) + Foreground(lipgloss.Color("243")) ) // NewSearch creates a new search component diff --git a/internal/tui/screens/help.go b/internal/tui/screens/help.go index 654dd96..14e6ae3 100644 --- a/internal/tui/screens/help.go +++ b/internal/tui/screens/help.go @@ -2,6 +2,7 @@ package screens import ( "github.com/calmcacil/wg-admin/internal/tui/components" + "github.com/calmcacil/wg-admin/internal/tui/theme" "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -41,55 +42,41 @@ func (s *HelpScreen) View() string { // Breadcrumb: Help breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}}) - // Styles + // Styles using theme borderStyle := lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). + BorderForeground(theme.StyleBorder). Padding(1, 2) - headerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("62")). - Bold(true). - MarginBottom(1) - - categoryStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("226")). - Bold(true). - MarginTop(1). - MarginBottom(0) - - keyStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("63")). - Bold(true). - Width(12) + keyStyle := theme.StyleHelpKey.Width(12) descStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("250")) // Header - header := headerStyle.Render("Keyboard Shortcuts") + header := theme.StyleTitle.MarginBottom(1).Render("Keyboard Shortcuts") // Shortcut groups - navigationGroup := categoryStyle.Render("Navigation") + "\n" + + navigationGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Navigation") + "\n" + keyStyle.Render("j / ↓") + descStyle.Render("Move down") + "\n" + keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" + keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" + keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" + keyStyle.Render("Esc") + descStyle.Render("Go back") - actionsGroup := categoryStyle.Render("Actions") + "\n" + + actionsGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Actions") + "\n" + keyStyle.Render("a") + descStyle.Render("Add client") + "\n" + keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" + keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" + keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" + keyStyle.Render("l") + descStyle.Render("List view") - otherGroup := categoryStyle.Render("Other") + "\n" + + otherGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Other") + "\n" + keyStyle.Render("?") + descStyle.Render("Show this help") + "\n" + keyStyle.Render("/") + descStyle.Render("Search") + "\n" + keyStyle.Render("q") + descStyle.Render("Quit") - copyGroup := categoryStyle.Render("Text Selection & Copy") + "\n" + + copyGroup := theme.StyleWarning.Bold(true).MarginTop(1).MarginBottom(0).Render("Text Selection & Copy") + "\n" + keyStyle.Render("SHIFT+drag") + descStyle.Render("Select text") + "\n" + keyStyle.Render("Ctrl+Shift+C") + descStyle.Render("Copy (Linux)") + "\n" + keyStyle.Render("Cmd+C") + descStyle.Render("Copy (macOS)") @@ -110,10 +97,7 @@ func (s *HelpScreen) View() string { content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn) // Footer - footerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - MarginTop(1) - footer := footerStyle.Render("Press q or Esc to return") + footer := theme.StyleMuted.MarginTop(1).Render("Press q or Esc to return") // Combine all return breadcrumb + "\n\n" + borderStyle.Render( diff --git a/internal/tui/screens/list.go b/internal/tui/screens/list.go index 8f7fff5..7204a52 100644 --- a/internal/tui/screens/list.go +++ b/internal/tui/screens/list.go @@ -28,8 +28,9 @@ type ListScreen struct { // ClientWithStatus wraps a client with its connection status type ClientWithStatus struct { - Client wireguard.Client - Status string + Client wireguard.Client + Status string + Quality string } // NewListScreen creates a new list screen @@ -204,7 +205,6 @@ func (s *ListScreen) View() string { Foreground(lipgloss.Color("241")). Render("Last updated: " + timeAgo) - // Add keyboard shortcuts help helpText := lipgloss.NewStyle(). Foreground(lipgloss.Color("63")). @@ -225,6 +225,8 @@ func formatDuration(d time.Duration) string { return fmt.Sprintf("%d hr ago", int(d.Hours())) } +// loadClients loads clients from wireguard config + // loadClients loads clients from wireguard config func (s *ListScreen) loadClients() tea.Msg { clients, err := wireguard.ListClients() @@ -232,16 +234,31 @@ func (s *ListScreen) loadClients() tea.Msg { return ErrMsg{Err: err} } - // Get status for each client + // Get all peer statuses to retrieve quality information + peerStatuses, err := wireguard.GetAllPeers() + if err != nil { + return ErrMsg{Err: err} + } + + // Match clients with their peer status clientsWithStatus := make([]ClientWithStatus, len(clients)) for i, client := range clients { - status, err := wireguard.GetClientStatus(client.PublicKey) - if err != nil { - status = wireguard.StatusDisconnected + status := wireguard.StatusDisconnected + quality := "" + + // Find matching peer status + for _, peerStatus := range peerStatuses { + if peerStatus.PublicKey == client.PublicKey { + status = peerStatus.Status + quality = peerStatus.Quality + break + } } + clientsWithStatus[i] = ClientWithStatus{ - Client: client, - Status: status, + Client: client, + Status: status, + Quality: quality, } } @@ -281,8 +298,11 @@ func (s *ListScreen) applyFilter() { } // formatStatusWithIcon formats the status with a colored circle icon -func (s *ListScreen) formatStatusWithIcon(status string) string { +func (s *ListScreen) formatStatusWithIcon(status string, quality string) string { if status == wireguard.StatusConnected { + if quality != "" { + return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status + " (" + quality + ")" + } return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status } return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status @@ -305,11 +325,30 @@ func (s *ListScreen) buildTable() { var rows []table.Row for _, cws := range displayClients { - statusText := s.formatStatusWithIcon(cws.Status) + // Apply highlighting based on filter type + name := cws.Client.Name + ipv4 := cws.Client.IPv4 + ipv6 := cws.Client.IPv6 + status := cws.Status + + if s.search.IsActive() { + switch s.search.GetFilterType() { + case components.FilterByName: + name = s.search.HighlightMatches(name) + case components.FilterByIPv4: + ipv4 = s.search.HighlightMatches(ipv4) + case components.FilterByIPv6: + ipv6 = s.search.HighlightMatches(ipv6) + case components.FilterByStatus: + status = s.search.HighlightMatches(status) + } + } + + statusText := s.formatStatusWithIcon(status, "") row := table.Row{ - cws.Client.Name, - cws.Client.IPv4, - cws.Client.IPv6, + name, + ipv4, + ipv6, statusText, } rows = append(rows, row) diff --git a/internal/tui/screens/qr.go b/internal/tui/screens/qr.go index fc3a440..47e853a 100644 --- a/internal/tui/screens/qr.go +++ b/internal/tui/screens/qr.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/calmcacil/wg-admin/internal/tui/components" + "github.com/calmcacil/wg-admin/internal/tui/theme" "github.com/calmcacil/wg-admin/internal/wireguard" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -102,39 +103,22 @@ func (s *QRScreen) generateQRCode() { // renderQR renders the QR code with styling func (s *QRScreen) renderQR() string { - styleTitle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("62")). - Bold(true). - MarginBottom(1) - - styleHelp := lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - MarginTop(1) - styleQR := lipgloss.NewStyle(). MarginLeft(2) - title := styleTitle.Render(fmt.Sprintf("QR Code: %s", s.clientName)) + title := theme.StyleTitle.MarginBottom(1).Render(fmt.Sprintf("QR Code: %s", s.clientName)) help := "Press [f] to toggle fullscreen • Press [q/Escape] to return" - return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + styleHelp.Render(help) + return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + theme.StyleMuted.MarginTop(1).Render(help) } // renderError renders an error message func (s *QRScreen) renderError() string { - styleError := lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). - Bold(true) - - styleHelp := lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - MarginTop(1) - - title := styleError.Render("Error") + title := theme.StyleError.Bold(true).Render("Error") message := s.errorMsg help := "Press [q/Escape] to return" - return title + "\n\n" + message + "\n" + styleHelp.Render(help) + return title + "\n\n" + message + "\n" + theme.StyleMuted.MarginTop(1).Render(help) } // Messages diff --git a/internal/tui/screens/restore.go b/internal/tui/screens/restore.go index 25a03fd..9095261 100644 --- a/internal/tui/screens/restore.go +++ b/internal/tui/screens/restore.go @@ -6,6 +6,7 @@ import ( "github.com/calmcacil/wg-admin/internal/backup" "github.com/calmcacil/wg-admin/internal/tui/components" + "github.com/calmcacil/wg-admin/internal/tui/theme" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" @@ -26,27 +27,7 @@ type RestoreScreen struct { spinner spinner.Model } -// Styles -var ( - restoreTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("62")). - Bold(true) - restoreHelpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("63")). - MarginTop(1) - restoreSuccessStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("46")). - Bold(true) - restoreErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). - Bold(true) - restoreInfoStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - MarginTop(1) - restoreLoadingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("62")). - Bold(true) -) +// No local styles - all use theme package // NewRestoreScreen creates a new restore screen func NewRestoreScreen() *RestoreScreen { @@ -184,12 +165,11 @@ func (s *RestoreScreen) renderContent() string { // Breadcrumb: Clients > Restore breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}}) - var content strings.Builder content.WriteString(breadcrumb) content.WriteString("\n") - content.WriteString(restoreTitleStyle.Render("Restore WireGuard Configuration")) + content.WriteString(theme.StyleTitle.Render("Restore WireGuard Configuration")) content.WriteString("\n\n") if len(s.backups) == 0 && !s.isRestoring && s.message == "" { @@ -198,23 +178,23 @@ func (s *RestoreScreen) renderContent() string { } if s.isRestoring { - content.WriteString(restoreLoadingStyle.Render(s.spinner.View() + " Restoring from backup, please wait...")) + content.WriteString(theme.StyleTitle.Render(s.spinner.View() + " Restoring from backup, please wait...")) return content.String() } if s.restoreSuccess { - content.WriteString(restoreSuccessStyle.Render("✓ " + s.message)) + content.WriteString(theme.StyleSuccess.Render("✓ " + s.message)) content.WriteString("\n\n") - content.WriteString(restoreInfoStyle.Render("Press 'q' to return to client list.")) + content.WriteString(theme.StyleMuted.Render("Press 'q' to return to client list.")) return content.String() } if s.restoreError != nil { - content.WriteString(restoreErrorStyle.Render("✗ " + s.message)) + content.WriteString(theme.StyleError.Render("✗ " + s.message)) content.WriteString("\n\n") content.WriteString(s.table.View()) content.WriteString("\n\n") - content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back")) + content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back")) return content.String() } @@ -224,7 +204,7 @@ func (s *RestoreScreen) renderContent() string { // Show selected backup details if len(s.table.Rows()) > 0 && s.selectedBackup != nil { - content.WriteString(restoreInfoStyle.Render( + content.WriteString(theme.StyleMuted.Render( fmt.Sprintf( "Selected: %s (%s) - %s\nSize: %s", s.selectedBackup.Operation, @@ -236,7 +216,7 @@ func (s *RestoreScreen) renderContent() string { content.WriteString("\n") } - content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back")) + content.WriteString(theme.StyleHelpKey.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back")) return content.String() } @@ -284,15 +264,8 @@ func (s *RestoreScreen) buildTable() { // setTableStyles applies styling to the table func (s *RestoreScreen) setTableStyles() { styles := table.DefaultStyles() - styles.Header = styles.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(true) - styles.Selected = styles.Selected. - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(false) + styles.Header = theme.StyleTableHeader + styles.Selected = theme.StyleTableSelected s.table.SetStyles(styles) } diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index 8183f94..b3a78d7 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -211,6 +211,54 @@ func ApplyTheme(theme *Theme) { StyleBackground = lipgloss.Color("235") } +// Modal styles +var ( + // ModalBaseStyle is the base style for all modals + ModalBaseStyle = func() lipgloss.Style { + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("196")). + Padding(1, 2). + Background(StyleBackground) + } + + // ModalTitleStyle is the style for modal titles + ModalTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("226")). + Bold(true) + + // ModalMessageStyle is the style for modal messages + ModalMessageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")). + Width(50) + + // ModalHelpStyle is the style for modal help text + ModalHelpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + MarginTop(1) + + // ModalSelectedStyle is the style for selected modal options + ModalSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("57")). + Bold(true). + Underline(true) + + // ModalUnselectedStyle is the style for unselected modal options + ModalUnselectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) +) + +// Status icon styles +var ( + // StatusConnectedStyle is the style for connected status icons + StatusConnectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("46")) + + // StatusDisconnectedStyle is the style for disconnected status icons + StatusDisconnectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")) +) + // GetThemeNames returns a list of available theme names func GetThemeNames() []string { names := make([]string, 0, len(ThemeRegistry)) diff --git a/internal/wireguard/status.go b/internal/wireguard/status.go index e9880ac..00a8420 100644 --- a/internal/wireguard/status.go +++ b/internal/wireguard/status.go @@ -173,7 +173,7 @@ func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) Pee // This allows for ~12 missed keepalive intervals (at 25 seconds each) if timeSinceHandshake < 5*time.Minute { peer.Status = StatusConnected - peer.Quality = calculateQuality(timeSinceHandshake) + peer.Quality = CalculateQuality(timeSinceHandshake) } else { peer.Status = StatusDisconnected }