diff options
| -rw-r--r-- | docs/config.md | 15 | ||||
| -rw-r--r-- | docs/effects.md | 54 | ||||
| -rw-r--r-- | src/animation.zig | 139 | ||||
| -rw-r--r-- | src/builtins.zig | 2 | ||||
| -rw-r--r-- | src/config.zig | 50 | ||||
| -rw-r--r-- | src/detached/wire.zig | 3 | ||||
| -rw-r--r-- | src/draw.zig | 330 | ||||
| -rw-r--r-- | src/gui/gui.zig | 192 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 2 | ||||
| -rw-r--r-- | src/surface.zig | 3 | ||||
| -rw-r--r-- | test/snapshots/builtins.snap | 3 |
11 files changed, 710 insertions, 83 deletions
diff --git a/docs/config.md b/docs/config.md index 16fd59e5..83fbd0eb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -527,6 +527,21 @@ the chain empty the pass is bypassed. CRT works in linear light with restrained scanlines, mask, bloom and vignette, and no curvature, so clicks land where they are drawn. +The focused pane can stand off the page (SDL GUI, off by default): + +```text +Lift shadow a soft drop shadow, on the other panes' bodies only +Lift rim a hairline just above the focused tag (light, or shade on a light page) +Lift auto a shadow on a light page; on a dark one the others recede +Lift off +InactiveDim 30 the unfocused panes' text fades 30% toward its ground +Motion smooth off, crisp, smooth, bouncy or playful +``` + +`InactiveDim` works everywhere (a grid shows it at once) and never takes a +pair below its own contrast or 4.5. Under `Lift auto` on a dark page it is 30 +while unset. `Motion` sets how every such effect moves: see docs/effects.md. + `EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's build-embedded source paths under `/virtual`. Look opens each full file; no checkout is needed, but the build must carry them (`-Dembed-sources=true`, diff --git a/docs/effects.md b/docs/effects.md index 44f3e949..fec2d13b 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -1,5 +1,15 @@ # Effects: feel reviews +**The rule above all the others:** no effect may alter or cover a focus +indicator (the focused pane's tag colour, its grip, the cursor, the +selection) or reduce its contrast. The effects are sugar; the indicators are +how a person knows where they are. Every G stage is reviewed against this. +Lift, for one, falls only on pane bodies and rails, never on a tag, a grip or +a header, and its strength is capped so text and the selection keep min(their +contrast, 4.5) (tests in src/gui/gui.zig). A dim touches only the unfocused +panes, with the same floor, and the focused pane's text is never at less +contrast than theirs (tests in src/draw.zig). + Each effect of docs/render-pipeline.md §9 lands behind its own switch, off, and is kept only after a feel review (§8.4): a frame series on the virtual clock, a recording, and a verdict — keep, polish or drop — with one line of @@ -9,10 +19,42 @@ why. The verdict is the user's. |---|---|---|---|---| | Crt (bundled post pass) | `Crt 0..3` | kept, rewritten (no barrel) | fx-compare stills, live window | kept at the user's live look | | Ripple, Glitch | — | removed | live window | dropped by the user after a live look | -| G1 soft elevation shadows | `Lift` (off) | opt-in until the focus-lift default is decided | frame series + mp4 per theme (forge, acme, dusk): Lift on, a focus switch, a run of quick switches, a notice, Lift off | pending | +| G1 lift | `Lift shadow\|rim\|auto` (off), `InactiveDim <percent>` | opt-in until the focus-lift default is decided | lift-shots-2 (off, shadow, rim, auto on acme, dusk, forge); frame series + mp4 per Motion flavour | shadow on acme: keep as is. Round 1 dropped glow and surface (surface lowered the focused text's contrast) and made rim a hairline just above the focused tag (light on a dark page, shade on a light one). auto on a dark page recedes the others (InactiveDim 30) | + +## Motion flavours + +`Motion off|crisp|smooth|bouncy|playful` (default smooth) is one parameter set +(animation.Motion) every fx animation reads, so a flavour is data, not a +branch in each effect. Input is never blocked, and a new target always +retargets from the current position and velocity. + +| flavour | timing (ω) | follow-through (ζ) | anticipation | squash/stretch | secondary lag | arcs | +|---|---|---|---|---|---|---| +| off | instant | — | — | — | — | — | +| crisp | 44 | 1 (none) | — | — | in step | — | +| smooth | 26 | 1 (none) | — | — | 0.85 | 0.04 | +| bouncy | 30 | 0.55 (overshoots ~12%) | — | 0.12 | 0.8 | 0.08 | +| playful | 24 | 0.42 (overshoots ~23%) | 0.08 of the move | 0.25 | 0.7 | 0.15 | + +Which principles each motion uses: + +- **Lift** (G1): timing, slow in / slow out (the spring), follow-through + (bouncy and playful lift past full and settle back), anticipation (playful + dips before it rises; below zero nothing is drawn, so it reads as a beat + before the lift), staging (only the focused pane lifts; nothing else moves + with a focus change). Squash, stretch, arcs and secondary lag have nothing + to act on in a lift. +- **Cursor** (G3, next): designed around the same set: glide on the spring, + stretch along the path, an arc on long jumps, the trailing corners as the + secondary action. + -Notes on G1: the lift runs on a critically damped spring (§7.2) that settles -in about 240 ms and keeps its velocity when a quick run of switches retargets -it; a notice floats on a lift of 1 while Lift is on. The series are the core's -frames: core animation steps at 62.5 Hz on any display (§7.1), so a 144 Hz -display shows the same frames, each held for two or three refreshes. +Notes on G1: the lift and the dim run on one focus spring per pane, at the +Motion flavour's pace, sampled at each frame's own time; while it moves the +GUI draws at the display's rate, and a grid snaps. A notice floats on a lift +of 1 while a shadow or rim is on. InactiveDim under `Lift auto` defaults to +30. The fade is in linear light, so on a dark page it is gentle: at 30 +forge's text goes from 15.5:1 to 11.2:1 and dusk's from 6.4:1 to 4.8:1, +plainly quieter and still easy reading; at 10 forge barely moves (14.1:1). +On a light page the same percent bites far harder (acme: 7.0:1 at 10, the +4.5 floor at 30), which is why `auto` shades there instead of dimming. diff --git a/src/animation.zig b/src/animation.zig index ca933b82..b02a242b 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -586,19 +586,58 @@ pub const frame_ms: u32 = 16; pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; pub const transition_steps: u16 = 10; -/// A value that follows its target on a critically damped spring, in closed -/// form (docs/render-pipeline.md §7.2): no overshoot, and a new target taken -/// partway keeps the value AND its velocity, so a quick run of changes never -/// snaps or starts over. `omega` sets the pace: it is within 1% of a jump -/// after about 6.6 / omega seconds, and settles (half a percent, nearly -/// still) at about 8.6 / omega. Settled, it is exactly its target and asks -/// for no frames. -pub const Spring = struct { - /// Within 1% of a jump in 6.6 / 36 ≈ 180 ms, settled by 240 ms: a - /// state change (§8.1). - pub const state_change: f32 = 36; +/// How the fx track moves (`Motion <flavour>`, docs/effects.md): one set of +/// parameters a flavour picks, read by every animation that follows the +/// classic principles, so a flavour is data and no effect branches on it. +pub const Motion = struct { + pub const Flavour = enum(u8) { off, crisp, smooth, bouncy, playful }; + + /// Nothing moves: every change lands at once (reduced motion). + instant: bool = false, + /// Timing: the spring's natural frequency, rad/s; slow in / slow out + /// come from the spring itself. + omega: f32 = 36, + /// Follow-through: 1 is critically damped (no overshoot); below 1 it + /// settles past its target and back. + zeta: f32 = 1, + /// Anticipation: a move from rest first winds up the other way, by + /// this fraction of the distance. + anticipate: f32 = 0, + /// Squash and stretch: how much a moving thing lengthens along its + /// velocity and squashes as it lands (G3's cursor, G7's panes). + stretch: f32 = 0, + /// Secondary action: how far behind a follower trails its leader (the + /// shadow behind a pane, a notice's text behind its chip), as the + /// follower's frequency over the leader's; 1 is in step. + follow: f32 = 1, + /// Arcs: how far a two-dimensional path bows, as a fraction of its + /// length (the cursor's glide). + arc: f32 = 0, - omega: f32 = state_change, + pub fn of(flavour: Flavour) Motion { + return switch (flavour) { + .off => .{ .instant = true }, + // Productivity: quick and exact, nothing past its mark. + .crisp => .{ .omega = 44 }, + // A longer, softer ease in and out, still without a bounce. + .smooth => .{ .omega = 26, .follow = 0.85, .arc = 0.04 }, + // Settles past and back, and stretches with its speed. + .bouncy => .{ .omega = 30, .zeta = 0.55, .stretch = 0.12, .follow = 0.8, .arc = 0.08 }, + // A cartoon's: winds up, overshoots, stretches, follows through. + .playful => .{ .omega = 24, .zeta = 0.42, .anticipate = 0.08, .stretch = 0.25, .follow = 0.7, .arc = 0.15 }, + }; + } +}; + +/// A value that follows its target on a damped spring, in closed form +/// (docs/render-pipeline.md §7.2), critically damped or under: a new target +/// taken partway keeps the value AND its velocity, so a quick run of changes +/// never snaps or starts over. A move from rest can wind up first +/// (Motion.anticipate). Settled, it is exactly its target and asks for no +/// frames. +pub const Spring = struct { + omega: f32 = 36, + zeta: f32 = 1, target: f32 = 0, /// The value and velocity (per second) at `from_ns`. from: f32 = 0, @@ -613,20 +652,41 @@ pub const Spring = struct { const t: f32 = @floatCast(@as(f64, @floatFromInt(now_ns -| spring.from_ns)) / std.time.ns_per_s); const w = spring.omega; const x0 = spring.from - spring.target; - const c = spring.velocity + w * x0; - const decay = @exp(-w * t); - return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (spring.velocity - w * t * c) * decay }; + const v0 = spring.velocity; + if (spring.zeta >= 1) { + const c = v0 + w * x0; + const decay = @exp(-w * t); + return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (v0 - w * t * c) * decay }; + } + // Underdamped: it rings about its target as it decays. + const z = spring.zeta; + const wd = w * @sqrt(1 - z * z); + const b = (v0 + z * w * x0) / wd; + const decay = @exp(-z * w * t); + const cos = @cos(wd * t); + const sin = @sin(wd * t); + return .{ + .value = spring.target + decay * (x0 * cos + b * sin), + .velocity = decay * ((b * wd - z * w * x0) * cos - (x0 * wd + z * w * b) * sin), + }; } pub fn value(spring: *const Spring, now_ns: u64) f32 { return spring.at(now_ns).value; } - /// Heads for `target` from wherever it is at `now_ns`, moving as it was. - pub fn retarget(spring: *Spring, target: f32, now_ns: u64) void { + /// Heads for `target` from wherever it is at `now_ns`, moving as it was, + /// at the pace and damping `motion` gives: at once when it is `off`. + pub fn retarget(spring: *Spring, target: f32, now_ns: u64, motion: Motion) void { if (target == spring.target) return; + if (motion.instant) { + spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = target }; + return; + } const state = spring.at(now_ns); - spring.* = .{ .omega = spring.omega, .target = target, .from = state.value, .velocity = state.velocity, .from_ns = now_ns, .settled = false }; + // From rest, a wind-up: set off the other way, just a little. + const kick: f32 = if (spring.settled) -motion.anticipate * (target - state.value) * motion.omega * 2 else 0; + spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false }; } /// Settles once it is within half a percent of its target and barely @@ -643,7 +703,8 @@ pub const Spring = struct { test "a spring settles without overshoot and keeps its velocity when retargeted" { var spring: Spring = .{}; const ms = std.time.ns_per_ms; - spring.retarget(1, 0); + const crisp = Motion.of(.crisp); + spring.retarget(1, 0, crisp); var last: f32 = 0; var t: u64 = 0; while (spring.step(t)) : (t += frame_ns) { @@ -651,21 +712,51 @@ test "a spring settles without overshoot and keeps its velocity when retargeted" try std.testing.expect(v >= last and v <= 1); last = v; } - // Settled within a frame or two of 6.6 / omega. - try std.testing.expect(t >= 180 * ms and t <= 260 * ms); + try std.testing.expect(t >= 150 * ms and t <= 260 * ms); try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms)); - // Back the other way halfway up: it carries on up for a moment, then + // Back the other way partway up: it carries on up for a moment, then // turns, never jumping. spring = .{}; - spring.retarget(1, 0); + spring.retarget(1, 0, crisp); const before = spring.at(60 * ms); - spring.retarget(0, 60 * ms); + spring.retarget(0, 60 * ms, crisp); const after = spring.at(60 * ms); try std.testing.expectApproxEqAbs(before.value, after.value, 1e-6); try std.testing.expectApproxEqAbs(before.velocity, after.velocity, 1e-4); try std.testing.expect(spring.value(64 * ms) > before.value); } +test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" { + const ms = std.time.ns_per_ms; + var spring: Spring = .{}; + spring.retarget(1, 0, Motion.of(.off)); + try std.testing.expect(spring.settled); + try std.testing.expectEqual(@as(f32, 1), spring.value(0)); + for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| { + spring = .{}; + spring.retarget(1, 0, Motion.of(flavour)); + var peak: f32 = 0; + var low: f32 = 0; + var t: u64 = 0; + var last = spring.at(0); + while (spring.step(t)) : (t += ms) { + const now = spring.at(t); + peak = @max(peak, now.value); + low = @min(low, now.value); + // Continuous, a millisecond at a time: no jump anywhere. + try std.testing.expect(@abs(now.value - last.value) < 0.05); + last = now; + } + try std.testing.expect(t < 1200 * ms); + switch (flavour) { + .crisp, .smooth => try std.testing.expect(peak <= 1.0005 and low >= 0), + .bouncy => try std.testing.expect(peak > 1.05 and low >= 0), + .playful => try std.testing.expect(peak > 1.05 and low < -0.01), + .off => unreachable, + } + } +} + /// A displayed value that fades from one target to the next over /// `transition_steps` frames; the chrome colours are one. pub fn Fade(comptime Value: type) type { diff --git a/src/builtins.zig b/src/builtins.zig index 7b2aca38..e49deb71 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -172,7 +172,7 @@ pub const registry = struct { if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default .toggle, .transition, .scene, .dump_dir, .lift => false, - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim => true, }; unreachable; } diff --git a/src/config.zig b/src/config.zig index f7e83e91..ed7bee92 100644 --- a/src/config.zig +++ b/src/config.zig @@ -116,6 +116,8 @@ pub const leader_path = paths: { if (builtins.capabilities.window_blur) table.set(.WindowBlur, null); if (builtins.capabilities.ligatures) table.set(.Ligatures, null); if (builtins.capabilities.lift) table.set(.Lift, null); + table.set(.Motion, null); + table.set(.InactiveDim, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -623,6 +625,13 @@ pub const Runtime = struct { /// docs/render-pipeline.md §9.1), or off. Opt-in until the focus lift's /// default is decided. Only a pixel shell draws it (`Capabilities.lift`). lift: LiftStyle = .off, + /// Blends the unfocused panes' text toward each cell's own ground, + /// percent (0 off): the focused pane wins by comparison, its tag tint + /// untouched. Kept above min(each cell's contrast, 4.5). + inactive_dim: u8 = 0, + /// How the fx track's animations move (animation.Motion): off for + /// reduced motion. + motion: animation.Motion.Flavour = .smooth, /// Builtins announce themselves on the message row. verbose: bool = true, @@ -670,12 +679,15 @@ pub const Runtime = struct { pub const ShaderAnimation = enum { off, on, always }; - /// shadow: a soft drop shadow. surface: the lifted pane's ground - /// lightens, as a raised surface does on a dark theme. rim: a soft light - /// along its top and left edges. glow: a faint halo in the theme's - /// accent. auto: shadow on a light page, surface and a faint rim on a - /// dark one. - pub const LiftStyle = enum(u8) { off, shadow, surface, rim, glow, auto }; + /// shadow: a soft drop shadow. rim: a light hairline along the top of + /// the focused pane's tag rows. auto: a shadow on a light page; on a + /// dark one, where a shadow has nothing to darken, the other panes + /// recede instead (InactiveDim, at `auto_dim` unless it is set). + pub const LiftStyle = enum(u8) { off, shadow, rim, auto }; + /// What `Lift auto` dims the unfocused panes by on a dark page when + /// InactiveDim is 0: enough to read at a glance (the user's own init + /// sets 35), not so much that they stop being worth reading. + pub const auto_dim: u8 = 30; /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { @@ -846,6 +858,10 @@ pub const Runtime = struct { dump_dir, /// A lift style, `on` (auto) or `off`; bare flips auto and off. lift, + /// A motion flavour. + motion, + /// A percentage, 0 through 100. + inactive_dim, transition: animation.Transition, scene: Scene, /// A Shadertoy file into the post chain or out of it; `off` empties @@ -881,6 +897,8 @@ pub const Runtime = struct { .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, .{ .word = "Lift", .action = .lift, .availability = .lift }, + .{ .word = "Motion", .action = .motion }, + .{ .word = "InactiveDim", .action = .inactive_dim }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, @@ -1074,6 +1092,16 @@ pub const Runtime = struct { else std.meta.stringToEnum(LiftStyle, text) orelse return false; }, + .motion => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + state.motion = std.meta.stringToEnum(animation.Motion.Flavour, text) orelse return false; + }, + .inactive_dim => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%"); + const percent = std.fmt.parseInt(u8, text, 10) catch return false; + if (percent > 100) return false; + state.inactive_dim = percent; + }, .theme, .font => return false, } return true; @@ -1247,6 +1275,8 @@ pub const Runtime = struct { try out.print("{s} {s}\n", .{ setting.word, @tagName(state.lift) }) else try out.print("{s} unsupported\n", .{setting.word}), + .motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }), + .inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }), }; if (context.startup_config_path) |path| @@ -1514,7 +1544,9 @@ pub const Runtime = struct { .dump_dir => "~/dumps", .shader => "~/crt.glsl", .shader_animation => "always", - .lift => "glow", + .lift => "rim", + .motion => "playful", + .inactive_dim => "35", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1565,7 +1597,9 @@ pub const Runtime = struct { }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", - .lift => "Lift glow\n", + .lift => "Lift rim\n", + .motion => "Motion playful\n", + .inactive_dim => "InactiveDim 35\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 0a066410..2a2ccc6b 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -254,7 +254,8 @@ pub const Frame = struct { region.notice = try r.getByte(); region.clip = try r.getU16(); region.lift = @bitCast(try r.getU32()); - if (!(region.lift >= 0 and region.lift <= 1)) return error.BadValue; + // A springy flavour overshoots, and a wind-up dips below. + if (!(region.lift >= -1 and region.lift <= 2)) return error.BadValue; if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; diff --git a/src/draw.zig b/src/draw.zig index 98932c23..d1d4c11b 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -18,6 +18,7 @@ const mouse = @import("mouse.zig"); const tagline = @import("tagline.zig"); const body_layer = @import("body_layer.zig"); const Messages = @import("Messages.zig"); +const builtins = @import("builtins.zig"); const Color = @import("surface.zig").Color; const FontRole = @import("surface.zig").FontRole; const CellStyle = @import("surface.zig").CellStyle; @@ -121,19 +122,24 @@ pub fn place(p: *Pardes, s: *Surface) void { // rail in the last cell: the layers under it stop a cell short. // Lift: the focused pane rises, the others settle back, each on its // spring; a notice floats while Lift is on. + const lift = liftStyle(p); for (&p.panes, &p.lifts, &p.lift_serials, 0..) |slot, *spring, *serial, id| { const pane = slot orelse continue; if (serial.* != pane.serial) { serial.* = pane.serial; spring.* = .{}; } - // At this frame's own time, not the stepped animations' 16 ms grid. - spring.retarget(if (p.settings.lift != .off and id == p.active) 1 else 0, p.now_ns); + // How focused it is, which both a lift and a dim follow: at this + // frame's own time, not the stepped animations' 16 ms grid. A grid + // snaps (it has no in-between), and so does nothing that shows it. + const shown = lift != .off or inactiveDim(p) != 0; + const motion = animation.Motion.of(if (shown and builtins.capabilities.lift) p.settings.motion else .off); + spring.retarget(if (id == p.active) 1 else 0, p.now_ns, motion); _ = spring.step(p.now_ns); } for (s.regions[0..s.nregions]) |*region| switch (region.kind) { - .grip, .tag, .body, .rail => region.lift = p.lifts[region.owner].value(p.now_ns), - .notice => region.lift = if (p.settings.lift != .off) 1 else 0, + .grip, .tag, .body, .rail => region.lift = if (lift != .off) p.lifts[region.owner].value(p.now_ns) else 0, + .notice => region.lift = if (lift != .off) 1 else 0, else => {}, }; if (p.drag == .column_move) rail: { @@ -154,6 +160,162 @@ pub fn place(p: *Pardes, s: *Surface) void { } } +/// The Lift style as drawn: `auto` is a shadow on a light page, and on a +/// dark one, where a shadow has nothing to darken, no lift at all (the other +/// panes recede instead, `inactiveDim`). +pub fn liftStyle(p: *const Pardes) config.Runtime.LiftStyle { + return switch (p.settings.lift) { + .auto => if (darkPage(p)) .off else .shadow, + else => |style| style, + }; +} + +/// How far, in percent, the unfocused panes' text fades toward its ground: +/// InactiveDim, or under `Lift auto` on a dark page `auto_dim` while that +/// is unset. +pub fn inactiveDim(p: *const Pardes) u8 { + if (p.settings.inactive_dim != 0) return p.settings.inactive_dim; + return if (p.settings.lift == .auto and darkPage(p)) config.Runtime.auto_dim else 0; +} + +fn darkPage(p: *const Pardes) bool { + return if (p.theme().bg) |bg| luminance(bg) < 0.18 else true; +} + +/// sRGB channel to linear light. +const linear: [256]f32 = blk: { + @setEvalBranchQuota(100_000); + var table: [256]f32 = undefined; + for (&table, 0..) |*out, i| { + const c = @as(f64, @floatFromInt(i)) / 255; + out.* = @floatCast(if (c <= 0.04045) c / 12.92 else std.math.pow(f64, (c + 0.055) / 1.055, 2.4)); + } + break :blk table; +}; + +fn luminance(rgb: [3]u8) f32 { + return 0.2126 * linear[rgb[0]] + 0.7152 * linear[rgb[1]] + 0.0722 * linear[rgb[2]]; +} + +fn contrast(a: [3]u8, b: [3]u8) f32 { + const x = luminance(a); + const y = luminance(b); + return (@max(x, y) + 0.05) / (@min(x, y) + 0.05); +} + +/// Pane `id`'s text, `amount` (0 to 1) of the way to its ground, on the grid +/// and on every layer of it: its tag, its body, its notices. Its grip keeps +/// its colour, which already says it is not the focused one. +fn recede(p: *Pardes, s: *Surface, id: usize, amount: f32) void { + const th = p.theme(); + const chrome = p.chromeTheme(); + const r = p.rects[id]; + const palette = th.palette; + var memo: Memo = .{}; + const text_x = r.x + @min(config.GUTTER, r.w); + var row = r.y; + while (row < @min(@as(u32, r.y) + r.h, s.rows)) : (row += 1) { + var col = text_x; + while (col < @min(@as(u32, r.x) + r.w, s.cols)) : (col += 1) { + const cell = s.at(col, row); + if (cell.default) continue; + const tagline_role = cell.style.font_role == .tagline; + fadeStyle(&cell.style, if (tagline_role) chrome.tag_fg else th.fg, if (tagline_role) chrome.tag_bg else th.bg, palette, amount, &memo); + } + } + const body = &s.body_layers[id]; + if (body.rows != 0) for (body.cells[0..@as(usize, body.cols) * body.rows]) |*cell| fadeStyle(&cell.style, th.fg, th.bg, palette, amount, &memo); + const tag = &s.tag_layers[id]; + if (tag.rows != 0) for (tag.cells[0..@as(usize, tag.cols) * tag.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, tag.bg, palette, amount, &memo); + for (0..Pane.Notices.max) |i| { + const notice = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i]; + if (notice.rows != 0) for (notice.cells[0..@as(usize, notice.cols) * notice.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, notice.bg, palette, amount, &memo); + } +} + +/// The few ink and paper pairs a pane has, each faded once a frame. +const Memo = struct { + keys: [32]?[6]u8 = @splat(null), + values: [32][3]u8 = undefined, + + fn faded(memo: *Memo, ink: [3]u8, paper: [3]u8, amount: f32) [3]u8 { + const key = ink ++ paper; + const slot = (@as(usize, ink[0]) *% 31 +% ink[1] *% 7 +% ink[2] +% @as(usize, paper[0]) *% 13 +% paper[1] *% 3 +% paper[2]) % memo.keys.len; + if (memo.keys[slot]) |held| if (std.mem.eql(u8, &held, &key)) return memo.values[slot]; + // Contrast falls as the ink nears its paper: the most of `amount` + // that keeps the pair at its own contrast or 4.5, whichever is lower. + const floor = @min(contrast(ink, paper), 4.5); + var t = amount; + if (contrast(blend(ink, paper, t), paper) < floor) { + var lo: f32 = 0; + var hi: f32 = t; + for (0..8) |_| { + const mid = (lo + hi) / 2; + if (contrast(blend(ink, paper, mid), paper) >= floor) lo = mid else hi = mid; + } + t = lo; + } + memo.keys[slot] = key; + memo.values[slot] = blend(ink, paper, t); + return memo.values[slot]; + } +}; + +/// A cell's ink blended `amount` of the way toward its paper, never below +/// the pair's own contrast or 4.5, whichever is lower (docs/effects.md): +/// faint resolved first, reverse honoured, as InactiveDim always did. +fn fadeStyle(style: *CellStyle, default_fg: ?[3]u8, default_bg: ?[3]u8, palette: ?[16][3]u8, amount: f32, memo: *Memo) void { + if (style.invisible) return; + var fg = resolved(style.fg, default_fg, palette) orelse return; + const bg = resolved(style.bg, default_bg, palette) orelse return; + if (style.dim) for (&fg) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + const ink = if (style.reverse) bg else fg; + const paper = if (style.reverse) fg else bg; + const rgb = memo.faded(ink, paper, amount); + if (style.reverse) { + style.bg = .{ .rgb = rgb }; + if (style.dim) style.fg = .{ .rgb = fg }; + } else style.fg = .{ .rgb = rgb }; + style.dim = false; +} + +/// `t` of the way from one colour to the other in linear light (§8.2), so a +/// coloured ink does not muddy on its way to the paper. +fn blend(from: [3]u8, to: [3]u8, t: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, from, to) |*o, a, b| { + const l = linear[a] * (1 - t) + linear[b] * t; + o.* = encoded[@intFromFloat(@round(std.math.clamp(l, 0, 1) * (encoded.len - 1)))]; + } + return out; +} + +/// Linear light back to an sRGB channel, in 4096 steps. +const encoded: [4096]u8 = blk: { + @setEvalBranchQuota(1_000_000); + var table: [4096]u8 = undefined; + for (&table, 0..) |*out, i| { + const l = @as(f64, @floatFromInt(i)) / 4095; + const c = if (l <= 0.0031308) l * 12.92 else 1.055 * std.math.pow(f64, l, 1.0 / 2.4) - 0.055; + out.* = @intFromFloat(@round(c * 255)); + } + break :blk table; +}; + +fn resolved(color: Color, default: ?[3]u8, palette: ?[16][3]u8) ?[3]u8 { + return switch (color) { + .rgb => |rgb| rgb, + .default => default, + // A theme's own sixteen, as the painters give them (Terminal.palColor). + .index => |index| if (palette != null and index < 16) palette.?[index] else if (comptime pardes.terminal_panes) blk: { + const rgb = @import("ghostty-vt").color.default[index]; + break :blk .{ rgb.r, rgb.g, rgb.b }; + } else null, + }; +} + pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { panes.File.refreshHighlights(p); const s = &p.surface; @@ -201,7 +363,9 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .scroll_track = chrome.scroll_track, .scroll_thumb = chrome.scroll_thumb, .accent = chrome.box, - .lift_style = @intFromEnum(p.settings.lift), + .sel_fg = p.theme().sel_fg, + .sel_bg = p.theme().sel_bg, + .lift_style = @intFromEnum(liftStyle(p)), }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. @@ -221,6 +385,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (region.kind == .notice) try joinNotice(p, s, arena, region); } + for (p.panes, p.lifts, 0..) |slot, spring, id| { + if (slot == null or id == p.active) continue; + const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - spring.value(p.now_ns), 0, 1); + if (amount > 0.001) recede(p, s, id, amount); + } + try paintHeaders(p, s, arena); switch (p.drag) { @@ -1128,18 +1298,25 @@ test "a notice wider than its band, as a shader's compile error is, draws its en test "a lift is sampled at each frame's own time, and asks for frames only while it moves" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); defer p.deinit(); + // A grid has no in-between: there a lift snaps. + if (!builtins.capabilities.lift) return error.SkipZigTest; _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const ms = std.time.ns_per_ms; + p.settings.lift = .shadow; p.advance(1000 * ms); _ = try p.render(arena.allocator()); + p.advance(3000 * ms); + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); - p.settings.lift = .shadow; + p.active = 0; var lifts: [3]f32 = undefined; // 144 Hz: 6.9 ms apart, none of them on a 16 ms step of its own. for (&lifts, 0..) |*lift, i| { - p.advance(1000 * ms + @as(u64, @intCast(i)) * 6_944_444); + p.advance(3000 * ms + @as(u64, @intCast(i)) * 6_944_444); _ = arena.reset(.retain_capacity); const s = try p.render(arena.allocator()); lift.* = for (s.regionList()) |region| { @@ -1149,13 +1326,150 @@ test "a lift is sampled at each frame's own time, and asks for frames only while } try std.testing.expect(lifts[0] < lifts[1] and lifts[1] < lifts[2]); // Settled, it is exactly up, and the core is idle again. - p.advance(2000 * ms); + p.advance(5000 * ms); _ = arena.reset(.retain_capacity); _ = try p.render(arena.allocator()); try std.testing.expect(!p.continuous()); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); } +/// The contrast a pane's first body glyph `g` is drawn at, on the grid. +fn glyphContrast(p: *Pardes, s: *Surface, id: usize, g: u8) f32 { + const r = p.rects[id]; + const th = p.theme(); + var row = p.bodyTop(p.panes[id].?, r); + while (row < r.y + r.h) : (row += 1) { + var col = r.x + config.GUTTER; + while (col < r.x + r.w) : (col += 1) { + const cell = s.at(col, row); + if (cell.default or cell.len != 1 or cell.text[0] != g) continue; + var fg = resolved(cell.style.fg, th.fg orelse .{ 255, 255, 255 }, th.palette).?; + const bg = resolved(cell.style.bg, th.bg orelse .{ 0, 0, 0 }, th.palette).?; + if (cell.style.dim) for (&fg) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + return contrast(fg, bg); + } + } + unreachable; +} + +test "a focused pane's text is never at less contrast than an unfocused one's, in any Lift, dim or theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const s_ns = std.time.ns_per_s; + var now: u64 = s_ns; + for (0..pardes.themes.len) |theme| { + colors.setThemeIndex(p, theme); + for (std.enums.values(config.Runtime.LiftStyle)) |style| for ([_]u8{ 0, 100 }) |dim| { + p.settings.lift = style; + p.settings.inactive_dim = dim; + var seen: [2]f32 = undefined; + for (&seen, [_]usize{ 0, 1 }) |*c, focus| { + p.active = focus; + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + now += 2 * s_ns; + p.advance(now); + _ = arena.reset(.retain_capacity); + c.* = glyphContrast(p, try p.render(arena.allocator()), 0, 't'); + } + try std.testing.expect(seen[0] >= seen[1] - 1e-3); + // And the unfocused one is still legible. + try std.testing.expect(seen[1] >= @min(seen[0], 4.5) - 2e-2); + }; + } +} + +test "a dim leaves the focused pane's tag, grip and cells exactly as they were" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.active = 0; + p.advance(std.time.ns_per_s); + const plain = try p.render(arena.allocator()); + const r = p.rects[0]; + var before: std.ArrayList(Cell) = .empty; + defer before.deinit(std.testing.allocator); + for (r.y..r.y + r.h) |row| try before.appendSlice(std.testing.allocator, plain.cells[row * plain.cols + r.x ..][0..r.w]); + const tag_bg = plain.tag_layers[0].bg; + const tag_cells = try std.testing.allocator.dupe(Cell, plain.tag_layers[0].cells[0 .. @as(usize, plain.tag_layers[0].cols) * plain.tag_layers[0].rows]); + defer std.testing.allocator.free(tag_cells); + p.settings.inactive_dim = 100; + p.advance(3 * std.time.ns_per_s); + const dimmed = try p.render(arena.allocator()); + var i: usize = 0; + for (r.y..r.y + r.h) |row| for (dimmed.cells[row * dimmed.cols + r.x ..][0..r.w]) |cell| { + try std.testing.expect(cell.visuallyEqual(&before.items[i])); + i += 1; + }; + try std.testing.expectEqual(tag_bg, dimmed.tag_layers[0].bg); + for (tag_cells, dimmed.tag_layers[0].cells[0..tag_cells.len]) |a, b| try std.testing.expect(a.visuallyEqual(&b)); +} + +test "a dim never takes a pair below its own contrast or 4.5" { + for (pardes.themes) |theme| { + const fg = theme.fg orelse continue; + const bg = theme.bg orelse continue; + for ([_]bool{ false, true }) |reverse| for ([_]bool{ false, true }) |faint| { + var style: CellStyle = .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .reverse = reverse, .dim = faint }; + var ink = fg; + if (faint) for (&ink) |*channel| { + channel.* = @intCast(@as(u16, channel.*) * 6 / 10); + }; + var memo: Memo = .{}; + fadeStyle(&style, null, null, null, 1, &memo); + const after = contrast(style.fg.rgb, style.bg.rgb); + try std.testing.expect(after >= @min(contrast(ink, bg), 4.5) - 2e-2); + }; + } +} + +test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixteen" { + var memo: Memo = .{}; + var low: CellStyle = .{ .fg = .{ .rgb = .{ 0x80, 0x80, 0x80 } }, .bg = .{ .rgb = .{ 0x60, 0x60, 0x60 } } }; + fadeStyle(&low, null, null, null, 1, &memo); + try std.testing.expectEqual([3]u8{ 0x80, 0x80, 0x80 }, low.fg.rgb); + // acme's bright white is black: faded, it stays a grey, never a white. + const acme = for (pardes.themes) |theme| { + if (std.mem.eql(u8, theme.name, "acme")) break theme; + } else unreachable; + var bright: CellStyle = .{ .fg = .{ .index = 15 } }; + memo = .{}; + fadeStyle(&bright, acme.fg, acme.bg, acme.palette, 0.3, &memo); + const rgb = bright.fg.rgb; + try std.testing.expect(rgb[0] == rgb[1] and rgb[0] < 0xc0); + // Reverse: the paper moves, the ink stays. + var reverse: CellStyle = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = .{ 255, 255, 255 } }, .reverse = true }; + memo = .{}; + fadeStyle(&reverse, null, null, null, 0.3, &memo); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, reverse.fg.rgb); + try std.testing.expect(reverse.bg.rgb[0] < 255); +} + +test "with no lift and no dim a focus switch asks for no frames" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + p.advance(std.time.ns_per_s); + _ = try p.render(arena.allocator()); + p.active = 0; + _ = arena.reset(.retain_capacity); + _ = try p.render(arena.allocator()); + try std.testing.expect(!p.continuous()); +} + test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" { // The snapshot harness runs the tty, whose tagline pitch is the body's, // so it never reaches what a narrower tagline does to a notice's band: diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 5d9783e3..d8bae6dc 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7448,55 +7448,90 @@ fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f const LiftCeilings = struct { shadow: f32, white: f32, - accent: f32, + /// A rim is light on a dark page and shade on a light one, where light + /// would not show. + rim: [3]u8, + /// Over the text on the page, and over a selection's text on its own + /// ground: the lower of the two. fn of(chrome: *const pardes.Chrome) LiftCeilings { const fg = chrome.fg orelse fg_default; const bg = chrome.page orelse bg_default; - return .{ .shadow = shadowCeiling(fg, bg), .white = lightenCeiling(fg, bg, .{ 255, 255, 255 }), .accent = lightenCeiling(fg, bg, chrome.accent) }; + return .{ + .shadow = @min(shadowCeiling(fg, bg), shadowCeiling(chrome.sel_fg, chrome.sel_bg)), + .white = @min(lightenCeiling(fg, bg, .{ 255, 255, 255 }), lightenCeiling(chrome.sel_fg, chrome.sel_bg, .{ 255, 255, 255 })), + .rim = if (luminance(bg) < 0.18) .{ 255, 255, 255 } else .{ 0, 0, 0 }, + }; } }; /// What stands a lifted part off the page, `lift` 0 to 1 (a pane's spring, -/// a notice's 1), in the style chosen; `auto` reads the page: a shadow on a -/// light one, a lighter surface and a faint rim on a dark one, where a -/// shadow has nothing to darken. -fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, chrome: *const pardes.Chrome, ceilings: LiftCeilings, caster: [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { +/// a notice's 1), in the style chosen: a soft shadow, or a rim along the +/// top of the focused pane's tag rows (`rim_edge`, a pane's only: +/// never a notice's). +fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, ceilings: LiftCeilings, caster: [4]f32, rim_edge: ?[3]f32, bodies: []const [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { if (lift <= 0.001) return; const h: f32 = @floatFromInt(g.cell_h); - const dark_page = luminance(chrome.page orelse bg_default) < 0.18; - const parts: struct { shadow: bool = false, surface: bool = false, rim: f32 = 0, glow: bool = false } = switch (style) { + // The core sends `auto` already resolved (draw.liftStyle). + const shadow = switch (style) { .off => return, - .shadow => .{ .shadow = true }, - .surface => .{ .surface = true }, - .rim => .{ .rim = 1 }, - .glow => .{ .glow = true }, - .auto => if (dark_page) .{ .surface = true, .rim = 0.5 } else .{ .shadow = true }, + .shadow, .auto => true, + .rim => false, }; - if (parts.surface) { - // A raised surface catches more light: its ground, and all on it, - // a few percent lighter, in whole pixels. - const a = @min(Lift.surface * lift, ceilings.white); - if (a > 0.002) try addDecor(g, gpa, caster[0], caster[1], caster[2], caster[3], .{ 255, 255, 255 }, a, track, win_w, win_h); + if (shadow) { + // Nothing of a shadow may touch a focus indicator (docs/effects.md): + // it falls on pane bodies and rails, never a tag, a grip or a header. + const first = g.decor.items.len; + try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); + try clipToBodies(g, gpa, first, bodies); } - if (parts.shadow) try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); - // Light from above and to the left: a thin halo off the top and left - // edges, gone by the bottom and the right. - if (parts.rim > 0) try addSoft(g, gpa, caster, Lift.rim_sigma * h * lift, -Lift.rim_offset * h * lift, -Lift.rim_offset * h * lift, @min(Lift.rim * parts.rim * lift, ceilings.white), .{ 255, 255, 255 }, track, win_w, win_h); - if (parts.glow) try addSoft(g, gpa, caster, Lift.glow_sigma * h * lift, 0, 0, @min(Lift.glow * lift, ceilings.accent), chrome.accent, track, win_w, win_h); + // A hairline just above the focused tag rows, never on them: it adds to + // the indicator without changing its colour. + if (style == .rim) if (rim_edge) |edge| { + const light = ceilings.rim[0] == 255; + const alpha = @min(Lift.rim * lift, if (light) ceilings.white else ceilings.shadow); + try addDecor(g, gpa, edge[0], edge[2] - 1, edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h); + }; } -/// The lighter styles' strengths, in the §8.2 budget: coloured light at -/// most 25%, a surface a few percent. +/// A rim's strength, in the §8.2 budget for coloured light. const Lift = struct { - const surface: f32 = 0.055; - const rim: f32 = 0.22; - const rim_sigma: f32 = 0.12; - const rim_offset: f32 = 0.1; - const glow: f32 = 0.2; - const glow_sigma: f32 = 0.5; + const rim: f32 = 0.35; }; +/// A body region in window pixels. +fn bodyPixels(region: pardes.Region, layout: CellLayout) [4]f32 { + return .{ + @as(f32, @floatFromInt(region.rect.x)) * layout.w, + @as(f32, @floatFromInt(region.rect.y)) * layout.h, + @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w, + @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h, + }; +} + +/// Replaces the decor from `first` on with a copy per pane body it reaches, +/// each clipped to that body (and to what a vertical transition already +/// clipped it to): the lift then never reaches a tag, a grip or a header. +fn clipToBodies(g: *Gui, gpa: std.mem.Allocator, first: usize, bodies: []const [4]f32) !void { + const added = g.decor.items.len - first; + if (added == 0) return; + var own: [8]CellInstance = undefined; + const n = @min(added, own.len); + @memcpy(own[0..n], g.decor.items[first..][0..n]); + g.decor.shrinkRetainingCapacity(first); + for (own[0..n]) |instance| for (bodies) |body| { + var copy = instance; + const vertical = copy.clip_x1 >= copy.clip_x0; + copy.clip_x0 = if (vertical) @max(copy.clip_x0, body[0]) else body[0]; + copy.clip_y0 = if (vertical) @max(copy.clip_y0, body[1]) else body[1]; + copy.clip_x1 = if (vertical) @min(copy.clip_x1, body[2]) else body[2]; + copy.clip_y1 = if (vertical) @min(copy.clip_y1, body[3]) else body[3]; + if (copy.clip_x1 <= copy.clip_x0 or copy.clip_y1 <= copy.clip_y0) continue; + try g.decor.append(gpa, copy); + }; +} + + /// Like shadowCeiling for a light colour laid over at `a` (sRGB blend, as /// the decor pipeline blends a colour that is not black): the largest `a` /// that keeps text and page apart by min(their contrast, 4.5). @@ -7547,11 +7582,11 @@ fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 { return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1); } -test "a rim, a glow or a surface never lightens a native theme's text below its contrast or 4.5" { +test "a rim never lightens a native theme's text below its contrast or 4.5" { for (pardes.themes) |theme| { const fg = theme.fg orelse fg_default; const bg = theme.bg orelse bg_default; - for ([_][3]u8{ .{ 255, 255, 255 }, theme.box }) |light| { + for ([_][3]u8{.{ 255, 255, 255 }}) |light| { const a = @min(Lift.rim, lightenCeiling(fg, bg, light)); const blend = struct { fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 { @@ -7570,6 +7605,74 @@ test "a rim, a glow or a surface never lightens a native theme's text below its } } +test "a lift never touches a tag, a grip or a header, on a light theme or a dark one" { + const gpa = std.testing.allocator; + for ([_][]const u8{ "forge", "daybreak" }) |name| { + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + var line: [64]u8 = undefined; + try std.testing.expect(core.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Theme {s}", .{name}))); + for (0..40) |_| core.update(.tick); + for (std.enums.values(pardes.config.Runtime.LiftStyle)) |style| { + core.settings.lift = style; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a floating notice")); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + const settled = try core.render(core.scratch.allocator()); + _ = s; + // The focus indicator itself: the focused pane's tag is not the + // others' colour, whatever the lift. + var active: ?[3]u8 = null; + var other: ?[3]u8 = null; + for (settled.tagLayers()) |layer| if (layer.rows != 0 and layer.kind == .pane) { + if (layer.id == core.active) active = layer.bg else other = layer.bg; + }; + try std.testing.expect(!std.mem.eql(u8, &active.?, &other.?)); + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.decor = .empty; + defer g.decor.deinit(gpa); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, settled, &groups, fixedCellLayout(&g), 800, 480, true); + var lifted: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 < item.clip_x0) continue; + lifted += 1; + // Inside a body or a rail, all of it. + const inside = for (settled.regionList()) |region| { + if (region.kind != .body and region.kind != .rail) continue; + const r = bodyPixels(region, fixedCellLayout(&g)); + if (item.clip_x0 >= r[0] and item.clip_y0 >= r[1] and item.clip_x1 <= r[2] and item.clip_y1 <= r[3]) break true; + } else false; + try std.testing.expect(inside); + } + const light_page = std.mem.eql(u8, name, "daybreak"); + try std.testing.expectEqual(style == .shadow or (style == .auto and light_page), lifted != 0); + // A rim is one hairline, just above the focused pane's tag row. + var rims: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 >= item.clip_x0) continue; + const top = (1 - item.y0) / 2 * 480; + const height = (item.y0 - item.y1) / 2 * 480; + const on_active_tag = for (settled.regionList()) |region| { + if (region.kind == .tag and region.owner == core.active and @abs(top + 1 - @as(f32, @floatFromInt(region.rect.y)) * 20) < 0.01 and @abs(height - 1) < 0.01) break true; + } else false; + // Not the rule under a column bar, which may run there too. + rims += @intFromBool(on_active_tag and item.fr < 1); + } + try std.testing.expectEqual(@as(usize, @intFromBool(style == .rim)), rims); + } + } +} + test "a shadow never darkens a native theme's text below its contrast or 4.5" { var shaded: usize = 0; for (pardes.themes) |theme| { @@ -7597,6 +7700,13 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const chrome = &surface.chrome; const regions = surface.regionList(); const ceilings = LiftCeilings.of(chrome); + var current_buf: [pardes.MAX_PANES][4]f32 = undefined; + var ncurrent: usize = 0; + for (regions) |part| if ((part.kind == .body or part.kind == .rail) and ncurrent < current_buf.len) { + current_buf[ncurrent] = bodyPixels(part, layout); + ncurrent += 1; + }; + const current_bodies = current_buf[0..ncurrent]; const lift_style = std.enums.fromInt(pardes.config.Runtime.LiftStyle, chrome.lift_style) orelse .off; const notice_lift = for (regions) |region| { if (region.kind == .notice) break region.lift; @@ -7638,6 +7748,14 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g // A closing pane is gone: its chrome is the last frame's. const closing = if (track) |active| active.phase == .closing else false; const placed = if (closing) surface.previous_regions else regions; + // Where a lift may fall: pane bodies, never tags (addLift). + var body_buf: [pardes.MAX_PANES][4]f32 = undefined; + var nbodies: usize = 0; + for (placed) |part| if ((part.kind == .body or part.kind == .rail) and nbodies < body_buf.len) { + body_buf[nbodies] = bodyPixels(part, layout); + nbodies += 1; + }; + const bodies = body_buf[0..nbodies]; const grid = if (closing) surface.previous_cells else surface.cells; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // A single spine runs down each column, through every tag and body. @@ -7663,7 +7781,11 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w); box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h); } - try addLift(g, gpa, lift_style, chrome, ceilings, box, grip.lift, track, win_w, win_h); + // The top edge of its tag rows, for a rim: x0, x1, y. + const rim_edge: ?[3]f32 = for (placed) |part| { + if (part.owner == grip.owner and part.kind == .tag) break .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w, @as(f32, @floatFromInt(part.rect.y)) * layout.h }; + } else null; + try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h); } } // A notice chip is tagline hung over the body's top rows, so it is @@ -7676,7 +7798,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h; const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; - try addLift(g, gpa, lift_style, chrome, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, notice_lift * layer.fade, track, win_w, win_h); + try addLift(g, gpa, lift_style, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, null, current_bodies, notice_lift * layer.fade, track, win_w, win_h); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 46c451ed..1c21d314 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -306,6 +306,8 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }), .lift => try w.print("{s} {s}\n", .{ word, @tagName(s.lift) }), + .motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }), + .inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }), } } try w.writeAll("LocationsConfig "); diff --git a/src/surface.zig b/src/surface.zig index e7149155..f16993a8 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -398,6 +398,9 @@ pub const Chrome = struct { scroll_thumb: [3]u8 = .{ 0, 0, 0 }, /// The theme's accent (a glow's tint). accent: [3]u8 = .{ 0, 0, 0 }, + /// A selection's text and ground: a lift keeps their contrast too. + sel_fg: [3]u8 = .{ 0, 0, 0 }, + sel_bg: [3]u8 = .{ 0, 0, 0 }, /// How a lifted part stands off the page (config.Runtime.LiftStyle). lift_style: u8 = 0, }; diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap index 84de680f..8bf70f36 100644 --- a/test/snapshots/builtins.snap +++ b/test/snapshots/builtins.snap @@ -27,6 +27,9 @@ wheel up 110 50 wheel up 110 50 wheel up 110 50 wheel up 110 50 +wheel up 110 50 +wheel up 110 50 +wheel up 110 50 wait 10000 Look enter, right-click wait 10000 Exec tab, middle-click wait 10000 Filter |
