diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 7e6808fe..de03ddbe 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -8,6 +8,7 @@ members = [ "colors_rgb", "demo", "demo2", + "hyperlinks", "minimal", "pong", "shared", diff --git a/examples/hyperlinks/Cargo.toml b/examples/hyperlinks/Cargo.toml new file mode 100644 index 00000000..c8471d1a --- /dev/null +++ b/examples/hyperlinks/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "hyperlinks" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +ratzilla.workspace = true +console_error_panic_hook.workspace = true diff --git a/examples/hyperlinks/index.html b/examples/hyperlinks/index.html new file mode 100644 index 00000000..15ad59f2 --- /dev/null +++ b/examples/hyperlinks/index.html @@ -0,0 +1,35 @@ + + + + + + + Ratzilla Hyperlinks + + + + diff --git a/examples/hyperlinks/src/main.rs b/examples/hyperlinks/src/main.rs new file mode 100644 index 00000000..56a61292 --- /dev/null +++ b/examples/hyperlinks/src/main.rs @@ -0,0 +1,92 @@ +use std::io; + +use ratzilla::{ + ratatui::{ + layout::{Alignment, Constraint, Layout, Rect}, + prelude::{Color, Stylize, Terminal}, + widgets::{Block, BorderType, Clear, Paragraph}, + }, + widgets::Hyperlink, + DomBackend, WebRenderer, +}; + +fn main() -> io::Result<()> { + std::panic::set_hook(Box::new(console_error_panic_hook::hook)); + + let terminal = Terminal::new(DomBackend::new()?)?; + terminal.draw_web(|frame| { + frame.render_widget(Clear, frame.area()); + + let [card] = Layout::vertical([Constraint::Length(9)]) + .flex(ratzilla::ratatui::layout::Flex::Center) + .areas(frame.area()); + let [card] = Layout::horizontal([Constraint::Length(44)]) + .flex(ratzilla::ratatui::layout::Flex::Center) + .areas(card); + + frame.render_widget( + Block::bordered() + .border_type(BorderType::Rounded) + .title(" Aliasable Hyperlinks ".bold()) + .border_style(Color::LightGreen), + card, + ); + + let inner = card.inner(ratzilla::ratatui::layout::Margin { + vertical: 1, + horizontal: 2, + }); + let [intro, docs, repo, plain] = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .spacing(0) + .areas(inner); + + frame.render_widget( + Paragraph::new("DOM links use native browser anchors.") + .alignment(Alignment::Left), + intro, + ); + render_link( + frame, + docs, + "Docs: ", + Hyperlink::with_label( + "Ratatui rendering guide".black().on_cyan().bold(), + "https://ratatui.rs/concepts/rendering/under-the-hood/", + ), + ); + render_link( + frame, + repo, + "Repo: ", + Hyperlink::with_label( + "ratzilla on GitHub".black().on_yellow().italic(), + "https://github.com/ratatui/ratzilla", + ), + ); + render_link( + frame, + plain, + "Website: ", + Hyperlink::new("https://ratatui.rs".light_cyan().underlined()), + ); + }); + Ok(()) +} + +fn render_link( + frame: &mut ratzilla::ratatui::Frame<'_>, + area: Rect, + label: &str, + link: Hyperlink<'_>, +) { + let [prefix, suffix] = + Layout::horizontal([Constraint::Length(label.len() as u16), Constraint::Min(0)]) + .areas(area); + frame.render_widget(Paragraph::new(label), prefix); + frame.render_widget(link, suffix); +} diff --git a/src/backend/dom.rs b/src/backend/dom.rs index f43846d4..138f7f70 100644 --- a/src/backend/dom.rs +++ b/src/backend/dom.rs @@ -25,6 +25,7 @@ use crate::{ error::Error, event::{KeyEvent, MouseEvent}, render::WebEventHandler, + widgets::hyperlink_state, CursorShape, }; @@ -78,6 +79,10 @@ pub struct DomBackend { initialized: Rc>, /// Cells. cells: Vec, + /// Current cell contents. + buffer: Vec, + /// Current hyperlink targets for each cell. + hyperlinks: Vec>>, /// Grid element. grid: Element, /// The parent of the grid element. @@ -162,6 +167,8 @@ impl DomBackend { let mut backend = Self { initialized, cells: vec![], + buffer: vec![], + hyperlinks: vec![], grid: document.create_element("div")?, grid_parent, options, @@ -225,6 +232,23 @@ impl DomBackend { Size::new((w / cell_size.0) as u16, (h / cell_size.1) as u16) } + fn render_cell(&self, index: usize) -> Result<(), Error> { + let cell = &self.buffer[index]; + let elem = &self.cells[index]; + + elem.set_inner_html(""); + elem.set_attribute("style", &get_cell_style_as_css(cell))?; + + if let Some(url) = &self.hyperlinks[index] { + let anchor = create_anchor(&self.document, cell.symbol(), url)?; + elem.append_child(&anchor)?; + } else { + elem.set_inner_html(cell.symbol()); + } + + Ok(()) + } + /// Resize event types. const RESIZE_EVENT_TYPES: &[&str] = &["resize"]; @@ -233,6 +257,8 @@ impl DomBackend { self.grid = self.document.create_element("div")?; self.grid.set_attribute("id", &self.options.grid_id())?; self.cells.clear(); + self.buffer.clear(); + self.hyperlinks.clear(); Ok(()) } @@ -244,8 +270,11 @@ impl DomBackend { for _y in 0..self.size.height { let mut line_cells: Vec = Vec::new(); for _x in 0..self.size.width { - let span = create_span(&self.document, &Cell::default())?; + let cell = Cell::default(); + let span = create_span(&self.document, &cell)?; self.cells.push(span.clone()); + self.buffer.push(cell); + self.hyperlinks.push(None); line_cells.push(span); } @@ -316,26 +345,51 @@ impl Backend for DomBackend { self.populate()?; } + let mut dirty_cells = vec![false; self.cells.len()]; + for (x, y, cell) in content { let cell_position = (y * self.size.width + x) as usize; - let elem = &self.cells[cell_position]; - - elem.set_inner_html(cell.symbol()); - elem.set_attribute("style", &get_cell_style_as_css(cell)) - .map_err(Error::from)?; + self.buffer[cell_position] = cell.clone(); + dirty_cells[cell_position] = true; // don't display the next cell if a fullwidth glyph preceeds it if cell.symbol().len() > 1 && cell.symbol().width() == 2 { if (cell_position + 1) < self.cells.len() { - let next_elem = &self.cells[cell_position + 1]; - next_elem.set_inner_html(""); - next_elem - .set_attribute("style", &get_cell_style_as_css(&Cell::new(""))) - .map_err(Error::from)?; + self.buffer[cell_position + 1] = Cell::new(""); + dirty_cells[cell_position + 1] = true; } } } + let mut next_hyperlinks = vec![None; self.cells.len()]; + for region in hyperlink_state::take() { + let row_offset = region.y as usize * self.size.width as usize; + if row_offset >= next_hyperlinks.len() { + continue; + } + let start = row_offset + region.x as usize; + if start >= next_hyperlinks.len() { + continue; + } + let end = (start + region.width as usize).min(row_offset + self.size.width as usize); + for cell in &mut next_hyperlinks[start..end] { + *cell = Some(region.url.clone()); + } + } + + for (index, current) in next_hyperlinks.iter().enumerate() { + if self.hyperlinks[index].as_deref() != current.as_deref() { + dirty_cells[index] = true; + } + } + self.hyperlinks = next_hyperlinks; + + for (index, dirty) in dirty_cells.into_iter().enumerate() { + if dirty { + self.render_cell(index)?; + } + } + Ok(()) } @@ -396,6 +450,15 @@ impl Backend for DomBackend { } fn clear(&mut self) -> IoResult<()> { + for cell in &mut self.buffer { + *cell = Cell::default(); + } + for link in &mut self.hyperlinks { + *link = None; + } + for index in 0..self.cells.len() { + self.render_cell(index)?; + } Ok(()) } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index fe22a32a..15c66e8c 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -22,7 +22,7 @@ //! |------------------------------|------------|---------------|----------------| //! | **60fps on large terminals** | ✗ | ✗ | ✓ | //! | **Memory Usage** | Highest | Medium | Lowest | -//! | **Hyperlinks** | ✗ | ✗ | ✓ | +//! | **Hyperlinks** | ✓ | ✗ | ✓ | //! | **Text Selection** | Linear | ✗ | Linear/Block | //! | **Unicode/Emoji Support** | Full | Limited² | Full¹ | //! | **Dynamic Characters** | ✓ | ✓ | ✓¹ | diff --git a/src/backend/utils.rs b/src/backend/utils.rs index 3064b6f5..25606d75 100644 --- a/src/backend/utils.rs +++ b/src/backend/utils.rs @@ -30,15 +30,16 @@ pub(crate) fn create_span(document: &Document, cell: &Cell) -> Result` element with the given cells. -#[allow(dead_code)] -pub(crate) fn create_anchor(document: &Document, cells: &[Cell]) -> Result { +/// Creates a new `` element with the given symbol and URL. +pub(crate) fn create_anchor( + document: &Document, + symbol: &str, + url: &str, +) -> Result { let anchor = document.create_element("a")?; - anchor.set_attribute( - "href", - &cells.iter().map(|c| c.symbol()).collect::(), - )?; - anchor.set_attribute("style", &get_cell_style_as_css(&cells[0]))?; + anchor.set_attribute("href", url)?; + anchor.set_inner_html(symbol); + anchor.set_attribute("style", "color: inherit; text-decoration: inherit;")?; Ok(anchor) } diff --git a/src/render.rs b/src/render.rs index a24c2c71..adc829b1 100644 --- a/src/render.rs +++ b/src/render.rs @@ -5,6 +5,7 @@ use web_sys::{wasm_bindgen::prelude::*, window}; use crate::{ error::Error, event::{KeyEvent, MouseEvent}, + widgets::hyperlink_state, }; /// Trait for rendering on the web. @@ -72,6 +73,7 @@ where *callback.borrow_mut() = Some(Closure::wrap(Box::new({ let cb = callback.clone(); move || { + hyperlink_state::begin_frame(); self.draw(|frame| { render_callback(frame); }) diff --git a/src/widgets/hyperlink.rs b/src/widgets/hyperlink.rs index 7eba98b6..327dd9b2 100644 --- a/src/widgets/hyperlink.rs +++ b/src/widgets/hyperlink.rs @@ -1,10 +1,8 @@ -use ratatui::{buffer::Buffer, layout::Rect, style::Modifier, text::Span, widgets::Widget}; +use std::{borrow::Cow, rc::Rc}; -/// Hyperlink modifier. -/// -/// When added as a modifier to a style, the styled element is marked as -/// hyperlink. -pub(crate) const HYPERLINK_MODIFIER: Modifier = Modifier::SLOW_BLINK; +use ratatui::{buffer::Buffer, layout::Rect, text::Span, widgets::Widget}; + +use crate::widgets::hyperlink_state::{register, HyperlinkRegion}; /// A widget that can be used to render hyperlinks. /// @@ -12,13 +10,14 @@ pub(crate) const HYPERLINK_MODIFIER: Modifier = Modifier::SLOW_BLINK; /// use ratzilla::widgets::Hyperlink; /// /// let link = Hyperlink::new("https://ratatui.rs"); +/// let docs = Hyperlink::with_label("Ratatui", "https://ratatui.rs"); /// /// // Then you can render it as usual: /// // frame.render_widget(link, frame.area()); /// ``` pub struct Hyperlink<'a> { - /// Line. - line: Span<'a>, + label: Span<'a>, + url: Rc, } impl<'a> Hyperlink<'a> { @@ -26,9 +25,23 @@ impl<'a> Hyperlink<'a> { pub fn new(url: T) -> Self where T: Into>, + { + let label = url.into(); + Self { + url: Rc::from(label.content.clone().into_owned()), + label, + } + } + + /// Constructs a new [`Hyperlink`] widget with a separate label and target URL. + pub fn with_label(label: T, url: U) -> Self + where + T: Into>, + U: Into>, { Self { - line: url.into().style(HYPERLINK_MODIFIER), + label: label.into(), + url: Rc::from(url.into().into_owned()), } } } @@ -38,6 +51,15 @@ impl Widget for Hyperlink<'_> { where Self: Sized, { - self.line.render(area, buf); + let width = self.label.width().min(area.width as usize) as u16; + self.label.render(area, buf); + if width > 0 && area.height > 0 { + register(HyperlinkRegion { + x: area.x, + y: area.y, + width, + url: self.url, + }); + } } } diff --git a/src/widgets/hyperlink_state.rs b/src/widgets/hyperlink_state.rs new file mode 100644 index 00000000..3926559f --- /dev/null +++ b/src/widgets/hyperlink_state.rs @@ -0,0 +1,25 @@ +use std::{cell::RefCell, rc::Rc}; + +thread_local! { + static HYPERLINKS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct HyperlinkRegion { + pub(crate) x: u16, + pub(crate) y: u16, + pub(crate) width: u16, + pub(crate) url: Rc, +} + +pub(crate) fn begin_frame() { + HYPERLINKS.with(|regions| regions.borrow_mut().clear()); +} + +pub(crate) fn register(region: HyperlinkRegion) { + HYPERLINKS.with(|regions| regions.borrow_mut().push(region)); +} + +pub(crate) fn take() -> Vec { + HYPERLINKS.with(|regions| std::mem::take(&mut *regions.borrow_mut())) +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index c8853009..21b428c4 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -3,5 +3,6 @@ //! **Ratzilla** provides web-only widgets that you can use while building TUIs. pub(crate) mod hyperlink; +pub(crate) mod hyperlink_state; pub use hyperlink::Hyperlink;