From 6c3869b3c48d87eefe1f30bed55cb7a121ba782a Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Mar 2026 19:43:03 -0400 Subject: [PATCH] feat: complete task editing coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the task editing surface had gaps — category and recurrence had no keymaps, `:Pending edit` required knowing the task ID, tasks couldn't be reordered with a keymap, priority was binary (0/1), and `wip`/`blocked` states were documented but unimplemented. Solution: fill every cell so every property is editable in every way. - `gc`/`gr` keymaps for category select and recurrence prompt - cursor-aware `:Pending edit` (omit ID to use task under cursor) - `J`/`K` keymaps to reorder tasks within a category - multi-level priorities (`max_priority` config, `g!` cycles 0→1→2→3→0) - `+!!`/`+!!!` tokens in `:Pending edit`, `:Pending add`, `parse.body()` - `PendingPriority2`/`PendingPriority3` highlight groups - `gw`/`gb` keymaps toggle `wip`/`blocked` status - `>`/`=` state chars in buffer rendering and diff parsing - `PendingWip`/`PendingBlocked` highlight groups - sort order: wip → pending → blocked → done - `wip`/`blocked` filter predicates and icons --- doc/pending.txt | 143 +++++++++++--------- lua/pending/buffer.lua | 19 +++ lua/pending/config.lua | 18 +++ lua/pending/diff.lua | 16 ++- lua/pending/init.lua | 299 ++++++++++++++++++++++++++++++++++++++--- lua/pending/parse.lua | 38 ++++-- lua/pending/store.lua | 2 +- lua/pending/views.lua | 36 ++++- plugin/pending.lua | 28 +++- 9 files changed, 498 insertions(+), 101 deletions(-) diff --git a/doc/pending.txt b/doc/pending.txt index d062375..f6179d3 100644 --- a/doc/pending.txt +++ b/doc/pending.txt @@ -133,8 +133,11 @@ COMMANDS *pending-commands* :Pending add School: Submit homework :Pending add Errands: Pick up dry cleaning due:fri :Pending add Work: standup due:tomorrow rec:weekdays + :Pending add Buy milk due:fri +!! < - If the buffer is currently open it is re-rendered after the add. + Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped + at `max_priority`). If the buffer is currently open it is re-rendered + after the add. *:Pending-archive* :Pending archive [{days}] @@ -215,18 +218,22 @@ COMMANDS *pending-commands* See |pending-filters| for the full list of supported predicates. *:Pending-edit* -:Pending edit {id} [{operations}] - Edit metadata on an existing task without opening the buffer. {id} is the - numeric task ID. One or more operations follow: >vim +:Pending edit [{id}] [{operations}] + Edit metadata on an existing task. {id} is the numeric task ID. When + {id} is omitted and the task buffer is open, the task under the cursor + is used. This makes `:Pending edit +!` work without knowing the ID. + One or more operations follow: >vim :Pending edit 5 due:tomorrow cat:Work +! :Pending edit 5 -due -cat -rec - :Pending edit 5 rec:!weekly due:fri + :Pending edit +!! < Operations: ~ `due:` Set due date (accepts all |pending-dates| vocabulary). `cat:` Set category. `rec:` Set recurrence (prefix `!` for completion-based). - `+!` Add priority flag. + `+!` Set priority to 1. + `+!!` Set priority to 2. + `+!!!` Set priority to 3 (capped at `max_priority`). `-!` Remove priority flag. `-due` Clear due date. `-cat` Clear category. @@ -267,13 +274,19 @@ Default buffer-local keys: ~ ------- ------------------------------------------------ `q` Close the task buffer (`close`) `` Toggle complete / uncomplete (`toggle`) - `g!` Toggle the priority flag (`priority`) + `g!` Cycle priority: 0→1→2→3→0 (`priority`) `gd` Prompt for a due date (`date`) + `gc` Select a category from existing categories (`category`) + `gr` Prompt for a recurrence pattern (`recur`) + `gw` Toggle work-in-progress status (`wip`) + `gb` Toggle blocked status (`blocked`) `gf` Prompt for filter predicates (`filter`) `` Switch between category / queue view (`view`) `gz` 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`) + `J` Move task down within its category (`move_down`) + `K` Move task up within its category (`move_up`) `zc` Fold the current category section (requires `folding`) `zo` Unfold the current category section (requires `folding`) @@ -338,7 +351,8 @@ old keys to `false`: >lua *(pending-priority)* (pending-priority) - Toggle the priority flag for the task under the cursor. + Cycle the priority level for the task under the cursor (0→1→2→3→0). + The maximum level is controlled by `max_priority` in |pending-config|. *(pending-date)* (pending-date) @@ -356,6 +370,35 @@ old keys to `false`: >lua (pending-filter) Prompt for filter predicates via |vim.ui.input|. + *(pending-category)* +(pending-category) + Select a category for the task under the cursor via |vim.ui.select|. + + *(pending-recur)* +(pending-recur) + Prompt for a recurrence pattern for the task under the cursor. + Prefix with `!` for completion mode (e.g. `!weekly`). Empty input + removes recurrence. + + *(pending-move-down)* +(pending-move-down) + Swap the task under the cursor with the one below it. In category + view, movement is limited to tasks within the same category. + + *(pending-move-up)* +(pending-move-up) + Swap the task under the cursor with the one above it. + + *(pending-wip)* +(pending-wip) + Toggle work-in-progress status for the task under the cursor. + If the task is already `wip`, reverts to `pending`. + + *(pending-blocked)* +(pending-blocked) + Toggle blocked status for the task under the cursor. + If the task is already `blocked`, reverts to `pending`. + *(pending-open-line)* (pending-open-line) Insert a correctly-formatted blank task line below the cursor. @@ -414,16 +457,13 @@ Category view (default): ~ *pending-view-category* order tasks were added unless `category_order` is set (see |pending-config|). Blank lines separate categories. Within each category, tasks are sorted by status (wip → pending → blocked → done), then by - priority, then by insertion order. The within-category sort order is - configurable via `category.sort` (see |pending-sort|). Category sections - are foldable with `zc` and `zo`. + priority, then by insertion order. Category sections are foldable with + `zc` and `zo`. Queue view: ~ *pending-view-queue* A flat list of all tasks sorted by status (wip → pending → blocked → done), then by priority, then by due date (tasks without a due date sort - last), then by internal order. The sort order is configurable via - `queue.sort` (see |pending-sort|). Category names are shown as - right-aligned virtual + last), then by internal order. 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`. @@ -618,6 +658,7 @@ loads: >lua date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + max_priority = 3, view = { default = 'category', eol_format = '%c %r %d', @@ -645,6 +686,12 @@ loads: >lua prev_header = '[[', next_task = ']t', prev_task = '[t', + category = 'gc', + recur = 'gr', + move_down = 'J', + move_up = 'K', + wip = 'gw', + blocked = 'gb', }, sync = { gcal = {}, @@ -732,11 +779,6 @@ Fields: ~ in this list appear in the given order; others are appended after. - {sort} (string|string[], default: 'default') - Sort order within each category. See - |pending-sort| for syntax. The `'default'` - preset is priority → order → id. - {folding} (boolean|table, default: true) *pending.FoldingConfig* Controls category-level folds. `true` @@ -751,43 +793,6 @@ Fields: ~ {queue} (table) *pending.QueueViewConfig* Queue (priority) view settings. - {sort} (string|string[], default: 'default') - Sort order for the queue view. See - |pending-sort| for syntax. The `'default'` - preset is priority → due → order → id. - - Sort keys: ~ *pending-sort* - Both `category.sort` and `queue.sort` accept a named - preset string or an ordered list of sort keys. - - Presets: ~ - `'default'` priority → due → order → id - `'due-first'` due → priority → order → id - `'alphabetical'` description → priority → order → id - `'newest-first'` entry (desc) → priority → order → id - `'recent'` modified (desc) → priority → order → id - - Available keys: ~ - `'priority'` Higher priority first (descending) - `'due'` Earlier due date first (nil last) - `'status'` Pending before done - `'category'` Alphabetical by category - `'description'` Alphabetical by task text - `'entry'` Oldest creation date first - `'modified'` Oldest modification first - `'order'` Internal insertion order - `'id'` Task creation order - - Prefix a key with `-` to flip its default direction - (e.g. `'-due'` for latest-first). `'priority'` - defaults to descending; all others default to - ascending. Implicit `order`, `id` tiebreakers are - appended when absent for stable, deterministic sort. - - When `'status'` appears in the key list, the - pending-before-done split is disabled and status - participates as a normal sort field. - Examples: >lua vim.g.pending = { view = { @@ -795,12 +800,8 @@ Fields: ~ eol_format = '%d | %r', category = { order = { 'Work', 'Personal' }, - sort = { 'due', 'priority', 'order' }, folding = { foldtext = '%c: %n items' }, }, - queue = { - sort = 'due-first', - }, }, } < @@ -812,6 +813,15 @@ Fields: ~ See |pending-mappings| for the full list of actions and their default keys. + {max_priority} (integer, default: 3) + Maximum priority level. The `g!` keymap cycles + through `0 → 1 → … → max_priority → 0`. Priority + levels map to highlight groups: `PendingPriority` + (1), `PendingPriority2` (2), `PendingPriority3` + (3+). `:Pending edit +!!` and `:Pending add +!!!` + accept multi-bang syntax capped at this value. + Set to `1` for the old binary on/off behavior. + {debug} (boolean, default: false) Enable diagnostic logging. When `true`, textobj motions, mapping registration, and cursor jumps @@ -899,9 +909,17 @@ PendingBlocked Applied to the checkbox icon and text of blocked tasks. Default: links to `DiagnosticError`. *PendingPriority* -PendingPriority Applied to the `! ` priority marker on priority tasks. +PendingPriority Applied to the checkbox icon of priority 1 tasks. Default: links to `DiagnosticWarn`. + *PendingPriority2* +PendingPriority2 Applied to the checkbox icon of priority 2 tasks. + Default: links to `DiagnosticError`. + + *PendingPriority3* +PendingPriority3 Applied to the checkbox icon of priority 3+ tasks. + Default: links to `DiagnosticError`. + *PendingRecur* PendingRecur Applied to the recurrence indicator virtual text shown alongside due dates for recurring tasks. @@ -1266,7 +1284,8 @@ Task fields: ~ {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, or `'deleted'`. {category} (string) Category name. Defaults to `default_category`. - {priority} (integer) `1` for priority tasks, `0` otherwise. + {priority} (integer) Priority level: `0` (none), `1`–`3` (or up to + `max_priority`). Higher values sort first. {due} (string) ISO date string `YYYY-MM-DD`, or absent. {recur} (string) Recurrence shorthand (e.g. `weekly`), or absent. {recur_mode} (string) `'scheduled'` or `'completion'`, or absent. diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index 012dc35..5d18e1f 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -137,12 +137,27 @@ local function apply_inline_row(bufnr, row, m, icons) end_col = #line, hl_group = 'PendingDone', }) + elseif m.status == 'blocked' then + 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 + vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, { + end_col = #line, + hl_group = 'PendingBlocked', + }) end local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' local bracket_col = (line:find('%[') or 1) - 1 local icon, icon_hl if m.status == 'done' then icon, icon_hl = icons.done, 'PendingDone' + elseif m.status == 'wip' then + icon, icon_hl = icons.wip or '>', 'PendingWip' + elseif m.status == 'blocked' then + icon, icon_hl = icons.blocked or '=', 'PendingBlocked' + elseif m.priority and m.priority >= 3 then + icon, icon_hl = icons.priority, 'PendingPriority3' + elseif m.priority and m.priority == 2 then + icon, icon_hl = icons.priority, 'PendingPriority2' elseif m.priority and m.priority > 0 then icon, icon_hl = icons.priority, 'PendingPriority' else @@ -464,6 +479,10 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingOverdue', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingDone', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority', { link = 'DiagnosticWarn', default = true }) + vim.api.nvim_set_hl(0, 'PendingPriority2', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true }) + vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true }) + vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) end diff --git a/lua/pending/config.lua b/lua/pending/config.lua index 36c63d2..81e7168 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -9,6 +9,8 @@ ---@field pending string ---@field done string ---@field priority string +---@field wip string +---@field blocked string ---@field due string ---@field recur string ---@field category string @@ -48,6 +50,12 @@ ---@field prev_header? string|false ---@field next_task? string|false ---@field prev_task? string|false +---@field category? string|false +---@field recur? string|false +---@field move_down? string|false +---@field move_up? string|false +---@field wip? string|false +---@field blocked? string|false ---@class pending.CategoryViewConfig ---@field order? string[] @@ -73,6 +81,7 @@ ---@field debug? boolean ---@field keymaps pending.Keymaps ---@field view pending.ViewConfig +---@field max_priority? integer ---@field sync? pending.SyncConfig ---@field icons pending.Icons @@ -87,6 +96,7 @@ local defaults = { date_syntax = 'due', recur_syntax = 'rec', someday_date = '9999-12-30', + max_priority = 3, view = { default = 'category', eol_format = '%c %r %d', @@ -114,12 +124,20 @@ local defaults = { prev_header = '[[', next_task = ']t', prev_task = '[t', + category = 'gc', + recur = 'gr', + move_down = 'J', + move_up = 'K', + wip = 'gw', + blocked = 'gb', }, sync = {}, icons = { pending = ' ', done = 'x', priority = '!', + wip = '>', + blocked = '=', due = '.', recur = '~', category = '#', diff --git a/lua/pending/diff.lua b/lua/pending/diff.lua index 6b79b8a..723dee1 100644 --- a/lua/pending/diff.lua +++ b/lua/pending/diff.lua @@ -43,7 +43,16 @@ function M.parse_buffer(lines) local stripped = body:match('^- %[.?%] (.*)$') or body local state_char = body:match('^- %[(.-)%]') or ' ' local priority = state_char == '!' and 1 or 0 - local status = state_char == 'x' and 'done' or 'pending' + local status + if state_char == 'x' then + status = 'done' + elseif state_char == '>' then + status = 'wip' + elseif state_char == '=' then + status = 'blocked' + else + status = 'pending' + end local description, metadata = parse.body(stripped) if description and description ~= '' then table.insert(result, { @@ -117,7 +126,10 @@ function M.apply(lines, s, hidden_ids) task.category = entry.category changed = true end - if task.priority ~= entry.priority then + if entry.priority == 0 and task.priority > 0 then + task.priority = 0 + changed = true + elseif entry.priority > 0 and task.priority == 0 then task.priority = entry.priority changed = true end diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 4d05503..46c6bb7 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -47,7 +47,7 @@ function M._recompute_counts() local today_str = os.date('%Y-%m-%d') --[[@as string]] for _, task in ipairs(get_store():active_tasks()) do - if task.status == 'pending' then + if task.status ~= 'done' and task.status ~= 'deleted' then pending = pending + 1 if task.priority > 0 then priority = priority + 1 @@ -163,6 +163,16 @@ local function compute_hidden_ids(tasks, predicates) visible = false break end + elseif pred == 'wip' then + if task.status ~= 'wip' then + visible = false + break + end + elseif pred == 'blocked' then + if task.status ~= 'blocked' then + visible = false + break + end end end if not visible then @@ -335,6 +345,24 @@ function M._setup_buf_mappings(bufnr) date = function() M.prompt_date() end, + category = function() + M.prompt_category() + end, + recur = function() + M.prompt_recur() + end, + move_down = function() + M.move_task('down') + end, + move_up = function() + M.move_task('up') + end, + wip = function() + M.toggle_status('wip') + end, + blocked = function() + M.toggle_status('blocked') + end, undo = function() M.undo_write() end, @@ -605,7 +633,8 @@ function M.toggle_priority() if not task then return end - local new_priority = task.priority > 0 and 0 or 1 + local max = require('pending.config').get().max_priority or 3 + local new_priority = (task.priority + 1) % (max + 1) s:update(id, { priority = new_priority }) _save_and_notify() buffer.render(bufnr) @@ -658,6 +687,222 @@ function M.prompt_date() end) end +---@param target_status 'wip'|'blocked' +---@return nil +function M.toggle_status(target_status) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + local s = get_store() + local task = s:get(id) + if not task then + return + end + if task.status == target_status then + s:update(id, { status = 'pending' }) + else + s:update(id, { status = target_status }) + end + _save_and_notify() + 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 + +---@param direction 'up'|'down' +---@return nil +function M.move_task(direction) + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + + local target_row + if direction == 'down' then + target_row = row + 1 + else + target_row = row - 1 + end + if not meta[target_row] or meta[target_row].type ~= 'task' then + return + end + + local current_view_name = buffer.current_view_name() or 'category' + if current_view_name == 'category' then + if meta[target_row].category ~= meta[row].category then + return + end + end + + local target_id = meta[target_row].id + if not target_id then + return + end + + local s = get_store() + local task_a = s:get(id) + local task_b = s:get(target_id) + if not task_a or not task_b then + return + end + + if task_a.order == 0 or task_b.order == 0 then + local tasks + if current_view_name == 'category' then + tasks = {} + for _, t in ipairs(s:active_tasks()) do + if t.category == task_a.category then + table.insert(tasks, t) + end + end + else + tasks = s:active_tasks() + end + table.sort(tasks, function(a, b) + if a.order ~= b.order then + return a.order < b.order + end + return a.id < b.id + end) + for i, t in ipairs(tasks) do + s:update(t.id, { order = i }) + end + task_a = s:get(id) + task_b = s:get(target_id) + if not task_a or not task_b then + return + end + end + + local order_a, order_b = task_a.order, task_b.order + s:update(id, { order = order_b }) + s:update(target_id, { order = order_a }) + _save_and_notify() + 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 + +---@return nil +function M.prompt_category() + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + local s = get_store() + local seen = {} + local categories = {} + for _, task in ipairs(s:active_tasks()) do + if task.category and not seen[task.category] then + seen[task.category] = true + table.insert(categories, task.category) + end + end + table.sort(categories) + vim.ui.select(categories, { prompt = 'Category: ' }, function(choice) + if not choice then + return + end + s:update(id, { category = choice }) + _save_and_notify() + buffer.render(bufnr) + end) +end + +---@return nil +function M.prompt_recur() + local bufnr = buffer.bufnr() + if not bufnr then + return + end + if not require_saved() then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + if not id then + return + end + vim.ui.input({ prompt = 'Recurrence (e.g. weekly, !daily): ' }, function(input) + if not input then + return + end + local s = get_store() + if input == '' then + s:update(id, { recur = vim.NIL, recur_mode = vim.NIL }) + _save_and_notify() + buffer.render(bufnr) + log.info('Task #' .. id .. ': recurrence removed.') + return + end + local raw_spec = input + local rec_mode = nil + if raw_spec:sub(1, 1) == '!' then + rec_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + local recur = require('pending.recur') + if not recur.validate(raw_spec) then + log.error('Invalid recurrence pattern: ' .. input) + return + end + s:update(id, { recur = raw_spec, recur_mode = rec_mode }) + _save_and_notify() + buffer.render(bufnr) + log.info('Task #' .. id .. ': recurrence set to ' .. raw_spec .. '.') + end) +end + ---@param text string ---@return nil function M.add(text) @@ -678,6 +923,7 @@ function M.add(text) due = metadata.due, recur = metadata.rec, recur_mode = metadata.rec_mode, + priority = metadata.priority, }) _save_and_notify() local bufnr = buffer.bufnr() @@ -817,8 +1063,11 @@ local function parse_edit_token(token) local dk = cfg.date_syntax or 'due' local rk = cfg.recur_syntax or 'rec' - if token == '+!' then - return 'priority', 1, nil + local bangs = token:match('^%+(!+)$') + if bangs then + local max = cfg.max_priority or 3 + local level = math.min(#bangs, max) + return 'priority', level, nil end if token == '-!' then return 'priority', 0, nil @@ -881,21 +1130,33 @@ local function parse_edit_token(token) .. rk end ----@param id_str string ----@param rest string +---@param id_str? string +---@param rest? string ---@return nil function M.edit(id_str, rest) - if not id_str or id_str == '' then - log.error( - 'Usage: :Pending edit [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' - ) - return - end - - local id = tonumber(id_str) + local id = id_str and tonumber(id_str) if not id then - log.error('Invalid task ID: ' .. id_str) - return + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if meta[row] and meta[row].type == 'task' and meta[row].id then + id = meta[row].id + if id_str and id_str ~= '' then + rest = rest and (id_str .. ' ' .. rest) or id_str + end + end + end + if not id then + if id_str and id_str ~= '' then + log.error('Invalid task ID: ' .. id_str) + else + log.error( + 'Usage: :Pending edit [] [due:] [cat:] [rec:] [+!] [-!] [-due] [-cat] [-rec]' + ) + end + return + end end local s = get_store() @@ -955,7 +1216,11 @@ function M.edit(id_str, rest) end elseif field == 'priority' then updates.priority = value - table.insert(feedback, value == 1 and 'priority added' or 'priority removed') + if value == 0 then + table.insert(feedback, 'priority removed') + else + table.insert(feedback, 'priority set to ' .. value) + end end end diff --git a/lua/pending/parse.lua b/lua/pending/parse.lua index 3e90b65..ea838f7 100644 --- a/lua/pending/parse.lua +++ b/lua/pending/parse.lua @@ -563,24 +563,34 @@ function M.body(text) metadata.cat = cat_val i = i - 1 else - local rec_val = token:match(rec_pattern) - if rec_val then - if metadata.rec then + local pri_bangs = token:match('^%+(!+)$') + if pri_bangs then + if metadata.priority then break end - local recur = require('pending.recur') - local raw_spec = rec_val - if raw_spec:sub(1, 1) == '!' then - metadata.rec_mode = 'completion' - raw_spec = raw_spec:sub(2) - end - if not recur.validate(raw_spec) then - break - end - metadata.rec = raw_spec + local max = config.get().max_priority or 3 + metadata.priority = math.min(#pri_bangs, max) i = i - 1 else - break + local rec_val = token:match(rec_pattern) + if rec_val then + if metadata.rec then + break + end + local recur = require('pending.recur') + local raw_spec = rec_val + if raw_spec:sub(1, 1) == '!' then + metadata.rec_mode = 'completion' + raw_spec = raw_spec:sub(2) + end + if not recur.validate(raw_spec) then + break + end + metadata.rec = raw_spec + i = i - 1 + else + break + end end end end diff --git a/lua/pending/store.lua b/lua/pending/store.lua index 20898fd..640e256 100644 --- a/lua/pending/store.lua +++ b/lua/pending/store.lua @@ -3,7 +3,7 @@ local config = require('pending.config') ---@class pending.Task ---@field id integer ---@field description string ----@field status 'pending'|'done'|'deleted' +---@field status 'pending'|'done'|'deleted'|'wip'|'blocked' ---@field category? string ---@field priority integer ---@field due? string diff --git a/lua/pending/views.lua b/lua/pending/views.lua index 3f7a4cf..d6c706b 100644 --- a/lua/pending/views.lua +++ b/lua/pending/views.lua @@ -41,9 +41,32 @@ local function format_due(due) return formatted end +---@type table +local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } + +---@param task pending.Task +---@return string +local function state_char(task) + if task.status == 'done' then + return 'x' + elseif task.status == 'wip' then + return '>' + elseif task.status == 'blocked' then + return '=' + elseif task.priority > 0 then + return '!' + end + return ' ' +end + ---@param tasks pending.Task[] local function sort_tasks(tasks) table.sort(tasks, function(a, b) + local ra = status_rank[a.status] or 1 + local rb = status_rank[b.status] or 1 + if ra ~= rb then + return ra < rb + end if a.priority ~= b.priority then return a.priority > b.priority end @@ -57,6 +80,11 @@ end ---@param tasks pending.Task[] local function sort_tasks_priority(tasks) table.sort(tasks, function(a, b) + local ra = status_rank[a.status] or 1 + local rb = status_rank[b.status] or 1 + if ra ~= rb then + return ra < rb + end if a.priority ~= b.priority then return a.priority > b.priority end @@ -95,7 +123,7 @@ function M.category_view(tasks) by_cat[cat] = {} done_by_cat[cat] = {} end - if task.status == 'done' then + if task.status == 'done' or task.status == 'deleted' then table.insert(done_by_cat[cat], task) else table.insert(by_cat[cat], task) @@ -146,7 +174,7 @@ function M.category_view(tasks) for _, task in ipairs(all) do local prefix = '/' .. task.id .. '/' - local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') + local state = state_char(task) local line = prefix .. '- [' .. state .. '] ' .. task.description table.insert(lines, line) table.insert(meta, { @@ -157,7 +185,7 @@ function M.category_view(tasks) status = task.status, category = cat, priority = task.priority, - overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) + overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, recur = task.recur, }) @@ -209,7 +237,7 @@ function M.priority_view(tasks) status = task.status, category = task.category, priority = task.priority, - overdue = task.status == 'pending' and task.due ~= nil and parse.is_overdue(task.due) or nil, + overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, show_category = true, recur = task.recur, }) diff --git a/plugin/pending.lua b/plugin/pending.lua index e456f09..9b67a59 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -13,6 +13,8 @@ local function edit_field_candidates() 'cat:', rk .. ':', '+!', + '+!!', + '+!!!', '-!', '-' .. dk, '-cat', @@ -181,7 +183,7 @@ end, { for word in after_filter:gmatch('%S+') do used[word] = true end - local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending' } + local candidates = { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } local store = require('pending.store') local s = store.new(store.resolve_path()) s:load() @@ -280,6 +282,30 @@ vim.keymap.set('n', '(pending-undo)', function() require('pending').undo_write() end) +vim.keymap.set('n', '(pending-category)', function() + require('pending').prompt_category() +end) + +vim.keymap.set('n', '(pending-recur)', function() + require('pending').prompt_recur() +end) + +vim.keymap.set('n', '(pending-move-down)', function() + require('pending').move_task('down') +end) + +vim.keymap.set('n', '(pending-move-up)', function() + require('pending').move_task('up') +end) + +vim.keymap.set('n', '(pending-wip)', function() + require('pending').toggle_status('wip') +end) + +vim.keymap.set('n', '(pending-blocked)', function() + require('pending').toggle_status('blocked') +end) + vim.keymap.set('n', '(pending-filter)', function() vim.ui.input({ prompt = 'Filter: ' }, function(input) if input then