summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/body_layer.zig8
-rw-r--r--src/builtins.zig2
-rw-r--r--src/colors.zig25
-rw-r--r--src/config.zig20
-rw-r--r--src/detached/wire.zig24
-rw-r--r--src/draw.zig124
-rw-r--r--src/gui/gui.zig461
-rw-r--r--src/ninep/ctl.zig1
-rw-r--r--src/surface.zig15
-rw-r--r--src/tagline.zig8
-rw-r--r--src/themes/acme.zig33
11 files changed, 657 insertions, 64 deletions
diff --git a/src/body_layer.zig b/src/body_layer.zig
index a0195fb8..875f2e36 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -156,6 +156,7 @@ pub fn paintPointerSelection(
/// The selection colour of each pointer button's sweep: the select button's
/// and the look and exec buttons', each tinted toward its own accent.
pub fn buttonTints(th: *const colors.Theme) [3][3]u8 {
+ if (th.sweep_bg) |own| return own;
const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num;
return .{
colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)),
@@ -164,6 +165,11 @@ pub fn buttonTints(th: *const colors.Theme) [3][3]u8 {
};
}
+/// The ink over each button's sweep.
+pub fn buttonInks(th: *const colors.Theme) [3][3]u8 {
+ return th.sweep_fg orelse @splat(th.sel_fg);
+}
+
/// A pane's body, painted once. A file body with compact tree-sitter
/// context rows is painted into its body layer -- more rows than the grid
/// has, the context rows at the tagline's height -- and the grid's rows are
@@ -312,7 +318,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
// A select sweep over the tag is the tag's selection, painted there
// in the tag's own selection colours.
if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue;
- paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], th.sel_fg);
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], buttonInks(th)[b]);
}
// modal line selection (helix `x`): whole body rows, normal mode only
if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) {
diff --git a/src/builtins.zig b/src/builtins.zig
index e49deb71..c25311e3 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -172,7 +172,7 @@ pub const registry = struct {
if (@intFromEnum(b) == i) return switch (setting.action) {
// a switch flips bare, and DumpDir bare is the default
.toggle, .transition, .scene, .dump_dir, .lift => false,
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim => true,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim, .grip_width => true,
};
unreachable;
}
diff --git a/src/colors.zig b/src/colors.zig
index aeff7b00..25aa838f 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -63,6 +63,29 @@ pub const Theme = struct {
// The current line number is a quiet foreground cue, not a tag-colored
// background block. Older themes fall back to their normal gutter ink.
lineno_active: ?[3]u8 = null,
+ /// A tag's selection, where it differs from the body's (acme's #9eeeee).
+ tag_sel_bg: ?[3]u8 = null,
+ /// Each pointer button's sweep, select, exec and look (Mouse.Button
+ /// order), and the ink over it. Unset, tints of `sel_bg` in `sel_fg`.
+ sweep_bg: ?[3][3]u8 = null,
+ sweep_fg: ?[3][3]u8 = null,
+ /// A pixel shell's rules, in pixels (2 unset); the rule between a tag
+ /// and its body is always one.
+ rule_px: ?u8 = null,
+ /// A grip is acme's button: `box` filled when focused, else a ring of
+ /// `box_border` (unset: `box_dim`) round the tag's ground; `box_dirty`
+ /// (unset: `diagnostic_warning`, then `num`) fills it, in either ring,
+ /// while its file is unsaved.
+ box_border: ?[3]u8 = null,
+ box_dirty: ?[3]u8 = null,
+ /// The rule between a pane's tag and its body, one pixel, quieter than
+ /// the rules between panes (acme's #8888cc under its 2px black ones).
+ /// Unset, halfway between the tag's ground and the page.
+ tag_rule: ?[3]u8 = null,
+ /// A pixel shell's scroll column, the grip's button over the scrollbar,
+ /// in pixels at a 17px tagline, the thumb one narrower (acme's 12px
+ /// Scrollwid, the default).
+ rail_px: ?u8 = null,
};
const curated = struct {
@@ -108,6 +131,8 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
}
pub const themes = fold(curated) ++ fold(@import("generated_themes"));
+/// The first `native_count` of `themes` are pardes's own; the rest imported.
+pub const native_count = @typeInfo(curated).@"struct".decls.len;
/// Original and classic-inspired Pardes palettes precede retained imports.
pub const native_theme_count = 15;
diff --git a/src/config.zig b/src/config.zig
index ed7bee92..24e058c7 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -118,6 +118,7 @@ pub const leader_path = paths: {
if (builtins.capabilities.lift) table.set(.Lift, null);
table.set(.Motion, null);
table.set(.InactiveDim, null);
+ if (builtins.capabilities.lift) table.set(.GripWidth, null);
if (builtins.capabilities.panel_transitions) {
table.set(.PanelSlide, "as");
table.set(.PanelZoom, "az");
@@ -629,6 +630,10 @@ pub const Runtime = struct {
/// percent (0 off): the focused pane wins by comparison, its tag tint
/// untouched. Kept above min(each cell's contrast, 4.5).
inactive_dim: u8 = 0,
+ /// The grip's button and the scrollbar under it, one width, as a percent
+ /// of the theme's `rail_px` (acme's 12px Scrollwid at a 17px tagline):
+ /// a pixel shell's (docs/themes.md).
+ grip_width: u16 = 150,
/// How the fx track's animations move (animation.Motion): off for
/// reduced motion.
motion: animation.Motion.Flavour = .smooth,
@@ -862,6 +867,7 @@ pub const Runtime = struct {
motion,
/// A percentage, 0 through 100.
inactive_dim,
+ grip_width,
transition: animation.Transition,
scene: Scene,
/// A Shadertoy file into the post chain or out of it; `off` empties
@@ -899,6 +905,7 @@ pub const Runtime = struct {
.{ .word = "Lift", .action = .lift, .availability = .lift },
.{ .word = "Motion", .action = .motion },
.{ .word = "InactiveDim", .action = .inactive_dim },
+ .{ .word = "GripWidth", .action = .grip_width, .availability = .lift },
.{ .word = "MessageLinger", .action = .{ .message_ms = .linger } },
.{ .word = "MessageFall", .action = .{ .message_ms = .fall } },
.{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } },
@@ -960,8 +967,8 @@ pub const Runtime = struct {
@compileError("scene effects must use the scene-shader capability"),
// A toggle is plain state any shell can hold, except one for
// what only some shells draw: that one exists where they do.
- .lift => if (setting.availability != .lift)
- @compileError("Lift must use the lift capability"),
+ .lift, .grip_width => if (setting.availability != .lift)
+ @compileError("Lift and GripWidth must use the lift capability (a pixel shell's)"),
.toggle => |field| if (field == .ligatures) {
if (setting.availability != .ligatures)
@compileError("the ligatures toggle must use the ligatures capability");
@@ -1102,6 +1109,12 @@ pub const Runtime = struct {
if (percent > 100) return false;
state.inactive_dim = percent;
},
+ .grip_width => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%");
+ const percent = std.fmt.parseInt(u16, text, 10) catch return false;
+ if (percent < 50 or percent > 300) return false;
+ state.grip_width = percent;
+ },
.theme, .font => return false,
}
return true;
@@ -1277,6 +1290,7 @@ pub const Runtime = struct {
try out.print("{s} unsupported\n", .{setting.word}),
.motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }),
.inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }),
+ .grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }),
};
if (context.startup_config_path) |path|
@@ -1547,6 +1561,7 @@ pub const Runtime = struct {
.lift => "rim",
.motion => "playful",
.inactive_dim => "35",
+ .grip_width => "200",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1600,6 +1615,7 @@ pub const Runtime = struct {
.lift => "Lift rim\n",
.motion => "Motion playful\n",
.inactive_dim => "InactiveDim 35\n",
+ .grip_width => "GripWidth 200\n",
.theme, .font => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 2a2ccc6b..c0e17054 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -11,6 +11,19 @@ const limits = @import("../memory.zig").limits;
/// line), and a frame carries the core's placed regions after its layers.
pub const version: u16 = 8;
+/// The Chrome palette is carried field by field as its declaration lists
+/// them, so any change to it changes the wire: bump `version`, then this.
+const chrome_layout: u64 = 0x7e3d0952317bfee;
+comptime {
+ @setEvalBranchQuota(10_000);
+ var hash = std.hash.Fnv1a_64.init();
+ for (@typeInfo(pardes.Chrome).@"struct".fields) |field| {
+ hash.update(field.name);
+ hash.update(@typeName(field.type));
+ }
+ if (hash.final() != chrome_layout) @compileError(std.fmt.comptimePrint("pardes.Chrome changed (layout 0x{x}): bump wire.version, then chrome_layout", .{hash.final()}));
+}
+
pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace };
pub const max_cols: u16 = 512;
@@ -248,7 +261,10 @@ pub const Frame = struct {
region.owner = try r.getByte();
region.serial = try r.getU32();
region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() };
- region.active = try r.getBool();
+ const flags = try r.getByte();
+ if (flags > 3) return error.BadValue;
+ region.active = flags & 1 != 0;
+ region.dirty = flags & 2 != 0;
region.thumb_y = try r.getU16();
region.thumb_h = try r.getU16();
region.notice = try r.getByte();
@@ -807,7 +823,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
try w.putU16(region.rect.y);
try w.putU16(region.rect.w);
try w.putU16(region.rect.h);
- try w.putBool(region.active);
+ try w.putByte(@as(u8, @intFromBool(region.active)) | @as(u8, @intFromBool(region.dirty)) << 1);
try w.putU16(region.thumb_y);
try w.putU16(region.thumb_h);
try w.putByte(region.notice);
@@ -1898,10 +1914,10 @@ test "detached wire carries compact body rows and exact pointer metrics" {
var tag_cells: [3]pardes.Cell = @splat(.{});
tag_cells[2] = logical[10];
const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } };
- const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }};
+ const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .dirty = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }};
const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed));
defer gpa.free(out);
- const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } };
+ const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 }, .rule_px = 3, .tag_rule = .{ 13, 14, 15 }, .rail_px = 12, .grip_border = .{ 16, 17, 18 }, .grip_focus_ring = .{ 19, 20, 21 }, .grip_dirty = .{ 22, 23, 24 }, .lift_style = 2 };
const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette);
const packet = (try framed(encoded)).?;
const frame = (try decodeServer(packet.tag, packet.payload)).frame;
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" {
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index d8bae6dc..1a1b1f99 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -2111,7 +2111,13 @@ const Cover = packed struct(u16) {
focus: bool = false,
/// At a column's left edge, where a tag's anchor is inset.
anchor: bool = false,
- _: u7 = 0,
+ /// In a dirty pane's grip.
+ dirty: bool = false,
+ /// In a pane's grip (not a column's).
+ pane_grip: bool = false,
+ /// In a grip's first row, where its button is.
+ grip_top: bool = false,
+ _: u4 = 0,
};
/// Marks the grid cells a body or tag layer covers, which the layers paint
@@ -2143,10 +2149,13 @@ fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes.
if (region.kind == .column_grip) bar = region.rect;
}
for (regions) |region| switch (region.kind) {
- .grip => {
+ .grip, .column_grip => {
mark.rect(cover, cols, rows, region.rect, "grip", true);
for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset)));
- if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true);
+ if (region.kind == .grip and region.active) mark.rect(cover, cols, rows, region.rect, "focus", true);
+ if (region.dirty) mark.rect(cover, cols, rows, region.rect, "dirty", true);
+ if (region.kind == .grip) mark.rect(cover, cols, rows, region.rect, "pane_grip", true);
+ mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = region.rect.y, .w = region.rect.w, .h = @min(1, region.rect.h) }, "grip_top", true);
},
.tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true),
.column => {
@@ -6022,6 +6031,67 @@ fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 {
return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height);
}
+/// acme's Scrollwid column at a pane's or column's left edge, in window
+/// pixels: where its button sits over its scrollbar, one width for both
+/// (`rail_px` at acme's 17px font, scaled with the tagline, and always
+/// short of the cell so the text keeps a gap), right of the column's rule.
+fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: CellLayout) [2]f32 {
+ const rule: f32 = if (col == 0) 0 else rulePx(chrome);
+ 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));
+ return .{ x0, x0 + w };
+}
+
+/// acme's button for the grip whose first cell is (col, row), in window
+/// pixels: the scroll column wide, its tag row tall, from the rule above to
+/// the rule below, so button and scrollbar read as one strip.
+fn gripButton(g: *const Gui, chrome: *const pardes.Chrome, col: u16, row: u16, layout: CellLayout, win_h: f32) [4]f32 {
+ _ = win_h;
+ const x = scrollColumn(g, chrome, col, layout);
+ const y0 = @round(@as(f32, @floatFromInt(row)) * layout.h);
+ return .{ x[0], y0, x[1], @round(@as(f32, @floatFromInt(row + 1)) * layout.h) };
+}
+
+/// A pane's button fill, from its region's state, not the grid's cells
+/// (which are drawn for cell shells): dirty, focused, or the tag's ground.
+fn gripFill(chrome: *const pardes.Chrome, focus: bool, dirty: bool) [3]u8 {
+ if (dirty) return chrome.grip_dirty;
+ return if (focus) chrome.grip_focus_ring else chrome.tag_bg;
+}
+
+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 };
+}
+
+/// Cuts an instance's quad to a pixel rectangle, its atlas window with it so
+/// the glyph neither moves nor stretches; nothing left, an empty quad.
+fn clipInstance(instance: *CellInstance, r: [4]f32, win_w: f32, win_h: f32) void {
+ const x0 = (instance.x0 + 1) / 2 * win_w;
+ const x1 = (instance.x1 + 1) / 2 * win_w;
+ const y0 = (1 - instance.y0) / 2 * win_h;
+ const y1 = (1 - instance.y1) / 2 * win_h;
+ const left = @max(0, r[0] - x0);
+ const right = @max(0, x1 - r[2]);
+ const top = @max(0, r[1] - y0);
+ const bottom = @max(0, y1 - r[3]);
+ if (x1 - right <= x0 + left or y1 - bottom <= y0 + top) {
+ instance.x1 = instance.x0;
+ instance.y1 = instance.y0;
+ return;
+ }
+ instance.x0 += left / win_w * 2;
+ instance.u0 += left / atlas_w;
+ instance.x1 -= right / win_w * 2;
+ instance.u1 -= right / atlas_w;
+ instance.y0 -= top / win_h * 2;
+ instance.v0 += top / atlas_h;
+ instance.y1 += bottom / win_h * 2;
+ instance.v1 -= bottom / atlas_h;
+}
+
fn emitSurfaceCell(
g: *Gui,
chrome: *const pardes.Chrome,
@@ -6054,33 +6124,82 @@ fn emitSurfaceCell(
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page);
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page);
+ // A pane's grip cells are the cell shells' form (a dirty `*`): the
+ // button draws its own glyph over a blank of them.
+ var blank = line[at];
+ blank.text[0] = ' ';
+ blank.len = 1;
+ if (here.pane_grip)
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, (&blank)[0..1], 0, null, old_layer, false, page)
+ else
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page);
// A grip's glyph is the cell at its left edge, and a blank one is
// no glyph: its marks are decor.
const glyph = &line[at - @min(here.grip_offset, at)];
- const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible;
+ const grip = here.grip and here.grip_top and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible;
const offset: u16 = if (grip) @min(here.grip_offset, at) else 0;
// An anchor starts at its column's rule: its left edge is inset by
// its top margin so the band frames it on the left as above and
// below, and its mark moves right by half that to stay centered.
const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0;
+ // A grip is acme's button: its fill is this cell's ground, cut to
+ // the button (and a dirty one's inset a pixel inside its ring, so
+ // the ring reads round it), its glyph centered in it.
+ const button = if (here.grip and here.grip_top) gripButton(g, chrome, col - @min(here.grip_offset, @as(u16, @intCast(at))), row, body_layout, win_h) else null;
if (grip) {
// A grip is one glyph centered across the gutter, not text: it is
// looked up by its codepoint's glyph, never shaped.
const drawn = glyphForCodepoint(g, cellCodepoint(glyph));
const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true });
const slot = cached orelse g.space_slot;
- const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0;
+ const b = button.?;
+ const center = (b[0] + b[2]) / 2 - @as(f32, @floatFromInt(col - offset)) * body_layout.w;
+ const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) + @as(f32, @floatFromInt(config.GUTTER * g.cell_w)) / 2 - center else 0;
const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page);
- instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255;
- instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255;
- instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255;
+ const ink = if (here.pane_grip) inkOn(gripFill(chrome, here.focus, here.dirty)) else resolved.fg;
+ instances[next.*].fr = @as(f32, @floatFromInt(ink[0])) / 255;
+ instances[next.*].fg = @as(f32, @floatFromInt(ink[1])) / 255;
+ instances[next.*].fb = @as(f32, @floatFromInt(ink[2])) / 255;
instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w;
instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h;
instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h;
}
instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
- if (inset > 0 and (!grip or offset == 0)) {
+ if (button) |b| {
+ // acme's button fills its tag row from the rule above to the rule
+ // below: the underlay is its fill, cut to its columns; a pane's
+ // from its region's state, not the grid's cell form (whose dirty
+ // `*` is the cell shells'), a column's its cell's own ground.
+ const base = &instances[next.* - 1];
+ const own: [3]f32 = .{ instances[next.*].br, instances[next.*].bg, instances[next.*].bb };
+ if (here.pane_grip) {
+ const fill = gripFill(chrome, here.focus, here.dirty);
+ base.br = @as(f32, @floatFromInt(fill[0])) / 255;
+ base.bg = @as(f32, @floatFromInt(fill[1])) / 255;
+ base.bb = @as(f32, @floatFromInt(fill[2])) / 255;
+ } else {
+ base.br = own[0];
+ base.bg = own[1];
+ base.bb = own[2];
+ }
+ base.effect &= ~clear_bg_bit;
+ clipInstance(base, .{ b[0], -1e9, b[2], 1e9 }, win_w, win_h);
+ // Over it, only its own glyph; the band round it and the ring
+ // are decor (gripDecor).
+ if (here.pane_grip and !grip) {
+ instances[next.*].u0 = @as(f32, @floatFromInt(g.space_slot.u)) / atlas_w;
+ instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
+ instances[next.*].v0 = @as(f32, @floatFromInt(g.space_slot.v)) / atlas_h;
+ instances[next.*].v1 = @as(f32, @floatFromInt(g.space_slot.v + g.tagline_height)) / atlas_h;
+ }
+ // Its ground is the fill again (a see-through ground would be
+ // written, not blended, in an opaque window).
+ instances[next.*].br = base.br;
+ instances[next.*].bg = base.bg;
+ instances[next.*].bb = base.bb;
+ instances[next.*].effect &= ~clear_bg_bit;
+ clipInstance(&instances[next.*], b, win_w, win_h);
+ } else if (inset > 0) {
instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2;
instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w;
}
@@ -6091,6 +6210,102 @@ fn emitSurfaceCell(
next.* += 1;
}
+test "every pixel of a grip column is band, button or ring, and the button sits over the scrollbar" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 20 });
+ defer core.deinit();
+ const pane = try core.setTestFile("text\n" ** 60);
+ pane.tag.own = try gpa.dupe(u8, "second\nthird");
+ core.newScratchBelow(0);
+ for ([_]struct { w: u32, h: u32, tag: u32 }{ .{ .w = 10, .h = 20, .tag = 12 }, .{ .w = 17, .h = 35, .tag = 30 }, .{ .w = 9, .h = 19, .tag = 15 }, .{ .w = 12, .h = 24, .tag = 24 } }) |m| for ([_]bool{ false, true }) |bottom| for ([_]usize{ 0, 1 }) |focus| for ([_]bool{ false, true }) |dirty| {
+ core.settings.tag_bottom = bottom;
+ core.active = focus;
+ pane.file.?.revision = pane.file.?.saved_revision + @intFromBool(dirty);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ var g: Gui = undefined;
+ g.words = .{};
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = m.w;
+ g.cell_h = m.h;
+ g.tagline_width = m.w;
+ g.tagline_height = m.tag;
+ g.space_slot = .{ .u = 0, .v = 0 };
+ const layout = fixedCellLayout(&g);
+ const win_w: f32 = @floatFromInt(60 * m.w);
+ const win_h: f32 = @floatFromInt(20 * m.h);
+ const cover = try gpa.alloc(Cover, @as(usize, s.cols) * s.rows);
+ defer gpa.free(cover);
+ coverFrame(cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList());
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, layout, win_w, win_h, true);
+ const grip = s.region(.grip, 0).?;
+ // The grip column's pixels, band first, then its cells, then decor.
+ const x_px: u32 = grip.rect.x * m.w;
+ const w_px: u32 = config.GUTTER * m.w;
+ const y_px: u32 = grip.rect.y * m.h;
+ const h_px: u32 = grip.rect.h * m.h;
+ const pixels = try gpa.alloc(?[3]f32, w_px * h_px);
+ defer gpa.free(pixels);
+ @memset(pixels, null);
+ const paint = struct {
+ fn quad(into: []?[3]f32, x0n: f32, y0n: f32, x1n: f32, y1n: f32, rgb: [3]f32, ww: f32, wh: f32, ox: u32, oy: u32, pw: u32, ph: u32) void {
+ const px0: i64 = @intFromFloat(@round((x0n + 1) / 2 * ww));
+ const px1: i64 = @intFromFloat(@round((x1n + 1) / 2 * ww));
+ const py0: i64 = @intFromFloat(@round((1 - y0n) / 2 * wh));
+ const py1: i64 = @intFromFloat(@round((1 - y1n) / 2 * wh));
+ var y = @max(py0, oy);
+ while (y < @min(py1, oy + ph)) : (y += 1) {
+ var x = @max(px0, ox);
+ while (x < @min(px1, ox + pw)) : (x += 1) into[@intCast((y - oy) * pw + (x - ox))] = rgb;
+ }
+ }
+ };
+ var instances: [4]CellInstance = undefined;
+ for (0..grip.rect.h) |line| for (0..config.GUTTER) |dx| {
+ const col: u16 = grip.rect.x + @as(u16, @intCast(dx));
+ const row: u16 = grip.rect.y + @as(u16, @intCast(line));
+ const at = @as(usize, row) * s.cols;
+ var next: u32 = 0;
+ emitSurfaceCell(&g, &s.chrome, &instances, &next, col, row, layout, win_w, win_h, null, s.cells[at..][0..s.cols], cover[at..][0..s.cols], col, null, false, false, Ground.opaqueRgb(bg_default));
+ for (instances[0..next]) |i| paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px);
+ };
+ for (g.decor.items) |i| if (i.fr >= 1 and i.clip_x1 < i.clip_x0) paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px);
+ const rgb = struct {
+ fn of(v: [3]u8) [3]f32 {
+ return .{ @as(f32, @floatFromInt(v[0])) / 255, @as(f32, @floatFromInt(v[1])) / 255, @as(f32, @floatFromInt(v[2])) / 255 };
+ }
+ }.of;
+ const chrome = &s.chrome;
+ const band = if (focus == 0) chrome.tag_focus_bg else chrome.tag_bg;
+ // Band, fills, rings, the rules between stacked panes and under the
+ // tag, and the mode marks' ink (the grip's first cell's).
+ const ink = inkOn(gripFill(chrome, grip.active, grip.dirty));
+ const allowed = [_][3]f32{ rgb(band), rgb(core.chromeTheme().box), rgb(pardes.draw.boxDirty(core)), rgb(chrome.grip_border), rgb(chrome.grip_focus_ring), rgb(chrome.border), rgb(ink), rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)) };
+ for (pixels, 0..) |pixel, n| {
+ const got = pixel orelse return error.TestUnpaintedGripPixel;
+ const known = for (allowed) |a| {
+ if (std.meta.eql(a, got)) break true;
+ } else false;
+ // Under the first row there is no button: band and rules only.
+ if (n / w_px >= m.h and n / w_px < h_px - 1 and !std.meta.eql(got, rgb(band)) and !std.meta.eql(got, rgb(chrome.border)))
+ return error.TestButtonUnderFirstRow;
+ if (!known) std.debug.print("sliver {any} at {d},{d} of {d}x{d} (metrics {d}x{d}/{d}, bottom {}, focus {d}, dirty {}) page {any}\n", .{ got, n % w_px, n / w_px, w_px, h_px, m.w, m.h, m.tag, bottom, focus, dirty, s.chrome.page });
+ try std.testing.expect(known);
+ }
+ // The rule under the tag runs unbroken across the grip column.
+ if (!bottom) for (pixels[(h_px - 1) * w_px ..][0..w_px]) |pixel|
+ try std.testing.expectEqual(rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)), pixel.?);
+ // The button's edges are the scrollbar's.
+ const button = gripButton(&g, chrome, grip.rect.x, grip.rect.y, layout, win_h);
+ const column = scrollColumn(&g, chrome, s.region(.rail, 0).?.rect.x, layout);
+ try std.testing.expectEqual(column[0], button[0]);
+ try std.testing.expectEqual(column[1], button[2]);
+ };
+ pane.file.?.revision = pane.file.?.saved_revision;
+}
+
test "small tagline fonts leave no background gutters between adjacent tags" {
const chrome: pardes.Chrome = .{};
var g: Gui = undefined;
@@ -6778,9 +6993,9 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" {
try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0));
try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0));
try std.testing.expect(!cover[2].grip);
- // A column's grip has no glyph of a pane's.
+ // A column's grip is a button too, cut the same way (it has no glyph).
coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }});
- try std.testing.expect(!cover[0].grip);
+ try std.testing.expect(cover[0].grip);
coverFrame(&cover, 6, 2, &.{}, &.{}, &grip);
const chrome: pardes.Chrome = .{};
@@ -7714,6 +7929,12 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
for (groups.items[0..groups.len], 0..) |*group, index| {
group.decor_start = @intCast(g.decor.items.len);
defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start;
+ // The grips' buttons before any rule: the rules over their rows cut
+ // them, and the band round them never covers a rule.
+ if (index <= groups.tracks) {
+ const closing = if (group.track) |active| active.phase == .closing else false;
+ try gripDecor(g, gpa, if (closing) surface.previous_regions else regions, if (closing) surface.previous_cells else surface.cells, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, group.track);
+ }
if (index == 0) {
const top = for (regions) |region| {
if (region.kind == .workspace_tag) break region.rect.h;
@@ -7723,7 +7944,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
.grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true,
else => {},
} else false;
- const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height);
+ // 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));
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);
@@ -7732,7 +7954,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
for (regions) |bar| {
if (bar.kind != .column_grip) continue;
const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h;
- try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h);
+ try addDecor(g, gpa, 0, y - rulePx(chrome), win_w, y, chrome.border, 1, null, win_w, win_h);
break;
}
// A tag on the last row runs on through the pixels under the grid.
@@ -7756,7 +7978,6 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
nbodies += 1;
};
const bodies = body_buf[0..nbodies];
- const grid = if (closing) surface.previous_cells else surface.cells;
try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track);
// A single spine runs down each column, through every tag and body.
if (index == 0) for (regions) |column| {
@@ -7765,9 +7986,10 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h;
const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h;
const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1;
- if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h);
+ // None at the window's edge: nothing to rule apart there.
+ if (column.rect.x == 0) continue;
+ if (y1 > y0) try addDecor(g, gpa, x, y0, x + rulePx(chrome), y1, chrome.border, 1, null, win_w, win_h);
};
- try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track);
// Cast last in the group, over its cells, images and chrome: a
// lifted pane's shadow darkens its neighbours' rails and rules
// as it darkens their text, and never the pane itself.
@@ -7837,47 +8059,210 @@ 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.border, 1, track, win_w, win_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);
+ // Stacked panes are ruled apart as columns are, where one pane's
+ // body meets the next one's tag (acme's Border).
+ {
+ const pane_top = @min(tag.rect.y, rail.rect.y);
+ const stacked = for (regions) |other| {
+ if ((other.kind == .body or other.kind == .tag) and other.owner != rail.owner and other.rect.y + other.rect.h == pane_top and
+ other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true;
+ } else false;
+ // Inside the tag band's slack, never over its glyphs: the top
+ // slack of a tag on top, the bottom slack of the one above when
+ // tags are at the bottom; a pixel at least.
+ const y = @as(f32, @floatFromInt(pane_top)) * layout.h;
+ 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);
+ } 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);
+ }
+ }
const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h;
const body_end = rail.rect.y + rail.rect.h;
const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h;
const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1;
+ // A theme's own track fills its width, the thumb one pixel short
+ // of it so the track edges it (acme's scrollbar); else a thin rail.
+ // acme's scrollbar: the track fills the scroll column, under the
+ // grip's button; the thumb is a pixel short of its right edge, so
+ // the track edges it.
+ const column = scrollColumn(g, chrome, rail.rect.x, layout);
try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h);
- try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h);
+ try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h);
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, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
+ try addDecor(g, gpa, column[0], thumb_y0, column[1] - 1, thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
}
}
}
+/// Halfway between two colours.
+fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, a, b) |*o, x, y| o.* = @intCast((@as(u16, x) + y) / 2);
+ return out;
+}
+
+/// 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));
+}
+
/// The two short bars on each row of a grip with no mode glyph there,
/// centered in its anchor. Only the panes of group `index`.
-fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
+fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
if (g.tagline_height < 6 or g.tagline_width < 3) return;
for (regions) |grip| {
- if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue;
+ if ((grip.kind != .grip and grip.kind != .column_grip) or groupOf(groups, grip.serial) != index) continue;
+ if (grip.rect.h == 0 or grip.rect.y >= rows) continue;
+ const button = gripButton(g, chrome, grip.rect.x, grip.rect.y, layout, win_h);
+ // Round the button, its first row is the tag's band: nothing of the
+ // cells under it shows there.
+ const band = if (grip.active) chrome.tag_focus_bg else chrome.tag_bg;
+ const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w;
+ const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w;
+ try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h);
+ if (grip.kind != .grip) continue;
+ // acme's button ring, two pixels (ButtonBorder) inside the button:
+ // the focused one's in its own fill. An unfocused dirty one keeps a
+ // pixel of band inside its ring, so the four states differ in shape
+ // whatever a theme's colours (a focused dirty one is acme's
+ // modbutton, filled up to its ring).
+ {
+ // The rules drawn over the row (paneDecor, after this) cut into
+ // it: the ring runs inside them, from rule to rule.
+ const tag = for (regions) |part| {
+ if (part.kind == .tag and part.owner == grip.owner) break part;
+ } else grip;
+ const tag_bottom = for (regions) |part| {
+ if (part.kind == .rail and part.owner == grip.owner) break part.rect.y < tag.rect.y;
+ } else false;
+ const stacked = !tag_bottom and for (regions) |other| {
+ 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 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);
+ 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 x = grip.rect.x;
for (0..grip.rect.h) |line| {
const y = grip.rect.y + @as(u16, @intCast(line));
if (x >= cols or y >= rows) continue;
const cell = &cells[@as(usize, y) * cols + x];
if (cell.default or cell.printableAscii() != ' ') continue;
- const rgb = switch (cell.style.fg) {
- .rgb => |rgb| rgb,
- else => continue,
- };
- const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2));
+ // The marks are in the button's ink, from the region's state.
+ const rgb = inkOn(gripFill(chrome, grip.active, grip.dirty));
+ // The marks are the first row's alone, centered in the button.
+ 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, layout.w - 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);
}
}
}
+test "a theme's own rules and scrollbar are drawn at its widths: acme's 2px black rules and 12px track" {
+ 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());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.rule_px);
+ // acme's 12px, at GripWidth's 150% default.
+ try std.testing.expectEqual(@as(u8, 18), s.chrome.rail_px);
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 16;
+ g.cell_h = 20;
+ g.tagline_width = 10;
+ g.tagline_height = 12;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 480, true);
+ const px = struct {
+ fn w(item: CellInstance) f32 {
+ return (item.x1 - item.x0) / 2 * 1280;
+ }
+ };
+ var spines: usize = 0;
+ var tracks: usize = 0;
+ for (g.decor.items) |item| {
+ const black = item.br == 0 and item.bg == 0 and item.bb == 0;
+ // No rule at the window's left edge; one 2px spine where columns meet.
+ if (black and @abs(px.w(item) - 2) < 0.01 and item.y1 < -0.9) {
+ try std.testing.expect(item.x0 > -1);
+ spines += 1;
+ }
+ const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g));
+ if (@abs(item.br - @as(f32, 0x99) / 255) < 1e-3 and @abs(item.bb - @as(f32, 0x4c) / 255) < 1e-3 and @abs(px.w(item) - (column[1] - column[0])) < 0.01) tracks += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 1), spines);
+ try std.testing.expect(tracks >= 2);
+}
+
+test "the rule between stacked panes stays in the tag band's slack, at any tagline size" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ core.newScratchBelow(0);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ const lower = for (s.regionList()) |region| {
+ if (region.kind == .tag and region.rect.y > 2) break region;
+ } else return error.TestExpectedStackedTag;
+ for ([_]u32{ 12, 20 }) |tagline_h| {
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = tagline_h;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true);
+ const top = @as(f32, @floatFromInt(lower.rect.y)) * 20;
+ const slack: f32 = @floatFromInt(@max(1, pardes.taglineBandOffset(lower.rect.y, 480, 20, tagline_h)));
+ var found = false;
+ for (g.decor.items) |item| {
+ const y0 = (1 - item.y0) / 2 * 480;
+ const y1 = (1 - item.y1) / 2 * 480;
+ if (@abs(y0 - top) > 0.01 or @abs(item.br - @as(f32, @floatFromInt(s.chrome.border[0])) / 255) > 1e-3) continue;
+ found = true;
+ try std.testing.expect(y1 - y0 <= slack + 0.01);
+ }
+ try std.testing.expect(found);
+ }
+}
+
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 });
@@ -7905,15 +8290,23 @@ test "decor is read from the regions, each pane's in its own group" {
// workspace tag; a rail two pixels wide at each pane's.
for (core.col_x[0..core.ncol]) |x| {
const left = ndc.x(@as(f32, @floatFromInt(x)) * 10);
+ // Two pixels (rule_px), and none at the window's own edge.
const spine = for (g.decor.items) |item| {
- if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item;
- } else return error.TestExpectedSpine;
- try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001);
- try std.testing.expectEqual(@as(f32, -1), spine.y1);
+ if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001 and
+ @abs(item.y0 - (1 - 20.0 / 480.0 * 2)) < 0.0001 and std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &.{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 })) break item;
+ } else null;
+ try std.testing.expectEqual(x == 0, spine == null);
+ if (spine) |rule| {
+ try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), rule.y0, 0.0001);
+ try std.testing.expectEqual(@as(f32, -1), rule.y1);
+ }
+ // The scrollbar fills the scroll column right of the rule.
+ const column = scrollColumn(&g, &s.chrome, x, layout);
const rail = for (g.decor.items) |item| {
- if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item;
+ if (@abs(item.x0 - ndc.x(column[0])) < 0.0001 and @abs(item.x1 - ndc.x(column[1])) < 0.0001 and
+ @abs(item.br - @as(f32, @floatFromInt(s.chrome.scroll_track[0])) / 255) < 0.0001) break item;
} else return error.TestExpectedRail;
- try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001);
+ _ = rail;
}
try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count);
const still = groups.items[0].decor_count;
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 1c21d314..b28e02fa 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -308,6 +308,7 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
.lift => try w.print("{s} {s}\n", .{ word, @tagName(s.lift) }),
.motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }),
.inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }),
+ .grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }),
}
}
try w.writeAll("LocationsConfig ");
diff --git a/src/surface.zig b/src/surface.zig
index f16993a8..bc0a019d 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -356,6 +356,8 @@ pub const Region = struct {
rect: layout.Rect,
/// The pane or column has the keyboard's focus.
active: bool = false,
+ /// A grip's pane has changes it has not saved.
+ dirty: bool = false,
/// A rail's thumb: rows from the rail's top, and how many. Zero rows is
/// a rail with no thumb (an image). A native PDF's thumb is measured in
/// document pixels and filled in by Pdf.draw while it paints, so read
@@ -403,6 +405,19 @@ pub const Chrome = struct {
sel_bg: [3]u8 = .{ 0, 0, 0 },
/// How a lifted part stands off the page (config.Runtime.LiftStyle).
lift_style: u8 = 0,
+ /// Rule width in pixels (colors.Theme.rule_px).
+ rule_px: u8 = 2,
+ /// The ring round a grip, acme's button: unfocused, and focused.
+ grip_border: [3]u8 = .{ 0, 0, 0 },
+ grip_focus_ring: [3]u8 = .{ 0, 0, 0 },
+ /// A dirty pane's button fill.
+ grip_dirty: [3]u8 = .{ 0, 0, 0 },
+ /// The rule between a pane's tag and its body; null is halfway between
+ /// the tag's ground and the page the shell actually draws.
+ tag_rule: ?[3]u8 = null,
+ /// The scroll column's width, button and scrollbar alike, in pixels at
+ /// a 17px tagline (acme's 12 at its font); a pixel shell scales it.
+ rail_px: u8 = 12,
};
/// The canonical interface: what a frame of pardes IS. The tty shell writes
diff --git a/src/tagline.zig b/src/tagline.zig
index 59c51b4d..d0907cbd 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -24,6 +24,12 @@ pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig";
pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
pub const command_pane_tail = " " ++ config.command_pane_builtins_str;
const dirty_marker = " *";
+
+/// A file with changes it has not saved.
+pub fn dirty(pane: *const panes.Pane) bool {
+ const f = pane.file orelse return false;
+ return panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision;
+}
pub const legacy_pane_tail = " Del";
pub const legacy_file_pane_tail = " Save Del";
// The defaults from the release before Newtty joined every tagline. Recognized
@@ -136,7 +142,7 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
}
if (pane.file) |f| {
const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
- if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision)
+ if (dirty(pane))
return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker });
return name;
}
diff --git a/src/themes/acme.zig b/src/themes/acme.zig
index d91e626b..17ef4224 100644
--- a/src/themes/acme.zig
+++ b/src/themes/acme.zig
@@ -6,20 +6,28 @@
//! in) is a mid grey, and 15 (bright white) is BLACK, because a program asking
//! for the brightest color on a light page means "make this stand out".
//!
-//! The selection is acme's own: plan9's DDarkyellow #eeee9e, the colour acme
-//! sweeps text with, and black ink over it — acme changes the paper under a
-//! selection and never the letters. Out of the same draw.h palette the
-//! scrollbar's #99994c (DYellowgreen) is already taken from.
+//! Every colour below that acme has is acme's own, from plan9port's acme.c
+//! iconinit and draw.h: the tag #eaffff and body #ffffea (each 25% of
+//! DPalebluegreen or DPaleyellow over white), their selections #9eeeee and
+//! #eeee9e with black ink (acme changes the paper under a selection, never
+//! the letters), the exec and look sweeps #aa0000 and #006600 with white ink,
+//! the scrollbar's #99994c track under a #ffffea thumb, 12px wide, the
+//! #8888cc (DPurpleblue) line between a tag and its body and the column
+//! button, and 2px black rules between columns, windows and tags.
+//!
+//! acme has no focus colour; pardes needs one, so the focused pane's button
+//! is filled #8888cc (the column button's look) and every other pane's is
+//! acme's own: #eaffff in a 2px #8888cc ring, #000099 inside it when dirty.
pub const theme = .{
.name = "acme",
.bg = .{ 0xff, 0xff, 0xea },
.fg = .{ 0x00, 0x00, 0x00 },
- .sel_bg = .{ 0xfa, 0xfa, 0x92 },
+ .sel_bg = .{ 0xee, 0xee, 0x9e },
.sel_fg = .{ 0x00, 0x00, 0x00 },
.tag_bg = .{ 0xea, 0xff, 0xff },
.tag_fg = .{ 0x00, 0x00, 0x00 },
- .box = .{ 0x66, 0x3f, 0xe0 },
- .box_dim = .{ 0x35, 0x3a, 0x58 },
+ .box = .{ 0x88, 0x88, 0xcc },
+ .box_dim = .{ 0xea, 0xff, 0xff },
.kw = .{ 0x97, 0x0d, 0xae },
.str = .{ 0x00, 0x77, 0x33 },
.num = .{ 0xa0, 0x52, 0x00 },
@@ -27,6 +35,17 @@ pub const theme = .{
.lineno = .{ 0x99, 0x99, 0x80 },
.scroll_track = .{ 0x99, 0x99, 0x4c },
.scroll_thumb = .{ 0xff, 0xff, 0xea },
+ .border = .{ 0x00, 0x00, 0x00 },
+ .tag_rule = .{ 0x88, 0x88, 0xcc },
+ .rule_px = 2,
+ .box_border = .{ 0x88, 0x88, 0xcc },
+ .box_dirty = .{ 0x00, 0x00, 0x99 },
+ .rail_px = 12,
+ .tag_sel_bg = .{ 0x9e, 0xee, 0xee },
+ .sweep_bg = .{ .{ 0xee, 0xee, 0x9e }, .{ 0xaa, 0x00, 0x00 }, .{ 0x00, 0x66, 0x00 } },
+ .sweep_fg = .{ .{ 0x00, 0x00, 0x00 }, .{ 0xff, 0xff, 0xff }, .{ 0xff, 0xff, 0xff } },
+ .column_box = .{ 0x88, 0x88, 0xcc },
+ .column_box_dim = .{ 0x88, 0x88, 0xcc },
.palette = .{
.{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 },
.{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 },