Skip to main content

max / shop

Split shop-grid's 1596-line impl Grid into seven subsystems lib.rs was 4465 lines. Cell attributes, mouse reporting, scrollback, the ring buffer and resize, SGR parsing, editing and the vte::Perform impl become flat siblings; struct Grid, its 45 private fields, HistoryRow and the cursor types stay at the root, so oracle.rs, selection.rs and text.rs are unchanged. The facade holds: lib.rs re-exports cell and mouse types at their old paths and all three in-workspace consumers compile unedited. Equivalence is evidenced rather than asserted. The multiset of fn name tokens is byte-identical before and after, and a normalized line diff of the original against the concatenation of all nine files shows exactly one content change in the body, `super::Cell::c` becoming `crate::Cell::c` in a moved helper. Everything else is module scaffolding. 183 lib tests before and after. Every new production module carries a literal #[cfg(test)]. That is required, not decorative: witchbroom runs mutants with tested_files_only, and mirror::declares_inline_tests selects on the attribute, so a carved module without one leaves mutation scope silently. testutil.rs deliberately has none, being test-only itself, and its module doc describes the attribute rather than spelling it so the predicate does not match prose. Seven items the sheet marked pub(crate) stay private: no caller exists outside their file, and widening only by name is the rule.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:38 UTC
Signed with PGP, not checked
Commit: 8bf9d8c7f0b2b95f1b0627c2b2d926bb3eb85476
Parent: f37fba2
9 files changed, +2953 insertions, -465 deletions
@@ -6,359 +6,26 @@
6 6 //! Implements [`vte::Perform`], so the binary can pipe PTY bytes through a
7 7 //! `vte::Parser` straight into the grid.
8 8
9 + mod cell;
10 + mod edit;
11 + mod history;
12 + mod mouse;
9 13 pub mod oracle;
14 + mod perform;
15 + mod ring;
10 16 mod selection;
17 + mod sgr;
11 18 mod text;
19 +
20 + #[cfg(test)]
21 + mod testutil;
22 +
23 + pub use cell::{Attrs, Cell, Color};
24 + pub use mouse::{MouseAction, MouseButton, MouseEncoding, MouseMods, MouseReport, MouseTracking};
12 25 pub use selection::{Point, Selection, SelectionMode, SelectionSpan};
13 26
14 - use shop_vt::{Params, Perform};
15 - use std::collections::{HashMap, VecDeque};
16 - use tracing::trace;
17 -
18 - /// A terminal color.
19 - ///
20 - /// [`Color::Default`] means "resolve at render time to the theme's default fg
21 - /// or bg" — kept out of `Rgb` so we don't lose the "this cell was never
22 - /// styled" signal (matters for e.g. transparent backgrounds).
23 - #[derive(Copy, Clone, Debug, PartialEq, Eq)]
24 - pub enum Color {
25 - Default,
26 - Named(u8),
27 - Indexed(u8),
28 - Rgb(u8, u8, u8),
29 - }
30 -
31 - /// SGR-set attributes for a cell.
32 - #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
33 - pub struct Attrs {
34 - pub bold: bool,
35 - pub italic: bool,
36 - pub underline: bool,
37 - pub reverse: bool,
38 - pub dim: bool,
39 - pub strikethrough: bool,
40 - }
41 -
42 - /// One cell of the display grid — 12 bytes, matching foot's layout.
43 - ///
44 - /// Three `u32`s so the struct aligns to 4 (a `u64` would pad to 16). Each
45 - /// color word holds a 24-bit RGB payload, a 2-bit source tag, and a 6-bit
46 - /// attribute half — the attribute bits live in `fg_word`, `bg_word`'s upper
47 - /// 6 bits are spare.
48 - ///
49 - /// Layout of `fg_word` / `bg_word`:
50 - /// - bits 0–23: color payload — RGB if `src == Rgb`, low byte = palette index if Named/Indexed
51 - /// - bits 24–25: source tag (`SRC_DEFAULT`/`NAMED`/`INDEXED`/`RGB`)
52 - /// - bits 26–31: attribute flags (fg_word only; bg_word width flags + spare)
53 - ///
54 - /// Layout of `c_raw`:
55 - /// - bits 0–20: the base character. A `char` is 21 bits, so this is the whole
56 - /// of one and [`Cell::c`] is a mask away.
57 - /// - bits 21–31: id into the grid's mark table, 0 meaning none.
58 - ///
59 - /// Keeping the base character INLINE and interning only the marks after it is
60 - /// what makes combining marks cost nothing to everything that does not care:
61 - /// the renderer's glyph lookup, the fills scan and word boundaries all read
62 - /// `c()` and never learn the table exists. It also keeps the cell `Copy` and
63 - /// memcpy-able, which is what lets `resize_buf`, the ring's origin advance and
64 - /// the scrollback rewrap move cells around without knowing any of this.
65 - #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
66 - #[repr(C)]
67 - pub struct Cell {
68 - c_raw: u32,
69 - fg_word: u32,
70 - bg_word: u32,
71 - }
72 -
73 - /// A `char` is 21 bits; `c_raw`'s low 21 hold one whole.
74 - const CHAR_MASK: u32 = (1 << 21) - 1;
75 - const MARKS_SHIFT: u32 = 21;
76 - /// Eleven bits of mark-sequence id, 0 reserved for "no marks". Distinct mark
77 - /// SEQUENCES in real text run to dozens — it is distinct clusters that run to
78 - /// thousands, and those are not what is interned here — so 2047 is not a
79 - /// ceiling anything is expected to reach.
80 - const MARKS_LIMIT: u32 = (1 << 11) - 1;
81 -
82 - /// How many combining marks one cell will accumulate.
83 - ///
84 - /// A stream of combining marks is unbounded input, and a cluster long enough to
85 - /// need more than this is not text anyone is reading. Past the cap the mark is
86 - /// dropped rather than the cell rewritten.
87 - const MAX_MARKS: usize = 8;
88 -
89 - const SRC_DEFAULT: u32 = 0;
90 - const SRC_NAMED: u32 = 1;
91 - const SRC_INDEXED: u32 = 2;
92 - const SRC_RGB: u32 = 3;
93 -
94 - const SRC_SHIFT: u32 = 24;
95 - const SRC_MASK: u32 = 0b11 << SRC_SHIFT;
96 - const RGB_MASK: u32 = 0x00FF_FFFF;
97 -
98 - // Width flags, on `bg_word`'s spare upper bits. A double-width character is one
99 - // cell holding the character with `FLAG_WIDE` set, followed by one holding a
100 - // blank with `FLAG_SPACER` set. The pair is always adjacent and always in that
101 - // order; `heal_pair` is what keeps that true when a write lands on half of one.
102 - //
103 - // The spacer carries the lead's colours so a background fill covers both halves
104 - // with no seam, and holds a blank so every reader that draws or copies a cell's
105 - // character already does the right thing with it without being taught to.
106 - const FLAG_WIDE: u32 = 1 << 26;
107 - const FLAG_SPACER: u32 = 1 << 27;
108 - const FLAG_WIDTH_MASK: u32 = FLAG_WIDE | FLAG_SPACER;
109 -
110 - const ATTR_BOLD: u32 = 1 << 26;
111 - const ATTR_ITALIC: u32 = 1 << 27;
112 - const ATTR_UNDERLINE: u32 = 1 << 28;
113 - const ATTR_REVERSE: u32 = 1 << 29;
114 - const ATTR_DIM: u32 = 1 << 30;
115 - const ATTR_STRIKE: u32 = 1 << 31;
116 -
117 - fn encode_color(c: Color) -> u32 {
118 - match c {
119 - Color::Default => SRC_DEFAULT << SRC_SHIFT,
120 - Color::Named(i) => (SRC_NAMED << SRC_SHIFT) | i as u32,
121 - Color::Indexed(i) => (SRC_INDEXED << SRC_SHIFT) | i as u32,
122 - Color::Rgb(r, g, b) => {
123 - (SRC_RGB << SRC_SHIFT) | ((r as u32) << 16) | ((g as u32) << 8) | b as u32
124 - }
125 - }
126 - }
127 -
128 - fn decode_color(word: u32) -> Color {
129 - let payload = word & RGB_MASK;
130 - match (word & SRC_MASK) >> SRC_SHIFT {
131 - SRC_NAMED => Color::Named(payload as u8),
132 - SRC_INDEXED => Color::Indexed(payload as u8),
133 - SRC_RGB => Color::Rgb((payload >> 16) as u8, (payload >> 8) as u8, payload as u8),
134 - _ => Color::Default,
135 - }
136 - }
137 -
138 - fn encode_attrs(a: Attrs) -> u32 {
139 - let mut bits = 0u32;
140 - if a.bold {
141 - bits |= ATTR_BOLD;
142 - }
143 - if a.italic {
144 - bits |= ATTR_ITALIC;
145 - }
146 - if a.underline {
147 - bits |= ATTR_UNDERLINE;
148 - }
149 - if a.reverse {
150 - bits |= ATTR_REVERSE;
151 - }
152 - if a.dim {
153 - bits |= ATTR_DIM;
154 - }
155 - if a.strikethrough {
156 - bits |= ATTR_STRIKE;
157 - }
158 - bits
159 - }
160 -
161 - impl Cell {
162 - pub fn new(c: char, fg: Color, bg: Color, attrs: Attrs) -> Self {
163 - Self {
164 - c_raw: c as u32,
165 - fg_word: encode_color(fg) | encode_attrs(attrs),
166 - bg_word: encode_color(bg),
167 - }
168 - }
169 -
170 - /// The cell's base character, with any combining marks left behind.
171 - ///
172 - /// This is what the renderer draws and what word boundaries read. The marks
173 - /// change how a cluster looks, not what it is or how wide it is, so nothing
174 - /// laying out columns needs them.
175 - #[inline]
176 - pub fn c(&self) -> char {
177 - // Grid only ever writes valid `char` values into `c_raw`.
178 - char::from_u32(self.c_raw & CHAR_MASK).unwrap_or('\u{FFFD}')
179 - }
180 -
181 - /// Id of this cell's combining marks in the grid's table, or 0 for none.
182 - #[inline]
183 - pub fn marks_id(&self) -> u16 {
184 - ((self.c_raw >> MARKS_SHIFT) & MARKS_LIMIT) as u16
185 - }
186 -
187 - #[inline]
188 - fn with_marks(self, id: u16) -> Self {
189 - Self {
190 - c_raw: (self.c_raw & CHAR_MASK) | (u32::from(id) << MARKS_SHIFT),
191 - ..self
192 - }
193 - }
194 -
195 - #[inline]
196 - pub fn fg(&self) -> Color {
197 - decode_color(self.fg_word)
198 - }
199 -
200 - #[inline]
201 - pub fn bg(&self) -> Color {
202 - decode_color(self.bg_word)
203 - }
204 -
205 - pub fn attrs(&self) -> Attrs {
206 - let b = self.fg_word;
207 - Attrs {
208 - bold: b & ATTR_BOLD != 0,
209 - italic: b & ATTR_ITALIC != 0,
210 - underline: b & ATTR_UNDERLINE != 0,
211 - reverse: b & ATTR_REVERSE != 0,
212 - dim: b & ATTR_DIM != 0,
213 - strikethrough: b & ATTR_STRIKE != 0,
214 - }
215 - }
216 -
217 - // Hot-path attribute readers — used by the renderer's per-frame fills
218 - // scan, which touches every visible cell. Avoids materializing `Attrs`.
219 - #[inline]
220 - pub fn reverse(&self) -> bool {
221 - self.fg_word & ATTR_REVERSE != 0
222 - }
223 -
224 - #[inline]
225 - pub fn underline(&self) -> bool {
226 - self.fg_word & ATTR_UNDERLINE != 0
227 - }
228 -
229 - /// Fast "does this cell need a bg fill drawn" check for the fills scan.
230 - /// True when bg is not Default OR reverse is set.
231 - #[inline]
232 - pub fn has_bg(&self) -> bool {
233 - (self.bg_word & SRC_MASK) != 0 || self.reverse()
234 - }
235 -
236 - /// Whether this cell holds a character that covers the column after it too.
237 - #[inline]
238 - pub fn is_wide(&self) -> bool {
239 - self.bg_word & FLAG_WIDE != 0
240 - }
241 -
242 - /// Whether this cell is the second column of the character before it.
243 - ///
244 - /// A spacer is not a character of its own. Anything turning cells back into
245 - /// text — copy, emit, word boundaries — has to skip it, or one `日` comes
246 - /// out as a `日` and a space.
247 - #[inline]
248 - pub fn is_spacer(&self) -> bool {
249 - self.bg_word & FLAG_SPACER != 0
250 - }
251 -
252 - /// How many columns this cell's character occupies: 2 on the lead of a
253 - /// wide pair, 1 otherwise (a spacer included — it is one column, it just
254 - /// is not its own character).
255 - #[inline]
256 - pub fn cols(&self) -> u16 {
257 - if self.is_wide() { 2 } else { 1 }
258 - }
259 -
260 - /// The column a wide character could not fit into at the right edge.
261 - ///
262 - /// A spacer with no lead in front of it: it holds a column on screen and is
263 - /// not a character, which is exactly what that column is.
264 - fn pad() -> Self {
265 - Self {
266 - bg_word: FLAG_SPACER,
267 - ..Self::default()
268 - }
269 - }
270 -
271 - /// The lead of a wide pair, and the spacer that follows it.
272 - fn wide_pair(c: char, fg_word: u32, bg_word: u32) -> (Self, Self) {
273 - let bg_word = bg_word & !FLAG_WIDTH_MASK;
274 - (
275 - Self {
276 - c_raw: c as u32,
277 - fg_word,
278 - bg_word: bg_word | FLAG_WIDE,
279 - },
280 - Self {
281 - c_raw: ' ' as u32,
282 - fg_word,
283 - bg_word: bg_word | FLAG_SPACER,
284 - },
285 - )
286 - }
287 - }
288 -
289 - /// The combining-mark sequences the cells refer to by id.
290 - ///
291 - /// Append-only and interned. Real text draws from a handful of distinct
292 - /// sequences however much of it goes past — one `U+0301` is every acute accent
293 - /// on screen — so this stays in the dozens and never needs freeing. Interning
294 - /// the marks rather than whole clusters is what keeps it that small: the bases
295 - /// they attach to are what vary.
296 - #[derive(Debug, Default)]
297 - struct MarkTable {
298 - /// Id `n` is at index `n - 1`; id 0 means a cell has no marks and is never
299 - /// stored.
300 - seqs: Vec<Box<[char]>>,
301 - ids: HashMap<Box<[char]>, u16>,
302 - }
303 -
304 - impl MarkTable {
305 - /// The marks for `id`, or empty for id 0.
306 - fn get(&self, id: u16) -> &[char] {
307 - match id.checked_sub(1) {
308 - Some(i) => self.seqs.get(i as usize).map_or(&[], |s| s),
309 - None => &[],
310 - }
311 - }
312 -
313 - /// The id for a sequence, interning it if it is new.
314 - ///
315 - /// `None` once the id space is exhausted, which is the caller's cue to drop
316 - /// the mark. Refusing to store a mark keeps the cell as it was; there is no
317 - /// id it could be given that would not mean somebody else's marks.
318 - fn intern(&mut self, seq: &[char]) -> Option<u16> {
319 - if let Some(id) = self.ids.get(seq) {
320 - return Some(*id);
321 - }
322 - let id = u16::try_from(self.seqs.len() + 1).ok()?;
323 - if u32::from(id) > MARKS_LIMIT {
324 - return None;
325 - }
326 - let seq: Box<[char]> = seq.into();
327 - self.seqs.push(seq.clone());
328 - self.ids.insert(seq, id);
329 - Some(id)
330 - }
331 - }
332 -
333 - /// How many columns a character occupies, as the application computing its own
334 - /// cursor moves will have counted it.
335 - ///
336 - /// Zero is a real answer here rather than a degenerate one: a combining mark
337 - /// occupies no column of its own, it modifies the one before it. `place_char`
338 - /// reads it as an instruction to amend rather than to place.
339 - ///
340 - /// A character with no width at all (a control) counts as one. `execute`
341 - /// handles C0 so one should not reach a print, and one column is the answer
342 - /// that leaves the cursor where the application put it if one does.
343 - fn char_cols(c: char) -> u16 {
344 - match unicode_width::UnicodeWidthChar::width(c) {
345 - Some(2) => 2,
346 - Some(0) => 0,
347 - _ => 1,
348 - }
349 - }
350 -
351 - impl Default for Cell {
352 - fn default() -> Self {
353 - Self {
354 - c_raw: ' ' as u32,
355 - fg_word: 0,
356 - bg_word: 0,
357 - }
358 - }
359 - }
360 -
361 - const _: () = assert!(std::mem::size_of::<Cell>() == 12);
27 + use cell::{MAX_MARKS, MarkTable, char_cols, encode_attrs, encode_color};
28 + use std::collections::VecDeque;
362 29
363 30 /// Shape hint from DECSCUSR (`CSI Ps SP q`). Blink flag is ignored — MVP
364 31 /// renders all as steady.
@@ -438,13 +105,6 @@
438 105 }
439 106 }
440 107
441 - /// One channel as OSC 10/11 want it: four hex digits, the 8-bit value
442 - /// doubled. `0x25` becomes `2525`, which is the 16-bit reading of the same
443 - /// intensity and what every terminal sends.
444 - fn osc_channel(v: u8) -> String {
445 - format!("{v:02x}{v:02x}")
446 - }
447 -
448 108 /// Cursor state (position + deferred-wrap flag).
449 109 #[derive(Copy, Clone, Debug, Default)]
450 110 pub struct Cursor {
@@ -457,121 +117,6 @@
457 117 pub wrap_next: bool,
458 118 }
459 119
460 - /// How much of the mouse a program has asked to be told about.
461 - ///
462 - /// Strictly increasing: each level includes everything below it, which is why
463 - /// one field holds all of them rather than a flag per DECSET number. Setting
464 - /// any level replaces the previous one, matching xterm — the modes are not
465 - /// composable there either, however much the separate numbers suggest it.
466 - #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
467 - pub enum MouseTracking {
468 - /// The pointer belongs to the user: shop selects text with it.
469 - #[default]
470 - Off,
471 - /// DECSET 9, X10 compatibility. Presses only, and no modifier bits.
472 - Press,
473 - /// DECSET 1000. Presses and releases.
474 - Click,
475 - /// DECSET 1002. Adds motion, but only while a button is held.
476 - Drag,
477 - /// DECSET 1003. Adds motion with no button down, which is a report per
478 - /// cell crossed for as long as the pointer is over the window.
479 - Motion,
480 - }
481 -
482 - /// How a mouse report is spelled on the wire.
483 - #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
484 - pub enum MouseEncoding {
485 - /// The original `CSI M Cb Cx Cy`, each field a byte biased by 32.
486 - ///
487 - /// Two consequences worth knowing, and both are why 1006 exists: a
488 - /// coordinate past 223 has no byte to land in and is dropped, and a
489 - /// release does not say which button was let go.
490 - #[default]
491 - X10,
492 - /// DECSET 1006. `CSI < b ; x ; y M` for a press, `m` for a release —
493 - /// decimal, so no coordinate ceiling, and the release keeps its button.
494 - Sgr,
495 - }
496 -
497 - /// Which button a mouse report is about.
498 - #[derive(Copy, Clone, Debug, PartialEq, Eq)]
499 - pub enum MouseButton {
500 - Left,
501 - Middle,
502 - Right,
503 - WheelUp,
504 - WheelDown,
505 - /// Motion with nothing held. Only [`MouseTracking::Motion`] asks for it.
506 - None,
507 - }
508 -
509 - impl MouseButton {
510 - /// The low bits the wire spells this button with. Wheel buttons set 64,
511 - /// which is the bit that distinguishes them from a real press.
512 - fn code(self) -> u8 {
513 - match self {
514 - Self::Left => 0,
515 - Self::Middle => 1,
516 - Self::Right => 2,
517 - Self::WheelUp => 64,
518 - Self::WheelDown => 65,
519 - // The same 3 a release uses. Unambiguous in context: this one
520 - // always arrives with the motion bit set.
521 - Self::None => 3,
522 - }
523 - }
524 -
525 - fn is_wheel(self) -> bool {
526 - matches!(self, Self::WheelUp | Self::WheelDown)
527 - }
528 - }
529 -
530 - /// What the pointer did.
531 - #[derive(Copy, Clone, Debug, PartialEq, Eq)]
532 - pub enum MouseAction {
533 - Press,
534 - Release,
535 - /// The pointer crossed into another cell. Whether a button is held is read
536 - /// from the report's button, not from here.
537 - Motion,
538 - }
539 -
540 - /// Modifiers held while the pointer did it.
541 - #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
542 - pub struct MouseMods {
543 - pub shift: bool,
544 - pub alt: bool,
545 - pub ctrl: bool,
546 - }
547 -
548 - impl MouseMods {
549 - fn bits(self) -> u8 {
550 - u8::from(self.shift) * 4 + u8::from(self.alt) * 8 + u8::from(self.ctrl) * 16
551 - }
552 - }
553 -
554 - /// One thing the pointer did, in grid coordinates, ready to be encoded.
555 - ///
556 - /// Cells, 0-based, as the rest of this crate counts them. The +1 the wire
557 - /// wants is applied at encoding time and nowhere else.
558 - #[derive(Copy, Clone, Debug, PartialEq, Eq)]
559 - pub struct MouseReport {
560 - pub button: MouseButton,
561 - pub action: MouseAction,
562 - pub col: u16,
563 - pub row: u16,
564 - pub mods: MouseMods,
565 - }
566 -
567 - /// The largest coordinate X10's byte-per-field encoding can carry.
568 - ///
569 - /// A field is `32 + 1 + n` in one byte, so n stops at 222. Past that the
Lines truncated
@@ -1,0 +1,367 @@
1 + //! The 12-byte display cell: its colours, its attributes, and the table that
2 + //! holds the combining-mark sequences cells refer to by id.
3 + //!
4 + //! The layout rationale lives on [`Cell`] itself. Nothing here knows about the
5 + //! grid; the grid is what owns a [`MarkTable`] and hands ids out.
6 +
7 + use std::collections::HashMap;
8 +
9 + /// A terminal color.
10 + ///
11 + /// [`Color::Default`] means "resolve at render time to the theme's default fg
12 + /// or bg" — kept out of `Rgb` so we don't lose the "this cell was never
13 + /// styled" signal (matters for e.g. transparent backgrounds).
14 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
15 + pub enum Color {
16 + Default,
17 + Named(u8),
18 + Indexed(u8),
19 + Rgb(u8, u8, u8),
20 + }
21 +
22 + /// SGR-set attributes for a cell.
23 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
24 + pub struct Attrs {
25 + pub bold: bool,
26 + pub italic: bool,
27 + pub underline: bool,
28 + pub reverse: bool,
29 + pub dim: bool,
30 + pub strikethrough: bool,
31 + }
32 +
33 + /// One cell of the display grid — 12 bytes, matching foot's layout.
34 + ///
35 + /// Three `u32`s so the struct aligns to 4 (a `u64` would pad to 16). Each
36 + /// color word holds a 24-bit RGB payload, a 2-bit source tag, and a 6-bit
37 + /// attribute half — the attribute bits live in `fg_word`, `bg_word`'s upper
38 + /// 6 bits are spare.
39 + ///
40 + /// Layout of `fg_word` / `bg_word`:
41 + /// - bits 0–23: color payload — RGB if `src == Rgb`, low byte = palette index if Named/Indexed
42 + /// - bits 24–25: source tag (`SRC_DEFAULT`/`NAMED`/`INDEXED`/`RGB`)
43 + /// - bits 26–31: attribute flags (fg_word only; bg_word width flags + spare)
44 + ///
45 + /// Layout of `c_raw`:
46 + /// - bits 0–20: the base character. A `char` is 21 bits, so this is the whole
47 + /// of one and [`Cell::c`] is a mask away.
48 + /// - bits 21–31: id into the grid's mark table, 0 meaning none.
49 + ///
50 + /// Keeping the base character INLINE and interning only the marks after it is
51 + /// what makes combining marks cost nothing to everything that does not care:
52 + /// the renderer's glyph lookup, the fills scan and word boundaries all read
53 + /// `c()` and never learn the table exists. It also keeps the cell `Copy` and
54 + /// memcpy-able, which is what lets `resize_buf`, the ring's origin advance and
55 + /// the scrollback rewrap move cells around without knowing any of this.
56 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
57 + #[repr(C)]
58 + pub struct Cell {
59 + pub(crate) c_raw: u32,
60 + pub(crate) fg_word: u32,
61 + pub(crate) bg_word: u32,
62 + }
63 +
64 + /// A `char` is 21 bits; `c_raw`'s low 21 hold one whole.
65 + const CHAR_MASK: u32 = (1 << 21) - 1;
66 + const MARKS_SHIFT: u32 = 21;
67 + /// Eleven bits of mark-sequence id, 0 reserved for "no marks". Distinct mark
68 + /// SEQUENCES in real text run to dozens — it is distinct clusters that run to
69 + /// thousands, and those are not what is interned here — so 2047 is not a
70 + /// ceiling anything is expected to reach.
71 + const MARKS_LIMIT: u32 = (1 << 11) - 1;
72 +
73 + /// How many combining marks one cell will accumulate.
74 + ///
75 + /// A stream of combining marks is unbounded input, and a cluster long enough to
76 + /// need more than this is not text anyone is reading. Past the cap the mark is
77 + /// dropped rather than the cell rewritten.
78 + pub(crate) const MAX_MARKS: usize = 8;
79 +
80 + const SRC_DEFAULT: u32 = 0;
81 + const SRC_NAMED: u32 = 1;
82 + const SRC_INDEXED: u32 = 2;
83 + const SRC_RGB: u32 = 3;
84 +
85 + const SRC_SHIFT: u32 = 24;
86 + const SRC_MASK: u32 = 0b11 << SRC_SHIFT;
87 + const RGB_MASK: u32 = 0x00FF_FFFF;
88 +
89 + // Width flags, on `bg_word`'s spare upper bits. A double-width character is one
90 + // cell holding the character with `FLAG_WIDE` set, followed by one holding a
91 + // blank with `FLAG_SPACER` set. The pair is always adjacent and always in that
92 + // order; `heal_pair` is what keeps that true when a write lands on half of one.
93 + //
94 + // The spacer carries the lead's colours so a background fill covers both halves
95 + // with no seam, and holds a blank so every reader that draws or copies a cell's
96 + // character already does the right thing with it without being taught to.
97 + const FLAG_WIDE: u32 = 1 << 26;
98 + const FLAG_SPACER: u32 = 1 << 27;
99 + const FLAG_WIDTH_MASK: u32 = FLAG_WIDE | FLAG_SPACER;
100 +
101 + const ATTR_BOLD: u32 = 1 << 26;
102 + const ATTR_ITALIC: u32 = 1 << 27;
103 + const ATTR_UNDERLINE: u32 = 1 << 28;
104 + const ATTR_REVERSE: u32 = 1 << 29;
105 + const ATTR_DIM: u32 = 1 << 30;
106 + const ATTR_STRIKE: u32 = 1 << 31;
107 +
108 + pub(crate) fn encode_color(c: Color) -> u32 {
109 + match c {
110 + Color::Default => SRC_DEFAULT << SRC_SHIFT,
111 + Color::Named(i) => (SRC_NAMED << SRC_SHIFT) | i as u32,
112 + Color::Indexed(i) => (SRC_INDEXED << SRC_SHIFT) | i as u32,
113 + Color::Rgb(r, g, b) => {
114 + (SRC_RGB << SRC_SHIFT) | ((r as u32) << 16) | ((g as u32) << 8) | b as u32
115 + }
116 + }
117 + }
118 +
119 + fn decode_color(word: u32) -> Color {
120 + let payload = word & RGB_MASK;
121 + match (word & SRC_MASK) >> SRC_SHIFT {
122 + SRC_NAMED => Color::Named(payload as u8),
123 + SRC_INDEXED => Color::Indexed(payload as u8),
124 + SRC_RGB => Color::Rgb((payload >> 16) as u8, (payload >> 8) as u8, payload as u8),
125 + _ => Color::Default,
126 + }
127 + }
128 +
129 + pub(crate) fn encode_attrs(a: Attrs) -> u32 {
130 + let mut bits = 0u32;
131 + if a.bold {
132 + bits |= ATTR_BOLD;
133 + }
134 + if a.italic {
135 + bits |= ATTR_ITALIC;
136 + }
137 + if a.underline {
138 + bits |= ATTR_UNDERLINE;
139 + }
140 + if a.reverse {
141 + bits |= ATTR_REVERSE;
142 + }
143 + if a.dim {
144 + bits |= ATTR_DIM;
145 + }
146 + if a.strikethrough {
147 + bits |= ATTR_STRIKE;
148 + }
149 + bits
150 + }
151 +
152 + impl Cell {
153 + pub fn new(c: char, fg: Color, bg: Color, attrs: Attrs) -> Self {
154 + Self {
155 + c_raw: c as u32,
156 + fg_word: encode_color(fg) | encode_attrs(attrs),
157 + bg_word: encode_color(bg),
158 + }
159 + }
160 +
161 + /// The cell's base character, with any combining marks left behind.
162 + ///
163 + /// This is what the renderer draws and what word boundaries read. The marks
164 + /// change how a cluster looks, not what it is or how wide it is, so nothing
165 + /// laying out columns needs them.
166 + #[inline]
167 + pub fn c(&self) -> char {
168 + // Grid only ever writes valid `char` values into `c_raw`.
169 + char::from_u32(self.c_raw & CHAR_MASK).unwrap_or('\u{FFFD}')
170 + }
171 +
172 + /// Id of this cell's combining marks in the grid's table, or 0 for none.
173 + #[inline]
174 + pub fn marks_id(&self) -> u16 {
175 + ((self.c_raw >> MARKS_SHIFT) & MARKS_LIMIT) as u16
176 + }
177 +
178 + #[inline]
179 + pub(crate) fn with_marks(self, id: u16) -> Self {
180 + Self {
181 + c_raw: (self.c_raw & CHAR_MASK) | (u32::from(id) << MARKS_SHIFT),
182 + ..self
183 + }
184 + }
185 +
186 + #[inline]
187 + pub fn fg(&self) -> Color {
188 + decode_color(self.fg_word)
189 + }
190 +
191 + #[inline]
192 + pub fn bg(&self) -> Color {
193 + decode_color(self.bg_word)
194 + }
195 +
196 + pub fn attrs(&self) -> Attrs {
197 + let b = self.fg_word;
198 + Attrs {
199 + bold: b & ATTR_BOLD != 0,
200 + italic: b & ATTR_ITALIC != 0,
201 + underline: b & ATTR_UNDERLINE != 0,
202 + reverse: b & ATTR_REVERSE != 0,
203 + dim: b & ATTR_DIM != 0,
204 + strikethrough: b & ATTR_STRIKE != 0,
205 + }
206 + }
207 +
208 + // Hot-path attribute readers — used by the renderer's per-frame fills
209 + // scan, which touches every visible cell. Avoids materializing `Attrs`.
210 + #[inline]
211 + pub fn reverse(&self) -> bool {
212 + self.fg_word & ATTR_REVERSE != 0
213 + }
214 +
215 + #[inline]
216 + pub fn underline(&self) -> bool {
217 + self.fg_word & ATTR_UNDERLINE != 0
218 + }
219 +
220 + /// Fast "does this cell need a bg fill drawn" check for the fills scan.
221 + /// True when bg is not Default OR reverse is set.
222 + #[inline]
223 + pub fn has_bg(&self) -> bool {
224 + (self.bg_word & SRC_MASK) != 0 || self.reverse()
225 + }
226 +
227 + /// Whether this cell holds a character that covers the column after it too.
228 + #[inline]
229 + pub fn is_wide(&self) -> bool {
230 + self.bg_word & FLAG_WIDE != 0
231 + }
232 +
233 + /// Whether this cell is the second column of the character before it.
234 + ///
235 + /// A spacer is not a character of its own. Anything turning cells back into
236 + /// text — copy, emit, word boundaries — has to skip it, or one `日` comes
237 + /// out as a `日` and a space.
238 + #[inline]
239 + pub fn is_spacer(&self) -> bool {
240 + self.bg_word & FLAG_SPACER != 0
241 + }
242 +
243 + /// How many columns this cell's character occupies: 2 on the lead of a
244 + /// wide pair, 1 otherwise (a spacer included — it is one column, it just
245 + /// is not its own character).
246 + #[inline]
247 + pub fn cols(&self) -> u16 {
248 + if self.is_wide() { 2 } else { 1 }
249 + }
250 +
251 + /// The column a wide character could not fit into at the right edge.
252 + ///
253 + /// A spacer with no lead in front of it: it holds a column on screen and is
254 + /// not a character, which is exactly what that column is.
255 + pub(crate) fn pad() -> Self {
256 + Self {
257 + bg_word: FLAG_SPACER,
258 + ..Self::default()
259 + }
260 + }
261 +
262 + /// The lead of a wide pair, and the spacer that follows it.
263 + pub(crate) fn wide_pair(c: char, fg_word: u32, bg_word: u32) -> (Self, Self) {
264 + let bg_word = bg_word & !FLAG_WIDTH_MASK;
265 + (
266 + Self {
267 + c_raw: c as u32,
268 + fg_word,
269 + bg_word: bg_word | FLAG_WIDE,
270 + },
271 + Self {
272 + c_raw: ' ' as u32,
273 + fg_word,
274 + bg_word: bg_word | FLAG_SPACER,
275 + },
276 + )
277 + }
278 + }
279 +
280 + /// The combining-mark sequences the cells refer to by id.
281 + ///
282 + /// Append-only and interned. Real text draws from a handful of distinct
283 + /// sequences however much of it goes past — one `U+0301` is every acute accent
284 + /// on screen — so this stays in the dozens and never needs freeing. Interning
285 + /// the marks rather than whole clusters is what keeps it that small: the bases
286 + /// they attach to are what vary.
287 + #[derive(Debug, Default)]
288 + pub(crate) struct MarkTable {
289 + /// Id `n` is at index `n - 1`; id 0 means a cell has no marks and is never
290 + /// stored.
291 + seqs: Vec<Box<[char]>>,
292 + ids: HashMap<Box<[char]>, u16>,
293 + }
294 +
295 + impl MarkTable {
296 + /// The marks for `id`, or empty for id 0.
297 + pub(crate) fn get(&self, id: u16) -> &[char] {
298 + match id.checked_sub(1) {
299 + Some(i) => self.seqs.get(i as usize).map_or(&[], |s| s),
300 + None => &[],
301 + }
302 + }
303 +
304 + /// The id for a sequence, interning it if it is new.
305 + ///
306 + /// `None` once the id space is exhausted, which is the caller's cue to drop
307 + /// the mark. Refusing to store a mark keeps the cell as it was; there is no
308 + /// id it could be given that would not mean somebody else's marks.
309 + pub(crate) fn intern(&mut self, seq: &[char]) -> Option<u16> {
310 + if let Some(id) = self.ids.get(seq) {
311 + return Some(*id);
312 + }
313 + let id = u16::try_from(self.seqs.len() + 1).ok()?;
314 + if u32::from(id) > MARKS_LIMIT {
315 + return None;
316 + }
317 + let seq: Box<[char]> = seq.into();
318 + self.seqs.push(seq.clone());
319 + self.ids.insert(seq, id);
320 + Some(id)
321 + }
322 + }
323 +
324 + /// How many columns a character occupies, as the application computing its own
325 + /// cursor moves will have counted it.
326 + ///
327 + /// Zero is a real answer here rather than a degenerate one: a combining mark
328 + /// occupies no column of its own, it modifies the one before it. `place_char`
329 + /// reads it as an instruction to amend rather than to place.
330 + ///
331 + /// A character with no width at all (a control) counts as one. `execute`
332 + /// handles C0 so one should not reach a print, and one column is the answer
333 + /// that leaves the cursor where the application put it if one does.
334 + pub(crate) fn char_cols(c: char) -> u16 {
335 + match unicode_width::UnicodeWidthChar::width(c) {
336 + Some(2) => 2,
337 + Some(0) => 0,
338 + _ => 1,
339 + }
340 + }
341 +
342 + impl Default for Cell {
343 + fn default() -> Self {
344 + Self {
345 + c_raw: ' ' as u32,
346 + fg_word: 0,
347 + bg_word: 0,
348 + }
349 + }
350 + }
351 +
352 + const _: () = assert!(std::mem::size_of::<Cell>() == 12);
353 +
354 + #[cfg(test)]
355 + mod tests {
356 + use crate::*;
357 +
358 + #[test]
359 + fn a_cell_is_still_twelve_bytes_and_still_copy() {
360 + // The property the whole layout choice exists to protect: cells move by
361 + // memcpy through resize, the ring origin and history.
362 + assert_eq!(std::mem::size_of::<Cell>(), 12);
363 + let a = Cell::default();
364 + let b = a;
365 + assert_eq!(a, b);
366 + }
367 + }
@@ -1,0 +1,632 @@
1 + //! Editing the active screen: newline and the scrolling region, line and
2 + //! column insert/delete, the erases, cursor save/restore, and the alt-screen
3 + //! swap.
4 + //!
5 + //! Every entry point here is called from the CSI or ESC dispatch in
6 + //! [`crate::perform`] and from nowhere else.
7 +
8 + use crate::{Cell, Grid};
9 +
10 + impl Grid {
11 + pub(crate) fn newline(&mut self) {
12 + if self.cursor.row < self.scroll_bottom {
13 + self.cursor.row += 1;
14 + } else {
15 + self.scroll_up_in_region(1);
16 + }
17 + self.cursor.wrap_next = false;
18 + // Cursor row moved, or the ring rotated under it — either way the
19 + // cached row start is stale. place_char re-derives on next use.
20 + self.invalidate_cur_row();
21 + }
22 +
23 + pub(crate) fn scroll_up_in_region(&mut self, n: u16) {
24 + let region_size = self.scroll_bottom - self.scroll_top + 1;
25 + let n = n.min(region_size);
26 + if n == 0 {
27 + return;
28 + }
29 + if self.is_partial_region() {
30 + // Partial region: ring-rotate within the region. active_origin is
31 + // guaranteed 0 in partial mode (unrolled on DECSTBM entry).
32 + let rs = region_size as u32;
33 + let old_region_origin = self.region_origin as u32;
34 + self.region_origin = ((old_region_origin + n as u32) % rs) as u16;
35 + for k in 0..n {
36 + let phys_in_region = (old_region_origin + k as u32) % rs;
37 + self.blank_physical_row(self.scroll_top + phys_in_region as u16);
38 + }
39 + // Partial-region scrolls don't propagate to the renderer's cache
40 + // rotation; mark exposed rows dirty for rebuild.
41 + for r in (self.scroll_bottom + 1 - n)..=self.scroll_bottom {
42 + self.mark_row_dirty(r);
43 + }
44 + } else {
45 + // Fullscreen fast path: O(1) origin shift + blank exposed rows.
46 + let old_origin = self.active_origin();
47 + self.advance_origin(n as i32);
48 + for k in 0..n {
49 + let phys = (old_origin as u32 + k as u32) % self.rows as u32;
50 + // Before blanking, not after: this row is leaving the screen
51 + // and the copy into history is the only thing that keeps it.
52 + self.push_history(phys as u16);
53 + self.blank_physical_row(phys as u16);
54 + }
55 + self.pending_scroll = self.pending_scroll.saturating_add(n as i16);
56 + for r in (self.rows - n)..self.rows {
57 + self.row_dirty[r as usize] = true;
58 + }
59 + }
60 + }
61 +
62 + pub(crate) fn scroll_down_in_region(&mut self, n: u16) {
63 + let region_size = self.scroll_bottom - self.scroll_top + 1;
64 + let n = n.min(region_size);
65 + if n == 0 {
66 + return;
67 + }
68 + if self.is_partial_region() {
69 + let rs = region_size as u32;
70 + let new_region_origin = ((self.region_origin as u32 + rs - n as u32) % rs) as u16;
71 + self.region_origin = new_region_origin;
72 + for k in 0..n {
73 + let phys_in_region = (new_region_origin as u32 + k as u32) % rs;
74 + self.blank_physical_row(self.scroll_top + phys_in_region as u16);
75 + }
76 + for r in self.scroll_top..(self.scroll_top + n) {
77 + self.mark_row_dirty(r);
78 + }
79 + } else {
80 + self.advance_origin(-(n as i32));
81 + for k in 0..n {
82 + self.blank_physical_row(
83 + (self.active_origin() as u32 + k as u32) as u16 % self.rows,
84 + );
85 + }
86 + self.pending_scroll = self.pending_scroll.saturating_sub(n as i16);
87 + for r in 0..n {
88 + self.row_dirty[r as usize] = true;
89 + }
90 + }
91 + }
92 +
93 + /// DECSC. Per-screen, so the alt screen's save cannot reach the main
94 + /// screen's slot.
95 + pub(crate) fn save_cursor(&mut self) {
96 + self.dec_saved[self.on_alt as usize] = self.cursor;
97 + }
98 +
99 + /// DECRC. A restore with no matching save puts the cursor home, which is
100 + /// what the default slot holds.
101 + pub(crate) fn restore_cursor(&mut self) {
102 + let mut c = self.dec_saved[self.on_alt as usize];
103 + // The screen may have shrunk since the save. A cursor off the end of
104 + // it is not a position anything can draw at.
105 + c.row = c.row.min(self.rows - 1);
106 + c.col = c.col.min(self.cols - 1);
107 + self.cursor = c;
108 + }
109 +
110 + /// Whether logical row `r` of the active screen runs onto the next.
111 + ///
112 + /// Screen-relative, unlike the public [`row_wrapped`](Self::row_wrapped),
113 + /// which takes a viewport row and may answer out of history. The row
114 + /// movers below deal in screen rows, and reading the viewport's numbering
115 + /// here would move the wrong flags whenever the user had scrolled back.
116 + fn screen_row_wrapped(&self, r: u16) -> bool {
117 + let phys = self.phys_row(r) as usize;
118 + let flags = if self.on_alt {
119 + &self.alt_wrapped
120 + } else {
121 + &self.main_wrapped
122 + };
123 + flags.get(phys).copied().unwrap_or(false)
124 + }
125 +
126 + /// Copy one whole screen row onto another, contents and wrap flag both.
127 + ///
128 + /// Goes through `row_start` per row rather than moving a span: logical
129 + /// rows are a ring, so two rows adjacent on screen need not be adjacent in
130 + /// memory, and a bulk move would shuffle the ring instead of the screen.
131 + fn copy_row(&mut self, src: u16, dst: u16) {
132 + if src == dst {
133 + return;
134 + }
135 + let wrapped = self.screen_row_wrapped(src);
136 + let (s, d) = (self.row_start(src), self.row_start(dst));
137 + let cols = self.cols as usize;
138 + self.active_cells_mut().copy_within(s..s + cols, d);
139 + self.set_row_wrapped(dst, wrapped);
140 + }
141 +
142 + fn blank_screen_row(&mut self, r: u16) {
143 + self.erase_line_range(r, 0, self.cols);
144 + self.set_row_wrapped(r, false);
145 + }
146 +
147 + /// Blank any half of a wide pair whose other half is gone.
148 + ///
149 + /// The column movers shift a run of cells sideways, and a shift can cut a
150 + /// pair in two: the lead of a wide character can be pushed off the right
151 + /// edge, or a spacer can be pulled away from its lead. Either half left
152 + /// alone is a cell lying about what it holds — the same reasoning
153 + /// `erase_line_range` applies at its ends, applied to the whole row
154 + /// because a shift can break a pair anywhere along it.
155 + fn heal_wide_pairs(&mut self, row: u16) {
156 + let start = self.row_start(row);
157 + let cols = self.cols as usize;
158 + let cells = &mut self.active_cells_mut()[start..start + cols];
159 + for i in 0..cols {
160 + let orphan_lead = cells[i].is_wide() && !cells.get(i + 1).is_some_and(Cell::is_spacer);
161 + let orphan_spacer = cells[i].is_spacer() && !(i > 0 && cells[i - 1].is_wide());
162 + if orphan_lead || orphan_spacer {
163 + cells[i] = Cell::default();
164 + }
165 + }
166 + }
167 +
168 + /// IL. Open `n` blank lines at the cursor, pushing what follows down and
169 + /// off the bottom of the scrolling region.
170 + ///
171 + /// Ignored when the cursor sits outside the region: the region is the part
172 + /// of the screen the program has claimed, and an insert from outside it
173 + /// would move rows it does not own.
174 + pub(crate) fn insert_lines(&mut self, n: u16) {
175 + if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom {
176 + return;
177 + }
178 + let top = self.cursor.row;
179 + let n = n.min(self.scroll_bottom - top + 1);
180 + if n == 0 {
181 + return;
182 + }
183 + // Downward, so a row is read before the copy that overwrites it.
184 + for r in (top + n..=self.scroll_bottom).rev() {
185 + self.copy_row(r - n, r);
186 + }
187 + for r in top..top + n {
188 + self.blank_screen_row(r);
189 + }
190 + // The row above the opening no longer runs into what is now a blank.
191 + if top > 0 {
192 + self.set_row_wrapped(top - 1, false);
193 + }
194 + for r in top..=self.scroll_bottom {
195 + self.mark_row_dirty(r);
196 + }
197 + // DEC puts the cursor at the left margin, and enough programs rely on
198 + // it that leaving the column alone is the surprising choice.
199 + self.cursor.col = 0;
200 + self.cursor.wrap_next = false;
201 + }
202 +
203 + /// DL. Remove `n` lines at the cursor, pulling the rest of the scrolling
204 + /// region up and blanking what it vacates at the bottom.
205 + pub(crate) fn delete_lines(&mut self, n: u16) {
206 + if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom {
207 + return;
208 + }
209 + let top = self.cursor.row;
210 + let n = n.min(self.scroll_bottom - top + 1);
211 + if n == 0 {
212 + return;
213 + }
214 + // Written as one exclusive boundary rather than two inclusive ranges,
215 + // because `scroll_bottom - n` underflows when the delete covers the
216 + // whole region from its top row: `n` is clamped to the region size, so
217 + // n == scroll_bottom + 1 is reachable with top == 0. A panic in debug,
218 + // and in release a range running to about 65,000 that hands `copy_row`
219 + // rows off the end of the ring. `scroll_bottom + 1 - n` cannot
220 + // underflow, since n is at most scroll_bottom - top + 1. Found by the
221 + // soak oracle, 2026-08-29.
222 + let keep_end = self.scroll_bottom + 1 - n;
223 + for r in top..keep_end {
224 + self.copy_row(r + n, r);
225 + }
226 + for r in keep_end..=self.scroll_bottom {
227 + self.blank_screen_row(r);
228 + }
229 + if top > 0 {
230 + self.set_row_wrapped(top - 1, false);
231 + }
232 + for r in top..=self.scroll_bottom {
233 + self.mark_row_dirty(r);
234 + }
235 + self.cursor.col = 0;
236 + self.cursor.wrap_next = false;
237 + }
238 +
239 + /// ICH. Open `n` blank cells at the cursor, pushing the rest of the line
240 + /// right and off the edge. The cursor does not move.
241 + pub(crate) fn insert_chars(&mut self, n: u16) {
242 + let row = self.cursor.row;
243 + let col = self.cursor.col;
244 + let cols = self.cols;
245 + let n = n.min(cols - col);
246 + if n == 0 {
247 + return;
248 + }
249 + let start = self.row_start(row);
250 + let (c, k, w) = (col as usize, n as usize, cols as usize);
251 + let cells = &mut self.active_cells_mut()[start..start + w];
252 + cells.copy_within(c..w - k, c + k);
253 + for cell in &mut cells[c..c + k] {
254 + *cell = Cell::default();
255 + }
256 + self.heal_wide_pairs(row);
257 + // Whatever ran off the right edge is gone, so the line stops here.
258 + self.set_row_wrapped(row, false);
259 + self.mark_row_dirty(row);
260 + }
261 +
262 + /// DCH. Remove `n` cells at the cursor, pulling the rest of the line left
263 + /// and blanking the tail it vacates.
264 + pub(crate) fn delete_chars(&mut self, n: u16) {
265 + let row = self.cursor.row;
266 + let col = self.cursor.col;
267 + let cols = self.cols;
268 + let n = n.min(cols - col);
269 + if n == 0 {
270 + return;
271 + }
272 + let start = self.row_start(row);
273 + let (c, k, w) = (col as usize, n as usize, cols as usize);
274 + let cells = &mut self.active_cells_mut()[start..start + w];
275 + cells.copy_within(c + k..w, c);
276 + for cell in &mut cells[w - k..] {
277 + *cell = Cell::default();
278 + }
279 + self.heal_wide_pairs(row);
280 + self.set_row_wrapped(row, false);
281 + self.mark_row_dirty(row);
282 + }
283 +
284 + /// ECH. Blank `n` cells from the cursor without moving anything. The
285 + /// difference from DCH is the whole point: the tail of the line stays
286 + /// where it is.
287 + pub(crate) fn erase_chars(&mut self, n: u16) {
288 + let row = self.cursor.row;
289 + let col = self.cursor.col;
290 + let end = col.saturating_add(n).min(self.cols);
291 + self.erase_line_range(row, col, end);
292 + self.mark_row_dirty(row);
293 + }
294 +
295 + pub(crate) fn erase_line(&mut self, mode: u16) {
296 + let row = self.cursor.row;
297 + let col = self.cursor.col;
298 + let (start_col, end_col) = match mode {
299 + 1 => (0, col + 1), // start to cursor
300 + 2 => (0, self.cols), // whole line
301 + _ => (col, self.cols), // 0: cursor to end
302 + };
303 + self.erase_line_range(row, start_col, end_col);
304 + self.mark_row_dirty(row);
305 + }
306 +
307 + pub(crate) fn erase_display(&mut self, mode: u16) {
308 + // Iterate LOGICAL rows — physical layout is a ring, so contiguous
309 + // "erase from cursor to end" isn't contiguous in memory.
310 + let cursor_row = self.cursor.row;
311 + let cursor_col = self.cursor.col;
312 + match mode {
313 + 1 => {
314 + // Start of screen to cursor (inclusive).
315 + for r in 0..cursor_row {
316 + self.blank_logical_row(r);
317 + }
318 + self.erase_line_range(cursor_row, 0, cursor_col + 1);
319 + }
320 + 2 | 3 => {
321 + for r in 0..self.rows {
322 + self.blank_logical_row(r);
323 + }
324 + }
325 + _ => {
326 + // Cursor to end of screen (inclusive).
327 + self.erase_line_range(cursor_row, cursor_col, self.cols);
328 + for r in (cursor_row + 1)..self.rows {
329 + self.blank_logical_row(r);
330 + }
331 + }
332 + }
333 + self.mark_all_rows_dirty();
334 + }
335 +
336 + fn erase_line_range(&mut self, row: u16, start_col: u16, end_col: u16) {
337 + let row_start = self.row_start(row);
338 + let end_col = end_col.min(self.cols);
339 + let start_col = start_col.min(end_col);
340 + let cols = self.cols;
341 + let cells = self.active_cells_mut();
342 + for cell in &mut cells[row_start + start_col as usize..row_start + end_col as usize] {
343 + *cell = Cell::default();
344 + }
345 + // An erase can start or stop in the middle of a wide character. The
346 + // half outside the range goes too: half a character is not a narrower
347 + // character, it is a cell lying about what it holds.
348 + if start_col > 0 && cells[row_start + start_col as usize - 1].is_wide() {
349 + cells[row_start + start_col as usize - 1] = Cell::default();
350 + }
351 + if end_col < cols && cells[row_start + end_col as usize].is_spacer() {
352 + cells[row_start + end_col as usize] = Cell::default();
353 + }
354 + // Erasing through the right edge destroys whatever ran off it, so the
355 + // row no longer continues onto the next.
356 + if end_col == self.cols {
357 + self.set_row_wrapped(row, false);
358 + }
359 + }
360 +
361 + pub(crate) fn swap_alt(&mut self, to_alt: bool) {
362 + if self.on_alt == to_alt {
363 + return;
364 + }
365 + // region_origin belongs to the currently-active screen; unroll before
366 + // switching so the other screen starts with region_origin = 0.
367 + self.unroll_region();
368 + // An application taking the alt screen is taking the whole window, so
369 + // a viewport parked in main's history has nothing left to show. Going
370 + // the other way, the user is put back where the shell is, not where
371 + // they were reading before vim opened.
372 + self.view_offset = 0;
373 + if to_alt {
374 + self.swap_saved[0] = self.cursor;
375 + self.on_alt = true;
376 + for cell in &mut self.alt {
377 + *cell = Cell::default();
378 + }
379 + self.alt_wrapped.fill(false);
380 + self.cursor = self.swap_saved[1];
381 + } else {
382 + self.swap_saved[1] = self.cursor;
383 + self.on_alt = false;
384 + self.cursor = self.swap_saved[0];
385 + }
386 + self.pending_screen_swap = true;
387 + self.mark_all_rows_dirty();
388 + }
389 +
390 + pub(crate) fn set_cursor(&mut self, row: u16, col: u16) {
391 + // Terminal params are 1-indexed; convert.
392 + let row = row.saturating_sub(1).min(self.rows - 1);
393 + let col = col.saturating_sub(1).min(self.cols - 1);
394 + self.cursor.row = row;
395 + self.cursor.col = col;
396 + self.cursor.wrap_next = false;
397 + }
398 +
399 + pub(crate) fn move_by(&mut self, drow: i32, dcol: i32) {
400 + let r = (self.cursor.row as i32 + drow).clamp(0, self.rows as i32 - 1) as u16;
401 + let c = (self.cursor.col as i32 + dcol).clamp(0, self.cols as i32 - 1) as u16;
402 + self.cursor.row = r;
403 + self.cursor.col = c;
404 + self.cursor.wrap_next = false;
405 + }
406 + }
407 +
408 + #[cfg(test)]
409 + mod tests {
410 + use crate::testutil::{feed, reply_to, row_str};
411 + use crate::*;
412 +
413 + // ---- erase ---------------------------------------------------------
414 +
415 + #[test]
416 + fn el0_erases_cursor_to_end() {
417 + let mut g = Grid::new(10, 2);
418 + feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[K");
419 + assert_eq!(row_str(&g, 0), "ABC");
420 + }
421 +
422 + #[test]
423 + fn el1_erases_start_to_cursor() {
424 + let mut g = Grid::new(10, 2);
425 + feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[1K");
426 + // Cells 0..=3 cleared, 4..=9 kept.
427 + assert_eq!(row_str(&g, 0), " EFGHIJ");
428 + }
429 +
430 + #[test]
431 + fn el2_erases_whole_line() {
432 + let mut g = Grid::new(10, 2);
433 + feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[2K");
434 + assert_eq!(row_str(&g, 0), "");
435 + }
436 +
437 + #[test]
438 + fn ed2_erases_whole_screen() {
439 + let mut g = Grid::new(6, 3);
440 + feed(&mut g, b"aaaaaa\r\nbbbbbb\r\ncccccc\x1b[2J");
441 + for r in 0..3 {
442 + assert_eq!(row_str(&g, r), "");
443 + }
444 + }
445 +
446 + // ---- scroll region + newline --------------------------------------
447 +
448 + #[test]
449 + fn newline_at_bottom_scrolls_up() {
450 + let mut g = Grid::new(6, 3);
451 + feed(&mut g, b"aaa\r\nbbb\r\nccc\r\nddd");
452 + // Last write scrolled: row 0 was aaa, is now bbb; row 1 ccc; row 2 ddd.
453 + assert_eq!(row_str(&g, 0), "bbb");
454 + assert_eq!(row_str(&g, 1), "ccc");
455 + assert_eq!(row_str(&g, 2), "ddd");
456 + }
457 +
458 + #[test]
459 + fn reverse_index_at_top_scrolls_down() {
460 + let mut g = Grid::new(6, 3);
461 + feed(&mut g, b"aaa\r\nbbb\r\nccc\x1b[H\x1bM");
462 + // RI from row 0 pushes row 0 down; row 0 blanked.
463 + assert_eq!(row_str(&g, 0), "");
464 + assert_eq!(row_str(&g, 1), "aaa");
465 + assert_eq!(row_str(&g, 2), "bbb");
466 + }
467 +
468 + #[test]
469 + fn insert_lines_pushes_the_rest_down_and_off_the_bottom() {
470 + let mut g = Grid::new(6, 4);
471 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour");
472 + feed(&mut g, b"\x1b[2;1H\x1b[L"); // row 1, insert one line
473 + assert_eq!(row_str(&g, 0), "one");
474 + assert_eq!(row_str(&g, 1), "");
475 + assert_eq!(row_str(&g, 2), "two");
476 + assert_eq!(row_str(&g, 3), "three");
477 + // "four" fell off the bottom rather than scrolling into history.
478 + assert_eq!(g.history_len(), 0);
479 + }
480 +
481 + #[test]
482 + fn insert_lines_moves_the_right_rows_after_a_scroll() {
483 + // The ring's origin is non-zero here, so a row mover that walked
484 + // memory instead of the logical rows would shuffle the wrong ones.
485 + let mut g = Grid::new(6, 3);
486 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
487 + assert_eq!(row_str(&g, 0), "three");
488 + feed(&mut g, b"\x1b[1;1H\x1b[L");
489 + assert_eq!(row_str(&g, 0), "");
490 + assert_eq!(row_str(&g, 1), "three");
491 + assert_eq!(row_str(&g, 2), "four");
492 + }
493 +
494 + #[test]
495 + fn delete_lines_pulls_the_rest_up_and_blanks_the_bottom() {
496 + let mut g = Grid::new(6, 4);
497 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour");
498 + feed(&mut g, b"\x1b[2;1H\x1b[M");
499 + assert_eq!(row_str(&g, 0), "one");
500 + assert_eq!(row_str(&g, 1), "three");
Lines truncated
@@ -1,0 +1,587 @@
1 + //! Scrollback: the rows that left the top of the main screen, the viewport
2 + //! that reads back into them, and the rewrap that keeps them the grid's width.
3 +
4 + use crate::{Cell, Grid, HistoryRow};
5 + use std::collections::VecDeque;
6 +
7 + impl Grid {
8 + /// The history row backing visible row `r`, if the viewport is far enough
9 + /// back that `r` falls in it.
10 + pub(crate) fn history_row(&self, r: u16) -> Option<&HistoryRow> {
11 + if r >= self.view_offset {
12 + return None;
13 + }
14 + // The viewport's top row is `view_offset` rows above the live screen,
15 + // so it is that far from the end of history.
16 + let back = (self.view_offset - r) as usize;
17 + self.history
18 + .len()
19 + .checked_sub(back)
20 + .map(|i| &self.history[i])
21 + }
22 +
23 + /// The live logical row under visible row `r`. Only meaningful once `r` is
24 + /// known not to fall in history.
25 + pub(crate) fn live_row(&self, r: u16) -> u16 {
26 + r - self.view_offset
27 + }
28 +
29 + /// How far back the viewport sits, in rows. Zero is live.
30 + pub fn view_offset(&self) -> u16 {
31 + self.view_offset
32 + }
33 +
34 + /// Rows currently in scrollback.
35 + pub fn history_len(&self) -> usize {
36 + self.history.len()
37 + }
38 +
39 + /// Set how many rows of scrollback to keep, dropping the oldest if the new
40 + /// limit is smaller. Zero disables scrollback.
41 + pub fn set_history_limit(&mut self, limit: usize) {
42 + self.history_limit = limit;
43 + while self.history.len() > limit {
44 + self.history.pop_front();
45 + }
46 + // The viewport cannot point past what is left.
47 + self.set_view_offset(self.view_offset.min(self.history_len_u16()));
48 + }
49 +
50 + /// Move the viewport back into history by `n` rows, stopping at the oldest
51 + /// row kept. Returns whether it moved.
52 + pub fn scroll_view_up(&mut self, n: u16) -> bool {
53 + let want = self
54 + .view_offset
55 + .saturating_add(n)
56 + .min(self.history_len_u16());
57 + self.set_view_offset(want)
58 + }
59 +
60 + /// Move the viewport toward the live screen by `n` rows. Returns whether
61 + /// it moved.
62 + pub fn scroll_view_down(&mut self, n: u16) -> bool {
63 + let want = self.view_offset.saturating_sub(n);
64 + self.set_view_offset(want)
65 + }
66 +
67 + /// Snap the viewport back to the live screen. Returns whether it moved.
68 + ///
69 + /// This is what typing does: input goes to a program whose output is at the
70 + /// bottom, so leaving the user reading history while their keystrokes land
71 + /// somewhere off-screen would be a lie about where they are.
72 + pub fn scroll_view_to_bottom(&mut self) -> bool {
73 + self.set_view_offset(0)
74 + }
75 +
76 + fn set_view_offset(&mut self, want: u16) -> bool {
77 + // The alt screen has no history, so there is nowhere to go.
78 + let want = if self.on_alt { 0 } else { want };
79 + if want == self.view_offset {
80 + return false;
81 + }
82 + self.view_offset = want;
83 + self.view_dirty = true;
84 + true
85 + }
86 +
87 + pub(crate) fn history_len_u16(&self) -> u16 {
88 + self.history.len().min(u16::MAX as usize) as u16
89 + }
90 +
91 + /// Push the row about to be overwritten into history, and keep the viewport
92 + /// looking at the same content if it is back in history.
93 + ///
94 + /// Called only from the fullscreen main-screen scroll. A partial scroll
95 + /// region is an application drawing inside a box — the row leaving the top
96 + /// of that box has not left the screen — and the alt screen keeps none.
97 + pub(crate) fn push_history(&mut self, phys: u16) {
98 + if self.on_alt || self.history_limit == 0 {
99 + return;
100 + }
101 + let cols = self.cols as usize;
102 + let start = phys as usize * cols;
103 + let wrapped = self
104 + .main_wrapped
105 + .get(phys as usize)
106 + .copied()
107 + .unwrap_or(false);
108 + // Recycle the evicted row's buffer rather than freeing one and
109 + // allocating another. Scrolling is the throughput case the ring layout
110 + // exists for, and once history is full — which a long build log reaches
111 + // in seconds — this makes the steady state a memcpy with no allocator
112 + // traffic behind it.
113 + let mut cells = if self.history.len() == self.history_limit {
114 + let recycled = self.history.pop_front().map(|row| row.cells);
115 + // The oldest row is gone, so a viewport anchored to it has to give
116 + // up a row rather than silently show different text.
117 + self.view_offset = self.view_offset.saturating_sub(1);
118 + recycled.unwrap_or_default()
119 + } else {
120 + Vec::new()
121 + };
122 + cells.clear();
123 + cells.extend_from_slice(&self.main[start..start + cols]);
124 + self.history.push_back(HistoryRow { cells, wrapped });
125 + // Pin the view: new output below should not drag what the user is
126 + // reading up the screen.
127 + if self.view_offset > 0 {
128 + self.view_offset = self
129 + .view_offset
130 + .saturating_add(1)
131 + .min(self.history_len_u16());
132 + self.view_dirty = true;
133 + }
134 + }
135 +
136 + /// Rewrap scrollback from `old_cols` to the width already stored in
137 + /// `self.cols`.
138 + ///
139 + /// The logical lines are recoverable from the materialized rows, so this
140 + /// needs no second representation: a maximal run of `wrapped` rows plus the
141 + /// row that ends it is one line a program printed, and `wrapped` is correct
142 + /// at the moment a row is pushed. Join those runs, re-split at the new
143 + /// width, and history is still a deque of exactly-`cols` rows — `row()` and
144 + /// every reader above it (selection, word boundaries, copy) is untouched.
145 + ///
146 + /// The alternative, storing history as logical lines and materializing rows
147 + /// on read, moves the cost to every frame and puts variable-width rows in
148 + /// front of every reader to buy nothing this does not.
149 + ///
150 + /// Costs one pass and a transient second copy of the buffer, at resize
151 + /// only. That is ~24 MB at the default limit and 200 columns, held for the
152 + /// length of a window drag.
153 + pub(crate) fn rewrap_history(&mut self, old_cols: u16) {
154 + if self.history.is_empty() || old_cols == 0 {
155 + return;
156 + }
157 + let cols = self.cols as usize;
158 + // Absolute index of the row the viewport's top sits on, if it is back
159 + // in history at all. Carried through as (logical line, cells into it)
160 + // so the text under the user's eye stays under it.
161 + let anchor_row = self.history.len().saturating_sub(self.view_offset as usize);
162 + // The newest line may run onto the live screen. Recorded before the
163 + // walk, because re-splitting otherwise decides the final row's flag
164 + // from the line's length and would break that join.
165 + let tail_continues = self.history.back().is_some_and(|r| r.wrapped);
166 +
167 + let mut lines: Vec<Vec<Cell>> = Vec::new();
168 + let mut anchor: Option<(usize, usize)> = None;
169 + // Whether the row being visited continues the line already open.
170 + let mut open = false;
171 + for (i, row) in self.history.iter().enumerate() {
172 + if !open {
173 + lines.push(Vec::new());
174 + }
175 + let li = lines.len() - 1;
176 + let line = lines.last_mut().expect("a line is open by here");
177 + if i == anchor_row {
178 + anchor = Some((li, line.len()));
179 + }
180 + // A logical line is the CHARACTERS the program printed, so the
181 + // spacers come out here and are re-derived at the new width. They
182 + // are not content: which column a wide character's second half
183 + // lands in is a fact about the old width, and carrying them through
184 + // would wedge stale blanks into the middle of the rewrapped line.
185 + // This is also what keeps a pair from being split by the re-split —
186 + // there is nothing to split, only a lead to place or defer.
187 + let end = if row.wrapped {
188 + // An interior row ran off the right edge, so it is full of
189 + // content by construction — nothing on it is padding, except a
190 + // pad column a wide character could not fit into, which drops
191 + // out with the rest of the spacers.
192 + row.cells.len()
193 + } else {
194 + // The last row of a line: its tail is padding, not content.
195 + // Only never-written cells count as padding. A space someone
196 + // typed is a cell like any other and keeps its background.
197 + row.cells
198 + .iter()
199 + .rposition(|c| *c != Cell::default())
200 + .map_or(0, |i| i + 1)
201 + };
202 + line.extend(row.cells[..end].iter().filter(|c| !c.is_spacer()).copied());
203 + open = row.wrapped;
204 + }
205 +
206 + let last_line = lines.len() - 1;
207 + let mut out: VecDeque<HistoryRow> = VecDeque::with_capacity(self.history.len());
208 + let mut new_anchor: Option<usize> = None;
209 + for (li, line) in lines.into_iter().enumerate() {
210 + let first = out.len();
211 + // Which row of THIS line the anchored character landed on. Counted
212 + // during the layout rather than divided out of an offset, because a
213 + // wide character can end a row one column early.
214 + let mut anchor_row_of_line: Option<usize> = None;
215 + // Only the newest line can be unterminated, and only if it was
216 + // running onto the live screen before the resize.
217 + let unterminated = li == last_line && tail_continues;
218 + if line.is_empty() {
219 + // A blank line is content: someone's output had a gap in it.
220 + // Never wrapped — an empty row holds nothing that could have
221 + // run off the edge.
222 + out.push_back(HistoryRow {
223 + cells: vec![Cell::default(); cols],
224 + wrapped: false,
225 + });
226 + } else {
227 + // Lay the characters out at the new width. A row ends when the
228 + // next character does not fit, which for a wide character can
229 + // be one column early — the column it cannot use becomes a pad,
230 + // the same as it would have on the way in.
231 + let mut cells: Vec<Cell> = Vec::with_capacity(cols);
232 + let mut rows_of_line = 0usize;
233 + for (ci, cell) in line.iter().enumerate() {
234 + let w = cell.cols() as usize;
235 + if cells.len() + w > cols {
236 + if cells.len() < cols {
237 + cells.push(Cell::pad());
238 + }
239 + cells.resize(cols, Cell::default());
240 + out.push_back(HistoryRow {
241 + cells: std::mem::take(&mut cells),
242 + wrapped: true,
243 + });
244 + rows_of_line += 1;
245 + cells.reserve(cols);
246 + }
247 + if anchor == Some((li, ci)) {
248 + anchor_row_of_line = Some(rows_of_line);
249 + }
250 + if w == 2 {
251 + let (lead, spacer) = Cell::wide_pair(cell.c(), cell.fg_word, cell.bg_word);
252 + cells.push(lead);
253 + cells.push(spacer);
254 + } else {
255 + cells.push(*cell);
256 + }
257 + }
258 + // The row the line ends on. It is wrapped only if the line ran
259 + // onto the live screen and still fills the new width: the live
260 + // screen is clipped rather than reflowed, so a flag on a
261 + // half-full row would be the same lie about where the text
262 + // leaves the edge that the live rows drop theirs for, and would
263 + // emit its padding as content on a copy.
264 + let full = cells.len() == cols;
265 + cells.resize(cols, Cell::default());
266 + out.push_back(HistoryRow {
267 + cells,
268 + wrapped: unterminated && full,
269 + });
270 + }
271 + if let Some((al, _)) = anchor
272 + && al == li
273 + {
274 + // Widening can put the anchor past the line's new end, in which
275 + // case that line's last row is the closest thing to it.
276 + new_anchor = Some(
277 + anchor_row_of_line.map_or(out.len() - 1, |r| (first + r).min(out.len() - 1)),
278 + );
279 + }
280 + }
281 + self.history = out;
282 +
283 + // Narrowing turns n rows into more than n, which can cross the limit.
284 + // Trim after the rewrap and not before, so the trim never cuts a
285 + // logical line in half and leaves its tail to be rewrapped alone.
286 + let over = self.history.len().saturating_sub(self.history_limit);
287 + self.history.drain(..over);
288 +
289 + let before = self.view_offset;
290 + self.view_offset = match new_anchor {
291 + // The anchored row itself can fall to the trim, and then the oldest
292 + // surviving row is the closest the viewport can get to it.
293 + Some(a) => {
294 + let a = a.saturating_sub(over);
295 + (self.history.len() - a).min(u16::MAX as usize) as u16
296 + }
297 + None => 0,
298 + };
299 + if self.view_offset != before {
300 + self.view_dirty = true;
301 + }
302 + }
303 + }
304 +
305 + #[cfg(test)]
306 + mod tests {
307 + use crate::testutil::{feed, history_text, row_str};
308 + use crate::*;
309 +
310 + #[test]
311 + fn rows_that_scroll_off_the_top_land_in_history() {
312 + let mut g = Grid::new(6, 3);
313 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
314 + assert_eq!(g.history_len(), 2);
315 + // Still live, so the screen reads as it did before scrollback existed.
316 + assert_eq!(row_str(&g, 0), "three");
317 + }
318 +
319 + #[test]
320 + fn scrolling_back_shows_the_rows_that_left() {
321 + let mut g = Grid::new(6, 3);
322 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
323 + assert!(g.scroll_view_up(2));
324 + assert_eq!(g.view_offset(), 2);
325 + assert_eq!(row_str(&g, 0), "one");
326 + assert_eq!(row_str(&g, 1), "two");
327 + assert_eq!(row_str(&g, 2), "three");
328 + }
329 +
330 + #[test]
331 + fn the_viewport_stops_at_the_oldest_row_kept() {
332 + let mut g = Grid::new(6, 3);
333 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
334 + assert!(g.scroll_view_up(999));
335 + assert_eq!(g.view_offset(), 2);
336 + // Already at the top: no move, so nothing asks for a redraw.
337 + assert!(!g.scroll_view_up(1));
338 + }
339 +
340 + #[test]
341 + fn output_under_a_scrolled_back_viewport_does_not_drag_it() {
342 + let mut g = Grid::new(6, 3);
343 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
344 + g.scroll_view_up(2);
345 + assert_eq!(row_str(&g, 0), "one");
346 + feed(&mut g, b"\r\nsix\r\nseven");
347 + // The reader is still looking at the same text, one row further back.
348 + assert_eq!(row_str(&g, 0), "one");
349 + assert_eq!(g.view_offset(), 4);
350 + }
351 +
352 + #[test]
353 + fn the_oldest_row_falls_off_at_the_limit() {
354 + let mut g = Grid::new(6, 3);
355 + g.set_history_limit(2);
356 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive\r\nsix");
357 + assert_eq!(g.history_len(), 2);
358 + g.scroll_view_up(2);
359 + // "one" is gone; the oldest kept row is what the top shows.
360 + assert_eq!(row_str(&g, 0), "two");
361 + }
362 +
363 + #[test]
364 + fn a_zero_limit_keeps_no_history() {
365 + let mut g = Grid::new(6, 3);
366 + g.set_history_limit(0);
367 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
368 + assert_eq!(g.history_len(), 0);
369 + assert!(!g.scroll_view_up(1));
370 + }
371 +
372 + #[test]
373 + fn the_alt_screen_neither_feeds_history_nor_scrolls_back() {
374 + let mut g = Grid::new(6, 3);
375 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
376 + let before = g.history_len();
377 + feed(&mut g, b"\x1b[?1049h"); // enter alt
378 + feed(&mut g, b"a\r\nb\r\nc\r\nd\r\ne");
379 + assert_eq!(g.history_len(), before, "alt screen wrote to history");
380 + assert!(!g.scroll_view_up(1));
381 + assert_eq!(g.view_offset(), 0);
382 + }
383 +
384 + #[test]
385 + fn taking_the_alt_screen_puts_the_viewport_back_at_the_bottom() {
386 + let mut g = Grid::new(6, 3);
387 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
388 + g.scroll_view_up(2);
389 + feed(&mut g, b"\x1b[?1049h");
390 + assert_eq!(g.view_offset(), 0);
391 + feed(&mut g, b"\x1b[?1049l"); // and back
392 + assert_eq!(g.view_offset(), 0);
393 + }
394 +
395 + #[test]
396 + fn a_partial_scroll_region_does_not_feed_history() {
397 + let mut g = Grid::new(6, 4);
398 + // DECSTBM rows 1-3: an application drawing in a box, so a row leaving
399 + // the top of that box has not left the screen.
400 + feed(&mut g, b"\x1b[1;3r");
401 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
402 + assert_eq!(g.history_len(), 0);
403 + }
404 +
405 + #[test]
406 + fn narrowing_wraps_a_long_history_row_instead_of_clipping_it() {
407 + let mut g = Grid::new(8, 2);
408 + feed(&mut g, b"abcdefgh\r\nsecond\r\nthird");
409 + g.scroll_view_up(1);
410 + assert_eq!(row_str(&g, 0), "abcdefgh");
411 + g.scroll_view_down(1);
412 + g.resize(4, 2);
413 + // The tail moved to a continuation row rather than being destroyed.
414 + assert_eq!(g.row(0).len(), 4, "history rows must be `cols` wide");
415 + assert!(history_text(&g).contains("abcdefgh"));
416 + }
417 +
418 + #[test]
419 + fn narrowing_then_widening_gives_the_logical_lines_back() {
420 + let mut g = Grid::new(8, 2);
421 + feed(&mut g, b"abcdefgh\r\nsecond\r\nthird");
422 + let before = history_text(&g);
423 + g.resize(4, 2);
424 + g.resize(8, 2);
425 + // The property the clipping code could not satisfy at any width.
426 + assert_eq!(history_text(&g), before);
427 + }
428 +
429 + #[test]
430 + fn a_line_exactly_cols_wide_gains_no_empty_continuation_row() {
431 + let mut g = Grid::new(4, 2);
432 + feed(&mut g, b"abcd\r\nxy\r\nz");
433 + let rows = g.history_len();
434 + g.resize(8, 2);
435 + g.resize(4, 2);
436 + assert_eq!(g.history_len(), rows, "a full row grew a continuation");
437 + }
438 +
439 + #[test]
440 + fn widening_rejoins_what_narrowing_split() {
441 + let mut g = Grid::new(4, 2);
442 + // "abcdefgh" wraps into two history rows at width 4.
443 + feed(&mut g, b"abcdefgh\r\nxy\r\nz");
444 + assert_eq!(g.history_len(), 2);
445 + g.resize(8, 2);
446 + assert_eq!(g.history_len(), 1, "the two halves did not rejoin");
447 + g.scroll_view_up(1);
448 + assert_eq!(g.row(0).len(), 8);
449 + assert_eq!(row_str(&g, 0), "abcdefgh");
450 + }
451 +
452 + #[test]
453 + fn a_blank_history_line_survives_a_rewrap() {
454 + let mut g = Grid::new(8, 2);
455 + feed(&mut g, b"one\r\n\r\ntwo\r\nthree");
456 + let before = history_text(&g);
457 + g.resize(4, 2);
458 + g.resize(8, 2);
459 + assert_eq!(history_text(&g), before, "the gap in the output closed");
460 + }
461 +
462 + #[test]
463 + fn narrowing_trims_to_the_limit_after_rewrapping_not_before() {
464 + let mut g = Grid::new(8, 2);
465 + g.set_history_limit(3);
466 + feed(&mut g, b"abcdefgh\r\nijklmnop\r\nqrst\r\nuvwx\r\nlast");
467 + g.resize(4, 2);
468 + assert_eq!(g.history_len(), 3, "the limit did not hold across a rewrap");
469 + // The newest rows survive and they are whole: the trim came after the
470 + // rewrap, so no line was cut in half and its tail rewrapped alone.
471 + assert!(
472 + history_text(&g).ends_with("mnop\nqrst\n"),
473 + "{:?}",
474 + history_text(&g)
475 + );
476 + }
477 +
478 + #[test]
479 + fn the_viewport_keeps_the_row_it_was_reading_across_a_rewrap() {
480 + let mut g = Grid::new(8, 2);
481 + feed(&mut g, b"aaaaaaaa\r\nbbbb\r\ncccc\r\ndddd\r\nlive");
482 + g.scroll_view_up(2);
483 + let reading = row_str(&g, 0);
484 + assert_eq!(reading, "bbbb");
485 + g.resize(4, 2);
486 + assert_eq!(row_str(&g, 0), reading, "narrowing moved the text");
487 + g.resize(8, 2);
488 + assert_eq!(row_str(&g, 0), reading, "widening moved the text");
489 + }
490 +
491 + #[test]
492 + fn a_history_row_running_onto_the_live_screen_still_joins_after_a_rewrap() {
493 + let mut g = Grid::new(8, 2);
494 + // The oldest line runs off the edge and continues onto the live
495 + // screen, so its wrap flag has to survive the rewrap.
496 + feed(&mut g, b"abcdefghijklmnopqrst");
497 + assert_eq!(g.history_len(), 1);
498 + g.resize(4, 2);
499 + let all = g.text_range(0, g.abs_rows());
500 + assert!(
Lines truncated
@@ -1,0 +1,341 @@
1 + //! Mouse reporting: what a program asked to be told, and how a report is
2 + //! spelled on the wire.
3 + //!
4 + //! The grid holds the two modes and does the encoding; deciding that the
5 + //! pointer did something is the binary's job.
6 +
7 + use crate::Grid;
8 +
9 + /// How much of the mouse a program has asked to be told about.
10 + ///
11 + /// Strictly increasing: each level includes everything below it, which is why
12 + /// one field holds all of them rather than a flag per DECSET number. Setting
13 + /// any level replaces the previous one, matching xterm — the modes are not
14 + /// composable there either, however much the separate numbers suggest it.
15 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
16 + pub enum MouseTracking {
17 + /// The pointer belongs to the user: shop selects text with it.
18 + #[default]
19 + Off,
20 + /// DECSET 9, X10 compatibility. Presses only, and no modifier bits.
21 + Press,
22 + /// DECSET 1000. Presses and releases.
23 + Click,
24 + /// DECSET 1002. Adds motion, but only while a button is held.
25 + Drag,
26 + /// DECSET 1003. Adds motion with no button down, which is a report per
27 + /// cell crossed for as long as the pointer is over the window.
28 + Motion,
29 + }
30 +
31 + /// How a mouse report is spelled on the wire.
32 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
33 + pub enum MouseEncoding {
34 + /// The original `CSI M Cb Cx Cy`, each field a byte biased by 32.
35 + ///
36 + /// Two consequences worth knowing, and both are why 1006 exists: a
37 + /// coordinate past 223 has no byte to land in and is dropped, and a
38 + /// release does not say which button was let go.
39 + #[default]
40 + X10,
41 + /// DECSET 1006. `CSI < b ; x ; y M` for a press, `m` for a release —
42 + /// decimal, so no coordinate ceiling, and the release keeps its button.
43 + Sgr,
44 + }
45 +
46 + /// Which button a mouse report is about.
47 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
48 + pub enum MouseButton {
49 + Left,
50 + Middle,
51 + Right,
52 + WheelUp,
53 + WheelDown,
54 + /// Motion with nothing held. Only [`MouseTracking::Motion`] asks for it.
55 + None,
56 + }
57 +
58 + impl MouseButton {
59 + /// The low bits the wire spells this button with. Wheel buttons set 64,
60 + /// which is the bit that distinguishes them from a real press.
61 + fn code(self) -> u8 {
62 + match self {
63 + Self::Left => 0,
64 + Self::Middle => 1,
65 + Self::Right => 2,
66 + Self::WheelUp => 64,
67 + Self::WheelDown => 65,
68 + // The same 3 a release uses. Unambiguous in context: this one
69 + // always arrives with the motion bit set.
70 + Self::None => 3,
71 + }
72 + }
73 +
74 + fn is_wheel(self) -> bool {
75 + matches!(self, Self::WheelUp | Self::WheelDown)
76 + }
77 + }
78 +
79 + /// What the pointer did.
80 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
81 + pub enum MouseAction {
82 + Press,
83 + Release,
84 + /// The pointer crossed into another cell. Whether a button is held is read
85 + /// from the report's button, not from here.
86 + Motion,
87 + }
88 +
89 + /// Modifiers held while the pointer did it.
90 + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
91 + pub struct MouseMods {
92 + pub shift: bool,
93 + pub alt: bool,
94 + pub ctrl: bool,
95 + }
96 +
97 + impl MouseMods {
98 + fn bits(self) -> u8 {
99 + u8::from(self.shift) * 4 + u8::from(self.alt) * 8 + u8::from(self.ctrl) * 16
100 + }
101 + }
102 +
103 + /// One thing the pointer did, in grid coordinates, ready to be encoded.
104 + ///
105 + /// Cells, 0-based, as the rest of this crate counts them. The +1 the wire
106 + /// wants is applied at encoding time and nowhere else.
107 + #[derive(Copy, Clone, Debug, PartialEq, Eq)]
108 + pub struct MouseReport {
109 + pub button: MouseButton,
110 + pub action: MouseAction,
111 + pub col: u16,
112 + pub row: u16,
113 + pub mods: MouseMods,
114 + }
115 +
116 + /// The largest coordinate X10's byte-per-field encoding can carry.
117 + ///
118 + /// A field is `32 + 1 + n` in one byte, so n stops at 222. Past that the
119 + /// report is dropped rather than truncated: a wrong coordinate tells the
120 + /// program the click was somewhere it wasn't, and a missing one tells it
121 + /// nothing, which is the smaller lie.
122 + const X10_COORD_MAX: u16 = 222;
123 +
124 + impl Grid {
125 + /// How much of the mouse the program has asked for. [`MouseTracking::Off`]
126 + /// means the pointer is the user's, for selecting text.
127 + pub fn mouse_tracking(&self) -> MouseTracking {
128 + self.mouse_tracking
129 + }
130 +
131 + /// Which spelling a mouse report should use.
132 + pub fn mouse_encoding(&self) -> MouseEncoding {
133 + self.mouse_encoding
134 + }
135 +
136 + /// The bytes this pointer event owes the program, or `None` when the
137 + /// program did not ask for it.
138 + ///
139 + /// Filtering lives here rather than at the call site because the levels
140 + /// are what decides it, and the levels are this crate's business. A
141 + /// caller reports everything the pointer does and lets the answer decide.
142 + pub fn encode_mouse(&self, r: MouseReport) -> Option<Vec<u8>> {
143 + if self.mouse_tracking == MouseTracking::Off || !self.mouse_coords_fit(r.col, r.row) {
144 + return None;
145 + }
146 + // The wheel has no release and no drag; it is a press or it is
147 + // nothing, at every level that reports the mouse at all.
148 + if r.button.is_wheel() {
149 + return (r.action == MouseAction::Press).then(|| self.spell_mouse(r));
150 + }
151 + let wanted = match r.action {
152 + MouseAction::Press => true,
153 + MouseAction::Release => self.mouse_tracking >= MouseTracking::Click,
154 + MouseAction::Motion if r.button == MouseButton::None => {
155 + self.mouse_tracking == MouseTracking::Motion
156 + }
157 + MouseAction::Motion => self.mouse_tracking >= MouseTracking::Drag,
158 + };
159 + wanted.then(|| self.spell_mouse(r))
160 + }
161 +
162 + fn spell_mouse(&self, r: MouseReport) -> Vec<u8> {
163 + let mut cb = r.button.code();
164 + if r.action == MouseAction::Motion {
165 + cb += 32;
166 + }
167 + // X10 compatibility mode predates modifier reporting, and a program
168 + // that asked for it is parsing three fixed bytes.
169 + if self.mouse_tracking != MouseTracking::Press {
170 + cb += r.mods.bits();
171 + }
172 + match self.mouse_encoding {
173 + MouseEncoding::Sgr => {
174 + let end = if r.action == MouseAction::Release {
175 + 'm'
176 + } else {
177 + 'M'
178 + };
179 + format!("\x1b[<{};{};{}{end}", cb, r.col + 1, r.row + 1).into_bytes()
180 + }
181 + MouseEncoding::X10 => {
182 + // The button a release let go of has nowhere to be spelled
183 + // here; 3 is "some button came up" and it is all the program
184 + // gets. This is the limitation 1006 exists to lift.
185 + if r.action == MouseAction::Release {
186 + cb = 3 + if self.mouse_tracking == MouseTracking::Press {
187 + 0
188 + } else {
189 + r.mods.bits()
190 + };
191 + }
192 + let mut out = vec![0x1b, b'[', b'M', 32 + cb];
193 + out.push(32 + 1 + r.col as u8);
194 + out.push(32 + 1 + r.row as u8);
195 + out
196 + }
197 + }
198 + }
199 +
200 + /// Whether a report at these coordinates can be spelled at all.
201 + ///
202 + /// Only X10 can fail, and only past its byte ceiling. Checked separately
203 + /// from encoding so a caller can drop the event before doing the work.
204 + pub fn mouse_coords_fit(&self, col: u16, row: u16) -> bool {
205 + self.mouse_encoding == MouseEncoding::Sgr || (col <= X10_COORD_MAX && row <= X10_COORD_MAX)
206 + }
207 + }
208 +
209 + #[cfg(test)]
210 + mod tests {
211 + use crate::testutil::feed;
212 + use crate::*;
213 +
214 + #[test]
215 + fn no_mouse_is_reported_until_a_program_asks() {
216 + let g = Grid::new(20, 10);
217 + assert_eq!(g.mouse_tracking(), MouseTracking::Off);
218 + assert_eq!(g.encode_mouse(press(2, 3)), None);
219 + }
220 +
221 + #[test]
222 + fn sgr_spells_a_press_and_a_release_differently() {
223 + let mut g = Grid::new(20, 10);
224 + feed(&mut g, b"\x1b[?1000h\x1b[?1006h");
225 + assert_eq!(bytes(&g, press(2, 3)), "\x1b[<0;3;4M");
226 + let mut up = press(2, 3);
227 + up.action = MouseAction::Release;
228 + // The button survives the release, which is what 1006 is for.
229 + assert_eq!(bytes(&g, up), "\x1b[<0;3;4m");
230 + }
231 +
232 + #[test]
233 + fn x10_biases_every_field_by_thirty_two() {
234 + let mut g = Grid::new(20, 10);
235 + feed(&mut g, b"\x1b[?1000h");
236 + assert_eq!(g.encode_mouse(press(2, 3)).unwrap(), b"\x1b[M\x20\x23\x24");
237 + }
238 +
239 + #[test]
240 + fn x10_drops_a_coordinate_it_cannot_spell() {
241 + let mut g = Grid::new(400, 400);
242 + feed(&mut g, b"\x1b[?1000h");
243 + assert_eq!(g.encode_mouse(press(300, 3)), None, "truncated instead");
244 + feed(&mut g, b"\x1b[?1006h");
245 + assert_eq!(bytes(&g, press(300, 3)), "\x1b[<0;301;4M");
246 + }
247 +
248 + #[test]
249 + fn click_tracking_reports_buttons_but_not_movement() {
250 + let mut g = Grid::new(20, 10);
251 + feed(&mut g, b"\x1b[?1000h\x1b[?1006h");
252 + let mut drag = press(2, 3);
253 + drag.action = MouseAction::Motion;
254 + assert_eq!(g.encode_mouse(drag), None);
255 + feed(&mut g, b"\x1b[?1002h");
256 + assert_eq!(bytes(&g, drag), "\x1b[<32;3;4M");
257 + }
258 +
259 + #[test]
260 + fn only_the_any_motion_level_reports_a_pointer_with_nothing_held() {
261 + let mut g = Grid::new(20, 10);
262 + feed(&mut g, b"\x1b[?1002h\x1b[?1006h");
263 + let hover = MouseReport {
264 + button: MouseButton::None,
265 + action: MouseAction::Motion,
266 + col: 2,
267 + row: 3,
268 + mods: MouseMods::default(),
269 + };
270 + assert_eq!(g.encode_mouse(hover), None);
271 + feed(&mut g, b"\x1b[?1003h");
272 + assert_eq!(bytes(&g, hover), "\x1b[<35;3;4M");
273 + }
274 +
275 + #[test]
276 + fn clearing_a_level_hands_the_pointer_back_to_the_user() {
277 + // Not "drop to the next level down": a program clearing 1002 is done
278 + // with the mouse, and shop takes the pointer back for selection.
279 + let mut g = Grid::new(20, 10);
280 + feed(&mut g, b"\x1b[?1002h\x1b[?1002l");
281 + assert_eq!(g.mouse_tracking(), MouseTracking::Off);
282 + }
283 +
284 + #[test]
285 + fn clearing_a_level_nobody_set_leaves_the_live_one_alone() {
286 + let mut g = Grid::new(20, 10);
287 + feed(&mut g, b"\x1b[?1003h\x1b[?1000l");
288 + assert_eq!(g.mouse_tracking(), MouseTracking::Motion);
289 + }
290 +
291 + #[test]
292 + fn modifiers_ride_along_except_in_x10_compatibility() {
293 + let mut g = Grid::new(20, 10);
294 + feed(&mut g, b"\x1b[?1000h\x1b[?1006h");
295 + let mut m = press(2, 3);
296 + m.mods = MouseMods {
297 + ctrl: true,
298 + ..MouseMods::default()
299 + };
300 + assert_eq!(bytes(&g, m), "\x1b[<16;3;4M");
301 + // Mode 9 predates modifier reporting and its readers parse fixed
302 + // fields, so the bits stay off there.
303 + feed(&mut g, b"\x1b[?1000l\x1b[?9h");
304 + assert_eq!(bytes(&g, m), "\x1b[<0;3;4M");
305 + }
306 +
307 + #[test]
308 + fn the_wheel_is_a_press_with_no_release() {
309 + let mut g = Grid::new(20, 10);
310 + feed(&mut g, b"\x1b[?1000h\x1b[?1006h");
311 + let mut w = press(2, 3);
312 + w.button = MouseButton::WheelUp;
313 + assert_eq!(bytes(&g, w), "\x1b[<64;3;4M");
314 + w.action = MouseAction::Release;
315 + assert_eq!(g.encode_mouse(w), None);
316 + }
317 +
318 + #[test]
319 + fn x10_compatibility_reports_the_press_and_stays_quiet_after() {
320 + let mut g = Grid::new(20, 10);
321 + feed(&mut g, b"\x1b[?9h\x1b[?1006h");
322 + assert!(g.encode_mouse(press(2, 3)).is_some());
323 + let mut up = press(2, 3);
324 + up.action = MouseAction::Release;
325 + assert_eq!(g.encode_mouse(up), None);
326 + }
327 +
328 + fn press(col: u16, row: u16) -> MouseReport {
329 + MouseReport {
330 + button: MouseButton::Left,
331 + action: MouseAction::Press,
332 + col,
333 + row,
334 + mods: MouseMods::default(),
335 + }
336 + }
337 +
338 + fn bytes(g: &Grid, r: MouseReport) -> String {
339 + String::from_utf8(g.encode_mouse(r).expect("nothing to send")).unwrap()
340 + }
341 + }
@@ -1,0 +1,719 @@
1 + //! The parser callbacks: printing, C0 execution, CSI, ESC and OSC dispatch.
2 + //!
3 + //! This is the only place a wire byte turns into a call on the grid. The work
4 + //! each arm does lives in the subsystem modules; what is here is the mapping
5 + //! from the escape sequence to it, plus the replies the query arms send back.
6 +
7 + use crate::{CursorShape, Grid, MouseEncoding, MouseTracking};
8 + use shop_vt::{Params, Perform};
9 + use tracing::trace;
10 +
11 + /// One channel as OSC 10/11 want it: four hex digits, the 8-bit value
12 + /// doubled. `0x25` becomes `2525`, which is the 16-bit reading of the same
13 + /// intensity and what every terminal sends.
14 + fn osc_channel(v: u8) -> String {
15 + format!("{v:02x}{v:02x}")
16 + }
17 +
18 + fn param1(params: &Params, default: u16) -> u16 {
19 + let first = params
20 + .iter()
21 + .next()
22 + .and_then(|p| p.first().copied())
23 + .unwrap_or(0);
24 + if first == 0 { default } else { first }
25 + }
26 +
27 + fn param2(params: &Params, defaults: (u16, u16)) -> (u16, u16) {
28 + let mut it = params.iter();
29 + let a = it.next().and_then(|p| p.first().copied()).unwrap_or(0);
30 + let b = it.next().and_then(|p| p.first().copied()).unwrap_or(0);
31 + let a = if a == 0 { defaults.0 } else { a };
32 + let b = if b == 0 { defaults.1 } else { b };
33 + (a, b)
34 + }
35 +
36 + impl Perform for Grid {
37 + fn print(&mut self, c: char) {
38 + self.place_char(c);
39 + }
40 +
41 + fn execute(&mut self, byte: u8) {
42 + // Any C0 that isn't NUL/BEL moves the cursor or scrolls; invalidate
43 + // the fast-path row cache up front so we don't have to sprinkle it
44 + // across every arm.
45 + self.invalidate_cur_row();
46 + match byte {
47 + 0x08 => {
48 + // BS
49 + if self.cursor.col > 0 {
50 + self.cursor.col -= 1;
51 + }
52 + self.cursor.wrap_next = false;
53 + }
54 + 0x09 => {
55 + // HT — advance to next multiple of 8, clamped.
56 + let next = ((self.cursor.col / 8) + 1) * 8;
57 + self.cursor.col = next.min(self.cols - 1);
58 + self.cursor.wrap_next = false;
59 + }
60 + 0x0A..=0x0C => {
61 + // LF / VT / FF
62 + self.newline();
63 + }
64 + 0x0D => {
65 + // CR
66 + self.cursor.col = 0;
67 + self.cursor.wrap_next = false;
68 + }
69 + 0x07 => {} // BEL — ignore for now
70 + other => trace!("unhandled C0 {other:#x}"),
71 + }
72 + }
73 +
74 + fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
75 + // Nearly every CSI mutates cursor, scroll region, or screen; a couple
76 + // (cursor visibility, SGR) don't but the invalidation is a single
77 + // store — cheaper than branching on which arm we're taking.
78 + self.invalidate_cur_row();
79 + let private = intermediates.first().copied() == Some(b'?');
80 + match (action, private) {
81 + ('H' | 'f', false) => {
82 + let (row, col) = param2(params, (1, 1));
83 + self.set_cursor(row, col);
84 + }
85 + ('A', false) => {
86 + let n = param1(params, 1) as i32;
87 + self.move_by(-n, 0);
88 + }
89 + ('B', false) => {
90 + let n = param1(params, 1) as i32;
91 + self.move_by(n, 0);
92 + }
93 + ('C', false) => {
94 + let n = param1(params, 1) as i32;
95 + self.move_by(0, n);
96 + }
97 + ('D', false) => {
98 + let n = param1(params, 1) as i32;
99 + self.move_by(0, -n);
100 + }
101 + ('E', false) => {
102 + let n = param1(params, 1) as i32;
103 + self.move_by(n, 0);
104 + self.cursor.col = 0;
105 + }
106 + ('F', false) => {
107 + let n = param1(params, 1) as i32;
108 + self.move_by(-n, 0);
109 + self.cursor.col = 0;
110 + }
111 + ('G', false) => {
112 + let col = param1(params, 1);
113 + self.cursor.col = col.saturating_sub(1).min(self.cols - 1);
114 + self.cursor.wrap_next = false;
115 + }
116 + ('d', false) => {
117 + let row = param1(params, 1);
118 + self.cursor.row = row.saturating_sub(1).min(self.rows - 1);
119 + self.cursor.wrap_next = false;
120 + }
121 + ('J', false) => {
122 + self.erase_display(param1(params, 0));
123 + }
124 + ('K', false) => {
125 + self.erase_line(param1(params, 0));
126 + }
127 + ('L', false) => self.insert_lines(param1(params, 1)),
128 + ('M', false) => self.delete_lines(param1(params, 1)),
129 + ('@', false) => self.insert_chars(param1(params, 1)),
130 + ('P', false) => self.delete_chars(param1(params, 1)),
131 + ('X', false) => self.erase_chars(param1(params, 1)),
132 + // DECSC/DECRC in their CSI spelling, the same pair as `ESC 7` and
133 + // `ESC 8`. `CSI s` is DECSLRM under DECLRMM, which shop does not
134 + // implement and no program can have turned on, so there is nothing
135 + // for it to be mistaken for here.
136 + ('s', false) if intermediates.is_empty() => self.save_cursor(),
137 + ('u', false) if intermediates.is_empty() => self.restore_cursor(),
138 + // DA1, "what are you". Guarded on empty intermediates because
139 + // `CSI > c` is DA2, a different question, and the private-flag
140 + // check above only screens for `?`.
141 + //
142 + // 62 is VT220, which is about what the VT side implements; 22 is
143 + // ANSI colour. Sixel is 4 and is deliberately absent: shop has no
144 + // sixel, and claiming it means a client picks sixel over kitty
145 + // graphics and draws nothing.
146 + ('c', false) if intermediates.is_empty() => self.reply(b"\x1b[?62;22c"),
147 + // DSR. Two questions share the final byte: 5 is "are you well"
148 + // and 6 is "where is the cursor" (CPR).
149 + //
150 + // CPR is not an optional courtesy. A line editor that draws a
151 + // prompt has to know which row it starts on, and reedline asks
152 + // this before it draws anything at all: nushell under a terminal
153 + // that never answers sits on a blank screen with a live cursor,
154 + // taking no input, because the shell is still waiting for us.
155 + // That is what shop did in Alloy, where nu is the login shell,
156 + // while bash — which asks nothing — hid it in daily use.
157 + //
158 + // Rows and columns are 1-based on the wire and 0-based here.
159 + // There is no origin mode to subtract: the cursor is absolute
160 + // even inside a scrolling region.
161 + ('n', false) if intermediates.is_empty() => match param1(params, 0) {
162 + 5 => self.reply(b"\x1b[0n"),
163 + 6 => {
164 + let (row, col) = (self.cursor.row + 1, self.cursor.col + 1);
165 + self.reply(format!("\x1b[{row};{col}R").as_bytes());
166 + }
167 + other => trace!("unhandled DSR {other}"),
168 + },
169 + // DECXCPR, the private form of the same question. The reply keeps
170 + // the `?` and carries a third parameter, the page, which is always
171 + // 1 here because shop has no page memory.
172 + ('n', true) if param1(params, 0) == 6 => {
173 + let (row, col) = (self.cursor.row + 1, self.cursor.col + 1);
174 + self.reply(format!("\x1b[?{row};{col};1R").as_bytes());
175 + }
176 + // XTVERSION. `DCS > | name(version) ST`, the form kitty and foot
177 + // both answer in, which is what makes it parseable by the clients
178 + // that ask.
179 + ('q', false) if intermediates.first().copied() == Some(b'>') => {
180 + let reply = format!(
181 + "\x1bP>|{}({})\x1b\\",
182 + self.identity.name, self.identity.version
183 + );
184 + self.reply(reply.as_bytes());
185 + }
186 + // XTWINOPS reports. Only the three read-only ones: the rest of
187 + // this sequence moves and resizes windows, which is the
188 + // compositor's business and not something a program on a PTY gets
189 + // to do here.
190 + //
191 + // Sizes are physical pixels. Programs that place images need cell
192 + // size in particular, and the ioctl that also carries it
193 + // (TIOCSWINSZ) is not what all of them read.
194 + ('t', false) if intermediates.is_empty() => {
195 + let (cw, ch) = self.identity.cell_px;
196 + match param1(params, 0) {
197 + // Text area, in pixels.
198 + 14 => {
199 + let (w, h) = (self.cols * cw, self.rows * ch);
200 + self.reply(format!("\x1b[4;{h};{w}t").as_bytes());
201 + }
202 + // One cell, in pixels. Height first, as the report orders it.
203 + 16 => self.reply(format!("\x1b[6;{ch};{cw}t").as_bytes()),
204 + // Text area, in cells.
205 + 18 => {
206 + let (rows, cols) = (self.rows, self.cols);
207 + self.reply(format!("\x1b[8;{rows};{cols}t").as_bytes());
208 + }
209 + other => trace!("unhandled XTWINOPS {other}"),
210 + }
211 + }
212 + ('S', false) => {
213 + self.scroll_up_in_region(param1(params, 1));
214 + }
215 + ('T', false) => {
216 + self.scroll_down_in_region(param1(params, 1));
217 + }
218 + ('r', false) => {
219 + // DECSTBM: transition-safe. If we're currently in a partial
220 + // region with a rotated region_origin, unroll it back to
221 + // logical order first. If the new region is partial, unroll
222 + // the fullscreen ring so outside-region rows sit at their
223 + // logical physical positions. Unroll preserves logical
224 + // contents so the renderer's per-row cache stays valid — no
225 + // need to mark rows dirty here.
226 + let (top, bot) = param2(params, (1, self.rows));
227 + let new_top = top.saturating_sub(1).min(self.rows - 1);
228 + let new_bottom = bot.saturating_sub(1).min(self.rows - 1);
229 + // A region needs at least two rows, and its top has to be
230 + // above its bottom. DEC and xterm both drop the whole request
231 + // when it does not, cursor move included, and so does this:
232 + // `region_size` is computed as `bottom - top + 1` in the scroll
233 + // paths, so an inverted pair underflows there — a panic in
234 + // debug and a region of ~65,000 rows in release, which is a
235 + // row index off the end of the ring feeding the unchecked
236 + // store in `place_char`. Found by the soak oracle on
237 + // `ESC [ 20 ; 3 r`, 2026-08-29.
238 + if new_top >= new_bottom {
239 + return;
240 + }
241 + self.unroll_region();
242 + self.scroll_top = new_top;
243 + self.scroll_bottom = new_bottom;
244 + if self.is_partial_region() {
245 + self.unroll_active_ring();
246 + }
247 + self.cursor.row = 0;
248 + self.cursor.col = 0;
249 + }
250 + ('m', false) => self.apply_sgr(params),
251 + ('q', false) if intermediates.first().copied() == Some(b' ') => {
252 + let shape = param1(params, 1);
253 + self.cursor_shape = match shape {
254 + 0..=2 => CursorShape::Block,
255 + 3 | 4 => CursorShape::Underline,
256 + 5 | 6 => CursorShape::Bar,
257 + _ => self.cursor_shape,
258 + };
259 + }
260 + ('h' | 'l', true) => {
261 + for p in params.iter() {
262 + if let Some(&code) = p.first() {
263 + match code {
264 + 25 => self.cursor.visible = action == 'h',
265 + 1049 | 47 | 1047 => self.swap_alt(action == 'h'),
266 + // DECSET/DECRST 2026: synchronized update. `h`
267 + // begins a batch (renderer should hold frames
268 + // until `l` or the caller's timeout); `l` ends
269 + // it. Grid just tracks the state — the binary
270 + // is what actually defers the redraw.
271 + 1 => self.cursor_keys_application = action == 'h',
272 + 1007 => self.alternate_scroll = action == 'h',
273 + 2004 => self.bracketed_paste = action == 'h',
274 + 2026 => self.sync_update = action == 'h',
275 + // Mouse tracking. Clearing any level turns the
276 + // pointer back over to the user rather than
277 + // dropping to the next level down: a program
278 + // clearing 1002 is done with the mouse, not asking
279 + // for 1000, and it clears only what it set.
280 + 9 | 1000 | 1002 | 1003 => {
281 + let level = match code {
282 + 9 => MouseTracking::Press,
283 + 1000 => MouseTracking::Click,
284 + 1002 => MouseTracking::Drag,
285 + _ => MouseTracking::Motion,
286 + };
287 + if action == 'h' {
288 + self.mouse_tracking = level;
289 + } else if self.mouse_tracking == level {
290 + self.mouse_tracking = MouseTracking::Off;
291 + }
292 + }
293 + 1006 => {
294 + self.mouse_encoding = if action == 'h' {
295 + MouseEncoding::Sgr
296 + } else {
297 + MouseEncoding::X10
298 + }
299 + }
300 + // 1005 (utf-8 coordinates) and 1015 (urxvt) are
301 + // the two other answers to X10's coordinate
302 + // ceiling, and both are worse than 1006: 1005
303 + // makes a report ambiguous with UTF-8 text, and
304 + // 1015 is ambiguous with a DSR reply. Declined
305 + // rather than unimplemented, and a program that
306 + // asks keeps whatever it had — every one of them
307 + // asks for 1006 first.
308 + 1005 | 1015 => trace!("declined mouse encoding {code}"),
309 + _ => {}
310 + }
311 + }
312 + }
313 + }
314 + _ => trace!("unhandled CSI {action} private={private}"),
315 + }
316 + }
317 +
318 + fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, byte: u8) {
319 + self.invalidate_cur_row();
320 + match byte {
321 + b'7' => self.save_cursor(),
322 + b'8' => self.restore_cursor(),
323 + // DECKPAM / DECKPNM. Application keypad is an ESC pair rather
324 + // than a DECSET, for no reason beyond how DEC numbered things.
325 + b'=' => self.keypad_application = true,
326 + b'>' => self.keypad_application = false,
327 + b'M' => {
328 + // RI — reverse index
329 + if self.cursor.row == self.scroll_top {
330 + self.scroll_down_in_region(1);
331 + } else {
332 + self.cursor.row = self.cursor.row.saturating_sub(1);
333 + }
334 + }
335 + _ => trace!("unhandled ESC {}", byte as char),
336 + }
337 + }
338 +
339 + fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) {
340 + let Some(id) = params.first().and_then(|p| std::str::from_utf8(p).ok()) else {
341 + return;
342 + };
343 + match id {
344 + // OSC 0 = icon + title, OSC 2 = title only. OSC 1 = icon-only,
345 + // treat as no-op (Wayland has no separate icon-name concept).
346 + "0" | "2" => {
347 + if let Some(payload) = params.get(1)
348 + && let Ok(s) = std::str::from_utf8(payload)
349 + {
350 + self.pending_title = Some(s.to_string());
351 + }
352 + }
353 + // OSC 10 and 11, default foreground and background. Only the `?`
354 + // query form: setting them is a separate feature, and answering a
355 + // set request would be worse than ignoring it.
356 + //
357 + // Programs ask in order to tell light from dark, so this decides
358 + // whether anything that adapts to the terminal's polarity adapts
359 + // the right way. shop's theme knows the answer; nothing else does.
360 + "10" | "11" if params.get(1) == Some(&b"?".as_slice()) => {
361 + let c = if id == "10" {
362 + self.identity.fg
363 + } else {
364 + self.identity.bg
365 + };
366 + let colour = format!(
367 + "rgb:{}/{}/{}",
368 + osc_channel(c[0]),
369 + osc_channel(c[1]),
370 + osc_channel(c[2])
371 + );
372 + // Terminated the way the question was. A client that asked
373 + // with BEL may well be parsing for one.
374 + let end: &str = if bell_terminated { "\x07" } else { "\x1b\\" };
375 + self.reply(format!("\x1b]{id};{colour}{end}").as_bytes());
376 + }
377 + _ => {}
378 + }
379 + }
380 + }
381 +
382 + #[cfg(test)]
383 + mod tests {
384 + use crate::testutil::{assert_cursor, feed, identified, reply_to, row_str};
385 + use crate::*;
386 +
387 + // ---- cursor shape --------------------------------------------------
388 +
389 + #[test]
390 + fn decscusr_sets_shape() {
391 + let mut g = Grid::new(10, 2);
392 + assert_eq!(g.cursor_shape(), CursorShape::Block);
393 + feed(&mut g, b"\x1b[3 q");
394 + assert_eq!(g.cursor_shape(), CursorShape::Underline);
395 + feed(&mut g, b"\x1b[6 q");
396 + assert_eq!(g.cursor_shape(), CursorShape::Bar);
397 + feed(&mut g, b"\x1b[1 q");
398 + assert_eq!(g.cursor_shape(), CursorShape::Block);
399 + }
400 +
401 + // ---- synchronized update (DECSET 2026) -----------------------------
402 +
403 + #[test]
404 + fn sync_update_toggles_on_2026() {
405 + let mut g = Grid::new(10, 1);
406 + assert!(!g.sync_update());
407 + feed(&mut g, b"\x1b[?2026h");
408 + assert!(g.sync_update());
409 + feed(&mut g, b"\x1b[?2026l");
410 + assert!(!g.sync_update());
411 + }
412 +
413 + // ---- alt screen ----------------------------------------------------
414 +
415 + #[test]
416 + fn alt_screen_swaps_and_restores() {
417 + let mut g = Grid::new(6, 2);
418 + feed(&mut g, b"MAIN");
419 + assert_eq!(row_str(&g, 0), "MAIN");
420 + feed(&mut g, b"\x1b[?1049h"); // enter alt
421 + // Alt starts blank.
422 + assert_eq!(row_str(&g, 0), "");
423 + feed(&mut g, b"ALT");
424 + assert_eq!(row_str(&g, 0), "ALT");
425 + feed(&mut g, b"\x1b[?1049l"); // exit
426 + assert_eq!(row_str(&g, 0), "MAIN");
427 + }
428 +
429 + // ---- OSC title -----------------------------------------------------
430 +
431 + #[test]
432 + fn osc_title_sets_pending() {
433 + let mut g = Grid::new(10, 2);
434 + feed(&mut g, b"\x1b]0;hello\x07");
435 + assert_eq!(g.take_pending_title().as_deref(), Some("hello"));
436 + // Draining clears it.
437 + assert!(g.take_pending_title().is_none());
438 + }
439 +
440 + #[test]
441 + fn osc_two_also_sets_title() {
442 + let mut g = Grid::new(10, 2);
443 + feed(&mut g, b"\x1b]2;from OSC 2\x07");
444 + assert_eq!(g.take_pending_title().as_deref(), Some("from OSC 2"));
445 + }
446 +
447 + // ---- device attributes ---------------------------------------------
448 +
449 + #[test]
450 + fn da1_is_answered() {
451 + let mut g = Grid::new(10, 3);
452 + assert!(g.take_pending_replies().is_empty(), "nothing owed yet");
453 + feed(&mut g, b"\x1b[c");
454 + assert_eq!(g.take_pending_replies(), b"\x1b[?62;22c".to_vec());
455 + }
456 +
457 + #[test]
458 + fn da1_does_not_claim_sixel() {
459 + // Attribute 4 is sixel. Claiming it makes a client prefer sixel over
460 + // kitty graphics, and shop would then draw nothing at all.
461 + let mut g = Grid::new(10, 3);
462 + feed(&mut g, b"\x1b[c");
463 + let reply = String::from_utf8(g.take_pending_replies()).unwrap();
464 + let attrs: Vec<&str> = reply
465 + .trim_start_matches("\x1b[?")
466 + .trim_end_matches('c')
467 + .split(';')
468 + .collect();
469 + assert!(!attrs.contains(&"4"), "claimed sixel in {reply:?}");
470 + }
471 +
472 + #[test]
473 + fn da1_with_an_explicit_zero_is_the_same_question() {
474 + let mut g = Grid::new(10, 3);
475 + feed(&mut g, b"\x1b[0c");
476 + assert_eq!(g.take_pending_replies(), b"\x1b[?62;22c".to_vec());
477 + }
478 +
479 + #[test]
480 + fn da2_is_not_answered_with_da1() {
481 + // `CSI > c` is a different question. The private-flag check only
482 + // screens for `?`, so without the intermediates guard this arm would
483 + // answer it, and answer it wrongly.
484 + let mut g = Grid::new(10, 3);
485 + feed(&mut g, b"\x1b[>c");
486 + assert!(g.take_pending_replies().is_empty());
487 + }
488 +
489 + #[test]
490 + fn replies_are_drained_not_repeated() {
491 + let mut g = Grid::new(10, 3);
492 + feed(&mut g, b"\x1b[c");
493 + assert!(!g.take_pending_replies().is_empty());
494 + assert!(g.take_pending_replies().is_empty(), "drained once only");
495 + }
496 +
497 + #[test]
498 + fn two_queries_in_one_parse_both_get_answers() {
499 + let mut g = Grid::new(10, 3);
500 + feed(&mut g, b"\x1b[c\x1b[c");
Lines truncated
@@ -1,0 +1,371 @@
1 + //! The active screen as a ring buffer: origin arithmetic, row addressing,
2 + //! blanking, and the resize that unrolls it into a fresh buffer.
3 + //!
4 + //! A scroll moves the origin rather than the rows, so every reader that wants
5 + //! visible row `r` goes through [`Grid::phys_row`] to find the physical one.
6 +
7 + use crate::{Cell, Grid};
8 +
9 + impl Grid {
10 + pub(crate) fn active_cells(&self) -> &[Cell] {
11 + if self.on_alt { &self.alt } else { &self.main }
12 + }
13 +
14 + pub(crate) fn active_cells_mut(&mut self) -> &mut [Cell] {
15 + if self.on_alt {
16 + &mut self.alt
17 + } else {
18 + &mut self.main
19 + }
20 + }
21 +
22 + pub(crate) fn active_origin(&self) -> u16 {
23 + if self.on_alt {
24 + self.alt_origin
25 + } else {
26 + self.main_origin
27 + }
28 + }
29 +
30 + /// Physical row index backing logical row `r`.
31 + pub(crate) fn phys_row(&self, r: u16) -> u16 {
32 + let phys = if self.is_partial_region() && r >= self.scroll_top && r <= self.scroll_bottom {
33 + // In partial region: active_origin is guaranteed 0 by unroll on
34 + // transition, so we rotate only within the region.
35 + let region_size = (self.scroll_bottom - self.scroll_top + 1) as u32;
36 + let region_r = (r - self.scroll_top) as u32;
37 + let phys_in_region = (self.region_origin as u32 + region_r) % region_size;
38 + self.scroll_top as u32 + phys_in_region
39 + } else {
40 + (self.active_origin() as u32 + r as u32) % self.rows as u32
41 + };
42 + phys as u16
43 + }
44 +
45 + /// Physical byte offset for the start of logical row `r`.
46 + pub(crate) fn row_start(&self, r: u16) -> usize {
47 + self.phys_row(r) as usize * self.cols as usize
48 + }
49 +
50 + fn active_wrapped_mut(&mut self) -> &mut [bool] {
51 + if self.on_alt {
52 + &mut self.alt_wrapped
53 + } else {
54 + &mut self.main_wrapped
55 + }
56 + }
57 +
58 + /// Does logical row `r` continue onto row `r + 1`?
59 + ///
60 + /// True only when the shell's output ran off the right edge, so a copy
61 + /// spanning the two rows should join them without a newline. A row that
62 + /// filled exactly and then got an explicit CR/LF reads false.
63 + pub fn row_wrapped(&self, r: u16) -> bool {
64 + if let Some(h) = self.history_row(r) {
65 + return h.wrapped;
66 + }
67 + let phys = self.phys_row(self.live_row(r)) as usize;
68 + let flags = if self.on_alt {
69 + &self.alt_wrapped
70 + } else {
71 + &self.main_wrapped
72 + };
73 + flags.get(phys).copied().unwrap_or(false)
74 + }
75 +
76 + pub(crate) fn set_row_wrapped(&mut self, r: u16, wrapped: bool) {
77 + let phys = self.phys_row(r) as usize;
78 + if let Some(slot) = self.active_wrapped_mut().get_mut(phys) {
79 + *slot = wrapped;
80 + }
81 + }
82 +
83 + pub(crate) fn is_partial_region(&self) -> bool {
84 + self.scroll_top != 0 || self.scroll_bottom != self.rows - 1
85 + }
86 +
87 + /// Rotate the active screen's cells so `active_origin` becomes 0.
88 + /// Cheap: one `slice::rotate_left`. Called before entering partial-region
89 + /// mode so rows outside the region are at logical=physical positions.
90 + pub(crate) fn unroll_active_ring(&mut self) {
91 + let origin = self.active_origin();
92 + if origin == 0 {
93 + return;
94 + }
95 + let cols = self.cols as usize;
96 + let cells = self.active_cells_mut();
97 + cells.rotate_left(origin as usize * cols);
98 + self.active_wrapped_mut().rotate_left(origin as usize);
99 + if self.on_alt {
100 + self.alt_origin = 0;
101 + } else {
102 + self.main_origin = 0;
103 + }
104 + }
105 +
106 + /// Rotate the current partial region so `region_origin` becomes 0.
107 + /// Called before exiting partial-region mode (or entering a different
108 + /// region) so region contents are back at logical positions.
109 + pub(crate) fn unroll_region(&mut self) {
110 + if self.region_origin == 0 {
111 + return;
112 + }
113 + let cols = self.cols as usize;
114 + let top = self.scroll_top as usize;
115 + let region_rows = (self.scroll_bottom - self.scroll_top + 1) as usize;
116 + let region_len = region_rows * cols;
117 + let shift = self.region_origin as usize;
118 + let cells = self.active_cells_mut();
119 + cells[top * cols..top * cols + region_len].rotate_left(shift * cols);
120 + self.active_wrapped_mut()[top..top + region_rows].rotate_left(shift);
121 + self.region_origin = 0;
122 + }
123 +
124 + /// Advance the active screen's ring origin. Positive `n` = scroll up
125 + /// (logical row 0 shows what was logical row `n`); negative = scroll
126 + /// down. Blanking of newly-exposed rows is the caller's job.
127 + pub(crate) fn advance_origin(&mut self, n: i32) {
128 + let rows = self.rows as i32;
129 + let origin = if self.on_alt {
130 + &mut self.alt_origin
131 + } else {
132 + &mut self.main_origin
133 + };
134 + let new = (*origin as i32 + n).rem_euclid(rows);
135 + *origin = new as u16;
136 + }
137 +
138 + /// Zero one physical row's cells.
139 + pub(crate) fn blank_physical_row(&mut self, phys: u16) {
140 + let cols = self.cols as usize;
141 + let start = phys as usize * cols;
142 + let cells = self.active_cells_mut();
143 + for cell in &mut cells[start..start + cols] {
144 + *cell = Cell::default();
145 + }
146 + if let Some(slot) = self.active_wrapped_mut().get_mut(phys as usize) {
147 + *slot = false;
148 + }
149 + }
150 +
151 + /// Zero one logical row's cells.
152 + pub(crate) fn blank_logical_row(&mut self, r: u16) {
153 + let cols = self.cols as usize;
154 + let start = self.row_start(r);
155 + let cells = self.active_cells_mut();
156 + for cell in &mut cells[start..start + cols] {
157 + *cell = Cell::default();
158 + }
159 + self.set_row_wrapped(r, false);
160 + }
161 +
162 + /// Resize the grid, preserving as much of the top-left of the live screen
163 + /// as fits and rewrapping scrollback to the new width.
164 + ///
165 + /// The live screen is truncated, not reflowed: it is whatever an
166 + /// application last painted, and it is about to be told the new size and
167 + /// repaint. History has no one to repaint it, so it is rewrapped — see
168 + /// [`Grid::rewrap_history`].
169 + pub fn resize(&mut self, cols: u16, rows: u16) {
170 + let cols = cols.max(1);
171 + let rows = rows.max(1);
172 + if cols == self.cols && rows == self.rows {
173 + return;
174 + }
175 + let old_cols = self.cols;
176 + self.main = resize_buf(
177 + &self.main,
178 + self.main_origin,
179 + self.cols,
180 + self.rows,
181 + cols,
182 + rows,
183 + );
184 + self.alt = resize_buf(&self.alt, self.alt_origin, self.cols, self.rows, cols, rows);
185 + // The live screen is clipped rather than reflowed, so every recorded
186 + // wrap point on it is now a lie about where the text runs off the edge.
187 + // Drop them all rather than carry wrong ones into a copy. History keeps
188 + // its flags: the rewrap is what makes them true again.
189 + self.main_wrapped = vec![false; rows as usize];
190 + self.alt_wrapped = vec![false; rows as usize];
191 + // The viewport survives a height change, but it cannot point further
192 + // back than history goes.
193 + self.view_offset = self.view_offset.min(self.history_len_u16());
194 + self.main_origin = 0;
195 + self.alt_origin = 0;
196 + self.region_origin = 0;
197 + self.cols = cols;
198 + self.rows = rows;
199 + if cols != old_cols {
200 + self.rewrap_history(old_cols);
201 + }
202 + self.scroll_top = 0;
203 + self.scroll_bottom = rows - 1;
204 + self.cursor.row = self.cursor.row.min(rows - 1);
205 + self.cursor.col = self.cursor.col.min(cols - 1);
206 + self.cursor.wrap_next = false;
207 + // Resize invalidates any per-row cache; caller wipes on receipt.
208 + self.row_dirty = vec![true; rows as usize];
209 + self.pending_resize = true;
210 + self.pending_scroll = 0;
211 + self.invalidate_cur_row();
212 + }
213 + }
214 +
215 + fn resize_buf(
216 + old: &[Cell],
217 + old_origin: u16,
218 + old_cols: u16,
219 + old_rows: u16,
220 + new_cols: u16,
221 + new_rows: u16,
222 + ) -> Vec<Cell> {
223 + let mut new = vec![Cell::default(); new_cols as usize * new_rows as usize];
224 + let copy_cols = old_cols.min(new_cols) as usize;
225 + let copy_rows = old_rows.min(new_rows) as usize;
226 + for r in 0..copy_rows {
227 + // Ring-map the old logical row to its physical offset.
228 + let src_phys = (old_origin as u32 + r as u32) % old_rows as u32;
229 + let src_start = src_phys as usize * old_cols as usize;
230 + let dst_start = r * new_cols as usize;
231 + new[dst_start..dst_start + copy_cols]
232 + .copy_from_slice(&old[src_start..src_start + copy_cols]);
233 + // Narrowing can cut a wide character in half at the new right edge.
234 + // The live screen is about to be repainted at the new size anyway, so
235 + // the lead is simply dropped rather than carried as half a character.
236 + if let Some(last) = new[dst_start..dst_start + copy_cols].last_mut()
237 + && last.is_wide()
238 + {
239 + *last = Cell::default();
240 + }
241 + }
242 + new
243 + }
244 +
245 + #[cfg(test)]
246 + mod tests {
247 + use crate::testutil::{assert_cursor, feed, row_str};
248 + use crate::*;
249 +
250 + // ---- resize --------------------------------------------------------
251 +
252 + #[test]
253 + fn resize_grow_preserves_top_left() {
254 + let mut g = Grid::new(4, 2);
255 + feed(&mut g, b"AB\r\nCD");
256 + g.resize(6, 3);
257 + assert_eq!(row_str(&g, 0), "AB");
258 + assert_eq!(row_str(&g, 1), "CD");
259 + }
260 +
261 + #[test]
262 + fn resize_shrink_truncates() {
263 + let mut g = Grid::new(6, 3);
264 + feed(&mut g, b"ABCDEF\r\nGHIJKL\r\nMNOPQR");
265 + g.resize(3, 2);
266 + assert_eq!(row_str(&g, 0), "ABC");
267 + assert_eq!(row_str(&g, 1), "GHI");
268 + }
269 +
270 + #[test]
271 + fn resize_clamps_cursor() {
272 + let mut g = Grid::new(10, 5);
273 + feed(&mut g, b"\x1b[5;10H"); // (4, 9)
274 + assert_cursor(&g, 4, 9);
275 + g.resize(4, 2);
276 + let c = g.cursor();
277 + assert!(c.row < 2 && c.col < 4);
278 + }
279 +
280 + // ---- wrapped-row flag ----------------------------------------------
281 +
282 + #[test]
283 + fn deferred_wrap_marks_the_row_it_left() {
284 + let mut g = Grid::new(4, 3);
285 + feed(&mut g, b"abcdef");
286 + assert!(g.row_wrapped(0));
287 + assert!(!g.row_wrapped(1));
288 + }
289 +
290 + #[test]
291 + fn filling_a_row_exactly_does_not_mark_it_wrapped() {
292 + let mut g = Grid::new(4, 3);
293 + feed(&mut g, b"abcd");
294 + assert!(!g.row_wrapped(0), "wrap_next alone is not a wrap");
295 + feed(&mut g, b"\r\nefgh");
296 + assert!(!g.row_wrapped(0));
297 + }
298 +
299 + #[test]
300 + fn the_wrapped_flag_rides_the_scroll_ring() {
301 + let mut g = Grid::new(4, 3);
302 + // "abcd" wraps onto "ef", one row down from the top.
303 + feed(&mut g, b"xy\r\nabcdef");
304 + assert!(g.row_wrapped(1));
305 + // Scrolling carries the wrapped row up to row 0 — the flag is indexed
306 + // physically, so it has to arrive with it.
307 + feed(&mut g, b"\r\n");
308 + assert_eq!(row_str(&g, 0), "abcd");
309 + assert!(g.row_wrapped(0));
310 + // One more scroll and it leaves the screen entirely.
311 + feed(&mut g, b"\r\n");
312 + assert_eq!(row_str(&g, 0), "ef");
313 + assert!(!g.row_wrapped(0), "the wrapped row scrolled off the top");
314 + }
315 +
316 + #[test]
317 + fn a_blank_row_exposed_by_a_scroll_is_not_wrapped() {
318 + let mut g = Grid::new(4, 2);
319 + feed(&mut g, b"abcdef\r\n\r\n\r\n");
320 + for r in 0..g.rows() {
321 + assert!(!g.row_wrapped(r), "row {r} came back wrapped");
322 + }
323 + }
324 +
325 + #[test]
326 + fn resize_drops_every_wrap_point() {
327 + let mut g = Grid::new(4, 3);
328 + feed(&mut g, b"abcdef");
329 + assert!(g.row_wrapped(0));
330 + g.resize(8, 3);
331 + assert!(!g.row_wrapped(0), "the wrap point is meaningless at 8 cols");
332 + }
333 +
334 + #[test]
335 + fn alt_screen_keeps_its_own_wrap_points() {
336 + let mut g = Grid::new(4, 3);
337 + feed(&mut g, b"abcdef");
338 + feed(&mut g, b"\x1b[?1049h");
339 + assert!(!g.row_wrapped(0), "alt screen starts clean");
340 + feed(&mut g, b"\x1b[?1049l");
341 + assert!(g.row_wrapped(0), "main screen's wrap point survived");
342 + }
343 +
344 + // ---- erase against a rotated ring ----------------------------------
345 +
346 + #[test]
347 + fn erase_line_targets_the_right_row_after_a_scroll() {
348 + // Scroll far enough that the ring origin is non-zero, then erase the
349 + // cursor's line. Erasing by logical row without the ring mapping
350 + // would blank some other row entirely.
351 + let mut g = Grid::new(6, 3);
352 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
353 + assert_eq!(row_str(&g, 0), "three");
354 + assert_eq!(row_str(&g, 1), "four");
355 + assert_eq!(row_str(&g, 2), "five");
356 + feed(&mut g, b"\x1b[2;1H\x1b[2K"); // row 1, erase whole line
357 + assert_eq!(row_str(&g, 0), "three");
358 + assert_eq!(row_str(&g, 1), "");
359 + assert_eq!(row_str(&g, 2), "five");
360 + }
361 +
362 + #[test]
363 + fn erase_to_end_of_line_targets_the_right_row_after_a_scroll() {
364 + let mut g = Grid::new(6, 3);
365 + feed(&mut g, b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
366 + feed(&mut g, b"\x1b[3;3H\x1b[K"); // row 2 col 2, erase to end
367 + assert_eq!(row_str(&g, 0), "three");
368 + assert_eq!(row_str(&g, 1), "four");
369 + assert_eq!(row_str(&g, 2), "fi");
370 + }
371 + }
@@ -1,0 +1,301 @@
1 + //! SGR: turning `CSI Pm m` into the pending colours and attributes the print
2 + //! path stamps onto every cell.
3 + //!
4 + //! Both spellings of extended colour are handled here: the semicolon form,
5 + //! which eats following parameter groups, and the colon form, which arrives as
6 + //! subparameters of one group.
7 +
8 + use crate::{Attrs, Color, Grid};
9 + use shop_vt::Params;
10 +
11 + impl Grid {
12 + pub(crate) fn apply_sgr(&mut self, params: &Params) {
13 + // Bare `\e[m` is the same as `\e[0m` — full reset.
14 + if params.is_empty() {
15 + self.pending_fg = Color::Default;
16 + self.pending_bg = Color::Default;
17 + self.pending_attrs = Attrs::default();
18 + self.recompute_style_words();
19 + return;
20 + }
21 +
22 + // Collect param-group references so we can distinguish colon-form
23 + // (subparams grouped) from semicolon-form (separate groups).
24 + let groups: Vec<&[u16]> = params.iter().collect();
25 + let mut i = 0;
26 + while i < groups.len() {
27 + let group = groups[i];
28 + if group.len() > 1 {
29 + // Colon form — the whole group is one logical SGR.
30 + self.apply_colon_group(group);
31 + i += 1;
32 + continue;
33 + }
34 + let p = group.first().copied().unwrap_or(0);
35 + i += self.apply_single(p, &groups, i);
36 + }
37 + self.recompute_style_words();
38 + }
39 +
40 + /// Semicolon-form: `p` came from a single-subparam group. Returns how many
41 + /// group indices were consumed (usually 1, or up to 5 for extended color).
42 + fn apply_single(&mut self, p: u16, groups: &[&[u16]], i: usize) -> usize {
43 + match p {
44 + 0 => {
45 + self.pending_fg = Color::Default;
46 + self.pending_bg = Color::Default;
47 + self.pending_attrs = Attrs::default();
48 + 1
49 + }
50 + 1 => {
51 + self.pending_attrs.bold = true;
52 + 1
53 + }
54 + 2 => {
55 + self.pending_attrs.dim = true;
56 + 1
57 + }
58 + 3 => {
59 + self.pending_attrs.italic = true;
60 + 1
61 + }
62 + 4 => {
63 + self.pending_attrs.underline = true;
64 + 1
65 + }
66 + 7 => {
67 + self.pending_attrs.reverse = true;
68 + 1
69 + }
70 + 9 => {
71 + self.pending_attrs.strikethrough = true;
72 + 1
73 + }
74 + 22 => {
75 + self.pending_attrs.bold = false;
76 + self.pending_attrs.dim = false;
77 + 1
78 + }
79 + 23 => {
80 + self.pending_attrs.italic = false;
81 + 1
82 + }
83 + 24 => {
84 + self.pending_attrs.underline = false;
85 + 1
86 + }
87 + 27 => {
88 + self.pending_attrs.reverse = false;
89 + 1
90 + }
91 + 29 => {
92 + self.pending_attrs.strikethrough = false;
93 + 1
94 + }
95 + 30..=37 => {
96 + self.pending_fg = Color::Named((p - 30) as u8);
97 + 1
98 + }
99 + 38 => {
100 + let (color, consumed) = take_semi_extended(groups, i + 1);
101 + if let Some(c) = color {
102 + self.pending_fg = c;
103 + }
104 + 1 + consumed
105 + }
106 + 39 => {
107 + self.pending_fg = Color::Default;
108 + 1
109 + }
110 + 40..=47 => {
111 + self.pending_bg = Color::Named((p - 40) as u8);
112 + 1
113 + }
114 + 48 => {
115 + let (color, consumed) = take_semi_extended(groups, i + 1);
116 + if let Some(c) = color {
117 + self.pending_bg = c;
118 + }
119 + 1 + consumed
120 + }
121 + 49 => {
122 + self.pending_bg = Color::Default;
123 + 1
124 + }
125 + 90..=97 => {
126 + self.pending_fg = Color::Named(((p - 90) + 8) as u8);
127 + 1
128 + }
129 + 100..=107 => {
130 + self.pending_bg = Color::Named(((p - 100) + 8) as u8);
131 + 1
132 + }
133 + _ => 1,
134 + }
135 + }
136 +
137 + /// Colon-form group: leading value is the SGR code, subsequent subparams
138 + /// carry the extended-color payload. We only recognize 38 (fg) and 48 (bg)
139 + /// here — colon-form underline color is a follow-up.
140 + fn apply_colon_group(&mut self, group: &[u16]) {
141 + let leading = group[0];
142 + let color = parse_colon_extended(&group[1..]);
143 + match leading {
144 + 38 => {
145 + if let Some(c) = color {
146 + self.pending_fg = c;
147 + }
148 + }
149 + 48 => {
150 + if let Some(c) = color {
151 + self.pending_bg = c;
152 + }
153 + }
154 + _ => {}
155 + }
156 + }
157 + }
158 +
159 + /// Parse extended color from the following semicolon-separated groups. Handles
160 + /// `2;R;G;B` (RGB) and `5;N` (indexed). Returns the parsed color and the
161 + /// number of groups consumed after the leading `38`/`48`.
162 + fn take_semi_extended(groups: &[&[u16]], start: usize) -> (Option<Color>, usize) {
163 + let Some(fmt) = groups.get(start).and_then(|g| g.first().copied()) else {
164 + return (None, 0);
165 + };
166 + match fmt {
167 + 5 => {
168 + let Some(idx) = groups.get(start + 1).and_then(|g| g.first().copied()) else {
169 + return (None, 1);
170 + };
171 + (Some(Color::Indexed(idx.min(255) as u8)), 2)
172 + }
173 + 2 => {
174 + let r = groups
175 + .get(start + 1)
176 + .and_then(|g| g.first().copied())
177 + .unwrap_or(0);
178 + let g = groups
179 + .get(start + 2)
180 + .and_then(|g| g.first().copied())
181 + .unwrap_or(0);
182 + let b = groups
183 + .get(start + 3)
184 + .and_then(|g| g.first().copied())
185 + .unwrap_or(0);
186 + (
187 + Some(Color::Rgb(
188 + r.min(255) as u8,
189 + g.min(255) as u8,
190 + b.min(255) as u8,
191 + )),
192 + 4,
193 + )
194 + }
195 + _ => (None, 1),
196 + }
197 + }
198 +
199 + /// Parse extended color from a colon-form subparam tail — the bytes after the
200 + /// leading `38`/`48`. `2;colorspace;R;G;B` (5 items) OR `2;R;G;B` (4 items)
201 + /// OR `5;N` (2 items). Colorspace slot is skipped when present.
202 + fn parse_colon_extended(rest: &[u16]) -> Option<Color> {
203 + match rest.first().copied()? {
204 + 5 => rest.get(1).map(|&i| Color::Indexed(i.min(255) as u8)),
205 + 2 => match rest.len() {
206 + // [2, colorspace, R, G, B]
207 + 5 => Some(Color::Rgb(
208 + rest[2].min(255) as u8,
209 + rest[3].min(255) as u8,
210 + rest[4].min(255) as u8,
211 + )),
212 + // [2, R, G, B]
213 + 4 => Some(Color::Rgb(
214 + rest[1].min(255) as u8,
215 + rest[2].min(255) as u8,
216 + rest[3].min(255) as u8,
217 + )),
218 + _ => None,
219 + },
220 + _ => None,
221 + }
222 + }
223 +
224 + #[cfg(test)]
225 + mod tests {
226 + use crate::testutil::feed;
227 + use crate::*;
228 +
229 + // ---- SGR -----------------------------------------------------------
230 +
231 + #[test]
232 + fn sgr_named_fg_and_bg() {
233 + let mut g = Grid::new(10, 1);
234 + feed(&mut g, b"\x1b[31;44mA");
235 + let cell = g.row(0)[0];
236 + assert_eq!(cell.fg(), Color::Named(1)); // red
237 + assert_eq!(cell.bg(), Color::Named(4)); // blue
238 + }
239 +
240 + #[test]
241 + fn sgr_bright_named() {
242 + let mut g = Grid::new(10, 1);
243 + feed(&mut g, b"\x1b[92mA");
244 + assert_eq!(g.row(0)[0].fg(), Color::Named(10)); // bright green = 8+2
245 + }
246 +
247 + #[test]
248 + fn sgr_indexed_256() {
249 + let mut g = Grid::new(10, 1);
250 + feed(&mut g, b"\x1b[38;5;123mA");
251 + assert_eq!(g.row(0)[0].fg(), Color::Indexed(123));
252 + }
253 +
254 + #[test]
255 + fn sgr_truecolor_rgb() {
256 + let mut g = Grid::new(10, 1);
257 + feed(&mut g, b"\x1b[38;2;255;128;0mA");
258 + assert_eq!(g.row(0)[0].fg(), Color::Rgb(255, 128, 0));
259 + }
260 +
261 + #[test]
262 + fn sgr_colon_subparam_rgb() {
263 + // The ITU-T `:` form: `\e[38:2::255:128:0m` — one param with
264 + // subparams. Our SGR parser flattens both forms.
265 + let mut g = Grid::new(10, 1);
266 + feed(&mut g, b"\x1b[38:2::255:128:0mA");
267 + assert_eq!(g.row(0)[0].fg(), Color::Rgb(255, 128, 0));
268 + }
269 +
270 + #[test]
271 + fn sgr_attrs_bold_italic_underline() {
272 + let mut g = Grid::new(10, 1);
273 + feed(&mut g, b"\x1b[1;3;4mA");
274 + let a = g.row(0)[0].attrs();
275 + assert!(a.bold && a.italic && a.underline);
276 + }
277 +
278 + #[test]
279 + fn sgr_reset_clears_everything() {
280 + let mut g = Grid::new(10, 1);
281 + feed(&mut g, b"\x1b[1;31;44mA\x1b[mB");
282 + let a = g.row(0)[0];
283 + let b = g.row(0)[1];
284 + assert_eq!(a.fg(), Color::Named(1));
285 + assert!(a.attrs().bold);
286 + assert_eq!(b.fg(), Color::Default);
287 + assert_eq!(b.bg(), Color::Default);
288 + assert!(!b.attrs().bold);
289 + }
290 +
291 + #[test]
292 + fn sgr_selective_clears() {
293 + let mut g = Grid::new(10, 1);
294 + feed(&mut g, b"\x1b[1;3mA\x1b[22mB\x1b[23mC");
295 + assert!(g.row(0)[0].attrs().bold && g.row(0)[0].attrs().italic);
296 + // 22 clears bold + dim; italic stays.
297 + assert!(!g.row(0)[1].attrs().bold && g.row(0)[1].attrs().italic);
298 + // 23 clears italic.
299 + assert!(!g.row(0)[2].attrs().italic);
300 + }
301 + }
@@ -1,0 +1,58 @@
1 + //! Helpers the inline test modules share.
2 + //!
3 + //! Compiled only for tests, and deliberately without a test module of its own:
4 + //! the mutation selector picks a file by the test attribute in it, and test
5 + //! helpers are not something to mutate. The modules that use these carry the
6 + //! attribute instead.
7 +
8 + use crate::{Grid, Identity};
9 + use shop_vt::Parser;
10 +
11 + /// Feed `bytes` through a fresh Parser into `grid`.
12 + pub(crate) fn feed(grid: &mut Grid, bytes: &[u8]) {
13 + let mut p = Parser::new();
14 + p.advance(grid, bytes);
15 + }
16 +
17 + pub(crate) fn row_str(grid: &Grid, r: u16) -> String {
18 + grid.row(r)
19 + .iter()
20 + .map(crate::Cell::c)
21 + .collect::<String>()
22 + .trim_end()
23 + .to_string()
24 + }
25 +
26 + pub(crate) fn assert_cursor(grid: &Grid, row: u16, col: u16) {
27 + let c = grid.cursor();
28 + assert_eq!(
29 + (c.row, c.col),
30 + (row, col),
31 + "cursor mismatch (wrap_next={})",
32 + c.wrap_next
33 + );
34 + }
35 +
36 + pub(crate) fn identified() -> Grid {
37 + let mut g = Grid::new(80, 24);
38 + g.set_identity(Identity {
39 + name: "shop".into(),
40 + version: "1.2.3".into(),
41 + cell_px: (9, 20),
42 + fg: [0xe6, 0xde, 0xd3],
43 + bg: [0x25, 0x23, 0x1f],
44 + });
45 + g
46 + }
47 +
48 + pub(crate) fn reply_to(g: &mut Grid, bytes: &[u8]) -> String {
49 + feed(g, bytes);
50 + String::from_utf8(g.take_pending_replies()).unwrap()
51 + }
52 +
53 + /// Everything in scrollback, as text. The round-trip property is stated
54 + /// over this rather than over rows, because the rows are exactly what a
55 + /// rewrap is allowed to change.
56 + pub(crate) fn history_text(g: &Grid) -> String {
57 + g.text_range(0, g.history_len())
58 + }