From fde030a699b3d6667d1fa21ae192b5011e3f373f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 23:50:56 -0300 Subject: Rules and chrome scale with the display's density; dark pages get visible separators and focus tints Density: dp() in gui.zig draws every size given in pixels in logical pixels (SDL's window display scale, rounded, at least one), as acme scales Border, ButtonBorder and Scrollgap: the rules (rule_px), the tag rule, the grip's ring, its dirty gap and mode bars, the rail's gap to the text and its minimum, the thumb's inset, the rim, a bar cursor's minimum width, underline and strikethrough thickness (the atlas is dropped on a scale change), the rule-grab margin, and the Crt's scanline and mask periods (pardesScale in the post prefix). Cell- and tagline-measured sizes stay: the rail's width, Lift's blur and offsets, every motion; iResolution, fragCoord and iMouse stay in the display's pixels, as ghostty's. PARDES_TEST_SCALE forces a density in a harness (a test window is 1x otherwise, whatever display it lands on) and sizes the test font with it. Colours: a separator that stands off its page by less than 1.5:1 is lifted toward the text to 1.6:1 (helix, atelier, paper; a theme with no page, dark, is judged against #121212), and empty_col follows it. forge's own rule already stands at 3.83:1; what hid it was density: 2 device pixels on a 3x screen, now 6 (dp). A focused tag's tint closer than 1.5:1 to the plain tag on a dark page (1.25:1 on a light one) is pushed further the way it leans, as far as its text keeps its own contrast or 4.5: forge 1.23 -> 1.51 (#2d3440 -> #374253), and likewise dusk, orchard, ink, lagoon, clay, harvest, spectrum, solarium, forge_soft; daybreak, atelier, paper to 1.25. dark and helix gain a tint of their own (dark: #484566 under #c8c8c8, 1.54:1; helix: ramp 237 under 250, 1.65:1). Tag caret: past a full row's end, the G3 glide box sits on the tag's right edge, where the layer's caret and the pixel shell's are. Tests: every native theme's separator >= 1.5 and every dark one has a tint >= 1.25 with readable text; forge keeps its own border; the tag caret glide box at a full row; at 2x the spine, tag rule, ring, rail start and gap, and bar cursor double. Legacy-ThemeFile test strips helix's new pair. Goldens: snapshot goldens change in colour rows only, the tints (orchard #283a2f -> #314738 at boot, forge, helix and dark in theme/themesel). GUI goldens: the harness's single Del of the renamed wide pane is refused since ysqnkyoy (a rename counted as unsaved; fixed on the 9P line in wkzlsqvy), so scenes 09-17 were recorded with that pane open and the file was stale since the merge (08 and 18 also moved with ykvnltvs's full-row tag wrap). The goldens here were recorded with that pane closed (as wkzlsqvy will close it); against the parent's binary under the same conditions they differ only in the tints: 01-13 forge's (#2d3440 -> #374253, and the glyph edges on it), 14 ink's; 15-18 are byte-identical. Until wkzlsqvy is merged, a run here fails 09-17. Each commit reproduces its own recorded goldens on this machine (checked at kvtmqkyn). A test window is 1x unless PARDES_TEST_SCALE says otherwise, whatever display it opens on. Prebuilt shaders refreshed from their sources. Captures: .scratch/render/dark-borders/{before,after,compare,scale2,scale3}. --- src/colors.zig | 124 +++++++++++++++++++++++++++-- src/draw.zig | 55 ++++++++++++- src/gui/Post.zig | 5 +- src/gui/gui.zig | 214 ++++++++++++++++++++++++++++++++++++++++----------- src/themes/dark.zig | 4 + src/themes/helix.zig | 4 + 6 files changed, 352 insertions(+), 54 deletions(-) (limited to 'src') diff --git a/src/colors.zig b/src/colors.zig index 224591d3..4a18bba9 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -17,6 +17,106 @@ const MAX_PANES = pardes.MAX_PANES; const BOX_H = pardes.BOX_H; const Pardes = pardes.Pardes; +/// WCAG relative luminance, and the contrast of two colours. +pub fn relativeLuminance(rgb: [3]u8) f32 { + var l: [3]f32 = undefined; + for (&l, rgb) |*out, v| { + const x = @as(f32, @floatFromInt(v)) / 255; + out.* = if (x <= 0.04045) x / 12.92 else std.math.pow(f32, (x + 0.055) / 1.055, 2.4); + } + return 0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2]; +} + +pub fn contrast(a: [3]u8, b: [3]u8) f32 { + const x = relativeLuminance(a); + const y = relativeLuminance(b); + return (@max(x, y) + 0.05) / (@min(x, y) + 0.05); +} + +fn lerp(a: [3]u8, b: [3]u8, t: f32) [3]u8 { + var out: [3]u8 = undefined; + for (&out, a, b) |*o, x, y| o.* = @intFromFloat(std.math.clamp(@round(@as(f32, @floatFromInt(x)) + (@as(f32, @floatFromInt(y)) - @as(f32, @floatFromInt(x))) * t), 0, 255)); + return out; +} + +/// The rules between panes and columns: acme's contrast, black on white. +/// A theme's own border, or its scroll track, as long as it stands off the +/// page by 1.5:1; one that does not (on a near-black page, a dark rule all +/// but vanishes) is lifted toward the text until it does, to 1.6:1. +pub const separator_floor: f32 = 1.5; +/// The page a theme with none (`dark`: the host's own) is judged against: +/// the GUI's default ground, and a dark terminal's. +pub const null_page: [3]u8 = .{ 18, 18, 18 }; +fn separatorOf(th: *const Theme) [3]u8 { + const base = th.border orelse th.scroll_track; + const page = th.bg orelse null_page; + if (contrast(base, page) >= separator_floor) return base; + const ink = th.fg orelse th.tag_fg; + var t: f32 = 0.05; + while (t <= 1) : (t += 0.05) { + const lifted = lerp(base, ink, t); + if (contrast(lifted, page) >= 1.6) return lifted; + } + return ink; +} + +/// The focused tag's tint, the main focus indicator: a theme's own, pushed +/// further the way it already leans from the plain tag where the two are +/// closer than the floor (1.5:1 on a dark page, where a tint is all that +/// tells the focused tag at a glance; 1.25:1 on a light one), but never so +/// far its text drops below its own contrast or 4.5. A theme with no tint +/// (acme: the grip's button says it) keeps none. +pub const focus_tint_floor: f32 = 1.25; +pub const focus_tint_floor_dark: f32 = 1.5; +pub fn focusTintFloor(th: *const Theme) f32 { + return if (relativeLuminance(th.bg orelse null_page) < 0.18) focus_tint_floor_dark else focus_tint_floor; +} +fn focusTint(th: *const Theme) [3]u8 { + const tint = th.tag_active_bg orelse return th.tag_bg; + const floor = focusTintFloor(th); + if (contrast(tint, th.tag_bg) >= floor) return tint; + const ink = th.tag_active_fg orelse th.tag_fg; + const text_floor = @min(contrast(ink, tint), 4.5); + var best = tint; + var k: f32 = 1.1; + while (k <= 12) : (k += 0.1) { + const pushed = lerp(th.tag_bg, tint, k); + if (contrast(ink, pushed) < text_floor) break; + best = pushed; + if (contrast(pushed, th.tag_bg) >= floor) break; + } + return best; +} + +test "every native theme's separators stand off its page, and its focus tint off its tag" { + for (themes[0..native_count]) |*theme| { + const chrome = ChromeTheme.fromTheme(theme); + const page = theme.bg orelse null_page; + try std.testing.expect(contrast(chrome.border, page) >= separator_floor); + // A dark page's focused tag is told by its band too, not the grip + // alone; acme's (light, and acme's own) is told by its button. + if (relativeLuminance(page) < 0.18) try std.testing.expect(theme.tag_active_bg != null); + if (theme.tag_active_bg) |own| { + try std.testing.expect(contrast(chrome.tag_active_bg, theme.tag_bg) >= focusTintFloor(theme)); + // And its text still reads. + const ink = theme.tag_active_fg orelse theme.tag_fg; + try std.testing.expect(contrast(ink, chrome.tag_active_bg) >= @min(contrast(ink, own), 4.5) - 0.01); + } + } +} + +test "a derived separator or tint keeps the theme's own when it already stands off" { + const forge = &themes[for (themes[0..native_count], 0..) |t, i| { + if (std.mem.eql(u8, t.name, "forge")) break i; + } else unreachable]; + const chrome = ChromeTheme.fromTheme(forge); + try std.testing.expectEqual(forge.border.?, chrome.border); + // forge's own tint is 1.23:1 off its tag: pushed on the way it leans. + try std.testing.expect(!std.meta.eql(forge.tag_active_bg.?, chrome.tag_active_bg)); + for (chrome.tag_active_bg, forge.tag_active_bg.?, forge.tag_bg) |pushed, own, plain| + try std.testing.expect(pushed >= own and own >= plain); +} + pub fn mix(a: [3]u8, b: [3]u8) [3]u8 { var out: [3]u8 = undefined; for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2); @@ -187,15 +287,17 @@ pub const ChromeTheme = struct { scroll_thumb: [3]u8, pub fn fromTheme(th: *const Theme) ChromeTheme { + // The initial chrome is resolved at compile time: contrast takes pow. + @setEvalBranchQuota(200_000); return .{ .tag_bg = th.tag_bg, .tag_fg = th.tag_fg, - .tag_active_bg = th.tag_active_bg orelse th.tag_bg, + .tag_active_bg = focusTint(th), .tag_active_fg = th.tag_active_fg orelse th.tag_fg, .tag_name_fg = th.tag_name_fg orelse th.tag_fg, .tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)), - .border = th.border orelse th.scroll_track, - .empty_col = th.empty_col orelse (th.border orelse th.scroll_track), + .border = separatorOf(th), + .empty_col = th.empty_col orelse separatorOf(th), .search_bg = th.search_bg orelse th.sel_bg, .search_fg = th.search_fg orelse th.sel_fg, .diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg), @@ -655,8 +757,20 @@ test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { @setEvalBranchQuota(10000); var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); defer encoded.deinit(); - try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer); - const source = try std.testing.allocator.dupeZ(u8, encoded.written()); + try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = false }, &encoded.writer); + // helix without the focused tag's pair it has since gained: a document + // written before those fields existed. + var legacy: std.ArrayList(u8) = .empty; + defer legacy.deinit(std.testing.allocator); + var rest: []const u8 = encoded.written(); + while (true) { + const at = @min(std.mem.indexOf(u8, rest, ".tag_active_bg") orelse rest.len, std.mem.indexOf(u8, rest, ".tag_active_fg") orelse rest.len); + try legacy.appendSlice(std.testing.allocator, rest[0..at]); + if (at == rest.len) break; + rest = rest[at + std.mem.indexOfScalar(u8, rest[at..], '}').? + 1 ..]; + rest = std.mem.trimStart(u8, rest, " ,"); + } + const source = try std.testing.allocator.dupeZ(u8, legacy.items); defer std.testing.allocator.free(source); const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{}); defer std.zon.parse.free(std.testing.allocator, parsed); diff --git a/src/draw.zig b/src/draw.zig index a2005e59..b8cf4e7d 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -197,8 +197,11 @@ fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box { const caret = layer.cursor orelse continue; if (layer.rows == 0 or layer.viewport.y + caret.y != cursor.y) continue; if (cursor.x < layer.viewport.x or cursor.x >= layer.viewport.x + layer.viewport.w) continue; - const x = @as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * tw / bw; const w = if (caret.bar) tw / bw / 8 else tw / bw; + // A caret past a full row's end sits at the tag's right edge, where + // the grid's is clamped too. + const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)); + const x = @min(@as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * tw / bw, right - w); return .{ .x = x, .y = @as(f32, @floatFromInt(cursor.y)) + (bh - th) / 2 / bh, .w = w, .h = th / bh }; } return .{ .x = @floatFromInt(cursor.x), .y = @floatFromInt(cursor.y), .w = if (cursor.bar) 1.0 / 8.0 else 1, .h = 1 }; @@ -1787,6 +1790,56 @@ test "the focused cursor glides a jump, lands a step at once, and is exact at re try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide); } +test "a tag caret past its row's end glides to the tag's right edge, where the layer's is" { + if (!builtins.capabilities.lift) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 60, .rows = 16, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + const pane = try p.setTestFile("text\n"); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + // The tag's text grown a letter at a time, the caret after its last one + // in insert mode, until a row is full and the caret is past its end. + var found = false; + var n: usize = 1; + while (n < 200 and !found) : (n += 1) { + _ = arena.reset(.retain_capacity); + if (pane.tag.own) |own| p.gpa.free(own); + const own = try p.gpa.alloc(u8, n); + @memset(own, 'x'); + own[0] = ' '; + pane.tag.own = own; + tagline.enterTag(p, pane); + const whole = try tagline.tagText(p, arena.allocator(), pane); + pane.tag.mode = .insert; + pane.tag.cur_row = 0; + pane.tag.cur_col = @intCast(modal.lineSlice(whole, 0).len); + p.sync(); + const s = try p.render(arena.allocator()); + const layer = for (s.tagLayers()) |*layer| { + if (layer.rows != 0 and layer.kind == .pane and layer.cursor != null) break layer; + } else continue; + const caret = layer.cursor.?; + const right: f32 = @floatFromInt(layer.viewport.x + layer.viewport.w); + const box = focusedCursorBox(p, s).?; + // Never past the tag's right edge, and the pane's. + try std.testing.expect(box.x + box.w <= right + 1e-4); + try std.testing.expect(box.x + box.w <= @as(f32, @floatFromInt(p.rects[p.active].x + p.rects[p.active].w))); + const at = @as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * 6 / 10; + if (at + box.w <= right) { + // Inside its row: exactly where the layer's caret is. + try std.testing.expectApproxEqAbs(at, box.x, 1e-4); + } else { + // Past it: on the tag's right edge, as the pixel shell's caret + // is (gui.zig cursorBoxes). + try std.testing.expectApproxEqAbs(right, box.x + box.w, 1e-4); + try std.testing.expectApproxEqAbs(@as(f32, 6.0 / 10.0 / 8.0), box.w, 1e-4); + found = true; + } + } + try std.testing.expect(found); +} + test "a terminal's cursor lands with scrolling output, never gliding against it" { if (!builtins.capabilities.lift or !pardes.terminal_panes) return error.SkipZigTest; const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12 }); diff --git a/src/gui/Post.zig b/src/gui/Post.zig index f715965f..45798f8d 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -98,6 +98,9 @@ ping: [2]?*c.SDL_GPUTexture = .{ null, null }, ping_w: u32 = 0, ping_h: u32 = 0, uniforms: Uniforms = .{}, +/// The display's density, for a pass whose pattern is in logical pixels +/// (pardesScale); the shell sets it. +scale: f32 = 1, started_ns: ?u64 = null, last_ns: u64 = 0, @@ -422,7 +425,7 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, const binding = c.SDL_GPUTextureSamplerBinding{ .texture = input, .sampler = sampler }; c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &binding, 1); c.SDL_PushGPUFragmentUniformData(cmd, 0, u, @sizeOf(Uniforms)); - const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), @floatFromInt(pass.level), 0, 0 }; + const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), @floatFromInt(pass.level), post.scale, 0 }; c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own))); c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0); c.SDL_EndGPURenderPass(pass_rp); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 491373a8..fdcd7b2a 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1981,7 +1981,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, @@ -2331,6 +2331,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 +2404,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; @@ -3349,6 +3356,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 => {}, @@ -3734,7 +3747,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| { @@ -5729,6 +5742,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; }; @@ -6131,7 +6145,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 }; } @@ -7150,31 +7164,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); } } @@ -7819,7 +7836,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); }; } @@ -8059,7 +8076,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); @@ -8145,9 +8162,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); } @@ -8173,7 +8190,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). { @@ -8189,10 +8206,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); } } @@ -8211,7 +8228,7 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr 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); } } } @@ -8225,7 +8242,34 @@ 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 logical pixel size in the display's pixels: rounded, at least one. +fn dp(logical: f32) f32 { + return @max(1, @round(logical * display_scale)); } /// The two short bars on each row of a grip with no mode glyph there, @@ -8262,22 +8306,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; @@ -8292,9 +8339,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); } } } @@ -8412,6 +8462,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 }); @@ -8493,7 +8612,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; } } @@ -8510,7 +8629,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| { @@ -8520,11 +8639,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; diff --git a/src/themes/dark.zig b/src/themes/dark.zig index 82396397..e93fd489 100644 --- a/src/themes/dark.zig +++ b/src/themes/dark.zig @@ -17,6 +17,10 @@ pub const theme = .{ .sel_fg = .{ 0xd8, 0xc8, 0xa8 }, .tag_bg = .{ 0x2c, 0x2a, 0x3e }, .tag_fg = .{ 0x96, 0x96, 0x96 }, + // The focused tag: its band lighter in its own hue (1.54:1 off the + // plain one), its ink lighter to keep reading on it. + .tag_active_bg = .{ 0x48, 0x45, 0x66 }, + .tag_active_fg = .{ 0xc8, 0xc8, 0xc8 }, .box = .{ 0x66, 0x3f, 0xe0 }, .box_dim = .{ 0x35, 0x3a, 0x58 }, .kw = .{ 0xda, 0x5e, 0xea }, diff --git a/src/themes/helix.zig b/src/themes/helix.zig index db51de3a..61dc8e63 100644 --- a/src/themes/helix.zig +++ b/src/themes/helix.zig @@ -43,6 +43,10 @@ pub const theme = .{ .sel_fg = .{ 0x08, 0x08, 0x08 }, .tag_bg = .{ 0x12, 0x12, 0x12 }, .tag_fg = .{ 0x94, 0x94, 0x94 }, + // The focused tag, still on the ramp: 237 #3a3a3a under 250 #bdbdbd + // (the page's text), 1.65:1 off the plain bar. + .tag_active_bg = .{ 0x3a, 0x3a, 0x3a }, + .tag_active_fg = .{ 0xbd, 0xbd, 0xbd }, .box = .{ 0x62, 0x62, 0x62 }, .box_dim = .{ 0x26, 0x26, 0x26 }, .kw = .{ 0xd7, 0x76, 0xf9 }, -- cgit v1.3