From 3e8fd0a6a34475016b62cb41e85e3454025e8a41 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:44:41 -0500 Subject: [PATCH] refactor(icons): ascii defaults, checkbox overlays, and cleanup (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: remove unnecessary mini.ai recipe from vimdoc Problem: the `*pending-mini-ai*` section assumed mini.ai intercepts buffer-local `at`/`it`/`aC`/`iC` mappings, requiring a manual `vim.b.miniai_config` workaround. Solution: remove the section. Neovim's keymap resolver already prioritizes longer buffer-local mappings over mini.ai's global `a`/`i` handlers — no recipe needed. * 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. * ci: remove empty `assets/` placeholder --- README.md | 8 ++-- assets/.gitkeep | 0 doc/pending.txt | 105 ++++------------------------------------- lua/pending/buffer.lua | 7 ++- lua/pending/config.lua | 4 +- lua/pending/diff.lua | 4 +- lua/pending/views.lua | 2 +- spec/diff_spec.lua | 40 ++++++++-------- spec/icons_spec.lua | 29 +++++------- spec/views_spec.lua | 14 +++--- 10 files changed, 61 insertions(+), 152 deletions(-) delete mode 100644 assets/.gitkeep diff --git a/README.md b/README.md index 5d57bcb..cb3d3eb 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,18 @@ luarocks install pending.nvim ## 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 vim.g.pending = { icons = { - pending = '-', done = 'x', priority = '!', - header = '>', due = '.', recur = '~', category = '#', + pending = ' ', done = 'x', priority = '!', + due = '.', recur = '~', category = '#', }, } ``` -See `:help pending.Icons` for unicode and nerd font examples. +See `:help pending.Icons` for nerd font examples. ## Acknowledgements diff --git a/assets/.gitkeep b/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/doc/pending.txt b/doc/pending.txt index b811288..01728a3 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -663,14 +663,18 @@ Fields: ~ table. Currently only `gcal` is built-in. {icons} (table) *pending.Icons* - Icon characters displayed in the buffer. Fields: - {pending} Uncompleted task icon. Default: '-' - {done} Completed task icon. Default: 'x' - {priority} Priority task icon. Default: '!' - {header} Category header prefix. Default: '>' + Icon characters displayed in the buffer. The + {pending}, {done}, and {priority} characters + appear inside brackets (`[icon]`) as an overlay + on the checkbox. The {category} character + 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: '.' {recur} Recurrence prefix. Default: '~' - {category} Category label prefix. Default: '#' + {category} Category prefix. Default: '#' ============================================================================== STORE RESOLUTION *pending-store-resolution* @@ -844,84 +848,9 @@ Event-driven statusline refresh: >lua }) < -mini.ai integration: ~ *pending-mini-ai* -mini.ai (from mini.nvim) maps `a` and `i` as single-key handlers in -operator-pending and visual modes. It captures the next keystroke internally -rather than routing it through Neovim's mapping system, which means the -buffer-local `at`, `it`, `aC`, and `iC` maps never fire for users who have -mini.ai installed. - -The fix is to register pending.nvim's text objects as mini.ai custom -textobjects via `vim.b.miniai_config` in a `FileType` autocmd. mini.ai's -`custom_textobjects` spec expects each entry to be a function returning -`{ from = { line, col }, to = { line, col } }` (1-indexed, col is -byte-offset from 1). - -pending.nvim's `textobj.inner_task_range(line)` returns the start and end -column offsets within the current line. Combine it with the cursor row and -the buffer line to build the region tables mini.ai expects: >lua - - vim.api.nvim_create_autocmd('FileType', { - pattern = 'pending', - callback = function() - local function task_inner() - local textobj = require('pending.textobj') - local row = vim.api.nvim_win_get_cursor(0)[1] - local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] - if not line then return end - local s, e = textobj.inner_task_range(line) - if s > e then return end - return { from = { line = row, col = s }, to = { line = row, col = e } } - end - - local function category_inner() - local textobj = require('pending.textobj') - local buffer = require('pending.buffer') - local meta = buffer.meta() - if not meta then return end - local row = vim.api.nvim_win_get_cursor(0)[1] - local header_row, last_row = textobj.category_bounds(row, meta) - if not header_row then return end - local first_task, last_task - for r = header_row + 1, last_row do - if meta[r] and meta[r].type == 'task' then - if not first_task then first_task = r end - last_task = r - end - end - if not first_task then return end - local first_line = vim.api.nvim_buf_get_lines(0, first_task - 1, first_task, false)[1] or '' - local last_line = vim.api.nvim_buf_get_lines(0, last_task - 1, last_task, false)[1] or '' - return { - from = { line = first_task, col = 1 }, - to = { line = last_task, col = #last_line }, - } - end - - vim.b.miniai_config = { - custom_textobjects = { t = task_inner, C = category_inner }, - } - end, - }) -< - -Note that the default `keymaps.a_task = 'at'` and friends still work in -standard Neovim operator-pending mode for users who do not have mini.ai. The -`vim.b.miniai_config` block is only needed when mini.ai is active. - -`aC` (outer category) is not exposed here because mini.ai does not support -the linewise selection that `aC` requires. Use the buffer-local `aC` key -directly, or disable `a_category` in `keymaps` and handle it via a -`vim.b.miniai_config` entry that returns a linewise region if mini.ai's -spec allows it in your version. - Nerd font icons: >lua vim.g.pending = { icons = { - pending = '', - done = '', - priority = '', - header = '', due = '', recur = '󰁯', category = '', @@ -929,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 vim.api.nvim_create_autocmd('VimEnter', { callback = function() diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 8b661a0..8fdcbe1 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -115,7 +115,7 @@ local function setup_syntax(bufnr) vim.cmd([[ syntax clear syntax match taskId /^\/\d\+\// conceal - syntax match taskHeader /^## .*$/ contains=taskId + syntax match taskHeader /^# .*$/ contains=taskId syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox ]]) @@ -205,9 +205,8 @@ local function apply_extmarks(bufnr, line_meta) else icon, icon_hl = icons.pending, 'Normal' end - local icon_padded = icon .. ' ' 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', priority = 100, }) @@ -218,7 +217,7 @@ local function apply_extmarks(bufnr, line_meta) hl_group = 'PendingHeader', }) 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', priority = 100, }) diff --git a/lua/pending/config.lua b/lua/pending/config.lua index dfc3052..09c5cf0 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -2,7 +2,6 @@ ---@field pending string ---@field done string ---@field priority string ----@field header string ---@field due string ---@field recur string ---@field category string @@ -82,10 +81,9 @@ local defaults = { }, sync = {}, icons = { - pending = '-', + pending = ' ', done = 'x', priority = '!', - header = '>', due = '.', recur = '~', category = '#', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index e5a93e5..7ebbfe1 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -59,8 +59,8 @@ function M.parse_buffer(lines) lnum = i, }) end - elseif line:match('^## (.+)$') then - current_category = line:match('^## (.+)$') + elseif line:match('^# (.+)$') then + current_category = line:match('^# (.+)$') table.insert(result, { type = 'header', category = current_category, lnum = i }) end end diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 286db9a..87fcee1 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -133,7 +133,7 @@ function M.category_view(tasks) table.insert(lines, '') table.insert(meta, { type = 'blank' }) end - table.insert(lines, '## ' .. cat) + table.insert(lines, '# ' .. cat) table.insert(meta, { type = 'header', category = cat }) local all = {} diff --git a/spec/diff_spec.lua b/spec/diff_spec.lua index 2322ded..c2a0406 100644 --- a/spec/diff_spec.lua +++ b/spec/diff_spec.lua @@ -21,11 +21,11 @@ describe('diff', function() describe('parse_buffer', function() it('parses headers and tasks', function() local lines = { - '## School', + '# School', '/1/- [ ] Do homework', '/2/- [!] Read chapter 5', '', - '## Errands', + '# Errands', '/3/- [ ] Buy groceries', } local result = diff.parse_buffer(lines) @@ -44,7 +44,7 @@ describe('diff', function() it('handles new tasks without ids', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] New task here', } local result = diff.parse_buffer(lines) @@ -56,7 +56,7 @@ describe('diff', function() it('inline cat: token overrides header category', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Buy milk cat:Work', } local result = diff.parse_buffer(lines) @@ -67,7 +67,7 @@ describe('diff', function() it('extracts rec: token from buffer line', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Take trash out rec:weekly', } local result = diff.parse_buffer(lines) @@ -76,7 +76,7 @@ describe('diff', function() it('extracts rec: with completion mode', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Water plants rec:!daily', } local result = diff.parse_buffer(lines) @@ -86,7 +86,7 @@ describe('diff', function() it('inline due: token is parsed', function() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Buy milk due:2026-03-15', } local result = diff.parse_buffer(lines) @@ -99,7 +99,7 @@ describe('diff', function() describe('apply', function() it('creates new tasks from buffer lines', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] First task', '- [ ] Second task', } @@ -116,7 +116,7 @@ describe('diff', function() s:add({ description = 'Delete me' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Keep me', } diff.apply(lines, s) @@ -132,7 +132,7 @@ describe('diff', function() s:add({ description = 'Original' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Renamed', } diff.apply(lines, s) @@ -146,7 +146,7 @@ describe('diff', function() t.modified = '2020-01-01T00:00:00Z' s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Renamed', } diff.apply(lines, s) @@ -160,7 +160,7 @@ describe('diff', function() s:add({ description = 'Original' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Original', '/1/- [ ] Copy of original', } @@ -174,7 +174,7 @@ describe('diff', function() s:add({ description = 'Moving task', category = 'Inbox' }) s:save() local lines = { - '## Work', + '# Work', '/1/- [ ] Moving task', } diff.apply(lines, s) @@ -187,7 +187,7 @@ describe('diff', function() s:add({ description = 'Stable task', category = 'Inbox' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Stable task', } diff.apply(lines, s) @@ -203,7 +203,7 @@ describe('diff', function() s:add({ description = 'Pay bill', due = '2026-03-15' }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Pay bill', } diff.apply(lines, s) @@ -214,7 +214,7 @@ describe('diff', function() it('stores recur field on new tasks from buffer', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] Take out trash rec:weekly', } diff.apply(lines, s) @@ -228,7 +228,7 @@ describe('diff', function() s:add({ description = 'Task', recur = 'daily' }) s:save() local lines = { - '## Todo', + '# Todo', '/1/- [ ] Task rec:weekly', } diff.apply(lines, s) @@ -241,7 +241,7 @@ describe('diff', function() s:add({ description = 'Task', recur = 'daily' }) s:save() local lines = { - '## Todo', + '# Todo', '/1/- [ ] Task', } diff.apply(lines, s) @@ -252,7 +252,7 @@ describe('diff', function() it('parses rec: with completion mode prefix', function() local lines = { - '## Inbox', + '# Inbox', '- [ ] Water plants rec:!weekly', } diff.apply(lines, s) @@ -266,7 +266,7 @@ describe('diff', function() s:add({ description = 'Task name', priority = 1 }) s:save() local lines = { - '## Inbox', + '# Inbox', '/1/- [ ] Task name', } diff.apply(lines, s) diff --git a/spec/icons_spec.lua b/spec/icons_spec.lua index d6569cc..47b518c 100644 --- a/spec/icons_spec.lua +++ b/spec/icons_spec.lua @@ -15,45 +15,42 @@ describe('icons', function() it('has default icon values', function() local icons = config.get().icons - assert.equals('-', icons.pending) + assert.equals(' ', icons.pending) assert.equals('x', icons.done) assert.equals('!', icons.priority) - assert.equals('>', icons.header) assert.equals('.', icons.due) assert.equals('~', icons.recur) assert.equals('#', icons.category) end) it('allows overriding individual icons', function() - vim.g.pending = { icons = { pending = '○', done = '✓' } } + vim.g.pending = { icons = { pending = '*', done = '+' } } config.reset() local icons = config.get().icons - assert.equals('○', icons.pending) - assert.equals('✓', icons.done) + assert.equals('*', icons.pending) + assert.equals('+', icons.done) assert.equals('!', icons.priority) - assert.equals('>', icons.header) + assert.equals('#', icons.category) end) it('allows overriding all icons', function() vim.g.pending = { icons = { pending = '-', - done = 'x', - priority = '!', - header = '>', + done = '+', + priority = '*', due = '@', - recur = '~', - category = '+', + recur = '^', + category = '&', }, } config.reset() local icons = config.get().icons assert.equals('-', icons.pending) - assert.equals('x', icons.done) - assert.equals('!', icons.priority) - assert.equals('>', icons.header) + assert.equals('+', icons.done) + assert.equals('*', icons.priority) assert.equals('@', icons.due) - assert.equals('~', icons.recur) - assert.equals('+', icons.category) + assert.equals('^', icons.recur) + assert.equals('&', icons.category) end) end) diff --git a/spec/views_spec.lua b/spec/views_spec.lua index c9785f9..ede9de9 100644 --- a/spec/views_spec.lua +++ b/spec/views_spec.lua @@ -26,7 +26,7 @@ describe('views', function() s:add({ description = 'Task A', category = 'Work' }) s:add({ description = 'Task B', category = 'Work' }) 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.is_true(lines[2]:find('Task A') ~= nil) assert.is_true(lines[3]:find('Task B') ~= nil) @@ -243,8 +243,8 @@ describe('views', function() end end end - assert.are.equal('## Work', first_header) - assert.are.equal('## Inbox', second_header) + assert.are.equal('# Work', first_header) + assert.are.equal('# Inbox', second_header) end) it('appends categories not in category_order after ordered ones', function() @@ -259,8 +259,8 @@ describe('views', function() table.insert(headers, lines[i]) end end - assert.are.equal('## Work', headers[1]) - assert.are.equal('## Errands', headers[2]) + assert.are.equal('# Work', headers[1]) + assert.are.equal('# Errands', headers[2]) end) it('preserves insertion order when category_order is empty', function() @@ -273,8 +273,8 @@ describe('views', function() table.insert(headers, lines[i]) end end - assert.are.equal('## Alpha', headers[1]) - assert.are.equal('## Beta', headers[2]) + assert.are.equal('# Alpha', headers[1]) + assert.are.equal('# Beta', headers[2]) end) end)