summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 07:31:35 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit87ad46cb7e7410c53419c20cef7df53ed4c15f60 (patch)
treeddc88864c5a741426d1e7d89097dd56b4586e883 /src/gui
parent411d0b6059192b75853e220e1242b694ae461a3e (diff)
downloadpardes-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.zig148
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