diff options
Diffstat (limited to 'src/gui/gui.zig')
| -rw-r--r-- | src/gui/gui.zig | 934 |
1 files changed, 894 insertions, 40 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 02f7bd9e..5dfacee9 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -73,6 +73,24 @@ const p9_arrow_set = [32]u8{ 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, }; +/// acme's boxcursor (acme.c:956), while a grip carries a pane or column. +const p9_box_clr = [32]u8{ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, + 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +}; +const p9_box_set = [32]u8{ + 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, + 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, + 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, + 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, 0x00, 0x00, +}; +const p9_box_mask = blk: { + var m: [32]u8 = undefined; + for (&m, p9_box_set, p9_box_clr) |*b, s, cl| b.* = s | cl; + break :blk m; +}; const p9_arrow_mask = blk: { var m: [32]u8 = undefined; for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; @@ -487,6 +505,8 @@ const initial_image_capacity: u32 = pardes.MAX_PANES; const old_layer_bit: u32 = 0x8000_0000; 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; const SavedImagePlace = struct { key: pardes.ImageCacheKey, @@ -1847,6 +1867,7 @@ const Gui = struct { pointer_present: bool = false, arrow_cursor: ?*c.SDL_Cursor = null, link_cursor: ?*c.SDL_Cursor = null, + box_cursor: ?*c.SDL_Cursor = null, pointer_shape: pardes.Surface.PointerShape = .arrow, pointer_mapped: bool = false, pointer_cell: ?MouseCell = null, @@ -1855,6 +1876,10 @@ const Gui = struct { /// Pixels the capture holds past the grid (PARDES_TEST_PAD). capture_pad: u32 = 0, capture_dir: []const u8 = "", + /// PARDES_TEST_SERIES: every captured frame is kept too, numbered, for a + /// feel review's frame series (docs/render-pipeline.md §8.4). + capture_series: bool = false, + captured: u32 = 0, capture_tex: ?*c.SDL_GPUTexture = null, capture_tex_w: u32 = 0, capture_tex_h: u32 = 0, @@ -2105,7 +2130,13 @@ const Cover = packed struct(u16) { focus: bool = false, /// At a column's left edge, where a tag's anchor is inset. anchor: bool = false, - _: u7 = 0, + /// In a dirty pane's grip. + dirty: bool = false, + /// In a pane's grip (not a column's). + pane_grip: bool = false, + /// In a grip's first row, where its button is. + grip_top: bool = false, + _: u4 = 0, }; /// Marks the grid cells a body or tag layer covers, which the layers paint @@ -2137,10 +2168,13 @@ fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes. if (region.kind == .column_grip) bar = region.rect; } for (regions) |region| switch (region.kind) { - .grip => { + .grip, .column_grip => { mark.rect(cover, cols, rows, region.rect, "grip", true); for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset))); - if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + if (region.kind == .grip and region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + if (region.dirty) mark.rect(cover, cols, rows, region.rect, "dirty", true); + if (region.kind == .grip) mark.rect(cover, cols, rows, region.rect, "pane_grip", true); + mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = region.rect.y, .w = region.rect.w, .h = @min(1, region.rect.h) }, "grip_top", true); }, .tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true), .column => { @@ -2292,6 +2326,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor); const link_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); defer if (link_cursor) |cursor| c.SDL_DestroyCursor(cursor); + // Its hot spot the middle, as acme's {-7, -7} offset puts it. + const box_cursor = c.SDL_CreateCursor(&p9_box_set, &p9_box_mask, 16, 16, 7, 7); + defer if (box_cursor) |cursor| c.SDL_DestroyCursor(cursor); if (arrow_cursor) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); @@ -2427,6 +2464,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .window = window, .arrow_cursor = arrow_cursor, .link_cursor = link_cursor, + .box_cursor = box_cursor, .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, @@ -2457,6 +2495,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa), .capture = test_mode, .capture_pad = if (test_mode) envU16(env, "PARDES_TEST_PAD") orelse 0 else 0, + .capture_series = test_mode and env.get("PARDES_TEST_SERIES") != null, .capture_dir = capture_dir orelse "", .soft_present = soft_present, .transparent = config.gui_transparent, @@ -2606,6 +2645,7 @@ fn localSession( .fs = fs, .test_mode = test_mode, .test_clock = if (test_mode) host_io.testClock() else null, + .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(); const host = shell.host(); @@ -3892,6 +3932,8 @@ const Shell = struct { retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode. test_clock: ?u64 = null, + /// The display rate a virtual clock stands in for (PARDES_TEST_HZ). + test_hz: u64 = 60, fn reap(s: *Shell) void { const now = shellClock(); @@ -4142,10 +4184,17 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); - // The core's wake ran out: a virtual clock moves exactly there. - if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| { - virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); - }; + // A virtual clock: while a render-time animation moves, each loop + // is a presented frame and moves it by one display frame + // (PARDES_TEST_HZ), events or not, as a real display's present + // would; otherwise a wait that ran out moves it exactly to the + // core's wake. + if (s.test_clock) |*virtual| { + 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.*); + } if (got) { // A run of queued motions is one move to its last position (the // handler reads only absolute x/y and held buttons): acting on @@ -5269,7 +5318,11 @@ fn renderFrame( ) !bool { if (surface.pointer_shape != g.pointer_shape) { g.pointer_shape = surface.pointer_shape; - const cursor = if (g.pointer_shape == .link) g.link_cursor else g.arrow_cursor orelse c.SDL_GetDefaultCursor(); + const cursor = switch (g.pointer_shape) { + .link => g.link_cursor, + .box => g.box_cursor orelse g.arrow_cursor, + else => g.arrow_cursor orelse c.SDL_GetDefaultCursor(), + }; if (cursor) |value| _ = c.SDL_SetCursor(value); } g.scene_target_failed = false; @@ -5498,7 +5551,13 @@ fn renderFrame( const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); // A notice floats over the panes, moving with its own. const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else track_index; + const start = cell_next[group_index]; emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); + // A notice dropping past its row (a flavour's overshoot) stays + // inside its pane's body. + if (layer.kind == .notice and layer.slide > 0) if (bodyBottom(surface, layer.id, layout)) |bottom| { + for (instances[start..cell_next[group_index]]) |*instance| clipInstance(instance, .{ -1e9, -1e9, 1e9, bottom }, win_w, win_h); + }; } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { const track = group.track.?; @@ -5809,7 +5868,6 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand var notices = false; for (0..32) |step| { core.settings.tag_bottom = step & 1 != 0; - core.settings.column_tags = step & 2 != 0; if (step & 3 == 0) try std.testing.expect(core.executeBuiltinLine(2, "Collapse")); core.leader_on = step & 4 != 0; if (step & 8 != 0) { @@ -5867,6 +5925,14 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } } +/// The bottom of pane `owner`'s body, in window pixels. +fn bodyBottom(surface: *const pardes.Surface, owner: u16, layout: CellLayout) ?f32 { + for (surface.regionList()) |region| { + if (region.kind == .body and region.owner == owner) return @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h; + } + return null; +} + fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 { const pixels = @as(u32, layer.viewport.w) * g.cell_w; return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width); @@ -6004,6 +6070,67 @@ fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); } +/// acme's Scrollwid column at a pane's or column's left edge, in window +/// pixels: where its button sits over its scrollbar, one width for both +/// (`rail_px` at acme's 17px font, scaled with the tagline, and always +/// short of the cell so the text keeps a gap), right of the column's rule. +fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: CellLayout) [2]f32 { + const rule: f32 = if (col == 0) 0 else rulePx(chrome); + 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)); + return .{ x0, x0 + w }; +} + +/// acme's button for the grip whose first cell is (col, row), in window +/// pixels: the scroll column wide, its tag row tall, from the rule above to +/// the rule below, so button and scrollbar read as one strip. +fn gripButton(g: *const Gui, chrome: *const pardes.Chrome, col: u16, row: u16, layout: CellLayout, win_h: f32) [4]f32 { + _ = win_h; + const x = scrollColumn(g, chrome, col, layout); + const y0 = @round(@as(f32, @floatFromInt(row)) * layout.h); + return .{ x[0], y0, x[1], @round(@as(f32, @floatFromInt(row + 1)) * layout.h) }; +} + +/// A pane's button fill, from its region's state, not the grid's cells +/// (which are drawn for cell shells): dirty, focused, or the tag's ground. +fn gripFill(chrome: *const pardes.Chrome, focus: bool, dirty: bool) [3]u8 { + if (dirty) return chrome.grip_dirty; + return if (focus) chrome.grip_focus_ring else chrome.tag_bg; +} + +fn inkOn(bg: [3]u8) [3]u8 { + const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10; + return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; +} + +/// Cuts an instance's quad to a pixel rectangle, its atlas window with it so +/// the glyph neither moves nor stretches; nothing left, an empty quad. +fn clipInstance(instance: *CellInstance, r: [4]f32, win_w: f32, win_h: f32) void { + const x0 = (instance.x0 + 1) / 2 * win_w; + const x1 = (instance.x1 + 1) / 2 * win_w; + const y0 = (1 - instance.y0) / 2 * win_h; + const y1 = (1 - instance.y1) / 2 * win_h; + const left = @max(0, r[0] - x0); + const right = @max(0, x1 - r[2]); + const top = @max(0, r[1] - y0); + const bottom = @max(0, y1 - r[3]); + if (x1 - right <= x0 + left or y1 - bottom <= y0 + top) { + instance.x1 = instance.x0; + instance.y1 = instance.y0; + return; + } + instance.x0 += left / win_w * 2; + instance.u0 += left / atlas_w; + instance.x1 -= right / win_w * 2; + instance.u1 -= right / atlas_w; + instance.y0 -= top / win_h * 2; + instance.v0 += top / atlas_h; + instance.y1 += bottom / win_h * 2; + instance.v1 -= bottom / atlas_h; +} + fn emitSurfaceCell( g: *Gui, chrome: *const pardes.Chrome, @@ -6036,33 +6163,82 @@ fn emitSurfaceCell( emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page); next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. - emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); + // A pane's grip cells are the cell shells' form (a dirty `*`): the + // button draws its own glyph over a blank of them. + var blank = line[at]; + blank.text[0] = ' '; + blank.len = 1; + if (here.pane_grip) + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, (&blank)[0..1], 0, null, old_layer, false, page) + else + emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); // A grip's glyph is the cell at its left edge, and a blank one is // no glyph: its marks are decor. const glyph = &line[at - @min(here.grip_offset, at)]; - const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; + const grip = here.grip and here.grip_top and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; const offset: u16 = if (grip) @min(here.grip_offset, at) else 0; // An anchor starts at its column's rule: its left edge is inset by // its top margin so the band frames it on the left as above and // below, and its mark moves right by half that to stay centered. const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0; + // A grip is acme's button: its fill is this cell's ground, cut to + // the button (and a dirty one's inset a pixel inside its ring, so + // the ring reads round it), its glyph centered in it. + const button = if (here.grip and here.grip_top) gripButton(g, chrome, col - @min(here.grip_offset, @as(u16, @intCast(at))), row, body_layout, win_h) else null; if (grip) { // A grip is one glyph centered across the gutter, not text: it is // looked up by its codepoint's glyph, never shaped. const drawn = glyphForCodepoint(g, cellCodepoint(glyph)); const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true }); const slot = cached orelse g.space_slot; - const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; + const b = button.?; + const center = (b[0] + b[2]) / 2 - @as(f32, @floatFromInt(col - offset)) * body_layout.w; + const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) + @as(f32, @floatFromInt(config.GUTTER * g.cell_w)) / 2 - center else 0; const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page); - instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; - instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; - instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; + const ink = if (here.pane_grip) inkOn(gripFill(chrome, here.focus, here.dirty)) else resolved.fg; + instances[next.*].fr = @as(f32, @floatFromInt(ink[0])) / 255; + instances[next.*].fg = @as(f32, @floatFromInt(ink[1])) / 255; + instances[next.*].fb = @as(f32, @floatFromInt(ink[2])) / 255; instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h; } instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; - if (inset > 0 and (!grip or offset == 0)) { + if (button) |b| { + // acme's button fills its tag row from the rule above to the rule + // below: the underlay is its fill, cut to its columns; a pane's + // from its region's state, not the grid's cell form (whose dirty + // `*` is the cell shells'), a column's its cell's own ground. + const base = &instances[next.* - 1]; + const own: [3]f32 = .{ instances[next.*].br, instances[next.*].bg, instances[next.*].bb }; + if (here.pane_grip) { + const fill = gripFill(chrome, here.focus, here.dirty); + base.br = @as(f32, @floatFromInt(fill[0])) / 255; + base.bg = @as(f32, @floatFromInt(fill[1])) / 255; + base.bb = @as(f32, @floatFromInt(fill[2])) / 255; + } else { + base.br = own[0]; + base.bg = own[1]; + base.bb = own[2]; + } + base.effect &= ~clear_bg_bit; + clipInstance(base, .{ b[0], -1e9, b[2], 1e9 }, win_w, win_h); + // Over it, only its own glyph; the band round it and the ring + // are decor (gripDecor). + if (here.pane_grip and !grip) { + instances[next.*].u0 = @as(f32, @floatFromInt(g.space_slot.u)) / atlas_w; + instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; + instances[next.*].v0 = @as(f32, @floatFromInt(g.space_slot.v)) / atlas_h; + instances[next.*].v1 = @as(f32, @floatFromInt(g.space_slot.v + g.tagline_height)) / atlas_h; + } + // Its ground is the fill again (a see-through ground would be + // written, not blended, in an opaque window). + instances[next.*].br = base.br; + instances[next.*].bg = base.bg; + instances[next.*].bb = base.bb; + instances[next.*].effect &= ~clear_bg_bit; + clipInstance(&instances[next.*], b, win_w, win_h); + } else if (inset > 0) { instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; } @@ -6073,6 +6249,125 @@ fn emitSurfaceCell( next.* += 1; } +test "every pixel of a grip column is band, button or ring, and the button sits over the scrollbar" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 20 }); + defer core.deinit(); + const pane = try core.setTestFile("text\n" ** 60); + pane.tag.own = try gpa.dupe(u8, "second\nthird"); + core.newScratchBelow(0); + for ([_]struct { w: u32, h: u32, tag: u32 }{ .{ .w = 10, .h = 20, .tag = 12 }, .{ .w = 17, .h = 35, .tag = 30 }, .{ .w = 9, .h = 19, .tag = 15 }, .{ .w = 12, .h = 24, .tag = 24 } }) |m| for ([_]bool{ false, true }) |bottom| for ([_]usize{ 0, 1 }) |focus| for ([_]bool{ false, true }) |dirty| { + core.settings.tag_bottom = bottom; + core.active = focus; + pane.file.?.revision = pane.file.?.saved_revision + @intFromBool(dirty); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + var g: Gui = undefined; + g.words = .{}; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = m.w; + g.cell_h = m.h; + g.tagline_width = m.w; + g.tagline_height = m.tag; + g.space_slot = .{ .u = 0, .v = 0 }; + const layout = fixedCellLayout(&g); + const win_w: f32 = @floatFromInt(60 * m.w); + const win_h: f32 = @floatFromInt(20 * m.h); + const cover = try gpa.alloc(Cover, @as(usize, s.cols) * s.rows); + defer gpa.free(cover); + coverFrame(cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList()); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, layout, win_w, win_h, true); + const grip = s.region(.grip, 0).?; + // The grip column's pixels, band first, then its cells, then decor. + const x_px: u32 = grip.rect.x * m.w; + const w_px: u32 = config.GUTTER * m.w; + const y_px: u32 = grip.rect.y * m.h; + const h_px: u32 = grip.rect.h * m.h; + const pixels = try gpa.alloc(?[3]f32, w_px * h_px); + defer gpa.free(pixels); + @memset(pixels, null); + const paint = struct { + fn quad(into: []?[3]f32, x0n: f32, y0n: f32, x1n: f32, y1n: f32, rgb: [3]f32, ww: f32, wh: f32, ox: u32, oy: u32, pw: u32, ph: u32) void { + const px0: i64 = @intFromFloat(@round((x0n + 1) / 2 * ww)); + const px1: i64 = @intFromFloat(@round((x1n + 1) / 2 * ww)); + const py0: i64 = @intFromFloat(@round((1 - y0n) / 2 * wh)); + const py1: i64 = @intFromFloat(@round((1 - y1n) / 2 * wh)); + var y = @max(py0, oy); + while (y < @min(py1, oy + ph)) : (y += 1) { + var x = @max(px0, ox); + while (x < @min(px1, ox + pw)) : (x += 1) into[@intCast((y - oy) * pw + (x - ox))] = rgb; + } + } + }; + var instances: [4]CellInstance = undefined; + for (0..grip.rect.h) |line| for (0..config.GUTTER) |dx| { + const col: u16 = grip.rect.x + @as(u16, @intCast(dx)); + const row: u16 = grip.rect.y + @as(u16, @intCast(line)); + const at = @as(usize, row) * s.cols; + var next: u32 = 0; + emitSurfaceCell(&g, &s.chrome, &instances, &next, col, row, layout, win_w, win_h, null, s.cells[at..][0..s.cols], cover[at..][0..s.cols], col, null, false, false, Ground.opaqueRgb(bg_default)); + for (instances[0..next]) |i| paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px); + }; + for (g.decor.items) |i| if (i.fr >= 1 and i.clip_x1 < i.clip_x0) paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px); + const rgb = struct { + fn of(v: [3]u8) [3]f32 { + return .{ @as(f32, @floatFromInt(v[0])) / 255, @as(f32, @floatFromInt(v[1])) / 255, @as(f32, @floatFromInt(v[2])) / 255 }; + } + }.of; + const chrome = &s.chrome; + const band = if (focus == 0) chrome.tag_focus_bg else chrome.tag_bg; + // Band, fills, rings, the rules between stacked panes and under the + // tag, and the mode marks' ink (the grip's first cell's). + const ink = inkOn(gripFill(chrome, grip.active, grip.dirty)); + const allowed = [_][3]f32{ rgb(band), rgb(core.chromeTheme().box), rgb(pardes.draw.boxDirty(core)), rgb(chrome.grip_border), rgb(chrome.grip_focus_ring), rgb(chrome.border), rgb(ink), rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)) }; + for (pixels, 0..) |pixel, n| { + const got = pixel orelse return error.TestUnpaintedGripPixel; + const known = for (allowed) |a| { + if (std.meta.eql(a, got)) break true; + } else false; + // Under the first row there is no button: band and rules only. + if (n / w_px >= m.h and n / w_px < h_px - 1 and !std.meta.eql(got, rgb(band)) and !std.meta.eql(got, rgb(chrome.border))) + return error.TestButtonUnderFirstRow; + if (!known) std.debug.print("sliver {any} at {d},{d} of {d}x{d} (metrics {d}x{d}/{d}, bottom {}, focus {d}, dirty {}) page {any}\n", .{ got, n % w_px, n / w_px, w_px, h_px, m.w, m.h, m.tag, bottom, focus, dirty, s.chrome.page }); + try std.testing.expect(known); + } + // The rule under the tag runs unbroken across the grip column. + if (!bottom) for (pixels[(h_px - 1) * w_px ..][0..w_px]) |pixel| + try std.testing.expectEqual(rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)), pixel.?); + // The button's edges are the scrollbar's. + const button = gripButton(&g, chrome, grip.rect.x, grip.rect.y, layout, win_h); + const column = scrollColumn(&g, chrome, s.region(.rail, 0).?.rect.x, layout); + try std.testing.expectEqual(column[0], button[0]); + try std.testing.expectEqual(column[1], button[2]); + }; + pane.file.?.revision = pane.file.?.saved_revision; +} + +test "a rising pane past its mark stays inside its box" { + // Bouncy, a vertical rise at its highest overshoot: drawn past its box, + // clipped to it. + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + var track: pardes.animation.Track = .{ .serial = 3, .phase = .opening, .effect = .vertical, .motion = @intFromEnum(pardes.animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = 0, .y = 10, .w = 40, .h = 10 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 10 } }; + var peak: u16 = 0; + var highest: f32 = 1e9; + while (track.frame < track.frames()) : (track.frame += 1) { + if (track.presented().y < highest) { + highest = track.presented().y; + peak = track.frame; + } + } + track.frame = peak; + try std.testing.expect(track.presented().y < 0); + var instance: CellInstance = std.mem.zeroes(CellInstance); + setTransitionFields(&instance, track, fixedCellLayout(&g), 800, 480, 0); + try std.testing.expectEqual(@as(f32, 0), instance.clip_y0); + try std.testing.expectEqual(@as(f32, 200), instance.clip_y1); +} + test "small tagline fonts leave no background gutters between adjacent tags" { const chrome: pardes.Chrome = .{}; var g: Gui = undefined; @@ -6760,9 +7055,9 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0)); try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0)); try std.testing.expect(!cover[2].grip); - // A column's grip has no glyph of a pane's. + // A column's grip is a button too, cut the same way (it has no glyph). coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}); - try std.testing.expect(!cover[0].grip); + try std.testing.expect(cover[0].grip); coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); const chrome: pardes.Chrome = .{}; @@ -7300,6 +7595,8 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest"); var name: [24]u8 = undefined; if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable); + if (g.capture_series) try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "frame-{d:0>5}", .{g.captured}) catch unreachable); + g.captured +%= 1; } fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void { @@ -7374,6 +7671,303 @@ fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, try g.decor.append(gpa, instance); } +/// 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. +const Shadow = struct { + // A key light above: the drop is most of the blur, so the shadow hangs + // below and to the right and barely reaches over the caster's top. + const sigma: f32 = 0.42; + const drop: f32 = 0.34; + const side: f32 = 0.1; + const strength: f32 = 0.42; +}; + +/// A soft rectangle around `caster` (window pixels), blurred by `sigma` +/// and moved by (dx, dy): black is a shadow, a light colour a rim or a +/// glow. The caster's own rectangle is left alone, and `track` moves it all +/// as its caster moves. +fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f32, dy: f32, strength: f32, rgb: [3]u8, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + if (strength <= 0.002) return; + const sigma = @max(0.5, sigma_in); + const x0 = caster[0] + dx - 3 * sigma; + const y0 = caster[1] + dy - 3 * sigma; + const x1 = caster[2] + dx + 3 * sigma; + const y1 = caster[3] + dy + 3 * sigma; + const w = x1 - x0; + const ht = y1 - y0; + if (w <= 0 or ht <= 0) return; + // In the quad's own 0..1 space (decor.frag.glsl): sigma per axis, the + // caster's offset from its shadow, the strength. + var instance: CellInstance = .{ + .x0 = (x0 / win_w) * 2.0 - 1.0, + .y0 = 1.0 - (y0 / win_h) * 2.0, + .x1 = (x1 / win_w) * 2.0 - 1.0, + .y1 = 1.0 - (y1 / win_h) * 2.0, + .u0 = sigma / w, + .v0 = sigma / ht, + .u1 = sigma / w, + .v1 = sigma / ht, + .fr = strength, + .fg = dx / w, + .fb = dy / ht, + .br = @as(f32, @floatFromInt(rgb[0])) / 255, + .bg = @as(f32, @floatFromInt(rgb[1])) / 255, + .bb = @as(f32, @floatFromInt(rgb[2])) / 255, + }; + setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0); + instance.effect |= soft_shadow_bit; + try g.decor.append(gpa, instance); +} + +/// How far each lift style may go on this theme without its text falling +/// below min(its contrast, 4.5): a shadow darkens, the others lighten. +const LiftCeilings = struct { + shadow: f32, + white: f32, + /// A rim is light on a dark page and shade on a light one, where light + /// would not show. + rim: [3]u8, + + /// Over the text on the page, and over a selection's text on its own + /// ground: the lower of the two. + fn of(chrome: *const pardes.Chrome) LiftCeilings { + const fg = chrome.fg orelse fg_default; + const bg = chrome.page orelse bg_default; + return .{ + .shadow = @min(shadowCeiling(fg, bg), shadowCeiling(chrome.sel_fg, chrome.sel_bg)), + .white = @min(lightenCeiling(fg, bg, .{ 255, 255, 255 }), lightenCeiling(chrome.sel_fg, chrome.sel_bg, .{ 255, 255, 255 })), + .rim = if (luminance(bg) < 0.18) .{ 255, 255, 255 } else .{ 0, 0, 0 }, + }; + } +}; + +/// What stands a lifted part off the page, `lift` 0 to 1 (a pane's spring, +/// a notice's 1), in the style chosen: a soft shadow, or a rim along the +/// top of the focused pane's tag rows (`rim_edge`, a pane's only: +/// never a notice's). +fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, ceilings: LiftCeilings, caster: [4]f32, rim_edge: ?[3]f32, bodies: []const [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + if (lift <= 0.001) return; + const h: f32 = @floatFromInt(g.cell_h); + // The core sends `auto` already resolved (draw.liftStyle). + const shadow = switch (style) { + .off => return, + .shadow, .auto => true, + .rim => false, + }; + if (shadow) { + // Nothing of a shadow may touch a focus indicator (docs/effects.md): + // it falls on pane bodies and rails, never a tag, a grip or a header. + const first = g.decor.items.len; + try addSoft(g, gpa, caster, 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, bodies); + } + // A hairline just above the focused tag rows, never on them: it adds to + // the indicator without changing its colour. + 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); + }; +} + +/// A rim's strength, in the §8.2 budget for coloured light. +const Lift = struct { + const rim: f32 = 0.35; +}; + +/// A body region in window pixels. +fn bodyPixels(region: pardes.Region, layout: CellLayout) [4]f32 { + return .{ + @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, + }; +} + +/// Replaces the decor from `first` on with a copy per pane body it reaches, +/// each clipped to that body (and to what a vertical transition already +/// clipped it to): the lift then never reaches a tag, a grip or a header. +fn clipToBodies(g: *Gui, gpa: std.mem.Allocator, first: usize, bodies: []const [4]f32) !void { + const added = g.decor.items.len - first; + if (added == 0) return; + var own: [8]CellInstance = undefined; + const n = @min(added, own.len); + @memcpy(own[0..n], g.decor.items[first..][0..n]); + g.decor.shrinkRetainingCapacity(first); + for (own[0..n]) |instance| for (bodies) |body| { + var copy = instance; + const vertical = copy.clip_x1 >= copy.clip_x0; + copy.clip_x0 = if (vertical) @max(copy.clip_x0, body[0]) else body[0]; + copy.clip_y0 = if (vertical) @max(copy.clip_y0, body[1]) else body[1]; + copy.clip_x1 = if (vertical) @min(copy.clip_x1, body[2]) else body[2]; + copy.clip_y1 = if (vertical) @min(copy.clip_y1, body[3]) else body[3]; + if (copy.clip_x1 <= copy.clip_x0 or copy.clip_y1 <= copy.clip_y0) continue; + try g.decor.append(gpa, copy); + }; +} + + +/// Like shadowCeiling for a light colour laid over at `a` (sRGB blend, as +/// the decor pipeline blends a colour that is not black): the largest `a` +/// that keeps text and page apart by min(their contrast, 4.5). +fn lightenCeiling(fg: [3]u8, bg: [3]u8, rgb: [3]u8) f32 { + const contrast = struct { + fn of(a: [3]u8, b: [3]u8) f32 { + const la = luminance(a); + const lb = luminance(b); + return (@max(la, lb) + 0.05) / (@min(la, lb) + 0.05); + } + fn blend(under: [3]u8, over: [3]u8, a: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - a) + @as(f32, @floatFromInt(y)) * a)); + return out; + } + }; + const target = @min(contrast.of(fg, bg), 4.5); + var lo: f32 = 0; + var hi: f32 = 1; + for (0..24) |_| { + const mid = (lo + hi) / 2; + if (contrast.of(contrast.blend(fg, rgb, mid), contrast.blend(bg, rgb, mid)) >= target - 1e-3) lo = mid else hi = mid; + } + return lo; +} + +/// sRGB's relative luminance (WCAG). +fn luminance(rgb: [3]u8) f32 { + var sum: f32 = 0; + for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { + const v = @as(f32, @floatFromInt(channel)) / 255; + sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4)); + } + return sum; +} + +/// How dark a shadow may make the page and its text, both together, before +/// the pair drops below its own contrast or 4.5, whichever is lower (§8.2): +/// darkening in linear light by k leaves (k L1 + .05) / (k L2 + .05). +fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 { + const a = luminance(fg); + const b = luminance(bg); + const light = @max(a, b); + const dark = @min(a, b); + const target = @min((light + 0.05) / (dark + 0.05), 4.5); + const room = light - target * dark; + if (room <= 0) return 0; + return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1); +} + +test "a rim never lightens a native theme's text below its contrast or 4.5" { + for (pardes.themes) |theme| { + const fg = theme.fg orelse fg_default; + const bg = theme.bg orelse bg_default; + for ([_][3]u8{.{ 255, 255, 255 }}) |light| { + const a = @min(Lift.rim, lightenCeiling(fg, bg, light)); + const blend = struct { + fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - t) + @as(f32, @floatFromInt(y)) * t)); + return out; + } + }.of; + const ratio = struct { + fn of(x: [3]u8, y: [3]u8) f32 { + return (@max(luminance(x), luminance(y)) + 0.05) / (@min(luminance(x), luminance(y)) + 0.05); + } + }.of; + try std.testing.expect(ratio(blend(fg, light, a), blend(bg, light, a)) >= @min(ratio(fg, bg), 4.5) - 2e-3); + } + } +} + +test "a lift never touches a tag, a grip or a header, on a light theme or a dark one" { + const gpa = std.testing.allocator; + for ([_][]const u8{ "forge", "daybreak" }) |name| { + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + var line: [64]u8 = undefined; + try std.testing.expect(core.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Theme {s}", .{name}))); + for (0..40) |_| core.update(.tick); + for (std.enums.values(pardes.config.Runtime.LiftStyle)) |style| { + core.settings.lift = style; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a floating notice")); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + core.advance(core.now_ns + 2 * std.time.ns_per_s); + const settled = try core.render(core.scratch.allocator()); + _ = s; + // The focus indicator itself: the focused pane's tag is not the + // others' colour, whatever the lift. + var active: ?[3]u8 = null; + var other: ?[3]u8 = null; + for (settled.tagLayers()) |layer| if (layer.rows != 0 and layer.kind == .pane) { + if (layer.id == core.active) active = layer.bg else other = layer.bg; + }; + try std.testing.expect(!std.mem.eql(u8, &active.?, &other.?)); + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.decor = .empty; + defer g.decor.deinit(gpa); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, settled, &groups, fixedCellLayout(&g), 800, 480, true); + var lifted: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 < item.clip_x0) continue; + lifted += 1; + // Inside a body or a rail, all of it. + const inside = for (settled.regionList()) |region| { + if (region.kind != .body and region.kind != .rail) continue; + const r = bodyPixels(region, fixedCellLayout(&g)); + if (item.clip_x0 >= r[0] and item.clip_y0 >= r[1] and item.clip_x1 <= r[2] and item.clip_y1 <= r[3]) break true; + } else false; + try std.testing.expect(inside); + } + const light_page = std.mem.eql(u8, name, "daybreak"); + try std.testing.expectEqual(style == .shadow or (style == .auto and light_page), lifted != 0); + // A rim is one hairline, just above the focused pane's tag row. + var rims: usize = 0; + for (g.decor.items) |item| { + if (item.clip_x1 >= item.clip_x0) continue; + const top = (1 - item.y0) / 2 * 480; + const height = (item.y0 - item.y1) / 2 * 480; + const on_active_tag = for (settled.regionList()) |region| { + if (region.kind == .tag and region.owner == core.active and @abs(top + 1 - @as(f32, @floatFromInt(region.rect.y)) * 20) < 0.01 and @abs(height - 1) < 0.01) break true; + } else false; + // Not the rule under a column bar, which may run there too. + rims += @intFromBool(on_active_tag and item.fr < 1); + } + try std.testing.expectEqual(@as(usize, @intFromBool(style == .rim)), rims); + } + } +} + +test "a shadow never darkens a native theme's text below its contrast or 4.5" { + var shaded: usize = 0; + for (pardes.themes) |theme| { + const fg = theme.fg orelse fg_default; + const bg = theme.bg orelse bg_default; + const ceiling = shadowCeiling(fg, bg); + const k = 1 - @min(Shadow.strength, ceiling); + const light = @max(luminance(fg), luminance(bg)); + const dark = @min(luminance(fg), luminance(bg)); + const before = (light + 0.05) / (dark + 0.05); + const after = (k * light + 0.05) / (k * dark + 0.05); + try std.testing.expect(after >= @min(before, 4.5) - 1e-3); + shaded += @intFromBool(ceiling > 0.05); + } + // And most themes have room for one. + try std.testing.expect(shaded * 2 > pardes.themes.len); +} + /// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it /// read from the regions and the palette: the page's rules and the still /// panes' rails, rules and grip marks in tier 0, a moving or closing pane's @@ -7382,9 +7976,27 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g g.decor.clearRetainingCapacity(); const chrome = &surface.chrome; const regions = surface.regionList(); + const ceilings = LiftCeilings.of(chrome); + var current_buf: [pardes.MAX_PANES][4]f32 = undefined; + var ncurrent: usize = 0; + for (regions) |part| if ((part.kind == .body or part.kind == .rail) and ncurrent < current_buf.len) { + current_buf[ncurrent] = bodyPixels(part, layout); + ncurrent += 1; + }; + const current_bodies = current_buf[0..ncurrent]; + const lift_style = std.enums.fromInt(pardes.config.Runtime.LiftStyle, chrome.lift_style) orelse .off; + const notice_lift = for (regions) |region| { + if (region.kind == .notice) break region.lift; + } else 0; 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; + // 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) { + const closing = if (group.track) |active| active.phase == .closing else false; + try gripDecor(g, gpa, if (closing) surface.previous_regions else regions, if (closing) surface.previous_cells else surface.cells, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, group.track); + } if (index == 0) { const top = for (regions) |region| { if (region.kind == .workspace_tag) break region.rect.h; @@ -7394,7 +8006,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g .grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true, else => {}, } else false; - const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height); + // 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)); 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); @@ -7403,7 +8016,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g for (regions) |bar| { if (bar.kind != .column_grip) continue; const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h; - try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h); + try addDecor(g, gpa, 0, y - rulePx(chrome), win_w, y, chrome.border, 1, null, win_w, win_h); break; } // A tag on the last row runs on through the pixels under the grid. @@ -7419,7 +8032,14 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g // A closing pane is gone: its chrome is the last frame's. const closing = if (track) |active| active.phase == .closing else false; const placed = if (closing) surface.previous_regions else regions; - const grid = if (closing) surface.previous_cells else surface.cells; + // Where a lift may fall: pane bodies, never tags (addLift). + var body_buf: [pardes.MAX_PANES][4]f32 = undefined; + var nbodies: usize = 0; + for (placed) |part| if ((part.kind == .body or part.kind == .rail) and nbodies < body_buf.len) { + body_buf[nbodies] = bodyPixels(part, layout); + nbodies += 1; + }; + const bodies = body_buf[0..nbodies]; try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); // A single spine runs down each column, through every tag and body. if (index == 0) for (regions) |column| { @@ -7428,14 +8048,42 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h; const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h; const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1; - if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h); + // None at the window's edge: nothing to rule apart there. + if (column.rect.x == 0) continue; + if (y1 > y0) try addDecor(g, gpa, x, y0, x + rulePx(chrome), y1, chrome.border, 1, null, win_w, win_h); }; - try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track); + // Cast last in the group, over its cells, images and chrome: a + // lifted pane's shadow darkens its neighbours' rails and rules + // as it darkens their text, and never the pane itself. + for (placed) |grip| { + if (grip.kind != .grip or grip.lift <= 0.001 or groupOf(groups, grip.serial) != index) continue; + var box: [4]f32 = .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(grip.rect.y)) * layout.h, 0, @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h }; + box[2] = box[0]; + for (placed) |part| { + if (part.owner != grip.owner or (part.kind != .tag and part.kind != .rail and part.kind != .body)) continue; + box[1] = @min(box[1], @as(f32, @floatFromInt(part.rect.y)) * layout.h); + box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w); + box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h); + } + // The top edge of its tag rows, for a rim: x0, x1, y. + const rim_edge: ?[3]f32 = for (placed) |part| { + if (part.owner == grip.owner and part.kind == .tag) break .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w, @as(f32, @floatFromInt(part.rect.y)) * layout.h }; + } else null; + try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h); + } } // 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 // colour and thickness along its bottom, running to the window's // edge when it does, fading up and dissolving with its band. + // A notice floats: its shadow first, then every rule. + for (surface.tagLayers()) |*layer| { + if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; + const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; + const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h; + const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track; + try addLift(g, gpa, lift_style, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, null, current_bodies, notice_lift * layer.fade, track, win_w, win_h); + } for (surface.tagLayers()) |*layer| { if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; @@ -7473,47 +8121,245 @@ 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.border, 1, track, win_w, win_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); + // Stacked panes are ruled apart as columns are, where one pane's + // body meets the next one's tag (acme's Border). + { + const pane_top = @min(tag.rect.y, rail.rect.y); + const stacked = for (regions) |other| { + if ((other.kind == .body or other.kind == .tag) and other.owner != rail.owner and other.rect.y + other.rect.h == pane_top and + other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true; + } else false; + // Inside the tag band's slack, never over its glyphs: the top + // slack of a tag on top, the bottom slack of the one above when + // tags are at the bottom; a pixel at least. + const y = @as(f32, @floatFromInt(pane_top)) * layout.h; + 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); + } 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); + } + } const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h; const body_end = rail.rect.y + rail.rect.h; const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1; + // A theme's own track fills its width, the thumb one pixel short + // of it so the track edges it (acme's scrollbar); else a thin rail. + // acme's scrollbar: the track fills the scroll column, under the + // grip's button; the thumb is a pixel short of its right edge, so + // 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, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 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 (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, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); + try addDecor(g, gpa, column[0], thumb_y0, column[1] - 1, thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); } } } +/// Halfway between two colours. +fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 { + var out: [3]u8 = undefined; + for (&out, a, b) |*o, x, y| o.* = @intCast((@as(u16, x) + y) / 2); + return out; +} + +/// 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)); +} + /// 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, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { +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 { if (g.tagline_height < 6 or g.tagline_width < 3) return; for (regions) |grip| { - if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue; + if ((grip.kind != .grip and grip.kind != .column_grip) or groupOf(groups, grip.serial) != index) continue; + if (grip.rect.h == 0 or grip.rect.y >= rows) continue; + const button = gripButton(g, chrome, grip.rect.x, grip.rect.y, layout, win_h); + // Round the button, its first row is the tag's band: nothing of the + // cells under it shows there. + const band = if (grip.active) chrome.tag_focus_bg else chrome.tag_bg; + 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); + 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 + // pixel of band inside its ring, so the four states differ in shape + // whatever a theme's colours (a focused dirty one is acme's + // modbutton, filled up to its ring). + { + // The rules drawn over the row (paneDecor, after this) cut into + // it: the ring runs inside them, from rule to rule. + const tag = for (regions) |part| { + if (part.kind == .tag and part.owner == grip.owner) break part; + } else grip; + const tag_bottom = for (regions) |part| { + if (part.kind == .rail and part.owner == grip.owner) break part.rect.y < tag.rect.y; + } else false; + const stacked = !tag_bottom and for (regions) |other| { + 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 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); + 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 x = grip.rect.x; for (0..grip.rect.h) |line| { const y = grip.rect.y + @as(u16, @intCast(line)); if (x >= cols or y >= rows) continue; const cell = &cells[@as(usize, y) * cols + x]; if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2)); + // The marks are in the button's ink, from the region's state. + const rgb = inkOn(gripFill(chrome, grip.active, grip.dirty)); + // The marks are the first row's alone, centered in the button. + 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, layout.w - 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); } } } +test "a theme's own rules and scrollbar are drawn at its widths: acme's 2px black rules and 12px track" { + 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()); + try std.testing.expectEqual(@as(u8, 2), s.chrome.rule_px); + // acme's 12px, at GripWidth's 150% default. + try std.testing.expectEqual(@as(u8, 18), s.chrome.rail_px); + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(gpa); + g.cell_w = 16; + g.cell_h = 20; + g.tagline_width = 10; + g.tagline_height = 12; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 480, true); + const px = struct { + fn w(item: CellInstance) f32 { + return (item.x1 - item.x0) / 2 * 1280; + } + }; + var spines: usize = 0; + var tracks: usize = 0; + for (g.decor.items) |item| { + const black = item.br == 0 and item.bg == 0 and item.bb == 0; + // No rule at the window's left edge; one 2px spine where columns meet. + if (black and @abs(px.w(item) - 2) < 0.01 and item.y1 < -0.9) { + try std.testing.expect(item.x0 > -1); + spines += 1; + } + const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g)); + if (@abs(item.br - @as(f32, 0x99) / 255) < 1e-3 and @abs(item.bb - @as(f32, 0x4c) / 255) < 1e-3 and @abs(px.w(item) - (column[1] - column[0])) < 0.01) tracks += 1; + } + try std.testing.expectEqual(@as(usize, 1), spines); + try std.testing.expect(tracks >= 2); +} + +test "the rule between stacked panes stays in the tag band's slack, at any tagline size" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + core.newScratchBelow(0); + pardes.test_api.sync(core); + const s = try core.render(core.scratch.allocator()); + const lower = for (s.regionList()) |region| { + if (region.kind == .tag and region.rect.y > 2) break region; + } else return error.TestExpectedStackedTag; + for ([_]u32{ 12, 20 }) |tagline_h| { + 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 = tagline_h; + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + const top = @as(f32, @floatFromInt(lower.rect.y)) * 20; + const slack: f32 = @floatFromInt(@max(1, pardes.taglineBandOffset(lower.rect.y, 480, 20, tagline_h))); + var found = false; + for (g.decor.items) |item| { + const y0 = (1 - item.y0) / 2 * 480; + const y1 = (1 - item.y1) / 2 * 480; + if (@abs(y0 - top) > 0.01 or @abs(item.br - @as(f32, @floatFromInt(s.chrome.border[0])) / 255) > 1e-3) continue; + found = true; + try std.testing.expect(y1 - y0 <= slack + 0.01); + } + try std.testing.expect(found); + } +} + +test "an empty column is ruled on both sides, as between two filled ones" { + 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)); + // Newcol from the left pane: an empty column between the two filled ones. + core.newColumn(0); + pardes.test_api.sync(core); + try std.testing.expectEqual(@as(usize, 3), core.ncol); + try std.testing.expectEqual(@as(usize, 0), core.col_n[1]); + 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); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true); + const border = [3]f32{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 }; + // Its left edge and its right edge (the next column's left): each a + // rule `rule_px` wide in the border colour, down to the window's foot. + for ([_]usize{ 1, 2 }) |column| { + const px: f32 = @floatFromInt(@as(u32, core.col_x[column]) * 10); + const rule = for (g.decor.items) |item| { + if (@abs(item.x0 - (px / 800 * 2 - 1)) < 1e-4 and @abs(item.x1 - ((px + @as(f32, @floatFromInt(s.chrome.rule_px))) / 800 * 2 - 1)) < 1e-4 and + std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &border)) break item; + } else return error.TestExpectedColumnRule; + try std.testing.expectEqual(@as(f32, -1), rule.y1); + } +} + 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 }); @@ -7541,15 +8387,23 @@ test "decor is read from the regions, each pane's in its own group" { // workspace tag; a rail two pixels wide at each pane's. for (core.col_x[0..core.ncol]) |x| { const left = ndc.x(@as(f32, @floatFromInt(x)) * 10); + // Two pixels (rule_px), and none at the window's own edge. const spine = for (g.decor.items) |item| { - if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item; - } else return error.TestExpectedSpine; - try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001); - try std.testing.expectEqual(@as(f32, -1), spine.y1); + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001 and + @abs(item.y0 - (1 - 20.0 / 480.0 * 2)) < 0.0001 and std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &.{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 })) break item; + } else null; + try std.testing.expectEqual(x == 0, spine == null); + if (spine) |rule| { + try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), rule.y0, 0.0001); + try std.testing.expectEqual(@as(f32, -1), rule.y1); + } + // The scrollbar fills the scroll column right of the rule. + const column = scrollColumn(&g, &s.chrome, x, layout); const rail = for (g.decor.items) |item| { - if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item; + if (@abs(item.x0 - ndc.x(column[0])) < 0.0001 and @abs(item.x1 - ndc.x(column[1])) < 0.0001 and + @abs(item.br - @as(f32, @floatFromInt(s.chrome.scroll_track[0])) / 255) < 0.0001) break item; } else return error.TestExpectedRail; - try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001); + _ = rail; } try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count); const still = groups.items[0].decor_count; |
