summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 07:17:07 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit99c088e277e7d79d7b4341d3f546c02e7254e107 (patch)
treefcfa295e3d236ec6bd676b5ffcf57401e2dcf732 /src
parentdd586f1d8937f67e95504da167216d5413f05262 (diff)
downloadpardes-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.zig12
-rw-r--r--src/detached/wire.zig2
-rw-r--r--src/draw.zig1
-rw-r--r--src/gui/gui.zig152
-rw-r--r--src/surface.zig2
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