Files
cl-tty/org/select.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

22 KiB

cl-tty v0.7.0 — Select Dropdown + Fuzzy Filter

Select Widget

A selection list component — the building block for command palettes, theme pickers, agent selectors, and file pickers. Options are plists with :title, :value, and optional :category fields.

The widget supports keyboard navigation (Up/Down, Ctrl+P/N, Enter, Esc), option filtering by case-insensitive substring match with trigram fuzzy fallback, and category grouping with dimmed headers.

Contract

select class — slots: options, filter, on-select, selected-index, layout-node.

make-select &key options filter on-select → select instance.

select-options sel / (setf select-options) — list of option plists. select-filter sel / (setf select-filter) — filter string or nil. select-selected-index sel / (setf select-selected-index) — currently highlighted index. select-on-select sel / (setf select-on-select) — callback fn (receives option plist). select-layout-node sel / (setf select-layout-node) — layout node.

select-filtered-options sel → list of options matching the filter. Returns all options when filter is nil. Matches title (case-insensitive). Falls back to trigram fuzzy matching when no exact substring matches.

select-next sel / select-prev sel — move selection forward/backward, skipping category headers. Wraps around at boundaries.

select-visible-options sel → filtered options visible in viewport. Uses available-height from layout node. Culls like ScrollBox.

select-handle-key sel event → T if handled. Down/Ctrl+N → next. Up/Ctrl+P → prev. Enter → on-select callback. Esc → nil.

render ((sel select) backend) — renders visible options with selection highlight.

Tests

Test package and suite setup

The test file uses FiveAM. The defpackage pulls in all the dependencies needed by the select widget tests — FiveAM itself, the backend/box/layout/input infrastructure, and the cl-tty.select package under test. run-tests is the entry point for CI and interactive use.

(defpackage :cl-tty-select-test
  (:use :cl :fiveam :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input :cl-tty.select)
  (:export #:run-tests))
(in-package #:cl-tty-select-test)
(def-suite select-suite :description "Select widget tests")
(in-suite select-suite)
(defun run-tests ()
  (let ((result (run 'select-suite)))
    (fiveam:explain! result)
    (uiop:quit 0)))

test select-creates

Verifies that a select widget can be constructed with default values. The selected-index should start at 0, and both options and filter should be nil. This establishes the baseline contract for the default constructor.

(test select-creates
  "A Select can be created with defaults."
  (let ((sel (make-select)))
    (is (typep sel 'select))
    (is-false (select-options sel))
    (is-false (select-filter sel))
    (is (= (select-selected-index sel) 0))))

test select-with-options

Ensures that passing :options to make-select stores them correctly. The length check is the simplest invariant — two options in, two options out.

(test select-with-options
  "A Select stores options."
  (let ((sel (make-select :options '((:title "Red" :value :red)
                                     (:title "Blue" :value :blue)))))
    (is (= (length (select-options sel)) 2))))

test select-filtered-exact

Tests case-insensitive substring filtering: setting filter to \"bl\" should match \"Blue\" but not \"Red\" or \"Green\". The return value is an alist of (display-index original-index option), so we dig into the third element to check the :value.

(test select-filtered-exact
  "Filter returns case-insensitive substring matches."
  (let ((sel (make-select
              :options '((:title "Red" :value :red)
                         (:title "Green" :value :green)
                         (:title "Blue" :value :blue)))))
    (setf (select-filter sel) "bl")
    (let ((filtered (select-filtered-options sel)))
      (is (= (length filtered) 1))
      (is (eql (getf (third (first filtered)) :value) :blue)))))

test select-filtered-all

When the filter is nil select-filtered-options must return every option unchanged. This is the unfiltered/identity case and the most common state when the user hasn't typed anything.

(test select-filtered-all
  "Nil filter returns all options."
  (let ((sel (make-select
              :options '((:title "Red" :value :red)
                         (:title "Blue" :value :blue)))))
    (let ((filtered (select-filtered-options sel)))
      (is (= (length filtered) 2)))))

test select-navigation

Exercises select-next and select-prev through a three-item list, confirming that forward and backward movement works and that both directions wrap around at list boundaries.

(test select-navigation
  "Select-next and select-prev navigate through options."
  (let ((sel (make-select
              :options '((:title "A" :value :a)
                         (:title "B" :value :b)
                         (:title "C" :value :c)))))
    (is (= (select-selected-index sel) 0))
    (select-next sel)
    (is (= (select-selected-index sel) 1))
    (select-next sel)
    (is (= (select-selected-index sel) 2))
    (select-next sel)
    (is (= (select-selected-index sel) 0) "wraps forward")
    (select-prev sel)
    (is (= (select-selected-index sel) 2) "wraps backward")))

test select-navigation-skips-categories

Category headers (options with :category t) should be invisible to navigation — select-next and select-prev skip over them. This test sets up a list with two category headers interleaved and verifies they are transparent to movement.

(test select-navigation-skips-categories
  "Navigation skips category header options."
  (let ((sel (make-select
              :options '((:title "Colors" :category t)
                         (:title "Red" :value :red)
                         (:title "Green" :value :green)
                         (:title "Shapes" :category t)
                         (:title "Circle" :value :circle)))))
    (is (= (select-selected-index sel) 0))
    (select-next sel)
    (is (= (select-selected-index sel) 1) "skipped category header at 0")
    (select-next sel)
    (is (= (select-selected-index sel) 2))
    (select-next sel)
    (is (= (select-selected-index sel) 4) "skipped category header at 3")))

test select-handle-key

Validates that select-handle-key dispatches correctly: Down moves forward, Up moves backward, and Enter invokes the on-select callback with the currently highlighted option's plist.

(test select-handle-key
  "Select handle-key dispatches navigation and selection."
  (let* ((result (list nil))
         (sel (make-select
              :options '((:title "A" :value :a) (:title "B" :value :b))
              :on-select (lambda (opt) (setf (car result) (getf opt :value))))))
    (select-handle-key sel (make-key-event :key :down))
    (is (= (select-selected-index sel) 1))
    (select-handle-key sel (make-key-event :key :up))
    (is (= (select-selected-index sel) 0))
    (select-handle-key sel (make-key-event :key :enter))
    (is (eql (car result) :a))))

test select-handle-key-ctrl

Ctrl+N and Ctrl+P are Emacs-compatible alternatives to Down/Up. They must produce identical navigation behavior. This test confirms the control-key dispatch paths.

(test select-handle-key-ctrl
  "Ctrl+N and Ctrl+P navigate like down/up."
  (let ((sel (make-select
              :options '((:title "A" :value :a) (:title "B" :value :b) (:title "C" :value :c)))))
    (select-handle-key sel (make-key-event :key :n :ctrl t))
    (is (= (select-selected-index sel) 1))
    (select-handle-key sel (make-key-event :key :p :ctrl t))
    (is (= (select-selected-index sel) 0))))

test select-visible-count

select-visible-options should never return more items than the viewport height. This test creates 20 options, sets the layout height to 5, and asserts the visible subset fits within that constraint.

(test select-visible-count
  "Visible options respects viewport height."
  (let* ((ln (make-layout-node))
         (sel (make-select
               :options (loop for i below 20 collect (list :title (format nil "Item ~D" i) :value i)))))
    (setf (select-layout-node sel) ln)
    (setf (layout-node-height ln) 5)
    (let ((visible (select-visible-options sel)))
      (is (<= (length visible) 5)))))

test select-fuzzy-fallback

When exact substring matching fails, the filter falls back to character-set Jaccard similarity. \"nrd\" should match \"Nord\" because the character overlap (n, o, r, d → 3 of 4) exceeds the 0.3 threshold.

(test select-fuzzy-fallback
  "Fuzzy filter catches near-misses."
  (let ((sel (make-select
              :options '((:title "Nord" :value :nord)
                         (:title "Tokyo Night" :value :tokyo)
                         (:title "Catppuccin" :value :cat)))))
    (setf (select-filter sel) "nrd")
    (let ((filtered (select-filtered-options sel)))
      (is (= (length filtered) 1))
      (is (eql (getf (third (first filtered)) :value) :nord)))))

Implementation

Package

The cl-tty.select package depends on the backend, box model, layout, and input subsystems. The exported symbols cover the public API: the select class, constructor, accessors, filtering, navigation, key handling, rendering, and the fuzzy matching predicate (exposed for testing and extensibility).

(defpackage :cl-tty.select
  (:use :cl :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input)
  (:export
   #:select #:make-select
   #:select-options #:select-filter
   #:select-selected-index #:select-on-select
   #:select-layout-node
   #:select-filtered-options
   #:select-next #:select-prev
   #:select-visible-options
   #:select-handle-key
   #:render
   #:fuzzy-match-p))

Select class

defclass select

select inherits from dirty-mixin so the rendering layer knows when the widget state has changed (after navigation, filter updates, etc.). Options are stored as a list of plists. selected-index tracks the currently highlighted option. filter is a string (or nil for unfiltered). on-select is a callback receiving the selected option plist. layout-node positions the widget in the window.

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

(defclass select (dirty-mixin)
  ((options :initform nil :initarg :options
            :accessor select-options :type list)
   (filter :initform nil :initarg :filter
           :accessor select-filter :type (or string null))
   (selected-index :initform 0 :initarg :selected-index
                   :accessor select-selected-index :type fixnum)
   (on-select :initform nil :initarg :on-select
              :accessor select-on-select)
   (layout-node :initform (make-layout-node) :initarg :layout-node
                :accessor select-layout-node)))

defun make-select

A convenience constructor that wraps make-instance with keyword arguments. Defaults to nil for all optional parameters, matching the defclass initforms.

(defun make-select (&key options filter on-select)
  (make-instance 'select
    :options (or options nil)
    :filter filter
    :on-select on-select))

Component protocol

defmethod component-layout-node

The layout engine needs a uniform way to access a component's position. component-layout-node is part of the component protocol; this method for select simply delegates to the select-layout-node accessor.

(defmethod component-layout-node ((sel select))
  (select-layout-node sel))

Option filtering: substring match

defun select-filtered-options

select-filtered-options returns options whose :title contains the filter string (case-insensitive). When filter is nil, returns all options. Category headers are NOT filtered out — they remain in the list so the user can see category context.

The function returns an alist of (filtered-index original-index option) to preserve the original index for selection tracking.

Internally, the filter first checks for exact substring containment via search. If no option matches that way, it falls through to the character-set fuzzy-match-p predicate. Category headers short-circuit so they always pass through the filter.

(defun select-filtered-options (sel)
  "Return list of options matching the current filter, in display order.
  Each item: (display-index original-index option-plist)."
  (let* ((filter (select-filter sel))
         (all-options (select-options sel))
         (filtered (if (null filter)
                       all-options
                       (let ((lower (string-downcase filter)))
                         (remove-if-not
                          (lambda (opt)
                            (or (getf opt :category)
                                (let ((title (string-downcase (getf opt :title))))
                                  (or (search lower title)
                                      (fuzzy-match-p lower title)))))
                          all-options)))))
    (loop for opt in filtered
          for i from 0
          collect (list i (position opt all-options) opt))))

Fuzzy matching: character-set Jaccard similarity

defun string-trigrams

Converts a string into a set of 3-character sliding window n-grams. Short strings (fewer than 3 characters) return the whole string as a single trigram. Duplicates are removed so the set can be used for Jaccard intersection/union calculations.

Note: the running tangle does not call this function directly — the simpler character-set fuzzy-match-p is used instead. Trigram matching is retained here as a documented alternative for future experimentation.

(defun string-trigrams (str)
  "Return a list of 3-character trigrams from STR."
  (let ((s (string-downcase str))
        (result nil))
    (when (< (length s) 3)
      (return-from string-trigrams (list s)))
    (loop for i from 0 to (- (length s) 3)
          do (push (subseq s i (+ i 3)) result))
    (delete-duplicates result :test #'string=)))

defun trigram-score

Jaccard similarity of two trigram sets: the size of the intersection divided by the size of the union. A score of 1.0 means identical sets; 0.0 means no overlap. This is used by fuzzy-match-p if trigram mode is enabled (currently unused in the default filter path — see string-trigrams).

(defun trigram-score (query target)
  "Jaccard similarity of trigram sets: |intersection| / |union|."
  (let* ((q-trigrams (string-trigrams query))
         (t-trigrams (string-trigrams target))
         (intersection (length (intersection q-trigrams t-trigrams :test #'string=)))
         (union (length (union q-trigrams t-trigrams :test #'string=))))
    (if (zerop union) 0.0 (/ (float intersection) union))))

defun fuzzy-match-p

Returns T if the Jaccard similarity between the character sets of the query and target exceeds 0.3. The character-set approach is simpler and cheaper than trigrams while still catching common typos and near-misses like \"nrd\" for \"Nord\".

(defun fuzzy-match-p (query target)
  "T if character-set Jaccard similarity exceeds threshold (0.3)."
  (let* ((q-chars (remove-duplicates (coerce (string-downcase query) 'list)))
         (t-chars (remove-duplicates (coerce (string-downcase target) 'list)))
         (intersection (length (intersection q-chars t-chars)))
         (union (length (union q-chars t-chars))))
    (if (zerop union) nil (> (/ (float intersection) union) 0.3))))

Navigation

defun select-clamp-index

After the filter changes (user types or clears input), the selected index may point beyond the filtered list. select-clamp-index ensures the index stays within valid bounds. If the list is empty the index resets to 0.

(defun select-clamp-index (sel)
  "Ensure selected-index is valid. Wraps if empty."
  (let* ((filtered (select-filtered-options sel))
         (count (length filtered)))
    (if (zerop count)
        (setf (select-selected-index sel) 0)
        (setf (select-selected-index sel)
              (max 0 (min (select-selected-index sel) (1- count)))))))

defun select-next

Moves the selection forward to the next non-category option. Iterates through the filtered list starting from the current index, wrapping around at the end. Each candidate is checked for :category t and skipped. Marks the widget dirty so the render pass picks up the change.

(defun select-next (sel)
  "Move selection to next non-category option. Wraps at end."
  (let* ((filtered (select-filtered-options sel))
         (count (length filtered))
         (current (select-selected-index sel)))
    (when (plusp count)
      (loop for i from 1 below count
            for idx = (mod (+ current i) count)
            for opt = (third (nth idx filtered))
            when (not (getf opt :category))
            do (setf (select-selected-index sel) idx)
               (mark-dirty sel)
               (return)))))

defun select-prev

Moves the selection backward to the previous non-category option. Mirrors select-next but decrements the index (with modular arithmetic for wrap-around). Category headers are skipped identically.

(defun select-prev (sel)
  "Move selection to previous non-category option. Wraps at start."
  (let* ((filtered (select-filtered-options sel))
         (count (length filtered))
         (current (select-selected-index sel)))
    (when (plusp count)
      (loop for i from 1 below count
            for idx = (mod (- current i) count)
            for opt = (third (nth idx filtered))
            when (not (getf opt :category))
            do (setf (select-selected-index sel) idx)
               (mark-dirty sel)
               (return)))))

Key event handler

defun select-handle-key

Dispatches keyboard events:

  • Down, Ctrl+N → select-next
  • Up, Ctrl+P → select-prev
  • Enter → on-select callback with the selected option
  • Esc → return NIL (caller can dismiss the widget)

Returns T if the key was handled (consumed), NIL otherwise so the caller knows not to propagate the event further.

(defun select-handle-key (sel event)
  "Handle a key-event. Returns T if handled."
  (let ((key (key-event-key event))
        (ctrl (key-event-ctrl event)))
    (cond
      ((or (eql key :down) (and ctrl (eql key :n)))
       (select-next sel) t)
      ((or (eql key :up) (and ctrl (eql key :p)))
       (select-prev sel) t)
      ((eql key :enter)
       (let* ((filtered (select-filtered-options sel))
              (idx (select-selected-index sel))
              (item (when (< idx (length filtered))
                      (third (nth idx filtered)))))
         (when item
           (let ((cb (select-on-select sel)))
             (when cb (funcall cb item))))
         t))
      ((eql key :escape) nil)
      (t nil))))

Visible options (viewport culling)

defun select-visible-options

Returns only the filtered options that fit within the widget's available height. Each option occupies 1 row. This prevents rendering hundreds of items when the viewport shows only 10. The window is centered around the currently selected index so the user always sees context around their cursor.

(defun select-visible-options (sel)
  "Return filtered options that fit within the viewport."
  (let* ((ln (select-layout-node sel))
         (height (if ln (layout-node-height ln) 80))
         (filtered (select-filtered-options sel))
         (sel-idx (select-selected-index sel))
         ;; Show items around the selection
         (half (floor (1- height) 2))
         (start (max 0 (- sel-idx half)))
         (end (min (length filtered) (+ start height))))
    (subseq filtered start end)))

Rendering

defmethod render

Draws each visible option on its own line. The selected option is highlighted with :accent foreground and :background-element background. Category headers are rendered dimmed (:text-muted) and visually distinct from selectable items. Long titles are truncated with an ellipsis character to fit the viewport width.

(defmethod render ((sel select) backend)
  (let* ((ln (select-layout-node sel))
         (x (if ln (layout-node-x ln) 0))
         (y (if ln (layout-node-y ln) 0))
         (w (if ln (layout-node-width ln) 80))
         (visible (select-visible-options sel))
         (sel-idx (select-selected-index sel)))
    (dolist (item visible)
      (let* ((display-idx (first item))
             (option (third item))
             (title (getf option :title))
             (is-category (getf option :category))
             (is-selected (eql display-idx sel-idx))
             (display (if (> (length title) (1- w))
                          (concatenate 'string (subseq title 0 (1- w)) "…")
                          title)))
        (cond
          (is-category
           (draw-text backend x y display :text-muted nil))
          (is-selected
           (draw-rect backend x y w 1 :bg :accent)
           (draw-text backend x y display :background :accent))
          (t
           (draw-text backend x y display nil nil)))
        (incf y 1)))
    (values)))