Skip to content

feat: allow to define multiple todo keyword sequences #974

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

Conversation

seflue
Copy link
Contributor

@seflue seflue commented May 4, 2025

They can be defined in the config or within an org file.

Summary

This PR adds the ability to define multiple todo keyword sets as described in Orgmode manual.

Related Issues

Relates to #250, #157, PR #956

Closes #250

Changes

  • additionally to a single keyword sequence org_todo_keywords allows to define a table of keyword sets
  • an org file can have multiple #+TODO: directives
  • if multiple keyword sets are defined, either in the config or in the current org file, org_todo keymap triggers fast access mode to select a keyword
  • org_todo_prev behaves like S-RIGHT in Emacs Orgmode

Falling back to fast access mode when multiple sets are defined is a bit of a shortcut to get a first version of this feature out of the door. Emacs Orgmode defines some additional keybindings to switch between keyword sets. This is a bit more elaborated and could be implemented in a further PR.

Checklist

I confirm that I have:

  • Followed the
    Conventional Commits
    specification
    (e.g., feat: add new feature, fix: correct bug,
    docs: update documentation).
  • My PR title also follows the conventional commits specification.
  • Updated relevant documentation, if necessary.
  • Thoroughly tested my changes.
  • Added tests (if applicable) and verified existing tests pass with
    make test.
  • Checked for breaking changes and documented them, if any.

@seflue seflue force-pushed the feature/support-multiple-todo-sequences branch from fc18036 to 2d74d78 Compare May 4, 2025 21:49
@seflue
Copy link
Contributor Author

seflue commented May 4, 2025

@kristijanhusak It seems, that the indentation test is a bit flaky (those are the both failing test). It also failed occasionally locally on my machine, but that is unrelated to my changes.

Copy link
Member

@kristijanhusak kristijanhusak left a comment

Choose a reason for hiding this comment

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

I left few comments around the code, some around code styling changes (mostly around avoiding else when there's early return), and some comments around the logic.

I think we should approach this in a slightly different , and potentially simpler way:

  1. Always store the todo keywords as sequences. You already did that here more-less, but there's no need have a separate logic like parse_single_sequence or parse_multiple_sequence. When TodoKeywords get's org_todo_keywords value, just check if it's a string[] or string[][]. If it's former, just convert it to string[][] and handle it accordingly.
  2. Instead of keeping the sequence index on the TodoKeyword, and sequences on the TodoKeywords, we can just search things on the fly. These things are used only when mutating the document, so it's not that necessary to do these optimizations. Lua is fast enough to handle this, and I doubt users have a lot of todo sequences.

Another approach could be to keep the TodoKeywords as they are, which is basically as single sequence, and have a layer above that will know to figure out which of the sequence we need at the given point, and just return it and use it where necessary.
This might complicate some other things so it might not be the best idea, but I wanted to point it out.

Let me know what you think.

Comment on lines 902 to 919
else
for _, directive in ipairs(directives) do
local name = directive:field('name')[1]
local value = directive:field('value')[1]

if name and value then
local name_text = self:get_node_text(name)
if name_text:lower() == directive_name:lower() then
return self:get_node_text(value)
end
end
end
end
Copy link
Member

Choose a reason for hiding this comment

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

We can end the above if statement here since it has a return

Suggested change
else
for _, directive in ipairs(directives) do
local name = directive:field('name')[1]
local value = directive:field('value')[1]
if name and value then
local name_text = self:get_node_text(name)
if name_text:lower() == directive_name:lower() then
return self:get_node_text(value)
end
end
end
end
end
for _, directive in ipairs(directives) do
local name = directive:field('name')[1]
local value = directive:field('value')[1]
if name and value then
local name_text = self:get_node_text(name)
if name_text:lower() == directive_name:lower() then
return self:get_node_text(value)
end
end
end

Comment on lines 106 to 107
self:_parse_single_sequence(self.org_todo_keywords)
return
Copy link
Member

Choose a reason for hiding this comment

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

We can just return directly here, method returns void anyway. Same for the if below.

Suggested change
self:_parse_single_sequence(self.org_todo_keywords)
return
return self:_parse_single_sequence(self.org_todo_keywords)

Comment on lines 42 to 45
if #self.todos.sequences > 1 or self.todos:has_fast_access() then
return true
end
return false
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
if #self.todos.sequences > 1 or self.todos:has_fast_access() then
return true
end
return false
return #self.todos.sequences > 1 or self.todos:has_fast_access()

return keyword
-- When we're starting from an empty state and moving backward,
-- go to the last todo keyword of the last sequence
else
Copy link
Member

Choose a reason for hiding this comment

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

There's no need for else block when if block returns. It removes one level of indentation.

-- Find the keyword by string value
if type(current_state) == 'string' then
opts.current_state = opts.todos:find(current_state) or TodoKeyword:empty()
-- Direct assignment of a TodoKeyword
Copy link
Member

Choose a reason for hiding this comment

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

Where do we create a TodoState with the direct assignment? We just provide the todo keyword string in all usages.

---@param headline OrgHeadline
---@param old_state string
---@param new_state string
function OrgMappings:_handle_repeating_task(headline, old_state, new_state)
Copy link
Member

Choose a reason for hiding this comment

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

Why was this part of code changed?

used_shortcuts[todo_keyword.shortcut] = true
elseif not used_shortcuts[todo_keyword.shortcut] then
-- Mark it as a fast access key when we have multiple sequences
if type(self.org_todo_keywords[1]) == 'table' and #self.org_todo_keywords > 1 then
Copy link
Member

Choose a reason for hiding this comment

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

Having multiple sequences doesn't necessarily mean we use fast access. When there is no fast access defined in the config of the todo keywords, Emacs just cycles through the sequence it figured out that is being used.

There's a catch though. Emacs somehow keeps in memory which was the last sequence used. For example, if you have these keywords:

(setq org-todo-keywords
      '((sequence "TODO" "|" "DONE")
        (sequence "REPORT" "BUG" "TESTING" "|" "FIXED")))

And you have a headline with * REPORT foo, cycling without the fast access will go REPORT -> BUG -> TESTING -> FIXED -> {EMPTY} -> REPORT -> etc.. So it keeps in memory that the last sequence is 2nd one. Now, if you switch to an empty headline without todo keyword, restart emacs, and try switching the todo state, it will use the first sequence. So it's only in memory while the emacs is open.

I'm not sure what's the best way to access that issue, but I wanted to bring it up in case you have some ideas.

@seflue seflue force-pushed the feature/support-multiple-todo-sequences branch from 2d74d78 to 68597a8 Compare May 25, 2025 08:32
@seflue seflue force-pushed the feature/support-multiple-todo-sequences branch 3 times, most recently from 8148136 to ddf3801 Compare July 6, 2025 15:49
@seflue seflue force-pushed the feature/support-multiple-todo-sequences branch from ddf3801 to ea8b362 Compare July 26, 2025 12:18
@seflue seflue force-pushed the feature/support-multiple-todo-sequences branch 2 times, most recently from e1f8a89 to 0b75b39 Compare August 9, 2025 18:22
@seflue
Copy link
Contributor Author

seflue commented Aug 9, 2025

@kristijanhusak Thanks for the feedback! I've implemented most of your suggestions:

Simplified parsing and code style:

  • Always store todo keywords as sequences (string[][]) - normalized in constructor
  • Removed separate parsing methods, single unified _parse() path
  • Fixed unnecessary else blocks after early returns
  • Cleaned up TodoState constructor

Regarding the sequence index approach:
I'd prefer to keep the current approach here. The sequence_index isn't just for performance - it's needed for proper cycling behavior. According to the Emacs orgmode manual, C-c C-t should cycle only within the current sequence. So a TODO in:

["TODO", "|", "DONE"]

cycles TODO → DONE → empty → TODO, while a REPORT in:

["REPORT", "BUG", "|", "FIXED"]

stays within that sequence.

Without sequence_index, we can't determine which sequence a keyword belongs to, especially when keywords are duplicated across sequences (like having "TODO" in both bug and feature workflows).

The next step would be implementing proper cycling behavior instead of jumping to fast access mode in a separate PR, which is where this becomes important.

What do you think?

@seflue seflue force-pushed the feature/support-multiple-todo-sequences branch from 0b75b39 to 3b2192e Compare August 9, 2025 21:07
They can be defined in the config or within an org file.
@seflue seflue force-pushed the feature/support-multiple-todo-sequences branch from 3b2192e to 26af6c0 Compare August 9, 2025 21:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support multiple todo keyword sequence definitions
2 participants