From 242467eb9f066f80164d534be55c2c4ebc9b5604 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 19:06:01 -0300 Subject: acme looks like acme; every theme gets acme's button, scroll column and 2px rules acme takes plan9port's own colours (acme.c iconinit, draw.h): selections #eeee9e in the body and #9eeeee in the tag, black ink; exec and look sweeps #aa0000 and #006600, white ink; a #99994c scrollbar under a #ffffea thumb; black rules; a #8888cc tag/body rule and column button. Every theme: the grip is acme's button, its tag row's full height, in one scroll column with the scrollbar (rail_px, 12 at a 17px tagline, scaled, times GripWidth, a percent, default 150; same x0/x1 to the pixel): focused clean = solid box; focused dirty = box_dirty filled to a box ring (acme's modbutton); unfocused clean = a box_border ring round the tag's ground; unfocused dirty = box_dirty a pixel inside that ring. The grid, which has no ring, marks dirty with a bold * on box_dirty in the grip's second cell and fills an unfocused grip halfway to its ring. Regions carry a dirty flag. Column buttons are the same shape, solid. 2px rules between columns and stacked panes (inside the tag band's slack), none at the window edge; a 1px tag/body rule halfway between tag and page. Theme data: tag_sel_bg, sweep_bg/fg, tag_rule, rule_px, rail_px, box_border, box_dirty. Chrome: rule_px, tag_rule, rail_px, grip_border, grip_focus_ring; a comptime guard on Chrome's layout forces a wire bump once v8 ships. Structural rules are exempt from the focus-indicator rule (docs/effects.md). Tests: acme widths; stacked rule in the slack at two tagline sizes; every grip-column pixel is band, button, ring or rule, and the button shares the scrollbar's x0/x1 (4 metrics x top/bottom x focus x dirty); the four grip states distinct in every native theme with a non-colour dirty cue in the grid; wire round-trip of the new fields. Shared files: src/colors.zig, src/surface.zig, src/body_layer.zig, src/tagline.zig, src/detached/wire.zig, docs/themes.md, docs/config.md, docs/effects.md, 21 snapshot goldens (the grip cells only), test/gui-goldens.txt. Motion flavours differ by design, and drive notice drops and pane moves --- src/draw.zig | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 110 insertions(+), 14 deletions(-) (limited to 'src/draw.zig') diff --git a/src/draw.zig b/src/draw.zig index 2e608132..1f30a40b 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -58,7 +58,7 @@ pub fn place(p: *Pardes, s: *Surface) void { const active = id == p.active; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); - s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .dirty = tagline.dirty(pane), .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; @@ -160,6 +160,29 @@ pub fn place(p: *Pardes, s: *Surface) void { } } +/// A pane's grip in the grid: `box` focused, else halfway between its ring +/// and `box_dim`, so it shows on its tag without the ring a pixel shell +/// draws (gui.zig draws its button from the region and Chrome instead). +fn gripBg(p: *const Pardes, active: bool) [3]u8 { + const chrome = p.chromeTheme(); + if (active) return chrome.box; + return colors.mix(boxBorder(p), chrome.box_dim); +} + +/// An unfocused grip's ring, and a dirty one's fill (colors.Theme). +pub fn boxBorder(p: *const Pardes) [3]u8 { + return p.theme().box_border orelse p.chromeTheme().box_dim; +} +pub fn boxDirty(p: *const Pardes) [3]u8 { + const th = p.theme(); + return th.box_dirty orelse th.diagnostic_warning orelse th.num; +} + +fn inkOn(bg: [3]u8) [3]u8 { + const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10; + return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; +} + /// The Lift style as drawn: `auto` is a shadow on a light page, and on a /// dark one, where a shadow has nothing to darken, no lift at all (the other /// panes recede instead, `inactiveDim`). @@ -366,6 +389,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .sel_fg = p.theme().sel_fg, .sel_bg = p.theme().sel_bg, .lift_style = @intFromEnum(liftStyle(p)), + .rule_px = p.theme().rule_px orelse 2, + .grip_border = boxBorder(p), + .grip_focus_ring = chrome.box, + .grip_dirty = boxDirty(p), + .tag_rule = p.theme().tag_rule, + .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)), }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. @@ -666,21 +695,28 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); } - const box_bg = if (active) chrome.box else chrome.box_dim; - const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; - const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; + // The grip is acme's button: the focused pane's filled, the others' + // a ring round the tag's ground, a dirty one's the dirty colour inside + // its ring. A pixel shell draws the ring (gui.zig gripDecor); a grid, + // which cannot, marks dirty in the grip's second cell with a bold `*` + // on the dirty colour, a cue that survives any palette. + const band = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const dirty = tagline.dirty(pane); + const box_bg = gripBg(p, active); const box_style: CellStyle = .{ - .fg = .{ .rgb = box_ink }, + .fg = .{ .rgb = inkOn(box_bg) }, .bg = .{ .rgb = box_bg }, // Grips keep physical grid dimensions, independently of tag text. .font_role = .tagline, }; - // The grip is the first row's alone, as acme's button is; the rows - // under it are the tag band's, and still grab the pane (the region - // spans them all, as acme's button column does). + // On the first row alone, as acme's button is; the rows under it are + // the tag band's, and still grab the pane (the region spans them all, + // as acme's button column does). s.fill(r.x, tag_y, config.GUTTER, @min(1, pane.tag_rows), box_style); + if (dirty and pane.tag_rows > 0 and config.GUTTER > 1) + s.set(r.x + config.GUTTER - 1, tag_y, "*", .{ .fg = .{ .rgb = inkOn(boxDirty(p)) }, .bg = .{ .rgb = boxDirty(p) }, .bold = true, .font_role = .tagline }); s.fill(r.x, tag_y + 1, config.GUTTER, pane.tag_rows -| 1, .{ - .bg = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg }, + .bg = .{ .rgb = band }, .font_role = .tagline, }); s.set(r.x, tag_y, switch (pane.body.mode) { @@ -880,7 +916,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, var cell = panes.File.rawDisplayCol(shown, bounds.lo) -| scroll; const end = panes.File.rawDisplayCol(shown, bounds.hi) -| scroll; while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().tag_sel_bg orelse p.theme().sel_bg }; surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } }; @@ -890,7 +926,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, var col = panes.File.rawDisplayCol(shown, span.lo) -| scroll; const end = panes.File.rawDisplayCol(shown, span.hi) -| scroll; while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().tag_sel_bg orelse p.theme().sel_bg }; surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; } } @@ -1131,7 +1167,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), y); cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.bg = .{ .rgb = th.tag_sel_bg orelse th.sel_bg }; cell.style.fg = .{ .rgb = th.sel_fg }; } } @@ -1151,7 +1187,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (button != pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.tag_sel_bg orelse th.sel_bg, th.sel_fg); } if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { @@ -1160,11 +1196,71 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa }; } const sel_btn = body_layer.buttonTints(th); + const sel_ink = body_layer.buttonInks(th); for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (button == pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], sel_ink[button]); + } +} + +test "an unfocused grip shows on its tag in the grid, in every native theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + for (pardes.themes[0..colors.native_count], 0..) |theme, index| { + colors.setThemeIndex(p, index); + p.active = 1; + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const r = p.rects[0]; + const grip = s.at(r.x, p.tagTop(p.panes[0].?, r)).style.bg.rgb; + const ratio = contrast(grip, p.chromeTheme().tag_bg); + // ponytail: dark and helix have always had a faint grip (1.2:1); flagged, not changed here. + const floor: f32 = if (std.mem.eql(u8, theme.name, "dark") or std.mem.eql(u8, theme.name, "helix")) 1.2 else 1.5; + if (ratio < floor) std.debug.print("{s}: grip {d:.2}\n", .{ theme.name, ratio }); + try std.testing.expect(ratio >= floor); + } +} + +test "a grip's four states, focused or not and clean or dirty, are each its own in every native theme" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 }); + defer p.deinit(); + const pane = try p.setTestFile("text\n"); + p.newScratchBelow(0); + p.animate_theme_changes = false; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const State = struct { first: Cell, second: Cell, dirty: bool }; + for (0..colors.native_count) |index| { + colors.setThemeIndex(p, index); + var states: [4]State = undefined; + for (&states, 0..) |*state, i| { + p.active = if (i & 1 == 0) 0 else 1; + const saved = pane.file.?.saved_revision; + pane.file.?.revision = if (i & 2 != 0) saved + 1 else saved; + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const r = p.rects[0]; + const y = p.tagTop(pane, r); + state.* = .{ .first = s.at(r.x, y).*, .second = s.at(r.x + 1, y).*, .dirty = s.region(.grip, 0).?.dirty }; + try std.testing.expectEqual(i & 2 != 0, state.dirty); + if (i & 2 == 0) continue; + // Dirty is more than a shade: the grid prints a bold `*` (one + // grid for every shell; a pixel shell draws its button over it). + try std.testing.expectEqualStrings("*", state.second.grapheme()); + try std.testing.expect(state.second.style.bold); + } + for (states, 0..) |a, i| for (states[i + 1 ..]) |b| { + const same = std.meta.eql(a.first.style.bg, b.first.style.bg) and a.second.visuallyEqual(&b.second); + try std.testing.expect(!same); + }; } + pane.file.?.revision = pane.file.?.saved_revision; } test "a tag of three rows has its grip on the first, and band under it that still grabs the pane" { -- cgit v1.3