literate: create org/render.org, org/theme.org, org/package.org
Follows the literate programming workflow: Overview → Contract → Tests → Implement → Tangle → Test (GREEN) render.org covers render.lisp + render-tests.lisp (component protocol, render dispatch, dirty propagation) theme.org covers theme.lisp + theme-tests.lisp (theme class, presets, color resolution) package.org covers package.lisp (cl-tty.box defpackage)
This commit is contained in:
267
org/theme.org
Normal file
267
org/theme.org
Normal file
@@ -0,0 +1,267 @@
|
||||
#+TITLE: Theme Engine
|
||||
#+STARTUP: content
|
||||
#+FILETAGS: :cl-tty:components:
|
||||
|
||||
* Overview
|
||||
|
||||
The theme engine provides semantic color tokens that decouple visual
|
||||
design from implementation code. Instead of writing ~:bright-yellow~ or
|
||||
~\"#FFD700\"~ everywhere, components use ~:accent~, ~:error~,
|
||||
~:background~ — semantic roles that resolve to concrete hex values
|
||||
through the current theme.
|
||||
|
||||
This means:
|
||||
- Themes are swappable at runtime (default dark/light, nord, etc.)
|
||||
- Components never reference hex values directly
|
||||
- A single ~load-preset~ call changes the entire application's look
|
||||
|
||||
The engine is intentionally simple: a ~theme~ class holding a hash
|
||||
table of role→hex mappings, a set of built-in presets defined via
|
||||
~define-preset~, and ~load-preset~ which populates both the theme
|
||||
and the backend's ~*theme-colors*~ for SGR resolution.
|
||||
|
||||
* Contract
|
||||
|
||||
** Theme class
|
||||
|
||||
- ~(make-theme &key mode)~ — create a theme in ~:dark~ or ~:light~ mode
|
||||
- ~(theme-mode theme)~ — get current mode
|
||||
- ~(theme-color theme role)~ → hex string or nil
|
||||
- ~(setf (theme-color theme role) hex)~ — set a role
|
||||
|
||||
** Presets
|
||||
|
||||
- ~(define-preset name &key dark light)~ — register a preset with
|
||||
dark and light plists of role→hex pairs
|
||||
- ~(load-preset theme preset-name)~ — apply a preset to ~theme~.
|
||||
Also populates ~cl-tty.backend:*theme-colors*~ so the backend can
|
||||
resolve semantic colors to hex at render time.
|
||||
- Unknown presets signal a ~warning~ (not an error).
|
||||
|
||||
** Built-in presets
|
||||
|
||||
- ~:default~ — gold/accent on dark blue-gray
|
||||
- ~:nord~ — cool blue nord palette
|
||||
|
||||
* Tests
|
||||
|
||||
#+BEGIN_SRC lisp :tangle ../src/components/theme-tests.lisp
|
||||
(in-package :cl-tty-box-test)
|
||||
(in-suite box-suite)
|
||||
|
||||
(test theme-create-default
|
||||
"A theme can be created with default mode"
|
||||
(let ((th (make-theme)))
|
||||
(is (typep th 'theme))
|
||||
(is (eql (theme-mode th) :dark))))
|
||||
|
||||
(test theme-create-light
|
||||
"A theme can be created in light mode"
|
||||
(let ((th (make-theme :mode :light)))
|
||||
(is (eql (theme-mode th) :light))))
|
||||
|
||||
(test theme-color-set-and-get
|
||||
"theme-color setf/get works"
|
||||
(let ((th (make-theme)))
|
||||
(setf (theme-color th :primary) "#FFD700")
|
||||
(is (string= (theme-color th :primary) "#FFD700"))))
|
||||
|
||||
(test theme-color-unknown-returns-nil
|
||||
"Unknown roles return nil"
|
||||
(let ((th (make-theme)))
|
||||
(is (null (theme-color th :nonexistent)))))
|
||||
|
||||
(test load-default-dark-preset
|
||||
"Loading the default dark preset populates roles"
|
||||
(let ((th (make-theme :mode :dark)))
|
||||
(load-preset th :default)
|
||||
(is (string= (theme-color th :primary) "#FFD700"))
|
||||
(is (string= (theme-color th :background) "#1A1A2E"))
|
||||
(is (string= (theme-color th :error) "#FF4444"))))
|
||||
|
||||
(test load-default-light-preset
|
||||
"Light variant has different colors"
|
||||
(let ((th (make-theme :mode :light)))
|
||||
(load-preset th :default)
|
||||
(is (string= (theme-color th :primary) "#B8860B"))
|
||||
(is (string= (theme-color th :background) "#F8F9FA"))))
|
||||
|
||||
(test load-nord-preset
|
||||
"Nord preset has different colors than default"
|
||||
(let ((th (make-theme :mode :dark)))
|
||||
(load-preset th :nord)
|
||||
(is (string= (theme-color th :primary) "#88C0D0"))
|
||||
(is (string= (theme-color th :background) "#2E3440"))))
|
||||
|
||||
(test load-preset-unknown-warns
|
||||
"Unknown preset warns but doesn't error"
|
||||
(let ((th (make-theme)))
|
||||
(signals warning (load-preset th :nonexistent))
|
||||
(is (null (theme-color th :primary)))))
|
||||
|
||||
(test preset-switch-mode
|
||||
"Switching mode and reloading changes colors"
|
||||
(let ((th (make-theme :mode :dark)))
|
||||
(load-preset th :default)
|
||||
(is (string= (theme-color th :background) "#1A1A2E"))
|
||||
(setf (theme-mode th) :light)
|
||||
(load-preset th :default)
|
||||
(is (string= (theme-color th :background) "#F8F9FA"))))
|
||||
#+END_SRC
|
||||
|
||||
* Implementation
|
||||
|
||||
** Theme class
|
||||
|
||||
The ~theme~ class holds a mode flag (~:dark~/~:light~) and a hash
|
||||
table of role→hex mappings. The hash table gives O(1) lookups for
|
||||
~theme-color~ and clean iteration for ~load-preset~.
|
||||
|
||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||
(in-package :cl-tty.box)
|
||||
|
||||
;; ── Theme Engine ──────────────────────────────────────────────
|
||||
|
||||
(defclass theme ()
|
||||
((mode :initform :dark :initarg :mode :accessor theme-mode)
|
||||
(roles :initform (make-hash-table) :accessor theme-roles)))
|
||||
|
||||
(defun make-theme (&key (mode :dark))
|
||||
(make-instance 'theme :mode mode))
|
||||
#+END_SRC
|
||||
|
||||
The mode defaults to ~:dark~. Applications can initialize with
|
||||
~:light~ for terminals with light backgrounds. The mode controls
|
||||
which variant ~load-preset~ selects.
|
||||
|
||||
** Color resolution
|
||||
|
||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||
(defun theme-color (theme role)
|
||||
"Resolve a semantic ROLE to a hex color string in THEME."
|
||||
(gethash role (theme-roles theme)))
|
||||
|
||||
(defun (setf theme-color) (hex theme role)
|
||||
"Set the hex color for a semantic ROLE in THEME."
|
||||
(setf (gethash role (theme-roles theme)) hex))
|
||||
#+END_SRC
|
||||
|
||||
Uses ~gethash~ for both getter and setter. Unknown roles return ~nil~,
|
||||
which the backend treats as "use default" — so missing roles degrade
|
||||
gracefully rather than crashing.
|
||||
|
||||
** Preset system
|
||||
|
||||
Presets are stored in a global hash table keyed by keyword name. The
|
||||
~define-preset~ macro registers a preset at macro-expansion time.
|
||||
|
||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||
(defparameter *presets* (make-hash-table :test #'eq))
|
||||
|
||||
(defmacro define-preset (name &key dark light)
|
||||
"Define a theme preset with DARK and LIGHT variants.
|
||||
NAME should be a keyword (e.g., :default, :nord)."
|
||||
(check-type name keyword)
|
||||
`(setf (gethash ,name *presets*) '(:dark ,dark :light ,light)))
|
||||
#+END_SRC
|
||||
|
||||
Using ~#\'~ (quoted list) instead of an alist or hash table keeps the
|
||||
preset data inline and easy to read. The ~eq~ hash table test matches
|
||||
keyword identity.
|
||||
|
||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||
(defun load-preset (theme preset-name)
|
||||
"Load PRESET-NAME colors into THEME.
|
||||
Side-effect: populates cl-tty.backend:*theme-colors* so that semantic
|
||||
color roles resolve to hex at SGR generation time."
|
||||
(let ((preset (gethash preset-name *presets*)))
|
||||
(if preset
|
||||
(let* ((colors (if (eql (theme-mode theme) :dark)
|
||||
(getf preset :dark)
|
||||
(getf preset :light)))
|
||||
;; Populate backend theme color map
|
||||
(theme-map cl-tty.backend:*theme-colors*))
|
||||
;; Set theme colors
|
||||
(loop for (role hex) on colors by #'cddr
|
||||
do (setf (theme-color theme role) hex)
|
||||
(setf (gethash role theme-map) hex)))
|
||||
(warn "Unknown preset: ~S" preset-name))))
|
||||
#+END_SRC
|
||||
|
||||
~load-preset~ does double duty: it populates the theme's role map and
|
||||
the backend's ~*theme-colors*~. This second step is what makes
|
||||
semantic colors work at the SGR level — when the backend renders
|
||||
~:accent~, it looks up ~*theme-colors*~ to get the hex, then
|
||||
generates the escape sequence.
|
||||
|
||||
The ~loop for (role hex) on colors by #'cddr~ iterates the plist in
|
||||
pairs, setting both the theme entry and the backend entry.
|
||||
|
||||
If the preset doesn't exist, ~warn~ is called instead of ~error~ — a
|
||||
missing preset shouldn't crash the application.
|
||||
|
||||
** Built-in presets
|
||||
|
||||
Two presets are built in:
|
||||
|
||||
*** Default preset
|
||||
|
||||
Gold/accent palette on dark navy background. The light variant
|
||||
inverts to warm tones on near-white.
|
||||
|
||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||
(define-preset :default
|
||||
:dark (:primary "#FFD700" :secondary "#B8860B" :accent "#FFA500"
|
||||
:error "#FF4444" :warning "#FF8800" :success "#44BB44" :info "#4488FF"
|
||||
:text "#FFFFFF" :text-muted "#888888"
|
||||
:background "#1A1A2E" :background-panel "#16213E" :background-element "#0F3460"
|
||||
:border "#334155" :border-active "#FFD700"
|
||||
:diff-added "#164B16" :diff-removed "#4B1616" :diff-context "#1A1A2E"
|
||||
:markdown-heading "#FFD700" :markdown-code "#334155"
|
||||
:markdown-link "#4488FF" :markdown-quote "#888888"
|
||||
:syntax-keyword "#FF79C6" :syntax-function "#50FA7B"
|
||||
:syntax-string "#F1FA8C" :syntax-number "#BD93F9"
|
||||
:syntax-comment "#6272A4" :syntax-type "#8BE9FD")
|
||||
:light (:primary "#B8860B" :secondary "#8B6914" :accent "#FF8C00"
|
||||
:error "#CC0000" :warning "#CC6600" :success "#228B22" :info "#0055CC"
|
||||
:text "#1A1A2E" :text-muted "#888888"
|
||||
:background "#F8F9FA" :background-panel "#FFFFFF" :background-element "#E9ECEF"
|
||||
:border "#DEE2E6" :border-active "#B8860B"
|
||||
:diff-added "#DFD" :diff-removed "#FDD" :diff-context "#F8F9FA"
|
||||
:markdown-heading "#B8860B" :markdown-code "#E9ECEF"
|
||||
:markdown-link "#0055CC" :markdown-quote "#888888"
|
||||
:syntax-keyword "#D63384" :syntax-function "#198754"
|
||||
:syntax-string "#FFC107" :syntax-number "#6F42C1"
|
||||
:syntax-comment "#6C757D" :syntax-type "#0DCAF0"))
|
||||
#+END_SRC
|
||||
|
||||
*** Nord preset
|
||||
|
||||
Cool blue palette inspired by Arctic Studio's Nord theme. Softer
|
||||
contrast than default, designed for reduced eye strain.
|
||||
|
||||
#+BEGIN_SRC lisp :tangle ../src/components/theme.lisp
|
||||
(define-preset :nord
|
||||
:dark (:primary "#88C0D0" :secondary "#81A1C1" :accent "#5E81AC"
|
||||
:error "#BF616A" :warning "#D08770" :success "#A3BE8C" :info "#B48EAD"
|
||||
:text "#ECEFF4" :text-muted "#616E88"
|
||||
:background "#2E3440" :background-panel "#3B4252" :background-element "#434C5E"
|
||||
:border "#4C566A" :border-active "#88C0D0"
|
||||
:diff-added "#164B16" :diff-removed "#4B1616" :diff-context "#2E3440"
|
||||
:markdown-heading "#88C0D0" :markdown-code "#3B4252"
|
||||
:markdown-link "#81A1C1" :markdown-quote "#616E88"
|
||||
:syntax-keyword "#81A1C1" :syntax-function "#A3BE8C"
|
||||
:syntax-string "#EBCB8B" :syntax-number "#B48EAD"
|
||||
:syntax-comment "#616E88" :syntax-type "#88C0D0")
|
||||
:light (:primary "#5E81AC" :secondary "#81A1C1" :accent "#88C0D0"
|
||||
:error "#BF616A" :warning "#D08770" :success "#A3BE8C" :info "#B48EAD"
|
||||
:text "#2E3440" :text-muted "#8F9BB3"
|
||||
:background "#ECEFF4" :background-panel "#FFFFFF" :background-element "#E5E9F0"
|
||||
:border "#D8DEE9" :border-active "#5E81AC"
|
||||
:diff-added "#DFD" :diff-removed "#FDD" :diff-context "#ECEFF4"
|
||||
:markdown-heading "#5E81AC" :markdown-code "#E5E9F0"
|
||||
:markdown-link "#81A1C1" :markdown-quote "#8F9BB3"
|
||||
:syntax-keyword "#81A1C1" :syntax-function "#A3BE8C"
|
||||
:syntax-string "#D08770" :syntax-number "#B48EAD"
|
||||
:syntax-comment "#8F9BB3" :syntax-type "#88C0D0"))
|
||||
#+END_SRC
|
||||
Reference in New Issue
Block a user