Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom beacons #65

Open
zbirenbaum opened this issue Oct 14, 2022 · 10 comments
Open

Custom beacons #65

zbirenbaum opened this issue Oct 14, 2022 · 10 comments
Labels
api enhancement New feature or request

Comments

@zbirenbaum
Copy link

I have read through the readme and the help file, and tried every highlight group present, but I can't replicate the photo in the readme which shows a search where typed characters in a search have their own highlight group. Was this feature removed for some reason?

For eye strain and focus reasons, I'd like to have a grey backdrop, where if I press s followed by a, all 'a' characters will be highlighted according to some highlight group. Ideally, a separate highlight group would also be available for all characters following the first one, so that I can see more clearly what the different options are.

Right now leap feels a bit 'dead' compared to lightspeed, and the lack of visual feedback is making it really hard to swap over.

@ggandor
Copy link
Owner

ggandor commented Oct 15, 2022

I think what you're looking for is opts.highlight_unlabeled_phase_one_targets = true. It is opt-in, since it's actually redundant, and Leap aims for reducing the visual noise as much as possible by default. I also added an entry to the FAQ on how to set "Lightspeed-style" highlighting.

@zbirenbaum
Copy link
Author

zbirenbaum commented Oct 17, 2022

I think what you're looking for is opts.highlight_unlabeled_phase_one_targets = true. It is opt-in, since it's actually redundant, and Leap aims for reducing the visual noise as much as possible by default. I also added an entry to the FAQ on how to set "Lightspeed-style" highlighting.

Thank you for the response. I was using that option, but I don't think it does quite what I was looking for. I pasted in the entry from the faq on lightspeed style highlighting, but the only time matches are highlighted is if they are unique and don't have a red label next to them. What I was looking for was for all of them to be highlighted, regardless of whether it has a label or not.

@zbirenbaum
Copy link
Author

zbirenbaum commented Oct 17, 2022

As an update, I managed to reproduce the documentation highlighting, it just didn't work how I thought it did.

What I really wanted was the ability to clear the backdrop highlight from chars pressed, and highlight the next possible next chars in the sequence with a different color than the label or the highlight applied to the unlabeled matches.

Without the backdrop, it is too difficult to see immediately what the jump options are, but with the backdrop it is too difficult to read the next character in the sequence. I realize this isn't an issue if you know the two chars ahead of time, but I would prefer to have progressive feedback as sometimes I lose my train of thought.

I tried implementing this with a action function, but it doesn't execute until after I have selected two chars and a label so it is impossible.

I have followed your development of lightspeed and leap for a while, and I recognize this might not be consistent with your goals for the project, as some would argue it encourages a more 'step-by-step' approach. I believe that this what leap was written to avoid, so feel free to close this if the feature is something you would rather not have as part of the project.

@zbirenbaum
Copy link
Author

zbirenbaum commented Oct 17, 2022

Nevermind, I managed to implement it. Here's a demo and the source.

demo.mp4

The code is kinda messy since I hacked it together, but for anyone who wants something similar, here it is:

local keymap = vim.keymap
local leap = require('leap')
local ns = vim.api.nvim_create_namespace('leap_custom')
local prio = 65535

leap.setup({
  highlight_unlabeled_phase_one_targets = false,
})

local api = vim.api
local hex_to_rgb = function(hex_str)
  local hex = "[abcdef0-9][abcdef0-9]"
  local pat = "^#(" .. hex .. ")(" .. hex .. ")(" .. hex .. ")$"
  hex_str = string.lower(hex_str)
  assert(string.find(hex_str, pat) ~= nil, "hex_to_rgb: invalid hex_str: " .. tostring(hex_str))
  local red, green, blue = string.match(hex_str, pat)
  return { tonumber(red, 16), tonumber(green, 16), tonumber(blue, 16) }
end

local blend = function(fg, bg, alpha)
  fg = hex_to_rgb(fg)
  bg = hex_to_rgb(bg)
  local blendChannel = function(i)
    local ret = (alpha * fg[i] + ((1 - alpha) * bg[i]))
    return math.floor(math.min(math.max(0, ret), 255) + 0.5)
  end
  return string.format("#%02X%02X%02X", blendChannel(1), blendChannel(2), blendChannel(3))
end

local hl_char_one = '#FFFFFF'
local hl_char_two = blend(hl_char_one, '#565c64', .5)
vim.api.nvim_set_hl(0, 'LeapHighlightChar1', {fg = '#FFFFFF', bold = true})
vim.api.nvim_set_hl(0, 'LeapHighlightChar2', {fg = hl_char_two, bold = true})

local extmarks = {}
local state = { prev_input = nil }

local function custom_motion(kwargs)
  require('leap').opts.safe_labels = {}
  local function get_input()
    vim.cmd('echo ""')
    local hl = require('leap.highlight')
    if vim.v.count == 0 and not (kwargs.unlabeled and vim.fn.mode(1):match('o')) then
      hl['apply-backdrop'](hl, kwargs.cc.backward)
    end
    hl['highlight-cursor'](hl)
    vim.cmd('redraw')
    local ch = require('leap.util')['get-input-by-keymap']({str = ">"})
    hl['cleanup'](hl, { vim.fn.getwininfo(vim.fn.win_getid())[1] })
    if not ch then
      return
    end
    -- Repeat with the previous input?
    local repeat_key = require('leap.opts').special_keys.repeat_search
    if ch == api.nvim_replace_termcodes(repeat_key, true, true, true) then
      if state.prev_input then
        ch = state.prev_input
      else
        require('leap.util').echo('no previous search')
        return
      end
    else
      state.prev_input = ch
    end
    return ch
  end

  local function get_pattern(input, max)
    local chars = require('leap.opts').eq_class_of[input]
    if chars then
      chars = vim.tbl_map(function (ch)
        if ch == '\n' then
          return '\\n'
        elseif ch == '\\' then
          return '\\\\'
        else return ch end
      end, chars or {})
      input = '\\(' .. table.concat(chars, '\\|') .. '\\)'  -- "\(a\|b\|c\)"
    end
    return '\\V' .. (kwargs.multiline == false and '\\%.l' or '') .. input
  end

  local function get_targets(pattern, max)
    local search = require('leap.search')
    local bounds = search['get-horizontal-bounds']()
    local get_char_at = require('leap.util')['get-char-at']
    local targets = {}
    for pos in search['get-match-positions'](
        pattern, bounds, { ['backward?'] = kwargs.cc.backward }
    ) do
      local char1 = get_char_at(pos, {})
      local char2 = get_char_at({pos[1], pos[2]+1}, {})
      table.insert(targets, { pos = {pos[1], pos[2]+1 }, chars={ char1, char2 } })
    end
    return targets
  end

  -- local get_targets = require('leap.search')['get-targets']
  local input = get_input()
  local pattern = get_pattern(input)
  local targets = get_targets(pattern, {})
  for _, target in ipairs(targets) do
    local extmark_pos = tostring(target.pos[1]) .. ':' .. tostring(target.pos[2])
    extmarks[extmark_pos] = {
      vim.api.nvim_buf_set_extmark(0, ns, target.pos[1]-1, target.pos[2]-2, {
        hl_group = 'LeapHighlightChar1',
        end_col = target.pos[2]-1,
        strict = false,
        priority = prio,
      }),
      vim.api.nvim_buf_set_extmark(0, ns, target.pos[1]-1, target.pos[2]-1, {
        hl_group = 'LeapHighlightChar2',
        end_col = target.pos[2],
        strict = false,
        priority = prio,
      })
    }
  end
  local input2 = get_input()
  if input2 then
    input = input .. input2
  else
    return {}
  end
  pattern = get_pattern(input)
  local new_targets = get_targets(pattern, {})

  for i, target in ipairs(new_targets) do
    extmarks[tostring(target.pos[1]) .. ':' .. tostring(target.pos[2]-1)] = nil
    new_targets[i].pos = { target.pos[1], target.pos[2]-1 }
  end

  for _, id in pairs(extmarks) do
    vim.api.nvim_buf_del_extmark(0, ns, id[1])
    vim.api.nvim_buf_del_extmark(0, ns, id[2])
  end
  return new_targets
end

local create_mappings = function ()
  local modes = { 'n', 'x', 'o' }
  local opts = { noremap = true, silent = true }
  local mappings = {
    ['s'] = { backward = false },
    ['S'] = { backward = true },
  }
  for mapping, opt in pairs(mappings) do
    keymap.set(modes, mapping, function ()
      require('leap').leap({
        targets = custom_motion({ cc =  opt }),
        offset = 0
      })
    end, opts)
  end
end

create_mappings()

vim.api.nvim_set_hl(0, 'LeapBackdrop', { link = 'Comment' })

vim.api.nvim_set_hl(0, 'LeapMatch', {
  fg = 'white',
  bold = true,
  nocombine = true,
})

vim.api.nvim_create_autocmd('User', {
  pattern = 'LeapLeave',
  callback = function ()
    vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
  end
})

There's a small bug when the character is at the end of the line with nothing after it, but it shouldn't be too hard to fix. I'd like to have the label show after the first keypress as leap usually does, but my instinct is that that's not going to be easy to implement. I'll take a look at fixing the end of line issue and label issue some time later, as it is quite late now.

@ggandor
Copy link
Owner

ggandor commented Oct 18, 2022

I recognize this might not be consistent with your goals for the project, as some would argue it encourages a more 'step-by-step' approach. I believe that this what leap was written to avoid

Yes, exactly. The very idea is that you simply look at the target, type the two characters you see, and then type the label, if appears, it's not some step-by-step "okay... I've entered the first one, let's see, what shall I do next..."-kind of process. The label is the only relevant information, everything else (except in some rare cases) is redundant noise.

Nevermind, I managed to implement it. Here's a demo and the source.

Wow, this is impressive though :)

I've been thinking about exposing a beacon callback, and optional argument to leap (similar to action), that gets the target as argument. (The target structure has the position, the label, and the matched characters themselves, so everything that is needed to create a custom "beacon".) One problem is that it must be "phase-independent" (otherwise it would be too complicated), that is, you couldn't implement things like making the match highlight disappear after the second input, like in your demo.

@zbirenbaum
Copy link
Author

zbirenbaum commented Oct 19, 2022

I've been thinking about exposing a beacon callback, and optional argument to leap (similar to action), that gets the target as argument. (The target structure has the position, the label, and the matched characters themselves, so everything that is needed to create a custom "beacon".) One problem is that it must be "phase-independent" (otherwise it would be too complicated), that is, you couldn't implement things like making the match highlight disappear after the second input, like in your demo.

I'd love to see this! Don't worry about it being phase agnostic. Currently, light-up-beacons is private, but if users can make a callback within it which would allow for setting up user extmarks or override its behavior completely (which is what it sounds like you are thinking about doing), then everything is super easy.

Clearing the highlights can be done by overriding the cleanup function in leap.highlight which executes after each keypress, and therefore is capable of deleting user extmarks at any point. Since it also receives self as a parameter, most of the relevant information known to leap is accessible.

If the user doesn't care about the efficiency of looping over the extmarks an additional time, this is as simple as:

local cleanup = require('leap.highlight').cleanup

require('leap.highlight').cleanup = function (self, affected_windows)
  print('User can make callback here')
  cleanup(self, affected_windows)
end

If the user doesn't mind their code being a bit longer, for very minor efficiency gains, they could do this:

local util = require("leap.util")
local inc = util["inc"]
local dec = util["dec"]

require('leap.highlight').cleanup = function(self, affected_windows)
  for _, context in ipairs(self.extmarks) do
    local bufnr = context[1]
    local id = context[2]
    -- Here is the inserted callback
    print('user can make callback for specific extmark without an additional loop over self.extmarks here')
    api.nvim_buf_del_extmark(bufnr, self.ns, id)
  end
  self.extmarks = {}

  if pcall(api.nvim_get_hl_by_name, self.group.backdrop, false) then
    for _, wininfo in ipairs(affected_windows) do
      api.nvim_buf_clear_namespace(wininfo.bufnr, self.ns, dec(wininfo.topline), wininfo.botline)
    end
    return api.nvim_buf_clear_namespace(0, self.ns, dec(vim.fn.line("w0")), vim.fn.line("w$"))
  else
    return nil
  end
end

All the user has to do is maintain a user_extmarks table indexed by the id of the leap extmarks, and they can find the id of the additional extmarks they placed by doing user_extmarks[id] and then delete it with nvim_buf_del_extmark.

@ggandor ggandor changed the title Reproduce Documentation Highlighting? Custom beacons Oct 19, 2022
@ggandor ggandor added enhancement New feature or request api and removed enhancement New feature or request labels Oct 19, 2022
@ggandor
Copy link
Owner

ggandor commented Jun 19, 2023

Clearing the highlights can be done by overriding the cleanup function in leap.highlight which executes after each keypress, and therefore is capable of deleting user extmarks at any point.

I think it's even simpler, users can add their extmarks to the highlight.extmarks table, just like the native light-up-beacons does, and then they will be cleaned up together with the default ones in each phase, won't they?

@ggandor
Copy link
Owner

ggandor commented Jun 19, 2023

Currently, light-up-beacons is private, but if users can make a callback within it which would allow for setting up user extmarks or override its behavior completely

That sounds pretty clean actually... the callback might return a boolean, so one might override the whole thing, or just decorate the original function if they want:

(light-up-beacons [targets ...]
  (when (or (not user-callback) (user-callback targets ...))
    (<default stuff>)))

@zbirenbaum
Copy link
Author

zbirenbaum commented Jul 13, 2023

Currently, light-up-beacons is private, but if users can make a callback within it which would allow for setting up user extmarks or override its behavior completely

That sounds pretty clean actually... the callback might return a boolean, so one might override the whole thing, or just decorate the original function if they want:

(light-up-beacons [targets ...]
  (when (or (not user-callback) (user-callback targets ...))
    (<default stuff>)))

I've been running into issues lately since I currently have to override a lot of the behavior in ways that that break frequently when code logic or function signatures change.

Do you have an idea of an implementation spec for this so I can get the ball rolling on a PR?

ggandor added a commit that referenced this issue Jul 17, 2023
This is an unofficial feature allowing DIY hacks, for people who know
what they are doing. Might be removed anytime. Do not rely on it for
anything serious (like a publicly released extension plugin).

Related: #65
@ggandor
Copy link
Owner

ggandor commented Jul 17, 2023

I just added that line as it is, as an undocumented "escape hatch", so that you folks can experiment with it right away. Feedback is welcome. The advantage of using an opts field is that you can make general tweaks to the highlighting, in addition to call-specific ones.

Example - this is how one could implement highlight_unlabeled_phase_one_targets for themselves, without setting it in opts:

function highlight_unlabeled_phase_one_targets(targets, first_idx, last_idx)
  local hl = require('leap.highlight')
  for i = first_idx or 1, last_idx or #targets do
    local target = targets[i]
    if not target.label and target.chars then
      local bufnr = target.wininfo.bufnr
      local id = vim.api.nvim_buf_set_extmark(
          bufnr, hl.ns, target.pos[1] - 1, target.pos[2] - 1,
          {
            virt_text = { { table.concat(target.chars), "LeapMatch" } },
            virt_text_pos = "overlay",
            hl_mode = "combine",
            priority = hl.priority.label,
          }
      )
      -- This way Leap automatically cleans up your stuff together with its own.
      table.insert(hl.extmarks, { bufnr, id })
    end
  end
  -- Continue with Leap's native function body.
  return true
end

require('leap').opts.on_beacons = highlight_unlabeled_phase_one_targets

To always highlight all matches, just remove not target.label from the the condition:

if target.chars then

@ggandor ggandor pinned this issue Jul 17, 2023
@ggandor ggandor unpinned this issue Nov 28, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants