beamterm_renderer/
terminal.rs

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