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