From 40dc87519e5970943256b5f05e4b0338dd6d1c78 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 22:50:22 +0200 Subject: [PATCH] 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) --- Cargo.lock | 21 + crates/ghostty-terminal/Cargo.toml | 5 + crates/ghostty-terminal/src/formatter.rs | 1581 +++++++++++++++++ .../src/formatter/tests/mod.rs | 53 + .../src/formatter/tests/p1.rs | 589 ++++++ .../src/formatter/tests/p2.rs | 686 +++++++ .../src/formatter/tests/p3.rs | 530 ++++++ .../src/formatter/tests/p4.rs | 327 ++++ crates/ghostty-terminal/src/highlight.rs | 228 +++ crates/ghostty-terminal/src/lib.rs | 3 + crates/ghostty-terminal/src/render.rs | 1390 +++++++++++++++ crates/ghostty-terminal/src/screen.rs | 396 +---- .../ghostty-terminal-render-formatter.md | 57 + 13 files changed, 5510 insertions(+), 356 deletions(-) create mode 100644 crates/ghostty-terminal/src/formatter.rs create mode 100644 crates/ghostty-terminal/src/formatter/tests/mod.rs create mode 100644 crates/ghostty-terminal/src/formatter/tests/p1.rs create mode 100644 crates/ghostty-terminal/src/formatter/tests/p2.rs create mode 100644 crates/ghostty-terminal/src/formatter/tests/p3.rs create mode 100644 crates/ghostty-terminal/src/formatter/tests/p4.rs create mode 100644 crates/ghostty-terminal/src/highlight.rs create mode 100644 crates/ghostty-terminal/src/render.rs create mode 100644 docs/rewrite/notes/ghostty-terminal-render-formatter.md diff --git a/Cargo.lock b/Cargo.lock index c23be88..b6648bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,6 +562,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -1351,19 +1360,31 @@ dependencies = [ name = "ghostty-terminal" version = "0.1.0" dependencies = [ + "crossbeam-channel", + "flate2", "ghostty-config", "ghostty-datastruct", "ghostty-unicode", "ghostty-vt", "log", "onig", + "png", + "rustix", + "thiserror 2.0.21", ] [[package]] name = "ghostty-termio" version = "0.1.0" dependencies = [ + "crossbeam-channel", + "ghostty-apprt", + "ghostty-config", + "ghostty-datastruct", "ghostty-os", + "ghostty-renderer", + "ghostty-terminal", + "ghostty-vt", "libc", "log", "nix 0.31.3", diff --git a/crates/ghostty-terminal/Cargo.toml b/crates/ghostty-terminal/Cargo.toml index b6bfbe1..107b5e1 100644 --- a/crates/ghostty-terminal/Cargo.toml +++ b/crates/ghostty-terminal/Cargo.toml @@ -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 diff --git a/crates/ghostty-terminal/src/formatter.rs b/crates/ghostty-terminal/src/formatter.rs new file mode 100644 index 0000000..61771a4 --- /dev/null +++ b/crates/ghostty-terminal/src/formatter.rs @@ -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`) 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 = io::Result; + +/// 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 "); + } + } + 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 = 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 = 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>, +} + +#[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), +} + +#[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 { + 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 = 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, + pub bottom_right: Option, + + /// 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>, +} + +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 { + 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 = Vec::new(); + + let mut page_state: Option = 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, + pub end_y: Option, + + /// 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>, + + /// 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, +} + +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 { + 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 { + 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 = Vec::new(); + + // Monospace and whitespace preserving + 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 = 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 tags + // when the hyperlink changes or ends. + let mut current_hyperlink_id: Option = None; + + let mut scratch: Vec = 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 = 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 = 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"
"; + 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, + 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, 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, codepoint: u32) -> Result<()> { + let push = |out: &mut Vec, 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"<"); + Ok(()) + } + 0x3E => { + out.extend_from_slice(b">"); + Ok(()) + } + 0x26 => { + out.extend_from_slice(b"&"); + Ok(()) + } + 0x22 => { + out.extend_from_slice(b"""); + Ok(()) + } + 0x27 => { + out.extend_from_slice(b"'"); + 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, 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, "
") + } + } + } + + 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"
", + }; + self.write_close_str(writer, s) + } + + fn format_hyperlink_open(&self, out: &mut Vec, uri: &[u8]) -> Result<()> { + match self.opts.emit { + Format::Plain | Format::Vt => unreachable!(), + + Format::Html => { + 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"", + Format::Plain | Format::Vt => return Ok(()), + }; + self.write_close_str(writer, s) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostty-terminal/src/formatter/tests/mod.rs b/crates/ghostty-terminal/src/formatter/tests/mod.rs new file mode 100644 index 0000000..370c19d --- /dev/null +++ b/crates/ghostty-terminal/src/formatter/tests/mod.rs @@ -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() +} diff --git a/crates/ghostty-terminal/src/formatter/tests/p1.rs b/crates/ghostty-terminal/src/formatter/tests/p1.rs new file mode 100644 index 0000000..169c9b5 --- /dev/null +++ b/crates/ghostty-terminal/src/formatter/tests/p1.rs @@ -0,0 +1,589 @@ +//! "Page plain ..." tests (formatter.zig lines 1580-3287). +use super::*; + +struct Out { + s: String, + st: TrailingState, + map: Vec, +} + +/// 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 { + (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)); +} diff --git a/crates/ghostty-terminal/src/formatter/tests/p2.rs b/crates/ghostty-terminal/src/formatter/tests/p2.rs new file mode 100644 index 0000000..084fce4 --- /dev/null +++ b/crates/ghostty-terminal/src/formatter/tests/p2.rs @@ -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) { + 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) { + 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); + } +} diff --git a/crates/ghostty-terminal/src/formatter/tests/p3.rs b/crates/ghostty-terminal/src/formatter/tests/p3.rs new file mode 100644 index 0000000..e432432 --- /dev/null +++ b/crates/ghostty-terminal/src/formatter/tests/p3.rs @@ -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) { + let mut pin_map: Vec = 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 = "
"; + +#[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, + "
\ +
bold
\ +
italic
\ + normal\ +
" + ); +} + +#[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
")); +} + +#[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, + "
\ +
colored
\ +
" + ); +} + +#[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("")); + 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, + "
hello
" + ); +} + +#[test] +fn page_html_with_escaping() { + let mut t = term(80, 24); + feed(&mut t, b"&\"'text"); + + let opts = Options::html(); + let mut point_map: Vec = 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}<tag>&"'text") + ); + + 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}╰─ ❯")); +} + +#[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")); +} + +#[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 ╰─❯ ok") + ); +} + +#[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, + "
\ +
red
\ +
" + ); + + let mut opts = Options::html(); + opts.palette = Some(t.colors.palette.current); + let output = page_fmt(&t, &opts); + assert_eq!( + output, + "
\ +
red
\ +
" + ); +} + +#[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"); +} diff --git a/crates/ghostty-terminal/src/formatter/tests/p4.rs b/crates/ghostty-terminal/src/formatter/tests/p4.rs new file mode 100644 index 0000000..bd5b673 --- /dev/null +++ b/crates/ghostty-terminal/src/formatter/tests/p4.rs @@ -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>) -> 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) -> 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 = "
"; + +fn html(t: &Terminal, point_map: Option<&mut Vec>) -> 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}link text normal
") + ); +} + +#[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}first second" + ) + ); +} + +#[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}link") + ); +} + +#[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}" + ) + ); +} + +#[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} plain" + ) + ); +} + +#[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}link normal"); + assert_eq!(output, expected_output); + assert_eq!(expected_output.len(), point_map.len()); + + // The closing tag bytes should all map to the last cell of the link + let closing_idx = expected_output.find("").unwrap(); + let expected_coord = point_map[closing_idx - 1]; + for i in closing_idx..closing_idx + "".len() { + assert_eq!(point_map[i], expected_coord); + } +} diff --git a/crates/ghostty-terminal/src/highlight.rs b/crates/ghostty-terminal/src/highlight.rs new file mode 100644 index 0000000..a9353d1 --- /dev/null +++ b/crates/ghostty-terminal/src/highlight.rs @@ -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::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 { + 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 { + 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, + + /// 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 { + 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 + '_ { + 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) + }) +} diff --git a/crates/ghostty-terminal/src/lib.rs b/crates/ghostty-terminal/src/lib.rs index 2a6948e..63c5cae 100644 --- a/crates/ghostty-terminal/src/lib.rs +++ b/crates/ghostty-terminal/src/lib.rs @@ -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; diff --git a/crates/ghostty-terminal/src/render.rs b/crates/ghostty-terminal/src/render.rs new file mode 100644 index 0000000..17713fd --- /dev/null +++ b/crates/ghostty-terminal/src/render.rs @@ -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, + + /// 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, + + /// The cached selection so we can avoid expensive selection calculations + /// if possible. + selection_cache: Option, +} + +/// 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, + 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, + + /// 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, +} + +#[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, + pub grapheme: Vec>, + pub style: Vec