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:
Barrett Ruth 2026-03-08 14:19:47 -04:00 committed by GitHub
parent d06731a7fd
commit ab06cfcf69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 162 additions and 48 deletions

View file

@ -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')