Compare commits

...

13 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
Calmcacil
17f4d52c8a bd sync: 2026-01-12 23:21:29 2026-01-12 23:21:29 +01:00
14 changed files with 776 additions and 208 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
```
## 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
### Importing the config

View File

@@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -13,6 +14,16 @@ import (
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 (
styleTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("62")).Bold(true)
styleSubtitle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
@@ -22,6 +33,13 @@ var (
)
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
previousScreen screens.Screen
quitting bool
@@ -38,6 +56,8 @@ func (m model) Init() tea.Cmd {
return nil
}
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
if !m.initialized {
@@ -46,92 +66,114 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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) {
case tea.KeyMsg:
// 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 _, ok := m.currentScreen.(*screens.ListScreen); ok {
m.quitting = true
return m, tea.Quit
}
// For other screens, let them handle the key
}
switch msg.String() {
case "?":
// Switch to help screen
m.previousScreen = m.currentScreen
m.currentScreen = screens.NewHelpScreen(m.previousScreen)
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewHelpScreen(m.previousScreen), screens.TransitionFade)
return m, cmd
case "l":
// Switch to list screen
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case "a":
// Switch to add screen
m.previousScreen = m.currentScreen
m.currentScreen = screens.NewAddScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewAddScreen(), screens.TransitionSlideLeft)
return m, cmd
}
case screens.ClientSelectedMsg:
// User selected a client - show detail screen
m.previousScreen = m.currentScreen
m.currentScreen = screens.NewDetailScreen(msg.Client.Client)
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewDetailScreen(msg.Client.Client), screens.TransitionSlideLeft)
return m, cmd
case screens.ClientDeletedMsg:
// Client was deleted - show success message and return to list
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case screens.ClientCreatedMsg:
// Client was created - return to list screen
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case screens.RestoreCompletedMsg:
// Restore completed - return to list screen to refresh clients
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case screens.CloseDetailScreenMsg:
// Detail screen closed - go back to previous screen
if m.previousScreen != nil {
m.currentScreen = m.previousScreen
m.previousScreen = nil
return m, m.currentScreen.Init()
cmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
return m, cmd
}
m.currentScreen = screens.NewListScreen()
return m, m.currentScreen.Init()
cmd := m.startTransition(screens.NewListScreen(), screens.TransitionFade)
return m, cmd
case screens.ErrMsg:
// An error occurred - show error screen
m.previousScreen = m.currentScreen
m.errorScreen = screens.NewErrorScreen(msg.Err)
m.currentScreen = m.errorScreen
return m, m.currentScreen.Init()
cmd := m.startTransition(m.errorScreen, screens.TransitionFade)
return m, cmd
}
// Pass messages to current screen
if m.currentScreen != nil {
newScreen, cmd := m.currentScreen.Update(msg)
// If screen returns nil, go back to previous screen
if newScreen == nil {
if m.previousScreen != nil {
m.currentScreen = m.previousScreen
m.previousScreen = nil
transitionCmd := m.startTransition(m.previousScreen, screens.TransitionSlideRight)
return m, tea.Batch(cmd, transitionCmd)
}
} else if newScreen != m.currentScreen {
// Screen is switching to a different screen
m.previousScreen = m.currentScreen
m.currentScreen = newScreen
transitionCmd := m.startTransition(newScreen, screens.TransitionSlideLeft)
return m, tea.Batch(cmd, transitionCmd)
}
return m, cmd
}
return m, nil
}
}
func (m model) View() string {
func (m model) View() string {
if m.quitting {
return "\nGoodbye!\n"
}
// If transitioning, render the transition animation
if m.transitionAnimating {
return renderTransition(m)
}
if m.currentScreen != nil && m.initialized {
return m.currentScreen.View()
}
@@ -142,6 +184,7 @@ func (m model) View() string {
return title + " " + subtitle + "\n\nInitializing...\n\n" + bar
}
}
func main() {
p := tea.NewProgram(model{}, tea.WithAltScreen())
@@ -150,3 +193,193 @@ func main() {
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 (
"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),
)
}

View File

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

View File

@@ -152,7 +152,9 @@ func (s *DetailScreen) renderContent() string {
statusText := s.status
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 {
statusText = theme.StyleError.Bold(true).Render("● " + s.status)
}
@@ -184,8 +186,8 @@ func (s *DetailScreen) renderContent() string {
"",
)
// Add help text
helpText := theme.StyleHelpKey.MarginTop(1).Render("Actions: [d] Delete • [c] View Config • [q/b] Back")
// Add help text with all keyboard shortcuts
helpText := theme.StyleHelpKey.MarginTop(1).Render("Shortcuts: [d] Delete • [c] View Config • [q/b/esc] Back")
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
return content

View File

@@ -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,45 @@ 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 := 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
leftColumn := lipgloss.JoinVertical(lipgloss.Left,
navigationGroup,
@@ -99,15 +90,14 @@ func (s *HelpScreen) View() string {
rightColumn := lipgloss.JoinVertical(lipgloss.Left,
otherGroup,
"",
copyGroup,
)
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(

View File

@@ -4,6 +4,16 @@ import (
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.)
type Screen interface {
Init() tea.Cmd

View File

@@ -30,6 +30,7 @@ type ListScreen struct {
type ClientWithStatus struct {
Client wireguard.Client
Status string
Quality string
}
// NewListScreen creates a new list screen
@@ -204,7 +205,13 @@ func (s *ListScreen) View() string {
Foreground(lipgloss.Color("241")).
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
@@ -218,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()
@@ -225,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,
Quality: quality,
}
}
@@ -274,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
@@ -298,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, cws.Quality)
row := table.Row{
cws.Client.Name,
cws.Client.IPv4,
cws.Client.IPv6,
name,
ipv4,
ipv6,
statusText,
}
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"
"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

View File

@@ -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)
}

View File

@@ -38,6 +38,12 @@ var (
StyleTitle lipgloss.Style
StyleSubtitle 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
@@ -176,8 +182,83 @@ func ApplyTheme(theme *Theme) {
StyleHelpKey = lipgloss.NewStyle().
Foreground(theme.Scheme.Primary).
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
func GetThemeNames() []string {
names := make([]string, 0, len(ThemeRegistry))

View File

@@ -15,6 +15,15 @@ const (
StatusConnected = "Connected"
// StatusDisconnected indicates a peer is not connected
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
@@ -26,6 +35,7 @@ type PeerStatus struct {
TransferRx string `json:"transfer_rx"`
TransferTx string `json:"transfer_tx"`
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
@@ -119,6 +129,20 @@ func parsePeersOutput(output string) []PeerStatus {
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
func finalizePeerStatus(peer *PeerStatus, handshake string, transfer string) PeerStatus {
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 != "" {
peer.LatestHandshake = parseHandshake(handshake)
timeSinceHandshake := time.Since(peer.LatestHandshake)
// Peer is considered connected if handshake is recent (within 5 minutes)
// 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.Quality = CalculateQuality(timeSinceHandshake)
} else {
peer.Status = StatusDisconnected
}