//! 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 animation = @import("animation.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 Layer = @import("Layer.zig"); 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, 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 = animation.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())); } /// One part of the frame and where it is, placed once by `draw.place` /// before anything is painted: every painter and every shell reads the /// geometry here instead of working it out again. `rect` is in grid cells /// and is the hit-test geometry. pub const Region = struct { pub const Kind = enum(u8) { /// The frame-wide border fill every other part is painted over. page, /// A pane's grip: the mode mark at the head of its tag rows. grip, /// A pane's tag rows, right of its grip. tag, /// A pane's body, right of its rail: absent while it is collapsed. body, /// The scroll rail down a pane's left edge, beside its body. rail, /// A transient band over the body's top rows (a message, a prompt). notice, column_grip, column_tag, workspace_tag, /// A column below the workspace tag, its bar and panes: its left /// edge is the spine a pixel shell rules down it. column, /// A drag's guide or a seam's hover hint, drawn over the panes. guide, /// The debug stats box, drawn over the panes. debug, }; pub const none = std.math.maxInt(u8); kind: Kind, /// The pane slot, or the column, the part belongs to; `none` for the /// page and the workspace tag. owner: u8 = none, serial: u32 = 0, rect: layout.Rect, /// The pane or column has the keyboard's focus. active: bool = false, /// A grip's pane has changes it has not saved. dirty: bool = false, /// A rail's thumb: rows from the rail's top, and how many. Zero rows is /// a rail with no thumb (an image). A native PDF's thumb is measured in /// document pixels and filled in by Pdf.draw while it paints, so read /// regions once render has returned. thumb_y: u16 = 0, thumb_h: u16 = 0, /// A notice's index in its pane's `notices`. notice: u8 = 0, /// How far it stands off the page, 0 to 1: a pane's while it has the /// focus (Lift, on a spring), a notice's while Lift is on. A pixel shell /// casts a soft shadow from it; the tier it is drawn in never changes. lift: f32 = 0, /// Cells the column-move rail takes off the right edge while it runs /// there: layers and pixel shells stop short of them, the grid does not. clip: u16 = 0, }; /// Guides a frame can draw: a run of a column-move rail per pane it skips /// the tags of, and a handful of border and hover dashes. pub const MAX_GUIDES = MAX_PANES + 4; pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 3 + 1 + MAX_GUIDES + 1; /// The colours a pixel shell draws its chrome in (rules, rails, grips, the /// tag underlay), resolved by the core each frame, so a frontend attached /// to a detached session draws them from the frame alone. pub const Chrome = struct { /// The theme's ground and ink; null where it leaves them to the shell. page: ?[3]u8 = null, fg: ?[3]u8 = null, /// Rules and column spines. border: [3]u8 = .{ 0, 0, 0 }, /// The rule under the workspace tag. topbar_rule: [3]u8 = .{ 0, 0, 0 }, tag_bg: [3]u8 = .{ 0, 0, 0 }, /// The focused pane's and column's tag underlay: `tag_bg` unless /// FocusTint is on. tag_focus_bg: [3]u8 = .{ 0, 0, 0 }, tag_fg: [3]u8 = .{ 0, 0, 0 }, scroll_track: [3]u8 = .{ 0, 0, 0 }, scroll_thumb: [3]u8 = .{ 0, 0, 0 }, /// The theme's accent (a glow's tint). accent: [3]u8 = .{ 0, 0, 0 }, /// A selection's text and ground: a lift keeps their contrast too. sel_fg: [3]u8 = .{ 0, 0, 0 }, sel_bg: [3]u8 = .{ 0, 0, 0 }, /// How a lifted part stands off the page (config.Runtime.LiftStyle). lift_style: u8 = 0, /// Rule width in pixels (colors.Theme.rule_px). rule_px: u8 = 2, /// The ring round a grip, acme's button: unfocused, and focused. grip_border: [3]u8 = .{ 0, 0, 0 }, grip_focus_ring: [3]u8 = .{ 0, 0, 0 }, /// A dirty pane's button fill. grip_dirty: [3]u8 = .{ 0, 0, 0 }, /// The rule between a pane's tag and its body; null is halfway between /// the tag's ground and the page the shell actually draws. tag_rule: ?[3]u8 = null, /// The scroll column's width, button and scrollbar alike, in pixels at /// a 17px tagline (acme's 12 at its font); a pixel shell scales it. rail_px: u8 = 12, }; /// 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, body_layers: [MAX_PANES]Layer = @splat(.{}), tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, regions: [MAX_REGIONS]Region = undefined, nregions: u16 = 0, chrome: Chrome = .{}, 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]animation.Track = undefined, npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, previous_body_layers: []const Layer = &.{}, previous_tag_layers: []const Layer = &.{}, previous_regions: []const Region = &.{}, cell_diffs: []const PanelCellDiff = &.{}, pub fn tagLayers(s: *const Surface) []const Layer { return &s.tag_layers; } pub fn bodyLayers(s: *const Surface) []const Layer { return &s.body_layers; } pub fn regionList(s: *const Surface) []const Region { return s.regions[0..s.nregions]; } pub fn addRegion(s: *Surface, r: Region) void { std.debug.assert(s.nregions < s.regions.len); s.regions[s.nregions] = r; s.nregions += 1; } /// The first part of `kind` that `owner` has: its tag, its rail. pub fn region(s: *Surface, kind: Region.Kind, owner: usize) ?*Region { for (s.regions[0..s.nregions]) |*r| if (r.kind == kind and r.owner == owner) return r; return null; } pub fn panelTracks(s: *const Surface) []const animation.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); return &s.cells[@as(usize, y) * s.cols + x]; } 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"); }