// 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) }