pending.nvim/lua/pending/config.lua
Barrett Ruth c9471ebe90
feat: persistent inline extmarks and configurable EOL format (#97)
* refactor(buffer): split extmark namespace into `ns_eol` and `ns_inline`

Problem: all extmarks shared a single `pending` namespace, making it
impossible to selectively clear position-sensitive extmarks (overlays,
highlights) while preserving stable EOL virtual text (due dates,
recurrence).

Solution: introduce `ns_eol` for end-of-line virtual text and
`ns_inline` for overlays and highlights. `clear_marks()` and
`apply_extmarks()` operate on both namespaces independently.

* feat(buffer): track line changes via `on_bytes` to keep `_meta` aligned

Problem: `_meta` is a positional array keyed by line number. Line
insertions and deletions during editing desync it from actual buffer
content, breaking `get_fold()`, cursor-based task lookups, and extmark
re-application.

Solution: attach an `on_bytes` callback that adjusts `_meta` on line
insertions/deletions and tracks dirty rows. Remove the manual
`_meta` insert from `open_line()` since `on_bytes` now handles it.
Reset dirty rows on each full render.

* feat(buffer): clear only inline extmarks on dirty rows during edits

Problem: `TextChanged` cleared all extmarks (both namespaces) on every
edit, causing EOL virtual text (due dates, recurrence) to vanish while
the user types.

Solution: replace blanket `clear_marks()` with per-row
`clear_inline_row()` that only removes `ns_inline` extmarks on rows
flagged dirty by `on_bytes`. EOL virtual text is preserved untouched.

* feat(buffer): re-apply inline extmarks after edits

Problem: inline extmarks (checkbox overlays, strikethrough, header
highlights) were cleared during edits and only restored on `:w`,
leaving the buffer visually bare while editing.

Solution: extract `apply_inline_row()` from `apply_extmarks()` and
call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode
`TextChanged`. Insert-mode `TextChangedI` still only clears inline
marks on dirty rows to avoid overlay flicker while typing.

* fix(buffer): suppress `on_bytes` during render and fix definition order

Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`,
corrupting `_meta` with duplicate entries and causing out-of-range
extmark errors. Also, `apply_inline_row` was defined after its first
caller `reapply_dirty_inline`.

Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in
`render()` so `on_bytes` is a no-op during authoritative renders.
Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua
local scoping rules.

* feat(buffer): add configurable `eol_format` for EOL virtual text

Problem: EOL virtual text order (category → recurrence → due) and the
double-space separator are hardcoded in `apply_extmarks()`. Users cannot
reorder, omit, or restyle metadata fields.

Solution: Add `eol_format` config field (default `'%c  %r  %d'`) with
`%c`, `%r`, `%d` specifiers. `parse_eol_format()` tokenizes the format
string; `build_eol_virt()` resolves specifiers against `LineMeta` and
collapses literals around absent fields.

* ci: format
2026-03-08 14:28:10 -04:00

144 lines
3.4 KiB
Lua

---@class pending.FoldingConfig
---@field foldtext? string|false
---@class pending.ResolvedFolding
---@field enabled boolean
---@field foldtext string|false
---@class pending.Icons
---@field pending string
---@field done string
---@field priority string
---@field due string
---@field recur string
---@field category string
---@class pending.GcalConfig
---@field remote_delete? boolean
---@field credentials_path? string
---@field client_id? string
---@field client_secret? string
---@class pending.GtasksConfig
---@field remote_delete? boolean
---@field credentials_path? string
---@field client_id? string
---@field client_secret? string
---@class pending.SyncConfig
---@field remote_delete? boolean
---@field gcal? pending.GcalConfig
---@field gtasks? pending.GtasksConfig
---@class pending.Keymaps
---@field close? string|false
---@field toggle? string|false
---@field view? string|false
---@field priority? string|false
---@field date? string|false
---@field undo? string|false
---@field filter? string|false
---@field open_line? string|false
---@field open_line_above? string|false
---@field a_task? string|false
---@field i_task? string|false
---@field a_category? string|false
---@field i_category? string|false
---@field next_header? string|false
---@field prev_header? string|false
---@field next_task? string|false
---@field prev_task? string|false
---@class pending.Config
---@field data_path string
---@field default_view 'category'|'priority'
---@field default_category string
---@field date_format string
---@field date_syntax string
---@field recur_syntax string
---@field someday_date string
---@field input_date_formats? string[]
---@field category_order? string[]
---@field drawer_height? integer
---@field debug? boolean
---@field keymaps pending.Keymaps
---@field folding? boolean|pending.FoldingConfig
---@field sync? pending.SyncConfig
---@field eol_format? string
---@field icons pending.Icons
---@class pending.config
local M = {}
---@type pending.Config
local defaults = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category',
default_category = 'Todo',
date_format = '%b %d',
date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',
eol_format = '%c %r %d',
folding = true,
category_order = {},
keymaps = {
close = 'q',
toggle = '<CR>',
view = '<Tab>',
priority = '!',
date = 'D',
undo = 'U',
filter = 'F',
open_line = 'o',
open_line_above = 'O',
a_task = 'at',
i_task = 'it',
a_category = 'aC',
i_category = 'iC',
next_header = ']]',
prev_header = '[[',
next_task = ']t',
prev_task = '[t',
},
sync = {},
icons = {
pending = ' ',
done = 'x',
priority = '!',
due = '.',
recur = '~',
category = '#',
},
}
---@type pending.Config?
local _resolved = nil
---@return pending.Config
function M.get()
if _resolved then
return _resolved
end
local user = vim.g.pending or {}
_resolved = vim.tbl_deep_extend('force', defaults, user)
return _resolved
end
---@return nil
function M.reset()
_resolved = nil
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