#+TITLE: cl-tty v0.5.0 — Text Input + Keybinding System #+STARTUP: content * Text Input System The input pipeline has four layers: 1. **Terminal raw mode** — put stdin into non-canonical mode so every keystroke is delivered immediately (no line buffering, no echo). 2. **Escape sequence parser** — read bytes from stdin, classify them as plain characters, modified keys (Ctrl/Alt), cursor keys, function keys, mouse events, or bracketed paste. 3. **Input widget (TextInput / Textarea)** — editable text with cursor, selection, undo/redo, and emacs-style keybindings. 4. **Keybinding system** — layered keymaps that route keystrokes through focused-component → local → global dispatch. SBCL's ~sb-posix~ provides the POSIX terminal APIs (~tcgetattr~, ~tcsetattr~, ~read~) needed for raw mode. No external libraries required. ** Design decisions - ~key-event~ is a struct — structs generate inline accessors, key/ctrl/alt are fixnum/boolean slots that never need CLOS dispatch. - Mouse events are a separate struct — they carry coordinates and button info that key events don't need. - Terminal state save/restore is explicit (save/set-raw/restore), not wired into backend lifecycle. Different apps want different modes. - The parser reads one byte at a time through a state machine, not a buffer-at-once approach. This keeps the implementation simple and handles arbitrary interleaving of terminal output with input. - SBCL's ~defstruct~ generates keyword constructors by default — we use them directly without custom ~:constructor~ overrides. * Contract ~(key-event key ctrl alt shift code raw text)~ — struct. ~make-key-event :key :enter :ctrl nil~ creates a key-press event. ~key-event-key~ returns the keyword (~:a~, ~:enter~, ~:space~, ~:up~, ~:f1~, etc.). ~(mouse-event type button x y raw)~ — struct. ~type~ is ~:press~, ~:release~, or ~:drag~. ~button~ is ~:left~, ~:middle~, ~:right~, ~:wheel-up~, or ~:wheel-down~. ~%split-string string separator~ → list of strings. Split a string at each occurrence of SEPARATOR character. Used internally to split textarea lines. ~*current-backend*~, ~*current-theme*~ — special variables. Set by the application's main loop. Used by widget render methods to draw themselves. ~save-terminal-state~ → termios. Capture current terminal settings. ~set-raw-mode~ → termios. Disable ICANON, ECHO, ISIG, IEXTEN. VMIN=1, VTIME=0. ~restore-terminal-state termios~ — restore saved settings. ~with-raw-terminal &body body~ — macro. Save → set raw → body → restore (via ~unwind-protect~). ~read-raw-byte &key timeout~ → byte or NIL. Read one byte from fd 0. Blocks indefinitely when timeout=NIL. Returns NIL on timeout. Uses ~sb-posix:read~. ~parse-csi-params~ → (values params final-byte raw-string). Read bytes from stdin until a final CSI byte (0x40-0x7E). Returns list of parameter numbers, the final byte, and the raw string. ~parse-sgr-mouse raw~ → mouse-event or NIL. Parse "ESC[= (length stack) (array-total-size stack)) (loop for i from 1 below (length stack) do (setf (aref stack (1- i)) (aref stack i))) (decf (fill-pointer stack))) (vector-push (textarea-value ta) stack) (setf (fill-pointer (textarea-redo-stack ta)) 0))) (defun textarea-undo (ta) (let ((stack (textarea-undo-stack ta))) (when (plusp (length stack)) (let ((prev (vector-pop stack))) (vector-push (textarea-value ta) (textarea-redo-stack ta)) (setf (textarea-value ta) prev) (textarea-ensure-cursor ta) (mark-dirty ta))))) (defun textarea-redo (ta) (let ((stack (textarea-redo-stack ta))) (when (plusp (length stack)) (let ((next (vector-pop stack))) (vector-push (textarea-value ta) (textarea-undo-stack ta)) (setf (textarea-value ta) next) (textarea-ensure-cursor ta) (mark-dirty ta))))) ;;; --------------------------------------------------------------------------- ;;; Key event handler ;;; --------------------------------------------------------------------------- (defun handle-textarea-input (ta event) "Process a key-event on a textarea widget." (cond ((key-event-ctrl event) (case (key-event-key event) (:z (textarea-undo ta)) (:y (textarea-redo ta)) ;; Ctrl+A/E: home/end (:a (setf (textarea-cursor-col ta) 0)) (:e (let ((lines (textarea-lines ta))) (when (< (textarea-cursor-row ta) (length lines)) (setf (textarea-cursor-col ta) (length (nth (textarea-cursor-row ta) lines)))))) (t nil))) (t (case (key-event-key event) (:left (decf (textarea-cursor-col ta)) (textarea-ensure-cursor ta)) (:right (incf (textarea-cursor-col ta)) (textarea-ensure-cursor ta)) (:up (textarea-move-up ta)) (:down (textarea-move-down ta)) (:home (setf (textarea-cursor-col ta) 0)) (:end (let ((lines (textarea-lines ta))) (when (< (textarea-cursor-row ta) (length lines)) (setf (textarea-cursor-col ta) (length (nth (textarea-cursor-row ta) lines)))))) (:enter (let ((cb (textarea-on-submit ta))) (if cb (funcall cb (textarea-value ta)) (textarea-newline ta)))) (:backspace (textarea-backspace ta)) (:delete (let* ((lines (textarea-lines ta)) (row (textarea-cursor-row ta)) (col (textarea-cursor-col ta)) (line (nth row lines))) (when (and line (< col (length line))) (textarea-push-undo ta) (setf (nth row lines) (concatenate 'string (subseq line 0 col) (subseq line (1+ col)))) (setf (textarea-value ta) (%join-lines lines)) (mark-dirty ta)))) ;; Character insertion (otherwise (let ((ch (code-char (key-event-code event)))) (when (and ch (graphic-char-p ch)) (textarea-insert-char ta ch)))))))) ;;; --------------------------------------------------------------------------- ;;; Rendering ;;; --------------------------------------------------------------------------- (defmethod render ((ta textarea) (backend t)) "Render textarea lines at layout position." (let* ((ln (textarea-layout-node ta)) (x (if ln (layout-node-x ln) 0)) (y (if ln (layout-node-y ln) 0)) (w (if ln (layout-node-width ln) 80)) (h (if ln (layout-node-height ln) 24)) (lines (textarea-lines ta)) (max-lines (min (length lines) h))) (declare (ignore w)) (loop for i from 0 below max-lines for line in lines do (draw-text backend x (+ y i) (subseq line 0 (min (length line) w)) nil nil)))) #+END_SRC ** keybindings.lisp #+BEGIN_SRC lisp :tangle ../src/components/keybindings.lisp (in-package #:cl-tty.input) ;;; --------------------------------------------------------------------------- ;;; Key map struct ;;; --------------------------------------------------------------------------- (defstruct keymap (name nil :type (or keyword null)) (bindings nil :type list) (parent nil :type (or keymap null))) ;;; --------------------------------------------------------------------------- ;;; Global keymap registry ;;; --------------------------------------------------------------------------- (defparameter *keymaps* (make-hash-table :test #'equal)) (defparameter *chord-timeout* 0.5) ;;; --------------------------------------------------------------------------- ;;; Key spec matching ;;; --------------------------------------------------------------------------- (defun key-match-p (spec event) "T if SPEC matches EVENT. Spec is :ctrl+p (modifier+key keyword) or (:ctrl+p) for single-spec in a list, or (:ctrl+x :ctrl+s) for chords." (etypecase spec ;; Keyword like :ctrl+p, :alt+f, :enter, :space, :f1 (keyword (let* ((name (string spec)) (plus (position #\+ name))) (if plus ;; Modified key: :ctrl+p → mod-str="CTRL", key-str="P" (let ((mod-str (subseq name 0 plus)) (key-str (subseq name (1+ plus)))) (and (eql (intern key-str :keyword) (key-event-key event)) (cond ((string= mod-str "CTRL") (key-event-ctrl event)) ((string= mod-str "ALT") (key-event-alt event)) ((string= mod-str "SHIFT") (key-event-shift event)) (t t)))) ;; Plain keyword: :enter, :escape, :f1, etc. (eql spec (key-event-key event))))) ;; List: (:ctrl+p) or (:ctrl+x :ctrl+s) (list (when spec (key-match-p (first spec) event))))) ;;; --------------------------------------------------------------------------- ;;; Dispatch ;;; --------------------------------------------------------------------------- (defun dispatch-key-event (event &key component) (labels ((try-keymap (km) (when km (loop for (spec . handler) in (keymap-bindings km) thereis (when (key-match-p spec event) (funcall handler event) t)))) (find-keymap (name) (gethash name *keymaps*))) (or (and component (let ((km (component-keymap component))) (when km (try-keymap km)))) (try-keymap (find-keymap :local)) (try-keymap (find-keymap :global))))) ;;; --------------------------------------------------------------------------- ;;; defkeymap macro ;;; --------------------------------------------------------------------------- (defmacro defkeymap (name &body bindings) `(setf (gethash ',name *keymaps*) (make-keymap :name ',name :bindings (list ,@(loop for b in bindings collect (if (consp (cdr b)) `(cons ',(car b) ,(cadr b)) `(cons ',(car b) ,(cdr b)))))))) ;;; --- Component protocol integration --- (defgeneric component-keymap (component) (:method ((c t)) nil)) #+END_SRC ** input-package.lisp #+BEGIN_SRC lisp :tangle ../src/components/input-package.lisp (defpackage :cl-tty.input (:use :cl :cl-tty.backend :cl-tty.box :cl-tty.layout) (:export ;; Key events #:key-event #:make-key-event #:key-event-p #:key-event-key #:key-event-ctrl #:key-event-alt #:key-event-shift #:key-event-code #:key-event-raw #:key-event-text ;; Mouse events #:mouse-event #:make-mouse-event #:mouse-event-p #:mouse-event-type #:mouse-event-button #:mouse-event-x #:mouse-event-y ;; Terminal raw mode #:save-terminal-state #:set-raw-mode #:restore-terminal-state #:with-raw-terminal ;; Event reading #:read-event ;; TextInput #:text-input #:make-text-input #:text-input-value #:text-input-cursor #:text-input-placeholder #:text-input-max-length #:text-input-on-submit #:text-input-layout-node #:handle-text-input #:render-text-input ;; Textarea #:textarea #:make-textarea #:textarea-value #:textarea-cursor-row #:textarea-cursor-col #:textarea-on-submit #:textarea-undo-stack #:textarea-redo-stack #:textarea-layout-node #:handle-textarea-input #:render-textarea ;; Keybindings #:keymap #:make-keymap #:keymap-name #:keymap-bindings #:keymap-parent #:*keymaps* #:*chord-timeout* #:defkeymap #:dispatch-key-event #:key-match-p #:component-keymap)) #+END_SRC ** input-tests.lisp #+BEGIN_SRC lisp :tangle ../tests/input-tests.lisp (defpackage :cl-tty-input-test (:use :cl :fiveam :cl-tty.backend :cl-tty.box :cl-tty.layout :cl-tty.input) (:export #:run-tests)) (in-package :cl-tty-input-test) (def-suite input-suite :description "Text input and keybinding tests") (in-suite input-suite) (defun run-tests () (let ((result (run 'input-suite))) (fiveam:explain! result) (uiop:quit 0))) ;; ── Key Event Tests ───────────────────────────────────────────── (test key-event-construction "A key-event can be created and queried." (let ((e (make-key-event :key :a :ctrl t :alt nil))) (is (eql (key-event-key e) :a)) (is-true (key-event-ctrl e)) (is-false (key-event-alt e)))) (test key-event-defaults "Fields default to NIL/nil." (let ((e (make-key-event :key :space))) (is (eql (key-event-key e) :space)) (is-false (key-event-ctrl e)) (is-false (key-event-alt e)) (is-false (key-event-shift e)))) (test mouse-event-construction "A mouse-event can be created and queried." (let ((e (make-mouse-event :type :press :button :left :x 10 :y 5))) (is (eql (mouse-event-type e) :press)) (is (eql (mouse-event-button e) :left)) (is (= (mouse-event-x e) 10)) (is (= (mouse-event-y e) 5)))) ;; ── TextInput Tests ───────────────────────────────────────────── (test text-input-empty "A newly created text-input has empty value and cursor at 0." (let ((in (make-text-input))) (is (string= (text-input-value in) "")) (is (= (text-input-cursor in) 0)))) (test text-input-insert-char "Inserting a character appends and moves cursor." (let ((in (make-text-input))) (handle-text-input in (make-key-event :key :a :code (char-code #\a))) (is (string= (text-input-value in) "a")) (is (= (text-input-cursor in) 1)))) (test text-input-insert-multiple "Inserting multiple characters works left to right." (let ((in (make-text-input))) (handle-text-input in (make-key-event :key :h :code (char-code #\h))) (handle-text-input in (make-key-event :key :e :code (char-code #\e))) (handle-text-input in (make-key-event :key :l :code (char-code #\l))) (handle-text-input in (make-key-event :key :l :code (char-code #\l))) (handle-text-input in (make-key-event :key :o :code (char-code #\o))) (is (string= (text-input-value in) "hello")) (is (= (text-input-cursor in) 5)))) (test text-input-backspace "Backspace removes the character before the cursor." (let ((in (make-text-input :value "ab" :cursor 2))) (handle-text-input in (make-key-event :key :backspace)) (is (string= (text-input-value in) "a")) (is (= (text-input-cursor in) 1)))) (test text-input-backspace-at-start "Backspace at position 0 does nothing." (let ((in (make-text-input :value "ab" :cursor 0))) (handle-text-input in (make-key-event :key :backspace)) (is (string= (text-input-value in) "ab")) (is (= (text-input-cursor in) 0)))) (test text-input-delete "Delete removes the character at the cursor." (let ((in (make-text-input :value "abc" :cursor 1))) (handle-text-input in (make-key-event :key :delete)) (is (string= (text-input-value in) "ac")) (is (= (text-input-cursor in) 1)))) (test text-input-cursor-left-right "Cursor moves left and right." (let ((in (make-text-input :value "ab" :cursor 2))) (handle-text-input in (make-key-event :key :left)) (is (= (text-input-cursor in) 1)) (handle-text-input in (make-key-event :key :right)) (is (= (text-input-cursor in) 2)))) (test text-input-cursor-bounds "Cursor cannot move past start or end." (let ((in (make-text-input :value "ab" :cursor 0))) (handle-text-input in (make-key-event :key :left)) (is (= (text-input-cursor in) 0)) (setf (text-input-cursor in) 2) (handle-text-input in (make-key-event :key :right)) (is (= (text-input-cursor in) 2)))) (test text-input-home-end "Home moves to start, End moves to end." (let ((in (make-text-input :value "hello" :cursor 3))) (handle-text-input in (make-key-event :key :home)) (is (= (text-input-cursor in) 0)) (handle-text-input in (make-key-event :key :end)) (is (= (text-input-cursor in) 5)))) (test text-input-max-length "Max-length prevents inserting beyond the limit." (let ((in (make-text-input :max-length 3))) (handle-text-input in (make-key-event :key :a :code (char-code #\a))) (handle-text-input in (make-key-event :key :b :code (char-code #\b))) (handle-text-input in (make-key-event :key :c :code (char-code #\c))) (handle-text-input in (make-key-event :key :d :code (char-code #\d))) (is (string= (text-input-value in) "abc")))) (test text-input-placeholder "Placeholder is stored but does not affect value." (let ((in (make-text-input :placeholder "Type here..."))) (is (string= (text-input-placeholder in) "Type here...")) (is (string= (text-input-value in) "")))) (test text-input-on-submit "On-submit callback fires on Enter." (let ((result (list nil))) (let ((in (make-text-input :value "hello" :on-submit (lambda (v) (setf (car result) v))))) (handle-text-input in (make-key-event :key :enter)) (is (string= (car result) "hello"))))) (test text-input-ctrl-a-e "Ctrl+A moves to home, Ctrl+E moves to end." (let ((in (make-text-input :value "abc" :cursor 2))) (handle-text-input in (make-key-event :key :a :ctrl t)) (is (= (text-input-cursor in) 0)) (handle-text-input in (make-key-event :key :e :ctrl t)) (is (= (text-input-cursor in) 3)))) (test text-input-insert-in-middle "Inserting in the middle of text shifts rest right." (let ((in (make-text-input :value "ab" :cursor 1))) (handle-text-input in (make-key-event :key :x :code (char-code #\x))) (is (string= (text-input-value in) "axb")) (is (= (text-input-cursor in) 2)))) (test text-input-dirty-on-insert "Inserting marks the widget dirty." (let ((in (make-text-input))) (mark-clean in) (handle-text-input in (make-key-event :key :a :code (char-code #\a))) (is-true (dirty-p in)))) ;; ── Textarea Tests ────────────────────────────────────────────── (test textarea-empty "New textarea has empty value and cursor at (0,0)." (let ((a (make-textarea))) (is (string= (textarea-value a) "")) (is (= (textarea-cursor-row a) 0)) (is (= (textarea-cursor-col a) 0)))) (test textarea-newline "Enter inserts a newline." (let ((a (make-textarea))) (handle-textarea-input a (make-key-event :key :a :code (char-code #\a))) (handle-textarea-input a (make-key-event :key :enter)) (handle-textarea-input a (make-key-event :key :b :code (char-code #\b))) (is (string= (textarea-value a) "a b")))) (test textarea-cursor-up-down "Cursor moves between lines maintaining column position." (let ((a (make-textarea :value "abc de fghi"))) (setf (textarea-cursor-row a) 1) (setf (textarea-cursor-col a) 1) (handle-textarea-input a (make-key-event :key :up)) (is (= (textarea-cursor-row a) 0)) (is (= (textarea-cursor-col a) 1)) (handle-textarea-input a (make-key-event :key :down)) (is (= (textarea-cursor-row a) 1)) (is (= (textarea-cursor-col a) 1)))) (test textarea-cursor-up-down-bounds "Cursor cannot move past first or last line." (let ((a (make-textarea :value "a b"))) (handle-textarea-input a (make-key-event :key :up)) (is (= (textarea-cursor-row a) 0)) (setf (textarea-cursor-row a) 1) (handle-textarea-input a (make-key-event :key :down)) (is (= (textarea-cursor-row a) 1)))) (test textarea-backspace-joins-lines "Backspace at start of a line joins with previous." (let ((a (make-textarea :value "hello world"))) (setf (textarea-cursor-row a) 1) (setf (textarea-cursor-col a) 0) (handle-textarea-input a (make-key-event :key :backspace)) (is (string= (textarea-value a) "helloworld")))) (test textarea-undo "Ctrl+Z undoes the last edit." (let ((a (make-textarea))) (handle-textarea-input a (make-key-event :key :a :code (char-code #\a))) (handle-textarea-input a (make-key-event :key :z :ctrl t)) (is (string= (textarea-value a) "")))) (test textarea-undo-redo "Ctrl+Y redoes an undone edit." (let ((a (make-textarea))) (handle-textarea-input a (make-key-event :key :a :code (char-code #\a))) (handle-textarea-input a (make-key-event :key :z :ctrl t)) (handle-textarea-input a (make-key-event :key :y :ctrl t)) (is (string= (textarea-value a) "a")))) ;; ── Keybinding Tests ──────────────────────────────────────────── (test keymap-simple "A keymap dispatches to its handler on matching event." (let ((called nil)) (setf (gethash :global *keymaps*) (make-keymap :name :global :bindings `((:ctrl+p . ,(lambda (e) (declare (ignore e)) (setf called t)))))) (is-true (dispatch-key-event (make-key-event :key :p :ctrl t))) (is-true called))) (test keymap-no-match "Non-matching event returns nil." (let ((called nil)) (setf (gethash :global *keymaps*) (make-keymap :name :global :bindings `((:ctrl+p . ,(lambda (e) (declare (ignore e)) (setf called t)))))) (is-false (dispatch-key-event (make-key-event :key :a))) (is-false called))) (test keymap-fallback "Event not in local falls through to global." (let ((global-called nil)) (setf (gethash :global *keymaps*) (make-keymap :name :global :bindings `((:ctrl+q . ,(lambda (e) (declare (ignore e)) (setf global-called t)))))) (dispatch-key-event (make-key-event :key :q :ctrl t)) (is-true global-called))) (test key-spec-simple "Keyword key-spec matches key+ctrl." (is-true (key-match-p :ctrl+p (make-key-event :key :p :ctrl t))) (is-false (key-match-p :ctrl+p (make-key-event :key :a :ctrl t))) (is-false (key-match-p :ctrl+p (make-key-event :key :p)))) (test defkeymap-macro "defkeymap macro registers a keymap." (let ((called nil)) (eval `(defkeymap :global (:ctrl+q ,(lambda (e) (declare (ignore e)) (setf called t))))) (dispatch-key-event (make-key-event :key :q :ctrl t)) (is-true called))) #+END_SRC