From 9695cb098c784792af79c22d46a17f7bc7247d33 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 22 Sep 2026 17:43:57 -0300 Subject: 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) --- src/config.zig | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) (limited to 'src/config.zig') 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", -- cgit v1.3