diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 23:48:38 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc (patch) | |
| tree | 5e61547e88386d78868b23decd3956339ee23e9d /src/pardes.zig | |
| parent | 90eed6a89c66d6243ea43e799113409244308563 (diff) | |
| download | pardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.tar.gz pardes-b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc.zip | |
add colorscheme-aware PDF tint modes
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 273 |
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; |
