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