From 6f43a260891deea175eeee83ec602f97a16c974e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 30 Jun 2026 01:00:33 -0400 Subject: 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. --- modules/nov-reading.el | 164 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 135 insertions(+), 29 deletions(-) (limited to 'modules') 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))) -- cgit v1.2.3