diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 16:44:51 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | deeb8ac54f71d4322a778336db9a7aec411c23ce (patch) | |
| tree | a1ccddd6dc7acf31a88a0d27153848b053ac10d1 /src | |
| parent | 3267d276ab2553adb412183eb92390ed579b494f (diff) | |
| download | pardes-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.
Diffstat (limited to 'src')
| -rw-r--r-- | src/animation.zig | 80 | ||||
| -rw-r--r-- | src/builtins.zig | 2 | ||||
| -rw-r--r-- | src/config.zig | 18 | ||||
| -rw-r--r-- | src/detached/wire.zig | 7 | ||||
| -rw-r--r-- | src/draw.zig | 16 | ||||
| -rw-r--r-- | src/gui/gui.zig | 129 | ||||
| -rw-r--r-- | src/pardes.zig | 5 | ||||
| -rw-r--r-- | src/surface.zig | 4 |
8 files changed, 255 insertions, 6 deletions
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, |
