Skip to main content

max / shop

Cell packing: 20 -> 12 bytes, foot-shaped Three u32s (align 4, no padding): c_raw + fg_word + bg_word. Each color word holds a 24-bit RGB payload + 2-bit source tag; the fg word's top 6 bits carry the SGR attribute flags, the bg word's top 6 are spare. Static assert on size_of::<Cell>() == 12 in the crate. Public API flips from struct fields to accessors: cell.c()/fg()/bg()/ attrs() plus hot-path readers cell.reverse()/underline()/has_bg() for the renderer's per-frame fills scan. Construction via Cell::new(...). Motivation: task 49e44ae7 in the shop GO project. The 2026-07-24 vtebench comparison against foot 1.16.2 pinned shop at 2-4x slower on cell workloads (dense/medium/light) while matching on scrolling; cell packing is the largest single contributor because it hits every hot path -- parser print, scroll memcpy, damage iteration, renderer scan. Prerequisite for the ASCII fast-path printer (task cabe6d1c) next.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 14:32 UTC
Signed with PGP, not checked
Commit: 61337644aa4909efbb09a14cfae06b807483a64a
Parent: b5d7133
2 files changed, +163 insertions, -44 deletions
@@ -23,8 +23,7 @@
23 23 Rgb(u8, u8, u8),
24 24 }
25 25
26 - /// SGR-set attributes for a cell. Not rendered visually yet; parsed for
27 - /// completeness and to keep the field shape stable.
26 + /// SGR-set attributes for a cell.
28 27 #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
29 28 pub struct Attrs {
30 29 pub bold: bool,
@@ -35,26 +34,149 @@
35 34 pub strikethrough: bool,
36 35 }
37 36
38 - /// One cell of the display grid.
39 - #[derive(Copy, Clone, Debug, PartialEq)]
37 + /// One cell of the display grid — 12 bytes, matching foot's layout.
38 + ///
39 + /// Three `u32`s so the struct aligns to 4 (a `u64` would pad to 16). Each
40 + /// color word holds a 24-bit RGB payload, a 2-bit source tag, and a 6-bit
41 + /// attribute half — the attribute bits live in `fg_word`, `bg_word`'s upper
42 + /// 6 bits are spare.
43 + ///
44 + /// Layout of `fg_word` / `bg_word`:
45 + /// - bits 0–23: color payload — RGB if `src == Rgb`, low byte = palette index if Named/Indexed
46 + /// - bits 24–25: source tag (`SRC_DEFAULT`/`NAMED`/`INDEXED`/`RGB`)
47 + /// - bits 26–31: attribute flags (fg_word only; bg_word spare)
48 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
49 + #[repr(C)]
40 50 pub struct Cell {
41 - pub c: char,
42 - pub fg: Color,
43 - pub bg: Color,
44 - pub attrs: Attrs,
51 + c_raw: u32,
52 + fg_word: u32,
53 + bg_word: u32,
54 + }
55 +
56 + const SRC_DEFAULT: u32 = 0;
57 + const SRC_NAMED: u32 = 1;
58 + const SRC_INDEXED: u32 = 2;
59 + const SRC_RGB: u32 = 3;
60 +
61 + const SRC_SHIFT: u32 = 24;
62 + const SRC_MASK: u32 = 0b11 << SRC_SHIFT;
63 + const RGB_MASK: u32 = 0x00FF_FFFF;
64 +
65 + const ATTR_BOLD: u32 = 1 << 26;
66 + const ATTR_ITALIC: u32 = 1 << 27;
67 + const ATTR_UNDERLINE: u32 = 1 << 28;
68 + const ATTR_REVERSE: u32 = 1 << 29;
69 + const ATTR_DIM: u32 = 1 << 30;
70 + const ATTR_STRIKE: u32 = 1 << 31;
71 +
72 + fn encode_color(c: Color) -> u32 {
73 + match c {
74 + Color::Default => SRC_DEFAULT << SRC_SHIFT,
75 + Color::Named(i) => (SRC_NAMED << SRC_SHIFT) | i as u32,
76 + Color::Indexed(i) => (SRC_INDEXED << SRC_SHIFT) | i as u32,
77 + Color::Rgb(r, g, b) => {
78 + (SRC_RGB << SRC_SHIFT)
79 + | ((r as u32) << 16)
80 + | ((g as u32) << 8)
81 + | b as u32
82 + }
83 + }
84 + }
85 +
86 + fn decode_color(word: u32) -> Color {
87 + let payload = word & RGB_MASK;
88 + match (word & SRC_MASK) >> SRC_SHIFT {
89 + SRC_NAMED => Color::Named(payload as u8),
90 + SRC_INDEXED => Color::Indexed(payload as u8),
91 + SRC_RGB => Color::Rgb(
92 + (payload >> 16) as u8,
93 + (payload >> 8) as u8,
94 + payload as u8,
95 + ),
96 + _ => Color::Default,
97 + }
98 + }
99 +
100 + fn encode_attrs(a: Attrs) -> u32 {
101 + let mut bits = 0u32;
102 + if a.bold { bits |= ATTR_BOLD; }
103 + if a.italic { bits |= ATTR_ITALIC; }
104 + if a.underline { bits |= ATTR_UNDERLINE; }
105 + if a.reverse { bits |= ATTR_REVERSE; }
106 + if a.dim { bits |= ATTR_DIM; }
107 + if a.strikethrough { bits |= ATTR_STRIKE; }
108 + bits
109 + }
110 +
111 + impl Cell {
112 + pub fn new(c: char, fg: Color, bg: Color, attrs: Attrs) -> Self {
113 + Self {
114 + c_raw: c as u32,
115 + fg_word: encode_color(fg) | encode_attrs(attrs),
116 + bg_word: encode_color(bg),
117 + }
118 + }
119 +
120 + #[inline]
121 + pub fn c(&self) -> char {
122 + // Grid only ever writes valid `char` values into `c_raw`.
123 + char::from_u32(self.c_raw).unwrap_or('\u{FFFD}')
124 + }
125 +
126 + #[inline]
127 + pub fn fg(&self) -> Color {
128 + decode_color(self.fg_word)
129 + }
130 +
131 + #[inline]
132 + pub fn bg(&self) -> Color {
133 + decode_color(self.bg_word)
134 + }
135 +
136 + pub fn attrs(&self) -> Attrs {
137 + let b = self.fg_word;
138 + Attrs {
139 + bold: b & ATTR_BOLD != 0,
140 + italic: b & ATTR_ITALIC != 0,
141 + underline: b & ATTR_UNDERLINE != 0,
142 + reverse: b & ATTR_REVERSE != 0,
143 + dim: b & ATTR_DIM != 0,
144 + strikethrough: b & ATTR_STRIKE != 0,
145 + }
146 + }
147 +
148 + // Hot-path attribute readers — used by the renderer's per-frame fills
149 + // scan, which touches every visible cell. Avoids materializing `Attrs`.
150 + #[inline]
151 + pub fn reverse(&self) -> bool {
152 + self.fg_word & ATTR_REVERSE != 0
153 + }
154 +
155 + #[inline]
156 + pub fn underline(&self) -> bool {
157 + self.fg_word & ATTR_UNDERLINE != 0
158 + }
159 +
160 + /// Fast "does this cell need a bg fill drawn" check for the fills scan.
161 + /// True when bg is not Default OR reverse is set.
162 + #[inline]
163 + pub fn has_bg(&self) -> bool {
164 + (self.bg_word & SRC_MASK) != 0 || self.reverse()
165 + }
45 166 }
46 167
47 168 impl Default for Cell {
48 169 fn default() -> Self {
49 170 Self {
50 - c: ' ',
51 - fg: Color::Default,
52 - bg: Color::Default,
53 - attrs: Attrs::default(),
171 + c_raw: ' ' as u32,
172 + fg_word: 0,
173 + bg_word: 0,
54 174 }
55 175 }
56 176 }
57 177
178 + const _: () = assert!(std::mem::size_of::<Cell>() == 12);
179 +
58 180 /// Shape hint from DECSCUSR (`CSI Ps SP q`). Blink flag is ignored — MVP
59 181 /// renders all as steady.
60 182 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -393,12 +515,8 @@
393 515 }
394 516 let row = self.cursor.row;
395 517 let idx = self.cell_index(row, self.cursor.col);
396 - self.active_cells_mut()[idx] = Cell {
397 - c,
398 - fg: self.pending_fg,
399 - bg: self.pending_bg,
400 - attrs: self.pending_attrs,
401 - };
518 + self.active_cells_mut()[idx] =
519 + Cell::new(c, self.pending_fg, self.pending_bg, self.pending_attrs);
402 520 self.mark_row_dirty(row);
403 521 if self.cursor.col + 1 >= self.cols {
404 522 self.cursor.wrap_next = true;
@@ -1001,7 +1119,7 @@
1001 1119 fn row_str(grid: &Grid, r: u16) -> String {
1002 1120 grid.row(r)
1003 1121 .iter()
1004 - .map(|c| c.c)
1122 + .map(|cell| cell.c())
1005 1123 .collect::<String>()
1006 1124 .trim_end()
1007 1125 .to_string()
@@ -1154,29 +1272,29 @@
1154 1272 let mut g = Grid::new(10, 1);
1155 1273 feed(&mut g, b"\x1b[31;44mA");
1156 1274 let cell = g.row(0)[0];
1157 - assert_eq!(cell.fg, Color::Named(1)); // red
1158 - assert_eq!(cell.bg, Color::Named(4)); // blue
1275 + assert_eq!(cell.fg(), Color::Named(1)); // red
1276 + assert_eq!(cell.bg(), Color::Named(4)); // blue
1159 1277 }
1160 1278
1161 1279 #[test]
1162 1280 fn sgr_bright_named() {
1163 1281 let mut g = Grid::new(10, 1);
1164 1282 feed(&mut g, b"\x1b[92mA");
1165 - assert_eq!(g.row(0)[0].fg, Color::Named(10)); // bright green = 8+2
1283 + assert_eq!(g.row(0)[0].fg(), Color::Named(10)); // bright green = 8+2
1166 1284 }
1167 1285
1168 1286 #[test]
1169 1287 fn sgr_indexed_256() {
1170 1288 let mut g = Grid::new(10, 1);
1171 1289 feed(&mut g, b"\x1b[38;5;123mA");
1172 - assert_eq!(g.row(0)[0].fg, Color::Indexed(123));
1290 + assert_eq!(g.row(0)[0].fg(), Color::Indexed(123));
1173 1291 }
1174 1292
1175 1293 #[test]
1176 1294 fn sgr_truecolor_rgb() {
1177 1295 let mut g = Grid::new(10, 1);
1178 1296 feed(&mut g, b"\x1b[38;2;255;128;0mA");
1179 - assert_eq!(g.row(0)[0].fg, Color::Rgb(255, 128, 0));
1297 + assert_eq!(g.row(0)[0].fg(), Color::Rgb(255, 128, 0));
1180 1298 }
1181 1299
1182 1300 #[test]
@@ -1185,14 +1303,14 @@
1185 1303 // subparams. Our SGR parser flattens both forms.
1186 1304 let mut g = Grid::new(10, 1);
1187 1305 feed(&mut g, b"\x1b[38:2::255:128:0mA");
1188 - assert_eq!(g.row(0)[0].fg, Color::Rgb(255, 128, 0));
1306 + assert_eq!(g.row(0)[0].fg(), Color::Rgb(255, 128, 0));
1189 1307 }
1190 1308
1191 1309 #[test]
1192 1310 fn sgr_attrs_bold_italic_underline() {
1193 1311 let mut g = Grid::new(10, 1);
1194 1312 feed(&mut g, b"\x1b[1;3;4mA");
1195 - let a = g.row(0)[0].attrs;
1313 + let a = g.row(0)[0].attrs();
1196 1314 assert!(a.bold && a.italic && a.underline);
1197 1315 }
1198 1316
@@ -1202,22 +1320,22 @@
1202 1320 feed(&mut g, b"\x1b[1;31;44mA\x1b[mB");
1203 1321 let a = g.row(0)[0];
1204 1322 let b = g.row(0)[1];
1205 - assert_eq!(a.fg, Color::Named(1));
1206 - assert!(a.attrs.bold);
1207 - assert_eq!(b.fg, Color::Default);
1208 - assert_eq!(b.bg, Color::Default);
1209 - assert!(!b.attrs.bold);
1323 + assert_eq!(a.fg(), Color::Named(1));
1324 + assert!(a.attrs().bold);
1325 + assert_eq!(b.fg(), Color::Default);
1326 + assert_eq!(b.bg(), Color::Default);
1327 + assert!(!b.attrs().bold);
1210 1328 }
1211 1329
1212 1330 #[test]
1213 1331 fn sgr_selective_clears() {
1214 1332 let mut g = Grid::new(10, 1);
1215 1333 feed(&mut g, b"\x1b[1;3mA\x1b[22mB\x1b[23mC");
1216 - assert!(g.row(0)[0].attrs.bold && g.row(0)[0].attrs.italic);
1334 + assert!(g.row(0)[0].attrs().bold && g.row(0)[0].attrs().italic);
1217 1335 // 22 clears bold + dim; italic stays.
1218 - assert!(!g.row(0)[1].attrs.bold && g.row(0)[1].attrs.italic);
1336 + assert!(!g.row(0)[1].attrs().bold && g.row(0)[1].attrs().italic);
1219 1337 // 23 clears italic.
1220 - assert!(!g.row(0)[2].attrs.italic);
1338 + assert!(!g.row(0)[2].attrs().italic);
1221 1339 }
1222 1340
1223 1341 // ---- cursor shape --------------------------------------------------
@@ -570,13 +570,13 @@
570 570 &app.queue,
571 571 row,
572 572 app.grid.row(row).iter().enumerate().filter_map(|(col, cell)| {
573 - let c = cell.c;
573 + let c = cell.c();
574 574 if c == ' ' || c == '\0' {
575 575 return None;
576 576 }
577 - let mut fg = resolve_color(cell.fg, DEFAULT_FG);
578 - let mut bg = resolve_color(cell.bg, DEFAULT_BG);
579 - if cell.attrs.reverse {
577 + let mut fg = resolve_color(cell.fg(), DEFAULT_FG);
578 + let mut bg = resolve_color(cell.bg(), DEFAULT_BG);
579 + if cell.reverse() {
580 580 std::mem::swap(&mut fg, &mut bg);
581 581 }
582 582 let _ = bg;
@@ -594,20 +594,21 @@
594 594 for r in 0..app.grid.rows() {
595 595 let y = pad_y_px + r as f32 * cell_h_px;
596 596 for (col, cell) in app.grid.row(r).iter().enumerate() {
597 - let has_bg = cell.bg != GridColor::Default || cell.attrs.reverse;
598 - if !has_bg && !cell.attrs.underline {
597 + let has_bg = cell.has_bg();
598 + let underline = cell.underline();
599 + if !has_bg && !underline {
599 600 continue;
600 601 }
601 602 let x = pad_x_px + col as f32 * cell_w_px;
602 - let mut fg = resolve_color(cell.fg, DEFAULT_FG);
603 + let mut fg = resolve_color(cell.fg(), DEFAULT_FG);
603 604 if has_bg {
604 - let mut bg = resolve_color(cell.bg, DEFAULT_BG);
605 - if cell.attrs.reverse {
605 + let mut bg = resolve_color(cell.bg(), DEFAULT_BG);
606 + if cell.reverse() {
606 607 std::mem::swap(&mut fg, &mut bg);
607 608 }
608 609 fills.push(BgFill { x, y, w: cell_w_px, h: cell_h_px, color: bg });
609 610 }
610 - if cell.attrs.underline {
611 + if underline {
611 612 fills.push(BgFill {
612 613 x,
613 614 y: y + cell_h_px - 2.0 * s,