From 648c150f6e4e89c9c04158795da1ab13370d7f73 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 6 Jul 2026 08:43:03 -0500 Subject: feat(readers): default EPUB and PDF to a dark/sepia/light reading cycle nov-mode's reading cycle opened on sepia and ran sepia, dark, light. I reordered it to dark, sepia, light and made a fresh EPUB open dark. pdf-view only had a binary midnight toggle. I added a matching palette layer: a fresh PDF opens dark, and c cycles dark, sepia, light, none (C selects by name, M also cycles). Each palette is a foreground/background pair fed to pdf-view-midnight-minor-mode, so sepia and light are that same duotone with warmer and lighter colors. The pdf colors mirror the nov palettes so EPUBs and PDFs read alike. Pure name-to-colors and cycle logic are unit-tested. The live render needs a manual check. --- modules/nov-reading.el | 10 +-- modules/pdf-config.el | 112 ++++++++++++++++++++++++++++- tests/test-nov-reading--config-defaults.el | 38 ++++++++++ tests/test-pdf-config--reading-palette.el | 87 ++++++++++++++++++++++ 4 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 tests/test-nov-reading--config-defaults.el create mode 100644 tests/test-pdf-config--reading-palette.el diff --git a/modules/nov-reading.el b/modules/nov-reading.el index 4134f497..636a2f53 100644 --- a/modules/nov-reading.el +++ b/modules/nov-reading.el @@ -93,12 +93,12 @@ :group 'cj/nov-reading) (defcustom cj/nov-reading-palettes - '(("sepia" :face cj/nov-reading-sepia - :heading cj/nov-reading-sepia-heading - :link cj/nov-reading-sepia-link) - ("dark" :face cj/nov-reading-dark + '(("dark" :face cj/nov-reading-dark :heading cj/nov-reading-dark-heading :link cj/nov-reading-dark-link) + ("sepia" :face cj/nov-reading-sepia + :heading cj/nov-reading-sepia-heading + :link cj/nov-reading-sepia-link) ("light" :face cj/nov-reading-light :heading cj/nov-reading-light-heading :link cj/nov-reading-light-link)) @@ -114,7 +114,7 @@ palette; omit :heading or :link to leave that element at the theme's default." (plist :options ((:face face) (:heading face) (:link face)))) :group 'cj/nov-reading) -(defcustom cj/nov-reading-default-palette "sepia" +(defcustom cj/nov-reading-default-palette "dark" "Reading palette applied to a fresh nov-mode buffer. A key in `cj/nov-reading-palettes', or nil for the theme's normal rendering." :type '(choice (const :tag "None (theme default)" nil) string) diff --git a/modules/pdf-config.el b/modules/pdf-config.el index a5dc3c49..b5a3e217 100644 --- a/modules/pdf-config.el +++ b/modules/pdf-config.el @@ -8,10 +8,18 @@ ;; Load shape: eager. ;; Eager reason: none; heavy PDF packages should load on PDF open, a file/mode ;; deferral candidate. -;; Top-level side effects: package configuration via use-package. +;; Top-level side effects: a defgroup, defcustoms, a defvar-local, and package +;; configuration via use-package. ;; Runtime requires: none (configures packages via use-package). ;; Direct test load: yes. ;; +;; A reading-view palette layer sits on top of pdf-tools: a fresh PDF opens in +;; the "dark" tint, and `c' cycles dark -> sepia -> light -> none, mirroring the +;; nov-mode reading view (see nov-reading.el). Each palette is a (FG . BG) cons +;; driven through `pdf-view-midnight-minor-mode', which recolors the rendered +;; page as a duotone -- so "sepia" and "light" are the same mechanism as the +;; built-in midnight mode, just with warmer / lighter color pairs. +;; ;;; Code: ;; ------------------------------- Declarations -------------------------------- @@ -30,13 +38,107 @@ (declare-function cj/open-file-with-command "system-utils") (declare-function cj/org-noter-insert-note-dwim "org-noter-config") +;; `pdf-view-midnight-colors' lives in pdf-view.el, which loads lazily on the +;; first PDF open. Declare it special so the `setq-local' below compiles as a +;; dynamic binding rather than a lexical no-op. +(defvar pdf-view-midnight-colors) + +;; ------------------------------ Reading palettes ----------------------------- +;; pdf-view has no sepia/light of its own -- only a binary midnight toggle. This +;; layer generalizes that toggle into a named palette cycle: `pdf-view-midnight- +;; minor-mode' recolors the page from a (FG . BG) pair, so any tint is just a +;; different pair. Colors mirror the nov-reading palettes for a consistent read +;; across EPUBs and PDFs; tune them there and here together. + +(defgroup cj/pdf-reading nil + "Reading-view theming for pdf-view PDFs." + :group 'cj) + +(defcustom cj/pdf-reading-palettes + '(("dark" . ("#cfc8b8" . "#15140f")) + ("sepia" . ("#c9b187" . "#1f1b16")) + ("light" . ("#2a2622" . "#ece3cf"))) + "Alist of reading-palette NAME -> (FOREGROUND . BACKGROUND) for pdf-view. +Each value is the color pair `pdf-view-midnight-minor-mode' renders the page +with (white maps to BACKGROUND, black to FOREGROUND). The selector and cycle +commands choose among these names; add an entry to add a palette." + :type '(alist :key-type string + :value-type (cons (string :tag "Foreground") + (string :tag "Background"))) + :group 'cj/pdf-reading) + +(defcustom cj/pdf-reading-default-palette "dark" + "Reading palette applied to a freshly opened PDF. +A key in `cj/pdf-reading-palettes', or nil for the PDF's native colors." + :type '(choice (const :tag "None (native colors)" nil) string) + :group 'cj/pdf-reading) + +(defvar-local cj/pdf--reading-palette nil + "Name of the reading palette active in this pdf buffer, or nil for none.") + +(defun cj/pdf--reading-palette-colors (name) + "Return the (FOREGROUND . BACKGROUND) cons for palette NAME, or nil when unknown. +NAME nil (the no-palette state) and unknown names both yield nil." + (cdr (assoc name cj/pdf-reading-palettes))) + +(defun cj/pdf--next-reading-palette (current names) + "Return the palette after CURRENT in the cycle NAMES then nil, wrapping. +CURRENT nil is the no-palette state, and a returned nil means no palette. An +unknown CURRENT falls back to the first palette." + (let* ((cycle (append names (list nil))) + (tail (cdr (member current cycle)))) + (car (or tail cycle)))) + +(defun cj/pdf--apply-reading-palette (name) + "Apply reading palette NAME to this pdf buffer; NAME nil removes any palette. +Drives `pdf-view-midnight-minor-mode' with the palette's (FG . BG) colors so the +page renders as a duotone in the reading tint. An unknown or nil NAME turns +midnight mode off, restoring the PDF's native colors." + (let ((colors (cj/pdf--reading-palette-colors name))) + (if colors + (progn + (setq-local pdf-view-midnight-colors colors) + (pdf-view-midnight-minor-mode 1) + (setq cj/pdf--reading-palette name)) + (pdf-view-midnight-minor-mode -1) + (setq cj/pdf--reading-palette nil)))) + +(defun cj/pdf-set-reading-palette (name) + "Choose reading palette NAME for this pdf buffer; \"none\" clears it. +Interactively prompts among `cj/pdf-reading-palettes' plus \"none\"." + (interactive + (list (completing-read "Reading palette: " + (cons "none" (mapcar #'car cj/pdf-reading-palettes)) + nil t))) + (unless (derived-mode-p 'pdf-view-mode) + (user-error "Not in a pdf-view-mode buffer")) + (cj/pdf--apply-reading-palette (unless (equal name "none") name)) + (message "Reading palette: %s" (or cj/pdf--reading-palette "none"))) + +(defun cj/pdf-cycle-reading-palette () + "Cycle to the next reading palette, then the no-palette state, wrapping." + (interactive) + (unless (derived-mode-p 'pdf-view-mode) + (user-error "Not in a pdf-view-mode buffer")) + (let ((next (cj/pdf--next-reading-palette + cj/pdf--reading-palette + (mapcar #'car cj/pdf-reading-palettes)))) + (cj/pdf--apply-reading-palette next) + (message "Reading palette: %s" (or next "none")))) + +(defun cj/pdf-reading-setup () + "Apply the default reading palette to a freshly opened PDF. +Called from the `pdf-view-mode' launch hook." + (when cj/pdf-reading-default-palette + (cj/pdf--apply-reading-palette cj/pdf-reading-default-palette))) + ;; --------------------------------- PDF Tools --------------------------------- (use-package pdf-tools :defer t :mode (("\\.pdf\\'" . pdf-view-mode)) :hook - (pdf-view-mode . pdf-view-midnight-minor-mode) + (pdf-view-mode . cj/pdf-reading-setup) :custom (pdf-view-display-size 'fit-page) (pdf-view-resize-factor 1.1) @@ -61,7 +163,11 @@ (with-current-buffer buf (when (eq major-mode 'pdf-view-mode) (revert-buffer nil t)))) - (define-key pdf-view-mode-map "M" #'pdf-view-midnight-minor-mode) + ;; Reading palette: c cycles dark -> sepia -> light -> none, C selects by name. + ;; M keeps the old midnight key working, now as the cycle. + (define-key pdf-view-mode-map "c" #'cj/pdf-cycle-reading-palette) + (define-key pdf-view-mode-map "C" #'cj/pdf-set-reading-palette) + (define-key pdf-view-mode-map "M" #'cj/pdf-cycle-reading-palette) (define-key pdf-view-mode-map "m" #'bookmark-set) (define-key pdf-view-mode-map (kbd "C-=") #'pdf-view-enlarge) (define-key pdf-view-mode-map (kbd "C--") #'pdf-view-shrink) diff --git a/tests/test-nov-reading--config-defaults.el b/tests/test-nov-reading--config-defaults.el new file mode 100644 index 00000000..5e454ce1 --- /dev/null +++ b/tests/test-nov-reading--config-defaults.el @@ -0,0 +1,38 @@ +;;; test-nov-reading--config-defaults.el --- nov reading default/order tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Asserts the shipped reading-view defaults: a fresh EPUB opens dark, and the +;; `c' cycle runs dark -> sepia -> light -> none -> back. The pure-logic file +;; test-nov-reading--palette.el covers the cycle mechanics on fixtures; this file +;; pins the real `cj/nov-reading-palettes' / `cj/nov-reading-default-palette' +;; values so a reorder can't silently drift the default or the cycle order. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'nov-reading) + +(declare-function cj/nov--next-reading-palette "nov-reading" (current names)) +(defvar cj/nov-reading-palettes) +(defvar cj/nov-reading-default-palette) + +(ert-deftest test-nov-reading-config-default-is-dark () + "Normal: a fresh nov buffer opens on the dark palette." + (should (equal cj/nov-reading-default-palette "dark"))) + +(ert-deftest test-nov-reading-config-order-is-dark-sepia-light () + "Normal: the shipped palette order is dark, then sepia, then light." + (should (equal (mapcar #'car cj/nov-reading-palettes) + '("dark" "sepia" "light")))) + +(ert-deftest test-nov-reading-config-cycle-from-default () + "Normal: cycling from the default advances dark -> sepia -> light -> none -> dark." + (let ((names (mapcar #'car cj/nov-reading-palettes))) + (should (equal (cj/nov--next-reading-palette "dark" names) "sepia")) + (should (equal (cj/nov--next-reading-palette "sepia" names) "light")) + (should-not (cj/nov--next-reading-palette "light" names)) + (should (equal (cj/nov--next-reading-palette nil names) "dark")))) + +(provide 'test-nov-reading--config-defaults) +;;; test-nov-reading--config-defaults.el ends here diff --git a/tests/test-pdf-config--reading-palette.el b/tests/test-pdf-config--reading-palette.el new file mode 100644 index 00000000..6a02d838 --- /dev/null +++ b/tests/test-pdf-config--reading-palette.el @@ -0,0 +1,87 @@ +;;; test-pdf-config--reading-palette.el --- pdf reading-palette tests -*- lexical-binding: t; -*- + +;;; Commentary: +;; Pure-logic tests for the pdf-view reading-palette layer: name->colors +;; resolution, the shipped default and order, and the cycle (palettes, then the +;; no-palette state, wrapping). The live application (which drives +;; `pdf-view-midnight-minor-mode') is exercised in the daemon, not here. +;; +;; Requires pdf-config, which uses use-package for pdf-tools; `make test' runs +;; without the implicit package-initialize, so bootstrap `package' first (the +;; shared format-wiring helper). pdf-tools is loaded lazily (:defer t), so this +;; only needs the pure top-level helpers, not the pdf-view runtime. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(require 'testutil-format-wiring) + +(format-test--ensure-packages-init) +(require 'pdf-config) + +(declare-function cj/pdf--reading-palette-colors "pdf-config" (name)) +(declare-function cj/pdf--next-reading-palette "pdf-config" (current names)) +(defvar cj/pdf-reading-palettes) +(defvar cj/pdf-reading-default-palette) + +;;; ------------------------ cj/pdf--reading-palette-colors -------------------- + +(ert-deftest test-pdf-reading-palette-colors-known () + "Normal: a known palette resolves to a (FG . BG) cons of two strings." + (let ((colors (cj/pdf--reading-palette-colors "dark"))) + (should (consp colors)) + (should (stringp (car colors))) + (should (stringp (cdr colors))))) + +(ert-deftest test-pdf-reading-palette-colors-unknown () + "Error: an unknown palette name resolves to nil." + (should-not (cj/pdf--reading-palette-colors "nope"))) + +(ert-deftest test-pdf-reading-palette-colors-nil () + "Boundary: a nil name (the no-palette state) resolves to nil." + (should-not (cj/pdf--reading-palette-colors nil))) + +;;; --------------------------- shipped default + order ------------------------ + +(ert-deftest test-pdf-reading-default-is-dark () + "Normal: a fresh PDF opens on the dark palette." + (should (equal cj/pdf-reading-default-palette "dark"))) + +(ert-deftest test-pdf-reading-order-is-dark-sepia-light () + "Normal: the shipped palette order is dark, then sepia, then light." + (should (equal (mapcar #'car cj/pdf-reading-palettes) + '("dark" "sepia" "light")))) + +;;; ------------------------- cj/pdf--next-reading-palette --------------------- + +(ert-deftest test-pdf-reading-next-palette-advances () + "Normal: cycles to the next palette in order." + (should (equal (cj/pdf--next-reading-palette "dark" '("dark" "sepia" "light")) + "sepia"))) + +(ert-deftest test-pdf-reading-next-palette-last-to-none () + "Boundary: the last palette cycles to the no-palette state (nil)." + (should-not (cj/pdf--next-reading-palette "light" '("dark" "sepia" "light")))) + +(ert-deftest test-pdf-reading-next-palette-none-to-first () + "Boundary: the no-palette state (nil) cycles to the first palette." + (should (equal (cj/pdf--next-reading-palette nil '("dark" "sepia" "light")) + "dark"))) + +(ert-deftest test-pdf-reading-next-palette-unknown-current-falls-to-first () + "Error: an unknown current palette falls back to the first." + (should (equal (cj/pdf--next-reading-palette "gone" '("dark" "sepia" "light")) + "dark"))) + +(ert-deftest test-pdf-reading-cycle-from-default () + "Normal: cycling from the default advances dark -> sepia -> light -> none -> dark." + (let ((names (mapcar #'car cj/pdf-reading-palettes))) + (should (equal (cj/pdf--next-reading-palette "dark" names) "sepia")) + (should (equal (cj/pdf--next-reading-palette "sepia" names) "light")) + (should-not (cj/pdf--next-reading-palette "light" names)) + (should (equal (cj/pdf--next-reading-palette nil names) "dark")))) + +(provide 'test-pdf-config--reading-palette) +;;; test-pdf-config--reading-palette.el ends here -- cgit v1.2.3