From fa5260c120268f1c1e3dbd2a17264e5896476576 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 21:44:52 -0300 Subject: Move the screen types out of pardes.zig into surface.zig Pure move: Color, FontRole, CellStyle, Cell, AsciiDiff, PanelCellDiff, ImageCacheKey, NativePlacement, ImagePatch, ImagePlace and Surface, with their eight tests, go verbatim to the new surface.zig with the imports they use. pardes.zig keeps one `pub const X = @import("surface.zig").X;` line per type, as it already does for Pane, Rect and the tag layers, so the shells' `pardes.Cell` / `pardes.Surface` spellings do not change. Surface.print, clearRect and overlayDash become pub because render calls them from pardes.zig. surface.zig is added to pardes.zig's test block and to the embedded source list. Co-Authored-By: Claude Opus 5.5 --- src/surface.zig | 647 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 647 insertions(+) create mode 100644 src/surface.zig (limited to 'src/surface.zig') diff --git a/src/surface.zig b/src/surface.zig new file mode 100644 index 00000000..a9e69cca --- /dev/null +++ b/src/surface.zig @@ -0,0 +1,647 @@ +//! The canonical screen every shell draws: cells, their styles and diffs, +//! the image placements laid over them, and the Surface that holds a frame. +const pardes = @import("pardes.zig"); +const std = @import("std"); +const layout = @import("layout.zig"); +const uucode = @import("uucode"); +const vaxis = @import("vaxis"); +const panes = @import("panes.zig"); +const config = @import("config.zig"); +const pdf_enabled = panes.Pdf.enabled; +const image = @import("image.zig"); +const MAX_PANES = pardes.MAX_PANES; +const BodyLayer = @import("body_layer.zig").Layer; +const TagLayer = @import("tag_layer.zig").Layer; +const MAX_TAG_LAYERS = pardes.MAX_TAG_LAYERS; +const CellPixels = pardes.CellPixels; + +pub const Color = union(enum) { default, index: u8, rgb: [3]u8 }; + +pub const FontRole = enum(u8) { body, tagline }; + +pub const CellStyle = struct { + fg: Color = .default, + bg: Color = .default, + bold: bool = false, + dim: bool = false, + italic: bool = false, + blink: bool = false, + reverse: bool = false, + invisible: bool = false, + strikethrough: bool = false, + ul: enum(u3) { off, single, double, curly, dotted, dashed } = .off, + font_role: FontRole = .body, +}; + +pub const Cell = struct { + text: [7]u8 = @splat(' '), + len: u8 = 1, + style: CellStyle = .{}, + default: bool = true, + /// The look-hover affordance is on this cell: the word under the pointer + /// is a real Look or Exec operand, and hosts that draw the quiet tint as + /// more than a flat fill — macOS renders it as a glass overlay — read + /// this instead of guessing from the background colour, which a real + /// selection shares. + hover: bool = false, + + pub fn grapheme(c: *const Cell) []const u8 { + return c.text[0..c.len]; + } + + pub fn visuallyEqual(a: *const Cell, b: *const Cell) bool { + if (a.default or b.default) return a.default and b.default; + return a.len == b.len and + std.mem.eql(u8, a.grapheme(), b.grapheme()) and + std.meta.eql(a.style, b.style); + } + + pub fn printableAscii(c: *const Cell) ?u8 { + if (c.default) return ' '; + if (c.len != 1) return null; + const byte = c.text[0]; + return if (byte >= ' ' and byte <= '~') byte else null; + } +}; + +pub const AsciiDiff = struct { + from: u8, + to: u8, + + /// Long printable-byte walks complete in about the same time as the other + /// panel effects. Extra distance is crossed by eased character skips. + pub const max_movement_frames = layout.ascii_max_movement_frames; + + pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { + const from = old.printableAscii() orelse return null; + const to = new.printableAscii() orelse return null; + if (from == to) return null; + return .{ .from = from, .to = to }; + } + + pub fn distance(diff: AsciiDiff) u8 { + return if (diff.from < diff.to) diff.to - diff.from else diff.from - diff.to; + } + + pub fn movementFrames(diff: AsciiDiff) u16 { + return @min(@as(u16, diff.distance()), max_movement_frames); + } + + pub fn frameCount(diff: AsciiDiff) u16 { + return diff.movementFrames() + 1; + } + + pub fn byteAt(diff: AsciiDiff, frame: u16) u8 { + const movements: u32 = diff.movementFrames(); + const at: u32 = @min(@as(u32, frame), movements); + const delta: u8 = @intCast(easedDistance(diff.distance(), movements, at)); + return if (diff.from < diff.to) diff.from + delta else diff.from - delta; + } + + fn easedDistance(span: u8, movements: u32, at: u32) u32 { + if (movements == 0 or at >= movements) return span; + const n: u64 = at; + const d: u64 = movements; + const shape = n * n * n * (10 * d * d + 6 * n * n - 15 * d * n); + const denominator = d * d * d * d * d; + return @intCast((@as(u64, span) * shape + denominator / 2) / denominator); + } + + pub fn complete(diff: AsciiDiff, frame: u16) bool { + return diff.byteAt(frame) == diff.to; + } +}; + +pub const PanelCellDiff = union(enum) { + unchanged, + visual, + ascii: AsciiDiff, + + pub fn between(old: *const Cell, new: *const Cell) PanelCellDiff { + if (old.visuallyEqual(new)) return .unchanged; + if (AsciiDiff.between(old, new)) |diff| return .{ .ascii = diff }; + return .visual; + } + + pub fn changed(diff: PanelCellDiff) bool { + return diff != .unchanged; + } +}; + +test "cell visual equality ignores dead grapheme tail bytes" { + var a: Cell = .{ .default = false }; + var b = a; + a.text[0] = 'x'; + b.text[0] = 'x'; + a.text[4] = 'a'; + b.text[4] = 'b'; + try std.testing.expect(a.visuallyEqual(&b)); + b.style.bold = true; + try std.testing.expect(!a.visuallyEqual(&b)); + a.default = true; + b.default = true; + try std.testing.expect(a.visuallyEqual(&b)); +} + +test "ASCII cell diffs ease long byte walks in both directions" { + var low: Cell = .{ .default = false }; + low.text[0] = 'A'; + var high: Cell = .{ .default = false }; + high.text[0] = 'F'; + + const rising = AsciiDiff.between(&low, &high).?; + try std.testing.expectEqual(@as(u16, 6), rising.frameCount()); + try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(0)); + try std.testing.expectEqual(@as(u8, 'A'), rising.byteAt(1)); + try std.testing.expectEqual(@as(u8, 'C'), rising.byteAt(2)); + try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(4)); + try std.testing.expectEqual(@as(u8, 'F'), rising.byteAt(500)); + + const falling = AsciiDiff.between(&high, &low).?; + try std.testing.expectEqual(@as(u8, 'D'), falling.byteAt(2)); + try std.testing.expectEqual(@as(u8, 'A'), falling.byteAt(4)); + + low.text[0] = ' '; + high.text[0] = '~'; + const long_rising = AsciiDiff.between(&low, &high).?; + try std.testing.expectEqual(@as(u16, 13), long_rising.frameCount()); + try std.testing.expectEqual(@as(u8, ' '), long_rising.byteAt(0)); + try std.testing.expectEqual(@as(u8, '#'), long_rising.byteAt(2)); + try std.testing.expectEqual(@as(u8, 'O'), long_rising.byteAt(6)); + try std.testing.expectEqual(@as(u8, '{'), long_rising.byteAt(10)); + try std.testing.expectEqual(@as(u8, '~'), long_rising.byteAt(12)); + + const long_falling = AsciiDiff.between(&high, &low).?; + try std.testing.expectEqual(@as(u8, 'O'), long_falling.byteAt(6)); + try std.testing.expectEqual(@as(u8, ' '), long_falling.byteAt(12)); + + // The shape itself is the contract: never backwards, and the widest step + // is in the middle of the walk rather than at either end. + var previous = long_rising.byteAt(0); + var first_step: u8 = 0; + var middle_step: u8 = 0; + var frame: u16 = 1; + while (frame <= long_rising.movementFrames()) : (frame += 1) { + const byte = long_rising.byteAt(frame); + try std.testing.expect(byte >= previous); + const step = byte - previous; + if (frame == 1) first_step = step; + if (frame == 6) middle_step = step; + previous = byte; + } + try std.testing.expect(middle_step > first_step); + try std.testing.expect(middle_step > long_rising.byteAt(12) - long_rising.byteAt(11)); +} + +test "ASCII diff classification skips stable and non-ASCII glyphs" { + var old: Cell = .{ .default = false }; + old.text[0] = 'x'; + var style_only = old; + style_only.style.bold = true; + try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &style_only)); + + var changed = old; + changed.text[0] = 'z'; + try std.testing.expectEqual(AsciiDiff{ .from = 'x', .to = 'z' }, PanelCellDiff.between(&old, &changed).ascii); + + var unicode = old; + unicode.text[0..2].* = .{ 0xc3, 0xa9 }; + unicode.len = 2; + try std.testing.expectEqual(PanelCellDiff.visual, PanelCellDiff.between(&old, &unicode)); +} + +pub const ImageCacheKey = if (pdf_enabled) struct { + serial: u32, + page: u32, + revision: u32, + + pub fn eql(a: @This(), b: @This()) bool { + return a.serial == b.serial and a.page == b.page and + a.revision == b.revision; + } +} else struct { + serial: u32, + + pub fn eql(a: @This(), b: @This()) bool { + return a.serial == b.serial; + } +}; + +const PdfFitMode = panes.Pdf.FitMode; + +const PdfTintMode = panes.Pdf.TintMode; + +/// Dynamic placement exists only for native PDF pages. Static image panes need +/// only their pane identity, so feature-off builds carry a zero-bit payload. +pub const NativePlacement = if (pdf_enabled) struct { + revision: u32 = 0, + page: u32 = 0, + fit: image.NativeFit = .contain, + pan_x: u16 = 0, + pan_y: u16 = 0, + /// Continuous-document pages supply their already-clipped geometry. + /// Static images and legacy callers leave this null and derive fit/pan. + geometry: ?image.NativeGeometry = null, + /// Subpixel vertical displacement retained from proportional SDL wheel dy. + pixel_offset_y: f32 = 0, + /// Separate paper behind straight-alpha PDF content; null for other images. + paper_bg: ?[3]u8 = null, +} else struct {}; + +pub const ImagePatch = struct { from: u32, y: u32, h: u32 }; + +pub const ImagePlace = struct { + pane: u8, + /// Pane slots are reused. This identity makes a cached GPU texture or + /// kitty image unambiguously belong to the pane which supplied the bytes. + serial: u32, + native: NativePlacement = .{}, + x: u16, + y: u16, + w: u16, + h: u16, + rgba: []const u8, + iw: usize, + ih: usize, + /// Rows [y, y+h) of `rgba` are the only ones that differ from the + /// same pane page at revision `from`: a shell still holding that one may + /// update just these rows instead of taking the whole image again. + patch: ?ImagePatch = null, + + pub fn cacheKey(place: ImagePlace) ImageCacheKey { + if (comptime pdf_enabled) + return .{ + .serial = place.serial, + .page = place.native.page, + .revision = place.native.revision, + }; + return .{ .serial = place.serial }; + } +}; + +test "pixel attachment cache key follows both pane lifetime and rendered revision" { + if (comptime pdf_enabled) { + const first = ImageCacheKey{ .serial = 41, .page = 7, .revision = 3 }; + try std.testing.expect(first.eql(.{ .serial = 41, .page = 7, .revision = 3 })); + try std.testing.expect(!first.eql(.{ .serial = 41, .page = 8, .revision = 3 })); + try std.testing.expect(!first.eql(.{ .serial = 41, .page = 7, .revision = 4 })); + try std.testing.expect(!first.eql(.{ .serial = 42, .page = 7, .revision = 3 })); + } else { + const first = ImageCacheKey{ .serial = 41 }; + try std.testing.expect(first.eql(.{ .serial = 41 })); + try std.testing.expect(!first.eql(.{ .serial = 42 })); + } + try std.testing.expectEqual(pdf_enabled, @hasField(ImageCacheKey, "revision")); + try std.testing.expectEqual(pdf_enabled, @hasField(NativePlacement, "fit")); + try std.testing.expectEqual(pdf_enabled, @hasField(CellPixels, "w")); + if (comptime !pdf_enabled) { + try std.testing.expectEqual(@as(usize, 4), @sizeOf(ImageCacheKey)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(NativePlacement)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(CellPixels)); + try std.testing.expectEqual(@as(usize, 0), @sizeOf(panes.Pdf.PointerDrag)); + } +} + +test "pixel attachment fit and pan do not invalidate cached pixels" { + if (comptime !pdf_enabled) return; + var place = ImagePlace{ + .pane = 0, + .serial = 41, + .native = .{ .revision = 3 }, + .x = 0, + .y = 0, + .w = 1, + .h = 1, + .rgba = &.{ 0, 0, 0, 255 }, + .iw = 1, + .ih = 1, + }; + const key = place.cacheKey(); + place.native.fit = .width; + place.native.pan_x = 1234; + place.native.pan_y = 65535; + try std.testing.expect(key.eql(place.cacheKey())); +} + +/// The canonical interface: what a frame of pardes IS. The tty shell writes +/// these cells to vaxis one-to-one; the SDL shells rasterize them. +pub const Surface = struct { + pub const PointerShape = enum(u8) { arrow, link, target }; + pointer_shape: PointerShape = .arrow, + /// Set only for the duration of the look-hover paint, so the cells that + /// paint the quiet affordance can carry the hover bit through the ABI + /// without every selection painter learning a new parameter. + mark_hover: bool = false, + body_layers: [MAX_PANES]BodyLayer = @splat(.{}), + tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}), + cols: u16 = 0, + rows: u16 = 0, + cells: []Cell = &.{}, + /// bar: draw an insert-style thin cursor instead of the block + cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, + images: []?ImagePlace = &.{}, + nimages: usize = 0, + panel_tracks: [MAX_PANES * 2]layout.Track = undefined, + npanel_tracks: usize = 0, + previous_cells: []const Cell = &.{}, + previous_body_layers: []const BodyLayer = &.{}, + previous_tag_layers: []const TagLayer = &.{}, + cell_diffs: []const PanelCellDiff = &.{}, + + pub fn tagLayers(s: *const Surface) []const TagLayer { + return &s.tag_layers; + } + + pub fn bodyLayers(s: *const Surface) []const BodyLayer { + return &s.body_layers; + } + + pub fn panelTracks(s: *const Surface) []const layout.Track { + return s.panel_tracks[0..s.npanel_tracks]; + } + + pub fn hasPanelDiff(s: *const Surface) bool { + return s.previous_cells.len == s.cells.len and + s.cell_diffs.len == s.cells.len; + } + + pub fn panelCellChanged(s: *const Surface, x: u16, y: u16) bool { + if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return false; + return s.cell_diffs[@as(usize, y) * s.cols + x].changed(); + } + + pub fn panelCellDiff(s: *const Surface, x: u16, y: u16) PanelCellDiff { + if (!s.hasPanelDiff() or x >= s.cols or y >= s.rows) return .unchanged; + return s.cell_diffs[@as(usize, y) * s.cols + x]; + } + + pub fn at(s: *Surface, x: u16, y: u16) *Cell { + std.debug.assert(x < s.cols and y < s.rows); + const cell = &s.cells[@as(usize, y) * s.cols + x]; + // The look-hover paint runs with mark_hover set, and its cells are + // exactly the ones this marks — see render's preview block. + if (s.mark_hover) cell.hover = true; + return cell; + } + + pub fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void { + const c = s.at(x, y); + std.debug.assert(text.len <= c.text.len); + @memcpy(c.text[0..text.len], text); + c.len = @intCast(text.len); + c.style = style; + c.default = false; + } + + pub fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { + var col = x; + const end = x + w; + var i: usize = 0; + while (i < text.len) { + if (col >= end) break; + { + const b = text[i]; + if (b >= 0x20 and b < 0x7f and (i + 1 == text.len or text[i + 1] < 0x80)) { + s.set(col, y, text[i .. i + 1], style); + i += 1; + col += 1; + continue; + } + } + if (col >= end) break; + // n == 0: not a start byte at all. A short tail or a bad + // continuation decodes to null the same way — one U+FFFD, one byte. + const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; + const decoded: ?u21 = if (n > 0 and i + n <= text.len) + (std.unicode.utf8Decode(text[i .. i + n]) catch null) + else + null; + var cp_slice: []const u8 = "\u{FFFD}"; + var consumed: usize = 1; + if (decoded != null) { + var git = uucode.grapheme.utf8Iterator(text[i..]); + if (git.nextGrapheme()) |g| { + const candidate = text[i .. i + g.end]; + if (std.unicode.utf8ValidateSlice(candidate)) { + cp_slice = candidate; + consumed = candidate.len; + } else { + cp_slice = text[i .. i + n]; + consumed = n; + } + } + } + i += consumed; + var cp = decoded orelse 0xFFFD; + if (cp == '\r') continue; + if (cp == '\t') { + const spaces = @min(config.tab_width, end - col); + s.fill(col, y, spaces, 1, style); + col += spaces; + continue; + } + // No other C0/C1 control or DEL reaches a font fallback either. + if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) { + cp = 0xFFFD; + cp_slice = "\u{FFFD}"; + } + const width: u16 = if (decoded == null or (cp < 0x80 and cp_slice.len == 1)) + 1 + else + @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode)); + if (width == 2 and col + 1 >= end) break; + var shown = cp_slice; + if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) { + const cap = @typeInfo(@FieldType(Cell, "text")).array.len; + var prefix: usize = 0; + while (prefix < shown.len) { + const cp_len = std.unicode.utf8ByteSequenceLength(shown[prefix]) catch break; + if (prefix + cp_len > cap) break; + prefix += cp_len; + } + shown = if (prefix > 0) shown[0..prefix] else "\u{FFFD}"; + } + s.set(col, y, shown, style); + if (width == 2 and col + 1 < end) { + // spacer: empty cell under the wide glyph's tail + s.set(col + 1, y, "", style); + } + col += width; + } + return col; + } + + pub fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void { + var yy = y; + while (yy < y + h) : (yy += 1) { + var xx = x; + while (xx < x + w) : (xx += 1) s.set(xx, yy, " ", style); + } + } + + pub fn clearRect(s: *Surface, x: u16, y: u16, w: u16, h: u16) void { + var yy = y; + while (yy < y + h) : (yy += 1) { + var xx = x; + while (xx < x + w) : (xx += 1) s.at(xx, yy).* = .{}; + } + } + + /// swap ONLY the glyph, keeping the cell's colors — the resize-handle hint + pub fn overlayDash(s: *Surface, x: u16, y: u16, glyph: []const u8) void { + const c = s.at(x, y); + @memcpy(c.text[0..glyph.len], glyph); + c.len = @intCast(glyph.len); + c.default = false; + } +}; + +test "surface print expands configured tabs and normalizes other controls" { + const tab_cells: usize = @as(usize, config.tab_width) * 2; + const replacement_cells = 5; + const cell_count = tab_cells + replacement_cells + 1; + var cells: [cell_count]Cell = @splat(.{}); + var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells }; + + const end = surface.print(0, 0, cells.len, "\t\t\x00\x0b\x0c\r\x7f\xc2\x85A", .{}); + try std.testing.expectEqual(@as(u16, cells.len), end); + for (cells[0..tab_cells]) |cell| + try std.testing.expectEqualStrings(" ", cell.grapheme()); + for (cells[tab_cells .. tab_cells + replacement_cells]) |cell| + try std.testing.expectEqualStrings("\u{FFFD}", cell.grapheme()); + try std.testing.expectEqualStrings("A", cells[cell_count - 1].grapheme()); +} + +test "surface print keeps combining and wide graphemes in their display cells" { + var cells: [8]Cell = @splat(.{}); + var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells }; + + const end = surface.print(0, 0, cells.len, "e\u{301}界👩🏽\u{200d}🚀1\u{fe0f}\u{20e3}A", .{}); + try std.testing.expectEqual(@as(u16, 8), end); + try std.testing.expectEqualStrings("e\u{301}", cells[0].grapheme()); + try std.testing.expectEqualStrings("界", cells[1].grapheme()); + try std.testing.expectEqualStrings("", cells[2].grapheme()); + try std.testing.expectEqualStrings("👩", cells[3].grapheme()); + try std.testing.expectEqualStrings("", cells[4].grapheme()); + try std.testing.expectEqualStrings("1\u{fe0f}\u{20e3}", cells[5].grapheme()); + try std.testing.expectEqualStrings("", cells[6].grapheme()); + try std.testing.expectEqualStrings("A", cells[7].grapheme()); +} + +test "the ASCII fast path in surface print paints what the general arm paints" { + const ref = struct { + fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 { + var col = x; + const end = x + w; + var i: usize = 0; + while (i < text.len) { + if (col >= end) break; + const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; + const decoded: ?u21 = if (n > 0 and i + n <= text.len) + (std.unicode.utf8Decode(text[i .. i + n]) catch null) + else + null; + var cp_slice: []const u8 = "\u{FFFD}"; + var consumed: usize = 1; + if (decoded != null) { + var git = uucode.grapheme.utf8Iterator(text[i..]); + if (git.nextGrapheme()) |g| { + const candidate = text[i .. i + g.end]; + if (std.unicode.utf8ValidateSlice(candidate)) { + cp_slice = candidate; + consumed = candidate.len; + } else { + cp_slice = text[i .. i + n]; + consumed = n; + } + } + } + i += consumed; + var cp = decoded orelse 0xFFFD; + if (cp == '\r') continue; + if (cp == '\t') { + const spaces = @min(config.tab_width, end - col); + s.fill(col, y, spaces, 1, style); + col += spaces; + continue; + } + if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) { + cp = 0xFFFD; + cp_slice = "\u{FFFD}"; + } + const width: u16 = if (decoded == null or (cp < 0x80 and cp_slice.len == 1)) + 1 + else + @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode)); + if (width == 2 and col + 1 >= end) break; + var shown = cp_slice; + if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) { + const cap = @typeInfo(@FieldType(Cell, "text")).array.len; + var prefix: usize = 0; + while (prefix < shown.len) { + const cp_len = std.unicode.utf8ByteSequenceLength(shown[prefix]) catch break; + if (prefix + cp_len > cap) break; + prefix += cp_len; + } + shown = if (prefix > 0) shown[0..prefix] else "\u{FFFD}"; + } + s.set(col, y, shown, style); + if (width == 2 and col + 1 < end) s.set(col + 1, y, "", style); + col += width; + } + return col; + } + }.print; + + const H = struct { + fn check(text: []const u8) !void { + // Every width from 0 past the end, because clipping interacts with the fast path: the + // wide-glyph bail at the right edge is only reachable from the general arm. + var w: u16 = 0; + while (w <= text.len + 4) : (w += 1) { + var fast_cells: [64]Cell = @splat(.{}); + var slow_cells: [64]Cell = @splat(.{}); + var fast = Surface{ .cols = fast_cells.len, .rows = 1, .cells = &fast_cells }; + var slow = Surface{ .cols = slow_cells.len, .rows = 1, .cells = &slow_cells }; + const got = fast.print(0, 0, w, text, .{}); + const want = ref(&slow, 0, 0, w, text, .{}); + std.testing.expectEqual(want, got) catch |e| { + std.debug.print("print end column: {any} w={d}\n", .{ text, w }); + return e; + }; + for (fast_cells, slow_cells, 0..) |f, sc, col| { + std.testing.expectEqualStrings(sc.grapheme(), f.grapheme()) catch |e| { + std.debug.print("print cell {d}: {any} w={d}\n", .{ col, text, w }); + return e; + }; + try std.testing.expectEqual(sc.default, f.default); + } + } + } + }; + + const neighbours = [_][]const u8{ + "", "x", "\u{301}", "\u{200d}\u{1f680}", + "\u{903}", "\u{fe0f}", "\u{20e3}", "\u{4e16}", + "\u{1f642}", "\xff", "\xe4\xb8", "\xe4\x28\xb8", + "\u{1f1e6}\u{1f1e7}", + }; + // Every byte 0x20..0x7e takes the fast path; \t, \r, the rest of the C0 controls and DEL are + // excluded by its range test and must keep the general arm's tab expansion and U+FFFD. + var buf: [16]u8 = undefined; + var b: u8 = 0; + while (b < 0x80) : (b += 1) { + buf[0] = b; + for (neighbours) |tail| { + @memcpy(buf[1..][0..tail.len], tail); + try H.check(buf[0 .. 1 + tail.len]); + @memcpy(buf[0..tail.len], tail); + buf[tail.len] = b; + try H.check(buf[0 .. tail.len + 1]); + } + } + + // Runs long enough that the fast path starts, hands over and restarts inside one call. + try H.check("ascii \u{4e16}\u{754c} e\u{301} \xff ok\t\r!"); + try H.check("plain"); +} -- cgit v1.3