#+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~).