On 2026-01-10 09:48, Ihor Radchenko wrote:
> This looks harmless, and reasonable, although technically breaking
> (and thus warrants ORG-NEWS entry).
> --8<--
> Maybe it warrants a small helper function rather than code
> duplication. Mostly because the exact behavior is a judgment call
> and may be missed during refactoring.
Thanks for the feedback, done in the attached v2, where I also updated
the column summary type X%.
> Let's wait for feedback.
Sounds good!
Best,
--
Jacob S. Gordon
[email protected]
Please don’t send me HTML emails or MS Office/Apple iWork documents.
https://useplaintext.email/#etiquette
https://www.fsf.org/campaigns/opendocument
From 4dd19722929ff5fc49ab839f6287554b0ae5bb12 Mon Sep 17 00:00:00 2001
From: "Jacob S. Gordon" <[email protected]>
Date: Sun, 11 Jan 2026 16:20:00 -0500
Subject: [PATCH v2] Ensure a non-zero percent cookie when anything is complete
With a large number of tasks (N > 100) the percent cookie can show 0%
when a non-zero amount have been completed, e.g. 1/101. In those
cases adjust the percent to show 1% instead.
* lisp/org.el (org-format-percent-cookie): Add function.
(org-update-parent-todo-statistics):
* lisp/org-list.el (org-update-checkbox-count):
* lisp/org-colview.el (org-columns--summary-checkbox-percent): Use
function.
* testing/lisp/test-org-list.el (test-org-list/update-checkbox-count):
* testing/lisp/test-org.el (test-org/update-todo-statistics-cookies):
Add tests.
* etc/ORG-NEWS (Important announcements and breaking changes):
Announce changes.
(New functions and changes in function arguments): Announce function.
---
etc/ORG-NEWS | 12 ++++++++
lisp/org-colview.el | 7 ++---
lisp/org-list.el | 3 +-
lisp/org.el | 12 ++++++--
testing/lisp/test-org-list.el | 20 +++++++++++-
testing/lisp/test-org.el | 57 +++++++++++++++++++++++++++++++++++
6 files changed, 101 insertions(+), 10 deletions(-)
diff --git a/etc/ORG-NEWS b/etc/ORG-NEWS
index 2e1f79f8c..9443548f1 100644
--- a/etc/ORG-NEWS
+++ b/etc/ORG-NEWS
@@ -167,6 +167,13 @@ The =show= parameter for the =org-priority= function was deprecated in
Org 9.2 (released in 2017). Sufficient time has passed and it is being
removed as part of refactoring for numeric priorities.
+*** The percent cookie is now non-zero when any sub-task is complete
+
+With a large number of tasks (N > 100) the percent cookie previously
+showed =0%= when a non-zero amount have been completed, e.g. 1/101.
+Those cases now show =1%= instead. The same is true for the checkbox
+status summary type =X%= when defining columns for column view.
+
** New features
# We list the most important features, and the features that may
@@ -622,6 +629,11 @@ For performance, if the invisibility spec has been constructed, it can
be passed in as ~invisibility-spec~ instead of having it be
constructed again.
+*** New function ~org-format-percent-cookie~
+
+Given the completed and total number of tasks, format the percent
+cookie =[N%]=.
+
** Removed or renamed functions and variables
*** ~org-edit-src-content-indentation~ is renamed to ~org-src-content-indentation~
diff --git a/lisp/org-colview.el b/lisp/org-colview.el
index c295cea1d..4c65f061f 100644
--- a/lisp/org-colview.el
+++ b/lisp/org-colview.el
@@ -1393,10 +1393,9 @@ (defun org-columns--summary-checkbox-count (check-boxes _)
(defun org-columns--summary-checkbox-percent (check-boxes _)
"Summarize CHECK-BOXES with a check-box percent."
- (format "[%d%%]"
- (round (* 100.0 (cl-count-if (lambda (b) (member b '("[X]" "[100%]")))
- check-boxes))
- (length check-boxes))))
+ (org-format-percent-cookie (cl-count-if (lambda (b) (member b '("[X]" "[100%]")))
+ check-boxes)
+ (length check-boxes)))
(defun org-columns--summary-min (values fmt)
"Compute the minimum of VALUES.
diff --git a/lisp/org-list.el b/lisp/org-list.el
index 9b3394402..70e7f5a1f 100644
--- a/lisp/org-list.el
+++ b/lisp/org-list.el
@@ -2656,8 +2656,7 @@ (defun org-update-checkbox-count (&optional all)
(goto-char beg)
(org-fold-core-ignore-modifications
(insert-and-inherit
- (if percent (format "[%d%%]" (floor (* 100.0 checked)
- (max 1 total)))
+ (if percent (org-format-percent-cookie checked total)
(format "[%d/%d]" checked total)))
(delete-region (point) (+ (point) (- end beg))))
(when org-auto-align-tags (org-fix-tags-on-the-fly))))))))
diff --git a/lisp/org.el b/lisp/org.el
index e3d0073c2..291e68b28 100644
--- a/lisp/org.el
+++ b/lisp/org.el
@@ -10143,9 +10143,8 @@ (defun org-update-parent-todo-statistics ()
(outline-next-heading)))
(setq new
(if is-percent
- (format "[%d%%]" (floor (* 100.0 cnt-done)
- (max 1 cnt-all)))
- (format "[%d/%d]" cnt-done cnt-all))
+ (org-format-percent-cookie cnt-done cnt-all)
+ (format "[%d/%d]" cnt-done cnt-all))
ndel (- (match-end 0) checkbox-beg))
(goto-char (match-end 0))
(unless (string-equal new (buffer-substring checkbox-beg (match-end 0)))
@@ -10158,6 +10157,13 @@ (defun org-update-parent-todo-statistics ()
cnt-done (- cnt-all cnt-done))))))
(run-hooks 'org-todo-statistics-hook)))
+(defun org-format-percent-cookie (completed total)
+ "Format the percent cookie `[N%]' for COMPLETED/TOTAL tasks.
+The percent is rounded down to the nearest integer, except for small
+percentages 0% < p < 1% which are rounded up to 1%."
+ (let ((p (floor (* 100.0 completed) (max 1 total))))
+ (format "[%d%%]" (if (and (= p 0) (> completed 0)) 1 p))))
+
(defvar org-after-todo-statistics-hook nil
"Hook that is called after a TODO statistics cookie has been updated.
Each function is called with two arguments: the number of not-done entries
diff --git a/testing/lisp/test-org-list.el b/testing/lisp/test-org-list.el
index a3a526ba6..5f069dee0 100644
--- a/testing/lisp/test-org-list.el
+++ b/testing/lisp/test-org-list.el
@@ -1206,7 +1206,25 @@ (ert-deftest test-org-list/update-checkbox-count ()
- [X] item2"
(let ((org-checkbox-hierarchical-statistics nil))
(org-update-checkbox-count))
- (buffer-string)))))
+ (buffer-string))))
+ (let ((checklist (concat "- [%]\n" ; 0/101 = 0%
+ (mapconcat #'identity
+ (make-list 101 " - [ ]") "\n"))))
+ (should (string-match "\\[0%\\]" (org-test-with-temp-text checklist
+ (org-update-checkbox-count)
+ (buffer-string)))))
+ (let ((checklist (concat "- [%]\n - [X]\n" ; 1/101 = 0.99% -> 1%
+ (mapconcat #'identity
+ (make-list 100 " - [ ]") "\n"))))
+ (should (string-match "\\[1%\\]" (org-test-with-temp-text checklist
+ (org-update-checkbox-count)
+ (buffer-string)))))
+ (let ((checklist (concat "- [%]\n - [ ]\n" ; 200/201 = 99.5% -> 99%
+ (mapconcat #'identity
+ (make-list 200 " - [X]") "\n"))))
+ (should (string-match "\\[99%\\]" (org-test-with-temp-text checklist
+ (org-update-checkbox-count)
+ (buffer-string))))))
;;; API
diff --git a/testing/lisp/test-org.el b/testing/lisp/test-org.el
index cd884c02f..4fceb71f6 100644
--- a/testing/lisp/test-org.el
+++ b/testing/lisp/test-org.el
@@ -4224,6 +4224,63 @@ (ert-deftest test-org/org-ctrl-c-ctrl-c ()
(org-ctrl-c-ctrl-c))
(should-not org-columns-overlays)))
+(ert-deftest test-org/update-todo-statistics-cookies ()
+ "Test updating TODO statistics cookies."
+ (let ((N 3)
+ (parent "* [/]"))
+ (dolist (n (number-sequence 0 N))
+ (let* ((match (format "\\[%d/%d\\]" n N))
+ (done (mapconcat #'(lambda (n) (format "** DONE D%d" n))
+ (number-sequence 1 n) "\n"))
+ (todo (mapconcat #'(lambda (n) (format "** TODO T%d" n))
+ (number-sequence 1 (- N n)) "\n"))
+ (tree (concat parent "\n" done "\n" todo)))
+ (should (string-match match
+ (org-test-with-temp-text tree
+ (org-update-statistics-cookies t)
+ (buffer-string)))))))
+ (let ((N 3)
+ (pvals '(0 33 66 100))
+ (parent "* [%]"))
+ (dolist (n (number-sequence 0 N))
+ (let* ((match (format "\\[%d%%\\]" (elt pvals n)))
+ (done (mapconcat #'(lambda (n) (format "** DONE D%d" n))
+ (number-sequence 1 n) "\n"))
+ (todo (mapconcat #'(lambda (n) (format "** TODO T%d" n))
+ (number-sequence 1 (- N n)) "\n"))
+ (tree (concat parent "\n" done "\n" todo)))
+ (should (string-match match
+ (org-test-with-temp-text tree
+ (org-update-statistics-cookies t)
+ (buffer-string)))))))
+ (let ((N 101)
+ (parent "* [%]"))
+ (let ((match "\\[0%\\]") ; 0/101 -> 0%
+ (tree (concat parent "\n"
+ (mapconcat #'(lambda (n) (format "** TODO T%d" n))
+ (number-sequence 1 N) "\n"))))
+ (should (string-match match
+ (org-test-with-temp-text tree
+ (org-update-statistics-cookies t)
+ (buffer-string)))))
+ (let ((match "\\[1%\\]") ; 1/101 -> 0.99% -> 1%
+ (tree (concat parent "\n** DONE D1\n"
+ (mapconcat #'(lambda (n) (format "** TODO T%d" n))
+ (number-sequence 1 (1- N)) "\n"))))
+ (should (string-match match
+ (org-test-with-temp-text tree
+ (org-update-statistics-cookies t)
+ (buffer-string))))))
+ (let ((N 201)
+ (parent "* [%]"))
+ (let ((match "\\[99%\\]") ; 200/201 -> 99.5% -> 99%
+ (tree (concat parent "\n** TODO T1\n"
+ (mapconcat #'(lambda (n) (format "** DONE D%d" n))
+ (number-sequence 1 N) "\n"))))
+ (should (string-match match
+ (org-test-with-temp-text tree
+ (org-update-statistics-cookies t)
+ (buffer-string)))))))
;;; Navigation
base-commit: ce83328b023e988aa8cbb8e8743db35abc24134e
--
Jacob S. Gordon
[email protected]
Please don’t send me HTML emails or MS Office/Apple iWork documents.
https://useplaintext.email/#etiquette
https://www.fsf.org/campaigns/opendocument