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]
55#[derive(Debug)]
56pub struct Batch {
57 terminal_grid: Rc<RefCell<TerminalGrid>>,
58}
59
60#[wasm_bindgen]
62#[derive(Debug, Clone, Copy)]
63pub enum SelectionMode {
64 Block,
66 Linear,
68}
69
70#[wasm_bindgen]
72#[derive(Debug, Clone, Copy)]
73pub enum MouseEventType {
74 MouseDown,
76 MouseUp,
78 MouseMove,
80 Click,
82 MouseEnter,
84 MouseLeave,
86}
87
88#[wasm_bindgen]
90#[derive(Debug, Clone, Copy)]
91pub struct MouseEvent {
92 pub event_type: MouseEventType,
94 pub col: u16,
96 pub row: u16,
98 pub button: i16,
100 pub ctrl_key: bool,
102 pub shift_key: bool,
104 pub alt_key: bool,
106 pub meta_key: bool,
108}
109
110#[wasm_bindgen]
118#[derive(Debug, Clone, Copy, Default)]
119pub struct ModifierKeys(u8);
120
121#[wasm_bindgen]
122#[allow(non_snake_case)]
123impl ModifierKeys {
124 #[wasm_bindgen(getter)]
126 pub fn NONE() -> ModifierKeys {
127 ModifierKeys(0)
128 }
129
130 #[wasm_bindgen(getter)]
132 pub fn CONTROL() -> ModifierKeys {
133 ModifierKeys(RustModifierKeys::CONTROL.bits())
134 }
135
136 #[wasm_bindgen(getter)]
138 pub fn SHIFT() -> ModifierKeys {
139 ModifierKeys(RustModifierKeys::SHIFT.bits())
140 }
141
142 #[wasm_bindgen(getter)]
144 pub fn ALT() -> ModifierKeys {
145 ModifierKeys(RustModifierKeys::ALT.bits())
146 }
147
148 #[wasm_bindgen(getter)]
150 pub fn META() -> ModifierKeys {
151 ModifierKeys(RustModifierKeys::META.bits())
152 }
153
154 #[wasm_bindgen(js_name = "or")]
156 pub fn or(&self, other: &ModifierKeys) -> ModifierKeys {
157 ModifierKeys(self.0 | other.0)
158 }
159}
160
161#[wasm_bindgen]
163#[derive(Debug, Clone)]
164pub struct CellQuery {
165 inner: RustCellQuery,
166}
167
168#[wasm_bindgen]
173#[derive(Debug)]
174pub struct UrlMatch {
175 url: String,
177 query: CellQuery,
179}
180
181#[wasm_bindgen]
182impl UrlMatch {
183 #[wasm_bindgen(getter)]
185 pub fn url(&self) -> String {
186 self.url.clone()
187 }
188
189 #[wasm_bindgen(getter)]
193 pub fn query(&self) -> CellQuery {
194 self.query.clone()
195 }
196}
197
198#[wasm_bindgen]
199impl CellQuery {
200 #[wasm_bindgen(constructor)]
202 pub fn new(mode: SelectionMode) -> CellQuery {
203 CellQuery { inner: select(mode.into()) }
204 }
205
206 pub fn start(mut self, col: u16, row: u16) -> CellQuery {
208 self.inner = self.inner.start((col, row));
209 self
210 }
211
212 pub fn end(mut self, col: u16, row: u16) -> CellQuery {
214 self.inner = self.inner.end((col, row));
215 self
216 }
217
218 #[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 #[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 #[wasm_bindgen(constructor)]
251 pub fn new() -> CellStyle {
252 Default::default()
253 }
254
255 #[wasm_bindgen]
257 pub fn fg(mut self, color: u32) -> CellStyle {
258 self.fg = color;
259 self
260 }
261
262 #[wasm_bindgen]
264 pub fn bg(mut self, color: u32) -> CellStyle {
265 self.bg = color;
266 self
267 }
268
269 #[wasm_bindgen]
271 pub fn bold(mut self) -> CellStyle {
272 self.style_bits |= Glyph::BOLD_FLAG;
273 self
274 }
275
276 #[wasm_bindgen]
278 pub fn italic(mut self) -> CellStyle {
279 self.style_bits |= Glyph::ITALIC_FLAG;
280 self
281 }
282
283 #[wasm_bindgen]
285 pub fn underline(mut self) -> CellStyle {
286 self.style_bits |= Glyph::UNDERLINE_FLAG;
287 self
288 }
289
290 #[wasm_bindgen]
292 pub fn strikethrough(mut self) -> CellStyle {
293 self.style_bits |= Glyph::STRIKETHROUGH_FLAG;
294 self
295 }
296
297 #[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, bg: 0x000000, style_bits: 0, }
311 }
312}
313
314#[wasm_bindgen]
315impl Batch {
316 #[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 #[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 #[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 #[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(()); }
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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[wasm_bindgen(js_name = "copyToClipboard")]
715 pub fn copy_to_clipboard(&self, text: &str) {
716 crate::js::copy_to_clipboard(text);
717 }
718
719 #[wasm_bindgen(js_name = "clearSelection")]
721 pub fn clear_selection(&self) {
722 self.terminal.clear_selection();
723 }
724
725 #[wasm_bindgen(js_name = "hasSelection")]
727 pub fn has_selection(&self) -> bool {
728 self.terminal.has_selection()
729 }
730
731 #[wasm_bindgen(js_name = "batch")]
733 pub fn new_render_batch(&mut self) -> Batch {
734 Batch { terminal_grid: self.terminal.grid() }
735 }
736
737 #[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 #[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 #[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 #[wasm_bindgen]
761 pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
762 Ok(self.terminal.resize(width, height)?)
763 }
764
765 #[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 #[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
833impl 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#[wasm_bindgen(start)]
887pub fn main() {
888 console_error_panic_hook::set_once();
889 console::log_1(&"beamterm WASM module loaded".into());
890}