beamterm_renderer/terminal.rs
1use std::{cell::RefCell, rc::Rc};
2
3use beamterm_data::FontAtlasData;
4use compact_str::{CompactString, ToCompactString};
5use unicode_width::UnicodeWidthStr;
6use wasm_bindgen::prelude::*;
7
8use crate::{
9 CellData, DynamicFontAtlas, Error, FontAtlas, Renderer, StaticFontAtlas, TerminalGrid,
10 gl::{CellQuery, ContextLossHandler, SelectionMode},
11 mouse::{
12 DefaultSelectionHandler, MouseEventCallback, TerminalMouseEvent, TerminalMouseHandler,
13 },
14};
15
16/// High-performance WebGL2 terminal renderer.
17///
18/// `Terminal` encapsulates the complete terminal rendering system, providing a
19/// simplified API over the underlying [`Renderer`] and [`TerminalGrid`] components.
20///
21/// ## Selection and Mouse Input
22///
23/// The renderer supports mouse-driven text selection with automatic clipboard
24/// integration:
25///
26/// ```rust,no_run
27/// // Enable default selection handler
28/// use beamterm_renderer::{SelectionMode, Terminal};
29///
30/// let terminal = Terminal::builder("#canvas")
31/// .default_mouse_input_handler(SelectionMode::Linear, true)
32/// .build().unwrap();
33///
34/// // Or implement custom mouse handling
35/// let terminal = Terminal::builder("#canvas")
36/// .mouse_input_handler(|event, grid| {
37/// // Custom handler logic
38/// })
39/// .build().unwrap();
40///```
41///
42/// # Examples
43///
44/// ```rust,no_run
45/// use beamterm_renderer::{CellData, Terminal};
46///
47/// // Create and render a simple terminal
48/// let mut terminal = Terminal::builder("#canvas").build().unwrap();
49///
50/// // Update cells with content
51/// let cells: Vec<CellData> = unimplemented!();
52/// terminal.update_cells(cells.into_iter()).unwrap();
53///
54/// // Render frame
55/// terminal.render_frame().unwrap();
56///
57/// // Handle window resize
58/// let (new_width, new_height) = (800, 600);
59/// terminal.resize(new_width, new_height).unwrap();
60/// ```
61#[derive(Debug)]
62pub struct Terminal {
63 renderer: Renderer,
64 grid: Rc<RefCell<TerminalGrid>>,
65 mouse_handler: Option<TerminalMouseHandler>,
66 context_loss_handler: Option<ContextLossHandler>,
67}
68
69impl Terminal {
70 /// Creates a new terminal builder with the specified canvas source.
71 ///
72 /// # Parameters
73 /// * `canvas` - Canvas identifier (CSS selector) or `HtmlCanvasElement`
74 ///
75 /// # Examples
76 ///
77 /// ```rust,no_run
78 /// // Using CSS selector
79 /// use web_sys::HtmlCanvasElement;
80 /// use beamterm_renderer::Terminal;
81 ///
82 /// let terminal = Terminal::builder("my-terminal").build().unwrap();
83 ///
84 /// // Using canvas element
85 /// let canvas: &HtmlCanvasElement = unimplemented!("document.get_element_by_id(...)");
86 /// let terminal = Terminal::builder(canvas).build().unwrap();
87 /// ```
88 #[allow(private_bounds)]
89 pub fn builder(canvas: impl Into<CanvasSource>) -> TerminalBuilder {
90 TerminalBuilder::new(canvas.into())
91 }
92
93 /// Updates terminal cell content efficiently.
94 ///
95 /// This method batches all cell updates and uploads them to the GPU in a single
96 /// operation. For optimal performance, collect all changes and update in one call
97 /// rather than making multiple calls for individual cells.
98 ///
99 /// Delegates to [`TerminalGrid::update_cells`].
100 pub fn update_cells<'a>(
101 &mut self,
102 cells: impl Iterator<Item = CellData<'a>>,
103 ) -> Result<(), Error> {
104 self.grid
105 .borrow_mut()
106 .update_cells(self.renderer.gl(), cells)
107 }
108
109 /// Updates terminal cell content efficiently.
110 ///
111 /// This method batches all cell updates and uploads them to the GPU in a single
112 /// operation. For optimal performance, collect all changes and update in one call
113 /// rather than making multiple calls for individual cells.
114 ///
115 /// Delegates to [`TerminalGrid::update_cells_by_position`].
116 pub fn update_cells_by_position<'a>(
117 &mut self,
118 cells: impl Iterator<Item = (u16, u16, CellData<'a>)>,
119 ) -> Result<(), Error> {
120 self.grid
121 .borrow_mut()
122 .update_cells_by_position(cells)
123 }
124
125 pub fn update_cells_by_index<'a>(
126 &mut self,
127 cells: impl Iterator<Item = (usize, CellData<'a>)>,
128 ) -> Result<(), Error> {
129 self.grid
130 .borrow_mut()
131 .update_cells_by_index(cells)
132 }
133
134 /// Returns the WebGL2 rendering context.
135 pub fn gl(&self) -> &web_sys::WebGl2RenderingContext {
136 self.renderer.gl()
137 }
138
139 /// Resizes the terminal to fit new canvas dimensions.
140 ///
141 /// This method updates both the renderer viewport and terminal grid to match
142 /// the new canvas size. The terminal dimensions (in cells) are automatically
143 /// recalculated based on the cell size from the font atlas.
144 ///
145 /// Combines [`Renderer::resize`] and [`TerminalGrid::resize`] operations.
146 pub fn resize(&mut self, width: i32, height: i32) -> Result<(), Error> {
147 self.renderer.resize(width, height);
148 self.grid
149 .borrow_mut()
150 .resize(self.renderer.gl(), (width, height))?;
151
152 if let Some(mouse_input) = &mut self.mouse_handler {
153 let (cols, rows) = self.grid.borrow_mut().terminal_size();
154 mouse_input.update_dimensions(cols, rows);
155 }
156
157 Ok(())
158 }
159
160 /// Returns the terminal dimensions in cells.
161 pub fn terminal_size(&self) -> (u16, u16) {
162 self.grid.borrow().terminal_size()
163 }
164
165 /// Returns the total number of cells in the terminal grid.
166 pub fn cell_count(&self) -> usize {
167 self.grid.borrow().cell_count()
168 }
169
170 /// Returns the size of the canvas in pixels.
171 pub fn canvas_size(&self) -> (i32, i32) {
172 self.renderer.canvas_size()
173 }
174
175 /// Returns the size of each cell in pixels.
176 pub fn cell_size(&self) -> (i32, i32) {
177 self.grid.borrow().cell_size()
178 }
179
180 /// Returns a reference to the HTML canvas element used for rendering.
181 pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
182 self.renderer.canvas()
183 }
184
185 /// Returns a reference to the underlying renderer.
186 pub fn renderer(&self) -> &Renderer {
187 &self.renderer
188 }
189
190 /// Returns a reference to the terminal grid.
191 pub fn grid(&self) -> Rc<RefCell<TerminalGrid>> {
192 self.grid.clone()
193 }
194
195 /// Returns the textual content of the specified cell selection.
196 pub fn get_text(&self, selection: CellQuery) -> CompactString {
197 self.grid.borrow().get_text(selection)
198 }
199
200 /// Renders the current terminal state to the canvas.
201 ///
202 /// This method performs the complete render pipeline: frame setup, grid rendering,
203 /// and frame finalization. Call this after updating terminal content to display
204 /// the changes.
205 ///
206 /// If a WebGL context loss occurred and the context has been restored by the browser,
207 /// this method will automatically recreate all GPU resources before rendering.
208 /// The terminal's cell content is preserved during this process.
209 ///
210 /// Combines [`Renderer::begin_frame`], [`Renderer::render`], and [`Renderer::end_frame`].
211 pub fn render_frame(&mut self) -> Result<(), Error> {
212 if self.needs_gl_reinit() {
213 self.restore_context()?;
214 }
215
216 // skip rendering if context is currently lost (waiting for restoration)
217 if self.is_context_lost() {
218 return Ok(());
219 }
220
221 self.grid
222 .borrow_mut()
223 .flush_cells(self.renderer.gl())?;
224
225 self.renderer.begin_frame();
226 self.renderer.render(&*self.grid.borrow());
227 self.renderer.end_frame();
228 Ok(())
229 }
230
231 /// Returns a sorted list of all glyphs that were requested but not found in the font atlas.
232 pub fn missing_glyphs(&self) -> Vec<CompactString> {
233 let mut glyphs: Vec<_> = self
234 .grid
235 .borrow()
236 .atlas()
237 .glyph_tracker()
238 .missing_glyphs()
239 .into_iter()
240 .collect();
241 glyphs.sort();
242 glyphs
243 }
244
245 /// Checks if the WebGL context has been lost.
246 ///
247 /// Returns `true` if the context is lost and waiting for restoration.
248 fn is_context_lost(&self) -> bool {
249 if let Some(handler) = &self.context_loss_handler {
250 handler.is_context_lost()
251 } else {
252 self.renderer.is_context_lost()
253 }
254 }
255
256 /// Restores all GPU resources after a WebGL context loss.
257 ///
258 /// # Returns
259 /// * `Ok(())` - All resources successfully restored
260 /// * `Err(Error)` - Failed to restore context or recreate resources
261 fn restore_context(&mut self) -> Result<(), Error> {
262 self.renderer.restore_context()?;
263
264 let gl = self.renderer.gl();
265
266 self.grid
267 .borrow_mut()
268 .recreate_atlas_texture(gl)?;
269 self.grid.borrow_mut().recreate_resources(gl)?;
270 self.grid.borrow_mut().flush_cells(gl)?;
271
272 if let Some(handler) = &self.context_loss_handler {
273 handler.clear_context_rebuild_needed();
274 }
275
276 Ok(())
277 }
278
279 /// Checks if the terminal needs to restore GPU resources after a context loss.
280 fn needs_gl_reinit(&mut self) -> bool {
281 self.context_loss_handler
282 .as_ref()
283 .map(ContextLossHandler::context_pending_rebuild)
284 .unwrap_or(false)
285 }
286
287 /// Exposes this terminal instance to the browser console for debugging.
288 ///
289 /// After calling this method, you can access the terminal from the console:
290 /// ```javascript
291 /// // In browser console:
292 /// window.__beamterm_debug.getMissingGlyphs();
293 /// ```
294 ///
295 /// Note: This creates a live reference that will show current missing glyphs
296 /// each time you call it.
297 fn expose_to_console(&self) {
298 let debug_api = TerminalDebugApi { grid: self.grid.clone() };
299
300 let window = web_sys::window().expect("no window");
301 js_sys::Reflect::set(
302 &window,
303 &"__beamterm_debug".into(),
304 &JsValue::from(debug_api),
305 )
306 .unwrap();
307
308 web_sys::console::log_1(
309 &"Terminal debugging API exposed at window.__beamterm_debug".into(),
310 );
311 }
312}
313
314/// Canvas source for terminal initialization.
315///
316/// Supports both CSS selector strings and direct `HtmlCanvasElement` references
317/// for flexible terminal creation.
318#[derive(Debug)]
319enum CanvasSource {
320 /// CSS selector string for canvas lookup (e.g., "#terminal", "canvas").
321 Id(CompactString),
322 /// Direct reference to an existing canvas element.
323 Element(web_sys::HtmlCanvasElement),
324}
325
326/// Builder for configuring and creating a [`Terminal`].
327///
328/// Provides a fluent API for terminal configuration with sensible defaults.
329/// The terminal will use the default embedded font atlas unless explicitly configured.
330///
331/// # Examples
332///
333/// ```rust,no_run
334/// // Simple terminal with default configuration
335/// use beamterm_renderer::{FontAtlasData, Terminal};
336///
337/// let terminal = Terminal::builder("#canvas").build().unwrap();
338///
339/// // Terminal with custom font atlas
340/// let atlas = FontAtlasData::from_binary(unimplemented!(".atlas data")).unwrap();
341/// let terminal = Terminal::builder("#canvas")
342/// .font_atlas(atlas)
343/// .fallback_glyph("X".into())
344/// .build().unwrap();
345/// ```
346pub struct TerminalBuilder {
347 canvas: CanvasSource,
348 atlas_kind: AtlasKind,
349 fallback_glyph: Option<CompactString>,
350 input_handler: Option<InputHandler>,
351 canvas_padding_color: u32,
352 enable_debug_api: bool,
353}
354
355#[derive(Debug)]
356enum AtlasKind {
357 Static(Option<FontAtlasData>),
358 Dynamic { font_size: f32, font_family: Vec<CompactString> },
359}
360
361impl TerminalBuilder {
362 /// Creates a new terminal builder with the specified canvas source.
363 fn new(canvas: CanvasSource) -> Self {
364 TerminalBuilder {
365 canvas,
366 atlas_kind: AtlasKind::Static(None),
367 fallback_glyph: None,
368 input_handler: None,
369 canvas_padding_color: 0x000000,
370 enable_debug_api: false,
371 }
372 }
373
374 /// Sets a custom static font atlas for the terminal.
375 ///
376 /// By default, the terminal uses an embedded font atlas. Use this method
377 /// to provide a custom atlas with different fonts, sizes, or character sets.
378 ///
379 /// Static atlases are pre-generated using the `beamterm-atlas` CLI tool and
380 /// loaded from binary `.atlas` files. They provide consistent rendering but
381 /// require the character set to be known at build time.
382 ///
383 /// For dynamic glyph rasterization at runtime, see [`dynamic_font_atlas`](Self::dynamic_font_atlas).
384 pub fn font_atlas(mut self, atlas: FontAtlasData) -> Self {
385 self.atlas_kind = AtlasKind::Static(Some(atlas));
386 self
387 }
388
389 /// Configures the terminal to use a dynamic font atlas.
390 ///
391 /// Unlike static atlases, the dynamic atlas rasterizes glyphs on-demand using
392 /// the browser's Canvas API. This enables:
393 /// - Runtime font selection without pre-generation
394 /// - Support for any system font available in the browser
395 /// - Automatic handling of unpredictable Unicode content
396 ///
397 /// # Parameters
398 /// * `font_family` - Font family names in priority order (e.g., `&["JetBrains Mono", "Fira Code"]`)
399 /// * `font_size` - Font size in pixels
400 ///
401 /// For pre-generated atlases with fixed character sets, see [`font_atlas`](Self::font_atlas).
402 pub fn dynamic_font_atlas(mut self, font_family: &[&str], font_size: f32) -> Self {
403 self.atlas_kind = AtlasKind::Dynamic {
404 font_family: font_family.iter().map(|&s| s.into()).collect(),
405 font_size,
406 };
407 self
408 }
409
410 /// Sets the fallback glyph for missing characters.
411 ///
412 /// When a character is not found in the font atlas, this glyph will be
413 /// displayed instead. Defaults to a space character if not specified.
414 pub fn fallback_glyph(mut self, glyph: &str) -> Self {
415 self.fallback_glyph = Some(glyph.into());
416 self
417 }
418
419 /// Sets the background color for the canvas area outside the terminal grid.
420 ///
421 /// When the canvas dimensions don't align perfectly with the terminal cell grid,
422 /// there may be unused pixels around the edges. This color fills those padding
423 /// areas to maintain a consistent appearance.
424 pub fn canvas_padding_color(mut self, color: u32) -> Self {
425 self.canvas_padding_color = color;
426 self
427 }
428
429 /// Enables the debug API that will be exposed to the browser console.
430 ///
431 /// When enabled, a debug API will be available at `window.__beamterm_debug`
432 /// with methods like `getMissingGlyphs()` for inspecting the terminal state.
433 pub fn enable_debug_api(mut self) -> Self {
434 self.enable_debug_api = true;
435 self
436 }
437
438 /// Sets a callback for handling terminal mouse input events.
439 pub fn mouse_input_handler<F>(mut self, callback: F) -> Self
440 where
441 F: FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
442 {
443 self.input_handler = Some(InputHandler::Mouse(Box::new(callback)));
444 self
445 }
446
447 /// Sets a default selection handler for mouse input events. Left
448 /// button selects text, `Ctrl/Cmd + C` copies the selected text to
449 /// the clipboard.
450 pub fn default_mouse_input_handler(
451 mut self,
452 selection_mode: SelectionMode,
453 trim_trailing_whitespace: bool,
454 ) -> Self {
455 self.input_handler =
456 Some(InputHandler::Internal { selection_mode, trim_trailing_whitespace });
457 self
458 }
459
460 /// Builds the terminal with the configured options.
461 pub fn build(self) -> Result<Terminal, Error> {
462 // setup renderer
463 let renderer = match self.canvas {
464 CanvasSource::Id(id) => Renderer::create(&id)?,
465 CanvasSource::Element(element) => Renderer::create_with_canvas(element)?,
466 };
467 let renderer = renderer.canvas_padding_color(self.canvas_padding_color);
468
469 // load font atlas
470 let gl = renderer.gl();
471 let atlas: FontAtlas = match self.atlas_kind {
472 AtlasKind::Static(atlas_data) => {
473 StaticFontAtlas::load(gl, atlas_data.unwrap_or_default())?.into()
474 },
475 AtlasKind::Dynamic { font_family, font_size } => {
476 DynamicFontAtlas::new(gl, &font_family, font_size)?.into()
477 },
478 };
479
480 // create terminal grid
481 let canvas_size = renderer.canvas_size();
482 let mut grid = TerminalGrid::new(gl, atlas, canvas_size)?;
483 if let Some(fallback) = self.fallback_glyph {
484 grid.set_fallback_glyph(&fallback)
485 };
486 let grid = Rc::new(RefCell::new(grid));
487
488 // Set up context loss handler for automatic recovery
489 let context_loss_handler = ContextLossHandler::new(renderer.canvas()).ok();
490
491 // initialize mouse handler if needed
492 let selection = grid.borrow().selection_tracker();
493 match self.input_handler {
494 None => Ok(Terminal {
495 renderer,
496 grid,
497 mouse_handler: None,
498 context_loss_handler,
499 }),
500 Some(InputHandler::Internal { selection_mode, trim_trailing_whitespace }) => {
501 let handler = DefaultSelectionHandler::new(
502 grid.clone(),
503 selection_mode,
504 trim_trailing_whitespace,
505 );
506
507 let mut mouse_input = TerminalMouseHandler::new(
508 renderer.canvas(),
509 grid.clone(),
510 handler.create_event_handler(selection),
511 )?;
512 mouse_input.default_input_handler = Some(handler);
513
514 Ok(Terminal {
515 renderer,
516 grid,
517 mouse_handler: Some(mouse_input),
518 context_loss_handler,
519 })
520 },
521 Some(InputHandler::Mouse(callback)) => {
522 let mouse_input =
523 TerminalMouseHandler::new(renderer.canvas(), grid.clone(), callback)?;
524 Ok(Terminal {
525 renderer,
526 grid,
527 mouse_handler: Some(mouse_input),
528 context_loss_handler,
529 })
530 },
531 }
532 .inspect(|terminal| {
533 if self.enable_debug_api {
534 terminal.expose_to_console();
535 }
536 })
537 }
538}
539
540enum InputHandler {
541 Mouse(MouseEventCallback),
542 Internal {
543 selection_mode: SelectionMode,
544 trim_trailing_whitespace: bool,
545 },
546}
547
548/// Checks if a grapheme is double-width (emoji or fullwidth character).
549pub(crate) fn is_double_width(grapheme: &str) -> bool {
550 grapheme.len() > 1 && (emojis::get(grapheme).is_some() || grapheme.width() == 2)
551}
552
553/// Debug API exposed to browser console for terminal inspection.
554#[wasm_bindgen]
555pub struct TerminalDebugApi {
556 grid: Rc<RefCell<TerminalGrid>>,
557}
558
559#[wasm_bindgen]
560impl TerminalDebugApi {
561 /// Returns an array of glyphs that were requested but not found in the font atlas.
562 #[wasm_bindgen(js_name = "getMissingGlyphs")]
563 pub fn get_missing_glyphs(&self) -> js_sys::Array {
564 let missing_set = self
565 .grid
566 .borrow()
567 .atlas()
568 .glyph_tracker()
569 .missing_glyphs();
570 let mut missing: Vec<_> = missing_set.into_iter().collect();
571 missing.sort();
572
573 let js_array = js_sys::Array::new();
574 for glyph in missing {
575 js_array.push(&JsValue::from_str(&glyph));
576 }
577 js_array
578 }
579
580 /// Returns the terminal size in cells as an object with `cols` and `rows` fields.
581 #[wasm_bindgen(js_name = "getTerminalSize")]
582 pub fn get_terminal_size(&self) -> JsValue {
583 let (cols, rows) = self.grid.borrow().terminal_size();
584 let obj = js_sys::Object::new();
585
586 js_sys::Reflect::set(&obj, &"cols".into(), &JsValue::from(cols)).unwrap();
587 js_sys::Reflect::set(&obj, &"rows".into(), &JsValue::from(rows)).unwrap();
588
589 obj.into()
590 }
591
592 /// Returns the canvas size in pixels as an object with `width` and `height` fields.
593 #[wasm_bindgen(js_name = "getCanvasSize")]
594 pub fn get_canvas_size(&self) -> JsValue {
595 let (width, height) = self.grid.borrow().canvas_size();
596 let obj = js_sys::Object::new();
597
598 js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
599 js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
600
601 obj.into()
602 }
603
604 /// Returns the number of glyphs available in the font atlas.
605 #[wasm_bindgen(js_name = "getGlyphCount")]
606 pub fn get_glyph_count(&self) -> u32 {
607 self.grid.borrow().atlas().glyph_count()
608 }
609
610 /// Returns the base glyph ID for a given symbol, or null if not found.
611 #[wasm_bindgen(js_name = "getBaseGlyphId")]
612 pub fn get_base_glyph_id(&self, symbol: &str) -> Option<u16> {
613 self.grid
614 .borrow()
615 .atlas()
616 .get_base_glyph_id(symbol)
617 }
618
619 /// Returns the symbol for a given glyph ID, or null if not found.
620 #[wasm_bindgen(js_name = "getSymbol")]
621 pub fn get_symbol(&self, glyph_id: u16) -> Option<String> {
622 self.grid
623 .borrow()
624 .atlas()
625 .get_symbol(glyph_id)
626 .map(|s| s.to_string())
627 }
628
629 /// Returns the cell size in pixels as an object with `width` and `height` fields.
630 #[wasm_bindgen(js_name = "getCellSize")]
631 pub fn get_cell_size(&self) -> JsValue {
632 let (width, height) = self.grid.borrow().atlas().cell_size();
633 let obj = js_sys::Object::new();
634
635 js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
636 js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
637
638 obj.into()
639 }
640
641 #[wasm_bindgen(js_name = "getAtlasLookup")]
642 pub fn get_symbol_lookup(&self) -> js_sys::Array {
643 let grid = self.grid.borrow();
644 let atlas = grid.atlas();
645
646 let mut glyphs: Vec<(u16, CompactString)> = Vec::new();
647 atlas.for_each_symbol(&mut |glyph_id, symbol| {
648 glyphs.push((glyph_id, symbol.to_compact_string()));
649 });
650
651 glyphs.sort();
652
653 let js_array = js_sys::Array::new();
654 for (glyph_id, symbol) in glyphs.iter() {
655 let obj = js_sys::Object::new();
656 js_sys::Reflect::set(&obj, &"glyph_id".into(), &JsValue::from(*glyph_id)).unwrap();
657 js_sys::Reflect::set(&obj, &"symbol".into(), &JsValue::from(symbol.as_str())).unwrap();
658
659 js_array.push(&obj.into());
660 }
661 js_array
662 }
663}
664
665impl<'a> From<&'a str> for CanvasSource {
666 fn from(id: &'a str) -> Self {
667 CanvasSource::Id(id.into())
668 }
669}
670
671impl From<web_sys::HtmlCanvasElement> for CanvasSource {
672 fn from(element: web_sys::HtmlCanvasElement) -> Self {
673 CanvasSource::Element(element)
674 }
675}
676
677impl<'a> From<&'a web_sys::HtmlCanvasElement> for CanvasSource {
678 fn from(value: &'a web_sys::HtmlCanvasElement) -> Self {
679 value.clone().into()
680 }
681}