Compare commits

..

12 Commits

Author SHA1 Message Date
Calmcacil
9c0da13a19 bd sync: 2026-01-12 23:44:57 2026-01-12 23:44:57 +01:00
Calmcacil
99b9dc17db Enhance search with match highlighting, count display, Ctrl+U to clear 2026-01-12 23:43:24 +01:00
Calmcacil
6629598574 bd sync: 2026-01-12 23:42:48 2026-01-12 23:42:48 +01:00
Calmcacil
50321a8471 Add connection quality indicators based on handshake time 2026-01-12 23:42:38 +01:00
Calmcacil
bf71a7a659 bd sync: 2026-01-12 23:41:27 2026-01-12 23:41:27 +01:00
Calmcacil
f154c7ff69 Standardize TUI formatting and styling across all screens 2026-01-12 23:41:07 +01:00
Calmcacil
0476f1e227 bd sync: 2026-01-12 23:40:16 2026-01-12 23:40:16 +01:00
Calmcacil
1187ae0046 Add screen transition animations for polished UX 2026-01-12 23:40:01 +01:00
Calmcacil
b7ddd54cf6 bd sync: 2026-01-12 23:34:33 2026-01-12 23:34:33 +01:00
Calmcacil
5b8b9b66f5 Add keyboard shortcut discoverability hints on each screen 2026-01-12 23:34:14 +01:00
Calmcacil
f0e26e4a0a bd sync: 2026-01-12 23:24:02 2026-01-12 23:24:02 +01:00
Calmcacil
dd62458515 Add text selection and copy capability to terminal UI 2026-01-12 23:23:48 +01:00
14 changed files with 775 additions and 207 deletions

File diff suppressed because one or more lines are too long

View File

@@ -223,6 +223,23 @@ Then run:
sudo ~/wireguard.sh load-clients sudo ~/wireguard.sh load-clients
``` ```
## Text Selection & Copying
To copy client configurations or other text from the terminal UI:
### Text Selection
- Hold **SHIFT key** while dragging your mouse with the left button
- This bypasses TUI mouse handling and enables your terminal's native text selection
- Then use your terminal's copy shortcut:
- **Linux/WSL**: Ctrl+Shift+C (or terminal-specific shortcut)
- **macOS**: Cmd+C
- **Windows**: Click right (or use terminal copy)
### Copy Buttons (when available)
- Some modals may have explicit copy buttons (e.g., "C" to copy config)
- These work when clipboard API is available (native Linux, macOS, WSL)
- Over SSH requires X11 forwarding (`ssh -X`) for clipboard access
## Client Setup ## Client Setup
### Importing the config ### Importing the config

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -13,6 +14,16 @@ import (
const version = "0.1.0" const version = "0.1.0"
// TickMsg is sent for transition animation frames
type TickMsg time.Time
// Transition settings
const (
transitionDuration = 200 * time.Millisecond
transitionFPS = 60
)
var ( var (
styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true) styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true)
styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
@@ -22,6 +33,13 @@ var (
) )
type model struct { type model struct {
transitionFrom screens.Screen
transitionTo screens.Screen
transitionProgress float64
transitionAnimating bool
transitionStartTime time.Time
transitionType screens.TransitionType
transitionDuration time.Duration
currentScreen screens.Screen currentScreen screens.Screen
previousScreen screens.Screen previousScreen screens.Screen
quitting bool quitting bool
@@ -38,6 +56,8 @@ func (m model) Init() tea.Cmd {
return nil return nil
} }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle initialization on first update after Init // Handle initialization on first update after Init
if !m.initialized { if !m.initialized {
@@ -46,92 +66,114 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.currentScreen.Init() return m, m.currentScreen.Init()
} }
// Handle transition animation updates
if m.transitionAnimating {
switch msg.(type) {
case TickMsg:
// Update transition progress
elapsed := time.Since(m.transitionStartTime)
m.transitionProgress = float64(elapsed) / float64(m.transitionDuration)
// Ease-out cubic function for smoother animation
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
if m.transitionProgress >= 1 || easeProgress >= 1 {
// Transition complete
m.transitionAnimating = false
m.currentScreen = m.transitionTo
m.previousScreen = m.transitionFrom
m.transitionFrom = nil
m.transitionTo = nil
return m, nil
}
// Continue animation
return m, tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
return TickMsg(t)
})
}
// During transition, don't process other messages
return m, nil
}
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
// Only quit on 'q' or ctrl+c when on list screen // Only quit on 'q' or ctrl+c when on list screen
// Other screens handle their own navigation keys
if msg.String() == "q" || msg.String() == "ctrl+c" { if msg.String() == "q" || msg.String() == "ctrl+c" {
if _, ok := m.currentScreen.(*screens.ListScreen); ok { if _, ok := m.currentScreen.(*screens.ListScreen); ok {
m.quitting = true m.quitting = true
return m, tea.Quit return m, tea.Quit
} }
// For other screens, let them handle the key
} }
switch msg.String() { switch msg.String() {
case "?": case "?":
// Switch to help screen cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade)
m.previousScreen = m.currentScreen return m, cmd
m.currentScreen = screens.NewHelpScreen(m.previousScreen)
return m, m.currentScreen.Init()
case "l": case "l":
// Switch to list screen cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
m.currentScreen = screens.NewListScreen() return m, cmd
return m, m.currentScreen.Init()
case "a": case "a":
// Switch to add screen cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft)
m.previousScreen = m.currentScreen return m, cmd
m.currentScreen = screens.NewAddScreen()
return m, m.currentScreen.Init()
} }
case screens.ClientSelectedMsg: case screens.ClientSelectedMsg:
// User selected a client - show detail screen cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft)
m.previousScreen = m.currentScreen return m, cmd
m.currentScreen = screens.NewDetailScreen(msg.Client.Client)
return m, m.currentScreen.Init()
case screens.ClientDeletedMsg: case screens.ClientDeletedMsg:
// Client was deleted - show success message and return to list cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
m.currentScreen = screens.NewListScreen() return m, cmd
return m, m.currentScreen.Init()
case screens.ClientCreatedMsg: case screens.ClientCreatedMsg:
// Client was created - return to list screen cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
m.currentScreen = screens.NewListScreen() return m, cmd
return m, m.currentScreen.Init()
case screens.RestoreCompletedMsg: case screens.RestoreCompletedMsg:
// Restore completed - return to list screen to refresh clients cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
m.currentScreen = screens.NewListScreen() return m, cmd
return m, m.currentScreen.Init()
case screens.CloseDetailScreenMsg: case screens.CloseDetailScreenMsg:
// Detail screen closed - go back to previous screen
if m.previousScreen != nil { if m.previousScreen != nil {
m.currentScreen = m.previousScreen cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
m.previousScreen = nil return m, cmd
return m, m.currentScreen.Init()
} }
m.currentScreen = screens.NewListScreen() cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, m.currentScreen.Init() return m, cmd
case screens.ErrMsg: case screens.ErrMsg:
// An error occurred - show error screen
m.previousScreen = m.currentScreen m.previousScreen = m.currentScreen
m.errorScreen = screens.NewErrorScreen(msg.Err) m.errorScreen = screens.NewErrorScreen(msg.Err)
m.currentScreen = m.errorScreen cmd := m.startTransition(m.errorScreen, screens.TransitionFade)
return m, m.currentScreen.Init() return m, cmd
} }
// Pass messages to current screen // Pass messages to current screen
if m.currentScreen != nil { if m.currentScreen != nil {
newScreen, cmd := m.currentScreen.Update(msg) newScreen, cmd := m.currentScreen.Update(msg)
// If screen returns nil, go back to previous screen
if newScreen == nil { if newScreen == nil {
if m.previousScreen != nil { if m.previousScreen != nil {
m.currentScreen = m.previousScreen transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
m.previousScreen = nil return m, tea.Batch(cmd, transitionCmd)
} }
} else if newScreen != m.currentScreen { } else if newScreen != m.currentScreen {
// Screen is switching to a different screen
m.previousScreen = m.currentScreen m.previousScreen = m.currentScreen
m.currentScreen = newScreen transitionCmd := m.startTransition(newScreen, screens.TransitionSlideLeft)
return m, tea.Batch(cmd, transitionCmd)
} }
return m, cmd return m, cmd
} }
return m, nil return m, nil
} }
}
func (m model) View() string {
func (m model) View() string { func (m model) View() string {
if m.quitting { if m.quitting {
return "\nGoodbye!\n" return "\nGoodbye!\n"
} }
// If transitioning, render the transition animation
if m.transitionAnimating {
return renderTransition(m)
}
if m.currentScreen != nil && m.initialized { if m.currentScreen != nil && m.initialized {
return m.currentScreen.View() return m.currentScreen.View()
} }
@@ -142,6 +184,7 @@ func (m model) View() string {
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
} }
}
func main() { func main() {
p := tea.NewProgram(model{}, tea.WithAltScreen()) p := tea.NewProgram(model{}, tea.WithAltScreen())
@@ -150,3 +193,193 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
// startTransition begins a screen transition animation
func (m *model) startTransition(toScreen screens.Screen, ttype screens.TransitionType) tea.Cmd {
// Initialize new screen
var initCmd tea.Cmd
if toScreen != nil {
initCmd = toScreen.Init()
}
// Set up transition
m.transitionFrom = m.currentScreen
m.transitionTo = toScreen
m.transitionType = ttype
m.transitionDuration = transitionDuration
m.transitionProgress = 0
m.transitionAnimating = true
m.transitionStartTime = time.Now()
// Return initialization command and tick command
tickCmd := tea.Tick(time.Second/time.Duration(transitionFPS), func(t time.Time) tea.Msg {
return TickMsg(t)
})
if initCmd != nil {
return tea.Batch(initCmd, tickCmd)
}
return tickCmd
}
func renderTransition(m model) string {
if m.transitionFrom == nil || m.transitionTo == nil {
return m.transitionTo.View()
}
fromView := m.transitionFrom.View()
toView := m.transitionTo.View()
// Apply easing to progress
easeProgress := 1 - ((1 - m.transitionProgress) * (1 - m.transitionProgress) * (1 - m.transitionProgress))
switch m.transitionType {
case screens.TransitionFade:
return renderFadeTransition(fromView, toView, easeProgress)
case screens.TransitionSlideLeft:
return renderSlideTransition(fromView, toView, easeProgress, true)
case screens.TransitionSlideRight:
return renderSlideTransition(fromView, toView, easeProgress, false)
default:
return toView
}
}
func renderFadeTransition(fromView, toView string, progress float64) string {
fromLines := splitLines(fromView)
toLines := splitLines(toView)
maxLines := max(len(fromLines), len(toLines))
for len(fromLines) < maxLines {
fromLines = append(fromLines, "")
}
for len(toLines) < maxLines {
toLines = append(toLines, "")
}
var result []string
for i := 0; i < maxLines; i++ {
threshold := progress * float64(maxLines)
if float64(i) < threshold {
result = append(result, toLines[i])
} else {
result = append(result, fromLines[i])
}
}
return joinLines(result)
}
func renderSlideTransition(fromView, toView string, progress float64, slideLeft bool) string {
fromLines := splitLines(fromView)
toLines := splitLines(toView)
width := 80
for i := range fromLines {
fromLines[i] = padLine(fromLines[i], width)
}
for i := range toLines {
toLines[i] = padLine(toLines[i], width)
}
offset := int(float64(width) * progress)
var result []string
maxLines := max(len(fromLines), len(toLines))
for i := 0; i < maxLines; i++ {
var fromLine, toLine string
if i < len(fromLines) {
fromLine = fromLines[i]
} else {
fromLine = ""
}
if i < len(toLines) {
toLine = toLines[i]
} else {
toLine = ""
}
if slideLeft {
fromOffset := -offset
toOffset := width - offset
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
result = append(result, combined)
} else {
fromOffset := offset
toOffset := -width + offset
combined := combineSlidingLines(fromLine, toLine, fromOffset, toOffset, width)
result = append(result, combined)
}
}
return joinLines(result)
}
func combineSlidingLines(from, to string, fromOffset, toOffset, width int) string {
result := make([]byte, width)
for i := range result {
result[i] = ' '
}
if fromOffset >= -len(from) && fromOffset < width {
for i := 0; i < len(from) && i+fromOffset < width && i+fromOffset >= 0; i++ {
if i+fromOffset >= 0 && i+fromOffset < width {
result[i+fromOffset] = from[i]
}
}
}
if toOffset >= -len(to) && toOffset < width {
for i := 0; i < len(to) && i+toOffset < width && i+toOffset >= 0; i++ {
if i+toOffset >= 0 && i+toOffset < width {
result[i+toOffset] = to[i]
}
}
}
return string(result)
}
func splitLines(s string) []string {
lines := make([]string, 0)
current := ""
for _, c := range s {
if c == '\n' {
lines = append(lines, current)
current = ""
} else {
current += string(c)
}
}
if current != "" {
lines = append(lines, current)
}
return lines
}
func joinLines(lines []string) string {
result := ""
for i, line := range lines {
if i > 0 {
result += "\n"
}
result += line
}
return result
}
func padLine(line string, width int) string {
if len(line) >= width {
return line[:width]
}
return fmt.Sprintf("%-*s", width, line)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -3,6 +3,7 @@ package components
import ( import (
"fmt" "fmt"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -18,37 +19,12 @@ type DeleteConfirmModel struct {
showErrorMessage bool showErrorMessage bool
} }
// Styles // Local styles for modal
var ( var (
deleteModalStyle = lipgloss.NewStyle(). modalBaseStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("196")).Background(lipgloss.Color("235"))
Border(lipgloss.RoundedBorder()). modalTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")).Bold(true)
BorderForeground(lipgloss.Color("196")). modalMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
Padding(1, 3). modalHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
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)
) )
// NewDeleteConfirm creates a new deletion confirmation modal // NewDeleteConfirm creates a new deletion confirmation modal
@@ -108,12 +84,12 @@ func (m *DeleteConfirmModel) View() string {
matches := m.input.Value() == m.clientName matches := m.input.Value() == m.clientName
// Build warning section // Build warning section
warningText := deleteWarningStyle.Render( warningText := theme.StyleError.Bold(true).MarginTop(1).Render(
fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName), fmt.Sprintf("⚠️ This will permanently delete client '%s'", m.clientName),
) )
// Build message section // 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."), 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( inputSection := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
"", "",
deleteInputStyle.Render("Client name:"), theme.StyleValue.Width(60).Render("Client name:"),
m.input.View(), m.input.View(),
) )
// Build status section // Build status section
var statusText string var statusText string
if matches { 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 { } 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() != "" { } else if m.input.Value() != "" {
statusText = deleteHelpStyle.Render("Client name does not match yet...") statusText = modalHelpStyle.Render("Client name does not match yet...")
} else { } else {
statusText = deleteHelpStyle.Render("Type the client name to enable confirmation.") statusText = modalHelpStyle.Render("Type the client name to enable confirmation.")
} }
// Build help section // 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 // Build modal content
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(
lipgloss.Left, lipgloss.Left,
deleteTitleStyle.Render("🗑️ Delete Client"), modalTitleStyle.Render("🗑️ Delete Client"),
"", "",
warningText, warningText,
"", "",
@@ -155,7 +131,7 @@ func (m *DeleteConfirmModel) View() string {
) )
// Apply modal style // Apply modal style
modal := deleteModalStyle.Render(content) modal := modalBaseStyle.Padding(1, 3).Render(content)
// Center modal on screen // Center modal on screen
modalWidth := lipgloss.Width(modal) modalWidth := lipgloss.Width(modal)
@@ -174,7 +150,7 @@ func (m *DeleteConfirmModel) View() string {
lipgloss.Left, lipgloss.Top, lipgloss.Left, lipgloss.Top,
modal, modal,
lipgloss.WithWhitespaceChars(" "), lipgloss.WithWhitespaceChars(" "),
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")), lipgloss.WithWhitespaceForeground(theme.StyleBackground),
) )
} }

View File

@@ -1,6 +1,9 @@
package components package components
import ( import (
"fmt"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
@@ -29,13 +32,15 @@ type SearchModel struct {
} }
// Styles // Styles
// Styles (using theme package)
var ( var (
searchBarStyle = lipgloss.NewStyle(). searchBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")). Foreground(lipgloss.Color("255")).
Background(lipgloss.Color("235")). Background(theme.StyleBackground).
Padding(0, 1). Padding(0, 1).
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")) BorderForeground(theme.StyleBorder)
searchPromptStyle = lipgloss.NewStyle(). searchPromptStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")). Foreground(lipgloss.Color("226")).
Bold(true) Bold(true)
@@ -87,6 +92,10 @@ func (m *SearchModel) Update(msg tea.Msg) (*SearchModel, tea.Cmd) {
m.input.Reset() m.input.Reset()
m.matchCount = m.totalCount m.matchCount = m.totalCount
return m, nil return m, nil
case "ctrl+u":
m.input.Reset()
m.matchCount = m.totalCount
return m, nil
case "tab": case "tab":
m.cycleFilterType() m.cycleFilterType()
return m, nil return m, nil
@@ -140,7 +149,7 @@ func (m *SearchModel) View() string {
helpText := "" helpText := ""
if m.active { if m.active {
helpText = searchHelpStyle.Render(" | Tab: filter | Esc: clear") helpText = searchHelpStyle.Render(" | Tab: filter | Ctrl+U: clear | Esc: exit")
} else { } else {
helpText = searchHelpStyle.Render(" | /: search") helpText = searchHelpStyle.Render(" | /: search")
} }
@@ -263,13 +272,13 @@ func (m *SearchModel) HighlightMatches(value string) string {
Bold(true) Bold(true)
before := value[:index] before := value[:index]
match := value[index+len(query)] match := value[index : index+len(query)]
after := value[index+len(query):] after := value[index+len(query):]
return lipgloss.JoinHorizontal( return lipgloss.JoinHorizontal(
lipgloss.Left, lipgloss.Left,
before, before,
matchStyle.Render(string(match)), matchStyle.Render(match),
after, after,
) )
} }
@@ -295,7 +304,7 @@ func (m *SearchModel) renderCount(count int) string {
Foreground(lipgloss.Color("196")). Foreground(lipgloss.Color("196")).
Render("No matches") Render("No matches")
} }
return searchCountStyle.Render(string(rune('0' + count))) return searchCountStyle.Render(fmt.Sprintf("%d", count))
} }
// ClientData represents client data for filtering // ClientData represents client data for filtering

View File

@@ -152,7 +152,9 @@ func (s *DetailScreen) renderContent() string {
statusText := s.status statusText := s.status
if s.status == wireguard.StatusConnected { if s.status == wireguard.StatusConnected {
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status) duration := time.Since(s.lastHandshake)
quality := wireguard.CalculateQuality(duration)
statusText = theme.StyleSuccess.Bold(true).Render("● " + s.status + " (" + quality + ")")
} else { } else {
statusText = theme.StyleError.Bold(true).Render("● " + s.status) statusText = theme.StyleError.Bold(true).Render("● " + s.status)
} }
@@ -184,8 +186,8 @@ func (s *DetailScreen) renderContent() string {
"", "",
) )
// Add help text // Add help text with all keyboard shortcuts
helpText := theme.StyleHelpKey.MarginTop(1).Render("Actions: [d] Delete • [c] View Config • [q/b] Back") helpText := theme.StyleHelpKey.MarginTop(1).Render("Shortcuts: [d] Delete • [c] View Config • [q/b/esc] Back")
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText) content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
return content return content

View File

@@ -2,6 +2,7 @@ package screens
import ( import (
"github.com/calmcacil/wg-admin/internal/tui/components" "github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/charmbracelet/bubbletea" "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -41,55 +42,45 @@ func (s *HelpScreen) View() string {
// Breadcrumb: Help // Breadcrumb: Help
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}}) breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Help", ID: "help"}})
// Styles using theme
// Styles
borderStyle := lipgloss.NewStyle(). borderStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")). BorderForeground(theme.StyleBorder).
Padding(1, 2) Padding(1, 2)
headerStyle := lipgloss.NewStyle(). keyStyle := theme.StyleHelpKey.Width(12)
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)
descStyle := lipgloss.NewStyle(). descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("250")) Foreground(lipgloss.Color("250"))
// Header // Header
header := headerStyle.Render("Keyboard Shortcuts") header := theme.StyleTitle.MarginBottom(1).Render("Keyboard Shortcuts")
// Shortcut groups // 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("j / ↓") + descStyle.Render("Move down") + "\n" +
keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" + keyStyle.Render("k / ↑") + descStyle.Render("Move up") + "\n" +
keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" + keyStyle.Render("← →") + descStyle.Render("Move left/right") + "\n" +
keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" + keyStyle.Render("Enter") + descStyle.Render("Select") + "\n" +
keyStyle.Render("Esc") + descStyle.Render("Go back") 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("a") + descStyle.Render("Add client") + "\n" +
keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" + keyStyle.Render("d") + descStyle.Render("Delete client") + "\n" +
keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" + keyStyle.Render("Q") + descStyle.Render("Show QR code") + "\n" +
keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" + keyStyle.Render("r") + descStyle.Render("Refresh list") + "\n" +
keyStyle.Render("l") + descStyle.Render("List view") 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("Show this help") + "\n" +
keyStyle.Render("/") + descStyle.Render("Search") + "\n" + keyStyle.Render("/") + descStyle.Render("Search") + "\n" +
keyStyle.Render("q") + descStyle.Render("Quit") keyStyle.Render("q") + descStyle.Render("Quit")
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)")
// Two-column layout // Two-column layout
leftColumn := lipgloss.JoinVertical(lipgloss.Left, leftColumn := lipgloss.JoinVertical(lipgloss.Left,
navigationGroup, navigationGroup,
@@ -99,15 +90,14 @@ func (s *HelpScreen) View() string {
rightColumn := lipgloss.JoinVertical(lipgloss.Left, rightColumn := lipgloss.JoinVertical(lipgloss.Left,
otherGroup, otherGroup,
"",
copyGroup,
) )
content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn) content := lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, " ", rightColumn)
// Footer // Footer
footerStyle := lipgloss.NewStyle(). footer := theme.StyleMuted.MarginTop(1).Render("Press q or Esc to return")
Foreground(lipgloss.Color("241")).
MarginTop(1)
footer := footerStyle.Render("Press q or Esc to return")
// Combine all // Combine all
return breadcrumb + "\n\n" + borderStyle.Render( return breadcrumb + "\n\n" + borderStyle.Render(

View File

@@ -4,6 +4,16 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
// TransitionType defines the type of transition animation
type TransitionType int
const (
TransitionNone TransitionType = iota
TransitionFade
TransitionSlideLeft
TransitionSlideRight
)
// Screen represents a UI screen (list, add, detail, etc.) // Screen represents a UI screen (list, add, detail, etc.)
type Screen interface { type Screen interface {
Init() tea.Cmd Init() tea.Cmd

View File

@@ -30,6 +30,7 @@ type ListScreen struct {
type ClientWithStatus struct { type ClientWithStatus struct {
Client wireguard.Client Client wireguard.Client
Status string Status string
Quality string
} }
// NewListScreen creates a new list screen // NewListScreen creates a new list screen
@@ -204,7 +205,13 @@ func (s *ListScreen) View() string {
Foreground(lipgloss.Color("241")). Foreground(lipgloss.Color("241")).
Render("Last updated: " + timeAgo) Render("Last updated: " + timeAgo)
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText // Add keyboard shortcuts help
helpText := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1).
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
} }
// formatDuration returns a human-readable string for the duration // formatDuration returns a human-readable string for the duration
@@ -218,6 +225,8 @@ func formatDuration(d time.Duration) string {
return fmt.Sprintf("%d hr ago", int(d.Hours())) return fmt.Sprintf("%d hr ago", int(d.Hours()))
} }
// loadClients loads clients from wireguard config
// loadClients loads clients from wireguard config // loadClients loads clients from wireguard config
func (s *ListScreen) loadClients() tea.Msg { func (s *ListScreen) loadClients() tea.Msg {
clients, err := wireguard.ListClients() clients, err := wireguard.ListClients()
@@ -225,16 +234,31 @@ func (s *ListScreen) loadClients() tea.Msg {
return ErrMsg{Err: err} 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)) clientsWithStatus := make([]ClientWithStatus, len(clients))
for i, client := range clients { for i, client := range clients {
status, err := wireguard.GetClientStatus(client.PublicKey) status := wireguard.StatusDisconnected
if err != nil { quality := ""
status = wireguard.StatusDisconnected
// Find matching peer status
for _, peerStatus := range peerStatuses {
if peerStatus.PublicKey == client.PublicKey {
status = peerStatus.Status
quality = peerStatus.Quality
break
} }
}
clientsWithStatus[i] = ClientWithStatus{ clientsWithStatus[i] = ClientWithStatus{
Client: client, Client: client,
Status: status, Status: status,
Quality: quality,
} }
} }
@@ -274,8 +298,11 @@ func (s *ListScreen) applyFilter() {
} }
// formatStatusWithIcon formats the status with a colored circle icon // 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 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("46")).Render("●") + " " + status
} }
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
@@ -298,11 +325,30 @@ func (s *ListScreen) buildTable() {
var rows []table.Row var rows []table.Row
for _, cws := range displayClients { 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, cws.Quality)
row := table.Row{ row := table.Row{
cws.Client.Name, name,
cws.Client.IPv4, ipv4,
cws.Client.IPv6, ipv6,
statusText, statusText,
} }
rows = append(rows, row) rows = append(rows, row)

View File

@@ -0,0 +1,220 @@
lastUpdatedText := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Render("Last updated: " + timeAgo)
// Add keyboard shortcuts help
helpText := lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1).
Render("Shortcuts: [?/h] Help • [a] Add • [R] Restore • [r] Refresh • [Q] QR Code • [/] Search • [1-4] Sort • [Enter] Details • [q] Quit")
return breadcrumb + "\n" + s.search.View() + "\n" + s.table.View() + "\n" + lastUpdatedText + "\n" + helpText
}
// formatDuration returns a human-readable string for the duration
func formatDuration(d time.Duration) string {
if d < time.Minute {
return "just now"
}
if d < time.Hour {
return fmt.Sprintf("%d min ago", int(d.Minutes()))
}
return fmt.Sprintf("%d hr ago", int(d.Hours()))
}
// loadClients loads clients from wireguard config
func (s *ListScreen) loadClients() tea.Msg {
clients, err := wireguard.ListClients()
if err != nil {
return ErrMsg{Err: err}
}
// Get status for each client
clientsWithStatus := make([]ClientWithStatus, len(clients))
for i, client := range clients {
status, err := wireguard.GetClientStatus(client.PublicKey)
if err != nil {
status = wireguard.StatusDisconnected
}
clientsWithStatus[i] = ClientWithStatus{
Client: client,
Status: status,
}
}
return clientsLoadedMsg{clients: clientsWithStatus}
}
// applyFilter applies the current search filter to clients
func (s *ListScreen) applyFilter() {
// Convert clients to ClientData for filtering
clientData := make([]components.ClientData, len(s.clients))
for i, cws := range s.clients {
clientData[i] = components.ClientData{
Name: cws.Client.Name,
IPv4: cws.Client.IPv4,
IPv6: cws.Client.IPv6,
Status: cws.Status,
}
}
// Filter clients
filteredData := s.search.Filter(clientData)
// Convert back to ClientWithStatus
s.filtered = make([]ClientWithStatus, len(filteredData))
for i, cd := range filteredData {
// Find the matching client
for _, cws := range s.clients {
if cws.Client.Name == cd.Name {
s.filtered[i] = cws
break
}
}
}
// Rebuild table with filtered clients
s.buildTable()
}
// formatStatusWithIcon formats the status with a colored circle icon
func (s *ListScreen) formatStatusWithIcon(status string) string {
if status == wireguard.StatusConnected {
return lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("●") + " " + status
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("●") + " " + status
}
// buildTable creates and configures the table
func (s *ListScreen) buildTable() {
columns := []table.Column{
{Title: "Name", Width: 20},
{Title: "IPv4", Width: 15},
{Title: "IPv6", Width: 35},
{Title: "Status", Width: 14},
}
// Use filtered clients if search is active, otherwise use all clients
displayClients := s.filtered
if !s.search.IsActive() {
displayClients = s.clients
}
var rows []table.Row
for _, cws := range displayClients {
statusText := s.formatStatusWithIcon(cws.Status)
row := table.Row{
cws.Client.Name,
cws.Client.IPv4,
cws.Client.IPv6,
statusText,
}
rows = append(rows, row)
}
// Sort rows based on current sort settings
s.sortRows(rows)
// Determine table height
tableHeight := len(rows) + 2 // Header + rows
if tableHeight < 5 {
tableHeight = 5
}
s.table = table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
)
// Apply styles
s.setTableStyles()
}
// setTableStyles applies styling to the table
func (s *ListScreen) 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)
s.table.SetStyles(styles)
}
// sortRows sorts the rows based on the current sort settings
func (s *ListScreen) sortRows(rows []table.Row) {
colIndex := s.getColumnIndex(s.sortedBy)
sort.Slice(rows, func(i, j int) bool {
var valI, valJ string
if colIndex < len(rows[i]) {
valI = rows[i][colIndex]
}
if colIndex < len(rows[j]) {
valJ = rows[j][colIndex]
}
if s.ascending {
return strings.ToLower(valI) < strings.ToLower(valJ)
}
return strings.ToLower(valI) > strings.ToLower(valJ)
})
}
// sortByColumn changes the sort column
func (s *ListScreen) sortByColumn(col string) {
sortedBy := "Name"
switch col {
case "1":
sortedBy = "Name"
case "2":
sortedBy = "IPv4"
case "3":
sortedBy = "IPv6"
case "4":
sortedBy = "Status"
}
// Toggle direction if clicking same column
if s.sortedBy == sortedBy {
s.ascending = !s.ascending
} else {
s.sortedBy = sortedBy
s.ascending = true
}
s.buildTable()
}
// getColumnIndex returns the index of a column by name
func (s *ListScreen) getColumnIndex(name string) int {
switch name {
case "Name":
return 0
case "IPv4":
return 1
case "IPv6":
return 2
case "Status":
return 3
}
return 0
}
// Messages
// clientsLoadedMsg is sent when clients are loaded
type clientsLoadedMsg struct {
clients []ClientWithStatus
}
// ErrMsg is sent when an error occurs
type ErrMsg struct {
Err error
}

View File

@@ -5,6 +5,7 @@ import (
"strings" "strings"
"github.com/calmcacil/wg-admin/internal/tui/components" "github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/tui/theme"
"github.com/calmcacil/wg-admin/internal/wireguard" "github.com/calmcacil/wg-admin/internal/wireguard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -102,39 +103,22 @@ func (s *QRScreen) generateQRCode() {
// renderQR renders the QR code with styling // renderQR renders the QR code with styling
func (s *QRScreen) renderQR() string { 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(). styleQR := lipgloss.NewStyle().
MarginLeft(2) 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" 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 // renderError renders an error message
func (s *QRScreen) renderError() string { func (s *QRScreen) renderError() string {
styleError := lipgloss.NewStyle(). title := theme.StyleError.Bold(true).Render("Error")
Foreground(lipgloss.Color("196")).
Bold(true)
styleHelp := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
title := styleError.Render("Error")
message := s.errorMsg message := s.errorMsg
help := "Press [q/Escape] to return" 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 // Messages

View File

@@ -6,6 +6,7 @@ import (
"github.com/calmcacil/wg-admin/internal/backup" "github.com/calmcacil/wg-admin/internal/backup"
"github.com/calmcacil/wg-admin/internal/tui/components" "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/spinner"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -26,27 +27,7 @@ type RestoreScreen struct {
spinner spinner.Model spinner spinner.Model
} }
// Styles // No local styles - all use theme package
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)
)
// NewRestoreScreen creates a new restore screen // NewRestoreScreen creates a new restore screen
func NewRestoreScreen() *RestoreScreen { func NewRestoreScreen() *RestoreScreen {
@@ -184,12 +165,11 @@ func (s *RestoreScreen) renderContent() string {
// Breadcrumb: Clients > Restore // Breadcrumb: Clients > Restore
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}}) breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}})
var content strings.Builder var content strings.Builder
content.WriteString(breadcrumb) content.WriteString(breadcrumb)
content.WriteString("\n") content.WriteString("\n")
content.WriteString(restoreTitleStyle.Render("Restore WireGuard Configuration")) content.WriteString(theme.StyleTitle.Render("Restore WireGuard Configuration"))
content.WriteString("\n\n") content.WriteString("\n\n")
if len(s.backups) == 0 && !s.isRestoring && s.message == "" { if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
@@ -198,23 +178,23 @@ func (s *RestoreScreen) renderContent() string {
} }
if s.isRestoring { 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() return content.String()
} }
if s.restoreSuccess { if s.restoreSuccess {
content.WriteString(restoreSuccessStyle.Render("✓ " + s.message)) content.WriteString(theme.StyleSuccess.Render("✓ " + s.message))
content.WriteString("\n\n") 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() return content.String()
} }
if s.restoreError != nil { if s.restoreError != nil {
content.WriteString(restoreErrorStyle.Render("✗ " + s.message)) content.WriteString(theme.StyleError.Render("✗ " + s.message))
content.WriteString("\n\n") content.WriteString("\n\n")
content.WriteString(s.table.View()) content.WriteString(s.table.View())
content.WriteString("\n\n") 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() return content.String()
} }
@@ -224,7 +204,7 @@ func (s *RestoreScreen) renderContent() string {
// Show selected backup details // Show selected backup details
if len(s.table.Rows()) > 0 && s.selectedBackup != nil { if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
content.WriteString(restoreInfoStyle.Render( content.WriteString(theme.StyleMuted.Render(
fmt.Sprintf( fmt.Sprintf(
"Selected: %s (%s) - %s\nSize: %s", "Selected: %s (%s) - %s\nSize: %s",
s.selectedBackup.Operation, s.selectedBackup.Operation,
@@ -236,7 +216,7 @@ func (s *RestoreScreen) renderContent() string {
content.WriteString("\n") 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() return content.String()
} }
@@ -284,15 +264,8 @@ func (s *RestoreScreen) buildTable() {
// setTableStyles applies styling to the table // setTableStyles applies styling to the table
func (s *RestoreScreen) setTableStyles() { func (s *RestoreScreen) setTableStyles() {
styles := table.DefaultStyles() styles := table.DefaultStyles()
styles.Header = styles.Header. styles.Header = theme.StyleTableHeader
BorderStyle(lipgloss.NormalBorder()). styles.Selected = theme.StyleTableSelected
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
styles.Selected = styles.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
s.table.SetStyles(styles) s.table.SetStyles(styles)
} }

View File

@@ -38,6 +38,12 @@ var (
StyleTitle lipgloss.Style StyleTitle lipgloss.Style
StyleSubtitle lipgloss.Style StyleSubtitle lipgloss.Style
StyleHelpKey lipgloss.Style StyleHelpKey lipgloss.Style
StyleValue lipgloss.Style
StyleDimmed lipgloss.Style
StyleTableHeader lipgloss.Style
StyleTableSelected lipgloss.Style
StyleBorder lipgloss.Color
StyleBackground lipgloss.Color
) )
// DefaultTheme is the standard blue-based theme // DefaultTheme is the standard blue-based theme
@@ -176,8 +182,83 @@ func ApplyTheme(theme *Theme) {
StyleHelpKey = lipgloss.NewStyle(). StyleHelpKey = lipgloss.NewStyle().
Foreground(theme.Scheme.Primary). Foreground(theme.Scheme.Primary).
Bold(true) Bold(true)
// Value style for content values
StyleValue = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
// Dimmed style for overlay content
StyleDimmed = lipgloss.NewStyle().
Foreground(theme.Scheme.Muted)
// Table header style
StyleTableHeader = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
// Table selected style
StyleTableSelected = lipgloss.NewStyle().
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
// Border color
StyleBorder = lipgloss.Color("240")
// Background color for modals
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 // GetThemeNames returns a list of available theme names
func GetThemeNames() []string { func GetThemeNames() []string {
names := make([]string, 0, len(ThemeRegistry)) names := make([]string, 0, len(ThemeRegistry))

View File

@@ -15,6 +15,15 @@ const (
StatusConnected = "Connected" StatusConnected = "Connected"
// StatusDisconnected indicates a peer is not connected // StatusDisconnected indicates a peer is not connected
StatusDisconnected = "Disconnected" StatusDisconnected = "Disconnected"
// QualityExcellent indicates handshake was very recent (< 30s)
QualityExcellent = "Excellent"
// QualityGood indicates handshake was recent (< 2m)
QualityGood = "Good"
// QualityFair indicates handshake was acceptable (< 5m)
QualityFair = "Fair"
// QualityPoor indicates handshake was old (> 5m)
QualityPoor = "Poor"
) )
// PeerStatus represents the status of a WireGuard peer // PeerStatus represents the status of a WireGuard peer
@@ -26,6 +35,7 @@ type PeerStatus struct {
TransferRx string `json:"transfer_rx"` TransferRx string `json:"transfer_rx"`
TransferTx string `json:"transfer_tx"` TransferTx string `json:"transfer_tx"`
Status string `json:"status"` // "Connected" or "Disconnected" Status string `json:"status"` // "Connected" or "Disconnected"
Quality string `json:"quality,omitempty"` // "Excellent", "Good", "Fair", "Poor" (if connected)
} }
// GetClientStatus checks if a specific client is connected // GetClientStatus checks if a specific client is connected
@@ -119,6 +129,20 @@ func parsePeersOutput(output string) []PeerStatus {
return peers return peers
} }
// CalculateQuality returns the connection quality based on handshake time
func CalculateQuality(timeSinceHandshake time.Duration) string {
if timeSinceHandshake < 30*time.Second {
return QualityExcellent
}
if timeSinceHandshake < 2*time.Minute {
return QualityGood
}
if timeSinceHandshake < 5*time.Minute {
return QualityFair
}
return QualityPoor
}
// finalizePeerStatus determines the peer's status based on handshake time // finalizePeerStatus determines the peer's status based on handshake time
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus { func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
peer.TransferRx = "" peer.TransferRx = ""
@@ -140,13 +164,16 @@ func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) Pee
} }
} }
// Determine status based on handshake // Determine status and quality based on handshake
if handshake != "" { if handshake != "" {
peer.LatestHandshake = parseHandshake(handshake) peer.LatestHandshake = parseHandshake(handshake)
timeSinceHandshake := time.Since(peer.LatestHandshake)
// Peer is considered connected if handshake is recent (within 5 minutes) // Peer is considered connected if handshake is recent (within 5 minutes)
// This allows for ~12 missed keepalive intervals (at 25 seconds each) // This allows for ~12 missed keepalive intervals (at 25 seconds each)
if time.Since(peer.LatestHandshake) < 5*time.Minute { if timeSinceHandshake < 5*time.Minute {
peer.Status = StatusConnected peer.Status = StatusConnected
peer.Quality = CalculateQuality(timeSinceHandshake)
} else { } else {
peer.Status = StatusDisconnected peer.Status = StatusDisconnected
} }