* feat(config): add recur_syntax and someday_date fields Problem: the plugin needs configuration for the recurrence token name and the sentinel date used by the `later`/`someday` named dates. Solution: add `recur_syntax` (default 'rec') and `someday_date` (default '9999-12-30') to pending.Config and the defaults table. * feat(parse): expand date vocabulary with named dates Problem: the date input only supports today, tomorrow, +Nd, and weekday names, lacking relative offsets like weeks/months, period boundaries, ordinals, month names, and backdating. Solution: add yesterday, eod, sow/eow, som/eom, soq/eoq, soy/eoy, +Nw, +Nm, -Nd, -Nw, ordinals (1st-31st), month names (jan-dec), and later/someday to resolve_date(). Add tests for all new tokens. * feat(recur): add recurrence parsing and next-date computation Problem: the plugin has no concept of recurring tasks, which is needed for habits and repeating deadlines. Solution: add recur.lua with parse(), validate(), next_due(), to_rrule(), and shorthand_list(). Supports named shorthands (daily, weekdays, weekly, etc.), interval notation (Nd, Nw, Nm, Ny), raw RRULE passthrough, and ! prefix for completion-based mode. Includes day-clamping for month/year advancement. * feat(store): add recur and recur_mode task fields Problem: the task schema has no fields for storing recurrence rules. Solution: add recur and recur_mode to the Task class, known_fields, task_to_table, table_to_task, and the add() signature. * feat(parse): add rec: inline token parsing Problem: the buffer parser does not recognize recurrence tokens, so users cannot set recurrence rules inline. Solution: add recur_key() helper and rec: token parsing in body() and command_add(), with ! prefix handling for completion-based mode and validation via recur.validate(). * feat(diff): propagate recurrence through buffer reconciliation Problem: the diff layer does not extract or apply recurrence fields, so rec: tokens written in the buffer are silently ignored on :w. Solution: add rec and rec_mode to ParsedEntry, extract them in parse_buffer(), and pass them through create and update paths in apply(). * feat(init): spawn next task on recurring task completion Problem: completing a recurring task does not create the next occurrence, and :Pending add does not pass recurrence fields. Solution: in toggle_complete(), detect recurrence and spawn a new pending task with the next due date. Wire rec/rec_mode through the add() command path. * feat(views): add recurrence to LineMeta Problem: LineMeta does not carry recurrence info, so the buffer layer cannot display recurrence indicators. Solution: add recur field to LineMeta and populate it in both category_view() and priority_view(). * feat(buffer): add PendingRecur highlight and recurrence virtual text Problem: recurring tasks have no visual indicator in the buffer, and the extmark logic uses a rigid if/elseif chain that does not compose well with additional virtual text fields. Solution: add PendingRecur highlight group linking to DiagnosticInfo. Refactor apply_extmarks() to build virtual text parts dynamically, appending category, recurrence indicator, and due date as separate composable segments. Set omnifunc on the pending buffer. * feat(complete): add omnifunc for cat:, due:, and rec: tokens Problem: the pending buffer has no completion source, requiring users to type metadata tokens from memory. Solution: add complete.lua with an omnifunc that completes cat: tokens from existing categories, due: tokens from the named date vocabulary, and rec: tokens from recurrence shorthands. * docs: document recurrence, expanded dates, omnifunc, new config Problem: the vimdoc does not cover recurrence, expanded date syntax, omnifunc completion, or the new config fields. Solution: add DATE INPUT and RECURRENCE sections, update INLINE METADATA, COMMANDS, CONFIGURATION, HIGHLIGHT GROUPS, HEALTH CHECK, and DATA FORMAT. Expand the help popup with recurrence patterns and new date tokens. Add recurrence validation to healthcheck. * ci: fix * fix(recur): resolve LuaLS type errors Problem: LuaLS reported undefined-field for `_raw` on RecurSpec and param-type-mismatch for `last_day.day` in `advance_date` because `osdate.day` infers as `string|integer`. Solution: Add `_raw` to the RecurSpec class annotation and cast `last_day.day` to integer in both `math.min` call sites. * refactor(init): remove help popup, use config-driven keymaps Problem: Buffer-local keymaps were hardcoded with no way for users to customize them. The g? help popup duplicated information already in the vimdoc. Solution: Remove show_help() and the g? mapping. Refactor _setup_buf_mappings to read from cfg.keymaps, letting users override or disable any buffer-local binding via vim.g.pending. * feat(config): add keymaps table for buffer-local bindings Problem: Users had no way to customize or disable buffer-local key bindings in the pending buffer. Solution: Add a pending.Keymaps class and keymaps field to pending.Config with defaults for all eight buffer actions. Setting any key to false disables that binding. * feat(plugin): add Plug mappings for all buffer actions Problem: Only five of nine buffer actions had <Plug> mappings, so users could not bind close, undo, open-line, or open-line-above globally. Solution: Add <Plug>(pending-close), <Plug>(pending-undo), <Plug>(pending-open-line), and <Plug>(pending-open-line-above). * docs: update mappings and config for keymaps and new Plug entries Problem: Vimdoc still listed g? help popup, lacked documentation for the four new <Plug> mappings, and had no keymaps config section. Solution: Remove g? from mappings table, document all nine <Plug> mappings, add keymaps table to the config example and field reference, and note that buffer-local keys are configurable.
298 lines
8 KiB
Lua
298 lines
8 KiB
Lua
local config = require('pending.config')
|
|
local store = require('pending.store')
|
|
local views = require('pending.views')
|
|
|
|
---@class pending.buffer
|
|
local M = {}
|
|
|
|
---@type integer?
|
|
local task_bufnr = nil
|
|
---@type integer?
|
|
local task_winid = nil
|
|
local task_ns = vim.api.nvim_create_namespace('pending')
|
|
---@type 'category'|'priority'|nil
|
|
local current_view = nil
|
|
---@type pending.LineMeta[]
|
|
local _meta = {}
|
|
---@type table<integer, table<string, boolean>>
|
|
local _fold_state = {}
|
|
|
|
---@return pending.LineMeta[]
|
|
function M.meta()
|
|
return _meta
|
|
end
|
|
|
|
---@return integer?
|
|
function M.bufnr()
|
|
return task_bufnr
|
|
end
|
|
|
|
---@return integer?
|
|
function M.winid()
|
|
return task_winid
|
|
end
|
|
|
|
---@return string?
|
|
function M.current_view_name()
|
|
return current_view
|
|
end
|
|
|
|
function M.clear_winid()
|
|
task_winid = nil
|
|
end
|
|
|
|
function M.close()
|
|
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
|
|
vim.api.nvim_win_close(task_winid, false)
|
|
end
|
|
task_winid = nil
|
|
end
|
|
|
|
---@param bufnr integer
|
|
local function set_buf_options(bufnr)
|
|
vim.bo[bufnr].buftype = 'acwrite'
|
|
vim.bo[bufnr].bufhidden = 'hide'
|
|
vim.bo[bufnr].swapfile = false
|
|
vim.bo[bufnr].filetype = 'pending'
|
|
vim.bo[bufnr].modifiable = true
|
|
vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc'
|
|
end
|
|
|
|
---@param winid integer
|
|
local function set_win_options(winid)
|
|
vim.wo[winid].conceallevel = 3
|
|
vim.wo[winid].concealcursor = 'nvic'
|
|
vim.wo[winid].wrap = false
|
|
vim.wo[winid].number = false
|
|
vim.wo[winid].relativenumber = false
|
|
vim.wo[winid].signcolumn = 'no'
|
|
vim.wo[winid].foldcolumn = '0'
|
|
vim.wo[winid].spell = false
|
|
vim.wo[winid].cursorline = true
|
|
vim.wo[winid].winfixheight = true
|
|
end
|
|
|
|
---@param bufnr integer
|
|
local function setup_syntax(bufnr)
|
|
vim.api.nvim_buf_call(bufnr, function()
|
|
vim.cmd([[
|
|
syntax clear
|
|
syntax match taskId /^\/\d\+\// conceal
|
|
syntax match taskHeader /^## .*$/ contains=taskId
|
|
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
|
|
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
|
|
]])
|
|
end)
|
|
end
|
|
|
|
---@param above boolean
|
|
function M.open_line(above)
|
|
local bufnr = task_bufnr
|
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
|
return
|
|
end
|
|
local row = vim.api.nvim_win_get_cursor(0)[1]
|
|
local insert_row = above and (row - 1) or row
|
|
vim.bo[bufnr].modifiable = true
|
|
vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' })
|
|
vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 })
|
|
vim.cmd('startinsert!')
|
|
end
|
|
|
|
---@return string
|
|
function M.get_fold()
|
|
local lnum = vim.v.lnum
|
|
local m = _meta[lnum]
|
|
if not m then
|
|
return '0'
|
|
end
|
|
if m.type == 'header' then
|
|
return '>1'
|
|
elseif m.type == 'task' then
|
|
return '1'
|
|
else
|
|
return '0'
|
|
end
|
|
end
|
|
|
|
---@param bufnr integer
|
|
---@param line_meta pending.LineMeta[]
|
|
local function apply_extmarks(bufnr, line_meta)
|
|
vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1)
|
|
for i, m in ipairs(line_meta) do
|
|
local row = i - 1
|
|
if m.type == 'task' then
|
|
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
|
|
local virt_parts = {}
|
|
if m.show_category and m.category then
|
|
table.insert(virt_parts, { m.category, 'PendingHeader' })
|
|
end
|
|
if m.recur then
|
|
table.insert(virt_parts, { '\u{21bb} ' .. m.recur, 'PendingRecur' })
|
|
end
|
|
if m.due then
|
|
table.insert(virt_parts, { m.due, due_hl })
|
|
end
|
|
if #virt_parts > 0 then
|
|
for p = 1, #virt_parts - 1 do
|
|
virt_parts[p][1] = virt_parts[p][1] .. ' '
|
|
end
|
|
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
|
virt_text = virt_parts,
|
|
virt_text_pos = 'eol',
|
|
})
|
|
end
|
|
if m.status == 'done' 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, task_ns, row, col_start, {
|
|
end_col = #line,
|
|
hl_group = 'PendingDone',
|
|
})
|
|
end
|
|
elseif m.type == 'header' then
|
|
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
|
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
|
end_col = #line,
|
|
hl_group = 'PendingHeader',
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
local function setup_highlights()
|
|
vim.api.nvim_set_hl(0, 'PendingHeader', { link = 'Title', default = true })
|
|
vim.api.nvim_set_hl(0, 'PendingDue', { link = 'DiagnosticHint', default = true })
|
|
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, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
|
|
end
|
|
|
|
local function snapshot_folds(bufnr)
|
|
if current_view ~= 'category' then
|
|
return
|
|
end
|
|
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
|
|
local state = {}
|
|
vim.api.nvim_win_call(winid, function()
|
|
for lnum, m in ipairs(_meta) do
|
|
if m.type == 'header' and m.category then
|
|
if vim.fn.foldclosed(lnum) ~= -1 then
|
|
state[m.category] = true
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
_fold_state[winid] = state
|
|
end
|
|
end
|
|
|
|
local function restore_folds(bufnr)
|
|
if current_view ~= 'category' then
|
|
return
|
|
end
|
|
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
|
|
local state = _fold_state[winid]
|
|
if state and next(state) ~= nil then
|
|
vim.api.nvim_win_call(winid, function()
|
|
vim.cmd('normal! zx')
|
|
local saved = vim.api.nvim_win_get_cursor(0)
|
|
for lnum, m in ipairs(_meta) do
|
|
if m.type == 'header' and m.category and state[m.category] then
|
|
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
|
|
vim.cmd('normal! zc')
|
|
end
|
|
end
|
|
vim.api.nvim_win_set_cursor(0, saved)
|
|
end)
|
|
_fold_state[winid] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param bufnr? integer
|
|
function M.render(bufnr)
|
|
bufnr = bufnr or task_bufnr
|
|
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
|
|
return
|
|
end
|
|
|
|
current_view = current_view or config.get().default_view
|
|
vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view)
|
|
local tasks = store.active_tasks()
|
|
|
|
local lines, line_meta
|
|
if current_view == 'priority' then
|
|
lines, line_meta = views.priority_view(tasks)
|
|
else
|
|
lines, line_meta = views.category_view(tasks)
|
|
end
|
|
|
|
_meta = line_meta
|
|
|
|
snapshot_folds(bufnr)
|
|
vim.bo[bufnr].modifiable = true
|
|
local saved = vim.bo[bufnr].undolevels
|
|
vim.bo[bufnr].undolevels = -1
|
|
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
|
vim.bo[bufnr].modified = false
|
|
vim.bo[bufnr].undolevels = saved
|
|
|
|
setup_syntax(bufnr)
|
|
apply_extmarks(bufnr, line_meta)
|
|
|
|
for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
|
|
if current_view == 'category' then
|
|
vim.wo[winid].foldmethod = 'expr'
|
|
vim.wo[winid].foldexpr = 'v:lua.require("pending.buffer").get_fold()'
|
|
vim.wo[winid].foldlevel = 99
|
|
vim.wo[winid].foldenable = true
|
|
else
|
|
vim.wo[winid].foldmethod = 'manual'
|
|
vim.wo[winid].foldenable = false
|
|
end
|
|
end
|
|
restore_folds(bufnr)
|
|
end
|
|
|
|
function M.toggle_view()
|
|
if current_view == 'category' then
|
|
current_view = 'priority'
|
|
else
|
|
current_view = 'category'
|
|
end
|
|
M.render()
|
|
end
|
|
|
|
---@return integer bufnr
|
|
function M.open()
|
|
setup_highlights()
|
|
store.load()
|
|
|
|
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
|
|
vim.api.nvim_set_current_win(task_winid)
|
|
M.render(task_bufnr)
|
|
return task_bufnr --[[@as integer]]
|
|
end
|
|
|
|
if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then
|
|
task_bufnr = vim.api.nvim_create_buf(true, false)
|
|
set_buf_options(task_bufnr)
|
|
end
|
|
|
|
vim.cmd('botright new')
|
|
task_winid = vim.api.nvim_get_current_win()
|
|
vim.api.nvim_win_set_buf(task_winid, task_bufnr)
|
|
local h = config.get().drawer_height
|
|
if h and h > 0 then
|
|
vim.api.nvim_win_set_height(task_winid, h)
|
|
end
|
|
set_win_options(task_winid)
|
|
|
|
M.render(task_bufnr)
|
|
|
|
return task_bufnr
|
|
end
|
|
|
|
return M
|