1use std::{cell::RefCell, rc::Rc};
2
3use beamterm_data::{FontAtlasData, Glyph};
4use compact_str::{CompactString, ToCompactString};
5use serde_wasm_bindgen::from_value;
6use unicode_segmentation::UnicodeSegmentation;
7use wasm_bindgen::prelude::*;
8use web_sys::console;
9
10use crate::{
11 gl::{
12 CellData, CellQuery as RustCellQuery, DynamicFontAtlas, Renderer,
13 SelectionMode as RustSelectionMode, StaticFontAtlas, TerminalGrid, select,
14 },
15 mouse::{DefaultSelectionHandler, TerminalMouseEvent, TerminalMouseHandler},
16 terminal::is_double_width,
17};
18
19#[wasm_bindgen]
21#[derive(Debug)]
22pub struct BeamtermRenderer {
23 renderer: Renderer,
24 terminal_grid: Rc<RefCell<TerminalGrid>>,
25 mouse_handler: Option<TerminalMouseHandler>,
26}
27
28#[wasm_bindgen]
30#[derive(Debug, Default, serde::Deserialize)]
31pub struct Cell {
32 symbol: CompactString,
33 style: u16,
34 fg: u32,
35 bg: u32,
36}
37
38#[wasm_bindgen]
39#[derive(Debug, Clone, Copy)]
40pub struct CellStyle {
41 fg: u32,
42 bg: u32,
43 style_bits: u16,
44}
45
46#[wasm_bindgen]
47#[derive(Debug, Clone, Copy)]
48pub struct Size {
49 pub width: u16,
50 pub height: u16,
51}
52
53#[wasm_bindgen]
54#[derive(Debug)]
55pub struct Batch {
56 terminal_grid: Rc<RefCell<TerminalGrid>>,
57 gl: web_sys::WebGl2RenderingContext,
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}
81
82#[wasm_bindgen]
84#[derive(Debug, Clone, Copy)]
85pub struct MouseEvent {
86 pub event_type: MouseEventType,
88 pub col: u16,
90 pub row: u16,
92 pub button: i16,
94 pub ctrl_key: bool,
96 pub shift_key: bool,
98 pub alt_key: bool,
100}
101
102#[wasm_bindgen]
104#[derive(Debug, Clone)]
105pub struct CellQuery {
106 inner: RustCellQuery,
107}
108
109#[wasm_bindgen]
110impl CellQuery {
111 #[wasm_bindgen(constructor)]
113 pub fn new(mode: SelectionMode) -> CellQuery {
114 CellQuery { inner: select(mode.into()) }
115 }
116
117 pub fn start(mut self, col: u16, row: u16) -> CellQuery {
119 self.inner = self.inner.start((col, row));
120 self
121 }
122
123 pub fn end(mut self, col: u16, row: u16) -> CellQuery {
125 self.inner = self.inner.end((col, row));
126 self
127 }
128
129 #[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 #[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 #[wasm_bindgen(constructor)]
162 pub fn new() -> CellStyle {
163 Default::default()
164 }
165
166 #[wasm_bindgen]
168 pub fn fg(mut self, color: u32) -> CellStyle {
169 self.fg = color;
170 self
171 }
172
173 #[wasm_bindgen]
175 pub fn bg(mut self, color: u32) -> CellStyle {
176 self.bg = color;
177 self
178 }
179
180 #[wasm_bindgen]
182 pub fn bold(mut self) -> CellStyle {
183 self.style_bits |= Glyph::BOLD_FLAG;
184 self
185 }
186
187 #[wasm_bindgen]
189 pub fn italic(mut self) -> CellStyle {
190 self.style_bits |= Glyph::ITALIC_FLAG;
191 self
192 }
193
194 #[wasm_bindgen]
196 pub fn underline(mut self) -> CellStyle {
197 self.style_bits |= Glyph::UNDERLINE_FLAG;
198 self
199 }
200
201 #[wasm_bindgen]
203 pub fn strikethrough(mut self) -> CellStyle {
204 self.style_bits |= Glyph::STRIKETHROUGH_FLAG;
205 self
206 }
207
208 #[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, bg: 0x000000, style_bits: 0, }
222 }
223}
224
225#[wasm_bindgen]
226impl Batch {
227 #[wasm_bindgen(js_name = "cell")]
229 pub fn cell(&mut self, x: u16, y: u16, cell_data: &Cell) {
230 let _ = self
231 .terminal_grid
232 .borrow_mut()
233 .update_cell(x, y, cell_data.as_cell_data());
234 }
235
236 #[wasm_bindgen(js_name = "cellByIndex")]
238 pub fn cell_by_index(&mut self, idx: usize, cell_data: &Cell) {
239 let _ = self
240 .terminal_grid
241 .borrow_mut()
242 .update_cell_by_index(idx, cell_data.as_cell_data());
243 }
244
245 #[wasm_bindgen(js_name = "cells")]
248 pub fn cells(&mut self, cells_json: JsValue) -> Result<(), JsValue> {
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
255 .iter()
256 .map(|(x, y, data)| (*x, *y, data.as_cell_data()));
257
258 let mut terminal_grid = self.terminal_grid.borrow_mut();
259 terminal_grid
260 .update_cells_by_position(cell_data)
261 .map_err(|e| JsValue::from_str(&e.to_string()))
262 },
263 e => e.map(|_| ()),
264 }
265 }
266
267 #[wasm_bindgen(js_name = "text")]
269 pub fn text(&mut self, x: u16, y: u16, text: &str, style: &CellStyle) -> Result<(), JsValue> {
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(()); }
276
277 let mut width_offset: u16 = 0;
278 for (i, ch) in text.graphemes(true).enumerate() {
279 let current_col = x + width_offset + i as u16;
280 if current_col >= cols {
281 break;
282 }
283
284 let cell = CellData::new_with_style_bits(ch, style.style_bits, style.fg, style.bg);
285 terminal_grid
286 .update_cell(current_col, y, cell)
287 .map_err(|e| JsValue::from_str(&e.to_string()))?;
288
289 if is_double_width(ch) {
290 width_offset += 1;
291 }
292 }
293
294 Ok(())
295 }
296
297 #[wasm_bindgen(js_name = "fill")]
299 pub fn fill(
300 &mut self,
301 x: u16,
302 y: u16,
303 width: u16,
304 height: u16,
305 cell_data: &Cell,
306 ) -> Result<(), JsValue> {
307 let mut terminal_grid = self.terminal_grid.borrow_mut();
308 let (cols, rows) = terminal_grid.terminal_size();
309
310 let width = (x + width).min(cols).saturating_sub(x);
311 let height = (y + height).min(rows).saturating_sub(y);
312
313 let fill_cell = cell_data.as_cell_data();
314 for y in y..y + height {
315 for x in x..x + width {
316 terminal_grid
317 .update_cell(x, y, fill_cell)
318 .map_err(|e| JsValue::from_str(&e.to_string()))?;
319 }
320 }
321
322 Ok(())
323 }
324
325 #[wasm_bindgen]
327 pub fn clear(&mut self, bg: u32) -> Result<(), JsValue> {
328 let mut terminal_grid = self.terminal_grid.borrow_mut();
329 let (cols, rows) = terminal_grid.terminal_size();
330
331 let clear_cell = CellData::new_with_style_bits(" ", 0, 0xFFFFFF, bg);
332 for y in 0..rows {
333 for x in 0..cols {
334 terminal_grid
335 .update_cell(x, y, clear_cell)
336 .map_err(|e| JsValue::from_str(&e.to_string()))?;
337 }
338 }
339
340 Ok(())
341 }
342
343 #[wasm_bindgen]
345 #[deprecated(since = "0.4.0", note = "no-op, flush is now automatic")]
346 #[allow(deprecated)]
347 pub fn flush(&mut self) -> Result<(), JsValue> {
348 Ok(())
349 }
350}
351
352#[wasm_bindgen]
353impl Cell {
354 #[wasm_bindgen(constructor)]
355 pub fn new(symbol: String, style: &CellStyle) -> Cell {
356 Cell {
357 symbol: symbol.into(),
358 style: style.style_bits,
359 fg: style.fg,
360 bg: style.bg,
361 }
362 }
363
364 #[wasm_bindgen(getter)]
365 pub fn symbol(&self) -> String {
366 self.symbol.to_string()
367 }
368
369 #[wasm_bindgen(setter)]
370 pub fn set_symbol(&mut self, symbol: String) {
371 self.symbol = symbol.into();
372 }
373
374 #[wasm_bindgen(getter)]
375 pub fn fg(&self) -> u32 {
376 self.fg
377 }
378
379 #[wasm_bindgen(setter)]
380 pub fn set_fg(&mut self, color: u32) {
381 self.fg = color;
382 }
383
384 #[wasm_bindgen(getter)]
385 pub fn bg(&self) -> u32 {
386 self.bg
387 }
388
389 #[wasm_bindgen(setter)]
390 pub fn set_bg(&mut self, color: u32) {
391 self.bg = color;
392 }
393
394 #[wasm_bindgen(getter)]
395 pub fn style(&self) -> u16 {
396 self.style
397 }
398
399 #[wasm_bindgen(setter)]
400 pub fn set_style(&mut self, style: u16) {
401 self.style = style;
402 }
403}
404
405impl Cell {
406 pub fn as_cell_data(&self) -> CellData<'_> {
407 CellData::new_with_style_bits(&self.symbol, self.style, self.fg, self.bg)
408 }
409}
410
411#[wasm_bindgen]
412impl BeamtermRenderer {
413 #[wasm_bindgen(constructor)]
415 pub fn new(canvas_id: &str) -> Result<BeamtermRenderer, JsValue> {
416 Self::with_static_atlas(canvas_id, None)
417 }
418
419 #[wasm_bindgen(js_name = "withStaticAtlas")]
425 pub fn with_static_atlas(
426 canvas_id: &str,
427 atlas_data: Option<js_sys::Uint8Array>,
428 ) -> Result<BeamtermRenderer, JsValue> {
429 console_error_panic_hook::set_once();
430
431 let renderer = Renderer::create(canvas_id)
432 .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {e}")))?;
433
434 let gl = renderer.gl();
435 let atlas_config = match atlas_data {
436 Some(data) => {
437 let bytes = data.to_vec();
438 FontAtlasData::from_binary(&bytes)
439 .map_err(|e| JsValue::from_str(&format!("Failed to parse atlas data: {e:?}")))?
440 },
441 None => FontAtlasData::default(),
442 };
443
444 let atlas = StaticFontAtlas::load(gl, atlas_config)
445 .map_err(|e| JsValue::from_str(&format!("Failed to load font atlas: {e}")))?;
446
447 let canvas_size = renderer.canvas_size();
448 let terminal_grid = TerminalGrid::new(gl, atlas.into(), canvas_size)
449 .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {e}")))?;
450
451 console::log_1(&"BeamtermRenderer initialized with static atlas".into());
452 let terminal_grid = Rc::new(RefCell::new(terminal_grid));
453 Ok(BeamtermRenderer { renderer, terminal_grid, mouse_handler: None })
454 }
455
456 #[wasm_bindgen(js_name = "withDynamicAtlas")]
475 pub fn with_dynamic_atlas(
476 canvas_id: &str,
477 font_family: js_sys::Array,
478 font_size: f32,
479 ) -> Result<BeamtermRenderer, JsValue> {
480 console_error_panic_hook::set_once();
481
482 let renderer = Renderer::create(canvas_id)
483 .map_err(|e| JsValue::from_str(&format!("Failed to create renderer: {e}")))?;
484
485 let font_families: Vec<CompactString> = font_family
486 .iter()
487 .filter_map(|v| v.as_string())
488 .map(|s| s.to_compact_string())
489 .collect();
490
491 if font_families.is_empty() {
492 return Err(JsValue::from_str("font_family array cannot be empty"));
493 }
494
495 let gl = renderer.gl();
496 let atlas = DynamicFontAtlas::new(gl, &font_families, font_size, None)
497 .map_err(|e| JsValue::from_str(&format!("Failed to create dynamic atlas: {e}")))?;
498
499 let canvas_size = renderer.canvas_size();
500 let terminal_grid = TerminalGrid::new(gl, atlas.into(), canvas_size)
501 .map_err(|e| JsValue::from_str(&format!("Failed to create terminal grid: {e}")))?;
502
503 console::log_1(
504 &format!(
505 "BeamtermRenderer initialized with dynamic atlas (font: {}, size: {}px)",
506 font_families.join(", "),
507 font_size
508 )
509 .into(),
510 );
511 let terminal_grid = Rc::new(RefCell::new(terminal_grid));
512 Ok(BeamtermRenderer { renderer, terminal_grid, mouse_handler: None })
513 }
514
515 #[wasm_bindgen(js_name = "enableSelection")]
517 pub fn enable_selection(
518 &mut self,
519 mode: SelectionMode,
520 trim_whitespace: bool,
521 ) -> Result<(), JsValue> {
522 if let Some(old_handler) = self.mouse_handler.take() {
524 old_handler.cleanup();
525 }
526
527 let selection_tracker = self.terminal_grid.borrow().selection_tracker();
528 let handler =
529 DefaultSelectionHandler::new(self.terminal_grid.clone(), mode.into(), trim_whitespace);
530
531 let mouse_handler = TerminalMouseHandler::new(
532 self.renderer.canvas(),
533 self.terminal_grid.clone(),
534 handler.create_event_handler(selection_tracker),
535 )
536 .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {e}")))?;
537
538 self.mouse_handler = Some(mouse_handler);
539 Ok(())
540 }
541
542 #[wasm_bindgen(js_name = "setMouseHandler")]
544 pub fn set_mouse_handler(&mut self, handler: js_sys::Function) -> Result<(), JsValue> {
545 if let Some(old_handler) = self.mouse_handler.take() {
547 old_handler.cleanup();
548 }
549
550 let handler_closure = {
551 let handler = handler.clone();
552 move |event: TerminalMouseEvent, _grid: &TerminalGrid| {
553 let js_event = MouseEvent::from(event);
554 let this = JsValue::null();
555 let args = js_sys::Array::new();
556 args.push(&JsValue::from(js_event));
557
558 if let Err(e) = handler.apply(&this, &args) {
559 console::error_1(&format!("Mouse handler error: {e:?}").into());
560 }
561 }
562 };
563
564 let mouse_handler = TerminalMouseHandler::new(
565 self.renderer.canvas(),
566 self.terminal_grid.clone(),
567 handler_closure,
568 )
569 .map_err(|e| JsValue::from_str(&format!("Failed to create mouse handler: {e}")))?;
570
571 self.mouse_handler = Some(mouse_handler);
572 Ok(())
573 }
574
575 #[wasm_bindgen(js_name = "getText")]
577 pub fn get_text(&self, query: &CellQuery) -> String {
578 self.terminal_grid
579 .borrow()
580 .get_text(query.inner)
581 .to_string()
582 }
583
584 #[wasm_bindgen(js_name = "copyToClipboard")]
586 pub fn copy_to_clipboard(&self, text: &str) {
587 use wasm_bindgen_futures::spawn_local;
588 let text = text.to_string();
589
590 spawn_local(async move {
591 if let Some(window) = web_sys::window() {
592 let clipboard = window.navigator().clipboard();
593 match wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&text)).await {
594 Ok(_) => {
595 console::log_1(
596 &format!("Copied {} characters to clipboard", text.len()).into(),
597 );
598 },
599 Err(err) => {
600 console::error_1(&format!("Failed to copy to clipboard: {err:?}").into());
601 },
602 }
603 }
604 });
605 }
606
607 #[wasm_bindgen(js_name = "clearSelection")]
609 pub fn clear_selection(&self) {
610 self.terminal_grid
611 .borrow()
612 .selection_tracker()
613 .clear();
614 }
615
616 #[wasm_bindgen(js_name = "hasSelection")]
618 pub fn has_selection(&self) -> bool {
619 self.terminal_grid
620 .borrow()
621 .selection_tracker()
622 .get_query()
623 .is_some()
624 }
625
626 #[wasm_bindgen(js_name = "batch")]
628 pub fn new_render_batch(&mut self) -> Batch {
629 let gl = self.renderer.gl().clone();
630 let terminal_grid = self.terminal_grid.clone();
631 Batch { terminal_grid, gl }
632 }
633
634 #[wasm_bindgen(js_name = "terminalSize")]
636 pub fn terminal_size(&self) -> Size {
637 let (cols, rows) = self.terminal_grid.borrow().terminal_size();
638 Size { width: cols, height: rows }
639 }
640
641 #[wasm_bindgen(js_name = "cellSize")]
643 pub fn cell_size(&self) -> Size {
644 let (width, height) = self.terminal_grid.borrow().cell_size();
645 Size { width: width as u16, height: height as u16 }
646 }
647
648 #[wasm_bindgen]
650 pub fn render(&mut self) {
651 let mut grid = self.terminal_grid.borrow_mut();
652 let _ = grid.flush_cells(self.renderer.gl());
653
654 self.renderer.begin_frame();
655 self.renderer.render(&*grid);
656 self.renderer.end_frame();
657 }
658
659 #[wasm_bindgen]
661 pub fn resize(&mut self, width: i32, height: i32) -> Result<(), JsValue> {
662 self.renderer.resize(width, height);
663
664 let gl = self.renderer.gl();
665 self.terminal_grid
666 .borrow_mut()
667 .resize(gl, (width, height))
668 .map_err(|e| JsValue::from_str(&format!("Failed to resize: {e}")))?;
669
670 if let Some(mouse_handler) = &mut self.mouse_handler {
672 let (cols, rows) = self.terminal_grid.borrow().terminal_size();
673 mouse_handler.update_dimensions(cols, rows);
674 }
675
676 Ok(())
677 }
678}
679
680impl From<SelectionMode> for RustSelectionMode {
682 fn from(mode: SelectionMode) -> Self {
683 match mode {
684 SelectionMode::Block => RustSelectionMode::Block,
685 SelectionMode::Linear => RustSelectionMode::Linear,
686 }
687 }
688}
689
690impl From<RustSelectionMode> for SelectionMode {
691 fn from(mode: RustSelectionMode) -> Self {
692 match mode {
693 RustSelectionMode::Block => SelectionMode::Block,
694 RustSelectionMode::Linear => SelectionMode::Linear,
695 }
696 }
697}
698
699impl From<TerminalMouseEvent> for MouseEvent {
700 fn from(event: TerminalMouseEvent) -> Self {
701 use crate::mouse::MouseEventType as RustMouseEventType;
702
703 let event_type = match event.event_type {
704 RustMouseEventType::MouseDown => MouseEventType::MouseDown,
705 RustMouseEventType::MouseUp => MouseEventType::MouseUp,
706 RustMouseEventType::MouseMove => MouseEventType::MouseMove,
707 };
708
709 MouseEvent {
710 event_type,
711 col: event.col,
712 row: event.row,
713 button: event.button(),
714 ctrl_key: event.ctrl_key(),
715 shift_key: event.shift_key(),
716 alt_key: event.alt_key(),
717 }
718 }
719}
720
721#[wasm_bindgen(start)]
723pub fn main() {
724 console_error_panic_hook::set_once();
725 console::log_1(&"beamterm WASM module loaded".into());
726}