use crate::{
backend::{
color::to_rgb,
event_callback::{EventCallback, KEY_EVENT_TYPES},
utils::*,
},
error::Error,
event::{KeyEvent, MouseEvent},
render::WebEventHandler,
CellSized, CursorShape,
};
pub use beamterm_renderer::SelectionMode;
use beamterm_renderer::{
mouse::*, CellData, CursorPosition, GlyphEffect, Terminal as Beamterm, Terminal,
};
use compact_str::CompactString;
use ratatui::{
backend::{ClearType, WindowSize},
buffer::Cell,
layout::{Position, Size},
prelude::Backend,
style::{Color, Modifier},
};
use std::{
cell::RefCell,
io::{Error as IoError, Result as IoResult},
mem::swap,
rc::Rc,
};
use web_sys::{wasm_bindgen::JsCast, Element};
pub use beamterm_renderer::FontAtlasData;
#[derive(Debug)]
pub enum FontAtlasConfig {
Static(FontAtlasData),
Dynamic(Vec<String>, f32),
}
impl FontAtlasConfig {
pub fn dynamic(font_family: &[&str], font_size: f32) -> Self {
Self::Dynamic(
font_family.iter().map(|s| s.to_string()).collect(),
font_size,
)
}
}
#[derive(Clone, Copy, Default)]
struct PendingHyperlinkEvent {
hover: Option<(u16, u16)>,
click: Option<(u16, u16)>,
}
const SYNC_TERMINAL_BUFFER_MARK: &str = "sync-terminal-buffer";
const WEBGL_RENDER_MARK: &str = "webgl-render";
#[derive(Default, Debug)]
pub struct WebGl2BackendOptions {
grid_id: Option<String>,
size: Option<(u32, u32)>,
fallback_glyph: Option<CompactString>,
font_atlas_config: Option<FontAtlasConfig>,
canvas_padding_color: Option<Color>,
cursor_shape: CursorShape,
hyperlink_callback: Option<HyperlinkCallback>,
mouse_selection_mode: Option<SelectionMode>,
measure_performance: bool,
console_debug_api: bool,
disable_auto_css_resize: bool,
}
impl WebGl2BackendOptions {
pub fn new() -> Self {
Default::default()
}
pub fn grid_id(mut self, id: &str) -> Self {
self.grid_id = Some(id.into());
self
}
pub fn size(mut self, size: (u32, u32)) -> Self {
self.size = Some(size);
self
}
pub fn measure_performance(mut self, measure: bool) -> Self {
self.measure_performance = measure;
self
}
pub fn fallback_glyph(mut self, glyph: &str) -> Self {
self.fallback_glyph = Some(glyph.into());
self
}
pub fn canvas_padding_color(mut self, color: Color) -> Self {
self.canvas_padding_color = Some(color);
self
}
pub fn cursor_shape(mut self, shape: CursorShape) -> Self {
self.cursor_shape = shape;
self
}
#[deprecated(
note = "use `font_atlas_config(FontAtlasConfig::Static(atlas))` instead",
since = "0.3.0"
)]
pub fn font_atlas(self, atlas: FontAtlasData) -> Self {
self.font_atlas_config(FontAtlasConfig::Static(atlas))
}
pub fn font_atlas_config(mut self, config: FontAtlasConfig) -> Self {
self.font_atlas_config = Some(config);
self
}
#[deprecated(
note = "use `enable_mouse_selection_with_mode` instead",
since = "0.3.0"
)]
pub fn enable_mouse_selection(self) -> Self {
self.enable_mouse_selection_with_mode(SelectionMode::default())
}
pub fn enable_mouse_selection_with_mode(mut self, mode: SelectionMode) -> Self {
self.mouse_selection_mode = Some(mode);
self
}
pub fn enable_hyperlinks(self) -> Self {
self.on_hyperlink_click(|url| {
if let Ok(w) = get_window() {
w.open_with_url_and_target(url, "_blank")
.unwrap_or_default();
}
})
}
pub fn on_hyperlink_click<F>(mut self, callback: F) -> Self
where
F: FnMut(&str) + 'static,
{
self.hyperlink_callback = Some(HyperlinkCallback::new(callback));
self
}
fn get_canvas_padding_color(&self) -> u32 {
self.canvas_padding_color
.map(|c| to_rgb(c, 0x000000))
.unwrap_or(0x000000)
}
pub fn enable_console_debug_api(mut self) -> Self {
self.console_debug_api = true;
self
}
pub fn disable_auto_css_resize(mut self) -> Self {
self.disable_auto_css_resize = true;
self
}
}
pub struct WebGl2Backend {
beamterm: Beamterm,
options: WebGl2BackendOptions,
cursor_position: Option<Position>,
performance: Option<web_sys::Performance>,
_hyperlink_mouse_handler: Option<TerminalMouseHandler>,
cursor_over_hyperlink: bool,
hyperlink_callback: Option<HyperlinkCallback>,
hyperlink_state: Option<Rc<std::cell::Cell<PendingHyperlinkEvent>>>,
_user_mouse_handler: Option<TerminalMouseHandler>,
_user_key_handler: Option<EventCallback<web_sys::KeyboardEvent>>,
}
impl WebGl2Backend {
pub fn new() -> Result<Self, Error> {
let (width, height) = get_raw_window_size();
Self::new_with_size(width.into(), height.into())
}
pub fn new_with_size(width: u32, height: u32) -> Result<Self, Error> {
Self::new_with_options(WebGl2BackendOptions {
size: Some((width, height)),
..Default::default()
})
}
pub fn new_with_options(mut options: WebGl2BackendOptions) -> Result<Self, Error> {
let performance = if options.measure_performance {
Some(performance()?)
} else {
None
};
let parent = get_element_by_id_or_body(options.grid_id.as_ref())?;
let beamterm = Self::init_beamterm(&mut options, &parent)?;
let hyperlink_callback = options.hyperlink_callback.take();
let (hyperlink_mouse_handler, hyperlink_state) = if hyperlink_callback.is_some() {
let state = Rc::new(std::cell::Cell::new(PendingHyperlinkEvent::default()));
let handler = Self::create_hyperlink_mouse_handler(&beamterm, state.clone())?;
(Some(handler), Some(state))
} else {
(None, None)
};
Ok(Self {
beamterm,
cursor_position: None,
options,
_hyperlink_mouse_handler: hyperlink_mouse_handler,
performance,
cursor_over_hyperlink: false,
hyperlink_callback,
hyperlink_state,
_user_mouse_handler: None,
_user_key_handler: None,
})
}
pub fn options(&self) -> &WebGl2BackendOptions {
&self.options
}
pub fn cursor_shape(&self) -> &CursorShape {
&self.options.cursor_shape
}
pub fn set_cursor_shape(mut self, shape: CursorShape) -> Self {
self.options.cursor_shape = shape;
self
}
pub fn resize_canvas(&mut self) -> Result<(), Error> {
let width = self.beamterm.canvas().client_width();
let height = self.beamterm.canvas().client_height();
self.beamterm.resize(width, height)?;
self.cursor_over_hyperlink = false;
Ok(())
}
#[deprecated(
since = "0.4.0",
note = "Use cell_size_px instead, which returns physical pixel dimensions"
)]
pub fn cell_size(&self) -> (i32, i32) {
let (w, h) = self.cell_size_px();
(w as i32, h as i32)
}
pub fn set_size(&mut self, width: u32, height: u32) -> Result<(), Error> {
self.beamterm.resize(width as i32, height as i32)?;
self.cursor_over_hyperlink = false;
Ok(())
}
fn check_canvas_resize(&mut self) -> Result<(), Error> {
let display_width = self.beamterm.canvas().client_width();
let display_height = self.beamterm.canvas().client_height();
let (stored_width, stored_height) = self.beamterm.canvas_size();
if display_width != stored_width || display_height != stored_height {
self.resize_canvas()?;
}
Ok(())
}
fn update_grid<'a, I>(&mut self, content: I) -> Result<(), Error>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
self.measure_begin(SYNC_TERMINAL_BUFFER_MARK);
let cells = content.map(|(x, y, cell)| (x, y, cell_data(cell)));
self.beamterm
.update_cells_by_position(cells)
.map_err(Error::from)?;
self.measure_end(SYNC_TERMINAL_BUFFER_MARK);
Ok(())
}
fn toggle_cursor(&mut self) {
if let Some(pos) = self.cursor_position {
self.draw_cursor(pos);
}
}
fn draw_cursor(&mut self, pos: Position) {
if let Some(c) = self
.beamterm
.grid()
.borrow_mut()
.cell_data_mut(pos.x, pos.y)
{
match self.options.cursor_shape {
CursorShape::SteadyBlock => {
c.flip_colors();
}
CursorShape::SteadyUnderScore => {
c.style(c.get_style() ^ (GlyphEffect::Underline as u16));
}
CursorShape::None => (),
}
}
}
fn measure_begin(&self, label: &str) {
if let Some(performance) = &self.performance {
performance.mark(label).unwrap_or_default();
}
}
fn measure_end(&self, label: &str) {
if let Some(performance) = &self.performance {
performance
.measure_with_start_mark(label, label)
.unwrap_or_default();
}
}
fn update_canvas_cursor_style(canvas: &web_sys::HtmlCanvasElement, is_pointer: bool) {
let cursor_value = if is_pointer { "pointer" } else { "default" };
if let Ok(element) = canvas.clone().dyn_into::<Element>() {
let current_style = element.get_attribute("style").unwrap_or_default();
let new_style = if let Some(start) = current_style.find("cursor:") {
let after_cursor = ¤t_style[start..];
let end_pos = after_cursor
.find(';')
.map(|p| p + 1)
.unwrap_or(after_cursor.len());
let full_end = start + end_pos;
format!(
"{}cursor: {}{}",
¤t_style[..start],
cursor_value,
¤t_style[full_end..]
)
} else if current_style.is_empty() {
format!("cursor: {}", cursor_value)
} else {
format!(
"{}; cursor: {}",
current_style.trim_end_matches(';'),
cursor_value
)
};
let _ = element.set_attribute("style", &new_style);
}
}
fn create_hyperlink_mouse_handler(
beamterm: &Beamterm,
hyperlink_state: Rc<std::cell::Cell<PendingHyperlinkEvent>>,
) -> Result<TerminalMouseHandler, Error> {
let grid = beamterm.grid();
let canvas = beamterm.canvas();
let mouse_handler = TerminalMouseHandler::new(
canvas,
grid,
move |event: TerminalMouseEvent, _grid: &beamterm_renderer::TerminalGrid| {
let mut state = hyperlink_state.get();
match event.event_type {
MouseEventType::MouseUp if event.button() == 0 => {
state.click = Some((event.col, event.row));
}
MouseEventType::MouseMove => {
state.hover = Some((event.col, event.row));
}
_ => return,
}
hyperlink_state.set(state);
},
)?;
Ok(mouse_handler)
}
fn process_hyperlink_events(&mut self) {
let state = match self.hyperlink_state.clone() {
Some(state) => state,
None => return,
};
let mut pending = state.get();
if let Some((col, row)) = pending.click {
pending.click = None;
if let Some(url_match) = self.beamterm.find_url_at(CursorPosition::new(col, row)) {
if let Some(ref callback) = self.hyperlink_callback {
if let Ok(mut cb) = callback.callback.try_borrow_mut() {
cb(&url_match.url);
}
}
}
}
if let Some((col, row)) = pending.hover {
let is_over = self
.beamterm
.find_url_at(CursorPosition::new(col, row))
.is_some();
if self.cursor_over_hyperlink != is_over {
self.cursor_over_hyperlink = is_over;
Self::update_canvas_cursor_style(&self.beamterm.canvas(), is_over);
}
}
state.set(pending);
}
fn init_beamterm(
options: &mut WebGl2BackendOptions,
parent: &Element,
) -> Result<Terminal, Error> {
let (width, height) = options
.size
.unwrap_or_else(|| (parent.client_width() as u32, parent.client_height() as u32));
let canvas = create_canvas_in_element(parent, width, height)?;
let mut beamterm = Beamterm::builder(canvas)
.canvas_padding_color(options.get_canvas_padding_color())
.fallback_glyph(options.fallback_glyph.as_ref().unwrap_or(&" ".into()));
beamterm = match options.font_atlas_config.take() {
Some(FontAtlasConfig::Dynamic(font_family, font_size)) => {
let font_family_refs: Vec<&str> = font_family.iter().map(|s| s.as_str()).collect();
beamterm.dynamic_font_atlas(&font_family_refs, font_size)
}
Some(FontAtlasConfig::Static(atlas)) => beamterm.font_atlas(atlas),
None => beamterm.font_atlas(FontAtlasData::default()),
};
let beamterm = if let Some(mode) = options.mouse_selection_mode {
beamterm.mouse_selection_handler(
MouseSelectOptions::new()
.selection_mode(mode)
.trim_trailing_whitespace(true),
)
} else {
beamterm
};
let beamterm = if options.console_debug_api {
beamterm.enable_debug_api()
} else {
beamterm
};
let beamterm = beamterm.auto_resize_canvas_css(!options.disable_auto_css_resize);
Ok(beamterm.build()?)
}
}
impl CellSized for WebGl2Backend {
fn cell_size_px(&self) -> (f32, f32) {
let cs = self.beamterm.cell_size();
(cs.width as f32, cs.height as f32)
}
fn cell_size_css_px(&self) -> (f32, f32) {
self.beamterm.grid().borrow().css_cell_size()
}
}
impl Backend for WebGl2Backend {
type Error = IoError;
fn draw<'a, I>(&mut self, content: I) -> IoResult<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
if content.size_hint().1 != Some(0) || self.options.mouse_selection_mode.is_some() {
self.update_grid(content)?;
}
Ok(())
}
fn flush(&mut self) -> IoResult<()> {
self.process_hyperlink_events();
self.check_canvas_resize()?;
self.measure_begin(WEBGL_RENDER_MARK);
self.toggle_cursor(); self.beamterm.render_frame().map_err(Error::from)?;
self.toggle_cursor();
self.measure_end(WEBGL_RENDER_MARK);
Ok(())
}
fn hide_cursor(&mut self) -> IoResult<()> {
self.cursor_position = None;
Ok(())
}
fn show_cursor(&mut self) -> IoResult<()> {
Ok(())
}
fn clear(&mut self) -> IoResult<()> {
let cells = [CellData::new_with_style_bits(" ", 0, 0xffffff, 0x000000)]
.into_iter()
.cycle()
.take(self.beamterm.cell_count());
self.beamterm.update_cells(cells).map_err(Error::from)?;
Ok(())
}
fn size(&self) -> IoResult<Size> {
let ts = self.beamterm.terminal_size();
Ok(Size::new(ts.cols, ts.rows))
}
fn window_size(&mut self) -> IoResult<WindowSize> {
let ts = self.beamterm.terminal_size();
let (w, h) = self.beamterm.canvas_size();
Ok(WindowSize {
columns_rows: Size::new(ts.cols, ts.rows),
pixels: Size::new(w as _, h as _),
})
}
fn get_cursor_position(&mut self) -> IoResult<Position> {
match self.cursor_position {
None => Ok((0, 0).into()),
Some(position) => Ok(position),
}
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> IoResult<()> {
self.cursor_position = Some(position.into());
Ok(())
}
fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
match clear_type {
ClearType::All => self.clear(),
_ => Err(IoError::other("unimplemented")),
}
}
}
fn resolve_fg_bg_colors(cell: &Cell) -> (u32, u32) {
let mut fg = to_rgb(cell.fg, 0xffffff);
let mut bg = to_rgb(cell.bg, 0x000000);
if cell.modifier.contains(Modifier::REVERSED) {
swap(&mut fg, &mut bg);
}
(fg, bg)
}
fn cell_data(cell: &Cell) -> CellData<'_> {
let (fg, bg) = resolve_fg_bg_colors(cell);
CellData::new_with_style_bits(cell.symbol(), into_glyph_bits(cell.modifier), fg, bg)
}
const fn into_glyph_bits(modifier: Modifier) -> u16 {
let m = modifier.bits();
(m << 10) & (1 << 10) | (m << 9) & (1 << 11) | (m << 10) & (1 << 13) | (m << 6) & (1 << 14) }
#[derive(Clone)]
struct HyperlinkCallback {
callback: Rc<RefCell<dyn FnMut(&str)>>,
}
impl HyperlinkCallback {
pub fn new<F>(callback: F) -> Self
where
F: FnMut(&str) + 'static,
{
Self {
callback: Rc::new(RefCell::new(callback)),
}
}
}
impl std::fmt::Debug for HyperlinkCallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CallbackWrapper")
.field("callback", &"<callback>")
.finish()
}
}
impl WebEventHandler for WebGl2Backend {
fn on_mouse_event<F>(&mut self, callback: F) -> Result<(), Error>
where
F: FnMut(MouseEvent) + 'static,
{
self.clear_mouse_events();
let grid = self.beamterm.grid();
let canvas = self.beamterm.canvas();
let callback = Rc::new(RefCell::new(callback));
let callback_clone = callback.clone();
let mouse_handler = TerminalMouseHandler::new(
canvas,
grid,
move |event: TerminalMouseEvent, _grid: &beamterm_renderer::TerminalGrid| {
let mouse_event = MouseEvent::from(&event);
if let Ok(mut cb) = callback_clone.try_borrow_mut() {
cb(mouse_event);
}
},
)?;
self._user_mouse_handler = Some(mouse_handler);
Ok(())
}
fn clear_mouse_events(&mut self) {
self._user_mouse_handler = None;
}
fn on_key_event<F>(&mut self, mut callback: F) -> Result<(), Error>
where
F: FnMut(KeyEvent) + 'static,
{
self.clear_key_events();
let canvas = self.beamterm.canvas();
let element: web_sys::Element = canvas.clone().into();
canvas.set_attribute("tabindex", "0").map_err(Error::from)?;
self._user_key_handler = Some(EventCallback::new(
element,
KEY_EVENT_TYPES,
move |event: web_sys::KeyboardEvent| {
callback(event.into());
},
)?);
Ok(())
}
fn clear_key_events(&mut self) {
self._user_key_handler = None;
}
}
impl From<&TerminalMouseEvent> for MouseEvent {
fn from(event: &TerminalMouseEvent) -> Self {
use crate::event::{MouseButton, MouseEventKind};
let button = MouseButton::from(event.button());
let kind = match event.event_type {
MouseEventType::MouseMove => MouseEventKind::Moved,
MouseEventType::MouseDown => MouseEventKind::ButtonDown(button),
MouseEventType::MouseUp => MouseEventKind::ButtonUp(button),
MouseEventType::Click => MouseEventKind::SingleClick(button),
MouseEventType::MouseEnter => MouseEventKind::Entered,
MouseEventType::MouseLeave => MouseEventKind::Exited,
};
MouseEvent {
kind,
col: event.col,
row: event.row,
ctrl: event.ctrl_key(),
alt: event.alt_key(),
shift: event.shift_key(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use beamterm_renderer::{FontStyle, GlyphEffect};
use ratatui::style::Modifier;
#[test]
fn test_font_style() {
[
(FontStyle::Bold, Modifier::BOLD),
(FontStyle::Italic, Modifier::ITALIC),
(FontStyle::BoldItalic, Modifier::BOLD | Modifier::ITALIC),
]
.into_iter()
.map(|(style, modifier)| (style as u16, into_glyph_bits(modifier)))
.for_each(|(expected, actual)| assert_eq!(expected, actual));
}
#[test]
fn test_glyph_effect() {
[
(GlyphEffect::Underline, Modifier::UNDERLINED),
(GlyphEffect::Strikethrough, Modifier::CROSSED_OUT),
]
.into_iter()
.map(|(effect, modifier)| (effect as u16, into_glyph_bits(modifier)))
.for_each(|(expected, actual)| assert_eq!(expected, actual));
}
}