abo-abo / avy

Jump to things in Emacs tree-style
1.71k stars 109 forks source link

Is it possible to make a space match a hyphen (or any other character) for avy-goto-char-timer? #322

Open Dima-369 opened 3 years ago

Dima-369 commented 3 years ago
(setq web-mode-enable-current-column-highlight t)
(setq web-mode-enable-current-element-highlight t)

Spaces are handled in a really great way in swiper where entering web mode would still match the 2 web-mode lines correctly.

I am so used from counsel-M-x to never actually type the hyphen but just entering spaces that I often stumble when using avy.

Is there similar functionality in avy? I checked the configuration but I couldn't find anything.


My avy use case is mainly via avy-goto-char-timer where a single typo often wipes all the input (because the idle time triggers and I notice it too late).

obar commented 2 years ago

I like this idea, which could perhaps use a configurable char for use like a . wildcard in regex. I'd use that! Think I'd set , so I can still use space and dot as unique chars (they come up often enough in my buffers!) without needing a shifted wildcard char.

obar commented 2 years ago

There's actually an optional re-builder we can pass to avy--read-candidates which makes implementation simple. Without going to the full solution of making it easily customizable, here's the quick-and-dirty redefinition I've put in my config. I'll test drive this for a bit and see how it goes, feel free to do the same @Gira-X; this works with space as the wildcard. If it's useful I'll polish and make a pull request.

  (defun obar/avy--space-wildcard-regexp (s)
    (string-replace " " "." (regexp-quote s)))

  (defun avy-goto-char-timer (&optional arg)
    "Read one or many consecutive chars and jump to the first one.
The window scope is determined by `avy-all-windows' (ARG negates it)."
    (interactive "P")
    (let ((avy-all-windows (if arg
                               (not avy-all-windows)
                             avy-all-windows)))
      (avy-with avy-goto-char-timer
        (setq avy--old-cands (avy--read-candidates #'obar/avy--space-wildcard-regexp))
        (avy-process avy--old-cands))))
Dima-369 commented 2 years ago

That is amazing, yes it works fairly well for me as well!

Although, not sure where string-replace comes from as I am on Emacs 27; but using s-replace-regexp shows the expected behavior.

obar commented 2 years ago

Hmm, you don't have that one? It's built in for me but I'm on 28.

From s.el you'd want s-replace because you don't need a regexp replacement of that space, though in this case they are the same thing :)

Dima-369 commented 2 years ago

On GNU Emacs 27.2 (build 1, x86_64-apple-darwin18.5.0, Carbon Version 158 AppKit 1671.4) of 2021-04-05 there is no (string-replace).

From s.el you'd want s-replace because you don't need a regexp replacement of that space, though in this case they are the same thing :)

Ah yes, true!

obar commented 2 years ago

I just played around with a little improvement I like even more. This uses different possibilities of what to match with space, including the rest of a word and an optional space or hyphen at the end, a chunk of whitespace, or any one character if the rest don't match nicely.

It's neat because I can type "e o v" and jump to the start of "end-of-visual-line" or "envy of vim users", without many extra matches in the buffer, and it still works for a specific char in the middle of a word.

For lack of a better name...

  (defun obar/avy--space-super-wildcard (s)
    (string-replace " " "\\(\\)\\([[:alpha:]]+[- ]?\\|[[:space:]]+\\|.\\)" (regexp-quote s)))

  (defun avy-goto-char-timer (&optional arg)
    "Read one or many consecutive chars and jump to the first one.
The window scope is determined by `avy-all-windows' (ARG negates it)."
    (interactive "P")
    (let ((avy-all-windows (if arg
                               (not avy-all-windows)
                             avy-all-windows)))
      (avy-with avy-goto-char-timer
        (setq avy--old-cands (avy--read-candidates #'obar/avy--space-super-wildcard))
        (avy-process avy--old-cands))))

I needed to include an extra empty group at the start because apparently there's an edge condition in avy if the match includes a single group, which is not an issue if there are two or more matching groups.

Dima-369 commented 2 years ago

Amazing!

Adding an asterisks here: [- ]*? allows matching against (end--of) by typing in e o as well which I like because some internal Elisp functions have this naming format.

I am definitely going to keep this in my config. Thanks for finding this out :)

obar commented 2 years ago

Here's a little update from (over-)using this for a few days. My realizations:

First, like Gira-X I found that this wildcard gets more useful as it is further generalized. I started by adding slashes for paths, then other leading or trailing characters, and then the broadest generalization occurred to me: it would be nice if a wildcard took you to the next alpha character, so your hands don't have to leave the alphas on the keyboard. Along those lines I was also considering a stricter requirement to match to the end of a word so the wildcard wouldn't match the middle of a word, but decided against it for camel-case friendliness (which could be more easily tackled if emacs regexps were a little more powerful, but I didn't dig into it further).

Second, there can be ambiguity in what we wish to match when jumping to a subword char, and because avy--read-candidates is using re-search-forward in a loop we only get the match that starts the earliest. For that reason, it makes sense to change that loop slightly to allow overlapping patterns and get a couple more subword matches before that first wildcard. The only change is the addition of the (goto-char (1+ ... expression.

Third, if the char we want to jump to isn't an alpha, it'd be nice if we could use a wildcard for it. However, starting the pattern in ivy with a wildcard as described previously can easily capture too much and give many possible matches, so the regexp builder should special-case any leading spaces as single-char wildcards.

Fourth, for backward compatibility it makes sense to use s.el which aliases these functions in later emacs versions.

Finally, a corollary that it might be useful to allow leading space wildcards as single char matches in the avy-goto-*-1 variants, so one could go to a specific position before those words in one step (as in, I want to get to the non-alpha char before that word rather than the start of that word). A space would let the function continue accepting input. Perhaps a future experiment for someone to try.

With all that in mind, here's the latest code in my config:

(defun obar/avy--space-super-wildcard (s)
  "Treat all leading spaces in S as single-char matches, and the rest as
special multi-matches to capture everything to the next alpha character."
  (let* ((lead-wild-count (length (car (s-match "^ +" s))))
         (trunc-s (substring s lead-wild-count))
         (lead-replacement (make-string lead-wild-count ?.)))
    (concat lead-replacement
            (s-replace
             " " "\\(\\)\\([[:alpha:]]+[^[:alpha:]\r\n]*\\|[^[:alpha:]\r\n]+\\)"
             (regexp-quote trunc-s)))))

(defun avy--read-candidates (&optional re-builder)
  "Read as many chars as possible and return their occurrences.
At least one char must be read, and then repeatedly one next char
may be read if it is entered before `avy-timeout-seconds'.  DEL
deletes the last char entered, and RET exits with the currently
read string immediately instead of waiting for another char for
`avy-timeout-seconds'.
The format of the result is the same as that of `avy--regex-candidates'.
This function obeys `avy-all-windows' setting.
RE-BUILDER is a function that takes a string and returns a regex.
When nil, `regexp-quote' is used.
If a group is captured, the first group is highlighted.
Otherwise, the whole regex is highlighted."
  (setq avy-text "")
  (let ((re-builder (or re-builder #'regexp-quote))
        char break overlays regex)
    (unwind-protect
        (progn
          (avy--make-backgrounds
           (avy-window-list))
          (while (and (not break)
                      (setq char
                            (read-char (format "%d  char%s: "
                                               (length overlays)
                                               (if (string= avy-text "")
                                                   avy-text
                                                 (format " (%s)" avy-text)))
                                       t
                                       (and (not (string= avy-text ""))
                                            avy-timeout-seconds))))
            ;; Unhighlight
            (dolist (ov overlays)
              (delete-overlay ov))
            (setq overlays nil)
            (cond
             ;; Handle RET
             ((= char 13)
              (if avy-enter-times-out
                  (setq break t)
                (setq avy-text (concat avy-text (list ?\n)))))
             ;; Handle C-h, DEL
             ((memq char avy-del-last-char-by)
              (let ((l (length avy-text)))
                (when (>= l 1)
                  (setq avy-text (substring avy-text 0 (1- l))))))
             ;; Handle ESC
             ((= char 27)
              (keyboard-quit))
             (t
              (setq avy-text (concat avy-text (list char)))))
            ;; Highlight
            (when (>= (length avy-text) 1)
              (let ((case-fold-search
                     (or avy-case-fold-search (string= avy-text (downcase avy-text))))
                    found)
                (avy-dowindows current-prefix-arg
                  (dolist (pair (avy--find-visible-regions
                                 (window-start)
                                 (window-end (selected-window) t)))
                    (save-excursion
                      (goto-char (car pair))
                      (setq regex (funcall re-builder avy-text))
                      (while (re-search-forward regex (cdr pair) t)
                        (unless (not (avy--visible-p (1- (point))))
                          (let* ((idx (if (= (length (match-data)) 4) 1 0))
                                 (ov (make-overlay
                                      (match-beginning idx) (match-end idx))))
                            (setq found t)
                            (push ov overlays)
                            (overlay-put
                             ov 'window (selected-window))
                            (overlay-put
                             ov 'face 'avy-goto-char-timer-face)
                            (goto-char (1+ (match-beginning idx)))))))))
                ;; No matches at all, so there's surely a typo in the input.
                (unless found (beep)))))
          (nreverse (mapcar (lambda (ov)
                              (cons (cons (overlay-start ov)
                                          (overlay-end ov))
                                    (overlay-get ov 'window)))
                            overlays)))
      (dolist (ov overlays)
        (delete-overlay ov))
      (avy--done))))

(defun avy-goto-char-timer (&optional arg)
  "Read one or many consecutive chars and jump to the first one.
The window scope is determined by `avy-all-windows' (ARG negates it)."
  (interactive "P")
  (let ((avy-all-windows (if arg
                             (not avy-all-windows)
                           avy-all-windows)))
    (avy-with avy-goto-char-timer
      (setq avy--old-cands (avy--read-candidates #'obar/avy--space-super-wildcard))
      (avy-process avy--old-cands))))