diff --git a/lua/config/keymaps.lua b/lua/config/keymaps.lua index 057cbb3..f92611a 100644 --- a/lua/config/keymaps.lua +++ b/lua/config/keymaps.lua @@ -19,3 +19,6 @@ vim.keymap.set("n", "tp", require("precognition").toggle) vim.keymap.set("n", "", function() require("lazyvim.util").pick("files", { root = false })() end, { desc = "Find Files (cwd)" }) + +-- Markdown task management +require("config.markdown-tasks").setup() diff --git a/lua/config/markdown-tasks.lua b/lua/config/markdown-tasks.lua new file mode 100644 index 0000000..4a964a9 --- /dev/null +++ b/lua/config/markdown-tasks.lua @@ -0,0 +1,389 @@ +-- Markdown task management +-- Keymaps are registered only in markdown buffers via FileType autocmd. +-- +-- File structure expected: +-- # TODO Lists +-- ## General Section ✅2 ❌1 +-- - [ ] undated task +-- ## DD-MM-YYYY ✅0 ❌3 (newest date first) +-- - [ ] task +-- - [x] done task + +local M = {} + +-- ── Core task operations ───────────────────────────────────────────────────── + +local function new_task() + local line = vim.api.nvim_get_current_line() + if line:match("^%s*$") then + vim.api.nvim_set_current_line("- [ ] ") + vim.cmd("startinsert!") + else + vim.cmd("normal! o- [ ] ") + vim.cmd("startinsert!") + end +end + +local function toggle_task() + local line = vim.api.nvim_get_current_line() + if line:find("%[ %]") then + vim.api.nvim_set_current_line((line:gsub("%[ %]", "[x]", 1))) + elseif line:find("%[x%]") then + vim.api.nvim_set_current_line((line:gsub("%[x%]", "[ ]", 1))) + else + return + end + -- stats updated below after toggle + local update = require("config.markdown-tasks")._update_stats + if update then update() end +end + +local function toggle_task_format() + local line = vim.api.nvim_get_current_line() + if line:find("^%s*%- %[.%] ") then + -- "- [ ] text" → "- text" + vim.api.nvim_set_current_line((line:gsub("^(%s*)%- %[.%] ", "%1- ", 1))) + elseif line:find("^%s*%- ") then + -- "- text" → "- [ ] text" + vim.api.nvim_set_current_line((line:gsub("^(%s*)%- ", "%1- [ ] ", 1))) + end +end + +local function jump_next_task() vim.fn.search("- \\[ \\]", "W") end +local function jump_prev_task() vim.fn.search("- \\[ \\]", "bW") end + +local function list_tasks_in_file() + require("snacks").picker.grep({ search = "- \\[ \\]", buffers = true }) +end + +local function search_all_tasks() + require("snacks").picker.grep({ search = "- \\[ \\]" }) +end + +-- ── Section task stats ──────────────────────────────────────────────────────── + +-- Strips the stats suffix from a heading line, returning the bare heading. +local function strip_stats(line) + return (line:gsub("%s+✅%d+ ❌%d+%s*$", "")) +end + +-- Counts [x] and [ ] tasks in a section starting at header_line (1-indexed). +local function count_section_tasks(lines, header_line) + local done, todo = 0, 0 + for i = header_line + 1, #lines do + if lines[i]:match("^## ") then break end + if lines[i]:match("^%s*%- %[x%]") then + done = done + 1 + elseif lines[i]:match("^%s*%- %[ %]") then + todo = todo + 1 + end + end + return done, todo +end + +-- Updates every ## heading with a live task count suffix. +local function update_section_stats() + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local changed = false + for i, line in ipairs(lines) do + if line:match("^## ") then + local base = strip_stats(line) + local done, todo = count_section_tasks(lines, i) + local new_line = base .. " ✅" .. done .. " ❌" .. todo + if new_line ~= line then + lines[i] = new_line + changed = true + end + end + end + if changed then + local cursor = vim.api.nvim_win_get_cursor(0) + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.api.nvim_win_set_cursor(0, cursor) + end +end + +-- Expose so toggle_task can call it without a forward-ref issue. +M._update_stats = update_section_stats + +-- ── Spacing normalizer ─────────────────────────────────────────────────────── + +-- Enforces: one blank line before and after every heading line, no consecutive +-- blank lines, no blank lines between sibling task lines. +local function normalize_spacing() + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + + -- Strip all blank lines first so we can re-insert them deterministically. + local stripped = {} + for _, l in ipairs(lines) do + if not l:match("^%s*$") then table.insert(stripped, l) end + end + + local result = {} + + local function ensure_blank() + if #result > 0 and not result[#result]:match("^%s*$") then + table.insert(result, "") + end + end + + for i, l in ipairs(stripped) do + local is_heading = l:match("^#") + if is_heading and #result > 0 then ensure_blank() end + table.insert(result, l) + if is_heading and i < #stripped then ensure_blank() end + end + + vim.api.nvim_buf_set_lines(0, 0, -1, false, result) + update_section_stats() +end + +-- ── Date helpers ───────────────────────────────────────────────────────────── + +-- Returns date string "DD-MM-YYYY" with an optional day offset. +local function date_string(offset_days) + local t = os.time() + (offset_days or 0) * 86400 + return os.date("%d-%m-%Y", t) +end + +-- Returns 1-indexed line number of the "## DD-MM-YYYY" header, or nil. +local function find_date_section(date_str) + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + for i, line in ipairs(lines) do + if strip_stats(line) == "## " .. date_str then return i end + end +end + +-- Returns the 1-indexed line of the last non-empty content line within a +-- section (from header_line until the next ## header or EOF). +local function section_end(header_line) + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local last = header_line + for i = header_line + 1, #lines do + if lines[i]:match("^## ") then return i - 1 end + if not lines[i]:match("^%s*$") then last = i end + end + return last +end + +-- Parses a "DD-MM-YYYY" string into a Unix timestamp for comparison. +local function date_to_time(s) + local d, m, y = s:match("(%d+)-(%d+)-(%d+)") + if not d then return 0 end + return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) +end + +-- Inserts a new "## date_str" line in descending date order (newest first). +-- Blank lines are left to normalize_spacing. +local function insert_date_section(date_str) + local new_time = date_to_time(date_str) + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + -- Find the first existing date header that is strictly older → insert before it. + for i, line in ipairs(lines) do + local existing = strip_stats(line):match("^## (%d%d%-%d%d%-%d%d%d%d)$") + if existing and date_to_time(existing) < new_time then + vim.api.nvim_buf_set_lines(0, i - 1, i - 1, false, { "## " .. date_str }) + return + end + end + -- All existing dates are newer (or there are none) → append at end. + vim.api.nvim_buf_set_lines(0, #lines, #lines, false, { "## " .. date_str }) +end + +-- ── Insert today's date header ──────────────────────────────────────────────── + +local function insert_date() + local header = "## " .. date_string(0) + local row = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_get_current_line() + if line:match("^%s*$") then + vim.api.nvim_set_current_line(header) + else + vim.api.nvim_buf_set_lines(0, row, row, false, { header }) + vim.api.nvim_win_set_cursor(0, { row + 1, 0 }) + end + normalize_spacing() +end + + +-- ── Move task helpers ───────────────────────────────────────────────────────── + +-- Removes the task at row (1-indexed), then deletes the owning date header if +-- it is now empty. Returns the task text. +local function remove_task(row) + local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] + + -- Find the ## header that owns this task before deleting it. + local all = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local src_hdr = nil + for i = row, 1, -1 do + if all[i] and all[i]:match("^##") then + src_hdr = i; break + end + end + + vim.api.nvim_buf_set_lines(0, row - 1, row, false, {}) + + -- If the source was a date section that is now empty, remove its header. + if src_hdr then + local updated = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local hdr = updated[src_hdr] + if hdr and strip_stats(hdr):match("^## %d%d%-%d%d%-%d%d%d%d$") then + local empty = true + for i = src_hdr + 1, #updated do + if updated[i]:match("^## ") then break end + if not updated[i]:match("^%s*$") then + empty = false; break + end + end + if empty then + vim.api.nvim_buf_set_lines(0, src_hdr - 1, src_hdr, false, {}) + end + end + end + + return line +end + +-- ── Move task to a date section ─────────────────────────────────────────────── + +local function move_task_to(date_str) + local row = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_get_current_line() + + if not line:match("^%s*%- %[") then + vim.notify("Not on a task line", vim.log.levels.WARN) + return + end + + remove_task(row) + + local target = find_date_section(date_str) + if not target then + insert_date_section(date_str) + target = find_date_section(date_str) + end + + local insert_at = section_end(target) + vim.api.nvim_buf_set_lines(0, insert_at, insert_at, false, { line }) + + normalize_spacing() +end + +local function move_to_general() + local row = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_get_current_line() + + if not line:match("^%s*%- %[") then + vim.notify("Not on a task line", vim.log.levels.WARN) + return + end + + remove_task(row) + + -- The general section is the first ## heading that isn't a date heading. + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local general = nil + for i, l in ipairs(lines) do + local base = strip_stats(l) + if base:match("^## ") and not base:match("^## %d%d%-%d%d%-%d%d%d%d$") then + general = i; break + end + end + + if not general then + vim.notify("No general section found", vim.log.levels.WARN) + return + end + + local insert_at = section_end(general) + vim.api.nvim_buf_set_lines(0, insert_at, insert_at, false, { line }) + + normalize_spacing() +end + +local function move_to_today() move_task_to(date_string(0)) end +local function move_to_tomorrow() move_task_to(date_string(1)) end +local function move_to_yesterday() move_task_to(date_string(-1)) end + +-- Parses flexible date input into "DD-MM-YYYY". Accepts separators - / or none. +-- If the year is omitted (DDMM or DD-MM or DD/MM), assumes the current year. +local function parse_date_input(input) + if not input or input == "" then return nil end + input = input:match("^%s*(.-)%s*$") -- trim + + local current_year = tonumber(os.date("%Y")) + local d, m, y + + -- With separator: DD-MM-YYYY or DD/MM/YYYY (year optional) + d, m, y = input:match("^(%d%d?)[%-%/](%d%d?)[%-%/](%d%d%d%d)$") + if d then + return string.format("%02d-%02d-%04d", tonumber(d), tonumber(m), tonumber(y)) + end + + d, m = input:match("^(%d%d?)[%-%/](%d%d?)$") + if d then + return string.format("%02d-%02d-%04d", tonumber(d), tonumber(m), current_year) + end + + -- No separator: DDMMYYYY or DDMM + d, m, y = input:match("^(%d%d)(%d%d)(%d%d%d%d)$") + if d then + return string.format("%02d-%02d-%04d", tonumber(d), tonumber(m), tonumber(y)) + end + + d, m = input:match("^(%d%d)(%d%d)$") + if d then + return string.format("%02d-%02d-%04d", tonumber(d), tonumber(m), current_year) + end + + return nil +end + +local function move_to_date() + vim.ui.input({ prompt = "Date (DD-MM-YYYY): " }, function(input) + local parsed = parse_date_input(input) + if parsed then + move_task_to(parsed) + elseif input and input ~= "" then + vim.notify("Unrecognised date: " .. input, vim.log.levels.WARN) + end + end) +end + +-- ── Setup ──────────────────────────────────────────────────────────────────── + +function M.setup() + local group = vim.api.nvim_create_augroup("MarkdownTasks", { clear = true }) + vim.api.nvim_create_autocmd("FileType", { + pattern = "markdown", + group = group, + callback = function(ev) + local buf = ev.buf + local o = function(desc) return { buffer = buf, desc = desc } end + + vim.keymap.set("n", "mn", new_task, o("New task")) + vim.keymap.set("n", "mx", toggle_task, o("Toggle done")) + vim.keymap.set("n", "mf", toggle_task_format, o("Toggle task format")) + vim.keymap.set("n", "mj", jump_next_task, o("Next task")) + vim.keymap.set("n", "mk", jump_prev_task, o("Prev task")) + vim.keymap.set("n", "ml", list_tasks_in_file, o("List tasks (file)")) + vim.keymap.set("n", "ms", search_all_tasks, o("Search all tasks")) + vim.keymap.set("n", "mt", insert_date, o("Insert date header")) + vim.keymap.set("n", "mmg", move_to_general, o("Move → general")) + vim.keymap.set("n", "mmt", move_to_today, o("Move → today")) + vim.keymap.set("n", "mmT", move_to_tomorrow, o("Move → tomorrow")) + vim.keymap.set("n", "mmy", move_to_yesterday, o("Move → yesterday")) + vim.keymap.set("n", "mmd", move_to_date, o("Move → date…")) + + -- Update stats after leaving insert mode (covers new_task completions). + vim.api.nvim_create_autocmd("InsertLeave", { + buffer = buf, + group = group, + callback = update_section_stats, + }) + end, + }) +end + +return M diff --git a/lua/plugins/render-markdown.lua b/lua/plugins/render-markdown.lua new file mode 100644 index 0000000..710d543 --- /dev/null +++ b/lua/plugins/render-markdown.lua @@ -0,0 +1,22 @@ +return { + "MeanderingProgrammer/render-markdown.nvim", + opts = { + heading = { + sign = false, + icons = { "> ", "> " }, + width = { "full", "block" }, + left_pad = 1, + right_pad = 2, + }, + checkbox = { + enabled = true, + unchecked = { icon = "󰄱 ", highlight = "RenderMarkdownUnchecked" }, + checked = { icon = "󰱒 ", highlight = "RenderMarkdownChecked" }, + }, + bullet = { + enabled = true, + icons = { "●", "○", "◆", "◇" }, + }, + anti_conceal = { enabled = false }, + }, +}