Files
cl-tty/org/tabbar.org
Hermes Agent 29f99a576d literate: restructure all 19 org files with per-function blocks and prose
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%.
2026-05-12 18:55:07 +00:00

7.7 KiB
Raw Blame History

TabBar

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.

(in-package #:cl-tty.container)

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.

(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)))

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.

(defun make-tab-bar (&key tabs active)
  (make-instance 'tab-bar :tabs (or tabs nil) :active active))

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.

(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)

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.

(defmethod component-layout-node ((tb tab-bar))
  (tab-bar-layout-node tb))

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.

(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)))))

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.

(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)))))

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.

(defun tab-bar-select (tb id)
  "Select a tab by ID."
  (setf (tab-bar-active tb) id)
  (mark-dirty tb))

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.

(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)))

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.

(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)))

Tests

TabBar tests are part of the combined scrollbox-tabbar test suite defined in org/scrollbox.org (tangled to tests/scrollbox-tabbar-tests.lisp).