Add text selection and copy capability to terminal UI

This commit is contained in:
Calmcacil
2026-01-12 23:23:48 +01:00
parent 17f4d52c8a
commit dd62458515
7 changed files with 106 additions and 20 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

@@ -1,6 +1,7 @@
package components package components
import ( import (
"strconv"
"strings" "strings"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
@@ -263,7 +264,7 @@ 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(

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

View File

@@ -41,7 +41,6 @@ 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 // Styles
borderStyle := lipgloss.NewStyle(). borderStyle := lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
@@ -90,6 +89,11 @@ func (s *HelpScreen) View() string {
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 := categoryStyle.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,6 +103,8 @@ 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)

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,6 +182,33 @@ 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")
} }
// GetThemeNames returns a list of available theme names // GetThemeNames returns a list of available theme names

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