diff --git a/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs b/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs index c1552ed..dec49d6 100644 --- a/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs @@ -14,6 +14,46 @@ fn hook(session: &str, name: &str, sequence: u64) -> AiHookEvent { crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(42)).unwrap() } +#[gpui::test] +fn pi_redraw_anchor_follows_accepted_hook_and_command_lifecycle(cx: &mut TestAppContext) { + use nebula_terminal::term::test::TermSize; + use nebula_terminal::vte::ansi::Processor; + + let (view, window, _) = open(cx); + view.update(window, |view, cx| { + let event = |kind: &str, seq: u64| { + crate::ai_hook::parse_remote_envelope( + format!("nebula-hook/1 source=pi pane=42\n{{\"kind\":\"{kind}\",\"session_id\":\"redraw-policy\",\"bridge_sequence\":{seq}}}").as_bytes(), + Some(42), + ).unwrap() + }; + let check = |view: &mut TerminalView, expected: usize| { + let mut term = view.session.as_ref().unwrap().term.lock(); + term.resize(TermSize::new(60, 5)); + let mut stream = Processor::::default(); + let text = (0..12).map(|n| format!("ROW_{n:03}")).collect::>().join("\r\n"); + let frame = format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{text}\x1b[?2026l"); + term.scroll_display(Scroll::Bottom); + stream.advance(&mut *term, frame.as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-2)); + stream.advance(&mut *term, frame.as_bytes()); + assert_eq!(term.grid().display_offset(), expected); + }; + check(view, 0); + assert!(view.handle_ai_hook(&event("session-start", 1), cx)); + check(view, 5); + assert!(view.handle_ai_hook(&event("done", 2), cx)); + check(view, 5); // 回合结束不是 Pi 进程退出。 + assert!(view.handle_ai_hook(&event("session-end", 3), cx)); + check(view, 0); + assert!(view.handle_ai_hook(&event("session-start", 4), cx)); + check(view, 5); + view.finish_foreground_command(Some(0), cx); + check(view, 0); + }); +} + fn screen(view: &mut TerminalView, text: &str) { feed(view, format!("\x1b[2J\x1b[H{}", text.replace('\n', "\r\n")).as_bytes()); } diff --git a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs index 625e23d..14b7617 100644 --- a/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs +++ b/nebula_app/src/gpui_shell/terminal/view/agent_activity.rs @@ -67,6 +67,11 @@ impl TerminalView { | crate::ai_hook::AiTurnOutcome::Incomplete ); } + if let Some(session) = &self.session { + session.term.lock().set_redraw_anchor_enabled( + event.source == "pi" && event.kind != AiHookKind::SessionEnd, + ); + } if let Some(target) = target { let previous = self.recovery.target.clone(); self.recovery.confirm(target); diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer.rs b/nebula_app/src/gpui_shell/terminal/view/pointer.rs index 0ae3802..a5f6221 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer.rs @@ -853,6 +853,9 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { + if let Some(session) = &self.session { + session.term.lock().cancel_redraw_anchor(); + } let delta_y = event.delta.pixel_delta(self.line_height).y.as_f32(); // 旧壳 `mouse_wheel_input`:Ctrl+滚轮先于一切滚动消费者,一步 1 // 逻辑像素,钳在 4–64。设置页步进会写盘;这里同样写 `font_size=`, diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index bf8b158..1940225 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -127,6 +127,9 @@ impl TerminalView { /// the primary edge; the cached-prompt path calls the same reset so the two /// lifecycle routes cannot drift apart. pub(super) fn clear_foreground_agent_state(&mut self, cx: &mut Context) -> bool { + if let Some(session) = &self.session { + session.term.lock().set_redraw_anchor_enabled(false); + } self.confirmation.observe_waiting(false); self.recovery.command_ended(); self.answers.close(); diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 120a20e..3f455f3 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -185,9 +185,21 @@ impl StreamProcessor { } pub fn stop_sync(&mut self, terminal: &mut Term) { + terminal.cancel_redraw_anchor(); self.parser.stop_sync(terminal); } + fn advance(&mut self, terminal: &mut Term, bytes: &[u8]) { + // VTE 0.15 在约 2 MiB 时强制提交同步缓冲;提前取消兼容锚点, + // 但不强制结束/截断输出。分块保证单次大输入也不会绕过检查。 + for chunk in bytes.chunks(4096) { + if self.parser.sync_bytes_count() >= 1024 * 1024 { + terminal.cancel_redraw_anchor(); + } + self.parser.advance(terminal, chunk); + } + } + pub fn feed( &mut self, terminal: &mut Term, @@ -200,7 +212,7 @@ impl StreamProcessor { // Titles and shell identity/cwd reports must retain wire order: // coalescing a remote cwd past a parent prompt would attribute that // directory to the parent shell's completion history. - self.parser.advance(terminal, &bytes[advanced..offset]); + self.advance(terminal, &bytes[advanced..offset]); advanced = offset; match event { OscEvent::Cwd(cwd) => event_proxy.send_event(Event::CwdReport(cwd)), @@ -239,7 +251,7 @@ impl StreamProcessor { let rows = (disp_h / cell_h).ceil().max(1.0) as usize; let abs_line = terminal.nebula_cursor_abs_line(); for _ in 0..=rows { - self.parser.advance(terminal, b"\r\n"); + self.advance(terminal, b"\r\n"); } event_proxy.send_event(Event::InlineImage { data: std::sync::Arc::new(data), @@ -250,7 +262,7 @@ impl StreamProcessor { }, } } - self.parser.advance(terminal, &bytes[advanced..]); + self.advance(terminal, &bytes[advanced..]); } } diff --git a/nebula_terminal/src/term/clear.rs b/nebula_terminal/src/term/clear.rs new file mode 100644 index 0000000..ef6805b --- /dev/null +++ b/nebula_terminal/src/term/clear.rs @@ -0,0 +1,57 @@ +//! 清屏与滚动历史清除的网格、选区及阅读位置合同。 +use super::{Boundary, Column, Dimensions, EventListener, Line, Term, TermMode, ansi, cmp}; + +impl Term { + pub(super) fn clear_screen_contents(&mut self, mode: ansi::ClearMode) { + self.observe_redraw_clear(&mode); + log::trace!("Clearing screen: {mode:?}"); + let bg = self.grid.cursor.template.bg; + let screen_lines = self.screen_lines(); + + match mode { + ansi::ClearMode::Above => { + let cursor = self.grid.cursor.point; + if cursor.line > 1 { + self.grid.reset_region(..cursor.line); + } + let end = cmp::min(cursor.column + 1, Column(self.columns())); + for cell in &mut self.grid[cursor.line][..end] { + *cell = bg.into(); + } + let range = Line(0)..=cursor.line; + self.selection = self.selection.take().filter(|s| !s.intersects_range(range)); + }, + ansi::ClearMode::Below => { + let cursor = self.grid.cursor.point; + for cell in &mut self.grid[cursor.line][cursor.column..] { + *cell = bg.into(); + } + if (cursor.line.0 as usize) < screen_lines - 1 { + self.grid.reset_region((cursor.line + 1)..); + } + let range = cursor.line..Line(screen_lines as i32); + self.selection = self.selection.take().filter(|s| !s.intersects_range(range)); + }, + ansi::ClearMode::All => { + if self.mode.contains(TermMode::ALT_SCREEN) { + self.grid.reset_region(..); + } else { + let old_offset = self.grid.display_offset(); + self.grid.clear_viewport(); + let lines = self.grid.display_offset().saturating_sub(old_offset); + self.vi_mode_cursor.point.line = + (self.vi_mode_cursor.point.line - lines).grid_clamp(self, Boundary::Grid); + } + self.selection = None; + }, + ansi::ClearMode::Saved if self.history_size() > 0 => { + self.grid.clear_history(); + self.vi_mode_cursor.point.line = + self.vi_mode_cursor.point.line.grid_clamp(self, Boundary::Cursor); + self.selection = self.selection.take().filter(|s| !s.intersects_range(..Line(0))); + }, + ansi::ClearMode::Saved => (), + } + self.mark_fully_damaged(); + } +} diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index c9fa81c..cffb9dc 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -28,12 +28,14 @@ use crate::vte::ansi::{ }; pub mod cell; +mod clear; pub mod color; mod damage; mod keyboard; #[cfg(test)] mod keyboard_contract_tests; mod prompt; +mod redraw_anchor; mod renderable; pub mod search; @@ -158,6 +160,7 @@ pub fn viewport_to_point_from(origin: Line, point: Point) -> Point { } pub struct Term { + redraw_anchor: redraw_anchor::RedrawAnchor, /// Terminal focus controlling the cursor shape. pub is_focused: bool, @@ -342,6 +345,7 @@ impl Term { where T: EventListener, { + self.cancel_redraw_anchor(); let old_display_offset = self.grid.display_offset(); self.grid.scroll_display(scroll); self.event_proxy.send_event(Event::MouseCursorDirty); @@ -382,6 +386,7 @@ impl Term { let damage = TermDamageState::new(num_cols, num_lines); Term { + redraw_anchor: Default::default(), inactive_grid, scroll_region, event_proxy, @@ -727,6 +732,7 @@ impl Term { /// Resize terminal to new dimensions. pub fn resize(&mut self, size: S) { + self.cancel_redraw_anchor(); let old_cols = self.columns(); let old_lines = self.screen_lines(); @@ -804,6 +810,7 @@ impl Term { /// Swap primary and alternate screen buffer. pub fn swap_alt(&mut self) { + self.cancel_redraw_anchor(); if !self.mode.contains(TermMode::ALT_SCREEN) { // Set alt screen cursor to the current primary screen cursor. self.inactive_grid.cursor = self.grid.cursor.clone(); @@ -1902,73 +1909,7 @@ impl Handler for Term { #[inline] fn clear_screen(&mut self, mode: ansi::ClearMode) { - trace!("Clearing screen: {mode:?}"); - let bg = self.grid.cursor.template.bg; - - let screen_lines = self.screen_lines(); - - match mode { - ansi::ClearMode::Above => { - let cursor = self.grid.cursor.point; - - // If clearing more than one line. - if cursor.line > 1 { - // Fully clear all lines before the current line. - self.grid.reset_region(..cursor.line); - } - - // Clear up to the current column in the current line. - let end = cmp::min(cursor.column + 1, Column(self.columns())); - for cell in &mut self.grid[cursor.line][..end] { - *cell = bg.into(); - } - - let range = Line(0)..=cursor.line; - self.selection = self.selection.take().filter(|s| !s.intersects_range(range)); - }, - ansi::ClearMode::Below => { - let cursor = self.grid.cursor.point; - for cell in &mut self.grid[cursor.line][cursor.column..] { - *cell = bg.into(); - } - - if (cursor.line.0 as usize) < screen_lines - 1 { - self.grid.reset_region((cursor.line + 1)..); - } - - let range = cursor.line..Line(screen_lines as i32); - self.selection = self.selection.take().filter(|s| !s.intersects_range(range)); - }, - ansi::ClearMode::All => { - if self.mode.contains(TermMode::ALT_SCREEN) { - self.grid.reset_region(..); - } else { - let old_offset = self.grid.display_offset(); - - self.grid.clear_viewport(); - - // Compute number of lines scrolled by clearing the viewport. - let lines = self.grid.display_offset().saturating_sub(old_offset); - - self.vi_mode_cursor.point.line = - (self.vi_mode_cursor.point.line - lines).grid_clamp(self, Boundary::Grid); - } - - self.selection = None; - }, - ansi::ClearMode::Saved if self.history_size() > 0 => { - self.grid.clear_history(); - - self.vi_mode_cursor.point.line = - self.vi_mode_cursor.point.line.grid_clamp(self, Boundary::Cursor); - - self.selection = self.selection.take().filter(|s| !s.intersects_range(..Line(0))); - }, - // We have no history to clear. - ansi::ClearMode::Saved => (), - } - - self.mark_fully_damaged(); + self.clear_screen_contents(mode); } #[inline] @@ -1987,6 +1928,7 @@ impl Handler for Term { /// Reset all important fields in the term struct. #[inline] fn reset_state(&mut self) { + self.cancel_redraw_anchor(); if self.mode.contains(TermMode::ALT_SCREEN) { mem::swap(&mut self.grid, &mut self.inactive_grid); } @@ -2156,7 +2098,7 @@ impl Handler for Term { self.cursor_blinking_override = Some(true); self.event_proxy.send_event(Event::CursorBlinkingChange); }, - NamedPrivateMode::SyncUpdate => (), + NamedPrivateMode::SyncUpdate => self.begin_redraw_anchor(), } } @@ -2212,7 +2154,7 @@ impl Handler for Term { self.cursor_blinking_override = Some(false); self.event_proxy.send_event(Event::CursorBlinkingChange); }, - NamedPrivateMode::SyncUpdate => (), + NamedPrivateMode::SyncUpdate => self.finish_redraw_anchor(), } } diff --git a/nebula_terminal/src/term/redraw_anchor.rs b/nebula_terminal/src/term/redraw_anchor.rs new file mode 100644 index 0000000..11546cb --- /dev/null +++ b/nebula_terminal/src/term/redraw_anchor.rs @@ -0,0 +1,144 @@ +//! 同步重绘后的保守阅读定位;只查找新网格,不恢复被应用清掉的内容。 +use super::{Cell, EventListener, Flags, Term, TermMode}; +use crate::grid::{Dimensions, Grid, Scroll}; +use crate::index::{Column, Line}; + +const ANCHOR_ROWS: usize = 3; +const MAX_COLUMNS: usize = 1024; +const MAX_SCAN_CELLS: usize = 4 * 1024 * 1024; + +#[derive(Default)] +pub(super) struct RedrawAnchor { + enabled: bool, + active: bool, + rows: Option>, + cleared_viewport: bool, + cleared_history: bool, +} + +impl Term { + /// 由宿主根据已确认的应用生命周期选择;默认不改变终端滚动语义。 + pub fn set_redraw_anchor_enabled(&mut self, enabled: bool) { + if self.redraw_anchor.enabled != enabled { + self.cancel_redraw_anchor(); + self.redraw_anchor.enabled = enabled; + } + } + + /// 用户操作或不完整同步更新优先,迟到的输出不得恢复旧阅读位置。 + pub fn cancel_redraw_anchor(&mut self) { + self.redraw_anchor.rows = None; + } + + pub(super) fn begin_redraw_anchor(&mut self) { + let previous_active = self.redraw_anchor.active; + self.redraw_anchor = RedrawAnchor { + enabled: self.redraw_anchor.enabled, + active: true, + ..Default::default() + }; + if previous_active + || !self.redraw_anchor.enabled + || self.grid.display_offset() == 0 + || self.mode.intersects(TermMode::ALT_SCREEN | TermMode::VI) + || self.selection.is_some() + || self.columns() > MAX_COLUMNS + || self.screen_lines() < ANCHOR_ROWS + { + return; + } + let start = -(self.grid.display_offset() as i32); + let rows: Option> = (0..ANCHOR_ROWS) + .map(|offset| row_key(&self.grid, Line(start + offset as i32))) + .collect(); + self.redraw_anchor.rows = rows.filter(|rows| rows.iter().any(|row| !row.is_empty())); + } + + pub(super) fn observe_redraw_clear(&mut self, mode: &super::ansi::ClearMode) { + if !self.redraw_anchor.active { + return; + } + match mode { + super::ansi::ClearMode::All => self.redraw_anchor.cleared_viewport = true, + super::ansi::ClearMode::Saved => self.redraw_anchor.cleared_history = true, + _ => (), + } + } +} + +impl Term { + pub(super) fn finish_redraw_anchor(&mut self) { + let enabled = self.redraw_anchor.enabled; + let state = std::mem::replace( + &mut self.redraw_anchor, + RedrawAnchor { enabled, ..Default::default() }, + ); + let Some(rows) = state.rows else { return }; + if !state.cleared_viewport + || !state.cleared_history + || self.mode.intersects(TermMode::ALT_SCREEN | TermMode::VI) + || self.selection.is_some() + || self.grid.total_lines().saturating_mul(self.columns()) > MAX_SCAN_CELLS + { + return; + } + let start = -(self.history_size() as i32); + let end = self.screen_lines() as i32 - ANCHOR_ROWS as i32; + let mut matched = None; + for line in start..=end { + if rows.iter().enumerate().all(|(offset, expected)| { + row_key(&self.grid, Line(line + offset as i32)).as_ref() == Some(expected) + }) { + if matched.is_some() { + return; + } + matched = Some(line); + } + } + if let Some(line) = matched.filter(|line| *line <= 0) { + self.scroll_display(Scroll::Bottom); + self.scroll_display(Scroll::Delta(-line)); + } + } +} + +fn row_key(grid: &Grid, line: Line) -> Option { + let mut text = String::new(); + let mut space = false; + for column in 0..grid.columns() { + let cell = &grid[line][Column(column)]; + // 换行重排没有可靠的行身份,不以不完整片段猜测原位置。 + if cell.flags.contains(Flags::WRAPLINE) { + return None; + } + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + if cell.c == ' ' && cell.zerowidth().is_none() { + space = !text.is_empty(); + continue; + } + if space { + text.push(' '); + space = false; + } + text.push(cell.c); + if let Some(chars) = cell.zerowidth() { + text.extend(chars); + } + } + // Pi 表格的纯边框随列宽伸长;只归一纯边框,不折叠正文中的重复字符。 + if text.contains('─') + && text + .chars() + .all(|c| matches!(c, '─' | '┌' | '┬' | '┐' | '├' | '┼' | '┤' | '└' | '┴' | '┘')) + { + let mut previous = None; + text.retain(|c| { + let keep = c != '─' || previous != Some(c); + previous = Some(c); + keep + }); + } + Some(text) +} diff --git a/nebula_terminal/tests/pi_redraw_anchor.rs b/nebula_terminal/tests/pi_redraw_anchor.rs new file mode 100644 index 0000000..2744698 --- /dev/null +++ b/nebula_terminal/tests/pi_redraw_anchor.rs @@ -0,0 +1,273 @@ +use nebula_terminal::Term; +use nebula_terminal::event::VoidListener; +use nebula_terminal::event_loop::StreamProcessor; +use nebula_terminal::grid::{Dimensions, Scroll}; +use nebula_terminal::index::{Column, Line}; +use nebula_terminal::term::{Config, test::TermSize}; + +type Terminal = Term; + +fn feed(stream: &mut StreamProcessor, term: &mut Terminal, bytes: &[u8]) { + stream.feed(term, &VoidListener, bytes); +} + +fn numbered(count: usize) -> String { + (0..count).map(|n| format!("ROW_{n:03}")).collect::>().join("\r\n") +} + +fn frame(text: &str) -> String { + format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{text}\x1b[?2026l") +} + +fn row(term: &Terminal, line: i32) -> String { + (0..term.columns()) + .map(|col| term.grid()[Line(line)][Column(col)].c) + .collect::() + .trim_end() + .to_owned() +} + +fn top(term: &Terminal) -> String { + row(term, -(term.grid().display_offset() as i32)) +} + +fn initial(enabled: bool) -> (Terminal, StreamProcessor) { + let mut term = Term::new(Config::default(), &TermSize::new(60, 5), VoidListener); + let mut stream = StreamProcessor::default(); + term.set_redraw_anchor_enabled(enabled); + feed(&mut stream, &mut term, format!("SECRET_OLD\r\n{}", numbered(10)).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-3)); + assert_eq!(top(&term), "ROW_002"); + (term, stream) +} + +#[test] +fn complete_sync_redraw_reanchors_new_content_without_resurrecting_history() { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes()); + assert_eq!(top(&term), "ROW_002"); + assert_eq!(term.history_size(), 7); + let rows: Vec<_> = (-7..5).map(|line| row(&term, line)).collect(); + assert_eq!(rows, numbered(12).split("\r\n").collect::>()); +} + +#[test] +fn disabled_policy_preserves_standard_clear_behavior() { + let (mut term, mut stream) = initial(false); + feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes()); + assert_eq!(term.grid().display_offset(), 0); + assert_eq!(top(&term), "ROW_007"); +} + +#[test] +fn byte_boundaries_do_not_change_reanchoring() { + let bytes = frame(&numbered(12)).into_bytes(); + for split in 0..=bytes.len() { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, &bytes[..split]); + feed(&mut stream, &mut term, &bytes[split..]); + assert_eq!(top(&term), "ROW_002", "split={split}"); + } +} + +#[test] +fn plain_clear_and_missing_or_ambiguous_anchor_do_not_reanchor() { + for bytes in [ + "\x1b[3J".to_owned(), + frame("new\r\ncontent"), + frame(&format!("{}\r\n{}", numbered(12), numbered(12))), + format!("\x1b[2J\x1b[H\x1b[3J{}", numbered(12)), + ] { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, bytes.as_bytes()); + assert_eq!(term.grid().display_offset(), 0); + } +} + +#[test] +fn user_scroll_or_disable_during_sync_cancels_pending_anchor() { + for disable in [false, true] { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, b"\x1b[?2026h"); + if disable { + term.set_redraw_anchor_enabled(false); + } else { + term.scroll_display(Scroll::Bottom); + } + feed( + &mut stream, + &mut term, + format!("\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l", numbered(12)).as_bytes(), + ); + assert_eq!(term.grid().display_offset(), 0); + } +} + +#[test] +fn forced_sync_stop_does_not_restore_a_partial_redraw() { + let (mut term, mut stream) = initial(true); + feed( + &mut stream, + &mut term, + format!("\x1b[?2026h\x1b[2J\x1b[H\x1b[3J{}", numbered(12)).as_bytes(), + ); + stream.stop_sync(&mut term); + assert_eq!(term.grid().display_offset(), 0); + feed(&mut stream, &mut term, b"\x1b[?2026l"); + assert_eq!(term.grid().display_offset(), 0); +} + +#[test] +fn cancellation_resize_reset_and_alt_screen_do_not_restore_old_anchor() { + for operation in 0..4 { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, b"\x1b[?2026h"); + match operation { + 0 => term.cancel_redraw_anchor(), + 1 => term.resize(TermSize::new(61, 5)), + 2 => term.swap_alt(), + _ => (), + } + let reset = if operation == 3 { "\x1bc" } else { "" }; + feed( + &mut stream, + &mut term, + format!("{reset}\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l", numbered(12)).as_bytes(), + ); + assert_eq!(term.grid().display_offset(), 0, "operation={operation}"); + } +} + +#[test] +fn oversized_sync_update_cancels_even_for_single_large_input() { + let (mut term, mut stream) = initial(true); + let bytes = format!( + "\x1b[?2026h{}\x1b[2J\x1b[H\x1b[3J{}\x1b[?2026l", + "\x00".repeat(2 * 1024 * 1024), + numbered(12) + ); + feed(&mut stream, &mut term, bytes.as_bytes()); + assert_eq!(term.grid().display_offset(), 0); +} + +#[test] +fn bottom_follow_and_append_remain_unchanged() { + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, b"\r\nAPPEND"); + assert_eq!(top(&term), "ROW_002"); + term.scroll_display(Scroll::Bottom); + feed(&mut stream, &mut term, frame(&numbered(12)).as_bytes()); + assert_eq!(term.grid().display_offset(), 0); +} + +#[test] +fn paragraph_spacing_preserves_anchor_including_blank_top_row() { + for blank_top in [false, true] { + let (mut term, mut stream) = initial(true); + let paragraphs = + (0..12).map(|n| format!("PARAGRAPH_{n:03}")).collect::>().join("\r\n\r\n"); + feed(&mut stream, &mut term, frame(¶graphs).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(if blank_top { -5 } else { -4 })); + let before: Vec<_> = + (0..3).map(|n| row(&term, -(term.grid().display_offset() as i32) + n)).collect(); + feed( + &mut stream, + &mut term, + frame(&format!("{paragraphs}\r\n\r\nNEW_PARAGRAPH")).as_bytes(), + ); + let after: Vec<_> = + (0..3).map(|n| row(&term, -(term.grid().display_offset() as i32) + n)).collect(); + assert_eq!(after, before, "blank_top={blank_top}"); + } +} + +#[test] +fn table_rules_widen_without_losing_the_reading_row() { + let table = |padding: usize| { + (0..12) + .map(|n| { + format!( + "│ ROW_{n:03}{}│ value {n} │\r\n├{}┼──────────┤", + " ".repeat(padding), + "─".repeat(9 + padding) + ) + }) + .collect::>() + .join("\r\n") + }; + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, frame(&table(1)).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-4)); + assert_eq!(top(&term), "│ ROW_002 │ value 2 │"); + feed(&mut stream, &mut term, frame(&table(6)).as_bytes()); + assert_eq!(top(&term), "│ ROW_002 │ value 2 │"); +} + +#[test] +fn partial_clear_does_not_trigger_compatibility_relocation() { + for clear in ["\x1b[2J\x1b[H", "\x1b[3J"] { + let (mut enabled, mut stream) = initial(true); + let (mut standard, mut control) = initial(false); + let bytes = format!("\x1b[?2026h{clear}{}\x1b[?2026l", numbered(12)); + feed(&mut stream, &mut enabled, bytes.as_bytes()); + feed(&mut control, &mut standard, bytes.as_bytes()); + assert_eq!(top(&enabled), top(&standard)); + assert_eq!(enabled.grid().display_offset(), standard.grid().display_offset()); + } +} + +#[test] +fn repeated_sync_frames_recapture_the_current_reading_position() { + let (mut term, mut stream) = initial(true); + for count in 12..20 { + feed(&mut stream, &mut term, frame(&numbered(count)).as_bytes()); + assert_eq!(top(&term), "ROW_002"); + } +} + +#[test] +fn wide_and_combining_characters_survive_reanchoring() { + let content = |count| { + (0..count).map(|n| format!("中文_{n:03} e\u{301}")).collect::>().join("\r\n") + }; + let (mut term, mut stream) = initial(true); + feed(&mut stream, &mut term, frame(&content(12)).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-2)); + let before = top(&term); + feed(&mut stream, &mut term, frame(&content(15)).as_bytes()); + assert_eq!(top(&term), before); + assert_eq!(term.grid().display_offset(), 8); +} + +#[test] +fn ambiguous_normalized_padding_does_not_choose_a_matching_copy() { + let (mut term, mut stream) = initial(true); + let first = (0..12).map(|n| format!("ROW_{n:03} value")).collect::>().join("\r\n"); + feed(&mut stream, &mut term, frame(&first).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-2)); + let second = first.replace(" value", " value"); + feed(&mut stream, &mut term, frame(&format!("{first}\r\n{second}")).as_bytes()); + assert_eq!(term.grid().display_offset(), 0); +} + +#[test] +fn table_padding_changes_preserve_three_row_anchor() { + let (mut term, mut stream) = initial(true); + let table = |padding: usize| { + (0..12) + .map(|n| format!("│ ROW_{n:03}{}│ value {n} │", " ".repeat(padding))) + .collect::>() + .join("\r\n") + }; + feed(&mut stream, &mut term, frame(&table(1)).as_bytes()); + term.scroll_display(Scroll::Top); + term.scroll_display(Scroll::Delta(-2)); + assert_eq!(top(&term), "│ ROW_002 │ value 2 │"); + feed(&mut stream, &mut term, frame(&table(6)).as_bytes()); + assert_eq!(top(&term), "│ ROW_002 │ value 2 │"); +}