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
380 // hash the selected content and store it with the query;
381 // this allows us to clear the selection if the content changes
382 let query = active_selection.query();
383 active_selection.set_content_hash(grid.hash_cells(query));
384
385 let selected_text = grid.get_text(active_selection.query());
386 copy_to_clipboard(selected_text);
387 } else {
388 state.clear();
389 active_selection.clear();
390 }
391 },
392 _ => {}, // ignore non-left button events
393 }
394 })
395 }
396}
397
398/// Internal state machine for tracking mouse selection operations.
399///
400/// Manages the lifecycle of a selection from initial click through dragging
401/// to final release. Handles edge cases like single-cell clicks that should
402/// cancel rather than select.
403///
404/// # State Transitions
405/// ```text
406/// ┌──────────┐
407/// ┌──▶│ Idle │
408/// │ └────┬─────┘
409/// │ │ begin_selection
410/// │ ▼
411/// │ ┌──────────┐
412/// │ │Selecting │◀────────────┐
413/// │ └────┬─────┘ │
414/// │ │ complete_selection│
415/// │ ▼ │
416/// │ ┌──────────┐ │
417/// │ │ Complete │ │
418/// │ └────┬─────┘ │
419/// │ │ maybe_selecting │
420/// │ ▼ │
421/// │ ┌──────────────┐ │
422/// └───│MaybeSelecting│─────────┘
423/// mouse └──────────────┘ update_selection
424/// up
425///
426/// ```
427#[derive(Debug, Clone, PartialEq, Eq)]
428enum SelectionState {
429 /// No selection in progress.
430 Idle,
431 /// Active selection with start point and current cursor position.
432 Selecting { start: (u16, u16), current: Option<(u16, u16)> },
433 /// MouseDown in a cell while selection exists.
434 /// Will become Selecting on MouseMove or Idle on MouseUp.
435 MaybeSelecting { start: (u16, u16) },
436 /// Selection completed, contains start and end coordinates.
437 Complete { start: (u16, u16), end: (u16, u16) },
438}
439
440impl SelectionState {
441 /// Begins a new selection from idle state.
442 ///
443 /// # Panics
444 /// Panics if called when not in Idle state (debug only).
445 fn begin_selection(&mut self, col: u16, row: u16) {
446 debug_assert!(matches!(self, SelectionState::Idle));
447 *self = SelectionState::Selecting { start: (col, row), current: None };
448 }
449
450 /// Updates selection end point during drag.
451 ///
452 /// Transitions MaybeSelecting to Selecting if needed.
453 fn update_selection(&mut self, col: u16, row: u16) {
454 use SelectionState::*;
455
456 match self {
457 Selecting { current, .. } => {
458 *current = Some((col, row));
459 },
460 MaybeSelecting { start } => {
461 if (col, row) != *start {
462 *self = Selecting { start: *start, current: Some((col, row)) };
463 }
464 },
465 _ => {},
466 }
467 }
468
469 /// Completes the selection on mouse release.
470 ///
471 /// # Returns
472 /// - `Some((start, end))` if selection completed
473 /// - `None` if selection was canceled (single cell click)
474 fn complete_selection(&mut self, col: u16, row: u16) -> Option<((u16, u16), (u16, u16))> {
475 match self {
476 SelectionState::Selecting { start, .. } => {
477 let result = Some((*start, (col, row)));
478 *self = SelectionState::Complete { start: *start, end: (col, row) };
479 result
480 },
481 _ => None,
482 }
483 }
484
485 /// Clears the selection state back to idle.
486 fn clear(&mut self) {
487 *self = SelectionState::Idle;
488 }
489
490 /// Checks if currently in selecting state.
491 fn is_selecting(&self) -> bool {
492 matches!(
493 self,
494 SelectionState::Selecting { .. } | SelectionState::MaybeSelecting { .. }
495 )
496 }
497
498 fn is_idle(&self) -> bool {
499 matches!(self, SelectionState::Idle)
500 }
501
502 /// Begins potential new selection while one exists.
503 ///
504 /// Used when clicking while a selection is complete.
505 fn maybe_selecting(&mut self, col: u16, row: u16) {
506 *self = SelectionState::MaybeSelecting { start: (col, row) };
507 }
508
509 /// Checks if a selection has been completed.
510 fn is_complete(&self) -> bool {
511 matches!(self, SelectionState::Complete { .. })
512 }
513}
514
515/// Creates a closure that handles browser mouse events and converts them to terminal events.
516///
517/// Wraps the event handler with coordinate conversion and terminal event creation logic.
518fn create_mouse_event_closure(
519 event_type: MouseEventType,
520 grid: Rc<RefCell<TerminalGrid>>,
521 event_handler: EventHandler,
522 pixel_to_cell: impl Fn(&web_sys::MouseEvent) -> Option<(u16, u16)> + 'static,
523) -> Closure<dyn FnMut(web_sys::MouseEvent)> {
524 Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
525 if let Some((col, row)) = pixel_to_cell(&event) {
526 let terminal_event = TerminalMouseEvent {
527 event_type,
528 col,
529 row,
530 button: event.button(),
531 ctrl_key: event.ctrl_key(),
532 shift_key: event.shift_key(),
533 alt_key: event.alt_key(),
534 };
535 let grid_ref = grid.borrow();
536 event_handler.borrow_mut()(terminal_event, &grid_ref);
537 }
538 }) as Box<dyn FnMut(_)>)
539}
540
541/// Copies text to the system clipboard using the browser's async clipboard API.
542///
543/// Spawns an async task to handle the clipboard write operation. Logs success
544/// or failure to the console.
545///
546/// # Security
547/// Browser may require user gesture or HTTPS for clipboard access.
548fn copy_to_clipboard(text: CompactString) {
549 spawn_local(async move {
550 if let Some(window) = web_sys::window() {
551 let clipboard = window.navigator().clipboard();
552 match wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
553 Ok(_) => {},
554 Err(err) => {
555 console::error_1(&format!("Failed to copy to clipboard: {err:?}").into());
556 },
557 }
558 }
559 });
560}
561
562impl Drop for TerminalMouseHandler {
563 /// Automatically removes event listeners when handler is dropped.
564 fn drop(&mut self) {
565 self.cleanup();
566 }
567}
568
569impl Debug for TerminalMouseHandler {
570 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
571 let (cols, rows) = self.terminal_dimensions.get();
572 write!(f, "TerminalMouseHandler {{ dimensions: {cols}x{rows} }}")
573 }
574}
575
576impl Debug for DefaultSelectionHandler {
577 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
578 let (cols, rows) = self.grid.borrow().terminal_size();
579 write!(
580 f,
581 "DefaultSelectionHandler {{ mode: {:?}, trim_whitespace: {}, grid: {}x{} }}",
582 self.query_mode, self.trim_trailing_whitespace, cols, rows
583 )
584 }
585}