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 /src/gui | |
| 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/.
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/gui.zig | 148 |
1 files changed, 127 insertions, 21 deletions
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 |
