beamterm_renderer/terminal.rs
1use std::{cell::RefCell, rc::Rc};
2
3use beamterm_data::FontAtlasData;
4use compact_str::CompactString;
5use wasm_bindgen::prelude::*;
6
7use crate::{
8 CellData, Error, FontAtlas, Renderer, TerminalGrid,
9 gl::{CellQuery, ContextLossHandler, SelectionMode},
10 mouse::{
11 DefaultSelectionHandler, MouseEventCallback, TerminalMouseEvent, TerminalMouseHandler,
12 },
13};
14
15/// High-performance WebGL2 terminal renderer.
16///
17/// `Terminal` encapsulates the complete terminal rendering system, providing a
18/// simplified API over the underlying [`Renderer`] and [`TerminalGrid`] components.
19///
20/// ## Selection and Mouse Input
21///
22/// The renderer supports mouse-driven text selection with automatic clipboard
23/// integration:
24///
25/// ```rust,no_run
26/// // Enable default selection handler
27/// use beamterm_renderer::{SelectionMode, Terminal};
28///
29/// let terminal = Terminal::builder("#canvas")
30/// .default_mouse_input_handler(SelectionMode::Linear, true)
31/// .build().unwrap();
32///
33/// // Or implement custom mouse handling
34/// let terminal = Terminal::builder("#canvas")
35/// .mouse_input_handler(|event, grid| {
36/// // Custom handler logic
37/// })
38/// .build().unwrap();
39///```
40///
41/// # Examples
42///
43/// ```rust,no_run
44/// use beamterm_renderer::{CellData, Terminal};
45///
46/// // Create and render a simple terminal
47/// let mut terminal = Terminal::builder("#canvas").build().unwrap();
48///
49/// // Update cells with content
50/// let cells: Vec<CellData> = unimplemented!();
51/// terminal.update_cells(cells.into_iter()).unwrap();
52///
53/// // Render frame
54/// terminal.render_frame().unwrap();
55///
56/// // Handle window resize
57/// let (new_width, new_height) = (800, 600);
58/// terminal.resize(new_width, new_height).unwrap();
59/// ```
60#[derive(Debug)]
61pub struct Terminal {
62 renderer: Renderer,
63 grid: Rc<RefCell<TerminalGrid>>,
64 mouse_handler: Option<TerminalMouseHandler>,
65 context_loss_handler: Option<ContextLossHandler>,
66}
67
68impl Terminal {
69 /// Creates a new terminal builder with the specified canvas source.
70 ///
71 /// # Parameters
72 /// * `canvas` - Canvas identifier (CSS selector) or `HtmlCanvasElement`
73 ///
74 /// # Examples
75 ///
76 /// ```rust,no_run
77 /// // Using CSS selector
78 /// use web_sys::HtmlCanvasElement;
79 /// use beamterm_renderer::Terminal;
80 ///
81 /// let terminal = Terminal::builder("my-terminal").build().unwrap();
82 ///
83 /// // Using canvas element
84 /// let canvas: &HtmlCanvasElement = unimplemented!("document.get_element_by_id(...)");
85 /// let terminal = Terminal::builder(canvas).build().unwrap();
86 /// ```
87 #[allow(private_bounds)]
88 pub fn builder(canvas: impl Into<CanvasSource>) -> TerminalBuilder {
89 TerminalBuilder::new(canvas.into())
90 }
91
92 /// Updates terminal cell content efficiently.
93 ///
94 /// This method batches all cell updates and uploads them to the GPU in a single
95 /// operation. For optimal performance, collect all changes and update in one call
96 /// rather than making multiple calls for individual cells.
97 ///
98 /// Delegates to [`TerminalGrid::update_cells`].
99 pub fn update_cells<'a>(
100 &mut self,
101 cells: impl Iterator<Item = CellData<'a>>,
102 ) -> Result<(), Error> {
103 self.grid
104 .borrow_mut()
105 .update_cells(self.renderer.gl(), cells)
106 }
107
108 /// Updates terminal cell content efficiently.
109 ///
110 /// This method batches all cell updates and uploads them to the GPU in a single
111 /// operation. For optimal performance, collect all changes and update in one call
112 /// rather than making multiple calls for individual cells.
113 ///
114 /// Delegates to [`TerminalGrid::update_cells_by_position`].
115 pub fn update_cells_by_position<'a>(
116 &mut self,
117 cells: impl Iterator<Item = (u16, u16, CellData<'a>)>,
118 ) -> Result<(), Error> {
119 self.grid
120 .borrow_mut()
121 .update_cells_by_position(self.renderer.gl(), cells)
122 }
123
124 /// Returns the WebGL2 rendering context.
125 pub fn gl(&self) -> &web_sys::WebGl2RenderingContext {
126 self.renderer.gl()
127 }
128
129 /// Resizes the terminal to fit new canvas dimensions.
130 ///
131 /// This method updates both the renderer viewport and terminal grid to match
132 /// the new canvas size. The terminal dimensions (in cells) are automatically
133 /// recalculated based on the cell size from the font atlas.
134 ///
135 /// Combines [`Renderer::resize`] and [`TerminalGrid::resize`] operations.
136 pub fn resize(&mut self, width: i32, height: i32) -> Result<(), Error> {
137 self.renderer.resize(width, height);
138 self.grid
139 .borrow_mut()
140 .resize(self.renderer.gl(), (width, height))?;
141
142 if let Some(mouse_input) = &mut self.mouse_handler {
143 let (cols, rows) = self.grid.borrow_mut().terminal_size();
144 mouse_input.update_dimensions(cols, rows);
145 }
146
147 Ok(())
148 }
149
150 /// Returns the terminal dimensions in cells.
151 pub fn terminal_size(&self) -> (u16, u16) {
152 self.grid.borrow().terminal_size()
153 }
154
155 /// Returns the total number of cells in the terminal grid.
156 pub fn cell_count(&self) -> usize {
157 self.grid.borrow().cell_count()
158 }
159
160 /// Returns the size of the canvas in pixels.
161 pub fn canvas_size(&self) -> (i32, i32) {
162 self.renderer.canvas_size()
163 }
164
165 /// Returns the size of each cell in pixels.
166 pub fn cell_size(&self) -> (i32, i32) {
167 self.grid.borrow().cell_size()
168 }
169
170 /// Returns a reference to the HTML canvas element used for rendering.
171 pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
172 self.renderer.canvas()
173 }
174
175 /// Returns a reference to the underlying renderer.
176 pub fn renderer(&self) -> &Renderer {
177 &self.renderer
178 }
179
180 /// Returns a reference to the terminal grid.
181 pub fn grid(&self) -> Rc<RefCell<TerminalGrid>> {
182 self.grid.clone()
183 }
184
185 /// Returns the textual content of the specified cell selection.
186 pub fn get_text(&self, selection: CellQuery) -> CompactString {
187 self.grid.borrow().get_text(selection)
188 }
189
190 /// Renders the current terminal state to the canvas.
191 ///
192 /// This method performs the complete render pipeline: frame setup, grid rendering,
193 /// and frame finalization. Call this after updating terminal content to display
194 /// the changes.
195 ///
196 /// If a WebGL context loss occurred and the context has been restored by the browser,
197 /// this method will automatically recreate all GPU resources before rendering.
198 /// The terminal's cell content is preserved during this process.
199 ///
200 /// Combines [`Renderer::begin_frame`], [`Renderer::render`], and [`Renderer::end_frame`].
201 pub fn render_frame(&mut self) -> Result<(), Error> {
202 if self.needs_gl_reinit() {
203 self.restore_context()?;
204 }
205
206 // skip rendering if context is currently lost (waiting for restoration)
207 if self.is_context_lost() {
208 return Ok(());
209 }
210
211 self.grid
212 .borrow_mut()
213 .flush_cells(self.renderer.gl())?;
214
215 self.renderer.begin_frame();
216 self.renderer.render(&*self.grid.borrow());
217 self.renderer.end_frame();
218 Ok(())
219 }
220
221 /// Returns a sorted list of all glyphs that were requested but not found in the font atlas.
222 pub fn missing_glyphs(&self) -> Vec<CompactString> {
223 let mut glyphs: Vec<_> = self
224 .grid
225 .borrow()
226 .atlas()
227 .glyph_tracker()
228 .missing_glyphs()
229 .into_iter()
230 .collect();
231 glyphs.sort();
232 glyphs
233 }
234
235 /// Checks if the WebGL context has been lost.
236 ///
237 /// Returns `true` if the context is lost and waiting for restoration.
238 fn is_context_lost(&self) -> bool {
239 if let Some(handler) = &self.context_loss_handler {
240 handler.is_context_lost()
241 } else {
242 self.renderer.is_context_lost()
243 }
244 }
245
246 /// Restores all GPU resources after a WebGL context loss.
247 ///
248 /// # Returns
249 /// * `Ok(())` - All resources successfully restored
250 /// * `Err(Error)` - Failed to restore context or recreate resources
251 fn restore_context(&mut self) -> Result<(), Error> {
252 self.renderer.restore_context()?;
253
254 let gl = self.renderer.gl();
255
256 self.grid
257 .borrow_mut()
258 .recreate_atlas_texture(gl)?;
259 self.grid.borrow_mut().recreate_resources(gl)?;
260 self.grid.borrow_mut().flush_cells(gl)?;
261
262 if let Some(handler) = &self.context_loss_handler {
263 handler.clear_context_rebuild_needed();
264 }
265
266 Ok(())
267 }
268
269 /// Checks if the terminal needs to restore GPU resources after a context loss.
270 fn needs_gl_reinit(&mut self) -> bool {
271 self.context_loss_handler
272 .as_ref()
273 .map(ContextLossHandler::context_pending_rebuild)
274 .unwrap_or(false)
275 }
276
277 /// Exposes this terminal instance to the browser console for debugging.
278 ///
279 /// After calling this method, you can access the terminal from the console:
280 /// ```javascript
281 /// // In browser console:
282 /// window.__beamterm_debug.getMissingGlyphs();
283 /// ```
284 ///
285 /// Note: This creates a live reference that will show current missing glyphs
286 /// each time you call it.
287 fn expose_to_console(&self) {
288 let debug_api = TerminalDebugApi { grid: self.grid.clone() };
289
290 let window = web_sys::window().expect("no window");
291 js_sys::Reflect::set(
292 &window,
293 &"__beamterm_debug".into(),
294 &JsValue::from(debug_api),
295 )
296 .unwrap();
297
298 web_sys::console::log_1(
299 &"Terminal debugging API exposed at window.__beamterm_debug".into(),
300 );
301 }
302}
303
304/// Canvas source for terminal initialization.
305///
306/// Supports both CSS selector strings and direct `HtmlCanvasElement` references
307/// for flexible terminal creation.
308enum CanvasSource {
309 /// CSS selector string for canvas lookup (e.g., "#terminal", "canvas").
310 Id(CompactString),
311 /// Direct reference to an existing canvas element.
312 Element(web_sys::HtmlCanvasElement),
313}
314
315/// Builder for configuring and creating a [`Terminal`].
316///
317/// Provides a fluent API for terminal configuration with sensible defaults.
318/// The terminal will use the default embedded font atlas unless explicitly configured.
319///
320/// # Examples
321///
322/// ```rust,no_run
323/// // Simple terminal with default configuration
324/// use beamterm_renderer::{FontAtlas, FontAtlasData, Terminal};
325///
326/// let terminal = Terminal::builder("#canvas").build().unwrap();
327///
328/// // Terminal with custom font atlas
329/// let atlas = FontAtlasData::from_binary(unimplemented!(".atlas data")).unwrap();
330/// let terminal = Terminal::builder("#canvas")
331/// .font_atlas(atlas)
332/// .fallback_glyph("X".into())
333/// .build().unwrap();
334/// ```
335pub struct TerminalBuilder {
336 canvas: CanvasSource,
337 atlas_data: Option<FontAtlasData>,
338 fallback_glyph: Option<CompactString>,
339 input_handler: Option<InputHandler>,
340 canvas_padding_color: u32,
341 enable_debug_api: bool,
342}
343
344impl TerminalBuilder {
345 /// Creates a new terminal builder with the specified canvas source.
346 fn new(canvas: CanvasSource) -> Self {
347 TerminalBuilder {
348 canvas,
349 atlas_data: None,
350 fallback_glyph: None,
351 input_handler: None,
352 canvas_padding_color: 0x000000,
353 enable_debug_api: false,
354 }
355 }
356
357 /// Sets a custom font atlas for the terminal.
358 ///
359 /// By default, the terminal uses an embedded font atlas. Use this method
360 /// to provide a custom atlas with different fonts, sizes, or character sets.
361 pub fn font_atlas(mut self, atlas: FontAtlasData) -> Self {
362 self.atlas_data = Some(atlas);
363 self
364 }
365
366 /// Sets the fallback glyph for missing characters.
367 ///
368 /// When a character is not found in the font atlas, this glyph will be
369 /// displayed instead. Defaults to a space character if not specified.
370 pub fn fallback_glyph(mut self, glyph: &str) -> Self {
371 self.fallback_glyph = Some(glyph.into());
372 self
373 }
374
375 /// Sets the background color for the canvas area outside the terminal grid.
376 ///
377 /// When the canvas dimensions don't align perfectly with the terminal cell grid,
378 /// there may be unused pixels around the edges. This color fills those padding
379 /// areas to maintain a consistent appearance.
380 pub fn canvas_padding_color(mut self, color: u32) -> Self {
381 self.canvas_padding_color = color;
382 self
383 }
384
385 /// Enables the debug API that will be exposed to the browser console.
386 ///
387 /// When enabled, a debug API will be available at `window.__beamterm_debug`
388 /// with methods like `getMissingGlyphs()` for inspecting the terminal state.
389 pub fn enable_debug_api(mut self) -> Self {
390 self.enable_debug_api = true;
391 self
392 }
393
394 /// Sets a callback for handling terminal mouse input events.
395 pub fn mouse_input_handler<F>(mut self, callback: F) -> Self
396 where
397 F: FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
398 {
399 self.input_handler = Some(InputHandler::Mouse(Box::new(callback)));
400 self
401 }
402
403 /// Sets a default selection handler for mouse input events. Left
404 /// button selects text, `Ctrl/Cmd + C` copies the selected text to
405 /// the clipboard.
406 pub fn default_mouse_input_handler(
407 mut self,
408 selection_mode: SelectionMode,
409 trim_trailing_whitespace: bool,
410 ) -> Self {
411 self.input_handler =
412 Some(InputHandler::Internal { selection_mode, trim_trailing_whitespace });
413 self
414 }
415
416 /// Builds the terminal with the configured options.
417 pub fn build(self) -> Result<Terminal, Error> {
418 // setup renderer
419 let renderer = match self.canvas {
420 CanvasSource::Id(id) => Renderer::create(&id)?,
421 CanvasSource::Element(element) => Renderer::create_with_canvas(element)?,
422 };
423 let renderer = renderer.canvas_padding_color(self.canvas_padding_color);
424
425 // load font atlas
426 let gl = renderer.gl();
427 let atlas = FontAtlas::load(gl, self.atlas_data.unwrap_or_default())?;
428
429 // create terminal grid
430 let canvas_size = renderer.canvas_size();
431 let mut grid = TerminalGrid::new(gl, atlas, canvas_size)?;
432 if let Some(fallback) = self.fallback_glyph {
433 grid.set_fallback_glyph(&fallback)
434 };
435 let grid = Rc::new(RefCell::new(grid));
436
437 // Set up context loss handler for automatic recovery
438 let context_loss_handler = ContextLossHandler::new(renderer.canvas()).ok();
439
440 // initialize mouse handler if needed
441 let selection = grid.borrow().selection_tracker();
442 match self.input_handler {
443 None => Ok(Terminal {
444 renderer,
445 grid,
446 mouse_handler: None,
447 context_loss_handler,
448 }),
449 Some(InputHandler::Internal { selection_mode, trim_trailing_whitespace }) => {
450 let handler = DefaultSelectionHandler::new(
451 grid.clone(),
452 selection_mode,
453 trim_trailing_whitespace,
454 );
455
456 let mut mouse_input = TerminalMouseHandler::new(
457 renderer.canvas(),
458 grid.clone(),
459 handler.create_event_handler(selection),
460 )?;
461 mouse_input.default_input_handler = Some(handler);
462
463 Ok(Terminal {
464 renderer,
465 grid,
466 mouse_handler: Some(mouse_input),
467 context_loss_handler,
468 })
469 },
470 Some(InputHandler::Mouse(callback)) => {
471 let mouse_input =
472 TerminalMouseHandler::new(renderer.canvas(), grid.clone(), callback)?;
473 Ok(Terminal {
474 renderer,
475 grid,
476 mouse_handler: Some(mouse_input),
477 context_loss_handler,
478 })
479 },
480 }
481 .inspect(|terminal| {
482 if self.enable_debug_api {
483 terminal.expose_to_console();
484 }
485 })
486 }
487}
488
489enum InputHandler {
490 Mouse(MouseEventCallback),
491 Internal {
492 selection_mode: SelectionMode,
493 trim_trailing_whitespace: bool,
494 },
495}
496
497/// Debug API exposed to browser console for terminal inspection.
498#[wasm_bindgen]
499pub struct TerminalDebugApi {
500 grid: Rc<RefCell<TerminalGrid>>,
501}
502
503#[wasm_bindgen]
504impl TerminalDebugApi {
505 /// Returns an array of glyphs that were requested but not found in the font atlas.
506 #[wasm_bindgen(js_name = "getMissingGlyphs")]
507 pub fn get_missing_glyphs(&self) -> js_sys::Array {
508 let missing_set = self
509 .grid
510 .borrow()
511 .atlas()
512 .glyph_tracker()
513 .missing_glyphs();
514 let mut missing: Vec<_> = missing_set.into_iter().collect();
515 missing.sort();
516
517 let js_array = js_sys::Array::new();
518 for glyph in missing {
519 js_array.push(&JsValue::from_str(&glyph));
520 }
521 js_array
522 }
523
524 /// Returns the terminal size in cells as an object with `cols` and `rows` fields.
525 #[wasm_bindgen(js_name = "getTerminalSize")]
526 pub fn get_terminal_size(&self) -> JsValue {
527 let (cols, rows) = self.grid.borrow().terminal_size();
528 let obj = js_sys::Object::new();
529
530 js_sys::Reflect::set(&obj, &"cols".into(), &JsValue::from(cols)).unwrap();
531 js_sys::Reflect::set(&obj, &"rows".into(), &JsValue::from(rows)).unwrap();
532
533 obj.into()
534 }
535
536 /// Returns the canvas size in pixels as an object with `width` and `height` fields.
537 #[wasm_bindgen(js_name = "getCanvasSize")]
538 pub fn get_canvas_size(&self) -> JsValue {
539 let (width, height) = self.grid.borrow().canvas_size();
540 let obj = js_sys::Object::new();
541
542 js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
543 js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
544
545 obj.into()
546 }
547
548 /// Returns the number of glyphs available in the font atlas.
549 #[wasm_bindgen(js_name = "getGlyphCount")]
550 pub fn get_glyph_count(&self) -> u32 {
551 self.grid.borrow().atlas().glyph_count()
552 }
553
554 /// Returns the base glyph ID for a given symbol, or null if not found.
555 #[wasm_bindgen(js_name = "getBaseGlyphId")]
556 pub fn get_base_glyph_id(&self, symbol: &str) -> Option<u16> {
557 self.grid
558 .borrow()
559 .atlas()
560 .get_base_glyph_id(symbol)
561 }
562
563 /// Returns the symbol for a given glyph ID, or null if not found.
564 #[wasm_bindgen(js_name = "getSymbol")]
565 pub fn get_symbol(&self, glyph_id: u16) -> Option<String> {
566 self.grid
567 .borrow()
568 .atlas()
569 .get_symbol(glyph_id)
570 .map(|s| s.to_string())
571 }
572
573 /// Returns the cell size in pixels as an object with `width` and `height` fields.
574 #[wasm_bindgen(js_name = "getCellSize")]
575 pub fn get_cell_size(&self) -> JsValue {
576 let (width, height) = self.grid.borrow().atlas().cell_size();
577 let obj = js_sys::Object::new();
578
579 js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
580 js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
581
582 obj.into()
583 }
584
585 #[wasm_bindgen(js_name = "getAtlasLookup")]
586 pub fn get_symbol_lookup(&self) -> js_sys::Array {
587 let grid = self.grid.borrow();
588 let atlas = grid.atlas();
589 let mut glyphs: Vec<_> = atlas.get_symbol_lookup().iter().collect();
590
591 glyphs.sort();
592
593 let js_array = js_sys::Array::new();
594 for (glyph_id, symbol) in glyphs.into_iter() {
595 let obj = js_sys::Object::new();
596 js_sys::Reflect::set(&obj, &"glyph_id".into(), &JsValue::from(*glyph_id)).unwrap();
597 js_sys::Reflect::set(&obj, &"symbol".into(), &JsValue::from(symbol.as_str())).unwrap();
598
599 js_array.push(&obj.into());
600 }
601 js_array
602 }
603}
604
605impl<'a> From<&'a str> for CanvasSource {
606 fn from(id: &'a str) -> Self {
607 CanvasSource::Id(id.into())
608 }
609}
610
611impl From<web_sys::HtmlCanvasElement> for CanvasSource {
612 fn from(element: web_sys::HtmlCanvasElement) -> Self {
613 CanvasSource::Element(element)
614 }
615}
616
617impl<'a> From<&'a web_sys::HtmlCanvasElement> for CanvasSource {
618 fn from(value: &'a web_sys::HtmlCanvasElement) -> Self {
619 value.clone().into()
620 }
621}