//! Editing the active screen: newline and the scrolling region, line and //! column insert/delete, the erases, cursor save/restore, and the alt-screen //! swap. //! //! Every entry point here is called from the CSI or ESC dispatch in //! [`crate::perform`] and from nowhere else. use crate::{Cell, Grid}; impl Grid { pub(crate) fn newline(&mut self) { if self.cursor.row < self.scroll_bottom { self.cursor.row += 1; } else { self.scroll_up_in_region(1); } self.cursor.wrap_next = false; // Cursor row moved, or the ring rotated under it — either way the // cached row start is stale. place_char re-derives on next use. self.invalidate_cur_row(); } pub(crate) fn scroll_up_in_region(&mut self, n: u16) { let region_size = self.scroll_bottom - self.scroll_top + 1; let n = n.min(region_size); if n == 0 { return; } if self.is_partial_region() { // Partial region: ring-rotate within the region. active_origin is // guaranteed 0 in partial mode (unrolled on DECSTBM entry). let rs = region_size as u32; let old_region_origin = self.region_origin as u32; self.region_origin = ((old_region_origin + n as u32) % rs) as u16; for k in 0..n { let phys_in_region = (old_region_origin + k as u32) % rs; self.blank_physical_row(self.scroll_top + phys_in_region as u16); } // Partial-region scrolls don't propagate to the renderer's cache // rotation; mark exposed rows dirty for rebuild. for r in (self.scroll_bottom + 1 - n)..=self.scroll_bottom { self.mark_row_dirty(r); } } else { // Fullscreen fast path: O(1) origin shift + blank exposed rows. let old_origin = self.active_origin(); self.advance_origin(n as i32); for k in 0..n { let phys = (old_origin as u32 + k as u32) % self.rows as u32; // Before blanking, not after: this row is leaving the screen // and the copy into history is the only thing that keeps it. self.push_history(phys as u16); self.blank_physical_row(phys as u16); } self.pending_scroll = self.pending_scroll.saturating_add(n as i16); for r in (self.rows - n)..self.rows { self.row_dirty[r as usize] = true; } } } pub(crate) fn scroll_down_in_region(&mut self, n: u16) { let region_size = self.scroll_bottom - self.scroll_top + 1; let n = n.min(region_size); if n == 0 { return; } if self.is_partial_region() { let rs = region_size as u32; let new_region_origin = ((self.region_origin as u32 + rs - n as u32) % rs) as u16; self.region_origin = new_region_origin; for k in 0..n { let phys_in_region = (new_region_origin as u32 + k as u32) % rs; self.blank_physical_row(self.scroll_top + phys_in_region as u16); } for r in self.scroll_top..(self.scroll_top + n) { self.mark_row_dirty(r); } } else { self.advance_origin(-(n as i32)); for k in 0..n { self.blank_physical_row( (self.active_origin() as u32 + k as u32) as u16 % self.rows, ); } self.pending_scroll = self.pending_scroll.saturating_sub(n as i16); for r in 0..n { self.row_dirty[r as usize] = true; } } } /// DECSC. Per-screen, so the alt screen's save cannot reach the main /// screen's slot. pub(crate) fn save_cursor(&mut self) { self.dec_saved[self.on_alt as usize] = self.cursor; } /// DECRC. A restore with no matching save puts the cursor home, which is /// what the default slot holds. pub(crate) fn restore_cursor(&mut self) { let mut c = self.dec_saved[self.on_alt as usize]; // The screen may have shrunk since the save. A cursor off the end of // it is not a position anything can draw at. c.row = c.row.min(self.rows - 1); c.col = c.col.min(self.cols - 1); self.cursor = c; } /// Whether logical row `r` of the active screen runs onto the next. /// /// Screen-relative, unlike the public [`row_wrapped`](Self::row_wrapped), /// which takes a viewport row and may answer out of history. The row /// movers below deal in screen rows, and reading the viewport's numbering /// here would move the wrong flags whenever the user had scrolled back. fn screen_row_wrapped(&self, r: u16) -> bool { let phys = self.phys_row(r) as usize; let flags = if self.on_alt { &self.alt_wrapped } else { &self.main_wrapped }; flags.get(phys).copied().unwrap_or(false) } /// Copy one whole screen row onto another, contents and wrap flag both. /// /// Goes through `row_start` per row rather than moving a span: logical /// rows are a ring, so two rows adjacent on screen need not be adjacent in /// memory, and a bulk move would shuffle the ring instead of the screen. fn copy_row(&mut self, src: u16, dst: u16) { if src == dst { return; } let wrapped = self.screen_row_wrapped(src); let (s, d) = (self.row_start(src), self.row_start(dst)); let cols = self.cols as usize; self.active_cells_mut().copy_within(s..s + cols, d); self.set_row_wrapped(dst, wrapped); } fn blank_screen_row(&mut self, r: u16) { self.erase_line_range(r, 0, self.cols); self.set_row_wrapped(r, false); } /// Blank any half of a wide pair whose other half is gone. /// /// The column movers shift a run of cells sideways, and a shift can cut a /// pair in two: the lead of a wide character can be pushed off the right /// edge, or a spacer can be pulled away from its lead. Either half left /// alone is a cell lying about what it holds — the same reasoning /// `erase_line_range` applies at its ends, applied to the whole row /// because a shift can break a pair anywhere along it. fn heal_wide_pairs(&mut self, row: u16) { let start = self.row_start(row); let cols = self.cols as usize; let cells = &mut self.active_cells_mut()[start..start + cols]; for i in 0..cols { let orphan_lead = cells[i].is_wide() && !cells.get(i + 1).is_some_and(Cell::is_spacer); let orphan_spacer = cells[i].is_spacer() && !(i > 0 && cells[i - 1].is_wide()); if orphan_lead || orphan_spacer { cells[i] = Cell::default(); } } } /// IL. Open `n` blank lines at the cursor, pushing what follows down and /// off the bottom of the scrolling region. /// /// Ignored when the cursor sits outside the region: the region is the part /// of the screen the program has claimed, and an insert from outside it /// would move rows it does not own. pub(crate) fn insert_lines(&mut self, n: u16) { if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom { return; } let top = self.cursor.row; let n = n.min(self.scroll_bottom - top + 1); if n == 0 { return; } // Downward, so a row is read before the copy that overwrites it. for r in (top + n..=self.scroll_bottom).rev() { self.copy_row(r - n, r); } for r in top..top + n { self.blank_screen_row(r); } // The row above the opening no longer runs into what is now a blank. if top > 0 { self.set_row_wrapped(top - 1, false); } for r in top..=self.scroll_bottom { self.mark_row_dirty(r); } // DEC puts the cursor at the left margin, and enough programs rely on // it that leaving the column alone is the surprising choice. self.cursor.col = 0; self.cursor.wrap_next = false; } /// DL. Remove `n` lines at the cursor, pulling the rest of the scrolling /// region up and blanking what it vacates at the bottom. pub(crate) fn delete_lines(&mut self, n: u16) { if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom { return; } let top = self.cursor.row; let n = n.min(self.scroll_bottom - top + 1); if n == 0 { return; } // Written as one exclusive boundary rather than two inclusive ranges, // because `scroll_bottom - n` underflows when the delete covers the // whole region from its top row: `n` is clamped to the region size, so // n == scroll_bottom + 1 is reachable with top == 0. A panic in debug, // and in release a range running to about 65,000 that hands `copy_row` // rows off the end of the ring. `scroll_bottom + 1 - n` cannot // underflow, since n is at most scroll_bottom - top + 1. Found by the // soak oracle, 2026-08-29. let keep_end = self.scroll_bottom + 1 - n; for r in top..keep_end { self.copy_row(r + n, r); } for r in keep_end..=self.scroll_bottom { self.blank_screen_row(r); } if top > 0 { self.set_row_wrapped(top - 1, false); } for r in top..=self.scroll_bottom { self.mark_row_dirty(r); } self.cursor.col = 0; self.cursor.wrap_next = false; } /// ICH. Open `n` blank cells at the cursor, pushing the rest of the line /// right and off the edge. The cursor does not move. pub(crate) fn insert_chars(&mut self, n: u16) { let row = self.cursor.row; let col = self.cursor.col; let cols = self.cols; let n = n.min(cols - col); if n == 0 { return; } let start = self.row_start(row); let (c, k, w) = (col as usize, n as usize, cols as usize); let cells = &mut self.active_cells_mut()[start..start + w]; cells.copy_within(c..w - k, c + k); for cell in &mut cells[c..c + k] { *cell = Cell::default(); } self.heal_wide_pairs(row); // Whatever ran off the right edge is gone, so the line stops here. self.set_row_wrapped(row, false); self.mark_row_dirty(row); } /// DCH. Remove `n` cells at the cursor, pulling the rest of the line left /// and blanking the tail it vacates. pub(crate) fn delete_chars(&mut self, n: u16) { let row = self.cursor.row; let col = self.cursor.col; let cols = self.cols; let n = n.min(cols - col); if n == 0 { return; } let start = self.row_start(row); let (c, k, w) = (col as usize, n as usize, cols as usize); let cells = &mut self.active_cells_mut()[start..start + w]; cells.copy_within(c + k..w, c); for cell in &mut cells[w - k..] { *cell = Cell::default(); } self.heal_wide_pairs(row); self.set_row_wrapped(row, false); self.mark_row_dirty(row); } /// ECH. Blank `n` cells from the cursor without moving anything. The /// difference from DCH is the whole point: the tail of the line stays /// where it is. pub(crate) fn erase_chars(&mut self, n: u16) { let row = self.cursor.row; let col = self.cursor.col; let end = col.saturating_add(n).min(self.cols); self.erase_line_range(row, col, end); self.mark_row_dirty(row); } pub(crate) fn erase_line(&mut self, mode: u16) { let row = self.cursor.row; let col = self.cursor.col; let (start_col, end_col) = match mode { 1 => (0, col + 1), // start to cursor 2 => (0, self.cols), // whole line _ => (col, self.cols), // 0: cursor to end }; self.erase_line_range(row, start_col, end_col); self.mark_row_dirty(row); } pub(crate) fn erase_display(&mut self, mode: u16) { // Iterate LOGICAL rows — physical layout is a ring, so contiguous // "erase from cursor to end" isn't contiguous in memory. let cursor_row = self.cursor.row; let cursor_col = self.cursor.col; match mode { 1 => { // Start of screen to cursor (inclusive). for r in 0..cursor_row { self.blank_logical_row(r); } self.erase_line_range(cursor_row, 0, cursor_col + 1); } 2 | 3 => { for r in 0..self.rows { self.blank_logical_row(r); } } _ => { // Cursor to end of screen (inclusive). self.erase_line_range(cursor_row, cursor_col, self.cols); for r in (cursor_row + 1)..self.rows { self.blank_logical_row(r); } } } self.mark_all_rows_dirty(); } fn erase_line_range(&mut self, row: u16, start_col: u16, end_col: u16) { let row_start = self.row_start(row); let end_col = end_col.min(self.cols); let start_col = start_col.min(end_col); let cols = self.cols; let cells = self.active_cells_mut(); for cell in &mut cells[row_start + start_col as usize..row_start + end_col as usize] { *cell = Cell::default(); } // An erase can start or stop in the middle of a wide character. The // half outside the range goes too: half a character is not a narrower // character, it is a cell lying about what it holds. if start_col > 0 && cells[row_start + start_col as usize - 1].is_wide() { cells[row_start + start_col as usize - 1] = Cell::default(); } if end_col < cols && cells[row_start + end_col as usize].is_spacer() { cells[row_start + end_col as usize] = Cell::default(); } // Erasing through the right edge destroys whatever ran off it, so the // row no longer continues onto the next. if end_col == self.cols { self.set_row_wrapped(row, false); } } pub(crate) fn swap_alt(&mut self, to_alt: bool) { if self.on_alt == to_alt { return; } // region_origin belongs to the currently-active screen; unroll before // switching so the other screen starts with region_origin = 0. self.unroll_region(); // An application taking the alt screen is taking the whole window, so // a viewport parked in main's history has nothing left to show. Going // the other way, the user is put back where the shell is, not where // they were reading before vim opened. self.view_offset = 0; if to_alt { self.swap_saved[0] = self.cursor; self.on_alt = true; for cell in &mut self.alt { *cell = Cell::default(); } self.alt_wrapped.fill(false); self.cursor = self.swap_saved[1]; } else { self.swap_saved[1] = self.cursor; self.on_alt = false; self.cursor = self.swap_saved[0]; } self.pending_screen_swap = true; self.mark_all_rows_dirty(); } pub(crate) fn set_cursor(&mut self, row: u16, col: u16) { // Terminal params are 1-indexed; convert. let row = row.saturating_sub(1).min(self.rows - 1); let col = col.saturating_sub(1).min(self.cols - 1); self.cursor.row = row; self.cursor.col = col; self.cursor.wrap_next = false; } pub(crate) fn move_by(&mut self, drow: i32, dcol: i32) { let r = (self.cursor.row as i32 + drow).clamp(0, self.rows as i32 - 1) as u16; let c = (self.cursor.col as i32 + dcol).clamp(0, self.cols as i32 - 1) as u16; self.cursor.row = r; self.cursor.col = c; self.cursor.wrap_next = false; } } #[cfg(test)] mod tests { use crate::testutil::{feed, reply_to, row_str}; use crate::*; // ---- erase --------------------------------------------------------- #[test] fn el0_erases_cursor_to_end() { let mut g = Grid::new(10, 2); feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[K"); assert_eq!(row_str(&g, 0), "ABC"); } #[test] fn el1_erases_start_to_cursor() { let mut g = Grid::new(10, 2); feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[1K"); // Cells 0..=3 cleared, 4..=9 kept. assert_eq!(row_str(&g, 0), " EFGHIJ"); } #[test] fn el2_erases_whole_line() { let mut g = Grid::new(10, 2); feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[2K"); assert_eq!(row_str(&g, 0), ""); } #[test] fn ed2_erases_whole_screen() { let mut g = Grid::new(6, 3); feed(&mut g, b"aaaaaa\r\nbbbbbb\r\ncccccc\x1b[2J"); for r in 0..3 { assert_eq!(row_str(&g, r), ""); } } // ---- scroll region + newline -------------------------------------- #[test] fn newline_at_bottom_scrolls_up() { let mut g = Grid::new(6, 3); feed(&mut g, b"aaa\r\nbbb\r\nccc\r\nddd"); // Last write scrolled: row 0 was aaa, is now bbb; row 1 ccc; row 2 ddd. assert_eq!(row_str(&g, 0), "bbb"); assert_eq!(row_str(&g, 1), "ccc"); assert_eq!(row_str(&g, 2), "ddd"); } #[test] fn reverse_index_at_top_scrolls_down() { let mut g = Grid::new(6, 3); feed(&mut g, b"aaa\r\nbbb\r\nccc\x1b[H\x1bM"); // RI from row 0 pushes row 0 down; row 0 blanked. assert_eq!(row_str(&g, 0), ""); assert_eq!(row_str(&g, 1), "aaa"); assert_eq!(row_str(&g, 2), "bbb"); } #[test] fn insert_lines_pushes_the_rest_down_and_off_the_bottom() { let mut g = Grid::new(6, 4); feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour"); feed(&mut g, b"\x1b[2;1H\x1b[L"); // row 1, insert one line assert_eq!(row_str(&g, 0), "one"); assert_eq!(row_str(&g, 1), ""); assert_eq!(row_str(&g, 2), "two"); assert_eq!(row_str(&g, 3), "three"); // "four" fell off the bottom rather than scrolling into history. assert_eq!(g.history_len(), 0); } #[test] fn insert_lines_moves_the_right_rows_after_a_scroll() { // The ring's origin is non-zero here, so a row mover that walked // memory instead of the logical rows would shuffle the wrong ones. let mut g = Grid::new(6, 3); feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive"); assert_eq!(row_str(&g, 0), "three"); feed(&mut g, b"\x1b[1;1H\x1b[L"); assert_eq!(row_str(&g, 0), ""); assert_eq!(row_str(&g, 1), "three"); assert_eq!(row_str(&g, 2), "four"); } #[test] fn delete_lines_pulls_the_rest_up_and_blanks_the_bottom() { let mut g = Grid::new(6, 4); feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour"); feed(&mut g, b"\x1b[2;1H\x1b[M"); assert_eq!(row_str(&g, 0), "one"); assert_eq!(row_str(&g, 1), "three"); assert_eq!(row_str(&g, 2), "four"); assert_eq!(row_str(&g, 3), ""); } #[test] fn line_edits_stay_inside_the_scrolling_region() { let mut g = Grid::new(6, 5); feed(&mut g, b"a\r\nb\r\nc\r\nd\r\ne"); feed(&mut g, b"\x1b[2;4r"); // region is rows 1..=3 feed(&mut g, b"\x1b[2;1H\x1b[M"); // delete at the region's top assert_eq!(row_str(&g, 0), "a", "a row above the region moved"); assert_eq!(row_str(&g, 1), "c"); assert_eq!(row_str(&g, 2), "d"); assert_eq!(row_str(&g, 3), "", "the region's bottom did not blank"); assert_eq!(row_str(&g, 4), "e", "a row below the region moved"); } #[test] fn a_line_edit_outside_the_region_does_nothing() { // The program claimed rows 1..=3; the cursor is on row 4. DEC ignores // this rather than clamping, and clamping would edit rows the program // said it was not touching. let mut g = Grid::new(6, 5); feed(&mut g, b"a\r\nb\r\nc\r\nd\r\ne"); feed(&mut g, b"\x1b[2;4r\x1b[5;1H\x1b[M"); assert_eq!(row_str(&g, 4), "e"); } #[test] fn a_line_edit_puts_the_cursor_at_the_left_margin() { let mut g = Grid::new(8, 3); feed(&mut g, b"\x1b[1;5H\x1b[L"); assert_eq!(reply_to(&mut g, b"\x1b[6n"), "\x1b[1;1R"); } #[test] fn insert_chars_opens_a_gap_and_drops_the_tail() { let mut g = Grid::new(6, 1); feed(&mut g, b"abcdef"); feed(&mut g, b"\x1b[1;3H\x1b[2@"); assert_eq!(row_str(&g, 0), "ab cd"); } #[test] fn delete_chars_closes_the_gap_and_blanks_the_tail() { let mut g = Grid::new(6, 1); feed(&mut g, b"abcdef"); feed(&mut g, b"\x1b[1;3H\x1b[2P"); assert_eq!(row_str(&g, 0), "abef"); } #[test] fn erase_chars_blanks_in_place_without_moving_the_tail() { // The whole difference from DCH: "ef" does not move left. let mut g = Grid::new(6, 1); feed(&mut g, b"abcdef"); feed(&mut g, b"\x1b[1;3H\x1b[2X"); assert_eq!(row_str(&g, 0), "ab ef"); } #[test] fn a_column_edit_past_the_end_of_the_row_stops_at_it() { let mut g = Grid::new(6, 1); feed(&mut g, b"abcdef"); feed(&mut g, b"\x1b[1;5H\x1b[99P"); assert_eq!(row_str(&g, 0), "abcd"); } #[test] fn a_delete_landing_on_a_wide_lead_takes_its_spacer_too() { // A shift moves a pair whole, so the break needs a cut inside it: this // deletes the lead and would otherwise pull the spacer up alone, a // cell claiming to be the second half of nothing. let mut g = Grid::new(6, 1); feed(&mut g, "ab\u{4e00}c".as_bytes()); // the wide char takes cols 2-3 feed(&mut g, b"\x1b[1;3H\x1b[P"); assert!( !g.row(0).iter().any(super::Cell::is_spacer), "a spacer outlived its character" ); assert_eq!(row_str(&g, 0), "ab c"); } #[test] fn an_insert_pushing_a_wide_lead_to_the_edge_drops_it() { // The other half of the same rule: the spacer goes off the right edge // and the lead cannot be two columns wide in one column. let mut g = Grid::new(6, 1); feed(&mut g, "ab\u{4e00}c".as_bytes()); feed(&mut g, b"\x1b[1;1H\x1b[3@"); let row: String = g.row(0).iter().map(super::Cell::c).collect(); assert!( !row.contains('\u{4e00}'), "half a wide character survived at the edge: {row:?}" ); } #[test] fn decsc_and_decrc_keep_a_slot_per_screen() { // The bug this pair of slots exists for: a save taken on the alt // screen used to land in the slot that leaving alt restored from, so // the shell's cursor moved to wherever the full-screen program's was. let mut g = Grid::new(20, 10); feed(&mut g, b"\x1b[5;5H\x1b7"); // main: park at 5,5 and save feed(&mut g, b"\x1b[?1049h"); // into alt feed(&mut g, b"\x1b[9;9H\x1b7"); // alt: save somewhere else feed(&mut g, b"\x1b[?1049l"); // back to main assert_eq!( reply_to(&mut g, b"\x1b[6n"), "\x1b[5;5R", "the swap lost it" ); feed(&mut g, b"\x1b[1;1H\x1b8"); // main's DECRC assert_eq!(reply_to(&mut g, b"\x1b[6n"), "\x1b[5;5R"); } #[test] fn the_csi_spelling_of_save_and_restore_is_the_same_pair() { let mut g = Grid::new(20, 10); feed(&mut g, b"\x1b[4;7H\x1b[s\x1b[1;1H\x1b[u"); assert_eq!(reply_to(&mut g, b"\x1b[6n"), "\x1b[4;7R"); } #[test] fn a_restore_onto_a_smaller_screen_lands_on_it() { let mut g = Grid::new(20, 10); feed(&mut g, b"\x1b[9;18H\x1b7"); g.resize(8, 4); feed(&mut g, b"\x1b8"); assert_eq!(reply_to(&mut g, b"\x1b[6n"), "\x1b[4;8R"); } }