//! 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); 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); _ = 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.cur_pinned = true; exec.placeDoc(p, id, free, pane); 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}); }