summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig.zon2
-rw-r--r--docs/config.md2
-rw-r--r--docs/fs.md27
-rw-r--r--docs/tags.md2
-rw-r--r--src/CHANGELOG.md20
-rw-r--r--src/File.zig15
-rw-r--r--src/Messages.zig2
-rw-r--r--src/Output.zig25
-rw-r--r--src/body_layer.zig35
-rw-r--r--src/builtins.zig59
-rw-r--r--src/colors.zig37
-rw-r--r--src/config.zig73
-rw-r--r--src/dump.zig2
-rw-r--r--src/exec.zig15
-rw-r--r--src/layout.zig27
-rw-r--r--src/look.zig47
-rw-r--r--src/ninep/addr.zig22
-rw-r--r--src/ninep/cols.zig26
-rw-r--r--src/ninep/ctl.zig158
-rw-r--r--src/ninep/events.zig5
-rw-r--r--src/ninep/pane.zig52
-rw-r--r--src/ninep/tree.zig6
-rw-r--r--src/pardes.zig61
-rw-r--r--src/pdf_view.zig13
-rw-r--r--test/mode.zig2
-rw-r--r--test/pdf.zig73
-rw-r--r--test/perf-baseline-gui-Debug.json2
-rw-r--r--test/perf-baseline-tty-Debug.json2
-rw-r--r--test/perf-baseline-tty-ReleaseFast.json2
-rw-r--r--test/perf.zig28
30 files changed, 738 insertions, 104 deletions
diff --git a/build.zig.zon b/build.zig.zon
index 35dad72c..5542568c 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .pardes,
- .version = "0.0.11",
+ .version = "0.0.12",
.dependencies = .{
// 9P sessions and transports live in the published cloud9 package, so
// the manifest pins the build: `zig build` fetches this commit into
diff --git a/docs/config.md b/docs/config.md
index 1967a158..23355b06 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -36,7 +36,7 @@ it is open. With no file there yet, the pane is named for it, empty, and
Save writes it, making its directory first. `DumpConfig` opens a
`+DumpConfig` pane with every live setting, each line the word that sets
it and its value (`Shell /bin/bash`, `PanelSlide off`, `LocationsConfig …`)
-and `unsupported` for one the frontend cannot show. After a blank line
+and a `# Lift unsupported` comment for one the frontend cannot show. After a blank line
come what is in effect but set by no word, such as the last shell spawned
and the font in use, and the startup path (a right click opens it), each a
`#` line. A line starting with `#` is a comment wherever a line runs (the
diff --git a/docs/fs.md b/docs/fs.md
index 5c952403..7fa33a02 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -214,7 +214,10 @@ go. An `exec` line's blanks at either end are trimmed.
is the home directory ($HOME, else the passwd entry's; `~user` that
user's) here and wherever a path is typed (`name`, `Save`, `ThemeFile`,
`DumpDir`, `Restore`, `pardes '~/x'`), even beside a file named `~`: write
- `./~` for that.
+ `./~` for that. A path to no file is a miss, as a search that finds
+ nothing is: said on the message row and logged as an `err`, the write
+ still answered. A file that is there but will not open (no permission to
+ read it, say) fails the write, with why.
- `@p<serial>:<addr>` addresses a pane by serial, a terminal's logical lines
too.
- a directory types `ls` into a terminal idle there, else opens one there.
@@ -241,7 +244,10 @@ A line written to `exec` is a middle click:
nothing, silently, here and in every ctl.
- the language server's words ask about a file pane's text at its cursor
(set it with `addr` and `dot=addr` first): `Hover` fills `+Hover`,
- `Rename new` renames the symbol everywhere and lists the places,
+ `Rename new` renames the symbol in the file and says how many ranges it
+ changed (on the message row, and so in /log) without listing them; one
+ that finds nothing to rename fails; a rename the server spreads over
+ other files lists them in `+Search`,
`Diagnostics` and `Symbols` list the file's in `+Search`, `Lspinfo` says
which server serves the file and its state, and `Lspwhy` narrates the
last query step by step (in `+Lsp`), to tell why it found nothing. The
@@ -313,7 +319,7 @@ Writes take settings and session builtins (`scope = .session` in
write is answered, then **every connection is hung up**: dial again, and
restart a 9ns mount. The new log has a `new` per pane, `restore <path>`,
then `restored <old> <new>` per pane and `restoredcol <old> <new>` per
- column. Undo history and REPL bindings are not restored; a command pane
+ column. Undo history, the jump list and REPL bindings are not restored; a command pane
comes back showing how it ended (`exit ?` if it was running) and does not
run again.
- `Kill [word...]` (above), `Mount name dial`, `Unmount name`, `Theme x`,
@@ -405,7 +411,9 @@ A terminal's body is its history as plain text in logical lines (wrapped
rows joined), frozen per open; writing it sends input to the child as typed
keys, never a paste: no bracketed-paste marks around it, even when the
program asked for them, so a newline in it is Enter. A PDF's
-body is the text layer of the page shown; images and PDFs take no write
+body is the text layer of the page shown, read-only: it is no text of the
+pane's, so `addr`, `data`, `dot` and a `file:<addr>` look do not address
+it (a PDF's `:<n>` is its page); images and PDFs take no write
(`this pane has no text`).
**`sel`** reads the selected text; a write replaces it. **`errors`** is
@@ -555,7 +563,9 @@ flag is `dirty`; a `+New` scratch reads 1 but holds up nothing under 100
bytes.
The writes of one open of `data`, `xdata` or `body` are one undo step (so a
-multi-line `printf` is one). To make a loop of opens one step: `echo 1 >
+multi-line `printf` is one), as long as no other client or keystroke edits
+the pane between them: such an edit ends the step, and the open's next
+write starts another. To make a loop of opens one step: `echo 1 >
mark` (a point now), `echo 0 > mark`, the writes, `echo 1 > mark`.
### event
@@ -683,9 +693,10 @@ become spaces.
- `/commands`: `Word [arg] root|pane|both [values] -- sentence`, one a line,
generated from the builtin registry (`both`: Edit, at the active pane from
the root).
-- `/recent`: up to 200 files, most recent first, `open <path>` or `closed
- <path>`; kept in `$XDG_STATE_HOME/pardes/recent`. `Recent` shows them in a
- pane; a look at a row reopens the file at its last dot.
+- `/recent`: up to 200 files, PDFs and images, most recent first, `open
+ <path>` or `closed <path>`; kept in `$XDG_STATE_HOME/pardes/recent`.
+ `Recent` shows them in a pane, an open one at its dot now and a closed
+ one at its last (a PDF's is its page); a look at a row reopens it there.
- `/status`: `pid`, `version`, `panes`.
- `/os/`: existing regular files take read, write and truncation to zero;
create, remove, rename and metadata changes are refused; ownership is
diff --git a/docs/tags.md b/docs/tags.md
index 43dc6686..9368a60d 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -16,7 +16,7 @@ pane's directory. Over 9P the tags are `/tag`, `/col/<n>/tag` and
- Column: `New Tty Find Grep Joincol Delcol`.
- File pane: `Save Tty Collapse Del`; a source file with a grammar adds
`TreeContext`, a result list `LocationsConfig`. PDF: `manual.pdf [1/12]
- Tty Del PdfSections PdfTint Collapse`.
+ Tty PdfSections PdfTint Collapse Del`.
- Terminal: `Tty+bash Save Mode Filter Collapse Del`. `Tty+bash` is one word
for `Tty bash`, opening another terminal on that shell (only `Tty` reads a
`+` so). `Mode` cycles raw terminal input, normal mode and insert mode.
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 014744df..ae796a4d 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,7 +1,25 @@
# Changelog
-## Unreleased
+## 0.0.12
+- A selection deep in a very long line no longer slows every frame: a 5 MB
+ line with the selection at its end redraws in milliseconds, not seconds.
+- `Joincol` is refused when the joined column cannot give each pane its tag
+ and 2 rows. Save on a PDF or image pane is refused. A builtin made for
+ one kind of pane (Petscii, PdfTint, Togglettymode, …) is refused on any
+ other; `Mode` and `TreeContext` say why when they cannot act.
+- After `ThemeFile`, `/ctl`, DumpConfig and a Dump read `ThemeFile <path>`.
+ DumpConfig's unsupported settings are `# … unsupported` comments, so the
+ whole report writes back to `/ctl`. Every bad setting value names what
+ the setting takes.
+- A refused write to `dot` changes nothing; an edit that changes nothing
+ makes no undo step; the jump list never holds one place twice in a row;
+ `Jumplist` refills its one `+Jumps`; a look miss cuts a long path in its
+ middle.
+- A look past a PDF's last page is a miss; PdfSections on a PDF with no
+ outline says so; a Rename that finds nothing says so and fails; a theme
+ file error names its path; Recent keeps PDFs and images, and an open
+ file's row is where its dot is now.
- Over a program that tracks the mouse, B2 and B3 are pardes's execute and
look again, sweeps and chords included; Shift-B2 and Shift-B3 go to the
program as its buttons 2 and 3. B1 and the wheel stay the program's, and
diff --git a/src/File.zig b/src/File.zig
index 78c84287..a89db613 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -352,11 +352,18 @@ pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: us
return from +| rawAtDisplay(line_text[from..], display_col);
}
+/// The display columns from `from_raw` to `to_raw` on a line. Widths add
+/// (a tab is `tab_width` wherever it stands), so only the text between
+/// the two is measured, never the line from its start: a selection a
+/// million bytes into a line draws as fast as one at its start.
pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 {
- const from_display = rawDisplayCol(line_text, from_raw);
- const to_display = rawDisplayCol(line_text, to_raw);
- if (to_display >= from_display) return @intCast(to_display - from_display);
- return -@as(i32, @intCast(from_display - to_display));
+ const lo = @min(from_raw, to_raw);
+ const hi = @max(from_raw, to_raw);
+ const a = modal.graphemeStart(line_text, @min(lo, line_text.len));
+ const b = modal.graphemeStart(line_text, @min(hi, line_text.len));
+ const past = (hi -| line_text.len) - (lo -| line_text.len);
+ const width: usize = displayWidth(line_text[a..b]) +| past;
+ return if (to_raw >= from_raw) @intCast(width) else -@as(i32, @intCast(width));
}
pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 {
diff --git a/src/Messages.zig b/src/Messages.zig
index 3eeb8c92..a316377a 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -876,7 +876,7 @@ test "a builtin announces itself on the message row, and Verbose silences it" {
// A value it does not take is said, in place of the announcement.
const wrapped = p.settings.wrap;
try std.testing.expect(p.executeBuiltinLine(p.active, "Wrap maybe"));
- try std.testing.expectEqualStrings("Wrap: takes on or off", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqualStrings("Wrap: takes on, off", pane.msg[0..pane.msg_len]);
try std.testing.expectEqual(wrapped, p.settings.wrap);
// The notice list is what the paint pass reads, so the announcement has to
diff --git a/src/Output.zig b/src/Output.zig
index 01797cbe..4aa0c021 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -606,17 +606,22 @@ pub fn openJumps(p: *Pardes, id: usize) !void {
else
try out.writer.print("{s}:{d}:{d} {s}\n", .{ loc, j.line, j.col, what[0..cut] });
}
- const content = try out.toOwnedSlice();
- try openStepped(p, id, .{ .cmd = .Jumplist }, content);
+ try refillOrOpen(p, id, .{ .cmd = .Jumplist }, try out.toOwnedSlice());
}
/// The recent list as rows: `<path>:<line>:<col> open|closed`, most recent
/// first; a look at a row opens the file there, as a +Jumps row does.
pub fn recentText(p: *Pardes, w: *std.Io.Writer, look_rows: bool) !void {
- for (p.recent.entries.items) |e| {
+ for (p.recent.entries.items) |stored| {
+ // An open one's row is where it is now, not where it last closed.
+ var e = stored;
var is_open = false;
- for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output == null and std.mem.eql(u8, f.path, e.path)) {
+ for (p.panes) |slot| if (slot) |pane| if (Pardes.recentPlace(pane)) |d| if (std.mem.eql(u8, d.path, e.path)) {
is_open = true;
+ e.line = d.line;
+ e.col = d.col;
+ e.aline = d.aline;
+ e.acol = d.acol;
};
const state: []const u8 = if (is_open) "open" else "(closed)";
if (!look_rows) {
@@ -699,12 +704,17 @@ pub fn openRecent(p: *Pardes, id: usize) !void {
var out: std.Io.Writer.Allocating = .init(p.gpa);
errdefer out.deinit();
try recentText(p, &out.writer, true);
- const content = try out.toOwnedSlice();
+ try refillOrOpen(p, id, .{ .cmd = .Recent }, try out.toOwnedSlice());
+}
+
+/// The one stepped output from `from`, filled again with `content` when it
+/// is there (Recent, Jumplist), or opened when it is not.
+fn refillOrOpen(p: *Pardes, id: usize, from: Origin, content: []u8) !void {
for (p.panes, 0..) |slot, i| {
const rp = slot orelse continue;
const rf = if (rp.file) |*f| f else continue;
const o = rf.output orelse continue;
- if (!std.meta.eql(o.from, Origin{ .cmd = .Recent })) continue;
+ if (!std.meta.eql(o.from, from)) continue;
File.setContent(p, rf, content);
resetBody(p, rp);
p.fs.refilled = rp.serial;
@@ -715,7 +725,7 @@ pub fn openRecent(p: *Pardes, id: usize) !void {
look.armLookWalk(p, i);
return;
}
- try openStepped(p, id, .{ .cmd = .Recent }, content);
+ try openStepped(p, id, from, content);
}
pub fn openThemes(p: *Pardes, id: usize) !void {
@@ -870,6 +880,7 @@ pub fn openConfig(p: *Pardes, id: usize) !void {
.startup_config_path = p.opts.startup_config_path,
.platform = @tagName(pardes.platform),
.theme_name = p.theme().name,
+ .theme_file = pardes.colors.loadedThemeFile(p),
.compiled_default_shell = config.defaultShell(),
.hover_delay_frames = config.look_preview_delay_frames,
.native_images = p.native_images,
diff --git a/src/body_layer.zig b/src/body_layer.zig
index 8e7cd030..914694e7 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -83,6 +83,17 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [
}
}
+/// Whether `col` lies past the last column its visible row shows: the last
+/// visible row of a line owns every column after its start (wrapRow), so a
+/// head millions of bytes along a long line is on that row, off the view.
+fn pastView(pane: *Pane, body: []const u8, line: i32, row: i32, at: i32, col: i32, tw: u16) bool {
+ const text = if (pane.file != null) panes.File.sourceLine(pane, line) else modal.lineSlice(body, @intCast(@max(0, row)));
+ const from: usize = @intCast(@max(0, at));
+ const to: usize = @intCast(@max(0, col));
+ if (from >= text.len) return to >= from +| tw;
+ return to >= from + panes.File.byteAtDisplay(text[from..], tw) and to < text.len;
+}
+
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 &.{};
@@ -371,14 +382,26 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
const w = pane.wrapAt(vr);
if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue;
const visible_line = modal.lineSlice(body, @intCast(vr));
- const cstart: i32 = if (w.line == bnd.lo_row)
+ // Only the visible columns are measured: a selection that
+ // starts before this row's first column starts at it, and
+ // one that ends past its last ends there.
+ const row_text = if (pane.file != null) panes.File.sourceLine(pane, w.line) else visible_line;
+ const row_at: usize = @intCast(@max(0, w.at));
+ // The byte past the row's last column, walked from its first:
+ // bounded by the view, not the line.
+ // A line that ends inside the view has no column past it.
+ const shown_to = if (row_at >= row_text.len) row_text.len else row_at + panes.File.byteAtDisplay(row_text[row_at..], tw);
+ const past_view = if (shown_to >= row_text.len) std.math.maxInt(usize) else shown_to;
+ // A row that ends before the selection starts has none of it.
+ if (w.line == bnd.lo_row and @as(usize, @intCast(@max(0, bnd.lo_col))) >= past_view) continue;
+ const cstart: i32 = if (w.line == bnd.lo_row and bnd.lo_col > w.at)
(if (pane.file != null)
panes.File.displayOffset(pane, w.line, w.at, bnd.lo_col)
else
panes.File.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx
else
vpfx;
- const cend: i32 = if (w.line == bnd.hi_row)
+ const cend: i32 = if (w.line == bnd.hi_row and @as(usize, @intCast(@max(0, bnd.hi_col))) < past_view)
(if (pane.file != null)
panes.File.displayEndOffset(pane, w.line, w.at, bnd.hi_col)
else
@@ -396,6 +419,10 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
if (primary and !preview and !hover_only) continue; // the hardware cursor IS the primary's
if (hover_only) continue; // quiet preview preserves the source ink
const cw = pane.wrapRow(sr.row, sr.col);
+ // A head off the view is measured not at all: its row is not
+ // drawn, and its column may be millions of bytes along.
+ if (cw.row < 0 or cw.row + @as(i32, pane.tag_rows) >= @as(i32, body_bottom)) continue;
+ if (pastView(pane, body, sr.row, cw.row, cw.at, sr.col, tw)) continue;
const crow = cw.row + @as(i32, pane.tag_rows);
const ccol = (if (pane.file != null)
panes.File.displayOffset(pane, sr.row, cw.at, sr.col)
@@ -447,6 +474,9 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
const ccol = if (pane.body.cur_pinned) pane.body.cur_col else @as(i32, @intCast(cur.x));
const cwp = pane.wrapRow(crow, ccol);
const prow = cwp.row + @as(i32, pane.tag_rows);
+ // Off the view, the cursor is not measured (its column may be
+ // millions of bytes along a line whose start is shown).
+ if (cwp.row >= 0 and prow < body_bottom and !pastView(pane, body, crow, cwp.row, cwp.at, ccol, tw)) {
// Files store source-byte columns; the Surface stores display
// cells, so account for every expanded tab before the cursor.
const cx = if (pane.file != null)
@@ -461,6 +491,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| {
if (cur.y + pane.tag_rows < body_bottom and cur.x < tw)
s.cursor = .{ .x = tx + cur.x, .y = body_y + cur.y };
diff --git a/src/builtins.zig b/src/builtins.zig
index 6688d05f..cb588ba3 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -38,6 +38,29 @@ pub const capabilities: config.Runtime.Capabilities = .{
/// ctl; pardes's root `ctl` takes the session builtins and a pane's `ctl`
/// the pane ones. A builtin says `pub const scope: Scope = .session;`,
/// and every setting is one; the rest act on their pane.
+/// The kinds of pane a builtin may be for alone (registry.needs).
+pub const PaneKind = enum {
+ terminal,
+ pdf,
+ image,
+
+ pub fn has(kind: PaneKind, pane: *const panes.Pane) bool {
+ return switch (kind) {
+ .terminal => pane.isTerminal(),
+ .pdf => pane.hasPdf(),
+ .image => pane.image != null,
+ };
+ }
+
+ pub fn noun(kind: PaneKind) []const u8 {
+ return switch (kind) {
+ .terminal => "a terminal",
+ .pdf => "a PDF",
+ .image => "an image",
+ };
+ }
+};
+
pub const Scope = enum { pane, session };
pub const Ctx = struct {
@@ -181,6 +204,15 @@ pub const registry = struct {
/// `Msg`, a setting's value), or would ask for it at a prompt (`Find`):
/// a ctl refuses it bare before any line of the write runs. A builtin
/// says `pub const requires_arg = true;` beside `takes_arg`.
+ /// The one kind of pane a builtin means something on, or null for any:
+ /// it says `pub const needs: PaneKind = .terminal;`, and on any other
+ /// pane it is refused (exec.runBuiltinFrom), as Repl and Edit are.
+ pub fn needs(b: Builtin()) ?PaneKind {
+ inline for (manualBuiltinList(), 0..) |T, i|
+ if (@intFromEnum(b) == i) return if (@hasDecl(T, "needs")) T.needs else null;
+ return null;
+ }
+
pub fn requiresArg(b: Builtin()) bool {
inline for (manualBuiltinList(), 0..) |T, i|
if (@intFromEnum(b) == i) return @hasDecl(T, "requires_arg") and T.requires_arg;
@@ -869,7 +901,7 @@ pub const TreeContext = struct {
pub fn run(c: Ctx) void {
if (comptime !enabled) unreachable;
- if (!panes.File.supportsContext(c.pane)) return;
+ if (!panes.File.supportsContext(c.pane)) return c.p.reportFailure(c.id, "TreeContext: only a source file the parser knows has context");
const file = &c.pane.file.?;
const arg = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
const on = if (arg.len == 0) !file.tree_context else if (std.mem.eql(u8, arg, "on")) true else if (std.mem.eql(u8, arg, "off")) false else {
@@ -889,6 +921,7 @@ pub const TreeContext = struct {
/// Fit the PDF page to the pane by width or by height, flipping between them.
pub const PdfFit = struct {
+ pub const needs: PaneKind = .pdf;
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
if (comptime enabled) apply(c) else unreachable;
@@ -900,6 +933,7 @@ pub const PdfFit = struct {
/// Tint the PDF page to the theme, or show it as printed, flipping between them.
pub const PdfTint = struct {
+ pub const needs: PaneKind = .pdf;
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
if (comptime enabled) apply(c) else unreachable;
@@ -911,6 +945,7 @@ pub const PdfTint = struct {
/// List the PDF's sections in a pane, each a place to go.
pub const PdfSections = struct {
+ pub const needs: PaneKind = .pdf;
pub const output: OutputTraits = .{ .name = config.pdf_sections_buffer, .steps = true, .locations = true };
pub const enabled = pardes.pdf_enabled;
pub fn run(c: Ctx) void {
@@ -923,6 +958,7 @@ pub const PdfSections = struct {
/// glyph art over the host's pixels
pub const Petscii = struct {
+ pub const needs: PaneKind = .image;
pub fn run(c: Ctx) void {
if (c.pane.image) |*state| panes.Image.toggleGlyphArt(state);
}
@@ -930,6 +966,7 @@ pub const Petscii = struct {
/// the C64 palette or the terminal's own 16
pub const Palette = struct {
+ pub const needs: PaneKind = .image;
pub fn run(c: Ctx) void {
if (c.pane.image) |*state| panes.Image.togglePalette(state);
}
@@ -937,6 +974,7 @@ pub const Palette = struct {
/// add the printable ASCII bitmaps to the matcher's glyph set
pub const Ascii = struct {
+ pub const needs: PaneKind = .image;
pub fn run(c: Ctx) void {
if (c.pane.image) |*state| panes.Image.toggleAscii(state);
}
@@ -986,6 +1024,11 @@ pub const ClipReplace = struct {
pub const Save = struct {
pub const takes_arg = true;
pub fn run(c: Ctx) void {
+ // A PDF or an image shows a file it does not hold as text: there
+ // is nothing of its own to write, bare or to a path. Said, never a
+ // silent success.
+ if (c.pane.file == null and !c.pane.isTerminal())
+ return c.p.reportFailure(c.id, if (c.pane.hasPdf()) "Save: a PDF pane has no text to save" else "Save: an image pane has no text to save");
const path = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
if (path.len > 0) return exec.saveTo(c.p, c.id, path);
if (c.pane.file) |file| if (file.output == null) return exec.saveFile(c.p, c.id);
@@ -1052,8 +1095,8 @@ pub const Del = struct {
/// Recolour a terminal's program colours to the theme, or show them as sent, flipping between them.
pub const Filter = struct {
+ pub const needs: PaneKind = .terminal;
pub fn run(c: Ctx) void {
- if (!c.pane.isTerminal()) return;
c.pane.tty_filter = !c.pane.tty_filter;
}
};
@@ -1061,12 +1104,14 @@ pub const Filter = struct {
/// Cycle the pane's input modes: terminals add raw tty mode to normal/insert.
pub const Mode = struct {
pub fn run(c: Ctx) void {
+ if (c.pane.file == null and !c.pane.isTerminal()) return c.p.reportFailure(c.id, "Mode: only a text or terminal pane has modes");
c.p.cycleMode(c.id);
}
};
/// Switch this terminal between raw program input and normal editor mode.
pub const Togglettymode = struct {
+ pub const needs: PaneKind = .terminal;
pub fn run(c: Ctx) void {
c.p.toggleTty(c.id);
}
@@ -1095,8 +1140,11 @@ pub const Edit = struct {
}
if (res.ops.len > 0) {
const new = sam.apply(c.p.gpa, f.content, res.ops) catch |err| return c.p.reportError(c.id, "Edit", err);
- panes.File.pushUndo(c.p, c.pane);
- panes.File.setContent(c.p, f, new);
+ // `s/a/a/` changes nothing: no undo step, no new revision.
+ if (std.mem.eql(u8, new, f.content)) c.p.gpa.free(new) else {
+ panes.File.pushUndo(c.p, c.pane);
+ panes.File.setContent(c.p, f, new);
+ }
}
ninep_pane.setDot(c.pane, sam.moveDot(res.dot, res.ops));
if (res.printed.len > 0) _ = ninep_pane.appendErrors(c.p, c.id, res.printed);
@@ -1271,6 +1319,9 @@ pub const Joincol = struct {
// The keyboard's column joins the one to its right; the last has none.
const col = c.p.keyboardColumn(c.p.active) orelse return;
if (col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right");
+ // As Newcol is refused: a column that could not give each pane its
+ // tag and 2 rows would leave a layout `size` refuses as it is.
+ if (!layout.joinFits(c.p, col)) return c.p.reportFailure(c.id, "Joincol: no space: the joined column cannot give each pane its tag and 2 rows");
layout.joinCol(c.p, col);
}
};
diff --git a/src/colors.zig b/src/colors.zig
index c78043cc..b75cdc3d 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -735,6 +735,14 @@ pub fn requestThemeFile(p: *Pardes, id: usize, argument: []const u8) void {
p.emit(.{ .theme_file = .{ .generation = generation, .on = true } });
}
+/// The .zon a ThemeFile loaded, while it is the theme in effect: what
+/// /ctl and DumpConfig say in place of `Theme <name>`, since the name
+/// inside the file is no theme `Theme` knows.
+pub fn loadedThemeFile(p: *const Pardes) ?[]const u8 {
+ const path = p.theme_file_path.get();
+ return if (p.custom_theme != null and path.len > 0) path else null;
+}
+
pub const ThemeFileRequest = struct {
path: []const u8,
pane: u8,
@@ -751,7 +759,16 @@ pub fn themeFileRequest(p: *const Pardes, generation: u32) ?ThemeFileRequest {
pub fn failThemeFile(p: *Pardes, generation: u32, err: anyerror) void {
const request = themeFileRequest(p, generation) orelse return;
- p.reportError(request.pane, "theme file", err);
+ themeFileError(p, request, err);
+}
+
+/// A theme file's failure, said with its path: `theme file x.zon: file
+/// not found`, not which file left to guess.
+fn themeFileError(p: *Pardes, request: ThemeFileRequest, err: anyerror) void {
+ var said: [300]u8 = undefined;
+ var cut: [200]u8 = undefined;
+ const path = @import("ninep/tree.zig").fitErr(request.path, &cut);
+ p.reportError(request.pane, std.fmt.bufPrint(&said, "theme file {s}", .{path}) catch "theme file", err);
}
pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool {
@@ -759,23 +776,23 @@ pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bo
@setEvalBranchQuota(10000);
const request = themeFileRequest(p, generation) orelse return false;
if (bytes.len > 1024 * 1024) {
- p.reportError(request.pane, "theme file", error.ThemeFileTooLarge);
+ themeFileError(p, request, error.ThemeFileTooLarge);
return false;
}
const source = p.gpa.dupeZ(u8, bytes) catch |err| {
- p.reportError(request.pane, "theme file", err);
+ themeFileError(p, request, err);
return false;
};
defer p.gpa.free(source);
var diagnostics: std.zon.parse.Diagnostics = .{};
defer diagnostics.deinit(p.gpa);
const parsed = std.zon.parse.fromSliceAlloc(Theme, p.gpa, source, &diagnostics, .{}) catch |err| {
- p.reportError(request.pane, "theme file", err);
+ themeFileError(p, request, err);
return false;
};
if (parsed.name.len == 0 or parsed.name.len > 255) {
std.zon.parse.free(p.gpa, parsed);
- p.reportError(request.pane, "theme file", error.InvalidThemeName);
+ themeFileError(p, request, error.InvalidThemeName);
return false;
}
@@ -904,6 +921,16 @@ pub fn setThemeIndex(p: *Pardes, index: usize) void {
invalidateThemeDependentRasters(p);
}
+test "a theme file that cannot be read is named in the failure" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ requestThemeFile(p, 0, "/nowhere/mine.zon");
+ failThemeFile(p, p.theme_file_generation, error.FileNotFound);
+ const pane = p.panes[0].?;
+ try std.testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "theme file /nowhere/mine.zon: ") != null);
+}
+
test "ThemeFile parses ZON atomically and Theme returns to the compiled ring" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{
diff --git a/src/config.zig b/src/config.zig
index 592281d3..6bb4d6e4 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -1242,6 +1242,22 @@ pub const Runtime = struct {
};
}
+ /// What a setting takes, for a refusal of a bad value to name: its
+ /// words (`choices`), else its range or the kind of thing it takes.
+ pub fn takes(action: Action) ?[]const u8 {
+ return choices(action) orelse switch (action) {
+ .inactive_dim, .window_opacity, .window_blur => "0-100 (a percentage)",
+ .grip_width => "50-300 (a percentage)",
+ .tagline_size => std.fmt.comptimePrint("{d}-{d} (a percentage)", .{ tagline_percent_min, tagline_percent_max }),
+ .message_ms => std.fmt.comptimePrint("0-{d} (milliseconds)", .{message_ms_max}),
+ .shell => "a path of at most 255 bytes; bare, the login shell",
+ .dump_dir => std.fmt.comptimePrint("a path of at most {d} bytes; bare, the default", .{limits.host_path_cap}),
+ .shader => "a Shadertoy file's path, or off",
+ .font => "a name or path, :8 to :72 after it (Mono:14)",
+ else => null,
+ };
+ }
+
/// The value a choice word names, or bare, the one after `now`; null for
/// a word it does not take.
fn stepped(comptime T: type, now: T, argument: ?[]const u8) ?T {
@@ -1267,6 +1283,9 @@ pub const Runtime = struct {
startup_config_path: ?[]const u8,
platform: []const u8,
theme_name: []const u8,
+ /// The ThemeFile in effect, said as `ThemeFile <path>` in place
+ /// of the theme's name.
+ theme_file: ?[]const u8 = null,
compiled_default_shell: []const u8,
hover_delay_frames: ?u16,
/// Where dumps go when `DumpDir` is unset; empty without a host
@@ -1288,8 +1307,8 @@ pub const Runtime = struct {
}
/// Each setting line reads as the command that would set it: the word,
- /// then the value you would type after it (`unsupported` where this
- /// shell has no such setting). No colons, no units: `WindowOpacity 70`
+ /// then the value you would type after it (a `# Word unsupported`
+ /// comment where this shell has no such setting). No colons, no units: `WindowOpacity 70`
/// is both what DumpConfig prints and what you write back. What is in
/// effect but set by no word follows, after a blank line.
pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void {
@@ -1297,7 +1316,7 @@ pub const Runtime = struct {
for (settings) |setting| switch (setting.action) {
.toggle => |field| {
if (!setting.enabled(context.capabilities)) {
- try out.print("{s} unsupported\n", .{setting.word});
+ try out.print("# {s} unsupported\n", .{setting.word});
continue;
}
const value = switch (field) {
@@ -1305,22 +1324,25 @@ pub const Runtime = struct {
};
try out.print("{s} {s}\n", .{ setting.word, onOff(value) });
},
- .theme => try out.print("{s} {s}\n", .{ setting.word, context.theme_name }),
+ .theme => if (context.theme_file) |path|
+ try out.print("ThemeFile {s}\n", .{path})
+ else
+ try out.print("{s} {s}\n", .{ setting.word, context.theme_name }),
.window_opacity => {
if (setting.enabled(context.capabilities))
try out.print("{s} {d}\n", .{ setting.word, state.window_opacity })
else
- try out.print("{s} unsupported\n", .{setting.word});
+ try out.print("# {s} unsupported\n", .{setting.word});
},
.window_blur => {
if (setting.enabled(context.capabilities))
try out.print("{s} {d}\n", .{ setting.word, state.window_blur })
else
- try out.print("{s} unsupported\n", .{setting.word});
+ try out.print("# {s} unsupported\n", .{setting.word});
},
.dump_dir => {
const dir = if (state.dump_dir.len > 0) state.dump_dir.get() else context.default_dump_dir;
- try out.print("{s} {s}\n", .{ setting.word, if (dir.len > 0) dir else "(none)" });
+ if (dir.len > 0) try out.print("{s} {s}\n", .{ setting.word, dir }) else try out.print("# {s} (none)\n", .{setting.word});
},
.message_ms => |which| try out.print("{s} {d}\n", .{ setting.word, switch (which) {
inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms"),
@@ -1330,28 +1352,34 @@ pub const Runtime = struct {
try out.print("{s} {s}\n", .{ setting.word, if (chosen.len == 0) context.compiled_default_shell else chosen });
},
.font => if (!setting.enabled(context.capabilities))
- try out.print("{s} unsupported\n", .{setting.word})
+ try out.print("# {s} unsupported\n", .{setting.word})
else {
// As `Font` takes it, `name:size`: the one asked for, else
// the one in use.
const f = &state.font;
const name = if (f.requested_path.get().len > 0) f.requested_path.get() else if (f.requested_name.get().len > 0) f.requested_name.get() else f.effective_name.get();
+ // None known yet (no font loaded): a comment, as `Font`
+ // alone is no line a ctl takes.
+ if (name.len == 0) {
+ try out.print("# {s} (none yet)\n", .{setting.word});
+ continue;
+ }
try out.print("{s} {s}", .{ setting.word, name });
if (f.requested_size_hundredths != 0) try out.print(":{d}.{d:0>2}", .{ f.requested_size_hundredths / 100, f.requested_size_hundredths % 100 });
try out.writeByte('\n');
},
.tagline_size => if (!context.capabilities.tagline_font_size)
- try out.print("{s} unsupported\n", .{setting.word})
+ try out.print("# {s} unsupported\n", .{setting.word})
else
try out.print("{s} {d}\n", .{ setting.word, state.font.tagline_percent }),
// Each a switch of its own, the one in use on.
.transition => |transition| if (!setting.enabled(context.capabilities))
- try out.print("{s} unsupported\n", .{setting.word})
+ try out.print("# {s} unsupported\n", .{setting.word})
else
try out.print("{s} {s}\n", .{ setting.word, onOff(state.panel_transition == transition) }),
.scene => |effect| {
if (!setting.enabled(context.capabilities)) {
- try out.print("{s} unsupported\n", .{setting.word});
+ try out.print("# {s} unsupported\n", .{setting.word});
continue;
}
const level = state.post.level(effect);
@@ -1359,7 +1387,7 @@ pub const Runtime = struct {
},
.shader => {
if (!setting.enabled(context.capabilities)) {
- try out.print("{s} unsupported\n", .{setting.word});
+ try out.print("# {s} unsupported\n", .{setting.word});
continue;
}
var files: usize = 0;
@@ -1372,17 +1400,20 @@ pub const Runtime = struct {
.shader_animation => if (setting.enabled(context.capabilities))
try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) })
else
- try out.print("{s} unsupported\n", .{setting.word}),
+ try out.print("# {s} unsupported\n", .{setting.word}),
.choice => |which| switch (which) {
inline else => |field| try out.print("{s} {s}\n", .{ setting.word, @tagName(@field(state, @tagName(field))) }),
},
.lift => if (setting.enabled(context.capabilities))
try out.print("{s} {s}\n", .{ setting.word, @tagName(state.lift) })
else
- try out.print("{s} unsupported\n", .{setting.word}),
+ try out.print("# {s} unsupported\n", .{setting.word}),
.motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }),
.inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }),
- .grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }),
+ .grip_width => if (setting.enabled(context.capabilities))
+ try out.print("{s} {d}\n", .{ setting.word, state.grip_width })
+ else
+ try out.print("# {s} unsupported\n", .{setting.word}),
};
if (context.locations.len > 0) try out.print("LocationsConfig {s}\n", .{context.locations});
@@ -1805,12 +1836,16 @@ pub const Runtime = struct {
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Look hover delay: off\n") != null);
try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Native images: off\n") != null);
- // A setting this shell does not have reads so, as a line to write.
- try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures unsupported\n") != null);
- // Every line before the blank one is a ctl word and its value.
+ // A setting this shell does not have reads so, as a comment.
+ try std.testing.expect(std.mem.indexOf(u8, defaults_report, "# Ligatures unsupported\n") != null);
+ // Every line before the blank one is a ctl word and its value, or a
+ // comment naming one.
const words = defaults_report[0..std.mem.indexOf(u8, defaults_report, "\n\n").?];
var lines = std.mem.splitScalar(u8, words, '\n');
- while (lines.next()) |line| try std.testing.expect(find(line[0..std.mem.indexOfScalar(u8, line, ' ').?]) != null);
+ while (lines.next()) |line| {
+ const word = if (std.mem.startsWith(u8, line, "# ")) line[2..] else line;
+ try std.testing.expect(find(word[0..std.mem.indexOfScalar(u8, word, ' ').?]) != null);
+ }
// ...and every one after it a `#` line, which runs as nothing.
var info = std.mem.splitScalar(u8, std.mem.trimEnd(u8, defaults_report[words.len + 2 ..], "\n"), '\n');
while (info.next()) |line| try std.testing.expect(std.mem.startsWith(u8, line, "# "));
diff --git a/src/dump.zig b/src/dump.zig
index 8e767240..4194f628 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -918,7 +918,7 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
&.{};
errdefer if (raw.len > 0) gpa.free(raw);
if (comptime pdf_enabled) {
- if (pardes.panes.Pdf.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| {
+ if (pardes.panes.Pdf.openPane(p, i, im.path, src.scroll + 1, .last) catch null) |restored| {
if (raw.len > 0) gpa.free(raw);
raw = &.{};
restored.cols = @max(1, src.cols);
diff --git a/src/exec.zig b/src/exec.zig
index 5a421f19..c673aab2 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -959,6 +959,12 @@ pub fn runBuiltinFrom(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?
p.fs.unlogged = false;
pane.msg_announcement = true;
}
+ // A word for one kind of pane, on another: refused with why, not a
+ // silent nothing (Palette on a file, Filter on a PDF).
+ if (builtins.registry.needs(b)) |kind| if (p.panes[id] == null or !kind.has(pane)) {
+ var said: [96]u8 = undefined;
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: only {s} pane takes it", .{ @tagName(b), kind.noun() }) catch "only another kind of pane takes it");
+ };
const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg, .from_body = from_body };
builtins.registry.dispatch(b, c);
}
@@ -1009,12 +1015,13 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
return p.reportFailure(p.active, std.fmt.bufPrint(&text, "Shell: {s}", .{refused}) catch "Shell: no such shell");
}
}
- if (!p.settings.apply(setting, want) and p.announce) p.reportFailure(p.active, "Shell: does not take that value");
+ if (!p.settings.apply(setting, want) and p.announce) p.reportFailure(p.active, "Shell: takes " ++ comptime config.Runtime.takes(.shell).?);
},
+ // Said with what it takes: its words, its range.
else => if (!p.settings.apply(setting, arg) and p.announce) {
- var text: [96]u8 = undefined;
- const takes = if (setting.action == .toggle) "takes on or off" else "does not take that value";
- p.reportFailure(p.active, std.fmt.bufPrint(&text, "{s}: {s}", .{ setting.word, takes }) catch takes);
+ var text: [160]u8 = undefined;
+ const why = if (config.Runtime.takes(setting.action)) |values| std.fmt.bufPrint(&text, "{s}: takes {s}", .{ setting.word, values }) else std.fmt.bufPrint(&text, "{s}: does not take that value", .{setting.word});
+ p.reportFailure(p.active, why catch "does not take that value");
},
}
if (p.settings.colors != previous_colors) {
diff --git a/src/layout.zig b/src/layout.zig
index 4aebcf28..a2211dfd 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -360,13 +360,38 @@ pub fn noteColumn(p: *Pardes, what: []const u8, serial: u32) void {
pub fn joinCol(p: *Pardes, c: usize) void {
if (c + 1 >= p.ncol) return;
const dst = c + 1;
+ joinPanes(p, c, dst);
+ dropColumn(p, c);
+}
+
+/// Whether column `c` joined into the one on its right leaves every pane
+/// its tag and 2 rows (minimaFit), tried in place and undone: nothing is
+/// dropped or logged for a join that does not fit.
+pub fn joinFits(p: *Pardes, c: usize) bool {
+ if (c + 1 >= p.ncol) return true;
+ const dst = c + 1;
+ const n = p.col_n[c];
+ const had = p.col_n[dst];
+ const weights = .{ p.col_weight[c], p.col_weight[dst] };
+ const panes_c = p.col_panes[c];
+ joinPanes(p, c, dst);
+ const fits = minimaFit(p);
+ p.col_n[dst] = had;
+ p.col_n[c] = n;
+ p.col_panes[c] = panes_c;
+ p.col_weight[c] = weights[0];
+ p.col_weight[dst] = weights[1];
+ compute(p);
+ return fits;
+}
+
+fn joinPanes(p: *Pardes, c: usize, dst: usize) void {
for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k];
p.col_n[dst] += p.col_n[c];
p.col_n[c] = 0;
// The width goes right, where the panes went.
p.col_weight[dst] +|= p.col_weight[c];
p.col_weight[c] = 0;
- dropColumn(p, c);
}
/// Put new pane `id` in column `c` as acme's makenewwindow does
diff --git a/src/look.zig b/src/look.zig
index afb5b760..36b91545 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1218,17 +1218,29 @@ pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) v
/// A look that found nothing: said on the message row, logged as an `err`
/// record, and marked so that /look reads back nothing (ninep/ctl.zig run).
-/// The most of a looked-at path or text a miss repeats; past it, cut on a
-/// character boundary and ended with `…`.
+/// The most of a looked-at path or text a miss repeats; past it, its
+/// middle gives up to `…` (the Rerror cutter's rule), so the file's name at
+/// the end stays.
const miss_path_cap = 256;
fn missText(text: []const u8, buf: *[miss_path_cap + "…".len]u8) []const u8 {
- if (text.len <= miss_path_cap) return text;
- var n: usize = miss_path_cap;
- while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1;
- @memcpy(buf[0..n], text[0..n]);
- @memcpy(buf[n..][0.."…".len], "…");
- return buf[0 .. n + "…".len];
+ return pardes.ctlfs.fitErr(text, buf[0..miss_path_cap]);
+}
+
+test "a miss names a long path by its start and its end, its middle given up to …" {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ const long = "/home/someone/" ++ "deep/" ** 80 ++ "the-file.zig";
+ const said = missText(long, &cut);
+ try std.testing.expect(said.len <= miss_path_cap);
+ try std.testing.expect(std.mem.startsWith(u8, said, "/home/someone/"));
+ try std.testing.expect(std.mem.endsWith(u8, said, "/the-file.zig"));
+ try std.testing.expect(std.mem.indexOf(u8, said, "…") != null);
+ try std.testing.expectEqualStrings("short/path.txt", missText("short/path.txt", &cut));
+}
+
+fn missedPage(p: *Pardes, id: usize, path: []const u8, page: usize) void {
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ missed(p, id, "{s} has no page {d}", .{ missText(path, &cut), page });
}
fn missedLine(p: *Pardes, id: usize, path: []const u8, line: anytype) void {
@@ -1402,7 +1414,8 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
const pane = p.panes[target] orelse return;
const f = pane.file orelse {
if (by_serial and pane.isTerminal()) return lookTerminalAddress(p, id, target, expr);
- return missed(p, id, "{s} is no text to address", .{path[0..@min(path.len, 64)]});
+ var cut: [miss_path_cap + "…".len]u8 = undefined;
+ return missed(p, id, "{s} is no text to address", .{missText(path, &cut)});
};
if (!by_serial and path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return;
var a: Addr = .{ .text = f.content, .lim = null, .expr = expr };
@@ -1468,6 +1481,11 @@ pub fn reopenAt(p: *Pardes, from: usize, path: []const u8, at: Spot) void {
lookAt(p, from, path);
const id = p.active;
const pane = p.panes[id] orelse return;
+ // A PDF's place is its page, the last if it has fewer now.
+ if (comptime pdf_enabled) if (pane.pdf) |*pv| if (std.mem.eql(u8, pv.path, path)) {
+ if (at.line > 0 and pv.focusLocation(p.pdf_gpa, @min(at.line, pv.page_count), 0)) panes.Pdf.resetPageChrome(pane);
+ return;
+ };
const f = if (pane.file) |*file| file else return;
if (!std.mem.eql(u8, f.path, path)) return;
// The empty "line" after a final newline is no line to land on.
@@ -1680,6 +1698,11 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void {
// `@p<serial>` names no open pane: a miss, said.
if (t.id >= MAX_PANES or p.panes[t.id] == null) return missed(p, id, "no pane {s}", .{pl.path[0..@min(pl.path.len, 32)]});
const target = p.panes[t.id].?;
+ // A PDF's page past its last is a miss, as a line past a file's
+ // end is: never the last page in its place (opening one, the
+ // same: Pdf.openPane's .refuse).
+ if (comptime pdf_enabled) if (target.pdf) |pv| if (t.at.line > pv.page_count)
+ return missedPage(p, id, pv.path, t.at.line);
const before = p.active;
clearNavigationSelection(p, target);
focusPaneLine(p, t.id, t.at, .center);
@@ -1710,8 +1733,10 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void {
if (comptime pdf_enabled) if (target.kind == .pdf) {
if (focusPaneByPath(p, target.path, target.at)) return;
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- // Said with its path and why, as an image that cannot be read is.
- _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| {
+ // Said with its path and why, as an image that cannot be read
+ // is; a page past its last is a miss, as for an open one.
+ _ = panes.Pdf.openPane(p, free, target.path, target.at.line, .refuse) catch |err| {
+ if (err == error.NoSuchPage) return missedPage(p, id, target.path, target.at.line);
var said: [miss_path_cap + 64]u8 = undefined;
var cut: [miss_path_cap + "…".len]u8 = undefined;
return p.reportError(id, std.fmt.bufPrint(&said, "look: {s}: not a PDF pardes can read", .{missText(target.path, &cut)}) catch "look: not a PDF pardes can read", err);
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index df2cfaf0..b78c46e7 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -498,6 +498,28 @@ test "regular expressions search lines as sam's do, and a search wraps" {
try testing.expectEqual(@as(u32, 5), q.panes[0].?.fs.addr.q0);
}
+test "a refused write to dot after a truncation changes nothing; a truncation with no write empties it at close" {
+ const p = try th.withFile(testing.allocator, "one\ntwo\nthree\n");
+ defer p.deinit();
+ const serial = th.serialOf(p);
+ const dot = Node.of(serial, .dot);
+ try testing.expectEqual(Status.ok, th.wr(p, dot, "2").reply.status);
+ const was = th.rd(p, dot, 0, 64).bytes;
+ var kept: [64]u8 = undefined;
+ @memcpy(kept[0..was.len], was);
+ // A shell's `echo /nomatch/ > dot`: truncate, then the refused write.
+ const h = th.call(p, .{ .tag = 1, .op = .open, .node = dot, .omode = 1 }).reply.handle;
+ _ = th.call(p, .{ .tag = 2, .op = .setattr, .node = dot, .truncate = true });
+ try testing.expectEqual(Status.err, th.call(p, .{ .tag = 3, .op = .write, .node = dot, .handle = h, .data = "/nomatch/" }).reply.status);
+ _ = th.call(p, .{ .tag = 4, .op = .release, .node = dot, .handle = h, .opened = true });
+ try testing.expectEqualStrings(kept[0..was.len], th.rd(p, dot, 0, 64).bytes);
+ // `: > dot`: the truncation alone empties it once its open goes.
+ const h2 = th.call(p, .{ .tag = 5, .op = .open, .node = dot, .omode = 1 }).reply.handle;
+ _ = th.call(p, .{ .tag = 6, .op = .setattr, .node = dot, .truncate = true });
+ _ = th.call(p, .{ .tag = 7, .op = .release, .node = dot, .handle = h2, .opened = true });
+ try testing.expectEqualStrings(" 0 0 ", th.rd(p, dot, 0, 64).bytes);
+}
+
test "a failed address leaves none, so data refuses rather than act at the last one" {
const p = try th.withFile(testing.allocator, "one\ntwo\n");
defer p.deinit();
diff --git a/src/ninep/cols.zig b/src/ninep/cols.zig
index a1fe9ecc..7afcfbd8 100644
--- a/src/ninep/cols.zig
+++ b/src/ninep/cols.zig
@@ -232,6 +232,32 @@ test "New written to a column's exec makes its scratch in the session's director
try testing.expectEqualStrings("/project/src/+New", p.panes[p.active].?.file.?.path);
}
+test "Joincol is refused when the joined column cannot give each pane its tag and 2 rows" {
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 12 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const ctl = @intFromEnum(tree.TopFile.ctl);
+ const focus = @intFromEnum(tree.TopFile.focus);
+ // Two panes in the first column, then a second column of two.
+ const a = try th.newPane(p);
+ _ = a;
+ try testing.expectEqual(tree.Status.ok, th.wr(p, ctl, "Newcol\n").reply.status);
+ const b = try th.newPane(p);
+ var digits: [16]u8 = undefined;
+ _ = th.wr(p, focus, try std.fmt.bufPrint(&digits, "{d}\n", .{b}));
+ _ = try th.newPane(p);
+ try testing.expectEqual(@as(usize, 2), p.ncol);
+ try testing.expect(p.col_n[0] >= 2 and p.col_n[1] >= 2);
+ // Focus in the left column: joined, four panes want 12 rows of 10.
+ const left = p.panes[p.col_panes[0][0]].?.serial;
+ _ = th.wr(p, focus, try std.fmt.bufPrint(&digits, "{d}\n", .{left}));
+ const r = th.wr(p, ctl, "Joincol\n");
+ try testing.expectEqual(tree.Status.err, r.reply.status);
+ try testing.expect(std.mem.indexOf(u8, r.reply.ename, "tag and 2 rows") != null);
+ try testing.expectEqual(@as(usize, 2), p.ncol);
+ try testing.expect(pardes.layout.minimaFit(p));
+}
+
test "pane/new takes rows from a pane in another column before refusing ENOSPC" {
const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 16 });
defer p.deinit();
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 118b6488..75a7eb9a 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -491,8 +491,8 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
},
};
if (takes) return null;
- var why: [104]u8 = undefined;
- return refuse(p, req, if (config.Runtime.choices(setting.action)) |values|
+ var why: [160]u8 = undefined;
+ return refuse(p, req, if (config.Runtime.takes(setting.action)) |values|
std.fmt.bufPrint(&why, "bad value in control message; takes {s}", .{values}) catch "bad value in control message"
else
"bad value in control message", line);
@@ -554,7 +554,9 @@ pub fn writeSettingsOf(p: *Pardes, s: *const config.Runtime, w: *std.Io.Writer)
const word = setting.word;
switch (setting.action) {
.toggle => |field| try w.print("{s} {s}\n", .{ word, if (@field(s, @tagName(field))) "on" else "off" }),
- .theme => try w.print("{s} {s}\n", .{ word, if (s == &p.settings) p.theme().name else pardes.themes[s.theme].name }),
+ .theme => if (s == &p.settings) {
+ if (pardes.colors.loadedThemeFile(p)) |path| try w.print("ThemeFile {s}\n", .{path}) else try w.print("{s} {s}\n", .{ word, p.theme().name });
+ } else try w.print("{s} {s}\n", .{ word, pardes.themes[s.theme].name }),
.shell => {
const chosen = s.shell.requested.get();
try w.print("{s} {s}\n", .{ word, if (chosen.len > 0) chosen else config.defaultShell() });
@@ -1254,6 +1256,11 @@ test "the root ctl reads the settings as a write takes them, and takes the sessi
try testing.expectEqualStrings("unknown control message \"Nonsense 1\"", wr(p, root_ctl, "Nonsense 1").reply.ename);
try testing.expectEqualStrings("bad value in control message; takes on, off \"Verbose maybe\"", wr(p, root_ctl, "Verbose maybe").reply.ename);
try testing.expectEqualStrings("bad value in control message; takes acme, pardes \"Placement east\"", wr(p, root_ctl, "Placement east").reply.ename);
+ // A number's refusal names its range, a path's what it takes.
+ try testing.expectEqualStrings("bad value in control message; takes 0-100 (a percentage) \"InactiveDim 200\"", wr(p, root_ctl, "InactiveDim 200").reply.ename);
+ try testing.expectEqualStrings("bad value in control message; takes 0-60000 (milliseconds) \"MessageLinger x\"", wr(p, root_ctl, "MessageLinger x").reply.ename);
+ for (config.Runtime.settings) |setting| if (setting.action != .theme)
+ try testing.expect(config.Runtime.takes(setting.action) != null);
const no_theme = wr(p, root_ctl, "Theme no-such-theme").reply.ename;
try testing.expectStringStartsWith(no_theme, "bad value in control message; like it: n");
try testing.expect(std.mem.endsWith(u8, no_theme, "; Themes lists all \"Theme no-such-theme\""));
@@ -1447,7 +1454,7 @@ test "the root look's file:/re/ goes on from the dot, and a miss leaves the dot
try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 64).bytes);
}
-test "a look's miss repeats the path whole up to 256 bytes, then cuts it with an ellipsis" {
+test "a look's miss repeats the path whole up to 256 bytes, then gives up its middle to an ellipsis" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
defer p.deinit();
@@ -1456,9 +1463,9 @@ test "a look's miss repeats the path whole up to 256 bytes, then cuts it with an
var line: [512]u8 = undefined;
_ = wr(p, root, try std.fmt.bufPrint(&line, "{s}\n", .{long}));
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&line, "look: no match for \"{s}\"\n", .{long})));
- const huge = "é" ** 150; // 300 bytes: cut at 256, between characters
+ const huge = "é" ** 150; // 300 bytes: its middle cut to fit 256, between characters
_ = wr(p, root, huge ++ "\n");
- try testing.expect(th.logHas(p, "look: no match for \"" ++ "é" ** 128 ++ "…\"\n"));
+ try testing.expect(th.logHas(p, "look: no match for \"" ++ "é" ** 63 ++ "…" ++ "é" ** 63 ++ "\"\n"));
}
test "exec runs a builtin at the pane and records the pane it acted on" {
@@ -1658,6 +1665,49 @@ test "Save path of a file pane logs a save record naming that path" {
try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "save {d} /tmp/pardes-save-copy.txt\n", .{serial})));
}
+test "Save and Save path on an image pane are refused with why, never a silent success" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const id = p.freeSlot().?;
+ const doc = try p.newDocPane(id);
+ doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") };
+ for ([_][]const u8{ "Save\n", "Save /tmp/pardes-copy.ppm\n" }) |line| {
+ const r = wr(p, Node.of(doc.serial, .ctl), line);
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expectEqualStrings("Save: an image pane has no text to save", r.reply.ename);
+ }
+}
+
+test "a builtin for one kind of pane is refused on another, and still acts on its own kind" {
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "text\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const cases = [_]struct { []const u8, []const u8 }{
+ .{ "Petscii\n", "Petscii: only an image pane takes it" },
+ .{ "Palette\n", "Palette: only an image pane takes it" },
+ .{ "Ascii\n", "Ascii: only an image pane takes it" },
+ .{ "Filter\n", "Filter: only a terminal pane takes it" },
+ .{ "Togglettymode\n", "Togglettymode: only a terminal pane takes it" },
+ };
+ for (cases) |case| {
+ const r = wr(p, Node.of(serial, .ctl), case[0]);
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expectEqualStrings(case[1], r.reply.ename);
+ }
+ if (pardes.pdf_enabled) for ([_][]const u8{ "PdfFit\n", "PdfTint\n", "PdfSections\n" }) |line| {
+ try testing.expectEqual(Status.err, wr(p, Node.of(serial, .ctl), line).reply.status);
+ };
+ const id = p.freeSlot().?;
+ const doc = try p.newDocPane(id);
+ doc.image = .{ .path = try p.gpa.dupe(u8, "/tmp/pardes-pic.ppm") };
+ const r = wr(p, Node.of(doc.serial, .ctl), "Mode\n");
+ try testing.expectEqualStrings("Mode: only a text or terminal pane has modes", r.reply.ename);
+ const glyphs = doc.image.?.glyph_art;
+ try testing.expectEqual(Status.ok, wr(p, Node.of(doc.serial, .ctl), "Petscii\n").reply.status);
+ try testing.expect(doc.image.?.glyph_art != glyphs);
+}
+
test "exec Save writes the named file through the host" {
const gpa = testing.allocator;
const p = try withFile(gpa, "one\ntwo\n");
@@ -1786,6 +1836,64 @@ test "an error's words give the errno a mount reads: EINVAL for what is malforme
}
}
+test "DumpConfig's whole report writes back to the root ctl, line by line, with no error" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "DumpConfig\n").reply.status);
+ const report = for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ const o = f.output orelse continue;
+ if (std.meta.eql(o.from, panes.Output.Origin{ .cmd = .DumpConfig })) break try testing.allocator.dupe(u8, f.content);
+ } else return error.NoDumpConfigPane;
+ defer testing.allocator.free(report);
+ // What this shell cannot show is a comment, never a value it refuses.
+ if (pardes.platform == .tty) try testing.expect(std.mem.indexOf(u8, report, "# GripWidth unsupported\n") != null);
+ var lines = std.mem.splitScalar(u8, report, '\n');
+ while (lines.next()) |line| {
+ if (line.len == 0) continue;
+ const r = wr(p, root_ctl, line);
+ if (r.reply.status != .ok) std.debug.print("refused: {s}: {s}\n", .{ line, r.reply.ename });
+ try testing.expectEqual(Status.ok, r.reply.status);
+ }
+}
+
+test "after a ThemeFile the root ctl and DumpConfig say ThemeFile and its path, which writes back" {
+ if (comptime !pardes.hosted) return;
+ const gpa = testing.allocator;
+ const p = try withFile(gpa, "x\n");
+ defer p.deinit();
+ const root_ctl = @intFromEnum(tree.TopFile.ctl);
+ const path = "/tmp/pardes-mine-theme.zon";
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "ThemeFile " ++ path ++ "\n").reply.status);
+ var custom = pardes.themes[0];
+ custom.name = "mine";
+ var encoded: std.Io.Writer.Allocating = .init(gpa);
+ defer encoded.deinit();
+ try std.zon.stringify.serialize(custom, .{}, &encoded.writer);
+ try testing.expect(pardes.colors.loadThemeFile(p, p.theme_file_generation, encoded.written(), false));
+ const settings = rd(p, root_ctl, 0, 1 << 16).bytes;
+ try testing.expect(std.mem.indexOf(u8, settings, "ThemeFile " ++ path ++ "\n") != null);
+ try testing.expect(std.mem.indexOf(u8, settings, "Theme mine") == null);
+ var storage: [16384]u8 = undefined;
+ var out: std.Io.Writer = .fixed(&storage);
+ try config.Runtime.writeReport(&out, .{
+ .startup_config_path = null,
+ .platform = @tagName(pardes.platform),
+ .theme_name = p.theme().name,
+ .theme_file = pardes.colors.loadedThemeFile(p),
+ .compiled_default_shell = config.defaultShell(),
+ .hover_delay_frames = null,
+ .native_images = false,
+ .capabilities = builtins.capabilities,
+ .state = &p.settings,
+ });
+ try testing.expect(std.mem.indexOf(u8, out.buffered(), "ThemeFile " ++ path ++ "\n") != null);
+ // Written back, it is the same request again, not "no theme mine".
+ try testing.expectEqual(Status.ok, wr(p, root_ctl, "ThemeFile " ++ path ++ "\n").reply.status);
+}
+
test "Joincol with no column to the right and Theme with no such theme say so" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
@@ -2909,6 +3017,23 @@ test "a column takes panes until their documented minimums fill it, not until no
}
}
+test "Jumplist fills its one +Jumps again, as Recent does, never a second" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ var count: [2]usize = undefined;
+ for (&count) |*n| {
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Jumplist\n").reply.status);
+ p.sync();
+ n.* = 0;
+ for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| {
+ n.* += @intFromBool(std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist }));
+ };
+ }
+ try testing.expectEqual(@as(usize, 1), count[0]);
+ try testing.expectEqual(@as(usize, 1), count[1]);
+}
+
test "a Jumplist entry is (closed) only while no pane has its file open" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
@@ -2935,6 +3060,27 @@ test "a Jumplist entry is (closed) only while no pane has its file open" {
for (p.jumps[0..p.njumps]) |j| try testing.expect(j.serial != 0);
}
+test "Recent's row for a file still open is where its dot is now, not where it last closed" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "a.txt", .data = "one\ntwo\nthree\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var line: [4200]u8 = undefined;
+ try testing.expectEqual(Status.ok, wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/a.txt:3\n", .{dir})).reply.status);
+ p.sync();
+ const pane = p.panes[p.active].?;
+ pane.body.vsel.active = false; // the look's selection of line 3 let go
+ pane.body.cur_row = 1;
+ pane.body.cur_col = 2;
+ try testing.expectEqual(Status.ok, wr(p, root_exec, "Recent\n").reply.status);
+ const rows = p.panes[p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?].?;
+ var want: [4300]u8 = undefined;
+ try testing.expect(std.mem.indexOf(u8, rows.file.?.content, try std.fmt.bufPrint(&want, "{s}/a.txt:2:3 open\n", .{dir})) != null);
+}
+
test "a closed file is found again: Recent and /recent list it closed, a look at its row reopens it at its dot, and a jump back does too" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index a09f7278..bacf3ad6 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -140,8 +140,9 @@ pub fn announce(p: *Pardes) void {
if (!pane.fs.unannounced) continue;
pane.fs.unannounced = false;
noteLog(p, .new, pane);
- // A file opened (a look, a Restore, a boot): first in Recent.
- if (pane.file) |f| if (f.output == null and pardes.Pardes.recentKeeps(f.path)) p.recent.opened(p.recentGpa(), f.path);
+ // A file, PDF or image opened (a look, a Restore, a boot): first in
+ // Recent.
+ if (pardes.Pardes.recentPlace(pane)) |d| if (pardes.Pardes.recentKeeps(d.path)) p.recent.opened(p.recentGpa(), d.path);
}
}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 78bec09f..ce6d0422 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -44,6 +44,10 @@ pub const State = struct {
/// The limit a truncation cleared, kept until the write that follows it
/// on that open (a shell's `>`): a write that fails puts it back.
limit_truncated: ?Range = null,
+ /// `dot` was truncated and nothing written since: the write that
+ /// follows sets it or, refused, leaves it as it was; with none, the
+ /// open's release empties it (tree.zig releaseHandle).
+ dot_truncated: bool = false,
/// Opens of `event`, which hold the pane scripted.
readers: u16 = 0,
/// One of `readers` reads `event`; a second reading open is refused.
@@ -286,6 +290,9 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const
const take = if (bytes.len == 0) 0 else wholeUtf8(bytes);
const lo = @min(q0, f.content.len);
const hi = @max(lo, @min(q1, f.content.len));
+ // An empty range deleted, or a range replaced by what it holds: no
+ // edit, so no undo step and no new revision.
+ if (std.mem.eql(u8, f.content[lo..hi], bytes[0..take])) return take;
const new = p.gpa.alloc(u8, f.content.len - (hi - lo) + take) catch return null;
@memcpy(new[0..lo], f.content[0..lo]);
@memcpy(new[lo..][0..take], bytes[0..take]);
@@ -297,6 +304,41 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const
return take;
}
+test "a write that changes nothing, an empty range deleted or a range replaced by itself, makes no undo step" {
+ const p = try withFile(testing.allocator, "abc\ndef\n");
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const serial = pane.serial;
+ const f = &pane.file.?;
+ const steps = f.history.undo_len;
+ const revision = f.revision;
+ const data = Node.of(serial, .data);
+ // Each as a shell does it, on an open of its own: `: > data` after
+ // an empty address, then `echo abc > data` over the line it holds.
+ _ = wr(p, Node.of(serial, .addr), "#2,#2");
+ const h = call(p, .{ .tag = 1, .op = .open, .node = data, .omode = 1 }).reply.handle;
+ try testing.expectEqual(tree.Status.ok, call(p, .{ .tag = 2, .op = .setattr, .node = data, .truncate = true }).reply.status);
+ _ = call(p, .{ .tag = 3, .op = .release, .node = data, .handle = h, .opened = true });
+ for ([_][]const u8{ "abc\n", "abd\n" }) |text| {
+ _ = wr(p, Node.of(serial, .addr), "1");
+ const w = call(p, .{ .tag = 4, .op = .open, .node = data, .omode = 1 }).reply.handle;
+ try testing.expectEqual(tree.Status.ok, call(p, .{ .tag = 5, .op = .write, .node = data, .handle = w, .data = text }).reply.status);
+ _ = call(p, .{ .tag = 6, .op = .release, .node = data, .handle = w, .opened = true });
+ if (text[2] == 'c') {
+ try testing.expectEqualStrings("abc\ndef\n", f.content);
+ try testing.expectEqual(steps, f.history.undo_len);
+ try testing.expectEqual(revision, f.revision);
+ }
+ }
+ // Edit's `s/d/d/` is the same nothing.
+ const undone = f.history.undo_len;
+ try testing.expectEqual(tree.Status.ok, wr(p, Node.of(serial, .ctl), "Edit ,s/d/d/\n").reply.status);
+ try testing.expectEqual(undone, f.history.undo_len);
+ // A real change is still one.
+ try testing.expectEqualStrings("abd\ndef\n", f.content);
+ try testing.expectEqual(steps + 1, f.history.undo_len);
+}
+
test "rows through the line index are the ones counting newlines gives, at every offset" {
const p = try withFile(testing.allocator, "héllo\nwo\u{301}rld\n\n last line é\ntail");
defer p.deinit();
@@ -800,9 +842,11 @@ fn writeRange(req: Req, pane: *Pane, file: PaneFile) Reply {
pf.limit = kept;
pf.limit_truncated = null;
};
+ if (file == .dot) pf.dot_truncated = false;
return tree.failText(req.tag, E.INVAL, a.err);
};
if (file == .limit) pf.limit_truncated = null;
+ if (file == .dot) pf.dot_truncated = false;
switch (file) {
.addr => {
pf.addr = range;
@@ -1000,7 +1044,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
pf.limit_truncated = pf.limit;
pf.limit = null;
},
- .dot => if (fileOf(pane) != null) setDot(pane, .{}),
+ .dot => pf.dot_truncated = fileOf(pane) != null,
else => {},
}
return .ok;
@@ -1211,8 +1255,12 @@ test "sel reads the selection and writing sel replaces it" {
try testing.expectEqual(@as(u32, 0), p.panes[0].?.fs.addr.q0);
try testing.expectEqual(@as(u32, 3), p.panes[0].?.fs.addr.q1);
- // A truncation empties dot; an address expression addresses it.
+ // A truncation empties dot once its open goes, nothing written after
+ // it; an address expression addresses it.
+ const h = call(p, .{ .tag = 8, .op = .open, .node = dot, .omode = 1 }).reply.handle;
_ = call(p, .{ .tag = 9, .op = .setattr, .node = dot, .truncate = true });
+ try testing.expectEqualStrings("one", rd(p, sel, 0, 100).bytes);
+ _ = call(p, .{ .tag = 10, .op = .release, .node = dot, .handle = h, .opened = true });
try testing.expectEqualStrings("", rd(p, sel, 0, 100).bytes);
try testing.expectEqual(Status.ok, wr(p, dot, "#0,#3").reply.status);
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 4300d2b5..c0ace5be 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -949,6 +949,12 @@ fn releaseHandle(p: *Pardes, req: Req) void {
if (t == .pane and t.pane.file == .limit) if (p.paneBySerial(t.pane.serial)) |id| {
p.panes[id].?.fs.limit_truncated = null;
};
+ // `: > dot` empties it once its open goes, no write having set it.
+ if (t == .pane and t.pane.file == .dot) if (p.paneBySerial(t.pane.serial)) |id| {
+ const pn = p.panes[id].?;
+ if (pn.fs.dot_truncated) pane.setDot(pn, .{});
+ pn.fs.dot_truncated = false;
+ };
if (t == .col and t.col.file == .tag) cols.released(p, t.col.serial);
if (t == .top and t.top == .tag) cols.released(p, null);
const o = openOf(p, req) orelse return;
diff --git a/src/pardes.zig b/src/pardes.zig
index f08cf01b..fbc19b8a 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4574,7 +4574,7 @@ pub const Pardes = struct {
const path = opts.file.?;
const opened = initial_doc: {
if (comptime pdf_enabled) if (look.isPdfPath(path))
- break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line);
+ break :initial_doc panes.Pdf.openPane(p, 0, path, opts.file_line, .last);
if (look.isImagePath(path))
break :initial_doc panes.Image.create(p, 0, path, &.{});
break :initial_doc panes.File.open(p, 0, path, opts.file_line);
@@ -4709,13 +4709,25 @@ pub const Pardes = struct {
return exec.isFile(local);
}
- /// A file pane closing: its place noted in Recent, when Recent keeps it.
+ /// A file, PDF or image pane closing: its place noted in Recent, when
+ /// Recent keeps it.
fn noteClosed(p: *Pardes, pane: *const Pane) void {
- const d = recentDot(pane) orelse return;
+ const d = recentPlace(pane) orelse return;
if (p.recent.get(d.path) == null and !recentKeeps(d.path)) return;
p.recent.closedSel(p.recentGpa(), d.path, d.line, d.col, d.aline, d.acol);
}
+ pub const RecentPlace = struct { path: []const u8, line: u32, col: u32, aline: u32 = 0, acol: u32 = 0 };
+
+ /// Where a pane is, as Recent keeps it: a file's dot, a PDF's page
+ /// (`:page:1`), an image's path alone (line 0). Null for any other.
+ pub fn recentPlace(pane: *const Pane) ?RecentPlace {
+ if (recentDot(pane)) |d| return .{ .path = d.path, .line = d.line, .col = d.col, .aline = d.aline, .acol = d.acol };
+ if (pane.pdfPath()) |path| return .{ .path = path, .line = @intCast(pane.pdfPage().? + 1), .col = 1 };
+ if (pane.image) |iv| return .{ .path = iv.path, .line = 0, .col = 0 };
+ return null;
+ }
+
fn recentDot(pane: *const Pane) ?struct { path: []const u8, line: u32, col: u32, aline: u32 = 0, acol: u32 = 0 } {
const f = pane.file orelse return null;
if (f.output != null) return null;
@@ -6656,7 +6668,7 @@ pub const Pardes = struct {
// fall through: a multi-file rename previews as rows
} else if (w.kind == .format and rows.len > 0) {
// fall through: "cannot format" and friends are prose
- } else return;
+ } else if (w.kind == .format) return; // a rename of nothing says so, below
}
if (rows.len == 0) {
if (w.kind == .completion and pane.body.mode == .insert and pane.body.nsel == 0 and
@@ -6664,7 +6676,8 @@ pub const Pardes = struct {
// Nothing found fails, but for a file with no diagnostics,
// which is no failure, and a completion's Tab.
const none: ?[]const u8 = switch (w.kind) {
- .completion, .format, .rename => null,
+ .completion, .format => null,
+ .rename => "nothing here to rename",
.diagnostics, .workspace_diagnostics => return p.setMessage(w.pane, std.fmt.bufPrint(&said, "{s}: none", .{lspWord(w.kind)}) catch "none"),
.incoming_calls => "callers not found",
.outgoing_calls => "callees not found",
@@ -6889,6 +6902,12 @@ pub const Pardes = struct {
break;
};
}
+ // Two in a row the same (a closed one reopened beside its own,
+ // or the entry between them gone): one entry, one step.
+ if (w > 0 and std.meta.eql(p.jumps[w - 1], kept)) {
+ if (i == p.jcur) cur = w - 1;
+ continue;
+ }
p.jumps[w] = kept;
w += 1;
}
@@ -8034,6 +8053,38 @@ test "raw tty Ctrl-B switches to editor mode without sending child input" {
try std.testing.expectEqualStrings("", drainWrites(p, &buf));
}
+test "the jump list never holds two entries in a row the same, when the one between them goes" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 40 });
+ defer p.deinit();
+ const pane = try p.setTestFile("a\n" ** 200);
+ const at: Loc = .{ .pane = 0, .serial = pane.serial, .line = 1, .col = 1 };
+ // A, a pane long closed with no file to keep it, A again, then far on.
+ p.jumps[0] = at;
+ p.jumps[1] = .{ .pane = 5, .serial = 999, .line = 1, .col = 1 };
+ p.jumps[2] = at;
+ p.jumps[3] = .{ .pane = 0, .serial = pane.serial, .line = 150, .col = 1 };
+ p.njumps = 4;
+ p.jcur = 3;
+ pane.body.cur_row = 80; // where you are now: far from both
+ p.trackJump();
+ for (1..p.njumps) |i| try std.testing.expect(!std.meta.eql(p.jumps[i - 1], p.jumps[i]));
+ try std.testing.expectEqual(@as(usize, 3), p.njumps);
+ try std.testing.expectEqual(@as(u32, 1), p.jumps[0].line);
+ try std.testing.expectEqual(@as(u32, 150), p.jumps[1].line);
+}
+
+test "a Rename the language server finds nothing for says so, and changes nothing" {
+ if (!lsp.supports.contains(.rename)) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const pane = try p.setTestFile("const value=1;\n");
+ p.lspRequest(p.active, .rename, "renamed");
+ const wait = p.lsp_wait.?;
+ p.update(.{ .lsp_resp = .{ .id = wait.id, .rows = "" } });
+ try std.testing.expectEqualStrings("Rename: nothing here to rename", pane.msg[0..pane.msg_len]);
+ try std.testing.expectEqualStrings("const value=1;\n", pane.file.?.content);
+}
+
test "hopping between two panes does not grow the jump stack" {
if (platform == .web) return;
const gpa = std.testing.allocator;
diff --git a/src/pdf_view.zig b/src/pdf_view.zig
index 5646a92f..6c137202 100644
--- a/src/pdf_view.zig
+++ b/src/pdf_view.zig
@@ -1877,6 +1877,9 @@ pub fn openPane(
id: usize,
path: []const u8,
page_one_based: usize,
+ /// A page past the last: the last page (a restore, a start-up), or
+ /// refused with error.NoSuchPage (a look, which names a place).
+ past_end: enum { last, refuse },
) !*pardes.Pane {
if (comptime !enabled) return error.PdfDisabled;
// The slot stays ours across the read: another request may make a
@@ -1891,6 +1894,7 @@ pub fn openPane(
break :virtual try State.openBytes(core.pdf_gpa, path, bytes, page_one_based);
};
errdefer state.deinit(core.pdf_gpa);
+ if (past_end == .refuse and page_one_based > state.page_count) return error.NoSuchPage;
const pane = try core.newDocPane(id);
pane.pdf = state;
pane.body.cur_pinned = true;
@@ -2004,11 +2008,16 @@ pub fn openSections(core: *pardes.Pardes, id: usize) void {
const pane = core.panes[id] orelse return;
const state = &(pane.pdf orelse return);
if (rearmCleanSections(core, id, pane, state)) return;
- const content = state.renderSections(core.pdf_gpa, core.gpa) catch return;
+ const content = state.renderSections(core.pdf_gpa, core.gpa) catch |err| return core.reportError(id, "PdfSections", err);
+ // No outline: said, not an empty pane to puzzle over.
+ if (content.len == 0) {
+ core.gpa.free(content);
+ return core.reportFailure(id, "PdfSections: this PDF has no outline");
+ }
const free = core.freeSlot() orelse {
core.gpa.free(content);
- return;
+ return core.reportError(id, "PdfSections", error.NoPaneSlots);
};
const dir = std.fs.path.dirname(state.path) orelse "/";
const result = Output.open(
diff --git a/test/mode.zig b/test/mode.zig
index 767cda93..f49c434c 100644
--- a/test/mode.zig
+++ b/test/mode.zig
@@ -109,7 +109,7 @@ test "Mode leaves image and PDF document modes unchanged" {
const len = try tmp.dir.realPath(std.testing.io, &buffer);
const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/mode.pdf", .{buffer[0..len]});
defer std.testing.allocator.free(path);
- const document = try panes.Pdf.openPane(p, 2, path, 1);
+ const document = try panes.Pdf.openPane(p, 2, path, 1, .last);
const page = document.pdf.?.page;
try mode(p, 2, .normal);
try std.testing.expectEqual(page, document.pdf.?.page);
diff --git a/test/pdf.zig b/test/pdf.zig
index 48869de1..fbd580b3 100644
--- a/test/pdf.zig
+++ b/test/pdf.zig
@@ -343,7 +343,7 @@ test "PdfSections Look follows the exact owning PDF, not an equal path" {
try std.testing.expectEqual(first_revision, first_output.file.?.revision);
const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot;
- const duplicate = try panes.Pdf.openPane(p, duplicate_id, path, 0);
+ const duplicate = try panes.Pdf.openPane(p, duplicate_id, path, 0, .last);
pardes.exec.placeNew(p, 0, 0, duplicate_id, .doc);
layout.compute(p);
panes.Pdf.openSections(p, duplicate_id);
@@ -364,7 +364,7 @@ test "PdfSections Look follows the exact owning PDF, not an equal path" {
try std.testing.expectEqual(first_output_id, p.active);
}
-test "PdfSections caches an empty outline output" {
+test "PdfSections on a PDF with no outline says so and opens nothing" {
if (!pdf_enabled or platform == .web) return;
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
@@ -376,14 +376,69 @@ test "PdfSections caches an empty outline output" {
const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/plain.pdf", .{tmp.sub_path});
const p = try Pardes.init(gpa, .{ .file = path });
defer p.deinit();
- panes.Pdf.openSections(p, 0);
const pane = p.panes[0].?;
- const output_id = pane.search_pane orelse return error.MissingEmptyPdfSections;
- try std.testing.expectEqual(@as(usize, 0), p.panes[output_id].?.file.?.content.len);
- const serial = p.panes[output_id].?.serial;
- panes.Pdf.openSections(p, 0);
- try std.testing.expectEqual(output_id, pane.search_pane.?);
- try std.testing.expectEqual(serial, p.panes[output_id].?.serial);
+ for (0..2) |_| {
+ panes.Pdf.openSections(p, 0);
+ try std.testing.expect(pane.search_pane == null);
+ try std.testing.expectEqualStrings("PdfSections: this PDF has no outline", pane.msg[0..pane.msg_len]);
+ }
+}
+
+test "Recent keeps a PDF as it keeps a file: open, then closed at its page" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ var path_buf: [4200]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "{s}/plain.pdf", .{dir});
+ const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 28 });
+ defer p.deinit();
+ pardes.look.lookAt(p, p.active, path);
+ p.update(.{ .resize = .{ .cols = 80, .rows = 28 } }); // an update's end announces it
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try panes.Output.recentText(p, &out.writer, true);
+ var want: [4300]u8 = undefined;
+ try std.testing.expect(std.mem.indexOf(u8, out.written(), try std.fmt.bufPrint(&want, "{s}:1:1 open\n", .{path})) != null);
+}
+
+test "a look at a PDF's page past its last is a miss, never the last page" {
+ if (!pdf_enabled or platform == .web) return;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const fixture = try pdf_impl.makeNoOutlineTestPdf(gpa);
+ defer gpa.free(fixture);
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain.pdf", .data = fixture });
+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "other.pdf", .data = fixture });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ var path_buf: [4200]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, "{s}/plain.pdf", .{dir});
+ const p = try Pardes.init(gpa, .{ .file = path, .cols = 80, .rows = 28 });
+ defer p.deinit();
+ const doc = p.panes[0].?;
+ try std.testing.expect(doc.hasPdf());
+ var line: [4300]u8 = undefined;
+ // Open: it stays on its page.
+ const page = doc.pdf.?.page;
+ pardes.look.lookAt(p, 0, try std.fmt.bufPrint(&line, "{s}:999", .{path}));
+ try std.testing.expect(p.look_missed);
+ try std.testing.expectEqual(page, doc.pdf.?.page);
+ // Not open: nothing opens.
+ p.look_missed = false;
+ var before: usize = 0;
+ for (p.panes) |slot| before += @intFromBool(slot != null);
+ pardes.look.lookAt(p, 0, try std.fmt.bufPrint(&line, "{s}/other.pdf:999", .{dir}));
+ try std.testing.expect(p.look_missed);
+ var after: usize = 0;
+ for (p.panes) |slot| after += @intFromBool(slot != null);
+ try std.testing.expectEqual(before, after);
}
test "SDL PDF raster policy is materially denser than Kitty" {
diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json
index 8808dd55..ace9a967 100644
--- a/test/perf-baseline-gui-Debug.json
+++ b/test/perf-baseline-gui-Debug.json
@@ -1 +1 @@
-{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1740,"med_us":2009,"p90_us":2077,"max_us":2079},{"op":"render","fixture":"medium","min_us":76,"med_us":80,"p90_us":90,"max_us":101},{"op":"key-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":94,"max_us":95},{"op":"key-right","fixture":"medium","min_us":77,"med_us":82,"p90_us":86,"max_us":89},{"op":"page-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":86,"max_us":99},{"op":"wheel","fixture":"medium","min_us":76,"med_us":76,"p90_us":80,"max_us":89},{"op":"edit-char","fixture":"medium","min_us":924,"med_us":1330,"p90_us":1522,"max_us":1566},{"op":"body-2m","fixture":"medium","min_us":4888,"med_us":8435,"p90_us":10635,"max_us":11460},{"op":"body-appends","fixture":"medium","min_us":119102,"med_us":187377,"p90_us":230392,"max_us":245721}]}
+{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2100,"med_us":2437,"p90_us":2875,"max_us":3207},{"op":"render","fixture":"medium","min_us":82,"med_us":84,"p90_us":88,"max_us":89},{"op":"key-down","fixture":"medium","min_us":88,"med_us":95,"p90_us":102,"max_us":114},{"op":"key-right","fixture":"medium","min_us":84,"med_us":88,"p90_us":94,"max_us":105},{"op":"page-down","fixture":"medium","min_us":83,"med_us":86,"p90_us":88,"max_us":89},{"op":"wheel","fixture":"medium","min_us":82,"med_us":85,"p90_us":90,"max_us":99},{"op":"edit-char","fixture":"medium","min_us":1276,"med_us":1769,"p90_us":2239,"max_us":2754},{"op":"body-2m","fixture":"medium","min_us":5333,"med_us":9734,"p90_us":12257,"max_us":14666},{"op":"body-appends","fixture":"medium","min_us":135052,"med_us":262059,"p90_us":295829,"max_us":304576},{"op":"deep-sel","fixture":"medium","min_us":77,"med_us":79,"p90_us":83,"max_us":118}]}
diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json
index ef42f0ad..6b5c0bd5 100644
--- a/test/perf-baseline-tty-Debug.json
+++ b/test/perf-baseline-tty-Debug.json
@@ -1 +1 @@
-{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1743,"med_us":2011,"p90_us":2152,"max_us":2217},{"op":"render","fixture":"medium","min_us":76,"med_us":79,"p90_us":80,"max_us":84},{"op":"key-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":85,"max_us":89},{"op":"key-right","fixture":"medium","min_us":77,"med_us":80,"p90_us":83,"max_us":95},{"op":"page-down","fixture":"medium","min_us":78,"med_us":78,"p90_us":87,"max_us":95},{"op":"wheel","fixture":"medium","min_us":76,"med_us":80,"p90_us":88,"max_us":90},{"op":"edit-char","fixture":"medium","min_us":1122,"med_us":1501,"p90_us":1812,"max_us":2440},{"op":"body-2m","fixture":"medium","min_us":4949,"med_us":8504,"p90_us":10909,"max_us":11452},{"op":"body-appends","fixture":"medium","min_us":115605,"med_us":190785,"p90_us":226244,"max_us":245258}]}
+{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1830,"med_us":2222,"p90_us":2264,"max_us":2287},{"op":"render","fixture":"medium","min_us":78,"med_us":83,"p90_us":86,"max_us":94},{"op":"key-down","fixture":"medium","min_us":80,"med_us":84,"p90_us":89,"max_us":98},{"op":"key-right","fixture":"medium","min_us":78,"med_us":81,"p90_us":88,"max_us":96},{"op":"page-down","fixture":"medium","min_us":78,"med_us":82,"p90_us":89,"max_us":95},{"op":"wheel","fixture":"medium","min_us":77,"med_us":85,"p90_us":96,"max_us":109},{"op":"edit-char","fixture":"medium","min_us":1125,"med_us":1307,"p90_us":1558,"max_us":1584},{"op":"body-2m","fixture":"medium","min_us":5189,"med_us":9224,"p90_us":10859,"max_us":14741},{"op":"body-appends","fixture":"medium","min_us":108477,"med_us":198709,"p90_us":227756,"max_us":252672},{"op":"deep-sel","fixture":"medium","min_us":73,"med_us":77,"p90_us":80,"max_us":91}]}
diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json
index 66421ba3..87a31754 100644
--- a/test/perf-baseline-tty-ReleaseFast.json
+++ b/test/perf-baseline-tty-ReleaseFast.json
@@ -1 +1 @@
-{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1751,"med_us":2134,"p90_us":2183,"max_us":2415},{"op":"render","fixture":"medium","min_us":76,"med_us":77,"p90_us":91,"max_us":92},{"op":"key-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":82,"max_us":83},{"op":"key-right","fixture":"medium","min_us":79,"med_us":85,"p90_us":100,"max_us":102},{"op":"page-down","fixture":"medium","min_us":78,"med_us":82,"p90_us":86,"max_us":95},{"op":"wheel","fixture":"medium","min_us":76,"med_us":79,"p90_us":83,"max_us":87},{"op":"edit-char","fixture":"medium","min_us":1063,"med_us":1290,"p90_us":1527,"max_us":1564},{"op":"body-2m","fixture":"medium","min_us":4950,"med_us":9414,"p90_us":10945,"max_us":13152},{"op":"body-appends","fixture":"medium","min_us":117839,"med_us":189253,"p90_us":232943,"max_us":247720}]}
+{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2042,"med_us":2280,"p90_us":2451,"max_us":2478},{"op":"render","fixture":"medium","min_us":82,"med_us":84,"p90_us":87,"max_us":104},{"op":"key-down","fixture":"medium","min_us":83,"med_us":86,"p90_us":90,"max_us":93},{"op":"key-right","fixture":"medium","min_us":84,"med_us":87,"p90_us":91,"max_us":104},{"op":"page-down","fixture":"medium","min_us":84,"med_us":87,"p90_us":93,"max_us":96},{"op":"wheel","fixture":"medium","min_us":81,"med_us":85,"p90_us":89,"max_us":89},{"op":"edit-char","fixture":"medium","min_us":1309,"med_us":1613,"p90_us":1840,"max_us":2128},{"op":"body-2m","fixture":"medium","min_us":5386,"med_us":11064,"p90_us":11706,"max_us":12374},{"op":"body-appends","fixture":"medium","min_us":144772,"med_us":232307,"p90_us":273586,"max_us":281169},{"op":"deep-sel","fixture":"medium","min_us":79,"med_us":83,"p90_us":87,"max_us":130}]}
diff --git a/test/perf.zig b/test/perf.zig
index 95b55748..94076a25 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -53,6 +53,10 @@ const Op = enum {
/// close, as a shell's `>>` loop makes: each costs its own bytes, not a
/// pass over the whole body for its cursor, line index and saved hash.
body_appends,
+ /// A render with the selection 2 MB into one line: only the columns
+ /// shown are measured, so it costs what a selection at the line's
+ /// start does (4.5 s a frame at 5 MB before).
+ deep_selection,
fn label(o: Op) []const u8 {
return switch (o) {
@@ -65,6 +69,7 @@ const Op = enum {
.edit_char => "edit-char",
.body_write => "body-2m",
.body_appends => "body-appends",
+ .deep_selection => "deep-sel",
};
}
};
@@ -83,6 +88,16 @@ fn bodyWrite(core: *pardes.Pardes, serial: u32) void {
_ = core.serveFs(.{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true });
}
+/// One line of 2 MB appended to the file, no newline in it.
+fn longLine(core: *pardes.Pardes, serial: u32) void {
+ const Node = pardes.ctlfs.Node;
+ const body = Node.of(serial, .body);
+ @memset(&body_chunk, 'z');
+ const h = core.serveFs(.{ .tag = 1, .op = .open, .node = body, .omode = 1 }).handle;
+ for (0..8) |_| _ = core.serveFs(.{ .tag = 2, .op = .write, .node = body, .handle = h, .data = &body_chunk });
+ _ = core.serveFs(.{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true });
+}
+
const append_chunk_len = 8 * 1024;
const append_count = 64;
@@ -534,9 +549,16 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
core.update(.{ .key = .{ .cp = 'i', .text = "i" } });
pump(core);
}
+ if (op == .deep_selection) {
+ longLine(core, pane.serial);
+ pump(core);
+ const len: u32 = @intCast(pane.file.?.content.len);
+ pardes.ctlfs.pane.setDot(pane, .{ .q0 = len - 10, .q1 = len - 5 });
+ pump(core);
+ }
for (0..warmup + reps) |n| {
- seek(pane, fx, n);
+ if (op != .deep_selection) seek(pane, fx, n);
_ = try frame(core); // settle the view at the new spot, untimed
const before = .{ .row = pane.body.cur_row, .col = pane.body.cur_col, .scroll = pane.scroll(), .len = pane.file.?.content.len };
const rect = core.rects[id];
@@ -545,7 +567,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
const t0 = nowNs();
switch (op) {
.open => unreachable,
- .render => {},
+ .render, .deep_selection => {},
.key_down => core.update(.{ .key = .{ .cp = 'j', .text = "j" } }),
.key_right => core.update(.{ .key = .{ .cp = 'l', .text = "l" } }),
.page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }),
@@ -561,7 +583,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
.open => unreachable,
.body_write => if (pane.file.?.content.len != before.len + 8 * body_chunk.len) return error.BodyWriteWasNotAppended,
.body_appends => if (pane.file.?.content.len != before.len + append_count * append_chunk_len) return error.BodyAppendsWereNotAppended,
- .render => if (pane.body.cur_row != before.row or pane.body.cur_col != before.col or pane.file.?.content.len != before.len)
+ .render, .deep_selection => if (pane.body.cur_row != before.row or pane.body.cur_col != before.col or pane.file.?.content.len != before.len)
return error.RenderChangedDocument,
.key_down => if (pane.body.cur_row != before.row + 1) return error.DownDidNotMove,
.key_right => if (pane.body.cur_row != before.row or pane.body.cur_col != before.col + 1) return error.RightDidNotMove,