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