Skip to main content

max / shop

shop-grid: 31 tests + rewrite SGR to fix two silent bugs Recording-Grid tests feed real byte streams through a Parser and assert on grid state. Covers basics (print, wrap, CR/LF/BS/HT), CSI motion (CUP, CUU/CUD/CUF/CUB clamp, CHA, VPA), erase (EL 0/1/2, ED 2), SGR (named/bright/indexed/RGB/attrs/selective-clear/reset), DECSCUSR, alt-screen swap, OSC title, scroll-region newline, reverse index, and resize behavior. Two live bugs caught, both in the SGR parser: 1. Colon-form RGB was one-off. `\e[38:2::R:G:Bm` groups into a single subparam group [38, 2, 0, R, G, B] where the empty `::` is a colorspace placeholder. The old flatten-then-scan approach couldn't tell colon from semicolon form and produced Rgb(0, R, G) — reading the colorspace slot as R. 2. Bare `\e[m` was a no-op instead of a full reset. The `params.is_ empty()` case fell through the match with no default clause, so any sequence like `\e[31mA\e[mB` produced a red 'B'. Rewrote apply_sgr to iterate param groups (not flatten), then dispatch each group as either colon-form (subparams within a group) or semicolon- form (one value per group, follow-ups consumed by 38/48 extended color). Empty-params case now explicitly resets. Old parse_extended_color helper deleted; replaced by take_semi_extended + parse_colon_extended. Full workspace: 52 tests passing, 0 failures.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 01:01 UTC
Signed with PGP, not checked
Commit: 34b75b25a04b1f3ae6b05631ff526ab0f41aa96b
Parent: dcd1c0a
1 file changed, +426 insertions, -56 deletions
@@ -209,53 +209,146 @@
209 209 }
210 210
211 211 fn apply_sgr(&mut self, params: &Params) {
212 - // Flatten all subparameter groups into one stream so both the
213 - // xterm-style `38;2;R;G;B` and the ITU-T `38:2::R:G:B` forms work.
214 - let flat: Vec<u16> = params.iter().flat_map(|p| p.iter().copied()).collect();
212 + // Bare `\e[m` is the same as `\e[0m` — full reset.
213 + if params.is_empty() {
214 + self.pending_fg = Color::Default;
215 + self.pending_bg = Color::Default;
216 + self.pending_attrs = Attrs::default();
217 + return;
218 + }
219 +
220 + // Collect param-group references so we can distinguish colon-form
221 + // (subparams grouped) from semicolon-form (separate groups).
222 + let groups: Vec<&[u16]> = params.iter().collect();
215 223 let mut i = 0;
216 - while i < flat.len() {
217 - let p = flat[i];
218 - match p {
219 - 0 => {
220 - self.pending_fg = Color::Default;
221 - self.pending_bg = Color::Default;
222 - self.pending_attrs = Attrs::default();
223 - }
224 - 1 => self.pending_attrs.bold = true,
225 - 2 => self.pending_attrs.dim = true,
226 - 3 => self.pending_attrs.italic = true,
227 - 4 => self.pending_attrs.underline = true,
228 - 7 => self.pending_attrs.reverse = true,
229 - 9 => self.pending_attrs.strikethrough = true,
230 - 22 => {
231 - self.pending_attrs.bold = false;
232 - self.pending_attrs.dim = false;
233 - }
234 - 23 => self.pending_attrs.italic = false,
235 - 24 => self.pending_attrs.underline = false,
236 - 27 => self.pending_attrs.reverse = false,
237 - 29 => self.pending_attrs.strikethrough = false,
238 - 30..=37 => self.pending_fg = Color::Named((p - 30) as u8),
239 - 38 => {
240 - if let Some((c, consumed)) = parse_extended_color(&flat[i + 1..]) {
241 - self.pending_fg = c;
242 - i += consumed;
243 - }
244 - }
245 - 39 => self.pending_fg = Color::Default,
246 - 40..=47 => self.pending_bg = Color::Named((p - 40) as u8),
247 - 48 => {
248 - if let Some((c, consumed)) = parse_extended_color(&flat[i + 1..]) {
249 - self.pending_bg = c;
250 - i += consumed;
251 - }
252 - }
253 - 49 => self.pending_bg = Color::Default,
254 - 90..=97 => self.pending_fg = Color::Named(((p - 90) + 8) as u8),
255 - 100..=107 => self.pending_bg = Color::Named(((p - 100) + 8) as u8),
256 - _ => {}
224 + while i < groups.len() {
225 + let group = groups[i];
226 + if group.len() > 1 {
227 + // Colon form — the whole group is one logical SGR.
228 + self.apply_colon_group(group);
229 + i += 1;
230 + continue;
257 231 }
258 - i += 1;
232 + let p = group.first().copied().unwrap_or(0);
233 + i += self.apply_single(p, &groups, i);
234 + }
235 + }
236 +
237 + /// Semicolon-form: `p` came from a single-subparam group. Returns how many
238 + /// group indices were consumed (usually 1, or up to 5 for extended color).
239 + fn apply_single(&mut self, p: u16, groups: &[&[u16]], i: usize) -> usize {
240 + match p {
241 + 0 => {
242 + self.pending_fg = Color::Default;
243 + self.pending_bg = Color::Default;
244 + self.pending_attrs = Attrs::default();
245 + 1
246 + }
247 + 1 => {
248 + self.pending_attrs.bold = true;
249 + 1
250 + }
251 + 2 => {
252 + self.pending_attrs.dim = true;
253 + 1
254 + }
255 + 3 => {
256 + self.pending_attrs.italic = true;
257 + 1
258 + }
259 + 4 => {
260 + self.pending_attrs.underline = true;
261 + 1
262 + }
263 + 7 => {
264 + self.pending_attrs.reverse = true;
265 + 1
266 + }
267 + 9 => {
268 + self.pending_attrs.strikethrough = true;
269 + 1
270 + }
271 + 22 => {
272 + self.pending_attrs.bold = false;
273 + self.pending_attrs.dim = false;
274 + 1
275 + }
276 + 23 => {
277 + self.pending_attrs.italic = false;
278 + 1
279 + }
280 + 24 => {
281 + self.pending_attrs.underline = false;
282 + 1
283 + }
284 + 27 => {
285 + self.pending_attrs.reverse = false;
286 + 1
287 + }
288 + 29 => {
289 + self.pending_attrs.strikethrough = false;
290 + 1
291 + }
292 + 30..=37 => {
293 + self.pending_fg = Color::Named((p - 30) as u8);
294 + 1
295 + }
296 + 38 => {
297 + let (color, consumed) = take_semi_extended(groups, i + 1);
298 + if let Some(c) = color {
299 + self.pending_fg = c;
300 + }
301 + 1 + consumed
302 + }
303 + 39 => {
304 + self.pending_fg = Color::Default;
305 + 1
306 + }
307 + 40..=47 => {
308 + self.pending_bg = Color::Named((p - 40) as u8);
309 + 1
310 + }
311 + 48 => {
312 + let (color, consumed) = take_semi_extended(groups, i + 1);
313 + if let Some(c) = color {
314 + self.pending_bg = c;
315 + }
316 + 1 + consumed
317 + }
318 + 49 => {
319 + self.pending_bg = Color::Default;
320 + 1
321 + }
322 + 90..=97 => {
323 + self.pending_fg = Color::Named(((p - 90) + 8) as u8);
324 + 1
325 + }
326 + 100..=107 => {
327 + self.pending_bg = Color::Named(((p - 100) + 8) as u8);
328 + 1
329 + }
330 + _ => 1,
331 + }
332 + }
333 +
334 + /// Colon-form group: leading value is the SGR code, subsequent subparams
335 + /// carry the extended-color payload. We only recognize 38 (fg) and 48 (bg)
336 + /// here — colon-form underline color is a follow-up.
337 + fn apply_colon_group(&mut self, group: &[u16]) {
338 + let leading = group[0];
339 + let color = parse_colon_extended(&group[1..]);
340 + match leading {
341 + 38 => {
342 + if let Some(c) = color {
343 + self.pending_fg = c;
344 + }
345 + }
346 + 48 => {
347 + if let Some(c) = color {
348 + self.pending_bg = c;
349 + }
350 + }
351 + _ => {}
259 352 }
260 353 }
261 354
@@ -372,18 +465,54 @@
372 465 }
373 466 }
374 467
375 - /// Parse the tail of a `38;…` or `48;…` extended-color SGR. Returns the
376 - /// parsed color and the number of parameters consumed *after* the leading
377 - /// `38`/`48`. Both `;5;N` (indexed) and `;2;R;G;B` (truecolor) supported.
378 - fn parse_extended_color(rest: &[u16]) -> Option<(Color, usize)> {
379 - match rest.first().copied()? {
380 - 5 => rest.get(1).map(|&i| (Color::Indexed(i.min(255) as u8), 2)),
381 - 2 => {
382 - let r = rest.get(1).copied().unwrap_or(0).min(255) as u8;
383 - let g = rest.get(2).copied().unwrap_or(0).min(255) as u8;
384 - let b = rest.get(3).copied().unwrap_or(0).min(255) as u8;
385 - Some((Color::Rgb(r, g, b), 4))
468 + /// Parse extended color from the following semicolon-separated groups. Handles
469 + /// `2;R;G;B` (RGB) and `5;N` (indexed). Returns the parsed color and the
470 + /// number of groups consumed after the leading `38`/`48`.
471 + fn take_semi_extended(groups: &[&[u16]], start: usize) -> (Option<Color>, usize) {
472 + let Some(fmt) = groups.get(start).and_then(|g| g.first().copied()) else {
473 + return (None, 0);
474 + };
475 + match fmt {
476 + 5 => {
477 + let Some(idx) = groups.get(start + 1).and_then(|g| g.first().copied()) else {
478 + return (None, 1);
479 + };
480 + (Some(Color::Indexed(idx.min(255) as u8)), 2)
386 481 }
482 + 2 => {
483 + let r = groups.get(start + 1).and_then(|g| g.first().copied()).unwrap_or(0);
484 + let g = groups.get(start + 2).and_then(|g| g.first().copied()).unwrap_or(0);
485 + let b = groups.get(start + 3).and_then(|g| g.first().copied()).unwrap_or(0);
486 + (
487 + Some(Color::Rgb(r.min(255) as u8, g.min(255) as u8, b.min(255) as u8)),
488 + 4,
489 + )
490 + }
491 + _ => (None, 1),
492 + }
493 + }
494 +
495 + /// Parse extended color from a colon-form subparam tail — the bytes after the
496 + /// leading `38`/`48`. `2;colorspace;R;G;B` (5 items) OR `2;R;G;B` (4 items)
497 + /// OR `5;N` (2 items). Colorspace slot is skipped when present.
498 + fn parse_colon_extended(rest: &[u16]) -> Option<Color> {
499 + match rest.first().copied()? {
500 + 5 => rest.get(1).map(|&i| Color::Indexed(i.min(255) as u8)),
501 + 2 => match rest.len() {
502 + // [2, colorspace, R, G, B]
503 + 5 => Some(Color::Rgb(
504 + rest[2].min(255) as u8,
505 + rest[3].min(255) as u8,
506 + rest[4].min(255) as u8,
507 + )),
508 + // [2, R, G, B]
509 + 4 => Some(Color::Rgb(
510 + rest[1].min(255) as u8,
511 + rest[2].min(255) as u8,
512 + rest[3].min(255) as u8,
513 + )),
514 + _ => None,
515 + },
387 516 _ => None,
388 517 }
389 518 }
@@ -584,3 +713,337 @@
584 713 }
585 714 }
586 715 }
716 +
717 + #[cfg(test)]
718 + mod tests {
719 + use super::*;
720 + use shop_vt::Parser;
721 +
722 + /// Feed `bytes` through a fresh Parser into `grid`.
723 + fn feed(grid: &mut Grid, bytes: &[u8]) {
724 + let mut p = Parser::new();
725 + p.advance(grid, bytes);
726 + }
727 +
728 + fn row_str(grid: &Grid, r: u16) -> String {
729 + grid.row(r)
730 + .iter()
731 + .map(|c| c.c)
732 + .collect::<String>()
733 + .trim_end()
734 + .to_string()
735 + }
736 +
737 + fn assert_cursor(grid: &Grid, row: u16, col: u16) {
738 + let c = grid.cursor();
739 + assert_eq!(
740 + (c.row, c.col),
741 + (row, col),
742 + "cursor mismatch (wrap_next={})",
743 + c.wrap_next
744 + );
745 + }
746 +
747 + // ---- basics --------------------------------------------------------
748 +
749 + #[test]
750 + fn new_grid_is_all_spaces() {
751 + let g = Grid::new(10, 3);
752 + for r in 0..3 {
753 + assert_eq!(row_str(&g, r), "");
754 + }
755 + assert_cursor(&g, 0, 0);
756 + }
757 +
758 + #[test]
759 + fn print_advances_cursor() {
760 + let mut g = Grid::new(20, 3);
761 + feed(&mut g, b"hello");
762 + assert_eq!(row_str(&g, 0), "hello");
763 + assert_cursor(&g, 0, 5);
764 + }
765 +
766 + #[test]
767 + fn cr_lf_move_to_next_row_col_zero() {
768 + let mut g = Grid::new(20, 3);
769 + feed(&mut g, b"one\r\ntwo");
770 + assert_eq!(row_str(&g, 0), "one");
771 + assert_eq!(row_str(&g, 1), "two");
772 + assert_cursor(&g, 1, 3);
773 + }
774 +
775 + #[test]
776 + fn backspace_moves_cursor_back() {
777 + let mut g = Grid::new(20, 3);
778 + feed(&mut g, b"abc\x08");
779 + assert_cursor(&g, 0, 2);
780 + // BS is non-destructive — 'c' still there.
781 + assert_eq!(row_str(&g, 0), "abc");
782 + }
783 +
784 + #[test]
785 + fn tab_advances_to_next_multiple_of_eight() {
786 + let mut g = Grid::new(40, 3);
787 + feed(&mut g, b"ab\t");
788 + assert_cursor(&g, 0, 8);
789 + feed(&mut g, b"c");
790 + assert_cursor(&g, 0, 9);
791 + }
792 +
793 + // ---- wrap ----------------------------------------------------------
794 +
795 + #[test]
796 + fn deferred_wrap_on_rightmost_cell() {
797 + let mut g = Grid::new(4, 3);
798 + feed(&mut g, b"ABCD");
799 + // After 4 chars in a 4-wide grid, cursor is at col 3 with wrap_next.
800 + assert_eq!(row_str(&g, 0), "ABCD");
801 + let c = g.cursor();
802 + assert!(c.wrap_next, "should have wrap_next set");
803 + feed(&mut g, b"E");
804 + // Next print wraps to row 1 col 0.
805 + assert_eq!(row_str(&g, 1), "E");
806 + }
807 +
808 + // ---- CSI cursor motion --------------------------------------------
809 +
810 + #[test]
811 + fn cup_moves_cursor_one_indexed() {
812 + let mut g = Grid::new(20, 5);
813 + feed(&mut g, b"\x1b[3;5H");
814 + assert_cursor(&g, 2, 4);
815 + }
816 +
817 + #[test]
818 + fn cup_defaults_to_top_left() {
819 + let mut g = Grid::new(20, 5);
820 + feed(&mut g, b"aaa\r\nbbb\r\nccc\x1b[H");
821 + assert_cursor(&g, 0, 0);
822 + }
823 +
824 + #[test]
825 + fn cuu_cud_cuf_cub_clamp_at_edges() {
826 + let mut g = Grid::new(20, 5);
827 + feed(&mut g, b"\x1b[100A"); // way up — should clamp at 0
828 + assert_cursor(&g, 0, 0);
829 + feed(&mut g, b"\x1b[100B"); // way down — clamp at rows-1
830 + assert_cursor(&g, 4, 0);
831 + feed(&mut g, b"\x1b[100C"); // way right — clamp at cols-1
832 + assert_cursor(&g, 4, 19);
833 + feed(&mut g, b"\x1b[100D"); // way left — clamp at 0
834 + assert_cursor(&g, 4, 0);
835 + }
836 +
837 + #[test]
838 + fn cha_and_vpa_position_absolutely() {
839 + let mut g = Grid::new(20, 5);
840 + feed(&mut g, b"\x1b[10G\x1b[3d");
841 + assert_cursor(&g, 2, 9);
842 + }
843 +
844 + // ---- erase ---------------------------------------------------------
845 +
846 + #[test]
847 + fn el0_erases_cursor_to_end() {
848 + let mut g = Grid::new(10, 2);
849 + feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[K");
850 + assert_eq!(row_str(&g, 0), "ABC");
851 + }
852 +
853 + #[test]
854 + fn el1_erases_start_to_cursor() {
855 + let mut g = Grid::new(10, 2);
856 + feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[1K");
857 + // Cells 0..=3 cleared, 4..=9 kept.
858 + assert_eq!(row_str(&g, 0), " EFGHIJ");
859 + }
860 +
861 + #[test]
862 + fn el2_erases_whole_line() {
863 + let mut g = Grid::new(10, 2);
864 + feed(&mut g, b"ABCDEFGHIJ\x1b[H\x1b[3C\x1b[2K");
865 + assert_eq!(row_str(&g, 0), "");
866 + }
867 +
868 + #[test]
869 + fn ed2_erases_whole_screen() {
870 + let mut g = Grid::new(6, 3);
871 + feed(&mut g, b"aaaaaa\r\nbbbbbb\r\ncccccc\x1b[2J");
872 + for r in 0..3 {
873 + assert_eq!(row_str(&g, r), "");
874 + }
875 + }
876 +
877 + // ---- SGR -----------------------------------------------------------
878 +
879 + #[test]
880 + fn sgr_named_fg_and_bg() {
881 + let mut g = Grid::new(10, 1);
882 + feed(&mut g, b"\x1b[31;44mA");
883 + let cell = g.row(0)[0];
884 + assert_eq!(cell.fg, Color::Named(1)); // red
885 + assert_eq!(cell.bg, Color::Named(4)); // blue
886 + }
887 +
888 + #[test]
889 + fn sgr_bright_named() {
890 + let mut g = Grid::new(10, 1);
891 + feed(&mut g, b"\x1b[92mA");
892 + assert_eq!(g.row(0)[0].fg, Color::Named(10)); // bright green = 8+2
893 + }
894 +
895 + #[test]
896 + fn sgr_indexed_256() {
897 + let mut g = Grid::new(10, 1);
898 + feed(&mut g, b"\x1b[38;5;123mA");
899 + assert_eq!(g.row(0)[0].fg, Color::Indexed(123));
900 + }
901 +
902 + #[test]
903 + fn sgr_truecolor_rgb() {
904 + let mut g = Grid::new(10, 1);
905 + feed(&mut g, b"\x1b[38;2;255;128;0mA");
906 + assert_eq!(g.row(0)[0].fg, Color::Rgb(255, 128, 0));
907 + }
908 +
909 + #[test]
910 + fn sgr_colon_subparam_rgb() {
911 + // The ITU-T `:` form: `\e[38:2::255:128:0m` — one param with
912 + // subparams. Our SGR parser flattens both forms.
913 + let mut g = Grid::new(10, 1);
914 + feed(&mut g, b"\x1b[38:2::255:128:0mA");
915 + assert_eq!(g.row(0)[0].fg, Color::Rgb(255, 128, 0));
916 + }
917 +
918 + #[test]
919 + fn sgr_attrs_bold_italic_underline() {
920 + let mut g = Grid::new(10, 1);
921 + feed(&mut g, b"\x1b[1;3;4mA");
922 + let a = g.row(0)[0].attrs;
923 + assert!(a.bold && a.italic && a.underline);
924 + }
925 +
926 + #[test]
927 + fn sgr_reset_clears_everything() {
928 + let mut g = Grid::new(10, 1);
929 + feed(&mut g, b"\x1b[1;31;44mA\x1b[mB");
930 + let a = g.row(0)[0];
931 + let b = g.row(0)[1];
932 + assert_eq!(a.fg, Color::Named(1));
933 + assert!(a.attrs.bold);
934 + assert_eq!(b.fg, Color::Default);
935 + assert_eq!(b.bg, Color::Default);
936 + assert!(!b.attrs.bold);
937 + }
938 +
939 + #[test]
940 + fn sgr_selective_clears() {
941 + let mut g = Grid::new(10, 1);
942 + feed(&mut g, b"\x1b[1;3mA\x1b[22mB\x1b[23mC");
943 + assert!(g.row(0)[0].attrs.bold && g.row(0)[0].attrs.italic);
944 + // 22 clears bold + dim; italic stays.
945 + assert!(!g.row(0)[1].attrs.bold && g.row(0)[1].attrs.italic);
946 + // 23 clears italic.
947 + assert!(!g.row(0)[2].attrs.italic);
948 + }
949 +
950 + // ---- cursor shape --------------------------------------------------
951 +
952 + #[test]
953 + fn decscusr_sets_shape() {
954 + let mut g = Grid::new(10, 2);
955 + assert_eq!(g.cursor_shape(), CursorShape::Block);
956 + feed(&mut g, b"\x1b[3 q");
Lines truncated