beamterm_renderer/
mouse.rs

1//! Mouse input handling for the beamterm terminal renderer.
2//!
3//! This module provides mouse event handling infrastructure for the terminal,
4//! including coordinate conversion from pixel space to terminal grid space,
5//! text selection with automatic clipboard integration, and customizable
6//! event handling.
7//!
8//! # Architecture
9//!
10//! The mouse handling system consists of:
11//! - [`TerminalMouseHandler`] - Main event handler that attaches to a canvas
12//! - [`TerminalMouseEvent`] - Mouse events translated to terminal coordinates
13//! - [`DefaultSelectionHandler`] - Built-in text selection implementation
14//! - Internal state tracking for selection operations
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use beamterm_renderer::{Terminal, SelectionMode};
20//!
21//! // Enable default selection handler
22//! let terminal = Terminal::builder("#canvas")
23//!     .default_mouse_input_handler(SelectionMode::Linear, true)
24//!     .build().unwrap();
25//!
26//! // Or provide custom mouse handling
27//! let terminal = Terminal::builder("#canvas")
28//!     .mouse_input_handler(|event, grid| {
29//!         println!("Mouse event at ({}, {})", event.col, event.row);
30//!     })
31//!     .build().unwrap();
32//! ```
33
34use std::{
35    cell::RefCell,
36    fmt::{Debug, Formatter},
37    rc::Rc,
38};
39
40use compact_str::CompactString;
41use wasm_bindgen::{closure::Closure, JsCast};
42use wasm_bindgen_futures::spawn_local;
43use web_sys::console;
44
45use crate::{
46    gl::{SelectionTracker, TerminalDimensions},
47    select, Error, SelectionMode, TerminalGrid,
48};
49
50/// Type alias for boxed mouse event callback functions.
51///
52/// Callbacks are invoked synchronously in the browser's event loop
53pub type MouseEventCallback = Box<dyn FnMut(TerminalMouseEvent, &TerminalGrid) + 'static>;
54
55/// Internal type for shared event handler wrapped in Rc<RefCell>.
56type EventHandler = Rc<RefCell<dyn FnMut(TerminalMouseEvent, &TerminalGrid) + 'static>>;
57
58/// Handles mouse input events for a terminal grid.
59///
60/// Converts browser mouse events into terminal grid coordinates and manages
61/// event handlers for mouse interactions. Maintains terminal dimensions for
62/// accurate coordinate mapping.
63///
64pub struct TerminalMouseHandler {
65    /// The canvas element this handler is attached to.
66    canvas: web_sys::HtmlCanvasElement,
67    /// Closure for mousedown events.
68    on_mouse_down: Closure<dyn FnMut(web_sys::MouseEvent)>,
69    /// Closure for mouseup events.
70    on_mouse_up: Closure<dyn FnMut(web_sys::MouseEvent)>,
71    /// Closure for mousemove events.
72    on_mouse_move: Closure<dyn FnMut(web_sys::MouseEvent)>,
73    /// Cached terminal dimensions for coordinate conversion.
74    terminal_dimensions: crate::gl::TerminalDimensions,
75    /// Optional default selection handler.
76    pub(crate) default_input_handler: Option<DefaultSelectionHandler>,
77}
78
79/// Mouse event data with terminal cell coordinates.
80///
81/// Represents a mouse event translated from pixel coordinates to terminal
82/// grid coordinates, including modifier key states.`col` and `row` are 0-based
83/// terminal grid coordinates
84#[derive(Debug, Clone, Copy)]
85pub struct TerminalMouseEvent {
86    /// Type of mouse event (down, up, or move).
87    pub event_type: MouseEventType,
88    /// Column in the terminal grid (0-based).
89    pub col: u16,
90    /// Row in the terminal grid (0-based).
91    pub row: u16,
92    /// Mouse button pressed (0 = left, 1 = middle, 2 = right).
93    button: i16,
94    /// Whether Ctrl key was pressed during the event.
95    ctrl_key: bool,
96    /// Whether Shift key was pressed during the event.
97    shift_key: bool,
98    /// Whether Alt key was pressed during the event.
99    alt_key: bool,
100}
101
102impl TerminalMouseEvent {
103    /// Returns the mouse button pressed during the event.
104    pub fn button(&self) -> i16 {
105        self.button
106    }
107
108    /// Creates a new mouse event with the given parameters.
109    pub fn ctrl_key(&self) -> bool {
110        self.ctrl_key
111    }
112
113    /// Returns whether the Ctrl key was pressed during the event.
114    pub fn shift_key(&self) -> bool {
115        self.shift_key
116    }
117
118    /// Returns whether the Shift key was pressed during the event.
119    pub fn alt_key(&self) -> bool {
120        self.alt_key
121    }
122}
123
124/// Types of mouse events that can occur.
125#[derive(Debug, Clone, Copy, PartialEq)]
126#[repr(u8)]
127pub enum MouseEventType {
128    /// Mouse button was pressed.
129    MouseDown = 0,
130    /// Mouse button was released.
131    MouseUp = 1,
132    /// Mouse moved while over the terminal.
133    MouseMove = 2,
134}
135
136impl TerminalMouseHandler {
137    /// Creates a new mouse handler for the given canvas and terminal grid.
138    ///
139    /// Sets up mouse event listeners on the canvas and converts pixel coordinates
140    /// to terminal cell coordinates before invoking the provided event handler.
141    ///
142    /// # Arguments
143    /// * `canvas` - The HTML canvas element to attach mouse listeners to
144    /// * `grid` - The terminal grid for coordinate calculations
145    /// * `event_handler` - Callback invoked for each mouse event
146    ///
147    /// # Errors
148    /// Returns `Error::Callback` if event listeners cannot be attached to the canvas.
149    ///
150    /// # Example
151    /// ```rust,no_run
152    /// use beamterm_renderer::mouse::TerminalMouseHandler;
153    /// use std::{cell::RefCell, rc::Rc};
154    ///
155    /// let canvas = unimplemented!("canvas");
156    /// let grid: Rc<RefCell<()>> = unimplemented!("TerminalGrid");
157    ///
158    /// // In real code, this would be TerminalGrid
159    /// // let handler = TerminalMouseHandler::new(
160    /// //     &canvas,
161    /// //     grid.clone(),
162    /// //     |event, grid| {
163    /// //         println!("Click at ({}, {})", event.col, event.row);
164    /// //     }
165    /// // ).unwrap();
166    /// ```
167    pub fn new<F>(
168        canvas: &web_sys::HtmlCanvasElement,
169        grid: Rc<RefCell<TerminalGrid>>,
170        event_handler: F,
171    ) -> Result<Self, Error>
172    where
173        F: FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
174    {
175        Self::new_internal(canvas, grid, Box::new(event_handler))
176    }
177
178    /// Internal constructor that accepts a boxed event handler.
179    ///
180    /// # Implementation Details
181    /// - Wraps handler in Rc<RefCell> for sharing between event closures
182    /// - Caches terminal dimensions for fast coordinate conversion
183    /// - Creates three closures (one per event type) that share the handler
184    fn new_internal(
185        canvas: &web_sys::HtmlCanvasElement,
186        grid: Rc<RefCell<TerminalGrid>>,
187        event_handler: MouseEventCallback,
188    ) -> Result<Self, Error> {
189        // Wrap the handler in Rc<RefCell> for sharing between closures
190        let shared_handler = Rc::new(RefCell::new(event_handler));
191
192        // Get grid metrics for coordinate conversion
193        let (cell_width, cell_height) = grid.borrow().cell_size();
194        let (cols, rows) = grid.borrow().terminal_size();
195        let terminal_dimensions = TerminalDimensions::new(cols, rows);
196
197        // Create pixel-to-cell coordinate converter
198        let dimensions_ref = terminal_dimensions.clone_ref();
199        let pixel_to_cell = move |event: &web_sys::MouseEvent| -> Option<(u16, u16)> {
200            let x = event.offset_x() as f32;
201            let y = event.offset_y() as f32;
202
203            let col = (x / cell_width as f32).floor() as u16;
204            let row = (y / cell_height as f32).floor() as u16;
205
206            let (max_cols, max_rows) = *dimensions_ref.borrow();
207            if col < max_cols && row < max_rows {
208                Some((col, row))
209            } else {
210                None
211            }
212        };
213
214        // Create event handlers
215        use MouseEventType::*;
216        let on_mouse_down = create_mouse_event_closure(
217            MouseDown,
218            grid.clone(),
219            shared_handler.clone(),
220            pixel_to_cell.clone(),
221        );
222        let on_mouse_up = create_mouse_event_closure(
223            MouseUp,
224            grid.clone(),
225            shared_handler.clone(),
226            pixel_to_cell.clone(),
227        );
228        let on_mouse_move =
229            create_mouse_event_closure(MouseMove, grid.clone(), shared_handler, pixel_to_cell);
230
231        // Attach event listeners
232        canvas
233            .add_event_listener_with_callback("mousedown", on_mouse_down.as_ref().unchecked_ref())
234            .map_err(|_| Error::Callback("Failed to add mousedown listener".into()))?;
235        canvas
236            .add_event_listener_with_callback("mouseup", on_mouse_up.as_ref().unchecked_ref())
237            .map_err(|_| Error::Callback("Failed to add mouseup listener".into()))?;
238        canvas
239            .add_event_listener_with_callback("mousemove", on_mouse_move.as_ref().unchecked_ref())
240            .map_err(|_| Error::Callback("Failed to add mousemove listener".into()))?;
241
242        Ok(Self {
243            canvas: canvas.clone(),
244            on_mouse_down,
245            on_mouse_up,
246            on_mouse_move,
247            terminal_dimensions,
248            default_input_handler: None,
249        })
250    }
251
252    /// Removes all event listeners from the canvas.
253    ///
254    /// Called automatically on drop. Safe to call multiple times.
255    pub fn cleanup(&self) {
256        let _ = self.canvas.remove_event_listener_with_callback(
257            "mousedown",
258            self.on_mouse_down.as_ref().unchecked_ref(),
259        );
260        let _ = self.canvas.remove_event_listener_with_callback(
261            "mouseup",
262            self.on_mouse_up.as_ref().unchecked_ref(),
263        );
264        let _ = self.canvas.remove_event_listener_with_callback(
265            "mousemove",
266            self.on_mouse_move.as_ref().unchecked_ref(),
267        );
268    }
269
270    /// Updates the cached terminal dimensions.
271    ///
272    /// Should be called when the terminal is resized to ensure accurate
273    /// coordinate conversion.
274    ///
275    /// # Arguments
276    /// * `cols` - New column count
277    /// * `rows` - New row count
278    pub fn update_dimensions(&mut self, cols: u16, rows: u16) {
279        self.terminal_dimensions.set(cols, rows);
280    }
281}
282
283/// Default mouse selection handler with clipboard integration.
284///
285/// Provides text selection functionality with automatic clipboard copying
286/// on selection completion. Supports both linear (text flow) and block
287/// (rectangular) selection modes.
288///
289/// # Features
290/// - Click and drag to select text
291/// - Automatic clipboard copy on mouse release
292/// - Configurable selection modes (Linear/Block)
293/// - Optional trailing whitespace trimming
294pub(crate) struct DefaultSelectionHandler {
295    /// Current selection state machine.
296    selection_state: Rc<RefCell<SelectionState>>,
297    /// Terminal grid reference for text extraction.
298    grid: Rc<RefCell<TerminalGrid>>,
299    /// Selection mode (Linear or Block).
300    query_mode: SelectionMode,
301    /// Whether to trim trailing whitespace from selections.
302    trim_trailing_whitespace: bool,
303}
304
305impl DefaultSelectionHandler {
306    /// Creates a new selection handler for the given terminal grid.
307    ///
308    /// # Arguments
309    /// * `grid` - Terminal grid for text extraction
310    /// * `query_mode` - Selection mode (Linear follows text flow, Block is rectangular)
311    /// * `trim_trailing_whitespace` - Whether to remove trailing spaces from selected text
312    pub(crate) fn new(
313        grid: Rc<RefCell<TerminalGrid>>,
314        query_mode: SelectionMode,
315        trim_trailing_whitespace: bool,
316    ) -> Self {
317        Self {
318            grid,
319            selection_state: Rc::new(RefCell::new(SelectionState::Idle)),
320            query_mode,
321            trim_trailing_whitespace,
322        }
323    }
324
325    /// Creates the mouse event handler closure for this selection handler.
326    ///
327    /// Returns a boxed closure that handles mouse events, tracks selection state,
328    /// and copies selected text to the clipboard on completion.
329    ///
330    /// # Arguments
331    /// * `active_selection` - Selection tracker for visual feedback
332    ///
333    /// # Algorithm
334    /// 1. MouseDown: Begin new selection or replace existing
335    /// 2. MouseMove: Update selection end point if selecting
336    /// 3. MouseUp: Complete selection and copy to clipboard
337    ///
338    /// Repeated single-cell clicks cancel selection rather than selecting one cell.
339    pub fn create_event_handler(&self, active_selection: SelectionTracker) -> MouseEventCallback {
340        let selection_state = self.selection_state.clone();
341        let query_mode = self.query_mode;
342        let trim_trailing = self.trim_trailing_whitespace;
343
344        Box::new(move |event: TerminalMouseEvent, grid: &TerminalGrid| {
345            let mut state = selection_state.borrow_mut();
346
347            // update mouse selection state based on the event type.
348            match event.event_type {
349                MouseEventType::MouseDown if event.button == 0 => {
350                    // note: if there's an existing selection in progress, it
351                    // means that the cursor left the terminal (canvas) area
352                    // while a previous selection was ongoing. if so, we do
353                    // nothing and await the MouseUp event.
354
355                    // mouse down always begins a new *potential* selection
356                    if state.is_complete() {
357                        // the existing (completed) selection is replaced with
358                        // a new selection which will be canceled if the mouse
359                        // up event is fired on the same cell.
360                        state.maybe_selecting(event.col, event.row);
361                    } else if state.is_idle() {
362                        // begins a new selection from a blank state
363                        state.begin_selection(event.col, event.row);
364                    }
365
366                    let query = select(query_mode)
367                        .start((event.col, event.row))
368                        .trim_trailing_whitespace(trim_trailing);
369
370                    active_selection.set_query(query);
371                },
372                MouseEventType::MouseMove if state.is_selecting() => {
373                    state.update_selection(event.col, event.row);
374                    active_selection.update_selection_end((event.col, event.row));
375                },
376                MouseEventType::MouseUp if event.button == 0 => {
377                    // at this point, we're either at:
378                    // a) the user has finished making the selection
379                    // b) the selection was canceled by a click inside a single cell
380                    if let Some((_start, _end)) = state.complete_selection(event.col, event.row) {
381                        active_selection.update_selection_end((event.col, event.row));
382                        let selected_text = grid.get_text(active_selection.query());
383                        copy_to_clipboard(selected_text);
384                    } else {
385                        state.clear();
386                        active_selection.clear();
387                    }
388                },
389                _ => {}, // ignore non-left button events
390            }
391        })
392    }
393}
394
395/// Internal state machine for tracking mouse selection operations.
396///
397/// Manages the lifecycle of a selection from initial click through dragging
398/// to final release. Handles edge cases like single-cell clicks that should
399/// cancel rather than select.
400///
401/// # State Transitions
402/// ```text
403///        ┌──────────┐
404///    ┌──▶│   Idle   │
405///    │   └────┬─────┘
406///    │        │ begin_selection
407///    │        ▼
408///    │   ┌──────────┐
409///    │   │Selecting │◀────────────┐
410///    │   └────┬─────┘             │
411///    │        │ complete_selection│
412///    │        ▼                   │
413///    │   ┌──────────┐             │
414///    │   │ Complete │             │
415///    │   └────┬─────┘             │
416///    │        │ maybe_selecting   │
417///    │        ▼                   │
418///    │   ┌──────────────┐         │
419///    └───│MaybeSelecting│─────────┘
420/// mouse  └──────────────┘  update_selection
421///   up       
422///          
423/// ```
424#[derive(Debug, Clone, PartialEq, Eq)]
425enum SelectionState {
426    /// No selection in progress.
427    Idle,
428    /// Active selection with start point and current cursor position.
429    Selecting { start: (u16, u16), current: Option<(u16, u16)> },
430    /// MouseDown in a cell while selection exists.
431    /// Will become Selecting on MouseMove or Idle on MouseUp.
432    MaybeSelecting { start: (u16, u16) },
433    /// Selection completed, contains start and end coordinates.
434    Complete { start: (u16, u16), end: (u16, u16) },
435}
436
437impl SelectionState {
438    /// Begins a new selection from idle state.
439    ///
440    /// # Panics
441    /// Panics if called when not in Idle state (debug only).
442    fn begin_selection(&mut self, col: u16, row: u16) {
443        debug_assert!(matches!(self, SelectionState::Idle));
444        *self = SelectionState::Selecting { start: (col, row), current: None };
445    }
446
447    /// Updates selection end point during drag.
448    ///
449    /// Transitions MaybeSelecting to Selecting if needed.
450    fn update_selection(&mut self, col: u16, row: u16) {
451        use SelectionState::*;
452
453        match self {
454            Selecting { current, .. } => {
455                *current = Some((col, row));
456            },
457            MaybeSelecting { start } => {
458                if (col, row) != *start {
459                    *self = Selecting { start: *start, current: Some((col, row)) };
460                }
461            },
462            _ => {},
463        }
464    }
465
466    /// Completes the selection on mouse release.
467    ///
468    /// # Returns
469    /// - `Some((start, end))` if selection completed
470    /// - `None` if selection was canceled (single cell click)
471    fn complete_selection(&mut self, col: u16, row: u16) -> Option<((u16, u16), (u16, u16))> {
472        match self {
473            SelectionState::Selecting { start, .. } => {
474                let result = Some((*start, (col, row)));
475                *self = SelectionState::Complete { start: *start, end: (col, row) };
476                result
477            },
478            _ => None,
479        }
480    }
481
482    /// Clears the selection state back to idle.
483    fn clear(&mut self) {
484        *self = SelectionState::Idle;
485    }
486
487    /// Checks if currently in selecting state.
488    fn is_selecting(&self) -> bool {
489        matches!(
490            self,
491            SelectionState::Selecting { .. } | SelectionState::MaybeSelecting { .. }
492        )
493    }
494
495    fn is_idle(&self) -> bool {
496        matches!(self, SelectionState::Idle)
497    }
498
499    /// Begins potential new selection while one exists.
500    ///
501    /// Used when clicking while a selection is complete.
502    fn maybe_selecting(&mut self, col: u16, row: u16) {
503        *self = SelectionState::MaybeSelecting { start: (col, row) };
504    }
505
506    /// Checks if a selection has been completed.
507    fn is_complete(&self) -> bool {
508        matches!(self, SelectionState::Complete { .. })
509    }
510}
511
512/// Creates a closure that handles browser mouse events and converts them to terminal events.
513///
514/// Wraps the event handler with coordinate conversion and terminal event creation logic.
515fn create_mouse_event_closure(
516    event_type: MouseEventType,
517    grid: Rc<RefCell<TerminalGrid>>,
518    event_handler: EventHandler,
519    pixel_to_cell: impl Fn(&web_sys::MouseEvent) -> Option<(u16, u16)> + 'static,
520) -> Closure<dyn FnMut(web_sys::MouseEvent)> {
521    Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
522        if let Some((col, row)) = pixel_to_cell(&event) {
523            let terminal_event = TerminalMouseEvent {
524                event_type,
525                col,
526                row,
527                button: event.button(),
528                ctrl_key: event.ctrl_key(),
529                shift_key: event.shift_key(),
530                alt_key: event.alt_key(),
531            };
532            let grid_ref = grid.borrow();
533            event_handler.borrow_mut()(terminal_event, &grid_ref);
534        }
535    }) as Box<dyn FnMut(_)>)
536}
537
538/// Copies text to the system clipboard using the browser's async clipboard API.
539///
540/// Spawns an async task to handle the clipboard write operation. Logs success
541/// or failure to the console.
542///
543/// # Security
544/// Browser may require user gesture or HTTPS for clipboard access.
545fn copy_to_clipboard(text: CompactString) {
546    spawn_local(async move {
547        if let Some(window) = web_sys::window() {
548            let clipboard = window.navigator().clipboard();
549            match wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
550                Ok(_) => {},
551                Err(err) => {
552                    console::error_1(&format!("Failed to copy to clipboard: {err:?}").into());
553                },
554            }
555        }
556    });
557}
558
559impl Drop for TerminalMouseHandler {
560    /// Automatically removes event listeners when handler is dropped.
561    fn drop(&mut self) {
562        self.cleanup();
563    }
564}
565
566impl Debug for TerminalMouseHandler {
567    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
568        let (cols, rows) = self.terminal_dimensions.get();
569        write!(f, "TerminalMouseHandler {{ dimensions: {cols}x{rows} }}")
570    }
571}
572
573impl Debug for DefaultSelectionHandler {
574    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
575        let (cols, rows) = self.grid.borrow().terminal_size();
576        write!(
577            f,
578            "DefaultSelectionHandler {{ mode: {:?}, trim_whitespace: {}, grid: {}x{} }}",
579            self.query_mode, self.trim_trailing_whitespace, cols, rows
580        )
581    }
582}