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