From 3267d276ab2553adb412183eb92390ed579b494f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 17:03:15 -0300 Subject: zig build monkey: random snapshot scripts hunting panics test/monkey.zig (Zig, as the project's tooling is): one random .snap per seed, over bad-UTF-8, wide-glyph and long files: keys, leader bursts, typing (an alphabet with no path characters), workspace words run by a middle click (Newcol for narrow panes, Find and Grep for prompts, long prompt text), presses, drags, wheel, hover, and resizes down to a cell. pardes-snap runs it with --update; a panic is a record in the app's crash file (in the script's fake home: seen even when the crash cut the script short) or a `.zig:N:N: 0x` frame of the app's (not the harness's) in its report or captured grid, and a hit keeps the script, captures and log under --out (default /tmp/pardes-monkey). A seed always makes the same script (tested), so --from=N replays one. Not a gate. README documents it next to snap. No Shader line: the tty has no post chain; the core fuzz in src/draw.zig covers the compile-error notice at a gui's metrics. 80 seeds x 70 steps: no panic. --- README.md | 15 ++++ build/snap.zig | 19 +++++ test/monkey.zig | 244 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+) create mode 100644 test/monkey.zig diff --git a/README.md b/README.md index 3e7a96f2..267b5ca7 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,8 @@ zig build 9p-test freestanding protocol tests zig build quic-test -Dquic=true optional QUIC transport tests zig build snap scripted input traces against frozen golden grids zig build snap-driver-test retry evidence, strict failures and fixture isolation +zig build monkey random snapshot scripts hunting panics (not a gate) +zig build monkey-test the monkey's generator and panic detector zig build hxdiff differential suite against helix's own behaviour zig build hxdiff-test comparator, allocation and CLI regression checks zig build hxdiff-live compare against a freshly run hx-harness @@ -150,6 +152,19 @@ The benchmark steps — `perf`, `pdf-bench`, `pdf-scroll-bench`, `pdf-sections-b Snapshot scripts can use `snap9p` to capture core cells and styles through the default socket alongside the terminal emulator's independent captures. +`zig build monkey -Dplatform=tty -- [steps] [--from=N] [--out=DIR] [--keep]` +writes one random script per seed (keys, leader bursts, typing, clicks, drags, +wheel, tiny resizes, prompts and long prompt text over bad-UTF-8 and wide-glyph +files), runs it through pardes-snap and looks for a panic's `.zig:N:N: 0x` +frame in the report and in the captured grid. A hit keeps the script, its +captures and its log in DIR (default `/tmp/pardes-monkey`); a seed always makes +the same script, so `--from=N` with one seed replays it. A trace longer than +the grid scrolls away: replay the kept script at a larger `start` size. Each +crash found gets a fix and a regression script in `test/snapshots/`. The tty's +tagline is the body's pitch, so what a narrower tagline does is fuzzed by a +unit test instead (`a monkey over notices…` in `src/draw.zig`, longer with +`PARDES_FUZZ_SEED`/`PARDES_FUZZ_STEPS`). + `-Dtest-filter=` applies to every unit-test binary. Use `-Doptimize=ReleaseFast` for performance measurements. To record output and first/warm command runtimes across revisions, run: diff --git a/build/snap.zig b/build/snap.zig index 6b3d2104..7aa06884 100644 --- a/build/snap.zig +++ b/build/snap.zig @@ -49,6 +49,25 @@ pub fn addTty(b: *std.Build, opts: struct { run_snap.has_side_effects = true; const snap_step = b.step("snap", "run the snapshot parity suite (-- --update regenerates goldens)"); snap_step.dependOn(&run_snap.step); + // Random scripts through pardes-snap, hunting panics (test/monkey.zig): + // not a gate, it is slow and random over seeds; a seed replays. + const monkey = b.addExecutable(.{ + .name = "pardes-monkey", + .root_module = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = b.path("test/monkey.zig"), + .link_libc = true, + }), + }); + const run_monkey = b.addRunArtifact(monkey); + run_monkey.addArtifactArg(snap); + run_monkey.addArtifactArg(opts.exe); + if (b.args) |args| run_monkey.addArgs(args); + run_monkey.has_side_effects = true; + b.step("monkey", "random snapshot scripts hunting panics (-- [steps] [--from=N] [--out=DIR])").dependOn(&run_monkey.step); + const monkey_tests = b.addTest(.{ .root_module = monkey.root_module }); + b.step("monkey-test", "test the monkey's generator and panic detector").dependOn(&b.addRunArtifact(monkey_tests).step); const run_driver_test = b.addRunArtifact(snap); run_driver_test.addArg("--self-test"); run_driver_test.has_side_effects = true; diff --git a/test/monkey.zig b/test/monkey.zig new file mode 100644 index 00000000..83869ce4 --- /dev/null +++ b/test/monkey.zig @@ -0,0 +1,244 @@ +// pardes-monkey: random snapshot scripts against the tty, hunting panics. +// +// zig build monkey -Dplatform=tty -- [steps] [--from=N] [--out=DIR] [--keep] +// +// Each seed makes one script (a fixed seed makes the same script, so a hit +// replays): keys and leader bursts, typing, mouse presses, drags and wheel, +// tiny resizes, prompts and long prompt text, over files of bad UTF-8 and +// wide glyphs. pardes-snap runs it with --update into DIR (default +// /tmp/pardes-monkey), and a panic is a record in the app's crash file +// (src/crash.zig, in the script's fake home: found even when the crash cut +// the script short) or a `.zig:N:N: 0x` stack frame in the report or the +// grid it captured (a trace renders INTO the screen). A hit +// keeps DIR/seed-N.snap, its captures and its report; the rest are deleted. +// A panic's trace can scroll off a small grid: replay the kept script, or +// run the app in a larger pty. Every hit gets a fix and a pinned regression +// script in test/snapshots/. +// +// Not a test gate: it is slow, random over seeds and aimed at finding, not +// holding. Shell text typed into a pane only ever runs in the throwaway +// script directory, and its alphabet has no path characters. +const std = @import("std"); +const libc = std.c; + +const Script = struct { + out: std.ArrayListUnmanaged(u8) = .empty, + gpa: std.mem.Allocator, + rows: u16 = 24, + cols: u16 = 80, + + fn line(s: *Script, comptime fmt: []const u8, args: anytype) !void { + try s.out.print(s.gpa, fmt ++ "\n", args); + } +}; + +// Workspace-tag words: clicked with the middle button they run, and the +// harness finds their column on row 1 while the window is wide enough. +const commands = [_][]const u8{ "Newcol", "Find", "Grep", "Joincol", "Help", "Dump", "NextColor", "Debug" }; +const leader_keys = "abcdefghijklmnopqrstuvwxyz0123456789$^"; +const typed = "abcdefghijklmnopqrstuvwxyz0123456789 -_=+,:;()"; + +fn generate(gpa: std.mem.Allocator, seed: u64, steps: usize) ![]u8 { + var prng = std.Random.DefaultPrng.init(seed); + const random = prng.random(); + var s: Script = .{ .gpa = gpa }; + try s.line("# pardes-monkey seed {d}, {d} steps", .{ seed, steps }); + try s.line("file bad.txt hello \\xff\\xfe world\\nlatin-1 caf\\xe9 tail\\ntruncated \\xe6\\x97 here\\nend\\n", .{}); + try s.line("file wide.txt {s}tail\\nsecond line\\n", .{"\\xe6\\x97\\xa5\\xe6\\x9c\\xac\\xe8\\xaa\\x9e " ** 8}); + try s.line("lines long.txt 400 some words after the number, long enough to wrap in a narrow pane", .{}); + try s.line("start 24 80 bad.txt", .{}); + try s.line("wait 8000 Newcol Joincol", .{}); + try s.line("stable 300 5000", .{}); + for (0..steps) |step| { + const wide = s.cols >= 70 and s.rows >= 6; + switch (random.uintLessThan(u8, 12)) { + // A workspace word, run with a middle click: new columns make + // narrow panes, Find and Grep open prompts. + 0, 1 => if (wide) { + const word = commands[random.uintLessThan(usize, commands.len)]; + try s.line("press middle @{s} 1", .{word}); + try s.line("release middle @{s} 1", .{word}); + }, + // Typing: into a prompt when one is up, far past its band. + 2, 3 => { + var text: [200]u8 = undefined; + const n = random.intRangeAtMost(usize, 1, if (random.uintLessThan(u8, 4) == 0) text.len else 12); + for (text[0..n]) |*c| c.* = typed[random.uintLessThan(usize, typed.len)]; + try s.line("text {s}", .{text[0..n]}); + }, + 4 => try s.line("key {s}", .{switch (random.uintLessThan(u8, 10)) { + 0 => "enter", + 1 => "tab", + 2 => "bs", + 3 => "up", + 4 => "down", + 5 => "left", + 6 => "right", + 7 => "pgdn", + 8 => "home", + else => "end", + }}), + 5 => { + try s.line("key esc", .{}); + try s.line("settle 120", .{}); + }, + // A leader burst: space and a chord's keys. + 6 => { + var chord: [3][2]u8 = undefined; + const n = random.intRangeAtMost(usize, 1, 3); + for (chord[0..n]) |*c| c.* = .{ leader_keys[random.uintLessThan(usize, leader_keys.len)], 0 }; + try s.out.appendSlice(gpa, "key space"); + for (chord[0..n]) |c| try s.out.print(gpa, " {c}", .{c[0]}); + try s.out.append(gpa, '\n'); + }, + 7, 8 => { + const buttons = [_][]const u8{ "left", "middle", "right" }; + const button = buttons[random.uintLessThan(usize, buttons.len)]; + const col = random.intRangeAtMost(u16, 1, s.cols); + const row = random.intRangeAtMost(u16, 1, s.rows); + try s.line("press {s} {d} {d}", .{ button, col, row }); + if (random.boolean()) try s.line("drag {s} {d} {d}", .{ button, random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) }); + try s.line("release {s} {d} {d}", .{ button, random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) }); + }, + 9 => try s.line("wheel {s} {d} {d}", .{ if (random.boolean()) "up" else "down", random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) }), + 10 => try s.line("motion {d} {d}", .{ random.intRangeAtMost(u16, 1, s.cols), random.intRangeAtMost(u16, 1, s.rows) }), + // Tiny windows as often as ordinary ones. + else => { + s.rows = if (random.boolean()) random.intRangeAtMost(u16, 1, 6) else random.intRangeAtMost(u16, 7, 40); + s.cols = if (random.boolean()) random.intRangeAtMost(u16, 1, 20) else random.intRangeAtMost(u16, 21, 140); + try s.line("resize {d} {d}", .{ s.rows, s.cols }); + }, + } + try s.line("settle 30", .{}); + if (step % 10 == 9) try s.line("snap s{d}", .{step}); + } + try s.line("stable 300 5000", .{}); + try s.line("snap end", .{}); + return s.out.toOwnedSlice(gpa); +} + +/// A stack frame of a Zig panic trace, `.zig:12:34: 0x`, of the app's: the +/// harness's own error trace (a script it could not finish) is not a hit. +fn hasTrace(bytes: []const u8) bool { + var lines = std.mem.splitScalar(u8, bytes, '\n'); + while (lines.next()) |line| { + if (std.mem.indexOf(u8, line, "test/snapshot.zig") != null or std.mem.indexOf(u8, line, "test/e2e_harness.zig") != null) continue; + if (frameIn(line)) return true; + } + return false; +} + +fn frameIn(bytes: []const u8) bool { + var from: usize = 0; + while (std.mem.indexOfPos(u8, bytes, from, ".zig:")) |at| : (from = at + 1) { + var i = at + ".zig:".len; + var digits: usize = 0; + while (i < bytes.len and std.ascii.isDigit(bytes[i])) : (i += 1) digits += 1; + if (digits == 0 or i >= bytes.len or bytes[i] != ':') continue; + i += 1; + digits = 0; + while (i < bytes.len and std.ascii.isDigit(bytes[i])) : (i += 1) digits += 1; + if (digits > 0 and std.mem.startsWith(u8, bytes[i..], ": 0x")) return true; + } + return false; +} + +test "a panic trace is found, and ordinary colons are not" { + try std.testing.expect(hasTrace(" /src/draw.zig:814:31: 0x4212821 in joinNotice")); + try std.testing.expect(!hasTrace("draw.zig:814 is where it was; 12:30: 0x")); + try std.testing.expect(!hasTrace("/x/test/snapshot.zig:197:28: 0x1279761 in main (snapshot.zig)")); +} + +test "a seed makes the same script every time" { + const a = try generate(std.testing.allocator, 42, 50); + defer std.testing.allocator.free(a); + const b = try generate(std.testing.allocator, 42, 50); + defer std.testing.allocator.free(b); + try std.testing.expectEqualStrings(a, b); +} + +fn readAll(gpa: std.mem.Allocator, io: std.Io, path: []const u8) []u8 { + return std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 << 20)) catch &.{}; +} + +pub fn main(init: std.process.Init) !void { + const gpa = std.heap.page_allocator; + const io = init.io; + const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len < 4) { + std.debug.print("usage: pardes-monkey [steps] [--from=N] [--out=DIR]\n", .{}); + std.process.exit(2); + } + const snap = args[1]; + const app = args[2]; + var seeds: u64 = 20; + var steps: usize = 70; + var from: u64 = 1; + var out: []const u8 = "/tmp/pardes-monkey"; + // Keep every script and capture, not only a hit's: to see what it did. + var keep = false; + var positional: usize = 0; + for (args[3..]) |a| { + if (std.mem.startsWith(u8, a, "--from=")) { + from = try std.fmt.parseInt(u64, a["--from=".len..], 10); + } else if (std.mem.eql(u8, a, "--keep")) { + keep = true; + } else if (std.mem.startsWith(u8, a, "--out=")) { + out = a["--out=".len..]; + } else { + switch (positional) { + 0 => seeds = try std.fmt.parseInt(u64, a, 10), + 1 => steps = try std.fmt.parseInt(usize, a, 10), + else => {}, + } + positional += 1; + } + } + std.Io.Dir.cwd().createDirPath(io, out) catch {}; + var hits: usize = 0; + var seed = from; + while (seed < from + seeds) : (seed += 1) { + const script = try generate(gpa, seed, steps); + defer gpa.free(script); + var path_buf: [4096]u8 = undefined; + const path = try std.fmt.bufPrint(&path_buf, "{s}/seed-{d}.snap", .{ out, seed }); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = script }); + const run = std.process.run(gpa, io, .{ + .argv = &.{ snap, app, "--update", "--no-retry", "--jobs=1", path }, + .stdout_limit = .limited(64 << 20), + .stderr_limit = .limited(64 << 20), + }) catch |err| { + std.debug.print("seed {d}: pardes-snap did not run ({t})\n", .{ seed, err }); + continue; + }; + defer gpa.free(run.stdout); + defer gpa.free(run.stderr); + var golden_buf: [4096]u8 = undefined; + const golden_path = try std.fmt.bufPrint(&golden_buf, "{s}/seed-{d}.golden", .{ out, seed }); + const golden = readAll(gpa, io, golden_path); + defer gpa.free(golden); + // The app's own crash file (src/crash.zig) in the script's fake home: + // found even when the crash cut the script short of a capture. + var crash_buf: [4096]u8 = undefined; + const crash_path = try std.fmt.bufPrint(&crash_buf, "/tmp/pardes-snap/seed-{d}/home/.config/pardes/crashes", .{seed}); + const crashes = readAll(gpa, io, crash_path); + defer gpa.free(crashes); + if (crashes.len != 0 or hasTrace(run.stdout) or hasTrace(run.stderr) or hasTrace(golden)) { + hits += 1; + var log_buf: [4096]u8 = undefined; + const log_path = try std.fmt.bufPrint(&log_buf, "{s}/seed-{d}.log", .{ out, seed }); + const both = try std.mem.concat(gpa, u8, &.{ crashes, run.stdout, run.stderr }); + defer gpa.free(both); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = log_path, .data = both }); + std.debug.print("PANIC seed {d}: {s}\n", .{ seed, path }); + } else if (!keep) { + std.Io.Dir.cwd().deleteFile(io, path) catch {}; + std.Io.Dir.cwd().deleteFile(io, golden_path) catch {}; + std.debug.print("ok seed {d}\n", .{seed}); + } else { + std.debug.print("ok seed {d} (kept)\n", .{seed}); + } + } + std.debug.print("{d} of {d} seeds panicked; kept in {s}\n", .{ hits, seeds, out }); + if (hits != 0) std.process.exit(1); +} -- cgit v1.3 From deeb8ac54f71d4322a778336db9a7aec411c23ce Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:44:51 -0300 Subject: G1: Lift, soft elevation shadows (off by default) `Lift` (a GUI toggle, capability lift) lifts the focused pane on a critically damped spring (animation.Spring: closed form, about 240 ms to settle, velocity kept across retargets, settled = no frames) and floats notices. The core puts the lift on the regions (Region.lift, on the wire); the GUI casts from it: a soft shadow is decor, an erf rectangle on one quad grown by 3 sigma (decor.frag, flag 0x10000000), in the quad's own space so a track's transform carries it; darken-only, the caster's rect left alone, alpha pre-warped for linear light and IGN-dithered. Cast last in its group, so a neighbour's rails and rules darken with its text; a notice's before its rule. Strength is capped per theme so text over the darkest shadow keeps min(its contrast, 4.5) (test over the native themes). Opt-in until the focus lift/dim default is decided. Feel review material: docs/effects.md. Test-mode PARDES_TEST_SERIES keeps every captured frame. Shared files touched: pardes.zig (two fields, one nextWake line), detached/wire.zig (lift on the region), config.zig, builtins.zig (capability), gui.zig. Not touched: Messages.zig, mouse.zig, tty, host_io. --- docs/effects.md | 18 +++++++ shaders/decor.frag.glsl | 58 +++++++++++++++++++--- shaders/ui.frag.glsl | 2 +- shaders/ui.vert.glsl | 8 +-- src/animation.zig | 80 ++++++++++++++++++++++++++++++ src/builtins.zig | 2 + src/config.zig | 18 +++++-- src/detached/wire.zig | 7 ++- src/draw.zig | 16 ++++++ src/gui/gui.zig | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ src/pardes.zig | 5 ++ src/surface.zig | 4 ++ 12 files changed, 329 insertions(+), 18 deletions(-) create mode 100644 docs/effects.md diff --git a/docs/effects.md b/docs/effects.md new file mode 100644 index 00000000..44f3e949 --- /dev/null +++ b/docs/effects.md @@ -0,0 +1,18 @@ +# Effects: feel reviews + +Each effect of docs/render-pipeline.md §9 lands behind its own switch, off, +and is kept only after a feel review (§8.4): a frame series on the virtual +clock, a recording, and a verdict — keep, polish or drop — with one line of +why. The verdict is the user's. + +| effect | switch | state | review material | verdict | +|---|---|---|---|---| +| Crt (bundled post pass) | `Crt 0..3` | kept, rewritten (no barrel) | fx-compare stills, live window | kept at the user's live look | +| Ripple, Glitch | — | removed | live window | dropped by the user after a live look | +| G1 soft elevation shadows | `Lift` (off) | opt-in until the focus-lift default is decided | frame series + mp4 per theme (forge, acme, dusk): Lift on, a focus switch, a run of quick switches, a notice, Lift off | pending | + +Notes on G1: the lift runs on a critically damped spring (§7.2) that settles +in about 240 ms and keeps its velocity when a quick run of switches retargets +it; a notice floats on a lift of 1 while Lift is on. The series are the core's +frames: core animation steps at 62.5 Hz on any display (§7.1), so a 144 Hz +display shows the same frames, each held for two or three refreshes. diff --git a/shaders/decor.frag.glsl b/shaders/decor.frag.glsl index 4c2bd6d8..77f9c95e 100644 --- a/shaders/decor.frag.glsl +++ b/shaders/decor.frag.glsl @@ -1,24 +1,68 @@ #version 450 -// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) -// that ui.vert.glsl places like a cell, its track's transition included. Its -// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The -// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so -// a fading rule shows through, and the result is that pipeline's exactly. -// Ink (a cursor) is drawn with the opacity uniform at 1. +// Decor, drawn by ui.vert.glsl like a cell, its track's transition included, +// and blended as the overlay pipeline blends (backgroundLayerBlend). Ink (a +// cursor) is drawn with the opacity uniform at 1. +// +// Flat: a hard-edged fill (a rule, a rail, a grip mark, a bar cursor). Its +// colour rides in the cell's ground (a_bg), its coverage in a_fg.r, so the +// result is the overlay pipeline's exactly. +// +// Soft shadow (flag 0x10000000): a rectangle's shadow blurred by a Gaussian, +// in closed form (the product of two erf edges), on one quad grown by 3 +// sigma: no blur pass. Everything is in the quad's own 0..1 space, so a +// track's transform carries it: sigma per axis in the uv, the caster's +// offset from its shadow in fg.gb, the strength in fg.r. The caster's own +// rectangle is left alone (it is drawn already), and the alpha is pre-warped +// so black darkens in linear light, then dithered so it never bands. layout(std140, set = 3, binding = 0) uniform UiUniforms { float background_opacity; } u_ui; +layout(location = 0) in vec2 v_uv; layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; +layout(location = 3) in vec2 v_cell_uv; +layout(location = 4) flat in uvec4 v_effect; layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; +// erf to about 4e-4 (Winitzki's approximation): smooth, cheap, odd. +float erfApprox(float x) { + float x2 = x * x; + const float a = 0.147; + float e = sqrt(1.0 - exp(-x2 * (1.2732395 + a * x2) / (1.0 + a * x2))); + return x < 0.0 ? -e : e; +} + +// How much of [lo, hi] a Gaussian of sigma s centred at x covers. +float edge(float x, float lo, float hi, float s) { + float k = 0.70710678 / s; + return 0.5 * (erfApprox((x - lo) * k) - erfApprox((x - hi) * k)); +} + +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + void main() { if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; - o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); + float opacity = clamp(u_ui.background_opacity, 0.0, 1.0); + if ((v_effect.x & 0x10000000u) == 0u) { + o_col = vec4(v_bg * opacity, v_fg.r); + return; + } + vec2 s = v_uv; + vec2 m = 3.0 * s; + vec2 p = v_cell_uv; + vec2 caster0 = m - v_fg.gb; + vec2 caster1 = 1.0 - m - v_fg.gb; + if (all(greaterThanEqual(p, caster0)) && all(lessThan(p, caster1))) discard; + float a = v_fg.r * edge(p.x, m.x, 1.0 - m.x, s.x) * edge(p.y, m.y, 1.0 - m.y, s.y); + a = 1.0 - pow(max(1.0 - a, 0.0), 1.0 / 2.2); + a = clamp(a + (ign(gl_FragCoord.xy) - 0.5) / 255.0, 0.0, 1.0); + o_col = vec4(v_bg * opacity, a); } diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index 0b099db3..dca224ba 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -43,7 +43,7 @@ void main() { float bgA = (v_effect.x & 0x20000000u) != 0u ? 1.0 : (v_effect.x & 0x40000000u) != 0u ? 0.0 : clamp(u_ui.background_opacity, 0.0, 1.0); - uint effect = v_effect.x & 0x1fffffffu; + uint effect = v_effect.x & 0x0fffffffu; float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0); uint cellCol = v_effect.w & 0xffffu; uint cellRow = v_effect.w >> 16; diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl index c1779add..5551f3f1 100644 --- a/shaders/ui.vert.glsl +++ b/shaders/ui.vert.glsl @@ -14,9 +14,9 @@ layout(location = 3) in vec3 a_bg; layout(location = 4) in vec4 a_panel_rect; layout(location = 5) in vec4 a_present_rect; // effect id, eased-progress float bits, pane serial, local row/column packed -// into one uint. The THREE high effect bits are flags — the frozen-old cell -// layer, a see-through cell background and an opaque block-cursor fill — so -// the id is what is left below them; integer transport preserves the exact +// into one uint. The FOUR high effect bits are flags — the frozen-old cell +// layer, a see-through cell background, an opaque block-cursor fill and a +// soft shadow (decor.frag.glsl) — so the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; // A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader @@ -61,7 +61,7 @@ void main() { v_cell_uv = vec2(0.0, 1.0); } - uint effect = a_effect.x & 0x1fffffffu; + uint effect = a_effect.x & 0x0fffffffu; if (effect == 1u || effect == 2u || effect == 5u) { // NDC y points up, so a top-left/bottom-right box has a NEGATIVE y // delta. Preserve that sign or every local y is mirrored/outside. diff --git a/src/animation.zig b/src/animation.zig index 948a6679..ca933b82 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -586,6 +586,86 @@ pub const frame_ms: u32 = 16; pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; pub const transition_steps: u16 = 10; +/// A value that follows its target on a critically damped spring, in closed +/// form (docs/render-pipeline.md §7.2): no overshoot, and a new target taken +/// partway keeps the value AND its velocity, so a quick run of changes never +/// snaps or starts over. `omega` sets the pace: it is within 1% of a jump +/// after about 6.6 / omega seconds, and settles (half a percent, nearly +/// still) at about 8.6 / omega. Settled, it is exactly its target and asks +/// for no frames. +pub const Spring = struct { + /// Within 1% of a jump in 6.6 / 36 ≈ 180 ms, settled by 240 ms: a + /// state change (§8.1). + pub const state_change: f32 = 36; + + omega: f32 = state_change, + target: f32 = 0, + /// The value and velocity (per second) at `from_ns`. + from: f32 = 0, + velocity: f32 = 0, + from_ns: u64 = 0, + settled: bool = true, + + const State = struct { value: f32, velocity: f32 }; + + fn at(spring: *const Spring, now_ns: u64) State { + if (spring.settled) return .{ .value = spring.target, .velocity = 0 }; + const t: f32 = @floatCast(@as(f64, @floatFromInt(now_ns -| spring.from_ns)) / std.time.ns_per_s); + const w = spring.omega; + const x0 = spring.from - spring.target; + const c = spring.velocity + w * x0; + const decay = @exp(-w * t); + return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (spring.velocity - w * t * c) * decay }; + } + + pub fn value(spring: *const Spring, now_ns: u64) f32 { + return spring.at(now_ns).value; + } + + /// Heads for `target` from wherever it is at `now_ns`, moving as it was. + pub fn retarget(spring: *Spring, target: f32, now_ns: u64) void { + if (target == spring.target) return; + const state = spring.at(now_ns); + spring.* = .{ .omega = spring.omega, .target = target, .from = state.value, .velocity = state.velocity, .from_ns = now_ns, .settled = false }; + } + + /// Settles once it is within half a percent of its target and barely + /// moving (no pixel of a shadow tells the rest): it is its target from + /// then on. True while it still moves. + pub fn step(spring: *Spring, now_ns: u64) bool { + if (spring.settled) return false; + const state = spring.at(now_ns); + if (@abs(state.value - spring.target) < 5e-3 and @abs(state.velocity) < 5e-2) spring.settled = true; + return !spring.settled; + } +}; + +test "a spring settles without overshoot and keeps its velocity when retargeted" { + var spring: Spring = .{}; + const ms = std.time.ns_per_ms; + spring.retarget(1, 0); + var last: f32 = 0; + var t: u64 = 0; + while (spring.step(t)) : (t += frame_ns) { + const v = spring.value(t); + try std.testing.expect(v >= last and v <= 1); + last = v; + } + // Settled within a frame or two of 6.6 / omega. + try std.testing.expect(t >= 180 * ms and t <= 260 * ms); + try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms)); + // Back the other way halfway up: it carries on up for a moment, then + // turns, never jumping. + spring = .{}; + spring.retarget(1, 0); + const before = spring.at(60 * ms); + spring.retarget(0, 60 * ms); + const after = spring.at(60 * ms); + try std.testing.expectApproxEqAbs(before.value, after.value, 1e-6); + try std.testing.expectApproxEqAbs(before.velocity, after.velocity, 1e-4); + try std.testing.expect(spring.value(64 * ms) > before.value); +} + /// A displayed value that fades from one target to the next over /// `transition_steps` frames; the chrome colours are one. pub fn Fade(comptime Value: type) type { diff --git a/src/builtins.zig b/src/builtins.zig index 23d853c8..53cf0999 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -28,6 +28,8 @@ pub const capabilities: config.Runtime.Capabilities = .{ // Only the SDL shell shapes with HarfBuzz and honours the setting. macOS // draws CoreText ligatures of its own, but nothing there reads it yet. .ligatures = pardes.platform == .gui, + // Soft shadows are the SDL shell's decor. + .lift = pardes.platform == .gui, }; /// Where a builtin acts: at the pane it runs from, or on the editor as a diff --git a/src/config.zig b/src/config.zig index 186b29a1..0ed8726a 100644 --- a/src/config.zig +++ b/src/config.zig @@ -115,6 +115,7 @@ pub const leader_path = paths: { if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null); if (builtins.capabilities.window_blur) table.set(.WindowBlur, null); if (builtins.capabilities.ligatures) table.set(.Ligatures, null); + if (builtins.capabilities.lift) table.set(.Lift, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -618,6 +619,10 @@ pub const Runtime = struct { /// A font's programming ligatures (`->`, `!=`) draw across their cells. /// Only a shell that shapes text has the setting (`Capabilities.ligatures`). ligatures: bool = true, + /// The focused pane and the notices stand off the page on soft shadows + /// (G1, docs/render-pipeline.md §9.1). Opt-in until the focus lift's + /// default is decided. Only a pixel shell draws it (`Capabilities.lift`). + lift: bool = false, /// Builtins announce themselves on the message row. verbose: bool = true, /// A message falls into its row and dissolves out of it. @@ -808,6 +813,8 @@ pub const Runtime = struct { window_blur: bool = false, /// The shell shapes text, so it can draw ligatures and stop drawing them. ligatures: bool = false, + /// The shell draws soft shadows under what stands off the page. + lift: bool = false, }; pub const Capability = std.meta.FieldEnum(Capabilities); @@ -815,7 +822,7 @@ pub const Runtime = struct { /// 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, ligatures }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures, lift }; /// The bundled post pass, a Shadertoy file in shaders/post/. pub const Scene = enum { crt }; @@ -863,6 +870,7 @@ pub const Runtime = struct { .{ .word = "Verbose", .action = .{ .toggle = .verbose } }, .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, + .{ .word = "Lift", .action = .{ .toggle = .lift }, .availability = .lift }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, @@ -924,9 +932,9 @@ pub const Runtime = struct { @compileError("scene effects must use the scene-shader capability"), // A toggle is plain state any shell can hold, except one for // what only some shells draw: that one exists where they do. - .toggle => |field| if (field == .ligatures) { - if (setting.availability != .ligatures) - @compileError("the ligatures toggle must use the ligatures capability"); + .toggle => |field| if (field == .ligatures or field == .lift) { + if (setting.availability == null or !std.mem.eql(u8, @tagName(setting.availability.?), @tagName(field))) + @compileError("a pixel shell's toggle must use its own capability: " ++ @tagName(field)); } else if (setting.availability != null) @compileError("unconditional toggles cannot carry a backend capability"), else => if (setting.availability != null) @@ -1447,6 +1455,7 @@ pub const Runtime = struct { .window_opacity = true, .window_blur = true, .ligatures = true, + .lift = true, }, .state = &state, }; @@ -1485,6 +1494,7 @@ pub const Runtime = struct { .verbose => "Verbose off\n", .message_animation => "MessageAnimation off\n", .ligatures => "Ligatures off\n", + .lift => "Lift on\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", diff --git a/src/detached/wire.zig b/src/detached/wire.zig index b1b81eda..6e3b2d01 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -253,6 +253,8 @@ pub const Frame = struct { region.thumb_h = try r.getU16(); region.notice = try r.getByte(); region.clip = try r.getU16(); + region.lift = @bitCast(try r.getU32()); + if (!(region.lift >= 0 and region.lift <= 1)) return error.BadValue; if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; @@ -723,7 +725,7 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg return n + regions.len * region_len + chrome_len; } -const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; +const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2 + 4; /// The palette: each colour three bytes, a theme's optional ones a flag /// before them. const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4; @@ -807,6 +809,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putU16(region.thumb_h); try w.putByte(region.notice); try w.putU16(region.clip); + try w.putU32(@bitCast(region.lift)); } inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { const rgb = @field(chrome, field.name); @@ -1890,7 +1893,7 @@ test "detached wire carries compact body rows and exact pointer metrics" { var tag_cells: [3]pardes.Cell = @splat(.{}); tag_cells[2] = logical[10]; const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } }; - const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; + const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }}; const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } }; diff --git a/src/draw.zig b/src/draw.zig index 2d757635..0998db24 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -119,6 +119,22 @@ pub fn place(p: *Pardes, s: *Surface) void { s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); // A column carried to the window's right edge shows where it lands as a // rail in the last cell: the layers under it stop a cell short. + // Lift: the focused pane rises, the others settle back, each on its + // spring; a notice floats while Lift is on. + for (&p.panes, &p.lifts, &p.lift_serials, 0..) |slot, *spring, *serial, id| { + const pane = slot orelse continue; + if (serial.* != pane.serial) { + serial.* = pane.serial; + spring.* = .{}; + } + spring.retarget(if (p.settings.lift and id == p.active) 1 else 0, p.stepped_ns); + _ = spring.step(p.stepped_ns); + } + for (s.regions[0..s.nregions]) |*region| switch (region.kind) { + .grip, .tag, .body, .rail => region.lift = p.lifts[region.owner].value(p.stepped_ns), + .notice => region.lift = if (p.settings.lift) 1 else 0, + else => {}, + }; if (p.drag == .column_move) rail: { const d = p.drag.column_move; const source = layout.columnBySerial(p, d.serial) orelse break :rail; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 5bc1ea6d..92bec92c 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -487,6 +487,8 @@ const initial_image_capacity: u32 = pardes.MAX_PANES; const old_layer_bit: u32 = 0x8000_0000; const clear_bg_bit: u32 = 0x4000_0000; const opaque_bg_bit: u32 = 0x2000_0000; +/// Decor that is a soft shadow, not a flat fill (decor.frag.glsl). +const soft_shadow_bit: u32 = 0x1000_0000; const SavedImagePlace = struct { key: pardes.ImageCacheKey, @@ -1855,6 +1857,10 @@ const Gui = struct { /// Pixels the capture holds past the grid (PARDES_TEST_PAD). capture_pad: u32 = 0, capture_dir: []const u8 = "", + /// PARDES_TEST_SERIES: every captured frame is kept too, numbered, for a + /// feel review's frame series (docs/render-pipeline.md §8.4). + capture_series: bool = false, + captured: u32 = 0, capture_tex: ?*c.SDL_GPUTexture = null, capture_tex_w: u32 = 0, capture_tex_h: u32 = 0, @@ -2457,6 +2463,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa), .capture = test_mode, .capture_pad = if (test_mode) envU16(env, "PARDES_TEST_PAD") orelse 0 else 0, + .capture_series = test_mode and env.get("PARDES_TEST_SERIES") != null, .capture_dir = capture_dir orelse "", .soft_present = soft_present, .transparent = config.gui_transparent, @@ -7301,6 +7308,8 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest"); var name: [24]u8 = undefined; if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable); + if (g.capture_series) try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "frame-{d:0>5}", .{g.captured}) catch unreachable); + g.captured +%= 1; } fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void { @@ -7375,6 +7384,99 @@ fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, try g.decor.append(gpa, instance); } +/// How a lift of 0 to 1 stands off the page: the shadow's blur and offset +/// in cell heights, and its darkest, where it leaves the caster's edge. +/// Within §8.2's budget: sigma 0.4-0.8 cell, at most 30% darker at the edge. +const Shadow = struct { + // A key light above: the drop is most of the blur, so the shadow hangs + // below and to the right and barely reaches over the caster's top. + const sigma: f32 = 0.42; + const drop: f32 = 0.34; + const side: f32 = 0.1; + const strength: f32 = 0.42; +}; + +/// A soft shadow under `caster` (window pixels), `lift` 0 to 1, no darker +/// than `ceiling`, moved by `track` as its caster is. +fn addShadow(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, lift: f32, ceiling: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + const strength = @min(Shadow.strength * lift, ceiling); + if (lift <= 0.001 or strength <= 0.002) return; + const h: f32 = @floatFromInt(g.cell_h); + const sigma = @max(0.5, Shadow.sigma * h * lift); + const dx = Shadow.side * h * lift; + const dy = Shadow.drop * h * lift; + const x0 = caster[0] + dx - 3 * sigma; + const y0 = caster[1] + dy - 3 * sigma; + const x1 = caster[2] + dx + 3 * sigma; + const y1 = caster[3] + dy + 3 * sigma; + const w = x1 - x0; + const ht = y1 - y0; + if (w <= 0 or ht <= 0) return; + // In the quad's own 0..1 space (decor.frag.glsl): sigma per axis, the + // caster's offset from its shadow, the strength. + var instance: CellInstance = .{ + .x0 = (x0 / win_w) * 2.0 - 1.0, + .y0 = 1.0 - (y0 / win_h) * 2.0, + .x1 = (x1 / win_w) * 2.0 - 1.0, + .y1 = 1.0 - (y1 / win_h) * 2.0, + .u0 = sigma / w, + .v0 = sigma / ht, + .u1 = sigma / w, + .v1 = sigma / ht, + .fr = strength, + .fg = dx / w, + .fb = dy / ht, + .br = 0, + .bg = 0, + .bb = 0, + }; + setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0); + instance.effect |= soft_shadow_bit; + try g.decor.append(gpa, instance); +} + +/// sRGB's relative luminance (WCAG). +fn luminance(rgb: [3]u8) f32 { + var sum: f32 = 0; + for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { + const v = @as(f32, @floatFromInt(channel)) / 255; + sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4)); + } + return sum; +} + +/// How dark a shadow may make the page and its text, both together, before +/// the pair drops below its own contrast or 4.5, whichever is lower (§8.2): +/// darkening in linear light by k leaves (k L1 + .05) / (k L2 + .05). +fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 { + const a = luminance(fg); + const b = luminance(bg); + const light = @max(a, b); + const dark = @min(a, b); + const target = @min((light + 0.05) / (dark + 0.05), 4.5); + const room = light - target * dark; + if (room <= 0) return 0; + return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1); +} + +test "a shadow never darkens a native theme's text below its contrast or 4.5" { + var shaded: usize = 0; + for (pardes.themes) |theme| { + const fg = theme.fg orelse fg_default; + const bg = theme.bg orelse bg_default; + const ceiling = shadowCeiling(fg, bg); + const k = 1 - @min(Shadow.strength, ceiling); + const light = @max(luminance(fg), luminance(bg)); + const dark = @min(luminance(fg), luminance(bg)); + const before = (light + 0.05) / (dark + 0.05); + const after = (k * light + 0.05) / (k * dark + 0.05); + try std.testing.expect(after >= @min(before, 4.5) - 1e-3); + shaded += @intFromBool(ceiling > 0.05); + } + // And most themes have room for one. + try std.testing.expect(shaded * 2 > pardes.themes.len); +} + /// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it /// read from the regions and the palette: the page's rules and the still /// panes' rails, rules and grip marks in tier 0, a moving or closing pane's @@ -7383,6 +7485,10 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g g.decor.clearRetainingCapacity(); const chrome = &surface.chrome; const regions = surface.regionList(); + const ceiling = shadowCeiling(chrome.fg orelse fg_default, chrome.page orelse bg_default); + const notice_lift = for (regions) |region| { + if (region.kind == .notice) break region.lift; + } else 0; for (groups.items[0..groups.len], 0..) |*group, index| { group.decor_start = @intCast(g.decor.items.len); defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; @@ -7432,11 +7538,34 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h); }; try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track); + // Cast last in the group, over its cells, images and chrome: a + // lifted pane's shadow darkens its neighbours' rails and rules + // as it darkens their text, and never the pane itself. + for (placed) |grip| { + if (grip.kind != .grip or grip.lift <= 0.001 or groupOf(groups, grip.serial) != index) continue; + var box: [4]f32 = .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(grip.rect.y)) * layout.h, 0, @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h }; + box[2] = box[0]; + for (placed) |part| { + if (part.owner != grip.owner or (part.kind != .tag and part.kind != .rail and part.kind != .body)) continue; + box[1] = @min(box[1], @as(f32, @floatFromInt(part.rect.y)) * layout.h); + box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w); + box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h); + } + try addShadow(g, gpa, box, grip.lift, ceiling, track, win_w, win_h); + } } // A notice chip is tagline hung over the body's top rows, so it is // ruled off from the text beneath it the way a tag is: the same // colour and thickness along its bottom, running to the window's // edge when it does, fading up and dissolving with its band. + // A notice floats: its shadow first, then every rule. + for (surface.tagLayers()) |*layer| { + if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; + const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; + const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h; + const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; + try addShadow(g, gpa, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, notice_lift * layer.fade, ceiling, track, win_w, win_h); + } for (surface.tagLayers()) |*layer| { if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; diff --git a/src/pardes.zig b/src/pardes.zig index f559b125..449ef798 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4111,6 +4111,10 @@ pub const Pardes = struct { theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, chrome_animation: colors.ChromeAnimation = colors.ChromeAnimation.init(colors.initial_chrome), + /// Each pane's lift off the page (Lift): 1 while it has the focus, on a + /// spring. `lift_serials` says whose it is, so a reused slot starts flat. + lifts: [MAX_PANES]animation.Spring = @splat(.{}), + lift_serials: [MAX_PANES]u32 = @splat(0), 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 @@ -6213,6 +6217,7 @@ pub const Pardes = struct { pub fn nextWake(p: *const Pardes) ?u64 { const next = p.stepped_ns + animation.frame_ns; if (p.chrome_animation.isActive() or p.presentation.animating()) return next; + for (p.lifts) |spring| if (!spring.settled) return next; var frames: ?u64 = null; if (p.look_hover_wait) |waiting| { const delay = config.look_preview_delay_frames orelse return next; diff --git a/src/surface.zig b/src/surface.zig index b6f41853..94b37915 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -364,6 +364,10 @@ pub const Region = struct { thumb_h: u16 = 0, /// A notice's index in its pane's `notices`. notice: u8 = 0, + /// How far it stands off the page, 0 to 1: a pane's while it has the + /// focus (Lift, on a spring), a notice's while Lift is on. A pixel shell + /// casts a soft shadow from it; the tier it is drawn in never changes. + lift: f32 = 0, /// Cells the column-move rail takes off the right edge while it runs /// there: layers and pixel shells stop short of them, the grid does not. clip: u16 = 0, -- cgit v1.3 From b4781b334a06d16aedb0b6a3d604cb249eb619de Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 17:56:10 -0300 Subject: Lift moves at the display's rate: render-time animation Review (and the coordinator): Lift stepped with the core's 16 ms ticks, so a 144 Hz display held each shadow for 2-3 refreshes. The lift springs are now sampled at each frame's own time (p.now_ns), and Pardes.continuous() reports a render-time animation in motion: while it is, the pump draws every loop (a 1 ms wait takes only the input there is) and the present's vsync paces it at the display's rate. They left nextWake; the stepped, frame-indexed animations (transitions, message fades, the theme fade) and every golden are unchanged. With PARDES_TEST_CLOCK, each loop of a moving render-time animation moves the virtual clock by a display frame at PARDES_TEST_HZ (default 60), so a 144 Hz series is deterministic: a focus switch is 51 distinct frames at 144 Hz. Test: renders 6.9 ms apart give rising lifts; settled, no frames. The tty never lifts (GUI capability). Shared files touched: pardes.zig (continuous, pump), gui.zig. --- src/draw.zig | 38 +++++++++++++++++++++++++++++++++++--- src/gui/gui.zig | 18 ++++++++++++++---- src/pardes.zig | 18 ++++++++++++++---- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/draw.zig b/src/draw.zig index 0998db24..70b629a5 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -127,11 +127,12 @@ pub fn place(p: *Pardes, s: *Surface) void { serial.* = pane.serial; spring.* = .{}; } - spring.retarget(if (p.settings.lift and id == p.active) 1 else 0, p.stepped_ns); - _ = spring.step(p.stepped_ns); + // At this frame's own time, not the stepped animations' 16 ms grid. + spring.retarget(if (p.settings.lift and id == p.active) 1 else 0, p.now_ns); + _ = spring.step(p.now_ns); } for (s.regions[0..s.nregions]) |*region| switch (region.kind) { - .grip, .tag, .body, .rail => region.lift = p.lifts[region.owner].value(p.stepped_ns), + .grip, .tag, .body, .rail => region.lift = p.lifts[region.owner].value(p.now_ns), .notice => region.lift = if (p.settings.lift) 1 else 0, else => {}, }; @@ -1122,6 +1123,37 @@ test "a notice wider than its band, as a shader's compile error is, draws its en } } +test "a lift is sampled at each frame's own time, and asks for frames only while it moves" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const ms = std.time.ns_per_ms; + p.advance(1000 * ms); + _ = try p.render(arena.allocator()); + try std.testing.expect(!p.continuous()); + p.settings.lift = true; + var lifts: [3]f32 = undefined; + // 144 Hz: 6.9 ms apart, none of them on a 16 ms step of its own. + for (&lifts, 0..) |*lift, i| { + p.advance(1000 * ms + @as(u64, @intCast(i)) * 6_944_444); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + lift.* = for (s.regionList()) |region| { + if (region.kind == .grip and region.owner == p.active) break region.lift; + } else return error.TestExpectedGrip; + try std.testing.expect(p.continuous()); + } + try std.testing.expect(lifts[0] < lifts[1] and lifts[1] < lifts[2]); + // Settled, it is exactly up, and the core is idle again. + p.advance(2000 * ms); + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + try std.testing.expect(!p.continuous()); + try std.testing.expectEqual(@as(?u64, null), p.nextWake()); +} + test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" { // The snapshot harness runs the tty, whose tagline pitch is the body's, // so it never reaches what a narrower tagline does to a notice's band: diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 92bec92c..905caa48 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2613,6 +2613,7 @@ fn localSession( .fs = fs, .test_mode = test_mode, .test_clock = if (test_mode) host_io.testClock() else null, + .test_hz = if (std.c.getenv("PARDES_TEST_HZ")) |hz| @max(1, std.fmt.parseInt(u64, std.mem.span(hz), 10) catch 60) else 60, }; defer shell.shutdownPtys(); const host = shell.host(); @@ -3898,6 +3899,8 @@ const Shell = struct { retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode. test_clock: ?u64 = null, + /// The display rate a virtual clock stands in for (PARDES_TEST_HZ). + test_hz: u64 = 60, fn reap(s: *Shell) void { const now = shellClock(); @@ -4150,10 +4153,17 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); - // The core's wake ran out: a virtual clock moves exactly there. - if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| { - virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); - }; + // A virtual clock: while a render-time animation moves, each loop + // is a presented frame and moves it by one display frame + // (PARDES_TEST_HZ), events or not, as a real display's present + // would; otherwise a wait that ran out moves it exactly to the + // core's wake. + if (s.test_clock) |*virtual| { + if (core.continuous()) { + const display = virtual.* + std.time.ns_per_s / s.test_hz; + virtual.* = if (core.nextWake()) |due| @max(virtual.*, @min(due, display)) else display; + } else if (!got and timeout_ms != 0) virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); + } if (got) { // A run of queued motions is one move to its last position (the // handler reads only absolute x/y and held buttons): acting on diff --git a/src/pardes.zig b/src/pardes.zig index 449ef798..04ef4b36 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5040,7 +5040,9 @@ pub const Pardes = struct { if (v.wait_input) |f| { // Asleep until input or until core animation next needs a // frame; 0 is no timeout at all. - const timeout: u32 = if (p.nextWake()) |due| wake: { + // A render-time animation draws every loop: the wait only takes + // what input is there. + const timeout: u32 = if (p.continuous()) 1 else if (p.nextWake()) |due| wake: { const now = if (v.now) |clock| clock(h.ctx) else p.now_ns; const ms = ((due -| now) + std.time.ns_per_ms - 1) / std.time.ns_per_ms; break :wake @intCast(std.math.clamp(ms, 1, std.math.maxInt(u32))); @@ -5059,7 +5061,7 @@ pub const Pardes = struct { // rendering and nothing else. // A shell with a clock draws what `advance` stepped; one without // steps its own `.tick`s and draws while anything animates. - if (!p.needs_frame and (v.now != null or !p.animationActive())) return; + if (!p.needs_frame and !p.continuous() and (v.now != null or !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); @@ -6217,7 +6219,6 @@ pub const Pardes = struct { pub fn nextWake(p: *const Pardes) ?u64 { const next = p.stepped_ns + animation.frame_ns; if (p.chrome_animation.isActive() or p.presentation.animating()) return next; - for (p.lifts) |spring| if (!spring.settled) return next; var frames: ?u64 = null; if (p.look_hover_wait) |waiting| { const delay = config.look_preview_delay_frames orelse return next; @@ -6230,10 +6231,19 @@ pub const Pardes = struct { return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null; } + /// A render-time animation is moving (a pane's lift): it is sampled at + /// each frame's own `now`, so while it moves the shell draws every loop + /// and its present (vsync) paces it, at the display's rate. `nextWake` + /// is for the stepped animations, which count frames of `frame_ns`. + pub fn continuous(p: *const Pardes) bool { + for (p.lifts) |spring| if (!spring.settled) return true; + return false; + } + /// For shells that only need a yes or no: macOS and the board, which /// keep their own frame banks. pub fn animationActive(p: *const Pardes) bool { - return p.nextWake() != null; + return p.nextWake() != null or p.continuous(); } /// Bring core animation up to `now_ns`, the shell's monotonic clock: one -- cgit v1.3 From b8c5e8772e646fa21f6afdd2a875a1bd52848bd5 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 18:53:37 -0300 Subject: A column can be empty, as acme's can; new panes go where acme's makenewwindow puts them Newcol makes an empty column; closing a column's last pane leaves it empty with the keyboard on its tag; Delcol and Joincol alone take a column away; the session's last pane closing quits. The +New stand-in and replaceStillborn / stillborn_joiner are gone. Every new pane goes through exec.placeNew, which follows acme's makenewwindow (active column, empty column whole, blank space, halving the biggest) or, with Placement pardes, the old rules. ColumnTags is gone: column tags are always shown. A grip drag shows acme's box cursor. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/pardes-9p/SKILL.md | 9 +- docs/config.md | 8 +- docs/fs.md | 9 +- docs/helix-keys.md | 2 +- docs/tags.md | 68 +++++- docs/ui-review.md | 3 +- src/CHANGELOG.md | 16 ++ src/Mini.zig | 2 +- src/Output.zig | 10 +- src/builtins.zig | 25 +- src/config.zig | 24 +- src/draw.zig | 7 +- src/dump.zig | 17 +- src/exec.zig | 160 ++++++++----- src/fs-help.txt | 8 +- src/gui/gui.zig | 30 ++- src/layout.zig | 238 ++++++++++++++----- src/look.zig | 17 +- src/mouse.zig | 11 +- src/ninep/ctl.zig | 19 +- src/ninep/pane.zig | 4 +- src/ninep/tree.zig | 2 +- src/pardes.zig | 459 ++++++++++++++++++++++++------------- src/pdf_view.zig | 2 +- src/surface.zig | 4 +- src/tagline.zig | 1 + src/web/app.mjs | 3 +- test/appearance_gallery.py | 4 +- test/column_drag.zig | 5 - test/column_tags.py | 5 +- test/gui-goldens.txt | 13 +- test/gui_golden.py | 5 +- test/panes.zig | 1 - test/pdf.zig | 2 +- test/snapshots/argv.snap | 2 + test/snapshots/builtins.snap | 2 + test/snapshots/chordcut.snap | 2 + test/snapshots/cmdword.snap | 2 + test/snapshots/delask.snap | 2 + test/snapshots/doccol.snap | 2 + test/snapshots/emptycol.golden | 14 ++ test/snapshots/emptycol.snap | 22 ++ test/snapshots/filewatch.snap | 2 + test/snapshots/find.snap | 2 + test/snapshots/fsearch.snap | 2 + test/snapshots/grep.snap | 2 + test/snapshots/jumps.snap | 2 + test/snapshots/layout-close.golden | 5 +- test/snapshots/layout-close.snap | 8 +- test/snapshots/layout-open.snap | 2 + test/snapshots/leader.snap | 2 + test/snapshots/lookpanes.snap | 2 + test/snapshots/lookrange.snap | 2 + test/snapshots/lsp-client.snap | 2 + test/snapshots/lsp.snap | 2 + test/snapshots/lspcomplete.snap | 2 + test/snapshots/lsprelpath.snap | 2 + test/snapshots/mini.snap | 2 + test/snapshots/nested.snap | 2 + test/snapshots/psearch.snap | 2 + test/snapshots/reflow.snap | 2 + test/snapshots/rsearch.snap | 2 + test/snapshots/savepane.snap | 2 + test/snapshots/scrolloff.snap | 2 + test/snapshots/splitparent.snap | 2 + test/snapshots/stepgrain.snap | 2 + test/snapshots/tagalign.snap | 2 + test/snapshots/tagalign2col.snap | 2 + test/snapshots/tagbottom.snap | 2 + test/snapshots/tagnav.snap | 2 + test/snapshots/themesel.snap | 2 + test/snapshots/tinywin.snap | 2 + test/snapshots/topbarexec.snap | 2 + test/snapshots/ttyhelp.snap | 2 + test/snapshots/ttylook.snap | 2 + test/snapshots/ttyonly.snap | 2 + test/snapshots/ttytaken.snap | 2 + test/snapshots/windownav.snap | 2 + 78 files changed, 934 insertions(+), 388 deletions(-) create mode 100644 test/snapshots/emptycol.golden create mode 100644 test/snapshots/emptycol.snap diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 757ce64a..9473974c 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -68,7 +68,7 @@ $m/listeners this session's dial addresses $m/focus the serial of the pane with the keyboard (empty while a column/workspace tag has it); echo a serial into it to move the keyboard (a folded pane stays folded) $m/ctl the settings, one a line as a write takes them; write a setting or a session builtin - (Newcol, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the + (Newcol makes an empty column, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the commands pardes started (command panes, lines it typed into shells), a word matching a command line's first word; Exit and Restore refuse once, naming every unsaved pane, `, : Modified (Exit again to discard them all)`, and the same @@ -78,8 +78,11 @@ $m/ctl the settings, one a line as a write takes them; write a setting or a pane's builtins (Del, Save f, Collapse, which folds that pane, and the column word Delcol, which closes that pane's column) go to $m/pane//ctl $m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it) -$m/pane/new open it to make a pane (a scratch named /+New), read names it; - rmdir $m/pane/ closes it; closing a column's last pane leaves a +New there +$m/pane/new open it to make a pane (a scratch named /+New), read names it; it goes in + the ACTIVE column (the one last typed or clicked in, or Newcol's), filling it if + empty, else taking the bottom half of its last pane (ctl `Placement pardes`: the old rules); + rmdir $m/pane/ closes it; a column's last pane leaves the column EMPTY + (focus reads empty, the log says only del), and the session's LAST pane QUITS it $m/pane//errors write-only: text appended to the +Errors pane of the pane's directory $m/os/ the host filesystem ``` diff --git a/docs/config.md b/docs/config.md index 16fd59e5..77ed68e7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,9 +111,11 @@ themes and all imported names remain available. `FocusTint` toggles the focused pane and column tag tints; it is on by default. Workspace, column and pane command text can be edited directly; see [editable tags](tags.md) for naming and saved-workspace behavior. -`ColumnTags` toggles the column command row in both GUI and TTY; it is on by -default. Add `ColumnTags` to startup configuration to reclaim that row on a -compact screen. Hiding it preserves your custom column commands. +The column command row is always shown; an old `ColumnTags` init line is +ignored with a message. +`Placement acme` (the default) puts new panes where acme would, in the active +column; `Placement pardes` brings back pardes's own rules, which open a first +document in a column of its own. See [where new panes go](tags.md#where-new-panes-go). `Verbose` toggles the message-row announcement every builtin makes of its own 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 diff --git a/docs/fs.md b/docs/fs.md index 98a8638d..eaaedf02 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -69,8 +69,13 @@ for any process, and `$NINE_MOUNT/pardes/NAME/` a `--detach=NAME` session's. 9ns exports `$NINE_MOUNT` to everything it starts, so a script checks that variable to know the mount is there, and takes the name from `$PARDES_9P` (`pardes-9p-.sock`). A new pane made through `pane/new` is a scratch named -`/+New` until it is given a name, and closing a column's last pane -leaves such a `+New` in its place (`Delcol` closes the column). +`/+New` until it is given a name. A column may hold no pane, as in acme: +`Newcol` makes one empty, and closing a column's last pane leaves it empty +with its tag holding the keyboard (`focus` reads empty) and logs only the +`del`. `pane/new` places its pane as acme's makenewwindow(nil) does: in the +active column, filling it when it is empty, else taking the bottom half of its +last pane ([where new panes go](tags.md#where-new-panes-go)). `Delcol` closes the column. Closing the +session's last pane quits pardes; see [tags](tags.md#empty-columns). For [Linux v9fs](https://www.kernel.org/doc/html/latest/filesystems/9p.html), use `version=9p2000,cache=none,access=any` and `trans=unix`, or `trans=tcp` diff --git a/docs/helix-keys.md b/docs/helix-keys.md index c703a3da..8874bf08 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -127,7 +127,7 @@ language-backend queries, and the shell pipe. | `Enter` (normal) | acme **look** chord: EXPLICIT selection, else file-ish word under cursor | pardes-specific, keep (helix normal-mode Enter unbound). Covers helix `gf`. Implicit motion residue falls back to the cursor word | pardes-specific | | `Tab` (normal) | acme **execute** chord | pardes-specific, keep; explicit-selection rule as Enter | pardes-specific | | `:` (normal, body or tag) | in the body, focuses the pane's tag in normal mode at its remembered cursor (the first time, on `Save`); in the tag, goes back to the body; in a column or workspace tag, back to the active pane. The tag's normal and insert modes ARE the body's: every motion, selection, edit and undo works there, and `0` goes to the line's start, the path's. Tab runs the word under the cursor or the selection and Enter looks it up (in a column or workspace tag Enter runs it too), and either hands the keyboard back to the body first. Clicks choose a new cursor position and type into the tag. | Each tag keeps its own cursor during the session. The computed path/marker/page is reachable and yankable but read-only: an edit into it is refused, and typing into a file's path drafts a new name. File-name changes are staged as described in [editable tags](tags.md). | pardes-specific | -| `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag; with `ColumnTags` disabled it goes directly to the workspace. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific | +| `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific | | `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode it moves focus to the neighbour's BODY, as from a body, and from the top pane `Up` reaches the column and workspace tags | pardes-specific | | `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` (select next sibling), which pardes spells `Alt-right` alone | pardes-specific | | `Alt-c` | move active terminal to a fresh column (outside raw tty) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) | diff --git a/docs/tags.md b/docs/tags.md index fdea1472..7787d7f1 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -1,8 +1,9 @@ # Editable tags Pardes has three levels of command text: the workspace tag, one tag per -column, and each pane's tag. Column commands run in that column's active -pane (or its first pane when focus comes from another column). This makes +column, and each pane's tag. Column commands act on that column and run in +its active pane (or its first pane when focus comes from another column); a +column with no pane is described under [empty columns](#empty-columns). This makes `New`, `Tty`, `Find`, and `Grep` available beside the work they act on. `New` appears only in the column tag by default; pane tags keep their own save, terminal, close, and collapse commands. `Tty` opens a new embedded terminal. @@ -152,7 +153,7 @@ upgrade to `Mode`; customized command text is preserved. ## Saved workspaces `Dump` and `Restore` preserve customized workspace and column tags, including -intentionally empty tags. New columns start with the standard column tag. +intentionally empty tags, and columns that hold no pane. New columns start with the standard column tag. Closing a column keeps surviving columns' tags; `Joincol` keeps the destination column's tag. Old dumps without these optional fields retain the defaults. The automatic `Restore` shortcut does not overwrite a customized workspace @@ -163,7 +164,66 @@ than three rows it is omitted so a pane still has room. SDL and TTY share the same tag text, editing and layout; SDL additionally uses compact font sizing and subtle pixel separators. -`ColumnTags` toggles the column row (on by default) without deleting its text. +The column row is always shown. An old `ColumnTags` line in an init file is +ignored, with a message saying so. `FocusTint` controls active-column and active-pane emphasis. SDL also honors the shared bold, underline, and strikethrough attributes, including diagnostic underlines and the optional `SyntaxBold` keyword weight. + +## Empty columns + +A column can hold no pane, as acme's can: its tag stands over blank space, the +frame's own fill, where acme paints white (cols.c:186-188). `Newcol` makes an +empty column right of the keyboard's and gives its tag the keyboard. Closing a +column's last pane (`Del`, `Del k`/`Del j`, a shell exiting, a drag to another +column) leaves the column empty where it was, and the keyboard goes to its tag +if it was on that pane. Only `Delcol` and `Joincol` take a column away. + +`Delcol` and `Joincol` from a column's tag act on that column; `Delcol` +written to a pane's ctl closes that pane's column. A pane dragged onto an +empty column fills it. + +## Where new panes go + +Every new pane goes through one placement, chosen by the `Placement` setting: +`acme` (the default) or `pardes`. `Placement pardes` or `Placement acme` sets +it, in an init file, a tag or the root ctl; bare `Placement` flips it; `SPC c +p` is its leader path, and `Config` reports it. + +`Placement acme` is acme's makenewwindow (util.c:449-495). The core keeps +acme's *active column* (activecol, dat.c:37): the column last typed in +(acme.c:487), clicked in with the select button (acme.c:659), dropped into by +a grip (acme.c:640), whose tag was given the keyboard (`Newcol`, an emptied +column, `Ctrl-w k`), or that was given the last new pane (util.c:467). A Look +click moves the keyboard but not the active column, as button 3 does not in +acme. A new pane goes into the column a command's tag belongs to when it came +from a column tag, else the active column, else the keyboard's pane's, and +never into a new column: + +- an empty column it takes whole (util.c:468-469); +- from a tag, or 9P's `pane/new` (acme's `t->w == nil`), it takes the bottom + half of the column's last pane (coladd, cols.c:62-65); +- from a pane's text (a Look, `Tty`, `Alt-n`, a Grep or Find listing), it goes + right under the text of the pane with the most blank rows when that is more + than 15 rows, or more than 3 and more than half the biggest pane + (util.c:482-486); otherwise it halves the biggest pane, or the asking pane + when that is in the column and not much smaller (util.c:487-491); +- `New` goes into its own column, the bottom half of its last pane + (look.c:921-923); +- a command pane or a `+Errors` pane goes to the last column, the bottom + half of its last pane (util.c:94-98). + +`Placement pardes` is what pardes did before: an empty column whose tag asked, +or has the keyboard, is filled; a scratch goes right under the pane that asked; +a shell under it or the nearest pane with room; a document beside the last one +read, or in a column of its own on the left when there is none and the column +is at least 200 cells wide; a command pane at the foot of the last column. + +Down from an empty column's tag stays there, and the pane-to-pane keys pass +over an empty column; Left and Right from a tag walk every column's tag. + +One divergence from acme: acme keeps running when its last window closes, +every column empty. Pardes quits when the session's last pane closes, as +`Delcol` of the last column always has. A key, a prompt or a command in +pardes runs in a pane, so a session without one would have nothing to run +them in. diff --git a/docs/ui-review.md b/docs/ui-review.md index 58a42e61..1180a20e 100644 --- a/docs/ui-review.md +++ b/docs/ui-review.md @@ -98,8 +98,7 @@ tests and 797 SDL tests; SDL image/PDF and Kitty PDF rendering harnesses pass. ## Column and editable-tag follow-up The follow-up adds editable workspace and column command rows, compact pane -tags, staged buffer-name edits, caret reveal for long tags, and `ColumnTags` -to reclaim the extra row when needed. See [editable tags](tags.md) for the +tags, staged buffer-name edits, and caret reveal for long tags. See [editable tags](tags.md) for the exact interaction and save-target rules. `test/column_tags.py` exercises isolated SDL and TTY sessions against the same diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md index f8451baf..874082d8 100644 --- a/src/CHANGELOG.md +++ b/src/CHANGELOG.md @@ -2,6 +2,22 @@ ## 0.0.3 +- A column can be empty, as in acme. `Newcol` makes an empty column and + gives its tag the keyboard; closing a column's last pane, or dragging it + away, leaves the column where it was with nothing in it, drawn as its tag + over blank space. The `+New` placeholder that used to stand in an emptied + column, and the machinery that swapped it out when something else arrived, + are gone. Closing the session's last pane now quits. +- New panes go where acme's makenewwindow puts them: into the active column + (the one last typed or clicked in), filling it when it is empty, else under + the text of a pane with room to spare, else halving the biggest pane; never + into a new column. `Placement pardes` brings back the old rules, which open a + first document in a column of its own. +- Column tags are always shown: the `ColumnTags` setting is gone, and an + init file's `ColumnTags` line is ignored with a message. +- Carrying a pane or a column by its grip shows acme's box cursor in the SDL + shell and a move cursor on the web. + - `Del` takes a side. `Del k` and `DelAbove` give the closed pane's rows to the expanded pane above it, `Del j` and `DelBelow` to the one below. A bare `Del` from the keyboard on a pane with panes both above and below asks on diff --git a/src/Mini.zig b/src/Mini.zig index e118e738..ac9a8e85 100644 --- a/src/Mini.zig +++ b/src/Mini.zig @@ -188,7 +188,7 @@ pub fn open(p: *Pardes, id: usize, argument: []const u8) !void { .syntax_dirty = false, }; pane.body.cur_pinned = true; - exec.placeDoc(p, id, free, pane); + exec.placeNew(p, id, id, free, .doc); p.active = free; } diff --git a/src/Output.zig b/src/Output.zig index 201b5491..2f462fd1 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -479,7 +479,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: [] const free = p.freeSlot() orelse return error.NoPaneSlots; const np = try open(p, free, dir, from, arg, result_content); np.file.?.location_rows = result_rows; - exec.placeDoc(p, id, free, np); + exec.placeNew(p, id, id, free, .doc); p.active = id; if (traits(from).steps) { pane.search_pane = free; @@ -558,8 +558,8 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void { const pane = p.panes[id] orelse return error.MissingPane; const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, "", content); - exec.placeDoc(p, id, free, np); + _ = try open(p, free, dir, from, "", content); + exec.placeNew(p, id, id, free, .doc); p.active = id; pane.search_pane = free; pane.search_row = null; @@ -777,7 +777,7 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8) } const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); const free = p.freeSlot() orelse return error.NoPaneSlots; - const np = try open(p, free, dir, from, arg, content); - exec.placeDoc(p, id, free, np); + _ = try open(p, free, dir, from, arg, content); + exec.placeNew(p, id, id, free, if (from == .errors) .errors else .doc); p.active = free; } diff --git a/src/builtins.zig b/src/builtins.zig index 72df0271..c2b4d62f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -169,7 +169,7 @@ pub const registry = struct { inline for (comptime settingList(), manualBuiltinCount()..) |setting, i| if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default - .toggle, .transition, .scene, .dump_dir => false, + .toggle, .transition, .scene, .dump_dir, .placement => false, .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true, }; unreachable; @@ -818,11 +818,11 @@ pub const New = struct { } }; -/// The same empty scratch, opened in a fresh column beside the calling pane. +/// An empty column right of the keyboard's, its tag taking the keyboard. pub const Newcol = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { - c.p.newScratchColumn(c.id); + c.p.newColumn(c.id); } }; @@ -937,7 +937,10 @@ pub const Collapse = struct { pub const Delcol = struct { pub fn run(c: Ctx) void { - c.p.removeColumn(c.id) catch |err| c.p.reportError(c.id, "close column", err); + // The column of the tag it came from, else the pane's: a pane ctl's + // Delcol closes that pane's column wherever the keyboard is. + const col = c.p.exec_column orelse (layout.findPane(c.p, c.id) orelse return).col; + c.p.removeColumn(col) catch |err| c.p.reportError(c.id, "close column", err); } }; @@ -1049,9 +1052,9 @@ pub const Joincol = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { // The keyboard's column joins the one to its right; the last has none. - const at = layout.findPane(c.p, c.p.active) orelse return; - if (at.col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right"); - layout.joinCol(c.p); + const col = c.p.keyboardColumn(c.p.active) orelse return; + if (col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right"); + layout.joinCol(c.p, col); } }; @@ -1059,8 +1062,8 @@ pub const Tutor = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { const free = c.p.freeSlot() orelse return; - const nt = c.p.openTutorView(free) catch return; - exec.placeDoc(c.p, c.id, free, nt); // a doc like any other + _ = c.p.openTutorView(free) catch return; + exec.placeNew(c.p, c.id, c.id, free, .doc); // a doc like any other } }; @@ -1221,8 +1224,10 @@ pub const Down = struct { return tagline.enterHeader(c.p, if (column) |at| at.col else 0); // Onto the top pane of the column the header stands over. const col = c.p.header_column orelse if (column) |at| at.col else return; + // An empty column's tag has nothing below it. + if (col < c.p.ncol and c.p.col_n[col] == 0) return; tagline.exitHeader(c.p); - if (col < c.p.ncol and c.p.col_n[col] > 0) c.p.active = c.p.col_panes[col][0]; + if (col < c.p.ncol) c.p.active = c.p.col_panes[col][0]; return; } _ = layout.focusDir(c.p, c.id, .down); diff --git a/src/config.zig b/src/config.zig index 2eaaa505..48f49d50 100644 --- a/src/config.zig +++ b/src/config.zig @@ -73,11 +73,11 @@ pub const leader_path = paths: { .Newcol = "cn", .Delcol = "cd", .Joincol = "cj", + .Placement = "cp", .Debug = "td", .Msg = null, .Colors = "tc", .FocusTint = null, - .ColumnTags = null, .SyntaxBold = null, .Verbose = null, .TreeContextTagStyle = null, @@ -616,7 +616,6 @@ pub const Runtime = struct { /// menu bar) turn it off and carry its commands there instead; the core /// keeps the row as the acme tradition everywhere else. workspace_tag: bool = true, - column_tags: bool = true, syntax_bold: bool = false, /// A font's programming ligatures (`->`, `!=`) draw across their cells. /// Only a shell that shapes text has the setting (`Capabilities.ligatures`). @@ -664,8 +663,12 @@ pub const Runtime = struct { /// never, while the window has the focus, or always (ghostty's /// custom-shader-animation). shader_animation: ShaderAnimation = .on, + /// Where a new pane goes (exec.placeNew): `acme`, acme's makenewwindow, + /// or `pardes`, the rules pardes had before it. + placement: Placement = .acme, pub const ShaderAnimation = enum { off, on, always }; + pub const Placement = enum { acme, pardes }; /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { @@ -818,7 +821,7 @@ pub const Runtime = struct { /// 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, ligatures }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures }; /// The bundled post pass, a Shadertoy file in shaders/post/. pub const Scene = enum { crt }; @@ -838,6 +841,7 @@ pub const Runtime = struct { /// it of files. shader, shader_animation, + placement, }; pub const Setting = struct { @@ -861,7 +865,6 @@ pub const Runtime = struct { .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, .{ .word = "Debug", .action = .{ .toggle = .debug } }, .{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } }, - .{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } }, .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } }, .{ .word = "Verbose", .action = .{ .toggle = .verbose } }, .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, @@ -890,6 +893,7 @@ pub const Runtime = struct { .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, + .{ .word = "Placement", .action = .placement }, }; pub fn find(name: []const u8) ?Setting { @@ -1048,6 +1052,14 @@ pub const Runtime = struct { const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false; }, + // Bare, it flips between the two, as a tag word should. + .placement => { + const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); + state.placement = if (text.len == 0) + (if (state.placement == .acme) .pardes else .acme) + else + std.meta.stringToEnum(Placement, text) orelse return false; + }, .theme, .font => return false, } return true; @@ -1217,6 +1229,7 @@ pub const Runtime = struct { try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) }) else try out.print("{s} unsupported\n", .{setting.word}), + .placement => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.placement) }), }; if (context.startup_config_path) |path| @@ -1468,6 +1481,7 @@ pub const Runtime = struct { .dump_dir => "~/dumps", .shader => "~/crt.glsl", .shader_animation => "always", + .placement => "pardes", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1483,7 +1497,6 @@ pub const Runtime = struct { .tag_bottom => "Tagbottom on\n", .debug => "Debug on\n", .focus_tint => "FocusTint off\n", - .column_tags => "ColumnTags off\n", .syntax_bold => "SyntaxBold on\n", .verbose => "Verbose off\n", .message_animation => "MessageAnimation off\n", @@ -1518,6 +1531,7 @@ pub const Runtime = struct { }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", + .placement => "Placement pardes\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/draw.zig b/src/draw.zig index 2d757635..b12ab52c 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -163,6 +163,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // the pointer per-text (the macOS I-beam) read this as "target" and // afford it the arrow instead. if (p.look_hover_preview != null) s.pointer_shape = .target; + if (p.drag == .move or p.drag == .column_move) s.pointer_shape = .box; for (&s.body_layers) |*layer| layer.rows = 0; for (&s.tag_layers) |*layer| layer.rows = 0; s.nimages = 0; @@ -974,14 +975,14 @@ test "place puts every part where the painters and layers put it, in every layou const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); _ = try p.setTestFile("placed\n" ** 80); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.newScratchBelow(0); p.sync(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); for (0..16) |step| { p.settings.tag_bottom = step & 1 != 0; - p.settings.column_tags = step & 2 != 0; if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice")); if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse")); if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse")); @@ -1121,7 +1122,7 @@ test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics nev var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " }; - const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" }; + const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" }; var text: std.ArrayListUnmanaged(u8) = .empty; defer text.deinit(std.testing.allocator); for (0..steps) |_| { diff --git a/src/dump.zig b/src/dump.zig index 954a3270..8e669b77 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -217,7 +217,8 @@ pub fn validate(state: State) !void { } for (state.columns) |col| { if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns; - if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns; + // An empty column is a column, as in acme's dump (rows.c:639). + if (col.panes.len > max_panes) return error.BadDumpColumns; for (col.panes) |pane| { if (pane >= state.panes.len) return error.BadDumpPaneRef; } @@ -638,14 +639,12 @@ pub fn dumpState(p: *Pardes) !void { column_ids[c][ids_len] = compact; ids_len += 1; }; - if (ids_len > 0) { - columns[columns_len] = .{ - .tag = p.col_tags[c].own, - .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), - .panes = column_ids[c][0..ids_len], - }; - columns_len += 1; - } + columns[columns_len] = .{ + .tag = p.col_tags[c].own, + .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), + .panes = column_ids[c][0..ids_len], + }; + columns_len += 1; } var mounts: [filesystem.max_mounts]Mount = undefined; diff --git a/src/exec.zig b/src/exec.zig index ee7c31e9..117970d9 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -322,12 +322,7 @@ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane { nt.greet = !v9fs; nt.v9fs_on_spawn = v9fs; nt.body.mode = .tty; - const parent = layout.splitParent(p, from); - const f = layout.findPane(p, parent).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, parent, nt); - p.active = free; - p.stillborn_joiner = nt.serial; + placeNew(p, from, from, free, .shell); return nt; } @@ -357,29 +352,6 @@ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void { if (p.active == tty_id) p.active = keep_id; } -/// A pane that just joined a column whose only other pane is stillborn — -/// an empty scratch nobody typed into, body or tag (what Newcol makes and -/// what a closed column's last pane leaves behind) — takes the column -/// whole: the placeholder was only holding the space for it. Run at the -/// end of a step: the pane that asked for the joiner is often the -/// placeholder itself, and its caller is still holding it. -pub fn replaceStillborn(p: *Pardes) void { - const serial = p.stillborn_joiner orelse return; - p.stillborn_joiner = null; - const new_id = p.paneBySerial(serial) orelse return; - const f = layout.findPane(p, new_id) orelse return; - if (p.col_n[f.col] != 2) return; - const old_id = p.col_panes[f.col][1 - f.idx]; - const old = p.panes[old_id] orelse return; - const file = old.file orelse return; - const out = file.output orelse return; - if (out.from != .cmd or out.from.cmd != .New) return; - if (file.revision != 0 or file.content.len != 0) return; - if (old.tag.own) |own| if (!std.mem.eql(u8, own, tagline.defaultTail(old))) return; - layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows - p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err); -} - // ---- REPLs ---- // // A terminal is bound as a language's REPL by `Repl python` (builtins.zig). @@ -552,7 +524,7 @@ pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usi /// Runs `line` in a command pane for `from`'s directory: `from` itself if /// it is one whose command is done, else the directory's command pane that -/// is done, else a new one below the last pane of the rightmost column. +/// is done, else a new one, placed as placeNew places a command pane. /// A pane reused keeps what it showed: the new command runs below a line /// naming it, as acme appends to +Errors and never clears it (util.c:213). fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { @@ -607,13 +579,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize { return null; }; echoCommand(p, pane, line); - const rc = if (p.ncol > 0) p.ncol - 1 else 0; - if (p.col_n[rc] > 0) { - const parent = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]); - const f = layout.findPane(p, parent).?; - layout.splitBelow(p, parent, pane); - layout.insert(p, f.col, f.idx + 1, free); - } else layout.insert(p, rc, p.col_n[rc], free); + placeNew(p, from, from, free, .command); noteRun(p, pane, "run", line); return free; } @@ -691,7 +657,24 @@ pub fn applyStartupConfig(p: *Pardes) void { p.announce = false; defer p.announce = true; var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n'); - while (lines.next()) |line| _ = executeBuiltinLine(p, p.active, line); + while (lines.next()) |line| { + if (executeBuiltinLine(p, p.active, line)) continue; + // A setting pardes no longer has is said, once, and changes nothing. + const word = commandText(line); + if (std.mem.eql(u8, word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len], "ColumnTags")) + p.setMessage(p.active, "ColumnTags: column tags are always shown now; the init line is ignored"); + } +} + +test "an init file's retired ColumnTags line is said and ignored" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + p.opts.startup_config = "ColumnTags\nWrap off\n"; + applyStartupConfig(p); + try std.testing.expect(!p.settings.wrap); + try std.testing.expect(p.columnBarHeight() > 0); + const pane = p.panes[p.active].?; + try std.testing.expectEqualStrings("ColumnTags: column tags are always shown now; the init line is ignored", pane.msg[0..pane.msg_len]); } pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { @@ -718,7 +701,6 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] const previous_context_style = p.settings.tree_context_tag_style; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; - const previous_column_tags = p.settings.column_tags; switch (setting.action) { .theme => { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); @@ -768,20 +750,98 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] p.settings.panel_transition != previous_transition; const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; - if (p.settings.column_tags != previous_column_tags) { - if (p.header_column != null) tagline.exitHeader(p); - } // Idle frames keep no frozen grid while transitions are off; the one // just drawn is the baseline the first transition starts from. if (transition_changed and previous_transition == .off) p.presentation.capturePrevious(p.gpa, &p.surface); - if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { + if (transition_changed or tagline_metrics_changed) { p.abandonPanelAnimations(); } } -pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { - p.stillborn_joiner = nt.serial; +/// What a new pane is, and who asked: each rule places them differently. +/// `scratch` is New's, `fs` 9P's pane/new, `errors` a +Errors pane. +pub const NewPane = enum { scratch, fs, shell, doc, command, errors }; + +/// Put new pane `id` in the layout: every new pane comes through here, by +/// the `Placement` setting, and every one but a command pane takes the +/// keyboard. `from` is the pane that asked, `origin` the text it asked +/// from: null when a column tag, the workspace tag or 9P asked (acme's +/// t->w == nil). +pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void { + const asked = if (p.exec_column != null) null else origin; + switch (p.settings.placement) { + .acme => switch (kind) { + // New: the bottom half of the last pane in its own column + // (look.c:921-923, et->col). + .scratch => layout.acmePlace(p, p.exec_column orelse columnOf(p, from), null, id), + // +Errors, a command's output: the last column (util.c:94-98). + .command, .errors => layout.acmePlace(p, p.ncol - 1, null, id), + // makenewwindow(nil) for 9P's new (acme.c:877), else (t). + .fs => layout.acmePlace(p, acmeColumn(p, from), null, id), + .shell, .doc => layout.acmePlace(p, acmeColumn(p, from), asked, id), + }, + .pardes => pardesPlace(p, from, id, kind), + } + if (kind == .command) return; + tagline.exitHeader(p); + p.active = id; + if (layout.findPane(p, id)) |f| p.setActiveColumn(f.col); +} + +/// acme's makenewwindow column (util.c:456-467): the column a command came +/// from (its tag's), else the active column, else the keyboard's pane's, +/// else the asking pane's. +fn acmeColumn(p: *Pardes, from: usize) usize { + if (p.exec_column) |c| return c; + if (p.activeColumn()) |c| return c; + if (layout.findPane(p, p.active)) |f| return f.col; + return columnOf(p, from); +} + +fn columnOf(p: *Pardes, id: usize) usize { + return if (layout.findPane(p, id)) |f| f.col else p.ncol - 1; +} + +/// The placements pardes had before it took acme's: an empty column whose +/// tag asked, or has the keyboard, is filled; a scratch goes under the +/// asking pane, a shell under it or the nearest pane with room, a document +/// beside the last one read or in a column of its own, and a command pane +/// at the foot of the last column. +fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void { + const empty = if (p.exec_column orelse if (p.header_focus) p.header_column else null) |c| + (if (c < p.ncol and p.col_n[c] == 0) c else null) + else + null; + if (empty) |c| { + layout.insert(p, c, 0, id); + p.panes[id].?.vweight = 1; + return; + } + const nt = p.panes[id].?; + switch (kind) { + .scratch, .fs => if (layout.findPane(p, from)) |sf| { + layout.insert(p, sf.col, sf.idx + 1, id); + layout.splitBelow(p, from, nt); + }, + .shell => belowParent(p, from, id), + .doc, .errors => pardesDoc(p, from, id), + .command => { + const rc = p.ncol - 1; + if (p.col_n[rc] > 0) belowParent(p, p.col_panes[rc][p.col_n[rc] - 1], id) else layout.insert(p, rc, 0, id); + }, + } +} + +fn belowParent(p: *Pardes, from: usize, id: usize) void { + const parent = layout.splitParent(p, from); + const f = layout.findPane(p, parent).?; + layout.insert(p, f.col, f.idx + 1, id); + layout.splitBelow(p, parent, p.panes[id].?); +} + +fn pardesDoc(p: *Pardes, from_id: usize, free: usize) void { + const nt = p.panes[free].?; const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc var src_id: ?usize = null; if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) { @@ -808,22 +868,14 @@ pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void { if (!doc) if (layout.findPane(p, from_id)) |sf| { layout.insert(p, sf.col, sf.idx + 1, free); layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays - p.active = free; return; }; if (src_id == null and doc and p.ncol < MAX_COLS and layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true)) - { - p.active = free; return; - } // Without room for a first-document column, split the Look origin // just like Tty. Never displace (or evict) a top-left bystander. - const src = layout.splitParent(p, src_id orelse from_id); - const sf = layout.findPane(p, src).?; - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; + belowParent(p, src_id orelse from_id, free); } test "the acme chords act once per selection, not once on the primary" { diff --git a/src/fs-help.txt b/src/fs-help.txt index 2b42be1f..0121a831 100644 --- a/src/fs-help.txt +++ b/src/fs-help.txt @@ -8,10 +8,10 @@ exec write a line: a middle click, an editor command word or a shell line log events: new/del/rename/save , run/exit/send ..., dump/restore , msg, err screen rendered screen as JSON, frozen from open to close listeners the session's dial addresses -focus the serial of the pane with the keyboard; write a serial to give it the keyboard -ctl settings, one a line; or Newcol, Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word) +focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard +ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word) commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it -pane/new open it to make a pane (named +New); the read answers that pane's serial +pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial pane// name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only: appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes os/, src/ the host filesystem; the editor's own sources (-Dembed-sources=true builds only) @@ -41,5 +41,5 @@ Pitfalls, one each: A terminal's body is a history snapshot frozen per open; pty/data is the live stream. exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in. Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why. - lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane -> +New. + lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane leaves it empty; the last pane of all quits. Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands. diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 84ec1c5c..26e9e24d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -73,6 +73,24 @@ const p9_arrow_set = [32]u8{ 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, }; +/// acme's boxcursor (acme.c:956), while a grip carries a pane or column. +const p9_box_clr = [32]u8{ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, + 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; +const p9_box_set = [32]u8{ + 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, + 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, + 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, + 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, 0x00, 0x00, +}; +const p9_box_mask = blk: { + var m: [32]u8 = undefined; + for (&m, p9_box_set, p9_box_clr) |*b, s, cl| b.* = s | cl; + break :blk m; +}; const p9_arrow_mask = blk: { var m: [32]u8 = undefined; for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; @@ -1847,6 +1865,7 @@ const Gui = struct { pointer_present: bool = false, arrow_cursor: ?*c.SDL_Cursor = null, link_cursor: ?*c.SDL_Cursor = null, + box_cursor: ?*c.SDL_Cursor = null, pointer_shape: pardes.Surface.PointerShape = .arrow, pointer_mapped: bool = false, pointer_cell: ?MouseCell = null, @@ -2292,6 +2311,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor); const link_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); defer if (link_cursor) |cursor| c.SDL_DestroyCursor(cursor); + // Its hot spot the middle, as acme's {-7, -7} offset puts it. + const box_cursor = c.SDL_CreateCursor(&p9_box_set, &p9_box_mask, 16, 16, 7, 7); + defer if (box_cursor) |cursor| c.SDL_DestroyCursor(cursor); if (arrow_cursor) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); @@ -2427,6 +2449,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .window = window, .arrow_cursor = arrow_cursor, .link_cursor = link_cursor, + .box_cursor = box_cursor, .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, @@ -5270,7 +5293,11 @@ fn renderFrame( ) !bool { if (surface.pointer_shape != g.pointer_shape) { g.pointer_shape = surface.pointer_shape; - const cursor = if (g.pointer_shape == .link) g.link_cursor else g.arrow_cursor orelse c.SDL_GetDefaultCursor(); + const cursor = switch (g.pointer_shape) { + .link => g.link_cursor, + .box => g.box_cursor orelse g.arrow_cursor, + else => g.arrow_cursor orelse c.SDL_GetDefaultCursor(), + }; if (cursor) |value| _ = c.SDL_SetCursor(value); } g.scene_target_failed = false; @@ -5810,7 +5837,6 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand var notices = false; for (0..32) |step| { core.settings.tag_bottom = step & 1 != 0; - core.settings.column_tags = step & 2 != 0; if (step & 3 == 0) try std.testing.expect(core.executeBuiltinLine(2, "Collapse")); core.leader_on = step & 4 != 0; if (step & 8 != 0) { diff --git a/src/layout.zig b/src/layout.zig index f0e6fd16..c58f3bcf 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -19,7 +19,9 @@ pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; pub const MovePlacement = struct { preview_col: usize, - above_id: usize, + /// The pane the moved one lands below; null when the column is empty + /// and the moved pane takes it whole. + above_id: ?usize, row: u16, above_y: u16, above_h: u16, @@ -60,6 +62,16 @@ pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) bo return true; } +/// Column `c`'s serial, handed out the first time it is asked for. +pub fn columnSerial(p: *Pardes, c: usize) u32 { + if (p.col_serial[c] == 0) { + p.col_serial[c] = p.next_column_serial; + p.next_column_serial +%= 1; + if (p.next_column_serial == 0) p.next_column_serial = 1; + } + return p.col_serial[c]; +} + pub fn columnBySerial(p: *const Pardes, serial: u32) ?usize { if (serial == 0) return null; for (0..p.ncol) |column| if (p.col_serial[column] == serial) return column; @@ -178,6 +190,7 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme heights[sib] +|= p.rects[id].h; } var y: u16 = p.topBarHeight() + p.columnBarHeight(); + if (p.col_n[tc] == 0) return .{ .preview_col = tc, .above_id = null, .row = y, .above_y = y, .above_h = 0 }; var last: ?MovePlacement = null; for (0..p.col_n[tc]) |k| { const pid = p.col_panes[tc][k]; @@ -213,18 +226,23 @@ pub fn movePane(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void { } } removePane(p, id); - if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) { + if (source_multi) { const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx]; heights[sib] +|= p.rects[id].h; } - const af = findPane(p, placement.above_id) orelse return; + const above = placement.above_id orelse { + insert(p, placement.preview_col, 0, id); + if (source_multi) setColumnWeights(p, src.col, &heights); + return; + }; + const af = findPane(p, above) orelse return; const upper_h = @max(1, placement.row -| placement.above_y); const lower_h = @max(1, placement.above_h -| upper_h); - heights[placement.above_id] = upper_h; + heights[above] = upper_h; heights[id] = lower_h; insert(p, af.col, af.idx + 1, id); setColumnWeights(p, af.col, &heights); - if (source_multi and src.col < p.ncol and src.col != af.col) setColumnWeights(p, src.col, &heights); + if (source_multi and src.col != af.col) setColumnWeights(p, src.col, &heights); } pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void { @@ -278,39 +296,23 @@ pub fn insert(p: *Pardes, c: usize, idx: usize, id: usize) void { p.col_n[c] += 1; } +/// Take a pane out of its column. The column stays, empty if this was its +/// last pane, as acme's colclose leaves it (cols.c:186): only Delcol and +/// Joincol take a column away. pub fn removePane(p: *Pardes, id: usize) void { const f = findPane(p, id) orelse return; const c = f.col; var k = f.idx; while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1]; p.col_n[c] -= 1; - if (p.col_n[c] == 0) { - tagline.exitHeader(p); - if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; - p.col_tags[c].deinit(p.gpa); - var j = c; - while (j + 1 < p.ncol) : (j += 1) { - p.col_panes[j] = p.col_panes[j + 1]; - p.col_n[j] = p.col_n[j + 1]; - p.col_weight[j] = p.col_weight[j + 1]; - p.col_tags[j] = p.col_tags[j + 1]; - p.col_serial[j] = p.col_serial[j + 1]; - } - p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; - p.col_serial[p.ncol] = 0; - } } -pub fn joinCol(p: *Pardes) void { - const f = findPane(p, p.active) orelse return; - const c = f.col; - if (c + 1 >= p.ncol) return; +/// Close column `c` once its panes are gone, its width to the one left of +/// it (the first column's to its right), as acme's rowclose gives it. +pub fn dropColumn(p: *Pardes, c: usize) void { + std.debug.assert(p.col_n[c] == 0); tagline.exitHeader(p); - const dst = c + 1; - p.col_weight[dst] +|= p.col_weight[c]; - for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; - p.col_n[dst] += p.col_n[c]; + if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; p.col_tags[c].deinit(p.gpa); var j = c; while (j + 1 < p.ncol) : (j += 1) { @@ -325,42 +327,145 @@ pub fn joinCol(p: *Pardes) void { p.col_serial[p.ncol] = 0; } +/// Fold column `c` into the one on its right, its panes below theirs. +pub fn joinCol(p: *Pardes, c: usize) void { + if (c + 1 >= p.ncol) return; + const dst = c + 1; + for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; + p.col_n[dst] += p.col_n[c]; + p.col_n[c] = 0; + // The width goes right, where the panes went. + p.col_weight[dst] +|= p.col_weight[c]; + p.col_weight[c] = 0; + dropColumn(p, c); +} + +/// Put new pane `id` in column `c` as acme's makenewwindow does +/// (util.c:449-495): an empty column it takes whole; asked from a tag or +/// from 9P (`origin` null, acme's t->w == nil) it takes the bottom half of +/// the column's last pane (coladd with y < 0, cols.c:62-65). Asked from a +/// pane's text, it goes under the text of the pane with the most blank rows +/// when there are many, else it halves the biggest pane, or `origin` when +/// that is in the column and not much smaller. +pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void { + compute(p); + const n = p.col_n[c]; + if (n == 0) { + insert(p, c, 0, id); + p.panes[id].?.vweight = 1; + return; + } + var big: ?usize = null; + var empty: ?usize = null; + for (p.col_panes[c][0..n]) |k| { + const pane = p.panes[k] orelse continue; + if (pane.collapsed) continue; + // `>=`: at a tie the one nearer the bottom, as acme picks. + if (big == null or bodyRows(p, k) >= bodyRows(p, big.?)) big = k; + if (empty == null or blankRows(p, k) >= blankRows(p, empty.?)) empty = k; + } + const v0 = big orelse { + // Every pane folded: the new one goes last. + insert(p, c, n, id); + return; + }; + const y: u16 = y: { + const o = origin orelse { + var last = v0; + for (p.col_panes[c][0..n]) |k| if (!p.panes[k].?.collapsed) { + last = k; + }; + break :y bodyTop(p, last) + bodyRows(p, last) / 2; + }; + const e = empty.?; + const el = blankRows(p, e); + if (el > 15 or (el > 3 and el > (bodyRows(p, v0) -| 1) / 2)) + break :y bodyTop(p, e) + bodyRows(p, e) - el; + const mine = if (findPane(p, o)) |f| f.col == c and !p.panes[o].?.collapsed else false; + const v = if (mine and @as(u32, p.rects[o].h) * 3 > @as(u32, p.rects[v0].h) * 2) o else v0; + break :y p.rects[v].y + p.rects[v].h / 2; + }; + splitAt(p, c, id, y); +} + +/// acme's coladd at row `y` (cols.c:66-126): the new pane goes under the +/// pane that `y` falls in, which keeps its rows above `y`. +fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void { + const n = p.col_n[c]; + var k: usize = 0; + while (k + 1 < n and y >= p.rects[p.col_panes[c][k]].y + p.rects[p.col_panes[c][k]].h) k += 1; + const v = p.col_panes[c][k]; + const r = p.rects[v]; + snapColWeights(p, c); + insert(p, c, k + 1, id); + const pane = p.panes[id].?; + // Each keeps a tag and a body row, as acme's minht does. + const lo = r.y +| BOX_H + 1; + const hi = (r.y + r.h) -| (BOX_H + 1); + const row = splitRowForExtent(r.y, r.h, if (lo <= hi) std.math.clamp(y, lo, hi) else r.y + r.h / 2) orelse { + pane.vweight = BOX_H + 1; + return; + }; + // `y` fell in an unfolded pane: acmePlace picks from those. + p.panes[v].?.vweight = @floatFromInt(@max(1, row - r.y)); + pane.vweight = @floatFromInt(@max(1, (r.y + r.h) - row)); +} + +fn bodyTop(p: *const Pardes, id: usize) u16 { + return p.rects[id].y + p.panes[id].?.tag_rows; +} + +fn bodyRows(p: *const Pardes, id: usize) u16 { + return p.rects[id].h -| p.panes[id].?.tag_rows; +} + +/// The rows at the bottom of a pane's body that show nothing: acme's +/// maxlines - nlines. A picture or a PDF page fills its body. +fn blankRows(p: *const Pardes, id: usize) u16 { + const pane = p.panes[id].?; + const rows = bodyRows(p, id); + const used: usize = if (pane.file) |*f| + // The empty line after a final newline shows nothing. + (panes.File.nlines(p.gpa, f) -| @intFromBool(std.mem.endsWith(u8, f.content, "\n"))) -| f.scroll + else if (pane.isTerminal()) + @as(usize, panes.Terminal.gridCursor(pane).y) + 1 + else + rows; + return rows -| @as(u16, @intCast(@min(used, rows))); +} + pub fn canSplitColumn(p: *Pardes, source_id: usize) bool { - if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false; + if (source_id >= MAX_PANES or p.panes[source_id] == null) return false; const source = findPane(p, source_id) orelse return false; + return canSplitCol(p, source.col); +} + +fn canSplitCol(p: *Pardes, col: usize) bool { + if (p.ncol >= MAX_COLS or col >= p.ncol) return false; // Refresh derived widths: public layout surgery may be chained between // syncs, and a cached width must never admit a now-too-narrow split. compute(p); - if (p.col_w[source.col] < config.MINW * 2) return false; + if (p.col_w[col] < config.MINW * 2) return false; - const weight = p.col_weight[source.col]; + const weight = p.col_weight[col]; if (weight >= 2 and weight % 2 == 0) return true; for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2) return false; return weight > 0; } -pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { - if (id >= MAX_PANES or p.panes[id] == null) return false; - if (!canSplitColumn(p, source_id)) return false; - const source = findPane(p, source_id) orelse return false; - const source_col = source.col; - var old_weight = p.col_weight[source_col]; - const needs_rebase = old_weight < 2 or old_weight % 2 != 0; - if (needs_rebase) old_weight *= 2; - if (id == source_id) { - if (p.col_n[source_col] <= 1) return false; - _ = absorbVWeight(p, id, null); - removePane(p, id); - } else if (findPane(p, id) != null) return false; - - if (needs_rebase) { +/// An empty column beside column `col`, with half its width: acme's Newcol +/// (rowadd, rows.c:51). The new column's index, or null with no room. +pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize { + if (!canSplitCol(p, col)) return null; + var old_weight = p.col_weight[col]; + if (old_weight < 2 or old_weight % 2 != 0) { + old_weight *= 2; for (0..p.ncol) |column| p.col_weight[column] *= 2; } const source_weight = old_weight / 2; - const new_weight = old_weight - source_weight; - p.col_weight[source_col] = source_weight; - const c = source_col + @intFromBool(!before); + p.col_weight[col] = source_weight; + const c = col + @intFromBool(!before); tagline.exitHeader(p); var j = p.ncol; while (j > c) : (j -= 1) { @@ -370,12 +475,27 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_tags[j] = p.col_tags[j - 1]; p.col_serial[j] = p.col_serial[j - 1]; } - p.col_weight[c] = new_weight; + p.col_weight[c] = old_weight - source_weight; p.col_tags[c] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[c] = 0; - p.col_panes[c][0] = id; - p.col_n[c] = 1; + p.col_n[c] = 0; p.ncol += 1; + return c; +} + +/// Pane `id` alone in a new column beside `source_id`'s; `id` may be the +/// source itself when its column keeps another pane. +pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { + if (id >= MAX_PANES or p.panes[id] == null) return false; + if (!canSplitColumn(p, source_id)) return false; + const source = findPane(p, source_id) orelse return false; + if (id == source_id) { + if (p.col_n[source.col] <= 1) return false; + _ = absorbVWeight(p, id, null); + removePane(p, id); + } else if (findPane(p, id) != null) return false; + const c = insertColumn(p, source.col, before).?; + insert(p, c, 0, id); return true; } @@ -551,11 +671,7 @@ fn computeCollapsedColumn(p: *Pardes, c: usize, x: u16, width: u16, folded: usiz pub fn compute(p: *Pardes) void { p.rects = @splat(.{}); if (p.ncol == 0) return; - for (0..p.ncol) |column| if (p.col_serial[column] == 0) { - p.col_serial[column] = p.next_column_serial; - p.next_column_serial +%= 1; - if (p.next_column_serial == 0) p.next_column_serial = 1; - }; + for (0..p.ncol) |column| _ = columnSerial(p, column); var wsum: u128 = 0; for (0..p.ncol) |c| wsum += p.col_weight[c]; if (wsum == 0) wsum = 1; @@ -688,7 +804,6 @@ test "collapse local transfers remain bounded through repeated tiny window resiz } for (0..180) |step| { p.screen_h = @intCast((step * 17) % 81); - p.settings.column_tags = step % 2 == 0; p.settings.tag_bottom = step % 3 == 0; compute(p); toggleCollapse(p, step % 5); @@ -743,7 +858,7 @@ test "collapse single and all panes leave unused column space" { try std.testing.expectEqual(p.screen_h - 1, p.rects[1].y); } -test "collapse survives tiny resizes column tags and TagBottom" { +test "collapse survives tiny resizes and TagBottom" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); _ = try p.newShell(1, ""); @@ -753,8 +868,7 @@ test "collapse survives tiny resizes column tags and TagBottom" { p.panes[1].?.collapsed = true; for ([_]bool{ false, true }) |bottom| { p.settings.tag_bottom = bottom; - for ([_]bool{ false, true }) |column_tags| { - p.settings.column_tags = column_tags; + { for (0..50) |height| { p.screen_h = @intCast(height); compute(p); diff --git a/src/look.zig b/src/look.zig index 020aacb8..b6a89b64 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1251,20 +1251,15 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err); nt.greet = true; - const src = layout.splitParent(p, id); - const f = layout.findPane(p, src).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - p.stillborn_joiner = nt.serial; + exec.placeNew(p, id, id, free, .shell); }, .file => |target| { if (comptime pdf_enabled) if (target.kind == .pdf) { if (focusPaneByPath(p, target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| + _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); - exec.placeDoc(p, id, free, nt); + exec.placeNew(p, id, id, free, .doc); return; }; // focus an existing pane on this path (rescrolled), else open @@ -1273,7 +1268,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const nt = panes.File.open(p, free, target.path, target.at.line) catch |err| return p.reportError(id, "look", err); if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1); - exec.placeDoc(p, id, free, nt); + exec.placeNew(p, id, id, free, .doc); layout.compute(p); nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2; if (target.at.end_line != 0) { @@ -1285,9 +1280,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { .image => |target| { if (focusPaneByPath(p, target.path, .{})) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - const nt = panes.Image.create(p, free, target.path, &.{}) catch |err| + _ = panes.Image.create(p, free, target.path, &.{}) catch |err| return p.reportError(id, "look", err); - exec.placeDoc(p, id, free, nt); + exec.placeNew(p, id, id, free, .doc); }, } } diff --git a/src/mouse.zig b/src/mouse.zig index 22eb3270..5f0a6c4b 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -337,6 +337,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { wordAtCol(bar, at); const word = p.scratch.allocator().dupe(u8, selected) catch return; tagline.exitHeader(p); + // A column tag's command acts on that column, empty or not. + p.exec_column = column; + defer p.exec_column = null; if (word.len > 0) { if (m.button == config.exec_button) { const named = std.meta.stringToEnum(Builtin, word); @@ -450,6 +453,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { const on_tag = mrow >= tag_y and mrow < tag_y + pane.tag_rows; if (on_tag and mcol < r.x + TAG_TEXT_INSET) return; p.active = id; + p.noteTyped(); // acme's button 1 sets activecol (acme.c:659) pane.closePrompt(); const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return; pane.clearPointerSelection(sel_slot); @@ -775,12 +779,15 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y), .column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| switch (layout.columnDrop(p, column, d.cur_x, d.grab)) { .stay => {}, - .reorder => |target| layout.reorderColumn(p, column, target), + .reorder => |target| { + layout.reorderColumn(p, column, target); + p.setActiveColumn(target); // a grip drag sets activecol (acme.c:640) + }, .edge => |x| layout.setColumnPairWidth(p, column - 1, x - p.col_x[column - 1]), }, .move => |d| { layout.movePane(p, d.id, d.cur_x, d.cur_y); - if (p.panes[d.id]) |mp| p.stillborn_joiner = mp.serial; + if (layout.findPane(p, d.id)) |f| p.setActiveColumn(f.col); // a file moved into the left column evicts a lone unused shell if (p.panes[d.id]) |mt| if (mt.file != null) { if (layout.findPane(p, d.id)) |f| if (f.col == 0) diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 18068ab1..b4ae7231 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -308,6 +308,7 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .tagline_size => try w.print("{s} {d}\n", .{ word, s.font.tagline_percent }), .window_opacity => try w.print("{s} {d}\n", .{ word, s.window_opacity }), .window_blur => try w.print("{s} {d}\n", .{ word, s.window_blur }), + .placement => try w.print("{s} {s}\n", .{ word, @tagName(s.placement) }), .message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }), // Bare, it names the default directory. .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}), @@ -1120,7 +1121,7 @@ test "Joincol with no column to the right and Theme with no such theme say so" { try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null); } -test "a look that fills a column's stillborn scratch is answered with the scratch already gone" { +test "a look after Newcol fills the empty column the keyboard is on" { const gpa = testing.allocator; const p = try withFile(gpa, "x\n"); defer p.deinit(); @@ -1130,14 +1131,12 @@ test "a look that fills a column's stillborn scratch is answered with the scratc var dir: [4096]u8 = undefined; const at = dir[0..try tmp.dir.realPath(testing.io, &dir)]; var line: [4200]u8 = undefined; - // Newcol makes a column of one empty +New; a file looked from it - // joins that column and takes the scratch's place. + // Newcol makes an empty column and gives its tag the keyboard; no pane + // has it, so focus reads empty. try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status); - const scratch = p.panes[p.active].?.serial; - try testing.expectEqual(tree.Status.ok, wr(p, Node.of(scratch, .look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); - try testing.expect(p.paneBySerial(scratch) == null); - var want: [32]u8 = undefined; - const gone = try std.fmt.bufPrint(&want, "{d} ", .{scratch}); - var rows = std.mem.splitScalar(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, '\n'); - while (rows.next()) |row| try testing.expect(!std.mem.startsWith(u8, row, gone)); + try testing.expectEqual(@as(usize, 0), p.col_n[p.ncol - 1]); + try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.focus), 0, 64).bytes); + try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status); + try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]); + try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]); } diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 714a2566..99acf24b 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -593,11 +593,11 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize { } const free = p.freeSlot() orelse return null; const content = p.gpa.dupe(u8, text) catch return null; - const np = panes.Output.open(p, free, dir, .errors, "", content) catch { + _ = panes.Output.open(p, free, dir, .errors, "", content) catch { p.gpa.free(content); return null; }; - exec.placeDoc(p, id, free, np); + exec.placeNew(p, id, id, free, .errors); return text.len; } diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index b3f3dde6..5ef69bb4 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -577,7 +577,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply { // closing it leaves the pane alone. .new => { const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE); - p.newScratchBelow(p.active); + p.newScratchFor(p.active, .fs); const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE); return .{ .tag = req.tag, .handle = made.serial }; }, diff --git a/src/pardes.zig b/src/pardes.zig index 00843b78..bad512a9 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1092,13 +1092,13 @@ test "Joincol folds the active column into its right neighbor, keeping its panes try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current - layout.joinCol(p); + layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); - layout.joinCol(p); // no right neighbor left: inert + layout.joinCol(p, 0); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } @@ -1179,6 +1179,8 @@ test "first document fallback splits like Tty from the originating pane" { defer p.deinit(); const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); defer tty.deinit(); + p.settings.placement = .pardes; + tty.settings.placement = .pardes; if (collapsed) { layout.toggleCollapse(p, from); layout.toggleCollapse(tty, from); @@ -1191,8 +1193,8 @@ test "first document fallback splits like Tty from the originating pane" { // The Look origin need not be the currently active pane. p.active = (from + 1) % 3; const free = p.freeSlot().?; - const doc = try p.openTutorView(free); - exec.placeDoc(p, from, free, doc); + _ = try p.openTutorView(free); + exec.placeNew(p, from, from, free, .doc); _ = exec.spawnTty(tty, from); p.sync(); tty.sync(); @@ -1215,9 +1217,10 @@ test "first document keeps its originating shell and only opens a column when it for ([_]u16{ 199, 200 }) |width| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); defer p.deinit(); + p.settings.placement = .pardes; const shell = p.panes[0].?; - const doc = try p.openTutorView(1); - exec.placeDoc(p, 0, 1, doc); + _ = try p.openTutorView(1); + exec.placeNew(p, 0, 0, 1, .doc); p.sync(); try std.testing.expectEqual(shell, p.panes[0].?); try std.testing.expectEqual(@as(usize, 1), p.active); @@ -1233,6 +1236,112 @@ test "first document keeps its originating shell and only opens a column when it } } +test "acme placement: an empty active column takes the new pane whole" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); + defer p.deinit(); + try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); + p.newColumn(0); // its tag has the keyboard: it is the active column + tagline.exitHeader(p); + p.sync(); + // A Look from the shell's text still lands in the active column, as + // acme's makenewwindow puts it in activecol first (util.c:456). + _ = try p.openTutorView(1); + exec.placeNew(p, 0, 0, 1, .doc); + try std.testing.expectEqualSlices(usize, &.{1}, p.col_panes[1][0..p.col_n[1]]); + try std.testing.expectEqual(@as(usize, 1), p.active); + try std.testing.expectEqual(@as(?usize, 1), p.activeColumn()); + // Never a new column. + try std.testing.expectEqual(@as(usize, 2), p.ncol); +} + +test "acme placement: under a pane's text when it has many blank rows, else halving the biggest" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 60 }); + defer p.deinit(); + const top = p.active; + _ = try p.setTestFile("one\ntwo\nthree\n"); + p.sync(); + p.setActiveColumn(0); + // Most of the pane is blank: a document looked from it starts under its + // three lines (makenewwindow, util.c:482-486). + const second = p.freeSlot().?; + _ = try p.openTutorView(second); + exec.placeNew(p, top, top, second, .doc); + p.sync(); + const text_end = p.bodyTop(p.panes[top].?, p.rects[top]) + 3; + try std.testing.expectEqual(text_end, p.rects[second].y); + // Fill both with text: no blank space is big, so the biggest pane is + // halved. + const full = "x\n" ** 80; + for ([_]usize{ top, second }) |id| panes.File.setContent(p, &p.panes[id].?.file.?, try p.gpa.dupe(u8, full)); + p.sync(); + const big = if (p.rects[second].h >= p.rects[top].h) second else top; + const was = p.rects[big]; + const third = p.freeSlot().?; + _ = try p.openTutorView(third); + exec.placeNew(p, second, second, third, .doc); + p.sync(); + try std.testing.expectEqual(big, p.col_panes[0][layout.findPane(p, third).?.idx - 1]); + try std.testing.expectEqual(was.y + was.h / 2, p.rects[third].y); + try std.testing.expectEqual(was.y + was.h, p.rects[third].y + p.rects[third].h); +} + +test "acme placement: New and 9P's new take the bottom half of the last pane" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); + defer p.deinit(); + p.newScratchBelow(p.active); + const last = p.active; + p.sync(); + const was = p.rects[last]; + p.newScratchFor(last, .fs); + const made = p.active; + p.sync(); + try std.testing.expectEqual(layout.findPane(p, last).?.idx + 1, layout.findPane(p, made).?.idx); + try std.testing.expectEqual(was.y + was.h, p.rects[made].y + p.rects[made].h); + try std.testing.expect(p.rects[made].y > was.y + BOX_H); +} + +test "Placement pardes brings back the old rules, and says so in Config" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes")); + try std.testing.expectEqual(config.Runtime.Placement.pardes, p.settings.placement); + const top = p.active; + p.sync(); + const was = p.rects[top]; + p.newScratchBelow(top); + p.sync(); + // Directly under the asking pane, as New always put it. + try std.testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.idx); + try std.testing.expectEqual(was.y + was.h, p.rects[p.active].y + p.rects[p.active].h); + try std.testing.expect(p.executeBuiltinLine(p.active, "Placement")); + try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); +} + +test "dragging a column's only pane to another leaves that column in place, empty" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); + defer p.deinit(); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); + const moved = p.active; + p.sync(); + const weights = p.col_weight; + const tag_serial = p.col_serial[1]; + layout.movePane(p, moved, p.col_x[0] + 4, p.rects[0].y + p.rects[0].h / 2); + p.sync(); + // acme's coldragwin: colclose then coladd (cols.c:516-517); the column + // left behind keeps its place, width and tag. + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); + try std.testing.expectEqual(tag_serial, p.col_serial[1]); + try std.testing.expectEqualDeep(weights, p.col_weight); + try std.testing.expectEqual(@as(usize, 2), p.col_n[0]); + // ...and a pane dragged into the empty column takes it whole. + layout.movePane(p, moved, p.col_x[1] + 4, p.screen_h / 2); + p.sync(); + try std.testing.expectEqualSlices(usize, &.{moved}, p.col_panes[1][0..p.col_n[1]]); + try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); +} + test "pane defaults keep creation of scratch panes in column tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -3047,7 +3156,7 @@ test "column tags follow their column through split and close" { try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0)); p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep"); p.active = p.col_panes[0][0]; - layout.joinCol(p); + layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0)); } @@ -3056,7 +3165,8 @@ test "column tags render edit hover and scroll from the column edge" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); p.acknowledgePanelPresentation(&.{}); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -3112,7 +3222,8 @@ test "column tag first-cell execution keeps New local to the clicked column" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); for (0..2) |column| { p.active = p.col_panes[1 - column][0]; @@ -3189,7 +3300,7 @@ test "tiny screens keep pane space before adding a column tag" { _ = try p.render(p.scratch.allocator()); } -test "ColumnTags reclaims the row and releases hidden header focus without losing commands" { +test "a screen too short for the column row releases a column tag's focus without losing its commands" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("unchanged body\n"); @@ -3201,24 +3312,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); try std.testing.expect(p.header_focus and p.header_column != null); - p.update(.{ .command = "ColumnTags" }); - try std.testing.expect(!p.settings.column_tags); - try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); - try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); - try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); - try std.testing.expect(!p.header_focus and p.header_column == null); - try std.testing.expect(!p.header_drag); - try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); - - p.update(.{ .command = "ColumnTags" }); - try std.testing.expect(p.settings.column_tags); - try std.testing.expectEqual(shown, p.rects[0]); - p.acknowledgePanelPresentation(&.{}); - p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - try std.testing.expect(p.header_column != null); p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); - try std.testing.expect(p.settings.column_tags); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); try std.testing.expect(!p.header_focus and p.header_column == null); @@ -3268,15 +3362,17 @@ test "Enter in a header runs the word under its cursor, as the execute key does" p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Newcol").?); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expectEqual(@as(usize, 2), p.ncol); - // and the header lets go of the keyboard, as a pane tag does - try std.testing.expect(!p.header_focus); + // and the workspace tag lets go of the keyboard: the new, empty + // column's tag has it + try std.testing.expect(p.header_focus and p.header_column.? == 1); } test "window keys walk from a pane up through the column and workspace tags and back" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); p.active = p.col_panes[0][0]; const window = struct { @@ -3563,6 +3659,7 @@ test "Newcol refuses an unsplittable restored weight before spawning" { const pane = p.panes[id].?; try p.deinitPane(pane); layout.removePane(p, id); + layout.dropColumn(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} @@ -4099,9 +4196,15 @@ pub const Pardes = struct { look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, - /// The pane (by serial) that joined a column this step; `replaceStillborn` - /// checks it once the step is over and nothing holds the column's panes. - stillborn_joiner: ?u32 = null, + /// The column whose tag the running command came from, while it runs: + /// the column acme's commands take from their Text's col. + exec_column: ?usize = null, + /// acme's activecol (dat.c:37), by column serial, 0 for none: the + /// column last typed in (acme.c:487), clicked in with the select button + /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new + /// pane (util.c:467). Where the acme placement puts new panes + /// (exec.placeNew). A Look click moves the keyboard but not this. + active_column: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations_config.Config = .{}, locations_cache: @import("locations_cache.zig").Cache = .{}, @@ -4347,10 +4450,12 @@ pub const Pardes = struct { p.panes[0].?.body.mode = .tty; p.oneColumn(); // A bare tty is still an editor: it opens with somewhere to put - // text under the shell. The shell keeps the keyboard. + // text under the shell. The shell keeps the keyboard. A boot + // layout, like .classic's, not a placement. if (p.boot == .tty) { - p.newScratchBelow(0); - p.active = 0; + const np = try p.newScratch(0, 1); + layout.insert(p, 0, 1, 1); + layout.splitBelow(p, 0, np); } }, .classic => { @@ -4469,7 +4574,7 @@ pub const Pardes = struct { /// The column tags' height: a row per line of the tallest one. pub fn columnBarHeight(p: *const Pardes) u16 { - if (!p.settings.column_tags or p.screen_h < 3) return 0; + if (p.screen_h < 3) return 0; var lines: usize = COLBAR_H; for (p.col_tags[0..p.ncol]) |t| if (t.own) |own| { lines = @max(lines, std.mem.count(u8, own, "\n") + 1); @@ -4609,34 +4714,56 @@ pub const Pardes = struct { return np; } - /// The empty pane that stands in for one that closed, keeping its column - /// alive and its directory. Unlike `newScratch` it inherits nothing: the - /// pane it replaces is on its way out. - fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane { - const content = try p.gpa.dupe(u8, ""); - errdefer p.gpa.free(content); - const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content); - np.body.cur_pinned = true; - return np; - } - - /// New: a scratch below the calling pane, in its column. + /// New: an empty scratch in `from_id`'s directory, placed as exec.placeNew + /// places it. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { - const free = p.freeSlot() orelse return; - const sf = layout.findPane(p, from_id) orelse return; - const np = p.newScratch(from_id, free) catch return; - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, from_id, np); - p.active = free; + p.newScratchFor(from_id, .scratch); } - /// Newcol: a scratch in a fresh column beside the calling pane. - pub fn newScratchColumn(p: *Pardes, from_id: usize) void { + /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`). + pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void { const free = p.freeSlot() orelse return; - if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; - std.debug.assert(layout.splitColumn(p, from_id, free, false)); - p.active = free; + exec.placeNew(p, from_id, from_id, free, kind); + } + + /// Newcol: an empty column right of the calling pane's, the keyboard + /// on its tag, for the next pane made to fill. + pub fn newColumn(p: *Pardes, from_id: usize) void { + const col = p.keyboardColumn(from_id) orelse return; + const c = layout.insertColumn(p, col, false) orelse return; + tagline.enterHeader(p, c); + } + + pub fn setActiveColumn(p: *Pardes, c: usize) void { + if (c < p.ncol) p.active_column = layout.columnSerial(p, c); + } + + /// The active column, while it is still there. + pub fn activeColumn(p: *const Pardes) ?usize { + return layout.columnBySerial(p, p.active_column); + } + + /// The column the keyboard is in: its tag's, or its pane's. + pub fn noteTyped(p: *Pardes) void { + if (p.header_focus) { + if (p.header_column) |c| p.setActiveColumn(c); + } else if (layout.findPane(p, p.active)) |f| p.setActiveColumn(f.col); + } + + /// New, run from column `c`'s tag. + pub fn newScratchIn(p: *Pardes, from_id: usize, c: usize) void { + p.exec_column = c; + defer p.exec_column = null; + p.newScratchBelow(from_id); + } + + /// The column a command acts on: the one whose tag it came from, else + /// the one whose tag has the keyboard, else `id`'s. + pub fn keyboardColumn(p: *Pardes, id: usize) ?usize { + if (p.exec_column) |c| return c; + if (p.header_focus) if (p.header_column) |c| return c; + return if (layout.findPane(p, id)) |f| f.col else null; } pub fn freeSlot(p: *Pardes) ?usize { @@ -5088,10 +5215,6 @@ pub const Pardes = struct { look.cancelLookHover(p); const reply = ctlfs.handle(p, req); if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply); - // A pane that joined a column holding only a stillborn scratch takes - // it now, before the answer: one read of index after the look shows - // the replacement done, and the log has its del with the new. - exec.replaceStillborn(p); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes // told, and the panes it made announced to /log now rather than at @@ -5103,7 +5226,6 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { - defer exec.replaceStillborn(p); // A tick with nothing animating is the one event that cannot change // the screen. p.needs_frame = p.needs_frame or ev != .tick; @@ -5347,6 +5469,7 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; + p.noteTyped(); // An open prompt takes the keys even when it answers for a header. const header = if (pane.prompt != .none) null else tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { @@ -5395,16 +5518,11 @@ pub const Pardes = struct { // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { - tagline.exitHeader(p); const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; - const src = layout.splitParent(p, p.active); - const f = layout.findPane(p, src).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - p.stillborn_joiner = nt.serial; + // From a tag, as a 9P new is: acme's t->w == nil. + exec.placeNew(p, p.active, if (p.header_focus) null else p.active, free, .shell); return; } for (config.jump_keys) |jk| { @@ -5483,6 +5601,9 @@ pub const Pardes = struct { const b = config.wordBounds(shown, modal.selectionCursor(shown, range)); break :word shown[b.lo..b.hi]; }; + // A column tag's command acts on that column, empty or not. + p.exec_column = if (header != null) p.header_column else null; + defer p.exec_column = null; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. if (header != null) tagline.exitHeader(p) else pane.focus = .body; @@ -5947,59 +6068,38 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- /// Close a pane; its rows go to `to`, or where layout.absorbVWeight - /// sends them when that is null. Focus on it follows its rows: to the - /// pane that grew into them, or to the scratch left standing in an - /// emptied column. Every route that closes a pane comes through here or - /// removeColumn, so none picks focus for itself, and none by recency. + /// sends them when that is null. Focus on it follows its rows to the + /// pane that grew into them. A column outlives its panes, as acme's + /// does (cols.c:186): closing its last one leaves it empty, the keyboard + /// on its tag; only Delcol takes a column away. The session's last pane + /// closing ends the session. Every route that closes a pane comes + /// through here or removeColumn, so none picks focus for itself, and + /// none by recency. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { const pane = p.panes[id] orelse return; - // A column outlives its panes. Closing the last one leaves an empty - // pane standing in its place and in its directory; only Delcol ever - // takes a column away. const place = layout.findPane(p, id); - const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false; - var dir_buf: [limits.host_path_cap]u8 = undefined; - var dir: []const u8 = ""; - if (last_in_column) { - const had = paneDir(pane); - const n = @min(had.len, dir_buf.len); - @memcpy(dir_buf[0..n], had[0..n]); - dir = dir_buf[0..n]; - } + const emptied = if (place) |f| (if (p.col_n[f.col] == 1) f.col else null) else null; // Before anything is torn down, so a failure here changes nothing. try p.deinitPane(pane); - // ...and after it, so the stand-in inherits none of the closing pane. - const heir: ?usize = heir: { - if (!last_in_column) break :heir null; - const f = place.?; - const free = p.freeSlot() orelse break :heir null; - _ = p.newScratchIn(free, dir) catch break :heir null; - layout.insert(p, f.col, f.idx + 1, free); - break :heir free; - }; const gainer = layout.absorbVWeight(p, id, to); - const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null; + const beside = if (emptied) |c| p.besideColumn(c, p.rects[id]) else null; layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse { - p.quit = true; - p.emit(.quit); - return; - }; + if (p.active != id) return; + p.active = gainer orelse beside orelse p.anyPane() orelse return p.quitEmpty(); + if (emptied) |c| if (!p.header_focus) tagline.enterHeader(p, c); } - pub fn removeColumn(p: *Pardes, id: usize) !void { - const place = layout.findPane(p, id) orelse return; - const had_focus = layout.findPane(p, p.active); - const beside = if (had_focus != null and had_focus.?.col == place.col) - p.besideColumn(place.col, p.active) - else - null; + /// Delcol: close column `col` and every pane in it. + pub fn removeColumn(p: *Pardes, col: usize) !void { + if (col >= p.ncol) return; + const had_focus = p.keyboardColumn(p.active) == col; + const beside = if (had_focus) p.besideColumn(col, if (p.header_focus) null else p.rects[p.active]) else null; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; - const count = p.col_n[place.col]; + const count = p.col_n[col]; for (0..count) |i| { - ids[i] = p.col_panes[place.col][i]; + ids[i] = p.col_panes[col][i]; parents[i] = p.panes[ids[i]].?; } try p.detachCwds(parents[0..count]); @@ -6008,11 +6108,14 @@ pub const Pardes = struct { layout.removePane(p, closed); p.panes[closed] = null; } - if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse { - p.quit = true; - p.emit(.quit); - return; - }; + layout.dropColumn(p, col); + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty(); + } + + /// No pane is left: the session is over. + fn quitEmpty(p: *Pardes) void { + p.quit = true; + p.emit(.quit); } /// The pane Last should fall back to when the jumplist has nothing: the @@ -6031,14 +6134,13 @@ pub const Pardes = struct { return null; } - /// The pane that takes over `id`'s place when column `col` goes: in the - /// column layout.removePane gives the width to, the one level with the - /// middle of `id`. Asked before the column goes, while rects still hold. - fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize { + /// The pane that takes over the keyboard from column `col`: in the + /// column layout.dropColumn gives the width to, the one level with the + /// middle of `r` (of the screen without one). Asked while rects hold. + fn besideColumn(p: *const Pardes, col: usize, r: ?Rect) ?usize { if (p.ncol < 2) return null; const next = if (col > 0) col - 1 else col + 1; - const r = p.rects[id]; - const mid = r.y + r.h / 2; + const mid = if (r) |at| at.y + at.h / 2 else p.screen_h / 2; var pick: ?usize = null; for (p.col_panes[next][0..p.col_n[next]]) |k| { if (p.panes[k] == null) continue; @@ -6648,43 +6750,67 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro try std.testing.expect(pane.prompt == .none and pane.input.own == null); } -test "a stillborn pane gives its column to the first pane that joins it" { +test "Newcol makes an empty column, and the next pane made from its tag fills it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); defer p.deinit(); - try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); - const blank = p.active; - p.sync(); // Tty splits by the rects a frame has laid out - try std.testing.expect(p.executeBuiltinLine(blank, "Tty")); + const first = p.active; + const panes_before = p.col_n[0]; + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); + try std.testing.expectEqual(panes_before, p.col_n[0]); + // The keyboard is on the new column's tag; no pane has it. + try std.testing.expect(p.header_focus and p.header_column.? == 1); + try std.testing.expectEqual(first, p.active); + p.sync(); + try std.testing.expect(p.executeBuiltinLine(first, "Tty")); const shell = p.active; - p.update(.tick); // the step ends: the placeholder goes - try std.testing.expect(p.panes[blank] == null); - try std.testing.expectEqual(@as(usize, 1), p.col_n[layout.findPane(p, shell).?.col]); - try std.testing.expectEqual(shell, p.active); + try std.testing.expect(shell != first); + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + try std.testing.expectEqual(shell, p.col_panes[1][0]); + try std.testing.expect(!p.header_focus); - // Typed into, body or tag, it is a pane like any other. - try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); - const typed = p.active; + // The next Tty splits as it always has: nothing is empty any more. p.sync(); - for ("ix") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); - try std.testing.expect(p.executeBuiltinLine(typed, "Tty")); - p.update(.tick); - try std.testing.expect(p.panes[typed] != null); - try std.testing.expectEqual(@as(usize, 2), p.col_n[layout.findPane(p, typed).?.col]); - - try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); - const tagged = p.active; + try std.testing.expect(p.executeBuiltinLine(shell, "Tty")); + try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); +} + +test "a column tag's command acts on that column" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); + defer p.deinit(); + const first = p.active; + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expectEqual(@as(usize, 3), p.ncol); p.sync(); - try p.panes[tagged].?.appendTag(" make"); - try std.testing.expect(p.executeBuiltinLine(tagged, "Tty")); - p.update(.tick); - try std.testing.expect(p.panes[tagged] != null); + // New from the middle column's tag lands in it, not below `first`. + p.exec_column = 1; + try std.testing.expect(p.executeBuiltinLine(first, "New")); + p.exec_column = null; + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + try std.testing.expectEqual(p.active, p.col_panes[1][0]); + // Delcol from the empty right column's tag closes that one. + p.exec_column = 2; + try std.testing.expect(p.executeBuiltinLine(first, "Delcol")); + p.exec_column = null; + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + // A pane's own Delcol closes its column whatever tag has the keyboard. + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expect(p.header_focus); + try std.testing.expect(p.executeBuiltinLine(p.col_panes[1][0], "Delcol")); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); } test "Del from a key asks which neighbour takes the rows; nothing else asks" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 40 }); defer p.deinit(); + p.settings.placement = .pardes; // the neighbours below are what is under test var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const top = p.active; @@ -6781,7 +6907,8 @@ test "focus on a closed pane goes to the pane that takes its place, not the one const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); defer p.deinit(); const shell = p.active; - p.newScratchColumn(shell); + p.newColumn(shell); + p.newScratchIn(shell, p.activeColumn().?); const right = p.active; p.sync(); p.newScratchBelow(shell); @@ -6813,45 +6940,50 @@ test "focus on a closed pane goes to the pane that takes its place, not the one p.sync(); p.active = left_low; p.sync(); - try p.removeColumn(left_low); + try p.removeColumn(layout.findPane(p, left_low).?.col); p.sync(); try std.testing.expectEqual(right_low, p.active); } -test "closing the last pane in a column leaves an empty one in its place" { +test "closing the last pane in a column leaves the column empty, its tag focused" { 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); + p.newColumn(shell); + p.newScratchIn(shell, p.activeColumn().?); 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); + p.sync(); try p.removePane(lone, null); 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. + // The column outlives the pane, as acme's does: still two columns, the + // emptied one with no pane and the keyboard on its tag. 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.expectEqual(@as(usize, 0), p.col_n[at.col]); 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.header_focus and p.header_column.? == at.col); + try std.testing.expectEqual(shell, p.active); try std.testing.expect(!p.quit); + // Down from it goes nowhere: nothing is below. + try std.testing.expect(p.executeBuiltinLine(shell, "Down")); + try std.testing.expect(p.header_focus and p.header_column.? == at.col); + // ...and Delcol is what actually takes the column away. - try p.removeColumn(heir); + try p.removeColumn(at.col); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqual(shell, p.active); + + // The session's last pane closing ends it. + try p.removePane(shell, null); + try std.testing.expect(p.quit); } test "the bare tty startup opens a text pane under the shell, unfocused" { @@ -7017,6 +7149,7 @@ test "hopping between two panes does not grow the jump stack" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); + p.settings.placement = .pardes; // two panes of room, not acme's fit to the doc's two lines defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 13c02231..58dd2c3c 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2045,7 +2045,7 @@ pub fn openSections(core: *pardes.Pardes, id: usize) void { .serial = result.serial, .revision = result.file.?.revision, }; - exec.placeDoc(core, id, free, result); + exec.placeNew(core, id, id, free, .doc); layout.compute(core); core.active = id; pane.search_pane = free; diff --git a/src/surface.zig b/src/surface.zig index b6f41853..a0471e51 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -397,7 +397,9 @@ pub const Chrome = struct { /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. pub const Surface = struct { - pub const PointerShape = enum(u8) { arrow, link, target }; + /// `box` is acme's boxcursor, shown while a pane or a column is carried + /// by its grip (cols.c:485, rows.c:151). + pub const PointerShape = enum(u8) { arrow, link, target, box }; pointer_shape: PointerShape = .arrow, body_layers: [MAX_PANES]Layer = @splat(.{}), tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}), diff --git a/src/tagline.zig b/src/tagline.zig index 59c51b4d..c04cbc66 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -321,6 +321,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { if (p.panes[p.active]) |pane| pane.focus = .body; p.header_focus = true; p.header_column = column; + if (column) |c| p.setActiveColumn(c); const t = focusedHeader(p) orelse return exitHeader(p); const text = edit.flatSurface(p, t) catch ""; const rows = modal.cursorLineCount(text); diff --git a/src/web/app.mjs b/src/web/app.mjs index 03829178..c053d663 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -42,7 +42,8 @@ export class DomRenderer { render() { const count = this.wasm.pardes_frame(); - this.root.style.cursor = this.wasm.pardes_pointer_shape() === 1 ? "pointer" : "text"; + const shape = this.wasm.pardes_pointer_shape(); + this.root.style.cursor = shape === 1 ? "pointer" : shape === 3 ? "move" : "text"; const cols = this.wasm.pardes_frame_cols(); const rows = this.wasm.pardes_frame_rows(); if (count !== cols * rows) throw new Error(`bad frame: ${count} cells for ${cols}x${rows}`); diff --git a/test/appearance_gallery.py b/test/appearance_gallery.py index 48c0ea1b..bc9a7570 100644 --- a/test/appearance_gallery.py +++ b/test/appearance_gallery.py @@ -78,8 +78,8 @@ def run(args): launch=launch, inherited=inherited, terminal=handles) as (client, _): source = next(serial for serial, tag in panes(client).items() if str(source_path) in tag) before = set(panes(client)) - command(client, source, 'Newcol') - right = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'right column missing') + command(client, source, 'Newcol') # empty, its tag with the keyboard + right = int(client.read('/pane/new')) client.write(f'/pane/{right}/name', f'{root / "Review"}\n'.encode()) client.write(f'/pane/{right}/body', PREFERENCE_NOTES if args.theme_selector else NOTES, truncate=True) client.write(f'/pane/{right}/dirty', b'0\n') diff --git a/test/column_drag.zig b/test/column_drag.zig index 3aea4286..89508800 100644 --- a/test/column_drag.zig +++ b/test/column_drag.zig @@ -46,7 +46,6 @@ test "column grip drag moves only its interval and keeps tag caret with the colu defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; for (0..3) |_| { try std.testing.expect(p.executeBuiltinLine(p.active, "New")); @@ -127,7 +126,6 @@ test "column grip is separate from compact text and the resize seam" { defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); @@ -181,7 +179,6 @@ test "column grips stay blank, and muted across focus and hover until grabbed" { defer p.deinit(); _ = try p.setTestFile("left\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); @@ -219,7 +216,6 @@ test "column grip dropped in its own place moves the column's left edge" { defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; try std.testing.expect(p.executeBuiltinLine(0, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); p.update(.tick); @@ -284,7 +280,6 @@ test "column grip drag previews where the column lands, in the seam beside its r defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; for (0..2) |_| { try std.testing.expect(p.executeBuiltinLine(p.active, "New")); try std.testing.expect(layout.splitColumn(p, p.active, p.active, false)); diff --git a/test/column_tags.py b/test/column_tags.py index fe3a8699..ba638b7d 100644 --- a/test/column_tags.py +++ b/test/column_tags.py @@ -138,7 +138,10 @@ def run(args): before = panes(client) keys.click(*require(locate(client, 'Newcol', row=0), 'Newcol missing'), button=1) - right = require(wait_for(lambda: new_serial(client, before)), 'Newcol did not create its pane') + # Newcol makes an empty column and gives its tag the keyboard; the + # pane made next fills it. + require(wait_for(lambda: client.read('/focus').strip() == b''), 'Newcol did not focus its empty column') + right = int(client.read('/pane/new')) right_name = root / 'right.rs' client.write(f'/pane/{right}/name', f'{right_name}\n'.encode()) right_body = b'// Column-local scratch\nfn main() {\n let ready = true;\n}\n' diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index 38ca89e5..8e859fdc 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -8,11 +8,10 @@ 08-wide-glyph-edges 123a182b82a2a8c455291a5c89ccfa28e1f8ce9566172ca404c0b9c71dd33b48 09-collapsed-focus-tint ae66076fdfb0cab9a906b052df257deee5ba91a6274712d52a76d8a7adc5b415 10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 -11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 -12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 -13-image a52e2cf9f238447d885692cba01e68ef7fdc6ae3fc7c8fe1e95c8463c85f519c -14-theme-ink cc2ea256ddda24e1c97871f75b71ddf62fa825bd02f5d1432592d05e478c707a -15-theme-acme-light 2bde8a2bdfada7f061217f52cb2272913fc0657dbfa1d7448e1852f321f8e3bf +12-multiline-tag 0edffadbabef48e5a7422ef8433ccf2552b9c812a352e30b32bb000aacad607d +13-image 91f9363493c9c1b9618426ef7362da469f5b051890a3128ef8c94cee6ae2bb06 +14-theme-ink 84440553176db695a9a8a82714e858c815ea31cd82a29760c4894b4675c3a2ed +15-theme-acme-light cd216282f477512284372f330709d0cde93d1f2fb6d9c4616ebb642f3ef5c129 16-debug 129ad6f5f85b0e60e81bc6e8e78434629ee751f49731cf2eb28fd0553efd914a -17-terminal 45a3d9f823ccf28a336ef6687c9c210f9fb05cc426c0fdee98bb0ebb2a957d42 -18-mid-transition 79f90fb247f75ab284fca147028ee18ea78e77a02a57203699fb423f4bcc02fb +17-terminal d958ca0182ddf360abd266e29c3934c6e251ed25f258f687597372369899eaea +18-mid-transition a35f460c8637c2183f37b3a5ac2e7bd34bdde708a52fa0f8dcdbf937627b125f diff --git a/test/gui_golden.py b/test/gui_golden.py index d97009c9..407b19cd 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -91,7 +91,7 @@ def run(args): config.mkdir(parents=True) # No linger: a dismissed message is gone, not caught part way, when a # scene settles. - (config / 'init').write_text('Shell /bin/sh\nTheme forge\nMessageLinger 0\n') + (config / 'init').write_text('Shell /bin/sh\nTheme forge\nMessageLinger 0\nPlacement pardes\n') source = root / 'shapes.zig' source.write_bytes(ZIG) notes = root / 'notes.txt' @@ -177,9 +177,6 @@ def run(args): client.write('/ctl', b'Tagbottom\n') scene('10-tagbottom') client.write('/ctl', b'Tagbottom\n') - client.write('/ctl', b'ColumnTags\n') - scene('11-column-tags') - client.write('/ctl', b'ColumnTags\n') client.write(f'/pane/{first}/tag', b' Second\nthird line of the tag') scene('12-multiline-tag') client.write(f'/pane/{first}/look', f'{image}\n'.encode()) diff --git a/test/panes.zig b/test/panes.zig index 40b4b6ba..9802432e 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -103,7 +103,6 @@ const TagLayerTests = struct { defer p.deinit(); _ = try p.setTestFile("body\n"); p.presentation.enabled = false; - p.settings.column_tags = true; p.row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 5, .tagline_h = 10 }; try std.testing.expect(p.executeBuiltinLine(0, "New")); const second = p.active; diff --git a/test/pdf.zig b/test/pdf.zig index 2699fe01..4cb8f3dd 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -343,7 +343,7 @@ test "PdfSections Look follows the exact owning PDF, not an equal path" { const duplicate_id = p.freeSlot() orelse return error.NoDuplicatePdfSlot; const duplicate = try panes.Pdf.openPane(p, duplicate_id, path, 0); - pardes.exec.placeDoc(p, 0, duplicate_id, duplicate); + pardes.exec.placeNew(p, 0, 0, duplicate_id, .doc); layout.compute(p); panes.Pdf.openSections(p, duplicate_id); const duplicate_output_id = duplicate.search_pane orelse return error.MissingDuplicatePdfSections; diff --git a/test/snapshots/argv.snap b/test/snapshots/argv.snap index 892f6b24..0d4c8f64 100644 --- a/test/snapshots/argv.snap +++ b/test/snapshots/argv.snap @@ -4,6 +4,8 @@ # argument becomes the cwd the shell spawns in lines notes.txt 8 file zmarker.txt x +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 wait 8000 $ stable 700 20000 diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index 8bf70f36..7810ebda 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -1,4 +1,6 @@ lines notes.txt 6 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 67 120 notes.txt wait 8000 notes.txt stable 700 20000 diff --git a/test/snapshots/chordcut.snap b/test/snapshots/chordcut.snap index 56170cce..01fee1f7 100644 --- a/test/snapshots/chordcut.snap +++ b/test/snapshots/chordcut.snap @@ -11,6 +11,8 @@ lines g.txt 2 # unrelated right column untouched. Give the caller enough cells that the selected # `echo B''OO g.txt` remains one displayed row; this test is about chords, not # soft-wrap traversal. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 120 -n 3 wait 8000 f.txt stable 700 20000 diff --git a/test/snapshots/cmdword.snap b/test/snapshots/cmdword.snap index 631fab8b..438d3ed5 100644 --- a/test/snapshots/cmdword.snap +++ b/test/snapshots/cmdword.snap @@ -23,6 +23,8 @@ file zzmark.txt x # the top of the body -- which would move every row this script clicks. The # verbs are what is under test here, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 120 cmds.txt wait 8000 Newcol Joincol wait 8000 cmds.txt diff --git a/test/snapshots/delask.snap b/test/snapshots/delask.snap index 04bc850c..05f4b204 100644 --- a/test/snapshots/delask.snap +++ b/test/snapshots/delask.snap @@ -2,6 +2,8 @@ # pane's notice band, which one takes its rows: k above, j below, anything else # keeps the pane. A click, and DelAbove/DelBelow (Del k / Del j), never ask. file top.txt top-marker +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 36 100 top.txt wait 8000 top-marker stable 700 20000 diff --git a/test/snapshots/doccol.snap b/test/snapshots/doccol.snap index 2135c533..7855453c 100644 --- a/test/snapshots/doccol.snap +++ b/test/snapshots/doccol.snap @@ -17,6 +17,8 @@ file c.txt CCC # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 a.txt wait 8000 AAA stable 700 20000 diff --git a/test/snapshots/emptycol.golden b/test/snapshots/emptycol.golden new file mode 100644 index 00000000..bb41c028 --- /dev/null +++ b/test/snapshots/emptycol.golden @@ -0,0 +1,14 @@ +== snap newcol grid=100x24 cursor=53,1 +|Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit +| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol +| /tmp/pardes-snap/emptycol/cwd/notes.txt Save Tt +| 1 one +| 2 two +== snap filled grid=100x24 cursor=54,5 +|2: /tmp/pardes-snap/emptycol/cwd/notes.txt Save Tt /tmp/pardes-snap/emptycol/cwd Tty+bash Save Mod +|3: 1 one ls +|4: 2 two notes.txt +== snap emptied grid=100x24 cursor=53,1 +|2: /tmp/pardes-snap/emptycol/cwd/notes.txt Save Tt +|3: 1 one +|4: 2 two diff --git a/test/snapshots/emptycol.snap b/test/snapshots/emptycol.snap new file mode 100644 index 00000000..edf61eed --- /dev/null +++ b/test/snapshots/emptycol.snap @@ -0,0 +1,22 @@ +# A column can hold no pane, as acme's can (cols.c:186). Newcol makes one +# empty, its tag over blank space and holding the keyboard; the next pane +# made while it is the active column fills it whole (makenewwindow, +# util.c:468-469); and a shell that exits there leaves it empty again, the +# keyboard back on its tag. +file notes.txt one\ntwo +config Verbose off +start 24 100 notes.txt +wait 8000 notes.txt +stable 700 20000 +key space c n +stable 400 5000 +snap newcol +key a-n +stable 700 15000 +snap filled +key c-b +stable 400 5000 +text exit +key enter +stable 700 10000 +snap emptied diff --git a/test/snapshots/filewatch.snap b/test/snapshots/filewatch.snap index 38b6ac8e..1579ad40 100644 --- a/test/snapshots/filewatch.snap +++ b/test/snapshots/filewatch.snap @@ -22,6 +22,8 @@ lines sub/s.txt 8 # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 210 -n 1 wait 8000 w.txt stable 700 20000 diff --git a/test/snapshots/find.snap b/test/snapshots/find.snap index fe1ecad3..82c68343 100644 --- a/test/snapshots/find.snap +++ b/test/snapshots/find.snap @@ -17,6 +17,8 @@ file sub/ZQMARK-beta.txt beta body file sub/nest/zqmark-gamma.txt gamma body file other.txt nothing to match here file .git/zqmark-hidden.txt hidden body +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 120 here.txt wait 8000 here.txt stable 700 20000 diff --git a/test/snapshots/fsearch.snap b/test/snapshots/fsearch.snap index b050f1db..aff3e37f 100644 --- a/test/snapshots/fsearch.snap +++ b/test/snapshots/fsearch.snap @@ -13,6 +13,8 @@ file g.txt l1\nMARK a\nl3\nl4\nl5\nMARK b\nl7\nl8\nl9\nMARK c\nl11\nl12 # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 180 -n 3 wait 8000 g.txt stable 700 20000 diff --git a/test/snapshots/grep.snap b/test/snapshots/grep.snap index 07777e67..207aedc6 100644 --- a/test/snapshots/grep.snap +++ b/test/snapshots/grep.snap @@ -21,6 +21,8 @@ config Verbose off # The stacked layout spends two rows on each hit, which puts the third one # below the fold of this results pane; the claim here is which hits there are. config LocationsConfig layout:inline +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 180 -n 3 wait 8000 a.txt stable 700 20000 diff --git a/test/snapshots/jumps.snap b/test/snapshots/jumps.snap index e4816f88..74dc5fd7 100644 --- a/test/snapshots/jumps.snap +++ b/test/snapshots/jumps.snap @@ -10,6 +10,8 @@ # Focus and position both show in the golden's `cursor=` header: that is the # ACTIVE pane's cursor, so each snap is named by where it should land. lines big.txt 200 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 150 big.txt wait 8000 big.txt stable 700 20000 diff --git a/test/snapshots/layout-close.golden b/test/snapshots/layout-close.golden index a7d159b5..38985b40 100644 --- a/test/snapshots/layout-close.golden +++ b/test/snapshots/layout-close.golden @@ -43,6 +43,7 @@ |10: |11: |12: -== snap colclose grid=140x41 cursor=42,3 -|3: ls 1 ls +== snap colclose grid=140x41 cursor=38,1 +|2: /tmp/pardes-snap/layout-close/cw /tmp/pardes-snap/layout-close/cwd Tty+bash Save Mode Filter Collaps +|3: ls ls |4: marker.txt marker.txt diff --git a/test/snapshots/layout-close.snap b/test/snapshots/layout-close.snap index 4ae3e31f..b168eb2b 100644 --- a/test/snapshots/layout-close.snap +++ b/test/snapshots/layout-close.snap @@ -1,7 +1,9 @@ # every pane close hands its space to ONE sibling, the rest of the column # keeps its sizes: Alt-c move-out, tag Del, and shell exit (pty EOF); an -# emptied column hands its width to one neighbor +# emptied column stays, as acme's does, its tag over blank space file marker.txt x +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 140 -n 3 wait 8000 marker.txt stable 700 20000 @@ -49,8 +51,8 @@ text exit key enter stable 700 10000 snap exit -# exit the moved-out pane's shell: its column empties and returns its width to -# the source column it was carved from; the untouched right column stays put +# exit the moved-out pane's shell: its column stays where it was, empty, the +# keyboard on its tag; the other columns keep their widths press left 40 9 release left 40 9 stable 400 5000 diff --git a/test/snapshots/layout-open.snap b/test/snapshots/layout-open.snap index 04770f27..c67dcfa5 100644 --- a/test/snapshots/layout-open.snap +++ b/test/snapshots/layout-open.snap @@ -5,6 +5,8 @@ # unrelated pane (including the ones above the source) at its exact rectangle. dirmk sub file sub/f.txt echo 'TTY'OK +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 -n 3 wait 8000 sub stable 700 20000 diff --git a/test/snapshots/leader.snap b/test/snapshots/leader.snap index 78594a47..e8f42331 100644 --- a/test/snapshots/leader.snap +++ b/test/snapshots/leader.snap @@ -1,4 +1,6 @@ file cmds.txt Help +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 100 cmds.txt wait 8000 Newcol Joincol wait 8000 cmds.txt diff --git a/test/snapshots/lookpanes.snap b/test/snapshots/lookpanes.snap index 8ada3f1b..296570f7 100644 --- a/test/snapshots/lookpanes.snap +++ b/test/snapshots/lookpanes.snap @@ -24,6 +24,8 @@ file beta/bmark.txt both.txt\nb-marker # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 51 130 beta/bmark.txt wait 8000 b-marker stable 700 20000 diff --git a/test/snapshots/lookrange.snap b/test/snapshots/lookrange.snap index 50ef9ad3..ef04409e 100644 --- a/test/snapshots/lookrange.snap +++ b/test/snapshots/lookrange.snap @@ -18,6 +18,8 @@ file look.txt r.txt:3-5\nr.txt:7:4-9\nr.txt:3:2-5:3\nr.txt:8:6-99:9 # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 150 look.txt wait 8000 r.txt:3-5 stable 700 20000 diff --git a/test/snapshots/lsp-client.snap b/test/snapshots/lsp-client.snap index 541e7c7f..7b255bd8 100644 --- a/test/snapshots/lsp-client.snap +++ b/test/snapshots/lsp-client.snap @@ -9,6 +9,8 @@ # gr -> sorted rows, selecting the next occurrence; n/N continue there. file main.rs mod lib;\n\nfn main() {\n let x = add(1, 2);\n let y = add(3, 4);\n let _ = x + y;\n}\n file lib.rs // mock fixture\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 25 100 main.rs wait 8000 add(1, 2) stable 700 20000 diff --git a/test/snapshots/lsp.snap b/test/snapshots/lsp.snap index b40484ee..68365527 100644 --- a/test/snapshots/lsp.snap +++ b/test/snapshots/lsp.snap @@ -11,6 +11,8 @@ # symbol from std — so these goldens do not encode this machine's stdlib # version or its install path. file a.zig const std = @import("std");\n\nfn helper(x: u32) u32 {\n return x + 1;\n}\n\npub fn main() void {\n _ = helper(41);\n}\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 120 a.zig wait 8000 helper stable 700 20000 diff --git a/test/snapshots/lspcomplete.snap b/test/snapshots/lspcomplete.snap index 53ce7ef9..dcdec45c 100644 --- a/test/snapshots/lspcomplete.snap +++ b/test/snapshots/lspcomplete.snap @@ -18,6 +18,8 @@ # script the only end-to-end coverage the feature has. file a.zig const Shade = enum {\n verdigris,\n cinnabar,\n};\n\nfn pick(s: Shade) u8 {\n return switch (s) {\n \n };\n}\n\n// note. see below\n file notes.txt abc.def ghi\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 41 120 a.zig wait 8000 verdigris stable 700 20000 diff --git a/test/snapshots/lsprelpath.snap b/test/snapshots/lsprelpath.snap index 247390fe..89b0e124 100644 --- a/test/snapshots/lsprelpath.snap +++ b/test/snapshots/lsprelpath.snap @@ -30,6 +30,8 @@ file sub/inner/tint.zig pub const Tint = enum {\n ochre,\n indigo,\n};\n file sub/parent.zig const shade = @import("../shade.zig");\n\npub fn pick(s: shade.Shade) u8 {\n return switch (s) {\n \n };\n}\n file sub/subdir.zig const tint = @import("inner/tint.zig");\n\npub fn hue(t: tint.Tint) u8 {\n return switch (t) {\n \n };\n}\n # ---- PARENT: outside the asking file's tree, so the row stays absolute ---- +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 35 100 sub/parent.zig wait 8000 fn pick stable 700 20000 diff --git a/test/snapshots/mini.snap b/test/snapshots/mini.snap index 4905c68d..ffa9d94e 100644 --- a/test/snapshots/mini.snap +++ b/test/snapshots/mini.snap @@ -1,5 +1,7 @@ file commands.txt @`Mini demo.zig` file demo.zig const greeting = "café λ 界 é";\n\npub fn choose(ok: bool) u32 {\n if (ok) return 42;\n return 7;\n}\n\n// space and color survive compression\nconst answer = choose(true);\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 23 90 commands.txt wait 8000 Mini demo.zig stable 400 10000 diff --git a/test/snapshots/nested.snap b/test/snapshots/nested.snap index c26b7cea..9c31fb00 100644 --- a/test/snapshots/nested.snap +++ b/test/snapshots/nested.snap @@ -1,6 +1,8 @@ dirmk nestdir file nestdir/inside-nested.txt marker file nested.txt hello-from-nested\nsecond-nested-line\n +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 wait 8000 $ stable 700 20000 diff --git a/test/snapshots/psearch.snap b/test/snapshots/psearch.snap index a46e2b9d..f32fdf75 100644 --- a/test/snapshots/psearch.snap +++ b/test/snapshots/psearch.snap @@ -12,6 +12,8 @@ # It splits BELOW the shell it came from: every snap below stays ONE column, # the shell's tag and the +Search tag both starting at x=0, thirty rows split # between them, and nothing shifts sideways. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 140 wait 8000 Newcol Joincol stable 700 20000 diff --git a/test/snapshots/reflow.snap b/test/snapshots/reflow.snap index 7aac1e8c..4a2a5368 100644 --- a/test/snapshots/reflow.snap +++ b/test/snapshots/reflow.snap @@ -4,6 +4,8 @@ # integrity check panicked the whole app ("ViewportPinInsufficientRows"). # build.zig now pins the ghostty dependency at ReleaseSafe so those # development-only checks are off — the app's own safety checks are unchanged. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 25 40 wait 8000 $ text printf 'y%.0s' $(seq 1 3000); echo do''ne diff --git a/test/snapshots/rsearch.snap b/test/snapshots/rsearch.snap index 30459488..a83d27c4 100644 --- a/test/snapshots/rsearch.snap +++ b/test/snapshots/rsearch.snap @@ -11,6 +11,8 @@ file code.txt run count_x now\nopen sub/dep.txt:3 here\ncount_x again\npick two # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 210 -n 1 wait 8000 code.txt stable 700 20000 diff --git a/test/snapshots/savepane.snap b/test/snapshots/savepane.snap index 58df13ef..39129347 100644 --- a/test/snapshots/savepane.snap +++ b/test/snapshots/savepane.snap @@ -7,6 +7,8 @@ # scratch asks for a path in its inherited dir and makes it a file"); none of # them can see it drawn, land on the tag rather than the message row, or show # what the pane becomes afterwards. That is what this script is for. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 -n 1 wait 8000 Newcol Joincol stable 700 20000 diff --git a/test/snapshots/scrolloff.snap b/test/snapshots/scrolloff.snap index caed9d47..f99c7341 100644 --- a/test/snapshots/scrolloff.snap +++ b/test/snapshots/scrolloff.snap @@ -1,6 +1,8 @@ # scrolloff: keyboard motion keeps 3 context rows above/below the cursor, # clamped at file boundaries (cursor may reach the top/bottom body row there) lines g.txt 40 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 12 210 -n 1 wait 8000 g.txt stable 700 20000 diff --git a/test/snapshots/splitparent.snap b/test/snapshots/splitparent.snap index 1fd4e935..e5595144 100644 --- a/test/snapshots/splitparent.snap +++ b/test/snapshots/splitparent.snap @@ -1,6 +1,8 @@ # a new pane never opens under a too-short parent: Alt-n from a tag-only pane # splits the tall sibling in its column instead; when NO pane could leave the # newcomer 2 body rows, the tallest one anywhere is split +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 -n 3 stable 700 20000 # shrink pane 0 (top-left) to just its tagline diff --git a/test/snapshots/stepgrain.snap b/test/snapshots/stepgrain.snap index 3c4d3e4c..323fda5c 100644 --- a/test/snapshots/stepgrain.snap +++ b/test/snapshots/stepgrain.snap @@ -21,6 +21,8 @@ dirmk sub file a.txt hello ZQMARK one\nsee also sub/b.txt for ZQMARK two file sub/b.txt ZQMARK inside sub run touch 'two words.txt' +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 150 a.txt wait 8000 hello ZQMARK stable 700 20000 diff --git a/test/snapshots/tagalign.snap b/test/snapshots/tagalign.snap index a77415d9..7a0c93ce 100644 --- a/test/snapshots/tagalign.snap +++ b/test/snapshots/tagalign.snap @@ -2,6 +2,8 @@ dirmk deep dirmk deep/nested-project-directory file deep/nested-project-directory/document.txt alpha\nbeta\ngamma +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 deep/nested-project-directory/document.txt wait 8000 Newcol Joincol wait 8000 document.txt diff --git a/test/snapshots/tagalign2col.snap b/test/snapshots/tagalign2col.snap index 0849ffae..353289d4 100644 --- a/test/snapshots/tagalign2col.snap +++ b/test/snapshots/tagalign2col.snap @@ -2,6 +2,8 @@ dirmk deep dirmk deep/proj file deep/proj/document.txt alpha\nbeta\ngamma +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 160 deep/proj/document.txt wait 8000 Newcol Joincol wait 8000 document.txt diff --git a/test/snapshots/tagbottom.snap b/test/snapshots/tagbottom.snap index 67bb2ead..c294a7b6 100644 --- a/test/snapshots/tagbottom.snap +++ b/test/snapshots/tagbottom.snap @@ -21,6 +21,8 @@ file bottom.txt ZZONE\nZZTWO # the top of the body -- which moves every row this script clicks. What the # verbs do is what is under test, not the announcement. config Verbose off +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 top.txt wait 8000 Newcol Joincol wait 8000 top.txt diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap index f145480e..a0e06cd0 100644 --- a/test/snapshots/tagnav.snap +++ b/test/snapshots/tagnav.snap @@ -10,6 +10,8 @@ # carries its BODY mode as one character: ` ` normal, `^` insert, `$` tty. The # tag has a mode of its own, so the box never changes while a tag is used. file w.txt alpha\nbeta\ngamma +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 w.txt wait 8000 Newcol Joincol wait 8000 w.txt diff --git a/test/snapshots/themesel.snap b/test/snapshots/themesel.snap index 04a95d23..734b9fdd 100644 --- a/test/snapshots/themesel.snap +++ b/test/snapshots/themesel.snap @@ -16,6 +16,8 @@ # read with snapstyle, so this proves the generated themes are real data and # not just names in a list. lines t.txt 40 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 t.txt wait 8000 t.txt stable 700 20000 diff --git a/test/snapshots/tinywin.snap b/test/snapshots/tinywin.snap index 3d22d583..388609e4 100644 --- a/test/snapshots/tinywin.snap +++ b/test/snapshots/tinywin.snap @@ -2,6 +2,8 @@ # every pane in a column at least one row, so `y` walked off the bottom and # renderPane got a rect outside the surface (assert). Panes that no longer fit # now get h = 0 and simply do not render until the window grows back. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 wait 8000 $ key a-n diff --git a/test/snapshots/topbarexec.snap b/test/snapshots/topbarexec.snap index 516a23b3..9f62af46 100644 --- a/test/snapshots/topbarexec.snap +++ b/test/snapshots/topbarexec.snap @@ -14,6 +14,8 @@ # So both halves are pinned here, against the same live selection: the no-arg # builtin runs, and the arg-taking one still gets the word. file zmarkfile.txt hay ZQNEEDLE stack +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 21 90 -n 3 wait 8000 zmarkfile.txt stable 700 20000 diff --git a/test/snapshots/ttyhelp.snap b/test/snapshots/ttyhelp.snap index 24399848..59e73af2 100644 --- a/test/snapshots/ttyhelp.snap +++ b/test/snapshots/ttyhelp.snap @@ -1,6 +1,8 @@ # bare boot = tty mode: SPC belongs to the shell, so the topbar Help word is # the only route to the command list. Row 0 is not a pane, so a middle-click # there is dispatched before any pane's mode is consulted. +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 wait 8000 Newcol Joincol stable 700 20000 diff --git a/test/snapshots/ttylook.snap b/test/snapshots/ttylook.snap index 5b82bd89..6eecc419 100644 --- a/test/snapshots/ttylook.snap +++ b/test/snapshots/ttylook.snap @@ -9,6 +9,8 @@ lines big.txt 40 file plain.txt just text dirmk sub +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 120 -n 3 wait 8000 big.txt stable 700 20000 diff --git a/test/snapshots/ttyonly.snap b/test/snapshots/ttyonly.snap index 1008e084..85180b40 100644 --- a/test/snapshots/ttyonly.snap +++ b/test/snapshots/ttyonly.snap @@ -1,5 +1,7 @@ # --tty: a single fullscreen shell already in raw tty mode, prompts visible file t.txt x +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 --tty wait 8000 $ stable 700 20000 diff --git a/test/snapshots/ttytaken.snap b/test/snapshots/ttytaken.snap index a6ccb632..be0bad29 100644 --- a/test/snapshots/ttytaken.snap +++ b/test/snapshots/ttytaken.snap @@ -6,6 +6,8 @@ # the pager's screen changes. (From any pane but a terminal, an Exec always # runs as a command pane: exec.snap and cmdword.snap.) lines big.txt 40 +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 210 -n 1 wait 8000 big.txt stable 700 20000 diff --git a/test/snapshots/windownav.snap b/test/snapshots/windownav.snap index 66509e11..fc26896f 100644 --- a/test/snapshots/windownav.snap +++ b/test/snapshots/windownav.snap @@ -8,6 +8,8 @@ # `cursor=` header: that is the # ACTIVE pane's cursor, so each snap is named by the pane it should land in. file w.txt alpha\nbeta\ngamma +# its clicks and goldens were laid out by the pardes placement rule +config Placement pardes start 31 100 w.txt wait 8000 Newcol Joincol wait 8000 w.txt -- cgit v1.3 From f41d0209f65237568c9f7d527d5989ac8921b74c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 18:14:36 -0300 Subject: Lift takes a style: shadow, surface, rim, glow or auto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user wants Lift to read on dark themes, where a shadow has nothing to darken. `Lift shadow|surface|rim|glow|auto` (`on` is auto, bare flips auto and off): surface lightens the lifted part's ground about 5.5% (Material-dark), rim is a thin soft light off its top and left edges, glow a faint halo in the theme's accent (Chrome.accent, the focused box colour), auto a shadow on a light page and surface with half a rim on a dark one (by the page's luminance). Rim and glow are the soft kernel in a light colour (no pre-warp: coloured light is laid on as is, within §8.2's budget); each style is capped per theme so text keeps min(its contrast, 4.5) (tests: darkening and lightening over the native themes). The style travels in the palette (Chrome.lift_style, one byte on the wire). Config reports and ctl reads `Lift