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 wasm_bindgen::prelude::*;
8use web_sys::console;
9
10use crate::{
11    gl::{
12        CellData, CellQuery as RustCellQuery, DynamicFontAtlas, Renderer,
13        SelectionMode as RustSelectionMode, StaticFontAtlas, TerminalGrid, select,
14    },
15    mouse::{DefaultSelectionHandler, TerminalMouseEvent, TerminalMouseHandler},
16    terminal::is_double_width,
17};
18
19/// JavaScript wrapper for the terminal renderer
20#[wasm_bindgen]
21#[derive(Debug)]
22pub struct BeamtermRenderer {
23    renderer: Renderer,
24    terminal_grid: Rc<RefCell<TerminalGrid>>,
25    mouse_handler: Option<TerminalMouseHandler>,
26}
27
28/// JavaScript wrapper for cell data
29#[wasm_bindgen]
30#[derive(Debug, Default, serde::Deserialize)]
31pub struct Cell {
32    symbol: CompactString,
33    style: u16,
34    fg: u32,
35    bg: u32,
36}
37
38#[wasm_bindgen]
39#[derive(Debug, Clone, Copy)]
40pub struct CellStyle {
41    fg: u32,
42    bg: u32,
43    style_bits: u16,
44}
45
46#[wasm_bindgen]
47#[derive(Debug, Clone, Copy)]
48pub struct Size {
49    pub width: u16,
50    pub height: u16,
51}
52
53#[wasm_bindgen]
54#[derive(Debug)]
55pub struct Batch {
56    terminal_grid: Rc<RefCell<TerminalGrid>>,
57    gl: web_sys::WebGl2RenderingContext,
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}
81
82/// Mouse event data with terminal coordinates
83#[wasm_bindgen]
84#[derive(Debug, Clone, Copy)]
85pub struct MouseEvent {
86    /// Type of mouse event
87    pub event_type: MouseEventType,
88    /// Column in terminal grid (0-based)
89    pub col: u16,
90    /// Row in terminal grid (0-based)
91    pub row: u16,
92    /// Mouse button (0=left, 1=middle, 2=right)
93    pub button: i16,
94    /// Whether Ctrl key was pressed
95    pub ctrl_key: bool,
96    /// Whether Shift key was pressed
97    pub shift_key: bool,
98    /// Whether Alt key was pressed
99    pub alt_key: bool,
100}
101
102/// Query for selecting cells in the terminal
103#[wasm_bindgen]
104#[derive(Debug, Clone)]
105pub struct CellQuery {
106    inner: RustCellQuery,
107}
108
109#[wasm_bindgen]
110impl CellQuery {
111    /// Create a new cell query with the specified selection mode
112    #[wasm_bindgen(constructor)]
113    pub fn new(mode: SelectionMode) -> CellQuery {
114        CellQuery { inner: select(mode.into()) }
115    }
116
117    /// Set the starting position for the selection
118    pub fn start(mut self, col: u16, row: u16) -> CellQuery {
119        self.inner = self.inner.start((col, row));
120        self
121    }
122
123    /// Set the ending position for the selection
124    pub fn end(mut self, col: u16, row: u16) -> CellQuery {
125        self.inner = self.inner.end((col, row));
126        self
127    }
128
129    /// Configure whether to trim trailing whitespace from lines
130    #[wasm_bindgen(js_name = "trimTrailingWhitespace")]
131    pub fn trim_trailing_whitespace(mut self, enabled: bool) -> CellQuery {
132        self.inner = self.inner.trim_trailing_whitespace(enabled);
133        self
134    }
135
136    /// Check if the query is empty (no selection range)
137    #[wasm_bindgen(js_name = "isEmpty")]
138    pub fn is_empty(&self) -> bool {
139        self.inner.is_empty()
140    }
141}
142
143#[wasm_bindgen]
144pub fn style() -> CellStyle {
145    CellStyle::new()
146}
147
148#[wasm_bindgen]
149pub fn cell(symbol: &str, style: CellStyle) -> Cell {
150    Cell {
151        symbol: symbol.into(),
152        style: style.style_bits,
153        fg: style.fg,
154        bg: style.bg,
155    }
156}
157
158#[wasm_bindgen]
159impl CellStyle {
160    /// Create a new TextStyle with default (normal) style
161    #[wasm_bindgen(constructor)]
162    pub fn new() -> CellStyle {
163        Default::default()
164    }
165
166    /// Sets the foreground color
167    #[wasm_bindgen]
168    pub fn fg(mut self, color: u32) -> CellStyle {
169        self.fg = color;
170        self
171    }
172
173    /// Sets the background color
174    #[wasm_bindgen]
175    pub fn bg(mut self, color: u32) -> CellStyle {
176        self.bg = color;
177        self
178    }
179
180    /// Add bold style
181    #[wasm_bindgen]
182    pub fn bold(mut self) -> CellStyle {
183        self.style_bits |= Glyph::BOLD_FLAG;
184        self
185    }
186
187    /// Add italic style
188    #[wasm_bindgen]
189    pub fn italic(mut self) -> CellStyle {
190        self.style_bits |= Glyph::ITALIC_FLAG;
191        self
192    }
193
194    /// Add underline effect
195    #[wasm_bindgen]
196    pub fn underline(mut self) -> CellStyle {
197        self.style_bits |= Glyph::UNDERLINE_FLAG;
198        self
199    }
200
201    /// Add strikethrough effect
202    #[wasm_bindgen]
203    pub fn strikethrough(mut self) -> CellStyle {
204        self.style_bits |= Glyph::STRIKETHROUGH_FLAG;
205        self
206    }
207
208    /// Get the combined style bits
209    #[wasm_bindgen(getter)]
210    pub fn bits(&self) -> u16 {
211        self.style_bits
212    }
213}
214
215impl Default for CellStyle {
216    fn default() -> Self {
217        CellStyle {
218            fg: 0xFFFFFF,  // Default foreground color (white)
219            bg: 0x000000,  // Default background color (black)
220            style_bits: 0, // No styles applied
221        }
222    }
223}
224
225#[wasm_bindgen]
226impl Batch {
227    /// Updates a single cell at the given position.
228    #[wasm_bindgen(js_name = "cell")]
229    pub fn cell(&mut self, x: u16, y: u16, cell_data: &Cell) {
230        let _ = self
231            .terminal_grid
232            .borrow_mut()
233            .update_cell(x, y, cell_data.as_cell_data());
234    }
235
236    /// Updates a cell by its buffer index.
237    #[wasm_bindgen(js_name = "cellByIndex")]
238    pub fn cell_by_index(&mut self, idx: usize, cell_data: &Cell) {
239        let _ = self
240            .terminal_grid
241            .borrow_mut()
242            .update_cell_by_index(idx, cell_data.as_cell_data());
243    }
244
245    /// Updates multiple cells from an array.
246    /// Each element should be [x, y, cellData].
247    #[wasm_bindgen(js_name = "cells")]
248    pub fn cells(&mut self, cells_json: JsValue) -> Result<(), JsValue> {
249        let updates = from_value::<Vec<(u16, u16, Cell)>>(cells_json)
250            .map_err(|e| JsValue::from_str(&e.to_string()));
251
252        match updates {
253            Ok(cells) => {
254                let cell_data = cells
255                    .iter()
256                    .map(|(x, y, data)| (*x, *y, data.as_cell_data()));
257
258                let mut terminal_grid = self.terminal_grid.borrow_mut();
259                terminal_grid
260                    .update_cells_by_position(cell_data)
261                    .map_err(|e| JsValue::from_str(&e.to_string()))
262            },
263            e => e.map(|_| ()),
264        }
265    }
266
267    /// Write text to the terminal
268    #[wasm_bindgen(js_name = "text")]
269    pub fn text(&mut self, x: u16, y: u16, text: &str, style: &CellStyle) -> Result<(), JsValue> {
270        let mut terminal_grid = self.terminal_grid.borrow_mut();
271        let (cols, rows) = terminal_grid.terminal_size();
272
273        if y >= rows {
274            return Ok(()); // oob, ignore
275        }
276
277        let mut width_offset: u16 = 0;
278        for (i, ch) in text.graphemes(true).enumerate() {
279            let current_col = x + width_offset + i as u16;
280            if current_col >= cols {
281                break;
282            }
283
284            let cell = CellData::new_with_style_bits(ch, style.style_bits, style.fg, style.bg);
285            terminal_grid
286                .update_cell(current_col, y, cell)
287                .map_err(|e| JsValue::from_str(&e.to_string()))?;
288
289            if is_double_width(ch) {
290                width_offset += 1;
291            }
292        }
293
294        Ok(())
295    }
296
297    /// Fill a rectangular region
298    #[wasm_bindgen(js_name = "fill")]
299    pub fn fill(
300        &mut self,
301        x: u16,
302        y: u16,
303        width: u16,
304        height: u16,
305        cell_data: &Cell,
306    ) -> Result<(), JsValue> {
307        let mut terminal_grid = self.terminal_grid.borrow_mut();
308        let (cols, rows) = terminal_grid.terminal_size();
309
310        let width = (x + width).min(cols).saturating_sub(x);
311        let height = (y + height).min(rows).saturating_sub(y);
312
313        let fill_cell = cell_data.as_cell_data();
314        for y in y..y + height {
315            for x in x..x + width {
316                terminal_grid
317                    .update_cell(x, y, fill_cell)
318                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
319            }
320        }
321
322        Ok(())
323    }
324
325    /// Clear the terminal with specified background color
326    #[wasm_bindgen]
327    pub fn clear(&mut self, bg: u32) -> Result<(), JsValue> {
328        let mut terminal_grid = self.terminal_grid.borrow_mut();
329        let (cols, rows) = terminal_grid.terminal_size();
330
331        let clear_cell = CellData::new_with_style_bits(" ", 0, 0xFFFFFF, bg);
332        for y in 0..rows {
333            for x in 0..cols {
334                terminal_grid
335                    .update_cell(x, y, clear_cell)
336                    .map_err(|e| JsValue::from_str(&e.to_string()))?;
337            }
338        }
339
340        Ok(())
341    }
342
343    /// Synchronize all pending updates to the GPU
344    #[wasm_bindgen]
345    #[deprecated(since = "0.4.0", note = "no-op, flush is now automatic")]
346    #[allow(deprecated)]
347    pub fn flush(&mut self) -> Result<(), JsValue> {
348        Ok(())
349    }
350}
351
352#[wasm_bindgen]
353impl Cell {
354    #[wasm_bindgen(constructor)]
355    pub fn new(symbol: String, style: &CellStyle) -> Cell {
356        Cell {
357            symbol: symbol.into(),
358            style: style.style_bits,
359            fg: style.fg,
360            bg: style.bg,
361        }
362    }
363
364    #[wasm_bindgen(getter)]
365    pub fn symbol(&self) -> String {
366        self.symbol.to_string()
367    }
368
369    #[wasm_bindgen(setter)]
370    pub fn set_symbol(&mut self, symbol: String) {
371        self.symbol = symbol.into();
372    }
373
374    #[wasm_bindgen(getter)]
375    pub fn fg(&self) -> u32 {
376        self.fg
377    }
378
379    #[wasm_bindgen(setter)]
380    pub fn set_fg(&mut self, color: u32) {
381        self.fg = color;
382    }
383
384    #[wasm_bindgen(getter)]
385    pub fn bg(&self) -> u32 {
386        self.bg
387    }
388
389    #[wasm_bindgen(setter)]
390    pub fn set_bg(&mut self, color: u32) {
391        self.bg = color;
392    }
393
394    #[wasm_bindgen(getter)]
395    pub fn style(&self) -> u16 {
396        self.style
397    }
398
399    #[wasm_bindgen(setter)]
400    pub fn set_style(&mut self, style: u16) {
401        self.style = style;
402    }
403}
404
405impl Cell {
406    pub fn as_cell_data(&self) -> CellData<'_> {
407        CellData::new_with_style_bits(&self.symbol, self.style, self.fg, self.bg)
408    }
409}
410
411#[wasm_bindgen]
412impl BeamtermRenderer {
413    /// Create a new terminal renderer with the default embedded font atlas.
414    #[wasm_bindgen(constructor)]
415    pub fn new(canvas_id: &str) -> Result<BeamtermRenderer, JsValue> {
416        Self::with_static_atlas(canvas_id, None)
417    }
418
419    /// Create a terminal renderer with custom static font atlas data.
420    ///
421    /// # Arguments
422    /// * `canvas_id` - CSS selector for the canvas element
423    /// * `atlas_data` - Binary atlas data (from .atlas file), or null for default
424    #[wasm_bindgen(js_name = "withStaticAtlas")]
425    pub fn with_static_atlas(
426        canvas_id: &str,
427        atlas_data: Option<js_sys::Uint8Array>,
428    ) -> Result<BeamtermRenderer, JsValue> {
429        console_error_panic_hook::set_once();
430
431        let renderer = Renderer::create(canvas_id)
432            .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {e}")))?;
433
434        let gl = renderer.gl();
435        let atlas_config = match atlas_data {
436            Some(data) => {
437                let bytes = data.to_vec();
438                FontAtlasData::from_binary(&bytes)
439                    .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e:?}")))?
440            },
441            None => FontAtlasData::default(),
442        };
443
444        let atlas = StaticFontAtlas::load(gl, atlas_config)
445            .map_err(|e| JsValue::from_str(&format!("Failed to load font atlas: {e}")))?;
446
447        let canvas_size = renderer.canvas_size();
448        let terminal_grid = TerminalGrid::new(gl, atlas.into(), canvas_size)
449            .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {e}")))?;
450
451        console::log_1(&"BeamtermRenderer initialized with static atlas".into());
452        let terminal_grid = Rc::new(RefCell::new(terminal_grid));
453        Ok(BeamtermRenderer { renderer, terminal_grid, mouse_handler: None })
454    }
455
456    /// Create a terminal renderer with a dynamic font atlas using browser fonts.
457    ///
458    /// The dynamic atlas rasterizes glyphs on-demand using the browser's canvas API,
459    /// enabling support for any system font, emoji, and complex scripts.
460    ///
461    /// # Arguments
462    /// * `canvas_id` - CSS selector for the canvas element
463    /// * `font_family` - Array of font family names (e.g., `["Hack", "JetBrains Mono"]`)
464    /// * `font_size` - Font size in pixels
465    ///
466    /// # Example
467    /// ```javascript
468    /// const renderer = BeamtermRenderer.withDynamicAtlas(
469    ///     "#terminal",
470    ///     ["JetBrains Mono", "Fira Code"],
471    ///     16.0
472    /// );
473    /// ```
474    #[wasm_bindgen(js_name = "withDynamicAtlas")]
475    pub fn with_dynamic_atlas(
476        canvas_id: &str,
477        font_family: js_sys::Array,
478        font_size: f32,
479    ) -> Result<BeamtermRenderer, JsValue> {
480        console_error_panic_hook::set_once();
481
482        let renderer = Renderer::create(canvas_id)
483            .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {e}")))?;
484
485        let font_families: Vec<CompactString> = font_family
486            .iter()
487            .filter_map(|v| v.as_string())
488            .map(|s| s.to_compact_string())
489            .collect();
490
491        if font_families.is_empty() {
492            return Err(JsValue::from_str("font_family array cannot be empty"));
493        }
494
495        let gl = renderer.gl();
496        let atlas = DynamicFontAtlas::new(gl, &font_families, font_size, None)
497            .map_err(|e| JsValue::from_str(&format!("Failed to create dynamic atlas: {e}")))?;
498
499        let canvas_size = renderer.canvas_size();
500        let terminal_grid = TerminalGrid::new(gl, atlas.into(), canvas_size)
501            .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {e}")))?;
502
503        console::log_1(
504            &format!(
505                "BeamtermRenderer initialized with dynamic atlas (font: {}, size: {}px)",
506                font_families.join(", "),
507                font_size
508            )
509            .into(),
510        );
511        let terminal_grid = Rc::new(RefCell::new(terminal_grid));
512        Ok(BeamtermRenderer { renderer, terminal_grid, mouse_handler: None })
513    }
514
515    /// Enable default mouse selection behavior with built-in copy to clipboard
516    #[wasm_bindgen(js_name = "enableSelection")]
517    pub fn enable_selection(
518        &mut self,
519        mode: SelectionMode,
520        trim_whitespace: bool,
521    ) -> Result<(), JsValue> {
522        // clean up existing mouse handler if present
523        if let Some(old_handler) = self.mouse_handler.take() {
524            old_handler.cleanup();
525        }
526
527        let selection_tracker = self.terminal_grid.borrow().selection_tracker();
528        let handler =
529            DefaultSelectionHandler::new(self.terminal_grid.clone(), mode.into(), trim_whitespace);
530
531        let mouse_handler = TerminalMouseHandler::new(
532            self.renderer.canvas(),
533            self.terminal_grid.clone(),
534            handler.create_event_handler(selection_tracker),
535        )
536        .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {e}")))?;
537
538        self.mouse_handler = Some(mouse_handler);
539        Ok(())
540    }
541
542    /// Set a custom mouse event handler
543    #[wasm_bindgen(js_name = "setMouseHandler")]
544    pub fn set_mouse_handler(&mut self, handler: js_sys::Function) -> Result<(), JsValue> {
545        // Clean up existing mouse handler if present
546        if let Some(old_handler) = self.mouse_handler.take() {
547            old_handler.cleanup();
548        }
549
550        let handler_closure = {
551            let handler = handler.clone();
552            move |event: TerminalMouseEvent, _grid: &TerminalGrid| {
553                let js_event = MouseEvent::from(event);
554                let this = JsValue::null();
555                let args = js_sys::Array::new();
556                args.push(&JsValue::from(js_event));
557
558                if let Err(e) = handler.apply(&this, &args) {
559                    console::error_1(&format!("Mouse handler error: {e:?}").into());
560                }
561            }
562        };
563
564        let mouse_handler = TerminalMouseHandler::new(
565            self.renderer.canvas(),
566            self.terminal_grid.clone(),
567            handler_closure,
568        )
569        .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {e}")))?;
570
571        self.mouse_handler = Some(mouse_handler);
572        Ok(())
573    }
574
575    /// Get selected text based on a cell query
576    #[wasm_bindgen(js_name = "getText")]
577    pub fn get_text(&self, query: &CellQuery) -> String {
578        self.terminal_grid
579            .borrow()
580            .get_text(query.inner)
581            .to_string()
582    }
583
584    /// Copy text to the system clipboard
585    #[wasm_bindgen(js_name = "copyToClipboard")]
586    pub fn copy_to_clipboard(&self, text: &str) {
587        use wasm_bindgen_futures::spawn_local;
588        let text = text.to_string();
589
590        spawn_local(async move {
591            if let Some(window) = web_sys::window() {
592                let clipboard = window.navigator().clipboard();
593                match wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
594                    Ok(_) => {
595                        console::log_1(
596                            &format!("Copied {} characters to clipboard", text.len()).into(),
597                        );
598                    },
599                    Err(err) => {
600                        console::error_1(&format!("Failed to copy to clipboard: {err:?}").into());
601                    },
602                }
603            }
604        });
605    }
606
607    /// Clear any active selection
608    #[wasm_bindgen(js_name = "clearSelection")]
609    pub fn clear_selection(&self) {
610        self.terminal_grid
611            .borrow()
612            .selection_tracker()
613            .clear();
614    }
615
616    /// Check if there is an active selection
617    #[wasm_bindgen(js_name = "hasSelection")]
618    pub fn has_selection(&self) -> bool {
619        self.terminal_grid
620            .borrow()
621            .selection_tracker()
622            .get_query()
623            .is_some()
624    }
625
626    /// Create a new render batch
627    #[wasm_bindgen(js_name = "batch")]
628    pub fn new_render_batch(&mut self) -> Batch {
629        let gl = self.renderer.gl().clone();
630        let terminal_grid = self.terminal_grid.clone();
631        Batch { terminal_grid, gl }
632    }
633
634    /// Get the terminal dimensions in cells
635    #[wasm_bindgen(js_name = "terminalSize")]
636    pub fn terminal_size(&self) -> Size {
637        let (cols, rows) = self.terminal_grid.borrow().terminal_size();
638        Size { width: cols, height: rows }
639    }
640
641    /// Get the cell size in pixels
642    #[wasm_bindgen(js_name = "cellSize")]
643    pub fn cell_size(&self) -> Size {
644        let (width, height) = self.terminal_grid.borrow().cell_size();
645        Size { width: width as u16, height: height as u16 }
646    }
647
648    /// Render the terminal to the canvas
649    #[wasm_bindgen]
650    pub fn render(&mut self) {
651        let mut grid = self.terminal_grid.borrow_mut();
652        let _ = grid.flush_cells(self.renderer.gl());
653
654        self.renderer.begin_frame();
655        self.renderer.render(&*grid);
656        self.renderer.end_frame();
657    }
658
659    /// Resize the terminal to fit new canvas dimensions
660    #[wasm_bindgen]
661    pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
662        self.renderer.resize(width, height);
663
664        let gl = self.renderer.gl();
665        self.terminal_grid
666            .borrow_mut()
667            .resize(gl, (width, height))
668            .map_err(|e| JsValue::from_str(&format!("Failed to resize: {e}")))?;
669
670        // Update mouse handler dimensions if present
671        if let Some(mouse_handler) = &mut self.mouse_handler {
672            let (cols, rows) = self.terminal_grid.borrow().terminal_size();
673            mouse_handler.update_dimensions(cols, rows);
674        }
675
676        Ok(())
677    }
678}
679
680// Convert between Rust and WASM types
681impl From<SelectionMode> for RustSelectionMode {
682    fn from(mode: SelectionMode) -> Self {
683        match mode {
684            SelectionMode::Block => RustSelectionMode::Block,
685            SelectionMode::Linear => RustSelectionMode::Linear,
686        }
687    }
688}
689
690impl From<RustSelectionMode> for SelectionMode {
691    fn from(mode: RustSelectionMode) -> Self {
692        match mode {
693            RustSelectionMode::Block => SelectionMode::Block,
694            RustSelectionMode::Linear => SelectionMode::Linear,
695        }
696    }
697}
698
699impl From<TerminalMouseEvent> for MouseEvent {
700    fn from(event: TerminalMouseEvent) -> Self {
701        use crate::mouse::MouseEventType as RustMouseEventType;
702
703        let event_type = match event.event_type {
704            RustMouseEventType::MouseDown => MouseEventType::MouseDown,
705            RustMouseEventType::MouseUp => MouseEventType::MouseUp,
706            RustMouseEventType::MouseMove => MouseEventType::MouseMove,
707        };
708
709        MouseEvent {
710            event_type,
711            col: event.col,
712            row: event.row,
713            button: event.button(),
714            ctrl_key: event.ctrl_key(),
715            shift_key: event.shift_key(),
716            alt_key: event.alt_key(),
717        }
718    }
719}
720
721/// Initialize the WASM module
722#[wasm_bindgen(start)]
723pub fn main() {
724    console_error_panic_hook::set_once();
725    console::log_1(&"beamterm WASM module loaded".into());
726}