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
```
## 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

@@ -28,8 +28,9 @@ type ListScreen struct {
// ClientWithStatus wraps a client with its connection status
type ClientWithStatus struct {
Client wireguard.Client
Status string
Client wireguard.Client
Status string
Quality string
}
// NewListScreen creates a new list screen
@@ -204,7 +205,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,
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

@@ -30,14 +30,20 @@ var (
once sync.Once
// Global styles that can be used throughout the application
StylePrimary lipgloss.Style
StyleSuccess lipgloss.Style
StyleWarning lipgloss.Style
StyleError lipgloss.Style
StyleMuted lipgloss.Style
StyleTitle lipgloss.Style
StyleSubtitle lipgloss.Style
StyleHelpKey lipgloss.Style
StylePrimary lipgloss.Style
StyleSuccess lipgloss.Style
StyleWarning lipgloss.Style
StyleError lipgloss.Style
StyleMuted lipgloss.Style
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
@@ -25,7 +34,8 @@ type PeerStatus struct {
LatestHandshake time.Time `json:"latest_handshake"`
TransferRx string `json:"transfer_rx"`
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
@@ -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
}