summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
20 files changed, 623 insertions, 77 deletions
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(