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