prose: split scrollbox-tabbar.org prose into per-module org files

Distribute the literate prose from the old combined scrollbox-tabbar.org
into three individual module org files:

- scrollbox.org: ScrollBox class, render, scrollbars, bug fixes,
  plus the combined test suite (tangles scrollbox-tabbar-tests.lisp)
- tabbar.org: TabBar class, navigation, keyboard handler, render
- container-package.org: Package definition and exports

The old scrollbox-tabbar.org is retained as a documentation archive
with all code blocks set to :tangle no and a redirecting note.

Fixes the draw-scrollbars code block to use the post-bugfix version
(with layout-node origin offset ox/oy), matching the working code.
All 13 test suites pass at 100%.
This commit is contained in:
Hermes Agent
2026-05-12 18:06:07 +00:00
parent a061d60898
commit 668966380e
7 changed files with 687 additions and 453 deletions

View File

@@ -7,14 +7,38 @@
TabBar handles horizontal tab navigation with keyboard support.
Tabs are rendered as labeled items; the active tab is highlighted.
~tab-bar~ inherits ~dirty-mixin~ and implements the component protocol
(~render~, ~component-layout-node~) so it integrates with the rendering
pipeline and layout engine.
** Contract
~(tab-bar &key tabs active-tab)~ → tab-bar
TABS is a list of ~(id title)~ plists.
~(tab-bar-active tb)~ / ~(setf tab-bar-active)~ — currently active tab id.
~(tab-bar-tabs tb)~ — list of tab plists.
~(tab-bar-add tb id title)~ — add a tab. Returns the tab id.
~(render ((tb tab-bar) backend))~ — renders tab row, active tab
highlighted, inactive tabs dimmed.
* Implementation
** TabBar class
~tab-bar~ stores a list of tab plists ~((:id :tab1 :title "One") ...)~
and the currently active tab id. ~tab-bar-add~ creates a new tab with
the given id and title, returns the id.
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
(in-package #:cl-tty.container)
(defclass tab-bar (dirty-mixin)
((tabs :initform nil :initarg :tabs :accessor tab-bar-tabs :type list)
(active :initform nil :initarg :active :accessor tab-bar-active)
((tabs :initform nil :initarg :tabs
:accessor tab-bar-tabs :type list)
(active :initform nil :initarg :active
:accessor tab-bar-active)
(layout-node :initform (make-layout-node) :accessor tab-bar-layout-node)
(focusable :initform t :accessor tab-bar-focusable)))
@@ -22,39 +46,108 @@ Tabs are rendered as labeled items; the active tab is highlighted.
(make-instance 'tab-bar :tabs (or tabs nil) :active active))
(defun tab-bar-add (tb id title)
(setf (tab-bar-tabs tb) (nconc (tab-bar-tabs tb) (list (list :id id :title title))))
(unless (tab-bar-active tb) (setf (tab-bar-active tb) id)) id)
"Add a tab with ID and TITLE. Sets as active if first tab."
(setf (tab-bar-tabs tb)
(nconc (tab-bar-tabs tb) (list (list :id id :title title))))
(unless (tab-bar-active tb)
(setf (tab-bar-active tb) id))
id)
#+END_SRC
(defmethod component-layout-node ((tb tab-bar)) (tab-bar-layout-node tb))
** TabBar: component protocol
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
(defmethod component-layout-node ((tb tab-bar))
(tab-bar-layout-node tb))
#+END_SRC
** TabBar: navigation
~tab-bar-next~ and ~tab-bar-prev~ cycle through tabs. ~tab-bar-select~
activates a tab by id. ~tab-bar-handle-key~ dispatches key events
(Left/Right to navigate, optional Enter to select).
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
(defun tab-bar-next (tb)
(let* ((tabs (tab-bar-tabs tb)) (current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs)) (pos (position current ids)))
(when pos (let ((next (nth (mod (1+ pos) (length ids)) ids)))
(setf (tab-bar-active tb) next) (mark-dirty tb)))))
"Move to next tab."
(let* ((tabs (tab-bar-tabs tb))
(current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs))
(pos (position current ids)))
(when pos
(let ((next (nth (mod (1+ pos) (length ids)) ids)))
(setf (tab-bar-active tb) next)
(mark-dirty tb)))))
(defun tab-bar-prev (tb)
(let* ((tabs (tab-bar-tabs tb)) (current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs)) (pos (position current ids)))
(when pos (let ((prev (nth (mod (1- pos) (length ids)) ids)))
(setf (tab-bar-active tb) prev) (mark-dirty tb)))))
"Move to previous tab."
(let* ((tabs (tab-bar-tabs tb))
(current (tab-bar-active tb))
(ids (mapcar (lambda (tab) (getf tab :id)) tabs))
(pos (position current ids)))
(when pos
(let ((prev (nth (mod (1- pos) (length ids)) ids)))
(setf (tab-bar-active tb) prev)
(mark-dirty tb)))))
(defun tab-bar-select (tb id) (setf (tab-bar-active tb) id) (mark-dirty tb))
(defun tab-bar-select (tb id)
"Select a tab by ID."
(setf (tab-bar-active tb) id)
(mark-dirty tb))
#+END_SRC
** TabBar: keyboard handler
~tab-bar-handle-key~ dispatches Left → previous tab, Right → next tab.
Returns T if the key was handled, NIL otherwise (for composability with
the keybinding system).
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
(defun tab-bar-handle-key (tb event)
(case (key-event-key event) (:left (tab-bar-prev tb) t) (:right (tab-bar-next tb) t) (t nil)))
"Handle a key-event on a TabBar. Returns T if handled."
(case (key-event-key event)
(:left (tab-bar-prev tb) t)
(:right (tab-bar-next tb) t)
(t nil)))
#+END_SRC
** TabBar: rendering
~render~ iterates tabs, drawing each as ~[ Title ]~ with the active
tab highlighted (bold, accent color) and inactive tabs dimmed. Tabs
are separated by two spaces.
The available width comes from the layout node. If tabs overflow,
they are truncated with an ellipsis.
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
(defmethod render ((tb tab-bar) backend)
(let* ((ln (tab-bar-layout-node tb)) (x (if ln (layout-node-x ln) 0))
(y (if ln (layout-node-y ln) 0)) (w (if ln (layout-node-width ln) 80))
(active-id (tab-bar-active tb)) (tabs (tab-bar-tabs tb)) (x-pos x))
(let* ((ln (tab-bar-layout-node tb))
(x (if ln (layout-node-x ln) 0))
(y (if ln (layout-node-y ln) 0))
(w (if ln (layout-node-width ln) 80))
(active-id (tab-bar-active tb))
(tabs (tab-bar-tabs tb))
(x-pos x))
(dolist (tab tabs)
(let* ((id (getf tab :id)) (title (getf tab :title))
(label (format nil " ~A " title)) (label-len (length label))
(let* ((id (getf tab :id))
(title (getf tab :title))
(label (format nil " ~A " title))
(label-len (length label))
(is-active (eql id active-id))
(fg (if is-active :accent :text-muted))
(bg (if is-active :background-element nil)))
(when (>= (+ x-pos label-len 2) w) (draw-text backend x-pos y "..." :text-muted nil) (return))
(draw-text backend x-pos y label fg bg) (incf x-pos (+ label-len 2)))))
(values))
;; Check if tab fits
(when (>= (+ x-pos label-len 2) (+ x w))
(draw-text backend x-pos y "..." :text-muted nil)
(return))
;; Draw tab
(draw-text backend x-pos y label fg bg)
(incf x-pos (+ label-len 2))))
(values)))
#+END_SRC
* Tests
TabBar tests are part of the combined scrollbox-tabbar test suite
defined in ~org/scrollbox.org~ (tangled to ~tests/scrollbox-tabbar-tests.lisp~).