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
This commit is contained in:
parent
ab06cfcf69
commit
c9471ebe90
3 changed files with 115 additions and 15 deletions
|
|
@ -648,6 +648,32 @@ Fields: ~
|
||||||
virtual text in the buffer. Examples: `'%Y-%m-%d'`
|
virtual text in the buffer. Examples: `'%Y-%m-%d'`
|
||||||
for ISO dates, `'%d %b'` for day-first.
|
for ISO dates, `'%d %b'` for day-first.
|
||||||
|
|
||||||
|
{eol_format} (string, default: '%c %r %d')
|
||||||
|
Format string controlling the order, content, and
|
||||||
|
separators of end-of-line virtual text on task lines.
|
||||||
|
Three specifiers are available:
|
||||||
|
|
||||||
|
`%c` category icon + name (`PendingHeader`)
|
||||||
|
`%r` recurrence icon + pattern (`PendingRecur`)
|
||||||
|
`%d` due icon + date (`PendingDue` / `PendingOverdue`)
|
||||||
|
|
||||||
|
Literal text between specifiers is rendered with the
|
||||||
|
`Normal` highlight group and acts as a separator.
|
||||||
|
When a specifier's data is absent (e.g. `%d` on a
|
||||||
|
task with no due date), the specifier and any
|
||||||
|
surrounding literal text up to the next specifier
|
||||||
|
are omitted — missing fields never leave gaps.
|
||||||
|
|
||||||
|
`%c` only renders in priority view (where
|
||||||
|
`show_category` is true). In category view it is
|
||||||
|
always omitted regardless of the format string.
|
||||||
|
|
||||||
|
Examples: >lua
|
||||||
|
vim.g.pending = { eol_format = '%d %r' }
|
||||||
|
vim.g.pending = { eol_format = '%d | %r' }
|
||||||
|
vim.g.pending = { eol_format = '%c %d %r' }
|
||||||
|
<
|
||||||
|
|
||||||
{input_date_formats} (string[], default: {}) *pending-input-formats*
|
{input_date_formats} (string[], default: {}) *pending-input-formats*
|
||||||
List of strftime-like format strings tried in order
|
List of strftime-like format strings tried in order
|
||||||
when parsing a `due:` token that does not match the
|
when parsing a `due:` token that does not match the
|
||||||
|
|
|
||||||
|
|
@ -352,30 +352,102 @@ function M.get_fold()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@class pending.EolSegment
|
||||||
|
---@field type 'specifier'|'literal'
|
||||||
|
---@field key? 'c'|'r'|'d'
|
||||||
|
---@field text? string
|
||||||
|
|
||||||
|
---@param fmt string
|
||||||
|
---@return pending.EolSegment[]
|
||||||
|
local function parse_eol_format(fmt)
|
||||||
|
local segments = {}
|
||||||
|
local pos = 1
|
||||||
|
local len = #fmt
|
||||||
|
while pos <= len do
|
||||||
|
if fmt:sub(pos, pos) == '%' and pos + 1 <= len then
|
||||||
|
local key = fmt:sub(pos + 1, pos + 1)
|
||||||
|
if key == 'c' or key == 'r' or key == 'd' then
|
||||||
|
table.insert(segments, { type = 'specifier', key = key })
|
||||||
|
pos = pos + 2
|
||||||
|
else
|
||||||
|
table.insert(segments, { type = 'literal', text = '%' .. key })
|
||||||
|
pos = pos + 2
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local next_pct = fmt:find('%%', pos + 1)
|
||||||
|
local chunk = next_pct and fmt:sub(pos, next_pct - 1) or fmt:sub(pos)
|
||||||
|
table.insert(segments, { type = 'literal', text = chunk })
|
||||||
|
pos = pos + #chunk
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return segments
|
||||||
|
end
|
||||||
|
|
||||||
|
---@param segments pending.EolSegment[]
|
||||||
|
---@param m pending.LineMeta
|
||||||
|
---@param icons pending.Icons
|
||||||
|
---@return string[][]
|
||||||
|
local function build_eol_virt(segments, m, icons)
|
||||||
|
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
|
||||||
|
local resolved = {}
|
||||||
|
for i, seg in ipairs(segments) do
|
||||||
|
if seg.type == 'specifier' then
|
||||||
|
local text, hl
|
||||||
|
if seg.key == 'c' and m.show_category and m.category then
|
||||||
|
text = icons.category .. ' ' .. m.category
|
||||||
|
hl = 'PendingHeader'
|
||||||
|
elseif seg.key == 'r' and m.recur then
|
||||||
|
text = icons.recur .. ' ' .. m.recur
|
||||||
|
hl = 'PendingRecur'
|
||||||
|
elseif seg.key == 'd' and m.due then
|
||||||
|
text = icons.due .. ' ' .. m.due
|
||||||
|
hl = due_hl
|
||||||
|
end
|
||||||
|
resolved[i] = text and { text = text, hl = hl, present = true } or { present = false }
|
||||||
|
else
|
||||||
|
resolved[i] = { text = seg.text, hl = 'Normal', literal = true }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local virt_parts = {}
|
||||||
|
for i, r in ipairs(resolved) do
|
||||||
|
if r.literal then
|
||||||
|
local prev_present, next_present = false, false
|
||||||
|
for j = i - 1, 1, -1 do
|
||||||
|
if not resolved[j].literal then
|
||||||
|
prev_present = resolved[j].present
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for j = i + 1, #resolved do
|
||||||
|
if not resolved[j].literal then
|
||||||
|
next_present = resolved[j].present
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if prev_present and next_present then
|
||||||
|
table.insert(virt_parts, { r.text, r.hl })
|
||||||
|
end
|
||||||
|
elseif r.present then
|
||||||
|
table.insert(virt_parts, { r.text, r.hl })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return virt_parts
|
||||||
|
end
|
||||||
|
|
||||||
---@param bufnr integer
|
---@param bufnr integer
|
||||||
---@param line_meta pending.LineMeta[]
|
---@param line_meta pending.LineMeta[]
|
||||||
local function apply_extmarks(bufnr, line_meta)
|
local function apply_extmarks(bufnr, line_meta)
|
||||||
local icons = config.get().icons
|
local cfg = config.get()
|
||||||
|
local icons = cfg.icons
|
||||||
|
local eol_segments = parse_eol_format(cfg.eol_format or '%c %r %d')
|
||||||
vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1)
|
vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1)
|
||||||
vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1)
|
vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1)
|
||||||
for i, m in ipairs(line_meta) do
|
for i, m in ipairs(line_meta) do
|
||||||
local row = i - 1
|
local row = i - 1
|
||||||
if m.type == 'task' then
|
if m.type == 'task' then
|
||||||
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
|
local virt_parts = build_eol_virt(eol_segments, m, icons)
|
||||||
local virt_parts = {}
|
|
||||||
if m.show_category and m.category then
|
|
||||||
table.insert(virt_parts, { icons.category .. ' ' .. m.category, 'PendingHeader' })
|
|
||||||
end
|
|
||||||
if m.recur then
|
|
||||||
table.insert(virt_parts, { icons.recur .. ' ' .. m.recur, 'PendingRecur' })
|
|
||||||
end
|
|
||||||
if m.due then
|
|
||||||
table.insert(virt_parts, { icons.due .. ' ' .. m.due, due_hl })
|
|
||||||
end
|
|
||||||
if #virt_parts > 0 then
|
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, ns_eol, row, 0, {
|
vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, {
|
||||||
virt_text = virt_parts,
|
virt_text = virt_parts,
|
||||||
virt_text_pos = 'eol',
|
virt_text_pos = 'eol',
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@
|
||||||
---@field keymaps pending.Keymaps
|
---@field keymaps pending.Keymaps
|
||||||
---@field folding? boolean|pending.FoldingConfig
|
---@field folding? boolean|pending.FoldingConfig
|
||||||
---@field sync? pending.SyncConfig
|
---@field sync? pending.SyncConfig
|
||||||
|
---@field eol_format? string
|
||||||
---@field icons pending.Icons
|
---@field icons pending.Icons
|
||||||
|
|
||||||
---@class pending.config
|
---@class pending.config
|
||||||
|
|
@ -78,6 +79,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',
|
||||||
|
eol_format = '%c %r %d',
|
||||||
folding = true,
|
folding = true,
|
||||||
category_order = {},
|
category_order = {},
|
||||||
keymaps = {
|
keymaps = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue