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    dirty: bool,
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        self.dirty = true;
231        self.terminal_grid.borrow_mut().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.dirty = true;
238        self.terminal_grid
239            .borrow_mut()
240            .update_cell_by_index(idx, cell_data.as_cell_data());
241    }
242
243    /// Updates multiple cells from an array.
244    /// Each element should be [x, y, cellData].
245    #[wasm_bindgen(js_name = "cells")]
246    pub fn cells(&mut self, cells_json: JsValue) -> Result<(), JsValue> {
247        self.dirty = true;
248
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.iter().map(|(x, y, data)| (*x, *y, data.as_cell_data()));
255
256                let mut terminal_grid = self.terminal_grid.borrow_mut();
257                terminal_grid
258                    .update_cells_by_position(&self.gl, cell_data)
259                    .map_err(|e| JsValue::from_str(&e.to_string()))
260            },
261            e => e.map(|_| ()),
262        }
263    }
264
265    /// Write text to the terminal
266    #[wasm_bindgen(js_name = "text")]
267    pub fn text(&mut self, x: u16, y: u16, text: &str, style: &CellStyle) -> Result<(), JsValue> {
268        self.dirty = true;
269
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        for (i, ch) in text.graphemes(true).enumerate() {
278            let current_col = x + i as u16;
279            if current_col >= cols {
280                break;
281            }
282
283            let cell = CellData::new_with_style_bits(ch, style.style_bits, style.fg, style.bg);
284            terminal_grid.update_cell(current_col, y, cell);
285        }
286
287        Ok(())
288    }
289
290    /// Fill a rectangular region
291    #[wasm_bindgen(js_name = "fill")]
292    pub fn fill(
293        &mut self,
294        x: u16,
295        y: u16,
296        width: u16,
297        height: u16,
298        cell_data: &Cell,
299    ) -> Result<(), JsValue> {
300        self.dirty = true;
301
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        self.dirty = true;
322
323        let mut terminal_grid = self.terminal_grid.borrow_mut();
324        let (cols, rows) = terminal_grid.terminal_size();
325
326        let clear_cell = CellData::new_with_style_bits(" ", 0, 0xFFFFFF, bg);
327        for y in 0..rows {
328            for x in 0..cols {
329                terminal_grid.update_cell(x, y, clear_cell);
330            }
331        }
332
333        Ok(())
334    }
335
336    /// Synchronize all pending updates to the GPU
337    #[wasm_bindgen]
338    pub fn flush(&mut self) -> Result<(), JsValue> {
339        if self.dirty {
340            self.dirty = false;
341            self.terminal_grid
342                .borrow_mut()
343                .flush_cells(&self.gl)
344                .map_err(|e| JsValue::from_str(&e.to_string()))?;
345        }
346
347        Ok(())
348    }
349}
350
351#[wasm_bindgen]
352impl Cell {
353    #[wasm_bindgen(constructor)]
354    pub fn new(symbol: String, style: &CellStyle) -> Cell {
355        Cell {
356            symbol: symbol.into(),
357            style: style.style_bits,
358            fg: style.fg,
359            bg: style.bg,
360        }
361    }
362
363    #[wasm_bindgen(getter)]
364    pub fn symbol(&self) -> String {
365        self.symbol.to_string()
366    }
367
368    #[wasm_bindgen(setter)]
369    pub fn set_symbol(&mut self, symbol: String) {
370        self.symbol = symbol.into();
371    }
372
373    #[wasm_bindgen(getter)]
374    pub fn fg(&self) -> u32 {
375        self.fg
376    }
377
378    #[wasm_bindgen(setter)]
379    pub fn set_fg(&mut self, color: u32) {
380        self.fg = color;
381    }
382
383    #[wasm_bindgen(getter)]
384    pub fn bg(&self) -> u32 {
385        self.bg
386    }
387
388    #[wasm_bindgen(setter)]
389    pub fn set_bg(&mut self, color: u32) {
390        self.bg = color;
391    }
392
393    #[wasm_bindgen(getter)]
394    pub fn style(&self) -> u16 {
395        self.style
396    }
397
398    #[wasm_bindgen(setter)]
399    pub fn set_style(&mut self, style: u16) {
400        self.style = style;
401    }
402}
403
404impl Cell {
405    pub fn as_cell_data(&self) -> CellData {
406        CellData::new_with_style_bits(&self.symbol, self.style, self.fg, self.bg)
407    }
408}
409
410#[wasm_bindgen]
411impl BeamtermRenderer {
412    /// Create a new terminal renderer
413    #[wasm_bindgen(constructor)]
414    pub fn new(canvas_id: &str) -> Result<BeamtermRenderer, JsValue> {
415        console_error_panic_hook::set_once();
416
417        let renderer = Renderer::create(canvas_id)
418            .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {}", e)))?;
419
420        let gl = renderer.gl();
421        let atlas_data = FontAtlasData::default();
422        let atlas = FontAtlas::load(gl, atlas_data)
423            .map_err(|e| JsValue::from_str(&format!("Failed to load font atlas: {}", e)))?;
424
425        let canvas_size = renderer.canvas_size();
426        let terminal_grid = TerminalGrid::new(gl, atlas, canvas_size)
427            .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {}", e)))?;
428
429        console::log_1(&"BeamtermRenderer initialized successfully".into());
430        let terminal_grid = Rc::new(RefCell::new(terminal_grid));
431        Ok(BeamtermRenderer {
432            renderer,
433            terminal_grid,
434            mouse_handler: None,
435        })
436    }
437
438    /// Enable default mouse selection behavior with built-in copy to clipboard
439    #[wasm_bindgen(js_name = "enableSelection")]
440    pub fn enable_selection(
441        &mut self,
442        mode: SelectionMode,
443        trim_whitespace: bool,
444    ) -> Result<(), JsValue> {
445        // clean up existing mouse handler if present
446        if let Some(old_handler) = self.mouse_handler.take() {
447            old_handler.cleanup();
448        }
449
450        let selection_tracker = self.terminal_grid.borrow().selection_tracker();
451        let handler =
452            DefaultSelectionHandler::new(self.terminal_grid.clone(), mode.into(), trim_whitespace);
453
454        let mouse_handler = TerminalMouseHandler::new(
455            self.renderer.canvas(),
456            self.terminal_grid.clone(),
457            handler.create_event_handler(selection_tracker),
458        )
459        .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {}", e)))?;
460
461        self.mouse_handler = Some(mouse_handler);
462        Ok(())
463    }
464
465    /// Set a custom mouse event handler
466    #[wasm_bindgen(js_name = "setMouseHandler")]
467    pub fn set_mouse_handler(&mut self, handler: js_sys::Function) -> Result<(), JsValue> {
468        // Clean up existing mouse handler if present
469        if let Some(old_handler) = self.mouse_handler.take() {
470            old_handler.cleanup();
471        }
472
473        let handler_closure = {
474            let handler = handler.clone();
475            move |event: TerminalMouseEvent, _grid: &TerminalGrid| {
476                let js_event = MouseEvent::from(event);
477                let this = JsValue::null();
478                let args = js_sys::Array::new();
479                args.push(&JsValue::from(js_event));
480
481                if let Err(e) = handler.apply(&this, &args) {
482                    console::error_1(&format!("Mouse handler error: {:?}", e).into());
483                }
484            }
485        };
486
487        let mouse_handler = TerminalMouseHandler::new(
488            self.renderer.canvas(),
489            self.terminal_grid.clone(),
490            handler_closure,
491        )
492        .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {}", e)))?;
493
494        self.mouse_handler = Some(mouse_handler);
495        Ok(())
496    }
497
498    /// Get selected text based on a cell query
499    #[wasm_bindgen(js_name = "getText")]
500    pub fn get_text(&self, query: &CellQuery) -> String {
501        self.terminal_grid.borrow().get_text(query.inner).to_string()
502    }
503
504    /// Copy text to the system clipboard
505    #[wasm_bindgen(js_name = "copyToClipboard")]
506    pub fn copy_to_clipboard(&self, text: &str) {
507        use wasm_bindgen_futures::spawn_local;
508        let text = text.to_string();
509
510        spawn_local(async move {
511            if let Some(window) = web_sys::window() {
512                let clipboard = window.navigator().clipboard();
513                match wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
514                    Ok(_) => {
515                        console::log_1(
516                            &format!("Copied {} characters to clipboard", text.len()).into(),
517                        );
518                    },
519                    Err(err) => {
520                        console::error_1(&format!("Failed to copy to clipboard: {:?}", err).into());
521                    },
522                }
523            }
524        });
525    }
526
527    /// Clear any active selection
528    #[wasm_bindgen(js_name = "clearSelection")]
529    pub fn clear_selection(&self) {
530        self.terminal_grid.borrow().selection_tracker().clear();
531    }
532
533    /// Check if there is an active selection
534    #[wasm_bindgen(js_name = "hasSelection")]
535    pub fn has_selection(&self) -> bool {
536        self.terminal_grid.borrow().selection_tracker().get_query().is_some()
537    }
538
539    /// Create a new render batch
540    #[wasm_bindgen(js_name = "batch")]
541    pub fn new_render_batch(&mut self) -> Batch {
542        let gl = self.renderer.gl().clone();
543        let terminal_grid = self.terminal_grid.clone();
544        Batch { terminal_grid, gl, dirty: false }
545    }
546
547    /// Get the terminal dimensions in cells
548    #[wasm_bindgen(js_name = "terminalSize")]
549    pub fn terminal_size(&self) -> Size {
550        let (cols, rows) = self.terminal_grid.borrow().terminal_size();
551        Size { width: cols, height: rows }
552    }
553
554    /// Get the cell size in pixels
555    #[wasm_bindgen(js_name = "cellSize")]
556    pub fn cell_size(&self) -> Size {
557        let (width, height) = self.terminal_grid.borrow().cell_size();
558        Size {
559            width: width as u16,
560            height: height as u16,
561        }
562    }
563
564    /// Render the terminal to the canvas
565    #[wasm_bindgen]
566    pub fn render(&mut self) {
567        self.renderer.begin_frame();
568        let grid: &TerminalGrid = &self.terminal_grid.borrow();
569        self.renderer.render(grid);
570        self.renderer.end_frame();
571    }
572
573    /// Resize the terminal to fit new canvas dimensions
574    #[wasm_bindgen]
575    pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
576        self.renderer.resize(width, height);
577
578        console::log_1(&format!("Resizing terminal to {}x{}", width, height).into());
579
580        let gl = self.renderer.gl();
581        self.terminal_grid
582            .borrow_mut()
583            .resize(gl, (width, height))
584            .map_err(|e| JsValue::from_str(&format!("Failed to resize: {}", e)))?;
585
586        // Update mouse handler dimensions if present
587        if let Some(mouse_handler) = &self.mouse_handler {
588            let (cols, rows) = self.terminal_grid.borrow().terminal_size();
589            mouse_handler.update_dimensions(cols, rows);
590        }
591
592        Ok(())
593    }
594}
595
596// Convert between Rust and WASM types
597impl From<SelectionMode> for RustSelectionMode {
598    fn from(mode: SelectionMode) -> Self {
599        match mode {
600            SelectionMode::Block => RustSelectionMode::Block,
601            SelectionMode::Linear => RustSelectionMode::Linear,
602        }
603    }
604}
605
606impl From<RustSelectionMode> for SelectionMode {
607    fn from(mode: RustSelectionMode) -> Self {
608        match mode {
609            RustSelectionMode::Block => SelectionMode::Block,
610            RustSelectionMode::Linear => SelectionMode::Linear,
611        }
612    }
613}
614
615impl From<TerminalMouseEvent> for MouseEvent {
616    fn from(event: TerminalMouseEvent) -> Self {
617        use crate::mouse::MouseEventType as RustMouseEventType;
618
619        let event_type = match event.event_type {
620            RustMouseEventType::MouseDown => MouseEventType::MouseDown,
621            RustMouseEventType::MouseUp => MouseEventType::MouseUp,
622            RustMouseEventType::MouseMove => MouseEventType::MouseMove,
623        };
624
625        MouseEvent {
626            event_type,
627            col: event.col,
628            row: event.row,
629            button: event.button,
630            ctrl_key: event.ctrl_key,
631            shift_key: event.shift_key,
632            alt_key: event.alt_key,
633        }
634    }
635}
636
637/// Initialize the WASM module
638#[wasm_bindgen(start)]
639pub fn main() {
640    console_error_panic_hook::set_once();
641    console::log_1(&"beamterm WASM module loaded".into());
642}