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    gl::{CellQuery, SelectionMode},
9    mouse::{
10        DefaultSelectionHandler, MouseEventCallback, TerminalMouseEvent, TerminalMouseHandler,
11    },
12    CellData, Error, FontAtlas, Renderer, TerminalGrid,
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}
66
67impl Terminal {
68    /// Creates a new terminal builder with the specified canvas source.
69    ///
70    /// # Parameters
71    /// * `canvas` - Canvas identifier (CSS selector) or `HtmlCanvasElement`
72    ///
73    /// # Examples
74    ///
75    /// ```rust,no_run
76    /// // Using CSS selector
77    /// use web_sys::HtmlCanvasElement;
78    /// use beamterm_renderer::Terminal;
79    ///
80    /// let terminal = Terminal::builder("my-terminal").build().unwrap();
81    ///
82    /// // Using canvas element
83    /// let canvas: &HtmlCanvasElement = unimplemented!("document.get_element_by_id(...)");
84    /// let terminal = Terminal::builder(canvas).build().unwrap();
85    /// ```
86    #[allow(private_bounds)]
87    pub fn builder(canvas: impl Into<CanvasSource>) -> TerminalBuilder {
88        TerminalBuilder::new(canvas.into())
89    }
90
91    /// Updates terminal cell content efficiently.
92    ///
93    /// This method batches all cell updates and uploads them to the GPU in a single
94    /// operation. For optimal performance, collect all changes and update in one call
95    /// rather than making multiple calls for individual cells.
96    ///
97    /// Delegates to [`TerminalGrid::update_cells`].
98    pub fn update_cells<'a>(
99        &mut self,
100        cells: impl Iterator<Item = CellData<'a>>,
101    ) -> Result<(), Error> {
102        self.grid
103            .borrow_mut()
104            .update_cells(self.renderer.gl(), cells)
105    }
106
107    /// Updates terminal cell content efficiently.
108    ///
109    /// This method batches all cell updates and uploads them to the GPU in a single
110    /// operation. For optimal performance, collect all changes and update in one call
111    /// rather than making multiple calls for individual cells.
112    ///
113    /// Delegates to [`TerminalGrid::update_cells_by_position`].
114    pub fn update_cells_by_position<'a>(
115        &mut self,
116        cells: impl Iterator<Item = (u16, u16, CellData<'a>)>,
117    ) -> Result<(), Error> {
118        self.grid
119            .borrow_mut()
120            .update_cells_by_position(self.renderer.gl(), cells)
121    }
122
123    /// Returns the WebGL2 rendering context.
124    pub fn gl(&self) -> &web_sys::WebGl2RenderingContext {
125        self.renderer.gl()
126    }
127
128    /// Resizes the terminal to fit new canvas dimensions.
129    ///
130    /// This method updates both the renderer viewport and terminal grid to match
131    /// the new canvas size. The terminal dimensions (in cells) are automatically
132    /// recalculated based on the cell size from the font atlas.
133    ///
134    /// Combines [`Renderer::resize`] and [`TerminalGrid::resize`] operations.
135    pub fn resize(&mut self, width: i32, height: i32) -> Result<(), Error> {
136        self.renderer.resize(width, height);
137        self.grid
138            .borrow_mut()
139            .resize(self.renderer.gl(), (width, height))?;
140
141        if let Some(mouse_input) = &mut self.mouse_handler {
142            let (cols, rows) = self.grid.borrow_mut().terminal_size();
143            mouse_input.update_dimensions(cols, rows);
144        }
145
146        Ok(())
147    }
148
149    /// Returns the terminal dimensions in cells.
150    pub fn terminal_size(&self) -> (u16, u16) {
151        self.grid.borrow().terminal_size()
152    }
153
154    /// Returns the total number of cells in the terminal grid.
155    pub fn cell_count(&self) -> usize {
156        self.grid.borrow().cell_count()
157    }
158
159    /// Returns the size of the canvas in pixels.
160    pub fn canvas_size(&self) -> (i32, i32) {
161        self.renderer.canvas_size()
162    }
163
164    /// Returns the size of each cell in pixels.
165    pub fn cell_size(&self) -> (i32, i32) {
166        self.grid.borrow().cell_size()
167    }
168
169    /// Returns a reference to the HTML canvas element used for rendering.
170    pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
171        self.renderer.canvas()
172    }
173
174    /// Returns a reference to the underlying renderer.
175    pub fn renderer(&self) -> &Renderer {
176        &self.renderer
177    }
178
179    /// Returns a reference to the terminal grid.
180    pub fn grid(&self) -> Rc<RefCell<TerminalGrid>> {
181        self.grid.clone()
182    }
183
184    /// Returns the textual content of the specified cell selection.
185    pub fn get_text(&self, selection: CellQuery) -> CompactString {
186        self.grid.borrow().get_text(selection)
187    }
188
189    /// Renders the current terminal state to the canvas.
190    ///
191    /// This method performs the complete render pipeline: frame setup, grid rendering,
192    /// and frame finalization. Call this after updating terminal content to display
193    /// the changes.
194    ///
195    /// Combines [`Renderer::begin_frame`], [`Renderer::render`], and [`Renderer::end_frame`].
196    pub fn render_frame(&mut self) -> Result<(), Error> {
197        self.grid
198            .borrow_mut()
199            .flush_cells(self.renderer.gl())?;
200
201        self.renderer.begin_frame();
202        self.renderer.render(&*self.grid.borrow());
203        self.renderer.end_frame();
204        Ok(())
205    }
206
207    /// Returns a sorted list of all glyphs that were requested but not found in the font atlas.
208    pub fn missing_glyphs(&self) -> Vec<CompactString> {
209        let mut glyphs: Vec<_> = self
210            .grid
211            .borrow()
212            .atlas()
213            .glyph_tracker()
214            .missing_glyphs()
215            .into_iter()
216            .collect();
217        glyphs.sort();
218        glyphs
219    }
220
221    /// Exposes this terminal instance to the browser console for debugging.
222    ///
223    /// After calling this method, you can access the terminal from the console:
224    /// ```javascript
225    /// // In browser console:
226    /// window.__beamterm_debug.getMissingGlyphs();
227    /// ```
228    ///
229    /// Note: This creates a live reference that will show current missing glyphs
230    /// each time you call it.
231    fn expose_to_console(&self) {
232        let debug_api = TerminalDebugApi { grid: self.grid.clone() };
233
234        let window = web_sys::window().expect("no window");
235        js_sys::Reflect::set(
236            &window,
237            &"__beamterm_debug".into(),
238            &JsValue::from(debug_api),
239        )
240        .unwrap();
241
242        web_sys::console::log_1(
243            &"Terminal debugging API exposed at window.__beamterm_debug".into(),
244        );
245    }
246}
247
248/// Canvas source for terminal initialization.
249///
250/// Supports both CSS selector strings and direct `HtmlCanvasElement` references
251/// for flexible terminal creation.
252enum CanvasSource {
253    /// CSS selector string for canvas lookup (e.g., "#terminal", "canvas").
254    Id(CompactString),
255    /// Direct reference to an existing canvas element.
256    Element(web_sys::HtmlCanvasElement),
257}
258
259/// Builder for configuring and creating a [`Terminal`].
260///
261/// Provides a fluent API for terminal configuration with sensible defaults.
262/// The terminal will use the default embedded font atlas unless explicitly configured.
263///
264/// # Examples
265///
266/// ```rust,no_run
267/// // Simple terminal with default configuration
268/// use beamterm_renderer::{FontAtlas, FontAtlasData, Terminal};
269///
270/// let terminal = Terminal::builder("#canvas").build().unwrap();
271///
272/// // Terminal with custom font atlas
273/// let atlas = FontAtlasData::from_binary(unimplemented!(".atlas data")).unwrap();
274/// let terminal = Terminal::builder("#canvas")
275///     .font_atlas(atlas)
276///     .fallback_glyph("X".into())
277///     .build().unwrap();
278/// ```
279pub struct TerminalBuilder {
280    canvas: CanvasSource,
281    atlas_data: Option<FontAtlasData>,
282    fallback_glyph: Option<CompactString>,
283    input_handler: Option<InputHandler>,
284    canvas_padding_color: u32,
285    enable_debug_api: bool,
286}
287
288impl TerminalBuilder {
289    /// Creates a new terminal builder with the specified canvas source.
290    fn new(canvas: CanvasSource) -> Self {
291        TerminalBuilder {
292            canvas,
293            atlas_data: None,
294            fallback_glyph: None,
295            input_handler: None,
296            canvas_padding_color: 0x000000,
297            enable_debug_api: false,
298        }
299    }
300
301    /// Sets a custom font atlas for the terminal.
302    ///
303    /// By default, the terminal uses an embedded font atlas. Use this method
304    /// to provide a custom atlas with different fonts, sizes, or character sets.
305    pub fn font_atlas(mut self, atlas: FontAtlasData) -> Self {
306        self.atlas_data = Some(atlas);
307        self
308    }
309
310    /// Sets the fallback glyph for missing characters.
311    ///
312    /// When a character is not found in the font atlas, this glyph will be
313    /// displayed instead. Defaults to a space character if not specified.
314    pub fn fallback_glyph(mut self, glyph: &str) -> Self {
315        self.fallback_glyph = Some(glyph.into());
316        self
317    }
318
319    /// Sets the background color for the canvas area outside the terminal grid.
320    ///
321    /// When the canvas dimensions don't align perfectly with the terminal cell grid,
322    /// there may be unused pixels around the edges. This color fills those padding
323    /// areas to maintain a consistent appearance.
324    pub fn canvas_padding_color(mut self, color: u32) -> Self {
325        self.canvas_padding_color = color;
326        self
327    }
328
329    /// Enables the debug API that will be exposed to the browser console.
330    ///
331    /// When enabled, a debug API will be available at `window.__beamterm_debug`
332    /// with methods like `getMissingGlyphs()` for inspecting the terminal state.
333    pub fn enable_debug_api(mut self) -> Self {
334        self.enable_debug_api = true;
335        self
336    }
337
338    /// Sets a callback for handling terminal mouse input events.
339    pub fn mouse_input_handler<F>(mut self, callback: F) -> Self
340    where
341        F: FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
342    {
343        self.input_handler = Some(InputHandler::Mouse(Box::new(callback)));
344        self
345    }
346
347    /// Sets a default selection handler for mouse input events. Left
348    /// button selects text, `Ctrl/Cmd + C` copies the selected text to
349    /// the clipboard.
350    pub fn default_mouse_input_handler(
351        mut self,
352        selection_mode: SelectionMode,
353        trim_trailing_whitespace: bool,
354    ) -> Self {
355        self.input_handler =
356            Some(InputHandler::Internal { selection_mode, trim_trailing_whitespace });
357        self
358    }
359
360    /// Builds the terminal with the configured options.
361    pub fn build(self) -> Result<Terminal, Error> {
362        // setup renderer
363        let renderer = match self.canvas {
364            CanvasSource::Id(id) => Renderer::create(&id)?,
365            CanvasSource::Element(element) => Renderer::create_with_canvas(element)?,
366        };
367        let renderer = renderer.canvas_padding_color(self.canvas_padding_color);
368
369        // load font atlas
370        let gl = renderer.gl();
371        let atlas = FontAtlas::load(gl, self.atlas_data.unwrap_or_default())?;
372
373        // create terminal grid
374        let canvas_size = renderer.canvas_size();
375        let mut grid = TerminalGrid::new(gl, atlas, canvas_size)?;
376        if let Some(fallback) = self.fallback_glyph {
377            grid.set_fallback_glyph(&fallback)
378        };
379        let grid = Rc::new(RefCell::new(grid));
380
381        // initialize mouse handler if needed
382        let selection = grid.borrow().selection_tracker();
383        match self.input_handler {
384            None => Ok(Terminal { renderer, grid, mouse_handler: None }),
385            Some(InputHandler::Internal { selection_mode, trim_trailing_whitespace }) => {
386                let handler = DefaultSelectionHandler::new(
387                    grid.clone(),
388                    selection_mode,
389                    trim_trailing_whitespace,
390                );
391
392                let mut mouse_input = TerminalMouseHandler::new(
393                    renderer.canvas(),
394                    grid.clone(),
395                    handler.create_event_handler(selection),
396                )?;
397                mouse_input.default_input_handler = Some(handler);
398
399                Ok(Terminal { renderer, grid, mouse_handler: Some(mouse_input) })
400            },
401            Some(InputHandler::Mouse(callback)) => {
402                let mouse_input =
403                    TerminalMouseHandler::new(renderer.canvas(), grid.clone(), callback)?;
404                Ok(Terminal { renderer, grid, mouse_handler: Some(mouse_input) })
405            },
406        }
407        .inspect(|terminal| {
408            if self.enable_debug_api {
409                terminal.expose_to_console();
410            }
411        })
412    }
413}
414
415enum InputHandler {
416    Mouse(MouseEventCallback),
417    Internal {
418        selection_mode: SelectionMode,
419        trim_trailing_whitespace: bool,
420    },
421}
422
423/// Debug API exposed to browser console for terminal inspection.
424#[wasm_bindgen]
425pub struct TerminalDebugApi {
426    grid: Rc<RefCell<TerminalGrid>>,
427}
428
429#[wasm_bindgen]
430impl TerminalDebugApi {
431    /// Returns an array of glyphs that were requested but not found in the font atlas.
432    #[wasm_bindgen(js_name = "getMissingGlyphs")]
433    pub fn get_missing_glyphs(&self) -> js_sys::Array {
434        let missing_set = self
435            .grid
436            .borrow()
437            .atlas()
438            .glyph_tracker()
439            .missing_glyphs();
440        let mut missing: Vec<_> = missing_set.into_iter().collect();
441        missing.sort();
442
443        let js_array = js_sys::Array::new();
444        for glyph in missing {
445            js_array.push(&JsValue::from_str(&glyph));
446        }
447        js_array
448    }
449
450    /// Returns the terminal size in cells as an object with `cols` and `rows` fields.
451    #[wasm_bindgen(js_name = "getTerminalSize")]
452    pub fn get_terminal_size(&self) -> JsValue {
453        let (cols, rows) = self.grid.borrow().terminal_size();
454        let obj = js_sys::Object::new();
455
456        js_sys::Reflect::set(&obj, &"cols".into(), &JsValue::from(cols)).unwrap();
457        js_sys::Reflect::set(&obj, &"rows".into(), &JsValue::from(rows)).unwrap();
458
459        obj.into()
460    }
461
462    /// Returns the canvas size in pixels as an object with `width` and `height` fields.
463    #[wasm_bindgen(js_name = "getCanvasSize")]
464    pub fn get_canvas_size(&self) -> JsValue {
465        let (width, height) = self.grid.borrow().canvas_size();
466        let obj = js_sys::Object::new();
467
468        js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
469        js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
470
471        obj.into()
472    }
473
474    /// Returns the number of glyphs available in the font atlas.
475    #[wasm_bindgen(js_name = "getGlyphCount")]
476    pub fn get_glyph_count(&self) -> u32 {
477        self.grid.borrow().atlas().glyph_count()
478    }
479
480    /// Returns the base glyph ID for a given symbol, or null if not found.
481    #[wasm_bindgen(js_name = "getBaseGlyphId")]
482    pub fn get_base_glyph_id(&self, symbol: &str) -> Option<u16> {
483        self.grid
484            .borrow()
485            .atlas()
486            .get_base_glyph_id(symbol)
487    }
488
489    /// Returns the symbol for a given glyph ID, or null if not found.
490    #[wasm_bindgen(js_name = "getSymbol")]
491    pub fn get_symbol(&self, glyph_id: u16) -> Option<String> {
492        self.grid
493            .borrow()
494            .atlas()
495            .get_symbol(glyph_id)
496            .map(|s| s.to_string())
497    }
498
499    /// Returns the cell size in pixels as an object with `width` and `height` fields.
500    #[wasm_bindgen(js_name = "getCellSize")]
501    pub fn get_cell_size(&self) -> JsValue {
502        let (width, height) = self.grid.borrow().atlas().cell_size();
503        let obj = js_sys::Object::new();
504
505        js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
506        js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
507
508        obj.into()
509    }
510}
511
512impl<'a> From<&'a str> for CanvasSource {
513    fn from(id: &'a str) -> Self {
514        CanvasSource::Id(id.into())
515    }
516}
517
518impl From<web_sys::HtmlCanvasElement> for CanvasSource {
519    fn from(element: web_sys::HtmlCanvasElement) -> Self {
520        CanvasSource::Element(element)
521    }
522}
523
524impl<'a> From<&'a web_sys::HtmlCanvasElement> for CanvasSource {
525    fn from(value: &'a web_sys::HtmlCanvasElement) -> Self {
526        value.clone().into()
527    }
528}