Files
github__gh-stack/internal/tui/shared/render.go
Sameen Karim 797c62e9b4 Adapt theme colors to light and dark terminals (#149)
* 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.
2026-06-29 20:11:09 -04:00

530 lines
14 KiB
Go

package shared
import (
"fmt"
"os/exec"
"runtime"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/github/gh-stack/internal/git"
ghapi "github.com/github/gh-stack/internal/github"
"github.com/github/gh-stack/internal/stack"
)
// BranchNodeData is the interface for branch data that can be rendered.
// Both stackview.BranchNode and modifyview.ModifyBranchNode satisfy this.
type BranchNodeData struct {
Ref stack.BranchRef
IsCurrent bool
IsLinear bool
BaseBranch string
Commits []git.CommitInfo
FilesChanged []git.FileDiffStat
PR *ghapi.PRDetails
Additions int
Deletions int
CommitsExpanded bool
FilesExpanded bool
// ShowCurrentLabel controls whether "(current)" is appended and cyan
// styling is used for the current branch. View sets this true; Modify
// leaves it false so all branches look uniform.
ShowCurrentLabel bool
// BranchNameStyleOverride, when non-nil, overrides the default branch
// name style. Used by Modify to render dropped branches in red
// strikethrough and folded branches in yellow strikethrough.
BranchNameStyleOverride *lipgloss.Style
// ForceDashedConnector, when true, forces the connector line to use
// the dashed style (┊) regardless of linearity. Used by Modify for
// branches marked for drop or fold.
ForceDashedConnector bool
// ConnectorStyleOverride, when non-nil, overrides the default connector
// color for dashed lines. Used to make drop connectors red and fold
// connectors yellow.
ConnectorStyleOverride *lipgloss.Style
}
// NodeAnnotation is an optional annotation to display after the branch info.
type NodeAnnotation struct {
Text string
Style lipgloss.Style
}
// ResolveConnectorStyle determines the connector character and style for a node.
func ResolveConnectorStyle(node BranchNodeData, isFocused bool) (string, lipgloss.Style) {
connector := "│"
connStyle := ConnectorStyle
isMerged := node.Ref.IsMerged()
isQueued := node.Ref.IsQueued()
if node.ForceDashedConnector || (!node.IsLinear && !isMerged && !isQueued) {
connector = "┊"
if node.ConnectorStyleOverride == nil {
connStyle = ConnectorDashedStyle
}
}
// Apply explicit connector color override (drop=red, fold=yellow, moved=magenta)
if node.ConnectorStyleOverride != nil {
connStyle = *node.ConnectorStyleOverride
}
if isFocused && node.ConnectorStyleOverride == nil {
if node.IsCurrent && node.ShowCurrentLabel {
connStyle = ConnectorCurrentStyle
} else if isMerged {
connStyle = ConnectorMergedStyle
} else if isQueued {
connStyle = ConnectorQueuedStyle
} else {
connStyle = ConnectorFocusedStyle
}
}
return connector, connStyle
}
// StatusIcon returns the appropriate status icon for a branch.
func StatusIcon(node BranchNodeData) string {
if node.Ref.IsMerged() {
return mergedIconStyle.Render(mergedGlyph)
}
if node.Ref.IsQueued() {
return queuedIconStyle.Render(queuedGlyph)
}
if !node.IsLinear {
return warningIconStyle.Render(warningGlyph)
}
if node.PR != nil && node.PR.Number != 0 {
return openIconStyle.Render(openGlyph)
}
return ""
}
// RenderNode renders a single branch node with all its sub-sections.
// annotation is optional — pass nil for plain view, or a NodeAnnotation to add a badge.
func RenderNode(b *strings.Builder, node BranchNodeData, isFocused bool, width int, annotation *NodeAnnotation) {
connector, connStyle := ResolveConnectorStyle(node, isFocused)
if node.PR != nil {
RenderPRHeader(b, node, isFocused, connStyle, annotation)
RenderBranchLine(b, node, connector, connStyle, nil) // annotation already on PR line
} else {
RenderBranchHeader(b, node, isFocused, connStyle, annotation)
}
if len(node.FilesChanged) > 0 {
RenderFiles(b, node, connector, connStyle, width)
}
if len(node.Commits) > 0 {
RenderCommits(b, node, connector, connStyle, width)
}
// Connector/spacer
b.WriteString(connStyle.Render(connector))
b.WriteString("\n")
}
// RenderPRHeader renders the top line with PR info: bullet + status icon + PR# + state + optional annotation.
func RenderPRHeader(b *strings.Builder, node BranchNodeData, isFocused bool, connStyle lipgloss.Style, annotation *NodeAnnotation) {
bullet := "├"
if isFocused {
bullet = "▶"
}
b.WriteString(connStyle.Render(bullet + " "))
icon := StatusIcon(node)
if icon != "" {
b.WriteString(icon + " ")
}
pr := node.PR
prLabel := fmt.Sprintf("#%d", pr.Number)
stateLabel := ""
style := PROpenStyle
switch {
case pr.Merged:
stateLabel = " MERGED"
style = PRMergedStyle
case pr.IsQueued:
stateLabel = " QUEUED"
style = PRQueuedStyle
case pr.State == "CLOSED":
stateLabel = " CLOSED"
style = PRClosedStyle
case pr.IsDraft:
stateLabel = " DRAFT"
style = PRDraftStyle
default:
stateLabel = " OPEN"
}
b.WriteString(PRLinkStyle.Render(prLabel) + style.Render(stateLabel))
if annotation != nil {
b.WriteString(" ")
b.WriteString(annotation.Style.Render(annotation.Text))
}
b.WriteString("\n")
}
// RenderBranchLine renders branch name + diff stats below a PR header.
func RenderBranchLine(b *strings.Builder, node BranchNodeData, connector string, connStyle lipgloss.Style, annotation *NodeAnnotation) {
b.WriteString(connStyle.Render(connector))
b.WriteString(" ")
b.WriteString(renderBranchName(node))
RenderDiffStats(b, node)
if annotation != nil {
b.WriteString(" ")
b.WriteString(annotation.Style.Render(annotation.Text))
}
b.WriteString("\n")
}
// RenderBranchHeader renders header when no PR exists: bullet + icon + branch + stats + annotation.
func RenderBranchHeader(b *strings.Builder, node BranchNodeData, isFocused bool, connStyle lipgloss.Style, annotation *NodeAnnotation) {
bullet := "├"
if isFocused {
bullet = "▶"
}
b.WriteString(connStyle.Render(bullet + " "))
icon := StatusIcon(node)
if icon != "" {
b.WriteString(icon + " ")
}
b.WriteString(renderBranchName(node))
RenderDiffStats(b, node)
if annotation != nil {
b.WriteString(" ")
b.WriteString(annotation.Style.Render(annotation.Text))
}
b.WriteString("\n")
}
// RenderDiffStats appends +N -N diff stats.
func RenderDiffStats(b *strings.Builder, node BranchNodeData) {
if node.Additions > 0 || node.Deletions > 0 {
b.WriteString(" ")
b.WriteString(AdditionsStyle.Render(fmt.Sprintf("+%d", node.Additions)))
b.WriteString(" ")
b.WriteString(DeletionsStyle.Render(fmt.Sprintf("-%d", node.Deletions)))
}
}
// renderBranchName returns the styled branch name string based on node settings.
func renderBranchName(node BranchNodeData) string {
name := node.Ref.Branch
if node.BranchNameStyleOverride != nil {
return node.BranchNameStyleOverride.Render(name)
}
if node.IsCurrent && node.ShowCurrentLabel {
return CurrentBranchStyle.Render(name + " (current)")
}
return NormalBranchStyle.Render(name)
}
// RenderFiles renders the files toggle and optionally expanded file list.
func RenderFiles(b *strings.Builder, node BranchNodeData, connector string, connStyle lipgloss.Style, width int) {
b.WriteString(connStyle.Render(connector))
b.WriteString(" ")
icon := CollapsedIcon
if node.FilesExpanded {
icon = ExpandedIcon
}
fileLabel := "files changed"
if len(node.FilesChanged) == 1 {
fileLabel = "file changed"
}
b.WriteString(CommitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.FilesChanged), fileLabel)))
b.WriteString("\n")
if !node.FilesExpanded {
return
}
for _, f := range node.FilesChanged {
b.WriteString(connStyle.Render(connector))
b.WriteString(" ")
path := f.Path
maxLen := width - 30
if maxLen < 20 {
maxLen = 20
}
if len(path) > maxLen {
path = "…" + path[len(path)-maxLen+1:]
}
b.WriteString(NormalBranchStyle.Render(path))
b.WriteString(" ")
b.WriteString(AdditionsStyle.Render(fmt.Sprintf("+%d", f.Additions)))
b.WriteString(" ")
b.WriteString(DeletionsStyle.Render(fmt.Sprintf("-%d", f.Deletions)))
b.WriteString("\n")
}
}
// RenderCommits renders the commits toggle and optionally expanded commits.
func RenderCommits(b *strings.Builder, node BranchNodeData, connector string, connStyle lipgloss.Style, width int) {
b.WriteString(connStyle.Render(connector))
b.WriteString(" ")
icon := CollapsedIcon
if node.CommitsExpanded {
icon = ExpandedIcon
}
commitLabel := "commits"
if len(node.Commits) == 1 {
commitLabel = "commit"
}
b.WriteString(CommitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.Commits), commitLabel)))
b.WriteString("\n")
if !node.CommitsExpanded {
return
}
for _, c := range node.Commits {
b.WriteString(connStyle.Render(connector))
b.WriteString(" ")
sha := c.SHA
if len(sha) > 7 {
sha = sha[:7]
}
b.WriteString(CommitSHAStyle.Render(sha))
b.WriteString(" ")
subject := c.Subject
maxLen := width - 35
if maxLen < 20 {
maxLen = 20
}
if len(subject) > maxLen {
subject = subject[:maxLen-1] + "…"
}
b.WriteString(CommitSubjectStyle.Render(subject))
b.WriteString(" ")
b.WriteString(CommitTimeStyle.Render(TimeAgo(c.Time)))
b.WriteString("\n")
}
}
// NodeLineCount returns how many rendered lines a node occupies.
func NodeLineCount(node BranchNodeData) int {
lines := 1 // header line
if node.PR != nil {
lines++ // branch + diff stats line below PR header
}
if len(node.FilesChanged) > 0 {
lines++ // files toggle
if node.FilesExpanded {
lines += len(node.FilesChanged)
}
}
if len(node.Commits) > 0 {
lines++ // commits toggle
if node.CommitsExpanded {
lines += len(node.Commits)
}
}
lines++ // connector/spacer
return lines
}
// RenderTrunk renders the trunk line.
func RenderTrunk(b *strings.Builder, trunkBranch string) {
b.WriteString(ConnectorStyle.Render("└ "))
b.WriteString(TrunkStyle.Render(trunkBranch))
b.WriteString("\n")
}
// RenderMergedSeparator renders the merged section separator.
func RenderMergedSeparator(b *strings.Builder) {
b.WriteString(ConnectorStyle.Render("────") + DimStyle.Render(" merged ") + ConnectorStyle.Render("─────") + "\n")
}
// RenderQueuedSeparator renders the queued section separator.
func RenderQueuedSeparator(b *strings.Builder) {
b.WriteString(ConnectorStyle.Render("────") + DimStyle.Render(" queued ") + ConnectorStyle.Render("─────") + "\n")
}
// TimeAgo returns a human-readable time-ago string.
func TimeAgo(t time.Time) string {
d := time.Since(t)
switch {
case d < time.Minute:
secs := int(d.Seconds())
if secs == 1 {
return "1 second ago"
}
return fmt.Sprintf("%d seconds ago", secs)
case d < time.Hour:
mins := int(d.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
case d < 24*time.Hour:
hours := int(d.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
case d < 30*24*time.Hour:
days := int(d.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
default:
months := int(d.Hours() / 24 / 30)
if months <= 1 {
return "1 month ago"
}
return fmt.Sprintf("%d months ago", months)
}
}
// --- Mouse click helpers ---
// ClickResult describes what happened when a node was clicked.
type ClickResult struct {
NodeIndex int // which node was clicked (-1 if none)
ToggleFiles bool // should toggle files expansion
ToggleCommits bool // should toggle commits expansion
OpenURL string // URL to open in browser (empty if none)
}
// HandleClick maps a screen click to a node action.
// nodes is the list of BranchNodeData in display order.
// headerHeight is the number of rows the header occupies (0 when it is hidden).
// scrollOffset is the current scroll position.
// hasSeparators controls whether merged/queued separator lines are accounted for.
func HandleClick(screenX, screenY int, nodes []BranchNodeData, width, height, scrollOffset int, headerHeight int, hasSeparators bool) ClickResult {
yOffset := 0
if headerHeight > 0 {
if screenY < headerHeight {
return ClickResult{NodeIndex: -1}
}
yOffset = headerHeight
}
contentLine := (screenY - yOffset) + scrollOffset
line := 0
prevWasMerged := false
prevWasQueued := false
for i := 0; i < len(nodes); i++ {
if hasSeparators {
isMerged := nodes[i].Ref.IsMerged()
isQueued := nodes[i].Ref.IsQueued()
if isMerged && !prevWasMerged && i > 0 {
line++
} else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 {
line++
}
prevWasMerged = isMerged
prevWasQueued = isQueued
}
nodeStart := line
nodeLines := NodeLineCount(nodes[i])
if contentLine >= nodeStart && contentLine < nodeStart+nodeLines {
result := ClickResult{NodeIndex: i}
// Click on PR header line — check if clicking the PR number
if contentLine == nodeStart && nodes[i].PR != nil && nodes[i].PR.URL != "" {
prStartX, prEndX := PRLabelColumns(nodes[i])
if screenX >= prStartX && screenX < prEndX {
result.OpenURL = nodes[i].PR.URL
}
}
// Click on files toggle line
if len(nodes[i].FilesChanged) > 0 {
if contentLine == nodeStart+FilesToggleLineOffset(nodes[i]) {
result.ToggleFiles = true
}
}
// Click on commits toggle line
if len(nodes[i].Commits) > 0 {
if contentLine == nodeStart+CommitToggleLineOffset(nodes[i]) {
result.ToggleCommits = true
}
}
return result
}
line += nodeLines
}
return ClickResult{NodeIndex: -1}
}
// FilesToggleLineOffset returns the line offset from node start to the files toggle.
func FilesToggleLineOffset(node BranchNodeData) int {
offset := 1 // after header
if node.PR != nil {
offset++
}
return offset
}
// CommitToggleLineOffset returns the line offset from node start to the commits toggle.
func CommitToggleLineOffset(node BranchNodeData) int {
offset := 1
if node.PR != nil {
offset++
}
if len(node.FilesChanged) > 0 {
offset++
if node.FilesExpanded {
offset += len(node.FilesChanged)
}
}
return offset
}
// PRLabelColumns returns the start and end X columns of the PR number label.
func PRLabelColumns(node BranchNodeData) (int, int) {
col := 2 // bullet + space
icon := StatusIcon(node)
if icon != "" {
col += 2
}
prLabel := fmt.Sprintf("#%d", node.PR.Number)
return col, col + len(prLabel)
}
// OpenBrowserInBackground launches the system browser for the given URL.
func OpenBrowserInBackground(url string) {
cmd := BrowserCmd(url)
_ = cmd.Start()
}
// BrowserCmd returns an exec.Cmd to open a URL in the default browser.
func BrowserCmd(url string) *exec.Cmd {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url)
case "windows":
return exec.Command("cmd", "/c", "start", url)
default:
return exec.Command("xdg-open", url)
}
}