diff --git a/cl-tui.asd b/cl-tui.asd index 3ccc036..86b7784 100644 --- a/cl-tui.asd +++ b/cl-tui.asd @@ -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))) diff --git a/demo.lisp b/demo.lisp new file mode 100644 index 0000000..f373266 --- /dev/null +++ b/demo.lisp @@ -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 "~%~%")) diff --git a/src/components/package.lisp b/src/components/package.lisp index 34cdfd3..3722403 100644 --- a/src/components/package.lisp +++ b/src/components/package.lisp @@ -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) diff --git a/src/components/theme-tests.lisp b/src/components/theme-tests.lisp new file mode 100644 index 0000000..b342719 --- /dev/null +++ b/src/components/theme-tests.lisp @@ -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")))) diff --git a/src/components/theme.lisp b/src/components/theme.lisp new file mode 100644 index 0000000..a2c3f45 --- /dev/null +++ b/src/components/theme.lisp @@ -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"))