From b0aaf1674dc573c19f0031efb8e36b955f813add Mon Sep 17 00:00:00 2001 From: david s Date: Fri, 9 Jan 2026 09:38:55 +0200 Subject: [PATCH 1/3] feat(ui): add dark/light/system theme support with toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Theme struct with Dark and Light presets in theme.go - Add 'system' theme that auto-detects OS preference: - Linux: freedesktop portal (gdbus) - macOS: defaults read AppleInterfaceStyle - Windows: registry AppsUseLightTheme - Replace all hardcoded colors with CurrentTheme.* references - Add 'T' keyboard shortcut to cycle themes (dark → light → system) - Persist theme preference in ~/.lazyssh/metadata.json - Support backward compatibility with old metadata format - Add settings infrastructure (GetTheme/SaveTheme) to service layer - Add tests for theme and settings functionality --- cmd/main.go | 10 +- .../data/ssh_config_file/metadata_manager.go | 103 ++++++++- .../data/ssh_config_file/settings_test.go | 195 ++++++++++++++++++ .../ssh_config_file/ssh_config_file_repo.go | 37 ++++ internal/adapters/ui/handlers.go | 53 ++++- internal/adapters/ui/header.go | 17 +- internal/adapters/ui/search_bar.go | 8 +- internal/adapters/ui/server_details.go | 8 +- internal/adapters/ui/server_form.go | 13 +- internal/adapters/ui/server_list.go | 8 +- internal/adapters/ui/status_bar.go | 9 +- internal/adapters/ui/theme.go | 194 +++++++++++++++++ internal/adapters/ui/theme_detect_darwin.go | 43 ++++ internal/adapters/ui/theme_detect_linux.go | 47 +++++ internal/adapters/ui/theme_detect_windows.go | 44 ++++ internal/adapters/ui/theme_test.go | 175 ++++++++++++++++ internal/adapters/ui/tui.go | 68 ++++-- internal/adapters/ui/utils.go | 11 +- internal/core/ports/repositories.go | 2 + internal/core/ports/services.go | 2 + internal/core/services/server_service.go | 10 + 21 files changed, 981 insertions(+), 76 deletions(-) create mode 100644 internal/adapters/data/ssh_config_file/settings_test.go create mode 100644 internal/adapters/ui/theme.go create mode 100644 internal/adapters/ui/theme_detect_darwin.go create mode 100644 internal/adapters/ui/theme_detect_linux.go create mode 100644 internal/adapters/ui/theme_detect_windows.go create mode 100644 internal/adapters/ui/theme_test.go diff --git a/cmd/main.go b/cmd/main.go index ee9cf15..a443a52 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -20,10 +20,9 @@ import ( "path/filepath" "github.com/Adembc/lazyssh/internal/adapters/data/ssh_config_file" - "github.com/Adembc/lazyssh/internal/logger" - "github.com/Adembc/lazyssh/internal/adapters/ui" "github.com/Adembc/lazyssh/internal/core/services" + "github.com/Adembc/lazyssh/internal/logger" "github.com/spf13/cobra" ) @@ -51,6 +50,13 @@ func main() { sshConfigFile := filepath.Join(home, ".ssh", "config") metaDataFile := filepath.Join(home, ".lazyssh", "metadata.json") + // Load theme preference before creating UI + settings, err := ssh_config_file.LoadSettings(metaDataFile) + if err != nil { + log.Warnw("failed to load settings, using default theme", "error", err) + } + ui.SetTheme(settings.Theme) + serverRepo := ssh_config_file.NewRepository(log, sshConfigFile, metaDataFile) serverService := services.NewServerService(log, serverRepo) tui := ui.NewTUI(log, serverService, version, gitCommit) diff --git a/internal/adapters/data/ssh_config_file/metadata_manager.go b/internal/adapters/data/ssh_config_file/metadata_manager.go index a4e7be7..dc1c37b 100644 --- a/internal/adapters/data/ssh_config_file/metadata_manager.go +++ b/internal/adapters/data/ssh_config_file/metadata_manager.go @@ -25,6 +25,12 @@ import ( "go.uber.org/zap" ) +// Settings contains application-level settings stored in the metadata file. +type Settings struct { + Theme string `json:"theme,omitempty"` +} + +// ServerMetadata contains per-server metadata that is not part of SSH config. type ServerMetadata struct { Tags []string `json:"tags,omitempty"` LastSeen string `json:"last_seen,omitempty"` @@ -32,6 +38,13 @@ type ServerMetadata struct { SSHCount int `json:"ssh_count,omitempty"` } +// MetadataFile is the top-level structure of the metadata JSON file. +// It contains both application settings and per-server metadata. +type MetadataFile struct { + Settings Settings `json:"settings,omitempty"` + Servers map[string]ServerMetadata `json:"servers,omitempty"` +} + type metadataManager struct { filePath string logger *zap.SugaredLogger @@ -41,11 +54,16 @@ func newMetadataManager(filePath string, logger *zap.SugaredLogger) *metadataMan return &metadataManager{filePath: filePath, logger: logger} } -func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) { - metadata := make(map[string]ServerMetadata) +// loadFile loads the entire metadata file, handling both old and new formats. +// Old format: {"server1": {...}, "server2": {...}} +// New format: {"settings": {...}, "servers": {"server1": {...}, ...}} +func (m *metadataManager) loadFile() (*MetadataFile, error) { + result := &MetadataFile{ + Servers: make(map[string]ServerMetadata), + } if _, err := os.Stat(m.filePath); os.IsNotExist(err) { - return metadata, nil + return result, nil } data, err := os.ReadFile(m.filePath) @@ -54,24 +72,51 @@ func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) { } if len(data) == 0 { - return metadata, nil + return result, nil } - if err := json.Unmarshal(data, &metadata); err != nil { + // First, try to parse as the new format + if err := json.Unmarshal(data, result); err != nil { return nil, fmt.Errorf("parse metadata JSON '%s': %w", m.filePath, err) } - return metadata, nil + // Check if this was the old format (no "servers" key, just server entries at root) + // In the old format, result.Servers will be nil/empty and the root object contains server data + if len(result.Servers) == 0 { + // Try parsing as old format (map of server metadata directly) + var oldFormat map[string]ServerMetadata + if err := json.Unmarshal(data, &oldFormat); err == nil { + // Check if this looks like server metadata (has expected fields) + // and not a settings object + isOldFormat := false + for _, v := range oldFormat { + // If any entry has tags, last_seen, pinned_at, or ssh_count, it's old format + if len(v.Tags) > 0 || v.LastSeen != "" || v.PinnedAt != "" || v.SSHCount > 0 { + isOldFormat = true + break + } + } + if isOldFormat { + result.Servers = oldFormat + } + } + } + + if result.Servers == nil { + result.Servers = make(map[string]ServerMetadata) + } + + return result, nil } -func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error { +// saveFile saves the entire metadata file in the new format. +func (m *metadataManager) saveFile(file *MetadataFile) error { if err := m.ensureDirectory(); err != nil { m.logger.Errorw("failed to ensure metadata directory", "path", m.filePath, "error", err) - return fmt.Errorf("ensure metadata directory for '%s': %w", m.filePath, err) } - data, err := json.MarshalIndent(metadata, "", " ") + data, err := json.MarshalIndent(file, "", " ") if err != nil { m.logger.Errorw("failed to marshal metadata", "path", m.filePath, "error", err) return fmt.Errorf("marshal metadata for '%s': %w", m.filePath, err) @@ -84,6 +129,46 @@ func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error { return nil } +// GetSettings returns the application settings from the metadata file. +func (m *metadataManager) GetSettings() (Settings, error) { + file, err := m.loadFile() + if err != nil { + return Settings{}, err + } + return file.Settings, nil +} + +// SaveSettings saves the application settings to the metadata file. +func (m *metadataManager) SaveSettings(settings Settings) error { + file, err := m.loadFile() + if err != nil { + m.logger.Errorw("failed to load metadata in SaveSettings", "path", m.filePath, "error", err) + return fmt.Errorf("load metadata: %w", err) + } + + file.Settings = settings + return m.saveFile(file) +} + +func (m *metadataManager) loadAll() (map[string]ServerMetadata, error) { + file, err := m.loadFile() + if err != nil { + return nil, err + } + return file.Servers, nil +} + +func (m *metadataManager) saveAll(metadata map[string]ServerMetadata) error { + file, err := m.loadFile() + if err != nil { + // If we can't load, start fresh but preserve any settings + file = &MetadataFile{} + } + + file.Servers = metadata + return m.saveFile(file) +} + func (m *metadataManager) updateServer(server domain.Server, oldAlias string) error { metadata, err := m.loadAll() if err != nil { diff --git a/internal/adapters/data/ssh_config_file/settings_test.go b/internal/adapters/data/ssh_config_file/settings_test.go new file mode 100644 index 0000000..ba26eb5 --- /dev/null +++ b/internal/adapters/data/ssh_config_file/settings_test.go @@ -0,0 +1,195 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ssh_config_file + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadSettings_NonExistentFile(t *testing.T) { + settings, err := LoadSettings("/nonexistent/path/metadata.json") + if err != nil { + t.Errorf("LoadSettings() with non-existent file should not error, got %v", err) + } + if settings.Theme != "" { + t.Errorf("LoadSettings() with non-existent file should return empty theme, got %q", settings.Theme) + } +} + +func TestLoadSettings_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "metadata.json") + + if err := os.WriteFile(tmpFile, []byte{}, 0o644); err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + settings, err := LoadSettings(tmpFile) + if err != nil { + t.Errorf("LoadSettings() with empty file should not error, got %v", err) + } + if settings.Theme != "" { + t.Errorf("LoadSettings() with empty file should return empty theme, got %q", settings.Theme) + } +} + +func TestLoadSettings_NewFormat(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "metadata.json") + + content := `{ + "settings": {"theme": "light"}, + "servers": {"server1": {"tags": ["prod"]}} + }` + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + settings, err := LoadSettings(tmpFile) + if err != nil { + t.Errorf("LoadSettings() unexpected error: %v", err) + } + if settings.Theme != "light" { + t.Errorf("LoadSettings() theme = %q, want %q", settings.Theme, "light") + } +} + +func TestLoadSettings_OldFormat(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "metadata.json") + + // Old format: servers directly at root level + content := `{ + "server1": {"tags": ["prod"], "ssh_count": 5}, + "server2": {"tags": ["dev"]} + }` + if err := os.WriteFile(tmpFile, []byte(content), 0o644); err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + settings, err := LoadSettings(tmpFile) + if err != nil { + t.Errorf("LoadSettings() unexpected error: %v", err) + } + // Old format has no settings, should return empty theme + if settings.Theme != "" { + t.Errorf("LoadSettings() with old format should return empty theme, got %q", settings.Theme) + } +} + +func TestMetadataManager_SaveAndGetSettings(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "metadata.json") + + mm := newMetadataManager(tmpFile, nil) + + // Save settings + settings := Settings{Theme: "light"} + if err := mm.SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() unexpected error: %v", err) + } + + // Read back + got, err := mm.GetSettings() + if err != nil { + t.Fatalf("GetSettings() unexpected error: %v", err) + } + if got.Theme != "light" { + t.Errorf("GetSettings() theme = %q, want %q", got.Theme, "light") + } +} + +func TestMetadataManager_SettingsPreserveServers(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "metadata.json") + + mm := newMetadataManager(tmpFile, nil) + + // First, save some server metadata + serverMeta := map[string]ServerMetadata{ + "server1": {Tags: []string{"prod"}, SSHCount: 5}, + } + if err := mm.saveAll(serverMeta); err != nil { + t.Fatalf("saveAll() unexpected error: %v", err) + } + + // Now save settings + settings := Settings{Theme: "light"} + if err := mm.SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() unexpected error: %v", err) + } + + // Verify servers are still there + servers, err := mm.loadAll() + if err != nil { + t.Fatalf("loadAll() unexpected error: %v", err) + } + if len(servers) != 1 { + t.Errorf("Expected 1 server, got %d", len(servers)) + } + if servers["server1"].SSHCount != 5 { + t.Errorf("Server metadata was not preserved, SSHCount = %d, want 5", servers["server1"].SSHCount) + } + + // Verify settings are there too + got, err := mm.GetSettings() + if err != nil { + t.Fatalf("GetSettings() unexpected error: %v", err) + } + if got.Theme != "light" { + t.Errorf("GetSettings() theme = %q, want %q", got.Theme, "light") + } +} + +func TestMetadataManager_ServersSavePreservesSettings(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "metadata.json") + + mm := newMetadataManager(tmpFile, nil) + + // First, save settings + settings := Settings{Theme: "light"} + if err := mm.SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() unexpected error: %v", err) + } + + // Now save server metadata + serverMeta := map[string]ServerMetadata{ + "server1": {Tags: []string{"prod"}, SSHCount: 10}, + } + if err := mm.saveAll(serverMeta); err != nil { + t.Fatalf("saveAll() unexpected error: %v", err) + } + + // Verify settings are still there + got, err := mm.GetSettings() + if err != nil { + t.Fatalf("GetSettings() unexpected error: %v", err) + } + if got.Theme != "light" { + t.Errorf("Settings were not preserved, theme = %q, want %q", got.Theme, "light") + } + + // Verify servers are there too + servers, err := mm.loadAll() + if err != nil { + t.Fatalf("loadAll() unexpected error: %v", err) + } + if servers["server1"].SSHCount != 10 { + t.Errorf("Server metadata incorrect, SSHCount = %d, want 10", servers["server1"].SSHCount) + } +} diff --git a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go index 37e8004..73af5e6 100644 --- a/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go +++ b/internal/adapters/data/ssh_config_file/ssh_config_file_repo.go @@ -164,3 +164,40 @@ func (r *Repository) SetPinned(alias string, pinned bool) error { func (r *Repository) RecordSSH(alias string) error { return r.metadataManager.recordSSH(alias) } + +// GetSettings returns the application settings. +func (r *Repository) GetSettings() (Settings, error) { + return r.metadataManager.GetSettings() +} + +// SaveSettings saves the application settings. +func (r *Repository) SaveSettings(settings Settings) error { + return r.metadataManager.SaveSettings(settings) +} + +// GetTheme returns the current theme name from settings. +func (r *Repository) GetTheme() (string, error) { + settings, err := r.metadataManager.GetSettings() + if err != nil { + return "", err + } + return settings.Theme, nil +} + +// SaveTheme saves the theme name to settings. +func (r *Repository) SaveTheme(theme string) error { + settings, err := r.metadataManager.GetSettings() + if err != nil { + // If we can't load settings, start with empty settings + settings = Settings{} + } + settings.Theme = theme + return r.metadataManager.SaveSettings(settings) +} + +// LoadSettings loads application settings from the metadata file at the given path. +// This is a standalone function for use during app initialization before the repository is created. +func LoadSettings(metaDataPath string) (Settings, error) { + mm := newMetadataManager(metaDataPath, nil) + return mm.GetSettings() +} diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 897e053..5126e6c 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -86,6 +86,9 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey { case 'x': t.handleStopForwarding() return nil + case 'T': + t.handleThemeToggle() + return nil case 'j': t.handleNavigateDown() return nil @@ -168,6 +171,34 @@ func (t *tui) handleNavigateUp() { } } +func (t *tui) handleThemeToggle() { + // Cycle theme: dark → light → system → dark + var newTheme string + switch CurrentThemeMode { + case ThemeDark: + newTheme = ThemeLight + case ThemeLight: + newTheme = ThemeSystem + default: + newTheme = ThemeDark + } + + // Save theme preference + if err := t.serverService.SaveTheme(newTheme); err != nil { + t.showStatusTempColor("Failed to save theme: "+err.Error(), CurrentTheme.StatusError) + return + } + + // Apply new theme + SetTheme(newTheme) + ApplyTheme() + + // Rebuild UI to apply theme changes + t.rebuildUI() + + t.showStatusTemp("Theme: " + newTheme) +} + func (t *tui) handleSearchInput(query string) { filtered, _ := t.serverService.ListServers(query) sortServersForUI(filtered, t.sortMode) @@ -296,13 +327,13 @@ func (t *tui) handlePingSelected() { up, dur, err := t.serverService.Ping(server) t.app.QueueUpdateDraw(func() { if err != nil { - t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), CurrentTheme.StatusError) return } if up { - t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0") + t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), CurrentTheme.StatusSuccess) } else { - t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), CurrentTheme.StatusError) } }) }() @@ -328,7 +359,7 @@ func (t *tui) handleRefreshBackground() { servers, err := t.serverService.ListServers(q) if err != nil { t.app.QueueUpdateDraw(func() { - t.showStatusTempColor(fmt.Sprintf("Refresh failed: %v", err), "#FF6B6B") + t.showStatusTempColor(fmt.Sprintf("Refresh failed: %v", err), CurrentTheme.StatusError) }) return } @@ -489,12 +520,12 @@ func (t *tui) showPortForwardForm(server domain.Server) { form.AddButton("Start", func() { if err := validatePort(portVal); err != nil { - t.showStatusTempColor("Invalid port: "+err.Error(), "#FF6B6B") + t.showStatusTempColor("Invalid port: "+err.Error(), CurrentTheme.StatusError) return } if bindAddrVal != "" { if err := validateBindAddress(bindAddrVal); err != nil { - t.showStatusTempColor("Invalid bind address: "+err.Error(), "#FF6B6B") + t.showStatusTempColor("Invalid bind address: "+err.Error(), CurrentTheme.StatusError) return } } @@ -509,11 +540,11 @@ func (t *tui) showPortForwardForm(server domain.Server) { args = append(args, "-D", spec) } else { if err := validateHost(hostVal); err != nil { - t.showStatusTempColor("Invalid host: "+err.Error(), "#FF6B6B") + t.showStatusTempColor("Invalid host: "+err.Error(), CurrentTheme.StatusError) return } if err := validatePort(hostPortVal); err != nil { - t.showStatusTempColor("Invalid host port: "+err.Error(), "#FF6B6B") + t.showStatusTempColor("Invalid host port: "+err.Error(), CurrentTheme.StatusError) return } spec := portVal + ":" + hostVal + ":" + hostPortVal @@ -536,7 +567,7 @@ func (t *tui) showPortForwardForm(server domain.Server) { pid, err := t.serverService.StartForward(alias, args) t.app.QueueUpdateDraw(func() { if err != nil { - t.showStatusTempColor("Forward failed: "+err.Error(), "#FF6B6B") + t.showStatusTempColor("Forward failed: "+err.Error(), CurrentTheme.StatusError) } else { t.refreshServerList() t.showStatusTemp(fmt.Sprintf("Port forwarding started (pid %d)", pid)) @@ -592,7 +623,7 @@ func (t *tui) showStatusTemp(msg string) { if t.statusBar == nil { return } - t.showStatusTempColor(msg, "#A0FFA0") + t.showStatusTempColor(msg, CurrentTheme.StatusSuccess) } // showStatusTempColor displays a temporary colored message in the status bar and restores default text after 2s. @@ -620,7 +651,7 @@ func (t *tui) handleStopForwarding() { err := t.serverService.StopForwarding(alias) t.app.QueueUpdateDraw(func() { if err != nil { - t.showStatusTempColor("Failed to stop forwarding: "+err.Error(), "#FF6B6B") + t.showStatusTempColor("Failed to stop forwarding: "+err.Error(), CurrentTheme.StatusError) } else { t.showStatusTemp("Stopped forwarding for " + alias) } diff --git a/internal/adapters/ui/header.go b/internal/adapters/ui/header.go index 45f7bd2..50bcbe3 100644 --- a/internal/adapters/ui/header.go +++ b/internal/adapters/ui/header.go @@ -15,6 +15,7 @@ package ui import ( + "fmt" "strings" "time" @@ -41,7 +42,7 @@ func NewAppHeader(version, gitCommit, repoURL string) *AppHeader { } func (h *AppHeader) build() { - headerBg := tcell.Color234 + headerBg := CurrentTheme.HeaderBackground left := h.buildLeftSection(headerBg) center := h.buildCenterSection(headerBg) @@ -64,7 +65,8 @@ func (h *AppHeader) buildLeftSection(bg tcell.Color) *tview.TextView { SetDynamicColors(true). SetTextAlign(tview.AlignLeft) left.SetBackgroundColor(bg) - stylizedName := "🚀 [#FFFFFF::b]lazy[-][#55D7FF::b]ssh[-]" + stylizedName := fmt.Sprintf("🚀 [%s::b]lazy[-][%s::b]ssh[-]", + CurrentTheme.BrandPrimary, CurrentTheme.BrandSecondary) left.SetText(stylizedName) return left } @@ -78,10 +80,10 @@ func (h *AppHeader) buildCenterSection(bg tcell.Color) *tview.TextView { commit := shortCommit(h.gitCommit) // Build tag-like chips for version, commit, and build time - versionTag := makeTag(h.version, "#22C55E") // green + versionTag := makeTag(h.version, CurrentTheme.VersionTag) commitTag := "" if commit != "" { - commitTag = makeTag(commit, "#A78BFA") // violet + commitTag = makeTag(commit, CurrentTheme.CommitTag) } text := versionTag @@ -99,14 +101,15 @@ func (h *AppHeader) buildRightSection(bg tcell.Color) *tview.TextView { SetTextAlign(tview.AlignRight) right.SetBackgroundColor(bg) currentTime := time.Now().Format("Mon, 02 Jan 2006 15:04") - right.SetText("[#55AAFF::u]🔗 " + h.repoURL + "[-] [#AAAAAA]• " + currentTime + "[-]") + right.SetText(fmt.Sprintf("[%s::u]🔗 %s[-] [%s]• %s[-]", + CurrentTheme.LinkColor, h.repoURL, CurrentTheme.MutedText, currentTime)) return right } func (h *AppHeader) createSeparator() *tview.TextView { separator := tview.NewTextView().SetDynamicColors(true) - separator.SetBackgroundColor(tcell.Color235) - separator.SetText("[#444444]" + strings.Repeat("─", 200) + "[-]") + separator.SetBackgroundColor(CurrentTheme.ContrastBackground) + separator.SetText(fmt.Sprintf("[%s]%s[-]", CurrentTheme.Separator, strings.Repeat("─", 200))) return separator } diff --git a/internal/adapters/ui/search_bar.go b/internal/adapters/ui/search_bar.go index 7d373b3..4310a7f 100644 --- a/internal/adapters/ui/search_bar.go +++ b/internal/adapters/ui/search_bar.go @@ -36,14 +36,14 @@ func NewSearchBar() *SearchBar { func (s *SearchBar) build() { s.InputField.SetLabel(" 🔍 Search: "). - SetFieldBackgroundColor(tcell.Color233). - SetFieldTextColor(tcell.Color252). + SetFieldBackgroundColor(CurrentTheme.SearchFieldBg). + SetFieldTextColor(CurrentTheme.SearchFieldText). SetFieldWidth(30). SetBorder(true). SetTitle(" Search "). SetTitleAlign(tview.AlignCenter). - SetBorderColor(tcell.Color238). - SetTitleColor(tcell.Color250) + SetBorderColor(CurrentTheme.BorderColor). + SetTitleColor(CurrentTheme.TitleColor) s.InputField.SetChangedFunc(func(text string) { if s.onSearch != nil { diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 48befc3..8ccb093 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -19,7 +19,6 @@ import ( "strings" "github.com/Adembc/lazyssh/internal/core/domain" - "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -41,8 +40,8 @@ func (sd *ServerDetails) build() { SetBorder(true). SetTitle(" Details "). SetTitleAlign(tview.AlignCenter). - SetBorderColor(tcell.Color238). - SetTitleColor(tcell.Color250) + SetBorderColor(CurrentTheme.BorderColor). + SetTitleColor(CurrentTheme.TitleColor) } // renderTagChips builds colored tag chips for details view. @@ -52,7 +51,8 @@ func renderTagChips(tags []string) string { } chips := make([]string, 0, len(tags)) for _, t := range tags { - chips = append(chips, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t)) + chips = append(chips, fmt.Sprintf("[%s:%s] %s [-:-:-]", + CurrentTheme.TagChipText, CurrentTheme.TagChipBg, t)) } return strings.Join(chips, " ") } diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 286b47f..6ae213b 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -131,8 +131,8 @@ func (sf *ServerForm) build() { sf.formPanel.SetBorder(true). SetTitle(" " + sf.titleForMode() + " "). SetTitleAlign(tview.AlignCenter). - SetBorderColor(tcell.Color238). - SetTitleColor(tcell.Color250) + SetBorderColor(CurrentTheme.BorderColor). + SetTitleColor(CurrentTheme.TitleColor) sf.formPanel.AddItem(sf.tabBar, 1, 0, false). AddItem(sf.pages, 0, 1, true) @@ -170,9 +170,10 @@ func (sf *ServerForm) build() { // Create hint bar with same background as main screen's status bar hintBar := tview.NewTextView().SetDynamicColors(true) - hintBar.SetBackgroundColor(tcell.Color235) + hintBar.SetBackgroundColor(CurrentTheme.StatusBarBackground) hintBar.SetTextAlign(tview.AlignCenter) - hintBar.SetText("[white]^H/^L[-] Navigate • [white]^S[-] Save • [white]Esc[-] Cancel") + k := CurrentTheme.HintKey + hintBar.SetText(fmt.Sprintf("[%s]^H/^L[-] Navigate • [%s]^S[-] Save • [%s]Esc[-] Cancel", k, k, k)) // Setup main container - header at top, hint bar at bottom sf.Flex.AddItem(sf.header, 2, 0, false). @@ -485,7 +486,7 @@ func (sf *ServerForm) formatDetailedHelp(help *FieldHelp) string { // Title with field name and separator below b.WriteString(fmt.Sprintf("[yellow::b]📖 %s[-::-]\n", help.Field)) - b.WriteString("[#444444]" + strings.Repeat("─", separatorWidth) + "[-]\n\n") + b.WriteString(fmt.Sprintf("[%s]%s[-]\n\n", CurrentTheme.Separator, strings.Repeat("─", separatorWidth))) // Description - needs escaping as it might contain brackets b.WriteString(fmt.Sprintf("%s\n\n", escapeForTview(help.Description))) @@ -1932,7 +1933,7 @@ func (sf *ServerForm) handleSave() bool { // Reset title and border (validation already done above) sf.formPanel.SetTitle(" " + sf.titleForMode() + " ") - sf.formPanel.SetBorderColor(tcell.Color238) + sf.formPanel.SetBorderColor(CurrentTheme.BorderColor) server := sf.dataToServer(data) if sf.onSave != nil { diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index 1a58d39..029bdcc 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -41,11 +41,11 @@ func (sl *ServerList) build() { sl.List.SetBorder(true). SetTitle(" Servers "). SetTitleAlign(tview.AlignCenter). - SetBorderColor(tcell.Color238). - SetTitleColor(tcell.Color250) + SetBorderColor(CurrentTheme.BorderColor). + SetTitleColor(CurrentTheme.TitleColor) sl.List. - SetSelectedBackgroundColor(tcell.Color24). - SetSelectedTextColor(tcell.Color255). + SetSelectedBackgroundColor(CurrentTheme.SelectedBackground). + SetSelectedTextColor(CurrentTheme.SelectedText). SetHighlightFullLine(true) sl.List.SetChangedFunc(func(index int, mainText string, secondaryText string, shortcut rune) { diff --git a/internal/adapters/ui/status_bar.go b/internal/adapters/ui/status_bar.go index 1d3c690..4997099 100644 --- a/internal/adapters/ui/status_bar.go +++ b/internal/adapters/ui/status_bar.go @@ -15,17 +15,20 @@ package ui import ( - "github.com/gdamore/tcell/v2" + "fmt" + "github.com/rivo/tview" ) func DefaultStatusText() string { - return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]f[-] Forward • [white]x[-] Stop Forward • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit" + k := CurrentTheme.HintKey + return fmt.Sprintf("[%s]↑↓[-] Navigate • [%s]Enter[-] SSH • [%s]f[-] Forward • [%s]x[-] Stop • [%s]c[-] Copy • [%s]a[-] Add • [%s]e[-] Edit • [%s]g[-] Ping • [%s]d[-] Del • [%s]p[-] Pin • [%s]T[-] Theme • [%s]/[-] Search • [%s]q[-] Quit", + k, k, k, k, k, k, k, k, k, k, k, k, k) } func NewStatusBar() *tview.TextView { status := tview.NewTextView().SetDynamicColors(true) - status.SetBackgroundColor(tcell.Color235) + status.SetBackgroundColor(CurrentTheme.StatusBarBackground) status.SetTextAlign(tview.AlignCenter) status.SetText(DefaultStatusText()) return status diff --git a/internal/adapters/ui/theme.go b/internal/adapters/ui/theme.go new file mode 100644 index 0000000..9e7ae0b --- /dev/null +++ b/internal/adapters/ui/theme.go @@ -0,0 +1,194 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// Theme name constants. +const ( + ThemeDark = "dark" + ThemeLight = "light" + ThemeSystem = "system" +) + +// Theme defines all colors used throughout the application. +// It contains both tcell.Color values for component styling and +// hex color strings for tview markup-based dynamic coloring. +type Theme struct { + Name string + + // tview.Styles colors (applied globally via ApplyTheme) + PrimitiveBackground tcell.Color + ContrastBackground tcell.Color + BorderColor tcell.Color + TitleColor tcell.Color + PrimaryText tcell.Color + SecondaryText tcell.Color + GraphicsColor tcell.Color + + // Component-specific tcell colors + HeaderBackground tcell.Color + SearchFieldBg tcell.Color + SearchFieldText tcell.Color + SelectedBackground tcell.Color + SelectedText tcell.Color + StatusBarBackground tcell.Color + + // Hex colors for tview markup (used in SetText with dynamic colors) + BrandPrimary string // "lazy" text in header + BrandSecondary string // "ssh" text in header + VersionTag string // version chip background + CommitTag string // commit chip background + LinkColor string // clickable links + MutedText string // secondary/muted text + DimText string // tertiary/dim text + Separator string // separator lines + TagChipBg string // tag chip background + TagChipText string // tag chip text + TagExtra string // "+N" extra tags indicator + ForwardingActive string // active forwarding indicator + StatusSuccess string // success status messages + StatusError string // error status messages + HintKey string // keyboard hint keys +} + +// DarkTheme is the default dark color scheme. +var DarkTheme = Theme{ + Name: ThemeDark, + + // tview.Styles colors + PrimitiveBackground: tcell.Color232, + ContrastBackground: tcell.Color235, + BorderColor: tcell.Color238, + TitleColor: tcell.Color250, + PrimaryText: tcell.Color252, + SecondaryText: tcell.Color245, + GraphicsColor: tcell.Color238, + + // Component-specific colors + HeaderBackground: tcell.Color234, + SearchFieldBg: tcell.Color233, + SearchFieldText: tcell.Color252, + SelectedBackground: tcell.Color24, + SelectedText: tcell.Color255, + StatusBarBackground: tcell.Color235, + + // Hex colors for markup + BrandPrimary: "#FFFFFF", + BrandSecondary: "#55D7FF", + VersionTag: "#22C55E", + CommitTag: "#A78BFA", + LinkColor: "#55AAFF", + MutedText: "#AAAAAA", + DimText: "#888888", + Separator: "#444444", + TagChipBg: "#5FAFFF", + TagChipText: "black", + TagExtra: "#8A8A8A", + ForwardingActive: "#A0FFA0", + StatusSuccess: "#A0FFA0", + StatusError: "#FF6B6B", + HintKey: "white", +} + +// LightTheme is the light color scheme for better visibility in bright environments. +var LightTheme = Theme{ + Name: ThemeLight, + + // tview.Styles colors + PrimitiveBackground: tcell.Color255, + ContrastBackground: tcell.Color254, + BorderColor: tcell.Color245, + TitleColor: tcell.Color236, + PrimaryText: tcell.Color232, + SecondaryText: tcell.Color240, + GraphicsColor: tcell.Color245, + + // Component-specific colors + HeaderBackground: tcell.Color253, + SearchFieldBg: tcell.Color254, + SearchFieldText: tcell.Color232, + SelectedBackground: tcell.Color39, + SelectedText: tcell.Color232, + StatusBarBackground: tcell.Color254, + + // Hex colors for markup + BrandPrimary: "#1A1A1A", + BrandSecondary: "#0088CC", + VersionTag: "#16A34A", + CommitTag: "#7C3AED", + LinkColor: "#0066CC", + MutedText: "#666666", + DimText: "#888888", + Separator: "#CCCCCC", + TagChipBg: "#0088CC", + TagChipText: "white", + TagExtra: "#666666", + ForwardingActive: "#16A34A", + StatusSuccess: "#16A34A", + StatusError: "#DC2626", + HintKey: "#1A1A1A", +} + +// CurrentTheme is the active theme used throughout the application. +// It defaults to DarkTheme and can be changed via SetTheme. +var CurrentTheme = &DarkTheme + +// CurrentThemeMode tracks the user's theme selection (dark, light, or system). +// This may differ from CurrentTheme.Name when system theme is selected. +var CurrentThemeMode = ThemeDark + +// SetTheme sets the current theme by name. +// Valid names are ThemeDark, ThemeLight, and ThemeSystem. +// ThemeSystem detects the OS preference. Unknown names default to dark. +func SetTheme(name string) { + CurrentThemeMode = name + switch name { + case ThemeLight: + CurrentTheme = &LightTheme + case ThemeSystem: + // Detect OS preference and apply appropriate theme + detected := detectOSTheme() + if detected == ThemeLight { + CurrentTheme = &LightTheme + } else { + CurrentTheme = &DarkTheme + } + default: + CurrentThemeMode = ThemeDark + CurrentTheme = &DarkTheme + } +} + +// ApplyTheme applies the current theme to tview's global Styles. +// This should be called once during initialization, before any UI components are created. +func ApplyTheme() { + tview.Styles.PrimitiveBackgroundColor = CurrentTheme.PrimitiveBackground + tview.Styles.ContrastBackgroundColor = CurrentTheme.ContrastBackground + tview.Styles.BorderColor = CurrentTheme.BorderColor + tview.Styles.TitleColor = CurrentTheme.TitleColor + tview.Styles.PrimaryTextColor = CurrentTheme.PrimaryText + tview.Styles.SecondaryTextColor = CurrentTheme.SecondaryText + tview.Styles.TertiaryTextColor = CurrentTheme.SecondaryText + tview.Styles.GraphicsColor = CurrentTheme.GraphicsColor +} + +// GetThemeNames returns the list of available theme names. +func GetThemeNames() []string { + return []string{ThemeDark, ThemeLight, ThemeSystem} +} diff --git a/internal/adapters/ui/theme_detect_darwin.go b/internal/adapters/ui/theme_detect_darwin.go new file mode 100644 index 0000000..f18d267 --- /dev/null +++ b/internal/adapters/ui/theme_detect_darwin.go @@ -0,0 +1,43 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build darwin + +package ui + +import ( + "os/exec" + "strings" +) + +// detectOSTheme queries macOS for the system appearance. +// Returns ThemeDark or ThemeLight. Defaults to ThemeLight if detection fails +// (macOS defaults to light mode when AppleInterfaceStyle is not set). +func detectOSTheme() string { + // defaults read -g AppleInterfaceStyle returns "Dark" if dark mode + // If light mode, the key doesn't exist and command returns error + cmd := exec.Command("defaults", "read", "-g", "AppleInterfaceStyle") + + output, err := cmd.Output() + if err != nil { + // Key not found = light mode (macOS default) + return ThemeLight + } + + result := strings.TrimSpace(string(output)) + if strings.EqualFold(result, "Dark") { + return ThemeDark + } + return ThemeLight +} diff --git a/internal/adapters/ui/theme_detect_linux.go b/internal/adapters/ui/theme_detect_linux.go new file mode 100644 index 0000000..5b7c130 --- /dev/null +++ b/internal/adapters/ui/theme_detect_linux.go @@ -0,0 +1,47 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package ui + +import ( + "os/exec" + "strings" +) + +// detectOSTheme queries the freedesktop portal for the system color scheme. +// Returns ThemeDark or ThemeLight. Defaults to ThemeDark if detection fails. +func detectOSTheme() string { + // Query freedesktop portal: org.freedesktop.appearance color-scheme + // Returns: 0 = no preference, 1 = prefer dark, 2 = prefer light + cmd := exec.Command("gdbus", "call", "--session", + "--dest", "org.freedesktop.portal.Desktop", + "--object-path", "/org/freedesktop/portal/desktop", + "--method", "org.freedesktop.portal.Settings.Read", + "org.freedesktop.appearance", "color-scheme") + + output, err := cmd.Output() + if err != nil { + return ThemeDark // Default to dark if detection fails + } + + // Parse output like "(<>,)" or "(<>,)" + // Value 2 = prefer light, anything else = dark + result := string(output) + if strings.Contains(result, "uint32 2") { + return ThemeLight + } + return ThemeDark +} diff --git a/internal/adapters/ui/theme_detect_windows.go b/internal/adapters/ui/theme_detect_windows.go new file mode 100644 index 0000000..32b83d8 --- /dev/null +++ b/internal/adapters/ui/theme_detect_windows.go @@ -0,0 +1,44 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package ui + +import ( + "os/exec" + "strings" +) + +// detectOSTheme queries Windows registry for the system theme preference. +// Returns ThemeDark or ThemeLight. Defaults to ThemeDark if detection fails. +func detectOSTheme() string { + // Query Windows registry for AppsUseLightTheme + // Value 0 = dark mode, 1 = light mode + cmd := exec.Command("reg", "query", + `HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize`, + "/v", "AppsUseLightTheme") + + output, err := cmd.Output() + if err != nil { + return ThemeDark // Default to dark if detection fails + } + + // Output format: "AppsUseLightTheme REG_DWORD 0x1" (light) or "0x0" (dark) + result := string(output) + if strings.Contains(result, "0x1") { + return ThemeLight + } + return ThemeDark +} diff --git a/internal/adapters/ui/theme_test.go b/internal/adapters/ui/theme_test.go new file mode 100644 index 0000000..c556f00 --- /dev/null +++ b/internal/adapters/ui/theme_test.go @@ -0,0 +1,175 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ui + +import ( + "testing" + + "github.com/gdamore/tcell/v2" +) + +func TestSetTheme(t *testing.T) { + tests := []struct { + name string + themeName string + expectedMode string + expectedThemeIn []string // CurrentTheme.Name should be one of these + }{ + { + name: "set dark theme", + themeName: ThemeDark, + expectedMode: ThemeDark, + expectedThemeIn: []string{ThemeDark}, + }, + { + name: "set light theme", + themeName: ThemeLight, + expectedMode: ThemeLight, + expectedThemeIn: []string{ThemeLight}, + }, + { + name: "set system theme", + themeName: ThemeSystem, + expectedMode: ThemeSystem, + expectedThemeIn: []string{ThemeDark, ThemeLight}, // depends on OS + }, + { + name: "unknown theme defaults to dark", + themeName: "unknown", + expectedMode: ThemeDark, + expectedThemeIn: []string{ThemeDark}, + }, + { + name: "empty string defaults to dark", + themeName: "", + expectedMode: ThemeDark, + expectedThemeIn: []string{ThemeDark}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + SetTheme(tt.themeName) + if CurrentThemeMode != tt.expectedMode { + t.Errorf("SetTheme(%q) mode = %q, want %q", tt.themeName, CurrentThemeMode, tt.expectedMode) + } + found := false + for _, expected := range tt.expectedThemeIn { + if CurrentTheme.Name == expected { + found = true + break + } + } + if !found { + t.Errorf("SetTheme(%q) theme = %q, want one of %v", tt.themeName, CurrentTheme.Name, tt.expectedThemeIn) + } + }) + } + + // Reset to dark theme after tests + SetTheme(ThemeDark) +} + +func TestGetThemeNames(t *testing.T) { + names := GetThemeNames() + + if len(names) != 3 { + t.Errorf("GetThemeNames() returned %d themes, want 3", len(names)) + } + + expectedNames := map[string]bool{ThemeDark: true, ThemeLight: true, ThemeSystem: true} + for _, name := range names { + if !expectedNames[name] { + t.Errorf("GetThemeNames() contains unexpected theme %q", name) + } + } +} + +func TestDarkThemeColors(t *testing.T) { + // Verify dark theme has reasonable color values + theme := DarkTheme + + if theme.Name != ThemeDark { + t.Errorf("DarkTheme.Name = %q, want %q", theme.Name, ThemeDark) + } + + // Check that background is dark (low color number = dark) + if theme.PrimitiveBackground > tcell.Color240 { + t.Errorf("DarkTheme.PrimitiveBackground should be a dark color, got %v", theme.PrimitiveBackground) + } + + // Check hex colors are not empty + hexColors := []struct { + name string + value string + }{ + {"BrandPrimary", theme.BrandPrimary}, + {"BrandSecondary", theme.BrandSecondary}, + {"StatusSuccess", theme.StatusSuccess}, + {"StatusError", theme.StatusError}, + } + + for _, hc := range hexColors { + if hc.value == "" { + t.Errorf("DarkTheme.%s should not be empty", hc.name) + } + } +} + +func TestLightThemeColors(t *testing.T) { + // Verify light theme has reasonable color values + theme := LightTheme + + if theme.Name != ThemeLight { + t.Errorf("LightTheme.Name = %q, want %q", theme.Name, ThemeLight) + } + + // Check that background is light (high color number = light) + if theme.PrimitiveBackground < tcell.Color240 { + t.Errorf("LightTheme.PrimitiveBackground should be a light color, got %v", theme.PrimitiveBackground) + } + + // Check hex colors are not empty + hexColors := []struct { + name string + value string + }{ + {"BrandPrimary", theme.BrandPrimary}, + {"BrandSecondary", theme.BrandSecondary}, + {"StatusSuccess", theme.StatusSuccess}, + {"StatusError", theme.StatusError}, + } + + for _, hc := range hexColors { + if hc.value == "" { + t.Errorf("LightTheme.%s should not be empty", hc.name) + } + } +} + +func TestThemesHaveDifferentColors(t *testing.T) { + // Ensure dark and light themes are actually different + if DarkTheme.PrimitiveBackground == LightTheme.PrimitiveBackground { + t.Error("DarkTheme and LightTheme should have different PrimitiveBackground colors") + } + + if DarkTheme.BrandPrimary == LightTheme.BrandPrimary { + t.Error("DarkTheme and LightTheme should have different BrandPrimary colors") + } + + if DarkTheme.MutedText == LightTheme.MutedText { + t.Error("DarkTheme and LightTheme should have different MutedText colors") + } +} diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index d938e6f..c5d0ff3 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -15,7 +15,6 @@ package ui import ( - "github.com/gdamore/tcell/v2" "go.uber.org/zap" "github.com/Adembc/lazyssh/internal/core/ports" @@ -65,7 +64,11 @@ func (t *tui) Run() error { } }() t.app.EnableMouse(true) - t.initializeTheme().buildComponents().buildLayout().bindEvents().loadInitialData() + t.initializeTheme() + t.buildComponents() + t.buildLayout() + t.bindEvents() + t.loadInitialData() t.app.SetRoot(t.root, true) t.logger.Infow("starting TUI application", "version", t.version, "commit", t.commit) if err := t.app.Run(); err != nil { @@ -75,19 +78,11 @@ func (t *tui) Run() error { return nil } -func (t *tui) initializeTheme() *tui { - tview.Styles.PrimitiveBackgroundColor = tcell.Color232 - tview.Styles.ContrastBackgroundColor = tcell.Color235 - tview.Styles.BorderColor = tcell.Color238 - tview.Styles.TitleColor = tcell.Color250 - tview.Styles.PrimaryTextColor = tcell.Color252 - tview.Styles.TertiaryTextColor = tcell.Color245 - tview.Styles.SecondaryTextColor = tcell.Color245 - tview.Styles.GraphicsColor = tcell.Color238 - return t +func (t *tui) initializeTheme() { + ApplyTheme() } -func (t *tui) buildComponents() *tui { +func (t *tui) buildComponents() { t.header = NewAppHeader(t.version, t.commit, RepoURL) t.searchBar = NewSearchBar(). OnSearch(t.handleSearchInput). @@ -103,11 +98,9 @@ func (t *tui) buildComponents() *tui { // default sort mode t.sortMode = SortByAliasAsc - - return t } -func (t *tui) buildLayout() *tui { +func (t *tui) buildLayout() { t.left = tview.NewFlex().SetDirection(tview.FlexRow). AddItem(t.searchBar, 3, 0, false). AddItem(t.serverList, 0, 1, true) @@ -123,21 +116,17 @@ func (t *tui) buildLayout() *tui { AddItem(t.header, 2, 0, false). AddItem(t.content, 0, 1, true). AddItem(t.statusBar, 1, 0, false) - return t } -func (t *tui) bindEvents() *tui { +func (t *tui) bindEvents() { t.root.SetInputCapture(t.handleGlobalKeys) - return t } -func (t *tui) loadInitialData() *tui { +func (t *tui) loadInitialData() { servers, _ := t.serverService.ListServers("") sortServersForUI(servers, t.sortMode) t.updateListTitle() t.serverList.UpdateServers(servers) - - return t } func (t *tui) updateListTitle() { @@ -145,3 +134,38 @@ func (t *tui) updateListTitle() { t.serverList.SetTitle(" Servers — Sort: " + t.sortMode.String() + " ") } } + +// rebuildUI rebuilds all UI components to apply theme changes. +// It preserves the current state (search query, selection, sort mode). +func (t *tui) rebuildUI() { + // Save current state + query := "" + if t.searchBar != nil { + query = t.searchBar.InputField.GetText() + } + currentIdx := 0 + if t.serverList != nil { + currentIdx = t.serverList.GetCurrentItem() + } + + // Rebuild components + t.buildComponents() + t.buildLayout() + t.bindEvents() + + // Restore state + if query != "" { + t.searchBar.InputField.SetText(query) + } + t.loadInitialData() + if currentIdx >= 0 && currentIdx < t.serverList.GetItemCount() { + t.serverList.SetCurrentItem(currentIdx) + } + if srv, ok := t.serverList.GetSelectedServer(); ok { + t.details.UpdateServer(srv) + } + + // Update display + t.app.SetRoot(t.root, true) + t.app.SetFocus(t.serverList) +} diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 0b49ad8..394fc5e 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -55,10 +55,11 @@ func renderTagBadgesForList(tags []string) string { parts := make([]string, 0, len(shown)+1) for _, t := range shown { // Light blue background chip, similar to details view. - parts = append(parts, fmt.Sprintf("[black:#5FAFFF] %s [-:-:-]", t)) + parts = append(parts, fmt.Sprintf("[%s:%s] %s [-:-:-]", + CurrentTheme.TagChipText, CurrentTheme.TagChipBg, t)) } if extra := len(tags) - len(shown); extra > 0 { - parts = append(parts, fmt.Sprintf("[#8A8A8A]+%d[-]", extra)) + parts = append(parts, fmt.Sprintf("[%s]+%d[-]", CurrentTheme.TagExtra, extra)) } return strings.Join(parts, " ") } @@ -91,10 +92,12 @@ func formatServerLine(s domain.Server) (primary, secondary string) { } fCol := cellPad(fGlyph, 2) if isFwd { - fCol = "[#A0FFA0]" + fCol + "[-]" + fCol = fmt.Sprintf("[%s]%s[-]", CurrentTheme.ForwardingActive, fCol) } // Use a consistent color for alias; host/IP fixed width; then forwarding column - primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] %s [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, fCol, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags)) + primary = fmt.Sprintf("%s [%s::b]%-12s[-] [%s]%-18s[-] %s [%s]Last SSH: %s[-] %s", + icon, CurrentTheme.HintKey, s.Alias, CurrentTheme.MutedText, s.Host, + fCol, CurrentTheme.DimText, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags)) secondary = "" return } diff --git a/internal/core/ports/repositories.go b/internal/core/ports/repositories.go index 133e4f8..28e744d 100644 --- a/internal/core/ports/repositories.go +++ b/internal/core/ports/repositories.go @@ -23,4 +23,6 @@ type ServerRepository interface { DeleteServer(server domain.Server) error SetPinned(alias string, pinned bool) error RecordSSH(alias string) error + GetTheme() (string, error) + SaveTheme(theme string) error } diff --git a/internal/core/ports/services.go b/internal/core/ports/services.go index 2407269..040bcd9 100644 --- a/internal/core/ports/services.go +++ b/internal/core/ports/services.go @@ -32,4 +32,6 @@ type ServerService interface { StopForwarding(alias string) error IsForwarding(alias string) bool Ping(server domain.Server) (bool, time.Duration, error) + GetTheme() (string, error) + SaveTheme(theme string) error } diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index 7926bc6..5523e5d 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -337,6 +337,16 @@ func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) return true, time.Since(start), nil } +// GetTheme returns the current theme name from settings. +func (s *serverService) GetTheme() (string, error) { + return s.serverRepository.GetTheme() +} + +// SaveTheme saves the theme name to settings. +func (s *serverService) SaveTheme(theme string) error { + return s.serverRepository.SaveTheme(theme) +} + // resolveSSHDestination uses `ssh -G ` to extract HostName and Port from the user's SSH config. // Returns host, port, ok where ok=false if resolution failed. func resolveSSHDestination(alias string) (string, int, bool) { From f9815101d89263d659e194957ebaee697ec4a66f Mon Sep 17 00:00:00 2001 From: david s Date: Fri, 9 Jan 2026 10:07:09 +0200 Subject: [PATCH 2/3] refactor(ui): add semantic AliasText theme color Separate alias text color from keyboard hint color for independent styling. --- internal/adapters/ui/theme.go | 3 +++ internal/adapters/ui/utils.go | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/adapters/ui/theme.go b/internal/adapters/ui/theme.go index 9e7ae0b..8afdec9 100644 --- a/internal/adapters/ui/theme.go +++ b/internal/adapters/ui/theme.go @@ -65,6 +65,7 @@ type Theme struct { StatusSuccess string // success status messages StatusError string // error status messages HintKey string // keyboard hint keys + AliasText string // server alias in list } // DarkTheme is the default dark color scheme. @@ -104,6 +105,7 @@ var DarkTheme = Theme{ StatusSuccess: "#A0FFA0", StatusError: "#FF6B6B", HintKey: "white", + AliasText: "white", } // LightTheme is the light color scheme for better visibility in bright environments. @@ -143,6 +145,7 @@ var LightTheme = Theme{ StatusSuccess: "#16A34A", StatusError: "#DC2626", HintKey: "#1A1A1A", + AliasText: "#1A1A1A", } // CurrentTheme is the active theme used throughout the application. diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 394fc5e..44e288d 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -96,7 +96,7 @@ func formatServerLine(s domain.Server) (primary, secondary string) { } // Use a consistent color for alias; host/IP fixed width; then forwarding column primary = fmt.Sprintf("%s [%s::b]%-12s[-] [%s]%-18s[-] %s [%s]Last SSH: %s[-] %s", - icon, CurrentTheme.HintKey, s.Alias, CurrentTheme.MutedText, s.Host, + icon, CurrentTheme.AliasText, s.Alias, CurrentTheme.MutedText, s.Host, fCol, CurrentTheme.DimText, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags)) secondary = "" return From 2140c60119eadacdde1cff307a4864deda513271 Mon Sep 17 00:00:00 2001 From: david s Date: Sun, 11 Jan 2026 10:28:56 +0200 Subject: [PATCH 3/3] feat(ui): add live system theme following - Watch for OS theme changes when "system" mode is selected - Linux: event-driven via gdbus monitor (instant) - macOS: polling-based detection (2s interval) - Windows: stub implementation (detection only, no live updates) - Improve light theme VersionTag/CommitTag contrast --- internal/adapters/ui/handlers.go | 9 ++ internal/adapters/ui/theme.go | 4 +- internal/adapters/ui/theme_watch_darwin.go | 92 +++++++++++++++ internal/adapters/ui/theme_watch_linux.go | 118 ++++++++++++++++++++ internal/adapters/ui/theme_watch_windows.go | 35 ++++++ internal/adapters/ui/tui.go | 39 ++++++- 6 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 internal/adapters/ui/theme_watch_darwin.go create mode 100644 internal/adapters/ui/theme_watch_linux.go create mode 100644 internal/adapters/ui/theme_watch_windows.go diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 5126e6c..f2955f0 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -189,6 +189,15 @@ func (t *tui) handleThemeToggle() { return } + // Manage theme watcher based on new mode + if t.themeWatcher != nil { + if newTheme == ThemeSystem { + t.themeWatcher.Start() + } else { + t.themeWatcher.Stop() + } + } + // Apply new theme SetTheme(newTheme) ApplyTheme() diff --git a/internal/adapters/ui/theme.go b/internal/adapters/ui/theme.go index 8afdec9..775ca3b 100644 --- a/internal/adapters/ui/theme.go +++ b/internal/adapters/ui/theme.go @@ -132,8 +132,8 @@ var LightTheme = Theme{ // Hex colors for markup BrandPrimary: "#1A1A1A", BrandSecondary: "#0088CC", - VersionTag: "#16A34A", - CommitTag: "#7C3AED", + VersionTag: "#065F46", + CommitTag: "#4C1D95", LinkColor: "#0066CC", MutedText: "#666666", DimText: "#888888", diff --git a/internal/adapters/ui/theme_watch_darwin.go b/internal/adapters/ui/theme_watch_darwin.go new file mode 100644 index 0000000..00f7dce --- /dev/null +++ b/internal/adapters/ui/theme_watch_darwin.go @@ -0,0 +1,92 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build darwin + +package ui + +import ( + "sync" + "time" +) + +// ThemeWatcher monitors system theme changes and triggers a callback. +// On macOS, this uses polling since there's no simple CLI for theme change events. +type ThemeWatcher struct { + stopCh chan struct{} + onChange func(theme string) + mu sync.Mutex + running bool + lastTheme string +} + +// NewThemeWatcher creates a new theme watcher with the given callback. +func NewThemeWatcher(onChange func(theme string)) *ThemeWatcher { + return &ThemeWatcher{ + onChange: onChange, + stopCh: make(chan struct{}), + } +} + +// Start begins watching for system theme changes. +func (w *ThemeWatcher) Start() { + w.mu.Lock() + if w.running { + w.mu.Unlock() + return + } + w.running = true + w.stopCh = make(chan struct{}) + w.lastTheme = detectOSTheme() + w.mu.Unlock() + + go w.poll() +} + +// Stop stops watching for theme changes. +func (w *ThemeWatcher) Stop() { + w.mu.Lock() + defer w.mu.Unlock() + + if !w.running { + return + } + w.running = false + close(w.stopCh) +} + +// poll checks for theme changes every 2 seconds. +func (w *ThemeWatcher) poll() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-w.stopCh: + return + case <-ticker.C: + currentTheme := detectOSTheme() + w.mu.Lock() + if currentTheme != w.lastTheme { + w.lastTheme = currentTheme + if w.onChange != nil { + w.mu.Unlock() + w.onChange(currentTheme) + continue + } + } + w.mu.Unlock() + } + } +} diff --git a/internal/adapters/ui/theme_watch_linux.go b/internal/adapters/ui/theme_watch_linux.go new file mode 100644 index 0000000..ee249e8 --- /dev/null +++ b/internal/adapters/ui/theme_watch_linux.go @@ -0,0 +1,118 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package ui + +import ( + "bufio" + "os/exec" + "strings" + "sync" +) + +// ThemeWatcher monitors system theme changes and triggers a callback. +type ThemeWatcher struct { + cmd *exec.Cmd + stopCh chan struct{} + onChange func(theme string) + mu sync.Mutex + running bool +} + +// NewThemeWatcher creates a new theme watcher with the given callback. +func NewThemeWatcher(onChange func(theme string)) *ThemeWatcher { + return &ThemeWatcher{ + onChange: onChange, + stopCh: make(chan struct{}), + } +} + +// Start begins watching for system theme changes. +func (w *ThemeWatcher) Start() { + w.mu.Lock() + if w.running { + w.mu.Unlock() + return + } + w.running = true + w.stopCh = make(chan struct{}) + w.mu.Unlock() + + go w.watch() +} + +// Stop stops watching for theme changes. +func (w *ThemeWatcher) Stop() { + w.mu.Lock() + defer w.mu.Unlock() + + if !w.running { + return + } + w.running = false + close(w.stopCh) + + if w.cmd != nil && w.cmd.Process != nil { + _ = w.cmd.Process.Kill() + w.cmd = nil + } +} + +// watch monitors the freedesktop portal for color-scheme changes. +func (w *ThemeWatcher) watch() { + // Monitor the settings changed signal from the freedesktop portal + w.cmd = exec.Command("gdbus", "monitor", "--session", + "--dest", "org.freedesktop.portal.Desktop", + "--object-path", "/org/freedesktop/portal/desktop") + + stdout, err := w.cmd.StdoutPipe() + if err != nil { + return + } + + if err := w.cmd.Start(); err != nil { + return + } + + scanner := bufio.NewScanner(stdout) + go func() { + for scanner.Scan() { + select { + case <-w.stopCh: + return + default: + line := scanner.Text() + // Look for SettingChanged signal with color-scheme + if strings.Contains(line, "SettingChanged") && + strings.Contains(line, "org.freedesktop.appearance") && + strings.Contains(line, "color-scheme") { + // Detect the new theme + newTheme := detectOSTheme() + if w.onChange != nil { + w.onChange(newTheme) + } + } + } + } + }() + + // Wait for stop signal + <-w.stopCh + if w.cmd.Process != nil { + _ = w.cmd.Process.Kill() + } + _ = w.cmd.Wait() +} diff --git a/internal/adapters/ui/theme_watch_windows.go b/internal/adapters/ui/theme_watch_windows.go new file mode 100644 index 0000000..f3c3756 --- /dev/null +++ b/internal/adapters/ui/theme_watch_windows.go @@ -0,0 +1,35 @@ +// Copyright 2025. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package ui + +// ThemeWatcher monitors system theme changes and triggers a callback. +// On Windows, live theme watching is not yet implemented. +type ThemeWatcher struct { + onChange func(theme string) +} + +// NewThemeWatcher creates a new theme watcher with the given callback. +func NewThemeWatcher(onChange func(theme string)) *ThemeWatcher { + return &ThemeWatcher{onChange: onChange} +} + +// Start begins watching for system theme changes. +// On Windows, this is a no-op (live watching not yet implemented). +func (w *ThemeWatcher) Start() {} + +// Stop stops watching for theme changes. +func (w *ThemeWatcher) Stop() {} diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index c5d0ff3..68a3478 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -44,7 +44,8 @@ type tui struct { left *tview.Flex content *tview.Flex - sortMode SortMode + sortMode SortMode + themeWatcher *ThemeWatcher } func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App { @@ -65,6 +66,7 @@ func (t *tui) Run() error { }() t.app.EnableMouse(true) t.initializeTheme() + t.initializeThemeWatcher() t.buildComponents() t.buildLayout() t.bindEvents() @@ -75,6 +77,7 @@ func (t *tui) Run() error { t.logger.Errorw("application run error", "error", err) return err } + t.stopThemeWatcher() return nil } @@ -82,6 +85,40 @@ func (t *tui) initializeTheme() { ApplyTheme() } +func (t *tui) initializeThemeWatcher() { + t.themeWatcher = NewThemeWatcher(func(newTheme string) { + // Only react if we're in system theme mode + if CurrentThemeMode != ThemeSystem { + return + } + // Check if the theme actually changed + if newTheme == CurrentTheme.Name { + return + } + // Apply the new theme on the UI thread + t.app.QueueUpdateDraw(func() { + if newTheme == ThemeLight { + CurrentTheme = &LightTheme + } else { + CurrentTheme = &DarkTheme + } + ApplyTheme() + t.rebuildUI() + t.showStatusTemp("Theme: " + newTheme + " (system)") + }) + }) + // Start watching if system theme is selected + if CurrentThemeMode == ThemeSystem { + t.themeWatcher.Start() + } +} + +func (t *tui) stopThemeWatcher() { + if t.themeWatcher != nil { + t.themeWatcher.Stop() + } +} + func (t *tui) buildComponents() { t.header = NewAppHeader(t.version, t.commit, RepoURL) t.searchBar = NewSearchBar().