Files
ghostty-rs/crates/ghostty-terminal/src/highlight.rs
T
ddidderr 6a374b6953 feat(terminal): port render state, highlights and the formatter
- render.rs ports render.zig: RenderState, the per-frame snapshot the
  renderer consumes (rows, cells, styles, graphemes, selection,
  highlights, cursor, colors), updated incrementally with Zig's full
  redraw triggers and per-page/per-row dirty tracking. Cells are stored
  struct-of-arrays; use the accessors.
- highlight.rs ports highlight.zig (untracked, tracked, flattened).
- formatter.rs ports formatter.zig: plain, VT and HTML output for
  terminal, screen, page list and page, with selection, rectangle,
  unwrap, trim, palette, codepoint maps, hyperlinks and pin/point maps.
  Screen's test dump helpers now delegate to it.

Cargo.toml also picks up dependencies for the kitty graphics and search
modules that are being ported concurrently.

Test Plan:
- cargo test -p ghostty-terminal --lib (1243 passed; formatter 100/100,
  render 15/15 Zig tests)
2026-10-11 22:50:22 +02:00

229 lines
7.5 KiB
Rust

//! Highlights are any contiguous sequences of cells that should
//! be called out in some way, most commonly for text selection but
//! also search results or any other purpose.
//!
//! Within the terminal package, a highlight is a generic concept
//! that represents a range of cells.
//!
//! Port of `ZIG/src/terminal/highlight.zig`.
// NOTE: The plan is for highlights to ultimately replace Selection
// completely. Selection is deeply tied to various parts of the Ghostty
// internals so this may take some time.
use crate::{
page::Error,
page_list::{NodeId, PageList, Pin, TrackedPin},
screen::Screen,
size,
};
/// An untracked highlight is a highlight that stores its highlighted
/// area as a top-left and bottom-right screen pin. Since it is untracked,
/// the pins are only valid for the current terminal state and may not
/// be safe to use after any terminal modifications.
///
/// For rectangle highlights/selections, the downstream consumer of this
/// code is expected to interpret the pins in whatever shape they want.
/// For example, a rectangular selection would interpret the pins as
/// setting the x bounds for each row between start.y and end.y.
///
/// To simplify all operations, start MUST be before or equal to end.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Untracked {
pub start: Pin,
pub end: Pin,
}
impl Untracked {
pub fn track(&self, screen: &mut Screen) -> Result<Tracked, Error> {
Tracked::init(screen, self.start, self.end)
}
pub fn eql(self, other: Untracked) -> bool {
self.start.eql(other.start) && self.end.eql(other.end)
}
}
/// A tracked highlight is a highlight that stores its highlighted
/// area as tracked pins within a screen.
///
/// A tracked highlight ensures that the pins remain valid even as
/// the terminal state changes. Because of this, tracked highlights
/// have more operations available to them.
///
/// There is more overhead to creating and maintaining tracked highlights.
/// If you're manipulating highlights that are untracked and you're sure
/// that the terminal state won't change, you can use the `init_assume`
/// function.
///
/// Unlike Zig (which stores `*Pin`), the tracked pins are handles into the
/// owning [`PageList`]; read them with `pages.tracked_pin(h.start)`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tracked {
pub start: TrackedPin,
pub end: TrackedPin,
}
impl Tracked {
pub fn init(screen: &mut Screen, start: Pin, end: Pin) -> Result<Tracked, Error> {
let start_tracked = screen.pages.track_pin(start)?;
let end_tracked = match screen.pages.track_pin(end) {
Ok(v) => v,
Err(e) => {
screen.pages.untrack_pin(start_tracked);
return Err(e);
}
};
Ok(Tracked {
start: start_tracked,
end: end_tracked,
})
}
/// Initializes a tracked highlight by assuming that the provided
/// pins are already tracked. This allows callers to perform tracked
/// operations without the overhead of tracking the pins, if the
/// caller can guarantee that the pins are already tracked or that
/// the terminal state will not change.
///
/// Do not call deinit on highlights created with this function.
pub fn init_assume(start: TrackedPin, end: TrackedPin) -> Tracked {
Tracked { start, end }
}
pub fn deinit(self, screen: &mut Screen) {
screen.pages.untrack_pin(self.start);
screen.pages.untrack_pin(self.end);
}
/// Current (untracked) value of the highlight.
pub fn untracked(&self, pages: &PageList) -> Option<Untracked> {
Some(Untracked {
start: pages.tracked_pin(self.start)?,
end: pages.tracked_pin(self.end)?,
})
}
}
/// A flattened highlight is a highlight that stores its highlighted
/// area as a list of page chunks. This representation allows for
/// traversing the entire highlighted area without needing to read any
/// terminal state or dereference any page nodes (which may have been
/// pruned).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Flattened {
/// The page chunks that make up this highlight. This handles the
/// y bounds since chunks[0].start is the first highlighted row
/// and chunks[len - 1].end is the last highlighted row (exclusive).
pub chunks: Vec<Chunk>,
/// The x bounds of the highlight. `bot_x` may be less than `top_x`
/// for typical left-to-right highlights: can start the selection right
/// of the end on a higher row.
pub top_x: size::CellCountInt,
pub bot_x: size::CellCountInt,
}
/// A flattened chunk is almost identical to a PageList.Chunk but
/// we also flatten the serial number. This lets the flattened
/// highlight more robust for comparisons and validity checks with
/// the PageList.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Chunk {
pub node: NodeId,
pub serial: u64,
pub start: size::CellCountInt,
pub end: size::CellCountInt,
}
impl Flattened {
pub const EMPTY: Flattened = Flattened {
chunks: Vec::new(),
top_x: 0,
bot_x: 0,
};
pub fn empty() -> Flattened {
Self::EMPTY
}
pub fn init(pages: &PageList, start: Pin, end: Pin) -> Result<Flattened, Error> {
let mut result = Vec::new();
for chunk in pin_page_iterator(pages, start, end) {
let serial = pages
.node(chunk.node)
.ok_or(Error::InvalidCoordinate)?
.serial;
result.push(Chunk {
node: chunk.node,
serial,
start: chunk.start,
end: chunk.end,
});
}
// NOTE: The Zig code assigns `.end_x = end.x` here (a typo for
// `bot_x`, never compiled since the function is unused).
Ok(Flattened {
chunks: result,
top_x: start.x,
bot_x: end.x,
})
}
pub fn start_pin(&self) -> Pin {
let first = self.chunks[0];
Pin {
node: first.node,
x: self.top_x,
y: first.start,
garbage: false,
}
}
pub fn end_pin(&self) -> Pin {
let last = self.chunks[self.chunks.len() - 1];
Pin {
node: last.node,
x: self.bot_x,
y: last.end - 1,
garbage: false,
}
}
/// Convert to an Untracked highlight.
pub fn untracked(&self) -> Untracked {
Untracked {
start: self.start_pin(),
end: self.end_pin(),
}
}
}
/// Equivalent of Zig `Pin.pageIterator(.right_down, limit)`: iterate the
/// page chunks (inclusive of `end`'s row) starting at `start`.
///
/// Like Zig this does not validate the pins against the page sizes (the end
/// row of the last chunk is `end.y + 1` even if the page is smaller), so
/// consumers must clamp.
pub fn pin_page_iterator(
pages: &PageList,
start: Pin,
end: Pin,
) -> impl Iterator<Item = crate::page_list::Chunk> + '_ {
let mut cur = Some(start.node);
std::iter::from_fn(move || {
let node = cur?;
let n = pages.node(node)?;
let first = node == start.node;
let last = node == end.node;
let chunk = crate::page_list::Chunk {
node,
start: if first { start.y } else { 0 },
end: if last { end.y + 1 } else { n.data.size.rows },
};
cur = if last { None } else { n.next };
Some(chunk)
})
}