From d94b0dc1603acae7abef0a00bc096ef45d79636b Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 29 Jun 2026 22:07:45 -0400 Subject: feat(nov): reading-view theme layer with palettes and font sizing EPUB reading prefs were scattered: a hardcoded Merriweather/180 font-remap in calibredb-epub-config's nov hook, no color control (the old sepia foreground had been stripped), and a frame-global EBook fontaine preset as the only way to size up. That preset resized the font in every buffer in the frame, not just the book. I pulled the reading view into its own layer, modules/nov-reading.el, on top of stock nov (no fork). It owns three things, all buffer-local: a reading palette (sepia/dark/light, each a face the dupre theme owns, sepia the default), the serif typography (family plus a defcustom base height replacing the hardcoded 180), and page font sizing (+/- bump the size live, = resets to the base). Width moves to { }. calibredb-epub-config keeps the library and width/centering layout. Its nov hook now calls into the layer. The three palette faces register as a nov-reading app in theme-studio (face_data.py), so they're tunable there like any other app. I dropped the EBook fontaine preset, since reading size is buffer-local now. --- init.el | 1 + modules/calibredb-epub-config.el | 25 ++--- modules/font-config.el | 5 - modules/nov-reading.el | 176 +++++++++++++++++++++++++++++++++ scripts/theme-studio/face_data.py | 9 ++ scripts/theme-studio/theme-studio.html | 2 +- tests/test-calibredb-epub-config.el | 11 ++- tests/test-nov-reading--palette.el | 60 +++++++++++ 8 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 modules/nov-reading.el create mode 100644 tests/test-nov-reading--palette.el diff --git a/init.el b/init.el index 919da1af0..c93a80128 100644 --- a/init.el +++ b/init.el @@ -90,6 +90,7 @@ ;; ---------------------- Added Features And Integrations ---------------------- +(require 'nov-reading) ;; epub reading-view theme layer (palettes + size) (require 'calibredb-epub-config) ;; ebook reader/manager settings (require 'dashboard-config) ;; the nice landing page with links (require 'dirvish-config) ;; file manager configuration diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el index a8b131be8..b03d83ed0 100644 --- a/modules/calibredb-epub-config.el +++ b/modules/calibredb-epub-config.el @@ -31,6 +31,8 @@ (declare-function nov-render-document "nov" ()) (defvar nov-text-width) ; from nov.el; set buffer-local here +(require 'nov-reading) ;; reading-view theme layer: palettes + typography + size + ;; calibredb commands the curated menu drives (all autoloaded by calibredb) (declare-function calibredb-switch-library "calibredb" ()) (declare-function calibredb-search-keyword-filter "calibredb-search") @@ -314,12 +316,8 @@ A positive DELTA narrows the text column; a negative DELTA widens it." (defun cj/nov-apply-preferences () "Apply preferences after nov-mode has launched." (interactive) - ;; Use Merriweather for comfortable reading with appropriate scaling. - ;; (Reading fg color stripped; falls back to the theme default until a - ;; themeable reading face exists -- see todo.org.) - (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0) - (face-remap-add-relative 'default :family "Merriweather" :height 180) - (face-remap-add-relative 'fixed-pitch :height 180) + ;; Reading typography + color palette live in the nov-reading theme layer. + (cj/nov-reading-setup) ;; Enable visual-line-mode for proper text wrapping (visual-line-mode 1) ;; Set fill-column as a fallback @@ -428,14 +426,19 @@ Try to use the Calibre book id from the parent folder name (for example, ("<" . nov-history-back) (">" . nov-history-forward) ("," . backward-paragraph) - ;; +/= widen the text column, -/_ narrow it (50%..100% of the window) - ("+" . cj/nov-widen-text) - ("=" . cj/nov-widen-text) - ("-" . cj/nov-narrow-text) - ("_" . cj/nov-narrow-text) + ;; +/- adjust the page font size, = resets it to the default height + ("+" . cj/nov-reading-text-bigger) + ("-" . cj/nov-reading-text-smaller) + ("=" . cj/nov-reading-text-reset) + ;; { } adjust the text-column width (50%..100% of the window) + ("}" . cj/nov-widen-text) + ("{" . cj/nov-narrow-text) ;; open current EPUB with zathura (same key in pdf-view) ("z" . cj/nov-open-external) ("t" . nov-goto-toc) + ;; c cycles reading palettes (sepia/dark/light/none); C picks one by name + ("c" . cj/nov-cycle-reading-palette) + ("C" . cj/nov-set-reading-palette) ("C-c C-b" . cj/nov-jump-to-calibredb))) ;; ------------------------- Nov bookmark naming ------------------------------- diff --git a/modules/font-config.el b/modules/font-config.el index 095b4c8c1..3aa3d80f6 100644 --- a/modules/font-config.el +++ b/modules/font-config.el @@ -83,11 +83,6 @@ (FiraCode-Literata :default-family "Fira Code Nerd Font" :variable-pitch-family "Literata") - (EBook - :default-family "Lexend" - :default-weight regular - :default-height 200 - :variable-pitch-family "Lexend") (24-point-font :default-height 240) (20-point-font diff --git a/modules/nov-reading.el b/modules/nov-reading.el new file mode 100644 index 000000000..0181b03a0 --- /dev/null +++ b/modules/nov-reading.el @@ -0,0 +1,176 @@ +;;; nov-reading.el --- Reading-view theme layer for nov-mode EPUBs -*- lexical-binding: t; -*- +;; author: Craig Jennings + +;;; Commentary: +;; +;; Layer: 4 (Added features). +;; Category: O (optional commands + faces). +;; 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. +;; Runtime requires: none (face-remap and text-scale are built in). +;; Direct test load: yes. +;; +;; A small theme layer on top of the stock `nov' package (no fork): how an EPUB +;; *reads*, kept buffer-local so it never disturbs the frame or other buffers. +;; Two knobs: +;; +;; - Reading palette -- the background + foreground, as sepia / dark / light, +;; each a face the dupre theme / theme-studio own (registered as the +;; "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. +;; +;; calibredb-epub-config.el owns the library/calibre side and the text-width / +;; centering layout; this module owns reading color and typography. Its launch +;; entry point `cj/nov-reading-setup' is called from that module's nov-mode hook. + +;;; Code: + +(defgroup cj/nov-reading nil + "Reading-view theming for nov-mode EPUBs." + :group 'cj) + +;; ----------------------------- Reading palettes ------------------------------ +;; nov renders through shr and defines no faces, so a palette is a buffer-local +;; face-remap of `default'. Each palette is one face carrying a :background and +;; :foreground, so the theme owns the real colors (the hex defaults here are a +;; starting point to tune in theme-studio). + +(defface cj/nov-reading-sepia + '((t :background "#1f1b16" :foreground "#c9b187")) + "Sepia reading palette for nov-mode: warm dark background, tan text." + :group 'cj/nov-reading) + +(defface cj/nov-reading-dark + '((t :background "#15140f" :foreground "#cfc8b8")) + "Dark reading palette for nov-mode: near-black background, light-gray text." + :group 'cj/nov-reading) + +(defface cj/nov-reading-light + '((t :background "#ece3cf" :foreground "#2a2622")) + "Light reading palette for nov-mode: cream background, near-black text." + :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) + :group 'cj/nov-reading) + +(defcustom cj/nov-reading-default-palette "sepia" + "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) + :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-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." + (cdr (assoc name cj/nov-reading-palettes))) + +(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 +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/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))) + (when face + (setq cj/nov--reading-remap-cookie + (face-remap-add-relative 'default face))) + (setq cj/nov--reading-palette (and face name)))) + +(defun cj/nov-set-reading-palette (name) + "Choose reading palette NAME for this nov buffer; \"none\" clears it. +Interactively prompts among `cj/nov-reading-palettes' plus \"none\"." + (interactive + (list (completing-read "Reading palette: " + (cons "none" (mapcar #'car cj/nov-reading-palettes)) + nil t))) + (unless (derived-mode-p 'nov-mode) + (user-error "Not in a nov-mode buffer")) + (cj/nov--apply-reading-palette (unless (equal name "none") name)) + (message "Reading palette: %s" (or cj/nov--reading-palette "none"))) + +(defun cj/nov-cycle-reading-palette () + "Cycle to the next reading palette, then the no-palette state, wrapping." + (interactive) + (unless (derived-mode-p 'nov-mode) + (user-error "Not in a nov-mode buffer")) + (let ((next (cj/nov--next-reading-palette + cj/nov--reading-palette + (mapcar #'car cj/nov-reading-palettes)))) + (cj/nov--apply-reading-palette next) + (message "Reading palette: %s" (or next "none")))) + +;; ------------------------------- Typography ---------------------------------- + +(defcustom cj/nov-reading-font-family "Merriweather" + "Variable-pitch serif family for the EPUB reading view." + :type 'string + :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." + :type 'integer + :group 'cj/nov-reading) + +(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 +as a comfortably-sized serif page." + (face-remap-add-relative 'variable-pitch + :family cj/nov-reading-font-family :height 1.0) + (face-remap-add-relative 'default + :family cj/nov-reading-font-family + :height cj/nov-reading-text-height) + (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." + (interactive) + (text-scale-increase 1)) + +(defun cj/nov-reading-text-smaller () + "Decrease the page font size (buffer-local), on top of the base height." + (interactive) + (text-scale-decrease 1)) + +(defun cj/nov-reading-text-reset () + "Reset the page font size back to the base reading height (buffer-local)." + (interactive) + (text-scale-set 0)) + +;; ------------------------------- Launch hook --------------------------------- + +(defun cj/nov-reading-setup () + "Apply the reading view (typography + default palette) to this nov buffer. +Called from the nov-mode launch hook in calibredb-epub-config.el." + (cj/nov-reading-apply-typography) + (when cj/nov-reading-default-palette + (cj/nov--apply-reading-palette cj/nov-reading-default-palette))) + +(provide 'nov-reading) +;;; nov-reading.el ends here diff --git a/scripts/theme-studio/face_data.py b/scripts/theme-studio/face_data.py index f149c5441..f6c0b5ca9 100644 --- a/scripts/theme-studio/face_data.py +++ b/scripts/theme-studio/face_data.py @@ -343,6 +343,14 @@ GNUS_SEED={ "gnus-cite-1":{"fg":"sage"},"gnus-cite-2":{"fg":"steel"},"gnus-cite-3":{"fg":"gold"},"gnus-cite-4":{"fg":"blue"},"gnus-cite-5":{"fg":"sage"},"gnus-cite-6":{"fg":"steel"},"gnus-cite-7":{"fg":"gold"},"gnus-cite-8":{"fg":"blue"},"gnus-cite-9":{"fg":"sage"},"gnus-cite-10":{"fg":"steel"},"gnus-cite-11":{"fg":"gold"},"gnus-cite-attribution":{"fg":"silver","italic":True}, "gnus-signature":{"fg":"pewter","italic":True},"gnus-button":{"fg":"blue","underline":True}, "gnus-emphasis-bold":{"bold":True},"gnus-emphasis-italic":{"italic":True},"gnus-emphasis-underline":{"underline":True},"gnus-emphasis-strikethru":{"fg":"pewter","strike":True},"gnus-emphasis-highlight-words":{"fg":"gold","bold":True}} +# nov-reading: the EPUB reading-view palettes (config faces, not a package). Each +# is the buffer-local default bg+fg for a reading mode; seeded with the module's +# starting hex so the studio shows sepia/dark/light from the first render. +NOV_READING_FACES=("cj/nov-reading-sepia cj/nov-reading-dark cj/nov-reading-light").split() +NOV_READING_SEED={ + "cj/nov-reading-sepia":{"bg":"#1f1b16","fg":"#c9b187"}, + "cj/nov-reading-dark":{"bg":"#15140f","fg":"#cfc8b8"}, + "cj/nov-reading-light":{"bg":"#ece3cf","fg":"#2a2622"}} # The bespoke package apps, single-sourced here. Each row is # (key, label, preview, FACES, prefix, SEED); add an app by adding one row. @@ -365,6 +373,7 @@ BESPOKE_APP_SPECS=[ ("dired","dired","dired",DIRED_FACES,"dired-",DIRED_SEED), ("dirvish","dirvish","dirvish",DIRVISH_FACES,"dirvish-",DIRVISH_SEED), ("calibredb","calibredb","calibredb",CALIBREDB_FACES,"calibredb-",CALIBREDB_SEED), + ("nov-reading","nov reading view","novreading",NOV_READING_FACES,"cj/nov-reading-",NOV_READING_SEED), ("erc","erc","erc",ERC_FACES,"erc-",ERC_SEED), ("org-drill","org-drill","orgdrill",ORGDRILL_FACES,"org-drill-",ORGDRILL_SEED), ("org-noter","org-noter","orgnoter",ORGNOTER_FACES,"org-noter-",ORGNOTER_SEED), diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 0529f27c2..e71658f62 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -297,7 +297,7 @@