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