diff --git a/doc/pending.txt b/doc/pending.txt index d3cf136..aad924c 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -34,7 +34,7 @@ Features: ~ - Relative date input: `today`, `tomorrow`, `+Nd`, `+Nw`, `+Nm`, weekday names, month names, ordinals, and more - Recurring tasks with automatic next-date spawning on completion -- Two views: category (default) and queue (priority-sorted flat list) +- Two views: category (default) and priority flat list - Multi-level undo (up to 20 `:w` saves, persisted across sessions) - Quick-add from the command line with `:Pending add` - Quickfix list of overdue/due-today tasks via `:Pending due` @@ -278,41 +278,13 @@ Default buffer-local keys: ~ `` Toggle complete / uncomplete (`toggle`) `!` Toggle the priority flag (`priority`) `D` Prompt for a due date (`date`) - `` Switch between category / queue view (`view`) + `` Switch between category / priority view (`view`) `U` Undo the last `:w` save (`undo`) `o` Insert a new task line below (`open_line`) `O` Insert a new task line above (`open_line_above`) `zc` Fold the current category section (category view only) `zo` Unfold the current category section (category view only) -Text objects (operator-pending and visual): ~ - - Key Action ~ - ------- ------------------------------------------------ - `at` Select the current task line (`a_task`) - `it` Select the task description only (`i_task`) - `aC` Select a category: header + tasks + blanks (`a_category`) - `iC` Select inner category: tasks only (`i_category`) - -`at` supports count: `d3at` deletes three consecutive tasks. `it` selects -the description text between the checkbox prefix and trailing metadata -tokens (`due:`, `cat:`, `rec:`), making `cit` the natural way to retype a -task description without touching its metadata. - -`aC` and `iC` are no-ops in the queue view (no headers to delimit). - -Motions (normal, visual, operator-pending): ~ - - Key Action ~ - ------- ------------------------------------------------ - `]]` Jump to the next category header (`next_header`) - `[[` Jump to the previous category header (`prev_header`) - `]t` Jump to the next task line (`next_task`) - `[t` Jump to the previous task line (`prev_task`) - -All motions support count: `3]]` jumps three headers forward. `]]` and -`[[` are no-ops in the queue view. `]t` and `[t` work in both views. - `dd`, `p`, `P`, and `:w` work as standard Vim operations. *(pending-open)* @@ -351,38 +323,6 @@ All motions support count: `3]]` jumps three headers forward. `]]` and (pending-open-line-above) Insert a correctly-formatted blank task line above the cursor. - *(pending-a-task)* -(pending-a-task) - Select the current task line (linewise). Supports count. - - *(pending-i-task)* -(pending-i-task) - Select the task description text (characterwise). - - *(pending-a-category)* -(pending-a-category) - Select a full category section: header, tasks, and surrounding blanks. - - *(pending-i-category)* -(pending-i-category) - Select tasks within a category, excluding the header and blanks. - - *(pending-next-header)* -(pending-next-header) - Jump to the next category header. Supports count. - - *(pending-prev-header)* -(pending-prev-header) - Jump to the previous category header. Supports count. - - *(pending-next-task)* -(pending-next-task) - Jump to the next task line, skipping headers and blanks. - - *(pending-prev-task)* -(pending-prev-task) - Jump to the previous task line, skipping headers and blanks. - Example configuration: >lua vim.keymap.set('n', 't', '(pending-open)') vim.keymap.set('n', 'T', '(pending-toggle)') @@ -401,12 +341,12 @@ Category view (default): ~ *pending-view-category* first within each group. Category sections are foldable with `zc` and `zo`. -Queue view: ~ *pending-view-queue* +Priority view: ~ *pending-view-priority* A flat list of all tasks sorted by priority, then by due date (tasks without a due date sort last), then by internal order. Done tasks appear after all pending tasks. Category names are shown as right-aligned virtual text alongside the due date virtual text so tasks remain identifiable - across categories. The buffer is named `pending://queue`. + across categories. ============================================================================== CONFIGURATION *pending-config* @@ -431,14 +371,6 @@ loads: >lua undo = 'U', open_line = 'o', open_line_above = 'O', - a_task = 'at', - i_task = 'it', - a_category = 'aC', - i_category = 'iC', - next_header = ']]', - prev_header = '[[', - next_task = ']t', - prev_task = '[t', }, gcal = { calendar = 'Tasks', @@ -497,17 +429,6 @@ Fields: ~ See |pending-mappings| for the full list of actions and their default keys. - {debug} (boolean, default: false) - Enable diagnostic logging. When `true`, textobj - motions, mapping registration, and cursor jumps - emit messages at `vim.log.levels.DEBUG`. Use - |:messages| to inspect the output. Useful for - diagnosing keymap conflicts (e.g. `]t` colliding - with Neovim defaults) or motion misbehavior. - Example: >lua - vim.g.pending = { debug = true } -< - {gcal} (table, default: nil) Google Calendar sync configuration. See |pending.GcalConfig|. Omit this field entirely to diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index a427b68..4738830 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -223,8 +223,7 @@ function M.render(bufnr) end current_view = current_view or config.get().default_view - local view_label = current_view == 'priority' and 'queue' or current_view - vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label) + vim.api.nvim_buf_set_name(bufnr, 'pending://' .. current_view) local tasks = store.active_tasks() local lines, line_meta diff --git a/lua/pending/config.lua b/lua/pending/config.lua index ac98b64..ec89cb2 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -11,14 +11,6 @@ ---@field undo? string|false ---@field open_line? string|false ---@field open_line_above? string|false ----@field a_task? string|false ----@field i_task? string|false ----@field a_category? string|false ----@field i_category? string|false ----@field next_header? string|false ----@field prev_header? string|false ----@field next_task? string|false ----@field prev_task? string|false ---@class pending.Config ---@field data_path string @@ -30,7 +22,6 @@ ---@field someday_date string ---@field category_order? string[] ---@field drawer_height? integer ----@field debug? boolean ---@field keymaps pending.Keymaps ---@field gcal? pending.GcalConfig @@ -56,14 +47,6 @@ local defaults = { undo = 'U', open_line = 'o', open_line_above = 'O', - a_task = 'at', - i_task = 'it', - a_category = 'aC', - i_category = 'iC', - next_header = ']]', - prev_header = '[[', - next_task = ']t', - prev_task = '[t', }, } diff --git a/lua/pending/init.lua b/lua/pending/init.lua index d176646..631c0e3 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -88,72 +88,6 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', key --[[@as string]], fn, opts) end end - - local textobj = require('pending.textobj') - - ---@type table - local textobjs = { - a_task = { - modes = { 'o', 'x' }, - fn = textobj.a_task, - visual_fn = textobj.a_task_visual, - }, - i_task = { - modes = { 'o', 'x' }, - fn = textobj.i_task, - visual_fn = textobj.i_task_visual, - }, - a_category = { - modes = { 'o', 'x' }, - fn = textobj.a_category, - visual_fn = textobj.a_category_visual, - }, - i_category = { - modes = { 'o', 'x' }, - fn = textobj.i_category, - visual_fn = textobj.i_category_visual, - }, - } - - for name, spec in pairs(textobjs) do - local key = km[name] - if key and key ~= false then - for _, mode in ipairs(spec.modes) do - if mode == 'x' and spec.visual_fn then - vim.keymap.set(mode, key --[[@as string]], function() - spec.visual_fn(vim.v.count1) - end, opts) - else - vim.keymap.set(mode, key --[[@as string]], function() - spec.fn(vim.v.count1) - end, opts) - end - end - end - end - - ---@type table - local motions = { - next_header = textobj.next_header, - prev_header = textobj.prev_header, - next_task = textobj.next_task, - prev_task = textobj.prev_task, - } - - for name, fn in pairs(motions) do - local key = km[name] - if cfg.debug then - vim.notify( - ('[pending] mapping motion %s → %s (buf=%d)'):format(name, key or 'nil', bufnr), - vim.log.levels.INFO - ) - end - if key and key ~= false then - vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function() - fn(vim.v.count1) - end, opts) - end - end end ---@param bufnr integer diff --git a/lua/pending/textobj.lua b/lua/pending/textobj.lua deleted file mode 100644 index 62d6db3..0000000 --- a/lua/pending/textobj.lua +++ /dev/null @@ -1,384 +0,0 @@ -local buffer = require('pending.buffer') -local config = require('pending.config') - ----@class pending.textobj -local M = {} - ----@param ... any ----@return nil -local function dbg(...) - if config.get().debug then - vim.notify('[pending.textobj] ' .. string.format(...), vim.log.levels.INFO) - end -end - ----@param lnum integer ----@param meta pending.LineMeta[] ----@return string -local function get_line_from_buf(lnum, meta) - local _ = meta - local bufnr = buffer.bufnr() - if not bufnr then - return '' - end - local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false) - return lines[1] or '' -end - ----@param line string ----@return integer start_col ----@return integer end_col -function M.inner_task_range(line) - local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] ')) - if not prefix_end then - prefix_end = select(2, line:find('^%- %[.%] ')) or 0 - end - local start_col = prefix_end + 1 - - local dk = config.get().date_syntax or 'due' - local rk = config.get().recur_syntax or 'rec' - local dk_pat = '^' .. vim.pesc(dk) .. ':%S+$' - local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$' - - local rest = line:sub(start_col) - local words = {} - for word in rest:gmatch('%S+') do - table.insert(words, word) - end - - local i = #words - while i >= 1 do - local word = words[i] - if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then - i = i - 1 - else - break - end - end - - if i < 1 then - return start_col, start_col - end - - local desc = table.concat(words, ' ', 1, i) - local end_col = start_col + #desc - 1 - return start_col, end_col -end - ----@param row integer ----@param meta pending.LineMeta[] ----@return integer? header_row ----@return integer? last_row -function M.category_bounds(row, meta) - if not meta or #meta == 0 then - return nil, nil - end - - local header_row = nil - local m = meta[row] - if not m then - return nil, nil - end - - if m.type == 'header' then - header_row = row - else - for r = row, 1, -1 do - if meta[r] and meta[r].type == 'header' then - header_row = r - break - end - end - end - - if not header_row then - return nil, nil - end - - local last_row = header_row - local total = #meta - for r = header_row + 1, total do - if meta[r].type == 'header' then - break - end - last_row = r - end - - return header_row, last_row -end - ----@param count integer ----@return nil -function M.a_task(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local m = meta[row] - if not m or m.type ~= 'task' then - return - end - - local start_row = row - local end_row = row - count = math.max(1, count) - for _ = 2, count do - local next_row = end_row + 1 - if next_row > #meta then - break - end - if meta[next_row] and meta[next_row].type == 'task' then - end_row = next_row - else - break - end - end - - vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') -end - ----@param count integer ----@return nil -function M.a_task_visual(count) - vim.cmd('normal! \27') - M.a_task(count) -end - ----@param count integer ----@return nil -function M.i_task(count) - local _ = count - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local row = vim.api.nvim_win_get_cursor(0)[1] - local m = meta[row] - if not m or m.type ~= 'task' then - return - end - - local line = get_line_from_buf(row, meta) - local start_col, end_col = M.inner_task_range(line) - if start_col > end_col then - return - end - - vim.api.nvim_win_set_cursor(0, { row, start_col - 1 }) - vim.cmd('normal! v') - vim.api.nvim_win_set_cursor(0, { row, end_col - 1 }) -end - ----@param count integer ----@return nil -function M.i_task_visual(count) - vim.cmd('normal! \27') - M.i_task(count) -end - ----@param count integer ----@return nil -function M.a_category(count) - local _ = count - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local view = buffer.current_view_name() - if view == 'priority' then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - local header_row, last_row = M.category_bounds(row, meta) - if not header_row or not last_row then - return - end - - local start_row = header_row - if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then - start_row = header_row - 1 - end - local end_row = last_row - if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then - end_row = last_row + 1 - end - - vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') -end - ----@param count integer ----@return nil -function M.a_category_visual(count) - vim.cmd('normal! \27') - M.a_category(count) -end - ----@param count integer ----@return nil -function M.i_category(count) - local _ = count - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local view = buffer.current_view_name() - if view == 'priority' then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - local header_row, last_row = M.category_bounds(row, meta) - if not header_row or not last_row then - return - end - - local first_task = nil - local last_task = nil - 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 or not last_task then - return - end - - vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G') -end - ----@param count integer ----@return nil -function M.i_category_visual(count) - vim.cmd('normal! \27') - M.i_category(count) -end - ----@param count integer ----@return nil -function M.next_header(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local view = buffer.current_view_name() - if view == 'priority' then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil') - local found = 0 - count = math.max(1, count) - for r = row + 1, #meta do - if meta[r] and meta[r].type == 'header' then - found = found + 1 - dbg( - 'next_header: found header at row=%d, cat=%s, found=%d/%d', - r, - meta[r].category or '?', - found, - count - ) - if found == count then - vim.api.nvim_win_set_cursor(0, { r, 0 }) - dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1]) - return - end - else - dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil') - end - end - dbg('next_header: no header found after row=%d', row) -end - ----@param count integer ----@return nil -function M.prev_header(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - local view = buffer.current_view_name() - if view == 'priority' then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - dbg('prev_header: cursor=%d, meta_len=%d', row, #meta) - local found = 0 - count = math.max(1, count) - for r = row - 1, 1, -1 do - if meta[r] and meta[r].type == 'header' then - found = found + 1 - dbg( - 'prev_header: found header at row=%d, cat=%s, found=%d/%d', - r, - meta[r].category or '?', - found, - count - ) - if found == count then - vim.api.nvim_win_set_cursor(0, { r, 0 }) - return - end - end - end -end - ----@param count integer ----@return nil -function M.next_task(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - dbg('next_task: cursor=%d, meta_len=%d', row, #meta) - local found = 0 - count = math.max(1, count) - for r = row + 1, #meta do - if meta[r] and meta[r].type == 'task' then - found = found + 1 - if found == count then - dbg('next_task: jumping to row=%d', r) - vim.api.nvim_win_set_cursor(0, { r, 0 }) - return - end - end - end - dbg('next_task: no task found after row=%d', row) -end - ----@param count integer ----@return nil -function M.prev_task(count) - local meta = buffer.meta() - if not meta or #meta == 0 then - return - end - - local row = vim.api.nvim_win_get_cursor(0)[1] - dbg('prev_task: cursor=%d, meta_len=%d', row, #meta) - local found = 0 - count = math.max(1, count) - for r = row - 1, 1, -1 do - if meta[r] and meta[r].type == 'task' then - found = found + 1 - if found == count then - dbg('prev_task: jumping to row=%d', r) - vim.api.nvim_win_set_cursor(0, { r, 0 }) - return - end - end - end - dbg('prev_task: no task found before row=%d', row) -end - -return M diff --git a/plugin/pending.lua b/plugin/pending.lua index a239c7a..bfacfec 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -54,35 +54,3 @@ end) vim.keymap.set('n', '(pending-open-line-above)', function() require('pending.buffer').open_line(true) end) - -vim.keymap.set({ 'o', 'x' }, '(pending-a-task)', function() - require('pending.textobj').a_task(vim.v.count1) -end) - -vim.keymap.set({ 'o', 'x' }, '(pending-i-task)', function() - require('pending.textobj').i_task(vim.v.count1) -end) - -vim.keymap.set({ 'o', 'x' }, '(pending-a-category)', function() - require('pending.textobj').a_category(vim.v.count1) -end) - -vim.keymap.set({ 'o', 'x' }, '(pending-i-category)', function() - require('pending.textobj').i_category(vim.v.count1) -end) - -vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-header)', function() - require('pending.textobj').next_header(vim.v.count1) -end) - -vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-header)', function() - require('pending.textobj').prev_header(vim.v.count1) -end) - -vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-task)', function() - require('pending.textobj').next_task(vim.v.count1) -end) - -vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-task)', function() - require('pending.textobj').prev_task(vim.v.count1) -end) diff --git a/spec/textobj_spec.lua b/spec/textobj_spec.lua deleted file mode 100644 index 1253f58..0000000 --- a/spec/textobj_spec.lua +++ /dev/null @@ -1,194 +0,0 @@ -require('spec.helpers') - -local config = require('pending.config') - -describe('textobj', function() - local textobj = require('pending.textobj') - - before_each(function() - vim.g.pending = nil - config.reset() - end) - - after_each(function() - vim.g.pending = nil - config.reset() - end) - - describe('inner_task_range', function() - it('returns description range for task with id prefix', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('returns description range for task without id prefix', function() - local s, e = textobj.inner_task_range('- [ ] Buy groceries') - assert.are.equal(7, s) - assert.are.equal(19, e) - end) - - it('excludes trailing due: token', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('excludes trailing cat: token', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('excludes trailing rec: token', function() - local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly') - assert.are.equal(10, s) - assert.are.equal(23, e) - end) - - it('excludes multiple trailing metadata tokens', function() - local s, e = - textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly') - assert.are.equal(10, s) - assert.are.equal(17, e) - end) - - it('handles priority checkbox', function() - local s, e = textobj.inner_task_range('/1/- [!] Important task') - assert.are.equal(10, s) - assert.are.equal(23, e) - end) - - it('handles done checkbox', function() - local s, e = textobj.inner_task_range('/1/- [x] Finished task') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('handles multi-digit task ids', function() - local s, e = textobj.inner_task_range('/123/- [ ] Some task') - assert.are.equal(12, s) - assert.are.equal(20, e) - end) - - it('does not strip non-metadata tokens', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner') - assert.are.equal(10, s) - assert.are.equal(33, e) - end) - - it('stops stripping at first non-metadata token from right', function() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15') - assert.are.equal(10, s) - assert.are.equal(33, e) - end) - - it('respects custom date_syntax', function() - vim.g.pending = { date_syntax = 'by' } - config.reset() - local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15') - assert.are.equal(10, s) - assert.are.equal(22, e) - end) - - it('respects custom recur_syntax', function() - vim.g.pending = { recur_syntax = 'repeat' } - config.reset() - local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly') - assert.are.equal(10, s) - assert.are.equal(19, e) - end) - - it('handles task with only metadata after description', function() - local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow') - assert.are.equal(10, s) - assert.are.equal(10, e) - end) - end) - - describe('category_bounds', function() - it('returns header and last row for single category', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - { type = 'task', id = 2 }, - } - local h, l = textobj.category_bounds(2, meta) - assert.are.equal(1, h) - assert.are.equal(3, l) - end) - - it('returns bounds for first category with trailing blank', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - { type = 'blank' }, - { type = 'header', category = 'Personal' }, - { type = 'task', id = 2 }, - } - local h, l = textobj.category_bounds(2, meta) - assert.are.equal(1, h) - assert.are.equal(3, l) - end) - - it('returns bounds for second category', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - { type = 'blank' }, - { type = 'header', category = 'Personal' }, - { type = 'task', id = 2 }, - { type = 'task', id = 3 }, - } - local h, l = textobj.category_bounds(5, meta) - assert.are.equal(4, h) - assert.are.equal(6, l) - end) - - it('returns bounds when cursor is on header', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - } - local h, l = textobj.category_bounds(1, meta) - assert.are.equal(1, h) - assert.are.equal(2, l) - end) - - it('returns nil for blank line with no preceding header', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'blank' }, - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - } - local h, l = textobj.category_bounds(1, meta) - assert.is_nil(h) - assert.is_nil(l) - end) - - it('returns nil for empty meta', function() - local h, l = textobj.category_bounds(1, {}) - assert.is_nil(h) - assert.is_nil(l) - end) - - it('includes blank between header and next header in bounds', function() - ---@type pending.LineMeta[] - local meta = { - { type = 'header', category = 'Work' }, - { type = 'task', id = 1 }, - { type = 'blank' }, - { type = 'header', category = 'Home' }, - { type = 'task', id = 2 }, - } - local h, l = textobj.category_bounds(1, meta) - assert.are.equal(1, h) - assert.are.equal(3, l) - end) - end) -end)