462 lines
17 KiB
EmacsLisp
462 lines
17 KiB
EmacsLisp
;;; evil-leap.el --- Evil's answer to Neovim's answer to the mouse -*- lexical-binding: t; -*-
|
||
;;
|
||
;; Copyright (C) 2024 Madeleine Sydney
|
||
;;
|
||
;; Author: Madeleine Sydney <lomiskiam@gmail.com>
|
||
;; Maintainer: Madeleine Sydney <lomiskiam@gmail.com>
|
||
;; Created: October 06, 2024
|
||
;; Modified: October 06, 2024
|
||
;; Version: 0.0.1
|
||
;; Keywords: convenience text
|
||
;; Homepage: https://github.com/crumbtoo/leap.el
|
||
;; Package-Requires: ((emacs "26.1") (evil "1.2.12") (ht "2.0"))
|
||
;;
|
||
;; This file is not part of GNU Emacs.
|
||
;;
|
||
;;; Commentary:
|
||
;; Evil leap.
|
||
;;
|
||
;;
|
||
;;; Code:
|
||
|
||
(require 'evil)
|
||
(eval-when-compile (require 'evil))
|
||
(require 'ht)
|
||
(require 'cl-lib)
|
||
|
||
|
||
; Options
|
||
|
||
(defcustom evil-leap-safe-labels
|
||
(string-to-list "sfnut/SFNLHMUGTZ?")
|
||
"TODO A list of characters that you will never use immediately after an
|
||
evil-leap jump."
|
||
:group 'evil-leap
|
||
:type "List of characters")
|
||
|
||
(defcustom evil-leap-labels
|
||
(string-to-list "sfnjklhodweimbuyvrgtaqpcxz/SFNJKLHODWEIMBUYVRGTAQPCXZ?")
|
||
;; (append evil-leap-safe-labels
|
||
;; (string-to-list "jklhodweimbyvrgaqpcxzJKODWEIBYVRAQPCX"))
|
||
"TODO A list of characters to use as labels."
|
||
:group 'evil-leap
|
||
:type "List of characters")
|
||
|
||
(defcustom evil-leap-character-aspect-ratio
|
||
0.3
|
||
"TODO"
|
||
:group 'evil-leap
|
||
:type "Number")
|
||
|
||
(defcustom evil-leap-equivalence-classes '((?\s ?\n ?\t ?\r))
|
||
"TODO"
|
||
:group 'evil-leap
|
||
:type "List of lists of characters")
|
||
|
||
(defface evil-leap-label-face '((t :inherit isearch))
|
||
"TODO"
|
||
:group 'evil-leap)
|
||
|
||
(defface evil-leap-further-label-face '((t :inherit shadow))
|
||
"TODO"
|
||
:group 'evil-leap)
|
||
|
||
(defcustom evil-leap-use-char-fold-table t
|
||
"TODO"
|
||
:group 'evil-leap
|
||
:type "Boolean")
|
||
|
||
|
||
; Core
|
||
|
||
(cl-defstruct
|
||
(evil-leap-target
|
||
(:constructor evil-leap-make-target))
|
||
"TODO"
|
||
pos
|
||
line
|
||
column
|
||
;; TODO: Perhaps we should be tracking /buffers/ rather than windows...
|
||
(window
|
||
nil
|
||
:type "TODO window or nil"
|
||
:documentation
|
||
"TODO nil means current window")
|
||
overlay
|
||
payload)
|
||
|
||
(defun evil-leap--target-at-point ()
|
||
"TODO"
|
||
(evil-leap-make-target :pos (point)
|
||
:line (line-number-at-pos)
|
||
:column (current-column)
|
||
:window (or (minibuffer-selected-window)
|
||
(selected-window))))
|
||
|
||
(define-inline evil-leap-jump-to-target (target)
|
||
(when-let ((window (evil-leap-target-window target)))
|
||
(select-window window))
|
||
(goto-char (evil-leap-target-pos target)))
|
||
|
||
(defvar evil-leap-action #'evil-leap-jump-to-target)
|
||
|
||
(defun evil-leap--remove-all-labels ()
|
||
(remove-overlays nil nil 'category 'evil-leap))
|
||
|
||
;; ₁₂
|
||
|
||
(defun evil-leap--add-overlay-target! (target label)
|
||
"TODO returns overlay and sets overlay slot"
|
||
;; The label shall be displayed two characters /after/ `pos'. This ensures
|
||
;; the user is able to see the entirety of the target; that is, the first
|
||
;; character, located at `pos', and the second, immediately following.
|
||
(let* ((pos (evil-leap-target-pos target))
|
||
;; If `target' has an associated window, grab the buffer associated
|
||
;; thereupon — we need to place the overlay in the correct buffer, of
|
||
;; course!
|
||
(buffer (-some-> (evil-leap-target-window target)
|
||
window-buffer))
|
||
;; If overlay already exists, just make sure it's in the correct
|
||
;; position. If it doesn't exist, create it.
|
||
(overlay (if-let ((ov (evil-leap-target-overlay target)))
|
||
(move-overlay ov (+ pos 2) (+ pos 3)
|
||
buffer)
|
||
(make-overlay (+ pos 2) (+ pos 3)
|
||
buffer)))
|
||
(label-string (string label)))
|
||
(put-text-property 0 1
|
||
'face (if (eq label ?\s)
|
||
'evil-leap-further-label-face
|
||
'evil-leap-label-face)
|
||
label-string)
|
||
(overlay-put overlay 'category 'evil-leap)
|
||
(overlay-put overlay 'before-string label-string)
|
||
;; Normally, we want the labels to cover the buffer's text, rather than
|
||
;; shifting it to the side; however, doing so unconditionally will cover
|
||
;; newlines, collapsing multiple visual lines onto one.
|
||
(unless (memq (char-after (+ pos 2)) '(?\n ?\r))
|
||
(overlay-put overlay 'invisible t))
|
||
(setf (evil-leap-target-overlay target) overlay)
|
||
overlay))
|
||
|
||
(defun evil-leap--add-to-labeled-targets! (labeled-targets label target)
|
||
"TODO Mutate LABELED-TARGETS by inserting value TARGET under key LABEL — with
|
||
spice!
|
||
|
||
Spice: If an entry with key LABEL already exists in LABELED-TARGETS, a fresh
|
||
hash table will be created and inserted into LABELED-TARGETS under key '?\\s'.
|
||
This fresh hash table will itself have a key '?\\d' pointing back to
|
||
LABELED-TARGETS. If this '?\\s' entry already exists in LABELED-TARGETS, it
|
||
will instead try to insert it into /that/ hash table, continuing until a
|
||
suitable home is found.
|
||
|
||
;; Spice: Since the expected appearance of label overlays is so tightly coupled
|
||
;; with the above logic, the overlays are also created by this routine. Sorry!
|
||
|
||
This all implies that important parts of evil-leap will enter the eldritch world
|
||
of Undefined Behaviour, should either `evil-leap-safe-labels' or
|
||
`evil-leap-labels' contain '?\\s' or '?\\d'."
|
||
;; If the label is already present in `labeled-targets', we insert it into the
|
||
;; "next map" at key '?\s'.
|
||
(if (ht-get labeled-targets label)
|
||
;; If labeled-targets[?\s] (the "next map") exists, ...
|
||
(if-let ((next-labeled-targets (ht-get labeled-targets ?\s)))
|
||
;; ... recurse into it. ...
|
||
(evil-leap--add-to-labeled-targets!
|
||
next-labeled-targets label target)
|
||
;; ... If it doesn't, create a new hash table, stored in
|
||
;; `labeled-targets' at key '?\s', containing nought but a back-link key
|
||
;; ('?\d')[1], and the given label/target pair[2].
|
||
(ht-set! labeled-targets ?\s
|
||
(ht (?\d labeled-targets) ; [1]
|
||
(label target)))) ; [2]
|
||
;; The label does not exist, meaning we needn't muck around with the next
|
||
;; map. Proceed normally with `ht-set!'!
|
||
(ht-set! labeled-targets label target)))
|
||
|
||
(defun evil-leap--label-targets-safe (targets)
|
||
"TODO returns a hash table mapping labels to targets"
|
||
;; Nasty! While I'd prefer to iterate over `zip evil-leap-safe-labels
|
||
;; targets`, this isn't Haskell; each iteration through `targets', we pop
|
||
;; `labels'.
|
||
(let ((labels evil-leap-safe-labels)
|
||
(labeled-targets (ht-create 'eq)))
|
||
;; The first target is assigned the label `auto-jump', to indicate that it can be
|
||
;; auto-jumped to. The remaining targets are labeled as you'd expect.
|
||
(ht-set! labeled-targets 'auto-jump (car targets))
|
||
(dolist (target (cdr targets) labeled-targets)
|
||
(let ((lbl (pop labels)))
|
||
;; (evil-leap--add-overlay-target! target lbl)
|
||
(evil-leap--add-to-labeled-targets! labeled-targets lbl target)))))
|
||
|
||
(defun evil-leap--label-targets-unsafe (targets)
|
||
"TODO returns a hash table mapping labels to targets"
|
||
;; TODO: space labels don't work
|
||
(let ((labels (-cycle evil-leap-labels))
|
||
(labeled-targets (ht-create 'eq)))
|
||
(dolist (target targets labeled-targets)
|
||
(let ((lbl (pop labels)))
|
||
;; (evil-leap--add-overlay-target! target lbl)
|
||
(evil-leap--add-to-labeled-targets! labeled-targets lbl target)))))
|
||
|
||
(defun evil-leap--label-targets (targets)
|
||
"TODO returns a hash table mapping labels to targets. Labels are
|
||
represented by characters. Adds overlays for labels"
|
||
;; Safe labels are preferred, but are not available when there are more
|
||
;; targets than safe labels.
|
||
(let ((safe? (<= (length targets) (length evil-leap-safe-labels))))
|
||
;; ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||
;; FIXME: I think this should be (+ 1 (length evil-leap-safe-labels)) to
|
||
;; account for the "anonymous label" given to the auto-jump target.
|
||
(if safe?
|
||
(evil-leap--label-targets-safe targets)
|
||
(evil-leap--label-targets-unsafe targets))))
|
||
|
||
(defun evil-leap--update-further-overlays! (labeled-targets)
|
||
(ht-each (lambda (k v)
|
||
(cond ((eq k ?\s)
|
||
(evil-leap--update-further-overlays! v))
|
||
((evil-leap-target-p v)
|
||
(evil-leap--add-overlay-target! v ?\s))
|
||
;; Auto-jumps and back-links are met with no-ops.
|
||
(t nil)))
|
||
labeled-targets))
|
||
|
||
(defun evil-leap--update-earlier-overlays! (labeled-targets)
|
||
(ht-each (lambda (k v)
|
||
(cond ((eq k ?\d)
|
||
(evil-leap--update-earlier-overlays! v))
|
||
((evil-leap-target-p v)
|
||
(when-let ((overlay (evil-leap-target-overlay v)))
|
||
(delete-overlay overlay)))
|
||
;; Auto-jumps and fore-links are met with no-ops.
|
||
(t nil)))
|
||
labeled-targets))
|
||
|
||
;; REVIEW: This could be more efficient. We needn't traverse /every/ target.
|
||
(defun evil-leap--update-target-overlays! (labeled-targets)
|
||
"TODO Create or update overlays to visually present a set of targets'
|
||
associated labels."
|
||
;; TODO: blah
|
||
(ht-each (lambda (k v)
|
||
(cond
|
||
;; No-op; auto-jump targets are not labeled in the typical sense.
|
||
((eq k 'auto-jump)
|
||
nil)
|
||
|
||
;; Label every further overlay.
|
||
((eq k ?\s)
|
||
(evil-leap--update-further-overlays! v))
|
||
|
||
;; Delete every previous overlay.
|
||
((eq k ?\d)
|
||
(evil-leap--update-earlier-overlays! v))
|
||
|
||
;; Targets are simply labeled with their label. }:D
|
||
((evil-leap-target-p v)
|
||
(evil-leap--add-overlay-target! v k))))
|
||
labeled-targets))
|
||
|
||
(defun evil-leap--euclidean-distance (x1 y1 x2 y2)
|
||
(let* ((width (abs (- x1 x2)))
|
||
(height (* (abs (- y1 y2))
|
||
evil-leap-character-aspect-ratio))
|
||
(hypotenuse (sqrt (+ (* width width)
|
||
(* height height)))))
|
||
hypotenuse))
|
||
|
||
(defun evil-leap--sort-by-euclidean-distance-from-point (targets)
|
||
(let ((point-x (current-column))
|
||
(point-y (line-number-at-pos)))
|
||
(sort targets
|
||
:lessp
|
||
(lambda (target-1 target-2)
|
||
(< (evil-leap--euclidean-distance
|
||
point-x
|
||
point-y
|
||
(evil-leap-target-column target-1)
|
||
(evil-leap-target-line target-1))
|
||
(evil-leap--euclidean-distance
|
||
point-x
|
||
point-y
|
||
(evil-leap-target-column target-2)
|
||
(evil-leap-target-line target-2)))))))
|
||
|
||
(defun evil-leap--try-auto-jump (labeled-targets)
|
||
(when-let ((auto-jump-target (ht-get labeled-targets 'auto-jump)))
|
||
(funcall evil-leap-action auto-jump-target)))
|
||
|
||
(defun evil-leap-select-from-labeled-targets (labeled-targets)
|
||
"Given LABELED-TARGETS, a hash table mapping labels to targets,
|
||
- auto-jump to the first target, if possible;
|
||
- read a key from the user;
|
||
- if the key corresponds to a label, jump to it;
|
||
- otherwise, let the input `fall through,' as if Leap were never here."
|
||
;; REVIEW: Should the return value of `evil-leap-action' be returned by us?
|
||
;; (with-output-to-temp-buffer (format "*select - %s*" (current-time))
|
||
;; (pp labeled-targets))
|
||
(evil-leap--try-auto-jump labeled-targets)
|
||
(evil-leap--update-target-overlays! labeled-targets)
|
||
(let ((given-key (evil-read-key)))
|
||
(if-let ((selected-target (ht-get labeled-targets given-key)))
|
||
;; '?\s' and '?\d' are special targets that lead to alternative sets
|
||
;; of labeled targets; if we find another hash table, select from that
|
||
;; one instead.
|
||
(if (ht-p selected-target)
|
||
(evil-leap-select-from-labeled-targets selected-target)
|
||
(funcall evil-leap-action selected-target))
|
||
;; If we are given a key that does not correspond to any label, we let
|
||
;; it 'fall through' to give it a proper run through Emacs' command
|
||
;; loop. We do this as a formality in the general case, but it is
|
||
;; essential to the idea behind auto-jump.
|
||
|
||
;; REVIEW: Should the passthrough behaviour be configurable in the
|
||
;; non-auto-jump case?
|
||
(progn (push given-key unread-command-events)
|
||
nil))))
|
||
|
||
|
||
; Labels
|
||
|
||
(defun evil-leap--label-branch-targets! (branches)
|
||
"Mutate BRANCHES by calling `evil-leap--label-targets' on each value, and
|
||
replacing it with the result."
|
||
(ht-each (lambda (k tgts)
|
||
(ht-set! branches k (evil-leap--label-targets tgts)))
|
||
branches))
|
||
|
||
(defun evil-leap--unlabel-other-branches (second-char branches)
|
||
"Delete every overlay referenced by BRANCHES, *except* those under the branch
|
||
with key SECOND-CHAR."
|
||
(ht-each
|
||
(lambda (k labeled-targets)
|
||
(unless (eq k second-char)
|
||
(ht-each
|
||
(lambda (_label target)
|
||
;; 1. A value in `labeled-targets' isn't always a target (e.g. entries
|
||
;; at '?\s' and '?\d').
|
||
;; 2. If a target is an auto-jump candidate, its `overlay' slot is nil.
|
||
(when-let ((overlay (and (evil-leap-target-p target)
|
||
(evil-leap-target-overlay target))))
|
||
(delete-overlay overlay)))
|
||
labeled-targets)))
|
||
branches))
|
||
|
||
(defun evil-leap--show-potential-targets (branches)
|
||
(ht-each (lambda (_key labeled-targets)
|
||
(evil-leap--update-target-overlays! labeled-targets))
|
||
branches))
|
||
|
||
|
||
; Gathering
|
||
|
||
(defun evil-leap--insert-branch-target (table second-char target)
|
||
(ht-set! table
|
||
second-char
|
||
(cons target (ht-get table second-char))))
|
||
|
||
(cl-defun evil-leap--gather-branches (first-char &key backward-p)
|
||
"TODO Searches the scope (for maddy: currently, this just means the visible
|
||
portion of the buffer) for two-character pairs, consisting of FIRST-CHAR
|
||
followed by some SECOND-CHAR. For each P = (FIRST-CHAR, SECOND-CHAR) pair
|
||
found, the returned hash table maps SECOND-CHAR to a list of targets
|
||
corresponding to each occurence of P."
|
||
(let* ((start-of-visible (window-start))
|
||
(end-of-visible (window-end))
|
||
(pattern (rx-to-string `(seq ,first-char (group anychar))))
|
||
(table (ht-create 'equal))
|
||
;; TODO: Clean this up. }:\
|
||
(search (if backward-p
|
||
(lambda () (re-search-backward pattern start-of-visible t))
|
||
(lambda () (re-search-forward pattern end-of-visible t)))))
|
||
(save-excursion
|
||
(while (funcall search)
|
||
;; We are forced to save-excursion a second time, lest we enter a
|
||
;; feedback loop, where `re-search-forward' moves to a match, and
|
||
;; `goto-char' jumps to a position *preceding* the match.
|
||
;;
|
||
;; REVIEW: Alternatively, we could call `forwrad-char' as the last
|
||
;; action in the loop.
|
||
(save-excursion
|
||
(goto-char (match-beginning 0))
|
||
(let ((second-char (aref (match-string 1) 0)) ; string->char
|
||
(target (evil-leap--target-at-point)))
|
||
(evil-leap--insert-branch-target table second-char target)))))
|
||
;; Reverse each list of targets.
|
||
(ht-each (lambda (k v) (ht-set! table k (reverse v)))
|
||
table)
|
||
table))
|
||
|
||
|
||
; Entry point
|
||
|
||
(cl-defun evil-leap
|
||
(&key targets)
|
||
"TODO Something something something.
|
||
|
||
Keyword arguments
|
||
:targets TARGETS A list of targets, or a Lisp procedure returning such a
|
||
list."
|
||
(unwind-protect
|
||
(evil-leap-select-from-labeled-targets
|
||
(evil-leap--label-targets targets))
|
||
;; TODO: would it be faster to go through `labeled-targets' and unlabel them
|
||
;; ourselves?
|
||
(evil-leap--remove-all-labels))
|
||
(evil-leap--remove-all-labels))
|
||
|
||
;; REVIEW: Could we the preview/branch functionality be rewriten in terms of
|
||
;; higher-level functions? Preferably ones included in the public API?
|
||
;;;###autoload (autoload 'evil-leap-directional "evil-leap" nil t)
|
||
(evil-define-motion evil-leap-directional (count backward-p)
|
||
"TODO"
|
||
:jump t
|
||
:type 'exclusive
|
||
(interactive "<c>")
|
||
;; FIXME: If the search pattern finds zero targets, `labeled-targets' ends up
|
||
;; being nil, which leads to an error in `evil-leap--select-from-labeled-targets'.
|
||
(unwind-protect
|
||
(let* ((first-char (evil-read-key))
|
||
(branches (evil-leap--gather-branches first-char
|
||
:backward-p backward-p))
|
||
(_ (progn (evil-leap--label-branch-targets! branches)
|
||
(evil-leap--show-potential-targets branches)))
|
||
(second-char (evil-read-key))
|
||
(_ (evil-leap--unlabel-other-branches second-char branches))
|
||
(labeled-targets (ht-get branches second-char)))
|
||
(evil-leap-select-from-labeled-targets labeled-targets))
|
||
;; TODO: would it be faster to go through `labeled-targets' and unlabel them
|
||
;; ourselves?
|
||
(evil-leap--remove-all-labels))
|
||
(evil-leap--remove-all-labels))
|
||
|
||
;;;###autoload (autoload 'evil-leap-forward "evil-leap" nil t)
|
||
(evil-define-motion evil-leap-forward (count)
|
||
"TODO"
|
||
:jump t
|
||
:type 'exclusive
|
||
(interactive "<c>")
|
||
(evil-leap-directional count nil))
|
||
|
||
;;;###autoload (autoload 'evil-leap-backward "evil-leap" nil t)
|
||
(evil-define-motion evil-leap-backward (count)
|
||
"TODO"
|
||
:jump t
|
||
:type 'exclusive
|
||
(interactive "<c>")
|
||
(evil-leap-directional count t))
|
||
|
||
;; (map!
|
||
;; :mn "s" #'evil-leap-forward
|
||
;; :mn "S" #'evil-leap-backward)
|
||
|
||
(general-define-key
|
||
:states '(motion normal)
|
||
"s" #'evil-leap-forward
|
||
"S" #'evil-leap-backward)
|
||
|
||
|
||
|
||
(provide 'evil-leap)
|
||
;;; evil-leap.el ends here
|