diff --git a/Cargo.toml b/Cargo.toml index f58808f2..52d10607 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,8 +43,9 @@ log.workspace = true # Smart double-click selection patterns (URL/email/path). Already in the tree # transitively, so pinning it here adds no new native code. regex = "1" -# SIMD byte search for `MarkScanner`'s Text-state fast path (`terminal::marks`), -# which scans every output batch the client receives — the same skip-ahead the +# SIMD byte search for `ParkedCursorScanner`'s fast path +# (`terminal::parked_cursor`), which scans every output batch the client +# receives — the same skip-ahead the # tokenizers in `tty7-core` already use. Already in the tree via `tty7-core`, # so this pins no new code. memchr = "2" diff --git a/src/terminal/images.rs b/src/terminal/images.rs index b71091d2..0cf21573 100644 --- a/src/terminal/images.rs +++ b/src/terminal/images.rs @@ -9,18 +9,24 @@ //! //! # Why anchor by absolute row //! -//! Like a command [`mark`](crate::terminal::marks), an image's position has to -//! survive scrolling. A kitty image is placed at the cursor cell as it stood when -//! its command appeared in the stream; once recorded, the grid keeps scrolling -//! under it. We store the row as an absolute index from the top of scrollback -//! (`history_size - display_offset + cursor_line`, the exact formula -//! [`record_mark`](crate::terminal::remote) uses) and convert back to a screen -//! row at paint time (`anchor_row - history_size + display_offset`, the inverse -//! conversion). Below the scrollback limit — where a pane spends most of its life — -//! this is exact; past it the anchor drifts by the (unobservable) discard count, -//! the same caveat marks carry, and a browser that redraws every frame corrects +//! An image's position has to survive scrolling. A kitty image is placed at the +//! cursor cell as it stood when its command appeared in the stream; once recorded, +//! the grid keeps scrolling under it. We store the row as an absolute index from +//! the top of scrollback (`history_size - display_offset + cursor_line`) and +//! convert back to a screen row at paint time (`anchor_row - history_size + +//! display_offset`, the inverse conversion). Below the scrollback limit — where a +//! pane spends most of its life — this is exact; past it the anchor drifts by the +//! (unobservable) discard count, and a browser that redraws every frame corrects //! it on the next transmit anyway. //! +//! The anchor is read off whichever grid is active, and the alt screen has no +//! history of its own — so an image placed there records a small absolute row +//! that resolves against the primary grid once the app exits. A sender that +//! deletes its own images on the way out (the normal case) is unaffected; one +//! that dies without an `a=d` can leave a frame anchored over the primary +//! screen. Modelling that properly wants a per-screen store rather than one +//! keyed on the displayed grid. +//! //! GPUI's sprite atlas expects **BGRA** pixels (it swaps R↔B when caching an //! `image` crate `RgbaImage` — see `gpui::img`), so [`decode`] does the swap once //! at ingest; the placed [`RenderImage`] is uploaded verbatim thereafter. @@ -66,7 +72,7 @@ pub struct PlacedImage { /// A pane's placed images plus the retired render images awaiting atlas /// eviction, shared between the reader thread (writer) and the paint path -/// (reader), exactly like [`Marks`](crate::terminal::marks::Marks). +/// (reader). /// /// `retired` is the other half of the fix for a browser that repaints at 60fps: /// each transmitted frame becomes a fresh [`RenderImage`] with a new atlas id, @@ -81,7 +87,7 @@ struct StoreInner { } /// A pane's placed images, shared between the reader thread (writer) and the -/// paint path (reader), exactly like [`Marks`](crate::terminal::marks::Marks). +/// paint path (reader). #[derive(Clone, Default)] pub struct ImageStore(Arc>); diff --git a/src/terminal/marks.rs b/src/terminal/marks.rs deleted file mode 100644 index 14045333..00000000 --- a/src/terminal/marks.rs +++ /dev/null @@ -1,339 +0,0 @@ -use std::sync::{Arc, Mutex}; - -const MAX_MARKS: usize = 500; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommandMark { - pub row: i64, - pub text: String, - pub exit: Option, - pub done: bool, -} - -#[derive(Clone, Default)] -pub struct Marks(Arc>>); - -impl Marks { - pub fn new() -> Self { - Self::default() - } - - pub fn begin(&self, row: i64, text: String) { - let Ok(mut marks) = self.0.lock() else { return }; - if marks.last().is_some_and(|m| m.row == row && !m.done) { - if let Some(last) = marks.last_mut() { - last.text = text; - } - return; - } - marks.push(CommandMark { - row, - text, - exit: None, - done: false, - }); - let overflow = marks.len().saturating_sub(MAX_MARKS); - if overflow > 0 { - marks.drain(..overflow); - } - } - - pub fn set_text(&self, text: String) { - let Ok(mut marks) = self.0.lock() else { return }; - if let Some(last) = marks.last_mut() { - if !last.done { - last.text = text; - } - } - } - - pub fn finish(&self, exit: Option) { - let Ok(mut marks) = self.0.lock() else { return }; - if let Some(last) = marks.last_mut() { - last.done = true; - last.exit = exit; - } - } - - /// Only the tests read the store back; the panel that used to has been - /// removed, and prompt state reaches the client over the wire instead. - #[cfg(test)] - pub fn list(&self) -> Vec { - let Ok(marks) = self.0.lock() else { - return Vec::new(); - }; - marks - .iter() - .filter(|m| !m.text.trim().is_empty()) - .cloned() - .collect() - } - - pub fn clear(&self) { - if let Ok(mut marks) = self.0.lock() { - marks.clear(); - } - } -} - -pub fn parse_done_exit(payload: &[u8]) -> Option { - let rest = payload.strip_prefix(b"D")?; - let rest = rest.strip_prefix(b";")?; - std::str::from_utf8(rest).ok()?.trim().parse().ok() -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum MarkEvent { - Prompt, - Command(String), - Done(Option), -} - -#[derive(Default)] -pub struct MarkScanner { - state: ScanState, - payload: Vec, -} - -#[derive(Default, PartialEq, Eq)] -enum ScanState { - #[default] - Text, - Esc, - Osc, - OscEsc, -} - -const MAX_PAYLOAD: usize = 64 * 1024; - -impl MarkScanner { - pub fn new() -> Self { - Self::default() - } - - pub fn feed(&mut self, bytes: &[u8], mut on_mark: impl FnMut(usize, MarkEvent)) { - let mut i = 0; - while i < bytes.len() { - if self.state == ScanState::Text { - let Some(off) = memchr::memchr(0x1b, &bytes[i..]) else { - return; - }; - self.state = ScanState::Esc; - i += off + 1; - continue; - } - let b = bytes[i]; - match self.state { - ScanState::Text => unreachable!(), - ScanState::Esc => { - if b == b']' { - self.state = ScanState::Osc; - self.payload.clear(); - } else { - self.state = if b == 0x1b { - ScanState::Esc - } else { - ScanState::Text - }; - } - } - ScanState::Osc => match b { - 0x07 => { - if let Some(ev) = self.take() { - on_mark(i + 1, ev); - } - self.state = ScanState::Text; - } - 0x1b => self.state = ScanState::OscEsc, - _ => { - if self.payload.len() < MAX_PAYLOAD { - self.payload.push(b); - } else { - self.state = ScanState::Text; - self.payload.clear(); - } - } - }, - ScanState::OscEsc => { - if b == b'\\' { - if let Some(ev) = self.take() { - on_mark(i + 1, ev); - } - self.state = ScanState::Text; - } else { - if self.payload.len() < MAX_PAYLOAD { - self.payload.push(0x1b); - self.state = ScanState::Osc; - } else { - self.state = ScanState::Text; - self.payload.clear(); - } - } - } - } - i += 1; - } - } - - fn take(&mut self) -> Option { - let payload = std::mem::take(&mut self.payload); - let body = payload.strip_prefix(b"133;")?; - match body.first()? { - b'A' => Some(MarkEvent::Prompt), - b'C' => { - let cmd = body - .strip_prefix(b"C;") - .map(|c| String::from_utf8_lossy(c).into_owned()) - .unwrap_or_default(); - Some(MarkEvent::Command(cmd)) - } - b'D' => Some(MarkEvent::Done(parse_done_exit(body))), - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_prompt_with_no_command_is_not_an_entry() { - let marks = Marks::new(); - marks.begin(10, String::new()); - assert!( - marks.list().is_empty(), - "an empty prompt the user walked away from isn't a command" - ); - marks.set_text("cargo build".into()); - assert_eq!(marks.list().len(), 1); - } - - #[test] - fn a_prompt_redraw_updates_in_place() { - let marks = Marks::new(); - marks.begin(10, String::new()); - marks.set_text("cargo t".into()); - marks.begin(10, "cargo test".into()); - let got = marks.list(); - assert_eq!(got.len(), 1, "a redraw is the same prompt, not a new one"); - assert_eq!(got[0].text, "cargo test"); - } - - #[test] - fn a_new_prompt_after_a_finished_command_is_a_new_entry() { - let marks = Marks::new(); - marks.begin(10, String::new()); - marks.set_text("ls".into()); - marks.finish(Some(0)); - marks.begin(10, String::new()); - marks.set_text("pwd".into()); - let got = marks.list(); - assert_eq!(got.len(), 2); - assert_eq!(got[0].exit, Some(0)); - assert!(!got[1].done); - } - - #[test] - fn marks_are_capped_from_the_front() { - let marks = Marks::new(); - for i in 0..(MAX_MARKS + 10) { - marks.begin(i as i64, format!("cmd{i}")); - marks.finish(Some(0)); - } - let got = marks.list(); - assert_eq!(got.len(), MAX_MARKS); - assert_eq!(got[0].text, "cmd10", "the oldest aged out, not the newest"); - } - - fn scan(chunks: &[&[u8]]) -> Vec<(usize, MarkEvent)> { - let mut scanner = MarkScanner::new(); - let mut out = Vec::new(); - for chunk in chunks { - scanner.feed(chunk, |off, ev| out.push((off, ev))); - } - out - } - - #[test] - fn splitting_anywhere_yields_the_same_marks() { - let stream: &[u8] = - b"out\x1b\x1b[32mmore\x1b]133;C;git status\x07text\x1b]133;D;0\x1b\\tail\x1b"; - let whole = scan(&[stream]); - assert_eq!(whole.len(), 2, "both marks found in one pass"); - - for at in 0..=stream.len() { - let mut scanner = MarkScanner::new(); - let mut got = Vec::new(); - scanner.feed(&stream[..at], |off, ev| got.push((off, ev))); - scanner.feed(&stream[at..], |off, ev| got.push((at + off, ev))); - assert_eq!(got, whole, "splitting at {at} changed the marks"); - } - } - - #[test] - fn reports_marks_just_past_their_terminator() { - let got = scan(&[b"ab\x1b]133;A\x07cd"]); - assert_eq!(got, vec![(10, MarkEvent::Prompt)]); - assert_eq!(&b"ab\x1b]133;A\x07cd"[10..], b"cd"); - } - - #[test] - fn carries_a_mark_split_across_two_feeds() { - let got = scan(&[b"out\x1b]13", b"3;C;cargo build\x07more"]); - assert_eq!( - got, - vec![(16, MarkEvent::Command("cargo build".into()))], - "the mark is attributed to the batch its terminator lands in" - ); - } - - #[test] - fn accepts_st_terminated_marks() { - let got = scan(&[b"\x1b]133;D;130\x1b\\"]); - assert_eq!(got, vec![(13, MarkEvent::Done(Some(130)))]); - } - - #[test] - fn ignores_other_osc_sequences() { - let got = scan(&[b"\x1b]0;a title\x07\x1b]7;file://h/x\x07\x1b]133;B\x07"]); - assert!( - got.is_empty(), - "titles, cwd reports and prompt-end are not command marks" - ); - } - - #[test] - fn a_command_containing_semicolons_survives_intact() { - let got = scan(&[b"\x1b]133;C;for i in a b; do echo $i; done\x07"]); - assert_eq!( - got, - vec![( - 39, - MarkEvent::Command("for i in a b; do echo $i; done".into()) - )], - "only the first two fields are structure; the rest is the command" - ); - } - - #[test] - fn an_unterminated_payload_cannot_grow_without_bound() { - let mut scanner = MarkScanner::new(); - let mut fired = 0; - scanner.feed(b"\x1b]133;C;", |_, _| fired += 1); - for _ in 0..40 { - scanner.feed(&vec![b'x'; 4096], |_, _| fired += 1); - } - assert_eq!(fired, 0, "never terminated, so never reported"); - assert!(scanner.payload.len() <= MAX_PAYLOAD); - } - - #[test] - fn parses_done_payloads() { - assert_eq!(parse_done_exit(b"D;0"), Some(0)); - assert_eq!(parse_done_exit(b"D;130"), Some(130)); - assert_eq!(parse_done_exit(b"D"), None, "done, code unknown"); - assert_eq!(parse_done_exit(b"D;aborted"), None); - assert_eq!(parse_done_exit(b"C"), None, "not a done mark at all"); - } -} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 6368d5b1..1da5da26 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -13,7 +13,6 @@ mod hold; pub(crate) mod images; pub mod input; mod loopback; -pub(crate) mod marks; pub mod palette; pub(crate) mod pane_liveness; pub(crate) mod parked_cursor; diff --git a/src/terminal/parked_cursor.rs b/src/terminal/parked_cursor.rs index bbfc1f13..8b3fbd25 100644 --- a/src/terminal/parked_cursor.rs +++ b/src/terminal/parked_cursor.rs @@ -36,10 +36,9 @@ pub enum CursorCut { Shown { parked: bool }, } -/// Byte scanner over the pty stream, reporting cursor hide/shows in the same -/// shape [`crate::terminal::marks::MarkScanner`] reports prompt marks: an -/// offset one past the sequence, so the reader can advance the emulator to -/// exactly there and act on the state the sequence left behind. +/// Byte scanner over the pty stream, reporting cursor hide/shows as an offset +/// one past the sequence, so the reader can advance the emulator to exactly +/// there and act on the state the sequence left behind. #[derive(Default)] pub struct ParkedCursorScanner { state: State, diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 9278f7a6..93e12652 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -12,7 +12,6 @@ use alacritty_terminal::sync::FairMutex; use alacritty_terminal::term::{Config, Term, TermMode}; use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle}; -use crate::terminal::marks::{MarkEvent, MarkScanner}; use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursorScanner}; use std::collections::VecDeque; @@ -77,7 +76,6 @@ struct ReaderSignals { shell_vi_mode: Arc, auth: Arc>>, phase: Arc>>, - marks: crate::terminal::marks::Marks, /// Kitty-graphics images the daemon lifted out of the stream (issue #213), /// anchored to the grid for the paint path to blit. Shared with the reader, /// which places/deletes them as `DaemonMsg::Image`/`DeleteImage` frames land. @@ -176,11 +174,10 @@ pub struct RemoteTerminal { auto_supplied_password: bool, agent: Arc>>, agent_session: Arc>>, - marks: crate::terminal::marks::Marks, /// Kitty-graphics images placed on this pane's grid (issue #213). /// Written by the reader thread from out-of-band `Image`/`DeleteImage` - /// frames, read by the paint path — same shared-handle discipline as - /// `marks`, since only the client holds the grid the anchors are relative to. + /// frames, read by the paint path — only the client holds the grid the + /// anchors are relative to, so the store lives here rather than in the daemon. images: crate::terminal::images::ImageStore, route: PaneRoute, proxy: EventProxy, @@ -409,7 +406,6 @@ impl RemoteTerminal { shell_vi_mode: self.shell_vi_mode.clone(), auth: self.auth_prompts.clone(), phase: self.ssh_phase.clone(), - marks: self.marks.clone(), images: self.images.clone(), }, ); @@ -459,7 +455,6 @@ impl RemoteTerminal { let auth_prompts: Arc>> = Arc::new(Mutex::new(VecDeque::new())); let ssh_phase: Arc>> = Arc::new(Mutex::new(None)); - let marks = crate::terminal::marks::Marks::new(); let images = crate::terminal::images::ImageStore::new(); let reader_quit = Arc::new(AtomicBool::new(false)); @@ -481,7 +476,6 @@ impl RemoteTerminal { shell_vi_mode: shell_vi_mode.clone(), auth: auth_prompts.clone(), phase: ssh_phase.clone(), - marks: marks.clone(), images: images.clone(), }, ); @@ -508,7 +502,6 @@ impl RemoteTerminal { auto_supplied_password: false, agent, agent_session, - marks, images, route: PaneRoute::Local, proxy, @@ -571,7 +564,6 @@ impl RemoteTerminal { shell_vi_mode, auth, phase, - marks, images, } = signals; crate::core::threads::promote_to_user_interactive(); @@ -580,7 +572,6 @@ impl RemoteTerminal { let mut osc = OscNotifyScanner::default(); let mut mode_tok = OscTokenizer::new(&[b"133"]); let mut zle_tok = OscTokenizer::new(&[b"133"]); - let mut mark_scan = MarkScanner::new(); let mut cursor_scan = ParkedCursorScanner::new(); let mut parked_cursor = ParkedCursorRepair::default(); let mut pending: Vec = buffered; @@ -627,18 +618,13 @@ impl RemoteTerminal { macro_rules! flush_batch { () => { if !out_batch.is_empty() { - // Both scanners report an offset one past the - // sequence they matched, so the batch splits at - // each of them: advance the emulator to the cut, - // act on the state that sequence left behind, - // carry on. In offset order, since a frame can - // carry marks and cursor shows both. - let mut cuts: Vec<(usize, Cut)> = Vec::new(); - mark_scan - .feed(&out_batch, |off, ev| cuts.push((off, Cut::Mark(ev)))); - cursor_scan - .feed(&out_batch, |off, c| cuts.push((off, Cut::Cursor(c)))); - cuts.sort_by_key(|(off, _)| *off); + // The scanner reports an offset one past the + // sequence it matched, in ascending order, so the + // batch splits at each of them: advance the + // emulator to the cut, act on the state that + // sequence left behind, carry on. + let mut cuts: Vec<(usize, CursorCut)> = Vec::new(); + cursor_scan.feed(&out_batch, |off, c| cuts.push((off, c))); { let t0 = trace.then(std::time::Instant::now); let mut term = term.lock(); @@ -653,12 +639,7 @@ impl RemoteTerminal { for (off, cut) in cuts { processor.advance(&mut *term, &out_batch[at..off]); at = off; - match cut { - Cut::Mark(ev) => record_mark(&term, &marks, ev), - Cut::Cursor(vis) => { - parked_cursor.apply(&mut term, vis); - } - } + parked_cursor.apply(&mut term, cut); } processor.advance(&mut *term, &out_batch[at..]); } @@ -786,8 +767,9 @@ impl RemoteTerminal { // around it. Flush the pending text first so the grid // cursor sits where the sender drew the image, then // anchor the placement to that cell in scroll-stable - // absolute-row coordinates (the same formula - // `record_mark` uses), so it tracks scrolling. + // absolute-row coordinates (`history_size - + // display_offset + cursor_line`), so it tracks + // scrolling. DaemonMsg::Image(frame) => { flush_batch!(); if let Some(img) = @@ -1108,10 +1090,6 @@ impl RemoteTerminal { self.agent.lock().ok().and_then(|g| *g) } - pub fn marks(&self) -> crate::terminal::marks::Marks { - self.marks.clone() - } - /// The kitty-graphics image store for this pane. Cheap handle clone — the /// store is an `Arc>` shared with the reader thread, which places /// and deletes images as out-of-band frames arrive from the daemon. @@ -1512,27 +1490,6 @@ impl RemoteTerminal { } } -/// Something in the pty stream the reader has to act on at the byte where it -/// appeared, rather than after the whole batch has been parsed. -enum Cut { - Mark(MarkEvent), - Cursor(CursorCut), -} - -fn record_mark(term: &Term, marks: &crate::terminal::marks::Marks, event: MarkEvent) { - use alacritty_terminal::grid::Dimensions as _; - match event { - MarkEvent::Prompt => { - let grid = term.grid(); - let row = grid.history_size() as i64 - grid.display_offset() as i64 - + i64::from(grid.cursor.point.line.0); - marks.begin(row, String::new()); - } - MarkEvent::Command(cmd) => marks.set_text(cmd), - MarkEvent::Done(exit) => marks.finish(exit), - } -} - fn daemon_not_listening(err: &anyhow::Error) -> bool { err.chain().any(|cause| { cause.downcast_ref::().is_some_and(|io| { @@ -3425,47 +3382,6 @@ mod tests { ); } - #[test] - fn marks_record_the_row_each_one_landed_on() { - let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); - let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - let poll = |want: usize| { - for _ in 0..200 { - if term.marks().list().len() == want { - return true; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - false - }; - - let mut stream = Vec::new(); - stream.extend_from_slice(b"\x1b]133;A\x07"); - stream.extend_from_slice(b"\x1b]133;C;echo one\x07"); - stream.extend_from_slice(b"one\r\n"); - stream.extend_from_slice(b"\x1b]133;D;0\x07"); - stream.extend_from_slice(b"\x1b]133;A\x07"); - stream.extend_from_slice(b"\x1b]133;C;false\x07"); - stream.extend_from_slice(b"\r\n"); - stream.extend_from_slice(b"\x1b]133;D;1\x07"); - DaemonMsg::Output(stream).encode(&mut daemon_side).unwrap(); - daemon_side.flush().unwrap(); - - assert!(poll(2), "both commands recorded"); - let marks = term.marks().list(); - assert_eq!(marks[0].text, "echo one"); - assert_eq!(marks[0].exit, Some(0)); - assert_eq!(marks[1].text, "false"); - assert_eq!(marks[1].exit, Some(1), "a failure keeps its exit code"); - assert!( - marks[1].row > marks[0].row, - "the second prompt is further down the scrollback ({} vs {}) — equal rows \ - would mean the advance wasn't split at the marks", - marks[0].row, - marks[1].row - ); - } - #[test] fn zle_reading_follows_live_prompt_end_marks() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index a7b7afa0..a181bd48 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2546,10 +2546,21 @@ impl TerminalView { } pub fn clear_scrollback(&mut self, cx: &mut Context) { + use alacritty_terminal::vte::ansi::{ClearMode, Handler as _}; + self.cancel_scroll_anim(); - self.terminal.term.lock().grid_mut().clear_history(); + // Go through `clear_screen` rather than `grid_mut().clear_history()`: + // it drops a selection anchored in the rows we are about to discard and + // clamps the vi cursor back into the grid, which purging the history + // behind the term's back would leave pointing at rows that no longer + // exist. + self.terminal.term.lock().clear_screen(ClearMode::Saved); + // Image placements are anchored in absolute scrollback rows, so the + // rows we just discarded moved every anchor. Drop them; the daemon does + // not replay out-of-band image frames, so a browser redraws on its next + // transmit (same reasoning as the reattach path in `adopt_relink`). + self.terminal.images().clear(); self.scroll_frac = 0.; - self.terminal.marks().clear(); self.terminal.write(vec![0x0c_u8]); cx.notify(); } @@ -7118,6 +7129,91 @@ mod gpui_tests { ); } + /// A 1x1 red placement anchored at an absolute scrollback row, built the way + /// the decode worker hands one to the store. + fn placed_at(anchor_row: i64) -> crate::terminal::images::PlacedImage { + use tty7_core::core::kitty_graphics::{Image, WireFormat}; + + let mut img = Image { + id: 1, + number: 0, + placement: 0, + width: 1, + height: 1, + cols: 0, + rows: 0, + data: vec![0xff, 0x00, 0x00, 0xff], + format: WireFormat::Rgba, + compressed: false, + }; + let (data, width_px, height_px) = crate::terminal::images::decode(&mut img).unwrap(); + crate::terminal::images::PlacedImage { + data, + anchor_row, + anchor_col: 0, + width_px, + height_px, + cols: 0, + rows: 0, + id: 1, + placement: 0, + painted: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + #[gpui::test] + fn clearing_the_scrollback_drops_what_was_anchored_in_it(cx: &mut TestAppContext) { + let (window, mut daemon) = harness(cx); + + // Overflow the 24-row viewport so there is a scrollback to purge. + let mut out = Vec::new(); + for i in 0..60 { + out.extend_from_slice(format!("line {i}\r\n").as_bytes()); + } + DaemonMsg::Output(out).encode(&mut daemon).unwrap(); + for _ in 0..200 { + let filled = window + .update(cx, |view, _, _| { + view.terminal.term.lock().grid().history_size() > 0 + }) + .unwrap(); + if filled { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, _, cx| { + let history = view.terminal.term.lock().grid().history_size(); + assert!(history > 0, "the test needs a scrollback to clear"); + + // Both of these address rows `clear_history` is about to drop: + // an image anchored by absolute row, and a selection reaching up + // into the history. + view.terminal.images().place(placed_at(history as i64)); + view.terminal.term.lock().selection = Some(Selection::new( + SelectionType::Simple, + Point::new(Line(-1), Column(0)), + Side::Left, + )); + + view.clear_scrollback(cx); + + assert!( + view.terminal.images().snapshot().is_empty(), + "a stale anchor blits the frame over live output, or off-screen \ + entirely — the daemon never replays the frame to correct it" + ); + assert!( + view.terminal.term.lock().selection.is_none(), + "a selection left pointing at purged rows clamps onto the \ + viewport and copies whatever text moved into them" + ); + }) + .unwrap(); + } + #[gpui::test] fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx);