Read-only mirror of https://codeberg.org/andyg/leap.nvim
at main 267 lines 9.9 kB view raw
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