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#[wasm_bindgen]
24#[derive(Debug)]
25pub struct BeamtermRenderer {
26 terminal: Terminal,
27}
28
29#[wasm_bindgen]
31#[derive(Debug, Default, serde::Deserialize)]
32pub struct Cell {
33 symbol: CompactString,
34 style: u16,
35 fg: u32,
36 bg: u32,
37}
38
39#[wasm_bindgen]
40#[derive(Debug, Clone, Copy)]
41pub struct CellStyle {
42 fg: u32,
43 bg: u32,
44 style_bits: u16,
45}
46
47#[wasm_bindgen]
48#[derive(Debug, Clone, Copy)]
49pub struct Size {
50 pub width: u16,
51 pub height: u16,
52}
53
54#[wasm_bindgen(js_name = "TerminalSize")]
55#[derive(Debug, Clone, Copy)]
56pub struct WasmTerminalSize {
57 pub cols: u16,
58 pub rows: u16,
59}
60
61#[wasm_bindgen]
62#[derive(Debug)]
63pub struct Batch {
64 terminal_grid: Rc<RefCell<TerminalGrid>>,
65}
66
67#[wasm_bindgen]
69#[derive(Debug, Clone, Copy)]
70pub enum SelectionMode {
71 Block,
73 Linear,
75}
76
77#[wasm_bindgen]
79#[derive(Debug, Clone, Copy)]
80pub enum MouseEventType {
81 MouseDown,
83 MouseUp,
85 MouseMove,
87 Click,
89 MouseEnter,
91 MouseLeave,
93}
94
95#[wasm_bindgen]
97#[derive(Debug, Clone, Copy)]
98pub struct MouseEvent {
99 pub event_type: MouseEventType,
101 pub col: u16,
103 pub row: u16,
105 pub button: i16,
107 pub ctrl_key: bool,
109 pub shift_key: bool,
111 pub alt_key: bool,
113 pub meta_key: bool,
115}
116
117#[wasm_bindgen]
125#[derive(Debug, Clone, Copy, Default)]
126pub struct ModifierKeys(u8);
127
128#[wasm_bindgen]
129#[allow(non_snake_case)]
130impl ModifierKeys {
131 #[wasm_bindgen(getter)]
133 pub fn NONE() -> ModifierKeys {
134 ModifierKeys(0)
135 }
136
137 #[wasm_bindgen(getter)]
139 pub fn CONTROL() -> ModifierKeys {
140 ModifierKeys(RustModifierKeys::CONTROL.bits())
141 }
142
143 #[wasm_bindgen(getter)]
145 pub fn SHIFT() -> ModifierKeys {
146 ModifierKeys(RustModifierKeys::SHIFT.bits())
147 }
148
149 #[wasm_bindgen(getter)]
151 pub fn ALT() -> ModifierKeys {
152 ModifierKeys(RustModifierKeys::ALT.bits())
153 }
154
155 #[wasm_bindgen(getter)]
157 pub fn META() -> ModifierKeys {
158 ModifierKeys(RustModifierKeys::META.bits())
159 }
160
161 #[wasm_bindgen(js_name = "or")]
163 pub fn or(&self, other: &ModifierKeys) -> ModifierKeys {
164 ModifierKeys(self.0 | other.0)
165 }
166}
167
168#[wasm_bindgen]
170#[derive(Debug, Clone)]
171pub struct CellQuery {
172 inner: RustCellQuery,
173}
174
175#[wasm_bindgen]
180#[derive(Debug)]
181pub struct UrlMatch {
182 url: String,
184 query: CellQuery,
186}
187
188#[wasm_bindgen]
189impl UrlMatch {
190 #[wasm_bindgen(getter)]
192 pub fn url(&self) -> String {
193 self.url.clone()
194 }
195
196 #[wasm_bindgen(getter)]
200 pub fn query(&self) -> CellQuery {
201 self.query.clone()
202 }
203}
204
205#[wasm_bindgen]
206impl CellQuery {
207 #[wasm_bindgen(constructor)]
209 pub fn new(mode: SelectionMode) -> CellQuery {
210 CellQuery { inner: select(mode.into()) }
211 }
212
213 pub fn start(mut self, col: u16, row: u16) -> CellQuery {
215 self.inner = self.inner.start((col, row));
216 self
217 }
218
219 pub fn end(mut self, col: u16, row: u16) -> CellQuery {
221 self.inner = self.inner.end((col, row));
222 self
223 }
224
225 #[wasm_bindgen(js_name = "trimTrailingWhitespace")]
227 pub fn trim_trailing_whitespace(mut self, enabled: bool) -> CellQuery {
228 self.inner = self.inner.trim_trailing_whitespace(enabled);
229 self
230 }
231
232 #[wasm_bindgen(js_name = "isEmpty")]
234 pub fn is_empty(&self) -> bool {
235 self.inner.is_empty()
236 }
237}
238
239#[wasm_bindgen]
240pub fn style() -> CellStyle {
241 CellStyle::new()
242}
243
244#[wasm_bindgen]
245pub fn cell(symbol: &str, style: CellStyle) -> Cell {
246 Cell {
247 symbol: symbol.into(),
248 style: style.style_bits,
249 fg: style.fg,
250 bg: style.bg,
251 }
252}
253
254#[wasm_bindgen]
255impl CellStyle {
256 #[wasm_bindgen(constructor)]
258 pub fn new() -> CellStyle {
259 Default::default()
260 }
261
262 #[wasm_bindgen]
264 pub fn fg(mut self, color: u32) -> CellStyle {
265 self.fg = color;
266 self
267 }
268
269 #[wasm_bindgen]
271 pub fn bg(mut self, color: u32) -> CellStyle {
272 self.bg = color;
273 self
274 }
275
276 #[wasm_bindgen]
278 pub fn bold(mut self) -> CellStyle {
279 self.style_bits |= Glyph::BOLD_FLAG;
280 self
281 }
282
283 #[wasm_bindgen]
285 pub fn italic(mut self) -> CellStyle {
286 self.style_bits |= Glyph::ITALIC_FLAG;
287 self
288 }
289
290 #[wasm_bindgen]
292 pub fn underline(mut self) -> CellStyle {
293 self.style_bits |= Glyph::UNDERLINE_FLAG;
294 self
295 }
296
297 #[wasm_bindgen]
299 pub fn strikethrough(mut self) -> CellStyle {
300 self.style_bits |= Glyph::STRIKETHROUGH_FLAG;
301 self
302 }
303
304 #[wasm_bindgen(getter)]
306 pub fn bits(&self) -> u16 {
307 self.style_bits
308 }
309}
310
311impl Default for CellStyle {
312 fn default() -> Self {
313 CellStyle {
314 fg: 0xFFFFFF, bg: 0x000000, style_bits: 0, }
318 }
319}
320
321#[wasm_bindgen]
322impl Batch {
323 #[wasm_bindgen(js_name = "cell")]
325 pub fn cell(&mut self, x: u16, y: u16, cell_data: &Cell) {
326 let _ = self
327 .terminal_grid
328 .borrow_mut()
329 .update_cell(x, y, cell_data.as_cell_data());
330 }
331
332 #[wasm_bindgen(js_name = "cellByIndex")]
334 pub fn cell_by_index(&mut self, idx: usize, cell_data: &Cell) {
335 let _ = self
336 .terminal_grid
337 .borrow_mut()
338 .update_cell_by_index(idx, cell_data.as_cell_data());
339 }
340
341 #[wasm_bindgen(js_name = "cells")]
344 pub fn cells(&mut self, cells_json: JsValue) -> Result<(), JsValue> {
345 let updates = from_value::<Vec<(u16, u16, Cell)>>(cells_json)
346 .map_err(|e| JsValue::from_str(&e.to_string()));
347
348 match updates {
349 Ok(cells) => {
350 let cell_data = cells
351 .iter()
352 .map(|(x, y, data)| (*x, *y, data.as_cell_data()));
353
354 let mut terminal_grid = self.terminal_grid.borrow_mut();
355 terminal_grid
356 .update_cells_by_position(cell_data)
357 .map_err(|e| JsValue::from_str(&e.to_string()))
358 },
359 e => e.map(|_| ()),
360 }
361 }
362
363 #[wasm_bindgen(js_name = "text")]
365 pub fn text(&mut self, x: u16, y: u16, text: &str, style: &CellStyle) -> Result<(), JsValue> {
366 let mut terminal_grid = self.terminal_grid.borrow_mut();
367 let ts = terminal_grid.terminal_size();
368
369 if y >= ts.rows {
370 return Ok(()); }
372
373 let mut col_offset: u16 = 0;
374 for ch in text.graphemes(true) {
375 let char_width = if ch.len() == 1 { 1 } else { ch.width() };
376
377 if char_width == 0 {
379 continue;
380 }
381
382 let current_col = x + col_offset;
383 if current_col >= ts.cols {
384 break;
385 }
386
387 let cell = CellData::new_with_style_bits(ch, style.style_bits, style.fg, style.bg);
388 terminal_grid
389 .update_cell(current_col, y, cell)
390 .map_err(|e| JsValue::from_str(&e.to_string()))?;
391
392 col_offset += char_width as u16;
393 }
394
395 Ok(())
396 }
397
398 #[wasm_bindgen(js_name = "fill")]
400 pub fn fill(
401 &mut self,
402 x: u16,
403 y: u16,
404 width: u16,
405 height: u16,
406 cell_data: &Cell,
407 ) -> Result<(), JsValue> {
408 let mut terminal_grid = self.terminal_grid.borrow_mut();
409 let ts = terminal_grid.terminal_size();
410
411 let width = (x + width).min(ts.cols).saturating_sub(x);
412 let height = (y + height).min(ts.rows).saturating_sub(y);
413
414 let fill_cell = cell_data.as_cell_data();
415 for y in y..y + height {
416 for x in x..x + width {
417 terminal_grid
418 .update_cell(x, y, fill_cell)
419 .map_err(|e| JsValue::from_str(&e.to_string()))?;
420 }
421 }
422
423 Ok(())
424 }
425
426 #[wasm_bindgen]
428 pub fn clear(&mut self, bg: u32) -> Result<(), JsValue> {
429 let mut terminal_grid = self.terminal_grid.borrow_mut();
430 let ts = terminal_grid.terminal_size();
431
432 let clear_cell = CellData::new_with_style_bits(" ", 0, 0xFFFFFF, bg);
433 for y in 0..ts.rows {
434 for x in 0..ts.cols {
435 terminal_grid
436 .update_cell(x, y, clear_cell)
437 .map_err(|e| JsValue::from_str(&e.to_string()))?;
438 }
439 }
440
441 Ok(())
442 }
443
444 #[wasm_bindgen]
446 #[deprecated(since = "0.4.0", note = "no-op, flush is now automatic")]
447 #[allow(deprecated)]
448 pub fn flush(&mut self) -> Result<(), JsValue> {
449 Ok(())
450 }
451}
452
453#[wasm_bindgen]
454impl Cell {
455 #[wasm_bindgen(constructor)]
456 pub fn new(symbol: String, style: &CellStyle) -> Cell {
457 Cell {
458 symbol: symbol.into(),
459 style: style.style_bits,
460 fg: style.fg,
461 bg: style.bg,
462 }
463 }
464
465 #[wasm_bindgen(getter)]
466 pub fn symbol(&self) -> String {
467 self.symbol.to_string()
468 }
469
470 #[wasm_bindgen(setter)]
471 pub fn set_symbol(&mut self, symbol: String) {
472 self.symbol = symbol.into();
473 }
474
475 #[wasm_bindgen(getter)]
476 pub fn fg(&self) -> u32 {
477 self.fg
478 }
479
480 #[wasm_bindgen(setter)]
481 pub fn set_fg(&mut self, color: u32) {
482 self.fg = color;
483 }
484
485 #[wasm_bindgen(getter)]
486 pub fn bg(&self) -> u32 {
487 self.bg
488 }
489
490 #[wasm_bindgen(setter)]
491 pub fn set_bg(&mut self, color: u32) {
492 self.bg = color;
493 }
494
495 #[wasm_bindgen(getter)]
496 pub fn style(&self) -> u16 {
497 self.style
498 }
499
500 #[wasm_bindgen(setter)]
501 pub fn set_style(&mut self, style: u16) {
502 self.style = style;
503 }
504}
505
506impl Cell {
507 pub fn as_cell_data(&self) -> CellData<'_> {
508 CellData::new_with_style_bits(&self.symbol, self.style, self.fg, self.bg)
509 }
510}
511
512#[wasm_bindgen]
513impl BeamtermRenderer {
514 #[wasm_bindgen(constructor)]
516 pub fn new(canvas_id: &str) -> Result<BeamtermRenderer, JsValue> {
517 Self::with_static_atlas(canvas_id, None, None)
518 }
519
520 #[wasm_bindgen(js_name = "withStaticAtlas")]
529 pub fn with_static_atlas(
530 canvas_id: &str,
531 atlas_data: Option<js_sys::Uint8Array>,
532 auto_resize_canvas_css: Option<bool>,
533 ) -> Result<BeamtermRenderer, JsValue> {
534 console_error_panic_hook::set_once();
535
536 let atlas =
537 match atlas_data {
538 Some(data) => {
539 let bytes = data.to_vec();
540 Some(FontAtlasData::from_binary(&bytes).map_err(|e| {
541 JsValue::from_str(&format!("Failed to parse atlas data: {e}"))
542 })?)
543 },
544 None => None,
545 };
546
547 let mut builder = Terminal::builder(canvas_id)
548 .auto_resize_canvas_css(auto_resize_canvas_css.unwrap_or(true));
549
550 if let Some(atlas) = atlas {
551 builder = builder.font_atlas(atlas);
552 }
553
554 let terminal = builder.build()?;
555
556 Ok(BeamtermRenderer { terminal })
557 }
558
559 #[wasm_bindgen(js_name = "withDynamicAtlas")]
581 pub fn with_dynamic_atlas(
582 canvas_id: &str,
583 font_family: js_sys::Array,
584 font_size: f32,
585 auto_resize_canvas_css: Option<bool>,
586 ) -> Result<BeamtermRenderer, JsValue> {
587 console_error_panic_hook::set_once();
588
589 let font_families: Vec<String> = font_family
590 .iter()
591 .filter_map(|v| v.as_string())
592 .collect();
593
594 if font_families.is_empty() {
595 return Err(JsValue::from_str("font_family array cannot be empty"));
596 }
597
598 let refs: Vec<&str> = font_families.iter().map(String::as_str).collect();
599
600 let terminal = Terminal::builder(canvas_id)
601 .auto_resize_canvas_css(auto_resize_canvas_css.unwrap_or(true))
602 .dynamic_font_atlas(&refs, font_size)
603 .build()?;
604
605 Ok(BeamtermRenderer { terminal })
606 }
607
608 #[wasm_bindgen(js_name = "enableSelection")]
610 pub fn enable_selection(
611 &mut self,
612 mode: SelectionMode,
613 trim_whitespace: bool,
614 ) -> Result<(), JsValue> {
615 self.enable_selection_with_options(mode, trim_whitespace, &ModifierKeys::default())
616 }
617
618 #[wasm_bindgen(js_name = "enableSelectionWithOptions")]
645 pub fn enable_selection_with_options(
646 &mut self,
647 mode: SelectionMode,
648 trim_whitespace: bool,
649 require_modifiers: &ModifierKeys,
650 ) -> Result<(), JsValue> {
651 let options = MouseSelectOptions::new()
652 .selection_mode(mode.into())
653 .trim_trailing_whitespace(trim_whitespace)
654 .require_modifier_keys((*require_modifiers).into());
655
656 Ok(self.terminal.enable_mouse_selection(options)?)
657 }
658
659 #[wasm_bindgen(js_name = "setMouseHandler")]
661 pub fn set_mouse_handler(&mut self, handler: js_sys::Function) -> Result<(), JsValue> {
662 let handler_closure = {
663 let handler = handler.clone();
664 move |event: TerminalMouseEvent, _grid: &TerminalGrid| {
665 let js_event = MouseEvent::from(event);
666 let this = JsValue::null();
667 let args = js_sys::Array::new();
668 args.push(&JsValue::from(js_event));
669
670 if let Err(e) = handler.apply(&this, &args) {
671 console::error_1(&format!("Mouse handler error: {e:?}").into());
672 }
673 }
674 };
675
676 Ok(self
677 .terminal
678 .set_mouse_callback(handler_closure)?)
679 }
680
681 #[wasm_bindgen(js_name = "getText")]
683 pub fn get_text(&self, query: &CellQuery) -> String {
684 self.terminal.get_text(query.inner).to_string()
685 }
686
687 #[wasm_bindgen(js_name = "findUrlAt")]
710 pub fn find_url_at(&self, col: u16, row: u16) -> Option<UrlMatch> {
711 let cursor = CursorPosition::new(col, row);
712 self.terminal
713 .find_url_at(cursor)
714 .map(|m| UrlMatch {
715 url: m.url.to_string(),
716 query: CellQuery { inner: m.query },
717 })
718 }
719
720 #[wasm_bindgen(js_name = "copyToClipboard")]
722 pub fn copy_to_clipboard(&self, text: &str) {
723 crate::js::copy_to_clipboard(text);
724 }
725
726 #[wasm_bindgen(js_name = "clearSelection")]
728 pub fn clear_selection(&self) {
729 self.terminal.clear_selection();
730 }
731
732 #[wasm_bindgen(js_name = "hasSelection")]
734 pub fn has_selection(&self) -> bool {
735 self.terminal.has_selection()
736 }
737
738 #[wasm_bindgen(js_name = "batch")]
740 pub fn new_render_batch(&mut self) -> Batch {
741 Batch { terminal_grid: self.terminal.grid() }
742 }
743
744 #[wasm_bindgen(js_name = "terminalSize")]
746 pub fn terminal_size(&self) -> WasmTerminalSize {
747 let ts = self.terminal.terminal_size();
748 WasmTerminalSize { cols: ts.cols, rows: ts.rows }
749 }
750
751 #[wasm_bindgen(js_name = "cellSize")]
753 pub fn cell_size(&self) -> Size {
754 let cs = self.terminal.cell_size();
755 Size { width: cs.width as u16, height: cs.height as u16 }
756 }
757
758 #[wasm_bindgen]
760 pub fn render(&mut self) {
761 if let Err(e) = self.terminal.render_frame() {
762 console::error_1(&format!("Render error: {e:?}").into());
763 }
764 }
765
766 #[wasm_bindgen]
768 pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
769 Ok(self.terminal.resize(width, height)?)
770 }
771
772 #[wasm_bindgen(js_name = "replaceWithStaticAtlas")]
786 pub fn replace_with_static_atlas(
787 &mut self,
788 atlas_data: Option<js_sys::Uint8Array>,
789 ) -> Result<(), JsValue> {
790 let atlas_config = match atlas_data {
791 Some(data) => {
792 let bytes = data.to_vec();
793 FontAtlasData::from_binary(&bytes)
794 .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e}")))?
795 },
796 None => FontAtlasData::default(),
797 };
798
799 Ok(self
800 .terminal
801 .replace_with_static_atlas(atlas_config)?)
802 }
803
804 #[wasm_bindgen(js_name = "replaceWithDynamicAtlas")]
819 pub fn replace_with_dynamic_atlas(
820 &mut self,
821 font_family: js_sys::Array,
822 font_size: f32,
823 ) -> Result<(), JsValue> {
824 let font_families: Vec<String> = font_family
825 .iter()
826 .filter_map(|v| v.as_string())
827 .collect();
828
829 if font_families.is_empty() {
830 return Err(JsValue::from_str("font_family array cannot be empty"));
831 }
832
833 let refs: Vec<&str> = font_families.iter().map(String::as_str).collect();
834 Ok(self
835 .terminal
836 .replace_with_dynamic_atlas(&refs, font_size)?)
837 }
838}
839
840impl From<SelectionMode> for RustSelectionMode {
842 fn from(mode: SelectionMode) -> Self {
843 match mode {
844 SelectionMode::Block => RustSelectionMode::Block,
845 SelectionMode::Linear => RustSelectionMode::Linear,
846 }
847 }
848}
849
850impl From<RustSelectionMode> for SelectionMode {
851 fn from(mode: RustSelectionMode) -> Self {
852 match mode {
853 RustSelectionMode::Block => SelectionMode::Block,
854 RustSelectionMode::Linear => SelectionMode::Linear,
855 _ => unreachable!(),
856 }
857 }
858}
859
860impl From<TerminalMouseEvent> for MouseEvent {
861 fn from(event: TerminalMouseEvent) -> Self {
862 use crate::mouse::MouseEventType as RustMouseEventType;
863
864 let event_type = match event.event_type {
865 RustMouseEventType::MouseDown => MouseEventType::MouseDown,
866 RustMouseEventType::MouseUp => MouseEventType::MouseUp,
867 RustMouseEventType::MouseMove => MouseEventType::MouseMove,
868 RustMouseEventType::Click => MouseEventType::Click,
869 RustMouseEventType::MouseEnter => MouseEventType::MouseEnter,
870 RustMouseEventType::MouseLeave => MouseEventType::MouseLeave,
871 };
872
873 MouseEvent {
874 event_type,
875 col: event.col,
876 row: event.row,
877 button: event.button(),
878 ctrl_key: event.ctrl_key(),
879 shift_key: event.shift_key(),
880 alt_key: event.alt_key(),
881 meta_key: event.meta_key(),
882 }
883 }
884}
885
886impl From<ModifierKeys> for RustModifierKeys {
887 fn from(keys: ModifierKeys) -> Self {
888 RustModifierKeys::from_bits_truncate(keys.0)
889 }
890}
891
892#[wasm_bindgen(start)]
894pub fn main() {
895 console_error_panic_hook::set_once();
896 console::log_1(&"beamterm WASM module loaded".into());
897}