v0.4.0: Theme engine — semantic colors, presets, dark/light variants

- Theme class with role→hex hash table, mode (dark/light)
- theme-color reader/writer (gethash based)
- define-preset macro with dark and light variants
- load-preset function with keyword lookup
- 2 built-in presets: default (gold) and nord
- 30+ semantic roles per preset (primary, accent, error, syntax-*, etc.)
- 9 theme tests: create, set/get, unknown, dark/light presets,
  nord, unknown-warn, switch-mode
- 57 total component tests, 100% GREEN
This commit is contained in:
Hermes
2026-05-11 15:25:09 +00:00
parent 6ba69f4610
commit 0851311c3d
5 changed files with 183 additions and 3 deletions

View File

@@ -21,7 +21,8 @@
(:file "dirty")
(:file "box" :depends-on ("package"))
(:file "text" :depends-on ("package" "box"))
(:file "render" :depends-on ("package" "box" "text")))))
(:file "render" :depends-on ("package" "box" "text"))
(:file "theme" :depends-on ("package")))))
:in-order-to ((test-op (test-op :cl-tui-tests))))
(asdf:defsystem :cl-tui-tests
@@ -38,6 +39,7 @@
:components
((:file "box-tests")
(:file "dirty-tests")
(:file "render-tests"))))
(:file "render-tests")
(:file "theme-tests"))))
:perform (test-op (o c)
(uiop:symbol-call :cl-tui-backend-test '#:run-tests)))

28
demo.lisp Normal file
View File

@@ -0,0 +1,28 @@
;; demo.lisp — minimal cl-tui demo
(load "/root/quicklisp/setup.lisp")
(ql:quickload :fiveam :silent t)
(load "backend/package.lisp")
(load "backend/classes.lisp")
(load "backend/simple.lisp")
(load "backend/modern.lisp")
(load "layout/layout.lisp")
(load "src/components/package.lisp")
(load "src/components/dirty.lisp")
(load "src/components/box.lisp")
(load "src/components/text.lisp")
(load "src/components/render.lisp")
(in-package :cl-tui.box)
;; Demo 1: Simple backend (ASCII)
(let* ((b (make-simple-backend))
(bx (make-box :border-style :rounded :title " Hello World " :width 30 :height 5)))
(compute-layout (box-layout-node bx) 30 5)
(render bx b))
;; Demo 2: Box with text inside
(let* ((b (make-simple-backend))
(tx (make-text "This is cl-tui in action!" :width 28 :height 1)))
(setf (layout-node-direction (text-layout-node tx)) :column)
(compute-layout (text-layout-node tx) 28 1)
(render tx b)
(format t "~%~%"))

View File

@@ -24,5 +24,8 @@
#:render #:render-screen #:render-node
#:component-layout-node #:component-children #:component-parent
#:available-width #:available-height
#:propagate-dirty))
#:propagate-dirty
;; Theme engine
#:theme #:make-theme #:theme-mode
#:theme-color #:load-preset #:define-preset))
(in-package :cl-tui.box)

View File

@@ -0,0 +1,61 @@
(in-package :cl-tui-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)))
(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"))))

86
src/components/theme.lisp Normal file
View File

@@ -0,0 +1,86 @@
(in-package :cl-tui.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))
(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))
(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)."
`(setf (gethash ,name *presets*) '(:dark ,dark :light ,light)))
(defun load-preset (theme preset-name)
"Load PRESET-NAME (a keyword) into THEME, overwriting role mappings."
(let ((preset (gethash preset-name *presets*)))
(if preset
(let* ((variant (if (eql (theme-mode theme) :dark)
(getf preset :dark)
(getf preset :light)))
(roles (theme-roles theme)))
(clrhash roles)
(loop for (role hex) on variant by #'cddr
do (setf (gethash role roles) hex)))
(warn "Unknown preset: ~S" preset-name))))
(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"))
(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"))