Files
cl-tty/src/components/input.lisp
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

308 lines
13 KiB
Common Lisp

(in-package #:cl-tui.input)
;;; ---------------------------------------------------------------------------
;;; Utility: split-string (avoids external dependency)
;;; ---------------------------------------------------------------------------
(defun %split-string (string separator)
"Split STRING at each occurrence of SEPARATOR. Returns list of strings."
(loop with start = 0
for pos = (position separator string :start start)
collect (subseq string start pos)
while pos
do (setf start (1+ pos))))
;;; ---------------------------------------------------------------------------
;;; Global variables for rendering pipeline (set by application)
;;; ---------------------------------------------------------------------------
(defvar *current-backend* nil
"The active backend used for rendering.")
(defvar *current-theme* nil
"The active theme used for semantic color resolution.")
;;; ---------------------------------------------------------------------------
;;; Key event struct
;;; ---------------------------------------------------------------------------
(defstruct key-event
(key nil :type (or keyword null))
(ctrl nil :type boolean)
(alt nil :type boolean)
(shift nil :type boolean)
(code nil :type (or fixnum null))
(raw nil :type (or string null))
(text nil :type (or string null)))
;;; ---------------------------------------------------------------------------
;;; Mouse event struct
;;; ---------------------------------------------------------------------------
(defstruct mouse-event
(type nil :type (or keyword null))
(button nil :type (or keyword nil))
(x 0 :type fixnum)
(y 0 :type fixnum)
(raw nil :type (or string null)))
;;; ---------------------------------------------------------------------------
;;; Terminal raw mode
;;; ---------------------------------------------------------------------------
(defun save-terminal-state ()
(sb-posix:tcgetattr 0))
(defun make-raw-termios (termios)
(flet ((clear-flag (flags mask)
(logand flags (lognot mask))))
(setf (sb-posix:termios-iflag termios)
(clear-flag (sb-posix:termios-iflag termios)
(logior sb-posix:brkint sb-posix:ignpar
sb-posix:istrip sb-posix:inlcr
sb-posix:igncr sb-posix:icrnl
sb-posix:ixon)))
(setf (sb-posix:termios-oflag termios)
(clear-flag (sb-posix:termios-oflag termios)
sb-posix:opost))
(setf (sb-posix:termios-lflag termios)
(clear-flag (sb-posix:termios-lflag termios)
(logior sb-posix:icanon sb-posix:echo
sb-posix:isig sb-posix:iexten)))
(setf (sb-posix:termios-cc termios sb-posix:vmin) 1)
(setf (sb-posix:termios-cc termios sb-posix:vtime) 0)
termios))
(defun set-raw-mode ()
(let ((raw (make-raw-termios (save-terminal-state))))
(sb-posix:tcsetattr 0 sb-posix:tcsanow raw)
raw))
(defun restore-terminal-state (termios)
(sb-posix:tcsetattr 0 sb-posix:tcsanow termios))
(defmacro with-raw-terminal (&body body)
(let ((saved (gensym "SAVED")))
`(let ((,saved (save-terminal-state)))
(set-raw-mode)
(unwind-protect
(progn ,@body)
(restore-terminal-state ,saved)))))
;;; ---------------------------------------------------------------------------
;;; Low-level byte reading
;;; ---------------------------------------------------------------------------
(defun read-raw-byte (&key timeout)
(if timeout
(let ((deadline (+ (get-universal-time) timeout)))
(loop while (< (get-universal-time) deadline)
do (handler-case
(let ((buf (make-array 1 :element-type '(unsigned-byte 8))))
(let ((n (sb-posix:read 0 buf 1)))
(when (plusp n)
(return-from read-raw-byte (aref buf 0)))))
(sb-posix:syscall-error ()
(return-from read-raw-byte nil)))
(sleep 0.01))
nil)
(let ((buf (make-array 1 :element-type '(unsigned-byte 8))))
(multiple-value-bind (n err)
(ignore-errors (sb-posix:read 0 buf 1))
(if (and (integerp n) (plusp n))
(aref buf 0)
(progn
(when err (format *error-output* "read error: ~A~%" err))
nil))))))
;;; ---------------------------------------------------------------------------
;;; CSI parameter parser
;;; ---------------------------------------------------------------------------
(defun parse-csi-params ()
(let ((params '())
(raw (make-array 0 :element-type '(unsigned-byte 8)
:fill-pointer 0 :adjustable t))
(current 0))
(loop
(let ((b (read-raw-byte)))
(unless b (return (values nil nil nil)))
(vector-push-extend b raw)
(cond
((and (>= b #x30) (<= b #x3f))
(if (char= (code-char b) #\;)
(progn (push current params) (setf current 0))
(setf current (+ (* current 10) (- b #x30)))))
((and (>= b #x20) (<= b #x2f))
nil)
((and (>= b #x40) (<= b #x7e))
(push current params)
(return (values (nreverse params) b
(map 'string #'code-char raw))))
(t
(return (values nil nil nil))))))))
;;; ---------------------------------------------------------------------------
;;; Key event tables
;;; ---------------------------------------------------------------------------
(defparameter *csi-key-table*
'((#\A . :up) (#\B . :down) (#\C . :right) (#\D . :left)
(#\F . :end) (#\H . :home)
(#\P . :f1) (#\Q . :f2) (#\R . :f3) (#\S . :f4)
(#\Z . :tab)))
(defparameter *csi-tilde-table*
'((1 . :home) (2 . :insert) (3 . :delete)
(4 . :end) (5 . :page-up) (6 . :page-down)
(7 . :home) (8 . :end)
(11 . :f1) (12 . :f2) (13 . :f3) (14 . :f4)
(15 . :f5) (17 . :f6) (18 . :f7) (19 . :f8)
(20 . :f9) (21 . :f10) (23 . :f11) (24 . :f12)))
;;; ---------------------------------------------------------------------------
;;; SGR mouse parser
;;; ---------------------------------------------------------------------------
(defun parse-sgr-mouse (raw)
(let* ((start (position #\< raw))
(end (position #\m raw :from-end t))
(end2 (position #\M raw :from-end t))
(final (if end end end2))
(releasep (char= (char raw (1- (length raw))) #\m)))
(when (and start final (> final start))
(let* ((nums (mapcar #'parse-integer
(%split-string (subseq raw (1+ start) final) #\;)))
(code (first nums))
(x (or (second nums) 0))
(y (or (third nums) 0))
(button (logand code #x03))
(mod (logand code #x1c))
(motion (logand code #x20))
(wheel (logand code #x40)))
(declare (ignore mod))
(make-mouse-event
:type (cond (releasep :release)
(motion :drag)
(t :press))
:button (cond (wheel (if (zerop (logand code #x01))
:wheel-up :wheel-down))
((= button 0) :left)
((= button 1) :middle)
((= button 2) :right)
(t :none))
:x x :y y :raw raw)))))
;;; ---------------------------------------------------------------------------
;;; Escape sequence reader
;;; ---------------------------------------------------------------------------
(defun %read-escape-sequence ()
(let ((b (read-raw-byte)))
(unless b
(return-from %read-escape-sequence
(make-key-event :key :escape :raw (string #\Esc))))
(case b
;; SS3: ESC O X
(#x4f
(let ((b2 (read-raw-byte)))
(if b2
(let ((key (cdr (assoc (code-char b2)
'((#\P . :f1) (#\Q . :f2)
(#\R . :f3) (#\S . :f4))))))
(make-key-event :key (or key :unknown)
:raw (format nil "~C~C~C" #\Esc #\O (code-char b2))))
(make-key-event :key :escape :raw (string #\Esc)))))
;; CSI: ESC [ ...
(#x5b
(multiple-value-bind (params final-byte) (parse-csi-params)
(if (null final-byte)
(make-key-event :key :escape :raw (string #\Esc))
(if (and (char= (code-char final-byte) #\M)
(>= (length params) 3))
(let* ((p0 (first params)))
(if (zerop (logand p0 #x40))
(let* ((x (second params))
(y (third params))
(button (logand p0 #x03))
(motion (logand p0 #x20))
(wheel (logand p0 #x40)))
(make-mouse-event
:type (if motion :drag :press)
:button (cond (wheel (if (zerop (logand p0 #x01))
:wheel-up :wheel-down))
((= button 0) :left)
((= button 1) :middle)
((= button 2) :right)
(t :none))
:x x :y y :raw (format nil "~C[<~d;~d;~d~C" #\Esc p0 x y (code-char final-byte))))
(let* ((tilde-p (char= (code-char final-byte) #\~))
(param (or p0 0))
(key (if tilde-p
(cdr (assoc param *csi-tilde-table*))
(cdr (assoc (code-char final-byte) *csi-key-table*))))
(modifier (when (> (length params) 1) (second params))))
(let ((ctrl nil) (alt nil) (shift nil))
(when modifier
(setf shift (logtest modifier 1)
alt (logtest modifier 2)
ctrl (logtest modifier 4)))
(make-key-event :key (or key :unknown)
:ctrl ctrl :alt alt :shift shift
:raw (format nil "~C[~d~C" #\Esc param (code-char final-byte))))))
(let* ((tilde-p (char= (code-char final-byte) #\~))
(param (or (first params) 0))
(key (if tilde-p
(cdr (assoc param *csi-tilde-table*))
(cdr (assoc (code-char final-byte) *csi-key-table*))))
(modifier (when (> (length params) 1) (second params))))
(let ((ctrl nil) (alt nil) (shift nil))
(when modifier
(setf shift (logtest modifier 1)
alt (logtest modifier 2)
ctrl (logtest modifier 4)))
(make-key-event :key (or key :unknown)
:ctrl ctrl :alt alt :shift shift
:raw (format nil "~C[~d~C" #\Esc param (code-char final-byte))))))))))
;; ESC ESC
(#x1b
(make-key-event :key :escape :alt t :raw "\\e\\e"))
;; ESC + printable = Alt+key
(t
(let ((ch (code-char b)))
(if (and (>= b #x20) (<= b #x7e))
(make-key-event :key (intern (string (string-upcase ch)) :keyword)
:alt t
:raw (format nil "~C~C" #\Esc ch))
(make-key-event :key :unknown
:raw (format nil "~C~C" #\Esc ch))))))))
;;; ---------------------------------------------------------------------------
;;; Top-level event reader
;;; ---------------------------------------------------------------------------
(defun %read-event (&key timeout)
(let ((b (read-raw-byte :timeout timeout)))
(unless b
(return-from %read-event nil))
(case b
(#x1b
(%read-escape-sequence))
(#x09
(make-key-event :key :tab :code #x09))
(#x0a
(make-key-event :key :enter :code #x0a))
(#x0d
(make-key-event :key :enter :code #x0d))
((#x7f #x08)
(make-key-event :key :backspace :code b))
((and (>= b #x01) (<= b #x1a))
(let ((key (intern (string-upcase (string (code-char (+ b #x60)))) :keyword)))
(make-key-event :key key :ctrl t :code b)))
(#x1c (make-key-event :key :backslash :ctrl t :code b))
(#x1d (make-key-event :key :rbracket :ctrl t :code b))
(#x1e (make-key-event :key :caret :ctrl t :code b))
(#x1f (make-key-event :key :underscore :ctrl t :code b))
((and (>= b #x20) (<= b #x7e))
(let ((ch (code-char b)))
(make-key-event :key (intern (string (string-upcase ch)) :keyword)
:code b)))
(t
(make-key-event :key :unknown :code b :raw (string (code-char b)))))))
;;; ---------------------------------------------------------------------------
;;; Backend integration
;;; ---------------------------------------------------------------------------
(defmethod read-event ((b cl-tui.backend:backend) &key timeout)
(declare (ignore b))
(when (probe-file "/dev/stdin")
(%read-event :timeout timeout)))