diff options
| -rw-r--r-- | docs/design/org-faces-spec.org | 150 | ||||
| -rw-r--r-- | init.el | 1 | ||||
| -rw-r--r-- | modules/org-config.el | 66 | ||||
| -rw-r--r-- | modules/org-faces-config.el | 129 | ||||
| -rw-r--r-- | tests/test-org-config-table-header.el | 115 | ||||
| -rw-r--r-- | tests/test-org-faces-config.el | 54 |
6 files changed, 515 insertions, 0 deletions
diff --git a/docs/design/org-faces-spec.org b/docs/design/org-faces-spec.org new file mode 100644 index 000000000..c81880270 --- /dev/null +++ b/docs/design/org-faces-spec.org @@ -0,0 +1,150 @@ +#+TITLE: Org Header-Row Faces — Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-15 +#+TODO: TODO | DONE SUPERSEDED CANCELLED + +* Metadata +| Status | draft | +|----------+----------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+----------------------------------------------------------------| +| Reviewer | Craig Jennings | +|----------+----------------------------------------------------------------| +| Related | [[file:../../todo.org][todo.org: org-faces module + theme-studio app]] | +|----------+----------------------------------------------------------------| + +* Summary + +A small config module, =org-faces-config.el=, that defines named, theme-agnostic faces for org's TODO keywords and priority cookies, wires them through =org-todo-keyword-faces= and =org-priority-faces=, and a matching theme-studio app titled "org-faces" so each is an editable, previewable element. It makes the agenda header row (keyword plus priority) a per-element themeable layer that reads as clearly custom, not built-in org. + +* Problem / Context + +Per-keyword and per-priority coloring was stripped from =org-config.el= earlier, so today every keyword (TODO, DOING, …) renders in org's built-in =org-todo= / =org-done=, and every priority cookie ([#A] through [#D]) renders in the single =org-priority= face — one color for all four. There's no way to color them individually. + +The dupre theme does carry a parallel custom set (=dupre-org-todo=, =dupre-org-priority-a..d=, with =-dim= variants), but it's theme-specific and currently unwired, so it colors nothing. And theme-studio has no surface for these at all: its org-mode app exposes only the built-in =org-priority=, and the preview shows a single [#A]. + +The immediate driver is theme-testing the agenda: the header row is the densest, most-scanned part of the agenda, and right now its keyword/priority colors can't be tuned in theme-studio or distinguished from built-in org faces. The user wants each keyword and priority to be its own themeable element, in its own clearly-custom namespace, surfaced in its own theme-studio section. + +* Goals and Non-Goals + +** Goals +- Each keyword (TODO, PROJECT, DOING, WAITING, VERIFY, STALLED, DELEGATED, FAILED, DONE, CANCELLED) and each priority (A-D) gets its own named face. +- The faces live in one module (=org-faces-config.el=), in their own prefix, wired through =org-todo-keyword-faces= and =org-priority-faces=. +- theme-studio surfaces them as a dedicated "org-faces" app (own section, alongside elfeed and mu4e) with one editable row per face and a header-row preview. +- They render correctly on any theme (sensible defaults) and are overridden by the generated theme. + +** Non-Goals +- Not editing the built-in org faces — the org-mode app keeps those. +- Not a general org face overhaul; only the header-row keyword + priority set. +- Not retiring or deleting the legacy =dupre-org-*= faces from dupre-faces.el in v1 — auto-dim is only repointed away from them (decision below). + +** Scope tiers +- v1: =org-faces-config.el= (base + =-dim= faces, wiring, init load); =org-faces-*-dim= wired into auto-dim (replacing the orphaned dupre-org-* entries); the theme-studio "org-faces" app (faces, preview, seeds). +- Out of scope: built-in org faces; non-keyword/priority org elements; terminal/document-rendered color sources. +- vNext (log to todo.org): retire or migrate the legacy =dupre-org-*= faces in dupre-faces.el; a grouped-subsection preview. + +* Design + +A new bespoke app, "org-faces", joins theme-studio's application list next to elfeed and mu4e. Its faces are the config's custom header-row set, not org's built-ins, and the =org-faces-= prefix says so on every row. + +** For the user + +Pick "org-faces" in theme-studio's application dropdown. The face table lists each keyword and each priority as a normal editable row — foreground, background, style toggles, box, lock — exactly like every other package face. The preview renders agenda-style lines: each keyword shown in its own face, then a =[#A] [#B] [#C] [#D]= row in the four priority faces, so the ramp is visible and any cookie is click-to-select. Setting a color there writes it into the generated theme; once that theme loads, the real agenda's keywords and priorities pick it up. Because the faces are named =org-faces-todo=, =org-faces-priority-a=, and so on, it's obvious this is the config's layer rather than built-in org. + +** For the implementer + +=org-faces-config.el= defines one =defface= per keyword and per priority (=org-faces-todo= … =org-faces-cancelled=, =org-faces-priority-a= … =org-faces-priority-d=), each with a default foreground so the row is colored out of the box. It then sets =org-todo-keyword-faces= to map each keyword string to its face and =org-priority-faces= to map =?A..?D= to the priority faces. The module is required after org so the faces exist before org applies the maps; the =defface=s themselves load eagerly, which is what org needs. + +theme-studio side, all mechanical against the existing bespoke-app machinery: +- =face_data.py=: =ORGFACES_FACES= (the face-name list) and =ORGFACES_SEED= (default colors mirroring =org-faces-config.el=). +- =generate.py=: one row in the =_BESPOKE_APPS= spec, =("org-faces","org-faces","orgfaces",ORGFACES_FACES,"org-faces-",ORGFACES_SEED)=. +- =app_inventory.py=: add =org-faces= to =BESPOKE_APPS=. +- =app.js=: =renderOrgFacesPreview= building the keyword lines and the priority-cookie row with =os('org-faces', face, text)=, registered under =orgfaces= in =PACKAGE_PREVIEWS=. +- =build-theme.el= needs no change — the package tier already emits these faces. + +The =org-faces-= prefix is also theme-studio's label-strip prefix, so rows read as "todo", "priority a", etc. + +* Alternatives Considered + +** Reuse the existing dupre-org-* names +- Good, because no new faces are defined. +- Bad, because the names are theme-specific (dupre) and the goal is a theme-agnostic, clearly-custom namespace; they'd still need all the theme-studio wiring. +- Neutral, because the auto-dim mappings already reference them, which both helps (dim variants exist) and hurts (couples the new layer to one theme). + +** Inline specs in org-todo-keyword-faces (no named faces) +- Good, because it's the least code and needs no defface. +- Bad, because theme-studio can only theme *named* faces; inline specs can't be edited or previewed there, which defeats the whole point. +- Neutral, because org supports both forms equally at runtime. + +** Put these in the existing org-mode app rather than a new app +- Good, because one fewer app in the dropdown. +- Bad, because it blurs the built-in-vs-custom line the user explicitly wants drawn. +- Neutral, because the preview would grow rather than a new one being added. + +* Decisions [4/4] + +** DONE Face prefix +- Context: the prefix is the public face namespace and theme-studio's label-strip; it must read as custom, not built-in org. +- Decision: We will use =org-faces-= (e.g. =org-faces-todo=, =org-faces-priority-a=). +- Consequences: easier — cohesive with the module and section name, and a clean label-strip prefix; harder — it still begins with "org", so a newcomer could misread it as built-in. + +** DONE defface defaults vs inherit-only +- Context: should the header row be colored on any theme, or only once a theme sets these faces? +- Decision: We will give each face a real default =:foreground= so it's colored out of the box, overridable by the theme. +- Consequences: easier — works on stock Emacs and any theme, and the theme-studio seed matches the live default; harder — the defaults are theme-blind, so a theme that doesn't override them shows the generic colors rather than its own palette. + +** DONE Auto-dim dim variants +- Context: dupre defines =dupre-org-*= and auto-dim remaps them to =-dim= variants in unfocused windows; rewiring to =org-faces-*= would drop that dim treatment unless it's carried over. +- Decision: We will include =org-faces-*-dim= variants in v1 and wire them into auto-dim, replacing the now-orphaned =dupre-org-*= entries in =auto-dim-config.el=, so keywords and priorities stay legible when a window is dimmed. The legacy =dupre-org-*= faces in =dupre-faces.el= are left defined-but-unused; retiring them stays vNext. +- Consequences: easier — the dim treatment carries onto the new layer and dupre's mapping stops referencing orphaned faces; harder — doubles the face count (base + dim), touches =auto-dim-config.el=, and under the dupre theme the new faces use their defaults until dupre themes =org-faces-*=. + +** DONE Keyword coverage +- Context: the vocabulary has 10 keywords; dupre only ever defined faces for 8. +- Decision: We will give all 10 keywords (including DELEGATED and CANCELLED) their own face. +- Consequences: easier — full control, no surprise fallbacks; harder — two more faces to seed and maintain. + +* Implementation phases + +** Phase 1 — org-faces.el module +Define the base and =-dim= =defface=s (all 10 keywords + A-D, each with real default foregrounds) and the =org-todo-keyword-faces= / =org-priority-faces= wiring, require it after org. Done when the agenda colors each keyword and priority through the new base faces. Verify with a full Emacs launch (the wiring is :config-adjacent). + +** Phase 2 — auto-dim integration +In =auto-dim-config.el=, replace the =dupre-org-*= → =dupre-org-*-dim= entries with =org-faces-*= → =org-faces-*-dim=, so dimmed windows render the new layer through its dim variants. Done when an unfocused window shows keywords/priorities in their dim colors. + +** Phase 3 — theme-studio org-faces app +Add the =face_data.py= face list and seed (base + dim), the =generate.py= spec row, the =app_inventory.py= entry, and the =app.js= preview plus registration. Done when "org-faces" appears in the dropdown next to elfeed/mu4e, the rows edit, the preview renders, and =make theme-studio-test= is green. + +** Phase 4 — generated-theme round-trip +Set a color for an =org-faces-*= face in theme-studio, =make deploy-wip=, and confirm the real agenda picks it up. Done when the round-trip lands the color in Emacs. + +* Acceptance criteria +- [ ] Each keyword (TODO … CANCELLED) renders in a distinct =org-faces-*= face in the agenda. +- [ ] [#A]-[#D] render in distinct =org-faces-priority-*= faces. +- [ ] theme-studio shows an "org-faces" app beside elfeed/mu4e, one row per face, with a header-row preview. +- [ ] A color set in theme-studio for an =org-faces-*= face appears on the real agenda after =deploy-wip=. +- [ ] =org-faces-config.el= byte-compiles clean and the theme-studio suite is green. + +* Readiness dimensions +- Data model & ownership: faces are user-authored defaults (=org-faces-config.el=) overridden by the generated theme; theme-studio edits the package-tier specs; =org-todo-keyword-faces= / =org-priority-faces= own the keyword/priority wiring. +- Errors, empty states & failure: a keyword with no mapping falls back to org's built-in =org-todo= / =org-done= — acceptable, not silent data loss. N/A otherwise (no I/O). +- Security & privacy: N/A — faces only. +- Observability: the live agenda and the theme-studio preview are the visible surface; a wrong color is self-evident. +- Performance & scale: N/A — about a dozen faces. +- Reuse & lost opportunities: rides org's built-in keyword/priority face hooks and build-theme's existing package tier; no converter changes. +- Architecture fit & weak points: same bespoke-app pattern as elfeed/mu4e. Weak point is load order — faces must exist before org applies the map; eager =defface= at module load covers it. +- Config surface: =org-todo-keyword-faces=, =org-priority-faces= (set by the module), plus the faces themselves; all overridable. +- Documentation plan: a header comment in =org-faces-config.el= and this spec; no user-facing docs needed. +- Dev tooling: existing =make theme-studio-test= and =make deploy-wip= cover build and round-trip. +- Rollout, compatibility & rollback: additive; rollback is removing the module and the theme-studio app. It re-introduces keyword/priority coloring that was deliberately stripped earlier, now as a named themeable layer. +- External APIs & deps: =org-todo-keyword-faces= and =org-priority-faces= are standard org defcustoms (verified); build-theme's package tier already emits inherit/box/style (verified this session). + +* Risks, Rabbit Holes, and Drawbacks +- Poor default colors fight unset themes. Dodge: seed conservatively, lean on theme override. +- Load order: the faces must be defined before org renders the agenda. Dodge: eager defface, require before/with org. +- Scope creep into the dupre/auto-dim migration. Dodge: it's explicitly vNext with its own decision. + +* Review and iteration history +** 2026-06-15 Mon @ 01:51:57 -0500 — Craig — author +- What: initial draft. +- Why: the A-D priority request generalized into a clearly-custom, theme-studio-surfaced header-row face layer; the prefix, default-color policy, and dupre/auto-dim reconciliation are real trade-offs worth settling on paper first. +- Artifacts: [[file:../../todo.org][todo.org org-faces task]]; theme-studio bespoke-app machinery (face_data.py, generate.py, app.js). @@ -122,6 +122,7 @@ ;; ---------------------------------- Org Mode --------------------------------- (require 'org-config) ;; basic org-mode settings +(require 'org-faces-config) ;; custom themeable faces for agenda keywords + priorities (require 'org-agenda-config) ;; agenda, task tracking, and notifications (require 'org-babel-config) ;; org-mode prog blocks; literate programming (require 'org-capture-config) diff --git a/modules/org-config.el b/modules/org-config.el index 26b5f0aa5..783109349 100644 --- a/modules/org-config.el +++ b/modules/org-config.el @@ -132,6 +132,72 @@ edge, less the tag width.") (add-hook 'org-mode-hook #'cj/org--manage-tag-display-prop) (font-lock-add-keywords 'org-mode cj/org-right-align-tags-keyword t) +;; ------------------------ Org Table Header Highlighting -------------------- +;; Org faces the whole table -- header rows included -- with `org-table'; it has +;; no in-buffer header-row face. `org-table-header' is used only by the sticky +;; header line of `org-table-header-line-mode'. This font-lock keyword prepends +;; `org-table-header' onto a table's header rows (the non-hline rows above its +;; first hline), so the themed header style lands in place in the buffer. + +(declare-function org-at-table-p "org") +(declare-function org-at-table-hline-p "org") +(declare-function org-table-begin "org-table") +(declare-function org-table-end "org-table") + +(defcustom cj/org-fontify-table-headers t + "When non-nil, highlight org table header rows with the `org-table-header' face. +A header row is a non-hline table row above its table's first hline. Org has no +in-buffer header-row face of its own, so this supplies one, deferring its whole +appearance to the themed `org-table-header' face." + :type 'boolean + :group 'org) + +(defun cj/--org-table-first-hline-position () + "Return the start position of the first hline in the table at point, or nil. +Point must be inside an org table." + (save-excursion + (let ((end (org-table-end)) + (found nil)) + (goto-char (org-table-begin)) + (while (and (not found) (< (point) end)) + (when (org-at-table-hline-p) + (setq found (line-beginning-position))) + (forward-line 1)) + found))) + +(defun cj/--org-table-header-row-p () + "Return non-nil if the line at point is a header row of its org table. +A header row is a non-hline table row positioned above the table's first hline. +A table with no hline has no header rows." + (and (org-at-table-p) + (not (org-at-table-hline-p)) + (let ((hline (cj/--org-table-first-hline-position))) + (and hline (< (line-beginning-position) hline))))) + +(defun cj/--org-fontify-table-header-matcher (limit) + "Font-lock matcher for the next org table header row before LIMIT. +Returns non-nil when a header row is found, with match group 0 spanning the +whole row line." + (let (beg end found) + (while (and (not found) + (re-search-forward "^[ \t]*|.*$" limit t)) + (setq beg (match-beginning 0) + end (match-end 0)) + (save-excursion + (goto-char beg) + (when (cj/--org-table-header-row-p) + (setq found t)))) + (when found + (set-match-data (list beg end)) + t))) + +(defconst cj/org-table-header-keyword + '((cj/--org-fontify-table-header-matcher (0 'org-table-header prepend))) + "Font-lock keyword prepending `org-table-header' onto org table header rows.") + +(when cj/org-fontify-table-headers + (font-lock-add-keywords 'org-mode cj/org-table-header-keyword t)) + ;; ----------------------------- Org TODO Settings --------------------------- (defun cj/org-todo-settings () diff --git a/modules/org-faces-config.el b/modules/org-faces-config.el new file mode 100644 index 000000000..e0dfa83fd --- /dev/null +++ b/modules/org-faces-config.el @@ -0,0 +1,129 @@ +;;; org-faces-config.el --- Custom faces for the org agenda header row -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; +;; Layer: 2 (Core UX). +;; Category: C/S. +;; Load shape: eager. +;; Eager reason: the faces must exist before org renders the agenda. +;; Top-level side effects: defines the org-faces-* faces; sets +;; org-todo-keyword-faces and org-priority-faces once org loads. +;; Runtime requires: none (org wiring is deferred via with-eval-after-load). +;; +;; Custom faces for the agenda "header row" -- the TODO keyword and the +;; priority cookie -- so each keyword and each priority is its own themeable +;; element rather than sharing org's built-in org-todo / org-done / org-priority. +;; They are named org-faces-* (not org-*) so it's obvious they are this config's +;; layer, not built-in org. Each carries a real default color so the agenda is +;; legible on any theme; a theme (e.g. one generated by theme-studio's +;; "org-faces" app) overrides them. The -dim variants are the dimmed colors +;; auto-dim-config.el remaps these to in non-selected windows, so keywords stay +;; recognizable when a window recedes. +;; +;; Note: this file is org-faces-CONFIG, not org-faces -- org ships its own +;; `org-faces' feature (lisp/org/org-faces.el), so reusing that name would +;; shadow org's face definitions on the load path. + +;;; Code: + +(eval-when-compile (require 'org)) + +(defgroup org-faces-config nil + "Custom faces for the org agenda header row (keywords and priorities)." + :group 'org) + +;; --------------------------- Keyword faces (focused) ------------------------- + +(defface org-faces-todo '((t (:foreground "#8fbf73" :weight bold))) + "Face for the TODO keyword." :group 'org-faces-config) +(defface org-faces-project '((t (:foreground "#7a9abe" :weight bold))) + "Face for the PROJECT keyword." :group 'org-faces-config) +(defface org-faces-doing '((t (:foreground "#e8c668" :weight bold))) + "Face for the DOING keyword." :group 'org-faces-config) +(defface org-faces-waiting '((t (:foreground "#c9b08a" :weight bold))) + "Face for the WAITING keyword." :group 'org-faces-config) +(defface org-faces-verify '((t (:foreground "#d98a5a" :weight bold))) + "Face for the VERIFY keyword." :group 'org-faces-config) +(defface org-faces-stalled '((t (:foreground "#9a8fb0" :weight bold))) + "Face for the STALLED keyword." :group 'org-faces-config) +(defface org-faces-delegated '((t (:foreground "#7fc0a8" :weight bold))) + "Face for the DELEGATED keyword." :group 'org-faces-config) +(defface org-faces-failed '((t (:foreground "#d05a5a" :weight bold))) + "Face for the FAILED keyword." :group 'org-faces-config) +(defface org-faces-done '((t (:foreground "#6f7a82" :weight bold))) + "Face for the DONE keyword." :group 'org-faces-config) +(defface org-faces-cancelled '((t (:foreground "#6f7a82" :weight bold :strike-through t))) + "Face for the CANCELLED keyword." :group 'org-faces-config) + +;; -------------------------- Priority faces (focused) ------------------------- + +(defface org-faces-priority-a '((t (:foreground "#7aa0d0" :weight bold))) + "Face for the [#A] priority cookie." :group 'org-faces-config) +(defface org-faces-priority-b '((t (:foreground "#e8c668"))) + "Face for the [#B] priority cookie." :group 'org-faces-config) +(defface org-faces-priority-c '((t (:foreground "#8fbf73"))) + "Face for the [#C] priority cookie." :group 'org-faces-config) +(defface org-faces-priority-d '((t (:foreground "#8a8a8a"))) + "Face for the [#D] priority cookie." :group 'org-faces-config) + +;; ----------------------------- Keyword faces (dim) --------------------------- +;; auto-dim-config.el remaps the focused faces above to these in non-selected +;; windows; a darker shade of the same hue keeps the keyword recognizable. + +(defface org-faces-todo-dim '((t (:foreground "#5f7a4d" :weight bold))) + "Dimmed TODO keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-project-dim '((t (:foreground "#4f6680" :weight bold))) + "Dimmed PROJECT keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-doing-dim '((t (:foreground "#9a8544" :weight bold))) + "Dimmed DOING keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-waiting-dim '((t (:foreground "#87745c" :weight bold))) + "Dimmed WAITING keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-verify-dim '((t (:foreground "#8f5a3c" :weight bold))) + "Dimmed VERIFY keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-stalled-dim '((t (:foreground "#665e75" :weight bold))) + "Dimmed STALLED keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-delegated-dim '((t (:foreground "#547d6c" :weight bold))) + "Dimmed DELEGATED keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-failed-dim '((t (:foreground "#8a3c3c" :weight bold))) + "Dimmed FAILED keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-done-dim '((t (:foreground "#4a5158" :weight bold))) + "Dimmed DONE keyword for non-selected windows." :group 'org-faces-config) +(defface org-faces-cancelled-dim '((t (:foreground "#4a5158" :weight bold :strike-through t))) + "Dimmed CANCELLED keyword for non-selected windows." :group 'org-faces-config) + +;; ---------------------------- Priority faces (dim) --------------------------- + +(defface org-faces-priority-a-dim '((t (:foreground "#4f6a8a" :weight bold))) + "Dimmed [#A] priority cookie for non-selected windows." :group 'org-faces-config) +(defface org-faces-priority-b-dim '((t (:foreground "#9a8544"))) + "Dimmed [#B] priority cookie for non-selected windows." :group 'org-faces-config) +(defface org-faces-priority-c-dim '((t (:foreground "#5f7a4d"))) + "Dimmed [#C] priority cookie for non-selected windows." :group 'org-faces-config) +(defface org-faces-priority-d-dim '((t (:foreground "#5a5a5a"))) + "Dimmed [#D] priority cookie for non-selected windows." :group 'org-faces-config) + +;; ---------------------------------- Wiring ----------------------------------- +;; Map each keyword string and priority char to its face once org is loaded, so +;; the values stick regardless of when org initializes. + +(with-eval-after-load 'org + (setq org-todo-keyword-faces + '(("TODO" . org-faces-todo) + ("PROJECT" . org-faces-project) + ("DOING" . org-faces-doing) + ("WAITING" . org-faces-waiting) + ("VERIFY" . org-faces-verify) + ("STALLED" . org-faces-stalled) + ("DELEGATED" . org-faces-delegated) + ("FAILED" . org-faces-failed) + ("DONE" . org-faces-done) + ("CANCELLED" . org-faces-cancelled))) + (setq org-priority-faces + '((?A . org-faces-priority-a) + (?B . org-faces-priority-b) + (?C . org-faces-priority-c) + (?D . org-faces-priority-d)))) + +(provide 'org-faces-config) +;;; org-faces-config.el ends here diff --git a/tests/test-org-config-table-header.el b/tests/test-org-config-table-header.el new file mode 100644 index 000000000..38e73b483 --- /dev/null +++ b/tests/test-org-config-table-header.el @@ -0,0 +1,115 @@ +;;; test-org-config-table-header.el --- In-buffer org table header fontify -*- lexical-binding: t; -*- + +;;; Commentary: +;; Org has no in-buffer header-row face -- the whole table uses `org-table'. +;; cj/--org-table-header-row-p, cj/--org-table-first-hline-position, and the +;; font-lock matcher cj/--org-fontify-table-header-matcher (org-config.el) add +;; one: they identify a table's header rows (the non-hline rows above its first +;; hline) so font-lock can prepend `org-table-header' there. These exercise the +;; detection logic directly against fixture tables, matching the tag-alignment +;; test's pure-logic style. + +;;; Code: + +(require 'ert) +(require 'org) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'org-config) + +(defmacro test-org-th--in (content &rest body) + "Run BODY in a temp org buffer holding CONTENT, hooks suppressed." + (declare (indent 1)) + `(let ((org-mode-hook nil)) + (with-temp-buffer + (insert ,content) + (org-mode) + (goto-char (point-min)) + ,@body))) + +(defun test-org-th--goto (substring) + "Move point to the beginning of the line containing SUBSTRING." + (goto-char (point-min)) + (search-forward substring) + (beginning-of-line)) + +;; ----- cj/--org-table-header-row-p ----- + +(ert-deftest test-org-table-header-row-p-header-above-hline () + "Normal: a non-hline row above the first hline is a header row." + (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n" + (test-org-th--goto "Name") + (should (cj/--org-table-header-row-p)))) + +(ert-deftest test-org-table-header-row-p-body-row-not-header () + "Normal: a row below the first hline is not a header row." + (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n" + (test-org-th--goto "Bob") + (should-not (cj/--org-table-header-row-p)))) + +(ert-deftest test-org-table-header-row-p-hline-not-header () + "Boundary: the hline itself is not a header row." + (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n" + (test-org-th--goto "----") + (should-not (cj/--org-table-header-row-p)))) + +(ert-deftest test-org-table-header-row-p-no-hline-no-header () + "Boundary: a table with no hline has no header rows." + (test-org-th--in "| A | B |\n| x | y |\n" + (test-org-th--goto "A |") + (should-not (cj/--org-table-header-row-p)))) + +(ert-deftest test-org-table-header-row-p-multi-row-header () + "Boundary: every non-hline row above the first hline is a header row." + (test-org-th--in "| A | B |\n| C | D |\n|---+---|\n| x | y |\n" + (test-org-th--goto "A |") + (should (cj/--org-table-header-row-p)) + (test-org-th--goto "C |") + (should (cj/--org-table-header-row-p)))) + +(ert-deftest test-org-table-header-row-p-key-value-first-row-only () + "Boundary: hline-after-every-row table -- only the first row is header." + (test-org-th--in "| Status | draft |\n|--------+-------|\n| Owner | cj |\n|--------+-------|\n" + (test-org-th--goto "Status") + (should (cj/--org-table-header-row-p)) + (test-org-th--goto "Owner") + (should-not (cj/--org-table-header-row-p)))) + +(ert-deftest test-org-table-header-row-p-non-table-line () + "Error: a line that is not in a table is never a header row." + (test-org-th--in "Just some prose.\n" + (test-org-th--goto "prose") + (should-not (cj/--org-table-header-row-p)))) + +;; ----- cj/--org-table-first-hline-position ----- + +(ert-deftest test-org-table-first-hline-position-found () + "Normal: returns the bol of the first hline in the table." + (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n" + (test-org-th--goto "Name") + (let ((expected (save-excursion (goto-char (point-min)) + (forward-line 1) + (line-beginning-position)))) + (should (equal (cj/--org-table-first-hline-position) expected))))) + +(ert-deftest test-org-table-first-hline-position-none () + "Boundary: a table with no hline returns nil." + (test-org-th--in "| A | B |\n| x | y |\n" + (test-org-th--goto "A |") + (should-not (cj/--org-table-first-hline-position)))) + +;; ----- cj/--org-fontify-table-header-matcher ----- + +(ert-deftest test-org-fontify-table-header-matcher-matches-header-only () + "Normal: the matcher sets match data to the header row, then stops." + (test-org-th--in "| Name | Age |\n|------+-----|\n| Bob | 3 |\n" + (should (cj/--org-fontify-table-header-matcher (point-max))) + (should (equal (match-string 0) "| Name | Age |")) + (should-not (cj/--org-fontify-table-header-matcher (point-max))))) + +(ert-deftest test-org-fontify-table-header-matcher-no-header () + "Boundary: a table with no hline yields no matches." + (test-org-th--in "| A | B |\n| x | y |\n" + (should-not (cj/--org-fontify-table-header-matcher (point-max))))) + +(provide 'test-org-config-table-header) +;;; test-org-config-table-header.el ends here diff --git a/tests/test-org-faces-config.el b/tests/test-org-faces-config.el new file mode 100644 index 000000000..8e7da3309 --- /dev/null +++ b/tests/test-org-faces-config.el @@ -0,0 +1,54 @@ +;;; test-org-faces-config.el --- Tests for org-faces-config -*- lexical-binding: t; -*- + +;;; Commentary: +;; Verifies the custom agenda header-row faces exist and that the keyword and +;; priority maps wire each keyword / priority to its org-faces-* face. org is +;; required first so the `with-eval-after-load' wiring in org-faces-config fires +;; on load. + +;;; Code: + +(require 'ert) +(require 'org) +(require 'org-faces-config) + +(ert-deftest test-org-faces-config-base-faces-exist () + "Normal: every base keyword and priority face is defined." + (dolist (f '(org-faces-todo org-faces-project org-faces-doing org-faces-waiting + org-faces-verify org-faces-stalled org-faces-delegated org-faces-failed + org-faces-done org-faces-cancelled + org-faces-priority-a org-faces-priority-b org-faces-priority-c org-faces-priority-d)) + (should (facep f)))) + +(ert-deftest test-org-faces-config-dim-faces-exist () + "Normal: every dim variant is defined (auto-dim remaps onto these)." + (dolist (f '(org-faces-todo-dim org-faces-project-dim org-faces-doing-dim org-faces-waiting-dim + org-faces-verify-dim org-faces-stalled-dim org-faces-delegated-dim org-faces-failed-dim + org-faces-done-dim org-faces-cancelled-dim + org-faces-priority-a-dim org-faces-priority-b-dim org-faces-priority-c-dim org-faces-priority-d-dim)) + (should (facep f)))) + +(ert-deftest test-org-faces-config-keyword-map () + "Normal: representative keywords map to their org-faces-* face." + (should (eq (cdr (assoc "TODO" org-todo-keyword-faces)) 'org-faces-todo)) + (should (eq (cdr (assoc "VERIFY" org-todo-keyword-faces)) 'org-faces-verify)) + (should (eq (cdr (assoc "CANCELLED" org-todo-keyword-faces)) 'org-faces-cancelled)) + (should (eq (cdr (assoc "DELEGATED" org-todo-keyword-faces)) 'org-faces-delegated))) + +(ert-deftest test-org-faces-config-keyword-coverage () + "Boundary: all ten keywords are mapped, each to a real face." + (dolist (kw '("TODO" "PROJECT" "DOING" "WAITING" "VERIFY" "STALLED" + "DELEGATED" "FAILED" "DONE" "CANCELLED")) + (let ((face (cdr (assoc kw org-todo-keyword-faces)))) + (should face) + (should (facep face))))) + +(ert-deftest test-org-faces-config-priority-map () + "Normal: each priority A-D maps to its org-faces-priority-* face." + (should (eq (cdr (assq ?A org-priority-faces)) 'org-faces-priority-a)) + (should (eq (cdr (assq ?B org-priority-faces)) 'org-faces-priority-b)) + (should (eq (cdr (assq ?C org-priority-faces)) 'org-faces-priority-c)) + (should (eq (cdr (assq ?D org-priority-faces)) 'org-faces-priority-d))) + +(provide 'test-org-faces-config) +;;; test-org-faces-config.el ends here |
