9354e1c176
Two bugs prevented keymaps from working after a Dein install: 1. <Leader>ww and <Leader>wt both called diary_today() instead of wiki_index() / wiki_tab_index(). Fixed in keymaps.lua (Neovim) and ftplugin/vimwiki.vim (Vim). 2. All keymaps were buffer-local (<buffer> / buffer=bufnr), so they only activated inside an already-open .wiki file. Users had no way to reach the wiki from any other buffer, making the plugin appear broken on a fresh Vim start. Fix: register the eight <Leader>w* entry-point mappings globally — in setup() for Neovim (lua/nuwiki/init.lua) and in plugin/nuwiki.vim for plain Vim. To avoid a chicken-and-egg LSP dependency, the global mappings open files directly from config (wiki_root, diary_rel_path, file_extension); the LSP auto-starts via the FileType autocmd once the vimwiki buffer loads. Three new autoload helpers (open_wiki_path, open_diary_path, open_diary_index_path) provide the LSP-free path for the Vim side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
322 lines
15 KiB
Lua
322 lines
15 KiB
Lua
-- lua/nuwiki/keymaps.lua — buffer-local key bindings for `.wiki` buffers.
|
|
--
|
|
-- Defaults mirror upstream vimwiki's ftplugin layout (see
|
|
-- github.com/vimwiki/vimwiki ftplugin/vimwiki.vim), grouped so users
|
|
-- can flip subgroups off independently via the `mappings` config:
|
|
--
|
|
-- mappings = {
|
|
-- enabled = true,
|
|
-- links = true, -- <CR>, <S-CR>, <Tab>, <BS>, +, …
|
|
-- lists = true, -- <C-Space>, gln/glp, glx, gl<Space>, …
|
|
-- headers = true, -- =, -, ]], [[, ]=, [=, ]u, [u
|
|
-- table_editing = true, -- gqq, gq1, gww, gw1, <A-Left>, <A-Right>
|
|
-- html_export = true, -- <Leader>wh, <Leader>whh
|
|
-- diary = true, -- <C-Down>, <C-Up>, <Leader>w<Leader>*
|
|
-- text_objects = true, -- ah, ih, al, il
|
|
-- wiki_prefix = true, -- <Leader>ww, <Leader>ws, <Leader>wi, …
|
|
-- }
|
|
--
|
|
-- Mappings that point at §13.1-deferred commands stub out to a
|
|
-- "not yet implemented" notification — better than a silent no-op
|
|
-- so users get parity *signalling*, not just parity in lhs.
|
|
|
|
local M = {}
|
|
|
|
local function map(mode, lhs, rhs, opts, bufnr)
|
|
opts = vim.tbl_extend('force', { buffer = bufnr or 0, silent = true }, opts or {})
|
|
vim.keymap.set(mode, lhs, rhs, opts)
|
|
end
|
|
|
|
-- ===== Header navigation (pure Lua) =====
|
|
--
|
|
-- Pure regex jumps, no LSP round-trip. The patterns mirror what
|
|
-- vimwiki's `vimwiki#base#goto_*_header` family does, just simpler:
|
|
-- match `^\s*=` runs as heading lines.
|
|
|
|
local function heading_level(line)
|
|
local lead = line and line:match('^%s*(=+)%s')
|
|
if not lead then return 0 end
|
|
local trail = line:match('%s(=+)%s*$')
|
|
if not trail or #trail ~= #lead then return 0 end
|
|
return #lead
|
|
end
|
|
|
|
local function jump_heading(direction, predicate)
|
|
local total = vim.api.nvim_buf_line_count(0)
|
|
local lnum = vim.fn.line('.')
|
|
local step = direction > 0 and 1 or -1
|
|
local i = lnum + step
|
|
while i >= 1 and i <= total do
|
|
local lvl = heading_level(vim.fn.getline(i))
|
|
if lvl > 0 and predicate(lvl, i) then
|
|
vim.api.nvim_win_set_cursor(0, { i, 0 })
|
|
return
|
|
end
|
|
i = i + step
|
|
end
|
|
end
|
|
|
|
local function current_heading_level()
|
|
local total = vim.api.nvim_buf_line_count(0)
|
|
local lnum = vim.fn.line('.')
|
|
for i = lnum, 1, -1 do
|
|
local lvl = heading_level(vim.fn.getline(i))
|
|
if lvl > 0 then return lvl, i end
|
|
end
|
|
return 0, nil
|
|
end
|
|
|
|
local nav = {}
|
|
function nav.next_header() jump_heading(1, function() return true end) end
|
|
function nav.prev_header() jump_heading(-1, function() return true end) end
|
|
function nav.next_sibling_header()
|
|
local lvl = current_heading_level()
|
|
jump_heading(1, function(l) return l <= lvl end)
|
|
end
|
|
function nav.prev_sibling_header()
|
|
local lvl = current_heading_level()
|
|
jump_heading(-1, function(l) return l <= lvl end)
|
|
end
|
|
function nav.parent_header()
|
|
local lvl = current_heading_level()
|
|
if lvl <= 1 then return end
|
|
jump_heading(-1, function(l) return l < lvl end)
|
|
end
|
|
|
|
-- ===== Link navigation (pure Lua) =====
|
|
--
|
|
-- `<Tab>` / `<S-Tab>` jump to the next/prev `[[…]]` on or after the
|
|
-- cursor line. The server-side `goto_definition` handles the actual
|
|
-- follow; this is just movement.
|
|
|
|
local function jump_to_pattern(pattern, direction)
|
|
-- Vim's `searchpos` handles forward/backward + wrap.
|
|
local flags = direction > 0 and 'W' or 'bW'
|
|
local pos = vim.fn.searchpos(pattern, flags)
|
|
if pos[1] > 0 then
|
|
vim.api.nvim_win_set_cursor(0, { pos[1], pos[2] - 1 })
|
|
end
|
|
end
|
|
local link = {}
|
|
function link.next() jump_to_pattern([[\[\[]], 1) end
|
|
function link.prev() jump_to_pattern([[\[\[]], -1) end
|
|
|
|
-- ===== List "o"/"O" continuation =====
|
|
--
|
|
-- Vimwiki's o/O preserves the list bullet on the new line. We
|
|
-- approximate: when the current line starts with a list marker, insert
|
|
-- the marker on the new line; otherwise fall back to plain o/O.
|
|
|
|
local function open_below_with_bullet()
|
|
local line = vim.fn.getline('.')
|
|
local indent, marker = line:match('^(%s*)([%-%*#])%s')
|
|
if not marker then
|
|
vim.api.nvim_feedkeys('o', 'n', false)
|
|
return
|
|
end
|
|
local prefix = indent .. marker .. ' '
|
|
-- Also preserve checkbox if present
|
|
local checkbox = line:match('^%s*[%-%*#]%s+(%[[%sxXoO%.%-]%])')
|
|
if checkbox then
|
|
prefix = prefix .. '[ ] '
|
|
end
|
|
vim.api.nvim_feedkeys('o' .. prefix, 'n', false)
|
|
end
|
|
local function open_above_with_bullet()
|
|
local line = vim.fn.getline('.')
|
|
local indent, marker = line:match('^(%s*)([%-%*#])%s')
|
|
if not marker then
|
|
vim.api.nvim_feedkeys('O', 'n', false)
|
|
return
|
|
end
|
|
local prefix = indent .. marker .. ' '
|
|
vim.api.nvim_feedkeys('O' .. prefix, 'n', false)
|
|
end
|
|
|
|
-- ===== Stub for deferred §13.1 commands =====
|
|
|
|
local function deferred(name)
|
|
return function()
|
|
vim.notify(
|
|
'nuwiki: ' .. name .. ' is not yet implemented — see SPEC §13.1',
|
|
vim.log.levels.WARN
|
|
)
|
|
end
|
|
end
|
|
|
|
-- ===== Public attach =====
|
|
|
|
function M.attach(bufnr, mappings)
|
|
local cmd = require('nuwiki.commands')
|
|
if not mappings or mappings.enabled == false then
|
|
return
|
|
end
|
|
|
|
-- Default a missing subgroup to true so users only opt *out*.
|
|
local function on(group)
|
|
if mappings[group] == nil then return true end
|
|
return mappings[group] ~= false
|
|
end
|
|
|
|
-- ===== Wiki prefix (`<Leader>w*`) =====
|
|
if on('wiki_prefix') then
|
|
map('n', '<Leader>ww', function() cmd.wiki_index(0) end, { desc = 'nuwiki: open wiki index' }, bufnr)
|
|
map('n', '<Leader>wt', function() cmd.wiki_tab_index(0) end, { desc = 'nuwiki: open wiki index (tab)' }, bufnr)
|
|
map('n', '<Leader>ws', cmd.wiki_ui_select, { desc = 'nuwiki: pick wiki' }, bufnr)
|
|
map('n', '<Leader>wi', cmd.diary_index, { desc = 'nuwiki: open diary index' }, bufnr)
|
|
map('n', '<Leader>w<Leader>w', cmd.diary_today, { desc = 'nuwiki: today' }, bufnr)
|
|
map('n', '<Leader>w<Leader>y', cmd.diary_yesterday, { desc = 'nuwiki: yesterday' }, bufnr)
|
|
map('n', '<Leader>w<Leader>t', cmd.diary_tomorrow, { desc = 'nuwiki: tomorrow' }, bufnr)
|
|
map('n', '<Leader>w<Leader>m', cmd.diary_tomorrow, { desc = 'nuwiki: tomorrow (vimwiki <Leader>w<Leader>m)' }, bufnr)
|
|
map('n', '<Leader>w<Leader>i', cmd.diary_generate_index, { desc = 'nuwiki: rebuild diary index' }, bufnr)
|
|
end
|
|
|
|
-- ===== Links =====
|
|
if on('links') then
|
|
map('n', '<CR>', cmd.follow_link_or_create,
|
|
{ desc = 'nuwiki: follow link (or wrap word + follow)' }, bufnr)
|
|
map('n', '<S-CR>', function() vim.cmd('split'); cmd.follow_link_or_create() end,
|
|
{ desc = 'nuwiki: follow link in split' }, bufnr)
|
|
map('n', '<C-CR>', function() vim.cmd('vsplit'); cmd.follow_link_or_create() end,
|
|
{ desc = 'nuwiki: follow link in vsplit' }, bufnr)
|
|
map('n', '<C-S-CR>', function() vim.cmd('tabnew'); cmd.follow_link_or_create() end,
|
|
{ desc = 'nuwiki: follow link in new tab' }, bufnr)
|
|
map('n', '<BS>', '<C-o>', { desc = 'nuwiki: go back', remap = true }, bufnr)
|
|
map('n', '<Tab>', link.next, { desc = 'nuwiki: next wikilink' }, bufnr)
|
|
map('n', '<S-Tab>', link.prev, { desc = 'nuwiki: prev wikilink' }, bufnr)
|
|
map('n', '+', cmd.normalize_link, { desc = 'nuwiki: wrap word as wikilink' }, bufnr)
|
|
map('x', '+', cmd.normalize_link, { desc = 'nuwiki: wrap word as wikilink' }, bufnr)
|
|
map('n', '<Leader>wn', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr)
|
|
map('n', '<Leader>wd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr)
|
|
map('n', '<Leader>wr', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr)
|
|
map('n', '<Leader>wc', function() cmd.colorize() end,
|
|
{ desc = 'nuwiki: wrap word in colour span' }, bufnr)
|
|
map('x', '<Leader>wc', function() cmd.colorize() end,
|
|
{ desc = 'nuwiki: wrap selection in colour span' }, bufnr)
|
|
end
|
|
|
|
-- ===== Diary nav =====
|
|
if on('diary') then
|
|
map('n', '<C-Down>', cmd.diary_next, { desc = 'nuwiki: next diary entry' }, bufnr)
|
|
map('n', '<C-Up>', cmd.diary_prev, { desc = 'nuwiki: prev diary entry' }, bufnr)
|
|
end
|
|
|
|
-- ===== Lists =====
|
|
if on('lists') then
|
|
-- Three aliases for the same intent. Terminals disagree about
|
|
-- what byte(s) Ctrl+Space produces:
|
|
-- * GUI Neovim / kitty-protocol terminals → `<C-Space>`
|
|
-- * xterm, most legacy terminals → NUL byte → `<C-@>`
|
|
-- * Some Neovim builds spell that same byte `<Nul>` in keymaps
|
|
-- Map all three so the user gets the binding regardless.
|
|
map('n', '<C-Space>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
|
|
map('x', '<C-Space>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
|
|
map('n', '<C-@>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
|
|
map('x', '<C-@>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
|
|
map('n', '<Nul>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
|
|
map('x', '<Nul>', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr)
|
|
map('n', 'gnt', cmd.next_task, { desc = 'nuwiki: next unfinished task' }, bufnr)
|
|
map('n', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr)
|
|
map('x', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr)
|
|
map('n', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr)
|
|
map('x', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr)
|
|
map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
|
|
map('x', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
|
|
map('n', 'glh', function() cmd.list_change_level(-1, false) end,
|
|
{ desc = 'nuwiki: list dedent' }, bufnr)
|
|
map('n', 'gll', function() cmd.list_change_level(1, false) end,
|
|
{ desc = 'nuwiki: list indent' }, bufnr)
|
|
map('n', 'gLh', function() cmd.list_change_level(-1, true) end,
|
|
{ desc = 'nuwiki: list dedent subtree' }, bufnr)
|
|
map('n', 'gLl', function() cmd.list_change_level(1, true) end,
|
|
{ desc = 'nuwiki: list indent subtree' }, bufnr)
|
|
map('n', 'glr', cmd.list_renumber,
|
|
{ desc = 'nuwiki: renumber list' }, bufnr)
|
|
map('n', 'gLr', cmd.list_renumber_all,
|
|
{ desc = 'nuwiki: renumber all lists' }, bufnr)
|
|
map('n', 'gl<Space>', cmd.list_remove_done,
|
|
{ desc = 'nuwiki: remove done items' }, bufnr)
|
|
map('n', 'gL<Space>', cmd.list_remove_done,
|
|
{ desc = 'nuwiki: remove done items (whole doc)' }, bufnr)
|
|
map('n', 'o', open_below_with_bullet, { desc = 'nuwiki: open below + bullet' }, bufnr)
|
|
map('n', 'O', open_above_with_bullet, { desc = 'nuwiki: open above + bullet' }, bufnr)
|
|
|
|
-- Insert-mode bindings (matches upstream vimwiki). The list helpers
|
|
-- mutate the buffer via the LSP / `nvim_buf_set_lines`, both of
|
|
-- which work cleanly from insert mode, so no `<C-o>` dance needed.
|
|
map('i', '<C-D>', function() cmd.list_change_level(-1, false) end,
|
|
{ desc = 'nuwiki: list dedent (insert)' }, bufnr)
|
|
map('i', '<C-T>', function() cmd.list_change_level(1, false) end,
|
|
{ desc = 'nuwiki: list indent (insert)' }, bufnr)
|
|
map('i', '<C-L><C-J>', function() cmd.list_cycle_symbol(1) end,
|
|
{ desc = 'nuwiki: cycle list symbol (insert)' }, bufnr)
|
|
map('i', '<C-L><C-K>', function() cmd.list_cycle_symbol(-1) end,
|
|
{ desc = 'nuwiki: cycle list symbol backward (insert)' }, bufnr)
|
|
map('i', '<C-L><C-M>', cmd.list_toggle_or_add_checkbox,
|
|
{ desc = 'nuwiki: toggle/add checkbox (insert)' }, bufnr)
|
|
|
|
-- Smart <CR>: continue list / table rows. `<expr>` so the function
|
|
-- result is the keystrokes fed — keeps undo coherent.
|
|
map('i', '<CR>', cmd.smart_return,
|
|
{ desc = 'nuwiki: smart return (insert)', expr = true }, bufnr)
|
|
end
|
|
|
|
-- ===== Insert-mode table navigation (Cluster 6) =====
|
|
-- Gated under `table_editing` since it's the same surface.
|
|
if on('table_editing') then
|
|
map('i', '<Tab>', cmd.smart_tab,
|
|
{ desc = 'nuwiki: next table cell (insert)', expr = true }, bufnr)
|
|
map('i', '<S-Tab>', cmd.smart_shift_tab,
|
|
{ desc = 'nuwiki: prev table cell (insert)', expr = true }, bufnr)
|
|
end
|
|
|
|
-- ===== Header levels + nav =====
|
|
if on('headers') then
|
|
-- vimwiki defaults — buffer-local so they don't clash globally.
|
|
map('n', '=', cmd.heading_add_level, { desc = 'nuwiki: heading deeper' }, bufnr)
|
|
map('n', '-', cmd.heading_remove_level, { desc = 'nuwiki: heading shallower' }, bufnr)
|
|
map('n', ']]', nav.next_header, { desc = 'nuwiki: next header' }, bufnr)
|
|
map('n', '[[', nav.prev_header, { desc = 'nuwiki: prev header' }, bufnr)
|
|
map('n', ']=', nav.next_sibling_header, { desc = 'nuwiki: next sibling header' }, bufnr)
|
|
map('n', '[=', nav.prev_sibling_header, { desc = 'nuwiki: prev sibling header' }, bufnr)
|
|
map('n', ']u', nav.parent_header, { desc = 'nuwiki: parent header' }, bufnr)
|
|
map('n', '[u', nav.parent_header, { desc = 'nuwiki: parent header' }, bufnr)
|
|
end
|
|
|
|
-- ===== Tables =====
|
|
if on('table_editing') then
|
|
map('n', 'gqq', cmd.table_align, { desc = 'nuwiki: align table' }, bufnr)
|
|
map('n', 'gq1', cmd.table_align, { desc = 'nuwiki: align table' }, bufnr)
|
|
map('n', 'gww', cmd.table_align, { desc = 'nuwiki: align table (wide)' }, bufnr)
|
|
map('n', 'gw1', cmd.table_align, { desc = 'nuwiki: align table (wide)' }, bufnr)
|
|
map('n', '<A-Left>', cmd.table_move_column_left, { desc = 'nuwiki: move column left' }, bufnr)
|
|
map('n', '<A-Right>', cmd.table_move_column_right, { desc = 'nuwiki: move column right' }, bufnr)
|
|
end
|
|
|
|
-- ===== HTML =====
|
|
if on('html_export') then
|
|
map('n', '<Leader>wh', cmd.export_current, { desc = 'nuwiki: export to HTML' }, bufnr)
|
|
map('n', '<Leader>whh', cmd.export_browse, { desc = 'nuwiki: export + browse' }, bufnr)
|
|
map('n', '<Leader>wha', cmd.export_all, { desc = 'nuwiki: export all' }, bufnr)
|
|
end
|
|
|
|
-- ===== Mouse (opt-in via `mappings.mouse = true`) =====
|
|
-- Mirrors upstream vimwiki: double-click follows, shift/ctrl-double
|
|
-- open in split/vsplit, middle-click adds the linked file to the
|
|
-- buffer list, right-click + left-click goes back.
|
|
if mappings.mouse then
|
|
map('n', '<2-LeftMouse>', cmd.follow_link_or_create,
|
|
{ desc = 'nuwiki: follow link (mouse)' }, bufnr)
|
|
map('n', '<S-2-LeftMouse>', function() vim.cmd('split'); cmd.follow_link_or_create() end,
|
|
{ desc = 'nuwiki: follow link in split (mouse)' }, bufnr)
|
|
map('n', '<C-2-LeftMouse>', function() vim.cmd('vsplit'); cmd.follow_link_or_create() end,
|
|
{ desc = 'nuwiki: follow link in vsplit (mouse)' }, bufnr)
|
|
map('n', '<MiddleMouse>', cmd.badd_link,
|
|
{ desc = 'nuwiki: badd link target' }, bufnr)
|
|
map('n', '<RightMouse>', '<C-o>',
|
|
{ desc = 'nuwiki: go back (mouse)', remap = true }, bufnr)
|
|
end
|
|
end
|
|
|
|
return M
|