From b576c238fb0e3da3ac8d22baf3f29245a5a6f2bc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 23:48:38 -0300 Subject: add colorscheme-aware PDF tint modes --- src/builtins.zig | 14 ++- src/config.zig | 13 ++- src/pardes.zig | 273 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/pdf.zig | 118 ++++++++++++++++++++++++ 4 files changed, 406 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/builtins.zig b/src/builtins.zig index b89952cd..4f6db6e1 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -190,7 +190,7 @@ pub const Colors = struct { /// clicks was never what made it useful. pub const NextColor = struct { pub fn run(c: Ctx) void { - c.p.theme_idx = (c.p.theme_idx + 1) % pardes.themes.len; + c.p.setThemeIndex((c.p.theme_idx + 1) % pardes.themes.len); } }; @@ -209,7 +209,7 @@ pub const Theme = struct { const want = std.mem.trim(u8, c.arg orelse return, " \t\r\n"); for (pardes.themes, 0..) |t, i| { if (std.mem.eql(u8, t.name, want)) { - c.p.theme_idx = i; + c.p.setThemeIndex(i); return; } } @@ -301,6 +301,16 @@ pub const PdfFit = struct { } }; +/// Cycle a native PDF through original pixels, a chroma-preserving themed +/// filter, and a full theme duotone. It has the same compile-time and +/// pane-local shape as PdfFit: absent without MuPDF and inert off a PDF pane. +pub const PdfTint = struct { + pub const run = if (pardes.pdf_enabled) apply else {}; + fn apply(c: Ctx) void { + c.p.togglePdfTint(c.pane); + } +}; + // The image pane's three renderer toggles. They used to be words the image tag // printed and the execute dispatcher matched by hand; as builtins they are // executable anywhere, pressable under SPC and listed by `SPC ?`, the whole diff --git a/src/config.zig b/src/config.zig index 41a70e4a..3eedf707 100644 --- a/src/config.zig +++ b/src/config.zig @@ -164,10 +164,15 @@ pub const leader_path = paths: { table.set(.FontSel, "tf"); table.set(.Font, null); } - // PdfFit exists only in MuPDF builds (its run declaration deliberately - // changes shape when the feature is off), so name its toggle path through - // the same comptime branch rather than making feature-off enums mention it. - if (pardes.pdf_enabled) table.set(.PdfFit, "tz"); + // The PDF toggles exist only in MuPDF builds (their run declarations + // deliberately change shape when the feature is off), so name their paths + // through the same comptime branch rather than making feature-off enums + // mention them. `ti` was the unused slot in the display-toggle group; + // PdfFit's established `tz` stays unchanged. + if (pardes.pdf_enabled) { + table.set(.PdfTint, "ti"); + table.set(.PdfFit, "tz"); + } // ...and the property EnumArray.init used to give for free: every builtin // this build HAS is a builtin someone decided a path (or a null) for. for (std.enums.values(Builtin)) |b| if (table.get(b)) |p| { 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; diff --git a/src/pdf.zig b/src/pdf.zig index 28f976e4..0895469a 100644 --- a/src/pdf.zig +++ b/src/pdf.zig @@ -72,6 +72,124 @@ pub const Render = struct { height: usize, }; +/// Pane-local PDF color treatment. The core owns when this changes; keeping +/// the pixel transform here lets the renderer benchmark and any future PDF +/// consumer exercise exactly the same fixed-point rule. +pub const TintMode = enum { + disabled, + filtered, + full, + + pub fn next(mode: TintMode) TintMode { + return switch (mode) { + .disabled => .filtered, + .filtered => .full, + .full => .disabled, + }; + } +}; + +pub const TintColors = struct { + background: [3]u8, + foreground: [3]u8, +}; + +/// Rewrite packed straight-alpha RGBA in place without allocating. +/// +/// Source luminance is Rec. 601's 77/150/29 integer approximation. `full` +/// linearly maps source black to theme foreground and source white to theme +/// background. `filtered` starts with that same themed target, then adds 3/4 +/// of each source channel's signed distance from source luminance, clamped to +/// a byte. Thus grayscale is identical in both modes while filtered color +/// retains the source hue/chroma. Alpha is never touched. +pub fn tintRgba(rgba: []u8, mode: TintMode, colors: TintColors) !void { + if (rgba.len % 4 != 0) return error.InvalidRgbaLength; + if (mode == .disabled) return; + var at: usize = 0; + while (at < rgba.len) : (at += 4) { + const source = rgba[at..][0..3]; + const luminance: u8 = @intCast((@as(u32, source[0]) * 77 + + @as(u32, source[1]) * 150 + + @as(u32, source[2]) * 29 + 128) >> 8); + for (0..3) |channel| { + const target: i32 = @intCast((@as(u32, colors.foreground[channel]) * (255 - @as(u32, luminance)) + + @as(u32, colors.background[channel]) * @as(u32, luminance) + 127) / 255); + const chroma = if (mode == .filtered) + @divTrunc( + (@as(i32, source[channel]) - @as(i32, luminance)) * 3, + 4, + ) + else + 0; + rgba[at + channel] = @intCast(std.math.clamp(target + chroma, 0, 255)); + } + } +} + +test "PDF tint mode cycle is exact" { + try std.testing.expectEqual(TintMode.filtered, TintMode.disabled.next()); + try std.testing.expectEqual(TintMode.full, TintMode.filtered.next()); + try std.testing.expectEqual(TintMode.disabled, TintMode.full.next()); +} + +test "full PDF tint maps black and white to dark and light theme endpoints" { + const dark: TintColors = .{ + .background = .{ 0x12, 0x12, 0x12 }, + .foreground = .{ 0x94, 0x94, 0x94 }, + }; + var dark_pixels = [_]u8{ 0, 0, 0, 17, 255, 255, 255, 231 }; + try tintRgba(&dark_pixels, .full, dark); + try std.testing.expectEqualSlices(u8, &.{ 0x94, 0x94, 0x94, 17 }, dark_pixels[0..4]); + try std.testing.expectEqualSlices(u8, &.{ 0x12, 0x12, 0x12, 231 }, dark_pixels[4..8]); + + const light: TintColors = .{ + .background = .{ 0xea, 0xff, 0xff }, + .foreground = .{ 0x00, 0x00, 0x00 }, + }; + var light_pixels = [_]u8{ 0, 0, 0, 0, 255, 255, 255, 255 }; + try tintRgba(&light_pixels, .full, light); + try std.testing.expectEqualSlices(u8, &.{ 0x00, 0x00, 0x00, 0 }, light_pixels[0..4]); + try std.testing.expectEqualSlices(u8, &.{ 0xea, 0xff, 0xff, 255 }, light_pixels[4..8]); +} + +test "filtered PDF tint preserves fixed-point source chroma and alpha" { + const colors: TintColors = .{ + .background = .{ 20, 40, 60 }, + .foreground = .{ 220, 200, 180 }, + }; + var full = [_]u8{ 255, 0, 0, 37 }; + var filtered = full; + try tintRgba(&full, .full, colors); + try tintRgba(&filtered, .filtered, colors); + // Red has integer luma 77. The themed target is {160,152,144}; adding + // 3/4 of its {178,-77,-77} chroma residual yields this clamped result. + try std.testing.expectEqualSlices(u8, &.{ 160, 152, 144, 37 }, &full); + try std.testing.expectEqualSlices(u8, &.{ 255, 95, 87, 37 }, &filtered); + + var gray_full = [_]u8{ 128, 128, 128, 91 }; + var gray_filtered = gray_full; + try tintRgba(&gray_full, .full, colors); + try tintRgba(&gray_filtered, .filtered, colors); + try std.testing.expectEqualSlices(u8, &gray_full, &gray_filtered); + try std.testing.expectEqual(@as(u8, 91), gray_filtered[3]); +} + +test "disabled PDF tint is byte-identical and malformed RGBA is rejected unchanged" { + const colors: TintColors = .{ + .background = .{ 1, 2, 3 }, + .foreground = .{ 4, 5, 6 }, + }; + var pixels = [_]u8{ 9, 80, 170, 0, 250, 33, 71, 199 }; + const before = pixels; + try tintRgba(&pixels, .disabled, colors); + try std.testing.expectEqualSlices(u8, &before, &pixels); + + var malformed = [_]u8{ 11, 22, 33 }; + const malformed_before = malformed; + try std.testing.expectError(error.InvalidRgbaLength, tintRgba(&malformed, .full, colors)); + try std.testing.expectEqualSlices(u8, &malformed_before, &malformed); +} + pub const PageSize = struct { width: f32, height: f32, -- cgit v1.3