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 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/effects.md (limited to '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. -- cgit v1.3 From 5dbd21795f6c68bd3c10d409135970e3033364f3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 18:24:18 -0300 Subject: Motion flavours, and Lift is shadow, rim or auto: auto on a dark page recedes the other panes (InactiveDim) Motion off|crisp|smooth|bouncy|playful is one parameter set (animation.Motion) every fx animation reads. One focus spring per pane drives both the lift and the dim, at the flavour's pace; a grid snaps. Lift drops glow and surface. A shadow falls on pane bodies and rails only, capped so text and the selection keep min(contrast, 4.5); rim is a hairline along the focused tag's top. auto is a shadow on a light page; on a dark page the core recedes the unfocused panes' text (InactiveDim, ported from 2e4d97f, default 30 under auto) with the same floor. Tests: focused text contrast is never below unfocused in any style, dim or theme; the focused pane's cells and tag are untouched by a dim. Shared files: src/config.zig, src/builtins.zig, src/ninep/ctl.zig, src/surface.zig, src/detached/wire.zig, docs/config.md, test/snapshots/builtins.snap (one more wheel for the longer help). --- docs/config.md | 15 ++ docs/effects.md | 54 ++++++- src/animation.zig | 139 ++++++++++++++---- src/builtins.zig | 2 +- src/config.zig | 50 +++++-- src/detached/wire.zig | 3 +- src/draw.zig | 330 +++++++++++++++++++++++++++++++++++++++++-- src/gui/gui.zig | 194 ++++++++++++++++++++----- src/ninep/ctl.zig | 2 + src/surface.zig | 3 + test/snapshots/builtins.snap | 3 + 11 files changed, 711 insertions(+), 84 deletions(-) (limited to 'docs/effects.md') diff --git a/docs/config.md b/docs/config.md index 16fd59e5..83fbd0eb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -527,6 +527,21 @@ the chain empty the pass is bypassed. CRT works in linear light with restrained scanlines, mask, bloom and vignette, and no curvature, so clicks land where they are drawn. +The focused pane can stand off the page (SDL GUI, off by default): + +```text +Lift shadow a soft drop shadow, on the other panes' bodies only +Lift rim a hairline just above the focused tag (light, or shade on a light page) +Lift auto a shadow on a light page; on a dark one the others recede +Lift off +InactiveDim 30 the unfocused panes' text fades 30% toward its ground +Motion smooth off, crisp, smooth, bouncy or playful +``` + +`InactiveDim` works everywhere (a grid shows it at once) and never takes a +pair below its own contrast or 4.5. Under `Lift auto` on a dark page it is 30 +while unset. `Motion` sets how every such effect moves: see docs/effects.md. + `EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's build-embedded source paths under `/virtual`. Look opens each full file; no checkout is needed, but the build must carry them (`-Dembed-sources=true`, diff --git a/docs/effects.md b/docs/effects.md index 44f3e949..fec2d13b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -1,5 +1,15 @@ # Effects: feel reviews +**The rule above all the others:** no effect may alter or cover a focus +indicator (the focused pane's tag colour, its grip, the cursor, the +selection) or reduce its contrast. The effects are sugar; the indicators are +how a person knows where they are. Every G stage is reviewed against this. +Lift, for one, falls only on pane bodies and rails, never on a tag, a grip or +a header, and its strength is capped so text and the selection keep min(their +contrast, 4.5) (tests in src/gui/gui.zig). A dim touches only the unfocused +panes, with the same floor, and the focused pane's text is never at less +contrast than theirs (tests in src/draw.zig). + 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 @@ -9,10 +19,42 @@ why. The verdict is the user's. |---|---|---|---|---| | 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 | +| G1 lift | `Lift shadow\|rim\|auto` (off), `InactiveDim ` | opt-in until the focus-lift default is decided | lift-shots-2 (off, shadow, rim, auto on acme, dusk, forge); frame series + mp4 per Motion flavour | shadow on acme: keep as is. Round 1 dropped glow and surface (surface lowered the focused text's contrast) and made rim a hairline just above the focused tag (light on a dark page, shade on a light one). auto on a dark page recedes the others (InactiveDim 30) | + +## Motion flavours + +`Motion off|crisp|smooth|bouncy|playful` (default smooth) is one parameter set +(animation.Motion) every fx animation reads, so a flavour is data, not a +branch in each effect. Input is never blocked, and a new target always +retargets from the current position and velocity. + +| flavour | timing (ω) | follow-through (ζ) | anticipation | squash/stretch | secondary lag | arcs | +|---|---|---|---|---|---|---| +| off | instant | — | — | — | — | — | +| crisp | 44 | 1 (none) | — | — | in step | — | +| smooth | 26 | 1 (none) | — | — | 0.85 | 0.04 | +| bouncy | 30 | 0.55 (overshoots ~12%) | — | 0.12 | 0.8 | 0.08 | +| playful | 24 | 0.42 (overshoots ~23%) | 0.08 of the move | 0.25 | 0.7 | 0.15 | + +Which principles each motion uses: + +- **Lift** (G1): timing, slow in / slow out (the spring), follow-through + (bouncy and playful lift past full and settle back), anticipation (playful + dips before it rises; below zero nothing is drawn, so it reads as a beat + before the lift), staging (only the focused pane lifts; nothing else moves + with a focus change). Squash, stretch, arcs and secondary lag have nothing + to act on in a lift. +- **Cursor** (G3, next): designed around the same set: glide on the spring, + stretch along the path, an arc on long jumps, the trailing corners as the + secondary action. + -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. +Notes on G1: the lift and the dim run on one focus spring per pane, at the +Motion flavour's pace, sampled at each frame's own time; while it moves the +GUI draws at the display's rate, and a grid snaps. A notice floats on a lift +of 1 while a shadow or rim is on. InactiveDim under `Lift auto` defaults to +30. The fade is in linear light, so on a dark page it is gentle: at 30 +forge's text goes from 15.5:1 to 11.2:1 and dusk's from 6.4:1 to 4.8:1, +plainly quieter and still easy reading; at 10 forge barely moves (14.1:1). +On a light page the same percent bites far harder (acme: 7.0:1 at 10, the +4.5 floor at 30), which is why `auto` shades there instead of dimming. diff --git a/src/animation.zig b/src/animation.zig index ca933b82..b02a242b 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -586,19 +586,58 @@ 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; +/// How the fx track moves (`Motion `, docs/effects.md): one set of +/// parameters a flavour picks, read by every animation that follows the +/// classic principles, so a flavour is data and no effect branches on it. +pub const Motion = struct { + pub const Flavour = enum(u8) { off, crisp, smooth, bouncy, playful }; + + /// Nothing moves: every change lands at once (reduced motion). + instant: bool = false, + /// Timing: the spring's natural frequency, rad/s; slow in / slow out + /// come from the spring itself. + omega: f32 = 36, + /// Follow-through: 1 is critically damped (no overshoot); below 1 it + /// settles past its target and back. + zeta: f32 = 1, + /// Anticipation: a move from rest first winds up the other way, by + /// this fraction of the distance. + anticipate: f32 = 0, + /// Squash and stretch: how much a moving thing lengthens along its + /// velocity and squashes as it lands (G3's cursor, G7's panes). + stretch: f32 = 0, + /// Secondary action: how far behind a follower trails its leader (the + /// shadow behind a pane, a notice's text behind its chip), as the + /// follower's frequency over the leader's; 1 is in step. + follow: f32 = 1, + /// Arcs: how far a two-dimensional path bows, as a fraction of its + /// length (the cursor's glide). + arc: f32 = 0, + + pub fn of(flavour: Flavour) Motion { + return switch (flavour) { + .off => .{ .instant = true }, + // Productivity: quick and exact, nothing past its mark. + .crisp => .{ .omega = 44 }, + // A longer, softer ease in and out, still without a bounce. + .smooth => .{ .omega = 26, .follow = 0.85, .arc = 0.04 }, + // Settles past and back, and stretches with its speed. + .bouncy => .{ .omega = 30, .zeta = 0.55, .stretch = 0.12, .follow = 0.8, .arc = 0.08 }, + // A cartoon's: winds up, overshoots, stretches, follows through. + .playful => .{ .omega = 24, .zeta = 0.42, .anticipate = 0.08, .stretch = 0.25, .follow = 0.7, .arc = 0.15 }, + }; + } +}; - omega: f32 = state_change, +/// A value that follows its target on a damped spring, in closed form +/// (docs/render-pipeline.md §7.2), critically damped or under: a new target +/// taken partway keeps the value AND its velocity, so a quick run of changes +/// never snaps or starts over. A move from rest can wind up first +/// (Motion.anticipate). Settled, it is exactly its target and asks for no +/// frames. +pub const Spring = struct { + omega: f32 = 36, + zeta: f32 = 1, target: f32 = 0, /// The value and velocity (per second) at `from_ns`. from: f32 = 0, @@ -613,20 +652,41 @@ pub const Spring = struct { 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 }; + const v0 = spring.velocity; + if (spring.zeta >= 1) { + const c = v0 + w * x0; + const decay = @exp(-w * t); + return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (v0 - w * t * c) * decay }; + } + // Underdamped: it rings about its target as it decays. + const z = spring.zeta; + const wd = w * @sqrt(1 - z * z); + const b = (v0 + z * w * x0) / wd; + const decay = @exp(-z * w * t); + const cos = @cos(wd * t); + const sin = @sin(wd * t); + return .{ + .value = spring.target + decay * (x0 * cos + b * sin), + .velocity = decay * ((b * wd - z * w * x0) * cos - (x0 * wd + z * w * b) * sin), + }; } 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 { + /// Heads for `target` from wherever it is at `now_ns`, moving as it was, + /// at the pace and damping `motion` gives: at once when it is `off`. + pub fn retarget(spring: *Spring, target: f32, now_ns: u64, motion: Motion) void { if (target == spring.target) return; + if (motion.instant) { + spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = 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 }; + // From rest, a wind-up: set off the other way, just a little. + const kick: f32 = if (spring.settled) -motion.anticipate * (target - state.value) * motion.omega * 2 else 0; + spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false }; } /// Settles once it is within half a percent of its target and barely @@ -643,7 +703,8 @@ pub const Spring = struct { 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); + const crisp = Motion.of(.crisp); + spring.retarget(1, 0, crisp); var last: f32 = 0; var t: u64 = 0; while (spring.step(t)) : (t += frame_ns) { @@ -651,21 +712,51 @@ test "a spring settles without overshoot and keeps its velocity when retargeted" 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.expect(t >= 150 * 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 + // Back the other way partway up: it carries on up for a moment, then // turns, never jumping. spring = .{}; - spring.retarget(1, 0); + spring.retarget(1, 0, crisp); const before = spring.at(60 * ms); - spring.retarget(0, 60 * ms); + spring.retarget(0, 60 * ms, crisp); 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); } +test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" { + const ms = std.time.ns_per_ms; + var spring: Spring = .{}; + spring.retarget(1, 0, Motion.of(.off)); + try std.testing.expect(spring.settled); + try std.testing.expectEqual(@as(f32, 1), spring.value(0)); + for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| { + spring = .{}; + spring.retarget(1, 0, Motion.of(flavour)); + var peak: f32 = 0; + var low: f32 = 0; + var t: u64 = 0; + var last = spring.at(0); + while (spring.step(t)) : (t += ms) { + const now = spring.at(t); + peak = @max(peak, now.value); + low = @min(low, now.value); + // Continuous, a millisecond at a time: no jump anywhere. + try std.testing.expect(@abs(now.value - last.value) < 0.05); + last = now; + } + try std.testing.expect(t < 1200 * ms); + switch (flavour) { + .crisp, .smooth => try std.testing.expect(peak <= 1.0005 and low >= 0), + .bouncy => try std.testing.expect(peak > 1.05 and low >= 0), + .playful => try std.testing.expect(peak > 1.05 and low < -0.01), + .off => unreachable, + } + } +} + /// 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 7b2aca38..e49deb71 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -172,7 +172,7 @@ pub const registry = struct { if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default .toggle, .transition, .scene, .dump_dir, .lift => false, - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim => true, }; unreachable; } diff --git a/src/config.zig b/src/config.zig index f7e83e91..ed7bee92 100644 --- a/src/config.zig +++ b/src/config.zig @@ -116,6 +116,8 @@ pub const leader_path = paths: { 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); + table.set(.Motion, null); + table.set(.InactiveDim, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -623,6 +625,13 @@ pub const Runtime = struct { /// docs/render-pipeline.md §9.1), or off. Opt-in until the focus lift's /// default is decided. Only a pixel shell draws it (`Capabilities.lift`). lift: LiftStyle = .off, + /// Blends the unfocused panes' text toward each cell's own ground, + /// percent (0 off): the focused pane wins by comparison, its tag tint + /// untouched. Kept above min(each cell's contrast, 4.5). + inactive_dim: u8 = 0, + /// How the fx track's animations move (animation.Motion): off for + /// reduced motion. + motion: animation.Motion.Flavour = .smooth, /// Builtins announce themselves on the message row. verbose: bool = true, @@ -670,12 +679,15 @@ pub const Runtime = struct { pub const ShaderAnimation = enum { off, on, always }; - /// shadow: a soft drop shadow. surface: the lifted pane's ground - /// lightens, as a raised surface does on a dark theme. rim: a soft light - /// along its top and left edges. glow: a faint halo in the theme's - /// accent. auto: shadow on a light page, surface and a faint rim on a - /// dark one. - pub const LiftStyle = enum(u8) { off, shadow, surface, rim, glow, auto }; + /// shadow: a soft drop shadow. rim: a light hairline along the top of + /// the focused pane's tag rows. auto: a shadow on a light page; on a + /// dark one, where a shadow has nothing to darken, the other panes + /// recede instead (InactiveDim, at `auto_dim` unless it is set). + pub const LiftStyle = enum(u8) { off, shadow, rim, auto }; + /// What `Lift auto` dims the unfocused panes by on a dark page when + /// InactiveDim is 0: enough to read at a glance (the user's own init + /// sets 35), not so much that they stop being worth reading. + pub const auto_dim: u8 = 30; /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { @@ -846,6 +858,10 @@ pub const Runtime = struct { dump_dir, /// A lift style, `on` (auto) or `off`; bare flips auto and off. lift, + /// A motion flavour. + motion, + /// A percentage, 0 through 100. + inactive_dim, transition: animation.Transition, scene: Scene, /// A Shadertoy file into the post chain or out of it; `off` empties @@ -881,6 +897,8 @@ pub const Runtime = struct { .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, .{ .word = "Lift", .action = .lift, .availability = .lift }, + .{ .word = "Motion", .action = .motion }, + .{ .word = "InactiveDim", .action = .inactive_dim }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, @@ -1074,6 +1092,16 @@ pub const Runtime = struct { else std.meta.stringToEnum(LiftStyle, text) orelse return false; }, + .motion => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + state.motion = std.meta.stringToEnum(animation.Motion.Flavour, text) orelse return false; + }, + .inactive_dim => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%"); + const percent = std.fmt.parseInt(u8, text, 10) catch return false; + if (percent > 100) return false; + state.inactive_dim = percent; + }, .theme, .font => return false, } return true; @@ -1247,6 +1275,8 @@ pub const Runtime = struct { try out.print("{s} {s}\n", .{ setting.word, @tagName(state.lift) }) else try out.print("{s} unsupported\n", .{setting.word}), + .motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }), + .inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }), }; if (context.startup_config_path) |path| @@ -1514,7 +1544,9 @@ pub const Runtime = struct { .dump_dir => "~/dumps", .shader => "~/crt.glsl", .shader_animation => "always", - .lift => "glow", + .lift => "rim", + .motion => "playful", + .inactive_dim => "35", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1565,7 +1597,9 @@ pub const Runtime = struct { }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", - .lift => "Lift glow\n", + .lift => "Lift rim\n", + .motion => "Motion playful\n", + .inactive_dim => "InactiveDim 35\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 0a066410..2a2ccc6b 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -254,7 +254,8 @@ pub const Frame = struct { 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; + // A springy flavour overshoots, and a wind-up dips below. + if (!(region.lift >= -1 and region.lift <= 2)) 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; diff --git a/src/draw.zig b/src/draw.zig index 98932c23..d1d4c11b 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -18,6 +18,7 @@ const mouse = @import("mouse.zig"); const tagline = @import("tagline.zig"); const body_layer = @import("body_layer.zig"); const Messages = @import("Messages.zig"); +const builtins = @import("builtins.zig"); const Color = @import("surface.zig").Color; const FontRole = @import("surface.zig").FontRole; const CellStyle = @import("surface.zig").CellStyle; @@ -121,19 +122,24 @@ pub fn place(p: *Pardes, s: *Surface) void { // 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. + const lift = liftStyle(p); 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.* = .{}; } - // At this frame's own time, not the stepped animations' 16 ms grid. - spring.retarget(if (p.settings.lift != .off and id == p.active) 1 else 0, p.now_ns); + // How focused it is, which both a lift and a dim follow: at this + // frame's own time, not the stepped animations' 16 ms grid. A grid + // snaps (it has no in-between), and so does nothing that shows it. + const shown = lift != .off or inactiveDim(p) != 0; + const motion = animation.Motion.of(if (shown and builtins.capabilities.lift) p.settings.motion else .off); + spring.retarget(if (id == p.active) 1 else 0, p.now_ns, motion); _ = 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.now_ns), - .notice => region.lift = if (p.settings.lift != .off) 1 else 0, + .grip, .tag, .body, .rail => region.lift = if (lift != .off) p.lifts[region.owner].value(p.now_ns) else 0, + .notice => region.lift = if (lift != .off) 1 else 0, else => {}, }; if (p.drag == .column_move) rail: { @@ -154,6 +160,162 @@ pub fn place(p: *Pardes, s: *Surface) void { } } +/// The Lift style as drawn: `auto` is a shadow on a light page, and on a +/// dark one, where a shadow has nothing to darken, no lift at all (the other +/// panes recede instead, `inactiveDim`). +pub fn liftStyle(p: *const Pardes) config.Runtime.LiftStyle { + return switch (p.settings.lift) { + .auto => if (darkPage(p)) .off else .shadow, + else => |style| style, + }; +} + +/// How far, in percent, the unfocused panes' text fades toward its ground: +/// InactiveDim, or under `Lift auto` on a dark page `auto_dim` while that +/// is unset. +pub fn inactiveDim(p: *const Pardes) u8 { + if (p.settings.inactive_dim != 0) return p.settings.inactive_dim; + return if (p.settings.lift == .auto and darkPage(p)) config.Runtime.auto_dim else 0; +} + +fn darkPage(p: *const Pardes) bool { + return if (p.theme().bg) |bg| luminance(bg) < 0.18 else true; +} + +/// sRGB channel to linear light. +const linear: [256]f32 = blk: { + @setEvalBranchQuota(100_000); + var table: [256]f32 = undefined; + for (&table, 0..) |*out, i| { + const c = @as(f64, @floatFromInt(i)) / 255; + out.* = @floatCast(if (c <= 0.04045) c / 12.92 else std.math.pow(f64, (c + 0.055) / 1.055, 2.4)); + } + break :blk table; +}; + +fn luminance(rgb: [3]u8) f32 { + return 0.2126 * linear[rgb[0]] + 0.7152 * linear[rgb[1]] + 0.0722 * linear[rgb[2]]; +} + +fn contrast(a: [3]u8, b: [3]u8) f32 { + const x = luminance(a); + const y = luminance(b); + return (@max(x, y) + 0.05) / (@min(x, y) + 0.05); +} + +/// Pane `id`'s text, `amount` (0 to 1) of the way to its ground, on the grid +/// and on every layer of it: its tag, its body, its notices. Its grip keeps +/// its colour, which already says it is not the focused one. +fn recede(p: *Pardes, s: *Surface, id: usize, amount: f32) void { + const th = p.theme(); + const chrome = p.chromeTheme(); + const r = p.rects[id]; + const palette = th.palette; + var memo: Memo = .{}; + const text_x = r.x + @min(config.GUTTER, r.w); + var row = r.y; + while (row < @min(@as(u32, r.y) + r.h, s.rows)) : (row += 1) { + var col = text_x; + while (col < @min(@as(u32, r.x) + r.w, s.cols)) : (col += 1) { + const cell = s.at(col, row); + if (cell.default) continue; + const tagline_role = cell.style.font_role == .tagline; + fadeStyle(&cell.style, if (tagline_role) chrome.tag_fg else th.fg, if (tagline_role) chrome.tag_bg else th.bg, palette, amount, &memo); + } + } + const body = &s.body_layers[id]; + if (body.rows != 0) for (body.cells[0..@as(usize, body.cols) * body.rows]) |*cell| fadeStyle(&cell.style, th.fg, th.bg, palette, amount, &memo); + const tag = &s.tag_layers[id]; + if (tag.rows != 0) for (tag.cells[0..@as(usize, tag.cols) * tag.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, tag.bg, palette, amount, &memo); + for (0..Pane.Notices.max) |i| { + const notice = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i]; + if (notice.rows != 0) for (notice.cells[0..@as(usize, notice.cols) * notice.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, notice.bg, palette, amount, &memo); + } +} + +/// The few ink and paper pairs a pane has, each faded once a frame. +const Memo = struct { + keys: [32]?[6]u8 = @splat(null), + values: [32][3]u8 = undefined, + + fn faded(memo: *Memo, ink: [3]u8, paper: [3]u8, amount: f32) [3]u8 { + const key = ink ++ paper; + const slot = (@as(usize, ink[0]) *% 31 +% ink[1] *% 7 +% ink[2] +% @as(usize, paper[0]) *% 13 +% paper[1] *% 3 +% paper[2]) % memo.keys.len; + if (memo.keys[slot]) |held| if (std.mem.eql(u8, &held, &key)) return memo.values[slot]; + // Contrast falls as the ink nears its paper: the most of `amount` + // that keeps the pair at its own contrast or 4.5, whichever is lower. + const floor = @min(contrast(ink, paper), 4.5); + var t = amount; + if (contrast(blend(ink, paper, t), paper) < floor) { + var lo: f32 = 0; + var hi: f32 = t; + for (0..8) |_| { + const mid = (lo + hi) / 2; + if (contrast(blend(ink, paper, mid), paper) >= floor) lo = mid else hi = mid; + } + t = lo; + } + memo.keys[slot] = key; + memo.values[slot] = blend(ink, paper, t); + return memo.values[slot]; + } +}; + +/// A cell's ink blended `amount` of the way toward its paper, never below +/// the pair's own contrast or 4.5, whichever is lower (docs/effects.md): +/// faint resolved first, reverse honoured, as InactiveDim always did. +fn fadeStyle(style: *CellStyle, default_fg: ?[3]u8, default_bg: ?[3]u8, palette: ?[16][3]u8, amount: f32, memo: *Memo) void { + if (style.invisible) return; + var fg = resolved(style.fg, default_fg, palette) orelse return; + const bg = resolved(style.bg, default_bg, palette) orelse return; + if (style.dim) for (&fg) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + const ink = if (style.reverse) bg else fg; + const paper = if (style.reverse) fg else bg; + const rgb = memo.faded(ink, paper, amount); + if (style.reverse) { + style.bg = .{ .rgb = rgb }; + if (style.dim) style.fg = .{ .rgb = fg }; + } else style.fg = .{ .rgb = rgb }; + style.dim = false; +} + +/// `t` of the way from one colour to the other in linear light (§8.2), so a +/// coloured ink does not muddy on its way to the paper. +fn blend(from: [3]u8, to: [3]u8, t: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, from, to) |*o, a, b| { + const l = linear[a] * (1 - t) + linear[b] * t; + o.* = encoded[@intFromFloat(@round(std.math.clamp(l, 0, 1) * (encoded.len - 1)))]; + } + return out; +} + +/// Linear light back to an sRGB channel, in 4096 steps. +const encoded: [4096]u8 = blk: { + @setEvalBranchQuota(1_000_000); + var table: [4096]u8 = undefined; + for (&table, 0..) |*out, i| { + const l = @as(f64, @floatFromInt(i)) / 4095; + const c = if (l <= 0.0031308) l * 12.92 else 1.055 * std.math.pow(f64, l, 1.0 / 2.4) - 0.055; + out.* = @intFromFloat(@round(c * 255)); + } + break :blk table; +}; + +fn resolved(color: Color, default: ?[3]u8, palette: ?[16][3]u8) ?[3]u8 { + return switch (color) { + .rgb => |rgb| rgb, + .default => default, + // A theme's own sixteen, as the painters give them (Terminal.palColor). + .index => |index| if (palette != null and index < 16) palette.?[index] else if (comptime pardes.terminal_panes) blk: { + const rgb = @import("ghostty-vt").color.default[index]; + break :blk .{ rgb.r, rgb.g, rgb.b }; + } else null, + }; +} + pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { panes.File.refreshHighlights(p); const s = &p.surface; @@ -201,7 +363,9 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .scroll_track = chrome.scroll_track, .scroll_thumb = chrome.scroll_thumb, .accent = chrome.box, - .lift_style = @intFromEnum(p.settings.lift), + .sel_fg = p.theme().sel_fg, + .sel_bg = p.theme().sel_bg, + .lift_style = @intFromEnum(liftStyle(p)), }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. @@ -221,6 +385,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (region.kind == .notice) try joinNotice(p, s, arena, region); } + for (p.panes, p.lifts, 0..) |slot, spring, id| { + if (slot == null or id == p.active) continue; + const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - spring.value(p.now_ns), 0, 1); + if (amount > 0.001) recede(p, s, id, amount); + } + try paintHeaders(p, s, arena); switch (p.drag) { @@ -1128,18 +1298,25 @@ 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(); + // A grid has no in-between: there a lift snaps. + if (!builtins.capabilities.lift) return error.SkipZigTest; _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const ms = std.time.ns_per_ms; + p.settings.lift = .shadow; p.advance(1000 * ms); _ = try p.render(arena.allocator()); + p.advance(3000 * ms); + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); - p.settings.lift = .shadow; + p.active = 0; 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); + p.advance(3000 * ms + @as(u64, @intCast(i)) * 6_944_444); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); lift.* = for (s.regionList()) |region| { @@ -1149,13 +1326,150 @@ test "a lift is sampled at each frame's own time, and asks for frames only while } 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); + p.advance(5000 * 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()); } +/// The contrast a pane's first body glyph `g` is drawn at, on the grid. +fn glyphContrast(p: *Pardes, s: *Surface, id: usize, g: u8) f32 { + const r = p.rects[id]; + const th = p.theme(); + var row = p.bodyTop(p.panes[id].?, r); + while (row < r.y + r.h) : (row += 1) { + var col = r.x + config.GUTTER; + while (col < r.x + r.w) : (col += 1) { + const cell = s.at(col, row); + if (cell.default or cell.len != 1 or cell.text[0] != g) continue; + var fg = resolved(cell.style.fg, th.fg orelse .{ 255, 255, 255 }, th.palette).?; + const bg = resolved(cell.style.bg, th.bg orelse .{ 0, 0, 0 }, th.palette).?; + if (cell.style.dim) for (&fg) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + return contrast(fg, bg); + } + } + unreachable; +} + +test "a focused pane's text is never at less contrast than an unfocused one's, in any Lift, dim or theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const s_ns = std.time.ns_per_s; + var now: u64 = s_ns; + for (0..pardes.themes.len) |theme| { + colors.setThemeIndex(p, theme); + for (std.enums.values(config.Runtime.LiftStyle)) |style| for ([_]u8{ 0, 100 }) |dim| { + p.settings.lift = style; + p.settings.inactive_dim = dim; + var seen: [2]f32 = undefined; + for (&seen, [_]usize{ 0, 1 }) |*c, focus| { + p.active = focus; + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + now += 2 * s_ns; + p.advance(now); + _ = arena.reset(.retain_capacity); + c.* = glyphContrast(p, try p.render(arena.allocator()), 0, 't'); + } + try std.testing.expect(seen[0] >= seen[1] - 1e-3); + // And the unfocused one is still legible. + try std.testing.expect(seen[1] >= @min(seen[0], 4.5) - 2e-2); + }; + } +} + +test "a dim leaves the focused pane's tag, grip and cells exactly as they were" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.active = 0; + p.advance(std.time.ns_per_s); + const plain = try p.render(arena.allocator()); + const r = p.rects[0]; + var before: std.ArrayList(Cell) = .empty; + defer before.deinit(std.testing.allocator); + for (r.y..r.y + r.h) |row| try before.appendSlice(std.testing.allocator, plain.cells[row * plain.cols + r.x ..][0..r.w]); + const tag_bg = plain.tag_layers[0].bg; + const tag_cells = try std.testing.allocator.dupe(Cell, plain.tag_layers[0].cells[0 .. @as(usize, plain.tag_layers[0].cols) * plain.tag_layers[0].rows]); + defer std.testing.allocator.free(tag_cells); + p.settings.inactive_dim = 100; + p.advance(3 * std.time.ns_per_s); + const dimmed = try p.render(arena.allocator()); + var i: usize = 0; + for (r.y..r.y + r.h) |row| for (dimmed.cells[row * dimmed.cols + r.x ..][0..r.w]) |cell| { + try std.testing.expect(cell.visuallyEqual(&before.items[i])); + i += 1; + }; + try std.testing.expectEqual(tag_bg, dimmed.tag_layers[0].bg); + for (tag_cells, dimmed.tag_layers[0].cells[0..tag_cells.len]) |a, b| try std.testing.expect(a.visuallyEqual(&b)); +} + +test "a dim never takes a pair below its own contrast or 4.5" { + for (pardes.themes) |theme| { + const fg = theme.fg orelse continue; + const bg = theme.bg orelse continue; + for ([_]bool{ false, true }) |reverse| for ([_]bool{ false, true }) |faint| { + var style: CellStyle = .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .reverse = reverse, .dim = faint }; + var ink = fg; + if (faint) for (&ink) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + var memo: Memo = .{}; + fadeStyle(&style, null, null, null, 1, &memo); + const after = contrast(style.fg.rgb, style.bg.rgb); + try std.testing.expect(after >= @min(contrast(ink, bg), 4.5) - 2e-2); + }; + } +} + +test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixteen" { + var memo: Memo = .{}; + var low: CellStyle = .{ .fg = .{ .rgb = .{ 0x80, 0x80, 0x80 } }, .bg = .{ .rgb = .{ 0x60, 0x60, 0x60 } } }; + fadeStyle(&low, null, null, null, 1, &memo); + try std.testing.expectEqual([3]u8{ 0x80, 0x80, 0x80 }, low.fg.rgb); + // acme's bright white is black: faded, it stays a grey, never a white. + const acme = for (pardes.themes) |theme| { + if (std.mem.eql(u8, theme.name, "acme")) break theme; + } else unreachable; + var bright: CellStyle = .{ .fg = .{ .index = 15 } }; + memo = .{}; + fadeStyle(&bright, acme.fg, acme.bg, acme.palette, 0.3, &memo); + const rgb = bright.fg.rgb; + try std.testing.expect(rgb[0] == rgb[1] and rgb[0] < 0xc0); + // Reverse: the paper moves, the ink stays. + var reverse: CellStyle = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = .{ 255, 255, 255 } }, .reverse = true }; + memo = .{}; + fadeStyle(&reverse, null, null, null, 0.3, &memo); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, reverse.fg.rgb); + try std.testing.expect(reverse.bg.rgb[0] < 255); +} + +test "with no lift and no dim a focus switch asks for no frames" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.advance(std.time.ns_per_s); + _ = try p.render(arena.allocator()); + p.active = 0; + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + try std.testing.expect(!p.continuous()); +} + 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 5d9783e3..d8bae6dc 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7448,55 +7448,90 @@ fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f const LiftCeilings = struct { shadow: f32, white: f32, - accent: f32, + /// A rim is light on a dark page and shade on a light one, where light + /// would not show. + rim: [3]u8, + /// Over the text on the page, and over a selection's text on its own + /// ground: the lower of the two. fn of(chrome: *const pardes.Chrome) LiftCeilings { const fg = chrome.fg orelse fg_default; const bg = chrome.page orelse bg_default; - return .{ .shadow = shadowCeiling(fg, bg), .white = lightenCeiling(fg, bg, .{ 255, 255, 255 }), .accent = lightenCeiling(fg, bg, chrome.accent) }; + return .{ + .shadow = @min(shadowCeiling(fg, bg), shadowCeiling(chrome.sel_fg, chrome.sel_bg)), + .white = @min(lightenCeiling(fg, bg, .{ 255, 255, 255 }), lightenCeiling(chrome.sel_fg, chrome.sel_bg, .{ 255, 255, 255 })), + .rim = if (luminance(bg) < 0.18) .{ 255, 255, 255 } else .{ 0, 0, 0 }, + }; } }; /// What stands a lifted part off the page, `lift` 0 to 1 (a pane's spring, -/// a notice's 1), in the style chosen; `auto` reads the page: a shadow on a -/// light one, a lighter surface and a faint rim on a dark one, where a -/// shadow has nothing to darken. -fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, chrome: *const pardes.Chrome, ceilings: LiftCeilings, caster: [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { +/// a notice's 1), in the style chosen: a soft shadow, or a rim along the +/// top of the focused pane's tag rows (`rim_edge`, a pane's only: +/// never a notice's). +fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, ceilings: LiftCeilings, caster: [4]f32, rim_edge: ?[3]f32, bodies: []const [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { if (lift <= 0.001) return; const h: f32 = @floatFromInt(g.cell_h); - const dark_page = luminance(chrome.page orelse bg_default) < 0.18; - const parts: struct { shadow: bool = false, surface: bool = false, rim: f32 = 0, glow: bool = false } = switch (style) { + // The core sends `auto` already resolved (draw.liftStyle). + const shadow = switch (style) { .off => return, - .shadow => .{ .shadow = true }, - .surface => .{ .surface = true }, - .rim => .{ .rim = 1 }, - .glow => .{ .glow = true }, - .auto => if (dark_page) .{ .surface = true, .rim = 0.5 } else .{ .shadow = true }, + .shadow, .auto => true, + .rim => false, + }; + if (shadow) { + // Nothing of a shadow may touch a focus indicator (docs/effects.md): + // it falls on pane bodies and rails, never a tag, a grip or a header. + const first = g.decor.items.len; + try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); + try clipToBodies(g, gpa, first, bodies); + } + // A hairline just above the focused tag rows, never on them: it adds to + // the indicator without changing its colour. + if (style == .rim) if (rim_edge) |edge| { + const light = ceilings.rim[0] == 255; + const alpha = @min(Lift.rim * lift, if (light) ceilings.white else ceilings.shadow); + try addDecor(g, gpa, edge[0], edge[2] - 1, edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h); }; - if (parts.surface) { - // A raised surface catches more light: its ground, and all on it, - // a few percent lighter, in whole pixels. - const a = @min(Lift.surface * lift, ceilings.white); - if (a > 0.002) try addDecor(g, gpa, caster[0], caster[1], caster[2], caster[3], .{ 255, 255, 255 }, a, track, win_w, win_h); - } - if (parts.shadow) try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); - // Light from above and to the left: a thin halo off the top and left - // edges, gone by the bottom and the right. - if (parts.rim > 0) try addSoft(g, gpa, caster, Lift.rim_sigma * h * lift, -Lift.rim_offset * h * lift, -Lift.rim_offset * h * lift, @min(Lift.rim * parts.rim * lift, ceilings.white), .{ 255, 255, 255 }, track, win_w, win_h); - if (parts.glow) try addSoft(g, gpa, caster, Lift.glow_sigma * h * lift, 0, 0, @min(Lift.glow * lift, ceilings.accent), chrome.accent, track, win_w, win_h); } -/// The lighter styles' strengths, in the §8.2 budget: coloured light at -/// most 25%, a surface a few percent. +/// A rim's strength, in the §8.2 budget for coloured light. const Lift = struct { - const surface: f32 = 0.055; - const rim: f32 = 0.22; - const rim_sigma: f32 = 0.12; - const rim_offset: f32 = 0.1; - const glow: f32 = 0.2; - const glow_sigma: f32 = 0.5; + const rim: f32 = 0.35; }; +/// A body region in window pixels. +fn bodyPixels(region: pardes.Region, layout: CellLayout) [4]f32 { + return .{ + @as(f32, @floatFromInt(region.rect.x)) * layout.w, + @as(f32, @floatFromInt(region.rect.y)) * layout.h, + @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w, + @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h, + }; +} + +/// Replaces the decor from `first` on with a copy per pane body it reaches, +/// each clipped to that body (and to what a vertical transition already +/// clipped it to): the lift then never reaches a tag, a grip or a header. +fn clipToBodies(g: *Gui, gpa: std.mem.Allocator, first: usize, bodies: []const [4]f32) !void { + const added = g.decor.items.len - first; + if (added == 0) return; + var own: [8]CellInstance = undefined; + const n = @min(added, own.len); + @memcpy(own[0..n], g.decor.items[first..][0..n]); + g.decor.shrinkRetainingCapacity(first); + for (own[0..n]) |instance| for (bodies) |body| { + var copy = instance; + const vertical = copy.clip_x1 >= copy.clip_x0; + copy.clip_x0 = if (vertical) @max(copy.clip_x0, body[0]) else body[0]; + copy.clip_y0 = if (vertical) @max(copy.clip_y0, body[1]) else body[1]; + copy.clip_x1 = if (vertical) @min(copy.clip_x1, body[2]) else body[2]; + copy.clip_y1 = if (vertical) @min(copy.clip_y1, body[3]) else body[3]; + if (copy.clip_x1 <= copy.clip_x0 or copy.clip_y1 <= copy.clip_y0) continue; + try g.decor.append(gpa, copy); + }; +} + + /// Like shadowCeiling for a light colour laid over at `a` (sRGB blend, as /// the decor pipeline blends a colour that is not black): the largest `a` /// that keeps text and page apart by min(their contrast, 4.5). @@ -7547,11 +7582,11 @@ fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 { return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1); } -test "a rim, a glow or a surface never lightens a native theme's text below its contrast or 4.5" { +test "a rim never lightens a native theme's text below its contrast or 4.5" { for (pardes.themes) |theme| { const fg = theme.fg orelse fg_default; const bg = theme.bg orelse bg_default; - for ([_][3]u8{ .{ 255, 255, 255 }, theme.box }) |light| { + for ([_][3]u8{.{ 255, 255, 255 }}) |light| { const a = @min(Lift.rim, lightenCeiling(fg, bg, light)); const blend = struct { fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 { @@ -7570,6 +7605,74 @@ test "a rim, a glow or a surface never lightens a native theme's text below its } } +test "a lift never touches a tag, a grip or a header, on a light theme or a dark one" { + const gpa = std.testing.allocator; + for ([_][]const u8{ "forge", "daybreak" }) |name| { + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + var line: [64]u8 = undefined; + try std.testing.expect(core.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Theme {s}", .{name}))); + for (0..40) |_| core.update(.tick); + for (std.enums.values(pardes.config.Runtime.LiftStyle)) |style| { + core.settings.lift = style; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a floating notice")); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + const settled = try core.render(core.scratch.allocator()); + _ = s; + // The focus indicator itself: the focused pane's tag is not the + // others' colour, whatever the lift. + var active: ?[3]u8 = null; + var other: ?[3]u8 = null; + for (settled.tagLayers()) |layer| if (layer.rows != 0 and layer.kind == .pane) { + if (layer.id == core.active) active = layer.bg else other = layer.bg; + }; + try std.testing.expect(!std.mem.eql(u8, &active.?, &other.?)); + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.decor = .empty; + defer g.decor.deinit(gpa); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, settled, &groups, fixedCellLayout(&g), 800, 480, true); + var lifted: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 < item.clip_x0) continue; + lifted += 1; + // Inside a body or a rail, all of it. + const inside = for (settled.regionList()) |region| { + if (region.kind != .body and region.kind != .rail) continue; + const r = bodyPixels(region, fixedCellLayout(&g)); + if (item.clip_x0 >= r[0] and item.clip_y0 >= r[1] and item.clip_x1 <= r[2] and item.clip_y1 <= r[3]) break true; + } else false; + try std.testing.expect(inside); + } + const light_page = std.mem.eql(u8, name, "daybreak"); + try std.testing.expectEqual(style == .shadow or (style == .auto and light_page), lifted != 0); + // A rim is one hairline, just above the focused pane's tag row. + var rims: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 >= item.clip_x0) continue; + const top = (1 - item.y0) / 2 * 480; + const height = (item.y0 - item.y1) / 2 * 480; + const on_active_tag = for (settled.regionList()) |region| { + if (region.kind == .tag and region.owner == core.active and @abs(top + 1 - @as(f32, @floatFromInt(region.rect.y)) * 20) < 0.01 and @abs(height - 1) < 0.01) break true; + } else false; + // Not the rule under a column bar, which may run there too. + rims += @intFromBool(on_active_tag and item.fr < 1); + } + try std.testing.expectEqual(@as(usize, @intFromBool(style == .rim)), rims); + } + } +} + test "a shadow never darkens a native theme's text below its contrast or 4.5" { var shaded: usize = 0; for (pardes.themes) |theme| { @@ -7597,6 +7700,13 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const chrome = &surface.chrome; const regions = surface.regionList(); const ceilings = LiftCeilings.of(chrome); + var current_buf: [pardes.MAX_PANES][4]f32 = undefined; + var ncurrent: usize = 0; + for (regions) |part| if ((part.kind == .body or part.kind == .rail) and ncurrent < current_buf.len) { + current_buf[ncurrent] = bodyPixels(part, layout); + ncurrent += 1; + }; + const current_bodies = current_buf[0..ncurrent]; const lift_style = std.enums.fromInt(pardes.config.Runtime.LiftStyle, chrome.lift_style) orelse .off; const notice_lift = for (regions) |region| { if (region.kind == .notice) break region.lift; @@ -7638,6 +7748,14 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g // A closing pane is gone: its chrome is the last frame's. const closing = if (track) |active| active.phase == .closing else false; const placed = if (closing) surface.previous_regions else regions; + // Where a lift may fall: pane bodies, never tags (addLift). + var body_buf: [pardes.MAX_PANES][4]f32 = undefined; + var nbodies: usize = 0; + for (placed) |part| if ((part.kind == .body or part.kind == .rail) and nbodies < body_buf.len) { + body_buf[nbodies] = bodyPixels(part, layout); + nbodies += 1; + }; + const bodies = body_buf[0..nbodies]; const grid = if (closing) surface.previous_cells else surface.cells; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // A single spine runs down each column, through every tag and body. @@ -7663,7 +7781,11 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g 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 addLift(g, gpa, lift_style, chrome, ceilings, box, grip.lift, track, win_w, win_h); + // The top edge of its tag rows, for a rim: x0, x1, y. + const rim_edge: ?[3]f32 = for (placed) |part| { + if (part.owner == grip.owner and part.kind == .tag) break .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w, @as(f32, @floatFromInt(part.rect.y)) * layout.h }; + } else null; + try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h); } } // A notice chip is tagline hung over the body's top rows, so it is @@ -7676,7 +7798,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g 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 addLift(g, gpa, lift_style, chrome, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, notice_lift * layer.fade, track, win_w, win_h); + try addLift(g, gpa, lift_style, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, null, current_bodies, notice_lift * layer.fade, track, win_w, win_h); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 46c451ed..1c21d314 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -306,6 +306,8 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }), .lift => try w.print("{s} {s}\n", .{ word, @tagName(s.lift) }), + .motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }), + .inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }), } } try w.writeAll("LocationsConfig "); diff --git a/src/surface.zig b/src/surface.zig index e7149155..f16993a8 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -398,6 +398,9 @@ pub const Chrome = struct { scroll_thumb: [3]u8 = .{ 0, 0, 0 }, /// The theme's accent (a glow's tint). accent: [3]u8 = .{ 0, 0, 0 }, + /// A selection's text and ground: a lift keeps their contrast too. + sel_fg: [3]u8 = .{ 0, 0, 0 }, + sel_bg: [3]u8 = .{ 0, 0, 0 }, /// How a lifted part stands off the page (config.Runtime.LiftStyle). lift_style: u8 = 0, }; diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index 84de680f..8bf70f36 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -27,6 +27,9 @@ wheel up 110 50 wheel up 110 50 wheel up 110 50 wheel up 110 50 +wheel up 110 50 +wheel up 110 50 +wheel up 110 50 wait 10000 Look enter, right-click wait 10000 Exec tab, middle-click wait 10000 Filter -- cgit v1.3 From 242467eb9f066f80164d534be55c2c4ebc9b5604 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 19:06:01 -0300 Subject: acme looks like acme; every theme gets acme's button, scroll column and 2px rules acme takes plan9port's own colours (acme.c iconinit, draw.h): selections #eeee9e in the body and #9eeeee in the tag, black ink; exec and look sweeps #aa0000 and #006600, white ink; a #99994c scrollbar under a #ffffea thumb; black rules; a #8888cc tag/body rule and column button. Every theme: the grip is acme's button, its tag row's full height, in one scroll column with the scrollbar (rail_px, 12 at a 17px tagline, scaled, times GripWidth, a percent, default 150; same x0/x1 to the pixel): focused clean = solid box; focused dirty = box_dirty filled to a box ring (acme's modbutton); unfocused clean = a box_border ring round the tag's ground; unfocused dirty = box_dirty a pixel inside that ring. The grid, which has no ring, marks dirty with a bold * on box_dirty in the grip's second cell and fills an unfocused grip halfway to its ring. Regions carry a dirty flag. Column buttons are the same shape, solid. 2px rules between columns and stacked panes (inside the tag band's slack), none at the window edge; a 1px tag/body rule halfway between tag and page. Theme data: tag_sel_bg, sweep_bg/fg, tag_rule, rule_px, rail_px, box_border, box_dirty. Chrome: rule_px, tag_rule, rail_px, grip_border, grip_focus_ring; a comptime guard on Chrome's layout forces a wire bump once v8 ships. Structural rules are exempt from the focus-indicator rule (docs/effects.md). Tests: acme widths; stacked rule in the slack at two tagline sizes; every grip-column pixel is band, button, ring or rule, and the button shares the scrollbar's x0/x1 (4 metrics x top/bottom x focus x dirty); the four grip states distinct in every native theme with a non-colour dirty cue in the grid; wire round-trip of the new fields. Shared files: src/colors.zig, src/surface.zig, src/body_layer.zig, src/tagline.zig, src/detached/wire.zig, docs/themes.md, docs/config.md, docs/effects.md, 21 snapshot goldens (the grip cells only), test/gui-goldens.txt. Motion flavours differ by design, and drive notice drops and pane moves --- docs/config.md | 8 +- docs/effects.md | 5 +- docs/themes.md | 6 + src/body_layer.zig | 8 +- src/builtins.zig | 2 +- src/colors.zig | 25 ++ src/config.zig | 20 +- src/detached/wire.zig | 24 +- src/draw.zig | 124 ++++++++- src/gui/gui.zig | 461 ++++++++++++++++++++++++++++++--- src/ninep/ctl.zig | 1 + src/surface.zig | 15 ++ src/tagline.zig | 8 +- src/themes/acme.zig | 33 ++- test/gui-goldens.txt | 36 +-- test/snapshots/chordcut.golden | 10 +- test/snapshots/delask.golden | 6 +- test/snapshots/fileedit.golden | 2 +- test/snapshots/filewatch.golden | 4 +- test/snapshots/lsp-client-edit.golden | 2 +- test/snapshots/lsp-rename.golden | 2 +- test/snapshots/lspcomplete.golden | 10 +- test/snapshots/lspcompletemoved.golden | 2 +- test/snapshots/lsprelpath.golden | 12 +- test/snapshots/msg.golden | 2 +- test/snapshots/multicursor.golden | 4 +- test/snapshots/ninep.golden | 16 +- test/snapshots/pipe.golden | 2 +- test/snapshots/savepane.golden | 2 +- test/snapshots/selregex.golden | 2 +- test/snapshots/tagbottom.golden | 2 +- test/snapshots/tagcmd.golden | 2 +- test/snapshots/tagpath.golden | 10 +- test/snapshots/theme.golden | 8 +- test/snapshots/themesel.golden | 14 +- test/snapshots/yankpaste.golden | 2 +- 36 files changed, 749 insertions(+), 143 deletions(-) (limited to 'docs/effects.md') diff --git a/docs/config.md b/docs/config.md index 83fbd0eb..324b54e9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -315,8 +315,10 @@ name, thirteen required RGB roles (`sel_bg`, `sel_fg`, `tag_bg`, `tag_fg`, `scroll_thumb`), nullable `bg`/`fg`, and either a 16-color RGB `palette` or `null`. Optional nullable RGB roles extend this format: `tag_active_bg`, `tag_active_fg`, `tag_name_fg`, `tag_active_name_fg`, `border`, `lineno_active`, `search_bg`, `search_fg`, -`diagnostic_error`, `diagnostic_warning`, `diagnostic_info`, and -`diagnostic_hint`. Missing new roles use backward-compatible defaults, so +`diagnostic_error`, `diagnostic_warning`, `diagnostic_info`, +`diagnostic_hint`, `tag_sel_bg`, `tag_rule`, `box_border` and `box_dirty`; +`sweep_bg` and `sweep_fg` take three triples, and `rule_px` and `rail_px` a +pixel count. Missing new roles use backward-compatible defaults, so previously exported files remain valid. [Theme customization](themes.md) describes each role and its fallback. RGB values are three-byte arrays, and hex literals are accepted. The original fields remain required; there is no @@ -536,6 +538,8 @@ Lift auto a shadow on a light page; on a dark one the others recede Lift off InactiveDim 30 the unfocused panes' text fades 30% toward its ground Motion smooth off, crisp, smooth, bouncy or playful +GripWidth 150 the grip's button and the scrollbar under it, percent of the + theme's rail_px (12px at a 17px tagline; 50 to 300) ``` `InactiveDim` works everywhere (a grid shows it at once) and never takes a diff --git a/docs/effects.md b/docs/effects.md index fec2d13b..eefa52d3 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -6,7 +6,10 @@ selection) or reduce its contrast. The effects are sugar; the indicators are how a person knows where they are. Every G stage is reviewed against this. Lift, for one, falls only on pane bodies and rails, never on a tag, a grip or a header, and its strength is capped so text and the selection keep min(their -contrast, 4.5) (tests in src/gui/gui.zig). A dim touches only the unfocused +contrast, 4.5) (tests in src/gui/gui.zig). The rule binds effects, not a +theme's structure: the rules between panes (2px, `rule_px`) are the theme's +borders, and the one over a tag band stays in the band's slack, never over +its text. A dim touches only the unfocused panes, with the same floor, and the focused pane's text is never at less contrast than theirs (tests in src/draw.zig). diff --git a/docs/themes.md b/docs/themes.md index 8cc4ff9b..6b218b13 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -114,6 +114,12 @@ so existing exported themes remain valid. | `diagnostic_warning` | Warning text | Same foreground fallback | | `diagnostic_info` | Informational diagnostic text | Same foreground fallback | | `diagnostic_hint` | Hint text | Same foreground fallback | +| `tag_sel_bg` | A tag's selection ground | `sel_bg` | +| `sweep_bg`, `sweep_fg` | Three triples each: the select, exec and look sweeps' ground and ink | `sel_bg` tinted toward `kw`, `str` and `num`, in `sel_fg` | +| `tag_rule` | The one-pixel rule between a pane's tag and its body, quieter than the `rule_px` rules between panes and columns | Halfway between `tag_bg` and the page the shell draws | +| `rule_px` | Pixel shells: the width of the rules between columns, between panes stacked in a column, and under the workspace and column tags, in `border` (the rule between a tag and its body is always one pixel) | 2 | +| `box_border`, `box_dirty` | The grip is acme's button: `box` filled when focused, else a two-pixel ring of `box_border` round the tag's ground; `box_dirty` fills it inside either ring while its file is unsaved (a grid, which has no ring, shows a bold `*` on `box_dirty` in the grip's second cell) | `box_dim`; `diagnostic_warning`, then `num` | +| `rail_px` | Pixel shells: the scroll column's width, the grip's button over the scrollbar (thumb a pixel narrower), in pixels at a 17px tagline, scaled with it | 12, acme's Scrollwid | Native palettes give filenames a distinct hue at approximately the same brightness as the surrounding tag text. This also applies to output names diff --git a/src/body_layer.zig b/src/body_layer.zig index a0195fb8..875f2e36 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -156,6 +156,7 @@ pub fn paintPointerSelection( /// The selection colour of each pointer button's sweep: the select button's /// and the look and exec buttons', each tinted toward its own accent. pub fn buttonTints(th: *const colors.Theme) [3][3]u8 { + if (th.sweep_bg) |own| return own; const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; return .{ colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)), @@ -164,6 +165,11 @@ pub fn buttonTints(th: *const colors.Theme) [3][3]u8 { }; } +/// The ink over each button's sweep. +pub fn buttonInks(th: *const colors.Theme) [3][3]u8 { + return th.sweep_fg orelse @splat(th.sel_fg); +} + /// A pane's body, painted once. A file body with compact tree-sitter /// context rows is painted into its body layer -- more rows than the grid /// has, the context rows at the tagline's height -- and the grid's rows are @@ -312,7 +318,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane // A select sweep over the tag is the tag's selection, painted there // in the tag's own selection colours. if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue; - paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], th.sel_fg); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], buttonInks(th)[b]); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) { diff --git a/src/builtins.zig b/src/builtins.zig index e49deb71..c25311e3 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -172,7 +172,7 @@ pub const registry = struct { if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default .toggle, .transition, .scene, .dump_dir, .lift => false, - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim, .grip_width => true, }; unreachable; } diff --git a/src/colors.zig b/src/colors.zig index aeff7b00..25aa838f 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -63,6 +63,29 @@ pub const Theme = struct { // The current line number is a quiet foreground cue, not a tag-colored // background block. Older themes fall back to their normal gutter ink. lineno_active: ?[3]u8 = null, + /// A tag's selection, where it differs from the body's (acme's #9eeeee). + tag_sel_bg: ?[3]u8 = null, + /// Each pointer button's sweep, select, exec and look (Mouse.Button + /// order), and the ink over it. Unset, tints of `sel_bg` in `sel_fg`. + sweep_bg: ?[3][3]u8 = null, + sweep_fg: ?[3][3]u8 = null, + /// A pixel shell's rules, in pixels (2 unset); the rule between a tag + /// and its body is always one. + rule_px: ?u8 = null, + /// A grip is acme's button: `box` filled when focused, else a ring of + /// `box_border` (unset: `box_dim`) round the tag's ground; `box_dirty` + /// (unset: `diagnostic_warning`, then `num`) fills it, in either ring, + /// while its file is unsaved. + box_border: ?[3]u8 = null, + box_dirty: ?[3]u8 = null, + /// The rule between a pane's tag and its body, one pixel, quieter than + /// the rules between panes (acme's #8888cc under its 2px black ones). + /// Unset, halfway between the tag's ground and the page. + tag_rule: ?[3]u8 = null, + /// A pixel shell's scroll column, the grip's button over the scrollbar, + /// in pixels at a 17px tagline, the thumb one narrower (acme's 12px + /// Scrollwid, the default). + rail_px: ?u8 = null, }; const curated = struct { @@ -108,6 +131,8 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { } pub const themes = fold(curated) ++ fold(@import("generated_themes")); +/// The first `native_count` of `themes` are pardes's own; the rest imported. +pub const native_count = @typeInfo(curated).@"struct".decls.len; /// Original and classic-inspired Pardes palettes precede retained imports. pub const native_theme_count = 15; diff --git a/src/config.zig b/src/config.zig index ed7bee92..24e058c7 100644 --- a/src/config.zig +++ b/src/config.zig @@ -118,6 +118,7 @@ pub const leader_path = paths: { if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); + if (builtins.capabilities.lift) table.set(.GripWidth, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -629,6 +630,10 @@ pub const Runtime = struct { /// percent (0 off): the focused pane wins by comparison, its tag tint /// untouched. Kept above min(each cell's contrast, 4.5). inactive_dim: u8 = 0, + /// The grip's button and the scrollbar under it, one width, as a percent + /// of the theme's `rail_px` (acme's 12px Scrollwid at a 17px tagline): + /// a pixel shell's (docs/themes.md). + grip_width: u16 = 150, /// How the fx track's animations move (animation.Motion): off for /// reduced motion. motion: animation.Motion.Flavour = .smooth, @@ -862,6 +867,7 @@ pub const Runtime = struct { motion, /// A percentage, 0 through 100. inactive_dim, + grip_width, transition: animation.Transition, scene: Scene, /// A Shadertoy file into the post chain or out of it; `off` empties @@ -899,6 +905,7 @@ pub const Runtime = struct { .{ .word = "Lift", .action = .lift, .availability = .lift }, .{ .word = "Motion", .action = .motion }, .{ .word = "InactiveDim", .action = .inactive_dim }, + .{ .word = "GripWidth", .action = .grip_width, .availability = .lift }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, @@ -960,8 +967,8 @@ 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. - .lift => if (setting.availability != .lift) - @compileError("Lift must use the lift capability"), + .lift, .grip_width => if (setting.availability != .lift) + @compileError("Lift and GripWidth must use the lift capability (a pixel shell's)"), .toggle => |field| if (field == .ligatures) { if (setting.availability != .ligatures) @compileError("the ligatures toggle must use the ligatures capability"); @@ -1102,6 +1109,12 @@ pub const Runtime = struct { if (percent > 100) return false; state.inactive_dim = percent; }, + .grip_width => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%"); + const percent = std.fmt.parseInt(u16, text, 10) catch return false; + if (percent < 50 or percent > 300) return false; + state.grip_width = percent; + }, .theme, .font => return false, } return true; @@ -1277,6 +1290,7 @@ pub const Runtime = struct { try out.print("{s} unsupported\n", .{setting.word}), .motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }), .inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }), + .grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }), }; if (context.startup_config_path) |path| @@ -1547,6 +1561,7 @@ pub const Runtime = struct { .lift => "rim", .motion => "playful", .inactive_dim => "35", + .grip_width => "200", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1600,6 +1615,7 @@ pub const Runtime = struct { .lift => "Lift rim\n", .motion => "Motion playful\n", .inactive_dim => "InactiveDim 35\n", + .grip_width => "GripWidth 200\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 2a2ccc6b..c0e17054 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -11,6 +11,19 @@ const limits = @import("../memory.zig").limits; /// line), and a frame carries the core's placed regions after its layers. pub const version: u16 = 8; +/// The Chrome palette is carried field by field as its declaration lists +/// them, so any change to it changes the wire: bump `version`, then this. +const chrome_layout: u64 = 0x7e3d0952317bfee; +comptime { + @setEvalBranchQuota(10_000); + var hash = std.hash.Fnv1a_64.init(); + for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + hash.update(field.name); + hash.update(@typeName(field.type)); + } + if (hash.final() != chrome_layout) @compileError(std.fmt.comptimePrint("pardes.Chrome changed (layout 0x{x}): bump wire.version, then chrome_layout", .{hash.final()})); +} + pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; pub const max_cols: u16 = 512; @@ -248,7 +261,10 @@ pub const Frame = struct { region.owner = try r.getByte(); region.serial = try r.getU32(); region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; - region.active = try r.getBool(); + const flags = try r.getByte(); + if (flags > 3) return error.BadValue; + region.active = flags & 1 != 0; + region.dirty = flags & 2 != 0; region.thumb_y = try r.getU16(); region.thumb_h = try r.getU16(); region.notice = try r.getByte(); @@ -807,7 +823,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putU16(region.rect.y); try w.putU16(region.rect.w); try w.putU16(region.rect.h); - try w.putBool(region.active); + try w.putByte(@as(u8, @intFromBool(region.active)) | @as(u8, @intFromBool(region.dirty)) << 1); try w.putU16(region.thumb_y); try w.putU16(region.thumb_h); try w.putByte(region.notice); @@ -1898,10 +1914,10 @@ 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, .lift = 0.5 }}; + const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .dirty = 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 } }; + const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 }, .rule_px = 3, .tag_rule = .{ 13, 14, 15 }, .rail_px = 12, .grip_border = .{ 16, 17, 18 }, .grip_focus_ring = .{ 19, 20, 21 }, .grip_dirty = .{ 22, 23, 24 }, .lift_style = 2 }; const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; diff --git a/src/draw.zig b/src/draw.zig index 2e608132..1f30a40b 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -58,7 +58,7 @@ pub fn place(p: *Pardes, s: *Surface) void { const active = id == p.active; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); - s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .dirty = tagline.dirty(pane), .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; @@ -160,6 +160,29 @@ pub fn place(p: *Pardes, s: *Surface) void { } } +/// A pane's grip in the grid: `box` focused, else halfway between its ring +/// and `box_dim`, so it shows on its tag without the ring a pixel shell +/// draws (gui.zig draws its button from the region and Chrome instead). +fn gripBg(p: *const Pardes, active: bool) [3]u8 { + const chrome = p.chromeTheme(); + if (active) return chrome.box; + return colors.mix(boxBorder(p), chrome.box_dim); +} + +/// An unfocused grip's ring, and a dirty one's fill (colors.Theme). +pub fn boxBorder(p: *const Pardes) [3]u8 { + return p.theme().box_border orelse p.chromeTheme().box_dim; +} +pub fn boxDirty(p: *const Pardes) [3]u8 { + const th = p.theme(); + return th.box_dirty orelse th.diagnostic_warning orelse th.num; +} + +fn inkOn(bg: [3]u8) [3]u8 { + const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10; + return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; +} + /// The Lift style as drawn: `auto` is a shadow on a light page, and on a /// dark one, where a shadow has nothing to darken, no lift at all (the other /// panes recede instead, `inactiveDim`). @@ -366,6 +389,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .sel_fg = p.theme().sel_fg, .sel_bg = p.theme().sel_bg, .lift_style = @intFromEnum(liftStyle(p)), + .rule_px = p.theme().rule_px orelse 2, + .grip_border = boxBorder(p), + .grip_focus_ring = chrome.box, + .grip_dirty = boxDirty(p), + .tag_rule = p.theme().tag_rule, + .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)), }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. @@ -666,21 +695,28 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); } - const box_bg = if (active) chrome.box else chrome.box_dim; - const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; - const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; + // The grip is acme's button: the focused pane's filled, the others' + // a ring round the tag's ground, a dirty one's the dirty colour inside + // its ring. A pixel shell draws the ring (gui.zig gripDecor); a grid, + // which cannot, marks dirty in the grip's second cell with a bold `*` + // on the dirty colour, a cue that survives any palette. + const band = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const dirty = tagline.dirty(pane); + const box_bg = gripBg(p, active); const box_style: CellStyle = .{ - .fg = .{ .rgb = box_ink }, + .fg = .{ .rgb = inkOn(box_bg) }, .bg = .{ .rgb = box_bg }, // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; - // The grip is the first row's alone, as acme's button is; the rows - // under it are the tag band's, and still grab the pane (the region - // spans them all, as acme's button column does). + // On the first row alone, as acme's button is; the rows under it are + // the tag band's, and still grab the pane (the region spans them all, + // as acme's button column does). s.fill(r.x, tag_y, config.GUTTER, @min(1, pane.tag_rows), box_style); + if (dirty and pane.tag_rows > 0 and config.GUTTER > 1) + s.set(r.x + config.GUTTER - 1, tag_y, "*", .{ .fg = .{ .rgb = inkOn(boxDirty(p)) }, .bg = .{ .rgb = boxDirty(p) }, .bold = true, .font_role = .tagline }); s.fill(r.x, tag_y + 1, config.GUTTER, pane.tag_rows -| 1, .{ - .bg = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg }, + .bg = .{ .rgb = band }, .font_role = .tagline, }); s.set(r.x, tag_y, switch (pane.body.mode) { @@ -880,7 +916,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, var cell = panes.File.rawDisplayCol(shown, bounds.lo) -| scroll; const end = panes.File.rawDisplayCol(shown, bounds.hi) -| scroll; while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().tag_sel_bg orelse p.theme().sel_bg }; surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } }; @@ -890,7 +926,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, var col = panes.File.rawDisplayCol(shown, span.lo) -| scroll; const end = panes.File.rawDisplayCol(shown, span.hi) -| scroll; while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().tag_sel_bg orelse p.theme().sel_bg }; surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } } @@ -1131,7 +1167,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), y); cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.bg = .{ .rgb = th.tag_sel_bg orelse th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } @@ -1151,7 +1187,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (button != pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.tag_sel_bg orelse th.sel_bg, th.sel_fg); } if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { @@ -1160,11 +1196,71 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa }; } const sel_btn = body_layer.buttonTints(th); + const sel_ink = body_layer.buttonInks(th); for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (button == pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], sel_ink[button]); + } +} + +test "an unfocused grip shows on its tag in the grid, in every native theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + for (pardes.themes[0..colors.native_count], 0..) |theme, index| { + colors.setThemeIndex(p, index); + p.active = 1; + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const r = p.rects[0]; + const grip = s.at(r.x, p.tagTop(p.panes[0].?, r)).style.bg.rgb; + const ratio = contrast(grip, p.chromeTheme().tag_bg); + // ponytail: dark and helix have always had a faint grip (1.2:1); flagged, not changed here. + const floor: f32 = if (std.mem.eql(u8, theme.name, "dark") or std.mem.eql(u8, theme.name, "helix")) 1.2 else 1.5; + if (ratio < floor) std.debug.print("{s}: grip {d:.2}\n", .{ theme.name, ratio }); + try std.testing.expect(ratio >= floor); + } +} + +test "a grip's four states, focused or not and clean or dirty, are each its own in every native theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const State = struct { first: Cell, second: Cell, dirty: bool }; + for (0..colors.native_count) |index| { + colors.setThemeIndex(p, index); + var states: [4]State = undefined; + for (&states, 0..) |*state, i| { + p.active = if (i & 1 == 0) 0 else 1; + const saved = pane.file.?.saved_revision; + pane.file.?.revision = if (i & 2 != 0) saved + 1 else saved; + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const r = p.rects[0]; + const y = p.tagTop(pane, r); + state.* = .{ .first = s.at(r.x, y).*, .second = s.at(r.x + 1, y).*, .dirty = s.region(.grip, 0).?.dirty }; + try std.testing.expectEqual(i & 2 != 0, state.dirty); + if (i & 2 == 0) continue; + // Dirty is more than a shade: the grid prints a bold `*` (one + // grid for every shell; a pixel shell draws its button over it). + try std.testing.expectEqualStrings("*", state.second.grapheme()); + try std.testing.expect(state.second.style.bold); + } + for (states, 0..) |a, i| for (states[i + 1 ..]) |b| { + const same = std.meta.eql(a.first.style.bg, b.first.style.bg) and a.second.visuallyEqual(&b.second); + try std.testing.expect(!same); + }; } + pane.file.?.revision = pane.file.?.saved_revision; } test "a tag of three rows has its grip on the first, and band under it that still grabs the pane" { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index d8bae6dc..1a1b1f99 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2111,7 +2111,13 @@ const Cover = packed struct(u16) { focus: bool = false, /// At a column's left edge, where a tag's anchor is inset. anchor: bool = false, - _: u7 = 0, + /// In a dirty pane's grip. + dirty: bool = false, + /// In a pane's grip (not a column's). + pane_grip: bool = false, + /// In a grip's first row, where its button is. + grip_top: bool = false, + _: u4 = 0, }; /// Marks the grid cells a body or tag layer covers, which the layers paint @@ -2143,10 +2149,13 @@ fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes. if (region.kind == .column_grip) bar = region.rect; } for (regions) |region| switch (region.kind) { - .grip => { + .grip, .column_grip => { mark.rect(cover, cols, rows, region.rect, "grip", true); for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset))); - if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + if (region.kind == .grip and region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + if (region.dirty) mark.rect(cover, cols, rows, region.rect, "dirty", true); + if (region.kind == .grip) mark.rect(cover, cols, rows, region.rect, "pane_grip", true); + mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = region.rect.y, .w = region.rect.w, .h = @min(1, region.rect.h) }, "grip_top", true); }, .tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true), .column => { @@ -6022,6 +6031,67 @@ fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); } +/// acme's Scrollwid column at a pane's or column's left edge, in window +/// pixels: where its button sits over its scrollbar, one width for both +/// (`rail_px` at acme's 17px font, scaled with the tagline, and always +/// short of the cell so the text keeps a gap), right of the column's rule. +fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: CellLayout) [2]f32 { + const rule: f32 = if (col == 0) 0 else rulePx(chrome); + const x0 = @round(@as(f32, @floatFromInt(col)) * layout.w + rule); + const scaled: f32 = @round(@as(f32, @floatFromInt(@max(1, chrome.rail_px))) * @as(f32, @floatFromInt(g.tagline_height)) / 17); + // The rail and the grip are GUTTER cells wide; the text starts after. + const w = std.math.clamp(scaled, 4, @max(4, @floor(layout.w * @as(f32, @floatFromInt(config.GUTTER))) - rule - 3)); + return .{ x0, x0 + w }; +} + +/// acme's button for the grip whose first cell is (col, row), in window +/// pixels: the scroll column wide, its tag row tall, from the rule above to +/// the rule below, so button and scrollbar read as one strip. +fn gripButton(g: *const Gui, chrome: *const pardes.Chrome, col: u16, row: u16, layout: CellLayout, win_h: f32) [4]f32 { + _ = win_h; + const x = scrollColumn(g, chrome, col, layout); + const y0 = @round(@as(f32, @floatFromInt(row)) * layout.h); + return .{ x[0], y0, x[1], @round(@as(f32, @floatFromInt(row + 1)) * layout.h) }; +} + +/// A pane's button fill, from its region's state, not the grid's cells +/// (which are drawn for cell shells): dirty, focused, or the tag's ground. +fn gripFill(chrome: *const pardes.Chrome, focus: bool, dirty: bool) [3]u8 { + if (dirty) return chrome.grip_dirty; + return if (focus) chrome.grip_focus_ring else chrome.tag_bg; +} + +fn inkOn(bg: [3]u8) [3]u8 { + const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10; + return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; +} + +/// Cuts an instance's quad to a pixel rectangle, its atlas window with it so +/// the glyph neither moves nor stretches; nothing left, an empty quad. +fn clipInstance(instance: *CellInstance, r: [4]f32, win_w: f32, win_h: f32) void { + const x0 = (instance.x0 + 1) / 2 * win_w; + const x1 = (instance.x1 + 1) / 2 * win_w; + const y0 = (1 - instance.y0) / 2 * win_h; + const y1 = (1 - instance.y1) / 2 * win_h; + const left = @max(0, r[0] - x0); + const right = @max(0, x1 - r[2]); + const top = @max(0, r[1] - y0); + const bottom = @max(0, y1 - r[3]); + if (x1 - right <= x0 + left or y1 - bottom <= y0 + top) { + instance.x1 = instance.x0; + instance.y1 = instance.y0; + return; + } + instance.x0 += left / win_w * 2; + instance.u0 += left / atlas_w; + instance.x1 -= right / win_w * 2; + instance.u1 -= right / atlas_w; + instance.y0 -= top / win_h * 2; + instance.v0 += top / atlas_h; + instance.y1 += bottom / win_h * 2; + instance.v1 -= bottom / atlas_h; +} + fn emitSurfaceCell( g: *Gui, chrome: *const pardes.Chrome, @@ -6054,33 +6124,82 @@ fn emitSurfaceCell( emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page); next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. - emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); + // A pane's grip cells are the cell shells' form (a dirty `*`): the + // button draws its own glyph over a blank of them. + var blank = line[at]; + blank.text[0] = ' '; + blank.len = 1; + if (here.pane_grip) + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, (&blank)[0..1], 0, null, old_layer, false, page) + else + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); // A grip's glyph is the cell at its left edge, and a blank one is // no glyph: its marks are decor. const glyph = &line[at - @min(here.grip_offset, at)]; - const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; + const grip = here.grip and here.grip_top and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; const offset: u16 = if (grip) @min(here.grip_offset, at) else 0; // An anchor starts at its column's rule: its left edge is inset by // its top margin so the band frames it on the left as above and // below, and its mark moves right by half that to stay centered. const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0; + // A grip is acme's button: its fill is this cell's ground, cut to + // the button (and a dirty one's inset a pixel inside its ring, so + // the ring reads round it), its glyph centered in it. + const button = if (here.grip and here.grip_top) gripButton(g, chrome, col - @min(here.grip_offset, @as(u16, @intCast(at))), row, body_layout, win_h) else null; if (grip) { // A grip is one glyph centered across the gutter, not text: it is // looked up by its codepoint's glyph, never shaped. const drawn = glyphForCodepoint(g, cellCodepoint(glyph)); const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true }); const slot = cached orelse g.space_slot; - const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; + const b = button.?; + const center = (b[0] + b[2]) / 2 - @as(f32, @floatFromInt(col - offset)) * body_layout.w; + const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) + @as(f32, @floatFromInt(config.GUTTER * g.cell_w)) / 2 - center else 0; const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page); - instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; - instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; - instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; + const ink = if (here.pane_grip) inkOn(gripFill(chrome, here.focus, here.dirty)) else resolved.fg; + instances[next.*].fr = @as(f32, @floatFromInt(ink[0])) / 255; + instances[next.*].fg = @as(f32, @floatFromInt(ink[1])) / 255; + instances[next.*].fb = @as(f32, @floatFromInt(ink[2])) / 255; instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h; } instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; - if (inset > 0 and (!grip or offset == 0)) { + if (button) |b| { + // acme's button fills its tag row from the rule above to the rule + // below: the underlay is its fill, cut to its columns; a pane's + // from its region's state, not the grid's cell form (whose dirty + // `*` is the cell shells'), a column's its cell's own ground. + const base = &instances[next.* - 1]; + const own: [3]f32 = .{ instances[next.*].br, instances[next.*].bg, instances[next.*].bb }; + if (here.pane_grip) { + const fill = gripFill(chrome, here.focus, here.dirty); + base.br = @as(f32, @floatFromInt(fill[0])) / 255; + base.bg = @as(f32, @floatFromInt(fill[1])) / 255; + base.bb = @as(f32, @floatFromInt(fill[2])) / 255; + } else { + base.br = own[0]; + base.bg = own[1]; + base.bb = own[2]; + } + base.effect &= ~clear_bg_bit; + clipInstance(base, .{ b[0], -1e9, b[2], 1e9 }, win_w, win_h); + // Over it, only its own glyph; the band round it and the ring + // are decor (gripDecor). + if (here.pane_grip and !grip) { + instances[next.*].u0 = @as(f32, @floatFromInt(g.space_slot.u)) / atlas_w; + instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; + instances[next.*].v0 = @as(f32, @floatFromInt(g.space_slot.v)) / atlas_h; + instances[next.*].v1 = @as(f32, @floatFromInt(g.space_slot.v + g.tagline_height)) / atlas_h; + } + // Its ground is the fill again (a see-through ground would be + // written, not blended, in an opaque window). + instances[next.*].br = base.br; + instances[next.*].bg = base.bg; + instances[next.*].bb = base.bb; + instances[next.*].effect &= ~clear_bg_bit; + clipInstance(&instances[next.*], b, win_w, win_h); + } else if (inset > 0) { instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; } @@ -6091,6 +6210,102 @@ fn emitSurfaceCell( next.* += 1; } +test "every pixel of a grip column is band, button or ring, and the button sits over the scrollbar" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 20 }); + defer core.deinit(); + const pane = try core.setTestFile("text\n" ** 60); + pane.tag.own = try gpa.dupe(u8, "second\nthird"); + core.newScratchBelow(0); + for ([_]struct { w: u32, h: u32, tag: u32 }{ .{ .w = 10, .h = 20, .tag = 12 }, .{ .w = 17, .h = 35, .tag = 30 }, .{ .w = 9, .h = 19, .tag = 15 }, .{ .w = 12, .h = 24, .tag = 24 } }) |m| for ([_]bool{ false, true }) |bottom| for ([_]usize{ 0, 1 }) |focus| for ([_]bool{ false, true }) |dirty| { + core.settings.tag_bottom = bottom; + core.active = focus; + pane.file.?.revision = pane.file.?.saved_revision + @intFromBool(dirty); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + var g: Gui = undefined; + g.words = .{}; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = m.w; + g.cell_h = m.h; + g.tagline_width = m.w; + g.tagline_height = m.tag; + g.space_slot = .{ .u = 0, .v = 0 }; + const layout = fixedCellLayout(&g); + const win_w: f32 = @floatFromInt(60 * m.w); + const win_h: f32 = @floatFromInt(20 * m.h); + const cover = try gpa.alloc(Cover, @as(usize, s.cols) * s.rows); + defer gpa.free(cover); + coverFrame(cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList()); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, layout, win_w, win_h, true); + const grip = s.region(.grip, 0).?; + // The grip column's pixels, band first, then its cells, then decor. + const x_px: u32 = grip.rect.x * m.w; + const w_px: u32 = config.GUTTER * m.w; + const y_px: u32 = grip.rect.y * m.h; + const h_px: u32 = grip.rect.h * m.h; + const pixels = try gpa.alloc(?[3]f32, w_px * h_px); + defer gpa.free(pixels); + @memset(pixels, null); + const paint = struct { + fn quad(into: []?[3]f32, x0n: f32, y0n: f32, x1n: f32, y1n: f32, rgb: [3]f32, ww: f32, wh: f32, ox: u32, oy: u32, pw: u32, ph: u32) void { + const px0: i64 = @intFromFloat(@round((x0n + 1) / 2 * ww)); + const px1: i64 = @intFromFloat(@round((x1n + 1) / 2 * ww)); + const py0: i64 = @intFromFloat(@round((1 - y0n) / 2 * wh)); + const py1: i64 = @intFromFloat(@round((1 - y1n) / 2 * wh)); + var y = @max(py0, oy); + while (y < @min(py1, oy + ph)) : (y += 1) { + var x = @max(px0, ox); + while (x < @min(px1, ox + pw)) : (x += 1) into[@intCast((y - oy) * pw + (x - ox))] = rgb; + } + } + }; + var instances: [4]CellInstance = undefined; + for (0..grip.rect.h) |line| for (0..config.GUTTER) |dx| { + const col: u16 = grip.rect.x + @as(u16, @intCast(dx)); + const row: u16 = grip.rect.y + @as(u16, @intCast(line)); + const at = @as(usize, row) * s.cols; + var next: u32 = 0; + emitSurfaceCell(&g, &s.chrome, &instances, &next, col, row, layout, win_w, win_h, null, s.cells[at..][0..s.cols], cover[at..][0..s.cols], col, null, false, false, Ground.opaqueRgb(bg_default)); + for (instances[0..next]) |i| paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px); + }; + for (g.decor.items) |i| if (i.fr >= 1 and i.clip_x1 < i.clip_x0) paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px); + const rgb = struct { + fn of(v: [3]u8) [3]f32 { + return .{ @as(f32, @floatFromInt(v[0])) / 255, @as(f32, @floatFromInt(v[1])) / 255, @as(f32, @floatFromInt(v[2])) / 255 }; + } + }.of; + const chrome = &s.chrome; + const band = if (focus == 0) chrome.tag_focus_bg else chrome.tag_bg; + // Band, fills, rings, the rules between stacked panes and under the + // tag, and the mode marks' ink (the grip's first cell's). + const ink = inkOn(gripFill(chrome, grip.active, grip.dirty)); + const allowed = [_][3]f32{ rgb(band), rgb(core.chromeTheme().box), rgb(pardes.draw.boxDirty(core)), rgb(chrome.grip_border), rgb(chrome.grip_focus_ring), rgb(chrome.border), rgb(ink), rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)) }; + for (pixels, 0..) |pixel, n| { + const got = pixel orelse return error.TestUnpaintedGripPixel; + const known = for (allowed) |a| { + if (std.meta.eql(a, got)) break true; + } else false; + // Under the first row there is no button: band and rules only. + if (n / w_px >= m.h and n / w_px < h_px - 1 and !std.meta.eql(got, rgb(band)) and !std.meta.eql(got, rgb(chrome.border))) + return error.TestButtonUnderFirstRow; + if (!known) std.debug.print("sliver {any} at {d},{d} of {d}x{d} (metrics {d}x{d}/{d}, bottom {}, focus {d}, dirty {}) page {any}\n", .{ got, n % w_px, n / w_px, w_px, h_px, m.w, m.h, m.tag, bottom, focus, dirty, s.chrome.page }); + try std.testing.expect(known); + } + // The rule under the tag runs unbroken across the grip column. + if (!bottom) for (pixels[(h_px - 1) * w_px ..][0..w_px]) |pixel| + try std.testing.expectEqual(rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)), pixel.?); + // The button's edges are the scrollbar's. + const button = gripButton(&g, chrome, grip.rect.x, grip.rect.y, layout, win_h); + const column = scrollColumn(&g, chrome, s.region(.rail, 0).?.rect.x, layout); + try std.testing.expectEqual(column[0], button[0]); + try std.testing.expectEqual(column[1], button[2]); + }; + pane.file.?.revision = pane.file.?.saved_revision; +} + test "small tagline fonts leave no background gutters between adjacent tags" { const chrome: pardes.Chrome = .{}; var g: Gui = undefined; @@ -6778,9 +6993,9 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0)); try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0)); try std.testing.expect(!cover[2].grip); - // A column's grip has no glyph of a pane's. + // A column's grip is a button too, cut the same way (it has no glyph). coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}); - try std.testing.expect(!cover[0].grip); + try std.testing.expect(cover[0].grip); coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); const chrome: pardes.Chrome = .{}; @@ -7714,6 +7929,12 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g 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; + // The grips' buttons before any rule: the rules over their rows cut + // them, and the band round them never covers a rule. + if (index <= groups.tracks) { + const closing = if (group.track) |active| active.phase == .closing else false; + try gripDecor(g, gpa, if (closing) surface.previous_regions else regions, if (closing) surface.previous_cells else surface.cells, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, group.track); + } if (index == 0) { const top = for (regions) |region| { if (region.kind == .workspace_tag) break region.rect.h; @@ -7723,7 +7944,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g .grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true, else => {}, } else false; - const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height); + // As wide as the other rules, where the tagline leaves the room. + const height: u32 = @max(1, @min(chrome.rule_px, (g.cell_h -| g.tagline_height) * 2)); if (top != 0 and top < surface.rows and tag_under and height != 0) { const y0: f32 = @floatFromInt(top * g.cell_h - height / 2); try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h); @@ -7732,7 +7954,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g for (regions) |bar| { if (bar.kind != .column_grip) continue; const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h; - try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h); + try addDecor(g, gpa, 0, y - rulePx(chrome), win_w, y, chrome.border, 1, null, win_w, win_h); break; } // A tag on the last row runs on through the pixels under the grid. @@ -7756,7 +7978,6 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g nbodies += 1; }; const bodies = body_buf[0..nbodies]; - const grid = if (closing) surface.previous_cells else surface.cells; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { @@ -7765,9 +7986,10 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h; const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h; const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1; - if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h); + // None at the window's edge: nothing to rule apart there. + if (column.rect.x == 0) continue; + if (y1 > y0) try addDecor(g, gpa, x, y0, x + rulePx(chrome), 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. @@ -7837,47 +8059,210 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; const tag_bottom = tag.rect.y > rail.rect.y; const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h; - try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.border, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.tag_rule orelse mixRgb(chrome.tag_bg, page), 1, track, win_w, win_h); + // Stacked panes are ruled apart as columns are, where one pane's + // body meets the next one's tag (acme's Border). + { + const pane_top = @min(tag.rect.y, rail.rect.y); + const stacked = for (regions) |other| { + if ((other.kind == .body or other.kind == .tag) and other.owner != rail.owner and other.rect.y + other.rect.h == pane_top and + other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true; + } else false; + // Inside the tag band's slack, never over its glyphs: the top + // slack of a tag on top, the bottom slack of the one above when + // tags are at the bottom; a pixel at least. + const y = @as(f32, @floatFromInt(pane_top)) * layout.h; + const spare = g.cell_h -| g.tagline_height; + if (stacked and !tag_bottom) { + const room = taglineBandOffset(pane_top, win_h, g.cell_h, g.tagline_height); + try addDecor(g, gpa, x0, y, rule_x1, y + @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), chrome.border, 1, track, win_w, win_h); + } else if (stacked and pane_top > 0) { + const room = spare - taglineBandOffset(pane_top - 1, win_h, g.cell_h, g.tagline_height); + try addDecor(g, gpa, x0, y - @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), rule_x1, y, chrome.border, 1, track, win_w, win_h); + } + } const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h; const body_end = rail.rect.y + rail.rect.h; const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1; + // A theme's own track fills its width, the thumb one pixel short + // of it so the track edges it (acme's scrollbar); else a thin rail. + // acme's scrollbar: the track fills the scroll column, under the + // grip's button; the thumb is a pixel short of its right edge, so + // the track edges it. + const column = scrollColumn(g, chrome, rail.rect.x, layout); try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); - try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h); + try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h); if (rail.thumb_h != 0) { const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h; const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h; - try addDecor(g, gpa, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); + try addDecor(g, gpa, column[0], thumb_y0, column[1] - 1, thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); } } } +/// Halfway between two colours. +fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 { + var out: [3]u8 = undefined; + for (&out, a, b) |*o, x, y| o.* = @intCast((@as(u16, x) + y) / 2); + return out; +} + +/// A rule's width: the theme's own, or one pixel. +fn rulePx(chrome: *const pardes.Chrome) f32 { + return @floatFromInt(@max(1, chrome.rule_px)); +} + /// The two short bars on each row of a grip with no mode glyph there, /// centered in its anchor. Only the panes of group `index`. -fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { +fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { if (g.tagline_height < 6 or g.tagline_width < 3) return; for (regions) |grip| { - if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue; + if ((grip.kind != .grip and grip.kind != .column_grip) or groupOf(groups, grip.serial) != index) continue; + if (grip.rect.h == 0 or grip.rect.y >= rows) continue; + const button = gripButton(g, chrome, grip.rect.x, grip.rect.y, layout, win_h); + // Round the button, its first row is the tag's band: nothing of the + // cells under it shows there. + const band = if (grip.active) chrome.tag_focus_bg else chrome.tag_bg; + const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w; + const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w; + try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h); + try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h); + if (grip.kind != .grip) continue; + // acme's button ring, two pixels (ButtonBorder) inside the button: + // the focused one's in its own fill. An unfocused dirty one keeps a + // pixel of band inside its ring, so the four states differ in shape + // whatever a theme's colours (a focused dirty one is acme's + // modbutton, filled up to its ring). + { + // The rules drawn over the row (paneDecor, after this) cut into + // it: the ring runs inside them, from rule to rule. + const tag = for (regions) |part| { + if (part.kind == .tag and part.owner == grip.owner) break part; + } else grip; + const tag_bottom = for (regions) |part| { + if (part.kind == .rail and part.owner == grip.owner) break part.rect.y < tag.rect.y; + } else false; + const stacked = !tag_bottom and for (regions) |other| { + if ((other.kind == .body or other.kind == .tag) and other.owner != grip.owner and other.rect.y + other.rect.h == grip.rect.y and + other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true; + } else false; + const top_rule: f32 = if (tag_bottom) 1 else if (stacked) @floatFromInt(@max(1, @min(chrome.rule_px, taglineBandOffset(grip.rect.y, win_h, g.cell_h, g.tagline_height)))) else 0; + const bottom_rule: f32 = if (!tag_bottom and grip.rect.h == 1) 1 else 0; + const x0 = button[0]; + const x1 = button[2]; + const y0 = button[1] + top_rule; + const y1 = button[3] - bottom_rule; + const ring = if (grip.active) chrome.grip_focus_ring else chrome.grip_border; + try addDecor(g, gpa, x0, y0, x1, y0 + 2, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y1 - 2, x1, y1, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y0 + 2, x0 + 2, y1 - 2, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x1 - 2, y0 + 2, x1, y1 - 2, ring, 1, track, win_w, win_h); + if (grip.dirty and !grip.active) { + try addDecor(g, gpa, x0 + 2, y0 + 2, x1 - 2, y0 + 3, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x0 + 2, y1 - 3, x1 - 2, y1 - 2, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x0 + 2, y0 + 3, x0 + 3, y1 - 3, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x1 - 3, y0 + 3, x1 - 2, y1 - 3, band, 1, track, win_w, win_h); + } + } const x = grip.rect.x; for (0..grip.rect.h) |line| { const y = grip.rect.y + @as(u16, @intCast(line)); if (x >= cols or y >= rows) continue; const cell = &cells[@as(usize, y) * cols + x]; if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2)); + // The marks are in the button's ink, from the region's state. + const rgb = inkOn(gripFill(chrome, grip.active, grip.dirty)); + // The marks are the first row's alone, centered in the button. + if (line != 0) continue; + const center_x = @floor((button[0] + button[2]) / 2); const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); + const half_w = @max(1, @min(3, @floor((button[2] - button[0] - 6) / 2))); try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h); try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h); } } } +test "a theme's own rules and scrollbar are drawn at its widths: acme's 2px black rules and 12px track" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + try std.testing.expect(core.executeBuiltinLine(0, "Theme acme")); + for (0..40) |_| core.update(.tick); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + try std.testing.expectEqual(@as(u8, 2), s.chrome.rule_px); + // acme's 12px, at GripWidth's 150% default. + try std.testing.expectEqual(@as(u8, 18), s.chrome.rail_px); + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 16; + g.cell_h = 20; + g.tagline_width = 10; + g.tagline_height = 12; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 480, true); + const px = struct { + fn w(item: CellInstance) f32 { + return (item.x1 - item.x0) / 2 * 1280; + } + }; + var spines: usize = 0; + var tracks: usize = 0; + for (g.decor.items) |item| { + const black = item.br == 0 and item.bg == 0 and item.bb == 0; + // No rule at the window's left edge; one 2px spine where columns meet. + if (black and @abs(px.w(item) - 2) < 0.01 and item.y1 < -0.9) { + try std.testing.expect(item.x0 > -1); + spines += 1; + } + const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g)); + if (@abs(item.br - @as(f32, 0x99) / 255) < 1e-3 and @abs(item.bb - @as(f32, 0x4c) / 255) < 1e-3 and @abs(px.w(item) - (column[1] - column[0])) < 0.01) tracks += 1; + } + try std.testing.expectEqual(@as(usize, 1), spines); + try std.testing.expect(tracks >= 2); +} + +test "the rule between stacked panes stays in the tag band's slack, at any tagline size" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + core.newScratchBelow(0); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + const lower = for (s.regionList()) |region| { + if (region.kind == .tag and region.rect.y > 2) break region; + } else return error.TestExpectedStackedTag; + for ([_]u32{ 12, 20 }) |tagline_h| { + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = tagline_h; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + const top = @as(f32, @floatFromInt(lower.rect.y)) * 20; + const slack: f32 = @floatFromInt(@max(1, pardes.taglineBandOffset(lower.rect.y, 480, 20, tagline_h))); + var found = false; + for (g.decor.items) |item| { + const y0 = (1 - item.y0) / 2 * 480; + const y1 = (1 - item.y1) / 2 * 480; + if (@abs(y0 - top) > 0.01 or @abs(item.br - @as(f32, @floatFromInt(s.chrome.border[0])) / 255) > 1e-3) continue; + found = true; + try std.testing.expect(y1 - y0 <= slack + 0.01); + } + try std.testing.expect(found); + } +} + test "decor is read from the regions, each pane's in its own group" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); @@ -7905,15 +8290,23 @@ test "decor is read from the regions, each pane's in its own group" { // workspace tag; a rail two pixels wide at each pane's. for (core.col_x[0..core.ncol]) |x| { const left = ndc.x(@as(f32, @floatFromInt(x)) * 10); + // Two pixels (rule_px), and none at the window's own edge. const spine = for (g.decor.items) |item| { - if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item; - } else return error.TestExpectedSpine; - try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001); - try std.testing.expectEqual(@as(f32, -1), spine.y1); + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001 and + @abs(item.y0 - (1 - 20.0 / 480.0 * 2)) < 0.0001 and std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &.{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 })) break item; + } else null; + try std.testing.expectEqual(x == 0, spine == null); + if (spine) |rule| { + try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), rule.y0, 0.0001); + try std.testing.expectEqual(@as(f32, -1), rule.y1); + } + // The scrollbar fills the scroll column right of the rule. + const column = scrollColumn(&g, &s.chrome, x, layout); const rail = for (g.decor.items) |item| { - if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item; + if (@abs(item.x0 - ndc.x(column[0])) < 0.0001 and @abs(item.x1 - ndc.x(column[1])) < 0.0001 and + @abs(item.br - @as(f32, @floatFromInt(s.chrome.scroll_track[0])) / 255) < 0.0001) break item; } else return error.TestExpectedRail; - try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001); + _ = rail; } try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count); const still = groups.items[0].decor_count; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 1c21d314..b28e02fa 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -308,6 +308,7 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .lift => try w.print("{s} {s}\n", .{ word, @tagName(s.lift) }), .motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }), .inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }), + .grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }), } } try w.writeAll("LocationsConfig "); diff --git a/src/surface.zig b/src/surface.zig index f16993a8..bc0a019d 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -356,6 +356,8 @@ pub const Region = struct { rect: layout.Rect, /// The pane or column has the keyboard's focus. active: bool = false, + /// A grip's pane has changes it has not saved. + dirty: bool = false, /// A rail's thumb: rows from the rail's top, and how many. Zero rows is /// a rail with no thumb (an image). A native PDF's thumb is measured in /// document pixels and filled in by Pdf.draw while it paints, so read @@ -403,6 +405,19 @@ pub const Chrome = struct { sel_bg: [3]u8 = .{ 0, 0, 0 }, /// How a lifted part stands off the page (config.Runtime.LiftStyle). lift_style: u8 = 0, + /// Rule width in pixels (colors.Theme.rule_px). + rule_px: u8 = 2, + /// The ring round a grip, acme's button: unfocused, and focused. + grip_border: [3]u8 = .{ 0, 0, 0 }, + grip_focus_ring: [3]u8 = .{ 0, 0, 0 }, + /// A dirty pane's button fill. + grip_dirty: [3]u8 = .{ 0, 0, 0 }, + /// The rule between a pane's tag and its body; null is halfway between + /// the tag's ground and the page the shell actually draws. + tag_rule: ?[3]u8 = null, + /// The scroll column's width, button and scrollbar alike, in pixels at + /// a 17px tagline (acme's 12 at its font); a pixel shell scales it. + rail_px: u8 = 12, }; /// The canonical interface: what a frame of pardes IS. The tty shell writes diff --git a/src/tagline.zig b/src/tagline.zig index 59c51b4d..d0907cbd 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -24,6 +24,12 @@ pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; pub const command_pane_tail = " " ++ config.command_pane_builtins_str; const dirty_marker = " *"; + +/// A file with changes it has not saved. +pub fn dirty(pane: *const panes.Pane) bool { + const f = pane.file orelse return false; + return panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision; +} pub const legacy_pane_tail = " Del"; pub const legacy_file_pane_tail = " Save Del"; // The defaults from the release before Newtty joined every tagline. Recognized @@ -136,7 +142,7 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 { } if (pane.file) |f| { const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path; - if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) + if (dirty(pane)) return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); return name; } diff --git a/src/themes/acme.zig b/src/themes/acme.zig index d91e626b..17ef4224 100644 --- a/src/themes/acme.zig +++ b/src/themes/acme.zig @@ -6,20 +6,28 @@ //! in) is a mid grey, and 15 (bright white) is BLACK, because a program asking //! for the brightest color on a light page means "make this stand out". //! -//! The selection is acme's own: plan9's DDarkyellow #eeee9e, the colour acme -//! sweeps text with, and black ink over it — acme changes the paper under a -//! selection and never the letters. Out of the same draw.h palette the -//! scrollbar's #99994c (DYellowgreen) is already taken from. +//! Every colour below that acme has is acme's own, from plan9port's acme.c +//! iconinit and draw.h: the tag #eaffff and body #ffffea (each 25% of +//! DPalebluegreen or DPaleyellow over white), their selections #9eeeee and +//! #eeee9e with black ink (acme changes the paper under a selection, never +//! the letters), the exec and look sweeps #aa0000 and #006600 with white ink, +//! the scrollbar's #99994c track under a #ffffea thumb, 12px wide, the +//! #8888cc (DPurpleblue) line between a tag and its body and the column +//! button, and 2px black rules between columns, windows and tags. +//! +//! acme has no focus colour; pardes needs one, so the focused pane's button +//! is filled #8888cc (the column button's look) and every other pane's is +//! acme's own: #eaffff in a 2px #8888cc ring, #000099 inside it when dirty. pub const theme = .{ .name = "acme", .bg = .{ 0xff, 0xff, 0xea }, .fg = .{ 0x00, 0x00, 0x00 }, - .sel_bg = .{ 0xfa, 0xfa, 0x92 }, + .sel_bg = .{ 0xee, 0xee, 0x9e }, .sel_fg = .{ 0x00, 0x00, 0x00 }, .tag_bg = .{ 0xea, 0xff, 0xff }, .tag_fg = .{ 0x00, 0x00, 0x00 }, - .box = .{ 0x66, 0x3f, 0xe0 }, - .box_dim = .{ 0x35, 0x3a, 0x58 }, + .box = .{ 0x88, 0x88, 0xcc }, + .box_dim = .{ 0xea, 0xff, 0xff }, .kw = .{ 0x97, 0x0d, 0xae }, .str = .{ 0x00, 0x77, 0x33 }, .num = .{ 0xa0, 0x52, 0x00 }, @@ -27,6 +35,17 @@ pub const theme = .{ .lineno = .{ 0x99, 0x99, 0x80 }, .scroll_track = .{ 0x99, 0x99, 0x4c }, .scroll_thumb = .{ 0xff, 0xff, 0xea }, + .border = .{ 0x00, 0x00, 0x00 }, + .tag_rule = .{ 0x88, 0x88, 0xcc }, + .rule_px = 2, + .box_border = .{ 0x88, 0x88, 0xcc }, + .box_dirty = .{ 0x00, 0x00, 0x99 }, + .rail_px = 12, + .tag_sel_bg = .{ 0x9e, 0xee, 0xee }, + .sweep_bg = .{ .{ 0xee, 0xee, 0x9e }, .{ 0xaa, 0x00, 0x00 }, .{ 0x00, 0x66, 0x00 } }, + .sweep_fg = .{ .{ 0x00, 0x00, 0x00 }, .{ 0xff, 0xff, 0xff }, .{ 0xff, 0xff, 0xff } }, + .column_box = .{ 0x88, 0x88, 0xcc }, + .column_box_dim = .{ 0x88, 0x88, 0xcc }, .palette = .{ .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 }, .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 }, diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index 87134609..4c19dc4f 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -1,18 +1,18 @@ -01-boot d4e3eb9dba35133df10d46b1d27ab7a90fa9bdefb64bcc304add221326ffdfc9 -02-second-document 235cdf75f3ce1d5800dbbdd7dcfde066af50cde01231a35d00c66c4310213a05 -03-notice 4d59205fe6b89a4589e94c68521257f21b40917c0198ea20248925687663d9f5 -04-context-rows-mid-thumb 7129ee318975f858c430b1e610b26ca756d5bb37553338c0dc6caab2290de2a4 -05-body-selection 39103f3747aae07a12b5151e7e377430b88b96e504670ac2f2d0272197eb827b -06-tag-focus-selection 6a7f50e292f4ede92df24a817218818b93a01fd15fcf39467cf06c3772077834 -07-prompt-notice d5cdacd74ae6d29834f0b09a607bfce5a2ca8a549c259be50b76a57a2d2d3271 -08-wide-glyph-edges 123a182b82a2a8c455291a5c89ccfa28e1f8ce9566172ca404c0b9c71dd33b48 -09-collapsed-focus-tint ae66076fdfb0cab9a906b052df257deee5ba91a6274712d52a76d8a7adc5b415 -10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 -11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 -12-multiline-tag 0ea8f8a83fecef0e9a72d11f56bb7174739109d15074c56165274046bab48e17 -13-image 33986b288f6e2f4b3dc82113c7890980fc10362be471b3d6bf04eb1df7785e6b -14-theme-ink 3c435c76370d09c9856a7b481a52206f4ab0314729da79eef8b442d25fa210fa -15-theme-acme-light 3cf7ed81fc85ec91b5d448cab3d943f4724d754215944b4525e44885f73bccea -16-debug d2fcf18c15c1cbf771c0141b7e7f680185a099bec9102af147bf7951446afb16 -17-terminal 9b46bf9969382991243aafba88977db3bd276bb2d72dd06552b1683d3ee7d90e -18-mid-transition 11473788385e248cd7e1cc952629b3ad9a55c2068b51bf78d1494f631509e343 +01-boot 39c940960ccd264a0ed2e9dd99a2c352a47ca64c67d42e3313ae344713c712ae +02-second-document 9a810fe372fa37bc3d338d7f0bd04b5224957cb57ef63952c8fc02d1e72e705a +03-notice b52ebd3860694d35a43e56d149d180249b2609d4a3fc4374347d8cd3b5ebc05c +04-context-rows-mid-thumb e7b9f5d6da06143fff79b9f2686c29050556173f97c759a4119a201542f993ef +05-body-selection 06b7cbcf368bb4371873b26f70286fb2b5ada4293b758fa73458157a248c7aa6 +06-tag-focus-selection a781c95cebb8c47a5c5168e1b7f9747f44a0aad043cc4eec1338bb1f08d0a340 +07-prompt-notice f2a39f0d1136d818e57d693873256f2134d89f10805d4078eaaf16a59bece1ce +08-wide-glyph-edges ad7439148a7705aac6f5f3de3f6a47f9863dccffeba561c70a77e9b9838c5847 +09-collapsed-focus-tint 807bd7621d54948b84cf2965ef463d3928d050259e8ee2d27472b1d3d378f91e +10-tagbottom 03086852528d9c8a76f5926a7e7ee371200da617db279f42cbbb6825b5f41fce +11-column-tags 28ac090425c28515893f184b8c1fd0ba1dc3f05c0385372bb23a0b9e2abd080e +12-multiline-tag 72be302d73e36e6d51d940300d557e34b081b94ce7ec4e50e3bbd56968ed0d58 +13-image 162e6404d5578d1a8c34a8abd152b91d54588e8ddba540e88d48396560c06f71 +14-theme-ink 42b756fd9f1fba42004ac40983bc6c45445fd7d039a970c11378cac652e00d89 +15-theme-acme-light 5cecfb32915c217c5d39492b8708532bd826a8a55ad6b8bd49d61ed5cee29ab9 +16-debug 51628eb1d8dce799256e336d081d8510fe70b7a3f28f9efefcbca7126583b348 +17-terminal 65794362c9e76fa2930e85e888f17216d24cace7475e29da3610c0dab411887b +18-mid-transition 72ce5905098af73e571a1efe009a700940458924502bcf5e96bfafe7c4b8f29c diff --git a/test/snapshots/chordcut.golden b/test/snapshots/chordcut.golden index d5266717..bff929b3 100644 --- a/test/snapshots/chordcut.golden +++ b/test/snapshots/chordcut.golden @@ -20,7 +20,7 @@ | ls | f.txt g.txt == snap cut grid=120x31 cursor=7,8 -|6: /tmp/pardes-snap/chordcut/cwd/f.txt * Save Tty Collapse D +|6: * /tmp/pardes-snap/chordcut/cwd/f.txt * Save Tty Collapse D |8: 2 2 == snap copied grid=120x31 cursor=10,9 == snap pointpaste grid=120x31 cursor=16,10 @@ -30,7 +30,7 @@ == snap cancelexec grid=120x31 cursor=25,8 |8: 2 2echo B''OO g.txt == snap execafter grid=120x31 cursor=25,8 -|6: /tmp/pardes-snap/chordcut/cwd/f.txt * Save Tty Collapse D /tmp/pardes-snap/chordcut/cwd (echo B''OO) exit 0 Kill Sa +|6: * /tmp/pardes-snap/chordcut/cwd/f.txt * Save Tty Collapse D /tmp/pardes-snap/chordcut/cwd (echo B''OO) exit 0 Kill Sa |7: 1 line 1 Exec % echo B''OO |8: 2 2echo B''OO g.txt BOO |9: 3 line 3 exit 0 @@ -48,13 +48,13 @@ |3: 0-0 d,#608269, 1-1 d,#0d1410, 2-59 #e5eee5,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |4: 0-0 d,#608269, 1-1 d,#0d1410, 2-59 #e5eee5,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |5: 0-0 d,#608269, 1-59 d,#0d1410, 60-60 d,#608269, 61-119 d,#0d1410, -|6: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-32 #b2c3b8,#1d2824, 33-37 #d3b0d9,#1d2824, 38-59 #b2c3b8,#1d2824, 60-61 #ffffff,#496656, 62-62 d,#1d2824, 63-119 #b2c3b8,#1d2824, +|6: 0-0 #ffffff,#496656, 1-1 #000000,#f9cb70,b 2-2 d,#1d2824, 3-32 #b2c3b8,#1d2824, 33-37 #d3b0d9,#1d2824, 38-59 #b2c3b8,#1d2824, 60-61 #ffffff,#496656, 62-62 d,#1d2824, 63-119 #b2c3b8,#1d2824, |7: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-24 #e5eee5,#0d1410, 25-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |9: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |10: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-16 #e5eee5,#0d1410, 17-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |11: 0-0 d,#152019, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-13 #e5eee5,#0d1410, 14-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, -|12: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-32 #becfc4,#26362d, 33-37 #dfbce5,#26362d, 38-59 #becfc4,#26362d, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, +|12: 0-0 #000000,#add38a, 1-1 #000000,#f9cb70,b 2-2 d,#26362d, 3-32 #becfc4,#26362d, 33-37 #dfbce5,#26362d, 38-59 #becfc4,#26362d, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |13: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-10 #f5f7ee,#3c614a, 11-12 #e5eee5,#0d1410, 13-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |14: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |15: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, @@ -77,7 +77,7 @@ |13: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-12 #e5eee5,#0d1410, 13-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, |14: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-12 #e5eee5,#0d1410, 13-59 d,#0d1410, 60-60 d,#608269, 61-61 d,#0d1410, 62-119 #e5eee5,#0d1410, == snap ttyprobe grid=120x31 cursor=42,22 -|12: /tmp/pardes-snap/chordcut/cwd/g.txt * Save Tty Collapse D +|12: * /tmp/pardes-snap/chordcut/cwd/g.txt * Save Tty Collapse D |17: $ /tmp/pardes-snap/chordcut/cwd Tty+bash Save Mode Filter C |18: $ ls |20: $ stty -icanon -echo; printf '\x1b[?2004h\x1b[?1000h\x1b[? diff --git a/test/snapshots/delask.golden b/test/snapshots/delask.golden index 6b5331ec..35562f00 100644 --- a/test/snapshots/delask.golden +++ b/test/snapshots/delask.golden @@ -18,7 +18,7 @@ | | | -| /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del +| * /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del | 1 middle-pane | | @@ -27,7 +27,7 @@ | | | -| /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del +| * /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del | 1 bottom-pane Up == snap asked grid=100x36 cursor=18,20 |20: 1 middle-pane Del: give space to k above j below @@ -40,7 +40,7 @@ |29: == snap typed grid=100x36 cursor=27,20 |20: 1 bottom-pane DelBelow -|28: /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del +|28: * /tmp/pardes-snap/delask/cwd/+New * Save Tty Collapse Del |29: 1 third-pane == snap below-took-click grid=100x36 cursor=17,20 |20: 1 third-pane diff --git a/test/snapshots/fileedit.golden b/test/snapshots/fileedit.golden index 4d367d72..8a9555e0 100644 --- a/test/snapshots/fileedit.golden +++ b/test/snapshots/fileedit.golden @@ -5,7 +5,7 @@ | ls ls | f.txt f.txt | -| /tmp/pardes-snap/fileedit/cwd/f.txt * Save Tty Collapse Del +| * /tmp/pardes-snap/fileedit/cwd/f.txt * Save Tty Collapse Del | 1 liEDIT''MEne 1 | 2 line 2 | 3 line 3 diff --git a/test/snapshots/filewatch.golden b/test/snapshots/filewatch.golden index de44f0ca..ba9bce7c 100644 --- a/test/snapshots/filewatch.golden +++ b/test/snapshots/filewatch.golden @@ -1,7 +1,7 @@ == snap edited grid=210x31 cursor=15,3 |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/filewatch/cwd/w.txt * Save Tty Collapse Del /tmp/pardes-snap/filewatch/cwd Tty+bash Save Mode Filter Collapse Del +| * /tmp/pardes-snap/filewatch/cwd/w.txt * Save Tty Collapse Del /tmp/pardes-snap/filewatch/cwd Tty+bash Save Mode Filter Collapse Del | 1 liMYEDITne 1 ls | 2 line 2 sub w.txt | 3 /tmp/pardes-snap/filewatch/cwd/sub/s.txt @@ -52,7 +52,7 @@ |25: |26: == snap undone grid=210x31 cursor=15,3 -|2: /tmp/pardes-snap/filewatch/cwd/w.txt * Save Tty Collapse Del /tmp/pardes-snap/filewatch/cwd Tty+bash Save Mode Filter Collapse Del +|2: * /tmp/pardes-snap/filewatch/cwd/w.txt * Save Tty Collapse Del /tmp/pardes-snap/filewatch/cwd Tty+bash Save Mode Filter Collapse Del |3: 1 liMYEDITne 1 ls |4: 2 line 2 sub w.txt |5: 3 /tmp/pardes-snap/filewatch/cwd/sub/s.txt diff --git a/test/snapshots/lsp-client-edit.golden b/test/snapshots/lsp-client-edit.golden index c75bae4c..7fe973f9 100644 --- a/test/snapshots/lsp-client-edit.golden +++ b/test/snapshots/lsp-client-edit.golden @@ -1,7 +1,7 @@ == snap formatted grid=100x25 cursor=7,4 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -| /tmp/pardes-snap/lsp-client-edit/cwd/main.rs * Save Tty Collapse Del TreeContext +| * /tmp/pardes-snap/lsp-client-edit/cwd/main.rs * Save Tty Collapse Del TreeContext | 1 // formatted by mock formatted 1 range(s) | 2 mod lib; | 3 diff --git a/test/snapshots/lsp-rename.golden b/test/snapshots/lsp-rename.golden index f6dc8358..9151d1f3 100644 --- a/test/snapshots/lsp-rename.golden +++ b/test/snapshots/lsp-rename.golden @@ -1,7 +1,7 @@ == snap renamed grid=110x25 cursor=17,10 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -| /tmp/pardes-snap/lsp-rename/cwd/rename.zig * Save Tty Collapse Del TreeContext +| * /tmp/pardes-snap/lsp-rename/cwd/rename.zig * Save Tty Collapse Del TreeContext | 1 const std = @import("std"); renamed 2 range(s) | 2 | 3 fn renamed_helper(x: u32) u32 { diff --git a/test/snapshots/lspcomplete.golden b/test/snapshots/lspcomplete.golden index 8efa7edf..0a58a231 100644 --- a/test/snapshots/lspcomplete.golden +++ b/test/snapshots/lspcomplete.golden @@ -16,7 +16,7 @@ | 12 // note. see below | 13 == snap indent grid=120x41 cursor=11,12 -|2: ^ /tmp/pardes-snap/lspcomplete/cwd/a.zig * Save Tty Collapse Del TreeContext +|2: ^* /tmp/pardes-snap/lspcomplete/cwd/a.zig * Save Tty Collapse Del TreeContext |12: 10 } == snap commentindent grid=120x41 cursor=19,14 |14: 12 // note. see below @@ -28,17 +28,17 @@ |40: 2 a.zig:3:5-12 cinnabar cinnabar, == snap refill grid=120x41 cursor=16,10 == snap step grid=120x41 cursor=19,4 -|2: /tmp/pardes-snap/lspcomplete/cwd/a.zig * Save Tty Collapse Del TreeContext +|2: * /tmp/pardes-snap/lspcomplete/cwd/a.zig * Save Tty Collapse Del TreeContext == snap txtindent grid=120x41 cursor=15,3 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -|^ /tmp/pardes-snap/lspcomplete/cwd/notes.txt * Save Tty Collapse Del +|^* /tmp/pardes-snap/lspcomplete/cwd/notes.txt * Save Tty Collapse Del | 1 abc. def ghi | 2 == snap nosemi grid=100x19 cursor=29,9 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -|^ /tmp/pardes-snap/lspcomplete/cwd/c.zig * Save Tty Collapse Del TreeContext +|^* /tmp/pardes-snap/lspcomplete/cwd/c.zig * Save Tty Collapse Del TreeContext | 1 const Shade = enum { | 2 verdigris, | 3 cinnabar, @@ -58,7 +58,7 @@ == snap nobrace grid=100x19 cursor=16,10 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -|^ /tmp/pardes-snap/lspcomplete/cwd/d.zig * Save Tty Collapse Del TreeContext +|^* /tmp/pardes-snap/lspcomplete/cwd/d.zig * Save Tty Collapse Del TreeContext | 1 const Shade = enum { | 2 verdigris, | 3 cinnabar, diff --git a/test/snapshots/lspcompletemoved.golden b/test/snapshots/lspcompletemoved.golden index f12b3f0c..797bcd11 100644 --- a/test/snapshots/lspcompletemoved.golden +++ b/test/snapshots/lspcompletemoved.golden @@ -1,7 +1,7 @@ == snap typedthrough grid=120x21 cursor=16,14 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -|^ /tmp/pardes-snap/lspcompletemoved/cwd/a.zig * Save Tty Collapse Del TreeContext +|^* /tmp/pardes-snap/lspcompletemoved/cwd/a.zig * Save Tty Collapse Del TreeContext | 1 const Shade = enum { | 2 verdigris, | 3 cinnabar, diff --git a/test/snapshots/lsprelpath.golden b/test/snapshots/lsprelpath.golden index 96dc4c99..e2201d5c 100644 --- a/test/snapshots/lsprelpath.golden +++ b/test/snapshots/lsprelpath.golden @@ -1,7 +1,7 @@ == snap absrow grid=100x35 cursor=16,7 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -|^ /tmp/pardes-snap/lsprelpath/cwd/sub/parent.zig * Save Tty Collapse Del TreeContext +|^* /tmp/pardes-snap/lsprelpath/cwd/sub/parent.zig * Save Tty Collapse Del TreeContext | 1 const shade = @import("../shade.zig"); | 2 | 3 pub fn pick(s: shade.Shade) u8 { @@ -35,7 +35,7 @@ | 1 /tmp/pardes-snap/lsprelpath/cwd/shade.zig:2:5-13 verdigris verdigris, | 2 /tmp/pardes-snap/lsprelpath/cwd/shade.zig:3:5-12 cinnabar cinnabar, == snap abslook grid=100x35 cursor=19,19 -|2: /tmp/pardes-snap/lsprelpath/cwd/sub/parent.zig * Save Tty Collapse Del TreeContext +|2: * /tmp/pardes-snap/lsprelpath/cwd/sub/parent.zig * Save Tty Collapse Del TreeContext |17: /tmp/pardes-snap/lsprelpath/cwd/shade.zig Save Tty Collapse Del TreeContext |18: 1 pub const Shade = enum { |19: 2 verdigris, @@ -45,7 +45,7 @@ == style abslook grid=100x35 |0: 0-70 #b2c3b8,#1d2824, 71-99 d,#1d2824, |1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-99 d,#26362d, -|2: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-48 #d3b0d9,#1d2824, 49-84 #b2c3b8,#1d2824, 85-99 d,#1d2824, +|2: 0-0 #ffffff,#496656, 1-1 #000000,#f9cb70,b 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-48 #d3b0d9,#1d2824, 49-84 #b2c3b8,#1d2824, 85-99 d,#1d2824, |3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-20 #e5eee5,#0d1410, 21-27 #c9a7ed,#0d1410, 28-28 #e5eee5,#0d1410, 29-42 #b8d88a,#0d1410, 43-44 #e5eee5,#0d1410, 45-99 d,#0d1410, |4: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410, |5: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-10 #e5eee5,#0d1410, 11-12 #c9a7ed,#0d1410, 13-38 #e5eee5,#0d1410, 39-99 d,#0d1410, @@ -81,7 +81,7 @@ == snap subrow grid=100x35 cursor=16,7 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -|^ /tmp/pardes-snap/lsprelpath/cwd/sub/subdir.zig * Save Tty Collapse Del TreeContext +|^* /tmp/pardes-snap/lsprelpath/cwd/sub/subdir.zig * Save Tty Collapse Del TreeContext | 1 const tint = @import("inner/tint.zig"); | 2 | 3 pub fn hue(t: tint.Tint) u8 { @@ -115,7 +115,7 @@ | 1 inner/tint.zig:2:5-9 ochre ochre, | 2 inner/tint.zig:3:5-10 indigo indigo, == snap sublook grid=100x35 cursor=15,19 -|2: /tmp/pardes-snap/lsprelpath/cwd/sub/subdir.zig * Save Tty Collapse Del TreeContext +|2: * /tmp/pardes-snap/lsprelpath/cwd/sub/subdir.zig * Save Tty Collapse Del TreeContext |17: /tmp/pardes-snap/lsprelpath/cwd/sub/inner/tint.zig Save Tty Collapse Del TreeContext |18: 1 pub const Tint = enum { |19: 2 ochre, @@ -125,7 +125,7 @@ == style sublook grid=100x35 |0: 0-70 #b2c3b8,#1d2824, 71-99 d,#1d2824, |1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-99 d,#26362d, -|2: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-48 #d3b0d9,#1d2824, 49-84 #b2c3b8,#1d2824, 85-99 d,#1d2824, +|2: 0-0 #ffffff,#496656, 1-1 #000000,#f9cb70,b 2-2 d,#1d2824, 3-38 #b2c3b8,#1d2824, 39-48 #d3b0d9,#1d2824, 49-84 #b2c3b8,#1d2824, 85-99 d,#1d2824, |3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-19 #e5eee5,#0d1410, 20-26 #c9a7ed,#0d1410, 27-27 #e5eee5,#0d1410, 28-43 #b8d88a,#0d1410, 44-45 #e5eee5,#0d1410, 46-99 d,#0d1410, |4: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410, |5: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-10 #e5eee5,#0d1410, 11-12 #c9a7ed,#0d1410, 13-35 #e5eee5,#0d1410, 36-99 d,#0d1410, diff --git a/test/snapshots/msg.golden b/test/snapshots/msg.golden index d9a4a079..293e137c 100644 --- a/test/snapshots/msg.golden +++ b/test/snapshots/msg.golden @@ -45,7 +45,7 @@ |3: 1 AGAIN |4: 2 == snap msgbuiltin grid=70x17 cursor=29,5 -|2: /tmp/pardes-snap/msg/cwd/a.txt * Save Tty Collapse Del +|2: * /tmp/pardes-snap/msg/cwd/a.txt * Save Tty Collapse Del |3: 1 AGAIN hand-written report |5: 3 Msg hand-written report == snap msgtail grid=70x17 cursor=32,7 diff --git a/test/snapshots/multicursor.golden b/test/snapshots/multicursor.golden index 96762bd5..010199b1 100644 --- a/test/snapshots/multicursor.golden +++ b/test/snapshots/multicursor.golden @@ -52,13 +52,13 @@ |29: 0-0 d,#608269, 1-1 d,#0d1410, 2-74 #e5eee5,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, |30: 0-0 d,#608269, 1-1 d,#0d1410, 2-74 #e5eee5,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, == snap edited-at-three grid=150x31 cursor=9,9 -|6: /tmp/pardes-snap/multicursor/cwd/f.txt * Save Tty Collapse Del +|6: * /tmp/pardes-snap/multicursor/cwd/f.txt * Save Tty Collapse Del |7: 1 >>line 1 |8: 2 >>line 2 |9: 3 >>line 3 == snap three-selections grid=150x31 cursor=8,9 == style three-selections grid=150x31 -|6: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-35 #becfc4,#26362d, 36-40 #dfbce5,#26362d, 41-64 #becfc4,#26362d, 65-74 d,#26362d, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|6: 0-0 #000000,#add38a, 1-1 #000000,#f9cb70,b 2-2 d,#26362d, 3-35 #becfc4,#26362d, 36-40 #dfbce5,#26362d, 41-64 #becfc4,#26362d, 65-74 d,#26362d, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, |7: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-7 #f5f7ee,#243a2d, 8-8 #3c614a,#f5f7ee, 9-14 #e5eee5,#0d1410, 15-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, |8: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-7 #f5f7ee,#243a2d, 8-8 #3c614a,#f5f7ee, 9-14 #e5eee5,#0d1410, 15-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, |9: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-8 #f5f7ee,#3c614a, 9-14 #e5eee5,#0d1410, 15-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, diff --git a/test/snapshots/ninep.golden b/test/snapshots/ninep.golden index 65fb38ec..5391a552 100644 --- a/test/snapshots/ninep.golden +++ b/test/snapshots/ninep.golden @@ -73,7 +73,7 @@ |7: 5 return 7; @7: 0-0 d,#608269,,body 1-1 d,#0d1410,,body 2-6 #59675c,#0d1410,,body 7-19 #e5eee5,#0d1410,,body 20-89 d,#0d1410,,body == snap edited-with-colors-off grid=90x15 cursor=7,4 -|2: /tmp/pardes-snap/ninep/cwd/demo.zig * Save Tty Collapse Del TreeContext +|2: * /tmp/pardes-snap/ninep/cwd/demo.zig * Save Tty Collapse Del TreeContext |3: 1 // café λ 界 |4: 2 const greeting = "café λ 界 é"; |5: 3 @@ -83,8 +83,8 @@ |9: 7 } |10: 8 == 9p edited-with-colors-off grid=90x15 cursor=7,4,block -|2: /tmp/pardes-snap/ninep/cwd/demo.zig * Save Tty Collapse Del TreeContext -@2: 0-1 #000000,#add38a,,tagline 2-2 d,#26362d,,tagline 3-29 #becfc4,#26362d,,tagline 30-37 #dfbce5,#26362d,,tagline 38-73 #becfc4,#26362d,,tagline 74-89 d,#26362d,,tagline +|2: * /tmp/pardes-snap/ninep/cwd/demo.zig * Save Tty Collapse Del TreeContext +@2: 0-0 #000000,#add38a,,tagline 1-1 #000000,#f9cb70,b,tagline 2-2 d,#26362d,,tagline 3-29 #becfc4,#26362d,,tagline 30-37 #dfbce5,#26362d,,tagline 38-73 #becfc4,#26362d,,tagline 74-89 d,#26362d,,tagline |3: 1 // café λ 界 +3: 13="é" 15="λ" 17="界" 18="" @3: 0-0 d,#608269,,body 1-1 d,#0d1410,,body 2-6 #59675c,#0d1410,,body 7-18 #e5eee5,#0d1410,,body 19-89 d,#0d1410,,body @@ -104,7 +104,7 @@ |10: 8 @10: 0-0 d,#608269,,body 1-1 d,#0d1410,,body 2-6 #59675c,#0d1410,,body 7-89 d,#0d1410,,body == style colors-on grid=90x15 -|2: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-29 #becfc4,#26362d, 30-37 #dfbce5,#26362d, 38-73 #becfc4,#26362d, 74-89 d,#26362d, +|2: 0-0 #000000,#add38a, 1-1 #000000,#f9cb70,b 2-2 d,#26362d, 3-29 #becfc4,#26362d, 30-37 #dfbce5,#26362d, 38-73 #becfc4,#26362d, 74-89 d,#26362d, |3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-17 #b9f480,#0d1410, 18-18 d,d, 19-81 d,#0d1410, 82-82 d,#1d2824, 83-88 #b2c3b8,#1d2824, 89-89 d,#1d2824, |4: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-23 #e5eee5,#0d1410, 24-32 #b8d88a,#0d1410, 33-33 d,d, 34-36 #b8d88a,#0d1410, 37-37 #e5eee5,#0d1410, 38-89 d,#0d1410, |5: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-89 d,#0d1410, @@ -128,7 +128,7 @@ == style resized grid=105x20 |0: 0-70 #b2c3b8,#1d2824, 71-104 d,#1d2824, |1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-104 d,#26362d, -|2: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-29 #becfc4,#26362d, 30-37 #dfbce5,#26362d, 38-73 #becfc4,#26362d, 74-104 d,#26362d, +|2: 0-0 #000000,#add38a, 1-1 #000000,#f9cb70,b 2-2 d,#26362d, 3-29 #becfc4,#26362d, 30-37 #dfbce5,#26362d, 38-73 #becfc4,#26362d, 74-104 d,#26362d, |3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-17 #b9f480,#0d1410, 18-18 d,d, 19-96 d,#0d1410, 97-97 d,#1d2824, 98-103 #b2c3b8,#1d2824, 104-104 d,#1d2824, |4: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #616f64,#0d1410,b 7-23 #e5eee5,#0d1410, 24-32 #b8d88a,#0d1410, 33-33 d,d, 34-36 #b8d88a,#0d1410, 37-37 #e5eee5,#0d1410, 38-104 d,#0d1410, |5: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-104 d,#0d1410, @@ -151,8 +151,8 @@ @0: 0-70 #b2c3b8,#1d2824,,tagline 71-104 d,#1d2824,,tagline |1: New Tty Find Grep Joincol Delcol @1: 0-1 #ffffff,#887455,,tagline 2-2 d,#26362d,,tagline 3-34 #becfc4,#26362d,,tagline 35-104 d,#26362d,,tagline -|2: /tmp/pardes-snap/ninep/cwd/demo.zig * Save Tty Collapse Del TreeContext -@2: 0-1 #000000,#add38a,,tagline 2-2 d,#26362d,,tagline 3-29 #becfc4,#26362d,,tagline 30-37 #dfbce5,#26362d,,tagline 38-73 #becfc4,#26362d,,tagline 74-104 d,#26362d,,tagline +|2: * /tmp/pardes-snap/ninep/cwd/demo.zig * Save Tty Collapse Del TreeContext +@2: 0-0 #000000,#add38a,,tagline 1-1 #000000,#f9cb70,b,tagline 2-2 d,#26362d,,tagline 3-29 #becfc4,#26362d,,tagline 30-37 #dfbce5,#26362d,,tagline 38-73 #becfc4,#26362d,,tagline 74-104 d,#26362d,,tagline |3: 1 // café λ 界 Colors +3: 13="é" 15="λ" 17="界" 18="" @3: 0-0 d,#608269,,body 1-1 d,#0d1410,,body 2-6 #59675c,#0d1410,,body 7-18 #b9f480,#0d1410,,body 19-96 d,#0d1410,,body 97-97 d,#1d2824,,tagline 98-103 #b2c3b8,#1d2824,,tagline 104-104 d,#1d2824,,tagline @@ -192,7 +192,7 @@ == snap resized grid=105x20 cursor=7,4 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -| /tmp/pardes-snap/ninep/cwd/demo.zig * Save Tty Collapse Del TreeContext +| * /tmp/pardes-snap/ninep/cwd/demo.zig * Save Tty Collapse Del TreeContext | 1 // café λ 界 Colors | 2 const greeting = "café λ 界 é"; | 3 diff --git a/test/snapshots/pipe.golden b/test/snapshots/pipe.golden index 14353ebf..208986a8 100644 --- a/test/snapshots/pipe.golden +++ b/test/snapshots/pipe.golden @@ -5,7 +5,7 @@ | 1 alpha beta |tr a-z A-Z | 2 gamma delta == snap filtered grid=180x13 cursor=17,3 -|2: /tmp/pardes-snap/pipe/cwd/pipe.txt * Save Tty Collapse Del +|2: * /tmp/pardes-snap/pipe/cwd/pipe.txt * Save Tty Collapse Del |3: 1 ALPHA BETA == snap undone grid=180x13 cursor=17,3 |3: 1 alpha beta diff --git a/test/snapshots/savepane.golden b/test/snapshots/savepane.golden index 5fcdf656..f1a47696 100644 --- a/test/snapshots/savepane.golden +++ b/test/snapshots/savepane.golden @@ -4,7 +4,7 @@ | /tmp/pardes-snap/savepane/cwd Tty+bash Save Mode Filter Collapse Del | ls | -| /tmp/pardes-snap/savepane/cwd/+New * Save Tty Collapse Del +| * /tmp/pardes-snap/savepane/cwd/+New * Save Tty Collapse Del | 1 scratch bytes == snap prompt grid=100x31 cursor=99,7 |6: 1 scratch bytes Save diff --git a/test/snapshots/selregex.golden b/test/snapshots/selregex.golden index 8b45ae43..fa29b661 100644 --- a/test/snapshots/selregex.golden +++ b/test/snapshots/selregex.golden @@ -51,7 +51,7 @@ |4: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-10 #f5f7ee,#243a2d, 11-11 #3c614a,#f5f7ee, 12-18 #e5eee5,#0d1410, 19-19 #f8c082,#0d1410, 20-20 #e5eee5,#0d1410, 21-159 d,#0d1410, |5: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-10 #f5f7ee,#243a2d, 11-11 #3c614a,#f5f7ee, 12-20 #e5eee5,#0d1410, 21-21 #f8c082,#0d1410, 22-22 #e5eee5,#0d1410, 23-159 d,#0d1410, == snap commented grid=160x25 cursor=14,3 -|2: /tmp/pardes-snap/selregex/cwd/m.zig * Save Tty Collapse Del TreeContext +|2: * /tmp/pardes-snap/selregex/cwd/m.zig * Save Tty Collapse Del TreeContext |3: 1 // const one = 1; |4: 2 // const two = 2; |5: 3 // const three = 3; diff --git a/test/snapshots/tagbottom.golden b/test/snapshots/tagbottom.golden index 8d770358..637d3143 100644 --- a/test/snapshots/tagbottom.golden +++ b/test/snapshots/tagbottom.golden @@ -164,7 +164,7 @@ |8: 7 mmm |9: 8 nnn |10: 9 ooo -|11: /tmp/pardes-snap/tagbottom/cwd/top.txt * Save Tty Collapse Del bottom.txt +|11: * /tmp/pardes-snap/tagbottom/cwd/top.txt * Save Tty Collapse Del bottom.txt |12: 1 ZZONE == file cut-buffer aaa diff --git a/test/snapshots/tagcmd.golden b/test/snapshots/tagcmd.golden index 7853de32..83106d0d 100644 --- a/test/snapshots/tagcmd.golden +++ b/test/snapshots/tagcmd.golden @@ -1,7 +1,7 @@ == snap edited grid=100x31 cursor=13,3 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -| /tmp/pardes-snap/tagcmd/cwd/c.txt * Save Tty Collapse Del +| * /tmp/pardes-snap/tagcmd/cwd/c.txt * Save Tty Collapse Del | 1 ZZEDITline 1 | 2 line 2 | 3 line 3 diff --git a/test/snapshots/tagpath.golden b/test/snapshots/tagpath.golden index ba22a859..2363bcd6 100644 --- a/test/snapshots/tagpath.golden +++ b/test/snapshots/tagpath.golden @@ -43,15 +43,15 @@ |29: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410, |30: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-99 d,#0d1410, == snap yanked-path grid=100x31 cursor=41,3 -|2: /tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del +|2: * /tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del |3: 1 l/tmp/pardes-snap/tagpath/cwd/c.txtine 1 == snap drag-cross grid=100x31 cursor=41,2 == style drag-cross grid=100x31 -|2: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-10 #becfc4,#26362d, 11-41 #f5f7ee,#3c614a, 42-60 #becfc4,#26362d, 61-99 d,#26362d, +|2: 0-0 #000000,#add38a, 1-1 #000000,#f9cb70,b 2-2 d,#26362d, 3-10 #becfc4,#26362d, 11-41 #f5f7ee,#3c614a, 42-60 #becfc4,#26362d, 61-99 d,#26362d, |3: 0-0 d,#608269, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-7 #e5eee5,#0d1410, 8-41 #f5f7ee,#3c614a, 42-46 #e5eee5,#0d1410, 47-99 d,#0d1410, == snap name-draft grid=100x31 cursor=11,2 -|2: /renamed/tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del +|2: * /renamed/tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del == snap name-cancelled grid=100x31 cursor=41,3 -|2: /tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del +|2: * /tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del == snap name-committed grid=100x31 cursor=41,3 -|2: /renamed/tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del +|2: * /renamed/tmp/pardes-snap/tagpath/cwd/c.txt * Save Tty Collapse Del diff --git a/test/snapshots/theme.golden b/test/snapshots/theme.golden index 4986eaae..5e7a3dc4 100644 --- a/test/snapshots/theme.golden +++ b/test/snapshots/theme.golden @@ -612,9 +612,9 @@ |30: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, == snap acme grid=100x31 cursor=4,5 == style acme grid=100x31 -|0: 0-50 #000000,#eaffff, 51-59 #000000,#fafa92, 60-70 #000000,#eaffff, 71-99 d,#eaffff, -|1: 0-1 #000000,#c5a87f, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 #000000,#c5a87f, 52-52 d,#eaffff, 53-84 #000000,#eaffff, 85-99 d,#eaffff, -|2: 0-1 #ffffff,#663fe0, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-51 #ffffff,#353a58, 52-52 d,#eaffff, 53-99 #000000,#eaffff, +|0: 0-50 #000000,#eaffff, 51-59 #000000,#9eeeee, 60-70 #000000,#eaffff, 71-99 d,#eaffff, +|1: 0-1 #000000,#8888cc, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 #000000,#8888cc, 52-52 d,#eaffff, 53-84 #000000,#eaffff, 85-99 d,#eaffff, +|2: 0-1 #000000,#8888cc, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-51 #000000,#b9c3e5, 52-52 d,#eaffff, 53-99 #000000,#eaffff, |3: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, |4: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, |5: 0-99 d,#ffffea, @@ -629,7 +629,7 @@ |14: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, |15: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, |16: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, -|17: 0-1 #ffffff,#353a58, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|17: 0-1 #000000,#b9c3e5, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-51 d,#ffffea, 52-99 #000000,#ffffea, |18: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, |19: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, |20: 0-51 d,#ffffea, 52-99 #000000,#ffffea, diff --git a/test/snapshots/themesel.golden b/test/snapshots/themesel.golden index 97d3ed41..65a4f563 100644 --- a/test/snapshots/themesel.golden +++ b/test/snapshots/themesel.golden @@ -630,8 +630,8 @@ |30: 24 Theme adwaita_dark == style step-acme grid=100x31 |0: 0-70 #000000,#eaffff, 71-99 d,#eaffff, -|1: 0-1 #000000,#c5a87f, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-99 d,#eaffff, -|2: 0-1 #ffffff,#353a58, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, +|1: 0-1 #000000,#8888cc, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-99 d,#eaffff, +|2: 0-1 #000000,#b9c3e5, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, |3: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, |4: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, |5: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, @@ -646,7 +646,7 @@ |14: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, |15: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, |16: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|17: 0-1 #ffffff,#663fe0, 2-2 d,#eaffff, 3-61 #000000,#eaffff, 62-99 d,#eaffff, +|17: 0-1 #000000,#8888cc, 2-2 d,#eaffff, 3-61 #000000,#eaffff, 62-99 d,#eaffff, |18: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-19 #000000,#ffffea, 20-92 d,#ffffea, 93-93 d,#eaffff, 94-98 #000000,#eaffff, 99-99 d,#eaffff, |19: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, |20: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-23 #000000,#ffffea, 24-99 d,#ffffea, @@ -709,8 +709,8 @@ == snap step-back grid=100x31 cursor=7,26 == style step-back grid=100x31 |0: 0-70 #000000,#eaffff, 71-99 d,#eaffff, -|1: 0-1 #000000,#c5a87f, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-99 d,#eaffff, -|2: 0-1 #ffffff,#353a58, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, +|1: 0-1 #000000,#8888cc, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-99 d,#eaffff, +|2: 0-1 #000000,#b9c3e5, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, |3: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, |4: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, |5: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, @@ -725,7 +725,7 @@ |14: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, |15: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, |16: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|17: 0-1 #ffffff,#663fe0, 2-2 d,#eaffff, 3-61 #000000,#eaffff, 62-99 d,#eaffff, +|17: 0-1 #000000,#8888cc, 2-2 d,#eaffff, 3-61 #000000,#eaffff, 62-99 d,#eaffff, |18: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-92 d,#ffffea, 93-93 d,#eaffff, 94-98 #000000,#eaffff, 99-99 d,#eaffff, |19: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-23 #000000,#ffffea, 24-99 d,#ffffea, |20: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, @@ -769,7 +769,7 @@ |29: 27 line 27 |30: 28 line 28 == style picked grid=100x31 -|2: 0-1 #ffffff,#663fe0, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, +|2: 0-1 #000000,#8888cc, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, |3: 0-1 d,#ffffea, 2-6 #999980,#ffffea,b 7-12 #000000,#ffffea, 13-99 d,#ffffea, |7: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, |8: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, diff --git a/test/snapshots/yankpaste.golden b/test/snapshots/yankpaste.golden index 2efcd66d..12e7a74e 100644 --- a/test/snapshots/yankpaste.golden +++ b/test/snapshots/yankpaste.golden @@ -1,7 +1,7 @@ == snap linewise-below grid=100x31 cursor=13,6 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Exit | New Tty Find Grep Joincol Delcol -| /tmp/pardes-snap/yankpaste/cwd/f.txt * Save Tty Collapse Del +| * /tmp/pardes-snap/yankpaste/cwd/f.txt * Save Tty Collapse Del | 1 line 1 | 2 line 2 | 3 line 3 -- cgit v1.3 From c5c2baf21bba0fb8f66e7d2ee096818d6bd5a8e0 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 20:31:56 -0300 Subject: Motion flavours differ by design, and drive notice drops and pane moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flavours looked alike because Lift, the only motion that read them, moves a shadow a few pixels: overshoot and wind-up were sub-pixel. By design now: crisp leaves at full speed and never passes its mark; smooth (the default) eases in and out in twice crisp's time, a slide arriving in 208 ms (inside §8.1's 220); bouncy passes its mark by ~30% and settles twice; playful winds up, passes by ~40%, and stretches and squashes, text included (the user's choice). Small motions are exaggerated by the flavour's gain. Motion gains launch, gain, span and norm, and ease(u)/speed(u) for moves of fixed length. The notice drop follows ease() over its fall (alpha never behind its position) and stays inside its pane's body past its row. Panel tracks carry the flavour in Track.motion (the old padding byte; macOS header name follows); opening and moving slides, zooms and rises follow it over span x their frames; landed, a pane is exactly its target; closing keeps its own exit. A pane past its mark never takes a neighbour's click; a rising one stays in its box. Tests: each flavour's lift trajectory distinct; flavour character; exact landing bit for bit; a stretched pane's drawn cells map back; the overshoot hit rule; the vertical clip at peak; legacy tests pinned to crisp. GUI golden 18-mid-transition changes (the retuned smooth slide at frame 6). Shared files: src/animation.zig, src/Presentation.zig, src/Messages.zig, src/macos.zig, src/config.zig, src/pardes.zig tests, src/ninep/screen.zig test, docs/effects.md, docs/config.md. --- docs/config.md | 2 +- docs/effects.md | 37 +++++++++---- src/Messages.zig | 10 +++- src/Presentation.zig | 59 +++++++++++++++++++-- src/animation.zig | 147 +++++++++++++++++++++++++++++++++++++++++++++------ src/config.zig | 2 + src/draw.zig | 48 +++++++++++++++-- src/gui/gui.zig | 37 +++++++++++++ src/macos.zig | 4 +- src/ninep/screen.zig | 2 + src/pardes.zig | 6 +++ test/gui-goldens.txt | 2 +- 12 files changed, 316 insertions(+), 40 deletions(-) (limited to 'docs/effects.md') diff --git a/docs/config.md b/docs/config.md index 324b54e9..921f817e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -537,7 +537,7 @@ Lift rim a hairline just above the focused tag (light, or shade on a lig Lift auto a shadow on a light page; on a dark one the others recede Lift off InactiveDim 30 the unfocused panes' text fades 30% toward its ground -Motion smooth off, crisp, smooth, bouncy or playful +Motion smooth off, crisp, smooth (the default), bouncy or playful GripWidth 150 the grip's button and the scrollbar under it, percent of the theme's rail_px (12px at a 17px tagline; 50 to 300) ``` diff --git a/docs/effects.md b/docs/effects.md index eefa52d3..3f375b7d 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -26,18 +26,31 @@ why. The verdict is the user's. ## Motion flavours -`Motion off|crisp|smooth|bouncy|playful` (default smooth) is one parameter set +`Motion off|crisp|smooth|bouncy|playful` (default smooth, tuned so arrivals +land inside §8.1's 220 ms; bouncy and playful run longer, opt-in) is one parameter set (animation.Motion) every fx animation reads, so a flavour is data, not a branch in each effect. Input is never blocked, and a new target always retargets from the current position and velocity. -| flavour | timing (ω) | follow-through (ζ) | anticipation | squash/stretch | secondary lag | arcs | -|---|---|---|---|---|---|---| -| off | instant | — | — | — | — | — | -| crisp | 44 | 1 (none) | — | — | in step | — | -| smooth | 26 | 1 (none) | — | — | 0.85 | 0.04 | -| bouncy | 30 | 0.55 (overshoots ~12%) | — | 0.12 | 0.8 | 0.08 | -| playful | 24 | 0.42 (overshoots ~23%) | 0.08 of the move | 0.25 | 0.7 | 0.15 | +| flavour | timing (ω, length) | follow-through (ζ) | anticipation | squash/stretch | exaggeration (gain) | secondary lag | arcs | +|---|---|---|---|---|---|---|---| +| off | instant | — | — | — | — | — | — | +| crisp | 60, 0.6× (leaves at full speed) | 1 (none) | — | — | 1 | in step | — | +| smooth | 34, 1.1× (slow in, slow out; ≤220 ms) | 1 (none) | — | — | 1 | 0.85 | 0.04 | +| bouncy | 24, 1.4× | 0.35 (overshoots ~30%, settles twice) | — | 0.15 | 1.25 | 0.8 | 0.08 | +| playful | 18, 1.7× | 0.28 (overshoots ~40%) | 0.15 of the move | 0.35 | 1.4 | 0.55 | 0.15 | + +The flavours are distinct by design, not tuning: crisp leaves at full speed +and never passes its mark; smooth takes twice as long and eases in and out; bouncy clearly passes its mark and settles back once or twice; +playful winds up the other way first, flies well past, and stretches along +its path and squashes as it lands, its text with it (the user's choice); a +pointer inverts the same stretched box, and landed, a pane is exactly its +target. A motion only a few pixels big (Lift) is +exaggerated by the flavour's gain so its character shows. The flavours drive +the Lift spring (time-based), the notice drop and the pane moves (PanelSlide, +PanelZoom, PanelVertical opening or moving, whose length scales with the +flavour); a closing pane keeps its effect's own exit. Chart and side-by-side +video: .scratch/render/motion/compare/. Which principles each motion uses: @@ -45,8 +58,12 @@ Which principles each motion uses: (bouncy and playful lift past full and settle back), anticipation (playful dips before it rises; below zero nothing is drawn, so it reads as a beat before the lift), staging (only the focused pane lifts; nothing else moves - with a focus change). Squash, stretch, arcs and secondary lag have nothing - to act on in a lift. + with a focus change), exaggeration (the flavour's gain). Squash, stretch, + arcs and secondary lag have nothing to act on in a lift. +- **Notice drop and pane moves**: timing and follow-through from the flavour's + spring over the move's length; anticipation (playful). A pane past its mark + never takes a neighbour's click, a rising one stays in its box, and a notice + past its row stays in its pane's body. - **Cursor** (G3, next): designed around the same set: glide on the spring, stretch along the path, an arc on long jumps, the trailing corners as the secondary action. diff --git a/src/Messages.zig b/src/Messages.zig index ea62041f..800218b7 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -356,11 +356,14 @@ pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion { // last frames only settle it. No overshoot: at a row's height // and a dozen frames, a follow-through is a pixel or two that // reads as a jitter, not a bounce. - const left = 1 - t; + // The drop follows the Motion flavour (its wind-up, overshoot + // and all) over the fall's length. + const left = 1 - animation.Motion.of(p.settings.motion).ease(t); // The colour leads the motion: solid by half way, so what // lands is already legible rather than still fading up. const faded = 1 - @min(1, 2 * t); - break :blk .{ .alpha = 1 - faded * faded, .slide = -(left * left * left) }; + // And never behind it, however fast the flavour leaves. + break :blk .{ .alpha = @max(1 - faded * faded, std.math.clamp(1 - left, 0, 1)), .slide = -left }; }, .leaving => blk: { // Counted to its last frame rather than past it, so that frame @@ -644,6 +647,9 @@ test "a message falls in, lingers past the input that dismisses it, and dissolve while (p.nextEffect()) |_| {} const pane = p.panes[p.active].?; try std.testing.expect(p.settings.message_animation); + // Crisp: the flavour whose drop leaves at full speed and lands without + // passing its row (the others are the Motion tests'). + p.settings.motion = .crisp; try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160")); try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello")); try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase); diff --git a/src/Presentation.zig b/src/Presentation.zig index 3b5bb3e0..15ed9ea6 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -183,7 +183,7 @@ pub fn sync(self: *Presentation, p: *Pardes) void { prior.?.from else old.box; - const next: Track = .{ + const next = stamp(p, .{ .serial = pane.serial, .pane = @intCast(id), .phase = if (!same_lifetime or @@ -191,19 +191,19 @@ pub fn sync(self: *Presentation, p: *Pardes) void { .effect = effect, .from = from, .to = target, - }; + }); self.tracks[id] = next; changed = true; animated_change = true; } else { - const next: Track = .{ + const next = stamp(p, .{ .serial = pane.serial, .pane = @intCast(id), .phase = .opening, .effect = effect, .from = openingBox(effect, target, p.screen_w), .to = target, - }; + }); self.tracks[id] = next; changed = true; animated_change = true; @@ -215,6 +215,21 @@ pub fn sync(self: *Presentation, p: *Pardes) void { if (changed and self.acknowledged) self.pending = true; } +/// A pane that moves (slides, zooms or rises) follows the Motion flavour: +/// its curve, and a length in proportion (animation.Motion.span). +fn stamp(p: *const Pardes, track: Track) Track { + var stamped = track; + switch (track.effect) { + .slide, .zoom, .vertical => { + const motion = animation.Motion.of(p.settings.motion); + stamped.motion = @intFromEnum(p.settings.motion) + 1; + stamped.frame_count = @intFromFloat(@max(2, @round(@as(f32, @floatFromInt(track.effect.frames())) * motion.span))); + }, + else => {}, + } + return stamped; +} + fn appendClosing(self: *Presentation, track: Track) bool { std.debug.assert(track.phase == .closing); const baseline = self.previous_layout[track.pane] orelse return false; @@ -388,6 +403,10 @@ pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32 .slide, .zoom, .vertical => { const target = track.contentBox(); if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue; + // Past its mark (a flavour's overshoot or wind-up) a pane + // is drawn over a neighbour; a point there is the + // neighbour's, never the moving pane's. + if (pastMark(track) and !boxContainsPoint(target, x, y)) continue; const shown = track.presented(); if (!boxContainsPoint(shown, x, y)) { if (track.effect == .vertical) return null; @@ -410,6 +429,13 @@ pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32 return .{ .x = x, .y = y }; } +/// A flavour's overshoot or wind-up: the pane is drawn beyond the path from +/// where it was to where it goes. +fn pastMark(track: Track) bool { + const t = track.amount(); + return t < 0 or t > 1; +} + fn boxContainsPoint(box: Box, x: f32, y: f32) bool { return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h; } @@ -436,6 +462,8 @@ pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u switch (track.effect) { .slide, .zoom => { const shown = track.presented(); + // Past its mark, the neighbour's cells stay the neighbour's. + if (pastMark(track) and !boxContainsCell(track.to, col, row)) continue; if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; const x = @as(f32, @floatFromInt(col)) + 0.5; const y = @as(f32, @floatFromInt(row)) + 0.5; @@ -688,6 +716,29 @@ test "presentation submission failure preserves shown geometry until retry is ac try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?); } +test "a pane past its mark never takes a neighbour's click" { + // Bouncy, at its peak: pane 0 slides right to 0..40 and swings past it, + // over the neighbour at 40..80. + var presentation: Presentation = .{}; + var track: Track = .{ .serial = 7, .effect = .slide, .motion = @intFromEnum(animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = -40, .y = 0, .w = 40, .h = 20 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 20 } }; + var peak: u16 = 0; + var furthest: f32 = -1e9; + while (track.frame < track.frames()) : (track.frame += 1) { + if (track.presented().x > furthest) { + furthest = track.presented().x; + peak = track.frame; + } + } + track.frame = peak; + try std.testing.expect(track.presented().x > 2); + presentation.shown_tracks[0] = track; + // A cell of the neighbour under the overshoot is the neighbour's own. + const col: u16 = 41; + try std.testing.expectEqual(CellPosition{ .col = col, .row = 5 }, presentation.pointer(80, 20, col, 5).?); + const point = presentation.pointerFractional(80, 20, 41.5, 5.5).?; + try std.testing.expectApproxEqAbs(@as(f32, 41.5), point.x, 1e-4); +} + test "fractional panel inverse preserves compact header rows through slide and zoom" { const layer: pardes.Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; for ([_]Transition{ .slide, .zoom, .vertical }) |effect| { diff --git a/src/animation.zig b/src/animation.zig index b02a242b..9b43644e 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -129,7 +129,9 @@ pub const Track = extern struct { pane: u8 = 0, phase: Phase = .moving, effect: Transition = .off, - _padding: u8 = 0, + /// The Motion flavour a pane's move follows, plus one; 0 is the + /// effect's own easing (Presentation.stamp). + motion: u8 = 0, frame: u16 = 0, frame_count: u16 = 0, from: Box = .{}, @@ -148,11 +150,47 @@ pub const Track = extern struct { // accelerates down out of the fixed clip. if (track.phase == .closing and track.effect == .vertical) return progressEased(.in_cubic, track.frames(), track.frame); + if (track.flavour()) |motion| + return motion.ease(track.progress()); return progressEased(track.effect.easing(), track.frames(), track.frame); } + /// The flavour an opening or moving pane follows; a closing one keeps + /// its effect's own exit. + fn flavour(track: Track) ?Motion { + if (track.motion == 0 or track.phase == .closing) return null; + const which = std.enums.fromInt(Motion.Flavour, track.motion - 1) orelse return null; + return Motion.of(which); + } + + fn progress(track: Track) f32 { + if (track.frames() <= 1 or track.frame >= track.frames() - 1) return 1; + return @as(f32, @floatFromInt(track.frame)) / @as(f32, @floatFromInt(track.frames() - 1)); + } + pub fn presented(track: Track) Box { - return lerpBox(track.from, track.to, track.amount()); + // A flavour's wind-up goes back past where the move began. + const t = track.amount(); + // Landed, it is exactly where it goes: no stretch or float residue. + if (track.flavour() != null and track.progress() >= 1) return track.to; + var box = if (t < 0) lerpBox(track.to, track.from, 1 - t) else lerpBox(track.from, track.to, t); + // Squash and stretch: along its path a moving pane lengthens with + // its speed and, landing past its mark and coming back, squashes + // (the user's choice: its text with it; a pointer inverts the same + // box, so a click lands on the glyph it is drawn as). + const motion = track.flavour() orelse return box; + if (motion.stretch == 0) return box; + const factor = 1 + motion.stretch * std.math.clamp(motion.speed(track.progress()) / 4, -1, 1); + if (@abs(track.to.x - track.from.x) >= @abs(track.to.y - track.from.y)) { + const w = box.w * factor; + box.x -= (w - box.w) / 2; + box.w = w; + } else { + const h = box.h * factor; + box.y -= (h - box.h) / 2; + box.h = h; + } + return box; } pub fn visualBox(track: Track) Box { @@ -613,20 +651,51 @@ pub const Motion = struct { /// Arcs: how far a two-dimensional path bows, as a fraction of its /// length (the cursor's glide). arc: f32 = 0, + /// Exaggeration: how far a small motion goes, as a multiple of its + /// plain distance (a lift of 1 rises `gain`), so a flavour's character + /// shows on a motion only a few pixels big. + gain: f32 = 1, + /// Timing: a move from rest leaves at this fraction of full speed (1: + /// out of the gate at once and easing in, 0: slow in and slow out). + launch: f32 = 0, + /// For a motion of fixed length (a pane's move, a notice's drop): its + /// length as a multiple of the effect's own, and the spring's frequency + /// over that length (`ease`). + span: f32 = 1, + norm: f32 = 10, pub fn of(flavour: Flavour) Motion { return switch (flavour) { .off => .{ .instant = true }, // Productivity: quick and exact, nothing past its mark. - .crisp => .{ .omega = 44 }, - // A longer, softer ease in and out, still without a bounce. - .smooth => .{ .omega = 26, .follow = 0.85, .arc = 0.04 }, - // Settles past and back, and stretches with its speed. - .bouncy => .{ .omega = 30, .zeta = 0.55, .stretch = 0.12, .follow = 0.8, .arc = 0.08 }, - // A cartoon's: winds up, overshoots, stretches, follows through. - .playful => .{ .omega = 24, .zeta = 0.42, .anticipate = 0.08, .stretch = 0.25, .follow = 0.7, .arc = 0.15 }, + .crisp => .{ .omega = 60, .launch = 1, .span = 0.6, .norm = 10 }, + // Longer and softer, easing in and out, still without a bounce. + // The default: within §8.1's 220 ms for an arrival. + .smooth => .{ .omega = 34, .follow = 0.85, .arc = 0.04, .span = 1.1, .norm = 6 }, + // Clearly past its mark and back, once or twice, stretching + // with its speed. + .bouncy => .{ .omega = 24, .zeta = 0.35, .stretch = 0.15, .follow = 0.8, .arc = 0.08, .gain = 1.25, .span = 1.4, .norm = 13 }, + // A cartoon's: winds up, flies past, squashes as it lands, + // its followers trailing. + .playful => .{ .omega = 18, .zeta = 0.28, .anticipate = 0.15, .stretch = 0.35, .follow = 0.55, .arc = 0.15, .gain = 1.4, .span = 1.7, .norm = 18 }, }; } + + /// A move of fixed length from 0 to 1, `u` of the way through it: the + /// flavour's spring (wind-up, overshoot and all), landing exactly on 1 + /// at its end. `instant` is there at once. + pub fn ease(motion: Motion, u: f32) f32 { + if (motion.instant or u >= 1) return 1; + if (u <= 0) return 0; + const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = (motion.launch - 2 * motion.anticipate) * motion.norm, .settled = false }; + return spring.value(@intFromFloat(u * std.time.ns_per_s)); + } + + /// The same move's speed at `u`, in lengths per unit of `u`. + pub fn speed(motion: Motion, u: f32) f32 { + const h: f32 = 1.0 / 256.0; + return (motion.ease(@min(1, u + h)) - motion.ease(@max(0, u - h))) / (@min(1, u + h) - @max(0, u - h)); + } }; /// A value that follows its target on a damped spring, in closed form @@ -684,8 +753,8 @@ pub const Spring = struct { return; } const state = spring.at(now_ns); - // From rest, a wind-up: set off the other way, just a little. - const kick: f32 = if (spring.settled) -motion.anticipate * (target - state.value) * motion.omega * 2 else 0; + // From rest, a launch, or a wind-up: set off the other way, a little. + const kick: f32 = if (spring.settled) (motion.launch - 2 * motion.anticipate) * (target - state.value) * motion.omega else 0; spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false }; } @@ -712,7 +781,7 @@ test "a spring settles without overshoot and keeps its velocity when retargeted" try std.testing.expect(v >= last and v <= 1); last = v; } - try std.testing.expect(t >= 150 * ms and t <= 260 * ms); + try std.testing.expect(t >= 80 * ms and t <= 200 * ms); try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms)); // Back the other way partway up: it carries on up for a moment, then // turns, never jumping. @@ -723,7 +792,7 @@ test "a spring settles without overshoot and keeps its velocity when retargeted" 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); + try std.testing.expect(spring.value(60 * ms + 500_000) > before.value); } test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" { @@ -732,6 +801,7 @@ test "each flavour is its own motion: off lands at once, bouncy overshoots, play spring.retarget(1, 0, Motion.of(.off)); try std.testing.expect(spring.settled); try std.testing.expectEqual(@as(f32, 1), spring.value(0)); + var settle: [5]u64 = @splat(0); for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| { spring = .{}; spring.retarget(1, 0, Motion.of(flavour)); @@ -744,17 +814,60 @@ test "each flavour is its own motion: off lands at once, bouncy overshoots, play peak = @max(peak, now.value); low = @min(low, now.value); // Continuous, a millisecond at a time: no jump anywhere. - try std.testing.expect(@abs(now.value - last.value) < 0.05); + try std.testing.expect(@abs(now.value - last.value) < 0.07); last = now; } - try std.testing.expect(t < 1200 * ms); + try std.testing.expect(t < 1600 * ms); + settle[@intFromEnum(flavour)] = t; switch (flavour) { .crisp, .smooth => try std.testing.expect(peak <= 1.0005 and low >= 0), - .bouncy => try std.testing.expect(peak > 1.05 and low >= 0), - .playful => try std.testing.expect(peak > 1.05 and low < -0.01), + // Clearly past the mark, not a hint of it. + .bouncy => try std.testing.expect(peak > 1.25 and low >= 0), + .playful => try std.testing.expect(peak > 1.35 and low < -0.03), .off => unreachable, } } + // Smooth takes twice crisp's time and more: a character, not a tuning. + // Yet as the default a pane's arrival (a slide's 12 frames, scaled) is + // inside §8.1's 220 ms, and a lift is still before 300 ms. + try std.testing.expect(settle[@intFromEnum(Motion.Flavour.smooth)] > 2 * settle[@intFromEnum(Motion.Flavour.crisp)]); + try std.testing.expect(settle[@intFromEnum(Motion.Flavour.smooth)] <= 300 * ms); + try std.testing.expect(@round(@as(f32, 12) * Motion.of(.smooth).span) * frame_ms <= 220); + // A fixed-length move lands exactly on its mark, whatever the flavour. + for (std.enums.values(Motion.Flavour)) |flavour| try std.testing.expectEqual(@as(f32, 1), Motion.of(flavour).ease(1)); +} + +test "a flavour's stretched pane lands exactly on its box, and a click finds the glyph drawn there" { + const from: Box = .{ .x = 0, .y = 2, .w = 30, .h = 10 }; + const to: Box = .{ .x = 37.3, .y = 2, .w = 30, .h = 10 }; + for (std.enums.values(Motion.Flavour)) |flavour| { + var track: Track = .{ .effect = .slide, .motion = @intFromEnum(flavour) + 1, .frame_count = 20, .from = from, .to = to }; + track.frame = track.frames() - 1; + const landed = track.presented(); + // Bit for bit: no stretch or float residue once it lands. + try std.testing.expectEqual(to, landed); + } + // Playful at its most stretched: the pane is drawn wider than its box, + // and the drawn place of a cell maps back to that cell (the pointer + // inverts the same box: target + (x - shown.x) / shown.w * target.w). + var track: Track = .{ .effect = .slide, .motion = @intFromEnum(Motion.Flavour.playful) + 1, .frame_count = 20, .from = from, .to = to }; + var most: u16 = 0; + var widest: f32 = 0; + while (track.frame < track.frames()) : (track.frame += 1) { + if (track.presented().w > widest) { + widest = track.presented().w; + most = track.frame; + } + } + track.frame = most; + const shown = track.presented(); + try std.testing.expect(shown.w > to.w * 1.05); + for (0..30) |k| { + const cell = to.x + @as(f32, @floatFromInt(k)) + 0.5; + const drawn = shown.x + (cell - to.x) / to.w * shown.w; + const back = to.x + (drawn - shown.x) / shown.w * to.w; + try std.testing.expectApproxEqAbs(cell, back, 1e-3); + } } /// A displayed value that fades from one target to the next over diff --git a/src/config.zig b/src/config.zig index 24e058c7..ce051be0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -636,6 +636,8 @@ pub const Runtime = struct { grip_width: u16 = 150, /// How the fx track's animations move (animation.Motion): off for /// reduced motion. + /// Smooth by default, tuned so arrivals land inside §8.1's 220 ms; the + /// slower flavours are a choice. motion: animation.Motion.Flavour = .smooth, /// Builtins announce themselves on the message row. diff --git a/src/draw.zig b/src/draw.zig index 1f30a40b..9df76cfd 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -134,11 +134,12 @@ pub fn place(p: *Pardes, s: *Surface) void { // snaps (it has no in-between), and so does nothing that shows it. const shown = lift != .off or inactiveDim(p) != 0; const motion = animation.Motion.of(if (shown and builtins.capabilities.lift) p.settings.motion else .off); - spring.retarget(if (id == p.active) 1 else 0, p.now_ns, motion); + // A flavour exaggerates what is only a few pixels (Motion.gain). + spring.retarget(if (id == p.active) animation.Motion.of(p.settings.motion).gain else 0, p.now_ns, motion); _ = spring.step(p.now_ns); } for (s.regions[0..s.nregions]) |*region| switch (region.kind) { - .grip, .tag, .body, .rail => region.lift = if (lift != .off) p.lifts[region.owner].value(p.now_ns) else 0, + .grip, .tag, .body, .rail => region.lift = if (lift != .off) std.math.clamp(p.lifts[region.owner].value(p.now_ns), -1, 2) else 0, .notice => region.lift = if (lift != .off) 1 else 0, else => {}, }; @@ -416,7 +417,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { for (p.panes, p.lifts, 0..) |slot, spring, id| { if (slot == null or id == p.active) continue; - const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - spring.value(p.now_ns), 0, 1); + const focus = spring.value(p.now_ns) / animation.Motion.of(p.settings.motion).gain; + const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - focus, 0, 1); if (amount > 0.001) recede(p, s, id, amount); } @@ -1604,6 +1606,46 @@ test "with no lift and no dim a focus switch asks for no frames" { try std.testing.expect(!p.continuous()); } +test "each Motion flavour reaches the lift as its own trajectory" { + // A grid snaps; the flavours are a pixel shell's. + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.settings.lift = .shadow; + var peaks: [5]f32 = undefined; + const ms = std.time.ns_per_ms; + var start: u64 = std.time.ns_per_s; + for (std.enums.values(animation.Motion.Flavour), &peaks) |flavour, *peak| { + p.settings.motion = flavour; + p.active = 1; + p.advance(start); + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + start += 3 * std.time.ns_per_s; + p.advance(start); + _ = try p.render(arena.allocator()); + p.active = 0; + peak.* = 0; + var t: u64 = 0; + while (t <= 1500 * ms) : (t += 4 * ms) { + p.advance(start + t); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const lift = for (s.regionList()) |region| { + if (region.kind == .grip and region.owner == 0) break region.lift; + } else unreachable; + peak.* = @max(peak.*, lift); + } + start += 3 * std.time.ns_per_s; + } + // Bouncy and playful pass their (exaggerated) mark; smooth never does. + try std.testing.expect(peaks[2] <= 1.001 and peaks[3] > 1.25 and peaks[4] > peaks[3]); +} + 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 1a1b1f99..9748d4c0 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -5525,7 +5525,13 @@ fn renderFrame( const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); // A notice floats over the panes, moving with its own. const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else track_index; + const start = cell_next[group_index]; emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); + // A notice dropping past its row (a flavour's overshoot) stays + // inside its pane's body. + if (layer.kind == .notice and layer.slide > 0) if (bodyBottom(surface, layer.id, layout)) |bottom| { + for (instances[start..cell_next[group_index]]) |*instance| clipInstance(instance, .{ -1e9, -1e9, 1e9, bottom }, win_w, win_h); + }; } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { const track = group.track.?; @@ -5894,6 +5900,14 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } } +/// The bottom of pane `owner`'s body, in window pixels. +fn bodyBottom(surface: *const pardes.Surface, owner: u16, layout: CellLayout) ?f32 { + for (surface.regionList()) |region| { + if (region.kind == .body and region.owner == owner) return @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h; + } + return null; +} + fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 { const pixels = @as(u32, layer.viewport.w) * g.cell_w; return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width); @@ -6306,6 +6320,29 @@ test "every pixel of a grip column is band, button or ring, and the button sits pane.file.?.revision = pane.file.?.saved_revision; } +test "a rising pane past its mark stays inside its box" { + // Bouncy, a vertical rise at its highest overshoot: drawn past its box, + // clipped to it. + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + var track: pardes.animation.Track = .{ .serial = 3, .phase = .opening, .effect = .vertical, .motion = @intFromEnum(pardes.animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = 0, .y = 10, .w = 40, .h = 10 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 10 } }; + var peak: u16 = 0; + var highest: f32 = 1e9; + while (track.frame < track.frames()) : (track.frame += 1) { + if (track.presented().y < highest) { + highest = track.presented().y; + peak = track.frame; + } + } + track.frame = peak; + try std.testing.expect(track.presented().y < 0); + var instance: CellInstance = std.mem.zeroes(CellInstance); + setTransitionFields(&instance, track, fixedCellLayout(&g), 800, 480, 0); + try std.testing.expectEqual(@as(f32, 0), instance.clip_y0); + try std.testing.expectEqual(@as(f32, 200), instance.clip_y1); +} + test "small tagline fonts leave no background gutters between adjacent tags" { const chrome: pardes.Chrome = .{}; var g: Gui = undefined; diff --git a/src/macos.zig b/src/macos.zig index b472e6cc..acc344d2 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -2866,7 +2866,7 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@offsetOf(c.pardes_panel_box_s, field.name), @offsetOf(PanelBox, field.name)); try expectEqual(@sizeOf(c.pardes_panel_track_s), @sizeOf(PanelTrack)); inline for (@typeInfo(PanelTrack).@"struct".fields) |field| { - const c_name = comptime if (std.mem.eql(u8, field.name, "_padding")) + const c_name = comptime if (std.mem.eql(u8, field.name, "motion")) "reserved0" else field.name; @@ -3000,7 +3000,7 @@ test "mac panel ABI has the documented compact byte layout" { try std.testing.expectEqual(@as(usize, 4), @offsetOf(PanelTrack, "pane")); try std.testing.expectEqual(@as(usize, 5), @offsetOf(PanelTrack, "phase")); try std.testing.expectEqual(@as(usize, 6), @offsetOf(PanelTrack, "effect")); - try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "_padding")); + try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "motion")); try std.testing.expectEqual(@as(usize, 8), @offsetOf(PanelTrack, "frame")); try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "frame_count")); try std.testing.expectEqual(@as(usize, 12), @offsetOf(PanelTrack, "from")); diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index 88947d0d..462ea40f 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -254,6 +254,8 @@ test "screen inspection preserves acknowledged presentation and the next real fr var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); for ([_]*Pardes{ control, inspected }) |p| { + // The mechanism, at a fixed flavour, not the default's tuning. + p.settings.motion = .crisp; p.settings.panel_transition = .off; p.update(.tick); p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks()); diff --git a/src/pardes.zig b/src/pardes.zig index 04ef4b36..eac21a14 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3580,6 +3580,8 @@ test "Newcol refuses an unsplittable restored weight before spawning" { test "layout commits publish finite tracks only for changed panes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); @@ -3643,6 +3645,8 @@ test "vertical close samples only a canonical baseline containing that pane" { { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); p.settings.panel_transition = .vertical; @@ -3666,6 +3670,8 @@ test "vertical close samples only a canonical baseline containing that pane" { { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); p.settings.panel_transition = .vertical; diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index 4c19dc4f..c4f11cc8 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -15,4 +15,4 @@ 15-theme-acme-light 5cecfb32915c217c5d39492b8708532bd826a8a55ad6b8bd49d61ed5cee29ab9 16-debug 51628eb1d8dce799256e336d081d8510fe70b7a3f28f9efefcbca7126583b348 17-terminal 65794362c9e76fa2930e85e888f17216d24cace7475e29da3610c0dab411887b -18-mid-transition 72ce5905098af73e571a1efe009a700940458924502bcf5e96bfafe7c4b8f29c +18-mid-transition d365b05255d82a5cddd4c829493595a67b49db9ac67178f8499175c5cc0f706a -- cgit v1.3