From ea6fb5880ff1bc25e07d27de8f8b9bb4c22c95d0 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 22:39:29 +0200 Subject: [PATCH] feat(terminal): port Terminal, read-only stream handler and StringMap Ports ZIG/src/terminal/Terminal.zig - printing with grapheme clustering and Unicode widths, wraparound, insert mode, charsets, cursor movement, scroll regions and left/right margins, erase/insert/delete, tabs, modes, alternate screens, save/restore cursor, DECALN, full reset, palette, mouse/focus/paste flags, kitty keyboard, titles/pwd, semantic prompts, hyperlinks, protected mode, synchronized output and resize. stream_readonly.rs implements ghostty_vt::stream::Handler, applying all state-changing actions to a Terminal and ignoring queries; it is the base for termio's side-effecting handler. StringMap uses the onig crate for Oniguruma-compatible regexes (needed by Ghostty's URL regex). Not yet wired: kitty graphics (hooks and dirty flags are in place where Zig calls into it, storage is being ported separately) and the tmux control-mode viewer. Test Plan: - cargo test -p ghostty-terminal (849 passed, 5 ignored; all 372 Terminal and 33 stream_readonly tests ported; ignored ones are blocked on the PageList parity pass) --- crates/ghostty-terminal/Cargo.toml | 4 + crates/ghostty-terminal/src/lib.rs | 3 + crates/ghostty-terminal/src/screen.rs | 9 +- .../ghostty-terminal/src/stream_readonly.rs | 1017 +++++ crates/ghostty-terminal/src/string_map.rs | 231 ++ crates/ghostty-terminal/src/terminal.rs | 3271 +++++++++++++++++ .../src/terminal/tests/glitch.txt | 1 + .../src/terminal/tests/mod.rs | 90 + .../ghostty-terminal/src/terminal/tests/p1.rs | 1211 ++++++ .../ghostty-terminal/src/terminal/tests/p2.rs | 781 ++++ .../ghostty-terminal/src/terminal/tests/p3.rs | 1061 ++++++ .../ghostty-terminal/src/terminal/tests/p4.rs | 731 ++++ .../ghostty-terminal/src/terminal/tests/p5.rs | 649 ++++ .../ghostty-terminal/src/terminal/tests/p6.rs | 709 ++++ .../ghostty-terminal/src/terminal/tests/p7.rs | 672 ++++ .../ghostty-terminal/src/terminal/tests/p8.rs | 746 ++++ 16 files changed, 11178 insertions(+), 8 deletions(-) create mode 100644 crates/ghostty-terminal/src/stream_readonly.rs create mode 100644 crates/ghostty-terminal/src/string_map.rs create mode 100644 crates/ghostty-terminal/src/terminal.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/glitch.txt create mode 100644 crates/ghostty-terminal/src/terminal/tests/mod.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/p1.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/p2.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/p3.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/p4.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/p5.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/p6.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/p7.rs create mode 100644 crates/ghostty-terminal/src/terminal/tests/p8.rs diff --git a/crates/ghostty-terminal/Cargo.toml b/crates/ghostty-terminal/Cargo.toml index 5db11df..b6bfbe1 100644 --- a/crates/ghostty-terminal/Cargo.toml +++ b/crates/ghostty-terminal/Cargo.toml @@ -9,6 +9,10 @@ ghostty-datastruct.workspace = true ghostty-unicode.workspace = true ghostty-vt.workspace = true log = "0.4.34" +onig = "6.5.3" + +[dev-dependencies] +ghostty-config.workspace = true [lints] workspace = true diff --git a/crates/ghostty-terminal/src/lib.rs b/crates/ghostty-terminal/src/lib.rs index 150187a..2a6948e 100644 --- a/crates/ghostty-terminal/src/lib.rs +++ b/crates/ghostty-terminal/src/lib.rs @@ -11,5 +11,8 @@ pub mod screen; pub mod screen_set; pub mod selection; pub mod size; +pub mod stream_readonly; +pub mod string_map; pub mod style; pub mod tabstops; +pub mod terminal; diff --git a/crates/ghostty-terminal/src/screen.rs b/crates/ghostty-terminal/src/screen.rs index 02a18bf..17de265 100644 --- a/crates/ghostty-terminal/src/screen.rs +++ b/crates/ghostty-terminal/src/screen.rs @@ -361,14 +361,7 @@ pub enum SemanticContentSet { Input(InputClear), } -/// A string and a map from each byte of the string to the pin it came from. -/// -/// TODO(integration): replace with `string_map::StringMap` once ported. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct StringMap { - pub string: String, - pub map: Vec, -} +pub use crate::string_map::StringMap; #[derive(Debug, Clone, Copy)] pub struct SelectionString { diff --git a/crates/ghostty-terminal/src/stream_readonly.rs b/crates/ghostty-terminal/src/stream_readonly.rs new file mode 100644 index 0000000..7917ada --- /dev/null +++ b/crates/ghostty-terminal/src/stream_readonly.rs @@ -0,0 +1,1017 @@ +//! A stream handler that applies all terminal-state-modifying actions to a +//! [`Terminal`] and ignores everything that would require a response. +//! +//! Port of `ZIG/src/terminal/stream_readonly.zig`. +//! +//! This is a Stream implementation that processes actions against a Terminal +//! and updates the Terminal state. It is called "readonly" because it only +//! processes actions that modify terminal state, while ignoring any actions +//! that require a response (like queries). +//! +//! If you're implementing a terminal emulator that only needs to render +//! output and doesn't need to respond (since it maybe isn't running the +//! actual program), this is the stream type to use. For example, this is +//! ideal for replay tooling, CI logs, PaaS builder output, etc. +use crate::{ + cursor::Style as CursorStyle, + terminal::{ + DeccolmMode, Error, MouseEvents, MouseFormat, MouseShiftCapture, SwitchScreenMode, Terminal, + }, +}; +use ghostty_vt::{ + ansi::{CursorStyle as AnsiCursorStyle, ModifyKeyFormat, ProtectedMode}, + color::Dynamic as DynamicColor, + csi, + kitty::{self, key::SetMode as KittySetMode}, + modes::Mode, + osc::color::{self as osc_color, Request, Target}, + stream::{self, Action}, +}; + +/// The stream type wrapping [`Handler`]. +pub type Stream<'a> = stream::Stream>; + +/// See [`Stream`], which is just the stream wrapper around this. +/// +/// This isn't attached directly to Terminal because there is additional +/// state and options we plan to add in the future, such as APC/DCS which +/// don't make sense to me to add to the Terminal directly. Instead, you +/// can call `vt_handler` on Terminal to initialize this handler. +pub struct Handler<'a> { + /// The terminal state to modify. + pub terminal: &'a mut Terminal, +} + +impl<'a> Handler<'a> { + pub fn new(terminal: &'a mut Terminal) -> Self { + Self { terminal } + } + + fn horizontal_tab(&mut self, count: u16) { + for _ in 0..count { + let x = self.terminal.screen().cursor.x; + self.terminal.horizontal_tab(); + if x == self.terminal.screen().cursor.x { + break; + } + } + } + + fn horizontal_tab_back(&mut self, count: u16) { + for _ in 0..count { + let x = self.terminal.screen().cursor.x; + self.terminal.horizontal_tab_back(); + if x == self.terminal.screen().cursor.x { + break; + } + } + } + + fn set_mode(&mut self, mode: Mode, enabled: bool) -> Result<(), Error> { + // Set the mode on the terminal + self.terminal.modes.set(mode, enabled); + + // Some modes require additional processing + match mode { + Mode::Autorepeat | Mode::ReverseColors => {} + + Mode::Origin => self.terminal.set_cursor_pos(1, 1), + + Mode::EnableLeftAndRightMargin => { + if !enabled { + self.terminal.scrolling_region.left = 0; + self.terminal.scrolling_region.right = self.terminal.cols - 1; + } + } + + Mode::AltScreenLegacy => self + .terminal + .switch_screen_mode(SwitchScreenMode::M47, enabled)?, + Mode::AltScreen => self + .terminal + .switch_screen_mode(SwitchScreenMode::M1047, enabled)?, + Mode::AltScreenSaveCursorClearEnter => self + .terminal + .switch_screen_mode(SwitchScreenMode::M1049, enabled)?, + + Mode::SaveCursor => { + if enabled { + self.terminal.save_cursor(); + } else { + self.terminal.restore_cursor(); + } + } + + Mode::EnableMode3 => {} + + Mode::M132Column => self.terminal.deccolm(if enabled { + DeccolmMode::Cols132 + } else { + DeccolmMode::Cols80 + })?, + + Mode::SynchronizedOutput + | Mode::Linefeed + | Mode::InBandSizeReports + | Mode::FocusEvent => {} + + Mode::MouseEventX10 => { + self.terminal.flags.mouse_event = if enabled { + MouseEvents::X10 + } else { + MouseEvents::None + }; + } + Mode::MouseEventNormal => { + self.terminal.flags.mouse_event = if enabled { + MouseEvents::Normal + } else { + MouseEvents::None + }; + } + Mode::MouseEventButton => { + self.terminal.flags.mouse_event = if enabled { + MouseEvents::Button + } else { + MouseEvents::None + }; + } + Mode::MouseEventAny => { + self.terminal.flags.mouse_event = if enabled { + MouseEvents::Any + } else { + MouseEvents::None + }; + } + + Mode::MouseFormatUtf8 => { + self.terminal.flags.mouse_format = if enabled { + MouseFormat::Utf8 + } else { + MouseFormat::X10 + }; + } + Mode::MouseFormatSgr => { + self.terminal.flags.mouse_format = if enabled { + MouseFormat::Sgr + } else { + MouseFormat::X10 + }; + } + Mode::MouseFormatUrxvt => { + self.terminal.flags.mouse_format = if enabled { + MouseFormat::Urxvt + } else { + MouseFormat::X10 + }; + } + Mode::MouseFormatSgrPixels => { + self.terminal.flags.mouse_format = if enabled { + MouseFormat::SgrPixels + } else { + MouseFormat::X10 + }; + } + + _ => {} + } + Ok(()) + } + + fn color_operation(&mut self, requests: &osc_color::List) { + for req in requests { + match *req { + Request::Set(set) => match set.target { + Target::Palette(i) => { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(i, set.color); + } + Target::Dynamic(dynamic) => match dynamic { + DynamicColor::Foreground => self.terminal.colors.foreground.set(set.color), + DynamicColor::Background => self.terminal.colors.background.set(set.color), + DynamicColor::Cursor => self.terminal.colors.cursor.set(set.color), + _ => {} + }, + Target::Special(_) => {} + }, + + Request::Reset(target) => match target { + Target::Palette(i) => { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(i); + } + Target::Dynamic(dynamic) => match dynamic { + DynamicColor::Foreground => self.terminal.colors.foreground.reset(), + DynamicColor::Background => self.terminal.colors.background.reset(), + DynamicColor::Cursor => self.terminal.colors.cursor.reset(), + _ => {} + }, + Target::Special(_) => {} + }, + + Request::ResetPalette => { + let indices: Vec = self.terminal.colors.palette.mask.iter().collect(); + for i in indices { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(i as u8); + } + self.terminal.colors.palette.mask = + ghostty_vt::color::PaletteMask::init_empty(); + } + + Request::Query(_) | Request::ResetSpecial => {} + } + } + } + + fn kitty_color_operation(&mut self, request: &kitty::color::OSC) { + use kitty::color::{Kind, Request as KReq, Special}; + for item in &request.list { + match item { + KReq::Set { key, color } => match key { + Kind::Palette(palette) => { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.set(*palette, *color); + } + Kind::Special(special) => match special { + Special::Foreground => self.terminal.colors.foreground.set(*color), + Special::Background => self.terminal.colors.background.set(*color), + Special::Cursor => self.terminal.colors.cursor.set(*color), + _ => {} + }, + }, + KReq::Reset(key) => match key { + Kind::Palette(palette) => { + self.terminal.flags.dirty.palette = true; + self.terminal.colors.palette.reset(*palette); + } + Kind::Special(special) => match special { + Special::Foreground => self.terminal.colors.foreground.reset(), + Special::Background => self.terminal.colors.background.reset(), + Special::Cursor => self.terminal.colors.cursor.reset(), + _ => {} + }, + }, + KReq::Query(_) => {} + } + } + } +} + +impl stream::Handler for Handler<'_> { + type Error = Error; + + fn vt(&mut self, action: Action<'_>) -> Result<(), Error> { + let t = &mut *self.terminal; + match action { + Action::Print(v) => t.print(v.cp)?, + Action::PrintRepeat(v) => t.print_repeat(v)?, + Action::Backspace => t.backspace(), + Action::CarriageReturn => t.carriage_return(), + Action::Linefeed => t.linefeed()?, + Action::Index => t.index()?, + Action::NextLine => { + t.index()?; + t.carriage_return(); + } + Action::ReverseIndex => t.reverse_index(), + Action::CursorUp(v) => t.cursor_up(usize::from(v.value)), + Action::CursorDown(v) => t.cursor_down(usize::from(v.value)), + Action::CursorLeft(v) => t.cursor_left(usize::from(v.value)), + Action::CursorRight(v) => t.cursor_right(usize::from(v.value)), + Action::CursorPos(v) => t.set_cursor_pos(usize::from(v.row), usize::from(v.col)), + Action::CursorCol(v) => { + let row = usize::from(t.screen().cursor.y) + 1; + t.set_cursor_pos(row, usize::from(v.value)); + } + Action::CursorRow(v) => { + let col = usize::from(t.screen().cursor.x) + 1; + t.set_cursor_pos(usize::from(v.value), col); + } + Action::CursorColRelative(v) => { + let row = usize::from(t.screen().cursor.y) + 1; + let col = + (usize::from(t.screen().cursor.x) + 1).saturating_add(usize::from(v.value)); + t.set_cursor_pos(row, col); + } + Action::CursorRowRelative(v) => { + let row = + (usize::from(t.screen().cursor.y) + 1).saturating_add(usize::from(v.value)); + let col = usize::from(t.screen().cursor.x) + 1; + t.set_cursor_pos(row, col); + } + Action::CursorStyle(v) => { + let blink = match v { + AnsiCursorStyle::Default + | AnsiCursorStyle::SteadyBlock + | AnsiCursorStyle::SteadyBar + | AnsiCursorStyle::SteadyUnderline => false, + AnsiCursorStyle::BlinkingBlock + | AnsiCursorStyle::BlinkingBar + | AnsiCursorStyle::BlinkingUnderline => true, + }; + let style = match v { + AnsiCursorStyle::Default + | AnsiCursorStyle::BlinkingBlock + | AnsiCursorStyle::SteadyBlock => CursorStyle::Block, + AnsiCursorStyle::BlinkingBar | AnsiCursorStyle::SteadyBar => CursorStyle::Bar, + AnsiCursorStyle::BlinkingUnderline | AnsiCursorStyle::SteadyUnderline => { + CursorStyle::Underline + } + }; + t.modes.set(Mode::CursorBlinking, blink); + t.screen_mut().cursor.cursor_style = style; + } + Action::EraseDisplayBelow(p) => t.erase_display(csi::EraseDisplay::Below, p), + Action::EraseDisplayAbove(p) => t.erase_display(csi::EraseDisplay::Above, p), + Action::EraseDisplayComplete(p) => t.erase_display(csi::EraseDisplay::Complete, p), + Action::EraseDisplayScrollback(p) => t.erase_display(csi::EraseDisplay::Scrollback, p), + Action::EraseDisplayScrollComplete(p) => { + t.erase_display(csi::EraseDisplay::ScrollComplete, p); + } + Action::EraseLineRight(p) => t.erase_line(csi::EraseLine::Right, p), + Action::EraseLineLeft(p) => t.erase_line(csi::EraseLine::Left, p), + Action::EraseLineComplete(p) => t.erase_line(csi::EraseLine::Complete, p), + Action::EraseLineRightUnlessPendingWrap(p) => { + t.erase_line(csi::EraseLine::RightUnlessPendingWrap, p); + } + Action::DeleteChars(v) => t.delete_chars(v), + Action::EraseChars(v) => t.erase_chars(v), + Action::InsertLines(v) => t.insert_lines(v), + Action::InsertBlanks(v) => t.insert_blanks(v), + Action::DeleteLines(v) => t.delete_lines(v), + Action::ScrollUp(v) => t.scroll_up(v)?, + Action::ScrollDown(v) => t.scroll_down(v), + Action::HorizontalTab(v) => self.horizontal_tab(v), + Action::HorizontalTabBack(v) => self.horizontal_tab_back(v), + Action::TabClearCurrent => t.tab_clear(csi::TabClear::Current), + Action::TabClearAll => t.tab_clear(csi::TabClear::All), + Action::TabSet => t.tab_set(), + Action::TabReset => t.tab_reset(), + Action::SetMode(v) => self.set_mode(v.mode, true)?, + Action::ResetMode(v) => self.set_mode(v.mode, false)?, + Action::SaveMode(v) => t.modes.save(v.mode), + Action::RestoreMode(v) => { + let enabled = t.modes.restore(v.mode); + self.set_mode(v.mode, enabled)?; + } + Action::TopAndBottomMargin(v) => { + t.set_top_and_bottom_margin(usize::from(v.top_left), usize::from(v.bottom_right)); + } + Action::LeftAndRightMargin(v) => { + t.set_left_and_right_margin(usize::from(v.top_left), usize::from(v.bottom_right)); + } + Action::LeftAndRightMarginAmbiguous => { + if t.modes.get(Mode::EnableLeftAndRightMargin) { + t.set_left_and_right_margin(0, 0); + } else { + t.save_cursor(); + } + } + Action::SaveCursor => t.save_cursor(), + Action::RestoreCursor => t.restore_cursor(), + Action::InvokeCharset(v) => t.invoke_charset(v.bank, v.charset, v.locking), + Action::ConfigureCharset(v) => t.configure_charset(v.slot, v.charset), + Action::SetAttribute(v) => { + if !matches!(v, ghostty_vt::sgr::Attribute::Unknown(_)) { + let _ = t.set_attribute(v); + } + } + Action::ProtectedModeOff => t.set_protected_mode(ProtectedMode::Off), + Action::ProtectedModeIso => t.set_protected_mode(ProtectedMode::Iso), + Action::ProtectedModeDec => t.set_protected_mode(ProtectedMode::Dec), + Action::MouseShiftCapture(v) => { + t.flags.mouse_shift_capture = if v { + MouseShiftCapture::True + } else { + MouseShiftCapture::False + }; + } + Action::KittyKeyboardPush(v) => t.screen_mut().kitty_keyboard.push(v.flags), + Action::KittyKeyboardPop(v) => t.screen_mut().kitty_keyboard.pop(usize::from(v)), + Action::KittyKeyboardSet(v) => { + t.screen_mut() + .kitty_keyboard + .set(KittySetMode::Set, v.flags); + } + Action::KittyKeyboardSetOr(v) => { + t.screen_mut().kitty_keyboard.set(KittySetMode::Or, v.flags); + } + Action::KittyKeyboardSetNot(v) => { + t.screen_mut() + .kitty_keyboard + .set(KittySetMode::Not, v.flags); + } + Action::ModifyKeyFormat(v) => { + t.flags.modify_other_keys_2 = false; + if v == ModifyKeyFormat::OtherKeysNumeric { + t.flags.modify_other_keys_2 = true; + } + } + Action::ActiveStatusDisplay(v) => t.status_display = v, + Action::Decaln => t.decaln()?, + Action::FullReset => t.full_reset(), + Action::StartHyperlink(v) => t.screen_mut().start_hyperlink(v.uri, v.id)?, + Action::EndHyperlink => t.screen_mut().end_hyperlink(), + Action::SemanticPrompt(v) => t.semantic_prompt(v)?, + Action::MouseShape(v) => t.mouse_shape = v, + Action::ColorOperation(v) => self.color_operation(v.requests), + Action::KittyColorReport(v) => self.kitty_color_operation(v), + + // No supported DCS commands have any terminal-modifying effects, + // but they may in the future. For now we just ignore it. + Action::DcsHook(_) | Action::DcsPut(_) | Action::DcsUnhook => {} + + // APC can modify terminal state (Kitty graphics) but we don't + // currently support it in the readonly stream. + Action::ApcStart | Action::ApcEnd | Action::ApcPut(_) => {} + + // Have no terminal-modifying effect + Action::Bell + | Action::Enquiry + | Action::RequestMode(_) + | Action::RequestModeUnknown(_) + | Action::SizeReport(_) + | Action::Xtversion + | Action::DeviceAttributes(_) + | Action::DeviceStatus(_) + | Action::KittyKeyboardQuery + | Action::WindowTitle(_) + | Action::ReportPwd(_) + | Action::ShowDesktopNotification(_) + | Action::ProgressReport(_) + | Action::ClipboardContents(_) + | Action::TitlePush(_) + | Action::TitlePop(_) => {} + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{page::SemanticContent, screen_set::Key as ScreenKey, terminal::Options}; + use ghostty_vt::osc::semantic_prompt::Redraw; + + fn term(cols: u16, rows: u16) -> Terminal { + Terminal::new(Options::new(cols, rows)).unwrap() + } + + #[test] + fn basic_print() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + s.next_slice(b"Hello").unwrap(); + assert_eq!(5, s.handler.terminal.screen().cursor.x); + assert_eq!(0, s.handler.terminal.screen().cursor.y); + + assert_eq!("Hello", s.handler.terminal.plain_string().unwrap()); + } + + #[test] + fn cursor_movement() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Move cursor using escape sequences + s.next_slice(b"Hello\x1B[1;1H").unwrap(); + assert_eq!(0, s.handler.terminal.screen().cursor.x); + assert_eq!(0, s.handler.terminal.screen().cursor.y); + + // Move to position 2,3 + s.next_slice(b"\x1B[2;3H").unwrap(); + assert_eq!(2, s.handler.terminal.screen().cursor.x); + assert_eq!(1, s.handler.terminal.screen().cursor.y); + } + + #[test] + fn erase_operations() { + let mut t = term(20, 10); + let mut s = t.vt_stream(); + + // Print some text + s.next_slice(b"Hello World").unwrap(); + assert_eq!(11, s.handler.terminal.screen().cursor.x); + assert_eq!(0, s.handler.terminal.screen().cursor.y); + + // Move cursor to position 1,6 and erase from cursor to end of line + s.next_slice(b"\x1B[1;6H").unwrap(); + s.next_slice(b"\x1B[K").unwrap(); + + assert_eq!("Hello", s.handler.terminal.plain_string().unwrap()); + } + + #[test] + fn tabs() { + let mut t = term(80, 10); + let mut s = t.vt_stream(); + + s.next_slice(b"A\tB").unwrap(); + assert_eq!(9, s.handler.terminal.screen().cursor.x); + assert_eq!("A B", s.handler.terminal.plain_string().unwrap()); + } + + #[test] + fn modes() { + let mut t = term(80, 24); + let mut s = t.vt_stream(); + + // Test wraparound mode + assert!(s.handler.terminal.modes.get(Mode::Wraparound)); + s.next_slice(b"\x1B[?7l").unwrap(); // Disable wraparound + assert!(!s.handler.terminal.modes.get(Mode::Wraparound)); + s.next_slice(b"\x1B[?7h").unwrap(); // Enable wraparound + assert!(s.handler.terminal.modes.get(Mode::Wraparound)); + } + + #[test] + fn scrolling_regions() { + let mut t = term(80, 24); + let mut s = t.vt_stream(); + + // Set scrolling region from line 5 to 20 + s.next_slice(b"\x1B[5;20r").unwrap(); + let sr = s.handler.terminal.scrolling_region; + assert_eq!(4, sr.top); + assert_eq!(19, sr.bottom); + assert_eq!(0, sr.left); + assert_eq!(79, sr.right); + } + + #[test] + fn charsets() { + let mut t = term(80, 24); + let mut s = t.vt_stream(); + + // Configure G0 as DEC special graphics + s.next_slice(b"\x1B(0").unwrap(); + s.next_slice(b"`").unwrap(); // Should print diamond character + + assert_eq!("\u{25c6}", s.handler.terminal.plain_string().unwrap()); + } + + #[test] + fn alt_screen() { + let mut t = term(10, 5); + let mut s = t.vt_stream(); + + // Write to primary screen + s.next_slice(b"Primary").unwrap(); + assert_eq!(ScreenKey::Primary, s.handler.terminal.screens.active_key); + + // Switch to alt screen + s.next_slice(b"\x1B[?1049h").unwrap(); + assert_eq!(ScreenKey::Alternate, s.handler.terminal.screens.active_key); + + // Write to alt screen + s.next_slice(b"Alt").unwrap(); + + // Switch back to primary + s.next_slice(b"\x1B[?1049l").unwrap(); + assert_eq!(ScreenKey::Primary, s.handler.terminal.screens.active_key); + + assert_eq!("Primary", s.handler.terminal.plain_string().unwrap()); + } + + #[test] + fn cursor_save_and_restore() { + let mut t = term(80, 24); + let mut s = t.vt_stream(); + + // Move cursor to 10,15 + s.next_slice(b"\x1B[10;15H").unwrap(); + assert_eq!(14, s.handler.terminal.screen().cursor.x); + assert_eq!(9, s.handler.terminal.screen().cursor.y); + + // Save cursor + s.next_slice(b"\x1B7").unwrap(); + + // Move cursor elsewhere + s.next_slice(b"\x1B[1;1H").unwrap(); + assert_eq!(0, s.handler.terminal.screen().cursor.x); + assert_eq!(0, s.handler.terminal.screen().cursor.y); + + // Restore cursor + s.next_slice(b"\x1B8").unwrap(); + assert_eq!(14, s.handler.terminal.screen().cursor.x); + assert_eq!(9, s.handler.terminal.screen().cursor.y); + } + + #[test] + fn attributes() { + let mut t = term(80, 24); + let mut s = t.vt_stream(); + + // Set bold and write text + s.next_slice(b"\x1B[1mBold\x1B[0m").unwrap(); + + // Verify we can write attributes - just check the string was written + assert_eq!("Bold", s.handler.terminal.plain_string().unwrap()); + } + + #[test] + fn decaln_screen_alignment() { + let mut t = term(10, 3); + let mut s = t.vt_stream(); + + // Run DECALN + s.next_slice(b"\x1B#8").unwrap(); + + // Verify entire screen is filled with 'E' + assert_eq!( + "EEEEEEEEEE\nEEEEEEEEEE\nEEEEEEEEEE", + s.handler.terminal.plain_string().unwrap() + ); + + // Cursor should be at 1,1 + assert_eq!(0, s.handler.terminal.screen().cursor.x); + assert_eq!(0, s.handler.terminal.screen().cursor.y); + } + + #[test] + fn full_reset() { + let mut t = term(80, 24); + let mut s = t.vt_stream(); + + // Make some changes + s.next_slice(b"Hello").unwrap(); + s.next_slice(b"\x1B[10;20H").unwrap(); + s.next_slice(b"\x1B[5;20r").unwrap(); // Set scroll region + s.next_slice(b"\x1B[?7l").unwrap(); // Disable wraparound + + // Full reset + s.next_slice(b"\x1Bc").unwrap(); + + // Verify reset state + let t = &*s.handler.terminal; + assert_eq!(0, t.screen().cursor.x); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(0, t.scrolling_region.top); + assert_eq!(23, t.scrolling_region.bottom); + assert!(t.modes.get(Mode::Wraparound)); + } + + #[test] + fn ignores_query_actions() { + let mut t = term(80, 24); + let mut s = t.vt_stream(); + + // These should be ignored without error + s.next_slice(b"\x1B[c").unwrap(); // Device attributes + s.next_slice(b"\x1B[5n").unwrap(); // Device status report + s.next_slice(b"\x1B[6n").unwrap(); // Cursor position report + + // Terminal should still be functional + s.next_slice(b"Test").unwrap(); + assert_eq!("Test", s.handler.terminal.plain_string().unwrap()); + } + + #[test] + fn osc_4_set_and_reset_palette() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Save default color + let default_color_0 = s.handler.terminal.colors.palette.original[0]; + + // Set color 0 to red + s.next_slice(b"\x1b]4;0;rgb:ff/00/00\x1b\\").unwrap(); + let p = &s.handler.terminal.colors.palette; + assert_eq!(0xff, p.current[0].r); + assert_eq!(0x00, p.current[0].g); + assert_eq!(0x00, p.current[0].b); + assert!(p.mask.is_set(0)); + + // Reset color 0 + s.next_slice(b"\x1b]104;0\x1b\\").unwrap(); + let p = &s.handler.terminal.colors.palette; + assert_eq!(default_color_0, p.current[0]); + assert!(!p.mask.is_set(0)); + } + + #[test] + fn osc_104_reset_all_palette_colors() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set multiple colors + s.next_slice(b"\x1b]4;0;rgb:ff/00/00\x1b\\").unwrap(); + s.next_slice(b"\x1b]4;1;rgb:00/ff/00\x1b\\").unwrap(); + s.next_slice(b"\x1b]4;2;rgb:00/00/ff\x1b\\").unwrap(); + let p = &s.handler.terminal.colors.palette; + assert!(p.mask.is_set(0)); + assert!(p.mask.is_set(1)); + assert!(p.mask.is_set(2)); + + // Reset all palette colors + s.next_slice(b"\x1b]104\x1b\\").unwrap(); + let p = &s.handler.terminal.colors.palette; + assert_eq!(p.original[0], p.current[0]); + assert_eq!(p.original[1], p.current[1]); + assert_eq!(p.original[2], p.current[2]); + assert!(!p.mask.is_set(0)); + assert!(!p.mask.is_set(1)); + assert!(!p.mask.is_set(2)); + } + + #[test] + fn osc_10_set_and_reset_foreground_color() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Initially unset + assert!(s.handler.terminal.colors.foreground.get().is_none()); + + // Set foreground to red + s.next_slice(b"\x1b]10;rgb:ff/00/00\x1b\\").unwrap(); + let fg = s.handler.terminal.colors.foreground.get().unwrap(); + assert_eq!(0xff, fg.r); + assert_eq!(0x00, fg.g); + assert_eq!(0x00, fg.b); + + // Reset foreground + s.next_slice(b"\x1b]110\x1b\\").unwrap(); + assert!(s.handler.terminal.colors.foreground.get().is_none()); + } + + #[test] + fn osc_11_set_and_reset_background_color() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set background to green + s.next_slice(b"\x1b]11;rgb:00/ff/00\x1b\\").unwrap(); + let bg = s.handler.terminal.colors.background.get().unwrap(); + assert_eq!(0x00, bg.r); + assert_eq!(0xff, bg.g); + assert_eq!(0x00, bg.b); + + // Reset background + s.next_slice(b"\x1b]111\x1b\\").unwrap(); + assert!(s.handler.terminal.colors.background.get().is_none()); + } + + #[test] + fn osc_12_set_and_reset_cursor_color() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set cursor to blue + s.next_slice(b"\x1b]12;rgb:00/00/ff\x1b\\").unwrap(); + let cursor = s.handler.terminal.colors.cursor.get().unwrap(); + assert_eq!(0x00, cursor.r); + assert_eq!(0x00, cursor.g); + assert_eq!(0xff, cursor.b); + + // Reset cursor + s.next_slice(b"\x1b]112\x1b\\").unwrap(); + // After reset, cursor might be null (using default) + } + + #[test] + fn kitty_color_protocol_set_palette() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set palette color 5 to magenta using kitty protocol + s.next_slice(b"\x1b]21;5=rgb:ff/00/ff\x1b\\").unwrap(); + let t = &*s.handler.terminal; + assert_eq!(0xff, t.colors.palette.current[5].r); + assert_eq!(0x00, t.colors.palette.current[5].g); + assert_eq!(0xff, t.colors.palette.current[5].b); + assert!(t.colors.palette.mask.is_set(5)); + assert!(t.flags.dirty.palette); + } + + #[test] + fn kitty_color_protocol_reset_palette() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set and then reset palette color + let original = s.handler.terminal.colors.palette.original[7]; + s.next_slice(b"\x1b]21;7=rgb:aa/bb/cc\x1b\\").unwrap(); + assert!(s.handler.terminal.colors.palette.mask.is_set(7)); + + s.next_slice(b"\x1b]21;7=\x1b\\").unwrap(); + let p = &s.handler.terminal.colors.palette; + assert_eq!(original, p.current[7]); + assert!(!p.mask.is_set(7)); + } + + #[test] + fn kitty_color_protocol_set_foreground() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set foreground using kitty protocol + s.next_slice(b"\x1b]21;foreground=rgb:12/34/56\x1b\\") + .unwrap(); + let fg = s.handler.terminal.colors.foreground.get().unwrap(); + assert_eq!(0x12, fg.r); + assert_eq!(0x34, fg.g); + assert_eq!(0x56, fg.b); + } + + #[test] + fn kitty_color_protocol_set_background() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set background using kitty protocol + s.next_slice(b"\x1b]21;background=rgb:78/9a/bc\x1b\\") + .unwrap(); + let bg = s.handler.terminal.colors.background.get().unwrap(); + assert_eq!(0x78, bg.r); + assert_eq!(0x9a, bg.g); + assert_eq!(0xbc, bg.b); + } + + #[test] + fn kitty_color_protocol_set_cursor() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set cursor using kitty protocol + s.next_slice(b"\x1b]21;cursor=rgb:de/f0/12\x1b\\").unwrap(); + let cursor = s.handler.terminal.colors.cursor.get().unwrap(); + assert_eq!(0xde, cursor.r); + assert_eq!(0xf0, cursor.g); + assert_eq!(0x12, cursor.b); + } + + #[test] + fn kitty_color_protocol_reset_foreground() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set and reset foreground + s.next_slice(b"\x1b]21;foreground=rgb:11/22/33\x1b\\") + .unwrap(); + assert!(s.handler.terminal.colors.foreground.get().is_some()); + + s.next_slice(b"\x1b]21;foreground=\x1b\\").unwrap(); + // After reset, should be unset + assert!(s.handler.terminal.colors.foreground.get().is_none()); + } + + #[test] + fn palette_dirty_flag_set_on_color_change() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Clear dirty flag + s.handler.terminal.flags.dirty.palette = false; + + // Setting palette color should set dirty flag + s.next_slice(b"\x1b]4;0;rgb:ff/00/00\x1b\\").unwrap(); + assert!(s.handler.terminal.flags.dirty.palette); + + // Clear and test reset + s.handler.terminal.flags.dirty.palette = false; + s.next_slice(b"\x1b]104;0\x1b\\").unwrap(); + assert!(s.handler.terminal.flags.dirty.palette); + + // Clear and test kitty protocol + s.handler.terminal.flags.dirty.palette = false; + s.next_slice(b"\x1b]21;1=rgb:00/ff/00\x1b\\").unwrap(); + assert!(s.handler.terminal.flags.dirty.palette); + } + + #[test] + fn semantic_prompt_fresh_line() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + s.next_slice(b"Hello").unwrap(); + s.next_slice(b"\x1b]133;L\x07").unwrap(); + assert_eq!(0, s.handler.terminal.screen().cursor.x); + assert_eq!(1, s.handler.terminal.screen().cursor.y); + } + + #[test] + fn semantic_prompt_fresh_line_new_prompt() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + s.next_slice(b"Hello").unwrap(); + s.next_slice(b"\x1b]133;A\x07").unwrap(); + + // Should do a fresh line (carriage return + index) + assert_eq!(0, s.handler.terminal.screen().cursor.x); + assert_eq!(1, s.handler.terminal.screen().cursor.y); + + // Should set cursor semantic_content to prompt + assert_eq!( + SemanticContent::Prompt, + s.handler.terminal.screen().cursor.semantic_content + ); + + // Test with redraw option + s.next_slice(b"prompt$ ").unwrap(); + s.next_slice(b"\x1b]133;A;redraw=1\x07").unwrap(); + assert_eq!(Redraw::True, s.handler.terminal.flags.shell_redraws_prompt); + } + + #[test] + fn semantic_prompt_end_of_input_then_start_output() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + s.next_slice(b"Hello").unwrap(); + s.next_slice(b"\x1b]133;A\x07").unwrap(); + s.next_slice(b"prompt$ ").unwrap(); + s.next_slice(b"\x1b]133;B\x07").unwrap(); + assert_eq!( + SemanticContent::Input, + s.handler.terminal.screen().cursor.semantic_content + ); + s.next_slice(b"\x1b]133;C\x07").unwrap(); + assert_eq!( + SemanticContent::Output, + s.handler.terminal.screen().cursor.semantic_content + ); + } + + #[test] + fn semantic_prompt_prompt_start() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Write some text + s.next_slice(b"Hello").unwrap(); + + // OSC 133;P marks the start of a prompt (without fresh line behavior) + s.next_slice(b"\x1b]133;P\x07").unwrap(); + let scr = s.handler.terminal.screen(); + assert_eq!(SemanticContent::Prompt, scr.cursor.semantic_content); + assert_eq!(5, scr.cursor.x); + assert_eq!(0, scr.cursor.y); + } + + #[test] + fn semantic_prompt_new_command() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Write some text + s.next_slice(b"Hello").unwrap(); + s.next_slice(b"\x1b]133;N\x07").unwrap(); + + // Should behave like fresh_line_new_prompt - cursor moves to column 0 + // on next line since we had content + let scr = s.handler.terminal.screen(); + assert_eq!(0, scr.cursor.x); + assert_eq!(1, scr.cursor.y); + assert_eq!(SemanticContent::Prompt, scr.cursor.semantic_content); + } + + #[test] + fn semantic_prompt_new_command_at_column_zero() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // OSC 133;N when already at column 0 should stay on same line + s.next_slice(b"\x1b]133;N\x07").unwrap(); + let scr = s.handler.terminal.screen(); + assert_eq!(0, scr.cursor.x); + assert_eq!(0, scr.cursor.y); + assert_eq!(SemanticContent::Prompt, scr.cursor.semantic_content); + } + + #[test] + fn semantic_prompt_end_prompt_start_input_terminate_eol_clears_on_linefeed() { + let mut t = term(10, 10); + let mut s = t.vt_stream(); + + // Set input terminated by EOL + s.next_slice(b"\x1b]133;I\x07").unwrap(); + assert_eq!( + SemanticContent::Input, + s.handler.terminal.screen().cursor.semantic_content + ); + + // Linefeed should reset semantic content to output + s.next_slice(b"\n").unwrap(); + assert_eq!( + SemanticContent::Output, + s.handler.terminal.screen().cursor.semantic_content + ); + } + + #[test] + fn stream_csi_w_with_intermediate_but_no_params() { + // Regression test from AFL++ crash. CSI ? W without + // parameters caused an out-of-bounds access on input.params[0]. + let mut o = Options::new(80, 24); + o.max_scrollback = 100; + let mut t = Terminal::new(o).unwrap(); + let mut s = t.vt_stream(); + + s.next_slice(b"\x1b[?W").unwrap(); + } +} diff --git a/crates/ghostty-terminal/src/string_map.rs b/crates/ghostty-terminal/src/string_map.rs new file mode 100644 index 0000000..5f5c307 --- /dev/null +++ b/crates/ghostty-terminal/src/string_map.rs @@ -0,0 +1,231 @@ +//! A string along with the mapping of each individual byte in the string +//! to the point in the screen. +//! +//! Port of `ZIG/src/terminal/StringMap.zig`. Regex matching uses Oniguruma +//! (the `onig` crate), like Ghostty. +use crate::{page_list::Pin, selection::Selection}; +use onig::{MatchParam, Regex, Region, SearchOptions}; + +/// Retry budget for StringMap regex searches. +/// +/// Units are Oniguruma retry steps (internal backtracking/retry counter), +/// not bytes/characters/time. +/// +/// Deviation: the `onig` crate only exposes the retry limit *in match* +/// (Zig also sets the retry limit in search); both abort matching with an +/// error which is treated as "no further matches". +const ONI_SEARCH_RETRY_LIMIT: u32 = 100_000; + +/// A string and a map from each byte of the string to the pin it came from. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StringMap { + pub string: String, + pub map: Vec, +} + +impl StringMap { + /// Returns an iterator that yields the next match of the given regex. + pub fn search_iterator<'a>(&'a self, regex: &'a Regex) -> SearchIterator<'a> { + SearchIterator { + map: self, + regex, + offset: 0, + } + } +} + +/// Iterates over the regular expression matches of the string. +pub struct SearchIterator<'a> { + map: &'a StringMap, + regex: &'a Regex, + offset: usize, +} + +impl<'a> SearchIterator<'a> { + /// Returns the next regular expression match or None if there are + /// no more matches. + pub fn next_match(&mut self) -> Option> { + if self.offset >= self.map.string.len() { + return None; + } + + // Use per-search match params so we can bound regex retry steps + // (Oniguruma's internal backtracking work counter). + let mut match_param = MatchParam::default(); + match_param.set_retry_limit_in_match(ONI_SEARCH_RETRY_LIMIT); + + let haystack = &self.map.string[self.offset..]; + let mut region = Region::new(); + let result = self.regex.search_with_param( + haystack, + 0, + haystack.len(), + SearchOptions::SEARCH_OPTION_NONE, + Some(&mut region), + match_param, + ); + + // Mismatch and retry/stack-limit errors mean no further matches. + let Ok(Some(_)) = result else { + self.offset = self.map.string.len(); + return None; + }; + let (start, end) = region.pos(0)?; + + // Increment our offset by the number of bytes in the match. + // We do this after building the match so that it is returned + // with the old offset. + let m = Match { + map: self.map, + offset: self.offset, + start, + end, + }; + self.offset += end; + Some(m) + } +} + +impl<'a> Iterator for SearchIterator<'a> { + type Item = Match<'a>; + + fn next(&mut self) -> Option> { + self.next_match() + } +} + +/// A single regular expression match. +#[derive(Debug, Clone, Copy)] +pub struct Match<'a> { + map: &'a StringMap, + offset: usize, + /// Byte range of the match relative to `offset`. + start: usize, + end: usize, +} + +impl Match<'_> { + /// Returns the selection containing the full match. + pub fn selection(&self) -> Selection { + let start_pt = self.map.map[self.offset + self.start]; + let end_pt = self.map.map[self.offset + self.end - 1]; + Selection::new(start_pt, end_pt, false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + point::{Coordinate, Point, Tag}, + screen::{Options, Screen, SelectLine, SelectionString}, + }; + + fn active(x: u16, y: u32) -> Point { + Point::Active(Coordinate { x, y }) + } + + fn screen_pt(s: &Screen, pin: Pin) -> Point { + s.pages.point_from_pin(Tag::Screen, pin).unwrap() + } + + fn line_map(s: &Screen, x: u16, y: u32) -> StringMap { + let pin = s.pages.pin(active(x, y)).unwrap(); + let line = s.select_line(SelectLine::new(pin)).unwrap(); + s.selection_string_map(SelectionString { + sel: line, + trim: false, + }) + } + + #[test] + fn string_map_search_iterator() { + // Initialize our regex + let re = Regex::new("[A-B]{2}").unwrap(); + + // Initialize our screen + let mut s = Screen::new(Options::new(5, 5, 0)).unwrap(); + s.test_write_string("1ABCD2EFGH\n3IJKL").unwrap(); + let map = line_map(&s, 2, 1); + + // Get our iterator + let mut it = map.search_iterator(&re); + { + let m = it.next_match().unwrap(); + let sel = m.selection(); + assert_eq!( + Point::Screen(Coordinate { x: 1, y: 0 }), + screen_pt(&s, sel.start(&s.pages)) + ); + assert_eq!( + Point::Screen(Coordinate { x: 2, y: 0 }), + screen_pt(&s, sel.end(&s.pages)) + ); + } + + assert!(it.next_match().is_none()); + } + + #[test] + fn string_map_search_iterator_url_detection() { + let re = Regex::new(ghostty_config::url::REGEX).unwrap(); + + let mut s = Screen::new(Options::new(40, 5, 0)).unwrap(); + s.test_write_string("hello https://example.com/path world") + .unwrap(); + + let map = line_map(&s, 10, 0); + + let mut it = map.search_iterator(&re); + { + let m = it.next_match().unwrap(); + let sel = m.selection(); + // URL should start at x=6 ("https://example.com/path" starts after "hello ") + assert_eq!( + Point::Screen(Coordinate { x: 6, y: 0 }), + screen_pt(&s, sel.start(&s.pages)) + ); + // URL should end at x=29 (end of "/path") + assert_eq!( + Point::Screen(Coordinate { x: 29, y: 0 }), + screen_pt(&s, sel.end(&s.pages)) + ); + } + + assert!(it.next_match().is_none()); + } + + #[test] + fn string_map_search_iterator_url_with_click_position() { + let re = Regex::new(ghostty_config::url::REGEX).unwrap(); + + let mut s = Screen::new(Options::new(40, 5, 0)).unwrap(); + s.test_write_string("hello https://example.com world") + .unwrap(); + + // Simulate clicking on "example" (x=14) + let click_pin = s.pages.pin(active(14, 0)).unwrap(); + + let map = line_map(&s, 14, 0); + + let mut it = map.search_iterator(&re); + let mut found_url = false; + while let Some(m) = it.next_match() { + let sel = m.selection(); + if sel.contains(&s.pages, click_pin) { + found_url = true; + // Verify URL bounds + assert_eq!( + Point::Screen(Coordinate { x: 6, y: 0 }), + screen_pt(&s, sel.start(&s.pages)) + ); + assert_eq!( + Point::Screen(Coordinate { x: 24, y: 0 }), + screen_pt(&s, sel.end(&s.pages)) + ); + break; + } + } + assert!(found_url); + } +} diff --git a/crates/ghostty-terminal/src/terminal.rs b/crates/ghostty-terminal/src/terminal.rs new file mode 100644 index 0000000..118c18e --- /dev/null +++ b/crates/ghostty-terminal/src/terminal.rs @@ -0,0 +1,3271 @@ +//! The primary terminal emulation structure. This represents a single +//! "terminal" containing a grid of characters and exposes various operations +//! on that grid. This also maintains the scrollback buffer. +//! +//! Port of `ZIG/src/terminal/Terminal.zig`. +//! +//! # Differences from the Zig API +//! +//! * `screens.active` is `screens.active()` / `screens.active_mut()`. +//! * Allocators are dropped; fallible operations return +//! [`Result<_, Error>`](Error) (`page::Error`). +//! * `switch_screen` returns the *key* of the previous screen instead of a +//! pointer to it (`self.screens.get(old)` gives the screen back). +//! * `print_attributes` returns a `String` instead of writing into a buffer. +#![allow( + clippy::manual_assert_eq, + clippy::single_match_else, + clippy::items_after_statements, + clippy::too_many_lines +)] +use crate::{ + page::{Cell, ContentTag, Page, SemanticContent, SemanticPrompt as RowPrompt, Wide}, + page_list::{Direction, IncreaseCapacity, Pin}, + pin_iter, + point::{Coordinate, Point}, + screen::{ + CharsetState, InputClear, Options as ScreenOptions, Resize as ScreenResize, SavedCursor, + Screen, Scroll, SemanticClick, SemanticContentSet as SetSemantic, + }, + screen_set::{Key as ScreenKey, ScreenSet}, + size, + style::{self, Color as StyleColor, Style}, + tabstops::Tabstops, +}; +use ghostty_vt::{ + ansi::{ProtectedMode, StatusDisplay}, + charsets::{self, Charset, Slots}, + color::{DynamicPalette, DynamicRgb}, + csi, + kitty::graphics_command as kitty_graphics_command, + modes::{Mode, ModePacked, ModeState}, + mouse_shape::MouseShape, + osc::{ + self, + semantic_prompt::{Action as PromptAction, Redraw}, + }, + sgr, +}; + +pub use crate::page::Error; + +/// Default tabstop interval +const TABSTOP_INTERVAL: usize = 8; + +/// The unicode codepoint for kitty graphics placeholders. +const KITTY_PLACEHOLDER: u32 = 0x10EEEE; + +/// The set of screens behind this terminal (e.g. primary vs alternate). +#[derive(Debug)] +pub struct Terminal { + /// The set of screens behind this terminal (e.g. primary vs alternate). + pub screens: ScreenSet, + + /// Whether we're currently writing to the status line (DECSASD and DECSSDT). + /// We don't support a status line currently so we just black hole this + /// data so that it doesn't mess up our main display. + pub status_display: StatusDisplay, + + /// Where the tabstops are. + pub tabstops: Tabstops, + + /// The size of the terminal. + pub rows: size::CellCountInt, + pub cols: size::CellCountInt, + + /// The size of the screen in pixels. This is used for pty events and images + pub width_px: u32, + pub height_px: u32, + + /// The current scrolling region. + pub scrolling_region: ScrollingRegion, + + /// The last reported pwd, if any. + pub pwd: Vec, + + /// The color state for this terminal. + pub colors: Colors, + + /// The previous printed character. This is used for the repeat previous + /// char CSI (ESC [ b). + pub previous_char: Option, + + /// The modes that this terminal currently has active. + pub modes: ModeState, + + /// The most recently set mouse shape for the terminal. + pub mouse_shape: MouseShape, + + /// These are just a packed set of flags we may set on the terminal. + pub flags: Flags, +} + +/// The `flags` struct of the terminal (a packed struct in Zig). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Flags { + /// This supports a Kitty extension where programs using semantic + /// prompts (OSC133) can annotate their new prompts with `redraw=0` to + /// disable clearing the prompt on resize. + pub shell_redraws_prompt: Redraw, + + /// This is set via ESC[4;2m. Any other modify key mode just sets + /// this to false and we act in mode 1 by default. + pub modify_other_keys_2: bool, + + /// The mouse event mode and format. These are set to the last + /// set mode in modes. You can't get the right event/format to use + /// based on modes alone because modes don't show you what order + /// this was called so we have to track it separately. + pub mouse_event: MouseEvents, + pub mouse_format: MouseFormat, + + /// Set via the XTSHIFTESCAPE sequence. If true (XTSHIFTESCAPE = 1) + /// then we want to capture the shift key for the mouse protocol + /// if the configuration allows it. + pub mouse_shift_capture: MouseShiftCapture, + + /// True if the window is focused. + pub focused: bool, + + /// True if the terminal is in a password entry mode. This is set + /// to true based on termios state. + pub password_input: bool, + + /// True if the terminal should perform selection scrolling. + pub selection_scroll: bool, + + /// Dirty flag used only by the search thread. The renderer is expected + /// to set this to true if the viewport was dirty as it was rendering. + /// This is used by the search thread to more efficiently re-search the + /// viewport and active area. + /// + /// Since the renderer is going to inspect the viewport/active area ANYWAYS, + /// this lets our search thread do less work and hold the lock less time, + /// resulting in more throughput for everything. + pub search_viewport_dirty: bool, + + /// Dirty flags for the renderer. + pub dirty: Dirty, +} + +impl Default for Flags { + fn default() -> Self { + Self { + shell_redraws_prompt: Redraw::True, + modify_other_keys_2: false, + mouse_event: MouseEvents::None, + mouse_format: MouseFormat::X10, + mouse_shift_capture: MouseShiftCapture::Null, + focused: true, + password_input: false, + selection_scroll: false, + search_viewport_dirty: false, + dirty: Dirty::default(), + } + } +} + +/// `mouse_shift_capture` tri-state (Zig `enum(u2) { null, false, true }`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MouseShiftCapture { + #[default] + Null, + False, + True, +} + +/// The various color configurations a terminal maintains and that can +/// be set dynamically via OSC, with defaults usually coming from a +/// configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Colors { + pub background: DynamicRgb, + pub foreground: DynamicRgb, + pub cursor: DynamicRgb, + pub palette: DynamicPalette, +} + +impl Colors { + pub const DEFAULT: Colors = Colors { + background: DynamicRgb::UNSET, + foreground: DynamicRgb::UNSET, + cursor: DynamicRgb::UNSET, + palette: DynamicPalette::DEFAULT, + }; +} + +impl Default for Colors { + fn default() -> Self { + Self::DEFAULT + } +} + +/// This is a set of dirty flags the renderer can use to determine +/// what parts of the screen need to be redrawn. It is up to the renderer +/// to clear these flags. +/// +/// This only contains dirty flags for terminal state, not for the screen +/// state. The screen state has its own dirty flags. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Dirty { + /// Set when the color palette is modified in any way. + pub palette: bool, + + /// Set when the reverse colors mode is modified. + pub reverse_colors: bool, + + /// Screen clear of some kind. This can be due to a screen change, + /// erase display, etc. + pub clear: bool, + + /// Set when the pre-edit is modified. + pub preedit: bool, +} + +/// The event types that can be reported for mouse-related activities. +/// These are all mutually exclusive (hence in a single enum). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MouseEvents { + #[default] + None = 0, + X10 = 1, // 9 + Normal = 2, // 1000 + Button = 3, // 1002 + Any = 4, // 1003 +} + +impl MouseEvents { + /// Returns true if this event sends motion events. + pub fn motion(self) -> bool { + self == MouseEvents::Button || self == MouseEvents::Any + } +} + +/// The format of mouse events when enabled. +/// These are all mutually exclusive (hence in a single enum). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MouseFormat { + #[default] + X10 = 0, + Utf8 = 1, // 1005 + Sgr = 2, // 1006 + Urxvt = 3, // 1015 + SgrPixels = 4, // 1016 +} + +/// Scrolling region is the area of the screen designated where scrolling +/// occurs. When scrolling the screen, only this viewport is scrolled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollingRegion { + // Top and bottom of the scroll region (0-indexed) + // Precondition: top < bottom + pub top: size::CellCountInt, + pub bottom: size::CellCountInt, + + // Left/right scroll regions. + // Precondition: right > left + // Precondition: right <= cols - 1 + pub left: size::CellCountInt, + pub right: size::CellCountInt, +} + +#[derive(Debug, Clone, Copy)] +pub struct Options { + pub cols: size::CellCountInt, + pub rows: size::CellCountInt, + pub max_scrollback: usize, + pub colors: Colors, + + /// The default mode state. When the terminal gets a reset, it + /// will revert back to this state. + pub default_modes: ModePacked, +} + +impl Options { + pub fn new(cols: size::CellCountInt, rows: size::CellCountInt) -> Self { + Self { + cols, + rows, + max_scrollback: 10_000, + colors: Colors::DEFAULT, + default_modes: ModePacked::default(), + } + } +} + +/// The semantic prompt type. This is used when tracking a line type and +/// requires integration with the shell. By default, we mark a line as "none" +/// meaning we don't know what type it is. +/// +/// See: +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SemanticPrompt { + Prompt, + PromptContinuation, + Input, + Command, +} + +/// Options for scrolling the viewport of the terminal grid. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollViewport { + /// Scroll to the top of the scrollback + Top, + + /// Scroll to the bottom, i.e. the top of the active area + Bottom, + + /// Scroll by some delta amount, up is negative. + Delta(isize), +} + +/// The modes for DECCOLM. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeccolmMode { + Cols80 = 0, + Cols132 = 1, +} + +/// Modal screen changes. These map to the literal terminal +/// modes to enable or disable alternate screen modes. They each +/// have subtle behaviors so we define them as an enum here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SwitchScreenMode { + /// Legacy alternate screen mode. This goes to the alternate + /// screen or primary screen and only copies the cursor. The + /// screen is not erased. + M47, + + /// Alternate screen mode where the alternate screen is cleared + /// on exit. The primary screen is never cleared. The cursor is + /// copied. + M1047, + + /// Save primary screen cursor, switch to alternate screen, + /// and clear the alternate screen on entry. On exit, + /// do not clear the screen, and restore the cursor on the + /// primary screen. + M1049, +} + +fn map_tabstops_err(_: std::collections::TryReserveError) -> Error { + Error::OutOfMemory +} + +/// The adjustment needed to retry a failed partial row clone (insert/delete +/// lines cross-page slow path). +#[allow(clippy::option_option)] +fn capacity_adjustment(err: Error) -> Option> { + Some(match err { + // Rehash the sets + Error::StyleSetNeedsRehash | Error::SetNeedsRehash => None, + // Increase style memory + Error::StyleSetOutOfMemory => Some(IncreaseCapacity::Styles), + // Increase string memory + Error::StringsOutOfMemory => Some(IncreaseCapacity::StringBytes), + // Increase hyperlink memory + Error::SetOutOfMemory | Error::HyperlinkMapOutOfMemory => { + Some(IncreaseCapacity::HyperlinkBytes) + } + // Increase grapheme memory + Error::GraphemeMapOutOfMemory | Error::GraphemeAllocOutOfMemory => { + Some(IncreaseCapacity::GraphemeBytes) + } + _ => return None, + }) +} + +/// To be called before shifting a row (as in insert_lines and delete_lines) +/// +/// Takes care of boundary conditions such as potentially split wide chars +/// across scrolling region boundaries and orphaned spacer heads at line +/// ends. +fn row_will_be_shifted(sr: ScrollingRegion, cols: size::CellCountInt, page: &mut Page, y: usize) { + let base = page.rows[y].cells(); + + // If our scrolling region includes the rightmost column then we + // need to turn any spacer heads in to normal empty cells, since + // once we move them they no longer correspond with soft-wrapped + // wide characters. + // + // If it contains either of the 2 leftmost columns, then the wide + // characters in the first column which may be associated with a + // spacer head will be either moved or cleared, so we also need + // to turn the spacer heads in to empty cells in that case. + if sr.right == cols - 1 || sr.left < 2 { + let end = base + usize::from(page.size.cols) - 1; + if page.cells[end].wide() == Wide::SpacerHead { + page.cells[end].set_wide(Wide::Narrow); + } + } + + // If the leftmost or rightmost cells of our scrolling region + // are parts of wide chars, we need to clear the cells' contents + // since they'd be split by the move. + let left = base + usize::from(sr.left); + let right = base + usize::from(sr.right); + + if page.cells[left].wide() == Wide::SpacerTail { + let wide = left - 1; + if page.cells[wide].has_grapheme() { + page.clear_grapheme(wide); + page.update_row_grapheme_flag(y); + } + page.cells[wide].set_codepoint(0); + page.cells[wide].set_wide(Wide::Narrow); + page.cells[left].set_wide(Wide::Narrow); + } + + if page.cells[right].wide() == Wide::Wide { + let tail = right + 1; + if page.cells[right].has_grapheme() { + page.clear_grapheme(right); + page.update_row_grapheme_flag(y); + } + page.cells[right].set_codepoint(0); + page.cells[right].set_wide(Wide::Narrow); + page.cells[tail].set_wide(Wide::Narrow); + } +} + +impl Terminal { + /// Initialize a new terminal. + pub fn new(opts: Options) -> Result { + let cols = opts.cols; + let rows = opts.rows; + + let screens = ScreenSet::new(ScreenOptions::new(cols, rows, opts.max_scrollback))?; + + Ok(Terminal { + cols, + rows, + screens, + status_display: StatusDisplay::Main, + tabstops: Tabstops::new(usize::from(cols), TABSTOP_INTERVAL) + .map_err(map_tabstops_err)?, + width_px: 0, + height_px: 0, + scrolling_region: ScrollingRegion { + top: 0, + bottom: rows - 1, + left: 0, + right: cols - 1, + }, + pwd: Vec::new(), + colors: opts.colors, + previous_char: None, + modes: ModeState { + values: opts.default_modes, + saved: ModePacked::default(), + default: opts.default_modes, + }, + mouse_shape: MouseShape::Text, + flags: Flags::default(), + }) + } + + /// Return a stream that can process VT streams and update this + /// terminal state. The streams will only process read-only data that + /// modifies terminal state. Sequences that query or otherwise require + /// output will be ignored. + pub fn vt_stream(&mut self) -> crate::stream_readonly::Stream<'_> { + crate::stream_readonly::Stream::new_alloc(self.vt_handler()) + } + + /// This is the handler-side only for vt_stream. + pub fn vt_handler(&mut self) -> crate::stream_readonly::Handler<'_> { + crate::stream_readonly::Handler::new(self) + } + + /// Shorthand for `self.screens.active()`. + #[inline] + pub fn screen(&self) -> &Screen { + self.screens.active() + } + + /// Shorthand for `self.screens.active_mut()`. + #[inline] + pub fn screen_mut(&mut self) -> &mut Screen { + self.screens.active_mut() + } + + /// Print UTF-8 encoded string to the terminal. + pub fn print_string(&mut self, s: &str) -> Result<(), Error> { + for ch in s.chars() { + match ch { + '\n' => { + self.carriage_return(); + self.linefeed()?; + } + + _ => self.print(u32::from(ch))?, + } + } + Ok(()) + } + + /// Print the previous printed character a repeated amount of times. + pub fn print_repeat(&mut self, count_req: usize) -> Result<(), Error> { + if let Some(c) = self.previous_char { + let count = count_req.max(1); + for _ in 0..count { + self.print(c)?; + } + } + Ok(()) + } + + pub fn print(&mut self, c: u32) -> Result<(), Error> { + // If we're not on the main display, do nothing for now + if self.status_display != StatusDisplay::Main { + return Ok(()); + } + + // After doing any printing, wrapping, scrolling, etc. we want to ensure + // that our screen remains in a consistent state. + let result = self.print_inner(c); + self.screen().assert_integrity(); + result + } + + fn print_inner(&mut self, c: u32) -> Result<(), Error> { + // Our right margin depends where our cursor is now. + let right_limit = if self.screen().cursor.x > self.scrolling_region.right { + self.cols + } else { + self.scrolling_region.right + 1 + }; + + // Perform grapheme clustering if grapheme support is enabled (mode 2027). + // This is MUCH slower than the normal path so the conditional below is + // purposely ordered in least-likely to most-likely so we can drop out + // as quickly as possible. + if c > 255 + && self.modes.get(Mode::GraphemeCluster) + && self.screen().cursor.x > 0 + && self.print_grapheme(c, right_limit)? + { + return Ok(()); + } + + // Determine the width of this character so we can handle + // non-single-width characters properly. We have a fast-path for + // byte-sized characters since they're so common. We can ignore + // control characters because they're always filtered prior. + let width: usize = if c <= 0xFF { + 1 + } else { + usize::from(ghostty_unicode::table().get(c).width) + }; + + // Note: it is possible to have a width of "3" and a width of "-1" from + // uucode.x's wcwidth. We should look into those cases and handle them + // appropriately. + assert!(width <= 2); + + // Attach zero-width characters to our cell as grapheme data. + if width == 0 { + // If we have grapheme clustering enabled, we don't blindly attach + // any zero width character to our cells and we instead just ignore + // it. + if self.modes.get(Mode::GraphemeCluster) { + return Ok(()); + } + + // If we're at cell zero, then this is malformed data and we don't + // print anything or even store this. Zero-width characters are ALWAYS + // attached to some other non-zero-width character at the time of + // writing. + if self.screen().cursor.x == 0 { + log::warn!("zero-width character with no prior character, ignoring"); + return Ok(()); + } + + // Find our previous cell + let (prev_x, prev) = { + let scr = self.screen(); + let immediate = scr.cursor_cell_left(1); + if immediate.wide() == Wide::SpacerTail { + (scr.cursor.x - 2, scr.cursor_cell_left(2)) + } else { + (scr.cursor.x - 1, immediate) + } + }; + + // If our previous cell has no text, just ignore the zero-width character + if !prev.has_text() { + log::warn!("zero-width character with no prior character, ignoring"); + return Ok(()); + } + + // If this is a emoji variation selector, prev must be an emoji + if c == 0xFE0F || c == 0xFE0E { + let prev_props = ghostty_unicode::table().get(prev.codepoint()); + let emoji = prev_props.grapheme_break + == ghostty_unicode::GraphemeBreakNoControl::ExtendedPictographic; + if !emoji { + return Ok(()); + } + } + + // Zig's appendGrapheme does not touch the row dirty flag, but the + // Rust Page::append_grapheme marks the row dirty; preserve the old + // flag (see pagelist requests). + let was_dirty = self.screen().cursor_row().dirty(); + let r = self.screen_mut().append_grapheme(prev_x, c); + if !was_dirty { + self.screen_mut().cursor_row_mut().set_dirty(false); + } + return r; + } + + // We have a printable character, save it + self.previous_char = Some(c); + + // If we're soft-wrapping, then handle that first. + if self.screen().cursor.pending_wrap && self.modes.get(Mode::Wraparound) { + self.print_wrap()?; + } + + // If we have insert mode enabled then we need to handle that. We + // only do insert mode if we're not at the end of the line. + if self.modes.get(Mode::Insert) + && usize::from(self.screen().cursor.x) + width < usize::from(self.cols) + { + self.insert_blanks(width); + } + + match width { + // Single cell is very easy: just write in the cell + 1 => { + self.screen_mut().cursor_mark_dirty(); + self.print_cell(c, Wide::Narrow); + } + + // Wide character requires a spacer. We print this by + // using two cells: the first is flagged "wide" and has the + // wide char. The second is guaranteed to be a spacer if + // we're not at the end of the line. + 2 => { + if (right_limit - self.scrolling_region.left) > 1 { + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if self.screen().cursor.x == right_limit - 1 { + // If we don't have wraparound enabled then we don't print + // this character at all and don't move the cursor. This is + // how xterm behaves. + if !self.modes.get(Mode::Wraparound) { + return Ok(()); + } + + // We only create a spacer head if we're at the real edge + // of the screen. Otherwise, we clear the space with a narrow. + // This allows soft wrapping to work correctly. + if right_limit == self.cols { + // Special-case: we need to set wrap to true even + // though we call print_wrap below because if there is + // a page resize during print_cell then it'll fail + // integrity checks. + self.screen_mut().cursor_row_mut().set_wrap(true); + self.print_cell(0, Wide::SpacerHead); + } else { + self.print_cell(0, Wide::Narrow); + } + self.print_wrap()?; + } + + self.screen_mut().cursor_mark_dirty(); + self.print_cell(c, Wide::Wide); + self.screen_mut().cursor_right(1); + self.print_cell(0, Wide::SpacerTail); + } else { + // This is pretty broken, terminals should never be only 1-wide. + // We should prevent this downstream. + self.screen_mut().cursor_mark_dirty(); + self.print_cell(0, Wide::Narrow); + } + } + + _ => unreachable!(), + } + + // If we're at the column limit, then we need to wrap the next time. + // In this case, we don't move the cursor. + if self.screen().cursor.x == right_limit - 1 { + self.screen_mut().cursor.pending_wrap = true; + return Ok(()); + } + + // Move the cursor + self.screen_mut().cursor_right(1); + Ok(()) + } + + /// The grapheme-clustering section of `print`. Returns `true` if the + /// character was fully handled (Zig: `return`), `false` if printing should + /// continue on the normal path (Zig: `break :grapheme`). + fn print_grapheme(&mut self, c: u32, right_limit: size::CellCountInt) -> Result { + // We need the previous cell to determine if we're at a grapheme + // break or not. If we are NOT, then we are still combining the + // same grapheme, and will be appending to prev.cell. Otherwise, we are + // in a new cell. + let wraparound = self.modes.get(Mode::Wraparound); + let (mut prev_left, mut prev_cell) = { + let scr = self.screen(); + let left: size::CellCountInt = if wraparound { + // If we have wraparound, then we use the prev col unless + // there's a pending wrap, in which case we use the current. + size::CellCountInt::from(!scr.cursor.pending_wrap) + } else if scr.cursor.x != right_limit - 1 { + // If we do not have wraparound, the logic is trickier. If + // we're not on the last column, then we just use the previous + // column. Otherwise, we need to check if there is text to + // figure out if we're attaching to the prev or current. + 1 + } else { + size::CellCountInt::from(scr.cursor_cell().codepoint() == 0) + }; + + // If the previous cell is a wide spacer tail, then we actually + // want to use the cell before that because that has the actual + // content. + let immediate = scr.cursor_cell_left(left); + if immediate.wide() == Wide::SpacerTail { + (left + 1, scr.cursor_cell_left(left + 1)) + } else { + (left, immediate) + } + }; + + // If our cell has no content, then this is a new cell and + // necessarily a grapheme break. + if prev_cell.codepoint() == 0 { + return Ok(false); + } + + let grapheme_break = { + let mut state = ghostty_unicode::BreakState::default(); + let mut cp1 = prev_cell.codepoint(); + if prev_cell.has_grapheme() { + let scr = self.screen(); + let pin = scr.cursor_pin(); + let page = scr.page(pin.node); + let idx = page + .cell_index(usize::from(scr.cursor.x - prev_left), usize::from(pin.y)) + .expect("prev cell"); + let cps = page.lookup_grapheme(idx).expect("grapheme data"); + for &cp2 in cps { + let b = ghostty_unicode::grapheme_break(cp1, cp2, &mut state); + debug_assert!(!b); + cp1 = cp2; + } + } + + ghostty_unicode::grapheme_break(cp1, c, &mut state) + }; + + // If we can NOT break, this means that "c" is part of a grapheme + // with the previous char. + if grapheme_break { + return Ok(false); + } + + #[derive(PartialEq, Eq)] + enum DesiredWide { + NoChange, + Wide, + Narrow, + } + let mut desired_wide = DesiredWide::NoChange; + + // The column of the previous cell on the cursor row. + let mut prev_x = self.screen().cursor.x - prev_left; + + // If this is an emoji variation selector then we need to modify + // the cell width accordingly. VS16 makes the character wide and + // VS15 makes it narrow. + if c == 0xFE0F || c == 0xFE0E { + let prev_props = ghostty_unicode::table().get(prev_cell.codepoint()); + // Check if it is a valid variation sequence in + // emoji-variation-sequences.txt, and if not, ignore the char. + if !prev_props.emoji_vs_base { + return Ok(true); + } + + desired_wide = if c == 0xFE0F { + DesiredWide::Wide + } else { + DesiredWide::Narrow + }; + } else if !ghostty_unicode::table().get(c).width_zero_in_grapheme { + // If we have a code point that contributes to the width of a + // grapheme, it necessarily means that we're at least at width + // 2, since the first code point must be at least width 1 to + // start. (Note that Prepend code points could effectively mean + // the first code point should be width 0, but we don't handle + // that yet.) + desired_wide = DesiredWide::Wide; + } + + match desired_wide { + DesiredWide::Wide => 'wide: { + if prev_cell.wide() == Wide::Wide { + break 'wide; + } + + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screen_mut().cursor_left(prev_left); + + // If we don't have space for the wide char, we need to + // insert spacers and wrap. We need special handling if the + // previous cell has grapheme data. + if self.screen().cursor.x == right_limit - 1 { + if !wraparound { + return Ok(true); + } + + // This path can write a spacer_head before print_wrap + // which can trigger integrity violations so mark + // the wrap first to keep the intermediary state valid + // if we're wrapping. + let row_wrap = right_limit == self.cols; + if row_wrap { + self.screen_mut().cursor_row_mut().set_wrap(true); + } + + let prev_cp = prev_cell.codepoint(); + if prev_cell.has_grapheme() { + // This is like print_cell but without clearing the + // grapheme data from the cell, so we can move it + // later. + { + let cell = self.screen_mut().cursor_cell_mut(); + cell.set_wide(if row_wrap { + Wide::SpacerHead + } else { + Wide::Narrow + }); + cell.set_codepoint(0); + } + + self.print_wrap()?; + self.print_cell(prev_cp, Wide::Wide); + + let new_pin = self.screen().cursor_pin(); + + 'transfer: { + let Some(mut old_pin) = new_pin.up(&self.screen().pages, 1) else { + break 'transfer; + }; + old_pin.x = right_limit - 1; + let (old_x, old_y) = (usize::from(old_pin.x), usize::from(old_pin.y)); + let (new_x, new_y) = (usize::from(new_pin.x), usize::from(new_pin.y)); + + if new_pin.node == old_pin.node { + let page = self.screen_mut().page_mut(new_pin.node); + let old_idx = page.cell_index(old_x, old_y).expect("old cell"); + let new_idx = page.cell_index(new_x, new_y).expect("new cell"); + page.move_grapheme(old_idx, new_idx)?; + page.cells[old_idx].set_content_tag(ContentTag::Codepoint); + page.cells[new_idx].set_content_tag(ContentTag::CodepointGrapheme); + page.rows[new_y].set_grapheme(true); + page.update_row_grapheme_flag(old_y); + } else { + let cps: Vec = { + let page = self.screen().page(old_pin.node); + let old_idx = page.cell_index(old_x, old_y).expect("old cell"); + page.lookup_grapheme(old_idx).expect("grapheme").to_vec() + }; + for cp in cps { + let x = self.screen().cursor.x; + self.screen_mut().append_grapheme(x, cp)?; + } + let page = self.screen_mut().page_mut(old_pin.node); + let old_idx = page.cell_index(old_x, old_y).expect("old cell"); + page.clear_grapheme(old_idx); + page.update_row_grapheme_flag(old_y); + } + } + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to + prev_x = self.screen().cursor.x; + } else { + self.print_cell( + 0, + if row_wrap { + Wide::SpacerHead + } else { + Wide::Narrow + }, + ); + self.print_wrap()?; + self.print_cell(prev_cp, Wide::Wide); + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to + prev_x = self.screen().cursor.x; + } + } else { + self.screen_mut().cursor_cell_mut().set_wide(Wide::Wide); + } + + // Write our spacer, since prev.cell is now wide + self.screen_mut().cursor_right(1); + self.print_cell(0, Wide::SpacerTail); + + // Move the cursor again so we're beyond our spacer + if self.screen().cursor.x == right_limit - 1 { + self.screen_mut().cursor.pending_wrap = true; + } else { + self.screen_mut().cursor_right(1); + } + } + + DesiredWide::Narrow => 'narrow: { + // Prev cell is no longer wide + if prev_cell.wide() != Wide::Wide { + break 'narrow; + } + { + let scr = self.screen_mut(); + let x = scr.cursor.x; + let pin = scr.cursor_pin(); + let page = scr.page_mut(pin.node); + let idx = page + .cell_index(usize::from(x - prev_left), usize::from(pin.y)) + .expect("prev cell"); + page.cells[idx].set_wide(Wide::Narrow); + } + prev_cell.set_wide(Wide::Narrow); + + // Remove the wide spacer tail + self.screen_mut() + .cursor_cell_left_mut(prev_left - 1) + .set_wide(Wide::Narrow); + + // Back track the cursor so that we don't end up with + // an extra space after the character. Since xterm is + // not VS aware, it cannot be used as a reference for + // this behavior; but it does follow the principle of + // least surprise, and also matches the behavior that + // can be observed in Kitty, which is one of the only + // other VS aware terminals. + if self.screen().cursor.x == right_limit - 1 { + // If we're already at the right edge, we stay + // here and set the pending wrap to false since + // when we pend a wrap, we only move our cursor once + // even for wide chars (tests verify). + self.screen_mut().cursor.pending_wrap = false; + } else { + // Otherwise, move back. + self.screen_mut().cursor_left(1); + } + } + + DesiredWide::NoChange => {} + } + let _ = &mut prev_left; + + log::debug!( + "c={c:X} grapheme attach to left={prev_left} primary_cp={:X}", + prev_cell.codepoint() + ); + self.screen_mut().cursor_mark_dirty(); + self.screen_mut().append_grapheme(prev_x, c)?; + Ok(true) + } + + fn print_cell(&mut self, unmapped_c: u32, wide: Wide) { + let cols = self.cols; + let scr = self.screens.active_mut(); + + // TODO: spacers should use a bgcolor only cell + + let c: u32 = 'c: { + // TODO: non-utf8 handling, gr + + // If we're single shifting, then we use the key exactly once. + let key = if let Some(key_once) = scr.charset.single_shift { + scr.charset.single_shift = None; + key_once + } else { + scr.charset.gl + }; + + let set = scr.charset.charsets.get(key); + + // UTF-8 or ASCII is used as-is + if set == Charset::Utf8 || set == Charset::Ascii { + break 'c unmapped_c; + } + + // If we're outside of ASCII range this is an invalid value in + // this table so we just return space. + if unmapped_c > u32::from(u8::MAX) { + break 'c u32::from(' '); + } + + // Get our lookup table and map it + let table = charsets::table(set); + u32::from(table[unmapped_c as usize]) + }; + + let cell = scr.cursor_cell(); + + // If the wide property of this cell is the same, then we don't + // need to do the special handling here because the structure will + // be the same. If it is NOT the same, then we may need to clear some + // cells. + if cell.wide() != wide { + match cell.wide() { + // Previous cell was narrow. Do nothing. + Wide::Narrow => {} + + // Previous cell was wide. We need to clear the tail and head. + Wide::Wide => 'wide: { + if scr.cursor.x >= cols - 1 { + break 'wide; + } + + let pin = scr.cursor_pin(); + scr.clear_cells( + pin.node, + pin.y, + usize::from(scr.cursor.x) + 1, + usize::from(scr.cursor.x) + 2, + ); + + // If we're near the left edge, a wide char may have + // wrapped from the previous row, leaving a spacer_head + // at the end of that row. Clear it so the previous row + // doesn't keep a stale spacer_head. + if scr.cursor.y > 0 && scr.cursor.x <= 1 { + clear_prev_row_spacer_head(scr, cols); + } + } + + Wide::SpacerTail => { + assert!(scr.cursor.x > 0); + + // So integrity checks pass. We fix this up later so we don't + // need to do this without safety checks. + if cfg!(debug_assertions) { + scr.cursor_cell_mut().set_wide(Wide::Narrow); + } + + let pin = scr.cursor_pin(); + scr.clear_cells( + pin.node, + pin.y, + usize::from(scr.cursor.x) - 1, + usize::from(scr.cursor.x), + ); + // If we're near the left edge, a wide char may have + // wrapped from the previous row, leaving a spacer_head + // at the end of that row. Clear it so the previous row + // doesn't keep a stale spacer_head. + if scr.cursor.y > 0 && scr.cursor.x <= 1 { + clear_prev_row_spacer_head(scr, cols); + } + } + + // TODO: this case was not handled in the old terminal implementation + // but it feels like we should do something. investigate other + // terminals (xterm mainly) and see what's up. + Wide::SpacerHead => {} + } + } + + let pin = scr.cursor_pin(); + let (x, y) = (usize::from(pin.x), usize::from(pin.y)); + let cursor_style_id = scr.cursor.style_id; + let cursor_protected = scr.cursor.protected; + let cursor_semantic = scr.cursor.semantic_content; + + // Re-read the cell (clearing above may have changed it). + let page = scr.page_mut(pin.node); + let idx = page.cell_index(x, y).expect("cursor cell"); + let cell = page.cells[idx]; + + // If the prior value had graphemes, clear those + if cell.has_grapheme() { + page.clear_grapheme(idx); + page.update_row_grapheme_flag(y); + } + + // We don't need to update the style refs unless the + // cell's new style will be different after writing. + let style_changed = cell.style_id() != cursor_style_id; + if style_changed { + // Release the old style. + if cell.style_id() != style::DEFAULT_ID { + assert!(page.rows[y].styled()); + let _ = page.styles.release(cell.style_id()); + } + } + + // Keep track if we had a hyperlink so we can unset it. + let had_hyperlink = cell.hyperlink(); + + // Write + let mut new_cell = Cell::init(c); + new_cell.set_content_tag(ContentTag::Codepoint); + new_cell.set_style_id(cursor_style_id); + new_cell.set_wide(wide); + new_cell.set_protected(cursor_protected); + new_cell.set_semantic_content(cursor_semantic); + page.cells[idx] = new_cell; + + if style_changed { + // Use the new style. + if cursor_style_id != style::DEFAULT_ID { + let _ = page.styles.r#use(cursor_style_id); + page.rows[y].set_styled(true); + } + } + + // If this is a Kitty unicode placeholder then we need to mark the + // row so that the renderer can lookup rows with these much faster. + if c == KITTY_PLACEHOLDER { + page.rows[y].set_kitty_virtual_placeholder(true); + } + + // We check for an active hyperlink first because setHyperlink + // handles clearing the old hyperlink and an optimization if we're + // overwriting the same hyperlink. + if scr.cursor.hyperlink_id > 0 { + if let Err(err) = scr.cursor_set_hyperlink() { + log::warn!( + "error reallocating for more hyperlink space, ignoring hyperlink err={err:?}" + ); + debug_assert!(!scr.cursor_cell().hyperlink()); + } + } else if had_hyperlink { + // If the previous cell had a hyperlink then we need to clear it. + let page = scr.page_mut(pin.node); + let _ = page.clear_hyperlink(idx); + page.update_row_hyperlink_flag(y); + } + + scr.assert_integrity(); + } + + fn print_wrap(&mut self) -> Result<(), Error> { + // We only mark that we soft-wrapped if we're at the edge of our + // full screen. We don't mark the row as wrapped if we're in the + // middle due to a right margin. + let mark_wrap = self.screen().cursor.x == self.cols - 1; + if mark_wrap { + self.screen_mut().cursor_row_mut().set_wrap(true); + } + + // Get the old semantic prompt so we can extend it to the next + // line. We need to do this before we index() because we may + // modify memory. + let old_semantic = self.screen().cursor.semantic_content; + let old_semantic_clear = self.screen().cursor.semantic_content_clear_eol; + + // Move to the next line + self.index()?; + let left = self.scrolling_region.left; + self.screen_mut().cursor_horizontal_absolute(left); + + // We always reset our semantic prompt state + let scr = self.screen_mut(); + scr.cursor.semantic_content = old_semantic; + scr.cursor.semantic_content_clear_eol = old_semantic_clear; + match old_semantic { + SemanticContent::Output | SemanticContent::Input => {} + SemanticContent::Prompt => scr + .cursor_row_mut() + .set_semantic_prompt(RowPrompt::PromptContinuation), + } + + if mark_wrap { + // Always mark the row as a continuation + scr.cursor_row_mut().set_wrap_continuation(true); + } + + // Assure that our screen is consistent + scr.assert_integrity(); + Ok(()) + } + + /// Set the charset into the given slot. + pub fn configure_charset(&mut self, slot: Slots, set: Charset) { + self.screen_mut().charset.charsets.set(slot, set); + } + + /// Invoke the charset in slot into the active slot. If single is true, + /// then this will only be invoked for a single character. + pub fn invoke_charset(&mut self, active: charsets::ActiveSlot, slot: Slots, single: bool) { + if single { + assert!(active == charsets::ActiveSlot::GL); + self.screen_mut().charset.single_shift = Some(slot); + return; + } + + match active { + charsets::ActiveSlot::GL => self.screen_mut().charset.gl = slot, + charsets::ActiveSlot::GR => self.screen_mut().charset.gr = slot, + } + } + + /// Carriage return moves the cursor to the first column. + pub fn carriage_return(&mut self) { + // Always reset pending wrap state + self.screen_mut().cursor.pending_wrap = false; + + // In origin mode we always move to the left margin + let x = if self.modes.get(Mode::Origin) + || self.screen().cursor.x >= self.scrolling_region.left + { + self.scrolling_region.left + } else { + 0 + }; + self.screen_mut().cursor_horizontal_absolute(x); + } + + /// Linefeed moves the cursor to the next line. + pub fn linefeed(&mut self) -> Result<(), Error> { + self.index()?; + if self.modes.get(Mode::Linefeed) { + self.carriage_return(); + } + Ok(()) + } + + /// Backspace moves the cursor back a column (but not less than 0). + pub fn backspace(&mut self) { + self.cursor_left(1); + } + + /// Move the cursor up amount lines. If amount is greater than the maximum + /// move distance then it is internally adjusted to the maximum. If amount is + /// 0, adjust it to 1. + pub fn cursor_up(&mut self, count_req: usize) { + // Always resets pending wrap + self.screen_mut().cursor.pending_wrap = false; + + // The maximum amount the cursor can move up depends on scrolling regions + let y = self.screen().cursor.y; + let max = if y >= self.scrolling_region.top { + y - self.scrolling_region.top + } else { + y + }; + let count = usize::from(max).min(count_req.max(1)); + + // We can safely cast below because of the min/max clamping we did above. + self.screen_mut().cursor_up(count as size::CellCountInt); + } + + /// Move the cursor down amount lines. If amount is greater than the maximum + /// move distance then it is internally adjusted to the maximum. This sequence + /// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. + pub fn cursor_down(&mut self, count_req: usize) { + // Always resets pending wrap + self.screen_mut().cursor.pending_wrap = false; + + // The max the cursor can move to depends where the cursor currently is + let y = self.screen().cursor.y; + let max = if y <= self.scrolling_region.bottom { + self.scrolling_region.bottom - y + } else { + self.rows - y - 1 + }; + let count = usize::from(max).min(count_req.max(1)); + self.screen_mut().cursor_down(count as size::CellCountInt); + } + + /// Move the cursor right amount columns. If amount is greater than the + /// maximum move distance then it is internally adjusted to the maximum. + /// This sequence will not scroll the screen or scroll region. If amount is + /// 0, adjust it to 1. + pub fn cursor_right(&mut self, count_req: usize) { + // Always resets pending wrap + self.screen_mut().cursor.pending_wrap = false; + + // The max the cursor can move to depends where the cursor currently is + let x = self.screen().cursor.x; + let max = if x <= self.scrolling_region.right { + self.scrolling_region.right - x + } else { + self.cols - x - 1 + }; + let count = usize::from(max).min(count_req.max(1)); + self.screen_mut().cursor_right(count as size::CellCountInt); + } + + /// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. + pub fn cursor_left(&mut self, count_req: usize) { + // Wrapping behavior depends on various terminal modes + #[derive(PartialEq, Eq, Clone, Copy)] + enum WrapMode { + None, + Reverse, + ReverseExtended, + } + let wrap_mode = if !self.modes.get(Mode::Wraparound) { + WrapMode::None + } else if self.modes.get(Mode::ReverseWrapExtended) { + WrapMode::ReverseExtended + } else if self.modes.get(Mode::ReverseWrap) { + WrapMode::Reverse + } else { + WrapMode::None + }; + + let mut count = count_req.max(1); + + // If we are in no wrap mode, then we move the cursor left and exit + // since this is the fastest and most typical path. + if wrap_mode == WrapMode::None { + let x = self.screen().cursor.x; + self.screen_mut() + .cursor_left(count.min(usize::from(x)) as size::CellCountInt); + self.screen_mut().cursor.pending_wrap = false; + return; + } + + // If we have a pending wrap state and we are in either reverse wrap + // modes then we decrement the amount we move by one to match xterm. + if self.screen().cursor.pending_wrap { + count -= 1; + self.screen_mut().cursor.pending_wrap = false; + } + + // The margins we can move to. + let top = self.scrolling_region.top; + let bottom = self.scrolling_region.bottom; + let right_margin = self.scrolling_region.right; + let left_margin = if self.screen().cursor.x < self.scrolling_region.left { + 0 + } else { + self.scrolling_region.left + }; + + // Handle some edge cases when our cursor is already on the left margin. + if self.screen().cursor.x == left_margin { + match wrap_mode { + // In reverse mode, if we're already before the top margin + // then we just set our cursor to the top-left and we're done. + WrapMode::Reverse => { + if self.screen().cursor.y <= top { + self.screen_mut().cursor_absolute(left_margin, top); + return; + } + } + + // Handled in while loop + WrapMode::ReverseExtended => {} + + // Handled above + WrapMode::None => unreachable!(), + } + } + + loop { + // We can move at most to the left margin. + let max = usize::from(self.screen().cursor.x - left_margin); + + // We want to move at most the number of columns we have left + // or our remaining count. Do the move. + let amount = max.min(count); + count -= amount; + self.screen_mut().cursor_left(amount as size::CellCountInt); + + // If we have no more to move, then we're done. + if count == 0 { + break; + } + + // If we are at the top, then we are done. + if self.screen().cursor.y == top { + if wrap_mode != WrapMode::ReverseExtended { + break; + } + + self.screen_mut().cursor_absolute(right_margin, bottom); + count -= 1; + continue; + } + + // UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm + // and currently results in a crash in xterm. Given no other known + // terminal [to me] implements XTREVWRAP2, I decided to just mimic + // the behavior of xterm up and not including the crash by wrapping + // up to the (0, 0) and stopping there. My reasoning is that for an + // appropriately sized value of "count" this is the behavior that xterm + // would have. This is unit tested. + if self.screen().cursor.y == 0 { + assert!(self.screen().cursor.x == left_margin); + break; + } + + // If our previous line is not wrapped then we are done. + if wrap_mode != WrapMode::ReverseExtended { + let prev_row = self.screen().cursor_row_up(1); + if !prev_row.wrap() { + break; + } + } + + let y = self.screen().cursor.y - 1; + self.screen_mut().cursor_absolute(right_margin, y); + count -= 1; + } + } + + /// Save cursor position and further state. + /// + /// The primary and alternate screen have distinct save state. One saved state + /// is kept per screen (main / alternative). If for the current screen state + /// was already saved it is overwritten. + pub fn save_cursor(&mut self) { + let origin = self.modes.get(Mode::Origin); + let scr = self.screen_mut(); + scr.saved_cursor = Some(SavedCursor { + x: scr.cursor.x, + y: scr.cursor.y, + style: scr.cursor.style, + protected: scr.cursor.protected, + pending_wrap: scr.cursor.pending_wrap, + origin, + charset: scr.charset, + }); + } + + /// Restore cursor position and other state. + /// + /// The primary and alternate screen have distinct save state. + /// If no save was done before values are reset to their initial values. + pub fn restore_cursor(&mut self) { + let saved = self.screen().saved_cursor.unwrap_or(SavedCursor { + x: 0, + y: 0, + style: Style::default(), + protected: false, + pending_wrap: false, + origin: false, + charset: CharsetState::default(), + }); + + // Set the style first because it can fail + let scr = self.screen_mut(); + scr.cursor.style = saved.style; + if let Err(err) = scr.manual_style_update() { + // Regardless of the error here, we revert back to an unstyled + // cursor. It is more important that the restore succeeds in + // other attributes because terminals have no way to communicate + // failure back. + log::warn!("restoreCursor error updating style err={err:?}"); + scr.cursor.style = Style::default(); + scr.manual_style_update().expect("default style update"); + } + + scr.charset = saved.charset; + scr.cursor.pending_wrap = saved.pending_wrap; + scr.cursor.protected = saved.protected; + let (cols, rows) = (self.cols, self.rows); + self.modes.set(Mode::Origin, saved.origin); + self.screen_mut() + .cursor_absolute(saved.x.min(cols - 1), saved.y.min(rows - 1)); + + // Ensure our screen is consistent + self.screen().assert_integrity(); + } + + /// Set the character protection mode for the terminal. + pub fn set_protected_mode(&mut self, mode: ProtectedMode) { + let scr = self.screen_mut(); + match mode { + ProtectedMode::Off => { + scr.cursor.protected = false; + + // screen.protected_mode is NEVER reset to ".off" because + // logic such as eraseChars depends on knowing what the + // _most recent_ mode was. + } + + ProtectedMode::Iso => { + scr.cursor.protected = true; + scr.protected_mode = ProtectedMode::Iso; + } + + ProtectedMode::Dec => { + scr.cursor.protected = true; + scr.protected_mode = ProtectedMode::Dec; + } + } + } + + /// Perform a semantic prompt command. + /// + /// If there is an error, we do our best to get the terminal into + /// some coherent state, since callers typically can't handle errors + /// (since they're sending sequences via the pty). + pub fn semantic_prompt(&mut self, cmd: &osc::SemanticPrompt) -> Result<(), Error> { + match cmd.action { + PromptAction::FreshLine => self.semantic_prompt_fresh_line()?, + + PromptAction::FreshLineNewPrompt => { + // "First do a fresh-line." + self.semantic_prompt_fresh_line()?; + + // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) + // is a prompt string (as if followed by OSC 133;P;k=i\007)." + let kind = cmd + .prompt_kind() + .unwrap_or(osc::semantic_prompt::PromptKind::Initial); + self.screen_mut() + .cursor_set_semantic_content(SetSemantic::Prompt(kind)); + + // This is a kitty-specific flag that notes that the shell + // is NOT capable of redraw. Redraw defaults to true so this + // usually just disables it, but either is possible. + if let Some(v) = cmd.redraw() { + self.flags.shell_redraws_prompt = v; + } + + 'click: { + // Handle click_events as a priority over cl. click_events + // is another Kitty-specific extension that converts clicks + // within a prompt area to SGR mouse events and defers to the + // shell to handle them. + if let Some(v) = cmd.click_events() + && v + { + self.screen_mut().semantic_prompt.click = SemanticClick::ClickEvents; + break 'click; + } + + // If click_events was not set or disabled, fallback to `cl`. + if let Some(v) = cmd.cl() { + self.screen_mut().semantic_prompt.click = SemanticClick::Cl(v); + } + } + + // The "aid" and "cl" options are also valid for this + // command but we don't yet handle these in any meaningful way. + } + + PromptAction::NewCommand => { + // Spec: + // Same as OSC "133;A" but may first implicitly terminate a + // previous command: if the options specify an aid and there + // is an active (open) command with matching aid, finish the + // innermost such command (as well as any other commands + // nested more deeply). If no aid is specified, treat as an + // aid whose value is the empty string. + + // Ghostty: + // We don't currently do explicit command tracking in any way + // so there is no need to terminate prior commands. We just + // perform the `A` action. + self.semantic_prompt(&osc::SemanticPrompt { + action: PromptAction::FreshLineNewPrompt, + options_unvalidated: cmd.options_unvalidated.clone(), + })?; + } + + PromptAction::PromptStart => { + // Explicit start of prompt. Optional after an A or N command. + // The k (kind) option specifies the type of prompt: + // regular primary prompt (k=i or default), + // right-side prompts (k=r), or prompts for continuation lines (k=c or k=s). + let kind = cmd + .prompt_kind() + .unwrap_or(osc::semantic_prompt::PromptKind::Initial); + self.screen_mut() + .cursor_set_semantic_content(SetSemantic::Prompt(kind)); + } + + PromptAction::EndPromptStartInput => { + // End of prompt and start of user input, terminated by a OSC + // "133;C" or another prompt (OSC "133;P"). + self.screen_mut() + .cursor_set_semantic_content(SetSemantic::Input(InputClear::ClearExplicit)); + } + + PromptAction::EndPromptStartInputTerminateEol => { + // End of prompt and start of user input, terminated by end-of-line. + self.screen_mut() + .cursor_set_semantic_content(SetSemantic::Input(InputClear::ClearEol)); + } + + PromptAction::EndInputStartOutput => { + // "End of input, and start of output." + self.screen_mut() + .cursor_set_semantic_content(SetSemantic::Output); + + // If our current row is marked as a prompt and we're + // at column zero then we assume we're un-prompting. This + // is a heuristic to deal with fish, mostly. The issue that + // fish brings up is that it has no PS2 equivalent and its + // builtin OSC133 marking doesn't output continuation lines + // as k=s. So, we assume when we get a newline with a prompt + // cursor that the new line is also a prompt. But fish changes + // to output on the newline. So if we're at col 0 we just assume + // we're overwriting the prompt. + if self.screen().cursor_row().semantic_prompt() != RowPrompt::None + && self.screen().cursor.x == 0 + { + self.screen_mut() + .cursor_row_mut() + .set_semantic_prompt(RowPrompt::None); + } + } + + PromptAction::EndCommand => { + // From a terminal state perspective, this doesn't really do + // anything. Other terminals appear to do nothing here. I think + // its reasonable at this point to reset our semantic content + // state but the spec doesn't really say what to do. + self.screen_mut() + .cursor_set_semantic_content(SetSemantic::Output); + } + } + Ok(()) + } + + // OSC 133;L + fn semantic_prompt_fresh_line(&mut self) -> Result<(), Error> { + let left_margin = if self.screen().cursor.x < self.scrolling_region.left { + 0 + } else { + self.scrolling_region.left + }; + + // Spec: "If the cursor is the initial column (left, assuming + // left-to-right writing), do nothing" This specification is very under + // specified. We are taking the liberty to assume that in a left/right + // margin context, if the cursor is outside of the left margin, we treat + // it as being at the left margin for the purposes of this command. + // This is arbitrary. If someone has a better reasonable idea we can + // apply it. + if self.screen().cursor.x == left_margin { + return Ok(()); + } + + self.carriage_return(); + self.index() + } + + /// Returns true if the cursor is currently at a prompt. Another way to look + /// at this is it returns false if the shell is currently outputting something. + /// This requires shell integration (semantic prompt integration). + /// + /// If the shell integration doesn't exist, this will always return false. + pub fn cursor_is_at_prompt(&self) -> bool { + // If we're on the secondary screen, we're never at a prompt. + if self.screens.active_key == ScreenKey::Alternate { + return false; + } + + // If our page row is a prompt then we're always at a prompt + let scr = self.screen(); + if scr.cursor_row().semantic_prompt() != RowPrompt::None { + return true; + } + + // Otherwise, determine our cursor state + match scr.cursor.semantic_content { + SemanticContent::Input | SemanticContent::Prompt => true, + SemanticContent::Output => false, + } + } + + /// Horizontal tab moves the cursor to the next tabstop, clearing + /// the screen to the left the tabstop. + pub fn horizontal_tab(&mut self) { + while self.screen().cursor.x < self.scrolling_region.right { + // Move the cursor right + self.screen_mut().cursor_right(1); + + // If the last cursor position was a tabstop we return. We do + // "last cursor position" because we want a space to be written + // at the tabstop unless we're at the end (the while condition). + if self.tabstops.get(usize::from(self.screen().cursor.x)) { + return; + } + } + } + + // Same as horizontal_tab but moves to the previous tabstop instead of the next. + pub fn horizontal_tab_back(&mut self) { + // With origin mode enabled, our leftmost limit is the left margin. + let left_limit = if self.modes.get(Mode::Origin) { + self.scrolling_region.left + } else { + 0 + }; + + loop { + // If we're already at the edge of the screen, then we're done. + if self.screen().cursor.x <= left_limit { + return; + } + + // Move the cursor left + self.screen_mut().cursor_left(1); + if self.tabstops.get(usize::from(self.screen().cursor.x)) { + return; + } + } + } + + /// Clear tab stops. + pub fn tab_clear(&mut self, cmd: csi::TabClear) { + match cmd { + csi::TabClear::Current => self.tabstops.unset(usize::from(self.screen().cursor.x)), + csi::TabClear::All => self.tabstops.reset(0), + other @ csi::TabClear::Other(_) => { + log::warn!("invalid or unknown tab clear setting: {other:?}"); + } + } + } + + /// Set a tab stop on the current cursor. + pub fn tab_set(&mut self) { + self.tabstops.set(usize::from(self.screen().cursor.x)); + } + + pub fn tab_reset(&mut self) { + self.tabstops.reset(TABSTOP_INTERVAL); + } + + /// Move the cursor to the next line in the scrolling region, possibly scrolling. + /// + /// If the cursor is outside of the scrolling region: move the cursor one line + /// down if it is not on the bottom-most line of the screen. + /// + /// If the cursor is inside the scrolling region: + /// If the cursor is on the bottom-most line of the scrolling region: + /// invoke scroll up with amount=1 + /// If the cursor is not on the bottom-most line of the scrolling region: + /// move the cursor one line down + /// + /// This unsets the pending wrap state without wrapping. + #[allow(clippy::if_not_else)] + pub fn index(&mut self) -> Result<(), Error> { + // Unset pending wrap state + self.screen_mut().cursor.pending_wrap = false; + + // We handle our cursor semantic prompt state AFTER doing the + // scrolling, because we may need to apply to new rows. + let result = self.index_inner(); + + let screen = self.screen_mut(); + if screen.cursor.semantic_content != SemanticContent::Output { + // Always reset any semantic content clear-eol state. + // + // The specification is not clear what "end-of-line" means. If we + // discover that there are more scenarios we should be unsetting + // this we should document and test it. + if screen.cursor.semantic_content_clear_eol { + screen.cursor.semantic_content = SemanticContent::Output; + screen.cursor.semantic_content_clear_eol = false; + } else { + // If we aren't clearing our state at EOL and we're not output, + // then we mark the new row as a prompt continuation. This is + // to work around shells that don't send OSC 133 k=s sequences + // for continuations. + // + // This can be a false positive if the shell changes content + // type later and outputs something. We handle that in the + // semanticPrompt function. + screen + .cursor_row_mut() + .set_semantic_prompt(RowPrompt::PromptContinuation); + } + } else { + // This should never be set in the output mode. + assert!(!screen.cursor.semantic_content_clear_eol); + } + + result + } + + fn index_inner(&mut self) -> Result<(), Error> { + let rows = self.rows; + let cols = self.cols; + let sr = self.scrolling_region; + let screen = self.screens.active_mut(); + + // Outside of the scroll region we move the cursor one line down. + if screen.cursor.y < sr.top || screen.cursor.y > sr.bottom { + // We only move down if we're not already at the bottom of + // the screen. + if screen.cursor.y < rows - 1 { + screen.cursor_down(1); + } + + return Ok(()); + } + + // If the cursor is inside the scrolling region and on the bottom-most + // line, then we scroll up. If our scrolling region is the full screen + // we create scrollback. + if screen.cursor.y == sr.bottom && screen.cursor.x >= sr.left && screen.cursor.x <= sr.right + { + // Scrolling dirties the images because it updates their placements pins. + screen.kitty_images.dirty = true; + + // If our scrolling region is at the top, we create scrollback. + if sr.top == 0 && sr.left == 0 && sr.right == cols - 1 { + screen.cursor_scroll_above()?; + return Ok(()); + } + + // Slow path for left and right scrolling region margins. + if sr.left != 0 + || sr.right != cols - 1 + // PERF(mitchellh): If we have an SGR background set then + // we need to preserve that background in our erased rows. + // scrollUp does that but eraseRowBounded below does not. + // However, scrollUp is WAY slower. We should optimize this + // case to work in the eraseRowBounded codepath and remove + // this check. + || !screen.blank_cell().is_zero() + { + return self.scroll_up(1); + } + + // Otherwise use a fast path function from PageList to efficiently + // scroll the contents of the scrolling region. + + // Preserve old cursor just for assertions + let (old_x, old_y) = (screen.cursor.x, screen.cursor.y); + + screen.pages.erase_row_bounded( + Point::Active(Coordinate { + x: 0, + y: u32::from(sr.top), + }), + usize::from(sr.bottom - sr.top), + )?; + + // eraseRow and eraseRowBounded will end up moving the cursor pin + // up by 1, so we need to move it back down. A `cursorReload` + // would be better option but this is more efficient and this is + // a super hot path so we do this instead. + assert!(screen.cursor.x == old_x); + assert!(screen.cursor.y == old_y); + screen.cursor.y -= 1; + screen.cursor_down(1); + + // The operations above can prune our cursor style so we need to + // update. This should never fail because the above can only FREE + // memory. + if let Err(err) = screen.manual_style_update() { + log::warn!("deleteLines manualStyleUpdate err={err:?}"); + screen.cursor.style = Style::default(); + screen.manual_style_update().expect("default style update"); + } + + return Ok(()); + } + + // Increase cursor by 1, maximum to bottom of scroll region + if screen.cursor.y < sr.bottom { + screen.cursor_down(1); + } + Ok(()) + } + + /// Move the cursor to the previous line in the scrolling region, possibly + /// scrolling. + /// + /// If the cursor is outside of the scrolling region, move the cursor one + /// line up if it is not on the top-most line of the screen. + /// + /// If the cursor is inside the scrolling region: + /// + /// * If the cursor is on the top-most line of the scrolling region: + /// invoke scroll down with amount=1 + /// * If the cursor is not on the top-most line of the scrolling region: + /// move the cursor one line up + pub fn reverse_index(&mut self) { + let scr = self.screen(); + if scr.cursor.y != self.scrolling_region.top + || scr.cursor.x < self.scrolling_region.left + || scr.cursor.x > self.scrolling_region.right + { + self.cursor_up(1); + return; + } + + self.scroll_down(1); + } + + /// Set Cursor Position. Move cursor to the position indicated + /// by row and column (1-indexed). If column is 0, it is adjusted to 1. + /// If column is greater than the right-most column it is adjusted to + /// the right-most column. If row is 0, it is adjusted to 1. If row is + /// greater than the bottom-most row it is adjusted to the bottom-most + /// row. + pub fn set_cursor_pos(&mut self, row_req: usize, col_req: usize) { + // If cursor origin mode is set the cursor row will be moved relative to + // the top margin row and adjusted to be above or at bottom-most row in + // the current scroll region. + // + // If origin mode is set and left and right margin mode is set the cursor + // will be moved relative to the left margin column and adjusted to be on + // or left of the right margin column. + let (x_offset, y_offset, x_max, y_max): (usize, usize, usize, usize) = + if self.modes.get(Mode::Origin) { + ( + usize::from(self.scrolling_region.left), + usize::from(self.scrolling_region.top), + usize::from(self.scrolling_region.right) + 1, // We need this 1-indexed + usize::from(self.scrolling_region.bottom) + 1, // We need this 1-indexed + ) + } else { + (0, 0, usize::from(self.cols), usize::from(self.rows)) + }; + + // Unset pending wrap state + self.screen_mut().cursor.pending_wrap = false; + + // Calculate our new x/y + let row = if row_req == 0 { 1 } else { row_req }; + let col = if col_req == 0 { 1 } else { col_req }; + let x = x_max.min(col.saturating_add(x_offset)).saturating_sub(1) as size::CellCountInt; + let y = y_max.min(row.saturating_add(y_offset)).saturating_sub(1) as size::CellCountInt; + + // If the y is unchanged then this is fast pointer math + let scr = self.screen_mut(); + if y == scr.cursor.y { + if x > scr.cursor.x { + scr.cursor_right(x - scr.cursor.x); + } else { + scr.cursor_left(scr.cursor.x - x); + } + + return; + } + + // If everything changed we do an absolute change which is slightly slower + scr.cursor_absolute(x, y); + } + + /// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than + /// the number of the bottom-most row, it is adjusted to the number of the + /// bottom most row. + /// + /// If top < bottom set the top and bottom row of the scroll region according + /// to top and bottom and move the cursor to the top-left cell of the display + /// (when in cursor origin mode is set to the top-left cell of the scroll region). + /// + /// Otherwise: Set the top and bottom row of the scroll region to the top-most + /// and bottom-most line of the screen. + /// + /// Top and bottom are 1-indexed. + pub fn set_top_and_bottom_margin(&mut self, top_req: usize, bottom_req: usize) { + let top = top_req.max(1); + let rows = usize::from(self.rows); + let bottom = rows.min(if bottom_req == 0 { rows } else { bottom_req }); + if top >= bottom { + return; + } + + self.scrolling_region.top = (top - 1) as size::CellCountInt; + self.scrolling_region.bottom = (bottom - 1) as size::CellCountInt; + self.set_cursor_pos(1, 1); + } + + /// DECSLRM + pub fn set_left_and_right_margin(&mut self, left_req: usize, right_req: usize) { + // We must have this mode enabled to do anything + if !self.modes.get(Mode::EnableLeftAndRightMargin) { + return; + } + + let left = left_req.max(1); + let cols = usize::from(self.cols); + let right = cols.min(if right_req == 0 { cols } else { right_req }); + if left >= right { + return; + } + + self.scrolling_region.left = (left - 1) as size::CellCountInt; + self.scrolling_region.right = (right - 1) as size::CellCountInt; + self.set_cursor_pos(1, 1); + } + + /// Scroll the text down by one row. + pub fn scroll_down(&mut self, count: usize) { + // Preserve our x/y to restore. + let (old_x, old_y, old_wrap) = { + let c = &self.screen().cursor; + (c.x, c.y, c.pending_wrap) + }; + + // Move to the top of the scroll region + let (l, t) = (self.scrolling_region.left, self.scrolling_region.top); + self.screen_mut().cursor_absolute(l, t); + self.insert_lines(count); + + let scr = self.screen_mut(); + scr.cursor_absolute(old_x, old_y); + scr.cursor.pending_wrap = old_wrap; + } + + /// Removes amount lines from the top of the scroll region. The remaining lines + /// to the bottom margin are shifted up and space from the bottom margin up + /// is filled with empty lines. + /// + /// The new lines are created according to the current SGR state. + /// + /// Does not change the (absolute) cursor position. + pub fn scroll_up(&mut self, count: usize) -> Result<(), Error> { + // Preserve our x/y to restore. + let (old_x, old_y, old_wrap) = { + let c = &self.screen().cursor; + (c.x, c.y, c.pending_wrap) + }; + + let result = self.scroll_up_inner(count); + + let scr = self.screen_mut(); + scr.cursor_absolute(old_x, old_y); + scr.cursor.pending_wrap = old_wrap; + result + } + + fn scroll_up_inner(&mut self, count: usize) -> Result<(), Error> { + let sr = self.scrolling_region; + + // If our scroll region is at the top and we have no left/right + // margins then we move the scrolled out text into the scrollback. + if sr.top == 0 && sr.left == 0 && sr.right == self.cols - 1 { + // Scrolling dirties the images because it updates their placements pins. + self.screen_mut().kitty_images.dirty = true; + + // Clamp count to the scroll region height. + let region_height = usize::from(sr.bottom) + 1; + let adjusted_count = count.min(region_height); + + // TODO: Create an optimized version that can scroll N times + // This isn't critical because in most cases, scrollUp is used + // with count=1, but it's still a big optimization opportunity. + + // Move our cursor to the bottom of the scroll region so we can + // use the cursorScrollAbove function to create scrollback + self.screen_mut().cursor_absolute(0, sr.bottom); + for _ in 0..adjusted_count { + self.screen_mut().cursor_scroll_above()?; + } + return Ok(()); + } + + // Move to the top of the scroll region + self.screen_mut().cursor_absolute(sr.left, sr.top); + self.delete_lines(count); + Ok(()) + } + + /// Scroll the viewport of the terminal grid. + pub fn scroll_viewport(&mut self, behavior: ScrollViewport) { + self.screen_mut().scroll(match behavior { + ScrollViewport::Top => Scroll::Top, + ScrollViewport::Bottom => Scroll::Active, + ScrollViewport::Delta(delta) => Scroll::DeltaRow(delta), + }); + } + + /// Copy columns `[left, right)` of a row of one page to a row of another + /// page in the same list (Zig: `Page.clonePartialRowFrom` across two page + /// nodes). + /// + /// TODO(integration): replace with a `PageList` helper (see + /// ghostty-terminal-pagelist-requests.md). + fn clone_partial_row_between( + &mut self, + src: Pin, + dst: Pin, + left: usize, + right: usize, + ) -> Result<(), Error> { + let len = right - left; + let (source_row, cells) = { + let sp = self.screen().page(src.node); + let source_row = sp.rows[usize::from(src.y)]; + let cells: Vec<_> = (left..right) + .map(|x| { + sp.cell_index(x, usize::from(src.y)) + .map(|i| sp.export_cell(i)) + }) + .collect::>()?; + (source_row, cells) + }; + let dp = self.screen_mut().page_mut(dst.node); + let dy = usize::from(dst.y); + let original_row = dp.rows[dy]; + dp.clear_cells(dy, left, right, false)?; + if source_row.managed_memory() { + for (n, c) in cells.iter().enumerate() { + dp.import_cell(left + n, dy, c)?; + } + } else { + let to = original_row.cells() + left; + for (n, c) in cells.iter().enumerate() { + dp.cells[to + n] = c.cell; + } + } + let managed = dp.rows[dy]; + let mut metadata = source_row; + metadata.set_cells(original_row.cells()); + if len < usize::from(dp.size.cols) { + metadata.set_wrap(original_row.wrap()); + metadata.set_wrap_continuation(original_row.wrap_continuation()); + metadata.set_grapheme(managed.grapheme()); + metadata.set_styled(managed.styled()); + metadata.set_hyperlink(managed.hyperlink()); + metadata.set_dirty(source_row.dirty() || original_row.dirty()); + } + dp.rows[dy] = metadata; + Ok(()) + } + + /// Shared implementation of insert_lines / delete_lines. When `insert` is + /// true rows are shifted down from the cursor (iterating bottom-up), + /// otherwise rows are shifted up (iterating top-down). + fn shift_lines(&mut self, count: usize, insert: bool) { + // Rare, but happens + if count == 0 { + return; + } + + let sr = self.scrolling_region; + + // If the cursor is outside the scroll region we do nothing. + { + let c = &self.screen().cursor; + if c.y < sr.top || c.y > sr.bottom || c.x < sr.left || c.x > sr.right { + return; + } + } + + // Scrolling dirties the images because it updates their placements pins. + self.screen_mut().kitty_images.dirty = true; + + // At the end we need to return the cursor to the row it started on. + let start_y = self.screen().cursor.y; + + // We have a slower path if we have left or right scroll margins. + let left_right = sr.left > 0 || sr.right < self.cols - 1; + + // Remaining rows from our cursor to the bottom of the scroll region. + let rem = usize::from(sr.bottom - self.screen().cursor.y) + 1; + + // We can only insert lines up to our remaining lines in the scroll + // region. So we take whichever is smaller. + let adjusted_count = count.min(rem); + + // Create a new tracked pin which we'll use to navigate the page list + // so that if we need to adjust capacity it will be properly tracked. + let start_pin = if insert { + self.screen() + .cursor_pin() + .down(&self.screen().pages, rem - 1) + .expect("scroll region bottom") + } else { + self.screen().cursor_pin() + }; + let cur_h = match self.screen_mut().pages.track_pin(start_pin) { + Ok(h) => h, + Err(err) => { + // This error scenario means that our GPA is OOM. This is not a + // situation we can gracefully handle. We can't just ignore + // because it'll result in a corrupted screen. + log::error!("shift_lines trackPin error err={err:?}"); + panic!("shift_lines trackPin OOM"); + } + }; + + // Our current y position relative to the cursor + let mut y: usize = if insert { rem } else { 0 }; + + // Traverse from the bottom up (insert) or top down (delete) + while if insert { y > 0 } else { y < rem } { + let cur_p = self.screen().pages.tracked_pin(cur_h).expect("tracked pin"); + + // If this is one of the lines we need to shift, do so + let shift = if insert { + y > adjusted_count + } else { + y < rem - adjusted_count + }; + if shift { + let off_p = if insert { + cur_p.up(&self.screen().pages, adjusted_count) + } else { + cur_p.down(&self.screen().pages, adjusted_count) + } + .expect("shift source row"); + + let sr = self.scrolling_region; + self.row_will_be_shifted_at(cur_p); + self.row_will_be_shifted_at(off_p); + + // If our scrolling region is full width, then we unset wrap. + if !left_right { + let scr = self.screen_mut(); + for p in [off_p, cur_p] { + let row = &mut scr.page_mut(p.node).rows[usize::from(p.y)]; + row.set_wrap(false); + row.set_wrap_continuation(false); + } + } + + let (src_p, dst_p) = (off_p, cur_p); + + // If our page doesn't match, then we need to do a copy from + // one page to another. This is the slow path. + if src_p.node != dst_p.node { + if let Err(err) = self.clone_partial_row_between( + src_p, + dst_p, + usize::from(sr.left), + usize::from(sr.right) + 1, + ) { + // Adjust our page capacity to make + // room for we didn't have space for + let Some(adj) = capacity_adjustment(err) else { + panic!("clone_partial_row_from failed: {err:?}"); + }; + if let Err(e) = self.screen_mut().increase_capacity(dst_p.node, adj) { + match e { + // System OOM. We have no way to recover from this + // currently. + Error::OutOfMemory => { + panic!("increaseCapacity system allocator OOM") + } + // The page can't accommodate the managed memory required + // for this operation. + _ => panic!("increaseCapacity OutOfSpace"), + } + } + + // Continue the loop to try handling this row again. + continue; + } + } else if !left_right { + // Swap the src/dst cells. This ensures that our dst gets the + // proper shifted rows and src gets non-garbage cell data that + // we can clear. + let page = self.screen_mut().page_mut(cur_p.node); + page.rows.swap(usize::from(dst_p.y), usize::from(src_p.y)); + + // Ensure what we did didn't corrupt the page + page.assert_integrity(); + } else { + // Left/right scroll margins we have to + // copy cells, which is much slower... + let page = self.screen_mut().page_mut(cur_p.node); + page.move_cells( + usize::from(src_p.y), + usize::from(sr.left), + usize::from(dst_p.y), + usize::from(sr.left), + usize::from(sr.right - sr.left) + 1, + ) + .expect("move_cells"); + } + } else { + // Clear the cells for this row, it has been shifted (insert) + // or it's from out of bounds (delete). + self.row_will_be_shifted_at(cur_p); + let (l, r) = (usize::from(sr.left), usize::from(sr.right) + 1); + self.screen_mut().clear_cells(cur_p.node, cur_p.y, l, r); + } + + // Mark the row as dirty + cur_p.mark_dirty(&mut self.screen_mut().pages); + + // We have successfully processed a line. + if insert { + y -= 1; + // Move our pin up to the next row. + if let Some(p) = cur_p.up(&self.screen().pages, 1) { + let _ = self.screen_mut().pages.set_tracked_pin(cur_h, p); + } + } else { + y += 1; + // Move our pin down to the next row. + if let Some(p) = cur_p.down(&self.screen().pages, 1) { + let _ = self.screen_mut().pages.set_tracked_pin(cur_h, p); + } + } + } + + let scr = self.screen_mut(); + scr.pages.untrack_pin(cur_h); + scr.cursor_absolute(sr.left, start_y); + + // Always unset pending wrap + scr.cursor.pending_wrap = false; + } + + fn row_will_be_shifted_at(&mut self, p: Pin) { + let (sr, cols) = (self.scrolling_region, self.cols); + let page = self.screens.active_mut().page_mut(p.node); + row_will_be_shifted(sr, cols, page, usize::from(p.y)); + } + + /// Insert amount lines at the current cursor row. The contents of the line + /// at the current cursor row and below (to the bottom-most line in the + /// scrolling region) are shifted down by amount lines. The contents of the + /// amount bottom-most lines in the scroll region are lost. + /// + /// This unsets the pending wrap state without wrapping. If the current cursor + /// position is outside of the current scroll region it does nothing. + /// + /// If amount is greater than the remaining number of lines in the scrolling + /// region it is adjusted down (still allowing for scrolling out every remaining + /// line in the scrolling region) + /// + /// In left and right margin mode the margins are respected; lines are only + /// scrolled in the scroll region. + /// + /// All cleared space is colored according to the current SGR state. + /// + /// Moves the cursor to the left margin. + pub fn insert_lines(&mut self, count: usize) { + self.shift_lines(count, true); + } + + /// Removes amount lines from the current cursor row down. The remaining lines + /// to the bottom margin are shifted up and space from the bottom margin up is + /// filled with empty lines. + /// + /// If the current cursor position is outside of the current scroll region it + /// does nothing. If amount is greater than the remaining number of lines in the + /// scrolling region it is adjusted down. + /// + /// In left and right margin mode the margins are respected; lines are only + /// scrolled in the scroll region. + /// + /// If the cell movement splits a multi cell character that character cleared, + /// by replacing it by spaces, keeping its current attributes. All other + /// cleared space is colored according to the current SGR state. + /// + /// Moves the cursor to the left margin. + pub fn delete_lines(&mut self, count: usize) { + self.shift_lines(count, false); + } +} + +/// Clear a stale `spacer_head` in the last cell of the row above the cursor. +fn clear_prev_row_spacer_head(scr: &mut Screen, cols: size::CellCountInt) { + let Some(mut p) = scr.cursor_pin().up(&scr.pages, 1) else { + return; + }; + p.x = cols - 1; + let page = scr.page_mut(p.node); + if let Ok(i) = page.cell_index(usize::from(p.x), usize::from(p.y)) + && page.cells[i].wide() == Wide::SpacerHead + { + page.cells[i].set_wide(Wide::Narrow); + } +} + +impl Terminal { + /// Inserts spaces at current cursor position moving existing cell contents + /// to the right. The contents of the count right-most columns in the scroll + /// region are lost. The cursor position is not changed. + /// + /// This unsets the pending wrap state without wrapping. + /// + /// The inserted cells are colored according to the current SGR state. + pub fn insert_blanks(&mut self, count: usize) { + // Unset pending wrap state without wrapping. Note: this purposely + // happens BEFORE the scroll region check below, because that's what + // xterm does. + self.screen_mut().cursor.pending_wrap = false; + + // If we're given a zero then we do nothing. The rest of this function + // assumes count > 0 and will crash if zero so return early. Note that + // this shouldn't be possible with real CSI sequences because the value + // is clamped to 1 min. + if count == 0 { + return; + } + + let sr = self.scrolling_region; + let cx = usize::from(self.screen().cursor.x); + + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if self.screen().cursor.x < sr.left || self.screen().cursor.x > sr.right { + return; + } + + let pin = self.screen().cursor_pin(); + let (node, y) = (pin.node, usize::from(pin.y)); + + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if self.screen().cursor_cell().wide() == Wide::SpacerTail { + assert!(cx > 0); + self.screen_mut().clear_cells(node, pin.y, cx - 1, cx + 1); + } + + // Remaining cols from our cursor to the right margin. + let rem = usize::from(sr.right) - cx + 1; + + // If the cell at the right margin is wide, its spacer tail is + // outside the scroll region and would be orphaned by either the + // shift or the clear. Clean up both halves up front. + { + let right = cx + rem - 1; + if self.screen().cursor_row_cells()[right].wide() == Wide::Wide { + self.screen_mut().clear_cells(node, pin.y, right, right + 2); + } + } + + // We can only insert blanks up to our remaining cols + let adjusted_count = count.min(rem); + + // This is the amount of space at the right of the scroll region + // that will NOT be blank, so we need to shift the correct cols right. + // "scroll_amount" is the number of such cols. + let scroll_amount = rem - adjusted_count; + if scroll_amount > 0 { + self.screen_mut() + .page_mut(node) + .pause_integrity_checks(true); + + let end = cx + scroll_amount - 1; + + // If our last cell we're shifting is wide, then we need to clear + // it to be empty so we don't split the multi-cell char. + if self.screen().cursor_row_cells()[end].wide() == Wide::Wide { + debug_assert!(self.screen().cursor_row_cells()[end + 1].wide() == Wide::SpacerTail); + self.screen_mut().clear_cells(node, pin.y, end, end + 2); + } + + // We work backwards so we don't overwrite data. + let page = self.screen_mut().page_mut(node); + for x in (cx..=end).rev() { + page.swap_cells(x, y, x + adjusted_count, y) + .expect("swap_cells"); + } + + self.screen_mut() + .page_mut(node) + .pause_integrity_checks(false); + } + + // Insert blanks. The blanks preserve the background color. + self.screen_mut() + .clear_cells(node, pin.y, cx, cx + adjusted_count); + + // Our row is always dirty + self.screen_mut().cursor_mark_dirty(); + } + + /// Removes amount characters from the current cursor position to the right. + /// The remaining characters are shifted to the left and space from the right + /// margin is filled with spaces. + /// + /// If amount is greater than the remaining number of characters in the + /// scrolling region, it is adjusted down. + /// + /// Does not change the cursor position. + pub fn delete_chars(&mut self, count_req: usize) { + if count_req == 0 { + return; + } + + let sr = self.scrolling_region; + let cx = usize::from(self.screen().cursor.x); + + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if self.screen().cursor.x < sr.left || self.screen().cursor.x > sr.right { + return; + } + + let pin = self.screen().cursor_pin(); + let (node, y) = (pin.node, usize::from(pin.y)); + + // Remaining cols from our cursor to the right margin. + let rem = usize::from(sr.right) - cx + 1; + + // We can only insert blanks up to our remaining cols + let count = count_req.min(rem); + + let scr = self.screen_mut(); + scr.split_cell_boundary(cx as size::CellCountInt); + scr.split_cell_boundary((cx + count) as size::CellCountInt); + scr.split_cell_boundary(sr.right + 1); + + // This is the amount of space at the right of the scroll region + // that will NOT be blank, so we need to shift the correct cols right. + // "scroll_amount" is the number of such cols. + let scroll_amount = rem - count; + if scroll_amount > 0 { + let page = scr.page_mut(node); + page.pause_integrity_checks(true); + for x in cx..cx + scroll_amount { + page.swap_cells(x + count, y, x, y).expect("swap_cells"); + } + page.pause_integrity_checks(false); + } + + // Insert blanks. The blanks preserve the background color. + scr.clear_cells(node, pin.y, cx + scroll_amount, cx + rem); + + // Our row's soft-wrap is always reset. + scr.cursor_reset_wrap(); + + // Our row is always dirty + scr.cursor_mark_dirty(); + } + + pub fn erase_chars(&mut self, count_req: usize) { + let cx = usize::from(self.screen().cursor.x); + let count = { + let remaining = usize::from(self.cols) - cx; + let mut end = remaining.min(count_req.max(1)); + + // If our last cell is a wide char then we need to also clear the + // cell beyond it since we can't just split a wide char. + if end != remaining { + let last = self + .screen() + .cursor_cell_right((end - 1) as size::CellCountInt); + if last.wide() == Wide::Wide { + end += 1; + } + } + + end + }; + + // Handle any boundary conditions on the edges of the erased area. + // + // TODO(qwerasd): This isn't actually correct if you take in to account + // protected modes. We need to figure out how to make `clearCells` or at + // least `clearUnprotectedCells` handle boundary conditions... + let scr = self.screen_mut(); + scr.split_cell_boundary(cx as size::CellCountInt); + scr.split_cell_boundary((cx + count) as size::CellCountInt); + + // Reset our row's soft-wrap. + scr.cursor_reset_wrap(); + + // Mark our cursor row as dirty + scr.cursor_mark_dirty(); + + // Clear the cells + let pin = scr.cursor_pin(); + + // If we never had a protection mode, then we can assume no cells + // are protected and go with the fast path. If the last protection + // mode was not ISO we also always ignore protection attributes. + if scr.protected_mode != ProtectedMode::Iso { + scr.clear_cells(pin.node, pin.y, cx, cx + count); + return; + } + + scr.clear_unprotected_cells(pin.node, pin.y, cx, cx + count); + } + + /// Erase the line. + pub fn erase_line(&mut self, mode: csi::EraseLine, protected_req: bool) { + // Get our start/end positions depending on mode. + let (start, end) = match mode { + csi::EraseLine::Right => { + let mut x = usize::from(self.screen().cursor.x); + + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if x > 0 && self.screen().cursor_cell().wide() == Wide::SpacerTail { + x -= 1; + } + + // Reset our row's soft-wrap. + self.screen_mut().cursor_reset_wrap(); + + (x, usize::from(self.cols)) + } + + csi::EraseLine::Left => { + let mut x = usize::from(self.screen().cursor.x); + + // If our x is a wide char we need to delete the tail too. + if self.screen().cursor_cell().wide() == Wide::Wide { + x += 1; + } + + (0, x + 1) + } + + // Note that it seems like complete should reset the soft-wrap + // state of the line but in xterm it does not. + csi::EraseLine::Complete => (0, usize::from(self.cols)), + + other => { + log::error!("unimplemented erase line mode: {other:?}"); + return; + } + }; + + // All modes will clear the pending wrap state and we know we have + // a valid mode at this point. + let scr = self.screen_mut(); + scr.cursor.pending_wrap = false; + + // We always mark our row as dirty + scr.cursor_mark_dirty(); + + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + let protected = scr.protected_mode == ProtectedMode::Iso || protected_req; + + let pin = scr.cursor_pin(); + + // If we're not respecting protected attributes, we can use a fast-path + // to fill the entire line. + if !protected { + scr.clear_cells(pin.node, pin.y, start, end); + return; + } + + scr.clear_unprotected_cells(pin.node, pin.y, start, end); + } + + /// Erase the display. + pub fn erase_display(&mut self, mode: csi::EraseDisplay, protected_req: bool) { + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + let protected = self.screen().protected_mode == ProtectedMode::Iso || protected_req; + + match mode { + csi::EraseDisplay::ScrollComplete => { + if let Err(err) = self.screen_mut().scroll_clear() { + log::warn!("scroll clear failed, doing a normal clear err={err:?}"); + self.erase_display(csi::EraseDisplay::Complete, protected_req); + return; + } + + // Unsets pending wrap state + self.screen_mut().cursor.pending_wrap = false; + + // Clear all Kitty graphics state for this screen + self.kitty_images_delete_all(); + } + + csi::EraseDisplay::Complete => { + // If we're on the primary screen and our last non-empty row is + // a prompt, then we do a scroll_complete instead. This is a + // heuristic to get the generally desirable behavior that ^L + // at a prompt scrolls the screen contents prior to clearing. + // Most shells send `ESC [ H ESC [ 2 J` so we can't just check + // our current cursor position. See #905 + if self.screens.active_key == ScreenKey::Primary && self.last_row_is_prompt() { + // If we fail, we just fall back to doing a normal clear + // so we don't worry about the error. + let _ = self.screen_mut().scroll_clear(); + } + + // All active area + self.screen_mut() + .clear_rows(Point::Active(Coordinate::default()), None, protected); + + // Unsets pending wrap state + self.screen_mut().cursor.pending_wrap = false; + + // Clear all Kitty graphics state for this screen + self.kitty_images_delete_all(); + + // Cleared screen dirty bit + self.flags.dirty.clear = true; + } + + csi::EraseDisplay::Below => { + // All lines to the right (including the cursor) + self.erase_line(csi::EraseLine::Right, protected_req); + + // All lines below + if self.screen().cursor.y + 1 < self.rows { + let y = u32::from(self.screen().cursor.y) + 1; + self.screen_mut().clear_rows( + Point::Active(Coordinate { x: 0, y }), + None, + protected, + ); + } + + // Unsets pending wrap state. Should be done by erase_line. + assert!(!self.screen().cursor.pending_wrap); + } + + csi::EraseDisplay::Above => { + // Erase to the left (including the cursor) + self.erase_line(csi::EraseLine::Left, protected_req); + + // All lines above + if self.screen().cursor.y > 0 { + let y = u32::from(self.screen().cursor.y) - 1; + self.screen_mut().clear_rows( + Point::Active(Coordinate { x: 0, y: 0 }), + Some(Point::Active(Coordinate { x: 0, y })), + protected, + ); + } + + // Unsets pending wrap state + assert!(!self.screen().cursor.pending_wrap); + } + + csi::EraseDisplay::Scrollback => self + .screen_mut() + .erase_rows(Point::History(Coordinate::default()), None), + } + } + + /// The "are we at a prompt" heuristic of `erase_display(.complete)`. + fn last_row_is_prompt(&self) -> bool { + let scr = self.screen(); + let Some(active_br) = scr.pages.get_bottom_right(crate::point::Tag::Active) else { + return false; + }; + let top = scr.pages.get_top_left(crate::point::Tag::Active); + // Go from the bottom of the active up and see if we're + // at a prompt. + // (The Zig loop decides on the first row it sees.) + let Some(p) = pin_iter::row_iterator(&scr.pages, active_br, Direction::LeftUp, top).next() + else { + return false; + }; + let row = p.row_and_cell(&scr.pages).expect("row").0; + match row.semantic_prompt() { + // If we're at a prompt or input area, then we are at a prompt. + RowPrompt::Prompt | RowPrompt::PromptContinuation => true, + + // If we have command output, then we're most certainly not + // at a prompt. + RowPrompt::None => false, + } + } + + /// Delete all kitty graphics state for the active screen. + /// + /// TODO(integration): call `kitty_images.delete(.{ .all = true })` once + /// kitty graphics storage is ported; for now only the dirty flag exists. + fn kitty_images_delete_all(&mut self) { + self.screen_mut().kitty_images.dirty = true; + } + + /// Resets all margins and fills the whole screen with the character 'E' + /// + /// Sets the cursor to the top left corner. + pub fn decaln(&mut self) -> Result<(), Error> { + // Clear our stylistic attributes. This is the only thing that can + // fail so we do it first so we can undo it. + let old_style = self.screen().cursor.style; + { + let scr = self.screen_mut(); + scr.cursor.style = Style { + bg_color: scr.cursor.style.bg_color, + fg_color: scr.cursor.style.fg_color, + ..Style::default() + }; + if let Err(e) = scr.manual_style_update() { + scr.cursor.style = old_style; + return Err(e); + } + } + + // Reset margins, also sets cursor to top-left + self.scrolling_region = ScrollingRegion { + top: 0, + bottom: self.rows - 1, + left: 0, + right: self.cols - 1, + }; + + // Origin mode is disabled + self.modes.set(Mode::Origin, false); + + // Move our cursor to the top-left + self.set_cursor_pos(1, 1); + + // Use clearRows instead of eraseDisplay because we must NOT respect + // protected attributes here. + self.screen_mut() + .clear_rows(Point::Active(Coordinate::default()), None, false); + + // Fill with Es by moving the cursor but reset it after. + let rows = self.rows; + loop { + let scr = self.screen_mut(); + let pin = scr.cursor_pin(); + let style_id = scr.cursor.style_id; + let page = scr.page_mut(pin.node); + let y = usize::from(pin.y); + let base = page.rows[y].cells(); + let cols = usize::from(page.size.cols); + let mut e = Cell::init(u32::from('E')); + e.set_style_id(style_id); + // DECALN does not respect protected state. Verified with xterm. + e.set_protected(false); + page.cells[base..base + cols].fill(e); + + // If we have a ref-counted style, increase + if style_id != style::DEFAULT_ID { + let _ = page.styles.use_multiple(style_id, cols as u16); + page.rows[y].set_styled(true); + } + + // We messed with the page so assert its integrity here. + page.assert_integrity(); + + scr.cursor_mark_dirty(); + if scr.cursor.y == rows - 1 { + break; + } + scr.cursor_down(1); + } + + // Reset the cursor to the top-left + self.set_cursor_pos(1, 1); + Ok(()) + } + + /// Execute a kitty graphics command. The response (if any) is a full, + /// valid APC sequence payload to send to the pty. + /// + /// If an error occurs, the caller should response to the pty that a + /// an error occurred otherwise the behavior of the graphics protocol is + /// undefined. + /// + /// TODO(integration): call `kitty::graphics::execute(self, cmd)` once + /// kitty graphics_exec is ported. Until then no command has any effect and + /// no response is produced. + pub fn kitty_graphics( + &mut self, + cmd: &mut kitty_graphics_command::Command, + ) -> Option { + let _ = cmd; + None + } + + /// Set a style attribute. + pub fn set_attribute(&mut self, attr: sgr::Attribute<'_>) -> Result<(), Error> { + self.screen_mut().set_attribute(attr) + } + + /// Print the active attributes as a string. This is used to respond to DECRQSS + /// requests. + /// + /// Boolean attributes are printed first, followed by foreground color, then + /// background color. Each attribute is separated by a semicolon. + pub fn print_attributes(&self) -> String { + use std::fmt::Write; + // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS + let mut out = String::from("0"); + + let pen = self.screen().cursor.style; + let mut attrs = Vec::with_capacity(8); + + if pen.flags.bold { + attrs.push('1'); + } + if pen.flags.faint { + attrs.push('2'); + } + if pen.flags.italic { + attrs.push('3'); + } + if pen.flags.underline != ghostty_vt::sgr::Underline::None { + attrs.push('4'); + } + if pen.flags.blink { + attrs.push('5'); + } + if pen.flags.inverse { + attrs.push('7'); + } + if pen.flags.invisible { + attrs.push('8'); + } + if pen.flags.strikethrough { + attrs.push('9'); + } + + for c in attrs { + let _ = write!(out, ";{c}"); + } + + match pen.fg_color { + StyleColor::None => {} + StyleColor::Palette(idx) => { + if idx >= 16 { + let _ = write!(out, ";38:5:{idx}"); + } else if idx >= 8 { + let _ = write!(out, ";9{}", idx - 8); + } else { + let _ = write!(out, ";3{idx}"); + } + } + StyleColor::Rgb(rgb) => { + let _ = write!(out, ";38:2::{}:{}:{}", rgb.r, rgb.g, rgb.b); + } + } + + match pen.bg_color { + StyleColor::None => {} + StyleColor::Palette(idx) => { + if idx >= 16 { + let _ = write!(out, ";48:5:{idx}"); + } else if idx >= 8 { + let _ = write!(out, ";10{}", idx - 8); + } else { + let _ = write!(out, ";4{idx}"); + } + } + StyleColor::Rgb(rgb) => { + let _ = write!(out, ";48:2::{}:{}:{}", rgb.r, rgb.g, rgb.b); + } + } + + out + } + + /// DECCOLM changes the terminal width between 80 and 132 columns. This + /// function call will do NOTHING unless `setDeccolmSupported` has been + /// called with "true". + /// + /// This breaks the expectation around modern terminals that they resize + /// with the window. This will fix the grid at either 80 or 132 columns. + /// The rows will continue to be variable. + pub fn deccolm(&mut self, mode: DeccolmMode) -> Result<(), Error> { + // If DEC mode 40 isn't enabled, then this is ignored. We also make + // sure that we don't have deccolm set because we want to fully ignore + // set mode. + if !self.modes.get(Mode::EnableMode3) { + self.modes.set(Mode::M132Column, false); + return Ok(()); + } + + // Enable it + self.modes + .set(Mode::M132Column, mode == DeccolmMode::Cols132); + + // Resize to the requested size + let rows = self.rows; + self.resize( + match mode { + DeccolmMode::Cols132 => 132, + DeccolmMode::Cols80 => 80, + }, + rows, + )?; + + // Erase our display and move our cursor. + self.erase_display(csi::EraseDisplay::Complete, false); + self.set_cursor_pos(1, 1); + Ok(()) + } + + /// Resize the underlying terminal. + pub fn resize( + &mut self, + cols: size::CellCountInt, + rows: size::CellCountInt, + ) -> Result<(), Error> { + // If our cols/rows didn't change then we're done + if self.cols == cols && self.rows == rows { + return Ok(()); + } + + // Resize our tabstops + if self.cols != cols { + self.tabstops = Tabstops::new(usize::from(cols), 8).map_err(map_tabstops_err)?; + } + + // Resize primary screen, which supports reflow + let reflow = self.modes.get(Mode::Wraparound); + let prompt_redraw = self.flags.shell_redraws_prompt; + let primary = self.screens.get_mut(ScreenKey::Primary).expect("primary"); + primary.resize(ScreenResize { + cols, + rows, + reflow, + prompt_redraw, + })?; + + // Alternate screen, if it exists, doesn't reflow + if let Some(alt) = self.screens.get_mut(ScreenKey::Alternate) { + alt.resize(ScreenResize { + cols, + rows, + reflow: false, + prompt_redraw: Redraw::False, + })?; + } + + // Whenever we resize we just mark it as a screen clear + self.flags.dirty.clear = true; + + // Set our size + self.cols = cols; + self.rows = rows; + + // Reset the scrolling region + self.scrolling_region = ScrollingRegion { + top: 0, + bottom: rows - 1, + left: 0, + right: cols - 1, + }; + Ok(()) + } + + /// Set the pwd for the terminal. + pub fn set_pwd(&mut self, pwd: &[u8]) -> Result<(), Error> { + self.pwd.clear(); + self.pwd.extend_from_slice(pwd); + Ok(()) + } + + /// Returns the pwd for the terminal, if any. The memory is owned by the + /// Terminal and is not copied. It is safe until a reset or set_pwd. + pub fn get_pwd(&self) -> Option<&[u8]> { + if self.pwd.is_empty() { + return None; + } + Some(&self.pwd) + } + + /// Switch to the given screen type (alternate or primary). + /// + /// This does NOT handle behaviors such as clearing the screen, + /// copying the cursor, etc. This should be handled by downstream + /// callers. + /// + /// After calling this function, the active screen is `key`, and the + /// returned value will be the key of the previous screen. If the return + /// value is `None`, then the screen was not switched because it was + /// already the active screen. + /// + /// Note: This is written in a generic way so that we can support + /// more than two screens in the future if needed. There isn't + /// currently a spec for this, but it is something I think might + /// be useful in the future. + pub fn switch_screen(&mut self, key: ScreenKey) -> Result, Error> { + // If we're already on the requested screen we do nothing. + if self.screens.active_key == key { + return Ok(None); + } + let old_key = self.screens.active_key; + + // We always end hyperlink state when switching screens. + // We need to do this on the original screen. + self.screens.active_mut().end_hyperlink(); + + // Switch the screens/ + if self.screens.get(key).is_none() { + let primary = self.screens.get(ScreenKey::Primary).expect("primary"); + let opts = ScreenOptions { + cols: self.cols, + rows: self.rows, + max_scrollback: match key { + ScreenKey::Primary => primary.pages.explicit_max_size, + ScreenKey::Alternate => 0, + }, + + // Inherit our Kitty image storage limit from the primary + // screen if we have to initialize. + // TODO(integration): `primary.kitty_images.total_limit`. + kitty_image_storage_limit: ScreenOptions::DEFAULT.kitty_image_storage_limit, + }; + self.screens.get_init(key, opts)?; + } + let old_charset = self.screens.active().charset; + let new = self.screens.get_mut(key).expect("screen exists"); + + // The new screen should not have any hyperlinks set + assert!(new.cursor.hyperlink_id == 0); + + // Bring our charset state with us + new.charset = old_charset; + + // Clear our selection + new.clear_selection(); + + // Mark kitty images as dirty so they redraw. Without this set + // the images will remain where they were (the dirty bit on + // the screen only tracks the terminal grid, not the images). + new.kitty_images.dirty = true; + + // Mark our terminal as dirty to redraw the grid. + self.flags.dirty.clear = true; + + // Finalize the switch + self.screens.switch_to(key); + + Ok(Some(old_key)) + } + + /// Switch screen via a mode switch (e.g. mode 47, 1047, 1049). + /// This is a much more opinionated operation than `switch_screen` + /// since it also handles the behaviors of the specific mode, + /// such as clearing the screen, saving/restoring the cursor, + /// etc. + /// + /// This should be used for legacy compatibility with VT protocols, + /// but more modern usage should use `switch_screen` instead and handle + /// details like clearing the screen, cursor saving, etc. manually. + pub fn switch_screen_mode( + &mut self, + mode: SwitchScreenMode, + enabled: bool, + ) -> Result<(), Error> { + // The behavior in this function is completely based on reading + // the xterm source, specifically "charproc.c" for + // `srm_ALTBUF`, `srm_OPT_ALTBUF`, and `srm_OPT_ALTBUF_CURSOR`. + // We shouldn't touch anything in here without adding a unit + // test AND verifying the behavior with xterm. + + match mode { + SwitchScreenMode::M47 => {} + + // If we're disabling 1047 and we're on alt screen then + // we clear the screen. + SwitchScreenMode::M1047 => { + if !enabled && self.screens.active_key == ScreenKey::Alternate { + self.erase_display(csi::EraseDisplay::Complete, false); + } + } + + // 1049 unconditionally saves the cursor on enabling, even + // if we're already on the alternate screen. + SwitchScreenMode::M1049 => { + if enabled { + self.save_cursor(); + } + } + } + + // Switch screens first to whatever we're going to. + let to = if enabled { + ScreenKey::Alternate + } else { + ScreenKey::Primary + }; + let old = self.switch_screen(to)?; + + match mode { + // For these modes, we need to copy the cursor. We only copy + // the cursor if the screen actually changed, otherwise the + // cursor is already copied. The cursor is copied regardless + // of destination screen. + SwitchScreenMode::M47 | SwitchScreenMode::M1047 => { + if let Some(old) = old { + self.copy_cursor_from(old); + } + } + + // Mode 1049 restores cursor on the primary screen when + // we disable it. + SwitchScreenMode::M1049 => { + if enabled { + assert!(self.screens.active_key == ScreenKey::Alternate); + self.erase_display(csi::EraseDisplay::Complete, false); + + // When we enter alt screen with 1049, we always copy the + // cursor from the primary screen (if we weren't already + // on it). + if let Some(old) = old { + self.copy_cursor_from(old); + } + } else { + assert!(self.screens.active_key == ScreenKey::Primary); + self.restore_cursor(); + } + } + } + Ok(()) + } + + fn copy_cursor_from(&mut self, old: ScreenKey) { + let other = self.screens.get(old).expect("old screen").cursor.clone(); + if let Err(err) = self.screen_mut().cursor_copy(&other, false) { + log::warn!("cursor copy failed entering alt screen err={err:?}"); + } + } + + /// Return the current string value of the terminal. Newlines are + /// encoded as "\n". This omits any formatting such as fg/bg. + pub fn plain_string(&self) -> Result { + self.screen() + .dump_string_alloc(Point::Viewport(Coordinate::default())) + } + + /// Same as plain_string, but respects row wrap state when building the string. + pub fn plain_string_unwrapped(&self) -> Result { + self.screen() + .dump_string_alloc_unwrapped(Point::Viewport(Coordinate::default())) + } + + /// Full reset. + /// + /// This will attempt to free the existing screen memory but if that fails + /// this will reuse the existing memory. In the latter case, memory may + /// be wasted (since its unused) but it isn't leaked. + pub fn full_reset(&mut self) { + // Ensure we're back on primary screen + self.screens.switch_to(ScreenKey::Primary); + self.screens.remove(ScreenKey::Alternate); + + // Reset our screens + if let Err(err) = self.screen_mut().reset() { + log::warn!("screen reset failed err={err:?}"); + } + + // Rest our basic state + self.modes.reset(); + self.flags = Flags::default(); + self.tabstops.reset(TABSTOP_INTERVAL); + self.previous_char = None; + self.pwd.clear(); + self.status_display = StatusDisplay::Main; + self.scrolling_region = ScrollingRegion { + top: 0, + bottom: self.rows - 1, + left: 0, + right: self.cols - 1, + }; + + // Always mark dirty so we redraw everything + self.flags.dirty.clear = true; + } + + /// Returns true if the point is dirty, used for testing. + pub fn is_dirty(&self, pt: Point) -> bool { + let scr = self.screen(); + let pin = scr.pages.pin(pt).expect("valid point"); + pin.is_dirty(&scr.pages) + } + + /// Clear all dirty bits. Testing only. + pub fn clear_dirty(&mut self) { + self.screen_mut().pages.clear_dirty(); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostty-terminal/src/terminal/tests/glitch.txt b/crates/ghostty-terminal/src/terminal/tests/glitch.txt new file mode 100644 index 0000000..f401c08 --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/glitch.txt @@ -0,0 +1 @@ +Ḡ̴͎͍̜͎͔͕̩̗͕͖̟̜͑̊̌̇̑͒͋͑̄̈́͐̈́́̽͌͂̎̀̔͋̓́̅̌̇͘̕͜͝͝h̶̡̞̫͉̳̬̜̱̥͕͑̾͛̒̆̒̉̒̑͂̄͘ͅǫ̷̨̥͔͔͖̭͚͙̯̟̭̘͇̫̰͚̺̳̙̳̟͚̫̱̹̱͒̂̑͒͜͠ͅş̴̖̰̜̱̹͙̅͒̀̏͆̐̋͂̓͋̃̈̔̂̈͛̐̿̔́̔̄͑̇͑̋̈́͌͋̾̃̽̈́̕͘̚͘͘͘͠͠t̵̢̜̱̦͇͉̬̮̼͖̳̗̥̝̬͇͕̥̜͕̳̱̥̮͉̮̩̘̰̪̤͉͎̲͈͍̳̟̠͈̝̫͋̊̀͐̍̅̀̄̃̈́̔̇̈́̄̃̽̂̌̅̄̋͒̃̈́̍̀̍̇̽̐͊̾̆̅̈̿̓͒̄̾͌̚͝͝͝͝͝t̴̥̼̳̗̬̬͔͎̯͉͇̮̰͖͇̝͔̳̳̗̰͇͎͉̬͇̝̺̯͎͖͔̍͆͒̊̒̔̊̈́̿̊̅͂̐͋̿͂̈̒̄͜͠͠ÿ̴̢̗̜̥͇͖̰͎̝̹̗̪̙̞̣̳͎̯̹͚̲̝̗̳̳̗̖͎̗̬͈͙̝̟͍̥̤͖͇̰͈̺͛̒̂͌̌̏̈̾̓̈́̿͐̂̓̔̓̂̈́͑͛͊͋̔̿̊͑͌̊̏͘͘̕͘͠͝ diff --git a/crates/ghostty-terminal/src/terminal/tests/mod.rs b/crates/ghostty-terminal/src/terminal/tests/mod.rs new file mode 100644 index 0000000..8bf3fbf --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/mod.rs @@ -0,0 +1,90 @@ +//! Tests ported from `Terminal.zig`, split into chunks (`p1`..`p8`) in source +//! order. Shared helpers live here. +#![allow(dead_code, unused_imports)] +pub(super) use super::*; +pub(super) use crate::point::{Coordinate, Point}; + +mod p1; +mod p2; +mod p3; +mod p4; +mod p5; +mod p6; +mod p7; +mod p8; + +/// `Terminal.init(alloc, .{ .cols, .rows })` (max_scrollback keeps the default). +pub(super) fn init(cols: u16, rows: u16) -> Terminal { + Terminal::new(Options::new(cols, rows)).unwrap() +} + +/// Like `init` but with an explicit max_scrollback (bytes). +pub(super) fn init_scrollback(cols: u16, rows: u16, max_scrollback: usize) -> Terminal { + let mut o = Options::new(cols, rows); + o.max_scrollback = max_scrollback; + Terminal::new(o).unwrap() +} + +pub(super) fn pt_active(x: u16, y: u32) -> Point { + Point::Active(Coordinate { x, y }) +} +pub(super) fn pt_screen(x: u16, y: u32) -> Point { + Point::Screen(Coordinate { x, y }) +} +pub(super) fn pt_viewport(x: u16, y: u32) -> Point { + Point::Viewport(Coordinate { x, y }) +} +pub(super) fn pt_history(x: u16, y: u32) -> Point { + Point::History(Coordinate { x, y }) +} + +/// Zig `try t.printString(str)`. +pub(super) fn print_str(t: &mut Terminal, s: &str) { + t.print_string(s).unwrap(); +} + +/// Feed raw bytes through a fresh read-only VT stream (Zig +/// `var s = t.vtStream(); try s.nextSlice(...)`). Parser state does not +/// persist across calls; create a stream with `t.vt_stream()` if it must. +pub(super) fn feed(t: &mut Terminal, bytes: &[u8]) { + let mut s = t.vt_stream(); + s.next_slice(bytes).unwrap(); +} + +/// Zig `t.plainString(alloc)`. +pub(super) fn plain(t: &Terminal) -> String { + t.plain_string().unwrap() +} + +/// Zig `t.plainStringUnwrapped(alloc)`. +pub(super) fn plain_unwrapped(t: &Terminal) -> String { + t.plain_string_unwrapped().unwrap() +} + +/// Zig `t.screens.active.pages.getCell(pt).?` (a copy of the cell). +pub(super) fn cell_at(t: &Terminal, pt: Point) -> crate::page::Cell { + t.screen().pages.get_cell(pt).unwrap() +} + +/// Zig `t.screens.active.dumpStringAlloc(alloc, pt)`. +pub(super) fn dump(t: &Terminal, pt: Point) -> String { + t.screen().dump_string_alloc(pt).unwrap() +} + +/// Zig `t.screens.active.pages.pin(pt).?`. +pub(super) fn pin_at(t: &Terminal, pt: Point) -> crate::page_list::Pin { + t.screen().pages.pin(pt).unwrap() +} + +/// Zig `pin.rowAndCell().row` for a point (copy of the row). +pub(super) fn row_at(t: &Terminal, pt: Point) -> crate::page::Row { + *pin_at(t, pt).row_and_cell(&t.screen().pages).unwrap().0 +} + +/// Zig `pin.node.data.lookupGrapheme(cell)` for a point: extra codepoints. +pub(super) fn graphemes_at(t: &Terminal, pt: Point) -> Option> { + let p = pin_at(t, pt); + let page = t.screen().page(p.node); + let i = page.cell_index(usize::from(p.x), usize::from(p.y)).unwrap(); + page.lookup_grapheme(i).map(<[u32]>::to_vec) +} diff --git a/crates/ghostty-terminal/src/terminal/tests/p1.rs b/crates/ghostty-terminal/src/terminal/tests/p1.rs new file mode 100644 index 0000000..f71a099 --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/p1.rs @@ -0,0 +1,1211 @@ +use super::*; +use crate::page::{Cell, Wide}; +use ghostty_vt::color::Rgb; +use ghostty_vt::sgr::Attribute; + +fn c(t: &Terminal, x: u16, y: u32) -> Cell { + cell_at(t, pt_screen(x, y)) +} + +fn dirty(t: &Terminal, x: u16, y: u32) -> bool { + t.is_dirty(pt_screen(x, y)) +} + +fn print_all(t: &mut Terminal, cps: &[u32]) { + for &cp in cps { + t.print(cp).unwrap(); + } +} + +fn cur(t: &Terminal) -> (u16, u16) { + (t.screen().cursor.x, t.screen().cursor.y) +} + +#[test] +fn terminal_input_with_no_control_characters() { + let mut t = init(40, 40); + + for ch in "hello".chars() { + t.print(ch as u32).unwrap(); + } + assert_eq!(0, t.screen().cursor.y); + assert_eq!(5, t.screen().cursor.x); + assert_eq!("hello", plain(&t)); + + // The first row should be dirty + assert!(dirty(&t, 5, 0)); + assert!(!dirty(&t, 5, 1)); +} + +#[test] +fn terminal_input_with_basic_wraparound() { + let mut t = init(5, 40); + + for ch in "helloworldabc12".chars() { + t.print(ch as u32).unwrap(); + } + assert_eq!(2, t.screen().cursor.y); + assert_eq!(4, t.screen().cursor.x); + assert!(t.screen().cursor.pending_wrap); + assert_eq!("hello\nworld\nabc12", plain(&t)); +} + +#[test] +fn terminal_input_with_basic_wraparound_dirty() { + let mut t = init(5, 40); + + for ch in "hello".chars() { + t.print(ch as u32).unwrap(); + } + assert!(dirty(&t, 4, 0)); + t.clear_dirty(); + t.print('w' as u32).unwrap(); + + // Old row is dirty because cursor moved from there + assert!(dirty(&t, 4, 0)); + assert!(dirty(&t, 0, 1)); +} + +#[test] +fn terminal_input_that_forces_scroll() { + let mut t = init(1, 5); + + for ch in "abcdef".chars() { + t.print(ch as u32).unwrap(); + } + assert_eq!(4, t.screen().cursor.y); + assert_eq!(0, t.screen().cursor.x); + assert_eq!("b\nc\nd\ne\nf", plain(&t)); +} + +#[test] +fn terminal_input_unique_style_per_cell() { + let mut t = init(30, 30); + + for y in 0..t.rows { + for x in 0..t.cols { + t.set_cursor_pos(usize::from(y), usize::from(x)); + t.set_attribute(Attribute::DirectColorBg(Rgb { + r: x as u8, + g: y as u8, + b: 0, + })) + .unwrap(); + t.print('x' as u32).unwrap(); + } + } +} + +#[test] +fn terminal_input_glitch_text() { + let glitch = include_str!("glitch.txt"); + let mut t = init(30, 30); + + // Get our initial grapheme capacity. + let first = |t: &Terminal| t.screen().pages.first.unwrap(); + let grapheme_cap = t.screen().page(first(&t)).capacity.grapheme_bytes; + + // Print glitch text until our capacity changes + loop { + let node = first(&t); + if t.screen().page(node).capacity.grapheme_bytes != grapheme_cap { + break; + } + t.print_string(glitch).unwrap(); + } + + // We're testing to make sure that grapheme capacity gets increased. + let node = first(&t); + assert!(t.screen().page(node).capacity.grapheme_bytes > grapheme_cap); +} + +#[test] +fn terminal_zero_width_character_at_start() { + let mut t = init(80, 80); + + // This used to crash the terminal. This is not allowed so we should + // just ignore it. + t.print(0x200D).unwrap(); + + assert_eq!((0, 0), cur(&t)); + + // Should not be dirty since we changed nothing. + assert!(!dirty(&t, 0, 0)); +} + +// https://github.com/mitchellh/ghostty/issues/1400 +#[test] +fn terminal_print_single_very_long_line() { + let mut t = init(5, 5); + + // This would crash for issue 1400. So the assertion here is + // that we simply do not crash. + for _ in 0..1000 { + t.print('x' as u32).unwrap(); + } +} + +#[test] +fn terminal_print_wide_char() { + let mut t = init(80, 80); + + t.print(0x1F600).unwrap(); // Smiley face + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!(0x1F600, cell.codepoint()); + assert_eq!(Wide::Wide, cell.wide()); + assert_eq!(Wide::SpacerTail, c(&t, 1, 0).wide()); + + assert!(dirty(&t, 0, 0)); +} + +#[test] +fn terminal_print_wide_char_at_edge_creates_spacer_head() { + let mut t = init(10, 10); + + t.set_cursor_pos(1, 10); + t.print(0x1F600).unwrap(); // Smiley face + assert_eq!(1, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + assert_eq!(Wide::SpacerHead, c(&t, 9, 0).wide()); + let cell = c(&t, 0, 1); + assert_eq!(0x1F600, cell.codepoint()); + assert_eq!(Wide::Wide, cell.wide()); + assert_eq!(Wide::SpacerTail, c(&t, 1, 1).wide()); + + // Old row is dirty because cursor moved from there + assert!(dirty(&t, 0, 0)); + assert!(dirty(&t, 0, 1)); +} + +#[test] +fn terminal_print_wide_char_with_1_column_width() { + let mut t = init(1, 2); + + t.print(0x1F600).unwrap(); + + // This prints a space so we should be dirty. + assert!(dirty(&t, 0, 0)); +} + +#[test] +fn terminal_print_wide_char_in_single_width_terminal() { + let mut t = init(1, 80); + + t.print(0x1F600).unwrap(); // Smiley face + assert_eq!((0, 0), cur(&t)); + assert!(t.screen().cursor.pending_wrap); + + let cell = c(&t, 0, 0); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + + assert!(dirty(&t, 0, 0)); +} + +#[test] +fn terminal_print_over_wide_char_at_0_0() { + let mut t = init(80, 80); + + t.print(0x1F600).unwrap(); // Smiley face + t.set_cursor_pos(0, 0); + t.print('A' as u32).unwrap(); + + assert_eq!((1, 0), cur(&t)); + + let cell = c(&t, 0, 0); + assert_eq!('A' as u32, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + let cell = c(&t, 1, 0); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + + assert!(dirty(&t, 0, 0)); + assert!(!dirty(&t, 0, 1)); +} + +#[test] +fn terminal_print_over_wide_char_at_col_0_corrupts_previous_row() { + // Crash found by AFL++ fuzzer (afl-out/stream/default/crashes/id:000002). + // + // printCell, when overwriting a wide cell with a narrow cell at x<=1 + // and y>0, sets the last cell of the previous row to .narrow — even + // when that cell is a .spacer_tail rather than a .spacer_head. This + // orphans the .wide cell at cols-2. + let mut t = init(10, 3); + + // Fill rows 0 and 1 with wide chars (5 per row on a 10-col terminal). + for _ in 0..10 { + t.print(0x4E2D).unwrap(); + } + + // Move cursor to row 1, col 0 (on top of a wide char) and print a + // narrow character. + t.set_cursor_pos(2, 1); + t.print('A' as u32).unwrap(); + + // Row 1, col 0 should be narrow (we just overwrote the wide char). + assert_eq!(Wide::Narrow, c(&t, 0, 1).wide()); + // Row 0, col 8 should still be .wide (the last wide char on the row). + assert_eq!(Wide::Wide, c(&t, 8, 0).wide()); + // Row 0, col 9 must remain .spacer_tail to pair with the .wide at col 8. + assert_eq!(Wide::SpacerTail, c(&t, 9, 0).wide()); +} + +#[test] +fn terminal_print_over_wide_spacer_tail() { + let mut t = init(5, 5); + + t.print('橋' as u32).unwrap(); + t.set_cursor_pos(1, 2); + t.print('X' as u32).unwrap(); + + let cell = c(&t, 0, 0); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + let cell = c(&t, 1, 0); + assert_eq!('X' as u32, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + + assert_eq!(" X", plain(&t)); + + assert!(dirty(&t, 0, 0)); +} + +#[test] +fn terminal_print_over_wide_char_with_bold() { + let mut t = init(80, 80); + + t.set_attribute(Attribute::Bold).unwrap(); + t.print(0x1F600).unwrap(); // Smiley face + // verify we have styles in our style map + assert_eq!(1, t.screen().cursor_page().styles.count()); + + // Go back and overwrite with no style + t.set_cursor_pos(0, 0); + t.set_attribute(Attribute::Unset).unwrap(); + t.print('A' as u32).unwrap(); // Smiley face + + // verify our style is gone + assert_eq!(0, t.screen().cursor_page().styles.count()); + + assert!(dirty(&t, 0, 0)); +} + +#[test] +fn terminal_print_over_wide_char_with_bg_color() { + let mut t = init(80, 80); + + t.set_attribute(Attribute::DirectColorBg(Rgb { + r: 0xFF, + g: 0, + b: 0, + })) + .unwrap(); + t.print(0x1F600).unwrap(); // Smiley face + // verify we have styles in our style map + assert_eq!(1, t.screen().cursor_page().styles.count()); + + // Go back and overwrite with no style + t.set_cursor_pos(0, 0); + t.set_attribute(Attribute::Unset).unwrap(); + t.print('A' as u32).unwrap(); // Smiley face + + // verify our style is gone + assert_eq!(0, t.screen().cursor_page().styles.count()); + + assert!(dirty(&t, 0, 0)); +} + +#[test] +fn terminal_print_multicodepoint_grapheme_disabled_mode_2027() { + let mut t = init(80, 80); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: 👨‍👩‍👧 (which may or may not render correctly) + print_all(&mut t, &[0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467]); + + // We should have 6 cells taken up + assert_eq!(0, t.screen().cursor.y); + assert_eq!(6, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!(0x1F468, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); + assert_eq!(1, graphemes_at(&t, pt_screen(0, 0)).unwrap().len()); + + let cell = c(&t, 1, 0); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerTail, cell.wide()); + assert!(graphemes_at(&t, pt_screen(1, 0)).is_none()); + + let cell = c(&t, 2, 0); + assert_eq!(0x1F469, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); + assert_eq!(1, graphemes_at(&t, pt_screen(2, 0)).unwrap().len()); + + let cell = c(&t, 3, 0); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerTail, cell.wide()); + assert!(graphemes_at(&t, pt_screen(3, 0)).is_none()); + + let cell = c(&t, 4, 0); + assert_eq!(0x1F467, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); + assert!(graphemes_at(&t, pt_screen(4, 0)).is_none()); + + let cell = c(&t, 5, 0); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerTail, cell.wide()); + assert!(graphemes_at(&t, pt_screen(5, 0)).is_none()); + + assert!(dirty(&t, 0, 0)); +} + +#[test] +fn terminal_vs16_doesnt_make_character_with_2027_disabled() { + let mut t = init(5, 5); + + // Disable grapheme clustering + t.modes.set(Mode::GraphemeCluster, false); + + t.print(0x2764).unwrap(); // Heart + t.print(0xFE0F).unwrap(); // VS16 to make wide + + assert_eq!("❤️", plain(&t)); + + let cell = c(&t, 0, 0); + assert_eq!(0x2764, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + assert_eq!(1, graphemes_at(&t, pt_screen(0, 0)).unwrap().len()); +} + +#[test] +fn terminal_ignored_vs16_doesnt_mark_dirty() { + let mut t = init(5, 5); + + // Disable grapheme clustering + t.modes.set(Mode::GraphemeCluster, false); + + t.print(0x2764).unwrap(); // Heart + assert!(dirty(&t, 0, 0)); + + t.clear_dirty(); + t.print(0xFE0F).unwrap(); // VS16 to make wide + assert!(!dirty(&t, 0, 0)); +} + +#[test] +fn terminal_print_invalid_vs16_non_grapheme() { + let mut t = init(80, 80); + + // https://github.com/mitchellh/ghostty/issues/1482 + t.print('x' as u32).unwrap(); + t.print(0xFE0F).unwrap(); + + // We should have 1 narrow cell. + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!('x' as u32, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + assert_eq!(0, c(&t, 1, 0).codepoint()); +} + +#[test] +fn terminal_invalid_vs16_doesnt_mark_dirty() { + let mut t = init(5, 5); + + // Disable grapheme clustering + t.modes.set(Mode::GraphemeCluster, false); + + t.print('x' as u32).unwrap(); + assert!(dirty(&t, 0, 0)); + + t.clear_dirty(); + t.print(0xFE0F).unwrap(); // VS16 to make wide + assert!(!dirty(&t, 0, 0)); +} + +#[test] +fn terminal_print_multicodepoint_grapheme_mode_2027() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: 👨‍👩‍👧 (which may or may not render correctly) + print_all(&mut t, &[0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467]); + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + // Row should be dirty + assert!(dirty(&t, 0, 0)); + + let cell = c(&t, 0, 0); + assert_eq!(0x1F468, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); + assert_eq!(4, graphemes_at(&t, pt_screen(0, 0)).unwrap().len()); + + let cell = c(&t, 1, 0); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerTail, cell.wide()); +} + +#[test] +fn terminal_keypad_sequence_vs15() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // This is: "#︎" (number sign with text presentation selector) + t.print(0x23).unwrap(); // # Number sign (valid base) + t.print(0xFE0E).unwrap(); // VS15 (text presentation selector) + + // VS15 should combine with the base character into a single grapheme cluster, + // taking 1 cell (narrow character). + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + + // Row should be dirty + assert!(dirty(&t, 0, 0)); + + let cell = c(&t, 0, 0); + assert_eq!(0x23, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); +} + +#[test] +fn terminal_keypad_sequence_vs16() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // This is: "#️" (number sign with emoji presentation selector) + t.print(0x23).unwrap(); // # Number sign (valid base) + t.print(0xFE0F).unwrap(); // VS16 (emoji presentation selector) + + // VS16 should combine with the base character into a single grapheme cluster, + // taking 2 cells (wide character). + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + // Row should be dirty + assert!(dirty(&t, 0, 0)); + + let cell = c(&t, 0, 0); + assert_eq!(0x23, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); +} + +#[test] +fn terminal_fitzpatrick_skin_tone_next_valid_base() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // This is: "👋🏿" (waving hand with dark skin tone) + t.print(0x1F44B).unwrap(); // 👋 Waving hand (valid base) + t.print(0x1F3FF).unwrap(); // 🏿 Dark skin tone modifier + + // The skin tone should combine with the base emoji into a single grapheme cluster, + // taking 2 cells (wide character). + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + // Row should be dirty + assert!(dirty(&t, 0, 0)); + + let cell = c(&t, 0, 0); + assert_eq!(0x1F44B, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); +} + +#[test] +fn terminal_fitzpatrick_skin_tone_next_to_non_base() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print(0x22).unwrap(); // " + t.print(0x1F3FF).unwrap(); // Dark skin tone + t.print(0x22).unwrap(); // " + + // We should have 4 cells taken up. Importantly, the skin tone + // should not join with the quotes. + assert_eq!(0, t.screen().cursor.y); + assert_eq!(4, t.screen().cursor.x); + + // Row should be dirty + assert!(dirty(&t, 0, 0)); + + let cell = c(&t, 0, 0); + assert_eq!(0x22, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + + let cell = c(&t, 1, 0); + assert_eq!(0x1F3FF, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); + + let cell = c(&t, 3, 0); + assert_eq!(0x22, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); +} + +#[test] +fn terminal_multicodepoint_grapheme_marks_dirty_on_every_codepoint() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: 👨‍👩‍👧 (which may or may not render correctly) + t.print(0x1F468).unwrap(); + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + t.print(0x200D).unwrap(); + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + t.print(0x1F469).unwrap(); + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + t.print(0x200D).unwrap(); + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + t.print(0x1F467).unwrap(); + assert!(dirty(&t, 0, 0)); + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); +} + +#[test] +fn terminal_vs15_to_make_narrow_character() { + let mut t = init(5, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print(0x2614).unwrap(); // Umbrella with rain drops, width=2 + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + t.print(0xFE0E).unwrap(); // VS15 to make narrow + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + + // VS15 should send us back a cell since our char is no longer wide. + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + + assert_eq!("☔︎", plain(&t)); + + let cell = c(&t, 0, 0); + assert_eq!(0x2614, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + assert_eq!(1, graphemes_at(&t, pt_screen(0, 0)).unwrap().len()); +} + +#[test] +fn terminal_vs15_on_already_narrow_emoji() { + let mut t = init(5, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print(0x26C8).unwrap(); // Thunder cloud and rain, width=1 + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + t.print(0xFE0E).unwrap(); // VS15 to make narrow + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + + // Character takes up one cell + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + + assert_eq!("⛈︎", plain(&t)); + + let cell = c(&t, 0, 0); + assert_eq!(0x26C8, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + assert_eq!(1, graphemes_at(&t, pt_screen(0, 0)).unwrap().len()); +} + +#[test] +fn terminal_print_invalid_vs15_following_emoji_is_wide() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print(0x1F9E0).unwrap(); // 🧠 + t.print(0xFE0E).unwrap(); // not valid with U+1F9E0 as base + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!(0x1F9E0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); + + let cell = c(&t, 1, 0); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::SpacerTail, cell.wide()); +} + +#[test] +fn terminal_print_invalid_vs15_in_emoji_zwj_sequence() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print(0x1F469).unwrap(); // 👩 + t.print(0xFE0E).unwrap(); // not valid with U+1F469 as base + t.print(0x200D).unwrap(); // ZWJ + t.print(0x1F466).unwrap(); // 👦 + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!(0x1F469, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!( + vec![0x200D, 0x1F466], + graphemes_at(&t, pt_screen(0, 0)).unwrap() + ); + assert_eq!(Wide::Wide, cell.wide()); + + let cell = c(&t, 1, 0); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::SpacerTail, cell.wide()); +} + +#[test] +fn terminal_vs15_to_make_narrow_character_with_pending_wrap() { + let mut t = init(4, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + assert!(t.modes.get(Mode::Wraparound)); + + t.print(0x1F34B).unwrap(); // Lemon, width=2 + t.print(0x2614).unwrap(); // Umbrella with rain drops, width=2 + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + + // We only move to the end of the line because we're in a pending wrap + // state. + assert_eq!(0, t.screen().cursor.y); + assert_eq!(3, t.screen().cursor.x); + assert!(t.screen().cursor.pending_wrap); + + t.print(0xFE0E).unwrap(); // VS15 to make narrow + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + + // VS15 should clear the pending wrap state + assert_eq!(0, t.screen().cursor.y); + assert_eq!(3, t.screen().cursor.x); + assert!(!t.screen().cursor.pending_wrap); + + assert_eq!("🍋☔︎", plain(&t)); + + let cell = c(&t, 2, 0); + assert_eq!(0x2614, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + assert_eq!(1, graphemes_at(&t, pt_screen(2, 0)).unwrap().len()); + + // VS15 should not affect the previous grapheme + let lemon = c(&t, 0, 0); + assert_eq!(0x1F34B, lemon.codepoint()); + assert_eq!(Wide::Wide, lemon.wide()); + let spacer = c(&t, 1, 0); + assert_eq!(0, spacer.codepoint()); + assert_eq!(Wide::SpacerTail, spacer.wide()); +} + +#[test] +fn terminal_vs16_to_make_wide_character_on_next_line() { + let mut t = init(3, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.cursor_right(2); + t.print('#' as u32).unwrap(); + assert_eq!(2, t.screen().cursor.x); + assert!(t.screen().cursor.pending_wrap); + assert!(dirty(&t, 2, 0)); + t.clear_dirty(); + + t.print(0xFE0F).unwrap(); // VS16 to make wide + + assert!(dirty(&t, 2, 0)); + t.clear_dirty(); + assert_eq!(1, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + assert!(!t.screen().cursor.pending_wrap); + + // Previous cell turns into spacer_head + let cell = c(&t, 2, 0); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerHead, cell.wide()); + + // '#' cell is wide + let cell = c(&t, 0, 1); + assert_eq!('#' as u32, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(vec![0xFE0F], graphemes_at(&t, pt_screen(0, 1)).unwrap()); + assert_eq!(Wide::Wide, cell.wide()); + + // spacer_tail + let cell = c(&t, 1, 1); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerTail, cell.wide()); +} + +#[test] +fn terminal_vs16_to_make_wide_character_on_next_line_with_hyperlink() { + // Regression test for the crash fixed in print's grapheme `.wide` path: + // writing a spacer_head at the screen edge before row.wrap was set. + let mut t = init(3, 5); + + // Enable grapheme clustering and activate a hyperlink so printCell + // calls cursorSetHyperlink (which runs page integrity checks). + t.modes.set(Mode::GraphemeCluster, true); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + + t.cursor_right(2); + t.print('#' as u32).unwrap(); + assert_eq!(2, t.screen().cursor.x); + assert!(t.screen().cursor.pending_wrap); + + // Without the fix, this panicked with UnwrappedSpacerHead. + t.print(0xFE0F).unwrap(); // VS16 to make wide + + assert_eq!(1, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + assert!(!t.screen().cursor.pending_wrap); + + // Previous cell turns into spacer_head and remains hyperlinked. + let cell = c(&t, 2, 0); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::SpacerHead, cell.wide()); + assert!(cell.hyperlink()); + assert!(row_at(&t, pt_screen(2, 0)).wrap()); + + // '#' cell is now wide and still hyperlinked. + let cell = c(&t, 0, 1); + assert_eq!('#' as u32, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(vec![0xFE0F], graphemes_at(&t, pt_screen(0, 1)).unwrap()); + assert_eq!(Wide::Wide, cell.wide()); + assert!(cell.hyperlink()); + + // spacer_tail inherits hyperlink as part of the same grapheme cell. + let cell = c(&t, 1, 1); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::SpacerTail, cell.wide()); + assert!(cell.hyperlink()); +} + +#[test] +fn terminal_vs16_to_make_wide_character_with_pending_wrap() { + let mut t = init(3, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.cursor_right(1); + t.print('#' as u32).unwrap(); + assert_eq!(2, t.screen().cursor.x); + assert!(!t.screen().cursor.pending_wrap); + + t.print(0xFE0F).unwrap(); // VS16 to make wide + + assert_eq!(2, t.screen().cursor.x); + assert_eq!(0, t.screen().cursor.y); + assert!(t.screen().cursor.pending_wrap); + + // '#' cell is wide + let cell = c(&t, 1, 0); + assert_eq!('#' as u32, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(vec![0xFE0F], graphemes_at(&t, pt_screen(1, 0)).unwrap()); + assert_eq!(Wide::Wide, cell.wide()); + + // spacer_tail + let cell = c(&t, 2, 0); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerTail, cell.wide()); +} + +#[test] +fn terminal_vs16_to_make_wide_character_with_mode_2027() { + let mut t = init(5, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print(0x2764).unwrap(); // Heart + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + t.print(0xFE0F).unwrap(); // VS16 to make wide + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + + assert_eq!("❤️", plain(&t)); + + let cell = c(&t, 0, 0); + assert_eq!(0x2764, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); + assert_eq!(1, graphemes_at(&t, pt_screen(0, 0)).unwrap().len()); +} + +#[test] +fn terminal_vs16_repeated_with_mode_2027() { + let mut t = init(5, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print(0x2764).unwrap(); // Heart + t.print(0xFE0F).unwrap(); // VS16 to make wide + t.print(0x2764).unwrap(); // Heart + t.print(0xFE0F).unwrap(); // VS16 to make wide + + assert!(dirty(&t, 0, 0)); + + assert_eq!("❤️❤️", plain(&t)); + + for x in [0, 2] { + let cell = c(&t, x, 0); + assert_eq!(0x2764, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(Wide::Wide, cell.wide()); + assert_eq!(1, graphemes_at(&t, pt_screen(x, 0)).unwrap().len()); + } +} + +#[test] +fn terminal_print_invalid_vs16_grapheme() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // https://github.com/mitchellh/ghostty/issues/1482 + t.print('x' as u32).unwrap(); + t.print(0xFE0F).unwrap(); // invalid VS16 + + // We should have 1 cells taken up, and narrow. + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!('x' as u32, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + + let cell = c(&t, 1, 0); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); +} + +#[test] +fn terminal_print_invalid_vs16_with_second_char() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // https://github.com/mitchellh/ghostty/issues/1482 + t.print('x' as u32).unwrap(); + t.print(0xFE0F).unwrap(); + t.print('y' as u32).unwrap(); + + // We should have 2 cells taken up, from two separate narrow characters. + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!('x' as u32, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + + let cell = c(&t, 1, 0); + assert_eq!('y' as u32, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); +} + +#[test] +fn terminal_print_grapheme_o_with_nonspacing_mark_should_be_narrow() { + let mut t = init(5, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print('o' as u32).unwrap(); + t.print(0x0300).unwrap(); // combining grave accent + + // We should have 1 cell taken up. + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!('o' as u32, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(vec![0x0300], graphemes_at(&t, pt_screen(0, 0)).unwrap()); + assert_eq!(Wide::Narrow, cell.wide()); +} + +#[test] +fn terminal_print_devanagari_grapheme_should_be_wide() { + let mut t = init(5, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // क्‍ष + print_all(&mut t, &[0x0915, 0x094D, 0x200D, 0x0937]); + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!(0x0915, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!( + vec![0x094D, 0x200D, 0x0937], + graphemes_at(&t, pt_screen(0, 0)).unwrap() + ); + assert_eq!(Wide::Wide, cell.wide()); + assert_eq!(Wide::SpacerTail, c(&t, 1, 0).wide()); +} + +#[test] +fn terminal_print_devanagari_grapheme_should_be_wide_on_next_line() { + let mut t = init(3, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.cursor_right(2); + + // क्‍ष + print_all(&mut t, &[0x0915, 0x094D, 0x200D]); + assert_eq!(2, t.screen().cursor.x); + assert!(t.screen().cursor.pending_wrap); + + // This one increases the width to wide + t.print(0x0937).unwrap(); + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(1, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + assert!(!t.screen().cursor.pending_wrap); + + // Previous cell turns into spacer_head + let cell = c(&t, 2, 0); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerHead, cell.wide()); + + // Devanagari grapheme is wide + let cell = c(&t, 0, 1); + assert_eq!(0x0915, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!( + vec![0x094D, 0x200D, 0x0937], + graphemes_at(&t, pt_screen(0, 1)).unwrap() + ); + assert_eq!(Wide::Wide, cell.wide()); + + assert_eq!(Wide::SpacerTail, c(&t, 1, 1).wide()); +} + +#[test] +fn terminal_print_devanagari_grapheme_should_be_wide_on_next_page() { + let rows = crate::page::STD_CAPACITY.rows; + let cols = crate::page::STD_CAPACITY.cols; + let mut t = init(cols, rows); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.cursor_down(usize::from(rows - 1)); + + let first = t.screen().pages.first.unwrap(); + let first_rows = t.screen().page(first).capacity.rows; + for _ in rows..first_rows { + t.index().unwrap(); + } + + t.cursor_right(usize::from(cols - 1)); + + assert_eq!(cols - 1, t.screen().cursor.x); + assert_eq!(rows - 1, t.screen().cursor.y); + + // क्‍ष + print_all(&mut t, &[0x0915, 0x094D, 0x200D]); + assert_eq!(cols - 1, t.screen().cursor.x); + assert!(t.screen().cursor.pending_wrap); + + // This one increases the width to wide + t.print(0x0937).unwrap(); + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(rows - 1, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + assert!(!t.screen().cursor.pending_wrap); + + // Previous cell turns into spacer_head + let cell = cell_at(&t, pt_active(cols - 1, u32::from(rows) - 2)); + assert_eq!(0, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::SpacerHead, cell.wide()); + + // Devanagari grapheme is wide + let pt = pt_active(0, u32::from(rows) - 1); + let cell = cell_at(&t, pt); + assert_eq!(0x0915, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(vec![0x094D, 0x200D, 0x0937], graphemes_at(&t, pt).unwrap()); + assert_eq!(Wide::Wide, cell.wide()); + + let cell = cell_at(&t, pt_active(1, u32::from(rows) - 1)); + assert_eq!(Wide::SpacerTail, cell.wide()); +} + +#[test] +fn terminal_print_invalid_vs16_with_second_char_combining() { + let mut t = init(80, 80); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // https://github.com/mitchellh/ghostty/issues/1482 + t.print('n' as u32).unwrap(); + t.print(0xFE0F).unwrap(); // invalid VS16 + t.print(0x0303).unwrap(); // combining tilde + + // We should have 1 cells taken up, and narrow. + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + + let cell = c(&t, 0, 0); + assert_eq!('n' as u32, cell.codepoint()); + assert!(cell.has_grapheme()); + assert_eq!(vec![0x0303], graphemes_at(&t, pt_screen(0, 0)).unwrap()); + assert_eq!(Wide::Narrow, cell.wide()); + + let cell = c(&t, 1, 0); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); +} + +#[test] +fn terminal_overwrite_grapheme_should_clear_grapheme_data() { + let mut t = init(5, 5); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + t.print(0x26C8).unwrap(); // Thunder cloud and rain + t.print(0xFE0E).unwrap(); // VS15 to make narrow + assert!(dirty(&t, 0, 0)); + t.clear_dirty(); + + t.set_cursor_pos(1, 1); + t.print('A' as u32).unwrap(); + assert!(dirty(&t, 0, 0)); + + assert_eq!("A", plain(&t)); + + let cell = c(&t, 0, 0); + assert_eq!('A' as u32, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); +} + +#[test] +fn terminal_overwrite_multicodepoint_grapheme_clears_grapheme_data() { + let mut t = init(10, 10); + + // Enable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: 👨‍👩‍👧 (which may or may not render correctly) + print_all(&mut t, &[0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467]); + + // We should have 2 cells taken up. It is one character but "wide". + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + // We should have one cell with graphemes + assert_eq!(1, t.screen().cursor_page().grapheme_count()); + + // Move back and overwrite wide + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.print('X' as u32).unwrap(); + assert!(dirty(&t, 0, 0)); + + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + assert_eq!(0, t.screen().cursor_page().grapheme_count()); + + assert_eq!("X", plain(&t)); +} diff --git a/crates/ghostty-terminal/src/terminal/tests/p2.rs b/crates/ghostty-terminal/src/terminal/tests/p2.rs new file mode 100644 index 0000000..275e5a1 --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/p2.rs @@ -0,0 +1,781 @@ +use super::*; +use crate::page::{SemanticPrompt as RowPrompt, Wide}; + +fn hl_id(t: &Terminal, pt: Point) -> Option { + let p = pin_at(t, pt); + let page = t.screen().page(p.node); + let i = page.cell_index(usize::from(p.x), usize::from(p.y)).unwrap(); + page.lookup_hyperlink(i) +} + +fn print_chars(t: &mut Terminal, s: &str) { + for c in s.chars() { + t.print(u32::from(c)).unwrap(); + } +} + +#[test] +fn terminal_overwrite_multicodepoint_grapheme_tail_clears_grapheme_data() { + let mut t = init(10, 10); + t.modes.set(Mode::GraphemeCluster, true); + + for cp in [0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467] { + t.print(cp).unwrap(); + } + + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + assert_eq!(1, t.screen().cursor_page().grapheme_count()); + + t.set_cursor_pos(1, 2); + t.print(u32::from('X')).unwrap(); + assert_eq!(" X", plain(&t)); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + assert_eq!(0, t.screen().cursor_page().grapheme_count()); +} + +#[test] +fn terminal_print_breaks_valid_grapheme_cluster_with_prepend_ascii_for_speed() { + let mut t = init(5, 5); + t.modes.set(Mode::GraphemeCluster, true); + t.print(u32::from('_')).unwrap(); + t.print(0x0600).unwrap(); + t.print(u32::from('1')).unwrap(); + + assert_eq!(0, t.screen().cursor.y); + assert_eq!(3, t.screen().cursor.x); + { + let cell = cell_at(&t, pt_screen(1, 0)); + assert_eq!(0x0600, cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + } + { + let cell = cell_at(&t, pt_screen(2, 0)); + assert_eq!(u32::from('1'), cell.codepoint()); + assert!(!cell.has_grapheme()); + assert_eq!(Wide::Narrow, cell.wide()); + } +} + +#[test] +fn terminal_print_writes_to_bottom_if_scrolled() { + let mut t = init(5, 2); + print_chars(&mut t, "hello"); + t.set_cursor_pos(0, 0); + + t.index().unwrap(); + t.index().unwrap(); + t.index().unwrap(); + assert_eq!("", plain(&t)); + + t.screen_mut().scroll(Scroll::Top); + assert_eq!("hello", plain(&t)); + + t.print(u32::from('A')).unwrap(); + t.screen_mut().scroll(Scroll::Active); + assert_eq!("\nA", plain(&t)); + + let (x, y) = (t.screen().cursor.x, t.screen().cursor.y); + assert!(t.is_dirty(pt_active(x, u32::from(y)))); +} + +#[test] +fn terminal_print_charset() { + let mut t = init(80, 80); + t.configure_charset(Slots::G1, Charset::DecSpecial); + t.configure_charset(Slots::G2, Charset::DecSpecial); + t.configure_charset(Slots::G3, Charset::DecSpecial); + + assert!(!t.is_dirty(pt_screen(0, 0))); + + t.print(u32::from('`')).unwrap(); + t.configure_charset(Slots::G0, Charset::Utf8); + t.print(u32::from('`')).unwrap(); + t.configure_charset(Slots::G0, Charset::Ascii); + t.print(u32::from('`')).unwrap(); + t.configure_charset(Slots::G0, Charset::DecSpecial); + t.print(u32::from('`')).unwrap(); + assert_eq!("```◆", plain(&t)); + + assert!(t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_print_charset_outside_of_ascii() { + let mut t = init(80, 80); + t.configure_charset(Slots::G1, Charset::DecSpecial); + t.configure_charset(Slots::G2, Charset::DecSpecial); + t.configure_charset(Slots::G3, Charset::DecSpecial); + assert!(!t.is_dirty(pt_screen(0, 0))); + + t.configure_charset(Slots::G0, Charset::DecSpecial); + t.print(u32::from('`')).unwrap(); + t.print(0x1F600).unwrap(); + assert_eq!("◆ ", plain(&t)); + assert!(t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_print_invoke_charset() { + let mut t = init(80, 80); + t.configure_charset(Slots::G1, Charset::DecSpecial); + t.print(u32::from('`')).unwrap(); + + t.clear_dirty(); + t.invoke_charset(charsets::ActiveSlot::GL, Slots::G1, false); + assert!(!t.is_dirty(pt_screen(0, 0))); + t.print(u32::from('`')).unwrap(); + assert!(t.is_dirty(pt_screen(0, 0))); + t.print(u32::from('`')).unwrap(); + t.invoke_charset(charsets::ActiveSlot::GL, Slots::G0, false); + t.print(u32::from('`')).unwrap(); + assert_eq!("`◆◆`", plain(&t)); +} + +#[test] +fn terminal_print_invoke_charset_single() { + let mut t = init(80, 80); + t.configure_charset(Slots::G1, Charset::DecSpecial); + t.print(u32::from('`')).unwrap(); + t.invoke_charset(charsets::ActiveSlot::GL, Slots::G1, true); + t.print(u32::from('`')).unwrap(); + t.print(u32::from('`')).unwrap(); + assert_eq!("`◆`", plain(&t)); +} + +#[test] +fn terminal_print_kitty_unicode_placeholder() { + let mut t = init(10, 10); + t.print(0x10EEEE).unwrap(); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(1, t.screen().cursor.x); + { + let cell = cell_at(&t, pt_screen(0, 0)); + assert_eq!(0x10EEEE, cell.codepoint()); + assert!(row_at(&t, pt_screen(0, 0)).kitty_virtual_placeholder()); + } + assert!(t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_soft_wrap() { + let mut t = init(3, 80); + print_chars(&mut t, "hello"); + assert_eq!(1, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + assert_eq!("hel\nlo", plain(&t)); +} + +#[test] +fn terminal_soft_wrap_with_semantic_prompt() { + let mut t = init(3, 80); + t.semantic_prompt(&osc::SemanticPrompt::init(PromptAction::PromptStart)) + .unwrap(); + assert!(!t.is_dirty(pt_screen(0, 0))); + + print_chars(&mut t, "hello"); + assert_eq!( + RowPrompt::Prompt, + row_at(&t, pt_screen(0, 0)).semantic_prompt() + ); + assert_eq!( + RowPrompt::PromptContinuation, + row_at(&t, pt_screen(0, 1)).semantic_prompt() + ); +} + +#[test] +fn terminal_disabled_wraparound_with_wide_char_and_one_space() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, false); + print_str(&mut t, "AAAA"); + t.clear_dirty(); + t.print(0x1F6A8).unwrap(); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(4, t.screen().cursor.x); + assert_eq!("AAAA", plain(&t)); + { + let cell = cell_at(&t, pt_screen(4, 0)); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + } + assert!(!t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_disabled_wraparound_with_wide_char_and_no_space() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, false); + print_str(&mut t, "AAAAA"); + t.clear_dirty(); + t.print(0x1F6A8).unwrap(); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(4, t.screen().cursor.x); + assert_eq!("AAAAA", plain(&t)); + { + let cell = cell_at(&t, pt_screen(4, 0)); + assert_eq!(u32::from('A'), cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + } + assert!(!t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_disabled_wraparound_with_wide_grapheme_and_half_space() { + let mut t = init(5, 5); + t.modes.set(Mode::GraphemeCluster, true); + t.modes.set(Mode::Wraparound, false); + print_str(&mut t, "AAAA"); + t.print(0x2764).unwrap(); + t.clear_dirty(); + t.print(0xFE0F).unwrap(); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(4, t.screen().cursor.x); + assert_eq!("AAAA❤", plain(&t)); + { + let cell = cell_at(&t, pt_screen(4, 0)); + assert_eq!(0x2764, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + } + assert!(!t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_print_right_margin_wrap() { + let mut t = init(10, 5); + print_str(&mut t, "123456789"); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(3, 5); + t.set_cursor_pos(1, 5); + print_str(&mut t, "XY"); + assert_eq!("1234X6789\n Y", plain(&t)); + assert!(!row_at(&t, pt_active(0, 0)).wrap()); +} + +#[test] +fn terminal_print_right_margin_wrap_dirty_tracking() { + let mut t = init(10, 5); + print_str(&mut t, "123456789"); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(3, 5); + t.set_cursor_pos(1, 5); + + t.clear_dirty(); + t.print(u32::from('X')).unwrap(); + assert!(t.is_dirty(pt_screen(4, 0))); + assert!(!t.is_dirty(pt_screen(2, 1))); + + t.clear_dirty(); + t.print(u32::from('Y')).unwrap(); + assert!(t.is_dirty(pt_screen(4, 0))); + assert!(t.is_dirty(pt_screen(2, 1))); + assert_eq!("1234X6789\n Y", plain(&t)); +} + +#[test] +fn terminal_print_right_margin_outside() { + let mut t = init(10, 5); + print_str(&mut t, "123456789"); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(3, 5); + t.set_cursor_pos(1, 6); + t.clear_dirty(); + print_str(&mut t, "XY"); + assert_eq!("12345XY89", plain(&t)); + assert!(t.is_dirty(pt_screen(5, 0))); +} + +#[test] +fn terminal_print_right_margin_outside_wrap() { + let mut t = init(10, 5); + print_str(&mut t, "123456789"); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(3, 5); + t.set_cursor_pos(1, 10); + print_str(&mut t, "XY"); + assert_eq!("123456789X\n Y", plain(&t)); +} + +#[test] +fn terminal_print_wide_char_at_right_margin_does_not_create_spacer_head() { + let mut t = init(10, 10); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(3, 5); + t.set_cursor_pos(1, 5); + t.print(0x1F600).unwrap(); + assert_eq!(1, t.screen().cursor.y); + assert_eq!(4, t.screen().cursor.x); + + assert!(t.is_dirty(pt_screen(4, 0))); + assert!(t.is_dirty(pt_screen(4, 1))); + + { + let cell = cell_at(&t, pt_screen(4, 0)); + assert_eq!(0, cell.codepoint()); + assert_eq!(Wide::Narrow, cell.wide()); + assert!(!row_at(&t, pt_screen(4, 0)).wrap()); + } + { + let cell = cell_at(&t, pt_screen(2, 1)); + assert_eq!(0x1F600, cell.codepoint()); + assert_eq!(Wide::Wide, cell.wide()); + } + { + let cell = cell_at(&t, pt_screen(3, 1)); + assert_eq!(Wide::SpacerTail, cell.wide()); + } +} + +#[test] +fn terminal_print_with_hyperlink() { + let mut t = init(80, 80); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + print_str(&mut t, "123456"); + + for x in 0..6 { + assert!(row_at(&t, pt_screen(x, 0)).hyperlink()); + assert!(cell_at(&t, pt_screen(x, 0)).hyperlink()); + assert_eq!(Some(1), hl_id(&t, pt_screen(x, 0))); + } + assert!(t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_print_over_cell_with_same_hyperlink() { + let mut t = init(80, 80); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + print_str(&mut t, "123456"); + t.set_cursor_pos(1, 1); + print_str(&mut t, "123456"); + + for x in 0..6 { + assert!(row_at(&t, pt_screen(x, 0)).hyperlink()); + assert!(cell_at(&t, pt_screen(x, 0)).hyperlink()); + assert_eq!(Some(1), hl_id(&t, pt_screen(x, 0))); + } + assert!(t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_print_and_end_hyperlink() { + let mut t = init(80, 80); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + print_str(&mut t, "123"); + t.screen_mut().end_hyperlink(); + print_str(&mut t, "456"); + + for x in 0..3 { + assert!(row_at(&t, pt_screen(x, 0)).hyperlink()); + assert!(cell_at(&t, pt_screen(x, 0)).hyperlink()); + assert_eq!(Some(1), hl_id(&t, pt_screen(x, 0))); + } + for x in 3..6 { + assert!(row_at(&t, pt_screen(x, 0)).hyperlink()); + assert!(!cell_at(&t, pt_screen(x, 0)).hyperlink()); + } + assert!(t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_print_and_change_hyperlink() { + let mut t = init(80, 80); + t.screen_mut() + .start_hyperlink(b"http://one.example.com", None) + .unwrap(); + print_str(&mut t, "123"); + t.screen_mut() + .start_hyperlink(b"http://two.example.com", None) + .unwrap(); + print_str(&mut t, "456"); + + for x in 0..3 { + assert!(cell_at(&t, pt_screen(x, 0)).hyperlink()); + assert_eq!(Some(1), hl_id(&t, pt_screen(x, 0))); + } + for x in 3..6 { + assert!(cell_at(&t, pt_screen(x, 0)).hyperlink()); + assert_eq!(Some(2), hl_id(&t, pt_screen(x, 0))); + } + assert!(t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_overwrite_hyperlink() { + let mut t = init(80, 80); + t.screen_mut() + .start_hyperlink(b"http://one.example.com", None) + .unwrap(); + print_str(&mut t, "123"); + t.set_cursor_pos(1, 1); + t.screen_mut().end_hyperlink(); + print_str(&mut t, "456"); + + for x in 0..3 { + let p = pin_at(&t, pt_screen(x, 0)); + assert!(!row_at(&t, pt_screen(x, 0)).hyperlink()); + assert!(!cell_at(&t, pt_screen(x, 0)).hyperlink()); + assert_eq!(None, hl_id(&t, pt_screen(x, 0))); + assert_eq!(0, t.screen().page(p.node).hyperlink_set.count()); + } + assert!(t.is_dirty(pt_screen(0, 0))); +} + +#[test] +fn terminal_print_wide_char_at_right_edge_with_hyperlink() { + let mut t = init(10, 5); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + t.set_cursor_pos(1, 10); + t.print(0x4E2D).unwrap(); + + assert_eq!(1, t.screen().cursor.y); + assert_eq!(2, t.screen().cursor.x); + + { + let cell = cell_at(&t, pt_screen(9, 0)); + assert_eq!(Wide::SpacerHead, cell.wide()); + assert!(cell.hyperlink()); + assert!(row_at(&t, pt_screen(9, 0)).wrap()); + } + { + let cell = cell_at(&t, pt_screen(0, 1)); + assert_eq!(0x4E2D, cell.codepoint()); + assert_eq!(Wide::Wide, cell.wide()); + assert!(cell.hyperlink()); + } + { + let cell = cell_at(&t, pt_screen(1, 1)); + assert_eq!(Wide::SpacerTail, cell.wide()); + assert!(cell.hyperlink()); + } +} + +#[test] +fn terminal_linefeed_and_carriage_return() { + let mut t = init(80, 80); + print_chars(&mut t, "hello"); + t.clear_dirty(); + t.carriage_return(); + assert!(!t.is_dirty(pt_screen(0, 0))); + + t.linefeed().unwrap(); + assert!(t.is_dirty(pt_screen(0, 0))); + assert!(t.is_dirty(pt_screen(0, 1))); + + print_chars(&mut t, "world"); + assert_eq!(1, t.screen().cursor.y); + assert_eq!(5, t.screen().cursor.x); + assert_eq!("hello\nworld", plain(&t)); +} + +#[test] +fn terminal_linefeed_unsets_pending_wrap() { + let mut t = init(5, 80); + print_chars(&mut t, "hello"); + assert!(t.screen().cursor.pending_wrap); + t.clear_dirty(); + t.linefeed().unwrap(); + assert!(t.is_dirty(pt_screen(0, 0))); + assert!(t.is_dirty(pt_screen(0, 1))); + assert!(!t.screen().cursor.pending_wrap); +} + +#[test] +fn terminal_linefeed_mode_automatic_carriage_return() { + let mut t = init(10, 10); + t.modes.set(Mode::Linefeed, true); + print_str(&mut t, "123456"); + t.linefeed().unwrap(); + t.print(u32::from('X')).unwrap(); + assert_eq!("123456\nX", plain(&t)); +} + +#[test] +fn terminal_carriage_return_unsets_pending_wrap() { + let mut t = init(5, 80); + print_chars(&mut t, "hello"); + assert!(t.screen().cursor.pending_wrap); + t.carriage_return(); + assert!(!t.screen().cursor.pending_wrap); +} + +#[test] +fn terminal_carriage_return_origin_mode_moves_to_left_margin() { + let mut t = init(5, 80); + t.modes.set(Mode::Origin, true); + t.screen_mut().cursor.x = 0; + t.scrolling_region.left = 2; + t.carriage_return(); + assert_eq!(2, t.screen().cursor.x); +} + +#[test] +fn terminal_carriage_return_left_of_left_margin_moves_to_zero() { + let mut t = init(5, 80); + t.screen_mut().cursor.x = 1; + t.scrolling_region.left = 2; + t.carriage_return(); + assert_eq!(0, t.screen().cursor.x); +} + +#[test] +fn terminal_carriage_return_right_of_left_margin_moves_to_left_margin() { + let mut t = init(5, 80); + t.screen_mut().cursor.x = 3; + t.scrolling_region.left = 2; + t.carriage_return(); + assert_eq!(2, t.screen().cursor.x); +} + +#[test] +fn terminal_backspace() { + let mut t = init(80, 80); + print_chars(&mut t, "hello"); + t.backspace(); + t.print(u32::from('y')).unwrap(); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(5, t.screen().cursor.x); + assert_eq!("helly", plain(&t)); +} + +#[test] +fn terminal_horizontal_tabs() { + let mut t = init(20, 5); + t.print(u32::from('1')).unwrap(); + t.horizontal_tab(); + assert_eq!(8, t.screen().cursor.x); + t.horizontal_tab(); + assert_eq!(16, t.screen().cursor.x); + t.horizontal_tab(); + assert_eq!(19, t.screen().cursor.x); + t.horizontal_tab(); + assert_eq!(19, t.screen().cursor.x); +} + +#[test] +fn terminal_horizontal_tabs_starting_on_tabstop() { + let mut t = init(20, 5); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y, 9); + t.print(u32::from('X')).unwrap(); + t.set_cursor_pos(y, 9); + t.horizontal_tab(); + t.print(u32::from('A')).unwrap(); + assert_eq!(" X A", plain(&t)); +} + +#[test] +fn terminal_horizontal_tabs_with_right_margin() { + let mut t = init(20, 5); + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y, 1); + t.print(u32::from('X')).unwrap(); + t.horizontal_tab(); + t.print(u32::from('A')).unwrap(); + assert_eq!("X A", plain(&t)); +} + +#[test] +fn terminal_horizontal_tabs_back() { + let mut t = init(20, 5); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y, 20); + t.horizontal_tab_back(); + assert_eq!(16, t.screen().cursor.x); + t.horizontal_tab_back(); + assert_eq!(8, t.screen().cursor.x); + t.horizontal_tab_back(); + assert_eq!(0, t.screen().cursor.x); + t.horizontal_tab_back(); + assert_eq!(0, t.screen().cursor.x); +} + +#[test] +fn terminal_horizontal_tabs_back_starting_on_tabstop() { + let mut t = init(20, 5); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y, 9); + t.print(u32::from('X')).unwrap(); + t.set_cursor_pos(y, 9); + t.horizontal_tab_back(); + t.print(u32::from('A')).unwrap(); + assert_eq!("A X", plain(&t)); +} + +#[test] +fn terminal_horizontal_tabs_with_left_margin_in_origin_mode() { + let mut t = init(20, 5); + t.modes.set(Mode::Origin, true); + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.set_cursor_pos(1, 2); + t.print(u32::from('X')).unwrap(); + t.horizontal_tab_back(); + t.print(u32::from('A')).unwrap(); + assert_eq!(" AX", plain(&t)); +} + +#[test] +fn terminal_horizontal_tab_back_with_cursor_before_left_margin() { + let mut t = init(20, 5); + t.modes.set(Mode::Origin, true); + t.save_cursor(); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(5, 0); + t.restore_cursor(); + t.horizontal_tab_back(); + t.print(u32::from('X')).unwrap(); + assert_eq!("X", plain(&t)); +} + +#[test] +fn terminal_cursor_pos_resets_wrap() { + let mut t = init(5, 5); + print_chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.set_cursor_pos(1, 1); + assert!(!t.screen().cursor.pending_wrap); + t.print(u32::from('X')).unwrap(); + assert_eq!("XBCDE", plain(&t)); +} + +#[test] +fn terminal_cursor_pos_off_the_screen() { + let mut t = init(5, 5); + t.set_cursor_pos(500, 500); + t.print(u32::from('X')).unwrap(); + assert_eq!("\n\n\n\n X", plain(&t)); +} + +#[test] +fn terminal_cursor_pos_relative_to_origin() { + let mut t = init(5, 5); + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.modes.set(Mode::Origin, true); + t.set_cursor_pos(1, 1); + t.print(u32::from('X')).unwrap(); + assert_eq!("\n\nX", plain(&t)); +} + +#[test] +fn terminal_cursor_pos_relative_to_origin_with_left_right() { + let mut t = init(5, 5); + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(Mode::Origin, true); + t.set_cursor_pos(1, 1); + t.print(u32::from('X')).unwrap(); + assert_eq!("\n\n X", plain(&t)); +} + +#[test] +fn terminal_cursor_pos_limits_with_full_scroll_region() { + let mut t = init(5, 5); + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(Mode::Origin, true); + t.set_cursor_pos(500, 500); + t.print(u32::from('X')).unwrap(); + assert_eq!("\n\n\n X", plain(&t)); +} + +#[test] +fn terminal_set_cursor_pos_original_test() { + let mut t = init(80, 80); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(0, t.screen().cursor.y); + + t.set_cursor_pos(0, 0); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(0, t.screen().cursor.y); + + t.set_cursor_pos(81, 81); + assert_eq!(79, t.screen().cursor.x); + assert_eq!(79, t.screen().cursor.y); + + t.set_cursor_pos(0, 80); + t.print(u32::from('c')).unwrap(); + assert!(t.screen().cursor.pending_wrap); + t.set_cursor_pos(0, 80); + assert!(!t.screen().cursor.pending_wrap); + + t.modes.set(Mode::Origin, true); + + t.set_cursor_pos(81, 81); + assert_eq!(79, t.screen().cursor.x); + assert_eq!(79, t.screen().cursor.y); + + t.set_top_and_bottom_margin(10, usize::from(t.rows)); + t.set_cursor_pos(0, 0); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(9, t.screen().cursor.y); + + t.set_cursor_pos(1, 1); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(9, t.screen().cursor.y); + + t.set_cursor_pos(100, 0); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(79, t.screen().cursor.y); + + t.set_top_and_bottom_margin(10, 11); + t.set_cursor_pos(2, 0); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(10, t.screen().cursor.y); +} + +fn abc_def_ghi(t: &mut Terminal) { + print_str(t, "ABC"); + t.carriage_return(); + t.linefeed().unwrap(); + print_str(t, "DEF"); + t.carriage_return(); + t.linefeed().unwrap(); + print_str(t, "GHI"); +} + +#[test] +fn terminal_set_top_and_bottom_margin_simple() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_top_and_bottom_margin(0, 0); + + t.clear_dirty(); + t.scroll_down(1); + + for y in 0..4 { + assert!(t.is_dirty(pt_active(0, y))); + } + assert_eq!("\nABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_set_top_and_bottom_margin_top_only() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_top_and_bottom_margin(2, 0); + + t.clear_dirty(); + t.scroll_down(1); + + for y in 0..4 { + assert!(t.is_dirty(pt_active(0, y))); + } + assert_eq!("ABC\n\nDEF\nGHI", plain(&t)); +} diff --git a/crates/ghostty-terminal/src/terminal/tests/p3.rs b/crates/ghostty-terminal/src/terminal/tests/p3.rs new file mode 100644 index 0000000..f241ff6 --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/p3.rs @@ -0,0 +1,1061 @@ +use super::*; +use crate::page::ContentTag; +use crate::point::Tag; +use ghostty_vt::color::Rgb; +use ghostty_vt::modes::Mode; +use ghostty_vt::sgr::Attribute as A; + +fn cr_lf(t: &mut Terminal) { + t.carriage_return(); + t.linefeed().unwrap(); +} + +/// "ABC\nDEF\nGHI" written with CR+LF between lines (no trailing CR/LF). +fn abc_def_ghi(t: &mut Terminal) { + print_str(t, "ABC"); + cr_lf(t); + print_str(t, "DEF"); + cr_lf(t); + print_str(t, "GHI"); +} + +fn dirty(t: &Terminal, y: u32) -> bool { + t.is_dirty(pt_active(0, y)) +} + +fn print_chars(t: &mut Terminal, s: &str) { + for c in s.chars() { + t.print(u32::from(c)).unwrap(); + } +} + +/// (row.hyperlink, cell.hyperlink, lookup id, hyperlink_set.count) +fn link_info(t: &Terminal, pt: Point) -> (bool, bool, Option, usize) { + let p = pin_at(t, pt); + let page = t.screen().page(p.node); + let (row, cell) = page + .get_row_and_cell(usize::from(p.x), usize::from(p.y)) + .unwrap(); + let i = page.cell_index(usize::from(p.x), usize::from(p.y)).unwrap(); + ( + row.hyperlink(), + cell.hyperlink(), + page.lookup_hyperlink(i), + page.hyperlink_set.count(), + ) +} + +fn expect_linked(t: &Terminal, x: u16, y: u32) { + let (row, cell, id, count) = link_info(t, pt_viewport(x, y)); + assert!(row); + assert!(cell); + assert_eq!(Some(1), id); + assert_eq!(1, count); +} + +fn expect_unlinked_cell(t: &Terminal, x: u16, y: u32) { + let (_, cell, id, _) = link_info(t, pt_viewport(x, y)); + assert!(!cell); + assert!(id.is_none()); +} + +fn expect_unlinked_row(t: &Terminal, x: u16, y: u32) { + let (row, cell, id, _) = link_info(t, pt_viewport(x, y)); + assert!(!row); + assert!(!cell); + assert!(id.is_none()); +} + +#[test] +fn terminal_set_top_and_bottom_margin_top_and_bottom() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_top_and_bottom_margin(1, 2); + + t.clear_dirty(); + t.scroll_down(1); + + assert!(dirty(&t, 0)); + assert!(dirty(&t, 1)); + assert!(!dirty(&t, 2)); + assert_eq!("\nABC\nGHI", plain(&t)); +} + +#[test] +fn terminal_set_top_and_bottom_margin_top_equal_to_bottom() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_top_and_bottom_margin(2, 2); + + t.clear_dirty(); + t.scroll_down(1); + + for y in 0..4 { + assert!(dirty(&t, y)); + } + assert_eq!("\nABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_set_left_and_right_margin_simple() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(0, 0); + + t.clear_dirty(); + t.erase_chars(1); + + assert!(dirty(&t, 0)); + assert!(!dirty(&t, 1)); + assert_eq!(" BC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_set_left_and_right_margin_left_only() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(2, 0); + assert_eq!(1, t.scrolling_region.left); + assert_eq!(t.cols - 1, t.scrolling_region.right); + t.set_cursor_pos(1, 2); + + t.clear_dirty(); + t.insert_lines(1); + + for y in 0..4 { + assert!(dirty(&t, y)); + } + assert_eq!("A\nDBC\nGEF\n HI", plain(&t)); +} + +#[test] +fn terminal_set_left_and_right_margin_left_and_right() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(1, 2); + t.set_cursor_pos(1, 2); + + t.clear_dirty(); + t.insert_lines(1); + + for y in 0..4 { + assert!(dirty(&t, y)); + } + assert_eq!(" C\nABF\nDEI\nGH", plain(&t)); +} + +#[test] +fn terminal_set_left_and_right_margin_left_equal_right() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(2, 2); + t.set_cursor_pos(1, 2); + + t.clear_dirty(); + t.insert_lines(1); + + for y in 0..4 { + assert!(dirty(&t, y)); + } + assert_eq!("\nABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_set_left_and_right_margin_mode_69_unset() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.modes.set(Mode::EnableLeftAndRightMargin, false); + t.set_left_and_right_margin(1, 2); + t.set_cursor_pos(1, 2); + + t.clear_dirty(); + t.insert_lines(1); + + for y in 0..4 { + assert!(dirty(&t, y)); + } + assert_eq!("\nABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_insert_lines_simple() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + + t.clear_dirty(); + t.insert_lines(1); + + assert!(!dirty(&t, 0)); + assert!(dirty(&t, 1)); + assert!(dirty(&t, 2)); + assert!(dirty(&t, 3)); + assert_eq!("ABC\n\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_insert_lines_colors_with_bg_color() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + + t.set_attribute(A::DirectColorBg(Rgb { + r: 0xFF, + g: 0, + b: 0, + })) + .unwrap(); + t.insert_lines(1); + + assert_eq!("ABC\n\nDEF\nGHI", plain(&t)); + + for x in 0..t.cols { + let cell = cell_at(&t, pt_active(x, 1)); + assert!(cell.content_tag() == ContentTag::BgColorRgb); + assert_eq!( + Rgb { + r: 0xFF, + g: 0, + b: 0 + }, + cell.color_rgb() + ); + } +} + +#[test] +fn terminal_insert_lines_handles_style_refs() { + let mut t = init(5, 3); + + print_str(&mut t, "ABC"); + cr_lf(&mut t); + print_str(&mut t, "DEF"); + cr_lf(&mut t); + + // For the line being deleted, create a refcounted style + t.set_attribute(A::Bold).unwrap(); + print_str(&mut t, "GHI"); + t.set_attribute(A::Unset).unwrap(); + + // verify we have styles in our style map + assert_eq!(1, t.screen().cursor_page().styles.count()); + + t.set_cursor_pos(2, 2); + t.insert_lines(1); + + assert_eq!("ABC\n\nDEF", plain(&t)); + + // verify we have no styles in our style map + assert_eq!(0, t.screen().cursor_page().styles.count()); +} + +#[test] +fn terminal_insert_lines_outside_of_scroll_region() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_top_and_bottom_margin(3, 4); + t.set_cursor_pos(2, 2); + + t.clear_dirty(); + t.insert_lines(1); + + assert!(!dirty(&t, 0)); + assert!(!dirty(&t, 1)); + assert!(!dirty(&t, 2)); + assert_eq!("ABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_insert_lines_top_bottom_scroll_region() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + cr_lf(&mut t); + print_str(&mut t, "123"); + t.set_top_and_bottom_margin(1, 3); + t.set_cursor_pos(2, 2); + + t.clear_dirty(); + t.insert_lines(1); + + assert!(!dirty(&t, 0)); + assert!(dirty(&t, 1)); + assert!(dirty(&t, 2)); + assert!(!dirty(&t, 3)); + assert_eq!("ABC\n\nDEF\n123", plain(&t)); +} + +#[test] +fn terminal_insert_lines_across_page_boundary_marks_all_shifted_rows_dirty() { + let mut t = init_scrollback(10, 5, 1024); + + let first = t.screen().pages.first.unwrap(); + let first_page_nrows = usize::from(t.screen().page(first).capacity.rows); + + // Fill up the first page minus 3 rows + for _ in 0..first_page_nrows - 3 { + t.linefeed().unwrap(); + } + + // Add content that will cross a page boundary + print_str(&mut t, "1AAAA"); + cr_lf(&mut t); + print_str(&mut t, "2BBBB"); + cr_lf(&mut t); + print_str(&mut t, "3CCCC"); + cr_lf(&mut t); + print_str(&mut t, "4DDDD"); + cr_lf(&mut t); + print_str(&mut t, "5EEEE"); + + // Verify we now have a second page + assert!(t.screen().pages.node(first).unwrap().next.is_some()); + + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_lines(1); + + for y in 0..5 { + assert!(dirty(&t, y)); + } + assert_eq!("\n1AAAA\n2BBBB\n3CCCC\n4DDDD", plain(&t)); +} + +#[test] +fn terminal_insert_lines_legacy_test() { + let mut t = init(2, 5); + + // Initial value + for (i, c) in "ABCDE".chars().enumerate() { + if i > 0 { + cr_lf(&mut t); + } + t.print(u32::from(c)).unwrap(); + } + + // Move to row 2 + t.set_cursor_pos(2, 1); + + // Insert two lines + t.insert_lines(2); + + assert_eq!("A\n\n\nB\nC", plain(&t)); +} + +#[test] +fn terminal_insert_lines_zero() { + let mut t = init(2, 5); + + // This should do nothing + t.set_cursor_pos(1, 1); + t.insert_lines(0); +} + +#[test] +fn terminal_insert_lines_with_scroll_region() { + let mut t = init(2, 6); + + // Initial value + for (i, c) in "ABCDE".chars().enumerate() { + if i > 0 { + cr_lf(&mut t); + } + t.print(u32::from(c)).unwrap(); + } + + t.set_top_and_bottom_margin(1, 2); + t.set_cursor_pos(1, 1); + + t.clear_dirty(); + t.insert_lines(1); + + assert!(dirty(&t, 0)); + assert!(dirty(&t, 1)); + assert!(!dirty(&t, 2)); + + t.print(u32::from('X')).unwrap(); + + assert_eq!("X\nA\nC\nD\nE", plain(&t)); +} + +#[test] +fn terminal_insert_lines_more_than_remaining() { + let mut t = init(2, 5); + + // Initial value + for (i, c) in "ABCDE".chars().enumerate() { + if i > 0 { + cr_lf(&mut t); + } + t.print(u32::from(c)).unwrap(); + } + + // Move to row 2 + t.set_cursor_pos(2, 1); + + // Insert a bunch of lines + t.clear_dirty(); + t.insert_lines(20); + + assert!(!dirty(&t, 0)); + assert!(dirty(&t, 1)); + assert!(dirty(&t, 2)); + + assert_eq!("A", plain(&t)); +} + +#[test] +fn terminal_insert_lines_resets_pending_wrap() { + let mut t = init(5, 5); + + print_chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.insert_lines(1); + assert!(!t.screen().cursor.pending_wrap); + t.print(u32::from('B')).unwrap(); + + assert_eq!("B\nABCDE", plain(&t)); +} + +#[test] +fn terminal_insert_lines_resets_wrap() { + let mut t = init(3, 3); + + t.print(u32::from('1')).unwrap(); + cr_lf(&mut t); + print_chars(&mut t, "ABCDEF"); + t.set_cursor_pos(1, 1); + t.insert_lines(1); + t.print(u32::from('X')).unwrap(); + + assert_eq!("X\n1\nABC", plain(&t)); + + assert!(!row_at(&t, pt_active(0, 2)).wrap()); +} + +#[test] +fn terminal_insert_lines_multi_codepoint_graphemes() { + let mut t = init(5, 5); + + // Disable grapheme clustering + t.modes.set(Mode::GraphemeCluster, true); + + print_str(&mut t, "ABC"); + cr_lf(&mut t); + + // This is: 👨‍👩‍👧 (which may or may not render correctly) + t.print(0x1F468).unwrap(); + t.print(0x200D).unwrap(); + t.print(0x1F469).unwrap(); + t.print(0x200D).unwrap(); + t.print(0x1F467).unwrap(); + + cr_lf(&mut t); + print_str(&mut t, "GHI"); + t.set_cursor_pos(2, 2); + t.insert_lines(1); + + assert_eq!("ABC\n\n👨‍👩‍👧\nGHI", plain(&t)); +} + +#[test] +fn terminal_insert_lines_left_right_scroll_region() { + let mut t = init(10, 10); + + print_str(&mut t, "ABC123"); + cr_lf(&mut t); + print_str(&mut t, "DEF456"); + cr_lf(&mut t); + print_str(&mut t, "GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.set_cursor_pos(2, 2); + + t.clear_dirty(); + t.insert_lines(1); + + assert!(!dirty(&t, 0)); + assert!(dirty(&t, 1)); + assert!(dirty(&t, 2)); + assert!(dirty(&t, 3)); + + assert_eq!("ABC123\nD 56\nGEF489\n HI7", plain(&t)); +} + +#[test] +fn terminal_scroll_up_simple() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + + let (cx, cy) = (t.screen().cursor.x, t.screen().cursor.y); + let viewport_before = t.screen().pages.get_top_left(Tag::Viewport).unwrap(); + t.scroll_up(1).unwrap(); + assert_eq!(cx, t.screen().cursor.x); + assert_eq!(cy, t.screen().cursor.y); + + // Viewport should have moved. Our entire page should've scrolled! + // The viewport moving will cause our render state to make the full + // frame as dirty. + let viewport_after = t.screen().pages.get_top_left(Tag::Viewport).unwrap(); + assert!(!viewport_before.eql(viewport_after)); + + assert_eq!("DEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_scroll_up_moves_hyperlink() { + let mut t = init(5, 5); + + print_str(&mut t, "ABC"); + cr_lf(&mut t); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + print_str(&mut t, "DEF"); + t.screen_mut().end_hyperlink(); + cr_lf(&mut t); + print_str(&mut t, "GHI"); + t.set_cursor_pos(2, 2); + t.scroll_up(1).unwrap(); + + assert_eq!("DEF\nGHI", plain(&t)); + + for x in 0..3 { + expect_linked(&t, x, 0); + } + for x in 0..3 { + expect_unlinked_row(&t, x, 1); + } +} + +#[test] +fn terminal_scroll_up_clears_hyperlink() { + let mut t = init(5, 5); + + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + print_str(&mut t, "ABC"); + t.screen_mut().end_hyperlink(); + cr_lf(&mut t); + print_str(&mut t, "DEF"); + cr_lf(&mut t); + print_str(&mut t, "GHI"); + t.set_cursor_pos(2, 2); + t.scroll_up(1).unwrap(); + + assert_eq!("DEF\nGHI", plain(&t)); + + for x in 0..3 { + expect_unlinked_row(&t, x, 0); + } +} + +#[test] +fn terminal_scroll_up_top_bottom_scroll_region() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_top_and_bottom_margin(2, 3); + t.set_cursor_pos(1, 1); + + t.clear_dirty(); + t.scroll_up(1).unwrap(); + + // This is dirty because the cursor moves from this row + assert!(dirty(&t, 0)); + assert!(dirty(&t, 1)); + assert!(dirty(&t, 2)); + + assert_eq!("ABC\nGHI", plain(&t)); +} + +#[test] +fn terminal_scroll_up_left_right_scroll_region() { + let mut t = init(10, 10); + + print_str(&mut t, "ABC123"); + cr_lf(&mut t); + print_str(&mut t, "DEF456"); + cr_lf(&mut t); + print_str(&mut t, "GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.set_cursor_pos(2, 2); + + let (cx, cy) = (t.screen().cursor.x, t.screen().cursor.y); + t.clear_dirty(); + t.scroll_up(1).unwrap(); + assert_eq!(cx, t.screen().cursor.x); + assert_eq!(cy, t.screen().cursor.y); + + assert!(dirty(&t, 0)); + assert!(dirty(&t, 1)); + assert!(dirty(&t, 2)); + + assert_eq!("AEF423\nDHI756\nG 89", plain(&t)); +} + +#[test] +fn terminal_scroll_up_left_right_scroll_region_hyperlink() { + let mut t = init(10, 10); + + print_str(&mut t, "ABC123"); + cr_lf(&mut t); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + print_str(&mut t, "DEF456"); + t.screen_mut().end_hyperlink(); + cr_lf(&mut t); + print_str(&mut t, "GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.set_cursor_pos(2, 2); + t.scroll_up(1).unwrap(); + + assert_eq!("AEF423\nDHI756\nG 89", plain(&t)); + + // First row gets some hyperlinks + for x in 0..1 { + expect_unlinked_cell(&t, x, 0); + } + for x in 1..4 { + expect_linked(&t, x, 0); + } + for x in 4..6 { + expect_unlinked_cell(&t, x, 0); + } + + // Second row preserves hyperlink where we didn't scroll + for x in 0..1 { + expect_linked(&t, x, 1); + } + for x in 1..4 { + expect_unlinked_cell(&t, x, 1); + } + for x in 4..6 { + expect_linked(&t, x, 1); + } +} + +#[test] +fn terminal_scroll_up_preserves_pending_wrap() { + let mut t = init(5, 5); + + t.set_cursor_pos(1, 5); + t.print(u32::from('A')).unwrap(); + t.set_cursor_pos(2, 5); + t.print(u32::from('B')).unwrap(); + t.set_cursor_pos(3, 5); + t.print(u32::from('C')).unwrap(); + t.scroll_up(1).unwrap(); + t.print(u32::from('X')).unwrap(); + + assert_eq!(" B\n C\n\nX", plain(&t)); +} + +#[test] +fn terminal_scroll_up_full_top_bottom_region() { + let mut t = init(5, 5); + + print_str(&mut t, "top"); + t.set_cursor_pos(5, 1); + print_str(&mut t, "ABCDE"); + t.set_top_and_bottom_margin(2, 5); + + t.clear_dirty(); + t.scroll_up(4).unwrap(); + + // This is dirty because the cursor moves from this row + assert!(dirty(&t, 0)); + assert!(dirty(&t, 1)); + + assert_eq!("top", plain(&t)); +} + +#[test] +fn terminal_scroll_up_full_top_bottomleft_right_scroll_region() { + let mut t = init(5, 5); + + print_str(&mut t, "top"); + t.set_cursor_pos(5, 1); + print_str(&mut t, "ABCDE"); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_top_and_bottom_margin(2, 5); + t.set_left_and_right_margin(2, 4); + + t.clear_dirty(); + t.scroll_up(4).unwrap(); + + // This is dirty because the cursor moves from this row + assert!(dirty(&t, 0)); + for y in 1..5 { + assert!(dirty(&t, y)); + } + + assert_eq!("top\n\n\n\nA E", plain(&t)); +} + +#[test] +fn terminal_scroll_up_creates_scrollback_in_primary_screen() { + // When in primary screen with full-width scroll region at top, + // scrollUp (CSI S) should push lines into scrollback like xterm. + let mut t = init_scrollback(5, 5, 10); + + // Fill the screen with content + print_str(&mut t, "AAAAA"); + cr_lf(&mut t); + print_str(&mut t, "BBBBB"); + cr_lf(&mut t); + print_str(&mut t, "CCCCC"); + cr_lf(&mut t); + print_str(&mut t, "DDDDD"); + cr_lf(&mut t); + print_str(&mut t, "EEEEE"); + + t.clear_dirty(); + + // Scroll up by 1, which should push "AAAAA" into scrollback + t.scroll_up(1).unwrap(); + + // The cursor row (new empty row) should be dirty + assert!(t.screen().cursor_row().dirty()); + + // The active screen should now show BBBBB through EEEEE plus one blank line + assert_eq!("BBBBB\nCCCCC\nDDDDD\nEEEEE", plain(&t)); + + // Now scroll to the top to see scrollback - AAAAA should be there + t.screen_mut().scroll(Scroll::Top); + assert_eq!("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", plain(&t)); +} + +#[test] +fn terminal_scroll_up_with_max_scrollback_zero() { + // When max_scrollback is 0, scrollUp should still work but not retain history + let mut t = init_scrollback(5, 5, 0); + + print_str(&mut t, "AAAAA"); + cr_lf(&mut t); + print_str(&mut t, "BBBBB"); + cr_lf(&mut t); + print_str(&mut t, "CCCCC"); + + t.scroll_up(1).unwrap(); + + // Active screen should show scrolled content + assert_eq!("BBBBB\nCCCCC", plain(&t)); + + // Scroll to top - should be same as active since no scrollback + t.screen_mut().scroll(Scroll::Top); + assert_eq!("BBBBB\nCCCCC", plain(&t)); +} + +#[test] +fn terminal_scroll_up_with_max_scrollback_zero_and_top_margin() { + // When max_scrollback is 0 and top margin is set, should use deleteLines path + let mut t = init_scrollback(5, 5, 0); + + print_str(&mut t, "AAAAA"); + cr_lf(&mut t); + print_str(&mut t, "BBBBB"); + cr_lf(&mut t); + print_str(&mut t, "CCCCC"); + cr_lf(&mut t); + print_str(&mut t, "DDDDD"); + + // Set top margin (not at row 0) + t.set_top_and_bottom_margin(2, 5); + + t.scroll_up(1).unwrap(); + + // First row preserved, rest scrolled + assert_eq!("AAAAA\nCCCCC\nDDDDD", plain(&t)); +} + +#[test] +fn terminal_scroll_up_with_max_scrollback_zero_and_left_right_margin() { + // When max_scrollback is 0 with left/right margins, uses deleteLines path + let mut t = init_scrollback(10, 5, 0); + + print_str(&mut t, "AAAAABBBBB"); + cr_lf(&mut t); + print_str(&mut t, "CCCCCDDDDD"); + cr_lf(&mut t); + print_str(&mut t, "EEEEEFFFFF"); + + // Set left/right margins (columns 2-6, 1-indexed = indices 1-5) + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(2, 6); + + t.scroll_up(1).unwrap(); + + // cols 1-5 scroll, col 0 and cols 6+ preserved + assert_eq!("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", plain(&t)); +} + +#[test] +fn terminal_scroll_down_simple() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + + let (cx, cy) = (t.screen().cursor.x, t.screen().cursor.y); + t.clear_dirty(); + t.scroll_down(1); + assert_eq!(cx, t.screen().cursor.x); + assert_eq!(cy, t.screen().cursor.y); + + for y in 0..5 { + assert!(dirty(&t, y)); + } + + assert_eq!("\nABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_scroll_down_hyperlink_moves() { + let mut t = init(5, 5); + + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + print_str(&mut t, "ABC"); + t.screen_mut().end_hyperlink(); + cr_lf(&mut t); + print_str(&mut t, "DEF"); + cr_lf(&mut t); + print_str(&mut t, "GHI"); + t.set_cursor_pos(2, 2); + t.scroll_down(1); + + assert_eq!("\nABC\nDEF\nGHI", plain(&t)); + + for x in 0..3 { + expect_linked(&t, x, 1); + } + for x in 0..3 { + expect_unlinked_row(&t, x, 0); + } +} + +#[test] +fn terminal_scroll_down_outside_of_scroll_region() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_top_and_bottom_margin(3, 4); + t.set_cursor_pos(2, 2); + + let (cx, cy) = (t.screen().cursor.x, t.screen().cursor.y); + t.clear_dirty(); + t.scroll_down(1); + assert_eq!(cx, t.screen().cursor.x); + assert_eq!(cy, t.screen().cursor.y); + + assert!(!dirty(&t, 0)); + + // This is dirty because the cursor moves from this row + assert!(dirty(&t, 1)); + assert!(dirty(&t, 2)); + assert!(dirty(&t, 3)); + + assert_eq!("ABC\nDEF\n\nGHI", plain(&t)); +} + +#[test] +fn terminal_scroll_down_left_right_scroll_region() { + let mut t = init(10, 10); + + print_str(&mut t, "ABC123"); + cr_lf(&mut t); + print_str(&mut t, "DEF456"); + cr_lf(&mut t); + print_str(&mut t, "GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.set_cursor_pos(2, 2); + + let (cx, cy) = (t.screen().cursor.x, t.screen().cursor.y); + t.clear_dirty(); + t.scroll_down(1); + assert_eq!(cx, t.screen().cursor.x); + assert_eq!(cy, t.screen().cursor.y); + + for y in 0..4 { + assert!(dirty(&t, y)); + } + + assert_eq!("A 23\nDBC156\nGEF489\n HI7", plain(&t)); +} + +#[test] +fn terminal_scroll_down_left_right_scroll_region_hyperlink() { + let mut t = init(10, 10); + + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + print_str(&mut t, "ABC123"); + t.screen_mut().end_hyperlink(); + cr_lf(&mut t); + print_str(&mut t, "DEF456"); + cr_lf(&mut t); + print_str(&mut t, "GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.set_cursor_pos(2, 2); + t.scroll_down(1); + + assert_eq!("A 23\nDBC156\nGEF489\n HI7", plain(&t)); + + // First row preserves hyperlink where we didn't scroll + for x in 0..1 { + expect_linked(&t, x, 0); + } + for x in 1..4 { + expect_unlinked_cell(&t, x, 0); + } + for x in 4..6 { + expect_linked(&t, x, 0); + } + + // Second row gets some hyperlinks + for x in 0..1 { + expect_unlinked_cell(&t, x, 1); + } + for x in 1..4 { + expect_linked(&t, x, 1); + } + for x in 4..6 { + expect_unlinked_cell(&t, x, 1); + } +} + +#[test] +fn terminal_scroll_down_outside_of_left_right_scroll_region() { + let mut t = init(10, 10); + + print_str(&mut t, "ABC123"); + cr_lf(&mut t); + print_str(&mut t, "DEF456"); + cr_lf(&mut t); + print_str(&mut t, "GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.set_cursor_pos(1, 1); + + let (cx, cy) = (t.screen().cursor.x, t.screen().cursor.y); + t.clear_dirty(); + t.scroll_down(1); + assert_eq!(cx, t.screen().cursor.x); + assert_eq!(cy, t.screen().cursor.y); + + for y in 0..4 { + assert!(dirty(&t, y)); + } + + assert_eq!("A 23\nDBC156\nGEF489\n HI7", plain(&t)); +} + +#[test] +fn terminal_scroll_down_preserves_pending_wrap() { + let mut t = init(5, 10); + + t.set_cursor_pos(1, 5); + t.print(u32::from('A')).unwrap(); + t.set_cursor_pos(2, 5); + t.print(u32::from('B')).unwrap(); + t.set_cursor_pos(3, 5); + t.print(u32::from('C')).unwrap(); + t.scroll_down(1); + t.print(u32::from('X')).unwrap(); + + assert_eq!("\n A\n B\nX C", plain(&t)); +} + +#[test] +fn terminal_erase_chars_simple_operation() { + let mut t = init(5, 5); + + print_chars(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.erase_chars(2); + t.print(u32::from('X')).unwrap(); + + assert!(dirty(&t, 0)); + assert!(!dirty(&t, 1)); + + assert_eq!("X C", plain(&t)); +} + +#[test] +fn terminal_erase_chars_minimum_one() { + let mut t = init(5, 5); + + print_chars(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.erase_chars(0); + t.print(u32::from('X')).unwrap(); + assert!(dirty(&t, 0)); + + assert_eq!("XBC", plain(&t)); +} + +#[test] +fn terminal_erase_chars_beyond_screen_edge() { + let mut t = init(5, 5); + + print_chars(&mut t, " ABC"); + t.set_cursor_pos(1, 4); + t.erase_chars(10); + + assert_eq!(" A", plain(&t)); +} + +#[test] +fn terminal_erase_chars_wide_character() { + let mut t = init(5, 5); + + t.print(u32::from('橋')).unwrap(); + print_chars(&mut t, "BC"); + t.set_cursor_pos(1, 1); + t.erase_chars(1); + t.print(u32::from('X')).unwrap(); + + assert_eq!("X BC", plain(&t)); +} + +#[test] +fn terminal_erase_chars_resets_pending_wrap() { + let mut t = init(5, 5); + + print_chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.erase_chars(1); + assert!(!t.screen().cursor.pending_wrap); + t.print(u32::from('X')).unwrap(); + + assert_eq!("ABCDX", plain(&t)); +} + +#[test] +fn terminal_erase_chars_resets_wrap() { + let mut t = init(5, 5); + + print_chars(&mut t, "ABCDE123"); + assert!(row_at(&t, pt_active(0, 0)).wrap()); + + t.set_cursor_pos(1, 1); + t.erase_chars(1); + + assert!(!row_at(&t, pt_active(0, 0)).wrap()); + + t.print(u32::from('X')).unwrap(); + + assert_eq!("XBCDE\n123", plain(&t)); +} diff --git a/crates/ghostty-terminal/src/terminal/tests/p4.rs b/crates/ghostty-terminal/src/terminal/tests/p4.rs new file mode 100644 index 0000000..9de6ef1 --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/p4.rs @@ -0,0 +1,731 @@ +use super::*; +use ghostty_vt::color::Rgb; +use ghostty_vt::sgr::Attribute as A; + +const RED: Rgb = Rgb { + r: 0xFF, + g: 0, + b: 0, +}; + +fn chars(t: &mut Terminal, s: &str) { + for c in s.chars() { + t.print(u32::from(c)).unwrap(); + } +} + +fn set_bg_red(t: &mut Terminal) { + t.set_attribute(A::DirectColorBg(RED)).unwrap(); +} + +fn assert_bg_red(t: &Terminal, pt: Point) { + let c = cell_at(t, pt); + assert_eq!(ContentTag::BgColorRgb, c.content_tag()); + assert_eq!(RED, c.color_rgb()); +} + +fn hl_id(t: &Terminal, pt: Point) -> (bool, bool, Option) { + let p = pin_at(t, pt); + let page = t.screen().page(p.node); + let i = page.cell_index(usize::from(p.x), usize::from(p.y)).unwrap(); + ( + page.rows[usize::from(p.y)].hyperlink(), + page.cells[i].hyperlink(), + page.lookup_hyperlink(i), + ) +} + +#[test] +fn terminal_erase_chars_preserves_background_sgr() { + let mut t = init(10, 10); + chars(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + set_bg_red(&mut t); + t.erase_chars(2); + assert_eq!(" C", plain(&t)); + assert_bg_red(&t, pt_active(0, 0)); + assert_bg_red(&t, pt_active(1, 0)); +} + +#[test] +fn terminal_erase_chars_handles_refcounted_styles() { + let mut t = init(10, 10); + t.set_attribute(A::Bold).unwrap(); + t.print('A' as u32).unwrap(); + t.print('B' as u32).unwrap(); + t.set_attribute(A::Unset).unwrap(); + t.print('C' as u32).unwrap(); + + assert_eq!(1, t.screen().cursor_page().styles.count()); + t.set_cursor_pos(1, 1); + t.erase_chars(2); + assert_eq!(0, t.screen().cursor_page().styles.count()); +} + +#[test] +fn terminal_erase_chars_protected_attributes_respected_with_iso() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + chars(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.erase_chars(2); + assert_eq!("ABC", plain(&t)); +} + +#[test] +fn terminal_erase_chars_protected_attributes_ignored_with_dec_most_recent() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + chars(&mut t, "ABC"); + t.set_protected_mode(ProtectedMode::Dec); + t.set_protected_mode(ProtectedMode::Off); + t.set_cursor_pos(1, 1); + t.erase_chars(2); + assert_eq!(" C", plain(&t)); +} + +#[test] +fn terminal_erase_chars_protected_attributes_ignored_with_dec_set() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Dec); + chars(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.erase_chars(2); + assert_eq!(" C", plain(&t)); +} + +#[test] +fn terminal_erase_chars_wide_char_boundary_conditions() { + let mut t = init(8, 1); + print_str(&mut t, "😀a😀b😀"); + assert_eq!("😀a😀b😀", plain(&t)); + + t.set_cursor_pos(1, 2); + t.erase_chars(3); + t.screen().cursor_page().assert_integrity(); + assert_eq!(" b😀", plain(&t)); +} + +#[test] +fn terminal_erase_chars_wide_char_splits_proper_cell_boundaries() { + let mut t = init(30, 1); + print_str(&mut t, "x食べて下さい"); + assert_eq!("x食べて下さい", plain(&t)); + + t.set_cursor_pos(1, 6); // At: て + t.erase_chars(4); // Delete: て下 + t.screen().cursor_page().assert_integrity(); + assert_eq!("x食べ さい", plain(&t)); +} + +#[test] +fn terminal_erase_chars_wide_char_wrap_boundary_conditions() { + let mut t = init(8, 3); + print_str(&mut t, ".......😀abcde😀......"); + assert_eq!(".......\n😀abcde\n😀......", plain(&t)); + assert_eq!(".......😀abcde😀......", plain_unwrapped(&t)); + + t.set_cursor_pos(2, 2); + t.erase_chars(3); + t.screen().cursor_page().assert_integrity(); + assert_eq!(".......\n cde\n😀......", plain(&t)); + assert_eq!("....... cde\n😀......", plain_unwrapped(&t)); +} + +#[test] +fn terminal_reverse_index() { + let mut t = init(2, 5); + t.print('A' as u32).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.print('B' as u32).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.print('C' as u32).unwrap(); + t.reverse_index(); + t.print('D' as u32).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + assert_eq!("A\nBD\nC", plain(&t)); +} + +#[test] +fn terminal_reverse_index_from_the_top() { + let mut t = init(2, 5); + t.print('A' as u32).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.print('B' as u32).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + + t.set_cursor_pos(1, 1); + t.reverse_index(); + t.print('D' as u32).unwrap(); + + t.carriage_return(); + t.linefeed().unwrap(); + t.set_cursor_pos(1, 1); + t.reverse_index(); + t.print('E' as u32).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + assert_eq!("E\nD\nA\nB", plain(&t)); +} + +#[test] +fn terminal_reverse_index_top_of_scrolling_region() { + let mut t = init(2, 10); + t.set_cursor_pos(2, 1); + for c in ['A', 'B', 'C', 'D'] { + t.print(c as u32).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + } + + t.set_top_and_bottom_margin(2, 5); + t.set_cursor_pos(2, 1); + t.reverse_index(); + t.print('X' as u32).unwrap(); + assert_eq!("\nX\nA\nB\nC", plain(&t)); +} + +#[test] +fn terminal_reverse_index_top_of_screen() { + let mut t = init(5, 5); + t.print('A' as u32).unwrap(); + t.set_cursor_pos(2, 1); + t.print('B' as u32).unwrap(); + t.set_cursor_pos(3, 1); + t.print('C' as u32).unwrap(); + t.set_cursor_pos(1, 1); + t.reverse_index(); + t.print('X' as u32).unwrap(); + assert_eq!("X\nA\nB\nC", plain(&t)); +} + +#[test] +fn terminal_reverse_index_not_top_of_screen() { + let mut t = init(5, 5); + t.print('A' as u32).unwrap(); + t.set_cursor_pos(2, 1); + t.print('B' as u32).unwrap(); + t.set_cursor_pos(3, 1); + t.print('C' as u32).unwrap(); + t.set_cursor_pos(2, 1); + t.reverse_index(); + t.print('X' as u32).unwrap(); + assert_eq!("X\nB\nC", plain(&t)); +} + +#[test] +fn terminal_reverse_index_top_bottom_margins() { + let mut t = init(5, 5); + t.print('A' as u32).unwrap(); + t.set_cursor_pos(2, 1); + t.print('B' as u32).unwrap(); + t.set_cursor_pos(3, 1); + t.print('C' as u32).unwrap(); + t.set_top_and_bottom_margin(2, 3); + t.set_cursor_pos(2, 1); + t.reverse_index(); + assert_eq!("A\n\nB", plain(&t)); +} + +#[test] +fn terminal_reverse_index_outside_top_bottom_margins() { + let mut t = init(5, 5); + t.print('A' as u32).unwrap(); + t.set_cursor_pos(2, 1); + t.print('B' as u32).unwrap(); + t.set_cursor_pos(3, 1); + t.print('C' as u32).unwrap(); + t.set_top_and_bottom_margin(2, 3); + t.set_cursor_pos(1, 1); + t.reverse_index(); + assert_eq!("A\nB\nC", plain(&t)); +} + +fn abc_def_ghi(t: &mut Terminal) { + print_str(t, "ABC"); + t.set_cursor_pos(2, 1); + print_str(t, "DEF"); + t.set_cursor_pos(3, 1); + print_str(t, "GHI"); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(2, 3); +} + +#[test] +fn terminal_reverse_index_left_right_margins() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(1, 2); + t.reverse_index(); + assert_eq!("A\nDBC\nGEF\n HI", plain(&t)); +} + +#[test] +fn terminal_reverse_index_outside_left_right_margins() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(1, 1); + t.reverse_index(); + assert_eq!("ABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_index() { + let mut t = init(2, 5); + t.index().unwrap(); + t.print('A' as u32).unwrap(); + assert!(t.is_dirty(pt_active(0, 0))); + assert!(t.is_dirty(pt_active(0, 1))); + assert_eq!("\nA", plain(&t)); +} + +#[test] +fn terminal_index_from_the_bottom() { + let mut t = init(2, 5); + t.set_cursor_pos(5, 1); + t.print('A' as u32).unwrap(); + t.cursor_left(1); // undo moving right from 'A' + + t.clear_dirty(); + t.index().unwrap(); + t.print('B' as u32).unwrap(); + + assert!(t.is_dirty(pt_active(0, 3))); + assert!(t.is_dirty(pt_active(0, 4))); + assert_eq!("\n\n\nA\nB", plain(&t)); +} + +#[test] +fn terminal_index_scrolling_with_hyperlink() { + let mut t = init(2, 5); + t.set_cursor_pos(5, 1); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + t.print('A' as u32).unwrap(); + t.screen_mut().end_hyperlink(); + t.cursor_left(1); // undo moving right from 'A' + t.index().unwrap(); + t.print('B' as u32).unwrap(); + + assert_eq!("\n\n\nA\nB", plain(&t)); + + let (row, cell, id) = hl_id(&t, pt_viewport(0, 3)); + assert!(row); + assert!(cell); + assert_eq!(Some(1), id); + let (row, cell, id) = hl_id(&t, pt_viewport(0, 4)); + assert!(!row); + assert!(!cell); + assert_eq!(None, id); +} + +#[test] +fn terminal_index_outside_of_scrolling_region() { + let mut t = init(2, 5); + assert_eq!(0, t.screen().cursor.y); + t.set_top_and_bottom_margin(2, 5); + t.index().unwrap(); + assert_eq!(1, t.screen().cursor.y); +} + +#[test] +fn terminal_index_from_the_bottom_outside_of_scroll_region() { + let mut t = init(2, 5); + t.set_top_and_bottom_margin(1, 2); + t.set_cursor_pos(5, 1); + t.print('A' as u32).unwrap(); + t.clear_dirty(); + t.index().unwrap(); + t.print('B' as u32).unwrap(); + assert!(t.is_dirty(pt_active(0, 4))); + assert_eq!("\n\n\n\nAB", plain(&t)); +} + +#[test] +fn terminal_index_no_scroll_region_top_of_screen() { + let mut t = init(5, 5); + t.print('A' as u32).unwrap(); + t.clear_dirty(); + t.index().unwrap(); + t.print('X' as u32).unwrap(); + assert!(t.is_dirty(pt_active(0, 0))); + assert!(t.is_dirty(pt_active(0, 1))); + assert_eq!("A\n X", plain(&t)); +} + +#[test] +fn terminal_index_bottom_of_primary_screen() { + let mut t = init(5, 5); + t.set_cursor_pos(5, 1); + t.print('A' as u32).unwrap(); + t.clear_dirty(); + t.index().unwrap(); + t.print('X' as u32).unwrap(); + assert!(t.is_dirty(pt_active(0, 3))); + assert!(t.is_dirty(pt_active(0, 4))); + assert_eq!("\n\n\nA\n X", plain(&t)); +} + +#[test] +fn terminal_index_bottom_of_primary_screen_background_sgr() { + let mut t = init(5, 5); + t.set_cursor_pos(5, 1); + t.print('A' as u32).unwrap(); + set_bg_red(&mut t); + t.index().unwrap(); + + assert_eq!("\n\n\nA", plain(&t)); + for x in 0..5 { + assert_bg_red(&t, pt_active(x, 4)); + } +} + +#[test] +fn terminal_index_inside_scroll_region() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(1, 3); + t.print('A' as u32).unwrap(); + t.clear_dirty(); + t.index().unwrap(); + t.print('X' as u32).unwrap(); + assert!(t.is_dirty(pt_active(0, 0))); + assert!(t.is_dirty(pt_active(0, 1))); + assert_eq!("A\n X", plain(&t)); +} + +#[test] +fn terminal_index_bottom_of_scroll_region_with_hyperlinks() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(1, 2); + t.print('A' as u32).unwrap(); + t.index().unwrap(); + t.carriage_return(); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + t.print('B' as u32).unwrap(); + t.screen_mut().end_hyperlink(); + t.index().unwrap(); + t.carriage_return(); + t.print('C' as u32).unwrap(); + + assert_eq!("B\nC", plain(&t)); + + let (row, cell, id) = hl_id(&t, pt_viewport(0, 0)); + assert!(row); + assert!(cell); + assert_eq!(Some(1), id); + let (row, cell, id) = hl_id(&t, pt_viewport(0, 1)); + assert!(!row); + assert!(!cell); + assert_eq!(None, id); +} + +#[test] +#[ignore = "pagelist: erase_row_bounded leaves the row hyperlink flag set on the blanked row"] +fn terminal_index_bottom_of_scroll_region_clear_hyperlinks() { + let mut t = init_scrollback(5, 5, 0); + t.set_top_and_bottom_margin(2, 3); + t.set_cursor_pos(2, 1); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + t.print('A' as u32).unwrap(); + t.screen_mut().end_hyperlink(); + t.index().unwrap(); + t.carriage_return(); + t.print('B' as u32).unwrap(); + t.index().unwrap(); + t.carriage_return(); + t.print('C' as u32).unwrap(); + + assert_eq!("\nB\nC", plain(&t)); + + for y in 1..3 { + let (row, cell, id) = hl_id(&t, pt_viewport(0, y)); + assert!(!row); + assert!(!cell); + assert_eq!(None, id); + let p = pin_at(&t, pt_viewport(0, y)); + assert_eq!(0, t.screen().page(p.node).hyperlink_set.count()); + } +} + +#[test] +fn terminal_index_bottom_of_scroll_region_with_background_sgr() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(1, 3); + t.set_cursor_pos(4, 1); + t.print('B' as u32).unwrap(); + t.set_cursor_pos(3, 1); + t.print('A' as u32).unwrap(); + set_bg_red(&mut t); + t.index().unwrap(); + + assert_eq!("\nA\n\nB", plain(&t)); + for x in 0..t.cols { + assert_bg_red(&t, pt_active(x, 2)); + } +} + +#[test] +fn terminal_index_bottom_of_primary_screen_with_scroll_region() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(1, 3); + t.set_cursor_pos(3, 1); + t.print('A' as u32).unwrap(); + t.set_cursor_pos(5, 1); + t.clear_dirty(); + t.index().unwrap(); + t.index().unwrap(); + t.index().unwrap(); + t.print('X' as u32).unwrap(); + + for y in 0..4 { + assert!(!t.is_dirty(pt_active(0, y))); + } + assert!(t.is_dirty(pt_active(0, 4))); + assert_eq!("\n\nA\n\nX", plain(&t)); +} + +#[test] +fn terminal_index_outside_left_right_margin() { + let mut t = init(10, 5); + t.set_top_and_bottom_margin(1, 3); + t.scrolling_region.left = 3; + t.scrolling_region.right = 5; + t.set_cursor_pos(3, 3); + t.print('A' as u32).unwrap(); + t.set_cursor_pos(3, 1); + t.clear_dirty(); + t.index().unwrap(); + t.print('X' as u32).unwrap(); + + assert!(t.is_dirty(pt_active(0, 2))); + assert_eq!("\n\nX A", plain(&t)); +} + +#[test] +fn terminal_index_inside_left_right_margin() { + let mut t = init(10, 5); + print_str(&mut t, "AAAAAA"); + t.carriage_return(); + t.linefeed().unwrap(); + print_str(&mut t, "AAAAAA"); + t.carriage_return(); + t.linefeed().unwrap(); + print_str(&mut t, "AAAAAA"); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_top_and_bottom_margin(1, 3); + t.set_left_and_right_margin(1, 3); + t.set_cursor_pos(3, 1); + + t.clear_dirty(); + t.index().unwrap(); + + assert!(t.is_dirty(pt_active(0, 0))); + assert!(t.is_dirty(pt_active(0, 1))); + assert!(t.is_dirty(pt_active(0, 2))); + + assert_eq!(2, t.screen().cursor.y); + assert_eq!(0, t.screen().cursor.x); + + assert_eq!("AAAAAA\nAAAAAA\n AAA", plain(&t)); +} + +#[test] +fn terminal_index_bottom_of_scroll_region_creates_scrollback() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(1, 3); + print_str(&mut t, "1\n2\n3"); + t.set_cursor_pos(4, 1); + t.print('X' as u32).unwrap(); + t.set_cursor_pos(3, 1); + t.index().unwrap(); + t.print('Y' as u32).unwrap(); + + assert_eq!("2\n3\nY\nX", dump(&t, pt_viewport(0, 0))); + assert_eq!("1\n2\n3\nY\nX", dump(&t, pt_screen(0, 0))); +} + +#[test] +fn terminal_index_bottom_of_scroll_region_no_scrollback() { + let mut t = init_scrollback(5, 5, 0); + t.set_top_and_bottom_margin(1, 3); + t.set_cursor_pos(4, 1); + t.print('B' as u32).unwrap(); + t.set_cursor_pos(3, 1); + t.print('A' as u32).unwrap(); + t.clear_dirty(); + t.index().unwrap(); + t.print('X' as u32).unwrap(); + assert_eq!("\nA\n X\nB", plain(&t)); +} + +#[test] +fn terminal_index_bottom_of_scroll_region_blank_line_preserves_sgr() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(1, 3); + print_str(&mut t, "1\n2\n3"); + t.set_cursor_pos(4, 1); + t.print('X' as u32).unwrap(); + t.set_cursor_pos(3, 1); + set_bg_red(&mut t); + t.index().unwrap(); + + assert_eq!("2\n3\n\nX", dump(&t, pt_viewport(0, 0))); + assert_eq!("1\n2\n3\n\nX", dump(&t, pt_screen(0, 0))); + for x in 0..t.cols { + assert_bg_red(&t, pt_active(x, 2)); + } +} + +#[test] +fn terminal_cursor_up_basic() { + let mut t = init(5, 5); + t.set_cursor_pos(3, 1); + t.print('A' as u32).unwrap(); + t.cursor_up(10); + t.print('X' as u32).unwrap(); + assert_eq!(" X\n\nA", plain(&t)); +} + +#[test] +fn terminal_cursor_up_below_top_scroll_margin() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(2, 4); + t.set_cursor_pos(3, 1); + t.print('A' as u32).unwrap(); + t.cursor_up(5); + t.print('X' as u32).unwrap(); + assert_eq!("\n X\nA", plain(&t)); +} + +#[test] +fn terminal_cursor_up_above_top_scroll_margin() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(3, 5); + t.set_cursor_pos(3, 1); + t.print('A' as u32).unwrap(); + t.set_cursor_pos(2, 1); + t.cursor_up(10); + t.print('X' as u32).unwrap(); + assert_eq!("X\n\nA", plain(&t)); +} + +#[test] +fn terminal_cursor_up_resets_wrap() { + let mut t = init(5, 5); + chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.cursor_up(1); + assert!(!t.screen().cursor.pending_wrap); + t.print('X' as u32).unwrap(); + assert_eq!("ABCDX", plain(&t)); +} + +#[test] +fn terminal_cursor_left_no_wrap() { + let mut t = init(10, 5); + t.print('A' as u32).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.print('B' as u32).unwrap(); + t.cursor_left(10); + assert_eq!("A\nB", plain(&t)); +} + +#[test] +fn terminal_cursor_left_unsets_pending_wrap_state() { + let mut t = init(5, 5); + chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.cursor_left(1); + assert!(!t.screen().cursor.pending_wrap); + t.print('X' as u32).unwrap(); + assert_eq!("ABCXE", plain(&t)); +} + +#[test] +fn terminal_cursor_left_unsets_pending_wrap_state_with_longer_jump() { + let mut t = init(5, 5); + chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.cursor_left(3); + assert!(!t.screen().cursor.pending_wrap); + t.print('X' as u32).unwrap(); + assert_eq!("AXCDE", plain(&t)); +} + +#[test] +fn terminal_cursor_left_reverse_wrap_with_pending_wrap_state() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrap, true); + chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.cursor_left(1); + assert!(!t.screen().cursor.pending_wrap); + t.print('X' as u32).unwrap(); + assert_eq!("ABCDX", plain(&t)); +} + +#[test] +fn terminal_cursor_left_reverse_wrap_extended_with_pending_wrap_state() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrapExtended, true); + chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.cursor_left(1); + assert!(!t.screen().cursor.pending_wrap); + t.print('X' as u32).unwrap(); + assert_eq!("ABCDX", plain(&t)); +} + +#[test] +fn terminal_cursor_left_reverse_wrap() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrap, true); + chars(&mut t, "ABCDE1"); + t.cursor_left(2); + t.print('X' as u32).unwrap(); + assert!(t.screen().cursor.pending_wrap); + assert_eq!("ABCDX\n1", plain(&t)); +} + +#[test] +fn terminal_cursor_left_reverse_wrap_with_no_soft_wrap() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrap, true); + chars(&mut t, "ABCDE"); + t.carriage_return(); + t.linefeed().unwrap(); + t.print('1' as u32).unwrap(); + t.cursor_left(2); + t.print('X' as u32).unwrap(); + assert_eq!("ABCDE\nX", plain(&t)); +} + +#[test] +fn terminal_cursor_left_reverse_wrap_before_left_margin() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrap, true); + t.set_top_and_bottom_margin(3, 0); + t.cursor_left(1); + t.print('X' as u32).unwrap(); + assert_eq!("\n\nX", plain(&t)); +} diff --git a/crates/ghostty-terminal/src/terminal/tests/p5.rs b/crates/ghostty-terminal/src/terminal/tests/p5.rs new file mode 100644 index 0000000..75fdfd3 --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/p5.rs @@ -0,0 +1,649 @@ +use super::*; +use crate::page::ContentTag; +use ghostty_vt::color::Rgb; +use ghostty_vt::sgr::Attribute; + +fn prints(t: &mut Terminal, s: &str) { + for c in s.chars() { + t.print(u32::from(c)).unwrap(); + } +} + +fn nl(t: &mut Terminal) { + t.carriage_return(); + t.linefeed().unwrap(); +} + +fn dirty(t: &Terminal, x: u16, y: u32) -> bool { + t.is_dirty(pt_active(x, y)) +} + +fn abc_def_ghi(t: &mut Terminal) { + print_str(t, "ABC"); + nl(t); + print_str(t, "DEF"); + nl(t); + print_str(t, "GHI"); +} + +fn abcd_lines(t: &mut Terminal) { + for (i, c) in ['A', 'B', 'C', 'D'].into_iter().enumerate() { + if i > 0 { + nl(t); + } + t.print(u32::from(c)).unwrap(); + } +} + +const RED: Rgb = Rgb { + r: 0xFF, + g: 0, + b: 0, +}; + +#[test] +fn terminal_cursor_left_extended_reverse_wrap() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrapExtended, true); + prints(&mut t, "ABCDE"); + nl(&mut t); + t.print(u32::from('1')).unwrap(); + t.cursor_left(2); + t.print(u32::from('X')).unwrap(); + assert_eq!("ABCDX\n1", plain(&t)); +} + +#[test] +fn terminal_cursor_left_extended_reverse_wrap_bottom_wraparound() { + let mut t = init(5, 3); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrapExtended, true); + prints(&mut t, "ABCDE"); + nl(&mut t); + t.print(u32::from('1')).unwrap(); + t.cursor_left(1 + usize::from(t.cols) + 1); + t.print(u32::from('X')).unwrap(); + assert_eq!("ABCDE\n1\n X", plain(&t)); +} + +#[test] +fn terminal_cursor_left_extended_reverse_wrap_is_priority_if_both_set() { + let mut t = init(5, 3); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrap, true); + t.modes.set(Mode::ReverseWrapExtended, true); + prints(&mut t, "ABCDE"); + nl(&mut t); + t.print(u32::from('1')).unwrap(); + t.cursor_left(1 + usize::from(t.cols) + 1); + t.print(u32::from('X')).unwrap(); + assert_eq!("ABCDE\n1\n X", plain(&t)); +} + +#[test] +fn terminal_cursor_left_extended_reverse_wrap_above_top_scroll_region() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrapExtended, true); + t.set_top_and_bottom_margin(3, 0); + t.set_cursor_pos(2, 1); + t.cursor_left(1000); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(0, t.screen().cursor.y); +} + +#[test] +fn terminal_cursor_left_reverse_wrap_on_first_row() { + let mut t = init(5, 5); + t.modes.set(Mode::Wraparound, true); + t.modes.set(Mode::ReverseWrap, true); + t.set_top_and_bottom_margin(3, 0); + t.set_cursor_pos(1, 2); + t.cursor_left(1000); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(0, t.screen().cursor.y); +} + +#[test] +fn terminal_cursor_down_basic() { + let mut t = init(5, 5); + t.print(u32::from('A')).unwrap(); + t.cursor_down(10); + t.print(u32::from('X')).unwrap(); + assert_eq!("A\n\n\n\n X", plain(&t)); +} + +#[test] +fn terminal_cursor_down_above_bottom_scroll_margin() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(1, 3); + t.print(u32::from('A')).unwrap(); + t.cursor_down(10); + t.print(u32::from('X')).unwrap(); + assert_eq!("A\n\n X", plain(&t)); +} + +#[test] +fn terminal_cursor_down_below_bottom_scroll_margin() { + let mut t = init(5, 5); + t.set_top_and_bottom_margin(1, 3); + t.print(u32::from('A')).unwrap(); + t.set_cursor_pos(4, 1); + t.cursor_down(10); + t.print(u32::from('X')).unwrap(); + assert_eq!("A\n\n\n\nX", plain(&t)); +} + +#[test] +fn terminal_cursor_down_resets_wrap() { + let mut t = init(5, 5); + prints(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.cursor_down(1); + assert!(!t.screen().cursor.pending_wrap); + t.print(u32::from('X')).unwrap(); + assert_eq!("ABCDE\n X", plain(&t)); +} + +#[test] +fn terminal_cursor_right_resets_wrap() { + let mut t = init(5, 5); + prints(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.cursor_right(1); + assert!(!t.screen().cursor.pending_wrap); + t.print(u32::from('X')).unwrap(); + assert_eq!("ABCDX", plain(&t)); +} + +#[test] +fn terminal_cursor_right_to_the_edge_of_screen() { + let mut t = init(5, 5); + t.cursor_right(100); + t.print(u32::from('X')).unwrap(); + assert_eq!(" X", plain(&t)); +} + +#[test] +fn terminal_cursor_right_left_of_right_margin() { + let mut t = init(5, 5); + t.scrolling_region.right = 2; + t.cursor_right(100); + t.print(u32::from('X')).unwrap(); + assert_eq!(" X", plain(&t)); +} + +#[test] +fn terminal_cursor_right_right_of_right_margin() { + let mut t = init(5, 5); + t.scrolling_region.right = 2; + t.set_cursor_pos(1, 4); + t.cursor_right(100); + t.print(u32::from('X')).unwrap(); + assert_eq!(" X", plain(&t)); +} + +#[test] +fn terminal_delete_lines_simple() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.clear_dirty(); + t.delete_lines(1); + assert!(!dirty(&t, 0, 0)); + assert!(dirty(&t, 0, 1)); + assert!(dirty(&t, 0, 2)); + assert!(dirty(&t, 0, 3)); + assert_eq!("ABC\nGHI", plain(&t)); +} + +#[test] +fn terminal_delete_lines_colors_with_bg_color() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.set_attribute(Attribute::DirectColorBg(RED)).unwrap(); + t.delete_lines(1); + assert_eq!("ABC\nGHI", plain(&t)); + for x in 0..t.cols { + let c = cell_at(&t, pt_active(x, 4)); + assert_eq!(ContentTag::BgColorRgb, c.content_tag()); + assert_eq!(RED, c.color_rgb()); + } +} + +#[test] +fn terminal_delete_lines_across_page_boundary_marks_all_shifted_rows_dirty() { + let mut t = init_scrollback(10, 5, 1024); + let first = t.screen().pages.first.unwrap(); + let first_page_nrows = t.screen().page(first).capacity.rows; + + // Fill up the first page minus 3 rows + for _ in 0..first_page_nrows - 3 { + t.linefeed().unwrap(); + } + + // Add content that will cross a page boundary + print_str(&mut t, "1AAAA"); + nl(&mut t); + print_str(&mut t, "2BBBB"); + nl(&mut t); + print_str(&mut t, "3CCCC"); + nl(&mut t); + print_str(&mut t, "4DDDD"); + nl(&mut t); + print_str(&mut t, "5EEEE"); + + // Verify we now have a second page + assert!(t.screen().pages.node(first).unwrap().next.is_some()); + + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.delete_lines(1); + + for y in 0..5 { + assert!(dirty(&t, 0, y)); + } + assert_eq!("2BBBB\n3CCCC\n4DDDD\n5EEEE", plain(&t)); +} + +#[test] +fn terminal_delete_lines_legacy() { + let mut t = init(80, 80); + abcd_lines(&mut t); + t.cursor_up(2); + t.delete_lines(1); + t.print(u32::from('E')).unwrap(); + nl(&mut t); + assert_eq!(0, t.screen().cursor.x); + assert_eq!(2, t.screen().cursor.y); + assert_eq!("A\nE\nD", plain(&t)); +} + +#[test] +fn terminal_delete_lines_with_scroll_region() { + let mut t = init(80, 80); + abcd_lines(&mut t); + t.set_top_and_bottom_margin(1, 3); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.delete_lines(1); + assert!(dirty(&t, 0, 0)); + assert!(dirty(&t, 0, 1)); + assert!(dirty(&t, 0, 2)); + assert!(!dirty(&t, 0, 3)); + t.print(u32::from('E')).unwrap(); + nl(&mut t); + assert_eq!("E\nC\n\nD", plain(&t)); +} + +#[test] +fn terminal_delete_lines_with_scroll_region_large_count() { + let mut t = init(80, 80); + abcd_lines(&mut t); + t.set_top_and_bottom_margin(1, 3); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.delete_lines(5); + assert!(dirty(&t, 0, 0)); + assert!(dirty(&t, 0, 1)); + assert!(dirty(&t, 0, 2)); + assert!(!dirty(&t, 0, 3)); + t.print(u32::from('E')).unwrap(); + nl(&mut t); + assert_eq!("E\n\n\nD", plain(&t)); +} + +#[test] +fn terminal_delete_lines_with_scroll_region_cursor_outside_of_region() { + let mut t = init(80, 80); + abcd_lines(&mut t); + t.set_top_and_bottom_margin(1, 3); + t.set_cursor_pos(4, 1); + t.clear_dirty(); + t.delete_lines(1); + for y in 0..4 { + assert!(!dirty(&t, 0, y)); + } + assert_eq!("A\nB\nC\nD", plain(&t)); +} + +#[test] +fn terminal_delete_lines_resets_pending_wrap() { + let mut t = init(5, 5); + prints(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.delete_lines(1); + assert!(!t.screen().cursor.pending_wrap); + t.print(u32::from('B')).unwrap(); + assert_eq!("B", plain(&t)); +} + +#[test] +fn terminal_delete_lines_resets_wrap() { + let mut t = init(3, 3); + t.print(u32::from('1')).unwrap(); + nl(&mut t); + prints(&mut t, "ABCDEF"); + t.set_top_and_bottom_margin(1, 2); + t.set_cursor_pos(1, 1); + t.delete_lines(1); + t.print(u32::from('X')).unwrap(); + assert_eq!("XBC\n\nDEF", plain(&t)); + for y in 0..u32::from(t.rows) { + assert!(!row_at(&t, pt_active(0, y)).wrap()); + } +} + +fn lr_setup() -> Terminal { + let mut t = init(10, 10); + abc123(&mut t); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t +} + +fn abc123(t: &mut Terminal) { + print_str(t, "ABC123"); + nl(t); + print_str(t, "DEF456"); + nl(t); + print_str(t, "GHI789"); +} + +#[test] +fn terminal_delete_lines_left_right_scroll_region() { + let mut t = lr_setup(); + t.set_cursor_pos(2, 2); + t.clear_dirty(); + t.delete_lines(1); + assert!(!dirty(&t, 0, 0)); + for y in 1..3 { + assert!(dirty(&t, 0, y)); + } + assert_eq!("ABC123\nDHI756\nG 89", plain(&t)); +} + +#[test] +fn terminal_delete_lines_left_right_scroll_region_from_top() { + let mut t = lr_setup(); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.delete_lines(1); + for y in 0..3 { + assert!(dirty(&t, 0, y)); + } + assert_eq!("AEF423\nDHI756\nG 89", plain(&t)); +} + +#[test] +fn terminal_delete_lines_left_right_scroll_region_high_count() { + let mut t = lr_setup(); + t.set_cursor_pos(2, 2); + t.clear_dirty(); + t.delete_lines(100); + assert!(!dirty(&t, 0, 0)); + for y in 1..3 { + assert!(dirty(&t, 0, y)); + } + assert_eq!("ABC123\nD 56\nG 89", plain(&t)); +} + +#[test] +fn terminal_delete_lines_wide_character_spacer_head() { + let mut t = init(5, 3); + print_str(&mut t, "AAAAABBBB\u{1F600}CCC"); + t.set_cursor_pos(1, 1); + t.delete_lines(1); + assert_eq!("BBBB\n\u{1F600}CCC", plain(&t)); + assert_eq!("BBBB\n\u{1F600}CCC", plain_unwrapped(&t)); +} + +#[test] +fn terminal_delete_lines_wide_character_spacer_head_left_scroll_margin() { + let mut t = init(5, 3); + print_str(&mut t, "AAAAABBBB\u{1F600}CCC"); + t.scrolling_region.left = 2; + t.set_cursor_pos(1, 3); + t.delete_lines(1); + assert_eq!("AABB\nBBCCC\n\u{1F600}", plain(&t)); + assert_eq!("AABB BBCCC\u{1F600}", plain_unwrapped(&t)); +} + +#[test] +fn terminal_delete_lines_wide_character_spacer_head_right_scroll_margin() { + let mut t = init(5, 3); + print_str(&mut t, "AAAAABBBB\u{1F600}CCC"); + t.scrolling_region.right = 3; + t.set_cursor_pos(1, 1); + t.delete_lines(1); + assert_eq!("BBBBA\n\u{1F600}CC\n C", plain(&t)); + assert_eq!("BBBBA\u{1F600}CC C", plain_unwrapped(&t)); +} + +#[test] +fn terminal_delete_lines_wide_character_spacer_head_left_and_right_scroll_margin() { + let mut t = init(5, 3); + print_str(&mut t, "AAAAABBBB\u{1F600}CCC"); + t.scrolling_region.right = 3; + t.scrolling_region.left = 2; + t.set_cursor_pos(1, 3); + t.delete_lines(1); + assert_eq!("AABBA\nBBCC\n\u{1F600} C", plain(&t)); + assert_eq!("AABBABBCC\u{1F600} C", plain_unwrapped(&t)); +} + +#[test] +fn terminal_delete_lines_wide_character_spacer_head_left_lt_2_and_right_scroll_margin() { + let mut t = init(5, 3); + print_str(&mut t, "AAAAABBBB\u{1F600}CCC"); + t.scrolling_region.right = 3; + t.scrolling_region.left = 1; + t.set_cursor_pos(1, 2); + t.delete_lines(1); + assert_eq!("ABBBA\nB CC\n C", plain(&t)); + assert_eq!("ABBBAB CC C", plain_unwrapped(&t)); +} + +#[test] +fn terminal_delete_lines_wide_characters_split_by_left_right_scroll_region_boundaries() { + let mut t = init(5, 2); + print_str(&mut t, "AAAAA\n\u{1F600}B\u{1F600}"); + t.scrolling_region.right = 3; + t.scrolling_region.left = 1; + t.set_cursor_pos(1, 2); + t.delete_lines(1); + assert_eq!("A B A", plain(&t)); +} + +#[test] +fn terminal_delete_lines_zero() { + let mut t = init(2, 5); + t.set_cursor_pos(1, 1); + t.delete_lines(0); +} + +#[test] +fn terminal_default_style_is_empty() { + let mut t = init(5, 5); + t.print(u32::from('A')).unwrap(); + let c = cell_at(&t, pt_screen(0, 0)); + assert_eq!(u32::from('A'), c.codepoint()); + assert_eq!(0, c.style_id()); +} + +#[test] +fn terminal_bold_style() { + let mut t = init(5, 5); + t.set_attribute(Attribute::Bold).unwrap(); + t.print(u32::from('A')).unwrap(); + let c = cell_at(&t, pt_screen(0, 0)); + assert_eq!(u32::from('A'), c.codepoint()); + assert!(c.style_id() != 0); + let id = t.screen().cursor.style_id; + assert!(t.screen().cursor_page().styles.ref_count(id) > 1); +} + +#[test] +fn terminal_garbage_collect_overwritten() { + let mut t = init(5, 5); + t.set_attribute(Attribute::Bold).unwrap(); + t.print(u32::from('A')).unwrap(); + t.set_cursor_pos(1, 1); + t.set_attribute(Attribute::Unset).unwrap(); + t.print(u32::from('B')).unwrap(); + let c = cell_at(&t, pt_screen(0, 0)); + assert_eq!(u32::from('B'), c.codepoint()); + assert_eq!(0, c.style_id()); + // verify we have no styles in our style map + assert_eq!(0, t.screen().cursor_page().styles.count()); +} + +#[test] +fn terminal_do_not_garbage_collect_old_styles_in_use() { + let mut t = init(5, 5); + t.set_attribute(Attribute::Bold).unwrap(); + t.print(u32::from('A')).unwrap(); + t.set_attribute(Attribute::Unset).unwrap(); + t.print(u32::from('B')).unwrap(); + let c = cell_at(&t, pt_screen(1, 0)); + assert_eq!(u32::from('B'), c.codepoint()); + assert_eq!(0, c.style_id()); + assert_eq!(1, t.screen().cursor_page().styles.count()); +} + +#[test] +fn terminal_print_with_style_marks_the_row_as_styled() { + let mut t = init(5, 5); + t.set_attribute(Attribute::Bold).unwrap(); + t.print(u32::from('A')).unwrap(); + t.set_attribute(Attribute::Unset).unwrap(); + t.print(u32::from('B')).unwrap(); + assert!(row_at(&t, pt_screen(0, 0)).styled()); +} + +#[test] +fn terminal_decaln() { + let mut t = init(2, 2); + t.print(u32::from('A')).unwrap(); + nl(&mut t); + t.print(u32::from('B')).unwrap(); + t.decaln().unwrap(); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(0, t.screen().cursor.x); + for y in 0..u32::from(t.rows) { + assert!(dirty(&t, 0, y)); + } + assert_eq!("EE\nEE", plain(&t)); +} + +#[test] +fn terminal_decaln_reset_margins() { + let mut t = init(3, 3); + t.modes.set(Mode::Origin, true); + t.set_top_and_bottom_margin(2, 3); + t.decaln().unwrap(); + t.scroll_down(1); + assert_eq!("\nEEE\nEEE", plain(&t)); +} + +#[test] +fn terminal_decaln_preserves_color() { + let mut t = init(3, 3); + t.set_attribute(Attribute::DirectColorBg(RED)).unwrap(); + t.modes.set(Mode::Origin, true); + t.set_top_and_bottom_margin(2, 3); + t.decaln().unwrap(); + t.scroll_down(1); + assert_eq!("\nEEE\nEEE", plain(&t)); + let c = cell_at(&t, pt_active(0, 0)); + assert_eq!(ContentTag::BgColorRgb, c.content_tag()); + assert_eq!(RED, c.color_rgb()); +} + +#[test] +fn terminal_decaln_resets_graphemes_with_protected_mode() { + let mut t = init(3, 3); + t.set_protected_mode(ProtectedMode::Iso); + t.modes.set(Mode::GraphemeCluster, true); + for cp in [0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467] { + t.print(cp).unwrap(); + } + t.decaln().unwrap(); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(0, t.screen().cursor.x); + assert!(t.screen().cursor.protected); + assert_eq!(ProtectedMode::Iso, t.screen().protected_mode); + for y in 0..u32::from(t.rows) { + assert!(dirty(&t, 0, y)); + } + assert_eq!("EEE\nEEE\nEEE", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_zero() { + let mut t = init(5, 2); + prints(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.insert_blanks(0); + assert_eq!("ABC", plain(&t)); +} + +#[test] +fn terminal_insert_blanks() { + let mut t = init(5, 2); + prints(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_blanks(2); + assert!(dirty(&t, 0, 0)); + assert!(!dirty(&t, 0, 1)); + assert_eq!(" ABC", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_pushes_off_end() { + let mut t = init(3, 2); + prints(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_blanks(2); + assert!(dirty(&t, 0, 0)); + assert_eq!(" A", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_more_than_size() { + let mut t = init(3, 2); + prints(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_blanks(5); + assert!(dirty(&t, 0, 0)); + assert_eq!("", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_no_scroll_region_fits() { + let mut t = init(10, 10); + prints(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_blanks(2); + assert!(dirty(&t, 0, 0)); + assert_eq!(" ABC", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_preserves_background_sgr() { + let mut t = init(10, 10); + prints(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.set_attribute(Attribute::DirectColorBg(RED)).unwrap(); + t.insert_blanks(2); + assert_eq!(" ABC", plain(&t)); + let c = cell_at(&t, pt_active(0, 0)); + assert_eq!(ContentTag::BgColorRgb, c.content_tag()); + assert_eq!(RED, c.color_rgb()); +} diff --git a/crates/ghostty-terminal/src/terminal/tests/p6.rs b/crates/ghostty-terminal/src/terminal/tests/p6.rs new file mode 100644 index 0000000..7478b2d --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/p6.rs @@ -0,0 +1,709 @@ +use super::*; +use crate::page::{ContentTag, Wide}; +use ghostty_vt::charsets::Slots; +use ghostty_vt::color::Rgb; +use ghostty_vt::sgr::Attribute; + +/// Zig `for ("...") |c| try t.print(c)`. +fn pr(t: &mut Terminal, s: &str) { + for c in s.chars() { + t.print(u32::from(c)).unwrap(); + } +} + +fn pt(t: &mut Terminal, c: u32) { + t.print(c).unwrap(); +} + +fn node_page(t: &Terminal) -> &crate::page::Page { + t.screen().cursor_page() +} + +#[test] +fn terminal_insert_blanks_shift_off_screen() { + let mut t = init(5, 10); + pr(&mut t, " ABC"); + t.set_cursor_pos(1, 3); + t.clear_dirty(); + t.insert_blanks(2); + assert!(t.is_dirty(pt_active(0, 0))); + pt(&mut t, 'X' as u32); + assert_eq!(" X A", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_split_multi_cell_character() { + let mut t = init(5, 10); + pr(&mut t, "123"); + pt(&mut t, '橋' as u32); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_blanks(1); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" 123", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_inside_left_right_scroll_region() { + let mut t = init(10, 10); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.set_cursor_pos(1, 3); + pr(&mut t, "ABC"); + t.set_cursor_pos(1, 3); + t.clear_dirty(); + t.insert_blanks(2); + assert!(t.is_dirty(pt_active(0, 0))); + pt(&mut t, 'X' as u32); + assert_eq!(" X A", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_outside_left_right_scroll_region() { + let mut t = init(6, 10); + t.set_cursor_pos(1, 4); + pr(&mut t, "ABC"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + assert!(t.screen().cursor.pending_wrap); + t.clear_dirty(); + t.insert_blanks(2); + assert!(!t.is_dirty(pt_active(0, 0))); + assert!(!t.screen().cursor.pending_wrap); + pt(&mut t, 'X' as u32); + assert_eq!(" ABX", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_left_right_scroll_region_large_count() { + let mut t = init(10, 10); + t.modes.set(Mode::Origin, true); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(3, 5); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_blanks(140); + assert!(t.is_dirty(pt_active(0, 0))); + pt(&mut t, 'X' as u32); + assert_eq!(" X", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_deleting_graphemes() { + let mut t = init(5, 5); + t.modes.set(Mode::GraphemeCluster, true); + t.print_string("ABC").unwrap(); + + for cp in [0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467] { + pt(&mut t, cp); + } + + assert_eq!(1, node_page(&t).grapheme_count()); + + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_blanks(4); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" A", plain(&t)); + assert_eq!(0, node_page(&t).grapheme_count()); +} + +#[test] +fn terminal_insert_blanks_shift_graphemes() { + let mut t = init(5, 5); + t.modes.set(Mode::GraphemeCluster, true); + t.print_string("A").unwrap(); + + for cp in [0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467] { + pt(&mut t, cp); + } + + assert_eq!(1, node_page(&t).grapheme_count()); + + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.insert_blanks(1); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" A👨‍👩‍👧", plain(&t)); + assert_eq!(1, node_page(&t).grapheme_count()); +} + +#[test] +fn terminal_insert_blanks_split_multi_cell_character_from_tail() { + let mut t = init(5, 10); + t.print_string("橋123").unwrap(); + t.set_cursor_pos(1, 2); + t.insert_blanks(1); + assert_eq!(" 12", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_shifts_hyperlinks() { + let mut t = init(10, 2); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + t.print_string("ABC").unwrap(); + t.set_cursor_pos(1, 1); + t.insert_blanks(2); + + assert_eq!(" ABC", plain(&t)); + + // Verify all our cells have a hyperlink + for x in 2..5u16 { + let p = pin_at(&t, pt_screen(x, 0)); + let page = t.screen().page(p.node); + assert!(page.rows[usize::from(p.y)].hyperlink()); + let i = page.cell_index(usize::from(p.x), usize::from(p.y)).unwrap(); + assert!(page.cells[i].hyperlink()); + assert_eq!(Some(1), page.lookup_hyperlink(i)); + } + for x in 0..2u16 { + let p = pin_at(&t, pt_screen(x, 0)); + let page = t.screen().page(p.node); + let i = page.cell_index(usize::from(p.x), usize::from(p.y)).unwrap(); + assert!(!page.cells[i].hyperlink()); + assert_eq!(None, page.lookup_hyperlink(i)); + } +} + +#[test] +fn terminal_insert_blanks_pushes_hyperlink_off_end_completely() { + let mut t = init(3, 2); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + t.print_string("ABC").unwrap(); + t.set_cursor_pos(1, 1); + t.insert_blanks(3); + + assert_eq!("", plain(&t)); + + for x in 0..3u16 { + let p = pin_at(&t, pt_screen(x, 0)); + let page = t.screen().page(p.node); + assert!(!page.rows[usize::from(p.y)].hyperlink()); + let i = page.cell_index(usize::from(p.x), usize::from(p.y)).unwrap(); + assert!(!page.cells[i].hyperlink()); + assert_eq!(None, page.lookup_hyperlink(i)); + } +} + +#[test] +fn terminal_insert_blanks_wide_char_straddling_right_margin() { + let mut t = init(10, 5); + t.set_cursor_pos(1, 1); + pr(&mut t, "ABCD"); + pt(&mut t, '橋' as u32); + t.scrolling_region.right = 4; + t.set_cursor_pos(1, 3); + t.insert_blanks(1); + assert_eq!("AB CD", plain(&t)); +} + +#[test] +fn terminal_insert_blanks_wide_char_spacer_tail_orphaned_beyond_right_margin() { + let mut t = init(10, 5); + for _ in 0..5 { + pt(&mut t, 0x4E2D); + } + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(1, 9); + pt(&mut t, 'a' as u32); + t.insert_blanks(8); + assert_eq!("a", plain(&t)); +} + +#[test] +fn terminal_insert_mode_with_space() { + let mut t = init(10, 2); + pr(&mut t, "hello"); + t.set_cursor_pos(1, 2); + t.modes.set(Mode::Insert, true); + pt(&mut t, 'X' as u32); + assert_eq!("hXello", plain(&t)); +} + +#[test] +fn terminal_insert_mode_doesnt_wrap_pushed_characters() { + let mut t = init(5, 2); + pr(&mut t, "hello"); + t.set_cursor_pos(1, 2); + t.modes.set(Mode::Insert, true); + pt(&mut t, 'X' as u32); + assert_eq!("hXell", plain(&t)); +} + +#[test] +fn terminal_insert_mode_does_nothing_at_the_end_of_the_line() { + let mut t = init(5, 2); + pr(&mut t, "hello"); + t.modes.set(Mode::Insert, true); + pt(&mut t, 'X' as u32); + assert_eq!("hello\nX", plain(&t)); +} + +#[test] +fn terminal_insert_mode_with_wide_characters() { + let mut t = init(5, 2); + pr(&mut t, "hello"); + t.set_cursor_pos(1, 2); + t.modes.set(Mode::Insert, true); + pt(&mut t, 0x1F600); + assert_eq!("h😀el", plain(&t)); +} + +#[test] +fn terminal_insert_mode_with_wide_characters_at_end() { + let mut t = init(5, 2); + pr(&mut t, "well"); + t.modes.set(Mode::Insert, true); + pt(&mut t, 0x1F600); + assert_eq!("well\n😀", plain(&t)); +} + +#[test] +fn terminal_insert_mode_pushing_off_wide_character() { + let mut t = init(5, 2); + pr(&mut t, "123"); + pt(&mut t, 0x1F600); + t.modes.set(Mode::Insert, true); + t.set_cursor_pos(1, 1); + pt(&mut t, 'X' as u32); + assert_eq!("X123", plain(&t)); +} + +#[test] +fn terminal_delete_chars() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.delete_chars(2); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("ADE", plain(&t)); +} + +#[test] +fn terminal_delete_chars_zero_count() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.delete_chars(0); + assert!(!t.is_dirty(pt_active(0, 0))); + assert_eq!("ABCDE", plain(&t)); +} + +#[test] +fn terminal_delete_chars_more_than_half() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.delete_chars(3); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("AE", plain(&t)); +} + +#[test] +fn terminal_delete_chars_more_than_line_width() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.delete_chars(10); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("A", plain(&t)); +} + +#[test] +fn terminal_delete_chars_should_shift_left() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.delete_chars(1); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("ACDE", plain(&t)); +} + +#[test] +fn terminal_delete_chars_resets_pending_wrap() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.delete_chars(1); + assert!(!t.screen().cursor.pending_wrap); + pt(&mut t, 'X' as u32); + assert_eq!("ABCDX", plain(&t)); +} + +#[test] +fn terminal_delete_chars_resets_wrap() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE123"); + assert!(row_at(&t, pt_active(0, 0)).wrap()); + t.set_cursor_pos(1, 1); + t.delete_chars(1); + assert!(!row_at(&t, pt_active(0, 0)).wrap()); + pt(&mut t, 'X' as u32); + assert_eq!("XCDE\n123", plain(&t)); +} + +#[test] +fn terminal_delete_chars_simple_operation() { + let mut t = init(10, 10); + t.print_string("ABC123").unwrap(); + t.set_cursor_pos(1, 3); + t.clear_dirty(); + t.delete_chars(2); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("AB23", plain(&t)); +} + +#[test] +fn terminal_delete_chars_preserves_background_sgr() { + let mut t = init(10, 10); + pr(&mut t, "ABC123"); + t.set_cursor_pos(1, 3); + t.set_attribute(Attribute::DirectColorBg(Rgb { + r: 0xFF, + g: 0, + b: 0, + })) + .unwrap(); + t.delete_chars(2); + + assert_eq!("AB23", plain(&t)); + for x in t.cols - 2..t.cols { + let c = cell_at(&t, pt_active(x, 0)); + assert!(c.content_tag() == ContentTag::BgColorRgb); + assert_eq!( + Rgb { + r: 0xFF, + g: 0, + b: 0 + }, + c.color_rgb() + ); + } +} + +#[test] +fn terminal_delete_chars_outside_scroll_region() { + let mut t = init(6, 10); + t.print_string("ABC123").unwrap(); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + assert!(t.screen().cursor.pending_wrap); + t.clear_dirty(); + t.delete_chars(2); + assert!(!t.is_dirty(pt_active(0, 0))); + assert!(t.screen().cursor.pending_wrap); + assert_eq!("ABC123", plain(&t)); +} + +#[test] +fn terminal_delete_chars_inside_scroll_region() { + let mut t = init(6, 10); + t.print_string("ABC123").unwrap(); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.set_cursor_pos(1, 4); + t.clear_dirty(); + t.delete_chars(1); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("ABC2 3", plain(&t)); +} + +#[test] +fn terminal_delete_chars_split_wide_character_from_spacer_tail() { + let mut t = init(6, 10); + t.print_string("A橋123").unwrap(); + t.set_cursor_pos(1, 3); + t.delete_chars(1); + assert_eq!("A 123", plain(&t)); +} + +#[test] +fn terminal_delete_chars_split_wide_character_from_wide() { + let mut t = init(6, 10); + t.print_string("橋123").unwrap(); + t.set_cursor_pos(1, 1); + t.delete_chars(1); + + let c = cell_at(&t, pt_screen(0, 0)); + assert_eq!(0, c.codepoint()); + assert_eq!(Wide::Narrow, c.wide()); + let c = cell_at(&t, pt_screen(1, 0)); + assert_eq!('1' as u32, c.codepoint()); + assert_eq!(Wide::Narrow, c.wide()); +} + +#[test] +fn terminal_delete_chars_split_wide_character_from_end() { + let mut t = init(6, 10); + t.print_string("A橋123").unwrap(); + t.set_cursor_pos(1, 1); + t.delete_chars(1); + + let c = cell_at(&t, pt_screen(0, 0)); + assert_eq!(0x6A4B, c.codepoint()); + assert_eq!(Wide::Wide, c.wide()); + let c = cell_at(&t, pt_screen(1, 0)); + assert_eq!(0, c.codepoint()); + assert_eq!(Wide::SpacerTail, c.wide()); +} + +#[test] +fn terminal_delete_chars_with_a_spacer_head_at_the_end() { + let mut t = init(5, 10); + t.print_string("0123橋123").unwrap(); + { + let c = cell_at(&t, pt_screen(4, 0)); + assert_eq!(Wide::SpacerHead, c.wide()); + assert!(row_at(&t, pt_screen(4, 0)).wrap()); + } + + t.set_cursor_pos(1, 1); + t.delete_chars(1); + + let c = cell_at(&t, pt_screen(3, 0)); + assert_eq!(0, c.codepoint()); + assert_eq!(Wide::Narrow, c.wide()); +} + +#[test] +fn terminal_delete_chars_split_wide_character_tail() { + let mut t = init(5, 5); + let cols = usize::from(t.cols); + t.set_cursor_pos(1, cols - 1); + pt(&mut t, 0x6A4B); + t.carriage_return(); + t.delete_chars(cols - 1); + pt(&mut t, '0' as u32); + assert_eq!("0", plain(&t)); +} + +#[test] +fn terminal_delete_chars_wide_char_boundary_conditions() { + let mut t = init(8, 1); + t.print_string("😀a😀b😀").unwrap(); + assert_eq!("😀a😀b😀", plain(&t)); + + t.set_cursor_pos(1, 2); + t.delete_chars(3); + node_page(&t).assert_integrity(); + + assert_eq!(" b😀", plain(&t)); +} + +#[test] +fn terminal_delete_chars_wide_char_wrap_boundary_conditions() { + let mut t = init(8, 3); + t.print_string(".......😀abcde😀......").unwrap(); + assert_eq!(".......\n😀abcde\n😀......", plain(&t)); + assert_eq!(".......😀abcde😀......", plain_unwrapped(&t)); + + t.set_cursor_pos(2, 2); + t.delete_chars(3); + node_page(&t).assert_integrity(); + + assert_eq!(".......\n cde\n😀......", plain(&t)); + assert_eq!("....... cde\n😀......", plain_unwrapped(&t)); +} + +#[test] +fn terminal_delete_chars_wide_char_across_right_margin() { + let mut t = init(8, 3); + t.print_string("123456橋").unwrap(); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(2, 7); + assert_eq!("123456橋", plain(&t)); + + t.set_cursor_pos(1, 2); + t.delete_chars(1); + node_page(&t).assert_integrity(); + + assert_eq!("13456", plain(&t)); +} + +#[test] +fn terminal_save_cursor() { + let mut t = init(3, 3); + t.set_attribute(Attribute::Bold).unwrap(); + t.screen_mut().charset.gr = Slots::G3; + t.modes.set(Mode::Origin, true); + t.save_cursor(); + t.screen_mut().charset.gr = Slots::G0; + t.set_attribute(Attribute::Unset).unwrap(); + t.modes.set(Mode::Origin, false); + t.restore_cursor(); + assert!(t.screen().cursor.style.flags.bold); + assert!(t.screen().charset.gr == Slots::G3); + assert!(t.modes.get(Mode::Origin)); +} + +#[test] +fn terminal_save_cursor_position() { + let mut t = init(10, 5); + t.set_cursor_pos(1, 5); + pt(&mut t, 'A' as u32); + t.save_cursor(); + t.set_cursor_pos(1, 1); + pt(&mut t, 'B' as u32); + t.restore_cursor(); + pt(&mut t, 'X' as u32); + assert_eq!("B AX", plain(&t)); +} + +#[test] +fn terminal_save_cursor_pending_wrap_state() { + let mut t = init(5, 5); + t.set_cursor_pos(1, 5); + pt(&mut t, 'A' as u32); + t.save_cursor(); + t.set_cursor_pos(1, 1); + pt(&mut t, 'B' as u32); + t.restore_cursor(); + pt(&mut t, 'X' as u32); + assert_eq!("B A\nX", plain(&t)); +} + +#[test] +fn terminal_save_cursor_origin_mode() { + let mut t = init(10, 5); + t.modes.set(Mode::Origin, true); + t.save_cursor(); + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(3, 5); + t.set_top_and_bottom_margin(2, 4); + t.restore_cursor(); + pt(&mut t, 'X' as u32); + assert_eq!("X", plain(&t)); +} + +#[test] +fn terminal_save_cursor_resize() { + let mut t = init(10, 5); + t.set_cursor_pos(1, 10); + t.save_cursor(); + t.resize(5, 5).unwrap(); + t.restore_cursor(); + pt(&mut t, 'X' as u32); + assert_eq!(" X", plain(&t)); +} + +#[test] +fn terminal_save_cursor_protected_pen() { + let mut t = init(10, 5); + t.set_protected_mode(ProtectedMode::Iso); + assert!(t.screen().cursor.protected); + t.set_cursor_pos(1, 10); + t.save_cursor(); + t.set_protected_mode(ProtectedMode::Off); + assert!(!t.screen().cursor.protected); + t.restore_cursor(); + assert!(t.screen().cursor.protected); +} + +#[test] +fn terminal_save_cursor_doesnt_modify_hyperlink_state() { + let mut t = init(3, 3); + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + let id = t.screen().cursor.hyperlink_id; + t.save_cursor(); + assert_eq!(id, t.screen().cursor.hyperlink_id); + t.restore_cursor(); + assert_eq!(id, t.screen().cursor.hyperlink_id); +} + +#[test] +fn terminal_restore_cursor_uses_default_style_on_out_of_space() { + // Use a single row so the page can't be split + let mut t = init(10, 1); + + // Set a style and save the cursor + t.set_attribute(Attribute::Bold).unwrap(); + t.save_cursor(); + + // Clear the style + t.set_attribute(Attribute::Unset).unwrap(); + assert!(!t.screen().cursor.style.flags.bold); + + // Fill the style map to max capacity + let max_styles = size::CellCountInt::MAX; + while t.screen().cursor_page().capacity.styles < max_styles { + let node = t.screen().cursor_pin().node; + if t.screen_mut() + .increase_capacity(node, Some(IncreaseCapacity::Styles)) + .is_err() + { + break; + } + } + + assert_eq!(max_styles, t.screen().cursor_page().capacity.styles); + + // Fill all style slots using the StyleSet's capacity. + { + let page = t.screen_mut().cursor_page_mut(); + page.pause_integrity_checks(true); + // Zig `layout.cap` counts the reserved zero slot. + let max_items = page.styles.capacity() + 1; + let mut n: usize = 1; + while n < max_items { + let bg = Rgb { + r: ((n >> 16) & 0xFF) as u8, + g: ((n >> 8) & 0xFF) as u8, + b: (n & 0xFF) as u8, + }; + let st = Style { + bg_color: StyleColor::Rgb(bg), + ..Style::default() + }; + if page.styles.add(st).is_err() { + break; + } + n += 1; + } + page.pause_integrity_checks(false); + page.assert_integrity(); + } + + // Restore cursor - should fall back to default style since page + // can't be split (1 row) and styles are at max capacity + t.restore_cursor(); + + // The style should be reset to default because OutOfSpace occurred + assert!(!t.screen().cursor.style.flags.bold); + assert_eq!(style::DEFAULT_ID, t.screen().cursor.style_id); +} + +#[test] +fn terminal_set_protected_mode() { + let mut t = init(3, 3); + assert!(!t.screen().cursor.protected); + t.set_protected_mode(ProtectedMode::Off); + assert!(!t.screen().cursor.protected); + t.set_protected_mode(ProtectedMode::Iso); + assert!(t.screen().cursor.protected); + t.set_protected_mode(ProtectedMode::Dec); + assert!(t.screen().cursor.protected); + t.set_protected_mode(ProtectedMode::Off); + assert!(!t.screen().cursor.protected); +} + +#[test] +fn terminal_erase_line_simple_erase_right() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 3); + t.clear_dirty(); + t.erase_line(csi::EraseLine::Right, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("AB", plain(&t)); +} diff --git a/crates/ghostty-terminal/src/terminal/tests/p7.rs b/crates/ghostty-terminal/src/terminal/tests/p7.rs new file mode 100644 index 0000000..a3ac80c --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/p7.rs @@ -0,0 +1,672 @@ +use super::*; +use crate::page::{ContentTag, SemanticContent, SemanticPrompt as RowPrompt}; +use ghostty_vt::{ + ansi::ProtectedMode, + color::Rgb, + csi::{EraseDisplay, EraseLine, TabClear}, + osc::semantic_prompt::{Action as PA, Command}, + sgr::{Attribute as A, Underline}, +}; + +fn pr(t: &mut Terminal, s: &str) { + for c in s.chars() { + t.print(u32::from(c)).unwrap(); + } +} + +fn red() -> Rgb { + Rgb { + r: 0xFF, + g: 0, + b: 0, + } +} + +fn assert_bg_red(t: &Terminal, xs: std::ops::Range, y: u32) { + for x in xs { + let c = cell_at(t, pt_active(x, y)); + assert_eq!(c.content_tag(), ContentTag::BgColorRgb); + assert_eq!(c.color_rgb(), red()); + } +} + +fn abc_def_ghi(t: &mut Terminal) { + pr(t, "ABC"); + t.carriage_return(); + t.linefeed().unwrap(); + pr(t, "DEF"); + t.carriage_return(); + t.linefeed().unwrap(); + pr(t, "GHI"); +} + +#[test] +fn terminal_erase_line_resets_pending_wrap() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.erase_line(EraseLine::Right, false); + assert!(!t.screen().cursor.pending_wrap); + t.print(u32::from('B')).unwrap(); + assert_eq!("ABCDB", plain(&t)); +} + +#[test] +fn terminal_erase_line_resets_wrap() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE123"); + assert!(row_at(&t, pt_active(0, 0)).wrap()); + t.set_cursor_pos(1, 1); + t.erase_line(EraseLine::Right, false); + assert!(!row_at(&t, pt_active(0, 0)).wrap()); + t.print(u32::from('X')).unwrap(); + assert_eq!("X\n123", plain(&t)); +} + +#[test] +fn terminal_erase_line_right_preserves_background_sgr() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 2); + t.set_attribute(A::DirectColorBg(red())).unwrap(); + t.erase_line(EraseLine::Right, false); + assert_eq!("A", plain(&t)); + assert_bg_red(&t, 1..5, 0); +} + +#[test] +fn terminal_erase_line_right_wide_character() { + let mut t = init(10, 5); + pr(&mut t, "AB橋DE"); + t.set_cursor_pos(1, 4); + t.clear_dirty(); + t.erase_line(EraseLine::Right, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("AB", plain(&t)); +} + +#[test] +fn terminal_erase_line_right_protected_attributes_respected_with_iso() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + pr(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.erase_line(EraseLine::Right, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("ABC", plain(&t)); +} + +#[test] +fn terminal_erase_line_right_protected_attributes_ignored_with_dec_most_recent() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + pr(&mut t, "ABC"); + t.set_protected_mode(ProtectedMode::Dec); + t.set_protected_mode(ProtectedMode::Off); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.erase_line(EraseLine::Right, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("A", plain(&t)); +} + +#[test] +fn terminal_erase_line_right_protected_attributes_ignored_with_dec_set() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Dec); + pr(&mut t, "ABC"); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.erase_line(EraseLine::Right, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("A", plain(&t)); +} + +#[test] +fn terminal_erase_line_right_protected_requested() { + let mut t = init(10, 5); + pr(&mut t, "12345678"); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 6); + t.set_protected_mode(ProtectedMode::Dec); + t.print(u32::from('X')).unwrap(); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 4); + t.clear_dirty(); + t.erase_line(EraseLine::Right, true); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("123 X", plain(&t)); +} + +#[test] +fn terminal_erase_line_simple_erase_left() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 3); + t.clear_dirty(); + t.erase_line(EraseLine::Left, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" DE", plain(&t)); +} + +#[test] +fn terminal_erase_line_left_resets_wrap() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + t.clear_dirty(); + t.erase_line(EraseLine::Left, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert!(!t.screen().cursor.pending_wrap); + t.print(u32::from('B')).unwrap(); + assert_eq!(" B", plain(&t)); +} + +#[test] +fn terminal_erase_line_left_preserves_background_sgr() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 2); + t.set_attribute(A::DirectColorBg(red())).unwrap(); + t.erase_line(EraseLine::Left, false); + assert_eq!(" CDE", plain(&t)); + assert_bg_red(&t, 0..2, 0); +} + +#[test] +fn terminal_erase_line_left_wide_character() { + let mut t = init(10, 5); + pr(&mut t, "AB橋DE"); + t.set_cursor_pos(1, 3); + t.clear_dirty(); + t.erase_line(EraseLine::Left, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" DE", plain(&t)); +} + +#[test] +fn terminal_erase_line_left_protected_attributes_respected_with_iso() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + pr(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.erase_line(EraseLine::Left, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("ABC", plain(&t)); +} + +#[test] +fn terminal_erase_line_left_protected_attributes_ignored_with_dec_most_recent() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + pr(&mut t, "ABC"); + t.set_protected_mode(ProtectedMode::Dec); + t.set_protected_mode(ProtectedMode::Off); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.erase_line(EraseLine::Left, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" C", plain(&t)); +} + +#[test] +fn terminal_erase_line_left_protected_attributes_ignored_with_dec_set() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Dec); + pr(&mut t, "ABC"); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.erase_line(EraseLine::Left, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" C", plain(&t)); +} + +#[test] +fn terminal_erase_line_left_protected_requested() { + let mut t = init(10, 5); + pr(&mut t, "123456789"); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 6); + t.set_protected_mode(ProtectedMode::Dec); + t.print(u32::from('X')).unwrap(); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 8); + t.clear_dirty(); + t.erase_line(EraseLine::Left, true); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" X 9", plain(&t)); +} + +#[test] +fn terminal_erase_line_complete_preserves_background_sgr() { + let mut t = init(5, 5); + pr(&mut t, "ABCDE"); + t.set_cursor_pos(1, 2); + t.set_attribute(A::DirectColorBg(red())).unwrap(); + t.erase_line(EraseLine::Complete, false); + assert_eq!("", plain(&t)); + assert_bg_red(&t, 0..5, 0); +} + +#[test] +fn terminal_erase_line_complete_protected_attributes_respected_with_iso() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + pr(&mut t, "ABC"); + t.set_cursor_pos(1, 1); + t.clear_dirty(); + t.erase_line(EraseLine::Complete, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("ABC", plain(&t)); +} + +#[test] +fn terminal_erase_line_complete_protected_attributes_ignored_with_dec_most_recent() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + pr(&mut t, "ABC"); + t.set_protected_mode(ProtectedMode::Dec); + t.set_protected_mode(ProtectedMode::Off); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.erase_line(EraseLine::Complete, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("", plain(&t)); +} + +#[test] +fn terminal_erase_line_complete_protected_attributes_ignored_with_dec_set() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Dec); + pr(&mut t, "ABC"); + t.set_cursor_pos(1, 2); + t.clear_dirty(); + t.erase_line(EraseLine::Complete, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("", plain(&t)); +} + +#[test] +fn terminal_erase_line_complete_protected_requested() { + let mut t = init(10, 5); + pr(&mut t, "123456789"); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 6); + t.set_protected_mode(ProtectedMode::Dec); + t.print(u32::from('X')).unwrap(); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 8); + t.clear_dirty(); + t.erase_line(EraseLine::Complete, true); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" X", plain(&t)); +} + +#[test] +fn terminal_tab_clear_single() { + let mut t = init(30, 5); + t.horizontal_tab(); + t.tab_clear(TabClear::Current); + assert!(!t.is_dirty(pt_active(0, 0))); + t.set_cursor_pos(1, 1); + t.horizontal_tab(); + assert_eq!(16, t.screen().cursor.x); +} + +#[test] +fn terminal_tab_clear_all() { + let mut t = init(30, 5); + t.tab_clear(TabClear::All); + assert!(!t.is_dirty(pt_active(0, 0))); + t.set_cursor_pos(1, 1); + t.horizontal_tab(); + assert_eq!(29, t.screen().cursor.x); +} + +#[test] +fn terminal_print_repeat_simple() { + let mut t = init(5, 5); + t.print_string("A").unwrap(); + t.print_repeat(1).unwrap(); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!("AA", plain(&t)); +} + +#[test] +fn terminal_print_repeat_wrap() { + let mut t = init(5, 5); + t.print_string(" A").unwrap(); + t.print_repeat(1).unwrap(); + assert!(t.is_dirty(pt_active(0, 0))); + assert_eq!(" A\nA", plain(&t)); +} + +#[test] +fn terminal_print_repeat_no_previous_character() { + let mut t = init(5, 5); + t.print_repeat(1).unwrap(); + assert!(!t.is_dirty(pt_active(0, 0))); + assert_eq!("", plain(&t)); +} + +#[test] +fn terminal_print_attributes() { + let mut t = init(5, 5); + let rgb = |r, g, b| Rgb { r, g, b }; + + { + t.set_attribute(A::DirectColorFg(rgb(1, 2, 3))).unwrap(); + assert_eq!("0;38:2::1:2:3", t.print_attributes()); + t.set_attribute(A::Unset).unwrap(); + } + { + t.set_attribute(A::Bold).unwrap(); + t.set_attribute(A::DirectColorBg(rgb(1, 2, 3))).unwrap(); + assert_eq!("0;1;48:2::1:2:3", t.print_attributes()); + t.set_attribute(A::Unset).unwrap(); + } + { + t.set_attribute(A::Bold).unwrap(); + t.set_attribute(A::Faint).unwrap(); + t.set_attribute(A::Italic).unwrap(); + t.set_attribute(A::Underline(Underline::Single)).unwrap(); + t.set_attribute(A::Blink).unwrap(); + t.set_attribute(A::Inverse).unwrap(); + t.set_attribute(A::Invisible).unwrap(); + t.set_attribute(A::Strikethrough).unwrap(); + t.set_attribute(A::DirectColorFg(rgb(100, 200, 255))) + .unwrap(); + t.set_attribute(A::DirectColorBg(rgb(101, 102, 103))) + .unwrap(); + assert_eq!( + "0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", + t.print_attributes() + ); + t.set_attribute(A::Unset).unwrap(); + } + { + t.set_attribute(A::Underline(Underline::Single)).unwrap(); + assert_eq!("0;4", t.print_attributes()); + t.set_attribute(A::Unset).unwrap(); + } + assert_eq!("0", t.print_attributes()); +} + +#[test] +fn terminal_erase_display_simple_erase_below() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.clear_dirty(); + t.erase_display(EraseDisplay::Below, false); + assert!(!t.is_dirty(pt_active(0, 0))); + assert!(t.is_dirty(pt_active(0, 1))); + assert!(t.is_dirty(pt_active(0, 2))); + assert_eq!("ABC\nD", plain(&t)); +} + +#[test] +fn terminal_erase_display_erase_below_preserves_sgr_bg() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.set_attribute(A::DirectColorBg(red())).unwrap(); + t.erase_display(EraseDisplay::Below, false); + assert_eq!("ABC\nD", plain(&t)); + assert_bg_red(&t, 1..5, 1); +} + +#[test] +fn terminal_erase_display_below_split_multi_cell() { + let mut t = init(5, 5); + t.print_string("AB橋C").unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.print_string("DE橋F").unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.print_string("GH橋I").unwrap(); + t.set_cursor_pos(2, 4); + t.erase_display(EraseDisplay::Below, false); + assert_eq!("AB橋C\nDE", plain(&t)); +} + +#[test] +fn terminal_erase_display_below_protected_attributes_respected_with_iso() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.erase_display(EraseDisplay::Below, false); + assert_eq!("ABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_erase_display_below_protected_attributes_ignored_with_dec_most_recent() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + abc_def_ghi(&mut t); + t.set_protected_mode(ProtectedMode::Dec); + t.set_protected_mode(ProtectedMode::Off); + t.set_cursor_pos(2, 2); + t.erase_display(EraseDisplay::Below, false); + assert_eq!("ABC\nD", plain(&t)); +} + +#[test] +fn terminal_erase_display_below_protected_attributes_ignored_with_dec_set() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Dec); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.erase_display(EraseDisplay::Below, false); + assert_eq!("ABC\nD", plain(&t)); +} + +#[test] +fn terminal_erase_display_below_protected_attributes_respected_with_force() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Dec); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.erase_display(EraseDisplay::Below, true); + assert_eq!("ABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_erase_display_simple_erase_above() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.clear_dirty(); + t.erase_display(EraseDisplay::Above, false); + assert!(t.is_dirty(pt_active(0, 0))); + assert!(t.is_dirty(pt_active(0, 1))); + assert!(!t.is_dirty(pt_active(0, 2))); + assert_eq!("\n F\nGHI", plain(&t)); +} + +#[test] +fn terminal_erase_display_erase_above_preserves_sgr_bg() { + let mut t = init(5, 5); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.set_attribute(A::DirectColorBg(red())).unwrap(); + t.erase_display(EraseDisplay::Above, false); + assert_eq!("\n F\nGHI", plain(&t)); + assert_bg_red(&t, 0..2, 1); +} + +#[test] +fn terminal_erase_display_above_split_multi_cell() { + let mut t = init(5, 5); + t.print_string("AB橋C").unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.print_string("DE橋F").unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.print_string("GH橋I").unwrap(); + t.set_cursor_pos(2, 3); + t.erase_display(EraseDisplay::Above, false); + assert_eq!("\n F\nGH橋I", plain(&t)); +} + +#[test] +fn terminal_erase_display_above_protected_attributes_respected_with_iso() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.erase_display(EraseDisplay::Above, false); + assert_eq!("ABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_erase_display_above_protected_attributes_ignored_with_dec_most_recent() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Iso); + abc_def_ghi(&mut t); + t.set_protected_mode(ProtectedMode::Dec); + t.set_protected_mode(ProtectedMode::Off); + t.set_cursor_pos(2, 2); + t.erase_display(EraseDisplay::Above, false); + assert_eq!("\n F\nGHI", plain(&t)); +} + +#[test] +fn terminal_erase_display_above_protected_attributes_ignored_with_dec_set() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Dec); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.erase_display(EraseDisplay::Above, false); + assert_eq!("\n F\nGHI", plain(&t)); +} + +#[test] +fn terminal_erase_display_above_protected_attributes_respected_with_force() { + let mut t = init(5, 5); + t.set_protected_mode(ProtectedMode::Dec); + abc_def_ghi(&mut t); + t.set_cursor_pos(2, 2); + t.erase_display(EraseDisplay::Above, true); + assert_eq!("ABC\nDEF\nGHI", plain(&t)); +} + +#[test] +fn terminal_erase_display_protected_complete() { + let mut t = init(10, 5); + t.print(u32::from('A')).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + pr(&mut t, "123456789"); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 6); + t.set_protected_mode(ProtectedMode::Dec); + t.print(u32::from('X')).unwrap(); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 4); + t.clear_dirty(); + t.erase_display(EraseDisplay::Complete, true); + for y in 0..u32::from(t.rows) { + assert!(t.is_dirty(pt_active(0, y))); + } + assert_eq!("\n X", plain(&t)); +} + +#[test] +fn terminal_erase_display_protected_below() { + let mut t = init(10, 5); + t.print(u32::from('A')).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + pr(&mut t, "123456789"); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 6); + t.set_protected_mode(ProtectedMode::Dec); + t.print(u32::from('X')).unwrap(); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 4); + t.erase_display(EraseDisplay::Below, true); + assert_eq!("A\n123 X", plain(&t)); +} + +#[test] +fn terminal_erase_display_scroll_complete() { + let mut t = init(10, 5); + t.print(u32::from('A')).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + t.erase_display(EraseDisplay::ScrollComplete, false); + assert_eq!("", plain(&t)); +} + +#[test] +fn terminal_erase_display_protected_above() { + let mut t = init(10, 3); + t.print(u32::from('A')).unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + pr(&mut t, "123456789"); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 6); + t.set_protected_mode(ProtectedMode::Dec); + t.print(u32::from('X')).unwrap(); + let y = usize::from(t.screen().cursor.y); + t.set_cursor_pos(y + 1, 8); + t.erase_display(EraseDisplay::Above, true); + assert_eq!("\n X 9", plain(&t)); +} + +#[test] +fn terminal_erase_display_complete_preserves_cursor() { + let mut t = init(5, 5); + + // Set our cursor + t.set_attribute(A::Bold).unwrap(); + t.print_string("AAAA").unwrap(); + assert_ne!(style::DEFAULT_ID, t.screen().cursor.style_id); + + // Erasing the display may detect that our style is no longer in use + // and prune our style, which we don't want because its still our + // active cursor. + t.erase_display(EraseDisplay::Complete, false); + assert_ne!(style::DEFAULT_ID, t.screen().cursor.style_id); +} + +#[test] +fn terminal_semantic_prompt() { + let mut t = init(10, 5); + + // Prompt + t.semantic_prompt(&Command::init(PA::FreshLineNewPrompt)) + .unwrap(); + pr(&mut t, "hello"); + + assert_eq!(0, t.screen().cursor.y); + assert_eq!(5, t.screen().cursor.x); + { + let pt = pt_active(t.screen().cursor.x - 1, u32::from(t.screen().cursor.y)); + assert_eq!(SemanticContent::Prompt, cell_at(&t, pt).semantic_content()); + assert_eq!(RowPrompt::Prompt, row_at(&t, pt).semantic_prompt()); + } + + // Start input but end it on EOL + t.semantic_prompt(&Command::init(PA::EndPromptStartInputTerminateEol)) + .unwrap(); + t.carriage_return(); + t.linefeed().unwrap(); + + // Write some output + assert_eq!(1, t.screen().cursor.y); + assert_eq!(0, t.screen().cursor.x); + pr(&mut t, "world"); + { + let pt = pt_active(t.screen().cursor.x - 1, u32::from(t.screen().cursor.y)); + assert_eq!(SemanticContent::Output, cell_at(&t, pt).semantic_content()); + assert_eq!(RowPrompt::None, row_at(&t, pt).semantic_prompt()); + } +} diff --git a/crates/ghostty-terminal/src/terminal/tests/p8.rs b/crates/ghostty-terminal/src/terminal/tests/p8.rs new file mode 100644 index 0000000..e922968 --- /dev/null +++ b/crates/ghostty-terminal/src/terminal/tests/p8.rs @@ -0,0 +1,746 @@ +use super::*; +use crate::page::{ContentTag, SemanticContent, SemanticPrompt as RowPrompt}; +use crate::screen::SemanticClick; +use ghostty_vt::color::Rgb; +use ghostty_vt::osc::{ + SemanticPrompt as Cmd, + semantic_prompt::{Action as A, Click}, +}; +use ghostty_vt::sgr::Attribute; + +fn sp(a: A) -> Cmd { + Cmd::init(a) +} + +fn sp_opts(a: A, o: &str) -> Cmd { + Cmd { + action: a, + options_unvalidated: o.as_bytes().to_vec(), + } +} + +fn print_chars(t: &mut Terminal, s: &str) { + for c in s.chars() { + t.print(u32::from(c)).unwrap(); + } +} + +fn row_prompt(t: &Terminal, x: u16, y: u32) -> RowPrompt { + row_at(t, pt_active(x, y)).semantic_prompt() +} + +fn at_cursor(t: &Terminal) -> crate::page::Cell { + let c = &t.screen().cursor; + cell_at(t, pt_active(c.x, u32::from(c.y))) +} + +#[test] +fn terminal_semantic_prompt_continuations() { + let mut t = init(10, 5); + + // Prompt + t.semantic_prompt(&sp(A::FreshLineNewPrompt)).unwrap(); + print_chars(&mut t, "hello"); + assert_eq!(0, t.screen().cursor.y); + assert_eq!(5, t.screen().cursor.x); + { + let cell = cell_at(&t, pt_active(t.screen().cursor.x - 1, 0)); + assert_eq!(SemanticContent::Prompt, cell.semantic_content()); + assert_eq!(RowPrompt::Prompt, row_prompt(&t, 0, 0)); + } + + // Start input but end it on EOL + t.carriage_return(); + t.linefeed().unwrap(); + t.semantic_prompt(&sp_opts(A::PromptStart, "k=c")).unwrap(); + + // Write some output + assert_eq!(1, t.screen().cursor.y); + assert_eq!(0, t.screen().cursor.x); + print_chars(&mut t, "world"); + { + let cell = cell_at(&t, pt_active(t.screen().cursor.x - 1, 1)); + assert_eq!(SemanticContent::Prompt, cell.semantic_content()); + assert_eq!(RowPrompt::PromptContinuation, row_prompt(&t, 0, 1)); + } +} + +#[test] +fn terminal_index_in_prompt_mode_marks_new_row_as_prompt_continuation() { + let mut t = init(10, 5); + + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + print_chars(&mut t, "hello"); + assert_eq!(RowPrompt::Prompt, row_prompt(&t, 0, 0)); + + t.carriage_return(); + t.linefeed().unwrap(); + + assert_eq!(RowPrompt::PromptContinuation, row_prompt(&t, 0, 1)); + assert_eq!(SemanticContent::Prompt, t.screen().cursor.semantic_content); +} + +#[test] +fn terminal_index_in_input_mode_does_not_mark_new_row_as_prompt() { + let mut t = init(10, 5); + + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + print_chars(&mut t, "$ "); + t.semantic_prompt(&sp(A::EndPromptStartInput)).unwrap(); + print_chars(&mut t, "echo \\"); + + t.carriage_return(); + t.linefeed().unwrap(); + + assert_eq!(RowPrompt::PromptContinuation, row_prompt(&t, 0, 1)); + assert_eq!(SemanticContent::Input, t.screen().cursor.semantic_content); +} + +#[test] +fn terminal_index_in_output_mode_does_not_mark_new_row_as_prompt() { + let mut t = init(10, 5); + + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + print_chars(&mut t, "$ "); + t.semantic_prompt(&sp(A::EndPromptStartInput)).unwrap(); + print_chars(&mut t, "ls"); + t.semantic_prompt(&sp(A::EndInputStartOutput)).unwrap(); + + t.carriage_return(); + t.linefeed().unwrap(); + + assert_eq!(RowPrompt::None, row_prompt(&t, 0, 1)); +} + +#[test] +fn terminal_osc133c_at_x0_on_prompt_row_clears_prompt_mark() { + let mut t = init(10, 5); + + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + print_chars(&mut t, "$ echo \\"); + + t.carriage_return(); + t.linefeed().unwrap(); + assert_eq!(RowPrompt::PromptContinuation, row_prompt(&t, 0, 1)); + + assert_eq!(0, t.screen().cursor.x); + t.semantic_prompt(&sp(A::EndInputStartOutput)).unwrap(); + + assert_eq!(RowPrompt::None, row_prompt(&t, 0, 1)); +} + +#[test] +fn terminal_osc133c_at_x_gt0_on_prompt_row_does_not_clear_prompt_mark() { + let mut t = init(10, 5); + + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + print_chars(&mut t, "$ "); + + t.carriage_return(); + t.linefeed().unwrap(); + t.semantic_prompt(&sp_opts(A::PromptStart, "k=c")).unwrap(); + print_chars(&mut t, "> "); + + assert_eq!(RowPrompt::PromptContinuation, row_prompt(&t, 0, 1)); + + assert!(t.screen().cursor.x > 0); + t.semantic_prompt(&sp(A::EndInputStartOutput)).unwrap(); + + assert_eq!(RowPrompt::PromptContinuation, row_prompt(&t, 0, 1)); +} + +#[test] +fn terminal_multiple_newlines_in_prompt_mode_marks_all_rows() { + let mut t = init(10, 5); + + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + print_chars(&mut t, "line1"); + + t.carriage_return(); + t.linefeed().unwrap(); + print_chars(&mut t, "line2"); + t.carriage_return(); + t.linefeed().unwrap(); + print_chars(&mut t, "line3"); + + assert_eq!(RowPrompt::Prompt, row_prompt(&t, 0, 0)); + assert_eq!(RowPrompt::PromptContinuation, row_prompt(&t, 0, 1)); + assert_eq!(RowPrompt::PromptContinuation, row_prompt(&t, 0, 2)); +} + +#[test] +fn terminal_osc133a_click_events_1_sets_click_to_click_events() { + let mut t = init(10, 5); + assert_eq!(SemanticClick::None, t.screen().semantic_prompt.click); + t.semantic_prompt(&sp_opts(A::FreshLineNewPrompt, "click_events=1")) + .unwrap(); + assert_eq!(SemanticClick::ClickEvents, t.screen().semantic_prompt.click); +} + +#[test] +fn terminal_osc133a_click_events_0_does_not_set_click_events() { + let mut t = init(10, 5); + t.semantic_prompt(&sp_opts(A::FreshLineNewPrompt, "click_events=0")) + .unwrap(); + assert_eq!(SemanticClick::None, t.screen().semantic_prompt.click); +} + +#[test] +fn terminal_osc133a_cl_option_sets_click_to_cl_value() { + let mut t = init(10, 5); + t.semantic_prompt(&sp_opts(A::FreshLineNewPrompt, "cl=m")) + .unwrap(); + assert_eq!( + SemanticClick::Cl(Click::Multiple), + t.screen().semantic_prompt.click + ); +} + +#[test] +fn terminal_osc133a_cl_line_sets_click_to_line() { + let mut t = init(10, 5); + t.semantic_prompt(&sp_opts(A::FreshLineNewPrompt, "cl=line")) + .unwrap(); + assert_eq!( + SemanticClick::Cl(Click::Line), + t.screen().semantic_prompt.click + ); +} + +#[test] +fn terminal_osc133a_click_events_1_takes_priority_over_cl() { + let mut t = init(10, 5); + t.semantic_prompt(&sp_opts(A::FreshLineNewPrompt, "click_events=1;cl=m")) + .unwrap(); + assert_eq!(SemanticClick::ClickEvents, t.screen().semantic_prompt.click); +} + +#[test] +fn terminal_osc133a_click_events_0_falls_back_to_cl() { + let mut t = init(10, 5); + t.semantic_prompt(&sp_opts(A::FreshLineNewPrompt, "click_events=0;cl=v")) + .unwrap(); + assert_eq!( + SemanticClick::Cl(Click::ConservativeVertical), + t.screen().semantic_prompt.click + ); +} + +#[test] +fn terminal_osc133a_no_click_options_leaves_click_as_none() { + let mut t = init(10, 5); + t.semantic_prompt(&sp_opts(A::FreshLineNewPrompt, "aid=123")) + .unwrap(); + assert_eq!(SemanticClick::None, t.screen().semantic_prompt.click); +} + +#[test] +fn terminal_cursor_is_at_prompt() { + let mut t = init(10, 3); + + assert!(!t.cursor_is_at_prompt()); + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + assert!(t.cursor_is_at_prompt()); + print_chars(&mut t, "$ "); + + // Input is also a prompt + t.semantic_prompt(&sp(A::EndPromptStartInput)).unwrap(); + assert!(t.cursor_is_at_prompt()); + print_chars(&mut t, "ls"); + + // (cursor is not at x=0, so the Fish heuristic doesn't trigger) + t.semantic_prompt(&sp(A::EndInputStartOutput)).unwrap(); + // Still a prompt because this line has a prompt + assert!(t.cursor_is_at_prompt()); + t.linefeed().unwrap(); + assert!(!t.cursor_is_at_prompt()); + + // Until we know we're at a prompt again + t.linefeed().unwrap(); + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + assert!(t.cursor_is_at_prompt()); +} + +#[test] +fn terminal_cursor_is_at_prompt_alternate_screen() { + let mut t = init(3, 2); + + assert!(!t.cursor_is_at_prompt()); + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + assert!(t.cursor_is_at_prompt()); + + // Secondary screen is never a prompt + t.switch_screen_mode(SwitchScreenMode::M1049, true).unwrap(); + assert!(!t.cursor_is_at_prompt()); + t.semantic_prompt(&sp(A::PromptStart)).unwrap(); + assert!(!t.cursor_is_at_prompt()); +} + +#[test] +fn terminal_full_reset_with_a_non_empty_pen() { + let mut t = init(80, 80); + + t.set_attribute(Attribute::DirectColorFg(Rgb { + r: 0xFF, + g: 0, + b: 0x7F, + })) + .unwrap(); + t.set_attribute(Attribute::DirectColorBg(Rgb { + r: 0xFF, + g: 0, + b: 0x7F, + })) + .unwrap(); + t.screen_mut().cursor.semantic_content = SemanticContent::Input; + t.full_reset(); + + assert_eq!(0, at_cursor(&t).style_id()); + assert_eq!(0, t.screen().cursor.style_id); + assert_eq!(SemanticContent::Output, t.screen().cursor.semantic_content); +} + +#[test] +fn terminal_full_reset_hyperlink() { + let mut t = init(80, 80); + + t.screen_mut() + .start_hyperlink(b"http://example.com", None) + .unwrap(); + t.full_reset(); + assert_eq!(0, t.screen().cursor.hyperlink_id); +} + +#[test] +fn terminal_full_reset_with_a_non_empty_saved_cursor() { + let mut t = init(80, 80); + + t.set_attribute(Attribute::DirectColorFg(Rgb { + r: 0xFF, + g: 0, + b: 0x7F, + })) + .unwrap(); + t.set_attribute(Attribute::DirectColorBg(Rgb { + r: 0xFF, + g: 0, + b: 0x7F, + })) + .unwrap(); + t.save_cursor(); + t.full_reset(); + + assert_eq!(0, at_cursor(&t).style_id()); + assert_eq!(0, t.screen().cursor.style_id); +} + +#[test] +fn terminal_full_reset_origin_mode() { + let mut t = init(10, 10); + + t.set_cursor_pos(3, 5); + t.modes.set(Mode::Origin, true); + t.full_reset(); + + assert_eq!(0, t.screen().cursor.y); + assert_eq!(0, t.screen().cursor.x); + assert!(!t.modes.get(Mode::Origin)); +} + +#[test] +fn terminal_full_reset_status_display() { + let mut t = init(10, 10); + + t.status_display = StatusDisplay::StatusLine; + t.full_reset(); + assert!(t.status_display == StatusDisplay::Main); +} + +// https://github.com/mitchellh/ghostty/issues/1607 +#[test] +fn terminal_full_reset_clears_alt_screen_kitty_keyboard_state() { + let mut t = init(10, 10); + + t.switch_screen_mode(SwitchScreenMode::M1049, true).unwrap(); + t.screen_mut() + .kitty_keyboard + .push(ghostty_vt::kitty::key::Flags { + disambiguate: true, + report_events: false, + report_alternates: true, + report_all: true, + report_associated: true, + }); + t.switch_screen_mode(SwitchScreenMode::M1049, false) + .unwrap(); + + t.full_reset(); + assert!(t.screens.get(ScreenKey::Alternate).is_none()); +} + +#[test] +fn terminal_full_reset_default_modes() { + let mut o = Options::new(10, 10); + let mut dm = ModePacked::default(); + dm.set(Mode::GraphemeCluster, true); + o.default_modes = dm; + let mut t = Terminal::new(o).unwrap(); + assert!(t.modes.get(Mode::GraphemeCluster)); + t.full_reset(); + assert!(t.modes.get(Mode::GraphemeCluster)); +} + +#[test] +fn terminal_full_reset_tracked_pins() { + let mut t = init(80, 80); + + // Create a tracked pin + let pin = t.screen().cursor_pin(); + let h = t.screen_mut().pages.track_pin(pin).unwrap(); + t.full_reset(); + let p = t.screen().pages.tracked_pin(h).unwrap(); + assert!(t.screen().pages.pin_is_valid(p)); +} + +// https://github.com/mitchellh/ghostty/issues/272 +#[test] +fn terminal_resize_less_cols_with_wide_char_then_print() { + let mut t = init(3, 3); + + t.print(u32::from('x')).unwrap(); + t.print(0x1F600).unwrap(); + t.resize(2, 3).unwrap(); + t.set_cursor_pos(1, 2); + t.print(0x1F600).unwrap(); +} + +// https://github.com/mitchellh/ghostty/issues/723 +#[test] +fn terminal_resize_with_left_and_right_margin_set() { + let cols = 70; + let rows = 23; + let mut t = init(cols, rows); + + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.print(u32::from('0')).unwrap(); + t.modes.set(Mode::EnableMode3, true); + t.resize(cols, rows).unwrap(); + t.set_left_and_right_margin(2, 0); + t.print_repeat(1850).unwrap(); + let _ = t.modes.restore(Mode::EnableMode3); + t.resize(cols, rows).unwrap(); +} + +// https://github.com/mitchellh/ghostty/issues/1343 +#[test] +fn terminal_resize_with_wraparound_off() { + let mut t = init(4, 2); + + t.modes.set(Mode::Wraparound, false); + print_chars(&mut t, "0123"); + t.resize(2, 2).unwrap(); + + assert_eq!("01", plain(&t)); +} + +#[test] +#[ignore = "pagelist: reflow less cols leaves the active area one row too low (screen has '01\n23' but active/viewport shows '23'); see pagelist-requests"] +fn terminal_resize_with_wraparound_on() { + let mut t = init(4, 2); + + t.modes.set(Mode::Wraparound, true); + print_chars(&mut t, "0123"); + t.resize(2, 2).unwrap(); + + assert_eq!("01\n23", plain(&t)); +} + +#[test] +fn terminal_resize_with_high_unique_style_per_cell() { + let mut t = init(30, 30); + + for y in 0..t.rows { + for x in 0..t.cols { + t.set_cursor_pos(usize::from(y), usize::from(x)); + t.set_attribute(Attribute::DirectColorBg(Rgb { + r: x as u8, + g: y as u8, + b: 0, + })) + .unwrap(); + t.print(u32::from('x')).unwrap(); + } + } + + t.resize(60, 30).unwrap(); +} + +#[test] +fn terminal_resize_with_high_unique_style_per_cell_with_wrapping() { + let mut t = init(30, 30); + + let cell_count: u16 = t.rows * t.cols; + for i in 0..cell_count { + let r = (i >> 8) as u8; + let g = (i & 0xFF) as u8; + + t.set_attribute(Attribute::DirectColorBg(Rgb { r, g, b: 0 })) + .unwrap(); + t.print(u32::from('x')).unwrap(); + } + + t.resize(60, 30).unwrap(); +} + +#[test] +fn terminal_resize_with_reflow_and_saved_cursor() { + let mut t = init(2, 3); + print_str(&mut t, "1A2B"); + t.set_cursor_pos(2, 2); + assert_eq!(u32::from('B'), at_cursor(&t).codepoint()); + + assert_eq!("1A\n2B", plain(&t)); + + t.save_cursor(); + t.resize(5, 3).unwrap(); + t.restore_cursor(); + + assert_eq!("1A2B", plain(&t)); + + // Verify our cursor is still in the same place + assert_eq!(u32::from('B'), at_cursor(&t).codepoint()); +} + +#[test] +fn terminal_resize_with_reflow_and_saved_cursor_pending_wrap() { + let mut t = init(2, 3); + print_str(&mut t, "1A2B"); + assert_eq!(u32::from('B'), at_cursor(&t).codepoint()); + + assert_eq!("1A\n2B", plain(&t)); + + t.save_cursor(); + t.resize(5, 3).unwrap(); + t.restore_cursor(); + + assert_eq!("1A2B", plain(&t)); + + // Pending wrap should be reset + t.print(u32::from('X')).unwrap(); + assert_eq!("1A2BX", plain(&t)); +} + +#[test] +fn terminal_deccolm_without_dec_mode_40() { + let mut t = init(5, 5); + + t.modes.set(Mode::M132Column, true); + t.deccolm(DeccolmMode::Cols132).unwrap(); + assert_eq!(5, t.cols); + assert_eq!(5, t.rows); + assert!(!t.modes.get(Mode::M132Column)); +} + +#[test] +fn terminal_deccolm_unset() { + let mut t = init(5, 5); + + t.modes.set(Mode::EnableMode3, true); + t.deccolm(DeccolmMode::Cols80).unwrap(); + assert_eq!(80, t.cols); + assert_eq!(5, t.rows); +} + +#[test] +fn terminal_deccolm_resets_pending_wrap() { + let mut t = init(5, 5); + + print_chars(&mut t, "ABCDE"); + assert!(t.screen().cursor.pending_wrap); + + t.modes.set(Mode::EnableMode3, true); + t.deccolm(DeccolmMode::Cols80).unwrap(); + assert_eq!(80, t.cols); + assert_eq!(5, t.rows); + assert!(!t.screen().cursor.pending_wrap); +} + +#[test] +fn terminal_deccolm_preserves_sgr_bg() { + let mut t = init(5, 5); + + t.set_attribute(Attribute::DirectColorBg(Rgb { + r: 0xFF, + g: 0, + b: 0, + })) + .unwrap(); + t.modes.set(Mode::EnableMode3, true); + t.deccolm(DeccolmMode::Cols80).unwrap(); + + let cell = cell_at(&t, pt_active(0, 0)); + assert!(cell.content_tag() == ContentTag::BgColorRgb); + assert_eq!( + Rgb { + r: 0xFF, + g: 0, + b: 0 + }, + cell.color_rgb() + ); +} + +#[test] +fn terminal_deccolm_resets_scroll_region() { + let mut t = init(5, 5); + + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_top_and_bottom_margin(2, 3); + t.set_left_and_right_margin(3, 5); + + t.modes.set(Mode::EnableMode3, true); + t.deccolm(DeccolmMode::Cols80).unwrap(); + + assert!(t.modes.get(Mode::EnableLeftAndRightMargin)); + assert_eq!(0, t.scrolling_region.top); + assert_eq!(4, t.scrolling_region.bottom); + assert_eq!(0, t.scrolling_region.left); + assert_eq!(79, t.scrolling_region.right); +} + +#[test] +fn terminal_mode_47_alt_screen_plain() { + let mut t = init(5, 5); + + print_str(&mut t, "1A"); + + t.switch_screen_mode(SwitchScreenMode::M47, true).unwrap(); + assert_eq!(ScreenKey::Alternate, t.screens.active_key); + + assert_eq!("", plain(&t)); + + // Off center because we copy the cursor over from the primary screen + print_str(&mut t, "2B"); + assert_eq!(" 2B", plain(&t)); + + t.switch_screen_mode(SwitchScreenMode::M47, false).unwrap(); + assert_eq!(ScreenKey::Primary, t.screens.active_key); + assert_eq!("1A", plain(&t)); + + t.switch_screen_mode(SwitchScreenMode::M47, true).unwrap(); + assert_eq!(ScreenKey::Alternate, t.screens.active_key); + + // Screen should retain content + assert_eq!(" 2B", plain(&t)); +} + +fn style_checks(t: &Terminal) { + assert!(t.screen().cursor.style_id != style::DEFAULT_ID); + let page = t.screen().cursor_page(); + assert_eq!(1, page.styles.count()); + assert!(page.styles.ref_count(t.screen().cursor.style_id) > 0); +} + +fn copies_cursor_both_directions(mode: SwitchScreenMode) { + let mut t = init(5, 5); + + // Color our cursor red + t.set_attribute(Attribute::DirectColorFg(Rgb { + r: 0xFF, + g: 0, + b: 0x7F, + })) + .unwrap(); + + t.switch_screen_mode(mode, true).unwrap(); + assert_eq!(ScreenKey::Alternate, t.screens.active_key); + style_checks(&t); + + // Set a new style + t.set_attribute(Attribute::DirectColorFg(Rgb { + r: 0, + g: 0xFF, + b: 0, + })) + .unwrap(); + + t.switch_screen_mode(mode, false).unwrap(); + assert_eq!(ScreenKey::Primary, t.screens.active_key); + style_checks(&t); +} + +#[test] +fn terminal_mode_47_copies_cursor_both_directions() { + copies_cursor_both_directions(SwitchScreenMode::M47); +} + +#[test] +fn terminal_mode_1047_alt_screen_plain() { + let mut t = init(5, 5); + + print_str(&mut t, "1A"); + + t.switch_screen_mode(SwitchScreenMode::M1047, true).unwrap(); + assert_eq!(ScreenKey::Alternate, t.screens.active_key); + assert_eq!("", plain(&t)); + + print_str(&mut t, "2B"); + assert_eq!(" 2B", plain(&t)); + + t.switch_screen_mode(SwitchScreenMode::M1047, false) + .unwrap(); + assert_eq!(ScreenKey::Primary, t.screens.active_key); + assert_eq!("1A", plain(&t)); + + t.switch_screen_mode(SwitchScreenMode::M1047, true).unwrap(); + assert_eq!(ScreenKey::Alternate, t.screens.active_key); + assert_eq!("", plain(&t)); +} + +#[test] +fn terminal_mode_1047_copies_cursor_both_directions() { + copies_cursor_both_directions(SwitchScreenMode::M1047); +} + +#[test] +fn terminal_mode_1049_alt_screen_plain() { + let mut t = init(5, 5); + + print_str(&mut t, "1A"); + + t.switch_screen_mode(SwitchScreenMode::M1049, true).unwrap(); + assert_eq!(ScreenKey::Alternate, t.screens.active_key); + assert_eq!("", plain(&t)); + + print_str(&mut t, "2B"); + assert_eq!(" 2B", plain(&t)); + + t.switch_screen_mode(SwitchScreenMode::M1049, false) + .unwrap(); + assert_eq!(ScreenKey::Primary, t.screens.active_key); + assert_eq!("1A", plain(&t)); + + // Write, our cursor should be restored back. + print_str(&mut t, "C"); + assert_eq!("1AC", plain(&t)); + + t.switch_screen_mode(SwitchScreenMode::M1049, true).unwrap(); + assert_eq!(ScreenKey::Alternate, t.screens.active_key); + assert_eq!("", plain(&t)); +} + +// Reproduces a crash found by AFL++ fuzzer: orphaned spacer_tail when +// deleteLines clears all rows without shifting. +#[test] +fn terminal_delete_lines_wide_char_at_right_margin_with_full_clear() { + let mut t = init(80, 24); + + t.set_cursor_pos(10, 39); + t.print(0x4E2D).unwrap(); + + t.modes.set(Mode::EnableLeftAndRightMargin, true); + t.set_left_and_right_margin(5, 39); + + let rows = usize::from(t.rows); + t.scroll_up(rows).unwrap(); +}