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}