diff --git a/crates/noa-core/src/attrs.rs b/crates/noa-core/src/attrs.rs index e8082c4b..8cc88468 100644 --- a/crates/noa-core/src/attrs.rs +++ b/crates/noa-core/src/attrs.rs @@ -20,6 +20,10 @@ bitflags! { const CURLY_UNDERLINE = 1 << 12; const DOTTED_UNDERLINE = 1 << 13; const DASHED_UNDERLINE = 1 << 14; + /// Filler in the last column of a soft-wrapped row where a wide glyph + /// did not fit and moved to the next row. Not content: reflow, copy + /// and search skip it. Anywhere else the flag is ignored. + const WIDE_PAD = 1 << 15; } } diff --git a/crates/noa-grid/src/cell.rs b/crates/noa-grid/src/cell.rs index 3ea4b4d9..009cc818 100644 --- a/crates/noa-grid/src/cell.rs +++ b/crates/noa-grid/src/cell.rs @@ -204,6 +204,17 @@ impl Row { self.cells[..self.occupied()].iter().all(|c| *c == blank) } + /// Whether the last cell is the filler a wide glyph left when it wrapped to + /// the next row. The flag counts only there: a flagged cell moved anywhere + /// else, or into a row that no longer soft-wraps, is an ordinary blank. + pub fn ends_with_wide_pad(&self) -> bool { + self.wrapped + && self + .cells + .last() + .is_some_and(|cell| cell.attrs.contains(CellAttrs::WIDE_PAD)) + } + /// Record that cells below `end` may now hold non-default content. /// Monotonic: never lowers the watermark. #[inline] diff --git a/crates/noa-grid/src/screen.rs b/crates/noa-grid/src/screen.rs index a2070fa0..d10b09e6 100644 --- a/crates/noa-grid/src/screen.rs +++ b/crates/noa-grid/src/screen.rs @@ -353,6 +353,41 @@ impl Screen { row.mark_occupied(x + 1); } + /// Blank the cell a wide glyph could not fit into before it wrapped. In + /// the last column it is also flagged as filler (see + /// [`Row::ends_with_wide_pad`]), so a later reflow does not mistake it for + /// a space; at a narrower right margin it stays an ordinary blank. + pub(super) fn mark_wide_pad(row: &mut Row, x: usize, blank: &Cell) { + if x >= row.cells.len() { + return; + } + Self::clear_wide_at(row, x, blank); + if x + 1 == row.cells.len() { + row.cells[x].attrs.insert(CellAttrs::WIDE_PAD); + } + } + + /// Seed-only (`Handler::seed_mark_wide_pad`): flag the cell under the + /// cursor as filler, leaving the cursor and its latch alone. + pub(crate) fn seed_mark_wide_pad(&mut self) { + let (x, y) = (self.cursor.x as usize, self.cursor.y as usize); + if let Some(row) = self.grid.get_mut(y) + && let Some(cell) = row.cells.get_mut(x) + { + cell.attrs.insert(CellAttrs::WIDE_PAD); + row.mark_occupied(x + 1); + row.dirty = true; + } + } + + /// Clear the filler flag from cells a shift or rectangle scroll moved: they + /// no longer sit where their row's soft wrap left them. + fn clear_moved_wide_pad(cells: &mut [Cell]) { + for cell in cells { + cell.attrs.remove(CellAttrs::WIDE_PAD); + } + } + fn sanitize_wide_row(row: &mut Row, blank: &Cell) { // Cells past the occupancy watermark are default (no layout flags), // so the invariant scan can stop there. Writes below only touch diff --git a/crates/noa-grid/src/screen/edit.rs b/crates/noa-grid/src/screen/edit.rs index 9b4f5ce9..c02f2904 100644 --- a/crates/noa-grid/src/screen/edit.rs +++ b/crates/noa-grid/src/screen/edit.rs @@ -476,6 +476,7 @@ impl Screen { for y in top..=bottom { let row = &mut self.grid[y]; row.mark_occupied(right + 1); + Self::clear_moved_wide_pad(&mut row.cells[left..=right]); Self::sanitize_wide_row(row, &blank); row.dirty = true; } @@ -496,6 +497,7 @@ impl Screen { for y in top..=bottom { let row = &mut self.grid[y]; row.mark_occupied(right + 1); + Self::clear_moved_wide_pad(&mut row.cells[left..=right]); Self::sanitize_wide_row(row, &blank); row.dirty = true; } @@ -883,6 +885,7 @@ impl Screen { for c in &mut row.cells[right + 1 - n..=right] { c.set_from(&blank); } + Self::clear_moved_wide_pad(&mut row.cells[x..=right]); // The left shift only moves content toward lower indices (the cells // it wraps to the end are overwritten by the fill), so the existing // watermark stays a valid bound — unless the fill is a styled (BCE) diff --git a/crates/noa-grid/src/screen/print.rs b/crates/noa-grid/src/screen/print.rs index 20d84c4f..726efac1 100644 --- a/crates/noa-grid/src/screen/print.rs +++ b/crates/noa-grid/src/screen/print.rs @@ -98,8 +98,12 @@ impl Screen { if width == 2 && self.cursor.x.saturating_add(1) > right { if autowrap { + let blank = self.blank(); + let x = self.cursor.x as usize; if let Some(row) = self.grid.get_mut(self.cursor.y as usize) { + Self::mark_wide_pad(row, x, &blank); row.wrapped = true; + row.dirty = true; } self.index(); self.cursor.x = left; @@ -396,8 +400,11 @@ impl Screen { } if self.cursor.x.saturating_add(1) > right { if autowrap { + let x = self.cursor.x as usize; if let Some(row) = self.grid.get_mut(self.cursor.y as usize) { + Self::mark_wide_pad(row, x, &blank); row.wrapped = true; + row.dirty = true; } self.index(); self.cursor.x = left; diff --git a/crates/noa-grid/src/screen/reflow.rs b/crates/noa-grid/src/screen/reflow.rs index ab9922f8..ccd25155 100644 --- a/crates/noa-grid/src/screen/reflow.rs +++ b/crates/noa-grid/src/screen/reflow.rs @@ -357,6 +357,13 @@ impl Screen { } } + // The filler a wide glyph left at the end of a soft-wrapped row is + // layout, not content: carrying it into the new width would leave + // a stray blank inside the text. + if len == row.cells.len() && row.ends_with_wide_pad() { + len -= 1; + } + row_starts.push(cells.len()); row_lens.push(len); cells.extend_from_slice(&row.cells[..len]); @@ -431,6 +438,9 @@ impl Screen { if x > 0 && x + render_width > cols_usize { if let Some(row) = rows.last_mut() { + if source_width == 2 && x < cols_usize { + Self::mark_wide_pad(row, x, blank); + } row.wrapped = true; } rows.push(Self::row_with_blank(cols, blank)); diff --git a/crates/noa-grid/src/screen/text.rs b/crates/noa-grid/src/screen/text.rs index 1bb8dc40..244ece4a 100644 --- a/crates/noa-grid/src/screen/text.rs +++ b/crates/noa-grid/src/screen/text.rs @@ -636,8 +636,9 @@ impl Screen { text: &mut String, ) { let before_len = text.len(); - for cell in &row.cells[start_x..=end_x] { - if cell.attrs.contains(CellAttrs::WIDE_SPACER) { + let pad_x = row.ends_with_wide_pad().then(|| row.cells.len() - 1); + for (x, cell) in row.cells[start_x..=end_x].iter().enumerate() { + if cell.attrs.contains(CellAttrs::WIDE_SPACER) || pad_x == Some(start_x + x) { continue; } cell.push_text_to(text); diff --git a/crates/noa-grid/src/search.rs b/crates/noa-grid/src/search.rs index 451103b9..8120c814 100644 --- a/crates/noa-grid/src/search.rs +++ b/crates/noa-grid/src/search.rs @@ -245,8 +245,9 @@ impl<'a> RowSearch<'a> { self.positions.clear(); } self.continuation = row.wrapped.then(|| storage_y.saturating_add(1)); + let pad_x = row.ends_with_wide_pad().then(|| row.cells.len() - 1); for (x, cell) in row.cells.iter().enumerate() { - if cell.attrs.contains(CellAttrs::WIDE_SPACER) { + if cell.attrs.contains(CellAttrs::WIDE_SPACER) || pad_x == Some(x) { continue; } cell.push_text_to(&mut self.text); @@ -372,8 +373,9 @@ mod tests { ) { let mut text = String::new(); let mut columns = Vec::new(); + let pad_x = row.ends_with_wide_pad().then(|| row.cells.len() - 1); for (x, cell) in row.cells.iter().enumerate() { - if !cell.attrs.contains(CellAttrs::WIDE_SPACER) { + if !cell.attrs.contains(CellAttrs::WIDE_SPACER) && pad_x != Some(x) { cell.push_text_to(&mut text); columns.extend(std::iter::repeat_n(x as u16, cell.text_chars().count())); } diff --git a/crates/noa-grid/src/snapshot.rs b/crates/noa-grid/src/snapshot.rs index 257ffa64..32da1da4 100644 --- a/crates/noa-grid/src/snapshot.rs +++ b/crates/noa-grid/src/snapshot.rs @@ -618,7 +618,7 @@ pub fn decode_within(bytes: &[u8], max_body: u64) -> Option /// /// A wide (CJK) glyph and its spacer are never separated: when a split would /// land between them, the lead moves to the next row and the vacated column is -/// left blank — the same choice the live reflow makes. +/// left as a blank `WIDE_PAD` filler — the same choice the live reflow makes. pub fn rewrap(rows: Vec, cols: u16) -> Vec { if cols == 0 { return Vec::new(); @@ -632,7 +632,12 @@ pub fn rewrap(rows: Vec, cols: u16) -> Vec { let mut logical: Vec = Vec::new(); for row in rows { let continues = row.wrapped; - let trimmed = trimmed_cells(&row).len(); + let mut trimmed = trimmed_cells(&row).len(); + // The filler a wide glyph left at the end of a soft-wrapped row is + // layout for the old width, not content. + if trimmed == row.cells.len() && row.ends_with_wide_pad() { + trimmed -= 1; + } logical.extend_from_slice(&row.cells[..trimmed]); if continues { continue; @@ -669,8 +674,12 @@ fn emit_logical_line(line: &[Cell], width: usize, out: &mut Vec) { end > start, "every iteration must consume at least one cell" ); + let backed_off = end < line.len() && end - start < width; let mut cells = line[start..end].to_vec(); cells.resize(width, Cell::default()); + if backed_off { + cells[end - start].attrs.insert(CellAttrs::WIDE_PAD); + } let wrapped = end < line.len(); out.push(Row::from_cells(cells, wrapped, false)); start = end; @@ -924,8 +933,8 @@ mod tests { assert_eq!(wrapped[0].cells[0].ch, 'x'); assert_eq!( wrapped[0].cells[1], - Cell::default(), - "the lead moved down, leaving the column blank" + styled(' ', Color::Default, CellAttrs::WIDE_PAD), + "the lead moved down, leaving the column as filler" ); assert_eq!(wrapped[1].cells[0].ch, 'あ'); assert!(wrapped[1].cells[1].attrs.contains(CellAttrs::WIDE_SPACER)); diff --git a/crates/noa-grid/src/terminal/handler.rs b/crates/noa-grid/src/terminal/handler.rs index e5c4df8e..c7447179 100644 --- a/crates/noa-grid/src/terminal/handler.rs +++ b/crates/noa-grid/src/terminal/handler.rs @@ -576,6 +576,10 @@ impl Handler for Terminal { }; } + fn seed_mark_wide_pad(&mut self) { + self.active_mut().seed_mark_wide_pad(); + } + fn seed_set_default_cursor_style(&mut self, ps: u16, hollow: bool) { use crate::cursor::CursorStyle; // Mirrors the `DECSCUSR` numbering `write_cursor_style` emits, plus diff --git a/crates/noa-grid/src/terminal/seed.rs b/crates/noa-grid/src/terminal/seed.rs index 00c57448..93a46757 100644 --- a/crates/noa-grid/src/terminal/seed.rs +++ b/crates/noa-grid/src/terminal/seed.rs @@ -3,7 +3,7 @@ use std::fmt::Write; use noa_core::{CellAttrs, Color, Rgb}; use noa_vt::{Charset, CharsetSlot}; -use crate::cell::Cell; +use crate::cell::{Cell, Row}; use crate::cursor::{Cursor, CursorStyle, SavedCursor}; use crate::screen::Screen; @@ -267,6 +267,11 @@ fn write_wrapped_rows(seed: &mut String, screen: &Screen) { if !screen.grid[y].wrapped { continue; } + if screen.grid[y].ends_with_wide_pad() { + write_wide_pad_wrap(seed, screen, y); + continue; + } + let (x, last) = pending_wrap_cell(screen, screen.cols - 1, y as u16); write_cup(seed, x, y as u16); write_pen(seed, last.fg, last.bg, last.underline_color, last.attrs); @@ -282,6 +287,27 @@ fn write_wrapped_rows(seed: &mut String, screen: &Screen) { write_private_mode(seed, 7, false); } +/// Recreate the filler a wide glyph left in the last column of row `y`, and +/// the soft wrap it came with, by printing a wide scalar that cannot fit +/// there: the replica lays down the filler (with the pen's background, which +/// is the filler's own) exactly as the source did. Grapheme clustering is off +/// for that print so the scalar never joins a cluster ending in a ZWJ before +/// it. The scalar lands on the next row, which is then repainted from the +/// source. +fn write_wide_pad_wrap(seed: &mut String, screen: &Screen, y: usize) { + let last_x = screen.cols - 1; + let pad = &screen.grid[y].cells[usize::from(last_x)]; + write_cup(seed, last_x, y as u16); + write_pen(seed, Color::Default, pad.bg, None, CellAttrs::empty()); + write_private_mode(seed, 2027, false); + seed.push('\u{3000}'); + write_private_mode(seed, 2027, true); + + write_cup(seed, 0, y as u16 + 1); + seed.push_str("\x1b[0m\x1b[2K"); + write_row_cells(seed, y + 1, &screen.grid[y + 1]); +} + fn write_kitty_keyboard_stack(seed: &mut String, stack: &[u8]) { for flags in stack { write!(seed, "\x1b[>{flags}u").expect("writing to String cannot fail"); @@ -290,12 +316,24 @@ fn write_kitty_keyboard_stack(seed: &mut String, stack: &[u8]) { fn write_visible_grid(seed: &mut String, screen: &Screen) { for (y, row) in screen.grid.iter().enumerate() { - for (x, cell) in row.cells.iter().enumerate() { - if cell.attrs.contains(CellAttrs::WIDE_SPACER) || cell == &Cell::default() { - continue; - } - write_cup(seed, x as u16, y as u16); - write_pen(seed, cell.fg, cell.bg, cell.underline_color, cell.attrs); + write_row_cells(seed, y, row); + } +} + +fn write_row_cells(seed: &mut String, y: usize, row: &Row) { + let pad_x = row.ends_with_wide_pad().then(|| row.cells.len() - 1); + for (x, cell) in row.cells.iter().enumerate() { + if cell.attrs.contains(CellAttrs::WIDE_SPACER) || cell == &Cell::default() { + continue; + } + write_cup(seed, x as u16, y as u16); + write_pen(seed, cell.fg, cell.bg, cell.underline_color, cell.attrs); + if pad_x == Some(x) { + // Erase rather than print the filler: a printed space could join a + // cluster ending in a ZWJ before it. `write_wide_pad_wrap` flags it + // afterwards unless it sits on the last row. + seed.push_str("\x1b[X"); + } else { cell.push_text_to(seed); } } @@ -372,6 +410,7 @@ fn write_cursor_origin_relative( write_cup(seed, print_x.saturating_sub(left), relative_y); write_pen(seed, cell.fg, cell.bg, cell.underline_color, cell.attrs); cell.push_text_to(seed); + write_wide_pad_after_latch(seed, screen, print_x, cursor.x, cursor.y); write_private_mode(seed, 7, autowrap); } else { write_cup(seed, relative_x, relative_y); @@ -407,6 +446,7 @@ fn write_cursor_state( write_cup(seed, print_x, y); write_pen(seed, cell.fg, cell.bg, cell.underline_color, cell.attrs); cell.push_text_to(seed); + write_wide_pad_after_latch(seed, screen, print_x, x, y); write_private_mode(seed, 7, autowrap); } else { write_cup(seed, x, y); @@ -414,6 +454,14 @@ fn write_cursor_state( write_pen(seed, fg, bg, underline_color, attrs); } +/// A latch reprint of the cell under the cursor at (`x`, `y`) turns a +/// wide-wrap filler there into a plain space: flag it again. +fn write_wide_pad_after_latch(seed: &mut String, screen: &Screen, print_x: u16, x: u16, y: u16) { + if print_x == x && screen.grid[usize::from(y)].ends_with_wide_pad() { + seed.push_str("\x1b[>$w"); + } +} + fn pending_wrap_cell(screen: &Screen, x: u16, y: u16) -> (u16, &Cell) { let row = &screen.grid[y.min(screen.rows.saturating_sub(1)) as usize]; let cell = &row.cells[x.min(screen.cols.saturating_sub(1)) as usize]; @@ -636,6 +684,153 @@ mod tests { assert_screen_state(source.active(), replica.active()); } + #[test] + fn synthetic_seed_recreates_the_wide_wrap_filler() { + let size = GridSize::new(4, 3); + let mut source = Terminal::new(size); + Stream::new().feed("abc界Z".as_bytes(), &mut source); + + let mut replica = Terminal::new(size); + Stream::new().feed(&source.synthetic_seed(), &mut replica); + + assert_screen_state(source.active(), replica.active()); + replica.select_all(); + assert_eq!( + replica.selected_text().as_deref().map(str::trim_end), + Some("abc界Z") + ); + + let wider = GridSize::new(8, 3); + source.resize(wider); + replica.resize(wider); + assert_screen_state(source.active(), replica.active()); + } + + fn assert_seed_round_trips(size: GridSize, bytes: &[u8]) -> Terminal { + let mut source = Terminal::new(size); + Stream::new().feed(bytes, &mut source); + let mut replica = Terminal::new(size); + Stream::new().feed(&source.synthetic_seed(), &mut replica); + assert_screen_state(source.active(), replica.active()); + replica + } + + #[test] + fn synthetic_seed_recreates_the_filler_before_a_widened_cluster() { + let mut source = Terminal::new(GridSize::new(8, 3)); + Stream::new().feed("\x1b[?2027habc\u{2764}\u{FE0F}Z".as_bytes(), &mut source); + source.resize(GridSize::new(4, 3)); + + let mut replica = Terminal::new(GridSize::new(4, 3)); + Stream::new().feed(&source.synthetic_seed(), &mut replica); + + assert_screen_state(source.active(), replica.active()); + } + + #[test] + fn synthetic_seed_recreates_the_filler_after_the_next_row_is_overwritten() { + let mut replica = + assert_seed_round_trips(GridSize::new(4, 3), "abc界Z\x1b[2;1HA".as_bytes()); + + replica.select_all(); + assert_eq!( + replica.selected_text().as_deref().map(str::trim_end), + Some("abcA Z") + ); + } + + #[test] + fn synthetic_seed_recreates_a_filler_left_at_an_old_right_margin() { + assert_seed_round_trips( + GridSize::new(8, 3), + "\x1b[?69h\x1b[1;4sabc界Z\x1b[?69l".as_bytes(), + ); + } + + #[test] + fn synthetic_seed_keeps_fillers_from_an_old_margin_and_the_last_column() { + let mut replica = assert_seed_round_trips( + GridSize::new(8, 3), + "\x1b[?69h\x1b[1;4sabc界Z\x1b[?69l\x1b[1;8H界Q".as_bytes(), + ); + + replica.select_all(); + let text = replica.selected_text().expect("text on screen"); + assert!(text.starts_with("abc 界"), "{text:?}"); + } + + #[test] + fn synthetic_seed_does_not_join_the_filler_to_a_zwj_cluster() { + assert_seed_round_trips( + GridSize::new(8, 3), + "\x1b[?69h\x1b[1;4sabc\u{200D}界\x1b[?69l\x1b[1;5HWXYZ".as_bytes(), + ); + assert_seed_round_trips(GridSize::new(4, 3), "abc\u{200D}界".as_bytes()); + } + + #[test] + fn synthetic_seed_keeps_the_filler_under_a_latched_saved_cursor() { + let mut replica = + assert_seed_round_trips(GridSize::new(4, 3), "abcd\x1b7\x1b[1;4H界Z".as_bytes()); + + replica.select_all(); + assert_eq!( + replica.selected_text().as_deref().map(str::trim_end), + Some("abc界Z") + ); + } + + #[test] + fn synthetic_seed_keeps_the_filler_under_a_latched_live_cursor() { + let mut replica = + assert_seed_round_trips(GridSize::new(4, 3), "abcd\x1b7\x1b[1;4H界Z\x1b8".as_bytes()); + + replica.select_all(); + assert_eq!( + replica.selected_text().as_deref().map(str::trim_end), + Some("abc界Z") + ); + } + + #[test] + fn synthetic_seed_keeps_the_filler_under_a_latched_origin_relative_cursor() { + let mut replica = assert_seed_round_trips( + GridSize::new(4, 3), + "\x1b[?6habcd\x1b7\x1b[1;4H界Z\x1b8".as_bytes(), + ); + + replica.select_all(); + assert_eq!( + replica.selected_text().as_deref().map(str::trim_end), + Some("abc界Z") + ); + } + + #[test] + fn synthetic_seed_keeps_the_background_of_a_filler_on_the_last_row() { + // The last row's soft wrap has no row to continue into, so the seed + // cannot re-create it (or the filler flag); the painted cell must + // still match. + let size = GridSize::new(4, 2); + let mut source = Terminal::new(size); + Stream::new().feed( + b"abc\x1b[41m\xe7\x95\x8c\x1b[0m\x1b[1;1H\x1b[L", + &mut source, + ); + let mut replica = Terminal::new(size); + Stream::new().feed(&source.synthetic_seed(), &mut replica); + + let pad = source.active().grid[1].cells[3]; + assert!(pad.attrs.contains(CellAttrs::WIDE_PAD)); + let copy = replica.active().grid[1].cells[3]; + assert_eq!((copy.ch, copy.bg), (pad.ch, pad.bg)); + } + + #[test] + fn synthetic_seed_keeps_the_filler_background() { + assert_seed_round_trips(GridSize::new(4, 3), "abc界Z\x1b[2;1H\x1b[41m界".as_bytes()); + } + #[test] fn synthetic_seed_preserves_saved_origin_mode() { let size = GridSize::new(8, 6); diff --git a/crates/noa-grid/src/tests/scrollback_snapshot.rs b/crates/noa-grid/src/tests/scrollback_snapshot.rs index 23bd3642..7c9b05ba 100644 --- a/crates/noa-grid/src/tests/scrollback_snapshot.rs +++ b/crates/noa-grid/src/tests/scrollback_snapshot.rs @@ -290,3 +290,22 @@ fn a_skipped_row_landing_on_a_live_row_drops_exactly_that_row() { "skip_row is unconditional by design: {text:?}" ); } + +#[test] +fn restored_history_rewraps_without_the_wide_wrap_filler() { + // The wide glyph wrapped at 4 columns; at 8 it fits right after "abc". + let source = run_size(4, 3, "abc界Z\r\n".as_bytes()); + let restored = restore_into(&source, 8, 3, 1 << 20); + + let row = restored + .active_absolute_row(0) + .expect("the first restored row is addressable"); + assert_eq!(row.cells[3].ch, '界'); + assert_eq!(row.cells[5].ch, 'Z'); + assert!(!row.wrapped); + assert!( + row.cells + .iter() + .all(|cell| !cell.attrs.contains(CellAttrs::WIDE_PAD)) + ); +} diff --git a/crates/noa-grid/src/tests/text_resize.rs b/crates/noa-grid/src/tests/text_resize.rs index 5aeec1b1..62837b61 100644 --- a/crates/noa-grid/src/tests/text_resize.rs +++ b/crates/noa-grid/src/tests/text_resize.rs @@ -377,3 +377,126 @@ fn resize_shrink_rows_treats_erased_rows_below_cursor_as_disposable() { assert_eq!(row_text(&t, 2, 1), "C"); assert_eq!(t.primary.cursor.y, 2); } + +#[test] +fn wide_char_wrap_flags_the_row_end_filler() { + let t = run_size(4, 2, "abc界Z".as_bytes()); + + assert!(cell(&t, 3, 0).attrs.contains(CellAttrs::WIDE_PAD)); + assert!(!cell(&t, 2, 0).attrs.contains(CellAttrs::WIDE_PAD)); +} + +#[test] +fn wide_char_wrap_blanks_stale_content_under_the_filler() { + let t = run_size(4, 2, "wxyz\x1b[1;4H界Z".as_bytes()); + + assert_eq!(cell(&t, 3, 0).ch, ' '); + assert!(cell(&t, 3, 0).attrs.contains(CellAttrs::WIDE_PAD)); +} + +#[test] +fn growing_reflow_drops_the_wide_wrap_filler() { + let mut t = run_size(4, 3, "abc界Z".as_bytes()); + + t.resize(GridSize::new(8, 3)); + + assert_eq!(row_text(&t, 0, 6), "abc界 Z"); + assert!(!t.primary.grid[0].wrapped); +} + +#[test] +fn shrinking_reflow_flags_the_wide_wrap_filler() { + let mut t = run_size(8, 3, "abc界Z".as_bytes()); + + t.resize(GridSize::new(4, 3)); + + assert!(cell(&t, 3, 0).attrs.contains(CellAttrs::WIDE_PAD)); + assert_eq!(cell(&t, 0, 1).ch, '界'); +} + +#[test] +fn reflow_keeps_a_real_space_at_the_row_end() { + let mut t = run_size(4, 3, "abc 界".as_bytes()); + assert!(!cell(&t, 3, 0).attrs.contains(CellAttrs::WIDE_PAD)); + + t.resize(GridSize::new(8, 3)); + + assert_eq!(row_text(&t, 0, 6), "abc 界 "); +} + +#[test] +fn selected_text_and_search_skip_the_wide_wrap_filler() { + let mut t = run_size(4, 3, "abc界Z".as_bytes()); + + t.select_all(); + assert_eq!( + t.selected_text().as_deref().map(|text| text.trim_end()), + Some("abc界Z") + ); + + t.set_search_query("c界Z"); + assert_eq!(t.primary.search.matches().len(), 1); +} + +#[test] +fn delete_chars_drops_the_shifted_wide_wrap_filler() { + let mut t = run_size(4, 3, "abc界Z\x1b[1;1H\x1b[P".as_bytes()); + + assert!(!cell(&t, 2, 0).attrs.contains(CellAttrs::WIDE_PAD)); + t.select_all(); + assert_eq!( + t.selected_text().as_deref().map(|text| text.trim_end()), + Some("bc 界Z") + ); +} + +#[test] +fn reflow_after_an_old_right_margin_wrap_is_path_independent() { + let bytes = "\x1b[?69h\x1b[1;4sabc界Z\x1b[?69l".as_bytes(); + let wide_x = |t: &Terminal| { + (0..12) + .find(|&x| cell(t, x, 0).ch == '界') + .expect("the wide glyph stays on the first row") + }; + + let mut direct = run_size(8, 3, bytes); + direct.resize(GridSize::new(12, 3)); + let mut via_narrow = run_size(8, 3, bytes); + via_narrow.resize(GridSize::new(4, 3)); + via_narrow.resize(GridSize::new(12, 3)); + + assert_eq!(wide_x(&direct), wide_x(&via_narrow)); + assert!((0..12).all(|x| !cell(&direct, x, 0).attrs.contains(CellAttrs::WIDE_PAD))); +} + +#[test] +fn rectangle_scroll_drops_the_moved_wide_wrap_filler() { + let mut t = run_size( + 8, + 3, + "\x1b[?69h\x1b[1;4s\x1b[2;1Habc界\x1b[S\x1b[?69l\x1b[1;5HQ".as_bytes(), + ); + + t.select_all(); + let text = t.selected_text().expect("text on screen"); + assert!(text.starts_with("abc Q"), "{text:?}"); + t.set_search_query("c Q"); + assert_eq!(t.primary.search.matches().len(), 1); +} + +#[test] +fn a_wrap_at_a_narrow_right_margin_leaves_a_plain_blank() { + let t = run_size(8, 3, "\x1b[?69h\x1b[1;4sabc界Z\x1b[?69l\x1b[1;8H界Q".as_bytes()); + + assert!(!cell(&t, 3, 0).attrs.contains(CellAttrs::WIDE_PAD)); + assert!(cell(&t, 7, 0).attrs.contains(CellAttrs::WIDE_PAD)); +} + +#[test] +fn seed_mark_wide_pad_keeps_the_occupancy_watermark() { + let mut t = run_size(4, 3, b"\x1b[1;4H\x1b[>$w"); + + assert!(t.primary.grid[0].occupied() >= 4); + Stream::new().feed(b"\x1b[2J", &mut t); + assert!(t.primary.grid[0].is_blank()); +} diff --git a/crates/noa-vt/src/handler.rs b/crates/noa-vt/src/handler.rs index 4bdf88cb..8b232d68 100644 --- a/crates/noa-vt/src/handler.rs +++ b/crates/noa-vt/src/handler.rs @@ -444,6 +444,14 @@ pub trait Handler { /// recover it exactly. Emitted only by `Terminal::synthetic_seed`; real /// programs never send this. fn seed_set_cursor_hollow(&mut self) {} + /// Client-mode seed-only: `CSI > $ w` flags the cell under the cursor as + /// the filler a wide glyph left when it wrapped (`CellAttrs::WIDE_PAD`), + /// without moving the cursor or clearing its deferred-wrap latch. The + /// seed recreates a latch by reprinting the cell under the cursor, which + /// turns a filler there into a plain space; this puts the flag back. + /// Emitted only by `Terminal::synthetic_seed`; real programs never send + /// this. + fn seed_mark_wide_pad(&mut self) {} /// Client-mode seed-only: `CSI > Ps ; Ph $ q` restores the DECSCUSR-0 /// default cursor style (the shape a bare `CSI 0 q` resets to), kept /// independent from whatever `DECSCUSR` the seed used to paint the diff --git a/crates/noa-vt/src/stream.rs b/crates/noa-vt/src/stream.rs index 91e75aac..66965403 100644 --- a/crates/noa-vt/src/stream.rs +++ b/crates/noa-vt/src/stream.rs @@ -637,6 +637,9 @@ fn dispatch_csi(csi: &Csi, h: &mut H, sgr_attrs: &mut Vec) // `$` intermediate keeps this clear of xterm's `CSI > Ps ; Ps t` // (title-mode set) namespace. b't' if csi.private == b'>' && csi.intermediates() == b"$" => h.seed_set_cursor_hollow(), + // Client-mode seed-only: see `Handler::seed_mark_wide_pad`. The + // private marker keeps this clear of DECRQPSR (`CSI Ps $ w`). + b'w' if csi.private == b'>' && csi.intermediates() == b"$" => h.seed_mark_wide_pad(), _ => {} // unknown / inc>=2 } }