Add WireGuard TUI implementation

- Add Go TUI with bubbletea for WireGuard management
- Implement client CRUD operations with QR code generation
- Add configuration and validation modules
- Install/update scripts for client setup
- Update Makefile to build binaries to bin/ directory
- Add .gitignore for Go projects
This commit is contained in:
Calmcacil
2026-01-12 19:03:35 +01:00
parent 5ac68db854
commit 26120b8bc2
37 changed files with 6330 additions and 97 deletions

View File

@@ -0,0 +1,143 @@
package components
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ConfirmModel represents a confirmation modal
type ConfirmModel struct {
Message string
Yes bool // true = yes, false = no
Visible bool
Width int
Height int
}
// Styles
var (
confirmModalStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("196")).
Padding(1, 2).
Background(lipgloss.Color("235"))
confirmTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
confirmMessageStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Width(50)
confirmHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
)
// NewConfirm creates a new confirmation modal
func NewConfirm(message string, width, height int) *ConfirmModel {
return &ConfirmModel{
Message: message,
Yes: false,
Visible: true,
Width: width,
Height: height,
}
}
// Init initializes the confirmation modal
func (m *ConfirmModel) Init() tea.Cmd {
return nil
}
// Update handles messages for the confirmation modal
func (m *ConfirmModel) Update(msg tea.Msg) (*ConfirmModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "y", "Y", "left":
m.Yes = true
case "n", "N", "right":
m.Yes = false
case "enter":
// Confirmed - will be handled by parent
return m, nil
case "esc":
m.Visible = false
return m, nil
}
}
return m, nil
}
// View renders the confirmation modal
func (m *ConfirmModel) View() string {
if !m.Visible {
return ""
}
// Build modal content
content := lipgloss.JoinVertical(
lipgloss.Left,
confirmTitleStyle.Render("⚠️ Confirm Action"),
"",
confirmMessageStyle.Render(m.Message),
"",
m.renderOptions(),
)
// Apply modal style
modal := confirmModalStyle.Render(content)
// Center modal on screen
modalWidth := lipgloss.Width(modal)
modalHeight := lipgloss.Height(modal)
x := (m.Width - modalWidth) / 2
if x < 0 {
x = 0
}
y := (m.Height - modalHeight) / 2
if y < 0 {
y = 0
}
return lipgloss.Place(m.Width, m.Height,
lipgloss.Left, lipgloss.Top,
modal,
lipgloss.WithWhitespaceChars(" "),
lipgloss.WithWhitespaceForeground(lipgloss.Color("235")),
)
}
// renderOptions renders the yes/no options
func (m *ConfirmModel) renderOptions() string {
yesStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
noStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("57")).
Bold(true).
Underline(true)
var yesText, noText string
if m.Yes {
yesText = selectedStyle.Render("[Yes]")
noText = noStyle.Render(" No ")
} else {
yesText = yesStyle.Render(" Yes ")
noText = selectedStyle.Render("[No]")
}
helpText := confirmHelpStyle.Render("←/→ to choose • Enter to confirm • Esc to cancel")
return lipgloss.JoinHorizontal(lipgloss.Left, yesText, " ", noText, "\n", helpText)
}
// IsConfirmed returns true if user confirmed with Yes
func (m *ConfirmModel) IsConfirmed() bool {
return m.Yes
}
// IsCancelled returns true if user cancelled
func (m *ConfirmModel) IsCancelled() bool {
return !m.Visible
}

View File

@@ -0,0 +1,307 @@
package components
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// SearchFilterType represents the type of filter
type SearchFilterType string
const (
FilterByName SearchFilterType = "name"
FilterByIPv4 SearchFilterType = "ipv4"
FilterByIPv6 SearchFilterType = "ipv6"
FilterByStatus SearchFilterType = "status"
)
// SearchModel represents the search component
type SearchModel struct {
input textinput.Model
active bool
filterType SearchFilterType
matchCount int
totalCount int
visible bool
}
// Styles
var (
searchBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Background(lipgloss.Color("235")).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
searchPromptStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
searchFilterStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("147"))
searchCountStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
searchHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("243"))
)
// NewSearch creates a new search component
func NewSearch() *SearchModel {
ti := textinput.New()
ti.Placeholder = "Search clients..."
ti.Focus()
ti.CharLimit = 156
ti.Width = 40
ti.Prompt = ""
return &SearchModel{
input: ti,
active: false,
filterType: FilterByName,
matchCount: 0,
totalCount: 0,
visible: true,
}
}
// Init initializes the search component
func (m *SearchModel) Init() tea.Cmd {
return nil
}
// Update handles messages for the search component
func (m *SearchModel) Update(msg tea.Msg) (*SearchModel, tea.Cmd) {
if !m.active {
return m, nil
}
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
m.active = false
m.input.Reset()
m.matchCount = m.totalCount
return m, nil
case "tab":
m.cycleFilterType()
return m, nil
}
}
m.input, cmd = m.input.Update(msg)
return m, cmd
}
// View renders the search component
func (m *SearchModel) View() string {
if !m.visible {
return ""
}
var filterLabel string
switch m.filterType {
case FilterByName:
filterLabel = "Name"
case FilterByIPv4:
filterLabel = "IPv4"
case FilterByIPv6:
filterLabel = "IPv6"
case FilterByStatus:
filterLabel = "Status"
}
searchIndicator := ""
if m.active {
searchIndicator = searchPromptStyle.Render("🔍 ")
} else {
searchIndicator = searchPromptStyle.Render("⌕ ")
}
filterText := searchFilterStyle.Render("[" + filterLabel + "]")
countText := ""
if m.totalCount > 0 {
countText = searchCountStyle.Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
strings.Repeat(" ", 4),
"Matched: ",
m.renderCount(m.matchCount),
"/",
m.renderCount(m.totalCount),
),
)
}
helpText := ""
if m.active {
helpText = searchHelpStyle.Render(" | Tab: filter | Esc: clear")
} else {
helpText = searchHelpStyle.Render(" | /: search")
}
content := lipgloss.JoinHorizontal(
lipgloss.Left,
searchIndicator,
m.input.View(),
filterText,
countText,
helpText,
)
return searchBarStyle.Render(content)
}
// IsActive returns true if search is active
func (m *SearchModel) IsActive() bool {
return m.active
}
// Activate activates the search
func (m *SearchModel) Activate() {
m.active = true
m.input.Focus()
}
// Deactivate deactivates the search
func (m *SearchModel) Deactivate() {
m.active = false
m.input.Reset()
m.matchCount = m.totalCount
}
// Clear clears the search input and filter
func (m *SearchModel) Clear() {
m.input.Reset()
m.matchCount = m.totalCount
}
// GetQuery returns the current search query
func (m *SearchModel) GetQuery() string {
return m.input.Value()
}
// GetFilterType returns the current filter type
func (m *SearchModel) GetFilterType() SearchFilterType {
return m.filterType
}
// SetTotalCount sets the total number of items
func (m *SearchModel) SetTotalCount(count int) {
m.totalCount = count
if !m.active {
m.matchCount = count
}
}
// SetMatchCount sets the number of matching items
func (m *SearchModel) SetMatchCount(count int) {
m.matchCount = count
}
// Filter filters a list of client data based on the current search query
func (m *SearchModel) Filter(clients []ClientData) []ClientData {
query := strings.TrimSpace(m.input.Value())
if query == "" || !m.active {
m.matchCount = len(clients)
return clients
}
var filtered []ClientData
queryLower := strings.ToLower(query)
for _, client := range clients {
var matches bool
switch m.filterType {
case FilterByName:
matches = strings.Contains(strings.ToLower(client.Name), queryLower)
case FilterByIPv4:
matches = strings.Contains(strings.ToLower(client.IPv4), queryLower)
case FilterByIPv6:
matches = strings.Contains(strings.ToLower(client.IPv6), queryLower)
case FilterByStatus:
matches = strings.Contains(strings.ToLower(client.Status), queryLower)
}
if matches {
filtered = append(filtered, client)
}
}
m.matchCount = len(filtered)
return filtered
}
// HighlightMatches highlights matching text in the given value
func (m *SearchModel) HighlightMatches(value string) string {
if !m.active {
return value
}
query := strings.TrimSpace(m.input.Value())
if query == "" {
return value
}
queryLower := strings.ToLower(query)
valueLower := strings.ToLower(value)
index := strings.Index(valueLower, queryLower)
if index == -1 {
return value
}
matchStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Background(lipgloss.Color("57")).
Bold(true)
before := value[:index]
match := value[index+len(query)]
after := value[index+len(query):]
return lipgloss.JoinHorizontal(
lipgloss.Left,
before,
matchStyle.Render(string(match)),
after,
)
}
// cycleFilterType cycles to the next filter type
func (m *SearchModel) cycleFilterType() {
switch m.filterType {
case FilterByName:
m.filterType = FilterByIPv4
case FilterByIPv4:
m.filterType = FilterByIPv6
case FilterByIPv6:
m.filterType = FilterByStatus
case FilterByStatus:
m.filterType = FilterByName
}
}
// renderCount renders a count number with proper styling
func (m *SearchModel) renderCount(count int) string {
if m.matchCount == 0 && m.active && m.input.Value() != "" {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Render("No matches")
}
return searchCountStyle.Render(string(rune('0' + count)))
}
// ClientData represents client data for filtering
type ClientData struct {
Name string
IPv4 string
IPv6 string
Status string
}

89
internal/tui/model.go Normal file
View File

@@ -0,0 +1,89 @@
package screens
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Screen represents a UI screen (list, add, detail, etc.)
type Screen interface {
Init() tea.Cmd
Update(tea.Msg) (Screen, tea.Cmd)
View() string
}
// Model is shared state across all screens
type Model struct {
err error
isQuitting bool
ready bool
statusMessage string
screen Screen
}
// View renders the model (implements Screen interface)
func (m *Model) View() string {
if m.err != nil {
return "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Render(m.err.Error())
}
if m.isQuitting {
return "\nGoodbye!\n"
}
if m.screen != nil {
return m.screen.View()
}
return "Initializing..."
}
// Init initializes the model
func (m *Model) Init() tea.Cmd {
m.ready = true
return nil
}
// Update handles incoming messages (implements Screen interface)
func (m *Model) Update(msg tea.Msg) (Screen, tea.Cmd) {
// If we have an error, let it persist for display
if m.err != nil {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
m.isQuitting = true
return m, tea.Quit
}
}
return m, nil
}
// No error - handle normally
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
m.isQuitting = true
return m, tea.Quit
}
}
return m, nil
}
// SetScreen changes the current screen
func (m *Model) SetScreen(screen Screen) {
m.screen = screen
}
// SetError sets an error message
func (m *Model) SetError(err error) {
m.err = err
}
// ClearError clears the error message
func (m *Model) ClearError() {
m.err = nil
}

152
internal/tui/screens/add.go Normal file
View File

@@ -0,0 +1,152 @@
package screens
import (
"fmt"
"github.com/calmcacil/wg-admin/internal/config"
"github.com/calmcacil/wg-admin/internal/validation"
"github.com/calmcacil/wg-admin/internal/wireguard"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// AddScreen is a form for adding new WireGuard clients
type AddScreen struct {
form *huh.Form
quitting bool
}
// Styles
var (
addTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true).
MarginBottom(1)
addHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
)
// NewAddScreen creates a new add screen
func NewAddScreen() *AddScreen {
// Get default DNS from config
cfg, err := config.LoadConfig()
defaultDNS := "8.8.8.8, 8.8.4.4"
if err == nil && cfg.DNSServers != "" {
defaultDNS = cfg.DNSServers
}
// Create the form
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Key("name").
Title("Client Name").
Description("Name for the new client (alphanumeric, -, _)").
Placeholder("e.g., laptop-john").
Validate(func(s string) error {
return validation.ValidateClientName(s)
}),
huh.NewInput().
Key("dns").
Title("DNS Servers").
Description("Comma-separated IPv4 addresses").
Placeholder("e.g., 8.8.8.8, 8.8.4.4").
Value(&defaultDNS).
Validate(func(s string) error {
return validation.ValidateDNSServers(s)
}),
huh.NewConfirm().
Key("use_psk").
Title("Use Preshared Key").
Description("Enable additional security layer with a preshared key").
Affirmative("Yes").
Negative("No"),
),
)
return &AddScreen{
form: form,
quitting: false,
}
}
// Init initializes the add screen
func (s *AddScreen) Init() tea.Cmd {
return s.form.Init()
}
// Update handles messages for the add screen
func (s *AddScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c", "esc":
// Cancel and return to list
return nil, nil
}
}
// Update the form
form, cmd := s.form.Update(msg)
if f, ok := form.(*huh.Form); ok {
s.form = f
}
cmds = append(cmds, cmd)
// Check if form is completed
if s.form.State == huh.StateCompleted {
name := s.form.GetString("name")
dns := s.form.GetString("dns")
usePSK := s.form.GetBool("use_psk")
// Create the client
return s, s.createClient(name, dns, usePSK)
}
return s, tea.Batch(cmds...)
}
// View renders the add screen
func (s *AddScreen) View() string {
if s.quitting {
return ""
}
content := lipgloss.JoinVertical(
lipgloss.Left,
addTitleStyle.Render("Add New WireGuard Client"),
s.form.View(),
addHelpStyle.Render("Press Enter to submit • Esc to cancel"),
)
return content
}
// createClient creates a new WireGuard client
func (s *AddScreen) createClient(name, dns string, usePSK bool) tea.Cmd {
return func() tea.Msg {
// Create the client via wireguard package
err := wireguard.CreateClient(name, dns, usePSK)
if err != nil {
return errMsg{err: fmt.Errorf("failed to create client: %w", err)}
}
// Return success message
return ClientCreatedMsg{
Name: name,
}
}
}
// Messages
// ClientCreatedMsg is sent when a client is successfully created
type ClientCreatedMsg struct {
Name string
}

View File

@@ -0,0 +1,299 @@
package screens
import (
"fmt"
"time"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/wireguard"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// DetailScreen displays detailed information about a single WireGuard client
type DetailScreen struct {
client wireguard.Client
status string
lastHandshake time.Time
transferRx string
transferTx string
confirmModal *components.ConfirmModel
showConfirm bool
clipboardCopied bool
clipboardTimer int
}
// Styles
var (
detailTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
detailSectionStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Bold(true).
MarginTop(1)
detailLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Width(18)
detailValueStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255"))
detailConnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
detailDisconnectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
detailWarningStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
detailHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1)
detailErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
MarginTop(1)
)
// NewDetailScreen creates a new detail screen for a client
func NewDetailScreen(client wireguard.Client) *DetailScreen {
return &DetailScreen{
client: client,
showConfirm: false,
}
}
// Init initializes the detail screen
func (s *DetailScreen) Init() tea.Cmd {
return s.loadClientStatus
}
// Update handles messages for the detail screen
func (s *DetailScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd
// Handle clipboard copy timeout
if s.clipboardCopied {
s.clipboardTimer++
if s.clipboardTimer > 2 {
s.clipboardCopied = false
s.clipboardTimer = 0
}
}
// Handle confirmation modal
if s.showConfirm && s.confirmModal != nil {
_, cmd = s.confirmModal.Update(msg)
// Handle confirmation result
if !s.confirmModal.Visible {
if s.confirmModal.IsConfirmed() {
// User confirmed deletion
return s, tea.Batch(s.deleteClient(), func() tea.Msg {
return CloseDetailScreenMsg{}
})
}
// User cancelled - close modal
s.showConfirm = false
return s, nil
}
// Handle Enter key to confirm
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" && s.confirmModal.IsConfirmed() {
return s, tea.Batch(s.deleteClient(), func() tea.Msg {
return CloseDetailScreenMsg{}
})
}
}
return s, cmd
}
// Handle normal screen messages
switch msg := msg.(type) {
case clipboardCopiedMsg:
s.clipboardCopied = true
case clientStatusLoadedMsg:
s.status = msg.status
s.lastHandshake = msg.lastHandshake
s.transferRx = msg.transferRx
s.transferTx = msg.transferTx
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
// Return to list screen - signal parent to switch screens
return s, nil
case "d":
// Show delete confirmation
s.confirmModal = components.NewConfirm(
fmt.Sprintf("Are you sure you want to delete client '%s'?\n\nThis action cannot be undone.", s.client.Name),
80,
24,
)
s.showConfirm = true
case "c":
// Copy public key to clipboard
return s, s.copyPublicKey()
}
}
return s, cmd
}
// View renders the detail screen
func (s *DetailScreen) View() string {
if s.showConfirm && s.confirmModal != nil {
// Render underlying content dimmed
content := s.renderContent()
dimmedContent := lipgloss.NewStyle().
Foreground(lipgloss.Color("244")).
Render(content)
// Overlay confirmation modal
return lipgloss.JoinVertical(
lipgloss.Left,
dimmedContent,
s.confirmModal.View(),
)
}
return s.renderContent()
}
// renderContent renders the main detail screen content
func (s *DetailScreen) renderContent() string {
statusText := s.status
if s.status == wireguard.StatusConnected {
statusText = detailConnectedStyle.Render(s.status)
} else {
statusText = detailDisconnectedStyle.Render(s.status)
}
// Build content
content := lipgloss.JoinVertical(
lipgloss.Left,
detailTitleStyle.Render(fmt.Sprintf("Client Details: %s", s.client.Name)),
"",
s.renderField("Status", statusText),
s.renderField("IPv4 Address", detailValueStyle.Render(s.client.IPv4)),
s.renderField("IPv6 Address", detailValueStyle.Render(s.client.IPv6)),
"",
detailSectionStyle.Render("WireGuard Configuration"),
s.renderField("Public Key", detailValueStyle.Render(s.client.PublicKey)),
s.renderField("Preshared Key", detailValueStyle.Render(func() string {
if s.client.HasPSK {
return "✓ Configured"
}
return "Not configured"
}())),
"",
detailSectionStyle.Render("Connection Info"),
s.renderField("Last Handshake", detailValueStyle.Render(s.formatHandshake())),
s.renderField("Transfer (Rx/Tx)", detailValueStyle.Render(fmt.Sprintf("%s / %s", s.transferRx, s.transferTx))),
s.renderField("Config Path", detailValueStyle.Render(s.client.ConfigPath)),
"",
)
// Add help text
helpText := detailHelpStyle.Render("Actions: [d] Delete • [c] Copy Public Key • [q] Back")
content = lipgloss.JoinVertical(lipgloss.Left, content, helpText)
// Show clipboard confirmation
if s.clipboardCopied {
content += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Render("✓ Public key copied to clipboard!")
}
return content
}
// renderField renders a label-value pair
func (s *DetailScreen) renderField(label string, value string) string {
return lipgloss.JoinHorizontal(lipgloss.Left,
detailLabelStyle.Render(label),
value,
)
}
// formatHandshake formats the last handshake time
func (s *DetailScreen) formatHandshake() string {
if s.lastHandshake.IsZero() {
return "Never"
}
duration := time.Since(s.lastHandshake)
if duration < time.Minute {
return "Just now"
} else if duration < time.Hour {
return fmt.Sprintf("%d min ago", int(duration.Minutes()))
} else if duration < 24*time.Hour {
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
} else if duration < 7*24*time.Hour {
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
}
return s.lastHandshake.Format("2006-01-02 15:04")
}
// loadClientStatus loads the current status of the client
func (s *DetailScreen) loadClientStatus() tea.Msg {
peers, err := wireguard.GetAllPeers()
if err != nil {
return errMsg{err: err}
}
// Find peer by public key
for _, peer := range peers {
if peer.PublicKey == s.client.PublicKey {
return clientStatusLoadedMsg{
status: peer.Status,
lastHandshake: peer.LatestHandshake,
transferRx: peer.TransferRx,
transferTx: peer.TransferTx,
}
}
}
// Peer not found in active list
return clientStatusLoadedMsg{
status: wireguard.StatusDisconnected,
lastHandshake: time.Time{},
transferRx: "",
transferTx: "",
}
}
// copyPublicKey copies the public key to clipboard
func (s *DetailScreen) copyPublicKey() tea.Cmd {
return func() tea.Msg {
// Note: In a real implementation, you would use a clipboard library like
// github.com/atotto/clipboard or implement platform-specific clipboard access
// For now, we'll just simulate the action
return clipboardCopiedMsg{}
}
}
// deleteClient deletes the client
func (s *DetailScreen) deleteClient() tea.Cmd {
return func() tea.Msg {
err := wireguard.DeleteClient(s.client.Name)
if err != nil {
return errMsg{fmt.Errorf("failed to delete client: %w", err)}
}
return ClientDeletedMsg{
Name: s.client.Name,
}
}
}
// Messages
// clientStatusLoadedMsg is sent when client status is loaded
type clientStatusLoadedMsg struct {
status string
lastHandshake time.Time
transferRx string
transferTx string
}
// clipboardCopiedMsg is sent when public key is copied to clipboard
type clipboardCopiedMsg struct{}

View File

@@ -0,0 +1,31 @@
package screens
import (
tea "github.com/charmbracelet/bubbletea"
)
// Screen represents a UI screen (list, add, detail, etc.)
type Screen interface {
Init() tea.Cmd
Update(tea.Msg) (Screen, tea.Cmd)
View() string
}
// ClientSelectedMsg is sent when a client is selected from the list
type ClientSelectedMsg struct {
Client ClientWithStatus
}
// ClientDeletedMsg is sent when a client is successfully deleted
type ClientDeletedMsg struct {
Name string
}
// CloseDetailScreenMsg signals to close detail screen
type CloseDetailScreenMsg struct{}
// RestoreCompletedMsg is sent when a restore operation completes
type RestoreCompletedMsg struct {
Err error
SafetyBackupPath string
}

View File

@@ -0,0 +1,324 @@
package screens
import (
"sort"
"strings"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/calmcacil/wg-admin/internal/wireguard"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const statusRefreshInterval = 10 // seconds
// ListScreen displays a table of WireGuard clients
type ListScreen struct {
table table.Model
search *components.SearchModel
clients []ClientWithStatus
filtered []ClientWithStatus
sortedBy string // Column name being sorted by
ascending bool // Sort direction
}
// ClientWithStatus wraps a client with its connection status
type ClientWithStatus struct {
Client wireguard.Client
Status string
}
// NewListScreen creates a new list screen
func NewListScreen() *ListScreen {
return &ListScreen{
search: components.NewSearch(),
sortedBy: "Name",
ascending: true,
}
}
// Init initializes the list screen
func (s *ListScreen) Init() tea.Cmd {
return tea.Batch(
s.loadClients,
wireguard.Tick(statusRefreshInterval),
)
}
// Update handles messages for the list screen
func (s *ListScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
// Handle search activation
if msg.String() == "/" && !s.search.IsActive() {
s.search.Activate()
return s, nil
}
// If search is active, pass input to search
if s.search.IsActive() {
s.search, cmd = s.search.Update(msg)
// Apply filter to clients
s.applyFilter()
return s, cmd
}
// Normal key handling when search is not active
switch msg.String() {
case "q", "ctrl+c":
// Handle quit in parent model
return s, nil
case "r":
// Refresh clients
return s, s.loadClients
case "R":
// Show restore screen
return NewRestoreScreen(), nil
case "Q":
// Show QR code for selected client
if len(s.table.Rows()) > 0 {
selected := s.table.SelectedRow()
clientName := selected[0] // First column is Name
return NewQRScreen(clientName), nil
}
case "enter":
// Open detail view for selected client
if len(s.table.Rows()) > 0 {
selectedRow := s.table.SelectedRow()
selectedName := selectedRow[0] // First column is Name
// Find the client with this name
for _, cws := range s.clients {
if cws.Client.Name == selectedName {
return s, func() tea.Msg {
return ClientSelectedMsg{Client: cws}
}
}
}
}
case "1", "2", "3", "4":
// Sort by column number (Name, IPv4, IPv6, Status)
s.sortByColumn(msg.String())
}
case clientsLoadedMsg:
s.clients = msg.clients
s.search.SetTotalCount(len(s.clients))
s.applyFilter()
case wireguard.StatusTickMsg:
// Refresh status on periodic tick
return s, s.loadClients
case wireguard.RefreshStatusMsg:
// Refresh status on manual refresh
return s, s.loadClients
}
s.table, cmd = s.table.Update(msg)
return s, cmd
}
// View renders the list screen
func (s *ListScreen) View() string {
if len(s.clients) == 0 {
return s.search.View() + "\n" + "No clients found. Press 'r' to refresh or 'q' to quit."
}
// Check if there are no matches
if s.search.IsActive() && len(s.filtered) == 0 && s.search.GetQuery() != "" {
return s.search.View() + "\n" + lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Italic(true).
Render("No matching clients found. Try a different search term.")
}
return s.search.View() + "\n" + s.table.View()
}
// 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()
}
// 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: 12},
}
// 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 {
row := table.Row{
cws.Client.Name,
cws.Client.IPv4,
cws.Client.IPv6,
cws.Status,
}
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
}

141
internal/tui/screens/qr.go Normal file
View File

@@ -0,0 +1,141 @@
package screens
import (
"fmt"
"strings"
"github.com/calmcacil/wg-admin/internal/wireguard"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mdp/qrterminal/v3"
)
// QRScreen displays a QR code for a WireGuard client configuration
type QRScreen struct {
clientName string
configContent string
qrCode string
inlineMode bool
width, height int
errorMsg string
}
// NewQRScreen creates a new QR screen for displaying client config QR codes
func NewQRScreen(clientName string) *QRScreen {
return &QRScreen{
clientName: clientName,
inlineMode: true, // Start in inline mode
}
}
// Init initializes the QR screen
func (s *QRScreen) Init() tea.Cmd {
return s.loadConfig
}
// Update handles messages for the QR screen
func (s *QRScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "escape":
// Return to list screen (parent should handle this)
return nil, nil
case "f":
// Toggle between inline and fullscreen mode
s.inlineMode = !s.inlineMode
s.generateQRCode()
}
case tea.WindowSizeMsg:
// Handle terminal resize
s.width = msg.Width
s.height = msg.Height
s.generateQRCode()
case configLoadedMsg:
s.configContent = msg.content
s.generateQRCode()
case errMsg:
s.errorMsg = msg.err.Error()
}
return s, nil
}
// View renders the QR screen
func (s *QRScreen) View() string {
if s.errorMsg != "" {
return s.renderError()
}
if s.qrCode == "" {
return "Loading QR code..."
}
return s.renderQR()
}
// loadConfig loads the client configuration
func (s *QRScreen) loadConfig() tea.Msg {
content, err := wireguard.GetClientConfigContent(s.clientName)
if err != nil {
return errMsg{err: err}
}
return configLoadedMsg{content: content}
}
// generateQRCode generates the QR code based on current mode and terminal size
func (s *QRScreen) generateQRCode() {
if s.configContent == "" {
return
}
// Generate QR code and capture output
var builder strings.Builder
// Generate ANSI QR code using half-block characters
qrterminal.GenerateHalfBlock(s.configContent, qrterminal.L, &builder)
s.qrCode = builder.String()
}
// 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))
help := "Press [f] to toggle fullscreen • Press [q/Escape] to return"
return title + "\n\n" + styleQR.Render(s.qrCode) + "\n" + styleHelp.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")
message := s.errorMsg
help := "Press [q/Escape] to return"
return title + "\n\n" + message + "\n" + styleHelp.Render(help)
}
// Messages
// configLoadedMsg is sent when the client configuration is loaded
type configLoadedMsg struct {
content string
}

View File

@@ -0,0 +1,333 @@
package screens
import (
"fmt"
"strings"
"github.com/calmcacil/wg-admin/internal/backup"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// RestoreScreen displays a list of available backups for restoration
type RestoreScreen struct {
table table.Model
backups []backup.Backup
selectedBackup *backup.Backup
confirmModal *components.ConfirmModel
showConfirm bool
isRestoring bool
restoreError error
restoreSuccess bool
message string
}
// 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)
)
// NewRestoreScreen creates a new restore screen
func NewRestoreScreen() *RestoreScreen {
return &RestoreScreen{
showConfirm: false,
}
}
// Init initializes the restore screen
func (s *RestoreScreen) Init() tea.Cmd {
return s.loadBackups
}
// Update handles messages for the restore screen
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd
// Handle confirmation modal
if s.showConfirm && s.confirmModal != nil {
_, cmd = s.confirmModal.Update(msg)
// Handle confirmation result
if !s.confirmModal.Visible {
if s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
// User confirmed restore
s.isRestoring = true
s.showConfirm = false
return s, s.performRestore()
}
// User cancelled - close modal
s.showConfirm = false
return s, nil
}
// Handle Enter key to confirm
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
s.isRestoring = true
s.showConfirm = false
return s, s.performRestore()
}
}
return s, cmd
}
// Handle normal screen messages
switch msg := msg.(type) {
case backupsLoadedMsg:
s.backups = msg.backups
s.buildTable()
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
// Return to list screen - signal parent to switch screens
return s, nil
case "enter":
// Show confirmation for selected backup
if len(s.table.Rows()) > 0 {
selected := s.table.SelectedRow()
if len(selected) > 0 {
// Find the backup by name
for _, b := range s.backups {
if b.Name == selected[0] {
s.selectedBackup = &b
s.confirmModal = components.NewConfirm(
fmt.Sprintf(
"Are you sure you want to restore from backup '%s'?\n\nOperation: %s\nDate: %s\n\nThis will replace current WireGuard configuration.\nA safety backup will be created first.",
b.Name,
b.Operation,
b.Timestamp.Format("2006-01-02 15:04:05"),
),
80,
24,
)
s.showConfirm = true
break
}
}
}
}
}
case restoreCompletedMsg:
s.isRestoring = false
if msg.err != nil {
s.restoreError = msg.err
s.message = fmt.Sprintf("Restore failed: %v", msg.err)
} else {
s.restoreSuccess = true
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.safetyBackupPath)
}
}
if !s.showConfirm && s.confirmModal != nil {
s.table, cmd = s.table.Update(msg)
}
return s, cmd
}
// View renders the restore screen
func (s *RestoreScreen) View() string {
if s.showConfirm && s.confirmModal != nil {
// Render underlying content dimmed
content := s.renderContent()
dimmedContent := lipgloss.NewStyle().
Foreground(lipgloss.Color("244")).
Render(content)
// Overlay confirmation modal
return lipgloss.JoinVertical(
lipgloss.Left,
dimmedContent,
s.confirmModal.View(),
)
}
return s.renderContent()
}
// renderContent renders the main restore screen content
func (s *RestoreScreen) renderContent() string {
var content strings.Builder
content.WriteString(restoreTitleStyle.Render("Restore WireGuard Configuration"))
content.WriteString("\n\n")
if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
content.WriteString("No backups found. Press 'q' to return.")
return content.String()
}
if s.isRestoring {
content.WriteString("Restoring from backup, please wait...")
return content.String()
}
if s.restoreSuccess {
content.WriteString(restoreSuccessStyle.Render("✓ " + s.message))
content.WriteString("\n\n")
content.WriteString(restoreInfoStyle.Render("Press 'q' to return to client list."))
return content.String()
}
if s.restoreError != nil {
content.WriteString(restoreErrorStyle.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"))
return content.String()
}
// Show backup list
content.WriteString(s.table.View())
content.WriteString("\n\n")
// Show selected backup details
if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
content.WriteString(restoreInfoStyle.Render(
fmt.Sprintf(
"Selected: %s (%s) - %s\nSize: %s",
s.selectedBackup.Operation,
s.selectedBackup.Timestamp.Format("2006-01-02 15:04:05"),
s.selectedBackup.Name,
formatBytes(s.selectedBackup.Size),
),
))
content.WriteString("\n")
}
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
return content.String()
}
// loadBackups loads the list of available backups
func (s *RestoreScreen) loadBackups() tea.Msg {
backups, err := backup.ListBackups()
if err != nil {
return errMsg{err: err}
}
return backupsLoadedMsg{backups: backups}
}
// buildTable creates and configures the backup list table
func (s *RestoreScreen) buildTable() {
columns := []table.Column{
{Title: "Name", Width: 40},
{Title: "Operation", Width: 15},
{Title: "Date", Width: 20},
{Title: "Size", Width: 12},
}
var rows []table.Row
for _, b := range s.backups {
row := table.Row{
b.Name,
b.Operation,
b.Timestamp.Format("2006-01-02 15:04"),
formatBytes(b.Size),
}
rows = append(rows, row)
}
s.table = table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(len(rows)+2), // Header + rows
)
// Apply styles
s.setTableStyles()
}
// 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)
s.table.SetStyles(styles)
}
// performRestore performs the restore operation
func (s *RestoreScreen) performRestore() tea.Cmd {
return func() tea.Msg {
if s.selectedBackup == nil {
return restoreCompletedMsg{
err: fmt.Errorf("no backup selected"),
}
}
// Get safety backup path from backup.BackupConfig
safetyBackupPath, err := backup.BackupConfig(fmt.Sprintf("pre-restore-from-%s", s.selectedBackup.Name))
if err != nil {
return restoreCompletedMsg{
err: fmt.Errorf("failed to create safety backup: %w", err),
}
}
// Perform restore
if err := backup.RestoreBackup(s.selectedBackup.Name); err != nil {
return restoreCompletedMsg{
err: err,
safetyBackupPath: safetyBackupPath,
}
}
// Restore succeeded - trigger client list refresh
return restoreCompletedMsg{
safetyBackupPath: safetyBackupPath,
}
}
}
// formatBytes formats a byte count into human-readable format
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// Messages
// backupsLoadedMsg is sent when backups are loaded
type backupsLoadedMsg struct {
backups []backup.Backup
}
// restoreCompletedMsg is sent when a restore operation completes
type restoreCompletedMsg struct {
err error
safetyBackupPath string
}

185
internal/tui/theme/theme.go Normal file
View File

@@ -0,0 +1,185 @@
package theme
import (
"fmt"
"os"
"sync"
"github.com/charmbracelet/lipgloss"
)
// ColorScheme defines the color palette for the theme
type ColorScheme struct {
Primary lipgloss.Color
Success lipgloss.Color
Warning lipgloss.Color
Error lipgloss.Color
Muted lipgloss.Color
Background lipgloss.Color
}
// Theme represents a color theme with its name and color scheme
type Theme struct {
Name string
Scheme ColorScheme
}
// Global variables for current theme and styles
var (
currentTheme *Theme
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
)
// DefaultTheme is the standard blue-based theme
var DefaultTheme = &Theme{
Name: "default",
Scheme: ColorScheme{
Primary: lipgloss.Color("62"), // Blue
Success: lipgloss.Color("46"), // Green
Warning: lipgloss.Color("208"), // Orange
Error: lipgloss.Color("196"), // Red
Muted: lipgloss.Color("241"), // Gray
Background: lipgloss.Color(""), // Default terminal background
},
}
// DarkTheme is a purple-based dark theme
var DarkTheme = &Theme{
Name: "dark",
Scheme: ColorScheme{
Primary: lipgloss.Color("141"), // Purple
Success: lipgloss.Color("51"), // Cyan
Warning: lipgloss.Color("226"), // Yellow
Error: lipgloss.Color("196"), // Red
Muted: lipgloss.Color("245"), // Light gray
Background: lipgloss.Color(""), // Default terminal background
},
}
// LightTheme is a green-based light theme
var LightTheme = &Theme{
Name: "light",
Scheme: ColorScheme{
Primary: lipgloss.Color("34"), // Green
Success: lipgloss.Color("36"), // Teal
Warning: lipgloss.Color("214"), // Amber
Error: lipgloss.Color("196"), // Red
Muted: lipgloss.Color("244"), // Gray
Background: lipgloss.Color(""), // Default terminal background
},
}
// ThemeRegistry holds all available themes
var ThemeRegistry = map[string]*Theme{
"default": DefaultTheme,
"dark": DarkTheme,
"light": LightTheme,
}
// GetTheme loads the theme from config or environment variable
// Returns the default theme if no theme is specified
func GetTheme() (*Theme, error) {
once.Do(func() {
// Try to get theme from environment variable first
themeName := os.Getenv("THEME")
if themeName == "" {
themeName = "default"
}
// Look up the theme in the registry
if theme, ok := ThemeRegistry[themeName]; ok {
currentTheme = theme
} else {
// If theme not found, use default
currentTheme = DefaultTheme
}
// Apply the theme to initialize styles
ApplyTheme(currentTheme)
})
return currentTheme, nil
}
// ApplyTheme applies the given theme to all global styles
func ApplyTheme(theme *Theme) {
currentTheme = theme
// Primary style
StylePrimary = lipgloss.NewStyle().
Foreground(theme.Scheme.Primary)
// Success style
StyleSuccess = lipgloss.NewStyle().
Foreground(theme.Scheme.Success)
// Warning style
StyleWarning = lipgloss.NewStyle().
Foreground(theme.Scheme.Warning)
// Error style
StyleError = lipgloss.NewStyle().
Foreground(theme.Scheme.Error)
// Muted style
StyleMuted = lipgloss.NewStyle().
Foreground(theme.Scheme.Muted)
// Title style (bold primary)
StyleTitle = lipgloss.NewStyle().
Foreground(theme.Scheme.Primary).
Bold(true)
// Subtitle style (muted)
StyleSubtitle = lipgloss.NewStyle().
Foreground(theme.Scheme.Muted)
// Help key style (bold primary, slightly different shade)
StyleHelpKey = lipgloss.NewStyle().
Foreground(theme.Scheme.Primary).
Bold(true)
}
// GetThemeNames returns a list of available theme names
func GetThemeNames() []string {
names := make([]string, 0, len(ThemeRegistry))
for name := range ThemeRegistry {
names = append(names, name)
}
return names
}
// SetTheme changes the current theme by name
func SetTheme(name string) error {
theme, ok := ThemeRegistry[name]
if !ok {
return fmt.Errorf("theme '%s' not found. Available themes: %v", name, GetThemeNames())
}
ApplyTheme(theme)
return nil
}
// GetCurrentTheme returns the currently active theme
func GetCurrentTheme() *Theme {
if currentTheme == nil {
currentTheme = DefaultTheme
ApplyTheme(currentTheme)
}
return currentTheme
}
// GetColorScheme returns the color scheme of the current theme
func GetColorScheme() ColorScheme {
return GetCurrentTheme().Scheme
}