summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:48:38 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commitb576c238fb0e3da3ac8d22baf3f29245a5a6f2bc (patch)
tree5e61547e88386d78868b23decd3956339ee23e9d /src/pardes.zig
parent90eed6a89c66d6243ea43e799113409244308563 (diff)
downloadpardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.tar.gz
pardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.zip
add colorscheme-aware PDF tint modes
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig273
1 files changed, 267 insertions, 6 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 04668f9c..23611990 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -358,6 +358,7 @@ test "PDF continuous strip renders every intersecting short page" {
.request = request,
.request_valid = true,
.tried = true,
+ .tint_key = p.pdfTintKey(pv),
.revision = @intCast(page + 1),
});
}
@@ -374,6 +375,189 @@ test "PDF continuous strip renders every intersecting short page" {
}
}
+test "PdfTint cycles pane-local state and exposes it in the live PDF tag" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const p = try Pardes.init(std.testing.allocator, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ const tint_builtin = std.meta.stringToEnum(Builtin, "PdfTint") orelse
+ return error.MissingPdfTintBuiltin;
+
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ const initial_tag = try p.tagText(p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(
+ u8,
+ initial_tag,
+ "width PdfFit disabled PdfTint",
+ ) != null);
+
+ p.runBuiltin(tint_builtin, 0, "", null);
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ const filtered_tag = try p.tagText(p.scratch.allocator(), pane);
+ try std.testing.expect(std.mem.indexOf(u8, filtered_tag, "filtered PdfTint") != null);
+
+ // The launch companion is a terminal. Running the same pane-scoped word
+ // there cannot mutate the PDF next to it.
+ try std.testing.expect(!hasPdf(p.panes[1].?));
+ p.runBuiltin(tint_builtin, 1, "", null);
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+
+ p.runBuiltin(tint_builtin, 0, "", null);
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+ p.runBuiltin(tint_builtin, 0, "", null);
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ try std.testing.expectEqual(PdfFitMode.width, pv.fit);
+
+ // `dark` intentionally leaves page bg/fg null. PDF tint resolves those
+ // deterministically to its chrome colors rather than host defaults.
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ p.setThemeIndex(dark_index);
+ const colors = p.pdfTintColors();
+ try std.testing.expectEqual(p.theme().tag_bg, colors.background);
+ try std.testing.expectEqual(p.theme().tag_fg, colors.foreground);
+}
+
+test "PDF tint and tinted theme changes rebuild every visible raster only" {
+ if (!pdf_enabled or platform == .web) return;
+
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{
+ .file = "docs/design.pdf",
+ .cols = 80,
+ .rows = 24,
+ });
+ defer p.deinit();
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ p.native_images = true;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+
+ _ = try p.render(frame.allocator());
+ const viewport = p.pdfViewport(pane).?;
+ pv.document_scroll_y = @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 2);
+ _ = frame.reset(.retain_capacity);
+ const plain_surface = try p.render(frame.allocator());
+ try std.testing.expectEqual(@as(usize, 2), plain_surface.nimages);
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len);
+
+ const Snapshot = struct {
+ page: u32,
+ revision: u32,
+ geometry: image.NativeGeometry,
+ pixel_offset_y: f32,
+ iw: usize,
+ ih: usize,
+ checksum: u64,
+ };
+ const Capture = struct {
+ fn get(surface: *const Surface) ![2]Snapshot {
+ if (surface.nimages != 2) return error.UnexpectedVisiblePdfCount;
+ var out: [2]Snapshot = undefined;
+ for (&out, 0..) |*snapshot, i| {
+ const place = surface.images[i] orelse return error.MissingVisiblePdf;
+ snapshot.* = .{
+ .page = place.native.page,
+ .revision = place.native.revision,
+ .geometry = place.native.geometry orelse return error.MissingPdfGeometry,
+ .pixel_offset_y = place.native.pixel_offset_y,
+ .iw = place.iw,
+ .ih = place.ih,
+ .checksum = std.hash.Wyhash.hash(0x5044_4654_494e_5421, place.rgba),
+ };
+ }
+ return out;
+ }
+
+ fn expectGeometry(before: [2]Snapshot, after: [2]Snapshot) !void {
+ for (before, after) |old, new| {
+ try std.testing.expectEqual(old.page, new.page);
+ try std.testing.expectEqual(old.geometry, new.geometry);
+ try std.testing.expectEqual(old.pixel_offset_y, new.pixel_offset_y);
+ try std.testing.expectEqual(old.iw, new.iw);
+ try std.testing.expectEqual(old.ih, new.ih);
+ }
+ }
+ };
+ const plain = try Capture.get(plain_surface);
+
+ p.togglePdfTint(pane);
+ try std.testing.expectEqual(PdfTintMode.filtered, pv.tint);
+ for (pv.rasters.items) |raster| try std.testing.expect(!raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const filtered_surface = try p.render(frame.allocator());
+ const filtered = try Capture.get(filtered_surface);
+ try Capture.expectGeometry(plain, filtered);
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len);
+ for (plain, filtered) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ try std.testing.expect(new.checksum != old.checksum);
+ }
+
+ const dark_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "dark")) break i;
+ } else return error.MissingDarkTheme;
+ p.setThemeIndex(dark_index);
+ for (pv.rasters.items) |raster| try std.testing.expect(!raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const themed_surface = try p.render(frame.allocator());
+ const themed = try Capture.get(themed_surface);
+ try Capture.expectGeometry(filtered, themed);
+ for (filtered, themed) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ try std.testing.expect(new.checksum != old.checksum);
+ }
+
+ p.togglePdfTint(pane);
+ try std.testing.expectEqual(PdfTintMode.full, pv.tint);
+ _ = frame.reset(.retain_capacity);
+ const full_surface = try p.render(frame.allocator());
+ const full = try Capture.get(full_surface);
+ try Capture.expectGeometry(themed, full);
+ for (themed, full) |old, new| {
+ try std.testing.expect(new.revision > old.revision);
+ }
+ for (pv.rasters.items) |raster|
+ try std.testing.expectEqual(PdfTintMode.full, raster.tint_key.?.mode);
+
+ p.togglePdfTint(pane);
+ try std.testing.expectEqual(PdfTintMode.disabled, pv.tint);
+ _ = frame.reset(.retain_capacity);
+ const disabled_surface = try p.render(frame.allocator());
+ const disabled = try Capture.get(disabled_surface);
+ try Capture.expectGeometry(full, disabled);
+ for (plain, disabled) |original, restored| {
+ try std.testing.expect(restored.revision > original.revision);
+ try std.testing.expectEqual(original.checksum, restored.checksum);
+ }
+
+ const acme_index = for (themes, 0..) |th, i| {
+ if (std.mem.eql(u8, th.name, "acme")) break i;
+ } else return error.MissingAcmeTheme;
+ for (pv.rasters.items) |raster| try std.testing.expect(raster.tried);
+ p.setThemeIndex(acme_index);
+ // Disabled keys ignore theme colors, so neither explicit invalidation nor
+ // the per-raster key comparison asks MuPDF for replacement pixels.
+ for (pv.rasters.items) |raster| try std.testing.expect(raster.tried);
+ _ = frame.reset(.retain_capacity);
+ const unchanged_surface = try p.render(frame.allocator());
+ const unchanged = try Capture.get(unchanged_surface);
+ try Capture.expectGeometry(disabled, unchanged);
+ for (disabled, unchanged) |old, new| {
+ try std.testing.expectEqual(old.revision, new.revision);
+ try std.testing.expectEqual(old.checksum, new.checksum);
+ }
+ try std.testing.expectEqual(@as(usize, 2), pv.rasters.items.len);
+}
+
test "PDF resize preserves page-relative document position" {
if (!pdf_enabled or platform == .web) return;
@@ -872,13 +1056,18 @@ const legacy_file_pane_tail = " Save Del";
// sits with the other two comptime folds (builtin_rows, the topbar check).
const Builtin = builtins.Builtin();
-test "PdfFit builtin and leader path follow the MuPDF feature gate" {
+test "PDF builtins and leader paths follow the MuPDF feature gate" {
const maybe_fit = std.meta.stringToEnum(Builtin, "PdfFit");
+ const maybe_tint = std.meta.stringToEnum(Builtin, "PdfTint");
try std.testing.expectEqual(pdf_enabled, maybe_fit != null);
+ try std.testing.expectEqual(pdf_enabled, maybe_tint != null);
if (pdf_enabled) {
- const path = config.leader_path.get(maybe_fit.?) orelse
+ const fit_path = config.leader_path.get(maybe_fit.?) orelse
return error.MissingPdfFitLeaderPath;
- try std.testing.expectEqualStrings("tz", path);
+ const tint_path = config.leader_path.get(maybe_tint.?) orelse
+ return error.MissingPdfTintLeaderPath;
+ try std.testing.expectEqualStrings("tz", fit_path);
+ try std.testing.expectEqualStrings("ti", tint_path);
}
}
@@ -1518,6 +1707,20 @@ pub const ImageCacheKey = if (pdf_enabled) struct {
/// The type remains available to shared core code, while PdfView itself and
/// the builtin which mutates it are compiled away with `-Dmupdf=false`.
const PdfFitMode = if (pdf_enabled) enum { width, height } else void;
+const PdfTintMode = if (pdf_enabled) pdf_impl.TintMode else void;
+
+/// Every resident page records the PDF-only pixel state it was rendered for.
+/// Disabled deliberately ignores colors: changing a theme must not replace an
+/// untouched PDF raster. In tinted modes colors are part of the generation.
+const PdfTintKey = if (pdf_enabled) struct {
+ mode: PdfTintMode,
+ colors: pdf_impl.TintColors,
+
+ fn eql(a: @This(), b: @This()) bool {
+ return a.mode == b.mode and
+ (a.mode == .disabled or std.meta.eql(a.colors, b.colors));
+ }
+} else void;
/// Dynamic placement exists only for native PDF pages. Static image panes need
/// only their pane identity, so feature-off builds carry a zero-bit payload.
@@ -2023,6 +2226,7 @@ const PdfRaster = if (pdf_enabled) struct {
request_valid: bool = false,
tried: bool = false,
decorated: bool = false,
+ tint_key: ?PdfTintKey = null,
revision: u32 = 0,
} else void;
@@ -2061,6 +2265,7 @@ const PdfView = if (pdf_enabled) struct {
layout_anchor_fraction: f64 = 0,
next_raster_revision: u32 = 0,
fit: PdfFitMode = .width,
+ tint: PdfTintMode = .disabled,
/// Normalized 0..65535 pan positions. NativeGeometry maps the extrema
/// exactly onto the source overflow, independent of page or cell size.
pan_x: u16 = 0,
@@ -3042,8 +3247,8 @@ pub const Pardes = struct {
const arena = p.scratch.allocator();
if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint(
arena,
- "pdf {d}/{d} {s} PdfFit {s}",
- .{ pv.page + 1, pv.page_count, @tagName(pv.fit), pv.path },
+ "pdf {d}/{d} {s} PdfFit {s} PdfTint {s}",
+ .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path },
);
if (pane.image) |iv| return std.fmt.allocPrint(arena, config.tag_image ++ " {s}", .{iv.path});
if (pane.file) |f| return arena.dupe(u8, f.path);
@@ -8005,6 +8210,18 @@ pub const Pardes = struct {
};
}
+ /// Tint and theme changes affect every page currently carried to a native
+ /// backend, including both sides of a continuous-strip page boundary.
+ /// Retain their old buffers until each replacement succeeds.
+ fn invalidateAllPdfRasters(p: *Pardes, pv: *PdfView) void {
+ if (comptime !pdf_enabled) return;
+ _ = p;
+ pv.render_tried = false;
+ for (pv.rasters.items) |*raster| {
+ if (raster.valid) raster.tried = false;
+ }
+ }
+
fn releasePdfRaster(p: *Pardes, raster: *PdfRaster) void {
if (comptime !pdf_enabled) return;
if (raster.rgba.len > 0) p.gpa.free(raster.rgba);
@@ -8211,6 +8428,16 @@ pub const Pardes = struct {
pv.search_reveal_pending = pv.search_query.len > 0;
}
+ /// Builtin entry point for the exact disabled -> filtered -> full ring.
+ /// State belongs to one PDF pane; other document and terminal panes are
+ /// deliberately inert.
+ pub fn togglePdfTint(p: *Pardes, pane: *Pane) void {
+ if (comptime !pdf_enabled) return;
+ const pv = &(pane.pdf orelse return);
+ pv.tint = pv.tint.next();
+ p.invalidateAllPdfRasters(pv);
+ }
+
fn stepPdfPage(p: *Pardes, pane: *Pane, delta: i64) void {
if (comptime !pdf_enabled) return;
const pv = pane.pdf orelse return;
@@ -8366,6 +8593,20 @@ pub const Pardes = struct {
};
}
+ fn pdfTintColors(p: *const Pardes) pdf_impl.TintColors {
+ if (comptime !pdf_enabled) return undefined;
+ const th = p.theme();
+ return .{
+ .background = th.bg orelse th.tag_bg,
+ .foreground = th.fg orelse th.tag_fg,
+ };
+ }
+
+ fn pdfTintKey(p: *const Pardes, pv: *const PdfView) PdfTintKey {
+ if (comptime !pdf_enabled) return undefined;
+ return .{ .mode = pv.tint, .colors = p.pdfTintColors() };
+ }
+
const PdfPlacedGeometry = struct {
geometry: image.NativeGeometry,
pixel_offset_y: f32,
@@ -9722,6 +9963,19 @@ 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.
+ pub fn setThemeIndex(p: *Pardes, index: usize) void {
+ if (index >= themes.len or index == p.theme_idx) return;
+ p.theme_idx = index;
+ if (comptime pdf_enabled) for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ const pv = &(pane.pdf orelse continue);
+ if (pv.tint != .disabled) p.invalidateAllPdfRasters(pv);
+ };
+ }
+
// ---- render: build the canonical surface ----
pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
@@ -10227,6 +10481,7 @@ pub const Pardes = struct {
visible: PdfVisiblePages,
) void {
if (comptime !pdf_enabled) return;
+ const tint_key = p.pdfTintKey(pv);
// Remove first: owned RGBA never accumulates with document length,
// and backends see stale keys disappear in this same frame.
@@ -10251,7 +10506,8 @@ pub const Pardes = struct {
const slot = raster.?;
const decorated = page == pv.page and highlights.len > 0;
if (!slot.tried or !slot.request_valid or !slot.request.eql(request) or
- slot.decorated != decorated)
+ slot.decorated != decorated or slot.tint_key == null or
+ !slot.tint_key.?.eql(tint_key))
{
slot.tried = true;
slot.request = request;
@@ -10264,11 +10520,16 @@ pub const Pardes = struct {
break :render fresh;
};
if (rendered) |fresh| {
+ pdf_impl.tintRgba(fresh.rgba, tint_key.mode, tint_key.colors) catch {
+ p.gpa.free(fresh.rgba);
+ continue;
+ };
if (slot.rgba.len > 0) p.gpa.free(slot.rgba);
slot.rgba = fresh.rgba;
slot.iw = fresh.width;
slot.ih = fresh.height;
slot.decorated = decorated;
+ slot.tint_key = tint_key;
pv.next_raster_revision +%= 1;
if (pv.next_raster_revision == 0) pv.next_raster_revision = 1;
slot.revision = pv.next_raster_revision;