summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 18:24:18 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit5dbd21795f6c68bd3c10d409135970e3033364f3 (patch)
tree7dcea4712daa0dcd46e8b8438ca00b2001a4aed4
parentf41d0209f65237568c9f7d527d5989ac8921b74c (diff)
downloadpardes-5dbd21795f6c68bd3c10d409135970e3033364f3.tar.gz
pardes-5dbd21795f6c68bd3c10d409135970e3033364f3.zip
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).
-rw-r--r--docs/config.md15
-rw-r--r--docs/effects.md54
-rw-r--r--src/animation.zig139
-rw-r--r--src/builtins.zig2
-rw-r--r--src/config.zig50
-rw-r--r--src/detached/wire.zig3
-rw-r--r--src/draw.zig330
-rw-r--r--src/gui/gui.zig192
-rw-r--r--src/ninep/ctl.zig2
-rw-r--r--src/surface.zig3
-rw-r--r--test/snapshots/builtins.snap3
11 files changed, 710 insertions, 83 deletions
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 <percent>` | 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 <flavour>`, 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,
- omega: f32 = state_change,
+ 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 },
+ };
+ }
+};
+
+/// 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 (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 (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);
}
- 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);
+ // 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);
+ };
}
-/// 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