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