git/orchard/main
tui/internal/ui/palette.go
167 lines6 KB last change raw · history
// Package ui is the Bubble Tea front end.
//
// Colour comes from the terminal's own 16-slot palette, exactly as the Python
// renderer does it: we name a slot, the terminal decides what it looks like. The
// profile is pinned to ANSI (not truecolour) so Lip Gloss emits colour indices
// rather than resolved RGB, which is what makes the theme follow the terminal.
package ui

import (
	"os"
	"strings"

	"github.com/charmbracelet/lipgloss"
	"github.com/muesli/termenv"
)

// renderer pins the colour profile to ANSI-16.
//
// This has to be an explicit renderer rather than lipgloss.SetColorProfile in an
// init(): package-level variables are initialised *before* init() runs, so the
// styles would be built against the auto-detected profile and then have the
// profile changed underneath them. Lip Gloss reacts to that mismatch by emitting
// an escape pair around every single character.
var renderer = func() *lipgloss.Renderer {
	r := lipgloss.NewRenderer(os.Stdout)
	if os.Getenv("UPDATER_TRUECOLOR") != "" {
		return r // let it detect; useful for capture and debugging
	}
	// Forced rather than detected: piping the frame (--print) is not a TTY, and
	// without this the styling vanishes and the layout cannot be inspected at
	// all. 16 colours only -- anything richer would hardcode a theme we do not
	// own, and the whole point is that the terminal decides.
	r.SetColorProfile(termenv.ANSI)
	return r
}()

// The ANSI-16 slot names, matching scripts/theme.py on the Python side.
const (
	slotBlack         = "0"
	slotRed           = "1"
	slotGreen         = "2"
	slotYellow        = "3"
	slotBlue          = "4"
	slotMagenta       = "5"
	slotCyan          = "6"
	slotBrightBlack   = "8"
	slotBrightRed     = "9"
	slotBrightGreen   = "10"
	slotBrightYellow  = "11"
	slotBrightMagenta = "13"
	slotBrightCyan    = "14"
)

// Roles. Bold and faint do real work here, not decoration: a theme is free to
// make two slots indistinguishable, so the attribute is a second channel.
var (
	Text   = renderer.NewStyle()
	Muted  = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack))
	Rule   = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack)).Faint(true)
	Accent = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightGreen)).Bold(true)
	OK     = renderer.NewStyle().Foreground(lipgloss.Color(slotGreen)).Faint(true)
	// No underline anywhere. lipgloss emits an SGR sequence *per rune* to draw
	// one under the pinned ANSI profile: measured, 200 characters cost 2800
	// bytes underlined against 209 plain, thirteen times over, on every
	// warning line and every security row. The second channel survives as a
	// slot difference instead, which is free -- Alert is bright red against
	// Err's red, Warn is bold against OK's faint -- so a reader whose theme
	// puts two slots close together still has something to go on. One place
	// in the build view had already worked around the cost by using Err where
	// it meant Warn; that workaround is gone.
	Warn     = renderer.NewStyle().Foreground(lipgloss.Color(slotYellow)).Bold(true)
	Alert    = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightRed)).Bold(true)
	Err      = renderer.NewStyle().Foreground(lipgloss.Color(slotRed)).Bold(true)
	Outgoing = renderer.NewStyle().Foreground(lipgloss.Color(slotRed))
	Incoming = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightGreen)).Bold(true)
	Ver      = renderer.NewStyle().Foreground(lipgloss.Color(slotCyan))

	// Syntax roles for the shell listings. They are ordinary theme slots, so
	// the highlighting follows the terminal rather than overriding it.
	SynVar    = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightMagenta)).Bold(true)
	SynPath   = renderer.NewStyle().Foreground(lipgloss.Color(slotCyan))
	SynURL    = renderer.NewStyle().Foreground(lipgloss.Color(slotBlue)).Bold(true)
	SynString = renderer.NewStyle().Foreground(lipgloss.Color(slotGreen))
	SynFlag   = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightCyan))
	SynOp     = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack))
)

// Severity maps the audit's own vocabulary onto a role.
func Severity(severity string) lipgloss.Style {
	switch severity {
	case "BLOCK":
		return Alert
	case "LOOK":
		return Warn
	default:
		return Muted
	}
}

// StylesByRole maps a semantic role name to its style, for the places that
// carry a role as data rather than choosing a style at the call site.
func StylesByRole(role string) lipgloss.Style {
	switch role {
	case "ok":
		return OK
	case "warn":
		return Warn
	case "alert":
		return Alert
	case "err":
		return Err
	case "accent":
		return Accent
	case "ver":
		return Ver
	case "text":
		return Text
	default:
		return Muted
	}
}

// Pane chrome.
var (
	Title    = renderer.NewStyle().Bold(true)
	Selected = renderer.NewStyle().Background(lipgloss.Color(slotBrightBlack)).
			Foreground(lipgloss.Color(slotBrightCyan)).Bold(true)
	Key  = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightGreen))
	Desc = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack))
)

// HRule renders a horizontal separator of the given width.
func HRule(width int) string {
	if width <= 0 {
		return ""
	}
	buf := make([]rune, width)
	for i := range buf {
		buf[i] = '─'
	}
	return Rule.Render(string(buf))
}

// Box is the modal chrome: rounded, padded, and quiet. Matches the other tools
// in this tree so the family resemblance is obvious.
var Box = renderer.NewStyle().
	Border(lipgloss.RoundedBorder()).
	BorderForeground(lipgloss.Color(slotBrightBlack)).
	Padding(1, 2)

// TabActive / TabIdle are the modal tab states.
var (
	TabActive = renderer.NewStyle().
			Background(lipgloss.Color(slotBrightGreen)).
			Foreground(lipgloss.Color(slotBlack)).
			Bold(true)
	TabIdle = renderer.NewStyle().Foreground(lipgloss.Color(slotBrightBlack))
)

// KeyField renders a keybinding's key right-aligned in a fixed field.
func KeyField(key string, width int) string {
	if len(key) > width {
		width = len(key)
	}
	return Accent.Render(strings.Repeat(" ", width-len(key)) + key)
}