From 5dbd21795f6c68bd3c10d409135970e3033364f3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 18:24:18 -0300 Subject: Motion flavours, and Lift is shadow, rim or auto: auto on a dark page recedes the other panes (InactiveDim) Motion off|crisp|smooth|bouncy|playful is one parameter set (animation.Motion) every fx animation reads. One focus spring per pane drives both the lift and the dim, at the flavour's pace; a grid snaps. Lift drops glow and surface. A shadow falls on pane bodies and rails only, capped so text and the selection keep min(contrast, 4.5); rim is a hairline along the focused tag's top. auto is a shadow on a light page; on a dark page the core recedes the unfocused panes' text (InactiveDim, ported from 2e4d97f, default 30 under auto) with the same floor. Tests: focused text contrast is never below unfocused in any style, dim or theme; the focused pane's cells and tag are untouched by a dim. Shared files: src/config.zig, src/builtins.zig, src/ninep/ctl.zig, src/surface.zig, src/detached/wire.zig, docs/config.md, test/snapshots/builtins.snap (one more wheel for the longer help). --- src/gui/gui.zig | 194 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 158 insertions(+), 36 deletions(-) (limited to 'src/gui/gui.zig') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 5d9783e3..d8bae6dc 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7448,55 +7448,90 @@ fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f const LiftCeilings = struct { shadow: f32, white: f32, - accent: f32, + /// A rim is light on a dark page and shade on a light one, where light + /// would not show. + rim: [3]u8, + /// Over the text on the page, and over a selection's text on its own + /// ground: the lower of the two. fn of(chrome: *const pardes.Chrome) LiftCeilings { const fg = chrome.fg orelse fg_default; const bg = chrome.page orelse bg_default; - return .{ .shadow = shadowCeiling(fg, bg), .white = lightenCeiling(fg, bg, .{ 255, 255, 255 }), .accent = lightenCeiling(fg, bg, chrome.accent) }; + return .{ + .shadow = @min(shadowCeiling(fg, bg), shadowCeiling(chrome.sel_fg, chrome.sel_bg)), + .white = @min(lightenCeiling(fg, bg, .{ 255, 255, 255 }), lightenCeiling(chrome.sel_fg, chrome.sel_bg, .{ 255, 255, 255 })), + .rim = if (luminance(bg) < 0.18) .{ 255, 255, 255 } else .{ 0, 0, 0 }, + }; } }; /// What stands a lifted part off the page, `lift` 0 to 1 (a pane's spring, -/// a notice's 1), in the style chosen; `auto` reads the page: a shadow on a -/// light one, a lighter surface and a faint rim on a dark one, where a -/// shadow has nothing to darken. -fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, chrome: *const pardes.Chrome, ceilings: LiftCeilings, caster: [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { +/// a notice's 1), in the style chosen: a soft shadow, or a rim along the +/// top of the focused pane's tag rows (`rim_edge`, a pane's only: +/// never a notice's). +fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, ceilings: LiftCeilings, caster: [4]f32, rim_edge: ?[3]f32, bodies: []const [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { if (lift <= 0.001) return; const h: f32 = @floatFromInt(g.cell_h); - const dark_page = luminance(chrome.page orelse bg_default) < 0.18; - const parts: struct { shadow: bool = false, surface: bool = false, rim: f32 = 0, glow: bool = false } = switch (style) { + // The core sends `auto` already resolved (draw.liftStyle). + const shadow = switch (style) { .off => return, - .shadow => .{ .shadow = true }, - .surface => .{ .surface = true }, - .rim => .{ .rim = 1 }, - .glow => .{ .glow = true }, - .auto => if (dark_page) .{ .surface = true, .rim = 0.5 } else .{ .shadow = true }, + .shadow, .auto => true, + .rim => false, + }; + if (shadow) { + // Nothing of a shadow may touch a focus indicator (docs/effects.md): + // it falls on pane bodies and rails, never a tag, a grip or a header. + const first = g.decor.items.len; + try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); + try clipToBodies(g, gpa, first, bodies); + } + // A hairline just above the focused tag rows, never on them: it adds to + // the indicator without changing its colour. + if (style == .rim) if (rim_edge) |edge| { + const light = ceilings.rim[0] == 255; + const alpha = @min(Lift.rim * lift, if (light) ceilings.white else ceilings.shadow); + try addDecor(g, gpa, edge[0], edge[2] - 1, edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h); }; - if (parts.surface) { - // A raised surface catches more light: its ground, and all on it, - // a few percent lighter, in whole pixels. - const a = @min(Lift.surface * lift, ceilings.white); - if (a > 0.002) try addDecor(g, gpa, caster[0], caster[1], caster[2], caster[3], .{ 255, 255, 255 }, a, track, win_w, win_h); - } - if (parts.shadow) try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); - // Light from above and to the left: a thin halo off the top and left - // edges, gone by the bottom and the right. - if (parts.rim > 0) try addSoft(g, gpa, caster, Lift.rim_sigma * h * lift, -Lift.rim_offset * h * lift, -Lift.rim_offset * h * lift, @min(Lift.rim * parts.rim * lift, ceilings.white), .{ 255, 255, 255 }, track, win_w, win_h); - if (parts.glow) try addSoft(g, gpa, caster, Lift.glow_sigma * h * lift, 0, 0, @min(Lift.glow * lift, ceilings.accent), chrome.accent, track, win_w, win_h); } -/// The lighter styles' strengths, in the §8.2 budget: coloured light at -/// most 25%, a surface a few percent. +/// A rim's strength, in the §8.2 budget for coloured light. const Lift = struct { - const surface: f32 = 0.055; - const rim: f32 = 0.22; - const rim_sigma: f32 = 0.12; - const rim_offset: f32 = 0.1; - const glow: f32 = 0.2; - const glow_sigma: f32 = 0.5; + const rim: f32 = 0.35; }; +/// A body region in window pixels. +fn bodyPixels(region: pardes.Region, layout: CellLayout) [4]f32 { + return .{ + @as(f32, @floatFromInt(region.rect.x)) * layout.w, + @as(f32, @floatFromInt(region.rect.y)) * layout.h, + @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w, + @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h, + }; +} + +/// Replaces the decor from `first` on with a copy per pane body it reaches, +/// each clipped to that body (and to what a vertical transition already +/// clipped it to): the lift then never reaches a tag, a grip or a header. +fn clipToBodies(g: *Gui, gpa: std.mem.Allocator, first: usize, bodies: []const [4]f32) !void { + const added = g.decor.items.len - first; + if (added == 0) return; + var own: [8]CellInstance = undefined; + const n = @min(added, own.len); + @memcpy(own[0..n], g.decor.items[first..][0..n]); + g.decor.shrinkRetainingCapacity(first); + for (own[0..n]) |instance| for (bodies) |body| { + var copy = instance; + const vertical = copy.clip_x1 >= copy.clip_x0; + copy.clip_x0 = if (vertical) @max(copy.clip_x0, body[0]) else body[0]; + copy.clip_y0 = if (vertical) @max(copy.clip_y0, body[1]) else body[1]; + copy.clip_x1 = if (vertical) @min(copy.clip_x1, body[2]) else body[2]; + copy.clip_y1 = if (vertical) @min(copy.clip_y1, body[3]) else body[3]; + if (copy.clip_x1 <= copy.clip_x0 or copy.clip_y1 <= copy.clip_y0) continue; + try g.decor.append(gpa, copy); + }; +} + + /// Like shadowCeiling for a light colour laid over at `a` (sRGB blend, as /// the decor pipeline blends a colour that is not black): the largest `a` /// that keeps text and page apart by min(their contrast, 4.5). @@ -7547,11 +7582,11 @@ fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 { return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1); } -test "a rim, a glow or a surface never lightens a native theme's text below its contrast or 4.5" { +test "a rim never lightens a native theme's text below its contrast or 4.5" { for (pardes.themes) |theme| { const fg = theme.fg orelse fg_default; const bg = theme.bg orelse bg_default; - for ([_][3]u8{ .{ 255, 255, 255 }, theme.box }) |light| { + for ([_][3]u8{.{ 255, 255, 255 }}) |light| { const a = @min(Lift.rim, lightenCeiling(fg, bg, light)); const blend = struct { fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 { @@ -7570,6 +7605,74 @@ test "a rim, a glow or a surface never lightens a native theme's text below its } } +test "a lift never touches a tag, a grip or a header, on a light theme or a dark one" { + const gpa = std.testing.allocator; + for ([_][]const u8{ "forge", "daybreak" }) |name| { + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + var line: [64]u8 = undefined; + try std.testing.expect(core.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Theme {s}", .{name}))); + for (0..40) |_| core.update(.tick); + for (std.enums.values(pardes.config.Runtime.LiftStyle)) |style| { + core.settings.lift = style; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a floating notice")); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + const settled = try core.render(core.scratch.allocator()); + _ = s; + // The focus indicator itself: the focused pane's tag is not the + // others' colour, whatever the lift. + var active: ?[3]u8 = null; + var other: ?[3]u8 = null; + for (settled.tagLayers()) |layer| if (layer.rows != 0 and layer.kind == .pane) { + if (layer.id == core.active) active = layer.bg else other = layer.bg; + }; + try std.testing.expect(!std.mem.eql(u8, &active.?, &other.?)); + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.decor = .empty; + defer g.decor.deinit(gpa); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, settled, &groups, fixedCellLayout(&g), 800, 480, true); + var lifted: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 < item.clip_x0) continue; + lifted += 1; + // Inside a body or a rail, all of it. + const inside = for (settled.regionList()) |region| { + if (region.kind != .body and region.kind != .rail) continue; + const r = bodyPixels(region, fixedCellLayout(&g)); + if (item.clip_x0 >= r[0] and item.clip_y0 >= r[1] and item.clip_x1 <= r[2] and item.clip_y1 <= r[3]) break true; + } else false; + try std.testing.expect(inside); + } + const light_page = std.mem.eql(u8, name, "daybreak"); + try std.testing.expectEqual(style == .shadow or (style == .auto and light_page), lifted != 0); + // A rim is one hairline, just above the focused pane's tag row. + var rims: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 >= item.clip_x0) continue; + const top = (1 - item.y0) / 2 * 480; + const height = (item.y0 - item.y1) / 2 * 480; + const on_active_tag = for (settled.regionList()) |region| { + if (region.kind == .tag and region.owner == core.active and @abs(top + 1 - @as(f32, @floatFromInt(region.rect.y)) * 20) < 0.01 and @abs(height - 1) < 0.01) break true; + } else false; + // Not the rule under a column bar, which may run there too. + rims += @intFromBool(on_active_tag and item.fr < 1); + } + try std.testing.expectEqual(@as(usize, @intFromBool(style == .rim)), rims); + } + } +} + test "a shadow never darkens a native theme's text below its contrast or 4.5" { var shaded: usize = 0; for (pardes.themes) |theme| { @@ -7597,6 +7700,13 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const chrome = &surface.chrome; const regions = surface.regionList(); const ceilings = LiftCeilings.of(chrome); + var current_buf: [pardes.MAX_PANES][4]f32 = undefined; + var ncurrent: usize = 0; + for (regions) |part| if ((part.kind == .body or part.kind == .rail) and ncurrent < current_buf.len) { + current_buf[ncurrent] = bodyPixels(part, layout); + ncurrent += 1; + }; + const current_bodies = current_buf[0..ncurrent]; const lift_style = std.enums.fromInt(pardes.config.Runtime.LiftStyle, chrome.lift_style) orelse .off; const notice_lift = for (regions) |region| { if (region.kind == .notice) break region.lift; @@ -7638,6 +7748,14 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g // A closing pane is gone: its chrome is the last frame's. const closing = if (track) |active| active.phase == .closing else false; const placed = if (closing) surface.previous_regions else regions; + // Where a lift may fall: pane bodies, never tags (addLift). + var body_buf: [pardes.MAX_PANES][4]f32 = undefined; + var nbodies: usize = 0; + for (placed) |part| if ((part.kind == .body or part.kind == .rail) and nbodies < body_buf.len) { + body_buf[nbodies] = bodyPixels(part, layout); + nbodies += 1; + }; + const bodies = body_buf[0..nbodies]; const grid = if (closing) surface.previous_cells else surface.cells; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // A single spine runs down each column, through every tag and body. @@ -7663,7 +7781,11 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w); box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h); } - try addLift(g, gpa, lift_style, chrome, ceilings, box, grip.lift, track, win_w, win_h); + // The top edge of its tag rows, for a rim: x0, x1, y. + const rim_edge: ?[3]f32 = for (placed) |part| { + if (part.owner == grip.owner and part.kind == .tag) break .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w, @as(f32, @floatFromInt(part.rect.y)) * layout.h }; + } else null; + try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h); } } // A notice chip is tagline hung over the body's top rows, so it is @@ -7676,7 +7798,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h; const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; - try addLift(g, gpa, lift_style, chrome, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, notice_lift * layer.fade, track, win_w, win_h); + try addLift(g, gpa, lift_style, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, null, current_bodies, notice_lift * layer.fade, track, win_w, win_h); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; -- cgit v1.3