Skip to content

feat(cookie): implement virtual cookies #992

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/configuration.org
Original file line number Diff line number Diff line change
Expand Up @@ -3012,3 +3012,46 @@ require('orgmode').setup({
#+end_src

📝 NOTE: If you are using a plugin for =vim.ui.input=, make sure it supports autocompletion for better experience. [[https://github.com/folke/snacks.nvim][snacks.nvim]] input module supports autocompletion.

*** virt_cookies
:PROPERTIES:
:CUSTOM_ID: virt_cookies
:END:

You can toggle Virtual cookies on the fly by executing command =:Org cookie_mode= when in a org buffer.
This additionally sets the buffer variable =vim.b.org_cookie_mode= to =true= or =false=, depending on the current state.

Currently this only applies Virtual cookies to headlines.

Uses the following highlights:
- ~@org.cookie.delimiter~: The highlight to use for the delimiters (brackets: ~[~ & ~]~)
- ~@org.cookie.delimiter.left~: A more granular highlight for just the left ~[~ bracket
- ~@org.cookie.delimiter.right~: A more granular highlight for just the right ~]~ bracket
- ~@org.cookie.number~: The numbers used in the cookie (~50~)
- ~org.cookie.number.complete~: A more granular highlight for the left side of ~[1/5]~ (in this case the ~1~)
- ~org.cookie.number.total~: A more granular highlight for the right side of ~[1/5]~ (in this case the ~5~)
- ~@org.cookie.sign~: The sign used in the cookie (e.g. ~/~, ~%~, or ~???~)
- ~@org.cookie.sign.div~: More granular for the div sign, the ~/~ in ~[1/5]~
- ~@org.cookie.sign.percent~: More granular for the percent sign, the ~%~ in ~[100%]~

**** enabled
:PROPERTIES:
:CUSTOM_ID: virt_cookies_enabled
:END:

- Type: =boolean=
- Default: =false=
Possible values:
- =true= - Uses /Virtual/ cookies to show live progress for applicable headlines.
- =false= - Do not add any /Virtual/ cookies.

**** type
:PROPERTIES:
:CUSTOM_ID: virt_cookies_type
:END:

- Type: ='%' | '/'=
- Default: ='/'=

The type of virtual cookie to draw for headlines that do not contain a "real" cookie. If a headline contains a real cookie, then it will opt to use the cookie type from that real cookie.
Comment on lines +3016 to +3056
Copy link
Contributor

@celsobenedetti celsobenedetti Jun 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of things came to mind on this docs entry.

  1. We could use a #+begin_src lua code block to show an example for this config, same as done with the other config options. Something like this:
diff --git a/docs/configuration.org b/docs/configuration.org
index 899c3df..91f01f7 100644
--- a/docs/configuration.org
+++ b/docs/configuration.org
@@ -3018,9 +3018,22 @@ require('orgmode').setup({
 :CUSTOM_ID: virt_cookies
 :END:
 
+Virtual cookies display the progress of TODO headings with children by indicating the proportion of DONE children.
+
 You can toggle Virtual cookies on the fly by executing command =:Org cookie_mode= when in a org buffer.
 This additionally sets the buffer variable =vim.b.org_cookie_mode= to =true= or =false=, depending on the current state.
 
+#+begin_src lua
+require('orgmode').setup({
+  ui = {
+    virt_cookies = {
+      enabled = true,
+      type = '/',
+    },
+  }
+})
+#+end_src
+
 Currently this only applies Virtual cookies to headlines.
 
 Uses the following highlights:
  1. We are missing an entry in doc/orgmode.txt for this config option
  2. The individual config properties (type and enabled) should probably not be described in individual child headings (****), but instead in list items, similar to what is done here for the org_capture_templates config options:
    - =description= (=string=) --- description of the template that is
    displayed in the template selection menu
    - =template= (=string|string[]=) --- body of the template that will be
    used when creating capture
    - =target= (=string?=) --- name of the file to which the capture content
    will be added. If the target is not specified, the content will be
    added to the [[#org_default_notes_file][org_default_notes_file]] file
    - =headline= (=string?=) --- title of the headline after which the
    capture content will be added. If no headline is specified, the
    content will be appended to the end of the file
    - =datetree (boolean | { time_prompt?: boolean, reversed?: boolean, tree_type: 'day' | 'month' | 'week' | 'custom' })=


9 changes: 8 additions & 1 deletion ftplugin/org.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ if config.org_startup_indented then
require('orgmode.ui.virtual_indent'):new(bufnr):attach()
end

if config.ui.virt_cookies.enabled then
require('orgmode.ui.virtcookie').new(bufnr, config.ui.virt_cookies.type):attach()
end

vim.bo.modeline = false
vim.opt_local.fillchars:append('fold: ')
vim.opt_local.foldmethod = 'expr'
Expand Down Expand Up @@ -74,6 +78,9 @@ vim.b.undo_ftplugin = table.concat({

-- Manually attach Snacks.image module to ensure that images are shown.
-- Snacks usually handles this automatically, but if Orgmode plugin is loaded after Snacks, it will not pick it up.
if vim.tbl_get(_G, 'Snacks', 'image', 'config', 'enabled') and vim.tbl_get(_G, 'Snacks', 'image', 'config', 'doc', 'enabled') then
if
vim.tbl_get(_G, 'Snacks', 'image', 'config', 'enabled')
and vim.tbl_get(_G, 'Snacks', 'image', 'config', 'doc', 'enabled')
then
require('snacks.image.doc').attach(bufnr)
end
5 changes: 5 additions & 0 deletions lua/orgmode/colors/highlights.lua
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ function M.link_highlights()
['@org.table.delimiter'] = '@punctuation.special',
['@org.table.heading'] = '@markup.heading',
['@org.edit_src'] = 'Visual',

-- For cookie extmarks (applicable if enabled)
['@org.cookie.delimiter'] = 'Delimiter',
['@org.cookie.sign'] = 'Special',
['@org.cookie.number'] = 'Number',
Comment on lines +74 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These do look good, but the highlights make it seem like it is real text rendered in the buffer.
The UX may be more consistent if regular virtual text highlights are used instead.

diff --git a/lua/orgmode/colors/highlights.lua b/lua/orgmode/colors/highlights.lua
index 3601119..2885cdb 100644
--- a/lua/orgmode/colors/highlights.lua
+++ b/lua/orgmode/colors/highlights.lua
@@ -73,9 +73,9 @@ function M.link_highlights()
     ['@org.edit_src'] = 'Visual',
 
     -- For cookie extmarks (applicable if enabled)
-    ['@org.cookie.delimiter'] = 'Delimiter',
-    ['@org.cookie.sign'] = 'Special',
-    ['@org.cookie.number'] = 'Number',
+    ['@org.cookie.delimiter'] = 'VirtualTextInfo',
+    ['@org.cookie.sign'] = 'VirtualTextInfo',
+    ['@org.cookie.number'] = 'VirtualTextInfo',
   }
 
   for src, def in pairs(links) do

before:
Screenshot from 2025-06-04 00-34-15
after:
Screenshot from 2025-06-04 00-34-03

}

for src, def in pairs(links) do
Expand Down
1 change: 1 addition & 0 deletions lua/orgmode/config/_meta.lua
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@
---@field folds? { colored: boolean } Should folds be colored or use the default folding highlight. Default: { colored: true }
---@field menu? { handler: fun() | nil } Menu configuration
---@field input? { use_vim_ui: boolean } Input configuration
---@field virt_cookies? { enabled: boolean, type: OrgVirtCookieType } Virtual cookie progress configuration

---@class OrgMappingsConfig
---@field disable_all? boolean Disable all mappings. Default: false
Expand Down
4 changes: 4 additions & 0 deletions lua/orgmode/config/defaults.lua
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ local DefaultConfig = {
input = {
use_vim_ui = false,
},
virt_cookies = {
enabled = false,
type = '/',
},
},
}

Expand Down
79 changes: 58 additions & 21 deletions lua/orgmode/files/headline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -903,22 +903,16 @@ function Headline:_set_cookie(cookie, num, denum)
return self:_set_node_text(cookie, new_cookie_val)
end

function Headline:update_cookie()
-- Update cookie state from a check box state change

-- Return early if the headline doesn't have a cookie
local cookie = self:get_cookie()
if not cookie then
return self
end

---@return { [1]: integer, [2]: integer }? checked_unchecked The first integer is the number of checked boxes, the second integer is the total boxes
function Headline:_get_checkbox_progress()
-- Count done checkboxes and total checkboxes for the headline
local section = self:node():parent()
local num_checked_boxes, num_boxes = 0, 0
if not section then
return self
return
end

-- Count checked boxes from all lists
local num_checked_boxes, num_boxes = 0, 0
local body = section:field('body')[1]
if body then
for node in body:iter_children() do
Expand All @@ -933,6 +927,53 @@ function Headline:update_cookie()
end
end

if num_boxes == 0 then
return
end

return { num_checked_boxes, num_boxes }
end

---@return { [1]: integer, [2]: integer }? done_total The first integer is the number of "DONE" todos, the second integer is the total todos
function Headline:_get_todo_progress()
-- Count done children headlines and total children with TODO keywords
local children = self:get_child_headlines()
local headlines_with_todo = vim.tbl_filter(function(h)
local todo, _, _ = h:get_todo()
return todo ~= nil
end, children)

local dones = vim.tbl_filter(function(h)
return h:is_done()
end, headlines_with_todo)

if #headlines_with_todo == 0 then
return
end

return { #dones, #headlines_with_todo }
end

function Headline:update_cookie()
-- Update cookie state from a check box state change

-- Return early if the headline doesn't have a cookie
local cookie = self:get_cookie()
if not cookie then
return self
end

local section = self:node():parent()
if not section then
return self
end

local progress = self:_get_checkbox_progress()
if not progress then
return self
end
local num_checked_boxes, num_boxes = unpack(progress)

-- Set the cookie
return self:_set_cookie(cookie, num_checked_boxes, num_boxes)
end
Expand All @@ -946,19 +987,15 @@ function Headline:update_todo_cookie()
return self
end

-- Count done children headlines and total children with TODO keywords
local children = self:get_child_headlines()
local headlines_with_todo = vim.tbl_filter(function(h)
local todo, _, _ = h:get_todo()
return todo ~= nil
end, children)
local progress = self:_get_todo_progress()
if not progress then
return self
end

local dones = vim.tbl_filter(function(h)
return h:is_done()
end, headlines_with_todo)
local dones, headlines_with_todo = unpack(progress)

-- Set the cookie
return self:_set_cookie(cookie, #dones, #headlines_with_todo)
return self:_set_cookie(cookie, dones, headlines_with_todo)
end

function Headline:update_parent_cookie()
Expand Down
12 changes: 12 additions & 0 deletions lua/orgmode/org/global.lua
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ local build = function(orgmode)
indent_mode = function()
require('orgmode.ui.virtual_indent').toggle_buffer_indent_mode()
end,
cookie_mode = function()
local virtcookie = require('orgmode.ui.virtcookie').get()
if virtcookie then
virtcookie:toggle()
end
end,
cookie_type = function()
local virtcookie = require('orgmode.ui.virtcookie').get()
if virtcookie then
virtcookie:toggle_cookie_type()
end
end,
}

_G.Org = OrgGlobal
Expand Down
Loading