use std::{cell::RefCell, rc::Rc};
use beamterm_core::GlslVersion;
use beamterm_data::{DebugSpacePattern, FontAtlasData};
use compact_str::{CompactString, CompactStringExt, ToCompactString, format_compact};
use wasm_bindgen::prelude::*;
use crate::{
CellData, CursorPosition, Error, FontAtlas, Renderer, SelectionMode, StaticFontAtlas,
TerminalGrid, UrlMatch,
gl::{CellQuery, ContextLossHandler, DynamicFontAtlas, dynamic_atlas::CanvasGlyphRasterizer},
js::device_pixel_ratio,
mouse::{
DefaultSelectionHandler, MouseEventCallback, MouseSelectOptions, TerminalMouseEvent,
TerminalMouseHandler,
},
};
#[derive(Debug)]
pub struct Terminal {
renderer: Renderer,
grid: Rc<RefCell<TerminalGrid>>,
mouse_handler: Option<TerminalMouseHandler>,
context_loss_handler: Option<ContextLossHandler>,
current_pixel_ratio: f32,
}
impl Terminal {
#[allow(private_bounds)]
pub fn builder(canvas: impl Into<CanvasSource>) -> TerminalBuilder {
TerminalBuilder::new(canvas.into())
}
pub fn update_cells<'a>(
&mut self,
cells: impl Iterator<Item = CellData<'a>>,
) -> Result<(), Error> {
Ok(self.grid.borrow_mut().update_cells(cells)?)
}
pub fn update_cells_by_position<'a>(
&mut self,
cells: impl Iterator<Item = (u16, u16, CellData<'a>)>,
) -> Result<(), Error> {
Ok(self
.grid
.borrow_mut()
.update_cells_by_position(cells)?)
}
pub fn update_cells_by_index<'a>(
&mut self,
cells: impl Iterator<Item = (usize, CellData<'a>)>,
) -> Result<(), Error> {
Ok(self
.grid
.borrow_mut()
.update_cells_by_index(cells)?)
}
pub fn gl(&self) -> &glow::Context {
self.renderer.gl()
}
pub fn resize(&mut self, width: i32, height: i32) -> Result<(), Error> {
self.renderer.resize(width, height);
let (w, h) = self.renderer.physical_size();
self.grid
.borrow_mut()
.resize(self.renderer.gl(), (w, h), self.current_pixel_ratio)?;
Ok(())
}
pub fn terminal_size(&self) -> beamterm_data::TerminalSize {
self.grid.borrow().terminal_size()
}
pub fn cell_count(&self) -> usize {
self.grid.borrow().cell_count()
}
pub fn canvas_size(&self) -> (i32, i32) {
self.renderer.canvas_size()
}
pub fn cell_size(&self) -> beamterm_data::CellSize {
self.grid.borrow().cell_size()
}
pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
self.renderer.canvas()
}
pub fn renderer(&self) -> &Renderer {
&self.renderer
}
pub fn grid(&self) -> Rc<RefCell<TerminalGrid>> {
self.grid.clone()
}
pub fn replace_with_static_atlas(&mut self, atlas_data: FontAtlasData) -> Result<(), Error> {
let gl = self.renderer.gl();
let atlas = StaticFontAtlas::load(gl, atlas_data)?;
self.grid
.borrow_mut()
.replace_atlas(gl, atlas.into());
Ok(())
}
pub fn replace_with_dynamic_atlas(
&mut self,
font_family: &[&str],
font_size: f32,
) -> Result<(), Error> {
let gl = self.renderer.gl();
let pixel_ratio = device_pixel_ratio();
let font_family_css = font_family
.iter()
.map(|&s| format_compact!("'{s}'"))
.join_compact(", ");
let effective_font_size = font_size * pixel_ratio;
let rasterizer = CanvasGlyphRasterizer::new(&font_family_css, effective_font_size)
.map_err(|e| Error::Rasterization(e.to_string()))?;
let atlas = DynamicFontAtlas::new(gl, rasterizer, font_size, pixel_ratio)?;
self.grid
.borrow_mut()
.replace_atlas(gl, atlas.into());
Ok(())
}
pub fn get_text(&self, selection: CellQuery) -> CompactString {
self.grid.borrow().get_text(selection)
}
pub fn find_url_at(&self, cursor: CursorPosition) -> Option<UrlMatch> {
let grid = self.grid.borrow();
beamterm_core::find_url_at_cursor(cursor, &grid)
}
pub fn render_frame(&mut self) -> Result<(), Error> {
if self.needs_gl_reinit() {
self.restore_context()?;
}
if self.is_context_lost() {
return Ok(());
}
let raw_dpr = device_pixel_ratio();
if (raw_dpr - self.current_pixel_ratio).abs() > f32::EPSILON {
self.handle_pixel_ratio_change(raw_dpr)?;
}
self.grid
.borrow_mut()
.flush_cells(self.renderer.gl())?;
self.renderer.begin_frame();
self.renderer.render(&*self.grid.borrow())?;
self.renderer.end_frame();
Ok(())
}
fn handle_pixel_ratio_change(&mut self, raw_pixel_ratio: f32) -> Result<(), Error> {
self.current_pixel_ratio = raw_pixel_ratio;
let gl = self.renderer.gl();
self.grid
.borrow_mut()
.atlas_mut()
.update_pixel_ratio(gl, raw_pixel_ratio)?;
self.renderer.set_pixel_ratio(raw_pixel_ratio);
let (w, h) = self.renderer.logical_size();
self.resize(w, h)
}
pub fn missing_glyphs(&self) -> Vec<CompactString> {
let mut glyphs: Vec<_> = self
.grid
.borrow()
.atlas()
.glyph_tracker()
.missing_glyphs()
.into_iter()
.collect();
glyphs.sort();
glyphs
}
fn is_context_lost(&self) -> bool {
if let Some(handler) = &self.context_loss_handler {
handler.is_context_lost()
} else {
self.renderer.is_context_lost()
}
}
fn restore_context(&mut self) -> Result<(), Error> {
self.renderer.restore_context()?;
let gl = self.renderer.gl();
self.grid
.borrow_mut()
.recreate_atlas_texture(gl)?;
self.grid
.borrow_mut()
.recreate_resources(gl, &GlslVersion::Es300)?;
self.grid.borrow_mut().flush_cells(gl)?;
if let Some(handler) = &self.context_loss_handler {
handler.clear_context_rebuild_needed();
}
let dpr = device_pixel_ratio();
if (dpr - self.current_pixel_ratio).abs() > f32::EPSILON {
self.handle_pixel_ratio_change(dpr)?;
} else {
self.renderer.set_pixel_ratio(dpr);
let (w, h) = self.renderer.logical_size();
self.renderer.resize(w, h);
}
Ok(())
}
fn needs_gl_reinit(&self) -> bool {
self.context_loss_handler
.as_ref()
.map(ContextLossHandler::context_pending_rebuild)
.unwrap_or(false)
}
pub fn current_pixel_ratio(&self) -> f32 {
self.current_pixel_ratio
}
pub fn enable_mouse_selection(&mut self, options: MouseSelectOptions) -> Result<(), Error> {
if let Some(old_handler) = self.mouse_handler.take() {
old_handler.cleanup();
}
let selection_tracker = self.grid.borrow().selection_tracker();
let handler = DefaultSelectionHandler::new(self.grid.clone(), options);
let mut mouse_handler = TerminalMouseHandler::new(
self.renderer.canvas(),
self.grid.clone(),
handler.create_event_handler(selection_tracker),
)?;
mouse_handler.default_input_handler = Some(handler);
self.mouse_handler = Some(mouse_handler);
Ok(())
}
pub fn set_mouse_callback(
&mut self,
callback: impl FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
) -> Result<(), Error> {
if let Some(old_handler) = self.mouse_handler.take() {
old_handler.cleanup();
}
let mouse_handler =
TerminalMouseHandler::new(self.renderer.canvas(), self.grid.clone(), callback)?;
self.mouse_handler = Some(mouse_handler);
Ok(())
}
pub fn clear_selection(&self) {
self.grid.borrow().selection_tracker().clear();
}
pub fn has_selection(&self) -> bool {
self.grid
.borrow()
.selection_tracker()
.get_query()
.is_some()
}
fn expose_to_console(&self) {
let debug_api = TerminalDebugApi { grid: self.grid.clone() };
let window = web_sys::window().expect("no window");
js_sys::Reflect::set(
&window,
&"__beamterm_debug".into(),
&JsValue::from(debug_api),
)
.unwrap();
web_sys::console::log_1(
&"Terminal debugging API exposed at window.__beamterm_debug".into(),
);
}
}
#[derive(Debug)]
enum CanvasSource {
Id(CompactString),
Element(web_sys::HtmlCanvasElement),
}
pub struct TerminalBuilder {
canvas: CanvasSource,
atlas_kind: AtlasKind,
fallback_glyph: Option<CompactString>,
input_handler: Option<InputHandler>,
canvas_padding_color: u32,
enable_debug_api: bool,
auto_resize_canvas_css: bool,
}
#[derive(Debug)]
enum AtlasKind {
Static(Option<FontAtlasData>),
Dynamic {
font_size: f32,
font_family: Vec<CompactString>,
},
DebugDynamic {
font_size: f32,
font_family: Vec<CompactString>,
debug_space_pattern: DebugSpacePattern,
},
}
impl TerminalBuilder {
fn new(canvas: CanvasSource) -> Self {
TerminalBuilder {
canvas,
atlas_kind: AtlasKind::Static(None),
fallback_glyph: None,
input_handler: None,
canvas_padding_color: 0x000000,
enable_debug_api: false,
auto_resize_canvas_css: true,
}
}
#[must_use]
pub fn font_atlas(mut self, atlas: FontAtlasData) -> Self {
self.atlas_kind = AtlasKind::Static(Some(atlas));
self
}
#[must_use]
pub fn dynamic_font_atlas(mut self, font_family: &[&str], font_size: f32) -> Self {
self.atlas_kind = AtlasKind::Dynamic {
font_family: font_family.iter().map(|&s| s.into()).collect(),
font_size,
};
self
}
#[must_use]
pub fn debug_dynamic_font_atlas(
mut self,
font_family: &[&str],
font_size: f32,
pattern: DebugSpacePattern,
) -> Self {
self.atlas_kind = AtlasKind::DebugDynamic {
font_family: font_family.iter().map(|&s| s.into()).collect(),
font_size,
debug_space_pattern: pattern,
};
self
}
#[must_use]
pub fn fallback_glyph(mut self, glyph: &str) -> Self {
self.fallback_glyph = Some(glyph.into());
self
}
#[must_use]
pub fn canvas_padding_color(mut self, color: u32) -> Self {
self.canvas_padding_color = color;
self
}
#[must_use]
pub fn enable_debug_api(mut self) -> Self {
self.enable_debug_api = true;
self
}
#[must_use]
pub fn auto_resize_canvas_css(mut self, enabled: bool) -> Self {
self.auto_resize_canvas_css = enabled;
self
}
#[must_use]
pub fn mouse_input_handler<F>(mut self, callback: F) -> Self
where
F: FnMut(TerminalMouseEvent, &TerminalGrid) + 'static,
{
self.input_handler = Some(InputHandler::Mouse(Box::new(callback)));
self
}
#[must_use]
pub fn mouse_selection_handler(mut self, configuration: MouseSelectOptions) -> Self {
self.input_handler = Some(InputHandler::CopyOnSelect(configuration));
self
}
#[deprecated(
since = "0.13.0",
note = "Use `mouse_selection_handler` with `MouseSelectOptions` instead"
)]
pub fn default_mouse_input_handler(
self,
selection_mode: SelectionMode,
trim_trailing_whitespace: bool,
) -> Self {
let options = MouseSelectOptions::new()
.selection_mode(selection_mode)
.trim_trailing_whitespace(trim_trailing_whitespace);
self.mouse_selection_handler(options)
}
pub fn build(self) -> Result<Terminal, Error> {
let mut renderer = Self::create_renderer(self.canvas, self.auto_resize_canvas_css)?
.canvas_padding_color(self.canvas_padding_color);
let raw_pixel_ratio = device_pixel_ratio();
renderer.set_pixel_ratio(raw_pixel_ratio);
let (w, h) = renderer.logical_size();
renderer.resize(w, h);
let gl = renderer.gl();
let atlas: FontAtlas = match self.atlas_kind {
AtlasKind::Static(atlas_data) => {
StaticFontAtlas::load(gl, atlas_data.unwrap_or_default())?.into()
},
AtlasKind::Dynamic { font_family, font_size } => {
let rasterizer =
create_canvas_rasterizer(&font_family, font_size, raw_pixel_ratio)?;
DynamicFontAtlas::new(gl, rasterizer, font_size, raw_pixel_ratio)?.into()
},
AtlasKind::DebugDynamic { font_family, font_size, debug_space_pattern } => {
let rasterizer =
create_canvas_rasterizer(&font_family, font_size, raw_pixel_ratio)?;
DynamicFontAtlas::with_debug_spaces(
gl,
rasterizer,
font_size,
raw_pixel_ratio,
Some(debug_space_pattern),
)?
.into()
},
};
let canvas_size = renderer.physical_size();
let mut grid =
TerminalGrid::new(gl, atlas, canvas_size, raw_pixel_ratio, &GlslVersion::Es300)?;
if let Some(fallback) = self.fallback_glyph {
grid.set_fallback_glyph(&fallback)
};
let grid = Rc::new(RefCell::new(grid));
let context_loss_handler = ContextLossHandler::new(renderer.canvas()).ok();
let selection = grid.borrow().selection_tracker();
match self.input_handler {
None => Ok(Terminal {
renderer,
grid,
mouse_handler: None,
context_loss_handler,
current_pixel_ratio: raw_pixel_ratio,
}),
Some(InputHandler::CopyOnSelect(select)) => {
let handler = DefaultSelectionHandler::new(grid.clone(), select);
let mut mouse_input = TerminalMouseHandler::new(
renderer.canvas(),
grid.clone(),
handler.create_event_handler(selection),
)?;
mouse_input.default_input_handler = Some(handler);
Ok(Terminal {
renderer,
grid,
mouse_handler: Some(mouse_input),
context_loss_handler,
current_pixel_ratio: raw_pixel_ratio,
})
},
Some(InputHandler::Mouse(callback)) => {
let mouse_input =
TerminalMouseHandler::new(renderer.canvas(), grid.clone(), callback)?;
Ok(Terminal {
renderer,
grid,
mouse_handler: Some(mouse_input),
context_loss_handler,
current_pixel_ratio: raw_pixel_ratio,
})
},
}
.inspect(|terminal| {
if self.enable_debug_api {
terminal.expose_to_console();
}
})
}
fn create_renderer(canvas: CanvasSource, auto_resize_css: bool) -> Result<Renderer, Error> {
let renderer = match canvas {
CanvasSource::Id(id) => Renderer::create(&id, auto_resize_css)?,
CanvasSource::Element(element) => {
Renderer::create_with_canvas(element, auto_resize_css)?
},
};
Ok(renderer)
}
}
enum InputHandler {
Mouse(MouseEventCallback),
CopyOnSelect(MouseSelectOptions),
}
#[wasm_bindgen]
pub struct TerminalDebugApi {
grid: Rc<RefCell<TerminalGrid>>,
}
#[wasm_bindgen]
impl TerminalDebugApi {
#[wasm_bindgen(js_name = "getMissingGlyphs")]
pub fn get_missing_glyphs(&self) -> js_sys::Array {
let missing_set = self
.grid
.borrow()
.atlas()
.glyph_tracker()
.missing_glyphs();
let mut missing: Vec<_> = missing_set.into_iter().collect();
missing.sort();
let js_array = js_sys::Array::new();
for glyph in missing {
js_array.push(&JsValue::from_str(&glyph));
}
js_array
}
#[wasm_bindgen(js_name = "getTerminalSize")]
pub fn get_terminal_size(&self) -> JsValue {
let ts = self.grid.borrow().terminal_size();
let obj = js_sys::Object::new();
js_sys::Reflect::set(&obj, &"cols".into(), &JsValue::from(ts.cols)).unwrap();
js_sys::Reflect::set(&obj, &"rows".into(), &JsValue::from(ts.rows)).unwrap();
obj.into()
}
#[wasm_bindgen(js_name = "getCanvasSize")]
pub fn get_canvas_size(&self) -> JsValue {
let (width, height) = self.grid.borrow().canvas_size();
let obj = js_sys::Object::new();
js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(width)).unwrap();
js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(height)).unwrap();
obj.into()
}
#[wasm_bindgen(js_name = "getGlyphCount")]
pub fn get_glyph_count(&self) -> u32 {
self.grid.borrow().atlas().glyph_count()
}
#[wasm_bindgen(js_name = "getBaseGlyphId")]
pub fn get_base_glyph_id(&self, symbol: &str) -> Option<u16> {
self.grid.borrow_mut().base_glyph_id(symbol)
}
#[wasm_bindgen(js_name = "getSymbol")]
pub fn get_symbol(&self, glyph_id: u16) -> Option<String> {
self.grid
.borrow()
.atlas()
.get_symbol(glyph_id)
.map(|s| s.to_string())
}
#[wasm_bindgen(js_name = "getCellSize")]
pub fn get_cell_size(&self) -> JsValue {
let cs = self.grid.borrow().atlas().cell_size();
let obj = js_sys::Object::new();
js_sys::Reflect::set(&obj, &"width".into(), &JsValue::from(cs.width)).unwrap();
js_sys::Reflect::set(&obj, &"height".into(), &JsValue::from(cs.height)).unwrap();
obj.into()
}
#[wasm_bindgen(js_name = "getAtlasLookup")]
pub fn get_symbol_lookup(&self) -> js_sys::Array {
let grid = self.grid.borrow();
let atlas = grid.atlas();
let mut glyphs: Vec<(u16, CompactString)> = Vec::new();
atlas.for_each_symbol(&mut |glyph_id, symbol| {
glyphs.push((glyph_id, symbol.to_compact_string()));
});
glyphs.sort();
let js_array = js_sys::Array::new();
for (glyph_id, symbol) in glyphs.iter() {
let obj = js_sys::Object::new();
js_sys::Reflect::set(&obj, &"glyph_id".into(), &JsValue::from(*glyph_id)).unwrap();
js_sys::Reflect::set(&obj, &"symbol".into(), &JsValue::from(symbol.as_str())).unwrap();
js_array.push(&obj.into());
}
js_array
}
}
impl<'a> From<&'a str> for CanvasSource {
fn from(id: &'a str) -> Self {
CanvasSource::Id(id.into())
}
}
impl From<web_sys::HtmlCanvasElement> for CanvasSource {
fn from(element: web_sys::HtmlCanvasElement) -> Self {
CanvasSource::Element(element)
}
}
impl<'a> From<&'a web_sys::HtmlCanvasElement> for CanvasSource {
fn from(value: &'a web_sys::HtmlCanvasElement) -> Self {
value.clone().into()
}
}
fn create_canvas_rasterizer(
font_family: &[CompactString],
font_size: f32,
pixel_ratio: f32,
) -> Result<CanvasGlyphRasterizer, Error> {
let font_family_css = font_family
.iter()
.map(|s| format_compact!("'{s}'"))
.join_compact(", ");
let effective_font_size = font_size * pixel_ratio;
CanvasGlyphRasterizer::new(&font_family_css, effective_font_size)
}