Files
ghostty-rs/crates/ghostty-terminal/src/render.rs
T
ddidderr abb89a80e9 perf: close the last benchmark gaps to Zig (render state, parser)
- RenderState stores styles sparsely (per-cell u16 style ids plus one
  Style per distinct run) and skips plain cells with a single mask test
  (Cell::is_plain). screen-clone render: 11.5 ms -> 4.1 ms (Zig 4.7).
  row::never_extend_bg now takes a style accessor instead of a slice.
- Parser::next_with(byte, |action| ..) hands actions to a closure
  instead of moving a 64-byte action array out; Parser::next remains a
  wrapper over the same core (equivalence test added).
  terminal-parser SGR: 538 ms -> 231 ms (Zig 354).

All Ghostty benchmarks are now at or below Zig's time; table in
docs/rewrite/notes/perf-P1.md (the parser benchmark uses next_with with
a discarding closure, which like Zig's `_ = actions` lets the optimizer
skip unused work).

Test Plan:
- cargo test --workspace
- ghostty-bench +screen-clone --mode=render / +terminal-parser
2026-10-12 05:22:01 +02:00

1438 lines
49 KiB
Rust

//! Render state: a snapshot of the terminal state required to render a frame.
//!
//! Port of `ZIG/src/terminal/render.zig`.
//!
//! Developer note: this is in the terminal crate and not the renderer crate
//! because the goal is that this remains generic to multiple renderers.
//!
//! Contains the state required to render the screen, including optimizing
//! for repeated render calls and only rendering dirty regions.
//!
//! Previously, our renderer would use `clone` to clone the screen within
//! the viewport to perform rendering. This worked well enough that we kept
//! it all the way up through the Ghostty 1.2.x series, but the clone time
//! was repeatedly a bottleneck blocking IO.
//!
//! Rather than a generic clone that tries to clone all screen state per call
//! (within a region), a stateful approach that optimizes for only what a
//! renderer needs to do makes more sense.
//!
//! To use this, initialize the render state to empty, then call `update`
//! on each frame to update the state to the latest terminal state.
//!
//! ```
//! # use ghostty_terminal::{render::RenderState, terminal::{Terminal, Options}};
//! # let mut terminal = Terminal::new(Options::new(80, 24))?;
//! let mut state = RenderState::new();
//! state.update(&mut terminal)?;
//! # Ok::<(), ghostty_terminal::page::Error>(())
//! ```
//!
//! Note: the render state retains as much memory as possible between updates
//! to prevent future allocations. If a very large frame is rendered once,
//! the render state will retain that much memory until dropped. To avoid
//! waste, it is recommended that the caller drop and start with an
//! empty render state every so often.
use std::collections::HashSet;
use ghostty_vt::{
color::{self, Palette, Rgb},
modes::Mode,
};
use crate::{
cursor,
highlight::Flattened,
page::{self, ContentTag, Error, Wide},
page_list::{Direction, PageList, Pin},
point::{Coordinate, Point, Tag},
screen_set::Key as ScreenKey,
selection::Selection,
size,
style::{Color, Style},
terminal::{Dirty as TerminalDirty, Terminal},
};
/// Contains the state required to render the screen. See the module docs.
#[derive(Debug, Clone)]
pub struct RenderState {
/// The current screen dimensions. It is possible that these don't match
/// the renderer's current dimensions in grid cells because resizing
/// can happen asynchronously. This can lead to a one or two frame
/// mismatch a renderer needs to handle.
///
/// The viewport is always exactly equal to the active area size so this
/// is also the viewport size.
pub rows: size::CellCountInt,
pub cols: size::CellCountInt,
/// The color state for the terminal.
pub colors: Colors,
/// Cursor state within the viewport.
pub cursor: Cursor,
/// The rows (y=0 is top) of the viewport. Guaranteed to be `rows` length.
pub row_data: Vec<Row>,
/// The dirty state of the render state. This is set by the update method.
/// The renderer/caller should set this to false when it has handled
/// the dirty state.
pub dirty: Dirty,
/// The screen type that this state represents. This is used primarily
/// to detect changes.
pub screen: ScreenKey,
/// The last viewport pin used to generate this state. This is NOT
/// a tracked pin and is generally NOT safe to read other than the direct
/// values for comparison.
pub viewport_pin: Option<Pin>,
/// The cached selection so we can avoid expensive selection calculations
/// if possible.
selection_cache: Option<SelectionCache>,
}
/// The color state for the terminal.
///
/// The background/foreground will be reversed if the terminal reverse
/// color mode is on! You do not need to handle that manually!
#[derive(Debug, Clone, Copy)]
pub struct Colors {
pub background: Rgb,
pub foreground: Rgb,
pub cursor: Option<Rgb>,
pub palette: Palette,
}
#[derive(Debug, Clone, Copy)]
pub struct Cursor {
/// The x/y position of the cursor within the active area.
pub active: Coordinate,
/// The x/y position of the cursor within the viewport. This
/// may be None if the cursor is not visible within the viewport.
pub viewport: Option<CursorViewport>,
/// The cell data for the cursor position. Managed memory is not
/// safe to access from this.
pub cell: page::Cell,
/// The style, always valid even if the cell is default style.
pub style: Style,
/// The visual style of the cursor itself, such as a block or bar.
pub visual_style: cursor::Style,
/// True if the cursor is detected to be at a password input field.
pub password_input: bool,
/// Cursor visibility state determined by the terminal mode.
pub visible: bool,
/// Cursor blink state determined by the terminal mode.
pub blinking: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CursorViewport {
/// The x/y position of the cursor within the viewport.
pub x: size::CellCountInt,
pub y: size::CellCountInt,
/// Whether the cursor is part of a wide character and
/// on the tail of it. If so, some renderers may use this
/// to move the cursor back one.
pub wide_tail: bool,
}
/// A row within the viewport.
#[derive(Debug, Clone)]
pub struct Row {
/// The page pin. This is not safe to read unless you can guarantee
/// the terminal state hasn't changed since the last `update` call.
pub pin: Pin,
/// Raw row data.
pub raw: page::Row,
/// The cells in this row. Guaranteed to be `cols` length.
pub cells: Cells,
/// A dirty flag that can be used by the renderer to track
/// its own draw state. `update` will mark this true whenever
/// this row is changed, too.
pub dirty: bool,
/// The x range of the selection within this row.
pub selection: Option<[size::CellCountInt; 2]>,
/// The highlights within this row.
pub highlights: Vec<Highlight>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Highlight {
/// A special tag that can be used by the caller to differentiate
/// different highlight types. The value is opaque to the RenderState.
pub tag: u8,
/// The x ranges of highlights within this row.
pub range: [size::CellCountInt; 2],
}
/// One cell of a row (the Zig `RenderState.Cell`), by reference.
#[derive(Debug, Clone, Copy)]
pub struct Cell<'a> {
/// Always set, this is the raw copied cell data from page.Cell.
/// The managed memory (hyperlinks, graphemes, etc.) is NOT safe
/// to access from here. It is duplicated into the other fields if
/// it exists.
pub raw: page::Cell,
/// Grapheme data for the cell (the codepoints after the first). Empty
/// unless the raw cell's content_tag is `CodepointGrapheme`.
pub grapheme: &'a [u32],
/// The style data for the cell. The default style unless the style_id
/// is non-default on raw (or the cell is a bg-color-only cell).
pub style: Style,
}
/// The cells of a row, stored as a struct of arrays (the Zig
/// `MultiArrayList(Cell)`) so that the common plain-text case only copies the
/// raw cells.
///
/// `raw` always has exactly `cols` entries. `grapheme` and `style_ids` either
/// have exactly `cols` entries, or are empty when the source row has no
/// managed memory (in which case every cell has no grapheme and the default
/// style). Styles are stored sparsely: `style_ids[x]` is 0 for the default
/// style, otherwise `styles[style_ids[x] - 1]`. Always use the accessors
/// rather than indexing those directly.
#[derive(Debug, Clone, Default)]
pub struct Cells {
pub raw: Vec<page::Cell>,
pub grapheme: Vec<Vec<u32>>,
pub style_ids: Vec<u16>,
pub styles: Vec<Style>,
}
impl Cells {
pub fn len(&self) -> usize {
self.raw.len()
}
pub fn is_empty(&self) -> bool {
self.raw.is_empty()
}
/// The grapheme (extra codepoints) of the cell at `x`.
pub fn grapheme(&self, x: usize) -> &[u32] {
self.grapheme.get(x).map_or(&[], Vec::as_slice)
}
/// The style of the cell at `x` (default style if none).
pub fn style(&self, x: usize) -> Style {
match self.style_ids.get(x) {
Some(&id) if id > 0 => self.styles[usize::from(id) - 1],
_ => Style::default(),
}
}
/// The full cell at `x`. Panics if out of range.
pub fn get(&self, x: usize) -> Cell<'_> {
Cell {
raw: self.raw[x],
grapheme: self.grapheme(x),
style: self.style(x),
}
}
/// Iterate all cells in order.
pub fn iter(&self) -> impl Iterator<Item = Cell<'_>> + '_ {
(0..self.raw.len()).map(|x| self.get(x))
}
fn clear(&mut self) {
self.raw.clear();
self.grapheme.clear();
self.style_ids.clear();
self.styles.clear();
}
}
/// Dirty state
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Dirty {
/// Not dirty at all. Can skip rendering if prior state was
/// already rendered.
#[default]
False,
/// Partially dirty. Some rows changed but not all. None of the
/// global state changed such as colors.
Partial,
/// Fully dirty. Global state changed or dimensions changed. All rows
/// should be redrawn.
Full,
}
#[derive(Debug, Clone, Copy)]
struct SelectionCache {
selection: Selection,
tl_pin: Pin,
br_pin: Pin,
}
impl Default for RenderState {
fn default() -> Self {
Self::new()
}
}
impl RenderState {
/// Initial (empty) state.
pub fn new() -> RenderState {
RenderState {
rows: 0,
cols: 0,
colors: Colors {
background: Rgb::default(),
foreground: Rgb::default(),
cursor: None,
palette: color::DEFAULT,
},
cursor: Cursor {
active: Coordinate { x: 0, y: 0 },
viewport: None,
cell: page::Cell::default(),
style: Style::default(),
visual_style: cursor::Style::Block,
password_input: false,
visible: true,
blinking: false,
},
row_data: Vec::new(),
dirty: Dirty::False,
screen: ScreenKey::Primary,
viewport_pin: None,
selection_cache: None,
}
}
/// Update the render state to the latest terminal state.
///
/// This will reset the terminal dirty state since it is consumed
/// by this render state update.
pub fn update(&mut self, t: &mut Terminal) -> Result<(), Error> {
let active_key = t.screens.active_key;
let terminal_dirty = t.flags.dirty != TerminalDirty::default();
let s = t.screens.active_mut();
let viewport_pin = s
.pages
.get_top_left(Tag::Viewport)
.ok_or(Error::InvalidCoordinate)?;
let redraw = 'redraw: {
// If our screen key changed, we need to do a full rebuild
// because our render state is viewport-specific.
if active_key != self.screen {
break 'redraw true;
}
// If our terminal is dirty at all, we do a full rebuild. These
// dirty values are full-terminal dirty values.
if terminal_dirty {
break 'redraw true;
}
// If our screen is dirty at all, we do a full rebuild. This is
// a full screen dirty tracker.
if s.dirty != crate::screen::Dirty::default() {
break 'redraw true;
}
// If our dimensions changed, we do a full rebuild.
if self.rows != s.pages.rows || self.cols != s.pages.cols {
break 'redraw true;
}
// If our viewport pin changed, we do a full rebuild.
if let Some(old) = self.viewport_pin
&& !old.eql(viewport_pin)
{
break 'redraw true;
}
false
};
// Always set our cheap fields, its more expensive to compare
self.rows = s.pages.rows;
self.cols = s.pages.cols;
self.viewport_pin = Some(viewport_pin);
self.cursor.active = Coordinate {
x: s.cursor.x,
y: u32::from(s.cursor.y),
};
self.cursor.cell = s.cursor_cell();
self.cursor.style = s.cursor.style;
self.cursor.visual_style = s.cursor.cursor_style;
self.cursor.password_input = t.flags.password_input;
self.cursor.visible = t.modes.get(Mode::CursorVisible);
self.cursor.blinking = t.modes.get(Mode::CursorBlinking);
// Always reset the cursor viewport position. In the future we can
// probably cache this by comparing the cursor pin and viewport pin
// but may not be worth it.
self.cursor.viewport = None;
// Colors.
self.colors.cursor = t.colors.cursor.get();
self.colors.palette = t.colors.palette.current;
// Background/foreground can be unset initially which would
// depend on "default" background/foreground. The expected use
// case of Terminal is that the caller set their own configured
// defaults on load so this doesn't happen.
if let (Some(bg), Some(fg)) = (t.colors.background.get(), t.colors.foreground.get()) {
if t.modes.get(Mode::ReverseColors) {
self.colors.background = fg;
self.colors.foreground = bg;
} else {
self.colors.background = bg;
self.colors.foreground = fg;
}
}
// Ensure our row length is exactly our height, freeing or allocating
// data as necessary. In most cases we'll have a perfectly matching
// size.
let nrows = usize::from(self.rows);
if self.row_data.len() != nrows {
if self.row_data.len() < nrows {
let filler = viewport_pin;
self.row_data.resize_with(nrows, || Row {
pin: filler,
raw: page::Row::default(),
cells: Cells::default(),
dirty: true,
selection: None,
highlights: Vec::new(),
});
} else {
self.row_data.truncate(nrows);
}
}
// Go through and set up our row pins and find the cursor. We do the
// cursor even if the row is not dirty because the cursor is unrelated.
// We have to store pins even if we're not dirty because dirty is
// only a renderer optimization. It doesn't apply to memory movement.
// This will let us remap any cell pins back to an exact entry in our
// RenderState.
let cursor_pin = s.cursor_pin();
let mut y: usize = 0;
for row_pin in s.pages.row_iterator(
Direction::RightDown,
Point::Viewport(Coordinate::default()),
None,
) {
if y >= nrows {
break;
}
if self.cursor.viewport.is_none()
&& row_pin.node == cursor_pin.node
&& row_pin.y == cursor_pin.y
{
self.cursor.viewport = Some(CursorViewport {
y: y as size::CellCountInt,
x: s.cursor.x,
// Future: we should use our own state here to look this
// up rather than calling this.
wide_tail: if s.cursor.x > 0 {
s.cursor_cell_left(1).wide() == Wide::Wide
} else {
false
},
});
}
self.row_data[y].pin = row_pin;
y += 1;
}
assert_eq!(y, nrows);
// Track the last page that we know was dirty. This lets us
// more quickly do the full-page dirty check.
let mut last_dirty_page: Option<crate::page_list::NodeId> = None;
let mut any_dirty = false;
let cols = usize::from(self.cols);
for y in 0..nrows {
let row_pin = self.row_data[y].pin;
let node = s
.pages
.node_mut(row_pin.node)
.ok_or(Error::InvalidCoordinate)?;
let is_dirty = 'dirty: {
// If we're redrawing then we're definitely dirty.
if redraw {
break 'dirty true;
}
// If our page is the same as last time then its dirty.
if last_dirty_page == Some(row_pin.node) {
break 'dirty true;
}
if node.data.dirty {
// If this page is dirty then clear the dirty flag
// of the last page and then store this one. This benchmarks
// faster than iterating pages again later.
if let Some(last) = last_dirty_page
&& let Some(n) = s.pages.node_mut(last)
{
n.data.dirty = false;
}
last_dirty_page = Some(row_pin.node);
break 'dirty true;
}
// If our row is dirty then we're dirty.
node.data
.get_row(usize::from(row_pin.y))
.is_some_and(|r| r.dirty())
};
if !is_dirty {
continue;
}
// Set that at least one row was dirty.
any_dirty = true;
// Clear our row dirty, we'll clear our page dirty later.
// We can't clear it now because we have more rows to go through.
let node = s
.pages
.node_mut(row_pin.node)
.ok_or(Error::InvalidCoordinate)?;
let raw_row = {
let r = node
.data
.get_row_mut(usize::from(row_pin.y))
.ok_or(Error::InvalidCoordinate)?;
r.set_dirty(false);
*r
};
let p: &page::Page = &node.data;
let row = &mut self.row_data[y];
// Reset our cells if we're rebuilding this row.
if !row.cells.is_empty() {
row.cells.clear();
row.selection = None;
row.highlights.clear();
}
row.dirty = true;
// Get all our cells in the page.
let page_cells = p
.get_cells(usize::from(row_pin.y))
.ok_or(Error::InvalidCoordinate)?;
assert_eq!(page_cells.len(), cols);
// Copy our raw row data
row.raw = raw_row;
// We always copy our raw cell data. In the case we have no
// managed memory, we can skip setting any other fields.
//
// This is an important optimization. For plain-text screens
// this ends up being something around 300% faster based on
// the `screen-clone` benchmark.
row.cells.raw.extend_from_slice(page_cells);
if !raw_row.managed_memory() {
continue;
}
row.cells.style_ids.resize(cols, 0);
// Cache of the last page style id and its index in `styles`.
let mut last_sid: u16 = 0;
let mut last_idx: u16 = 0;
let mut last_bg_key: u32 = 0;
let mut last_bg_idx: u16 = 0;
for (x, page_cell) in page_cells.iter().enumerate() {
// Plain cells (the vast majority) need nothing more.
if page_cell.is_plain() {
continue;
}
// Append assuming its a single-codepoint, styled cell
// (most common by far).
let sid = page_cell.style_id();
if sid > 0 {
if sid != last_sid {
row.cells.styles.push(p.style(*page_cell));
last_sid = sid;
last_idx = row.cells.styles.len() as u16;
}
row.cells.style_ids[x] = last_idx;
}
// Switch on our content tag to handle less likely cases.
match page_cell.content_tag() {
// Primary codepoint goes into `raw` field.
ContentTag::Codepoint => {}
// If we have a multi-codepoint grapheme, look it up and
// set our content type.
ContentTag::CodepointGrapheme => {
if let Ok(idx) = p.cell_index(x, usize::from(row_pin.y))
&& let Some(g) = p.lookup_grapheme(idx)
{
// Only rows that actually contain graphemes pay
// for the per-cell vectors.
if row.cells.grapheme.is_empty() {
row.cells.grapheme.resize_with(cols, Vec::new);
}
row.cells.grapheme[x].extend_from_slice(g);
}
}
ContentTag::BgColorRgb => {
let c = page_cell.color_rgb();
let key =
1 << 24 | u32::from(c.r) << 16 | u32::from(c.g) << 8 | u32::from(c.b);
if key != last_bg_key {
row.cells.styles.push(Style {
bg_color: Color::Rgb(c),
..Style::default()
});
last_bg_key = key;
last_bg_idx = row.cells.styles.len() as u16;
}
row.cells.style_ids[x] = last_bg_idx;
}
ContentTag::BgColorPalette => {
let c = page_cell.color_palette();
let key = 2 << 24 | u32::from(c);
if key != last_bg_key {
row.cells.styles.push(Style {
bg_color: Color::Palette(c),
..Style::default()
});
last_bg_key = key;
last_bg_idx = row.cells.styles.len() as u16;
}
row.cells.style_ids[x] = last_bg_idx;
}
}
}
}
// If our screen has a selection, then mark the rows with the
// selection. We do this outside of the loop above because its unlikely
// a selection exists and because the way our selections are structured
// today is very inefficient.
//
// NOTE: To improve the performance of the block below, we'll need
// to rethink how we model selections in general.
//
// There are performance improvements that can be made here, though.
// For example, `contained_row` recalculates a bunch of information
// we can cache.
if let Some(sel) = s.selection {
'selection: {
// Populate our selection cache to avoid some expensive
// recalculation.
let cached = match self.selection_cache {
// If we're redrawing, we recalculate the cache just to
// be safe. If our selection isn't equal, we aren't cached!
Some(c) if !redraw && c.selection.eql(&s.pages, &sel) => Some(c),
_ => None,
};
let cache = if let Some(c) = cached {
// If we have no dirty rows, we can not recalculate.
if !any_dirty {
break 'selection;
}
// We have dirty rows, we can utilize the cache.
c
} else {
// Create a new cache
let tl_pin = sel.top_left(&s.pages);
let br_pin = sel.bottom_right(&s.pages);
let c = SelectionCache {
selection: Selection::new(tl_pin, br_pin, sel.rectangle),
tl_pin,
br_pin,
};
self.selection_cache = Some(c);
c
};
// Grab the inefficient data we need from the selection. At
// least we can cache it.
let tl = s
.pages
.point_from_pin(Tag::Screen, cache.tl_pin)
.ok_or(Error::InvalidCoordinate)?
.coord();
let br = s
.pages
.point_from_pin(Tag::Screen, cache.br_pin)
.ok_or(Error::InvalidCoordinate)?
.coord();
// We need to determine if our selection is within the viewport.
// The viewport is generally very small so the efficient way to
// do this is to traverse the viewport pages and check for the
// matching selection pages.
for row in &mut self.row_data {
let pin = row.pin;
let p = s
.pages
.point_from_pin(Tag::Screen, pin)
.ok_or(Error::InvalidCoordinate)?
.coord();
let Some(row_sel) = sel.contained_row_cached(
&s.pages,
cache.tl_pin,
cache.br_pin,
pin,
tl,
br,
p,
) else {
continue;
};
let start = row_sel.start(&s.pages);
let end = row_sel.end(&s.pages);
assert_eq!(start.node, end.node);
assert!(start.x <= end.x);
assert_eq!(start.y, end.y);
row.selection = Some([start.x, end.x]);
}
}
}
// Handle dirty state.
if redraw {
// Fully redraw resets some other state.
self.screen = active_key;
self.dirty = Dirty::Full;
// Note: we don't clear any row_data here because our rebuild
// above did this.
} else if any_dirty && self.dirty == Dirty::False {
self.dirty = Dirty::Partial;
}
// Finalize our final dirty page
if let Some(last) = last_dirty_page
&& let Some(n) = s.pages.node_mut(last)
{
n.data.dirty = false;
}
// Clear our dirty flags
t.flags.dirty = TerminalDirty::default();
s.dirty = crate::screen::Dirty::default();
Ok(())
}
/// Update the highlights in the render state from the given flattened
/// highlights. Because this uses flattened highlights, it does not require
/// reading from the terminal state so it should be done outside of
/// any critical sections.
///
/// This will not clear any previous highlights, so the caller must
/// manually clear them if desired.
pub fn update_highlights_flattened(&mut self, tag: u8, hls: &[Flattened]) {
// Fast path, we have no highlights!
if hls.is_empty() {
return;
}
// This is, admittedly, horrendous. This is some low hanging fruit
// to optimize. In my defense, screens are usually small, the number
// of highlights is usually small, and this only happens on the
// viewport outside of a locked area. Still, I'd love to see this
// improved someday.
// We need to track whether any row had a match so we can mark
// the dirty state.
let mut any_dirty = false;
let cols = self.cols;
for row in &mut self.row_data {
let row_pin = row.pin;
for hl in hls {
let n = hl.chunks.len();
for (i, chunk) in hl.chunks.iter().enumerate() {
// If this node doesn't match or we're not within
// the row range, skip it.
if chunk.node != row_pin.node
|| row_pin.y < chunk.start
|| row_pin.y >= chunk.end
{
continue;
}
// We're a match!
row.highlights.push(Highlight {
tag,
range: [
if i == 0 && row_pin.y == hl.chunks[0].start {
hl.top_x
} else {
0
},
if i == n - 1 && row_pin.y == hl.chunks[n - 1].end - 1 {
hl.bot_x
} else {
cols - 1
},
],
});
row.dirty = true;
any_dirty = true;
}
}
}
// Mark our dirty state.
if any_dirty && self.dirty == Dirty::False {
self.dirty = Dirty::Partial;
}
}
/// Convert the current render state contents to a UTF-8 encoded
/// string written to the given string. This will unwrap all the wrapped
/// rows. This is useful for a minimal viewport search.
///
/// This currently writes empty cell contents as \x00 and writes all
/// blank lines. This is fine for our current usage (link search) but
/// we can adjust this later.
///
/// If `map` is given, every byte written gets the coordinate of the cell
/// it came from appended (a newline maps to `x = cols`).
///
/// NOTE: There is a limitation in that wrapped lines before/after
/// the the top/bottom line of the viewport are not included, since
/// the render state cuts them off.
///
/// Returns `Err` if a cell holds an invalid Unicode scalar value.
pub fn string(
&self,
out: &mut String,
mut map: Option<&mut Vec<Coordinate>>,
) -> Result<(), std::fmt::Error> {
for (y, row) in self.row_data.iter().enumerate() {
for (x, cell) in row.cells.raw.iter().enumerate() {
let coord = Coordinate {
x: x as u16,
y: y as u32,
};
let c = char::from_u32(cell.codepoint()).ok_or(std::fmt::Error)?;
let mut len = c.len_utf8();
out.push(c);
if cell.has_grapheme() {
for &cp in row.cells.grapheme(x) {
let c = char::from_u32(cp).ok_or(std::fmt::Error)?;
len += c.len_utf8();
out.push(c);
}
}
if let Some(m) = map.as_deref_mut() {
m.extend(std::iter::repeat_n(coord, len));
}
}
if !row.raw.wrap() {
out.push('\n');
if let Some(m) = map.as_deref_mut() {
m.push(Coordinate {
x: row.cells.len() as u16,
y: y as u32,
});
}
}
}
Ok(())
}
/// Returns a set of the cells that match to an OSC8 hyperlink over the
/// given point in the render state.
///
/// IMPORTANT: The terminal must not have updated since the last call to
/// `update`. If there is any chance the terminal has updated, the caller
/// must first call `update` again to refresh the render state.
///
/// For example, you may want to hold a lock for the duration of the
/// update and hyperlink lookup to ensure no updates happen in between.
///
/// Unlike Zig (where pins hold page pointers) the page list the pins
/// belong to must be passed (`&terminal.screen().pages`).
pub fn link_cells(&self, pages: &PageList, viewport_point: Coordinate) -> HashSet<Coordinate> {
let mut result = HashSet::new();
// Our viewport point is sent in by the caller and can't be trusted.
// If it is outside the valid area then just return empty because
// we can't possibly have a link there.
if viewport_point.x >= self.cols || viewport_point.y as usize >= self.row_data.len() {
return result;
}
// Grab our link ID
let link_pin = self.row_data[viewport_point.y as usize].pin;
let Some(link_node) = pages.node(link_pin.node) else {
return result;
};
let link_page = &link_node.data;
let Some(link) = ({
let x = usize::from(viewport_point.x);
let y = usize::from(link_pin.y);
let Some((_, cell)) = link_page.get_row_and_cell(x, y) else {
return result;
};
// The likely scenario is that our mouse isn't even over a link.
if !cell.hyperlink() {
return result;
}
let Ok(idx) = link_page.cell_index(x, y) else {
return result;
};
let Some(link_id) = link_page.lookup_hyperlink(idx) else {
return result;
};
link_page.hyperlink_set.get(link_id)
}) else {
return result;
};
for (y, row) in self.row_data.iter().enumerate() {
let pin = row.pin;
let Some(other_node) = pages.node(pin.node) else {
continue;
};
let other_page = &other_node.data;
for (x, cell) in row.cells.raw.iter().enumerate() {
if !cell.hyperlink() {
continue;
}
let Ok(idx) = other_page.cell_index(x, usize::from(pin.y)) else {
continue;
};
let Some(link_id) = other_page.lookup_hyperlink(idx) else {
continue;
};
let Some(other) = other_page.hyperlink_set.get(link_id) else {
continue;
};
if link.eql(other) {
result.insert(Coordinate {
y: y as u32,
x: x as u16,
});
}
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::Options;
fn term(cols: u16, rows: u16) -> Terminal {
Terminal::new(Options::new(cols, rows)).unwrap()
}
fn feed(t: &mut Terminal, bytes: &[u8]) {
let mut s = t.vt_stream();
s.next_slice(bytes).unwrap();
}
fn clear_dirty(state: &mut RenderState) {
state.dirty = Dirty::False;
for r in &mut state.row_data {
r.dirty = false;
}
}
#[test]
fn styled() {
let mut t = term(80, 24);
// This fills the screen up
t.decaln().unwrap();
let mut state = RenderState::new();
state.update(&mut t).unwrap();
}
#[test]
fn basic_text() {
let mut t = term(10, 3);
feed(&mut t, b"ABCD");
let mut state = RenderState::new();
state.update(&mut t).unwrap();
// Verify we have the right number of rows
assert_eq!(state.row_data.len(), 3);
// All rows should have cols cells
for r in &state.row_data {
assert_eq!(r.cells.len(), 10);
}
// Row zero should contain our text
let cells = &state.row_data[0].cells;
assert_eq!(cells.get(0).raw.codepoint(), 'A' as u32);
assert_eq!(cells.get(1).raw.codepoint(), 'B' as u32);
assert_eq!(cells.get(2).raw.codepoint(), 'C' as u32);
assert_eq!(cells.get(3).raw.codepoint(), 'D' as u32);
assert_eq!(cells.get(4).raw.codepoint(), 0);
}
#[test]
fn styled_text() {
let mut t = term(10, 3);
feed(&mut t, b"\x1b[1mA"); // Bold
feed(&mut t, b"\x1b[0;3mB"); // Italic
feed(&mut t, b"\x1b[0;4mC"); // Underline
let mut state = RenderState::new();
state.update(&mut t).unwrap();
assert_eq!(state.row_data.len(), 3);
for r in &state.row_data {
assert_eq!(r.cells.len(), 10);
}
let cells = &state.row_data[0].cells;
{
let cell = cells.get(0);
assert_eq!(cell.raw.codepoint(), 'A' as u32);
assert!(cell.style.flags.bold);
}
{
let cell = cells.get(1);
assert_eq!(cell.raw.codepoint(), 'B' as u32);
assert!(!cell.style.flags.bold);
assert!(cell.style.flags.italic);
}
assert_eq!(cells.get(2).raw.codepoint(), 'C' as u32);
assert_eq!(cells.get(3).raw.codepoint(), 0);
}
#[test]
fn grapheme() {
let mut t = term(10, 3);
feed(&mut t, b"A");
feed(&mut t, "\u{1F468}\u{200D}".as_bytes()); // this has a ZWJ
let mut state = RenderState::new();
state.update(&mut t).unwrap();
assert_eq!(state.row_data.len(), 3);
for r in &state.row_data {
assert_eq!(r.cells.len(), 10);
}
let cells = &state.row_data[0].cells;
assert_eq!(cells.get(0).raw.codepoint(), 'A' as u32);
{
let cell = cells.get(1);
assert_eq!(cell.raw.codepoint(), 0x1F468);
assert_eq!(cell.raw.wide(), Wide::Wide);
assert_eq!(cell.grapheme, &[0x200D]);
}
{
let cell = cells.get(2);
assert_eq!(cell.raw.codepoint(), 0);
assert_eq!(cell.raw.wide(), Wide::SpacerTail);
}
}
#[test]
fn cursor_state_in_viewport() {
let mut t = term(10, 5);
feed(&mut t, b"A\x1b[H");
let mut state = RenderState::new();
// Initial update
state.update(&mut t).unwrap();
assert_eq!(state.cursor.active.x, 0);
assert_eq!(state.cursor.active.y, 0);
assert_eq!(state.cursor.viewport.unwrap().x, 0);
assert_eq!(state.cursor.viewport.unwrap().y, 0);
assert_eq!(state.cursor.cell.codepoint(), 'A' as u32);
assert!(state.cursor.style.is_default());
// Set a style on the cursor
feed(&mut t, b"\x1b[1m"); // Bold
state.update(&mut t).unwrap();
assert!(!state.cursor.style.is_default());
assert!(state.cursor.style.flags.bold);
feed(&mut t, b"\x1b[0m"); // Reset style
// Move cursor to 2,1
feed(&mut t, b"\x1b[2;3H");
state.update(&mut t).unwrap();
assert_eq!(state.cursor.active.x, 2);
assert_eq!(state.cursor.active.y, 1);
assert_eq!(state.cursor.viewport.unwrap().x, 2);
assert_eq!(state.cursor.viewport.unwrap().y, 1);
}
#[test]
fn cursor_state_out_of_viewport() {
let mut t = term(10, 2);
feed(&mut t, b"A\r\nB\r\nC\r\nD\r\n");
let mut state = RenderState::new();
// Initial update
state.update(&mut t).unwrap();
assert_eq!(state.cursor.active.x, 0);
assert_eq!(state.cursor.active.y, 1);
assert_eq!(state.cursor.viewport.unwrap().x, 0);
assert_eq!(state.cursor.viewport.unwrap().y, 1);
// Scroll the viewport
t.scroll_viewport(crate::terminal::ScrollViewport::Top);
state.update(&mut t).unwrap();
// Set a style on the cursor
assert_eq!(state.cursor.active.x, 0);
assert_eq!(state.cursor.active.y, 1);
assert!(state.cursor.viewport.is_none());
}
#[test]
fn dirty_state() {
let mut t = term(10, 5);
let mut state = RenderState::new();
// First update should trigger redraw due to resize
state.update(&mut t).unwrap();
assert_eq!(state.dirty, Dirty::Full);
// Reset dirty flag and dirty rows
clear_dirty(&mut state);
// Second update with no changes - no dirty rows
state.update(&mut t).unwrap();
assert_eq!(state.dirty, Dirty::False);
for r in &state.row_data {
assert!(!r.dirty);
}
// Write to first line
feed(&mut t, b"A");
state.update(&mut t).unwrap();
assert_eq!(state.dirty, Dirty::Partial);
assert!(state.row_data[0].dirty); // First row dirty
assert!(!state.row_data[1].dirty); // Second row clean
}
#[test]
fn colors() {
let mut t = term(10, 5);
let mut state = RenderState::new();
// Default colors
state.update(&mut t).unwrap();
// Change cursor color
feed(&mut t, b"\x1b]12;#FF0000\x07");
state.update(&mut t).unwrap();
let c = state.colors.cursor.unwrap();
assert_eq!(c.r, 0xFF);
assert_eq!(c.g, 0);
assert_eq!(c.b, 0);
// Change palette color 0 to White
feed(&mut t, b"\x1b]4;0;#FFFFFF\x07");
state.update(&mut t).unwrap();
let p0 = state.colors.palette[0];
assert_eq!(p0.r, 0xFF);
assert_eq!(p0.g, 0xFF);
assert_eq!(p0.b, 0xFF);
}
fn pin_active(t: &Terminal, x: u16, y: u32) -> Pin {
t.screen()
.pages
.pin(Point::Active(Coordinate { x, y }))
.unwrap()
}
#[test]
fn selection_single_line() {
let mut t = term(10, 3);
let (a, b) = (pin_active(&t, 0, 1), pin_active(&t, 2, 1));
t.screen_mut()
.select(Some(Selection::new(a, b, false)))
.unwrap();
let mut state = RenderState::new();
state.update(&mut t).unwrap();
assert_eq!(state.row_data[0].selection, None);
assert_eq!(state.row_data[1].selection, Some([0, 2]));
assert_eq!(state.row_data[2].selection, None);
// Clear the selection
t.screen_mut().select(None).unwrap();
state.update(&mut t).unwrap();
assert_eq!(state.row_data[0].selection, None);
assert_eq!(state.row_data[1].selection, None);
assert_eq!(state.row_data[2].selection, None);
}
#[test]
fn selection_multiple_lines() {
let mut t = term(10, 3);
let (a, b) = (pin_active(&t, 0, 1), pin_active(&t, 2, 2));
t.screen_mut()
.select(Some(Selection::new(a, b, false)))
.unwrap();
let mut state = RenderState::new();
state.update(&mut t).unwrap();
assert_eq!(state.row_data[0].selection, None);
assert_eq!(
state.row_data[1].selection,
Some([0, t.screen().pages.cols - 1])
);
assert_eq!(state.row_data[2].selection, Some([0, 2]));
}
#[test]
fn link_cells() {
let mut t = term(10, 5);
let mut state = RenderState::new();
// Create a hyperlink
feed(
&mut t,
b"\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\",
);
state.update(&mut t).unwrap();
// Query link at 0,0
let cells = state.link_cells(&t.screen().pages, Coordinate { x: 0, y: 0 });
assert_eq!(cells.len(), 4);
assert!(cells.contains(&Coordinate { x: 0, y: 0 }));
assert!(cells.contains(&Coordinate { x: 1, y: 0 }));
assert!(cells.contains(&Coordinate { x: 2, y: 0 }));
assert!(cells.contains(&Coordinate { x: 3, y: 0 }));
// Query no link
let cells2 = state.link_cells(&t.screen().pages, Coordinate { x: 4, y: 0 });
assert_eq!(cells2.len(), 0);
}
#[test]
fn string() {
let mut t = term(5, 2);
feed(&mut t, b"AB");
let mut state = RenderState::new();
state.update(&mut t).unwrap();
let mut result = String::new();
state.string(&mut result, None).unwrap();
let expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n";
assert_eq!(result, expected);
// With a map, every byte maps to its cell; newline maps to x = cols.
let mut result = String::new();
let mut map = Vec::new();
state.string(&mut result, Some(&mut map)).unwrap();
assert_eq!(map.len(), result.len());
assert_eq!(map[0], Coordinate { x: 0, y: 0 });
assert_eq!(map[5], Coordinate { x: 5, y: 0 });
assert_eq!(map[6], Coordinate { x: 0, y: 1 });
}
#[test]
fn link_cells_with_scrollback_spanning_pages() {
let viewport_rows: u16 = 10;
let tail_rows: u16 = 5;
let mut t = Terminal::new(Options {
max_scrollback: 10_000,
..Options::new(page::STD_CAPACITY.cols, viewport_rows)
})
.unwrap();
let first_page_cap = {
let pages = &t.screen().pages;
pages.node(pages.first.unwrap()).unwrap().data.capacity.rows
};
// Fill first page
for _ in 0..first_page_cap - 1 {
feed(&mut t, b"\r\n");
}
// Create second page with hyperlink
feed(&mut t, b"\r\n");
feed(
&mut t,
b"\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\",
);
for _ in 0..tail_rows - 1 {
feed(&mut t, b"\r\n");
}
let mut state = RenderState::new();
state.update(&mut t).unwrap();
let expected_viewport_y = u32::from(viewport_rows - tail_rows);
let cells = state.link_cells(
&t.screen().pages,
Coordinate {
x: 0,
y: expected_viewport_y,
},
);
assert_eq!(cells.len(), 4);
}
#[test]
fn link_cells_with_invalid_viewport_point() {
let mut t = term(10, 5);
let mut state = RenderState::new();
state.update(&mut t).unwrap();
// Row out of bound
let cells = state.link_cells(
&t.screen().pages,
Coordinate {
x: 0,
y: u32::from(t.rows) + 10,
},
);
assert_eq!(cells.len(), 0);
// Col out of bound
let cells = state.link_cells(
&t.screen().pages,
Coordinate {
x: t.cols + 10,
y: 0,
},
);
assert_eq!(cells.len(), 0);
}
#[test]
fn dirty_row_resets_highlights() {
let mut t = term(10, 3);
feed(&mut t, b"ABC");
let mut state = RenderState::new();
state.update(&mut t).unwrap();
// Reset dirty state
clear_dirty(&mut state);
// Manually add a highlight to row 0
state.row_data[0].highlights.push(Highlight {
tag: 1,
range: [0, 2],
});
assert_eq!(state.row_data[0].highlights.len(), 1);
// Write to row 0 to make it dirty
feed(&mut t, b"\x1b[H"); // Move to home
feed(&mut t, b"X");
state.update(&mut t).unwrap();
// Verify the highlight was reset on the dirty row
assert_eq!(state.row_data[0].highlights.len(), 0);
}
// Additional tests (not in Zig).
#[test]
fn bg_color_cell_style() {
let mut t = term(10, 3);
// The row must have managed memory (a styled cell) for the style of
// bg-color-only cells to be populated (same as Zig).
feed(&mut t, b"\x1b[48;2;1;2;3mA\x1b[K");
let mut state = RenderState::new();
state.update(&mut t).unwrap();
let cell = state.row_data[0].cells.get(5);
assert_eq!(cell.raw.content_tag(), ContentTag::BgColorRgb);
assert_eq!(cell.style.bg_color, Color::Rgb(Rgb { r: 1, g: 2, b: 3 }));
}
#[test]
fn update_highlights_flattened() {
let mut t = term(10, 3);
feed(&mut t, b"ABC");
let mut state = RenderState::new();
state.update(&mut t).unwrap();
clear_dirty(&mut state);
let pages = &t.screen().pages;
let start = pages
.pin(Point::Viewport(Coordinate { x: 1, y: 0 }))
.unwrap();
let end = pages
.pin(Point::Viewport(Coordinate { x: 4, y: 1 }))
.unwrap();
let hl = Flattened::init(pages, start, end).unwrap();
state.update_highlights_flattened(7, &[hl]);
assert_eq!(state.dirty, Dirty::Partial);
assert_eq!(
state.row_data[0].highlights,
vec![Highlight {
tag: 7,
range: [1, 9]
}]
);
assert_eq!(
state.row_data[1].highlights,
vec![Highlight {
tag: 7,
range: [0, 4]
}]
);
assert_eq!(state.row_data[2].highlights, vec![]);
assert!(state.row_data[0].dirty && state.row_data[1].dirty);
assert!(!state.row_data[2].dirty);
}
}