-- 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, -- , , , , +, … -- lists = true, -- , gln/glp, glx, gl/gL, … -- headers = true, -- =, -, ]], [[, ]=, [=, ]u, [u -- table_editing = true, -- gqq, gq1, gww, gw1, , -- html_export = true, -- wh, whh -- diary = true, -- , , w* -- text_objects = true, -- ah, ih, al, il -- wiki_prefix = true, -- ww, ws, wi, … -- } 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) ===== -- -- `` / `` 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 -- ===== 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 -- Configurable wiki command prefix (vimwiki's `g:vimwiki_map_prefix`). -- Every `…` mapping below is built from this so users can relocate -- the whole family by setting `map_prefix` in setup(). local p = require('nuwiki.config').options.map_prefix or 'w' -- ===== Wiki prefix (`*`, default `w*`) ===== if on('wiki_prefix') then map('n', p .. 'w', function() cmd.wiki_index(0) end, { desc = 'nuwiki: open wiki index' }, bufnr) map('n', p .. 't', function() cmd.wiki_tab_index(0) end, { desc = 'nuwiki: open wiki index (tab)' }, bufnr) map('n', p .. 's', cmd.wiki_ui_select, { desc = 'nuwiki: pick wiki' }, bufnr) map('n', p .. 'i', cmd.diary_index, { desc = 'nuwiki: open diary index' }, bufnr) map('n', p .. 'w', cmd.diary_today, { desc = 'nuwiki: today' }, bufnr) map('n', p .. 'y', cmd.diary_yesterday, { desc = 'nuwiki: yesterday' }, bufnr) map('n', p .. 't', cmd.diary_today_tab, { desc = 'nuwiki: today in a new tab' }, bufnr) map('n', p .. 'm', cmd.diary_tomorrow, { desc = 'nuwiki: tomorrow (vimwiki m)' }, bufnr) map('n', p .. 'i', cmd.diary_generate_index, { desc = 'nuwiki: rebuild diary index' }, bufnr) end -- ===== Links ===== if on('links') then map('n', '', cmd.follow_link_or_create, { desc = 'nuwiki: follow link (or wrap word + follow)' }, bufnr) map('n', '', function() vim.cmd('split'); cmd.follow_link_or_create() end, { desc = 'nuwiki: follow link in split' }, bufnr) map('n', '', function() vim.cmd('vsplit'); cmd.follow_link_or_create() end, { desc = 'nuwiki: follow link in vsplit' }, bufnr) map('n', '', cmd.follow_link_drop, { desc = 'nuwiki: follow link in tab (reuse existing)' }, bufnr) map('n', '', cmd.follow_link_drop, { desc = 'nuwiki: follow link in tab (macOS Cmd alias of )' }, bufnr) map('n', '', cmd.badd_link, { desc = 'nuwiki: add link target to buffer list' }, bufnr) map('n', '', '', { desc = 'nuwiki: go back', remap = true }, bufnr) map('n', '', link.next, { desc = 'nuwiki: next wikilink' }, bufnr) map('n', '', link.prev, { desc = 'nuwiki: prev wikilink' }, bufnr) map('n', '+', cmd.normalize_link, { desc = 'nuwiki: wrap word as wikilink' }, bufnr) map('x', '+', function() cmd.normalize_link(true) end, { desc = 'nuwiki: wrap selection as wikilink' }, bufnr) -- Upstream binds visual to the same normalize-link as visual `+`. map('x', '', function() cmd.normalize_link(true) end, { desc = 'nuwiki: wrap selection as wikilink' }, bufnr) map('n', p .. 'n', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr) map('n', p .. 'd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr) map('n', p .. 'r', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr) map('n', p .. 'c', function() cmd.colorize() end, { desc = 'nuwiki: wrap word in colour span' }, bufnr) map('x', p .. 'c', function() cmd.colorize(nil, true) end, { desc = 'nuwiki: wrap selection in colour span' }, bufnr) end -- ===== Diary nav ===== if on('diary') then map('n', '', cmd.diary_next, { desc = 'nuwiki: next diary entry' }, bufnr) map('n', '', 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 → `` -- * xterm, most legacy terminals → NUL byte → `` -- * Some Neovim builds spell that same byte `` in keymaps -- Map all three so the user gets the binding regardless. map('n', '', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr) map('x', '', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr) map('n', '', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr) map('x', '', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr) map('n', '', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr) map('x', '', 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_back, { desc = 'nuwiki: decrement checkbox' }, bufnr) map('x', 'glp', cmd.cycle_list_item_back, { 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) -- Upstream binds case-variant aliases gLH/gLL/gLR == gLh/gLl/gLr. 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', 'gLR', cmd.list_renumber_all, { desc = 'nuwiki: renumber all lists' }, bufnr) -- gl/gL: set the current item's / whole list's marker (upstream -- VimwikiChangeSymbolTo / ChangeSymbolInListTo). 1) is command-only. for _, pair in ipairs({ { '-', '-' }, { '*', '*' }, { '#', '#' }, { '1', '1.' }, { 'i', 'i)' }, { 'I', 'I)' }, { 'a', 'a)' }, { 'A', 'A)' }, }) do local key, sym = pair[1], pair[2] map('n', 'gl' .. key, function() cmd.list_change_symbol(sym, false) end, { desc = 'nuwiki: set item marker to ' .. sym }, bufnr) map('n', 'gL' .. key, function() cmd.list_change_symbol(sym, true) end, { desc = 'nuwiki: set list markers to ' .. sym }, bufnr) end -- Bare gl/gL: remove the checkbox marker, keep the item text (upstream -- VimwikiRemoveSingleCB / RemoveCBInList). They share the `gl…` prefix -- with the maps above, so they fire after `timeoutlen` — exactly as -- upstream. Remove-done is command-only (`:NuwikiRemoveDone[!]`). map('n', 'gl', cmd.list_remove_checkbox, { desc = 'nuwiki: remove checkbox (current item)' }, bufnr) map('n', 'gL', cmd.list_remove_checkbox_in_list, { desc = 'nuwiki: remove checkboxes (whole list)' }, 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 `` dance needed. map('i', '', function() cmd.list_change_level(-1, false) end, { desc = 'nuwiki: list dedent (insert)' }, bufnr) map('i', '', function() cmd.list_change_level(1, false) end, { desc = 'nuwiki: list indent (insert)' }, bufnr) map('i', '', function() cmd.list_cycle_symbol(1) end, { desc = 'nuwiki: cycle list symbol (insert)' }, bufnr) map('i', '', function() cmd.list_cycle_symbol(-1) end, { desc = 'nuwiki: cycle list symbol backward (insert)' }, bufnr) map('i', '', cmd.list_toggle_or_add_checkbox, { desc = 'nuwiki: toggle/add checkbox (insert)' }, bufnr) -- Smart : continue list / table rows. `` so the function -- result is the keystrokes fed — keeps undo coherent. map('i', '', cmd.smart_return, { desc = 'nuwiki: smart return (insert)', expr = true }, bufnr) -- Smart : multiline list item (continuation with no new marker). map('i', '', cmd.smart_shift_return, { desc = 'nuwiki: multiline list item (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', '', cmd.smart_tab, { desc = 'nuwiki: next table cell (insert)', expr = true }, bufnr) map('i', '', 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', function() cmd.table_align_or_cmd('gqq') end, { desc = 'nuwiki: align table / gqq' }, bufnr) map('n', 'gq1', function() cmd.table_align_or_cmd('gqq') end, { desc = 'nuwiki: align table / gqq' }, bufnr) map('n', 'gww', function() cmd.table_align_or_cmd('gww') end, { desc = 'nuwiki: align table / gww' }, bufnr) map('n', 'gw1', function() cmd.table_align_or_cmd('gww') end, { desc = 'nuwiki: align table / gww' }, bufnr) map('n', '', cmd.table_move_column_left, { desc = 'nuwiki: move column left' }, bufnr) map('n', '', cmd.table_move_column_right, { desc = 'nuwiki: move column right' }, bufnr) end -- ===== HTML ===== if on('html_export') then map('n', p .. 'h', cmd.export_current, { desc = 'nuwiki: export to HTML' }, bufnr) map('n', p .. 'hh', cmd.export_browse, { desc = 'nuwiki: export + browse' }, bufnr) map('n', p .. 'ha', 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', '', function() vim.cmd('split'); cmd.follow_link_or_create() end, { desc = 'nuwiki: follow link in split (mouse)' }, bufnr) map('n', '', function() vim.cmd('vsplit'); cmd.follow_link_or_create() end, { desc = 'nuwiki: follow link in vsplit (mouse)' }, bufnr) map('n', '', cmd.badd_link, { desc = 'nuwiki: badd link target' }, bufnr) map('n', '', '', { desc = 'nuwiki: go back (mouse)', remap = true }, bufnr) end end return M