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 gl::{CellQuery, SelectionMode},
9 mouse::{
10 DefaultSelectionHandler, MouseEventCallback, TerminalMouseEvent, TerminalMouseHandler,
11 },
12 CellData, Error, FontAtlas, Renderer, TerminalGrid,
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}
66
67impl Terminal {
68 /// Creates a new terminal builder with the specified canvas source.
69 ///
70 /// # Parameters
71 /// * `canvas` - Canvas identifier (CSS selector) or `HtmlCanvasElement`
72 ///
73 /// # Examples
74 ///
75 /// ```rust,no_run
76 /// // Using CSS selector
77 /// use web_sys::HtmlCanvasElement;
78 /// use beamterm_renderer::Terminal;
79 ///
80 /// let terminal = Terminal::builder("my-terminal").build().unwrap();
81 ///
82 /// // Using canvas element
83 /// let canvas: &HtmlCanvasElement = unimplemented!("document.get_element_by_id(...)");
84 /// let terminal = Terminal::builder(canvas).build().unwrap();
85 /// ```
86 #[allow(private_bounds)]
87 pub fn builder(canvas: impl Into<CanvasSource>) -> TerminalBuilder {
88 TerminalBuilder::new(canvas.into())
89 }
90
91 /// Updates terminal cell content efficiently.
92 ///
93 /// This method batches all cell updates and uploads them to the GPU in a single
94 /// operation. For optimal performance, collect all changes and update in one call
95 /// rather than making multiple calls for individual cells.
96 ///
97 /// Delegates to [`TerminalGrid::update_cells`].
98 pub fn update_cells<'a>(
99 &mut self,
100 cells: impl Iterator<Item = CellData<'a>>,
101 ) -> Result<(), Error> {
102 self.grid
103 .borrow_mut()
104 .update_cells(self.renderer.gl(), cells)
105 }
106
107 /// Updates terminal cell content efficiently.
108 ///
109 /// This method batches all cell updates and uploads them to the GPU in a single
110 /// operation. For optimal performance, collect all changes and update in one call
111 /// rather than making multiple calls for individual cells.
112 ///
113 /// Delegates to [`TerminalGrid::update_cells_by_position`].
114 pub fn update_cells_by_position<'a>(
115 &mut self,
116 cells: impl Iterator<Item = (u16, u16, CellData<'a>)>,
117 ) -> Result<(), Error> {
118 self.grid
119 .borrow_mut()
120 .update_cells_by_position(self.renderer.gl(), cells)
121 }
122
123 /// Returns the WebGL2 rendering context.
124 pub fn gl(&self) -> &web_sys::WebGl2RenderingContext {
125 self.renderer.gl()
126 }
127
128 /// Resizes the terminal to fit new canvas dimensions.
129 ///
130 /// This method updates both the renderer viewport and terminal grid to match
131 /// the new canvas size. The terminal dimensions (in cells) are automatically
132 /// recalculated based on the cell size from the font atlas.
133 ///
134 /// Combines [`Renderer::resize`] and [`TerminalGrid::resize`] operations.
135 pub fn resize(&mut self, width: i32, height: i32) -> Result<(), Error> {
136 self.renderer.resize(width, height);
137 self.grid
138 .borrow_mut()
139 .resize(self.renderer.gl(), (width, height))?;
140
141 if let Some(mouse_input) = &mut self.mouse_handler {
142 let (cols, rows) = self.grid.borrow_mut().terminal_size();
143 mouse_input.update_dimensions(cols, rows);
144 }
145
146 Ok(())
147 }
148
149 /// Returns the terminal dimensions in cells.
150 pub fn terminal_size(&self) -> (u16, u16) {
151 self.grid.borrow().terminal_size()
152 }
153
154 /// Returns the total number of cells in the terminal grid.
155 pub fn cell_count(&self) -> usize {
156 self.grid.borrow().cell_count()
157 }
158
159 /// Returns the size of the canvas in pixels.
160 pub fn canvas_size(&self) -> (i32, i32) {
161 self.renderer.canvas_size()
162 }
163
164 /// Returns the size of each cell in pixels.
165 pub fn cell_size(&self) -> (i32, i32) {
166 self.grid.borrow().cell_size()
167 }
168
169 /// Returns a reference to the HTML canvas element used for rendering.
170 pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
171 self.renderer.canvas()
172 }
173
174 /// Returns a reference to the underlying renderer.
175 pub fn renderer(&self) -> &Renderer {
176 &self.renderer
177 }
178
179 /// Returns a reference to the terminal grid.
180 pub fn grid(&self) -> Rc<RefCell<TerminalGrid>> {
181 self.grid.clone()
182 }
183
184 /// Returns the textual content of the specified cell selection.
185 pub fn get_text(&self, selection: CellQuery) -> CompactString {
186 self.grid.borrow().get_text(selection)
187 }
188
189 /// Renders the current terminal state to the canvas.
190 ///
191 /// This method performs the complete render pipeline: frame setup, grid rendering,
192 /// and frame finalization. Call this after updating terminal content to display
193 /// the changes.
194 ///
195 /// Combines [`Renderer::begin_frame`], [`Renderer::render`], and [`Renderer::end_frame`].
196 pub fn render_frame(&mut self) -> Result<(), Error> {
197 self.grid
198 .borrow_mut()
199 .flush_cells(self.renderer.gl())?;
200
201 self.renderer.begin_frame();
202 self.renderer.render(&*self.grid.borrow());
203 self.renderer.end_frame();
204 Ok(())
205 }
206
207 /// Returns a sorted list of all glyphs that were requested but not found in the font atlas.
208 pub fn missing_glyphs(&self) -> Vec<CompactString> {
209 let mut glyphs: Vec<_> = self
210 .grid
211 .borrow()
212 .atlas()
213 .glyph_tracker()
214 .missing_glyphs()
215 .into_iter()
216 .collect();
217 glyphs.sort();
218 glyphs
219 }
220
221 /// Exposes this terminal instance to the browser console for debugging.
222 ///
223 /// After calling this method, you can access the terminal from the console:
224 /// ```javascript
225 /// // In browser console:
226 /// window.__beamterm_debug.getMissingGlyphs();
227 /// ```
228 ///
229 /// Note: This creates a live reference that will show current missing glyphs
230 /// each time you call it.
231 fn expose_to_console(&self) {
232 let debug_api = TerminalDebugApi { grid: self.grid.clone() };
233
234 let window = web_sys::window().expect("no window");
235 js_sys::Reflect::set(
236 &window,
237 &"__beamterm_debug".into(),
238 &JsValue::from(debug_api),
239 )
240 .unwrap();
241
242 web_sys::console::log_1(
243 &"Terminal debugging API exposed at window.__beamterm_debug".into(),
244 );
245 }
246}
247
248/// Canvas source for terminal initialization.
249///
250/// Supports both CSS selector strings and direct `HtmlCanvasElement` references
251/// for flexible terminal creation.
252enum CanvasSource {
253 /// CSS selector string for canvas lookup (e.g., "#terminal", "canvas").
254 Id(CompactString),
255 /// Direct reference to an existing canvas element.
256 Element(web_sys::HtmlCanvasElement),
257}
258
259/// Builder for configuring and creating a [`Terminal`].
260///
261/// Provides a fluent API for terminal configuration with sensible defaults.
262/// The terminal will use the default embedded font atlas unless explicitly configured.
263///
264/// # Examples
265///
266/// ```rust,no_run
267/// // Simple terminal with default configuration
268/// use beamterm_renderer::{FontAtlas, FontAtlasData, Terminal};
269///
270/// let terminal = Terminal::builder("#canvas").build().unwrap();
271///
272/// // Terminal with custom font atlas
273/// let atlas = FontAtlasData::from_binary(unimplemented!(".atlas data")).unwrap();
274/// let terminal = Terminal::builder("#canvas")
275/// .font_atlas(atlas)
276/// .fallback_glyph("X".into())
277/// .build().unwrap();
278/// ```
279pub struct TerminalBuilder {
280 canvas: CanvasSource,
281 atlas_data: Option<FontAtlasData>,
282 fallback_glyph: Option<CompactString>,
283 input_handler: Option<InputHandler>,
284 canvas_padding_color: u32,
285 enable_debug_api: bool,
286}
287
288impl TerminalBuilder {
289 /// Creates a new terminal builder with the specified canvas source.
290 fn new(canvas: CanvasSource) -> Self {
291 TerminalBuilder {
292 canvas,
293 atlas_data: None,
294 fallback_glyph: None,
295 input_handler: None,
296 canvas_padding_color: 0x000000,
297 enable_debug_api: false,
298 }
299 }
300
301 /// Sets a custom font atlas for the terminal.
302 ///
303 /// By default, the terminal uses an embedded font atlas. Use this method
304 /// to provide a custom atlas with different fonts, sizes, or character sets.
305 pub fn font_atlas(mut self, atlas: FontAtlasData) -> Self {
306 self.atlas_data = Some(atlas);
307 self
308 }
309
310 /// Sets the fallback glyph for missing characters.
311 ///
312 /// When a character is not found in the font atlas, this glyph will be
313 /// displayed instead. Defaults to a space character if not specified.
314 pub fn fallback_glyph(mut self, glyph: &str) -> Self {
315 self.fallback_glyph = Some(glyph.into());
316 self
317 }
318
319 /// Sets the background color for the canvas area outside the terminal grid.
320 ///
321 /// When the canvas dimensions don't align perfectly with the terminal cell grid,
322 /// there may be unused pixels around the edges. This color fills those padding
323 /// areas to maintain a consistent appearance.
324 pub fn canvas_padding_color(mut self, color: u32) -> Self {
325 self.canvas_padding_color = color;
326 self
327 }
328
329 /// Enables the debug API that will be exposed to the browser console.
330 ///
331 /// When enabled, a debug API will be available at `window.__beamterm_debug`
332 /// with methods like `getMissingGlyphs()` for inspecting the terminal state.
333 pub fn enable_debug_api(mut self) -> Self {
334 self.enable_debug_api = true;
335 self
336 }
337
338 /// Sets a callback for handling terminal mouse input events.
339 pub fn mouse_input_handler<F>(mut self, callback: F) -> Self
340 where
341 F: FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
342 {
343 self.input_handler = Some(InputHandler::Mouse(Box::new(callback)));
344 self
345 }
346
347 /// Sets a default selection handler for mouse input events. Left
348 /// button selects text, `Ctrl/Cmd + C` copies the selected text to
349 /// the clipboard.
350 pub fn default_mouse_input_handler(
351 mut self,
352 selection_mode: SelectionMode,
353 trim_trailing_whitespace: bool,
354 ) -> Self {
355 self.input_handler =
356 Some(InputHandler::Internal { selection_mode, trim_trailing_whitespace });
357 self
358 }
359
360 /// Builds the terminal with the configured options.
361 pub fn build(self) -> Result<Terminal, Error> {
362 // setup renderer
363 let renderer = match self.canvas {
364 CanvasSource::Id(id) => Renderer::create(&id)?,
365 CanvasSource::Element(element) => Renderer::create_with_canvas(element)?,
366 };
367 let renderer = renderer.canvas_padding_color(self.canvas_padding_color);
368
369 // load font atlas
370 let gl = renderer.gl();
371 let atlas = FontAtlas::load(gl, self.atlas_data.unwrap_or_default())?;
372
373 // create terminal grid
374 let canvas_size = renderer.canvas_size();
375 let mut grid = TerminalGrid::new(gl, atlas, canvas_size)?;
376 if let Some(fallback) = self.fallback_glyph {
377 grid.set_fallback_glyph(&fallback)
378 };
379 let grid = Rc::new(RefCell::new(grid));
380
381 // initialize mouse handler if needed
382 let selection = grid.borrow().selection_tracker();
383 match self.input_handler {
384 None => Ok(Terminal { renderer, grid, mouse_handler: None }),
385 Some(InputHandler::Internal { selection_mode, trim_trailing_whitespace }) => {
386 let handler = DefaultSelectionHandler::new(
387 grid.clone(),
388 selection_mode,
389 trim_trailing_whitespace,
390 );
391
392 let mut mouse_input = TerminalMouseHandler::new(
393 renderer.canvas(),
394 grid.clone(),
395 handler.create_event_handler(selection),
396 )?;
397 mouse_input.default_input_handler = Some(handler);
398
399 Ok(Terminal { renderer, grid, mouse_handler: Some(mouse_input) })
400 },
401 Some(InputHandler::Mouse(callback)) => {
402 let mouse_input =
403 TerminalMouseHandler::new(renderer.canvas(), grid.clone(), callback)?;
404 Ok(Terminal { renderer, grid, mouse_handler: Some(mouse_input) })
405 },
406 }
407 .inspect(|terminal| {
408 if self.enable_debug_api {
409 terminal.expose_to_console();
410 }
411 })
412 }
413}
414
415enum InputHandler {
416 Mouse(MouseEventCallback),
417 Internal {
418 selection_mode: SelectionMode,
419 trim_trailing_whitespace: bool,
420 },
421}
422
423/// Debug API exposed to browser console for terminal inspection.
424#[wasm_bindgen]
425pub struct TerminalDebugApi {
426 grid: Rc<RefCell<TerminalGrid>>,
427}
428
429#[wasm_bindgen]
430impl TerminalDebugApi {
431 /// Returns an array of glyphs that were requested but not found in the font atlas.
432 #[wasm_bindgen(js_name = "getMissingGlyphs")]
433 pub fn get_missing_glyphs(&self) -> js_sys::Array {
434 let missing_set = self
435 .grid
436 .borrow()
437 .atlas()
438 .glyph_tracker()
439 .missing_glyphs();
440 let mut missing: Vec<_> = missing_set.into_iter().collect();
441 missing.sort();
442
443 let js_array = js_sys::Array::new();
444 for glyph in missing {
445 js_array.push(&JsValue::from_str(&glyph));
446 }
447 js_array
448 }
449
450 /// Returns the terminal size in cells as an object with `cols` and `rows` fields.
451 #[wasm_bindgen(js_name = "getTerminalSize")]
452 pub fn get_terminal_size(&self) -> JsValue {
453 let (cols, rows) = self.grid.borrow().terminal_size();
454 let obj = js_sys::Object::new();
455
456 js_sys::Reflect::set(&obj, &"cols".into(), &JsValue::from(cols)).unwrap();
457 js_sys::Reflect::set(&obj, &"rows".into(), &JsValue::from(rows)).unwrap();
458
459 obj.into()
460 }
461
462 /// Returns the canvas size in pixels as an object with `width` and `height` fields.
463 #[wasm_bindgen(js_name = "getCanvasSize")]
464 pub fn get_canvas_size(&self) -> JsValue {
465 let (width, height) = self.grid.borrow().canvas_size();
466 let obj = js_sys::Object::new();
467
468 js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
469 js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
470
471 obj.into()
472 }
473
474 /// Returns the number of glyphs available in the font atlas.
475 #[wasm_bindgen(js_name = "getGlyphCount")]
476 pub fn get_glyph_count(&self) -> u32 {
477 self.grid.borrow().atlas().glyph_count()
478 }
479
480 /// Returns the base glyph ID for a given symbol, or null if not found.
481 #[wasm_bindgen(js_name = "getBaseGlyphId")]
482 pub fn get_base_glyph_id(&self, symbol: &str) -> Option<u16> {
483 self.grid
484 .borrow()
485 .atlas()
486 .get_base_glyph_id(symbol)
487 }
488
489 /// Returns the symbol for a given glyph ID, or null if not found.
490 #[wasm_bindgen(js_name = "getSymbol")]
491 pub fn get_symbol(&self, glyph_id: u16) -> Option<String> {
492 self.grid
493 .borrow()
494 .atlas()
495 .get_symbol(glyph_id)
496 .map(|s| s.to_string())
497 }
498
499 /// Returns the cell size in pixels as an object with `width` and `height` fields.
500 #[wasm_bindgen(js_name = "getCellSize")]
501 pub fn get_cell_size(&self) -> JsValue {
502 let (width, height) = self.grid.borrow().atlas().cell_size();
503 let obj = js_sys::Object::new();
504
505 js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
506 js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
507
508 obj.into()
509 }
510}
511
512impl<'a> From<&'a str> for CanvasSource {
513 fn from(id: &'a str) -> Self {
514 CanvasSource::Id(id.into())
515 }
516}
517
518impl From<web_sys::HtmlCanvasElement> for CanvasSource {
519 fn from(element: web_sys::HtmlCanvasElement) -> Self {
520 CanvasSource::Element(element)
521 }
522}
523
524impl<'a> From<&'a web_sys::HtmlCanvasElement> for CanvasSource {
525 fn from(value: &'a web_sys::HtmlCanvasElement) -> Self {
526 value.clone().into()
527 }
528}