Every function, defclass, defstruct, defgeneric, defmethod, defmacro, defvar, and defparameter in every org file now has its own #+BEGIN_SRC block with literate prose above it explaining the design reasoning. Block counts before → after: package.org: 1 → 7 container-package.org: 1 → 1 (prose expanded) dirty.org: 4 → 6 render.org: 10 → 25 theme.org: 6 → 19 box-renderable.org: 9 → 29 scrollbox.org: 8 → 26 tabbar.org: 5 → 10 backend-protocol.org: 8 → 66 modern-backend.org: 17 → 53 detection.org: 4 → 6 layout-engine.org: 9 → 36 framebuffer.org: 8 → 37 markdown-renderer.org:13 → 38 dialog.org: 17 → 23 (merged dual structure) mouse.org: 4 → 25 select.org: 12 → 30 slot.org: 4 → 12 text-input.org: 11 → 53 Total: ~153 blocks → ~502 blocks Bugs fixed during restructuring: - render.org: stray π character typo (backenπd → backend) - modern-backend.org: sgr-attr missing closing paren + #+END_SRC - detection.org: invalid #\Esc character reference - select.org: extra closing paren in select-visible-options All 13 test suites pass at 100%.
220 lines
7.7 KiB
Org Mode
220 lines
7.7 KiB
Org Mode
#+TITLE: TabBar
|
||
#+STARTUP: content
|
||
#+FILETAGS: :cl-tty:container:
|
||
|
||
* Overview
|
||
|
||
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
|
||
|
||
** Package declaration
|
||
|
||
All TabBar code lives in the ~cl-tty.container~ package alongside the
|
||
other container components (scrollbox, box, slot, etc.). This keeps
|
||
the symbol namespace clean and avoids accidental collisions with
|
||
user-level code.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(in-package #:cl-tty.container)
|
||
#+END_SRC
|
||
|
||
** TabBar class
|
||
|
||
~tab-bar~ stores a list of tab plists ~((:id :tab1 :title "One") ...)~
|
||
and the currently active tab id. It inherits from ~dirty-mixin~ so that
|
||
any mutation (adding a tab, switching tabs) automatically marks the
|
||
component for re-render. A layout node holds its geometry; the
|
||
~focusable~ slot allows the keyboard navigation system to discover it.
|
||
|
||
The ~tabs~ slot is a simple plist list rather than a hash table or
|
||
alist because the total number of tabs in a UI is typically small
|
||
(< 20) and we need ordered iteration for rendering.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(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)
|
||
(layout-node :initform (make-layout-node) :accessor tab-bar-layout-node)
|
||
(focusable :initform t :accessor tab-bar-focusable)))
|
||
#+END_SRC
|
||
|
||
** make-tab-bar constructor
|
||
|
||
Convenience constructor that forwards keyword arguments to
|
||
~make-instance~. Using a dedicated function instead of inlining
|
||
~make-instance~ everywhere gives us a single place to add
|
||
defaulting, validation, or initialization hooks in the future.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(defun make-tab-bar (&key tabs active)
|
||
(make-instance 'tab-bar :tabs (or tabs nil) :active active))
|
||
#+END_SRC
|
||
|
||
** tab-bar-add: adding tabs
|
||
|
||
~tab-bar-add~ appends a new tab plist to the end of the tab list.
|
||
The callers supply both an ~id~ (for programmatic selection) and a
|
||
~title~ (for display). If no tab is currently active, the newly added
|
||
tab becomes active automatically — this ensures there is always a
|
||
sensible default when the first tab is created. Returns the ~id~ so
|
||
callers can chain or store it.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(defun tab-bar-add (tb id title)
|
||
"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
|
||
|
||
** component-layout-node protocol
|
||
|
||
Returns the layout node so the layout engine can position and size
|
||
the tab bar within its parent. Every component that participates in
|
||
automatic layout must implement this method.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(defmethod component-layout-node ((tb tab-bar))
|
||
(tab-bar-layout-node tb))
|
||
#+END_SRC
|
||
|
||
** tab-bar-next: cycling forward
|
||
|
||
~tab-bar-next~ moves the active cursor to the next tab in the list,
|
||
wrapping around from the last tab to the first (~mod~ arithmetic).
|
||
It calls ~mark-dirty~ so the rendering pass picks up the change.
|
||
|
||
The lookup strategy — mapcar ids, position, mod — is O(n) but
|
||
acceptable since tab lists are small. A hash-based index would be
|
||
premature optimization at this scale.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(defun tab-bar-next (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)))))
|
||
#+END_SRC
|
||
|
||
** tab-bar-prev: cycling backward
|
||
|
||
Mirror of ~tab-bar-next~; decrements the position index instead of
|
||
incrementing it. ~mod~ handles negative wrap-around correctly in
|
||
Common Lisp (returns a non-negative remainder), so ~(mod (1- 0) 3)~
|
||
produces 2 rather than −1.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(defun tab-bar-prev (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)))))
|
||
#+END_SRC
|
||
|
||
** tab-bar-select: direct tab selection
|
||
|
||
~tab-bar-select~ sets the active tab directly by id, bypassing the
|
||
cyclic navigation. This is used when a user clicks a tab (via mouse
|
||
binding), when a programmatic action needs to switch views, or when
|
||
activating a tab from outside the keyboard flow. Always marks dirty.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(defun tab-bar-select (tb id)
|
||
"Select a tab by ID."
|
||
(setf (tab-bar-active tb) id)
|
||
(mark-dirty tb))
|
||
#+END_SRC
|
||
|
||
** tab-bar-handle-key: keyboard dispatch
|
||
|
||
Dispatches key events for tab navigation. Left arrow goes to the
|
||
previous tab, right arrow to the next. Returns ~t~ when the key was
|
||
consumed and ~nil~ otherwise, which lets the keybinding system fall
|
||
through to other handlers — important for composable UIs where a tab
|
||
bar lives alongside other focusable elements.
|
||
|
||
#+BEGIN_SRC lisp :tangle ../src/components/tabbar.lisp
|
||
(defun tab-bar-handle-key (tb event)
|
||
"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
|
||
|
||
** render: drawing the tab row
|
||
|
||
~render~ iterates the tab list and draws each one as ~[ Title ]~.
|
||
The active tab uses the ~:accent~ foreground color and
|
||
~:background-element~ background for visual prominence; inactive tabs
|
||
are rendered in ~:text-muted~. Tabs are separated by two spaces.
|
||
|
||
Available width comes from the layout node. If the total tab width
|
||
exceeds the available space, tabs are truncated and an ellipsis
|
||
~...~ is drawn at the overflow point. This prevents the tab bar from
|
||
breaking the layout on narrow terminals.
|
||
|
||
#+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))
|
||
(dolist (tab tabs)
|
||
(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)))
|
||
;; 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~).
|