diff --git a/README.md b/README.md index 8494dce..d45ce68 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,36 @@ This plugin is a fork of which is excellent and which I used happily for a long time. Big thanks to the original author for a clean, focused implementation. -I forked it because I wanted to pass label highlight attributes directly -to `setup()` (instead of having to define a separate highlight group and -re-apply it on every colorscheme change). I submitted that as a PR but -it was declined, which is the maintainer's right — the upstream plugin -is explicitly considered feature-complete. See the closed PR for -context: . +I forked it because I wanted to pass label highlight attributes directly to +`setup()` (instead of having to define a separate highlight group and re-apply +it on every colorscheme change). I submitted that as a +(PR)[https://github.com/yorickpeterse/nvim-jump/pull/8] but it was declined, +which is the maintainer's right — the upstream plugin is explicitly considered +feature-complete. -Since the upstream code is MPL-2.0, a fork is fair game. This plugin is -a tiny extension of it — same core behavior, plus a slightly friendlier -highlight API — maintained under the `tiny-*` family of my other -Neovim plugins. +Since the upstream code is MPL-2.0, a fork is fair game. This plugin is a tiny +extension of it — same core behavior, plus a slightly friendlier highlight API — +maintained under the `tiny-*` family of my other Neovim plugins. + +## Size vs. the alternatives + +`tokei lua/` (code lines only, ignoring comments and blank lines), run +against a fresh `git clone --depth=1` of each project: + +| Plugin | Files | SLOC | +| -------------------------------------------------------------------------------- | ----: | ----: | +| **swaits/tiny-jump.nvim** (this fork) | 1 | 126 | +| [yorickpeterse/nvim-jump](https://github.com/yorickpeterse/nvim-jump) (upstream) | 1 | 131 | +| [echasnovski/mini.jump2d](https://github.com/echasnovski/mini.nvim) | 1 | 553 | +| [rlane/pounce.nvim](https://github.com/rlane/pounce.nvim) | 4 | 634 | +| [smoka7/hop.nvim](https://github.com/smoka7/hop.nvim) | 18 | 1,489 | +| [ggandor/leap.nvim](https://github.com/ggandor/leap.nvim) | 11 | 2,549 | +| [folke/flash.nvim](https://github.com/folke/flash.nvim) | 22 | 2,695 | + +Despite fixing a double-`setup()` bug, adding inline label-color configuration, +cursor-proximity sorting, and two-char labels for overflow matches, this fork is +slightly _smaller_ than upstream — and an order of magnitude smaller than every +other alternative on this list. ## Requirements diff --git a/lua/tiny-jump/init.lua b/lua/tiny-jump/init.lua index 8967cb6..5e0cbe6 100644 --- a/lua/tiny-jump/init.lua +++ b/lua/tiny-jump/init.lua @@ -6,55 +6,42 @@ -- License, v. 2.0. If a copy of the MPL was not distributed with this -- file, You can obtain one at http://mozilla.org/MPL/2.0/. -local api = vim.api -local fn = vim.fn -local M = {} +local api, fn, M = vim.api, vim.fn, {} M.config = { - -- The labels that may be used, in order of their preference. Ordered by - -- QWERTY finger strength (index > middle > ring > pinky, home > top > - -- bottom) with hand alternation to break ties. labels = 'fjdkslahgeiruowmcnvptyqxzb', - - -- The highlight group to use for match highlights. search = 'Search', - - -- The highlight group to use for labels. May be a group name (string) or - -- a table of `nvim_set_hl` attributes. label = 'IncSearch', } -local K = { - CR = vim.keycode(''), - BS = vim.keycode(''), - C_H = vim.keycode(''), - ESC = vim.keycode(''), -} +-- Termcode constants. BS and C_H cover both backspace variants terminals send. +local K = { CR = vim.keycode(''), BS = vim.keycode(''), C_H = vim.keycode(''), ESC = vim.keycode('') } local NS = api.nvim_create_namespace('tiny-jump') --- Find all matches of `pattern` in `lines` and compute the set of labels that --- do not conflict with the character immediately following any match (so that --- typing the next input character cannot be mistaken for a label). +-- Scan the visible lines for matches of `pattern` and, as a side product, +-- compute `avail[c]` = true for each label char `c` that is *safe* to use as a +-- label — i.e. `c` is never the character immediately following a match. If it +-- were, pressing `c` would be ambiguous: jump-to-label-c vs extend-pattern-by-c. local function scan(pattern, lines, start_line) + -- smartcase: lowercase-only pattern → case-insensitive search local lower = pattern == pattern:lower() local matches, avail = {}, {} for i = 1, #M.config.labels do avail[M.config.labels:sub(i, i)] = true end for idx, line in ipairs(lines) do - local search_line = lower and line:lower() or line + local sline = lower and line:lower() or line local col = 1 while true do - local s, e = search_line:find(pattern, col, true) - if not s then - break - end + local s, e = sline:find(pattern, col, true) + if not s then break end col = e + 1 - matches[#matches + 1] = { - line = start_line + idx - 2, - start_col = s - 1, - end_col = e, - } + -- start_line is 1-indexed (Vim convention), buffer rows are 0-indexed, and + -- ipairs's idx is also 1-indexed — the `- 2` reconciles both offsets. + matches[#matches + 1] = { line = start_line + idx - 2, start_col = s - 1, end_col = e } + -- Mark the char after this match as unavailable for labeling. Note this + -- is a *global* disqualification: any one match with 'e' next disables + -- the 'e' label for all matches. avail[line:sub(e + 1, e + 1):lower()] = false end end @@ -67,29 +54,31 @@ function M.start() local info = fn.getwininfo(win)[1] local top = info.topline local lines = api.nvim_buf_get_lines(buf, top - 1, info.botline, true) + -- State that persists across input keystrokes: + -- chars = accumulated search pattern typed so far + -- active = map from label-key (1 or 2 chars) to { row, col } target + -- prefixes = set of chars that are the first char of some 2-char label; + -- input loop checks this to decide whether to buffer a 2nd key local chars, active, prefixes = '', {}, {} while true do api.nvim_echo({ { '/' .. chars, '' } }, false, {}) local ch = fn.getcharstr(-1) - if prefixes[ch] then - ch = ch .. fn.getcharstr(-1) - end + -- Two-char label mode: if `ch` is a known prefix, wait for a 2nd keystroke + -- and treat the pair as the label key. + if prefixes[ch] then ch = ch .. fn.getcharstr(-1) end local jump_to = active[ch] if ch == K.ESC then break elseif ch == K.CR then + -- jumps to whichever labeled match has the strongest label — i.e. + -- the one that appears earliest in the label-preference string. for i = 1, #M.config.labels do - local c = M.config.labels:sub(i, i) - if active[c] then - jump_to = active[c] - break - end - end - if jump_to then - api.nvim_win_set_cursor(win, jump_to) + jump_to = active[M.config.labels:sub(i, i)] + if jump_to then break end end + if jump_to then api.nvim_win_set_cursor(win, jump_to) end break elseif ch == K.BS or ch == K.C_H then chars = chars:sub(1, -2) @@ -100,65 +89,66 @@ function M.start() chars = chars .. ch end + -- Fresh scan per keystroke: clear everything that was drawn, then redraw + -- from the new `chars` value. `active` and `prefixes` must be regenerated + -- because label-to-position mapping depends on the current match set. active, prefixes = {}, {} api.nvim_buf_clear_namespace(buf, NS, 0, -1) if #chars > 0 then local matches, avail = scan(chars, lines, top) + -- Sort matches by cursor proximity so the N closest get the N strongest + -- labels. Primary: line distance. Secondary: column distance. local cr = api.nvim_win_get_cursor(win) local cl, cc = cr[1] - 1, cr[2] table.sort(matches, function(a, b) local da, db = math.abs(a.line - cl), math.abs(b.line - cl) - if da ~= db then - return da < db - end + if da ~= db then return da < db end return math.abs(a.start_col - cc) < math.abs(b.start_col - cc) end) local L = M.config.labels - local two = #matches > #L - local line_intervals = {} - local li = 1 + -- Switch to two-char labels when there are more matches than single-char + -- labels. Pair labels expand capacity to up to |L|^2 (676 with defaults), + -- at the cost of requiring two keystrokes per jump while `two` is true. + local two, ivs_by_line, li = #matches > #L, {}, 1 for _, m in ipairs(matches) do - vim.hl.range( - buf, - NS, - M.config.search, - { m.line, m.start_col }, - { m.line, m.end_col }, - { priority = 200 } - ) + vim.hl.range(buf, NS, M.config.search, { m.line, m.start_col }, { m.line, m.end_col }, { priority = 200 }) local label - if two then - if li <= #L * #L then - local a = math.floor((li - 1) / #L) + 1 - local b = (li - 1) % #L + 1 - label = L:sub(a, a) .. L:sub(b, b) - end - else + if two and li <= #L * #L then + -- Pair encoding: `a` (slow-varying) is the first char, `b` (fast- + -- varying) is the second. So labels li=1..#L all share the same + -- first char, keeping `prefixes` minimal for as long as possible — + -- important because every char in `prefixes` is a char the user + -- can no longer use to extend the search pattern in one keystroke. + local a, b = math.floor((li - 1) / #L) + 1, (li - 1) % #L + 1 + label = L:sub(a, a) .. L:sub(b, b) + elseif not two then + -- Single-char: walk the cursor past any label that's unavailable + -- (would conflict with pattern extension). while li <= #L and not avail[L:sub(li, li)] do li = li + 1 end - if li <= #L then - label = L:sub(li, li) - end + if li <= #L then label = L:sub(li, li) end end if label then + -- Skip this match's label if it would visually overlap (or, in 2-char + -- mode, touch within 1 column of) any label already placed on this + -- line. The match still gets a search highlight; the unused label + -- carries over to the next non-conflicting match. local gap = two and 1 or 0 - local ivs = line_intervals[m.line] or {} + local ivs = ivs_by_line[m.line] or {} local fits = true for _, iv in ipairs(ivs) do - if - m.start_col < iv[2] + gap - and iv[1] < m.start_col + #label + gap - then + -- Standard [a, b) interval-overlap test with `gap` padding on each side. + if m.start_col < iv[2] + gap and iv[1] < m.start_col + #label + gap then fits = false break end end if fits then - if two then - prefixes[label:sub(1, 1)] = true - end + -- Record the 2-char label's first char so the input loop knows + -- to buffer a second keystroke when it sees that char. + if two then prefixes[label:sub(1, 1)] = true end active[label] = { m.line + 1, m.start_col } api.nvim_buf_set_extmark(buf, NS, m.line, m.start_col, { virt_text = { { label, M.config.label } }, @@ -166,7 +156,7 @@ function M.start() priority = 201, }) ivs[#ivs + 1] = { m.start_col, m.start_col + #label } - line_intervals[m.line] = ivs + ivs_by_line[m.line] = ivs li = li + 1 end end @@ -183,18 +173,21 @@ end function M.setup(opts) M.config = vim.tbl_extend('force', M.config, opts or {}) - local group = - api.nvim_create_augroup('tiny-jump.highlights', { clear = true }) + -- Recreate the augroup on every setup() call (clear = true drops any prior + -- autocmds). Without this, repeated setup calls with different label configs + -- would accumulate stale ColorScheme autocmds. + local group = api.nvim_create_augroup('tiny-jump.highlights', { clear = true }) if type(M.config.label) == 'table' then + -- User passed highlight attrs directly. Own a dedicated highlight group + -- (TinyJumpLabel) and re-apply it on ColorScheme so their colors survive + -- theme changes — colorschemes clear user-defined highlights. local attrs = M.config.label M.config.label = 'TinyJumpLabel' - -- Apply now (setup may run after the colorscheme is already loaded) and - -- on every ColorScheme event (themes clear user-defined highlights). - local function apply() - api.nvim_set_hl(0, 'TinyJumpLabel', attrs) - end - apply() - api.nvim_create_autocmd('ColorScheme', { group = group, callback = apply }) + api.nvim_set_hl(0, 'TinyJumpLabel', attrs) + api.nvim_create_autocmd('ColorScheme', { + group = group, + callback = function() api.nvim_set_hl(0, 'TinyJumpLabel', attrs) end, + }) end end diff --git a/stylua.toml b/stylua.toml index e6cdba3..22a2c63 100644 --- a/stylua.toml +++ b/stylua.toml @@ -1,4 +1,5 @@ -column_width = 80 +column_width = 120 indent_type = 'Spaces' indent_width = 2 quote_style = 'AutoPreferSingle' +collapse_simple_statement = 'Always'