org/text-input.org: remove (declare (ignore w)) from textarea render; add truncation to text-input render (subseq display 0 w) org/mouse.org: hit-test now uses component-layout-node and recurses into children for deepest-match hit testing org/select.org: render reads layout-node-x/y instead of hardcoded (0,0) org/scrollbox-tabbar.org: tabbar render reads layout-node-x/y instead of hardcoded (0,0); x-pos starts at x offset All 4 org files tangled clean. 392 tests pass.
7.4 KiB
7.4 KiB
Mouse Support (v0.10.0)
Overview
Mouse event propagation through the component tree. The input system
already parses SGR mouse sequences into mouse-event structs. This
module adds:
- A
mouse-mixinclass with event handler slots - Hit-testing: given (x,y), find the deepest component owning that cell
- Event dispatch: route
mouse-event→ component handlers, bubble up - ScrollBox integration: wheel → scroll
- Text selection: drag highlight + clipboard copy
Contract
mouse-mixin— mixin class with:on-mouse-down/up/move/scrollslotshandle-mouse-event component event— dispatch to the right handlerhit-test root x y→ deepest component at (x,y)selection— highlighted text region (start-x, start-y, end-x, end-y)get-selection→ selected text as stringcopy-to-clipboard text→ pipe to xclip/wl-copy
Code
(defpackage :cl-tty.mouse
(:use :cl :cl-tty.input :cl-tty.box :cl-tty.rendering)
(:export
#:mouse-mixin
#:on-mouse-down #:on-mouse-up #:on-mouse-move #:on-mouse-scroll
#:handle-mouse-event
#:hit-test
#:selection #:get-selection #:copy-to-clipboard
#:make-selection #:selection-p
#:start-selection #:update-selection #:finalize-selection
#:selection-active-p
#:cell-link-at #:open-link-at))
(in-package :cl-tty.mouse)
(defclass mouse-mixin ()
((on-mouse-down :initarg :on-mouse-down :initform nil :accessor on-mouse-down)
(on-mouse-up :initarg :on-mouse-up :initform nil :accessor on-mouse-up)
(on-mouse-move :initarg :on-mouse-move :initform nil :accessor on-mouse-move)
(on-mouse-scroll :initarg :on-mouse-scroll :initform nil :accessor on-mouse-scroll)))
(defun handle-mouse-event (component event)
(let* ((type (mouse-event-type event))
(handler (case type
(:press (on-mouse-down component))
(:release (on-mouse-up component))
(:drag (on-mouse-move component))
(t nil))))
(when handler (funcall handler event))))
(defun hit-test (root x y)
"Find the deepest component at (X, Y) by testing layout-node bounds.
Recurses into component-children to find the innermost match.
Components without a layout-node or position return nil."
(labels ((recurse (node)
(let ((ln (ignore-errors (component-layout-node node)))
(best nil))
(when ln
(let ((nx (layout-node-x ln))
(ny (layout-node-y ln))
(nw (layout-node-width ln))
(nh (layout-node-height ln)))
;; Check children first for deeper match
(dolist (child (ignore-errors (component-children node)))
(let ((child-hit (recurse child)))
(when child-hit
(setf best child-hit))))
;; If no child matched, check self
(or best
(when (and (>= x nx) (< x (+ nx nw))
(>= y ny) (< y (+ ny nh)))
node)))))))
(recurse root)))
;; Selection
(defvar *selection* nil)
(defstruct (selection (:conc-name sel-))
(start-x 0) (start-y 0) (end-x 0) (end-y 0) (text ""))
(defun get-selection ()
(when *selection* (sel-text *selection*)))
(defun copy-to-clipboard (text)
#+linux (sb-ext:run-program "xclip" (list "-selection" "clipboard")
:input text :wait nil)
#+darwin (sb-ext:run-program "pbcopy" nil :input text :wait nil))
;;; --- Selection tracking (mouse drag) ---------------------------------------
(defvar *selection-active* nil
"T when a drag selection is in progress.")
(defvar *selection-start* nil
"Cons (X . Y) of mouse-down position during drag.")
(defvar *selection-end* nil
"Cons (X . Y) of current mouse position during drag.")
(defun start-selection (x y)
"Begin a drag selection at (X Y)."
(setf *selection-start* (cons x y)
*selection-end* (cons x y)
*selection-active* t))
(defun update-selection (x y)
"Update the drag selection end position to (X Y)."
(setf *selection-end* (cons x y)))
(defun selection-active-p ()
"Return T if a drag selection is in progress."
*selection-active*)
(defun finalize-selection (fb)
"End the drag selection and extract text from the framebuffer."
(setf *selection-active* nil)
(when (and *selection-start* *selection-end* fb)
(let* ((x1 (car *selection-start*))
(y1 (cdr *selection-start*))
(x2 (car *selection-end*))
(y2 (cdr *selection-end*))
(text (cl-tty.rendering:extract-text fb x1 y1 x2 y2)))
(setf *selection* (make-selection :start-x x1 :start-y y1
:end-x x2 :end-y y2
:text text))
(setf *selection-start* nil *selection-end* nil)
text)))
;;; --- Link clicking ---------------------------------------------------------
(defun cell-link-at (fb x y)
"Return the link URL at (X Y) in framebuffer FB, or nil."
(cl-tty.rendering:fb-cell-link-url fb x y))
(defun open-link-at (fb x y)
"If there is a link URL at (X Y) in FB, open it via xdg-open."
(let ((url (cell-link-at fb x y)))
(when url
#+linux (sb-ext:run-program "xdg-open" (list url) :wait nil)
#+darwin (sb-ext:run-program "open" (list url) :wait nil))
url))
(defpackage :cl-tty-mouse-test (:use :cl :cl-tty.mouse :fiveam))
(in-package :cl-tty-mouse-test)
(def-suite mouse-suite :description "Mouse tests")
(in-suite mouse-suite)
(def-test mouse-mixin-create ()
(let ((m (make-instance 'mouse-mixin)))
(is-true (typep m 'mouse-mixin))))
(def-test mouse-hit-test-point ()
"hit-test returns nil when no component has position slots bound"
(let ((obj (make-instance 'mouse-mixin)))
(is-false (hit-test obj 0 0))
(is-false (hit-test obj 100 100))))
(def-test selection-set-and-get ()
(setf cl-tty.mouse::*selection* (make-selection :text "hello"))
(is (equal "hello" (get-selection))))
;; ── Selection tracking ──────────────────────────────────────
(def-test start-selection-initializes-state ()
(start-selection 5 10)
(is-true (selection-active-p))
(is (equal '(5 . 10) cl-tty.mouse::*selection-start*))
(is (equal '(5 . 10) cl-tty.mouse::*selection-end*))
(setf cl-tty.mouse::*selection-active* nil
cl-tty.mouse::*selection-start* nil
cl-tty.mouse::*selection-end* nil))
(def-test update-selection-moves-end ()
(start-selection 0 0)
(update-selection 3 7)
(is (equal '(3 . 7) cl-tty.mouse::*selection-end*))
(setf cl-tty.mouse::*selection-active* nil
cl-tty.mouse::*selection-start* nil
cl-tty.mouse::*selection-end* nil))
(def-test finalize-selection-extracts-text ()
(let* ((fb-be (cl-tty.rendering:make-framebuffer-backend))
(fb (cl-tty.rendering:fb-framebuffer fb-be)))
(cl-tty.backend:draw-text fb-be 0 0 "hello" nil nil)
(cl-tty.backend:draw-text fb-be 0 1 "world" nil nil)
(start-selection 0 0)
(update-selection 4 1)
(let ((text (finalize-selection fb)))
(is (equal "hello
world" text)))))