Skip to content

Let embedders register built-in themes via RegisterBuiltinThemes#3182

Draft
EronWright wants to merge 2 commits into
docker:mainfrom
EronWright:register-builtin-themes
Draft

Let embedders register built-in themes via RegisterBuiltinThemes#3182
EronWright wants to merge 2 commits into
docker:mainfrom
EronWright:register-builtin-themes

Conversation

@EronWright

@EronWright EronWright commented Jun 20, 2026

Copy link
Copy Markdown

What

Adds styles.RegisterBuiltinThemes(fsys fs.FS) error, which lets an embedder
contribute additional built-in themes from a filesystem (typically an
embed.FS of themes/*.yaml).

Why

Themes are already partial overrides on top of DefaultTheme()loadBuiltinTheme
and loadThemeFrom both mergeTheme(DefaultTheme(), &override), which is why a
bundled theme YAML only declares the colors it changes. But the built-in set is a
single compile-time //go:embed themes/*.yaml, which a downstream consumer can't
extend.

A downstream CLI/TUI built on cagent wants its own theme(s) to be first-class
without forking. RegisterBuiltinThemes generalizes the bundled-theme source
from one hardcoded embed.FS to a list, so registered themes flow through the
exact list/load/merge pipeline cagent already uses. They are treated like
cagent's own built-ins with no changes to the picker or handlers:

  • appear in ListThemeRefs (and the /theme picker),
  • resolve via LoadTheme / ApplyThemeRef,
  • classify as built-in (IsBuiltinTheme),
  • persist as the user's selection.

Registered sources take precedence over the bundled themes, so an embedder can
override a built-in and, in particular, mask default with their own. A
masked default is still a partial override merged onto cagent's pristine
DefaultTheme() base, so it only needs to specify the fields it changes.
DefaultTheme() itself remains the canonical merge base.

Embedder usage

import (
    "embed"

    "github.com/docker/docker-agent/pkg/tui/styles"
)

//go:embed themes/*.yaml
var brandThemes embed.FS

// At startup, before applying any persisted theme:
if err := styles.RegisterBuiltinThemes(brandThemes); err != nil {
    return err
}

themes/brand.yaml is a partial override; unset fields fall back to the default
theme:

# themes/brand.yaml
name: Brand
colors:
  accent: "#FF6A00"
  brand: "#FF6A00"
  background: "#1A0F0A"
# text_primary, success, error, chroma.*, etc. are inherited from the default.

To make the brand theme the launch default, ship it as themes/default.yaml
instead (or in addition) — it masks cagent's default while inheriting everything
it doesn't set.

Notes / semantics

  • The source must contain themes/<ref>.yaml (or .yml). An embedder that
    embeds at themes/*.yaml passes its embed.FS directly; otherwise fs.Sub to
    re-root at the themes dir.
  • A registered ref that collides with a bundled name overrides it; the bundled
    name is still listed once.
  • RegisterBuiltinThemes validates the source eagerly (nil / missing themes
    dir) so problems surface at registration, not at picker time.

Tests

Tests live in theme_test.go (package styles), consistent with the existing
theme tests, and exercise registration through the public functions an embedder
uses, with a small unexported reset for isolation:

  • TestRegisterBuiltinThemes_EmbedderFlow — registers from a real embed.FS and
    runs the full register → list → IsBuiltinThemeApplyThemeRef loop.
  • TestRegisterBuiltinThemes — listed, classified built-in, loads merged onto
    the default (overridden field changes, unset fields inherited).
  • TestRegisterBuiltinThemes_MultipleSources — aggregates across more than one
    source; first-registered wins a collision between two registered sources.
  • TestRegisterBuiltinThemes_OverridesBuiltin — a registered name overrides the
    bundled theme.
  • TestRegisterBuiltinThemes_MasksDefault — masks default, merged onto the
    pristine DefaultTheme() base.
  • TestRegisterBuiltinThemes_Errors — nil / missing-themes-dir rejected eagerly.

Full pkg/tui/styles suite passes under -race; go vet and gofmt clean.

@aheritier aheritier added area/tui For features/issues/fixes related to the TUI kind/feature New feature labels Jun 20, 2026
@EronWright EronWright force-pushed the register-builtin-themes branch from fd2fc6f to 6c7b2f9 Compare June 20, 2026 01:21
Add styles.RegisterBuiltinThemes(fs.FS), which adds an embedder-provided
theme source (e.g. an embed.FS of themes/*.yaml) to the built-in theme
set. Registered themes flow through the exact list/load/merge-onto-default
pipeline cagent already uses for its bundled themes, so they are
first-class: they appear in ListThemeRefs and the /theme picker, resolve
via LoadTheme/ApplyThemeRef, and persist as the user's selection — with no
changes to the picker or handlers.

Registered sources take precedence over the bundled themes, so an embedder
can override a built-in and, in particular, mask "default" with their own
(still merged onto cagent's pristine DefaultTheme() base). This lets a
downstream CLI/TUI ship its own default and theme set without forking,
while cagent core stays domain-agnostic.
@EronWright EronWright force-pushed the register-builtin-themes branch from 6c7b2f9 to 9a14205 Compare June 20, 2026 01:50
@Sayt-0 Sayt-0 self-assigned this Jun 20, 2026
- lint: use errors.New for the static nil-fs message (perfsprint) and
  require.Error for the error assertions (testifylint)
- docs: the godoc stated the opposite of the implemented behavior.
  Registered sources take precedence over bundled themes and can mask
  "default", which is what the PR description and the tests already
  assert. Rewrite the godoc to match.
- bug: RegisterBuiltinThemes invalidated only the refs-list cache, not
  the parsed-theme cache. Built-in cache entries are treated as
  permanently valid, so an override of a built-in (or "default") that
  was loaded before registration was silently ignored. Invalidate the
  theme cache on registration and add a regression test.
@Sayt-0

Sayt-0 commented Jun 20, 2026

Copy link
Copy Markdown
Member

Pushed a few fixes (7cad5565):

  • Lint: errors.New + require.Error to get CI green (perfsprint / testifylint).
  • Docs: the godoc said default is ignored and collisions keep the bundled theme, but registered sources actually take precedence and can mask default (as the PR description and the override/mask tests assert). Rewrote it to match.
  • Bug: registration only invalidated the refs-list cache, not the parsed-theme cache. A built-in (or default) loaded before RegisterBuiltinThemes stayed cached, so the override was silently dropped. Added InvalidateThemeCache("") + a regression test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/tui For features/issues/fixes related to the TUI kind/feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants