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)
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    #[wasm_bindgen(js_name = "withStaticAtlas")]
489    pub fn with_static_atlas(
490        canvas_id: &str,
491        atlas_data: Option<js_sys::Uint8Array>,
492    ) -> Result<BeamtermRenderer, JsValue> {
493        console_error_panic_hook::set_once();
494
495        // Setup renderer with exact pixel ratio for HiDPI
496        let mut renderer = Renderer::create(canvas_id)
497            .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {e}")))?;
498        let current_pixel_ratio = crate::js::device_pixel_ratio();
499        renderer.set_pixel_ratio(current_pixel_ratio);
500        let (w, h) = renderer.logical_size();
501        renderer.resize(w, h);
502
503        let gl = renderer.gl();
504        let atlas_config = match atlas_data {
505            Some(data) => {
506                let bytes = data.to_vec();
507                FontAtlasData::from_binary(&bytes)
508                    .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e:?}")))?
509            },
510            None => FontAtlasData::default(),
511        };
512
513        let atlas = StaticFontAtlas::load(gl, atlas_config)
514            .map_err(|e| JsValue::from_str(&format!("Failed to load font atlas: {e}")))?;
515
516        let canvas_size = renderer.physical_size();
517        let terminal_grid =
518            TerminalGrid::new(gl, atlas.into(), canvas_size, current_pixel_ratio)
519                .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {e}")))?;
520
521        let terminal_grid = Rc::new(RefCell::new(terminal_grid));
522        Ok(BeamtermRenderer {
523            renderer,
524            terminal_grid,
525            mouse_handler: None,
526            current_pixel_ratio,
527        })
528    }
529
530    /// Create a terminal renderer with a dynamic font atlas using browser fonts.
531    ///
532    /// The dynamic atlas rasterizes glyphs on-demand using the browser's canvas API,
533    /// enabling support for any system font, emoji, and complex scripts.
534    ///
535    /// # Arguments
536    /// * `canvas_id` - CSS selector for the canvas element
537    /// * `font_family` - Array of font family names (e.g., `["Hack", "JetBrains Mono"]`)
538    /// * `font_size` - Font size in pixels
539    ///
540    /// # Example
541    /// ```javascript
542    /// const renderer = BeamtermRenderer.withDynamicAtlas(
543    ///     "#terminal",
544    ///     ["JetBrains Mono", "Fira Code"],
545    ///     16.0
546    /// );
547    /// ```
548    #[wasm_bindgen(js_name = "withDynamicAtlas")]
549    pub fn with_dynamic_atlas(
550        canvas_id: &str,
551        font_family: js_sys::Array,
552        font_size: f32,
553    ) -> Result<BeamtermRenderer, JsValue> {
554        console_error_panic_hook::set_once();
555
556        // Setup renderer with exact pixel ratio for HiDPI
557        let mut renderer = Renderer::create(canvas_id)
558            .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {e}")))?;
559        let current_pixel_ratio = crate::js::device_pixel_ratio();
560        renderer.set_pixel_ratio(current_pixel_ratio);
561        let (w, h) = renderer.logical_size();
562        renderer.resize(w, h);
563
564        let font_families: Vec<CompactString> = font_family
565            .iter()
566            .filter_map(|v| v.as_string())
567            .map(|s| s.to_compact_string())
568            .collect();
569
570        if font_families.is_empty() {
571            return Err(JsValue::from_str("font_family array cannot be empty"));
572        }
573
574        let gl = renderer.gl();
575        let atlas = DynamicFontAtlas::new(gl, &font_families, font_size, current_pixel_ratio, None)
576            .map_err(|e| JsValue::from_str(&format!("Failed to create dynamic atlas: {e}")))?;
577
578        let canvas_size = renderer.physical_size();
579        let terminal_grid =
580            TerminalGrid::new(gl, atlas.into(), canvas_size, current_pixel_ratio)
581                .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {e}")))?;
582
583        let terminal_grid = Rc::new(RefCell::new(terminal_grid));
584        Ok(BeamtermRenderer {
585            renderer,
586            terminal_grid,
587            mouse_handler: None,
588            current_pixel_ratio,
589        })
590    }
591
592    /// Enable default mouse selection behavior with built-in copy to clipboard
593    #[wasm_bindgen(js_name = "enableSelection")]
594    pub fn enable_selection(
595        &mut self,
596        mode: SelectionMode,
597        trim_whitespace: bool,
598    ) -> Result<(), JsValue> {
599        self.enable_selection_internal(mode, trim_whitespace, ModifierKeys::default())
600    }
601
602    /// Enable mouse selection with full configuration options.
603    ///
604    /// This method allows specifying modifier keys that must be held for selection
605    /// to activate, in addition to the selection mode and whitespace trimming.
606    ///
607    /// # Arguments
608    /// * `mode` - Selection mode (Block or Linear)
609    /// * `trim_whitespace` - Whether to trim trailing whitespace from selected text
610    /// * `require_modifiers` - Modifier keys that must be held to start selection
611    ///
612    /// # Example
613    /// ```javascript
614    /// // Require Shift+Click to start selection
615    /// renderer.enableSelectionWithOptions(
616    ///     SelectionMode.Linear,
617    ///     true,
618    ///     ModifierKeys.SHIFT
619    /// );
620    ///
621    /// // Require Ctrl+Shift+Click
622    /// renderer.enableSelectionWithOptions(
623    ///     SelectionMode.Block,
624    ///     false,
625    ///     ModifierKeys.CONTROL.or(ModifierKeys.SHIFT)
626    /// );
627    /// ```
628    #[wasm_bindgen(js_name = "enableSelectionWithOptions")]
629    pub fn enable_selection_with_options(
630        &mut self,
631        mode: SelectionMode,
632        trim_whitespace: bool,
633        require_modifiers: &ModifierKeys,
634    ) -> Result<(), JsValue> {
635        self.enable_selection_internal(mode, trim_whitespace, *require_modifiers)
636    }
637
638    fn enable_selection_internal(
639        &mut self,
640        mode: SelectionMode,
641        trim_whitespace: bool,
642        require_modifiers: ModifierKeys,
643    ) -> Result<(), JsValue> {
644        // clean up existing mouse handler if present
645        if let Some(old_handler) = self.mouse_handler.take() {
646            old_handler.cleanup();
647        }
648
649        let selection_tracker = self.terminal_grid.borrow().selection_tracker();
650        let options = MouseSelectOptions::new()
651            .selection_mode(mode.into())
652            .trim_trailing_whitespace(trim_whitespace)
653            .require_modifier_keys(require_modifiers.into());
654        let handler = DefaultSelectionHandler::new(self.terminal_grid.clone(), options);
655
656        let mut mouse_handler = TerminalMouseHandler::new(
657            self.renderer.canvas(),
658            self.terminal_grid.clone(),
659            handler.create_event_handler(selection_tracker),
660        )
661        .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {e}")))?;
662
663        self.update_mouse_metrics(&mut mouse_handler);
664
665        self.mouse_handler = Some(mouse_handler);
666        Ok(())
667    }
668
669    /// Set a custom mouse event handler
670    #[wasm_bindgen(js_name = "setMouseHandler")]
671    pub fn set_mouse_handler(&mut self, handler: js_sys::Function) -> Result<(), JsValue> {
672        // Clean up existing mouse handler if present
673        if let Some(old_handler) = self.mouse_handler.take() {
674            old_handler.cleanup();
675        }
676
677        let handler_closure = {
678            let handler = handler.clone();
679            move |event: TerminalMouseEvent, _grid: &TerminalGrid| {
680                let js_event = MouseEvent::from(event);
681                let this = JsValue::null();
682                let args = js_sys::Array::new();
683                args.push(&JsValue::from(js_event));
684
685                if let Err(e) = handler.apply(&this, &args) {
686                    console::error_1(&format!("Mouse handler error: {e:?}").into());
687                }
688            }
689        };
690
691        let mut mouse_handler = TerminalMouseHandler::new(
692            self.renderer.canvas(),
693            self.terminal_grid.clone(),
694            handler_closure,
695        )
696        .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {e}")))?;
697
698        self.update_mouse_metrics(&mut mouse_handler);
699
700        self.mouse_handler = Some(mouse_handler);
701        Ok(())
702    }
703
704    /// Get selected text based on a cell query
705    #[wasm_bindgen(js_name = "getText")]
706    pub fn get_text(&self, query: &CellQuery) -> String {
707        self.terminal_grid
708            .borrow()
709            .get_text(query.inner)
710            .to_string()
711    }
712
713    /// Copy text to the system clipboard
714    #[wasm_bindgen(js_name = "copyToClipboard")]
715    pub fn copy_to_clipboard(&self, text: &str) {
716        use wasm_bindgen_futures::spawn_local;
717        let text = text.to_string();
718
719        spawn_local(async move {
720            if let Some(window) = web_sys::window() {
721                let clipboard = window.navigator().clipboard();
722                match wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
723                    Ok(_) => {
724                        console::log_1(
725                            &format!("Copied {} characters to clipboard", text.len()).into(),
726                        );
727                    },
728                    Err(err) => {
729                        console::error_1(&format!("Failed to copy to clipboard: {err:?}").into());
730                    },
731                }
732            }
733        });
734    }
735
736    /// Clear any active selection
737    #[wasm_bindgen(js_name = "clearSelection")]
738    pub fn clear_selection(&self) {
739        self.terminal_grid
740            .borrow()
741            .selection_tracker()
742            .clear();
743    }
744
745    /// Check if there is an active selection
746    #[wasm_bindgen(js_name = "hasSelection")]
747    pub fn has_selection(&self) -> bool {
748        self.terminal_grid
749            .borrow()
750            .selection_tracker()
751            .get_query()
752            .is_some()
753    }
754
755    /// Create a new render batch
756    #[wasm_bindgen(js_name = "batch")]
757    pub fn new_render_batch(&mut self) -> Batch {
758        let gl = self.renderer.gl().clone();
759        let terminal_grid = self.terminal_grid.clone();
760        Batch { terminal_grid, gl }
761    }
762
763    /// Get the terminal dimensions in cells
764    #[wasm_bindgen(js_name = "terminalSize")]
765    pub fn terminal_size(&self) -> Size {
766        let (cols, rows) = self.terminal_grid.borrow().terminal_size();
767        Size { width: cols, height: rows }
768    }
769
770    /// Get the cell size in pixels
771    #[wasm_bindgen(js_name = "cellSize")]
772    pub fn cell_size(&self) -> Size {
773        let (width, height) = self.terminal_grid.borrow().cell_size();
774        Size { width: width as u16, height: height as u16 }
775    }
776
777    /// Render the terminal to the canvas
778    #[wasm_bindgen]
779    pub fn render(&mut self) {
780        // Check for device pixel ratio changes (HiDPI display switching)
781        let raw_dpr = device_pixel_ratio();
782        if (raw_dpr - self.current_pixel_ratio).abs() > f32::EPSILON {
783            let _ = self.handle_pixel_ratio_change(raw_dpr);
784        }
785
786        let mut grid = self.terminal_grid.borrow_mut();
787        let _ = grid.flush_cells(self.renderer.gl());
788
789        self.renderer.begin_frame();
790        self.renderer.render(&*grid);
791        self.renderer.end_frame();
792    }
793
794    /// Handles a change in device pixel ratio.
795    ///
796    /// Callers should verify the ratio has changed before calling this method.
797    fn handle_pixel_ratio_change(&mut self, raw_pixel_ratio: f32) -> Result<(), JsValue> {
798        self.current_pixel_ratio = raw_pixel_ratio;
799
800        let gl = self.renderer.gl();
801
802        // Update atlas (for dynamic atlas, this re-rasterizes glyphs)
803        self.terminal_grid
804            .borrow_mut()
805            .atlas_mut()
806            .update_pixel_ratio(gl, raw_pixel_ratio)
807            .map_err(|e| JsValue::from_str(&format!("Failed to update pixel ratio: {e}")))?;
808
809        // Always use exact DPR for canvas sizing
810        self.renderer.set_pixel_ratio(raw_pixel_ratio);
811
812        // Resize to apply the new pixel ratio
813        let (w, h) = self.renderer.logical_size();
814        self.resize(w, h)
815    }
816
817    /// Resize the terminal to fit new canvas dimensions
818    #[wasm_bindgen]
819    pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
820        self.renderer.resize(width, height);
821
822        let gl = self.renderer.gl();
823        let physical_size = self.renderer.physical_size();
824        self.terminal_grid
825            .borrow_mut()
826            .resize(gl, physical_size, self.current_pixel_ratio)
827            .map_err(|e| JsValue::from_str(&format!("Failed to resize: {e}")))?;
828
829        self.update_mouse_handler_metrics();
830
831        Ok(())
832    }
833
834    /// Updates the mouse handler with current grid metrics (cell size and dimensions).
835    fn update_mouse_handler_metrics(&mut self) {
836        if let Some(mouse_handler) = &mut self.mouse_handler {
837            let grid = self.terminal_grid.borrow();
838            let (cols, rows) = grid.terminal_size();
839            let (phys_width, phys_height) = grid.cell_size();
840            let cell_width = phys_width as f32 / self.current_pixel_ratio;
841            let cell_height = phys_height as f32 / self.current_pixel_ratio;
842            mouse_handler.update_metrics(cols, rows, cell_width, cell_height);
843        }
844    }
845
846    /// Replace the current font atlas with a new static atlas.
847    ///
848    /// This method enables runtime font switching by loading a new `.atlas` file.
849    /// All existing cell content is preserved and translated to the new atlas.
850    ///
851    /// # Arguments
852    /// * `atlas_data` - Binary atlas data (from .atlas file), or null for default
853    ///
854    /// # Example
855    /// ```javascript
856    /// const atlasData = await fetch('new-font.atlas').then(r => r.arrayBuffer());
857    /// renderer.replaceWithStaticAtlas(new Uint8Array(atlasData));
858    /// ```
859    #[wasm_bindgen(js_name = "replaceWithStaticAtlas")]
860    pub fn replace_with_static_atlas(
861        &mut self,
862        atlas_data: Option<js_sys::Uint8Array>,
863    ) -> Result<(), JsValue> {
864        let gl = self.renderer.gl();
865
866        let atlas_config = match atlas_data {
867            Some(data) => {
868                let bytes = data.to_vec();
869                FontAtlasData::from_binary(&bytes)
870                    .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e:?}")))?
871            },
872            None => FontAtlasData::default(),
873        };
874
875        let atlas = StaticFontAtlas::load(gl, atlas_config)
876            .map_err(|e| JsValue::from_str(&format!("Failed to load font atlas: {e}")))?;
877
878        self.terminal_grid
879            .borrow_mut()
880            .replace_atlas(gl, atlas.into());
881
882        self.update_mouse_handler_metrics();
883
884        Ok(())
885    }
886
887    /// Replace the current font atlas with a new dynamic atlas.
888    ///
889    /// This method enables runtime font switching by creating a new dynamic atlas
890    /// with the specified font family and size. All existing cell content is
891    /// preserved and translated to the new atlas.
892    ///
893    /// # Arguments
894    /// * `font_family` - Array of font family names (e.g., `["Hack", "JetBrains Mono"]`)
895    /// * `font_size` - Font size in pixels
896    ///
897    /// # Example
898    /// ```javascript
899    /// renderer.replaceWithDynamicAtlas(["Fira Code", "monospace"], 18.0);
900    /// ```
901    #[wasm_bindgen(js_name = "replaceWithDynamicAtlas")]
902    pub fn replace_with_dynamic_atlas(
903        &mut self,
904        font_family: js_sys::Array,
905        font_size: f32,
906    ) -> Result<(), JsValue> {
907        let font_families: Vec<CompactString> = font_family
908            .iter()
909            .filter_map(|v| v.as_string())
910            .map(|s| s.to_compact_string())
911            .collect();
912
913        if font_families.is_empty() {
914            return Err(JsValue::from_str("font_family array cannot be empty"));
915        }
916
917        let gl = self.renderer.gl();
918        let pixel_ratio = device_pixel_ratio();
919        let atlas = DynamicFontAtlas::new(gl, &font_families, font_size, pixel_ratio, None)
920            .map_err(|e| JsValue::from_str(&format!("Failed to create dynamic atlas: {e}")))?;
921
922        self.terminal_grid
923            .borrow_mut()
924            .replace_atlas(gl, atlas.into());
925
926        self.update_mouse_handler_metrics();
927
928        Ok(())
929    }
930
931    fn update_mouse_metrics(&mut self, mouse_handler: &mut TerminalMouseHandler) {
932        let grid = self.terminal_grid.borrow();
933        let (cols, rows) = grid.terminal_size();
934        let (phys_w, phys_h) = grid.cell_size();
935        let css_w = phys_w as f32 / self.current_pixel_ratio;
936        let css_h = phys_h as f32 / self.current_pixel_ratio;
937        mouse_handler.update_metrics(cols, rows, css_w, css_h);
938    }
939}
940
941// Convert between Rust and WASM types
942impl From<SelectionMode> for RustSelectionMode {
943    fn from(mode: SelectionMode) -> Self {
944        match mode {
945            SelectionMode::Block => RustSelectionMode::Block,
946            SelectionMode::Linear => RustSelectionMode::Linear,
947        }
948    }
949}
950
951impl From<RustSelectionMode> for SelectionMode {
952    fn from(mode: RustSelectionMode) -> Self {
953        match mode {
954            RustSelectionMode::Block => SelectionMode::Block,
955            RustSelectionMode::Linear => SelectionMode::Linear,
956        }
957    }
958}
959
960impl From<TerminalMouseEvent> for MouseEvent {
961    fn from(event: TerminalMouseEvent) -> Self {
962        use crate::mouse::MouseEventType as RustMouseEventType;
963
964        let event_type = match event.event_type {
965            RustMouseEventType::MouseDown => MouseEventType::MouseDown,
966            RustMouseEventType::MouseUp => MouseEventType::MouseUp,
967            RustMouseEventType::MouseMove => MouseEventType::MouseMove,
968        };
969
970        MouseEvent {
971            event_type,
972            col: event.col,
973            row: event.row,
974            button: event.button(),
975            ctrl_key: event.ctrl_key(),
976            shift_key: event.shift_key(),
977            alt_key: event.alt_key(),
978            meta_key: event.meta_key(),
979        }
980    }
981}
982
983impl From<ModifierKeys> for RustModifierKeys {
984    fn from(keys: ModifierKeys) -> Self {
985        RustModifierKeys::from_bits_truncate(keys.0)
986    }
987}
988
989/// Initialize the WASM module
990#[wasm_bindgen(start)]
991pub fn main() {
992    console_error_panic_hook::set_once();
993    console::log_1(&"beamterm WASM module loaded".into());
994}