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/gui/Post.zig | 5 +- src/gui/gui.zig | 214 +++++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 171 insertions(+), 48 deletions(-) (limited to 'src/gui') 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; -- cgit v1.3