diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-22 17:43:57 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 9695cb098c784792af79c22d46a17f7bc7247d33 (patch) | |
| tree | fa446f3cb588b3184b7914b3f50c36cd3e5ece7b | |
| parent | f962de52df4b3d33438150920ae95b8708b0dde0 (diff) | |
| download | pardes-9695cb098c784792af79c22d46a17f7bc7247d33.tar.gz pardes-9695cb098c784792af79c22d46a17f7bc7247d33.zip | |
Repaint PDF highlights by row, send rasters by shared memory, and animate messages
PDF highlights (hover preview, search, selection) are baked into page rasters,
and any change re-rendered the whole page with MuPDF; the TTY then re-sent it
as base64 (4.7 MB a page), the GUI as a new texture. Worse, a pointer motion
over a PDF invalidated the page even when no preview was shown, so every
motion paid that. Now:
- A raster whose baked highlight set equals the wanted one is left alone.
- A highlighted page keeps its clean rows (before highlights and tint); a
change repaints only the rows of quads that differ, running MuPDF's
highlight pass (pardes_pdf_paint_highlights) over those clean rows and
tinting them: the operations a full render performs, so the pixels are
identical. MuPDF band renders are NOT bit-identical to a whole page (edge
rows, resampled images), so they are never used to patch; the comment
claiming otherwise is corrected.
- ImagePlace.patch hands shells the changed rows; the GUI uploads just those
rows into the texture it holds.
- The TTY probes kitty shared memory (t=s) with an id vaxis never reaches and
sends rasters as a /dev/shm name when the terminal reads it; direct base64
otherwise (ssh).
- Shells that take row patches (GUI, TTY with shm) repaint a selection while
it is dragged instead of only on release.
Latency elsewhere:
- TTY: an animating frame no longer sleeps 16 ms blind; a tick thread posts
into the input queue, so input inside the frame is handled at once.
- TTY and GUI: queued pointer motions coalesce to the last.
- GUI: a skipped swapchain image re-arms the frame (3 retries); animations
still tick while nothing presents.
- Editing: the line index is carried across an edit instead of rebuilt from
a scan of the whole file per keystroke.
Messages fall into their row (ease-in; the GUI slides the band out from under
the tagline, a terminal fades it), stay until the next input as before, linger
MessageLinger ms (default 800), and dissolve (ease-out). MessageAnimation
toggles it; both are settings, in Config and startup files. The snapshot
harness pins the old behaviour. The detached server now ticks animations.
A restored terminal comes back live: the old screen and scrollback (dumped
as clean VT by ghostty's formatter, replayed at the new size; older dumps
fall back to their rendered text), a dim
"restored history" marker, then a new shell in the directory it was in.
Right-click on a line number in a file pane looks at that line (a sticky
context header's number included).
Measured with an external pty driver (TTY), an in-process fence trace
(GUI, PARDES_TEST_LATENCY), and test/pdf_pointer_bench.zig (pixel identity
against the baseline and a whole-page oracle); balanced A/A/B rounds, paired
per-round statistics.
Messages stack: each event gets its own row and its own fall, linger and
dissolve; a line keeps its row until it leaves and a new one fills the first
free row. Announcements and statuses are replaced in place, not stacked.
MessageFall, MessageDissolve and DumpDir are settings Config reports.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
| -rw-r--r-- | build.zig | 19 | ||||
| -rw-r--r-- | docs/config.md | 24 | ||||
| -rw-r--r-- | src/config.zig | 66 | ||||
| -rw-r--r-- | src/detached/server.zig | 14 | ||||
| -rw-r--r-- | src/dump.zig | 19 | ||||
| -rw-r--r-- | src/fs.zig | 4 | ||||
| -rw-r--r-- | src/gui/gui.zig | 110 | ||||
| -rw-r--r-- | src/macos.zig | 4 | ||||
| -rw-r--r-- | src/panes.zig | 484 | ||||
| -rw-r--r-- | src/pardes.zig | 433 | ||||
| -rw-r--r-- | src/pdf.zig | 67 | ||||
| -rw-r--r-- | src/pdf_bridge.c | 228 | ||||
| -rw-r--r-- | src/pdf_bridge.h | 46 | ||||
| -rw-r--r-- | src/tag_layer.zig | 4 | ||||
| -rw-r--r-- | src/tty/tty.zig | 148 | ||||
| -rw-r--r-- | test/output.zig | 3 | ||||
| -rw-r--r-- | test/panes.zig | 37 | ||||
| -rw-r--r-- | test/pdf_pointer_bench.zig | 224 | ||||
| -rw-r--r-- | test/snapshot.zig | 4 | ||||
| -rw-r--r-- | test/snapshots/builtins.snap | 4 | ||||
| -rw-r--r-- | test/snapshots/dump.golden | 3 | ||||
| -rw-r--r-- | test/snapshots/load.golden | 18 | ||||
| -rw-r--r-- | test/snapshots/restore.golden | 6 |
23 files changed, 1815 insertions, 154 deletions
@@ -142,6 +142,7 @@ pub fn build(b: *std.Build) void { const mupdf_check = b.step("mupdf-check", "compile, link, render, and search docs/design.pdf with MuPDF"); const pdf_bench_step = b.step("pdf-bench", "benchmark real MuPDF page rendering (-- [--json] [--reps N] [--warmup N] [--path FILE] [--page N] [--pages N])"); const pdf_sections_bench_step = b.step("pdf-sections-bench", "benchmark PDF outline/sections paths in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N])"); + const pdf_pointer_bench_step = b.step("pdf-pointer-bench", "time PDF hover and selection gestures and prove their pixels (-Doptimize=ReleaseFast -- [--label L] [--reps N] [--path FILE] [--cell WxH])"); const pdf_scroll_bench_step = b.step("pdf-scroll-bench", "benchmark fast continuous-strip PDF scrolling in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N] [--path FILE])"); if (!enable_mupdf) mupdf_check.dependOn(&b.addFail("mupdf-check is unavailable with -Dmupdf=false").step); @@ -151,6 +152,8 @@ pub fn build(b: *std.Build) void { pdf_sections_bench_step.dependOn(&b.addFail("pdf-sections-bench is unavailable with -Dmupdf=false").step); if (!enable_mupdf) pdf_scroll_bench_step.dependOn(&b.addFail("pdf-scroll-bench is unavailable with -Dmupdf=false").step); + if (!enable_mupdf) + pdf_pointer_bench_step.dependOn(&b.addFail("pdf-pointer-bench is unavailable with -Dmupdf=false").step); const shaders_step = b.step("shaders", "refresh paired shaders/prebuilt/*.spv + *.glsl snapshots (glslc)"); const update_shaders = b.addUpdateSourceFiles(); @@ -482,6 +485,22 @@ pub fn build(b: *std.Build) void { run_pdf_sections_bench.setCwd(b.path(".")); pdf_sections_bench_step.dependOn(&run_pdf_sections_bench.step); + const pdf_pointer_bench = b.addExecutable(.{ + .name = "pardes-pdf-pointer-bench", + .root_module = b.createModule(.{ + .target = target, + .optimize = .ReleaseFast, + .root_source_file = b.path("test/pdf_pointer_bench.zig"), + .link_libc = true, + }), + }); + pdf_pointer_bench.root_module.addImport("pardes", core_module); + pdf_pointer_bench.root_module.addImport("mupdf", mupdf_mod); + const run_pdf_pointer_bench = b.addRunArtifact(pdf_pointer_bench); + if (b.args) |args| run_pdf_pointer_bench.addArgs(args); + run_pdf_pointer_bench.setCwd(b.path(".")); + pdf_pointer_bench_step.dependOn(&run_pdf_pointer_bench.step); + const pdf_scroll_bench = b.addExecutable(.{ .name = "pardes-pdf-scroll-bench", .root_module = b.createModule(.{ diff --git a/docs/config.md b/docs/config.md index 8eff79a8..27818b5b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -115,6 +115,16 @@ compact screen. Hiding it preserves your custom column commands. name before it runs; it is on by default, and the builtins that own the message row themselves (`Msg`) never announce. Turning it off leaves the row to the messages a builtin chooses to write. +`MessageAnimation` toggles how a message comes and goes; it is on by default. +A message eases down into its row (graphical frontends slide it out from under +the tagline; a terminal fades it in), stays until the next key or click as it +always has, then lingers for `MessageLinger` milliseconds (default 800) before +it dissolves into the page. `MessageFall` (default 180) and `MessageDissolve` +(default 300) set how long the fall and the dissolve take, also in +milliseconds; each timing is at most 60000, and `Config` reports all three. `MessageLinger 0` with +`MessageAnimation off` restores the old behaviour, a message cleared by the +very input that follows it. An updated line on a row already showing one swaps +its text in place rather than falling again. `SyntaxBold` toggles bold syntax keywords; it is off by default. These settings are shared by GUI and TTY, and `Config` reports their current states. Like `Colors` and `Wrap`, these commands take no argument and invert @@ -573,8 +583,18 @@ control all yield null, and the flag has to work anyway. Neither is a question asked at runtime — a binary that shelled out to `git` would describe whatever tree it was standing in rather than the one it came from. -`Restore a.dump` first looks for the relative path in the default dump directory -(`$XDG_DATA_HOME/pardes`, or `~/.local/share/pardes`). If it is absent, Restore +`Restore a.dump` first looks for the relative path in the dump directory: +`DumpDir <path>` when set (a leading `~/` is your home; bare `DumpDir` returns +to the default), else `$XDG_DATA_HOME/pardes` or `~/.local/share/pardes`. +`Config` reports the directory in effect as `DumpDir <path>`, so the line can +be fed back as configuration. If it is absent, Restore uses the argument as a path as before. Absolute paths and argument-free Restore retain their existing behavior. `Dump` still honors `$PARDES_DUMP` and otherwise writes a timestamped file in the default directory. + +A restored terminal comes back live: its recorded output (the last MiB of it) +is replayed as history, a dim `── restored history ──` line marks where it +ends, and a new shell starts below it in the directory the old one was in. A +view left scrolled back stays where it was. Only the shell is new; nothing the +old one was running is restarted. The web shell, which has no ptys, still +shows the history alone. diff --git a/src/config.zig b/src/config.zig index 98bbf3f4..527c3752 100644 --- a/src/config.zig +++ b/src/config.zig @@ -102,6 +102,11 @@ pub const leader_path = paths: { table.set(.TaglineSize, null); } if (pardes.platform == .gui) table.set(.Pet, null); + table.set(.MessageAnimation, null); + table.set(.MessageLinger, null); + table.set(.MessageFall, null); + table.set(.MessageDissolve, null); + table.set(.DumpDir, null); if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null); if (builtins.capabilities.window_blur) table.set(.WindowBlur, null); if (builtins.capabilities.panel_transitions) { @@ -269,6 +274,10 @@ pub const wheel_cols: i32 = 4; pub const look_preview_delay_frames: ?u16 = 2; +/// The longest any message timing (MessageLinger, MessageFall, +/// MessageDissolve) may be set to, in milliseconds. +pub const message_ms_max: u16 = 60_000; + // Tag fonts keep body-cell geometry; valid sizes are 1...100 percent. pub const gui_tagline_font_percent: u8 = 82; comptime { @@ -558,6 +567,17 @@ pub const Runtime = struct { syntax_bold: bool = false, /// Builtins announce themselves on the message row. verbose: bool = true, + /// A message falls into its row and dissolves out of it. + message_animation: bool = true, + /// How long a message stays after the key or click that dismisses it. + message_linger_ms: u16 = 800, + /// How long its ease-in fall into the row takes. + message_fall_ms: u16 = 180, + /// How long its ease-out dissolve takes. + message_dissolve_ms: u16 = 300, + /// Where Dump writes and a relative Restore looks; empty is the default + /// ($XDG_DATA_HOME/pardes, else ~/.local/share/pardes). + dump_dir: Text(limits.host_path_cap) = .{}, window_opacity: u8 = 100, window_blur: u8 = 0, window_opacity_pending: bool = true, @@ -655,7 +675,10 @@ pub const Runtime = struct { pub const Capability = std.meta.FieldEnum(Capabilities); - pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose }; + /// The message timings a setting word sets, each in milliseconds. + pub const MessageTime = enum { linger, fall, dissolve }; + + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation }; pub const Scene = std.meta.FieldEnum(layout.SceneEffect); pub const Action = union(enum) { @@ -666,6 +689,8 @@ pub const Runtime = struct { tagline_size, window_opacity, window_blur, + message_ms: MessageTime, + dump_dir, transition: layout.Transition, scene: Scene, }; @@ -679,7 +704,7 @@ pub const Runtime = struct { return switch (setting.action) { // A toggle flips when it is a bare tagline word and SETS when // it is given `on` or `off`, so the report is configuration. - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .toggle => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .dump_dir, .toggle => true, else => false, }; } @@ -703,6 +728,11 @@ pub const Runtime = struct { .{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } }, .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } }, .{ .word = "Verbose", .action = .{ .toggle = .verbose } }, + .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, + .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, + .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, + .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, + .{ .word = "DumpDir", .action = .dump_dir }, .{ .word = "Theme", .action = .theme }, .{ .word = "Shell", .action = .shell }, .{ .word = "Font", .action = .font, .availability = .font_picker }, @@ -827,6 +857,17 @@ pub const Runtime = struct { if (percent > 100) return false; state.window_blur = percent; }, + // Bare, it returns to the default directory. + .dump_dir => if (!state.dump_dir.set(std.mem.trim(u8, argument orelse "", " \t\r\n"))) return false, + .message_ms => |which| { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + for (text) |byte| if (!std.ascii.isDigit(byte)) return false; + const ms = std.fmt.parseInt(u16, text, 10) catch return false; + if (ms > message_ms_max) return false; + switch (which) { + inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms") = ms, + } + }, .transition => |effect| state.toggleTransition(effect), .scene => |effect| switch (effect) { inline else => |field| { @@ -847,6 +888,9 @@ pub const Runtime = struct { compiled_default_shell: []const u8, gui_shader_source_mode: ?[]const u8 = null, hover_delay_frames: ?u16, + /// Where dumps go when `DumpDir` is unset; empty without a host + /// filesystem. + default_dump_dir: []const u8 = "", native_images: bool, capabilities: Capabilities, state: *const Runtime, @@ -895,6 +939,13 @@ pub const Runtime = struct { else 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)" }); + }, + .message_ms => |which| try out.print("{s} {d}\n", .{ setting.word, switch (which) { + inline else => |w| @field(state, "message_" ++ @tagName(w) ++ "_ms"), + } }), .shell => { const chosen = state.shell.requested.get(); try out.print( @@ -993,7 +1044,7 @@ pub const Runtime = struct { for (settings[i + 1 ..]) |later| try std.testing.expect(!std.mem.eql(u8, setting.word, later.word)); try std.testing.expectEqual(switch (setting.action) { - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .toggle => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .dump_dir, .toggle => true, else => false, }, setting.takesArg()); } @@ -1152,6 +1203,8 @@ pub const Runtime = struct { .tagline_size => "73", .window_opacity => "85", .window_blur => "40", + .message_ms => "1500", + .dump_dir => "~/dumps", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1170,11 +1223,18 @@ pub const Runtime = struct { .column_tags => "ColumnTags off\n", .syntax_bold => "SyntaxBold on\n", .verbose => "Verbose off\n", + .message_animation => "MessageAnimation off\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", .window_opacity => "WindowOpacity 85 (pending)\n", .window_blur => "WindowBlur 40\n", + .dump_dir => "DumpDir ~/dumps\n", + .message_ms => |which| switch (which) { + .linger => "MessageLinger 1500\n", + .fall => "MessageFall 1500\n", + .dissolve => "MessageDissolve 1500\n", + }, .transition => |transition| switch (transition) { .off => unreachable, .slide => "Panel transition PanelSlide\n", diff --git a/src/detached/server.zig b/src/detached/server.zig index 3e98fc9d..6959e577 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -536,7 +536,7 @@ pub const Session = struct { fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf) orelse return; + const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return; filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } @@ -809,8 +809,11 @@ pub const Session = struct { // The wait is the 9P connections' turn with the core. if (n == 0) { pardes.turn.rest(); - defer pardes.turn.wake(); - return nap(if (timeout_ms == 0) 16 else timeout_ms); + nap(if (timeout_ms == 0) 16 else timeout_ms); + pardes.turn.wake(); + // An animating frame's wait is the frame: tick it. + if (timeout_ms != 0) s.core.update(.tick); + return; } var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int))); if (s.nextWake(now)) |due| timeout = if (timeout < 0) due else @min(timeout, due); @@ -819,6 +822,9 @@ pub const Session = struct { const ready = libc.poll(&fds, @intCast(n), timeout); pardes.turn.wake(); if (ready > 0) s.dispatch(fds[0..n], src[0..n]); + // Animating, the frame's time is up: advance it. There is no other + // tick in a detached session, and without it an animation never ends. + if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) s.core.update(.tick); _ = s.drainCompletions(true); s.expire(monotonicMs()); s.harvest(); @@ -1156,7 +1162,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void try session.core.pump(h); if (session.core.quit) break; if (session.core.takeRestore()) |path| restore: { - const bytes = filesystem.readRestore(gpa, path) catch |err| { + const bytes = filesystem.readRestore(gpa, path, session.core.settings.dump_dir.get()) catch |err| { session.core.reportError(session.core.active, "Restore", err); break :restore; }; diff --git a/src/dump.zig b/src/dump.zig index 89abd995..ff5d2166 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -11,11 +11,11 @@ const log = std.log.scoped(.dump); /// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share /// /pardes/pardes-<utc>.zon — timestamped so dumps never overwrite each other. /// Creates the pardes dir (parents assumed; a failure surfaces at open). -pub fn outPath(buf: *[1024:0]u8) ?[:0]const u8 { +pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 { if (std.c.getenv("PARDES_DUMP")) |p| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null; var dir_buf: [900]u8 = undefined; - const dir = defaultDirectory(&dir_buf) orelse return null; + const dir = directory(&dir_buf, dir_setting) orelse return null; var dz: [901:0]u8 = undefined; @memcpy(dz[0..dir.len], dir); dz[dir.len] = 0; @@ -32,6 +32,15 @@ pub fn outPath(buf: *[1024:0]u8) ?[:0]const u8 { ds.getSecondsIntoMinute(), }, 0) catch null; } +/// Where dumps go and relative Restore names are looked up: the `DumpDir` +/// setting when set (a leading `~/` is the home directory), else the default. +pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 { + if (dir_setting.len == 0) return defaultDirectory(buf); + if (std.mem.startsWith(u8, dir_setting, "~/")) if (std.c.getenv("HOME")) |h| + return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(h), dir_setting[2..] }) catch null; + return std.fmt.bufPrint(buf, "{s}", .{dir_setting}) catch null; +} + /// The standard dump directory, without creating it or generating a filename. pub fn defaultDirectory(buf: []u8) ?[]const u8 { if (std.c.getenv("XDG_DATA_HOME")) |x| @@ -69,8 +78,14 @@ pub const PaneKind = enum { pub const Terminal = struct { cwd: []const u8 = "", + /// The rendered scrollback and screen as plain text, one row per line. stream: []const u8 = "", stream_b64: []const u8 = "", + /// `stream_b64` is the emulator's primary screen and scrollback as clean + /// VT (ghostty's formatter), which replays to what the screen showed. + /// Absent (older dumps), it is the raw tail of the pty's output, which + /// replays faithfully only if it holds the whole session at one size. + stream_is_screen: bool = false, cursor: Cursor = .{}, }; @@ -1104,9 +1104,9 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 { /// Restore first tries a relative argument under the default dump directory, /// then preserves the ordinary file lookup. Absolute paths remain verbatim. -pub fn readRestore(gpa: std.mem.Allocator, path: []const u8) ![]u8 { +pub fn readRestore(gpa: std.mem.Allocator, path: []const u8, dir_setting: []const u8) ![]u8 { var dir_buf: [4096]u8 = undefined; - const dir = @import("dump.zig").defaultDirectory(&dir_buf); + const dir = @import("dump.zig").directory(&dir_buf, dir_setting); return readRestoreIn(gpa, path, dir); } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 17bdb46f..7f28124c 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2111,8 +2111,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u } var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; - // A latency run renders to the capture texture; keep it off the desktop. - if (latency_fd >= 0) win_flags |= c.SDL_WINDOW_HIDDEN; + // A test run renders to the capture texture; keep it off the desktop. + if (test_mode) win_flags |= c.SDL_WINDOW_HIDDEN; const native_wayland = !test_mode and std.mem.eql(u8, std.mem.span(c.SDL_GetCurrentVideoDriver()), "wayland"); // Background-only opacity needs per-pixel alpha; whole-window compositor // opacity would dim the text as well. SDL requires this flag at creation. @@ -2353,6 +2353,7 @@ fn localSession( } else try pardes.Pardes.init(allocs.pardes, opts); defer core.deinit(); core.native_images = true; + core.native_image_patches = true; observeGuiFont(g, core); syncTaglineFont(g, core); @@ -2448,7 +2449,7 @@ fn localSession( } } if (core.takeRestore()) |rp| blk: { - const bytes = filesystem.readRestore(gpa, rp) catch |err| { + const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; @@ -2473,6 +2474,7 @@ fn localSession( g.presented_images.clearRetainingCapacity(); g.prepared_images.clearRetainingCapacity(); nc.native_images = true; + nc.native_image_patches = true; nc.host = host; if (fs) |f| f.reset(nc); core.deinit(); @@ -3591,6 +3593,7 @@ const Shell = struct { test_mode: bool = false, feed: StdinFeed = .{}, presented: bool = false, + skipped_presents: u8 = 0, surface: ?*pardes.Surface = null, animation_clock: AnimationClock = .{}, saw_event: bool = false, @@ -3774,14 +3777,29 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); - const ms: c_int = if (timeout_ms != 0) @intCast(timeout_ms) else 16; + // A latency trace is fed on stdin, whose poll below paces the loop: + // waiting here too would make every tick cost two frames. + const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); if (got) { - dispatch(g, &in, &sev); - while (c.SDL_PollEvent(&sev)) dispatch(g, &in, &sev); + // A run of queued motions is one move to its last position (the + // handler reads only absolute x/y and held buttons): acting on + // each would repeat hover and selection work the next undoes. + var motion: ?c.SDL_Event = null; + while (true) { + if (sev.type == c.SDL_EVENT_MOUSE_MOTION) { + motion = sev; + } else { + if (motion) |*m| dispatch(g, &in, m); + motion = null; + dispatch(g, &in, &sev); + } + if (!c.SDL_PollEvent(&sev)) break; + } + if (motion) |*m| dispatch(g, &in, m); } } if (s.test_mode) { @@ -3913,6 +3931,10 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { log.err("render: {t}", .{err}); break :blk false; }; + // A skipped swapchain image would otherwise hide this input's frame until + // the next event. A few retries only: a minimized window never gets one. + s.skipped_presents = if (s.presented) 0 else s.skipped_presents +| 1; + if (!s.presented and s.skipped_presents <= 3) core.present_skipped = true; if (g.scene_target_failed) { g.scene_failures +|= 1; log.err("scene target unavailable (attempt {d}/3)", .{g.scene_failures}); @@ -3926,7 +3948,12 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { fn postPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); const g = s.gui orelse return; - if (!s.presented) return; + if (!s.presented) { + // No image (a minimized window): time still passes, so a lingering + // message or a hover wait runs out instead of redrawing forever. + if (s.animation_clock.due(s.core.animationActive(), c.SDL_GetTicksNS())) s.core.update(.tick); + return; + } const frame = s.surface orelse return; refreshPresentedPointer(g, s.core); finishPresentedAnimationFrame( @@ -4030,7 +4057,7 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = shellOf(ctx); var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf) orelse return; + const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return; filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } @@ -4533,6 +4560,23 @@ fn uploadNativeTexture( const byte_len = validImageBytes(place) orelse return error.BadImage; const key = place.cacheKey(); if (g.native_images.contains(key)) return; + if (comptime pardes.pdf_enabled) if (place.patch) |patch| patch: { + // The core changed only these rows since a revision we still hold: + // write them into that texture rather than sending the whole page. + var base = key; + base.revision = patch.from; + if (patch.y + patch.h > place.ih) break :patch; + const texture = g.native_images.get(base) orelse break :patch; + const row = place.iw * 4; + try uploadTextureRows(g, cmd, texture, place.rgba[patch.y * row ..][0 .. patch.h * row], place.iw, patch.y, patch.h); + try g.native_images.put(gpa, key, texture); + _ = g.native_images.remove(base); + // A transition snapshot of the last frame follows its texture. + for (g.presented_images.items) |*saved| if (saved.key.eql(base)) { + saved.key = key; + }; + return; + }; var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; @@ -4545,39 +4589,51 @@ fn uploadNativeTexture( tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const texture = c.SDL_CreateGPUTexture(g.device, &tex_info) orelse return error.GpuCreate; errdefer c.SDL_ReleaseGPUTexture(g.device, texture); + try uploadTextureRows(g, cmd, texture, place.rgba[0..byte_len], place.iw, 0, place.ih); + try g.native_images.put(gpa, key, texture); +} +/// Copy `rows` rows of RGBA, `width` pixels each, into `texture` at row `y`. +fn uploadTextureRows( + g: *Gui, + cmd: *c.SDL_GPUCommandBuffer, + texture: *c.SDL_GPUTexture, + rgba: []const u8, + width: usize, + y: usize, + rows: usize, +) !void { var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, - .size = byte_len, + .size = @intCast(rgba.len), .props = 0, }; const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; defer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap); - @memcpy(mapped[0..byte_len], place.rgba); + @memcpy(mapped[0..rgba.len], rgba); c.SDL_UnmapGPUTransferBuffer(g.device, transfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = transfer, .offset = 0, - .pixels_per_row = @intCast(place.iw), - .rows_per_layer = @intCast(place.ih), + .pixels_per_row = @intCast(width), + .rows_per_layer = @intCast(rows), }; const dst = c.SDL_GPUTextureRegion{ .texture = texture, .mip_level = 0, .layer = 0, .x = 0, - .y = 0, + .y = @intCast(y), .z = 0, - .w = @intCast(place.iw), - .h = @intCast(place.ih), + .w = @intCast(width), + .h = @intCast(rows), .d = 1, }; c.SDL_UploadToGPUTexture(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); - try g.native_images.put(gpa, key, texture); } fn appendPreparedImage( @@ -5442,16 +5498,30 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * bw; const background: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = layer.bg } } }; - emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = right - left, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .body, &background, old_layer, false, page); + // A band sliding into place is drawn offset and cut at its row's top, so + // it comes out from under whatever is above it. + const slide = layer.slide * bh; + const top = @as(f32, @floatFromInt(layer.viewport.y)) * bh; + emitInstance(g, instances, next.*, 0, layer.viewport.y, .{ .w = right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, &background, old_layer, false, page); + if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; for (0..tagLayerCellCount(g, layer)) |col| { const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false; - emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = 0 }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page); + emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, &layer.cells[col], old_layer, cursor, page); clipInstanceRight(&instances[next.*], right, win_w); + if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; } } +fn clipInstanceTop(instance: *CellInstance, top: f32, win_h: f32) void { + const limit = 1 - top / win_h * 2; + if (instance.y0 <= limit) return; + const fraction = std.math.clamp((limit - instance.y1) / (instance.y0 - instance.y1), 0, 1); + instance.v0 = instance.v1 + (instance.v0 - instance.v1) * fraction; + instance.y0 = @max(instance.y1, limit); +} + fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer { return bodyLayerIn(surface.bodyLayers(), col, row); } @@ -6436,7 +6506,9 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t } fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { - if (g.capture_dir.len == 0) return; + // A latency trace times frames, and an 8 MB file write per frame would + // stall the loop it is timing. + if (g.capture_dir.len == 0 or latency_fd >= 0) return; const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); if (bpp != 4) return error.UnsupportedCaptureFormat; const bgr = switch (g.swapchain_format) { diff --git a/src/macos.zig b/src/macos.zig index a7ee3164..c359eb3f 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1325,7 +1325,7 @@ export fn pardes_tick() bool { fn restoreCore(st: *State) bool { if (st.core.quit) return false; const path = st.core.takeRestore() orelse return false; - const bytes = filesystem.readRestore(st.gpa, path) catch |err| { + const bytes = filesystem.readRestore(st.gpa, path, st.core.settings.dump_dir.get()) catch |err| { st.core.reportError(st.core.active, "Restore", err); return false; }; @@ -2431,7 +2431,7 @@ fn writeFile(ctx: ?*anyopaque, pane: u8, path: []const u8, bytes: []const u8) vo fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const st = hostState(ctx); var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf) orelse return; + const path = pardes.dump.outPath(&pbuf, st.core.settings.dump_dir.get()) orelse return; filesystem.write(st.core, path, bytes) catch |err| return st.core.reportError(0, "dump", err); st.core.setLastDump(path); } diff --git a/src/panes.zig b/src/panes.zig index b3424385..8d4330e9 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -183,6 +183,20 @@ pub const Pane = struct { tty_filter: bool = false, msg: [256]u8 = undefined, msg_len: u16 = 0, + /// Where the newest message line is in its life. + msg_life: MessageLife = .{}, + /// The row (among the notice rows) the newest line holds for its life. + msg_slot: u8 = 0, + /// Earlier message lines still on screen, oldest first, each running its + /// own life: a new message stacks under them instead of replacing them. + msg_older: [message_stack]OlderMessage = undefined, + msg_older_len: u8 = 0, + /// The newest line is a builtin announcing itself in this step: what + /// the step says next replaces it rather than stacking under it. + msg_announcement: bool = false, + /// The newest line is a status (progress, a server's state): the next + /// line replaces it rather than stacking under it. + msg_status: bool = false, /// The transient lines this pane shows between its body and its tagline: /// a builtin's message, the pending leader chord, a prompt waiting for /// input. Rebuilt every frame from the state that owns each one, so a @@ -192,30 +206,102 @@ pub const Pane = struct { /// reserves rows for sticky context headers. notices: Notices = .{}, + pub const MessagePhase = enum { entering, shown, lingering, leaving }; + /// One message line's animation: falling in, shown, lingering after the + /// key or click that dismissed it, dissolving. `frame` counts animation + /// frames within the phase. + pub const MessageLife = struct { + phase: MessagePhase = .shown, + frame: u16 = 0, + /// Dismissed while still falling: it lingers once it has landed. + dismissed: bool = false, + }; + /// Older message lines kept on screen under a new one. + pub const message_stack = 3; + pub const OlderMessage = struct { + text: [256]u8 = undefined, + len: u16 = 0, + life: MessageLife = .{}, + slot: u8 = 0, + + pub fn slice(line: *const OlderMessage) []const u8 { + return line.text[0..line.len]; + } + }; + + /// Move the newest line onto the stack of older ones, dropping the oldest + /// when it is full; the newest slot is left empty for what arrives. + pub fn pushOlderMessage(pane: *Pane) void { + if (pane.msg_older_len == message_stack) { + std.mem.copyForwards(OlderMessage, pane.msg_older[0 .. message_stack - 1], pane.msg_older[1..]); + pane.msg_older_len -= 1; + } + pane.msg_older[pane.msg_older_len] = .{ .text = pane.msg, .len = pane.msg_len, .life = pane.msg_life, .slot = pane.msg_slot }; + pane.msg_older_len += 1; + pane.msg_len = 0; + pane.msg_life = .{}; + } + + /// The first row no older line holds. A line keeps its row until it + /// leaves, and a new one fills the first gap: nothing on screen moves. + pub fn freeMessageSlot(pane: *const Pane) u8 { + var used: u8 = 0; + for (pane.msg_older[0..pane.msg_older_len]) |line| used |= @as(u8, 1) << @intCast(line.slot); + var slot: u8 = 0; + while (used & (@as(u8, 1) << @intCast(slot)) != 0) slot += 1; + return slot; + } + + pub fn removeOlderMessage(pane: *Pane, i: usize) void { + std.mem.copyForwards(OlderMessage, pane.msg_older[i .. pane.msg_older_len - 1], pane.msg_older[i + 1 .. pane.msg_older_len]); + pane.msg_older_len -= 1; + } + pub const Notices = struct { /// Painted bottom-up in this order, so a prompt keeps the row nearest /// the tagline that it has always had, and its cursor with it. - pub const Kind = enum { message, leader, prompt }; - pub const max = @typeInfo(Kind).@"enum".fields.len; + /// `older` is an earlier message line still on screen; `index` says + /// which (`Pane.msg_older`). + pub const Kind = enum { older, message, leader, prompt }; + pub const max = @typeInfo(Kind).@"enum".fields.len - 1 + message_stack; kinds: [max]Kind = undefined, + index: [max]u8 = @splat(0), + /// Each band's row below the body's top: message lines keep theirs, + /// so a band that leaves leaves a gap rather than moving the rest. + row: [max]u8 = @splat(0), /// Where each band's chip starts, as an absolute grid column. A chip /// is only as wide as its own text, so the rest of the row underneath /// is ordinary body text and a click there has to reach it. left: [max]u16 = @splat(std.math.maxInt(u16)), len: u8 = 0, + /// A band on the next row after every band so far. pub fn push(n: *Notices, kind: Kind) void { + n.pushAt(kind, 0, n.span()); + } + + pub fn pushAt(n: *Notices, kind: Kind, index: u8, row: u8) void { if (n.len >= max) return; n.kinds[n.len] = kind; + n.index[n.len] = index; + n.row[n.len] = row; n.left[n.len] = std.math.maxInt(u16); n.len += 1; } /// Is this cell inside the chip drawn for band `i`? Nothing else on /// that row belongs to the notice. - pub fn covers(n: *const Notices, i: usize, col: u16) bool { - return i < n.len and col >= n.left[i]; + pub fn covers(n: *const Notices, row: usize, col: u16) bool { + for (0..n.len) |i| if (n.row[i] == row) return col >= n.left[i]; + return false; + } + + /// Rows from the body's top down to the last band's. + pub fn span(n: *const Notices) u8 { + var rows: u8 = 0; + for (n.row[0..n.len]) |row| rows = @max(rows, row + 1); + return rows; } pub fn slice(n: *const Notices) []const Kind { @@ -1327,7 +1413,15 @@ pub const File = struct { const file = &pane.file.?; for ([_][]const u8{ "", "one", "one\n", "a\r\nλ界\nlast", "\n\n", "\tλ e\u{301}\n\r\n" }) |content| { setContent(p, file, try p.gpa.dupe(u8, content)); - try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + // An edit carries a built index forward; it must equal a fresh one. + if (file.line_starts.len > 0) { + const carried = file.line_starts; + defer p.gpa.free(carried); + file.line_starts = &.{}; + try std.testing.expectEqualSlices(usize, try lineIndex(p.gpa, file), carried); + p.gpa.free(file.line_starts); + file.line_starts = &.{}; + } const rows = lineCount(content) + 2; for ([_]bool{ false, true }) |indexed| { if (indexed) _ = try lineIndex(p.gpa, file); @@ -1546,6 +1640,80 @@ pub const File = struct { return starts; } + /// The line index of `new` from `old`'s: an edit replaces one span, so + /// line starts before it stand, starts after it shift, and only the span + /// is scanned. Rebuilding scanned the whole file on every keystroke. + fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize { + const n = @min(old.len, new.len); + var head: usize = 0; + while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; + while (head < n and old[head] == new[head]) head += 1; + var tail: usize = 0; + while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; + while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; + // A start s follows the newline at s-1: kept while that newline is in + // the common head, shifted while it is in the common tail. + const kept = std.sort.upperBound(usize, starts, head, struct { + fn order(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.order); + const old_tail_at = old.len - tail; + const moved = starts.len - std.sort.upperBound(usize, starts, old_tail_at, struct { + fn order(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.order); + const middle = new[head .. new.len - tail]; + const out = try gpa.alloc(usize, kept + std.mem.count(u8, middle, "\n") + moved); + @memcpy(out[0..kept], starts[0..kept]); + var i = kept; + var off: usize = 0; + while (std.mem.indexOfScalarPos(u8, middle, off, '\n')) |nl| { + off = nl + 1; + out[i] = head + off; + i += 1; + } + for (starts[starts.len - moved ..]) |start| { + out[i] = start + new.len - old.len; + i += 1; + } + return out; + } + + test "edited line index equals a rebuilt one" { + const gpa = std.testing.allocator; + var prng = std.Random.DefaultPrng.init(7); + const r = prng.random(); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + for (0..300) |_| try text.append(gpa, "ab\n"[r.uintLessThan(usize, 3)]); + for (0..2000) |_| { + var old_state: State = undefined; + old_state.content = text.items; + old_state.line_starts = &.{}; + const starts = try lineIndex(gpa, &old_state); + defer gpa.free(starts); + // one random replace, like any edit through setContent + const at = r.uintAtMost(usize, text.items.len); + const del = r.uintAtMost(usize, @min(8, text.items.len - at)); + var ins: [8]u8 = undefined; + const ins_len = r.uintAtMost(usize, 8); + for (ins[0..ins_len]) |*c| c.* = "ab\n"[r.uintLessThan(usize, 3)]; + const old = try gpa.dupe(u8, text.items); + defer gpa.free(old); + try text.replaceRange(gpa, at, del, ins[0..ins_len]); + const edited = try editedLineStarts(gpa, starts, old, text.items); + defer gpa.free(edited); + var fresh_state: State = undefined; + fresh_state.content = text.items; + fresh_state.line_starts = &.{}; + const fresh = try lineIndex(gpa, &fresh_state); + defer gpa.free(fresh); + try std.testing.expectEqualSlices(usize, fresh, edited); + } + } + /// line count, O(1) once the index is warm pub fn nlines(gpa: std.mem.Allocator, f: *State) usize { const idx = lineIndex(gpa, f) catch return lineCount(f.content); @@ -1760,12 +1928,13 @@ pub const File = struct { reportEdit(p, f, new); if (f.mini) |*mini| mini.deinit(p.gpa); f.mini = null; + const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{}; p.gpa.free(f.content); f.content = new; f.revision +%= 1; f.mtime = pardes.ctlfs.events.now(); if (f.line_starts.len > 0) p.gpa.free(f.line_starts); - f.line_starts = &.{}; + f.line_starts = starts; if (f.highlights.len > 0) p.tree_sitter_gpa.free(f.highlights); f.highlights = &.{}; f.highlight_start = 0; @@ -2864,7 +3033,10 @@ pub const Output = struct { pub fn openConfig(p: *Pardes, id: usize) !void { var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); + var dir_buf: [1024]u8 = undefined; + const host_fs = comptime (pardes.platform != .web and pardes.platform != .esp32p4); try config.Runtime.writeReport(&out.writer, .{ + .default_dump_dir = if (host_fs) (pardes.dump.defaultDirectory(&dir_buf) orelse "") else "", .startup_config_path = p.opts.startup_config_path, .platform = @tagName(pardes.platform), .theme_name = p.theme().name, @@ -3599,6 +3771,9 @@ pub const Pdf = struct { const Page = struct { page: usize, items: []const Highlight }; pages: [3]Page = undefined, len: usize = 0, + /// Repaint whenever the set changes, not only when a page's raster was + /// invalidated (`HighlightInput.live`). + live: bool = false, pub fn forPage(highlights: @This(), page: usize) []const Highlight { for (highlights.pages[0..highlights.len]) |group| @@ -3612,6 +3787,7 @@ pub const Pdf = struct { hover_page: ?usize = null, hover_color: [3]u8, selection_color: [3]u8, + live: bool = false, } else void; pub fn buildHighlights( @@ -3623,7 +3799,7 @@ pub const Pdf = struct { const search_len = if (state.search_results) |results| results.quads.len else 0; const selection_len = if (state.selection) |selection| selection.quads.len else 0; const items = try arena.alloc(Highlight, input.hover_quads.len + search_len + selection_len); - var result: Highlights = .{}; + var result: Highlights = .{ .live = input.live }; var n: usize = 0; // Hover, search and selection can belong to three different pages. // Group them once so raster rendering can borrow a contiguous slice. @@ -3721,6 +3897,19 @@ pub const Pdf = struct { decorated: bool = false, tint_key: ?TintKey = null, revision: u32 = 0, + /// `rgba` before its highlights and tint, kept once the page has been + /// highlighted: a highlight change repaints rows from it instead of + /// rendering the page again. + clean: []u8 = &.{}, + /// The highlights baked into `rgba` (gpa-owned), so a change repaints + /// only the rows of the quads that differ. + baked: []Highlight = &.{}, + /// `baked` is a true record (false after a render could not copy it). + baked_valid: bool = false, + /// Page rows changed since revision `patch_from`, the one last handed + /// to a shell; 0 when the pixels were replaced wholesale since. + patch_from: u32 = 0, + patch_rows: pdf.Document.Rows = .{ .start = 0, .end = 0 }, } else void; pub const SectionsOutput = if (enabled) struct { @@ -3894,6 +4083,8 @@ pub const Pdf = struct { } fn retireRaster(state: *@This(), gpa: std.mem.Allocator, raster: *Raster) void { + if (raster.clean.len > 0) gpa.free(raster.clean); + gpa.free(raster.baked); state.retireRgba(gpa, raster.rgba); raster.* = .{}; } @@ -4138,8 +4329,11 @@ pub const Pdf = struct { pub fn deinit(state: *@This(), gpa: std.mem.Allocator) void { gpa.free(state.path); - for (state.rasters[0..state.rasters_len]) |raster| + for (state.rasters[0..state.rasters_len]) |raster| { if (raster.rgba.len > 0) gpa.free(raster.rgba); + if (raster.clean.len > 0) gpa.free(raster.clean); + gpa.free(raster.baked); + } for (state.spare[0..state.spare_len]) |rgba| gpa.free(rgba); gpa.free(state.page_sizes); gpa.free(state.page_starts); @@ -4614,16 +4808,27 @@ pub const Pdf = struct { const slot = raster.?; const page_highlights = highlights.forPage(page); const decorated = page_highlights.len > 0; - const stale = !slot.tried or !slot.request_valid or - !slot.request.eql(request) or slot.decorated != decorated or + const base_stale = !slot.request_valid or !slot.request.eql(request) or slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or slot.rgba.len == 0 or slot.band_h == 0; - const uncovered = !stale and uncovered: { + const uncovered = !base_stale and uncovered: { const want = wantedBand(state, viewport, page, slotShape(slot), is_flinging); break :uncovered slot.band_y > want.y or slot.band_y + slot.band_h < want.y + want.height; }; - if (!stale and !uncovered) continue; + if (!base_stale and !uncovered) { + if (slot.tried and slot.decorated == decorated and + !(highlights.live and !sameHighlights(slot.baked, page_highlights))) continue; + // Invalidated, but it already shows exactly these highlights + // (a preview withdrawn that was never drawn, on every pointer + // move over a page): nothing to render. + if (slot.baked_valid and sameHighlights(slot.baked, page_highlights)) { + slot.tried = true; + continue; + } + // Only the highlights moved: repaint their rows in place. + if (repaintHighlights(state, gpa, slot, request, tint_key, page_highlights)) continue; + } slot.tried = true; slot.request = request; @@ -4640,15 +4845,27 @@ pub const Pdf = struct { { const tz_render = tracy.zone(@src(), "pdf.render_into"); defer tz_render.end(); + // A highlighted page keeps its clean rows for repaints. + const keep_clean = pardes.platform != .macos and (decorated or slot.clean.len > 0); state.document.renderIntoAtWithPaper( page, request, shape, want, - page_highlights, + if (keep_clean) &.{} else page_highlights, fresh, pardes.platform == .macos, ) catch break :filled false; + if (slot.clean.len != 0 and (!keep_clean or slot.clean.len != fresh.len)) { + gpa.free(slot.clean); + slot.clean = &.{}; + } + if (keep_clean) { + if (slot.clean.len == 0) slot.clean = gpa.alloc(u8, fresh.len) catch &.{}; + if (slot.clean.len == fresh.len) @memcpy(slot.clean, fresh); + state.document.paintHighlightsAt(page, request, shape, want, page_highlights, fresh) catch + break :filled false; + } } const tz_tint = tracy.zone(@src(), "pdf.tint"); defer tz_tint.end(); @@ -4658,6 +4875,9 @@ pub const Pdf = struct { }; if (!filled) { state.retireRgba(gpa, fresh); + // `clean` may already hold the band that failed. + if (slot.clean.len > 0) gpa.free(slot.clean); + slot.clean = &.{}; continue; } @@ -4669,6 +4889,18 @@ pub const Pdf = struct { slot.band_h = want.height; slot.decorated = decorated; slot.tint_key = tint_key; + if (gpa.dupe(Highlight, page_highlights)) |copy| { + gpa.free(slot.baked); + slot.baked = copy; + slot.baked_valid = true; + } else |_| { + // Unrecorded highlights cannot be compared or diffed. + slot.baked_valid = false; + if (slot.clean.len > 0) gpa.free(slot.clean); + slot.clean = &.{}; + } + slot.patch_from = 0; + slot.patch_rows = .{ .start = 0, .end = 0 }; state.next_raster_revision +%= 1; if (state.next_raster_revision == 0) state.next_raster_revision = 1; slot.revision = state.next_raster_revision; @@ -4676,6 +4908,114 @@ pub const Pdf = struct { state.trimSpares(gpa); } + /// Repaint only the rows where the old and new highlights differ: the + /// clean rows kept from the page's last render, MuPDF's highlight pass + /// (the whole new set) over them, the per-pixel tint — the operations a + /// full render performs, in its order, on fewer rows and without running + /// the page again. False leaves the slot for the full path. + fn repaintHighlights( + state: *State, + gpa: std.mem.Allocator, + slot: *Raster, + request: RenderRequest, + tint_key: TintKey, + page_highlights: []const Highlight, + ) bool { + const tz = tracy.zone(@src(), "pdf.repaint_highlights"); + defer tz.end(); + if (slot.clean.len != slot.rgba.len or !slot.baked_valid) return false; + if (sameHighlights(slot.baked, page_highlights)) { + slot.tried = true; + return true; + } + const baked = gpa.dupe(Highlight, page_highlights) catch return false; + const changed = changedHighlights(gpa, slot.baked, page_highlights) catch { + gpa.free(baked); + return false; + }; + defer gpa.free(changed); + var dirty = state.document.highlightRows(slot.page, request, changed) catch { + gpa.free(baked); + return false; + }; + slot.tried = true; + dirty.start = @max(dirty.start, slot.band_y); + dirty.end = @min(dirty.end, slot.band_y + slot.band_h); + if (dirty.start < dirty.end) { + const shape = slotShape(slot); + const band = shape.band(dirty.start, dirty.end - dirty.start); + const offset = (dirty.start - slot.band_y) * shape.stride; + const out = slot.rgba[offset..][0..band.len]; + @memcpy(out, slot.clean[offset..][0..band.len]); + // On failure the rows are half-made: let the full path redo them. + state.document.paintHighlightsAt(slot.page, request, shape, band, page_highlights, out) catch { + gpa.free(baked); + slot.tried = false; + return false; + }; + pdf.tintRgba(out, tint_key.mode, tint_key.colors) catch { + gpa.free(baked); + slot.tried = false; + return false; + }; + slot.patch_rows = unionRows(slot.patch_rows, dirty); + state.next_raster_revision +%= 1; + if (state.next_raster_revision == 0) state.next_raster_revision = 1; + slot.revision = state.next_raster_revision; + } + gpa.free(slot.baked); + slot.baked = baked; + slot.decorated = page_highlights.len > 0; + return true; + } + + fn sameHighlights(a: []const Highlight, b: []const Highlight) bool { + return std.mem.eql(u8, std.mem.sliceAsBytes(a), std.mem.sliceAsBytes(b)); + } + + /// The highlights in one set and not the other, as multisets (a quad + /// painted twice blends twice). When only the order changed, blending + /// order did, so everything counts. + // ponytail: quadratic match; past 64k comparisons every quad counts. + fn changedHighlights(gpa: std.mem.Allocator, old: []const Highlight, new: []const Highlight) ![]Highlight { + const out = try gpa.alloc(Highlight, old.len + new.len); + errdefer gpa.free(out); + var n: usize = 0; + if (old.len * new.len <= 1 << 16) { + const matched = try gpa.alloc(bool, new.len); + defer gpa.free(matched); + @memset(matched, false); + for (old) |h| { + for (new, matched) |candidate, *used| { + if (!used.* and std.mem.eql(u8, std.mem.asBytes(&h), std.mem.asBytes(&candidate))) { + used.* = true; + break; + } + } else { + out[n] = h; + n += 1; + } + } + for (new, matched) |h, used| if (!used) { + out[n] = h; + n += 1; + }; + } + if (n == 0) { + @memcpy(out[0..old.len], old); + @memcpy(out[old.len..], new); + return out; + } + // Freed by length: hand back exactly what the caller will free. + return gpa.realloc(out, n); + } + + fn unionRows(a: pdf.Document.Rows, b: pdf.Document.Rows) pdf.Document.Rows { + if (a.start >= a.end) return b; + if (b.start >= b.end) return a; + return .{ .start = @min(a.start, b.start), .end = @max(a.end, b.end) }; + } + pub fn renderFrame( state: *State, gpa: std.mem.Allocator, @@ -5837,6 +6177,7 @@ pub const Pdf = struct { .hover_page = hover_page, .hover_color = if (link_hover) core.theme().box else selection_color, .selection_color = selection_color, + .live = core.native_image_patches, }; } @@ -5907,6 +6248,16 @@ pub const Pdf = struct { const visible_end = visible.first + visible.len; while (page < visible_end) : (page += 1) { const placed = placedRaster(state, view, page) orelse continue; + const slot = rasterForPage(state, page).?; + const patch: ?pardes.ImagePatch = if (slot.patch_from != 0 and + slot.patch_rows.start < slot.patch_rows.end) + .{ + .from = slot.patch_from, + .y = @intCast(slot.patch_rows.start - slot.band_y), + .h = @intCast(slot.patch_rows.end - slot.patch_rows.start), + } + else + null; if (!core.appendImagePlace(.{ .pane = @intCast(pane_id), .serial = pane.serial, @@ -5928,14 +6279,18 @@ pub const Pdf = struct { .x = text_x, // Below the notice chips, like an image: a placed page is // drawn after the cells and would paint a chip out. - .y = core.bodyTop(rect) + pane.notices.len, + .y = core.bodyTop(rect) + pane.notices.span(), .w = text_width, - .h = (rect.h -| pardes.BOX_H) -| pane.notices.len, + .h = (rect.h -| pardes.BOX_H) -| pane.notices.span(), .rgba = placed.rgba, .iw = placed.width, // The texture contains the retained band, not the full page. .ih = placed.band_height, + .patch = patch, })) break; + // A shell now holds (or will fetch whole) this revision. + slot.patch_from = slot.revision; + slot.patch_rows = .{ .start = 0, .end = 0 }; placed_any = true; } if (!placed_any) return false; @@ -6439,9 +6794,15 @@ pub const Terminal = struct { return pane; } - /// Rebuild a dump's dead terminal emulator. Registration and tag/cwd policy - /// stay with the core; raw VT replay and viewport restoration belong here. - pub fn restore(p: *Pardes, src: dump.Pane) !*Pane { + /// Where a restored terminal's history ends and its new shell begins. + pub const restored_banner = "\x1b[0m\r\n\x1b[2m\u{2500}\u{2500} restored history \u{2500}\u{2500}\x1b[0m\r\n"; + + /// Rebuild a dump's terminal emulator from its recorded output. `live`: a + /// shell will be spawned into it, so the history is marked off from what + /// that shell prints and the pane waits for it like any new terminal. + /// Registration and tag/cwd policy stay with the core; raw VT replay and + /// viewport restoration belong here. + pub fn restore(p: *Pardes, src: dump.Pane, live: bool) !*Pane { const terminal = src.terminal.?; if (comptime !enabled) { const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); @@ -6450,12 +6811,28 @@ pub const Terminal = struct { pane.ovl = .{ .row = 0, .rows = 1, .text = try p.gpa.dupe(u8, terminal.stream) }; return pane; } - const bytes = if (terminal.stream_b64.len > 0) + // An older dump's raw output tail cannot come back live: cut off mid + // redraw, or written at sizes the pane no longer has, it replays as + // garbage. Its rendered text can. + const legacy_text = live and !terminal.stream_is_screen and terminal.stream.len > 0; + const bytes = if (legacy_text) + try std.mem.replaceOwned(u8, p.scratch.allocator(), terminal.stream, "\n", "\r\n") + else if (terminal.stream_b64.len > 0) try dump.decodeBytes(p.scratch.allocator(), terminal.stream_b64) else &.{}; const pane = try create(p.gpa, @max(1, src.cols), @max(1, src.rows)); - if (bytes.len > 0) { + if (live) { + if (bytes.len > 0) { + ingest(pane, bytes); + ingest(pane, restored_banner); + } + followOutput(pane); + // A view left scrolled back stays on what it was showing: the + // banner's line breaks scrolled a full screen by that many more. + if (src.scroll > 0) scrollGrid(pane, -@as(i32, @intCast(src.scroll + comptime std.mem.count(u8, restored_banner, "\n")))); + armShellSpawn(pane); + } else if (bytes.len > 0) { ingest(pane, bytes); followOutput(pane); if (src.scroll > 0) @@ -6613,7 +6990,7 @@ pub const Terminal = struct { .cols = pane.cols, .rows = pane.rows, .terminal = .{ .stream_b64 = encoded }, - }); + }, false); defer gpa.destroy(restored); defer deinitEmulator(restored, gpa); const before = try screenTextAlloc(pane, gpa); @@ -6624,6 +7001,58 @@ pub const Terminal = struct { try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa)); } + test "a restored terminal keeps its history above a marker and gets a shell where it was" { + if (comptime !enabled or pardes.isolated) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[0].?; + feedOutput(p, pane, "old output\r\n$ ls\r\n"); + try pane.setOwnedCwd("/tmp"); + try p.dumpState(); + const restored = try Pardes.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?); + defer restored.deinit(); + var spawned = false; + while (restored.nextEffect()) |effect| switch (effect) { + .spawn => |sp| spawned = sp.pane == 0 and std.mem.eql(u8, sp.cwd.slice(), "/tmp"), + else => {}, + }; + try std.testing.expect(spawned); + const again = restored.panes[0].?; + try std.testing.expectEqual(.spawn, again.pending_command.wait); + const text = try screenTextAlloc(again, gpa); + defer gpa.free(text); + const old_at = std.mem.indexOf(u8, text, "old output") orelse return error.HistoryLost; + const mark_at = std.mem.indexOf(u8, text, "restored history") orelse return error.NoMarker; + try std.testing.expect(old_at < mark_at); + } + + test "an older dump comes back live from its rendered text, not its raw output tail" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + // A tail cut mid-redraw: relative moves from a cursor it never set. + const tail = try dump.encodeBytes(gpa, "\x1b[7A\x1b[2Kgarbage\x1b[3B\x1b[9Gmore"); + defer gpa.free(tail); + const restored = try restore(p, .{ + .kind = .terminal, + .tag = "", + .body = "", + .cols = 40, + .rows = 12, + .terminal = .{ .stream = "$ ls\na.txt b.txt\n$ ", .stream_b64 = tail }, + }, true); + defer gpa.destroy(restored); + defer deinitEmulator(restored, gpa); + defer deinitPendingCommand(restored); + const text = try screenTextAlloc(restored, gpa); + defer gpa.free(text); + try std.testing.expect(std.mem.indexOf(u8, text, "$ ls\na.txt b.txt") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "garbage") == null); + } + /// Record and parse one live pty read, invalidate its motion surface, and /// follow it only when the body (possibly parked under a tag edit) is raw. pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { @@ -7247,6 +7676,16 @@ pub const Terminal = struct { overlay.text = new; } + /// The shell's screen and scrollback (the primary screen, even under a + /// full-screen program) as VT that replays to what was shown: styled + /// text, soft-wrapped lines rejoined so they reflow at another width. + fn screenVt(pane: *const Pane, arena: std.mem.Allocator) ![]const u8 { + const screen = pane.terminal.?.vt.screens.get(.primary) orelse return &.{}; + var out: std.Io.Writer.Allocating = .init(arena); + try ghostty_vt.formatter.ScreenFormatter.init(screen, .{ .emit = .vt, .unwrap = true }).format(&out.writer); + return out.written(); + } + /// Serialize terminal-only state; the core supplies shared pane metadata. pub fn dumpPane( pane: *Pane, @@ -7318,7 +7757,8 @@ pub const Terminal = struct { .terminal = .{ .cwd = try arena.dupe(u8, pane.cwdSlice()), .stream = stream[0..len], - .stream_b64 = try dump.encodeBytes(arena, try replayBytes(pane, arena)), + .stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)), + .stream_is_screen = true, .cursor = .{ .col = pane.terminal.?.vt.screens.active.cursor.x, .row = pane.terminal.?.vt.screens.active.cursor.y, diff --git a/src/pardes.zig b/src/pardes.zig index cc57127f..fae14f47 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -2722,8 +2722,16 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it try std.testing.expect(p.executeBuiltinLine(2, "Msg")); try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2)); - // A message is exactly as old as your last input. + // Input starts its exit: it lingers, dissolves, and is gone. p.update(.{ .key = .{ .cp = Key.escape } }); + try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2)); + for (0..200) |_| p.update(.tick); + try std.testing.expectEqualStrings("", row(p, 2)); + // ...and with neither linger nor animation it is as old as your last input. + try std.testing.expect(p.executeBuiltinLine(2, "MessageAnimation off")); + try std.testing.expect(p.executeBuiltinLine(2, "MessageLinger 0")); + try std.testing.expect(p.executeBuiltinLine(2, "Msg gone")); + p.update(.{ .key = .{ .cp = 'h' } }); try std.testing.expectEqualStrings("", row(p, 2)); // Longer than the row's buffer is CUT, never refused and never overrun: @@ -2735,6 +2743,151 @@ test "Msg writes the transient row by hand, bare or with text, and input ends it try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len); } +test "a message falls in, lingers past the input that dismisses it, and dissolves" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[p.active].?; + try std.testing.expect(p.settings.message_animation); + try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160")); + try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello")); + try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase); + try std.testing.expect(p.animationActive()); + // An ease-in fall: starts a whole row up, lands exactly on it. + const start = p.messageMotion(pane.msg_life); + try std.testing.expectEqual(@as(f32, -1), start.slide); + var last = start.slide; + for (0..Pardes.messageFrames(p.settings.message_fall_ms)) |_| { + p.needs_frame = false; + p.update(.tick); + // Every tick that moves it owes a frame, the landing one included. + try std.testing.expect(p.needs_frame); + const now = p.messageMotion(pane.msg_life); + try std.testing.expect(now.slide >= last); + last = now.slide; + } + try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); + try std.testing.expectEqual(@as(f32, 0), p.messageMotion(pane.msg_life).slide); + try std.testing.expect(!p.animationActive()); + // Shown until input, like always; the same line again does not re-enter. + for (0..100) |_| p.update(.tick); + try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); + try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello")); + try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); + // Input (a key that announces nothing): 160 ms of lingering (ten frames), unaffected by more input... + p.update(.{ .key = .{ .cp = 'h' } }); + try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase); + for (0..9) |_| { + p.update(.tick); + p.update(.{ .key = .{ .cp = 'h' } }); + } + try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase); + p.update(.tick); + // ...then an ease-out dissolve toward nothing, and gone. + try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); + var alpha: f32 = 1; + for (0..Pardes.messageFrames(p.settings.message_dissolve_ms)) |_| { + const now = p.messageMotion(pane.msg_life).alpha; + try std.testing.expect(now <= alpha); + alpha = now; + p.needs_frame = false; + p.update(.tick); + } + try std.testing.expectEqual(@as(u16, 0), pane.msg_len); + try std.testing.expect(!p.animationActive()); + // The tick that clears it still owes the frame that shows it gone. + try std.testing.expect(p.needs_frame); + + // Dismissed mid-fall: it lands, then lingers. + try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again")); + try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase); + p.update(.tick); + p.update(.{ .key = .{ .cp = 'h' } }); + try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase); + for (0..Pardes.messageFrames(p.settings.message_fall_ms)) |_| p.update(.tick); + try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase); + // Shown again while lingering: fresh, so it stays; a status update to a + // line on screen swaps its text in place instead of falling again. + try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again")); + try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); + p.setStatus(p.active, "progress 50%"); + try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); + try std.testing.expectEqualStrings("progress 50%", pane.msg[0..pane.msg_len]); + try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len); + // The fall and the dissolve are timings of their own, in milliseconds. + try std.testing.expect(p.executeBuiltinLine(p.active, "MessageFall 48")); + try std.testing.expect(p.executeBuiltinLine(p.active, "MessageDissolve 0")); + try std.testing.expectEqual(@as(u16, 3), Pardes.messageFrames(p.settings.message_fall_ms)); + try std.testing.expectEqual(@as(u16, 1), Pardes.messageFrames(p.settings.message_dissolve_ms)); + // The Config report reads back as configuration. + try std.testing.expect(p.executeBuiltinLine(p.active, "MessageAnimation")); + try std.testing.expect(!p.settings.message_animation); + try std.testing.expect(p.executeBuiltinLine(p.active, "Msg plain")); + try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); +} + +test "messages stack, oldest on top, each on its own timing" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = p.panes[p.active].?; + try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off")); + try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160")); + // `Verbose off` announced itself before it took effect. + pane.msg_len = 0; + pane.msg_life = .{}; + const fall = Pardes.messageFrames(p.settings.message_fall_ms); + p.setMessage(p.active, "first"); + for (0..fall) |_| p.update(.tick); + try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); + // A second message stacks under the first instead of replacing it. + p.setMessage(p.active, "second"); + try std.testing.expectEqual(@as(u8, 1), pane.msg_older_len); + try std.testing.expectEqualStrings("first", pane.msg_older[0].slice()); + try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]); + try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase); + p.collectNotices(pane, p.rects[p.active], p.active); + try std.testing.expectEqual(Pane.Notices.Kind.older, pane.notices.kinds[0]); + try std.testing.expectEqual(Pane.Notices.Kind.message, pane.notices.kinds[1]); + // One key dismisses both, but each runs its own clock: the first starts + // lingering now, the second only once it has landed. + p.update(.{ .key = .{ .cp = 'h' } }); + try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_older[0].life.phase); + try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase); + const linger = Pardes.messageFrames(p.settings.message_linger_ms); + const dissolve = Pardes.messageFrames(p.settings.message_dissolve_ms); + for (0..linger + dissolve) |_| p.update(.tick); + // The first is gone; the second, a fall later, is still dissolving. + try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len); + try std.testing.expectEqualStrings("second", pane.msg[0..pane.msg_len]); + try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); + for (0..fall) |_| p.update(.tick); + try std.testing.expectEqual(@as(u16, 0), pane.msg_len); + try std.testing.expect(!p.animationActive()); + // A status ticking in place does not pile up. + p.setStatus(p.active, "lsp 10%"); + p.setStatus(p.active, "lsp 20%"); + try std.testing.expectEqual(@as(u8, 0), pane.msg_older_len); + // Lines keep their rows: when the top one leaves, the one under it does + // not move up, and the next message fills the gap instead. + p.setMessage(p.active, "top"); + p.setMessage(p.active, "under"); + try std.testing.expectEqual(@as(u8, 0), pane.msg_older[0].slot); + try std.testing.expectEqual(@as(u8, 1), pane.msg_slot); + pane.removeOlderMessage(0); + p.collectNotices(pane, p.rects[p.active], p.active); + try std.testing.expectEqual(@as(u8, 1), pane.notices.row[0]); + try std.testing.expectEqual(@as(u8, 2), pane.notices.span()); + p.setMessage(p.active, "fills"); + try std.testing.expectEqual(@as(u8, 0), pane.msg_slot); + try std.testing.expectEqual(@as(u8, 1), pane.msg_older[0].slot); + // The stack keeps the newest few. + for ([_][]const u8{ "a", "b", "c", "d", "e" }) |text| p.setMessage(p.active, text); + try std.testing.expectEqual(@as(u8, Pane.message_stack), pane.msg_older_len); + try std.testing.expectEqualStrings("b", pane.msg_older[0].slice()); + try std.testing.expectEqualStrings("e", pane.msg[0..pane.msg_len]); +} + test "Font keeps requested and acknowledged faces as separate plain state" { if (comptime !font_picker) return; // there is no Font builtin on tty or web const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); @@ -3774,6 +3927,8 @@ pub const NativePlacement = if (pdf_enabled) struct { paper_bg: ?[3]u8 = null, } else struct {}; +pub const ImagePatch = struct { from: u32, y: u32, h: u32 }; + pub const ImagePlace = struct { pane: u8, /// Pane slots are reused. This identity makes a cached GPU texture or @@ -3787,6 +3942,10 @@ pub const ImagePlace = struct { rgba: []const u8, iw: usize, ih: usize, + /// Rows [y, y+h) of `rgba` are the only ones that differ from the + /// same pane page at revision `from`: a shell still holding that one may + /// update just these rows instead of taking the whole image again. + patch: ?ImagePatch = null, pub fn cacheKey(place: ImagePlace) ImageCacheKey { if (comptime pdf_enabled) @@ -6194,6 +6353,10 @@ pub const Pardes = struct { chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome), animate_theme_changes: bool = false, native_images: bool = false, + /// The shell can take a changed image a few rows at a time (a texture row + /// upload, a shared-memory hand-off), so a PDF selection is repainted as + /// it is dragged instead of once on release. + native_image_patches: bool = false, quit: bool = false, haptic: HapticSlot = if (platform == .macos) .none else {}, drag: Drag = .none, @@ -6250,6 +6413,9 @@ pub const Pardes = struct { /// Is there a frame worth drawing? Cleared once one is presented, set by /// anything that can change what is on screen. The first frame always is. needs_frame: bool = true, + /// Set by a shell whose present was skipped (no swapchain image): the + /// frame is still owed, and the next pump draws it again. + present_skipped: bool = false, /// Builtins announce themselves on the message row. Off while startup /// configuration is applied: those lines are nobody clicking, and should /// not leave the row holding the name of the last one. @@ -7040,16 +7206,18 @@ pub const Pardes = struct { } }; + /// A status updates the message line in place (a progress count does not + /// pile up); a message is an event, and stacks. pub fn setStatus(p: *Pardes, id: usize, text: []const u8) void { - p.showMessage(id, text); + p.showMessage(id, text, false); } pub fn setMessage(p: *Pardes, id: usize, text: []const u8) void { p.logMessage(id, text); - p.showMessage(id, text); + p.showMessage(id, text, true); } - fn showMessage(p: *Pardes, id: usize, text: []const u8) void { + fn showMessage(p: *Pardes, id: usize, text: []const u8, stack: bool) void { if (id >= MAX_PANES) return; const pane = p.panes[id] orelse return; // One row of printable text: a language server's multi-line report @@ -7058,8 +7226,158 @@ pub const Pardes = struct { if (n < text.len) { while (n > 0 and text[n] & 0xC0 == 0x80) n -= 1; } + const same = pane.msg_len == n and for (text[0..n], pane.msg[0..n]) |c, have| { + if ((if (c < 0x20 or c == 0x7f) ' ' else c) != have) break false; + } else true; + // A new event moves the line on screen up the stack, where it keeps + // its own timing, and falls in below it. + // Only an event stacks on an event: an announcement or a status + // stands in for whatever comes next. + if (stack and pane.msg_len > 0 and !same and !pane.msg_announcement and !pane.msg_status) + pane.pushOlderMessage(); + if (pane.msg_len == 0) pane.msg_slot = pane.freeMessageSlot(); + pane.msg_announcement = false; + pane.msg_status = !stack; + const arrives = pane.msg_len == 0 or pane.msg_life.phase == .leaving; for (text[0..n], pane.msg[0..n]) |c, *cell| cell.* = if (c < 0x20 or c == 0x7f) ' ' else c; pane.msg_len = @intCast(n); + p.needs_frame = true; + if (arrives) { + pane.msg_life = .{ .phase = if (p.settings.message_animation and n > 0) .entering else .shown }; + } else if (pane.msg_life.phase == .lingering or + (pane.msg_life.phase == .entering and !p.settings.message_animation)) + { + // The same line again, or a status update: fresh, so it stays. + pane.msg_life = .{}; + } else { + pane.msg_life.dismissed = false; + } + } + + /// The key or click that used to clear the message row now starts each + /// line's exit: it stays `message_linger_ms`, then dissolves. A line + /// already on its way out keeps its own timing. + fn dismissMessage(p: *Pardes, pane: *Pane) void { + if (pane.msg_len > 0 and p.dismissLine(&pane.msg_life)) { + pane.msg_len = 0; + pane.msg_life = .{}; + } + var i: usize = 0; + while (i < pane.msg_older_len) { + if (p.dismissLine(&pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1; + } + } + + /// Start one line's exit; true when it is gone at once (no linger, no + /// animation). + fn dismissLine(p: *const Pardes, life: *Pane.MessageLife) bool { + switch (life.phase) { + .lingering, .leaving => return false, + // A falling line lands first, then lingers -- unless falling was + // just turned off, and nothing will land it. + .entering => if (p.settings.message_animation) { + life.dismissed = true; + return false; + }, + .shown => {}, + } + life.frame = 0; + if (p.settings.message_linger_ms > 0) { + life.phase = .lingering; + } else if (p.settings.message_animation) { + life.phase = .leaving; + } else return true; + return false; + } + + /// One animation frame of every message line. Each one that moves owes + /// a frame, the one that finishes included: a tick asks for none itself. + fn advanceMessages(p: *Pardes) void { + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.msg_len > 0 and pane.msg_life.phase != .shown) { + p.needs_frame = true; + if (p.advanceLine(&pane.msg_life)) { + pane.msg_len = 0; + pane.msg_life = .{}; + } + } + var i: usize = 0; + while (i < pane.msg_older_len) { + if (pane.msg_older[i].life.phase != .shown) p.needs_frame = true; + if (p.advanceLine(&pane.msg_older[i].life)) pane.removeOlderMessage(i) else i += 1; + } + } + } + + /// One frame of one line's life; true once it has left. + fn advanceLine(p: *const Pardes, life: *Pane.MessageLife) bool { + if (life.phase == .shown) return false; + life.frame +|= 1; + switch (life.phase) { + .shown => {}, + .entering => if (life.frame >= messageFrames(p.settings.message_fall_ms)) { + life.* = .{ .phase = if (life.dismissed) .lingering else .shown }; + }, + .lingering => if (life.frame >= messageFrames(p.settings.message_linger_ms)) { + if (!p.settings.message_animation) return true; + life.* = .{ .phase = .leaving }; + }, + .leaving => if (life.frame >= messageFrames(p.settings.message_dissolve_ms)) return true, + } + return false; + } + + /// Is any message line moving (and so owed ticks)? + fn messagesAnimating(pane: *const Pane) bool { + if (pane.msg_len > 0 and pane.msg_life.phase != .shown) return true; + for (pane.msg_older[0..pane.msg_older_len]) |line| if (line.life.phase != .shown) return true; + return false; + } + + /// How present a message is (1 = fully) and how far above its row it + /// still is (rows), eased: an ease-in fall, an ease-out dissolve. + const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 }; + + /// Animation frames a message timing lasts; at least one. + pub fn messageFrames(ms: u16) u16 { + return @intCast(@max(1, (@as(u32, ms) + layout.Animation.frame_ms - 1) / layout.Animation.frame_ms)); + } + + fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion { + const fall_frames = messageFrames(p.settings.message_fall_ms); + const dissolve_frames = messageFrames(p.settings.message_dissolve_ms); + return switch (life.phase) { + .shown, .lingering => .{ .alpha = 1, .slide = 0 }, + .entering => blk: { + const t = @as(f32, @floatFromInt(@min(life.frame, fall_frames))) / @as(f32, @floatFromInt(fall_frames)); + const eased = t * t; + break :blk .{ .alpha = eased, .slide = eased - 1 }; + }, + .leaving => blk: { + const t = @as(f32, @floatFromInt(@min(life.frame, dissolve_frames))) / @as(f32, @floatFromInt(dissolve_frames)); + break :blk .{ .alpha = (1 - t) * (1 - t), .slide = 0 }; + }, + }; + } + + /// The life of the message line notice `i` shows, if it shows one. + fn noticeLife(pane: *const Pane, i: usize) ?Pane.MessageLife { + return switch (pane.notices.kinds[i]) { + .message => pane.msg_life, + .older => pane.msg_older[pane.notices.index[i]].life, + .leader, .prompt => null, + }; + } + + fn blendRgb(from: [3]u8, to: [3]u8, amount: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, from, to) |*o, a, b| { + const af: f32 = @floatFromInt(a); + const bf: f32 = @floatFromInt(b); + o.* = @intFromFloat(@round(bf + (af - bf) * std.math.clamp(amount, 0, 1))); + } + return out; } fn logMessage(p: *Pardes, id: usize, text: []const u8) void { @@ -7358,8 +7676,11 @@ pub const Pardes = struct { _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); + // Settled before post_present: a shell ticks there, and what that + // tick moves owes the next frame. + p.needs_frame = p.present_skipped; + p.present_skipped = false; if (v.post_present) |f| f(h.ctx); - p.needs_frame = false; } /// One request of the control filesystem, from whichever thread has the @@ -7389,6 +7710,10 @@ pub const Pardes = struct { // the screen. p.needs_frame = p.needs_frame or ev != .tick; p.shell_rows.sweep(p.gpa); + // An announcement stands in only until its own step says more. + for (p.panes) |slot| if (slot) |pane| { + pane.msg_announcement = false; + }; switch (ev) { .tick => {}, .mouse => |m| if (!(m.button == .none and m.kind == .motion)) { @@ -7415,10 +7740,11 @@ pub const Pardes = struct { switch (ev) { .key, .mouse => { for (p.panes) |slot| { - if (slot) |pane| pane.msg_len = 0; + if (slot) |pane| p.dismissMessage(pane); } p.clip_pending = null; }, + .tick => p.advanceMessages(), else => {}, } if (p.fs.listeners != 0) p.fs.origin = switch (ev) { @@ -11462,7 +11788,7 @@ pub const Pardes = struct { // A notice chip is chrome: it sits OVER the body's top rows, so a // click inside one belongs to nobody. The rest of those rows is // ordinary body text and still answers normally. - if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.len and + if (!on_tag and row >= body_y and row - body_y < p.panes[id].?.notices.span() and p.panes[id].?.notices.covers(row - body_y, col)) return null; if (!on_tag) if (body_hit) |supplied| { const mapped_hit = p.bodyHitForPane(id, supplied, false) orelse return null; @@ -12252,6 +12578,21 @@ pub const Pardes = struct { } } else { const clk = pane.sel[b]; + // A right-click on a line NUMBER looks at that line of + // this file -- a sticky context header's number included, + // since the row map holds each header's own source line. + if (s.button == config.look_button and clk.r0 >= BOX_H and clk.r0 == clk.r1 and + clk.c0 == clk.c1 and clk.c0 >= 0 and pane.mode != .tty) + if (pane.file) |f| if (f.output == null and clk.c0 < panes.File.gutterWidth(pane)) { + const line = pane.wrapAt(clk.r0 - BOX_H).line; + if (line >= 0) { + pane.sel[b].state = .none; + var buf: [48]u8 = undefined; + const target = std.fmt.bufPrint(&buf, "@p{d}:{d}", .{ s.id, @as(usize, @intCast(line)) + 1 }) catch return; + p.runBuiltin(config.look_cmd, s.id, "", target); + return; + } + }; var operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded @@ -12876,8 +13217,10 @@ pub const Pardes = struct { // A builtin says its own name before it runs, so a tagline click is // legible even when what it did is invisible. Whatever the builtin // then says for itself replaces the announcement. - if (p.announce and p.settings.verbose and builtins.registry.announces(b)) + if (p.announce and p.settings.verbose and builtins.registry.announces(b)) { p.setMessage(id, @tagName(b)); + pane.msg_announcement = true; + } const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; builtins.registry.dispatch(b, c); } @@ -13121,10 +13464,16 @@ pub const Pardes = struct { const pane: *Pane = switch (src.kind) { .terminal => terminal: { const t = src.terminal.?; - const restored = try panes.Terminal.restore(p, src); + // A host that runs shells gets them back: the old history + // above a new shell in the directory it was in. The web + // shell and an isolated build only ever replay it. + const live = comptime (terminal_panes and platform != .web and !isolated); + const revive = live and t.cwd.len <= effect_path_cap; + const restored = try panes.Terminal.restore(p, src, revive); p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.mode = .tty; + if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .cwd = .from(t.cwd) } }); break :terminal restored; }, .file => try panes.File.restore(p, i, src), @@ -13487,6 +13836,9 @@ pub const Pardes = struct { /// core work belongs in this one predicate so TTY and GUI mature it alike. pub fn animationActive(p: *const Pardes) bool { if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true; + for (p.panes) |slot| if (slot) |pane| { + if (messagesAnimating(pane)) return true; + }; const scene = p.settings.scene_effects; if (scene.crt or scene.ripple or scene.glitch) return true; return p.presentation.animating(); @@ -13653,7 +14005,7 @@ pub const Pardes = struct { const first = p.bodyTop(r); var leader_buf: [16]u8 = undefined; for (pane.notices.slice(), 0..) |kind, i| { - const row = first + @as(u16, @intCast(i)); + const row = first + @as(u16, pane.notices.row[i]); // Every notice is painted on the canonical grid as well as // emitted as a tag layer: the grid is what a terminal client // draws, and a band it cannot see is a message it never gets. @@ -13661,6 +14013,7 @@ pub const Pardes = struct { // drawn twice there. const text = switch (kind) { .message => pane.msg[0..pane.msg_len], + .older => pane.msg_older[pane.notices.index[i]].slice(), .leader => p.leaderText(&leader_buf), .prompt => blk: { const at = pane.promptAt() orelse continue; @@ -13671,13 +14024,24 @@ pub const Pardes = struct { if (chip == 0) continue; const cx = tx + tw - chip; pane.notices.left[i] = cx; - s.fill(cx, row, chip, 1, .{ .bg = msg_style.bg, .font_role = .tagline }); + // A message coming or going blends toward the page it sits + // on; a grid cannot move it by less than a row. + var style = msg_style; + if (noticeLife(pane, i)) |life| { + // No page colour to fade to (a terminal's own default): + // the text dissolves into its chip instead. + const alpha = p.messageMotion(life).alpha; + const page = th.bg orelse chrome.tag_bg; + style.fg = .{ .rgb = blendRgb(chrome.tag_fg, page, alpha) }; + style.bg = .{ .rgb = blendRgb(chrome.tag_bg, page, alpha) }; + } + s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline }); // One column short of the chip's right edge: a prompt's caret // sits AFTER its last character the moment it opens, and flush // right would put it past the pane, off the grid, and past what // the detached wire will encode -- which drops every frame for // as long as the prompt is up. - const printed = printRight(s, cx, row, chip -| 1, text, msg_style); + const printed = printRight(s, cx, row, chip -| 1, text, style); if (kind != .prompt or id != p.active) continue; const at = pane.promptAt() orelse continue; const prompt0 = (p.tagPrefix(pane) catch continue).len + at; @@ -14166,6 +14530,7 @@ pub const Pardes = struct { for (pane.notices.slice(), 0..) |kind, i| { const text = switch (kind) { .message => pane.msg[0..pane.msg_len], + .older => pane.msg_older[pane.notices.index[i]].slice(), .leader => p.leaderText(&leader_buf), // A prompt is an editing line with a cursor in it, so it // stays on the grid where the cursor can sit on a cell. @@ -14190,12 +14555,19 @@ pub const Pardes = struct { const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); - try p.renderHeaderLayer(arena, NOTICE_LAYER_BASE + id * Pane.Notices.max + i, .notice, @intCast(id), .{ + // A pixel shell slides the message in whole and only fades + // it on the way out. + const motion: MessageMotion = if (noticeLife(pane, i)) |life| p.messageMotion(life) else .{}; + const fade = if (motion.slide == 0) motion.alpha else 1; + const page = p.theme().bg orelse chrome.tag_bg; + const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; + try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), .{ .x = cx, - .y = first + @as(u16, @intCast(i)), + .y = first + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H, - }, line, chrome.tag_bg, chrome.tag_fg); + }, line, blendRgb(chrome.tag_bg, page, fade), blendRgb(chrome.tag_fg, page, fade)); + p.surface.tag_layers[layer_index].slide = motion.slide; } } if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { @@ -14221,6 +14593,7 @@ pub const Pardes = struct { layer.cols = columns; layer.bg = bg; layer.cursor = null; + layer.slide = 0; var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; // A notice band carries a PANE id, not a column index, so it must not // answer to the column being edited -- that would scroll and select @@ -14270,7 +14643,10 @@ pub const Pardes = struct { fn collectNotices(p: *Pardes, pane: *Pane, r: Rect, id: usize) void { pane.notices.len = 0; if (r.w <= config.GUTTER or r.h <= BOX_H) return; - if (pane.msg_len > 0) pane.notices.push(.message); + // Oldest message line on top, so lines already up do not move when a + // new one lands under them; when rows run out the oldest go first. + for (pane.msg_older[0..pane.msg_older_len], 0..) |line, i| pane.notices.pushAt(.older, @intCast(i), line.slot); + if (pane.msg_len > 0) pane.notices.pushAt(.message, 0, pane.msg_slot); if (id == p.active and p.leader_on) pane.notices.push(.leader); if (pane.promptAt()) |at| { if (pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0) pane.notices.push(.prompt); @@ -14281,11 +14657,26 @@ pub const Pardes = struct { // into blind. A two-row pane spends its only body row on one notice, // which is what the single shared row used to do anyway. const room: u8 = @intCast(@min(@as(u16, Pane.Notices.max), r.h - BOX_H)); - if (pane.notices.len > room) { - const drop = pane.notices.len - room; - for (0..room) |i| pane.notices.kinds[i] = pane.notices.kinds[i + drop]; - pane.notices.len = room; + const n = &pane.notices; + if (n.span() <= room) return; + // Too short for the gaps: close them up in screen order, and if even + // that does not fit, the top bands go. + var i: usize = 1; + while (i < n.len) : (i += 1) { + var j = i; + while (j > 0 and n.row[j - 1] > n.row[j]) : (j -= 1) { + std.mem.swap(Pane.Notices.Kind, &n.kinds[j - 1], &n.kinds[j]); + std.mem.swap(u8, &n.index[j - 1], &n.index[j]); + std.mem.swap(u8, &n.row[j - 1], &n.row[j]); + } + } + const drop = n.len -| room; + for (0..n.len - drop) |k| { + n.kinds[k] = n.kinds[k + drop]; + n.index[k] = n.index[k + drop]; + n.row[k] = @intCast(k); } + n.len -= @intCast(drop); } fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { @@ -14390,7 +14781,7 @@ pub const Pardes = struct { // Below the notice chips: a picture is drawn after the cells (the // GUI's image pass, kitty's z-order), so a chip over it would be // painted out. Text gets the overlay; a picture gives up the rows. - const shown = pane.notices.len; + const shown = pane.notices.span(); const image_h = (r.h -| BOX_H) -| shown; panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); // thumbless, but the same one column as the real scrollbar below — diff --git a/src/pdf.zig b/src/pdf.zig index 712750dc..de5e6d86 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -1017,6 +1017,73 @@ pub const Document = struct { ) != c.PARDES_PDF_OK) return error.RenderFailed; } + /// Only the highlight pass of `renderIntoAtWithPaper`, over `rgba`, which + /// holds `band` of this page as that render left it before highlighting + /// (opaque paper). Rendering with highlights equals rendering without and + /// then this, row for row: the render draws highlights over the finished + /// page, and so does this. + pub fn paintHighlightsAt( + document: *Document, + page: usize, + request: RenderRequest, + raster: Raster, + band: Raster.Band, + highlights: []const Highlight, + rgba: []u8, + ) !void { + if (rgba.len != band.len) return error.BadPixmap; + if (band.height == 0 or band.y + band.height > raster.height) + return error.BadPixmap; + const bounded = try boundedRequest(document, page, request); + if (highlights.len > c.PARDES_PDF_MAX_RESULT_QUADS) return error.RenderFailed; + if (c.pardes_pdf_paint_highlights( + document.handle, + @intCast(page), + bounded.dpi, + bounded.minimum_width, + bounded.minimum_height, + bounded.max_dimension, + if (highlights.len == 0) null else @ptrCast(highlights.ptr), + highlights.len, + rgba.ptr, + rgba.len, + @intCast(raster.width), + @intCast(raster.height), + @intCast(raster.stride), + @intCast(band.y), + @intCast(band.height), + ) != c.PARDES_PDF_OK) return error.RenderFailed; + } + + pub const Rows = struct { start: usize, end: usize }; + + /// The rows of `request`'s raster that painting `highlights` can touch, + /// by the transform the render itself uses (rotation included). Empty + /// when nothing would paint. + pub fn highlightRows( + document: *Document, + page: usize, + request: RenderRequest, + highlights: []const Highlight, + ) !Rows { + const bounded = try boundedRequest(document, page, request); + var row_start: c_int = 0; + var row_end: c_int = 0; + if (c.pardes_pdf_highlight_rows( + document.handle, + @intCast(page), + bounded.dpi, + bounded.minimum_width, + bounded.minimum_height, + bounded.max_dimension, + if (highlights.len == 0) null else @ptrCast(highlights.ptr), + highlights.len, + &row_start, + &row_end, + ) != c.PARDES_PDF_OK) return error.RenderFailed; + return .{ .start = @intCast(row_start), .end = @intCast(row_end) }; + } + const BoundedRequest = struct { dpi: c_int, minimum_width: c_int, diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c index 976ed838..2c723dbb 100644 --- a/src/pdf_bridge.c +++ b/src/pdf_bridge.c @@ -572,6 +572,55 @@ pardes_pdf_measure_render( return PARDES_PDF_OK; } +/* The highlight pass of a render: fills over whatever the pixmap holds. */ +static void +pardes_pdf_fill_highlights(fz_context *ctx, pardes_pdf_document *document, + fz_device *device, const pardes_pdf_highlight *highlights, size_t highlight_count) +{ + fz_path *path = NULL; + size_t i; + + fz_var(path); + fz_try(ctx) + { + for (i = 0; i < highlight_count; ++i) { + fz_quad quad; + float color[3]; + float alpha; + + if (!pardes_pdf_valid_quad(highlights[i].quad)) + fz_throw(ctx, FZ_ERROR_ARGUMENT, + "PDF highlight contains a non-finite point"); + if (highlights[i].rgba[3] == 0) + continue; + + /* Kind is semantic metadata; caller-supplied RGBA is final. */ + (void)highlights[i].kind; + quad = pardes_pdf_quad_to_page(document, highlights[i].quad); + color[0] = highlights[i].rgba[0] / 255.0f; + color[1] = highlights[i].rgba[1] / 255.0f; + color[2] = highlights[i].rgba[2] / 255.0f; + alpha = highlights[i].rgba[3] / 255.0f; + + path = fz_new_path(ctx); + fz_moveto(ctx, path, quad.ul.x, quad.ul.y); + fz_lineto(ctx, path, quad.ur.x, quad.ur.y); + fz_lineto(ctx, path, quad.lr.x, quad.lr.y); + fz_lineto(ctx, path, quad.ll.x, quad.ll.y); + fz_closepath(ctx, path); + fz_fill_path(ctx, device, path, 0, fz_identity, + fz_device_rgb(ctx), color, alpha, + fz_default_color_params); + fz_drop_path(ctx, path); + path = NULL; + } + } + fz_always(ctx) + fz_drop_path(ctx, path); + fz_catch(ctx) + fz_rethrow(ctx); +} + int pardes_pdf_render_into( pardes_pdf_document *document, @@ -594,10 +643,8 @@ pardes_pdf_render_into( fz_context *ctx; fz_pixmap *pixmap = NULL; fz_device *device = NULL; - fz_path *path = NULL; fz_matrix ctm; fz_irect bbox; - size_t i; if (document == NULL || page_number < 0 || page_number >= document->page_count || dpi < 1 || @@ -614,7 +661,6 @@ pardes_pdf_render_into( ctx = document->ctx; fz_var(pixmap); fz_var(device); - fz_var(path); fz_try(ctx) { pardes_pdf_render_geometry(document, page_number, dpi, @@ -627,10 +673,12 @@ pardes_pdf_render_into( * The BAND: rows [band_y, band_y + band_height) of the page raster, * and nothing else. A pixmap's bbox IS the draw device's clip, so the * page runs under the same CTM it would for a full raster and MuPDF - * discards everything outside these rows. Every row inside them is - * therefore bit-identical to the same row of the full-page raster — - * "band rows equal full-page rows" in pdf.zig proves it, because the - * whole point of a band is that the reader cannot tell. + * discards everything outside these rows. The rows are NOT always + * bit-identical to the full page's: rows at the band's edges, and a + * resampled image anywhere in it, can differ (measured on + * docs/registry.pdf and docs/design.pdf). Good enough for a band that + * is only shown while flinging; never use a band to patch rows into a + * whole-page raster -- `pardes_pdf_paint_highlights` exists for that. */ bbox.y0 += band_y; bbox.y1 = bbox.y0 + band_height; @@ -674,43 +722,12 @@ pardes_pdf_render_into( if (highlight_count != 0) { device = fz_new_draw_device(ctx, ctm, pixmap); - for (i = 0; i < highlight_count; ++i) { - fz_quad quad; - float color[3]; - float alpha; - - if (!pardes_pdf_valid_quad(highlights[i].quad)) - fz_throw(ctx, FZ_ERROR_ARGUMENT, - "PDF highlight contains a non-finite point"); - if (highlights[i].rgba[3] == 0) - continue; - - /* Kind is semantic metadata; caller-supplied RGBA is final. */ - (void)highlights[i].kind; - quad = pardes_pdf_quad_to_page(document, highlights[i].quad); - color[0] = highlights[i].rgba[0] / 255.0f; - color[1] = highlights[i].rgba[1] / 255.0f; - color[2] = highlights[i].rgba[2] / 255.0f; - alpha = highlights[i].rgba[3] / 255.0f; - - path = fz_new_path(ctx); - fz_moveto(ctx, path, quad.ul.x, quad.ul.y); - fz_lineto(ctx, path, quad.ur.x, quad.ur.y); - fz_lineto(ctx, path, quad.lr.x, quad.lr.y); - fz_lineto(ctx, path, quad.ll.x, quad.ll.y); - fz_closepath(ctx, path); - fz_fill_path(ctx, device, path, 0, fz_identity, - fz_device_rgb(ctx), color, alpha, - fz_default_color_params); - fz_drop_path(ctx, path); - path = NULL; - } + pardes_pdf_fill_highlights(ctx, document, device, highlights, highlight_count); fz_close_device(ctx, device); } } fz_always(ctx) { - fz_drop_path(ctx, path); fz_drop_device(ctx, device); /* Drops only the wrapper: caller-owned samples remain untouched. */ fz_drop_pixmap(ctx, pixmap); @@ -743,6 +760,139 @@ pardes_pdf_render_into( } int +pardes_pdf_paint_highlights( + pardes_pdf_document *document, + int page_number, + int dpi, + int minimum_width, + int minimum_height, + int max_dimension, + const pardes_pdf_highlight *highlights, + size_t highlight_count, + unsigned char *samples, + size_t samples_len, + int width, + int height, + int stride, + int band_y, + int band_height) +{ + fz_context *ctx; + fz_pixmap *pixmap = NULL; + fz_device *device = NULL; + fz_matrix ctm; + fz_irect bbox; + + if (document == NULL || page_number < 0 || + page_number >= document->page_count || dpi < 1 || + minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + (highlight_count != 0 && highlights == NULL) || + highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL || + width < 1 || height < 1 || width > INT_MAX / 4 || stride != width * 4 || + band_y < 0 || band_height < 1 || band_y > height - band_height || + (size_t)band_height > SIZE_MAX / (size_t)stride || + samples_len != (size_t)stride * (size_t)band_height) + return PARDES_PDF_ERROR; + if (highlight_count == 0) + return PARDES_PDF_OK; + + ctx = document->ctx; + fz_var(pixmap); + fz_var(device); + fz_try(ctx) + { + pardes_pdf_render_geometry(document, page_number, dpi, + minimum_width, minimum_height, max_dimension, &ctm, &bbox); + if (fz_irect_width(bbox) != width || fz_irect_height(bbox) != height) + fz_throw(ctx, FZ_ERROR_ARGUMENT, + "PDF raster layout changed between measure and paint"); + bbox.y0 += band_y; + bbox.y1 = bbox.y0 + band_height; + pixmap = fz_new_pixmap_with_bbox_and_data( + ctx, fz_device_rgb(ctx), bbox, NULL, 1, samples); + device = fz_new_draw_device(ctx, ctm, pixmap); + pardes_pdf_fill_highlights(ctx, document, device, highlights, highlight_count); + fz_close_device(ctx, device); + } + fz_always(ctx) + { + fz_drop_device(ctx, device); + fz_drop_pixmap(ctx, pixmap); + } + fz_catch(ctx) + { + fz_report_error(ctx); + return PARDES_PDF_ERROR; + } + return PARDES_PDF_OK; +} + +int +pardes_pdf_highlight_rows( + pardes_pdf_document *document, + int page_number, + int dpi, + int minimum_width, + int minimum_height, + int max_dimension, + const pardes_pdf_highlight *highlights, + size_t highlight_count, + int *row_start, + int *row_end) +{ + fz_context *ctx; + fz_matrix ctm; + fz_irect bbox; + float y0 = 0, y1 = 0; + int any = 0; + size_t i; + + if (row_start == NULL || row_end == NULL) + return PARDES_PDF_ERROR; + *row_start = *row_end = 0; + if (document == NULL || page_number < 0 || + page_number >= document->page_count || dpi < 1 || + minimum_width < 0 || minimum_height < 0 || max_dimension < 1 || + (highlight_count != 0 && highlights == NULL)) + return PARDES_PDF_ERROR; + + ctx = document->ctx; + fz_try(ctx) + { + pardes_pdf_render_geometry(document, page_number, dpi, + minimum_width, minimum_height, max_dimension, &ctm, &bbox); + for (i = 0; i < highlight_count; ++i) { + fz_rect r; + if (highlights[i].rgba[3] == 0 || + !pardes_pdf_valid_quad(highlights[i].quad)) + continue; + r = fz_rect_from_quad(fz_transform_quad( + pardes_pdf_quad_to_page(document, highlights[i].quad), ctm)); + if (!any || r.y0 < y0) y0 = r.y0; + if (!any || r.y1 > y1) y1 = r.y1; + any = 1; + } + } + fz_catch(ctx) + { + fz_report_error(ctx); + return PARDES_PDF_ERROR; + } + if (any) { + /* + * Antialiasing covers the pixel a fractional edge falls in, and the + * rasterizer paints row floor(y1) even for a zero-height quad. + */ + int lo = (int)floorf(y0) - bbox.y0; + int hi = (int)floorf(y1) + 1 - bbox.y0; + int h = fz_irect_height(bbox); + *row_start = lo < 0 ? 0 : (lo > h ? h : lo); + *row_end = hi < *row_start ? *row_start : (hi > h ? h : hi); + } + return PARDES_PDF_OK; +} + +int pardes_pdf_page_text( pardes_pdf_document *document, int page_number, diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h index cda426aa..e3514437 100644 --- a/src/pdf_bridge.h +++ b/src/pdf_bridge.h @@ -222,8 +222,9 @@ int pardes_pdf_measure_render( * MuPDF wraps but never owns or frees samples. Every successful pixel is * opaque unless transparent_paper is nonzero. That mode preserves content * coverage and returns straight-alpha RGBA over an unpainted page. - * The CTM does not depend on the band, so a band's rows are bit-identical to - * the same rows of the whole page: it is a clip, not a different rendering. + * The CTM does not depend on the band, so a band is a clip of the same + * rendering -- but not a bit-exact one: its edge rows and resampled images can + * differ from the whole page's (see pardes_pdf_render_into). */ int pardes_pdf_render_into( pardes_pdf_document *document, @@ -244,6 +245,47 @@ int pardes_pdf_render_into( int transparent_paper ); +/* + * The highlight pass of `pardes_pdf_render_into` alone, over caller-supplied + * rows of that page's opaque raster: render_into(h) == paint(render_into({}), h) + * row for row, because a render draws its highlights over the finished page. + */ +int pardes_pdf_paint_highlights( + pardes_pdf_document *document, + int page, + int dpi, + int minimum_width, + int minimum_height, + int max_dimension, + const pardes_pdf_highlight *highlights, + size_t highlight_count, + unsigned char *samples, + size_t samples_len, + int width, + int height, + int stride, + int band_y, + int band_height +); + +/* + * The raster rows [*row_start, *row_end) that painting `highlights` can touch + * under the same request, in the coordinates `pardes_pdf_render_into` bands + * use. An empty set (or only transparent ones) yields start == end == 0. + */ +int pardes_pdf_highlight_rows( + pardes_pdf_document *document, + int page, + int dpi, + int minimum_width, + int minimum_height, + int max_dimension, + const pardes_pdf_highlight *highlights, + size_t highlight_count, + int *row_start, + int *row_end +); + int pardes_pdf_page_text( pardes_pdf_document *document, int page, diff --git a/src/tag_layer.zig b/src/tag_layer.zig index 0fb2297d..f8cc6eca 100644 --- a/src/tag_layer.zig +++ b/src/tag_layer.zig @@ -26,6 +26,10 @@ pub const Layer = struct { bg: [3]u8 = .{ 0, 0, 0 }, cells: []pardes.Cell = &.{}, cursor: ?struct { col: u16, bar: bool = false } = null, + /// Vertical offset in rows (negative: above its viewport), for a band + /// that is moving into place. A pixel shell draws it offset and clipped + /// to the rows below the viewport's top; the grid has no such thing. + slide: f32 = 0, pub fn capacity(layer: *const Layer, metrics: pardes.RowMetrics) u16 { return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 03ccd3e2..5976852f 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -21,6 +21,56 @@ extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); +/// The terminal answered the shared-memory probe (`kittyShmProbe`): it can +/// read a raster from a POSIX shm object, so a page costs a name on the wire +/// instead of its whole base64 body. Set by the input reader, which sees the +/// reply before vaxis' parser swallows every graphics APC alike. +var kitty_shm = std.atomic.Value(bool).init(false); +/// Only a reply that arrives while the probe is out counts. The id is one +/// vaxis' own image numbering (1, 2, ...) never reaches, because a terminal +/// answers every transmission `i=<id>;OK` and one of those must not pass. +var kitty_shm_probing = std.atomic.Value(bool).init(false); +const kitty_shm_probe_id = 0x7fff_fff1; +var kitty_shm_serial: u32 = 0; + +fn kittyShmName(buf: []u8, n: u32) ![:0]const u8 { + return std.fmt.bufPrintSentinel(buf, "/pardes-{d}-{d}", .{ libc.getpid(), n }, 0); +} + +/// Write `bytes` to a fresh shm object; the terminal unlinks it after reading. +fn kittyShmWrite(name: [:0]const u8, bytes: []const u8) bool { + const fd = libc.shm_open(name, @bitCast(libc.O{ .ACCMODE = .RDWR, .CREAT = true, .EXCL = true }), @as(c_uint, 0o600)); + if (fd < 0) return false; + defer _ = libc.close(fd); + if (libc.ftruncate(fd, @intCast(bytes.len)) != 0) { + _ = libc.shm_unlink(name); + return false; + } + var off: usize = 0; + while (off < bytes.len) { + const n = libc.write(fd, bytes[off..].ptr, bytes.len - off); + if (n <= 0) { + _ = libc.shm_unlink(name); + return false; + } + off += @intCast(n); + } + return true; +} + +/// Ask with a 1x1 RGB image in shm whether the terminal reads that medium. A +/// remote terminal cannot see our shm and answers an error (or nothing). +fn kittyShmProbe(w: *std.Io.Writer) void { + var buf: [64]u8 = undefined; + const name = kittyShmName(&buf, 0) catch return; + if (!kittyShmWrite(name, &.{ 0, 0, 0 })) return; + var b64: [64]u8 = undefined; + const enc = std.base64.standard.Encoder.encode(&b64, name); + kitty_shm_probing.store(true, .release); + w.print("\x1b_Gi={d},s=1,v=1,a=q,t=s,f=24;{s}\x1b\\", .{ kitty_shm_probe_id, enc }) catch return; + w.flush() catch {}; +} + pub const Command = struct { pub var value: union(enum) { nop, @@ -81,6 +131,13 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { const received = try tty.read(buf[carried..]); if (received == 0) return; const end = carried + received; + if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| { + kitty_shm_probing.store(false, .release); + kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release); + // Answered either way: a terminal that could not read it left it. + var name_buf: [64]u8 = undefined; + if (kittyShmName(&name_buf, 0)) |name| _ = libc.shm_unlink(name) else |_| {} + }; var parse_end = end; var lead = end; while (lead > 0 and buf[lead - 1] & 0xc0 == 0x80) lead -= 1; @@ -717,6 +774,7 @@ fn localSession( try startInput(loop, input_cache); defer if (attached.* == null) stopInput(loop); (try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach(); + (try std.Thread.spawn(.{}, tickWatch, .{ io, loop })).detach(); if (fs) |f| try f.watch(loop, wakeFs); try vx.queryTerminalSend(tty.writer()); @@ -726,11 +784,20 @@ fn localSession( }; if (sh.inotify_fd >= 0) sh.watch_task = io.concurrent(watchFiles, .{ io, sh.inotify_fd, loop }) catch null; + // A terminal unlinks what it reads; whatever it never read (the probe + // unanswered, a transmission lost with the terminal) is ours to remove. + defer { + var name_buf: [64]u8 = undefined; + var n: u32 = 0; + while (n <= kitty_shm_serial) : (n += 1) { + if (kittyShmName(&name_buf, n)) |name| _ = libc.shm_unlink(name) else |_| {} + } + } frames: while (!core.quit) { pardes.turn.restoreSettled(); try core.pump(host); if (core.takeRestore()) |rp| blk: { - const bytes = filesystem.readRestore(gpa, rp) catch |err| { + const bytes = filesystem.readRestore(gpa, rp, core.settings.dump_dir.get()) catch |err| { core.reportError(core.active, "Restore", err); break :blk; }; @@ -872,29 +939,39 @@ const Shell = struct { fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); var batch: usize = 0; - // The wait is the 9P connections' turn with the core. - if (timeout_ms == 0) { - pardes.turn.rest(); - const first = s.loop.nextEvent() catch { - pardes.turn.wake(); - s.core.quit = true; - return s.reloadWatched(); - }; + // The wait is the 9P connections' turn with the core. Animating, the + // frame's tick arrives through the same queue (`tickWatch`). + if (timeout_ms != 0) tick_request.set(s.io); + pardes.turn.rest(); + const first = s.loop.nextEvent() catch { pardes.turn.wake(); - batch = 1; - if (s.apply(first)) return s.reloadWatched(); - } else { - pardes.turn.rest(); - std.Io.sleep(s.io, .fromMilliseconds(timeout_ms), .awake) catch {}; - pardes.turn.wake(); - _ = s.apply(.tick); - batch = 1; - } - while (s.in_paste or batch < 64) { - const ev = (s.loop.tryEvent() catch null) orelse break; + s.core.quit = true; + return s.reloadWatched(); + }; + pardes.turn.wake(); + batch = 1; + // A run of queued motions is one move to its last cell: each would + // otherwise redo hover and selection work the next one undoes. + var motion: ?@TypeOf(Command.value) = null; + var ev = first; + while (true) { + if (ev == .mouse and (ev.mouse.type == .motion or ev.mouse.type == .drag)) { + if (motion) |m| if (m.mouse.button != ev.mouse.button or m.mouse.type != ev.mouse.type or + @as(u3, @bitCast(m.mouse.mods)) != @as(u3, @bitCast(ev.mouse.mods))) + { + _ = s.apply(m); + }; + motion = ev; + } else { + if (motion) |m| _ = s.apply(m); + motion = null; + if (s.apply(ev)) break; + } + if (!s.in_paste and batch >= 64) break; + ev = (s.loop.tryEvent() catch null) orelse break; batch += 1; - if (s.apply(ev)) break; } + if (motion) |m| _ = s.apply(m); s.reloadWatched(); } @@ -1001,6 +1078,7 @@ const Shell = struct { fn pollFrame(ctx: ?*anyopaque) void { const s = of(ctx); + s.core.native_image_patches = kitty_shm.load(.acquire); if (s.fs) |f| if (f.tick().pending) { _ = s.loop.tryPostEvent(.fs_ready) catch {}; }; @@ -1013,6 +1091,7 @@ const Shell = struct { s.caps_pending = false; s.vx.enableDetectedFeatures(s.tty.writer()) catch {}; s.core.native_images = s.vx.caps.kitty_graphics; + if (s.vx.caps.kitty_graphics) kittyShmProbe(s.tty.writer()); s.vx.queueRefresh(); } } @@ -1039,6 +1118,16 @@ const Shell = struct { if (!kittyImageRepresentable(place)) continue; } const key = place.cacheKey(); + if (!s.kitty.contains(key) and vx.caps.kitty_graphics and kitty_shm.load(.acquire)) shm: { + var buf: [64]u8 = undefined; + kitty_shm_serial +%= 1; + const name = kittyShmName(&buf, kitty_shm_serial) catch break :shm; + if (!kittyShmWrite(name, place.rgba)) break :shm; + if (vx.transmitLocalImagePath(s.gpa, s.tty.writer(), name, @intCast(place.iw), @intCast(place.ih), .shared_mem, .rgba) catch null) |handle| + s.kitty.put(key, handle) catch vx.freeImage(s.tty.writer(), handle.id) + else + _ = libc.shm_unlink(name); + } if (!s.kitty.contains(key) and vx.caps.kitty_graphics) { const enc = std.base64.standard.Encoder; if (s.gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| { @@ -1160,7 +1249,7 @@ const Shell = struct { fn writeDump(ctx: ?*anyopaque, bytes: []const u8) void { const s = of(ctx); var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf) orelse return; + const path = pardes.dump.outPath(&pbuf, s.core.settings.dump_dir.get()) orelse return; filesystem.write(s.core, path, bytes) catch |err| return s.core.reportError(0, "dump", err); s.core.setLastDump(path); } @@ -1415,6 +1504,21 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } +/// Set by an animating frame's wait: post one `.tick` a frame from now. The +/// tick joins the input queue, so a key or a click that lands inside the +/// frame is handled at once instead of after a blind sleep. +var tick_request: std.Io.Event = .unset; + +fn tickWatch(io: std.Io, loop: *Loop) void { + while (true) { + tick_request.waitUncancelable(io); + tick_request.reset(); + var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.layout.Animation.frame_ms) * std.time.ns_per_ms }; + _ = libc.nanosleep(&ts, null); + loop.postEvent(.tick) catch {}; + } +} + fn wakeFs(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.fs_ready) catch {}; diff --git a/test/output.zig b/test/output.zig index ad33954f..7c80e105 100644 --- a/test/output.zig +++ b/test/output.zig @@ -1089,7 +1089,8 @@ test "identical result refresh keeps warm content caches without allocation" { try std.testing.expectEqual(revision + 1, file.revision); try std.testing.expectEqualStrings("short\n", file.content); try std.testing.expectEqualStrings("changed", file.output.?.arg()); - try std.testing.expectEqual(@as(usize, 0), file.line_starts.len); + // The warm index is carried through the edit, not dropped. + try std.testing.expectEqualSlices(usize, &.{ 0, 6 }, file.line_starts); try std.testing.expectEqual(@as(usize, 0), file.highlights.len); try std.testing.expect(file.syntax_dirty); try std.testing.expect(!result.vsel.active and !result.select); diff --git a/test/panes.zig b/test/panes.zig index 8e71e69d..2d3aed43 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -394,6 +394,42 @@ const BodyHitTests = struct { } }; +const LineNumberLookTests = struct { + test "right-click on a line number looks at that line, sticky context headers included" { + if (!syntax.enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 64, .rows = 18 }); + defer p.deinit(); + p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 8, .tagline_h = 10 }; + const pane = try TreeContextTests.setZigFile(p, "const Box = struct {\n pub fn work(\n value: usize,\n ) void {\n" ++ " // body\n" ** 60 ++ " }\n};\n"); + try std.testing.expect(p.executeBuiltinLine(0, "TreeContext")); + pane.scrollBy(30); + var frame = std.heap.ArenaAllocator.init(p.gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const layer = &p.surface.body_layers[0]; + try std.testing.expect(layer.context_rows >= 2); + // A header row's number is its declaration's own source line, not the + // body line it sits over (`pub fn work(` is line 1); a body row's is + // its own. + try std.testing.expectEqual(@as(i32, 1), pane.wrapAt(1).line); + const rows = [_]u16{ 1, pane.context_rows + 3 }; + for (rows) |row| { + // Each Look recentres the view; come back under the headers. + pane.scrollBy(30 - pane.scroll()); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + const expected = pane.wrapAt(row).line; + var press = BodyHitTests.mouse(&p.surface.body_layers[0], row, 1, .press); + press.button = .right; + var release = BodyHitTests.mouse(&p.surface.body_layers[0], row, 1, .release); + release.button = .right; + p.update(.{ .mouse = press }); + p.update(.{ .mouse = release }); + try std.testing.expectEqual(expected, pane.cur_row); + } + } +}; + const TreeContextTests = struct { const source = "const Box = struct {\n" ++ " pub fn work(\n" ++ @@ -4069,6 +4105,7 @@ test { _ = TagNameTintTests; _ = TreeContextTests; _ = BodyHitTests; + _ = LineNumberLookTests; _ = TtySelectionTests; _ = ClickCursorTests; _ = JumpSelectionTests; diff --git a/test/pdf_pointer_bench.zig b/test/pdf_pointer_bench.zig new file mode 100644 index 00000000..f1d61229 --- /dev/null +++ b/test/pdf_pointer_bench.zig @@ -0,0 +1,224 @@ +//! Pointer gestures over a native PDF pane: hovering words (the Look preview +//! highlight), and dragging a selection to its release. +//! +//! zig build pdf-pointer-bench -Doptimize=ReleaseFast -- [--label L] [--reps N] [--path FILE] +//! [--cell WxH] [--scroll NOTCHES] [--verify 1] [--live 1] +//! +//! Every frame is timed the way a shell pump spends it: the event through +//! `Pardes.update`, one `render()` into a reset arena, the effect drain. A +//! hover step is its motion frame plus every tick frame the preview animates +//! before settling, which is when the highlight lands. Stdout is CSV, +//! `label,scenario:kind,round,ns`, one row per frame and one per step +//! (`kind=step`), for an external statistics pass. +//! +//! `visual` hashes every presented raster in full, with its placement, on the +//! frame each step settles on. It is the no-regression proof: a change to how highlights +//! are painted may change what it costs, never one presented pixel. +const std = @import("std"); +const pardes = @import("pardes"); +const pdf = @import("mupdf"); + +pub const std_options: std.Options = .{ .log_level = .err }; + +const gpa = std.heap.smp_allocator; +const cols: u16 = 120; +const rows: u16 = 40; + +const Config = struct { + path: []const u8 = "docs/design.pdf", + label: []const u8 = "run", + reps: usize = 5, + /// Wheel notches before the gestures, off the sparse title page. + scroll: usize = 40, + /// 8x16 is the Kitty policy's cell; the SDL shell's is closer to 16x35. + cell_w: u16 = 8, + cell_h: u16 = 16, + /// Re-render every raster that claims to hold the current highlights and + /// compare it row by row: exactness without a baseline build. + verify: bool = false, + /// A shell that takes row patches: selections repaint while dragged. + live: bool = false, +}; + +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + var config: Config = .{}; + var i: usize = 1; + while (i + 1 < args.len) : (i += 2) { + const value = args[i + 1]; + if (std.mem.eql(u8, args[i], "--path")) config.path = value else if (std.mem.eql(u8, args[i], "--label")) config.label = value else if (std.mem.eql(u8, args[i], "--reps")) config.reps = try std.fmt.parseUnsigned(usize, value, 10) else if (std.mem.eql(u8, args[i], "--scroll")) config.scroll = try std.fmt.parseUnsigned(usize, value, 10) else if (std.mem.eql(u8, args[i], "--verify")) config.verify = std.mem.eql(u8, value, "1") else if (std.mem.eql(u8, args[i], "--live")) config.live = std.mem.eql(u8, value, "1") else if (std.mem.eql(u8, args[i], "--cell")) { + var it = std.mem.splitScalar(u8, value, 'x'); + config.cell_w = try std.fmt.parseUnsigned(u16, it.next() orelse "", 10); + config.cell_h = try std.fmt.parseUnsigned(u16, it.next() orelse "", 10); + } else return error.UnknownOption; + } + if (i != args.len) return error.MissingValue; + + var visual: ?u64 = null; + for (0..config.reps) |round| { + const got = try measure(config, round); + if (visual) |want| if (want != got) { + std.debug.print("pdf-pointer-bench: visual changed between rounds: {x} vs {x}\n", .{ want, got }); + return error.Unstable; + }; + visual = got; + } + std.debug.print("pdf-pointer-bench {s}: visual={x:0>16}\n", .{ config.label, visual.? }); +} + +const Run = struct { + core: *pardes.Pardes, + arena: std.heap.ArenaAllocator, + config: Config, + round: usize, + visual: u64 = 0x7061_7264_6573_5050, + step_ns: u64 = 0, + frames: usize = 0, + steps: usize = 0, + last: ?*const pardes.Surface = null, + + fn frame(run: *Run, scenario: []const u8, kind: []const u8, event: pardes.Event) !void { + const started = nowNs(); + run.core.update(event); + while (run.core.nextEffect()) |_| {} + _ = run.arena.reset(.retain_capacity); + const surface = try run.core.render(run.arena.allocator()); + while (run.core.nextEffect()) |_| {} + const elapsed = nowNs() - started; + run.step_ns += elapsed; + run.frames += 1; + print("{s},{s}:{s},{d},{d}\n", .{ run.config.label, scenario, kind, run.round, elapsed }); + run.last = surface; + if (run.config.verify) try verify(run.core, run.arena.allocator()); + } + + /// The step, then the tick frames its animation asks for (a hover's + /// preview arrives on one of them), as a pump with nothing else queued. + fn step(run: *Run, scenario: []const u8, kind: []const u8, event: pardes.Event) !void { + run.step_ns = 0; + try run.frame(scenario, kind, event); + var ticks: usize = 0; + while (run.core.animationActive() and ticks < 32) : (ticks += 1) + try run.frame(scenario, "tick", .tick); + print("{s},{s}:step,{d},{d}\n", .{ run.config.label, scenario, run.round, run.step_ns }); + // Only the settled frame is the invariant: how many frames a preview + // takes to arrive is a setting (look_preview_delay_frames). + run.observe(run.last.?); + run.steps += 1; + } + + fn observe(run: *Run, surface: *const pardes.Surface) void { + for (surface.images[0..surface.nimages]) |maybe| { + const place = maybe orelse continue; + run.visual = mix(run.visual, [_]u64{ place.pane, place.x, place.y, place.w, place.h, place.native.page, place.iw, place.ih }); + if (place.native.geometry) |g| run.visual = mix(run.visual, [_]u64{ g.src.x, g.src.y, g.src.w, g.src.h, g.dst.x, g.dst.y, g.dst.w, g.dst.h }); + run.visual = std.hash.Wyhash.hash(run.visual, place.rgba); + } + } +}; + +var verify_failures: usize = 0; + +fn verify(core: *pardes.Pardes, arena: std.mem.Allocator) !void { + const Pdf = pardes.panes.Pdf; + const pane = core.panes[0] orelse return; + const state = &(pane.pdf orelse return); + const highlights = try Pdf.buildHighlights(state, arena, Pdf.highlightInput(core, 0, pane)); + for (state.rasters[0..state.rasters_len]) |*r| { + if (!r.valid or r.rgba.len == 0 or r.tint_key == null) continue; + const want = highlights.forPage(r.page); + if (comptime !@hasField(@TypeOf(r.*), "baked")) return else if (!std.mem.eql(u8, std.mem.sliceAsBytes(want), std.mem.sliceAsBytes(r.baked))) continue; + const shape = try state.document.measureRenderAt(r.page, r.request); + // The oracle is the WHOLE page: a band's own edge rows can differ. + const whole = try arena.alloc(u8, shape.len); + try state.document.renderIntoAtWithPaper(r.page, r.request, shape, shape.wholePage(), want, whole, false); + try pdf.tintRgba(whole, r.tint_key.?.mode, r.tint_key.?.colors); + const fresh = whole[r.band_y * shape.stride ..][0 .. r.band_h * shape.stride]; + var first: ?usize = null; + var last: usize = 0; + var count: usize = 0; + for (0..r.band_h) |y| { + const row = fresh[y * shape.stride ..][0..shape.stride]; + if (std.mem.eql(u8, row, r.rgba[y * shape.stride ..][0..shape.stride])) continue; + if (first == null) first = y; + last = y; + count += 1; + } + if (first) |f| { + verify_failures += 1; + if (verify_failures <= 20) std.debug.print("verify: page {d} rows {d}..{d} ({d} differ), highlights {d}, band {d}+{d}\n", .{ r.page, f + r.band_y, last + r.band_y, count, want.len, r.band_y, r.band_h }); + } + } +} + +fn measure(config: Config, round: usize) !u64 { + const core = try pardes.Pardes.init(gpa, .{ .file = config.path, .cols = cols, .rows = rows }); + defer core.deinit(); + core.native_images = true; + if (comptime @hasField(pardes.Pardes, "native_image_patches")) core.native_image_patches = config.live; + var run: Run = .{ .core = core, .arena = .init(gpa), .config = config, .round = round }; + defer run.arena.deinit(); + const cell: pardes.CellPixels = .{ .w = config.cell_w, .h = config.cell_h }; + core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = cell } }); + _ = try core.render(run.arena.allocator()); + const pane = core.panes[0] orelse return error.NoPane; + if (pane.pdf == null) return error.NotPdf; + const rect = core.rects[0]; + const mid_col = rect.x + rect.w / 2; + const mid_row = rect.y + rect.h / 2; + for (0..config.scroll) |_| { + core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mid_col, .row = mid_row } }); + _ = run.arena.reset(.retain_capacity); + _ = try core.render(run.arena.allocator()); + while (core.nextEffect()) |_| {} + } + + const top = rect.y + 4; + const bottom = rect.y + rect.h -| 3; + const left = rect.x + 4; + const right = rect.x + rect.w -| 4; + + // Hover: walk four rows of the body, a word-ish stride at a time. + var row: u16 = top; + while (row < bottom) : (row += (bottom - top) / 4) { + var col: u16 = left; + while (col < right) : (col += 3) + try run.step("hover", "motion", .{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } }); + } + // Leave the page so the last preview is withdrawn like a real exit. + try run.step("hover", "leave", .pointer_leave); + + // Select: press, drag across three lines, release; five times down the page. + row = top; + while (row + 3 < bottom) : (row += (bottom - top) / 5) { + try run.step("select", "press", .{ .mouse = .{ .button = .left, .kind = .press, .col = left, .row = row } }); + var n: u16 = 1; + while (n <= 24) : (n += 1) { + const col = left + (right - left) * (n % 8) / 8; + try run.step("select", "drag", .{ .mouse = .{ .button = .left, .kind = .drag, .col = col, .row = row + n / 8 } }); + } + try run.step("select", "release", .{ .mouse = .{ .button = .left, .kind = .release, .col = right, .row = row + 3 } }); + } + return mix(run.visual, @as(u64, run.steps)); +} + +fn print(comptime format: []const u8, args: anytype) void { + var buf: [256]u8 = undefined; + const line = std.fmt.bufPrint(&buf, format, args) catch return; + _ = std.c.write(1, line.ptr, line.len); +} + +fn mix(seed: u64, value: anytype) u64 { + var copy = value; + return std.hash.Wyhash.hash(seed, std.mem.asBytes(©)); +} + +fn nowNs() u64 { + var ts: std.c.timespec = undefined; + _ = std.c.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); +} + +comptime { + _ = pdf.Document; +} diff --git a/test/snapshot.zig b/test/snapshot.zig index 7cc5eb91..bdabb56e 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -569,7 +569,9 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const try mkdir(arena, config_home, false); const pardes_config = try std.fmt.allocPrint(arena, "{s}/pardes", .{config_home}); try mkdir(arena, pardes_config, false); - try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/init", .{pardes_config}, 0), "Shell bash\n"); + // Messages leave the moment they are dismissed, as goldens have always + // recorded: a linger or a dissolve is time, and time cannot be a golden. + try eh.writeFile(try std.fmt.allocPrintSentinel(arena, "{s}/init", .{pardes_config}, 0), "Shell bash\nMessageAnimation off\nMessageLinger 0\n"); _ = setenv("TERM", "xterm-256color", 1); _ = setenv("LC_ALL", "C", 1); // a wall clock cannot live in a golden; everything that would print one diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index cd47209f..02547b91 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -19,6 +19,10 @@ wheel up 110 50 wheel up 110 50 wheel up 110 50 wheel up 110 50 +wheel up 110 50 +wheel up 110 50 +wheel up 110 50 +wheel up 110 50 wait 10000 Look enter, right-click wait 10000 Exec tab, middle-click wait 10000 Filter diff --git a/test/snapshots/dump.golden b/test/snapshots/dump.golden index 8f920136..e466dbf1 100644 --- a/test/snapshots/dump.golden +++ b/test/snapshots/dump.golden @@ -38,6 +38,7 @@ .cwd = "/tmp/pardes-snap/dump/cwd", .stream = "ls\nd.txt\n", .stream_b64 = <elided> + .stream_is_screen = true, .cursor = .{ .col = 2, .row = 2 }, }, .file = null, @@ -57,6 +58,7 @@ .cwd = "/tmp/pardes-snap/dump/cwd", .stream = "ls\nd.txt\n", .stream_b64 = <elided> + .stream_is_screen = true, .cursor = .{ .col = 2, .row = 2 }, }, .file = null, @@ -76,6 +78,7 @@ .cwd = "/tmp/pardes-snap/dump/cwd", .stream = "ls\nd.txt\n", .stream_b64 = <elided> + .stream_is_screen = true, .cursor = .{ .col = 2, .row = 2 }, }, .file = null, diff --git a/test/snapshots/load.golden b/test/snapshots/load.golden index 994ab83e..32024cec 100644 --- a/test/snapshots/load.golden +++ b/test/snapshots/load.golden @@ -2,10 +2,10 @@ |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill | New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol | /tmp/pardes-snap/load/cwd Tty Save Mode Filter /tmp/pardes-snap/load/cwd Tty Save Mode Filter -| echo LOAD''MARK; seq 1 40; echo E''ND ls +| $ echo LOAD''MARK; seq 1 40; echo E''ND $ ls | LOADMARK d.txt -| 1 -| 2 +| 1 $ +| 2 ── restored history ── | 3 | 4 | 5 @@ -17,8 +17,10 @@ | 11 | 12 | /tmp/pardes-snap/load/cwd Tty Save Mode Filter -| ls +| $ ls | d.txt +| $ +| ── restored history ── == style loaded grid=100x31 |0: 0-70 #b2c3b8,#1d2824, 71-99 d,#1d2824, |1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-49 d,#26362d, 50-51 #ffffff,#887455, 52-52 d,#1d2824, 53-84 #b2c3b8,#1d2824, 85-99 d,#1d2824, @@ -26,8 +28,8 @@ |3: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |4: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |5: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, -|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, -|7: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, +|6: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-73 #e5eee5,#0d1410,f 74-99 #e5eee5,#0d1410, +|7: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-99 d,#0d1410, |8: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |9: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |10: 0-0 d,#152019, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, @@ -41,8 +43,8 @@ |18: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |19: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |20: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, -|21: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, -|22: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, +|21: 0-0 d,#608269, 1-1 d,#0d1410, 2-23 #e5eee5,#0d1410,f 24-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, +|22: 0-0 d,#608269, 1-49 d,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |23: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |24: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, |25: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, diff --git a/test/snapshots/restore.golden b/test/snapshots/restore.golden index 11d92288..01b52984 100644 --- a/test/snapshots/restore.golden +++ b/test/snapshots/restore.golden @@ -6,9 +6,7 @@ | $ echo KEEP''ME | KEEPME | $ -== snap restored grid=100x31 cursor=4,6 +== snap restored grid=100x31 cursor=4,8 |0: Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill |2: /tmp/pardes-snap/restore/cwd Tty Save Mode Filter Collapse Del -|3: ls -|4: echo KEEP''ME -|6: +|7: ── restored history ── |
