summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 16:44:51 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commitdeeb8ac54f71d4322a778336db9a7aec411c23ce (patch)
treea1ccddd6dc7acf31a88a0d27153848b053ac10d1
parent3267d276ab2553adb412183eb92390ed579b494f (diff)
downloadpardes-deeb8ac54f71d4322a778336db9a7aec411c23ce.tar.gz
pardes-deeb8ac54f71d4322a778336db9a7aec411c23ce.zip
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.
-rw-r--r--docs/effects.md18
-rw-r--r--shaders/decor.frag.glsl58
-rw-r--r--shaders/ui.frag.glsl2
-rw-r--r--shaders/ui.vert.glsl8
-rw-r--r--src/animation.zig80
-rw-r--r--src/builtins.zig2
-rw-r--r--src/config.zig18
-rw-r--r--src/detached/wire.zig7
-rw-r--r--src/draw.zig16
-rw-r--r--src/gui/gui.zig129
-rw-r--r--src/pardes.zig5
-rw-r--r--src/surface.zig4
12 files changed, 329 insertions, 18 deletions
diff --git a/docs/effects.md b/docs/effects.md
new file mode 100644
index 00000000..44f3e949
--- /dev/null
+++ b/docs/effects.md
@@ -0,0 +1,18 @@
+# Effects: feel reviews
+
+Each effect of docs/render-pipeline.md §9 lands behind its own switch, off,
+and is kept only after a feel review (§8.4): a frame series on the virtual
+clock, a recording, and a verdict — keep, polish or drop — with one line of
+why. The verdict is the user's.
+
+| effect | switch | state | review material | verdict |
+|---|---|---|---|---|
+| Crt (bundled post pass) | `Crt 0..3` | kept, rewritten (no barrel) | fx-compare stills, live window | kept at the user's live look |
+| Ripple, Glitch | — | removed | live window | dropped by the user after a live look |
+| G1 soft elevation shadows | `Lift` (off) | opt-in until the focus-lift default is decided | frame series + mp4 per theme (forge, acme, dusk): Lift on, a focus switch, a run of quick switches, a notice, Lift off | pending |
+
+Notes on G1: the lift runs on a critically damped spring (§7.2) that settles
+in about 240 ms and keeps its velocity when a quick run of switches retargets
+it; a notice floats on a lift of 1 while Lift is on. The series are the core's
+frames: core animation steps at 62.5 Hz on any display (§7.1), so a 144 Hz
+display shows the same frames, each held for two or three refreshes.
diff --git a/shaders/decor.frag.glsl b/shaders/decor.frag.glsl
index 4c2bd6d8..77f9c95e 100644
--- a/shaders/decor.frag.glsl
+++ b/shaders/decor.frag.glsl
@@ -1,24 +1,68 @@
#version 450
-// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor)
-// that ui.vert.glsl places like a cell, its track's transition included. Its
-// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The
-// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so
-// a fading rule shows through, and the result is that pipeline's exactly.
-// Ink (a cursor) is drawn with the opacity uniform at 1.
+// Decor, drawn by ui.vert.glsl like a cell, its track's transition included,
+// and blended as the overlay pipeline blends (backgroundLayerBlend). Ink (a
+// cursor) is drawn with the opacity uniform at 1.
+//
+// Flat: a hard-edged fill (a rule, a rail, a grip mark, a bar cursor). Its
+// colour rides in the cell's ground (a_bg), its coverage in a_fg.r, so the
+// result is the overlay pipeline's exactly.
+//
+// Soft shadow (flag 0x10000000): a rectangle's shadow blurred by a Gaussian,
+// in closed form (the product of two erf edges), on one quad grown by 3
+// sigma: no blur pass. Everything is in the quad's own 0..1 space, so a
+// track's transform carries it: sigma per axis in the uv, the caster's
+// offset from its shadow in fg.gb, the strength in fg.r. The caster's own
+// rectangle is left alone (it is drawn already), and the alpha is pre-warped
+// so black darkens in linear light, then dithered so it never bands.
layout(std140, set = 3, binding = 0) uniform UiUniforms {
float background_opacity;
} u_ui;
+layout(location = 0) in vec2 v_uv;
layout(location = 1) in vec3 v_fg;
layout(location = 2) in vec3 v_bg;
+layout(location = 3) in vec2 v_cell_uv;
+layout(location = 4) flat in uvec4 v_effect;
layout(location = 5) flat in vec4 v_clip;
layout(location = 0) out vec4 o_col;
+// erf to about 4e-4 (Winitzki's approximation): smooth, cheap, odd.
+float erfApprox(float x) {
+ float x2 = x * x;
+ const float a = 0.147;
+ float e = sqrt(1.0 - exp(-x2 * (1.2732395 + a * x2) / (1.0 + a * x2)));
+ return x < 0.0 ? -e : e;
+}
+
+// How much of [lo, hi] a Gaussian of sigma s centred at x covers.
+float edge(float x, float lo, float hi, float s) {
+ float k = 0.70710678 / s;
+ return 0.5 * (erfApprox((x - lo) * k) - erfApprox((x - hi) * k));
+}
+
+float ign(vec2 p) {
+ return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715))));
+}
+
void main() {
if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z ||
gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard;
- o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r);
+ float opacity = clamp(u_ui.background_opacity, 0.0, 1.0);
+ if ((v_effect.x & 0x10000000u) == 0u) {
+ o_col = vec4(v_bg * opacity, v_fg.r);
+ return;
+ }
+ vec2 s = v_uv;
+ vec2 m = 3.0 * s;
+ vec2 p = v_cell_uv;
+ vec2 caster0 = m - v_fg.gb;
+ vec2 caster1 = 1.0 - m - v_fg.gb;
+ if (all(greaterThanEqual(p, caster0)) && all(lessThan(p, caster1))) discard;
+ float a = v_fg.r * edge(p.x, m.x, 1.0 - m.x, s.x) * edge(p.y, m.y, 1.0 - m.y, s.y);
+ a = 1.0 - pow(max(1.0 - a, 0.0), 1.0 / 2.2);
+ a = clamp(a + (ign(gl_FragCoord.xy) - 0.5) / 255.0, 0.0, 1.0);
+ o_col = vec4(v_bg * opacity, a);
}
diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl
index 0b099db3..dca224ba 100644
--- a/shaders/ui.frag.glsl
+++ b/shaders/ui.frag.glsl
@@ -43,7 +43,7 @@ void main() {
float bgA = (v_effect.x & 0x20000000u) != 0u ? 1.0
: (v_effect.x & 0x40000000u) != 0u ? 0.0
: clamp(u_ui.background_opacity, 0.0, 1.0);
- uint effect = v_effect.x & 0x1fffffffu;
+ uint effect = v_effect.x & 0x0fffffffu;
float progress = clamp(uintBitsToFloat(v_effect.y), 0.0, 1.0);
uint cellCol = v_effect.w & 0xffffu;
uint cellRow = v_effect.w >> 16;
diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl
index c1779add..5551f3f1 100644
--- a/shaders/ui.vert.glsl
+++ b/shaders/ui.vert.glsl
@@ -14,9 +14,9 @@ layout(location = 3) in vec3 a_bg;
layout(location = 4) in vec4 a_panel_rect;
layout(location = 5) in vec4 a_present_rect;
// effect id, eased-progress float bits, pane serial, local row/column packed
-// into one uint. The THREE high effect bits are flags — the frozen-old cell
-// layer, a see-through cell background and an opaque block-cursor fill — so
-// the id is what is left below them; integer transport preserves the exact
+// into one uint. The FOUR high effect bits are flags — the frozen-old cell
+// layer, a see-through cell background, an opaque block-cursor fill and a
+// soft shadow (decor.frag.glsl) — so the id is what is left below them; integer transport preserves the exact
// cross-backend hash.
layout(location = 6) in uvec4 a_effect;
// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader
@@ -61,7 +61,7 @@ void main() {
v_cell_uv = vec2(0.0, 1.0);
}
- uint effect = a_effect.x & 0x1fffffffu;
+ uint effect = a_effect.x & 0x0fffffffu;
if (effect == 1u || effect == 2u || effect == 5u) {
// NDC y points up, so a top-left/bottom-right box has a NEGATIVE y
// delta. Preserve that sign or every local y is mirrored/outside.
diff --git a/src/animation.zig b/src/animation.zig
index 948a6679..ca933b82 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -586,6 +586,86 @@ pub const frame_ms: u32 = 16;
pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
pub const transition_steps: u16 = 10;
+/// A value that follows its target on a critically damped spring, in closed
+/// form (docs/render-pipeline.md §7.2): no overshoot, and a new target taken
+/// partway keeps the value AND its velocity, so a quick run of changes never
+/// snaps or starts over. `omega` sets the pace: it is within 1% of a jump
+/// after about 6.6 / omega seconds, and settles (half a percent, nearly
+/// still) at about 8.6 / omega. Settled, it is exactly its target and asks
+/// for no frames.
+pub const Spring = struct {
+ /// Within 1% of a jump in 6.6 / 36 ≈ 180 ms, settled by 240 ms: a
+ /// state change (§8.1).
+ pub const state_change: f32 = 36;
+
+ omega: f32 = state_change,
+ target: f32 = 0,
+ /// The value and velocity (per second) at `from_ns`.
+ from: f32 = 0,
+ velocity: f32 = 0,
+ from_ns: u64 = 0,
+ settled: bool = true,
+
+ const State = struct { value: f32, velocity: f32 };
+
+ fn at(spring: *const Spring, now_ns: u64) State {
+ if (spring.settled) return .{ .value = spring.target, .velocity = 0 };
+ const t: f32 = @floatCast(@as(f64, @floatFromInt(now_ns -| spring.from_ns)) / std.time.ns_per_s);
+ const w = spring.omega;
+ const x0 = spring.from - spring.target;
+ const c = spring.velocity + w * x0;
+ const decay = @exp(-w * t);
+ return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (spring.velocity - w * t * c) * decay };
+ }
+
+ pub fn value(spring: *const Spring, now_ns: u64) f32 {
+ return spring.at(now_ns).value;
+ }
+
+ /// Heads for `target` from wherever it is at `now_ns`, moving as it was.
+ pub fn retarget(spring: *Spring, target: f32, now_ns: u64) void {
+ if (target == spring.target) return;
+ const state = spring.at(now_ns);
+ spring.* = .{ .omega = spring.omega, .target = target, .from = state.value, .velocity = state.velocity, .from_ns = now_ns, .settled = false };
+ }
+
+ /// Settles once it is within half a percent of its target and barely
+ /// moving (no pixel of a shadow tells the rest): it is its target from
+ /// then on. True while it still moves.
+ pub fn step(spring: *Spring, now_ns: u64) bool {
+ if (spring.settled) return false;
+ const state = spring.at(now_ns);
+ if (@abs(state.value - spring.target) < 5e-3 and @abs(state.velocity) < 5e-2) spring.settled = true;
+ return !spring.settled;
+ }
+};
+
+test "a spring settles without overshoot and keeps its velocity when retargeted" {
+ var spring: Spring = .{};
+ const ms = std.time.ns_per_ms;
+ spring.retarget(1, 0);
+ var last: f32 = 0;
+ var t: u64 = 0;
+ while (spring.step(t)) : (t += frame_ns) {
+ const v = spring.value(t);
+ try std.testing.expect(v >= last and v <= 1);
+ last = v;
+ }
+ // Settled within a frame or two of 6.6 / omega.
+ try std.testing.expect(t >= 180 * ms and t <= 260 * ms);
+ try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms));
+ // Back the other way halfway up: it carries on up for a moment, then
+ // turns, never jumping.
+ spring = .{};
+ spring.retarget(1, 0);
+ const before = spring.at(60 * ms);
+ spring.retarget(0, 60 * ms);
+ const after = spring.at(60 * ms);
+ try std.testing.expectApproxEqAbs(before.value, after.value, 1e-6);
+ try std.testing.expectApproxEqAbs(before.velocity, after.velocity, 1e-4);
+ try std.testing.expect(spring.value(64 * ms) > before.value);
+}
+
/// A displayed value that fades from one target to the next over
/// `transition_steps` frames; the chrome colours are one.
pub fn Fade(comptime Value: type) type {
diff --git a/src/builtins.zig b/src/builtins.zig
index 23d853c8..53cf0999 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -28,6 +28,8 @@ pub const capabilities: config.Runtime.Capabilities = .{
// Only the SDL shell shapes with HarfBuzz and honours the setting. macOS
// draws CoreText ligatures of its own, but nothing there reads it yet.
.ligatures = pardes.platform == .gui,
+ // Soft shadows are the SDL shell's decor.
+ .lift = pardes.platform == .gui,
};
/// Where a builtin acts: at the pane it runs from, or on the editor as a
diff --git a/src/config.zig b/src/config.zig
index 186b29a1..0ed8726a 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -115,6 +115,7 @@ pub const leader_path = paths: {
if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null);
if (builtins.capabilities.window_blur) table.set(.WindowBlur, null);
if (builtins.capabilities.ligatures) table.set(.Ligatures, null);
+ if (builtins.capabilities.lift) table.set(.Lift, null);
if (builtins.capabilities.panel_transitions) {
table.set(.PanelSlide, "as");
table.set(.PanelZoom, "az");
@@ -618,6 +619,10 @@ pub const Runtime = struct {
/// A font's programming ligatures (`->`, `!=`) draw across their cells.
/// Only a shell that shapes text has the setting (`Capabilities.ligatures`).
ligatures: bool = true,
+ /// The focused pane and the notices stand off the page on soft shadows
+ /// (G1, docs/render-pipeline.md §9.1). Opt-in until the focus lift's
+ /// default is decided. Only a pixel shell draws it (`Capabilities.lift`).
+ lift: bool = false,
/// Builtins announce themselves on the message row.
verbose: bool = true,
/// A message falls into its row and dissolves out of it.
@@ -808,6 +813,8 @@ pub const Runtime = struct {
window_blur: bool = false,
/// The shell shapes text, so it can draw ligatures and stop drawing them.
ligatures: bool = false,
+ /// The shell draws soft shadows under what stands off the page.
+ lift: bool = false,
};
pub const Capability = std.meta.FieldEnum(Capabilities);
@@ -815,7 +822,7 @@ pub const Runtime = struct {
/// The message timings a setting word sets, each in milliseconds.
pub const MessageTime = enum { linger, fall, dissolve };
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures, lift };
/// The bundled post pass, a Shadertoy file in shaders/post/.
pub const Scene = enum { crt };
@@ -863,6 +870,7 @@ pub const Runtime = struct {
.{ .word = "Verbose", .action = .{ .toggle = .verbose } },
.{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } },
.{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures },
+ .{ .word = "Lift", .action = .{ .toggle = .lift }, .availability = .lift },
.{ .word = "MessageLinger", .action = .{ .message_ms = .linger } },
.{ .word = "MessageFall", .action = .{ .message_ms = .fall } },
.{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } },
@@ -924,9 +932,9 @@ pub const Runtime = struct {
@compileError("scene effects must use the scene-shader capability"),
// A toggle is plain state any shell can hold, except one for
// what only some shells draw: that one exists where they do.
- .toggle => |field| if (field == .ligatures) {
- if (setting.availability != .ligatures)
- @compileError("the ligatures toggle must use the ligatures capability");
+ .toggle => |field| if (field == .ligatures or field == .lift) {
+ if (setting.availability == null or !std.mem.eql(u8, @tagName(setting.availability.?), @tagName(field)))
+ @compileError("a pixel shell's toggle must use its own capability: " ++ @tagName(field));
} else if (setting.availability != null)
@compileError("unconditional toggles cannot carry a backend capability"),
else => if (setting.availability != null)
@@ -1447,6 +1455,7 @@ pub const Runtime = struct {
.window_opacity = true,
.window_blur = true,
.ligatures = true,
+ .lift = true,
},
.state = &state,
};
@@ -1485,6 +1494,7 @@ pub const Runtime = struct {
.verbose => "Verbose off\n",
.message_animation => "MessageAnimation off\n",
.ligatures => "Ligatures off\n",
+ .lift => "Lift on\n",
},
.shell => "Shell requested (new panes) fish\n",
.tagline_size => "TaglineSize 73\n",
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index b1b81eda..6e3b2d01 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -253,6 +253,8 @@ pub const Frame = struct {
region.thumb_h = try r.getU16();
region.notice = try r.getByte();
region.clip = try r.getU16();
+ region.lift = @bitCast(try r.getU32());
+ if (!(region.lift >= 0 and region.lift <= 1)) return error.BadValue;
if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or
region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or
region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue;
@@ -723,7 +725,7 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg
return n + regions.len * region_len + chrome_len;
}
-const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2;
+const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2 + 4;
/// The palette: each colour three bytes, a theme's optional ones a flag
/// before them.
const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4;
@@ -807,6 +809,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
try w.putU16(region.thumb_h);
try w.putByte(region.notice);
try w.putU16(region.clip);
+ try w.putU32(@bitCast(region.lift));
}
inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| {
const rgb = @field(chrome, field.name);
@@ -1890,7 +1893,7 @@ test "detached wire carries compact body rows and exact pointer metrics" {
var tag_cells: [3]pardes.Cell = @splat(.{});
tag_cells[2] = logical[10];
const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } };
- const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }};
+ const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }};
const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed));
defer gpa.free(out);
const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } };
diff --git a/src/draw.zig b/src/draw.zig
index 2d757635..0998db24 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -119,6 +119,22 @@ pub fn place(p: *Pardes, s: *Surface) void {
s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } });
// A column carried to the window's right edge shows where it lands as a
// rail in the last cell: the layers under it stop a cell short.
+ // Lift: the focused pane rises, the others settle back, each on its
+ // spring; a notice floats while Lift is on.
+ for (&p.panes, &p.lifts, &p.lift_serials, 0..) |slot, *spring, *serial, id| {
+ const pane = slot orelse continue;
+ if (serial.* != pane.serial) {
+ serial.* = pane.serial;
+ spring.* = .{};
+ }
+ spring.retarget(if (p.settings.lift and id == p.active) 1 else 0, p.stepped_ns);
+ _ = spring.step(p.stepped_ns);
+ }
+ for (s.regions[0..s.nregions]) |*region| switch (region.kind) {
+ .grip, .tag, .body, .rail => region.lift = p.lifts[region.owner].value(p.stepped_ns),
+ .notice => region.lift = if (p.settings.lift) 1 else 0,
+ else => {},
+ };
if (p.drag == .column_move) rail: {
const d = p.drag.column_move;
const source = layout.columnBySerial(p, d.serial) orelse break :rail;
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 5bc1ea6d..92bec92c 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -487,6 +487,8 @@ const initial_image_capacity: u32 = pardes.MAX_PANES;
const old_layer_bit: u32 = 0x8000_0000;
const clear_bg_bit: u32 = 0x4000_0000;
const opaque_bg_bit: u32 = 0x2000_0000;
+/// Decor that is a soft shadow, not a flat fill (decor.frag.glsl).
+const soft_shadow_bit: u32 = 0x1000_0000;
const SavedImagePlace = struct {
key: pardes.ImageCacheKey,
@@ -1855,6 +1857,10 @@ const Gui = struct {
/// Pixels the capture holds past the grid (PARDES_TEST_PAD).
capture_pad: u32 = 0,
capture_dir: []const u8 = "",
+ /// PARDES_TEST_SERIES: every captured frame is kept too, numbered, for a
+ /// feel review's frame series (docs/render-pipeline.md §8.4).
+ capture_series: bool = false,
+ captured: u32 = 0,
capture_tex: ?*c.SDL_GPUTexture = null,
capture_tex_w: u32 = 0,
capture_tex_h: u32 = 0,
@@ -2457,6 +2463,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa),
.capture = test_mode,
.capture_pad = if (test_mode) envU16(env, "PARDES_TEST_PAD") orelse 0 else 0,
+ .capture_series = test_mode and env.get("PARDES_TEST_SERIES") != null,
.capture_dir = capture_dir orelse "",
.soft_present = soft_present,
.transparent = config.gui_transparent,
@@ -7301,6 +7308,8 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest");
var name: [24]u8 = undefined;
if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable);
+ if (g.capture_series) try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "frame-{d:0>5}", .{g.captured}) catch unreachable);
+ g.captured +%= 1;
}
fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void {
@@ -7375,6 +7384,99 @@ fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32,
try g.decor.append(gpa, instance);
}
+/// How a lift of 0 to 1 stands off the page: the shadow's blur and offset
+/// in cell heights, and its darkest, where it leaves the caster's edge.
+/// Within §8.2's budget: sigma 0.4-0.8 cell, at most 30% darker at the edge.
+const Shadow = struct {
+ // A key light above: the drop is most of the blur, so the shadow hangs
+ // below and to the right and barely reaches over the caster's top.
+ const sigma: f32 = 0.42;
+ const drop: f32 = 0.34;
+ const side: f32 = 0.1;
+ const strength: f32 = 0.42;
+};
+
+/// A soft shadow under `caster` (window pixels), `lift` 0 to 1, no darker
+/// than `ceiling`, moved by `track` as its caster is.
+fn addShadow(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, lift: f32, ceiling: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void {
+ const strength = @min(Shadow.strength * lift, ceiling);
+ if (lift <= 0.001 or strength <= 0.002) return;
+ const h: f32 = @floatFromInt(g.cell_h);
+ const sigma = @max(0.5, Shadow.sigma * h * lift);
+ const dx = Shadow.side * h * lift;
+ const dy = Shadow.drop * h * lift;
+ const x0 = caster[0] + dx - 3 * sigma;
+ const y0 = caster[1] + dy - 3 * sigma;
+ const x1 = caster[2] + dx + 3 * sigma;
+ const y1 = caster[3] + dy + 3 * sigma;
+ const w = x1 - x0;
+ const ht = y1 - y0;
+ if (w <= 0 or ht <= 0) return;
+ // In the quad's own 0..1 space (decor.frag.glsl): sigma per axis, the
+ // caster's offset from its shadow, the strength.
+ var instance: CellInstance = .{
+ .x0 = (x0 / win_w) * 2.0 - 1.0,
+ .y0 = 1.0 - (y0 / win_h) * 2.0,
+ .x1 = (x1 / win_w) * 2.0 - 1.0,
+ .y1 = 1.0 - (y1 / win_h) * 2.0,
+ .u0 = sigma / w,
+ .v0 = sigma / ht,
+ .u1 = sigma / w,
+ .v1 = sigma / ht,
+ .fr = strength,
+ .fg = dx / w,
+ .fb = dy / ht,
+ .br = 0,
+ .bg = 0,
+ .bb = 0,
+ };
+ setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0);
+ instance.effect |= soft_shadow_bit;
+ try g.decor.append(gpa, instance);
+}
+
+/// sRGB's relative luminance (WCAG).
+fn luminance(rgb: [3]u8) f32 {
+ var sum: f32 = 0;
+ for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |channel, weight| {
+ const v = @as(f32, @floatFromInt(channel)) / 255;
+ sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ }
+ return sum;
+}
+
+/// How dark a shadow may make the page and its text, both together, before
+/// the pair drops below its own contrast or 4.5, whichever is lower (§8.2):
+/// darkening in linear light by k leaves (k L1 + .05) / (k L2 + .05).
+fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 {
+ const a = luminance(fg);
+ const b = luminance(bg);
+ const light = @max(a, b);
+ const dark = @min(a, b);
+ const target = @min((light + 0.05) / (dark + 0.05), 4.5);
+ const room = light - target * dark;
+ if (room <= 0) return 0;
+ return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1);
+}
+
+test "a shadow never darkens a native theme's text below its contrast or 4.5" {
+ var shaded: usize = 0;
+ for (pardes.themes) |theme| {
+ const fg = theme.fg orelse fg_default;
+ const bg = theme.bg orelse bg_default;
+ const ceiling = shadowCeiling(fg, bg);
+ const k = 1 - @min(Shadow.strength, ceiling);
+ const light = @max(luminance(fg), luminance(bg));
+ const dark = @min(luminance(fg), luminance(bg));
+ const before = (light + 0.05) / (dark + 0.05);
+ const after = (k * light + 0.05) / (k * dark + 0.05);
+ try std.testing.expect(after >= @min(before, 4.5) - 1e-3);
+ shaded += @intFromBool(ceiling > 0.05);
+ }
+ // And most themes have room for one.
+ try std.testing.expect(shaded * 2 > pardes.themes.len);
+}
+
/// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it
/// read from the regions and the palette: the page's rules and the still
/// panes' rails, rules and grip marks in tier 0, a moving or closing pane's
@@ -7383,6 +7485,10 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
g.decor.clearRetainingCapacity();
const chrome = &surface.chrome;
const regions = surface.regionList();
+ const ceiling = shadowCeiling(chrome.fg orelse fg_default, chrome.page orelse bg_default);
+ const notice_lift = for (regions) |region| {
+ if (region.kind == .notice) break region.lift;
+ } else 0;
for (groups.items[0..groups.len], 0..) |*group, index| {
group.decor_start = @intCast(g.decor.items.len);
defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start;
@@ -7432,11 +7538,34 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h);
};
try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track);
+ // Cast last in the group, over its cells, images and chrome: a
+ // lifted pane's shadow darkens its neighbours' rails and rules
+ // as it darkens their text, and never the pane itself.
+ for (placed) |grip| {
+ if (grip.kind != .grip or grip.lift <= 0.001 or groupOf(groups, grip.serial) != index) continue;
+ var box: [4]f32 = .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(grip.rect.y)) * layout.h, 0, @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h };
+ box[2] = box[0];
+ for (placed) |part| {
+ if (part.owner != grip.owner or (part.kind != .tag and part.kind != .rail and part.kind != .body)) continue;
+ box[1] = @min(box[1], @as(f32, @floatFromInt(part.rect.y)) * layout.h);
+ box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w);
+ box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h);
+ }
+ try addShadow(g, gpa, box, grip.lift, ceiling, track, win_w, win_h);
+ }
}
// A notice chip is tagline hung over the body's top rows, so it is
// ruled off from the text beneath it the way a tag is: the same
// colour and thickness along its bottom, running to the window's
// edge when it does, fading up and dissolving with its band.
+ // A notice floats: its shadow first, then every rule.
+ for (surface.tagLayers()) |*layer| {
+ if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue;
+ const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
+ const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h;
+ const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track;
+ try addShadow(g, gpa, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, notice_lift * layer.fade, ceiling, track, win_w, win_h);
+ }
for (surface.tagLayers()) |*layer| {
if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue;
const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
diff --git a/src/pardes.zig b/src/pardes.zig
index f559b125..449ef798 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -4111,6 +4111,10 @@ pub const Pardes = struct {
theme_file_generation: u32 = 0,
theme_file_pane: u8 = 0,
chrome_animation: colors.ChromeAnimation = colors.ChromeAnimation.init(colors.initial_chrome),
+ /// Each pane's lift off the page (Lift): 1 while it has the focus, on a
+ /// spring. `lift_serials` says whose it is, so a reused slot starts flat.
+ lifts: [MAX_PANES]animation.Spring = @splat(.{}),
+ lift_serials: [MAX_PANES]u32 = @splat(0),
animate_theme_changes: bool = false,
native_images: bool = false,
/// The shell can take a changed image a few rows at a time (a texture row
@@ -6213,6 +6217,7 @@ pub const Pardes = struct {
pub fn nextWake(p: *const Pardes) ?u64 {
const next = p.stepped_ns + animation.frame_ns;
if (p.chrome_animation.isActive() or p.presentation.animating()) return next;
+ for (p.lifts) |spring| if (!spring.settled) return next;
var frames: ?u64 = null;
if (p.look_hover_wait) |waiting| {
const delay = config.look_preview_delay_frames orelse return next;
diff --git a/src/surface.zig b/src/surface.zig
index b6f41853..94b37915 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -364,6 +364,10 @@ pub const Region = struct {
thumb_h: u16 = 0,
/// A notice's index in its pane's `notices`.
notice: u8 = 0,
+ /// How far it stands off the page, 0 to 1: a pane's while it has the
+ /// focus (Lift, on a spring), a notice's while Lift is on. A pixel shell
+ /// casts a soft shadow from it; the tier it is drawn in never changes.
+ lift: f32 = 0,
/// Cells the column-move rail takes off the right edge while it runs
/// there: layers and pixel shells stop short of them, the grid does not.
clip: u16 = 0,