Problem: the ]] motion reportedly lands one line past the header in
some environments, and ]t/[t may not override Neovim defaults. No
way to diagnose these at runtime. Also, pending://priority is a poor
buffer name for the flat ranked view.
Solution: add a debug config option (vim.g.pending = { debug = true })
that logs meta state, cursor positions, and mapping registration to
:messages at DEBUG level. Rename the buffer from pending://priority to
pending://queue. Internal view identifier stays 'priority'.
384 lines
8.5 KiB
Lua
384 lines
8.5 KiB
Lua
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.DEBUG)
|
|
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
|