summaryrefslogtreecommitdiff
path: root/src/draw.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 19:06:01 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit242467eb9f066f80164d534be55c2c4ebc9b5604 (patch)
treee3ec33330d13c63c61d51cdd406d9788a67d947b /src/draw.zig
parent8f0ac9d1f4aaffa7e264cab80b949040a88f0f08 (diff)
downloadpardes-242467eb9f066f80164d534be55c2c4ebc9b5604.tar.gz
pardes-242467eb9f066f80164d534be55c2c4ebc9b5604.zip
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
Diffstat (limited to 'src/draw.zig')
-rw-r--r--src/draw.zig124
1 files changed, 110 insertions, 14 deletions
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" {