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}