summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/colors.zig124
-rw-r--r--src/draw.zig55
-rw-r--r--src/gui/Post.zig5
-rw-r--r--src/gui/gui.zig214
-rw-r--r--src/themes/dark.zig4
-rw-r--r--src/themes/helix.zig4
6 files changed, 352 insertions, 54 deletions
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 },