# 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-tty.asd` — add input module to main and test systems **Code architecture:** ```lisp ;; 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:** ```lisp (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-tty.asd` — add input.lisp **TextInput class:** ```lisp (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:** ```lisp (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-tty.asd` — add textarea.lisp **Textarea class:** ```lisp (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-tty.asd` — add keybindings.lisp **Architecture:** ```lisp (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: ```lisp (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:** ```lisp (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-tty)" --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