Files
cl-tty/docs/plans/2026-05-11-v0.5.0-text-input.md
Hermes f07cb65186 v0.5.0: Text input + keybinding system
Four new modules:
- input.lisp: terminal raw mode, escape sequence parser, key/mouse event
  structs, read-event backend integration
- text-input.lisp: single-line text input with cursor, insertion,
  deletion, ctrl-A/E/W/U/K, on-submit callback, max-length
- textarea.lisp: multi-line text input with cursor up/down, newline,
  backspace joins lines, delete, undo/redo stack
- keybindings.lisp: layered keymap dispatch (global/local/focused),
  defkeymap macro, key spec matching with modifier prefixes

60 test assertions, 100% GREEN:
  RED: 0/12, 0/27, 0/30 — no tests existed
  GREEN: 60/60 across backend (27), box (58), input (60)

Dependencies: sb-posix for terminal raw mode (tcgetattr/tcsetattr)
Test files: 30 input tests covering all widgets and keybinding system
2026-05-11 16:31:07 +00:00

13 KiB

v0.5.0: Text Input + Keybinding System

Architecture: Three layers. First, terminal input infrastructure (raw mode, escape parsing, key events) — this is the missing piece the roadmap assumed croatoan would provide. Then TextInput and Textarea widgets. Finally, the layered keybinding system.

The hidden dependency: read-event is currently a no-op in both backends. We need raw terminal I/O (tcsetattr, non-canonical mode, escape sequence parsing) before any input widget works. SBCL provides sb-posix for POSIX terminal APIs.

File structure:

org/input.org              — literate source: terminal input + key events
org/text-input.org         — literate source: TextInput widget
org/textarea.org           — literate source: Textarea widget  
org/keybindings.org        — literate source: keybinding system

backend/input.lisp         — tangled: raw terminal, escape parser, key events
src/components/input.lisp  — tangled: TextInput widget
src/components/textarea.lisp — tangled: Textarea widget
src/components/keybindings.lisp — tangled: keybinding system

Task 1: Terminal Input Infrastructure

Objective: Raw terminal mode, ANSI escape sequence parser, key event types. Implements read-event for both backends.

Files:

  • Create: org/input.org
  • Create: src/input.lisp (tangled)
  • Create: tests/input-tests.lisp
  • Modify: backend/package.lisp — add input exports
  • Modify: backend/modern.lisp — implement read-event
  • Modify: backend/simple.lisp — implement read-event (stdin)
  • Modify: cl-tui.asd — add input module to main and test systems

Code architecture:

;; Key event type — all input gets normalized to this
(defstruct key-event
  key         ;; :a, :b, :space, :enter, :tab, :escape
              ;; :up, :down, :left, :right
              ;; :f1..:f12
  ctrl        ;; boolean
  alt         ;; boolean  
  shift       ;; boolean
  code        ;; raw character code (fixnum)
  raw         ;; raw escape sequence string (for debugging)
  text)       ;; for bracketed paste: the pasted text string

(defstruct mouse-event
  type        ;; :press, :release, :drag
  button      ;; :left, :middle, :right, :none
  x y
  raw)

;; Terminal raw mode — saves/restores termios
(defun save-terminal-state () ...)   ;; tcgetattr(0)
(defun set-raw-mode () ...)          ;; tcsetattr(0, TCSANOW, raw)
(defun restore-terminal-state () ...)
(defmacro with-raw-terminal (&body body) ...)

;; Escape sequence parser
(defun read-byte-from-stdin (&optional timeout) ...)
(defun parse-escape-sequence () ...)  ;; reads CSI, SS3 sequences
(defun parse-csi-sequence () ...)    ;; parses CSI number;...$char
(defun parse-sgr-mouse () ...)       ;; parse CSI < r;c;M/m
(defun read-event-from-stdin (&key timeout) ...)  ;; full read+parse

;; Backend integration
(defmethod read-event ((b modern-backend) &key timeout)
  (let ((event (read-event-from-stdin :timeout timeout)))
    (if (key-event-p event)
        (values (key-event-key event) event)
        (values nil event))))

(defmethod read-event ((b simple-backend) &key timeout)
  (read-event-from-stdin :timeout timeout))

Key normalization table (partial):

Raw byte(s) Key Ctrl Alt
#x1b :escape nil nil
#x7f or #x08 :backspace nil nil
#x0a :enter nil nil
#x09 :tab nil nil
#x01 :a t nil
CSI A :up nil nil
CSI 1~ :home nil nil
CSI 200~ (bracketed paste start)

Tests:

(test read-ctrl-a
  (let* ((event (make-key-event :a :ctrl t)))
    (is (eql (key-event-key event) :a))
    (is-true (key-event-ctrl event))))

(test parse-csi-up
  (let ((kb (terminal-sequence->key-event (format nil \"~C[A\" #\\Esc))))
    (is (eql (key-event-key kb) :up))))

(test mouse-sgr
  (let ((event (parse-sgr-mouse \"<0;10;5M\")))
    (is (eql (mouse-event-type event) :press))
    (is (eql (mouse-event-button event) :left))
    (is (= (mouse-event-x event) 10))
    (is (= (mouse-event-y event) 5))))

Line count: ~250 lines


Task 2: TextInput Widget

Objective: Single-line text input widget with cursor, placeholder, insertion/deletion, clipboard, emacs keybindings.

Files:

  • Create: org/text-input.org
  • Create: src/components/input.lisp
  • Modify: src/components/package.lisp — add exports
  • Modify: cl-tui.asd — add input.lisp

TextInput class:

(defclass text-input (dirty-mixin)
  ((value :initform "" :initarg :value :accessor text-input-value)
   (cursor :initform 0 :initarg :cursor :accessor text-input-cursor)
   (placeholder :initform "" :initarg :placeholder :accessor text-input-placeholder)
   (max-length :initform nil :initarg :max-length :accessor text-input-max-length)
   (on-submit :initform nil :initarg :on-submit :accessor text-input-on-submit)
   (layout-node :initform (make-layout-node) :accessor text-input-layout-node)
   (focusable :initform t :accessor text-input-focusable)))

Methods:

  • render-text-input — renders value at cursor position, placeholder when empty, cursor
  • handle-input text-input key-event — dispatches key events to editing actions:
    • Left/Right → cursor-char-left/right
    • Home → cursor-line-start
    • End → cursor-line-end
    • Backspace → delete-char-before
    • Delete → delete-char-after
    • Printable chars → insert-char
    • Enter → on-submit callback
    • Ctrl+W → delete-word-before
    • Ctrl+U → delete-line-before
    • Ctrl+K → delete-line-after
    • Ctrl+A → cursor-line-start
    • Ctrl+E → cursor-line-end

Visual:

┌──────────────────────────────┐
│ Hello world|                  │  ← cursor at position 11
└──────────────────────────────┘

┌──────────────────────────────┐
│  Type something...           │  ← placeholder (dimmed)
└──────────────────────────────┘

Tests:

(test input-empty
  (let ((in (make-text-input)))
    (is (string= (text-input-value in) ""))
    (is (= (text-input-cursor in) 0))))

(test input-insert-char
  (let ((in (make-text-input)))
    (handle-input in (make-key-event :a))
    (is (string= (text-input-value in) "a"))
    (is (= (text-input-cursor in) 1))))

(test input-backspace
  (let ((in (make-text-input :initial-value "ab")))
    (setf (text-input-cursor in) 2)
    (handle-input in (make-key-event :backspace))
    (is (string= (text-input-value in) "a"))
    (is (= (text-input-cursor in) 1))))

(test input-max-length
  (let ((in (make-text-input :max-length 3)))
    (handle-input in (make-key-event :a))
    (handle-input in (make-key-event :b))
    (handle-input in (make-key-event :c))
    (handle-input in (make-key-event :d))  ;; should be ignored
    (is (string= (text-input-value in) "abc"))))

(test input-cursor-movement
  (let ((in (make-text-input :initial-value "hello")))
    (setf (text-input-cursor in) 5)
    (handle-input in (make-key-event :left))
    (is (= (text-input-cursor in) 4))
    (handle-input in (make-key-event :right))
    (is (= (text-input-cursor in) 5))
    (handle-input in (make-key-event :home))
    (is (= (text-input-cursor in) 0))
    (handle-input in (make-key-event :end))
    (is (= (text-input-cursor in) 5))))

Line count: ~150 lines


Task 3: Textarea Widget

Objective: Multi-line text input with selection, undo/redo, word navigation.

Files:

  • Create: org/textarea.org
  • Create: src/components/textarea.lisp
  • Modify: src/components/package.lisp — add exports
  • Modify: cl-tui.asd — add textarea.lisp

Textarea class:

(defclass textarea (dirty-mixin)
  ((value :initform "" :initarg :value :accessor textarea-value)
   (cursor-row :initform 0 :accessor textarea-cursor-row)
   (cursor-col :initform 0 :accessor textarea-cursor-col)
   (selection-start :initform nil :accessor textarea-selection-start) ;; (row . col) or nil
   (undo-stack :initform (make-array 100 :fill-pointer 0) :accessor textarea-undo-stack)
   (on-submit :initform nil :initarg :on-submit :accessor textarea-on-submit)
   (layout-node :initform (make-layout-node) :accessor textarea-layout-node)
   (focusable :initform t :accessor textarea-focusable)))

Methods:

  • render-textarea — renders visible lines with cursor, optional selection highlight
  • handle-textarea-input textarea key-event — dispatches
  • textarea-insert-at textarea str — insert at cursor
  • textarea-delete-before textarea — backspace
  • textarea-delete-after textarea — delete
  • textarea-newline textarea — insert newline
  • textarea-cursor-up/down/left/right — movement
  • textarea-word-forward/backward — word skips
  • textarea-select-to textarea — extend selection to cursor
  • textarea-copy-selection / cut-selection / paste — clipboard
  • textarea-undo / redo — undo/redo stack

Tests: Similar pattern to TextInput but multi-line, with selection tests. Line count: ~200 lines


Task 4: Keybinding System

Objective: Layered keymaps (global → local → input), defkeymap macro, chord sequences.

Files:

  • Create: org/keybindings.org
  • Create: src/components/keybindings.lisp
  • Modify: src/components/package.lisp — add exports
  • Modify: cl-tui.asd — add keybindings.lisp

Architecture:

(defstruct keymap
  name           ;; :global, :local, or symbol
  bindings       ;; alist: ((key-event-spec . handler-function) ...)
  parent)        ;; parent keymap for fallback

(defmacro defkeymap (name &body bindings)
  ;; (defkeymap :global
  ;;   (:ctrl+p . command-palette)
  ;;   ((:ctrl+c :ctrl+d) . quit))
  `(setf (gethash ',name *keymaps*)
         (make-keymap :name ',name
                      :bindings ',bindings)))

(defparameter *keymaps* (make-hash-table))

;; Dispatch order: focused-component-keymap → local → global
(defun dispatch-key-event (event &key component)
  (let* ((local (and component (component-keymap component)))
         (global (gethash :global *keymaps*)))
    (or (match-and-call local event)
        (match-and-call global event))))

(defun match-and-call (keymap event)
  (loop for (spec . handler) in (keymap-bindings keymap)
        thereis (when (key-match-p spec event)
                  (funcall handler event))))

;; Key spec matching
(defun key-match-p (spec event)
  (etypecase spec
    (keyword (eql spec (key-event-key event)))
    (list    (and (eql (first spec) (key-event-key event))
                  (eql (getf (rest spec) :ctrl) (key-event-ctrl event))
                  (eql (getf (rest spec) :alt) (key-event-alt event))))))

Chord support: Two-key sequences with timeout:

(defparameter *chord-timeout* 0.5)  ;; seconds

(defun handle-chord (first-event)
  (when (chord-p first-event)  ;; first key has pending status
    (let ((second-event (read-event-from-stdin :timeout *chord-timeout*)))
      (if (key-event-p second-event)
          (dispatch-key-event (combine-chord first-event second-event))
          ;; timeout — dispatch first event as standalone
          (dispatch-key-event first-event)))))

Tests:

(test keymap-simple
  (let ((called nil))
    (setf (gethash :test *keymaps*)
          (make-keymap :name :test
                       :bindings `((:ctrl+p . ,(lambda (e) (setf called t))))))
    (dispatch-key-event (make-key-event :p :ctrl t))
    (is-true called)))

(test keymap-fallback
  (let ((global-called nil) (local-called nil))
    (setf (gethash :global *keymaps*)
          (make-keymap :name :global
                       :bindings `((:ctrl+q . ,(lambda (e) (setf global-called t))))))
    ;; Event not in local should fall through
    (dispatch-key-event (make-key-event :q :ctrl t))
    (is-true global-called)))

(test chord-sequence
  (let ((called nil))
    (setf (gethash :global *keymaps*)
          (make-keymap :name :global
                       :bindings `(((:ctrl+c :ctrl+d) . ,(lambda (e) (setf called t))))))
    ;; Simulate chord
    (handler-chord (make-key-event :c :ctrl t) (make-key-event :d :ctrl t))
    (is-true called)))

Line count: ~150 lines


Dependency Order

Task 1 (input infra) ──→ Task 2 (TextInput) ──→ Task 3 (Textarea)
                      └──→ Task 4 (keybinding) ──→ uses both

Task 1 is the prerequisite for everything. Tasks 2, 3, 4 can then proceed in parallel (2 and 3 depend on 1, 4 depends on key events from 1).


Verification

After each task:

  1. sbcl --eval "(asdf:test-system :cl-tui)" --quit — all tests GREEN
  2. scripts/validate-parens.py — all files balanced
  3. Commit with RED/GREEN evidence

Final verification:

  • All 4 phases implemented and tested
  • ~750 lines total across all components
  • Full test suite: ~100+ assertions, 100% GREEN