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)
This commit is contained in:
ddidderr committed 2026-10-11 22:50:22 +02:00
1 parent 225f5ae2d7
commit 40dc87519e
13 files changed
+5510 -356

No files matched your search

+5
View File
@@ -5,11 +5,16 @@ edition.workspace = true
license.workspace = true
[dependencies]
crossbeam-channel = "0.5.17"
flate2 = "1.1.10"
ghostty-datastruct.workspace = true
ghostty-unicode.workspace = true
ghostty-vt.workspace = true
log = "0.4.34"
onig = "6.5.3"
png = "0.18.1"
rustix = { version = "1.1.5", features = ["shm", "fs"] }
thiserror = "2.0.21"
[dev-dependencies]
ghostty-config.workspace = true
+1581
View File
@@ -0,0 +1,1581 @@
//! Terminal state formatters: plain text, VT (with styles) and HTML.
//!
//! Port of `ZIG/src/terminal/formatter.zig`.
//!
//! Writers are `&mut dyn std::io::Write`; use the `format_string` helpers (or
//! write into a `Vec<u8>`) for in-memory output. Where Zig used a
//! `Discarding` writer to count bytes (for pin maps), the Rust port renders
//! into a small scratch buffer and measures it.
#![allow(clippy::too_many_arguments)]
use crate::{
highlight::pin_page_iterator,
hyperlink::HyperlinkId,
page::{Cell, ContentTag, Page, Wide},
page_list::{PageList, Pin},
point::{Coordinate, Tag},
screen::Screen,
selection::Selection,
size,
style::{Color, Style},
terminal::Terminal,
};
use ghostty_vt::{
charsets::{Charset, Slots},
color::{Palette, Rgb},
modes::ENTRIES,
};
use std::io::{self, Write};
type Result<T> = io::Result<T>;
/// Formats available.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
/// Plain text.
Plain,
/// Include VT sequences to preserve colors, styles, URLs, etc.
/// This is predominantly SGR sequences but may contain others as needed.
///
/// Note that for reference colors, like palette indices, this will
/// vary based on the formatter and you should see the docs. For example,
/// PageFormatter with VT will emit SGR sequences with palette indices,
/// not the color itself.
///
/// For VT, newlines will be emitted as `\r\n` so that the cursor properly
/// moves back to the beginning prior emitting follow-up lines.
Vt,
/// HTML output.
///
/// This will emit inline styles for as much styling as possible,
/// in the interest of simplicity and ease of editing. This isn't meant
/// to build the most beautiful or efficient HTML, but rather to be
/// stylistically correct.
///
/// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette
/// indices use CSS variables (var(--vt-palette-N)). The palette colors are
/// emitted by TerminalFormatter.Extra.palette as a <style> block if you
/// want to also include that. But if you only format a screen or lower,
/// the formatter doesn't have access to the current palette to render it.
///
/// Newlines are emitted as actual '\n' characters. Consumers should use
/// CSS white-space: pre or pre-wrap to preserve spacing and alignment.
Html,
}
impl Format {
pub fn styled(self) -> bool {
match self {
Format::Plain => false,
Format::Html | Format::Vt => true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodepointMap {
/// Unicode codepoint range to replace.
/// Asserts: range[0] <= range[1]
pub range: [u32; 2],
/// Replacement value for this range.
pub replacement: Replacement,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Replacement {
/// A single replacement codepoint.
Codepoint(u32),
/// A string to replace with.
String(String),
}
/// Common encoding options regardless of what exact formatter is used.
#[derive(Debug, Clone)]
pub struct Options {
/// The format to emit.
pub emit: Format,
/// Whether to unwrap soft-wrapped lines. If false, this will emit the
/// screen contents as it is rendered on the page in the given size.
pub unwrap: bool,
/// Trim trailing whitespace on lines with other text. Trailing blank
/// lines are always trimmed. This only affects trailing whitespace
/// on rows that have at least one other cell with text. Whitespace
/// is currently only space characters (0x20).
pub trim: bool,
/// Replace matching Unicode codepoints with some other values.
/// This will use the last matching range found in the list.
pub codepoint_map: Option<Vec<CodepointMap>>,
/// Set a background and foreground color to use for the "screen".
/// For styled formats, this will emit the proper sequences or styles.
pub background: Option<Rgb>,
pub foreground: Option<Rgb>,
/// If set, then styled formats in `emit` will use this palette to
/// emit colors directly as RGB. If this is None, styled formats will
/// still work but will use deferred palette styling (e.g. CSS variables
/// for HTML or the actual palette indexes for VT).
pub palette: Option<Palette>,
}
impl Options {
pub fn new(emit: Format) -> Options {
Options {
emit,
unwrap: false,
trim: true,
codepoint_map: Some(Vec::new()),
background: None,
foreground: None,
palette: None,
}
}
pub fn plain() -> Options {
Options::new(Format::Plain)
}
pub fn vt() -> Options {
Options::new(Format::Vt)
}
pub fn html() -> Options {
Options::new(Format::Html)
}
}
/// Maps byte positions in formatted output to PageList pins.
///
/// Used by formatters that operate on PageLists to track the source position
/// of each byte written. (Zig: `PinMap{alloc, map}`; here simply the vector to
/// append to.)
pub type PinMap<'a> = &'a mut Vec<Pin>;
fn hex(rgb: Rgb) -> String {
format!("{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)
}
fn invalid_cp() -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, "invalid codepoint")
}
fn clone_pin(p: Pin) -> Pin {
Pin {
node: p.node,
x: p.x,
y: p.y,
garbage: false,
}
}
/// Append `count` copies of the last pin in `map` (or the top-left of the
/// screen when empty).
fn map_pad_last_or_top(map: &mut Vec<Pin>, pages: &PageList, count: usize) {
let pin = match map.last() {
Some(last) => clone_pin(*last),
None => pages.get_top_left(Tag::Screen).expect("top left"),
};
map.extend(std::iter::repeat_n(pin, count));
}
/// Terminal formatter formats the active terminal screen.
///
/// This will always only emit data related to the currently active screen.
/// If you want to emit data for a specific screen (e.g. primary vs alt), then
/// switch to that screen in the terminal prior to using this.
///
/// If you want to emit data for all screens (a less common operation), then
/// you must create a no-content TerminalFormatter followed by multiple
/// explicit ScreenFormatter calls. This isn't a common operation so this
/// little extra work should be acceptable.
///
/// For styled formatting, this will emit the palette colors at the
/// beginning so that the output can be rendered properly according to
/// the current terminal state.
pub struct TerminalFormatter<'a> {
/// The terminal to format.
pub terminal: &'a Terminal,
/// The common options
pub opts: Options,
/// The content to include.
pub content: Content,
/// Extra stuff to emit, such as terminal modes, palette, cursor, etc.
/// This information is ONLY emitted when the format is "vt".
pub extra: TerminalExtra,
/// If non-null, then `map` will contain the Pin of every byte
/// byte written to the writer offset by the byte index.
///
/// Note that some emitted bytes may not correspond to any Pin, such as
/// the extra data around terminal state (palette, modes, etc.). For these,
/// we'll map it to the most previous pin so there is some continuity but
/// its an arbitrary choice.
///
/// Warning: there is a significant performance hit to track this
pub pin_map: Option<PinMap<'a>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalExtra {
/// Emit the palette using OSC 4 sequences.
pub palette: bool,
/// Emit terminal modes that differ from their defaults using CSI h/l
/// sequences. Defaults are according to the Ghostty defaults which
/// are generally match most terminal defaults. This will include
/// things like current screen, bracketed mode, mouse event reporting,
/// etc.
pub modes: bool,
/// Emit scrolling region state using DECSTBM and DECSLRM sequences.
pub scrolling_region: bool,
/// Emit tabstop positions by clearing all tabs (CSI 3 g) and setting
/// each configured tabstop with HTS.
pub tabstops: bool,
/// Emit the present working directory using OSC 7.
pub pwd: bool,
/// Emit keyboard modes such as ModifyOtherKeys using CSI > 4 m
/// sequences.
pub keyboard: bool,
/// The screen extras to emit. TerminalFormatter always only
/// emits data for the currently active screen. If you want to emit
/// data for all screens, you should manually construct a no-content
/// terminal formatter, followed by screen formatters.
pub screen: ScreenExtra,
}
impl TerminalExtra {
/// Emit nothing.
pub const NONE: TerminalExtra = TerminalExtra {
palette: false,
modes: false,
scrolling_region: false,
tabstops: false,
pwd: false,
keyboard: false,
screen: ScreenExtra::NONE,
};
/// Emit style-relevant information only such as palettes.
pub const STYLES: TerminalExtra = TerminalExtra {
palette: true,
modes: false,
scrolling_region: false,
tabstops: false,
pwd: false,
keyboard: false,
screen: ScreenExtra::STYLES,
};
/// Emit everything. This reconstructs the terminal state as closely
/// as possible.
pub const ALL: TerminalExtra = TerminalExtra {
palette: true,
modes: true,
scrolling_region: true,
tabstops: true,
pwd: true,
keyboard: true,
screen: ScreenExtra::ALL,
};
}
impl<'a> TerminalFormatter<'a> {
pub fn new(terminal: &'a Terminal, opts: Options) -> Self {
TerminalFormatter {
terminal,
opts,
content: Content::Selection(None),
extra: TerminalExtra::STYLES,
pin_map: None,
}
}
/// Format into a new `String` (convenience, no pin map is touched
/// beyond what `pin_map` asks for).
pub fn format_string(self) -> Result<String> {
let mut buf = Vec::new();
self.format(&mut buf)?;
String::from_utf8(buf).map_err(|_| invalid_cp())
}
pub fn format(mut self, writer: &mut dyn Write) -> Result<()> {
let pages = &self.terminal.screens.active().pages;
// Emit palette before screen content if using VT format. Technically
// we could do this after but this way if replay is slow for whatever
// reason the colors will be right right away.
if self.extra.palette {
let mut buf: Vec<u8> = Vec::new();
match self.opts.emit {
Format::Plain => {}
Format::Vt => {
for (i, rgb) in self.terminal.colors.palette.current.iter().enumerate() {
write!(
buf,
"\x1b]4;{};rgb:{:02x}/{:02x}/{:02x}\x1b\\",
i, rgb.r, rgb.g, rgb.b
)?;
}
}
// For HTML, we emit CSS to setup our palette variables.
Format::Html => {
buf.extend_from_slice(b"<style>:root{");
for (i, rgb) in self.terminal.colors.palette.current.iter().enumerate() {
write!(
buf,
"--vt-palette-{}: #{:02x}{:02x}{:02x};",
i, rgb.r, rgb.g, rgb.b
)?;
}
buf.extend_from_slice(b"}</style>");
}
}
writer.write_all(&buf)?;
// If we have a pin_map, add the bytes we wrote to map. Map all
// those bytes to the same pin. Use the top left to ensure the
// node is always properly initialized.
if self.opts.emit != Format::Plain
&& let Some(m) = self.pin_map.as_deref_mut()
{
let top = pages.get_top_left(Tag::Screen).expect("top left");
m.extend(std::iter::repeat_n(top, buf.len()));
}
}
// Emit terminal modes that differ from defaults. We probably have
// some modes we want to emit before and some after, but for now for
// simplicity we just emit them all before. If we make this more complex
// later we should add test cases for it.
if self.opts.emit == Format::Vt && self.extra.modes {
let mut buf: Vec<u8> = Vec::new();
for entry in ENTRIES {
let current = self.terminal.modes.get(entry.mode);
let default_val = self.terminal.modes.default.get(entry.mode);
if current != default_val {
let prefix = if entry.ansi { "" } else { "?" };
let suffix = if current { "h" } else { "l" };
write!(buf, "\x1b[{}{}{}", prefix, entry.value, suffix)?;
}
}
writer.write_all(&buf)?;
if let Some(m) = self.pin_map.as_deref_mut() {
let top = pages.get_top_left(Tag::Screen).expect("top left");
m.extend(std::iter::repeat_n(top, buf.len()));
}
}
let mut screen_formatter =
ScreenFormatter::new(self.terminal.screens.active(), self.opts.clone());
screen_formatter.content = self.content;
screen_formatter.extra = self.extra.screen;
screen_formatter.pin_map = self.pin_map.as_deref_mut();
screen_formatter.format(writer)?;
// Extra terminal state to emit after the screen contents so that
// it doesn't impact the emitted contents.
if self.opts.emit == Format::Vt {
let mut buf: Vec<u8> = Vec::new();
// Emit scrolling region using DECSTBM and DECSLRM
if self.extra.scrolling_region {
let region = &self.terminal.scrolling_region;
// DECSTBM: top and bottom margins (1-indexed)
// Only emit if not the full screen
if region.top != 0 || region.bottom != self.terminal.rows - 1 {
write!(buf, "\x1b[{};{}r", region.top + 1, region.bottom + 1)?;
}
// DECSLRM: left and right margins (1-indexed)
// Only emit if not the full width
if region.left != 0 || region.right != self.terminal.cols - 1 {
write!(buf, "\x1b[{};{}s", region.left + 1, region.right + 1)?;
}
}
// Emit tabstop positions
if self.extra.tabstops {
// Clear all tabs (CSI 3 g)
write!(buf, "\x1b[3g")?;
// Set each configured tabstop by moving cursor and using HTS
for col in 0..usize::from(self.terminal.cols) {
if self.terminal.tabstops.get(col) {
// Move cursor to the column (1-indexed)
write!(buf, "\x1b[{}G", col + 1)?;
// Set tab (HTS)
write!(buf, "\x1bH")?;
}
}
}
// Emit keyboard modes such as ModifyOtherKeys
if self.extra.keyboard {
// Only emit if modify_other_keys_2 is true
if self.terminal.flags.modify_other_keys_2 {
write!(buf, "\x1b[>4;2m")?;
}
}
// Emit present working directory using OSC 7
if self.extra.pwd {
let pwd = &self.terminal.pwd;
if !pwd.is_empty() {
buf.extend_from_slice(b"\x1b]7;");
buf.extend_from_slice(pwd);
buf.extend_from_slice(b"\x1b\\");
}
}
writer.write_all(&buf)?;
// If we have a pin_map, add the bytes we wrote to map.
if let Some(m) = self.pin_map.as_deref_mut() {
map_pad_last_or_top(m, pages, buf.len());
}
}
Ok(())
}
}
/// Screen formatter formats a single terminal screen (e.g. primary vs alt).
pub struct ScreenFormatter<'a> {
/// The screen to format.
pub screen: &'a Screen,
/// The common options
pub opts: Options,
/// The content to include.
pub content: Content,
/// Extra stuff to emit, such as cursor, style, hyperlinks, etc.
/// This information is ONLY emitted when the format is "vt".
pub extra: ScreenExtra,
/// If non-null, then `map` will contain the Pin of every byte
/// byte written to the writer offset by the byte index.
///
/// Note that some emitted bytes may not correspond to any Pin, such as
/// the extra data around screen state. For these, we'll map it to the
/// most previous pin so there is some continuity but its an arbitrary
/// choice.
///
/// Warning: there is a significant performance hit to track this
pub pin_map: Option<PinMap<'a>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Content {
/// Emit no content, only terminal state such as modes, palette, etc.
/// via extra.
None,
/// Emit the content specified by the selection. None for all.
/// The selection is inclusive on both ends.
Selection(Option<Selection>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScreenExtra {
/// Emit cursor position using CUP (CSI H).
pub cursor: bool,
/// Emit current SGR style state based on the cursor's active style_id.
/// This reconstructs the SGR attributes (bold, italic, colors, etc.) at
/// the cursor position.
pub style: bool,
/// Emit current hyperlink state using OSC 8 sequences.
/// This sets the active hyperlink based on cursor.hyperlink_id.
pub hyperlink: bool,
/// Emit character protection mode using DECSCA.
pub protection: bool,
/// Emit Kitty keyboard protocol state using CSI > u and CSI = sequences.
pub kitty_keyboard: bool,
/// Emit character set designations and invocations.
/// This includes G0-G3 designations (ESC ( ) * +) and GL/GR invocations.
pub charsets: bool,
}
impl ScreenExtra {
/// Emit nothing.
pub const NONE: ScreenExtra = ScreenExtra {
cursor: false,
style: false,
hyperlink: false,
protection: false,
kitty_keyboard: false,
charsets: false,
};
/// Emit style-relevant information only.
pub const STYLES: ScreenExtra = ScreenExtra {
cursor: false,
style: true,
hyperlink: true,
protection: false,
kitty_keyboard: false,
charsets: false,
};
/// Emit everything. This reconstructs the screen state as closely
/// as possible.
pub const ALL: ScreenExtra = ScreenExtra {
cursor: true,
style: true,
hyperlink: true,
protection: true,
kitty_keyboard: true,
charsets: true,
};
fn is_set(self) -> bool {
self != Self::NONE
}
}
impl<'a> ScreenFormatter<'a> {
pub fn new(screen: &'a Screen, opts: Options) -> Self {
ScreenFormatter {
screen,
opts,
content: Content::Selection(None),
extra: ScreenExtra::NONE,
pin_map: None,
}
}
/// Format into a new `String`.
pub fn format_string(self) -> Result<String> {
let mut buf = Vec::new();
self.format(&mut buf)?;
String::from_utf8(buf).map_err(|_| invalid_cp())
}
pub fn format(mut self, writer: &mut dyn Write) -> Result<()> {
match self.content {
Content::None => {}
Content::Selection(selection) => {
// Emit our pagelist contents according to our selection.
let mut list_formatter =
PageListFormatter::new(&self.screen.pages, self.opts.clone());
list_formatter.pin_map = self.pin_map.as_deref_mut();
if let Some(sel) = selection {
list_formatter.top_left = Some(sel.top_left(&self.screen.pages));
list_formatter.bottom_right = Some(sel.bottom_right(&self.screen.pages));
list_formatter.rectangle = sel.rectangle;
}
list_formatter.format(writer)?;
}
}
// Emit extra screen state after content if we care. The state has
// to be emitted after since some state such as cursor position and
// style are impacted by content rendering.
match self.opts.emit {
Format::Plain => return Ok(()),
Format::Vt => {
if !self.extra.is_set() {
return Ok(());
}
}
// HTML doesn't preserve any screen state because it has
// nothing to do with rendering.
Format::Html => return Ok(()),
}
let mut buf: Vec<u8> = Vec::new();
// Emit current SGR style state
if self.extra.style {
let cursor = &self.screen.cursor;
write!(buf, "{}", cursor.style.formatter_vt())?;
}
// Emit current hyperlink state using OSC 8
if self.extra.hyperlink
&& let Some(link) = &self.screen.cursor.hyperlink
{
// Start hyperlink with uri (and explicit id if present)
match &link.id {
HyperlinkId::Explicit(id) => {
buf.extend_from_slice(b"\x1b]8;id=");
buf.extend_from_slice(id);
buf.push(b';');
buf.extend_from_slice(&link.uri);
buf.extend_from_slice(b"\x1b\\");
}
HyperlinkId::Implicit(_) => {
buf.extend_from_slice(b"\x1b]8;;");
buf.extend_from_slice(&link.uri);
buf.extend_from_slice(b"\x1b\\");
}
}
}
// Emit character protection mode using DECSCA
if self.extra.protection && self.screen.cursor.protected {
// DEC protected mode
write!(buf, "\x1b[1\"q")?;
}
// Emit Kitty keyboard protocol state using CSI = u
if self.extra.kitty_keyboard {
let current_flags = self.screen.kitty_keyboard.current();
if current_flags.int() != ghostty_vt::kitty::key::Flags::DISABLED.int() {
write!(buf, "\x1b[={};1u", current_flags.int())?;
}
}
// Emit character set designations and invocations
if self.extra.charsets {
let charset = &self.screen.charset;
// Emit G0-G3 designations
for slot in [Slots::G0, Slots::G1, Slots::G2, Slots::G3] {
let cs = charset.charsets.get(slot);
if cs != Charset::Utf8 {
// Only emit non-default charsets
let intermediate = match slot {
Slots::G0 => '(',
Slots::G1 => ')',
Slots::G2 => '*',
Slots::G3 => '+',
};
let fin = match cs {
Charset::Ascii => 'B',
Charset::British => 'A',
Charset::DecSpecial => '0',
Charset::Utf8 => continue,
};
write!(buf, "\x1b{intermediate}{fin}")?;
}
}
// Emit GL invocation if not G0
if charset.gl != Slots::G0 {
let seq: &[u8] = match charset.gl {
Slots::G0 => unreachable!(),
Slots::G1 => b"\x0e", // SO - Shift Out
Slots::G2 => b"\x1bn", // LS2
Slots::G3 => b"\x1bo", // LS3
};
buf.extend_from_slice(seq);
}
// Emit GR invocation if not G2
if charset.gr != Slots::G2 {
let seq: &[u8] = match charset.gr {
Slots::G0 => unreachable!(), // GR can't be G0
Slots::G1 => b"\x1b~", // LS1R
Slots::G2 => unreachable!(),
Slots::G3 => b"\x1b|", // LS3R
};
buf.extend_from_slice(seq);
}
}
// Emit cursor position using CUP (CSI H)
if self.extra.cursor {
let cursor = &self.screen.cursor;
// CUP is 1-indexed
write!(buf, "\x1b[{};{}H", cursor.y + 1, cursor.x + 1)?;
}
writer.write_all(&buf)?;
// If we have a pin_map, map all the extra bytes to the same pin.
if let Some(m) = self.pin_map.as_deref_mut() {
map_pad_last_or_top(m, &self.screen.pages, buf.len());
}
Ok(())
}
}
/// PageList formatter formats multiple pages as represented by a PageList.
pub struct PageListFormatter<'a> {
/// The pagelist to format.
pub list: &'a PageList,
/// The common options
pub opts: Options,
/// The bounds of the PageList to format. The top left and bottom right
/// MUST be ordered properly.
pub top_left: Option<Pin>,
pub bottom_right: Option<Pin>,
/// If true, the boundaries define a rectangle selection where start_x
/// and end_x apply to every row, not just the first and last.
pub rectangle: bool,
/// If non-null, then `map` will contain the Pin of every byte
/// byte written to the writer offset by the byte index.
///
/// Warning: there is a significant performance hit to track this
pub pin_map: Option<PinMap<'a>>,
}
impl<'a> PageListFormatter<'a> {
pub fn new(list: &'a PageList, opts: Options) -> Self {
PageListFormatter {
list,
opts,
top_left: None,
bottom_right: None,
rectangle: false,
pin_map: None,
}
}
/// Format into a new `String`.
pub fn format_string(self) -> Result<String> {
let mut buf = Vec::new();
self.format(&mut buf)?;
String::from_utf8(buf).map_err(|_| invalid_cp())
}
pub fn format(mut self, writer: &mut dyn Write) -> Result<()> {
let tl = self
.top_left
.unwrap_or_else(|| self.list.get_top_left(Tag::Screen).expect("top left"));
let br = self.bottom_right.unwrap_or_else(|| {
self.list
.get_bottom_right(Tag::Screen)
.expect("bottom right")
});
// If we keep track of pins, we'll need this.
let mut point_map: Vec<Coordinate> = Vec::new();
let mut page_state: Option<TrailingState> = None;
for chunk in pin_page_iterator(self.list, tl, br) {
assert!(chunk.start < chunk.end);
assert!(chunk.end > 0);
let node = self.list.node(chunk.node).expect("chunk node");
let mut formatter = PageFormatter::new(&node.data, &self.opts);
formatter.start_y = chunk.start;
formatter.end_y = Some(chunk.end - 1);
formatter.trailing_state = page_state;
formatter.rectangle = self.rectangle;
// For rectangle selection, apply start_x and end_x to all chunks
if self.rectangle {
formatter.start_x = tl.x;
formatter.end_x = Some(br.x);
} else {
// Otherwise only on the first/last, respectively.
if chunk.node == tl.node {
formatter.start_x = tl.x;
}
if chunk.node == br.node {
formatter.end_x = Some(br.x);
}
}
// If we're tracking pins, then we setup a point map for the
// page formatter (cause it can't track pins). And then we convert
// this to pins later.
let tracking = self.pin_map.is_some();
if tracking {
point_map.clear();
formatter.point_map = Some(&mut point_map);
}
page_state = Some(formatter.format_with_state(writer)?);
// If we're tracking pins then grab our points and write them
// to our pin map.
if let Some(m) = self.pin_map.as_deref_mut() {
for coord in &point_map {
m.push(Pin {
node: chunk.node,
x: coord.x,
y: coord.y as size::CellCountInt,
garbage: false,
});
}
}
}
Ok(())
}
}
/// Trailing state. This is used to ensure that rows wrapped across
/// multiple pages are unwrapped properly, as well as other accounting
/// we may do in the future.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TrailingState {
pub rows: usize,
pub cells: usize,
}
impl TrailingState {
pub const EMPTY: TrailingState = TrailingState { rows: 0, cells: 0 };
}
/// Page formatter.
///
/// For styled formatting such as VT, this will emit references for palette
/// colors. If you want to capture the palette as-is at the type of formatting,
/// you'll have to emit the sequences for setting up the palette prior to
/// this formatting. (TODO: A function to do this)
pub struct PageFormatter<'a> {
/// The page to format.
pub page: &'a Page,
/// The common options
pub opts: &'a Options,
/// Start and end points within the page to format. If end x is not given
/// then it will be the full width. If end y is not given then it will be
/// the full height.
///
/// The start and end are both inclusive, so equal values will still
/// return a non-empty result (i.e. a single cell or row).
///
/// The start x is considered the X in the first row and end X is
/// X in the final row. This isn't a rectangle selection by default.
///
/// If start X falls on the second column of a wide character, then
/// the entire character will be included (as if you specified the
/// previous column).
pub start_x: size::CellCountInt,
pub start_y: size::CellCountInt,
pub end_x: Option<size::CellCountInt>,
pub end_y: Option<size::CellCountInt>,
/// If true, the start x/y and end x/y define a rectangle selection.
/// In this case, the boundaries will apply to every row, not just
/// the first and last.
pub rectangle: bool,
/// If non-null, then `map` will contain the x/y coordinate of every
/// byte written to the writer offset by the byte index.
///
/// The x/y coordinate will be the coordinates within the page.
///
/// Warning: there is a significant performance hit to track this
pub point_map: Option<&'a mut Vec<Coordinate>>,
/// The previous trailing state from the prior page. If you're iterating
/// over multiple pages this helps ensure that unwrapping and other
/// accounting works properly.
pub trailing_state: Option<TrailingState>,
}
impl<'a> PageFormatter<'a> {
/// Initializes a page formatter. Other options can be set directly on the
/// struct after initialization and before calling `format()`.
pub fn new(page: &'a Page, opts: &'a Options) -> Self {
PageFormatter {
page,
opts,
start_x: 0,
start_y: 0,
end_x: None,
end_y: None,
rectangle: false,
point_map: None,
trailing_state: None,
}
}
/// Format into a new `String`.
pub fn format_string(self) -> Result<String> {
let mut buf = Vec::new();
self.format(&mut buf)?;
String::from_utf8(buf).map_err(|_| invalid_cp())
}
pub fn format(self, writer: &mut dyn Write) -> Result<()> {
self.format_with_state(writer).map(|_| ())
}
fn map_push_n(&mut self, coord: Coordinate, n: usize) {
if let Some(m) = self.point_map.as_deref_mut() {
m.extend(std::iter::repeat_n(coord, n));
}
}
pub fn format_with_state(mut self, writer: &mut dyn Write) -> Result<TrailingState> {
let page = self.page;
let opts = self.opts;
let mut blank_rows: usize = 0;
let mut blank_cells: usize = 0;
// Continue our prior trailing state if we have it, but only if we're
// starting from the beginning (start_y and start_x are both 0).
// If a non-zero start position is specified, ignore trailing state.
if let Some(state) = self.trailing_state
&& self.start_y == 0
&& self.start_x == 0
{
blank_rows = state.rows;
blank_cells = state.cells;
}
let ret = |rows: usize, cells: usize| TrailingState { rows, cells };
// Setup our starting column and perform some validation for overflows.
// Note: start_x only applies to the first row, end_x only applies to the last row.
let start_x = self.start_x;
if start_x >= page.size.cols {
return Ok(ret(blank_rows, blank_cells));
}
let end_x_unclamped = self.end_x.unwrap_or(page.size.cols - 1);
let mut end_x = end_x_unclamped.min(page.size.cols - 1);
// Setup our starting row and perform some validation for overflows.
let start_y = self.start_y;
if start_y >= page.size.rows {
return Ok(ret(blank_rows, blank_cells));
}
let end_y_unclamped = self.end_y.unwrap_or(page.size.rows - 1);
if start_y > end_y_unclamped {
return Ok(ret(blank_rows, blank_cells));
}
let mut end_y = end_y_unclamped.min(page.size.rows - 1);
// Edge case: if our end x/y falls on a spacer head AND we're unwrapping,
// then we move the x/y to the start of the next row (if available).
if opts.unwrap && !self.rectangle {
let cells = page.get_cells(usize::from(end_y)).expect("row cells");
if cells[usize::from(end_x)].wide() == Wide::SpacerHead {
// Move to next row if available
//
// TODO: if unavailable, we should add to our trailing state
//
// so the pagelist formatter can be aware and maybe add
// another page
if end_y < page.size.rows - 1 {
end_y += 1;
end_x = 0;
}
}
}
// If we only have a single row, validate that start_x <= end_x
if start_y == end_y && start_x > end_x {
return Ok(ret(blank_rows, blank_cells));
}
// Wrap HTML output in monospace font styling
match opts.emit {
Format::Plain => {}
Format::Html => {
let mut header: Vec<u8> = Vec::new();
// Monospace and whitespace preserving
header.extend_from_slice(b"<div style=\"font-family: monospace; white-space: pre;");
// Background/foreground colors
if let Some(bg) = opts.background {
write!(header, "background-color: #{};", hex(bg))?;
}
if let Some(fg) = opts.foreground {
write!(header, "color: #{};", hex(fg))?;
}
header.extend_from_slice(b"\">");
writer.write_all(&header)?;
self.map_push_n(Coordinate { x: 0, y: 0 }, header.len());
}
Format::Vt => {
// OSC 10 sets foreground color, OSC 11 sets background color
let mut header: Vec<u8> = Vec::new();
if let Some(fg) = opts.foreground {
write!(
header,
"\x1b]10;rgb:{:02x}/{:02x}/{:02x}\x1b\\",
fg.r, fg.g, fg.b
)?;
}
if let Some(bg) = opts.background {
write!(
header,
"\x1b]11;rgb:{:02x}/{:02x}/{:02x}\x1b\\",
bg.r, bg.g, bg.b
)?;
}
writer.write_all(&header)?;
self.map_push_n(Coordinate { x: 0, y: 0 }, header.len());
}
}
// Our style for non-plain formats
let mut style = Style::default();
// Track hyperlink state for HTML output. We need to close </a> tags
// when the hyperlink changes or ends.
let mut current_hyperlink_id: Option<u16> = None;
let mut scratch: Vec<u8> = Vec::new();
'rows: for y in start_y..=end_y {
let row = *page.get_row(usize::from(y)).expect("row");
let cells: &[Cell] = page.get_cells(usize::from(y)).expect("cells");
// Determine the x range for this row
// - First row: start_x to end of row (or end_x if single row)
// - Last row: start of row to end_x
// - Middle rows: full width
let (cells_subset, row_start_x) = {
// The end is always straightforward
let row_end_x: size::CellCountInt = if self.rectangle || y == end_y {
end_x + 1
} else {
page.size.cols
};
// The first we have to check if our start X falls on the
// tail of a wide character.
let row_start_x: size::CellCountInt =
if start_x > 0 && (self.rectangle || y == start_y) {
match cells[usize::from(start_x)].wide() {
// Include the prior cell to get the full wide char
Wide::SpacerTail => start_x - 1,
// If we're a spacer head on our first row then we
// skip this whole row.
Wide::SpacerHead => continue 'rows,
Wide::Narrow | Wide::Wide => start_x,
}
} else {
0
};
(
&cells[usize::from(row_start_x)..usize::from(row_end_x)],
row_start_x,
)
};
// If this row is blank, accumulate to avoid a bunch of extra
// work later. If it isn't blank, make sure we dump all our
// blanks.
if !Cell::has_text_any(cells_subset) {
blank_rows += 1;
continue;
}
if blank_rows > 0 {
// Reset style before emitting newlines to prevent background
// colors from bleeding into the next line's leading cells.
if !style.is_default() {
self.format_style_close(writer)?;
style = Style::default();
}
let sequence: &[u8] = match opts.emit {
// Plaintext just uses standard newlines because newlines
// on their own usually move the cursor back in anywhere
// you type plaintext.
Format::Plain => b"\n",
// VT uses \r\n because in a raw pty, \n alone doesn't
// guarantee moving the cursor back to column 0. \r
// makes it work for sure.
Format::Vt => b"\r\n",
// HTML uses just \n because HTML rendering will move
// the cursor back.
Format::Html => b"\n",
};
for _ in 0..blank_rows {
writer.write_all(sequence)?;
}
// \r and \n map to the row that ends with this newline.
// If we're continuing (trailing state) then this will be
// in a prior page, so we just map to the first row of this
// page.
if let Some(map) = self.point_map.as_deref_mut() {
let start = map.last().copied().unwrap_or(Coordinate { x: 0, y: 0 });
// The first one inherits the x value.
map.extend(std::iter::repeat_n(
Coordinate {
x: start.x,
y: start.y,
},
sequence.len(),
));
// All others have x = 0 since they reference their prior
// blank line.
for y_offset in 1..blank_rows {
map.extend(std::iter::repeat_n(
Coordinate {
x: 0,
y: start.y + y_offset as u32,
},
sequence.len(),
));
}
}
blank_rows = 0;
}
// If we're not wrapped, we always add a newline so after
// the row is printed we can add a newline.
if !row.wrap() || !opts.unwrap {
blank_rows += 1;
}
// If the row doesn't continue a wrap then we need to reset
// our blank cell count.
if !row.wrap_continuation() || !opts.unwrap {
blank_cells = 0;
}
// Go through each cell and print it
'cells: for (i, cell) in cells_subset.iter().enumerate() {
let x: size::CellCountInt = row_start_x + i as size::CellCountInt;
// Skip spacers. These happen naturally when wide characters
// are printed again on the screen (for well-behaved terminals!)
match cell.wide() {
Wide::Narrow | Wide::Wide => {}
Wide::SpacerHead | Wide::SpacerTail => continue,
}
// If we have a zero value, then we accumulate a counter. We
// only want to turn zero values into spaces if we have a non-zero
// char sometime later.
'blank: {
// If we're emitting styled output (not plaintext) and
// the cell has some kind of styling or is not empty
// then this isn't blank.
if opts.emit.styled() && (!cell.is_empty() || cell.has_styling()) {
break 'blank;
}
// Cells with no text are blank
if !cell.has_text() {
blank_cells += 1;
continue 'cells;
}
// Trailing spaces are blank. We know it is trailing
// because if we get a non-empty cell later we'll
// fill the blanks.
if cell.codepoint() == ' ' as u32 && opts.trim {
blank_cells += 1;
continue 'cells;
}
}
// This cell is not blank. If we have accumulated blank cells
// then we want to emit them now.
if blank_cells > 0 {
scratch.clear();
scratch.resize(blank_cells, b' ');
writer.write_all(&scratch)?;
if let Some(map) = self.point_map.as_deref_mut() {
// Map each blank cell to its coordinate. Blank cells can span
// multiple rows if they carry over from wrap continuation.
let mut remaining_blanks = blank_cells;
let mut blank_x = x;
let mut blank_y = y;
while remaining_blanks > 0 {
if blank_x > 0 {
// We have space in this row
blank_x -= 1;
} else if blank_y > 0 {
// Wrap to previous row
blank_y -= 1;
blank_x = page.size.cols - 1;
} else {
// Can't go back further, just use (0, 0)
blank_x = 0;
blank_y = 0;
}
map.push(Coordinate {
x: blank_x,
y: u32::from(blank_y),
});
remaining_blanks -= 1;
}
}
blank_cells = 0;
}
'style: {
// If we aren't emitting styled output then we don't
// have to worry about styles.
if !opts.emit.styled() {
break 'style;
}
// Get our cell style.
let cell_style = self.cell_style(*cell);
// If the style hasn't changed, don't bloat output.
if cell_style.eql(style) {
break 'style;
}
// If we had a previous style, we need to close it,
// because we've confirmed we have some new style
// (which is maybe default).
if !style.is_default() {
match opts.emit {
Format::Html => self.format_style_close(writer)?,
// For VT, we only close if we're switching to a default
// style because any non-default style will emit
// a \x1b[0m as the start of a VT coloring sequence.
Format::Vt => {
if cell_style.is_default() {
self.format_style_close(writer)?;
}
}
// Unreachable because of the styled() check at the
// top of this block.
Format::Plain => unreachable!(),
}
}
// At this point, we can copy our style over
style = cell_style;
// If we're just the default style now, we're done.
if cell_style.is_default() {
break 'style;
}
// New style, emit it.
scratch.clear();
self.format_style_open(&mut scratch, &style)?;
writer.write_all(&scratch)?;
// If we have a point map, we map the style to
// this cell.
self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len());
}
// Hyperlink state
'hyperlink: {
// We currently only emit hyperlinks for HTML. In the
// future we can support emitting OSC 8 hyperlinks for
// VT output as well.
if opts.emit != Format::Html {
break 'hyperlink;
}
// Get the hyperlink ID. This ID is our internal ID,
// not necessarily the OSC8 ID.
let link_id_: Option<u16> = if cell.hyperlink() {
page.cell_index(usize::from(x), usize::from(y))
.ok()
.and_then(|i| page.lookup_hyperlink(i))
} else {
None
};
// If our hyperlink IDs match (even null) then we have
// identical hyperlink state and we do nothing.
if current_hyperlink_id == link_id_ {
break 'hyperlink;
}
// If our prior hyperlink ID was non-null, we need to
// close it because the ID has changed.
if current_hyperlink_id.is_some() {
self.format_hyperlink_close(writer)?;
current_hyperlink_id = None;
}
// Set our current hyperlink ID
let Some(link_id) = link_id_ else {
break 'hyperlink;
};
current_hyperlink_id = Some(link_id);
// Emit the opening hyperlink tag
let uri: Vec<u8> = page
.hyperlink_set
.get(link_id)
.map(|l| l.uri.clone())
.unwrap_or_default();
scratch.clear();
self.format_hyperlink_open(&mut scratch, &uri)?;
writer.write_all(&scratch)?;
// If we have a point map, we map the hyperlink to
// this cell.
self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len());
}
match cell.content_tag() {
// We combine codepoint and graphemes because both have
// shared style handling.
ContentTag::Codepoint | ContentTag::CodepointGrapheme => {
scratch.clear();
self.write_cell(&mut scratch, *cell, x, y)?;
writer.write_all(&scratch)?;
// If we have a point map, all codepoints map to this
// cell.
self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len());
}
// Cells with only background color (no text). Emit a space
// with the appropriate background color SGR sequence.
ContentTag::BgColorPalette | ContentTag::BgColorRgb => {
writer.write_all(b" ")?;
self.map_push_n(Coordinate { x, y: u32::from(y) }, 1);
}
}
}
}
// If the style is non-default, we need to close our style tag.
if !style.is_default() {
self.format_style_close(writer)?;
}
// Close any open hyperlink for HTML output
if current_hyperlink_id.is_some() {
self.format_hyperlink_close(writer)?;
}
// Close the monospace wrapper for HTML output
if opts.emit == Format::Html {
let closing = b"</div>";
writer.write_all(closing)?;
if let Some(map) = self.point_map.as_deref_mut() {
let last = map.last().copied().unwrap_or(Coordinate { x: 0, y: 0 });
map.extend(std::iter::repeat_n(last, closing.len()));
}
}
Ok(ret(blank_rows, blank_cells))
}
fn write_cell(
&self,
out: &mut Vec<u8>,
cell: Cell,
x: size::CellCountInt,
y: size::CellCountInt,
) -> Result<()> {
// Blank cells get an empty space that isn't replaced by anything
// because it isn't really a space. We do this so that formatting
// is preserved if we're emitting styles.
if !cell.has_text() {
out.push(b' ');
return Ok(());
}
self.write_codepoint_with_replacement(out, cell.codepoint())?;
if cell.content_tag() == ContentTag::CodepointGrapheme {
let idx = self
.page
.cell_index(usize::from(x), usize::from(y))
.map_err(|_| invalid_cp())?;
for &cp in self.page.lookup_grapheme(idx).expect("grapheme") {
self.write_codepoint_with_replacement(out, cp)?;
}
}
Ok(())
}
fn write_codepoint_with_replacement(&self, out: &mut Vec<u8>, codepoint: u32) -> Result<()> {
// Search for our replacement
let r: Option<&Replacement> = self.opts.codepoint_map.as_ref().and_then(|map| {
map.iter()
.rev()
.find(|m| m.range[0] <= codepoint && codepoint <= m.range[1])
.map(|m| &m.replacement)
});
// If no replacement, write it directly.
let Some(r) = r else {
return self.write_codepoint(out, codepoint);
};
match r {
Replacement::Codepoint(v) => self.write_codepoint(out, *v),
Replacement::String(s) => {
for c in s.chars() {
self.write_codepoint(out, c as u32)?;
}
Ok(())
}
}
}
fn write_codepoint(&self, out: &mut Vec<u8>, codepoint: u32) -> Result<()> {
let push = |out: &mut Vec<u8>, cp: u32| -> Result<()> {
let c = char::from_u32(cp).ok_or_else(invalid_cp)?;
let mut b = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
Ok(())
};
match self.opts.emit {
Format::Plain | Format::Vt => push(out, codepoint),
Format::Html => match codepoint {
0x3C => {
out.extend_from_slice(b"&lt;");
Ok(())
}
0x3E => {
out.extend_from_slice(b"&gt;");
Ok(())
}
0x26 => {
out.extend_from_slice(b"&amp;");
Ok(())
}
0x22 => {
out.extend_from_slice(b"&quot;");
Ok(())
}
0x27 => {
out.extend_from_slice(b"&#39;");
Ok(())
}
_ => {
// For HTML, emit ASCII (< 0x80) directly, but encode
// all non-ASCII as numeric entities to avoid encoding
// detection issues (fixes #9426). We can't set the
// meta tag because we emit partial HTML so this ensures
// proper unicode handling.
if codepoint < 0x80 {
push(out, codepoint)
} else {
write!(out, "&#{codepoint};")
}
}
},
}
}
/// Returns the style for the given cell. If there is no styling this
/// will return the default style.
fn cell_style(&self, cell: Cell) -> Style {
match cell.content_tag() {
ContentTag::Codepoint | ContentTag::CodepointGrapheme => {
if cell.has_styling() {
self.page.style(cell)
} else {
Style::default()
}
}
ContentTag::BgColorPalette => Style {
bg_color: Color::Palette(cell.color_palette()),
..Style::default()
},
ContentTag::BgColorRgb => Style {
bg_color: Color::Rgb(cell.color_rgb()),
..Style::default()
},
}
}
fn format_style_open(&self, out: &mut Vec<u8>, style: &Style) -> Result<()> {
match self.opts.emit {
Format::Plain => unreachable!(),
Format::Vt => {
let mut formatter = style.formatter_vt();
formatter.palette = self.opts.palette.as_ref();
write!(out, "{formatter}")
}
// We use `display: inline` so that the div doesn't impact
// layout since we're primarily using it as a CSS wrapper.
Format::Html => {
let mut formatter = style.formatter_html();
formatter.palette = self.opts.palette.as_ref();
write!(out, "<div style=\"display: inline;{formatter}\">")
}
}
}
fn write_close_str(&mut self, writer: &mut dyn Write, s: &[u8]) -> Result<()> {
writer.write_all(s)?;
if let Some(m) = self.point_map.as_deref_mut() {
debug_assert!(m.capacity() > 0);
let last = m.last().copied().unwrap_or(Coordinate { x: 0, y: 0 });
m.extend(std::iter::repeat_n(last, s.len()));
}
Ok(())
}
fn format_style_close(&mut self, writer: &mut dyn Write) -> Result<()> {
let s: &[u8] = match self.opts.emit {
Format::Plain => return Ok(()),
Format::Vt => b"\x1b[0m",
Format::Html => b"</div>",
};
self.write_close_str(writer, s)
}
fn format_hyperlink_open(&self, out: &mut Vec<u8>, uri: &[u8]) -> Result<()> {
match self.opts.emit {
Format::Plain | Format::Vt => unreachable!(),
Format::Html => {
out.extend_from_slice(b"<a href=\"");
for &byte in uri {
self.write_codepoint(out, u32::from(byte))?;
}
out.extend_from_slice(b"\">");
Ok(())
}
}
}
fn format_hyperlink_close(&mut self, writer: &mut dyn Write) -> Result<()> {
let s: &[u8] = match self.opts.emit {
Format::Html => b"</a>",
Format::Plain | Format::Vt => return Ok(()),
};
self.write_close_str(writer, s)
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,53 @@
//! Ported formatter.zig tests, split by Zig source line range:
//! p1 = "Page plain ..." (Zig lines 1580-3287), p2 = "Page VT ...", "PageList ..."
//! and "TerminalFormatter ..." (3288-4458), p3 = "Screen ...", "Terminal vt ...",
//! "Page html ..." up to "Page VT style reset properly closes styles"
//! (4459-5617), p4 = "Page codepoint_map ..." onwards (5618-end).
#![allow(dead_code, clippy::needless_range_loop)]
use super::*;
use crate::{
page::Page,
page_list::Pin,
point::{Coordinate, Point},
terminal::{Options as TermOptions, Terminal},
};
mod p1;
mod p2;
mod p3;
mod p4;
/// Zig `Terminal.init(alloc, .{ .cols, .rows })`.
pub(super) fn term(cols: u16, rows: u16) -> Terminal {
Terminal::new(TermOptions::new(cols, rows)).unwrap()
}
/// Zig `var s = t.vtStream(); try s.nextSlice(bytes)` (fresh stream per call;
/// use `t.vt_stream()` directly if parser state must persist).
pub(super) fn feed(t: &mut Terminal, bytes: &[u8]) {
let mut s = t.vt_stream();
s.next_slice(bytes).unwrap();
}
/// Zig `&pages.pages.last.?.data`.
pub(super) fn last_page(t: &Terminal) -> &Page {
let pages = &t.screen().pages;
&pages.node(pages.last.unwrap()).unwrap().data
}
/// Zig `&pages.pages.first.?.data`.
pub(super) fn first_page(t: &Terminal) -> &Page {
let pages = &t.screen().pages;
&pages.node(pages.first.unwrap()).unwrap().data
}
/// Zig `pages.pages.first.?.data.capacity.rows`.
pub(super) fn first_page_rows(t: &Terminal) -> u16 {
first_page(t).capacity.rows
}
/// Format a whole `PageFormatter` into a string, returning the output.
pub(super) fn fmt_str(f: PageFormatter<'_>) -> String {
f.format_string().unwrap()
}
@@ -0,0 +1,589 @@
//! "Page plain ..." tests (formatter.zig lines 1580-3287).
use super::*;
struct Out {
s: String,
st: TrailingState,
map: Vec<Coordinate>,
}
/// Runs a PageFormatter over the last page with a point map attached.
fn run(t: &Terminal, opts: &Options, cfg: impl FnOnce(&mut PageFormatter<'_>)) -> Out {
let page = last_page(t);
let mut map = Vec::new();
let mut buf = Vec::new();
let st = {
let mut f = PageFormatter::new(page, opts);
f.point_map = Some(&mut map);
cfg(&mut f);
f.format_with_state(&mut buf).unwrap()
};
Out {
s: String::from_utf8(buf).unwrap(),
st,
map,
}
}
fn c(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
/// `n` consecutive cells on row `y` starting at column `x0`.
fn span(x0: u16, n: u16, y: u32) -> Vec<Coordinate> {
(0..n).map(|i| c(x0 + i, y)).collect()
}
fn setup(cols: u16, rows: u16, input: &str) -> Terminal {
let mut t = term(cols, rows);
feed(&mut t, input.as_bytes());
// Verify we have only a single page
let pages = &t.screen().pages;
assert!(pages.first.is_some());
assert_eq!(pages.first, pages.last);
t
}
fn unwrap_opts() -> Options {
Options {
unwrap: true,
..Options::plain()
}
}
#[test]
fn page_plain_single_line() {
let t = setup(80, 24, "hello, world");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello, world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 12));
assert_eq!(o.map, span(0, 12, 0));
}
#[test]
fn page_plain_single_line_soft_wrapped_unwrapped() {
let t = setup(3, 5, "hello!");
let o = run(&t, &unwrap_opts(), |_| {});
assert_eq!(o.s, "hello!");
let mut e = span(0, 3, 0);
e.extend(span(0, 3, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_single_wide_char() {
let t = setup(80, 24, "1A⚡");
let page = last_page(&t);
let opts = Options::plain();
// Full string
let o = run(&t, &opts, |_| {});
assert_eq!(o.s, "1A⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
assert_eq!(o.map.len(), o.s.len());
for i in 2..o.s.len() {
assert_eq!(o.map[i], c(2, 0));
}
// Wide only (from start)
let o = run(&t, &opts, |f| f.start_x = 2);
assert_eq!(o.s, "⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
assert_eq!(o.map, vec![c(2, 0); o.s.len()]);
// Wide only (from tail)
let o = run(&t, &opts, |f| f.start_x = 3);
assert_eq!(o.s, "⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
assert_eq!(o.map, vec![c(2, 0); o.s.len()]);
}
#[test]
fn page_plain_single_wide_char_soft_wrapped_unwrapped() {
let t = setup(3, 24, "1A⚡");
let page = last_page(&t);
let opts = unwrap_opts();
// Full string
let o = run(&t, &opts, |_| {});
assert_eq!(o.s, "1A⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
assert_eq!(o.map.len(), o.s.len());
for i in 2..o.s.len() {
assert_eq!(o.map[i], c(0, 1));
}
// Full string (ending on spacer head)
let o = run(&t, &opts, |f| {
f.end_x = Some(2);
f.end_y = Some(0);
});
assert_eq!(o.s, "1A⚡");
assert_eq!(o.map.len(), o.s.len());
for i in 2..o.s.len() {
assert_eq!(o.map[i], c(0, 1));
}
// Wide only (from start)
let o = run(&t, &opts, |f| f.start_x = 2);
assert_eq!(o.s, "⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
assert_eq!(o.map, vec![c(0, 1); o.s.len()]);
// Wide only (from tail). In Zig the formatter carried start_x = 2 over
// from the previous block; start_y = 1, start_x = 1.
let o = run(&t, &opts, |f| {
f.start_y = 1;
f.start_x = 1;
});
assert_eq!(o.s, "⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
assert_eq!(o.map, vec![c(0, 1); o.s.len()]);
}
#[test]
fn page_plain_multiline() {
let t = setup(80, 24, "hello\r\nworld");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello\nworld");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0)); // \n
e.extend(span(0, 5, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_multiline_rectangle() {
let t = setup(80, 24, "hello\r\nworld");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_x = 1;
f.end_x = Some(3);
f.rectangle = true;
});
assert_eq!(o.s, "ell\norl");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, 0);
let mut e = span(1, 3, 0);
e.push(c(3, 0)); // \n
e.extend(span(1, 3, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_multi_blank_lines() {
let t = setup(80, 24, "hello\r\n\r\n\r\nworld");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello\n\n\nworld");
assert_eq!(o.st.rows, usize::from(page.size.rows - 3));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0)); // \n after row 0
e.push(c(0, 1)); // \n after blank row 1
e.push(c(0, 2)); // \n after blank row 2
e.extend(span(0, 5, 3));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_trailing_blank_lines() {
let t = setup(80, 24, "hello\r\nworld\r\n\r\n");
let page = last_page(&t);
// We expect there to be no trailing newlines because we can't
// differentiate trailing blank lines as being meaningful because the
// page formatter can't see the cursor position.
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello\nworld");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0));
e.extend(span(0, 5, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_trailing_whitespace() {
let t = setup(80, 24, "hello \r\nworld ");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello\nworld");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0));
e.extend(span(0, 5, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_trailing_whitespace_no_trim() {
let t = setup(80, 24, "hello \r\nworld ");
let page = last_page(&t);
let opts = Options {
trim: false,
..Options::plain()
};
let o = run(&t, &opts, |_| {});
assert_eq!(o.s, "hello \nworld ");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 7));
let mut e = span(0, 8, 0);
e.push(c(7, 0));
e.extend(span(0, 7, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_with_prior_trailing_state_rows() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.trailing_state = Some(TrailingState { rows: 2, cells: 0 });
});
assert_eq!(o.s, "\n\nhello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = vec![c(0, 0), c(0, 1)];
e.extend(span(0, 5, 0));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_with_prior_trailing_state_cells_no_wrapped_line() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.trailing_state = Some(TrailingState { rows: 0, cells: 3 });
});
// Blank cells are reset when row is not a wrap continuation
assert_eq!(o.s, "hello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
assert_eq!(o.map, span(0, 5, 0));
}
#[test]
fn page_plain_with_prior_trailing_state_cells_with_wrap_continuation() {
let mut t = setup(80, 24, "world");
// Surgically modify the first row to be a wrap continuation
{
let pages = &mut t.screen_mut().pages;
let id = pages.last.unwrap();
pages
.node_mut(id)
.unwrap()
.data
.get_row_mut(0)
.unwrap()
.set_wrap_continuation(true);
}
let page = last_page(&t);
let o = run(&t, &unwrap_opts(), |f| {
f.trailing_state = Some(TrailingState { rows: 0, cells: 3 });
});
// Blank cells are preserved when row is a wrap continuation with unwrap enabled
assert_eq!(o.s, " world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
// 3 spaces from prior trailing state (can't go back beyond (0,0)) + "world"
let mut e = vec![c(0, 0); 3];
e.extend(span(0, 5, 0));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_soft_wrapped_without_unwrap() {
let t = setup(10, 24, "hello world test");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
// Without unwrap, wrapped lines show as separate lines
assert_eq!(o.s, "hello worl\nd test");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
let mut e = span(0, 10, 0);
e.push(c(9, 0)); // \n
e.extend(span(0, 6, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_soft_wrapped_with_unwrap() {
let t = setup(10, 24, "hello world test");
let page = last_page(&t);
let o = run(&t, &unwrap_opts(), |_| {});
// With unwrap, wrapped lines are joined together
assert_eq!(o.s, "hello world test");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
let mut e = span(0, 10, 0);
e.extend(span(0, 6, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_soft_wrapped_3_lines_without_unwrap() {
let t = setup(10, 24, "hello world this is a test");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello worl\nd this is\na test");
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
let mut e = span(0, 10, 0);
e.push(c(9, 0)); // \n
e.extend(span(0, 9, 1));
e.push(c(8, 1)); // \n
e.extend(span(0, 6, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_soft_wrapped_3_lines_with_unwrap() {
let t = setup(10, 24, "hello world this is a test");
let page = last_page(&t);
let o = run(&t, &unwrap_opts(), |_| {});
assert_eq!(o.s, "hello world this is a test");
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
let mut e = span(0, 10, 0);
e.extend(span(0, 10, 1));
e.extend(span(0, 6, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_y_subset() {
let t = setup(80, 24, "hello\r\nworld\r\ntest");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.start_y = 1);
assert_eq!(o.s, "world\ntest");
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
let mut e = span(0, 5, 1);
e.push(c(4, 1)); // \n
e.extend(span(0, 4, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_end_y_subset() {
let t = setup(80, 24, "hello\r\nworld\r\ntest");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.end_y = Some(1));
assert_eq!(o.s, "hello\nworld");
assert_eq!(o.st.rows, 1);
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0)); // \n
e.extend(span(0, 5, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_y_and_end_y_range() {
let t = setup(80, 24, "hello\r\nworld\r\ntest\r\nfoo");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_y = 1;
f.end_y = Some(2);
});
assert_eq!(o.s, "world\ntest");
assert_eq!(o.st.rows, 1);
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
let mut e = span(0, 5, 1);
e.push(c(4, 1)); // \n
e.extend(span(0, 4, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_y_out_of_bounds() {
let t = setup(80, 24, "hello");
let o = run(&t, &Options::plain(), |f| f.start_y = 30);
assert_eq!(o.s, "");
assert_eq!(o.st.rows, 0);
assert_eq!(o.st.cells, 0);
assert_eq!(o.map.len(), 0);
}
#[test]
fn page_plain_end_y_greater_than_rows() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.end_y = Some(30));
// Should clamp to page.size.rows and work normally
assert_eq!(o.s, "hello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
assert_eq!(o.map, span(0, 5, 0));
}
#[test]
fn page_plain_end_y_less_than_start_y() {
let t = setup(80, 24, "hello");
let o = run(&t, &Options::plain(), |f| {
f.start_y = 5;
f.end_y = Some(2);
});
assert_eq!(o.s, "");
assert_eq!(o.st.rows, 0);
assert_eq!(o.st.cells, 0);
assert_eq!(o.map.len(), 0);
}
#[test]
fn page_plain_start_x_on_first_row_only() {
let t = setup(80, 24, "hello world");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.start_x = 6);
assert_eq!(o.s, "world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 11));
assert_eq!(o.map, span(6, 5, 0));
}
#[test]
fn page_plain_end_x_on_last_row_only() {
let t = setup(80, 24, "first line\r\nsecond line\r\nthird line");
let o = run(&t, &Options::plain(), |f| {
f.end_y = Some(2);
f.end_x = Some(4);
});
assert_eq!(o.s, "first line\nsecond line\nthird");
assert_eq!(o.st.rows, 1);
assert_eq!(o.st.cells, 0);
let mut e = span(0, 10, 0);
e.push(c(9, 0)); // \n
e.extend(span(0, 11, 1));
e.push(c(10, 1)); // \n
e.extend(span(0, 5, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_x_and_end_x_multiline() {
let t = setup(80, 24, "hello world\r\ntest case\r\nfoo bar");
let o = run(&t, &Options::plain(), |f| {
f.start_x = 6;
f.end_y = Some(2);
f.end_x = Some(2);
});
// First row: "world" (start_x=6 to end of row)
// Second row: "test case" (full row)
// Third row: "foo" (start to end_x=2, inclusive)
assert_eq!(o.s, "world\ntest case\nfoo");
assert_eq!(o.st.rows, 1);
assert_eq!(o.st.cells, 0);
let mut e = span(6, 5, 0);
e.push(c(10, 0)); // \n
e.extend(span(0, 9, 1));
e.push(c(8, 1)); // \n
e.extend(span(0, 3, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_x_out_of_bounds() {
let t = setup(80, 24, "hello");
let o = run(&t, &Options::plain(), |f| f.start_x = 100);
assert_eq!(o.s, "");
assert_eq!(o.st.rows, 0);
assert_eq!(o.st.cells, 0);
assert_eq!(o.map.len(), 0);
}
#[test]
fn page_plain_end_x_greater_than_cols() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.end_x = Some(100));
assert_eq!(o.s, "hello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
assert_eq!(o.map, span(0, 5, 0));
}
#[test]
fn page_plain_end_x_less_than_start_x_single_row() {
let t = setup(80, 24, "hello");
let o = run(&t, &Options::plain(), |f| {
f.start_x = 10;
f.end_y = Some(0);
f.end_x = Some(5);
});
assert_eq!(o.s, "");
assert_eq!(o.st.rows, 0);
assert_eq!(o.st.cells, 0);
assert_eq!(o.map.len(), 0);
}
#[test]
fn page_plain_start_y_non_zero_ignores_trailing_state() {
let t = setup(80, 24, "hello\r\nworld");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_y = 1;
f.trailing_state = Some(TrailingState { rows: 5, cells: 10 });
});
// Should NOT output the 5 newlines from trailing_state because start_y is non-zero
assert_eq!(o.s, "world");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
assert_eq!(o.map, span(0, 5, 1));
}
#[test]
fn page_plain_start_x_non_zero_ignores_trailing_state() {
let t = setup(80, 24, "hello world");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_x = 6;
f.trailing_state = Some(TrailingState { rows: 2, cells: 8 });
});
// Should NOT output the 2 newlines or 8 spaces from trailing_state
assert_eq!(o.s, "world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 11));
assert_eq!(o.map, span(6, 5, 0));
}
#[test]
fn page_plain_start_y_and_start_x_zero_uses_trailing_state() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_y = 0;
f.start_x = 0;
f.trailing_state = Some(TrailingState { rows: 2, cells: 0 });
});
// SHOULD output the 2 newlines from trailing_state
assert_eq!(o.s, "\n\nhello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = vec![c(0, 0), c(0, 1)];
e.extend(span(0, 5, 0));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_single_line_with_styling() {
let t = setup(80, 24, "hello, \x1b[1mworld\x1b[0m");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello, world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 12));
assert_eq!(o.map, span(0, 12, 0));
}
@@ -0,0 +1,686 @@
use super::*;
/// Format the last page of `t` with a page formatter and a point map.
fn page_fmt(t: &Terminal, opts: &Options) -> (String, Vec<Coordinate>) {
let mut point_map = Vec::new();
let mut f = PageFormatter::new(last_page(t), opts);
f.point_map = Some(&mut point_map);
let out = f.format_string().unwrap();
(out, point_map)
}
fn trim_start<'a>(s: &'a str, chars: &[char]) -> &'a str {
s.trim_start_matches(chars)
}
fn coord(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
#[test]
fn page_vt_single_line_plain_text() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(output, "hello");
assert_eq!(point_map.len(), output.len());
for i in 0..5 {
assert_eq!(point_map[i], coord(i as u16, 0));
}
}
#[test]
fn page_vt_single_line_with_bold() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mhello\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[1mhello\x1b[0m");
assert_eq!(point_map.len(), output.len());
for i in 0..8 {
assert_eq!(point_map[i], coord(0, 0));
}
for i in 0..5 {
assert_eq!(point_map[8 + i], coord(i as u16, 0));
}
}
#[test]
fn page_vt_multiple_styles() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mhello \x1b[3mworld\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(
output,
"\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld\x1b[0m"
);
assert_eq!(point_map.len(), output.len());
}
#[test]
fn page_vt_with_foreground_color() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[31mred\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[38;5;1mred\x1b[0m");
assert_eq!(point_map.len(), output.len());
for i in 0..13 {
assert_eq!(point_map[i], coord(0, 0));
}
for i in 0..3 {
assert_eq!(point_map[13 + i], coord(i as u16, 0));
}
}
#[test]
fn page_vt_with_background_and_foreground_colors() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
let mut opts = Options::vt();
opts.background = Some(Rgb {
r: 0x12,
g: 0x34,
b: 0x56,
});
opts.foreground = Some(Rgb {
r: 0xab,
g: 0xcd,
b: 0xef,
});
let output = fmt_str(PageFormatter::new(last_page(&t), &opts));
assert_eq!(
output,
"\x1b]10;rgb:ab/cd/ef\x1b\\\x1b]11;rgb:12/34/56\x1b\\hello"
);
}
#[test]
fn page_vt_multi_line_with_styles() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
// Note: style is reset before newline to prevent background colors from
// bleeding to the next line's leading cells.
assert_eq!(
output,
"\x1b[0m\x1b[1mfirst\x1b[0m\r\n\x1b[0m\x1b[3msecond\x1b[0m"
);
assert_eq!(point_map.len(), output.len());
}
#[test]
fn page_vt_duplicate_style_not_emitted_twice() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mhel\x1b[1mlo\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[1mhello\x1b[0m");
assert_eq!(point_map.len(), output.len());
}
fn node_of_first(t: &Terminal) -> crate::page_list::NodeId {
t.screen().pages.first.unwrap()
}
fn node_of_last(t: &Terminal) -> crate::page_list::NodeId {
t.screen().pages.last.unwrap()
}
fn list_fmt(t: &Terminal, opts: Options) -> (String, Vec<Pin>) {
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, opts);
f.pin_map = Some(&mut pin_map);
let out = f.format_string().unwrap();
(out, pin_map)
}
#[test]
fn pagelist_plain_single_line() {
let mut t = term(80, 24);
feed(&mut t, b"hello, world");
let (output, pin_map) = list_fmt(&t, Options::plain());
assert_eq!(output, "hello, world");
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for i in 0..output.len() {
assert_eq!(
pin_map[i],
Pin {
node,
x: i as u16,
y: 0,
garbage: false
}
);
}
}
#[test]
fn pagelist_plain_spanning_two_pages() {
let mut t = term(80, 24);
let first_page_rows = first_page_rows(&t);
// Fill the first page almost completely
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"page one");
// Verify we're still on one page
assert_eq!(t.screen().pages.first, t.screen().pages.last);
// Add one more newline to push content to a second page
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
// Write content on the second page
feed(&mut t, b"page two");
let (full_output, pin_map) = list_fmt(&t, Options::plain());
let output = trim_start(&full_output, &['\n']);
assert_eq!(output, "page one\npage two");
assert_eq!(pin_map.len(), full_output.len());
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let trimmed_count = full_output.len() - output.len();
// First part (trimmed blank lines) maps to first node
for p in &pin_map[..trimmed_count] {
assert_eq!(p.node, first_node);
}
// "page one" (8 chars) maps to first node
for i in 0..8 {
let idx = trimmed_count + i;
assert_eq!(pin_map[idx].node, first_node);
assert_eq!(pin_map[idx].x, i as u16);
}
// \n - maps to last node as it represents the transition to new page
assert_eq!(pin_map[trimmed_count + 8].node, last_node);
// "page two" (8 chars) maps to last node
for i in 0..8 {
let idx = trimmed_count + 9 + i;
assert_eq!(pin_map[idx].node, last_node);
assert_eq!(pin_map[idx].x, i as u16);
}
}
#[test]
fn pagelist_soft_wrapped_line_spanning_two_pages_without_unwrap() {
let mut t = term(10, 3);
let first_page_rows = first_page_rows(&t);
// Fill the first page with soft-wrapped content
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"hello world test");
// Verify we're on two pages due to wrapping
assert_ne!(t.screen().pages.first, t.screen().pages.last);
let (full_output, pin_map) = list_fmt(&t, Options::plain());
let output = trim_start(&full_output, &['\n']);
assert_eq!(output, "hello worl\nd test");
assert_eq!(pin_map.len(), full_output.len());
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let trimmed_count = full_output.len() - output.len();
for p in &pin_map[..trimmed_count] {
assert_eq!(p.node, first_node);
}
for i in 0..10 {
assert_eq!(pin_map[trimmed_count + i].node, first_node);
}
// \n - maps to last node as it represents the transition to new page
assert_eq!(pin_map[trimmed_count + 10].node, last_node);
// "d test" (6 chars) maps to last node
for i in 0..6 {
assert_eq!(pin_map[trimmed_count + 11 + i].node, last_node);
}
}
#[test]
fn pagelist_soft_wrapped_line_spanning_two_pages_with_unwrap() {
let mut t = term(10, 3);
let first_page_rows = first_page_rows(&t);
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"hello world test");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
let mut opts = Options::plain();
opts.unwrap = true;
let (full_output, pin_map) = list_fmt(&t, opts);
let output = trim_start(&full_output, &['\r', '\n']);
assert_eq!(output, "hello world test");
assert_eq!(pin_map.len(), full_output.len());
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let trimmed_count = full_output.len() - output.len();
for p in &pin_map[..trimmed_count] {
assert_eq!(p.node, first_node);
}
for i in 0..10 {
assert_eq!(pin_map[trimmed_count + i].node, first_node);
}
for i in 0..6 {
assert_eq!(pin_map[trimmed_count + 10 + i].node, last_node);
}
}
#[test]
fn pagelist_vt_spanning_two_pages() {
let mut t = term(80, 24);
let first_page_rows = first_page_rows(&t);
// Fill the first page almost completely
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"\x1b[1mpage one");
assert_eq!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
// New content is still styled
feed(&mut t, b"page two");
let (full_output, pin_map) = list_fmt(&t, Options::vt());
let output = trim_start(&full_output, &['\r', '\n']);
assert_eq!(
output,
"\x1b[0m\x1b[1mpage one\x1b[0m\r\n\x1b[0m\x1b[1mpage two\x1b[0m"
);
assert_eq!(pin_map.len(), full_output.len());
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let first_count = pin_map.iter().filter(|p| p.node == first_node).count();
let last_count = pin_map.iter().filter(|p| p.node == last_node).count();
assert!(first_count > 0);
assert!(last_count > 0);
}
#[test]
fn pagelist_plain_with_x_offset_on_single_page() {
let mut t = term(80, 24);
feed(&mut t, b"hello world\r\ntest case\r\nfoo bar");
let node = node_of_first(&t);
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
f.top_left = Some(Pin {
node,
y: 0,
x: 6,
garbage: false,
});
f.bottom_right = Some(Pin {
node,
y: 2,
x: 2,
garbage: false,
});
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "world\ntest case\nfoo");
assert_eq!(pin_map.len(), output.len());
for p in &pin_map {
assert_eq!(p.node, node);
}
// "world" starts at x=6, y=0
for i in 0..5 {
assert_eq!(pin_map[i].x, 6 + i as u16);
assert_eq!(pin_map[i].y, 0);
}
}
#[test]
fn pagelist_plain_with_x_offset_spanning_two_pages() {
let mut t = term(80, 24);
let first_page_rows = first_page_rows(&t);
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"hello world");
assert_eq!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"foo bar test");
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let first_rows = t.screen().pages.node(first_node).unwrap().data.size.rows;
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
f.top_left = Some(Pin {
node: first_node,
y: first_rows - 1,
x: 6,
garbage: false,
});
f.bottom_right = Some(Pin {
node: last_node,
y: 1,
x: 2,
garbage: false,
});
f.pin_map = Some(&mut pin_map);
let full_output = f.format_string().unwrap();
let output = trim_start(&full_output, &['\n']);
assert_eq!(output, "world\nfoo");
assert_eq!(pin_map.len(), full_output.len());
let trimmed_count = full_output.len() - output.len();
// "world" (5 chars) from first page
for i in 0..5 {
let idx = trimmed_count + i;
assert_eq!(pin_map[idx].node, first_node);
assert_eq!(pin_map[idx].x, 6 + i as u16);
}
// \n - maps to last node as it represents the transition to new page
assert_eq!(pin_map[trimmed_count + 5].node, last_node);
// "foo" (3 chars) from last page
for i in 0..3 {
let idx = trimmed_count + 6 + i;
assert_eq!(pin_map[idx].node, last_node);
assert_eq!(pin_map[idx].x, i as u16);
}
}
#[test]
fn pagelist_plain_with_start_x_only() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
let node = node_of_first(&t);
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
f.top_left = Some(Pin {
node,
y: 0,
x: 6,
garbage: false,
});
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "world");
assert_eq!(pin_map.len(), output.len());
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, 6 + i as u16);
assert_eq!(pin_map[i].y, 0);
}
}
#[test]
fn pagelist_plain_with_end_x_only() {
let mut t = term(80, 24);
feed(&mut t, b"hello world\r\ntest");
let node = node_of_first(&t);
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
f.bottom_right = Some(Pin {
node,
y: 1,
x: 2,
garbage: false,
});
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "hello world\ntes");
assert_eq!(pin_map.len(), output.len());
for i in 0..11 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 0);
}
// \n
assert_eq!(pin_map[11].node, node);
for i in 0..3 {
assert_eq!(pin_map[12 + i].node, node);
assert_eq!(pin_map[12 + i].x, i as u16);
assert_eq!(pin_map[12 + i].y, 1);
}
}
fn rect_format(t: &Terminal, tl: (u16, u32), br: (u16, u32)) -> String {
let pages = &t.screen().pages;
let mut f = PageListFormatter::new(pages, Options::plain());
f.top_left = Some(
pages
.pin(Point::Screen(Coordinate { x: tl.0, y: tl.1 }))
.unwrap(),
);
f.bottom_right = Some(
pages
.pin(Point::Screen(Coordinate { x: br.0, y: br.1 }))
.unwrap(),
);
f.rectangle = true;
f.format_string().unwrap()
}
#[test]
fn pagelist_plain_rectangle_basic() {
let mut t = term(30, 5);
feed(&mut t, b"Lorem ipsum dolor\r\n");
feed(&mut t, b"sit amet, consectetur\r\n");
feed(&mut t, b"adipiscing elit, sed do\r\n");
feed(&mut t, b"eiusmod tempor incididunt\r\n");
feed(&mut t, b"ut labore et dolore");
let output = rect_format(&t, (2, 1), (6, 3));
assert_eq!(output, "t ame\nipisc\nusmod");
}
#[test]
fn pagelist_plain_rectangle_with_eol() {
let mut t = term(30, 5);
feed(&mut t, b"Lorem ipsum dolor\r\n");
feed(&mut t, b"sit amet, consectetur\r\n");
feed(&mut t, b"adipiscing elit, sed do\r\n");
feed(&mut t, b"eiusmod tempor incididunt\r\n");
feed(&mut t, b"ut labore et dolore");
let output = rect_format(&t, (12, 0), (26, 4));
assert_eq!(
output,
"dolor\nnsectetur\nlit, sed do\nor incididunt\n dolore"
);
}
#[test]
fn pagelist_plain_rectangle_more_complex_with_breaks() {
let mut t = term(30, 8);
feed(&mut t, b"Lorem ipsum dolor\r\n");
feed(&mut t, b"sit amet, consectetur\r\n");
feed(&mut t, b"adipiscing elit, sed do\r\n");
feed(&mut t, b"eiusmod tempor incididunt\r\n");
feed(&mut t, b"ut labore et dolore\r\n");
feed(&mut t, b"\r\n");
feed(&mut t, b"magna aliqua. Ut enim\r\n");
feed(&mut t, b"ad minim veniam, quis");
let output = rect_format(&t, (11, 2), (26, 7));
assert_eq!(
output,
"elit, sed do\npor incididunt\nt dolore\n\na. Ut enim\nniam, quis"
);
}
#[test]
fn terminal_formatter_plain_no_selection() {
let mut t = term(80, 24);
feed(&mut t, b"hello\r\nworld");
let f = TerminalFormatter::new(&t, Options::plain());
assert_eq!(f.format_string().unwrap(), "hello\nworld");
}
#[test]
fn terminal_formatter_vt_with_palette() {
let mut t = term(80, 24);
// Modify some palette colors using VT sequences
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
feed(&mut t, b"\x1b]4;255;rgb:ff/00/ff\x1b\\");
feed(&mut t, b"test");
let output = TerminalFormatter::new(&t, Options::vt())
.format_string()
.unwrap();
// Create a second terminal and apply the output
let mut t2 = term(80, 24);
feed(&mut t2, output.as_bytes());
// Verify the palettes match
for i in [0, 1, 255] {
assert_eq!(t.colors.palette.current[i], t2.colors.palette.current[i]);
}
}
fn line2_selection(t: &Terminal) -> Selection {
let pages = &t.screen().pages;
Selection::new(
pages.pin(Point::Active(Coordinate { x: 0, y: 1 })).unwrap(),
pages.pin(Point::Active(Coordinate { x: 4, y: 1 })).unwrap(),
false,
)
}
#[test]
fn terminal_formatter_with_selection() {
let mut t = term(80, 24);
feed(&mut t, b"line1\r\nline2\r\nline3");
let sel = line2_selection(&t);
let mut f = TerminalFormatter::new(&t, Options::plain());
f.content = Content::Selection(Some(sel));
assert_eq!(f.format_string().unwrap(), "line2");
}
#[test]
fn terminal_formatter_plain_with_pin_map() {
let mut t = term(80, 24);
feed(&mut t, b"hello, world");
let mut pin_map = Vec::new();
let mut f = TerminalFormatter::new(&t, Options::plain());
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "hello, world");
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for i in 0..output.len() {
assert_eq!(
pin_map[i],
Pin {
node,
x: i as u16,
y: 0,
garbage: false
}
);
}
}
#[test]
fn terminal_formatter_plain_multiline_with_pin_map() {
let mut t = term(80, 24);
feed(&mut t, b"hello\r\nworld");
let mut pin_map = Vec::new();
let mut f = TerminalFormatter::new(&t, Options::plain());
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "hello\nworld");
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 0);
}
// "\n" maps to end of first line
assert_eq!(pin_map[5].node, node);
for i in 0..5 {
let idx = 6 + i;
assert_eq!(pin_map[idx].node, node);
assert_eq!(pin_map[idx].x, i as u16);
assert_eq!(pin_map[idx].y, 1);
}
}
#[test]
fn terminal_formatter_vt_with_palette_and_pin_map() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
feed(&mut t, b"test");
let mut pin_map = Vec::new();
let mut f = TerminalFormatter::new(&t, Options::vt());
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
// Palette bytes should be mapped to top left
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn terminal_formatter_with_selection_and_pin_map() {
let mut t = term(80, 24);
feed(&mut t, b"line1\r\nline2\r\nline3");
let sel = line2_selection(&t);
let mut pin_map = Vec::new();
let mut f = TerminalFormatter::new(&t, Options::plain());
f.content = Content::Selection(Some(sel));
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "line2");
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 1);
}
}
@@ -0,0 +1,530 @@
use super::*;
use ghostty_vt::modes::Mode;
/// Format the screen with the given extras, returning output and pin map.
fn screen_vt(t: &Terminal, mutate: impl FnOnce(&mut ScreenFormatter<'_>)) -> (String, Vec<Pin>) {
let mut pin_map: Vec<Pin> = Vec::new();
let out = {
let mut f = ScreenFormatter::new(t.screen(), Options::vt());
mutate(&mut f);
f.pin_map = Some(&mut pin_map);
f.format_string().unwrap()
};
(out, pin_map)
}
fn replay(output: &str) -> Terminal {
let mut t2 = term(80, 24);
feed(&mut t2, output.as_bytes());
t2
}
fn first_node(t: &Terminal) -> crate::page_list::NodeId {
t.screen().pages.first.unwrap()
}
fn page_fmt(t: &Terminal, opts: &Options) -> String {
PageFormatter::new(last_page(t), opts)
.format_string()
.unwrap()
}
const HTML_OPEN: &str = "<div style=\"font-family: monospace; white-space: pre;\">";
#[test]
fn screen_plain_single_line() {
let mut t = term(80, 24);
feed(&mut t, b"hello, world");
let mut pin_map = Vec::new();
let output = {
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
f.pin_map = Some(&mut pin_map);
f.format_string().unwrap()
};
assert_eq!(output, "hello, world");
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for (i, p) in pin_map.iter().enumerate() {
assert_eq!(
*p,
Pin {
node,
x: i as u16,
y: 0,
garbage: false
}
);
}
}
#[test]
fn screen_plain_multiline() {
let mut t = term(80, 24);
feed(&mut t, b"hello\r\nworld");
let mut pin_map = Vec::new();
let output = {
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
f.pin_map = Some(&mut pin_map);
f.format_string().unwrap()
};
assert_eq!(output, "hello\nworld");
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 0);
}
assert_eq!(pin_map[5].node, node);
for i in 0..5 {
let idx = 6 + i;
assert_eq!(pin_map[idx].node, node);
assert_eq!(pin_map[idx].x, i as u16);
assert_eq!(pin_map[idx].y, 1);
}
}
#[test]
fn screen_plain_with_selection() {
let mut t = term(80, 24);
feed(&mut t, b"line1\r\nline2\r\nline3");
let pages = &t.screen().pages;
let a = pages.pin(Point::Active(Coordinate { x: 0, y: 1 })).unwrap();
let b = pages.pin(Point::Active(Coordinate { x: 4, y: 1 })).unwrap();
let mut pin_map = Vec::new();
let output = {
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
f.content = Content::Selection(Some(Selection::new(a, b, false)));
f.pin_map = Some(&mut pin_map);
f.format_string().unwrap()
};
assert_eq!(output, "line2");
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 1);
}
}
#[test]
fn screen_vt_with_cursor_position() {
let mut t = term(80, 24);
feed(&mut t, b"hello\r\nworld");
let (output, pin_map) = screen_vt(&t, |f| f.extra.cursor = true);
let t2 = replay(&output);
assert_eq!(t.screen().cursor.x, t2.screen().cursor.x);
assert_eq!(t.screen().cursor.y, t2.screen().cursor.y);
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_style() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1;31mhello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.style = true);
let t2 = replay(&output);
assert!(t.screen().cursor.style.eql(t2.screen().cursor.style));
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_hyperlink() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]8;;http://example.com\x1b\\hello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.hyperlink = true);
let t2 = replay(&output);
let l1 = &t.screen().cursor.hyperlink;
let l2 = &t2.screen().cursor.hyperlink;
assert_eq!(l1.is_some(), l2.is_some());
if let (Some(a), Some(b)) = (l1, l2) {
assert_eq!(a.uri, b.uri);
}
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_protection() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1\"qhello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.protection = true);
let t2 = replay(&output);
assert_eq!(t.screen().cursor.protected, t2.screen().cursor.protected);
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_kitty_keyboard() {
let mut t = term(80, 24);
// Set kitty keyboard flags (disambiguate + report_events = 3)
feed(&mut t, b"\x1b[=3;1uhello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.kitty_keyboard = true);
let t2 = replay(&output);
let flags1 = t.screen().kitty_keyboard.current().int();
let flags2 = t2.screen().kitty_keyboard.current().int();
assert_eq!(flags1, flags2);
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_charsets() {
use ghostty_vt::charsets::Slots;
let mut t = term(80, 24);
// Set G0 to DEC special and shift to G1
feed(&mut t, b"\x1b(0\x0ehello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.charsets = true);
let t2 = replay(&output);
let (c1, c2) = (&t.screen().charset, &t2.screen().charset);
assert_eq!(c1.gl, c2.gl);
assert_eq!(c1.gr, c2.gr);
assert_eq!(c1.charsets.get(Slots::G0), c2.charsets.get(Slots::G0));
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
fn term_vt(t: &Terminal, mutate: impl FnOnce(&mut TerminalFormatter<'_>)) -> String {
let mut f = TerminalFormatter::new(t, Options::vt());
mutate(&mut f);
f.format_string().unwrap()
}
#[test]
fn terminal_vt_with_scrolling_region() {
let mut t = term(80, 24);
// Set scrolling region: top=5, bottom=20
feed(&mut t, b"\x1b[6;21rhello");
let output = term_vt(&t, |f| f.extra.scrolling_region = true);
let t2 = replay(&output);
assert_eq!(t.scrolling_region.top, t2.scrolling_region.top);
assert_eq!(t.scrolling_region.bottom, t2.scrolling_region.bottom);
assert_eq!(t.scrolling_region.left, t2.scrolling_region.left);
assert_eq!(t.scrolling_region.right, t2.scrolling_region.right);
}
#[test]
fn terminal_vt_with_modes() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[?2004h"); // Bracketed paste
feed(&mut t, b"\x1b[?1000h"); // Mouse event normal
feed(&mut t, b"\x1b[?7l"); // Disable wraparound (default is true)
feed(&mut t, b"hello");
let output = term_vt(&t, |f| f.extra.modes = true);
let t2 = replay(&output);
for m in [
Mode::BracketedPaste,
Mode::MouseEventNormal,
Mode::Wraparound,
] {
assert_eq!(t.modes.get(m), t2.modes.get(m));
}
}
#[test]
fn terminal_vt_with_tabstops() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[3g"); // Clear all tabs
feed(&mut t, b"\x1b[5G\x1bH"); // Set tab at column 5
feed(&mut t, b"\x1b[15G\x1bH"); // Set tab at column 15
feed(&mut t, b"\x1b[30G\x1bH"); // Set tab at column 30
feed(&mut t, b"hello");
let output = term_vt(&t, |f| f.extra.tabstops = true);
let t2 = replay(&output);
for c in [4, 14, 29] {
assert_eq!(t.tabstops.get(c), t2.tabstops.get(c));
assert!(t2.tabstops.get(c));
}
assert!(!t2.tabstops.get(8)); // Not a tab
}
#[test]
fn terminal_vt_with_keyboard_modes() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[>4;2m");
feed(&mut t, b"hello");
let output = term_vt(&t, |f| f.extra.keyboard = true);
let t2 = replay(&output);
assert_eq!(t.flags.modify_other_keys_2, t2.flags.modify_other_keys_2);
assert!(t2.flags.modify_other_keys_2);
}
#[test]
fn terminal_vt_with_pwd() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]7;file://host/home/user\x1b\\hello");
let output = term_vt(&t, |f| f.extra.pwd = true);
let t2 = replay(&output);
assert_eq!(t.pwd, t2.pwd);
}
#[test]
fn page_html_with_multiple_styles() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mbold\x1b[3mitalic\x1b[0mnormal");
let output = page_fmt(&t, &Options::html());
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;\">\
<div style=\"display: inline;font-weight: bold;\">bold</div>\
<div style=\"display: inline;font-weight: bold;font-style: italic;\">italic</div>\
normal\
</div>"
);
}
#[test]
fn page_html_plain_text() {
let mut t = term(80, 24);
feed(&mut t, b"hello, world");
let output = page_fmt(&t, &Options::html());
assert_eq!(output, format!("{HTML_OPEN}hello, world</div>"));
}
#[test]
fn page_html_with_colors() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[31;44mcolored");
let output = page_fmt(&t, &Options::html());
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;\">\
<div style=\"display: inline;color: var(--vt-palette-1);background-color: var(--vt-palette-4);\">colored</div>\
</div>"
);
}
#[test]
fn terminal_formatter_html_with_palette() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
feed(&mut t, b"\x1b]4;255;rgb:ff/00/ff\x1b\\");
feed(&mut t, b"test");
let mut f = TerminalFormatter::new(&t, Options::html());
f.extra.palette = true;
let output = f.format_string().unwrap();
assert!(output.contains("<style>:root{"));
assert!(output.contains("--vt-palette-0: #123456;"));
assert!(output.contains("--vt-palette-1: #abcdef;"));
assert!(output.contains("--vt-palette-255: #ff00ff;"));
assert!(output.contains("}</style>"));
assert!(output.contains("test"));
}
#[test]
fn page_html_with_background_and_foreground_colors() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
let mut opts = Options::html();
opts.background = Some(Rgb {
r: 0x12,
g: 0x34,
b: 0x56,
});
opts.foreground = Some(Rgb {
r: 0xab,
g: 0xcd,
b: 0xef,
});
let output = page_fmt(&t, &opts);
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;background-color: #123456;color: #abcdef;\">hello</div>"
);
}
#[test]
fn page_html_with_escaping() {
let mut t = term(80, 24);
feed(&mut t, b"<tag>&\"'text");
let opts = Options::html();
let mut point_map: Vec<Coordinate> = Vec::new();
let output = {
let mut f = PageFormatter::new(last_page(&t), &opts);
f.point_map = Some(&mut point_map);
f.format_string().unwrap()
};
assert_eq!(
output,
format!("{HTML_OPEN}&lt;tag&gt;&amp;&quot;&#39;text</div>")
);
assert_eq!(output.len(), point_map.len());
let offset = HTML_OPEN.len();
for c in &point_map[..offset] {
assert_eq!(*c, Coordinate { x: 0, y: 0 });
}
let at = |i: usize, x: u16| {
assert_eq!(point_map[offset + i], Coordinate { x, y: 0 }, "index {i}");
};
for i in 0..4 {
at(i, 0);
}
at(4, 1);
at(5, 2);
at(6, 3);
for i in 0..4 {
at(7 + i, 4);
}
for i in 0..5 {
at(11 + i, 5);
}
for i in 0..6 {
at(16 + i, 6);
}
for i in 0..5 {
at(22 + i, 7);
}
at(27, 8);
at(28, 9);
at(29, 10);
at(30, 11);
}
#[test]
fn page_html_with_unicode_as_numeric_entities() {
let mut t = term(80, 24);
feed(&mut t, "╰─ ❯".as_bytes());
let output = page_fmt(&t, &Options::html());
assert_eq!(output, format!("{HTML_OPEN}&#9584;&#9472; &#10095;</div>"));
}
#[test]
fn page_html_ascii_characters_unchanged() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
let output = page_fmt(&t, &Options::html());
assert_eq!(output, format!("{HTML_OPEN}hello world</div>"));
}
#[test]
fn page_html_mixed_ascii_and_unicode() {
let mut t = term(80, 24);
feed(&mut t, "test ╰─❯ ok".as_bytes());
let output = page_fmt(&t, &Options::html());
assert_eq!(
output,
format!("{HTML_OPEN}test &#9584;&#9472;&#10095; ok</div>")
);
}
#[test]
fn page_vt_with_palette_option_emits_rgb() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
feed(&mut t, b"\x1b[31mred");
// Without palette option - should emit palette index
let output = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[38;5;1mred\x1b[0m");
// With palette option - should emit RGB directly
let mut opts = Options::vt();
opts.palette = Some(t.colors.palette.current);
let output = page_fmt(&t, &opts);
assert_eq!(output, "\x1b[0m\x1b[38;2;171;205;239mred\x1b[0m");
}
#[test]
fn page_html_with_palette_option_emits_rgb() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
feed(&mut t, b"\x1b[31mred");
let output = page_fmt(&t, &Options::html());
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;\">\
<div style=\"display: inline;color: var(--vt-palette-1);\">red</div>\
</div>"
);
let mut opts = Options::html();
opts.palette = Some(t.colors.palette.current);
let output = page_fmt(&t, &opts);
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;\">\
<div style=\"display: inline;color: rgb(171, 205, 239);\">red</div>\
</div>"
);
}
#[test]
fn page_vt_style_reset_properly_closes_styles() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mbold\x1b[0mnormal");
let output = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[1mbold\x1b[0mnormal");
}
@@ -0,0 +1,327 @@
use super::*;
fn map_entry(range: (char, char), replacement: Replacement) -> CodepointMap {
CodepointMap {
range: [range.0 as u32, range.1 as u32],
replacement,
}
}
/// Format the last page of `t` with `opts`, optionally collecting a point map.
fn run(t: &Terminal, opts: &Options, point_map: Option<&mut Vec<Coordinate>>) -> String {
let mut f = PageFormatter::new(last_page(t), opts);
f.point_map = point_map;
f.format_string().unwrap()
}
fn plain_with_map(entries: Vec<CodepointMap>) -> Options {
let mut opts = Options::plain();
opts.codepoint_map = Some(entries);
opts
}
fn c(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
#[test]
fn page_codepoint_map_single_replacement() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
// Replace 'o' with 'x'
let opts = plain_with_map(vec![map_entry(
('o', 'o'),
Replacement::Codepoint('x' as u32),
)]);
let mut point_map = Vec::new();
let output = run(&t, &opts, Some(&mut point_map));
assert_eq!(output, "hellx wxrld");
// Verify point map - each output byte should map to original cell position
assert_eq!(output.len(), point_map.len());
for (i, coord) in point_map.iter().enumerate() {
assert_eq!(*coord, c(i as u16, 0));
}
}
#[test]
fn page_codepoint_map_conflicting_replacement_prefers_last() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
// Replace 'o' with 'x', then with 'y' - should prefer last
let opts = plain_with_map(vec![
map_entry(('o', 'o'), Replacement::Codepoint('x' as u32)),
map_entry(('o', 'o'), Replacement::Codepoint('y' as u32)),
]);
assert_eq!(run(&t, &opts, None), "helly");
}
#[test]
fn page_codepoint_map_replace_with_string() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
// Replace 'o' with a multi-byte string
let opts = plain_with_map(vec![map_entry(
('o', 'o'),
Replacement::String("XYZ".into()),
)]);
let mut point_map = Vec::new();
let output = run(&t, &opts, Some(&mut point_map));
assert_eq!(output, "hellXYZ");
// Verify point map - string replacements should all map to the original cell
assert_eq!(output.len(), point_map.len());
for i in 0..4 {
assert_eq!(point_map[i], c(i as u16, 0));
}
// All bytes of the replacement string "XYZ" should point to position 4 (where 'o' was)
assert_eq!(point_map[4], c(4, 0)); // X
assert_eq!(point_map[5], c(4, 0)); // Y
assert_eq!(point_map[6], c(4, 0)); // Z
}
#[test]
fn page_codepoint_map_range_replacement() {
let mut t = term(80, 24);
feed(&mut t, b"abcdefg");
// Replace 'b' through 'e' with 'X'
let opts = plain_with_map(vec![map_entry(
('b', 'e'),
Replacement::Codepoint('X' as u32),
)]);
assert_eq!(run(&t, &opts, None), "aXXXXfg");
}
#[test]
fn page_codepoint_map_multiple_ranges() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
// Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z'
let opts = plain_with_map(vec![
map_entry(('a', 'm'), Replacement::Codepoint('A' as u32)),
map_entry(('n', 'z'), Replacement::Codepoint('Z' as u32)),
]);
// h e l l o w o r l d
// A A A A Z Z Z Z A A
assert_eq!(run(&t, &opts, None), "AAAAZ ZZZAA");
}
#[test]
fn page_codepoint_map_unicode_replacement() {
let mut t = term(80, 24);
feed(&mut t, "hello \u{26A1} world".as_bytes());
// Replace lightning bolt with fire emoji
let opts = plain_with_map(vec![map_entry(
('\u{26A1}', '\u{26A1}'),
Replacement::String("\u{1F525}".into()),
)]);
let mut point_map = Vec::new();
let output = run(&t, &opts, Some(&mut point_map));
assert_eq!(output, "hello \u{1F525} world");
// Verify point map
assert_eq!(output.len(), point_map.len());
// Note: the bolt is a wide character occupying cells 6-7
for i in 0..6 {
assert_eq!(point_map[i], c(i as u16, 0));
}
// The fire emoji is 4 UTF-8 bytes, all should map to cell 6 (where the bolt was)
let fire_start = 6; // "hello " is 6 bytes
for i in 0..4 {
assert_eq!(point_map[fire_start + i], c(6, 0));
}
// " world" follows
let world_start = fire_start + 4;
for i in 0..6 {
assert_eq!(point_map[world_start + i], c(8 + i as u16, 0));
}
}
#[test]
fn page_codepoint_map_with_styled_formats() {
let mut t = term(10, 24);
feed(&mut t, b"\x1b[31mred text\x1b[0m");
// Replace 'e' with 'X' in styled text
let mut opts = Options::vt();
opts.codepoint_map = Some(vec![map_entry(
('e', 'e'),
Replacement::Codepoint('X' as u32),
)]);
// Should preserve styles while replacing text
// "red text" becomes "rXd tXxt"
// VT format uses \x1b[38;5;1m for palette color 1
assert_eq!(run(&t, &opts, None), "\x1b[0m\x1b[38;5;1mrXd tXxt\x1b[0m");
}
#[test]
fn page_codepoint_map_empty_map() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
// Empty map should not change anything
let opts = plain_with_map(Vec::new());
assert_eq!(run(&t, &opts, None), "hello world");
// A None map is equivalent.
let mut opts = Options::plain();
opts.codepoint_map = None;
assert_eq!(run(&t, &opts, None), "hello world");
}
#[test]
fn page_vt_background_color_on_trailing_blank_cells() {
// This test reproduces a bug where trailing cells with background color
// but no text are emitted as plain spaces without SGR sequences.
// This causes TUIs like htop to lose background colors on rehydration.
let mut t = term(20, 5);
// Simulate a TUI row: "CPU:" with text, then trailing cells with red background
// to end of line (no text after the colored region).
// \x1b[41m sets red background, then EL fills rest of row with that bg.
feed(&mut t, b"CPU:\x1b[41m\x1b[K");
// Reset colors and move to next line with different content
feed(&mut t, b"\x1b[0m\r\nline2");
let mut opts = Options::vt();
opts.trim = false; // Don't trim so we can see the trailing behavior
let output = run(&t, &opts, None);
// The output should preserve the red background SGR for trailing cells on line 1.
// The red background should appear BEFORE the newline, not after.
// Find position of CRLF
let crlf_pos = output.find("\r\n").expect("crlf");
// Check that red background (48;5;1) appears BEFORE the newline (on line 1)
let line1 = &output[..crlf_pos];
let has_red_bg_line1 = line1.contains("\x1b[41m") || line1.contains("\x1b[48;5;1m");
assert!(has_red_bg_line1);
}
const HTML_OPEN: &str = "<div style=\"font-family: monospace; white-space: pre;\">";
fn html(t: &Terminal, point_map: Option<&mut Vec<Coordinate>>) -> String {
run(t, &Options::html(), point_map)
}
#[test]
fn page_html_with_hyperlinks() {
let mut t = term(80, 24);
// Start a hyperlink, write some text, end it
feed(
&mut t,
b"\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal",
);
assert_eq!(
html(&t, None),
format!("{HTML_OPEN}<a href=\"https://example.com\">link text</a> normal</div>")
);
}
#[test]
fn page_html_with_multiple_hyperlinks() {
let mut t = term(80, 24);
// Two different hyperlinks
feed(
&mut t,
b"\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ ",
);
feed(
&mut t,
b"\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\",
);
assert_eq!(
html(&t, None),
format!(
"{HTML_OPEN}<a href=\"https://first.com\">first</a> <a href=\"https://second.com\">second</a></div>"
)
);
}
#[test]
fn page_html_with_hyperlink_escaping() {
let mut t = term(80, 24);
// URL with special characters that need escaping
feed(
&mut t,
b"\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\",
);
assert_eq!(
html(&t, None),
format!("{HTML_OPEN}<a href=\"https://example.com?a=1&amp;b=2\">link</a></div>")
);
}
#[test]
fn page_html_with_styled_hyperlink() {
let mut t = term(80, 24);
// Bold hyperlink
feed(
&mut t,
b"\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\",
);
assert_eq!(
html(&t, None),
format!(
"{HTML_OPEN}<div style=\"display: inline;font-weight: bold;\"><a href=\"https://example.com\">bold link</div></a></div>"
)
);
}
#[test]
fn page_html_hyperlink_closes_style_before_anchor() {
let mut t = term(80, 24);
// Styled hyperlink followed by plain text
feed(
&mut t,
b"\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain",
);
assert_eq!(
html(&t, None),
format!(
"{HTML_OPEN}<div style=\"display: inline;font-weight: bold;\"><a href=\"https://example.com\">bold</div> plain</a></div>"
)
);
}
#[test]
fn page_html_hyperlink_point_map_maps_closing_to_previous_cell() {
let mut t = term(80, 24);
feed(
&mut t,
b"\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal",
);
let mut point_map = Vec::new();
let output = html(&t, Some(&mut point_map));
let expected_output =
format!("{HTML_OPEN}<a href=\"https://example.com\">link</a> normal</div>");
assert_eq!(output, expected_output);
assert_eq!(expected_output.len(), point_map.len());
// The </a> closing tag bytes should all map to the last cell of the link
let closing_idx = expected_output.find("</a>").unwrap();
let expected_coord = point_map[closing_idx - 1];
for i in closing_idx..closing_idx + "</a>".len() {
assert_eq!(point_map[i], expected_coord);
}
}
+228
View File
@@ -0,0 +1,228 @@
//! 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)
})
}
+3
View File
@@ -1,12 +1,15 @@
//! Safe page-based terminal memory model.
#![cfg_attr(test, allow(clippy::unwrap_used))]
pub mod cursor;
pub mod formatter;
pub mod highlight;
pub mod hyperlink;
pub mod page;
pub mod page_list;
pub mod pin_iter;
pub mod point;
pub mod ref_counted_set;
pub mod render;
pub mod screen;
pub mod screen_set;
pub mod selection;
+1390
View File
@@ -0,0 +1,1390 @@
//! 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.
//!
//! ```ignore
//! let mut state = RenderState::new();
//! state.update(&mut terminal)?;
//! ```
//!
//! 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 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},
};
use ghostty_vt::{
color::{self, Palette, Rgb},
modes::Mode,
};
use std::collections::HashSet;
/// 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` 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). Always use the accessors rather than indexing those two directly.
#[derive(Debug, Clone, Default)]
pub struct Cells {
pub raw: Vec<page::Cell>,
pub grapheme: Vec<Vec<u32>>,
pub style: 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 {
self.style.get(x).copied().unwrap_or_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.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.grapheme.resize_with(cols, Vec::new);
row.cells.style.resize(cols, Style::default());
for (x, page_cell) in page_cells.iter().enumerate() {
// Append assuming its a single-codepoint, styled cell
// (most common by far).
if page_cell.style_id() > 0 {
row.cells.style[x] = p.style(*page_cell);
}
// 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)
{
row.cells.grapheme[x].extend_from_slice(g);
}
}
ContentTag::BgColorRgb => {
row.cells.style[x] = Style {
bg_color: Color::Rgb(page_cell.color_rgb()),
..Style::default()
};
}
ContentTag::BgColorPalette => {
row.cells.style[x] = Style {
bg_color: Color::Palette(page_cell.color_palette()),
..Style::default()
};
}
}
}
}
// 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);
}
}
+40 -356
View File
@@ -20,6 +20,7 @@
)]
use crate::{
cursor::Style as CursorStyle,
formatter::{Content as FormatContent, Options as FormatOptions, ScreenFormatter},
hyperlink::{self, Hyperlink, HyperlinkId},
page::{Cell, ContentTag, Page, Row, SemanticContent, SemanticPrompt as RowPrompt, Wide},
page_list::{
@@ -2617,19 +2618,23 @@ impl Screen {
opts: SelectionString,
pin_map: Option<&mut Vec<Pin>>,
) -> String {
// TODO(integration): delegate to formatter::ScreenFormatter (plain,
// unwrap = true, trim = opts.trim, content = selection).
let tl = opts.sel.top_left(&self.pages);
let br = opts.sel.bottom_right(&self.pages);
plain_format(
&self.pages,
tl,
br,
opts.sel.rectangle,
true,
opts.trim,
pin_map,
)
// Create a formatter and use that to emit our text.
let mut formatter = ScreenFormatter::new(
self,
FormatOptions {
unwrap: true,
trim: opts.trim,
..FormatOptions::plain()
},
);
formatter.content = FormatContent::Selection(Some(opts.sel));
// If we have a string map, we need to set that up.
formatter.pin_map = pin_map;
formatter
.format_string()
.expect("formatting to memory cannot fail")
}
/// Select the line under the given point. This will select across soft-wrapped
@@ -3224,23 +3229,31 @@ impl Screen {
/// Dump the screen to a string.
pub fn dump_string(&self, out: &mut String, opts: DumpString) {
// TODO(integration): delegate to formatter::ScreenFormatter (plain,
// unwrap = opts.unwrap, trim = false, content = selection(tl, br)).
// Create a formatter and use that to emit our text.
let mut formatter = ScreenFormatter::new(
self,
FormatOptions {
unwrap: opts.unwrap,
trim: false,
..FormatOptions::plain()
},
);
// Set up the selection based on the pins
let tl = opts.tl;
let br = opts
.br
.unwrap_or_else(|| self.pages.get_bottom_right(Tag::Screen).expect("screen"));
let sel = Selection::new(opts.tl, br, false);
let tl = sel.top_left(&self.pages);
let br = sel.bottom_right(&self.pages);
out.push_str(&plain_format(
&self.pages,
tl,
br,
false,
opts.unwrap,
false,
None,
));
formatter.content = FormatContent::Selection(Some(Selection::new(
tl, br, false, // not rectangle
)));
// Emit
out.push_str(
&formatter
.format_string()
.expect("formatting to memory cannot fail"),
);
}
/// You should use dump_string, this is a restricted version mostly for
@@ -3471,334 +3484,5 @@ impl Iterator for LineIterator<'_> {
}
}
// ---------------------------------------------------------------------------
// Minimal plain-text formatter.
//
// TODO(integration): this is a minimal port of the `emit = .plain` paths of
// formatter.zig's PageListFormatter/PageFormatter (unwrapping soft-wrapped
// lines, trimming trailing whitespace, trailing blank lines are dropped).
// Replace with the real formatter once ported.
// ---------------------------------------------------------------------------
/// Trailing state carried between pages (formatter.zig `TrailingState`).
#[derive(Clone, Copy, Default)]
struct TrailingState {
rows: usize,
cells: usize,
}
fn write_codepoint(
out: &mut String,
cp: u32,
map: &mut Option<&mut Vec<(u16, u16)>>,
x: u16,
y: u16,
) {
let ch = char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER);
let before = out.len();
out.push(ch);
if let Some(map) = map {
for _ in before..out.len() {
map.push((x, y));
}
}
}
#[allow(clippy::too_many_arguments)]
fn plain_format_page(
page: &Page,
out: &mut String,
start_x: u16,
start_y: u16,
end_x: Option<u16>,
end_y: Option<u16>,
rectangle: bool,
unwrap: bool,
trim: bool,
trailing_state: Option<TrailingState>,
mut point_map: Option<&mut Vec<(u16, u16)>>,
) -> TrailingState {
let mut blank_rows: usize = 0;
let mut blank_cells: usize = 0;
// Continue our prior trailing state if we have it, but only if we're
// starting from the beginning (start_y and start_x are both 0).
// If a non-zero start position is specified, ignore trailing state.
if let Some(state) = trailing_state
&& start_y == 0
&& start_x == 0
{
blank_rows = state.rows;
blank_cells = state.cells;
}
let ret = |blank_rows, blank_cells| TrailingState {
rows: blank_rows,
cells: blank_cells,
};
// Setup our starting column and perform some validation for overflows.
if start_x >= page.size.cols {
return ret(blank_rows, blank_cells);
}
let end_x_unclamped = end_x.unwrap_or(page.size.cols - 1);
let mut end_x = end_x_unclamped.min(page.size.cols - 1);
// Setup our starting row and perform some validation for overflows.
if start_y >= page.size.rows {
return ret(blank_rows, blank_cells);
}
let end_y_unclamped = end_y.unwrap_or(page.size.rows - 1);
if start_y > end_y_unclamped {
return ret(blank_rows, blank_cells);
}
let mut end_y = end_y_unclamped.min(page.size.rows - 1);
// Edge case: if our end x/y falls on a spacer head AND we're unwrapping,
// then we move the x/y to the start of the next row (if available).
if unwrap && !rectangle {
let cells = page.get_cells(usize::from(end_y)).expect("row");
if cells[usize::from(end_x)].wide() == Wide::SpacerHead && end_y < page.size.rows - 1 {
end_y += 1;
end_x = 0;
}
}
// If we only have a single row, validate that start_x <= end_x
if start_y == end_y && start_x > end_x {
return ret(blank_rows, blank_cells);
}
for y in start_y..=end_y {
let row = *page.get_row(usize::from(y)).expect("row");
let cells = page.get_cells(usize::from(y)).expect("row cells");
let base = row.cells();
// Determine the x range for this row
// - First row: start_x to end of row (or end_x if single row)
// - Last row: start of row to end_x
// - Middle rows: full width
let row_end_x: u16 = if rectangle || y == end_y {
end_x + 1
} else {
page.size.cols
};
// The first we have to check if our start X falls on the
// tail of a wide character.
let row_start_x: u16 = if start_x > 0 && (rectangle || y == start_y) {
match cells[usize::from(start_x)].wide() {
// Include the prior cell to get the full wide char
Wide::SpacerTail => start_x - 1,
// If we're a spacer head on our first row then we
// skip this whole row.
Wide::SpacerHead => continue,
Wide::Narrow | Wide::Wide => start_x,
}
} else {
0
};
let subset = &cells[usize::from(row_start_x)..usize::from(row_end_x)];
// If this row is blank, accumulate to avoid a bunch of extra
// work later. If it isn't blank, make sure we dump all our
// blanks.
if !Cell::has_text_any(subset) {
blank_rows += 1;
continue;
}
if blank_rows > 0 {
for _ in 0..blank_rows {
out.push('\n');
}
// \r and \n map to the row that ends with this newline.
// If we're continuing (trailing state) then this will be
// in a prior page, so we just map to the first row of this
// page.
if let Some(map) = point_map.as_deref_mut() {
let start = map.last().copied().unwrap_or((0, 0));
// The first one inherits the x value.
map.push(start);
// All others have x = 0 since they reference their prior
// blank line.
for y_offset in 1..blank_rows {
map.push((0, start.1 + y_offset as u16));
}
}
blank_rows = 0;
}
// If we're not wrapped, we always add a newline so after
// the row is printed we can add a newline.
if !row.wrap() || !unwrap {
blank_rows += 1;
}
// If the row doesn't continue a wrap then we need to reset
// our blank cell count.
if !row.wrap_continuation() || !unwrap {
blank_cells = 0;
}
// Go through each cell and print it
for (offset, cell) in subset.iter().enumerate() {
let x = row_start_x + offset as u16;
// Skip spacers. These happen naturally when wide characters
// are printed again on the screen (for well-behaved terminals!)
match cell.wide() {
Wide::Narrow | Wide::Wide => {}
Wide::SpacerHead | Wide::SpacerTail => continue,
}
// If we have a zero value, then we accumulate a counter. We
// only want to turn zero values into spaces if we have a non-zero
// char sometime later.
// Cells with no text are blank
if !cell.has_text() {
blank_cells += 1;
continue;
}
// Trailing spaces are blank. We know it is trailing
// because if we get a non-empty cell later we'll
// fill the blanks.
if cell.codepoint() == u32::from(' ') && trim {
blank_cells += 1;
continue;
}
// This cell is not blank. If we have accumulated blank cells
// then we want to emit them now.
if blank_cells > 0 {
for _ in 0..blank_cells {
out.push(' ');
}
if let Some(map) = point_map.as_deref_mut() {
// Map each blank cell to its coordinate. Blank cells can span
// multiple rows if they carry over from wrap continuation.
let mut remaining_blanks = blank_cells;
let mut blank_x = x;
let mut blank_y = y;
while remaining_blanks > 0 {
if blank_x > 0 {
// We have space in this row
blank_x -= 1;
} else if blank_y > 0 {
// Wrap to previous row
blank_y -= 1;
blank_x = page.size.cols - 1;
} else {
// Can't go back further, just use (0, 0)
blank_x = 0;
blank_y = 0;
}
map.push((blank_x, blank_y));
remaining_blanks -= 1;
}
}
blank_cells = 0;
}
write_codepoint(out, cell.codepoint(), &mut point_map, x, y);
if cell.content_tag() == ContentTag::CodepointGrapheme
&& let Some(extra) = page.lookup_grapheme(base + usize::from(x))
{
for &cp in extra {
write_codepoint(out, cp, &mut point_map, x, y);
}
}
}
}
ret(blank_rows, blank_cells)
}
/// Plain-text format of the pages between `tl` and `br` (both inclusive,
/// properly ordered).
fn plain_format(
list: &PageList,
tl: Pin,
br: Pin,
rectangle: bool,
unwrap: bool,
trim: bool,
mut pin_map: Option<&mut Vec<Pin>>,
) -> String {
let mut out = String::new();
let mut page_state: Option<TrailingState> = None;
let mut point_map: Vec<(u16, u16)> = Vec::new();
let mut node = Some(tl.node);
while let Some(id) = node {
let n = list.node(id).expect("live node");
let chunk_start = if id == tl.node { tl.y } else { 0 };
let chunk_end = if id == br.node {
br.y + 1
} else {
n.data.size.rows
};
debug_assert!(chunk_start < chunk_end);
let (start_x, end_x) = if rectangle {
// For rectangle selection, apply start_x and end_x to all chunks
(tl.x, Some(br.x))
} else {
// Otherwise only on the first/last, respectively.
(
if id == tl.node { tl.x } else { 0 },
if id == br.node { Some(br.x) } else { None },
)
};
point_map.clear();
page_state = Some(plain_format_page(
&n.data,
&mut out,
start_x,
chunk_start,
end_x,
Some(chunk_end - 1),
rectangle,
unwrap,
trim,
page_state,
if pin_map.is_some() {
Some(&mut point_map)
} else {
None
},
));
if let Some(m) = pin_map.as_deref_mut() {
for &(x, y) in &point_map {
m.push(Pin {
node: id,
x,
y,
garbage: false,
});
}
}
if id == br.node {
break;
}
node = n.next;
}
out
}
#[cfg(test)]
mod tests;