diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 23:43:31 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | e714bbfa8b7cbf9970053cfbabbb9b1f02a2290e (patch) | |
| tree | d1112abb074f739cb2d70f1117b91f064d9875b7 | |
| parent | 8fc6bb0633d7992dad6b28a2d36730689226e096 (diff) | |
| download | pardes-e714bbfa8b7cbf9970053cfbabbb9b1f02a2290e.tar.gz pardes-e714bbfa8b7cbf9970053cfbabbb9b1f02a2290e.zip | |
Draw a frame only when there is one worth drawing
A 9P round trip on a local Unix socket cost 9.7 ms against the SDL shell and
0.758 ms against the terminal one. The server was not slow and the wake was
not broken: instrumenting the path showed every request waking the loop early
(wakes=210, woke_early=212, timed_out=38 over 250 ticks) and being answered on
that same pass. The cost was that `pump` answers 9P at one point in a loop
that then renders and presents unconditionally, so a client's next request
landed while the main thread was blocked on the display, and each round trip
therefore cost a whole frame. The frame rate was governing something that has
nothing to do with drawing.
`Pardes.needs_frame` starts true, is set by every event except a tick with
nothing animating and a filesystem request that only reads, and is cleared
once a frame is presented. `pump` returns before render and present when it is
false and nothing is animating. An idle editor answering reads now draws
nothing at all.
9P read_fid, one RPC: gui 9.7 ms -> 0.056 ms (173x)
tty 0.758 ms -> 0.062 ms (12x)
Verified the shells still paint rather than going quiet: the rendered screen
carries the opened file, a write through 9P redraws within the frame, and
`fs-discovery-test` passes over the real wire. Suite unchanged at 778/783 with
the two pre-existing crashes.
Also from the adversarial review of the previous commits:
`pardes --tty FILE` silently discarded the file, and `--tty MISSING` silently
discarded the error pane. main.zig named the boot layout before the positional
was resolved, and naming one short-circuits `Boot.of`. The choice now happens
after the argument is known, and only when there is no file and no missing
word. macos.zig names the same layout, so the app no longer boots a different
one from the terminal and SDL shells.
`pre_close_last_pane_tail` was transcribed from the NEW default rather than the
old one, so the upgrade path it was added for did not exist: a workspace dumped
before the tagline reorder came back with the old default welded on as a custom
tail. It is now the string it claims to be.
A pane two rows tall lost its message and, worse, its prompt and the cursor
with it. The notice cap keeps the LAST notices now, because the prompt is last
and a prompt you cannot see is one you type into blind.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
| -rw-r--r-- | docs/config.md | 4 | ||||
| -rw-r--r-- | features.txt | 17 | ||||
| -rw-r--r-- | src/macos.zig | 4 | ||||
| -rw-r--r-- | src/main.zig | 11 | ||||
| -rw-r--r-- | src/pardes.zig | 69 |
5 files changed, 96 insertions, 9 deletions
diff --git a/docs/config.md b/docs/config.md index e2b95e21..8eff79a8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -37,7 +37,7 @@ hosted shell, `window_opacity` the SDL GUI only, and `tagline_font_size` everything but the TTY and the board. So the TTY reports Font, TaglineSize and the scene shaders as unsupported; the browser reports Font, panel transitions and the scene shaders as unsupported, -and its TaglineSize row reads `82% (build-time only)` — tagline font size is +and its TaglineSize row reads `82 (build-time only)` — tagline font size is its own capability precisely because GUI font SELECTION is native-only while the browser still applies the compiled percentage to its DOM glyphs. The startup path is printed whether or not a file exists — that is usually when @@ -203,7 +203,7 @@ does not make those areas more opaque. Regular text, syntax colors, tagline text, terminal glyphs, and the cursor keep their normal opacity. This does not blend foreground colors into their cell backgrounds. The TTY and other non-SDL hosts do not emulate this effect; their `Config` report says -`WindowOpacity: unsupported`. +`WindowOpacity unsupported`. On native Wayland, Pardes uses an alpha-capable transparent surface. It does not use whole-window opacity protocols such as `wp_alpha_modifier_v1`, because diff --git a/features.txt b/features.txt index e05f6f07..5583e7f3 100644 --- a/features.txt +++ b/features.txt @@ -139,3 +139,20 @@ tracing and the GPU/driver stack coming up, and the only lever on either is the The Last builtin should fall back to a heuristic when the jumplist has nothing to offer, so the bare tty layout -- a shell with an unfocused text pane under it -- alternates with Last even though that text pane was never focused and is not on the jumplist. + +Before finishing: remove the zig cache, then build ReleaseSafe with the full tree-sitter options, install to ~/.local/bin, and make sure the desktop entry and anything else that launches +pardes point at the updated binaries. + +Design smell, and it is a real one: Pardes.pump serialises input, event processing, 9P servicing, rendering and presenting into one loop, so the frame rate ends up +governing things that have nothing to do with drawing. The frame rate should only govern rendering. Evidence: a 9P RPC against an idle gui session costs 19.6 ms +and ZERO editor CPU -- it is not doing work, it is sleeping out the 16 ms SDL_WaitEventTimeout because the cross-thread wake does not wake it, and 9P is only +serviced afterwards in poll_frame. The same RPC against a tty session, same cloud9 runner, is 0.758 ms. So the server is fine and the gui host loop is the problem. + +Caching optimisations for the gui: worth doing, the renderer rebuilds more per frame than it needs to. + +FIXED, and it was the frame coupling after all: pump now draws a frame only when something that can change the screen happened. Pardes.needs_frame starts true, +is set by every event except a tick with nothing animating and a read-only fs_req, and is cleared once a frame is presented; pump returns before render/present +when it is false and nothing is animating. 9P round trip on a local socket: gui 9.7 ms -> 0.056 ms (173x), tty 0.758 ms -> 0.062 ms (12x). The wake path was never +the problem -- instrumentation proved every request woke the loop early (wakes=210, woke_early=212, timed_out=38 over 250 ticks) -- the reply simply could not be +produced until the loop had finished drawing a frame it did not need. Verified the gui still paints (screen shows the file, and a write through 9P redraws) and +the full suite is unchanged at 778/783 with the two known crashes. diff --git a/src/macos.zig b/src/macos.zig index d263cfc6..83b5eac9 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -709,6 +709,10 @@ fn initCore(runtime: ?*const Runtime, cols_arg: u16, rows_arg: u16) !void { var opts: pardes.Options = .{ .tty_only = true, + // The app is a real bare-tty startup, like the terminal and SDL + // shells, so it names the same layout rather than the bare shell a + // core booted for a test gets. + .boot = .tty, .frame_allocator = allocs.frame, .image_allocator = allocs.image, .pdf_allocator = allocs.pdf, diff --git a/src/main.zig b/src/main.zig index 1cbdf24c..9abc04bf 100644 --- a/src/main.zig +++ b/src/main.zig @@ -270,10 +270,6 @@ fn nativeMain(init: std.process.Init) !void { } } opts.tty_only = explicit_tty or session_only; - // The real bare-tty startup wants the editor half present from the first - // frame, so it names that layout; a core booted for a test names none and - // gets the raw shell by itself. - if (opts.tty_only) opts.boot = .tty; opts.mounts = mounts[0..mounts_len]; if (detach != null and attach != null) badArgs(init.io, "--detach and --attach are opposites: one runs the session, the other joins one", .{}); @@ -370,6 +366,13 @@ fn nativeMain(init: std.process.Init) !void { } } } + // The real bare-tty startup wants the editor half present from the first + // frame, so it names that layout; a core booted for a test names none and + // gets the raw shell by itself. This has to come AFTER the positional is + // resolved: naming a layout short-circuits `Boot.of`, so choosing it any + // earlier threw away a file argument, and the error pane for one that does + // not exist, without a word. + if (opts.tty_only and opts.file == null and opts.missing == null) opts.boot = .tty; const found = pardes.config.User.load(init.io, arena, init.environ_map); opts.startup_config = found.bytes; opts.startup_config_path = found.path; diff --git a/src/pardes.zig b/src/pardes.zig index 51442034..e9908d65 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -304,7 +304,7 @@ const pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Col const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse"; // ...and the defaults from before the closing word moved to the end of every // tagline, where a click cannot land on it by overshooting the one before. -const pre_close_last_pane_tail = " Tty Collapse Del"; +const pre_close_last_pane_tail = " Tty Del Collapse"; const pre_close_last_file_pane_tail = " Save Tty Del Collapse"; const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse"; const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse"; @@ -6099,6 +6099,9 @@ pub const Pardes = struct { fs: filesystem.Namespace = .{}, boot: Boot = .shell, + /// 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, /// 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. @@ -7137,13 +7140,28 @@ pub const Pardes = struct { // A quitting frame has already freed what it would draw. if (p.quit) return; if (v.poll_frame) |f| f(h.ctx); + // Nothing to draw: do not spend a frame, and above all do not block on + // the display for one. This is what keeps the frame rate governing + // rendering and nothing else. + if (!p.needs_frame and !p.animationActive()) return; _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); if (v.post_present) |f| f(h.ctx); + p.needs_frame = false; } pub fn update(p: *Pardes, ev: Event) void { + // Only two events cannot change the screen: a tick with nothing + // animating, and a filesystem request that only reads. The second is + // what 9P traffic overwhelmingly is, and answering it used to drag a + // whole vsync-blocked frame behind it -- which is why a round trip on + // a local socket cost a frame instead of a few microseconds. + p.needs_frame = p.needs_frame or switch (ev) { + .tick => false, + .fs_req => |req| ctlfs.changesPane(req), + else => true, + }; p.shell_rows.sweep(p.gpa); switch (ev) { .tick => {}, @@ -13953,8 +13971,17 @@ pub const Pardes = struct { if (pane.promptAt()) |at| { if (pane.tagSlice()[@min(at, pane.tag_tail_len)..].len > 0) pane.notices.push(.prompt); } - // Never claim so many rows that the body has none left. - pane.notices.len = @min(pane.notices.len, r.h - BOX_H -| 1); + // A pane only has so many rows to give. When they do not all fit the + // LAST ones survive, because the prompt is last and it is the one + // carrying the cursor: a prompt you cannot see is a prompt you type + // 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; + } } fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { @@ -14603,6 +14630,42 @@ test "Esc back into a tty leaves its view at the prompt" { try std.testing.expectEqual(live, sp.terminal.?.vt.screens.active.pages.scrollbar().offset); } +test "closing the last pane in a column leaves an empty one in its place" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); + defer p.deinit(); + const shell = p.active; + p.newScratchColumn(shell); + const lone = p.active; + try std.testing.expect(lone != shell); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + const at = layout.findPane(p, lone).?; + try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); + const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice()); + defer std.testing.allocator.free(had); + + try p.removePane(lone); + p.sync(); + + // The column outlives the pane: still two columns, and the one that was + // emptied holds a fresh empty pane in the same directory. + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); + const heir = p.col_panes[at.col][0]; + try std.testing.expect(heir != lone); + try std.testing.expect(p.panes[lone] == null); + const hp = p.panes[heir].?; + try std.testing.expectEqualStrings("", hp.file.?.content); + try std.testing.expectEqualStrings(had, hp.cwdSlice()); + try std.testing.expectEqual(heir, p.active); + try std.testing.expect(!p.quit); + + // ...and Delcol is what actually takes the column away. + try p.removeColumn(heir); + p.sync(); + try std.testing.expectEqual(@as(usize, 1), p.ncol); +} + test "the bare tty startup opens a text pane under the shell, unfocused" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .boot = .tty }); |
