From 2b388888348860097967ae04d03f68c00a70472c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 20:25:28 -0300 Subject: Mini.zig is mini.zig: a file of functions and no fields takes a namespace's lowercase name The naming the split agreed on, deferred until now; panes.mini is its name at its importers. File.zig keeps its alias line (const Mini = panes.mini) so the file another agent is working in changes by that line alone. The served sources list and docs/design.typ name the new file. No behaviour changes; test/perf.zig's two references change, and since the perf harness is keyed by its own text, its three baselines are recorded again (under other agents' builds, so a little slower). Co-Authored-By: Claude Opus 5.5 --- docs/design.typ | 2 +- src/File.zig | 2 +- src/Mini.zig | 321 -------------------------------- src/builtins.zig | 2 +- src/fs.zig | 2 +- src/mini.zig | 321 ++++++++++++++++++++++++++++++++ src/panes.zig | 4 +- test/output.zig | 6 +- test/perf-baseline-gui-Debug.json | 2 +- test/perf-baseline-tty-Debug.json | 2 +- test/perf-baseline-tty-ReleaseFast.json | 2 +- test/perf.zig | 10 +- 12 files changed, 338 insertions(+), 338 deletions(-) delete mode 100644 src/Mini.zig create mode 100644 src/mini.zig diff --git a/docs/design.typ b/docs/design.typ index ed2bbb94..20085706 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -526,7 +526,7 @@ verbatim by right-click and the delayed hover preview; that policy stays beside input because it also observes live pane selections and wrapped grid coordinates. Each pane kind keeps its storage and operations together in its own file -(`File.zig`, `Terminal.zig`, `Output.zig`, `Mini.zig`, `pdf_view.zig`, and the +(`File.zig`, `Terminal.zig`, `Output.zig`, `mini.zig`, `pdf_view.zig`, and the image pane in `image.zig`), reached through `panes.zig` beside `Pane`. `layout.zig` owns placement and presentation state. `pardes.zig` handles input and cross-pane state directly, without a pane vtable; the rest of the editor diff --git a/src/File.zig b/src/File.zig index 9f0f329c..c4040863 100644 --- a/src/File.zig +++ b/src/File.zig @@ -16,7 +16,7 @@ const limits = @import("memory.zig").limits; const locations = @import("locations.zig"); const Pane = panes.Pane; const Output = panes.Output; -const Mini = panes.Mini; +const Mini = panes.mini; const Terminal = panes.Terminal; const Text = panes.Text; diff --git a/src/Mini.zig b/src/Mini.zig deleted file mode 100644 index a6aef15a..00000000 --- a/src/Mini.zig +++ /dev/null @@ -1,321 +0,0 @@ -//! The Mini minimap: a file drawn four rows to a cell in braille dots. -const panes = @import("panes.zig"); -const std = @import("std"); -const pardes = @import("pardes.zig"); -const exec = @import("exec.zig"); -const config = @import("config.zig"); -const Pardes = pardes.Pardes; -const modal = @import("modal.zig"); -const filesystem = @import("fs.zig"); -const syntax = @import("syntax.zig"); -const File = panes.File; -const Output = panes.Output; - -pub const max_input_bytes = 4 * 1024 * 1024; -pub const max_output_bytes = 4 * 1024 * 1024; - -pub const State = struct { - source: []u8, - colors: []u8, - - pub fn deinit(state: *State, gpa: std.mem.Allocator) void { - gpa.free(state.source); - gpa.free(state.colors); - state.* = undefined; - } -}; - -pub const Result = struct { - content: []u8, - colors: []u8, - - pub fn deinit(result: Result, gpa: std.mem.Allocator) void { - gpa.free(result.content); - gpa.free(result.colors); - } -}; - -const Row = struct { - text: []const u8 = "", - base: usize = 0, - at: usize = 0, - left: usize = 0, - ink: bool = false, - color: u8 = 0, - - fn dot(row: *Row, styles: []const u8) ?u8 { - if (row.left == 0) { - if (row.at == row.text.len) return null; - const end = modal.nextGrapheme(row.text, row.at); - const grapheme = row.text[row.at..end]; - row.left = File.graphemeDisplayWidth(grapheme); - // unreachable below: `generate` refuses a source that is no UTF-8 - const n = std.unicode.utf8ByteSequenceLength(grapheme[0]) catch unreachable; - const cp = std.unicode.utf8Decode(grapheme[0..n]) catch unreachable; - const blank = switch (cp) { - '\t'...'\r', ' ', 0x85, 0xa0, 0x1680, 0x2000...0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000 => true, - else => false, - }; - row.ink = !blank or grapheme.len != n; - row.color = if (styles.len == 0) 0 else styles[row.base + row.at]; - row.at = end; - } - row.left -= 1; - return if (row.ink) row.color else null; - } -}; - -fn render(output: ?Result, source: []const u8, styles: []const u8) !usize { - if (source.len == 0) return 0; - const end = source.len - @intFromBool(source[source.len - 1] == '\n'); - var lines = std.mem.splitScalar(u8, source[0..end], '\n'); - var offset: usize = 0; - while (lines.peek() != null) { - var rows: [4]Row = @splat(.{}); - for (&rows) |*row| { - const line = lines.next() orelse break; - row.text = std.mem.trimEnd(u8, line, "\r"); - row.base = @intFromPtr(line.ptr) - @intFromPtr(source.ptr); - } - var spaces: usize = 0; - while (true) { - var more = false; - for (rows) |row| more = more or row.at < row.text.len or row.left > 0; - if (!more) break; - const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } }; - var mask: u8 = 0; - var counts: [5]u8 = @splat(0); - for (&rows, 0..) |*row, y| { - for (0..2) |x| { - if (row.dot(styles)) |color| { - mask |= @as(u8, 1) << bits[y][x]; - counts[color] += 1; - } - } - } - if (mask == 0) { - spaces += 1; - continue; - } - if (spaces + 3 > max_output_bytes - offset) return error.MiniTooLarge; - var color: u8 = 0; - var most: u8 = 0; - for (counts[1..], 1..) |count, i| { - if (count > most) { - color = @intCast(i); - most = count; - } - } - if (output) |out| { - @memset(out.content[offset..][0..spaces], ' '); - @memset(out.colors[offset..][0..spaces], 0); - // unreachable: U+2800 plus a u8 mask is a braille codepoint, always three bytes - _ = std.unicode.utf8Encode(@as(u21, 0x2800) + mask, out.content[offset + spaces ..][0..3]) catch unreachable; - @memset(out.colors[offset + spaces ..][0..3], color); - } - offset += spaces + 3; - spaces = 0; - } - if (offset == max_output_bytes) return error.MiniTooLarge; - if (output) |out| { - out.content[offset] = '\n'; - out.colors[offset] = 0; - } - offset += 1; - } - return offset; -} - -pub fn generate(gpa: std.mem.Allocator, source: []const u8, styles: []const u8) !Result { - if (source.len > max_input_bytes) return error.MiniTooLarge; - if (!std.unicode.utf8ValidateSlice(source)) return error.InvalidUtf8; - if (styles.len != 0 and styles.len != source.len) return error.InvalidMiniColors; - for (styles) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidMiniColors; - const len = try render(null, source, styles); - const content = try gpa.alloc(u8, len); - errdefer gpa.free(content); - const colors = try gpa.alloc(u8, len); - errdefer gpa.free(colors); - const result: Result = .{ .content = content, .colors = colors }; - const written = try render(result, source, styles); - std.debug.assert(written == len); - return result; -} - -pub fn open(p: *Pardes, id: usize, argument: []const u8) !void { - const caller = p.panes[id] orelse return error.MissingPane; - const word = std.mem.trim(u8, argument, " \t\r\n"); - if (word.len == 0) return error.MissingPath; - var path_buf: [4096]u8 = undefined; - const target = filesystem.resolve(p, word, Pardes.paneDir(caller), &path_buf) orelse return error.FileNotFound; - if (target.dir) return error.NotAFile; - const source = try p.gpa.dupe(u8, target.path); - errdefer p.gpa.free(source); - const input = try filesystem.readLimit(p, source, max_input_bytes); - defer p.gpa.free(input); - const styles = try syntax.highlightFileRange(p.tree_sitter_gpa, source, input, 0, input.len); - defer p.tree_sitter_gpa.free(styles); - const result = try generate(p.gpa, input, styles); - errdefer result.deinit(p.gpa); - for (p.panes, 0..) |slot, i| { - const pane = slot orelse continue; - const file = if (pane.file) |*f| f else continue; - const old = file.mini orelse continue; - if (!std.mem.eql(u8, old.source, source)) continue; - if (std.mem.eql(u8, file.content, result.content) and std.mem.eql(u8, old.colors, result.colors)) { - p.gpa.free(source); - result.deinit(p.gpa); - } else { - File.setContent(p, file, result.content); - file.mini = .{ .source = source, .colors = result.colors }; - file.syntax_dirty = false; - Output.resetBody(p, pane); - } - p.active = i; - return; - } - const free = p.freeSlot() orelse return error.NoPaneSlots; - const dir = std.fs.path.dirname(source) orelse "/"; - const path = try std.fmt.allocPrint(p.gpa, "{s}/Mini {s}", .{ std.mem.trimEnd(u8, dir, "/"), std.fs.path.basename(source) }); - errdefer p.gpa.free(path); - const history = try File.History.create(p.gpa); - errdefer p.gpa.destroy(history); - const pane = try p.newDocPane(free); - pane.file = .{ - .path = path, - .content = result.content, - .output = .{ .from = .{ .cmd = .Mini } }, - .mini = .{ .source = source, .colors = result.colors }, - .history = history, - .syntax_dirty = false, - }; - pane.body.cur_pinned = true; - exec.placeNew(p, id, id, free, .doc); - p.active = free; -} - -test "Mini maps every braille dot and partial line group" { - const gpa = std.testing.allocator; - const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } }; - for (0..256) |mask| { - var source: [12]u8 = undefined; - for (0..4) |row| { - for (0..2) |col| source[row * 3 + col] = if (mask & (@as(usize, 1) << bits[row][col]) != 0) 'x' else ' '; - source[row * 3 + 2] = '\n'; - } - const result = try generate(gpa, &source, ""); - defer result.deinit(gpa); - var expected: [4]u8 = undefined; - const len: usize = if (mask == 0) 0 else try std.unicode.utf8Encode(@as(u21, 0x2800) + @as(u21, @intCast(mask)), &expected); - expected[len] = '\n'; - try std.testing.expectEqualStrings(expected[0 .. len + 1], result.content); - try std.testing.expectEqual(result.content.len, result.colors.len); - for (result.colors) |color| try std.testing.expectEqual(@as(u8, 0), color); - } - for ([_]struct { source: []const u8, expected: []const u8 }{ - .{ .source = "", .expected = "" }, - .{ .source = "x", .expected = "⠁\n" }, - .{ .source = "xx\n", .expected = "⠉\n" }, - .{ .source = "x \n", .expected = "⠁\n" }, - .{ .source = "\n\n\n\nx", .expected = "\n⠁\n" }, - .{ .source = "x\r\nx\r\n", .expected = "⠃\n" }, - }) |case| { - const result = try generate(gpa, case.source, ""); - defer result.deinit(gpa); - try std.testing.expectEqualStrings(case.expected, result.content); - } -} - -test "Mini uses display cells for tabs combining text and wide characters" { - const gpa = std.testing.allocator; - for ([_]struct { source: []const u8, expected: []const u8 }{ - .{ .source = "e\u{301}界\n", .expected = "⠉⠁\n" }, - .{ .source = " x\n", .expected = " ⠁\n" }, - .{ .source = "\u{a0}x\n", .expected = "⠈\n" }, - }) |case| { - const result = try generate(gpa, case.source, ""); - defer result.deinit(gpa); - try std.testing.expectEqualStrings(case.expected, result.content); - } - const tabs = try generate(gpa, "\tx\n", ""); - defer tabs.deinit(gpa); - const spaces = config.tab_width / 2; - for (tabs.content[0..spaces]) |byte| try std.testing.expectEqual(@as(u8, ' '), byte); - try std.testing.expectEqualStrings(if (config.tab_width % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]); -} - -test "Mini chooses highlighted dots over plain ink with stable color ties" { - const gpa = std.testing.allocator; - const source = "xx\nxx\nxx\nxx\n"; - var styles: [source.len]u8 = @splat(0); - styles[0] = @intFromEnum(syntax.Syn.keyword); - const rare = try generate(gpa, source, &styles); - defer rare.deinit(gpa); - try std.testing.expectEqualStrings("⣿\n", rare.content); - try std.testing.expectEqualSlices(u8, &.{ 1, 1, 1, 0 }, rare.colors); - styles[0] = @intFromEnum(syntax.Syn.string); - styles[1] = @intFromEnum(syntax.Syn.number); - const tied = try generate(gpa, source, &styles); - defer tied.deinit(gpa); - try std.testing.expectEqualSlices(u8, &.{ 2, 2, 2, 0 }, tied.colors); - styles[3] = @intFromEnum(syntax.Syn.number); - const majority = try generate(gpa, source, &styles); - defer majority.deinit(gpa); - try std.testing.expectEqualSlices(u8, &.{ 3, 3, 3, 0 }, majority.colors); -} - -test "Mini generation bounds and allocation failures leave no partial result" { - const Case = struct { - fn run(gpa: std.mem.Allocator) !void { - const result = try generate(gpa, "alpha\nbeta\ngamma\ndelta\nepsilon\n", ""); - defer result.deinit(gpa); - } - }; - try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{}); - try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "x", &.{5})); - try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "xx", &.{0})); - var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); - const source = try std.testing.allocator.alloc(u8, max_input_bytes + 1); - defer std.testing.allocator.free(source); - try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source, "")); - for (source[0..max_input_bytes], 0..) |*byte, i| byte.* = if (i % 2 == 0) 'x' else ' '; - try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source[0..max_input_bytes], "")); - try std.testing.expect(!allocator.has_induced_failure); -} - -test "Mini publishes only complete snapshots and content replacement drops metadata" { - const Case = struct { - fn run(gpa: std.mem.Allocator, path: []const u8) !void { - const p = try Pardes.init(gpa, .{ .tty_only = true }); - defer p.deinit(); - const source = try p.setTestFile("untouched\n"); - const free = p.freeSlot(); - open(p, 0, path) catch |err| { - try std.testing.expectEqual(@as(usize, 0), p.active); - try std.testing.expectEqual(free, p.freeSlot()); - try std.testing.expectEqual(source, p.panes[0].?); - try std.testing.expectEqualStrings("untouched\n", source.file.?.content); - return err; - }; - const file = &p.panes[p.active].?.file.?; - try std.testing.expectEqualStrings("⠉\n", file.content); - try std.testing.expectEqualStrings(path, file.mini.?.source); - const replacement = try gpa.dupe(u8, "plain\n"); - File.setContent(p, file, replacement); - try std.testing.expect(file.mini == null); - try std.testing.expectEqualStrings("plain\n", file.content); - } - }; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "mini.txt", .data = "xx\n" }); - var dir_buf: [4096]u8 = undefined; - const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; - var path_buf: [4096]u8 = undefined; - const path = try std.fmt.bufPrint(&path_buf, "{s}/mini.txt", .{dir}); - try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{path}); -} - -test "Mini refuses a file that is no UTF-8, before its decoding could panic" { - try std.testing.expectError(error.InvalidUtf8, generate(std.testing.allocator, "ok \xff\xfe bad\n\xc3", "")); -} diff --git a/src/builtins.zig b/src/builtins.zig index 8a01b3d6..cc86067e 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1379,7 +1379,7 @@ pub const Mini = struct { pub const output: OutputTraits = .{ .name = "Mini", .doc = true }; pub fn run(c: Ctx) void { - panes.Mini.open(c.p, c.id, c.arg orelse "") catch |err| + panes.mini.open(c.p, c.id, c.arg orelse "") catch |err| c.p.reportError(c.id, "Mini", err); } }; diff --git a/src/fs.zig b/src/fs.zig index edead0ff..a2e8daf8 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -55,7 +55,7 @@ const source_files = [_]Source{ .{ .path = "src/Pipe.zig", .contents = @embedFile("Pipe.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") }, + .{ .path = "src/mini.zig", .contents = @embedFile("mini.zig") }, .{ .path = "src/image.zig", .contents = @embedFile("image.zig") }, .{ .path = "src/Terminal.zig", .contents = @embedFile("Terminal.zig") }, .{ .path = "src/pdf_view.zig", .contents = @embedFile("pdf_view.zig") }, diff --git a/src/mini.zig b/src/mini.zig new file mode 100644 index 00000000..a6aef15a --- /dev/null +++ b/src/mini.zig @@ -0,0 +1,321 @@ +//! The Mini minimap: a file drawn four rows to a cell in braille dots. +const panes = @import("panes.zig"); +const std = @import("std"); +const pardes = @import("pardes.zig"); +const exec = @import("exec.zig"); +const config = @import("config.zig"); +const Pardes = pardes.Pardes; +const modal = @import("modal.zig"); +const filesystem = @import("fs.zig"); +const syntax = @import("syntax.zig"); +const File = panes.File; +const Output = panes.Output; + +pub const max_input_bytes = 4 * 1024 * 1024; +pub const max_output_bytes = 4 * 1024 * 1024; + +pub const State = struct { + source: []u8, + colors: []u8, + + pub fn deinit(state: *State, gpa: std.mem.Allocator) void { + gpa.free(state.source); + gpa.free(state.colors); + state.* = undefined; + } +}; + +pub const Result = struct { + content: []u8, + colors: []u8, + + pub fn deinit(result: Result, gpa: std.mem.Allocator) void { + gpa.free(result.content); + gpa.free(result.colors); + } +}; + +const Row = struct { + text: []const u8 = "", + base: usize = 0, + at: usize = 0, + left: usize = 0, + ink: bool = false, + color: u8 = 0, + + fn dot(row: *Row, styles: []const u8) ?u8 { + if (row.left == 0) { + if (row.at == row.text.len) return null; + const end = modal.nextGrapheme(row.text, row.at); + const grapheme = row.text[row.at..end]; + row.left = File.graphemeDisplayWidth(grapheme); + // unreachable below: `generate` refuses a source that is no UTF-8 + const n = std.unicode.utf8ByteSequenceLength(grapheme[0]) catch unreachable; + const cp = std.unicode.utf8Decode(grapheme[0..n]) catch unreachable; + const blank = switch (cp) { + '\t'...'\r', ' ', 0x85, 0xa0, 0x1680, 0x2000...0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000 => true, + else => false, + }; + row.ink = !blank or grapheme.len != n; + row.color = if (styles.len == 0) 0 else styles[row.base + row.at]; + row.at = end; + } + row.left -= 1; + return if (row.ink) row.color else null; + } +}; + +fn render(output: ?Result, source: []const u8, styles: []const u8) !usize { + if (source.len == 0) return 0; + const end = source.len - @intFromBool(source[source.len - 1] == '\n'); + var lines = std.mem.splitScalar(u8, source[0..end], '\n'); + var offset: usize = 0; + while (lines.peek() != null) { + var rows: [4]Row = @splat(.{}); + for (&rows) |*row| { + const line = lines.next() orelse break; + row.text = std.mem.trimEnd(u8, line, "\r"); + row.base = @intFromPtr(line.ptr) - @intFromPtr(source.ptr); + } + var spaces: usize = 0; + while (true) { + var more = false; + for (rows) |row| more = more or row.at < row.text.len or row.left > 0; + if (!more) break; + const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } }; + var mask: u8 = 0; + var counts: [5]u8 = @splat(0); + for (&rows, 0..) |*row, y| { + for (0..2) |x| { + if (row.dot(styles)) |color| { + mask |= @as(u8, 1) << bits[y][x]; + counts[color] += 1; + } + } + } + if (mask == 0) { + spaces += 1; + continue; + } + if (spaces + 3 > max_output_bytes - offset) return error.MiniTooLarge; + var color: u8 = 0; + var most: u8 = 0; + for (counts[1..], 1..) |count, i| { + if (count > most) { + color = @intCast(i); + most = count; + } + } + if (output) |out| { + @memset(out.content[offset..][0..spaces], ' '); + @memset(out.colors[offset..][0..spaces], 0); + // unreachable: U+2800 plus a u8 mask is a braille codepoint, always three bytes + _ = std.unicode.utf8Encode(@as(u21, 0x2800) + mask, out.content[offset + spaces ..][0..3]) catch unreachable; + @memset(out.colors[offset + spaces ..][0..3], color); + } + offset += spaces + 3; + spaces = 0; + } + if (offset == max_output_bytes) return error.MiniTooLarge; + if (output) |out| { + out.content[offset] = '\n'; + out.colors[offset] = 0; + } + offset += 1; + } + return offset; +} + +pub fn generate(gpa: std.mem.Allocator, source: []const u8, styles: []const u8) !Result { + if (source.len > max_input_bytes) return error.MiniTooLarge; + if (!std.unicode.utf8ValidateSlice(source)) return error.InvalidUtf8; + if (styles.len != 0 and styles.len != source.len) return error.InvalidMiniColors; + for (styles) |color| if (color > @intFromEnum(syntax.Syn.comment)) return error.InvalidMiniColors; + const len = try render(null, source, styles); + const content = try gpa.alloc(u8, len); + errdefer gpa.free(content); + const colors = try gpa.alloc(u8, len); + errdefer gpa.free(colors); + const result: Result = .{ .content = content, .colors = colors }; + const written = try render(result, source, styles); + std.debug.assert(written == len); + return result; +} + +pub fn open(p: *Pardes, id: usize, argument: []const u8) !void { + const caller = p.panes[id] orelse return error.MissingPane; + const word = std.mem.trim(u8, argument, " \t\r\n"); + if (word.len == 0) return error.MissingPath; + var path_buf: [4096]u8 = undefined; + const target = filesystem.resolve(p, word, Pardes.paneDir(caller), &path_buf) orelse return error.FileNotFound; + if (target.dir) return error.NotAFile; + const source = try p.gpa.dupe(u8, target.path); + errdefer p.gpa.free(source); + const input = try filesystem.readLimit(p, source, max_input_bytes); + defer p.gpa.free(input); + const styles = try syntax.highlightFileRange(p.tree_sitter_gpa, source, input, 0, input.len); + defer p.tree_sitter_gpa.free(styles); + const result = try generate(p.gpa, input, styles); + errdefer result.deinit(p.gpa); + for (p.panes, 0..) |slot, i| { + const pane = slot orelse continue; + const file = if (pane.file) |*f| f else continue; + const old = file.mini orelse continue; + if (!std.mem.eql(u8, old.source, source)) continue; + if (std.mem.eql(u8, file.content, result.content) and std.mem.eql(u8, old.colors, result.colors)) { + p.gpa.free(source); + result.deinit(p.gpa); + } else { + File.setContent(p, file, result.content); + file.mini = .{ .source = source, .colors = result.colors }; + file.syntax_dirty = false; + Output.resetBody(p, pane); + } + p.active = i; + return; + } + const free = p.freeSlot() orelse return error.NoPaneSlots; + const dir = std.fs.path.dirname(source) orelse "/"; + const path = try std.fmt.allocPrint(p.gpa, "{s}/Mini {s}", .{ std.mem.trimEnd(u8, dir, "/"), std.fs.path.basename(source) }); + errdefer p.gpa.free(path); + const history = try File.History.create(p.gpa); + errdefer p.gpa.destroy(history); + const pane = try p.newDocPane(free); + pane.file = .{ + .path = path, + .content = result.content, + .output = .{ .from = .{ .cmd = .Mini } }, + .mini = .{ .source = source, .colors = result.colors }, + .history = history, + .syntax_dirty = false, + }; + pane.body.cur_pinned = true; + exec.placeNew(p, id, id, free, .doc); + p.active = free; +} + +test "Mini maps every braille dot and partial line group" { + const gpa = std.testing.allocator; + const bits = [4][2]u3{ .{ 0, 3 }, .{ 1, 4 }, .{ 2, 5 }, .{ 6, 7 } }; + for (0..256) |mask| { + var source: [12]u8 = undefined; + for (0..4) |row| { + for (0..2) |col| source[row * 3 + col] = if (mask & (@as(usize, 1) << bits[row][col]) != 0) 'x' else ' '; + source[row * 3 + 2] = '\n'; + } + const result = try generate(gpa, &source, ""); + defer result.deinit(gpa); + var expected: [4]u8 = undefined; + const len: usize = if (mask == 0) 0 else try std.unicode.utf8Encode(@as(u21, 0x2800) + @as(u21, @intCast(mask)), &expected); + expected[len] = '\n'; + try std.testing.expectEqualStrings(expected[0 .. len + 1], result.content); + try std.testing.expectEqual(result.content.len, result.colors.len); + for (result.colors) |color| try std.testing.expectEqual(@as(u8, 0), color); + } + for ([_]struct { source: []const u8, expected: []const u8 }{ + .{ .source = "", .expected = "" }, + .{ .source = "x", .expected = "⠁\n" }, + .{ .source = "xx\n", .expected = "⠉\n" }, + .{ .source = "x \n", .expected = "⠁\n" }, + .{ .source = "\n\n\n\nx", .expected = "\n⠁\n" }, + .{ .source = "x\r\nx\r\n", .expected = "⠃\n" }, + }) |case| { + const result = try generate(gpa, case.source, ""); + defer result.deinit(gpa); + try std.testing.expectEqualStrings(case.expected, result.content); + } +} + +test "Mini uses display cells for tabs combining text and wide characters" { + const gpa = std.testing.allocator; + for ([_]struct { source: []const u8, expected: []const u8 }{ + .{ .source = "e\u{301}界\n", .expected = "⠉⠁\n" }, + .{ .source = " x\n", .expected = " ⠁\n" }, + .{ .source = "\u{a0}x\n", .expected = "⠈\n" }, + }) |case| { + const result = try generate(gpa, case.source, ""); + defer result.deinit(gpa); + try std.testing.expectEqualStrings(case.expected, result.content); + } + const tabs = try generate(gpa, "\tx\n", ""); + defer tabs.deinit(gpa); + const spaces = config.tab_width / 2; + for (tabs.content[0..spaces]) |byte| try std.testing.expectEqual(@as(u8, ' '), byte); + try std.testing.expectEqualStrings(if (config.tab_width % 2 == 0) "⠁\n" else "⠈\n", tabs.content[spaces..]); +} + +test "Mini chooses highlighted dots over plain ink with stable color ties" { + const gpa = std.testing.allocator; + const source = "xx\nxx\nxx\nxx\n"; + var styles: [source.len]u8 = @splat(0); + styles[0] = @intFromEnum(syntax.Syn.keyword); + const rare = try generate(gpa, source, &styles); + defer rare.deinit(gpa); + try std.testing.expectEqualStrings("⣿\n", rare.content); + try std.testing.expectEqualSlices(u8, &.{ 1, 1, 1, 0 }, rare.colors); + styles[0] = @intFromEnum(syntax.Syn.string); + styles[1] = @intFromEnum(syntax.Syn.number); + const tied = try generate(gpa, source, &styles); + defer tied.deinit(gpa); + try std.testing.expectEqualSlices(u8, &.{ 2, 2, 2, 0 }, tied.colors); + styles[3] = @intFromEnum(syntax.Syn.number); + const majority = try generate(gpa, source, &styles); + defer majority.deinit(gpa); + try std.testing.expectEqualSlices(u8, &.{ 3, 3, 3, 0 }, majority.colors); +} + +test "Mini generation bounds and allocation failures leave no partial result" { + const Case = struct { + fn run(gpa: std.mem.Allocator) !void { + const result = try generate(gpa, "alpha\nbeta\ngamma\ndelta\nepsilon\n", ""); + defer result.deinit(gpa); + } + }; + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{}); + try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "x", &.{5})); + try std.testing.expectError(error.InvalidMiniColors, generate(std.testing.allocator, "xx", &.{0})); + var allocator = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); + const source = try std.testing.allocator.alloc(u8, max_input_bytes + 1); + defer std.testing.allocator.free(source); + try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source, "")); + for (source[0..max_input_bytes], 0..) |*byte, i| byte.* = if (i % 2 == 0) 'x' else ' '; + try std.testing.expectError(error.MiniTooLarge, generate(allocator.allocator(), source[0..max_input_bytes], "")); + try std.testing.expect(!allocator.has_induced_failure); +} + +test "Mini publishes only complete snapshots and content replacement drops metadata" { + const Case = struct { + fn run(gpa: std.mem.Allocator, path: []const u8) !void { + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + const source = try p.setTestFile("untouched\n"); + const free = p.freeSlot(); + open(p, 0, path) catch |err| { + try std.testing.expectEqual(@as(usize, 0), p.active); + try std.testing.expectEqual(free, p.freeSlot()); + try std.testing.expectEqual(source, p.panes[0].?); + try std.testing.expectEqualStrings("untouched\n", source.file.?.content); + return err; + }; + const file = &p.panes[p.active].?.file.?; + try std.testing.expectEqualStrings("⠉\n", file.content); + try std.testing.expectEqualStrings(path, file.mini.?.source); + const replacement = try gpa.dupe(u8, "plain\n"); + File.setContent(p, file, replacement); + try std.testing.expect(file.mini == null); + try std.testing.expectEqualStrings("plain\n", file.content); + } + }; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "mini.txt", .data = "xx\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + var path_buf: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/mini.txt", .{dir}); + try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{path}); +} + +test "Mini refuses a file that is no UTF-8, before its decoding could panic" { + try std.testing.expectError(error.InvalidUtf8, generate(std.testing.allocator, "ok \xff\xfe bad\n\xc3", "")); +} diff --git a/src/panes.zig b/src/panes.zig index 32ad7b6b..b6fdc31a 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -636,7 +636,7 @@ pub const File = @import("File.zig"); pub const Output = @import("Output.zig"); -pub const Mini = @import("Mini.zig"); +pub const mini = @import("mini.zig"); pub const Image = @import("image.zig"); @@ -649,7 +649,7 @@ test { _ = Text; _ = File; _ = Output; - _ = Mini; + _ = mini; _ = Image; _ = Pdf; _ = Terminal; diff --git a/test/output.zig b/test/output.zig index e5f87fb7..1b1b80c9 100644 --- a/test/output.zig +++ b/test/output.zig @@ -472,7 +472,7 @@ test "Mini syntax colors survive toggles themes and rendering without source acc defer p.deinit(); try p.panes[0].?.setOwnedCwd(dir); p.settings.colors = false; - try panes.Mini.open(p, 0, "demo.zig"); + try panes.mini.open(p, 0, "demo.zig"); const id = p.active; const mini = p.panes[id].?; const keyword = std.mem.indexOfScalar(u8, mini.file.?.mini.?.colors, @intFromEnum(pardes.syntax.Syn.keyword)) orelse return error.MissingKeywordColor; @@ -522,12 +522,12 @@ test "Mini failures leave its existing snapshot and focus unchanged" { const source = try p.setTestFile("abc\n"); var path_buf: [128]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "/virtual/pane/{d}/body", .{source.serial}); - try panes.Mini.open(p, 0, path); + try panes.mini.open(p, 0, path); const id = p.active; const mini = p.panes[id].?; const body = mini.file.?.content.ptr; const next = p.freeSlot(); - try std.testing.expectError(error.FileNotFound, panes.Mini.open(p, 0, "/virtual/does-not-exist")); + try std.testing.expectError(error.FileNotFound, panes.mini.open(p, 0, "/virtual/does-not-exist")); try std.testing.expectEqual(id, p.active); try std.testing.expectEqual(body, mini.file.?.content.ptr); try std.testing.expectEqual(next, p.freeSlot()); diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json index 659e49d1..56a5da91 100644 --- a/test/perf-baseline-gui-Debug.json +++ b/test/perf-baseline-gui-Debug.json @@ -1 +1 @@ -{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1841,"med_us":2197,"p90_us":2308,"max_us":2328},{"op":"render","fixture":"medium","min_us":72,"med_us":81,"p90_us":84,"max_us":86},{"op":"key-down","fixture":"medium","min_us":74,"med_us":74,"p90_us":80,"max_us":81},{"op":"key-right","fixture":"medium","min_us":74,"med_us":80,"p90_us":85,"max_us":100},{"op":"page-down","fixture":"medium","min_us":74,"med_us":81,"p90_us":84,"max_us":176},{"op":"wheel","fixture":"medium","min_us":71,"med_us":75,"p90_us":79,"max_us":93},{"op":"edit-char","fixture":"medium","min_us":1086,"med_us":1515,"p90_us":1692,"max_us":1822},{"op":"body-2m","fixture":"medium","min_us":9044,"med_us":16111,"p90_us":19782,"max_us":21405}]} +{"harness":"5cbb536cdc74cbcf","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2022,"med_us":2284,"p90_us":2363,"max_us":2431},{"op":"render","fixture":"medium","min_us":80,"med_us":83,"p90_us":90,"max_us":94},{"op":"key-down","fixture":"medium","min_us":79,"med_us":82,"p90_us":87,"max_us":97},{"op":"key-right","fixture":"medium","min_us":82,"med_us":83,"p90_us":91,"max_us":95},{"op":"page-down","fixture":"medium","min_us":79,"med_us":83,"p90_us":84,"max_us":86},{"op":"wheel","fixture":"medium","min_us":79,"med_us":82,"p90_us":85,"max_us":87},{"op":"edit-char","fixture":"medium","min_us":1190,"med_us":1621,"p90_us":1843,"max_us":2008},{"op":"body-2m","fixture":"medium","min_us":9178,"med_us":16872,"p90_us":20886,"max_us":22596}]} diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json index bed59c8c..9c4b5ebe 100644 --- a/test/perf-baseline-tty-Debug.json +++ b/test/perf-baseline-tty-Debug.json @@ -1 +1 @@ -{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1800,"med_us":2113,"p90_us":2193,"max_us":2326},{"op":"render","fixture":"medium","min_us":71,"med_us":76,"p90_us":85,"max_us":97},{"op":"key-down","fixture":"medium","min_us":73,"med_us":76,"p90_us":82,"max_us":84},{"op":"key-right","fixture":"medium","min_us":72,"med_us":76,"p90_us":81,"max_us":95},{"op":"page-down","fixture":"medium","min_us":74,"med_us":78,"p90_us":83,"max_us":83},{"op":"wheel","fixture":"medium","min_us":72,"med_us":75,"p90_us":82,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1027,"med_us":1418,"p90_us":1699,"max_us":1834},{"op":"body-2m","fixture":"medium","min_us":8357,"med_us":17722,"p90_us":20080,"max_us":21699}]} +{"harness":"5cbb536cdc74cbcf","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1902,"med_us":2386,"p90_us":2484,"max_us":2686},{"op":"render","fixture":"medium","min_us":83,"med_us":87,"p90_us":93,"max_us":118},{"op":"key-down","fixture":"medium","min_us":88,"med_us":94,"p90_us":100,"max_us":156},{"op":"key-right","fixture":"medium","min_us":83,"med_us":86,"p90_us":88,"max_us":92},{"op":"page-down","fixture":"medium","min_us":78,"med_us":84,"p90_us":89,"max_us":101},{"op":"wheel","fixture":"medium","min_us":77,"med_us":84,"p90_us":89,"max_us":132},{"op":"edit-char","fixture":"medium","min_us":1177,"med_us":1519,"p90_us":1721,"max_us":1909},{"op":"body-2m","fixture":"medium","min_us":8533,"med_us":15772,"p90_us":21361,"max_us":23276}]} diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json index d13442a5..984d480a 100644 --- a/test/perf-baseline-tty-ReleaseFast.json +++ b/test/perf-baseline-tty-ReleaseFast.json @@ -1 +1 @@ -{"harness":"b96624a78789195f","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1819,"med_us":2002,"p90_us":2068,"max_us":2122},{"op":"render","fixture":"medium","min_us":71,"med_us":76,"p90_us":88,"max_us":89},{"op":"key-down","fixture":"medium","min_us":73,"med_us":75,"p90_us":76,"max_us":82},{"op":"key-right","fixture":"medium","min_us":73,"med_us":77,"p90_us":86,"max_us":126},{"op":"page-down","fixture":"medium","min_us":71,"med_us":75,"p90_us":81,"max_us":83},{"op":"wheel","fixture":"medium","min_us":71,"med_us":74,"p90_us":76,"max_us":97},{"op":"edit-char","fixture":"medium","min_us":1036,"med_us":1493,"p90_us":1805,"max_us":1967},{"op":"body-2m","fixture":"medium","min_us":8482,"med_us":17227,"p90_us":20296,"max_us":23040}]} +{"harness":"5cbb536cdc74cbcf","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2061,"med_us":2326,"p90_us":2462,"max_us":2503},{"op":"render","fixture":"medium","min_us":76,"med_us":80,"p90_us":86,"max_us":98},{"op":"key-down","fixture":"medium","min_us":78,"med_us":83,"p90_us":91,"max_us":95},{"op":"key-right","fixture":"medium","min_us":77,"med_us":81,"p90_us":86,"max_us":90},{"op":"page-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":89,"max_us":90},{"op":"wheel","fixture":"medium","min_us":75,"med_us":82,"p90_us":87,"max_us":94},{"op":"edit-char","fixture":"medium","min_us":1277,"med_us":1713,"p90_us":1948,"max_us":2063},{"op":"body-2m","fixture":"medium","min_us":9261,"med_us":17043,"p90_us":23512,"max_us":23722}]} diff --git a/test/perf.zig b/test/perf.zig index c639178f..38c640b4 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -1023,12 +1023,12 @@ const MiniMeasurement = struct { live_after_deinit: usize = 0, }; -fn checkMini(actual: pardes.panes.Mini.Result, expected: pardes.panes.Mini.Result) !void { +fn checkMini(actual: pardes.panes.mini.Result, expected: pardes.panes.mini.Result) !void { if (!std.mem.eql(u8, actual.content, expected.content) or !std.mem.eql(u8, actual.colors, expected.colors)) return error.MiniOutputMismatch; } -fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const u8, source: []const u8, styles: []const u8, expected: pardes.panes.Mini.Result, reps: usize) !MiniMeasurement { +fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const u8, source: []const u8, styles: []const u8, expected: pardes.panes.mini.Result, reps: usize) !MiniMeasurement { const samples = try gpa.alloc(u64, reps); defer gpa.free(samples); const allocators = pardes.memory.init(fallback); @@ -1059,7 +1059,7 @@ fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const defer arena.deinit(); if (core) |p| { pump(p); - try pardes.panes.Mini.open(p, 0, path); + try pardes.panes.mini.open(p, 0, path); for (p.panes, 0..) |slot, id| if (slot != null and id != p.active) try p.removePane(id, null); pump(p); p.settings.colors = true; @@ -1095,7 +1095,7 @@ fn miniMeasurement(fallback: std.mem.Allocator, probe: MiniProbe, path: []const else styles; defer if (probe == .highlight_generate) syntax_allocator.free(highlighted); - const converted = try pardes.panes.Mini.generate(allocator, source, highlighted); + const converted = try pardes.panes.mini.generate(allocator, source, highlighted); const elapsed = nowNs() - begin; defer converted.deinit(allocator); if (index == 0) result.cold_ns = elapsed else samples[index - 1] = elapsed; @@ -1151,7 +1151,7 @@ fn measureMini(io: std.Io, fallback: std.mem.Allocator, reps: usize, json: bool) defer gpa.free(styles); if (styles.len != source.len or std.mem.indexOfScalar(u8, styles, @intFromEnum(pardes.syntax.Syn.comment)) == null) return error.MiniSyntaxUnavailable; - const expected = try pardes.panes.Mini.generate(gpa, source, styles); + const expected = try pardes.panes.mini.generate(gpa, source, styles); defer expected.deinit(gpa); if (expected.content.len == 0 or expected.content.len != expected.colors.len or !std.unicode.utf8ValidateSlice(expected.content)) return error.InvalidMiniOutput; -- cgit v1.3