feat: complete task editing coverage

Problem: the task editing surface had gaps — category and recurrence had
no keymaps, `:Pending edit` required knowing the task ID, tasks couldn't
be reordered with a keymap, priority was binary (0/1), and `wip`/`blocked`
states were documented but unimplemented.

Solution: fill every cell so every property is editable in every way.

- `gc`/`gr` keymaps for category select and recurrence prompt
- cursor-aware `:Pending edit` (omit ID to use task under cursor)
- `J`/`K` keymaps to reorder tasks within a category
- multi-level priorities (`max_priority` config, `g!` cycles 0→1→2→3→0)
- `+!!`/`+!!!` tokens in `:Pending edit`, `:Pending add`, `parse.body()`
- `PendingPriority2`/`PendingPriority3` highlight groups
- `gw`/`gb` keymaps toggle `wip`/`blocked` status
- `>`/`=` state chars in buffer rendering and diff parsing
- `PendingWip`/`PendingBlocked` highlight groups
- sort order: wip → pending → blocked → done
- `wip`/`blocked` filter predicates and icons
This commit is contained in:
Barrett Ruth 2026-03-08 19:43:03 -04:00
parent 073541424e
commit 6c3869b3c4
9 changed files with 498 additions and 101 deletions

View file

@ -137,12 +137,27 @@ local function apply_inline_row(bufnr, row, m, icons)
end_col = #line,
hl_group = 'PendingDone',
})
elseif m.status == 'blocked' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
end_col = #line,
hl_group = 'PendingBlocked',
})
end
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local bracket_col = (line:find('%[') or 1) - 1
local icon, icon_hl
if m.status == 'done' then
icon, icon_hl = icons.done, 'PendingDone'
elseif m.status == 'wip' then
icon, icon_hl = icons.wip or '>', 'PendingWip'
elseif m.status == 'blocked' then
icon, icon_hl = icons.blocked or '=', 'PendingBlocked'
elseif m.priority and m.priority >= 3 then
icon, icon_hl = icons.priority, 'PendingPriority3'
elseif m.priority and m.priority == 2 then
icon, icon_hl = icons.priority, 'PendingPriority2'
elseif m.priority and m.priority > 0 then
icon, icon_hl = icons.priority, 'PendingPriority'
else
@ -464,6 +479,10 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingPriority2', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
end

View file

@ -9,6 +9,8 @@
---@field pending string
---@field done string
---@field priority string
---@field wip string
---@field blocked string
---@field due string
---@field recur string
---@field category string
@ -48,6 +50,12 @@
---@field prev_header? string|false
---@field next_task? string|false
---@field prev_task? string|false
---@field category? string|false
---@field recur? string|false
---@field move_down? string|false
---@field move_up? string|false
---@field wip? string|false
---@field blocked? string|false
---@class pending.CategoryViewConfig
---@field order? string[]
@ -73,6 +81,7 @@
---@field debug? boolean
---@field keymaps pending.Keymaps
---@field view pending.ViewConfig
---@field max_priority? integer
---@field sync? pending.SyncConfig
---@field icons pending.Icons
@ -87,6 +96,7 @@ local defaults = {
date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
max_priority = 3,
view = {
default = 'category',
eol_format = '%c %r %d',
@ -114,12 +124,20 @@ local defaults = {
prev_header = '[[',
next_task = ']t',
prev_task = '[t',
category = 'gc',
recur = 'gr',
move_down = 'J',
move_up = 'K',
wip = 'gw',
blocked = 'gb',
},
sync = {},
icons = {
pending = ' ',
done = 'x',
priority = '!',
wip = '>',
blocked = '=',
due = '.',
recur = '~',
category = '#',

View file

@ -43,7 +43,16 @@ function M.parse_buffer(lines)
local stripped = body:match('^- %[.?%] (.*)$') or body
local state_char = body:match('^- %[(.-)%]') or ' '
local priority = state_char == '!' and 1 or 0
local status = state_char == 'x' and 'done' or 'pending'
local status
if state_char == 'x' then
status = 'done'
elseif state_char == '>' then
status = 'wip'
elseif state_char == '=' then
status = 'blocked'
else
status = 'pending'
end
local description, metadata = parse.body(stripped)
if description and description ~= '' then
table.insert(result, {
@ -117,7 +126,10 @@ function M.apply(lines, s, hidden_ids)
task.category = entry.category
changed = true
end
if task.priority ~= entry.priority then
if entry.priority == 0 and task.priority > 0 then
task.priority = 0
changed = true
elseif entry.priority > 0 and task.priority == 0 then
task.priority = entry.priority
changed = true
end

View file

@ -47,7 +47,7 @@ function M._recompute_counts()
local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(get_store():active_tasks()) do
if task.status == 'pending' then
if task.status ~= 'done' and task.status ~= 'deleted' then
pending = pending + 1
if task.priority > 0 then
priority = priority + 1
@ -163,6 +163,16 @@ local function compute_hidden_ids(tasks, predicates)
visible = false
break
end
elseif pred == 'wip' then
if task.status ~= 'wip' then
visible = false
break
end
elseif pred == 'blocked' then
if task.status ~= 'blocked' then
visible = false
break
end
end
end
if not visible then
@ -335,6 +345,24 @@ function M._setup_buf_mappings(bufnr)
date = function()
M.prompt_date()
end,
category = function()
M.prompt_category()
end,
recur = function()
M.prompt_recur()
end,
move_down = function()
M.move_task('down')
end,
move_up = function()
M.move_task('up')
end,
wip = function()
M.toggle_status('wip')
end,
blocked = function()
M.toggle_status('blocked')
end,
undo = function()
M.undo_write()
end,
@ -605,7 +633,8 @@ function M.toggle_priority()
if not task then
return
end
local new_priority = task.priority > 0 and 0 or 1
local max = require('pending.config').get().max_priority or 3
local new_priority = (task.priority + 1) % (max + 1)
s:update(id, { priority = new_priority })
_save_and_notify()
buffer.render(bufnr)
@ -658,6 +687,222 @@ function M.prompt_date()
end)
end
---@param target_status 'wip'|'blocked'
---@return nil
function M.toggle_status(target_status)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local s = get_store()
local task = s:get(id)
if not task then
return
end
if task.status == target_status then
s:update(id, { status = 'pending' })
else
s:update(id, { status = target_status })
end
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break
end
end
end
---@param direction 'up'|'down'
---@return nil
function M.move_task(direction)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local target_row
if direction == 'down' then
target_row = row + 1
else
target_row = row - 1
end
if not meta[target_row] or meta[target_row].type ~= 'task' then
return
end
local current_view_name = buffer.current_view_name() or 'category'
if current_view_name == 'category' then
if meta[target_row].category ~= meta[row].category then
return
end
end
local target_id = meta[target_row].id
if not target_id then
return
end
local s = get_store()
local task_a = s:get(id)
local task_b = s:get(target_id)
if not task_a or not task_b then
return
end
if task_a.order == 0 or task_b.order == 0 then
local tasks
if current_view_name == 'category' then
tasks = {}
for _, t in ipairs(s:active_tasks()) do
if t.category == task_a.category then
table.insert(tasks, t)
end
end
else
tasks = s:active_tasks()
end
table.sort(tasks, function(a, b)
if a.order ~= b.order then
return a.order < b.order
end
return a.id < b.id
end)
for i, t in ipairs(tasks) do
s:update(t.id, { order = i })
end
task_a = s:get(id)
task_b = s:get(target_id)
if not task_a or not task_b then
return
end
end
local order_a, order_b = task_a.order, task_b.order
s:update(id, { order = order_b })
s:update(target_id, { order = order_a })
_save_and_notify()
buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break
end
end
end
---@return nil
function M.prompt_category()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local s = get_store()
local seen = {}
local categories = {}
for _, task in ipairs(s:active_tasks()) do
if task.category and not seen[task.category] then
seen[task.category] = true
table.insert(categories, task.category)
end
end
table.sort(categories)
vim.ui.select(categories, { prompt = 'Category: ' }, function(choice)
if not choice then
return
end
s:update(id, { category = choice })
_save_and_notify()
buffer.render(bufnr)
end)
end
---@return nil
function M.prompt_recur()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
vim.ui.input({ prompt = 'Recurrence (e.g. weekly, !daily): ' }, function(input)
if not input then
return
end
local s = get_store()
if input == '' then
s:update(id, { recur = vim.NIL, recur_mode = vim.NIL })
_save_and_notify()
buffer.render(bufnr)
log.info('Task #' .. id .. ': recurrence removed.')
return
end
local raw_spec = input
local rec_mode = nil
if raw_spec:sub(1, 1) == '!' then
rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
local recur = require('pending.recur')
if not recur.validate(raw_spec) then
log.error('Invalid recurrence pattern: ' .. input)
return
end
s:update(id, { recur = raw_spec, recur_mode = rec_mode })
_save_and_notify()
buffer.render(bufnr)
log.info('Task #' .. id .. ': recurrence set to ' .. raw_spec .. '.')
end)
end
---@param text string
---@return nil
function M.add(text)
@ -678,6 +923,7 @@ function M.add(text)
due = metadata.due,
recur = metadata.rec,
recur_mode = metadata.rec_mode,
priority = metadata.priority,
})
_save_and_notify()
local bufnr = buffer.bufnr()
@ -817,8 +1063,11 @@ local function parse_edit_token(token)
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
if token == '+!' then
return 'priority', 1, nil
local bangs = token:match('^%+(!+)$')
if bangs then
local max = cfg.max_priority or 3
local level = math.min(#bangs, max)
return 'priority', level, nil
end
if token == '-!' then
return 'priority', 0, nil
@ -881,21 +1130,33 @@ local function parse_edit_token(token)
.. rk
end
---@param id_str string
---@param rest string
---@param id_str? string
---@param rest? string
---@return nil
function M.edit(id_str, rest)
if not id_str or id_str == '' then
log.error(
'Usage: :Pending edit <id> [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]'
)
return
end
local id = tonumber(id_str)
local id = id_str and tonumber(id_str)
if not id then
log.error('Invalid task ID: ' .. id_str)
return
local bufnr = buffer.bufnr()
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if meta[row] and meta[row].type == 'task' and meta[row].id then
id = meta[row].id
if id_str and id_str ~= '' then
rest = rest and (id_str .. ' ' .. rest) or id_str
end
end
end
if not id then
if id_str and id_str ~= '' then
log.error('Invalid task ID: ' .. id_str)
else
log.error(
'Usage: :Pending edit [<id>] [due:<date>] [cat:<name>] [rec:<pattern>] [+!] [-!] [-due] [-cat] [-rec]'
)
end
return
end
end
local s = get_store()
@ -955,7 +1216,11 @@ function M.edit(id_str, rest)
end
elseif field == 'priority' then
updates.priority = value
table.insert(feedback, value == 1 and 'priority added' or 'priority removed')
if value == 0 then
table.insert(feedback, 'priority removed')
else
table.insert(feedback, 'priority set to ' .. value)
end
end
end

View file

@ -563,24 +563,34 @@ function M.body(text)
metadata.cat = cat_val
i = i - 1
else
local rec_val = token:match(rec_pattern)
if rec_val then
if metadata.rec then
local pri_bangs = token:match('^%+(!+)$')
if pri_bangs then
if metadata.priority then
break
end
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
metadata.rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
break
end
metadata.rec = raw_spec
local max = config.get().max_priority or 3
metadata.priority = math.min(#pri_bangs, max)
i = i - 1
else
break
local rec_val = token:match(rec_pattern)
if rec_val then
if metadata.rec then
break
end
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
metadata.rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
break
end
metadata.rec = raw_spec
i = i - 1
else
break
end
end
end
end

View file

@ -3,7 +3,7 @@ local config = require('pending.config')
---@class pending.Task
---@field id integer
---@field description string
---@field status 'pending'|'done'|'deleted'
---@field status 'pending'|'done'|'deleted'|'wip'|'blocked'
---@field category? string
---@field priority integer
---@field due? string

View file

@ -41,9 +41,32 @@ local function format_due(due)
return formatted
end
---@type table<string, integer>
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 }
---@param task pending.Task
---@return string
local function state_char(task)
if task.status == 'done' then
return 'x'
elseif task.status == 'wip' then
return '>'
elseif task.status == 'blocked' then
return '='
elseif task.priority > 0 then
return '!'
end
return ' '
end
---@param tasks pending.Task[]
local function sort_tasks(tasks)
table.sort(tasks, function(a, b)
local ra = status_rank[a.status] or 1
local rb = status_rank[b.status] or 1
if ra ~= rb then
return ra < rb
end
if a.priority ~= b.priority then
return a.priority > b.priority
end
@ -57,6 +80,11 @@ end
---@param tasks pending.Task[]
local function sort_tasks_priority(tasks)
table.sort(tasks, function(a, b)
local ra = status_rank[a.status] or 1
local rb = status_rank[b.status] or 1
if ra ~= rb then
return ra < rb
end
if a.priority ~= b.priority then
return a.priority > b.priority
end
@ -95,7 +123,7 @@ function M.category_view(tasks)
by_cat[cat] = {}
done_by_cat[cat] = {}
end
if task.status == 'done' then
if task.status == 'done' or task.status == 'deleted' then
table.insert(done_by_cat[cat], task)
else
table.insert(by_cat[cat], task)
@ -146,7 +174,7 @@ function M.category_view(tasks)
for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ')
local state = state_char(task)
local line = prefix .. '- [' .. state .. '] ' .. task.description
table.insert(lines, line)
table.insert(meta, {
@ -157,7 +185,7 @@ function M.category_view(tasks)
status = task.status,
category = cat,
priority = task.priority,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due)
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due)
or nil,
recur = task.recur,
})
@ -209,7 +237,7 @@ function M.priority_view(tasks)
status = task.status,
category = task.category,
priority = task.priority,
overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil,
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil,
show_category = true,
recur = task.recur,
})