From a5f8e6c9d4d7707869cdabcdede10c2a53caa874 Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 11 May 2026 14:41:38 +0000 Subject: [PATCH] =?UTF-8?q?v0.2.0:=20Box=20renderable=20=E2=80=94=20border?= =?UTF-8?q?,=20background,=20and=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Box class with border-style, title, fg/bg slots - render-box dispatches through backend protocol - draw-border for borders, draw-rect for background - draw-text for title below top border - 7 tests: defaults, border, background, title, no-border, zero-size, minimum-size - 13 assertions, 100% GREEN - ASDF updated with src/components module - modern-backend now accepts :output-stream initarg --- backend/modern.lisp | 5 +- cl-tui.asd | 13 +- docs/plans/2026-05-11-v0.2.0-box-and-text.md | 127 ++++++++++++++ org/box-renderable.org | 169 +++++++++++++++++++ src/components/box-tests.lisp | 83 +++++++++ src/components/box.lisp | 50 ++++++ src/components/package.lisp | 9 + 7 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-05-11-v0.2.0-box-and-text.md create mode 100644 org/box-renderable.org create mode 100644 src/components/box-tests.lisp create mode 100644 src/components/box.lisp create mode 100644 src/components/package.lisp diff --git a/backend/modern.lisp b/backend/modern.lisp index b83597a..6b43c04 100644 --- a/backend/modern.lisp +++ b/backend/modern.lisp @@ -113,12 +113,13 @@ (defclass modern-backend (backend) ((output-stream :initform *standard-output* + :initarg :output-stream :accessor backend-output-stream) (in-sync-p :initform nil :accessor in-sync-p))) -(defun make-modern-backend (&key color-palette) +(defun make-modern-backend (&key color-palette output-stream) (declare (ignore color-palette)) - (make-instance 'modern-backend)) + (make-instance 'modern-backend :output-stream (or output-stream *standard-output*))) (defmethod initialize-backend ((b modern-backend)) ;; Enter raw mode, enable mouse, bracketed paste diff --git a/cl-tui.asd b/cl-tui.asd index d22a94a..8d4a08d 100644 --- a/cl-tui.asd +++ b/cl-tui.asd @@ -2,7 +2,7 @@ (asdf:defsystem :cl-tui :description "Reusable Common Lisp Terminal UI Framework" :author "Amr Gharbeia" - :version "0.0.3" + :version "0.2.0" :license "TBD" :depends-on (:fiveam) :components @@ -14,7 +14,11 @@ (:file "modern" :depends-on ("package" "classes")))) (:module "layout" :components - ((:file "layout")))) + ((:file "layout"))) + (:module "src/components" + :components + ((:file "package") + (:file "box" :depends-on ("package"))))) :in-order-to ((test-op (test-op :cl-tui-tests)))) (asdf:defsystem :cl-tui-tests @@ -26,6 +30,9 @@ ((:file "tests"))) (:module "layout" :components - ((:file "tests")))) + ((:file "tests"))) + (:module "src/components" + :components + ((:file "box-tests")))) :perform (test-op (o c) (uiop:symbol-call :cl-tui-backend-test '#:run!))) diff --git a/docs/plans/2026-05-11-v0.2.0-box-and-text.md b/docs/plans/2026-05-11-v0.2.0-box-and-text.md new file mode 100644 index 0000000..b39edfa --- /dev/null +++ b/docs/plans/2026-05-11-v0.2.0-box-and-text.md @@ -0,0 +1,127 @@ +# v0.2.0: Renderables — Box and Text + +> Implementation plan for the first two renderable component types. + +**Goal:** Create Box (border+background+title) and Text (styled wrapping text) renderables that render through the backend protocol. + +**Architecture:** Each renderable is a CLOS class with a `layout-node` slot for positioning. The `render` method dispatches through the backend protocol (draw-text, draw-border, draw-rect). Tests capture backend output via string streams. + +**Files created:** +- `org/box-renderable.org` — Box class, render method (literate source) +- `org/text-renderable.org` — Text class, render method, inline spans (literate source) +- `org/dirty-tracking.org` — Dirty flag system (literate source) +- `src/components/box.lisp` — tangled +- `src/components/text.lisp` — tangled +- `src/components/dirty.lisp` — tangled + +**Files modified:** +- `cl-tui.asd` — add component modules +- `docs/ROADMAP.org` — mark v0.2.0 tasks DONE + +## Task 1: Box renderable + +**Objective:** Box class that draws borders, fills backgrounds, and renders titles. + +**Files:** +- Create: `org/box-renderable.org` +- Create: `src/components/box.lisp` (extracted) +- Modify: `cl-tui.asd` — add components module + +**Box class:** +```lisp +(defclass box () + ((layout-node :initarg :layout-node :accessor box-layout-node) + (border-style :initform :single :initarg :border-style :accessor box-border-style) + (title :initform nil :initarg :title :accessor box-title) + (title-align :initform :left :initarg :title-align :accessor box-title-align) + (fg :initform nil :initarg :fg :accessor box-fg) + (bg :initform nil :initarg :bg :accessor box-bg))) +``` + +**render-box method:** +Renders at computed layout position using backend's draw-border, draw-rect, draw-text. +Delegates to the backend — no escape sequences directly. + +**Tests:** +- Create box with border, verify draw-border was called with correct params +- Create box with title, verify title positioning +- Create box with background fill +- Edge cases: box with 0 width/height, no border style, very long title + +## Task 2: Text renderable + +**Objective:** Text class that renders strings at layout position with word-wrap. + +**Files:** +- Create: `org/text-renderable.org` +- Create: `src/components/text.lisp` (extracted) + +**Text class:** +```lisp +(defclass text () + ((layout-node :initarg :layout-node :accessor text-layout-node) + (content :initarg :content :accessor text-content) + (fg :initform nil :initarg :fg :accessor text-fg) + (bg :initform nil :initarg :bg :accessor text-bg) + (wrap-mode :initform :word :initarg :wrap-mode :accessor text-wrap-mode) + (spans :initform nil :initarg :spans :accessor text-spans))) +``` + +**render-text method:** +1. Get layout position (x, y, width, height) +2. If wrap-mode is :none, truncate to width +3. If wrap-mode is :word, word-wrap (break on whitespace) +4. Draw each line via backend's draw-text +5. Apply span attributes (bold, italic, etc.) per segment + +**Inline spans:** +```lisp +(defclass span () + ((text :initarg :text :accessor span-text) + (bold :initform nil :initarg :bold :accessor span-bold) + (italic :initform nil :initarg :italic :accessor span-italic) + (underline :initform nil :initarg :underline :accessor span-underline))) +``` + +**Tests:** +- Text renders string at correct position +- Word-wrap breaks at word boundaries +- Truncation mode clips at width +- Spans apply style attributes per segment +- Empty string rendering +- Single character +- String shorter than width (no wrapping needed) + +## Task 3: Dirty tracking + +**Objective:** Lightweight dirty-flag system for incremental rendering. + +**Files:** +- Create: `org/dirty-tracking.org` +- Create: `src/components/dirty.lisp` (extracted) + +```lisp +(defgeneric mark-dirty (component)) +(defgeneric dirty-p (component)) +(defgeneric mark-clean (component)) +``` + +Default methods mark/check a `dirty` slot on the component. When implemented: +- `mark-dirty` — sets dirty flag, propagates to parent +- `dirty-p` — returns T if component needs re-render +- `mark-clean` — clears dirty flag after render + +**Tests:** +- New component is dirty (default) +- mark-clean clears dirty flag +- dirty-p returns nil after mark-clean +- mark-dirty sets dirty flag again + +## Task 4: Wire into ASDF + update roadmap + +**Files:** +- Modify: `cl-tui.asd` — add `:module "components"` to both main and test systems +- Modify: `docs/ROADMAP.org` — mark v0.2.0 tasks DONE + +**Run full test suite:** +All 72 existing tests + new component tests: 100% GREEN. diff --git a/org/box-renderable.org b/org/box-renderable.org new file mode 100644 index 0000000..a96935d --- /dev/null +++ b/org/box-renderable.org @@ -0,0 +1,169 @@ +#+TITLE: cl-tui Box Renderable — v0.2.0 +#+STARTUP: content +#+FILETAGS: :cl-tui:components:v0.2.0: +#+OPTIONS: ^:nil + +* Box Renderable + +The Box renderable draws a bordered rectangle with optional title and background +fill. It is the first renderable type and the foundation for all container +components (dialog, panel, group). + +A Box has a =layout-node= slot for positioning via the layout engine. Its +=render-box= method dispatches through the backend protocol. + +** Contract + +- =(make-box &key border-style title title-align fg bg)= → box + Create a Box with optional border style, title, and colors. + +- =(render-box box backend)= → nil + Render the box at its computed layout position. Draws background fill, + border, and title if configured. + +- =(box-layout-node box)= → layout-node + Access the underlying layout-node for positioning. + +** Tests + +#+BEGIN_SRC lisp +(defpackage :cl-tui-box-test + (:use :cl :fiveam :cl-tui.backend :cl-tui.layout) + (:export #:run-tests)) +(in-package :cl-tui-box-test) + +(def-suite box-suite :description "Box renderable tests") +(in-suite box-suite) + +(defun run-tests () + (let ((result (run 'box-suite))) + (fiveam:explain! result) + (uiop:quit 0))) + +(defun make-capturing-backend () + (let* ((s (make-string-output-stream)) + (b (make-modern-backend :output-stream s))) + (values b s))) + +(test box-creates-with-defaults + "A box created with no arguments has reasonable defaults" + (let ((b (make-box))) + (is (typep b 'box)) + (is (typep (box-layout-node b) 'layout-node)))) + +(test box-renders-border + "A box with border draws border characters" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :border-style :single :width 10 :height 5))) + (compute-layout (box-layout-node bx) 10 5) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "┌" out) "top-left corner") + (is (search "┐" out) "top-right corner") + (is (search "└" out) "bottom-left corner") + (is (search "┘" out) "bottom-right corner"))))) + +(test box-renders-background + "A box with background color fills interior" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :bg :red :width 5 :height 3))) + (compute-layout (box-layout-node bx) 5 3) + (render-box bx b) + (let ((out (get-output-stream-string s))) + ;; Should contain SGR background escape for red + (is (search "48;2;255;0;0" out) "SGR background should be red") + (is (search "┌" out) "border with background"))))) + +(test box-renders-title + "A box with title renders the title text" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :title "Hello" :width 12 :height 3))) + (compute-layout (box-layout-node bx) 12 3) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "Hello" out) "title text should appear"))))) + +(test box-without-border + "A box with border-style nil draws no border" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :border-style nil :bg :red :width 5 :height 3))) + (compute-layout (box-layout-node bx) 5 3) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "48;2;255;0;0" out) "background still renders") + ;; No border chars + (is-false (search "┌" out) "no top-left corner"))))) + +(test box-zero-size + "A zero-size box renders nothing" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :border-style :single :width 0 :height 0))) + (compute-layout (box-layout-node bx) 0 0) + (render-box bx b) + (is (string= (get-output-stream-string s) "") + "zero-size box produces no output")))) + +(test box-minimum-size + "A box with minimum non-zero size still renders" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :border-style :single :width 2 :height 2))) + (compute-layout (box-layout-node bx) 2 2) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "┌" out) "2x2 box still has borders"))))) +#+END_SRC + +** Implementation + +#+BEGIN_SRC lisp +(in-package :cl-tui.box) + +(defclass box () + ((layout-node :initform (make-layout-node) :accessor box-layout-node + :initarg :layout-node) + (border-style :initform :single :initarg :border-style + :accessor box-border-style) + (title :initform nil :initarg :title :accessor box-title) + (title-align :initform :left :initarg :title-align + :accessor box-title-align) + (fg :initform nil :initarg :fg :accessor box-fg) + (bg :initform nil :initarg :bg :accessor box-bg))) + +(defun make-box (&key (border-style :single) title + (title-align :left) fg bg + width height) + (make-instance 'box + :border-style border-style + :title title + :title-align title-align + :fg fg + :bg bg + :layout-node (make-layout-node + :width width + :height height + :direction :column))) + +(defun render-box (box backend) + "Render BOX at its computed layout position using BACKEND." + (let ((ln (box-layout-node box)) + (bs (box-border-style box)) + (title (box-title box)) + (fg (box-fg box)) + (bg (box-bg box))) + (let ((x (layout-node-x ln)) + (y (layout-node-y ln)) + (w (layout-node-width ln)) + (h (layout-node-height ln))) + (when (and (zerop w) (zerop h)) + (return-from render-box (values))) + (when bg + (draw-rect backend x y w h :bg bg)) + (when bs + (draw-border backend x y w h + :style bs :fg fg :bg bg + :title title + :title-align (box-title-align box))) + (when (and title bs) + ;; Title is rendered by draw-border — nothing extra needed + (values))))) +#+END_SRC diff --git a/src/components/box-tests.lisp b/src/components/box-tests.lisp new file mode 100644 index 0000000..ad123b7 --- /dev/null +++ b/src/components/box-tests.lisp @@ -0,0 +1,83 @@ +(defpackage :cl-tui-box-test + (:use :cl :fiveam :cl-tui.backend :cl-tui.layout :cl-tui.box) + (:export #:run-tests)) +(in-package :cl-tui-box-test) + +(def-suite box-suite :description "Box renderable tests") +(in-suite box-suite) + +(defun run-tests () + (let ((result (run 'box-suite))) + (fiveam:explain! result) + (uiop:quit 0))) + +(defun make-capturing-backend () + (let* ((s (make-string-output-stream)) + (b (make-modern-backend :output-stream s))) + (values b s))) + +(test box-creates-with-defaults + "A box created with no arguments has reasonable defaults" + (let ((b (make-box))) + (is (typep b 'box)) + (is (typep (box-layout-node b) 'layout-node)))) + +(test box-renders-border + "A box with border draws border characters" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :border-style :single :width 10 :height 5))) + (compute-layout (box-layout-node bx) 10 5) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "┌" out) "top-left corner") + (is (search "┐" out) "top-right corner") + (is (search "└" out) "bottom-left corner") + (is (search "┘" out) "bottom-right corner"))))) + +(test box-renders-background + "A box with background color fills interior" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :bg :red :width 5 :height 3))) + (compute-layout (box-layout-node bx) 5 3) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "┌" out) "border with background") + ;; :red is a named color → indexed SGR (41m, not 48;2;...) + (is (search "41m" out) "SGR background for red"))))) + +(test box-renders-title + "A box with title renders the title text" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :title "Hello" :width 12 :height 3))) + (compute-layout (box-layout-node bx) 12 3) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "Hello" out) "title text should appear"))))) + +(test box-without-border + "A box with border-style nil draws no border" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :border-style nil :bg :red :width 5 :height 3))) + (compute-layout (box-layout-node bx) 5 3) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "41m" out) "background still renders") + (is-false (search "┌" out) "no top-left corner"))))) + +(test box-zero-size + "A zero-size box renders nothing" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :border-style :single :width 0 :height 0))) + (compute-layout (box-layout-node bx) 0 0) + (render-box bx b) + (is (string= (get-output-stream-string s) "") + "zero-size box produces no output")))) + +(test box-minimum-size + "A box with minimum non-zero size still renders" + (multiple-value-bind (b s) (make-capturing-backend) + (let ((bx (make-box :border-style :single :width 2 :height 2))) + (compute-layout (box-layout-node bx) 2 2) + (render-box bx b) + (let ((out (get-output-stream-string s))) + (is (search "┌" out) "2x2 box still has borders"))))) diff --git a/src/components/box.lisp b/src/components/box.lisp new file mode 100644 index 0000000..83a0d6a --- /dev/null +++ b/src/components/box.lisp @@ -0,0 +1,50 @@ +(in-package :cl-tui.box) + +(defclass box () + ((layout-node :initform (make-layout-node) :accessor box-layout-node + :initarg :layout-node) + (border-style :initform :single :initarg :border-style + :accessor box-border-style) + (title :initform nil :initarg :title :accessor box-title) + (title-align :initform :left :initarg :title-align + :accessor box-title-align) + (fg :initform nil :initarg :fg :accessor box-fg) + (bg :initform nil :initarg :bg :accessor box-bg))) + +(defun make-box (&key (border-style :single) title + (title-align :left) fg bg + width height) + (make-instance 'box + :border-style border-style + :title title + :title-align title-align + :fg fg + :bg bg + :layout-node (make-layout-node + :width width + :height height + :direction :column))) + +(defun render-box (box backend) + "Render BOX at its computed layout position using BACKEND." + (let ((ln (box-layout-node box)) + (bs (box-border-style box)) + (title (box-title box)) + (fg (box-fg box)) + (bg (box-bg box))) + (let ((x (layout-node-x ln)) + (y (layout-node-y ln)) + (w (layout-node-width ln)) + (h (layout-node-height ln))) + (when (and (zerop w) (zerop h)) + (return-from render-box (values))) + (when bg + (draw-rect backend x y w h :bg bg)) + (when bs + (draw-border backend x y w h + :style bs :fg fg :bg bg)) + (when title + ;; Render title below top border, left-aligned inside the box + (let ((tx (+ x 2)) + (ty (+ y 1))) + (draw-text backend tx ty title fg bg)))))) diff --git a/src/components/package.lisp b/src/components/package.lisp new file mode 100644 index 0000000..6e44d55 --- /dev/null +++ b/src/components/package.lisp @@ -0,0 +1,9 @@ +(defpackage :cl-tui.box + (:use :cl :cl-tui.backend :cl-tui.layout) + (:export + #:box #:make-box + #:box-layout-node + #:box-border-style #:box-title #:box-title-align + #:box-fg #:box-bg + #:render-box)) +(in-package :cl-tui.box)