From a12e5b5763467965a3282ef40683f30ff0e55be5 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 14:08:15 -0400 Subject: [PATCH 1/6] 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. --- lua/pending/buffer.lua | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index ecdae4c..da3caca 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -12,7 +12,8 @@ local _store = nil local task_bufnr = nil ---@type integer? local task_winid = nil -local task_ns = vim.api.nvim_create_namespace('pending') +local ns_eol = vim.api.nvim_create_namespace('pending_eol') +local ns_inline = vim.api.nvim_create_namespace('pending_inline') ---@type 'category'|'priority'|nil local current_view = nil ---@type pending.LineMeta[] @@ -89,7 +90,9 @@ end ---@param b? integer ---@return nil function M.clear_marks(b) - vim.api.nvim_buf_clear_namespace(b or task_bufnr, task_ns, 0, -1) + local bufnr = b or task_bufnr + vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) end ---@return nil @@ -230,12 +233,13 @@ end ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) local icons = config.get().icons - vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1) + vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) for i, m in ipairs(line_meta) do local row = i - 1 if m.type == 'filter' 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, { + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { end_col = #line, hl_group = 'PendingFilter', }) @@ -255,7 +259,7 @@ local function apply_extmarks(bufnr, line_meta) 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, { + vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { virt_text = virt_parts, virt_text_pos = 'eol', }) @@ -263,7 +267,7 @@ local function apply_extmarks(bufnr, line_meta) 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, { + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { end_col = #line, hl_group = 'PendingDone', }) @@ -278,18 +282,18 @@ local function apply_extmarks(bufnr, line_meta) else icon, icon_hl = icons.pending, 'Normal' end - vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, bracket_col, { + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, { virt_text = { { '[' .. icon .. ']', icon_hl } }, virt_text_pos = 'overlay', priority = 100, }) 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, { + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { end_col = #line, hl_group = 'PendingHeader', }) - vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { virt_text = { { icons.category .. ' ', 'PendingHeader' } }, virt_text_pos = 'overlay', priority = 100, From db391c5715e76b5e393d6fcc5e98a64390bb7b6f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 14:09:36 -0400 Subject: [PATCH 2/6] 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. --- lua/pending/buffer.lua | 49 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index da3caca..367a8bb 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -26,6 +26,10 @@ local _initial_fold_loaded = false local _filter_predicates = {} ---@type table local _hidden_ids = {} +---@type table +local _dirty_rows = {} +---@type boolean +local _on_bytes_active = false ---@return pending.LineMeta[] function M.meta() @@ -95,6 +99,48 @@ function M.clear_marks(b) vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) end +---@return table +function M.dirty_rows() + return _dirty_rows +end + +---@return nil +function M.clear_dirty_rows() + _dirty_rows = {} +end + +---@param bufnr integer +---@return nil +function M.attach_bytes(bufnr) + if _on_bytes_active then + return + end + _on_bytes_active = true + vim.api.nvim_buf_attach(bufnr, false, { + on_bytes = function(_, buf, _, start_row, _, _, old_end_row, _, _, new_end_row, _, _) + if buf ~= task_bufnr then + _on_bytes_active = false + return true + end + local delta = new_end_row - old_end_row + if delta > 0 then + for _ = 1, delta do + table.insert(_meta, start_row + 2, { type = 'task' }) + end + elseif delta < 0 then + for _ = 1, -delta do + if _meta[start_row + 2] then + table.remove(_meta, start_row + 2) + end + end + end + for r = start_row + 1, start_row + 1 + math.max(0, new_end_row) do + _dirty_rows[r] = true + end + end, + }) +end + ---@return nil function M.persist_folds() log.debug( @@ -208,7 +254,6 @@ function M.open_line(above) 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, { '- [ ] ' }) - table.insert(_meta, insert_row + 1, { type = 'task' }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end @@ -452,6 +497,7 @@ function M.render(bufnr) end _meta = line_meta + _dirty_rows = {} snapshot_folds(bufnr) vim.bo[bufnr].modifiable = true @@ -511,6 +557,7 @@ function M.open() 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) + M.attach_bytes(task_bufnr) end vim.cmd('botright new') From ec08ca96452d62287885d91bb9c5aef53542311a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 14:10:33 -0400 Subject: [PATCH 3/6] 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. --- lua/pending/buffer.lua | 7 +++++++ lua/pending/init.lua | 7 +++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 367a8bb..79cd2c6 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -99,6 +99,13 @@ function M.clear_marks(b) vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) end +---@param b integer +---@param row integer +---@return nil +function M.clear_inline_row(b, row) + vim.api.nvim_buf_clear_namespace(b, ns_inline, row - 1, row) +end + ---@return table function M.dirty_rows() return _dirty_rows diff --git a/lua/pending/init.lua b/lua/pending/init.lua index af018a7..3e8702d 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -255,8 +255,11 @@ function M._setup_autocmds(bufnr) group = group, buffer = bufnr, callback = function() - if vim.bo[bufnr].modified then - buffer.clear_marks(bufnr) + if not vim.bo[bufnr].modified then + return + end + for row in pairs(buffer.dirty_rows()) do + buffer.clear_inline_row(bufnr, row) end end, }) From e71d6cdff612f4976db4ad4230e2da76851f3755 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 14:12:03 -0400 Subject: [PATCH 4/6] 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. --- lua/pending/buffer.lua | 109 +++++++++++++++++++++++++---------------- lua/pending/init.lua | 21 +++++++- 2 files changed, 88 insertions(+), 42 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 79cd2c6..a387104 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -116,6 +116,23 @@ function M.clear_dirty_rows() _dirty_rows = {} end +---@param bufnr integer +---@return nil +function M.reapply_dirty_inline(bufnr) + if not next(_dirty_rows) then + return + end + local icons = config.get().icons + for row in pairs(_dirty_rows) do + local m = _meta[row] + if m then + vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, row - 1, row) + apply_inline_row(bufnr, row - 1, m, icons) + end + end + _dirty_rows = {} +end + ---@param bufnr integer ---@return nil function M.attach_bytes(bufnr) @@ -281,6 +298,55 @@ function M.get_fold() end end +---@param bufnr integer +---@param row integer +---@param m pending.LineMeta +---@param icons table +local function apply_inline_row(bufnr, row, m, icons) + if m.type == 'filter' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { + end_col = #line, + hl_group = 'PendingFilter', + }) + elseif m.type == 'task' then + 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, ns_inline, row, col_start, { + end_col = #line, + hl_group = 'PendingDone', + }) + 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.priority and m.priority > 0 then + icon, icon_hl = icons.priority, 'PendingPriority' + else + icon, icon_hl = icons.pending, 'Normal' + end + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, { + virt_text = { { '[' .. icon .. ']', icon_hl } }, + virt_text_pos = 'overlay', + priority = 100, + }) + 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, ns_inline, row, 0, { + end_col = #line, + hl_group = 'PendingHeader', + }) + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { + virt_text = { { icons.category .. ' ', 'PendingHeader' } }, + virt_text_pos = 'overlay', + priority = 100, + }) + end +end + ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) @@ -289,13 +355,7 @@ local function apply_extmarks(bufnr, line_meta) vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1) for i, m in ipairs(line_meta) do local row = i - 1 - if m.type == 'filter' then - local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { - end_col = #line, - hl_group = 'PendingFilter', - }) - elseif m.type == 'task' then + 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 @@ -316,41 +376,8 @@ local function apply_extmarks(bufnr, line_meta) 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, ns_inline, row, col_start, { - end_col = #line, - hl_group = 'PendingDone', - }) - 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.priority and m.priority > 0 then - icon, icon_hl = icons.priority, 'PendingPriority' - else - icon, icon_hl = icons.pending, 'Normal' - end - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, { - virt_text = { { '[' .. icon .. ']', icon_hl } }, - virt_text_pos = 'overlay', - priority = 100, - }) - 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, ns_inline, row, 0, { - end_col = #line, - hl_group = 'PendingHeader', - }) - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { - virt_text = { { icons.category .. ' ', 'PendingHeader' } }, - virt_text_pos = 'overlay', - priority = 100, - }) end + apply_inline_row(bufnr, row, m, icons) end end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 3e8702d..4d05503 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -251,7 +251,7 @@ function M._setup_autocmds(bufnr) end end, }) - vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + vim.api.nvim_create_autocmd('TextChangedI', { group = group, buffer = bufnr, callback = function() @@ -263,6 +263,25 @@ function M._setup_autocmds(bufnr) end end, }) + vim.api.nvim_create_autocmd('TextChanged', { + group = group, + buffer = bufnr, + callback = function() + if not vim.bo[bufnr].modified then + return + end + buffer.reapply_dirty_inline(bufnr) + end, + }) + vim.api.nvim_create_autocmd('InsertLeave', { + group = group, + buffer = bufnr, + callback = function() + if vim.bo[bufnr].modified then + buffer.reapply_dirty_inline(bufnr) + end + end, + }) vim.api.nvim_create_autocmd('WinClosed', { group = group, callback = function(ev) From 27f46ae0dd5d08132b307ec0afd6d6e4db27afc6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 14:17:47 -0400 Subject: [PATCH 5/6] 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. --- lua/pending/buffer.lua | 105 ++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index a387104..c3e666f 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -30,6 +30,8 @@ local _hidden_ids = {} local _dirty_rows = {} ---@type boolean local _on_bytes_active = false +---@type boolean +local _rendering = false ---@return pending.LineMeta[] function M.meta() @@ -116,6 +118,55 @@ function M.clear_dirty_rows() _dirty_rows = {} end +---@param bufnr integer +---@param row integer +---@param m pending.LineMeta +---@param icons table +local function apply_inline_row(bufnr, row, m, icons) + if m.type == 'filter' then + local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { + end_col = #line, + hl_group = 'PendingFilter', + }) + elseif m.type == 'task' then + 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, ns_inline, row, col_start, { + end_col = #line, + hl_group = 'PendingDone', + }) + 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.priority and m.priority > 0 then + icon, icon_hl = icons.priority, 'PendingPriority' + else + icon, icon_hl = icons.pending, 'Normal' + end + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, { + virt_text = { { '[' .. icon .. ']', icon_hl } }, + virt_text_pos = 'overlay', + priority = 100, + }) + 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, ns_inline, row, 0, { + end_col = #line, + hl_group = 'PendingHeader', + }) + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { + virt_text = { { icons.category .. ' ', 'PendingHeader' } }, + virt_text_pos = 'overlay', + priority = 100, + }) + end +end + ---@param bufnr integer ---@return nil function M.reapply_dirty_inline(bufnr) @@ -146,6 +197,9 @@ function M.attach_bytes(bufnr) _on_bytes_active = false return true end + if _rendering then + return + end local delta = new_end_row - old_end_row if delta > 0 then for _ = 1, delta do @@ -298,55 +352,6 @@ function M.get_fold() end end ----@param bufnr integer ----@param row integer ----@param m pending.LineMeta ----@param icons table -local function apply_inline_row(bufnr, row, m, icons) - if m.type == 'filter' then - local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { - end_col = #line, - hl_group = 'PendingFilter', - }) - elseif m.type == 'task' then - 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, ns_inline, row, col_start, { - end_col = #line, - hl_group = 'PendingDone', - }) - 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.priority and m.priority > 0 then - icon, icon_hl = icons.priority, 'PendingPriority' - else - icon, icon_hl = icons.pending, 'Normal' - end - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, { - virt_text = { { '[' .. icon .. ']', icon_hl } }, - virt_text_pos = 'overlay', - priority = 100, - }) - 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, ns_inline, row, 0, { - end_col = #line, - hl_group = 'PendingHeader', - }) - vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, { - virt_text = { { icons.category .. ' ', 'PendingHeader' } }, - virt_text_pos = 'overlay', - priority = 100, - }) - end -end - ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) @@ -537,7 +542,9 @@ function M.render(bufnr) vim.bo[bufnr].modifiable = true local saved = vim.bo[bufnr].undolevels vim.bo[bufnr].undolevels = -1 + _rendering = true vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + _rendering = false vim.bo[bufnr].modified = false vim.bo[bufnr].undolevels = saved From b6c28eb7b37daea54afe440f58dd7f78da3c7aa4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 14:25:22 -0400 Subject: [PATCH 6/6] feat(buffer): add configurable `eol_format` for EOL virtual text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- doc/pending.txt | 26 +++++++++++ lua/pending/buffer.lua | 103 +++++++++++++++++++++++++++++++++++------ lua/pending/config.lua | 2 + 3 files changed, 116 insertions(+), 15 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index 2883664..11c3973 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -648,6 +648,32 @@ Fields: ~ virtual text in the buffer. Examples: `'%Y-%m-%d'` 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* List of strftime-like format strings tried in order when parsing a `due:` token that does not match the diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index c3e666f..34ce3b3 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -352,30 +352,103 @@ function M.get_fold() 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 line_meta pending.LineMeta[] 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_inline, 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, { 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 + local virt_parts = build_eol_virt(eol_segments, m, icons) 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, { virt_text = virt_parts, virt_text_pos = 'eol', diff --git a/lua/pending/config.lua b/lua/pending/config.lua index d926037..b7a42ad 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -64,6 +64,7 @@ ---@field keymaps pending.Keymaps ---@field folding? boolean|pending.FoldingConfig ---@field sync? pending.SyncConfig +---@field eol_format? string ---@field icons pending.Icons ---@class pending.config @@ -78,6 +79,7 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + eol_format = '%c %r %d', folding = true, category_order = {}, keymaps = {