summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig192
1 files changed, 172 insertions, 20 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 4684ee56..70b23a86 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -19,6 +19,7 @@
//! file, and so a later builtin can enumerate the bindings the way Help already
//! enumerates the leader.
const std = @import("std");
+pub const animation = @import("animation.zig");
const ghostty_vt = @import("ghostty-vt");
const uucode = @import("uucode");
const mvzr = @import("mvzr");
@@ -660,6 +661,22 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" {
try std.testing.expect(new.checksum != old.checksum);
}
+ // Chrome animation reads a separate palette. Its ticks must never disturb
+ // the target-theme tint key or ask MuPDF for the same pixels again.
+ try std.testing.expect(p.themeAnimationActive());
+ for (0..animation.transition_steps) |_| {
+ p.update(.tick);
+ _ = frame.reset(.retain_capacity);
+ const tick_surface = try p.render(frame.allocator());
+ const ticked = try Capture.get(tick_surface);
+ try Capture.expectGeometry(themed, ticked);
+ for (themed, ticked) |once, after_tick| {
+ try std.testing.expectEqual(once.revision, after_tick.revision);
+ try std.testing.expectEqual(once.checksum, after_tick.checksum);
+ }
+ }
+ try std.testing.expect(!p.themeAnimationActive());
+
p.togglePdfTint(pane);
try std.testing.expectEqual(PdfTintMode.full, pv.tint);
_ = frame.reset(.retain_capacity);
@@ -1384,6 +1401,8 @@ test "startup config runs builtin lines in order and isolates bad lines" {
// well-formed-but-failing lines. `Kill trailing-garbage` must not be
// accepted as Kill, nor fall through to the shell from config.
try std.testing.expectEqualStrings("acme", p.theme().name);
+ try std.testing.expect(!p.themeAnimationActive());
+ try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
try std.testing.expect(!p.quit);
for (p.effects.items) |effect| switch (effect) {
.write => |w| {
@@ -1394,6 +1413,71 @@ test "startup config runs builtin lines in order and isolates bad lines" {
};
}
+test "runtime theme changes animate chrome and retarget without a jump" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer p.deinit();
+ const first = p.chromeTheme().*;
+ try std.testing.expect(!p.themeAnimationActive());
+
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ const acme_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "acme")) break i;
+ } else return error.MissingAcmeTheme;
+
+ p.setThemeIndex(dark_index);
+ // Target semantics are immediate, while the displayed chrome begins at
+ // the exact previous frame.
+ try std.testing.expectEqualStrings("dark", p.theme().name);
+ try std.testing.expectEqual(first, p.chromeTheme().*);
+ try std.testing.expect(p.themeAnimationActive());
+ p.update(.tick);
+ p.update(.tick);
+ p.update(.tick);
+ const midflight = p.chromeTheme().*;
+ try std.testing.expect(!std.meta.eql(first, midflight));
+
+ p.setThemeIndex(acme_index);
+ try std.testing.expectEqualStrings("acme", p.theme().name);
+ try std.testing.expectEqual(midflight, p.chromeTheme().*);
+ try std.testing.expect(p.themeAnimationActive());
+ for (0..animation.transition_steps) |_| p.update(.tick);
+ try std.testing.expect(!p.themeAnimationActive());
+ try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
+ // Extra ticks are inert at the exact endpoint.
+ p.update(.tick);
+ try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*);
+}
+
+test "render uses target content colors while chrome is in flight" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 });
+ defer p.deinit();
+ const source_chrome = p.chromeTheme().*;
+ const acme_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "acme")) break i;
+ } else return error.MissingAcmeTheme;
+ p.setThemeIndex(acme_index);
+
+ var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer frame.deinit();
+ const surface = try p.render(frame.allocator());
+ // The anchored topbar has not advanced from its source frame.
+ try std.testing.expectEqual(
+ Color{ .rgb = source_chrome.tag_bg },
+ surface.at(0, 0).style.bg,
+ );
+ // The document/terminal page does not ride the chrome animation: acme's
+ // target background is already present in the same render.
+ const r = p.rects[0];
+ const body = surface.at(r.x + r.w - 1, r.y + r.h - 1);
+ try std.testing.expectEqual(
+ Color{ .rgb = p.theme().bg.? },
+ body.style.bg,
+ );
+ try std.testing.expect(p.themeAnimationActive());
+}
+
test "pane-tag Exec prefers New and argument builtins before shell fallback" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 });
@@ -1674,6 +1758,10 @@ test "startup config overrides restored styling before initFromDump returns" {
);
defer restored.deinit();
try std.testing.expectEqualStrings("acme", restored.theme().name);
+ try std.testing.expect(!source.themeAnimationActive());
+ try std.testing.expect(!restored.themeAnimationActive());
+ try std.testing.expectEqual(ChromeTheme.fromTheme(source.theme()), source.chromeTheme().*);
+ try std.testing.expectEqual(ChromeTheme.fromTheme(restored.theme()), restored.chromeTheme().*);
}
/// How a `Chord` is SPELLED in the index. The named keys come from Key's OWN
@@ -1947,6 +2035,42 @@ comptime {
@compileError("theme name \"" ++ c.name ++ "\" is both ours and generated; rename the vendored source");
}
+/// The color roles attached to stable screen geometry. Document backgrounds,
+/// syntax, terminal ANSI palettes, and PDF tint colors deliberately are not
+/// here: those switch to `theme()` immediately while this small palette moves
+/// between themes over a handful of display frames.
+pub const ChromeTheme = struct {
+ tag_bg: [3]u8,
+ tag_fg: [3]u8,
+ box: [3]u8,
+ box_dim: [3]u8,
+ lineno: [3]u8,
+ scroll_track: [3]u8,
+ scroll_thumb: [3]u8,
+
+ pub fn fromTheme(th: *const Theme) ChromeTheme {
+ return .{
+ .tag_bg = th.tag_bg,
+ .tag_fg = th.tag_fg,
+ .box = th.box,
+ .box_dim = th.box_dim,
+ .lineno = th.lineno,
+ .scroll_track = th.scroll_track,
+ .scroll_thumb = th.scroll_thumb,
+ };
+ }
+
+ pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme {
+ var out: ChromeTheme = undefined;
+ inline for (@typeInfo(ChromeTheme).@"struct".fields) |field|
+ @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
+ return out;
+ }
+};
+
+const ChromeAnimation = animation.Transition(ChromeTheme);
+const initial_chrome = ChromeTheme.fromTheme(&themes[0]);
+
// ---- the boundary types ----
pub const Color = union(enum) { default, index: u8, rgb: [3]u8 };
@@ -3237,6 +3361,10 @@ pub const Pardes = struct {
/// hands out Pane.serial; monotonic, never reused
next_serial: u32 = 0,
theme_idx: usize = 0,
+ chrome_animation: ChromeAnimation = ChromeAnimation.init(initial_chrome),
+ /// False only while startup configuration or a dump restore is selecting
+ /// its first theme. No frame is rendered in that interval.
+ animate_theme_changes: bool = false,
colors_on: bool = true,
show_debug: bool = false,
/// the Crt builtin's CRT post-process, rendered by the gui shell only
@@ -3378,6 +3506,7 @@ pub const Pardes = struct {
}
p.sync();
p.applyStartupConfig();
+ p.finishThemeInitialization();
p.sync();
return p;
}
@@ -3676,7 +3805,7 @@ pub const Pardes = struct {
},
.pinch => |scale| p.ov_pinch_scale = scale,
.touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
- .tick => {},
+ .tick => p.chrome_animation.advance(),
}
_ = p.scratch.reset(.retain_capacity);
p.sync();
@@ -10403,6 +10532,7 @@ pub const Pardes = struct {
p.active = @min(st.active, MAX_PANES - 1);
p.sync();
p.applyStartupConfig();
+ p.finishThemeInitialization();
p.sync();
return p;
}
@@ -10626,11 +10756,32 @@ pub const Pardes = struct {
return &themes[p.theme_idx];
}
- /// The sole live-session theme mutation path. Text chrome reads the new
- /// theme next frame; only PDFs whose pixels depend on theme colors are
- /// marked stale. Untinted source rasters remain byte-for-byte resident.
+ /// Displayed colors for anchored UI chrome. Unlike `theme()`, this may be
+ /// between themes while a live transition is active.
+ pub fn chromeTheme(p: *const Pardes) *const ChromeTheme {
+ return &p.chrome_animation.displayed;
+ }
+
+ pub fn themeAnimationActive(p: *const Pardes) bool {
+ return p.chrome_animation.isActive();
+ }
+
+ fn finishThemeInitialization(p: *Pardes) void {
+ p.chrome_animation.snap(ChromeTheme.fromTheme(p.theme()));
+ p.animate_theme_changes = true;
+ }
+
+ /// The sole live-session theme mutation path. Target colors change now;
+ /// anchored chrome retargets from its currently displayed palette. Only
+ /// PDFs whose pixels depend on target theme colors are marked stale, once;
+ /// untinted source rasters remain byte-for-byte resident.
pub fn setThemeIndex(p: *Pardes, index: usize) void {
if (index >= themes.len or index == p.theme_idx) return;
+ const target_chrome = ChromeTheme.fromTheme(&themes[index]);
+ if (p.animate_theme_changes)
+ p.chrome_animation.retarget(target_chrome)
+ else
+ p.chrome_animation.snap(target_chrome);
p.theme_idx = index;
if (comptime pdf_enabled) for (p.panes) |slot| {
const pane = slot orelse continue;
@@ -10653,9 +10804,9 @@ pub const Pardes = struct {
s.rows = p.screen_h;
s.cursor = null;
s.nimages = 0;
- const th = p.theme();
+ const chrome = p.chromeTheme();
// gaps between panes read as chrome (scrollbar track), not raw default
- s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = th.scroll_track } });
+ s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.scroll_track } });
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
@@ -10663,9 +10814,9 @@ pub const Pardes = struct {
}
// global tagbar: full width, top row
- s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = th.tag_bg } });
+ s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg } });
var tb_buf: [1200]u8 = undefined;
- _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } });
+ _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } });
// the topbar's cursor, if it has the keyboard. AFTER the pane loop on
// purpose: there is exactly one Surface cursor and the bar's must beat
// the active pane's. Always a block — the bar has no insert mode.
@@ -10702,9 +10853,9 @@ pub const Pardes = struct {
if (p.movePlacement(d.id, d.cur_x, d.cur_y)) |placement| {
var col: u16 = p.col_x[placement.preview_col];
while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
- s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = th.lineno } });
+ s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } });
}
- s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = th.lineno } });
+ s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } });
}
},
.none, .select, .tag => {
@@ -10773,12 +10924,12 @@ pub const Pardes = struct {
s.set(ox, by, "│", .{});
s.set(ox + ow - 1, by, "│", .{});
}
- s.fill(ox + 1, TOPBAR_H + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = th.scroll_track } });
+ s.fill(ox + 1, TOPBAR_H + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } });
var lines = std.mem.splitScalar(u8, text, '\n');
var ly: u16 = TOPBAR_H + 1;
while (lines.next()) |line| : (ly += 1) {
if (ly >= TOPBAR_H + oh - 1) break;
- _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = th.tag_fg } });
+ _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } });
}
}
};
@@ -10791,6 +10942,7 @@ pub const Pardes = struct {
if (r.w <= config.GUTTER or r.h == 0) return;
const s = &p.surface;
const th = p.theme();
+ const chrome = p.chromeTheme();
const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter
const tw = r.w - config.GUTTER;
// the pane's own background, for everything that has to read as "no
@@ -10829,7 +10981,7 @@ pub const Pardes = struct {
// ponytail: two colours, black or white, chosen at a fixed threshold.
// Upgrade to the theme's own fg/bg pair when a theme turns up whose
// box wants a tint rather than a contrast.
- const box_bg = if (active) th.box else th.box_dim;
+ 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 };
const box_style: CellStyle = .{ .fg = .{ .rgb = box_ink }, .bg = .{ .rgb = box_bg } };
@@ -10842,8 +10994,8 @@ pub const Pardes = struct {
}, box_style);
// tag: live prefix (cwd/path) + editable tail, on a dim bar
- const tag_style: CellStyle = .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } };
- s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = th.tag_bg } });
+ const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg } };
+ s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = chrome.tag_bg } });
const tag = try p.tagText(arena, pane);
_ = s.print(tx, r.y, tw, tag, tag_style);
// pending SPC leader path, on the ACTIVE pane's tag at its RIGHT edge:
@@ -10887,7 +11039,7 @@ pub const Pardes = struct {
// extracted text projection becomes an ordinary readable body.
if (comptime pdf_enabled) {
if (hasPdf(pane) and p.native_images and p.drawPdf(pane, r, tx, tw)) {
- s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg });
if (r.h > BOX_H) {
const pv = &pane.pdf.?;
@@ -10908,7 +11060,7 @@ pub const Pardes = struct {
));
var sy = pos;
while (sy < track_h and sy < pos + len) : (sy += 1)
- s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = th.scroll_thumb } });
+ s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
}
return;
}
@@ -10921,7 +11073,7 @@ pub const Pardes = struct {
p.drawImage(pane, r, tx, tw);
// thumbless, but the same one column as the real scrollbar below —
// that is the whole point of drawing it
- s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ s.fill(r.x, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
s.fill(r.x + 1, r.y + BOX_H, 1, r.h -| BOX_H, .{ .bg = pane_bg });
return;
}
@@ -11090,7 +11242,7 @@ pub const Pardes = struct {
// config.GUTTER columns and the click handlers still scroll on any of
// them, so the blank column is still live. Only the ink narrowed.
if (r.h > BOX_H) {
- s.fill(r.x, r.y + BOX_H, 1, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ s.fill(r.x, r.y + BOX_H, 1, r.h - BOX_H, .{ .bg = .{ .rgb = chrome.scroll_track } });
s.fill(r.x + 1, r.y + BOX_H, 1, r.h - BOX_H, .{ .bg = pane_bg });
const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{
.total = file_pane.nlines(p.gpa, f),
@@ -11110,7 +11262,7 @@ pub const Pardes = struct {
const pos = (track_h * sb.offset) / total;
var sy: usize = pos;
while (sy < track_h and sy < pos + len) : (sy += 1) {
- s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = th.scroll_thumb } });
+ s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } });
}
}
}