diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-03 01:34:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | a774cad033fdc371af9f89b9a787a30ea017481c (patch) | |
| tree | 321190fb8e3c7708822be9a168c7026d0e4cd819 /src | |
| parent | b50005359c9fa33beef9a2ea7496268a246b296e (diff) | |
| download | pardes-a774cad033fdc371af9f89b9a787a30ea017481c.tar.gz pardes-a774cad033fdc371af9f89b9a787a30ea017481c.zip | |
animate anchored theme colors on native and tty backends
Diffstat (limited to 'src')
| -rw-r--r-- | src/animation.zig | 124 | ||||
| -rw-r--r-- | src/file_pane.zig | 8 | ||||
| -rw-r--r-- | src/gui/gui.zig | 46 | ||||
| -rw-r--r-- | src/pardes.zig | 192 | ||||
| -rw-r--r-- | src/tty/tty.zig | 13 |
5 files changed, 358 insertions, 25 deletions
diff --git a/src/animation.zig b/src/animation.zig new file mode 100644 index 00000000..aef6fe6d --- /dev/null +++ b/src/animation.zig @@ -0,0 +1,124 @@ +//! Small, backend-neutral fixed-step animations. +//! +//! A transition always interpolates from its saved endpoints. It never folds +//! the rounded value from one frame into the next, so channels are monotonic, +//! completion is exact, and a different backend cadence cannot accumulate a +//! different rounding error. Values opt in by providing +//! `interpolate(from, to, step, steps)`. +const std = @import("std"); + +/// Frontends aim for one animation step per display frame. Ten 16 ms steps is +/// deliberately short: enough to make a palette change legible without +/// turning theme browsing into something the user has to wait through. +pub const frame_ms: u32 = 16; +pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; +pub const transition_steps: u16 = 10; + +pub fn Transition(comptime Value: type) type { + return struct { + const Self = @This(); + + from: Value, + to: Value, + displayed: Value, + step: u16 = transition_steps, + + pub fn init(value: Value) Self { + return .{ .from = value, .to = value, .displayed = value }; + } + + pub fn isActive(a: *const Self) bool { + return a.step < transition_steps; + } + + /// Begin again from the value on screen, not the old target. This is + /// what makes a mid-flight retarget continuous. + pub fn retarget(a: *Self, target: Value) void { + a.from = a.displayed; + a.to = target; + a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; + if (a.step == transition_steps) a.displayed = target; + } + + pub fn advance(a: *Self) void { + if (!a.isActive()) return; + a.step += 1; + // Assign the endpoint directly. Besides documenting the contract, + // this keeps exact completion independent of an interpolator's + // internal rounding choices. + a.displayed = if (a.step == transition_steps) + a.to + else + Value.interpolate(a.from, a.to, a.step, transition_steps); + } + + /// Initialization and dump restore use snap: their first frame is the + /// selected theme, never an animation from a compiled-in default. + pub fn snap(a: *Self, value: Value) void { + a.* = init(value); + } + }; +} + +/// Linear RGB interpolation with nearest-integer rounding. The weighted-sum +/// form stays unsigned for both rising and falling channels. +pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { + if (step == 0) return from; + if (step >= steps) return to; + var out: [3]u8 = undefined; + for (&out, from, to) |*dst, a, b| { + const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; + dst.* = @intCast((numerator + steps / 2) / steps); + } + return out; +} + +const TestColor = struct { + rgb: [3]u8, + + pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { + return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; + } +}; + +test "fixed-step interpolation has exact monotonic endpoints" { + const Tween = Transition(TestColor); + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + var tween = Tween.init(from); + tween.retarget(to); + try std.testing.expectEqual(from, tween.displayed); + + var previous = tween.displayed; + for (0..transition_steps) |_| { + tween.advance(); + try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); + try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); + try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); + previous = tween.displayed; + } + try std.testing.expect(!tween.isActive()); + try std.testing.expectEqual(to, tween.displayed); + tween.advance(); + try std.testing.expectEqual(to, tween.displayed); +} + +test "retarget starts at the currently displayed value" { + const Tween = Transition(TestColor); + const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; + const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; + const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; + var tween = Tween.init(first); + tween.retarget(second); + tween.advance(); + tween.advance(); + tween.advance(); + const on_screen = tween.displayed; + + tween.retarget(third); + try std.testing.expectEqual(on_screen, tween.from); + try std.testing.expectEqual(on_screen, tween.displayed); + try std.testing.expect(tween.isActive()); + for (0..transition_steps) |_| tween.advance(); + try std.testing.expectEqual(third, tween.displayed); +} diff --git a/src/file_pane.zig b/src/file_pane.zig index 2f2a0acb..36818d8d 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -223,7 +223,7 @@ pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 { /// the eye finds the current line. pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { const s = &p.surface; - const th = p.theme(); + const ch = p.chromeTheme(); const goff = pane.scroll(); const gcur = pane.vt.screens.active.cursor; const gcrow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(gcur.y)) + goff; @@ -236,9 +236,9 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod const cell = s.at(tx + c, r.y + pardes.BOX_H + vr); cell.default = false; // paints blank gutter rows too if (on_cursor) { - cell.style.fg = .{ .rgb = th.tag_fg }; - cell.style.bg = .{ .rgb = th.tag_bg }; - } else cell.style.fg = .{ .rgb = th.lineno }; + cell.style.fg = .{ .rgb = ch.tag_fg }; + cell.style.bg = .{ .rgb = ch.tag_bg }; + } else cell.style.fg = .{ .rgb = ch.lineno }; } } } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 6bd2c715..983be262 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -111,6 +111,39 @@ const overlay_circle_vertices: usize = 16 * 3; const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; + +/// SDL input can wake the loop faster than display cadence, so ticking once +/// per pass would make animation duration depend on pty traffic or mouse +/// motion. This monotonic gate keeps it near 60 Hz without sleeping the event +/// loop; the first frame after activation displays the exact source palette. +const AnimationClock = struct { + next_ns: u64 = 0, + + fn due(clock: *AnimationClock, active: bool, now_ns: u64) bool { + if (!active) { + clock.next_ns = 0; + return false; + } + if (clock.next_ns == 0) { + clock.next_ns = now_ns +| pardes.animation.frame_ns; + return false; + } + if (now_ns < clock.next_ns) return false; + clock.next_ns = now_ns +| pardes.animation.frame_ns; + return true; + } +}; + +test "GUI animation clock is active-only and cadence gated" { + var clock: AnimationClock = .{}; + try std.testing.expect(!clock.due(false, 100)); + try std.testing.expect(!clock.due(true, 100)); + try std.testing.expect(!clock.due(true, 100 + pardes.animation.frame_ns - 1)); + try std.testing.expect(clock.due(true, 100 + pardes.animation.frame_ns)); + try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.frame_ns)); + try std.testing.expectEqual(@as(u64, 0), clock.next_ns); +} + /// Web tap-vs-scroll heuristic, measured after normalized coordinates have /// been mapped into backing pixels (12px = 8 CSS px at web_render_scale). const touch_tap_slop_px: f32 = 12.0; @@ -1273,6 +1306,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { var frame_arena: std.heap.ArenaAllocator = .init(gpa); defer frame_arena.deinit(); + var animation_clock: AnimationClock = .{}; while (!core.quit) { // 1. SDL input: block briefly for the first event, then drain the rest @@ -1389,6 +1423,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { // 7. render — apply the wheel batch LAST before it, while // core.surface still holds the frame the last pass drew stepScroll(&g, core, gpa); + if (animation_clock.due(core.themeAnimationActive(), c.SDL_GetTicksNS())) + core.update(.tick); _ = frame_arena.reset(.retain_capacity); const surface = try core.render(frame_arena.allocator()); renderFrame(&g, gpa, surface, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err}); @@ -1410,6 +1446,7 @@ const WebState = struct { core: *pardes.Pardes, gpa: std.mem.Allocator, frame_arena: std.heap.ArenaAllocator, + animation_clock: AnimationClock = .{}, }; var live_web_state: ?*WebState = null; @@ -1672,6 +1709,8 @@ fn webFrame(arg: ?*anyopaque) callconv(.c) void { if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(g); // 4. render — apply the wheel batch last, see runNative stepScroll(g, core, st.gpa); + if (st.animation_clock.due(core.themeAnimationActive(), c.SDL_GetTicksNS())) + core.update(.tick); _ = st.frame_arena.reset(.retain_capacity); const surface = core.render(st.frame_arena.allocator()) catch return; renderFrameGl(g, st.gpa, surface, core.show_debug) catch return; @@ -1805,6 +1844,13 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { msgs.deinit(gpa); drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true); pollCwds(core, &ptys); + // The grid harness polls stdin at the same 16 ms cadence as native + // SDL. Advancing here lets `stable` wait for exact endpoint colors; + // once inactive it resumes the old event-only frame contract. + if (core.themeAnimationActive()) { + core.update(.tick); + n_events += 1; + } if (n_events == 0) continue; // idle tick: nothing changed, no frame _ = frame_arena.reset(.retain_capacity); const surface = try core.render(frame_arena.allocator()); 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 } }); } } } diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 908f17d7..6047e5a2 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -527,7 +527,8 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var output = false; var native_pdf_page_changed = false; switch (event) { - .nop, .tick => {}, + .nop => {}, + .tick => core.update(.tick), .quit => stop = true, .winsize => |ws| { try vx.resize(gpa, tty.writer(), ws); @@ -799,6 +800,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { try vx.render(tty.writer()); tz_render.end(); tracy.frameMark(); + + // A theme transition is the only reason this otherwise event-driven + // frontend redraws on a clock. Sleep on the loop thread, then make the + // next pass a tick; input and worker events can queue during the short + // sleep and are drained in that pass. No timer worker can outlive the + // loop, and at the exact endpoint we return to an indefinite wait. + if (core.themeAnimationActive()) { + std.Io.sleep(io, .fromMilliseconds(pardes.animation.frame_ms), .awake) catch {}; + pending = .tick; + } } } |
