refactor(icons): unify category/header icon and use checkbox overlays

Problem: `header` and `category` were separate icons for the same
concept. The icon overlay replaced `[ ]` with a bare character,
hiding the markdown checkbox syntax. Header format `## ` produced
a double-space with single-char icons.

Solution: merge `header` into `category` (one icon for both header
lines and EOL labels). Overlay renders `[icon]` preserving bracket
syntax. Change header line format from `## ` to `# ` so the
2-char overlay (`# `) maps cleanly.
This commit is contained in:
Barrett Ruth 2026-03-04 18:38:10 -05:00
parent 26b14b6ba8
commit 530009d830
9 changed files with 61 additions and 81 deletions

View file

@ -26,18 +26,18 @@ luarocks install pending.nvim
## Icons ## Icons
pending.nvim renders task status and metadata using configurable icon characters. The defaults are ASCII-only (no unicode or nerd font required): All display characters are configurable. Defaults produce markdown-style checkboxes (`[ ]`, `[x]`, `[!]`):
```lua ```lua
vim.g.pending = { vim.g.pending = {
icons = { icons = {
pending = '-', done = 'x', priority = '!', pending = ' ', done = 'x', priority = '!',
header = '>', due = '.', recur = '~', category = '#', due = '.', recur = '~', category = '#',
}, },
} }
``` ```
See `:help pending.Icons` for unicode and nerd font examples. See `:help pending.Icons` for nerd font examples.
## Acknowledgements ## Acknowledgements

View file

@ -663,14 +663,18 @@ Fields: ~
table. Currently only `gcal` is built-in. table. Currently only `gcal` is built-in.
{icons} (table) *pending.Icons* {icons} (table) *pending.Icons*
Icon characters displayed in the buffer. Fields: Icon characters displayed in the buffer. The
{pending} Uncompleted task icon. Default: '-' {pending}, {done}, and {priority} characters
{done} Completed task icon. Default: 'x' appear inside brackets (`[icon]`) as an overlay
{priority} Priority task icon. Default: '!' on the checkbox. The {category} character
{header} Category header prefix. Default: '>' prefixes both header lines and EOL category
labels. Fields:
{pending} Pending task character. Default: ' '
{done} Done task character. Default: 'x'
{priority} Priority task character. Default: '!'
{due} Due date prefix. Default: '.' {due} Due date prefix. Default: '.'
{recur} Recurrence prefix. Default: '~' {recur} Recurrence prefix. Default: '~'
{category} Category label prefix. Default: '#' {category} Category prefix. Default: '#'
============================================================================== ==============================================================================
STORE RESOLUTION *pending-store-resolution* STORE RESOLUTION *pending-store-resolution*
@ -847,10 +851,6 @@ Event-driven statusline refresh: >lua
Nerd font icons: >lua Nerd font icons: >lua
vim.g.pending = { vim.g.pending = {
icons = { icons = {
pending = '',
done = '',
priority = '',
header = '',
due = '', due = '',
recur = '󰁯', recur = '󰁯',
category = '', category = '',
@ -858,20 +858,6 @@ Nerd font icons: >lua
} }
< <
ASCII fallback icons: >lua
vim.g.pending = {
icons = {
pending = '-',
done = 'x',
priority = '!',
header = '>',
due = '@',
recur = '~',
category = '+',
},
}
<
Open tasks in a new tab on startup: >lua Open tasks in a new tab on startup: >lua
vim.api.nvim_create_autocmd('VimEnter', { vim.api.nvim_create_autocmd('VimEnter', {
callback = function() callback = function()

View file

@ -115,7 +115,7 @@ local function setup_syntax(bufnr)
vim.cmd([[ vim.cmd([[
syntax clear syntax clear
syntax match taskId /^\/\d\+\// conceal syntax match taskId /^\/\d\+\// conceal
syntax match taskHeader /^## .*$/ contains=taskId syntax match taskHeader /^# .*$/ contains=taskId
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox
]]) ]])
@ -205,9 +205,8 @@ local function apply_extmarks(bufnr, line_meta)
else else
icon, icon_hl = icons.pending, 'Normal' icon, icon_hl = icons.pending, 'Normal'
end end
local icon_padded = icon .. ' '
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, bracket_col, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, bracket_col, {
virt_text = { { icon_padded, icon_hl } }, virt_text = { { '[' .. icon .. ']', icon_hl } },
virt_text_pos = 'overlay', virt_text_pos = 'overlay',
priority = 100, priority = 100,
}) })
@ -218,7 +217,7 @@ local function apply_extmarks(bufnr, line_meta)
hl_group = 'PendingHeader', hl_group = 'PendingHeader',
}) })
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = { { icons.header .. ' ', 'PendingHeader' } }, virt_text = { { icons.category .. ' ', 'PendingHeader' } },
virt_text_pos = 'overlay', virt_text_pos = 'overlay',
priority = 100, priority = 100,
}) })

View file

@ -2,7 +2,6 @@
---@field pending string ---@field pending string
---@field done string ---@field done string
---@field priority string ---@field priority string
---@field header string
---@field due string ---@field due string
---@field recur string ---@field recur string
---@field category string ---@field category string
@ -82,10 +81,9 @@ local defaults = {
}, },
sync = {}, sync = {},
icons = { icons = {
pending = '-', pending = ' ',
done = 'x', done = 'x',
priority = '!', priority = '!',
header = '>',
due = '.', due = '.',
recur = '~', recur = '~',
category = '#', category = '#',

View file

@ -59,8 +59,8 @@ function M.parse_buffer(lines)
lnum = i, lnum = i,
}) })
end end
elseif line:match('^## (.+)$') then elseif line:match('^# (.+)$') then
current_category = line:match('^## (.+)$') current_category = line:match('^# (.+)$')
table.insert(result, { type = 'header', category = current_category, lnum = i }) table.insert(result, { type = 'header', category = current_category, lnum = i })
end end
end end

View file

@ -133,7 +133,7 @@ function M.category_view(tasks)
table.insert(lines, '') table.insert(lines, '')
table.insert(meta, { type = 'blank' }) table.insert(meta, { type = 'blank' })
end end
table.insert(lines, '## ' .. cat) table.insert(lines, '# ' .. cat)
table.insert(meta, { type = 'header', category = cat }) table.insert(meta, { type = 'header', category = cat })
local all = {} local all = {}

View file

@ -21,11 +21,11 @@ describe('diff', function()
describe('parse_buffer', function() describe('parse_buffer', function()
it('parses headers and tasks', function() it('parses headers and tasks', function()
local lines = { local lines = {
'## School', '# School',
'/1/- [ ] Do homework', '/1/- [ ] Do homework',
'/2/- [!] Read chapter 5', '/2/- [!] Read chapter 5',
'', '',
'## Errands', '# Errands',
'/3/- [ ] Buy groceries', '/3/- [ ] Buy groceries',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -44,7 +44,7 @@ describe('diff', function()
it('handles new tasks without ids', function() it('handles new tasks without ids', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'- [ ] New task here', '- [ ] New task here',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -56,7 +56,7 @@ describe('diff', function()
it('inline cat: token overrides header category', function() it('inline cat: token overrides header category', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Buy milk cat:Work', '/1/- [ ] Buy milk cat:Work',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -67,7 +67,7 @@ describe('diff', function()
it('extracts rec: token from buffer line', function() it('extracts rec: token from buffer line', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Take trash out rec:weekly', '/1/- [ ] Take trash out rec:weekly',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -76,7 +76,7 @@ describe('diff', function()
it('extracts rec: with completion mode', function() it('extracts rec: with completion mode', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Water plants rec:!daily', '/1/- [ ] Water plants rec:!daily',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -86,7 +86,7 @@ describe('diff', function()
it('inline due: token is parsed', function() it('inline due: token is parsed', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Buy milk due:2026-03-15', '/1/- [ ] Buy milk due:2026-03-15',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
@ -99,7 +99,7 @@ describe('diff', function()
describe('apply', function() describe('apply', function()
it('creates new tasks from buffer lines', function() it('creates new tasks from buffer lines', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'- [ ] First task', '- [ ] First task',
'- [ ] Second task', '- [ ] Second task',
} }
@ -116,7 +116,7 @@ describe('diff', function()
s:add({ description = 'Delete me' }) s:add({ description = 'Delete me' })
s:save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Keep me', '/1/- [ ] Keep me',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -132,7 +132,7 @@ describe('diff', function()
s:add({ description = 'Original' }) s:add({ description = 'Original' })
s:save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Renamed', '/1/- [ ] Renamed',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -146,7 +146,7 @@ describe('diff', function()
t.modified = '2020-01-01T00:00:00Z' t.modified = '2020-01-01T00:00:00Z'
s:save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Renamed', '/1/- [ ] Renamed',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -160,7 +160,7 @@ describe('diff', function()
s:add({ description = 'Original' }) s:add({ description = 'Original' })
s:save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Original', '/1/- [ ] Original',
'/1/- [ ] Copy of original', '/1/- [ ] Copy of original',
} }
@ -174,7 +174,7 @@ describe('diff', function()
s:add({ description = 'Moving task', category = 'Inbox' }) s:add({ description = 'Moving task', category = 'Inbox' })
s:save() s:save()
local lines = { local lines = {
'## Work', '# Work',
'/1/- [ ] Moving task', '/1/- [ ] Moving task',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -187,7 +187,7 @@ describe('diff', function()
s:add({ description = 'Stable task', category = 'Inbox' }) s:add({ description = 'Stable task', category = 'Inbox' })
s:save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Stable task', '/1/- [ ] Stable task',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -203,7 +203,7 @@ describe('diff', function()
s:add({ description = 'Pay bill', due = '2026-03-15' }) s:add({ description = 'Pay bill', due = '2026-03-15' })
s:save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Pay bill', '/1/- [ ] Pay bill',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -214,7 +214,7 @@ describe('diff', function()
it('stores recur field on new tasks from buffer', function() it('stores recur field on new tasks from buffer', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'- [ ] Take out trash rec:weekly', '- [ ] Take out trash rec:weekly',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -228,7 +228,7 @@ describe('diff', function()
s:add({ description = 'Task', recur = 'daily' }) s:add({ description = 'Task', recur = 'daily' })
s:save() s:save()
local lines = { local lines = {
'## Todo', '# Todo',
'/1/- [ ] Task rec:weekly', '/1/- [ ] Task rec:weekly',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -241,7 +241,7 @@ describe('diff', function()
s:add({ description = 'Task', recur = 'daily' }) s:add({ description = 'Task', recur = 'daily' })
s:save() s:save()
local lines = { local lines = {
'## Todo', '# Todo',
'/1/- [ ] Task', '/1/- [ ] Task',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -252,7 +252,7 @@ describe('diff', function()
it('parses rec: with completion mode prefix', function() it('parses rec: with completion mode prefix', function()
local lines = { local lines = {
'## Inbox', '# Inbox',
'- [ ] Water plants rec:!weekly', '- [ ] Water plants rec:!weekly',
} }
diff.apply(lines, s) diff.apply(lines, s)
@ -266,7 +266,7 @@ describe('diff', function()
s:add({ description = 'Task name', priority = 1 }) s:add({ description = 'Task name', priority = 1 })
s:save() s:save()
local lines = { local lines = {
'## Inbox', '# Inbox',
'/1/- [ ] Task name', '/1/- [ ] Task name',
} }
diff.apply(lines, s) diff.apply(lines, s)

View file

@ -15,45 +15,42 @@ describe('icons', function()
it('has default icon values', function() it('has default icon values', function()
local icons = config.get().icons local icons = config.get().icons
assert.equals('-', icons.pending) assert.equals(' ', icons.pending)
assert.equals('x', icons.done) assert.equals('x', icons.done)
assert.equals('!', icons.priority) assert.equals('!', icons.priority)
assert.equals('>', icons.header)
assert.equals('.', icons.due) assert.equals('.', icons.due)
assert.equals('~', icons.recur) assert.equals('~', icons.recur)
assert.equals('#', icons.category) assert.equals('#', icons.category)
end) end)
it('allows overriding individual icons', function() it('allows overriding individual icons', function()
vim.g.pending = { icons = { pending = '', done = '' } } vim.g.pending = { icons = { pending = '*', done = '+' } }
config.reset() config.reset()
local icons = config.get().icons local icons = config.get().icons
assert.equals('', icons.pending) assert.equals('*', icons.pending)
assert.equals('', icons.done) assert.equals('+', icons.done)
assert.equals('!', icons.priority) assert.equals('!', icons.priority)
assert.equals('>', icons.header) assert.equals('#', icons.category)
end) end)
it('allows overriding all icons', function() it('allows overriding all icons', function()
vim.g.pending = { vim.g.pending = {
icons = { icons = {
pending = '-', pending = '-',
done = 'x', done = '+',
priority = '!', priority = '*',
header = '>',
due = '@', due = '@',
recur = '~', recur = '^',
category = '+', category = '&',
}, },
} }
config.reset() config.reset()
local icons = config.get().icons local icons = config.get().icons
assert.equals('-', icons.pending) assert.equals('-', icons.pending)
assert.equals('x', icons.done) assert.equals('+', icons.done)
assert.equals('!', icons.priority) assert.equals('*', icons.priority)
assert.equals('>', icons.header)
assert.equals('@', icons.due) assert.equals('@', icons.due)
assert.equals('~', icons.recur) assert.equals('^', icons.recur)
assert.equals('+', icons.category) assert.equals('&', icons.category)
end) end)
end) end)

View file

@ -26,7 +26,7 @@ describe('views', function()
s:add({ description = 'Task A', category = 'Work' }) s:add({ description = 'Task A', category = 'Work' })
s:add({ description = 'Task B', category = 'Work' }) s:add({ description = 'Task B', category = 'Work' })
local lines, meta = views.category_view(s:active_tasks()) local lines, meta = views.category_view(s:active_tasks())
assert.are.equal('## Work', lines[1]) assert.are.equal('# Work', lines[1])
assert.are.equal('header', meta[1].type) assert.are.equal('header', meta[1].type)
assert.is_true(lines[2]:find('Task A') ~= nil) assert.is_true(lines[2]:find('Task A') ~= nil)
assert.is_true(lines[3]:find('Task B') ~= nil) assert.is_true(lines[3]:find('Task B') ~= nil)
@ -243,8 +243,8 @@ describe('views', function()
end end
end end
end end
assert.are.equal('## Work', first_header) assert.are.equal('# Work', first_header)
assert.are.equal('## Inbox', second_header) assert.are.equal('# Inbox', second_header)
end) end)
it('appends categories not in category_order after ordered ones', function() it('appends categories not in category_order after ordered ones', function()
@ -259,8 +259,8 @@ describe('views', function()
table.insert(headers, lines[i]) table.insert(headers, lines[i])
end end
end end
assert.are.equal('## Work', headers[1]) assert.are.equal('# Work', headers[1])
assert.are.equal('## Errands', headers[2]) assert.are.equal('# Errands', headers[2])
end) end)
it('preserves insertion order when category_order is empty', function() it('preserves insertion order when category_order is empty', function()
@ -273,8 +273,8 @@ describe('views', function()
table.insert(headers, lines[i]) table.insert(headers, lines[i])
end end
end end
assert.are.equal('## Alpha', headers[1]) assert.are.equal('# Alpha', headers[1])
assert.are.equal('## Beta', headers[2]) assert.are.equal('# Beta', headers[2])
end) end)
end) end)