aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-30 01:00:33 -0400
committerCraig Jennings <c@cjennings.net>2026-06-30 01:00:33 -0400
commit6f43a260891deea175eeee83ec602f97a16c974e (patch)
tree858f4fa16ceee6097eb34966aa4cb77035c77864 /modules
parentdb903ea32b10ac2f8d10c7e718d81b68492225cc (diff)
downloaddotemacs-6f43a260891deea175eeee83ec602f97a16c974e.tar.gz
dotemacs-6f43a260891deea175eeee83ec602f97a16c974e.zip
feat(nov-reading): persist font size, add per-palette structural faces
Font size now carries across books and sessions. The +/-/= keys write the text-scale offset to data/nov-reading-text-scale and the offset is restored when a book opens, so a size I set sticks instead of resetting to the base height on every reopen. The = key returns to the base height and persists that reset. Each palette grows from a single bg/fg face into a bundle: :face plus optional :heading and :link. When a palette is active, its heading and link faces remap shr's h1-h6 and link faces buffer-local, so the EPUB hierarchy reads in the palette's accent. The remap stays buffer-local to the nov buffer, so HTML mail and eww keep the theme's normal shr colors.
Diffstat (limited to 'modules')
-rw-r--r--modules/nov-reading.el164
1 files changed, 135 insertions, 29 deletions
diff --git a/modules/nov-reading.el b/modules/nov-reading.el
index 0181b03a0..4134f4975 100644
--- a/modules/nov-reading.el
+++ b/modules/nov-reading.el
@@ -8,7 +8,8 @@
;; Load shape: eager.
;; Eager reason: defines the reading faces and commands the nov launch hook and
;; keymap reference; the faces must exist for theme-studio's inventory too.
-;; Top-level side effects: defface x3, defcustoms, a defgroup.
+;; Top-level side effects: defface x9 (3 palettes + per-palette heading/link),
+;; defcustoms, a defgroup, a defvar.
;; Runtime requires: none (face-remap and text-scale are built in).
;; Direct test load: yes.
;;
@@ -21,6 +22,8 @@
;; "nov-reading" bespoke app in theme-studio's face_data.py).
;; - Typography -- a serif family and a base height, with +/-/= adjusting the
;; page font size live via a buffer-local text-scale on top of the base.
+;; The live size is remembered globally, so every book opens where you left
+;; it; "=" returns to the base height.
;;
;; calibredb-epub-config.el owns the library/calibre side and the text-width /
;; centering layout; this module owns reading color and typography. Its launch
@@ -53,14 +56,62 @@
"Light reading palette for nov-mode: cream background, near-black text."
:group 'cj/nov-reading)
+;; Structural faces: recolor shr's heading (h1-h6) and link faces per palette,
+;; remapped buffer-local so the EPUB's hierarchy reads in the palette's accent
+;; while mail/eww (the other shr consumers) keep the theme's shr colors. Heading
+;; faces carry :foreground only -- shr's per-level height and weight survive the
+;; relative remap; link faces add :underline so the cue reads as a link.
+
+(defface cj/nov-reading-sepia-heading
+ '((t :foreground "#e6c98a"))
+ "Heading accent for the sepia reading palette (recolors shr-h1..h6)."
+ :group 'cj/nov-reading)
+
+(defface cj/nov-reading-sepia-link
+ '((t :foreground "#c98f5a" :underline t))
+ "Link accent for the sepia reading palette (recolors shr-link)."
+ :group 'cj/nov-reading)
+
+(defface cj/nov-reading-dark-heading
+ '((t :foreground "#e8e0cc"))
+ "Heading accent for the dark reading palette (recolors shr-h1..h6)."
+ :group 'cj/nov-reading)
+
+(defface cj/nov-reading-dark-link
+ '((t :foreground "#8fb0c4" :underline t))
+ "Link accent for the dark reading palette (recolors shr-link)."
+ :group 'cj/nov-reading)
+
+(defface cj/nov-reading-light-heading
+ '((t :foreground "#5a3d28"))
+ "Heading accent for the light reading palette (recolors shr-h1..h6)."
+ :group 'cj/nov-reading)
+
+(defface cj/nov-reading-light-link
+ '((t :foreground "#8a5a2a" :underline t))
+ "Link accent for the light reading palette (recolors shr-link)."
+ :group 'cj/nov-reading)
+
(defcustom cj/nov-reading-palettes
- '(("sepia" . cj/nov-reading-sepia)
- ("dark" . cj/nov-reading-dark)
- ("light" . cj/nov-reading-light))
- "Alist of reading-palette NAME -> face for nov-mode.
-Each face supplies the reading view's :background and :foreground; the selector
-and cycle commands choose among these names. Add an entry to add a palette."
- :type '(alist :key-type string :value-type face)
+ '(("sepia" :face cj/nov-reading-sepia
+ :heading cj/nov-reading-sepia-heading
+ :link cj/nov-reading-sepia-link)
+ ("dark" :face cj/nov-reading-dark
+ :heading cj/nov-reading-dark-heading
+ :link cj/nov-reading-dark-link)
+ ("light" :face cj/nov-reading-light
+ :heading cj/nov-reading-light-heading
+ :link cj/nov-reading-light-link))
+ "Alist of reading-palette NAME -> face property list for nov-mode.
+Each entry's plist supplies the palette's colors, all theme-owned faces:
+ :face reading-view :background and :foreground, remapped onto `default'
+ :heading recolors shr's heading faces (h1-h6) for this palette
+ :link recolors shr's link face for this palette
+The selector and cycle commands choose among these names. Add an entry to add a
+palette; omit :heading or :link to leave that element at the theme's default."
+ :type '(alist :key-type string
+ :value-type
+ (plist :options ((:face face) (:heading face) (:link face))))
:group 'cj/nov-reading)
(defcustom cj/nov-reading-default-palette "sepia"
@@ -69,16 +120,23 @@ A key in `cj/nov-reading-palettes', or nil for the theme's normal rendering."
:type '(choice (const :tag "None (theme default)" nil) string)
:group 'cj/nov-reading)
-(defvar-local cj/nov--reading-remap-cookie nil
- "The `face-remap-add-relative' cookie for the active reading palette, or nil.")
+(defvar-local cj/nov--reading-remap-cookies nil
+ "List of `face-remap-add-relative' cookies for the active reading palette.
+Covers the `default' remap and any shr heading/link remaps, so switching
+palettes can remove them all at once.")
(defvar-local cj/nov--reading-palette nil
"Name of the reading palette active in this buffer, or nil for none.")
-(defun cj/nov--reading-palette-face (name)
- "Return the face for palette NAME, or nil when NAME is nil or unknown."
+(defun cj/nov--reading-palette-plist (name)
+ "Return the face property list for palette NAME, or nil when unknown.
+NAME nil (the no-palette state) and unknown names both yield nil."
(cdr (assoc name cj/nov-reading-palettes)))
+(defun cj/nov--reading-palette-face (name)
+ "Return the base (bg/fg) face for palette NAME, or nil when NAME is unknown."
+ (plist-get (cj/nov--reading-palette-plist name) :face))
+
(defun cj/nov--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
@@ -89,15 +147,26 @@ unknown CURRENT falls back to the first palette."
(defun cj/nov--apply-reading-palette (name)
"Apply reading palette NAME buffer-local; NAME nil removes any palette.
-Removes the previous palette remap first so switching never stacks remaps, and
-leaves the typography remap (a separate `default' remap) untouched."
- (when cj/nov--reading-remap-cookie
- (face-remap-remove-relative cj/nov--reading-remap-cookie)
- (setq cj/nov--reading-remap-cookie nil))
- (let ((face (cj/nov--reading-palette-face name)))
+Remaps `default' to the palette's :face, and (when present) shr's heading faces
+h1-h6 to its :heading face and shr-link to its :link face. Removes the previous
+palette's remaps first so switching never stacks, and leaves the typography
+remap (a separate `default' remap) untouched."
+ (mapc #'face-remap-remove-relative cj/nov--reading-remap-cookies)
+ (setq cj/nov--reading-remap-cookies nil)
+ (let* ((plist (cj/nov--reading-palette-plist name))
+ (face (plist-get plist :face)))
(when face
- (setq cj/nov--reading-remap-cookie
- (face-remap-add-relative 'default face)))
+ (push (face-remap-add-relative 'default face)
+ cj/nov--reading-remap-cookies)
+ (let ((heading (plist-get plist :heading)))
+ (when heading
+ (dolist (h '(shr-h1 shr-h2 shr-h3 shr-h4 shr-h5 shr-h6))
+ (push (face-remap-add-relative h heading)
+ cj/nov--reading-remap-cookies))))
+ (let ((link (plist-get plist :link)))
+ (when link
+ (push (face-remap-add-relative 'shr-link link)
+ cj/nov--reading-remap-cookies))))
(setq cj/nov--reading-palette (and face name))))
(defun cj/nov-set-reading-palette (name)
@@ -131,12 +200,44 @@ Interactively prompts among `cj/nov-reading-palettes' plus \"none\"."
:group 'cj/nov-reading)
(defcustom cj/nov-reading-text-height 180
- "Base `default'-face height (1/10 pt) a fresh nov buffer opens at.
-The +/-/= keys adjust the page size from here with a buffer-local text-scale;
-that adjustment resets to this base each time a book is opened."
+ "Base `default'-face height (1/10 pt) the reading view renders at.
+The +/-/= keys adjust the page size from here with a buffer-local text-scale.
+That adjustment is remembered globally (see `cj/nov-reading-text-scale-file'):
+every book and every session opens at the size you last left it, and `='
+returns to this base."
:type 'integer
:group 'cj/nov-reading)
+(defvar cj/nov-reading-text-scale-file
+ (expand-file-name "data/nov-reading-text-scale" user-emacs-directory)
+ "File persisting the global reading text-scale offset across sessions.
+A single integer: the buffer-local `text-scale-mode-amount' the +/-/= keys
+last set, applied on top of `cj/nov-reading-text-height' when a book opens.")
+
+(defun cj/nov-reading--parse-text-scale (s)
+ "Parse S (a string or nil) as an integer text-scale offset; 0 when invalid.
+Surrounding whitespace is tolerated; non-integer content yields 0."
+ (let ((trimmed (and (stringp s) (string-trim s))))
+ (if (and trimmed (string-match-p "\\`[+-]?[0-9]+\\'" trimmed))
+ (string-to-number trimmed)
+ 0)))
+
+(defun cj/nov-reading--load-text-scale ()
+ "Return the persisted reading text-scale offset, or 0 when none is saved."
+ (if (file-readable-p cj/nov-reading-text-scale-file)
+ (cj/nov-reading--parse-text-scale
+ (with-temp-buffer
+ (insert-file-contents cj/nov-reading-text-scale-file)
+ (buffer-string)))
+ 0))
+
+(defun cj/nov-reading--save-text-scale (amount)
+ "Persist AMOUNT as the global reading text-scale offset.
+Creates the data directory when absent."
+ (make-directory (file-name-directory cj/nov-reading-text-scale-file) t)
+ (with-temp-file cj/nov-reading-text-scale-file
+ (insert (number-to-string amount))))
+
(defun cj/nov-reading-apply-typography ()
"Apply the reading family and base height buffer-local.
Remaps `variable-pitch', `default', and `fixed-pitch' so nov's shr output reads
@@ -149,26 +250,31 @@ as a comfortably-sized serif page."
(face-remap-add-relative 'fixed-pitch :height cj/nov-reading-text-height))
(defun cj/nov-reading-text-bigger ()
- "Increase the page font size (buffer-local), on top of the base height."
+ "Increase the page font size and remember it across books and sessions."
(interactive)
- (text-scale-increase 1))
+ (text-scale-increase 1)
+ (cj/nov-reading--save-text-scale text-scale-mode-amount))
(defun cj/nov-reading-text-smaller ()
- "Decrease the page font size (buffer-local), on top of the base height."
+ "Decrease the page font size and remember it across books and sessions."
(interactive)
- (text-scale-decrease 1))
+ (text-scale-decrease 1)
+ (cj/nov-reading--save-text-scale text-scale-mode-amount))
(defun cj/nov-reading-text-reset ()
- "Reset the page font size back to the base reading height (buffer-local)."
+ "Reset the page font size to the base reading height; clears the saved offset."
(interactive)
- (text-scale-set 0))
+ (text-scale-set 0)
+ (cj/nov-reading--save-text-scale 0))
;; ------------------------------- Launch hook ---------------------------------
(defun cj/nov-reading-setup ()
"Apply the reading view (typography + default palette) to this nov buffer.
+Restores the remembered page font size on top of the base height.
Called from the nov-mode launch hook in calibredb-epub-config.el."
(cj/nov-reading-apply-typography)
+ (text-scale-set (cj/nov-reading--load-text-scale))
(when cj/nov-reading-default-palette
(cj/nov--apply-reading-palette cj/nov-reading-default-palette)))