diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 192 |
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 } }); } } } |
