From 5b8b9b66f57a4dc01cb468eaf47a4a03423c9398 Mon Sep 17 00:00:00 2001 From: Calmcacil Date: Mon, 12 Jan 2026 23:34:14 +0100 Subject: [PATCH] Add keyboard shortcut discoverability hints on each screen --- internal/tui/screens/detail.go | 4 +- internal/tui/screens/list.go | 9 +- internal/tui/screens/list_go_append.txt | 220 ++++++++++++++++++++++++ 3 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 internal/tui/screens/list_go_append.txt diff --git a/internal/tui/screens/detail.go b/internal/tui/screens/detail.go index 9142e2f..63435e5 100644 --- a/internal/tui/screens/detail.go +++ b/internal/tui/screens/detail.go @@ -186,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 diff --git a/internal/tui/screens/list.go b/internal/tui/screens/list.go index 5306f66..8f7fff5 100644 --- a/internal/tui/screens/list.go +++ b/internal/tui/screens/list.go @@ -204,7 +204,14 @@ 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 diff --git a/internal/tui/screens/list_go_append.txt b/internal/tui/screens/list_go_append.txt new file mode 100644 index 0000000..b9bdf04 --- /dev/null +++ b/internal/tui/screens/list_go_append.txt @@ -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 +}