diff --git a/README.org b/README.org index 4fc5fab..c9fbbe4 100644 --- a/README.org +++ b/README.org @@ -59,7 +59,7 @@ class. Programs never call terminal codes directly: (draw-link backend x y string url &key fg bg) ;; Input -(read-event backend &key timeout) → key-event or mouse-event +(read-event backend &key timeout) → key-event, mouse-event, :eof, or nil (backend-size backend) → (values columns lines) ;; Cursor @@ -86,7 +86,8 @@ class. Programs never call terminal codes directly: (setf running nil))) (mouse-event ;; handle mouse - )))) + )) + (when (eq event :eof) (setf running nil)))) (shutdown-backend be)) ``` diff --git a/backend/simple.lisp b/backend/simple.lisp index a7af39f..3074f6b 100644 --- a/backend/simple.lisp +++ b/backend/simple.lisp @@ -41,14 +41,24 @@ POS is :top-left, :top-right, :bottom-left, :bottom-right, (defmethod draw-border ((b simple-backend) x y width height &key style fg bg title title-align) - (declare (ignore style fg bg title title-align)) + (declare (ignore style fg bg title-align)) (let ((h (%simple-border-char nil :horizontal)) (v (%simple-border-char nil :vertical))) ;; Position cursor with newlines and spaces (no escape sequences) (dotimes (row y) (backend-write b (string #\Newline))) - ;; Top edge + ;; Top edge with optional title (backend-write b (make-string x :initial-element #\space)) - (backend-write b (make-string width :initial-element h)) + (if title + (let* ((tlen (length title)) + (space-left (- width tlen 2)) + (left (max 0 (floor space-left 2))) + (right (max 0 (- space-left left)))) + (backend-write b (make-string left :initial-element h)) + (backend-write b (string #\space)) + (backend-write b title) + (backend-write b (string #\space)) + (backend-write b (make-string right :initial-element h))) + (backend-write b (make-string width :initial-element h))) ;; Sides (loop for i from 1 below (1- height) do (backend-write b (string #\Newline)) diff --git a/demo.lisp b/demo.lisp index 3c90460..148f502 100644 --- a/demo.lisp +++ b/demo.lisp @@ -50,7 +50,7 @@ (draw-text backend (+ x 2) (+ y 10) " deps: zero FFI, zero ncurses, pure CL" :bright-cyan nil) (draw-text backend (+ x 2) (+ y 12) "Controls" :bright-white nil :bold t) (draw-text backend (+ x 2) (+ y 13) " Tab / arrows switch tabs" nil nil) - (draw-text backend (+ x 2) (+ y 14) " q / Ctrl+C / Esc quit" nil nil) + (draw-text backend (+ x 2) (+ y 14) " Ctrl+C / Esc quit" nil nil) (draw-text backend (+ x 2) (+ y 15) " mouse click/drag select text (test SGR mouse)" nil nil)) (defun render-tab-widgets (backend x y w h input ta) @@ -97,7 +97,7 @@ (ctrl (key-event-ctrl event))) (log-append "Key: ~a (ctrl=~a alt=~a shift=~a)" key ctrl (key-event-alt event) (key-event-shift event)) (cond - ((or (eql key :|Q|) (and ctrl (eql key :|C|)) (eql key :escape)) + ((or (and ctrl (eql key :|C|)) (eql key :escape)) (setf (getf *app* :running) nil) t) ((eql key :tab) (incf (getf *app* :tab)) @@ -108,10 +108,11 @@ ((eql key :right) (incf (getf *app* :tab)) (when (> (getf *app* :tab) 2) (setf (getf *app* :tab) 0)) t) - ;; Forward key to widgets for testing - (t (handle-text-input (getf *app* :input) event) - (handle-textarea-input (getf *app* :textarea) event) - t)))) + ;; Forward key to widgets only when on the Widgets tab + (t (when (= (getf *app* :tab) 1) + (handle-text-input (getf *app* :input) event) + (handle-textarea-input (getf *app* :textarea) event)) + t)))) (mouse-event (log-append "Mouse: ~a btn=~a pos=(~d,~d)" (mouse-event-type event) (mouse-event-button event) (mouse-event-x event) (mouse-event-y event)) @@ -133,7 +134,7 @@ (backend-clear backend) ;; Title bar (draw-border backend 2 1 (- w 4) 3 :style :double :title " cl-tty v0.15.0 ") - (draw-text backend 4 2 "arrows/tab: tabs type: test input mouse: test SGR q/esc: quit" + (draw-text backend 4 2 "arrows/tab: tabs type: test input mouse: test SGR Esc/Ctrl+C: quit" :bright-white nil) ;; Tab bar (loop for (label . idx) in '((" Home " . 0) (" Widgets " . 1) (" Console " . 2)) @@ -164,8 +165,9 @@ (finish-output *standard-output*) ;; Read event — blocks until a key or mouse event arrives (let ((event (read-event backend))) - (when event - (handle-event event)))) + (cond + ((eq event :eof) (setf (getf *app* :running) nil)) + (event (handle-event event))))) (shutdown-backend backend)))) (run-demo) diff --git a/org/text-input.org b/org/text-input.org index 0d95004..b2fbfe0 100644 --- a/org/text-input.org +++ b/org/text-input.org @@ -57,9 +57,10 @@ SBCL's ~sb-posix~ provides the POSIX terminal APIs (~tcgetattr~, ~with-raw-terminal &body body~ — macro. Save → set raw → body → restore (via ~unwind-protect~). -~read-raw-byte &key timeout~ → byte or NIL. +~read-raw-byte &key timeout~ → (values byte-or-nil reason). Read one byte from fd 0. Blocks indefinitely when timeout=NIL. - Returns NIL on timeout. Uses ~sb-posix:read~. + Returns (values byte NIL) on success, (values NIL :TIMEOUT) on timeout, + (values NIL :EOF) when stdin is closed or /dev/null. ~parse-csi-params~ → (values params final-byte raw-string). Read bytes from stdin until a final CSI byte (0x40-0x7E). @@ -70,14 +71,17 @@ SBCL's ~sb-posix~ provides the POSIX terminal APIs (~tcgetattr~, Converts button codes (0=left, 1=middle, 2=right, 32=motion) and tracks press vs release vs drag. -~%read-escape-sequence~ → key-event. - Called after reading ESC (0x1b). Dispatches: +~%read-escape-sequence~ → key-event or :eof. + Called after reading ESC (0x1b). Uses a 50ms timeout on the first + follow-up byte to resolve Escape ambiguity (lone Escape vs start of + CSI/SS3 sequence). Dispatches: + - timeout → :escape key event - ESC O X → SS3 (F1-F4) - ESC [ ... → CSI (cursors, function keys, mouse) - ESC ESC → Alt+Escape - ESC printable → Alt+letter -~%read-event &key timeout~ → key-event, mouse-event, or NIL. +~%read-event &key timeout~ → key-event, mouse-event, :eof, or NIL. Top-level reader. Handles: - Printable ASCII (0x20-0x7e) → key :A, :B, ..., :~ - Ctrl letters (0x01-0x1a) → :A with ctrl=T diff --git a/src/components/input.lisp b/src/components/input.lisp index b25d54e..ab184fc 100644 --- a/src/components/input.lisp +++ b/src/components/input.lisp @@ -82,27 +82,33 @@ ;;; Low-level byte reading ;;; --------------------------------------------------------------------------- (defun read-raw-byte (&key timeout) + "Read one raw byte from stdin. +Returns: + (values byte nil) on success (byte is 0-255) + (values nil :timeout) on timeout + (values nil :eof) on EOF (stdin closed or /dev/null)" (flet ((read-one () (let ((buf (make-array 1 :element-type '(unsigned-byte 8)))) ;; Use sb-sys:with-pinned-objects so sb-posix:read can access the buffer (sb-sys:with-pinned-objects (buf) (let ((n (sb-posix:read 0 (sb-sys:vector-sap buf) 1))) - (when (plusp n) - (return-from read-raw-byte (aref buf 0)))))))) + (cond + ((plusp n) (return-from read-raw-byte (aref buf 0))) + ((zerop n) (return-from read-raw-byte (values nil :eof))))))))) (if timeout (let ((deadline (+ (get-universal-time) timeout))) (loop while (< (get-universal-time) deadline) do (handler-case (read-one) (sb-posix:syscall-error () - (return-from read-raw-byte nil))) + (return-from read-raw-byte (values nil :timeout)))) (sleep 0.01)) - nil) + (values nil :timeout)) (handler-case (read-one) (sb-posix:syscall-error (e) (format *error-output* "read error: ~A~%" e) - nil))))) + (values nil :eof)))))) ;;; --------------------------------------------------------------------------- ;;; CSI parameter parser @@ -113,8 +119,12 @@ :fill-pointer 0 :adjustable t)) (current 0)) (loop - (let ((b (read-raw-byte))) - (unless b (return (values nil nil nil))) + (multiple-value-bind (b reason) (read-raw-byte) + (unless b + (return-from parse-csi-params + (if (eq reason :eof) + (values nil nil :eof) + (values nil nil nil)))) (vector-push-extend b raw) (cond ((and (>= b #x30) (<= b #x3f)) @@ -186,10 +196,15 @@ ;;; Escape sequence reader ;;; --------------------------------------------------------------------------- (defun %read-escape-sequence () - (let ((b (read-raw-byte))) + "Read the remainder of an escape sequence after the initial ESC (0x1b). +Uses a 50ms timeout on the first follow-up byte to resolve the classic +Escape ambiguity: a lone Escape press returns immediately as an :escape +key event rather than blocking indefinitely." + (multiple-value-bind (b reason) (read-raw-byte :timeout 0.05) (unless b (return-from %read-escape-sequence - (make-key-event :key :escape :raw (string #\Esc)))) + (if (eq reason :eof) :eof + (make-key-event :key :escape :raw (string #\Esc))))) (case b ;; SS3: ESC O X (#x4f @@ -200,59 +215,64 @@ (#\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))))) + :eof))) ;; CSI: ESC [ ... (#x5b (multiple-value-bind (params final-byte raw) (parse-csi-params) - (if (null final-byte) - (make-key-event :key :escape :raw (string #\Esc)) - ;; SGR mouse: ESC [ < ... m/M - (if (and raw (plusp (length raw)) (char= (char raw 0) #\<)) - (or (parse-sgr-mouse raw) - (make-key-event :key :unknown :raw raw)) - (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)) - (release (= button 3))) - (make-mouse-event - :type (cond (release :release) - (motion :drag) - (t :press)) - :button (let ((b button)) (cond ((= b 0) :left) ((= b 1) :middle) ((= b 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))))))))))) + (cond + ((null final-byte) + ;; EOF during CSI parsing — propagate it + (if (eq raw :eof) + :eof + (make-key-event :key :escape :raw (string #\Esc)))) + ;; SGR mouse: ESC [ < ... m/M + ((and raw (plusp (length raw)) (char= (char raw 0) #\<)) + (or (parse-sgr-mouse raw) + (make-key-event :key :unknown :raw raw))) + ((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)) + (release (= button 3))) + (make-mouse-event + :type (cond (release :release) + (motion :drag) + (t :press)) + :button (let ((b button)) (cond ((= b 0) :left) ((= b 1) :middle) ((= b 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))))))) + (t + (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")) @@ -270,9 +290,9 @@ ;;; Top-level event reader ;;; --------------------------------------------------------------------------- (defun %read-event (&key timeout) - (let ((b (read-raw-byte :timeout timeout))) + (multiple-value-bind (b reason) (read-raw-byte :timeout timeout) (unless b - (return-from %read-event nil)) + (return-from %read-event (if (eq reason :eof) :eof nil))) (cond ((= b #x1b) (%read-escape-sequence))