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