duat_sneak/
lib.rs

1//! A `duat` [`Mode`] for searching for character sequences
2//!
3//! This is a plugin inspired by [`vim-sneak`], which is a kind of
4//! extension to the regular `f`/`t` key bindings in vim. This one is
5//! similar to it, but implemented for Duat instead
6//!
7//! # Installation
8//!
9//! Just like other Duat plugins, this one can be installed by calling
10//! `cargo add` in the config directory:
11//!
12//! ```bash
13//! cargo add duat-sneak@"*" --rename sneak
14//! ```
15//!
16//! Or, if you are using a `--git-deps` version of duat, do this:
17//!
18//! ```bash
19//! cargo add --git https://github.com/AhoyISki/duat-sneak --rename sneak
20//! ```
21//!
22//! # Usage
23//!
24//! In order to make use of it, just add the following to your `setup`
25//! function:
26//!
27//! ```rust
28//! setup_duat!(setup);
29//! use duat::prelude::*;
30//!
31//! fn setup() {
32//!     plug(duat_sneak::Sneak::new());
33//! }
34//! ```
35//!
36//! With the above call, you will map the `s` key in [`User`] [`Mode`]
37//! to the [`Sneak`] mode, you can also do that manually:
38//!
39//! ```rust
40//! setup_duat!(setup);
41//! use duat::prelude::*;
42//!
43//! fn setup() {
44//!     map::<User>("s", duat_sneak::Sneak::new());
45//! }
46//! ```
47//!
48//! In the [`Sneak`] mode, these are the available key sequences:
49//!
50//! - `{char0}{char1}`: Highlight any instance of the string
51//!   `{char0}{char1}` on screen. If there is only one instance, it
52//!   will be selected immediately, returning to the [default mode].
53//!   If there are multiple instances, one entry will be selected, and
54//!   typing does the following:
55//!
56//!   - `n` for the next entry
57//!   - `N` for the previous entry if [`mode::alt_is_reverse()`] is
58//!     `false`
59//!   - `<A-n>` for the previous entry if [`mode::alt_is_reverse()`]
60//!     is `true`
61//!   - Any other key will select and return to the [default mode]
62//!
63//! - Any other key will pick the last `{char0}{char1}` sequence and
64//!   use that. If there was no previous sequence, just returns to the
65//!   [default mode].
66//!
67//! # More Options
68//!
69//! Note: The following options can be used when plugging the mode as
70//! well.
71//!
72//! ```rust
73//! setup_duat!(setup);
74//! use duat::prelude::*;
75//! use duat_sneak::Sneak;
76//!
77//! fn setup() {
78//!     map::<User>("s", Sneak::new().select_keys(',', ';').with_len(3));
79//! }
80//! ```
81//!
82//! Instead of switching with the regular keys, `;` selects the
83//! previous entry and `,` selects the next. Additionally, this will
84//! select three characters instead of just two.
85//!
86//! # Labels
87//!
88//! If there are too many matches, switching to a far away match could
89//! be tedious, so you can do the following instead:
90//!
91//! ```rust
92//! setup_duat!(setup);
93//! use duat::prelude::*;
94//! use duat_sneak::Sneak;
95//!
96//! fn setup() {
97//!     map::<User>("s", Sneak::new().min_for_labels(8));
98//! }
99//! ```
100//!
101//! Now, if there are 8 or more matches, instead of switching to them
102//! via `n` and `N`, labels with one character will show up on each
103//! match. If you type the character in a label, all other labels will
104//! be filtered out, until there is only one label left, at which
105//! point it will be selected and you'll return to the [default mode].
106//!
107//! # Forms
108//!
109//! When plugging [`Sneak`] this crate sets two [`Form`]s:
110//!
111//! - `"sneak.match"`, which is set to `"default.info"`
112//! - `"sneak.label"`, which is set to `"accent.info"`
113//!
114//! [`Mode`]: duat::mode::Mode
115//! [`vim-sneak`]: https://github.com/justinmk/vim-sneak
116//! [`Cargo.toml`'s `dependencies` section]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html
117//! [map]: https://docs.rs/duat/latest/duat/prelude/map
118//! [`User`]: duat::mode::User
119//! [default mode]: mode::reset
120use std::{
121    ops::Range,
122    sync::{LazyLock, Mutex},
123};
124
125use duat::{
126    mode::{KeyCode::*, KeyMod},
127    prelude::*,
128};
129
130static TAGGER: LazyLock<Tagger> = Tagger::new_static();
131static CUR_TAGGER: LazyLock<Tagger> = Tagger::new_static();
132static CLOAK_TAGGER: LazyLock<Tagger> = Tagger::new_static();
133static LAST: Mutex<String> = Mutex::new(String::new());
134
135/// A [`Mode`] used for jumping to sequences of characters
136#[derive(Clone)]
137pub struct Sneak {
138    step: Step,
139    len: usize,
140    prev_key: KeyEvent,
141    next_key: KeyEvent,
142    min_for_labels: usize,
143}
144
145impl Sneak {
146    /// Create a new instance of the [`Sneak`] [`Mode`]
147    pub fn new() -> Self {
148        Self {
149            step: Step::Start,
150            len: 2,
151            next_key: KeyCode::Char('n').into(),
152            prev_key: if mode::alt_is_reverse() {
153                KeyEvent::new(KeyCode::Char('n'), KeyMod::ALT)
154            } else {
155                Char('N').into()
156            },
157            min_for_labels: usize::MAX,
158        }
159    }
160
161    /// Which `char`s to select the previous and next matches,
162    /// respectively
163    ///
164    /// By default, they are:
165    ///
166    /// - `n` for the next entry
167    /// - `N` for the previous entry if [`mode::alt_is_reverse()`] is
168    ///   `false`
169    /// - `<A-n>` for the previous entry if [`mode::alt_is_reverse()`]
170    ///   is `true`
171    pub fn select_keys(self, prev: char, next: char) -> Self {
172        Self {
173            prev_key: Char(prev).into(),
174            next_key: Char(next).into(),
175            ..self
176        }
177    }
178
179    /// Sneaks with `len` chars, as opposed to just 2
180    #[track_caller]
181    pub fn with_len(self, len: usize) -> Self {
182        assert!(len >= 1, "Can't match on 0 characters");
183        Self { len, ..self }
184    }
185
186    /// Sets a minimum number of matches to enable labels
187    ///
188    /// Instead of getting to a specific match with [the selection
189    /// keys], a label will appear in front of each match, if you type
190    /// the character in the label, [`Sneak`] will filter out all non
191    /// matching labels until there are only at most 26 left, in which
192    /// case the next character will finish sneaking.
193    ///
194    /// This feature is disabled by default (i.e. `min_for_labels ==
195    /// usize::MAX`).
196    ///
197    /// [the selection keys]: Self::select_keys
198    pub fn min_for_labels(self, min_for_labels: usize) -> Self {
199        Self { min_for_labels, ..self }
200    }
201}
202
203impl Plugin for Sneak {
204    fn plug(self, _: &Plugins) {
205        mode::map::<mode::User>("s", self);
206
207        form::set_weak("sneak.match", "default.info");
208        form::set_weak("sneak.label", "accent.info");
209        form::set_weak("sneak.current", Form::underlined());
210    }
211}
212
213impl Mode for Sneak {
214    type Widget = Buffer;
215
216    fn bindings() -> mode::Bindings {
217        mode::bindings!(match _ {
218            event!(Char(..)) => txt!("Filter by [key.char]{{char}}"),
219        })
220    }
221
222    fn send_key(&mut self, pa: &mut Pass, key: mode::KeyEvent, handle: Handle) {
223        match &mut self.step {
224            Step::Start => {
225                let (pat, finished_filtering) = if let event!(Char(char)) = key {
226                    (char.to_string(), self.len == 1)
227                } else {
228                    let last = LAST.lock().unwrap();
229
230                    if last.is_empty() {
231                        context::error!("mode hasn't been set to [a]Sneak[] yet");
232                        mode::reset::<Buffer>(pa);
233                        return;
234                    } else {
235                        (last.clone(), true)
236                    }
237                };
238
239                let regex = format!("{pat}[^\n]{{{}}}", self.len - pat.chars().count());
240                let (matches, cur) = hi_matches(pa, &regex, &handle);
241
242                let Some(cur) = cur else {
243                    context::error!("No matches found for [a]{pat}");
244                    mode::reset::<Buffer>(pa);
245                    return;
246                };
247
248                self.step = if finished_filtering {
249                    // Stop immediately if there is only one match
250                    if matches.len() == 1 {
251                        let range = matches[0].clone();
252                        handle.edit_main(pa, |mut c| c.move_to(range));
253
254                        mode::reset::<Buffer>(pa);
255
256                        Step::MatchedMove(pat, matches, cur)
257                    } else if matches.len() >= self.min_for_labels {
258                        hi_labels(pa, &handle, &matches);
259
260                        Step::MatchedLabels(pat, matches)
261                    } else {
262                        hi_cur(pa, &handle, matches[cur].clone(), matches[cur].clone());
263
264                        Step::MatchedMove(pat, matches, cur)
265                    }
266                } else {
267                    Step::Filter(pat)
268                }
269            }
270            Step::Filter(pat) => {
271                handle.text_mut(pa).remove_tags(*TAGGER, ..);
272
273                let (regex, finished_filtering) = if let event!(Char(char)) = key {
274                    pat.push(char);
275
276                    let regex = format!("{pat}[^\n]{{{}}}", self.len - pat.chars().count());
277                    (regex, pat.chars().count() >= self.len)
278                } else {
279                    (pat.clone(), true)
280                };
281
282                let (matches, cur) = hi_matches(pa, &regex, &handle);
283
284                let Some(cur) = cur else {
285                    context::error!("No matches found for [a]{pat}");
286                    mode::reset::<Buffer>(pa);
287                    return;
288                };
289
290                hi_cur(pa, &handle, matches[cur].clone(), matches[cur].clone());
291
292                if finished_filtering {
293                    // Stop immediately if there is only one match
294                    self.step = if matches.len() == 1 {
295                        let range = matches[0].clone();
296                        handle.edit_main(pa, |mut c| c.move_to(range));
297
298                        mode::reset::<Buffer>(pa);
299
300                        Step::MatchedMove(pat.clone(), matches, cur)
301                    } else if matches.len() >= self.min_for_labels {
302                        hi_labels(pa, &handle, &matches);
303
304                        Step::MatchedLabels(pat.clone(), matches)
305                    } else {
306                        hi_cur(pa, &handle, matches[cur].clone(), matches[cur].clone());
307
308                        Step::MatchedMove(pat.clone(), matches, cur)
309                    };
310                }
311            }
312            Step::MatchedMove(_, matches, cur) => {
313                let prev = *cur;
314                let last = matches.len() - 1;
315
316                if key == self.next_key {
317                    *cur = if *cur == last { 0 } else { *cur + 1 };
318                    hi_cur(pa, &handle, matches[*cur].clone(), matches[prev].clone());
319                } else if key == self.prev_key {
320                    *cur = if *cur == 0 { last } else { *cur - 1 };
321                    hi_cur(pa, &handle, matches[*cur].clone(), matches[prev].clone());
322                } else {
323                    let range = matches[*cur].clone();
324                    handle.edit_main(pa, |mut c| c.move_to(range));
325
326                    mode::reset::<Buffer>(pa);
327                }
328            }
329            Step::MatchedLabels(_, matches) => {
330                handle.text_mut(pa).remove_tags(*TAGGER, ..);
331
332                let filtered_label = if let event!(Char(char)) = key
333                    && iter_labels(matches.len()).any(|label| char == label)
334                {
335                    char
336                } else {
337                    if let event!(Char(char)) = key {
338                        context::error!("[a]{char}[] is not a valid label");
339                    } else {
340                        context::error!("[a]{key.code:?}[] is not a valid label");
341                    }
342                    mode::reset::<Buffer>(pa);
343                    return;
344                };
345
346                let mut iter = iter_labels(matches.len());
347                matches.retain(|_| iter.next() == Some(filtered_label));
348
349                if matches.len() == 1 {
350                    let range = matches[0].clone();
351                    handle.edit_main(pa, |mut c| c.move_to(range));
352
353                    mode::reset::<Buffer>(pa);
354                } else {
355                    hi_labels(pa, &handle, matches);
356                }
357            }
358        }
359    }
360
361    fn on_switch(&mut self, pa: &mut Pass, handle: Handle<Self::Widget>) {
362        let id = form::id_of!("cloak");
363        handle
364            .text_mut(pa)
365            .insert_tag(*CLOAK_TAGGER, .., id.to_tag(101));
366    }
367
368    fn before_exit(&mut self, pa: &mut Pass, handle: Handle<Self::Widget>) {
369        use Step::*;
370        if let Filter(pat) | MatchedMove(pat, ..) | MatchedLabels(pat, _) = &self.step {
371            *LAST.lock().unwrap() = pat.clone();
372        }
373
374        handle
375            .text_mut(pa)
376            .remove_tags([*TAGGER, *CUR_TAGGER, *CLOAK_TAGGER], ..)
377    }
378}
379
380fn hi_labels(pa: &mut Pass, handle: &Handle, matches: &Vec<Range<usize>>) {
381    let mut text = handle.text_mut(pa);
382
383    text.remove_tags([*TAGGER, *CUR_TAGGER], ..);
384
385    for (label, range) in iter_labels(matches.len()).zip(matches) {
386        let ghost = Ghost::new(txt!("[sneak.label:102]{label}"));
387        text.insert_tag(*TAGGER, range.start, ghost);
388
389        let len = text.char_at(range.start).map(|c| c.len_utf8()).unwrap_or(1);
390        text.insert_tag(*TAGGER, range.start..range.start + len, Conceal);
391    }
392}
393
394fn hi_matches(pa: &mut Pass, pat: &str, handle: &Handle) -> (Vec<Range<usize>>, Option<usize>) {
395    let (buffer, area) = handle.write_with_area(pa);
396
397    let start = area.start_points(buffer.text(), buffer.opts).real;
398    let end = area.end_points(buffer.text(), buffer.opts).real;
399    let caret = buffer.selections().main().caret().byte();
400
401    let mut parts = buffer.text_mut().parts();
402
403    let matches: Vec<_> = parts.bytes.search(pat).range(start..end).collect();
404
405    let id = form::id_of!("sneak.match");
406
407    let tagger = *TAGGER;
408    let mut next = None;
409    for (i, range) in matches.iter().enumerate() {
410        if range.start > caret && next.is_none() {
411            next = Some(i);
412        }
413        parts.tags.insert(tagger, range.clone(), id.to_tag(102));
414    }
415
416    let last = matches.len().checked_sub(1);
417    (matches, next.or(last))
418}
419
420fn hi_cur(pa: &mut Pass, handle: &Handle, cur: Range<usize>, prev: Range<usize>) {
421    let cur_id = form::id_of!("sneak.current");
422
423    let mut text = handle.text_mut(pa);
424    text.remove_tags(*CUR_TAGGER, prev.start);
425    text.insert_tag(*CUR_TAGGER, cur, cur_id.to_tag(103));
426}
427
428fn iter_labels(total: usize) -> impl Iterator<Item = char> {
429    const LETTERS: &str = "abcdefghijklmnopqrstuvwxyz";
430
431    let multiple = total / LETTERS.len();
432
433    let singular = LETTERS.chars().skip(multiple);
434
435    singular
436        .chain(
437            LETTERS
438                .chars()
439                .take(multiple)
440                .flat_map(|c| std::iter::repeat_n(c, 26)),
441        )
442        .take(total)
443}
444
445#[derive(Clone)]
446enum Step {
447    Start,
448    Filter(String),
449    MatchedMove(String, Vec<Range<usize>>, usize),
450    MatchedLabels(String, Vec<Range<usize>>),
451}
452
453impl Default for Sneak {
454    fn default() -> Self {
455        Self::new()
456    }
457}