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