Compare commits

..

No commits in common. "ce00f28c9634540f10316e35c09eda4c0e0252e5" and "fc4a47a1ec0f7ebcd79b56cb4a1a61ccd1e63a03" have entirely different histories.

10 changed files with 123 additions and 94 deletions

View file

@ -56,9 +56,9 @@ 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 /^\S.*$/ contains=taskId
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskPriority /\[\d\+\] / contained containedin=taskLine
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority
]]) ]])
end) end)
end end
@ -72,8 +72,8 @@ function M.open_line(above)
local row = vim.api.nvim_win_get_cursor(0)[1] local row = vim.api.nvim_win_get_cursor(0)[1]
local insert_row = above and (row - 1) or row local insert_row = above and (row - 1) or row
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { ' ' })
vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 2 })
vim.cmd('startinsert!') vim.cmd('startinsert!')
end end
@ -113,18 +113,18 @@ local function apply_extmarks(bufnr, line_meta)
if virt_text then if virt_text then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = virt_text, virt_text = virt_text,
virt_text_pos = 'eol', virt_text_pos = 'right_align',
}) })
end end
elseif m.due then elseif m.due then
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
virt_text = { { m.due, due_hl } }, virt_text = { { m.due, due_hl } },
virt_text_pos = 'eol', virt_text_pos = 'right_align',
}) })
end end
if m.status == 'done' then if m.status == 'done' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' 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 local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) + 2 or 0
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, {
end_col = #line, end_col = #line,
hl_group = 'PendingDone', hl_group = 'PendingDone',
@ -168,11 +168,8 @@ function M.render(bufnr)
_meta = line_meta _meta = line_meta
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
local saved = vim.bo[bufnr].undolevels
vim.bo[bufnr].undolevels = -1
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modified = false vim.bo[bufnr].modified = false
vim.bo[bufnr].undolevels = saved
setup_syntax(bufnr) setup_syntax(bufnr)
apply_extmarks(bufnr, line_meta) apply_extmarks(bufnr, line_meta)

View file

@ -18,7 +18,7 @@ local M = {}
local defaults = { local defaults = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json', data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_view = 'category', default_view = 'category',
default_category = 'Todo', default_category = 'Inbox',
date_format = '%b %d', date_format = '%b %d',
date_syntax = 'due', date_syntax = 'due',
category_order = {}, category_order = {},

View file

@ -7,7 +7,6 @@ local store = require('pending.store')
---@field id? integer ---@field id? integer
---@field description? string ---@field description? string
---@field priority? integer ---@field priority? integer
---@field status? string
---@field category? string ---@field category? string
---@field due? string ---@field due? string
---@field lnum integer ---@field lnum integer
@ -27,17 +26,20 @@ function M.parse_buffer(lines)
local current_category = nil local current_category = nil
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
local id, body = line:match('^/(%d+)/(- %[.%] .*)$') local id, body = line:match('^/(%d+)/( .+)$')
if not id then if not id then
body = line:match('^(- %[.%] .*)$') body = line:match('^( .+)$')
end end
if line == '' then if line == '' then
table.insert(result, { type = 'blank', lnum = i }) table.insert(result, { type = 'blank', lnum = i })
elseif id or body then elseif id or body then
local stripped = body:match('^- %[.%] (.*)$') or body local stripped = body:match('^ (.+)$') or body
local state_char = body:match('^- %[(.-)%]') or ' ' local prio_str = stripped:match('^%[(%d+)%] ')
local priority = state_char == '!' and 1 or 0 local priority = 0
local status = state_char == 'x' and 'done' or 'pending' if prio_str then
priority = tonumber(prio_str)
stripped = stripped:sub(#prio_str + 4)
end
local description, metadata = parse.body(stripped) local description, metadata = parse.body(stripped)
if description and description ~= '' then if description and description ~= '' then
table.insert(result, { table.insert(result, {
@ -45,15 +47,14 @@ function M.parse_buffer(lines)
id = id and tonumber(id) or nil, id = id and tonumber(id) or nil,
description = description, description = description,
priority = priority, priority = priority,
status = status,
category = metadata.cat or current_category or config.get().default_category, category = metadata.cat or current_category or config.get().default_category,
due = metadata.due, due = metadata.due,
lnum = i, lnum = i,
}) })
end end
elseif line:match('^## (.+)$') then elseif line:match('^%S') then
current_category = line:match('^## (.+)$') current_category = line
table.insert(result, { type = 'header', category = current_category, lnum = i }) table.insert(result, { type = 'header', category = line, lnum = i })
end end
end end
@ -112,15 +113,6 @@ function M.apply(lines)
task.due = entry.due task.due = entry.due
changed = true changed = true
end end
if entry.status and task.status ~= entry.status then
task.status = entry.status
if entry.status == 'done' then
task['end'] = now
else
task['end'] = nil
end
changed = true
end
if task.order ~= order_counter then if task.order ~= order_counter then
task.order = order_counter task.order = order_counter
changed = true changed = true

View file

@ -52,8 +52,17 @@ function M._setup_buf_mappings(bufnr)
vim.keymap.set('n', 'g?', function() vim.keymap.set('n', 'g?', function()
M.show_help() M.show_help()
end, opts) end, opts)
vim.keymap.set('n', '!', function() vim.keymap.set('n', '<C-a>', function()
M.toggle_priority() M.change_priority(1)
end, opts)
vim.keymap.set('n', '<C-x>', function()
M.change_priority(-1)
end, opts)
vim.keymap.set('v', 'g<C-a>', function()
M.change_priority_visual(1)
end, opts)
vim.keymap.set('v', 'g<C-x>', function()
M.change_priority_visual(-1)
end, opts) end, opts)
vim.keymap.set('n', 'D', function() vim.keymap.set('n', 'D', function()
M.prompt_date() M.prompt_date()
@ -117,15 +126,10 @@ function M.toggle_complete()
end end
store.save() store.save()
buffer.render(bufnr) buffer.render(bufnr)
for lnum, m in ipairs(buffer.meta()) do
if m.id == id then
vim.api.nvim_win_set_cursor(0, { lnum, 0 })
break
end
end
end end
function M.toggle_priority() ---@param delta integer
function M.change_priority(delta)
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
return return
@ -143,7 +147,7 @@ function M.toggle_priority()
if not task then if not task then
return return
end end
local new_priority = task.priority > 0 and 0 or 1 local new_priority = math.max(0, task.priority + delta)
store.update(id, { priority = new_priority }) store.update(id, { priority = new_priority })
store.save() store.save()
buffer.render(bufnr) buffer.render(bufnr)
@ -155,6 +159,33 @@ function M.toggle_priority()
end end
end end
---@param delta integer
function M.change_priority_visual(delta)
local bufnr = buffer.bufnr()
if not bufnr then
return
end
local start_row = vim.fn.line("'<")
local end_row = vim.fn.line("'>")
local meta = buffer.meta()
local changed = false
for row = start_row, end_row do
local m = meta[row]
if m and m.type == 'task' and m.id then
local task = store.get(m.id)
if task then
local new_priority = math.max(0, task.priority + delta)
store.update(m.id, { priority = new_priority })
changed = true
end
end
end
if changed then
store.save()
buffer.render(bufnr)
end
end
function M.prompt_date() function M.prompt_date()
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
if not bufnr then if not bufnr then
@ -311,7 +342,10 @@ function M.show_help()
'', '',
'<CR> Toggle complete/uncomplete', '<CR> Toggle complete/uncomplete',
'<Tab> Switch category/priority view', '<Tab> Switch category/priority view',
'! Toggle urgent', '<C-a> Raise priority level',
'<C-x> Lower priority level',
'g<C-a> Raise priority for visual selection',
'g<C-x> Lower priority for visual selection',
'D Set due date', 'D Set due date',
'U Undo last write', 'U Undo last write',
'o / O Add new task line', 'o / O Add new task line',
@ -337,7 +371,7 @@ function M.show_help()
'', '',
'Highlights:', 'Highlights:',
' PendingOverdue overdue tasks (red)', ' PendingOverdue overdue tasks (red)',
' PendingPriority [!] urgent tasks', ' PendingPriority [N] priority prefix',
'', '',
'Press q or <Esc> to close', 'Press q or <Esc> to close',
} }

View file

@ -125,7 +125,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 = {}
@ -138,8 +138,9 @@ function M.category_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local indent = ' '
local line = prefix .. '- [' .. state .. '] ' .. task.description local prio = task.priority > 0 and ('[' .. task.priority .. '] ') or ''
local line = prefix .. indent .. prio .. task.description
table.insert(lines, line) table.insert(lines, line)
table.insert(meta, { table.insert(meta, {
type = 'task', type = 'task',
@ -188,8 +189,9 @@ function M.priority_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local indent = ' '
local line = prefix .. '- [' .. state .. '] ' .. task.description local prio = task.priority == 1 and '! ' or ''
local line = prefix .. indent .. prio .. task.description
table.insert(lines, line) table.insert(lines, line)
table.insert(meta, { table.insert(meta, {
type = 'task', type = 'task',

View file

@ -8,7 +8,7 @@ vim.api.nvim_create_user_command('Pending', function(opts)
end, { end, {
nargs = '*', nargs = '*',
complete = function(arg_lead, cmd_line) complete = function(arg_lead, cmd_line)
local subcmds = { 'add', 'sync', 'archive', 'due', 'undo' } local subcmds = { 'add', 'sync', 'archive' }
if not cmd_line:match('^Pending%s+%S') then if not cmd_line:match('^Pending%s+%S') then
return vim.tbl_filter(function(s) return vim.tbl_filter(function(s)
return s:find(arg_lead, 1, true) == 1 return s:find(arg_lead, 1, true) == 1
@ -30,8 +30,12 @@ vim.keymap.set('n', '<Plug>(pending-view)', function()
require('pending.buffer').toggle_view() require('pending.buffer').toggle_view()
end) end)
vim.keymap.set('n', '<Plug>(pending-priority)', function() vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
require('pending').toggle_priority() require('pending').change_priority(1)
end)
vim.keymap.set('n', '<Plug>(pending-priority-down)', function()
require('pending').change_priority(-1)
end) end)
vim.keymap.set('n', '<Plug>(pending-date)', function() vim.keymap.set('n', '<Plug>(pending-date)', function()

View file

@ -25,12 +25,12 @@ 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)
assert.are.equal(6, #result) assert.are.equal(6, #result)
@ -48,8 +48,8 @@ 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)
assert.are.equal(2, #result) assert.are.equal(2, #result)
@ -62,9 +62,9 @@ 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',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -80,8 +80,8 @@ describe('diff', function()
store.add({ description = 'Delete me' }) store.add({ description = 'Delete me' })
store.save() store.save()
local lines = { local lines = {
'## Inbox', 'Inbox',
'/1/- [ ] Keep me', '/1/ Keep me',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -97,8 +97,8 @@ describe('diff', function()
store.add({ description = 'Original' }) store.add({ description = 'Original' })
store.save() store.save()
local lines = { local lines = {
'## Inbox', 'Inbox',
'/1/- [ ] Renamed', '/1/ Renamed',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -111,9 +111,9 @@ describe('diff', function()
store.add({ description = 'Original' }) store.add({ description = 'Original' })
store.save() store.save()
local lines = { local lines = {
'## Inbox', 'Inbox',
'/1/- [ ] Original', '/1/ Original',
'/1/- [ ] Copy of original', '/1/ Copy of original',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -126,8 +126,8 @@ describe('diff', function()
store.add({ description = 'Moving task', category = 'Inbox' }) store.add({ description = 'Moving task', category = 'Inbox' })
store.save() store.save()
local lines = { local lines = {
'## Work', 'Work',
'/1/- [ ] Moving task', '/1/ Moving task',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -140,8 +140,8 @@ describe('diff', function()
store.add({ description = 'Stable task', category = 'Inbox' }) store.add({ description = 'Stable task', category = 'Inbox' })
store.save() store.save()
local lines = { local lines = {
'## Inbox', 'Inbox',
'/1/- [ ] Stable task', '/1/ Stable task',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -158,8 +158,8 @@ describe('diff', function()
store.add({ description = 'Pay bill', due = '2026-03-15' }) store.add({ description = 'Pay bill', due = '2026-03-15' })
store.save() store.save()
local lines = { local lines = {
'## Inbox', 'Inbox',
'/1/- [ ] Pay bill', '/1/ Pay bill',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()
@ -172,8 +172,8 @@ describe('diff', function()
store.add({ description = 'Task name', priority = 1 }) store.add({ description = 'Task name', priority = 1 })
store.save() store.save()
local lines = { local lines = {
'## Inbox', 'Inbox',
'/1/- [ ] Task name', '/1/ Task name',
} }
diff.apply(lines) diff.apply(lines)
store.unload() store.unload()

View file

@ -92,7 +92,7 @@ describe('store', function()
assert.are.equal(1, t1.id) assert.are.equal(1, t1.id)
assert.are.equal(2, t2.id) assert.are.equal(2, t2.id)
assert.are.equal('pending', t1.status) assert.are.equal('pending', t1.status)
assert.are.equal('Todo', t1.category) assert.are.equal('Inbox', t1.category)
end) end)
it('uses provided category', function() it('uses provided category', function()

View file

@ -27,7 +27,7 @@ describe('views', function()
store.add({ description = 'Task A', category = 'Work' }) store.add({ description = 'Task A', category = 'Work' })
store.add({ description = 'Task B', category = 'Work' }) store.add({ description = 'Task B', category = 'Work' })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(store.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)
@ -113,10 +113,10 @@ describe('views', function()
task_line = lines[i] task_line = lines[i]
end end
end end
assert.are.equal('/1/- [ ] My task', task_line) assert.are.equal('/1/ My task', task_line)
end) end)
it('formats priority task lines as /ID/- [!] description', function() it('formats priority task lines as /ID/ ! description', function()
store.add({ description = 'Important', category = 'Inbox', priority = 1 }) store.add({ description = 'Important', category = 'Inbox', priority = 1 })
local lines, meta = views.category_view(store.active_tasks()) local lines, meta = views.category_view(store.active_tasks())
local task_line local task_line
@ -125,7 +125,7 @@ describe('views', function()
task_line = lines[i] task_line = lines[i]
end end
end end
assert.are.equal('/1/- [!] Important', task_line) assert.are.equal('/1/ ! Important', task_line)
end) end)
it('sets LineMeta type=header for header lines with correct category', function() it('sets LineMeta type=header for header lines with correct category', function()
@ -220,8 +220,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()
@ -236,8 +236,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()
@ -250,8 +250,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)
@ -325,10 +325,10 @@ describe('views', function()
assert.is_true(earlier_row < later_row) assert.is_true(earlier_row < later_row)
end) end)
it('formats task lines as /ID/- [ ] description', function() it('formats task lines as /ID/ description', function()
store.add({ description = 'My task', category = 'Inbox' }) store.add({ description = 'My task', category = 'Inbox' })
local lines, _ = views.priority_view(store.active_tasks()) local lines, _ = views.priority_view(store.active_tasks())
assert.are.equal('/1/- [ ] My task', lines[1]) assert.are.equal('/1/ My task', lines[1])
end) end)
it('sets show_category=true for all task meta entries', function() it('sets show_category=true for all task meta entries', function()

View file

@ -3,12 +3,12 @@ if exists('b:current_syntax')
endif endif
syntax match taskId /^\/\d\+\// conceal syntax match taskId /^\/\d\+\// conceal
syntax match taskHeader /^## .*$/ contains=taskId syntax match taskHeader /^\S.*$/ contains=taskId
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskPriority /!\ze / contained
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority
highlight default link taskHeader PendingHeader highlight default link taskHeader PendingHeader
highlight default link taskCheckbox PendingPriority highlight default link taskPriority PendingPriority
highlight default link taskLine Normal highlight default link taskLine Normal
let b:current_syntax = 'task' let b:current_syntax = 'task'