feat(terminal): port render state, highlights and the formatter
- render.rs ports render.zig: RenderState, the per-frame snapshot the renderer consumes (rows, cells, styles, graphemes, selection, highlights, cursor, colors), updated incrementally with Zig's full redraw triggers and per-page/per-row dirty tracking. Cells are stored struct-of-arrays; use the accessors. - highlight.rs ports highlight.zig (untracked, tracked, flattened). - formatter.rs ports formatter.zig: plain, VT and HTML output for terminal, screen, page list and page, with selection, rectangle, unwrap, trim, palette, codepoint maps, hyperlinks and pin/point maps. Screen's test dump helpers now delegate to it. Cargo.toml also picks up dependencies for the kitty graphics and search modules that are being ported concurrently. Test Plan: - cargo test -p ghostty-terminal --lib (1243 passed; formatter 100/100, render 15/15 Zig tests)
This commit is contained in:
1 parent
225f5ae2d7
commit
40dc87519e
13 files changed
+5510
-356
No files matched your search
@@ -0,0 +1,1581 @@
|
||||
//! Terminal state formatters: plain text, VT (with styles) and HTML.
|
||||
//!
|
||||
//! Port of `ZIG/src/terminal/formatter.zig`.
|
||||
//!
|
||||
//! Writers are `&mut dyn std::io::Write`; use the `format_string` helpers (or
|
||||
//! write into a `Vec<u8>`) for in-memory output. Where Zig used a
|
||||
//! `Discarding` writer to count bytes (for pin maps), the Rust port renders
|
||||
//! into a small scratch buffer and measures it.
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use crate::{
|
||||
highlight::pin_page_iterator,
|
||||
hyperlink::HyperlinkId,
|
||||
page::{Cell, ContentTag, Page, Wide},
|
||||
page_list::{PageList, Pin},
|
||||
point::{Coordinate, Tag},
|
||||
screen::Screen,
|
||||
selection::Selection,
|
||||
size,
|
||||
style::{Color, Style},
|
||||
terminal::Terminal,
|
||||
};
|
||||
use ghostty_vt::{
|
||||
charsets::{Charset, Slots},
|
||||
color::{Palette, Rgb},
|
||||
modes::ENTRIES,
|
||||
};
|
||||
use std::io::{self, Write};
|
||||
|
||||
type Result<T> = io::Result<T>;
|
||||
|
||||
/// Formats available.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
/// Plain text.
|
||||
Plain,
|
||||
|
||||
/// Include VT sequences to preserve colors, styles, URLs, etc.
|
||||
/// This is predominantly SGR sequences but may contain others as needed.
|
||||
///
|
||||
/// Note that for reference colors, like palette indices, this will
|
||||
/// vary based on the formatter and you should see the docs. For example,
|
||||
/// PageFormatter with VT will emit SGR sequences with palette indices,
|
||||
/// not the color itself.
|
||||
///
|
||||
/// For VT, newlines will be emitted as `\r\n` so that the cursor properly
|
||||
/// moves back to the beginning prior emitting follow-up lines.
|
||||
Vt,
|
||||
|
||||
/// HTML output.
|
||||
///
|
||||
/// This will emit inline styles for as much styling as possible,
|
||||
/// in the interest of simplicity and ease of editing. This isn't meant
|
||||
/// to build the most beautiful or efficient HTML, but rather to be
|
||||
/// stylistically correct.
|
||||
///
|
||||
/// For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette
|
||||
/// indices use CSS variables (var(--vt-palette-N)). The palette colors are
|
||||
/// emitted by TerminalFormatter.Extra.palette as a <style> block if you
|
||||
/// want to also include that. But if you only format a screen or lower,
|
||||
/// the formatter doesn't have access to the current palette to render it.
|
||||
///
|
||||
/// Newlines are emitted as actual '\n' characters. Consumers should use
|
||||
/// CSS white-space: pre or pre-wrap to preserve spacing and alignment.
|
||||
Html,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
pub fn styled(self) -> bool {
|
||||
match self {
|
||||
Format::Plain => false,
|
||||
Format::Html | Format::Vt => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodepointMap {
|
||||
/// Unicode codepoint range to replace.
|
||||
/// Asserts: range[0] <= range[1]
|
||||
pub range: [u32; 2],
|
||||
|
||||
/// Replacement value for this range.
|
||||
pub replacement: Replacement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Replacement {
|
||||
/// A single replacement codepoint.
|
||||
Codepoint(u32),
|
||||
|
||||
/// A string to replace with.
|
||||
String(String),
|
||||
}
|
||||
|
||||
/// Common encoding options regardless of what exact formatter is used.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Options {
|
||||
/// The format to emit.
|
||||
pub emit: Format,
|
||||
|
||||
/// Whether to unwrap soft-wrapped lines. If false, this will emit the
|
||||
/// screen contents as it is rendered on the page in the given size.
|
||||
pub unwrap: bool,
|
||||
|
||||
/// Trim trailing whitespace on lines with other text. Trailing blank
|
||||
/// lines are always trimmed. This only affects trailing whitespace
|
||||
/// on rows that have at least one other cell with text. Whitespace
|
||||
/// is currently only space characters (0x20).
|
||||
pub trim: bool,
|
||||
|
||||
/// Replace matching Unicode codepoints with some other values.
|
||||
/// This will use the last matching range found in the list.
|
||||
pub codepoint_map: Option<Vec<CodepointMap>>,
|
||||
|
||||
/// Set a background and foreground color to use for the "screen".
|
||||
/// For styled formats, this will emit the proper sequences or styles.
|
||||
pub background: Option<Rgb>,
|
||||
pub foreground: Option<Rgb>,
|
||||
|
||||
/// If set, then styled formats in `emit` will use this palette to
|
||||
/// emit colors directly as RGB. If this is None, styled formats will
|
||||
/// still work but will use deferred palette styling (e.g. CSS variables
|
||||
/// for HTML or the actual palette indexes for VT).
|
||||
pub palette: Option<Palette>,
|
||||
}
|
||||
|
||||
impl Options {
|
||||
pub fn new(emit: Format) -> Options {
|
||||
Options {
|
||||
emit,
|
||||
unwrap: false,
|
||||
trim: true,
|
||||
codepoint_map: Some(Vec::new()),
|
||||
background: None,
|
||||
foreground: None,
|
||||
palette: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plain() -> Options {
|
||||
Options::new(Format::Plain)
|
||||
}
|
||||
|
||||
pub fn vt() -> Options {
|
||||
Options::new(Format::Vt)
|
||||
}
|
||||
|
||||
pub fn html() -> Options {
|
||||
Options::new(Format::Html)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps byte positions in formatted output to PageList pins.
|
||||
///
|
||||
/// Used by formatters that operate on PageLists to track the source position
|
||||
/// of each byte written. (Zig: `PinMap{alloc, map}`; here simply the vector to
|
||||
/// append to.)
|
||||
pub type PinMap<'a> = &'a mut Vec<Pin>;
|
||||
|
||||
fn hex(rgb: Rgb) -> String {
|
||||
format!("{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
fn invalid_cp() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "invalid codepoint")
|
||||
}
|
||||
|
||||
fn clone_pin(p: Pin) -> Pin {
|
||||
Pin {
|
||||
node: p.node,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
garbage: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `count` copies of the last pin in `map` (or the top-left of the
|
||||
/// screen when empty).
|
||||
fn map_pad_last_or_top(map: &mut Vec<Pin>, pages: &PageList, count: usize) {
|
||||
let pin = match map.last() {
|
||||
Some(last) => clone_pin(*last),
|
||||
None => pages.get_top_left(Tag::Screen).expect("top left"),
|
||||
};
|
||||
map.extend(std::iter::repeat_n(pin, count));
|
||||
}
|
||||
|
||||
/// Terminal formatter formats the active terminal screen.
|
||||
///
|
||||
/// This will always only emit data related to the currently active screen.
|
||||
/// If you want to emit data for a specific screen (e.g. primary vs alt), then
|
||||
/// switch to that screen in the terminal prior to using this.
|
||||
///
|
||||
/// If you want to emit data for all screens (a less common operation), then
|
||||
/// you must create a no-content TerminalFormatter followed by multiple
|
||||
/// explicit ScreenFormatter calls. This isn't a common operation so this
|
||||
/// little extra work should be acceptable.
|
||||
///
|
||||
/// For styled formatting, this will emit the palette colors at the
|
||||
/// beginning so that the output can be rendered properly according to
|
||||
/// the current terminal state.
|
||||
pub struct TerminalFormatter<'a> {
|
||||
/// The terminal to format.
|
||||
pub terminal: &'a Terminal,
|
||||
|
||||
/// The common options
|
||||
pub opts: Options,
|
||||
|
||||
/// The content to include.
|
||||
pub content: Content,
|
||||
|
||||
/// Extra stuff to emit, such as terminal modes, palette, cursor, etc.
|
||||
/// This information is ONLY emitted when the format is "vt".
|
||||
pub extra: TerminalExtra,
|
||||
|
||||
/// If non-null, then `map` will contain the Pin of every byte
|
||||
/// byte written to the writer offset by the byte index.
|
||||
///
|
||||
/// Note that some emitted bytes may not correspond to any Pin, such as
|
||||
/// the extra data around terminal state (palette, modes, etc.). For these,
|
||||
/// we'll map it to the most previous pin so there is some continuity but
|
||||
/// its an arbitrary choice.
|
||||
///
|
||||
/// Warning: there is a significant performance hit to track this
|
||||
pub pin_map: Option<PinMap<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TerminalExtra {
|
||||
/// Emit the palette using OSC 4 sequences.
|
||||
pub palette: bool,
|
||||
|
||||
/// Emit terminal modes that differ from their defaults using CSI h/l
|
||||
/// sequences. Defaults are according to the Ghostty defaults which
|
||||
/// are generally match most terminal defaults. This will include
|
||||
/// things like current screen, bracketed mode, mouse event reporting,
|
||||
/// etc.
|
||||
pub modes: bool,
|
||||
|
||||
/// Emit scrolling region state using DECSTBM and DECSLRM sequences.
|
||||
pub scrolling_region: bool,
|
||||
|
||||
/// Emit tabstop positions by clearing all tabs (CSI 3 g) and setting
|
||||
/// each configured tabstop with HTS.
|
||||
pub tabstops: bool,
|
||||
|
||||
/// Emit the present working directory using OSC 7.
|
||||
pub pwd: bool,
|
||||
|
||||
/// Emit keyboard modes such as ModifyOtherKeys using CSI > 4 m
|
||||
/// sequences.
|
||||
pub keyboard: bool,
|
||||
|
||||
/// The screen extras to emit. TerminalFormatter always only
|
||||
/// emits data for the currently active screen. If you want to emit
|
||||
/// data for all screens, you should manually construct a no-content
|
||||
/// terminal formatter, followed by screen formatters.
|
||||
pub screen: ScreenExtra,
|
||||
}
|
||||
|
||||
impl TerminalExtra {
|
||||
/// Emit nothing.
|
||||
pub const NONE: TerminalExtra = TerminalExtra {
|
||||
palette: false,
|
||||
modes: false,
|
||||
scrolling_region: false,
|
||||
tabstops: false,
|
||||
pwd: false,
|
||||
keyboard: false,
|
||||
screen: ScreenExtra::NONE,
|
||||
};
|
||||
|
||||
/// Emit style-relevant information only such as palettes.
|
||||
pub const STYLES: TerminalExtra = TerminalExtra {
|
||||
palette: true,
|
||||
modes: false,
|
||||
scrolling_region: false,
|
||||
tabstops: false,
|
||||
pwd: false,
|
||||
keyboard: false,
|
||||
screen: ScreenExtra::STYLES,
|
||||
};
|
||||
|
||||
/// Emit everything. This reconstructs the terminal state as closely
|
||||
/// as possible.
|
||||
pub const ALL: TerminalExtra = TerminalExtra {
|
||||
palette: true,
|
||||
modes: true,
|
||||
scrolling_region: true,
|
||||
tabstops: true,
|
||||
pwd: true,
|
||||
keyboard: true,
|
||||
screen: ScreenExtra::ALL,
|
||||
};
|
||||
}
|
||||
|
||||
impl<'a> TerminalFormatter<'a> {
|
||||
pub fn new(terminal: &'a Terminal, opts: Options) -> Self {
|
||||
TerminalFormatter {
|
||||
terminal,
|
||||
opts,
|
||||
content: Content::Selection(None),
|
||||
extra: TerminalExtra::STYLES,
|
||||
pin_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format into a new `String` (convenience, no pin map is touched
|
||||
/// beyond what `pin_map` asks for).
|
||||
pub fn format_string(self) -> Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
self.format(&mut buf)?;
|
||||
String::from_utf8(buf).map_err(|_| invalid_cp())
|
||||
}
|
||||
|
||||
pub fn format(mut self, writer: &mut dyn Write) -> Result<()> {
|
||||
let pages = &self.terminal.screens.active().pages;
|
||||
|
||||
// Emit palette before screen content if using VT format. Technically
|
||||
// we could do this after but this way if replay is slow for whatever
|
||||
// reason the colors will be right right away.
|
||||
if self.extra.palette {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
match self.opts.emit {
|
||||
Format::Plain => {}
|
||||
|
||||
Format::Vt => {
|
||||
for (i, rgb) in self.terminal.colors.palette.current.iter().enumerate() {
|
||||
write!(
|
||||
buf,
|
||||
"\x1b]4;{};rgb:{:02x}/{:02x}/{:02x}\x1b\\",
|
||||
i, rgb.r, rgb.g, rgb.b
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// For HTML, we emit CSS to setup our palette variables.
|
||||
Format::Html => {
|
||||
buf.extend_from_slice(b"<style>:root{");
|
||||
for (i, rgb) in self.terminal.colors.palette.current.iter().enumerate() {
|
||||
write!(
|
||||
buf,
|
||||
"--vt-palette-{}: #{:02x}{:02x}{:02x};",
|
||||
i, rgb.r, rgb.g, rgb.b
|
||||
)?;
|
||||
}
|
||||
buf.extend_from_slice(b"}</style>");
|
||||
}
|
||||
}
|
||||
writer.write_all(&buf)?;
|
||||
|
||||
// If we have a pin_map, add the bytes we wrote to map. Map all
|
||||
// those bytes to the same pin. Use the top left to ensure the
|
||||
// node is always properly initialized.
|
||||
if self.opts.emit != Format::Plain
|
||||
&& let Some(m) = self.pin_map.as_deref_mut()
|
||||
{
|
||||
let top = pages.get_top_left(Tag::Screen).expect("top left");
|
||||
m.extend(std::iter::repeat_n(top, buf.len()));
|
||||
}
|
||||
}
|
||||
|
||||
// Emit terminal modes that differ from defaults. We probably have
|
||||
// some modes we want to emit before and some after, but for now for
|
||||
// simplicity we just emit them all before. If we make this more complex
|
||||
// later we should add test cases for it.
|
||||
if self.opts.emit == Format::Vt && self.extra.modes {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
for entry in ENTRIES {
|
||||
let current = self.terminal.modes.get(entry.mode);
|
||||
let default_val = self.terminal.modes.default.get(entry.mode);
|
||||
|
||||
if current != default_val {
|
||||
let prefix = if entry.ansi { "" } else { "?" };
|
||||
let suffix = if current { "h" } else { "l" };
|
||||
write!(buf, "\x1b[{}{}{}", prefix, entry.value, suffix)?;
|
||||
}
|
||||
}
|
||||
writer.write_all(&buf)?;
|
||||
|
||||
if let Some(m) = self.pin_map.as_deref_mut() {
|
||||
let top = pages.get_top_left(Tag::Screen).expect("top left");
|
||||
m.extend(std::iter::repeat_n(top, buf.len()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut screen_formatter =
|
||||
ScreenFormatter::new(self.terminal.screens.active(), self.opts.clone());
|
||||
screen_formatter.content = self.content;
|
||||
screen_formatter.extra = self.extra.screen;
|
||||
screen_formatter.pin_map = self.pin_map.as_deref_mut();
|
||||
screen_formatter.format(writer)?;
|
||||
|
||||
// Extra terminal state to emit after the screen contents so that
|
||||
// it doesn't impact the emitted contents.
|
||||
if self.opts.emit == Format::Vt {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
|
||||
// Emit scrolling region using DECSTBM and DECSLRM
|
||||
if self.extra.scrolling_region {
|
||||
let region = &self.terminal.scrolling_region;
|
||||
|
||||
// DECSTBM: top and bottom margins (1-indexed)
|
||||
// Only emit if not the full screen
|
||||
if region.top != 0 || region.bottom != self.terminal.rows - 1 {
|
||||
write!(buf, "\x1b[{};{}r", region.top + 1, region.bottom + 1)?;
|
||||
}
|
||||
|
||||
// DECSLRM: left and right margins (1-indexed)
|
||||
// Only emit if not the full width
|
||||
if region.left != 0 || region.right != self.terminal.cols - 1 {
|
||||
write!(buf, "\x1b[{};{}s", region.left + 1, region.right + 1)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit tabstop positions
|
||||
if self.extra.tabstops {
|
||||
// Clear all tabs (CSI 3 g)
|
||||
write!(buf, "\x1b[3g")?;
|
||||
|
||||
// Set each configured tabstop by moving cursor and using HTS
|
||||
for col in 0..usize::from(self.terminal.cols) {
|
||||
if self.terminal.tabstops.get(col) {
|
||||
// Move cursor to the column (1-indexed)
|
||||
write!(buf, "\x1b[{}G", col + 1)?;
|
||||
// Set tab (HTS)
|
||||
write!(buf, "\x1bH")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit keyboard modes such as ModifyOtherKeys
|
||||
if self.extra.keyboard {
|
||||
// Only emit if modify_other_keys_2 is true
|
||||
if self.terminal.flags.modify_other_keys_2 {
|
||||
write!(buf, "\x1b[>4;2m")?;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit present working directory using OSC 7
|
||||
if self.extra.pwd {
|
||||
let pwd = &self.terminal.pwd;
|
||||
if !pwd.is_empty() {
|
||||
buf.extend_from_slice(b"\x1b]7;");
|
||||
buf.extend_from_slice(pwd);
|
||||
buf.extend_from_slice(b"\x1b\\");
|
||||
}
|
||||
}
|
||||
|
||||
writer.write_all(&buf)?;
|
||||
|
||||
// If we have a pin_map, add the bytes we wrote to map.
|
||||
if let Some(m) = self.pin_map.as_deref_mut() {
|
||||
map_pad_last_or_top(m, pages, buf.len());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen formatter formats a single terminal screen (e.g. primary vs alt).
|
||||
pub struct ScreenFormatter<'a> {
|
||||
/// The screen to format.
|
||||
pub screen: &'a Screen,
|
||||
|
||||
/// The common options
|
||||
pub opts: Options,
|
||||
|
||||
/// The content to include.
|
||||
pub content: Content,
|
||||
|
||||
/// Extra stuff to emit, such as cursor, style, hyperlinks, etc.
|
||||
/// This information is ONLY emitted when the format is "vt".
|
||||
pub extra: ScreenExtra,
|
||||
|
||||
/// If non-null, then `map` will contain the Pin of every byte
|
||||
/// byte written to the writer offset by the byte index.
|
||||
///
|
||||
/// Note that some emitted bytes may not correspond to any Pin, such as
|
||||
/// the extra data around screen state. For these, we'll map it to the
|
||||
/// most previous pin so there is some continuity but its an arbitrary
|
||||
/// choice.
|
||||
///
|
||||
/// Warning: there is a significant performance hit to track this
|
||||
pub pin_map: Option<PinMap<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Content {
|
||||
/// Emit no content, only terminal state such as modes, palette, etc.
|
||||
/// via extra.
|
||||
None,
|
||||
|
||||
/// Emit the content specified by the selection. None for all.
|
||||
/// The selection is inclusive on both ends.
|
||||
Selection(Option<Selection>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ScreenExtra {
|
||||
/// Emit cursor position using CUP (CSI H).
|
||||
pub cursor: bool,
|
||||
|
||||
/// Emit current SGR style state based on the cursor's active style_id.
|
||||
/// This reconstructs the SGR attributes (bold, italic, colors, etc.) at
|
||||
/// the cursor position.
|
||||
pub style: bool,
|
||||
|
||||
/// Emit current hyperlink state using OSC 8 sequences.
|
||||
/// This sets the active hyperlink based on cursor.hyperlink_id.
|
||||
pub hyperlink: bool,
|
||||
|
||||
/// Emit character protection mode using DECSCA.
|
||||
pub protection: bool,
|
||||
|
||||
/// Emit Kitty keyboard protocol state using CSI > u and CSI = sequences.
|
||||
pub kitty_keyboard: bool,
|
||||
|
||||
/// Emit character set designations and invocations.
|
||||
/// This includes G0-G3 designations (ESC ( ) * +) and GL/GR invocations.
|
||||
pub charsets: bool,
|
||||
}
|
||||
|
||||
impl ScreenExtra {
|
||||
/// Emit nothing.
|
||||
pub const NONE: ScreenExtra = ScreenExtra {
|
||||
cursor: false,
|
||||
style: false,
|
||||
hyperlink: false,
|
||||
protection: false,
|
||||
kitty_keyboard: false,
|
||||
charsets: false,
|
||||
};
|
||||
|
||||
/// Emit style-relevant information only.
|
||||
pub const STYLES: ScreenExtra = ScreenExtra {
|
||||
cursor: false,
|
||||
style: true,
|
||||
hyperlink: true,
|
||||
protection: false,
|
||||
kitty_keyboard: false,
|
||||
charsets: false,
|
||||
};
|
||||
|
||||
/// Emit everything. This reconstructs the screen state as closely
|
||||
/// as possible.
|
||||
pub const ALL: ScreenExtra = ScreenExtra {
|
||||
cursor: true,
|
||||
style: true,
|
||||
hyperlink: true,
|
||||
protection: true,
|
||||
kitty_keyboard: true,
|
||||
charsets: true,
|
||||
};
|
||||
|
||||
fn is_set(self) -> bool {
|
||||
self != Self::NONE
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ScreenFormatter<'a> {
|
||||
pub fn new(screen: &'a Screen, opts: Options) -> Self {
|
||||
ScreenFormatter {
|
||||
screen,
|
||||
opts,
|
||||
content: Content::Selection(None),
|
||||
extra: ScreenExtra::NONE,
|
||||
pin_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format into a new `String`.
|
||||
pub fn format_string(self) -> Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
self.format(&mut buf)?;
|
||||
String::from_utf8(buf).map_err(|_| invalid_cp())
|
||||
}
|
||||
|
||||
pub fn format(mut self, writer: &mut dyn Write) -> Result<()> {
|
||||
match self.content {
|
||||
Content::None => {}
|
||||
|
||||
Content::Selection(selection) => {
|
||||
// Emit our pagelist contents according to our selection.
|
||||
let mut list_formatter =
|
||||
PageListFormatter::new(&self.screen.pages, self.opts.clone());
|
||||
list_formatter.pin_map = self.pin_map.as_deref_mut();
|
||||
if let Some(sel) = selection {
|
||||
list_formatter.top_left = Some(sel.top_left(&self.screen.pages));
|
||||
list_formatter.bottom_right = Some(sel.bottom_right(&self.screen.pages));
|
||||
list_formatter.rectangle = sel.rectangle;
|
||||
}
|
||||
list_formatter.format(writer)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit extra screen state after content if we care. The state has
|
||||
// to be emitted after since some state such as cursor position and
|
||||
// style are impacted by content rendering.
|
||||
match self.opts.emit {
|
||||
Format::Plain => return Ok(()),
|
||||
Format::Vt => {
|
||||
if !self.extra.is_set() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// HTML doesn't preserve any screen state because it has
|
||||
// nothing to do with rendering.
|
||||
Format::Html => return Ok(()),
|
||||
}
|
||||
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
|
||||
// Emit current SGR style state
|
||||
if self.extra.style {
|
||||
let cursor = &self.screen.cursor;
|
||||
write!(buf, "{}", cursor.style.formatter_vt())?;
|
||||
}
|
||||
|
||||
// Emit current hyperlink state using OSC 8
|
||||
if self.extra.hyperlink
|
||||
&& let Some(link) = &self.screen.cursor.hyperlink
|
||||
{
|
||||
// Start hyperlink with uri (and explicit id if present)
|
||||
match &link.id {
|
||||
HyperlinkId::Explicit(id) => {
|
||||
buf.extend_from_slice(b"\x1b]8;id=");
|
||||
buf.extend_from_slice(id);
|
||||
buf.push(b';');
|
||||
buf.extend_from_slice(&link.uri);
|
||||
buf.extend_from_slice(b"\x1b\\");
|
||||
}
|
||||
HyperlinkId::Implicit(_) => {
|
||||
buf.extend_from_slice(b"\x1b]8;;");
|
||||
buf.extend_from_slice(&link.uri);
|
||||
buf.extend_from_slice(b"\x1b\\");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit character protection mode using DECSCA
|
||||
if self.extra.protection && self.screen.cursor.protected {
|
||||
// DEC protected mode
|
||||
write!(buf, "\x1b[1\"q")?;
|
||||
}
|
||||
|
||||
// Emit Kitty keyboard protocol state using CSI = u
|
||||
if self.extra.kitty_keyboard {
|
||||
let current_flags = self.screen.kitty_keyboard.current();
|
||||
if current_flags.int() != ghostty_vt::kitty::key::Flags::DISABLED.int() {
|
||||
write!(buf, "\x1b[={};1u", current_flags.int())?;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit character set designations and invocations
|
||||
if self.extra.charsets {
|
||||
let charset = &self.screen.charset;
|
||||
|
||||
// Emit G0-G3 designations
|
||||
for slot in [Slots::G0, Slots::G1, Slots::G2, Slots::G3] {
|
||||
let cs = charset.charsets.get(slot);
|
||||
if cs != Charset::Utf8 {
|
||||
// Only emit non-default charsets
|
||||
let intermediate = match slot {
|
||||
Slots::G0 => '(',
|
||||
Slots::G1 => ')',
|
||||
Slots::G2 => '*',
|
||||
Slots::G3 => '+',
|
||||
};
|
||||
let fin = match cs {
|
||||
Charset::Ascii => 'B',
|
||||
Charset::British => 'A',
|
||||
Charset::DecSpecial => '0',
|
||||
Charset::Utf8 => continue,
|
||||
};
|
||||
write!(buf, "\x1b{intermediate}{fin}")?;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit GL invocation if not G0
|
||||
if charset.gl != Slots::G0 {
|
||||
let seq: &[u8] = match charset.gl {
|
||||
Slots::G0 => unreachable!(),
|
||||
Slots::G1 => b"\x0e", // SO - Shift Out
|
||||
Slots::G2 => b"\x1bn", // LS2
|
||||
Slots::G3 => b"\x1bo", // LS3
|
||||
};
|
||||
buf.extend_from_slice(seq);
|
||||
}
|
||||
|
||||
// Emit GR invocation if not G2
|
||||
if charset.gr != Slots::G2 {
|
||||
let seq: &[u8] = match charset.gr {
|
||||
Slots::G0 => unreachable!(), // GR can't be G0
|
||||
Slots::G1 => b"\x1b~", // LS1R
|
||||
Slots::G2 => unreachable!(),
|
||||
Slots::G3 => b"\x1b|", // LS3R
|
||||
};
|
||||
buf.extend_from_slice(seq);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit cursor position using CUP (CSI H)
|
||||
if self.extra.cursor {
|
||||
let cursor = &self.screen.cursor;
|
||||
// CUP is 1-indexed
|
||||
write!(buf, "\x1b[{};{}H", cursor.y + 1, cursor.x + 1)?;
|
||||
}
|
||||
|
||||
writer.write_all(&buf)?;
|
||||
|
||||
// If we have a pin_map, map all the extra bytes to the same pin.
|
||||
if let Some(m) = self.pin_map.as_deref_mut() {
|
||||
map_pad_last_or_top(m, &self.screen.pages, buf.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// PageList formatter formats multiple pages as represented by a PageList.
|
||||
pub struct PageListFormatter<'a> {
|
||||
/// The pagelist to format.
|
||||
pub list: &'a PageList,
|
||||
|
||||
/// The common options
|
||||
pub opts: Options,
|
||||
|
||||
/// The bounds of the PageList to format. The top left and bottom right
|
||||
/// MUST be ordered properly.
|
||||
pub top_left: Option<Pin>,
|
||||
pub bottom_right: Option<Pin>,
|
||||
|
||||
/// If true, the boundaries define a rectangle selection where start_x
|
||||
/// and end_x apply to every row, not just the first and last.
|
||||
pub rectangle: bool,
|
||||
|
||||
/// If non-null, then `map` will contain the Pin of every byte
|
||||
/// byte written to the writer offset by the byte index.
|
||||
///
|
||||
/// Warning: there is a significant performance hit to track this
|
||||
pub pin_map: Option<PinMap<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> PageListFormatter<'a> {
|
||||
pub fn new(list: &'a PageList, opts: Options) -> Self {
|
||||
PageListFormatter {
|
||||
list,
|
||||
opts,
|
||||
top_left: None,
|
||||
bottom_right: None,
|
||||
rectangle: false,
|
||||
pin_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format into a new `String`.
|
||||
pub fn format_string(self) -> Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
self.format(&mut buf)?;
|
||||
String::from_utf8(buf).map_err(|_| invalid_cp())
|
||||
}
|
||||
|
||||
pub fn format(mut self, writer: &mut dyn Write) -> Result<()> {
|
||||
let tl = self
|
||||
.top_left
|
||||
.unwrap_or_else(|| self.list.get_top_left(Tag::Screen).expect("top left"));
|
||||
let br = self.bottom_right.unwrap_or_else(|| {
|
||||
self.list
|
||||
.get_bottom_right(Tag::Screen)
|
||||
.expect("bottom right")
|
||||
});
|
||||
|
||||
// If we keep track of pins, we'll need this.
|
||||
let mut point_map: Vec<Coordinate> = Vec::new();
|
||||
|
||||
let mut page_state: Option<TrailingState> = None;
|
||||
for chunk in pin_page_iterator(self.list, tl, br) {
|
||||
assert!(chunk.start < chunk.end);
|
||||
assert!(chunk.end > 0);
|
||||
|
||||
let node = self.list.node(chunk.node).expect("chunk node");
|
||||
let mut formatter = PageFormatter::new(&node.data, &self.opts);
|
||||
formatter.start_y = chunk.start;
|
||||
formatter.end_y = Some(chunk.end - 1);
|
||||
formatter.trailing_state = page_state;
|
||||
formatter.rectangle = self.rectangle;
|
||||
|
||||
// For rectangle selection, apply start_x and end_x to all chunks
|
||||
if self.rectangle {
|
||||
formatter.start_x = tl.x;
|
||||
formatter.end_x = Some(br.x);
|
||||
} else {
|
||||
// Otherwise only on the first/last, respectively.
|
||||
if chunk.node == tl.node {
|
||||
formatter.start_x = tl.x;
|
||||
}
|
||||
if chunk.node == br.node {
|
||||
formatter.end_x = Some(br.x);
|
||||
}
|
||||
}
|
||||
|
||||
// If we're tracking pins, then we setup a point map for the
|
||||
// page formatter (cause it can't track pins). And then we convert
|
||||
// this to pins later.
|
||||
let tracking = self.pin_map.is_some();
|
||||
if tracking {
|
||||
point_map.clear();
|
||||
formatter.point_map = Some(&mut point_map);
|
||||
}
|
||||
|
||||
page_state = Some(formatter.format_with_state(writer)?);
|
||||
|
||||
// If we're tracking pins then grab our points and write them
|
||||
// to our pin map.
|
||||
if let Some(m) = self.pin_map.as_deref_mut() {
|
||||
for coord in &point_map {
|
||||
m.push(Pin {
|
||||
node: chunk.node,
|
||||
x: coord.x,
|
||||
y: coord.y as size::CellCountInt,
|
||||
garbage: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Trailing state. This is used to ensure that rows wrapped across
|
||||
/// multiple pages are unwrapped properly, as well as other accounting
|
||||
/// we may do in the future.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct TrailingState {
|
||||
pub rows: usize,
|
||||
pub cells: usize,
|
||||
}
|
||||
|
||||
impl TrailingState {
|
||||
pub const EMPTY: TrailingState = TrailingState { rows: 0, cells: 0 };
|
||||
}
|
||||
|
||||
/// Page formatter.
|
||||
///
|
||||
/// For styled formatting such as VT, this will emit references for palette
|
||||
/// colors. If you want to capture the palette as-is at the type of formatting,
|
||||
/// you'll have to emit the sequences for setting up the palette prior to
|
||||
/// this formatting. (TODO: A function to do this)
|
||||
pub struct PageFormatter<'a> {
|
||||
/// The page to format.
|
||||
pub page: &'a Page,
|
||||
|
||||
/// The common options
|
||||
pub opts: &'a Options,
|
||||
|
||||
/// Start and end points within the page to format. If end x is not given
|
||||
/// then it will be the full width. If end y is not given then it will be
|
||||
/// the full height.
|
||||
///
|
||||
/// The start and end are both inclusive, so equal values will still
|
||||
/// return a non-empty result (i.e. a single cell or row).
|
||||
///
|
||||
/// The start x is considered the X in the first row and end X is
|
||||
/// X in the final row. This isn't a rectangle selection by default.
|
||||
///
|
||||
/// If start X falls on the second column of a wide character, then
|
||||
/// the entire character will be included (as if you specified the
|
||||
/// previous column).
|
||||
pub start_x: size::CellCountInt,
|
||||
pub start_y: size::CellCountInt,
|
||||
pub end_x: Option<size::CellCountInt>,
|
||||
pub end_y: Option<size::CellCountInt>,
|
||||
|
||||
/// If true, the start x/y and end x/y define a rectangle selection.
|
||||
/// In this case, the boundaries will apply to every row, not just
|
||||
/// the first and last.
|
||||
pub rectangle: bool,
|
||||
|
||||
/// If non-null, then `map` will contain the x/y coordinate of every
|
||||
/// byte written to the writer offset by the byte index.
|
||||
///
|
||||
/// The x/y coordinate will be the coordinates within the page.
|
||||
///
|
||||
/// Warning: there is a significant performance hit to track this
|
||||
pub point_map: Option<&'a mut Vec<Coordinate>>,
|
||||
|
||||
/// The previous trailing state from the prior page. If you're iterating
|
||||
/// over multiple pages this helps ensure that unwrapping and other
|
||||
/// accounting works properly.
|
||||
pub trailing_state: Option<TrailingState>,
|
||||
}
|
||||
|
||||
impl<'a> PageFormatter<'a> {
|
||||
/// Initializes a page formatter. Other options can be set directly on the
|
||||
/// struct after initialization and before calling `format()`.
|
||||
pub fn new(page: &'a Page, opts: &'a Options) -> Self {
|
||||
PageFormatter {
|
||||
page,
|
||||
opts,
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
end_x: None,
|
||||
end_y: None,
|
||||
rectangle: false,
|
||||
point_map: None,
|
||||
trailing_state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format into a new `String`.
|
||||
pub fn format_string(self) -> Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
self.format(&mut buf)?;
|
||||
String::from_utf8(buf).map_err(|_| invalid_cp())
|
||||
}
|
||||
|
||||
pub fn format(self, writer: &mut dyn Write) -> Result<()> {
|
||||
self.format_with_state(writer).map(|_| ())
|
||||
}
|
||||
|
||||
fn map_push_n(&mut self, coord: Coordinate, n: usize) {
|
||||
if let Some(m) = self.point_map.as_deref_mut() {
|
||||
m.extend(std::iter::repeat_n(coord, n));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_with_state(mut self, writer: &mut dyn Write) -> Result<TrailingState> {
|
||||
let page = self.page;
|
||||
let opts = self.opts;
|
||||
let mut blank_rows: usize = 0;
|
||||
let mut blank_cells: usize = 0;
|
||||
|
||||
// Continue our prior trailing state if we have it, but only if we're
|
||||
// starting from the beginning (start_y and start_x are both 0).
|
||||
// If a non-zero start position is specified, ignore trailing state.
|
||||
if let Some(state) = self.trailing_state
|
||||
&& self.start_y == 0
|
||||
&& self.start_x == 0
|
||||
{
|
||||
blank_rows = state.rows;
|
||||
blank_cells = state.cells;
|
||||
}
|
||||
let ret = |rows: usize, cells: usize| TrailingState { rows, cells };
|
||||
|
||||
// Setup our starting column and perform some validation for overflows.
|
||||
// Note: start_x only applies to the first row, end_x only applies to the last row.
|
||||
let start_x = self.start_x;
|
||||
if start_x >= page.size.cols {
|
||||
return Ok(ret(blank_rows, blank_cells));
|
||||
}
|
||||
let end_x_unclamped = self.end_x.unwrap_or(page.size.cols - 1);
|
||||
let mut end_x = end_x_unclamped.min(page.size.cols - 1);
|
||||
|
||||
// Setup our starting row and perform some validation for overflows.
|
||||
let start_y = self.start_y;
|
||||
if start_y >= page.size.rows {
|
||||
return Ok(ret(blank_rows, blank_cells));
|
||||
}
|
||||
let end_y_unclamped = self.end_y.unwrap_or(page.size.rows - 1);
|
||||
if start_y > end_y_unclamped {
|
||||
return Ok(ret(blank_rows, blank_cells));
|
||||
}
|
||||
let mut end_y = end_y_unclamped.min(page.size.rows - 1);
|
||||
|
||||
// Edge case: if our end x/y falls on a spacer head AND we're unwrapping,
|
||||
// then we move the x/y to the start of the next row (if available).
|
||||
if opts.unwrap && !self.rectangle {
|
||||
let cells = page.get_cells(usize::from(end_y)).expect("row cells");
|
||||
if cells[usize::from(end_x)].wide() == Wide::SpacerHead {
|
||||
// Move to next row if available
|
||||
//
|
||||
// TODO: if unavailable, we should add to our trailing state
|
||||
//
|
||||
// so the pagelist formatter can be aware and maybe add
|
||||
// another page
|
||||
if end_y < page.size.rows - 1 {
|
||||
end_y += 1;
|
||||
end_x = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we only have a single row, validate that start_x <= end_x
|
||||
if start_y == end_y && start_x > end_x {
|
||||
return Ok(ret(blank_rows, blank_cells));
|
||||
}
|
||||
|
||||
// Wrap HTML output in monospace font styling
|
||||
match opts.emit {
|
||||
Format::Plain => {}
|
||||
|
||||
Format::Html => {
|
||||
let mut header: Vec<u8> = Vec::new();
|
||||
|
||||
// Monospace and whitespace preserving
|
||||
header.extend_from_slice(b"<div style=\"font-family: monospace; white-space: pre;");
|
||||
|
||||
// Background/foreground colors
|
||||
if let Some(bg) = opts.background {
|
||||
write!(header, "background-color: #{};", hex(bg))?;
|
||||
}
|
||||
if let Some(fg) = opts.foreground {
|
||||
write!(header, "color: #{};", hex(fg))?;
|
||||
}
|
||||
|
||||
header.extend_from_slice(b"\">");
|
||||
|
||||
writer.write_all(&header)?;
|
||||
self.map_push_n(Coordinate { x: 0, y: 0 }, header.len());
|
||||
}
|
||||
|
||||
Format::Vt => {
|
||||
// OSC 10 sets foreground color, OSC 11 sets background color
|
||||
let mut header: Vec<u8> = Vec::new();
|
||||
if let Some(fg) = opts.foreground {
|
||||
write!(
|
||||
header,
|
||||
"\x1b]10;rgb:{:02x}/{:02x}/{:02x}\x1b\\",
|
||||
fg.r, fg.g, fg.b
|
||||
)?;
|
||||
}
|
||||
if let Some(bg) = opts.background {
|
||||
write!(
|
||||
header,
|
||||
"\x1b]11;rgb:{:02x}/{:02x}/{:02x}\x1b\\",
|
||||
bg.r, bg.g, bg.b
|
||||
)?;
|
||||
}
|
||||
|
||||
writer.write_all(&header)?;
|
||||
self.map_push_n(Coordinate { x: 0, y: 0 }, header.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Our style for non-plain formats
|
||||
let mut style = Style::default();
|
||||
|
||||
// Track hyperlink state for HTML output. We need to close </a> tags
|
||||
// when the hyperlink changes or ends.
|
||||
let mut current_hyperlink_id: Option<u16> = None;
|
||||
|
||||
let mut scratch: Vec<u8> = Vec::new();
|
||||
|
||||
'rows: for y in start_y..=end_y {
|
||||
let row = *page.get_row(usize::from(y)).expect("row");
|
||||
let cells: &[Cell] = page.get_cells(usize::from(y)).expect("cells");
|
||||
|
||||
// Determine the x range for this row
|
||||
// - First row: start_x to end of row (or end_x if single row)
|
||||
// - Last row: start of row to end_x
|
||||
// - Middle rows: full width
|
||||
let (cells_subset, row_start_x) = {
|
||||
// The end is always straightforward
|
||||
let row_end_x: size::CellCountInt = if self.rectangle || y == end_y {
|
||||
end_x + 1
|
||||
} else {
|
||||
page.size.cols
|
||||
};
|
||||
|
||||
// The first we have to check if our start X falls on the
|
||||
// tail of a wide character.
|
||||
let row_start_x: size::CellCountInt =
|
||||
if start_x > 0 && (self.rectangle || y == start_y) {
|
||||
match cells[usize::from(start_x)].wide() {
|
||||
// Include the prior cell to get the full wide char
|
||||
Wide::SpacerTail => start_x - 1,
|
||||
|
||||
// If we're a spacer head on our first row then we
|
||||
// skip this whole row.
|
||||
Wide::SpacerHead => continue 'rows,
|
||||
|
||||
Wide::Narrow | Wide::Wide => start_x,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
(
|
||||
&cells[usize::from(row_start_x)..usize::from(row_end_x)],
|
||||
row_start_x,
|
||||
)
|
||||
};
|
||||
|
||||
// If this row is blank, accumulate to avoid a bunch of extra
|
||||
// work later. If it isn't blank, make sure we dump all our
|
||||
// blanks.
|
||||
if !Cell::has_text_any(cells_subset) {
|
||||
blank_rows += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if blank_rows > 0 {
|
||||
// Reset style before emitting newlines to prevent background
|
||||
// colors from bleeding into the next line's leading cells.
|
||||
if !style.is_default() {
|
||||
self.format_style_close(writer)?;
|
||||
style = Style::default();
|
||||
}
|
||||
|
||||
let sequence: &[u8] = match opts.emit {
|
||||
// Plaintext just uses standard newlines because newlines
|
||||
// on their own usually move the cursor back in anywhere
|
||||
// you type plaintext.
|
||||
Format::Plain => b"\n",
|
||||
|
||||
// VT uses \r\n because in a raw pty, \n alone doesn't
|
||||
// guarantee moving the cursor back to column 0. \r
|
||||
// makes it work for sure.
|
||||
Format::Vt => b"\r\n",
|
||||
|
||||
// HTML uses just \n because HTML rendering will move
|
||||
// the cursor back.
|
||||
Format::Html => b"\n",
|
||||
};
|
||||
|
||||
for _ in 0..blank_rows {
|
||||
writer.write_all(sequence)?;
|
||||
}
|
||||
|
||||
// \r and \n map to the row that ends with this newline.
|
||||
// If we're continuing (trailing state) then this will be
|
||||
// in a prior page, so we just map to the first row of this
|
||||
// page.
|
||||
if let Some(map) = self.point_map.as_deref_mut() {
|
||||
let start = map.last().copied().unwrap_or(Coordinate { x: 0, y: 0 });
|
||||
|
||||
// The first one inherits the x value.
|
||||
map.extend(std::iter::repeat_n(
|
||||
Coordinate {
|
||||
x: start.x,
|
||||
y: start.y,
|
||||
},
|
||||
sequence.len(),
|
||||
));
|
||||
|
||||
// All others have x = 0 since they reference their prior
|
||||
// blank line.
|
||||
for y_offset in 1..blank_rows {
|
||||
map.extend(std::iter::repeat_n(
|
||||
Coordinate {
|
||||
x: 0,
|
||||
y: start.y + y_offset as u32,
|
||||
},
|
||||
sequence.len(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
blank_rows = 0;
|
||||
}
|
||||
|
||||
// If we're not wrapped, we always add a newline so after
|
||||
// the row is printed we can add a newline.
|
||||
if !row.wrap() || !opts.unwrap {
|
||||
blank_rows += 1;
|
||||
}
|
||||
|
||||
// If the row doesn't continue a wrap then we need to reset
|
||||
// our blank cell count.
|
||||
if !row.wrap_continuation() || !opts.unwrap {
|
||||
blank_cells = 0;
|
||||
}
|
||||
|
||||
// Go through each cell and print it
|
||||
'cells: for (i, cell) in cells_subset.iter().enumerate() {
|
||||
let x: size::CellCountInt = row_start_x + i as size::CellCountInt;
|
||||
|
||||
// Skip spacers. These happen naturally when wide characters
|
||||
// are printed again on the screen (for well-behaved terminals!)
|
||||
match cell.wide() {
|
||||
Wide::Narrow | Wide::Wide => {}
|
||||
Wide::SpacerHead | Wide::SpacerTail => continue,
|
||||
}
|
||||
|
||||
// If we have a zero value, then we accumulate a counter. We
|
||||
// only want to turn zero values into spaces if we have a non-zero
|
||||
// char sometime later.
|
||||
'blank: {
|
||||
// If we're emitting styled output (not plaintext) and
|
||||
// the cell has some kind of styling or is not empty
|
||||
// then this isn't blank.
|
||||
if opts.emit.styled() && (!cell.is_empty() || cell.has_styling()) {
|
||||
break 'blank;
|
||||
}
|
||||
|
||||
// Cells with no text are blank
|
||||
if !cell.has_text() {
|
||||
blank_cells += 1;
|
||||
continue 'cells;
|
||||
}
|
||||
|
||||
// Trailing spaces are blank. We know it is trailing
|
||||
// because if we get a non-empty cell later we'll
|
||||
// fill the blanks.
|
||||
if cell.codepoint() == ' ' as u32 && opts.trim {
|
||||
blank_cells += 1;
|
||||
continue 'cells;
|
||||
}
|
||||
}
|
||||
|
||||
// This cell is not blank. If we have accumulated blank cells
|
||||
// then we want to emit them now.
|
||||
if blank_cells > 0 {
|
||||
scratch.clear();
|
||||
scratch.resize(blank_cells, b' ');
|
||||
writer.write_all(&scratch)?;
|
||||
|
||||
if let Some(map) = self.point_map.as_deref_mut() {
|
||||
// Map each blank cell to its coordinate. Blank cells can span
|
||||
// multiple rows if they carry over from wrap continuation.
|
||||
let mut remaining_blanks = blank_cells;
|
||||
let mut blank_x = x;
|
||||
let mut blank_y = y;
|
||||
while remaining_blanks > 0 {
|
||||
if blank_x > 0 {
|
||||
// We have space in this row
|
||||
blank_x -= 1;
|
||||
} else if blank_y > 0 {
|
||||
// Wrap to previous row
|
||||
blank_y -= 1;
|
||||
blank_x = page.size.cols - 1;
|
||||
} else {
|
||||
// Can't go back further, just use (0, 0)
|
||||
blank_x = 0;
|
||||
blank_y = 0;
|
||||
}
|
||||
|
||||
map.push(Coordinate {
|
||||
x: blank_x,
|
||||
y: u32::from(blank_y),
|
||||
});
|
||||
remaining_blanks -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
blank_cells = 0;
|
||||
}
|
||||
|
||||
'style: {
|
||||
// If we aren't emitting styled output then we don't
|
||||
// have to worry about styles.
|
||||
if !opts.emit.styled() {
|
||||
break 'style;
|
||||
}
|
||||
|
||||
// Get our cell style.
|
||||
let cell_style = self.cell_style(*cell);
|
||||
|
||||
// If the style hasn't changed, don't bloat output.
|
||||
if cell_style.eql(style) {
|
||||
break 'style;
|
||||
}
|
||||
|
||||
// If we had a previous style, we need to close it,
|
||||
// because we've confirmed we have some new style
|
||||
// (which is maybe default).
|
||||
if !style.is_default() {
|
||||
match opts.emit {
|
||||
Format::Html => self.format_style_close(writer)?,
|
||||
|
||||
// For VT, we only close if we're switching to a default
|
||||
// style because any non-default style will emit
|
||||
// a \x1b[0m as the start of a VT coloring sequence.
|
||||
Format::Vt => {
|
||||
if cell_style.is_default() {
|
||||
self.format_style_close(writer)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable because of the styled() check at the
|
||||
// top of this block.
|
||||
Format::Plain => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, we can copy our style over
|
||||
style = cell_style;
|
||||
|
||||
// If we're just the default style now, we're done.
|
||||
if cell_style.is_default() {
|
||||
break 'style;
|
||||
}
|
||||
|
||||
// New style, emit it.
|
||||
scratch.clear();
|
||||
self.format_style_open(&mut scratch, &style)?;
|
||||
writer.write_all(&scratch)?;
|
||||
|
||||
// If we have a point map, we map the style to
|
||||
// this cell.
|
||||
self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len());
|
||||
}
|
||||
|
||||
// Hyperlink state
|
||||
'hyperlink: {
|
||||
// We currently only emit hyperlinks for HTML. In the
|
||||
// future we can support emitting OSC 8 hyperlinks for
|
||||
// VT output as well.
|
||||
if opts.emit != Format::Html {
|
||||
break 'hyperlink;
|
||||
}
|
||||
|
||||
// Get the hyperlink ID. This ID is our internal ID,
|
||||
// not necessarily the OSC8 ID.
|
||||
let link_id_: Option<u16> = if cell.hyperlink() {
|
||||
page.cell_index(usize::from(x), usize::from(y))
|
||||
.ok()
|
||||
.and_then(|i| page.lookup_hyperlink(i))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// If our hyperlink IDs match (even null) then we have
|
||||
// identical hyperlink state and we do nothing.
|
||||
if current_hyperlink_id == link_id_ {
|
||||
break 'hyperlink;
|
||||
}
|
||||
|
||||
// If our prior hyperlink ID was non-null, we need to
|
||||
// close it because the ID has changed.
|
||||
if current_hyperlink_id.is_some() {
|
||||
self.format_hyperlink_close(writer)?;
|
||||
current_hyperlink_id = None;
|
||||
}
|
||||
|
||||
// Set our current hyperlink ID
|
||||
let Some(link_id) = link_id_ else {
|
||||
break 'hyperlink;
|
||||
};
|
||||
current_hyperlink_id = Some(link_id);
|
||||
|
||||
// Emit the opening hyperlink tag
|
||||
let uri: Vec<u8> = page
|
||||
.hyperlink_set
|
||||
.get(link_id)
|
||||
.map(|l| l.uri.clone())
|
||||
.unwrap_or_default();
|
||||
scratch.clear();
|
||||
self.format_hyperlink_open(&mut scratch, &uri)?;
|
||||
writer.write_all(&scratch)?;
|
||||
|
||||
// If we have a point map, we map the hyperlink to
|
||||
// this cell.
|
||||
self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len());
|
||||
}
|
||||
|
||||
match cell.content_tag() {
|
||||
// We combine codepoint and graphemes because both have
|
||||
// shared style handling.
|
||||
ContentTag::Codepoint | ContentTag::CodepointGrapheme => {
|
||||
scratch.clear();
|
||||
self.write_cell(&mut scratch, *cell, x, y)?;
|
||||
writer.write_all(&scratch)?;
|
||||
|
||||
// If we have a point map, all codepoints map to this
|
||||
// cell.
|
||||
self.map_push_n(Coordinate { x, y: u32::from(y) }, scratch.len());
|
||||
}
|
||||
|
||||
// Cells with only background color (no text). Emit a space
|
||||
// with the appropriate background color SGR sequence.
|
||||
ContentTag::BgColorPalette | ContentTag::BgColorRgb => {
|
||||
writer.write_all(b" ")?;
|
||||
self.map_push_n(Coordinate { x, y: u32::from(y) }, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If the style is non-default, we need to close our style tag.
|
||||
if !style.is_default() {
|
||||
self.format_style_close(writer)?;
|
||||
}
|
||||
|
||||
// Close any open hyperlink for HTML output
|
||||
if current_hyperlink_id.is_some() {
|
||||
self.format_hyperlink_close(writer)?;
|
||||
}
|
||||
|
||||
// Close the monospace wrapper for HTML output
|
||||
if opts.emit == Format::Html {
|
||||
let closing = b"</div>";
|
||||
writer.write_all(closing)?;
|
||||
if let Some(map) = self.point_map.as_deref_mut() {
|
||||
let last = map.last().copied().unwrap_or(Coordinate { x: 0, y: 0 });
|
||||
map.extend(std::iter::repeat_n(last, closing.len()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ret(blank_rows, blank_cells))
|
||||
}
|
||||
|
||||
fn write_cell(
|
||||
&self,
|
||||
out: &mut Vec<u8>,
|
||||
cell: Cell,
|
||||
x: size::CellCountInt,
|
||||
y: size::CellCountInt,
|
||||
) -> Result<()> {
|
||||
// Blank cells get an empty space that isn't replaced by anything
|
||||
// because it isn't really a space. We do this so that formatting
|
||||
// is preserved if we're emitting styles.
|
||||
if !cell.has_text() {
|
||||
out.push(b' ');
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.write_codepoint_with_replacement(out, cell.codepoint())?;
|
||||
if cell.content_tag() == ContentTag::CodepointGrapheme {
|
||||
let idx = self
|
||||
.page
|
||||
.cell_index(usize::from(x), usize::from(y))
|
||||
.map_err(|_| invalid_cp())?;
|
||||
for &cp in self.page.lookup_grapheme(idx).expect("grapheme") {
|
||||
self.write_codepoint_with_replacement(out, cp)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_codepoint_with_replacement(&self, out: &mut Vec<u8>, codepoint: u32) -> Result<()> {
|
||||
// Search for our replacement
|
||||
let r: Option<&Replacement> = self.opts.codepoint_map.as_ref().and_then(|map| {
|
||||
map.iter()
|
||||
.rev()
|
||||
.find(|m| m.range[0] <= codepoint && codepoint <= m.range[1])
|
||||
.map(|m| &m.replacement)
|
||||
});
|
||||
|
||||
// If no replacement, write it directly.
|
||||
let Some(r) = r else {
|
||||
return self.write_codepoint(out, codepoint);
|
||||
};
|
||||
|
||||
match r {
|
||||
Replacement::Codepoint(v) => self.write_codepoint(out, *v),
|
||||
Replacement::String(s) => {
|
||||
for c in s.chars() {
|
||||
self.write_codepoint(out, c as u32)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_codepoint(&self, out: &mut Vec<u8>, codepoint: u32) -> Result<()> {
|
||||
let push = |out: &mut Vec<u8>, cp: u32| -> Result<()> {
|
||||
let c = char::from_u32(cp).ok_or_else(invalid_cp)?;
|
||||
let mut b = [0u8; 4];
|
||||
out.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
|
||||
Ok(())
|
||||
};
|
||||
match self.opts.emit {
|
||||
Format::Plain | Format::Vt => push(out, codepoint),
|
||||
Format::Html => match codepoint {
|
||||
0x3C => {
|
||||
out.extend_from_slice(b"<");
|
||||
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<u8>, style: &Style) -> Result<()> {
|
||||
match self.opts.emit {
|
||||
Format::Plain => unreachable!(),
|
||||
|
||||
Format::Vt => {
|
||||
let mut formatter = style.formatter_vt();
|
||||
formatter.palette = self.opts.palette.as_ref();
|
||||
write!(out, "{formatter}")
|
||||
}
|
||||
|
||||
// We use `display: inline` so that the div doesn't impact
|
||||
// layout since we're primarily using it as a CSS wrapper.
|
||||
Format::Html => {
|
||||
let mut formatter = style.formatter_html();
|
||||
formatter.palette = self.opts.palette.as_ref();
|
||||
write!(out, "<div style=\"display: inline;{formatter}\">")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_close_str(&mut self, writer: &mut dyn Write, s: &[u8]) -> Result<()> {
|
||||
writer.write_all(s)?;
|
||||
if let Some(m) = self.point_map.as_deref_mut() {
|
||||
debug_assert!(m.capacity() > 0);
|
||||
let last = m.last().copied().unwrap_or(Coordinate { x: 0, y: 0 });
|
||||
m.extend(std::iter::repeat_n(last, s.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_style_close(&mut self, writer: &mut dyn Write) -> Result<()> {
|
||||
let s: &[u8] = match self.opts.emit {
|
||||
Format::Plain => return Ok(()),
|
||||
Format::Vt => b"\x1b[0m",
|
||||
Format::Html => b"</div>",
|
||||
};
|
||||
self.write_close_str(writer, s)
|
||||
}
|
||||
|
||||
fn format_hyperlink_open(&self, out: &mut Vec<u8>, uri: &[u8]) -> Result<()> {
|
||||
match self.opts.emit {
|
||||
Format::Plain | Format::Vt => unreachable!(),
|
||||
|
||||
Format::Html => {
|
||||
out.extend_from_slice(b"<a href=\"");
|
||||
for &byte in uri {
|
||||
self.write_codepoint(out, u32::from(byte))?;
|
||||
}
|
||||
out.extend_from_slice(b"\">");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_hyperlink_close(&mut self, writer: &mut dyn Write) -> Result<()> {
|
||||
let s: &[u8] = match self.opts.emit {
|
||||
Format::Html => b"</a>",
|
||||
Format::Plain | Format::Vt => return Ok(()),
|
||||
};
|
||||
self.write_close_str(writer, s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Ported formatter.zig tests, split by Zig source line range:
|
||||
//! p1 = "Page plain ..." (Zig lines 1580-3287), p2 = "Page VT ...", "PageList ..."
|
||||
//! and "TerminalFormatter ..." (3288-4458), p3 = "Screen ...", "Terminal vt ...",
|
||||
//! "Page html ..." up to "Page VT style reset properly closes styles"
|
||||
//! (4459-5617), p4 = "Page codepoint_map ..." onwards (5618-end).
|
||||
#![allow(dead_code, clippy::needless_range_loop)]
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
page::Page,
|
||||
page_list::Pin,
|
||||
point::{Coordinate, Point},
|
||||
terminal::{Options as TermOptions, Terminal},
|
||||
};
|
||||
|
||||
mod p1;
|
||||
mod p2;
|
||||
mod p3;
|
||||
mod p4;
|
||||
|
||||
/// Zig `Terminal.init(alloc, .{ .cols, .rows })`.
|
||||
pub(super) fn term(cols: u16, rows: u16) -> Terminal {
|
||||
Terminal::new(TermOptions::new(cols, rows)).unwrap()
|
||||
}
|
||||
|
||||
/// Zig `var s = t.vtStream(); try s.nextSlice(bytes)` (fresh stream per call;
|
||||
/// use `t.vt_stream()` directly if parser state must persist).
|
||||
pub(super) fn feed(t: &mut Terminal, bytes: &[u8]) {
|
||||
let mut s = t.vt_stream();
|
||||
s.next_slice(bytes).unwrap();
|
||||
}
|
||||
|
||||
/// Zig `&pages.pages.last.?.data`.
|
||||
pub(super) fn last_page(t: &Terminal) -> &Page {
|
||||
let pages = &t.screen().pages;
|
||||
&pages.node(pages.last.unwrap()).unwrap().data
|
||||
}
|
||||
|
||||
/// Zig `&pages.pages.first.?.data`.
|
||||
pub(super) fn first_page(t: &Terminal) -> &Page {
|
||||
let pages = &t.screen().pages;
|
||||
&pages.node(pages.first.unwrap()).unwrap().data
|
||||
}
|
||||
|
||||
/// Zig `pages.pages.first.?.data.capacity.rows`.
|
||||
pub(super) fn first_page_rows(t: &Terminal) -> u16 {
|
||||
first_page(t).capacity.rows
|
||||
}
|
||||
|
||||
/// Format a whole `PageFormatter` into a string, returning the output.
|
||||
pub(super) fn fmt_str(f: PageFormatter<'_>) -> String {
|
||||
f.format_string().unwrap()
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! "Page plain ..." tests (formatter.zig lines 1580-3287).
|
||||
use super::*;
|
||||
|
||||
struct Out {
|
||||
s: String,
|
||||
st: TrailingState,
|
||||
map: Vec<Coordinate>,
|
||||
}
|
||||
|
||||
/// Runs a PageFormatter over the last page with a point map attached.
|
||||
fn run(t: &Terminal, opts: &Options, cfg: impl FnOnce(&mut PageFormatter<'_>)) -> Out {
|
||||
let page = last_page(t);
|
||||
let mut map = Vec::new();
|
||||
let mut buf = Vec::new();
|
||||
let st = {
|
||||
let mut f = PageFormatter::new(page, opts);
|
||||
f.point_map = Some(&mut map);
|
||||
cfg(&mut f);
|
||||
f.format_with_state(&mut buf).unwrap()
|
||||
};
|
||||
Out {
|
||||
s: String::from_utf8(buf).unwrap(),
|
||||
st,
|
||||
map,
|
||||
}
|
||||
}
|
||||
|
||||
fn c(x: u16, y: u32) -> Coordinate {
|
||||
Coordinate { x, y }
|
||||
}
|
||||
|
||||
/// `n` consecutive cells on row `y` starting at column `x0`.
|
||||
fn span(x0: u16, n: u16, y: u32) -> Vec<Coordinate> {
|
||||
(0..n).map(|i| c(x0 + i, y)).collect()
|
||||
}
|
||||
|
||||
fn setup(cols: u16, rows: u16, input: &str) -> Terminal {
|
||||
let mut t = term(cols, rows);
|
||||
feed(&mut t, input.as_bytes());
|
||||
// Verify we have only a single page
|
||||
let pages = &t.screen().pages;
|
||||
assert!(pages.first.is_some());
|
||||
assert_eq!(pages.first, pages.last);
|
||||
t
|
||||
}
|
||||
|
||||
fn unwrap_opts() -> Options {
|
||||
Options {
|
||||
unwrap: true,
|
||||
..Options::plain()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_single_line() {
|
||||
let t = setup(80, 24, "hello, world");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |_| {});
|
||||
assert_eq!(o.s, "hello, world");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 12));
|
||||
assert_eq!(o.map, span(0, 12, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_single_line_soft_wrapped_unwrapped() {
|
||||
let t = setup(3, 5, "hello!");
|
||||
let o = run(&t, &unwrap_opts(), |_| {});
|
||||
assert_eq!(o.s, "hello!");
|
||||
let mut e = span(0, 3, 0);
|
||||
e.extend(span(0, 3, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_single_wide_char() {
|
||||
let t = setup(80, 24, "1A⚡");
|
||||
let page = last_page(&t);
|
||||
let opts = Options::plain();
|
||||
|
||||
// Full string
|
||||
let o = run(&t, &opts, |_| {});
|
||||
assert_eq!(o.s, "1A⚡");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
|
||||
assert_eq!(o.map.len(), o.s.len());
|
||||
for i in 2..o.s.len() {
|
||||
assert_eq!(o.map[i], c(2, 0));
|
||||
}
|
||||
|
||||
// Wide only (from start)
|
||||
let o = run(&t, &opts, |f| f.start_x = 2);
|
||||
assert_eq!(o.s, "⚡");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
|
||||
assert_eq!(o.map, vec![c(2, 0); o.s.len()]);
|
||||
|
||||
// Wide only (from tail)
|
||||
let o = run(&t, &opts, |f| f.start_x = 3);
|
||||
assert_eq!(o.s, "⚡");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
|
||||
assert_eq!(o.map, vec![c(2, 0); o.s.len()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_single_wide_char_soft_wrapped_unwrapped() {
|
||||
let t = setup(3, 24, "1A⚡");
|
||||
let page = last_page(&t);
|
||||
let opts = unwrap_opts();
|
||||
|
||||
// Full string
|
||||
let o = run(&t, &opts, |_| {});
|
||||
assert_eq!(o.s, "1A⚡");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
|
||||
assert_eq!(o.map.len(), o.s.len());
|
||||
for i in 2..o.s.len() {
|
||||
assert_eq!(o.map[i], c(0, 1));
|
||||
}
|
||||
|
||||
// Full string (ending on spacer head)
|
||||
let o = run(&t, &opts, |f| {
|
||||
f.end_x = Some(2);
|
||||
f.end_y = Some(0);
|
||||
});
|
||||
assert_eq!(o.s, "1A⚡");
|
||||
assert_eq!(o.map.len(), o.s.len());
|
||||
for i in 2..o.s.len() {
|
||||
assert_eq!(o.map[i], c(0, 1));
|
||||
}
|
||||
|
||||
// Wide only (from start)
|
||||
let o = run(&t, &opts, |f| f.start_x = 2);
|
||||
assert_eq!(o.s, "⚡");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
|
||||
assert_eq!(o.map, vec![c(0, 1); o.s.len()]);
|
||||
|
||||
// Wide only (from tail). In Zig the formatter carried start_x = 2 over
|
||||
// from the previous block; start_y = 1, start_x = 1.
|
||||
let o = run(&t, &opts, |f| {
|
||||
f.start_y = 1;
|
||||
f.start_x = 1;
|
||||
});
|
||||
assert_eq!(o.s, "⚡");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
|
||||
assert_eq!(o.map, vec![c(0, 1); o.s.len()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_multiline() {
|
||||
let t = setup(80, 24, "hello\r\nworld");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |_| {});
|
||||
assert_eq!(o.s, "hello\nworld");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
let mut e = span(0, 5, 0);
|
||||
e.push(c(4, 0)); // \n
|
||||
e.extend(span(0, 5, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_multiline_rectangle() {
|
||||
let t = setup(80, 24, "hello\r\nworld");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.start_x = 1;
|
||||
f.end_x = Some(3);
|
||||
f.rectangle = true;
|
||||
});
|
||||
assert_eq!(o.s, "ell\norl");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, 0);
|
||||
let mut e = span(1, 3, 0);
|
||||
e.push(c(3, 0)); // \n
|
||||
e.extend(span(1, 3, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_multi_blank_lines() {
|
||||
let t = setup(80, 24, "hello\r\n\r\n\r\nworld");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |_| {});
|
||||
assert_eq!(o.s, "hello\n\n\nworld");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 3));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
let mut e = span(0, 5, 0);
|
||||
e.push(c(4, 0)); // \n after row 0
|
||||
e.push(c(0, 1)); // \n after blank row 1
|
||||
e.push(c(0, 2)); // \n after blank row 2
|
||||
e.extend(span(0, 5, 3));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_trailing_blank_lines() {
|
||||
let t = setup(80, 24, "hello\r\nworld\r\n\r\n");
|
||||
let page = last_page(&t);
|
||||
// We expect there to be no trailing newlines because we can't
|
||||
// differentiate trailing blank lines as being meaningful because the
|
||||
// page formatter can't see the cursor position.
|
||||
let o = run(&t, &Options::plain(), |_| {});
|
||||
assert_eq!(o.s, "hello\nworld");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
let mut e = span(0, 5, 0);
|
||||
e.push(c(4, 0));
|
||||
e.extend(span(0, 5, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_trailing_whitespace() {
|
||||
let t = setup(80, 24, "hello \r\nworld ");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |_| {});
|
||||
assert_eq!(o.s, "hello\nworld");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
let mut e = span(0, 5, 0);
|
||||
e.push(c(4, 0));
|
||||
e.extend(span(0, 5, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_trailing_whitespace_no_trim() {
|
||||
let t = setup(80, 24, "hello \r\nworld ");
|
||||
let page = last_page(&t);
|
||||
let opts = Options {
|
||||
trim: false,
|
||||
..Options::plain()
|
||||
};
|
||||
let o = run(&t, &opts, |_| {});
|
||||
assert_eq!(o.s, "hello \nworld ");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 7));
|
||||
let mut e = span(0, 8, 0);
|
||||
e.push(c(7, 0));
|
||||
e.extend(span(0, 7, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_with_prior_trailing_state_rows() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.trailing_state = Some(TrailingState { rows: 2, cells: 0 });
|
||||
});
|
||||
assert_eq!(o.s, "\n\nhello");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
let mut e = vec![c(0, 0), c(0, 1)];
|
||||
e.extend(span(0, 5, 0));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_with_prior_trailing_state_cells_no_wrapped_line() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.trailing_state = Some(TrailingState { rows: 0, cells: 3 });
|
||||
});
|
||||
// Blank cells are reset when row is not a wrap continuation
|
||||
assert_eq!(o.s, "hello");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
assert_eq!(o.map, span(0, 5, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_with_prior_trailing_state_cells_with_wrap_continuation() {
|
||||
let mut t = setup(80, 24, "world");
|
||||
// Surgically modify the first row to be a wrap continuation
|
||||
{
|
||||
let pages = &mut t.screen_mut().pages;
|
||||
let id = pages.last.unwrap();
|
||||
pages
|
||||
.node_mut(id)
|
||||
.unwrap()
|
||||
.data
|
||||
.get_row_mut(0)
|
||||
.unwrap()
|
||||
.set_wrap_continuation(true);
|
||||
}
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &unwrap_opts(), |f| {
|
||||
f.trailing_state = Some(TrailingState { rows: 0, cells: 3 });
|
||||
});
|
||||
// Blank cells are preserved when row is a wrap continuation with unwrap enabled
|
||||
assert_eq!(o.s, " world");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
// 3 spaces from prior trailing state (can't go back beyond (0,0)) + "world"
|
||||
let mut e = vec![c(0, 0); 3];
|
||||
e.extend(span(0, 5, 0));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_soft_wrapped_without_unwrap() {
|
||||
let t = setup(10, 24, "hello world test");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |_| {});
|
||||
// Without unwrap, wrapped lines show as separate lines
|
||||
assert_eq!(o.s, "hello worl\nd test");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
|
||||
let mut e = span(0, 10, 0);
|
||||
e.push(c(9, 0)); // \n
|
||||
e.extend(span(0, 6, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_soft_wrapped_with_unwrap() {
|
||||
let t = setup(10, 24, "hello world test");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &unwrap_opts(), |_| {});
|
||||
// With unwrap, wrapped lines are joined together
|
||||
assert_eq!(o.s, "hello world test");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
|
||||
let mut e = span(0, 10, 0);
|
||||
e.extend(span(0, 6, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_soft_wrapped_3_lines_without_unwrap() {
|
||||
let t = setup(10, 24, "hello world this is a test");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |_| {});
|
||||
assert_eq!(o.s, "hello worl\nd this is\na test");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
|
||||
let mut e = span(0, 10, 0);
|
||||
e.push(c(9, 0)); // \n
|
||||
e.extend(span(0, 9, 1));
|
||||
e.push(c(8, 1)); // \n
|
||||
e.extend(span(0, 6, 2));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_soft_wrapped_3_lines_with_unwrap() {
|
||||
let t = setup(10, 24, "hello world this is a test");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &unwrap_opts(), |_| {});
|
||||
assert_eq!(o.s, "hello world this is a test");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
|
||||
let mut e = span(0, 10, 0);
|
||||
e.extend(span(0, 10, 1));
|
||||
e.extend(span(0, 6, 2));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_y_subset() {
|
||||
let t = setup(80, 24, "hello\r\nworld\r\ntest");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| f.start_y = 1);
|
||||
assert_eq!(o.s, "world\ntest");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
|
||||
let mut e = span(0, 5, 1);
|
||||
e.push(c(4, 1)); // \n
|
||||
e.extend(span(0, 4, 2));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_end_y_subset() {
|
||||
let t = setup(80, 24, "hello\r\nworld\r\ntest");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| f.end_y = Some(1));
|
||||
assert_eq!(o.s, "hello\nworld");
|
||||
assert_eq!(o.st.rows, 1);
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
let mut e = span(0, 5, 0);
|
||||
e.push(c(4, 0)); // \n
|
||||
e.extend(span(0, 5, 1));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_y_and_end_y_range() {
|
||||
let t = setup(80, 24, "hello\r\nworld\r\ntest\r\nfoo");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.start_y = 1;
|
||||
f.end_y = Some(2);
|
||||
});
|
||||
assert_eq!(o.s, "world\ntest");
|
||||
assert_eq!(o.st.rows, 1);
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
|
||||
let mut e = span(0, 5, 1);
|
||||
e.push(c(4, 1)); // \n
|
||||
e.extend(span(0, 4, 2));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_y_out_of_bounds() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let o = run(&t, &Options::plain(), |f| f.start_y = 30);
|
||||
assert_eq!(o.s, "");
|
||||
assert_eq!(o.st.rows, 0);
|
||||
assert_eq!(o.st.cells, 0);
|
||||
assert_eq!(o.map.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_end_y_greater_than_rows() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| f.end_y = Some(30));
|
||||
// Should clamp to page.size.rows and work normally
|
||||
assert_eq!(o.s, "hello");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
assert_eq!(o.map, span(0, 5, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_end_y_less_than_start_y() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.start_y = 5;
|
||||
f.end_y = Some(2);
|
||||
});
|
||||
assert_eq!(o.s, "");
|
||||
assert_eq!(o.st.rows, 0);
|
||||
assert_eq!(o.st.cells, 0);
|
||||
assert_eq!(o.map.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_x_on_first_row_only() {
|
||||
let t = setup(80, 24, "hello world");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| f.start_x = 6);
|
||||
assert_eq!(o.s, "world");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 11));
|
||||
assert_eq!(o.map, span(6, 5, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_end_x_on_last_row_only() {
|
||||
let t = setup(80, 24, "first line\r\nsecond line\r\nthird line");
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.end_y = Some(2);
|
||||
f.end_x = Some(4);
|
||||
});
|
||||
assert_eq!(o.s, "first line\nsecond line\nthird");
|
||||
assert_eq!(o.st.rows, 1);
|
||||
assert_eq!(o.st.cells, 0);
|
||||
let mut e = span(0, 10, 0);
|
||||
e.push(c(9, 0)); // \n
|
||||
e.extend(span(0, 11, 1));
|
||||
e.push(c(10, 1)); // \n
|
||||
e.extend(span(0, 5, 2));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_x_and_end_x_multiline() {
|
||||
let t = setup(80, 24, "hello world\r\ntest case\r\nfoo bar");
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.start_x = 6;
|
||||
f.end_y = Some(2);
|
||||
f.end_x = Some(2);
|
||||
});
|
||||
// First row: "world" (start_x=6 to end of row)
|
||||
// Second row: "test case" (full row)
|
||||
// Third row: "foo" (start to end_x=2, inclusive)
|
||||
assert_eq!(o.s, "world\ntest case\nfoo");
|
||||
assert_eq!(o.st.rows, 1);
|
||||
assert_eq!(o.st.cells, 0);
|
||||
let mut e = span(6, 5, 0);
|
||||
e.push(c(10, 0)); // \n
|
||||
e.extend(span(0, 9, 1));
|
||||
e.push(c(8, 1)); // \n
|
||||
e.extend(span(0, 3, 2));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_x_out_of_bounds() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let o = run(&t, &Options::plain(), |f| f.start_x = 100);
|
||||
assert_eq!(o.s, "");
|
||||
assert_eq!(o.st.rows, 0);
|
||||
assert_eq!(o.st.cells, 0);
|
||||
assert_eq!(o.map.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_end_x_greater_than_cols() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| f.end_x = Some(100));
|
||||
assert_eq!(o.s, "hello");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
assert_eq!(o.map, span(0, 5, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_end_x_less_than_start_x_single_row() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.start_x = 10;
|
||||
f.end_y = Some(0);
|
||||
f.end_x = Some(5);
|
||||
});
|
||||
assert_eq!(o.s, "");
|
||||
assert_eq!(o.st.rows, 0);
|
||||
assert_eq!(o.st.cells, 0);
|
||||
assert_eq!(o.map.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_y_non_zero_ignores_trailing_state() {
|
||||
let t = setup(80, 24, "hello\r\nworld");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.start_y = 1;
|
||||
f.trailing_state = Some(TrailingState { rows: 5, cells: 10 });
|
||||
});
|
||||
// Should NOT output the 5 newlines from trailing_state because start_y is non-zero
|
||||
assert_eq!(o.s, "world");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
assert_eq!(o.map, span(0, 5, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_x_non_zero_ignores_trailing_state() {
|
||||
let t = setup(80, 24, "hello world");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.start_x = 6;
|
||||
f.trailing_state = Some(TrailingState { rows: 2, cells: 8 });
|
||||
});
|
||||
// Should NOT output the 2 newlines or 8 spaces from trailing_state
|
||||
assert_eq!(o.s, "world");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 11));
|
||||
assert_eq!(o.map, span(6, 5, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_start_y_and_start_x_zero_uses_trailing_state() {
|
||||
let t = setup(80, 24, "hello");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |f| {
|
||||
f.start_y = 0;
|
||||
f.start_x = 0;
|
||||
f.trailing_state = Some(TrailingState { rows: 2, cells: 0 });
|
||||
});
|
||||
// SHOULD output the 2 newlines from trailing_state
|
||||
assert_eq!(o.s, "\n\nhello");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
|
||||
let mut e = vec![c(0, 0), c(0, 1)];
|
||||
e.extend(span(0, 5, 0));
|
||||
assert_eq!(o.map, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_plain_single_line_with_styling() {
|
||||
let t = setup(80, 24, "hello, \x1b[1mworld\x1b[0m");
|
||||
let page = last_page(&t);
|
||||
let o = run(&t, &Options::plain(), |_| {});
|
||||
assert_eq!(o.s, "hello, world");
|
||||
assert_eq!(o.st.rows, usize::from(page.size.rows));
|
||||
assert_eq!(o.st.cells, usize::from(page.size.cols - 12));
|
||||
assert_eq!(o.map, span(0, 12, 0));
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
use super::*;
|
||||
|
||||
/// Format the last page of `t` with a page formatter and a point map.
|
||||
fn page_fmt(t: &Terminal, opts: &Options) -> (String, Vec<Coordinate>) {
|
||||
let mut point_map = Vec::new();
|
||||
let mut f = PageFormatter::new(last_page(t), opts);
|
||||
f.point_map = Some(&mut point_map);
|
||||
let out = f.format_string().unwrap();
|
||||
(out, point_map)
|
||||
}
|
||||
|
||||
fn trim_start<'a>(s: &'a str, chars: &[char]) -> &'a str {
|
||||
s.trim_start_matches(chars)
|
||||
}
|
||||
|
||||
fn coord(x: u16, y: u32) -> Coordinate {
|
||||
Coordinate { x, y }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_single_line_plain_text() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello");
|
||||
let (output, point_map) = page_fmt(&t, &Options::vt());
|
||||
assert_eq!(output, "hello");
|
||||
assert_eq!(point_map.len(), output.len());
|
||||
for i in 0..5 {
|
||||
assert_eq!(point_map[i], coord(i as u16, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_single_line_with_bold() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[1mhello\x1b[0m");
|
||||
let (output, point_map) = page_fmt(&t, &Options::vt());
|
||||
assert_eq!(output, "\x1b[0m\x1b[1mhello\x1b[0m");
|
||||
assert_eq!(point_map.len(), output.len());
|
||||
for i in 0..8 {
|
||||
assert_eq!(point_map[i], coord(0, 0));
|
||||
}
|
||||
for i in 0..5 {
|
||||
assert_eq!(point_map[8 + i], coord(i as u16, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_multiple_styles() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[1mhello \x1b[3mworld\x1b[0m");
|
||||
let (output, point_map) = page_fmt(&t, &Options::vt());
|
||||
assert_eq!(
|
||||
output,
|
||||
"\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld\x1b[0m"
|
||||
);
|
||||
assert_eq!(point_map.len(), output.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_with_foreground_color() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[31mred\x1b[0m");
|
||||
let (output, point_map) = page_fmt(&t, &Options::vt());
|
||||
assert_eq!(output, "\x1b[0m\x1b[38;5;1mred\x1b[0m");
|
||||
assert_eq!(point_map.len(), output.len());
|
||||
for i in 0..13 {
|
||||
assert_eq!(point_map[i], coord(0, 0));
|
||||
}
|
||||
for i in 0..3 {
|
||||
assert_eq!(point_map[13 + i], coord(i as u16, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_with_background_and_foreground_colors() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello");
|
||||
let mut opts = Options::vt();
|
||||
opts.background = Some(Rgb {
|
||||
r: 0x12,
|
||||
g: 0x34,
|
||||
b: 0x56,
|
||||
});
|
||||
opts.foreground = Some(Rgb {
|
||||
r: 0xab,
|
||||
g: 0xcd,
|
||||
b: 0xef,
|
||||
});
|
||||
let output = fmt_str(PageFormatter::new(last_page(&t), &opts));
|
||||
assert_eq!(
|
||||
output,
|
||||
"\x1b]10;rgb:ab/cd/ef\x1b\\\x1b]11;rgb:12/34/56\x1b\\hello"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_multi_line_with_styles() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m");
|
||||
let (output, point_map) = page_fmt(&t, &Options::vt());
|
||||
// Note: style is reset before newline to prevent background colors from
|
||||
// bleeding to the next line's leading cells.
|
||||
assert_eq!(
|
||||
output,
|
||||
"\x1b[0m\x1b[1mfirst\x1b[0m\r\n\x1b[0m\x1b[3msecond\x1b[0m"
|
||||
);
|
||||
assert_eq!(point_map.len(), output.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_duplicate_style_not_emitted_twice() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[1mhel\x1b[1mlo\x1b[0m");
|
||||
let (output, point_map) = page_fmt(&t, &Options::vt());
|
||||
assert_eq!(output, "\x1b[0m\x1b[1mhello\x1b[0m");
|
||||
assert_eq!(point_map.len(), output.len());
|
||||
}
|
||||
|
||||
fn node_of_first(t: &Terminal) -> crate::page_list::NodeId {
|
||||
t.screen().pages.first.unwrap()
|
||||
}
|
||||
|
||||
fn node_of_last(t: &Terminal) -> crate::page_list::NodeId {
|
||||
t.screen().pages.last.unwrap()
|
||||
}
|
||||
|
||||
fn list_fmt(t: &Terminal, opts: Options) -> (String, Vec<Pin>) {
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = PageListFormatter::new(&t.screen().pages, opts);
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let out = f.format_string().unwrap();
|
||||
(out, pin_map)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_single_line() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello, world");
|
||||
let (output, pin_map) = list_fmt(&t, Options::plain());
|
||||
assert_eq!(output, "hello, world");
|
||||
assert_eq!(pin_map.len(), output.len());
|
||||
let node = node_of_first(&t);
|
||||
for i in 0..output.len() {
|
||||
assert_eq!(
|
||||
pin_map[i],
|
||||
Pin {
|
||||
node,
|
||||
x: i as u16,
|
||||
y: 0,
|
||||
garbage: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_spanning_two_pages() {
|
||||
let mut t = term(80, 24);
|
||||
let first_page_rows = first_page_rows(&t);
|
||||
|
||||
// Fill the first page almost completely
|
||||
for _ in 0..first_page_rows - 1 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
feed(&mut t, b"page one");
|
||||
|
||||
// Verify we're still on one page
|
||||
assert_eq!(t.screen().pages.first, t.screen().pages.last);
|
||||
|
||||
// Add one more newline to push content to a second page
|
||||
feed(&mut t, b"\r\n");
|
||||
assert_ne!(t.screen().pages.first, t.screen().pages.last);
|
||||
|
||||
// Write content on the second page
|
||||
feed(&mut t, b"page two");
|
||||
|
||||
let (full_output, pin_map) = list_fmt(&t, Options::plain());
|
||||
let output = trim_start(&full_output, &['\n']);
|
||||
assert_eq!(output, "page one\npage two");
|
||||
|
||||
assert_eq!(pin_map.len(), full_output.len());
|
||||
let first_node = node_of_first(&t);
|
||||
let last_node = node_of_last(&t);
|
||||
let trimmed_count = full_output.len() - output.len();
|
||||
|
||||
// First part (trimmed blank lines) maps to first node
|
||||
for p in &pin_map[..trimmed_count] {
|
||||
assert_eq!(p.node, first_node);
|
||||
}
|
||||
|
||||
// "page one" (8 chars) maps to first node
|
||||
for i in 0..8 {
|
||||
let idx = trimmed_count + i;
|
||||
assert_eq!(pin_map[idx].node, first_node);
|
||||
assert_eq!(pin_map[idx].x, i as u16);
|
||||
}
|
||||
|
||||
// \n - maps to last node as it represents the transition to new page
|
||||
assert_eq!(pin_map[trimmed_count + 8].node, last_node);
|
||||
|
||||
// "page two" (8 chars) maps to last node
|
||||
for i in 0..8 {
|
||||
let idx = trimmed_count + 9 + i;
|
||||
assert_eq!(pin_map[idx].node, last_node);
|
||||
assert_eq!(pin_map[idx].x, i as u16);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_soft_wrapped_line_spanning_two_pages_without_unwrap() {
|
||||
let mut t = term(10, 3);
|
||||
let first_page_rows = first_page_rows(&t);
|
||||
|
||||
// Fill the first page with soft-wrapped content
|
||||
for _ in 0..first_page_rows - 1 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
feed(&mut t, b"hello world test");
|
||||
|
||||
// Verify we're on two pages due to wrapping
|
||||
assert_ne!(t.screen().pages.first, t.screen().pages.last);
|
||||
|
||||
let (full_output, pin_map) = list_fmt(&t, Options::plain());
|
||||
let output = trim_start(&full_output, &['\n']);
|
||||
assert_eq!(output, "hello worl\nd test");
|
||||
|
||||
assert_eq!(pin_map.len(), full_output.len());
|
||||
let first_node = node_of_first(&t);
|
||||
let last_node = node_of_last(&t);
|
||||
let trimmed_count = full_output.len() - output.len();
|
||||
|
||||
for p in &pin_map[..trimmed_count] {
|
||||
assert_eq!(p.node, first_node);
|
||||
}
|
||||
for i in 0..10 {
|
||||
assert_eq!(pin_map[trimmed_count + i].node, first_node);
|
||||
}
|
||||
|
||||
// \n - maps to last node as it represents the transition to new page
|
||||
assert_eq!(pin_map[trimmed_count + 10].node, last_node);
|
||||
|
||||
// "d test" (6 chars) maps to last node
|
||||
for i in 0..6 {
|
||||
assert_eq!(pin_map[trimmed_count + 11 + i].node, last_node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_soft_wrapped_line_spanning_two_pages_with_unwrap() {
|
||||
let mut t = term(10, 3);
|
||||
let first_page_rows = first_page_rows(&t);
|
||||
|
||||
for _ in 0..first_page_rows - 1 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
feed(&mut t, b"hello world test");
|
||||
|
||||
assert_ne!(t.screen().pages.first, t.screen().pages.last);
|
||||
|
||||
let mut opts = Options::plain();
|
||||
opts.unwrap = true;
|
||||
let (full_output, pin_map) = list_fmt(&t, opts);
|
||||
let output = trim_start(&full_output, &['\r', '\n']);
|
||||
assert_eq!(output, "hello world test");
|
||||
|
||||
assert_eq!(pin_map.len(), full_output.len());
|
||||
let first_node = node_of_first(&t);
|
||||
let last_node = node_of_last(&t);
|
||||
let trimmed_count = full_output.len() - output.len();
|
||||
|
||||
for p in &pin_map[..trimmed_count] {
|
||||
assert_eq!(p.node, first_node);
|
||||
}
|
||||
for i in 0..10 {
|
||||
assert_eq!(pin_map[trimmed_count + i].node, first_node);
|
||||
}
|
||||
for i in 0..6 {
|
||||
assert_eq!(pin_map[trimmed_count + 10 + i].node, last_node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_vt_spanning_two_pages() {
|
||||
let mut t = term(80, 24);
|
||||
let first_page_rows = first_page_rows(&t);
|
||||
|
||||
// Fill the first page almost completely
|
||||
for _ in 0..first_page_rows - 1 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
feed(&mut t, b"\x1b[1mpage one");
|
||||
|
||||
assert_eq!(t.screen().pages.first, t.screen().pages.last);
|
||||
|
||||
feed(&mut t, b"\r\n");
|
||||
assert_ne!(t.screen().pages.first, t.screen().pages.last);
|
||||
|
||||
// New content is still styled
|
||||
feed(&mut t, b"page two");
|
||||
|
||||
let (full_output, pin_map) = list_fmt(&t, Options::vt());
|
||||
let output = trim_start(&full_output, &['\r', '\n']);
|
||||
assert_eq!(
|
||||
output,
|
||||
"\x1b[0m\x1b[1mpage one\x1b[0m\r\n\x1b[0m\x1b[1mpage two\x1b[0m"
|
||||
);
|
||||
|
||||
assert_eq!(pin_map.len(), full_output.len());
|
||||
let first_node = node_of_first(&t);
|
||||
let last_node = node_of_last(&t);
|
||||
|
||||
let first_count = pin_map.iter().filter(|p| p.node == first_node).count();
|
||||
let last_count = pin_map.iter().filter(|p| p.node == last_node).count();
|
||||
assert!(first_count > 0);
|
||||
assert!(last_count > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_with_x_offset_on_single_page() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello world\r\ntest case\r\nfoo bar");
|
||||
|
||||
let node = node_of_first(&t);
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
|
||||
f.top_left = Some(Pin {
|
||||
node,
|
||||
y: 0,
|
||||
x: 6,
|
||||
garbage: false,
|
||||
});
|
||||
f.bottom_right = Some(Pin {
|
||||
node,
|
||||
y: 2,
|
||||
x: 2,
|
||||
garbage: false,
|
||||
});
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let output = f.format_string().unwrap();
|
||||
assert_eq!(output, "world\ntest case\nfoo");
|
||||
|
||||
assert_eq!(pin_map.len(), output.len());
|
||||
for p in &pin_map {
|
||||
assert_eq!(p.node, node);
|
||||
}
|
||||
|
||||
// "world" starts at x=6, y=0
|
||||
for i in 0..5 {
|
||||
assert_eq!(pin_map[i].x, 6 + i as u16);
|
||||
assert_eq!(pin_map[i].y, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_with_x_offset_spanning_two_pages() {
|
||||
let mut t = term(80, 24);
|
||||
let first_page_rows = first_page_rows(&t);
|
||||
|
||||
for _ in 0..first_page_rows - 1 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
feed(&mut t, b"hello world");
|
||||
|
||||
assert_eq!(t.screen().pages.first, t.screen().pages.last);
|
||||
|
||||
feed(&mut t, b"\r\n");
|
||||
assert_ne!(t.screen().pages.first, t.screen().pages.last);
|
||||
|
||||
feed(&mut t, b"foo bar test");
|
||||
|
||||
let first_node = node_of_first(&t);
|
||||
let last_node = node_of_last(&t);
|
||||
let first_rows = t.screen().pages.node(first_node).unwrap().data.size.rows;
|
||||
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
|
||||
f.top_left = Some(Pin {
|
||||
node: first_node,
|
||||
y: first_rows - 1,
|
||||
x: 6,
|
||||
garbage: false,
|
||||
});
|
||||
f.bottom_right = Some(Pin {
|
||||
node: last_node,
|
||||
y: 1,
|
||||
x: 2,
|
||||
garbage: false,
|
||||
});
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let full_output = f.format_string().unwrap();
|
||||
let output = trim_start(&full_output, &['\n']);
|
||||
assert_eq!(output, "world\nfoo");
|
||||
|
||||
assert_eq!(pin_map.len(), full_output.len());
|
||||
let trimmed_count = full_output.len() - output.len();
|
||||
|
||||
// "world" (5 chars) from first page
|
||||
for i in 0..5 {
|
||||
let idx = trimmed_count + i;
|
||||
assert_eq!(pin_map[idx].node, first_node);
|
||||
assert_eq!(pin_map[idx].x, 6 + i as u16);
|
||||
}
|
||||
|
||||
// \n - maps to last node as it represents the transition to new page
|
||||
assert_eq!(pin_map[trimmed_count + 5].node, last_node);
|
||||
|
||||
// "foo" (3 chars) from last page
|
||||
for i in 0..3 {
|
||||
let idx = trimmed_count + 6 + i;
|
||||
assert_eq!(pin_map[idx].node, last_node);
|
||||
assert_eq!(pin_map[idx].x, i as u16);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_with_start_x_only() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello world");
|
||||
|
||||
let node = node_of_first(&t);
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
|
||||
f.top_left = Some(Pin {
|
||||
node,
|
||||
y: 0,
|
||||
x: 6,
|
||||
garbage: false,
|
||||
});
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let output = f.format_string().unwrap();
|
||||
assert_eq!(output, "world");
|
||||
|
||||
assert_eq!(pin_map.len(), output.len());
|
||||
for i in 0..5 {
|
||||
assert_eq!(pin_map[i].node, node);
|
||||
assert_eq!(pin_map[i].x, 6 + i as u16);
|
||||
assert_eq!(pin_map[i].y, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_with_end_x_only() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello world\r\ntest");
|
||||
|
||||
let node = node_of_first(&t);
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
|
||||
f.bottom_right = Some(Pin {
|
||||
node,
|
||||
y: 1,
|
||||
x: 2,
|
||||
garbage: false,
|
||||
});
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let output = f.format_string().unwrap();
|
||||
assert_eq!(output, "hello world\ntes");
|
||||
|
||||
assert_eq!(pin_map.len(), output.len());
|
||||
|
||||
for i in 0..11 {
|
||||
assert_eq!(pin_map[i].node, node);
|
||||
assert_eq!(pin_map[i].x, i as u16);
|
||||
assert_eq!(pin_map[i].y, 0);
|
||||
}
|
||||
|
||||
// \n
|
||||
assert_eq!(pin_map[11].node, node);
|
||||
|
||||
for i in 0..3 {
|
||||
assert_eq!(pin_map[12 + i].node, node);
|
||||
assert_eq!(pin_map[12 + i].x, i as u16);
|
||||
assert_eq!(pin_map[12 + i].y, 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_format(t: &Terminal, tl: (u16, u32), br: (u16, u32)) -> String {
|
||||
let pages = &t.screen().pages;
|
||||
let mut f = PageListFormatter::new(pages, Options::plain());
|
||||
f.top_left = Some(
|
||||
pages
|
||||
.pin(Point::Screen(Coordinate { x: tl.0, y: tl.1 }))
|
||||
.unwrap(),
|
||||
);
|
||||
f.bottom_right = Some(
|
||||
pages
|
||||
.pin(Point::Screen(Coordinate { x: br.0, y: br.1 }))
|
||||
.unwrap(),
|
||||
);
|
||||
f.rectangle = true;
|
||||
f.format_string().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_rectangle_basic() {
|
||||
let mut t = term(30, 5);
|
||||
feed(&mut t, b"Lorem ipsum dolor\r\n");
|
||||
feed(&mut t, b"sit amet, consectetur\r\n");
|
||||
feed(&mut t, b"adipiscing elit, sed do\r\n");
|
||||
feed(&mut t, b"eiusmod tempor incididunt\r\n");
|
||||
feed(&mut t, b"ut labore et dolore");
|
||||
|
||||
let output = rect_format(&t, (2, 1), (6, 3));
|
||||
assert_eq!(output, "t ame\nipisc\nusmod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_rectangle_with_eol() {
|
||||
let mut t = term(30, 5);
|
||||
feed(&mut t, b"Lorem ipsum dolor\r\n");
|
||||
feed(&mut t, b"sit amet, consectetur\r\n");
|
||||
feed(&mut t, b"adipiscing elit, sed do\r\n");
|
||||
feed(&mut t, b"eiusmod tempor incididunt\r\n");
|
||||
feed(&mut t, b"ut labore et dolore");
|
||||
|
||||
let output = rect_format(&t, (12, 0), (26, 4));
|
||||
assert_eq!(
|
||||
output,
|
||||
"dolor\nnsectetur\nlit, sed do\nor incididunt\n dolore"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagelist_plain_rectangle_more_complex_with_breaks() {
|
||||
let mut t = term(30, 8);
|
||||
feed(&mut t, b"Lorem ipsum dolor\r\n");
|
||||
feed(&mut t, b"sit amet, consectetur\r\n");
|
||||
feed(&mut t, b"adipiscing elit, sed do\r\n");
|
||||
feed(&mut t, b"eiusmod tempor incididunt\r\n");
|
||||
feed(&mut t, b"ut labore et dolore\r\n");
|
||||
feed(&mut t, b"\r\n");
|
||||
feed(&mut t, b"magna aliqua. Ut enim\r\n");
|
||||
feed(&mut t, b"ad minim veniam, quis");
|
||||
|
||||
let output = rect_format(&t, (11, 2), (26, 7));
|
||||
assert_eq!(
|
||||
output,
|
||||
"elit, sed do\npor incididunt\nt dolore\n\na. Ut enim\nniam, quis"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_formatter_plain_no_selection() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello\r\nworld");
|
||||
let f = TerminalFormatter::new(&t, Options::plain());
|
||||
assert_eq!(f.format_string().unwrap(), "hello\nworld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_formatter_vt_with_palette() {
|
||||
let mut t = term(80, 24);
|
||||
|
||||
// Modify some palette colors using VT sequences
|
||||
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
|
||||
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
|
||||
feed(&mut t, b"\x1b]4;255;rgb:ff/00/ff\x1b\\");
|
||||
feed(&mut t, b"test");
|
||||
|
||||
let output = TerminalFormatter::new(&t, Options::vt())
|
||||
.format_string()
|
||||
.unwrap();
|
||||
|
||||
// Create a second terminal and apply the output
|
||||
let mut t2 = term(80, 24);
|
||||
feed(&mut t2, output.as_bytes());
|
||||
|
||||
// Verify the palettes match
|
||||
for i in [0, 1, 255] {
|
||||
assert_eq!(t.colors.palette.current[i], t2.colors.palette.current[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn line2_selection(t: &Terminal) -> Selection {
|
||||
let pages = &t.screen().pages;
|
||||
Selection::new(
|
||||
pages.pin(Point::Active(Coordinate { x: 0, y: 1 })).unwrap(),
|
||||
pages.pin(Point::Active(Coordinate { x: 4, y: 1 })).unwrap(),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_formatter_with_selection() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"line1\r\nline2\r\nline3");
|
||||
let sel = line2_selection(&t);
|
||||
let mut f = TerminalFormatter::new(&t, Options::plain());
|
||||
f.content = Content::Selection(Some(sel));
|
||||
assert_eq!(f.format_string().unwrap(), "line2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_formatter_plain_with_pin_map() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello, world");
|
||||
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = TerminalFormatter::new(&t, Options::plain());
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let output = f.format_string().unwrap();
|
||||
assert_eq!(output, "hello, world");
|
||||
|
||||
assert_eq!(pin_map.len(), output.len());
|
||||
let node = node_of_first(&t);
|
||||
for i in 0..output.len() {
|
||||
assert_eq!(
|
||||
pin_map[i],
|
||||
Pin {
|
||||
node,
|
||||
x: i as u16,
|
||||
y: 0,
|
||||
garbage: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_formatter_plain_multiline_with_pin_map() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello\r\nworld");
|
||||
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = TerminalFormatter::new(&t, Options::plain());
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let output = f.format_string().unwrap();
|
||||
assert_eq!(output, "hello\nworld");
|
||||
|
||||
assert_eq!(pin_map.len(), output.len());
|
||||
let node = node_of_first(&t);
|
||||
for i in 0..5 {
|
||||
assert_eq!(pin_map[i].node, node);
|
||||
assert_eq!(pin_map[i].x, i as u16);
|
||||
assert_eq!(pin_map[i].y, 0);
|
||||
}
|
||||
// "\n" maps to end of first line
|
||||
assert_eq!(pin_map[5].node, node);
|
||||
for i in 0..5 {
|
||||
let idx = 6 + i;
|
||||
assert_eq!(pin_map[idx].node, node);
|
||||
assert_eq!(pin_map[idx].x, i as u16);
|
||||
assert_eq!(pin_map[idx].y, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_formatter_vt_with_palette_and_pin_map() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
|
||||
feed(&mut t, b"test");
|
||||
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = TerminalFormatter::new(&t, Options::vt());
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let output = f.format_string().unwrap();
|
||||
|
||||
// Palette bytes should be mapped to top left
|
||||
assert_eq!(pin_map.len(), output.len());
|
||||
let node = node_of_first(&t);
|
||||
for p in &pin_map {
|
||||
assert_eq!(p.node, node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_formatter_with_selection_and_pin_map() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"line1\r\nline2\r\nline3");
|
||||
let sel = line2_selection(&t);
|
||||
|
||||
let mut pin_map = Vec::new();
|
||||
let mut f = TerminalFormatter::new(&t, Options::plain());
|
||||
f.content = Content::Selection(Some(sel));
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
let output = f.format_string().unwrap();
|
||||
assert_eq!(output, "line2");
|
||||
|
||||
assert_eq!(pin_map.len(), output.len());
|
||||
let node = node_of_first(&t);
|
||||
for i in 0..5 {
|
||||
assert_eq!(pin_map[i].node, node);
|
||||
assert_eq!(pin_map[i].x, i as u16);
|
||||
assert_eq!(pin_map[i].y, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
use super::*;
|
||||
use ghostty_vt::modes::Mode;
|
||||
|
||||
/// Format the screen with the given extras, returning output and pin map.
|
||||
fn screen_vt(t: &Terminal, mutate: impl FnOnce(&mut ScreenFormatter<'_>)) -> (String, Vec<Pin>) {
|
||||
let mut pin_map: Vec<Pin> = Vec::new();
|
||||
let out = {
|
||||
let mut f = ScreenFormatter::new(t.screen(), Options::vt());
|
||||
mutate(&mut f);
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
f.format_string().unwrap()
|
||||
};
|
||||
(out, pin_map)
|
||||
}
|
||||
|
||||
fn replay(output: &str) -> Terminal {
|
||||
let mut t2 = term(80, 24);
|
||||
feed(&mut t2, output.as_bytes());
|
||||
t2
|
||||
}
|
||||
|
||||
fn first_node(t: &Terminal) -> crate::page_list::NodeId {
|
||||
t.screen().pages.first.unwrap()
|
||||
}
|
||||
|
||||
fn page_fmt(t: &Terminal, opts: &Options) -> String {
|
||||
PageFormatter::new(last_page(t), opts)
|
||||
.format_string()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const HTML_OPEN: &str = "<div style=\"font-family: monospace; white-space: pre;\">";
|
||||
|
||||
#[test]
|
||||
fn screen_plain_single_line() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello, world");
|
||||
|
||||
let mut pin_map = Vec::new();
|
||||
let output = {
|
||||
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
f.format_string().unwrap()
|
||||
};
|
||||
assert_eq!(output, "hello, world");
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for (i, p) in pin_map.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*p,
|
||||
Pin {
|
||||
node,
|
||||
x: i as u16,
|
||||
y: 0,
|
||||
garbage: false
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_plain_multiline() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello\r\nworld");
|
||||
|
||||
let mut pin_map = Vec::new();
|
||||
let output = {
|
||||
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
f.format_string().unwrap()
|
||||
};
|
||||
assert_eq!(output, "hello\nworld");
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for i in 0..5 {
|
||||
assert_eq!(pin_map[i].node, node);
|
||||
assert_eq!(pin_map[i].x, i as u16);
|
||||
assert_eq!(pin_map[i].y, 0);
|
||||
}
|
||||
assert_eq!(pin_map[5].node, node);
|
||||
for i in 0..5 {
|
||||
let idx = 6 + i;
|
||||
assert_eq!(pin_map[idx].node, node);
|
||||
assert_eq!(pin_map[idx].x, i as u16);
|
||||
assert_eq!(pin_map[idx].y, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_plain_with_selection() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"line1\r\nline2\r\nline3");
|
||||
|
||||
let pages = &t.screen().pages;
|
||||
let a = pages.pin(Point::Active(Coordinate { x: 0, y: 1 })).unwrap();
|
||||
let b = pages.pin(Point::Active(Coordinate { x: 4, y: 1 })).unwrap();
|
||||
|
||||
let mut pin_map = Vec::new();
|
||||
let output = {
|
||||
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
|
||||
f.content = Content::Selection(Some(Selection::new(a, b, false)));
|
||||
f.pin_map = Some(&mut pin_map);
|
||||
f.format_string().unwrap()
|
||||
};
|
||||
assert_eq!(output, "line2");
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for i in 0..5 {
|
||||
assert_eq!(pin_map[i].node, node);
|
||||
assert_eq!(pin_map[i].x, i as u16);
|
||||
assert_eq!(pin_map[i].y, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_vt_with_cursor_position() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello\r\nworld");
|
||||
|
||||
let (output, pin_map) = screen_vt(&t, |f| f.extra.cursor = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
assert_eq!(t.screen().cursor.x, t2.screen().cursor.x);
|
||||
assert_eq!(t.screen().cursor.y, t2.screen().cursor.y);
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for p in &pin_map {
|
||||
assert_eq!(p.node, node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_vt_with_style() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[1;31mhello");
|
||||
|
||||
let (output, pin_map) = screen_vt(&t, |f| f.extra.style = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
assert!(t.screen().cursor.style.eql(t2.screen().cursor.style));
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for p in &pin_map {
|
||||
assert_eq!(p.node, node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_vt_with_hyperlink() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b]8;;http://example.com\x1b\\hello");
|
||||
|
||||
let (output, pin_map) = screen_vt(&t, |f| f.extra.hyperlink = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
let l1 = &t.screen().cursor.hyperlink;
|
||||
let l2 = &t2.screen().cursor.hyperlink;
|
||||
assert_eq!(l1.is_some(), l2.is_some());
|
||||
if let (Some(a), Some(b)) = (l1, l2) {
|
||||
assert_eq!(a.uri, b.uri);
|
||||
}
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for p in &pin_map {
|
||||
assert_eq!(p.node, node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_vt_with_protection() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[1\"qhello");
|
||||
|
||||
let (output, pin_map) = screen_vt(&t, |f| f.extra.protection = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
assert_eq!(t.screen().cursor.protected, t2.screen().cursor.protected);
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for p in &pin_map {
|
||||
assert_eq!(p.node, node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_vt_with_kitty_keyboard() {
|
||||
let mut t = term(80, 24);
|
||||
// Set kitty keyboard flags (disambiguate + report_events = 3)
|
||||
feed(&mut t, b"\x1b[=3;1uhello");
|
||||
|
||||
let (output, pin_map) = screen_vt(&t, |f| f.extra.kitty_keyboard = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
let flags1 = t.screen().kitty_keyboard.current().int();
|
||||
let flags2 = t2.screen().kitty_keyboard.current().int();
|
||||
assert_eq!(flags1, flags2);
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for p in &pin_map {
|
||||
assert_eq!(p.node, node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screen_vt_with_charsets() {
|
||||
use ghostty_vt::charsets::Slots;
|
||||
let mut t = term(80, 24);
|
||||
// Set G0 to DEC special and shift to G1
|
||||
feed(&mut t, b"\x1b(0\x0ehello");
|
||||
|
||||
let (output, pin_map) = screen_vt(&t, |f| f.extra.charsets = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
let (c1, c2) = (&t.screen().charset, &t2.screen().charset);
|
||||
assert_eq!(c1.gl, c2.gl);
|
||||
assert_eq!(c1.gr, c2.gr);
|
||||
assert_eq!(c1.charsets.get(Slots::G0), c2.charsets.get(Slots::G0));
|
||||
|
||||
assert_eq!(output.len(), pin_map.len());
|
||||
let node = first_node(&t);
|
||||
for p in &pin_map {
|
||||
assert_eq!(p.node, node);
|
||||
}
|
||||
}
|
||||
|
||||
fn term_vt(t: &Terminal, mutate: impl FnOnce(&mut TerminalFormatter<'_>)) -> String {
|
||||
let mut f = TerminalFormatter::new(t, Options::vt());
|
||||
mutate(&mut f);
|
||||
f.format_string().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_vt_with_scrolling_region() {
|
||||
let mut t = term(80, 24);
|
||||
// Set scrolling region: top=5, bottom=20
|
||||
feed(&mut t, b"\x1b[6;21rhello");
|
||||
|
||||
let output = term_vt(&t, |f| f.extra.scrolling_region = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
assert_eq!(t.scrolling_region.top, t2.scrolling_region.top);
|
||||
assert_eq!(t.scrolling_region.bottom, t2.scrolling_region.bottom);
|
||||
assert_eq!(t.scrolling_region.left, t2.scrolling_region.left);
|
||||
assert_eq!(t.scrolling_region.right, t2.scrolling_region.right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_vt_with_modes() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[?2004h"); // Bracketed paste
|
||||
feed(&mut t, b"\x1b[?1000h"); // Mouse event normal
|
||||
feed(&mut t, b"\x1b[?7l"); // Disable wraparound (default is true)
|
||||
feed(&mut t, b"hello");
|
||||
|
||||
let output = term_vt(&t, |f| f.extra.modes = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
for m in [
|
||||
Mode::BracketedPaste,
|
||||
Mode::MouseEventNormal,
|
||||
Mode::Wraparound,
|
||||
] {
|
||||
assert_eq!(t.modes.get(m), t2.modes.get(m));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_vt_with_tabstops() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[3g"); // Clear all tabs
|
||||
feed(&mut t, b"\x1b[5G\x1bH"); // Set tab at column 5
|
||||
feed(&mut t, b"\x1b[15G\x1bH"); // Set tab at column 15
|
||||
feed(&mut t, b"\x1b[30G\x1bH"); // Set tab at column 30
|
||||
feed(&mut t, b"hello");
|
||||
|
||||
let output = term_vt(&t, |f| f.extra.tabstops = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
for c in [4, 14, 29] {
|
||||
assert_eq!(t.tabstops.get(c), t2.tabstops.get(c));
|
||||
assert!(t2.tabstops.get(c));
|
||||
}
|
||||
assert!(!t2.tabstops.get(8)); // Not a tab
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_vt_with_keyboard_modes() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[>4;2m");
|
||||
feed(&mut t, b"hello");
|
||||
|
||||
let output = term_vt(&t, |f| f.extra.keyboard = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
assert_eq!(t.flags.modify_other_keys_2, t2.flags.modify_other_keys_2);
|
||||
assert!(t2.flags.modify_other_keys_2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_vt_with_pwd() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b]7;file://host/home/user\x1b\\hello");
|
||||
|
||||
let output = term_vt(&t, |f| f.extra.pwd = true);
|
||||
let t2 = replay(&output);
|
||||
|
||||
assert_eq!(t.pwd, t2.pwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_multiple_styles() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[1mbold\x1b[3mitalic\x1b[0mnormal");
|
||||
|
||||
let output = page_fmt(&t, &Options::html());
|
||||
assert_eq!(
|
||||
output,
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">\
|
||||
<div style=\"display: inline;font-weight: bold;\">bold</div>\
|
||||
<div style=\"display: inline;font-weight: bold;font-style: italic;\">italic</div>\
|
||||
normal\
|
||||
</div>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_plain_text() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello, world");
|
||||
|
||||
let output = page_fmt(&t, &Options::html());
|
||||
assert_eq!(output, format!("{HTML_OPEN}hello, world</div>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_colors() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[31;44mcolored");
|
||||
|
||||
let output = page_fmt(&t, &Options::html());
|
||||
assert_eq!(
|
||||
output,
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">\
|
||||
<div style=\"display: inline;color: var(--vt-palette-1);background-color: var(--vt-palette-4);\">colored</div>\
|
||||
</div>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_formatter_html_with_palette() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
|
||||
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
|
||||
feed(&mut t, b"\x1b]4;255;rgb:ff/00/ff\x1b\\");
|
||||
feed(&mut t, b"test");
|
||||
|
||||
let mut f = TerminalFormatter::new(&t, Options::html());
|
||||
f.extra.palette = true;
|
||||
let output = f.format_string().unwrap();
|
||||
|
||||
assert!(output.contains("<style>:root{"));
|
||||
assert!(output.contains("--vt-palette-0: #123456;"));
|
||||
assert!(output.contains("--vt-palette-1: #abcdef;"));
|
||||
assert!(output.contains("--vt-palette-255: #ff00ff;"));
|
||||
assert!(output.contains("}</style>"));
|
||||
assert!(output.contains("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_background_and_foreground_colors() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello");
|
||||
|
||||
let mut opts = Options::html();
|
||||
opts.background = Some(Rgb {
|
||||
r: 0x12,
|
||||
g: 0x34,
|
||||
b: 0x56,
|
||||
});
|
||||
opts.foreground = Some(Rgb {
|
||||
r: 0xab,
|
||||
g: 0xcd,
|
||||
b: 0xef,
|
||||
});
|
||||
let output = page_fmt(&t, &opts);
|
||||
assert_eq!(
|
||||
output,
|
||||
"<div style=\"font-family: monospace; white-space: pre;background-color: #123456;color: #abcdef;\">hello</div>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_escaping() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"<tag>&\"'text");
|
||||
|
||||
let opts = Options::html();
|
||||
let mut point_map: Vec<Coordinate> = Vec::new();
|
||||
let output = {
|
||||
let mut f = PageFormatter::new(last_page(&t), &opts);
|
||||
f.point_map = Some(&mut point_map);
|
||||
f.format_string().unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
output,
|
||||
format!("{HTML_OPEN}<tag>&"'text</div>")
|
||||
);
|
||||
|
||||
assert_eq!(output.len(), point_map.len());
|
||||
|
||||
let offset = HTML_OPEN.len();
|
||||
for c in &point_map[..offset] {
|
||||
assert_eq!(*c, Coordinate { x: 0, y: 0 });
|
||||
}
|
||||
|
||||
let at = |i: usize, x: u16| {
|
||||
assert_eq!(point_map[offset + i], Coordinate { x, y: 0 }, "index {i}");
|
||||
};
|
||||
for i in 0..4 {
|
||||
at(i, 0);
|
||||
}
|
||||
at(4, 1);
|
||||
at(5, 2);
|
||||
at(6, 3);
|
||||
for i in 0..4 {
|
||||
at(7 + i, 4);
|
||||
}
|
||||
for i in 0..5 {
|
||||
at(11 + i, 5);
|
||||
}
|
||||
for i in 0..6 {
|
||||
at(16 + i, 6);
|
||||
}
|
||||
for i in 0..5 {
|
||||
at(22 + i, 7);
|
||||
}
|
||||
at(27, 8);
|
||||
at(28, 9);
|
||||
at(29, 10);
|
||||
at(30, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_unicode_as_numeric_entities() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, "╰─ ❯".as_bytes());
|
||||
|
||||
let output = page_fmt(&t, &Options::html());
|
||||
assert_eq!(output, format!("{HTML_OPEN}╰─ ❯</div>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_ascii_characters_unchanged() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello world");
|
||||
|
||||
let output = page_fmt(&t, &Options::html());
|
||||
assert_eq!(output, format!("{HTML_OPEN}hello world</div>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_mixed_ascii_and_unicode() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, "test ╰─❯ ok".as_bytes());
|
||||
|
||||
let output = page_fmt(&t, &Options::html());
|
||||
assert_eq!(
|
||||
output,
|
||||
format!("{HTML_OPEN}test ╰─❯ ok</div>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_with_palette_option_emits_rgb() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
|
||||
feed(&mut t, b"\x1b[31mred");
|
||||
|
||||
// Without palette option - should emit palette index
|
||||
let output = page_fmt(&t, &Options::vt());
|
||||
assert_eq!(output, "\x1b[0m\x1b[38;5;1mred\x1b[0m");
|
||||
|
||||
// With palette option - should emit RGB directly
|
||||
let mut opts = Options::vt();
|
||||
opts.palette = Some(t.colors.palette.current);
|
||||
let output = page_fmt(&t, &opts);
|
||||
assert_eq!(output, "\x1b[0m\x1b[38;2;171;205;239mred\x1b[0m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_palette_option_emits_rgb() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
|
||||
feed(&mut t, b"\x1b[31mred");
|
||||
|
||||
let output = page_fmt(&t, &Options::html());
|
||||
assert_eq!(
|
||||
output,
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">\
|
||||
<div style=\"display: inline;color: var(--vt-palette-1);\">red</div>\
|
||||
</div>"
|
||||
);
|
||||
|
||||
let mut opts = Options::html();
|
||||
opts.palette = Some(t.colors.palette.current);
|
||||
let output = page_fmt(&t, &opts);
|
||||
assert_eq!(
|
||||
output,
|
||||
"<div style=\"font-family: monospace; white-space: pre;\">\
|
||||
<div style=\"display: inline;color: rgb(171, 205, 239);\">red</div>\
|
||||
</div>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_style_reset_properly_closes_styles() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"\x1b[1mbold\x1b[0mnormal");
|
||||
|
||||
let output = page_fmt(&t, &Options::vt());
|
||||
assert_eq!(output, "\x1b[0m\x1b[1mbold\x1b[0mnormal");
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use super::*;
|
||||
|
||||
fn map_entry(range: (char, char), replacement: Replacement) -> CodepointMap {
|
||||
CodepointMap {
|
||||
range: [range.0 as u32, range.1 as u32],
|
||||
replacement,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the last page of `t` with `opts`, optionally collecting a point map.
|
||||
fn run(t: &Terminal, opts: &Options, point_map: Option<&mut Vec<Coordinate>>) -> String {
|
||||
let mut f = PageFormatter::new(last_page(t), opts);
|
||||
f.point_map = point_map;
|
||||
f.format_string().unwrap()
|
||||
}
|
||||
|
||||
fn plain_with_map(entries: Vec<CodepointMap>) -> Options {
|
||||
let mut opts = Options::plain();
|
||||
opts.codepoint_map = Some(entries);
|
||||
opts
|
||||
}
|
||||
|
||||
fn c(x: u16, y: u32) -> Coordinate {
|
||||
Coordinate { x, y }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_codepoint_map_single_replacement() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello world");
|
||||
|
||||
// Replace 'o' with 'x'
|
||||
let opts = plain_with_map(vec![map_entry(
|
||||
('o', 'o'),
|
||||
Replacement::Codepoint('x' as u32),
|
||||
)]);
|
||||
let mut point_map = Vec::new();
|
||||
let output = run(&t, &opts, Some(&mut point_map));
|
||||
assert_eq!(output, "hellx wxrld");
|
||||
|
||||
// Verify point map - each output byte should map to original cell position
|
||||
assert_eq!(output.len(), point_map.len());
|
||||
for (i, coord) in point_map.iter().enumerate() {
|
||||
assert_eq!(*coord, c(i as u16, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_codepoint_map_conflicting_replacement_prefers_last() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello");
|
||||
|
||||
// Replace 'o' with 'x', then with 'y' - should prefer last
|
||||
let opts = plain_with_map(vec![
|
||||
map_entry(('o', 'o'), Replacement::Codepoint('x' as u32)),
|
||||
map_entry(('o', 'o'), Replacement::Codepoint('y' as u32)),
|
||||
]);
|
||||
assert_eq!(run(&t, &opts, None), "helly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_codepoint_map_replace_with_string() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello");
|
||||
|
||||
// Replace 'o' with a multi-byte string
|
||||
let opts = plain_with_map(vec![map_entry(
|
||||
('o', 'o'),
|
||||
Replacement::String("XYZ".into()),
|
||||
)]);
|
||||
let mut point_map = Vec::new();
|
||||
let output = run(&t, &opts, Some(&mut point_map));
|
||||
assert_eq!(output, "hellXYZ");
|
||||
|
||||
// Verify point map - string replacements should all map to the original cell
|
||||
assert_eq!(output.len(), point_map.len());
|
||||
for i in 0..4 {
|
||||
assert_eq!(point_map[i], c(i as u16, 0));
|
||||
}
|
||||
// All bytes of the replacement string "XYZ" should point to position 4 (where 'o' was)
|
||||
assert_eq!(point_map[4], c(4, 0)); // X
|
||||
assert_eq!(point_map[5], c(4, 0)); // Y
|
||||
assert_eq!(point_map[6], c(4, 0)); // Z
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_codepoint_map_range_replacement() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"abcdefg");
|
||||
|
||||
// Replace 'b' through 'e' with 'X'
|
||||
let opts = plain_with_map(vec![map_entry(
|
||||
('b', 'e'),
|
||||
Replacement::Codepoint('X' as u32),
|
||||
)]);
|
||||
assert_eq!(run(&t, &opts, None), "aXXXXfg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_codepoint_map_multiple_ranges() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello world");
|
||||
|
||||
// Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z'
|
||||
let opts = plain_with_map(vec![
|
||||
map_entry(('a', 'm'), Replacement::Codepoint('A' as u32)),
|
||||
map_entry(('n', 'z'), Replacement::Codepoint('Z' as u32)),
|
||||
]);
|
||||
// h e l l o w o r l d
|
||||
// A A A A Z Z Z Z A A
|
||||
assert_eq!(run(&t, &opts, None), "AAAAZ ZZZAA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_codepoint_map_unicode_replacement() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, "hello \u{26A1} world".as_bytes());
|
||||
|
||||
// Replace lightning bolt with fire emoji
|
||||
let opts = plain_with_map(vec![map_entry(
|
||||
('\u{26A1}', '\u{26A1}'),
|
||||
Replacement::String("\u{1F525}".into()),
|
||||
)]);
|
||||
let mut point_map = Vec::new();
|
||||
let output = run(&t, &opts, Some(&mut point_map));
|
||||
assert_eq!(output, "hello \u{1F525} world");
|
||||
|
||||
// Verify point map
|
||||
assert_eq!(output.len(), point_map.len());
|
||||
// Note: the bolt is a wide character occupying cells 6-7
|
||||
for i in 0..6 {
|
||||
assert_eq!(point_map[i], c(i as u16, 0));
|
||||
}
|
||||
// The fire emoji is 4 UTF-8 bytes, all should map to cell 6 (where the bolt was)
|
||||
let fire_start = 6; // "hello " is 6 bytes
|
||||
for i in 0..4 {
|
||||
assert_eq!(point_map[fire_start + i], c(6, 0));
|
||||
}
|
||||
// " world" follows
|
||||
let world_start = fire_start + 4;
|
||||
for i in 0..6 {
|
||||
assert_eq!(point_map[world_start + i], c(8 + i as u16, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_codepoint_map_with_styled_formats() {
|
||||
let mut t = term(10, 24);
|
||||
feed(&mut t, b"\x1b[31mred text\x1b[0m");
|
||||
|
||||
// Replace 'e' with 'X' in styled text
|
||||
let mut opts = Options::vt();
|
||||
opts.codepoint_map = Some(vec![map_entry(
|
||||
('e', 'e'),
|
||||
Replacement::Codepoint('X' as u32),
|
||||
)]);
|
||||
// Should preserve styles while replacing text
|
||||
// "red text" becomes "rXd tXxt"
|
||||
// VT format uses \x1b[38;5;1m for palette color 1
|
||||
assert_eq!(run(&t, &opts, None), "\x1b[0m\x1b[38;5;1mrXd tXxt\x1b[0m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_codepoint_map_empty_map() {
|
||||
let mut t = term(80, 24);
|
||||
feed(&mut t, b"hello world");
|
||||
|
||||
// Empty map should not change anything
|
||||
let opts = plain_with_map(Vec::new());
|
||||
assert_eq!(run(&t, &opts, None), "hello world");
|
||||
|
||||
// A None map is equivalent.
|
||||
let mut opts = Options::plain();
|
||||
opts.codepoint_map = None;
|
||||
assert_eq!(run(&t, &opts, None), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_vt_background_color_on_trailing_blank_cells() {
|
||||
// This test reproduces a bug where trailing cells with background color
|
||||
// but no text are emitted as plain spaces without SGR sequences.
|
||||
// This causes TUIs like htop to lose background colors on rehydration.
|
||||
let mut t = term(20, 5);
|
||||
|
||||
// Simulate a TUI row: "CPU:" with text, then trailing cells with red background
|
||||
// to end of line (no text after the colored region).
|
||||
// \x1b[41m sets red background, then EL fills rest of row with that bg.
|
||||
feed(&mut t, b"CPU:\x1b[41m\x1b[K");
|
||||
// Reset colors and move to next line with different content
|
||||
feed(&mut t, b"\x1b[0m\r\nline2");
|
||||
|
||||
let mut opts = Options::vt();
|
||||
opts.trim = false; // Don't trim so we can see the trailing behavior
|
||||
let output = run(&t, &opts, None);
|
||||
|
||||
// The output should preserve the red background SGR for trailing cells on line 1.
|
||||
// The red background should appear BEFORE the newline, not after.
|
||||
|
||||
// Find position of CRLF
|
||||
let crlf_pos = output.find("\r\n").expect("crlf");
|
||||
|
||||
// Check that red background (48;5;1) appears BEFORE the newline (on line 1)
|
||||
let line1 = &output[..crlf_pos];
|
||||
let has_red_bg_line1 = line1.contains("\x1b[41m") || line1.contains("\x1b[48;5;1m");
|
||||
assert!(has_red_bg_line1);
|
||||
}
|
||||
|
||||
const HTML_OPEN: &str = "<div style=\"font-family: monospace; white-space: pre;\">";
|
||||
|
||||
fn html(t: &Terminal, point_map: Option<&mut Vec<Coordinate>>) -> String {
|
||||
run(t, &Options::html(), point_map)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_hyperlinks() {
|
||||
let mut t = term(80, 24);
|
||||
|
||||
// Start a hyperlink, write some text, end it
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
html(&t, None),
|
||||
format!("{HTML_OPEN}<a href=\"https://example.com\">link text</a> normal</div>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_multiple_hyperlinks() {
|
||||
let mut t = term(80, 24);
|
||||
|
||||
// Two different hyperlinks
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ ",
|
||||
);
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
html(&t, None),
|
||||
format!(
|
||||
"{HTML_OPEN}<a href=\"https://first.com\">first</a> <a href=\"https://second.com\">second</a></div>"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_hyperlink_escaping() {
|
||||
let mut t = term(80, 24);
|
||||
|
||||
// URL with special characters that need escaping
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
html(&t, None),
|
||||
format!("{HTML_OPEN}<a href=\"https://example.com?a=1&b=2\">link</a></div>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_with_styled_hyperlink() {
|
||||
let mut t = term(80, 24);
|
||||
|
||||
// Bold hyperlink
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
html(&t, None),
|
||||
format!(
|
||||
"{HTML_OPEN}<div style=\"display: inline;font-weight: bold;\"><a href=\"https://example.com\">bold link</div></a></div>"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_hyperlink_closes_style_before_anchor() {
|
||||
let mut t = term(80, 24);
|
||||
|
||||
// Styled hyperlink followed by plain text
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
html(&t, None),
|
||||
format!(
|
||||
"{HTML_OPEN}<div style=\"display: inline;font-weight: bold;\"><a href=\"https://example.com\">bold</div> plain</a></div>"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_html_hyperlink_point_map_maps_closing_to_previous_cell() {
|
||||
let mut t = term(80, 24);
|
||||
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal",
|
||||
);
|
||||
|
||||
let mut point_map = Vec::new();
|
||||
let output = html(&t, Some(&mut point_map));
|
||||
|
||||
let expected_output =
|
||||
format!("{HTML_OPEN}<a href=\"https://example.com\">link</a> normal</div>");
|
||||
assert_eq!(output, expected_output);
|
||||
assert_eq!(expected_output.len(), point_map.len());
|
||||
|
||||
// The </a> closing tag bytes should all map to the last cell of the link
|
||||
let closing_idx = expected_output.find("</a>").unwrap();
|
||||
let expected_coord = point_map[closing_idx - 1];
|
||||
for i in closing_idx..closing_idx + "</a>".len() {
|
||||
assert_eq!(point_map[i], expected_coord);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Highlights are any contiguous sequences of cells that should
|
||||
//! be called out in some way, most commonly for text selection but
|
||||
//! also search results or any other purpose.
|
||||
//!
|
||||
//! Within the terminal package, a highlight is a generic concept
|
||||
//! that represents a range of cells.
|
||||
//!
|
||||
//! Port of `ZIG/src/terminal/highlight.zig`.
|
||||
|
||||
// NOTE: The plan is for highlights to ultimately replace Selection
|
||||
// completely. Selection is deeply tied to various parts of the Ghostty
|
||||
// internals so this may take some time.
|
||||
|
||||
use crate::{
|
||||
page::Error,
|
||||
page_list::{NodeId, PageList, Pin, TrackedPin},
|
||||
screen::Screen,
|
||||
size,
|
||||
};
|
||||
|
||||
/// An untracked highlight is a highlight that stores its highlighted
|
||||
/// area as a top-left and bottom-right screen pin. Since it is untracked,
|
||||
/// the pins are only valid for the current terminal state and may not
|
||||
/// be safe to use after any terminal modifications.
|
||||
///
|
||||
/// For rectangle highlights/selections, the downstream consumer of this
|
||||
/// code is expected to interpret the pins in whatever shape they want.
|
||||
/// For example, a rectangular selection would interpret the pins as
|
||||
/// setting the x bounds for each row between start.y and end.y.
|
||||
///
|
||||
/// To simplify all operations, start MUST be before or equal to end.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Untracked {
|
||||
pub start: Pin,
|
||||
pub end: Pin,
|
||||
}
|
||||
|
||||
impl Untracked {
|
||||
pub fn track(&self, screen: &mut Screen) -> Result<Tracked, Error> {
|
||||
Tracked::init(screen, self.start, self.end)
|
||||
}
|
||||
|
||||
pub fn eql(self, other: Untracked) -> bool {
|
||||
self.start.eql(other.start) && self.end.eql(other.end)
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked highlight is a highlight that stores its highlighted
|
||||
/// area as tracked pins within a screen.
|
||||
///
|
||||
/// A tracked highlight ensures that the pins remain valid even as
|
||||
/// the terminal state changes. Because of this, tracked highlights
|
||||
/// have more operations available to them.
|
||||
///
|
||||
/// There is more overhead to creating and maintaining tracked highlights.
|
||||
/// If you're manipulating highlights that are untracked and you're sure
|
||||
/// that the terminal state won't change, you can use the `init_assume`
|
||||
/// function.
|
||||
///
|
||||
/// Unlike Zig (which stores `*Pin`), the tracked pins are handles into the
|
||||
/// owning [`PageList`]; read them with `pages.tracked_pin(h.start)`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Tracked {
|
||||
pub start: TrackedPin,
|
||||
pub end: TrackedPin,
|
||||
}
|
||||
|
||||
impl Tracked {
|
||||
pub fn init(screen: &mut Screen, start: Pin, end: Pin) -> Result<Tracked, Error> {
|
||||
let start_tracked = screen.pages.track_pin(start)?;
|
||||
let end_tracked = match screen.pages.track_pin(end) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
screen.pages.untrack_pin(start_tracked);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
Ok(Tracked {
|
||||
start: start_tracked,
|
||||
end: end_tracked,
|
||||
})
|
||||
}
|
||||
|
||||
/// Initializes a tracked highlight by assuming that the provided
|
||||
/// pins are already tracked. This allows callers to perform tracked
|
||||
/// operations without the overhead of tracking the pins, if the
|
||||
/// caller can guarantee that the pins are already tracked or that
|
||||
/// the terminal state will not change.
|
||||
///
|
||||
/// Do not call deinit on highlights created with this function.
|
||||
pub fn init_assume(start: TrackedPin, end: TrackedPin) -> Tracked {
|
||||
Tracked { start, end }
|
||||
}
|
||||
|
||||
pub fn deinit(self, screen: &mut Screen) {
|
||||
screen.pages.untrack_pin(self.start);
|
||||
screen.pages.untrack_pin(self.end);
|
||||
}
|
||||
|
||||
/// Current (untracked) value of the highlight.
|
||||
pub fn untracked(&self, pages: &PageList) -> Option<Untracked> {
|
||||
Some(Untracked {
|
||||
start: pages.tracked_pin(self.start)?,
|
||||
end: pages.tracked_pin(self.end)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A flattened highlight is a highlight that stores its highlighted
|
||||
/// area as a list of page chunks. This representation allows for
|
||||
/// traversing the entire highlighted area without needing to read any
|
||||
/// terminal state or dereference any page nodes (which may have been
|
||||
/// pruned).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Flattened {
|
||||
/// The page chunks that make up this highlight. This handles the
|
||||
/// y bounds since chunks[0].start is the first highlighted row
|
||||
/// and chunks[len - 1].end is the last highlighted row (exclusive).
|
||||
pub chunks: Vec<Chunk>,
|
||||
|
||||
/// The x bounds of the highlight. `bot_x` may be less than `top_x`
|
||||
/// for typical left-to-right highlights: can start the selection right
|
||||
/// of the end on a higher row.
|
||||
pub top_x: size::CellCountInt,
|
||||
pub bot_x: size::CellCountInt,
|
||||
}
|
||||
|
||||
/// A flattened chunk is almost identical to a PageList.Chunk but
|
||||
/// we also flatten the serial number. This lets the flattened
|
||||
/// highlight more robust for comparisons and validity checks with
|
||||
/// the PageList.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Chunk {
|
||||
pub node: NodeId,
|
||||
pub serial: u64,
|
||||
pub start: size::CellCountInt,
|
||||
pub end: size::CellCountInt,
|
||||
}
|
||||
|
||||
impl Flattened {
|
||||
pub const EMPTY: Flattened = Flattened {
|
||||
chunks: Vec::new(),
|
||||
top_x: 0,
|
||||
bot_x: 0,
|
||||
};
|
||||
|
||||
pub fn empty() -> Flattened {
|
||||
Self::EMPTY
|
||||
}
|
||||
|
||||
pub fn init(pages: &PageList, start: Pin, end: Pin) -> Result<Flattened, Error> {
|
||||
let mut result = Vec::new();
|
||||
for chunk in pin_page_iterator(pages, start, end) {
|
||||
let serial = pages
|
||||
.node(chunk.node)
|
||||
.ok_or(Error::InvalidCoordinate)?
|
||||
.serial;
|
||||
result.push(Chunk {
|
||||
node: chunk.node,
|
||||
serial,
|
||||
start: chunk.start,
|
||||
end: chunk.end,
|
||||
});
|
||||
}
|
||||
// NOTE: The Zig code assigns `.end_x = end.x` here (a typo for
|
||||
// `bot_x`, never compiled since the function is unused).
|
||||
Ok(Flattened {
|
||||
chunks: result,
|
||||
top_x: start.x,
|
||||
bot_x: end.x,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start_pin(&self) -> Pin {
|
||||
let first = self.chunks[0];
|
||||
Pin {
|
||||
node: first.node,
|
||||
x: self.top_x,
|
||||
y: first.start,
|
||||
garbage: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end_pin(&self) -> Pin {
|
||||
let last = self.chunks[self.chunks.len() - 1];
|
||||
Pin {
|
||||
node: last.node,
|
||||
x: self.bot_x,
|
||||
y: last.end - 1,
|
||||
garbage: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to an Untracked highlight.
|
||||
pub fn untracked(&self) -> Untracked {
|
||||
Untracked {
|
||||
start: self.start_pin(),
|
||||
end: self.end_pin(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Equivalent of Zig `Pin.pageIterator(.right_down, limit)`: iterate the
|
||||
/// page chunks (inclusive of `end`'s row) starting at `start`.
|
||||
///
|
||||
/// Like Zig this does not validate the pins against the page sizes (the end
|
||||
/// row of the last chunk is `end.y + 1` even if the page is smaller), so
|
||||
/// consumers must clamp.
|
||||
pub fn pin_page_iterator(
|
||||
pages: &PageList,
|
||||
start: Pin,
|
||||
end: Pin,
|
||||
) -> impl Iterator<Item = crate::page_list::Chunk> + '_ {
|
||||
let mut cur = Some(start.node);
|
||||
std::iter::from_fn(move || {
|
||||
let node = cur?;
|
||||
let n = pages.node(node)?;
|
||||
let first = node == start.node;
|
||||
let last = node == end.node;
|
||||
let chunk = crate::page_list::Chunk {
|
||||
node,
|
||||
start: if first { start.y } else { 0 },
|
||||
end: if last { end.y + 1 } else { n.data.size.rows },
|
||||
};
|
||||
cur = if last { None } else { n.next };
|
||||
Some(chunk)
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,1390 @@
|
||||
//! Render state: a snapshot of the terminal state required to render a frame.
|
||||
//!
|
||||
//! Port of `ZIG/src/terminal/render.zig`.
|
||||
//!
|
||||
//! Developer note: this is in the terminal crate and not the renderer crate
|
||||
//! because the goal is that this remains generic to multiple renderers.
|
||||
//!
|
||||
//! Contains the state required to render the screen, including optimizing
|
||||
//! for repeated render calls and only rendering dirty regions.
|
||||
//!
|
||||
//! Previously, our renderer would use `clone` to clone the screen within
|
||||
//! the viewport to perform rendering. This worked well enough that we kept
|
||||
//! it all the way up through the Ghostty 1.2.x series, but the clone time
|
||||
//! was repeatedly a bottleneck blocking IO.
|
||||
//!
|
||||
//! Rather than a generic clone that tries to clone all screen state per call
|
||||
//! (within a region), a stateful approach that optimizes for only what a
|
||||
//! renderer needs to do makes more sense.
|
||||
//!
|
||||
//! To use this, initialize the render state to empty, then call `update`
|
||||
//! on each frame to update the state to the latest terminal state.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let mut state = RenderState::new();
|
||||
//! state.update(&mut terminal)?;
|
||||
//! ```
|
||||
//!
|
||||
//! Note: the render state retains as much memory as possible between updates
|
||||
//! to prevent future allocations. If a very large frame is rendered once,
|
||||
//! the render state will retain that much memory until dropped. To avoid
|
||||
//! waste, it is recommended that the caller drop and start with an
|
||||
//! empty render state every so often.
|
||||
|
||||
use crate::{
|
||||
cursor,
|
||||
highlight::Flattened,
|
||||
page::{self, ContentTag, Error, Wide},
|
||||
page_list::{Direction, PageList, Pin},
|
||||
point::{Coordinate, Point, Tag},
|
||||
screen_set::Key as ScreenKey,
|
||||
selection::Selection,
|
||||
size,
|
||||
style::{Color, Style},
|
||||
terminal::{Dirty as TerminalDirty, Terminal},
|
||||
};
|
||||
use ghostty_vt::{
|
||||
color::{self, Palette, Rgb},
|
||||
modes::Mode,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Contains the state required to render the screen. See the module docs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderState {
|
||||
/// The current screen dimensions. It is possible that these don't match
|
||||
/// the renderer's current dimensions in grid cells because resizing
|
||||
/// can happen asynchronously. This can lead to a one or two frame
|
||||
/// mismatch a renderer needs to handle.
|
||||
///
|
||||
/// The viewport is always exactly equal to the active area size so this
|
||||
/// is also the viewport size.
|
||||
pub rows: size::CellCountInt,
|
||||
pub cols: size::CellCountInt,
|
||||
|
||||
/// The color state for the terminal.
|
||||
pub colors: Colors,
|
||||
|
||||
/// Cursor state within the viewport.
|
||||
pub cursor: Cursor,
|
||||
|
||||
/// The rows (y=0 is top) of the viewport. Guaranteed to be `rows` length.
|
||||
pub row_data: Vec<Row>,
|
||||
|
||||
/// The dirty state of the render state. This is set by the update method.
|
||||
/// The renderer/caller should set this to false when it has handled
|
||||
/// the dirty state.
|
||||
pub dirty: Dirty,
|
||||
|
||||
/// The screen type that this state represents. This is used primarily
|
||||
/// to detect changes.
|
||||
pub screen: ScreenKey,
|
||||
|
||||
/// The last viewport pin used to generate this state. This is NOT
|
||||
/// a tracked pin and is generally NOT safe to read other than the direct
|
||||
/// values for comparison.
|
||||
pub viewport_pin: Option<Pin>,
|
||||
|
||||
/// The cached selection so we can avoid expensive selection calculations
|
||||
/// if possible.
|
||||
selection_cache: Option<SelectionCache>,
|
||||
}
|
||||
|
||||
/// The color state for the terminal.
|
||||
///
|
||||
/// The background/foreground will be reversed if the terminal reverse
|
||||
/// color mode is on! You do not need to handle that manually!
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Colors {
|
||||
pub background: Rgb,
|
||||
pub foreground: Rgb,
|
||||
pub cursor: Option<Rgb>,
|
||||
pub palette: Palette,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Cursor {
|
||||
/// The x/y position of the cursor within the active area.
|
||||
pub active: Coordinate,
|
||||
|
||||
/// The x/y position of the cursor within the viewport. This
|
||||
/// may be None if the cursor is not visible within the viewport.
|
||||
pub viewport: Option<CursorViewport>,
|
||||
|
||||
/// The cell data for the cursor position. Managed memory is not
|
||||
/// safe to access from this.
|
||||
pub cell: page::Cell,
|
||||
|
||||
/// The style, always valid even if the cell is default style.
|
||||
pub style: Style,
|
||||
|
||||
/// The visual style of the cursor itself, such as a block or bar.
|
||||
pub visual_style: cursor::Style,
|
||||
|
||||
/// True if the cursor is detected to be at a password input field.
|
||||
pub password_input: bool,
|
||||
|
||||
/// Cursor visibility state determined by the terminal mode.
|
||||
pub visible: bool,
|
||||
|
||||
/// Cursor blink state determined by the terminal mode.
|
||||
pub blinking: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CursorViewport {
|
||||
/// The x/y position of the cursor within the viewport.
|
||||
pub x: size::CellCountInt,
|
||||
pub y: size::CellCountInt,
|
||||
|
||||
/// Whether the cursor is part of a wide character and
|
||||
/// on the tail of it. If so, some renderers may use this
|
||||
/// to move the cursor back one.
|
||||
pub wide_tail: bool,
|
||||
}
|
||||
|
||||
/// A row within the viewport.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Row {
|
||||
/// The page pin. This is not safe to read unless you can guarantee
|
||||
/// the terminal state hasn't changed since the last `update` call.
|
||||
pub pin: Pin,
|
||||
|
||||
/// Raw row data.
|
||||
pub raw: page::Row,
|
||||
|
||||
/// The cells in this row. Guaranteed to be `cols` length.
|
||||
pub cells: Cells,
|
||||
|
||||
/// A dirty flag that can be used by the renderer to track
|
||||
/// its own draw state. `update` will mark this true whenever
|
||||
/// this row is changed, too.
|
||||
pub dirty: bool,
|
||||
|
||||
/// The x range of the selection within this row.
|
||||
pub selection: Option<[size::CellCountInt; 2]>,
|
||||
|
||||
/// The highlights within this row.
|
||||
pub highlights: Vec<Highlight>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Highlight {
|
||||
/// A special tag that can be used by the caller to differentiate
|
||||
/// different highlight types. The value is opaque to the RenderState.
|
||||
pub tag: u8,
|
||||
|
||||
/// The x ranges of highlights within this row.
|
||||
pub range: [size::CellCountInt; 2],
|
||||
}
|
||||
|
||||
/// One cell of a row (the Zig `RenderState.Cell`), by reference.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Cell<'a> {
|
||||
/// Always set, this is the raw copied cell data from page.Cell.
|
||||
/// The managed memory (hyperlinks, graphemes, etc.) is NOT safe
|
||||
/// to access from here. It is duplicated into the other fields if
|
||||
/// it exists.
|
||||
pub raw: page::Cell,
|
||||
|
||||
/// Grapheme data for the cell (the codepoints after the first). Empty
|
||||
/// unless the raw cell's content_tag is `CodepointGrapheme`.
|
||||
pub grapheme: &'a [u32],
|
||||
|
||||
/// The style data for the cell. The default style unless the style_id
|
||||
/// is non-default on raw (or the cell is a bg-color-only cell).
|
||||
pub style: Style,
|
||||
}
|
||||
|
||||
/// The cells of a row, stored as a struct of arrays (the Zig
|
||||
/// `MultiArrayList(Cell)`) so that the common plain-text case only copies the
|
||||
/// raw cells.
|
||||
///
|
||||
/// `raw` always has exactly `cols` entries. `grapheme` and `style` either
|
||||
/// have exactly `cols` entries, or are empty when the source row has no
|
||||
/// managed memory (in which case every cell has no grapheme and the default
|
||||
/// style). Always use the accessors rather than indexing those two directly.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Cells {
|
||||
pub raw: Vec<page::Cell>,
|
||||
pub grapheme: Vec<Vec<u32>>,
|
||||
pub style: Vec<Style>,
|
||||
}
|
||||
|
||||
impl Cells {
|
||||
pub fn len(&self) -> usize {
|
||||
self.raw.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.raw.is_empty()
|
||||
}
|
||||
|
||||
/// The grapheme (extra codepoints) of the cell at `x`.
|
||||
pub fn grapheme(&self, x: usize) -> &[u32] {
|
||||
self.grapheme.get(x).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The style of the cell at `x` (default style if none).
|
||||
pub fn style(&self, x: usize) -> Style {
|
||||
self.style.get(x).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The full cell at `x`. Panics if out of range.
|
||||
pub fn get(&self, x: usize) -> Cell<'_> {
|
||||
Cell {
|
||||
raw: self.raw[x],
|
||||
grapheme: self.grapheme(x),
|
||||
style: self.style(x),
|
||||
}
|
||||
}
|
||||
|
||||
/// Iterate all cells in order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = Cell<'_>> + '_ {
|
||||
(0..self.raw.len()).map(|x| self.get(x))
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.raw.clear();
|
||||
self.grapheme.clear();
|
||||
self.style.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dirty state
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Dirty {
|
||||
/// Not dirty at all. Can skip rendering if prior state was
|
||||
/// already rendered.
|
||||
#[default]
|
||||
False,
|
||||
|
||||
/// Partially dirty. Some rows changed but not all. None of the
|
||||
/// global state changed such as colors.
|
||||
Partial,
|
||||
|
||||
/// Fully dirty. Global state changed or dimensions changed. All rows
|
||||
/// should be redrawn.
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct SelectionCache {
|
||||
selection: Selection,
|
||||
tl_pin: Pin,
|
||||
br_pin: Pin,
|
||||
}
|
||||
|
||||
impl Default for RenderState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
/// Initial (empty) state.
|
||||
pub fn new() -> RenderState {
|
||||
RenderState {
|
||||
rows: 0,
|
||||
cols: 0,
|
||||
colors: Colors {
|
||||
background: Rgb::default(),
|
||||
foreground: Rgb::default(),
|
||||
cursor: None,
|
||||
palette: color::DEFAULT,
|
||||
},
|
||||
cursor: Cursor {
|
||||
active: Coordinate { x: 0, y: 0 },
|
||||
viewport: None,
|
||||
cell: page::Cell::default(),
|
||||
style: Style::default(),
|
||||
visual_style: cursor::Style::Block,
|
||||
password_input: false,
|
||||
visible: true,
|
||||
blinking: false,
|
||||
},
|
||||
row_data: Vec::new(),
|
||||
dirty: Dirty::False,
|
||||
screen: ScreenKey::Primary,
|
||||
viewport_pin: None,
|
||||
selection_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the render state to the latest terminal state.
|
||||
///
|
||||
/// This will reset the terminal dirty state since it is consumed
|
||||
/// by this render state update.
|
||||
pub fn update(&mut self, t: &mut Terminal) -> Result<(), Error> {
|
||||
let active_key = t.screens.active_key;
|
||||
let terminal_dirty = t.flags.dirty != TerminalDirty::default();
|
||||
let s = t.screens.active_mut();
|
||||
let viewport_pin = s
|
||||
.pages
|
||||
.get_top_left(Tag::Viewport)
|
||||
.ok_or(Error::InvalidCoordinate)?;
|
||||
let redraw = 'redraw: {
|
||||
// If our screen key changed, we need to do a full rebuild
|
||||
// because our render state is viewport-specific.
|
||||
if active_key != self.screen {
|
||||
break 'redraw true;
|
||||
}
|
||||
|
||||
// If our terminal is dirty at all, we do a full rebuild. These
|
||||
// dirty values are full-terminal dirty values.
|
||||
if terminal_dirty {
|
||||
break 'redraw true;
|
||||
}
|
||||
|
||||
// If our screen is dirty at all, we do a full rebuild. This is
|
||||
// a full screen dirty tracker.
|
||||
if s.dirty != crate::screen::Dirty::default() {
|
||||
break 'redraw true;
|
||||
}
|
||||
|
||||
// If our dimensions changed, we do a full rebuild.
|
||||
if self.rows != s.pages.rows || self.cols != s.pages.cols {
|
||||
break 'redraw true;
|
||||
}
|
||||
|
||||
// If our viewport pin changed, we do a full rebuild.
|
||||
if let Some(old) = self.viewport_pin
|
||||
&& !old.eql(viewport_pin)
|
||||
{
|
||||
break 'redraw true;
|
||||
}
|
||||
|
||||
false
|
||||
};
|
||||
|
||||
// Always set our cheap fields, its more expensive to compare
|
||||
self.rows = s.pages.rows;
|
||||
self.cols = s.pages.cols;
|
||||
self.viewport_pin = Some(viewport_pin);
|
||||
self.cursor.active = Coordinate {
|
||||
x: s.cursor.x,
|
||||
y: u32::from(s.cursor.y),
|
||||
};
|
||||
self.cursor.cell = s.cursor_cell();
|
||||
self.cursor.style = s.cursor.style;
|
||||
self.cursor.visual_style = s.cursor.cursor_style;
|
||||
self.cursor.password_input = t.flags.password_input;
|
||||
self.cursor.visible = t.modes.get(Mode::CursorVisible);
|
||||
self.cursor.blinking = t.modes.get(Mode::CursorBlinking);
|
||||
|
||||
// Always reset the cursor viewport position. In the future we can
|
||||
// probably cache this by comparing the cursor pin and viewport pin
|
||||
// but may not be worth it.
|
||||
self.cursor.viewport = None;
|
||||
|
||||
// Colors.
|
||||
self.colors.cursor = t.colors.cursor.get();
|
||||
self.colors.palette = t.colors.palette.current;
|
||||
// Background/foreground can be unset initially which would
|
||||
// depend on "default" background/foreground. The expected use
|
||||
// case of Terminal is that the caller set their own configured
|
||||
// defaults on load so this doesn't happen.
|
||||
if let (Some(bg), Some(fg)) = (t.colors.background.get(), t.colors.foreground.get()) {
|
||||
if t.modes.get(Mode::ReverseColors) {
|
||||
self.colors.background = fg;
|
||||
self.colors.foreground = bg;
|
||||
} else {
|
||||
self.colors.background = bg;
|
||||
self.colors.foreground = fg;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure our row length is exactly our height, freeing or allocating
|
||||
// data as necessary. In most cases we'll have a perfectly matching
|
||||
// size.
|
||||
let nrows = usize::from(self.rows);
|
||||
if self.row_data.len() != nrows {
|
||||
if self.row_data.len() < nrows {
|
||||
let filler = viewport_pin;
|
||||
self.row_data.resize_with(nrows, || Row {
|
||||
pin: filler,
|
||||
raw: page::Row::default(),
|
||||
cells: Cells::default(),
|
||||
dirty: true,
|
||||
selection: None,
|
||||
highlights: Vec::new(),
|
||||
});
|
||||
} else {
|
||||
self.row_data.truncate(nrows);
|
||||
}
|
||||
}
|
||||
|
||||
// Go through and set up our row pins and find the cursor. We do the
|
||||
// cursor even if the row is not dirty because the cursor is unrelated.
|
||||
// We have to store pins even if we're not dirty because dirty is
|
||||
// only a renderer optimization. It doesn't apply to memory movement.
|
||||
// This will let us remap any cell pins back to an exact entry in our
|
||||
// RenderState.
|
||||
let cursor_pin = s.cursor_pin();
|
||||
let mut y: usize = 0;
|
||||
for row_pin in s.pages.row_iterator(
|
||||
Direction::RightDown,
|
||||
Point::Viewport(Coordinate::default()),
|
||||
None,
|
||||
) {
|
||||
if y >= nrows {
|
||||
break;
|
||||
}
|
||||
if self.cursor.viewport.is_none()
|
||||
&& row_pin.node == cursor_pin.node
|
||||
&& row_pin.y == cursor_pin.y
|
||||
{
|
||||
self.cursor.viewport = Some(CursorViewport {
|
||||
y: y as size::CellCountInt,
|
||||
x: s.cursor.x,
|
||||
|
||||
// Future: we should use our own state here to look this
|
||||
// up rather than calling this.
|
||||
wide_tail: if s.cursor.x > 0 {
|
||||
s.cursor_cell_left(1).wide() == Wide::Wide
|
||||
} else {
|
||||
false
|
||||
},
|
||||
});
|
||||
}
|
||||
self.row_data[y].pin = row_pin;
|
||||
y += 1;
|
||||
}
|
||||
assert_eq!(y, nrows);
|
||||
|
||||
// Track the last page that we know was dirty. This lets us
|
||||
// more quickly do the full-page dirty check.
|
||||
let mut last_dirty_page: Option<crate::page_list::NodeId> = None;
|
||||
let mut any_dirty = false;
|
||||
let cols = usize::from(self.cols);
|
||||
|
||||
for y in 0..nrows {
|
||||
let row_pin = self.row_data[y].pin;
|
||||
let node = s
|
||||
.pages
|
||||
.node_mut(row_pin.node)
|
||||
.ok_or(Error::InvalidCoordinate)?;
|
||||
|
||||
let is_dirty = 'dirty: {
|
||||
// If we're redrawing then we're definitely dirty.
|
||||
if redraw {
|
||||
break 'dirty true;
|
||||
}
|
||||
|
||||
// If our page is the same as last time then its dirty.
|
||||
if last_dirty_page == Some(row_pin.node) {
|
||||
break 'dirty true;
|
||||
}
|
||||
if node.data.dirty {
|
||||
// If this page is dirty then clear the dirty flag
|
||||
// of the last page and then store this one. This benchmarks
|
||||
// faster than iterating pages again later.
|
||||
if let Some(last) = last_dirty_page
|
||||
&& let Some(n) = s.pages.node_mut(last)
|
||||
{
|
||||
n.data.dirty = false;
|
||||
}
|
||||
last_dirty_page = Some(row_pin.node);
|
||||
break 'dirty true;
|
||||
}
|
||||
|
||||
// If our row is dirty then we're dirty.
|
||||
node.data
|
||||
.get_row(usize::from(row_pin.y))
|
||||
.is_some_and(|r| r.dirty())
|
||||
};
|
||||
if !is_dirty {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set that at least one row was dirty.
|
||||
any_dirty = true;
|
||||
|
||||
// Clear our row dirty, we'll clear our page dirty later.
|
||||
// We can't clear it now because we have more rows to go through.
|
||||
let node = s
|
||||
.pages
|
||||
.node_mut(row_pin.node)
|
||||
.ok_or(Error::InvalidCoordinate)?;
|
||||
let raw_row = {
|
||||
let r = node
|
||||
.data
|
||||
.get_row_mut(usize::from(row_pin.y))
|
||||
.ok_or(Error::InvalidCoordinate)?;
|
||||
r.set_dirty(false);
|
||||
*r
|
||||
};
|
||||
let p: &page::Page = &node.data;
|
||||
|
||||
let row = &mut self.row_data[y];
|
||||
|
||||
// Reset our cells if we're rebuilding this row.
|
||||
if !row.cells.is_empty() {
|
||||
row.cells.clear();
|
||||
row.selection = None;
|
||||
row.highlights.clear();
|
||||
}
|
||||
row.dirty = true;
|
||||
|
||||
// Get all our cells in the page.
|
||||
let page_cells = p
|
||||
.get_cells(usize::from(row_pin.y))
|
||||
.ok_or(Error::InvalidCoordinate)?;
|
||||
assert_eq!(page_cells.len(), cols);
|
||||
|
||||
// Copy our raw row data
|
||||
row.raw = raw_row;
|
||||
|
||||
// We always copy our raw cell data. In the case we have no
|
||||
// managed memory, we can skip setting any other fields.
|
||||
//
|
||||
// This is an important optimization. For plain-text screens
|
||||
// this ends up being something around 300% faster based on
|
||||
// the `screen-clone` benchmark.
|
||||
row.cells.raw.extend_from_slice(page_cells);
|
||||
if !raw_row.managed_memory() {
|
||||
continue;
|
||||
}
|
||||
|
||||
row.cells.grapheme.resize_with(cols, Vec::new);
|
||||
row.cells.style.resize(cols, Style::default());
|
||||
for (x, page_cell) in page_cells.iter().enumerate() {
|
||||
// Append assuming its a single-codepoint, styled cell
|
||||
// (most common by far).
|
||||
if page_cell.style_id() > 0 {
|
||||
row.cells.style[x] = p.style(*page_cell);
|
||||
}
|
||||
|
||||
// Switch on our content tag to handle less likely cases.
|
||||
match page_cell.content_tag() {
|
||||
// Primary codepoint goes into `raw` field.
|
||||
ContentTag::Codepoint => {}
|
||||
|
||||
// If we have a multi-codepoint grapheme, look it up and
|
||||
// set our content type.
|
||||
ContentTag::CodepointGrapheme => {
|
||||
if let Ok(idx) = p.cell_index(x, usize::from(row_pin.y))
|
||||
&& let Some(g) = p.lookup_grapheme(idx)
|
||||
{
|
||||
row.cells.grapheme[x].extend_from_slice(g);
|
||||
}
|
||||
}
|
||||
|
||||
ContentTag::BgColorRgb => {
|
||||
row.cells.style[x] = Style {
|
||||
bg_color: Color::Rgb(page_cell.color_rgb()),
|
||||
..Style::default()
|
||||
};
|
||||
}
|
||||
|
||||
ContentTag::BgColorPalette => {
|
||||
row.cells.style[x] = Style {
|
||||
bg_color: Color::Palette(page_cell.color_palette()),
|
||||
..Style::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If our screen has a selection, then mark the rows with the
|
||||
// selection. We do this outside of the loop above because its unlikely
|
||||
// a selection exists and because the way our selections are structured
|
||||
// today is very inefficient.
|
||||
//
|
||||
// NOTE: To improve the performance of the block below, we'll need
|
||||
// to rethink how we model selections in general.
|
||||
//
|
||||
// There are performance improvements that can be made here, though.
|
||||
// For example, `contained_row` recalculates a bunch of information
|
||||
// we can cache.
|
||||
if let Some(sel) = s.selection {
|
||||
'selection: {
|
||||
// Populate our selection cache to avoid some expensive
|
||||
// recalculation.
|
||||
let cached = match self.selection_cache {
|
||||
// If we're redrawing, we recalculate the cache just to
|
||||
// be safe. If our selection isn't equal, we aren't cached!
|
||||
Some(c) if !redraw && c.selection.eql(&s.pages, &sel) => Some(c),
|
||||
_ => None,
|
||||
};
|
||||
let cache = if let Some(c) = cached {
|
||||
// If we have no dirty rows, we can not recalculate.
|
||||
if !any_dirty {
|
||||
break 'selection;
|
||||
}
|
||||
|
||||
// We have dirty rows, we can utilize the cache.
|
||||
c
|
||||
} else {
|
||||
// Create a new cache
|
||||
let tl_pin = sel.top_left(&s.pages);
|
||||
let br_pin = sel.bottom_right(&s.pages);
|
||||
let c = SelectionCache {
|
||||
selection: Selection::new(tl_pin, br_pin, sel.rectangle),
|
||||
tl_pin,
|
||||
br_pin,
|
||||
};
|
||||
self.selection_cache = Some(c);
|
||||
c
|
||||
};
|
||||
|
||||
// Grab the inefficient data we need from the selection. At
|
||||
// least we can cache it.
|
||||
let tl = s
|
||||
.pages
|
||||
.point_from_pin(Tag::Screen, cache.tl_pin)
|
||||
.ok_or(Error::InvalidCoordinate)?
|
||||
.coord();
|
||||
let br = s
|
||||
.pages
|
||||
.point_from_pin(Tag::Screen, cache.br_pin)
|
||||
.ok_or(Error::InvalidCoordinate)?
|
||||
.coord();
|
||||
|
||||
// We need to determine if our selection is within the viewport.
|
||||
// The viewport is generally very small so the efficient way to
|
||||
// do this is to traverse the viewport pages and check for the
|
||||
// matching selection pages.
|
||||
for row in &mut self.row_data {
|
||||
let pin = row.pin;
|
||||
let p = s
|
||||
.pages
|
||||
.point_from_pin(Tag::Screen, pin)
|
||||
.ok_or(Error::InvalidCoordinate)?
|
||||
.coord();
|
||||
let Some(row_sel) = sel.contained_row_cached(
|
||||
&s.pages,
|
||||
cache.tl_pin,
|
||||
cache.br_pin,
|
||||
pin,
|
||||
tl,
|
||||
br,
|
||||
p,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let start = row_sel.start(&s.pages);
|
||||
let end = row_sel.end(&s.pages);
|
||||
assert_eq!(start.node, end.node);
|
||||
assert!(start.x <= end.x);
|
||||
assert_eq!(start.y, end.y);
|
||||
row.selection = Some([start.x, end.x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dirty state.
|
||||
if redraw {
|
||||
// Fully redraw resets some other state.
|
||||
self.screen = active_key;
|
||||
self.dirty = Dirty::Full;
|
||||
|
||||
// Note: we don't clear any row_data here because our rebuild
|
||||
// above did this.
|
||||
} else if any_dirty && self.dirty == Dirty::False {
|
||||
self.dirty = Dirty::Partial;
|
||||
}
|
||||
|
||||
// Finalize our final dirty page
|
||||
if let Some(last) = last_dirty_page
|
||||
&& let Some(n) = s.pages.node_mut(last)
|
||||
{
|
||||
n.data.dirty = false;
|
||||
}
|
||||
|
||||
// Clear our dirty flags
|
||||
t.flags.dirty = TerminalDirty::default();
|
||||
s.dirty = crate::screen::Dirty::default();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the highlights in the render state from the given flattened
|
||||
/// highlights. Because this uses flattened highlights, it does not require
|
||||
/// reading from the terminal state so it should be done outside of
|
||||
/// any critical sections.
|
||||
///
|
||||
/// This will not clear any previous highlights, so the caller must
|
||||
/// manually clear them if desired.
|
||||
pub fn update_highlights_flattened(&mut self, tag: u8, hls: &[Flattened]) {
|
||||
// Fast path, we have no highlights!
|
||||
if hls.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// This is, admittedly, horrendous. This is some low hanging fruit
|
||||
// to optimize. In my defense, screens are usually small, the number
|
||||
// of highlights is usually small, and this only happens on the
|
||||
// viewport outside of a locked area. Still, I'd love to see this
|
||||
// improved someday.
|
||||
|
||||
// We need to track whether any row had a match so we can mark
|
||||
// the dirty state.
|
||||
let mut any_dirty = false;
|
||||
let cols = self.cols;
|
||||
|
||||
for row in &mut self.row_data {
|
||||
let row_pin = row.pin;
|
||||
for hl in hls {
|
||||
let n = hl.chunks.len();
|
||||
for (i, chunk) in hl.chunks.iter().enumerate() {
|
||||
// If this node doesn't match or we're not within
|
||||
// the row range, skip it.
|
||||
if chunk.node != row_pin.node
|
||||
|| row_pin.y < chunk.start
|
||||
|| row_pin.y >= chunk.end
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// We're a match!
|
||||
row.highlights.push(Highlight {
|
||||
tag,
|
||||
range: [
|
||||
if i == 0 && row_pin.y == hl.chunks[0].start {
|
||||
hl.top_x
|
||||
} else {
|
||||
0
|
||||
},
|
||||
if i == n - 1 && row_pin.y == hl.chunks[n - 1].end - 1 {
|
||||
hl.bot_x
|
||||
} else {
|
||||
cols - 1
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
row.dirty = true;
|
||||
any_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark our dirty state.
|
||||
if any_dirty && self.dirty == Dirty::False {
|
||||
self.dirty = Dirty::Partial;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the current render state contents to a UTF-8 encoded
|
||||
/// string written to the given string. This will unwrap all the wrapped
|
||||
/// rows. This is useful for a minimal viewport search.
|
||||
///
|
||||
/// This currently writes empty cell contents as \x00 and writes all
|
||||
/// blank lines. This is fine for our current usage (link search) but
|
||||
/// we can adjust this later.
|
||||
///
|
||||
/// If `map` is given, every byte written gets the coordinate of the cell
|
||||
/// it came from appended (a newline maps to `x = cols`).
|
||||
///
|
||||
/// NOTE: There is a limitation in that wrapped lines before/after
|
||||
/// the the top/bottom line of the viewport are not included, since
|
||||
/// the render state cuts them off.
|
||||
///
|
||||
/// Returns `Err` if a cell holds an invalid Unicode scalar value.
|
||||
pub fn string(
|
||||
&self,
|
||||
out: &mut String,
|
||||
mut map: Option<&mut Vec<Coordinate>>,
|
||||
) -> Result<(), std::fmt::Error> {
|
||||
for (y, row) in self.row_data.iter().enumerate() {
|
||||
for (x, cell) in row.cells.raw.iter().enumerate() {
|
||||
let coord = Coordinate {
|
||||
x: x as u16,
|
||||
y: y as u32,
|
||||
};
|
||||
let c = char::from_u32(cell.codepoint()).ok_or(std::fmt::Error)?;
|
||||
let mut len = c.len_utf8();
|
||||
out.push(c);
|
||||
if cell.has_grapheme() {
|
||||
for &cp in row.cells.grapheme(x) {
|
||||
let c = char::from_u32(cp).ok_or(std::fmt::Error)?;
|
||||
len += c.len_utf8();
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(m) = map.as_deref_mut() {
|
||||
m.extend(std::iter::repeat_n(coord, len));
|
||||
}
|
||||
}
|
||||
|
||||
if !row.raw.wrap() {
|
||||
out.push('\n');
|
||||
if let Some(m) = map.as_deref_mut() {
|
||||
m.push(Coordinate {
|
||||
x: row.cells.len() as u16,
|
||||
y: y as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns a set of the cells that match to an OSC8 hyperlink over the
|
||||
/// given point in the render state.
|
||||
///
|
||||
/// IMPORTANT: The terminal must not have updated since the last call to
|
||||
/// `update`. If there is any chance the terminal has updated, the caller
|
||||
/// must first call `update` again to refresh the render state.
|
||||
///
|
||||
/// For example, you may want to hold a lock for the duration of the
|
||||
/// update and hyperlink lookup to ensure no updates happen in between.
|
||||
///
|
||||
/// Unlike Zig (where pins hold page pointers) the page list the pins
|
||||
/// belong to must be passed (`&terminal.screen().pages`).
|
||||
pub fn link_cells(&self, pages: &PageList, viewport_point: Coordinate) -> HashSet<Coordinate> {
|
||||
let mut result = HashSet::new();
|
||||
|
||||
// Our viewport point is sent in by the caller and can't be trusted.
|
||||
// If it is outside the valid area then just return empty because
|
||||
// we can't possibly have a link there.
|
||||
if viewport_point.x >= self.cols || viewport_point.y as usize >= self.row_data.len() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Grab our link ID
|
||||
let link_pin = self.row_data[viewport_point.y as usize].pin;
|
||||
let Some(link_node) = pages.node(link_pin.node) else {
|
||||
return result;
|
||||
};
|
||||
let link_page = &link_node.data;
|
||||
let Some(link) = ({
|
||||
let x = usize::from(viewport_point.x);
|
||||
let y = usize::from(link_pin.y);
|
||||
let Some((_, cell)) = link_page.get_row_and_cell(x, y) else {
|
||||
return result;
|
||||
};
|
||||
|
||||
// The likely scenario is that our mouse isn't even over a link.
|
||||
if !cell.hyperlink() {
|
||||
return result;
|
||||
}
|
||||
|
||||
let Ok(idx) = link_page.cell_index(x, y) else {
|
||||
return result;
|
||||
};
|
||||
let Some(link_id) = link_page.lookup_hyperlink(idx) else {
|
||||
return result;
|
||||
};
|
||||
link_page.hyperlink_set.get(link_id)
|
||||
}) else {
|
||||
return result;
|
||||
};
|
||||
|
||||
for (y, row) in self.row_data.iter().enumerate() {
|
||||
let pin = row.pin;
|
||||
let Some(other_node) = pages.node(pin.node) else {
|
||||
continue;
|
||||
};
|
||||
let other_page = &other_node.data;
|
||||
for (x, cell) in row.cells.raw.iter().enumerate() {
|
||||
if !cell.hyperlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(idx) = other_page.cell_index(x, usize::from(pin.y)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(link_id) = other_page.lookup_hyperlink(idx) else {
|
||||
continue;
|
||||
};
|
||||
let Some(other) = other_page.hyperlink_set.get(link_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if link.eql(other) {
|
||||
result.insert(Coordinate {
|
||||
y: y as u32,
|
||||
x: x as u16,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::terminal::Options;
|
||||
|
||||
fn term(cols: u16, rows: u16) -> Terminal {
|
||||
Terminal::new(Options::new(cols, rows)).unwrap()
|
||||
}
|
||||
|
||||
fn feed(t: &mut Terminal, bytes: &[u8]) {
|
||||
let mut s = t.vt_stream();
|
||||
s.next_slice(bytes).unwrap();
|
||||
}
|
||||
|
||||
fn clear_dirty(state: &mut RenderState) {
|
||||
state.dirty = Dirty::False;
|
||||
for r in &mut state.row_data {
|
||||
r.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled() {
|
||||
let mut t = term(80, 24);
|
||||
|
||||
// This fills the screen up
|
||||
t.decaln().unwrap();
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_text() {
|
||||
let mut t = term(10, 3);
|
||||
feed(&mut t, b"ABCD");
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
// Verify we have the right number of rows
|
||||
assert_eq!(state.row_data.len(), 3);
|
||||
|
||||
// All rows should have cols cells
|
||||
for r in &state.row_data {
|
||||
assert_eq!(r.cells.len(), 10);
|
||||
}
|
||||
|
||||
// Row zero should contain our text
|
||||
let cells = &state.row_data[0].cells;
|
||||
assert_eq!(cells.get(0).raw.codepoint(), 'A' as u32);
|
||||
assert_eq!(cells.get(1).raw.codepoint(), 'B' as u32);
|
||||
assert_eq!(cells.get(2).raw.codepoint(), 'C' as u32);
|
||||
assert_eq!(cells.get(3).raw.codepoint(), 'D' as u32);
|
||||
assert_eq!(cells.get(4).raw.codepoint(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_text() {
|
||||
let mut t = term(10, 3);
|
||||
feed(&mut t, b"\x1b[1mA"); // Bold
|
||||
feed(&mut t, b"\x1b[0;3mB"); // Italic
|
||||
feed(&mut t, b"\x1b[0;4mC"); // Underline
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
assert_eq!(state.row_data.len(), 3);
|
||||
for r in &state.row_data {
|
||||
assert_eq!(r.cells.len(), 10);
|
||||
}
|
||||
|
||||
let cells = &state.row_data[0].cells;
|
||||
{
|
||||
let cell = cells.get(0);
|
||||
assert_eq!(cell.raw.codepoint(), 'A' as u32);
|
||||
assert!(cell.style.flags.bold);
|
||||
}
|
||||
{
|
||||
let cell = cells.get(1);
|
||||
assert_eq!(cell.raw.codepoint(), 'B' as u32);
|
||||
assert!(!cell.style.flags.bold);
|
||||
assert!(cell.style.flags.italic);
|
||||
}
|
||||
assert_eq!(cells.get(2).raw.codepoint(), 'C' as u32);
|
||||
assert_eq!(cells.get(3).raw.codepoint(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme() {
|
||||
let mut t = term(10, 3);
|
||||
feed(&mut t, b"A");
|
||||
feed(&mut t, "\u{1F468}\u{200D}".as_bytes()); // this has a ZWJ
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
assert_eq!(state.row_data.len(), 3);
|
||||
for r in &state.row_data {
|
||||
assert_eq!(r.cells.len(), 10);
|
||||
}
|
||||
|
||||
let cells = &state.row_data[0].cells;
|
||||
assert_eq!(cells.get(0).raw.codepoint(), 'A' as u32);
|
||||
{
|
||||
let cell = cells.get(1);
|
||||
assert_eq!(cell.raw.codepoint(), 0x1F468);
|
||||
assert_eq!(cell.raw.wide(), Wide::Wide);
|
||||
assert_eq!(cell.grapheme, &[0x200D]);
|
||||
}
|
||||
{
|
||||
let cell = cells.get(2);
|
||||
assert_eq!(cell.raw.codepoint(), 0);
|
||||
assert_eq!(cell.raw.wide(), Wide::SpacerTail);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_state_in_viewport() {
|
||||
let mut t = term(10, 5);
|
||||
feed(&mut t, b"A\x1b[H");
|
||||
|
||||
let mut state = RenderState::new();
|
||||
|
||||
// Initial update
|
||||
state.update(&mut t).unwrap();
|
||||
assert_eq!(state.cursor.active.x, 0);
|
||||
assert_eq!(state.cursor.active.y, 0);
|
||||
assert_eq!(state.cursor.viewport.unwrap().x, 0);
|
||||
assert_eq!(state.cursor.viewport.unwrap().y, 0);
|
||||
assert_eq!(state.cursor.cell.codepoint(), 'A' as u32);
|
||||
assert!(state.cursor.style.is_default());
|
||||
|
||||
// Set a style on the cursor
|
||||
feed(&mut t, b"\x1b[1m"); // Bold
|
||||
state.update(&mut t).unwrap();
|
||||
assert!(!state.cursor.style.is_default());
|
||||
assert!(state.cursor.style.flags.bold);
|
||||
feed(&mut t, b"\x1b[0m"); // Reset style
|
||||
|
||||
// Move cursor to 2,1
|
||||
feed(&mut t, b"\x1b[2;3H");
|
||||
state.update(&mut t).unwrap();
|
||||
assert_eq!(state.cursor.active.x, 2);
|
||||
assert_eq!(state.cursor.active.y, 1);
|
||||
assert_eq!(state.cursor.viewport.unwrap().x, 2);
|
||||
assert_eq!(state.cursor.viewport.unwrap().y, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_state_out_of_viewport() {
|
||||
let mut t = term(10, 2);
|
||||
feed(&mut t, b"A\r\nB\r\nC\r\nD\r\n");
|
||||
|
||||
let mut state = RenderState::new();
|
||||
|
||||
// Initial update
|
||||
state.update(&mut t).unwrap();
|
||||
assert_eq!(state.cursor.active.x, 0);
|
||||
assert_eq!(state.cursor.active.y, 1);
|
||||
assert_eq!(state.cursor.viewport.unwrap().x, 0);
|
||||
assert_eq!(state.cursor.viewport.unwrap().y, 1);
|
||||
|
||||
// Scroll the viewport
|
||||
t.scroll_viewport(crate::terminal::ScrollViewport::Top);
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
// Set a style on the cursor
|
||||
assert_eq!(state.cursor.active.x, 0);
|
||||
assert_eq!(state.cursor.active.y, 1);
|
||||
assert!(state.cursor.viewport.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_state() {
|
||||
let mut t = term(10, 5);
|
||||
|
||||
let mut state = RenderState::new();
|
||||
|
||||
// First update should trigger redraw due to resize
|
||||
state.update(&mut t).unwrap();
|
||||
assert_eq!(state.dirty, Dirty::Full);
|
||||
|
||||
// Reset dirty flag and dirty rows
|
||||
clear_dirty(&mut state);
|
||||
|
||||
// Second update with no changes - no dirty rows
|
||||
state.update(&mut t).unwrap();
|
||||
assert_eq!(state.dirty, Dirty::False);
|
||||
for r in &state.row_data {
|
||||
assert!(!r.dirty);
|
||||
}
|
||||
|
||||
// Write to first line
|
||||
feed(&mut t, b"A");
|
||||
state.update(&mut t).unwrap();
|
||||
assert_eq!(state.dirty, Dirty::Partial);
|
||||
assert!(state.row_data[0].dirty); // First row dirty
|
||||
assert!(!state.row_data[1].dirty); // Second row clean
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors() {
|
||||
let mut t = term(10, 5);
|
||||
|
||||
let mut state = RenderState::new();
|
||||
|
||||
// Default colors
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
// Change cursor color
|
||||
feed(&mut t, b"\x1b]12;#FF0000\x07");
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
let c = state.colors.cursor.unwrap();
|
||||
assert_eq!(c.r, 0xFF);
|
||||
assert_eq!(c.g, 0);
|
||||
assert_eq!(c.b, 0);
|
||||
|
||||
// Change palette color 0 to White
|
||||
feed(&mut t, b"\x1b]4;0;#FFFFFF\x07");
|
||||
state.update(&mut t).unwrap();
|
||||
let p0 = state.colors.palette[0];
|
||||
assert_eq!(p0.r, 0xFF);
|
||||
assert_eq!(p0.g, 0xFF);
|
||||
assert_eq!(p0.b, 0xFF);
|
||||
}
|
||||
|
||||
fn pin_active(t: &Terminal, x: u16, y: u32) -> Pin {
|
||||
t.screen()
|
||||
.pages
|
||||
.pin(Point::Active(Coordinate { x, y }))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_single_line() {
|
||||
let mut t = term(10, 3);
|
||||
|
||||
let (a, b) = (pin_active(&t, 0, 1), pin_active(&t, 2, 1));
|
||||
t.screen_mut()
|
||||
.select(Some(Selection::new(a, b, false)))
|
||||
.unwrap();
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
assert_eq!(state.row_data[0].selection, None);
|
||||
assert_eq!(state.row_data[1].selection, Some([0, 2]));
|
||||
assert_eq!(state.row_data[2].selection, None);
|
||||
|
||||
// Clear the selection
|
||||
t.screen_mut().select(None).unwrap();
|
||||
state.update(&mut t).unwrap();
|
||||
assert_eq!(state.row_data[0].selection, None);
|
||||
assert_eq!(state.row_data[1].selection, None);
|
||||
assert_eq!(state.row_data[2].selection, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_multiple_lines() {
|
||||
let mut t = term(10, 3);
|
||||
|
||||
let (a, b) = (pin_active(&t, 0, 1), pin_active(&t, 2, 2));
|
||||
t.screen_mut()
|
||||
.select(Some(Selection::new(a, b, false)))
|
||||
.unwrap();
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
assert_eq!(state.row_data[0].selection, None);
|
||||
assert_eq!(
|
||||
state.row_data[1].selection,
|
||||
Some([0, t.screen().pages.cols - 1])
|
||||
);
|
||||
assert_eq!(state.row_data[2].selection, Some([0, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_cells() {
|
||||
let mut t = term(10, 5);
|
||||
|
||||
let mut state = RenderState::new();
|
||||
|
||||
// Create a hyperlink
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\",
|
||||
);
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
// Query link at 0,0
|
||||
let cells = state.link_cells(&t.screen().pages, Coordinate { x: 0, y: 0 });
|
||||
assert_eq!(cells.len(), 4);
|
||||
assert!(cells.contains(&Coordinate { x: 0, y: 0 }));
|
||||
assert!(cells.contains(&Coordinate { x: 1, y: 0 }));
|
||||
assert!(cells.contains(&Coordinate { x: 2, y: 0 }));
|
||||
assert!(cells.contains(&Coordinate { x: 3, y: 0 }));
|
||||
|
||||
// Query no link
|
||||
let cells2 = state.link_cells(&t.screen().pages, Coordinate { x: 4, y: 0 });
|
||||
assert_eq!(cells2.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string() {
|
||||
let mut t = term(5, 2);
|
||||
feed(&mut t, b"AB");
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
let mut result = String::new();
|
||||
state.string(&mut result, None).unwrap();
|
||||
|
||||
let expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n";
|
||||
assert_eq!(result, expected);
|
||||
|
||||
// With a map, every byte maps to its cell; newline maps to x = cols.
|
||||
let mut result = String::new();
|
||||
let mut map = Vec::new();
|
||||
state.string(&mut result, Some(&mut map)).unwrap();
|
||||
assert_eq!(map.len(), result.len());
|
||||
assert_eq!(map[0], Coordinate { x: 0, y: 0 });
|
||||
assert_eq!(map[5], Coordinate { x: 5, y: 0 });
|
||||
assert_eq!(map[6], Coordinate { x: 0, y: 1 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_cells_with_scrollback_spanning_pages() {
|
||||
let viewport_rows: u16 = 10;
|
||||
let tail_rows: u16 = 5;
|
||||
|
||||
let mut t = Terminal::new(Options {
|
||||
max_scrollback: 10_000,
|
||||
..Options::new(page::STD_CAPACITY.cols, viewport_rows)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let first_page_cap = {
|
||||
let pages = &t.screen().pages;
|
||||
pages.node(pages.first.unwrap()).unwrap().data.capacity.rows
|
||||
};
|
||||
|
||||
// Fill first page
|
||||
for _ in 0..first_page_cap - 1 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
|
||||
// Create second page with hyperlink
|
||||
feed(&mut t, b"\r\n");
|
||||
feed(
|
||||
&mut t,
|
||||
b"\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\",
|
||||
);
|
||||
for _ in 0..tail_rows - 1 {
|
||||
feed(&mut t, b"\r\n");
|
||||
}
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
let expected_viewport_y = u32::from(viewport_rows - tail_rows);
|
||||
let cells = state.link_cells(
|
||||
&t.screen().pages,
|
||||
Coordinate {
|
||||
x: 0,
|
||||
y: expected_viewport_y,
|
||||
},
|
||||
);
|
||||
assert_eq!(cells.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_cells_with_invalid_viewport_point() {
|
||||
let mut t = term(10, 5);
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
// Row out of bound
|
||||
let cells = state.link_cells(
|
||||
&t.screen().pages,
|
||||
Coordinate {
|
||||
x: 0,
|
||||
y: u32::from(t.rows) + 10,
|
||||
},
|
||||
);
|
||||
assert_eq!(cells.len(), 0);
|
||||
|
||||
// Col out of bound
|
||||
let cells = state.link_cells(
|
||||
&t.screen().pages,
|
||||
Coordinate {
|
||||
x: t.cols + 10,
|
||||
y: 0,
|
||||
},
|
||||
);
|
||||
assert_eq!(cells.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_row_resets_highlights() {
|
||||
let mut t = term(10, 3);
|
||||
feed(&mut t, b"ABC");
|
||||
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
// Reset dirty state
|
||||
clear_dirty(&mut state);
|
||||
|
||||
// Manually add a highlight to row 0
|
||||
state.row_data[0].highlights.push(Highlight {
|
||||
tag: 1,
|
||||
range: [0, 2],
|
||||
});
|
||||
assert_eq!(state.row_data[0].highlights.len(), 1);
|
||||
|
||||
// Write to row 0 to make it dirty
|
||||
feed(&mut t, b"\x1b[H"); // Move to home
|
||||
feed(&mut t, b"X");
|
||||
state.update(&mut t).unwrap();
|
||||
|
||||
// Verify the highlight was reset on the dirty row
|
||||
assert_eq!(state.row_data[0].highlights.len(), 0);
|
||||
}
|
||||
|
||||
// Additional tests (not in Zig).
|
||||
|
||||
#[test]
|
||||
fn bg_color_cell_style() {
|
||||
let mut t = term(10, 3);
|
||||
// The row must have managed memory (a styled cell) for the style of
|
||||
// bg-color-only cells to be populated (same as Zig).
|
||||
feed(&mut t, b"\x1b[48;2;1;2;3mA\x1b[K");
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
let cell = state.row_data[0].cells.get(5);
|
||||
assert_eq!(cell.raw.content_tag(), ContentTag::BgColorRgb);
|
||||
assert_eq!(cell.style.bg_color, Color::Rgb(Rgb { r: 1, g: 2, b: 3 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_highlights_flattened() {
|
||||
let mut t = term(10, 3);
|
||||
feed(&mut t, b"ABC");
|
||||
let mut state = RenderState::new();
|
||||
state.update(&mut t).unwrap();
|
||||
clear_dirty(&mut state);
|
||||
|
||||
let pages = &t.screen().pages;
|
||||
let start = pages
|
||||
.pin(Point::Viewport(Coordinate { x: 1, y: 0 }))
|
||||
.unwrap();
|
||||
let end = pages
|
||||
.pin(Point::Viewport(Coordinate { x: 4, y: 1 }))
|
||||
.unwrap();
|
||||
let hl = Flattened::init(pages, start, end).unwrap();
|
||||
state.update_highlights_flattened(7, &[hl]);
|
||||
assert_eq!(state.dirty, Dirty::Partial);
|
||||
assert_eq!(
|
||||
state.row_data[0].highlights,
|
||||
vec![Highlight {
|
||||
tag: 7,
|
||||
range: [1, 9]
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
state.row_data[1].highlights,
|
||||
vec![Highlight {
|
||||
tag: 7,
|
||||
range: [0, 4]
|
||||
}]
|
||||
);
|
||||
assert_eq!(state.row_data[2].highlights, vec![]);
|
||||
assert!(state.row_data[0].dirty && state.row_data[1].dirty);
|
||||
assert!(!state.row_data[2].dirty);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
)]
|
||||
use crate::{
|
||||
cursor::Style as CursorStyle,
|
||||
formatter::{Content as FormatContent, Options as FormatOptions, ScreenFormatter},
|
||||
hyperlink::{self, Hyperlink, HyperlinkId},
|
||||
page::{Cell, ContentTag, Page, Row, SemanticContent, SemanticPrompt as RowPrompt, Wide},
|
||||
page_list::{
|
||||
@@ -2617,19 +2618,23 @@ impl Screen {
|
||||
opts: SelectionString,
|
||||
pin_map: Option<&mut Vec<Pin>>,
|
||||
) -> String {
|
||||
// TODO(integration): delegate to formatter::ScreenFormatter (plain,
|
||||
// unwrap = true, trim = opts.trim, content = selection).
|
||||
let tl = opts.sel.top_left(&self.pages);
|
||||
let br = opts.sel.bottom_right(&self.pages);
|
||||
plain_format(
|
||||
&self.pages,
|
||||
tl,
|
||||
br,
|
||||
opts.sel.rectangle,
|
||||
true,
|
||||
opts.trim,
|
||||
pin_map,
|
||||
)
|
||||
// Create a formatter and use that to emit our text.
|
||||
let mut formatter = ScreenFormatter::new(
|
||||
self,
|
||||
FormatOptions {
|
||||
unwrap: true,
|
||||
trim: opts.trim,
|
||||
..FormatOptions::plain()
|
||||
},
|
||||
);
|
||||
formatter.content = FormatContent::Selection(Some(opts.sel));
|
||||
|
||||
// If we have a string map, we need to set that up.
|
||||
formatter.pin_map = pin_map;
|
||||
|
||||
formatter
|
||||
.format_string()
|
||||
.expect("formatting to memory cannot fail")
|
||||
}
|
||||
|
||||
/// Select the line under the given point. This will select across soft-wrapped
|
||||
@@ -3224,23 +3229,31 @@ impl Screen {
|
||||
|
||||
/// Dump the screen to a string.
|
||||
pub fn dump_string(&self, out: &mut String, opts: DumpString) {
|
||||
// TODO(integration): delegate to formatter::ScreenFormatter (plain,
|
||||
// unwrap = opts.unwrap, trim = false, content = selection(tl, br)).
|
||||
// Create a formatter and use that to emit our text.
|
||||
let mut formatter = ScreenFormatter::new(
|
||||
self,
|
||||
FormatOptions {
|
||||
unwrap: opts.unwrap,
|
||||
trim: false,
|
||||
..FormatOptions::plain()
|
||||
},
|
||||
);
|
||||
|
||||
// Set up the selection based on the pins
|
||||
let tl = opts.tl;
|
||||
let br = opts
|
||||
.br
|
||||
.unwrap_or_else(|| self.pages.get_bottom_right(Tag::Screen).expect("screen"));
|
||||
let sel = Selection::new(opts.tl, br, false);
|
||||
let tl = sel.top_left(&self.pages);
|
||||
let br = sel.bottom_right(&self.pages);
|
||||
out.push_str(&plain_format(
|
||||
&self.pages,
|
||||
tl,
|
||||
br,
|
||||
false,
|
||||
opts.unwrap,
|
||||
false,
|
||||
None,
|
||||
));
|
||||
formatter.content = FormatContent::Selection(Some(Selection::new(
|
||||
tl, br, false, // not rectangle
|
||||
)));
|
||||
|
||||
// Emit
|
||||
out.push_str(
|
||||
&formatter
|
||||
.format_string()
|
||||
.expect("formatting to memory cannot fail"),
|
||||
);
|
||||
}
|
||||
|
||||
/// You should use dump_string, this is a restricted version mostly for
|
||||
@@ -3471,334 +3484,5 @@ impl Iterator for LineIterator<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal plain-text formatter.
|
||||
//
|
||||
// TODO(integration): this is a minimal port of the `emit = .plain` paths of
|
||||
// formatter.zig's PageListFormatter/PageFormatter (unwrapping soft-wrapped
|
||||
// lines, trimming trailing whitespace, trailing blank lines are dropped).
|
||||
// Replace with the real formatter once ported.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Trailing state carried between pages (formatter.zig `TrailingState`).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct TrailingState {
|
||||
rows: usize,
|
||||
cells: usize,
|
||||
}
|
||||
|
||||
fn write_codepoint(
|
||||
out: &mut String,
|
||||
cp: u32,
|
||||
map: &mut Option<&mut Vec<(u16, u16)>>,
|
||||
x: u16,
|
||||
y: u16,
|
||||
) {
|
||||
let ch = char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER);
|
||||
let before = out.len();
|
||||
out.push(ch);
|
||||
if let Some(map) = map {
|
||||
for _ in before..out.len() {
|
||||
map.push((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn plain_format_page(
|
||||
page: &Page,
|
||||
out: &mut String,
|
||||
start_x: u16,
|
||||
start_y: u16,
|
||||
end_x: Option<u16>,
|
||||
end_y: Option<u16>,
|
||||
rectangle: bool,
|
||||
unwrap: bool,
|
||||
trim: bool,
|
||||
trailing_state: Option<TrailingState>,
|
||||
mut point_map: Option<&mut Vec<(u16, u16)>>,
|
||||
) -> TrailingState {
|
||||
let mut blank_rows: usize = 0;
|
||||
let mut blank_cells: usize = 0;
|
||||
|
||||
// Continue our prior trailing state if we have it, but only if we're
|
||||
// starting from the beginning (start_y and start_x are both 0).
|
||||
// If a non-zero start position is specified, ignore trailing state.
|
||||
if let Some(state) = trailing_state
|
||||
&& start_y == 0
|
||||
&& start_x == 0
|
||||
{
|
||||
blank_rows = state.rows;
|
||||
blank_cells = state.cells;
|
||||
}
|
||||
let ret = |blank_rows, blank_cells| TrailingState {
|
||||
rows: blank_rows,
|
||||
cells: blank_cells,
|
||||
};
|
||||
|
||||
// Setup our starting column and perform some validation for overflows.
|
||||
if start_x >= page.size.cols {
|
||||
return ret(blank_rows, blank_cells);
|
||||
}
|
||||
let end_x_unclamped = end_x.unwrap_or(page.size.cols - 1);
|
||||
let mut end_x = end_x_unclamped.min(page.size.cols - 1);
|
||||
|
||||
// Setup our starting row and perform some validation for overflows.
|
||||
if start_y >= page.size.rows {
|
||||
return ret(blank_rows, blank_cells);
|
||||
}
|
||||
let end_y_unclamped = end_y.unwrap_or(page.size.rows - 1);
|
||||
if start_y > end_y_unclamped {
|
||||
return ret(blank_rows, blank_cells);
|
||||
}
|
||||
let mut end_y = end_y_unclamped.min(page.size.rows - 1);
|
||||
|
||||
// Edge case: if our end x/y falls on a spacer head AND we're unwrapping,
|
||||
// then we move the x/y to the start of the next row (if available).
|
||||
if unwrap && !rectangle {
|
||||
let cells = page.get_cells(usize::from(end_y)).expect("row");
|
||||
if cells[usize::from(end_x)].wide() == Wide::SpacerHead && end_y < page.size.rows - 1 {
|
||||
end_y += 1;
|
||||
end_x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// If we only have a single row, validate that start_x <= end_x
|
||||
if start_y == end_y && start_x > end_x {
|
||||
return ret(blank_rows, blank_cells);
|
||||
}
|
||||
|
||||
for y in start_y..=end_y {
|
||||
let row = *page.get_row(usize::from(y)).expect("row");
|
||||
let cells = page.get_cells(usize::from(y)).expect("row cells");
|
||||
let base = row.cells();
|
||||
|
||||
// Determine the x range for this row
|
||||
// - First row: start_x to end of row (or end_x if single row)
|
||||
// - Last row: start of row to end_x
|
||||
// - Middle rows: full width
|
||||
let row_end_x: u16 = if rectangle || y == end_y {
|
||||
end_x + 1
|
||||
} else {
|
||||
page.size.cols
|
||||
};
|
||||
|
||||
// The first we have to check if our start X falls on the
|
||||
// tail of a wide character.
|
||||
let row_start_x: u16 = if start_x > 0 && (rectangle || y == start_y) {
|
||||
match cells[usize::from(start_x)].wide() {
|
||||
// Include the prior cell to get the full wide char
|
||||
Wide::SpacerTail => start_x - 1,
|
||||
|
||||
// If we're a spacer head on our first row then we
|
||||
// skip this whole row.
|
||||
Wide::SpacerHead => continue,
|
||||
|
||||
Wide::Narrow | Wide::Wide => start_x,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let subset = &cells[usize::from(row_start_x)..usize::from(row_end_x)];
|
||||
|
||||
// If this row is blank, accumulate to avoid a bunch of extra
|
||||
// work later. If it isn't blank, make sure we dump all our
|
||||
// blanks.
|
||||
if !Cell::has_text_any(subset) {
|
||||
blank_rows += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if blank_rows > 0 {
|
||||
for _ in 0..blank_rows {
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// \r and \n map to the row that ends with this newline.
|
||||
// If we're continuing (trailing state) then this will be
|
||||
// in a prior page, so we just map to the first row of this
|
||||
// page.
|
||||
if let Some(map) = point_map.as_deref_mut() {
|
||||
let start = map.last().copied().unwrap_or((0, 0));
|
||||
|
||||
// The first one inherits the x value.
|
||||
map.push(start);
|
||||
|
||||
// All others have x = 0 since they reference their prior
|
||||
// blank line.
|
||||
for y_offset in 1..blank_rows {
|
||||
map.push((0, start.1 + y_offset as u16));
|
||||
}
|
||||
}
|
||||
|
||||
blank_rows = 0;
|
||||
}
|
||||
|
||||
// If we're not wrapped, we always add a newline so after
|
||||
// the row is printed we can add a newline.
|
||||
if !row.wrap() || !unwrap {
|
||||
blank_rows += 1;
|
||||
}
|
||||
|
||||
// If the row doesn't continue a wrap then we need to reset
|
||||
// our blank cell count.
|
||||
if !row.wrap_continuation() || !unwrap {
|
||||
blank_cells = 0;
|
||||
}
|
||||
|
||||
// Go through each cell and print it
|
||||
for (offset, cell) in subset.iter().enumerate() {
|
||||
let x = row_start_x + offset as u16;
|
||||
|
||||
// Skip spacers. These happen naturally when wide characters
|
||||
// are printed again on the screen (for well-behaved terminals!)
|
||||
match cell.wide() {
|
||||
Wide::Narrow | Wide::Wide => {}
|
||||
Wide::SpacerHead | Wide::SpacerTail => continue,
|
||||
}
|
||||
|
||||
// If we have a zero value, then we accumulate a counter. We
|
||||
// only want to turn zero values into spaces if we have a non-zero
|
||||
// char sometime later.
|
||||
// Cells with no text are blank
|
||||
if !cell.has_text() {
|
||||
blank_cells += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Trailing spaces are blank. We know it is trailing
|
||||
// because if we get a non-empty cell later we'll
|
||||
// fill the blanks.
|
||||
if cell.codepoint() == u32::from(' ') && trim {
|
||||
blank_cells += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// This cell is not blank. If we have accumulated blank cells
|
||||
// then we want to emit them now.
|
||||
if blank_cells > 0 {
|
||||
for _ in 0..blank_cells {
|
||||
out.push(' ');
|
||||
}
|
||||
|
||||
if let Some(map) = point_map.as_deref_mut() {
|
||||
// Map each blank cell to its coordinate. Blank cells can span
|
||||
// multiple rows if they carry over from wrap continuation.
|
||||
let mut remaining_blanks = blank_cells;
|
||||
let mut blank_x = x;
|
||||
let mut blank_y = y;
|
||||
while remaining_blanks > 0 {
|
||||
if blank_x > 0 {
|
||||
// We have space in this row
|
||||
blank_x -= 1;
|
||||
} else if blank_y > 0 {
|
||||
// Wrap to previous row
|
||||
blank_y -= 1;
|
||||
blank_x = page.size.cols - 1;
|
||||
} else {
|
||||
// Can't go back further, just use (0, 0)
|
||||
blank_x = 0;
|
||||
blank_y = 0;
|
||||
}
|
||||
|
||||
map.push((blank_x, blank_y));
|
||||
remaining_blanks -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
blank_cells = 0;
|
||||
}
|
||||
|
||||
write_codepoint(out, cell.codepoint(), &mut point_map, x, y);
|
||||
if cell.content_tag() == ContentTag::CodepointGrapheme
|
||||
&& let Some(extra) = page.lookup_grapheme(base + usize::from(x))
|
||||
{
|
||||
for &cp in extra {
|
||||
write_codepoint(out, cp, &mut point_map, x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ret(blank_rows, blank_cells)
|
||||
}
|
||||
|
||||
/// Plain-text format of the pages between `tl` and `br` (both inclusive,
|
||||
/// properly ordered).
|
||||
fn plain_format(
|
||||
list: &PageList,
|
||||
tl: Pin,
|
||||
br: Pin,
|
||||
rectangle: bool,
|
||||
unwrap: bool,
|
||||
trim: bool,
|
||||
mut pin_map: Option<&mut Vec<Pin>>,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
let mut page_state: Option<TrailingState> = None;
|
||||
let mut point_map: Vec<(u16, u16)> = Vec::new();
|
||||
|
||||
let mut node = Some(tl.node);
|
||||
while let Some(id) = node {
|
||||
let n = list.node(id).expect("live node");
|
||||
let chunk_start = if id == tl.node { tl.y } else { 0 };
|
||||
let chunk_end = if id == br.node {
|
||||
br.y + 1
|
||||
} else {
|
||||
n.data.size.rows
|
||||
};
|
||||
debug_assert!(chunk_start < chunk_end);
|
||||
|
||||
let (start_x, end_x) = if rectangle {
|
||||
// For rectangle selection, apply start_x and end_x to all chunks
|
||||
(tl.x, Some(br.x))
|
||||
} else {
|
||||
// Otherwise only on the first/last, respectively.
|
||||
(
|
||||
if id == tl.node { tl.x } else { 0 },
|
||||
if id == br.node { Some(br.x) } else { None },
|
||||
)
|
||||
};
|
||||
|
||||
point_map.clear();
|
||||
page_state = Some(plain_format_page(
|
||||
&n.data,
|
||||
&mut out,
|
||||
start_x,
|
||||
chunk_start,
|
||||
end_x,
|
||||
Some(chunk_end - 1),
|
||||
rectangle,
|
||||
unwrap,
|
||||
trim,
|
||||
page_state,
|
||||
if pin_map.is_some() {
|
||||
Some(&mut point_map)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
));
|
||||
|
||||
if let Some(m) = pin_map.as_deref_mut() {
|
||||
for &(x, y) in &point_map {
|
||||
m.push(Pin {
|
||||
node: id,
|
||||
x,
|
||||
y,
|
||||
garbage: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if id == br.node {
|
||||
break;
|
||||
}
|
||||
node = n.next;
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user