diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CHANGELOG.md | 20 | ||||
| -rw-r--r-- | src/File.zig | 15 | ||||
| -rw-r--r-- | src/Messages.zig | 2 | ||||
| -rw-r--r-- | src/Output.zig | 25 | ||||
| -rw-r--r-- | src/body_layer.zig | 35 | ||||
| -rw-r--r-- | src/builtins.zig | 59 | ||||
| -rw-r--r-- | src/colors.zig | 37 | ||||
| -rw-r--r-- | src/config.zig | 73 | ||||
| -rw-r--r-- | src/dump.zig | 2 | ||||
| -rw-r--r-- | src/exec.zig | 15 | ||||
| -rw-r--r-- | src/layout.zig | 27 | ||||
| -rw-r--r-- | src/look.zig | 47 | ||||
| -rw-r--r-- | src/ninep/addr.zig | 22 | ||||
| -rw-r--r-- | src/ninep/cols.zig | 26 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 158 | ||||
| -rw-r--r-- | src/ninep/events.zig | 5 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 52 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 6 | ||||
| -rw-r--r-- | src/pardes.zig | 61 | ||||
| -rw-r--r-- | src/pdf_view.zig | 13 |
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( |
