Skip to main content

beamterm_renderer/
wasm.rs

1use std::{cell::RefCell, rc::Rc};
2
3use beamterm_data::{FontAtlasData, Glyph};
4use compact_str::{CompactString, ToCompactString};
5use serde_wasm_bindgen::from_value;
6use unicode_segmentation::UnicodeSegmentation;
7use unicode_width::UnicodeWidthStr;
8use wasm_bindgen::prelude::*;
9use web_sys::console;
10
11use crate::{
12    gl::{
13        CellData, CellQuery as RustCellQuery, ContextLossHandler, DynamicFontAtlas, Renderer,
14        SelectionMode as RustSelectionMode, StaticFontAtlas, TerminalGrid, select,
15    },
16    js::device_pixel_ratio,
17    mouse::{
18        DefaultSelectionHandler, ModifierKeys as RustModifierKeys, MouseSelectOptions,
19        TerminalMouseEvent, TerminalMouseHandler,
20    },
21    position::CursorPosition,
22    url::find_url_at_cursor,
23};
24
25/// JavaScript wrapper for the terminal renderer
26#[wasm_bindgen]
27#[derive(Debug)]
28pub struct BeamtermRenderer {
29    renderer: Renderer,
30    terminal_grid: Rc<RefCell<TerminalGrid>>,
31    mouse_handler: Option<TerminalMouseHandler>,
32    /// Handles WebGL context loss and restoration
33    context_loss_handler: Option<ContextLossHandler>,
34    /// Current device pixel ratio for HiDPI rendering
35    current_pixel_ratio: f32,
36}
37
38/// JavaScript wrapper for cell data
39#[wasm_bindgen]
40#[derive(Debug, Default, serde::Deserialize)]
41pub struct Cell {
42    symbol: CompactString,
43    style: u16,
44    fg: u32,
45    bg: u32,
46}
47
48#[wasm_bindgen]
49#[derive(Debug, Clone, Copy)]
50pub struct CellStyle {
51    fg: u32,
52    bg: u32,
53    style_bits: u16,
54}
55
56#[wasm_bindgen]
57#[derive(Debug, Clone, Copy)]
58pub struct Size {
59    pub width: u16,
60    pub height: u16,
61}
62
63#[wasm_bindgen]
64#[derive(Debug)]
65pub struct Batch {
66    terminal_grid: Rc<RefCell<TerminalGrid>>,
67    gl: web_sys::WebGl2RenderingContext,
68}
69
70/// Selection mode for text selection in the terminal
71#[wasm_bindgen]
72#[derive(Debug, Clone, Copy)]
73pub enum SelectionMode {
74    /// Rectangular block selection
75    Block,
76    /// Linear text flow selection
77    Linear,
78}
79
80/// Type of mouse event
81#[wasm_bindgen]
82#[derive(Debug, Clone, Copy)]
83pub enum MouseEventType {
84    /// Mouse button pressed
85    MouseDown,
86    /// Mouse button released
87    MouseUp,
88    /// Mouse moved
89    MouseMove,
90    /// Mouse button clicked (pressed and released)
91    Click,
92    /// Mouse cursor entered the terminal area
93    MouseEnter,
94    /// Mouse cursor left the terminal area
95    MouseLeave,
96}
97
98/// Mouse event data with terminal coordinates
99#[wasm_bindgen]
100#[derive(Debug, Clone, Copy)]
101pub struct MouseEvent {
102    /// Type of mouse event
103    pub event_type: MouseEventType,
104    /// Column in terminal grid (0-based)
105    pub col: u16,
106    /// Row in terminal grid (0-based)
107    pub row: u16,
108    /// Mouse button (0=left, 1=middle, 2=right)
109    pub button: i16,
110    /// Whether Ctrl key was pressed
111    pub ctrl_key: bool,
112    /// Whether Shift key was pressed
113    pub shift_key: bool,
114    /// Whether Alt key was pressed
115    pub alt_key: bool,
116    /// Whether Meta key was pressed (Command on macOS, Windows key on Windows)
117    pub meta_key: bool,
118}
119
120/// Modifier key flags for mouse selection.
121///
122/// Use bitwise OR to combine multiple modifiers:
123/// ```javascript
124/// const modifiers = ModifierKeys.SHIFT | ModifierKeys.CONTROL;
125/// renderer.enableSelectionWithOptions(SelectionMode.Block, true, modifiers);
126/// ```
127#[wasm_bindgen]
128#[derive(Debug, Clone, Copy, Default)]
129pub struct ModifierKeys(u8);
130
131#[wasm_bindgen]
132#[allow(non_snake_case)]
133impl ModifierKeys {
134    /// No modifier keys required
135    #[wasm_bindgen(getter)]
136    pub fn NONE() -> ModifierKeys {
137        ModifierKeys(0)
138    }
139
140    /// Control key (Ctrl)
141    #[wasm_bindgen(getter)]
142    pub fn CONTROL() -> ModifierKeys {
143        ModifierKeys(RustModifierKeys::CONTROL.bits())
144    }
145
146    /// Shift key
147    #[wasm_bindgen(getter)]
148    pub fn SHIFT() -> ModifierKeys {
149        ModifierKeys(RustModifierKeys::SHIFT.bits())
150    }
151
152    /// Alt key (Option on macOS)
153    #[wasm_bindgen(getter)]
154    pub fn ALT() -> ModifierKeys {
155        ModifierKeys(RustModifierKeys::ALT.bits())
156    }
157
158    /// Meta key (Command on macOS, Windows key on Windows)
159    #[wasm_bindgen(getter)]
160    pub fn META() -> ModifierKeys {
161        ModifierKeys(RustModifierKeys::META.bits())
162    }
163
164    /// Combines two modifier key sets using bitwise OR
165    #[wasm_bindgen(js_name = "or")]
166    pub fn or(&self, other: &ModifierKeys) -> ModifierKeys {
167        ModifierKeys(self.0 | other.0)
168    }
169}
170
171/// Query for selecting cells in the terminal
172#[wasm_bindgen]
173#[derive(Debug, Clone)]
174pub struct CellQuery {
175    inner: RustCellQuery,
176}
177
178/// Result of URL detection at a terminal position.
179///
180/// Contains the detected URL string and a `CellQuery` for highlighting
181/// or extracting the URL region.
182#[wasm_bindgen]
183#[derive(Debug)]
184pub struct UrlMatch {
185    /// The detected URL string
186    url: String,
187    /// Query for the URL's cell range
188    query: CellQuery,
189}
190
191#[wasm_bindgen]
192impl UrlMatch {
193    /// Returns the detected URL string.
194    #[wasm_bindgen(getter)]
195    pub fn url(&self) -> String {
196        self.url.clone()
197    }
198
199    /// Returns a `CellQuery` for the URL's position in the terminal grid.
200    ///
201    /// This can be used for highlighting or extracting text.
202    #[wasm_bindgen(getter)]
203    pub fn query(&self) -> CellQuery {
204        self.query.clone()
205    }
206}
207
208#[wasm_bindgen]
209impl CellQuery {
210    /// Create a new cell query with the specified selection mode
211    #[wasm_bindgen(constructor)]
212    pub fn new(mode: SelectionMode) -> CellQuery {
213        CellQuery { inner: select(mode.into()) }
214    }
215
216    /// Set the starting position for the selection
217    pub fn start(mut self, col: u16, row: u16) -> CellQuery {
218        self.inner = self.inner.start((col, row));
219        self
220    }
221
222    /// Set the ending position for the selection
223    pub fn end(mut self, col: u16, row: u16) -> CellQuery {
224        self.inner = self.inner.end((col, row));
225        self
226    }
227
228    /// Configure whether to trim trailing whitespace from lines
229    #[wasm_bindgen(js_name = "trimTrailingWhitespace")]
230    pub fn trim_trailing_whitespace(mut self, enabled: bool) -> CellQuery {
231        self.inner = self.inner.trim_trailing_whitespace(enabled);
232        self
233    }
234
235    /// Check if the query is empty (no selection range)
236    #[wasm_bindgen(js_name = "isEmpty")]
237    pub fn is_empty(&self) -> bool {
238        self.inner.is_empty()
239    }
240}
241
242#[wasm_bindgen]
243pub fn style() -> CellStyle {
244    CellStyle::new()
245}
246
247#[wasm_bindgen]
248pub fn cell(symbol: &str, style: CellStyle) -> Cell {
249    Cell {
250        symbol: symbol.into(),
251        style: style.style_bits,
252        fg: style.fg,
253        bg: style.bg,
254    }
255}
256
257#[wasm_bindgen]
258impl CellStyle {
259    /// Create a new TextStyle with default (normal) style
260    #[wasm_bindgen(constructor)]
261    pub fn new() -> CellStyle {
262        Default::default()
263    }
264
265    /// Sets the foreground color
266    #[wasm_bindgen]
267    pub fn fg(mut self, color: u32) -> CellStyle {
268        self.fg = color;
269        self
270    }
271
272    /// Sets the background color
273    #[wasm_bindgen]
274    pub fn bg(mut self, color: u32) -> CellStyle {
275        self.bg = color;
276        self
277    }
278
279    /// Add bold style
280    #[wasm_bindgen]
281    pub fn bold(mut self) -> CellStyle {
282        self.style_bits |= Glyph::BOLD_FLAG;
283        self
284    }
285
286    /// Add italic style
287    #[wasm_bindgen]
288    pub fn italic(mut self) -> CellStyle {
289        self.style_bits |= Glyph::ITALIC_FLAG;
290        self
291    }
292
293    /// Add underline effect
294    #[wasm_bindgen]
295    pub fn underline(mut self) -> CellStyle {
296        self.style_bits |= Glyph::UNDERLINE_FLAG;
297        self
298    }
299
300    /// Add strikethrough effect
301    #[wasm_bindgen]
302    pub fn strikethrough(mut self) -> CellStyle {
303        self.style_bits |= Glyph::STRIKETHROUGH_FLAG;
304        self
305    }
306
307    /// Get the combined style bits
308    #[wasm_bindgen(getter)]
309    pub fn bits(&self) -> u16 {
310        self.style_bits
311    }
312}
313
314impl Default for CellStyle {
315    fn default() -> Self {
316        CellStyle {
317            fg: 0xFFFFFF,  // Default foreground color (white)
318            bg: 0x000000,  // Default background color (black)
319            style_bits: 0, // No styles applied
320        }
321    }
322}
323
324#[wasm_bindgen]
325impl Batch {
326    /// Updates a single cell at the given position.
327    #[wasm_bindgen(js_name = "cell")]
328    pub fn cell(&mut self, x: u16, y: u16, cell_data: &Cell) {
329        let _ = self
330            .terminal_grid
331            .borrow_mut()
332            .update_cell(x, y, cell_data.as_cell_data());
333    }
334
335    /// Updates a cell by its buffer index.
336    #[wasm_bindgen(js_name = "cellByIndex")]
337    pub fn cell_by_index(&mut self, idx: usize, cell_data: &Cell) {
338        let _ = self
339            .terminal_grid
340            .borrow_mut()
341            .update_cell_by_index(idx, cell_data.as_cell_data());
342    }
343
344    /// Updates multiple cells from an array.
345    /// Each element should be [x, y, cellData].
346    #[wasm_bindgen(js_name = "cells")]
347    pub fn cells(&mut self, cells_json: JsValue) -> Result<(), JsValue> {
348        let updates = from_value::<Vec<(u16, u16, Cell)>>(cells_json)
349            .map_err(|e| JsValue::from_str(&e.to_string()));
350
351        match updates {
352            Ok(cells) => {
353                let cell_data = cells
354                    .iter()
355                    .map(|(x, y, data)| (*x, *y, data.as_cell_data()));
356
357                let mut terminal_grid = self.terminal_grid.borrow_mut();
358                terminal_grid
359                    .update_cells_by_position(cell_data)
360                    .map_err(|e| JsValue::from_str(&e.to_string()))
361            },
362            e => e.map(|_| ()),
363        }
364    }
365
366    /// Write text to the terminal
367    #[wasm_bindgen(js_name = "text")]
368    pub fn text(&mut self, x: u16, y: u16, text: &str, style: &CellStyle) -> Result<(), JsValue> {
369        let mut terminal_grid = self.terminal_grid.borrow_mut();
370        let (cols, rows) = terminal_grid.terminal_size();
371
372        if y >= rows {
373            return Ok(()); // oob, ignore
374        }
375
376        let mut col_offset: u16 = 0;
377        for ch in text.graphemes(true) {
378            let char_width = if ch.len() == 1 { 1 } else { ch.width() };
379
380            // Skip zero-width characters (they don't occupy terminal cells)
381            if char_width == 0 {
382                continue;
383            }
384
385            let current_col = x + col_offset;
386            if current_col >= cols {
387                break;
388            }
389
390            let cell = CellData::new_with_style_bits(ch, style.style_bits, style.fg, style.bg);
391            terminal_grid
392                .update_cell(current_col, y, cell)
393                .map_err(|e| JsValue::from_str(&e.to_string()))?;
394
395            col_offset += char_width as u16;
396        }
397
398        Ok(())
399    }
400
401    /// Fill a rectangular region
402    #[wasm_bindgen(js_name = "fill")]
403    pub fn fill(
404        &mut self,
405        x: u16,
406        y: u16,
407        width: u16,
408        height: u16,
409        cell_data: &Cell,
410    ) -> Result<(), JsValue> {
411        let mut terminal_grid = self.terminal_grid.borrow_mut();
412        let (cols, rows) = terminal_grid.terminal_size();
413
414        let width = (x + width).min(cols).saturating_sub(x);
415        let height = (y + height).min(rows).saturating_sub(y);
416
417        let fill_cell = cell_data.as_cell_data();
418        for y in y..y + height {
419            for x in x..x + width {
420                terminal_grid
421                    .update_cell(x, y, fill_cell)
422                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
423            }
424        }
425
426        Ok(())
427    }
428
429    /// Clear the terminal with specified background color
430    #[wasm_bindgen]
431    pub fn clear(&mut self, bg: u32) -> Result<(), JsValue> {
432        let mut terminal_grid = self.terminal_grid.borrow_mut();
433        let (cols, rows) = terminal_grid.terminal_size();
434
435        let clear_cell = CellData::new_with_style_bits(" ", 0, 0xFFFFFF, bg);
436        for y in 0..rows {
437            for x in 0..cols {
438                terminal_grid
439                    .update_cell(x, y, clear_cell)
440                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
441            }
442        }
443
444        Ok(())
445    }
446
447    /// Synchronize all pending updates to the GPU
448    #[wasm_bindgen]
449    #[deprecated(since = "0.4.0", note = "no-op, flush is now automatic")]
450    #[allow(deprecated)]
451    pub fn flush(&mut self) -> Result<(), JsValue> {
452        Ok(())
453    }
454}
455
456#[wasm_bindgen]
457impl Cell {
458    #[wasm_bindgen(constructor)]
459    pub fn new(symbol: String, style: &CellStyle) -> Cell {
460        Cell {
461            symbol: symbol.into(),
462            style: style.style_bits,
463            fg: style.fg,
464            bg: style.bg,
465        }
466    }
467
468    #[wasm_bindgen(getter)]
469    pub fn symbol(&self) -> String {
470        self.symbol.to_string()
471    }
472
473    #[wasm_bindgen(setter)]
474    pub fn set_symbol(&mut self, symbol: String) {
475        self.symbol = symbol.into();
476    }
477
478    #[wasm_bindgen(getter)]
479    pub fn fg(&self) -> u32 {
480        self.fg
481    }
482
483    #[wasm_bindgen(setter)]
484    pub fn set_fg(&mut self, color: u32) {
485        self.fg = color;
486    }
487
488    #[wasm_bindgen(getter)]
489    pub fn bg(&self) -> u32 {
490        self.bg
491    }
492
493    #[wasm_bindgen(setter)]
494    pub fn set_bg(&mut self, color: u32) {
495        self.bg = color;
496    }
497
498    #[wasm_bindgen(getter)]
499    pub fn style(&self) -> u16 {
500        self.style
501    }
502
503    #[wasm_bindgen(setter)]
504    pub fn set_style(&mut self, style: u16) {
505        self.style = style;
506    }
507}
508
509impl Cell {
510    pub fn as_cell_data(&self) -> CellData<'_> {
511        CellData::new_with_style_bits(&self.symbol, self.style, self.fg, self.bg)
512    }
513}
514
515#[wasm_bindgen]
516impl BeamtermRenderer {
517    /// Create a new terminal renderer with the default embedded font atlas.
518    #[wasm_bindgen(constructor)]
519    pub fn new(canvas_id: &str) -> Result<BeamtermRenderer, JsValue> {
520        Self::with_static_atlas(canvas_id, None, None)
521    }
522
523    /// Create a terminal renderer with custom static font atlas data.
524    ///
525    /// # Arguments
526    /// * `canvas_id` - CSS selector for the canvas element
527    /// * `atlas_data` - Binary atlas data (from .atlas file), or null for default
528    /// * `auto_resize_canvas_css` - Whether to automatically set canvas CSS dimensions
529    ///   on resize. Set to `false` when external CSS (flexbox, grid) controls sizing.
530    ///   Defaults to `true` if not specified.
531    #[wasm_bindgen(js_name = "withStaticAtlas")]
532    pub fn with_static_atlas(
533        canvas_id: &str,
534        atlas_data: Option<js_sys::Uint8Array>,
535        auto_resize_canvas_css: Option<bool>,
536    ) -> Result<BeamtermRenderer, JsValue> {
537        console_error_panic_hook::set_once();
538
539        let auto_resize = auto_resize_canvas_css.unwrap_or(true);
540
541        // Setup renderer with exact pixel ratio for HiDPI
542        let mut renderer = Renderer::create(canvas_id, auto_resize)
543            .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {e}")))?;
544        let current_pixel_ratio = crate::js::device_pixel_ratio();
545        renderer.set_pixel_ratio(current_pixel_ratio);
546        let (w, h) = renderer.logical_size();
547        renderer.resize(w, h);
548
549        let gl = renderer.gl();
550        let atlas_config = match atlas_data {
551            Some(data) => {
552                let bytes = data.to_vec();
553                FontAtlasData::from_binary(&bytes)
554                    .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e:?}")))?
555            },
556            None => FontAtlasData::default(),
557        };
558
559        let atlas = StaticFontAtlas::load(gl, atlas_config)
560            .map_err(|e| JsValue::from_str(&format!("Failed to load font atlas: {e}")))?;
561
562        let canvas_size = renderer.physical_size();
563        let terminal_grid =
564            TerminalGrid::new(gl, atlas.into(), canvas_size, current_pixel_ratio)
565                .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {e}")))?;
566
567        let terminal_grid = Rc::new(RefCell::new(terminal_grid));
568
569        let context_loss_handler = ContextLossHandler::new(renderer.canvas()).map_err(|e| {
570            JsValue::from_str(&format!("Failed to create context loss handler: {e}"))
571        })?;
572
573        Ok(BeamtermRenderer {
574            renderer,
575            terminal_grid,
576            mouse_handler: None,
577            context_loss_handler: Some(context_loss_handler),
578            current_pixel_ratio,
579        })
580    }
581
582    /// Create a terminal renderer with a dynamic font atlas using browser fonts.
583    ///
584    /// The dynamic atlas rasterizes glyphs on-demand using the browser's canvas API,
585    /// enabling support for any system font, emoji, and complex scripts.
586    ///
587    /// # Arguments
588    /// * `canvas_id` - CSS selector for the canvas element
589    /// * `font_family` - Array of font family names (e.g., `["Hack", "JetBrains Mono"]`)
590    /// * `font_size` - Font size in pixels
591    /// * `auto_resize_canvas_css` - Whether to automatically set canvas CSS dimensions
592    ///   on resize. Set to `false` when external CSS (flexbox, grid) controls sizing.
593    ///   Defaults to `true` if not specified.
594    ///
595    /// # Example
596    /// ```javascript
597    /// const renderer = BeamtermRenderer.withDynamicAtlas(
598    ///     "#terminal",
599    ///     ["JetBrains Mono", "Fira Code"],
600    ///     16.0
601    /// );
602    /// ```
603    #[wasm_bindgen(js_name = "withDynamicAtlas")]
604    pub fn with_dynamic_atlas(
605        canvas_id: &str,
606        font_family: js_sys::Array,
607        font_size: f32,
608        auto_resize_canvas_css: Option<bool>,
609    ) -> Result<BeamtermRenderer, JsValue> {
610        console_error_panic_hook::set_once();
611
612        let auto_resize = auto_resize_canvas_css.unwrap_or(true);
613
614        // Setup renderer with exact pixel ratio for HiDPI
615        let mut renderer = Renderer::create(canvas_id, auto_resize)
616            .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {e}")))?;
617        let current_pixel_ratio = crate::js::device_pixel_ratio();
618        renderer.set_pixel_ratio(current_pixel_ratio);
619        let (w, h) = renderer.logical_size();
620        renderer.resize(w, h);
621
622        let font_families: Vec<CompactString> = font_family
623            .iter()
624            .filter_map(|v| v.as_string())
625            .map(|s| s.to_compact_string())
626            .collect();
627
628        if font_families.is_empty() {
629            return Err(JsValue::from_str("font_family array cannot be empty"));
630        }
631
632        let gl = renderer.gl();
633        let atlas = DynamicFontAtlas::new(gl, &font_families, font_size, current_pixel_ratio, None)
634            .map_err(|e| JsValue::from_str(&format!("Failed to create dynamic atlas: {e}")))?;
635
636        let canvas_size = renderer.physical_size();
637        let terminal_grid =
638            TerminalGrid::new(gl, atlas.into(), canvas_size, current_pixel_ratio)
639                .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {e}")))?;
640
641        let terminal_grid = Rc::new(RefCell::new(terminal_grid));
642
643        let context_loss_handler = ContextLossHandler::new(renderer.canvas()).map_err(|e| {
644            JsValue::from_str(&format!("Failed to create context loss handler: {e}"))
645        })?;
646
647        Ok(BeamtermRenderer {
648            renderer,
649            terminal_grid,
650            mouse_handler: None,
651            context_loss_handler: Some(context_loss_handler),
652            current_pixel_ratio,
653        })
654    }
655
656    /// Enable default mouse selection behavior with built-in copy to clipboard
657    #[wasm_bindgen(js_name = "enableSelection")]
658    pub fn enable_selection(
659        &mut self,
660        mode: SelectionMode,
661        trim_whitespace: bool,
662    ) -> Result<(), JsValue> {
663        self.enable_selection_internal(mode, trim_whitespace, ModifierKeys::default())
664    }
665
666    /// Enable mouse selection with full configuration options.
667    ///
668    /// This method allows specifying modifier keys that must be held for selection
669    /// to activate, in addition to the selection mode and whitespace trimming.
670    ///
671    /// # Arguments
672    /// * `mode` - Selection mode (Block or Linear)
673    /// * `trim_whitespace` - Whether to trim trailing whitespace from selected text
674    /// * `require_modifiers` - Modifier keys that must be held to start selection
675    ///
676    /// # Example
677    /// ```javascript
678    /// // Require Shift+Click to start selection
679    /// renderer.enableSelectionWithOptions(
680    ///     SelectionMode.Linear,
681    ///     true,
682    ///     ModifierKeys.SHIFT
683    /// );
684    ///
685    /// // Require Ctrl+Shift+Click
686    /// renderer.enableSelectionWithOptions(
687    ///     SelectionMode.Block,
688    ///     false,
689    ///     ModifierKeys.CONTROL.or(ModifierKeys.SHIFT)
690    /// );
691    /// ```
692    #[wasm_bindgen(js_name = "enableSelectionWithOptions")]
693    pub fn enable_selection_with_options(
694        &mut self,
695        mode: SelectionMode,
696        trim_whitespace: bool,
697        require_modifiers: &ModifierKeys,
698    ) -> Result<(), JsValue> {
699        self.enable_selection_internal(mode, trim_whitespace, *require_modifiers)
700    }
701
702    fn enable_selection_internal(
703        &mut self,
704        mode: SelectionMode,
705        trim_whitespace: bool,
706        require_modifiers: ModifierKeys,
707    ) -> Result<(), JsValue> {
708        // clean up existing mouse handler if present
709        if let Some(old_handler) = self.mouse_handler.take() {
710            old_handler.cleanup();
711        }
712
713        let selection_tracker = self.terminal_grid.borrow().selection_tracker();
714        let options = MouseSelectOptions::new()
715            .selection_mode(mode.into())
716            .trim_trailing_whitespace(trim_whitespace)
717            .require_modifier_keys(require_modifiers.into());
718        let handler = DefaultSelectionHandler::new(self.terminal_grid.clone(), options);
719
720        let mut mouse_handler = TerminalMouseHandler::new(
721            self.renderer.canvas(),
722            self.terminal_grid.clone(),
723            handler.create_event_handler(selection_tracker),
724        )
725        .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {e}")))?;
726
727        self.update_mouse_metrics(&mut mouse_handler);
728
729        self.mouse_handler = Some(mouse_handler);
730        Ok(())
731    }
732
733    /// Set a custom mouse event handler
734    #[wasm_bindgen(js_name = "setMouseHandler")]
735    pub fn set_mouse_handler(&mut self, handler: js_sys::Function) -> Result<(), JsValue> {
736        // Clean up existing mouse handler if present
737        if let Some(old_handler) = self.mouse_handler.take() {
738            old_handler.cleanup();
739        }
740
741        let handler_closure = {
742            let handler = handler.clone();
743            move |event: TerminalMouseEvent, _grid: &TerminalGrid| {
744                let js_event = MouseEvent::from(event);
745                let this = JsValue::null();
746                let args = js_sys::Array::new();
747                args.push(&JsValue::from(js_event));
748
749                if let Err(e) = handler.apply(&this, &args) {
750                    console::error_1(&format!("Mouse handler error: {e:?}").into());
751                }
752            }
753        };
754
755        let mut mouse_handler = TerminalMouseHandler::new(
756            self.renderer.canvas(),
757            self.terminal_grid.clone(),
758            handler_closure,
759        )
760        .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {e}")))?;
761
762        self.update_mouse_metrics(&mut mouse_handler);
763
764        self.mouse_handler = Some(mouse_handler);
765        Ok(())
766    }
767
768    /// Get selected text based on a cell query
769    #[wasm_bindgen(js_name = "getText")]
770    pub fn get_text(&self, query: &CellQuery) -> String {
771        self.terminal_grid
772            .borrow()
773            .get_text(query.inner)
774            .to_string()
775    }
776
777    /// Detects an HTTP/HTTPS URL at or around the given cell position.
778    ///
779    /// Scans left from the position to find a URL scheme (`http://` or `https://`),
780    /// then scans right to find the URL end. Handles trailing punctuation and
781    /// unbalanced parentheses (e.g., Wikipedia URLs).
782    ///
783    /// Returns `undefined` if no URL is found at the position.
784    ///
785    /// **Note:** Only detects URLs within a single row. URLs that wrap across
786    /// multiple lines are not supported.
787    ///
788    /// # Example
789    /// ```javascript
790    /// // In a mouse handler:
791    /// renderer.setMouseHandler((event) => {
792    ///     const match = renderer.findUrlAt(event.col, event.row);
793    ///     if (match) {
794    ///         console.log("URL found:", match.url);
795    ///         // match.query can be used for highlighting
796    ///     }
797    /// });
798    /// ```
799    #[wasm_bindgen(js_name = "findUrlAt")]
800    pub fn find_url_at(&self, col: u16, row: u16) -> Option<UrlMatch> {
801        let cursor = CursorPosition::new(col, row);
802        let grid = self.terminal_grid.borrow();
803
804        find_url_at_cursor(cursor, &grid).map(|m| UrlMatch {
805            url: m.url.to_string(),
806            query: CellQuery { inner: m.query },
807        })
808    }
809
810    /// Copy text to the system clipboard
811    #[wasm_bindgen(js_name = "copyToClipboard")]
812    pub fn copy_to_clipboard(&self, text: &str) {
813        use wasm_bindgen_futures::spawn_local;
814        let text = text.to_string();
815
816        spawn_local(async move {
817            if let Some(window) = web_sys::window() {
818                let clipboard = window.navigator().clipboard();
819                match wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
820                    Ok(_) => {
821                        console::log_1(
822                            &format!("Copied {} characters to clipboard", text.len()).into(),
823                        );
824                    },
825                    Err(err) => {
826                        console::error_1(&format!("Failed to copy to clipboard: {err:?}").into());
827                    },
828                }
829            }
830        });
831    }
832
833    /// Clear any active selection
834    #[wasm_bindgen(js_name = "clearSelection")]
835    pub fn clear_selection(&self) {
836        self.terminal_grid
837            .borrow()
838            .selection_tracker()
839            .clear();
840    }
841
842    /// Check if there is an active selection
843    #[wasm_bindgen(js_name = "hasSelection")]
844    pub fn has_selection(&self) -> bool {
845        self.terminal_grid
846            .borrow()
847            .selection_tracker()
848            .get_query()
849            .is_some()
850    }
851
852    /// Create a new render batch
853    #[wasm_bindgen(js_name = "batch")]
854    pub fn new_render_batch(&mut self) -> Batch {
855        let gl = self.renderer.gl().clone();
856        let terminal_grid = self.terminal_grid.clone();
857        Batch { terminal_grid, gl }
858    }
859
860    /// Get the terminal dimensions in cells
861    #[wasm_bindgen(js_name = "terminalSize")]
862    pub fn terminal_size(&self) -> Size {
863        let (cols, rows) = self.terminal_grid.borrow().terminal_size();
864        Size { width: cols, height: rows }
865    }
866
867    /// Get the cell size in pixels
868    #[wasm_bindgen(js_name = "cellSize")]
869    pub fn cell_size(&self) -> Size {
870        let (width, height) = self.terminal_grid.borrow().cell_size();
871        Size { width: width as u16, height: height as u16 }
872    }
873
874    /// Render the terminal to the canvas
875    #[wasm_bindgen]
876    pub fn render(&mut self) {
877        // Check for pending rebuild after context restoration
878        if self.needs_gl_reinit()
879            && let Err(e) = self.restore_context()
880        {
881            console::error_1(&format!("Failed to restore WebGL context: {e:?}").into());
882            return;
883        }
884
885        // Skip rendering if context is currently lost (waiting for browser restoration)
886        if self.is_context_lost() {
887            return;
888        }
889
890        // Check for device pixel ratio changes (HiDPI display switching)
891        let raw_dpr = device_pixel_ratio();
892        if (raw_dpr - self.current_pixel_ratio).abs() > f32::EPSILON {
893            let _ = self.handle_pixel_ratio_change(raw_dpr);
894        }
895
896        let mut grid = self.terminal_grid.borrow_mut();
897        let _ = grid.flush_cells(self.renderer.gl());
898
899        self.renderer.begin_frame();
900        self.renderer.render(&*grid);
901        self.renderer.end_frame();
902    }
903
904    /// Checks if the WebGL context has been lost.
905    fn is_context_lost(&self) -> bool {
906        if let Some(handler) = &self.context_loss_handler {
907            handler.is_context_lost()
908        } else {
909            self.renderer.is_context_lost()
910        }
911    }
912
913    /// Checks if the terminal needs to restore GPU resources after a context loss.
914    fn needs_gl_reinit(&self) -> bool {
915        self.context_loss_handler
916            .as_ref()
917            .is_some_and(ContextLossHandler::context_pending_rebuild)
918    }
919
920    /// Restores all GPU resources after a WebGL context loss.
921    fn restore_context(&mut self) -> Result<(), JsValue> {
922        self.renderer
923            .restore_context()
924            .map_err(|e| JsValue::from_str(&format!("Failed to restore renderer context: {e}")))?;
925
926        let gl = self.renderer.gl();
927
928        self.terminal_grid
929            .borrow_mut()
930            .recreate_atlas_texture(gl)
931            .map_err(|e| JsValue::from_str(&format!("Failed to recreate atlas texture: {e}")))?;
932
933        self.terminal_grid
934            .borrow_mut()
935            .recreate_resources(gl)
936            .map_err(|e| JsValue::from_str(&format!("Failed to recreate grid resources: {e}")))?;
937
938        self.terminal_grid
939            .borrow_mut()
940            .flush_cells(gl)
941            .map_err(|e| JsValue::from_str(&format!("Failed to flush cells: {e}")))?;
942
943        if let Some(handler) = &self.context_loss_handler {
944            handler.clear_context_rebuild_needed();
945        }
946
947        // Re-apply current pixel ratio after context restoration
948        // (display may have changed during context loss)
949        let dpr = device_pixel_ratio();
950        if (dpr - self.current_pixel_ratio).abs() > f32::EPSILON {
951            self.handle_pixel_ratio_change(dpr)?;
952        } else {
953            // Even if DPR unchanged, renderer state was reset - reapply it
954            self.renderer.set_pixel_ratio(dpr);
955            let (w, h) = self.renderer.logical_size();
956            self.renderer.resize(w, h);
957        }
958
959        console::log_1(&"WebGL context restored successfully".into());
960        Ok(())
961    }
962
963    /// Handles a change in device pixel ratio.
964    ///
965    /// Callers should verify the ratio has changed before calling this method.
966    fn handle_pixel_ratio_change(&mut self, raw_pixel_ratio: f32) -> Result<(), JsValue> {
967        self.current_pixel_ratio = raw_pixel_ratio;
968
969        let gl = self.renderer.gl();
970
971        // Update atlas (for dynamic atlas, this re-rasterizes glyphs)
972        self.terminal_grid
973            .borrow_mut()
974            .atlas_mut()
975            .update_pixel_ratio(gl, raw_pixel_ratio)
976            .map_err(|e| JsValue::from_str(&format!("Failed to update pixel ratio: {e}")))?;
977
978        // Always use exact DPR for canvas sizing
979        self.renderer.set_pixel_ratio(raw_pixel_ratio);
980
981        // Resize to apply the new pixel ratio
982        let (w, h) = self.renderer.logical_size();
983        self.resize(w, h)
984    }
985
986    /// Resize the terminal to fit new canvas dimensions
987    #[wasm_bindgen]
988    pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
989        self.renderer.resize(width, height);
990
991        let gl = self.renderer.gl();
992        let physical_size = self.renderer.physical_size();
993        self.terminal_grid
994            .borrow_mut()
995            .resize(gl, physical_size, self.current_pixel_ratio)
996            .map_err(|e| JsValue::from_str(&format!("Failed to resize: {e}")))?;
997
998        self.update_mouse_handler_metrics();
999
1000        Ok(())
1001    }
1002
1003    /// Updates the mouse handler with current grid metrics (cell size and dimensions).
1004    fn update_mouse_handler_metrics(&mut self) {
1005        if let Some(mouse_handler) = &mut self.mouse_handler {
1006            let grid = self.terminal_grid.borrow();
1007            let (cols, rows) = grid.terminal_size();
1008            let (phys_width, phys_height) = grid.cell_size();
1009            let cell_width = phys_width as f32 / self.current_pixel_ratio;
1010            let cell_height = phys_height as f32 / self.current_pixel_ratio;
1011            mouse_handler.update_metrics(cols, rows, cell_width, cell_height);
1012        }
1013    }
1014
1015    /// Replace the current font atlas with a new static atlas.
1016    ///
1017    /// This method enables runtime font switching by loading a new `.atlas` file.
1018    /// All existing cell content is preserved and translated to the new atlas.
1019    ///
1020    /// # Arguments
1021    /// * `atlas_data` - Binary atlas data (from .atlas file), or null for default
1022    ///
1023    /// # Example
1024    /// ```javascript
1025    /// const atlasData = await fetch('new-font.atlas').then(r => r.arrayBuffer());
1026    /// renderer.replaceWithStaticAtlas(new Uint8Array(atlasData));
1027    /// ```
1028    #[wasm_bindgen(js_name = "replaceWithStaticAtlas")]
1029    pub fn replace_with_static_atlas(
1030        &mut self,
1031        atlas_data: Option<js_sys::Uint8Array>,
1032    ) -> Result<(), JsValue> {
1033        let gl = self.renderer.gl();
1034
1035        let atlas_config = match atlas_data {
1036            Some(data) => {
1037                let bytes = data.to_vec();
1038                FontAtlasData::from_binary(&bytes)
1039                    .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e:?}")))?
1040            },
1041            None => FontAtlasData::default(),
1042        };
1043
1044        let atlas = StaticFontAtlas::load(gl, atlas_config)
1045            .map_err(|e| JsValue::from_str(&format!("Failed to load font atlas: {e}")))?;
1046
1047        self.terminal_grid
1048            .borrow_mut()
1049            .replace_atlas(gl, atlas.into());
1050
1051        self.update_mouse_handler_metrics();
1052
1053        Ok(())
1054    }
1055
1056    /// Replace the current font atlas with a new dynamic atlas.
1057    ///
1058    /// This method enables runtime font switching by creating a new dynamic atlas
1059    /// with the specified font family and size. All existing cell content is
1060    /// preserved and translated to the new atlas.
1061    ///
1062    /// # Arguments
1063    /// * `font_family` - Array of font family names (e.g., `["Hack", "JetBrains Mono"]`)
1064    /// * `font_size` - Font size in pixels
1065    ///
1066    /// # Example
1067    /// ```javascript
1068    /// renderer.replaceWithDynamicAtlas(["Fira Code", "monospace"], 18.0);
1069    /// ```
1070    #[wasm_bindgen(js_name = "replaceWithDynamicAtlas")]
1071    pub fn replace_with_dynamic_atlas(
1072        &mut self,
1073        font_family: js_sys::Array,
1074        font_size: f32,
1075    ) -> Result<(), JsValue> {
1076        let font_families: Vec<CompactString> = font_family
1077            .iter()
1078            .filter_map(|v| v.as_string())
1079            .map(|s| s.to_compact_string())
1080            .collect();
1081
1082        if font_families.is_empty() {
1083            return Err(JsValue::from_str("font_family array cannot be empty"));
1084        }
1085
1086        let gl = self.renderer.gl();
1087        let pixel_ratio = device_pixel_ratio();
1088        let atlas = DynamicFontAtlas::new(gl, &font_families, font_size, pixel_ratio, None)
1089            .map_err(|e| JsValue::from_str(&format!("Failed to create dynamic atlas: {e}")))?;
1090
1091        self.terminal_grid
1092            .borrow_mut()
1093            .replace_atlas(gl, atlas.into());
1094
1095        self.update_mouse_handler_metrics();
1096
1097        Ok(())
1098    }
1099
1100    fn update_mouse_metrics(&mut self, mouse_handler: &mut TerminalMouseHandler) {
1101        let grid = self.terminal_grid.borrow();
1102        let (cols, rows) = grid.terminal_size();
1103        let (phys_w, phys_h) = grid.cell_size();
1104        let css_w = phys_w as f32 / self.current_pixel_ratio;
1105        let css_h = phys_h as f32 / self.current_pixel_ratio;
1106        mouse_handler.update_metrics(cols, rows, css_w, css_h);
1107    }
1108}
1109
1110// Convert between Rust and WASM types
1111impl From<SelectionMode> for RustSelectionMode {
1112    fn from(mode: SelectionMode) -> Self {
1113        match mode {
1114            SelectionMode::Block => RustSelectionMode::Block,
1115            SelectionMode::Linear => RustSelectionMode::Linear,
1116        }
1117    }
1118}
1119
1120impl From<RustSelectionMode> for SelectionMode {
1121    fn from(mode: RustSelectionMode) -> Self {
1122        match mode {
1123            RustSelectionMode::Block => SelectionMode::Block,
1124            RustSelectionMode::Linear => SelectionMode::Linear,
1125        }
1126    }
1127}
1128
1129impl From<TerminalMouseEvent> for MouseEvent {
1130    fn from(event: TerminalMouseEvent) -> Self {
1131        use crate::mouse::MouseEventType as RustMouseEventType;
1132
1133        let event_type = match event.event_type {
1134            RustMouseEventType::MouseDown => MouseEventType::MouseDown,
1135            RustMouseEventType::MouseUp => MouseEventType::MouseUp,
1136            RustMouseEventType::MouseMove => MouseEventType::MouseMove,
1137            RustMouseEventType::Click => MouseEventType::Click,
1138            RustMouseEventType::MouseEnter => MouseEventType::MouseEnter,
1139            RustMouseEventType::MouseLeave => MouseEventType::MouseLeave,
1140        };
1141
1142        MouseEvent {
1143            event_type,
1144            col: event.col,
1145            row: event.row,
1146            button: event.button(),
1147            ctrl_key: event.ctrl_key(),
1148            shift_key: event.shift_key(),
1149            alt_key: event.alt_key(),
1150            meta_key: event.meta_key(),
1151        }
1152    }
1153}
1154
1155impl From<ModifierKeys> for RustModifierKeys {
1156    fn from(keys: ModifierKeys) -> Self {
1157        RustModifierKeys::from_bits_truncate(keys.0)
1158    }
1159}
1160
1161/// Initialize the WASM module
1162#[wasm_bindgen(start)]
1163pub fn main() {
1164    console_error_panic_hook::set_once();
1165    console::log_1(&"beamterm WASM module loaded".into());
1166}