From 63f0cefdfabf34f6632797e5ce385231664d3008 Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Wed, 30 Sep 2026 18:13:56 +0900 Subject: [PATCH 1/3] fix(grid): keep the wide-glyph wrap filler out of reflowed text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a wide glyph does not fit in the last column, it moves to the next row and leaves one blank cell behind. That cell looked exactly like a real space, so a column-count resize carried it into the reflowed text and left stray blanks mid-word at the old wrap points ("あた り", "1,391,8 31件"). The cell was also left untouched, so stale content could survive under it. Blank that cell when printing or reflow wraps a wide glyph. In the last column, also flag it `WIDE_PAD`. `Row::ends_with_wide_pad` defines where the flag counts: only in the last column of a soft-wrapped row. A flagged cell anywhere else is an ordinary blank, so a stale flag can never hide visible text. Reflow drops the filler from the end of a soft-wrapped row, and copy and search skip it. A wrap at a narrower right margin just blanks the cell. Flagging it there would leave fillers mid-row once the margins go away, and a row could carry more than one of them. DCH and rectangle scrolls clear the flag on the cells they move. --- crates/noa-core/src/attrs.rs | 4 + crates/noa-grid/src/cell.rs | 11 +++ crates/noa-grid/src/screen.rs | 22 +++++ crates/noa-grid/src/screen/edit.rs | 3 + crates/noa-grid/src/screen/print.rs | 7 ++ crates/noa-grid/src/screen/reflow.rs | 10 ++ crates/noa-grid/src/screen/text.rs | 5 +- crates/noa-grid/src/search.rs | 6 +- crates/noa-grid/src/tests/text_resize.rs | 114 +++++++++++++++++++++++ 9 files changed, 178 insertions(+), 4 deletions(-) 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..8453a798 100644 --- a/crates/noa-grid/src/screen.rs +++ b/crates/noa-grid/src/screen.rs @@ -353,6 +353,28 @@ 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); + } + } + + /// 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/tests/text_resize.rs b/crates/noa-grid/src/tests/text_resize.rs index 5aeec1b1..0285f52a 100644 --- a/crates/noa-grid/src/tests/text_resize.rs +++ b/crates/noa-grid/src/tests/text_resize.rs @@ -377,3 +377,117 @@ 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)); +} From a65f456ac35a50cc82c5827099b6a812e8ae92b0 Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Wed, 30 Sep 2026 18:16:54 +0900 Subject: [PATCH 2/3] fix(grid): drop the wide-wrap filler when rewrapping restored history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restoring saved scrollback at a new width rewraps it with `snapshot::rewrap`, which kept the filler as a regular cell. As a result, "abc界Z" saved at 4 columns placed 界 at column 4 instead of 3 when restored at 8 columns. Drop the filler from the end of a soft-wrapped row, as live reflow does. When rewrap moves a wide glyph down, flag the column it vacates, so a later live reflow treats that column as filler too. --- crates/noa-grid/src/snapshot.rs | 17 +++++++++++++---- .../noa-grid/src/tests/scrollback_snapshot.rs | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) 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/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)) + ); +} From ef2b848353c791f7e2b6f3e100cf017c2754db3d Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Wed, 30 Sep 2026 18:20:03 +0900 Subject: [PATCH 3/3] fix(grid): carry the wide-wrap filler through client-mode seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed rebuilds a replica's screen with plain VT output, which has no way to express the filler. Before this change, the replica received it as an ordinary space and copied "abc 界Z" where the server copied "abc界Z". Its own reflow then disagreed with the server as well. - For a row that ends in a filler, print U+3000 in the last column with the filler's own background and grapheme clustering off. The replica then lays down the filler and soft-wraps by itself, however the next row now looks, and the next row is repainted from the source. - Paint filler cells with ECH rather than a printed space. This keeps a filler's background on the last row, where no wrap can be rebuilt, and never joins a preceding cluster ending in a ZWJ. - Recreating a cursor's deferred-wrap latch reprints the cell under it, which turns a filler there into a plain space. That cursor must stay latched, so the filler cannot be rebuilt afterwards. The new seed-only `CSI > $ w` flags the cell under the cursor without moving it or clearing the latch. Both the absolute and origin-relative cursor writers emit it after such a reprint. --- crates/noa-grid/src/screen.rs | 13 ++ crates/noa-grid/src/terminal/handler.rs | 4 + crates/noa-grid/src/terminal/seed.rs | 209 ++++++++++++++++++++++- crates/noa-grid/src/tests/text_resize.rs | 9 + crates/noa-vt/src/handler.rs | 8 + crates/noa-vt/src/stream.rs | 3 + 6 files changed, 239 insertions(+), 7 deletions(-) diff --git a/crates/noa-grid/src/screen.rs b/crates/noa-grid/src/screen.rs index 8453a798..d10b09e6 100644 --- a/crates/noa-grid/src/screen.rs +++ b/crates/noa-grid/src/screen.rs @@ -367,6 +367,19 @@ impl Screen { } } + /// 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]) { 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/text_resize.rs b/crates/noa-grid/src/tests/text_resize.rs index 0285f52a..62837b61 100644 --- a/crates/noa-grid/src/tests/text_resize.rs +++ b/crates/noa-grid/src/tests/text_resize.rs @@ -491,3 +491,12 @@ fn a_wrap_at_a_narrow_right_margin_leaves_a_plain_blank() { 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 } }