duat_hop/
lib.rs

1//! A duat [`Mode`] to quickly move around the screen, inspired by
2//! [`hop.nvim`]
3//!
4//! This plugin will highlight every word (or line, or a custom regex)
5//! in the screen, and let you jump to it with at most 2 keypresses,
6//! selecting the matched sequence.
7//!
8//! # Installation
9//!
10//! Just like other Duat plugins, this one can be installed by calling
11//! `cargo add` in the config directory:
12//!
13//! ```bash
14//! cargo add duat-hop@"*"
15//! ```
16//!
17//! Or, if you are using a `--git-deps` version of duat, do this:
18//!
19//! ```bash
20//! cargo add --git https://github.com/AhoyISki/duat-hop
21//! ```
22//!
23//! # Usage
24//!
25//! In order to make use of it, just add the following to your `setup`
26//! function:
27//!
28//! ```rust
29//! setup_duat!(setup);
30//! use duat::prelude::*;
31//!
32//! fn setup() {
33//!     plug(duat_hop::Hop);
34//! }
35//! ```
36//!
37//! When plugging this, the `w` key will be mapped to [`Hopper::word`]
38//! in the [`User`] mode, while the `l` key will map onto
39//! [`Hopper::line`] in the same mode.
40//!
41//! # Forms
42//!
43//! When plugging [`Hop`] will set the `"hop"` [`Form`] to
44//! `"accent.info"`. This is then inherited by the following
45//! [`Form`]s:
46//!
47//! - `"hop.one_char"` will be used on labels with just one character.
48//! - `"hop.char1"` will be used on the first character of two
49//!   character labels.
50//! - `"hop.char2"` will be used on the second character of two
51//!   character labels. By default, this form inherits `"hop.char1"`.
52//!
53//! Which you can modify via [`form::set`]:
54//!
55//! ```rust
56//! setup_duat!(setup);
57//! use duat::prelude::*;
58//!
59//! fn setup() {
60//!     plug(duat_hop::Hop);
61//!
62//!     form::set("hop.one_char", Form::red().underlined());
63//!     form::set("hop.char1", "hop.one_char");
64//!     form::set("hop.char2", "search");
65//! }
66//! ```
67//!
68//! [`Mode`]: duat::mode::Mode
69//! [`hop.nvim`]: https://github.com/smoka7/hop.nvim
70//! [`User`]: duat::mode::User
71//! [`Form`]: duat::form::Form
72//! [`form::set`]: duat::form::set
73use std::{ops::Range, sync::LazyLock};
74
75use duat::prelude::*;
76
77/// The [`Plugin`] for the [`Hopper`] [`Mode`]
78#[derive(Default)]
79pub struct Hop;
80
81impl Plugin for Hop {
82    fn plug(self, _: &Plugins) {
83        mode::map::<mode::User>("w", Hopper::word()).doc(txt!("[mode]Hop[] to a [a]word"));
84        mode::map::<mode::User>("l", Hopper::line()).doc(txt!("[mode]Hop[] to a [a]line"));
85
86        opts::set(|opts| opts.whichkey.always_show::<Hopper>());
87
88        form::set_weak("hop", "accent.info");
89        form::set_weak("hop.char2", "hop.char1");
90    }
91}
92
93#[derive(Clone)]
94pub struct Hopper {
95    regex: &'static str,
96    ranges: Vec<Range<usize>>,
97    seq: String,
98}
99
100impl Hopper {
101    /// Returns a new instance of [`Hop`], moving by word by
102    /// default
103    pub fn word() -> Self {
104        Self {
105            regex: "[^\n\\s]+",
106            ranges: Vec::new(),
107            seq: String::new(),
108        }
109    }
110
111    /// Changes this [`Mode`] to move by line, not by word
112    pub fn line() -> Self {
113        Self { regex: "[^\n\\s][^\n]+", ..Self::word() }
114    }
115
116    /// Use a custom regex instead of the word or line regexes
117    pub fn with_regex(regex: &'static str) -> Self {
118        Self { regex, ..Self::word() }
119    }
120}
121
122impl Mode for Hopper {
123    type Widget = Buffer;
124
125    fn bindings() -> mode::Bindings {
126        mode::bindings!(match _ {
127            event!(KeyCode::Char(..)) => txt!("Filter hopping entries"),
128        })
129    }
130
131    fn on_switch(&mut self, pa: &mut Pass, handle: Handle) {
132        let (buffer, area) = handle.write_with_area(pa);
133
134        let opts = buffer.opts;
135        let mut text = buffer.text_mut();
136
137        let id = form::id_of!("cloak");
138        text.insert_tag(*CLOAK_TAGGER, .., id.to_tag(101));
139
140        let start = area.start_points(&text, opts).real;
141        let end = area.end_points(&text, opts).real;
142
143        self.ranges = text.search(self.regex).range(start..end).collect();
144
145        let seqs = key_seqs(self.ranges.len());
146
147        for (seq, r) in seqs.iter().zip(&self.ranges) {
148            let ghost = if seq.len() == 1 {
149                Ghost::new(txt!("[hop.one_char:102]{seq}"))
150            } else {
151                let mut chars = seq.chars();
152                Ghost::new(txt!(
153                    "[hop.char1:102]{}[hop.char2:102]{}",
154                    chars.next().unwrap(),
155                    chars.next().unwrap()
156                ))
157            };
158
159            text.insert_tag(*TAGGER, r.start, ghost);
160
161            let seq_end = if r.end == r.start + 1
162                && let Some('\n') = text.char_at(r.end)
163            {
164                r.end
165            } else {
166                let chars = text.strs(r.start..).unwrap().chars().map(|c| c.len_utf8());
167                r.start + chars.take(seq.len()).sum::<usize>()
168            };
169
170            text.insert_tag(*TAGGER, r.start..seq_end, Conceal);
171        }
172    }
173
174    fn send_key(&mut self, pa: &mut Pass, key_event: KeyEvent, handle: Handle) {
175        let char = match key_event {
176            event!(KeyCode::Char(c)) => c,
177            _ => {
178                context::error!("Invalid label input");
179                mode::reset::<Buffer>(pa);
180                return;
181            }
182        };
183
184        self.seq.push(char);
185
186        handle.write(pa).selections_mut().remove_extras();
187
188        let seqs = key_seqs(self.ranges.len());
189        for (seq, r) in seqs.iter().zip(&self.ranges) {
190            if *seq == self.seq {
191                handle.edit_main(pa, |mut e| e.move_to(r.clone()));
192                mode::reset::<Buffer>(pa);
193            } else if seq.starts_with(&self.seq) {
194                continue;
195            }
196            // Removing one end of the conceal range will remove both ends.
197            handle.write(pa).text_mut().remove_tags(*TAGGER, r.start);
198        }
199
200        if self.seq.chars().count() == 2 || !LETTERS.contains(char) {
201            mode::reset::<Buffer>(pa);
202        }
203    }
204
205    fn before_exit(&mut self, pa: &mut Pass, handle: Handle<Self::Widget>) {
206        handle
207            .write(pa)
208            .text_mut()
209            .remove_tags([*TAGGER, *CLOAK_TAGGER], ..)
210    }
211}
212
213fn key_seqs(len: usize) -> Vec<String> {
214    let double = len / LETTERS.len();
215    let mut seqs = Vec::new();
216
217    seqs.extend(LETTERS.chars().skip(double).map(char::into));
218    let chars = LETTERS.chars().take(double);
219    seqs.extend(chars.flat_map(|c1| LETTERS.chars().map(move |c2| format!("{c1}{c2}"))));
220
221    seqs
222}
223
224static LETTERS: &str = "abcdefghijklmnopqrstuvwxyz";
225static TAGGER: LazyLock<Tagger> = Tagger::new_static();
226static CLOAK_TAGGER: LazyLock<Tagger> = Tagger::new_static();