feat: text objects and motions for the pending buffer
Problem: the pending buffer has action-button mappings but no Vim grammar. You cannot dat to delete a task, cit to change a description, or ]] to jump to the next category header. Solution: add textobj.lua with at/it (a task / inner task), aC/iC (a category / inner category), ]]/[[ (next/prev header), and ]t/[t (next/prev task). All text objects work in operator-pending and visual modes; motions work in normal, visual, and operator-pending. Mappings are configurable via the keymaps table and exposed as <Plug> mappings.
This commit is contained in:
parent
c57cc0845b
commit
233ff31df1
5 changed files with 661 additions and 0 deletions
360
lua/pending/textobj.lua
Normal file
360
lua/pending/textobj.lua
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
local buffer = require('pending.buffer')
|
||||
local config = require('pending.config')
|
||||
|
||||
---@class pending.textobj
|
||||
local M = {}
|
||||
|
||||
---@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_esc = vim.pesc(dk)
|
||||
local rk_esc = vim.pesc(rk)
|
||||
|
||||
local desc_end = #line
|
||||
local tokens = {}
|
||||
for token in line:gmatch('%S+') do
|
||||
table.insert(tokens, token)
|
||||
end
|
||||
|
||||
local i = #tokens
|
||||
while i >= 1 do
|
||||
local token = tokens[i]
|
||||
if token:match('^' .. dk_esc .. ':%S+$')
|
||||
or token:match('^cat:%S+$')
|
||||
or token:match('^' .. rk_esc .. ':%S+$')
|
||||
then
|
||||
i = i - 1
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if i < #tokens then
|
||||
local rebuilt = {}
|
||||
for j = 1, i do
|
||||
table.insert(rebuilt, tokens[j])
|
||||
end
|
||||
local desc_text = table.concat(rebuilt, ' ')
|
||||
local search_start = prefix_end
|
||||
local found = line:find(desc_text, search_start + 1, true)
|
||||
if found then
|
||||
desc_end = found + #desc_text - 1
|
||||
end
|
||||
end
|
||||
|
||||
return start_col, desc_end
|
||||
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]
|
||||
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
|
||||
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.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]
|
||||
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
|
||||
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]
|
||||
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
|
||||
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
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]
|
||||
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
|
||||
vim.api.nvim_win_set_cursor(0, { r, 0 })
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
Loading…
Add table
Add a link
Reference in a new issue