diff --git a/lua/marksman/init.lua b/lua/marksman/init.lua index 901c617..3177d50 100644 --- a/lua/marksman/init.lua +++ b/lua/marksman/init.lua @@ -31,13 +31,15 @@ local default_config = { ProjectMarksHelp = { fg = "#61AFEF" }, ProjectMarksBorder = { fg = "#5A5F8C" }, ProjectMarksSearch = { fg = "#E5C07B" }, + ProjectMarksSeparator = { fg = "#3E4451" }, + ProjectMarksSign = { fg = "#61AFEF" }, }, auto_save = true, max_marks = 100, silent = false, minimal = false, disable_default_keymaps = false, - debounce_ms = 500, -- Debounce save operations + debounce_ms = 100, -- Debounce save operations ui = { -- Position of the marks window. -- "center" positions the window in the middle of the editor (default). @@ -45,6 +47,16 @@ local default_config = { -- "bottom_center" aligns the window at the bottom of the screen, centered horizontally. position = "center", }, + + -- Sign column settings for displaying marks in the left gutter. When + -- `enabled` is true, Marksman will place a sign next to each mark in + -- its associated buffer. Users can customize the text displayed in + -- the sign column (`text`) and the sign priority (`priority`). + sign = { + enabled = false, + text = "●", + priority = 50, + }, } -- Configuration validation schema @@ -54,7 +66,7 @@ local config_schema = { silent = { type = "boolean" }, minimal = { type = "boolean" }, disable_default_keymaps = { type = "boolean" }, - debounce_ms = { type = "number", min = 100, max = 5000 }, + debounce_ms = { type = "number", min = 50, max = 5000 }, ui = { type = "table", fields = { @@ -64,6 +76,15 @@ local config_schema = { }, }, }, + + sign = { + type = "table", + fields = { + enabled = { type = "boolean" }, + text = { type = "string" }, + priority = { type = "number" }, + }, + }, } local config = {} @@ -71,6 +92,9 @@ local config = {} -- Debounced save timer local save_timer = nil +-- Forward declaration for sign updates (defined below) +local update_signs + ---Helper function for conditional notifications ---@param message string The notification message ---@param level number The log level (vim.log.levels.*) @@ -105,6 +129,27 @@ local function validate_config(user_config, schema) string.format("Config value %s above maximum: %s > %s", key, value, rule.max), vim.log.levels.WARN ) + elseif rule.allowed then + local allowed = false + for _, v in ipairs(rule.allowed) do + if value == v then + allowed = true + break + end + end + if not allowed then + notify( + string.format( + "Invalid value for %s: %s (allowed: %s)", + key, + value, + table.concat(rule.allowed, ", ") + ), + vim.log.levels.WARN + ) + else + validated[key] = value + end else validated[key] = value end @@ -175,6 +220,53 @@ local function debounced_save() end, config.debounce_ms or 500) end +---Define and place sign indicators for marksman marks. This function clears +---existing signs in the "marksman" group and places a sign at each mark +---location in its loaded buffer. It respects the user's sign configuration +---(enabled, text, highlight, priority). Called internally after mark +---mutations and on relevant autocommands. +update_signs = function() + -- Only proceed if sign column support is enabled + if not config.sign or not config.sign.enabled then + return + end + -- Define the sign used for marksman marks. Use pcall to handle + -- environments where sign definitions may already exist. + local sign_name = "MarksmanSign" + local ok_define = pcall(vim.fn.sign_define, sign_name, { + text = config.sign.text or "●", + texthl = "ProjectMarksSign", + linehl = "", + numhl = "", + }) + -- Clear any previously placed signs in the marksman group + pcall(vim.fn.sign_unplace, "marksman", { buffer = "*" }) + vim.api.nvim_exec2("redraw!", {}) + -- Fetch marks + local storage_module = get_storage() + if not storage_module then + return + end + local marks = storage_module.get_marks() + local id = 1 + for _, mark in pairs(marks) do + -- Place sign only if buffer is loaded + local bufnr = vim.fn.bufnr(mark.file, false) + if bufnr ~= -1 and vim.api.nvim_buf_is_loaded(bufnr) then + pcall(vim.fn.sign_place, id, "marksman", sign_name, bufnr, { + lnum = mark.line, + priority = config.sign.priority or 10, + }) + id = id + 1 + end + end +end + +-- Expose update_signs so tests and users can trigger sign updates manually +M.update_signs = function() + update_signs() +end + ---Add a mark at the current cursor position ---@param name string? Optional mark name (auto-generated if nil) ---@param description string? Optional mark description @@ -235,6 +327,8 @@ function M.add_mark(name, description) local success = storage_module.add_mark(name, mark) if success then debounced_save() + -- Update signs after successfully adding a mark + update_signs() notify("󰃀 Mark added: " .. name, vim.log.levels.INFO) return { success = true, message = "Mark added successfully", mark_name = name } else @@ -408,6 +502,8 @@ function M.delete_mark(name) local success = storage_module.delete_mark(name) if success then debounced_save() + -- Update signs after deleting a mark + update_signs() notify("󰃀 Mark deleted: " .. name, vim.log.levels.INFO) return { success = true, message = "Mark deleted successfully", mark_name = name } else @@ -436,6 +532,8 @@ function M.rename_mark(old_name, new_name) local success = storage_module.rename_mark(old_name, new_name) if success then debounced_save() + -- Update signs after renaming a mark + update_signs() notify("󰃀 Mark renamed: " .. old_name .. " → " .. new_name, vim.log.levels.INFO) return { success = true, message = "Mark renamed successfully", old_name = old_name, new_name = new_name } else @@ -460,6 +558,8 @@ function M.move_mark(name, direction) local success = storage_module.move_mark(name, direction) if success then debounced_save() + -- Re-apply signs after moving mark order (even if line doesn't change) + update_signs() notify("󰃀 Mark moved " .. direction, vim.log.levels.INFO) return { success = true, message = "Mark moved successfully", direction = direction } else @@ -544,6 +644,8 @@ function M.clear_all_marks() if storage_module then storage_module.clear_all_marks() debounced_save() + -- Remove all signs after clearing marks + update_signs() notify("󰃀 All marks cleared", vim.log.levels.INFO) end end @@ -743,6 +845,22 @@ function M.setup(opts) callback = M.cleanup, desc = "Cleanup marksman resources", }) + + -- If sign column support is enabled, place initial signs and set up autocommands + if config.sign and config.sign.enabled then + -- Place signs for marks that already exist + pcall(update_signs) + -- Update signs whenever a buffer is read or entered (marks in that file may need signs) + vim.api.nvim_create_autocmd({ "BufReadPost", "BufWinEnter" }, { + callback = function() + -- Delay slightly to ensure buffer is loaded + vim.schedule(function() + pcall(update_signs) + end) + end, + desc = "Update marksman signs when entering a buffer", + }) + end end return M diff --git a/lua/marksman/ui.lua b/lua/marksman/ui.lua index 04edb8e..a3b0b05 100644 --- a/lua/marksman/ui.lua +++ b/lua/marksman/ui.lua @@ -73,6 +73,9 @@ local function setup_highlights() ProjectMarksBorder = { fg = "#5A5F8C" }, ProjectMarksSearch = { fg = "#E5C07B" }, ProjectMarksSeparator = { fg = "#3E4451" }, -- For separator line + -- Highlight for the sign indicator shown next to marks from the current file. + -- This colour defaults to the same as the title but can be overridden in the user's config. + ProjectMarksSign = { fg = "#61AFEF" }, } -- Merge with user config @@ -314,6 +317,9 @@ local function create_marks_content(marks, search_query) local footer_height = 2 -- Separator + help line local available_height = WINDOW_HEIGHT - header_height - footer_height - 2 + -- Determine current file (absolute path) to mark entries belonging to the active buffer + local current_file = vim.fn.expand("%:p") + -- Handle no marks case if shown_marks == 0 then local no_marks_line = search_query and search_query ~= "" and " No marks found matching search" @@ -342,6 +348,25 @@ local function create_marks_content(marks, search_query) local line_idx = current_line + marks_added local line, line_highlights = create_minimal_mark_line(name, mark, i, line_idx) + -- Determine if this mark is in the current file + if mark.file == current_file then + -- Replace leading space with sign indicator + line = config.sign.text .. " " .. line:sub(2) + for _, hl in ipairs(line_highlights) do + hl.col = hl.col + 1 + if hl.end_col ~= -1 then + hl.end_col = hl.end_col + 1 + end + end + -- Add highlight for the sign indicator + table.insert(highlights, { + line = line_idx, + col = 0, + end_col = 1, + hl_group = "ProjectMarksSign", + }) + end + table.insert(lines, line) mark_info[line_idx] = { name = name, mark = mark, index = i } @@ -362,10 +387,22 @@ local function create_marks_content(marks, search_query) local start_line_idx = current_line + marks_added local mark_lines, mark_highlights = create_detailed_mark_lines(name, mark, i, start_line_idx) + -- If mark is in current file, decorate the first line with a sign indicator + if mark.file == current_file then + mark_lines[1] = config.sign.text .. mark_lines[1]:sub(2) + -- Insert highlight for sign indicator + table.insert(highlights, { + line = start_line_idx, + col = 0, + end_col = 1, + hl_group = "ProjectMarksSign", + }) + end + mark_info[start_line_idx] = { name = name, mark = mark, index = i } - for _, line in ipairs(mark_lines) do - table.insert(lines, line) + for _, line_content in ipairs(mark_lines) do + table.insert(lines, line_content) end for _, hl in ipairs(mark_highlights) do table.insert(highlights, hl) @@ -736,4 +773,3 @@ function M.cleanup() end return M -