local config = require('pending.config') local log = require('pending.log') local views = require('pending.views') ---@class pending.buffer local M = {} ---@type pending.Store? local _store = nil ---@type integer? local task_bufnr = nil ---@type integer? local task_winid = nil 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[] local _meta = {} ---@type table> local _fold_state = {} ---@type boolean local _initial_fold_loaded = false ---@type string[] local _filter_predicates = {} ---@type table local _hidden_ids = {} ---@type table local _dirty_rows = {} ---@type boolean local _on_bytes_active = false ---@type boolean local _rendering = false ---@return pending.LineMeta[] function M.meta() return _meta end ---@return integer? function M.bufnr() return task_bufnr end ---@return integer? function M.winid() return task_winid end ---@return string? function M.current_view_name() return current_view end ---@param s pending.Store? ---@return nil function M.set_store(s) _store = s end ---@return pending.Store? function M.store() return _store end ---@return string[] function M.filter_predicates() return _filter_predicates end ---@return table function M.hidden_ids() return _hidden_ids end ---@param predicates string[] ---@param hidden table ---@return nil function M.set_filter(predicates, hidden) _filter_predicates = predicates _hidden_ids = hidden end ---@return nil function M.clear_winid() task_winid = nil end ---@param winid integer ---@return nil function M.update_winid(winid) task_winid = winid end ---@param b? integer ---@return nil function M.clear_marks(b) 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 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', }) 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 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 line string ---@return string? local function infer_status(line) local ch = line:match('^/%d+/- %[(.)%]') or line:match('^- %[(.)%]') if not ch then return nil end if ch == 'x' then return 'done' elseif ch == '>' then return 'wip' elseif ch == '=' then return 'blocked' end return 'pending' 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 and m.type == 'task' then local line = vim.api.nvim_buf_get_lines(bufnr, row - 1, row, false)[1] or '' m.status = infer_status(line) or m.status end 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 function M.persist_folds() log.debug( ('persist_folds: view=%s store=%s'):format(tostring(current_view), tostring(_store ~= nil)) ) if current_view ~= 'category' or not _store then log.debug('persist_folds: early return (view or store)') return end local bufnr = task_bufnr if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then log.debug('persist_folds: early return (no valid bufnr)') return end local folded = {} local seen = {} local wins = vim.fn.win_findbuf(bufnr) log.debug( ('persist_folds: checking %d windows for bufnr=%d, meta has %d entries'):format( #wins, bufnr, #_meta ) ) for _, winid in ipairs(wins) do if vim.api.nvim_win_is_valid(winid) then vim.api.nvim_win_call(winid, function() for lnum, m in ipairs(_meta) do if m.type == 'header' and m.category and not seen[m.category] then local closed = vim.fn.foldclosed(lnum) log.debug( ('persist_folds: win=%d lnum=%d cat=%s foldclosed=%d'):format( winid, lnum, m.category, closed ) ) if closed ~= -1 then seen[m.category] = true table.insert(folded, m.category) end end end end) end end log.debug( ('persist_folds: saving %d folded categories: %s'):format(#folded, table.concat(folded, ', ')) ) _store:set_folded_categories(folded) end ---@return nil function M.close() if not task_winid or not vim.api.nvim_win_is_valid(task_winid) then task_winid = nil return end M.persist_folds() if _store then _store:save() end local wins = vim.api.nvim_list_wins() if #wins == 1 then vim.cmd.enew() else vim.api.nvim_win_close(task_winid, false) end task_winid = nil end ---@param bufnr integer local function set_buf_options(bufnr) vim.bo[bufnr].buftype = 'acwrite' vim.bo[bufnr].bufhidden = 'hide' vim.bo[bufnr].swapfile = false vim.bo[bufnr].filetype = 'pending' vim.bo[bufnr].modifiable = true vim.bo[bufnr].omnifunc = 'v:lua.require("pending.complete").omnifunc' end ---@param winid integer local function set_win_options(winid) vim.wo[winid].conceallevel = 3 vim.wo[winid].concealcursor = 'nvic' vim.wo[winid].winfixheight = true end ---@param bufnr integer local function setup_syntax(bufnr) vim.api.nvim_buf_call(bufnr, function() vim.cmd([[ syntax clear syntax match taskId /^\/\d\+\// conceal syntax match taskHeader /^# .*$/ contains=taskId syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox ]]) end) end ---@param above boolean ---@return nil function M.open_line(above) local bufnr = task_bufnr if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end local row = vim.api.nvim_win_get_cursor(0)[1] local insert_row = above and (row - 1) or row local meta_pos = insert_row + 1 _rendering = true vim.bo[bufnr].modifiable = true vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) _rendering = false table.insert(_meta, meta_pos, { type = 'task' }) local icons = config.get().icons local total = vim.api.nvim_buf_line_count(bufnr) for r = meta_pos, math.min(meta_pos + 1, total) do vim.api.nvim_buf_clear_namespace(bufnr, ns_inline, r - 1, r) local m = _meta[r] if m then apply_inline_row(bufnr, r - 1, m, icons) end end vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.cmd('startinsert!') end ---@return string function M.get_fold() local lnum = vim.v.lnum local m = _meta[lnum] if not m then return '0' end if m.type == 'header' then return '>1' elseif m.type == 'task' then return '1' else return '0' end end ---@class pending.EolSegment ---@field type 'specifier'|'literal' ---@field key? 'c'|'r'|'d' ---@field text? string ---@param fmt string ---@return pending.EolSegment[] local function parse_eol_format(fmt) local segments = {} local pos = 1 local len = #fmt while pos <= len do if fmt:sub(pos, pos) == '%' and pos + 1 <= len then local key = fmt:sub(pos + 1, pos + 1) if key == 'c' or key == 'r' or key == 'd' then table.insert(segments, { type = 'specifier', key = key }) pos = pos + 2 else table.insert(segments, { type = 'literal', text = '%' .. key }) pos = pos + 2 end else local next_pct = fmt:find('%%', pos + 1) local chunk = next_pct and fmt:sub(pos, next_pct - 1) or fmt:sub(pos) table.insert(segments, { type = 'literal', text = chunk }) pos = pos + #chunk end end return segments end ---@param segments pending.EolSegment[] ---@param m pending.LineMeta ---@param icons pending.Icons ---@return string[][] local function build_eol_virt(segments, m, icons) local due_hl = m.overdue and 'PendingOverdue' or 'PendingDue' local resolved = {} for i, seg in ipairs(segments) do if seg.type == 'specifier' then local text, hl if seg.key == 'c' and m.show_category and m.category then text = icons.category .. ' ' .. m.category hl = 'PendingHeader' elseif seg.key == 'r' and m.recur then text = icons.recur .. ' ' .. m.recur hl = 'PendingRecur' elseif seg.key == 'd' and m.due then text = icons.due .. ' ' .. m.due hl = due_hl end resolved[i] = text and { text = text, hl = hl, present = true } or { present = false } else resolved[i] = { text = seg.text, hl = 'Normal', literal = true } end end local virt_parts = {} for i, r in ipairs(resolved) do if r.literal then local prev_present, next_present = false, false for j = i - 1, 1, -1 do if not resolved[j].literal then prev_present = resolved[j].present break end end for j = i + 1, #resolved do if not resolved[j].literal then next_present = resolved[j].present break end end if prev_present and next_present then table.insert(virt_parts, { r.text, r.hl }) end elseif r.present then table.insert(virt_parts, { r.text, r.hl }) end end return virt_parts end ---@param bufnr integer ---@param line_meta pending.LineMeta[] local function apply_extmarks(bufnr, line_meta) local cfg = config.get() local icons = cfg.icons local eol_segments = parse_eol_format(cfg.view.eol_format or '%c %r %d') 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 == 'task' then local virt_parts = build_eol_virt(eol_segments, m, icons) if #virt_parts > 0 then vim.api.nvim_buf_set_extmark(bufnr, ns_eol, row, 0, { virt_text = virt_parts, virt_text_pos = 'eol', }) end end apply_inline_row(bufnr, row, m, icons) end end local function setup_highlights() vim.api.nvim_set_hl(0, 'PendingHeader', { link = 'Title', default = true }) vim.api.nvim_set_hl(0, 'PendingDue', { link = 'DiagnosticHint', default = true }) 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 ---@return string function M.get_foldtext() local folding = config.resolve_folding() if not folding.foldtext then return vim.fn.foldtext() end local line = vim.fn.getline(vim.v.foldstart) local cat = line:match('^#%s+(.+)$') or line local task_count = vim.v.foldend - vim.v.foldstart local icons = config.get().icons local result = folding.foldtext :gsub('%%c', cat) :gsub('%%n', tostring(task_count)) :gsub('(%d+) (%w+)s%)', function(n, word) if n == '1' then return n .. ' ' .. word .. ')' end return n .. ' ' .. word .. 's)' end) return icons.category .. ' ' .. result end local function snapshot_folds(bufnr) if current_view ~= 'category' or not config.resolve_folding().enabled then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do if _fold_state[winid] == nil and _initial_fold_loaded then local state = {} vim.api.nvim_win_call(winid, function() for lnum, m in ipairs(_meta) do if m.type == 'header' and m.category then if vim.fn.foldclosed(lnum) ~= -1 then state[m.category] = true end end end end) _fold_state[winid] = state end end end local function restore_folds(bufnr) log.debug( ('restore_folds: view=%s folding_enabled=%s'):format( tostring(current_view), tostring(config.resolve_folding().enabled) ) ) if current_view ~= 'category' or not config.resolve_folding().enabled then return end for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do local state = _fold_state[winid] _fold_state[winid] = nil log.debug( ('restore_folds: win=%d has_fold_state=%s initial_loaded=%s has_store=%s'):format( winid, tostring(state ~= nil), tostring(_initial_fold_loaded), tostring(_store ~= nil) ) ) if not state and not _initial_fold_loaded and _store then _initial_fold_loaded = true local cats = _store:get_folded_categories() log.debug( ('restore_folds: loaded %d categories from store: %s'):format( #cats, table.concat(cats, ', ') ) ) if #cats > 0 then state = {} for _, cat in ipairs(cats) do state[cat] = true end end end if state and next(state) ~= nil then local applying = {} for k in pairs(state) do table.insert(applying, k) end log.debug(('restore_folds: applying folds for: %s'):format(table.concat(applying, ', '))) vim.api.nvim_win_call(winid, function() vim.cmd('normal! zx') local saved = vim.api.nvim_win_get_cursor(0) for lnum, m in ipairs(_meta) do if m.type == 'header' and m.category and state[m.category] then log.debug(('restore_folds: folding lnum=%d cat=%s'):format(lnum, m.category)) vim.api.nvim_win_set_cursor(0, { lnum, 0 }) vim.cmd('normal! zc') end end vim.api.nvim_win_set_cursor(0, saved) end) end end end ---@param bufnr? integer ---@return nil function M.render(bufnr) bufnr = bufnr or task_bufnr if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end current_view = current_view or config.get().view.default local view_label = current_view == 'priority' and 'queue' or current_view vim.api.nvim_buf_set_name(bufnr, 'pending://' .. view_label) local all_tasks = _store and _store:active_tasks() or {} local tasks = {} for _, task in ipairs(all_tasks) do if not _hidden_ids[task.id] then table.insert(tasks, task) end end local lines, line_meta if current_view == 'priority' then lines, line_meta = views.priority_view(tasks) else lines, line_meta = views.category_view(tasks) end if #lines == 0 and #_filter_predicates == 0 then local default_cat = config.get().default_category lines = { '# ' .. default_cat } line_meta = { { type = 'header', category = default_cat } } end if #_filter_predicates > 0 then table.insert(lines, 1, 'FILTER: ' .. table.concat(_filter_predicates, ' ')) table.insert(line_meta, 1, { type = 'filter' }) 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 setup_syntax(bufnr) apply_extmarks(bufnr, line_meta) local folding = config.resolve_folding() for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do if current_view == 'category' and folding.enabled then vim.wo[winid].foldmethod = 'expr' vim.wo[winid].foldexpr = 'v:lua.require("pending.buffer").get_fold()' vim.wo[winid].foldlevel = 99 vim.wo[winid].foldenable = true if folding.foldtext then vim.wo[winid].foldtext = 'v:lua.require("pending.buffer").get_foldtext()' else vim.wo[winid].foldtext = 'foldtext()' end else vim.wo[winid].foldmethod = 'manual' vim.wo[winid].foldenable = false end end restore_folds(bufnr) end ---@return nil function M.toggle_view() snapshot_folds(task_bufnr) if current_view == 'category' then current_view = 'priority' else current_view = 'category' end M.render() end ---@return integer bufnr function M.open() setup_highlights() if _store then _store:load() end if task_winid and vim.api.nvim_win_is_valid(task_winid) then vim.api.nvim_set_current_win(task_winid) M.render(task_bufnr) return task_bufnr --[[@as integer]] end 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') task_winid = vim.api.nvim_get_current_win() vim.api.nvim_win_set_buf(task_winid, task_bufnr) local h = config.get().drawer_height if h and h > 0 then vim.api.nvim_win_set_height(task_winid, h) end set_win_options(task_winid) M.render(task_bufnr) return task_bufnr end return M