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