summaryrefslogtreecommitdiff
path: root/src/draw.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/draw.zig')
-rw-r--r--src/draw.zig330
1 files changed, 322 insertions, 8 deletions
diff --git a/src/draw.zig b/src/draw.zig
index 98932c23..d1d4c11b 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;
@@ -121,19 +122,24 @@ pub fn place(p: *Pardes, s: *Surface) void {
// 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.* = .{};
}
- // At this frame's own time, not the stepped animations' 16 ms grid.
- spring.retarget(if (p.settings.lift != .off and id == p.active) 1 else 0, p.now_ns);
+ // 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);
+ spring.retarget(if (id == p.active) 1 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 = p.lifts[region.owner].value(p.now_ns),
- .notice => region.lift = if (p.settings.lift != .off) 1 else 0,
+ .grip, .tag, .body, .rail => region.lift = if (lift != .off) p.lifts[region.owner].value(p.now_ns) else 0,
+ .notice => region.lift = if (lift != .off) 1 else 0,
else => {},
};
if (p.drag == .column_move) rail: {
@@ -154,6 +160,162 @@ pub fn place(p: *Pardes, s: *Surface) void {
}
}
+/// 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;
@@ -201,7 +363,9 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
.scroll_track = chrome.scroll_track,
.scroll_thumb = chrome.scroll_thumb,
.accent = chrome.box,
- .lift_style = @intFromEnum(p.settings.lift),
+ .sel_fg = p.theme().sel_fg,
+ .sel_bg = p.theme().sel_bg,
+ .lift_style = @intFromEnum(liftStyle(p)),
};
place(p, s);
// Separators have their own role; a scrollbar need not frame the page.
@@ -221,6 +385,12 @@ 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 amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - spring.value(p.now_ns), 0, 1);
+ if (amount > 0.001) recede(p, s, id, amount);
+ }
+
try paintHeaders(p, s, arena);
switch (p.drag) {
@@ -1128,18 +1298,25 @@ 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.settings.lift = .shadow;
+ 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(1000 * ms + @as(u64, @intCast(i)) * 6_944_444);
+ 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| {
@@ -1149,13 +1326,150 @@ test "a lift is sampled at each frame's own time, and asks for frames only while
}
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(2000 * ms);
+ 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 "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: