Documentation
¶
Overview ¶
Package termtheme is the shared theming engine for terminal TUIs. It owns the cross-compat-critical, palette-independent layer that sibling apps (passage, ssherpa, and future TUIs) had each been carrying a near-identical copy of: the semantic role registry, the theme.conf grammar, the style-spec interpreter, the SGR + grapheme-cluster render helpers, and a portable, import/export friendly .theme file format.
What termtheme deliberately does NOT own is each app's builtin palettes (TerminalTheme/VividTheme genuinely diverge per app) or its environment and config-path resolution. The palette is always supplied by the caller — see ThemeConfig.Resolve — so unifying the engine never silently restyles an app.
Index ¶
- Constants
- func Apply(noColor bool, code string, value string) string
- func EnvMap(env []string) map[string]string
- func EnvNoColor(app string, env []string, optNoColor bool) bool
- func EnvTruthy(value string) bool
- func ExpandPath(path string) string
- func FormatThemeConfig(cfg ThemeConfig, opts ConfigOptions) []byte
- func Marshal(cfg ThemeConfig, base Theme, opts MarshalOptions) []byte
- func PadRight(value string, width int) string
- func ParseStyleSpec(value string) (string, error)
- func ResolveThemeFile(app, file string, env []string, skipDefault bool) (string, bool)
- func Sanitize(value string) string
- func Strip(value string) string
- func Truncate(value string, width int) string
- func TruncateWith(value string, width int, marker string) string
- func Unmarshal(data []byte) (ThemeConfig, Meta, error)
- func VisibleWidth(value string) int
- type ConfigOptions
- type MarshalOptions
- type Meta
- type Role
- type Theme
- type ThemeConfig
Constants ¶
const FormatVersion = 1
FormatVersion is the current portable .theme format version, written as the `format` key and the `# termtheme vN` header. Readers parse any version best-effort: a higher version warns but never hard-fails (forward compat).
Variables ¶
This section is empty.
Functions ¶
func Apply ¶
Apply wraps value in the SGR sequence for code (the parameters between CSI and 'm'), appending a reset. It is a no-op when NoColor is set, the code is empty, or the value is empty.
func EnvMap ¶ added in v0.2.0
EnvMap turns an environment slice ("KEY=value") into a lookup map. A nil slice falls back to the current process environment.
func EnvNoColor ¶ added in v0.2.0
EnvNoColor reports whether color should be disabled for an app: an explicit option, the app's own "<APP>_NO_COLOR" (truthy), or the cross-tool NO_COLOR (any non-empty value) all force it. env may be nil.
func EnvTruthy ¶ added in v0.2.0
EnvTruthy reports whether an environment value reads as "on". Empty and the usual falsy spellings are false; anything else is true.
func ExpandPath ¶ added in v0.2.0
ExpandPath expands a leading "~" or "~/" to the user's home directory.
func FormatThemeConfig ¶
func FormatThemeConfig(cfg ThemeConfig, opts ConfigOptions) []byte
FormatThemeConfig serializes a ThemeConfig to the live theme.conf grammar: an optional header comment block, a `theme = <base>` line for a non-default base, and one line per role that has an explicit spec (roles left to inherit the base are omitted to keep the file lean). This is the writer an app's theme editor and import command use to persist the live config.
func Marshal ¶
func Marshal(cfg ThemeConfig, base Theme, opts MarshalOptions) []byte
Marshal serializes a theme to the portable .theme format: a versioned header, a `format` key, an optional `theme = <base>` line, then a full inline dump of every role's effective spec — so the receiver never has to reconstruct the producer's base palette. base is the producer's builtin palette the config was resolved against; roles the config did not override are dumped with base's effective code.
The output is byte-droppable straight into an app's live theme.conf: the `# termtheme` / `# source` lines are comments, `format` is tolerated-and-warned by any binary that predates it, and `theme`/role lines are understood by all.
func PadRight ¶
PadRight pads value with spaces to width visible cells. It is escape-aware and never truncates (a value already wider than width is returned unchanged).
func ParseStyleSpec ¶
ParseStyleSpec converts a human style spec into a normalized SGR parameter string (the bytes between CSI and 'm'). It accepts:
- the empty spec, "none", or "plain" -> "" (no styling / inherit)
- a raw SGR string -> normalized verbatim ("1;31")
- space/comma/'+'-separated tokens -> style + color names, e.g. "bold red", "bold cyan, underline", "bright-white reverse"
Color tokens may be prefixed "fg-"/"bg-" to force fore/background. An unrecognized token is a hard error so a typo in a config never silently renders as plain.
func ResolveThemeFile ¶ added in v0.2.0
ResolveThemeFile picks an app's theme-config path. Precedence: an explicit file argument, then "<APP>_THEME_FILE", then (unless skipDefault) the default "<configDir>/<app>/theme.conf". The bool reports whether the path was explicitly requested, so callers can treat a missing explicit file as an error while tolerating a missing default. env may be nil.
func Sanitize ¶
Sanitize neutralizes untrusted text for terminal display: it strips escape sequences like Strip and additionally drops raw control characters — C0 (except tab), DEL, and the C1 range U+0080–U+009F, which xterm-class terminals treat as escape introducers (U+009B is CSI) and which Strip alone passes through. Use it on any string a remote host or an imported theme may have influenced.
func Strip ¶
Strip removes ANSI escape sequences, leaving the printable text (including any raw control characters, which Sanitize additionally removes).
func Truncate ¶
Truncate shortens value to at most width visible cells, marking cut text with a trailing "~". See TruncateWith.
func TruncateWith ¶
TruncateWith is Truncate with a caller-chosen cut marker (which may be empty or multi-cell). The marker's own cell width is reserved from the budget, so the result never exceeds width cells. It is escape-aware: escape sequences do not count toward the width and are never split, whole grapheme clusters (so an emoji-with-variation-selector is never split) are the unit of cutting, and if the kept portion leaves SGR styling active a reset is appended so styling cannot leak past the truncation.
func Unmarshal ¶
func Unmarshal(data []byte) (ThemeConfig, Meta, error)
Unmarshal parses a portable .theme file into a ThemeConfig plus its Meta. It understands the portable additions (the `format` key and the header comments) and otherwise delegates to ParseThemeConfig, so the role/base/unknown-key semantics are identical to a plain theme.conf. A format newer than FormatVersion is read best-effort with a warning rather than rejected.
func VisibleWidth ¶
VisibleWidth reports the terminal cell width of value, ignoring ANSI escape sequences and accounting for wide runes (East Asian, emoji) and zero-width runes (combining marks, controls). It keeps a hand-rolled escape-skip so the audited handling of malformed sequences is preserved, and measures each printable run with ansi.StringWidth — the exact cell measure the Bubble Tea v2 renderer paints with. The measure is locale-independent (honors only RUNEWIDTH_EASTASIAN, never LANG), so box-drawing chrome can never desync from the painter in a CJK locale.
Types ¶
type ConfigOptions ¶
type ConfigOptions struct {
// Header lines are written as leading "# " comments (no leading hash).
Header []string
// Roles is the emit order; when nil, Roles() is used. Roles the config
// carries outside this list are appended (passthrough), sorted.
Roles []Role
}
ConfigOptions parameterizes serialization to the live theme.conf grammar (the import-write path), as opposed to the portable export of Marshal.
type MarshalOptions ¶
type MarshalOptions struct {
App string // producer app name, recorded in the header (informational)
AppVersion string // producer version, recorded in the header (informational)
// Roles is the set of roles the producing app renders, in display order.
// They are dumped first; any other role the config carries is re-emitted
// after them (cross-app passthrough), so a foreign role survives a round
// trip through an app that does not render it. When nil, Roles() is used.
Roles []Role
}
MarshalOptions parameterizes a portable export.
type Meta ¶
type Meta struct {
Format int // the file's declared format version (0 if absent)
App string // producing app, from `# source = <app> <version>`
AppVersion string
// Warnings carries forward-compat and tolerate-and-warn diagnostics
// (unknown roles, a newer-than-supported format).
Warnings []string
}
Meta is the header/version information recovered from a portable file.
type Role ¶
type Role string
Role is a semantic styling slot. Roles name what a span of text MEANS (a danger, a selection, a border) rather than a concrete color, so a theme can be retargeted across terminals and apps. The set below is the universal superset shared across apps; an individual app renders some subset of it (its "app roles") but may carry, preserve, and re-export the rest verbatim.
const ( RoleTitle Role = "title" RolePrimary Role = "primary" RoleSecondary Role = "secondary" RoleAccent Role = "accent" RoleMuted Role = "muted" RoleSubtle Role = "subtle" RoleForeground Role = "foreground" RoleSelected Role = "selected" RoleSelectedBar Role = "selected_bar" RoleBorder Role = "border" RoleSuccess Role = "success" RoleWarning Role = "warning" RoleDanger Role = "danger" RoleInfo Role = "info" RoleSearch Role = "search" RolePill Role = "pill" )
func RoleForKey ¶
RoleForKey resolves a config key to its role. The key is normalized first, so "Selected-Bar", "selection_bar", and "bar" all resolve to RoleSelectedBar.
type Theme ¶
Theme is a concrete, fully-resolved palette: a role->SGR-code map plus a NoColor switch. termtheme ships no Theme values of its own — the builtin palettes live in each app because they genuinely diverge — so a Theme is produced either by an app constructing one directly or by ThemeConfig.Resolve overlaying a config onto an app-supplied base.
func (Theme) Clone ¶
Clone returns a deep copy so mutating the result never aliases the original's Codes map.
func (Theme) Style ¶
Style wraps value in the SGR code for role. A role with no code (absent, or explicitly cleared to inherit) renders plain. NoColor renders everything plain. Because Style is a direct lookup with no implicit palette fill, the caller is responsible for handing Style a Theme whose Codes are already complete — which ThemeConfig.Resolve guarantees.
func (Theme) WithNoColor ¶
WithNoColor returns a copy of the theme with NoColor set.
type ThemeConfig ¶
type ThemeConfig struct {
BaseName string
Codes map[Role]string
Specs map[Role]string
// Warnings collects non-fatal diagnostics, such as role keys this binary
// does not know about. Unknown keys are tolerated so a theme file written
// by a newer app never hard-fails an older binary; callers decide where to
// surface them.
Warnings []string
}
ThemeConfig is the parsed contents of a theme file: an optional base palette name, the per-role overrides as both normalized SGR Codes and the original human Specs (preserved so a file can be re-edited and re-serialized without loss), and any non-fatal parse Warnings.
Codes/Specs carry EVERY recognized universal role the file mentions, not just the ones the host app renders. A role an app does not paint (a foreign role from a sibling app) is still parked here so it survives a round-trip — see Marshal's passthrough handling.
func ParseThemeConfig ¶
func ParseThemeConfig(data []byte) (ThemeConfig, error)
ParseThemeConfig parses the theme.conf grammar: '#' line comments, blank lines, and "key = value" / "key : value" assignments. The keys "theme"/"base" set the base palette name; every other key must resolve to a universal role (via RoleForKey) whose value is a style spec. Unknown role keys are collected as Warnings and skipped (forward compatibility); a malformed line or an invalid spec for a KNOWN role is a hard error.
func (ThemeConfig) Resolve ¶
func (cfg ThemeConfig) Resolve(base Theme) Theme
Resolve overlays the config's role codes onto base and returns a complete theme. base is the caller's chosen builtin palette (terminal/vivid/…); termtheme intentionally ships no palettes, so the base is always supplied by the host app — this is the seam that keeps per-app palette divergence intact.
Roles the config does not set keep base's code (fail-open, never errors). Roles the config carries that are absent from base — including foreign roles an app does not itself render — are still included, so a resolved theme can be re-serialized without dropping them.