Read-only mirror of https://codeberg.org/andyg/leap.nvim
1local hl = require('leap.highlight')
2local opts = require('leap.opts')
3
4local api = vim.api
5
6-- "Beacon" is an umbrella term for any kind of visual overlay tied to
7-- targets - in practice, either a label character, or a highlighting of
8-- the match itself. Technically an [offset extmark-opts] tuple, where
9-- the latter is an option table expected by `nvim_buf_set_extmark()`.
10
11local M = {}
12
13local function set_beacon_to_match_hl(target)
14 local col = target.pos[2]
15 local ch1, ch2 = unpack(target.chars)
16 local extmark_opts =
17 ch1 == '' -- on empty lines...
18 and { virt_text = { { ' ', hl.group.match } } } -- ...fill the column
19 or { end_col = col + ch1:len() + ch2:len() - 1, hl_group = hl.group.match }
20 target.beacon = { 0, extmark_opts }
21end
22
23local function set_beacon_to_concealed_label(target)
24 local virt_text = target.beacon[2].virt_text
25 if virt_text then virt_text[1][1] = opts.concealed_label end
26end
27
28local function get_label_offset(target)
29 return target.chars[1]:len()
30 + (target.is_at_win_edge and 0 or target.chars[2]:len())
31end
32
33local function set_beacon_for_labeled(target, group_offset, phase)
34 local has_ch2 = target.chars and (target.chars[2] ~= '')
35 local pad = (has_ch2 and not phase) and ' ' or ''
36 local label = opts.substitute_chars[target.label] or target.label
37 local relative_group = target.group - (group_offset or 0)
38 -- In unlabeled matches are not highlighted, then "no highlight"
39 -- should be the very signal for "no further keystrokes needed", so
40 -- in that case it is mandatory to show all labeled positions in some
41 -- way. (Note: We're keeping this on even after phase one - sudden
42 -- visual changes should be avoided as much as possible.)
43 local show_all = phase and not opts.highlight_unlabeled_phase_one_targets
44 local virt_text =
45 relative_group == 1
46 and { { label .. pad, hl.group.label } }
47 or (relative_group == 2 or show_all and relative_group > 2)
48 and { { opts.concealed_label .. pad, hl.group.label_dimmed } }
49
50 if not virt_text then
51 target.beacon = nil
52 else
53 local offset = (target.chars and phase) and get_label_offset(target) or 0
54 local extmark_opts = { virt_text = virt_text }
55 target.beacon = { offset, extmark_opts }
56 end
57end
58
59function M.set_beacons(targets, kwargs)
60 local group_offset = kwargs.group_offset
61 local use_no_labels = kwargs.use_no_labels
62 local phase = kwargs.phase
63
64 if use_no_labels then
65 -- User-given targets might not have `chars`.
66 if targets[1].chars then
67 for _, target in ipairs(targets) do
68 set_beacon_to_match_hl(target)
69 end
70 else
71 for _, target in ipairs(targets) do
72 target.beacon = nil
73 end
74 end
75 else
76 for _, target in ipairs(targets) do
77 if target.label then
78 if phase ~= 1 or target.is_previewable then
79 set_beacon_for_labeled(target, group_offset, phase)
80 end
81 elseif
82 phase == 1
83 and target.is_previewable
84 and opts.highlight_unlabeled_phase_one_targets
85 then
86 set_beacon_to_match_hl(target)
87 end
88 end
89 end
90end
91
92--- After setting the beacons in a context-unaware manner, the following
93--- conflicts can occur:
94---
95--- (A) Two labels on top of each other (possible at EOL or window edge,
96--- where labels need to be shifted left).
97---
98--- x1 lbl |
99--- y1 y2 lbl |
100--- -------------
101--- -3 -2 -1 |
102---
103--- (B) An unlabeled match touches the label of another match (possible
104--- if the label is shifted, just like above). This is unacceptable - it
105--- looks like the label is for the unlabeled target:
106---
107--- x1 lbl |
108--- y1 y2 |
109--- -------------
110--- -3 -2 -1 |
111---
112--- (C) An unlabeled match covers a label.
113---
114--- Fix: switch the label(s) to an empty one. This keeps things simple
115--- from a UI perspective (no special beacon for marking conflicts). An
116--- empty label next to, or on top of an unlabeled match (case B and C)
117--- is not ideal, but the important thing is to avoid accidents - typing
118--- a label by mistake. A possibly unexpected autojump on these rare
119--- occasions is a relatively minor nuisance.
120--- Show the empty label even if unlabeled targets are set to be
121--- highlighted, and remove the match highlight instead, for a similar
122--- reason - to prevent (falsely) expecting an autojump. (In short:
123--- always err on the safe side.)
124---
125function M.resolve_conflicts(targets)
126 -- Tables to help us check potential conflicts (we'll be filling them
127 -- as we go):
128 -- { "<buf> <win> <lnum> <col>" = <target-obj> }
129 local unlabeled_match_pos = {}
130 local label_pos = {}
131 -- We do only one traversal run, and we don't assume anything about
132 -- the ordering of the targets; a particular conflict will always be
133 -- resolved the second time we encounter the conflicting pair - at
134 -- that point, one of them will already have been registered as a
135 -- potential source of conflict. That is why we need to check two
136 -- separate subcases for both A and B (for C, they are the same).
137 for _, target in ipairs(targets) do
138 local is_empty_line = (target.chars[1] == '') and (target.pos[2] == 0)
139 if not is_empty_line then
140 local buf = target.wininfo.bufnr
141 local win = target.wininfo.winid
142 local lnum = target.pos[1]
143 local col_ch1 = target.pos[2]
144 local col_ch2 = col_ch1 + (target.chars[1]):len()
145 local key_prefix = buf .. ' ' .. win .. ' ' .. lnum .. ' '
146
147 if target.label and target.beacon then
148 -- Active label.
149 local label_offset = target.beacon[1]
150 local col_label = col_ch1 + label_offset
151 local label_shifted = col_label == col_ch2
152 local other =
153 -- label on top of label (A)
154 -- [-][a][L]| | current
155 -- [a][a][L]| | other
156 -- ^ | column to check
157 -- or
158 -- [a][a][L]|
159 -- [-][a][L]|
160 -- ^
161 label_pos[key_prefix .. col_label]
162 -- label touches unlabeled (B1)
163 -- [-][a][L]|
164 -- [a][a][-]|
165 -- ^
166 or label_shifted and unlabeled_match_pos[key_prefix .. col_ch1]
167 -- label covered by unlabeled (C1)
168 -- [a][b][L][-]
169 -- [-][-][a][c]
170 -- ^
171 -- or
172 -- [a][a][L]
173 -- [-][a][b]
174 -- ^
175 or unlabeled_match_pos[key_prefix .. col_label]
176 if other then
177 other.beacon = nil
178 set_beacon_to_concealed_label(target)
179 end
180 -- Do this LAST (chasing our own tail is not funny).
181 label_pos[key_prefix .. col_label] = target
182 else
183 -- No label (unlabeled or inactive).
184 local col_ch3 = col_ch2 + (target.chars[2]):len()
185 local other =
186 -- unlabeled covers label (C2)
187 -- [-][-][a][b]
188 -- [a][c][L][-]
189 -- ^
190 label_pos[key_prefix .. col_ch1]
191 -- unlabeled covers label (C2)
192 -- [-][a][b]
193 -- [a][a][L]
194 -- ^
195 or label_pos[key_prefix .. col_ch2]
196 -- unlabeled touches label (B2)
197 -- [a][a][-]|
198 -- [-][a][L]|
199 -- ^
200 or label_pos[(key_prefix .. col_ch3)]
201 if other then
202 target.beacon = nil
203 set_beacon_to_concealed_label(other)
204 end
205 unlabeled_match_pos[key_prefix .. col_ch1] = target
206 unlabeled_match_pos[key_prefix .. col_ch2] = target
207 end
208 end
209 end
210end
211
212do
213 local ns = api.nvim_create_namespace('')
214 -- Register each newly set extmark in a table, so that we can delete
215 -- them one by one, without needing any further contextual
216 -- information. This is relevant if we process user-given targets and
217 -- have no knowledge about the boundaries of the search area.
218 local extmarks = {}
219
220 local function light_up_beacon(target, at_endpos)
221 local lnum, col = unpack(at_endpos and target.endpos or target.pos)
222 local buf = target.wininfo.bufnr
223 local offset, extmark_opts_ = unpack(target.beacon)
224 local extmark_opts = vim.tbl_extend('keep', extmark_opts_, {
225 virt_text_pos = opts.virt_text_pos or 'overlay',
226 hl_mode = 'combine',
227 priority = hl.priority.label,
228 strict = false,
229 })
230 local id = api.nvim_buf_set_extmark(
231 buf, ns, lnum - 1, col - 1 + offset, extmark_opts
232 )
233 table.insert(extmarks, { buf, id })
234 end
235
236 function M.light_up_beacons(targets, start_idx, end_idx)
237 if opts.on_beacons and opts.on_beacons(targets, start_idx, end_idx) == false then
238 return
239 end
240
241 for i = (start_idx or 1), (end_idx or #targets) do
242 local target = targets[i]
243 if target.beacon then
244 light_up_beacon(target)
245 if target.endpos then
246 light_up_beacon(target, true)
247 end
248 end
249 end
250 -- Set auto-cleanup.
251 api.nvim_create_autocmd('User', {
252 pattern = { 'LeapRedraw', 'LeapLeave' },
253 once = true,
254 callback = function()
255 for _, t in ipairs(extmarks) do
256 local buf, id = unpack(t)
257 if api.nvim_buf_is_valid(buf) then
258 api.nvim_buf_del_extmark(buf, ns, id)
259 end
260 end
261 extmarks = {}
262 end
263 })
264 end
265end
266
267return M