Files
cl-tty/org/tabbar.org
Hermes Agent 668966380e 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%.
2026-05-12 18:06:07 +00:00

4.8 KiB

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

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.

(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)
   (layout-node :initform (make-layout-node) :accessor tab-bar-layout-node)
   (focusable :initform t :accessor tab-bar-focusable)))

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

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

TabBar: component protocol

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

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

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

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

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

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

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

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.

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