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
445#[wasm_bindgen]
446impl Cell {
447 #[wasm_bindgen(constructor)]
448 pub fn new(symbol: String, style: &CellStyle) -> Cell {
449 Cell {
450 symbol: symbol.into(),
451 style: style.style_bits,
452 fg: style.fg,
453 bg: style.bg,
454 }
455 }
456
457 #[wasm_bindgen(getter)]
458 pub fn symbol(&self) -> String {
459 self.symbol.to_string()
460 }
461
462 #[wasm_bindgen(setter)]
463 pub fn set_symbol(&mut self, symbol: String) {
464 self.symbol = symbol.into();
465 }
466
467 #[wasm_bindgen(getter)]
468 pub fn fg(&self) -> u32 {
469 self.fg
470 }
471
472 #[wasm_bindgen(setter)]
473 pub fn set_fg(&mut self, color: u32) {
474 self.fg = color;
475 }
476
477 #[wasm_bindgen(getter)]
478 pub fn bg(&self) -> u32 {
479 self.bg
480 }
481
482 #[wasm_bindgen(setter)]
483 pub fn set_bg(&mut self, color: u32) {
484 self.bg = color;
485 }
486
487 #[wasm_bindgen(getter)]
488 pub fn style(&self) -> u16 {
489 self.style
490 }
491
492 #[wasm_bindgen(setter)]
493 pub fn set_style(&mut self, style: u16) {
494 self.style = style;
495 }
496}
497
498impl Cell {
499 pub fn as_cell_data(&self) -> CellData<'_> {
500 CellData::new_with_style_bits(&self.symbol, self.style, self.fg, self.bg)
501 }
502}
503
504#[wasm_bindgen]
505impl BeamtermRenderer {
506 #[wasm_bindgen(constructor)]
508 pub fn new(canvas_id: &str) -> Result<BeamtermRenderer, JsValue> {
509 Self::with_static_atlas(canvas_id, None, None)
510 }
511
512 #[wasm_bindgen(js_name = "withStaticAtlas")]
521 pub fn with_static_atlas(
522 canvas_id: &str,
523 atlas_data: Option<js_sys::Uint8Array>,
524 auto_resize_canvas_css: Option<bool>,
525 ) -> Result<BeamtermRenderer, JsValue> {
526 console_error_panic_hook::set_once();
527
528 let atlas =
529 match atlas_data {
530 Some(data) => {
531 let bytes = data.to_vec();
532 Some(FontAtlasData::from_binary(&bytes).map_err(|e| {
533 JsValue::from_str(&format!("Failed to parse atlas data: {e}"))
534 })?)
535 },
536 None => None,
537 };
538
539 let mut builder = Terminal::builder(canvas_id)
540 .auto_resize_canvas_css(auto_resize_canvas_css.unwrap_or(true));
541
542 if let Some(atlas) = atlas {
543 builder = builder.font_atlas(atlas);
544 }
545
546 let terminal = builder.build()?;
547
548 Ok(BeamtermRenderer { terminal })
549 }
550
551 #[wasm_bindgen(js_name = "withDynamicAtlas")]
573 pub fn with_dynamic_atlas(
574 canvas_id: &str,
575 font_family: js_sys::Array,
576 font_size: f32,
577 auto_resize_canvas_css: Option<bool>,
578 ) -> Result<BeamtermRenderer, JsValue> {
579 console_error_panic_hook::set_once();
580
581 let font_families: Vec<String> = font_family
582 .iter()
583 .filter_map(|v| v.as_string())
584 .collect();
585
586 if font_families.is_empty() {
587 return Err(JsValue::from_str("font_family array cannot be empty"));
588 }
589
590 let refs: Vec<&str> = font_families.iter().map(String::as_str).collect();
591
592 let terminal = Terminal::builder(canvas_id)
593 .auto_resize_canvas_css(auto_resize_canvas_css.unwrap_or(true))
594 .dynamic_font_atlas(&refs, font_size)
595 .build()?;
596
597 Ok(BeamtermRenderer { terminal })
598 }
599
600 #[wasm_bindgen(js_name = "enableSelection")]
602 pub fn enable_selection(
603 &mut self,
604 mode: SelectionMode,
605 trim_whitespace: bool,
606 ) -> Result<(), JsValue> {
607 self.enable_selection_with_options(mode, trim_whitespace, &ModifierKeys::default())
608 }
609
610 #[wasm_bindgen(js_name = "enableSelectionWithOptions")]
637 pub fn enable_selection_with_options(
638 &mut self,
639 mode: SelectionMode,
640 trim_whitespace: bool,
641 require_modifiers: &ModifierKeys,
642 ) -> Result<(), JsValue> {
643 let options = MouseSelectOptions::new()
644 .selection_mode(mode.into())
645 .trim_trailing_whitespace(trim_whitespace)
646 .require_modifier_keys((*require_modifiers).into());
647
648 Ok(self.terminal.enable_mouse_selection(options)?)
649 }
650
651 #[wasm_bindgen(js_name = "setMouseHandler")]
653 pub fn set_mouse_handler(&mut self, handler: js_sys::Function) -> Result<(), JsValue> {
654 let handler_closure = {
655 let handler = handler.clone();
656 move |event: TerminalMouseEvent, _grid: &TerminalGrid| {
657 let js_event = MouseEvent::from(event);
658 let this = JsValue::null();
659 let args = js_sys::Array::new();
660 args.push(&JsValue::from(js_event));
661
662 if let Err(e) = handler.apply(&this, &args) {
663 console::error_1(&format!("Mouse handler error: {e:?}").into());
664 }
665 }
666 };
667
668 Ok(self
669 .terminal
670 .set_mouse_callback(handler_closure)?)
671 }
672
673 #[wasm_bindgen(js_name = "getText")]
675 pub fn get_text(&self, query: &CellQuery) -> String {
676 self.terminal.get_text(query.inner).to_string()
677 }
678
679 #[wasm_bindgen(js_name = "findUrlAt")]
702 pub fn find_url_at(&self, col: u16, row: u16) -> Option<UrlMatch> {
703 let cursor = CursorPosition::new(col, row);
704 self.terminal
705 .find_url_at(cursor)
706 .map(|m| UrlMatch {
707 url: m.url.to_string(),
708 query: CellQuery { inner: m.query },
709 })
710 }
711
712 #[wasm_bindgen(js_name = "copyToClipboard")]
714 pub fn copy_to_clipboard(&self, text: &str) {
715 crate::js::copy_to_clipboard(text);
716 }
717
718 #[wasm_bindgen(js_name = "clearSelection")]
720 pub fn clear_selection(&self) {
721 self.terminal.clear_selection();
722 }
723
724 #[wasm_bindgen(js_name = "hasSelection")]
726 pub fn has_selection(&self) -> bool {
727 self.terminal.has_selection()
728 }
729
730 #[wasm_bindgen(js_name = "batch")]
732 pub fn new_render_batch(&mut self) -> Batch {
733 Batch { terminal_grid: self.terminal.grid() }
734 }
735
736 #[wasm_bindgen(js_name = "terminalSize")]
738 pub fn terminal_size(&self) -> WasmTerminalSize {
739 let ts = self.terminal.terminal_size();
740 WasmTerminalSize { cols: ts.cols, rows: ts.rows }
741 }
742
743 #[wasm_bindgen(js_name = "cellSize")]
745 pub fn cell_size(&self) -> Size {
746 let cs = self.terminal.cell_size();
747 Size { width: cs.width as u16, height: cs.height as u16 }
748 }
749
750 #[wasm_bindgen]
752 pub fn render(&mut self) {
753 if let Err(e) = self.terminal.render_frame() {
754 console::error_1(&format!("Render error: {e:?}").into());
755 }
756 }
757
758 #[wasm_bindgen]
760 pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
761 Ok(self.terminal.resize(width, height)?)
762 }
763
764 #[wasm_bindgen(js_name = "replaceWithStaticAtlas")]
778 pub fn replace_with_static_atlas(
779 &mut self,
780 atlas_data: Option<js_sys::Uint8Array>,
781 ) -> Result<(), JsValue> {
782 let atlas_config = match atlas_data {
783 Some(data) => {
784 let bytes = data.to_vec();
785 FontAtlasData::from_binary(&bytes)
786 .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e}")))?
787 },
788 None => FontAtlasData::default(),
789 };
790
791 Ok(self
792 .terminal
793 .replace_with_static_atlas(atlas_config)?)
794 }
795
796 #[wasm_bindgen(js_name = "replaceWithDynamicAtlas")]
811 pub fn replace_with_dynamic_atlas(
812 &mut self,
813 font_family: js_sys::Array,
814 font_size: f32,
815 ) -> Result<(), JsValue> {
816 let font_families: Vec<String> = font_family
817 .iter()
818 .filter_map(|v| v.as_string())
819 .collect();
820
821 if font_families.is_empty() {
822 return Err(JsValue::from_str("font_family array cannot be empty"));
823 }
824
825 let refs: Vec<&str> = font_families.iter().map(String::as_str).collect();
826 Ok(self
827 .terminal
828 .replace_with_dynamic_atlas(&refs, font_size)?)
829 }
830}
831
832impl From<SelectionMode> for RustSelectionMode {
834 fn from(mode: SelectionMode) -> Self {
835 match mode {
836 SelectionMode::Block => RustSelectionMode::Block,
837 SelectionMode::Linear => RustSelectionMode::Linear,
838 }
839 }
840}
841
842impl From<RustSelectionMode> for SelectionMode {
843 fn from(mode: RustSelectionMode) -> Self {
844 match mode {
845 RustSelectionMode::Block => SelectionMode::Block,
846 RustSelectionMode::Linear => SelectionMode::Linear,
847 _ => unreachable!(),
848 }
849 }
850}
851
852impl From<TerminalMouseEvent> for MouseEvent {
853 fn from(event: TerminalMouseEvent) -> Self {
854 use crate::mouse::MouseEventType as RustMouseEventType;
855
856 let event_type = match event.event_type {
857 RustMouseEventType::MouseDown => MouseEventType::MouseDown,
858 RustMouseEventType::MouseUp => MouseEventType::MouseUp,
859 RustMouseEventType::MouseMove => MouseEventType::MouseMove,
860 RustMouseEventType::Click => MouseEventType::Click,
861 RustMouseEventType::MouseEnter => MouseEventType::MouseEnter,
862 RustMouseEventType::MouseLeave => MouseEventType::MouseLeave,
863 };
864
865 MouseEvent {
866 event_type,
867 col: event.col,
868 row: event.row,
869 button: event.button(),
870 ctrl_key: event.ctrl_key(),
871 shift_key: event.shift_key(),
872 alt_key: event.alt_key(),
873 meta_key: event.meta_key(),
874 }
875 }
876}
877
878impl From<ModifierKeys> for RustModifierKeys {
879 fn from(keys: ModifierKeys) -> Self {
880 RustModifierKeys::from_bits_truncate(keys.0)
881 }
882}
883
884#[wasm_bindgen(start)]
886pub fn main() {
887 console_error_panic_hook::set_once();
888 console::log_1(&"beamterm WASM module loaded".into());
889}