Skip to content

Themes: dark/light/system colour themes - #6

Merged
jinyeow merged 18 commits into
mainfrom
feature/theming
Jul 11, 2026
Merged

jinyeow merged 18 commits into
mainfrom
feature/theming

Conversation

@jinyeow

@jinyeow jinyeow commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Makes cobalt's colours themeable. Stacked on PR #4 (base feature/diff-view-enhancements); retarget to main once #4 merges.

What

  • theme = "dark" | "light" | "system" in config.toml (default dark = the original look), switchable live with :theme dark|light|system.
  • Hybrid (ADR 0019): Terminal.Gui's built-in themes colour app chrome + syntax roles; a cobalt DiffPalette colours the diff tints, so both recolour together.
  • dark→TG Default (byte-identical to today), light→TG Light.
  • system follows the OS light/dark setting live on Windows (registry AppsUseLightTheme + RegNotifyChangeKeyValue watch); no-op fallback on macOS/Linux (use :theme manually).

How it was built

Contract-lock → 4 parallel tracks (config parse · resolver+presets+command · Windows OS monitor · TG integration) → review-fix-loop. 708 tests, 0 warnings.

Notes

  • Terminal.Gui 2.4.16's ConfigurationManager is [Obsolete] but is the only runtime-theming API (MEC replacement can't own theme data yet — TG #5416); the suppression is confined to ThemeService.cs. Enable is scoped to LibraryResources only, so user/app TG config is never read; the library's non-theme settings were verified to equal TG's hardcoded defaults (no keybinding/behaviour change).
  • Codex review: clean (verified no CM regression). Fable adversarial pass pending (hit a session limit).

Needs manual/PTY UAT (ADR 0010 — colours aren't unit-testable)

  1. theme = dark / empty config looks byte-identical to before (chrome + keybindings).
  2. Dark & light diff tints + chrome render correctly.
  3. Live :theme dark|light|system repaint (with the diff review open) and OS light/dark follow in a real terminal.

Base automatically changed from feature/diff-view-enhancements to main July 9, 2026 04:15
@jinyeow
jinyeow force-pushed the feature/theming branch from 4013cf7 to da647ea Compare July 9, 2026 04:52
Comment thread tests/Cobalt.Tui.Tests/App/ShellThemeFollowTests.cs Dismissed
Comment thread tests/Cobalt.Tui.Tests/Theming/OsThemeMonitorTests.cs Dismissed
Comment thread src/Cobalt.Tui/Theming/WindowsOsThemeMonitor.cs Fixed
Comment thread src/Cobalt.Tui/Theming/WindowsOsThemeMonitor.cs Dismissed
claude added 6 commits July 10, 2026 14:53
In TOML a `theme = ...` line written after a `[contexts.*]` header binds to
that context table. The context parser only read organization/project/pr_scope,
so a `theme` appended to the end of a config (the natural place to add it) was
silently ignored — leaving the app on the default theme with no error. Fail
loudly with a message pointing to the top-level fix.

Adversarial-review finding on the theming feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRcdzgES1cBLLxEEW4jw1d
Adversarial-review low-severity nits on the theming feature:

- ThemeResolver.Resolve now maps ThemeChoice exhaustively and throws on an
  unknown value instead of silently defaulting to dark (catches drift if a
  fourth choice is ever added).
- `:theme system` re-issued while already on system now re-resolves against
  the live OS and repaints (a real refresh, e.g. to recover if the OS-follow
  watcher stopped); re-issuing a fixed theme stays a no-op.
- The config `theme` value is trimmed, so `theme = " light "` parses instead
  of hard-failing startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRcdzgES1cBLLxEEW4jw1d
Adversarial-review findings C1/C2 (+ C3/C5) on the Windows OS-follow seam.
The watch loop's control flow is extracted into ThemeWatchLoop behind an
IThemeWatchOps port, so the ordering and retry policy are now deterministically
unit-testable on any OS; only the thin RegNotifyChangeKeyValue glue stays a
native seam.

- C1 (arm-before-read): the change notification is armed *before* the value is
  sampled, so a registry write racing the read fires the already-armed
  notification and is picked up next iteration instead of being lost.
- C2 (retry, don't park): arm failures back off (capped exponential) and retry
  instead of parking the watcher forever, so a transient failure or a
  momentarily-absent key self-heals. Kept silent by design — a blip that
  auto-recovers shouldn't nag the user.
- C3: Start() is a no-op after Dispose (guards Start-after-Dispose).
- C5: onChanged fires only on a real theme change, so a spurious wake (any
  write under the watched key, e.g. an accent-colour change) no longer forces a
  redundant repaint.

Behaviour is verifiable end-to-end only on a real Windows terminal (the native
seam is un-unit-tested by design); the extracted policy has full unit coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRcdzgES1cBLLxEEW4jw1d
…atchLoop

CS1674: `using var ops = new RegistryWatchOps(...)` requires the type to
implement IDisposable; a Dispose method alone isn't enough for a class. The
port owns native handles, so make IThemeWatchOps : IDisposable and give the
test fake a no-op Dispose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRcdzgES1cBLLxEEW4jw1d
Avoid a collection-expression first argument, which can be ambiguous between
xUnit's Assert.Equal(T,T) and Assert.Equal(IEnumerable<T>,IEnumerable<T>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRcdzgES1cBLLxEEW4jw1d
Comment thread src/Cobalt.Tui/Theming/WindowsOsThemeMonitor.cs Dismissed
jinyeow added 3 commits July 11, 2026 15:08
Add the tests the deep-review found missing on introduced theming behaviour:

- DiffPaletteTests pins DiffPalette.Dark to its literal pre-theming colours
  (the existing self-comparisons were tautological and couldn't catch an edit).
- ShellThemeFollowTests covers the `:theme` command's shell-side apply path
  (OnThemeChangeRequested) end-to-end; previously only the VM event-raise and
  the OS-follow path were tested.
- ShellViewModelTests now asserts the "re-synced to OS" message text on a
  repeat `:theme system`.

No production code change.
The first version drove `:theme light` (a fixed theme), so ThemeResolver
ignored the OS value and the test passed even if OnThemeChangeRequested's
`_themeMonitor?.Current ?? OsTheme.Unknown` read regressed. Switch to
`:theme system` with a light monitor: resolution now depends on the monitor
read, so a broken fallback drops to the dark palette and fails the test
(verified RED by forcing os = Unknown).
Address the low-severity findings from the multi-model review:

- WindowsOsThemeMonitor.RegistryWatchOps.Dispose: close the registry key
  before the notification event, so a still-pending one-shot RegNotify flushes
  before its event handle is freed (never signals a reused handle on teardown).
- DiffPalette.Light: move the light diff palette here next to DiffPalette.Dark
  (verbatim values); ThemeResolver now references it and no longer owns colour
  data. Drops ThemeResolver's now-unused Terminal.Gui.Drawing import.
- CobaltShell.ApplyPreset: extract the Apply+LayoutAndDraw pair shared by the
  :theme command and the OS-follow path so the two can't drift.
- ShellViewModelTests: assert the "already <theme>" no-op and "unknown theme"
  error message text (previously only level/event-count were checked).

No behaviour change.
@jinyeow
jinyeow merged commit 7a8e7d0 into main Jul 11, 2026
5 checks passed
@jinyeow
jinyeow deleted the feature/theming branch July 11, 2026 07:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants