diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/fs.zig | 1 | ||||
| -rw-r--r-- | src/pardes.zig | 781 | ||||
| -rw-r--r-- | src/surface.zig | 647 |
3 files changed, 730 insertions, 699 deletions
@@ -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"); +} |
