summaryrefslogtreecommitdiff
path: root/src/draw.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/draw.zig')
-rw-r--r--src/draw.zig572
1 files changed, 557 insertions, 15 deletions
diff --git a/src/draw.zig b/src/draw.zig
index 280b5e90..963d61c1 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -18,6 +18,7 @@ const mouse = @import("mouse.zig");
const tagline = @import("tagline.zig");
const body_layer = @import("body_layer.zig");
const Messages = @import("Messages.zig");
+const builtins = @import("builtins.zig");
const Color = @import("surface.zig").Color;
const FontRole = @import("surface.zig").FontRole;
const CellStyle = @import("surface.zig").CellStyle;
@@ -57,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;
@@ -119,6 +120,29 @@ pub fn place(p: *Pardes, s: *Surface) void {
s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } });
// A column carried to the window's right edge shows where it lands as a
// rail in the last cell: the layers under it stop a cell short.
+ // Lift: the focused pane rises, the others settle back, each on its
+ // spring; a notice floats while Lift is on.
+ const lift = liftStyle(p);
+ for (&p.panes, &p.lifts, &p.lift_serials, 0..) |slot, *spring, *serial, id| {
+ const pane = slot orelse continue;
+ if (serial.* != pane.serial) {
+ serial.* = pane.serial;
+ spring.* = .{};
+ }
+ // How focused it is, which both a lift and a dim follow: at this
+ // frame's own time, not the stepped animations' 16 ms grid. A grid
+ // snaps (it has no in-between), and so does nothing that shows it.
+ const shown = lift != .off or inactiveDim(p) != 0;
+ const motion = animation.Motion.of(if (shown and builtins.capabilities.lift) p.settings.motion else .off);
+ // A flavour exaggerates what is only a few pixels (Motion.gain).
+ spring.retarget(if (id == p.active) animation.Motion.of(p.settings.motion).gain else 0, p.now_ns, motion);
+ _ = spring.step(p.now_ns);
+ }
+ for (s.regions[0..s.nregions]) |*region| switch (region.kind) {
+ .grip, .tag, .body, .rail => region.lift = if (lift != .off) std.math.clamp(p.lifts[region.owner].value(p.now_ns), -1, 2) else 0,
+ .notice => region.lift = if (lift != .off) 1 else 0,
+ else => {},
+ };
if (p.drag == .column_move) rail: {
const d = p.drag.column_move;
const source = layout.columnBySerial(p, d.serial) orelse break :rail;
@@ -137,6 +161,185 @@ 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`).
+pub fn liftStyle(p: *const Pardes) config.Runtime.LiftStyle {
+ return switch (p.settings.lift) {
+ .auto => if (darkPage(p)) .off else .shadow,
+ else => |style| style,
+ };
+}
+
+/// How far, in percent, the unfocused panes' text fades toward its ground:
+/// InactiveDim, or under `Lift auto` on a dark page `auto_dim` while that
+/// is unset.
+pub fn inactiveDim(p: *const Pardes) u8 {
+ if (p.settings.inactive_dim != 0) return p.settings.inactive_dim;
+ return if (p.settings.lift == .auto and darkPage(p)) config.Runtime.auto_dim else 0;
+}
+
+fn darkPage(p: *const Pardes) bool {
+ return if (p.theme().bg) |bg| luminance(bg) < 0.18 else true;
+}
+
+/// sRGB channel to linear light.
+const linear: [256]f32 = blk: {
+ @setEvalBranchQuota(100_000);
+ var table: [256]f32 = undefined;
+ for (&table, 0..) |*out, i| {
+ const c = @as(f64, @floatFromInt(i)) / 255;
+ out.* = @floatCast(if (c <= 0.04045) c / 12.92 else std.math.pow(f64, (c + 0.055) / 1.055, 2.4));
+ }
+ break :blk table;
+};
+
+fn luminance(rgb: [3]u8) f32 {
+ return 0.2126 * linear[rgb[0]] + 0.7152 * linear[rgb[1]] + 0.0722 * linear[rgb[2]];
+}
+
+fn contrast(a: [3]u8, b: [3]u8) f32 {
+ const x = luminance(a);
+ const y = luminance(b);
+ return (@max(x, y) + 0.05) / (@min(x, y) + 0.05);
+}
+
+/// Pane `id`'s text, `amount` (0 to 1) of the way to its ground, on the grid
+/// and on every layer of it: its tag, its body, its notices. Its grip keeps
+/// its colour, which already says it is not the focused one.
+fn recede(p: *Pardes, s: *Surface, id: usize, amount: f32) void {
+ const th = p.theme();
+ const chrome = p.chromeTheme();
+ const r = p.rects[id];
+ const palette = th.palette;
+ var memo: Memo = .{};
+ const text_x = r.x + @min(config.GUTTER, r.w);
+ var row = r.y;
+ while (row < @min(@as(u32, r.y) + r.h, s.rows)) : (row += 1) {
+ var col = text_x;
+ while (col < @min(@as(u32, r.x) + r.w, s.cols)) : (col += 1) {
+ const cell = s.at(col, row);
+ if (cell.default) continue;
+ const tagline_role = cell.style.font_role == .tagline;
+ fadeStyle(&cell.style, if (tagline_role) chrome.tag_fg else th.fg, if (tagline_role) chrome.tag_bg else th.bg, palette, amount, &memo);
+ }
+ }
+ const body = &s.body_layers[id];
+ if (body.rows != 0) for (body.cells[0..@as(usize, body.cols) * body.rows]) |*cell| fadeStyle(&cell.style, th.fg, th.bg, palette, amount, &memo);
+ const tag = &s.tag_layers[id];
+ if (tag.rows != 0) for (tag.cells[0..@as(usize, tag.cols) * tag.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, tag.bg, palette, amount, &memo);
+ for (0..Pane.Notices.max) |i| {
+ const notice = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i];
+ if (notice.rows != 0) for (notice.cells[0..@as(usize, notice.cols) * notice.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, notice.bg, palette, amount, &memo);
+ }
+}
+
+/// The few ink and paper pairs a pane has, each faded once a frame.
+const Memo = struct {
+ keys: [32]?[6]u8 = @splat(null),
+ values: [32][3]u8 = undefined,
+
+ fn faded(memo: *Memo, ink: [3]u8, paper: [3]u8, amount: f32) [3]u8 {
+ const key = ink ++ paper;
+ const slot = (@as(usize, ink[0]) *% 31 +% ink[1] *% 7 +% ink[2] +% @as(usize, paper[0]) *% 13 +% paper[1] *% 3 +% paper[2]) % memo.keys.len;
+ if (memo.keys[slot]) |held| if (std.mem.eql(u8, &held, &key)) return memo.values[slot];
+ // Contrast falls as the ink nears its paper: the most of `amount`
+ // that keeps the pair at its own contrast or 4.5, whichever is lower.
+ const floor = @min(contrast(ink, paper), 4.5);
+ var t = amount;
+ if (contrast(blend(ink, paper, t), paper) < floor) {
+ var lo: f32 = 0;
+ var hi: f32 = t;
+ for (0..8) |_| {
+ const mid = (lo + hi) / 2;
+ if (contrast(blend(ink, paper, mid), paper) >= floor) lo = mid else hi = mid;
+ }
+ t = lo;
+ }
+ memo.keys[slot] = key;
+ memo.values[slot] = blend(ink, paper, t);
+ return memo.values[slot];
+ }
+};
+
+/// A cell's ink blended `amount` of the way toward its paper, never below
+/// the pair's own contrast or 4.5, whichever is lower (docs/effects.md):
+/// faint resolved first, reverse honoured, as InactiveDim always did.
+fn fadeStyle(style: *CellStyle, default_fg: ?[3]u8, default_bg: ?[3]u8, palette: ?[16][3]u8, amount: f32, memo: *Memo) void {
+ if (style.invisible) return;
+ var fg = resolved(style.fg, default_fg, palette) orelse return;
+ const bg = resolved(style.bg, default_bg, palette) orelse return;
+ if (style.dim) for (&fg) |*channel| {
+ channel.* = @intCast(@as(u16, channel.*) * 6 / 10);
+ };
+ const ink = if (style.reverse) bg else fg;
+ const paper = if (style.reverse) fg else bg;
+ const rgb = memo.faded(ink, paper, amount);
+ if (style.reverse) {
+ style.bg = .{ .rgb = rgb };
+ if (style.dim) style.fg = .{ .rgb = fg };
+ } else style.fg = .{ .rgb = rgb };
+ style.dim = false;
+}
+
+/// `t` of the way from one colour to the other in linear light (ยง8.2), so a
+/// coloured ink does not muddy on its way to the paper.
+fn blend(from: [3]u8, to: [3]u8, t: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, from, to) |*o, a, b| {
+ const l = linear[a] * (1 - t) + linear[b] * t;
+ o.* = encoded[@intFromFloat(@round(std.math.clamp(l, 0, 1) * (encoded.len - 1)))];
+ }
+ return out;
+}
+
+/// Linear light back to an sRGB channel, in 4096 steps.
+const encoded: [4096]u8 = blk: {
+ @setEvalBranchQuota(1_000_000);
+ var table: [4096]u8 = undefined;
+ for (&table, 0..) |*out, i| {
+ const l = @as(f64, @floatFromInt(i)) / 4095;
+ const c = if (l <= 0.0031308) l * 12.92 else 1.055 * std.math.pow(f64, l, 1.0 / 2.4) - 0.055;
+ out.* = @intFromFloat(@round(c * 255));
+ }
+ break :blk table;
+};
+
+fn resolved(color: Color, default: ?[3]u8, palette: ?[16][3]u8) ?[3]u8 {
+ return switch (color) {
+ .rgb => |rgb| rgb,
+ .default => default,
+ // A theme's own sixteen, as the painters give them (Terminal.palColor).
+ .index => |index| if (palette != null and index < 16) palette.?[index] else if (comptime pardes.terminal_panes) blk: {
+ const rgb = @import("ghostty-vt").color.default[index];
+ break :blk .{ rgb.r, rgb.g, rgb.b };
+ } else null,
+ };
+}
+
pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
panes.File.refreshHighlights(p);
const s = &p.surface;
@@ -163,6 +366,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
// the pointer per-text (the macOS I-beam) read this as "target" and
// afford it the arrow instead.
if (p.look_hover_preview != null) s.pointer_shape = .target;
+ if (p.drag == .move or p.drag == .column_move) s.pointer_shape = .box;
for (&s.body_layers) |*layer| layer.rows = 0;
for (&s.tag_layers) |*layer| layer.rows = 0;
s.nimages = 0;
@@ -183,11 +387,22 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
.tag_fg = chrome.tag_fg,
.scroll_track = chrome.scroll_track,
.scroll_thumb = chrome.scroll_thumb,
+ .accent = chrome.box,
+ .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.
const whole = s.regions[0].rect;
s.fill(whole.x, whole.y, whole.w, whole.h, .{ .bg = .{ .rgb = chrome.border } });
+ for (0..p.ncol) |c| if (p.col_n[c] == 0) s.fill(p.col_x[c], p.topBarHeight() + p.columnBarHeight(), p.col_w[c], s.rows -| (p.topBarHeight() + p.columnBarHeight()), .{ .bg = .{ .rgb = chrome.empty_col } });
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
@@ -202,6 +417,13 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
if (region.kind == .notice) try joinNotice(p, s, arena, region);
}
+ for (p.panes, p.lifts, 0..) |slot, spring, id| {
+ if (slot == null or id == p.active) continue;
+ const focus = spring.value(p.now_ns) / animation.Motion.of(p.settings.motion).gain;
+ const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - focus, 0, 1);
+ if (amount > 0.001) recede(p, s, id, amount);
+ }
+
try paintHeaders(p, s, arena);
switch (p.drag) {
@@ -477,16 +699,30 @@ 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,
};
- s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style);
+ // 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 = band },
+ .font_role = .tagline,
+ });
s.set(r.x, tag_y, switch (pane.body.mode) {
.normal => config.tag_normal,
.insert => config.tag_insert,
@@ -550,7 +786,8 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void {
const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10;
const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 };
- s.fill(p.col_x[c], region.rect.y, grip, column_rows, .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline });
+ s.fill(p.col_x[c], region.rect.y, grip, @min(1, column_rows), .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline });
+ s.fill(p.col_x[c], region.rect.y + 1, grip, column_rows -| 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
}
const top = if (p.header_focus and p.header_column == c) p.header_top else 0;
@@ -683,7 +920,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 };
}
};
@@ -693,7 +930,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 };
}
}
@@ -939,7 +1176,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 };
}
}
@@ -959,7 +1196,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) {
@@ -968,10 +1205,100 @@ 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" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try p.setTestFile("text\n");
+ pane.tag.own = try std.testing.allocator.dupe(u8, "Second\nthird\nfourth");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ for ([_]bool{ false, true }) |bottom| {
+ p.settings.tag_bottom = bottom;
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const r = p.rects[0];
+ const top = p.tagTop(pane, r);
+ try std.testing.expect(pane.tag_rows >= 3);
+ const chrome = p.chromeTheme();
+ const band = if (p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ for (0..config.GUTTER) |dx| {
+ const x = r.x + @as(u16, @intCast(dx));
+ try std.testing.expectEqual(Color{ .rgb = chrome.box }, s.at(x, top).style.bg);
+ for (1..pane.tag_rows) |line| {
+ const cell = s.at(x, top + @as(u16, @intCast(line)));
+ try std.testing.expectEqual(Color{ .rgb = band }, cell.style.bg);
+ try std.testing.expectEqualStrings(" ", cell.grapheme());
+ }
+ }
+ try std.testing.expectEqual(pane.tag_rows, s.region(.grip, 0).?.rect.h);
}
}
@@ -979,14 +1306,14 @@ test "place puts every part where the painters and layers put it, in every layou
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
defer p.deinit();
_ = try p.setTestFile("placed\n" ** 80);
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.newScratchBelow(0);
p.sync();
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
for (0..16) |step| {
p.settings.tag_bottom = step & 1 != 0;
- p.settings.column_tags = step & 2 != 0;
if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice"));
if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
@@ -1111,6 +1438,221 @@ test "a notice wider than its band, as a shader's compile error is, draws its en
}
}
+test "a lift is sampled at each frame's own time, and asks for frames only while it moves" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ // A grid has no in-between: there a lift snaps.
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const ms = std.time.ns_per_ms;
+ p.settings.lift = .shadow;
+ p.advance(1000 * ms);
+ _ = try p.render(arena.allocator());
+ p.advance(3000 * ms);
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ try std.testing.expect(!p.continuous());
+ p.active = 0;
+ var lifts: [3]f32 = undefined;
+ // 144 Hz: 6.9 ms apart, none of them on a 16 ms step of its own.
+ for (&lifts, 0..) |*lift, i| {
+ p.advance(3000 * ms + @as(u64, @intCast(i)) * 6_944_444);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ lift.* = for (s.regionList()) |region| {
+ if (region.kind == .grip and region.owner == p.active) break region.lift;
+ } else return error.TestExpectedGrip;
+ try std.testing.expect(p.continuous());
+ }
+ try std.testing.expect(lifts[0] < lifts[1] and lifts[1] < lifts[2]);
+ // Settled, it is exactly up, and the core is idle again.
+ p.advance(5000 * ms);
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ try std.testing.expect(!p.continuous());
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+}
+
+/// The contrast a pane's first body glyph `g` is drawn at, on the grid.
+fn glyphContrast(p: *Pardes, s: *Surface, id: usize, g: u8) f32 {
+ const r = p.rects[id];
+ const th = p.theme();
+ var row = p.bodyTop(p.panes[id].?, r);
+ while (row < r.y + r.h) : (row += 1) {
+ var col = r.x + config.GUTTER;
+ while (col < r.x + r.w) : (col += 1) {
+ const cell = s.at(col, row);
+ if (cell.default or cell.len != 1 or cell.text[0] != g) continue;
+ var fg = resolved(cell.style.fg, th.fg orelse .{ 255, 255, 255 }, th.palette).?;
+ const bg = resolved(cell.style.bg, th.bg orelse .{ 0, 0, 0 }, th.palette).?;
+ if (cell.style.dim) for (&fg) |*channel| {
+ channel.* = @intCast(@as(u16, channel.*) * 6 / 10);
+ };
+ return contrast(fg, bg);
+ }
+ }
+ unreachable;
+}
+
+test "a focused pane's text is never at less contrast than an unfocused one's, in any Lift, dim or theme" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ 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();
+ const s_ns = std.time.ns_per_s;
+ var now: u64 = s_ns;
+ for (0..pardes.themes.len) |theme| {
+ colors.setThemeIndex(p, theme);
+ for (std.enums.values(config.Runtime.LiftStyle)) |style| for ([_]u8{ 0, 100 }) |dim| {
+ p.settings.lift = style;
+ p.settings.inactive_dim = dim;
+ var seen: [2]f32 = undefined;
+ for (&seen, [_]usize{ 0, 1 }) |*c, focus| {
+ p.active = focus;
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ now += 2 * s_ns;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ c.* = glyphContrast(p, try p.render(arena.allocator()), 0, 't');
+ }
+ try std.testing.expect(seen[0] >= seen[1] - 1e-3);
+ // And the unfocused one is still legible.
+ try std.testing.expect(seen[1] >= @min(seen[0], 4.5) - 2e-2);
+ };
+ }
+}
+
+test "a dim leaves the focused pane's tag, grip and cells exactly as they were" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.active = 0;
+ p.advance(std.time.ns_per_s);
+ const plain = try p.render(arena.allocator());
+ const r = p.rects[0];
+ var before: std.ArrayList(Cell) = .empty;
+ defer before.deinit(std.testing.allocator);
+ for (r.y..r.y + r.h) |row| try before.appendSlice(std.testing.allocator, plain.cells[row * plain.cols + r.x ..][0..r.w]);
+ const tag_bg = plain.tag_layers[0].bg;
+ const tag_cells = try std.testing.allocator.dupe(Cell, plain.tag_layers[0].cells[0 .. @as(usize, plain.tag_layers[0].cols) * plain.tag_layers[0].rows]);
+ defer std.testing.allocator.free(tag_cells);
+ p.settings.inactive_dim = 100;
+ p.advance(3 * std.time.ns_per_s);
+ const dimmed = try p.render(arena.allocator());
+ var i: usize = 0;
+ for (r.y..r.y + r.h) |row| for (dimmed.cells[row * dimmed.cols + r.x ..][0..r.w]) |cell| {
+ try std.testing.expect(cell.visuallyEqual(&before.items[i]));
+ i += 1;
+ };
+ try std.testing.expectEqual(tag_bg, dimmed.tag_layers[0].bg);
+ for (tag_cells, dimmed.tag_layers[0].cells[0..tag_cells.len]) |a, b| try std.testing.expect(a.visuallyEqual(&b));
+}
+
+test "a dim never takes a pair below its own contrast or 4.5" {
+ for (pardes.themes) |theme| {
+ const fg = theme.fg orelse continue;
+ const bg = theme.bg orelse continue;
+ for ([_]bool{ false, true }) |reverse| for ([_]bool{ false, true }) |faint| {
+ var style: CellStyle = .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .reverse = reverse, .dim = faint };
+ var ink = fg;
+ if (faint) for (&ink) |*channel| {
+ channel.* = @intCast(@as(u16, channel.*) * 6 / 10);
+ };
+ var memo: Memo = .{};
+ fadeStyle(&style, null, null, null, 1, &memo);
+ const after = contrast(style.fg.rgb, style.bg.rgb);
+ try std.testing.expect(after >= @min(contrast(ink, bg), 4.5) - 2e-2);
+ };
+ }
+}
+
+test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixteen" {
+ var memo: Memo = .{};
+ var low: CellStyle = .{ .fg = .{ .rgb = .{ 0x80, 0x80, 0x80 } }, .bg = .{ .rgb = .{ 0x60, 0x60, 0x60 } } };
+ fadeStyle(&low, null, null, null, 1, &memo);
+ try std.testing.expectEqual([3]u8{ 0x80, 0x80, 0x80 }, low.fg.rgb);
+ // acme's bright white is black: faded, it stays a grey, never a white.
+ const acme = for (pardes.themes) |theme| {
+ if (std.mem.eql(u8, theme.name, "acme")) break theme;
+ } else unreachable;
+ var bright: CellStyle = .{ .fg = .{ .index = 15 } };
+ memo = .{};
+ fadeStyle(&bright, acme.fg, acme.bg, acme.palette, 0.3, &memo);
+ const rgb = bright.fg.rgb;
+ try std.testing.expect(rgb[0] == rgb[1] and rgb[0] < 0xc0);
+ // Reverse: the paper moves, the ink stays.
+ var reverse: CellStyle = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = .{ 255, 255, 255 } }, .reverse = true };
+ memo = .{};
+ fadeStyle(&reverse, null, null, null, 0.3, &memo);
+ try std.testing.expectEqual([3]u8{ 0, 0, 0 }, reverse.fg.rgb);
+ try std.testing.expect(reverse.bg.rgb[0] < 255);
+}
+
+test "with no lift and no dim a focus switch asks for no frames" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.advance(std.time.ns_per_s);
+ _ = try p.render(arena.allocator());
+ p.active = 0;
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ try std.testing.expect(!p.continuous());
+}
+
+test "each Motion flavour reaches the lift as its own trajectory" {
+ // A grid snaps; the flavours are a pixel shell's.
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.settings.lift = .shadow;
+ var peaks: [5]f32 = undefined;
+ const ms = std.time.ns_per_ms;
+ var start: u64 = std.time.ns_per_s;
+ for (std.enums.values(animation.Motion.Flavour), &peaks) |flavour, *peak| {
+ p.settings.motion = flavour;
+ p.active = 1;
+ p.advance(start);
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ start += 3 * std.time.ns_per_s;
+ p.advance(start);
+ _ = try p.render(arena.allocator());
+ p.active = 0;
+ peak.* = 0;
+ var t: u64 = 0;
+ while (t <= 1500 * ms) : (t += 4 * ms) {
+ p.advance(start + t);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const lift = for (s.regionList()) |region| {
+ if (region.kind == .grip and region.owner == 0) break region.lift;
+ } else unreachable;
+ peak.* = @max(peak.*, lift);
+ }
+ start += 3 * std.time.ns_per_s;
+ }
+ // Bouncy and playful pass their (exaggerated) mark; smooth never does.
+ try std.testing.expect(peaks[2] <= 1.001 and peaks[3] > 1.25 and peaks[4] > peaks[3]);
+}
+
test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" {
// The snapshot harness runs the tty, whose tagline pitch is the body's,
// so it never reaches what a narrower tagline does to a notice's band:
@@ -1126,7 +1668,7 @@ test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics nev
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " };
- const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" };
+ const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" };
var text: std.ArrayListUnmanaged(u8) = .empty;
defer text.deinit(std.testing.allocator);
for (0..steps) |_| {