feat: SGR mouse event parsing in read-event

- Add %read-digits to read multi-digit parameters from raw terminal bytes
- Add %parse-sgr-mouse to decode ESC[<Cb;Cx;CyM/m SGR mouse sequences
  into mouse-event structs with :press/:release type and :left/:middle/
  :right/:scroll-up/:scroll-down/:drag button classification
- Modified parse-csi-sequence to detect the < marker (0x3C) and
  delegate to %parse-sgr-mouse instead of treating it as key input
- Coordinates converted from 1-based (terminal protocol) to 0-based
  (framebuffer convention)
- All 12 test suites pass at 100% (461 checks, no regressions)
- Org source (text-input.org) updated as the source of truth
This commit is contained in:
Hermes Agent
2026-05-12 22:14:03 +00:00
parent 38ee561625
commit b3b191529a
2 changed files with 178 additions and 27 deletions

View File

@@ -110,22 +110,64 @@
(t (make-key-event :key (intern (string-upcase (string (code-char b1))) :keyword)
:alt t :code b1))))))
(defun %read-digits (&optional (initial-bytes nil))
"Read bytes until a non-digit is encountered.
Returns (values number terminator-byte)."
(let ((acc nil))
(dolist (b initial-bytes)
(when (and (>= b 48) (<= b 57))
(push (- b 48) acc)))
(loop for b = (read-raw-byte)
while (and (>= b 48) (<= b 57))
do (push (- b 48) acc)
finally (return (values (if acc
(reduce (lambda (n d) (+ (* n 10) d))
(reverse acc))
0)
b)))))
(defun %parse-sgr-mouse ()
"Parse SGR mouse escape sequence: ESC [ < Cb ; Cx ; Cy M/m
Returns a mouse-event struct."
(let ((b (read-raw-byte)))
(multiple-value-bind (cb sep1) (%read-digits (list b))
(declare (ignore sep1))
(multiple-value-bind (cx sep2) (%read-digits)
(declare (ignore sep2))
(multiple-value-bind (cy term) (%read-digits)
(let ((button (cond
((= cb 0) :left)
((= cb 1) :middle)
((= cb 2) :right)
((= cb 64) :scroll-up)
((= cb 65) :scroll-down)
((>= cb 32) :drag)
(t :left)))
(type (cond
((= term 77) :press)
((= term 109) :release)
(t :press))))
(make-mouse-event :type type :button button
:x (- cx 1) :y (- cy 1))))))))
(defun parse-csi-sequence ()
(flet ((read-param (next-fn) (let ((acc nil))
(loop for b = (funcall next-fn)
do (if (and (>= b 48) (<= b 57))
(push (- b 48) acc)
(return (values (reverse acc) b)))))))
(let* ((extended (make-array 8 :element-type 'fixnum :fill-pointer 0))
(b2 (read-raw-byte))
(params (if (and (>= b2 48) (<= b2 57))
(multiple-value-bind (p term) (read-param (lambda () (read-raw-byte)))
(setf (fill-pointer extended) (length p))
(replace extended p)
(values p term))
(progn (vector-push-extend b2 extended) (read-param (lambda () (read-raw-byte)))))))
(destructuring-bind (params terminator) params
(parse-csi-params params terminator extended)))))
(let* ((b2 (read-raw-byte)))
(if (= b2 60) ;; < — SGR mouse marker
(%parse-sgr-mouse)
(let* ((extended (make-array 8 :element-type 'fixnum :fill-pointer 0))
(params (if (and (>= b2 48) (<= b2 57))
(multiple-value-bind (p term) (read-param (lambda () (read-raw-byte)))
(setf (fill-pointer extended) (length p))
(replace extended p)
(values p term))
(progn (vector-push-extend b2 extended) (read-param (lambda () (read-raw-byte)))))))
(destructuring-bind (params terminator) params
(parse-csi-params params terminator extended)))))))
(defun utf8-decode (bytes)
(case (length bytes)
@@ -185,6 +227,18 @@
(declare (ignore signal info context))
(setf *terminal-resized-p* t))))
(defun %raw-mode-on ()
(uiop:run-program '("stty" "raw" "-echo" "-echoe" "-echok" "-onlcr") :output nil :error-output nil))
(defun %raw-mode-off ()
(uiop:run-program '("stty" "sane") :output nil :error-output nil))
(defmacro with-raw-terminal (&body body)
"Execute BODY with the terminal in raw mode."
`(unwind-protect
(progn (%raw-mode-on) ,@body)
(%raw-mode-off)))
(defmethod read-event ((b cl-tty.backend:backend) &key timeout)
;; Check for pending terminal resize before reading input.
;; The SIGWINCH handler sets *terminal-resized-p* asynchronously.