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
445#[wasm_bindgen]
446impl Cell {
447    #[wasm_bindgen(constructor)]
448    pub fn new(symbol: String, style: &CellStyle) -> Cell {
449        Cell {
450            symbol: symbol.into(),
451            style: style.style_bits,
452            fg: style.fg,
453            bg: style.bg,
454        }
455    }
456
457    #[wasm_bindgen(getter)]
458    pub fn symbol(&self) -> String {
459        self.symbol.to_string()
460    }
461
462    #[wasm_bindgen(setter)]
463    pub fn set_symbol(&mut self, symbol: String) {
464        self.symbol = symbol.into();
465    }
466
467    #[wasm_bindgen(getter)]
468    pub fn fg(&self) -> u32 {
469        self.fg
470    }
471
472    #[wasm_bindgen(setter)]
473    pub fn set_fg(&mut self, color: u32) {
474        self.fg = color;
475    }
476
477    #[wasm_bindgen(getter)]
478    pub fn bg(&self) -> u32 {
479        self.bg
480    }
481
482    #[wasm_bindgen(setter)]
483    pub fn set_bg(&mut self, color: u32) {
484        self.bg = color;
485    }
486
487    #[wasm_bindgen(getter)]
488    pub fn style(&self) -> u16 {
489        self.style
490    }
491
492    #[wasm_bindgen(setter)]
493    pub fn set_style(&mut self, style: u16) {
494        self.style = style;
495    }
496}
497
498impl Cell {
499    pub fn as_cell_data(&self) -> CellData<'_> {
500        CellData::new_with_style_bits(&self.symbol, self.style, self.fg, self.bg)
501    }
502}
503
504#[wasm_bindgen]
505impl BeamtermRenderer {
506    /// Create a new terminal renderer with the default embedded font atlas.
507    #[wasm_bindgen(constructor)]
508    pub fn new(canvas_id: &str) -> Result<BeamtermRenderer, JsValue> {
509        Self::with_static_atlas(canvas_id, None, None)
510    }
511
512    /// Create a terminal renderer with custom static font atlas data.
513    ///
514    /// # Arguments
515    /// * `canvas_id` - CSS selector for the canvas element
516    /// * `atlas_data` - Binary atlas data (from .atlas file), or null for default
517    /// * `auto_resize_canvas_css` - Whether to automatically set canvas CSS dimensions
518    ///   on resize. Set to `false` when external CSS (flexbox, grid) controls sizing.
519    ///   Defaults to `true` if not specified.
520    #[wasm_bindgen(js_name = "withStaticAtlas")]
521    pub fn with_static_atlas(
522        canvas_id: &str,
523        atlas_data: Option<js_sys::Uint8Array>,
524        auto_resize_canvas_css: Option<bool>,
525    ) -> Result<BeamtermRenderer, JsValue> {
526        console_error_panic_hook::set_once();
527
528        let atlas =
529            match atlas_data {
530                Some(data) => {
531                    let bytes = data.to_vec();
532                    Some(FontAtlasData::from_binary(&bytes).map_err(|e| {
533                        JsValue::from_str(&format!("Failed to parse atlas data: {e}"))
534                    })?)
535                },
536                None => None,
537            };
538
539        let mut builder = Terminal::builder(canvas_id)
540            .auto_resize_canvas_css(auto_resize_canvas_css.unwrap_or(true));
541
542        if let Some(atlas) = atlas {
543            builder = builder.font_atlas(atlas);
544        }
545
546        let terminal = builder.build()?;
547
548        Ok(BeamtermRenderer { terminal })
549    }
550
551    /// Create a terminal renderer with a dynamic font atlas using browser fonts.
552    ///
553    /// The dynamic atlas rasterizes glyphs on-demand using the browser's canvas API,
554    /// enabling support for any system font, emoji, and complex scripts.
555    ///
556    /// # Arguments
557    /// * `canvas_id` - CSS selector for the canvas element
558    /// * `font_family` - Array of font family names (e.g., `["Hack", "JetBrains Mono"]`)
559    /// * `font_size` - Font size in pixels
560    /// * `auto_resize_canvas_css` - Whether to automatically set canvas CSS dimensions
561    ///   on resize. Set to `false` when external CSS (flexbox, grid) controls sizing.
562    ///   Defaults to `true` if not specified.
563    ///
564    /// # Example
565    /// ```javascript
566    /// const renderer = BeamtermRenderer.withDynamicAtlas(
567    ///     "#terminal",
568    ///     ["JetBrains Mono", "Fira Code"],
569    ///     16.0
570    /// );
571    /// ```
572    #[wasm_bindgen(js_name = "withDynamicAtlas")]
573    pub fn with_dynamic_atlas(
574        canvas_id: &str,
575        font_family: js_sys::Array,
576        font_size: f32,
577        auto_resize_canvas_css: Option<bool>,
578    ) -> Result<BeamtermRenderer, JsValue> {
579        console_error_panic_hook::set_once();
580
581        let font_families: Vec<String> = font_family
582            .iter()
583            .filter_map(|v| v.as_string())
584            .collect();
585
586        if font_families.is_empty() {
587            return Err(JsValue::from_str("font_family array cannot be empty"));
588        }
589
590        let refs: Vec<&str> = font_families.iter().map(String::as_str).collect();
591
592        let terminal = Terminal::builder(canvas_id)
593            .auto_resize_canvas_css(auto_resize_canvas_css.unwrap_or(true))
594            .dynamic_font_atlas(&refs, font_size)
595            .build()?;
596
597        Ok(BeamtermRenderer { terminal })
598    }
599
600    /// Enable default mouse selection behavior with built-in copy to clipboard
601    #[wasm_bindgen(js_name = "enableSelection")]
602    pub fn enable_selection(
603        &mut self,
604        mode: SelectionMode,
605        trim_whitespace: bool,
606    ) -> Result<(), JsValue> {
607        self.enable_selection_with_options(mode, trim_whitespace, &ModifierKeys::default())
608    }
609
610    /// Enable mouse selection with full configuration options.
611    ///
612    /// This method allows specifying modifier keys that must be held for selection
613    /// to activate, in addition to the selection mode and whitespace trimming.
614    ///
615    /// # Arguments
616    /// * `mode` - Selection mode (Block or Linear)
617    /// * `trim_whitespace` - Whether to trim trailing whitespace from selected text
618    /// * `require_modifiers` - Modifier keys that must be held to start selection
619    ///
620    /// # Example
621    /// ```javascript
622    /// // Require Shift+Click to start selection
623    /// renderer.enableSelectionWithOptions(
624    ///     SelectionMode.Linear,
625    ///     true,
626    ///     ModifierKeys.SHIFT
627    /// );
628    ///
629    /// // Require Ctrl+Shift+Click
630    /// renderer.enableSelectionWithOptions(
631    ///     SelectionMode.Block,
632    ///     false,
633    ///     ModifierKeys.CONTROL.or(ModifierKeys.SHIFT)
634    /// );
635    /// ```
636    #[wasm_bindgen(js_name = "enableSelectionWithOptions")]
637    pub fn enable_selection_with_options(
638        &mut self,
639        mode: SelectionMode,
640        trim_whitespace: bool,
641        require_modifiers: &ModifierKeys,
642    ) -> Result<(), JsValue> {
643        let options = MouseSelectOptions::new()
644            .selection_mode(mode.into())
645            .trim_trailing_whitespace(trim_whitespace)
646            .require_modifier_keys((*require_modifiers).into());
647
648        Ok(self.terminal.enable_mouse_selection(options)?)
649    }
650
651    /// Set a custom mouse event handler
652    #[wasm_bindgen(js_name = "setMouseHandler")]
653    pub fn set_mouse_handler(&mut self, handler: js_sys::Function) -> Result<(), JsValue> {
654        let handler_closure = {
655            let handler = handler.clone();
656            move |event: TerminalMouseEvent, _grid: &TerminalGrid| {
657                let js_event = MouseEvent::from(event);
658                let this = JsValue::null();
659                let args = js_sys::Array::new();
660                args.push(&JsValue::from(js_event));
661
662                if let Err(e) = handler.apply(&this, &args) {
663                    console::error_1(&format!("Mouse handler error: {e:?}").into());
664                }
665            }
666        };
667
668        Ok(self
669            .terminal
670            .set_mouse_callback(handler_closure)?)
671    }
672
673    /// Get selected text based on a cell query
674    #[wasm_bindgen(js_name = "getText")]
675    pub fn get_text(&self, query: &CellQuery) -> String {
676        self.terminal.get_text(query.inner).to_string()
677    }
678
679    /// Detects an HTTP/HTTPS URL at or around the given cell position.
680    ///
681    /// Scans left from the position to find a URL scheme (`http://` or `https://`),
682    /// then scans right to find the URL end. Handles trailing punctuation and
683    /// unbalanced parentheses (e.g., Wikipedia URLs).
684    ///
685    /// Returns `undefined` if no URL is found at the position.
686    ///
687    /// **Note:** Only detects URLs within a single row. URLs that wrap across
688    /// multiple lines are not supported.
689    ///
690    /// # Example
691    /// ```javascript
692    /// // In a mouse handler:
693    /// renderer.setMouseHandler((event) => {
694    ///     const match = renderer.findUrlAt(event.col, event.row);
695    ///     if (match) {
696    ///         console.log("URL found:", match.url);
697    ///         // match.query can be used for highlighting
698    ///     }
699    /// });
700    /// ```
701    #[wasm_bindgen(js_name = "findUrlAt")]
702    pub fn find_url_at(&self, col: u16, row: u16) -> Option<UrlMatch> {
703        let cursor = CursorPosition::new(col, row);
704        self.terminal
705            .find_url_at(cursor)
706            .map(|m| UrlMatch {
707                url: m.url.to_string(),
708                query: CellQuery { inner: m.query },
709            })
710    }
711
712    /// Copy text to the system clipboard
713    #[wasm_bindgen(js_name = "copyToClipboard")]
714    pub fn copy_to_clipboard(&self, text: &str) {
715        crate::js::copy_to_clipboard(text);
716    }
717
718    /// Clear any active selection
719    #[wasm_bindgen(js_name = "clearSelection")]
720    pub fn clear_selection(&self) {
721        self.terminal.clear_selection();
722    }
723
724    /// Check if there is an active selection
725    #[wasm_bindgen(js_name = "hasSelection")]
726    pub fn has_selection(&self) -> bool {
727        self.terminal.has_selection()
728    }
729
730    /// Create a new render batch
731    #[wasm_bindgen(js_name = "batch")]
732    pub fn new_render_batch(&mut self) -> Batch {
733        Batch { terminal_grid: self.terminal.grid() }
734    }
735
736    /// Get the terminal dimensions in cells
737    #[wasm_bindgen(js_name = "terminalSize")]
738    pub fn terminal_size(&self) -> WasmTerminalSize {
739        let ts = self.terminal.terminal_size();
740        WasmTerminalSize { cols: ts.cols, rows: ts.rows }
741    }
742
743    /// Get the cell size in pixels
744    #[wasm_bindgen(js_name = "cellSize")]
745    pub fn cell_size(&self) -> Size {
746        let cs = self.terminal.cell_size();
747        Size { width: cs.width as u16, height: cs.height as u16 }
748    }
749
750    /// Render the terminal to the canvas
751    #[wasm_bindgen]
752    pub fn render(&mut self) {
753        if let Err(e) = self.terminal.render_frame() {
754            console::error_1(&format!("Render error: {e:?}").into());
755        }
756    }
757
758    /// Resize the terminal to fit new canvas dimensions
759    #[wasm_bindgen]
760    pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
761        Ok(self.terminal.resize(width, height)?)
762    }
763
764    /// Replace the current font atlas with a new static atlas.
765    ///
766    /// This method enables runtime font switching by loading a new `.atlas` file.
767    /// All existing cell content is preserved and translated to the new atlas.
768    ///
769    /// # Arguments
770    /// * `atlas_data` - Binary atlas data (from .atlas file), or null for default
771    ///
772    /// # Example
773    /// ```javascript
774    /// const atlasData = await fetch('new-font.atlas').then(r => r.arrayBuffer());
775    /// renderer.replaceWithStaticAtlas(new Uint8Array(atlasData));
776    /// ```
777    #[wasm_bindgen(js_name = "replaceWithStaticAtlas")]
778    pub fn replace_with_static_atlas(
779        &mut self,
780        atlas_data: Option<js_sys::Uint8Array>,
781    ) -> Result<(), JsValue> {
782        let atlas_config = match atlas_data {
783            Some(data) => {
784                let bytes = data.to_vec();
785                FontAtlasData::from_binary(&bytes)
786                    .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e}")))?
787            },
788            None => FontAtlasData::default(),
789        };
790
791        Ok(self
792            .terminal
793            .replace_with_static_atlas(atlas_config)?)
794    }
795
796    /// Replace the current font atlas with a new dynamic atlas.
797    ///
798    /// This method enables runtime font switching by creating a new dynamic atlas
799    /// with the specified font family and size. All existing cell content is
800    /// preserved and translated to the new atlas.
801    ///
802    /// # Arguments
803    /// * `font_family` - Array of font family names (e.g., `["Hack", "JetBrains Mono"]`)
804    /// * `font_size` - Font size in pixels
805    ///
806    /// # Example
807    /// ```javascript
808    /// renderer.replaceWithDynamicAtlas(["Fira Code", "monospace"], 18.0);
809    /// ```
810    #[wasm_bindgen(js_name = "replaceWithDynamicAtlas")]
811    pub fn replace_with_dynamic_atlas(
812        &mut self,
813        font_family: js_sys::Array,
814        font_size: f32,
815    ) -> Result<(), JsValue> {
816        let font_families: Vec<String> = font_family
817            .iter()
818            .filter_map(|v| v.as_string())
819            .collect();
820
821        if font_families.is_empty() {
822            return Err(JsValue::from_str("font_family array cannot be empty"));
823        }
824
825        let refs: Vec<&str> = font_families.iter().map(String::as_str).collect();
826        Ok(self
827            .terminal
828            .replace_with_dynamic_atlas(&refs, font_size)?)
829    }
830}
831
832// Convert between Rust and WASM types
833impl From<SelectionMode> for RustSelectionMode {
834    fn from(mode: SelectionMode) -> Self {
835        match mode {
836            SelectionMode::Block => RustSelectionMode::Block,
837            SelectionMode::Linear => RustSelectionMode::Linear,
838        }
839    }
840}
841
842impl From<RustSelectionMode> for SelectionMode {
843    fn from(mode: RustSelectionMode) -> Self {
844        match mode {
845            RustSelectionMode::Block => SelectionMode::Block,
846            RustSelectionMode::Linear => SelectionMode::Linear,
847            _ => unreachable!(),
848        }
849    }
850}
851
852impl From<TerminalMouseEvent> for MouseEvent {
853    fn from(event: TerminalMouseEvent) -> Self {
854        use crate::mouse::MouseEventType as RustMouseEventType;
855
856        let event_type = match event.event_type {
857            RustMouseEventType::MouseDown => MouseEventType::MouseDown,
858            RustMouseEventType::MouseUp => MouseEventType::MouseUp,
859            RustMouseEventType::MouseMove => MouseEventType::MouseMove,
860            RustMouseEventType::Click => MouseEventType::Click,
861            RustMouseEventType::MouseEnter => MouseEventType::MouseEnter,
862            RustMouseEventType::MouseLeave => MouseEventType::MouseLeave,
863        };
864
865        MouseEvent {
866            event_type,
867            col: event.col,
868            row: event.row,
869            button: event.button(),
870            ctrl_key: event.ctrl_key(),
871            shift_key: event.shift_key(),
872            alt_key: event.alt_key(),
873            meta_key: event.meta_key(),
874        }
875    }
876}
877
878impl From<ModifierKeys> for RustModifierKeys {
879    fn from(keys: ModifierKeys) -> Self {
880        RustModifierKeys::from_bits_truncate(keys.0)
881    }
882}
883
884/// Initialize the WASM module
885#[wasm_bindgen(start)]
886pub fn main() {
887    console_error_panic_hook::set_once();
888    console::log_1(&"beamterm WASM module loaded".into());
889}