feat(buffer): add configurable category-level folds

Problem: category folds were hardcoded with no config option, no custom
foldtext, and no vimdoc coverage.

Solution: add `folding` config field (boolean or table with `foldtext`
format string). Default foldtext is `%c (%n tasks)` with automatic
singular/plural. Gate all fold logic on the config so `folding = false`
disables folds entirely. Document the new option in vimdoc.
This commit is contained in:
Barrett Ruth 2026-03-06 20:04:22 -05:00
parent 12b9295c34
commit 03d3ac5851
3 changed files with 84 additions and 6 deletions

View file

@ -38,7 +38,7 @@ Features: ~
- Multi-level undo (up to 20 `:w` saves, persisted across sessions) - Multi-level undo (up to 20 `:w` saves, persisted across sessions)
- Quick-add from the command line with `:Pending add` - Quick-add from the command line with `:Pending add`
- Quickfix list of overdue/due-today tasks via `:Pending due` - Quickfix list of overdue/due-today tasks via `:Pending due`
- Foldable category sections (`zc`/`zo`) in category view - Configurable category folds (`zc`/`zo`) with custom foldtext
- Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (`<C-x><C-o>`) - Omnifunc completion for `cat:`, `due:`, and `rec:` tokens (`<C-x><C-o>`)
- Google Calendar one-way push via OAuth PKCE - Google Calendar one-way push via OAuth PKCE
- Google Tasks bidirectional sync via OAuth PKCE - Google Tasks bidirectional sync via OAuth PKCE
@ -274,8 +274,8 @@ Default buffer-local keys: ~
`U` Undo the last `:w` save (`undo`) `U` Undo the last `:w` save (`undo`)
`o` Insert a new task line below (`open_line`) `o` Insert a new task line below (`open_line`)
`O` Insert a new task line above (`open_line_above`) `O` Insert a new task line above (`open_line_above`)
`zc` Fold the current category section (category view only) `zc` Fold the current category section (requires `folding`)
`zo` Unfold the current category section (category view only) `zo` Unfold the current category section (requires `folding`)
Text objects (operator-pending and visual): ~ Text objects (operator-pending and visual): ~
@ -595,6 +595,7 @@ loads: >lua
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec', recur_syntax = 'rec',
someday_date = '9999-12-30', someday_date = '9999-12-30',
folding = true,
category_order = {}, category_order = {},
keymaps = { keymaps = {
close = 'q', close = 'q',
@ -684,6 +685,35 @@ Fields: ~
given order. Categories not in the list are appended given order. Categories not in the list are appended
after the ordered ones in their natural order. after the ordered ones in their natural order.
{folding} (boolean|table, default: true) *pending.FoldingConfig*
Controls category-level folds in category view. When
`true`, folds are enabled with the default foldtext
`'%c (%n tasks)'`. When `false`, folds are disabled
entirely. When a table, folds are enabled and the
table may contain:
{foldtext} (string|false, default: '%c (%n tasks)')
Custom foldtext format string. Set to
`false` to use Vim's built-in
foldtext. Two specifiers are
available:
`%c` category name
`%n` number of tasks in the fold
The category icon is prepended
automatically. When `false`, the
default Vim foldtext is used.
Folds only apply to category view; priority view
is always fold-free regardless of this setting.
Examples: >lua
vim.g.pending = { folding = true }
vim.g.pending = { folding = false }
vim.g.pending = {
folding = { foldtext = '%c (%n tasks)' },
}
<
{keymaps} (table, default: see below) *pending.Keymaps* {keymaps} (table, default: see below) *pending.Keymaps*
Buffer-local key bindings. Each field maps an action Buffer-local key bindings. Each field maps an action
name to a key string. Set a field to `false` to name to a key string. Set a field to `false` to

View file

@ -236,8 +236,30 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
end end
---@return string
function M.get_foldtext()
local folding = config.resolve_folding()
if not folding.foldtext then
return vim.fn.foldtext()
end
local line = vim.fn.getline(vim.v.foldstart)
local cat = line:match('^#%s+(.+)$') or line
local task_count = vim.v.foldend - vim.v.foldstart
local icons = config.get().icons
local result = folding.foldtext
:gsub('%%c', cat)
:gsub('%%n', tostring(task_count))
:gsub('(%d+) (%w+)s%)', function(n, word)
if n == '1' then
return n .. ' ' .. word .. ')'
end
return n .. ' ' .. word .. 's)'
end)
return icons.category .. ' ' .. result
end
local function snapshot_folds(bufnr) local function snapshot_folds(bufnr)
if current_view ~= 'category' then if current_view ~= 'category' or not config.resolve_folding().enabled then
return return
end end
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
@ -256,7 +278,7 @@ local function snapshot_folds(bufnr)
end end
local function restore_folds(bufnr) local function restore_folds(bufnr)
if current_view ~= 'category' then if current_view ~= 'category' or not config.resolve_folding().enabled then
return return
end end
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
@ -328,12 +350,18 @@ function M.render(bufnr)
setup_syntax(bufnr) setup_syntax(bufnr)
apply_extmarks(bufnr, line_meta) apply_extmarks(bufnr, line_meta)
local folding = config.resolve_folding()
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
if current_view == 'category' then if current_view == 'category' and folding.enabled then
vim.wo[winid].foldmethod = 'expr' vim.wo[winid].foldmethod = 'expr'
vim.wo[winid].foldexpr = 'v:lua.require("pending.buffer").get_fold()' vim.wo[winid].foldexpr = 'v:lua.require("pending.buffer").get_fold()'
vim.wo[winid].foldlevel = 99 vim.wo[winid].foldlevel = 99
vim.wo[winid].foldenable = true vim.wo[winid].foldenable = true
if folding.foldtext then
vim.wo[winid].foldtext = 'v:lua.require("pending.buffer").get_foldtext()'
else
vim.wo[winid].foldtext = 'foldtext()'
end
else else
vim.wo[winid].foldmethod = 'manual' vim.wo[winid].foldmethod = 'manual'
vim.wo[winid].foldenable = false vim.wo[winid].foldenable = false

View file

@ -1,3 +1,10 @@
---@class pending.FoldingConfig
---@field foldtext? string|false
---@class pending.ResolvedFolding
---@field enabled boolean
---@field foldtext string|false
---@class pending.Icons ---@class pending.Icons
---@field pending string ---@field pending string
---@field done string ---@field done string
@ -55,6 +62,7 @@
---@field drawer_height? integer ---@field drawer_height? integer
---@field debug? boolean ---@field debug? boolean
---@field keymaps pending.Keymaps ---@field keymaps pending.Keymaps
---@field folding? boolean|pending.FoldingConfig
---@field sync? pending.SyncConfig ---@field sync? pending.SyncConfig
---@field icons pending.Icons ---@field icons pending.Icons
@ -70,6 +78,7 @@ local defaults = {
date_syntax = 'due', date_syntax = 'due',
recur_syntax = 'rec', recur_syntax = 'rec',
someday_date = '9999-12-30', someday_date = '9999-12-30',
folding = true,
category_order = {}, category_order = {},
keymaps = { keymaps = {
close = 'q', close = 'q',
@ -119,4 +128,15 @@ function M.reset()
_resolved = nil _resolved = nil
end end
---@return pending.ResolvedFolding
function M.resolve_folding()
local raw = M.get().folding
if raw == false then
return { enabled = false, foldtext = false }
elseif raw == true or raw == nil then
return { enabled = true, foldtext = '%c (%n tasks)' }
end
return { enabled = true, foldtext = raw.foldtext or false }
end
return M return M