#+TITLE: Mouse Support (v0.10.0) #+DATE: 2026-05-11 #+AUTHOR: Amr Gharbeia / Hermes * Overview Mouse event propagation through the component tree. The input system already parses SGR mouse sequences into ~mouse-event~ structs. This module adds: 1. A ~mouse-mixin~ class with event handler slots 2. Hit-testing: given (x,y), find the deepest component owning that cell 3. Event dispatch: route ~mouse-event~ → component handlers, bubble up 4. ScrollBox integration: wheel → scroll 5. Text selection: drag highlight + clipboard copy ** Contract - ~mouse-mixin~ — mixin class with ~:on-mouse-down/up/move/scroll~ slots - ~handle-mouse-event component event~ — dispatch to the right handler - ~hit-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 string - ~copy-to-clipboard text~ → pipe to xclip/wl-copy ** Code #+BEGIN_SRC lisp :tangle ../src/components/mouse-package.lisp :noweb no (defpackage :cl-tty.mouse (:use :cl :cl-tty.layout :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)) #+END_SRC #+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no (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*))) #+END_SRC *** Bug Fixes (v1.0.0): Wayland clipboard support ~copy-to-clipboard~ only called ~xclip~, which fails silently on Wayland sessions (where ~xclip~ is often unavailable or requires XWayland). Fix: Check the ~WAYLAND_DISPLAY~ environment variable. If set, use ~wl-copy~ instead of ~xclip~. Fall back to ~xclip~ for traditional X11 sessions. #+BEGIN_SRC lisp :tangle ../src/components/mouse.lisp :noweb no (defun copy-to-clipboard (text) #+linux (cond ((sb-ext:posix-getenv "WAYLAND_DISPLAY") (sb-ext:run-program "wl-copy" nil :input text :wait nil)) (t (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)) #+END_SRC #+BEGIN_SRC lisp :tangle ../tests/mouse-tests.lisp :noweb no (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))))) #+END_SRC