aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-26 17:10:55 -0500
committerCraig Jennings <c@cjennings.net>2026-05-26 18:09:28 -0500
commit5c8d908a943470e3e4738c090cf8eaa1deee5a1f (patch)
tree2c5d4d7d390013b32768321389dce6419191715b
parent1338b2ae757b7143fe4d211fc5a354c73cee526b (diff)
downloadorg-drill-5c8d908a943470e3e4738c090cf8eaa1deee5a1f.tar.gz
org-drill-5c8d908a943470e3e4738c090cf8eaa1deee5a1f.zip
fix: scope cloze fontification to drill buffers via org-drill-mode
org-drill-add-cloze-fontification ran on org-font-lock-set-keywords-hook, which fires in every org buffer, and pushed the cloze rule into org's global org-font-lock-extra-keywords. The cloze regexp is built from the [ and ] delimiters, so an org priority cookie like [#A] matched the cloze pattern and got fontified as a cloze in every org buffer, colliding with org's headline fontification and stripping the heading's org-level-N face. I replaced the global install with org-drill-mode, a buffer-local minor mode that adds the cloze keywords only to its own buffer via font-lock-add-keywords. org-drill-auto-enable-mode (default on) turns the mode on from org-mode-hook in buffers that hold drill cards, so existing drill files keep their cloze highlighting while plain org buffers stay clean. Highlighting still respects org-drill-use-visible-cloze-face-p. The cloze regexp itself is unchanged, so the single-line cloze constraint from #38 is preserved.
-rw-r--r--org-drill.el83
-rw-r--r--tests/test-org-drill-mode.el136
-rw-r--r--tests/test-org-drill-queue-and-misc.el13
-rw-r--r--tests/test-org-drill-small-branch-coverage.el28
4 files changed, 230 insertions, 30 deletions
diff --git a/org-drill.el b/org-drill.el
index 836f754..13e59da 100644
--- a/org-drill.el
+++ b/org-drill.el
@@ -82,6 +82,9 @@ Returns the version string so it is useful in non-interactive code too."
:group 'org-drill
:type 'string)
+(defvar org-drill-leitner-tag "leitner"
+ "Tag marking entries reviewed via the Leitner box system.")
+
(defcustom org-drill-maximum-items-per-session
30
"Each drill session will present at most this many topics for review.
@@ -175,6 +178,14 @@ Possible values:
:group 'org-drill
:type 'boolean)
+(defcustom org-drill-auto-enable-mode t
+ "When non-nil, enable `org-drill-mode' automatically in Org buffers that
+contain drill cards — headings tagged with `org-drill-question-tag' or
+`org-drill-leitner-tag'. This scopes cloze fontification to buffers that
+actually hold cards instead of installing it in every Org buffer."
+ :group 'org-drill
+ :type 'boolean)
+
(defcustom org-drill-hide-item-headings-p
nil
"If non-nil, conceal headings during a drill session.
@@ -237,8 +248,6 @@ Mature items are due for review, but are not new."
face default
window t))
-(add-hook 'org-font-lock-set-keywords-hook 'org-drill-add-cloze-fontification)
-
(defvar org-drill-hint-separator "||"
"Delimiter in cloze expression for hints.")
@@ -3353,14 +3362,70 @@ values as `org-drill-scope'."
(message "Done.")))
-(defun org-drill-add-cloze-fontification ()
- ;; Compute local versions of the regexp for cloze deletions, in case
- ;; the left and right delimiters are redefined locally.
+(defvar-local org-drill--installed-cloze-keywords nil
+ "The exact cloze font-lock keyword list installed by `org-drill-mode' in
+this buffer, kept so the mode can remove precisely what it added.")
+
+(define-minor-mode org-drill-mode
+ "Minor mode that highlights cloze deletions in the current buffer.
+
+Enabling the mode adds cloze fontification to this buffer only, so the
+highlighting does not leak into unrelated Org buffers. That scoping also
+prevents Org priority cookies such as `[#A]' — which match the cloze
+`[...]' pattern — from being fontified as clozes in non-drill buffers.
+
+The mode controls only the persistent display of clozes in the source
+buffer. Running drill sessions (`org-drill', `org-drill-cram', etc.) does
+not depend on it.
+
+Highlighting is applied only when `org-drill-use-visible-cloze-face-p' is
+non-nil; otherwise the mode is a no-op for fontification."
+ :lighter " Drill"
+ :group 'org-drill
+ ;; Recompute the buffer-local cloze regexp/keywords in case the cloze
+ ;; delimiters were redefined locally.
(setq-local org-drill-cloze-regexp (org-drill--compute-cloze-regexp))
(setq-local org-drill-cloze-keywords (org-drill--compute-cloze-keywords))
- (when org-drill-use-visible-cloze-face-p
- (add-to-list 'org-font-lock-extra-keywords
- (cl-first org-drill-cloze-keywords))))
+ ;; Remove any previously-installed keywords first so repeated toggles
+ ;; don't stack duplicates.
+ (when org-drill--installed-cloze-keywords
+ (font-lock-remove-keywords nil org-drill--installed-cloze-keywords)
+ (setq org-drill--installed-cloze-keywords nil))
+ (when (and org-drill-mode org-drill-use-visible-cloze-face-p)
+ (setq org-drill--installed-cloze-keywords org-drill-cloze-keywords)
+ (font-lock-add-keywords nil org-drill--installed-cloze-keywords 'append))
+ (when font-lock-mode
+ (save-restriction
+ (widen)
+ (font-lock-flush)
+ (font-lock-ensure))))
+
+(defun org-drill-buffer-has-cards-p ()
+ "Return non-nil if the current buffer contains a drill card — a heading
+tagged with `org-drill-question-tag' or `org-drill-leitner-tag'."
+ (save-excursion
+ (save-restriction
+ (widen)
+ (goto-char (point-min))
+ (let ((case-fold-search t))
+ (re-search-forward
+ (concat "^\\*+ .*:\\(?:"
+ (regexp-quote org-drill-question-tag)
+ "\\|"
+ (regexp-quote org-drill-leitner-tag)
+ "\\):")
+ nil t)))))
+
+(defun org-drill-maybe-enable-mode ()
+ "Enable `org-drill-mode' when appropriate for the current buffer.
+Intended for `org-mode-hook': turns the mode on when
+`org-drill-auto-enable-mode' is non-nil and the buffer holds drill cards."
+ (when (and org-drill-auto-enable-mode
+ (derived-mode-p 'org-mode)
+ (org-drill-buffer-has-cards-p))
+ (org-drill-mode 1)))
+
+(add-hook 'org-mode-hook #'org-drill-maybe-enable-mode)
;;; Synching card collections =================================================
@@ -3800,8 +3865,6 @@ Returns a list of strings."
(defvar org-drill-leitner-completed 0
"The number of entries that have been completed this time.")
-(defvar org-drill-leitner-tag "leitner")
-
(defun org-drill-sm-or-leitner ()
(interactive)
;; org-drill-again uses org-drill-pending-entry-count to decide
diff --git a/tests/test-org-drill-mode.el b/tests/test-org-drill-mode.el
new file mode 100644
index 0000000..2050feb
--- /dev/null
+++ b/tests/test-org-drill-mode.el
@@ -0,0 +1,136 @@
+;;; test-org-drill-mode.el --- Tests for org-drill-mode minor mode -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `org-drill-mode' scopes cloze fontification to buffers that hold drill
+;; cards instead of installing it globally via `org-font-lock-set-keywords-hook'.
+;;
+;; Regression target: the global hook caused org priority cookies ([#A]..[#D])
+;; to match the cloze `[...]' pattern in EVERY org buffer, colliding with org's
+;; headline fontification and stripping the heading's `org-level-N' face. With
+;; the mode, plain org buffers carry no cloze rule at all, so the collision
+;; cannot happen there.
+
+;;; Code:
+
+(require 'ert)
+(require 'org-drill)
+
+;;;; Helpers
+
+(defun tdm--face-prop-has (face prop)
+ "Return non-nil if FACE is, or is a member of, the face PROP."
+ (or (eq prop face)
+ (and (listp prop) (memq face prop))))
+
+(defun tdm--face-at-string (s)
+ "Fontify the buffer and return the `face' text property at the first
+character of the first occurrence of literal string S."
+ (font-lock-ensure)
+ (goto-char (point-min))
+ (when (search-forward s nil t)
+ (get-text-property (match-beginning 0) 'face)))
+
+;;;; Minor mode definition
+
+(ert-deftest test-org-drill-mode-is-buffer-local-minor-mode ()
+ "`org-drill-mode' exists and is buffer-local."
+ (should (fboundp 'org-drill-mode))
+ (should (local-variable-if-set-p 'org-drill-mode)))
+
+(ert-deftest test-org-drill-mode-toggle-adds-and-removes-cloze-keyword ()
+ "Enabling the mode highlights a cloze; disabling stops highlighting it."
+ (let ((org-drill-use-visible-cloze-face-p t))
+ (with-temp-buffer
+ (insert "Question [answer] tail\n")
+ (org-mode)
+ (org-drill-mode 1)
+ (should org-drill-mode)
+ (should (tdm--face-prop-has 'org-drill-visible-cloze-face
+ (tdm--face-at-string "answer")))
+ (org-drill-mode -1)
+ (should-not org-drill-mode)
+ (should-not (tdm--face-prop-has 'org-drill-visible-cloze-face
+ (tdm--face-at-string "answer"))))))
+
+;;;; Drill-buffer predicate
+
+(ert-deftest test-org-drill-buffer-has-cards-p-true-for-drill-tag ()
+ "A heading tagged with the question tag counts as a drill buffer."
+ (with-temp-buffer
+ (insert "* A card :drill:\nbody\n")
+ (org-mode)
+ (should (org-drill-buffer-has-cards-p))))
+
+(ert-deftest test-org-drill-buffer-has-cards-p-true-for-leitner-tag ()
+ "A heading tagged with the leitner tag counts as a drill buffer."
+ (with-temp-buffer
+ (insert "* A card :leitner:\nbody\n")
+ (org-mode)
+ (should (org-drill-buffer-has-cards-p))))
+
+(ert-deftest test-org-drill-buffer-has-cards-p-false-for-plain-org ()
+ "An org buffer with no drill/leitner tag is not a drill buffer."
+ (with-temp-buffer
+ (insert "* Just notes :work:\n** TODO [#A] A heading\nbody [bracketed] text\n")
+ (org-mode)
+ (should-not (org-drill-buffer-has-cards-p))))
+
+;;;; Auto-enable on org-mode-hook
+
+(ert-deftest test-org-drill-mode-auto-enables-in-drill-buffer ()
+ "With auto-enable on, opening a drill buffer turns the mode on."
+ (let ((org-drill-auto-enable-mode t))
+ (with-temp-buffer
+ (insert "* A card :drill:\nQ [answer] A\n")
+ (org-mode)
+ (should org-drill-mode))))
+
+(ert-deftest test-org-drill-mode-does-not-auto-enable-in-plain-buffer ()
+ "With auto-enable on, a plain org buffer does NOT turn the mode on."
+ (let ((org-drill-auto-enable-mode t))
+ (with-temp-buffer
+ (insert "* Notes\n** TODO [#A] With cookie\n")
+ (org-mode)
+ (should-not org-drill-mode))))
+
+(ert-deftest test-org-drill-mode-auto-enable-respects-defcustom-off ()
+ "When `org-drill-auto-enable-mode' is nil, drill buffers do not auto-enable."
+ (let ((org-drill-auto-enable-mode nil))
+ (with-temp-buffer
+ (insert "* A card :drill:\nQ [answer] A\n")
+ (org-mode)
+ (should-not org-drill-mode))))
+
+(ert-deftest test-org-drill-auto-enable-mode-defaults-on ()
+ "The auto-enable defcustom ships on so existing drill files keep highlighting."
+ (should (eq t (default-value 'org-drill-auto-enable-mode))))
+
+;;;; Regression — the bug
+
+(ert-deftest test-org-drill-no-global-font-lock-keywords-hook ()
+ "Cloze fontification is no longer installed globally on
+`org-font-lock-set-keywords-hook' — that was the every-buffer pollution that
+made priority cookies collide with the cloze pattern."
+ (should-not (memq 'org-drill-add-cloze-fontification
+ (if (boundp 'org-font-lock-set-keywords-hook)
+ org-font-lock-set-keywords-hook
+ nil))))
+
+(ert-deftest test-org-drill-priority-cookie-not-clozed-in-plain-buffer ()
+ "In a plain org buffer (no drill cards) a priority cookie must not be
+fontified as a cloze, regardless of `org-drill-use-visible-cloze-face-p'."
+ (let ((org-drill-use-visible-cloze-face-p t)
+ (org-drill-auto-enable-mode t))
+ (with-temp-buffer
+ (insert "* Notes\n** TODO [#A] With cookie\n")
+ (org-mode)
+ (should-not org-drill-mode)
+ (goto-char (point-min))
+ (search-forward "#A")
+ (should-not (tdm--face-prop-has
+ 'org-drill-visible-cloze-face
+ (get-text-property (match-beginning 0) 'face))))))
+
+(provide 'test-org-drill-mode)
+
+;;; test-org-drill-mode.el ends here
diff --git a/tests/test-org-drill-queue-and-misc.el b/tests/test-org-drill-queue-and-misc.el
index d8cc3fa..81a7844 100644
--- a/tests/test-org-drill-queue-and-misc.el
+++ b/tests/test-org-drill-queue-and-misc.el
@@ -194,26 +194,27 @@ warned-about-id-creation slot flips to t and a fresh ID is returned."
(org-drill-id-get-create-with-warning session)
(should (oref session warned-about-id-creation))))))
-;;;; org-drill-add-cloze-fontification
+;;;; org-drill-mode (cloze fontification)
-(ert-deftest test-org-drill-add-cloze-fontification-sets-buffer-local-regex ()
- "Sets buffer-local `org-drill-cloze-regexp' built from the current delimiters."
+(ert-deftest test-org-drill-mode-sets-buffer-local-regex ()
+ "Enabling `org-drill-mode' sets buffer-local `org-drill-cloze-regexp' built
+from the current delimiters."
(with-temp-buffer
(let ((org-startup-folded nil))
(org-mode)
(let ((org-drill-left-cloze-delimiter "{{")
(org-drill-right-cloze-delimiter "}}"))
- (org-drill-add-cloze-fontification)
+ (org-drill-mode 1)
(should (local-variable-p 'org-drill-cloze-regexp))
;; The buffer-local regex matches the new delimiters.
(should (string-match-p org-drill-cloze-regexp "test {{x}} more"))
(should-not (string-match-p org-drill-cloze-regexp "test [x] more"))))))
-(ert-deftest test-org-drill-add-cloze-fontification-sets-buffer-local-keywords ()
+(ert-deftest test-org-drill-mode-sets-buffer-local-keywords ()
(with-temp-buffer
(let ((org-startup-folded nil))
(org-mode)
- (org-drill-add-cloze-fontification)
+ (org-drill-mode 1)
(should (local-variable-p 'org-drill-cloze-keywords))
(should (listp org-drill-cloze-keywords)))))
diff --git a/tests/test-org-drill-small-branch-coverage.el b/tests/test-org-drill-small-branch-coverage.el
index db1a2e9..21b67c6 100644
--- a/tests/test-org-drill-small-branch-coverage.el
+++ b/tests/test-org-drill-small-branch-coverage.el
@@ -105,24 +105,24 @@ read and re-activates it on the way out."
(should hook-ran)
(should key-read)))
-;;;; org-drill-add-cloze-fontification
+;;;; org-drill-mode cloze-face flag branches
-(ert-deftest test-add-cloze-fontification-with-flag-extends-keywords ()
- "When `org-drill-use-visible-cloze-face-p' is t, the cloze keyword spec is
-added to `org-font-lock-extra-keywords'."
+(ert-deftest test-org-drill-mode-with-flag-installs-cloze-keywords ()
+ "When `org-drill-use-visible-cloze-face-p' is t, enabling `org-drill-mode'
+installs the cloze keyword spec buffer-locally."
(with-temp-buffer
- (let ((org-drill-use-visible-cloze-face-p t)
- (org-font-lock-extra-keywords nil))
- (org-drill-add-cloze-fontification)
- (should org-font-lock-extra-keywords))))
+ (org-mode)
+ (let ((org-drill-use-visible-cloze-face-p t))
+ (org-drill-mode 1)
+ (should org-drill--installed-cloze-keywords))))
-(ert-deftest test-add-cloze-fontification-without-flag-leaves-keywords-untouched ()
- "When the flag is nil, no entry is added to `org-font-lock-extra-keywords'."
+(ert-deftest test-org-drill-mode-without-flag-installs-nothing ()
+ "When the flag is nil, enabling `org-drill-mode' installs no cloze keywords."
(with-temp-buffer
- (let ((org-drill-use-visible-cloze-face-p nil)
- (org-font-lock-extra-keywords nil))
- (org-drill-add-cloze-fontification)
- (should (null org-font-lock-extra-keywords)))))
+ (org-mode)
+ (let ((org-drill-use-visible-cloze-face-p nil))
+ (org-drill-mode 1)
+ (should (null org-drill--installed-cloze-keywords)))))
(provide 'test-org-drill-small-branch-coverage)
;;; test-org-drill-small-branch-coverage.el ends here