diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-27 01:11:35 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-27 01:11:35 -0500 |
| commit | 1f362535a109939081a9a65a4601add87afc052d (patch) | |
| tree | c7f8b70acda4b97b165ecbc7a28fbb01c4b45c12 | |
| parent | 7eece407772d7c5cfba93ba914439094f0d9fbf2 (diff) | |
| download | org-drill-1f362535a109939081a9a65a4601add87afc052d.tar.gz org-drill-1f362535a109939081a9a65a4601add87afc052d.zip | |
feat: undo last rating, customizable keys, and configurable text limit
A batch of self-contained user-facing improvements, squashed from the feat/org-drill-solo-features branch.
I added an undo for the last rating (issue #2 follow-up). The rating prompt now takes an undo key (org-drill--undo-key, default u): it restores the previous card's scheduling snapshot, drops the recorded quality, and re-queues that card, then returns to the current prompt. Each rating snapshots the scheduling properties and SCHEDULED line onto a per-session stack capped at org-drill-undo-limit (default 3). org-drill-reschedule loops on the rating read so undo doesn't rate the current card.
I made the five session-control keys (quit, edit, help, skip, tags) defcustoms so they can be rebound from customize-group (issue #35), keeping their defaults. The 0-5 rating keys stay as-is, since they're tied to the quality scale rather than being variables.
I lifted the hardcoded 100-line entry-text limit in org-drill-get-entry-text into the org-drill-entry-text-max-lines defcustom, defaulting to 100.
I also deleted a commented-out old org-entry-empty-p that the real definition had already replaced.
Existing tests stay green and each change added its own, including snapshot/restore and prompt-loop tests for undo.
| -rw-r--r-- | org-drill.el | 191 | ||||
| -rw-r--r-- | tests/test-org-drill-entry-text-limit.el | 34 | ||||
| -rw-r--r-- | tests/test-org-drill-session-keys.el | 33 | ||||
| -rw-r--r-- | tests/test-org-drill-undo.el | 114 |
4 files changed, 323 insertions, 49 deletions
diff --git a/org-drill.el b/org-drill.el index 74cbc6c..2bea27e 100644 --- a/org-drill.el +++ b/org-drill.el @@ -307,18 +307,42 @@ This is buffer-local variable.") This is a buffer-local variable.") -;; Variables defining what keys can be pressed during drill sessions to quit the -;; session, edit the item, etc. -(defvar org-drill--quit-key ?q - "Character to quit the session.") -(defvar org-drill--edit-key ?e - "Character to suspend the session.") -(defvar org-drill--help-key ?? - "Character to show help.") -(defvar org-drill--skip-key ?s - "Character to skip to the next item.") -(defvar org-drill--tags-key ?t - "Character to edit the tags.") +;; Keys pressed during a drill session to quit, edit the item, etc. +;; These are defcustoms so they can be rebound from customize-group. +(defcustom org-drill--quit-key ?q + "Character to quit the session." + :group 'org-drill-session + :type 'character) +(defcustom org-drill--edit-key ?e + "Character to suspend the session." + :group 'org-drill-session + :type 'character) +(defcustom org-drill--help-key ?? + "Character to show help." + :group 'org-drill-session + :type 'character) +(defcustom org-drill--skip-key ?s + "Character to skip to the next item." + :group 'org-drill-session + :type 'character) +(defcustom org-drill--tags-key ?t + "Character to edit the tags." + :group 'org-drill-session + :type 'character) +(defcustom org-drill--undo-key ?u + "Character to undo the most recent rating during a session. +Pressing it at the rating prompt restores the previous card's +scheduling data and re-queues that card (see `org-drill-undo-last-rating')." + :group 'org-drill-session + :type 'character) + +(defcustom org-drill-undo-limit + 3 + "How many recent ratings can be undone with `org-drill--undo-key'. +Each rating snapshots the card's scheduling state; only this many of the +most recent snapshots are kept." + :group 'org-drill-session + :type 'integer) (defcustom org-drill-card-type-alist '((nil org-drill-present-simple-card) @@ -667,6 +691,10 @@ interval was greater than ORG-DRILL-DAYS-BEFORE-OLD days.") (failed-entries :initform nil) (again-entries :initform nil) (done-entries :initform nil) + (undo-stack + :initform nil + :documentation "Stack of pre-rating scheduling snapshots, most recent +first, used by `org-drill-undo-last-rating'. Capped at `org-drill-undo-limit'.") (current-item :initform nil :documentation "Set to the marker for the item currently being tested.") @@ -1563,14 +1591,16 @@ Shared by `org-drill-reschedule' and `org-drill-leitner-rebox'." (typed-answer-statement (if typed-answer (format "Your answer: %s\n" typed-answer) "")) - (key-prompt (format "(0-5, %c=help, %c=edit, %c=tags, %c=quit)" + (key-prompt (format "(0-5, %c=help, %c=edit, %c=tags, %c=undo, %c=quit)" org-drill--help-key org-drill--edit-key org-drill--tags-key + org-drill--undo-key org-drill--quit-key))) (save-excursion (while (not (memq ch (list org-drill--quit-key org-drill--edit-key + org-drill--undo-key 7 ; C-g ?0 ?1 ?2 ?3 ?4 ?5))) (run-hooks 'org-drill-display-answer-hook) @@ -1602,6 +1632,55 @@ Shared by `org-drill-reschedule' and `org-drill-leitner-rebox'." (org-set-tags-command)))) ch)) +(defun org-drill--snapshot-entry-data () + "Capture the scheduling state of the entry at point for undo. +Returns (MARKER . DATA), where MARKER points at the entry heading and +DATA is an alist mapping each scheduling property (and the special +`scheduled' key) to its current value, or nil when unset. Restore it +with `org-drill--restore-entry-data'." + (cons (save-excursion (org-back-to-heading t) (point-marker)) + (cons (cons 'scheduled (org-entry-get (point) "SCHEDULED")) + (mapcar (lambda (prop) + (cons prop (org-entry-get (point) prop))) + org-drill-scheduling-properties)))) + +(defun org-drill--restore-entry-data (snapshot) + "Restore the entry scheduling state captured in SNAPSHOT. +A property absent at snapshot time is deleted; the SCHEDULED line is put +back, or removed if there was none." + (org-with-point-at (car snapshot) + (dolist (cell (cdr snapshot)) + (cond + ((eq (car cell) 'scheduled) + (if (cdr cell) + (org-schedule nil (cdr cell)) + (org-schedule '(4)))) + ((cdr cell) + (org-set-property (car cell) (cdr cell))) + (t + (org-delete-property (car cell))))))) + +(defun org-drill--push-undo-snapshot (session) + "Snapshot the entry at point onto SESSION's undo stack, capped at +`org-drill-undo-limit'." + (push (org-drill--snapshot-entry-data) (oref session undo-stack)) + (when (> (length (oref session undo-stack)) org-drill-undo-limit) + (setf (oref session undo-stack) + (cl-subseq (oref session undo-stack) 0 org-drill-undo-limit)))) + +(defun org-drill-undo-last-rating (session) + "Undo the most recent rating in SESSION. +Restore the card's pre-rating scheduling data, drop the recorded quality, +and re-queue the card so it is presented again. Does nothing when there +is nothing to undo." + (let ((snapshot (pop (oref session undo-stack)))) + (if (null snapshot) + (message "Nothing to undo") + (org-drill--restore-entry-data snapshot) + (pop (oref session qualities)) + (push (car snapshot) (oref session again-entries)) + (message "Undid the last rating; that card will come around again")))) + (defun org-drill-reschedule (session) "Return qualityrating (0-5), or nil if the user quit." (let* ((next-review-dates (org-drill-hypothetical-next-review-dates)) @@ -1616,39 +1695,47 @@ Shared by `org-drill-reschedule' and `org-drill-leitner-rebox'." 5 - You remembered the item really easily. (+%s days)" (round (nth 3 next-review-dates)) (round (nth 4 next-review-dates)) - (round (nth 5 next-review-dates)))) - (ch (org-drill--read-rating-key (oref session typed-answer) - rating-help))) - (cond - ((and (>= ch ?0) (<= ch ?5)) - (let ((quality (- ch ?0)) - (failures (org-drill-entry-failure-count))) - (unless (oref session cram-mode) - (save-excursion - (let ((quality (if (org-drill--entry-lapsed-p session) 2 quality))) - (org-drill-smart-reschedule quality - (nth quality next-review-dates)))) - (push quality (oref session qualities)) + (round (nth 5 next-review-dates))))) + (cl-block org-drill-reschedule + ;; Loop so the undo key can take back the previous rating and then + ;; return us to the prompt for the current card. + (while t + (let ((ch (org-drill--read-rating-key (oref session typed-answer) + rating-help))) (cond - ((org-drill--quality-failed-p quality) - (when org-drill-leech-failure-threshold - (if (> (1+ failures) org-drill-leech-failure-threshold) - (org-toggle-tag "leech" 'on)))) + ((eql ch org-drill--undo-key) + (org-drill-undo-last-rating session)) + ((and (>= ch ?0) (<= ch ?5)) + (let ((quality (- ch ?0)) + (failures (org-drill-entry-failure-count))) + (unless (oref session cram-mode) + ;; Snapshot the pre-rating state so this rating can be undone. + (org-drill--push-undo-snapshot session) + (save-excursion + (let ((quality (if (org-drill--entry-lapsed-p session) 2 quality))) + (org-drill-smart-reschedule quality + (nth quality next-review-dates)))) + (push quality (oref session qualities)) + (cond + ((org-drill--quality-failed-p quality) + (when org-drill-leech-failure-threshold + (if (> (1+ failures) org-drill-leech-failure-threshold) + (org-toggle-tag "leech" 'on)))) + (t + (let ((scheduled-time (org-get-scheduled-time (point)))) + (when scheduled-time + (message "Next review in %d days" + (- (time-to-days scheduled-time) + (time-to-days (current-time)))) + (sit-for 0.5))))) + (org-set-property "DRILL_LAST_QUALITY" (format "%d" quality)) + (org-set-property "DRILL_LAST_REVIEWED" + (org-drill-time-to-inactive-org-timestamp (current-time)))) + (cl-return-from org-drill-reschedule quality))) + ((eql ch org-drill--edit-key) + (cl-return-from org-drill-reschedule 'edit)) (t - (let ((scheduled-time (org-get-scheduled-time (point)))) - (when scheduled-time - (message "Next review in %d days" - (- (time-to-days scheduled-time) - (time-to-days (current-time)))) - (sit-for 0.5))))) - (org-set-property "DRILL_LAST_QUALITY" (format "%d" quality)) - (org-set-property "DRILL_LAST_REVIEWED" - (org-drill-time-to-inactive-org-timestamp (current-time)))) - quality)) - ((= ch org-drill--edit-key) - 'edit) - (t - nil)))) + (cl-return-from org-drill-reschedule nil)))))))) (defun org-drill-hide-subheadings-if (test) "TEST is a function taking no arguments. TEST will be called for each @@ -2216,17 +2303,22 @@ Note: does not actually alter the item." (when (eql 'org-drill-cloze-overlay-defaults (overlay-get ovl 'category)) (delete-overlay ovl))))) +(defcustom org-drill-entry-text-max-lines + 100 + "Maximum number of lines of an entry's body text org-drill collects. +Used by `org-drill-get-entry-text', for example when echoing the next +Leitner item. Raise it for decks with very long card bodies." + :group 'org-drill-session + :type 'integer) + (defun org-drill-get-entry-text (&optional keep-properties-p) "Return the text of the current entry." - (let ((text (org-agenda-get-some-entry-text (point-marker) 100))) + (let ((text (org-agenda-get-some-entry-text + (point-marker) org-drill-entry-text-max-lines))) (if keep-properties-p text (substring-no-properties text)))) -;; (defun org-entry-empty-p () -;; (zerop (length (org-drill-get-entry-text)))) - -;; This version is about 5x faster than the old version, above. (defun org-drill-entry-empty-p () "Return non-nil if the current entry is empty. @@ -3162,6 +3254,7 @@ CRAM, if non-nil, starts the session in cram mode." (oref session old-mature-entries) nil (oref session failed-entries) nil (oref session again-entries) nil + (oref session undo-stack) nil (oref session start-time) (float-time (current-time)))) (defun org-drill--collect-entries (session scope drill-match) diff --git a/tests/test-org-drill-entry-text-limit.el b/tests/test-org-drill-entry-text-limit.el new file mode 100644 index 0000000..c9466cc --- /dev/null +++ b/tests/test-org-drill-entry-text-limit.el @@ -0,0 +1,34 @@ +;;; test-org-drill-entry-text-limit.el --- Tests for the entry-text line limit -*- lexical-binding: t; -*- + +;;; Commentary: +;; org-drill-get-entry-text asks org-agenda for at most N lines of an entry's +;; text. That limit used to be a hardcoded 100; it is now the defcustom +;; org-drill-entry-text-max-lines so a user with very long cards can raise it. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'org-drill) + +(ert-deftest test-org-drill-entry-text-max-lines-defaults-to-100 () + "The limit keeps its historical default of 100 lines." + (should (eq 100 (default-value 'org-drill-entry-text-max-lines)))) + +(ert-deftest test-org-drill-get-entry-text-uses-the-custom-limit () + "get-entry-text passes the defcustom value as the line limit, not a +hardcoded number." + (let ((org-drill-entry-text-max-lines 7) + (captured nil)) + (cl-letf (((symbol-function 'org-agenda-get-some-entry-text) + (lambda (_marker n &rest _) (setq captured n) "text"))) + (with-temp-buffer + (insert "* Card :drill:\nbody\n") + (org-mode) + (goto-char (point-min)) + (org-drill-get-entry-text)) + (should (eq 7 captured))))) + +(provide 'test-org-drill-entry-text-limit) + +;;; test-org-drill-entry-text-limit.el ends here diff --git a/tests/test-org-drill-session-keys.el b/tests/test-org-drill-session-keys.el new file mode 100644 index 0000000..ff1b4b1 --- /dev/null +++ b/tests/test-org-drill-session-keys.el @@ -0,0 +1,33 @@ +;;; test-org-drill-session-keys.el --- Tests for customizable session keys -*- lexical-binding: t; -*- + +;;; Commentary: +;; The session-control keys (quit/edit/help/skip/tags) are defcustoms so they +;; can be rebound through customize-group (upstream issue #35), while keeping +;; their historical default characters. + +;;; Code: + +(require 'ert) +(require 'org-drill) + +(defconst test-org-drill-session-key-defaults + '((org-drill--quit-key . ?q) + (org-drill--edit-key . ?e) + (org-drill--help-key . ??) + (org-drill--skip-key . ?s) + (org-drill--tags-key . ?t)) + "Each session-control key variable and its historical default character.") + +(ert-deftest test-org-drill-session-keys-are-customizable () + "Each session-control key is a defcustom, so customize-group can set it." + (dolist (cell test-org-drill-session-key-defaults) + (should (custom-variable-p (car cell))))) + +(ert-deftest test-org-drill-session-keys-keep-their-defaults () + "Promoting to defcustom does not change the default bindings." + (dolist (cell test-org-drill-session-key-defaults) + (should (eq (cdr cell) (default-value (car cell)))))) + +(provide 'test-org-drill-session-keys) + +;;; test-org-drill-session-keys.el ends here diff --git a/tests/test-org-drill-undo.el b/tests/test-org-drill-undo.el new file mode 100644 index 0000000..a5727e4 --- /dev/null +++ b/tests/test-org-drill-undo.el @@ -0,0 +1,114 @@ +;;; test-org-drill-undo.el --- Tests for undo/retry of the last rating -*- lexical-binding: t; -*- + +;;; Commentary: +;; Undo lets the user take back a misclicked rating. Before a rating is +;; applied, the entry's scheduling state is snapshotted onto the session's +;; undo-stack (capped at org-drill-undo-limit). org-drill-undo-last-rating +;; restores that state, drops the recorded quality, and re-queues the card so +;; it comes around again. + +;;; Code: + +(require 'ert) +(require 'org-drill) + +;;;; defcustoms + +(ert-deftest test-org-drill-undo-limit-defaults-to-3 () + (should (eq 3 (default-value 'org-drill-undo-limit)))) + +(ert-deftest test-org-drill-undo-key-is-customizable-and-defaults-to-u () + (should (custom-variable-p 'org-drill--undo-key)) + (should (eq ?u (default-value 'org-drill--undo-key)))) + +;;;; snapshot / restore round-trip + +(ert-deftest test-org-drill-snapshot-restore-round-trips-scheduling-data () + "Restoring a snapshot puts changed properties back to their captured values." + (with-temp-buffer + (insert "* Card :drill:\n") + (org-mode) + (goto-char (point-min)) + (org-set-property "DRILL_LAST_QUALITY" "4") + (org-set-property "DRILL_EASE" "2.5") + (let ((snap (org-drill--snapshot-entry-data))) + (org-set-property "DRILL_LAST_QUALITY" "1") + (org-set-property "DRILL_EASE" "1.8") + (org-drill--restore-entry-data snap) + (should (equal "4" (org-entry-get (point) "DRILL_LAST_QUALITY"))) + (should (equal "2.5" (org-entry-get (point) "DRILL_EASE")))))) + +(ert-deftest test-org-drill-restore-deletes-properties-absent-at-snapshot () + "A property that did not exist when snapshotted is removed on restore." + (with-temp-buffer + (insert "* Card :drill:\n") + (org-mode) + (goto-char (point-min)) + (let ((snap (org-drill--snapshot-entry-data))) + (org-set-property "DRILL_LAST_QUALITY" "3") + (org-drill--restore-entry-data snap) + (should (null (org-entry-get (point) "DRILL_LAST_QUALITY")))))) + +;;;; org-drill-undo-last-rating + +(ert-deftest test-org-drill-undo-last-rating-restores-pops-and-requeues () + "Undo restores the entry data, drops the recorded quality, and re-queues +the card onto the again list." + (with-temp-buffer + (insert "* Card :drill:\n") + (org-mode) + (goto-char (point-min)) + (org-set-property "DRILL_LAST_QUALITY" "4") + (let ((session (org-drill-session))) + (push (org-drill--snapshot-entry-data) (oref session undo-stack)) + (org-set-property "DRILL_LAST_QUALITY" "1") + (push 1 (oref session qualities)) + (org-drill-undo-last-rating session) + (should (equal "4" (org-entry-get (point) "DRILL_LAST_QUALITY"))) + (should (null (oref session qualities))) + (should (= 1 (length (oref session again-entries))))))) + +(ert-deftest test-org-drill-undo-last-rating-empty-stack-is-noop () + "Undo with nothing recorded does not error and changes no session state." + (let ((session (org-drill-session))) + (org-drill-undo-last-rating session) + (should (null (oref session qualities))) + (should (null (oref session again-entries))))) + +;;;; undo-key routing through the rating prompt + +(ert-deftest test-org-drill-reschedule-undo-key-takes-back-previous-rating () + "Pressing the undo key at the prompt restores the previous card and +re-queues it, then the prompt loop continues (here, a quit ends it). + +Components integrated: +- org-drill-reschedule (entry point, real) +- org-drill--read-rating-key (MOCKED — returns undo key then quit key) +- org-drill-undo-last-rating + snapshot/restore (real) + +Validates the undo key is handled in the prompt loop rather than treated +as a rating, and that the loop keeps prompting afterward." + (with-temp-buffer + (insert "* Prev :drill:\n* Current :drill:\n") + (org-mode) + (let ((session (org-drill-session))) + (goto-char (point-min)) + (org-set-property "DRILL_LAST_QUALITY" "5") + (push (org-drill--snapshot-entry-data) (oref session undo-stack)) + (org-set-property "DRILL_LAST_QUALITY" "0") ; the misrating to take back + (push 0 (oref session qualities)) + (goto-char (point-min)) + (re-search-forward "Current") + (let ((keys (list org-drill--undo-key org-drill--quit-key))) + (cl-letf (((symbol-function 'org-drill--read-rating-key) + (lambda (&rest _) (pop keys))) + ((symbol-function 'sit-for) #'ignore)) + (should (null (org-drill-reschedule session))))) + (goto-char (point-min)) + (should (equal "5" (org-entry-get (point) "DRILL_LAST_QUALITY"))) + (should (null (oref session qualities))) + (should (= 1 (length (oref session again-entries))))))) + +(provide 'test-org-drill-undo) + +;;; test-org-drill-undo.el ends here |
