aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-06 08:43:03 -0500
committerCraig Jennings <c@cjennings.net>2026-07-06 08:43:03 -0500
commit648c150f6e4e89c9c04158795da1ab13370d7f73 (patch)
tree442f9349e0904a4c00cb57c076e13f1152614fe5
parentf613bd63811d84816f88d26b33323e1b0f395793 (diff)
downloaddotemacs-648c150f6e4e89c9c04158795da1ab13370d7f73.tar.gz
dotemacs-648c150f6e4e89c9c04158795da1ab13370d7f73.zip
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.
-rw-r--r--modules/nov-reading.el10
-rw-r--r--modules/pdf-config.el112
-rw-r--r--tests/test-nov-reading--config-defaults.el38
-rw-r--r--tests/test-pdf-config--reading-palette.el87
4 files changed, 239 insertions, 8 deletions
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