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//! # duat_core::doc_duat!(duat);
30//! setup_duat!(setup);
31//! use duat::prelude::*;
32//!
33//! fn setup() {
34//!     plug(duat_hop::Hop);
35//! }
36//! ```
37//!
38//! When plugging this, the `w` key will be mapped to [`Hopper::word`]
39//! in the [`User`] mode, while the `l` key will map onto
40//! [`Hopper::line`] in the same mode.
41//!
42//! # Forms
43//!
44//! When plugging [`Hop`] will set the `"hop"` [`Form`] to
45//! `"accent.info"`. This is then inherited by the following
46//! [`Form`]s:
47//!
48//! - `"hop.one_char"` will be used on labels with just one character.
49//! - `"hop.char1"` will be used on the first character of two
50//!   character labels.
51//! - `"hop.char2"` will be used on the second character of two
52//!   character labels. By default, this form inherits `"hop.char1"`.
53//!
54//! Which you can modify via [`form::set`]:
55//!
56//! ```rust
57//! # duat_core::doc_duat!(duat);
58//! setup_duat!(setup);
59//! use duat::prelude::*;
60//!
61//! fn setup() {
62//!     plug(duat_hop::Hop);
63//!
64//!     form::set("hop.one_char", Form::red().underlined());
65//!     form::set("hop.char1", "hop.one_char");
66//!     form::set("hop.char2", "search");
67//! }
68//! ```
69//!
70//! [`Mode`]: duat_core::mode::Mode
71//! [`hop.nvim`]: https://github.com/smoka7/hop.nvim
72//! [`User`]: duat_core::mode::User
73//! [`Form`]: duat_core::form::Form
74//! [`form::set`]: duat_core::form::set
75use std::{ops::Range, sync::LazyLock};
76
77use duat::prelude::*;
78
79/// The [`Plugin`] for the [`Hopper`] [`Mode`]
80#[derive(Default)]
81pub struct Hop;
82
83impl Plugin for Hop {
84    fn plug(self, _: &Plugins) {
85        mode::map::<mode::User>("w", Hopper::word());
86        mode::map::<mode::User>("l", Hopper::line());
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 on_switch(&mut self, pa: &mut Pass, handle: Handle) {
126        let (file, area) = handle.write_with_area(pa);
127
128        let opts = file.opts;
129        let text = file.text_mut();
130
131        let id = form::id_of!("cloak");
132        text.insert_tag(*CLOAK_TAGGER, .., id.to_tag(101));
133
134        let start = area.start_points(text, opts).real;
135        let end = area.end_points(text, opts).real;
136
137        self.ranges = text.search_fwd(self.regex, start..end).unwrap().collect();
138
139        let seqs = key_seqs(self.ranges.len());
140
141        for (seq, r) in seqs.iter().zip(&self.ranges) {
142            let ghost = if seq.len() == 1 {
143                Ghost(txt!("[hop.one_char:102]{seq}"))
144            } else {
145                let mut chars = seq.chars();
146                Ghost(txt!(
147                    "[hop.char1:102]{}[hop.char2:102]{}",
148                    chars.next().unwrap(),
149                    chars.next().unwrap()
150                ))
151            };
152
153            text.insert_tag(*TAGGER, r.start, ghost);
154
155            let seq_end = if r.end == r.start + 1
156                && let Some('\n') = text.char_at(r.end)
157            {
158                r.end
159            } else {
160                let chars = text.strs(r.start..).unwrap().chars().map(|c| c.len_utf8());
161                r.start + chars.take(seq.len()).sum::<usize>()
162            };
163
164            text.insert_tag(*TAGGER, r.start..seq_end, Conceal);
165        }
166    }
167
168    fn send_key(&mut self, pa: &mut Pass, key_event: KeyEvent, handle: Handle) {
169        let char = match key_event {
170            event!(KeyCode::Char(c)) => c,
171            _ => {
172                context::error!("Invalid label input");
173                mode::reset::<Buffer>();
174                return;
175            }
176        };
177
178        self.seq.push(char);
179
180        handle.write(pa).selections_mut().remove_extras();
181
182        let seqs = key_seqs(self.ranges.len());
183        for (seq, r) in seqs.iter().zip(&self.ranges) {
184            if *seq == self.seq {
185                handle.edit_main(pa, |mut e| e.move_to(r.clone()));
186                mode::reset::<Buffer>();
187            } else if seq.starts_with(&self.seq) {
188                continue;
189            }
190            // Removing one end of the conceal range will remove both ends.
191            handle.write(pa).text_mut().remove_tags(*TAGGER, r.start);
192        }
193
194        if self.seq.chars().count() == 2 || !LETTERS.contains(char) {
195            mode::reset::<Buffer>();
196        }
197    }
198
199    fn before_exit(&mut self, pa: &mut Pass, handle: Handle<Self::Widget>) {
200        handle
201            .write(pa)
202            .text_mut()
203            .remove_tags([*TAGGER, *CLOAK_TAGGER], ..)
204    }
205}
206
207fn key_seqs(len: usize) -> Vec<String> {
208    let double = len / LETTERS.len();
209    let mut seqs = Vec::new();
210
211    seqs.extend(LETTERS.chars().skip(double).map(char::into));
212    let chars = LETTERS.chars().take(double);
213    seqs.extend(chars.flat_map(|c1| LETTERS.chars().map(move |c2| format!("{c1}{c2}"))));
214
215    seqs
216}
217
218static LETTERS: &str = "abcdefghijklmnopqrstuvwxyz";
219static TAGGER: LazyLock<Tagger> = Tagger::new_static();
220static CLOAK_TAGGER: LazyLock<Tagger> = Tagger::new_static();