mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
797c62e9b4
* Make the TUIs adapt to light and dark terminal backgrounds
The submit, view, and modify TUIs were tuned for dark terminals. On light
or solarized-light backgrounds the result was hard to read and inverted:
primary text used ANSI white (invisible on white), dim chrome used light
grays (too faint), and accents used bright cyan (low contrast) — so
"active" things looked lighter than "disabled" ones.
Introduce a centralized, background-aware color palette and migrate all
three TUIs to it:
- internal/tui/shared/theme.go: a semantic palette of lipgloss.AdaptiveColor
values (primary/muted/faint text, chrome/border, accent, PR-state colors,
badge backgrounds, row shade, button, switch). lipgloss resolves the
light/dark variant per render from the terminal background, which Bubble
Tea detects at startup; terminals that don't report it fall back to dark,
preserving the original look.
- Replace every hardcoded ANSI color in shared/, submitview/, and
modifyview/ with palette roles. The four pre-rendered status icons now
render at use-time so their adaptive colors resolve correctly. The submit
markdown preview picks glamour's light or dark style from the detected
background.
- GH_STACK_THEME=auto|light|dark forces the palette for terminals that
mis-detect (some SSH/tmux setups); wired via the root command's
PersistentPreRun before any render. Documented in the README and CLI docs.
Neutral text/chrome use truecolor hex (GitHub Primer-inspired) for
predictability across themes, including solarized which repurposes ANSI
8-15; lipgloss downsamples on terminals without truecolor.
Tests verify the palette resolves differently for light vs dark and that
GH_STACK_THEME is honored.
* Apply background-aware colors to all command output
Background detection and the GH_STACK_THEME override (added for the TUIs)
only affected the interactive screens. Plain command output -- status
messages and interactive prompts -- went through the mgutz/ansi library
with fixed ANSI palette names (green/red/yellow/cyan/...), so it never
adapted to the terminal background and could read poorly on light or
solarized themes.
Unify everything on the same adaptive palette so all colors react to the
detected background and to GH_STACK_THEME.
- Extract internal/theme, a foundational package with no internal
dependencies, that owns:
- the background-aware lipgloss.AdaptiveColor palette (moved out of
internal/tui/shared),
- ApplyOverride(), the GH_STACK_THEME=auto|light|dark logic, and
- non-TUI colorizers (Success/Error/Warning/Blue/Magenta/Cyan/Gray/
Bold) plus FgSeqs(), which returns the raw start/reset escapes used
to color the user's echoed prompt input.
- internal/tui/shared/theme.go now re-exports the palette, so the TUI code
keeps referring to shared.ColorX unchanged.
- internal/config/config.go wires the Config.Color* funcs to the theme
colorizers and drops mgutz/ansi (now an indirect dependency only).
- cmd/utils.go colors the prompt icon and echoed input via theme.
- cmd/root.go calls theme.ApplyOverride() in PersistentPreRun.
Detection adds no cost: because the command package imports Bubble Tea,
its init() already triggers (and caches) the terminal background query for
every command, so the non-TUI colorizers just read the cached value.
Terminals that don't answer the query fall back to the dark palette;
GH_STACK_THEME=light|dark forces it. Colors are truecolor on capable
terminals and downsample to the nearest ANSI color elsewhere.
Tests: internal/theme covers palette adaptiveness, ApplyOverride, the
colorizers, and FgSeqs; a new internal/config test verifies the wired-up
Config.Color* funcs adapt to the background when color is enabled.
Docs: README and the CLI reference note that GH_STACK_THEME now controls
all colored output, not just the interactive screens.
No behavior change beyond colors.
139 lines
6.5 KiB
Go
139 lines
6.5 KiB
Go
// Package theme defines gh-stack's background-aware color palette and the
|
||
// helpers that render it, shared by both the interactive TUIs and ordinary
|
||
// command output (prompts, status messages).
|
||
//
|
||
// Colors are expressed as lipgloss.AdaptiveColor, whose Light/Dark variant is
|
||
// chosen at render time from the terminal's detected background. Bubble Tea
|
||
// triggers that detection once at startup (see bubbletea/tea_init.go) — which,
|
||
// because the command package imports Bubble Tea, happens for every command —
|
||
// so the right variant is picked automatically and cached. Terminals that don't
|
||
// answer the query fall back to the dark palette, preserving the original look.
|
||
// GH_STACK_THEME (see ApplyOverride) forces a palette when detection is wrong.
|
||
//
|
||
// Values are truecolor hex (GitHub Primer-inspired) rather than ANSI palette
|
||
// indices so they render consistently across themes — notably solarized, which
|
||
// repurposes ANSI 8–15 as background tones. lipgloss downsamples to the nearest
|
||
// ANSI color on terminals without truecolor support.
|
||
package theme
|
||
|
||
import (
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
var (
|
||
// ColorText is primary/emphasis ink: titles, branch names, links, active
|
||
// keys, the description scrollbar thumb.
|
||
ColorText = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"}
|
||
// ColorTextMuted is secondary ink and dim chrome text: section labels,
|
||
// shortcut descriptions, hints, trunk/merged branches, timestamps.
|
||
ColorTextMuted = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"}
|
||
// ColorTextFaint is disabled/de-emphasized ink: skipped branches, disabled
|
||
// shortcuts.
|
||
ColorTextFaint = lipgloss.AdaptiveColor{Dark: "#656c76", Light: "#818b98"}
|
||
|
||
// ColorBorder is structural chrome: panel borders, tree connectors, the
|
||
// vertical spine, horizontal rules, scrollbar tracks, segmented-control frame.
|
||
ColorBorder = lipgloss.AdaptiveColor{Dark: "#3d444d", Light: "#d1d9e0"}
|
||
// ColorRowShade tints the focused (currently-viewed) row's background in the
|
||
// left timeline. A neutral wash that reads as a subtle highlight on either
|
||
// background — light gray on light terminals, and a lifted slate on dark
|
||
// terminals so it stays visible against near-black backgrounds.
|
||
ColorRowShade = lipgloss.AdaptiveColor{Dark: "#353941", Light: "#eaeef2"}
|
||
|
||
// ColorAccent is interactive emphasis: the current/focused branch, keyboard
|
||
// shortcut keys, footer accents, and the cyan used in plain command output.
|
||
ColorAccent = lipgloss.AdaptiveColor{Dark: "#2dd4bf", Light: "#0a7ea4"}
|
||
|
||
// Semantic status colors, mirroring how GitHub colors PR states. Reused for
|
||
// diff stats (green/red), commit SHAs and warnings (yellow), modify action
|
||
// badges, and the success/error/warning message icons.
|
||
ColorBlue = lipgloss.AdaptiveColor{Dark: "#4493f8", Light: "#0969da"} // NEW, blue
|
||
ColorGreen = lipgloss.AdaptiveColor{Dark: "#3fb950", Light: "#1a7f37"} // OPEN, additions, success
|
||
ColorGray = lipgloss.AdaptiveColor{Dark: "#9198a1", Light: "#59636e"} // DRAFT, dim
|
||
ColorYellow = lipgloss.AdaptiveColor{Dark: "#d29922", Light: "#9a6700"} // QUEUED, warning, commit SHA
|
||
ColorPurple = lipgloss.AdaptiveColor{Dark: "#bc8cff", Light: "#8250df"} // MERGED, magenta
|
||
ColorRed = lipgloss.AdaptiveColor{Dark: "#f85149", Light: "#cf222e"} // CLOSED, deletions, error
|
||
|
||
// ColorOnFill is text drawn on top of a solid colored fill (e.g. the green
|
||
// "selected" pill): near-black on the lighter dark-mode fills, white on the
|
||
// darker light-mode fills.
|
||
ColorOnFill = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"}
|
||
|
||
// ColorButtonFg/ColorButtonBg style the prominent inverted action button
|
||
// (e.g. submit). The background inverts against the terminal so the button
|
||
// stays prominent in both modes.
|
||
ColorButtonBg = lipgloss.AdaptiveColor{Dark: "#f0f6fc", Light: "#1f2328"}
|
||
ColorButtonFg = lipgloss.AdaptiveColor{Dark: "#0d1117", Light: "#ffffff"}
|
||
)
|
||
|
||
// ApplyOverride honors the GH_STACK_THEME environment variable, forcing the
|
||
// light or dark palette regardless of what the terminal reports. It must be
|
||
// called before the first render (e.g. before any colored output or launching a
|
||
// Bubble Tea program).
|
||
//
|
||
// GH_STACK_THEME=light force the light palette
|
||
// GH_STACK_THEME=dark force the dark palette
|
||
// GH_STACK_THEME=auto (or unset) detect from the terminal background
|
||
//
|
||
// Use this for terminals that don't answer the background query (some SSH/tmux
|
||
// setups) and therefore mis-detect.
|
||
func ApplyOverride() {
|
||
switch strings.ToLower(strings.TrimSpace(os.Getenv("GH_STACK_THEME"))) {
|
||
case "light":
|
||
lipgloss.SetHasDarkBackground(false)
|
||
case "dark":
|
||
lipgloss.SetHasDarkBackground(true)
|
||
}
|
||
}
|
||
|
||
// Colorize renders s in the given adaptive color for plain (non-TUI) output. It
|
||
// emits ANSI only when the default renderer detects a color-capable terminal, so
|
||
// callers should still gate on their own color-enabled check for consistency.
|
||
func Colorize(c lipgloss.TerminalColor, s string) string {
|
||
return lipgloss.NewStyle().Foreground(c).Render(s)
|
||
}
|
||
|
||
// The following helpers color plain command output and prompts. They map the
|
||
// semantic roles the command layer uses onto the adaptive palette.
|
||
|
||
// Success renders s in the success (green) color.
|
||
func Success(s string) string { return Colorize(ColorGreen, s) }
|
||
|
||
// Error renders s in the error (red) color.
|
||
func Error(s string) string { return Colorize(ColorRed, s) }
|
||
|
||
// Warning renders s in the warning (yellow) color.
|
||
func Warning(s string) string { return Colorize(ColorYellow, s) }
|
||
|
||
// Blue renders s in the blue color.
|
||
func Blue(s string) string { return Colorize(ColorBlue, s) }
|
||
|
||
// Magenta renders s in the magenta/purple color.
|
||
func Magenta(s string) string { return Colorize(ColorPurple, s) }
|
||
|
||
// Cyan renders s in the accent (cyan/teal) color.
|
||
func Cyan(s string) string { return Colorize(ColorAccent, s) }
|
||
|
||
// Gray renders s in the muted gray color.
|
||
func Gray(s string) string { return Colorize(ColorGray, s) }
|
||
|
||
// Bold renders s in bold using the terminal's default foreground, which already
|
||
// contrasts with either background.
|
||
func Bold(s string) string { return lipgloss.NewStyle().Bold(true).Render(s) }
|
||
|
||
// FgSeqs returns the raw SGR escape that starts foreground rendering in c and
|
||
// the matching reset, for coloring text printed outside lipgloss (e.g. echoed
|
||
// terminal input). Both are empty when the default renderer has no color
|
||
// support.
|
||
func FgSeqs(c lipgloss.TerminalColor) (start, reset string) {
|
||
rendered := lipgloss.NewStyle().Foreground(c).Render("\x00")
|
||
i := strings.IndexByte(rendered, '\x00')
|
||
if i < 0 {
|
||
return "", ""
|
||
}
|
||
return rendered[:i], rendered[i+1:]
|
||
}
|