diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 07:31:35 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | 87ad46cb7e7410c53419c20cef7df53ed4c15f60 (patch) | |
| tree | ddc88864c5a741426d1e7d89097dd56b4586e883 | |
| parent | 411d0b6059192b75853e220e1242b694ae461a3e (diff) | |
| download | pardes-87ad46cb7e7410c53419c20cef7df53ed4c15f60.tar.gz pardes-87ad46cb7e7410c53419c20cef7df53ed4c15f60.zip | |
G11: HoverGlow, a soft underline of light under a look-hover's word
HoverGlow on|off (off, a pixel shell's; Chrome.glows bit 1, SelectionGlow
bit 0 now that there are two): under the word a look-hover would open (its
cells in the affordance's tint) a strip three logical pixels tall along
its foot, blurred by one, in the theme's accent, at most 22% and under
the text's contrast floor, fading in over 80 ms; kept to the bodies and
split round a cursor. glowBlocks serves G9 and G11. The strip is cast from
a row away so addSoft's caster cut-out misses it.
Tests: the glow comes up by 90 ms and never falls on the cursor's cell.
Gates as G10 (a loaded machine shifted the GUI goldens a scene once; a
rerun matched 01-08). Feel review: .scratch/render/hover/.
| -rw-r--r-- | docs/config.md | 2 | ||||
| -rw-r--r-- | docs/effects.md | 12 | ||||
| -rw-r--r-- | src/config.zig | 12 | ||||
| -rw-r--r-- | src/detached/wire.zig | 2 | ||||
| -rw-r--r-- | src/draw.zig | 2 | ||||
| -rw-r--r-- | src/gui/gui.zig | 148 | ||||
| -rw-r--r-- | src/surface.zig | 6 |
7 files changed, 156 insertions, 28 deletions
diff --git a/docs/config.md b/docs/config.md index 96281cd5..cd1244f7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -574,6 +574,8 @@ InactiveDim 30 the unfocused panes' text fades 30% toward its ground Motion smooth off, crisp, smooth (the default), bouncy or playful SelectionGlow on a soft halo of the selection's colour round it in the body, fading in over 100 ms, never over a tag, a grip or the cursor +HoverGlow on a soft glow under the word a look-hover would open, fading + in over 80 ms CursorBlink on the cursor blinks, solid while typing and half a second after, eased at each edge, solid after 10 idle seconds GripWidth 150 the grip's button and the scrollbar under it, percent of the diff --git a/docs/effects.md b/docs/effects.md index 0e0ad51a..c11ff070 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -44,6 +44,7 @@ why. The verdict is the user's. | G8 notice chips | follows `Motion` and `Lift` | a notice floats in tier 4 on a lift (shadow) while Lift is on, drops in easing out and dissolves easing in, quicker than it came (150 ms against 180) | .scratch/render/notices/ (acme, forge) | pending | | G9 selection glow | `SelectionGlow on\|off` (off) | a soft halo of the selection's colour round each block of it in a body, fading in over 100 ms | .scratch/render/selection/ (forge, acme, lapis; lapis-glow-zoom.png) | pending | | G10 theme crossfade | always (a theme change) | the chrome's colours fade through OKLab, easing in and out (out when retargeted) | .scratch/render/crossfade/ (forge, lapis, acme cut short by dusk) | pending | +| G11 look-hover glow | `HoverGlow on\|off` (off) | a soft underline of the theme's accent under the word a look-hover would open, fading in over 80 ms | .scratch/render/hover/ (forge, acme, lapis; zooms) | pending | ## Motion flavours @@ -207,3 +208,14 @@ muddy, on a smooth ease-in-out; a change made mid-fade sets off from where it shows and eases out, so it moves at once instead of stalling. The page, its text and the syntax colours still change at once with the theme, as before: fading them would re-resolve every cell each frame. + +Notes on G11: the word a look-hover would open is already lit (the +affordance's tint); HoverGlow adds a strip three logical pixels tall along +its foot in the theme's accent, its edges blurred by one, at most 22% and +under the text's contrast floor, fading in over 80 ms (micro-feedback). It +stays on bodies and is split round a cursor, so it never touches one. The +word is found by the tint's colour, as G9 finds the selection. +Found while reviewing: with the machine loaded, test/gui_golden.py's +two-captures-agree settle can take a scene's frame before the scene's +change lands, and every later hash moves up a scene; a rerun on a quiet +machine matches. diff --git a/src/config.zig b/src/config.zig index 229c70ea..4e159d01 100644 --- a/src/config.zig +++ b/src/config.zig @@ -123,6 +123,7 @@ pub const leader_path = paths: { if (builtins.capabilities.ligatures) table.set(.Ligatures, null); if (builtins.capabilities.lift) table.set(.CursorBlink, null); if (builtins.capabilities.lift) table.set(.SelectionGlow, null); + if (builtins.capabilities.lift) table.set(.HoverGlow, null); if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); @@ -670,6 +671,9 @@ pub const Runtime = struct { /// G9: a soft halo round the selection in the body, fading in over /// 100 ms, a pixel shell's (docs/effects.md). selection_glow: bool = false, + /// G11: a soft glow under the word a look-hover would open, fading in + /// over 80 ms, a pixel shell's. + hover_glow: bool = false, /// How long a message stays after the key or click that dismisses it. message_linger_ms: u16 = 800, /// How long its fall into the row takes, easing out (§8.1: arriving, @@ -893,7 +897,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, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow, hover_glow }; /// A bundled post pass, a Shadertoy file in shaders/post/: the CRT, and /// G4's bloom, vignette and grain (docs/effects.md). pub const Scene = enum { crt, bloom, vignette, grain }; @@ -953,6 +957,7 @@ pub const Runtime = struct { .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures, .doc = "A font's programming ligatures draw across their cells, on or off." }, .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift, .doc = "The cursor blinks, solid while typing, on or off." }, .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift, .doc = "A soft halo of the selection's colour round it in the body, on or off." }, + .{ .word = "HoverGlow", .action = .{ .toggle = .hover_glow }, .availability = .lift, .doc = "A soft underline of light under the word a look-hover would open, on or off." }, .{ .word = "Lift", .action = .lift, .availability = .lift, .doc = "How the focused pane stands off the page: off, shadow, rim or auto." }, .{ .word = "Motion", .action = .motion, .doc = "How animations move: off, crisp, smooth, bouncy or playful." }, .{ .word = "InactiveDim", .action = .inactive_dim, .doc = "Fade the unfocused panes' text by a percentage, 0 through 100, 0 off." }, @@ -1029,9 +1034,9 @@ pub const Runtime = struct { .toggle => |field| if (field == .ligatures) { if (setting.availability != .ligatures) @compileError("the ligatures toggle must use the ligatures capability"); - } else if (field == .cursor_blink or field == .selection_glow) { + } else if (field == .cursor_blink or field == .selection_glow or field == .hover_glow) { if (setting.availability != .lift) - @compileError("CursorBlink and SelectionGlow must use the lift capability (a pixel shell's)"); + @compileError("CursorBlink, SelectionGlow and HoverGlow must use the lift capability (a pixel shell's)"); } else if (setting.availability != null) @compileError("unconditional toggles cannot carry a backend capability"), else => if (setting.availability != null) @@ -1687,6 +1692,7 @@ pub const Runtime = struct { .ligatures => "Ligatures off\n", .cursor_blink => "CursorBlink off\n", .selection_glow => "SelectionGlow on\n", + .hover_glow => "HoverGlow 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 3326277d..cf5a8711 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -13,7 +13,7 @@ pub const version: u16 = 8; /// The Chrome palette is carried field by field as its declaration lists /// them, so any change to it changes the wire: bump `version`, then this. -const chrome_layout: u64 = 0x7f982e44ef87de72; +const chrome_layout: u64 = 0x3195816f182fe5bc; comptime { @setEvalBranchQuota(10_000); var hash = std.hash.Fnv1a_64.init(); diff --git a/src/draw.zig b/src/draw.zig index 6c0cc077..5e40f0fa 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -472,7 +472,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)), .name_ink = chrome.tag_name_fg, .active_name_ink = if (p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg, - .selection_glow = @intFromBool(p.settings.selection_glow), + .glows = @as(u8, @intFromBool(p.settings.selection_glow)) | @as(u8, @intFromBool(p.settings.hover_glow)) << 1, }; if (p.theme().decor) |decor| { s.chrome.decor_dots = decor.page_dots; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index bd2dca69..45861ea4 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1870,8 +1870,10 @@ const Gui = struct { /// picture follows on a spring (Glide), never the other way round. scroll_notch: i32 = 0, glide: Glide = .{}, - /// G9: the selection's glow blocks as last drawn, for their fade-in. + /// G9 and G11: the selection's and the hovered word's glow blocks as + /// last drawn, for their fade-in. sel_glow: SelGlow = .{}, + hover_glow: SelGlow = .{}, /// The cursor's blink (blinkNow): when the frame drawn last arrived, /// a redraw owed for an edge, and this frame's cursor mode and alpha. @@ -4988,7 +4990,7 @@ fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const glide = &g.glide; // The glide runs on the core's continuous path: frames every loop, and // a virtual clock carried on. - defer core.shell_continuous = glide.moving() or g.sel_glow.fading; + defer core.shell_continuous = glide.moving() or g.sel_glow.fading or g.hover_glow.fading; glide.now_ns = core.now_ns; glide.motion = pardes.animation.Motion.of(core.settings.motion); if (!glide.spring.settled) { @@ -5813,7 +5815,9 @@ fn renderFrame( if (g.glide.pending) g.glide.measure(gpa, surface); g.sel_glow.now_ns = if (core) |p| p.now_ns else c.SDL_GetTicksNS(); // Off, or while panes move, no glow and nothing fading. - if (surface.chrome.selection_glow == 0 or surface.panelTracks().len != 0) g.sel_glow = .{ .now_ns = g.sel_glow.now_ns }; + if (surface.chrome.glows & 1 == 0 or surface.panelTracks().len != 0) g.sel_glow = .{ .now_ns = g.sel_glow.now_ns }; + g.hover_glow.now_ns = g.sel_glow.now_ns; + if (surface.chrome.glows & 2 == 0 or surface.panelTracks().len != 0) g.hover_glow = .{ .now_ns = g.sel_glow.now_ns }; if (g.glide.moving()) g.glide.offset = g.glide.spring.value(g.glide.now_ns); cell_decor = CellDecor.of(chrome); // Plaques need an opaque window: with WindowOpacity under 100 the cells @@ -8704,7 +8708,9 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const bodies = body_buf[0..nbodies]; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // G9, while nothing moves: a halo round the body's selection. - if (index == 0 and groups.tracks == 0 and chrome.selection_glow != 0) try selectionGlow(g, gpa, surface, ceilings, layout, win_w, win_h); + if (index == 0 and groups.tracks == 0 and chrome.glows & 1 != 0) try glowBlocks(g, gpa, surface, .selection, layout, win_w, win_h); + // G11: a glow under the word a look-hover would open. + if (index == 0 and groups.tracks == 0 and chrome.glows & 2 != 0) try glowBlocks(g, gpa, surface, .hover, layout, win_w, win_h); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { if (column.kind != .column) continue; @@ -8956,19 +8962,31 @@ const SelGlow = struct { /// How long a glow takes to come up (§8.1's micro-feedback: 80 to 120 ms). const sel_glow_ns: u64 = 100 * std.time.ns_per_ms; +/// A glow's kind: G9's halo round the selection, G11's under a hovered word. +const GlowKind = enum { selection, hover }; + /// G9: a soft halo of the selection's own colour round each block of it in /// a pane body (rows of selected cells that touch), fading in over 100 ms /// with an ease-out. The selection itself is the halo's caster, never /// touched; so is a cursor beside it; the halo stays on bodies (never a /// tag or a grip) and at most 22%, less where the text round it would drop /// below min(its contrast, 4.5). -fn selectionGlow(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, ceilings: LiftCeilings, layout: CellLayout, win_w: f32, win_h: f32) !void { - _ = ceilings; +/// G11: under the word a look-hover would open (its cells in the +/// affordance's tint), a soft line of the theme's accent along its foot, +/// fading in over 80 ms, as strong at most, never under a cursor. +fn glowBlocks(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, kind: GlowKind, layout: CellLayout, win_w: f32, win_h: f32) !void { const chrome = &surface.chrome; - const sel = chrome.sel_bg; const page = chrome.page orelse bg_default; const fg = chrome.fg orelse fg_default; - const cap = @min(0.22, lightenCeiling(fg, page, sel)); + // The selection's halo is its own colour; a hovered word's underline is + // the theme's accent (its focused grip), like a link's. + const glow = if (kind == .selection) chrome.sel_bg else chrome.accent; + // The cells a block is made of: the selection's, or the hover + // affordance's tint (draw.zig: halfway to a quarter of the selection). + const sel = if (kind == .selection) chrome.sel_bg else mixRgb(page, mixRgb(page, chrome.sel_bg)); + const tracker = if (kind == .selection) &g.sel_glow else &g.hover_glow; + const rise_ns: u64 = if (kind == .selection) sel_glow_ns else 80 * std.time.ns_per_ms; + const cap = @min(0.22, lightenCeiling(fg, page, glow)); var blocks: [32][4]u16 = undefined; var nblocks: usize = 0; var bodies: [pardes.MAX_PANES][4]f32 = undefined; @@ -9012,28 +9030,66 @@ fn selectionGlow(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface } nblocks = @min(nblocks, blocks.len); // Each block keeps the start of the one it overlaps from the last frame. - var next: SelGlow = .{ .now_ns = g.sel_glow.now_ns }; + var next: SelGlow = .{ .now_ns = tracker.now_ns }; for (blocks[0..nblocks]) |block| { - const start = for (g.sel_glow.blocks[0..g.sel_glow.len]) |old| { + const start = for (tracker.blocks[0..tracker.len]) |old| { if (old.box[0] < block[2] and block[0] < old.box[2] and old.box[1] < block[3] and block[1] < old.box[3]) break old.start_ns; - } else g.sel_glow.now_ns; + } else tracker.now_ns; next.blocks[next.len] = .{ .box = block, .start_ns = start }; next.len += 1; - const t = @as(f32, @floatFromInt(g.sel_glow.now_ns -| start)) / @as(f32, @floatFromInt(sel_glow_ns)); + const t = @as(f32, @floatFromInt(tracker.now_ns -| start)) / @as(f32, @floatFromInt(rise_ns)); if (t < 1) next.fading = true; const u = std.math.clamp(t, 0, 1); const fade = 1 - (1 - u) * (1 - u) * (1 - u); var caster: [4]f32 = .{ @as(f32, @floatFromInt(block[0])) * layout.w, @as(f32, @floatFromInt(block[1])) * layout.h, @as(f32, @floatFromInt(block[2])) * layout.w, @as(f32, @floatFromInt(block[3])) * layout.h }; - // A cursor beside the selection is not glowed over: it joins the - // caster. - if (surface.cursor) |cu| if (cu.x + 1 >= block[0] and cu.x <= block[2] and cu.y + 1 >= block[1] and cu.y <= block[3]) { - caster = .{ @min(caster[0], @as(f32, @floatFromInt(cu.x)) * layout.w), @min(caster[1], @as(f32, @floatFromInt(cu.y)) * layout.h), @max(caster[2], @as(f32, @floatFromInt(cu.x + 1)) * layout.w), @max(caster[3], @as(f32, @floatFromInt(cu.y + 1)) * layout.h) }; - }; + const cursor_box: ?[4]f32 = if (surface.cursor) |cu| .{ @as(f32, @floatFromInt(cu.x)) * layout.w, @as(f32, @floatFromInt(cu.y)) * layout.h, @as(f32, @floatFromInt(cu.x + 1)) * layout.w, @as(f32, @floatFromInt(cu.y + 1)) * layout.h } else null; const first = g.decor.items.len; - try addSoft(g, gpa, caster, 0.3 * layout.h, 0, 0, cap * fade, sel, null, win_w, win_h); - try clipToBodies(g, gpa, first, bodies[0..nbodies]); + switch (kind) { + .selection => { + // A cursor beside the selection is not glowed over: it + // joins the caster. + if (surface.cursor) |cu| if (cu.x + 1 >= block[0] and cu.x <= block[2] and cu.y + 1 >= block[1] and cu.y <= block[3]) { + const cb = cursor_box.?; + caster = .{ @min(caster[0], cb[0]), @min(caster[1], cb[1]), @max(caster[2], cb[2]), @max(caster[3], cb[3]) }; + }; + try addSoft(g, gpa, caster, 0.3 * layout.h, 0, 0, cap * fade, glow, null, win_w, win_h); + try clipToBodies(g, gpa, first, bodies[0..nbodies]); + }, + .hover => { + // A strip three logical pixels tall along the word's foot, + // its edges blurred by one: an underline of light. It is + // cast from a row away (addSoft leaves its caster's own + // rectangle out), so all of it shows. + const foot = caster[3] - dp(1); + const away = layout.h; + try addSoft(g, gpa, .{ caster[0] + dp(1), foot - dp(3) - away, caster[2] - dp(1), foot - away }, dp(1), 0, away, cap * fade, glow, null, win_w, win_h); + // Kept to the bodies, and off a cursor: the parts either + // side of it. + var parts: [4 * pardes.MAX_PANES][4]f32 = undefined; + var nparts: usize = 0; + for (bodies[0..nbodies]) |b| { + const cb = cursor_box orelse { + parts[nparts] = b; + nparts += 1; + continue; + }; + if (cb[0] >= b[2] or cb[2] <= b[0] or cb[1] >= b[3] or cb[3] <= b[1]) { + parts[nparts] = b; + nparts += 1; + continue; + } + // Left and right of it, and above and below it. + parts[nparts] = .{ b[0], b[1], cb[0], b[3] }; + parts[nparts + 1] = .{ cb[2], b[1], b[2], b[3] }; + parts[nparts + 2] = .{ cb[0], b[1], cb[2], cb[1] }; + parts[nparts + 3] = .{ cb[0], cb[3], cb[2], b[3] }; + nparts += 4; + } + try clipToBodies(g, gpa, first, parts[0..nparts]); + }, + } } - g.sel_glow = next; + tracker.* = next; } /// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of @@ -9480,7 +9536,7 @@ test "the selection glows round its block, on the body only, fading in over 100 core.settings.selection_glow = true; pardes.test_api.sync(core); const s = try core.render(core.scratch.allocator()); - try std.testing.expectEqual(@as(u8, 1), s.chrome.selection_glow); + try std.testing.expectEqual(@as(u8, 1), s.chrome.glows); // Two rows of a selection in the body, as the core paints one. const body = for (s.regionList()) |region| { if (region.kind == .body) break region; @@ -9522,6 +9578,56 @@ test "the selection glows round its block, on the body only, fading in over 100 try std.testing.expect(!g.sel_glow.fading); } +test "a hovered word glows along its foot, never over the cursor, fading in over 80 ms" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.setTestFile("some text on a line\n" ** 30); + core.settings.hover_glow = true; + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + try std.testing.expectEqual(@as(u8, 2), s.chrome.glows); + const body = for (s.regionList()) |region| { + if (region.kind == .body) break region; + } else return error.NoBody; + const page = s.chrome.page orelse bg_default; + const tint = mixRgb(page, mixRgb(page, s.chrome.sel_bg)); + const row = body.rect.y + 4; + for (5..12) |x| { + const cell = &s.cells[@as(usize, row) * s.cols + body.rect.x + x]; + cell.default = false; + cell.style.bg = .{ .rgb = tint }; + } + s.cursor = .{ .x = body.rect.x + 8, .y = row }; + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.sel_glow = .{}; + g.hover_glow = .{ .now_ns = std.time.ns_per_s }; + var strongest: f32 = 0; + for ([_]u64{ 0, 90 * std.time.ns_per_ms }) |later| { + g.decor.clearRetainingCapacity(); + g.hover_glow.now_ns = std.time.ns_per_s + later; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + for (g.decor.items) |item| { + if (item.effect & soft_shadow_bit == 0) continue; + if (later == 0) try std.testing.expect(false); + strongest = @max(strongest, item.fr); + // Never over the cursor's cell. + const cx0: f32 = @floatFromInt(@as(u32, s.cursor.?.x) * 10); + const cy0: f32 = @floatFromInt(@as(u32, s.cursor.?.y) * 20); + try std.testing.expect(item.clip_x1 <= cx0 + 0.01 or item.clip_x0 >= cx0 + 10 - 0.01 or item.clip_y1 <= cy0 + 0.01 or item.clip_y0 >= cy0 + 20 - 0.01); + } + } + try std.testing.expect(strongest > 0.05); + try std.testing.expect(!g.hover_glow.fading); +} + /// What a bundled post pass leaves alone (Post.Frame.spare), in window /// pixels: every tag with its grip, the column and workspace tags, and the /// notices, the chrome the focus indicators live in. A grip, the gap after diff --git a/src/surface.zig b/src/surface.zig index 7256ecc1..26783df4 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -448,8 +448,10 @@ pub const Chrome = struct { decor_title_px: u8 = 2, name_ink: [3]u8 = .{ 0, 0, 0 }, active_name_ink: [3]u8 = .{ 0, 0, 0 }, - /// G9: a pixel shell glows round the body's selection (SelectionGlow). - selection_glow: u8 = 0, + /// A pixel shell's glows: bit 0 round the body's selection (G9, + /// SelectionGlow), bit 1 under the word a look-hover would open (G11, + /// HoverGlow). + glows: u8 = 0, }; /// The canonical interface: what a frame of pardes IS. The tty shell writes |
