Skip to main content

beamterm_renderer/
wasm.rs

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