diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 18:14:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | f41d0209f65237568c9f7d527d5989ac8921b74c (patch) | |
| tree | c313dc20fa0593ecff725de32d6ad191b01d82ac /src | |
| parent | b4781b334a06d16aedb0b6a3d604cb249eb619de (diff) | |
| download | pardes-f41d0209f65237568c9f7d527d5989ac8921b74c.tar.gz pardes-f41d0209f65237568c9f7d527d5989ac8921b74c.zip | |
Lift takes a style: shadow, surface, rim, glow or auto
The user wants Lift to read on dark themes, where a shadow has nothing to
darken. `Lift shadow|surface|rim|glow|auto` (`on` is auto, bare flips
auto and off): surface lightens the lifted part's ground about 5.5%
(Material-dark), rim is a thin soft light off its top and left edges,
glow a faint halo in the theme's accent (Chrome.accent, the focused box
colour), auto a shadow on a light page and surface with half a rim on a
dark one (by the page's luminance). Rim and glow are the soft kernel in a
light colour (no pre-warp: coloured light is laid on as is, within §8.2's
budget); each style is capped per theme so text keeps min(its contrast,
4.5) (tests: darkening and lightening over the native themes). The style
travels in the palette (Chrome.lift_style, one byte on the wire). Config
reports and ctl reads `Lift <style>`.
Shared files touched: config.zig, builtins.zig, ninep/ctl.zig,
detached/wire.zig (palette byte), gui.zig. Not touched: pardes.zig,
Messages.zig, mouse.zig.
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 2 | ||||
| -rw-r--r-- | src/config.zig | 59 | ||||
| -rw-r--r-- | src/detached/wire.zig | 4 | ||||
| -rw-r--r-- | src/draw.zig | 8 | ||||
| -rw-r--r-- | src/gui/gui.zig | 132 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 1 | ||||
| -rw-r--r-- | src/surface.zig | 4 |
7 files changed, 182 insertions, 28 deletions
diff --git a/src/builtins.zig b/src/builtins.zig index 53cf0999..7b2aca38 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -171,7 +171,7 @@ pub const registry = struct { inline for (comptime settingList(), manualBuiltinCount()..) |setting, i| if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default - .toggle, .transition, .scene, .dump_dir => false, + .toggle, .transition, .scene, .dump_dir, .lift => false, .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true, }; unreachable; diff --git a/src/config.zig b/src/config.zig index 0ed8726a..f7e83e91 100644 --- a/src/config.zig +++ b/src/config.zig @@ -619,10 +619,11 @@ 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 + /// How the focused pane and the notices stand off the page (G1, + /// 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: bool = false, + lift: LiftStyle = .off, + /// Builtins announce themselves on the message row. verbose: bool = true, /// A message falls into its row and dissolves out of it. @@ -669,6 +670,13 @@ 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 }; + /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { pub const max = 8; @@ -822,7 +830,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, lift }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; /// The bundled post pass, a Shadertoy file in shaders/post/. pub const Scene = enum { crt }; @@ -836,6 +844,8 @@ pub const Runtime = struct { window_blur, message_ms: MessageTime, dump_dir, + /// A lift style, `on` (auto) or `off`; bare flips auto and off. + lift, transition: animation.Transition, scene: Scene, /// A Shadertoy file into the post chain or out of it; `off` empties @@ -870,7 +880,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 = "Lift", .action = .lift, .availability = .lift }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, @@ -932,9 +942,11 @@ 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 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)); + .lift => if (setting.availability != .lift) + @compileError("Lift must use the lift capability"), + .toggle => |field| if (field == .ligatures) { + if (setting.availability != .ligatures) + @compileError("the ligatures toggle must use the ligatures capability"); } else if (setting.availability != null) @compileError("unconditional toggles cannot carry a backend capability"), else => if (setting.availability != null) @@ -1053,6 +1065,15 @@ pub const Runtime = struct { const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false; }, + .lift => { + const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); + state.lift = if (text.len == 0) + (if (state.lift == .off) .auto else .off) + else if (std.mem.eql(u8, text, "on")) + .auto + else + std.meta.stringToEnum(LiftStyle, text) orelse return false; + }, .theme, .font => return false, } return true; @@ -1222,6 +1243,10 @@ pub const Runtime = struct { try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) }) else try out.print("{s} unsupported\n", .{setting.word}), + .lift => if (setting.enabled(context.capabilities)) + try out.print("{s} {s}\n", .{ setting.word, @tagName(state.lift) }) + else + try out.print("{s} unsupported\n", .{setting.word}), }; if (context.startup_config_path) |path| @@ -1353,6 +1378,21 @@ pub const Runtime = struct { try std.testing.expectEqual(@as(u8, 0), state.post.level(.crt)); } + test "Lift takes a style, on is auto, and bare flips it off and back to auto" { + var state: Runtime = .{}; + const lift = find("Lift").?; + try std.testing.expect(apply(&state, lift, null)); + try std.testing.expectEqual(LiftStyle.auto, state.lift); + try std.testing.expect(apply(&state, lift, "rim")); + try std.testing.expectEqual(LiftStyle.rim, state.lift); + try std.testing.expect(apply(&state, lift, null)); + try std.testing.expectEqual(LiftStyle.off, state.lift); + try std.testing.expect(apply(&state, lift, "on")); + try std.testing.expectEqual(LiftStyle.auto, state.lift); + try std.testing.expect(!apply(&state, lift, "sparkle")); + try std.testing.expectEqual(LiftStyle.auto, state.lift); + } + test "tagline size validates before mutating live state" { const setting = find("TaglineSize").?; var state: Runtime = .{}; @@ -1474,6 +1514,7 @@ pub const Runtime = struct { .dump_dir => "~/dumps", .shader => "~/crt.glsl", .shader_animation => "always", + .lift => "glow", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1494,7 +1535,6 @@ 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", @@ -1525,6 +1565,7 @@ pub const Runtime = struct { }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", + .lift => "Lift glow\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 6e3b2d01..0a066410 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -267,6 +267,8 @@ pub const Frame = struct { rgb.* = .{ 0, 0, 0 }; for (&rgb.*.?) |*channel| channel.* = try r.getByte(); } + } else if (field.type == u8) { + rgb.* = try r.getByte(); } else for (rgb) |*channel| channel.* = try r.getByte(); } try r.end(); @@ -816,6 +818,8 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point if (field.type == ?[3]u8) { try w.putBool(rgb != null); if (rgb) |value| for (value) |channel| try w.putByte(channel); + } else if (field.type == u8) { + try w.putByte(rgb); } else for (rgb) |channel| try w.putByte(channel); } } diff --git a/src/draw.zig b/src/draw.zig index 70b629a5..98932c23 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -128,12 +128,12 @@ pub fn place(p: *Pardes, s: *Surface) void { spring.* = .{}; } // At this frame's own time, not the stepped animations' 16 ms grid. - spring.retarget(if (p.settings.lift and id == p.active) 1 else 0, p.now_ns); + spring.retarget(if (p.settings.lift != .off and id == p.active) 1 else 0, p.now_ns); _ = 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) 1 else 0, + .notice => region.lift = if (p.settings.lift != .off) 1 else 0, else => {}, }; if (p.drag == .column_move) rail: { @@ -200,6 +200,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .tag_fg = chrome.tag_fg, .scroll_track = chrome.scroll_track, .scroll_thumb = chrome.scroll_thumb, + .accent = chrome.box, + .lift_style = @intFromEnum(p.settings.lift), }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. @@ -1133,7 +1135,7 @@ test "a lift is sampled at each frame's own time, and asks for frames only while p.advance(1000 * ms); _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); - p.settings.lift = true; + p.settings.lift = .shadow; 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| { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 905caa48..5d9783e3 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7406,15 +7406,13 @@ const Shadow = struct { 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; +/// A soft rectangle around `caster` (window pixels), blurred by `sigma` +/// and moved by (dx, dy): black is a shadow, a light colour a rim or a +/// glow. The caster's own rectangle is left alone, and `track` moves it all +/// as its caster moves. +fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f32, dy: f32, strength: f32, rgb: [3]u8, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + if (strength <= 0.002) return; + const sigma = @max(0.5, sigma_in); const x0 = caster[0] + dx - 3 * sigma; const y0 = caster[1] + dy - 3 * sigma; const x1 = caster[2] + dx + 3 * sigma; @@ -7436,15 +7434,95 @@ fn addShadow(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, lift: f32, ceiling .fr = strength, .fg = dx / w, .fb = dy / ht, - .br = 0, - .bg = 0, - .bb = 0, + .br = @as(f32, @floatFromInt(rgb[0])) / 255, + .bg = @as(f32, @floatFromInt(rgb[1])) / 255, + .bb = @as(f32, @floatFromInt(rgb[2])) / 255, }; setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0); instance.effect |= soft_shadow_bit; try g.decor.append(gpa, instance); } +/// How far each lift style may go on this theme without its text falling +/// below min(its contrast, 4.5): a shadow darkens, the others lighten. +const LiftCeilings = struct { + shadow: f32, + white: f32, + accent: f32, + + 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) }; + } +}; + +/// 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 { + 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) { + .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 }, + }; + 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. +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; +}; + +/// 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). +fn lightenCeiling(fg: [3]u8, bg: [3]u8, rgb: [3]u8) f32 { + const contrast = struct { + fn of(a: [3]u8, b: [3]u8) f32 { + const la = luminance(a); + const lb = luminance(b); + return (@max(la, lb) + 0.05) / (@min(la, lb) + 0.05); + } + fn blend(under: [3]u8, over: [3]u8, a: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - a) + @as(f32, @floatFromInt(y)) * a)); + return out; + } + }; + const target = @min(contrast.of(fg, bg), 4.5); + var lo: f32 = 0; + var hi: f32 = 1; + for (0..24) |_| { + const mid = (lo + hi) / 2; + if (contrast.of(contrast.blend(fg, rgb, mid), contrast.blend(bg, rgb, mid)) >= target - 1e-3) lo = mid else hi = mid; + } + return lo; +} + /// sRGB's relative luminance (WCAG). fn luminance(rgb: [3]u8) f32 { var sum: f32 = 0; @@ -7469,6 +7547,29 @@ 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" { + 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| { + const a = @min(Lift.rim, lightenCeiling(fg, bg, light)); + const blend = struct { + fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - t) + @as(f32, @floatFromInt(y)) * t)); + return out; + } + }.of; + const ratio = struct { + fn of(x: [3]u8, y: [3]u8) f32 { + return (@max(luminance(x), luminance(y)) + 0.05) / (@min(luminance(x), luminance(y)) + 0.05); + } + }.of; + try std.testing.expect(ratio(blend(fg, light, a), blend(bg, light, a)) >= @min(ratio(fg, bg), 4.5) - 2e-3); + } + } +} + test "a shadow never darkens a native theme's text below its contrast or 4.5" { var shaded: usize = 0; for (pardes.themes) |theme| { @@ -7495,7 +7596,8 @@ 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 ceilings = LiftCeilings.of(chrome); + 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; } else 0; @@ -7561,7 +7663,7 @@ 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 addShadow(g, gpa, box, grip.lift, ceiling, track, win_w, win_h); + try addLift(g, gpa, lift_style, chrome, ceilings, box, grip.lift, track, win_w, win_h); } } // A notice chip is tagline hung over the body's top rows, so it is @@ -7574,7 +7676,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 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); + 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); } 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 a64521e7..46c451ed 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -305,6 +305,7 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .scene => |effect| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }), .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) }), } } try w.writeAll("LocationsConfig "); diff --git a/src/surface.zig b/src/surface.zig index 94b37915..e7149155 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -396,6 +396,10 @@ pub const Chrome = struct { tag_fg: [3]u8 = .{ 0, 0, 0 }, scroll_track: [3]u8 = .{ 0, 0, 0 }, scroll_thumb: [3]u8 = .{ 0, 0, 0 }, + /// The theme's accent (a glow's tint). + accent: [3]u8 = .{ 0, 0, 0 }, + /// How a lifted part stands off the page (config.Runtime.LiftStyle). + lift_style: u8 = 0, }; /// The canonical interface: what a frame of pardes IS. The tty shell writes |
