diff options
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/Post.zig | 234 | ||||
| -rw-r--r-- | src/gui/gui.zig | 653 |
2 files changed, 623 insertions, 264 deletions
diff --git a/src/gui/Post.zig b/src/gui/Post.zig index 61ebc2cd..06310b7f 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -1,10 +1,10 @@ //! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the //! finished frame, each reading the one before it (the frame first) and the //! last one writing the window. A pass is the bundled Crt (shaders/post/, -//! compiled with the build) or a user's file (Shader <path>), -//! compiled when it joins the chain by the build's own glslc invocation on a -//! thread of its own, never on the frame path. A file whose compile fails -//! keeps its last good pipeline, and glslc missing is said once. +//! compiled with the build) or a user's file (Shader <path>), whose SPIR-V +//! comes from shader_build.zig: compiled by the process that holds the core, +//! this one or the detached session this GUI is attached to. A file whose +//! compile fails keeps its last good pipeline. //! //! The uniforms are ghostty's (Uniforms), so a shader written for ghostty runs //! here. Their clock is the shell's and moves only when the chain draws: @@ -14,7 +14,6 @@ const std = @import("std"); const libc = std.c; const ghostty_vt = @import("ghostty-vt"); const pardes = @import("../pardes.zig"); -const filesystem = @import("../fs.zig"); const gui = @import("gui.zig"); const c = gui.c; @@ -25,6 +24,7 @@ const Pipeline = c.SDL_GPUGraphicsPipeline; pub const max = Chain.max; const prefix = @embedFile("post-prefix.glsl"); +const Built = @import("../shader_build.zig").Pass; const vert_spv = @embedFile("post.vert.spv"); const bundled_spv = std.EnumArray(Scene, []const u8).init(.{ .crt = @embedFile("post-crt.frag.spv"), @@ -92,28 +92,15 @@ pub const Pass = struct { /// A user's file, owned; empty for a bundled pass. path: []u8 = &.{}, pipeline: ?*Pipeline = null, -}; - -/// One compile of a chain's new files, on its own thread. -const Job = struct { - const Result = union(enum) { none, spirv: []u8, failed: []u8, missing }; - io: std.Io, - gpa: std.mem.Allocator, - paths: [max][]u8 = @splat(&.{}), - results: [max]Result = @splat(.none), - len: usize = 0, - done: std.atomic.Value(bool) = .init(false), - thread: std.Thread = undefined, + /// The SPIR-V revision `pipeline` was made from (shader_build.Pass). + revision: u32 = 0, }; passes: [max]Pass = @splat(.{}), len: usize = 0, -/// The chain generation `passes` follow; null before the first. -generation: ?u32 = null, +/// The chain key `passes` follow (sync); null before the first. +key: ?u32 = null, bundled: std.EnumArray(Scene, ?*Pipeline) = .initFill(null), -job: ?*Job = null, -/// Said once: without glslc, files never compile. -missing_said: bool = false, ping: [2]?*c.SDL_GPUTexture = .{ null, null }, ping_w: u32 = 0, ping_h: u32 = 0, @@ -136,20 +123,17 @@ bloom_up: ?*Pipeline = null, started_ns: ?u64 = null, last_ns: u64 = 0, -/// Brings the passes to the chain the core holds, and takes in a finished -/// compile. Called each loop step, off the frame path. -pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes) void { - if (post.job) |job| { - if (!job.done.load(.acquire)) return; - job.thread.join(); - post.job = null; - post.collect(gpa, device, format, core, job); - } - const chain = &core.settings.post; - if (post.generation == chain.generation) return; - post.generation = chain.generation; +/// Brings the passes to the chain a shell was given (shader_build.zig: a +/// local core's, or an attached session's over the wire), whose `key` moves +/// whenever the chain or a file's SPIR-V does. A file whose SPIR-V has not +/// moved keeps its pipeline; one the GPU refuses keeps its last, and is said +/// to `core`, or logged where there is none. Each loop step, off the frame +/// path. +pub fn sync(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, chain: []const Built, key: u32, core: ?*pardes.Pardes) void { + if (post.key == key) return; + post.key = key; var next: [max]Pass = @splat(.{}); - for (chain.list(), 0..) |entry, i| { + for (chain, 0..) |entry, i| { if (entry.scene) |scene| { if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene), if (scene == .bloom) 2 else 1, 4) catch null); if (scene == .bloom and post.bloom_down == null) { @@ -161,11 +145,22 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD } // A file already in the chain keeps its pipeline and its path. for (post.passes[0..post.len]) |*pass| { - if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path.get())) continue; + if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path)) continue; next[i] = pass.*; pass.* = .{}; break; - } else next[i] = .{ .path = gpa.dupe(u8, entry.path.get()) catch &.{} }; + } else next[i] = .{ .path = gpa.dupe(u8, entry.path) catch &.{} }; + const pass = &next[i]; + if (pass.revision == entry.revision or entry.spirv.len == 0) continue; + pass.revision = entry.revision; + const made = makePipeline(device, format, entry.spirv, 1, 4) catch { + var buf: [512]u8 = undefined; + const text = std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{entry.path}) catch "Shader: the GPU refused it"; + if (core) |p| p.setMessage(p.active, text) else std.log.err("{s}", .{text}); + continue; + }; + if (pass.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old); + pass.pipeline = made; } for (post.passes[0..post.len]) |pass| { if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); @@ -179,127 +174,6 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD post.last_ns = 0; post.uniforms.frame = 0; } - // The new files compile together, on a thread of their own. - const job = gpa.create(Job) catch return; - job.* = .{ .io = io, .gpa = gpa }; - for (post.passes[0..post.len]) |pass| { - if (pass.scene != null or pass.pipeline != null or pass.path.len == 0) continue; - job.paths[job.len] = gpa.dupe(u8, pass.path) catch continue; - job.len += 1; - } - if (job.len == 0) return gpa.destroy(job); - job.thread = std.Thread.spawn(.{}, compile, .{job}) catch { - for (job.paths[0..job.len]) |path| gpa.free(path); - return gpa.destroy(job); - }; - post.job = job; -} - -fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes, job: *Job) void { - defer gpa.destroy(job); - for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { - defer gpa.free(path); - const pass = for (post.passes[0..post.len]) |*pass| { - if (pass.scene == null and std.mem.eql(u8, pass.path, path)) break pass; - } else null; - var buf: [512]u8 = undefined; - switch (result) { - .none => {}, - .missing => if (!post.missing_said) { - post.missing_said = true; - core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); - }, - .failed => |text| { - defer gpa.free(text); - // glslc's first line says where: in the file's own lines. - const first = std.mem.sliceTo(text, '\n'); - core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: {s}", .{ path, first[0..@min(first.len, 400)] }) catch "Shader: compile failed"); - }, - .spirv => |spirv| { - defer gpa.free(spirv); - const target = pass orelse continue; - const made = makePipeline(device, format, spirv, 1, 4) catch { - core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it"); - continue; - }; - if (target.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old); - target.pipeline = made; - }, - } - } -} - -/// The compile thread: each file behind the prefix, through glslc with the -/// build's own flags (build.zig compileGlsl), source on stdin, SPIR-V on -/// stdout. -fn compile(job: *Job) void { - defer job.done.store(true, .release); - defer { - var event = std.mem.zeroes(c.SDL_Event); - event.type = c.SDL_EVENT_USER; - _ = c.SDL_PushEvent(&event); - } - for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path); -} - -fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result { - var home_buf: [4096]u8 = undefined; - const expanded = if (std.mem.startsWith(u8, path, "~/")) if (libc.getenv("HOME")) |home| - std.fmt.bufPrint(&home_buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path - else - path else path; - const body = filesystem.readFile(gpa, expanded) catch |err| - return .{ .failed = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none }; - defer gpa.free(body); - // The prefix and the file go to glslc on its stdin: no file of ours is - // written anywhere. std's spawn does every allocation before fork and - // searches PATH on the stack after it (Io/Threaded.zig spawnPosix, - // posixExecv), so a child forked from this thread cannot wedge on a - // lock another thread held (the forkShell hazard). - var child = std.process.spawn(io, .{ - .argv = &.{ "glslc", "-fshader-stage=fragment", "-o", "-", "-" }, - .stdin = .pipe, - .stdout = .pipe, - .stderr = .pipe, - }) catch |err| return switch (err) { - error.FileNotFound => .missing, - else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none }, - }; - defer child.kill(io); - // glslc reads all of its input before it writes a byte, so writing it - // first cannot deadlock against a full output pipe. - { - var stdin = child.stdin.?; - child.stdin = null; - defer stdin.close(io); - var buf: [4096]u8 = undefined; - var writer = stdin.writer(io, &buf); - writer.interface.writeAll(prefix) catch {}; - writer.interface.writeAll(body) catch {}; - writer.interface.flush() catch {}; - } - var streams: std.Io.File.MultiReader.Buffer(2) = undefined; - var reader: std.Io.File.MultiReader = undefined; - reader.init(gpa, io, streams.toStreams(), &.{ child.stdout.?, child.stderr.? }); - defer reader.deinit(); - while (reader.fill(64, .none)) |_| { - if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10) - return .{ .failed = gpa.dupe(u8, "glslc said too much") catch return .none }; - } else |err| switch (err) { - error.EndOfStream => {}, - else => return .{ .failed = gpa.dupe(u8, "reading glslc failed") catch return .none }, - } - const term = child.wait(io) catch return .{ .failed = gpa.dupe(u8, "glslc did not finish") catch return .none }; - const clean = switch (term) { - .exited => |code| code == 0, - else => false, - }; - if (clean) return .{ .spirv = reader.toOwnedSlice(0) catch return .none }; - const said = reader.toOwnedSlice(1) catch return .none; - // glslc calls its input <stdin>; the file is what a person knows. - defer gpa.free(said); - const named = std.mem.replaceOwned(u8, gpa, said, "<stdin>", path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none }; - return .{ .failed = named }; } fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, uniform_buffers: u32) !*Pipeline { @@ -686,17 +560,6 @@ fn bloomStep(cmd: *c.SDL_GPUCommandBuffer, pipeline: *Pipeline, sampler: *c.SDL_ pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void { for (post.bloom_tex) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t); for ([_]?*Pipeline{ post.bloom_down, post.bloom_up }) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p); - if (post.job) |job| { - job.thread.join(); - for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { - gpa.free(path); - switch (result) { - .spirv, .failed => |bytes| gpa.free(bytes), - .none, .missing => {}, - } - } - gpa.destroy(job); - } for (post.passes[0..post.len]) |pass| { if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); gpa.free(pass.path); @@ -801,36 +664,3 @@ test "a moved cursor becomes the previous one, stamped with the time of the move try std.testing.expectEqual([4]f32{ 1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1 }, post.uniforms.selection_foreground_color); } -test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" { - const gpa = std.testing.allocator; - const io = std.testing.io; - // ghostty's own test shaders, from its package in zig-pkg. - var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest; - defer pkg.close(io); - var it = pkg.iterate(); - const ghostty = while (it.next(io) catch null) |entry| { - if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; - } else return error.SkipZigTest; - var buf: [512]u8 = undefined; - for ([_][]const u8{ "crt", "focus", "invalid" }) |name| { - const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); - const result = compileOne(gpa, io, path); - switch (result) { - .missing => return error.SkipZigTest, // no glslc here - .spirv => |spirv| { - defer gpa.free(spirv); - try std.testing.expect(!std.mem.eql(u8, name, "invalid")); - // SPIR-V's magic number, little-endian. - try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.readInt(u32, spirv[0..4], .little)); - }, - .failed => |text| { - defer gpa.free(text); - try std.testing.expectEqualStrings("invalid", name); - // Named by the file, not by glslc's <stdin>. - try std.testing.expect(std.mem.indexOf(u8, text, path) != null); - try std.testing.expect(std.mem.indexOf(u8, text, "<stdin>") == null); - }, - .none => return error.TestUnexpectedResult, - } - } -} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index d36cf187..cb3bdd97 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -11,6 +11,7 @@ const config = @import("../config.zig"); const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); +const shader_build = @import("../shader_build.zig"); const deck = @import("deck.zig"); const Post = @import("Post.zig"); const pet = @import("pet.zig"); @@ -1870,8 +1871,12 @@ 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 = .{}, + /// G14: the cells last drawn and when each last changed. + afterglow: Afterglow = .{}, /// 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. @@ -2581,6 +2586,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll defer g.glide.deinit(gpa); + defer g.afterglow.deinit(gpa); resetGlyphAtlas(&g); if (attach) |requested| return attachRequested(gpa, &g, requested); @@ -2688,6 +2694,7 @@ fn localSession( .test_hz = if (std.c.getenv("PARDES_TEST_HZ")) |hz| @max(1, std.fmt.parseInt(u64, std.mem.span(hz), 10) catch 60) else 60, }; defer shell.shutdownPtys(); + defer shell.shaders.deinit(gpa, inotify_fd, &watches); // A virtual clock never blinks: goldens and feel series hold still. g.no_blink = shell.test_clock != null; const host = shell.host(); @@ -2857,6 +2864,7 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client in.post(.{ .paste = text }); }, .open_link => |url| look.openLink(url), + .post => dirty = true, } } try link; @@ -2872,7 +2880,15 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client geom = now; try attachedResize(client, g, now.cols, now.rows); } - if (!dirty) continue; + // The session's chain, its files compiled there. + g.post.sync(gpa, g.device, g.swapchain_format, client.post.list(), client.post_key, null); + if (!dirty) { + // As a local window does: a chain that moves on its own redraws + // alone, once a refresh. + if (g.post.animating(client.post.animation) and g.scene_tex != null and + c.SDL_GetTicksNS() -| g.post.last_ns + std.time.ns_per_ms >= refreshNs(g)) redrawPost(g, gpa); + continue; + } dirty = false; paintAttached(g, gpa, client); } @@ -3988,6 +4004,8 @@ const Shell = struct { pipe_tasks: *PipeTasks, inotify_fd: c_int, watches: *file_watch.Table, + /// The post chain's files as SPIR-V. + shaders: shader_build = .{}, threads_ok: bool = false, test_mode: bool = false, feed: StdinFeed = .{}, @@ -4204,11 +4222,19 @@ const Shell = struct { .files_changed => check_files = true, .fs_ready => {}, }; + if (check_files) s.shaders.recheck(); if (check_files and file_watch.reloadChanged(s.core, s.io, s.gpa, s.watches)) s.queue.push(.files_changed); } }; +/// A post chain compile is done (shader_build.zig): the loop's SDL wait ends. +fn wakeSdl(_: ?*anyopaque) void { + var event = std.mem.zeroes(c.SDL_Event); + event.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&event); +} + fn wakeFs(ctx: ?*anyopaque) void { const q: *Queue = @ptrCast(@alignCast(ctx.?)); q.push(.fs_ready); @@ -4271,7 +4297,13 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { if (core.continuous()) { const display = virtual.* + std.time.ns_per_s / s.test_hz; virtual.* = if (core.nextWake()) |due| @max(virtual.*, @min(due, display)) else display; - } else if (!got and timeout_ms != 0) virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); + } else if (timeout_ms != 0 and (!got or !core.needs_frame)) { + // A wait that ran out, or one a stray event cut short while + // nothing owes a frame: time moves to the core's wake all + // the same, so a steady trickle of events (a pty's, the + // test feed's) cannot hold a virtual clock still forever. + virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); + } } if (got) { // A run of queued motions is one move to its last position (the @@ -4345,7 +4377,12 @@ fn pollFrame(ctx: ?*anyopaque) void { const geom = windowCells(g); if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g); stepScroll(g, core, s.gpa); - g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core); + _ = s.shaders.sync(s.gpa, s.io, core, s.inotify_fd, s.watches, .{ .call = wakeSdl }); + { + var passes: [Post.max]shader_build.Pass = undefined; + g.post.sync(s.gpa, g.device, g.swapchain_format, s.shaders.view(&core.settings.post, &passes), s.shaders.revision, core); + } + if (s.test_mode) writeTestStatus(g, core, s.presented); // Between the core's frames, a chain that moves on its own redraws // alone, once a refresh of the display the window is on (a millisecond // early is on time: the wait that paces it is in whole milliseconds). @@ -4988,7 +5025,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 or g.afterglow.glowing; glide.now_ns = core.now_ns; glide.motion = pardes.animation.Motion.of(core.settings.motion); if (!glide.spring.settled) { @@ -5793,8 +5830,9 @@ fn renderFrame( const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); - // The post chain runs over a local frame, from the scene texture. - var scene_on = core != null and g.post.ready(); + // The post chain runs from the scene texture, over a local frame or an + // attached session's alike. + var scene_on = g.post.ready(); if (scene_on) ensureSceneTexture(g, sw, sh) catch { g.scene_target_failed = true; scene_on = false; @@ -5813,9 +5851,23 @@ 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.fx & 1 == 0 or surface.panelTracks().len != 0) g.sel_glow = .{ .now_ns = g.sel_glow.now_ns }; + if (surface.chrome.fx & 16 == 0) g.afterglow.glowing = false; + g.hover_glow.now_ns = g.sel_glow.now_ns; + if (surface.chrome.fx & 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); + // G13: a body's dots follow its text at a quarter of its speed: its + // first line, and a glide's picture offset, in rows. + if (chrome.fx & 8 != 0 and chrome.decor_dots != null) for (surface.regionList()) |region| { + if (region.kind != .body or cell_decor.nbodies == cell_decor.bodies.len) continue; + var rows: f32 = @floatFromInt(region.line); + if (g.glide.moving() and g.glide.pane == region.owner) rows += g.glide.offset; + const period = cell_decor.uniforms[4]; + cell_decor.bodies[cell_decor.nbodies] = bodyPixels(region, layout); + cell_decor.shifts[cell_decor.nbodies] = @mod(-rows * layout.h * 0.25, period); + cell_decor.nbodies += 1; + }; // Plaques need an opaque window: with WindowOpacity under 100 the cells // are drawn as they are, and the plaques not at all. cell_decor.plaques_off = backgroundOpacity(g.applied_window_opacity) < 1; @@ -5830,9 +5882,10 @@ fn renderFrame( } } var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff()); - // Cursors stand still while panes move, as they always have; while the - // focused one glides or blinks, it is the overlay's quad instead. One - // or the other every frame (none only at a blink's off half). + // While panes move the cells' cursors are not drawn; the focused one is + // the overlay's quad, riding with its pane (draw.glideCursor), as it is + // while it glides or blinks. One or the other every frame (none only at + // a blink's off half). const blink_alpha: f32 = if (blinkNow(g, chrome)) |b| b.alpha else 1; const quad_mode = chrome.cursor_glide & 2 != 0 and (chrome.cursor_glide & 1 != 0 or blink_alpha < 1); g.cursor_quad_alpha = if (quad_mode) chrome.cursor_alpha * blink_alpha else 0; @@ -6153,19 +6206,17 @@ fn renderFrame( c.SDL_EndGPURenderPass(rp); if (scene_on) { - if (core) |p| { - var boxes: [max_cursors]CursorBox = undefined; - const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0; - var spare: [Post.max_spare][4]f32 = undefined; - g.post.describe(.{ - .cursor = if (cursors > 0) boxes[0] else null, - .spare = spare[0..postSpare(surface, layout, &spare)], - .background = chrome.page orelse bg_default, - .foreground = chrome.fg orelse fg_default, - .selection_foreground = p.theme().sel_fg, - .selection_background = p.theme().sel_bg, - }); - } + var boxes: [max_cursors]CursorBox = undefined; + const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0; + var spare: [Post.max_spare][4]f32 = undefined; + g.post.describe(.{ + .cursor = if (cursors > 0) boxes[0] else null, + .spare = spare[0..postSpare(surface, layout, &spare)], + .background = chrome.page orelse bg_default, + .foreground = chrome.fg orelse fg_default, + .selection_foreground = if (core) |p| p.theme().sel_fg else chrome.sel_fg, + .selection_background = if (core) |p| p.theme().sel_bg else chrome.sel_bg, + }); const chain_zone = tracy.zone(@src(), "post chain"); defer chain_zone.end(); g.post.scale = display_scale; @@ -6656,9 +6707,12 @@ fn emitSurfaceCell( // this underlay at tagline height leaves page-colored gutters between // adjacent workspace, column and pane tags at TaglineSize < 100. emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page); - // Over a theme's plaque (not a grip's button) the underlay lets it - // show (tagPlaques). - if (cell_decor.nplaques != 0 and !here.grip and cell_decor.inPlaque((@as(f32, @floatFromInt(col)) + 0.5) * body_layout.w + body_layout.x_off, @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5)) + // Over a theme's plaque the underlay lets it show (tagPlaques), + // in a grip's cell too where the plaque reaches into it past the + // button (which is drawn over the cells). + const cell_x = @as(f32, @floatFromInt(col)) * body_layout.w + body_layout.x_off; + const cell_y = @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5; + if (cell_decor.nplaques != 0 and cell_decor.inPlaque(if (here.grip) cell_x + body_layout.w - 0.5 else cell_x + 0.5, cell_y)) instances[next.*].effect |= clear_bg_bit; next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. @@ -6993,7 +7047,13 @@ fn emitInstance( (cell_decor.inPlaque(px0 + 0.5, py0 + 0.5) or cell_decor.inPlaque(px0 + layout.w - 0.5, py0 + 0.5))) instances[idx].effect |= clear_bg_bit; if (cell_decor.dots) |dotted| { - if (!resolved.clear_bg and std.mem.eql(u8, &resolved.bg, &dotted)) instances[idx].effect |= dotted_bit; + if (!resolved.clear_bg and std.mem.eql(u8, &resolved.bg, &dotted)) { + instances[idx].effect |= dotted_bit; + // G13: the dots under a body move with its text, a quarter + // as far; the serial slot carries it (the shader reads it + // as the dots' offset alone). + instances[idx].serial = @bitCast(cell_decor.parallaxAt(px0 + 0.5, py0 + 0.5)); + } } if (cell_decor.title) |inks| { if (resolved.role == .tagline and (std.mem.eql(u8, &resolved.fg, &inks[0]) or std.mem.eql(u8, &resolved.fg, &inks[1]))) instances[idx].effect |= title_bit; @@ -7082,16 +7142,20 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu for (under) |item| { const r: [4]f32 = .{ (item.x0 + 1) / 2 * 1280, (1 - item.y0) / 2 * 768, (item.x1 + 1) / 2 * 1280, (1 - item.y1) / 2 * 768 }; // Inside one tag's band, whole: never on a body, a grip or the - // next pane. + // next pane. A box stacked under another tag's lays its top frame + // over their shared edge, its own width up into the band above. const home = for (s.regionList()) |region| { if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue; const band = plaqueBox(&g, &s.chrome, s.regionList(), region, layout, 1280, 768) orelse continue; - if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region; + if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - band.border - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region; } else return error.PlaqueOutsideItsBand; + // Clear of every grip's button: a plaque starts flush against it, + // in the gap between it and the text. for (s.regionList()) |grip| { if (grip.kind != .grip and grip.kind != .column_grip) continue; - const gx0 = @as(f32, @floatFromInt(grip.rect.x)) * 16; - const gx1 = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * 16; + const column = scrollColumn(&g, &s.chrome, grip.rect.x, layout); + const gx0 = column[0]; + const gx1 = column[1]; const gy0 = @as(f32, @floatFromInt(grip.rect.y)) * 32; const gy1 = @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * 32; try std.testing.expect(r[2] <= gx0 + 0.01 or r[0] >= gx1 - 0.01 or r[3] <= gy0 + 0.01 or r[1] >= gy1 - 0.01); @@ -7105,6 +7169,31 @@ test "lapis plaques stay in their tag's band, clear of every grip; only the focu } try std.testing.expectEqual(@as(usize, 1), stripes); try std.testing.expect(shadows >= 2); + // Stacked boxes share an edge and line up: a column's box spans its + // first pane's across, runs down to the edge that pane's frame lies + // over, and no shadow reaches the next column. + var stacks: usize = 0; + for (s.regionList()) |column| { + if (column.kind != .column_tag) continue; + const upper = plaqueBox(&g, &s.chrome, s.regionList(), column, layout, 1280, 768).?; + for (s.regionList()) |tag| { + if (tag.kind != .tag or tag.rect.y != column.rect.y + column.rect.h or tag.rect.x + tag.rect.w != column.rect.x + column.rect.w) continue; + const lower = plaqueBox(&g, &s.chrome, s.regionList(), tag, layout, 1280, 768).?; + try std.testing.expectEqual(upper.box[0], lower.box[0]); + // Flush against the grips' buttons, which line up too. + const grip = for (s.regionList()) |part| { + if (part.kind == .grip and part.owner == tag.owner) break part; + } else unreachable; + try std.testing.expectEqual(scrollColumn(&g, &s.chrome, grip.rect.x, layout)[1], lower.box[0]); + try std.testing.expectEqual(upper.box[2], lower.box[2]); + try std.testing.expectEqual(upper.band[3], upper.box[3]); + try std.testing.expectEqual(lower.band[1] - lower.border, lower.box[1]); + try std.testing.expectEqual(@as(f32, 0), upper.shadow[1]); + if (lower.band[2] < 1280) try std.testing.expect(lower.box[2] + lower.shadow[0] < lower.band[2]); + stacks += 1; + } + } + try std.testing.expectEqual(@as(usize, 2), stacks); // The scroll track is chequered. var checkers: usize = 0; for (g.decor.items[groups.items[0].decor_start + groups.items[0].under_count ..]) |item| checkers += @intFromBool(item.effect & checker_bit != 0); @@ -8231,6 +8320,32 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t g.captured +%= 1; } +/// A test window's state for a harness (test/gui_golden.py), in the +/// capture directory's `status`: "<loop step> <frames captured> <idle>". +/// Idle is 1 once the frame last captured is the one that stays: nothing +/// owes a frame, nothing moves, nothing waits on a clock. A harness that +/// sees a step past the one it acted on, idle, has its frame, however slow +/// the machine is. +var test_status_step: u64 = 0; +fn writeTestStatus(g: *const Gui, core: *const pardes.Pardes, presented: bool) void { + test_status_step +%= 1; + if (g.capture_dir.len == 0) return; + const idle = presented and !core.needs_frame and !core.continuous() and core.nextWake() == null and + !g.glide.moving() and g.scroll_lag == 0 and g.scroll_delta == 0 and g.scroll_notch == 0; + var line_buf: [64]u8 = undefined; + // What keeps it busy, after: owes a frame, moves, waits, not shown. + const line = std.fmt.bufPrint(&line_buf, "{d} {d} {d} {d}{d}{d}{d}\n", .{ test_status_step, g.captured, @intFromBool(idle), @intFromBool(core.needs_frame), @intFromBool(core.continuous()), @intFromBool(core.nextWake() != null), @intFromBool(!presented) }) catch return; + var tmp_buf: [4096]u8 = undefined; + var final_buf: [4096]u8 = undefined; + const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/status.tmp", .{g.capture_dir}, 0) catch return; + const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/status", .{g.capture_dir}, 0) catch return; + const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); + if (fd < 0) return; + _ = libc.write(fd, line.ptr, line.len); + _ = libc.close(fd); + _ = libc.rename(tmp_path, final_path); +} + fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void { // A latency trace times frames, and an 8 MB file write per frame would // stall the loop it is timing. @@ -8704,7 +8819,13 @@ 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.fx & 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.fx & 2 != 0) try glowBlocks(g, gpa, surface, .hover, layout, win_w, win_h); + // G14: what changed glows, while nothing moves. + if (index == 0 and groups.tracks == 0 and chrome.fx & 16 != 0) try afterglowDecor(g, gpa, surface, layout, win_w, win_h); + // G12: the bodies' edges shaded, a pane's in its own group. + if (chrome.fx & 4 != 0) try occlusion(g, gpa, placed, groups, index, surface, ceilings, layout, win_w, win_h, track); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { if (column.kind != .column) continue; @@ -8913,6 +9034,17 @@ const CellDecor = struct { grounds: [2][3]u8 = .{ .{ 0, 0, 0 }, .{ 0, 0, 0 } }, plaques_off: bool = false, + /// G13: each body's rectangle and how far its dots have moved. + bodies: [pardes.MAX_PANES][4]f32 = undefined, + shifts: [pardes.MAX_PANES]f32 = undefined, + nbodies: usize = 0, + + fn parallaxAt(decor: *const CellDecor, x: f32, y: f32) f32 { + for (decor.bodies[0..decor.nbodies], decor.shifts[0..decor.nbodies]) |band, shift| + if (x >= band[0] and x < band[2] and y >= band[1] and y < band[3]) return shift; + return 0; + } + fn inPlaque(decor: *const CellDecor, x: f32, y: f32) bool { for (decor.plaques[0..decor.nplaques]) |band| if (x >= band[0] and x < band[2] and y >= band[1] and y < band[3]) return true; @@ -8956,19 +9088,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 +9156,189 @@ 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]); + }, + } + } + tracker.* = next; +} + +/// G12: ambient occlusion, a pane body darkening faintly toward its edges +/// (§9.1: an inner shadow of 1 to 2%): four soft strips cast from just +/// outside the body inward, clipped to it and round a cursor, black in +/// linear light, under the same contrast ceiling as a lift. +fn occlusion(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, surface: *const pardes.Surface, ceilings: LiftCeilings, layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + const strength = @min(0.02, ceilings.shadow); + if (strength <= 0.002) return; + const reach = 0.5 * layout.h; + for (regions) |body| { + if (body.kind != .body or body.rect.w == 0 or body.rect.h == 0 or groupOf(groups, body.serial) != index) continue; + const b = bodyPixels(body, layout); + // The body, less a cursor's cell in it: left, right, above, below. + var parts: [4][4]f32 = undefined; + var nparts: usize = 0; + const cursor: ?[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; + if (cursor) |cb| if (!(cb[0] >= b[2] or cb[2] <= b[0] or cb[1] >= b[3] or cb[3] <= b[1])) { + parts = .{ .{ b[0], b[1], cb[0], b[3] }, .{ cb[2], b[1], b[2], b[3] }, .{ cb[0], b[1], cb[2], cb[1] }, .{ cb[0], cb[3], cb[2], b[3] } }; + nparts = 4; + }; + if (nparts == 0) { + parts[0] = b; + nparts = 1; + } + const t = reach / 3; + const strips = [_][4]f32{ + .{ b[0], b[1] - t, b[2], b[1] }, + .{ b[0], b[3], b[2], b[3] + t }, + .{ b[0] - t, b[1], b[0], b[3] }, + .{ b[2], b[1], b[2] + t, b[3] }, + }; + for (strips) |strip| { + const first = g.decor.items.len; + try addSoft(g, gpa, strip, reach / 3, 0, 0, strength, .{ 0, 0, 0 }, track, win_w, win_h); + try clipToBodies(g, gpa, first, parts[0..nparts]); + } } - g.sel_glow = next; +} + +/// G14's state: the grid last drawn, when each cell last changed, and each +/// body's line then (a scroll changes every cell and is not a change). +const Afterglow = struct { + cells: []pardes.Cell = &.{}, + changed: []u64 = &.{}, + cols: u16 = 0, + rows: u16 = 0, + lines: [pardes.MAX_PANES]i32 = @splat(std.math.minInt(i32)), + /// A cell still glows: the core keeps frames coming. + glowing: bool = false, + + fn deinit(glow: *Afterglow, gpa: std.mem.Allocator) void { + gpa.free(glow.cells); + gpa.free(glow.changed); + } +}; + +const afterglow_ns: u64 = 250 * std.time.ns_per_ms; + +/// G14: a cell of a pane body whose text changed since the last frame +/// glows in the theme's accent at 18% and fades out over 250 ms with an +/// ease-out, as a run of cells a row. Never on a tag or a grip (bodies +/// only), the cursor or a selection; a body whose view moved (a scroll) +/// starts over rather than lighting up whole. +fn afterglowDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { + const glow = &g.afterglow; + const now = g.sel_glow.now_ns; + const n = @as(usize, surface.cols) * surface.rows; + const fresh = glow.cols != surface.cols or glow.rows != surface.rows or glow.cells.len != n; + if (fresh) { + glow.cells = try gpa.realloc(glow.cells, n); + glow.changed = try gpa.realloc(glow.changed, n); + @memcpy(glow.cells, surface.cells); + @memset(glow.changed, 0); + glow.cols = surface.cols; + glow.rows = surface.rows; + } + const chrome = &surface.chrome; + const page = chrome.page orelse bg_default; + const fg = chrome.fg orelse fg_default; + const cap = @min(0.18, lightenCeiling(fg, page, chrome.accent)); + var glowing = false; + for (surface.regionList()) |body| { + if (body.kind != .body or body.owner >= glow.lines.len) continue; + const scrolled = glow.lines[body.owner] != body.line; + glow.lines[body.owner] = body.line; + var row = body.rect.y; + while (row < body.rect.y + body.rect.h and row < surface.rows) : (row += 1) { + var run_start: ?u16 = null; + var run_age: u64 = 0; + var col = body.rect.x; + while (col <= body.rect.x + body.rect.w and col <= surface.cols) : (col += 1) { + var age: ?u64 = null; + if (col < body.rect.x + body.rect.w and col < surface.cols) { + const at = @as(usize, row) * surface.cols + col; + const cell = surface.cells[at]; + const differs = !std.meta.eql(cell.style, glow.cells[at].style) or !std.mem.eql(u8, cell.grapheme(), glow.cells[at].grapheme()) or cell.default != glow.cells[at].default; + if (differs and !fresh) glow.changed[at] = if (scrolled) 0 else now; + const selected = !cell.default and switch (cell.style.bg) { + .rgb => |rgb| std.mem.eql(u8, &rgb, &chrome.sel_bg), + else => false, + }; + const cursor_here = if (surface.cursor) |cu| cu.x == col and cu.y == row else false; + const since = now -| glow.changed[at]; + if (glow.changed[at] != 0 and since < afterglow_ns and !selected and !cursor_here) age = since; + } + // A run is cells that changed together. + if (run_start) |start| if (age == null or age.? != run_age) { + const t = @as(f32, @floatFromInt(run_age)) / @as(f32, @floatFromInt(afterglow_ns)); + const fade = (1 - t) * (1 - t) * (1 - t); + try addDecor(g, gpa, @as(f32, @floatFromInt(start)) * layout.w, @as(f32, @floatFromInt(row)) * layout.h, @as(f32, @floatFromInt(col)) * layout.w, @as(f32, @floatFromInt(row + 1)) * layout.h, chrome.accent, cap * fade, null, win_w, win_h); + glowing = true; + run_start = null; + }; + if (age) |a| if (run_start == null) { + run_start = col; + run_age = a; + }; + } + } + } + @memcpy(glow.cells, surface.cells); + glow.glowing = glowing; } /// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of @@ -9048,8 +9353,11 @@ fn tagPlaques(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, g const border_rgb = chrome.decor_box_border orelse return; if (cell_decor.plaques_off) return; const page = chrome.page orelse bg_default; - for (regions) |tag| { + // The workspace and column plaques first: a pane's plaque right under + // them shares their bottom edge (plaqueBox) and draws its frame last. + for ([_]bool{ false, true }) |panes| for (regions) |tag| { const pane = tag.kind == .tag; + if (pane != panes) continue; if (!pane and tag.kind != .column_tag and tag.kind != .workspace_tag) continue; if (tag.rect.w == 0 or tag.rect.h == 0) continue; if (pane and groupOf(groups, tag.serial) != index) continue; @@ -9068,7 +9376,7 @@ fn tagPlaques(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, g try addPattern(g, gpa, inner[0], inner[1], inner[2], inner[3], chrome.decor_box_stripe.?, fill, dp(@floatFromInt(@max(1, chrome.decor_box_stripe_px))), true, track, win_w, win_h) else try addDecor(g, gpa, inner[0], inner[1], inner[2], inner[3], fill, 1, track, win_w, win_h); - } + }; } const Plaque = struct { band: [4]f32, box: [4]f32, border: f32, shadow: [2]f32 }; @@ -9077,16 +9385,29 @@ const Plaque = struct { band: [4]f32, box: [4]f32, border: f32, shadow: [2]f32 } /// grip to the end), the framed box round the tagline's text, and the /// frame's and the shadow's widths, the shadow cut to what the band's slack /// below the text and its right end leave. Null where there is no room. +/// Tags stacked on each other (the workspace's over the columns', a +/// column's over its first pane's) share one edge, the ruled one between +/// their bands: the upper box runs down to it and casts no shadow down, the +/// lower one's frame lies over it. Every box in a column spans the same +/// pixels across, right of the grips, its shadow short of the next column. fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const pardes.Region, tag: pardes.Region, layout: CellLayout, win_w: f32, win_h: f32) ?Plaque { const pane = tag.kind == .tag; const first = tag.rect.y; const last = tag.rect.y + tag.rect.h - 1; - // From its start, right of its grip: the gap cell before its text is - // the plaque's margin, so its frame is never under the first letter. - _ = regions; - const x0 = @as(f32, @floatFromInt(tag.rect.x)) * layout.w; + // Flush against its grip's button, so the two read as one piece: the + // gap between button and text (the scroll gap) is the plaque's, and so + // is the cell before its text, so its frame is never under the first + // letter. A column's plaque starts where the pane's under it does. + const grip_kind: pardes.Region.Kind = if (tag.kind == .column_tag) .column_grip else .grip; + const x0 = for (regions) |grip| { + if (tag.kind != .workspace_tag and grip.kind == grip_kind and grip.owner == tag.owner) + break scrollColumn(g, chrome, grip.rect.x, layout)[1]; + } else @as(f32, @floatFromInt(tag.rect.x)) * layout.w; const x1_cells = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w; - const x1 = if (win_w - x1_cells < layout.w) @max(x1_cells, win_w) else x1_cells; + const window_edge = win_w - x1_cells < layout.w; + const x1 = if (window_edge) @max(x1_cells, win_w) else x1_cells; + const stacked_on = tagBandAt(regions, tag, .above); + const stacked_under = tagBandAt(regions, tag, .below); const row_top = @as(f32, @floatFromInt(first)) * layout.h; const row_bottom = @as(f32, @floatFromInt(last + 1)) * layout.h; const text_top = row_top + @as(f32, @floatFromInt(taglineBandOffset(first, win_h, g.cell_h, g.tagline_height))); @@ -9096,16 +9417,42 @@ fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const parde // The shadow's full offset to the right; down, what the slack under the // text leaves (a pixel or three at a tagline under the body's size). const want: f32 = if (pane) dp(@floatFromInt(chrome.decor_box_shadow_px)) else dp(2); - const drop = std.math.clamp(@floor(row_bottom - text_bottom - border), 0, want); - if (x1 - x0 < 4 * border + want) return null; + const drop = if (stacked_under) 0 else std.math.clamp(@floor(row_bottom - text_bottom - border), 0, want); + // Every box ends where a pane's does, short of the next column's rule + // by a pane frame's width past its shadow, so no shadow meets the rule + // and a column's box lines up with its panes'; the window's edge needs + // no gap. + const pane_shadow = dp(@floatFromInt(chrome.decor_box_shadow_px)); + const gap = @max(0, pane_shadow - want) + if (window_edge) 0 else dp(@floatFromInt(@max(1, chrome.decor_box_border_px))); + if (x1 - x0 < 4 * border + want + gap) return null; return .{ .band = .{ x0, row_top, x1, row_bottom }, - .box = .{ x0, text_top - border, x1 - want, text_bottom + border }, + .box = .{ + x0, + if (stacked_on) row_top - border else text_top - border, + x1 - want - gap, + if (stacked_under) row_bottom else text_bottom + border, + }, .border = border, .shadow = .{ want, drop }, }; } +/// Whether another tag's band (the workspace's, a column's, a pane's) meets +/// `tag`'s right above or below it, across some of its width. +fn tagBandAt(regions: []const pardes.Region, tag: pardes.Region, side: enum { above, below }) bool { + for (regions) |other| { + if (other.kind != .tag and other.kind != .column_tag and other.kind != .workspace_tag) continue; + if (other.rect.h == 0 or std.meta.eql(other.rect, tag.rect)) continue; + const meets = switch (side) { + .above => other.rect.y + other.rect.h == tag.rect.y, + .below => tag.rect.y + tag.rect.h == other.rect.y, + }; + if (meets and other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) return true; + } + return false; +} + /// The two short bars on each row of a grip with no mode glyph there, /// centered in its anchor. Only the panes of group `index`. fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { @@ -9120,7 +9467,13 @@ fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, ce const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w; const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w; try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h); - try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h); + // Right of it, a theme's plaque starts flush against it (plaqueBox). + const plaqued = chrome.decor_box_border != null and !cell_decor.plaques_off; + if (!plaqued) try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h); + // Its cells over the plaque's reach are let through (emitInstance): + // under the button's row, the band goes on as far as the plaque. + if (plaqued and grip.rect.h > 1) + try addDecor(g, gpa, left, button[3], button[2], @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h, band, 1, track, win_w, win_h); if (grip.kind != .grip) continue; // acme's button ring, two pixels (ButtonBorder) inside the button: // the focused one's in its own fill. An unfocused dirty one keeps a @@ -9480,7 +9833,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.fx); // 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 +9875,183 @@ 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.fx); + 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); +} + +test "occlusion shades each body's edges, inside it, never a tag, a grip or the cursor" { + 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.occlusion = true; + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + try std.testing.expectEqual(@as(u8, 4), s.chrome.fx); + const body = for (s.regionList()) |region| { + if (region.kind == .body) break region; + } else return error.NoBody; + // The cursor on the body's top-left cell, where the shade is. + s.cursor = .{ .x = body.rect.x, .y = body.rect.y }; + 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 = .{}; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + const r = bodyPixels(body, fixedCellLayout(&g)); + var shades: usize = 0; + for (g.decor.items) |item| { + if (item.effect & soft_shadow_bit == 0) continue; + shades += 1; + try std.testing.expect(item.fr <= 0.02 + 1e-6); + 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); + const cx: f32 = @floatFromInt(@as(u32, s.cursor.?.x) * 10); + const cy: f32 = @floatFromInt(@as(u32, s.cursor.?.y) * 20); + try std.testing.expect(item.clip_x1 <= cx + 0.01 or item.clip_x0 >= cx + 10 - 0.01 or item.clip_y1 <= cy + 0.01 or item.clip_y0 >= cy + 20 - 0.01); + } + try std.testing.expect(shades >= 4); +} + +test "a body tells its first line, and lapis's dots under it move a quarter as far" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + const pane = try core.setTestFile("line\n" ** 80); + pane.scrollBy(8); + try std.testing.expect(core.executeBuiltinLine(0, "Theme lapis")); + core.settings.parallax = true; + for (0..40) |_| core.update(.tick); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + const body = for (s.regionList()) |region| { + if (region.kind == .body) break region; + } else return error.NoBody; + try std.testing.expectEqual(@as(i32, 8), body.line); + try std.testing.expect(s.chrome.fx & 8 != 0); + // The shift the cell shader is handed: a quarter of 8 rows of 20 px, + // upward, within the dots' period. + var decor = CellDecor.of(&s.chrome); + const period = decor.uniforms[4]; + decor.bodies[0] = .{ 0, 0, 800, 480 }; + decor.shifts[0] = @mod(-8.0 * 20.0 * 0.25, period); + decor.nbodies = 1; + try std.testing.expectApproxEqAbs(@mod(-40.0, period), decor.parallaxAt(10, 10), 1e-4); + try std.testing.expectEqual(@as(f32, 0), decor.parallaxAt(900, 10)); +} + +test "changed text glows and fades in 250 ms; the cursor and a scroll do not light up" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + const pane = try core.setTestFile("line\n" ** 60); + core.settings.afterglow = true; + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + const body = for (s.regionList()) |region| { + if (region.kind == .body) break region; + } else return error.NoBody; + s.cursor = .{ .x = body.rect.x + 12, .y = body.rect.y + 2 }; + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 10; + g.cell_h = 20; + g.afterglow = .{}; + defer g.afterglow.deinit(gpa); + const layout = fixedCellLayout(&g); + const Count = struct { + fn glows(items: []const CellInstance, accent: [3]u8) usize { + var n: usize = 0; + for (items) |item| { + 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, &accent) and item.fr > 0.001) n += 1; + } + return n; + } + }; + g.sel_glow = .{ .now_ns = std.time.ns_per_s }; + try afterglowDecor(&g, gpa, s, layout, 800, 480); + try std.testing.expectEqual(@as(usize, 0), g.decor.items.len); + // Three cells of a row change, the cursor's among them. + for (10..13) |x| { + const cell = &s.cells[@as(usize, body.rect.y + 2) * s.cols + body.rect.x + x]; + cell.* = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 1, 2, 3 } } } }; + cell.text[0] = 'X'; + cell.len = 1; + } + g.sel_glow.now_ns += 16 * std.time.ns_per_ms; + try afterglowDecor(&g, gpa, s, layout, 800, 480); + try std.testing.expectEqual(@as(usize, 1), Count.glows(g.decor.items, s.chrome.accent)); + const lit = g.decor.items[0]; + // Two cells wide: the cursor's is left out. + try std.testing.expectApproxEqAbs(@as(f32, 2 * 10 * 2.0 / 800.0), lit.x1 - lit.x0, 1e-4); + try std.testing.expect(g.afterglow.glowing); + // Gone by 250 ms. + g.decor.clearRetainingCapacity(); + g.sel_glow.now_ns += 260 * std.time.ns_per_ms; + try afterglowDecor(&g, gpa, s, layout, 800, 480); + try std.testing.expectEqual(@as(usize, 0), g.decor.items.len); + try std.testing.expect(!g.afterglow.glowing); + // A scroll changes every cell and lights none. + pane.scrollBy(5); + const scrolled = try core.render(core.scratch.allocator()); + g.sel_glow.now_ns += 16 * std.time.ns_per_ms; + try afterglowDecor(&g, gpa, scrolled, layout, 800, 480); + try std.testing.expectEqual(@as(usize, 0), g.decor.items.len); +} + /// 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 @@ -9649,7 +10179,6 @@ fn buildOverlay( fn appendCursorGlide(g: *Gui, surface: *const pardes.Surface, builder: *OverlayBuilder) void { const chrome = &surface.chrome; if (g.cursor_quad_alpha <= 0.001) return; - if (surface.panelTracks().len != 0) return; const layout = fixedCellLayout(g); var px: [4][2]f32 = undefined; var centre: [2]f32 = .{ 0, 0 }; |
