Files
cl-tty/org/mouse.org
Hermes abf8e5cdeb Backport round-2 fixes to org source files
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.
2026-05-12 01:00:17 +00:00

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:

  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

(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)))))