termtheme

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 28, 2026 License: MIT Imports: 8 Imported by: 0

README

termtheme

Shared Go theming engine for terminal TUIs: semantic SGR roles plus a portable, import/export-friendly .theme format.

termtheme is the cross-compat core that sibling TUIs (e.g. passage and ssherpa) had each been carrying a near-identical copy of. It owns the parts that must agree for themes to interchange — the role registry, the theme.conf grammar, the style-spec interpreter, the SGR + grapheme-cluster render helpers, the portable .theme file, and the pure, app-parameterized environment / config-path helpers (EnvMap, ExpandPath, EnvTruthy, ResolveThemeFile, EnvNoColor) — while leaving each app its own builtin palettes and its fail-open theme resolution (base selection + overrides + normalization), which genuinely differ from app to app.

go get github.com/0xbenc/termtheme

Requires Go 1.26+. The only dependency is github.com/charmbracelet/x/ansi for cell-accurate width.

Concepts

  • Role — a semantic styling slot (title, primary, danger, border, selected_bar, …). Roles name what text means, not a color, so a theme retargets across terminals and apps. Roles() is the universal superset.
  • Theme — a resolved Role → SGR-code map plus a NoColor switch. theme.Style(role, text) wraps text in that role's SGR sequence.
  • ThemeConfig — the parsed contents of a theme file: an optional base palette name plus per-role overrides (kept as both normalized Codes and human Specs). cfg.Resolve(base) overlays it onto a caller-supplied base palette.
  • Palettes stay in the app. termtheme ships no TerminalTheme/VividTheme — those diverge per app, so the base is always passed in via Resolve. This is the seam that lets the engine unify without silently restyling anyone.

Style specs

A role's value is a human spec or a raw SGR string, both via ParseStyleSpec:

red            green   bright-blue   bold   dim   underline   reverse
bold red       bold cyan, underline  bright-white reverse
bg-blue        fg-magenta            1;38;2;96;221;255   (raw SGR)
"" / none / plain   → inherit (no styling)

Unknown role keys in a file are tolerated and collected as warnings (forward compat); an invalid spec for a known role is a hard error.

The portable .theme format

The interchange unit is the existing theme.conf grammar plus a versioned header, a format key, and a full-role dump (every role emitted inline, so the receiver never needs the producer's base palette):

# termtheme v1
# source = passage 0.6.0
format = 1
theme = vivid
title = 1;38;2;96;221;255
primary = 1;38;2;96;221;255
...
selected_bar = 48;2;45;55;72
pill = 1;38;2;25;30;38;48;2;96;221;255
data := termtheme.Marshal(cfg, app.VividPalette(), termtheme.MarshalOptions{
    App: "passage", AppVersion: "0.6.0", Roles: app.AppRoles,
})
cfg, meta, err := termtheme.Unmarshal(data)

Interchange rules:

  • Missing role → the importer fills from its own base palette (Resolve), so it never errors. (Exporters dump every role to avoid relying on this.)
  • Foreign role an app doesn't render (e.g. selected_bar arriving at an app without it) → parsed, parked in Codes/Specs, re-emitted verbatim on the next export. appA → appB → appA is lossless.
  • Unknown future role → tolerated with a warning, skipped.
  • Newer format → read best-effort with a warning, never rejected.

A .theme file is byte-droppable straight into an app's live theme.conf: the header lines are comments and format is a tolerated key.

Status

Extracted from passage/ssherpa per their unified theme-engine feasibility doc. This module is the data layer + portable format; the interactive theme editor and per-app palettes/resolution remain in each app.

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

View Source
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

func Apply(noColor bool, code string, value string) string

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

func EnvMap(env []string) map[string]string

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

func EnvNoColor(app string, env []string, optNoColor bool) bool

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

func EnvTruthy(value string) bool

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

func ExpandPath(path string) string

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

func PadRight(value string, width int) string

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

func ParseStyleSpec(value string) (string, error)

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

func ResolveThemeFile(app, file string, env []string, skipDefault bool) (string, bool)

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

func Sanitize(value string) string

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

func Strip(value string) string

Strip removes ANSI escape sequences, leaving the printable text (including any raw control characters, which Sanitize additionally removes).

func Truncate

func Truncate(value string, width int) string

Truncate shortens value to at most width visible cells, marking cut text with a trailing "~". See TruncateWith.

func TruncateWith

func TruncateWith(value string, width int, marker string) string

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

func VisibleWidth(value string) int

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

func RoleForKey(key string) (Role, bool)

RoleForKey resolves a config key to its role. The key is normalized first, so "Selected-Bar", "selection_bar", and "bar" all resolve to RoleSelectedBar.

func Roles

func Roles() []Role

Roles returns the universal role superset in canonical display order. Adding a role here is the single source of truth: older binaries that predate it hit the tolerate-and-warn path in ParseThemeConfig and never hard-fail.

type Theme

type Theme struct {
	Name    string
	NoColor bool
	Codes   map[Role]string
}

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

func (t Theme) Clone() Theme

Clone returns a deep copy so mutating the result never aliases the original's Codes map.

func (Theme) IsZero

func (t Theme) IsZero() bool

IsZero reports whether the theme carries no information.

func (Theme) Style

func (t Theme) Style(role Role, value string) string

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

func (t Theme) WithNoColor(noColor bool) Theme

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL