summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p.zig2
-rw-r--r--src/File.zig8
-rw-r--r--src/Layer.zig28
-rw-r--r--src/Messages.zig38
-rw-r--r--src/Output.zig2
-rw-r--r--src/ShaderBuild.zig (renamed from src/shader_build.zig)0
-rw-r--r--src/Text.zig18
-rw-r--r--src/body_layer.zig16
-rw-r--r--src/builtins.zig7
-rw-r--r--src/colors.zig121
-rw-r--r--src/detached/server.zig4
-rw-r--r--src/detached/wire.zig10
-rw-r--r--src/draw.zig62
-rw-r--r--src/dump.zig52
-rw-r--r--src/edit.zig30
-rw-r--r--src/exec.zig34
-rw-r--r--src/file_watch.zig2
-rw-r--r--src/fs.zig4
-rw-r--r--src/gui/Post.zig12
-rw-r--r--src/gui/gui.zig8
-rw-r--r--src/host_io.zig2
-rw-r--r--src/layout.zig4
-rw-r--r--src/locations.zig292
-rw-r--r--src/locations_cache.zig213
-rw-r--r--src/locations_config.zig75
-rw-r--r--src/look.zig8
-rw-r--r--src/main.zig42
-rw-r--r--src/mini.zig (renamed from src/Mini.zig)0
-rw-r--r--src/mouse.zig14
-rw-r--r--src/ninep/ctl.zig2
-rw-r--r--src/ninep/events.zig6
-rw-r--r--src/ninep/pane.zig10
-rw-r--r--src/ninep/pty.zig24
-rw-r--r--src/ninep/screen.zig4
-rw-r--r--src/ninep/tree.zig99
-rw-r--r--src/panes.zig24
-rw-r--r--src/pardes.zig124
-rw-r--r--src/tag_layer.zig32
-rw-r--r--src/terminal.zig (renamed from src/Terminal.zig)0
-rw-r--r--src/themes/acme.zig5
-rw-r--r--src/tty/tty.zig282
41 files changed, 1075 insertions, 645 deletions
diff --git a/src/9p.zig b/src/9p.zig
index 5ffe9058..837525cb 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -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;
diff --git a/src/fs.zig b/src/fs.zig
index edead0ff..fc3eb98f 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -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 {};
}