From 049e77a4fb5d2213c2fc3483e3dee9c7f4a45c19 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:29 -0500 Subject: [PATCH] feat(buffer): add scratch buffer with concealment and extmarks Problem: need a buffer to display tasks with concealed IDs, virtual text due dates, strikethrough for done items, and auto-indent. Solution: add buffer module with acwrite scratch buffer, syntax conceal for /id/ prefixes, right-aligned due date extmarks, header/done highlighting, indentexpr, and view toggling. --- lua/todo/buffer.lua | 194 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 lua/todo/buffer.lua diff --git a/lua/todo/buffer.lua b/lua/todo/buffer.lua new file mode 100644 index 0000000..b059b6b --- /dev/null +++ b/lua/todo/buffer.lua @@ -0,0 +1,194 @@ +local config = require('todo.config') +local store = require('todo.store') +local views = require('todo.views') + +---@class todo.buffer +local M = {} + +---@type integer? +local task_bufnr = nil +local task_ns = vim.api.nvim_create_namespace('todo') +---@type 'category'|'priority'|nil +local current_view = nil +---@type todo.LineMeta[] +local _meta = {} + +---@return todo.LineMeta[] +function M.meta() + return _meta +end + +---@return integer? +function M.bufnr() + return task_bufnr +end + +---@return string? +function M.current_view_name() + return current_view +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 = 'todo' + vim.bo[bufnr].modifiable = true +end + +---@param winid integer +local function set_win_options(winid) + vim.wo[winid].conceallevel = 3 + vim.wo[winid].concealcursor = 'nvic' + vim.wo[winid].wrap = false + vim.wo[winid].number = false + vim.wo[winid].relativenumber = false + vim.wo[winid].signcolumn = 'no' + vim.wo[winid].foldcolumn = '0' + vim.wo[winid].spell = false + vim.wo[winid].cursorline = 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 /^\S.*$/ contains=taskId + syntax match taskPriority /! / contained containedin=taskLine + syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority + ]]) + end) +end + +---@param bufnr integer +local function setup_indentexpr(bufnr) + vim.bo[bufnr].indentexpr = 'v:lua.require("todo.buffer").get_indent()' +end + +---@return integer +function M.get_indent() + local lnum = vim.v.lnum + if lnum <= 1 then + return 0 + end + local prev = vim.fn.getline(lnum - 1) + if prev == '' or prev:match('^%S') then + return 0 + end + return 2 +end + +---@param bufnr integer +---@param line_meta todo.LineMeta[] +local function apply_extmarks(bufnr, line_meta) + vim.api.nvim_buf_clear_namespace(bufnr, task_ns, 0, -1) + for i, m in ipairs(line_meta) do + local row = i - 1 + if m.type == 'task' then + if m.due then + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, 0, { + virt_text = { { m.due, 'TodoDue' } }, + virt_text_pos = 'right_align', + }) + 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+/')) + 2 or 0 + vim.api.nvim_buf_set_extmark(bufnr, task_ns, row, col_start, { + end_col = #line, + hl_group = 'TodoDone', + }) + end + 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 = 'TodoHeader', + }) + end + end +end + +local function setup_highlights() + local function hl(name, opts) + if vim.fn.hlexists(name) == 0 or vim.tbl_isempty(vim.api.nvim_get_hl(0, { name = name })) then + vim.api.nvim_set_hl(0, name, opts) + end + end + hl('TodoHeader', { bold = true }) + hl('TodoDue', { fg = '#888888', italic = true }) + hl('TodoDone', { strikethrough = true, fg = '#666666' }) + hl('TodoPriority', { fg = '#e06c75', bold = true }) +end + +---@param bufnr? integer +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().default_view + local tasks = store.active_tasks() + + 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 + + _meta = line_meta + + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modified = false + + setup_syntax(bufnr) + apply_extmarks(bufnr, line_meta) +end + +function M.toggle_view() + if current_view == 'category' then + current_view = 'priority' + else + current_view = 'category' + end + M.render() +end + +---@return integer bufnr +function M.open() + setup_highlights() + store.load() + + if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then + local wins = vim.fn.win_findbuf(task_bufnr) + if #wins > 0 then + vim.api.nvim_set_current_win(wins[1]) + M.render(task_bufnr) + return task_bufnr + end + vim.api.nvim_set_current_buf(task_bufnr) + set_win_options(vim.api.nvim_get_current_win()) + M.render(task_bufnr) + return task_bufnr + end + + task_bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_name(task_bufnr, 'todo://') + + set_buf_options(task_bufnr) + setup_indentexpr(task_bufnr) + vim.api.nvim_set_current_buf(task_bufnr) + set_win_options(vim.api.nvim_get_current_win()) + + M.render(task_bufnr) + + return task_bufnr +end + +return M