Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/noa-core/src/attrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
11 changes: 11 additions & 0 deletions crates/noa-grid/src/cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
35 changes: 35 additions & 0 deletions crates/noa-grid/src/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/noa-grid/src/screen/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions crates/noa-grid/src/screen/print.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the wide-pad marker in synthetic seeds

When a client-mode source contains a wide glyph wrapped from the last column (for example, abc界 at width 4), this call adds WIDE_PAD, but terminal/seed.rs can only replay the cell as an ordinary space. The replica therefore differs immediately, and widening both terminals later makes the replica retain that space and shift the following glyph while the source drops it. The seed path needs to recreate or explicitly encode this layout marker.

Useful? React with 👍 / 👎.

row.wrapped = true;
row.dirty = true;
}
self.index();
self.cursor.x = left;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions crates/noa-grid/src/screen/reflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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));
Expand Down
5 changes: 3 additions & 2 deletions crates/noa-grid/src/screen/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions crates/noa-grid/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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()));
}
Expand Down
17 changes: 13 additions & 4 deletions crates/noa-grid/src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ pub fn decode_within(bytes: &[u8], max_body: u64) -> Option<ScrollbackSnapshot>
///
/// 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<Row>, cols: u16) -> Vec<Row> {
if cols == 0 {
return Vec::new();
Expand All @@ -632,7 +632,12 @@ pub fn rewrap(rows: Vec<Row>, cols: u16) -> Vec<Row> {
let mut logical: Vec<Cell> = 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;
Expand Down Expand Up @@ -669,8 +674,12 @@ fn emit_logical_line(line: &[Cell], width: usize, out: &mut Vec<Row>) {
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;
Expand Down Expand Up @@ -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));
Expand Down
4 changes: 4 additions & 0 deletions crates/noa-grid/src/terminal/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading