diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 07:17:07 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | 99c088e277e7d79d7b4341d3f546c02e7254e107 (patch) | |
| tree | fcfa295e3d236ec6bd676b5ffcf57401e2dcf732 /src | |
| parent | dd586f1d8937f67e95504da167216d5413f05262 (diff) | |
| download | pardes-99c088e277e7d79d7b4341d3f546c02e7254e107.tar.gz pardes-99c088e277e7d79d7b4341d3f546c02e7254e107.zip | |
G9: SelectionGlow, a soft halo round the body's selection
SelectionGlow on|off (off; a pixel shell's, Chrome.selection_glow): each
block of selected rows in a pane body casts Lift's soft kind in the
selection's own colour, the block its caster (never touched) and a cursor
beside it joined to the caster; clipped to the bodies (never a tag or a
grip); at most 22% (§8.2), less where the text round it would drop below
min(its contrast, 4.5); fading in over 100 ms with an ease-out, on the
core's continuous path (shell_continuous); none while panes move. Blocks
come from the cells in the theme's selection colour (documented).
Tests: the halo's colour, its clip to the body, nothing at its first frame
and up to its cap after 100 ms, fading done. Gates as G8. Feel review:
.scratch/render/selection/.
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.zig | 12 | ||||
| -rw-r--r-- | src/detached/wire.zig | 2 | ||||
| -rw-r--r-- | src/draw.zig | 1 | ||||
| -rw-r--r-- | src/gui/gui.zig | 152 | ||||
| -rw-r--r-- | src/surface.zig | 2 |
5 files changed, 164 insertions, 5 deletions
diff --git a/src/config.zig b/src/config.zig index 5734cd0c..d4efed0e 100644 --- a/src/config.zig +++ b/src/config.zig @@ -121,6 +121,7 @@ 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(.CursorBlink, null); + if (builtins.capabilities.lift) table.set(.SelectionGlow, null); if (builtins.capabilities.lift) table.set(.Lift, null); table.set(.Motion, null); table.set(.InactiveDim, null); @@ -656,6 +657,9 @@ pub const Runtime = struct { /// solid while typing and for half a second after, eased at each edge, /// solid again after ten idle seconds. cursor_blink: bool = true, + /// 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, /// 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, @@ -874,7 +878,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 }; + 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 }; /// 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 }; @@ -931,6 +935,7 @@ pub const Runtime = struct { .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, .{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift }, + .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift }, .{ .word = "Lift", .action = .lift, .availability = .lift }, .{ .word = "Motion", .action = .motion }, .{ .word = "InactiveDim", .action = .inactive_dim }, @@ -1006,9 +1011,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) { + } else if (field == .cursor_blink or field == .selection_glow) { if (setting.availability != .lift) - @compileError("CursorBlink must use the lift capability (a pixel shell's)"); + @compileError("CursorBlink and SelectionGlow 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) @@ -1661,6 +1666,7 @@ pub const Runtime = struct { .message_animation => "MessageAnimation off\n", .ligatures => "Ligatures off\n", .cursor_blink => "CursorBlink off\n", + .selection_glow => "SelectionGlow 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 9f9c716a..3326277d 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 = 0xf852cb229ae38f51; +const chrome_layout: u64 = 0x7f982e44ef87de72; comptime { @setEvalBranchQuota(10_000); var hash = std.hash.Fnv1a_64.init(); diff --git a/src/draw.zig b/src/draw.zig index 5f14ae21..6c0cc077 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -472,6 +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), }; 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 b5da9ad6..f74d5a0a 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1870,6 +1870,8 @@ 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. + sel_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. @@ -4978,7 +4980,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(); + defer core.shell_continuous = glide.moving() or g.sel_glow.fading; glide.now_ns = core.now_ns; glide.motion = pardes.animation.Motion.of(core.settings.motion); if (!glide.spring.settled) { @@ -5801,6 +5803,9 @@ fn renderFrame( // G6: what the scroll just done moved the pane's rows by, measured on // this frame against the last (the glide follows it from there). 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 (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 @@ -8690,6 +8695,8 @@ 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); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { if (column.kind != .column) continue; @@ -8928,6 +8935,99 @@ fn dp(logical: f32) f32 { return @max(1, @round(logical * display_scale)); } +/// G9's state: the selection's blocks last drawn and when each appeared. +const SelGlow = struct { + const Block = struct { box: [4]u16, start_ns: u64 }; + blocks: [32]Block = undefined, + len: usize = 0, + now_ns: u64 = 0, + /// A block is still fading in: the core keeps frames coming. + fading: bool = false, +}; + +/// 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; + +/// 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; + 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)); + var blocks: [32][4]u16 = undefined; + var nblocks: usize = 0; + var bodies: [pardes.MAX_PANES][4]f32 = undefined; + var nbodies: usize = 0; + for (surface.regionList()) |body| { + if (body.kind != .body or body.rect.w == 0) continue; + if (nbodies < bodies.len) { + bodies[nbodies] = bodyPixels(body, layout); + nbodies += 1; + } + var open: ?[4]u16 = null; // x0, y0, x1, y1 of the block being grown + var row = body.rect.y; + while (row < body.rect.y + body.rect.h and row < surface.rows) : (row += 1) { + var x0: ?u16 = null; + var x1: u16 = 0; + var col = body.rect.x; + while (col < body.rect.x + body.rect.w and col < surface.cols) : (col += 1) { + const cell = &surface.cells[@as(usize, row) * surface.cols + col]; + const selected = !cell.default and switch (cell.style.bg) { + .rgb => |rgb| std.mem.eql(u8, &rgb, &sel), + else => false, + }; + if (!selected) continue; + if (x0 == null) x0 = col; + x1 = col + 1; + } + if (x0) |start| { + if (open) |*block| { + block.* = .{ @min(block[0], start), block[1], @max(block[2], x1), row + 1 }; + } else open = .{ start, row, x1, row + 1 }; + } else if (open) |block| { + if (nblocks < blocks.len) blocks[nblocks] = block; + nblocks += 1; + open = null; + } + } + if (open) |block| if (nblocks < blocks.len) { + blocks[nblocks] = block; + nblocks += 1; + }; + } + 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 }; + for (blocks[0..nblocks]) |block| { + const start = for (g.sel_glow.blocks[0..g.sel_glow.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; + 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)); + 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 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]); + } + g.sel_glow = next; +} + /// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of /// group `index`: each pane tag, and with the still panes the column and /// workspace tags, becomes a framed box inside its own band with a hard @@ -9364,6 +9464,56 @@ test "a pane on the move casts a shadow on the other panes' bodies alone, most a try std.testing.expectEqual(@as(f32, 0), strengths[2]); } +test "the selection glows round its block, on the body only, fading in over 100 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.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); + // 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; + } else return error.NoBody; + for (0..2) |dy| for (5..12) |x| { + const cell = &s.cells[(@as(usize, body.rect.y) + 3 + dy) * s.cols + body.rect.x + x]; + cell.default = false; + cell.style.bg = .{ .rgb = s.chrome.sel_bg }; + }; + s.cursor = null; + 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 = .{ .now_ns = std.time.ns_per_s }; + var strengths: [2]f32 = .{ 0, 0 }; + for ([_]u64{ 0, 150 * std.time.ns_per_ms }, 0..) |later, i| { + g.decor.clearRetainingCapacity(); + g.sel_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; + const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) }; + if (!std.mem.eql(u8, &rgb, &s.chrome.sel_bg)) continue; + strengths[i] = @max(strengths[i], item.fr); + // On the body alone. + const r = bodyPixels(body, fixedCellLayout(&g)); + try std.testing.expect(item.clip_x0 >= r[0] - 0.01 and item.clip_x1 <= r[2] + 0.01 and item.clip_y0 >= r[1] - 0.01 and item.clip_y1 <= r[3] + 0.01); + } + try std.testing.expectEqual(@as(usize, 1), g.sel_glow.len); + } + // Just appeared: nothing yet, and still fading; later, up to its cap. + try std.testing.expectEqual(@as(f32, 0), strengths[0]); + try std.testing.expect(strengths[1] > 0.05 and strengths[1] <= 0.22 + 1e-4); + try std.testing.expect(!g.sel_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 5b850a8b..7256ecc1 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -448,6 +448,8 @@ 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, }; /// The canonical interface: what a frame of pardes IS. The tty shell writes |
