use crate::{
backend::{color::to_rgb, utils::*},
error::Error,
widgets::hyperlink::HYPERLINK_MODIFIER,
CursorShape,
};
pub use beamterm_renderer::SelectionMode;
use beamterm_renderer::{mouse::*, select, CellData, GlyphEffect, Terminal as Beamterm, Terminal};
use bitvec::prelude::BitVec;
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, window, Element};
pub use beamterm_renderer::FontAtlasData;
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: Option<FontAtlasData>,
canvas_padding_color: Option<Color>,
cursor_shape: CursorShape,
hyperlink_callback: Option<HyperlinkCallback>,
mouse_selection_mode: Option<SelectionMode>,
measure_performance: bool,
console_debug_api: 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
}
pub fn font_atlas(mut self, atlas: FontAtlasData) -> Self {
self.font_atlas = Some(atlas);
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 Some(w) = 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 struct WebGl2Backend {
beamterm: Beamterm,
options: WebGl2BackendOptions,
cursor_position: Option<Position>,
performance: Option<web_sys::Performance>,
hyperlink_cells: Option<Rc<RefCell<BitVec>>>,
hyperlink_mouse_handler: Option<TerminalMouseHandler>,
cursor_over_hyperlink: Option<Rc<RefCell<bool>>>,
_hyperlink_callback: Option<HyperlinkCallback>,
}
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_cells = if options.hyperlink_callback.is_some() {
let indices = BitVec::repeat(false, beamterm.cell_count());
Some(Rc::new(RefCell::new(indices)))
} else {
None
};
let hyperlink_callback = options.hyperlink_callback.take();
let cursor_over_hyperlink = if hyperlink_callback.is_some() {
Some(Rc::new(RefCell::new(false)))
} else {
None
};
let hyperlink_mouse_handler = if let Some(ref callback) = hyperlink_callback {
let hyperlink_cells = hyperlink_cells
.clone()
.expect("known to exist at this point");
let cursor_state = cursor_over_hyperlink
.clone()
.expect("known to exist at this point");
Some(Self::create_hyperlink_mouse_handler(
&beamterm,
hyperlink_cells.clone(),
callback.callback.clone(),
cursor_state,
)?)
} else {
None
};
Ok(Self {
beamterm,
cursor_position: None,
options,
hyperlink_cells,
hyperlink_mouse_handler,
performance,
cursor_over_hyperlink,
_hyperlink_callback: hyperlink_callback,
})
}
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 size_px = self.beamterm.canvas_size();
self.beamterm.resize(size_px.0, size_px.1)?;
if let Some(mouse_handler) = &mut self.hyperlink_mouse_handler {
let (cols, rows) = self.beamterm.terminal_size();
mouse_handler.update_dimensions(cols, rows);
}
if let Some(hyperlink_cells) = &mut self.hyperlink_cells {
let cell_count = self.beamterm.cell_count();
let mut hyperlink_cells = hyperlink_cells.borrow_mut();
hyperlink_cells.clear();
hyperlink_cells.resize(cell_count, false);
}
if let Some(cursor_state) = &self.cursor_over_hyperlink {
if let Ok(mut state) = cursor_state.try_borrow_mut() {
*state = false;
}
}
Ok(())
}
fn check_canvas_resize(&mut self) -> Result<(), Error> {
let canvas = self.beamterm.canvas();
let display_width = canvas.client_width() as u32;
let display_height = canvas.client_height() as u32;
let buffer_width = canvas.width();
let buffer_height = canvas.height();
if display_width != buffer_width || display_height != buffer_height {
canvas.set_width(display_width);
canvas.set_height(display_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);
if let Some(hyperlink_cells) = self.hyperlink_cells.as_mut() {
let w = self.beamterm.terminal_size().0 as usize;
let mut hyperlink_cells = hyperlink_cells.borrow_mut();
let cells = content.inspect(|(x, y, c)| {
let idx = *y as usize * w + *x as usize;
let is_hyperlink = c.modifier.contains(HYPERLINK_MODIFIER);
hyperlink_cells.set(idx, is_hyperlink);
});
let cells = cells.map(|(x, y, cell)| (x, y, cell_data(cell)));
self.beamterm.update_cells_by_position(cells)
} else {
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_cells: Rc<RefCell<BitVec>>,
callback: Rc<RefCell<dyn FnMut(&str)>>,
cursor_state: Rc<RefCell<bool>>,
) -> Result<TerminalMouseHandler, Error> {
let grid = beamterm.grid();
let canvas = beamterm.canvas();
let hyperlink_cells_clone = hyperlink_cells.clone();
let hyperlink_cells_move = hyperlink_cells.clone();
let canvas_clone = canvas.clone();
let cursor_state_clone = cursor_state.clone();
let mouse_handler = TerminalMouseHandler::new(
canvas,
grid,
move |event: TerminalMouseEvent, grid: &beamterm_renderer::TerminalGrid| {
match event.event_type {
MouseEventType::MouseUp => {
if event.button() == 0 {
if let Some(url) = extract_hyperlink_url(
hyperlink_cells_clone.clone(),
grid,
event.col,
event.row,
) {
if let Ok(mut cb) = callback.try_borrow_mut() {
cb(&url);
}
}
}
}
MouseEventType::MouseMove => {
let is_over_hyperlink = Self::is_over_hyperlink(
hyperlink_cells_move.clone(),
grid,
event.col,
event.row,
);
if let Ok(mut current_state) = cursor_state_clone.try_borrow_mut() {
if *current_state != is_over_hyperlink {
*current_state = is_over_hyperlink;
Self::update_canvas_cursor_style(&canvas_clone, is_over_hyperlink);
}
}
}
_ => {}
}
},
)?;
Ok(mouse_handler)
}
fn is_over_hyperlink(
hyperlink_cells: Rc<RefCell<BitVec>>,
grid: &beamterm_renderer::TerminalGrid,
col: u16,
row: u16,
) -> bool {
let (cols, _) = grid.terminal_size();
let row_start_idx = row as usize * cols as usize;
let cell_idx = row_start_idx + col as usize;
hyperlink_cells
.borrow()
.get(cell_idx)
.map(|b| *b)
.unwrap_or(false)
}
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 beamterm = Beamterm::builder(canvas)
.canvas_padding_color(options.get_canvas_padding_color())
.fallback_glyph(options.fallback_glyph.as_ref().unwrap_or(&" ".into()))
.font_atlas(options.font_atlas.take().unwrap_or_default());
let beamterm = if let Some(mode) = options.mouse_selection_mode {
beamterm.default_mouse_input_handler(mode, true)
} else {
beamterm
};
let beamterm = if options.console_debug_api {
beamterm.enable_debug_api()
} else {
beamterm
};
Ok(beamterm.build()?)
}
}
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.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)?;
if let Some(hyperlink_cells) = &mut self.hyperlink_cells {
hyperlink_cells.borrow_mut().clear();
}
Ok(())
}
fn size(&self) -> IoResult<Size> {
let (w, h) = self.beamterm.terminal_size();
Ok(Size::new(w, h))
}
fn window_size(&mut self) -> IoResult<WindowSize> {
let (cols, rows) = self.beamterm.terminal_size();
let (w, h) = self.beamterm.canvas_size();
Ok(WindowSize {
columns_rows: Size::new(cols, 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 extract_text_from_grid(
grid: &beamterm_renderer::TerminalGrid,
start_col: u16,
end_col: u16,
row: u16,
) -> Option<String> {
let query = select(SelectionMode::Block)
.start((start_col, row))
.end((end_col, row))
.trim_trailing_whitespace(true);
let text = grid.get_text(query);
if text.is_empty() {
None
} else {
Some(text.to_string())
}
}
fn extract_hyperlink_url(
hyperlink_cells: Rc<RefCell<BitVec>>,
grid: &beamterm_renderer::TerminalGrid,
start_col: u16,
row: u16,
) -> Option<String> {
let hyperlink_cells = hyperlink_cells;
let (cols, _) = grid.terminal_size();
let (link_start, link_end) =
find_hyperlink_bounds(&hyperlink_cells.borrow(), start_col, row, cols)?;
extract_text_from_grid(grid, link_start, link_end, row)
}
fn find_hyperlink_bounds(
hyperlink_cells: &BitVec,
start_col: u16,
row: u16,
cols: u16,
) -> Option<(u16, u16)> {
let row_start_idx = row as usize * cols as usize;
if !hyperlink_cells
.get(row_start_idx + start_col as usize)
.map(|b| *b)
.unwrap_or(false)
{
return None;
}
let mut link_start = start_col;
while link_start > 0 {
let idx = row_start_idx + (link_start - 1) as usize;
if !hyperlink_cells.get(idx).map(|b| *b).unwrap_or(false) {
break;
}
link_start -= 1;
}
let mut link_end = start_col;
while link_end < cols - 1 {
let idx = row_start_idx + (link_end + 1) as usize;
if !hyperlink_cells.get(idx).map(|b| *b).unwrap_or(false) {
break;
}
link_end += 1;
}
Some((link_start, link_end))
}
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()
}
}
#[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));
}
}