diff --git a/internal/ui/shared/config.go b/internal/ui/shared/config.go index 5e9aa1e..2aa6b48 100644 --- a/internal/ui/shared/config.go +++ b/internal/ui/shared/config.go @@ -1,35 +1,346 @@ package shared +import ( + "fmt" + "sort" + + "gopkg.in/yaml.v3" +) + // Config manages UI settings type Config struct { - Title string `yaml:"-"` - Border bool `yaml:"border"` - EnableColors bool `yaml:"enable_colors"` - Colors struct { - Header string `yaml:"header"` - Footer string `yaml:"footer"` - Success string `yaml:"success"` - Warning string `yaml:"warning"` - Error string `yaml:"error"` - ModalBorder string `yaml:"modal_border"` - } `yaml:"colors"` + Title string `yaml:"-"` + Theme string `yaml:"theme"` // "light", "dark", "custom" + Themes map[string]Theme `yaml:"themes"` // User-defined themes +} + +// UnmarshalYAML implements yaml.Unmarshaler to handle theme merging +func (c *Config) UnmarshalYAML(value *yaml.Node) error { + // Create a temporary structure to unmarshal into + type tempConfig struct { + Theme string `yaml:"theme"` + Themes map[string]Theme `yaml:"themes"` + } + + var temp tempConfig + if err := value.Decode(&temp); err != nil { + return err + } + + // Apply theme if provided + if temp.Theme != "" { + c.Theme = temp.Theme + } + + // Merge themes if provided + if temp.Themes != nil { + if err := c.mergeThemes(temp.Themes); err != nil { + return err + } + } + + return nil +} + +// Theme represents a color theme with direct color values +type Theme struct { + // Base text colors + Primary string `yaml:"primary"` // 主要テキスト色 + Secondary string `yaml:"secondary"` // 補助テキスト色 + + // Background colors + Background string `yaml:"background"` // メイン背景色 + + // State colors + Success string `yaml:"success"` // 成功状態 + Warning string `yaml:"warning"` // 警告状態 + Error string `yaml:"error"` // エラー状態 + + // Table colors + TableHeader string `yaml:"table_header"` // テーブルヘッダー + + // Interactive colors + SelectionBg string `yaml:"selection_bg"` // 選択背景 + SelectionFg string `yaml:"selection_fg"` // 選択前景 + + // Detail colors + Accent string `yaml:"accent"` // アクセント色(ラベル用) + Separator string `yaml:"separator"` // 区切り線 + Timestamp string `yaml:"timestamp"` // タイムスタンプ +} + +// PredefinedThemes contains built-in color themes +var PredefinedThemes = map[string]Theme{ + "dark": { + Primary: "#ffffff", + Secondary: "#cccccc", + Background: "#000000", + Success: "#5FFF87", + Warning: "#ffff00", + Error: "#FF5F87", + TableHeader: "#ffff00", + SelectionBg: "#006400", + SelectionFg: "#ffffff", + Accent: "#5FAFFF", + Separator: "#666666", + Timestamp: "#999999", + }, + "light": { + Primary: "#000000", + Secondary: "#333333", + Background: "#ffffff", + Success: "#008000", + Warning: "#ff8c00", + Error: "#cc0000", + TableHeader: "#000080", + SelectionBg: "#add8e6", + SelectionFg: "#000000", + Accent: "#000080", + Separator: "#666666", + Timestamp: "#666666", + }, + "monokai": { + Primary: "#f8f8f2", + Secondary: "#cfcfc2", + Background: "#272822", + Success: "#a6e22e", + Warning: "#fd971f", + Error: "#f92672", + TableHeader: "#66d9ef", + SelectionBg: "#49483e", + SelectionFg: "#f8f8f2", + Accent: "#ae81ff", + Separator: "#75715e", + Timestamp: "#75715e", + }, + "nord": { + Primary: "#d8dee9", + Secondary: "#e5e9f0", + Background: "#2e3440", + Success: "#a3be8c", + Warning: "#ebcb8b", + Error: "#bf616a", + TableHeader: "#81a1c1", + SelectionBg: "#3b4252", + SelectionFg: "#eceff4", + Accent: "#88c0d0", + Separator: "#4c566a", + Timestamp: "#616e88", + }, + "xoria256": { + Primary: "#d0d0d0", // Normal text + Secondary: "#9e9e9e", // LineNr, secondary text + Background: "#1c1c1c", // Normal background + Success: "#afdf87", // PreProc green + Warning: "#ffffaf", // Constant yellow + Error: "#df8787", // Special/Error red + TableHeader: "#87afdf", // Statement blue + SelectionBg: "#5f5f87", // Folded background + SelectionFg: "#eeeeee", // Folded foreground + Accent: "#dfafdf", // Identifier purple + Separator: "#808080", // Comment gray + Timestamp: "#dfaf87", // Number tan + }, } // DefaultConfig returns default configuration func DefaultConfig() *Config { cfg := &Config{ - Title: "mping", - Border: true, - EnableColors: true, // Enable colors by default + Title: "mping", + Theme: "dark", + Themes: make(map[string]Theme), } - // Use color names available in tview - cfg.Colors.Header = "dodgerblue" - cfg.Colors.Footer = "gray" - cfg.Colors.Success = "green" - cfg.Colors.Warning = "yellow" - cfg.Colors.Error = "red" - cfg.Colors.ModalBorder = "white" + // Pre-populate with predefined themes so they can be overridden in YAML + for name, theme := range PredefinedThemes { + cfg.Themes[name] = theme + } return cfg -} \ No newline at end of file +} + +// GetTheme returns the appropriate theme based on config +func (c *Config) GetTheme() *Theme { + themeName := c.Theme + + // Check user-defined themes first + if theme, exists := c.Themes[themeName]; exists { + return &theme + } + + // Check predefined themes + if theme, exists := PredefinedThemes[themeName]; exists { + return &theme + } + + // Fallback to dark theme + if theme, exists := PredefinedThemes["dark"]; exists { + return &theme + } + + // Ultimate fallback + return &Theme{ + Primary: "#ffffff", + Secondary: "#cccccc", + Success: "#00ff00", + Warning: "#ffff00", + Error: "#ff0000", + TableHeader: "#ffff00", + SelectionBg: "#006400", + SelectionFg: "#ffffff", + Accent: "#00ffff", + Separator: "#666666", + Timestamp: "#999999", + } +} + +// GetThemeList returns ordered list of available themes +func GetThemeList() []string { + themes := make([]string, 0, len(PredefinedThemes)) + for name := range PredefinedThemes { + themes = append(themes, name) + } + sort.Strings(themes) + return themes +} + +// GetAllThemeList returns ordered list of all available themes (predefined + user-defined) +func (c *Config) GetAllThemeList() []string { + themes := make([]string, 0, len(c.Themes)) + for name := range c.Themes { + themes = append(themes, name) + } + sort.Strings(themes) + return themes +} + +// CycleTheme cycles to the next theme in the available list +func (c *Config) CycleTheme() { + themes := c.GetAllThemeList() + currentIndex := -1 + + // Find current theme index + for i, theme := range themes { + if theme == c.Theme { + currentIndex = i + break + } + } + + // Move to next theme (cycle back to first if at end) + nextIndex := (currentIndex + 1) % len(themes) + c.Theme = themes[nextIndex] +} + +// mergeThemes merges user theme overrides with base themes +func (c *Config) mergeThemes(userThemes map[string]Theme) error { + for name, userTheme := range userThemes { + // Check if this is a predefined theme (allows partial override) + if _, isPredefined := PredefinedThemes[name]; isPredefined { + // For predefined themes, allow partial override + var baseTheme Theme + if existing, exists := c.Themes[name]; exists { + baseTheme = existing + } else { + baseTheme = PredefinedThemes[name] + } + + // Merge user theme with base theme + merged := mergeTheme(baseTheme, userTheme) + c.Themes[name] = merged + } else { + // For custom themes, require complete definition + if err := validateTheme(userTheme); err != nil { + return fmt.Errorf("custom theme '%s': %w", name, err) + } + c.Themes[name] = userTheme + } + } + return nil +} + +// mergeTheme merges user theme settings with base theme, preserving non-empty values +func mergeTheme(base, user Theme) Theme { + result := base // Start with base theme + + // Override with non-empty user values + if user.Primary != "" { + result.Primary = user.Primary + } + if user.Secondary != "" { + result.Secondary = user.Secondary + } + if user.Background != "" { + result.Background = user.Background + } + if user.Success != "" { + result.Success = user.Success + } + if user.Warning != "" { + result.Warning = user.Warning + } + if user.Error != "" { + result.Error = user.Error + } + if user.TableHeader != "" { + result.TableHeader = user.TableHeader + } + if user.SelectionBg != "" { + result.SelectionBg = user.SelectionBg + } + if user.SelectionFg != "" { + result.SelectionFg = user.SelectionFg + } + if user.Accent != "" { + result.Accent = user.Accent + } + if user.Separator != "" { + result.Separator = user.Separator + } + if user.Timestamp != "" { + result.Timestamp = user.Timestamp + } + + return result +} + +// validateTheme checks if a theme has all required fields defined +func validateTheme(theme Theme) error { + if theme.Primary == "" { + return fmt.Errorf("primary color is required") + } + if theme.Secondary == "" { + return fmt.Errorf("secondary color is required") + } + if theme.Background == "" { + return fmt.Errorf("background color is required") + } + if theme.Success == "" { + return fmt.Errorf("success color is required") + } + if theme.Warning == "" { + return fmt.Errorf("warning color is required") + } + if theme.Error == "" { + return fmt.Errorf("error color is required") + } + if theme.TableHeader == "" { + return fmt.Errorf("table_header color is required") + } + if theme.SelectionBg == "" { + return fmt.Errorf("selection_bg color is required") + } + if theme.SelectionFg == "" { + return fmt.Errorf("selection_fg color is required") + } + if theme.Accent == "" { + return fmt.Errorf("accent color is required") + } + if theme.Separator == "" { + return fmt.Errorf("separator color is required") + } + if theme.Timestamp == "" { + return fmt.Errorf("timestamp color is required") + } + return nil +} diff --git a/internal/ui/shared/config_test.go b/internal/ui/shared/config_test.go index 58cdcc3..cd9af78 100644 --- a/internal/ui/shared/config_test.go +++ b/internal/ui/shared/config_test.go @@ -1,7 +1,11 @@ package shared import ( + "fmt" + "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestDefaultConfig(t *testing.T) { @@ -12,37 +16,18 @@ func TestDefaultConfig(t *testing.T) { t.Errorf("Expected title 'mping', got '%s'", cfg.Title) } - if !cfg.Border { - t.Error("Expected border to be true by default") + // Verify predefined themes are pre-populated + if len(cfg.Themes) == 0 { + t.Error("Expected predefined themes to be pre-populated") } - if !cfg.EnableColors { - t.Error("Expected EnableColors to be true by default") + // Check that dark theme exists and has correct values + darkTheme, exists := cfg.Themes["dark"] + if !exists { + t.Error("Expected dark theme to be pre-populated") } - - // Verify color settings - expectedColors := map[string]string{ - "Header": "dodgerblue", - "Footer": "gray", - "Success": "green", - "Warning": "yellow", - "Error": "red", - "ModalBorder": "white", - } - - actualColors := map[string]string{ - "Header": cfg.Colors.Header, - "Footer": cfg.Colors.Footer, - "Success": cfg.Colors.Success, - "Warning": cfg.Colors.Warning, - "Error": cfg.Colors.Error, - "ModalBorder": cfg.Colors.ModalBorder, - } - - for key, expected := range expectedColors { - if actual := actualColors[key]; actual != expected { - t.Errorf("Expected %s color '%s', got '%s'", key, expected, actual) - } + if darkTheme.Primary != "#ffffff" { + t.Errorf("Expected dark theme primary to be '#ffffff', got '%s'", darkTheme.Primary) } } @@ -51,23 +36,204 @@ func TestConfigCustomization(t *testing.T) { // Test that we can modify config values cfg.Title = "custom-mping" - cfg.Border = false - cfg.EnableColors = false - cfg.Colors.Header = "red" if cfg.Title != "custom-mping" { t.Errorf("Expected title to be modifiable, got '%s'", cfg.Title) } +} + +func TestThemeOverride(t *testing.T) { + // Create default config with predefined themes + cfg := DefaultConfig() + + // Verify dark theme has default primary color + if cfg.Themes["dark"].Primary != "#ffffff" { + t.Errorf("Expected default dark theme primary to be '#ffffff', got '%s'", cfg.Themes["dark"].Primary) + } + + // Simulate YAML override using the new UI config structure + yamlConfig := ` +theme: mytheme +themes: + mytheme: + primary: "#ff0000" + secondary: "#ff0001" + background: "#ff0002" + success: "#ff0003" + warning: "#ff0004" + error: "#ff0005" + table_header: "#ff0006" + selection_bg: "#ff0007" + selection_fg: "#ff0008" + accent: "#ff0009" + separator: "#ff000a" + timestamp: "#ff000b" + dark: + primary: "#333333" +` + + // Apply YAML override to existing config + err := yaml.Unmarshal([]byte(yamlConfig), cfg) + if err != nil { + t.Fatalf("Failed to unmarshal YAML: %v", err) + } + + // Verify theme was changed + if cfg.Theme != "mytheme" { + t.Errorf("Expected theme to be 'mytheme', got '%s'", cfg.Theme) + } + + // Verify custom theme was added + myTheme, exists := cfg.Themes["mytheme"] + if !exists { + t.Error("Expected mytheme to be added") + } + if myTheme.Primary != "#ff0000" { + t.Errorf("Expected mytheme primary to be '#ff0000', got '%s'", myTheme.Primary) + } + + // Verify dark theme was partially overridden + darkTheme := cfg.Themes["dark"] + if darkTheme.Primary != "#333333" { + t.Errorf("Expected dark theme primary to be overridden to '#333333', got '%s'", darkTheme.Primary) + } + // Verify other properties remain from default + if darkTheme.Secondary != "#cccccc" { + t.Errorf("Expected dark theme secondary to remain '#cccccc', got '%s'", darkTheme.Secondary) + } +} + +func TestCustomThemeValidation(t *testing.T) { + cfg := DefaultConfig() + + // Test that incomplete custom theme fails validation + yamlConfig := ` +themes: + incomplete: + primary: "#ff0000" + # Missing other required fields +` + + err := yaml.Unmarshal([]byte(yamlConfig), cfg) + if err == nil { + t.Error("Expected incomplete custom theme to fail validation") + } + if err != nil && !strings.Contains(fmt.Sprintf("%v", err), "custom theme 'incomplete'") { + t.Errorf("Expected specific error message for custom theme, got: %v", err) + } +} + +func TestCompleteCustomTheme(t *testing.T) { + cfg := DefaultConfig() + + // Test that complete custom theme passes validation + yamlConfig := ` +themes: + complete: + primary: "#ff0000" + secondary: "#ff0001" + background: "#ff0002" + success: "#ff0003" + warning: "#ff0004" + error: "#ff0005" + table_header: "#ff0006" + selection_bg: "#ff0007" + selection_fg: "#ff0008" + accent: "#ff0009" + separator: "#ff000a" + timestamp: "#ff000b" +` + + err := yaml.Unmarshal([]byte(yamlConfig), cfg) + if err != nil { + t.Errorf("Complete custom theme should not fail validation: %v", err) + } - if cfg.Border { - t.Error("Expected border to be modifiable") + // Verify the theme was added correctly + completeTheme, exists := cfg.Themes["complete"] + if !exists { + t.Error("Expected complete theme to be added") + } + if completeTheme.Primary != "#ff0000" { + t.Errorf("Expected complete theme primary to be '#ff0000', got '%s'", completeTheme.Primary) + } + if completeTheme.Timestamp != "#ff000b" { + t.Errorf("Expected complete theme timestamp to be '#ff000b', got '%s'", completeTheme.Timestamp) } +} + +func TestPredefinedThemePartialOverride(t *testing.T) { + cfg := DefaultConfig() + + // Test that predefined theme can be partially overridden + yamlConfig := ` +themes: + dark: + primary: "#333333" + # Only primary is overridden, others should remain from default +` - if cfg.EnableColors { - t.Error("Expected EnableColors to be modifiable") + err := yaml.Unmarshal([]byte(yamlConfig), cfg) + if err != nil { + t.Errorf("Predefined theme partial override should not fail: %v", err) } - if cfg.Colors.Header != "red" { - t.Errorf("Expected header color to be modifiable, got '%s'", cfg.Colors.Header) + darkTheme := cfg.Themes["dark"] + if darkTheme.Primary != "#333333" { + t.Errorf("Expected dark theme primary to be overridden to '#333333', got '%s'", darkTheme.Primary) } -} \ No newline at end of file + // Other fields should remain from default + if darkTheme.Secondary != "#cccccc" { + t.Errorf("Expected dark theme secondary to remain '#cccccc', got '%s'", darkTheme.Secondary) + } +} + +func TestGetThemeList(t *testing.T) { + themes := GetThemeList() + + // Should contain all predefined themes + expectedThemes := []string{ + "dark", "light", "monokai", "nord", "xoria256", + } + + if len(themes) != len(expectedThemes) { + t.Errorf("Expected %d themes, got %d", len(expectedThemes), len(themes)) + } + + // Should be sorted alphabetically + for i, theme := range expectedThemes { + if themes[i] != theme { + t.Errorf("Expected theme at index %d to be '%s', got '%s'", i, theme, themes[i]) + } + } +} + +func TestGetAllThemeList(t *testing.T) { + cfg := DefaultConfig() + + // Add custom theme + cfg.Themes["custom"] = Theme{ + Primary: "#ff0000", Secondary: "#ff0001", Background: "#ff0002", + Success: "#ff0003", Warning: "#ff0004", Error: "#ff0005", + TableHeader: "#ff0006", SelectionBg: "#ff0007", SelectionFg: "#ff0008", + Accent: "#ff0009", Separator: "#ff000a", Timestamp: "#ff000b", + } + + themes := cfg.GetAllThemeList() + + // Should contain all predefined themes + custom theme + expectedThemes := []string{ + "custom", "dark", "light", "monokai", "nord", "xoria256", + } + + if len(themes) != len(expectedThemes) { + t.Errorf("Expected %d themes, got %d: %v", len(expectedThemes), len(themes), themes) + } + + // Should be sorted alphabetically + for i, theme := range expectedThemes { + if themes[i] != theme { + t.Errorf("Expected theme at index %d to be '%s', got '%s'", i, theme, themes[i]) + } + } +} diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index 2c4c833..90c84d2 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -29,52 +29,52 @@ func TimeFormater(t time.Time) string { } // FormatHostDetail generates detailed information for a host -func FormatHostDetail(metric stats.Metrics) string { +func FormatHostDetail(metric stats.Metrics, theme *Theme) string { // Color-coded basic statistics lossRate := metric.GetLoss() - lossColor := "green" + lossColor := theme.Success if lossRate > 50 { - lossColor = "red" + lossColor = theme.Error } else if lossRate > 10 { - lossColor = "yellow" + lossColor = theme.Warning } - successColor := "green" + successColor := theme.Success if metric.GetSuccessful() == 0 { - successColor = "red" + successColor = theme.Error } - failColor := "white" + failColor := theme.Primary if metric.GetFailed() > 0 { - failColor = "red" + failColor = theme.Error } - basicInfo := fmt.Sprintf(`[cyan]Total Probes:[white] %d -[%s]Successful:[white] %d -[%s]Failed:[white] %d -[cyan]Loss Rate:[white] [%s]%.1f%%[white] -[cyan]Last RTT:[white] %s -[cyan]Average RTT:[white] %s -[cyan]Minimum RTT:[white] %s -[cyan]Maximum RTT:[white] %s -[cyan]Last Success:[white] %s -[cyan]Last Failure:[white] %s -[cyan]Last Error:[white] %s`, - metric.GetTotal(), - successColor, metric.GetSuccessful(), - failColor, metric.GetFailed(), - lossColor, lossRate, - DurationFormater(metric.GetLastRTT()), - DurationFormater(metric.GetAverageRTT()), - DurationFormater(metric.GetMinimumRTT()), - DurationFormater(metric.GetMaximumRTT()), - TimeFormater(metric.GetLastSuccTime()), - TimeFormater(metric.GetLastFailTime()), - metric.GetLastFailDetail(), + basicInfo := fmt.Sprintf(`[%s]Total Probes:[%s] %d +[%s]Successful:[%s] %d +[%s]Failed:[%s] %d +[%s]Loss Rate:[%s] [%s]%.1f%%[%s] +[%s]Last RTT:[%s] %s +[%s]Average RTT:[%s] %s +[%s]Minimum RTT:[%s] %s +[%s]Maximum RTT:[%s] %s +[%s]Last Success:[%s] %s +[%s]Last Failure:[%s] %s +[%s]Last Error:[%s] %s`, + theme.Accent, theme.Primary, metric.GetTotal(), + successColor, theme.Primary, metric.GetSuccessful(), + failColor, theme.Primary, metric.GetFailed(), + theme.Accent, theme.Primary, lossColor, lossRate, theme.Primary, + theme.Accent, theme.Primary, DurationFormater(metric.GetLastRTT()), + theme.Accent, theme.Primary, DurationFormater(metric.GetAverageRTT()), + theme.Accent, theme.Primary, DurationFormater(metric.GetMinimumRTT()), + theme.Accent, theme.Primary, DurationFormater(metric.GetMaximumRTT()), + theme.Accent, theme.Primary, TimeFormater(metric.GetLastSuccTime()), + theme.Accent, theme.Primary, TimeFormater(metric.GetLastFailTime()), + theme.Accent, theme.Primary, metric.GetLastFailDetail(), ) // Add history section - historySection := FormatHistory(metric) + historySection := FormatHistory(metric, theme) if historySection != "" { basicInfo += "\n\n" + historySection } @@ -83,38 +83,38 @@ func FormatHostDetail(metric stats.Metrics) string { } // FormatHistory generates history section for a host -func FormatHistory(metric stats.Metrics) string { +func FormatHistory(metric stats.Metrics, theme *Theme) string { history := metric.GetRecentHistory(10) if len(history) == 0 { return "" } var sb strings.Builder - sb.WriteString("\n[yellow]Recent History (last 10 entries):[white]\n") - sb.WriteString("[cyan]Time Status RTT Details[white]\n") - sb.WriteString("[gray]-------- ------ ------- --------[white]\n") + sb.WriteString(fmt.Sprintf("\n[%s]Recent History (last 10 entries):[%s]\n", theme.Warning, theme.Primary)) + sb.WriteString(fmt.Sprintf("[%s]Time Status RTT Details[%s]\n", theme.Accent, theme.Primary)) + sb.WriteString(fmt.Sprintf("[%s]-------- ------ ------- --------[%s]\n", theme.Separator, theme.Primary)) for _, entry := range history { - statusColor := "green" + statusColor := theme.Success status := "OK" details := "" if !entry.Success { status = "FAIL" - statusColor = "red" + statusColor = theme.Error // Show error message for failed entries if entry.Error != "" { - details = fmt.Sprintf("[red]%s[white]", entry.Error) + details = fmt.Sprintf("[%s]%s[%s]", theme.Error, entry.Error, theme.Primary) } } else { // Show probe-specific details for successful entries details = formatProbeDetails(entry.Details) } - sb.WriteString(fmt.Sprintf("[gray]%-8s[white] [%s]%-6s[white] %-7s %s\n", - entry.Timestamp.Format("15:04:05"), - statusColor, status, - DurationFormater(entry.RTT), + sb.WriteString(fmt.Sprintf("[%s]%-8s[%s] [%s]%-6s[%s] %-7s %s\n", + theme.Timestamp, entry.Timestamp.Format("15:04:05"), + theme.Primary, statusColor, status, + theme.Primary, DurationFormater(entry.RTT), details, )) } diff --git a/internal/ui/shared/formatters_test.go b/internal/ui/shared/formatters_test.go index 114771a..1e24ca6 100644 --- a/internal/ui/shared/formatters_test.go +++ b/internal/ui/shared/formatters_test.go @@ -113,21 +113,31 @@ func TestFormatHostDetail(t *testing.T) { "timeout", ) - result := FormatHostDetail(metric) + theme := &Theme{ + Primary: "#ffffff", + Secondary: "#cccccc", + Success: "#00ff00", + Warning: "#ffff00", + Error: "#ff0000", + Accent: "#00afd7", + Separator: "#666666", + Timestamp: "#999999", + } + result := FormatHostDetail(metric, theme) expectedContents := []string{ - "Total Probes:[white] 100", - "Successful:[white] 95", - "Failed:[white] 5", - "Loss Rate:[white]", - "5.0%", - "Last RTT:[white] 25ms", - "Average RTT:[white] 30ms", - "Minimum RTT:[white] 20ms", - "Maximum RTT:[white] 40ms", - "Last Success:[white] 15:30:45", - "Last Failure:[white] 15:30:46", - "Last Error:[white] timeout", + "[#00afd7]Total Probes:[#ffffff] 100", + "[#00ff00]Successful:[#ffffff] 95", + "[#ff0000]Failed:[#ffffff] 5", + "[#00afd7]Loss Rate:[#ffffff]", + "[#00ff00]5.0%[#ffffff]", + "[#00afd7]Last RTT:[#ffffff] 25ms", + "[#00afd7]Average RTT:[#ffffff] 30ms", + "[#00afd7]Minimum RTT:[#ffffff] 20ms", + "[#00afd7]Maximum RTT:[#ffffff] 40ms", + "[#00afd7]Last Success:[#ffffff] 15:30:45", + "[#00afd7]Last Failure:[#ffffff] 15:30:46", + "[#00afd7]Last Error:[#ffffff] timeout", } for _, expected := range expectedContents { @@ -155,21 +165,31 @@ func TestFormatHostDetailWithZeroValues(t *testing.T) { "", ) - result := FormatHostDetail(metric) + theme := &Theme{ + Primary: "#ffffff", + Secondary: "#cccccc", + Success: "#00ff00", + Warning: "#ffff00", + Error: "#ff0000", + Accent: "#00afd7", + Separator: "#666666", + Timestamp: "#999999", + } + result := FormatHostDetail(metric, theme) expectedContents := []string{ - "Total Probes:[white] 0", - "Successful:[white] 0", - "Failed:[white] 0", - "Loss Rate:[white]", - "0.0%", - "Last RTT:[white] -", - "Average RTT:[white] -", - "Minimum RTT:[white] -", - "Maximum RTT:[white] -", - "Last Success:[white] -", - "Last Failure:[white] -", - "Last Error:[white] ", + "[#00afd7]Total Probes:[#ffffff] 0", + "[#ff0000]Successful:[#ffffff] 0", + "[#ffffff]Failed:[#ffffff] 0", + "[#00afd7]Loss Rate:[#ffffff]", + "[#00ff00]0.0%[#ffffff]", + "[#00afd7]Last RTT:[#ffffff] -", + "[#00afd7]Average RTT:[#ffffff] -", + "[#00afd7]Minimum RTT:[#ffffff] -", + "[#00afd7]Maximum RTT:[#ffffff] -", + "[#00afd7]Last Success:[#ffffff] -", + "[#00afd7]Last Failure:[#ffffff] -", + "[#00afd7]Last Error:[#ffffff] ", } for _, expected := range expectedContents { diff --git a/internal/ui/shared/table_data.go b/internal/ui/shared/table_data.go index 1508282..a62b5c9 100644 --- a/internal/ui/shared/table_data.go +++ b/internal/ui/shared/table_data.go @@ -90,15 +90,19 @@ func (td *TableData) ToGoPrettyTable() table.Writer { } // ToTviewTable converts to interactive tview.Table format (primary UI) +// Note: This method is deprecated. Use host_list.go panel implementation instead. func (td *TableData) ToTviewTable() *tview.Table { + // Use default dark theme colors as fallback + theme := PredefinedThemes["dark"] + t := tview.NewTable(). SetFixed(1, 0). SetSelectable(true, false). SetBorders(false). // Disable all borders SetSeparator(' '). // Use space separator instead of lines SetSelectedStyle(tcell.StyleDefault. - Background(tcell.ColorDarkBlue). - Foreground(tcell.ColorWhite)) // Pattern 1: DarkBlue + White - k9s style + Background(tcell.GetColor(theme.SelectionBg)). + Foreground(tcell.GetColor(theme.SelectionFg))) // Define alignment for each column alignments := []int{ @@ -125,7 +129,7 @@ func (td *TableData) ToTviewTable() *tview.Table { t.SetCell(0, col, &tview.TableCell{ Text: " " + header + " ", - Color: tcell.ColorYellow, + Color: tcell.GetColor(theme.TableHeader), Align: alignment, NotSelectable: true, }) @@ -141,7 +145,7 @@ func (td *TableData) ToTviewTable() *tview.Table { t.SetCell(row+1, col, &tview.TableCell{ Text: " " + cellData + " ", - Color: tcell.ColorWhite, + Color: tcell.GetColor(theme.Primary), Align: alignment, }) } diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index 9ca5922..f9082a2 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -34,6 +34,7 @@ func NewTUIApp(mm stats.MetricsManager, cfg *shared.Config, interval, timeout ti ctx, cancel := context.WithCancel(context.Background()) app := tview.NewApplication() + app.EnableMouse(true) uiState := state.NewUIState() layout := NewLayoutManager(uiState, mm, cfg, interval, timeout) @@ -153,6 +154,9 @@ func (a *TUIApp) setupKeyBindings() { case '/': a.showFilter() return nil + case 't': + a.cycleTheme() + return nil } // Delegate navigation to layout @@ -183,6 +187,7 @@ NAVIGATION: R Reset all metrics v Toggle detail view / Filter hosts + t Cycle theme h Show/hide this help q, Ctrl+C Quit application @@ -302,3 +307,8 @@ func (a *TUIApp) getFilteredMetrics() []stats.Metrics { metrics := a.mm.SortBy(a.state.GetSortKey(), a.state.IsAscending()) return shared.FilterMetrics(metrics, a.state.GetFilter()) } + +// Theme-related methods +func (a *TUIApp) cycleTheme() { + a.config.CycleTheme() +} diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index c1041f2..b53b8d5 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -59,15 +59,15 @@ func NewLayoutManager(uiState *state.UIState, mm stats.MetricsManager, config *s // setupPanels initializes all panels func (l *LayoutManager) setupPanels(uiState *state.UIState, mm stats.MetricsProvider, config *shared.Config, interval, timeout time.Duration) { l.header = panels.NewHeaderPanel(uiState, config, interval, timeout) - l.hostList = panels.NewHostListPanel(uiState, mm) + l.hostList = panels.NewHostListPanel(uiState, mm, config) l.footer = panels.NewFooterPanel(config) - l.hostDetail = panels.NewHostDetailPanel() + l.hostDetail = panels.NewHostDetailPanel(config) - // Setup filter input + // Setup filter input with theme-aware colors + theme := config.GetTheme() l.filterInput = tview.NewInputField(). SetLabel("Filter: "). - SetLabelColor(tcell.ColorWhite). - SetFieldBackgroundColor(tcell.ColorBlack) + SetLabelColor(tcell.GetColor(theme.Primary)) } // setupLayout configures the main layout structure diff --git a/internal/ui/tui/panels/header_footer.go b/internal/ui/tui/panels/header_footer.go index 12c257f..dc06470 100644 --- a/internal/ui/tui/panels/header_footer.go +++ b/internal/ui/tui/panels/header_footer.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/servak/mping/internal/ui/shared" @@ -37,7 +38,9 @@ func NewHeaderPanel(renderState state.RenderState, config *shared.Config, interv // Update refreshes header display based on current state func (h *HeaderPanel) Update() { + theme := h.config.GetTheme() content := h.generateHeaderContent() + h.view.SetBackgroundColor(tcell.GetColor(theme.Background)) h.view.SetText(content) } @@ -45,31 +48,23 @@ func (h *HeaderPanel) Update() { func (h *HeaderPanel) generateHeaderContent() string { sortDisplay := h.renderState.GetSortKey().String() filterText := h.renderState.GetFilter() - + var parts []string - if h.config.EnableColors && h.config.Colors.Header != "" { - parts = append(parts, fmt.Sprintf("[%s]Sort: %s[-]", h.config.Colors.Header, sortDisplay)) - parts = append(parts, fmt.Sprintf("[%s]Interval: %dms[-]", h.config.Colors.Header, h.interval.Milliseconds())) - parts = append(parts, fmt.Sprintf("[%s]Timeout: %dms[-]", h.config.Colors.Header, h.timeout.Milliseconds())) - - if filterText != "" { - parts = append(parts, fmt.Sprintf("[%s]Filter: %s[-]", h.config.Colors.Warning, filterText)) - } - - parts = append(parts, fmt.Sprintf("[%s]%s[-]", h.config.Colors.Header, h.config.Title)) - } else { - parts = append(parts, fmt.Sprintf("Sort: %s", sortDisplay)) - parts = append(parts, fmt.Sprintf("Interval: %dms", h.interval.Milliseconds())) - parts = append(parts, fmt.Sprintf("Timeout: %dms", h.timeout.Milliseconds())) - - if filterText != "" { - parts = append(parts, fmt.Sprintf("Filter: %s", filterText)) - } - - parts = append(parts, h.config.Title) + theme := h.config.GetTheme() + if h.config.Title != "" { + // Use title from config if available + parts = append(parts, fmt.Sprintf("[%s]%s[-]", theme.Primary, h.config.Title)) + } + parts = append(parts, fmt.Sprintf("[%s]Sort: %s[-]", theme.Accent, sortDisplay)) + parts = append(parts, fmt.Sprintf("[%s]Interval: %dms[-]", theme.Accent, h.interval.Milliseconds())) + parts = append(parts, fmt.Sprintf("[%s]Timeout: %dms[-]", theme.Accent, h.timeout.Milliseconds())) + + if filterText != "" { + parts = append(parts, fmt.Sprintf("[%s]Filter: %s[-]", theme.Warning, filterText)) } - - return strings.Join(parts, " ") + parts = append(parts, fmt.Sprintf("[%s]Theme: %s[-]", theme.Secondary, h.config.Theme)) + sep := fmt.Sprintf("[%s] | [-]", theme.Separator) + return strings.Join(parts, sep) } // GetView returns the underlying tview component @@ -98,26 +93,26 @@ func NewFooterPanel(config *shared.Config) *FooterPanel { // Update refreshes footer display based on current state func (f *FooterPanel) Update() { content := f.generateFooterContent() + theme := f.config.GetTheme() + f.view.SetBackgroundColor(tcell.GetColor(theme.Background)) f.view.SetText(content) } // generateFooterContent generates footer text func (f *FooterPanel) generateFooterContent() string { - if f.config.EnableColors && f.config.Colors.Footer != "" { - helpText := fmt.Sprintf("[%s]h:help[-]", f.config.Colors.Footer) - quitText := fmt.Sprintf("[%s]q:quit[-]", f.config.Colors.Footer) - sortText := fmt.Sprintf("[%s]s:sort[-]", f.config.Colors.Footer) - reverseText := fmt.Sprintf("[%s]r:reverse[-]", f.config.Colors.Footer) - resetText := fmt.Sprintf("[%s]R:reset[-]", f.config.Colors.Footer) - filterText := fmt.Sprintf("[%s]/:filter[-]", f.config.Colors.Footer) - moveText := fmt.Sprintf("[%s]j/k/g/G/u/d:move[-]", f.config.Colors.Footer) - return fmt.Sprintf("%s %s %s %s %s %s %s", helpText, quitText, sortText, reverseText, resetText, filterText, moveText) - } else { - return "h:help q:quit s:sort r:reverse R:reset /:filter j/k/g/G/u/d:move" - } + theme := f.config.GetTheme() + helpText := fmt.Sprintf("[%s]h:help[-]", theme.Secondary) + quitText := fmt.Sprintf("[%s]q:quit[-]", theme.Secondary) + sortText := fmt.Sprintf("[%s]s:sort[-]", theme.Secondary) + reverseText := fmt.Sprintf("[%s]r:reverse[-]", theme.Secondary) + resetText := fmt.Sprintf("[%s]R:reset[-]", theme.Secondary) + filterText := fmt.Sprintf("[%s]/:filter[-]", theme.Secondary) + themeText := fmt.Sprintf("[%s]t:theme[-]", theme.Secondary) + moveText := fmt.Sprintf("[%s]j/k/g/G/u/d:move[-]", theme.Secondary) + return fmt.Sprintf("%s %s %s %s %s %s %s %s", helpText, quitText, sortText, reverseText, resetText, filterText, themeText, moveText) } // GetView returns the underlying tview component func (f *FooterPanel) GetView() *tview.TextView { return f.view -} \ No newline at end of file +} diff --git a/internal/ui/tui/panels/host_detail.go b/internal/ui/tui/panels/host_detail.go index d0f80e3..633f867 100644 --- a/internal/ui/tui/panels/host_detail.go +++ b/internal/ui/tui/panels/host_detail.go @@ -1,6 +1,9 @@ package panels import ( + "fmt" + + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/servak/mping/internal/stats" @@ -13,10 +16,11 @@ type HostDetailPanel struct { container *tview.Flex // Container with border currentHost string currentMetrics stats.Metrics + config *shared.Config } // NewHostDetailPanel creates a new HostDetailPanel -func NewHostDetailPanel() *HostDetailPanel { +func NewHostDetailPanel(config *shared.Config) *HostDetailPanel { view := tview.NewTextView() view.SetDynamicColors(true). SetScrollable(true) @@ -26,12 +30,10 @@ func NewHostDetailPanel() *HostDetailPanel { SetDirection(tview.FlexRow). AddItem(view, 0, 1, false) - container.SetBorder(true). - SetTitle(" Host Details ") - return &HostDetailPanel{ view: view, container: container, + config: config, } } @@ -41,9 +43,16 @@ func (h *HostDetailPanel) Update() { h.view.SetText("Select a host to view details") return } + theme := h.config.GetTheme() + h.container. + SetBorder(true). + SetTitle(fmt.Sprintf(" [%s]Host Details ", theme.Primary)). + SetBackgroundColor(tcell.GetColor(theme.Background)). + SetBorderColor(tcell.GetColor(theme.Primary)) // Format and display the host details with history - content := shared.FormatHostDetail(h.currentMetrics) + content := shared.FormatHostDetail(h.currentMetrics, theme) + h.view.SetBackgroundColor(tcell.GetColor(theme.Background)) h.view.SetText(content) } diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index a6cb14b..27930a5 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -1,6 +1,8 @@ package panels import ( + "fmt" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -16,6 +18,7 @@ type HostListPanel struct { renderState state.RenderState selectionState state.SelectionState mm stats.MetricsProvider + config *shared.Config onSelectionChange func(metrics stats.Metrics) // Callback when selection changes } @@ -25,7 +28,7 @@ type HostListParams interface { } // NewHostListPanel creates a new HostListPanel -func NewHostListPanel(state HostListParams, mm stats.MetricsProvider) *HostListPanel { +func NewHostListPanel(state HostListParams, mm stats.MetricsProvider, config *shared.Config) *HostListPanel { table := tview.NewTable(). SetSelectable(true, false) @@ -34,15 +37,13 @@ func NewHostListPanel(state HostListParams, mm stats.MetricsProvider) *HostListP SetDirection(tview.FlexRow). AddItem(table, 0, 1, true) - container.SetBorder(true). - SetTitle(" Host List ") - panel := &HostListPanel{ table: table, container: container, renderState: state, selectionState: state, mm: mm, + config: config, } return panel @@ -57,15 +58,23 @@ func (h *HostListPanel) Update() { // Clear existing content and repopulate h.table.Clear() - // Configure table settings + // Configure table settings with theme-aware colors + theme := h.config.GetTheme() + h.container. + SetBorder(true). + SetTitle(fmt.Sprintf(" [%s]Host List ", theme.Primary)). + SetBackgroundColor(tcell.GetColor(theme.Background)). + SetBorderColor(tcell.GetColor(theme.Primary)) + h.table. SetBorders(false). SetSeparator(' '). SetFixed(1, 0). SetSelectable(true, false). SetSelectedStyle(tcell.StyleDefault. - Background(tcell.ColorDarkGreen). - Foreground(tcell.ColorWhite)) + Background(tcell.GetColor(theme.SelectionBg)). + Foreground(tcell.GetColor(theme.SelectionFg))). + SetBackgroundColor(tcell.GetColor(theme.Background)) // Use TableData's logic but populate our existing table h.populateTableFromData(tableData) @@ -214,6 +223,9 @@ func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { tview.AlignLeft, // FAIL Reason } + // Get theme for theme-aware colors + theme := h.config.GetTheme() + // Set headers for col, header := range tableData.Headers { alignment := tview.AlignLeft @@ -222,10 +234,11 @@ func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { } h.table.SetCell(0, col, &tview.TableCell{ - Text: " " + header + " ", - Color: tcell.ColorYellow, - Align: alignment, - NotSelectable: true, + Text: " " + header + " ", + Color: tcell.GetColor(theme.TableHeader), + BackgroundColor: tcell.GetColor(theme.Background), + Align: alignment, + NotSelectable: true, }) } @@ -238,9 +251,10 @@ func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { } h.table.SetCell(row+1, col, &tview.TableCell{ - Text: " " + cellData + " ", - Color: tcell.ColorWhite, - Align: alignment, + Text: " " + cellData + " ", + Color: tcell.GetColor(theme.Primary), + BackgroundColor: tcell.GetColor(theme.Background), + Align: alignment, }) } } diff --git a/internal/ui/tui/panels/host_list_test.go b/internal/ui/tui/panels/host_list_test.go index 0e4d2bf..a056d3f 100644 --- a/internal/ui/tui/panels/host_list_test.go +++ b/internal/ui/tui/panels/host_list_test.go @@ -39,7 +39,8 @@ func TestNewHostListPanel(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) if panel == nil { t.Fatal("NewHostListPanel() returned nil") @@ -65,7 +66,8 @@ func TestNewHostListPanel(t *testing.T) { func TestHostListPanelGetView(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) view := panel.GetView() if view == nil { @@ -80,7 +82,8 @@ func TestHostListPanelGetView(t *testing.T) { func TestHostListPanelUpdate(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register some test metrics mm.Register("google.com", "google.com") @@ -107,7 +110,8 @@ func TestHostListPanelUpdate(t *testing.T) { func TestHostListPanelUpdateWithFilter(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register test metrics mm.Register("google.com", "google.com") @@ -130,7 +134,8 @@ func TestHostListPanelUpdateWithFilter(t *testing.T) { func TestHostListPanelUpdateSelectedHost(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register test metrics mm.Register("test.com", "test.com") @@ -148,7 +153,8 @@ func TestHostListPanelUpdateSelectedHost(t *testing.T) { func TestHostListPanelScrollDown(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register multiple metrics to enable scrolling for i := 0; i < 10; i++ { @@ -171,7 +177,8 @@ func TestHostListPanelScrollDown(t *testing.T) { func TestHostListPanelScrollUp(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register multiple metrics for i := 0; i < 10; i++ { @@ -194,7 +201,8 @@ func TestHostListPanelScrollUp(t *testing.T) { func TestHostListPanelScrollToTop(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register metrics mm.Register("test.com", "test.com") @@ -213,7 +221,8 @@ func TestHostListPanelScrollToTop(t *testing.T) { func TestHostListPanelScrollToBottom(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register metrics mm.Register("test.com", "test.com") @@ -232,7 +241,8 @@ func TestHostListPanelScrollToBottom(t *testing.T) { func TestHostListPanelPageDown(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register metrics mm.Register("test.com", "test.com") @@ -251,7 +261,8 @@ func TestHostListPanelPageDown(t *testing.T) { func TestHostListPanelPageUp(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register metrics mm.Register("test.com", "test.com") @@ -270,7 +281,8 @@ func TestHostListPanelPageUp(t *testing.T) { func TestHostListPanelRestoreSelection(t *testing.T) { mm := stats.NewMetricsManager() state := newMockState() - panel := NewHostListPanel(state, mm) + config := shared.DefaultConfig() + panel := NewHostListPanel(state, mm, config) // Register test metrics mm.Register("google.com", "google.com")