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
This commit is contained in:
34
src/components/input-package.lisp
Normal file
34
src/components/input-package.lisp
Normal file
@@ -0,0 +1,34 @@
|
||||
(defpackage :cl-tui.input
|
||||
(:use :cl :cl-tui.backend :cl-tui.box :cl-tui.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))
|
||||
269
src/components/input-tests.lisp
Normal file
269
src/components/input-tests.lisp
Normal file
@@ -0,0 +1,269 @@
|
||||
(defpackage :cl-tui-input-test
|
||||
(:use :cl :fiveam :cl-tui.backend :cl-tui.box :cl-tui.layout :cl-tui.input)
|
||||
(:export #:run-tests))
|
||||
(in-package :cl-tui-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)))
|
||||
307
src/components/input.lisp
Normal file
307
src/components/input.lisp
Normal file
@@ -0,0 +1,307 @@
|
||||
(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)))
|
||||
77
src/components/keybindings.lisp
Normal file
77
src/components/keybindings.lisp
Normal file
@@ -0,0 +1,77 @@
|
||||
(in-package #:cl-tui.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))
|
||||
163
src/components/text-input.lisp
Normal file
163
src/components/text-input.lisp
Normal file
@@ -0,0 +1,163 @@
|
||||
(in-package #:cl-tui.input)
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; TextInput class
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defclass text-input (dirty-mixin)
|
||||
((value :initform "" :initarg :value :accessor text-input-value
|
||||
:type string)
|
||||
(cursor :initform 0 :initarg :cursor :accessor text-input-cursor
|
||||
:type fixnum)
|
||||
(placeholder :initform "" :initarg :placeholder
|
||||
:accessor text-input-placeholder :type string)
|
||||
(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)))
|
||||
|
||||
(defun make-text-input (&key value cursor placeholder max-length on-submit)
|
||||
(make-instance 'text-input
|
||||
:value (or value "")
|
||||
:cursor (or cursor 0)
|
||||
:placeholder (or placeholder "")
|
||||
:max-length max-length
|
||||
:on-submit on-submit))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Editing operations
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defun text-input-insert (input char)
|
||||
"Insert CHAR at the cursor position in INPUT."
|
||||
(let* ((val (text-input-value input))
|
||||
(pos (text-input-cursor input))
|
||||
(max (text-input-max-length input)))
|
||||
(when (and max (>= (length val) max))
|
||||
(return-from text-input-insert))
|
||||
(setf (text-input-value input)
|
||||
(concatenate 'string
|
||||
(subseq val 0 pos)
|
||||
(string char)
|
||||
(subseq val pos)))
|
||||
(incf (text-input-cursor input))
|
||||
(mark-dirty input)))
|
||||
|
||||
(defun text-input-backspace (input)
|
||||
"Delete character before cursor."
|
||||
(let* ((val (text-input-value input))
|
||||
(pos (text-input-cursor input)))
|
||||
(when (zerop pos) (return-from text-input-backspace))
|
||||
(setf (text-input-value input)
|
||||
(concatenate 'string
|
||||
(subseq val 0 (1- pos))
|
||||
(subseq val pos)))
|
||||
(decf (text-input-cursor input))
|
||||
(mark-dirty input)))
|
||||
|
||||
(defun text-input-delete (input)
|
||||
"Delete character at cursor."
|
||||
(let* ((val (text-input-value input))
|
||||
(pos (text-input-cursor input)))
|
||||
(when (>= pos (length val))
|
||||
(return-from text-input-delete))
|
||||
(setf (text-input-value input)
|
||||
(concatenate 'string
|
||||
(subseq val 0 pos)
|
||||
(subseq val (1+ pos))))
|
||||
(mark-dirty input)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Cursor movement
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defun text-input-move-left (input)
|
||||
(when (plusp (text-input-cursor input))
|
||||
(decf (text-input-cursor input))))
|
||||
|
||||
(defun text-input-move-right (input)
|
||||
(when (< (text-input-cursor input) (length (text-input-value input)))
|
||||
(incf (text-input-cursor input))))
|
||||
|
||||
(defun text-input-move-home (input)
|
||||
(setf (text-input-cursor input) 0))
|
||||
|
||||
(defun text-input-move-end (input)
|
||||
(setf (text-input-cursor input) (length (text-input-value input))))
|
||||
|
||||
(defun text-input-delete-word-before (input)
|
||||
"Delete from cursor back to previous word boundary."
|
||||
(let* ((val (text-input-value input))
|
||||
(pos (text-input-cursor input)))
|
||||
(when (zerop pos)
|
||||
(return-from text-input-delete-word-before))
|
||||
(let* ((start (or (position-if (lambda (c) (not (char= c #\Space)))
|
||||
val :end pos :from-end t)
|
||||
0))
|
||||
(word-start (or (and (plusp start)
|
||||
(position #\Space val :end start :from-end t))
|
||||
0))
|
||||
(delete-start (if (and (zerop word-start)
|
||||
(or (char/= (char val 0) #\Space)
|
||||
(zerop start)))
|
||||
0
|
||||
(if (zerop start)
|
||||
(1+ word-start)
|
||||
(1+ (or (position #\Space val :end start :from-end t)
|
||||
0))))))
|
||||
(setf (text-input-value input)
|
||||
(concatenate 'string
|
||||
(subseq val 0 delete-start)
|
||||
(subseq val pos)))
|
||||
(setf (text-input-cursor input) delete-start)
|
||||
(mark-dirty input))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Key event handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defun handle-text-input (input event)
|
||||
"Process a key-event on a text-input widget."
|
||||
(cond
|
||||
((key-event-ctrl event)
|
||||
(case (key-event-key event)
|
||||
(:a (text-input-move-home input))
|
||||
(:e (text-input-move-end input))
|
||||
(:w (text-input-delete-word-before input))
|
||||
(:u (progn
|
||||
(setf (text-input-value input)
|
||||
(subseq (text-input-value input)
|
||||
(text-input-cursor input)))
|
||||
(setf (text-input-cursor input) 0)
|
||||
(mark-dirty input)))
|
||||
(:k (progn
|
||||
(setf (text-input-value input)
|
||||
(subseq (text-input-value input) 0
|
||||
(text-input-cursor input)))
|
||||
(mark-dirty input)))
|
||||
(t nil)))
|
||||
(t
|
||||
(case (key-event-key event)
|
||||
(:left (text-input-move-left input))
|
||||
(:right (text-input-move-right input))
|
||||
(:home (text-input-move-home input))
|
||||
(:end (text-input-move-end input))
|
||||
(:backspace (text-input-backspace input))
|
||||
(:delete (text-input-delete input))
|
||||
(:enter (let ((cb (text-input-on-submit input)))
|
||||
(when cb (funcall cb (text-input-value input)))))
|
||||
(:tab nil)
|
||||
(:escape nil)
|
||||
;; Insert printable characters
|
||||
(otherwise
|
||||
(let ((ch (code-char (key-event-code event))))
|
||||
(when (and ch (graphic-char-p ch))
|
||||
(text-input-insert input ch))))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Rendering (stub — proper rendering uses theme + backend)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defmethod render ((in text-input) (backend t))
|
||||
"Render a text-input widget. Full rendering requires *current-backend*,
|
||||
*current-theme*, and the rendering pipeline. This is a no-op stub for
|
||||
unit testing the widget logic."
|
||||
(declare (ignore in backend))
|
||||
(values))
|
||||
258
src/components/textarea.lisp
Normal file
258
src/components/textarea.lisp
Normal file
@@ -0,0 +1,258 @@
|
||||
(in-package #:cl-tui.input)
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Utility: split string (local copy for dependency-free operation)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(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))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Textarea class
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defclass textarea (dirty-mixin)
|
||||
((value :initform "" :initarg :value :accessor textarea-value :type string)
|
||||
(cursor-row :initform 0 :accessor textarea-cursor-row :type fixnum)
|
||||
(cursor-col :initform 0 :accessor textarea-cursor-col :type fixnum)
|
||||
(selection-start :initform nil :accessor textarea-selection-start)
|
||||
(undo-stack :initform (make-array 100 :fill-pointer 0)
|
||||
:accessor textarea-undo-stack)
|
||||
(redo-stack :initform (make-array 100 :fill-pointer 0)
|
||||
:accessor textarea-redo-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)))
|
||||
|
||||
(defun make-textarea (&key value on-submit)
|
||||
(make-instance 'textarea
|
||||
:value (or value "")
|
||||
:on-submit on-submit))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Line helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defun textarea-lines (ta)
|
||||
"Split value into lines."
|
||||
(%split-string (textarea-value ta) #\Newline))
|
||||
|
||||
(defun textarea-line-count (ta)
|
||||
"Number of lines in value."
|
||||
(length (textarea-lines ta)))
|
||||
|
||||
(defun textarea-ensure-cursor (ta)
|
||||
"Clamp cursor to valid range."
|
||||
(let ((lines (textarea-lines ta)))
|
||||
(setf (textarea-cursor-row ta)
|
||||
(max 0 (min (textarea-cursor-row ta) (1- (length lines)))))
|
||||
(let ((line-len (length (nth (textarea-cursor-row ta) lines))))
|
||||
(setf (textarea-cursor-col ta)
|
||||
(max 0 (min (textarea-cursor-col ta) line-len))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Utility: join strings with newline
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defun %join-lines (lines)
|
||||
"Join a sequence of strings with newlines."
|
||||
(with-output-to-string (s)
|
||||
(loop for line across (if (listp lines) (coerce lines 'vector) lines)
|
||||
for first = t then nil
|
||||
do (unless first (write-char #\Newline s))
|
||||
(write-string line s))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Text manipulation
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defun textarea-insert-char (ta char)
|
||||
"Insert CHAR at the cursor position."
|
||||
(textarea-push-undo ta)
|
||||
(let* ((lines (coerce (textarea-lines ta) 'vector))
|
||||
(row (textarea-cursor-row ta))
|
||||
(col (textarea-cursor-col ta)))
|
||||
(if (< row (length lines))
|
||||
(let* ((line (aref lines row))
|
||||
(new-line (concatenate 'string
|
||||
(subseq line 0 col)
|
||||
(string char)
|
||||
(subseq line col))))
|
||||
(setf (aref lines row) new-line)
|
||||
(setf (textarea-value ta)
|
||||
(%join-lines lines))
|
||||
(incf (textarea-cursor-col ta))
|
||||
(mark-dirty ta))
|
||||
(progn
|
||||
(setf (textarea-value ta)
|
||||
(concatenate 'string (textarea-value ta) (string char)))
|
||||
(incf (textarea-cursor-col ta))
|
||||
(mark-dirty ta)))))
|
||||
|
||||
(defun textarea-newline (ta)
|
||||
"Insert a newline at the cursor."
|
||||
(textarea-push-undo ta)
|
||||
(let* ((lines (coerce (textarea-lines ta) 'vector))
|
||||
(row (textarea-cursor-row ta))
|
||||
(col (textarea-cursor-col ta)))
|
||||
(if (< row (length lines))
|
||||
(let* ((line (aref lines row))
|
||||
(before (subseq line 0 col))
|
||||
(after (subseq line col)))
|
||||
(setf (aref lines row) before)
|
||||
(let ((new-lines (concatenate 'vector
|
||||
(subseq lines 0 (1+ row))
|
||||
(vector after)
|
||||
(subseq lines (1+ row)))))
|
||||
(setf (textarea-value ta)
|
||||
(%join-lines new-lines)))
|
||||
(incf (textarea-cursor-row ta))
|
||||
(setf (textarea-cursor-col ta) 0)
|
||||
(mark-dirty ta))
|
||||
(progn
|
||||
(setf (textarea-value ta)
|
||||
(concatenate 'string (textarea-value ta) (string #\Newline)))
|
||||
(incf (textarea-cursor-row ta))
|
||||
(setf (textarea-cursor-col ta) 0)
|
||||
(mark-dirty ta)))))
|
||||
|
||||
(defun textarea-backspace (ta)
|
||||
"Delete character before cursor."
|
||||
(textarea-push-undo ta)
|
||||
(let* ((lines (coerce (textarea-lines ta) 'vector))
|
||||
(row (textarea-cursor-row ta))
|
||||
(col (textarea-cursor-col ta)))
|
||||
(cond
|
||||
((and (zerop row) (zerop col))
|
||||
nil) ;; nothing to delete
|
||||
((zerop col)
|
||||
;; Join with previous line
|
||||
(let* ((prev (aref lines (1- row)))
|
||||
(curr (aref lines row))
|
||||
(new-pos (length prev)))
|
||||
(setf (aref lines (1- row))
|
||||
(concatenate 'string prev curr))
|
||||
(let ((new-lines (concatenate 'vector
|
||||
(subseq lines 0 row)
|
||||
(subseq lines (1+ row)))))
|
||||
(setf (textarea-value ta)
|
||||
(%join-lines new-lines)))
|
||||
(decf (textarea-cursor-row ta))
|
||||
(setf (textarea-cursor-col ta) new-pos)
|
||||
(mark-dirty ta)))
|
||||
(t
|
||||
(let* ((line (aref lines row))
|
||||
(new-line (concatenate 'string
|
||||
(subseq line 0 (1- col))
|
||||
(subseq line col))))
|
||||
(setf (aref lines row) new-line)
|
||||
(setf (textarea-value ta)
|
||||
(%join-lines lines))
|
||||
(decf (textarea-cursor-col ta))
|
||||
(mark-dirty ta))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Cursor movement
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defun textarea-move-up (ta)
|
||||
(decf (textarea-cursor-row ta))
|
||||
(textarea-ensure-cursor ta))
|
||||
|
||||
(defun textarea-move-down (ta)
|
||||
(incf (textarea-cursor-row ta))
|
||||
(textarea-ensure-cursor ta))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Undo/redo
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defun textarea-push-undo (ta)
|
||||
"Save current value on undo stack."
|
||||
(let ((stack (textarea-undo-stack ta)))
|
||||
(when (>= (length stack) (array-total-size stack))
|
||||
(setf (textarea-undo-stack ta)
|
||||
(make-array 100 :fill-pointer 0)))
|
||||
(vector-push (textarea-value ta) stack)
|
||||
;; Clear redo stack on new action
|
||||
(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 (stub — proper rendering uses theme + backend)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
(defmethod render ((ta textarea) (backend t))
|
||||
"Render a textarea widget. Full rendering requires *current-backend*,
|
||||
*current-theme*, and the rendering pipeline. This is a no-op stub for
|
||||
unit testing the widget logic."
|
||||
(declare (ignore ta backend))
|
||||
(values))
|
||||
Reference in New Issue
Block a user