Skip to main content

beamterm_renderer/
terminal.rs

1use std::{cell::RefCell, rc::Rc};
2
3use beamterm_core::GlslVersion;
4use beamterm_data::{DebugSpacePattern, FontAtlasData};
5use compact_str::{CompactString, CompactStringExt, ToCompactString, format_compact};
6use wasm_bindgen::prelude::*;
7
8use crate::{
9    CellData, CursorPosition, Error, FontAtlas, Renderer, StaticFontAtlas, TerminalGrid, UrlMatch,
10    gl::{CellQuery, ContextLossHandler, DynamicFontAtlas, dynamic_atlas::CanvasGlyphRasterizer},
11    js::device_pixel_ratio,
12    mouse::{
13        DefaultSelectionHandler, MouseEventCallback, MouseSelectOptions, TerminalMouseEvent,
14        TerminalMouseHandler,
15    },
16};
17
18/// High-performance WebGL2 terminal renderer.
19///
20/// `Terminal` encapsulates the complete terminal rendering system, providing a
21/// simplified API over the underlying [`Renderer`] and [`TerminalGrid`] components.
22///
23///  ## Selection and Mouse Input
24///
25/// The renderer supports mouse-driven text selection with automatic clipboard
26/// integration:
27///
28/// ```rust,no_run
29/// // Enable selection handler with options
30/// use beamterm_renderer::{MouseSelectOptions, Terminal, mouse::SelectionMode};
31///
32/// let terminal = Terminal::builder("#canvas")
33///     .mouse_selection_handler(
34///         MouseSelectOptions::new()
35///             .selection_mode(SelectionMode::Linear)
36///             .trim_trailing_whitespace(true),
37///     )
38///     .build().unwrap();
39///
40/// // Or implement custom mouse handling
41/// let terminal = Terminal::builder("#canvas")
42///     .mouse_input_handler(|event, grid| {
43///         // Custom handler logic
44///     })
45///     .build().unwrap();
46///```
47///
48/// # Examples
49///
50/// ```rust,no_run
51/// use beamterm_renderer::{CellData, Terminal};
52///
53/// // Create and render a simple terminal
54/// let mut terminal = Terminal::builder("#canvas").build().unwrap();
55///
56/// // Update cells with content
57/// let cells: Vec<CellData> = unimplemented!();
58/// terminal.update_cells(cells.into_iter()).unwrap();
59///
60/// // Render frame
61/// terminal.render_frame().unwrap();
62///
63/// // Handle window resize
64/// let (new_width, new_height) = (800, 600);
65/// terminal.resize(new_width, new_height).unwrap();
66/// ```
67#[derive(Debug)]
68pub struct Terminal {
69    renderer: Renderer,
70    grid: Rc<RefCell<TerminalGrid>>,
71    mouse_handler: Option<TerminalMouseHandler>,
72    context_loss_handler: Option<ContextLossHandler>,
73    /// Current device pixel ratio for HiDPI rendering
74    current_pixel_ratio: f32,
75}
76
77impl Terminal {
78    /// Creates a new terminal builder with the specified canvas source.
79    ///
80    /// # Parameters
81    /// * `canvas` - Canvas identifier (CSS selector) or `HtmlCanvasElement`
82    ///
83    /// # Examples
84    ///
85    /// ```rust,no_run
86    /// // Using CSS selector
87    /// use web_sys::HtmlCanvasElement;
88    /// use beamterm_renderer::Terminal;
89    ///
90    /// let terminal = Terminal::builder("my-terminal").build().unwrap();
91    ///
92    /// // Using canvas element
93    /// let canvas: &HtmlCanvasElement = unimplemented!("document.get_element_by_id(...)");
94    /// let terminal = Terminal::builder(canvas).build().unwrap();
95    /// ```
96    #[allow(private_bounds)]
97    pub fn builder(canvas: impl Into<CanvasSource>) -> TerminalBuilder {
98        TerminalBuilder::new(canvas.into())
99    }
100
101    /// Updates terminal cell content efficiently.
102    ///
103    /// This method batches all cell updates and uploads them to the GPU in a single
104    /// operation. For optimal performance, collect all changes and update in one call
105    /// rather than making multiple calls for individual cells.
106    ///
107    /// Delegates to [`TerminalGrid::update_cells`].
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if glyph rasterization or atlas flushing fails.
112    pub fn update_cells<'a>(
113        &mut self,
114        cells: impl Iterator<Item = CellData<'a>>,
115    ) -> Result<(), Error> {
116        Ok(self.grid.borrow_mut().update_cells(cells)?)
117    }
118
119    /// Updates terminal cell content efficiently.
120    ///
121    /// This method batches all cell updates and uploads them to the GPU in a single
122    /// operation. For optimal performance, collect all changes and update in one call
123    /// rather than making multiple calls for individual cells.
124    ///
125    /// Delegates to [`TerminalGrid::update_cells_by_position`].
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if glyph rasterization or atlas flushing fails.
130    pub fn update_cells_by_position<'a>(
131        &mut self,
132        cells: impl Iterator<Item = (u16, u16, CellData<'a>)>,
133    ) -> Result<(), Error> {
134        Ok(self
135            .grid
136            .borrow_mut()
137            .update_cells_by_position(cells)?)
138    }
139
140    /// Updates terminal cells by their flat index in the grid.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if glyph rasterization or atlas flushing fails.
145    pub fn update_cells_by_index<'a>(
146        &mut self,
147        cells: impl Iterator<Item = (usize, CellData<'a>)>,
148    ) -> Result<(), Error> {
149        Ok(self
150            .grid
151            .borrow_mut()
152            .update_cells_by_index(cells)?)
153    }
154
155    /// Returns the glow rendering context.
156    pub fn gl(&self) -> &glow::Context {
157        self.renderer.gl()
158    }
159
160    /// Resizes the terminal to fit new canvas dimensions.
161    ///
162    /// This method updates both the renderer viewport and terminal grid to match
163    /// the new canvas size. The terminal dimensions (in cells) are automatically
164    /// recalculated based on the cell size from the font atlas.
165    ///
166    /// Combines [`Renderer::resize`] and [`TerminalGrid::resize`] operations.
167    ///
168    /// # Errors
169    ///
170    /// Returns an error if the terminal grid fails to resize (e.g., GPU buffer
171    /// reallocation failure).
172    pub fn resize(&mut self, width: i32, height: i32) -> Result<(), Error> {
173        self.renderer.resize(width, height);
174        // Use physical size for grid layout
175        let (w, h) = self.renderer.physical_size();
176        self.grid
177            .borrow_mut()
178            .resize(self.renderer.gl(), (w, h), self.current_pixel_ratio)?;
179
180        Ok(())
181    }
182
183    /// Returns the terminal dimensions in cells.
184    pub fn terminal_size(&self) -> beamterm_data::TerminalSize {
185        self.grid.borrow().terminal_size()
186    }
187
188    /// Returns the total number of cells in the terminal grid.
189    pub fn cell_count(&self) -> usize {
190        self.grid.borrow().cell_count()
191    }
192
193    /// Returns the size of the canvas in pixels.
194    pub fn canvas_size(&self) -> (i32, i32) {
195        self.renderer.canvas_size()
196    }
197
198    /// Returns the size of each cell in pixels.
199    pub fn cell_size(&self) -> beamterm_data::CellSize {
200        self.grid.borrow().cell_size()
201    }
202
203    /// Returns a reference to the HTML canvas element used for rendering.
204    pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
205        self.renderer.canvas()
206    }
207
208    /// Returns a reference to the underlying renderer.
209    pub fn renderer(&self) -> &Renderer {
210        &self.renderer
211    }
212
213    /// Returns a reference to the terminal grid.
214    pub fn grid(&self) -> Rc<RefCell<TerminalGrid>> {
215        self.grid.clone()
216    }
217
218    /// Replaces the current font atlas with a new static atlas.
219    ///
220    /// All existing cell content is preserved and translated to the new atlas.
221    /// The grid will be resized if the new atlas has different cell dimensions.
222    ///
223    /// # Parameters
224    /// * `atlas_data` - Binary atlas data loaded from a `.atlas` file
225    ///
226    /// # Example
227    /// ```rust,ignore
228    /// use beamterm_renderer::{Terminal, FontAtlasData};
229    ///
230    /// let mut terminal = Terminal::builder("#canvas").build().unwrap();
231    ///
232    /// // Load and apply a new static atlas
233    /// let atlas_data = FontAtlasData::from_binary(&atlas_bytes).unwrap();
234    /// terminal.replace_with_static_atlas(atlas_data).unwrap();
235    /// ```
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if the atlas data cannot be loaded or the GPU texture
240    /// cannot be created.
241    pub fn replace_with_static_atlas(&mut self, atlas_data: FontAtlasData) -> Result<(), Error> {
242        let gl = self.renderer.gl();
243        let atlas = StaticFontAtlas::load(gl, atlas_data)?;
244        self.grid
245            .borrow_mut()
246            .replace_atlas(gl, atlas.into());
247
248        Ok(())
249    }
250
251    /// Replaces the current font atlas with a new dynamic atlas.
252    ///
253    /// The dynamic atlas rasterizes glyphs on-demand using the browser's Canvas API,
254    /// enabling runtime font selection. All existing cell content is preserved and
255    /// translated to the new atlas.
256    ///
257    /// # Parameters
258    /// * `font_family` - Font family names in priority order (e.g., `&["JetBrains Mono", "Hack"]`)
259    /// * `font_size` - Font size in pixels
260    ///
261    /// # Example
262    /// ```rust,no_run
263    /// use beamterm_renderer::Terminal;
264    ///
265    /// let mut terminal = Terminal::builder("#canvas").build().unwrap();
266    ///
267    /// // Switch to a different font at runtime
268    /// terminal.replace_with_dynamic_atlas(&["Fira Code", "Hack"], 15.0).unwrap();
269    /// ```
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if the canvas rasterizer cannot be created or the dynamic
274    /// atlas fails to initialize.
275    pub fn replace_with_dynamic_atlas(
276        &mut self,
277        font_family: &[&str],
278        font_size: f32,
279    ) -> Result<(), Error> {
280        let gl = self.renderer.gl();
281        let pixel_ratio = device_pixel_ratio();
282        let font_family_css = font_family
283            .iter()
284            .map(|&s| format_compact!("'{s}'"))
285            .join_compact(", ");
286        let effective_font_size = font_size * pixel_ratio;
287        let rasterizer = CanvasGlyphRasterizer::new(&font_family_css, effective_font_size)
288            .map_err(|e| Error::Rasterization(e.to_string()))?;
289        let atlas = DynamicFontAtlas::new(gl, rasterizer, font_size, pixel_ratio)?;
290        self.grid
291            .borrow_mut()
292            .replace_atlas(gl, atlas.into());
293
294        Ok(())
295    }
296
297    /// Returns the textual content of the specified cell selection.
298    pub fn get_text(&self, selection: CellQuery) -> CompactString {
299        self.grid.borrow().get_text(selection)
300    }
301
302    /// Detects an HTTP/HTTPS URL at or around the given cell position.
303    ///
304    /// Scans left from the cursor to find a URL scheme (`http://` or `https://`),
305    /// then scans right to find the URL end. Handles trailing punctuation and
306    /// unbalanced parentheses (e.g., Wikipedia URLs).
307    ///
308    /// Returns `None` if no URL is found at the cursor position.
309    ///
310    /// **Note:** Only detects URLs within a single row. URLs that wrap across
311    /// multiple lines are not supported.
312    pub fn find_url_at(&self, cursor: CursorPosition) -> Option<UrlMatch> {
313        let grid = self.grid.borrow();
314        beamterm_core::find_url_at_cursor(cursor, &grid)
315    }
316
317    /// Renders the current terminal state to the canvas.
318    ///
319    /// This method performs the complete render pipeline: frame setup, grid rendering,
320    /// and frame finalization. Call this after updating terminal content to display
321    /// the changes.
322    ///
323    /// If a WebGL context loss occurred and the context has been restored by the browser,
324    /// this method will automatically recreate all GPU resources before rendering.
325    /// The terminal's cell content is preserved during this process.
326    ///
327    /// Combines [`Renderer::begin_frame`], [`Renderer::render`], and [`Renderer::end_frame`].
328    ///
329    /// # Errors
330    ///
331    /// Returns an error if context restoration fails, pixel ratio update fails,
332    /// cell flushing fails, or the grid cannot be rendered.
333    pub fn render_frame(&mut self) -> Result<(), Error> {
334        if self.needs_gl_reinit() {
335            self.restore_context()?;
336        }
337
338        // skip rendering if context is currently lost (waiting for restoration)
339        if self.is_context_lost() {
340            return Ok(());
341        }
342
343        // Check for device pixel ratio changes (HiDPI display switching)
344        let raw_dpr = device_pixel_ratio();
345        if (raw_dpr - self.current_pixel_ratio).abs() > f32::EPSILON {
346            self.handle_pixel_ratio_change(raw_dpr)?;
347        }
348
349        self.grid
350            .borrow_mut()
351            .flush_cells(self.renderer.gl())?;
352
353        self.renderer.begin_frame();
354        self.renderer.render(&*self.grid.borrow())?;
355        self.renderer.end_frame();
356        Ok(())
357    }
358
359    /// Handles a change in device pixel ratio.
360    ///
361    /// Callers should verify the ratio has changed before calling this method.
362    fn handle_pixel_ratio_change(&mut self, raw_pixel_ratio: f32) -> Result<(), Error> {
363        self.current_pixel_ratio = raw_pixel_ratio;
364        let gl = self.renderer.gl();
365
366        // Update atlas (sets cell_scale for static, re-rasterizes for dynamic)
367        self.grid
368            .borrow_mut()
369            .atlas_mut()
370            .update_pixel_ratio(gl, raw_pixel_ratio)?;
371
372        // Always use exact DPR for canvas sizing
373        self.renderer.set_pixel_ratio(raw_pixel_ratio);
374
375        // Resize to apply the new pixel ratio
376        let (w, h) = self.renderer.logical_size();
377        self.resize(w, h)
378    }
379
380    /// Returns a sorted list of all glyphs that were requested but not found in the font atlas.
381    pub fn missing_glyphs(&self) -> Vec<CompactString> {
382        let mut glyphs: Vec<_> = self
383            .grid
384            .borrow()
385            .atlas()
386            .glyph_tracker()
387            .missing_glyphs()
388            .into_iter()
389            .collect();
390        glyphs.sort();
391        glyphs
392    }
393
394    /// Checks if the WebGL context has been lost.
395    ///
396    /// Returns `true` if the context is lost and waiting for restoration.
397    fn is_context_lost(&self) -> bool {
398        if let Some(handler) = &self.context_loss_handler {
399            handler.is_context_lost()
400        } else {
401            self.renderer.is_context_lost()
402        }
403    }
404
405    /// Restores all GPU resources after a WebGL context loss.
406    ///
407    /// # Returns
408    /// * `Ok(())` - All resources successfully restored
409    /// * `Err(Error)` - Failed to restore context or recreate resources
410    fn restore_context(&mut self) -> Result<(), Error> {
411        self.renderer.restore_context()?;
412
413        let gl = self.renderer.gl();
414
415        self.grid
416            .borrow_mut()
417            .recreate_atlas_texture(gl)?;
418        self.grid
419            .borrow_mut()
420            .recreate_resources(gl, &GlslVersion::Es300)?;
421        self.grid.borrow_mut().flush_cells(gl)?;
422
423        if let Some(handler) = &self.context_loss_handler {
424            handler.clear_context_rebuild_needed();
425        }
426
427        // re-apply current pixel ratio after context restoration
428        // (display may have changed during context loss)
429        let dpr = device_pixel_ratio();
430        if (dpr - self.current_pixel_ratio).abs() > f32::EPSILON {
431            self.handle_pixel_ratio_change(dpr)?;
432        } else {
433            // even if DPR unchanged, renderer state was reset - reapply it
434            self.renderer.set_pixel_ratio(dpr);
435            let (w, h) = self.renderer.logical_size();
436            self.renderer.resize(w, h);
437        }
438
439        Ok(())
440    }
441
442    /// Checks if the terminal needs to restore GPU resources after a context loss.
443    fn needs_gl_reinit(&self) -> bool {
444        self.context_loss_handler
445            .as_ref()
446            .is_some_and(ContextLossHandler::context_pending_rebuild)
447    }
448
449    /// Returns the current device pixel ratio.
450    pub fn current_pixel_ratio(&self) -> f32 {
451        self.current_pixel_ratio
452    }
453
454    /// Enables mouse-based text selection with the given options.
455    ///
456    /// Replaces any existing mouse handler. Creates a [`DefaultSelectionHandler`]
457    /// and a [`TerminalMouseHandler`] attached to this terminal's canvas.
458    ///
459    /// # Errors
460    ///
461    /// Returns an error if the mouse event listeners cannot be attached to the canvas.
462    pub fn enable_mouse_selection(&mut self, options: MouseSelectOptions) -> Result<(), Error> {
463        // clean up existing mouse handler if present
464        if let Some(old_handler) = self.mouse_handler.take() {
465            old_handler.cleanup();
466        }
467
468        let selection_tracker = self.grid.borrow().selection_tracker();
469        let handler = DefaultSelectionHandler::new(self.grid.clone(), options);
470
471        let mut mouse_handler = TerminalMouseHandler::new(
472            self.renderer.canvas(),
473            self.grid.clone(),
474            handler.create_event_handler(selection_tracker),
475        )?;
476        mouse_handler.default_input_handler = Some(handler);
477        self.mouse_handler = Some(mouse_handler);
478        Ok(())
479    }
480
481    /// Sets a custom mouse event callback.
482    ///
483    /// Replaces any existing mouse handler. The callback receives
484    /// [`TerminalMouseEvent`] and a reference to the [`TerminalGrid`].
485    ///
486    /// # Errors
487    ///
488    /// Returns an error if the mouse event listeners cannot be attached to the canvas.
489    pub fn set_mouse_callback(
490        &mut self,
491        callback: impl FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
492    ) -> Result<(), Error> {
493        // clean up existing mouse handler if present
494        if let Some(old_handler) = self.mouse_handler.take() {
495            old_handler.cleanup();
496        }
497
498        let mouse_handler =
499            TerminalMouseHandler::new(self.renderer.canvas(), self.grid.clone(), callback)?;
500        self.mouse_handler = Some(mouse_handler);
501        Ok(())
502    }
503
504    /// Clears any active text selection.
505    pub fn clear_selection(&self) {
506        self.grid.borrow().selection_tracker().clear();
507    }
508
509    /// Returns whether there is an active text selection.
510    pub fn has_selection(&self) -> bool {
511        self.grid
512            .borrow()
513            .selection_tracker()
514            .get_query()
515            .is_some()
516    }
517
518    /// Exposes this terminal instance to the browser console for debugging.
519    ///
520    /// After calling this method, you can access the terminal from the console:
521    /// ```javascript
522    /// // In browser console:
523    /// window.__beamterm_debug.getMissingGlyphs();
524    /// ```
525    ///
526    /// Note: This creates a live reference that will show current missing glyphs
527    /// each time you call it.
528    fn expose_to_console(&self) {
529        let debug_api = TerminalDebugApi { grid: self.grid.clone() };
530
531        let window = web_sys::window().expect("no window");
532        js_sys::Reflect::set(
533            &window,
534            &"__beamterm_debug".into(),
535            &JsValue::from(debug_api),
536        )
537        .unwrap();
538
539        web_sys::console::log_1(
540            &"Terminal debugging API exposed at window.__beamterm_debug".into(),
541        );
542    }
543}
544
545/// Canvas source for terminal initialization.
546///
547/// Supports both CSS selector strings and direct `HtmlCanvasElement` references
548/// for flexible terminal creation.
549#[derive(Debug)]
550enum CanvasSource {
551    /// CSS selector string for canvas lookup (e.g., "#terminal", "canvas").
552    Id(CompactString),
553    /// Direct reference to an existing canvas element.
554    Element(web_sys::HtmlCanvasElement),
555}
556
557/// Builder for configuring and creating a [`Terminal`].
558///
559/// Provides a fluent API for terminal configuration with sensible defaults.
560/// The terminal will use the default embedded font atlas unless explicitly configured.
561///
562/// # Examples
563///
564/// ```rust,no_run
565/// // Simple terminal with default configuration
566/// use beamterm_renderer::{FontAtlasData, Terminal};
567///
568/// let terminal = Terminal::builder("#canvas").build().unwrap();
569///
570/// // Terminal with custom font atlas
571/// let atlas = FontAtlasData::from_binary(unimplemented!(".atlas data")).unwrap();
572/// let terminal = Terminal::builder("#canvas")
573///     .font_atlas(atlas)
574///     .fallback_glyph("X".into())
575///     .build().unwrap();
576/// ```
577pub struct TerminalBuilder {
578    canvas: CanvasSource,
579    atlas_kind: AtlasKind,
580    fallback_glyph: Option<CompactString>,
581    input_handler: Option<InputHandler>,
582    canvas_padding_color: u32,
583    enable_debug_api: bool,
584    auto_resize_canvas_css: bool,
585}
586
587#[derive(Debug)]
588enum AtlasKind {
589    Static(Option<FontAtlasData>),
590    Dynamic {
591        font_size: f32,
592        font_family: Vec<CompactString>,
593    },
594    DebugDynamic {
595        font_size: f32,
596        font_family: Vec<CompactString>,
597        debug_space_pattern: DebugSpacePattern,
598    },
599}
600
601impl TerminalBuilder {
602    /// Creates a new terminal builder with the specified canvas source.
603    fn new(canvas: CanvasSource) -> Self {
604        TerminalBuilder {
605            canvas,
606            atlas_kind: AtlasKind::Static(None),
607            fallback_glyph: None,
608            input_handler: None,
609            canvas_padding_color: 0x000000,
610            enable_debug_api: false,
611            auto_resize_canvas_css: true,
612        }
613    }
614
615    /// Sets a custom static font atlas for the terminal.
616    ///
617    /// By default, the terminal uses an embedded font atlas. Use this method
618    /// to provide a custom atlas with different fonts, sizes, or character sets.
619    ///
620    /// Static atlases are pre-generated using the `beamterm-atlas` CLI tool and
621    /// loaded from binary `.atlas` files. They provide consistent rendering but
622    /// require the character set to be known at build time.
623    ///
624    /// For dynamic glyph rasterization at runtime, see [`dynamic_font_atlas`](Self::dynamic_font_atlas).
625    #[must_use]
626    pub fn font_atlas(mut self, atlas: FontAtlasData) -> Self {
627        self.atlas_kind = AtlasKind::Static(Some(atlas));
628        self
629    }
630
631    /// Configures the terminal to use a dynamic font atlas.
632    ///
633    /// Unlike static atlases, the dynamic atlas rasterizes glyphs on-demand using
634    /// the browser's Canvas API. This enables:
635    /// - Runtime font selection without pre-generation
636    /// - Support for any system font available in the browser
637    /// - Automatic handling of unpredictable Unicode content
638    ///
639    /// # Parameters
640    /// * `font_family` - Font family names in priority order (e.g., `&["JetBrains Mono", "Fira Code"]`)
641    /// * `font_size` - Font size in pixels
642    ///
643    /// For pre-generated atlases with fixed character sets, see [`font_atlas`](Self::font_atlas).
644    #[must_use]
645    pub fn dynamic_font_atlas(mut self, font_family: &[&str], font_size: f32) -> Self {
646        self.atlas_kind = AtlasKind::Dynamic {
647            font_family: font_family.iter().map(|&s| s.into()).collect(),
648            font_size,
649        };
650        self
651    }
652
653    /// Configures the terminal to use a dynamic font atlas with debug space pattern.
654    ///
655    /// This is the same as [`dynamic_font_atlas`](Self::dynamic_font_atlas), but replaces
656    /// the space glyph with a checkered pattern for validating pixel-perfect rendering.
657    ///
658    /// # Parameters
659    /// * `font_family` - Font family names in priority order
660    /// * `font_size` - Font size in pixels
661    /// * `pattern` - The checkered pattern to use (1px or 2x2 pixels)
662    #[must_use]
663    pub fn debug_dynamic_font_atlas(
664        mut self,
665        font_family: &[&str],
666        font_size: f32,
667        pattern: DebugSpacePattern,
668    ) -> Self {
669        self.atlas_kind = AtlasKind::DebugDynamic {
670            font_family: font_family.iter().map(|&s| s.into()).collect(),
671            font_size,
672            debug_space_pattern: pattern,
673        };
674        self
675    }
676
677    /// Sets the fallback glyph for missing characters.
678    ///
679    /// When a character is not found in the font atlas, this glyph will be
680    /// displayed instead. Defaults to a space character if not specified.
681    #[must_use]
682    pub fn fallback_glyph(mut self, glyph: &str) -> Self {
683        self.fallback_glyph = Some(glyph.into());
684        self
685    }
686
687    /// Sets the background color for the canvas area outside the terminal grid.
688    ///
689    /// When the canvas dimensions don't align perfectly with the terminal cell grid,
690    /// there may be unused pixels around the edges. This color fills those padding
691    /// areas to maintain a consistent appearance.
692    #[must_use]
693    pub fn canvas_padding_color(mut self, color: u32) -> Self {
694        self.canvas_padding_color = color;
695        self
696    }
697
698    /// Enables the debug API that will be exposed to the browser console.
699    ///
700    /// When enabled, a debug API will be available at `window.__beamterm_debug`
701    /// with methods like `getMissingGlyphs()` for inspecting the terminal state.
702    #[must_use]
703    pub fn enable_debug_api(mut self) -> Self {
704        self.enable_debug_api = true;
705        self
706    }
707
708    /// Controls whether the renderer automatically updates the canvas CSS
709    /// `width` and `height` style properties on resize.
710    ///
711    /// Set to `false` when external CSS (flexbox, grid, percentages) controls the
712    /// canvas dimensions, such as in responsive layouts.
713    ///
714    /// When `true` (the default), the renderer sets `style.width` and `style.height`
715    /// to match the logical size. When `false`, the canvas CSS size is left unchanged.
716    #[must_use]
717    pub fn auto_resize_canvas_css(mut self, enabled: bool) -> Self {
718        self.auto_resize_canvas_css = enabled;
719        self
720    }
721
722    /// Sets a callback for handling terminal mouse input events.
723    #[must_use]
724    pub fn mouse_input_handler<F>(mut self, callback: F) -> Self
725    where
726        F: FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
727    {
728        self.input_handler = Some(InputHandler::Mouse(Box::new(callback)));
729        self
730    }
731
732    /// Enables mouse-based text selection with automatic clipboard copying.
733    ///
734    /// When enabled, users can click and drag to select text in the terminal.
735    /// Selected text is automatically copied to the clipboard on mouse release.
736    ///
737    /// # Example
738    /// ```rust,no_run
739    /// use beamterm_renderer::{Terminal, SelectionMode};
740    /// use beamterm_renderer::mouse::{MouseSelectOptions, ModifierKeys};
741    ///
742    /// let terminal = Terminal::builder("#canvas")
743    ///     .mouse_selection_handler(
744    ///         MouseSelectOptions::new()
745    ///             .selection_mode(SelectionMode::Linear)
746    ///             .require_modifier_keys(ModifierKeys::SHIFT)
747    ///             .trim_trailing_whitespace(true)
748    ///     )
749    ///     .build()
750    ///     .unwrap();
751    /// ```
752    #[must_use]
753    pub fn mouse_selection_handler(mut self, configuration: MouseSelectOptions) -> Self {
754        self.input_handler = Some(InputHandler::CopyOnSelect(configuration));
755        self
756    }
757
758    /// Builds the terminal with the configured options.
759    ///
760    /// # Errors
761    ///
762    /// Returns an error if the renderer cannot be created, the font atlas fails
763    /// to load, the terminal grid cannot be initialized, or the mouse handler
764    /// cannot be attached.
765    pub fn build(self) -> Result<Terminal, Error> {
766        // setup renderer
767        let mut renderer = Self::create_renderer(self.canvas, self.auto_resize_canvas_css)?
768            .canvas_padding_color(self.canvas_padding_color);
769
770        // Always use exact DPR for canvas sizing (physical pixels)
771        // Cell scaling is handled separately by each atlas type
772        let raw_pixel_ratio = device_pixel_ratio();
773        renderer.set_pixel_ratio(raw_pixel_ratio);
774        let (w, h) = renderer.logical_size();
775        renderer.resize(w, h);
776
777        // load font atlas
778        let gl = renderer.gl();
779        let atlas: FontAtlas = match self.atlas_kind {
780            AtlasKind::Static(atlas_data) => {
781                StaticFontAtlas::load(gl, atlas_data.unwrap_or_default())?.into()
782            },
783            AtlasKind::Dynamic { font_family, font_size } => {
784                let rasterizer =
785                    create_canvas_rasterizer(&font_family, font_size, raw_pixel_ratio)?;
786                DynamicFontAtlas::new(gl, rasterizer, font_size, raw_pixel_ratio)?.into()
787            },
788            AtlasKind::DebugDynamic { font_family, font_size, debug_space_pattern } => {
789                let rasterizer =
790                    create_canvas_rasterizer(&font_family, font_size, raw_pixel_ratio)?;
791                DynamicFontAtlas::with_debug_spaces(
792                    gl,
793                    rasterizer,
794                    font_size,
795                    raw_pixel_ratio,
796                    Some(debug_space_pattern),
797                )?
798                .into()
799            },
800        };
801
802        // create terminal grid with physical canvas size
803        let canvas_size = renderer.physical_size();
804        let mut grid =
805            TerminalGrid::new(gl, atlas, canvas_size, raw_pixel_ratio, &GlslVersion::Es300)?;
806        if let Some(fallback) = self.fallback_glyph {
807            grid.set_fallback_glyph(&fallback);
808        };
809        let grid = Rc::new(RefCell::new(grid));
810
811        // Set up context loss handler for automatic recovery
812        let context_loss_handler = ContextLossHandler::new(renderer.canvas()).ok();
813
814        // initialize mouse handler if needed
815        let selection = grid.borrow().selection_tracker();
816
817        match self.input_handler {
818            None => Ok(Terminal {
819                renderer,
820                grid,
821                mouse_handler: None,
822                context_loss_handler,
823                current_pixel_ratio: raw_pixel_ratio,
824            }),
825            Some(InputHandler::CopyOnSelect(select)) => {
826                let handler = DefaultSelectionHandler::new(grid.clone(), select);
827
828                let mut mouse_input = TerminalMouseHandler::new(
829                    renderer.canvas(),
830                    grid.clone(),
831                    handler.create_event_handler(selection),
832                )?;
833                mouse_input.default_input_handler = Some(handler);
834
835                Ok(Terminal {
836                    renderer,
837                    grid,
838                    mouse_handler: Some(mouse_input),
839                    context_loss_handler,
840                    current_pixel_ratio: raw_pixel_ratio,
841                })
842            },
843            Some(InputHandler::Mouse(callback)) => {
844                let mouse_input =
845                    TerminalMouseHandler::new(renderer.canvas(), grid.clone(), callback)?;
846                Ok(Terminal {
847                    renderer,
848                    grid,
849                    mouse_handler: Some(mouse_input),
850                    context_loss_handler,
851                    current_pixel_ratio: raw_pixel_ratio,
852                })
853            },
854        }
855        .inspect(|terminal| {
856            if self.enable_debug_api {
857                terminal.expose_to_console();
858            }
859        })
860    }
861
862    fn create_renderer(canvas: CanvasSource, auto_resize_css: bool) -> Result<Renderer, Error> {
863        let renderer = match canvas {
864            CanvasSource::Id(id) => Renderer::create(&id, auto_resize_css)?,
865            CanvasSource::Element(element) => {
866                Renderer::create_with_canvas(element, auto_resize_css)?
867            },
868        };
869        Ok(renderer)
870    }
871}
872
873enum InputHandler {
874    Mouse(MouseEventCallback),
875    CopyOnSelect(MouseSelectOptions),
876}
877
878/// Debug API exposed to browser console for terminal inspection.
879#[wasm_bindgen]
880pub struct TerminalDebugApi {
881    grid: Rc<RefCell<TerminalGrid>>,
882}
883
884#[wasm_bindgen]
885impl TerminalDebugApi {
886    /// Returns an array of glyphs that were requested but not found in the font atlas.
887    #[wasm_bindgen(js_name = "getMissingGlyphs")]
888    #[must_use]
889    pub fn get_missing_glyphs(&self) -> js_sys::Array {
890        let missing_set = self
891            .grid
892            .borrow()
893            .atlas()
894            .glyph_tracker()
895            .missing_glyphs();
896        let mut missing: Vec<_> = missing_set.into_iter().collect();
897        missing.sort();
898
899        let js_array = js_sys::Array::new();
900        for glyph in missing {
901            js_array.push(&JsValue::from_str(&glyph));
902        }
903        js_array
904    }
905
906    /// Returns the terminal size in cells as an object with `cols` and `rows` fields.
907    ///
908    /// # Panics
909    ///
910    /// Panics if setting properties on the JavaScript object fails.
911    #[wasm_bindgen(js_name = "getTerminalSize")]
912    #[must_use]
913    pub fn get_terminal_size(&self) -> JsValue {
914        let ts = self.grid.borrow().terminal_size();
915        let obj = js_sys::Object::new();
916
917        js_sys::Reflect::set(&obj, &"cols".into(), &JsValue::from(ts.cols)).unwrap();
918        js_sys::Reflect::set(&obj, &"rows".into(), &JsValue::from(ts.rows)).unwrap();
919
920        obj.into()
921    }
922
923    /// Returns the canvas size in pixels as an object with `width` and `height` fields.
924    ///
925    /// # Panics
926    ///
927    /// Panics if setting properties on the JavaScript object fails.
928    #[wasm_bindgen(js_name = "getCanvasSize")]
929    #[must_use]
930    pub fn get_canvas_size(&self) -> JsValue {
931        let (width, height) = self.grid.borrow().canvas_size();
932        let obj = js_sys::Object::new();
933
934        js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
935        js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
936
937        obj.into()
938    }
939
940    /// Returns the number of glyphs available in the font atlas.
941    #[wasm_bindgen(js_name = "getGlyphCount")]
942    #[must_use]
943    pub fn get_glyph_count(&self) -> u32 {
944        self.grid.borrow().atlas().glyph_count()
945    }
946
947    /// Returns the base glyph ID for a given symbol, or null if not found.
948    #[wasm_bindgen(js_name = "getBaseGlyphId")]
949    #[must_use]
950    pub fn get_base_glyph_id(&self, symbol: &str) -> Option<u16> {
951        self.grid.borrow_mut().base_glyph_id(symbol)
952    }
953
954    /// Returns the symbol for a given glyph ID, or null if not found.
955    #[wasm_bindgen(js_name = "getSymbol")]
956    #[must_use]
957    pub fn get_symbol(&self, glyph_id: u16) -> Option<String> {
958        self.grid
959            .borrow()
960            .atlas()
961            .get_symbol(glyph_id)
962            .map(|s| s.to_string())
963    }
964
965    /// Returns the cell size in pixels as an object with `width` and `height` fields.
966    ///
967    /// # Panics
968    ///
969    /// Panics if setting properties on the JavaScript object fails.
970    #[wasm_bindgen(js_name = "getCellSize")]
971    #[must_use]
972    pub fn get_cell_size(&self) -> JsValue {
973        let cs = self.grid.borrow().atlas().cell_size();
974        let obj = js_sys::Object::new();
975
976        js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(cs.width)).unwrap();
977        js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(cs.height)).unwrap();
978
979        obj.into()
980    }
981
982    /// Returns the full atlas glyph-to-symbol mapping as a JavaScript array.
983    ///
984    /// # Panics
985    ///
986    /// Panics if setting properties on the JavaScript objects fails.
987    #[wasm_bindgen(js_name = "getAtlasLookup")]
988    #[must_use]
989    pub fn get_symbol_lookup(&self) -> js_sys::Array {
990        let grid = self.grid.borrow();
991        let atlas = grid.atlas();
992
993        let mut glyphs: Vec<(u16, CompactString)> = Vec::new();
994        atlas.for_each_symbol(&mut |glyph_id, symbol| {
995            glyphs.push((glyph_id, symbol.to_compact_string()));
996        });
997
998        glyphs.sort();
999
1000        let js_array = js_sys::Array::new();
1001        for (glyph_id, symbol) in &glyphs {
1002            let obj = js_sys::Object::new();
1003            js_sys::Reflect::set(&obj, &"glyph_id".into(), &JsValue::from(*glyph_id)).unwrap();
1004            js_sys::Reflect::set(&obj, &"symbol".into(), &JsValue::from(symbol.as_str())).unwrap();
1005
1006            js_array.push(&obj.into());
1007        }
1008        js_array
1009    }
1010}
1011
1012impl<'a> From<&'a str> for CanvasSource {
1013    fn from(id: &'a str) -> Self {
1014        CanvasSource::Id(id.into())
1015    }
1016}
1017
1018impl From<web_sys::HtmlCanvasElement> for CanvasSource {
1019    fn from(element: web_sys::HtmlCanvasElement) -> Self {
1020        CanvasSource::Element(element)
1021    }
1022}
1023
1024impl<'a> From<&'a web_sys::HtmlCanvasElement> for CanvasSource {
1025    fn from(value: &'a web_sys::HtmlCanvasElement) -> Self {
1026        value.clone().into()
1027    }
1028}
1029
1030fn create_canvas_rasterizer(
1031    font_family: &[CompactString],
1032    font_size: f32,
1033    pixel_ratio: f32,
1034) -> Result<CanvasGlyphRasterizer, Error> {
1035    let font_family_css = font_family
1036        .iter()
1037        .map(|s| format_compact!("'{s}'"))
1038        .join_compact(", ");
1039    let effective_font_size = font_size * pixel_ratio;
1040    CanvasGlyphRasterizer::new(&font_family_css, effective_font_size)
1041}