From 4452a8f467765cbfa0dd0444a9bf416ac8bd0bf5 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 05:44:11 -0300 Subject: G4: bloom, vignette and grain in the post chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bundled Shadertoy passes beside Crt, each `Bloom|Vignette|Grain 0..3` (off by default, nothing drawn while off), in shaders/post/: - Bloom: what is brighter than the page (a channel's linear peak over 0.75/0.7/0.6 and over the page's own + 0.15, so a light page does not bloom) glows, 2/3.5/5% (§8.2's budget). The glow is a dual Kawase blur in RGBA16F textures at half size and down, 3/4/5 steps by level (shaders/bloom-down, bloom-up; Post.bloomChain), composited in linear light and dithered. - Vignette: round, from halfway to a corner, 10/18/28% at the far corners, in linear light, dithered. - Grain: a still, triangular-spread grain of 1.5/3/5 of 255 on page-coloured pixels only, a logical pixel big; still, so it asks for no frames. Focus rule: the shell passes every bundled pass the rectangles of the tags with their grips, the column and workspace tags, the notices and the cursor (pardesSpare, at most 32, neighbours on one row merged), and a bundled pass leaves them as they are; and a per-theme ceiling (pardesCap, Post.capsOf) keeps the page's text and the selection's text at min(their contrast, 4.5) under each pass at its strongest. User files get no ceiling and no spared rectangles. Fix found on the way: SDL GPU binds at most 4 KiB of a uniform block, so every Shadertoy colour after iPalette (iBackgroundColor, iForegroundColor, the cursor and selection colours, iPalette[>=4]) read zero. The prefix's block is cut in three (Globals, Palette, Colors) with ghostty's names and order, each pushed as a slice of the one struct; pardes's own block moves to binding 3. Tests: the caps hold over every native theme; each uniform slice fits 4 KiB; the prefix's members in ghostty's order across its blocks. Gates: tty/gui unit-test and core-test pass; snap fails only nested-optout; GUI goldens 01-08 byte-identical (09-18 wait on wkzlsqvy, as in somtrmsz); web builds. Feel review material: .scratch/render/{bloom,vignette,grain}/ (levels on forge, dusk, acme, crops, a levels mp4). --- src/config.zig | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) (limited to 'src/config.zig') diff --git a/src/config.zig b/src/config.zig index fb38e4dd..43d2cfda 100644 --- a/src/config.zig +++ b/src/config.zig @@ -26,6 +26,7 @@ const undecided: []const u8 = ""; // null means word/chord-only. Every enabled builtin must explicitly choose a path. pub const leader_path = paths: { + @setEvalBranchQuota(2000); var table = std.EnumArray(Builtin, ?[]const u8).initDefault(@as(?[]const u8, undecided), .{ .Help = &[_]u8{leader_help}, .Hover = "lk", @@ -139,6 +140,9 @@ pub const leader_path = paths: { } if (builtins.capabilities.scene_shaders) { table.set(.Crt, "tr"); + table.set(.Bloom, null); + table.set(.Vignette, null); + table.set(.Grain, null); table.set(.Shader, null); table.set(.ShaderAnimation, null); } @@ -869,8 +873,9 @@ pub const Runtime = struct { 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 }; - /// The bundled post pass, a Shadertoy file in shaders/post/. - pub const Scene = enum { crt }; + /// 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 }; pub const Action = union(enum) { toggle: Toggle, @@ -950,6 +955,9 @@ pub const Runtime = struct { .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, + .{ .word = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders }, + .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders }, + .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders }, .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, .{ .word = "Placement", .action = .{ .choice = .placement } }, @@ -1678,6 +1686,9 @@ pub const Runtime = struct { }, .scene => |effect| switch (effect) { .crt => "Crt 2\n", + .bloom => "Bloom 2\n", + .vignette => "Vignette 2\n", + .grain => "Grain 2\n", }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", -- cgit v1.3 From dd586f1d8937f67e95504da167216d5413f05262 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:11:01 -0300 Subject: G8: a notice leaves quicker than it arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice chip's motion was mostly there (a drop easing out on the Motion flavour, a dissolve easing in, Lift's shadow in tier 4 at its fade). Its dissolve took 300 ms against a 180 ms fall, the wrong way round for §8.1 (leaving quicker than arriving, 100 to 160 ms): MessageDissolve now defaults to 150. The field comments had the two easings swapped; the stack test pins a longer dissolve for the case it checks. docs/config.md and docs/effects.md say so. Tests: both timings in their bands, leaving quicker. Gates as G7 (no golden moved). Feel review: .scratch/render/notices/. --- docs/config.md | 2 +- docs/effects.md | 8 ++++++++ src/Messages.zig | 9 +++++++++ src/config.zig | 8 +++++--- 4 files changed, 23 insertions(+), 4 deletions(-) (limited to 'src/config.zig') diff --git a/docs/config.md b/docs/config.md index b3cff3a4..db15289b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -137,7 +137,7 @@ colour whole by half way (graphical frontends slide it out from under the tagline as it fades up; a terminal only fades it in), stays until the next key or click as it always has, then lingers for `MessageLinger` milliseconds (default 800) before it dissolves into the page. `MessageFall` (default 180) and `MessageDissolve` -(default 300) set how long the fall and the dissolve take, also in +(default 150: leaving is quicker than arriving) set how long the fall and the dissolve take, also in milliseconds; each timing is at most 60000, and `Config` reports all three. `MessageLinger 0` with `MessageAnimation off` restores the old behaviour, a message cleared by the very input that follows it. An updated line on a row already showing one swaps diff --git a/docs/effects.md b/docs/effects.md index 58c0098e..2fc68c81 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -41,6 +41,7 @@ why. The verdict is the user's. | G5 lapis | `Theme lapis` (its `decor`) | a native theme with ornament: tag plaques with hard shadows inside their bands (the focused one striped), dots, rail checker, title shadow | .scratch/render/lapis/ (whole window at 1x and 2x, crops, a slide mp4) | pending | | G6 smooth scroll | follows `Motion` (on through the default smooth; `Motion off` lands at once) | a mouse wheel's notch glides, a touchpad follows the finger | .scratch/render/scroll/ (glide-.mp4, quarter speed, strips) | pending | | G7 panel transitions | `PanelSlide`, `PanelZoom`, `PanelVertical`, `PanelDissolve` (off) with `Motion` | a moving pane casts a shadow; a retargeted move keeps its speed; the GUI's dissolve is a crossfade | .scratch/render/transitions/ (slide smooth, bouncy, retargeted; dissolve) | pending | +| 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 | ## Motion flavours @@ -176,3 +177,10 @@ its old one over it at 1 - progress; the blend is in sRGB, not linear light (a two-colour mix in linear light needs both in one shader; open point 3). The tty keeps its per-cell swap. GUI golden 18-mid-transition changes (the slide's shadow at frame 6). + +Notes on G8: most of it was there: the drop eases out on the Motion +flavour's spring (sxrrwplk), the dissolve eases in, and a notice floats in +tier 4 with Lift's shadow at its fade while Lift is on, on the bodies +alone. What G8 changed: leaving was slower than arriving (a 300 ms +dissolve against a 180 ms fall); §8.1 wants leaving the quicker, in 100 to +160 ms, so MessageDissolve now defaults to 150 (a test holds both bands). diff --git a/src/Messages.zig b/src/Messages.zig index 7d284e6b..36cd44da 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -768,6 +768,8 @@ test "messages stack, oldest on top, each on its own timing" { const pane = p.panes[p.active].?; try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off")); try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160")); + // A dissolve longer than a fall, so the second is caught mid-way. + try std.testing.expect(p.executeBuiltinLine(p.active, "MessageDissolve 300")); // `Verbose off` announced itself before it took effect. pane.msg_len = 0; pane.msg_life = .{}; @@ -968,3 +970,10 @@ test "an idle core wants no wake and a lingering message sleeps to its linger's p.advance(long_due); try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); } + +test "a notice leaves quicker than it arrives, each within its §8.1 band" { + const settings: config.Runtime = .{}; + try std.testing.expect(settings.message_dissolve_ms < settings.message_fall_ms); + try std.testing.expect(settings.message_fall_ms >= 150 and settings.message_fall_ms <= 220); + try std.testing.expect(settings.message_dissolve_ms >= 100 and settings.message_dissolve_ms <= 160); +} diff --git a/src/config.zig b/src/config.zig index 43d2cfda..5734cd0c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -658,10 +658,12 @@ pub const Runtime = struct { cursor_blink: bool = true, /// How long a message stays after the key or click that dismisses it. message_linger_ms: u16 = 800, - /// How long its ease-in fall into the row takes. + /// How long its fall into the row takes, easing out (§8.1: arriving, + /// 150 to 220 ms). message_fall_ms: u16 = 180, - /// How long its ease-out dissolve takes. - message_dissolve_ms: u16 = 300, + /// How long its dissolve takes, easing in: leaving is quicker than + /// arriving (§8.1: 100 to 160 ms). + message_dissolve_ms: u16 = 150, /// Where Dump writes and a relative Restore looks; empty is the default /// ($XDG_DATA_HOME/pardes, else ~/.local/share/pardes). dump_dir: Text(limits.host_path_cap) = .{}, -- cgit v1.3 From 99c088e277e7d79d7b4341d3f546c02e7254e107 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:17:07 -0300 Subject: G9: SelectionGlow, a soft halo round the body's selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. --- docs/config.md | 2 + docs/effects.md | 13 +++++ src/config.zig | 12 +++- src/detached/wire.zig | 2 +- src/draw.zig | 1 + src/gui/gui.zig | 152 +++++++++++++++++++++++++++++++++++++++++++++++++- src/surface.zig | 2 + 7 files changed, 179 insertions(+), 5 deletions(-) (limited to 'src/config.zig') diff --git a/docs/config.md b/docs/config.md index db15289b..29671e4e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -565,6 +565,8 @@ Lift auto a shadow on a light page; on a dark one the others recede Lift off 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 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 2fc68c81..61b969c2 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -42,6 +42,7 @@ why. The verdict is the user's. | G6 smooth scroll | follows `Motion` (on through the default smooth; `Motion off` lands at once) | a mouse wheel's notch glides, a touchpad follows the finger | .scratch/render/scroll/ (glide-.mp4, quarter speed, strips) | pending | | G7 panel transitions | `PanelSlide`, `PanelZoom`, `PanelVertical`, `PanelDissolve` (off) with `Motion` | a moving pane casts a shadow; a retargeted move keeps its speed; the GUI's dissolve is a crossfade | .scratch/render/transitions/ (slide smooth, bouncy, retargeted; dissolve) | pending | | 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 | ## Motion flavours @@ -184,3 +185,15 @@ tier 4 with Lift's shadow at its fade while Lift is on, on the bodies alone. What G8 changed: leaving was slower than arriving (a 300 ms dissolve against a 180 ms fall); §8.1 wants leaving the quicker, in 100 to 160 ms, so MessageDissolve now defaults to 150 (a test holds both bands). + +Notes on G9: the halo is Lift's soft kind in the selection's own colour, +cast by each block of selected rows in a pane body (the rows that touch), +with the block as its caster, so the selection itself is never touched; +a cursor beside a block joins the caster, so it is never glowed over +either. It stays on bodies (never a tag or a grip), is at most 22% (§8.2: +coloured glows at most 25%) and less where the text round it would drop +below min(its contrast, 4.5); it comes up over 100 ms with an ease-out +(micro-feedback), on the core's continuous path, and is not drawn while +panes move. The blocks are read from the cells painted in the theme's +selection colour, a pixel shell's inference (§1.5, smell 4) until +selections are regions. 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 -- cgit v1.3