beamterm_renderer/
mouse.rs

1use std::{
2    cell::RefCell,
3    fmt::{Debug, Formatter},
4    rc::Rc,
5};
6
7use compact_str::CompactString;
8use wasm_bindgen::{closure::Closure, JsCast};
9use wasm_bindgen_futures::spawn_local;
10use web_sys::console;
11
12use crate::{
13    gl::{SelectionTracker, TerminalDimensions},
14    select, Error, SelectionMode, TerminalGrid,
15};
16
17pub(super) type MouseEventCallback = Box<dyn FnMut(TerminalMouseEvent, &TerminalGrid) + 'static>;
18type EventHandler = Rc<RefCell<dyn FnMut(TerminalMouseEvent, &TerminalGrid) + 'static>>;
19
20/// Handles mouse input events for a terminal grid.
21///
22/// Converts browser mouse events into terminal grid coordinates and manages
23/// event handlers for mouse interactions. Maintains terminal dimensions for
24/// accurate coordinate mapping.
25pub struct TerminalMouseHandler {
26    canvas: web_sys::HtmlCanvasElement,
27    on_mouse_down: Closure<dyn FnMut(web_sys::MouseEvent)>,
28    on_mouse_up: Closure<dyn FnMut(web_sys::MouseEvent)>,
29    on_mouse_move: Closure<dyn FnMut(web_sys::MouseEvent)>,
30    terminal_dimensions: crate::gl::TerminalDimensions,
31    pub(crate) default_input_handler: Option<DefaultSelectionHandler>,
32}
33
34/// Mouse event data with terminal cell coordinates.
35///
36/// Represents a mouse event translated from pixel coordinates to terminal
37/// grid coordinates, including modifier key states.
38#[derive(Debug, Clone, Copy)]
39pub struct TerminalMouseEvent {
40    /// Type of mouse event (down, up, or move).
41    pub event_type: MouseEventType,
42    /// Column in the terminal grid (0-based).
43    pub col: u16,
44    /// Row in the terminal grid (0-based).
45    pub row: u16,
46    /// Mouse button pressed (0 = left, 1 = middle, 2 = right).
47    pub button: i16,
48    /// Whether Ctrl key was pressed during the event.
49    pub ctrl_key: bool,
50    /// Whether Shift key was pressed during the event.
51    pub shift_key: bool,
52    /// Whether Alt key was pressed during the event.
53    pub alt_key: bool,
54}
55
56/// Types of mouse events that can occur.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum MouseEventType {
59    /// Mouse button was pressed.
60    MouseDown,
61    /// Mouse button was released.
62    MouseUp,
63    /// Mouse moved while over the terminal.
64    MouseMove,
65}
66
67impl TerminalMouseHandler {
68    /// Creates a new mouse handler for the given canvas and terminal grid.
69    ///
70    /// Sets up mouse event listeners on the canvas and converts pixel coordinates
71    /// to terminal cell coordinates before invoking the provided event handler.
72    ///
73    /// # Arguments
74    /// * `canvas` - The HTML canvas element to attach mouse listeners to
75    /// * `grid` - The terminal grid for coordinate calculations
76    /// * `event_handler` - Callback invoked for each mouse event
77    ///
78    /// # Errors
79    /// Returns an error if event listeners cannot be attached to the canvas.
80    pub(crate) fn new<F>(
81        canvas: &web_sys::HtmlCanvasElement,
82        grid: Rc<RefCell<TerminalGrid>>,
83        event_handler: F,
84    ) -> Result<Self, Error>
85    where
86        F: FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
87    {
88        Self::new_internal(canvas, grid, Box::new(event_handler))
89    }
90
91    fn new_internal(
92        canvas: &web_sys::HtmlCanvasElement,
93        grid: Rc<RefCell<TerminalGrid>>,
94        event_handler: MouseEventCallback,
95    ) -> Result<Self, Error> {
96        // Wrap the handler in Rc<RefCell> for sharing between closures
97        let shared_handler = Rc::new(RefCell::new(event_handler));
98
99        // Get grid metrics for coordinate conversion
100        let (cell_width, cell_height) = grid.borrow().cell_size();
101        let (cols, rows) = grid.borrow().terminal_size();
102        let terminal_dimensions = TerminalDimensions::new(cols, rows);
103
104        // Create pixel-to-cell coordinate converter
105        let dimensions_ref = terminal_dimensions.clone_ref();
106        let pixel_to_cell = move |event: &web_sys::MouseEvent| -> Option<(u16, u16)> {
107            let x = event.offset_x() as f32;
108            let y = event.offset_y() as f32;
109
110            let col = (x / cell_width as f32).floor() as u16;
111            let row = (y / cell_height as f32).floor() as u16;
112
113            let (max_cols, max_rows) = *dimensions_ref.borrow();
114            if col < max_cols && row < max_rows {
115                Some((col, row))
116            } else {
117                None
118            }
119        };
120
121        // Create event handlers
122        use MouseEventType::*;
123        let on_mouse_down = create_mouse_event_closure(
124            MouseDown,
125            grid.clone(),
126            shared_handler.clone(),
127            pixel_to_cell.clone(),
128        );
129        let on_mouse_up = create_mouse_event_closure(
130            MouseUp,
131            grid.clone(),
132            shared_handler.clone(),
133            pixel_to_cell.clone(),
134        );
135        let on_mouse_move =
136            create_mouse_event_closure(MouseMove, grid.clone(), shared_handler, pixel_to_cell);
137
138        // Attach event listeners
139        canvas
140            .add_event_listener_with_callback("mousedown", on_mouse_down.as_ref().unchecked_ref())
141            .map_err(|_| Error::Callback("Failed to add mousedown listener".into()))?;
142        canvas
143            .add_event_listener_with_callback("mouseup", on_mouse_up.as_ref().unchecked_ref())
144            .map_err(|_| Error::Callback("Failed to add mouseup listener".into()))?;
145        canvas
146            .add_event_listener_with_callback("mousemove", on_mouse_move.as_ref().unchecked_ref())
147            .map_err(|_| Error::Callback("Failed to add mousemove listener".into()))?;
148
149        Ok(Self {
150            canvas: canvas.clone(),
151            on_mouse_down,
152            on_mouse_up,
153            on_mouse_move,
154            terminal_dimensions,
155            default_input_handler: None,
156        })
157    }
158
159    /// Updates the terminal dimensions after a resize.
160    ///
161    /// Must be called when the terminal grid is resized to ensure accurate
162    /// coordinate conversion from pixels to cells.
163    pub(crate) fn update_dimensions(&self, cols: u16, rows: u16) {
164        self.terminal_dimensions.set(cols, rows);
165    }
166
167    /// Removes all event listeners from the canvas.
168    ///
169    /// This should be called before dropping the handler to prevent memory leaks
170    /// and conflicts with new handlers.
171    pub(crate) fn cleanup(&self) {
172        let _ = self.canvas.remove_event_listener_with_callback(
173            "mousedown",
174            self.on_mouse_down.as_ref().unchecked_ref(),
175        );
176        let _ = self.canvas.remove_event_listener_with_callback(
177            "mouseup",
178            self.on_mouse_up.as_ref().unchecked_ref(),
179        );
180        let _ = self.canvas.remove_event_listener_with_callback(
181            "mousemove",
182            self.on_mouse_move.as_ref().unchecked_ref(),
183        );
184    }
185}
186
187/// Default handler for mouse-based text selection and clipboard operations.
188///
189/// Implements standard terminal selection behavior: click and drag to select text,
190/// automatic clipboard copy on selection completion. Supports both block and
191/// linear selection modes.
192pub(crate) struct DefaultSelectionHandler {
193    selection_state: Rc<RefCell<SelectionState>>,
194    grid: Rc<RefCell<TerminalGrid>>,
195    query_mode: SelectionMode,
196    trim_trailing_whitespace: bool,
197}
198
199impl DefaultSelectionHandler {
200    /// Creates a new selection handler.
201    ///
202    /// # Arguments
203    /// * `grid` - The terminal grid to select from
204    /// * `query_mode` - Selection mode (block or linear)
205    /// * `trim_trailing_whitespace` - Whether to trim whitespace from selected lines
206    pub(crate) fn new(
207        grid: Rc<RefCell<TerminalGrid>>,
208        query_mode: SelectionMode,
209        trim_trailing_whitespace: bool,
210    ) -> Self {
211        Self {
212            selection_state: Rc::new(RefCell::new(SelectionState::new())),
213            grid,
214            query_mode,
215            trim_trailing_whitespace,
216        }
217    }
218
219    /// Creates an event handler function for mouse events.
220    ///
221    /// Returns a boxed closure that handles mouse events, tracks selection state,
222    /// and copies selected text to the clipboard on completion.
223    pub fn create_event_handler(&self, active_selection: SelectionTracker) -> MouseEventCallback {
224        let selection_state = self.selection_state.clone();
225        let query_mode = self.query_mode;
226        let trim_trailing = self.trim_trailing_whitespace;
227
228        Box::new(move |event: TerminalMouseEvent, grid: &TerminalGrid| {
229            let mut state = selection_state.borrow_mut();
230
231            match event.event_type {
232                // only handle left mouse button events
233                MouseEventType::MouseDown if event.button == 0 => {
234                    // mouse down always begins a new *potential* selection
235                    if state.is_complete() {
236                        // the existing (completed) selection is replaced with
237                        // a new selection which will be canceled if the mouse
238                        // up event is fired on the same cell.
239                        state.maybe_selecting(event.col, event.row);
240                    } else {
241                        // begins a new selection from a blank state
242                        state.begin_selection(event.col, event.row);
243                    }
244
245                    let query = select(query_mode)
246                        .start((event.col, event.row))
247                        .trim_trailing_whitespace(trim_trailing);
248
249                    active_selection.set_query(query);
250                },
251                MouseEventType::MouseMove if state.is_selecting() => {
252                    state.update_selection(event.col, event.row);
253                    active_selection.update_selection_end((event.col, event.row));
254                },
255                MouseEventType::MouseUp if event.button == 0 => {
256                    // at this point, we're either at:
257                    // a) the user has finished making the selection
258                    // b) the selection was canceled by a click inside a single cell
259                    if let Some((_start, _end)) = state.complete_selection(event.col, event.row) {
260                        active_selection.update_selection_end((event.col, event.row));
261                        let selected_text = grid.get_text(active_selection.query());
262                        copy_to_clipboard(selected_text);
263                    } else {
264                        state.clear();
265                        active_selection.clear();
266                    }
267                },
268                _ => {}, // ignore non-left button events
269            }
270        })
271    }
272}
273
274/// Internal state machine for tracking mouse selection operations.
275///
276/// Manages the lifecycle of a selection from initial click through dragging
277/// to final release. Handles edge cases like single-cell clicks that should
278/// cancel rather than select.
279#[derive(Debug, Clone, PartialEq, Eq)]
280enum SelectionState {
281    /// No selection in progress.
282    Idle,
283    /// Active selection with start point and current cursor position.
284    Selecting {
285        start: (u16, u16),
286        current: Option<(u16, u16)>,
287    },
288    /// Potential selection that will be canceled if mouse up occurs on same cell.
289    MaybeSelecting { start: (u16, u16) },
290    /// Completed selection with final coordinates.
291    Complete { start: (u16, u16), end: (u16, u16) },
292}
293
294impl SelectionState {
295    /// Creates a new idle selection state.
296    fn new() -> Self {
297        SelectionState::Idle
298    }
299
300    /// Begins a new selection at the specified coordinates.
301    fn begin_selection(&mut self, col: u16, row: u16) {
302        *self = SelectionState::Selecting { start: (col, row), current: None };
303    }
304
305    /// Updates the current selection endpoint during dragging.
306    fn update_selection(&mut self, col: u16, row: u16) {
307        use SelectionState::*;
308
309        match self {
310            Selecting { current, .. } => {
311                *current = Some((col, row));
312            },
313            MaybeSelecting { start } => {
314                if (col, row) != *start {
315                    *self = Selecting { start: *start, current: Some((col, row)) };
316                }
317            },
318            _ => {},
319        }
320    }
321
322    /// Checks if a selection is currently in progress.
323    fn is_selecting(&self) -> bool {
324        use SelectionState::*;
325        matches!(self, Selecting { .. } | MaybeSelecting { .. })
326    }
327
328    /// Completes the selection at the specified coordinates.
329    ///
330    /// Returns the selection coordinates if valid, None if canceled.
331    fn complete_selection(&mut self, col: u16, row: u16) -> Option<((u16, u16), (u16, u16))> {
332        match self {
333            SelectionState::Selecting { start, .. } => {
334                let result = Some((*start, (col, row)));
335                *self = SelectionState::Complete { start: *start, end: (col, row) };
336                result
337            },
338            _ => None,
339        }
340    }
341
342    /// Resets the selection state to idle.
343    fn clear(&mut self) {
344        *self = SelectionState::Idle;
345    }
346
347    /// Enters a tentative selection state that may be canceled.
348    fn maybe_selecting(&mut self, col: u16, row: u16) {
349        *self = SelectionState::MaybeSelecting { start: (col, row) };
350    }
351
352    /// Checks if a selection has been completed.
353    fn is_complete(&self) -> bool {
354        matches!(self, SelectionState::Complete { .. })
355    }
356}
357
358/// Creates a closure that handles browser mouse events and converts them to terminal events.
359///
360/// Wraps the event handler with coordinate conversion and terminal event creation logic.
361fn create_mouse_event_closure(
362    event_type: MouseEventType,
363    grid: Rc<RefCell<TerminalGrid>>,
364    event_handler: EventHandler,
365    pixel_to_cell: impl Fn(&web_sys::MouseEvent) -> Option<(u16, u16)> + 'static,
366) -> Closure<dyn FnMut(web_sys::MouseEvent)> {
367    Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
368        if let Some((col, row)) = pixel_to_cell(&event) {
369            let terminal_event = TerminalMouseEvent {
370                event_type,
371                col,
372                row,
373                button: event.button(),
374                ctrl_key: event.ctrl_key(),
375                shift_key: event.shift_key(),
376                alt_key: event.alt_key(),
377            };
378            let grid_ref = grid.borrow();
379            event_handler.borrow_mut()(terminal_event, &grid_ref);
380        }
381    }) as Box<dyn FnMut(_)>)
382}
383
384/// Copies text to the system clipboard using the browser's async clipboard API.
385///
386/// Spawns an async task to handle the clipboard write operation. Logs success
387/// or failure to the console.
388fn copy_to_clipboard(text: CompactString) {
389    console::log_1(&format!("Copying {} characters to clipboard", text.len()).into());
390
391    spawn_local(async move {
392        if let Some(window) = web_sys::window() {
393            let clipboard = window.navigator().clipboard();
394            match wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
395                Ok(_) => {
396                    console::log_1(
397                        &format!("Successfully copied {} characters", text.chars().count()).into(),
398                    );
399                },
400                Err(err) => {
401                    console::error_1(&format!("Failed to copy to clipboard: {err:?}").into());
402                },
403            }
404        }
405    });
406}
407
408impl Drop for TerminalMouseHandler {
409    fn drop(&mut self) {
410        self.cleanup();
411    }
412}
413
414impl Debug for TerminalMouseHandler {
415    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416        let (cols, rows) = self.terminal_dimensions.get();
417        write!(f, "TerminalMouseHandler {{ dimensions: {cols}x{rows} }}")
418    }
419}
420
421impl Debug for DefaultSelectionHandler {
422    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
423        let (cols, rows) = self.grid.borrow().terminal_size();
424        write!(
425            f,
426            "DefaultSelectionHandler {{ mode: {:?}, trim_whitespace: {}, grid: {}x{} }}",
427            self.query_mode, self.trim_trailing_whitespace, cols, rows
428        )
429    }
430}