max / shop
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
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 | + | } |