summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-27 21:44:52 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commitfa5260c120268f1c1e3dbd2a17264e5896476576 (patch)
treebf508789ec224022677c17ff3e4e9180514711fb /src
parent0ce8269c251eaba188883c38dd3e97be9fb362cc (diff)
downloadpardes-fa5260c120268f1c1e3dbd2a17264e5896476576.tar.gz
pardes-fa5260c120268f1c1e3dbd2a17264e5896476576.zip
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 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/fs.zig1
-rw-r--r--src/pardes.zig781
-rw-r--r--src/surface.zig647
3 files changed, 730 insertions, 699 deletions
diff --git a/src/fs.zig b/src/fs.zig
index 3dccdf7f..e1fe0b9c 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -36,6 +36,7 @@ else
const source_files = [_]Source{
.{ .path = "src/pardes.zig", .contents = @embedFile("pardes.zig") },
.{ .path = "src/panes.zig", .contents = @embedFile("panes.zig") },
+ .{ .path = "src/surface.zig", .contents = @embedFile("surface.zig") },
.{ .path = "src/File.zig", .contents = @embedFile("File.zig") },
.{ .path = "src/Output.zig", .contents = @embedFile("Output.zig") },
.{ .path = "src/Mini.zig", .contents = @embedFile("Mini.zig") },
diff --git a/src/pardes.zig b/src/pardes.zig
index 1159dd73..23cb1734 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -11,6 +11,18 @@ pub const syntax = @import("syntax.zig");
pub const locations_config = @import("locations_config.zig");
const tracy = @import("tracy.zig");
pub const panes = @import("panes.zig");
+/// The screen types live in surface.zig; every shell names them through here.
+pub const Color = @import("surface.zig").Color;
+pub const FontRole = @import("surface.zig").FontRole;
+pub const CellStyle = @import("surface.zig").CellStyle;
+pub const Cell = @import("surface.zig").Cell;
+pub const AsciiDiff = @import("surface.zig").AsciiDiff;
+pub const PanelCellDiff = @import("surface.zig").PanelCellDiff;
+pub const ImageCacheKey = @import("surface.zig").ImageCacheKey;
+pub const NativePlacement = @import("surface.zig").NativePlacement;
+pub const ImagePatch = @import("surface.zig").ImagePatch;
+pub const ImagePlace = @import("surface.zig").ImagePlace;
+pub const Surface = @import("surface.zig").Surface;
pub const builtins = @import("builtins.zig");
const limits = memory.limits;
const selection_pipe = @import("selection_pipe.zig");
@@ -413,6 +425,7 @@ pub const Loc = struct {
};
test {
+ _ = @import("surface.zig");
_ = @import("body_layer.zig");
_ = @import("tag_layer.zig");
}
@@ -3718,636 +3731,6 @@ const initial_chrome = ChromeTheme.fromTheme(&themes[0]);
// ---- the boundary types ----
-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;
- }
-
- 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);
- }
- }
-
- 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
- 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");
-}
-
test "insert and normal modes edit complete Unicode graphemes" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
@@ -6530,77 +5913,77 @@ pub const Pardes = struct {
p.opts.mounts = &.{};
p.boot = Boot.of(opts);
switch (p.boot) {
- .document => {
- const path = opts.file.?;
- const opened = initial_doc: {
- if (comptime pdf_enabled) if (look.isPdfPath(path))
- break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line);
- if (look.isImagePath(path))
- break :initial_doc panes.Image.create(p, 0, path, &.{});
- break :initial_doc panes.File.open(p, 0, path, opts.file_line);
- };
- if (opened) |_| {} else |err| {
- if (err == error.OutOfMemory) return err;
- const why = switch (@as(anyerror, err)) {
- error.PermissionDenied => "permission denied",
- error.FileNotFound => "no file of that name",
- error.IsDirectory => "that is a directory, and not one that could be read",
- error.PathTooLong => "that path is too long",
- error.FileTooLarge => "that file is too large to open",
- error.NotAFile => "that is a pipe or a device, not a document",
- else => @errorName(err),
+ .document => {
+ const path = opts.file.?;
+ const opened = initial_doc: {
+ if (comptime pdf_enabled) if (look.isPdfPath(path))
+ break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line);
+ if (look.isImagePath(path))
+ break :initial_doc panes.Image.create(p, 0, path, &.{});
+ break :initial_doc panes.File.open(p, 0, path, opts.file_line);
};
- const content = try std.fmt.allocPrint(gpa, "cannot open\n\n\t{s}\n\n{s}\n", .{ path, why });
+ if (opened) |_| {} else |err| {
+ if (err == error.OutOfMemory) return err;
+ const why = switch (@as(anyerror, err)) {
+ error.PermissionDenied => "permission denied",
+ error.FileNotFound => "no file of that name",
+ error.IsDirectory => "that is a directory, and not one that could be read",
+ error.PathTooLong => "that path is too long",
+ error.FileTooLarge => "that file is too large to open",
+ error.NotAFile => "that is a pipe or a device, not a document",
+ else => @errorName(err),
+ };
+ const content = try std.fmt.allocPrint(gpa, "cannot open\n\n\t{s}\n\n{s}\n", .{ path, why });
+ errdefer gpa.free(content);
+ _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content);
+ }
+ p.oneColumn();
+ },
+ .missing => {
+ const missing = opts.missing.?;
+ const content = try std.fmt.allocPrint(
+ gpa,
+ "file or directory not found\n\n\t{s}\n",
+ .{missing.word},
+ );
errdefer gpa.free(content);
- _ = try panes.Output.open(p, 0, std.fs.path.dirname(path) orelse "/", .errors, "", content);
- }
- p.oneColumn();
- },
- .missing => {
- const missing = opts.missing.?;
- const content = try std.fmt.allocPrint(
- gpa,
- "file or directory not found\n\n\t{s}\n",
- .{missing.word},
- );
- errdefer gpa.free(content);
- _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content);
- p.oneColumn();
- },
- .embedded => {
- const content = try p.gpa.dupe(u8, boot_buffer);
- errdefer p.gpa.free(content);
- _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content);
- p.oneColumn();
- },
- .tty, .tty_shell => {
- _ = try p.newShell(0, "");
- p.panes[0].?.mode = .tty;
- p.oneColumn();
- // A bare tty is still an editor: it opens with somewhere to put
- // text under the shell. The shell keeps the keyboard.
- if (p.boot == .tty) {
- p.newScratchBelow(0);
- p.active = 0;
- }
- },
- .classic => {
- _ = try p.newShell(0, "");
- _ = try p.newShell(1, "");
- _ = try p.newShell(2, "");
- for (p.panes[0..3]) |slot| slot.?.greet = true;
- p.ncol = 2;
- p.col_n[0] = 2;
- p.col_panes[0][0] = 0;
- p.col_panes[0][1] = 1;
- p.col_n[1] = 1;
- p.col_panes[1][0] = 2;
- },
- .shell => {
- _ = try p.newShell(0, "");
- p.panes[0].?.greet = true;
- p.oneColumn();
- },
+ _ = try panes.Output.open(p, 0, missing.dir, .errors, "", content);
+ p.oneColumn();
+ },
+ .embedded => {
+ const content = try p.gpa.dupe(u8, boot_buffer);
+ errdefer p.gpa.free(content);
+ _ = try panes.Output.open(p, 0, "", .{ .cmd = .New }, "", content);
+ p.oneColumn();
+ },
+ .tty, .tty_shell => {
+ _ = try p.newShell(0, "");
+ p.panes[0].?.mode = .tty;
+ p.oneColumn();
+ // A bare tty is still an editor: it opens with somewhere to put
+ // text under the shell. The shell keeps the keyboard.
+ if (p.boot == .tty) {
+ p.newScratchBelow(0);
+ p.active = 0;
+ }
+ },
+ .classic => {
+ _ = try p.newShell(0, "");
+ _ = try p.newShell(1, "");
+ _ = try p.newShell(2, "");
+ for (p.panes[0..3]) |slot| slot.?.greet = true;
+ p.ncol = 2;
+ p.col_n[0] = 2;
+ p.col_panes[0][0] = 0;
+ p.col_panes[0][1] = 1;
+ p.col_n[1] = 1;
+ p.col_panes[1][0] = 2;
+ },
+ .shell => {
+ _ = try p.newShell(0, "");
+ p.panes[0].?.greet = true;
+ p.oneColumn();
+ },
}
p.sync();
p.applyStartupConfig();
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");
+}