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