Skip to main content

beamterm_renderer/
terminal.rs

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