diff options
Diffstat (limited to 'src/gui/gui.zig')
| -rw-r--r-- | src/gui/gui.zig | 1223 |
1 files changed, 1152 insertions, 71 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index de5da1e7..bd2dca69 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -507,6 +507,12 @@ const clear_bg_bit: u32 = 0x4000_0000; const opaque_bg_bit: u32 = 0x2000_0000; /// Decor that is a soft shadow, not a flat fill (decor.frag.glsl). const soft_shadow_bit: u32 = 0x1000_0000; +/// Decor that is a checker (decor.frag.glsl). +const checker_bit: u32 = 0x0800_0000; +/// A cell on the page's ground under the theme's dots, and a file name's +/// cell that casts the theme's shadow (ui.frag.glsl, Chrome.decor_*). +const dotted_bit: u32 = 0x1000_0000; +const title_bit: u32 = 0x0800_0000; const SavedImagePlace = struct { key: pardes.ImageCacheKey, @@ -1787,6 +1793,10 @@ const Gui = struct { device: *c.SDL_GPUDevice, swapchain_format: c.SDL_GPUTextureFormat, pipeline: *c.SDL_GPUGraphicsPipeline, + /// The cell pipeline, blending premultiplied over what is drawn under: + /// for a frame with a theme's tag plaques, whose blank tag cells let + /// them show (tagPlaques). + pipeline_over: *c.SDL_GPUGraphicsPipeline, decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, @@ -1856,6 +1866,12 @@ const Gui = struct { scroll_lag: f32 = 0, // picture position - core position, in rows scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane scroll_edge_len: u16 = 0, + /// G6: a wheel's notches, whole lines, scroll the core at once and the + /// 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. @@ -1981,7 +1997,7 @@ const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null, tag_hit: ?pardes.TagHit = null, rule_above: bool = false }; /// How near a row's top edge a press is on the rule drawn there between two -/// panes: the rule's 2 px and a pixel either side. +/// panes: the rule's 2 px and a pixel either side, in logical pixels (dp). const rule_grab_px: f32 = 3; const WindowGeometry = struct { window_w: f32, @@ -2068,6 +2084,9 @@ const Group = struct { image_count: u32 = 0, decor_start: u32 = 0, decor_count: u32 = 0, + /// The first `under_count` of the group's decor are drawn before its + /// cells, under them (a theme's tag plaques, whose cells let them show). + under_count: u32 = 0, }; /// The frame's draw groups, bottom to top: tier 0 (the page and the panes @@ -2331,6 +2350,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u return error.SdlInit; }; defer c.SDL_DestroyWindow(window); + updateDisplayScale(window); if (native_wayland) log.info("Wayland opacity: background-only transparent surface", .{}); const arrow_cursor = c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1); defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor); @@ -2403,7 +2423,13 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u font_allocator = std.heap.smp_allocator; c.ui_font_prime(font); font_allocator = gpa; - const px: f32 = 27.0; + // A harness forcing a density (PARDES_TEST_SCALE) draws the text as a + // person on that display would size it, so its chrome is seen at the + // proportion it has there; PARDES_TEST_FONT_SCALE sizes the text alone + // (a density's text with 1x chrome: how it looked before dp). A live + // window keeps the size the user sets. + const font_scale: f32 = if (env.get("PARDES_TEST_FONT_SCALE")) |v| std.fmt.parseFloat(f32, v) catch display_scale else display_scale; + const px: f32 = if (test_mode) 27.0 * std.math.clamp(font_scale, 1, 4) else 27.0; var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; @@ -2448,6 +2474,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; const pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState)); + const pipeline_over = try makePipeline(device, swapchain_format, frag_spv, 1, premultipliedBlend()); const decor_pipeline = try makePipeline(device, swapchain_format, decor_frag_spv, 0, backgroundLayerBlend()); const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); const image_pipeline = try makeImagePipeline(device, swapchain_format); @@ -2478,6 +2505,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, + .pipeline_over = pipeline_over, .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, @@ -2516,6 +2544,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u // regrown to; the image ones are released below. defer { c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline); + c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline_over); c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); @@ -2551,6 +2580,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll + defer g.glide.deinit(gpa); resetGlyphAtlas(&g); if (attach) |requested| return attachRequested(gpa, &g, requested); @@ -3081,6 +3111,7 @@ const StdinFeed = struct { var seq_start: usize = 0; while (seq_start < len) { // ESC]777;mouse;<down|up|motion>;<SDL button, 0 for none>;<x>;<y>BEL + // ESC]777;mouse;wheel;<amount>;<x>;<y>BEL // A mouse event at window pixels, built as SDL would and run // through dispatch, so a driver exercises the real pointer path // (pixel to cell, tag and body hits). SGR mouse carries only cells. @@ -3095,11 +3126,21 @@ const StdinFeed = struct { if (g) |gp| { var parts = std.mem.splitScalar(u8, body[0..end], ';'); const kind = parts.next() orelse ""; - const button = std.fmt.parseInt(u8, parts.next() orelse "", 10) catch 0; + const second = parts.next() orelse ""; + const button = std.fmt.parseInt(u8, second, 10) catch 0; const x = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0; const y = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0; var sev = std.mem.zeroes(c.SDL_Event); - if (std.mem.eql(u8, kind, "motion")) { + if (std.mem.eql(u8, kind, "wheel")) { + // ESC]777;mouse;wheel;<y, SDL's sign: up positive>;<x>;<y>BEL: + // a whole number is a mouse's notches, else a touchpad's. + const amount = std.fmt.parseFloat(f32, second) catch 0; + sev.type = c.SDL_EVENT_MOUSE_WHEEL; + sev.wheel.y = amount; + sev.wheel.integer_y = @intFromFloat(@trunc(amount)); + sev.wheel.mouse_x = x; + sev.wheel.mouse_y = y; + } else if (std.mem.eql(u8, kind, "motion")) { sev.type = c.SDL_EVENT_MOUSE_MOTION; sev.motion.x = x; sev.motion.y = y; @@ -3349,6 +3390,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { const at = windowPointToPixels(windowGeometry(g.window), sev.button.x, sev.button.y); g.post.mouse(at.x, at.y, sev.button.down); }, + c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => { + updateDisplayScale(g.window); + // Underlines are drawn into the glyphs a logical pixel thick. + resetGlyphAtlas(g); + if (in.core) |core| core.needs_frame = true; + }, c.SDL_EVENT_WINDOW_FOCUS_GAINED => g.post.focus(true), c.SDL_EVENT_WINDOW_FOCUS_LOST => g.post.focus(false), else => {}, @@ -3504,7 +3551,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { g.scroll_pane = id; g.scroll_col = mc.col; g.scroll_row = mc.row; - g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); + // A mouse wheel's notch is a whole line and + // glides (G6); a touchpad's fraction moves the + // picture by itself, under the finger. + if (notchOf(w.y, w.integer_y)) |lines| { + g.scroll_notch +|= lines; + } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); } } } else { @@ -3734,7 +3786,7 @@ fn gridCellAtDimensions( const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000)); var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row }; // On the rule over this row, which is where two panes meet if they do. - point.rule_above = row > 0 and @max(y, 0) - @as(f32, @floatFromInt(row)) * @max(body_h, 1) < rule_grab_px; + point.rule_above = row > 0 and @max(y, 0) - @as(f32, @floatFromInt(row)) * @max(body_h, 1) < dp(rule_grab_px); if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @max(body_w, 1), y / @max(body_h, 1))) |mapped| { for (p.surface.tagLayers()) |*layer| { if (layer.tagHit(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { @@ -4185,7 +4237,7 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { } else for (s.ptys) |slot| { if (slot) |pt| if (pt.kill_at != 0) break true; } else false; - const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring or + const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.scroll_notch != 0 or g.glide.moving() or g.gamepad != null or s.test_mode or core.needs_frame or retiring or g.post.animating(core.settings.shader_animation); const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0; // A latency trace is fed on stdin, whose poll below paces the loop: @@ -4776,14 +4828,356 @@ test "fractional scroll reversals preserve signed distance" { try std.testing.expect(reverse.lag >= 0 and reverse.lag < 1); } +/// A mouse wheel's step as whole lines (down positive), or null for a +/// touchpad's fraction, which follows the finger as it is. +fn notchOf(raw_y: f32, integer_y: i32) ?i32 { + if (!std.math.isFinite(raw_y) or integer_y == 0 or raw_y != @round(raw_y)) return null; + return @intFromFloat(std.math.clamp(-raw_y, -64, 64)); +} + +test "a wheel's notch is whole lines, a touchpad's fraction is not" { + try std.testing.expectEqual(@as(?i32, 1), notchOf(-1, -1)); + try std.testing.expectEqual(@as(?i32, -3), notchOf(3, 3)); + try std.testing.expectEqual(@as(?i32, null), notchOf(-0.25, 0)); + try std.testing.expectEqual(@as(?i32, null), notchOf(-1.5, -1)); + try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1)); +} + +/// G6 (docs/render-pipeline.md §9.1): a wheel's lines scroll the core at +/// once, so every key and click acts on where the text is, and the picture +/// glides there on the Motion flavour's spring (critically damped: text +/// never passes its mark). How far the rows moved is measured on the next +/// frame against the last, a line wrapped over several rows or a terminal's +/// output included, and the rows that left are kept to draw in the gap. +const Glide = struct { + spring: pardes.animation.Spring = .{}, + /// The picture's offset from the core's rows now (rows; negative, the + /// picture still shows what is above). + offset: f32 = 0, + now_ns: u64 = 0, + pane: usize = 0, + rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, + body_y: u16 = 0, + body_h: u16 = 0, + /// A scroll made and not yet measured: its direction, and the body's + /// rows before it. + pending: bool = false, + dir: i8 = 0, + before: []pardes.Cell = &.{}, + /// The rows past the body's top (`above`) and bottom (`below`), nearest + /// first, `bw` cells each: what the gap shows while the picture lags. + above: []pardes.Cell = &.{}, + below: []pardes.Cell = &.{}, + nabove: u16 = 0, + nbelow: u16 = 0, + /// The Motion flavour the last loop step read. + motion: pardes.animation.Motion = .{}, + + fn moving(glide: *const Glide) bool { + return !glide.spring.settled or glide.pending; + } + + fn stop(glide: *Glide) void { + glide.spring = .{}; + glide.offset = 0; + glide.pending = false; + glide.nabove = 0; + glide.nbelow = 0; + } + + fn deinit(glide: *Glide, gpa: std.mem.Allocator) void { + gpa.free(glide.before); + gpa.free(glide.above); + gpa.free(glide.below); + } + + fn bw(glide: *const Glide) u16 { + return glide.rect.w -| config.GUTTER; + } + + /// The core's rows moved by `rows` (down positive) since the last + /// frame: the picture now lags by as many, carrying its speed. + fn kick(glide: *Glide, rows: f32, now_ns: u64, motion: pardes.animation.Motion) void { + // Follows input: the flavour's pace, half again, never overshooting. + var pace = motion; + pace.omega = motion.omega * 1.5; + glide.spring.carry(-rows, now_ns, pace); + // Never more than a body's rows behind: a flick past that jumps. + const limit: f32 = @floatFromInt(glide.body_h); + const now = glide.spring.value(now_ns); + if (@abs(now) > limit) glide.spring.carry(std.math.clamp(now, -limit, limit) - now, now_ns, pace); + glide.offset = glide.spring.value(now_ns); + } + + /// Finds how far the pane's body rows moved, `dir` rows at a time, by + /// the rows of `surface` against `before`; keeps the rows that left. + fn measure(glide: *Glide, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { + glide.pending = false; + const w = glide.bw(); + const h = glide.body_h; + const x0 = glide.rect.x + config.GUTTER; + if (w == 0 or h == 0 or x0 + w > surface.cols or glide.body_y + h > surface.rows or glide.before.len < @as(usize, w) * h) return glide.stop(); + const now = struct { + fn row(s: *const pardes.Surface, x: u16, y: u16, n: u16) []const pardes.Cell { + return s.cells[@as(usize, y) * s.cols + x ..][0..n]; + } + }; + // The least shift under which the rows agree, but for a few: the + // cursor's line and its number's weight move with the scroll. + const shift: u16 = found: for (1..h) |k| { + const kk: u16 = @intCast(k); + const allowed = @max(2, (h - kk) / 8); + var misses: usize = 0; + for (0..h - kk) |r| { + const rr: u16 = @intCast(r); + const old_row = glide.before[@as(usize, if (glide.dir > 0) rr + kk else rr) * w ..][0..w]; + const new_row = now.row(surface, x0, glide.body_y + if (glide.dir > 0) rr else rr + kk, w); + if (!sameRow(old_row, new_row)) { + misses += 1; + if (misses > allowed) continue :found; + } + } + // Too few rows left to tell: no glide. + if (h - kk < 3) break :found 0; + break :found kk; + } else 0; + if (shift == 0) return glide.stop(); + // The rows that left: past the top going down, the bottom going up. + if (!glide.ensure(gpa)) return glide.stop(); + const keep = if (glide.dir > 0) &glide.above else &glide.below; + const count = if (glide.dir > 0) &glide.nabove else &glide.nbelow; + const kept = @min(count.*, h - shift); + std.mem.copyBackwards(pardes.Cell, keep.*[@as(usize, shift) * w ..][0 .. @as(usize, kept) * w], keep.*[0 .. @as(usize, kept) * w]); + for (0..shift) |i| { + const src_row: usize = if (glide.dir > 0) shift - 1 - i else h - shift + i; + @memcpy(keep.*[i * w ..][0..w], glide.before[src_row * w ..][0..w]); + } + count.* = kept + shift; + // What came into view is no longer past the other edge. + const other = if (glide.dir > 0) &glide.below else &glide.above; + const other_n = if (glide.dir > 0) &glide.nbelow else &glide.nabove; + const dropped = @min(other_n.*, shift); + std.mem.copyForwards(pardes.Cell, other.*[0 .. @as(usize, other_n.* - dropped) * w], other.*[@as(usize, dropped) * w ..][0 .. @as(usize, other_n.* - dropped) * w]); + other_n.* -= dropped; + glide.kick(@as(f32, @floatFromInt(shift)) * @as(f32, @floatFromInt(glide.dir)), glide.now_ns, glide.motion); + } + + fn ensure(glide: *Glide, gpa: std.mem.Allocator) bool { + const n = @as(usize, glide.bw()) * glide.body_h; + inline for (.{ &glide.above, &glide.below }) |buffer| if (buffer.len < n) { + const grown = gpa.realloc(buffer.*, n) catch return false; + buffer.* = grown; + }; + return true; + } +}; + +fn sameRow(a: []const pardes.Cell, b: []const pardes.Cell) bool { + for (a, b) |x, y| { + if (x.default != y.default) return false; + if (x.default) continue; + if (!std.mem.eql(u8, x.grapheme(), y.grapheme()) or !std.meta.eql(x.style, y.style)) return false; + } + return true; +} + +/// Feeds a wheel's notches to the core, snapshotting the body first so the +/// next frame can measure the move (Glide.measure); advances the glide and +/// asks for frames while it moves. +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; + glide.now_ns = core.now_ns; + glide.motion = pardes.animation.Motion.of(core.settings.motion); + if (!glide.spring.settled) { + _ = glide.spring.step(core.now_ns); + core.needs_frame = true; + if (glide.spring.settled) { + glide.offset = 0; + glide.nabove = 0; + glide.nbelow = 0; + } + } + const notch = g.scroll_notch; + if (notch == 0) return; + g.scroll_notch = 0; + const id = g.scroll_pane orelse return; + const pane = core.panes[id] orelse return resetScroll(g); + const r = core.rects[id]; + if (r.h <= pane.tag_rows or r.w <= config.GUTTER) return resetScroll(g); + const body_y = core.bodyTop(pane, r); + const body_h = r.h -| pane.tag_rows; + // A new pane or place: what was kept belongs to another picture. + if (glide.pane != id or !std.meta.eql(glide.rect, r) or glide.body_y != body_y or glide.body_h != body_h or glide.pending) glide.stop(); + glide.pane = id; + glide.rect = r; + glide.body_y = body_y; + glide.body_h = body_h; + // The body as the last frame drew it, which the move is measured from. + const s = &core.surface; + const w = r.w - config.GUTTER; + const x0 = r.x + config.GUTTER; + const animate = !glide.motion.instant and x0 + w <= s.cols and body_y + body_h <= s.rows and s.cells.len != 0; + if (animate) { + const n = @as(usize, w) * body_h; + if (glide.before.len < n) glide.before = gpa.realloc(glide.before, n) catch &.{}; + if (glide.before.len >= n) { + for (0..body_h) |row| @memcpy(glide.before[row * w ..][0..w], s.cells[(@as(usize, body_y) + row) * s.cols + x0 ..][0..w]); + } + } + var left = notch; + var moved = false; + while (left != 0) { + const down = left > 0; + left += if (down) -1 else 1; + const was = pane.scroll(); + core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row } }); + if (pane.scroll() == was) break; + moved = true; + } + if (!moved) return; + core.needs_frame = true; + if (animate and glide.before.len >= @as(usize, w) * body_h) { + glide.pending = true; + glide.dir = @intCast(std.math.sign(notch)); + } +} + +/// The glide's picture: the pane's body rows moved by the offset, and in +/// the gap it opens the rows that left, as `emitScrollRows` draws a +/// touchpad's fraction. +fn emitGlideRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { + const glide = &g.glide; + for (surface.panelTracks()) |track| + if (track.active() and track.pane == glide.pane) return 0; + const r = glide.rect; + const bh = glide.body_h; + const bw = glide.bw(); + const x0 = r.x + config.GUTTER; + const y0 = glide.body_y; + if (bw == 0 or bh == 0 or x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; + // The scissor the shifted rows are drawn in is the scroll's. + g.scroll_rect = r; + g.scroll_body_y = y0; + g.scroll_body_h = bh; + var shifted = layout; + shifted.y_off -= glide.offset * layout.h; + const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); + const cursor_bar = if (surface.cursor) |cu| cu.bar else false; + var n: u32 = 0; + var row = y0; + while (row < y0 + bh) : (row += 1) { + const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; + const row_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; + var col = x0; + while (col < x0 + bw) : (col += 1) { + const sidx: u32 = @as(u32, row) * surface.cols + col; + emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), line, col, row_cursor, false, sidx == cursor_idx and !cursor_bar, page); + n += 1; + } + } + // The gap: rows past the top while the picture lags a move down, past + // the bottom for one up; as many as it opens. + const gap: u16 = @intFromFloat(@min(@ceil(@abs(glide.offset)), @as(f32, @floatFromInt(bh)))); + const kept = if (glide.offset < 0) glide.nabove else glide.nbelow; + const rows = if (glide.offset < 0) glide.above else glide.below; + for (0..@min(gap, kept)) |i| { + const cells = rows[i * bw ..][0..bw]; + // Row -1 - i above the body, or bh + i below it, in grid rows. + const at_row: i32 = if (glide.offset < 0) @as(i32, y0) - 1 - @as(i32, @intCast(i)) else @as(i32, y0) + bh + @as(i32, @intCast(i)); + var at = shifted; + // emitInstance takes an unsigned row: move the layout instead. + at.y_off += @as(f32, @floatFromInt(at_row - @as(i32, y0))) * layout.h; + for (0..bw) |c_i| { + emitInstance(g, instances, base + n, x0 + @as(u16, @intCast(c_i)), y0, at, win_w, win_h, null, cellFontRole(&cells[c_i]), cells, c_i, null, false, false, page); + n += 1; + } + } + return n; +} + +test "a notch scrolls the core at once and the picture glides after it, a wrapped line's rows at a time" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + // Lines of two rows each at this width, numbered so no two rows match. + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + for (0..60) |i| { + try text.print(gpa, "{d:0>4} ", .{i}); + try text.appendNTimes(gpa, 'a' + @as(u8, @intCast(i % 26)), 100); + try text.append(gpa, '\n'); + } + _ = try core.setTestFile(text.items); + core.settings.motion = .smooth; + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const ms = std.time.ns_per_ms; + var now: u64 = std.time.ns_per_s; + core.advance(now); + var s = try core.render(arena.allocator()); + var g: Gui = undefined; + g.glide = .{}; + defer g.glide.deinit(gpa); + g.scroll_delta = 0; + g.scroll_lag = 0; + g.scroll_edge_len = 0; + const r = core.rects[core.active]; + g.scroll_pane = core.active; + g.scroll_col = r.x + 10; + g.scroll_row = r.y + r.h - 2; + const body_y = core.bodyTop(core.panes[core.active].?, r); + const row_before = try gpa.dupe(pardes.Cell, s.cells[@as(usize, body_y + 1) * s.cols + r.x + config.GUTTER ..][0 .. r.w - config.GUTTER]); + defer gpa.free(row_before); + // One notch down: the core is a line on at once. + g.scroll_notch = 1; + const scrolled = core.panes[core.active].?.scroll(); + stepGlide(&g, core, gpa); + try std.testing.expectEqual(scrolled + 1, core.panes[core.active].?.scroll()); + try std.testing.expect(g.glide.pending); + // On the core's continuous path while it moves: frames every loop. + try std.testing.expect(core.shell_continuous and core.continuous()); + _ = arena.reset(.retain_capacity); + s = try core.render(arena.allocator()); + g.glide.measure(gpa, s); + // Its two rows: the picture two rows behind, the rows that left kept, + // nearest first. + try std.testing.expectApproxEqAbs(@as(f32, -2), g.glide.offset, 1e-4); + try std.testing.expectEqual(@as(u16, 2), g.glide.nabove); + try std.testing.expect(sameRow(g.glide.above[0 .. r.w - config.GUTTER], row_before)); + // Frame by frame it comes back, never past, and settles. + var frames: usize = 0; + while (g.glide.moving()) : (frames += 1) { + now += 7 * ms; + core.advance(now); + stepGlide(&g, core, gpa); + try std.testing.expect(g.glide.spring.value(now) <= 1e-4); + if (frames > 200) return error.NeverSettled; + } + try std.testing.expect(frames > 5); + try std.testing.expectEqual(@as(f32, 0), g.glide.offset); + try std.testing.expect(!core.shell_continuous); + // Motion off: a notch lands at once. + core.settings.motion = .off; + g.scroll_notch = 1; + stepGlide(&g, core, gpa); + try std.testing.expect(!g.glide.pending and !g.glide.moving()); +} + + fn resetScroll(g: *Gui) void { g.scroll_pane = null; g.scroll_delta = 0; g.scroll_lag = 0; g.scroll_edge_len = 0; + g.scroll_notch = 0; + g.glide.stop(); } fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { + stepGlide(g, core, gpa); const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse { resetScroll(g); @@ -4792,7 +5186,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const queued_delta = g.scroll_delta; g.scroll_delta = 0; if (queued_delta == 0) { - if (g.scroll_lag == 0) resetScroll(g); + if (g.scroll_lag == 0 and g.scroll_notch == 0 and !g.glide.moving()) resetScroll(g); return; } const r = core.rects[id]; @@ -4847,6 +5241,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { } fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 { + if (g.glide.offset != 0) return emitGlideRows(g, instances, base, surface, layout, win_w, win_h, page); const scroll_pane = g.scroll_pane orelse return 0; if (g.scroll_lag == 0) return 0; for (surface.panelTracks()) |track| @@ -5413,6 +5808,27 @@ fn renderFrame( defer if (building) build_zone.end(); const layout = fixedCellLayout(g); const chrome = &surface.chrome; + // 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 + // are drawn as they are, and the plaques not at all. + cell_decor.plaques_off = backgroundOpacity(g.applied_window_opacity) < 1; + if (chrome.decor_box_border != null and !cell_decor.plaques_off) { + cell_decor.grounds = .{ chrome.tag_bg, chrome.tag_focus_bg }; + for (surface.regionList()) |region| { + if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue; + if (cell_decor.nplaques == cell_decor.plaques.len) break; + const plaque = plaqueBox(g, chrome, surface.regionList(), region, layout, win_w, win_h) orelse continue; + cell_decor.plaques[cell_decor.nplaques] = plaque.band; + cell_decor.nplaques += 1; + } + } 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 @@ -5535,7 +5951,8 @@ fn renderFrame( } const decor_len: u32 = @intCast(g.decor.items.len); if (cell_total != 0 or decor_len != 0) { - const capacity = std.math.add(u32, cell_total, cells + decor_len) catch return error.GpuCapacity; + // The shifted rows of a scroll: its body, and a glide's rows past it. + const capacity = std.math.add(u32, cell_total, 2 * cells + decor_len) catch return error.GpuCapacity; try ensureVbuf(g, capacity); const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { @@ -5559,7 +5976,9 @@ fn renderFrame( const track = groups.items[group_index].track; const data_effect = if (track) |active| active.effect == .dissolve else false; const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row); - if (data_diff) emitSurfaceCell( + // The new cell, then (a dissolve) the old one fading out over + // it, as the old layers come after the new ones. + emitSurfaceCell( g, chrome, instances, @@ -5569,16 +5988,16 @@ fn renderFrame( layout, win_w, win_h, - track, - surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], - previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], + if (data_effect and !data_diff) null else track, + line, + line_cover, col, - null, - true, + line_cursor, false, + cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); - emitSurfaceCell( + if (data_diff) emitSurfaceCell( g, chrome, instances, @@ -5588,13 +6007,13 @@ fn renderFrame( layout, win_w, win_h, - if (data_effect and !data_diff) null else track, - line, - line_cover, + track, + surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], col, - line_cursor, + null, + true, false, - cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); } @@ -5691,9 +6110,19 @@ fn renderFrame( const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; const decor_base = cell_total + shifted; for (groups.items[0..groups.len], 0..) |group, index| { + if (group.under_count != 0 and g.vbuf != null) { + c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline); + pushLayerOpacity(cmd, rp, background_opacity); + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start) * @sizeOf(CellInstance) }; + c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, group.under_count, 0, 0); + } const has_shifted = index == 0 and shifted != 0; if ((group.cell_count != 0 or has_shifted) and g.vbuf != null) { - c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline); + // Blended over what is under: a theme's plaques show through + // blank tag cells; a dissolve's old cells fade over its new. + const dissolving = if (group.track) |active| active.effect == .dissolve else false; + c.SDL_BindGPUGraphicsPipeline(rp, if (cell_decor.nplaques != 0 or dissolving) g.pipeline_over else g.pipeline); const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); if (group.cell_count != 0) { @@ -5711,13 +6140,13 @@ fn renderFrame( } } if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole); - if (group.decor_count != 0 and g.vbuf != null) { + if (group.decor_count > group.under_count and g.vbuf != null) { c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline); // Ink keeps its colour at any WindowOpacity. pushLayerOpacity(cmd, rp, if (index == groups.ink) 1 else background_opacity); - const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start) * @sizeOf(CellInstance) }; + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start + group.under_count) * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count, 0, 0); + c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count - group.under_count, 0, 0); } } drawOverlayGpu(g, cmd, rp, overlay_count); @@ -5727,8 +6156,10 @@ fn renderFrame( 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, @@ -5737,6 +6168,7 @@ fn renderFrame( } const chain_zone = tracy.zone(@src(), "post chain"); defer chain_zone.end(); + g.post.scale = display_scale; g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, g.scene_tex.?, target, sw, sh, c.SDL_GetTicksNS(), background_opacity >= 1) catch { g.scene_target_failed = true; }; @@ -6016,6 +6448,9 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p const y = layer.viewport.y + @as(u16, @intCast(index)); const top = @as(f32, @floatFromInt(y)) * bh; emitInstance(g, instances, next.*, 0, y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page); + // A theme's plaque is drawn under the band: the band's own ground + // lets it show (tagPlaques). + if (cell_decor.nplaques != 0 and cell_decor.inPlaque(left + 0.5, top + 0.5)) instances[next.*].effect |= clear_bg_bit; if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; const cells = layer.cells[index * layer.cols ..][0..layer.cols]; @@ -6139,7 +6574,7 @@ fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: C const x0 = @round(@as(f32, @floatFromInt(col)) * layout.w + rule); const scaled: f32 = @round(@as(f32, @floatFromInt(@max(1, chrome.rail_px))) * @as(f32, @floatFromInt(g.tagline_height)) / 17); // The rail and the grip are GUTTER cells wide; the text starts after. - const w = std.math.clamp(scaled, 4, @max(4, @floor(layout.w * @as(f32, @floatFromInt(config.GUTTER))) - rule - 3)); + const w = std.math.clamp(scaled, dp(4), @max(dp(4), @floor(layout.w * @as(f32, @floatFromInt(config.GUTTER))) - rule - dp(3))); return .{ x0, x0 + w }; } @@ -6221,6 +6656,10 @@ 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)) + instances[next.*].effect |= clear_bg_bit; next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. // A pane's grip cells are the cell shells' form (a dirty `*`): the @@ -6545,6 +6984,21 @@ fn emitInstance( // A block cursor is foreground UI ink, even though its color is carried // in the reversed cell background. Keep it visible at WindowOpacity 0. if (is_cursor) instances[idx].effect |= opaque_bg_bit; + // The theme's ornament (cell_decor): dots on the page's own ground, a + // shadow under a file name's letters. Never on a cursor. + if (!is_cursor) { + // A blank cell on a tag's ground inside a plaque: the plaque shows. + if (cell_decor.nplaques != 0 and resolved.role == .tagline and cellCodepoint(&line[at]) == ' ' and + (std.mem.eql(u8, &resolved.bg, &cell_decor.grounds[0]) or std.mem.eql(u8, &resolved.bg, &cell_decor.grounds[1])) and + (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 (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; + } + } } test "block cursor alone overrides transparent cell background" { @@ -6561,6 +7015,107 @@ test "block cursor alone overrides transparent cell background" { } } +test "lapis dots only the page's own ground and shadows only a file name, never a cursor" { + var g: Gui = undefined; + g.words = .{}; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 8; + g.tagline_height = 16; + g.space_slot = .{ .u = 0, .v = 0 }; + const page: [3]u8 = .{ 0x0a, 0x10, 0x30 }; + const name: [3]u8 = .{ 0xe8, 0xc4, 0x6a }; + var chrome: pardes.Chrome = .{ .page = page, .decor_dots = .{ 1, 2, 3 }, .decor_dot_alpha = 0x22, .decor_title = .{ 4, 5, 6 }, .name_ink = name, .active_name_ink = name }; + cell_decor = CellDecor.of(&chrome); + defer cell_decor = .{}; + const on_page: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 200, 200, 200 } }, .bg = .{ .rgb = page } } }; + const selected: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = name } } }; + const title: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = name }, .bg = .{ .rgb = .{ 0x0f, 0x1a, 0x4a } }, .font_role = .tagline } }; + const plain_tag: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 0x9a, 0xa6, 0xd9 } }, .bg = .{ .rgb = .{ 0x0f, 0x1a, 0x4a } }, .font_role = .tagline } }; + var instances: [1]CellInstance = undefined; + const Case = struct { cell: pardes.Cell, role: pardes.FontRole, cursor: bool, dotted: bool, shadowed: bool }; + for ([_]Case{ + .{ .cell = on_page, .role = .body, .cursor = false, .dotted = true, .shadowed = false }, + .{ .cell = on_page, .role = .body, .cursor = true, .dotted = false, .shadowed = false }, + .{ .cell = selected, .role = .body, .cursor = false, .dotted = false, .shadowed = false }, + .{ .cell = title, .role = .tagline, .cursor = false, .dotted = false, .shadowed = true }, + .{ .cell = title, .role = .tagline, .cursor = true, .dotted = false, .shadowed = false }, + .{ .cell = plain_tag, .role = .tagline, .cursor = false, .dotted = false, .shadowed = false }, + }) |case| { + emitInstance(&g, &instances, 0, 0, 0, fixedCellLayout(&g), 800, 480, null, case.role, (&case.cell)[0..1], 0, null, false, case.cursor, Ground.opaqueRgb(page)); + try std.testing.expectEqual(case.dotted, instances[0].effect & dotted_bit != 0); + try std.testing.expectEqual(case.shadowed, instances[0].effect & title_bit != 0); + } + // A theme without decor marks nothing. + chrome = .{ .page = page }; + cell_decor = CellDecor.of(&chrome); + emitInstance(&g, &instances, 0, 0, 0, fixedCellLayout(&g), 800, 480, null, .body, (&on_page)[0..1], 0, null, false, false, Ground.opaqueRgb(page)); + try std.testing.expectEqual(@as(u32, 0), instances[0].effect & (dotted_bit | title_bit)); +} + +test "lapis plaques stay in their tag's band, clear of every grip; only the focused one is striped" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + try std.testing.expect(core.executeBuiltinLine(0, "Theme lapis")); + for (0..40) |_| core.update(.tick); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + try std.testing.expect(s.chrome.decor_box_border != null and s.chrome.decor_checker != null and s.chrome.decor_dots != null and s.chrome.decor_title != null); + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 16; + g.cell_h = 32; + g.tagline_width = 12; + g.tagline_height = 22; + display_scale = 1; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 768, true); + const layout = fixedCellLayout(&g); + const under = g.decor.items[groups.items[0].decor_start..][0..groups.items[0].under_count]; + try std.testing.expect(under.len != 0); + var stripes: usize = 0; + var shadows: usize = 0; + 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. + 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; + } else return error.PlaqueOutsideItsBand; + 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 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); + } + if (item.effect & checker_bit != 0) { + try std.testing.expect(home.kind == .tag and home.active); + stripes += 1; + } + 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.decor_box_shadow.?)) shadows += 1; + } + try std.testing.expectEqual(@as(usize, 1), stripes); + try std.testing.expect(shadows >= 2); + // 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); + try std.testing.expect(checkers >= 1); + // No other theme draws any of it. + try std.testing.expect(core.executeBuiltinLine(0, "Theme forge")); + for (0..40) |_| core.update(.tick); + const plain = try core.render(core.scratch.allocator()); + try std.testing.expect(plain.chrome.decor_box_border == null and plain.chrome.decor_checker == null and plain.chrome.decor_dots == null and plain.chrome.decor_title == null); +} + fn cellCodepoint(cell: *const pardes.Cell) u32 { const grapheme = cell.grapheme(); return if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); @@ -7158,31 +7713,34 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { // the outline. fn decorateLine(out: [*]u8, stride: usize, width: usize, height: usize, baseline: i32, decoration: GlyphDecoration) void { if (width == 0 or height == 0) return; + // A line is a logical pixel thick, its dots and waves as long: the same + // weight at any density (dp). + const t: usize = @intFromFloat(dp(1)); if (decoration.underline != .off) { const base: usize = @intCast(@max(0, baseline)); - const row = @min(base + 1, height - 1); + const row = @min(base + 1, height -| t); for (0..width) |x| { - switch (decoration.underline) { + const top = switch (decoration.underline) { .off => unreachable, - .single => out[row * stride + x] = 255, + .single => row, .double => { - const upper = @min(base + 1, height -| 3); - out[upper * stride + x] = 255; - out[@min(upper + 2, height - 1) * stride + x] = 255; - }, - .curly => out[(row -| @as(usize, if (x % 4 < 2) 1 else 0)) * stride + x] = 255, - .dotted => if (x % 3 == 0) { - out[row * stride + x] = 255; - }, - .dashed => if (x % 6 < 4) { - out[row * stride + x] = 255; + const upper = @min(base + 1, height -| 3 * t); + for (0..t) |k| { + out[@min(upper + k, height - 1) * stride + x] = 255; + out[@min(upper + 2 * t + k, height - 1) * stride + x] = 255; + } + continue; }, - } + .curly => row -| @as(usize, if (x % (4 * t) < 2 * t) t else 0), + .dotted => if (x % (3 * t) < t) row else continue, + .dashed => if (x % (6 * t) < 4 * t) row else continue, + }; + for (0..t) |k| out[@min(top + k, height - 1) * stride + x] = 255; } } if (decoration.strikethrough) { - const row = @min(height - 1, @as(usize, @intCast(@max(0, baseline))) * 2 / 3); - @memset(out[row * stride ..][0..width], 255); + const row = @min(height -| t, @as(usize, @intCast(@max(0, baseline))) * 2 / 3); + for (0..t) |k| @memset(out[@min(row + k, height - 1) * stride ..][0..width], 255); } } @@ -7341,6 +7899,20 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat, return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } +/// Premultiplied over: an opaque cell writes itself exactly, a see-through +/// one only its ink. +fn premultipliedBlend() c.SDL_GPUColorTargetBlendState { + var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); + blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; + blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; + blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; + blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; + blend.enable_blend = true; + return blend; +} + fn backgroundLayerBlend() c.SDL_GPUColorTargetBlendState { var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; @@ -7731,6 +8303,29 @@ fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, try g.decor.append(gpa, instance); } +/// A checker (decor.frag.glsl): squares of `px` window pixels in `a` and +/// `b` from the quad's corner (lapis's scroll track); `stripes`, bands of +/// `px` across it instead (lapis's focused title bar), `a` first. +fn addChecker(g: *Gui, gpa: std.mem.Allocator, x0: f32, y0: f32, x1: f32, y1: f32, a: [3]u8, b: [3]u8, px: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + return addPattern(g, gpa, x0, y0, x1, y1, a, b, px, false, track, win_w, win_h); +} + +fn addPattern(g: *Gui, gpa: std.mem.Allocator, x0: f32, y0: f32, x1: f32, y1: f32, a: [3]u8, b: [3]u8, px: f32, stripes: bool, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + const first = g.decor.items.len; + try addDecor(g, gpa, x0, y0, x1, y1, a, 1, track, win_w, win_h); + if (g.decor.items.len == first) return; + const instance = &g.decor.items[first]; + instance.u0 = px; + instance.u1 = px; + instance.v0 = if (stripes) 1 else 0; + instance.v1 = if (stripes) 1 else 0; + instance.fr = @as(f32, @floatFromInt(b[0])) / 255.0; + instance.fg = @as(f32, @floatFromInt(b[1])) / 255.0; + instance.fb = @as(f32, @floatFromInt(b[2])) / 255.0; + instance.effect |= checker_bit; +} + + /// How a lift of 0 to 1 stands off the page: the shadow's blur and offset /// in cell heights, and its darkest, where it leaves the caster's edge. /// Within §8.2's budget: sigma 0.4-0.8 cell, at most 30% darker at the edge. @@ -7827,7 +8422,7 @@ fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftSty if (style == .rim) if (rim_edge) |edge| { const light = ceilings.rim[0] == 255; const alpha = @min(Lift.rim * lift, if (light) ceilings.white else ceilings.shadow); - try addDecor(g, gpa, edge[0], edge[2] - 1, edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h); + try addDecor(g, gpa, edge[0], edge[2] - dp(1), edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h); }; } @@ -8051,6 +8646,13 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g for (groups.items[0..groups.len], 0..) |*group, index| { group.decor_start = @intCast(g.decor.items.len); defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; + // Under the cells: a theme's tag plaques, this group's panes' (and + // the column and workspace tags with the still ones). + if (index <= groups.tracks) { + const closing = if (group.track) |active| active.phase == .closing else false; + try tagPlaques(g, gpa, if (closing) surface.previous_regions else regions, groups, index, chrome, layout, win_w, win_h, group.track); + } + group.under_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start; // The grips' buttons before any rule: the rules over their rows cut // them, and the band round them never covers a rule. if (index <= groups.tracks) { @@ -8067,7 +8669,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g else => {}, } else false; // As wide as the other rules, where the tagline leaves the room. - const height: u32 = @max(1, @min(chrome.rule_px, (g.cell_h -| g.tagline_height) * 2)); + const height: u32 = @intFromFloat(@max(1, @min(rulePx(chrome), @as(f32, @floatFromInt((g.cell_h -| g.tagline_height) * 2))))); if (top != 0 and top < surface.rows and tag_under and height != 0) { const y0: f32 = @floatFromInt(top * g.cell_h - height / 2); try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h); @@ -8101,6 +8703,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; @@ -8131,6 +8735,34 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g } else null; try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h); } + // G7: a pane on the move stands off the page, rising as it sets + // out and settling as it lands, its shadow on the other panes' + // bodies only (never a tag, a grip or its own text), moving + // with it. + if (track) |active| if (active.phase != .closing and (active.effect == .slide or active.effect == .zoom or active.effect == .vertical)) { + const steps: f32 = @floatFromInt(@max(1, active.frames() -| 1)); + const u = std.math.clamp(@as(f32, @floatFromInt(active.frame)) / steps, 0, 1); + const lift = @sin(std.math.pi * u); + var box: [4]f32 = .{ std.math.inf(f32), std.math.inf(f32), -std.math.inf(f32), -std.math.inf(f32) }; + var others: [pardes.MAX_PANES][4]f32 = undefined; + var nothers: usize = 0; + for (placed) |part| { + if (part.kind != .tag and part.kind != .rail and part.kind != .body and part.kind != .grip) continue; + const r = bodyPixels(part, layout); + if (part.owner == active.pane) { + box = .{ @min(box[0], r[0]), @min(box[1], r[1]), @max(box[2], r[2]), @max(box[3], r[3]) }; + } else if ((part.kind == .body or part.kind == .rail) and nothers < others.len) { + others[nothers] = r; + nothers += 1; + } + } + if (box[2] > box[0] and lift > 0.01) { + const h: f32 = @floatFromInt(g.cell_h); + const first = g.decor.items.len; + try addSoft(g, gpa, box, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h); + try clipToBodies(g, gpa, first, others[0..nothers]); + } + }; } // A notice chip is tagline hung over the body's top rows, so it is // ruled off from the text beneath it the way a tag is: the same @@ -8153,9 +8785,9 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g // A band still falling into place is cut at its row's top; its // rule comes with it, and shows only once it is below that cut. const rule_y = top + (1 + layer.slide) * layout.h; - if (rule_y - 1 < top) continue; + if (rule_y - dp(1) < top) continue; const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; - try addDecor(g, gpa, x0, rule_y - 1, rule_x1, rule_y, chrome.border, layer.fade, track, win_w, win_h); + try addDecor(g, gpa, x0, rule_y - dp(1), rule_x1, rule_y, chrome.border, layer.fade, track, win_w, win_h); } if (index == groups.ink and cursors_shown) try cursorDecor(g, gpa, surface, layout, win_w, win_h); } @@ -8181,7 +8813,7 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; const tag_bottom = tag.rect.y > rail.rect.y; const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h; - try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.tag_rule orelse mixRgb(chrome.tag_bg, page), 1, track, win_w, win_h); + try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - dp(1), rule_x1, if (tag_bottom) rule_y + dp(1) else rule_y, chrome.tag_rule orelse mixRgb(chrome.tag_bg, page), 1, track, win_w, win_h); // Stacked panes are ruled apart as columns are, where one pane's // body meets the next one's tag (acme's Border). { @@ -8197,10 +8829,10 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr const spare = g.cell_h -| g.tagline_height; if (stacked and !tag_bottom) { const room = taglineBandOffset(pane_top, win_h, g.cell_h, g.tagline_height); - try addDecor(g, gpa, x0, y, rule_x1, y + @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), chrome.border, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y, rule_x1, y + @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(room)))), chrome.border, 1, track, win_w, win_h); } else if (stacked and pane_top > 0) { const room = spare - taglineBandOffset(pane_top - 1, win_h, g.cell_h, g.tagline_height); - try addDecor(g, gpa, x0, y - @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), rule_x1, y, chrome.border, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y - @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(room)))), rule_x1, y, chrome.border, 1, track, win_w, win_h); } } @@ -8215,11 +8847,14 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr // the track edges it. const column = scrollColumn(g, chrome, rail.rect.x, layout); try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); - try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h); + if (chrome.decor_checker) |gold| + try addChecker(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, gold, dp(@floatFromInt(@max(1, chrome.decor_checker_px))), track, win_w, win_h) + else + try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h); if (rail.thumb_h != 0) { const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h; const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h; - try addDecor(g, gpa, column[0], thumb_y0, column[1] - 1, thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); + try addDecor(g, gpa, column[0], thumb_y0, column[1] - dp(1), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); } } } @@ -8233,7 +8868,242 @@ fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 { /// A rule's width: the theme's own, or one pixel. fn rulePx(chrome: *const pardes.Chrome) f32 { - return @floatFromInt(@max(1, chrome.rule_px)); + return dp(@floatFromInt(@max(1, chrome.rule_px))); +} + +/// The display's density (SDL's window display scale: 2 on a 2x screen), +/// by which every size given in logical pixels is drawn, as acme scales its +/// Border, ButtonBorder and Scrollgap (scalesize). Cells, glyphs and what is +/// measured in them are already the display's pixels and are not scaled. +/// ponytail: one window per process, so one global; per window if ever two. +var display_scale: f32 = 1; + +/// The window's display scale, or PARDES_TEST_SCALE (a harness forcing 2x); +/// a test window is 1x unless forced, whatever display it lands on, so its +/// captures are the same on any machine. +fn updateDisplayScale(window: *c.SDL_Window) void { + if (std.c.getenv("PARDES_TEST_SCALE")) |forced| { + display_scale = std.fmt.parseFloat(f32, std.mem.span(forced)) catch 1; + } else if (std.c.getenv("PARDES_TEST") != null) { + display_scale = 1; + } else { + const scale = c.SDL_GetWindowDisplayScale(window); + display_scale = if (scale > 0 and std.math.isFinite(scale)) scale else 1; + } + display_scale = std.math.clamp(display_scale, 1, 4); +} + +/// A theme's ornament as the cell shader draws it (Chrome.decor_*), for the +/// frame being built: renderFrame sets it, writing a cell marks the cells +/// it falls on, and pushLayerOpacity hands the shader its colours and +/// sizes. ponytail: one window per process, as display_scale. +var cell_decor: CellDecor = .{}; +const CellDecor = struct { + /// The page's ground, when the theme dots it. + dots: ?[3]u8 = null, + /// A file name's inks in a tag, when the theme shadows it. + title: ?[2][3]u8 = null, + /// UiUniforms after background_opacity (ui.frag.glsl): the dots' colour + /// and strength, their period and radii, the shadow's colour and offset. + uniforms: [12]f32 = @splat(0), + /// The tag plaques' bands (window pixels) and the tag grounds: a blank + /// tag cell there lets its plaque show through (tagPlaques). + plaques: [pardes.MAX_TAG_LAYERS][4]f32 = undefined, + nplaques: usize = 0, + grounds: [2][3]u8 = .{ .{ 0, 0, 0 }, .{ 0, 0, 0 } }, + plaques_off: bool = false, + + 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; + return false; + } + + fn of(chrome: *const pardes.Chrome) CellDecor { + var out: CellDecor = .{}; + if (chrome.decor_dots) |rgb| if (chrome.page) |page| { + out.dots = page; + out.uniforms[0..4].* = .{ unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), unit(chrome.decor_dot_alpha) }; + out.uniforms[4..8].* = .{ dp(@floatFromInt(@max(2, chrome.decor_dot_px))), 0.75 * display_scale, 1.25 * display_scale, 0 }; + }; + if (chrome.decor_title) |rgb| { + out.title = .{ chrome.name_ink, chrome.active_name_ink }; + out.uniforms[8..12].* = .{ unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), dp(@floatFromInt(@max(1, chrome.decor_title_px))) }; + } + return out; + } + + fn unit(v: u8) f32 { + return @as(f32, @floatFromInt(v)) / 255; + } +}; + +/// A logical pixel size in the display's pixels: rounded, at least one. +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 +/// shadow down and right that stays inside the band too, so it never falls +/// on a body, a grip or the next pane. The focused pane's box is striped, +/// its words on plates of the tag's ground (their cells stay opaque; the +/// spaces between let the stripes through, emitInstance); a column or the +/// workspace takes a quieter box, a one-pixel frame and a two-pixel shadow. +fn tagPlaques(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + 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| { + const pane = tag.kind == .tag; + 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; + if (!pane and index != 0) continue; + const plaque = plaqueBox(g, chrome, regions, tag, layout, win_w, win_h) orelse continue; + const b = plaque.border; + const sh = plaque.shadow; + const fill = if (tag.active) chrome.tag_focus_bg else chrome.tag_bg; + // The band's own ground, then the shadow, the frame and the fill. + try addDecor(g, gpa, plaque.band[0], plaque.band[1], plaque.band[2], plaque.band[3], page, 1, track, win_w, win_h); + const shadow_rgb = chrome.decor_box_shadow orelse border_rgb; + try addDecor(g, gpa, plaque.box[0] + sh[0], plaque.box[1] + sh[1], plaque.box[2] + sh[0], plaque.box[3] + sh[1], if (pane) shadow_rgb else mixRgb(shadow_rgb, page), 1, track, win_w, win_h); + try addDecor(g, gpa, plaque.box[0], plaque.box[1], plaque.box[2], plaque.box[3], border_rgb, 1, track, win_w, win_h); + const inner: [4]f32 = .{ plaque.box[0] + b, plaque.box[1] + b, plaque.box[2] - b, plaque.box[3] - b }; + if (pane and tag.active and chrome.decor_box_stripe != null) + 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 }; + +/// A tag's plaque in window pixels: its band (the rows, from after the +/// 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. +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; + 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 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))); + const text_bottom = @as(f32, @floatFromInt(last)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(last, win_h, g.cell_h, g.tagline_height) + g.tagline_height)); + const border = @min(if (pane) dp(@floatFromInt(@max(1, chrome.decor_box_border_px))) else dp(1), @floor(text_top - row_top)); + if (border < 1) return null; + // 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; + return .{ + .band = .{ x0, row_top, x1, row_bottom }, + .box = .{ x0, text_top - border, x1 - want, text_bottom + border }, + .border = border, + .shadow = .{ want, drop }, + }; } /// The two short bars on each row of a grip with no mode glyph there, @@ -8270,22 +9140,25 @@ fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, ce if ((other.kind == .body or other.kind == .tag) and other.owner != grip.owner and other.rect.y + other.rect.h == grip.rect.y and other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true; } else false; - const top_rule: f32 = if (tag_bottom) 1 else if (stacked) @floatFromInt(@max(1, @min(chrome.rule_px, taglineBandOffset(grip.rect.y, win_h, g.cell_h, g.tagline_height)))) else 0; - const bottom_rule: f32 = if (!tag_bottom and grip.rect.h == 1) 1 else 0; + const top_rule: f32 = if (tag_bottom) dp(1) else if (stacked) @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(taglineBandOffset(grip.rect.y, win_h, g.cell_h, g.tagline_height))))) else 0; + const bottom_rule: f32 = if (!tag_bottom and grip.rect.h == 1) dp(1) else 0; const x0 = button[0]; const x1 = button[2]; const y0 = button[1] + top_rule; const y1 = button[3] - bottom_rule; const ring = if (grip.active) chrome.grip_focus_ring else chrome.grip_border; - try addDecor(g, gpa, x0, y0, x1, y0 + 2, ring, 1, track, win_w, win_h); - try addDecor(g, gpa, x0, y1 - 2, x1, y1, ring, 1, track, win_w, win_h); - try addDecor(g, gpa, x0, y0 + 2, x0 + 2, y1 - 2, ring, 1, track, win_w, win_h); - try addDecor(g, gpa, x1 - 2, y0 + 2, x1, y1 - 2, ring, 1, track, win_w, win_h); + // acme's ButtonBorder: two logical pixels. + const b = dp(2); + try addDecor(g, gpa, x0, y0, x1, y0 + b, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y1 - b, x1, y1, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y0 + b, x0 + b, y1 - b, ring, 1, track, win_w, win_h); + try addDecor(g, gpa, x1 - b, y0 + b, x1, y1 - b, ring, 1, track, win_w, win_h); if (grip.dirty and !grip.active) { - try addDecor(g, gpa, x0 + 2, y0 + 2, x1 - 2, y0 + 3, band, 1, track, win_w, win_h); - try addDecor(g, gpa, x0 + 2, y1 - 3, x1 - 2, y1 - 2, band, 1, track, win_w, win_h); - try addDecor(g, gpa, x0 + 2, y0 + 3, x0 + 3, y1 - 3, band, 1, track, win_w, win_h); - try addDecor(g, gpa, x1 - 3, y0 + 3, x1 - 2, y1 - 3, band, 1, track, win_w, win_h); + const gap = b + dp(1); + try addDecor(g, gpa, x0 + b, y0 + b, x1 - b, y0 + gap, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x0 + b, y1 - gap, x1 - b, y1 - b, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x0 + b, y0 + gap, x0 + gap, y1 - gap, band, 1, track, win_w, win_h); + try addDecor(g, gpa, x1 - gap, y0 + gap, x1 - b, y1 - gap, band, 1, track, win_w, win_h); } } const x = grip.rect.x; @@ -8300,9 +9173,12 @@ fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, ce if (line != 0) continue; const center_x = @floor((button[0] + button[2]) / 2); const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, @floor((button[2] - button[0] - 6) / 2))); - try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h); - try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h); + const half_w = @max(dp(1), @min(dp(3), @floor((button[2] - button[0] - 2 * dp(3)) / 2))); + // The mode's two bars: a logical pixel thick, a pixel either + // side of the middle. + const bar = dp(1); + try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - bar - dp(1)), @floor(center_x + half_w), @floor(center_y - dp(1)), rgb, 1, track, win_w, win_h); + try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + dp(1)), @floor(center_x + half_w), @floor(center_y + dp(1) + bar), rgb, 1, track, win_w, win_h); } } } @@ -8420,6 +9296,75 @@ test "an empty column is ruled on both sides, as between two filled ones" { } } +test "at a 2x display every logical pixel is two: rules, the tag rule, the grip's ring, the rail's edge" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + try std.testing.expect(core.executeBuiltinLine(0, "Theme acme")); + for (0..40) |_| core.update(.tick); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + for ([_]f32{ 1, 2 }) |scale| { + display_scale = scale; + defer display_scale = 1; + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 16; + g.cell_h = 32; + g.tagline_width = 12; + g.tagline_height = 24; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 768, true); + const w = struct { + fn of(item: CellInstance) f32 { + return (item.x1 - item.x0) / 2 * 1280; + } + fn h(item: CellInstance) f32 { + return (item.y0 - item.y1) / 2 * 768; + } + }; + var spine: ?f32 = null; + var tag_rule: ?f32 = null; + var ring: ?f32 = null; + for (g.decor.items) |item| { + const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) }; + // The spine between the columns: black, tall. + if (std.mem.eql(u8, &rgb, &.{ 0, 0, 0 }) and w.h(item) > 300) spine = @round(w.of(item)); + // The tag rule: #8888cc, wide and thin. + if (std.mem.eql(u8, &rgb, &.{ 0x88, 0x88, 0xcc }) and w.of(item) > 200) tag_rule = @round(w.h(item)); + // The ring's left side: #8888cc, a tag row tall. + if (std.mem.eql(u8, &rgb, &.{ 0x88, 0x88, 0xcc }) and w.h(item) > 10 and w.of(item) < 8) ring = @round(w.of(item)); + } + try std.testing.expectEqual(@as(?f32, 2 * scale), spine); + try std.testing.expectEqual(@as(?f32, 1 * scale), tag_rule); + try std.testing.expectEqual(@as(?f32, 2 * scale), ring); + // The rail starts after the rule, a logical 2 in, and keeps a + // logical 3 clear of the text when the cell is narrow. + const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g)); + try std.testing.expectEqual(40 * 16 + 2 * scale, column[0]); + g.cell_w = 12; + defer g.cell_w = 16; + const narrow = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g)); + try std.testing.expectEqual(@as(f32, 40 * 12 + 2 * scale), narrow[0]); + try std.testing.expectEqual(@as(f32, 40 * 12) + 12 * @as(f32, @floatFromInt(config.GUTTER)) - 3 * scale, narrow[1]); + g.cell_w = 8; + // A bar cursor is an eighth of its cell, never under a logical pixel + // (an 8 px cell: one pixel, two at 2x). + const saved = s.cursor; + defer s.cursor = saved; + s.cursor = .{ .x = 60, .y = 10, .bar = true }; + var boxes: [max_cursors]CursorBox = undefined; + const n = cursorBoxes(&g, s, fixedCellLayout(&g), 768, &boxes); + const bar = for (boxes[0..n]) |box| { + if (box.bar and box.x0 == 60 * 8) break box; + } else return error.NoBarCursor; + try std.testing.expectEqual(scale, bar.x1 - bar.x0); + } +} + test "decor is read from the regions, each pane's in its own group" { const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); @@ -8483,6 +9428,140 @@ test "decor is read from the regions, each pane's in its own group" { try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count); } +test "a pane on the move casts a shadow on the other panes' bodies alone, most at mid-way" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + try std.testing.expect(core.executeBuiltinLine(0, "Theme acme")); + for (0..40) |_| core.update(.tick); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + 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; + const layout = fixedCellLayout(&g); + const serial = core.panes[1].?.serial; + var strengths: [3]f32 = @splat(0); + for ([_]u16{ 0, 6, 12 }, 0..) |frame, i| { + g.decor.clearRetainingCapacity(); + const moving: pardes.animation.Track = .{ .pane = 1, .serial = serial, .phase = .moving, .effect = .slide, .frame = frame, .frame_count = 13, .from = .{ .x = 0, .y = 1, .w = 40, .h = 23 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 23 } }; + var groups = makeGroups(&.{moving}, true); + if (!moving.active()) groups = makeGroups(&.{}, true); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, false); + for (g.decor.items) |item| { + if (item.effect & soft_shadow_bit == 0) continue; + strengths[i] = @max(strengths[i], item.fr); + // Clipped to pane 0's body or rail, never pane 1's, never a tag. + try std.testing.expect(item.clip_x1 >= item.clip_x0); + for (s.regionList()) |region| { + if (region.kind != .tag and region.kind != .grip and !(region.owner == 1 and (region.kind == .body or region.kind == .rail))) continue; + const r = bodyPixels(region, layout); + try std.testing.expect(item.clip_x1 <= r[0] or item.clip_x0 >= r[2] or item.clip_y1 <= r[1] or item.clip_y0 >= r[3]); + } + } + } + // Rising as it sets out, gone as it lands. + try std.testing.expectEqual(@as(f32, 0), strengths[0]); + try std.testing.expect(strengths[1] > 0.05); + 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 +/// it and the tag beside it are one rectangle; past the list's end the last +/// grows to cover the rest. +fn postSpare(surface: *const pardes.Surface, layout: CellLayout, out: *[Post.max_spare][4]f32) usize { + var n: usize = 0; + for (surface.regionList()) |region| { + switch (region.kind) { + .grip, .tag, .notice, .column_grip, .column_tag, .workspace_tag => {}, + else => continue, + } + if (region.rect.w == 0 or region.rect.h == 0) continue; + const rect: [4]f32 = .{ + @as(f32, @floatFromInt(region.rect.x)) * layout.w, + @as(f32, @floatFromInt(region.rect.y)) * layout.h, + @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w, + @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h, + }; + const joined = for (out[0..n]) |*other| { + // The same rows, a gap cell or two apart (a grip, the gap after + // it, its tag): one band. + const gap = 2 * layout.w + 0.5; + if (other[1] == rect[1] and other[3] == rect[3] and rect[0] <= other[2] + gap and other[0] <= rect[2] + gap) { + other.* = .{ @min(other[0], rect[0]), rect[1], @max(other[2], rect[2]), rect[3] }; + break true; + } + } else false; + if (joined) continue; + if (n == out.len) { + const last = &out[n - 1]; + last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) }; + continue; + } + out[n] = rect; + n += 1; + } + return n; +} + const CursorBox = Post.Frame.Cursor; const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS; @@ -8501,7 +9580,7 @@ fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout const height = std.math.clamp(if (role == .tagline) @as(f32, @floatFromInt(g.tagline_height)) else layout.h, 1.0, layout.h); const x0 = @as(f32, @floatFromInt(cursor.x)) * layout.w; const y0 = @as(f32, @floatFromInt(cursor.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(cursor.y, win_h, @intFromFloat(layout.h), @intFromFloat(height)))); - out[n] = .{ .x0 = x0, .y0 = y0, .x1 = x0 + if (cursor.bar) @max(1.0, width / 8.0) else width, .y1 = y0 + height, .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar }; + out[n] = .{ .x0 = x0, .y0 = y0, .x1 = x0 + if (cursor.bar) @max(dp(1), width / 8.0) else width, .y1 = y0 + height, .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar }; n += 1; } } @@ -8518,7 +9597,7 @@ fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; const x = left + @as(f32, @floatFromInt(cursor.x)) * width; const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x]; - out[n] = .{ .x0 = x, .y0 = top, .x1 = x + if (cursor.bar) @max(1, width / 8) else width, .y1 = @min(bottom, top + height), .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar }; + out[n] = .{ .x0 = x, .y0 = top, .x1 = x + if (cursor.bar) @max(dp(1), width / 8) else width, .y1 = @min(bottom, top + height), .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar }; n += 1; } for (surface.tagLayers()) |*layer| { @@ -8528,11 +9607,12 @@ fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout const width: f32 = @floatFromInt(g.tagline_width); const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; - const x = left + @as(f32, @floatFromInt(cursor.x)) * width; - if (x >= right) continue; + // Past a full row's end the caret sits at the tag's right edge, as + // the grid's does (and the core's cursor box, draw.focusedCursorBox). + const x = @min(left + @as(f32, @floatFromInt(cursor.x)) * width, right - if (cursor.bar) @max(dp(1), width / 8) else width); const y = layer.viewport.y + cursor.y; const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))); - out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(1, width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar }; + out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(dp(1), width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar }; n += 1; } return n; @@ -8749,7 +9829,8 @@ fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) bo } fn pushLayerOpacity(cmd: *c.SDL_GPUCommandBuffer, pass: ?*c.SDL_GPURenderPass, opacity: f32) void { - const uniforms = [4]f32{ opacity, 0, 0, 0 }; + var uniforms = [4]f32{ opacity, 0, 0, 0 } ++ @as([12]f32, @splat(0)); + uniforms[4..].* = cell_decor.uniforms; c.SDL_PushGPUFragmentUniformData(cmd, 0, &uniforms, @sizeOf(@TypeOf(uniforms))); // The source alpha is geometric/image coverage, not window opacity. Its // alpha contribution is multiplied by this constant separately, so a |
