feat(buffer): persist extmarks during editing (#96)
* refactor(buffer): split extmark namespace into `ns_eol` and `ns_inline` Problem: all extmarks shared a single `pending` namespace, making it impossible to selectively clear position-sensitive extmarks (overlays, highlights) while preserving stable EOL virtual text (due dates, recurrence). Solution: introduce `ns_eol` for end-of-line virtual text and `ns_inline` for overlays and highlights. `clear_marks()` and `apply_extmarks()` operate on both namespaces independently. * feat(buffer): track line changes via `on_bytes` to keep `_meta` aligned Problem: `_meta` is a positional array keyed by line number. Line insertions and deletions during editing desync it from actual buffer content, breaking `get_fold()`, cursor-based task lookups, and extmark re-application. Solution: attach an `on_bytes` callback that adjusts `_meta` on line insertions/deletions and tracks dirty rows. Remove the manual `_meta` insert from `open_line()` since `on_bytes` now handles it. Reset dirty rows on each full render. * feat(buffer): clear only inline extmarks on dirty rows during edits Problem: `TextChanged` cleared all extmarks (both namespaces) on every edit, causing EOL virtual text (due dates, recurrence) to vanish while the user types. Solution: replace blanket `clear_marks()` with per-row `clear_inline_row()` that only removes `ns_inline` extmarks on rows flagged dirty by `on_bytes`. EOL virtual text is preserved untouched. * feat(buffer): re-apply inline extmarks after edits Problem: inline extmarks (checkbox overlays, strikethrough, header highlights) were cleared during edits and only restored on `:w`, leaving the buffer visually bare while editing. Solution: extract `apply_inline_row()` from `apply_extmarks()` and call it via `reapply_dirty_inline()` on `InsertLeave` and normal-mode `TextChanged`. Insert-mode `TextChangedI` still only clears inline marks on dirty rows to avoid overlay flicker while typing. * fix(buffer): suppress `on_bytes` during render and fix definition order Problem: `on_bytes` fired during `render()`'s `nvim_buf_set_lines`, corrupting `_meta` with duplicate entries and causing out-of-range extmark errors. Also, `apply_inline_row` was defined after its first caller `reapply_dirty_inline`. Solution: add `_rendering` guard flag around `nvim_buf_set_lines` in `render()` so `on_bytes` is a no-op during authoritative renders. Move `apply_inline_row` above `reapply_dirty_inline` to satisfy Lua local scoping rules.
This commit is contained in:
parent
f56de46b4d
commit
5161ef00a0
2 changed files with 162 additions and 48 deletions
|
|
@ -12,7 +12,8 @@ local _store = nil
|
|||
local task_bufnr = nil
|
||||
---@type integer?
|
||||
local task_winid = nil
|
||||
local task_ns = vim.api.nvim_create_namespace('pending')
|
||||
local ns_eol = vim.api.nvim_create_namespace('pending_eol')
|
||||
local ns_inline = vim.api.nvim_create_namespace('pending_inline')
|
||||
---@type 'category'|'priority'|nil
|
||||
local current_view = nil
|
||||
---@type pending.LineMeta[]
|
||||
|
|
@ -25,6 +26,12 @@ local _initial_fold_loaded = false
|
|||
local _filter_predicates = {}
|
||||
---@type table<integer, true>
|
||||
local _hidden_ids = {}
|
||||
---@type table<integer, true>
|
||||
local _dirty_rows = {}
|
||||
---@type boolean
|
||||
local _on_bytes_active = false
|
||||
---@type boolean
|
||||
local _rendering = false
|
||||
|
||||
---@return pending.LineMeta[]
|
||||
function M.meta()
|
||||
|
|
@ -89,7 +96,127 @@ end
|
|||
---@param b? integer
|
||||
---@return nil
|
||||
function M.clear_marks(b)
|
||||
vim.api.nvim_buf_clear_namespace(b or task_bufnr, task_ns, 0, -1)
|
||||
local bufnr = b or task_bufnr
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1)
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1)
|
||||
end
|
||||
|
||||
---@param b integer
|
||||
---@param row integer
|
||||
---@return nil
|
||||
function M.clear_inline_row(b, row)
|
||||
vim.api.nvim_buf_clear_namespace(b, ns_inline, row - 1, row)
|
||||
end
|
||||
|
||||
---@return table<integer, true>
|
||||
function M.dirty_rows()
|
||||
return _dirty_rows
|
||||
end
|
||||
|
||||
---@return nil
|
||||
function M.clear_dirty_rows()
|
||||
_dirty_rows = {}
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param row integer
|
||||
---@param m pending.LineMeta
|
||||
---@param icons table
|
||||
local function apply_inline_row(bufnr, row, m, icons)
|
||||
if m.type == 'filter' then
|
||||
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
||||
end_col = #line,
|
||||
hl_group = 'PendingFilter',
|
||||
})
|
||||
elseif m.type == 'task' then
|
||||
if m.status == 'done' 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 = 'PendingDone',
|
||||
})
|
||||
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.priority and m.priority > 0 then
|
||||
icon, icon_hl = icons.priority, 'PendingPriority'
|
||||
else
|
||||
icon, icon_hl = icons.pending, 'Normal'
|
||||
end
|
||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, bracket_col, {
|
||||
virt_text = { { '[' .. icon .. ']', icon_hl } },
|
||||
virt_text_pos = 'overlay',
|
||||
priority = 100,
|
||||
})
|
||||
elseif m.type == 'header' then
|
||||
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
||||
end_col = #line,
|
||||
hl_group = 'PendingHeader',
|
||||
})
|
||||
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, 0, {
|
||||
virt_text = { { icons.category .. ' ', 'PendingHeader' } },
|
||||
virt_text_pos = 'overlay',
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return nil
|
||||
function M.reapply_dirty_inline(bufnr)
|
||||
if not next(_dirty_rows) then
|
||||
return
|
||||
end
|
||||
local icons = config.get().icons
|
||||
for row in pairs(_dirty_rows) do
|
||||
local m = _meta[row]
|
||||
if m then
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, row - 1, row)
|
||||
apply_inline_row(bufnr, row - 1, m, icons)
|
||||
end
|
||||
end
|
||||
_dirty_rows = {}
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return nil
|
||||
function M.attach_bytes(bufnr)
|
||||
if _on_bytes_active then
|
||||
return
|
||||
end
|
||||
_on_bytes_active = true
|
||||
vim.api.nvim_buf_attach(bufnr, false, {
|
||||
on_bytes = function(_, buf, _, start_row, _, _, old_end_row, _, _, new_end_row, _, _)
|
||||
if buf ~= task_bufnr then
|
||||
_on_bytes_active = false
|
||||
return true
|
||||
end
|
||||
if _rendering then
|
||||
return
|
||||
end
|
||||
local delta = new_end_row - old_end_row
|
||||
if delta > 0 then
|
||||
for _ = 1, delta do
|
||||
table.insert(_meta, start_row + 2, { type = 'task' })
|
||||
end
|
||||
elseif delta < 0 then
|
||||
for _ = 1, -delta do
|
||||
if _meta[start_row + 2] then
|
||||
table.remove(_meta, start_row + 2)
|
||||
end
|
||||
end
|
||||
end
|
||||
for r = start_row + 1, start_row + 1 + math.max(0, new_end_row) do
|
||||
_dirty_rows[r] = true
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
---@return nil
|
||||
|
|
@ -205,7 +332,6 @@ function M.open_line(above)
|
|||
local insert_row = above and (row - 1) or row
|
||||
vim.bo[bufnr].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' })
|
||||
table.insert(_meta, insert_row + 1, { type = 'task' })
|
||||
vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 })
|
||||
vim.cmd('startinsert!')
|
||||
end
|
||||
|
|
@ -230,16 +356,11 @@ end
|
|||
---@param line_meta pending.LineMeta[]
|
||||
local function apply_extmarks(bufnr, line_meta)
|
||||
local icons = config.get().icons
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1)
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, ns_eol, 0, -1)
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, 0, -1)
|
||||
for i, m in ipairs(line_meta) do
|
||||
local row = i - 1
|
||||
if m.type == 'filter' then
|
||||
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||
end_col = #line,
|
||||
hl_group = 'PendingFilter',
|
||||
})
|
||||
elseif m.type == 'task' then
|
||||
if m.type == 'task' then
|
||||
local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue'
|
||||
local virt_parts = {}
|
||||
if m.show_category and m.category then
|
||||
|
|
@ -255,46 +376,13 @@ local function apply_extmarks(bufnr, line_meta)
|
|||
for p = 1, #virt_parts - 1 do
|
||||
virt_parts[p][1] = virt_parts[p][1] .. ' '
|
||||
end
|
||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||
vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, {
|
||||
virt_text = virt_parts,
|
||||
virt_text_pos = 'eol',
|
||||
})
|
||||
end
|
||||
if m.status == 'done' 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, task_ns, row, col_start, {
|
||||
end_col = #line,
|
||||
hl_group = 'PendingDone',
|
||||
})
|
||||
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.priority and m.priority > 0 then
|
||||
icon, icon_hl = icons.priority, 'PendingPriority'
|
||||
else
|
||||
icon, icon_hl = icons.pending, 'Normal'
|
||||
end
|
||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, bracket_col, {
|
||||
virt_text = { { '[' .. icon .. ']', icon_hl } },
|
||||
virt_text_pos = 'overlay',
|
||||
priority = 100,
|
||||
})
|
||||
elseif m.type == 'header' then
|
||||
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
|
||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||
end_col = #line,
|
||||
hl_group = 'PendingHeader',
|
||||
})
|
||||
vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, {
|
||||
virt_text = { { icons.category .. ' ', 'PendingHeader' } },
|
||||
virt_text_pos = 'overlay',
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
apply_inline_row(bufnr, row, m, icons)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -448,12 +536,15 @@ function M.render(bufnr)
|
|||
end
|
||||
|
||||
_meta = line_meta
|
||||
_dirty_rows = {}
|
||||
|
||||
snapshot_folds(bufnr)
|
||||
vim.bo[bufnr].modifiable = true
|
||||
local saved = vim.bo[bufnr].undolevels
|
||||
vim.bo[bufnr].undolevels = -1
|
||||
_rendering = true
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
||||
_rendering = false
|
||||
vim.bo[bufnr].modified = false
|
||||
vim.bo[bufnr].undolevels = saved
|
||||
|
||||
|
|
@ -507,6 +598,7 @@ function M.open()
|
|||
if not (task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr)) then
|
||||
task_bufnr = vim.api.nvim_create_buf(true, false)
|
||||
set_buf_options(task_bufnr)
|
||||
M.attach_bytes(task_bufnr)
|
||||
end
|
||||
|
||||
vim.cmd('botright new')
|
||||
|
|
|
|||
|
|
@ -251,12 +251,34 @@ function M._setup_autocmds(bufnr)
|
|||
end
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
|
||||
vim.api.nvim_create_autocmd('TextChangedI', {
|
||||
group = group,
|
||||
buffer = bufnr,
|
||||
callback = function()
|
||||
if not vim.bo[bufnr].modified then
|
||||
return
|
||||
end
|
||||
for row in pairs(buffer.dirty_rows()) do
|
||||
buffer.clear_inline_row(bufnr, row)
|
||||
end
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd('TextChanged', {
|
||||
group = group,
|
||||
buffer = bufnr,
|
||||
callback = function()
|
||||
if not vim.bo[bufnr].modified then
|
||||
return
|
||||
end
|
||||
buffer.reapply_dirty_inline(bufnr)
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd('InsertLeave', {
|
||||
group = group,
|
||||
buffer = bufnr,
|
||||
callback = function()
|
||||
if vim.bo[bufnr].modified then
|
||||
buffer.clear_marks(bufnr)
|
||||
buffer.reapply_dirty_inline(bufnr)
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue