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
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, cursorhandle-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 highlighthandle-textarea-input textarea key-event— dispatchestextarea-insert-at textarea str— insert at cursortextarea-delete-before textarea— backspacetextarea-delete-after textarea— deletetextarea-newline textarea— insert newlinetextarea-cursor-up/down/left/right— movementtextarea-word-forward/backward— word skipstextarea-select-to textarea— extend selection to cursortextarea-copy-selection / cut-selection / paste— clipboardtextarea-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:
sbcl --eval "(asdf:test-system :cl-tui)" --quit— all tests GREENscripts/validate-parens.py— all files balanced- 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