diff options
Diffstat (limited to 'src')
41 files changed, 1075 insertions, 645 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..8babb27d 100644 --- a/src/File.zig +++ b/src/File.zig @@ -16,8 +16,8 @@ const limits = @import("memory.zig").limits; const locations = @import("locations.zig"); const Pane = panes.Pane; const Output = panes.Output; -const Mini = panes.Mini; -const Terminal = panes.Terminal; +const Mini = panes.mini; +const terminal = panes.terminal; const Text = panes.Text; const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2; @@ -782,7 +782,7 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { std.debug.assert(p.panes[id] == null and !p.reserved_slots[id]); const path_copy = try p.gpa.dupe(u8, path); errdefer p.gpa.free(path_copy); - const pane = try Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + const pane = try terminal.createDoc(p.gpa, p.screen_w, p.screen_h); errdefer p.gpa.destroy(pane); const history = try History.create(p.gpa); errdefer p.gpa.destroy(history); @@ -1239,7 +1239,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { const prefix_width = gutterWidth(pane); const goff = pane.scroll(); - const gcur = Terminal.gridCursor(pane); + const gcur = terminal.gridCursor(pane); const gcrow = if (pane.body.cur_pinned) pane.body.cur_row else @as(i32, gcur.y) + goff; const typing_body = pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice); const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32); 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/Text.zig b/src/Text.zig index ab23bf8e..cef76b3b 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -14,7 +14,7 @@ const config = @import("config.zig"); const memory = @import("memory.zig"); const Pane = panes.Pane; const File = panes.File; -const Terminal = panes.Terminal; +const terminal = panes.terminal; const Text = @This(); pub const Mode = enum { normal, insert, tty }; @@ -84,9 +84,9 @@ normal: modal.Normal.State = .{}, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, find_ch: u21 = 0, -ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined, +ed_undo: [terminal.history_max]terminal.Snapshot = undefined, ed_undo_len: usize = 0, -ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined, +ed_redo: [terminal.history_max]terminal.Snapshot = undefined, ed_redo_len: usize = 0, /// Set when an edit would have changed characters this text does not own /// (a tag's computed prefix) and was refused; the key that tried it puts @@ -706,10 +706,10 @@ pub fn clampCursor(t: *Text, text: []const u8, row0: i32) void { t.show(); } -fn pushHistory(gpa: std.mem.Allocator, slots: []Terminal.Snapshot, len: *usize, value: Terminal.Snapshot) void { +fn pushHistory(gpa: std.mem.Allocator, slots: []terminal.Snapshot, len: *usize, value: terminal.Snapshot) void { if (len.* == slots.len) { if (slots[0].ovl) |overlay| gpa.free(overlay.text); - std.mem.copyForwards(Terminal.Snapshot, slots[0 .. slots.len - 1], slots[1..]); + std.mem.copyForwards(terminal.Snapshot, slots[0 .. slots.len - 1], slots[1..]); len.* -= 1; } slots[len.*] = value; @@ -719,14 +719,14 @@ fn pushHistory(gpa: std.mem.Allocator, slots: []Terminal.Snapshot, len: *usize, /// Record an edit buffer (a terminal's overlay, a tag's own text) as it /// stands before an edit, once per change of it, and forget what could have /// been redone. Null is a buffer not made yet. -pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer) void { +pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?terminal.EditBuffer) void { if (t.ed_undo_len > 0) { const top = t.ed_undo[t.ed_undo_len - 1]; const same = if (top.ovl) |overlay| if (current) |now| overlay.row == now.row and overlay.rows == now.rows and std.mem.eql(u8, overlay.text, now.text) else false else current == null; if (same) return; } - const copy: ?Terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return } else null; + const copy: ?terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return } else null; pushHistory(gpa, &t.ed_undo, &t.ed_undo_len, .{ .ovl = copy, .cur_row = t.cur_row, .cur_col = t.cur_col, .vsel = t.vsel }); for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); t.ed_redo_len = 0; @@ -736,13 +736,13 @@ pub fn remember(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer) /// other history and the state to return to comes back, its cursor and /// selection already restored here. The caller installs its buffer, which /// it then owns. Null when there is nothing to step to. -pub fn step(t: *Text, gpa: std.mem.Allocator, current: ?Terminal.EditBuffer, back: bool) ?Terminal.Snapshot { +pub fn step(t: *Text, gpa: std.mem.Allocator, current: ?terminal.EditBuffer, back: bool) ?terminal.Snapshot { const from, const from_len, const to, const to_len = if (back) .{ &t.ed_undo, &t.ed_undo_len, &t.ed_redo, &t.ed_redo_len } else .{ &t.ed_redo, &t.ed_redo_len, &t.ed_undo, &t.ed_undo_len }; if (from_len.* == 0) return null; - const copy: ?Terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return null } else null; + const copy: ?terminal.EditBuffer = if (current) |now| .{ .row = now.row, .rows = now.rows, .text = gpa.dupe(u8, now.text) catch return null } else null; pushHistory(gpa, to, to_len, .{ .ovl = copy, .cur_row = t.cur_row, .cur_col = t.cur_col, .vsel = t.vsel }); from_len.* -= 1; const back_to = from[from_len.*]; diff --git a/src/body_layer.zig b/src/body_layer.zig index 6e1fe8f1..cb26c1f2 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -51,7 +51,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [ var visible: i32 = 0; while (lines.next()) |line| : (visible += 1) { if (visible >= r.h -| pane.tag_rows) break; - const source_row = if (raw) panes.Terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; + const source_row = if (raw) panes.terminal.gridOffset(pane) + visible else pane.wrapAt(visible).line; for (rows) |row| { const target = if (row.raw_terminal) (if (raw) row.row else pane.surfRow(row.row)) @@ -64,7 +64,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [ lo -|= row.prompt_bytes; hi -|= row.prompt_bytes; } else if (!row.raw_terminal and raw) { - const prefix = panes.Terminal.promptPrefixBytes(pane, source_row, line); + const prefix = panes.terminal.promptPrefixBytes(pane, source_row, line); lo += prefix; hi += prefix; } @@ -85,7 +85,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [ fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { if (pane.isTerminal()) return paintTerminalSelection(p, s, pane, r, rows, bg, fg); - const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; + const terminal_lines = if (pane.file == null) panes.terminal.cursorLines(p, pane) catch return else &.{}; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; const body_y = p.bodyTop(pane, r); @@ -291,7 +291,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane panes.File.recolorSyntax(p, s, pane, f, r, tx, tw, body_h); panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg); } else if (pane.isTerminal() and p.settings.colors) { - panes.Terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body); + panes.terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body); } tz_color.end(); @@ -440,8 +440,8 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane // (the tag's or a prompt's cursor wins while that is being typed into) if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) { if (pane.body.mode != .tty) { - const cur = panes.Terminal.gridCursor(pane); - const goff = panes.Terminal.gridOffset(pane); + const cur = panes.terminal.gridCursor(pane); + const goff = panes.terminal.gridOffset(pane); const crow = if (pane.body.cur_pinned) pane.body.cur_row else pane.surfRow(@as(i32, @intCast(cur.y)) + goff); const ccol = if (pane.body.cur_pinned) pane.body.cur_col else @as(i32, @intCast(cur.x)); const cwp = pane.wrapRow(crow, ccol); @@ -460,7 +460,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane ccol; if (prow >= pane.tag_rows and cx >= 0 and prow < body_bottom and cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = body_y + @as(u16, @intCast(prow - pane.tag_rows)), .bar = pane.body.mode == .insert }; - } else if (panes.Terminal.visibleCursor(pane)) |cur| { + } else if (panes.terminal.visibleCursor(pane)) |cur| { if (cur.y + pane.tag_rows < body_bottom and cur.x < tw) s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y }; } @@ -492,5 +492,5 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { unreachable; } if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); - return panes.Terminal.bodyText(arena, pane); + return panes.terminal.bodyText(arena, pane); } diff --git a/src/builtins.zig b/src/builtins.zig index e47afeb4..c549b17f 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); } @@ -624,7 +625,7 @@ pub const Kill = struct { stopped = true; continue; }; - if (!panes.Terminal.commandRunning(pane)) continue; + if (!panes.terminal.commandRunning(pane)) continue; const sent = pane.sent_command.?; if (names.len > 0) { var words = std.mem.tokenizeAny(u8, names, " \t"); @@ -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/colors.zig b/src/colors.zig index 72e25aed..b6da0d2c 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -153,18 +153,22 @@ fn toOklab(rgb: [3]u8) [3]f32 { }; } -fn fromOklab(lab: [3]f32) [3]u8 { +fn oklabLinear(lab: [3]f32) [3]f32 { const lm = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2]; const mm = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2]; const sm = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2]; const l = lm * lm * lm; const m = mm * mm * mm; const s = sm * sm * sm; - const linear: [3]f32 = .{ + return .{ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, }; +} + +fn fromOklab(lab: [3]f32) [3]u8 { + const linear = oklabLinear(lab); var out: [3]u8 = undefined; for (&out, linear) |*o, v| { const x = std.math.clamp(v, 0, 1); @@ -181,6 +185,72 @@ pub fn mixOklab(a: [3]u8, b: [3]u8, t: f32) [3]u8 { return fromOklab(.{ x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t }); } +fn oklabDistance(a: [3]f32, b: [3]f32) f32 { + return @sqrt((a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]) + (a[2] - b[2]) * (a[2] - b[2])); +} + +/// How far a name's tint stands off its tag's ink in OKLab: a hue you see +/// at a glance, not a shade you look for. +pub const name_tint_distance: f32 = 0.07; +/// How much hue a colour needs to lend a name its tint. +const name_tint_chroma: f32 = 0.06; + +/// A name's tint for a theme that sets none (the faithful ports, the +/// imports, a theme file): a hue of the theme's own -- its keyword colour, +/// else the first of its ANSI accents, syntax, grip, selection or +/// diagnostic colours that has one -- at the lightness nearest the tag's +/// ink where that hue holds, the name reads on its ground (nameTintFloor) +/// and it stands off the ink. Null when no colour of the theme will do. +fn derivedNameTint(th: *const Theme, ink: [3]u8, ground: [3]u8) ?[3]u8 { + const pal = th.palette orelse [_][3]u8{th.kw} ** 16; + const sources = [_][3]u8{ th.kw, pal[4], pal[5], pal[6], pal[3], pal[2], pal[1], th.num, th.str, th.box } ++ + [_][3]u8{ th.column_box orelse th.num, th.box_dirty orelse th.num, th.sel_bg, th.search_bg orelse th.num } ++ + [_][3]u8{ th.diagnostic_info orelse th.num, th.diagnostic_hint orelse th.num, th.diagnostic_warning orelse th.num } ++ + // A theme with no hue of its own at all (flatwhite): acme's DMedblue's. + [_][3]u8{.{ 0x00, 0x00, 0x99 }}; + // Near the ink's lightness first, from any of the theme's hues; only + // then further off it. + for ([2]f32{ 0.15, 1 }) |reach| for (sources) |source| { + if (nameTintFrom(toOklab(source), ink, ground, reach)) |tint| return tint; + }; + return null; +} + +fn nameTintFrom(source: [3]f32, ink: [3]u8, ground: [3]u8, reach: f32) ?[3]u8 { + const chroma = @sqrt(source[1] * source[1] + source[2] * source[2]); + if (chroma < name_tint_chroma) return null; + const ink_lab = toOklab(ink); + const floor = nameTintFloor(ink, ground); + var step: f32 = 0; + while (step <= reach) : (step += 0.01) { + for ([2]f32{ 1, -1 }) |sign| { + const lightness = ink_lab[0] + sign * step; + if (lightness < 0 or lightness > 1) continue; + // As much of the hue as that lightness holds, up to a tint's. + var want = @min(chroma, 0.12); + while (want >= 0.04) : (want -= 0.01) { + const lab: [3]f32 = .{ lightness, source[1] / chroma * want, source[2] / chroma * want }; + const in_gamut = for (oklabLinear(lab)) |v| { + if (v < -0.001 or v > 1.001) break false; + } else true; + if (!in_gamut) continue; + const tint = fromOklab(lab); + if (contrast(tint, ground) < floor) continue; + if (oklabDistance(toOklab(tint), ink_lab) < name_tint_distance) continue; + return tint; + } + } + } + return null; +} + +/// What a name's tint must read at on its ground: 4.5:1, or within half a +/// point of the ink's own where the ink has less to spare (seoulbones_dark's +/// near-white on grey leaves no hue at the ink's 4.53:1). +pub fn nameTintFloor(ink: [3]u8, ground: [3]u8) f32 { + return @min(4.5, contrast(ink, ground) - 0.5); +} + test "an OKLab fade holds its ends exactly and keeps a mid-way bright" { for ([_][3]u8{ .{ 0, 0, 0 }, .{ 255, 255, 255 }, .{ 0x0c, 0x0c, 0x0e }, .{ 0xe8, 0xc4, 0x6a }, .{ 0x1c, 0x51, 0x72 } }) |rgb| { try std.testing.expectEqual(rgb, fromOklab(toOklab(rgb))); @@ -548,13 +618,21 @@ pub const ChromeTheme = struct { pub fn fromTheme(th: *const Theme) ChromeTheme { // The initial chrome is resolved at compile time: contrast takes pow. @setEvalBranchQuota(200_000); + const active_bg = focusTint(th); + const active_fg = th.tag_active_fg orelse th.tag_fg; + // A name is always tinted: a theme that names no tint gets one of + // its own hues, on each ground. One that names only the plain tint + // keeps it when focused too. + const name_fg = th.tag_name_fg orelse (derivedNameTint(th, th.tag_fg, th.tag_bg) orelse th.tag_fg); + const active_name_fg = th.tag_active_name_fg orelse + (th.tag_name_fg orelse (derivedNameTint(th, active_fg, active_bg) orelse active_fg)); return .{ .tag_bg = th.tag_bg, .tag_fg = th.tag_fg, - .tag_active_bg = focusTint(th), - .tag_active_fg = th.tag_active_fg orelse th.tag_fg, - .tag_name_fg = th.tag_name_fg orelse th.tag_fg, - .tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)), + .tag_active_bg = active_bg, + .tag_active_fg = active_fg, + .tag_name_fg = name_fg, + .tag_active_name_fg = active_name_fg, .border = separatorOf(th), .empty_col = th.empty_col orelse separatorOf(th), .search_bg = th.search_bg orelse th.sel_bg, @@ -1108,8 +1186,9 @@ test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg); const chrome = ChromeTheme.fromTheme(&parsed); try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg); - try std.testing.expectEqual(parsed.tag_fg, chrome.tag_name_fg); - try std.testing.expectEqual(chrome.tag_active_fg, chrome.tag_active_name_fg); + // Its names take a tint of its own hues, never the plain ink. + try std.testing.expect(!std.meta.eql(parsed.tag_fg, chrome.tag_name_fg)); + try std.testing.expect(!std.meta.eql(chrome.tag_active_fg, chrome.tag_active_name_fg)); try std.testing.expectEqual(parsed.num, chrome.column_box); try std.testing.expectEqual(mix(parsed.num, parsed.tag_bg), chrome.column_box_dim); try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); @@ -1148,8 +1227,9 @@ test "custom filename tints preserve optional active fallback precedence" { theme.tag_name_fg = null; theme.tag_active_name_fg = null; var chrome = ChromeTheme.fromTheme(&theme); - try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); - try std.testing.expectEqual(theme.tag_active_fg.?, chrome.tag_active_name_fg); + // Neither named: each derived, on its own ground. + try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_fg, theme.tag_bg).?, chrome.tag_name_fg); + try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_active_fg.?, chrome.tag_active_bg).?, chrome.tag_active_name_fg); theme.tag_name_fg = .{ 120, 130, 140 }; chrome = ChromeTheme.fromTheme(&theme); try std.testing.expectEqual(theme.tag_name_fg.?, chrome.tag_name_fg); @@ -1160,6 +1240,25 @@ test "custom filename tints preserve optional active fallback precedence" { try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); theme.tag_name_fg = null; chrome = ChromeTheme.fromTheme(&theme); - try std.testing.expectEqual(theme.tag_fg, chrome.tag_name_fg); + try std.testing.expectEqual(derivedNameTint(&theme, theme.tag_fg, theme.tag_bg).?, chrome.tag_name_fg); try std.testing.expectEqual(theme.tag_active_name_fg.?, chrome.tag_active_name_fg); } + +test "every theme tints its names off the tag's ink, and they read on both grounds" { + for (&themes) |*th| { + const chrome = ChromeTheme.fromTheme(th); + const pairs = [2][3][3]u8{ + .{ chrome.tag_fg, chrome.tag_name_fg, chrome.tag_bg }, + .{ chrome.tag_active_fg, chrome.tag_active_name_fg, chrome.tag_active_bg }, + }; + for (pairs) |pair| { + const ink, const name, const ground = pair; + const apart = oklabDistance(toOklab(ink), toOklab(name)); + const floor = nameTintFloor(ink, ground); + if (apart < name_tint_distance or contrast(name, ground) < floor - 0.01) + std.debug.print("{s}: name {x} on {x} is {d:.3} off the ink {x}, {d:.2}:1\n", .{ th.name, name, ground, apart, ink, contrast(name, ground) }); + try std.testing.expect(apart >= name_tint_distance); + try std.testing.expect(contrast(name, ground) >= floor - 0.01); + } + } +} 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/draw.zig b/src/draw.zig index 9c9e11dd..ad24153f 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -63,7 +63,7 @@ pub fn place(p: *Pardes, s: *Surface) void { // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; const body_h = r.h -| pane.tag_rows; - const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0; + const line: i32 = if (pane.file) |f| @intCast(@min(f.scroll, std.math.maxInt(i32))) else if (pane.isTerminal()) panes.terminal.gridOffset(pane) else 0; s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .line = line, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; // An image's rail has no thumb; a native PDF's is measured as its @@ -78,7 +78,7 @@ pub fn place(p: *Pardes, s: *Surface) void { .offset = page, .len = 1, } else blk: { - const gsb = panes.Terminal.scrollbar(pane); + const gsb = panes.terminal.scrollbar(pane); break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; }; const track_h: usize = body_h; @@ -241,7 +241,7 @@ fn glideCursor(p: *Pardes, s: *Surface) void { if (inside) frame = paneFrame(s, pane.serial, rect); const origin: [2]f32 = .{ @floatFromInt(rect.x), @floatFromInt(rect.y) }; const view = p.cursor_view; - const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.Terminal.gridOffset(pane) else 0; + const line: i64 = if (pane.file) |f| @intCast(f.scroll) else if (pane.isTerminal()) panes.terminal.gridOffset(pane) else 0; if (view.serial == pane.serial and view.inside == inside) { // The pane moved in the layout: the springs move with it (its // own cursor's; one in a column's tag stays). @@ -719,7 +719,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { sb_off = page; sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; } else { - const sb = panes.Terminal.scrollbar(at); + const sb = panes.terminal.scrollbar(at); sb_off = sb.offset; sb_total = sb.total; } @@ -1477,6 +1477,60 @@ test "a grip's four states, focused or not and clean or dirty, are each its own pane.file.?.revision = pane.file.?.saved_revision; } +test "every theme draws a file's name, a +Errors and a Tty word in a tint, never the tag's plain ink" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 120, .rows = 40, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + try std.testing.expect(p.executeBuiltinLine(0, "Tty+sh")); + const terminal = p.active; + p.acknowledgeShell(terminal, "/bin/sh", false); + try @import("Output.zig").openErrors(p, 0, try gpa.dupe(u8, "oops\n")); + while (p.nextEffect()) |_| {} + p.animate_theme_changes = false; + const errors = for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + if (pane.file) |f| if (std.mem.endsWith(u8, f.path, "+Errors")) break id; + } else return error.NoErrorsPane; + const Probe = struct { id: usize, word: []const u8 }; + const probes = [_]Probe{ .{ .id = 0, .word = "test.txt" }, .{ .id = terminal, .word = "Tty+sh" }, .{ .id = errors, .word = "+Errors" } }; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + for (pardes.themes, 0..) |theme, index| { + colors.setThemeIndex(p, index); + // Each pane focused in turn: every word on both grounds. + for (probes) |focused| { + p.active = focused.id; + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + for (probes) |probe| { + const r = p.rects[probe.id]; + const y = p.tagTop(p.panes[probe.id].?, r); + // The row's text, and the column each byte of it is drawn at. + var row: [512]u8 = undefined; + var cols: [512]u16 = undefined; + var len: usize = 0; + for (r.x..r.x + r.w) |x| { + const g = s.at(@intCast(x), y).grapheme(); + for (if (g.len == 0) " " else g) |byte| if (len < row.len) { + row[len] = byte; + cols[len] = @intCast(x); + len += 1; + }; + } + const name = std.mem.indexOf(u8, row[0..len], probe.word) orelse return error.WordNotDrawn; + const plain = std.mem.indexOf(u8, row[0..len], " Del") orelse return error.WordNotDrawn; + const tint = s.at(cols[name], y).style.fg; + const ink = s.at(cols[plain + 1], y).style.fg; + if (std.meta.eql(tint, ink)) std.debug.print("{s}: {s} drawn in the tag's ink\n", .{ theme.name, probe.word }); + try std.testing.expect(!std.meta.eql(tint, ink)); + // The whole word, in the one tint. + try std.testing.expect(std.meta.eql(s.at(cols[name + probe.word.len - 1], y).style.fg, tint)); + } + } + } +} + test "a tag of three rows has its grip on the first, and band under it that still grabs the pane" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); diff --git a/src/dump.zig b/src/dump.zig index fca88a2d..73d4e4ed 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 = &.{}, @@ -709,7 +731,7 @@ pub fn dumpState(p: *Pardes) !void { } else if (pane.image) |iv| try pardes.panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw) else - try pardes.panes.Terminal.dumpPane(pane, arena, tag, body, scroll); + try pardes.panes.terminal.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = pane.tag.own; if (dp.file) |*df| { const dot = ctlfs.pane.dotOf(pane); @@ -892,7 +914,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo // and how it ended: its command is not run again. const live = comptime (terminal_panes and platform != .web and !isolated); const revive = live and t.cwd.len <= effect_path_cap and t.command.len == 0; - const restored = try pardes.panes.Terminal.restore(p, src, revive); + const restored = try pardes.panes.terminal.restore(p, src, revive); p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (t.shell.len > 0) restored.shell = try gpa.dupe(u8, t.shell); @@ -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..98c60efb 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; @@ -113,7 +113,7 @@ pub fn clipRequest(p: *Pardes, id: usize, mode: @FieldType(ClipRequest, "mode")) pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) void { if (text.len == 0) return; - if (panes.Terminal.bracketedPaste(pane)) { + if (panes.terminal.bracketedPaste(pane)) { p.emitWrite(id, "\x1b[200~"); p.emitWrite(id, text); p.emitWrite(id, "\x1b[201~"); @@ -225,7 +225,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { // Refresh the source map before translating the rendered rectangle. const raw = pane.isTerminal() and pane.body.mode == .tty; const body = try body_layer.bodyText(p, p.scratch.allocator(), pane); - const terminal_lines = if (pane.file == null and !raw) try panes.Terminal.cursorLines(p, pane) else &.{}; + const terminal_lines = if (pane.file == null and !raw) try panes.terminal.cursorLines(p, pane) else &.{}; const first = @max(0, @min(gesture.r0, gesture.r1) - pane.tag_rows); const logical_rows = if (pane.body_rows > 0) pane.body_rows else pane.rows; const last = @min(@as(i32, logical_rows) - 1, @max(gesture.r0, gesture.r1) - pane.tag_rows); @@ -242,7 +242,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { const left: i32 = if (cols.first) cols.lo else prefix; const right: i32 = cols.hi orelse std.math.maxInt(i32) / 2; var source = pane.wrapAt(visible); - if (raw) source = .{ .line = panes.Terminal.gridOffset(pane) + visible, .at = 0 }; + if (raw) source = .{ .line = panes.terminal.gridOffset(pane) + visible, .at = 0 }; const line = if (raw) modal.lineSlice(body, @intCast(visible)) else pointerSourceLine(pane, terminal_lines, source.line); var next = pane.wrapAt(visible + 1); if (raw) next = .{ .line = source.line + 1, .at = 0 }; @@ -256,7 +256,7 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void { .hi = if (right < prefix) lo else if (cols.hi == null) end else @min(end, modal.nextGrapheme(line, at)), .to_edge = cols.hi == null, .raw_terminal = raw, - .prompt_bytes = if (raw) panes.Terminal.promptPrefixBytes(pane, source.line, line) else 0, + .prompt_bytes = if (raw) panes.terminal.promptPrefixBytes(pane, source.line, line) else 0, }; const rendered = modal.lineSlice(body, @intCast(visible)); const prefix_end = @min(rendered.len, @as(usize, @intCast(@min(prefix, right + 1)))); @@ -276,7 +276,7 @@ pub fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 { const arena = p.scratch.allocator(); if (pane.rawPointerText(sl)) |text| return arena.dupe(u8, text); if (pane.pointerSelection(sl)) |rows| { - const terminal_lines = if (pane.file == null) try panes.Terminal.cursorLines(p, pane) else &.{}; + const terminal_lines = if (pane.file == null) try panes.terminal.cursorLines(p, pane) else &.{}; var total: usize = rows.len -| 1; for (rows) |row| { const line = pointerSourceLine(pane, terminal_lines, row.row); @@ -402,7 +402,7 @@ pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 { if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena); unreachable; } - return panes.Terminal.cursorLines(p, pane); + return panes.terminal.cursorLines(p, pane); } pub fn paneByteAtDisplay(p: *Pardes, t: *Text, row: i32, from_raw: i32, display_col: i32) i32 { @@ -448,8 +448,8 @@ pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { if (comptime pdf_enabled) return pane.pdf.?.ensureText(p.pdf_gpa); unreachable; } - const lines = try panes.Terminal.cursorLines(p, pane); - return panes.Terminal.flatSurface(p, pane, lines); + const lines = try panes.terminal.cursorLines(p, pane); + return panes.terminal.flatSurface(p, pane, lines); } pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize { @@ -474,7 +474,7 @@ fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText { const pane = t.pane() orelse return null; if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; if (pane.image != null or pane.hasPdf()) return null; - return panes.Terminal.editText(p, pane, lo, hi, col); + return panes.terminal.editText(p, pane, lo, hi, col); } fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText { @@ -535,7 +535,7 @@ pub fn setEditTextEnd(p: *Pardes, t: *Text, new: []u8, known_end: ?modal.Cursor) }; t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) }; if (pane.file) |*f| return panes.File.setContent(p, f, new); - panes.Terminal.setEditText(p, pane, new); + panes.terminal.setEditText(p, pane, new); } /// Take a text an edit made that nothing keeps, holding it until the next @@ -2057,7 +2057,7 @@ pub fn pushUndo(p: *Pardes, t: *Text) void { } const pane = t.pane() orelse return; if (pane.file != null) return panes.File.pushUndo(p, pane); - panes.Terminal.pushUndo(p, pane); + panes.terminal.pushUndo(p, pane); } pub fn doUndo(p: *Pardes, t: *Text) void { @@ -2065,7 +2065,7 @@ pub fn doUndo(p: *Pardes, t: *Text) void { if (t.what != .body) return; const pane = t.pane() orelse return; if (pane.file != null) return panes.File.undo(p, pane); - panes.Terminal.undo(p, pane); + panes.terminal.undo(p, pane); } pub fn doRedo(p: *Pardes, t: *Text) void { @@ -2073,7 +2073,7 @@ pub fn doRedo(p: *Pardes, t: *Text) void { if (t.what != .body) return; const pane = t.pane() orelse return; if (pane.file != null) return panes.File.redo(p, pane); - panes.Terminal.redo(p, pane); + panes.terminal.redo(p, pane); } /// Undo or redo an edit of a text's own characters; stepping back past its @@ -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/exec.zig b/src/exec.zig index 2c42389c..221de105 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -42,11 +42,11 @@ pub fn takesCommandLine(p: *const Pardes, id: usize) bool { // idle, EMPTY prompt is typed at; otherwise a new pane runs the line. // (Text in pardes's own edit buffer is not the shell's yet: an exec of // it, or of anything, types only what it runs.) - if (panes.Terminal.promptInputReady(pane) and !panes.Terminal.promptInputEmpty(pane)) return false; + if (panes.terminal.promptInputReady(pane) and !panes.terminal.promptInputEmpty(pane)) return false; // A mounted shell sits behind sudo's process supervisor. Its OSC 133 // prompt marks identify input readiness; the launcher's executable is // not the shell executable used by the ordinary process heuristic. - if (pane.v9fs_on_spawn) return panes.Terminal.promptInputReady(pane); + if (pane.v9fs_on_spawn) return panes.terminal.promptInputReady(pane); return !p.hostTtyTaken(id); } @@ -111,7 +111,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand const visible = clicked.r0 - @as(i32, pane.tag_rows); const wrapped = pane.wrapAt(visible); const raw = pane.isTerminal() and pane.body.mode == .tty and clicked.r0 >= pane.tag_rows; - const row = if (raw) panes.Terminal.gridOffset(pane) + visible else wrapped.line; + const row = if (raw) panes.terminal.gridOffset(pane) + visible else wrapped.line; const raw_line = if (raw) modal.lineSlice(body_layer.bodyText(p, p.scratch.allocator(), pane) catch "", @intCast(@max(0, visible))) else ""; const col = if (raw) @as(i32, @intCast(panes.File.rawAtDisplay(raw_line, @intCast(@max(0, clicked.c0))))) @@ -143,7 +143,7 @@ pub fn pointerOperand(p: *Pardes, pane: *Pane, clicked: Pane.Sel) PointerOperand lo -|= span.prompt_bytes; hi -|= span.prompt_bytes; } else if (!span.raw_terminal and raw) { - const prefix = panes.Terminal.promptPrefixBytes(pane, row, raw_line); + const prefix = panes.terminal.promptPrefixBytes(pane, row, raw_line); lo += prefix; hi += prefix; } @@ -429,8 +429,8 @@ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { const tt = p.panes[tty_id] orelse return; // No typing, cursor on the first prompt line, no scrollback: this is // the throwaway boot placeholder a document may replace. - if (tt.ovl != null or panes.Terminal.gridCursor(tt).y != 0 or - panes.Terminal.scrollbar(tt).total > tt.rows) return; + if (tt.ovl != null or panes.terminal.gridCursor(tt).y != 0 or + panes.terminal.scrollbar(tt).total > tt.rows) return; p.deinitPane(tt) catch |err| return p.reportError(tty_id, "close", err); layout.compute(p); // a just-stacked doc has no rect yet; absorb snaps to rows _ = layout.absorbVWeight(p, tty_id, null); @@ -588,7 +588,7 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void { var said: [128]u8 = undefined; return p.setMessage(from, std.fmt.bufPrint(&said, "Repl: {d} bytes is more than can be sent at once; send less", .{n}) catch "Repl: too long to send"); } - const pasted = panes.Terminal.bracketedPaste(dst); + const pasted = panes.terminal.bracketedPaste(dst); edit.typeToTty(p, to, dst, clean[0..n]); // A Python block whose last line is indented, or a bare header, is not // over at one Enter, pasted into 3.13's REPL or typed into the old one: @@ -611,7 +611,7 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void { dst.repl_enter_due_ns = clockNow(p) + repl_enter_wait_ns; } else for (0..enters) |_| p.emitWrite(to, "\r"); // Line by line, a blank line inside a Python block ends the block. - if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and + if (!panes.terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and std.mem.indexOfScalar(u8, text, '\n') != null) { dst.repl_warned = true; @@ -671,9 +671,9 @@ pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usi return null; } if (!takesCommandLine(p, id)) return runCommand(p, id, cmd); - panes.Terminal.padOutputBelowEdits(p, id); - panes.Terminal.noteCommand(pane, cmd); - if (panes.Terminal.queuePendingCommand(pane, cmd) catch |err| { + panes.terminal.padOutputBelowEdits(p, id); + panes.terminal.noteCommand(pane, cmd); + if (panes.terminal.queuePendingCommand(pane, cmd) catch |err| { p.reportError(id, "queue command", err); return id; }) return id; @@ -733,8 +733,8 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { // saved cursor), mouse reports and bracketed paste off, the cursor // shown, colours reset. (Not DECSTR: ghostty's stream does not // implement it.) - if (panes.Terminal.onAlternateScreen(pane)) panes.Terminal.feedOutput(p, pane, "\x1b[?1049l"); - panes.Terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m"); + if (panes.terminal.onAlternateScreen(pane)) panes.terminal.feedOutput(p, pane, "\x1b[?1049l"); + panes.terminal.feedOutput(p, pane, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?25h\x1b[0m"); echoCommand(p, pane, line); p.emitSpawn(id, pane.serial, pane.cwdSlice()); noteRun(p, pane, "run", line); @@ -760,10 +760,10 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { fn echoCommand(p: *Pardes, pane: *Pane, line: []const u8) void { var buf: [2 * command_max + 8]u8 = undefined; var w = std.Io.Writer.fixed(&buf); - w.writeAll(if (panes.Terminal.gridCursor(pane).x != 0) "\r\n% " else "% ") catch {}; + w.writeAll(if (panes.terminal.gridCursor(pane).x != 0) "\r\n% " else "% ") catch {}; for (line) |c| (if (c == '\n') w.writeAll("\r\n") else w.writeByte(c)) catch {}; w.writeAll("\r\n") catch {}; - panes.Terminal.feedOutput(p, pane, w.buffered()); + panes.terminal.feedOutput(p, pane, w.buffered()); } /// `run <serial> <word>` or `exit <serial> <N|?>` in the log. @@ -788,8 +788,8 @@ pub fn commandDone(p: *Pardes, id: usize, status: ?u8) void { var buf: [16]u8 = undefined; const code = if (status) |n| std.fmt.bufPrint(&buf, "{d}", .{n}) catch "?" else "?"; var said: [32]u8 = undefined; - const lead = if (panes.Terminal.gridCursor(pane).x != 0) "\r\n" else ""; - panes.Terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n"); + const lead = if (panes.terminal.gridCursor(pane).x != 0) "\r\n" else ""; + panes.terminal.feedOutput(p, pane, std.fmt.bufPrint(&said, "{s}exit {s}\r\n", .{ lead, code }) catch "exit ?\r\n"); noteRun(p, pane, "exit", code); p.needs_frame = true; } 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,9 +55,9 @@ 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/terminal.zig", .contents = @embedFile("terminal.zig") }, .{ .path = "src/pdf_view.zig", .contents = @embedFile("pdf_view.zig") }, .{ .path = "src/layout.zig", .contents = @embedFile("layout.zig") }, .{ .path = "src/fs.zig", .contents = @embedFile("fs.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/host_io.zig b/src/host_io.zig index eb6d1d5d..05ccca09 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1293,7 +1293,7 @@ pub fn forkShell( var command: std.Io.Writer.Allocating = .init(c.gpa); defer command.deinit(); try @import("linux/v9fs.zig").writeLaunchCommand(&command.writer, path, std.mem.span(socket), &spawn.argv); - if (!try pardes.panes.Terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted; + if (!try pardes.panes.terminal.queuePendingCommand(pn, command.written())) return error.ShellAlreadyStarted; } // Built here and not after the fork: between fork and exec the child may // not call setenv, whose malloc can deadlock against a pty reader thread diff --git a/src/layout.zig b/src/layout.zig index 28f567bc..28f3d270 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -607,7 +607,7 @@ fn blankRows(p: *const Pardes, id: usize) u16 { // The empty line after a final newline shows nothing. (panes.File.nlines(p.gpa, f) -| @intFromBool(std.mem.endsWith(u8, f.content, "\n"))) -| f.scroll else if (pane.isTerminal()) - @as(usize, panes.Terminal.gridCursor(pane).y) + 1 + @as(usize, panes.terminal.gridCursor(pane).y) + 1 else rows; return rows -| @as(u16, @intCast(@min(used, rows))); @@ -742,7 +742,7 @@ pub fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void { // Its tag may wrap to more than one row: the rows under it are its body. const tag_h = @max(BOX_H, src.tag_rows); const body: u16 = if (src_h > tag_h) src_h - tag_h else 1; - const cur: u16 = if (!src.isTerminal()) body / 2 else panes.Terminal.gridCursor(src).y + 1; + const cur: u16 = if (!src.isTerminal()) body / 2 else panes.terminal.gridCursor(src).y + 1; // cap keep so a content-full source still leaves the new pane a tag + // a few body rows (an Alt-n from a full shell was born 0 rows tall) // ...and the source min_body_rows of its own (exec.placeNew), when it 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..cb0378de 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; @@ -821,7 +821,7 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { const forward = gesture.r1 > gesture.r0 or (gesture.r1 == gesture.r0 and gesture.c1 >= gesture.c0); const endpoint = rows[if (forward) rows.len - 1 else 0]; const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return; - const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane)))); + const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.terminal.gridOffset(pane)))); const byte = if (forward) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo; pane.body.cur_row = pane.surfRow(endpoint.row); var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t"); @@ -1137,11 +1137,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { if (cut) return; const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return; if (y.len == 0) return; - if (panes.Terminal.reportsMouse(pane)) { + if (panes.terminal.reportsMouse(pane)) { const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999)); const row: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].r1 - @as(i32, pane.tag_rows) + 1, 1, 9999)); var mb: [32]u8 = undefined; - if (panes.Terminal.mouseFormatSgr(pane)) { + if (panes.terminal.mouseFormatSgr(pane)) { p.emitWrite(s.id, std.fmt.bufPrint(&mb, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ col, row, col, row }) catch return); } else { // ponytail: legacy X10 bytes; add utf8/urxvt formats if an app ever wants them diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 5bf354eb..060e0d6c 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -344,7 +344,7 @@ pub fn paneText(p: *Pardes, pane: *Pane, buf: []u8) []const u8 { pane.serial, pane_files.tagOf(p, pane).len, // A terminal's body is its history, read whole on an open's first read. - if (pane.isTerminal()) panes.Terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len, + if (pane.isTerminal()) panes.terminal.screenTextLen(pane) else pane_files.bodyOf(pane).len, @as(u32, 0), @intFromBool(pane_files.dirtyOf(pane)), pane.cols, 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..3fad1fae 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -258,7 +258,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { const pf = &pane.fs; return switch (f) { // A terminal's body reads its screen and history, as text. - .body => if (pane.file == null and pane.isTerminal()) panes.Terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len, + .body => if (pane.file == null and pane.isTerminal()) panes.terminal.screenTextLen(pane) else if (pdfText(p, pane)) |t| t.len else bodyOf(pane).len, .data, .xdata => bodyOf(pane).len, .tag => tagOf(p, pane).len, .name => nameOf(pane).len + 1, @@ -399,7 +399,7 @@ fn readBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { return tree.stagedReply(p, req); } if (req.handle != 0 and pane.isTerminal()) return screen.readSnapshot(p, req, pane); - const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch + const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); defer p.gpa.free(text); const out = p.fs.stage(p.gpa); @@ -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/pty.zig b/src/ninep/pty.zig index 8ba9f4df..0f5202cc 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -139,9 +139,9 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { // or a line is typed at the prompt. Before the shell's first prompt a // run waits for it rather than answer busy, so that is not busy. // A build with no terminal panes (the board) has no marks to read. - const marks = if (comptime !pardes.panes.Terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null; + const marks = if (comptime !pardes.panes.terminal.enabled) null else if (pane.terminal) |state| &state.stream.handler else null; const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or - (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false))); + (if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.terminal.promptInputEmpty(pane)) else false))); const out = p.fs.stage(p.gpa); const size = winsize(pane); out.print(p.gpa, "{d:>11} {d:>11} {d:>11}\n", .{ size[0], size[1], @intFromBool(busy) }) catch {}; @@ -222,7 +222,7 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { if (std.mem.trim(u8, line, " \t").len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); if (line.len == 0) return tree.failText(req.tag, E.INVAL, e_bad_line); for (line) |c| if (c < ' ' and c != '\t') return tree.failText(req.tag, E.INVAL, e_bad_line); - if (comptime !pardes.panes.Terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line); + if (comptime !pardes.panes.terminal.enabled) return tree.failText(req.tag, E.INVAL, e_bad_line); const pf = &pane.fs; const state = pane.terminal orelse return tree.failText(req.tag, E.INVAL, e_bad_line); const marks = &state.stream.handler; @@ -235,7 +235,7 @@ pub fn writeRun(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply { } else if (pf.unmarked) { answer(p, slot, "error no prompt marks", .{}); } else if (waitingRun(p, pane) != null or (marks.prompts > 0 and (marks.phase != .input or - !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)))) + !pardes.panes.terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)))) { // Something is running or someone has typed at the prompt: sending // now would type into the middle of it. The phase is pardes's own @@ -288,7 +288,7 @@ pub fn readRun(p: *Pardes, req: Req) Reply { /// for the prompt means whoever reads the answer can send the next command /// at once instead of finding the shell still drawing it. pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { - if (comptime !pardes.panes.Terminal.enabled) return; + if (comptime !pardes.panes.terminal.enabled) return; const state = pane.terminal orelse return; const pf = &pane.fs; const slot = waitingRun(p, pane) orelse return; @@ -299,13 +299,13 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { pf.run = null; return; } - if (marks.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) return; + if (marks.phase != .input or !pardes.panes.terminal.promptInputEmpty(pane) or p.hostTtyTaken(id)) return; slot.want = marks.started +% 1; slot.prompts = marks.prompts; slot.declined = marks.declined; p.emitWrite(id, slot.line); p.emitWrite(id, "\r"); - pardes.panes.Terminal.noteCommand(pane, slot.line); + pardes.panes.terminal.noteCommand(pane, slot.line); slot.phase = .sent; return; } @@ -319,7 +319,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { // typed into an empty prompt, so throw it away with Ctrl-C, which // drops the whole pending command, and answer at the prompt after // that, so the next line finds the shell clear. - const empty = pardes.panes.Terminal.promptInputEmpty(pane); + const empty = pardes.panes.terminal.promptInputEmpty(pane); switch (slot.cleared) { .no => { if (marks.continuation) { @@ -360,7 +360,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void { /// Answers a run whose command ended with `status`: the header and the /// output it printed. fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void { - const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch + const printed = pardes.panes.terminal.commandOutput(pane, p.gpa, output_rows) catch return answer(p, slot, "error out of memory", .{}); defer p.gpa.free(printed.text); // Keep the tail, cut at a line start so the first line kept is whole. @@ -400,7 +400,7 @@ pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void { const code = status orelse return; if (waitingRun(p, pane)) |slot| if (slot.phase == .sent) { pane.fs.run = null; - pardes.panes.Terminal.endOutputHere(pane); + pardes.panes.terminal.endOutputHere(pane); finish(p, slot, pane, code); }; var buf: [4]u8 = undefined; @@ -963,10 +963,10 @@ test "a run's answer says cut when its output's start is gone, and reads a bound // A resize between its start and end reflows the pins with the text. h = sh.send(p, node); sh.put(p, c ++ "one\r\ntwo\r\n"); - pardes.panes.Terminal.resizeGrid(pane, gpa, 50, 20); + pardes.panes.terminal.resizeGrid(pane, gpa, 50, 20); sh.put(p, d ++ prompt); try testing.expectEqualStrings("exit 0\none\ntwo\n", sh.answered(p, node, h, into)); - pardes.panes.Terminal.resizeGrid(pane, gpa, pane.cols, pane.rows); + pardes.panes.terminal.resizeGrid(pane, gpa, pane.cols, pane.rows); // A full-screen program leaves nothing on the primary screen, and a D // with no status is not a success. diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index f496da03..b991c3e4 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -47,7 +47,7 @@ pub fn readSnapshot(p: *Pardes, req: Req, pane: ?*Pane) Reply { const snapshot = &(tree.openOf(p, req) orelse return Reply.fail(req.tag, E.INVAL)).what.snapshot; if (snapshot.* == null) { const terminal = pane orelse return Reply.fail(req.tag, E.INVAL); - snapshot.* = panes.Terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); + snapshot.* = panes.terminal.screenTextAlloc(terminal, p.gpa) catch return Reply.fail(req.tag, E.NOMEM); } const bytes = snapshot.*.?; const off = @min(req.off, bytes.len); @@ -129,7 +129,7 @@ test "terminal body handles keep one history snapshot across fragmented reads" { const node = Node.of(serialOf(p), .body); p.update(.{ .output = .{ .pane = 0, .bytes = "old caf\xc3\xa9\r\nold tail" } }); while (p.nextEffect()) |_| {} - const original = try panes.Terminal.screenTextAlloc(p.panes[0].?, gpa); + const original = try panes.terminal.screenTextAlloc(p.panes[0].?, gpa); defer gpa.free(original); const opened = call(p, .{ .tag = 1, .op = .open, .node = node }); try testing.expectEqual(Status.ok, opened.reply.status); 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..0380f89f 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -89,7 +89,7 @@ pub const Pane = struct { pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 }; pub const Focus = enum { body, tag }; - terminal: ?*Terminal.State = null, + terminal: ?*terminal.State = null, gpa: std.mem.Allocator, serial: u32 = 0, /// What the control filesystem keeps of this pane -- addr and limit, the @@ -116,7 +116,7 @@ pub const Pane = struct { /// A shell has run in it (Pardes.acknowledgeShell): a Tty whose first /// shell fails to start closes, one started again keeps its pane. had_shell: bool = false, - pending_command: Terminal.PendingCommand = .{}, + pending_command: terminal.PendingCommand = .{}, /// The last command line pardes typed into this terminal (an exec, a /// middle click, a pty/run): its first word, which Kill matches, and /// the shell's command count its start (C mark) reaches. @@ -227,7 +227,7 @@ pub const Pane = struct { next_pointer_selection: u64 = 0, /// terminals only: the typed-text buffer standing in for shell rows - ovl: ?Terminal.EditBuffer = null, + ovl: ?terminal.EditBuffer = null, tty_filter: bool = false, msg: [256]u8 = undefined, msg_len: u16 = 0, @@ -486,7 +486,7 @@ pub const Pane = struct { pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); if (comptime Pdf.enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); - return pane.surfRow(Terminal.gridOffset(pane)); + return pane.surfRow(terminal.gridOffset(pane)); } pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { @@ -531,8 +531,8 @@ pub const Pane = struct { } } else { // the vt scrolls in SHELL rows; convert through the edit buffer - const off = Terminal.gridOffset(pane); - Terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); + const off = terminal.gridOffset(pane); + terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); } } @@ -611,8 +611,8 @@ pub const Pane = struct { pane.body.cur_row = pane.scroll(); pane.body.cur_col = 0; } else { - const cur = Terminal.gridCursor(pane); - pane.body.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane)); + const cur = terminal.gridCursor(pane); + pane.body.cur_row = pane.surfRow(@as(i32, cur.y) + terminal.gridOffset(pane)); pane.body.cur_col = @intCast(cur.x); } pane.body.cur_pinned = true; @@ -636,21 +636,21 @@ 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"); pub const Pdf = @import("pdf_view.zig"); -pub const Terminal = @import("Terminal.zig"); +pub const terminal = @import("terminal.zig"); test { _ = Pane; _ = Text; _ = File; _ = Output; - _ = Mini; + _ = mini; _ = Image; _ = Pdf; - _ = Terminal; + _ = terminal; } diff --git a/src/pardes.zig b/src/pardes.zig index 19fe51d1..623af3c9 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); @@ -1196,9 +1195,9 @@ test "Tty9p marks only the new Linux terminal for a mounted shell" { try std.testing.expect(pane.v9fs_on_spawn); try std.testing.expect(!pane.greet); try std.testing.expect(!exec.takesCommandLine(p, p.active)); - panes.Terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); + panes.terminal.feedOutput(p, pane, "\x1b]133;A\x07$ \x1b]133;B\x07"); try std.testing.expect(exec.takesCommandLine(p, p.active)); - panes.Terminal.feedOutput(p, pane, "\x1b]133;C\x07"); + panes.terminal.feedOutput(p, pane, "\x1b]133;C\x07"); try std.testing.expect(!exec.takesCommandLine(p, p.active)); } @@ -1559,7 +1558,7 @@ test "a command of several lines keeps its tag one row, and its echo does not st _ = exec.execute(p, 1, two); while (p.nextEffect()) |_| {} _ = try p.render(frame.allocator()); - const body = try panes.Terminal.screenTextAlloc(pane, gpa); + const body = try panes.terminal.screenTextAlloc(pane, gpa); defer gpa.free(body); try std.testing.expect(std.mem.indexOf(u8, body, "% def greet(name):\n return name") != null); } @@ -1879,7 +1878,7 @@ test "a command pane shows how its command ended, and the next command there run const pane = p.panes[dst].?; // Its first command is echoed as every later one is. { - const first = try panes.Terminal.screenTextAlloc(pane, gpa); + const first = try panes.terminal.screenTextAlloc(pane, gpa); defer gpa.free(first); try std.testing.expect(std.mem.startsWith(u8, first, "% make -j8")); } @@ -1910,16 +1909,16 @@ test "a command pane shows how its command ended, and the next command there run // A program that crashed out of its full screen leaves the emulator // in its modes; the next command starts clear of them. p.update(.{ .output = .{ .pane = @intCast(dst), .bytes = "\x1b[?1049h\x1b[?2004h\x1b[?1000h" } }); - try std.testing.expect(panes.Terminal.bracketedPaste(pane)); + try std.testing.expect(panes.terminal.bracketedPaste(pane)); // Done, the directory's next command runs there, below what it showed. try std.testing.expectEqual(@as(?usize, dst), exec.execute(p, 1, "make test")); - try std.testing.expect(!panes.Terminal.bracketedPaste(pane)); + try std.testing.expect(!panes.terminal.bracketedPaste(pane)); var respawned = false; while (p.nextEffect()) |effect| if (effect == .spawn and effect.spawn.pane == dst) { respawned = true; }; try std.testing.expect(respawned); - const body = try panes.Terminal.screenTextAlloc(pane, gpa); + const body = try panes.terminal.screenTextAlloc(pane, gpa); defer gpa.free(body); const said = std.mem.indexOf(u8, body, "compiled") orelse return error.OutputLost; const ended = std.mem.indexOf(u8, body, "exit 2") orelse return error.ExitLost; @@ -4393,9 +4392,9 @@ 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 = .{}, - tty_filter_palette: panes.Terminal.FilterPalette = .{}, + locations_config: locations.Config = .{}, + locations_cache: locations.Cache = .{}, + tty_filter_palette: panes.terminal.FilterPalette = .{}, font_request_taken: bool = false, custom_theme: ?Theme = null, /// bodyChrome's: the theme it was worked out for, and it. @@ -4581,8 +4580,8 @@ pub const Pardes = struct { /// that still own their loop pass their own arena to `render` instead. frame_arena: std.heap.ArenaAllocator, /// the terminal motion surface, memoized against the pane it was built - /// for — see panes.Terminal.RowsCache for the lifetime rule - shell_rows: panes.Terminal.RowsCache = .{}, + /// for — see panes.terminal.RowsCache for the lifetime rule + shell_rows: panes.terminal.RowsCache = .{}, /// The message row's entry points, called from everywhere as /// `p.setMessage(...)`; they live with the rest of it in Messages.zig. @@ -4945,7 +4944,7 @@ pub const Pardes = struct { } p.shell_rows.dropPane(pane); for (0..pane.pointer_selections.len) |slot| pane.clearPointerSelection(slot); - panes.Terminal.deinitPendingCommand(pane); + panes.terminal.deinitPendingCommand(pane); if (pane.command) |line| p.gpa.free(line); if (pane.shell) |bin| p.gpa.free(bin); if (pane.image) |*iv| { @@ -4958,7 +4957,7 @@ pub const Pardes = struct { pane.tag.deinit(p.gpa); pane.input.deinit(p.gpa); pane.jump.deinit(p.gpa); - panes.Terminal.deinitEmulator(pane, p.gpa); + panes.terminal.deinitEmulator(pane, p.gpa); pane.clearCwd(); pane.fs.deinit(p.gpa); p.gpa.destroy(pane); @@ -4987,12 +4986,12 @@ pub const Pardes = struct { pub fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane { std.debug.assert(p.panes[id] == null); if (cwd.len > limits.host_path_cap) return error.PathTooLong; - const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.terminal.create(p.gpa, p.screen_w, p.screen_h); // Named by the directory it is started in from the first, so the // shell saying it is there is no rename (`new N /` then `rename`); // out of memory for it, the shell names it when it says. if (cwd.len > 0) pane.setOwnedCwd(cwd) catch {}; - panes.Terminal.armShellSpawn(pane); + panes.terminal.armShellSpawn(pane); p.installPane(id, pane); p.emitSpawn(id, pane.serial, cwd); return pane; @@ -5018,7 +5017,7 @@ pub const Pardes = struct { if (cwd.len > limits.host_path_cap) return error.PathTooLong; const owned = try p.gpa.dupe(u8, line); errdefer p.gpa.free(owned); - const pane = try panes.Terminal.create(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.terminal.create(p.gpa, p.screen_w, p.screen_h); pane.command = owned; pane.command_pty = true; pane.body.mode = .tty; @@ -5032,7 +5031,7 @@ pub const Pardes = struct { pub fn newDocPane(p: *Pardes, id: usize) !*Pane { std.debug.assert(p.panes[id] == null); - const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h); p.installPane(id, pane); return pane; } @@ -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); } @@ -5215,7 +5223,7 @@ pub const Pardes = struct { } else |_| {} } }; - if (id < MAX_PANES) panes.Terminal.shellSpawned(p, id, prompt_marks); + if (id < MAX_PANES) panes.terminal.shellSpawned(p, id, prompt_marks); if (p.settings.shell.effective.set(executable)) p.settings.shell.pending = false; } @@ -5350,7 +5358,7 @@ pub const Pardes = struct { /// The pane a session word runs with in an empty window. pub fn standIn(p: *Pardes) ?*Pane { - if (p.stand_in == null) p.stand_in = panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null; + if (p.stand_in == null) p.stand_in = panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null; return p.stand_in; } @@ -5619,7 +5627,7 @@ pub const Pardes = struct { return; } if (!pane.isTerminal()) return; - const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return; + const text = panes.terminal.screenTextAlloc(pane, p.gpa) catch return; defer p.gpa.free(text); p.hostWriteFile(st.pane, st.path.slice(), text); }, @@ -5831,7 +5839,7 @@ pub const Pardes = struct { const pane = p.panes[o.pane] orelse return; pane.shell_spoke = true; ctlfs.events.notePtyOutput(p, o.pane, o.bytes); - panes.Terminal.feedOutput(p, pane, o.bytes); + panes.terminal.feedOutput(p, pane, o.bytes); ctlfs.pty.noteMarks(p, o.pane, pane); exec.flushReplEnter(p, o.pane, true); }, @@ -5936,7 +5944,7 @@ pub const Pardes = struct { .normal => edit.enterInsert(p, &pane.body, .at, 1), .insert => { edit.exitInsert(p, &pane.body); - if (pane.isTerminal()) panes.Terminal.enterTty(p, id); + if (pane.isTerminal()) panes.terminal.enterTty(p, id); }, } } @@ -5952,7 +5960,7 @@ pub const Pardes = struct { if (pane.body.mode == .tty) { pane.body.mode = .normal; pane.body.normal.clear(); - } else panes.Terminal.enterTty(p, id); + } else panes.terminal.enterTty(p, id); } /// Type `keys` again (a macro, `.`): through the modal handling as @@ -6071,7 +6079,7 @@ pub const Pardes = struct { return exec.runBuiltin(p, .Last, p.active, "", null); if (key.cp == Key.escape and !key.ctrl and !key.alt and !key.shift and exec.takesCommandLine(p, p.active)) return exec.runBuiltin(p, .Last, p.active, "", null); - panes.Terminal.followOutput(pane); // typing snaps back to live output + panes.terminal.followOutput(pane); // typing snaps back to live output // Paste stays the window's, the one exception to forwarding a raw // tty's keys: Ctrl-V types the register at the program and // Ctrl-Shift-V asks the desktop for its clipboard first. Forwarded, @@ -6086,7 +6094,7 @@ pub const Pardes = struct { // Ctrl-V a black hole in every full-screen application. if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text); } - return panes.Terminal.forwardKey(p, p.active, key); + return panes.terminal.forwardKey(p, p.active, key); } if (p.leader_on) return p.leaderKey(key); if (p.ctrl_w_pending) { @@ -6239,7 +6247,7 @@ pub const Pardes = struct { edit.exitInsert(p, t) else edit.handleInsert(p, t, key), - .tty => panes.Terminal.forwardKey(p, p.active, key), + .tty => panes.terminal.forwardKey(p, p.active, key), } // `.`'s log: the keys of the normal command that entered insert // mode, then everything typed until it is left @@ -6730,7 +6738,7 @@ pub const Pardes = struct { errdefer p.gpa.free(path); const history = try panes.File.History.create(p.gpa); errdefer p.gpa.destroy(history); - const pane = try panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h); + const pane = try panes.terminal.createDoc(p.gpa, p.screen_w, p.screen_h); errdefer p.gpa.destroy(pane); try p.deinitPane(p.panes[0].?); p.panes[0] = null; @@ -7078,7 +7086,7 @@ pub const Pardes = struct { // doc panes have no pty/emulator grid to reflow; just record // the size so bodyText renders the right number of rows if (pane.isTerminal()) { - panes.Terminal.resizeGrid(pane, p.gpa, cols, rows); + panes.terminal.resizeGrid(pane, p.gpa, cols, rows); p.shell_rows.markStale(pane); // reflow moved every row p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } }); pane.fs.winsize = null; @@ -7087,12 +7095,12 @@ pub const Pardes = struct { pane.rows = rows; if (pane.file) |*file| file.syntax_dirty = true; } - panes.Terminal.releasePendingCommandIfReady(p, id, pane); + panes.terminal.releasePendingCommandIfReady(p, id, pane); // The greeting `ls` of a new terminal in a directory is typed at // its shell's FIRST prompt, or never: typed later it would land // in whatever runs then, or in the middle of a line someone - // typed (panes.Terminal.greeting). - if (pane.greet and pane.isTerminal() and p.resize_count > 0) switch (panes.Terminal.greeting(pane)) { + // typed (panes.terminal.greeting). + if (pane.greet and pane.isTerminal() and p.resize_count > 0) switch (panes.terminal.greeting(pane)) { .wait => {}, .never => pane.greet = false, .now => { @@ -7373,7 +7381,7 @@ test "argv naming nothing boots an errors pane rather than failing the launch" { } test "raw terminal cursor obeys visibility without hiding modal and tag cursors" { - if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + if (comptime !panes.terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); @@ -7418,7 +7426,7 @@ test "raw terminal cursor obeys visibility without hiding modal and tag cursors" } test "raw terminal cursor follows its active row only while that row is visible" { - if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + if (comptime !panes.terminal.enabled) return error.SkipZigTest; const gpa = std.testing.allocator; for ([_]bool{ false, true }) |tag_bottom| { const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); @@ -7437,13 +7445,13 @@ test "raw terminal cursor follows its active row only while that row is visible" try std.testing.expectEqual(rect.x + config.GUTTER + 3, live.x); try std.testing.expectEqual(body_y + 2, live.y); - panes.Terminal.scrollGrid(pane, -1); + panes.terminal.scrollGrid(pane, -1); const scrolled = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(live.x, scrolled.x); try std.testing.expectEqual(live.y + 1, scrolled.y); - panes.Terminal.scrollGrid(pane, -@as(i32, pane.rows)); + panes.terminal.scrollGrid(pane, -@as(i32, pane.rows)); try std.testing.expect((try p.render(frame.allocator())).cursor == null); - panes.Terminal.followOutput(pane); + panes.terminal.followOutput(pane); const returned = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(live.x, returned.x); try std.testing.expectEqual(live.y, returned.y); @@ -7887,31 +7895,31 @@ test "tty output follows only from the bottom" { p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); - const bottom = panes.Terminal.scrollbar(sp).offset; + const bottom = panes.terminal.scrollbar(sp).offset; try std.testing.expect(bottom > 0); // Scrolled back, the reader stays put however much the shell prints. sp.scrollBy(-10); p.sync(); - const parked = panes.Terminal.scrollbar(sp).offset; + const parked = panes.terminal.scrollbar(sp).offset; try std.testing.expect(parked < bottom); for (60..70) |i| { var buf: [32]u8 = undefined; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = std.fmt.bufPrint(&buf, "line {d}\r\n", .{i}) catch unreachable } }); } p.sync(); - try std.testing.expectEqual(parked, panes.Terminal.scrollbar(sp).offset); + try std.testing.expectEqual(parked, panes.terminal.scrollbar(sp).offset); // Typing snaps back to live output, so nobody types blind. p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); p.sync(); - try std.testing.expect(panes.Terminal.scrollbar(sp).offset > parked); + try std.testing.expect(panes.terminal.scrollbar(sp).offset > parked); // ...and from there output follows again. - const live = panes.Terminal.scrollbar(sp).offset; + const live = panes.terminal.scrollbar(sp).offset; p.update(.{ .output = .{ .pane = @intCast(shell), .bytes = "tail\r\n" } }); p.sync(); - try std.testing.expect(panes.Terminal.scrollbar(sp).offset > live); + try std.testing.expect(panes.terminal.scrollbar(sp).offset > live); } test "Esc back into a file leaves its view where it was" { @@ -8127,7 +8135,7 @@ test "an unasked desktop paste reaches a tty pane's program, not its buffer" { _ = drainWrites(p, &buf); const pane = p.panes[0].?; - panes.Terminal.enterTty(p, 0); + panes.terminal.enterTty(p, 0); // No request behind it: the window manager's own paste, or SDL answering a // Ctrl-Shift-V the desktop handled. It still has to reach the shell. p.update(.{ .paste = "ls -la" }); @@ -8145,7 +8153,7 @@ test "a paste larger than the effect ring reaches the program whole and in order const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - panes.Terminal.enterTty(p, 0); + panes.terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @@ -8158,7 +8166,7 @@ test "a paste larger than the effect ring reaches the program whole and in order test "a bracketed paste larger than the ring still closes its bracket" { if (platform == .web) return; - if (comptime !panes.Terminal.enabled) return; + if (comptime !panes.terminal.enabled) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -8166,10 +8174,10 @@ test "a bracketed paste larger than the ring still closes its bracket" { const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - panes.Terminal.enterTty(p, 0); + panes.terminal.enterTty(p, 0); p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); - try std.testing.expect(panes.Terminal.bracketedPaste(p.panes[0].?)); + try std.testing.expect(panes.terminal.bracketedPaste(p.panes[0].?)); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @memset(text, 'z'); @@ -8189,7 +8197,7 @@ test "pasted bytes still queued at shutdown are freed, not leaked" { const buf = try gpa.alloc(u8, 1 << 20); defer gpa.free(buf); _ = drainWrites(p, buf); - panes.Terminal.enterTty(p, 0); + panes.terminal.enterTty(p, 0); const text = try gpa.alloc(u8, 300 * 1024); defer gpa.free(text); @@ -8219,14 +8227,14 @@ test "leaving tty hides the prompt and keeps the command typed at it" { const rowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { - const rows = panes.Terminal.shellRows(pp, pane) catch return null; + const rows = panes.terminal.shellRows(pp, pane) catch return null; for (rows) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; } }.at; const bodyRowOf = struct { fn at(pp: *Pardes, pane: *Pane, needle: []const u8) ?[]const u8 { - const body = panes.Terminal.bodyText(pp.scratch.allocator(), pane) catch return null; + const body = panes.terminal.bodyText(pp.scratch.allocator(), pane) catch return null; var it = std.mem.splitScalar(u8, body, '\n'); while (it.next()) |r| if (std.mem.indexOf(u8, r, needle) != null) return r; return null; @@ -8282,7 +8290,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" p.shell_rows.stale = true; // The command shows LEFT-HUGGED, so its column 3 is the '3'... - const rows = try panes.Terminal.shellRows(p, sp); + const rows = try panes.terminal.shellRows(p, sp); var row: i32 = 0; const at = for (rows, 0..) |r, i| { if (std.mem.indexOf(u8, r, "0123456789") != null) break i; @@ -8296,7 +8304,7 @@ test "entering tty walks the shell cursor to the column clicked past the prompt" sp.body.cur_col = 3; sp.body.cur_pinned = true; while (p.nextEffect()) |_| {} - panes.Terminal.enterTty(p, shell); + panes.terminal.enterTty(p, shell); // The walk is arrow keys the shell understands. Seven lefts: readline's // cursor sits past the '9' and the click was on the '3'. @@ -8339,7 +8347,7 @@ test "board heap: every inline ring the board pays for still fits its budget" { const effect_ring = 128 * @sizeOf(Effect); // ...and the undo history, the largest in `Pane` once the terminal is out: // 16 snapshots on the board against 256 on a desktop. - const undo_history = 2 * 16 * @sizeOf(panes.Terminal.Snapshot); + const undo_history = 2 * 16 * @sizeOf(panes.terminal.Snapshot); const grid = @as(usize, board_cols) * board_rows * (2 * @sizeOf(Cell) + @sizeOf(PanelCellDiff)); // Each of the three separately, so a failure names the one that grew @@ -8467,7 +8475,7 @@ test "jump history terminal rectangle follows output and clears on reflow" { try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0])); for (0..40) |_| p.update(.{ .output = .{ .pane = 0, .bytes = "more output\r\n" } }); try std.testing.expectEqualStrings("alpha", try edit.selectionText(p, pane, pane.sel[0])); - panes.Terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows); + panes.terminal.resizeGrid(pane, p.gpa, pane.cols - 1, pane.rows); try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null); try std.testing.expectEqual(.none, pane.sel[0].state); } 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/Terminal.zig b/src/terminal.zig index c99a92a6..c99a92a6 100644 --- a/src/Terminal.zig +++ b/src/terminal.zig diff --git a/src/themes/acme.zig b/src/themes/acme.zig index 621ffa23..277249d3 100644 --- a/src/themes/acme.zig +++ b/src/themes/acme.zig @@ -18,6 +18,10 @@ //! acme has no focus colour; pardes needs one, so the focused pane's button //! is filled #8888cc (the column button's look) and every other pane's is //! acme's own: #eaffff in a 2px #8888cc ring, #000099 inside it when dirty. +//! +//! acme tints nothing in a tag; pardes tints the file's name, a +Name and +//! the Tty word everywhere, so here it is done quietly, in that same #000099 +//! (draw.h's DMedblue): a navy that reads as ink until you look for it. pub const theme = .{ .name = "acme", .bg = .{ 0xff, 0xff, 0xea }, @@ -26,6 +30,7 @@ pub const theme = .{ .sel_fg = .{ 0x00, 0x00, 0x00 }, .tag_bg = .{ 0xea, 0xff, 0xff }, .tag_fg = .{ 0x00, 0x00, 0x00 }, + .tag_name_fg = .{ 0x00, 0x00, 0x99 }, .box = .{ 0x88, 0x88, 0xcc }, .box_dim = .{ 0xea, 0xff, 0xff }, .kw = .{ 0x97, 0x0d, 0xae }, diff --git a/src/tty/tty.zig b/src/tty/tty.zig index efccd98e..3902271c 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(); } @@ -1747,7 +1915,7 @@ test "TTY queued results reject old PTY generations and LSP request IDs" { shell.gens[0] = 2; _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 1, .bytes = try gpa.dupe(u8, "old session\n") } }); _ = shell.apply(.{ .pty_read = .{ .id = 0, .gen = 2, .bytes = try gpa.dupe(u8, "current session\n") } }); - const text = try pardes.panes.Terminal.screenTextAlloc(core.panes[0].?, gpa); + const text = try pardes.panes.terminal.screenTextAlloc(core.panes[0].?, gpa); defer gpa.free(text); try std.testing.expect(std.mem.indexOf(u8, text, "old session") == null); try std.testing.expect(std.mem.indexOf(u8, text, "current session") != null); @@ -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 {}; } |
