summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/animation.zig124
-rw-r--r--src/file_pane.zig8
-rw-r--r--src/gui/gui.zig46
-rw-r--r--src/pardes.zig192
-rw-r--r--src/tty/tty.zig13
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;
+ }
}
}