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