|
| 1 | +// Package theme defines gh-stack's background-aware color palette and the |
| 2 | +// helpers that render it, shared by both the interactive TUIs and ordinary |
| 3 | +// command output (prompts, status messages). |
| 4 | +// |
| 5 | +// Colors are expressed as lipgloss.AdaptiveColor, whose Light/Dark variant is |
| 6 | +// chosen at render time from the terminal's detected background. Bubble Tea |
| 7 | +// triggers that detection once at startup (see bubbletea/tea_init.go) — which, |
| 8 | +// because the command package imports Bubble Tea, happens for every command — |
| 9 | +// so the right variant is picked automatically and cached. Terminals that don't |
| 10 | +// answer the query fall back to the dark palette, preserving the original look. |
| 11 | +// GH_STACK_THEME (see ApplyOverride) forces a palette when detection is wrong. |
| 12 | +// |
| 13 | +// Values are truecolor hex (GitHub Primer-inspired) rather than ANSI palette |
| 14 | +// indices so they render consistently across themes — notably solarized, which |
| 15 | +// repurposes ANSI 8–15 as background tones. lipgloss downsamples to the nearest |
| 16 | +// ANSI color on terminals without truecolor support. |
| 17 | +package theme |
| 18 | + |
| 19 | +import ( |
| 20 | + "os" |
| 21 | + "strings" |
| 22 | + |
| 23 | + "github.com/charmbracelet/lipgloss" |
| 24 | +) |
| 25 | + |
| 26 | +var ( |
| 27 | + // ColorText is primary/emphasis ink: titles, branch names, links, active |
| 28 | + // keys, the description scrollbar thumb. |
| 29 | + ColorText = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} |
| 30 | + // ColorTextMuted is secondary ink and dim chrome text: section labels, |
| 31 | + // shortcut descriptions, hints, trunk/merged branches, timestamps. |
| 32 | + ColorTextMuted = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} |
| 33 | + // ColorTextFaint is disabled/de-emphasized ink: skipped branches, disabled |
| 34 | + // shortcuts. |
| 35 | + ColorTextFaint = lipgloss.AdaptiveColor{Dark: "#656c76", Light: "#818b98"} |
| 36 | + |
| 37 | + // ColorBorder is structural chrome: panel borders, tree connectors, the |
| 38 | + // vertical spine, horizontal rules, scrollbar tracks, segmented-control frame. |
| 39 | + ColorBorder = lipgloss.AdaptiveColor{Dark: "#3d444d", Light: "#d1d9e0"} |
| 40 | + // ColorRowShade tints the focused (currently-viewed) row's background in the |
| 41 | + // left timeline. A neutral wash that reads as a subtle highlight on either |
| 42 | + // background — light gray on light terminals, and a lifted slate on dark |
| 43 | + // terminals so it stays visible against near-black backgrounds. |
| 44 | + ColorRowShade = lipgloss.AdaptiveColor{Dark: "#353941", Light: "#eaeef2"} |
| 45 | + |
| 46 | + // ColorAccent is interactive emphasis: the current/focused branch, keyboard |
| 47 | + // shortcut keys, footer accents, and the cyan used in plain command output. |
| 48 | + ColorAccent = lipgloss.AdaptiveColor{Dark: "#2dd4bf", Light: "#0a7ea4"} |
| 49 | + |
| 50 | + // Semantic status colors, mirroring how GitHub colors PR states. Reused for |
| 51 | + // diff stats (green/red), commit SHAs and warnings (yellow), modify action |
| 52 | + // badges, and the success/error/warning message icons. |
| 53 | + ColorBlue = lipgloss.AdaptiveColor{Dark: "#4493f8", Light: "#0969da"} // NEW, blue |
| 54 | + ColorGreen = lipgloss.AdaptiveColor{Dark: "#3fb950", Light: "#1a7f37"} // OPEN, additions, success |
| 55 | + ColorGray = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} // DRAFT, dim |
| 56 | + ColorYellow = lipgloss.AdaptiveColor{Dark: "#d29922", Light: "#9a6700"} // QUEUED, warning, commit SHA |
| 57 | + ColorPurple = lipgloss.AdaptiveColor{Dark: "#bc8cff", Light: "#8250df"} // MERGED, magenta |
| 58 | + ColorRed = lipgloss.AdaptiveColor{Dark: "#f85149", Light: "#cf222e"} // CLOSED, deletions, error |
| 59 | + |
| 60 | + // ColorOnFill is text drawn on top of a solid colored fill (e.g. the green |
| 61 | + // "selected" pill): near-black on the lighter dark-mode fills, white on the |
| 62 | + // darker light-mode fills. |
| 63 | + ColorOnFill = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} |
| 64 | + |
| 65 | + // ColorButtonFg/ColorButtonBg style the prominent inverted action button |
| 66 | + // (e.g. submit). The background inverts against the terminal so the button |
| 67 | + // stays prominent in both modes. |
| 68 | + ColorButtonBg = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"} |
| 69 | + ColorButtonFg = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"} |
| 70 | +) |
| 71 | + |
| 72 | +// ApplyOverride honors the GH_STACK_THEME environment variable, forcing the |
| 73 | +// light or dark palette regardless of what the terminal reports. It must be |
| 74 | +// called before the first render (e.g. before any colored output or launching a |
| 75 | +// Bubble Tea program). |
| 76 | +// |
| 77 | +// GH_STACK_THEME=light force the light palette |
| 78 | +// GH_STACK_THEME=dark force the dark palette |
| 79 | +// GH_STACK_THEME=auto (or unset) detect from the terminal background |
| 80 | +// |
| 81 | +// Use this for terminals that don't answer the background query (some SSH/tmux |
| 82 | +// setups) and therefore mis-detect. |
| 83 | +func ApplyOverride() { |
| 84 | + switch strings.ToLower(strings.TrimSpace(os.Getenv("GH_STACK_THEME"))) { |
| 85 | + case "light": |
| 86 | + lipgloss.SetHasDarkBackground(false) |
| 87 | + case "dark": |
| 88 | + lipgloss.SetHasDarkBackground(true) |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +// Colorize renders s in the given adaptive color for plain (non-TUI) output. It |
| 93 | +// emits ANSI only when the default renderer detects a color-capable terminal, so |
| 94 | +// callers should still gate on their own color-enabled check for consistency. |
| 95 | +func Colorize(c lipgloss.TerminalColor, s string) string { |
| 96 | + return lipgloss.NewStyle().Foreground(c).Render(s) |
| 97 | +} |
| 98 | + |
| 99 | +// The following helpers color plain command output and prompts. They map the |
| 100 | +// semantic roles the command layer uses onto the adaptive palette. |
| 101 | + |
| 102 | +// Success renders s in the success (green) color. |
| 103 | +func Success(s string) string { return Colorize(ColorGreen, s) } |
| 104 | + |
| 105 | +// Error renders s in the error (red) color. |
| 106 | +func Error(s string) string { return Colorize(ColorRed, s) } |
| 107 | + |
| 108 | +// Warning renders s in the warning (yellow) color. |
| 109 | +func Warning(s string) string { return Colorize(ColorYellow, s) } |
| 110 | + |
| 111 | +// Blue renders s in the blue color. |
| 112 | +func Blue(s string) string { return Colorize(ColorBlue, s) } |
| 113 | + |
| 114 | +// Magenta renders s in the magenta/purple color. |
| 115 | +func Magenta(s string) string { return Colorize(ColorPurple, s) } |
| 116 | + |
| 117 | +// Cyan renders s in the accent (cyan/teal) color. |
| 118 | +func Cyan(s string) string { return Colorize(ColorAccent, s) } |
| 119 | + |
| 120 | +// Gray renders s in the muted gray color. |
| 121 | +func Gray(s string) string { return Colorize(ColorGray, s) } |
| 122 | + |
| 123 | +// Bold renders s in bold using the terminal's default foreground, which already |
| 124 | +// contrasts with either background. |
| 125 | +func Bold(s string) string { return lipgloss.NewStyle().Bold(true).Render(s) } |
| 126 | + |
| 127 | +// FgSeqs returns the raw SGR escape that starts foreground rendering in c and |
| 128 | +// the matching reset, for coloring text printed outside lipgloss (e.g. echoed |
| 129 | +// terminal input). Both are empty when the default renderer has no color |
| 130 | +// support. |
| 131 | +func FgSeqs(c lipgloss.TerminalColor) (start, reset string) { |
| 132 | + rendered := lipgloss.NewStyle().Foreground(c).Render("\x00") |
| 133 | + i := strings.IndexByte(rendered, '\x00') |
| 134 | + if i < 0 { |
| 135 | + return "", "" |
| 136 | + } |
| 137 | + return rendered[:i], rendered[i+1:] |
| 138 | +} |
0 commit comments