diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/9p.zig | 2 | ||||
| -rw-r--r-- | src/File.zig | 2 | ||||
| -rw-r--r-- | src/Layer.zig | 28 | ||||
| -rw-r--r-- | src/Messages.zig | 38 | ||||
| -rw-r--r-- | src/Output.zig | 2 | ||||
| -rw-r--r-- | src/ShaderBuild.zig (renamed from src/shader_build.zig) | 0 | ||||
| -rw-r--r-- | src/builtins.zig | 5 | ||||
| -rw-r--r-- | src/detached/server.zig | 4 | ||||
| -rw-r--r-- | src/detached/wire.zig | 10 | ||||
| -rw-r--r-- | src/dump.zig | 48 | ||||
| -rw-r--r-- | src/edit.zig | 4 | ||||
| -rw-r--r-- | src/file_watch.zig | 2 | ||||
| -rw-r--r-- | src/fs.zig | 2 | ||||
| -rw-r--r-- | src/gui/Post.zig | 12 | ||||
| -rw-r--r-- | src/gui/gui.zig | 8 | ||||
| -rw-r--r-- | src/locations.zig | 292 | ||||
| -rw-r--r-- | src/locations_cache.zig | 213 | ||||
| -rw-r--r-- | src/locations_config.zig | 75 | ||||
| -rw-r--r-- | src/look.zig | 8 | ||||
| -rw-r--r-- | src/main.zig | 42 | ||||
| -rw-r--r-- | src/mini.zig (renamed from src/Mini.zig) | 0 | ||||
| -rw-r--r-- | src/mouse.zig | 8 | ||||
| -rw-r--r-- | src/ninep/events.zig | 6 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 6 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 99 | ||||
| -rw-r--r-- | src/panes.zig | 4 | ||||
| -rw-r--r-- | src/pardes.zig | 18 | ||||
| -rw-r--r-- | src/tag_layer.zig | 32 | ||||
| -rw-r--r-- | src/tty/tty.zig | 280 |
29 files changed, 761 insertions, 489 deletions
@@ -53,7 +53,7 @@ pub const msize_min = cloud9.fs.msize_min; /// The smallest msize a native listener offers (src/9p_io.zig). pub const min_msize: u32 = 4096; /// What a native listener negotiates. -pub const msize: u32 = 8192; +pub const msize: u32 = 65536; /// The editor's engine: native fid count, native file names, and room to /// park a write of any size a frame can carry -- a request that would change 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/Layer.zig b/src/Layer.zig index a9827304..7a5fcf7e 100644 --- a/src/Layer.zig +++ b/src/Layer.zig @@ -199,3 +199,31 @@ test "a taller tag's rows are grid rows, each hit on its own line" { try std.testing.expect(layer.tagHit(120, 140, 10, 20, 6, 12) == null); try std.testing.expect(layer.bodyHit(120, 100, 10, 20, 6, 12) == null); } + +// Pointer hits on tag bands: comparing two, and bringing one that left +// its band back onto it. + +/// Whether two pointer hits name the same cell of the same tag line. +pub fn sameCell(a: ?TagHit, b: ?TagHit) bool { + const first = a orelse return b == null; + const second = b orelse return false; + return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.line == second.line and first.col == second.col; +} + +/// The tag column a host's pointer is at. Clamped, a point off the tag is +/// brought back onto line `line` of it, for a drag that leaves it. +pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?TagHit, clamp: bool, line: u16) ?u16 { + var point = supplied orelse return null; + if (point.kind != kind or point.id != id) return null; + if (clamp) for (p.surface.tagLayers()) |*layer| { + if (layer.rows == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or line >= layer.rows) continue; + const bw: f32 = @floatFromInt(point.metrics.body_w); + const bh: f32 = @floatFromInt(point.metrics.body_h); + const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; + point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); + point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y + line)) + 0.5) * bh; + break; + }; + return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; +} diff --git a/src/Messages.zig b/src/Messages.zig index 7342f726..7dcb22ed 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -51,13 +51,13 @@ pub const LoggedMessage = struct { fn leaderText(p: *const Pardes, buf: *[16]u8) []const u8 { buf.* = @splat(' '); @memcpy(buf[0..3], "SPC"); - var at: usize = 3; + var end: usize = 3; for (p.leader_keys[0..p.leader_n]) |ch| { - if (at + 2 > buf.len) break; - buf[at + 1] = ch; - at += 2; + if (end + 2 > buf.len) break; + buf[end + 1] = ch; + end += 2; } - return buf[0..at]; + return buf[0..end]; } /// How wide a notice chip is, in grid columns: its own text plus a blank @@ -437,7 +437,7 @@ fn logMessage(p: *Pardes, id: usize, text: []const u8) void { } /// The log oldest-first, which is reading order. -pub fn messageLog(m: *const Messages, i: usize) ?*const LoggedMessage { +pub fn at(m: *const Messages, i: usize) ?*const LoggedMessage { if (i >= m.len) return null; const first = (m.head + limits.message_log - m.len) % limits.message_log; return &m.log[(first + i) % limits.message_log]; @@ -503,18 +503,14 @@ pub fn clip(text: []const u8, max: usize) []const u8 { /// reportError with the words already chosen. pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void { p.fs.failures +%= 1; - // A builtin a ctl write runs: its first error is also the write's, cut - // between words. + // A builtin a ctl write runs: its first error is also the write's, its + // path shortened in the middle if it must be, never its reason. const failing_write = p.fs.no_prompt or p.fs.capturing or p.fs.write_waits; if (failing_write and p.fs.failure_len == 0) { - const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text; + var room: @TypeOf(p.fs.failure) = undefined; + const kept = pardes.ctlfs.fitErr(text, &room); @memcpy(p.fs.failure[0..kept.len], kept); - var n = kept.len; - if (kept.len < text.len) { - @memcpy(p.fs.failure[n..][0..3], "..."); - n += 3; - } - p.fs.failure_len = @intCast(n); + p.fs.failure_len = @intCast(kept.len); } // The write fails with it, and its err record says it: no msg for it, // so the same failure again is the same record again, counted @@ -584,13 +580,13 @@ test "the message log keeps what the row forgets, and collapses repeats" { setMessage(p, 0, "save: AccessDenied"); try std.testing.expectEqual(@as(usize, 2), p.messages.len); - const first = p.messages.messageLog(0).?; + const first = p.messages.at(0).?; try std.testing.expectEqual(@as(u16, 2), first.repeats); // ...and the NEWEST wording is what survives, so the row carries the last // time it happened rather than the first. try std.testing.expectEqualStrings("14:32:09 saved /x.zig", first.slice()); - try std.testing.expectEqualStrings("save: AccessDenied", p.messages.messageLog(1).?.slice()); - try std.testing.expect(p.messages.messageLog(2) == null); + try std.testing.expectEqualStrings("save: AccessDenied", p.messages.at(1).?.slice()); + try std.testing.expect(p.messages.at(2) == null); // The same text from a DIFFERENT pane is a different event: one pane's // failure must not be recorded as another's. @@ -598,7 +594,7 @@ test "the message log keeps what the row forgets, and collapses repeats" { const other = p.active; setMessage(p, other, "save: AccessDenied"); try std.testing.expectEqual(@as(usize, 3), p.messages.len); - try std.testing.expectEqual(p.panes[other].?.serial, p.messages.messageLog(2).?.serial); + try std.testing.expectEqual(p.panes[other].?.serial, p.messages.at(2).?.serial); // Progress is NOT logged: it arrives several times a second for a whole // index and would push everything else out (`setStatus`). @@ -611,10 +607,10 @@ test "the message log keeps what the row forgets, and collapses repeats" { setMessage(p, 0, std.fmt.bufPrint(&buf, "line {d}", .{i}) catch unreachable); } try std.testing.expectEqual(@as(usize, limits.message_log), p.messages.len); - try std.testing.expectEqualStrings("line 5", p.messages.messageLog(0).?.slice()); + try std.testing.expectEqualStrings("line 5", p.messages.at(0).?.slice()); var last_buf: [32]u8 = undefined; const want_last = std.fmt.bufPrint(&last_buf, "line {d}", .{limits.message_log + 4}) catch unreachable; - try std.testing.expectEqualStrings(want_last, p.messages.messageLog(limits.message_log - 1).?.slice()); + try std.testing.expectEqualStrings(want_last, p.messages.at(limits.message_log - 1).?.slice()); } test "Msg writes the transient row by hand, bare or with text, and input ends it" { diff --git a/src/Output.zig b/src/Output.zig index 6ea81924..fb8fe7a3 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -844,7 +844,7 @@ pub fn openMessages(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); var i: usize = 0; - while (p.messages.messageLog(i)) |m| : (i += 1) { + while (p.messages.at(i)) |m| : (i += 1) { if (m.serial != 0) try out.writer.print("{d}: ", .{m.serial}); try out.writer.writeAll(m.slice()); if (m.repeats > 1) try out.writer.print(" (x{d})", .{m.repeats}); diff --git a/src/shader_build.zig b/src/ShaderBuild.zig index c2610fd7..c2610fd7 100644 --- a/src/shader_build.zig +++ b/src/ShaderBuild.zig diff --git a/src/builtins.zig b/src/builtins.zig index e47afeb4..cc86067e 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -574,7 +574,8 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { std.fmt.bufPrint(&said_buf, "{s}: Modified ({s} again to discard)", .{ one, @tagName(asking) }) else std.fmt.bufPrint(&said_buf, "{d} unsaved panes: Modified ({s} again to discard)", .{ count, @tagName(asking) })) catch "unsaved panes: Modified"; - const kept = @import("Messages.zig").clip(said, c.p.fs.failure.len); + var room: @TypeOf(c.p.fs.failure) = undefined; + const kept = pardes.ctlfs.fitErr(said, &room); @memcpy(c.p.fs.failure[0..kept.len], kept); c.p.fs.failure_len = @intCast(kept.len); } @@ -1378,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/detached/server.zig b/src/detached/server.zig index b536e4a7..de26db13 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -11,7 +11,7 @@ const host_io = @import("../host_io.zig"); const selection_pipe = @import("../selection_pipe.zig"); const file_watch = @import("../file_watch.zig"); -const shader_build = @import("../shader_build.zig"); +const ShaderBuild = @import("../ShaderBuild.zig"); const ninep_io = @import("../9p_io.zig"); @@ -177,7 +177,7 @@ pub const Session = struct { ninep_wake: std.atomic.Value(bool) = .init(false), watches: file_watch.Table = @splat(null), /// The post chain's files, compiled here for the GUIs attached. - shaders: shader_build = .{}, + shaders: ShaderBuild = .{}, check_files: bool = false, in_loop: bool = false, greet_deadline_ms: u32 = greet_deadline_default_ms, diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 15d3641c..051e9317 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -3,7 +3,7 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); const limits = @import("../memory.zig").limits; -const shader_build = @import("../shader_build.zig"); +const ShaderBuild = @import("../ShaderBuild.zig"); const Scene = pardes.config.Runtime.Scene; const ShaderAnimation = pardes.config.Runtime.ShaderAnimation; @@ -364,15 +364,15 @@ pub const ServerMsg = union(enum) { /// The session's post chain, as a GUI attached to it draws it: its passes in /// order, each a bundled scene at its level or a Shadertoy file's SPIR-V -/// (compiled by the session, shader_build.zig: a frontend reads no disk and +/// (compiled by the session, ShaderBuild.zig: a frontend reads no disk and /// runs no program), and when it animates on its own. Slices borrow the /// payload. pub const Post = struct { animation: ShaderAnimation = .on, - passes: [shader_build.max]shader_build.Pass = @splat(.{}), + passes: [ShaderBuild.max]ShaderBuild.Pass = @splat(.{}), len: u8 = 0, - pub fn list(p: *const Post) []const shader_build.Pass { + pub fn list(p: *const Post) []const ShaderBuild.Pass { return p.passes[0..p.len]; } }; @@ -1275,7 +1275,7 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { .post => blk: { var p: Post = .{ .animation = try r.getTag(ShaderAnimation) }; p.len = try r.getByte(); - if (p.len > shader_build.max) return error.Overlong; + if (p.len > ShaderBuild.max) return error.Overlong; for (p.passes[0..p.len]) |*pass| { const kind = try r.getByte(); pass.* = .{ diff --git a/src/dump.zig b/src/dump.zig index fca88a2d..1eabdae8 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -31,7 +31,8 @@ const Pardes = pardes.Pardes; /// Where the next dump goes. $PARDES_DUMP wins verbatim (the snapshot harness /// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share /// /pardes/pardes-<utc>.zon — timestamped so dumps never overwrite each other. -/// Creates the pardes dir (parents assumed; a failure surfaces at open). +/// Creates the directory and any parent missing (a fresh HOME has no +/// ~/.local/share); a failure surfaces at open. pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 { if (std.c.getenv("PARDES_DUMP")) |p| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null; @@ -40,7 +41,13 @@ pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 { var dz: [901:0]u8 = undefined; @memcpy(dz[0..dir.len], dir); dz[dir.len] = 0; - _ = std.c.mkdir(dz[0..dir.len :0], 0o755); // EEXIST is fine + // Each missing directory on the way, as `mkdir -p`: EEXIST is fine. + for (dz[1..dir.len], 1..) |c, i| if (c == '/') { + dz[i] = 0; + _ = std.c.mkdir(dz[0..i :0], 0o755); + dz[i] = '/'; + }; + _ = std.c.mkdir(dz[0..dir.len :0], 0o755); var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.REALTIME, &ts); const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) }; @@ -109,6 +116,21 @@ pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 { return std.fmt.bufPrint(buf, "{s}", .{resolved}) catch null; } +test "a dump makes every directory missing on the way to its DumpDir" { + if (std.c.getenv("PARDES_DUMP") != null) return error.SkipZigTest; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var at: [4096]u8 = undefined; + const base = at[0..try tmp.dir.realPath(std.testing.io, &at)]; + var setting: [4200]u8 = undefined; + const dir = try std.fmt.bufPrint(&setting, "{s}/fresh/.local/share/pardes", .{base}); + var out: [1024:0]u8 = undefined; + const path = outPath(&out, dir).?; + try std.testing.expect(std.mem.startsWith(u8, path, dir)); + var made = try tmp.dir.openDir(std.testing.io, "fresh/.local/share/pardes", .{}); + made.close(std.testing.io); +} + test "a DumpDir reads back absolute, however it was written" { var buf: [4096]u8 = undefined; try std.testing.expectEqualStrings("/tmp/dumps", directory(&buf, "/tmp/dumps/").?); @@ -129,7 +151,7 @@ pub fn defaultDirectory(buf: []u8) ?[]const u8 { pub const magic = "pardes-dump"; pub const version: u32 = 1; pub const max_panes: usize = MAX_PANES; -pub const max_cols: usize = 6; +pub const max_cols: usize = MAX_COLS; /// Output arguments are typed in the same bounded one-line tag storage. Keep /// the schema limit named independently so a dump reader can validate it /// without importing the output-pane implementation. The bound itself is @@ -256,7 +278,7 @@ pub const State = struct { topbar: []const u8 = "", topbar_custom: ?[]const u8 = null, theme: []const u8 = "dark", - locations_config: @import("locations_config.zig").Config = .{}, + locations_config: @import("locations.zig").Config = .{}, tree_context_tag_style: bool = true, columns: []const Column = &.{}, panes: []const Pane = &.{}, @@ -987,6 +1009,24 @@ test "a dump keeps the settings that differ from a fresh session's, and a restor try std.testing.expect(!restored.settings.verbose); } +test "a session with every column it may hold dumps and restores them all" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }); + defer p.deinit(); + while (p.ncol < MAX_COLS) { + layout.compute(p); + var widest: usize = 0; + for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) { + widest = c; + }; + _ = layout.insertColumn(p, widest, false) orelse return error.NoColumn; + } + try dumpState(p); + const restored = try initFromDump(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }, p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqual(@as(usize, MAX_COLS), restored.ncol); +} + test "a restored pane keeps its dot, as acme's dump keeps a window's" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); diff --git a/src/edit.zig b/src/edit.zig index 183d6325..26062029 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -17,7 +17,7 @@ const syntax = @import("syntax.zig"); const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; -const tag_layer = @import("tag_layer.zig"); +const Layer = @import("Layer.zig"); const TagHit = pardes.TagHit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; @@ -2108,7 +2108,7 @@ pub fn pointerTextSelection(p: *const Pardes, id: usize, col: u16, row: u16, bod const v = @as(i32, mapped_hit.row) + pane.tag_rows; return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, .on_tag = false }; }; - const tag_col = if (on_tag) tag_layer.columnAt(p, .pane, id, tag_hit, false, row - tag_y) else null; + const tag_col = if (on_tag) Layer.columnAt(p, .pane, id, tag_hit, false, row - tag_y) else null; const c: i32 = (if (tag_col) |value| @as(i32, value) else @as(i32, col) - @as(i32, r.x + (if (on_tag) TAG_TEXT_INSET else config.GUTTER))) + (if (on_tag) @as(i32, pane.tag_scroll) else 0); const v: i32 = if (on_tag) @as(i32, row) - @as(i32, tag_y) else @as(i32, row) - @as(i32, body_y) + @as(i32, pane.tag_rows); diff --git a/src/file_watch.zig b/src/file_watch.zig index 148bc836..6c746e74 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -70,7 +70,7 @@ pub const Watch = struct { /// Sharing the table also shares directory descriptors correctly when a user /// happens to open that .zon as an ordinary document. pub const theme_slot = pardes.MAX_PANES; -/// After it, one slot a post chain file (shader_build.zig): marked only so +/// After it, one slot a post chain file (ShaderBuild.zig): marked only so /// a save there wakes the host, which has the files read again; no /// generation of theirs is kept here. pub const shader_slot = theme_slot + 1; @@ -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/gui/Post.zig b/src/gui/Post.zig index 602b2790..fc256135 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -2,7 +2,7 @@ //! finished frame, each reading the one before it (the frame first) and the //! last one writing the window. A pass is the bundled Crt (shaders/post/, //! compiled with the build) or a user's file (Shader <path>), whose SPIR-V -//! comes from shader_build.zig: compiled by the process that holds the core, +//! comes from ShaderBuild.zig: compiled by the process that holds the core, //! this one or the detached session this GUI is attached to. A file whose //! compile fails keeps its last good pipeline. //! @@ -24,7 +24,7 @@ const Pipeline = c.SDL_GPUGraphicsPipeline; pub const max = Chain.max; const prefix = @embedFile("post-prefix.glsl"); -const Built = @import("../shader_build.zig").Pass; +const Built = @import("../ShaderBuild.zig").Pass; const vert_spv = @embedFile("post.vert.spv"); const bundled_spv = std.EnumArray(Scene, []const u8).init(.{ .crt = @embedFile("post-crt.frag.spv"), @@ -92,9 +92,9 @@ pub const Pass = struct { /// A user's file, owned; empty for a bundled pass. path: []u8 = &.{}, pipeline: ?*Pipeline = null, - /// The SPIR-V revision `pipeline` was made from (shader_build.Pass). + /// The SPIR-V revision `pipeline` was made from (ShaderBuild.Pass). revision: u32 = 0, - /// It moves on its own (shader_build.Pass). + /// It moves on its own (ShaderBuild.Pass). animated: bool = false, }; @@ -125,7 +125,7 @@ bloom_up: ?*Pipeline = null, started_ns: ?u64 = null, last_ns: u64 = 0, -/// Brings the passes to the chain a shell was given (shader_build.zig: a +/// Brings the passes to the chain a shell was given (ShaderBuild.zig: a /// local core's, or an attached session's over the wire), whose `key` moves /// whenever the chain or a file's SPIR-V does. A file whose SPIR-V has not /// moved keeps its pipeline; one the GPU refuses keeps its last, and is said @@ -203,7 +203,7 @@ pub fn ready(post: *const Post) bool { } /// The chain redraws on its own (level A) as ShaderAnimation says. -/// Only a pass that moves on its own (shader_build.Pass.animated) asks for +/// Only a pass that moves on its own (ShaderBuild.Pass.animated) asks for /// frames while idle: a still one (Bloom, Vignette, Grain, a file that reads /// no clock) is drawn again only with a frame of the core's. pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index b34b314d..df565b94 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -11,7 +11,7 @@ const config = @import("../config.zig"); const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); -const shader_build = @import("../shader_build.zig"); +const ShaderBuild = @import("../ShaderBuild.zig"); const deck = @import("deck.zig"); const Post = @import("Post.zig"); const pet = @import("pet.zig"); @@ -4016,7 +4016,7 @@ const Shell = struct { inotify_fd: c_int, watches: *file_watch.Table, /// The post chain's files as SPIR-V. - shaders: shader_build = .{}, + shaders: ShaderBuild = .{}, threads_ok: bool = false, test_mode: bool = false, feed: StdinFeed = .{}, @@ -4240,7 +4240,7 @@ const Shell = struct { } }; -/// A post chain compile is done (shader_build.zig): the loop's SDL wait ends. +/// A post chain compile is done (ShaderBuild.zig): the loop's SDL wait ends. fn wakeSdl(_: ?*anyopaque) void { var event = std.mem.zeroes(c.SDL_Event); event.type = c.SDL_EVENT_USER; @@ -4391,7 +4391,7 @@ fn pollFrame(ctx: ?*anyopaque) void { stepScroll(g, core, s.gpa); _ = s.shaders.sync(s.gpa, s.io, core, s.inotify_fd, s.watches, .{ .call = wakeSdl }); { - var passes: [Post.max]shader_build.Pass = undefined; + var passes: [Post.max]ShaderBuild.Pass = undefined; g.post.sync(s.gpa, g.device, g.swapchain_format, s.shaders.view(&core.settings.post, &passes), s.shaders.revision, core); } if (s.test_mode) writeTestStatus(g, core, s.presented); diff --git a/src/locations.zig b/src/locations.zig index b34f7a6f..6a604907 100644 --- a/src/locations.zig +++ b/src/locations.zig @@ -4,7 +4,6 @@ const look = @import("look.zig"); const syntax = @import("syntax.zig"); const filesystem = @import("fs.zig"); -pub const Config = @import("locations_config.zig").Config; const neighbor_context: u8 = 1; const declaration_context: u8 = 2; const declaration_start: u8 = 4; @@ -206,7 +205,7 @@ pub fn format(p: *pardes.Pardes, dir: []const u8, input: []const u8, anchor: ?us // Muted declaration headers do not need a source-color pass. // Exact bytes include unsaved edits and virtual/remote sources. var cached = p.locations_cache.get(p.tree_sitter_gpa, text.path, source, p.locations_config.tscontext, p.locations_config.context > 0) catch - @import("locations_cache.zig").Result{ .analysis = syntax.analyzeSource(arena, text.path, source, p.locations_config.tscontext, p.locations_config.context > 0) catch .{} }; + Cache.Hit{ .analysis = syntax.analyzeSource(arena, text.path, source, p.locations_config.tscontext, p.locations_config.context > 0) catch .{} }; defer cached.deinit(p.tree_sitter_gpa); const analysis = cached.analysis; const source_colors = analysis.colors; @@ -393,3 +392,292 @@ test "locations native results retain the first source tab" { try std.testing.expectEqualStrings("some directory/a.zig", row.path); try std.testing.expectEqualStrings("\t value", text[row.code_start..]); } + +// ---- the Locations setting ---- + +pub const Config = struct { + pub const Layout = enum { @"inline", stacked }; + context: u16 = 0, + tscontext: bool = false, + tslocations: bool = true, + layout: Layout = .stacked, + + /// Apply only supplied fields. A malformed token rejects the whole update. + /// Repeating a field uses its last supplied value. + pub fn parse(current: Config, text: []const u8) !Config { + var result = current; + var tokens = std.mem.tokenizeAny(u8, text, " \t\r\n"); + while (tokens.next()) |token| { + const colon = std.mem.indexOfScalar(u8, token, ':') orelse return error.ExpectedKeyValue; + const key = token[0..colon]; + const value = token[colon + 1 ..]; + var found = false; + inline for (@typeInfo(Config).@"struct".fields) |field| { + if (std.mem.eql(u8, key, field.name)) { + @field(result, field.name) = switch (@typeInfo(field.type)) { + .bool => if (std.mem.eql(u8, value, "on")) true else if (std.mem.eql(u8, value, "off")) false else return error.ExpectedOnOrOff, + .int => std.fmt.parseInt(field.type, value, 10) catch return error.InvalidContext, + .@"enum" => std.meta.stringToEnum(field.type, value) orelse return error.InvalidLayout, + else => @compileError("unsupported location setting type"), + }; + found = true; + } + } + if (!found) return error.UnknownLocationSetting; + } + return result; + } + + pub fn write(config: Config, writer: *std.Io.Writer) !void { + inline for (@typeInfo(Config).@"struct".fields, 0..) |field, index| { + if (index > 0) try writer.writeByte(' '); + try writer.writeAll(field.name ++ ":"); + const value = @field(config, field.name); + switch (@typeInfo(field.type)) { + .bool => try writer.writeAll(if (value) "on" else "off"), + .int => try writer.print("{d}", .{value}), + .@"enum" => try writer.writeAll(@tagName(value)), + else => @compileError("unsupported location setting type"), + } + } + } +}; + +test "LocationsConfig reflection round trip and partial updates" { + var config: Config = .{}; + config = try config.parse("context:5 tscontext:on"); + config = try config.parse("tslocations:off"); + try std.testing.expectEqual(Config{ .context = 5, .tscontext = true, .tslocations = false }, config); + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + try config.write(&out.writer); + try std.testing.expectEqualStrings("context:5 tscontext:on tslocations:off layout:stacked", out.written()); + try std.testing.expectEqual(config, try (Config{}).parse(out.written())); + try std.testing.expectEqual(config, try config.parse(" \n\t")); + try std.testing.expectEqual(@as(u16, 2), (try config.parse("context:1 context:2")).context); + try std.testing.expectEqual(Config.Layout.@"inline", (try config.parse("layout:inline")).layout); +} + +test "LocationsConfig rejects invalid updates atomically" { + const config: Config = .{ .context = 3, .tscontext = true }; + try std.testing.expectError(error.UnknownLocationSetting, config.parse("context:5 unknown:on")); + try std.testing.expectError(error.ExpectedOnOrOff, config.parse("context:5 tscontext:true")); + try std.testing.expectError(error.ExpectedKeyValue, config.parse("context")); + try std.testing.expectError(error.InvalidLayout, config.parse("context:5 layout:sideways")); + for ([_][]const u8{ "context:", "context:-1", "context:65536", "context:five" }) |input| + try std.testing.expectError(error.InvalidContext, config.parse(input)); + try std.testing.expectEqual(@as(u16, 3), config.context); +} + +// ---- the analysis cache ---- + +/// Owned source snapshots keep analysis valid across filesystem changes and +/// borrowed editor buffers. Entries are ordered from least to most recent. +pub const Cache = struct { + /// A cache hit borrows its analysis; an entry too large to retain transfers + /// ownership instead. Call deinit after consuming either result. + pub const Hit = struct { + analysis: syntax.SourceAnalysis, + owned: bool = false, + + pub fn deinit(result: *Hit, gpa: std.mem.Allocator) void { + if (result.owned) result.analysis.deinit(gpa); + result.* = undefined; + } + }; + + pub const max_bytes = 64 * 1024 * 1024; + pub const max_entries = 64; + + const Entry = struct { + path: []u8, + source: []u8, + analysis: syntax.SourceAnalysis, + declarations_ready: bool, + colors_ready: bool, + + fn size(entry: Entry) usize { + return entry.path.len + entry.source.len + entry.analysis.colors.len + + entry.analysis.declarations.len * @sizeOf(syntax.ContextDeclaration); + } + + fn deinit(entry: *Entry, gpa: std.mem.Allocator) void { + gpa.free(entry.path); + gpa.free(entry.source); + entry.analysis.deinit(gpa); + } + }; + + entries: [max_entries]Entry = undefined, + len: usize = 0, + bytes: usize = 0, + analyses: usize = 0, + hits: usize = 0, + + pub fn deinit(cache: *Cache, gpa: std.mem.Allocator) void { + for (cache.entries[0..cache.len]) |*entry| entry.deinit(gpa); + cache.* = .{}; + } + + /// Borrowed slices remain valid until the next get or cache deinit; an + /// owned result remains valid until Hit.deinit. All calls for this + /// cache must use the same allocator. Unsupported languages + /// are cached too, including their empty analysis arrays. + pub fn get(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool) !Hit { + return cache.getBounded(gpa, path, source, want_declarations, want_colors, max_bytes); + } + + fn getBounded(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool, budget: usize) !Hit { + if (source.len > budget or path.len > budget - source.len) return error.SourceTooLarge; + var previous: ?usize = null; + var declarations = want_declarations; + var colors = want_colors; + for (cache.entries[0..cache.len], 0..) |entry, index| { + if (!std.mem.eql(u8, entry.path, path)) continue; + previous = index; + if (!std.mem.eql(u8, entry.source, source)) break; + if ((!want_declarations or entry.declarations_ready) and (!want_colors or entry.colors_ready)) { + // Moving the small ownership record keeps the underlying + // allocations intact and avoids timestamp/overflow state. + std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]); + cache.entries[cache.len - 1] = entry; + cache.hits +|= 1; + return .{ .analysis = entry.analysis }; + } + declarations = declarations or entry.declarations_ready; + colors = colors or entry.colors_ready; + break; + } + // A supported full-source color map needs one byte per source byte. + // Reject that known minimum before parsing, so the caller's uncached + // path does not repeat an expensive analysis for oversized inputs. + const input_bytes = path.len + source.len; + if (colors and syntax.supportsPath(path) and source.len > budget - input_bytes) return error.SourceTooLarge; + var analysis = try syntax.analyzeSource(gpa, path, source, declarations, colors); + errdefer analysis.deinit(gpa); + cache.analyses +|= 1; + const analysis_bytes = analysis.colors.len + analysis.declarations.len * @sizeOf(syntax.ContextDeclaration); + if (analysis_bytes > budget - input_bytes) return .{ .analysis = analysis, .owned = true }; + const owned_path = try gpa.dupe(u8, path); + errdefer gpa.free(owned_path); + const owned_source = try gpa.dupe(u8, source); + errdefer gpa.free(owned_source); + const next: Entry = .{ + .path = owned_path, + .source = owned_source, + .analysis = analysis, + .declarations_ready = declarations, + .colors_ready = colors, + }; + // Commit only after every allocation succeeds. A failed refresh leaves + // the previous cached snapshot usable by the next request. + if (previous) |index| cache.remove(gpa, index); + while (cache.len == max_entries or cache.bytes > budget - next.size()) cache.remove(gpa, 0); + cache.entries[cache.len] = next; + cache.len += 1; + cache.bytes += next.size(); + return .{ .analysis = next.analysis }; + } + + fn remove(cache: *Cache, gpa: std.mem.Allocator, index: usize) void { + cache.bytes -= cache.entries[index].size(); + cache.entries[index].deinit(gpa); + std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]); + cache.len -= 1; + } +}; + +test "locations cache validates exact source and path and upgrades analysis" { + const gpa = std.testing.allocator; + syntax.start(gpa); + defer syntax.stop(); + var cache: Cache = .{}; + defer cache.deinit(gpa); + const source = "const Thing = struct {\n value: u32,\n};\n"; + _ = try cache.get(gpa, "a.zig", source, true, false); + try std.testing.expectEqual(@as(usize, 1), cache.analyses); + _ = try cache.get(gpa, "a.zig", source, true, false); + try std.testing.expectEqual(@as(usize, 1), cache.analyses); + try std.testing.expectEqual(@as(usize, 1), cache.hits); + _ = try cache.get(gpa, "a.zig", source, false, true); + try std.testing.expectEqual(@as(usize, 2), cache.analyses); + try std.testing.expect(cache.entries[0].declarations_ready and cache.entries[0].colors_ready); + _ = try cache.get(gpa, "a.zig", source, true, true); + try std.testing.expectEqual(@as(usize, 2), cache.analyses); + _ = try cache.get(gpa, "a.zig", "const Thing = struct {\n other: u32,\n};\n", true, true); + try std.testing.expectEqual(@as(usize, 3), cache.analyses); + try std.testing.expectEqual(@as(usize, 1), cache.len); + _ = try cache.get(gpa, "a.txt", source, true, true); + try std.testing.expectEqual(@as(usize, 4), cache.analyses); + try std.testing.expectEqual(@as(usize, 2), cache.len); +} + +test "locations cache bounds memory and entries and evicts least recently used" { + const gpa = std.testing.allocator; + var cache: Cache = .{}; + defer cache.deinit(gpa); + for (0..Cache.max_entries) |index| { + var path: [32]u8 = undefined; + _ = try cache.get(gpa, try std.fmt.bufPrint(&path, "{d}.unknown", .{index}), "source", false, false); + } + _ = try cache.get(gpa, "0.unknown", "source", false, false); + _ = try cache.get(gpa, "new.unknown", "source", false, false); + try std.testing.expectEqual(Cache.max_entries, cache.len); + try std.testing.expectEqualStrings("2.unknown", cache.entries[0].path); + const count = cache.analyses; + _ = try cache.get(gpa, "0.unknown", "source", false, false); + try std.testing.expectEqual(count, cache.analyses); + cache.deinit(gpa); + _ = try cache.getBounded(gpa, "a.unknown", "source", false, false, 40); + _ = try cache.getBounded(gpa, "b.unknown", "source", false, false, 40); + _ = try cache.getBounded(gpa, "c.unknown", "source", false, false, 40); + try std.testing.expectEqual(@as(usize, 2), cache.len); + try std.testing.expect(cache.bytes <= 40); + try std.testing.expectEqualStrings("b.unknown", cache.entries[0].path); + try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.unknown", "x" ** 41, false, false, 40)); + try std.testing.expectEqual(@as(usize, 2), cache.len); + if (syntax.supportsPath("large.zig")) { + const before = cache.analyses; + try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.zig", "x" ** 25, true, true, 40)); + try std.testing.expectEqual(before, cache.analyses); + } + cache.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), cache.bytes); + try std.testing.expectEqual(@as(usize, 0), cache.len); +} + +test "locations cache keeps its previous snapshot after allocation failure" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const gpa = failing.allocator(); + var cache: Cache = .{}; + defer cache.deinit(gpa); + _ = try cache.get(gpa, "a.unknown", "old bytes", false, false); + const retained = cache.bytes; + failing.fail_index = failing.alloc_index; + try std.testing.expectError(error.OutOfMemory, cache.get(gpa, "a.unknown", "new bytes", false, false)); + try std.testing.expectEqual(@as(usize, 1), cache.len); + try std.testing.expectEqual(retained, cache.bytes); + try std.testing.expectEqualStrings("old bytes", cache.entries[0].source); + // This succeeds with allocations still disabled because the old entry + // remains valid and the hit path moves ownership records only. + _ = try cache.get(gpa, "a.unknown", "old bytes", false, false); + try std.testing.expectEqual(@as(usize, 1), cache.hits); +} + +test "locations cache transfers oversized completed analysis without reparsing" { + if (!syntax.supportsPath("large.zig")) return error.SkipZigTest; + const gpa = std.testing.allocator; + syntax.start(gpa); + defer syntax.stop(); + var cache: Cache = .{}; + defer cache.deinit(gpa); + const source = "const Thing = struct {\n value: u32,\n};\n"; + const budget = "large.zig".len + source.len; + var result = try cache.getBounded(gpa, "large.zig", source, true, false, budget); + defer result.deinit(gpa); + try std.testing.expect(result.owned); + try std.testing.expect(result.analysis.declarations.len > 0); + try std.testing.expectEqual(@as(usize, 1), cache.analyses); + try std.testing.expectEqual(@as(usize, 0), cache.len); + try std.testing.expectEqual(@as(usize, 0), cache.bytes); +} diff --git a/src/locations_cache.zig b/src/locations_cache.zig deleted file mode 100644 index a079226b..00000000 --- a/src/locations_cache.zig +++ /dev/null @@ -1,213 +0,0 @@ -const std = @import("std"); -const syntax = @import("syntax.zig"); - -/// A cache hit borrows its analysis; an entry too large to retain transfers -/// ownership instead. Call deinit after consuming either result. -pub const Result = struct { - analysis: syntax.SourceAnalysis, - owned: bool = false, - - pub fn deinit(result: *Result, gpa: std.mem.Allocator) void { - if (result.owned) result.analysis.deinit(gpa); - result.* = undefined; - } -}; - -/// Owned source snapshots keep analysis valid across filesystem changes and -/// borrowed editor buffers. Entries are ordered from least to most recent. -pub const Cache = struct { - pub const max_bytes = 64 * 1024 * 1024; - pub const max_entries = 64; - - const Entry = struct { - path: []u8, - source: []u8, - analysis: syntax.SourceAnalysis, - declarations_ready: bool, - colors_ready: bool, - - fn size(entry: Entry) usize { - return entry.path.len + entry.source.len + entry.analysis.colors.len + - entry.analysis.declarations.len * @sizeOf(syntax.ContextDeclaration); - } - - fn deinit(entry: *Entry, gpa: std.mem.Allocator) void { - gpa.free(entry.path); - gpa.free(entry.source); - entry.analysis.deinit(gpa); - } - }; - - entries: [max_entries]Entry = undefined, - len: usize = 0, - bytes: usize = 0, - analyses: usize = 0, - hits: usize = 0, - - pub fn deinit(cache: *Cache, gpa: std.mem.Allocator) void { - for (cache.entries[0..cache.len]) |*entry| entry.deinit(gpa); - cache.* = .{}; - } - - /// Borrowed slices remain valid until the next get or cache deinit; an - /// owned result remains valid until Result.deinit. All calls for this - /// cache must use the same allocator. Unsupported languages - /// are cached too, including their empty analysis arrays. - pub fn get(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool) !Result { - return cache.getBounded(gpa, path, source, want_declarations, want_colors, max_bytes); - } - - fn getBounded(cache: *Cache, gpa: std.mem.Allocator, path: []const u8, source: []const u8, want_declarations: bool, want_colors: bool, budget: usize) !Result { - if (source.len > budget or path.len > budget - source.len) return error.SourceTooLarge; - var previous: ?usize = null; - var declarations = want_declarations; - var colors = want_colors; - for (cache.entries[0..cache.len], 0..) |entry, index| { - if (!std.mem.eql(u8, entry.path, path)) continue; - previous = index; - if (!std.mem.eql(u8, entry.source, source)) break; - if ((!want_declarations or entry.declarations_ready) and (!want_colors or entry.colors_ready)) { - // Moving the small ownership record keeps the underlying - // allocations intact and avoids timestamp/overflow state. - std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]); - cache.entries[cache.len - 1] = entry; - cache.hits +|= 1; - return .{ .analysis = entry.analysis }; - } - declarations = declarations or entry.declarations_ready; - colors = colors or entry.colors_ready; - break; - } - // A supported full-source color map needs one byte per source byte. - // Reject that known minimum before parsing, so the caller's uncached - // path does not repeat an expensive analysis for oversized inputs. - const input_bytes = path.len + source.len; - if (colors and syntax.supportsPath(path) and source.len > budget - input_bytes) return error.SourceTooLarge; - var analysis = try syntax.analyzeSource(gpa, path, source, declarations, colors); - errdefer analysis.deinit(gpa); - cache.analyses +|= 1; - const analysis_bytes = analysis.colors.len + analysis.declarations.len * @sizeOf(syntax.ContextDeclaration); - if (analysis_bytes > budget - input_bytes) return .{ .analysis = analysis, .owned = true }; - const owned_path = try gpa.dupe(u8, path); - errdefer gpa.free(owned_path); - const owned_source = try gpa.dupe(u8, source); - errdefer gpa.free(owned_source); - const next: Entry = .{ - .path = owned_path, - .source = owned_source, - .analysis = analysis, - .declarations_ready = declarations, - .colors_ready = colors, - }; - // Commit only after every allocation succeeds. A failed refresh leaves - // the previous cached snapshot usable by the next request. - if (previous) |index| cache.remove(gpa, index); - while (cache.len == max_entries or cache.bytes > budget - next.size()) cache.remove(gpa, 0); - cache.entries[cache.len] = next; - cache.len += 1; - cache.bytes += next.size(); - return .{ .analysis = next.analysis }; - } - - fn remove(cache: *Cache, gpa: std.mem.Allocator, index: usize) void { - cache.bytes -= cache.entries[index].size(); - cache.entries[index].deinit(gpa); - std.mem.copyForwards(Entry, cache.entries[index .. cache.len - 1], cache.entries[index + 1 .. cache.len]); - cache.len -= 1; - } -}; - -test "locations cache validates exact source and path and upgrades analysis" { - const gpa = std.testing.allocator; - syntax.start(gpa); - defer syntax.stop(); - var cache: Cache = .{}; - defer cache.deinit(gpa); - const source = "const Thing = struct {\n value: u32,\n};\n"; - _ = try cache.get(gpa, "a.zig", source, true, false); - try std.testing.expectEqual(@as(usize, 1), cache.analyses); - _ = try cache.get(gpa, "a.zig", source, true, false); - try std.testing.expectEqual(@as(usize, 1), cache.analyses); - try std.testing.expectEqual(@as(usize, 1), cache.hits); - _ = try cache.get(gpa, "a.zig", source, false, true); - try std.testing.expectEqual(@as(usize, 2), cache.analyses); - try std.testing.expect(cache.entries[0].declarations_ready and cache.entries[0].colors_ready); - _ = try cache.get(gpa, "a.zig", source, true, true); - try std.testing.expectEqual(@as(usize, 2), cache.analyses); - _ = try cache.get(gpa, "a.zig", "const Thing = struct {\n other: u32,\n};\n", true, true); - try std.testing.expectEqual(@as(usize, 3), cache.analyses); - try std.testing.expectEqual(@as(usize, 1), cache.len); - _ = try cache.get(gpa, "a.txt", source, true, true); - try std.testing.expectEqual(@as(usize, 4), cache.analyses); - try std.testing.expectEqual(@as(usize, 2), cache.len); -} - -test "locations cache bounds memory and entries and evicts least recently used" { - const gpa = std.testing.allocator; - var cache: Cache = .{}; - defer cache.deinit(gpa); - for (0..Cache.max_entries) |index| { - var path: [32]u8 = undefined; - _ = try cache.get(gpa, try std.fmt.bufPrint(&path, "{d}.unknown", .{index}), "source", false, false); - } - _ = try cache.get(gpa, "0.unknown", "source", false, false); - _ = try cache.get(gpa, "new.unknown", "source", false, false); - try std.testing.expectEqual(Cache.max_entries, cache.len); - try std.testing.expectEqualStrings("2.unknown", cache.entries[0].path); - const count = cache.analyses; - _ = try cache.get(gpa, "0.unknown", "source", false, false); - try std.testing.expectEqual(count, cache.analyses); - cache.deinit(gpa); - _ = try cache.getBounded(gpa, "a.unknown", "source", false, false, 40); - _ = try cache.getBounded(gpa, "b.unknown", "source", false, false, 40); - _ = try cache.getBounded(gpa, "c.unknown", "source", false, false, 40); - try std.testing.expectEqual(@as(usize, 2), cache.len); - try std.testing.expect(cache.bytes <= 40); - try std.testing.expectEqualStrings("b.unknown", cache.entries[0].path); - try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.unknown", "x" ** 41, false, false, 40)); - try std.testing.expectEqual(@as(usize, 2), cache.len); - if (syntax.supportsPath("large.zig")) { - const before = cache.analyses; - try std.testing.expectError(error.SourceTooLarge, cache.getBounded(gpa, "large.zig", "x" ** 25, true, true, 40)); - try std.testing.expectEqual(before, cache.analyses); - } - cache.deinit(gpa); - try std.testing.expectEqual(@as(usize, 0), cache.bytes); - try std.testing.expectEqual(@as(usize, 0), cache.len); -} - -test "locations cache keeps its previous snapshot after allocation failure" { - var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); - const gpa = failing.allocator(); - var cache: Cache = .{}; - defer cache.deinit(gpa); - _ = try cache.get(gpa, "a.unknown", "old bytes", false, false); - const retained = cache.bytes; - failing.fail_index = failing.alloc_index; - try std.testing.expectError(error.OutOfMemory, cache.get(gpa, "a.unknown", "new bytes", false, false)); - try std.testing.expectEqual(@as(usize, 1), cache.len); - try std.testing.expectEqual(retained, cache.bytes); - try std.testing.expectEqualStrings("old bytes", cache.entries[0].source); - // This succeeds with allocations still disabled because the old entry - // remains valid and the hit path moves ownership records only. - _ = try cache.get(gpa, "a.unknown", "old bytes", false, false); - try std.testing.expectEqual(@as(usize, 1), cache.hits); -} - -test "locations cache transfers oversized completed analysis without reparsing" { - if (!syntax.supportsPath("large.zig")) return error.SkipZigTest; - const gpa = std.testing.allocator; - syntax.start(gpa); - defer syntax.stop(); - var cache: Cache = .{}; - defer cache.deinit(gpa); - const source = "const Thing = struct {\n value: u32,\n};\n"; - const budget = "large.zig".len + source.len; - var result = try cache.getBounded(gpa, "large.zig", source, true, false, budget); - defer result.deinit(gpa); - try std.testing.expect(result.owned); - try std.testing.expect(result.analysis.declarations.len > 0); - try std.testing.expectEqual(@as(usize, 1), cache.analyses); - try std.testing.expectEqual(@as(usize, 0), cache.len); - try std.testing.expectEqual(@as(usize, 0), cache.bytes); -} diff --git a/src/locations_config.zig b/src/locations_config.zig deleted file mode 100644 index 6b23575a..00000000 --- a/src/locations_config.zig +++ /dev/null @@ -1,75 +0,0 @@ -const std = @import("std"); - -pub const Config = struct { - pub const Layout = enum { @"inline", stacked }; - context: u16 = 0, - tscontext: bool = false, - tslocations: bool = true, - layout: Layout = .stacked, - - /// Apply only supplied fields. A malformed token rejects the whole update. - /// Repeating a field uses its last supplied value. - pub fn parse(current: Config, text: []const u8) !Config { - var result = current; - var tokens = std.mem.tokenizeAny(u8, text, " \t\r\n"); - while (tokens.next()) |token| { - const colon = std.mem.indexOfScalar(u8, token, ':') orelse return error.ExpectedKeyValue; - const key = token[0..colon]; - const value = token[colon + 1 ..]; - var found = false; - inline for (@typeInfo(Config).@"struct".fields) |field| { - if (std.mem.eql(u8, key, field.name)) { - @field(result, field.name) = switch (@typeInfo(field.type)) { - .bool => if (std.mem.eql(u8, value, "on")) true else if (std.mem.eql(u8, value, "off")) false else return error.ExpectedOnOrOff, - .int => std.fmt.parseInt(field.type, value, 10) catch return error.InvalidContext, - .@"enum" => std.meta.stringToEnum(field.type, value) orelse return error.InvalidLayout, - else => @compileError("unsupported location setting type"), - }; - found = true; - } - } - if (!found) return error.UnknownLocationSetting; - } - return result; - } - - pub fn write(config: Config, writer: *std.Io.Writer) !void { - inline for (@typeInfo(Config).@"struct".fields, 0..) |field, index| { - if (index > 0) try writer.writeByte(' '); - try writer.writeAll(field.name ++ ":"); - const value = @field(config, field.name); - switch (@typeInfo(field.type)) { - .bool => try writer.writeAll(if (value) "on" else "off"), - .int => try writer.print("{d}", .{value}), - .@"enum" => try writer.writeAll(@tagName(value)), - else => @compileError("unsupported location setting type"), - } - } - } -}; - -test "LocationsConfig reflection round trip and partial updates" { - var config: Config = .{}; - config = try config.parse("context:5 tscontext:on"); - config = try config.parse("tslocations:off"); - try std.testing.expectEqual(Config{ .context = 5, .tscontext = true, .tslocations = false }, config); - var out: std.Io.Writer.Allocating = .init(std.testing.allocator); - defer out.deinit(); - try config.write(&out.writer); - try std.testing.expectEqualStrings("context:5 tscontext:on tslocations:off layout:stacked", out.written()); - try std.testing.expectEqual(config, try (Config{}).parse(out.written())); - try std.testing.expectEqual(config, try config.parse(" \n\t")); - try std.testing.expectEqual(@as(u16, 2), (try config.parse("context:1 context:2")).context); - try std.testing.expectEqual(Config.Layout.@"inline", (try config.parse("layout:inline")).layout); -} - -test "LocationsConfig rejects invalid updates atomically" { - const config: Config = .{ .context = 3, .tscontext = true }; - try std.testing.expectError(error.UnknownLocationSetting, config.parse("context:5 unknown:on")); - try std.testing.expectError(error.ExpectedOnOrOff, config.parse("context:5 tscontext:true")); - try std.testing.expectError(error.ExpectedKeyValue, config.parse("context")); - try std.testing.expectError(error.InvalidLayout, config.parse("context:5 layout:sideways")); - for ([_][]const u8{ "context:", "context:-1", "context:65536", "context:five" }) |input| - try std.testing.expectError(error.InvalidContext, config.parse(input)); - try std.testing.expectEqual(@as(u16, 3), config.context); -} diff --git a/src/look.zig b/src/look.zig index fe5a3ac1..65d001a9 100644 --- a/src/look.zig +++ b/src/look.zig @@ -21,7 +21,7 @@ const Color = @import("surface.zig").Color; const pdf = panes.Pdf.pdf; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; -const tag_layer = @import("tag_layer.zig"); +const Layer = @import("Layer.zig"); const TagHit = pardes.TagHit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; @@ -946,14 +946,14 @@ fn noteLookHover(p: *Pardes, col: u16, row: u16, body_hit: ?Mouse.BodyHit, tag_h if (comptime pdf_enabled) if (p.pdf_hover_preview) |shown| if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial) return; if (p.look_hover_preview) |shown| - if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and tag_layer.sameCell(shown.tag_hit, tag_hit)) return; + if (shown.col == col and shown.row == row and shown.pane == id and shown.serial == pane.serial and mouse.sameBodyCell(shown.body_hit, body_hit) and Layer.sameCell(shown.tag_hit, tag_hit)) return; if (p.look_hover_wait) |waiting| - if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and tag_layer.sameCell(waiting.tag_hit, tag_hit)) return; + if (waiting.col == col and waiting.row == row and waiting.pane == id and waiting.serial == pane.serial and mouse.sameBodyCell(waiting.body_hit, body_hit) and Layer.sameCell(waiting.tag_hit, tag_hit)) return; // Found nothing to open here a moment ago: the pointer resting on it // does not start another wait (and another frame) every frame. if (p.look_hover_refused) |refused| - if (refused.col == col and refused.row == row and refused.pane == id and refused.serial == pane.serial and mouse.sameBodyCell(refused.body_hit, body_hit) and tag_layer.sameCell(refused.tag_hit, tag_hit)) return; + if (refused.col == col and refused.row == row and refused.pane == id and refused.serial == pane.serial and mouse.sameBodyCell(refused.body_hit, body_hit) and Layer.sameCell(refused.tag_hit, tag_hit)) return; p.look_hover_refused = null; cancelLookHover(p); diff --git a/src/main.zig b/src/main.zig index ec2472d7..6ea05479 100644 --- a/src/main.zig +++ b/src/main.zig @@ -408,10 +408,30 @@ fn nativeMain(init: std.process.Init) !void { if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding; const target = @import("look.zig").parsePathLine(word); var realbuf: [4096]u8 = undefined; - const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path; - var command_buf: [8192]u8 = undefined; - const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; - ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding; + var newbuf: [4096]u8 = undefined; + const found_path: ?[]const u8 = if (pardes.filesystem.isVirtual(target.path)) target.path else if (pardes.filesystem.resolveOs(target.path, &realbuf)) |r| r.path else null; + const path = found_path orelse named: { + // A name not there yet, as acme's B takes one: its directory + // resolved and the name kept, a pane made for it that Save + // creates the file from. + const base = std.fs.path.basename(target.path); + if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding; + const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse break :forwarding; + break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding; + }; + if (found_path != null) { + var command_buf: [8192]u8 = undefined; + const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding; + ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding; + } else { + const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch break :forwarding; + const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch break :forwarding; + var name_buf: [64]u8 = undefined; + const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial}); + var line_buf: [4200]u8 = undefined; + const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch break :forwarding; + ninep_io.Client.write(arena, parent.dial, name, line) catch break :forwarding; + } if (wait) waitForDel(init.io, arena, parent.dial, path); return; } @@ -468,7 +488,15 @@ fn nativeMain(init: std.process.Init) !void { std.process.exit(1); } switch (pardes.platform) { - .tty => try @import("tty/tty.zig").run(init, opts, attach), + .tty => @import("tty/tty.zig").run(init, opts, attach) catch |err| switch (err) { + // No controlling terminal (a detached pty, a daemon): said, not + // an error trace. + error.NoDevice => { + std.Io.File.stderr().writeStreamingAll(init.io, "pardes: no terminal to draw on: /dev/tty will not open; run it in a terminal, or --detach\n") catch {}; + std.process.exit(1); + }, + else => return err, + }, .gui => try @import("gui/gui.zig").run(init, opts, attach), .web, .macos, .esp32p4 => unreachable, } @@ -493,10 +521,10 @@ test { _ = pardes.config.User; _ = @import("crash.zig"); _ = @import("memory.zig"); - _ = @import("locations_cache.zig"); + _ = @import("locations.zig"); _ = @import("fs.zig"); _ = @import("detached/wire.zig"); - _ = @import("shader_build.zig"); + _ = @import("ShaderBuild.zig"); _ = @import("detached/server.zig"); _ = @import("detached/client.zig"); _ = @import("9p_io.zig"); diff --git a/src/Mini.zig b/src/mini.zig index a6aef15a..a6aef15a 100644 --- a/src/Mini.zig +++ b/src/mini.zig diff --git a/src/mouse.zig b/src/mouse.zig index 5bfd92ee..1f2648b0 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -22,7 +22,7 @@ const pdf = panes.Pdf.pdf; const platform = pardes.platform; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; -const tag_layer = @import("tag_layer.zig"); +const Layer = @import("Layer.zig"); const TOPBAR_H = pardes.TOPBAR_H; const BOX_H = pardes.BOX_H; const TAG_GAP = pardes.TAG_GAP; @@ -332,7 +332,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { const row: u16 = @intCast(@min(mrow -| base, (if (p.header_column != null) p.columnBarHeight() else p.topBarHeight()) -| 1)); const line: u16 = @intCast(@min(row + p.header_top, modal.lineCount(text) -| 1)); const bar = modal.lineSlice(text, line); - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + p.header_scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (Layer.columnAt(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + p.header_scroll)); // the anchor stays where the press put it t.cur_row = line; t.cur_col = @intCast(at); @@ -368,7 +368,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { if (line > 0 and line >= modal.lineCount(text)) return; const bar = modal.lineSlice(text, line); const scroll = if (focused) p.header_scroll else 0; - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tag_layer.columnAt(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + scroll)); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (Layer.columnAt(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag, row) orelse (mcol -| x)) + scroll)); if (m.button == config.select_button) { // A clicked header is typed straight into, as a clicked tag is. if (!focused) tagline.enterHeader(p, column); @@ -668,7 +668,7 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo // nearest the pointer. const tag_y = p.tagTop(pane, r); const tag_line: u16 = @intCast(std.math.clamp(@as(i32, mrow) - @as(i32, tag_y), 0, tag_rows - 1)); - if (pane.sel[b].r0 < tag_rows) if (tag_layer.columnAt(p, .pane, s.id, p.pointer_tag_hit, true, tag_line)) |value| { + if (pane.sel[b].r0 < tag_rows) if (Layer.columnAt(p, .pane, s.id, p.pointer_tag_hit, true, tag_line)) |value| { pane.sel[b].c1 = @as(i32, value) + pane.tag_scroll; pane.sel[b].r1 = tag_line; return; diff --git a/src/ninep/events.zig b/src/ninep/events.zig index c0bf4a9a..fe7af77e 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -1289,7 +1289,7 @@ test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out)); } -test "a long err record is cut between words, with an ellipsis" { +test "a long err record keeps its end, its middle given up to an ellipsis" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); _ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n"); @@ -1298,7 +1298,9 @@ test "a long err record is cut between words, with an ellipsis" { const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes; const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?; const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1]; - try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n")); + try testing.expect(std.mem.indexOf(u8, record, "…") != null); + try testing.expect(std.mem.startsWith(u8, record, "err - ctl: ")); + try testing.expect(std.mem.endsWith(u8, record, "abcdefgh\"\n") or std.mem.endsWith(u8, record, "abcdefgh \"\n")); _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); } diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index bb4ebf86..3c1e181e 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -571,7 +571,7 @@ fn writeTag(req: Req, pane: *Pane) Reply { const had = tagline.curTail(pane).len; const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline); // Whole or not at all: a write that would pass the limit changes nothing. - if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: over {d} bytes", .{limits.max_tag_tail})); + if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail})); const take = wholeUtf8(req.data); // A truncating write drops ONE trailing newline, its whole text's: a // newline held from a write before goes in once more text follows it. @@ -1214,7 +1214,9 @@ test "a tag write past the limit is refused whole, naming the limit" { const big = "w" ** (limits.max_tag_tail + 10); const r = wr(p, tag, big); try testing.expectEqual(E.NOSPC, r.errno()); - try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: over ")); + // `no space` is what 9ns maps to ENOSPC, so a shell through a mount + // sees the errno the tree gives. + try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over ")); try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes); } diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 30bf073d..5f3ffc9c 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -456,9 +456,59 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply { pub fn handle(p: *Pardes, req: Req) Reply { var reply = serve(p, req); if (reply.status == .err and reply.ename.len == 0) reply.ename = errWords(reply.errno); + if (reply.status == .err and reply.ename.len > cloud9.fs.errmax) + if (p.scratch.allocator().alloc(u8, cloud9.fs.errmax)) |room| { + reply.ename = fitErr(reply.ename, room); + } else |_| {}; return reply; } +/// A reason longer than an Rerror carries (Plan 9's ERRMAX, 128 bytes) +/// keeps its end, which says why: the longest path in it -- else the whole +/// text -- gives up its middle to `…`. +pub fn fitErr(text: []const u8, out: []u8) []const u8 { + const ell = "…"; + if (text.len <= out.len) return text; + const over = text.len - out.len + ell.len; + var from: usize = 0; + var to: usize = text.len; + var words = std.mem.tokenizeAny(u8, text, " \t"); + var longest: usize = 0; + while (words.next()) |w| if (std.mem.indexOfScalar(u8, w, '/') != null and w.len > longest and w.len > over + 2) { + longest = w.len; + from = @intFromPtr(w.ptr) - @intFromPtr(text.ptr); + to = from + w.len; + }; + var a = from + (to - from - over) / 2; + var b = a + over; + while (a > from and text[a] & 0xC0 == 0x80) a -= 1; + while (b < to and text[b] & 0xC0 == 0x80) b += 1; + return std.fmt.bufPrint(out, "{s}" ++ ell ++ "{s}", .{ text[0..a], text[b..] }) catch text[0..out.len]; +} + +test "a reason past 128 bytes keeps its end: the path in it gives up its middle" { + var out: [128]u8 = undefined; + const long = "Save /home/someone/projects/" ++ "deep/" ** 30 ++ "file.txt: no such directory"; + const fit = fitErr(long, &out); + try testing.expect(fit.len <= 128); + try testing.expect(std.mem.startsWith(u8, fit, "Save /home/someone/")); + try testing.expect(std.mem.endsWith(u8, fit, "file.txt: no such directory")); + try testing.expect(std.mem.indexOf(u8, fit, "…") != null); + try testing.expect(std.unicode.utf8ValidateSlice(fit)); + // No path: the whole text's middle goes, the end stays. + const words = "why " ** 40 ++ "the reason"; + try testing.expect(std.mem.endsWith(u8, fitErr(words, &out), "the reason")); + try testing.expectEqualStrings("short", fitErr("short", &out)); + // Through the tree: a look at a long ./ name not there says why at the end. + const p = try th.withFile(testing.allocator, "x\n"); + defer p.deinit(); + var line: [512]u8 = undefined; + const r = th.wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "./{s}x.txt\n", .{"no-such-dir/" ** 20})); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(r.reply.ename.len <= 128); + try testing.expect(std.mem.endsWith(u8, r.reply.ename, "no such file") or std.mem.endsWith(u8, r.reply.ename, "no such directory")); +} + /// A refusal with no reason of its own said in Plan 9's words, not the C /// library's (`Operation not permitted`); each maps back to its errno in /// 9ns (enameToErrno), EPERM's to EACCES as Plan 9's does. @@ -1578,6 +1628,55 @@ test "editor paths resolve to the same nodes the wire serves" { try testing.expectEqual(before, p.next_serial); } +test "random placements, refused or not, leave no pane under its tag and two rows" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var look_line: [4200]u8 = undefined; + const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir}); + var seed: u64 = 0; + while (seed < 300) : (seed += 1) { + var prng = std.Random.DefaultPrng.init(seed); + const r = prng.random(); + const rows = r.intRangeAtMost(u16, 6, 40); + const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile("x\n"); + while (p.nextEffect()) |_| {} + for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n"); + if (r.boolean()) p.settings.placement = .pardes; + for (0..40) |step| { + var serials: [pardes.MAX_PANES]u32 = undefined; + var n: usize = 0; + for (p.panes) |slot| if (slot) |pn| { + serials[n] = pn.serial; + n += 1; + }; + if (n == 0) break; + const at = serials[r.uintLessThan(usize, n)]; + const action = r.uintLessThan(u8, 8); + switch (action) { + 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"), + 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }), + 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"), + 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"), + 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"), + 5 => _ = th.wr(p, Node.of(at, .look), looked), + 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"), + else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"), + } + for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { + std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c }); + for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) }); + return error.TestUnexpectedResult; + }; + } + } +} + test "at the pane cap, pane/new, look and New each say so, and look reads back empty" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); 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/src/pardes.zig b/src/pardes.zig index 19fe51d1..141b214e 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -9,9 +9,9 @@ pub const look = @import("look.zig"); pub const filesystem = @import("fs.zig"); pub const ctlfs = @import("ninep/tree.zig"); 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"); +const locations = @import("locations.zig"); pub const tagline = @import("tagline.zig"); pub const exec = @import("exec.zig"); const recent_files = @import("recent.zig"); @@ -492,7 +492,6 @@ test { _ = @import("surface.zig"); _ = @import("body_layer.zig"); _ = @import("draw.zig"); - _ = @import("tag_layer.zig"); _ = @import("dump.zig"); // its tests ran nowhere before _ = @import("recent.zig"); _ = @import("Presentation.zig"); @@ -767,7 +766,7 @@ test "LocationsConfig command reports partial updates and survives restore" { _ = try p.setTestFile("source\n"); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:5 tscontext:on")); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig tslocations:off")); - const expected: locations_config.Config = .{ .context = 5, .tscontext = true, .tslocations = false }; + const expected: locations.Config = .{ .context = 5, .tscontext = true, .tslocations = false }; try std.testing.expectEqual(expected, p.locations_config); try std.testing.expect(p.executeBuiltinLine(0, "LocationsConfig context:2 unknown:on")); try std.testing.expectEqual(expected, p.locations_config); @@ -4393,8 +4392,8 @@ pub const Pardes = struct { /// (exec.placeNew). A Look click moves the keyboard but not this. active_column: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, - locations_config: locations_config.Config = .{}, - locations_cache: @import("locations_cache.zig").Cache = .{}, + locations_config: locations.Config = .{}, + locations_cache: locations.Cache = .{}, tty_filter_palette: panes.Terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, @@ -5082,6 +5081,15 @@ pub const Pardes = struct { else "Newcol: no space for a column: this one is too narrow to split"); }; + // A narrower column wraps long tags onto more rows, which can leave + // a pane under its tag and two rows: its column's rows are shared + // out again, and where they cannot hold every minimum there is no + // new column, as a size too small is refused. + for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) { + layout.dropColumn(p, c); + layout.compute(p); + return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit"); + }; tagline.enterHeader(p, c); } diff --git a/src/tag_layer.zig b/src/tag_layer.zig deleted file mode 100644 index 213ebc07..00000000 --- a/src/tag_layer.zig +++ /dev/null @@ -1,32 +0,0 @@ -//! Pointer hits on tag bands (Layer.TagHit): comparing two, and bringing one -//! that left its band back onto it. -const std = @import("std"); -const pardes = @import("pardes.zig"); -const Layer = @import("Layer.zig"); -const Hit = Layer.TagHit; -const Kind = Layer.Kind; - -/// Whether two pointer hits name the same cell of the same tag line. -pub fn sameCell(a: ?Hit, b: ?Hit) bool { - const first = a orelse return b == null; - const second = b orelse return false; - return first.kind == second.kind and first.id == second.id and first.serial == second.serial and first.line == second.line and first.col == second.col; -} - -/// The tag column a host's pointer is at. Clamped, a point off the tag is -/// brought back onto line `line` of it, for a drag that leaves it. -pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?Hit, clamp: bool, line: u16) ?u16 { - var point = supplied orelse return null; - if (point.kind != kind or point.id != id) return null; - if (clamp) for (p.surface.tagLayers()) |*layer| { - if (layer.rows == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or line >= layer.rows) continue; - const bw: f32 = @floatFromInt(point.metrics.body_w); - const bh: f32 = @floatFromInt(point.metrics.body_h); - const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; - const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; - point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); - point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y + line)) + 0.5) * bh; - break; - }; - return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; -} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index efccd98e..f5ae078a 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -132,7 +132,14 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { if (carried == buf.len) return error.InputSequenceTooLong; const received = try tty.read(buf[carried..]); if (received == 0) return; - const end = carried + received; + // The terminal's answers to frames (FrameAck) come out first, + // wherever they sit: never events, and never glued to a key (an + // Escape pressed just before one would read as Alt on it). + const end = FrameAck.take(buf[0 .. carried + received], loop); + if (end == 0) { + carried = 0; + continue; + } if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| { kitty_shm_probing.store(false, .release); kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release); @@ -153,7 +160,8 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { // its writes whole, so no sequence arrives cut right after its ESC. // Waiting on it cost every Escape press 50 ms on a legacy terminal // (tmux, most ssh sessions), and 1 s before kitty's flags were on. - var waited = carried == 0 and received == 1; + // (A terminal's answer to a frame is one write too: FrameAck.) + var waited = carried == 0 and end == 1; while (consumed < parse_end) { // A lone ESC that ends a longer read is the Escape key, or the // first byte of a sequence whose rest the read boundary held @@ -162,6 +170,9 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { // kitty keyboard protocol Escape is `CSI 27 u`, so there the ESC // can only start a sequence and the wait can be long. const rest = buf[consumed..parse_end]; + // A frame's answer cut short by the read waits for its rest. + if (parse_end == end and rest.len > 1 and rest.len < FrameAck.answer.len and + std.mem.startsWith(u8, FrameAck.answer, rest)) break; if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) { waited = true; if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break; @@ -191,6 +202,63 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { } } +/// One frame in flight at most. Each frame the tty draws ends with a device +/// status request (`CSI 5 n`), and no other frame is drawn until the +/// terminal's `CSI 0 n` comes back: over ssh the frames a burst of scrolling +/// or typing would queue behind each other (sshd's window holds megabytes) +/// are never drawn, and the burst ends one frame and one round trip after +/// its last input. On a local terminal the answer is back in well under a +/// millisecond. A terminal that never answers turns this off for good after +/// `timeout_ns`. +const FrameAck = struct { + const request = "\x1b[5n"; + const answer = "\x1b[0n"; + const timeout_ns: u64 = std.time.ns_per_s; + var waiting = std.atomic.Value(bool).init(false); + var off = std.atomic.Value(bool).init(false); + /// When the request went out (the loop's thread only). + var sent_ns: u64 = 0; + + /// Take every answer out of `bytes`, closing up the rest; the new + /// length. The loop is woken for the frame that waited on one. + fn take(bytes: []u8, loop: *Loop) usize { + var len = bytes.len; + var found = false; + while (std.mem.indexOf(u8, bytes[0..len], answer)) |at| { + std.mem.copyForwards(u8, bytes[at..], bytes[at + answer.len .. len]); + len -= answer.len; + found = true; + } + if (found and waiting.swap(false, .acq_rel)) loop.postEvent(.nop) catch {}; + return len; + } + + /// Whether a new frame must wait (and the loop has a wake at the + /// timeout); past the timeout the terminal is taken to never answer. + fn holds(now_ns: u64, io: std.Io) bool { + if (off.load(.acquire) or !waiting.load(.acquire)) return false; + const waited_ns = now_ns -| sent_ns; + if (waited_ns >= timeout_ns) { + off.store(true, .release); + waiting.store(false, .release); + return false; + } + requestWake(io, @intCast((timeout_ns - waited_ns + std.time.ns_per_ms - 1) / std.time.ns_per_ms)); + return true; + } + + /// After a frame that wrote something: ask for the answer. + fn ask(w: *std.Io.Writer, now_ns: u64) void { + if (off.load(.acquire)) return; + // Waiting before the request goes: a local terminal can answer + // before the write returns, and an answer nobody waited for is lost. + sent_ns = now_ns; + waiting.store(true, .release); + w.writeAll(request) catch return waiting.store(false, .release); + w.flush() catch return waiting.store(false, .release); + } +}; + /// Whether more input is readable within `ms`, polled in short slices so /// that a cancel of the input thread is still seen. A test reader answers /// for itself. @@ -521,6 +589,98 @@ test "an ESC read by itself is the Escape key at once" { vx.caps.kitty_keyboard = false; } +test "a frame's answer is taken wherever the reads cut it, and never typed" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{}); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: [2][]const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } + fn read(self: *@This(), buf: []u8) !usize { + while (self.next < self.parts.len) { + const part = self.parts[self.next]; + self.next += 1; + if (part.len == 0) continue; + @memcpy(buf[0..part.len], part); + return part.len; + } + return 0; + } + }; + const all = "x" ++ FrameAck.answer ++ "y"; + defer FrameAck.waiting.store(false, .release); + for (0..all.len) |cut| { + FrameAck.waiting.store(true, .release); + var reader: Reader = .{ .parts = .{ all[0..cut], all[cut..] } }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(!FrameAck.waiting.load(.acquire)); + var typed: std.ArrayList(u8) = .empty; + defer typed.deinit(gpa); + var woke = false; + while (try loop.tryEvent()) |event| switch (event) { + .winsize => {}, + .nop => woke = true, + .key_press => |key| try typed.appendSlice(gpa, key.text orelse return error.UnexpectedInputKey), + else => return error.UnexpectedInputEvent, + }; + std.testing.expectEqualStrings("xy", typed.items) catch |err| { + std.debug.print("cut at {d}\n", .{cut}); + return err; + }; + try std.testing.expect(woke); + } + // An Escape pressed just before an answer, in the same read, is the + // Escape key, not Alt on the answer. + FrameAck.waiting.store(true, .release); + var glued: Reader = .{ .parts = .{ "\x1b" ++ FrameAck.answer, "j" } }; + try readInput(&loop, &glued, &cache); + try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?)); + try std.testing.expectEqual(.nop, std.meta.activeTag((try loop.tryEvent()).?)); + const escape = (try loop.tryEvent()).?.key_press; + try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint); + try std.testing.expect(!escape.mods.alt); + try std.testing.expectEqual(@as(u21, 'j'), (try loop.tryEvent()).?.key_press.codepoint); + try std.testing.expect(try loop.tryEvent() == null); +} + +test "a terminal that never answers a frame stops being asked after one timeout" { + const io = std.testing.io; + defer { + FrameAck.off.store(false, .release); + FrameAck.waiting.store(false, .release); + FrameAck.sent_ns = 0; + } + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + const t0: u64 = 5 * std.time.ns_per_s; + FrameAck.ask(&out.writer, t0); + try std.testing.expectEqualStrings(FrameAck.request, out.written()); + // Unanswered: the next frame waits, until the timeout. + try std.testing.expect(FrameAck.holds(t0 + 10 * std.time.ns_per_ms, io)); + try std.testing.expect(FrameAck.holds(t0 + 999 * std.time.ns_per_ms, io)); + try std.testing.expect(!FrameAck.holds(t0 + FrameAck.timeout_ns, io)); + // Then never again: no request, no wait. + out.clearRetainingCapacity(); + FrameAck.ask(&out.writer, t0 + 2 * std.time.ns_per_s); + try std.testing.expectEqual(@as(usize, 0), out.written().len); + try std.testing.expect(!FrameAck.holds(t0 + 2 * std.time.ns_per_s, io)); +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -1438,6 +1598,12 @@ const Shell = struct { const s = of(ctx); const vx = s.vx; s.tracks = canonical.panelTracks(); + // The last frame is not answered yet: this one stays owed, and the + // answer's wake draws the state as it is then. + if (FrameAck.holds(host_io.monotonicNs(), s.io)) { + s.core.present_skipped = true; + return; + } _ = s.frame.reset(.retain_capacity); const surface = panel_compositor.compose( s.frame.allocator(), @@ -1453,7 +1619,7 @@ const Shell = struct { const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface; const flashed = s.flash.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed; const trailed = s.trail.apply(s.frame.allocator(), flashed, s.core, truecolor) catch flashed; - paintCells(vx, win, trailed.cells, trailed.cols, trailed.rows); + paintCells(win, trailed.cells, trailed.cols, trailed.rows); tz_cells.end(); for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; @@ -1523,7 +1689,9 @@ const Shell = struct { } if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); const tz_render = tracy.zone(@src(), "vx.render"); + const before = s.tty.tty_writer.pos + s.tty.writer().end; vx.render(s.tty.writer()) catch {}; + if (s.tty.tty_writer.pos + s.tty.writer().end != before) FrameAck.ask(s.tty.writer(), host_io.monotonicNs()); tz_render.end(); } @@ -2170,94 +2338,26 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { } }; } -fn paintCells(vx: *const vaxis.Vaxis, win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { +/// A blank whose ink alone changed is not sent: vaxis's render skips it +/// (pardes's patch to Vaxis.zig in build.zig), since a blank's ink shows +/// nowhere. Deciding that here cost every cell of every frame a look at the +/// frame before; vaxis already makes that comparison. +fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { win.clear(); var y: u16 = 0; while (y < rows) : (y += 1) { - const row = cells[@as(usize, y) * cols ..][0..cols]; var x: u16 = 0; - // The cell to the left as written, for a blank that needs it. - var left: ?Written = null; while (x < cols) : (x += 1) { - const cell = &row[x]; - if (cell.default) { - left = null; - continue; - } - const grapheme = cell.grapheme(); - var style = vaxisStyle(cell.style); - if (inkless(grapheme, style)) blankInk(vx, row, x, y, &style, left); - left = .{ .grapheme = grapheme, .style = style }; - win.writeCell(x, y, .{ .char = .{ .grapheme = grapheme }, .style = style }); + const cell = &cells[@as(usize, y) * cols + x]; + if (cell.default) continue; + win.writeCell(x, y, .{ + .char = .{ .grapheme = cell.grapheme() }, + .style = vaxisStyle(cell.style), + }); } } } -const Written = struct { grapheme: []const u8, style: vaxis.Style }; - -/// A blank's ink shows nowhere (no underline, strike or reverse to show it). -fn inkless(grapheme: []const u8, style: vaxis.Style) bool { - return std.mem.eql(u8, grapheme, " ") and !style.reverse and !style.strikethrough and style.ul_style == .off; -} - -/// A blank whose ink alone changed (a fade: InactiveDim on a focus change) -/// keeps the ink the terminal has there and so is not sent at all: -/// otherwise every blank of every pane a fade touched went, 7 KB a click -/// for three shells. Only a short gap between two cells that are sent -/// anyway is cheaper sent than jumped (a cursor move): it takes the ink of -/// the cell before it, so the run needs no colour change either. -fn blankInk(vx: *const vaxis.Vaxis, row: []const pardes.Cell, x: u16, y: u16, style: *vaxis.Style, left: ?Written) void { - const last = lastCell(vx, x, y) orelse return; - if (vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, " ")) return; - if (sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style.*)) return; - var kept = style.*; - kept.fg = last.style.fg; - if (left) |l| if (sends(vx, x - 1, y, l.grapheme, l.style)) { - var end = x + 1; - while (end < row.len and end - x < 8) : (end += 1) { - const next = &row[end]; - if (next.default) break; - const next_style = vaxisStyle(next.style); - if (inkless(next.grapheme(), next_style)) continue; - if (sends(vx, end, y, next.grapheme(), next_style)) { - style.fg = l.style.fg; - return; - } - break; - } - }; - style.* = kept; -} - -/// `Style.eql` but for the ink, and cheap: this runs for every blank of -/// every frame. -fn sameButInk(a: vaxis.Style, b: vaxis.Style) bool { - return sameColor(a.bg, b.bg) and sameColor(a.ul, b.ul) and a.ul_style == b.ul_style and - a.bold == b.bold and a.dim == b.dim and a.italic == b.italic and a.blink == b.blink and - a.reverse == b.reverse and a.invisible == b.invisible and a.strikethrough == b.strikethrough; -} - -fn sameColor(a: vaxis.Color, b: vaxis.Color) bool { - return switch (a) { - .default => b == .default, - .index => |i| b == .index and b.index == i, - .rgb => |rgb| b == .rgb and std.mem.eql(u8, &rgb, &b.rgb), - }; -} - -/// Whether vaxis sends this cell: it differs from what the terminal shows. -fn sends(vx: *const vaxis.Vaxis, x: u16, y: u16, grapheme: []const u8, style: vaxis.Style) bool { - const last = lastCell(vx, x, y) orelse return true; - return vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, grapheme) or - !sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style); -} - -fn lastCell(vx: *const vaxis.Vaxis, x: u16, y: u16) ?*const vaxis.AllocatingScreen.InternalCell { - if (x >= vx.screen.width) return null; - const i = @as(usize, y) * vx.screen.width + x; - return if (i < vx.screen_last.buf.len) &vx.screen_last.buf[i] else null; -} - test "a fade sends the text it dims, not the blanks beside it" { const gpa = std.testing.allocator; var env = try std.testing.environ.createMap(gpa); @@ -2267,8 +2367,8 @@ test "a fade sends the text it dims, not the blanks beside it" { defer out.deinit(); defer vx.deinit(gpa, &out.writer); try vx.resize(gpa, &out.writer, .{ .rows = 1, .cols = 32, .x_pixel = 0, .y_pixel = 0 }); - // `a`, a long gap, `b`, a two-cell gap, `c`, a long gap, an underlined - // blank (which shows its ink) and one more blank. + // `a`, a gap, `b`, a two-cell gap, `c`, a gap, an underlined blank + // (which shows its ink) and one more blank. var cells: [32]pardes.Cell = @splat(.{ .default = false }); cells[0].text[0] = 'a'; cells[12].text[0] = 'b'; @@ -2279,16 +2379,16 @@ test "a fade sends the text it dims, not the blanks beside it" { for ([_][3]u8{ .{ 200, 200, 200 }, .{ 90, 90, 90 } }, 0..) |ink, frame| { for (&cells) |*cell| cell.style.fg = .{ .rgb = ink }; out.clearRetainingCapacity(); - paintCells(&vx, vx.window(), &cells, 32, 1); + paintCells(vx.window(), &cells, 32, 1); try vx.render(&out.writer); sent[frame] = try out.toOwnedSlice(); } // Against a cleared screen every cell goes. After the fade the letters - // go, the short gap between two of them (cheaper than a cursor move), - // and the underlined blank; the long gaps are jumped. + // go and the underlined blank; every plain blank is jumped. try std.testing.expectEqual(@as(usize, 29), std.mem.count(u8, sent[0], " ")); - try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, sent[1], " ")); - try std.testing.expect(std.mem.indexOf(u8, sent[1], "b c") != null); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, sent[1], " ")); + try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'b') != null); + try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'c') != null); try std.testing.expect(std.mem.indexOf(u8, sent[1], "90:90:90") != null); try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'a') != null); } @@ -2688,7 +2788,7 @@ const Attach = struct { fn paint(a: *Attach) void { a.dirty = false; const win = a.vx.window(); - paintCells(a.vx, win, a.client.grid.items, a.client.cols, a.client.rows); + paintCells(win, a.client.grid.items, a.client.cols, a.client.rows); if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); a.vx.render(a.tty.writer()) catch {}; } |
