From 1021ba13aa5c3fc1b569524aff092a95a9fe879b Mon Sep 17 00:00:00 2001 From: Karol Broda <122811026+karol-broda@users.noreply.github.com> Date: Wed, 24 Dec 2025 10:49:03 +0100 Subject: [PATCH] feat: introduce theme management and performance improvements (#7) --- cmd/root.go | 5 +- cmd/themes.go | 24 +++ cmd/top.go | 6 +- internal/collector/collector_linux.go | 179 ++++++++++++++---- internal/collector/collector_test.go | 101 ++++++++++ .../collector/collector_test_helpers_linux.go | 18 ++ internal/config/config.go | 21 +- internal/resolver/resolver.go | 10 + internal/theme/ansi.go | 24 +++ internal/theme/catppuccin.go | 87 +++++++++ internal/theme/dracula.go | 24 +++ internal/theme/gruvbox.go | 45 +++++ internal/theme/mono.go | 49 +++++ internal/theme/nord.go | 24 +++ internal/theme/one_dark.go | 24 +++ internal/theme/palette.go | 111 +++++++++++ internal/theme/readme.md | 14 ++ internal/theme/solarized.go | 45 +++++ internal/theme/theme.go | 172 +++++------------ internal/theme/tokyo_night.go | 66 +++++++ 20 files changed, 877 insertions(+), 172 deletions(-) create mode 100644 cmd/themes.go create mode 100644 internal/collector/collector_test_helpers_linux.go create mode 100644 internal/theme/ansi.go create mode 100644 internal/theme/catppuccin.go create mode 100644 internal/theme/dracula.go create mode 100644 internal/theme/gruvbox.go create mode 100644 internal/theme/mono.go create mode 100644 internal/theme/nord.go create mode 100644 internal/theme/one_dark.go create mode 100644 internal/theme/palette.go create mode 100644 internal/theme/readme.md create mode 100644 internal/theme/solarized.go create mode 100644 internal/theme/tokyo_night.go diff --git a/cmd/root.go b/cmd/root.go index c16be75..90938dc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,11 +3,12 @@ package cmd import ( "fmt" "os" - "github.com/karol-broda/snitch/internal/config" + "github.com/karol-broda/snitch/internal/config" "github.com/spf13/cobra" ) + var ( cfgFile string ) @@ -42,7 +43,7 @@ func init() { // add top's flags to root so `snitch -l` works (defaults to top command) cfg := config.Get() - rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)") + rootCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (see 'snitch themes')") rootCmd.Flags().DurationVarP(&topInterval, "interval", "i", 0, "Refresh interval (default 1s)") rootCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames") rootCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names") diff --git a/cmd/themes.go b/cmd/themes.go new file mode 100644 index 0000000..b34c18a --- /dev/null +++ b/cmd/themes.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/karol-broda/snitch/internal/theme" + "github.com/spf13/cobra" +) + +var themesCmd = &cobra.Command{ + Use: "themes", + Short: "List available themes", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Available themes (default: %s):\n\n", theme.DefaultTheme) + for _, name := range theme.ListThemes() { + fmt.Printf(" %s\n", name) + } + }, +} + +func init() { + rootCmd.AddCommand(themesCmd) +} + diff --git a/cmd/top.go b/cmd/top.go index 4546585..33c617c 100644 --- a/cmd/top.go +++ b/cmd/top.go @@ -2,11 +2,11 @@ package cmd import ( "log" - "github.com/karol-broda/snitch/internal/config" - "github.com/karol-broda/snitch/internal/tui" "time" tea "github.com/charmbracelet/bubbletea" + "github.com/karol-broda/snitch/internal/config" + "github.com/karol-broda/snitch/internal/tui" "github.com/spf13/cobra" ) @@ -60,7 +60,7 @@ func init() { cfg := config.Get() // top-specific flags - topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (dark, light, mono, auto)") + topCmd.Flags().StringVar(&topTheme, "theme", cfg.Defaults.Theme, "Theme for TUI (see 'snitch themes')") topCmd.Flags().DurationVarP(&topInterval, "interval", "i", time.Second, "Refresh interval") topCmd.Flags().BoolVar(&topResolveAddrs, "resolve-addrs", !cfg.Defaults.Numeric, "Resolve IP addresses to hostnames") topCmd.Flags().BoolVar(&topResolvePorts, "resolve-ports", false, "Resolve port numbers to service names") diff --git a/internal/collector/collector_linux.go b/internal/collector/collector_linux.go index 8e68477..c3f5da9 100644 --- a/internal/collector/collector_linux.go +++ b/internal/collector/collector_linux.go @@ -11,21 +11,76 @@ import ( "path/filepath" "strconv" "strings" + "sync" + "sync/atomic" "time" ) +// set SNITCH_DEBUG_TIMING=1 to enable timing diagnostics +var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != "" + +func logTiming(label string, start time.Time, extra ...string) { + if !debugTiming { + return + } + elapsed := time.Since(start) + if len(extra) > 0 { + fmt.Fprintf(os.Stderr, "[timing] %s: %v (%s)\n", label, elapsed, extra[0]) + } else { + fmt.Fprintf(os.Stderr, "[timing] %s: %v\n", label, elapsed) + } +} + +// userCache caches uid to username mappings to avoid repeated lookups +var userCache = struct { + sync.RWMutex + m map[int]string +}{m: make(map[int]string)} + +func lookupUsername(uid int) string { + userCache.RLock() + if username, exists := userCache.m[uid]; exists { + userCache.RUnlock() + return username + } + userCache.RUnlock() + + start := time.Now() + username := strconv.Itoa(uid) + u, err := user.LookupId(strconv.Itoa(uid)) + if err == nil && u != nil { + username = u.Username + } + elapsed := time.Since(start) + if debugTiming && elapsed > 10*time.Millisecond { + fmt.Fprintf(os.Stderr, "[timing] user.LookupId(%d) slow: %v\n", uid, elapsed) + } + + userCache.Lock() + userCache.m[uid] = username + userCache.Unlock() + + return username +} + // DefaultCollector implements the Collector interface using /proc filesystem type DefaultCollector struct{} // GetConnections fetches all network connections by parsing /proc files func (dc *DefaultCollector) GetConnections() ([]Connection, error) { + totalStart := time.Now() + defer func() { logTiming("GetConnections total", totalStart) }() + + inodeStart := time.Now() inodeMap, err := buildInodeToProcessMap() + logTiming("buildInodeToProcessMap", inodeStart, fmt.Sprintf("%d inodes", len(inodeMap))) if err != nil { return nil, fmt.Errorf("failed to build inode map: %w", err) } var connections []Connection + parseStart := time.Now() tcpConns, err := parseProcNet("/proc/net/tcp", "tcp", 4, inodeMap) if err == nil { connections = append(connections, tcpConns...) @@ -45,6 +100,7 @@ func (dc *DefaultCollector) GetConnections() ([]Connection, error) { if err == nil { connections = append(connections, udpConns6...) } + logTiming("parseProcNet (all)", parseStart, fmt.Sprintf("%d connections", len(connections))) return connections, nil } @@ -71,9 +127,13 @@ type processInfo struct { user string } -func buildInodeToProcessMap() (map[int64]*processInfo, error) { - inodeMap := make(map[int64]*processInfo) +type inodeEntry struct { + inode int64 + info *processInfo +} +func buildInodeToProcessMap() (map[int64]*processInfo, error) { + readDirStart := time.Now() procDir, err := os.Open("/proc") if err != nil { return nil, err @@ -85,47 +145,103 @@ func buildInodeToProcessMap() (map[int64]*processInfo, error) { return nil, err } + // collect pids first + pids := make([]int, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() { continue } + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + pids = append(pids, pid) + } + logTiming(" readdir /proc", readDirStart, fmt.Sprintf("%d pids", len(pids))) - pidStr := entry.Name() - pid, err := strconv.Atoi(pidStr) + // process pids in parallel with limited concurrency + scanStart := time.Now() + const numWorkers = 8 + pidChan := make(chan int, len(pids)) + resultChan := make(chan []inodeEntry, len(pids)) + + var totalFDs atomic.Int64 + var wg sync.WaitGroup + for i := 0; i < numWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for pid := range pidChan { + entries := scanProcessSockets(pid) + if len(entries) > 0 { + totalFDs.Add(int64(len(entries))) + resultChan <- entries + } + } + }() + } + + for _, pid := range pids { + pidChan <- pid + } + close(pidChan) + + go func() { + wg.Wait() + close(resultChan) + }() + + inodeMap := make(map[int64]*processInfo) + for entries := range resultChan { + for _, e := range entries { + inodeMap[e.inode] = e.info + } + } + logTiming(" scan all processes", scanStart, fmt.Sprintf("%d socket fds scanned", totalFDs.Load())) + + return inodeMap, nil +} + +func scanProcessSockets(pid int) []inodeEntry { + start := time.Now() + + procInfo, err := getProcessInfo(pid) + if err != nil { + return nil + } + + pidStr := strconv.Itoa(pid) + fdDir := filepath.Join("/proc", pidStr, "fd") + fdEntries, err := os.ReadDir(fdDir) + if err != nil { + return nil + } + + var results []inodeEntry + for _, fdEntry := range fdEntries { + fdPath := filepath.Join(fdDir, fdEntry.Name()) + link, err := os.Readlink(fdPath) if err != nil { continue } - procInfo, err := getProcessInfo(pid) - if err != nil { - continue - } - - fdDir := filepath.Join("/proc", pidStr, "fd") - fdEntries, err := os.ReadDir(fdDir) - if err != nil { - continue - } - - for _, fdEntry := range fdEntries { - fdPath := filepath.Join(fdDir, fdEntry.Name()) - link, err := os.Readlink(fdPath) + if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") { + inodeStr := link[8 : len(link)-1] + inode, err := strconv.ParseInt(inodeStr, 10, 64) if err != nil { continue } - - if strings.HasPrefix(link, "socket:[") && strings.HasSuffix(link, "]") { - inodeStr := link[8 : len(link)-1] - inode, err := strconv.ParseInt(inodeStr, 10, 64) - if err != nil { - continue - } - inodeMap[inode] = procInfo - } + results = append(results, inodeEntry{inode: inode, info: procInfo}) } } - return inodeMap, nil + elapsed := time.Since(start) + if debugTiming && elapsed > 20*time.Millisecond { + fmt.Fprintf(os.Stderr, "[timing] slow process scan: pid=%d (%s) fds=%d time=%v\n", + pid, procInfo.command, len(fdEntries), elapsed) + } + + return results } func getProcessInfo(pid int) (*processInfo, error) { @@ -173,12 +289,7 @@ func getProcessInfo(pid int) (*processInfo, error) { uid, err := strconv.Atoi(fields[1]) if err == nil { info.uid = uid - u, err := user.LookupId(strconv.Itoa(uid)) - if err == nil { - info.user = u.Username - } else { - info.user = strconv.Itoa(uid) - } + info.user = lookupUsername(uid) } } break diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index 9bb2006..207c209 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -1,7 +1,10 @@ +//go:build linux + package collector import ( "testing" + "time" ) func TestGetConnections(t *testing.T) { @@ -13,4 +16,102 @@ func TestGetConnections(t *testing.T) { // connections are dynamic, so just verify function succeeded t.Logf("Successfully got %d connections", len(conns)) +} + +func TestGetConnectionsPerformance(t *testing.T) { + // measures performance to catch regressions + // run with: go test -v -run TestGetConnectionsPerformance + + const maxDuration = 500 * time.Millisecond + const iterations = 5 + + // warm up caches first + _, err := GetConnections() + if err != nil { + t.Fatalf("warmup failed: %v", err) + } + + var total time.Duration + var maxSeen time.Duration + + for i := 0; i < iterations; i++ { + start := time.Now() + conns, err := GetConnections() + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("iteration %d failed: %v", i, err) + } + + total += elapsed + if elapsed > maxSeen { + maxSeen = elapsed + } + + t.Logf("iteration %d: %v (%d connections)", i+1, elapsed, len(conns)) + } + + avg := total / time.Duration(iterations) + t.Logf("average: %v, max: %v", avg, maxSeen) + + if maxSeen > maxDuration { + t.Errorf("slowest iteration took %v, expected < %v", maxSeen, maxDuration) + } +} + +func TestGetConnectionsColdCache(t *testing.T) { + // tests performance with cold user cache + // this simulates first run or after cache invalidation + + const maxDuration = 2 * time.Second + + clearUserCache() + + start := time.Now() + conns, err := GetConnections() + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("GetConnections() failed: %v", err) + } + + t.Logf("cold cache: %v (%d connections, %d cached users after)", + elapsed, len(conns), userCacheSize()) + + if elapsed > maxDuration { + t.Errorf("cold cache took %v, expected < %v", elapsed, maxDuration) + } +} + +func BenchmarkGetConnections(b *testing.B) { + // warm cache benchmark - measures typical runtime + // run with: go test -bench=BenchmarkGetConnections -benchtime=5s + + // warm up + _, _ = GetConnections() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = GetConnections() + } +} + +func BenchmarkGetConnectionsColdCache(b *testing.B) { + // cold cache benchmark - measures worst-case with cache cleared each iteration + // run with: go test -bench=BenchmarkGetConnectionsColdCache -benchtime=10s + + b.ResetTimer() + for i := 0; i < b.N; i++ { + clearUserCache() + _, _ = GetConnections() + } +} + +func BenchmarkBuildInodeMap(b *testing.B) { + // benchmarks just the inode map building (most expensive part) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = buildInodeToProcessMap() + } } \ No newline at end of file diff --git a/internal/collector/collector_test_helpers_linux.go b/internal/collector/collector_test_helpers_linux.go new file mode 100644 index 0000000..8b1bac8 --- /dev/null +++ b/internal/collector/collector_test_helpers_linux.go @@ -0,0 +1,18 @@ +//go:build linux + +package collector + +// clearUserCache clears the user lookup cache for testing +func clearUserCache() { + userCache.Lock() + userCache.m = make(map[int]string) + userCache.Unlock() +} + +// userCacheSize returns the number of cached user entries +func userCacheSize() int { + userCache.RLock() + defer userCache.RUnlock() + return len(userCache.m) +} + diff --git a/internal/config/config.go b/internal/config/config.go index 44eea97..3e0f87d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,8 +4,10 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" + "github.com/karol-broda/snitch/internal/theme" "github.com/spf13/viper" ) @@ -92,7 +94,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("defaults.interval", "1s") v.SetDefault("defaults.numeric", false) v.SetDefault("defaults.fields", []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"}) - v.SetDefault("defaults.theme", "auto") + v.SetDefault("defaults.theme", "ansi") v.SetDefault("defaults.units", "auto") v.SetDefault("defaults.color", "auto") v.SetDefault("defaults.resolve", true) @@ -127,7 +129,7 @@ func Get() *Config { Interval: "1s", Numeric: false, Fields: []string{"pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"}, - Theme: "auto", + Theme: "ansi", Units: "auto", Color: "auto", Resolve: true, @@ -154,7 +156,9 @@ func (c *Config) GetInterval() time.Duration { // CreateExampleConfig creates an example configuration file func CreateExampleConfig(path string) error { - exampleConfig := `# snitch configuration file + themeList := strings.Join(theme.ListThemes(), ", ") + + exampleConfig := fmt.Sprintf(`# snitch configuration file # See https://github.com/you/snitch for full documentation [defaults] @@ -167,8 +171,9 @@ numeric = false # Default fields to display (comma-separated list) fields = ["pid", "process", "user", "proto", "state", "laddr", "lport", "raddr", "rport"] -# Default theme for TUI (dark, light, mono, auto) -theme = "auto" +# Default theme for TUI (ansi inherits terminal colors) +# Available: %s +theme = "%s" # Default units for byte display (auto, si, iec) units = "auto" @@ -187,17 +192,17 @@ ipv6 = false no_headers = false output_format = "table" sort_by = "" -` +`, themeList, theme.DefaultTheme) // Ensure directory exists if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) } - + // Write config file if err := os.WriteFile(path, []byte(exampleConfig), 0644); err != nil { return fmt.Errorf("failed to write config file: %w", err) } - + return nil } \ No newline at end of file diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index ac4847d..deed367 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -2,12 +2,16 @@ package resolver import ( "context" + "fmt" "net" + "os" "strconv" "sync" "time" ) +var debugTiming = os.Getenv("SNITCH_DEBUG_TIMING") != "" + // Resolver handles DNS and service name resolution with caching and timeouts type Resolver struct { timeout time.Duration @@ -41,6 +45,7 @@ func (r *Resolver) ResolveAddr(addr string) string { } // Perform resolution with timeout + start := time.Now() ctx, cancel := context.WithTimeout(context.Background(), r.timeout) defer cancel() @@ -55,6 +60,11 @@ func (r *Resolver) ResolveAddr(addr string) string { } } + elapsed := time.Since(start) + if debugTiming && elapsed > 50*time.Millisecond { + fmt.Fprintf(os.Stderr, "[timing] slow DNS lookup: %s -> %s (%v)\n", addr, resolved, elapsed) + } + // Cache the result r.mutex.Lock() r.cache[addr] = resolved diff --git a/internal/theme/ansi.go b/internal/theme/ansi.go new file mode 100644 index 0000000..bd59c06 --- /dev/null +++ b/internal/theme/ansi.go @@ -0,0 +1,24 @@ +package theme + +// ANSI palette uses standard terminal colors (0-15) +// this allows the theme to inherit from the user's terminal color scheme +var paletteANSI = Palette{ + Name: "ansi", + + Fg: "15", // bright white + FgMuted: "7", // white + FgSubtle: "8", // bright black (gray) + Bg: "0", // black + BgMuted: "0", // black + Border: "8", // bright black (gray) + + Red: "1", // red + Green: "2", // green + Yellow: "3", // yellow + Blue: "4", // blue + Magenta: "5", // magenta + Cyan: "6", // cyan + Orange: "3", // yellow (ansi has no orange, fallback to yellow) + Gray: "8", // bright black +} + diff --git a/internal/theme/catppuccin.go b/internal/theme/catppuccin.go new file mode 100644 index 0000000..315d6f9 --- /dev/null +++ b/internal/theme/catppuccin.go @@ -0,0 +1,87 @@ +package theme + +// catppuccin mocha (dark) +// https://github.com/catppuccin/catppuccin +var paletteCatppuccinMocha = Palette{ + Name: "catppuccin-mocha", + + Fg: "#cdd6f4", // text + FgMuted: "#a6adc8", // subtext0 + FgSubtle: "#6c7086", // overlay0 + Bg: "#1e1e2e", // base + BgMuted: "#313244", // surface0 + Border: "#45475a", // surface1 + + Red: "#f38ba8", + Green: "#a6e3a1", + Yellow: "#f9e2af", + Blue: "#89b4fa", + Magenta: "#cba6f7", // mauve + Cyan: "#94e2d5", // teal + Orange: "#fab387", // peach + Gray: "#585b70", // surface2 +} + +// catppuccin macchiato (medium-dark) +var paletteCatppuccinMacchiato = Palette{ + Name: "catppuccin-macchiato", + + Fg: "#cad3f5", // text + FgMuted: "#a5adcb", // subtext0 + FgSubtle: "#6e738d", // overlay0 + Bg: "#24273a", // base + BgMuted: "#363a4f", // surface0 + Border: "#494d64", // surface1 + + Red: "#ed8796", + Green: "#a6da95", + Yellow: "#eed49f", + Blue: "#8aadf4", + Magenta: "#c6a0f6", // mauve + Cyan: "#8bd5ca", // teal + Orange: "#f5a97f", // peach + Gray: "#5b6078", // surface2 +} + +// catppuccin frappe (medium) +var paletteCatppuccinFrappe = Palette{ + Name: "catppuccin-frappe", + + Fg: "#c6d0f5", // text + FgMuted: "#a5adce", // subtext0 + FgSubtle: "#737994", // overlay0 + Bg: "#303446", // base + BgMuted: "#414559", // surface0 + Border: "#51576d", // surface1 + + Red: "#e78284", + Green: "#a6d189", + Yellow: "#e5c890", + Blue: "#8caaee", + Magenta: "#ca9ee6", // mauve + Cyan: "#81c8be", // teal + Orange: "#ef9f76", // peach + Gray: "#626880", // surface2 +} + +// catppuccin latte (light) +var paletteCatppuccinLatte = Palette{ + Name: "catppuccin-latte", + + Fg: "#4c4f69", // text + FgMuted: "#6c6f85", // subtext0 + FgSubtle: "#9ca0b0", // overlay0 + Bg: "#eff1f5", // base + BgMuted: "#ccd0da", // surface0 + Border: "#bcc0cc", // surface1 + + Red: "#d20f39", + Green: "#40a02b", + Yellow: "#df8e1d", + Blue: "#1e66f5", + Magenta: "#8839ef", // mauve + Cyan: "#179299", // teal + Orange: "#fe640b", // peach + Gray: "#acb0be", // surface2 +} + diff --git a/internal/theme/dracula.go b/internal/theme/dracula.go new file mode 100644 index 0000000..732fca8 --- /dev/null +++ b/internal/theme/dracula.go @@ -0,0 +1,24 @@ +package theme + +// dracula theme +// https://draculatheme.com/ +var paletteDracula = Palette{ + Name: "dracula", + + Fg: "#f8f8f2", // foreground + FgMuted: "#f8f8f2", // foreground + FgSubtle: "#6272a4", // comment + Bg: "#282a36", // background + BgMuted: "#44475a", // selection + Border: "#44475a", // selection + + Red: "#ff5555", + Green: "#50fa7b", + Yellow: "#f1fa8c", + Blue: "#6272a4", // dracula uses comment color for blue tones + Magenta: "#bd93f9", // purple + Cyan: "#8be9fd", + Orange: "#ffb86c", + Gray: "#6272a4", // comment +} + diff --git a/internal/theme/gruvbox.go b/internal/theme/gruvbox.go new file mode 100644 index 0000000..ee8ac37 --- /dev/null +++ b/internal/theme/gruvbox.go @@ -0,0 +1,45 @@ +package theme + +// gruvbox dark +// https://github.com/morhetz/gruvbox +var paletteGruvboxDark = Palette{ + Name: "gruvbox-dark", + + Fg: "#ebdbb2", // fg + FgMuted: "#d5c4a1", // fg2 + FgSubtle: "#a89984", // fg4 + Bg: "#282828", // bg + BgMuted: "#3c3836", // bg1 + Border: "#504945", // bg2 + + Red: "#fb4934", + Green: "#b8bb26", + Yellow: "#fabd2f", + Blue: "#83a598", + Magenta: "#d3869b", // purple + Cyan: "#8ec07c", // aqua + Orange: "#fe8019", + Gray: "#928374", +} + +// gruvbox light +var paletteGruvboxLight = Palette{ + Name: "gruvbox-light", + + Fg: "#3c3836", // fg + FgMuted: "#504945", // fg2 + FgSubtle: "#7c6f64", // fg4 + Bg: "#fbf1c7", // bg + BgMuted: "#ebdbb2", // bg1 + Border: "#d5c4a1", // bg2 + + Red: "#cc241d", + Green: "#98971a", + Yellow: "#d79921", + Blue: "#458588", + Magenta: "#b16286", // purple + Cyan: "#689d6a", // aqua + Orange: "#d65d0e", + Gray: "#928374", +} + diff --git a/internal/theme/mono.go b/internal/theme/mono.go new file mode 100644 index 0000000..a8b812a --- /dev/null +++ b/internal/theme/mono.go @@ -0,0 +1,49 @@ +package theme + +import "github.com/charmbracelet/lipgloss" + +// createMonoTheme creates a monochrome theme (no colors) +// useful for accessibility, piping output, or minimal terminals +func createMonoTheme() *Theme { + baseStyle := lipgloss.NewStyle() + boldStyle := lipgloss.NewStyle().Bold(true) + + return &Theme{ + Name: "mono", + Styles: Styles{ + Header: boldStyle, + Border: baseStyle, + Selected: boldStyle, + Watched: boldStyle, + Normal: baseStyle, + Error: boldStyle, + Success: boldStyle, + Warning: boldStyle, + Footer: baseStyle, + Background: baseStyle, + + Proto: ProtoStyles{ + TCP: baseStyle, + UDP: baseStyle, + Unix: baseStyle, + TCP6: baseStyle, + UDP6: baseStyle, + }, + + State: StateStyles{ + Listen: baseStyle, + Established: baseStyle, + TimeWait: baseStyle, + CloseWait: baseStyle, + SynSent: baseStyle, + SynRecv: baseStyle, + FinWait1: baseStyle, + FinWait2: baseStyle, + Closing: baseStyle, + LastAck: baseStyle, + Closed: baseStyle, + }, + }, + } +} + diff --git a/internal/theme/nord.go b/internal/theme/nord.go new file mode 100644 index 0000000..671cd4b --- /dev/null +++ b/internal/theme/nord.go @@ -0,0 +1,24 @@ +package theme + +// nord theme +// https://www.nordtheme.com/ +var paletteNord = Palette{ + Name: "nord", + + Fg: "#eceff4", // snow storm - nord6 + FgMuted: "#d8dee9", // snow storm - nord4 + FgSubtle: "#4c566a", // polar night - nord3 + Bg: "#2e3440", // polar night - nord0 + BgMuted: "#3b4252", // polar night - nord1 + Border: "#434c5e", // polar night - nord2 + + Red: "#bf616a", // aurora - nord11 + Green: "#a3be8c", // aurora - nord14 + Yellow: "#ebcb8b", // aurora - nord13 + Blue: "#81a1c1", // frost - nord9 + Magenta: "#b48ead", // aurora - nord15 + Cyan: "#88c0d0", // frost - nord8 + Orange: "#d08770", // aurora - nord12 + Gray: "#4c566a", // polar night - nord3 +} + diff --git a/internal/theme/one_dark.go b/internal/theme/one_dark.go new file mode 100644 index 0000000..2fecf4f --- /dev/null +++ b/internal/theme/one_dark.go @@ -0,0 +1,24 @@ +package theme + +// one dark theme (atom editor) +// https://github.com/atom/atom/tree/master/packages/one-dark-syntax +var paletteOneDark = Palette{ + Name: "one-dark", + + Fg: "#abb2bf", // foreground + FgMuted: "#9da5b4", // foreground muted + FgSubtle: "#5c6370", // comment + Bg: "#282c34", // background + BgMuted: "#21252b", // gutter background + Border: "#3e4451", // selection + + Red: "#e06c75", + Green: "#98c379", + Yellow: "#e5c07b", + Blue: "#61afef", + Magenta: "#c678dd", // purple + Cyan: "#56b6c2", + Orange: "#d19a66", + Gray: "#5c6370", // comment +} + diff --git a/internal/theme/palette.go b/internal/theme/palette.go new file mode 100644 index 0000000..01a47f9 --- /dev/null +++ b/internal/theme/palette.go @@ -0,0 +1,111 @@ +package theme + +import ( + "strconv" + + "github.com/charmbracelet/lipgloss" +) + +// Palette defines the semantic colors for a theme +type Palette struct { + Name string + + // base colors + Fg string // primary foreground + FgMuted string // secondary/muted foreground + FgSubtle string // subtle/disabled foreground + Bg string // primary background + BgMuted string // secondary background (selections, highlights) + Border string // border color + + // semantic colors + Red string + Green string + Yellow string + Blue string + Magenta string + Cyan string + Orange string + Gray string +} + +// Color converts a palette color string to a lipgloss.TerminalColor. +// If the string is 1-2 characters, it's treated as an ANSI color code. +// Otherwise, it's treated as a hex color. +func (p *Palette) Color(c string) lipgloss.TerminalColor { + if c == "" { + return lipgloss.NoColor{} + } + + if len(c) <= 2 { + n, err := strconv.Atoi(c) + if err == nil { + return lipgloss.ANSIColor(n) + } + } + + return lipgloss.Color(c) +} + +// ToTheme converts a Palette to a Theme with lipgloss styles +func (p *Palette) ToTheme() *Theme { + return &Theme{ + Name: p.Name, + Styles: Styles{ + Header: lipgloss.NewStyle(). + Bold(true). + Foreground(p.Color(p.Fg)), + + Border: lipgloss.NewStyle(). + Foreground(p.Color(p.Border)), + + Selected: lipgloss.NewStyle(). + Bold(true). + Foreground(p.Color(p.Fg)), + + Watched: lipgloss.NewStyle(). + Bold(true). + Foreground(p.Color(p.Orange)), + + Normal: lipgloss.NewStyle(). + Foreground(p.Color(p.FgMuted)), + + Error: lipgloss.NewStyle(). + Foreground(p.Color(p.Red)), + + Success: lipgloss.NewStyle(). + Foreground(p.Color(p.Green)), + + Warning: lipgloss.NewStyle(). + Foreground(p.Color(p.Yellow)), + + Footer: lipgloss.NewStyle(). + Foreground(p.Color(p.FgSubtle)), + + Background: lipgloss.NewStyle(), + + Proto: ProtoStyles{ + TCP: lipgloss.NewStyle().Foreground(p.Color(p.Green)), + UDP: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)), + Unix: lipgloss.NewStyle().Foreground(p.Color(p.Gray)), + TCP6: lipgloss.NewStyle().Foreground(p.Color(p.Cyan)), + UDP6: lipgloss.NewStyle().Foreground(p.Color(p.Blue)), + }, + + State: StateStyles{ + Listen: lipgloss.NewStyle().Foreground(p.Color(p.Green)), + Established: lipgloss.NewStyle().Foreground(p.Color(p.Blue)), + TimeWait: lipgloss.NewStyle().Foreground(p.Color(p.Yellow)), + CloseWait: lipgloss.NewStyle().Foreground(p.Color(p.Orange)), + SynSent: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)), + SynRecv: lipgloss.NewStyle().Foreground(p.Color(p.Magenta)), + FinWait1: lipgloss.NewStyle().Foreground(p.Color(p.Red)), + FinWait2: lipgloss.NewStyle().Foreground(p.Color(p.Red)), + Closing: lipgloss.NewStyle().Foreground(p.Color(p.Red)), + LastAck: lipgloss.NewStyle().Foreground(p.Color(p.Red)), + Closed: lipgloss.NewStyle().Foreground(p.Color(p.Gray)), + }, + }, + } +} + diff --git a/internal/theme/readme.md b/internal/theme/readme.md new file mode 100644 index 0000000..2a8d832 --- /dev/null +++ b/internal/theme/readme.md @@ -0,0 +1,14 @@ +# theme Palettes + +the color palettes in this directory were generated by an LLM agent (Claude Opus 4.5) using web search to fetch the official color specifications from each themes documentation +as it is with llm agents its possible the colors may be wrong + +Sources: +- [Catppuccin](https://github.com/catppuccin/catppuccin) +- [Dracula](https://draculatheme.com/) +- [Gruvbox](https://github.com/morhetz/gruvbox) +- [Nord](https://www.nordtheme.com/) +- [One Dark](https://github.com/atom/one-dark-syntax) +- [Solarized](https://ethanschoonover.com/solarized/) +- [Tokyo Night](https://github.com/enkia/tokyo-night-vscode-theme) + diff --git a/internal/theme/solarized.go b/internal/theme/solarized.go new file mode 100644 index 0000000..40d22e1 --- /dev/null +++ b/internal/theme/solarized.go @@ -0,0 +1,45 @@ +package theme + +// solarized dark theme +// https://ethanschoonover.com/solarized/ +var paletteSolarizedDark = Palette{ + Name: "solarized-dark", + + Fg: "#839496", // base0 + FgMuted: "#93a1a1", // base1 + FgSubtle: "#586e75", // base01 + Bg: "#002b36", // base03 + BgMuted: "#073642", // base02 + Border: "#073642", // base02 + + Red: "#dc322f", + Green: "#859900", + Yellow: "#b58900", + Blue: "#268bd2", + Magenta: "#d33682", + Cyan: "#2aa198", + Orange: "#cb4b16", + Gray: "#657b83", // base00 +} + +// solarized light theme +var paletteSolarizedLight = Palette{ + Name: "solarized-light", + + Fg: "#657b83", // base00 + FgMuted: "#586e75", // base01 + FgSubtle: "#93a1a1", // base1 + Bg: "#fdf6e3", // base3 + BgMuted: "#eee8d5", // base2 + Border: "#eee8d5", // base2 + + Red: "#dc322f", + Green: "#859900", + Yellow: "#b58900", + Blue: "#268bd2", + Magenta: "#d33682", + Cyan: "#2aa198", + Orange: "#cb4b16", + Gray: "#839496", // base0 +} + diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 6340136..dca5505 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -1,6 +1,7 @@ package theme import ( + "sort" "strings" "github.com/charmbracelet/lipgloss" @@ -52,152 +53,73 @@ type StateStyles struct { Closed lipgloss.Style } -var ( - themes map[string]*Theme -) +var themes map[string]*Theme func init() { - themes = map[string]*Theme{ - "default": createAdaptiveTheme(), - "mono": createMonoTheme(), - } + themes = make(map[string]*Theme) + + // ansi theme (default) - inherits from terminal colors + themes["ansi"] = paletteANSI.ToTheme() + + // catppuccin variants + themes["catppuccin-mocha"] = paletteCatppuccinMocha.ToTheme() + themes["catppuccin-macchiato"] = paletteCatppuccinMacchiato.ToTheme() + themes["catppuccin-frappe"] = paletteCatppuccinFrappe.ToTheme() + themes["catppuccin-latte"] = paletteCatppuccinLatte.ToTheme() + + // gruvbox variants + themes["gruvbox-dark"] = paletteGruvboxDark.ToTheme() + themes["gruvbox-light"] = paletteGruvboxLight.ToTheme() + + // dracula + themes["dracula"] = paletteDracula.ToTheme() + + // nord + themes["nord"] = paletteNord.ToTheme() + + // tokyo night variants + themes["tokyo-night"] = paletteTokyoNight.ToTheme() + themes["tokyo-night-storm"] = paletteTokyoNightStorm.ToTheme() + themes["tokyo-night-light"] = paletteTokyoNightLight.ToTheme() + + // solarized variants + themes["solarized-dark"] = paletteSolarizedDark.ToTheme() + themes["solarized-light"] = paletteSolarizedLight.ToTheme() + + // one dark + themes["one-dark"] = paletteOneDark.ToTheme() + + // monochrome (no colors) + themes["mono"] = createMonoTheme() } -// GetTheme returns a theme by name, with auto-detection support +// DefaultTheme is the theme used when none is specified +const DefaultTheme = "ansi" + +// GetTheme returns a theme by name func GetTheme(name string) *Theme { - if name == "auto" { - // lipgloss handles adaptive colors, so we just return the default - return themes["default"] + if name == "" || name == "auto" || name == "default" { + return themes[DefaultTheme] } if theme, exists := themes[name]; exists { return theme } - // a specific theme was requested (e.g. "dark", "light"), but we now use adaptive - // so we can just return the default theme and lipgloss will handle it - if name == "dark" || name == "light" { - return themes["default"] - } - // fallback to default - return themes["default"] + return themes[DefaultTheme] } -// ListThemes returns available theme names +// ListThemes returns available theme names sorted alphabetically func ListThemes() []string { - var names []string + names := make([]string, 0, len(themes)) for name := range themes { names = append(names, name) } + sort.Strings(names) return names } -// createAdaptiveTheme creates a clean, minimal theme -func createAdaptiveTheme() *Theme { - return &Theme{ - Name: "default", - Styles: Styles{ - Header: lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#F9FAFB"}), - - Watched: lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"}), - - Border: lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#D1D5DB", Dark: "#374151"}), - - Selected: lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "#1F2937", Dark: "#F9FAFB"}), - - Normal: lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"}), - - Error: lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}), - - Success: lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}), - - Warning: lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}), - - Footer: lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#6B7280"}), - - Background: lipgloss.NewStyle(), - - Proto: ProtoStyles{ - TCP: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}), - UDP: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}), - Unix: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#9CA3AF"}), - TCP6: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}), - UDP6: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}), - }, - - State: StateStyles{ - Listen: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#059669", Dark: "#34D399"}), - Established: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#2563EB", Dark: "#60A5FA"}), - TimeWait: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}), - CloseWait: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#FBBF24"}), - SynSent: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}), - SynRecv: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#7C3AED", Dark: "#A78BFA"}), - FinWait1: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}), - FinWait2: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}), - Closing: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}), - LastAck: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#F87171"}), - Closed: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9CA3AF", Dark: "#6B7280"}), - }, - }, - } -} - -// createMonoTheme creates a monochrome theme (no colors) -func createMonoTheme() *Theme { - baseStyle := lipgloss.NewStyle() - boldStyle := lipgloss.NewStyle().Bold(true) - - return &Theme{ - Name: "mono", - Styles: Styles{ - Header: boldStyle, - Border: baseStyle, - Selected: boldStyle, - Normal: baseStyle, - Error: boldStyle, - Success: boldStyle, - Warning: boldStyle, - Footer: baseStyle, - Background: baseStyle, - - Proto: ProtoStyles{ - TCP: baseStyle, - UDP: baseStyle, - Unix: baseStyle, - TCP6: baseStyle, - UDP6: baseStyle, - }, - - State: StateStyles{ - Listen: baseStyle, - Established: baseStyle, - TimeWait: baseStyle, - CloseWait: baseStyle, - SynSent: baseStyle, - SynRecv: baseStyle, - FinWait1: baseStyle, - FinWait2: baseStyle, - Closing: baseStyle, - LastAck: baseStyle, - Closed: baseStyle, - }, - }, - } -} - // GetProtoStyle returns the appropriate style for a protocol func (s *Styles) GetProtoStyle(proto string) lipgloss.Style { switch strings.ToLower(proto) { diff --git a/internal/theme/tokyo_night.go b/internal/theme/tokyo_night.go new file mode 100644 index 0000000..a4fb793 --- /dev/null +++ b/internal/theme/tokyo_night.go @@ -0,0 +1,66 @@ +package theme + +// tokyo night theme +// https://github.com/enkia/tokyo-night-vscode-theme +var paletteTokyoNight = Palette{ + Name: "tokyo-night", + + Fg: "#c0caf5", // foreground + FgMuted: "#a9b1d6", // foreground dark + FgSubtle: "#565f89", // comment + Bg: "#1a1b26", // background + BgMuted: "#24283b", // background highlight + Border: "#414868", // border + + Red: "#f7768e", + Green: "#9ece6a", + Yellow: "#e0af68", + Blue: "#7aa2f7", + Magenta: "#bb9af7", // purple + Cyan: "#7dcfff", + Orange: "#ff9e64", + Gray: "#565f89", // comment +} + +// tokyo night storm variant +var paletteTokyoNightStorm = Palette{ + Name: "tokyo-night-storm", + + Fg: "#c0caf5", // foreground + FgMuted: "#a9b1d6", // foreground dark + FgSubtle: "#565f89", // comment + Bg: "#24283b", // background (storm is slightly lighter) + BgMuted: "#1f2335", // background dark + Border: "#414868", // border + + Red: "#f7768e", + Green: "#9ece6a", + Yellow: "#e0af68", + Blue: "#7aa2f7", + Magenta: "#bb9af7", // purple + Cyan: "#7dcfff", + Orange: "#ff9e64", + Gray: "#565f89", // comment +} + +// tokyo night light variant +var paletteTokyoNightLight = Palette{ + Name: "tokyo-night-light", + + Fg: "#343b58", // foreground + FgMuted: "#565a6e", // foreground dark + FgSubtle: "#9699a3", // comment + Bg: "#d5d6db", // background + BgMuted: "#cbccd1", // background highlight + Border: "#b4b5b9", // border + + Red: "#8c4351", + Green: "#485e30", + Yellow: "#8f5e15", + Blue: "#34548a", + Magenta: "#5a4a78", // purple + Cyan: "#0f4b6e", + Orange: "#965027", + Gray: "#9699a3", // comment +} +