From b6c28eb7b37daea54afe440f58dd7f78da3c7aa4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 14:25:22 -0400 Subject: [PATCH] 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 = {