diff --git a/book/src/editor.md b/book/src/editor.md index 82d5f8461..6556d1bab 100644 --- a/book/src/editor.md +++ b/book/src/editor.md @@ -51,6 +51,7 @@ | `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` | | `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid` | `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | `"abcdefghijklmnopqrstuvwxyz"` +| `jump-label-follow-blacklist` | Used to remove jump label combinations that are hard to type. For example, to make the labels "fj" and "fy" not used, set this option to `{ f = "jy" }` | `{}` | `end-of-line-diagnostics` | Minimum severity of diagnostics to render at the end of the line. Set to `disable` to disable entirely. Refer to the setting about `inline-diagnostics` for more details | "disable" ### `[editor.statusline]` Section diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index b1c29378d..aed709f30 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -6152,15 +6152,52 @@ fn extend_to_word(cx: &mut Context) { } fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { - let doc = doc!(cx.editor); - let alphabet = &cx.editor.config().jump_label_alphabet; if labels.is_empty() { return; } - let alphabet_char = |i| { - let mut res = Tendril::new(); - res.push(alphabet[i]); - res + + let doc = doc!(cx.editor); + let alphabet = &cx.editor.config().jump_label_alphabet; + let follow_blacklist = &cx.editor.config().jump_label_follow_blacklist; + let jump_label_lookup = follow_blacklist.get_allow_list(alphabet); + let partial_sums = jump_label_lookup + .alphabet + .iter() + .map(|c| jump_label_lookup.follow_whitelist.get(c).unwrap()) + .map(|v| v.len()) + .fold(vec![0], |mut acc, curr| { + acc.push(acc.last().unwrap() + curr); + acc + }); + + let make_jump_label = |i| { + // finding, based on the total number of overlays generated so far, what the index into the + // first characters and second characters respectively should be. + let first_i = partial_sums.iter().take_while(|p| p <= &&i).count() - 1; + + let second_i = i - partial_sums + .get(first_i) + .unwrap_or_else(|| panic!("first_i outside of partial_sums indices.")); + + let first = *jump_label_lookup.alphabet.get(first_i).unwrap_or_else(|| { + panic!( + "alphabet_char called with i ({}) greater than the number of valid pairs ({}).", + i, + partial_sums.last().unwrap() + ) + }); + + let second = jump_label_lookup.follow_whitelist.get(&first) + .unwrap_or_else(|| { + panic!("there should not be any first chars created which don't have entries in follow_whitelist.") + })[second_i]; + + let mut first_res = Tendril::new(); + let mut second_res = Tendril::new(); + + first_res.push(first); + second_res.push(second); + (first_res, second_res) }; // Add label for each jump candidate to the View as virtual text. @@ -6169,15 +6206,18 @@ fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { .iter() .enumerate() .flat_map(|(i, range)| { + let (first_grapheme, second_grapheme) = make_jump_label(i); + [ - Overlay::new(range.from(), alphabet_char(i / alphabet.len())), + Overlay::new(range.from(), first_grapheme), Overlay::new( graphemes::next_grapheme_boundary(text, range.from()), - alphabet_char(i % alphabet.len()), + second_grapheme, ), ] }) .collect(); + overlays.sort_unstable_by_key(|overlay| overlay.char_idx); let (view, doc) = current!(cx.editor); doc.set_jump_labels(view.id, overlays); @@ -6188,15 +6228,17 @@ fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { let view = view.id; let doc = doc.id(); cx.on_next_key(move |cx, event| { - let alphabet = &cx.editor.config().jump_label_alphabet; - let Some(i) = event - .char() - .and_then(|ch| alphabet.iter().position(|&it| it == ch)) - else { + let Some((first_ch, i)) = event.char().and_then(|ch| { + jump_label_lookup + .alphabet + .iter() + .position(|&it| it == ch) + .map(|i| (ch, i)) + }) else { doc_mut!(cx.editor, &doc).remove_jump_labels(view); return; }; - let outer = i * alphabet.len(); + let outer = partial_sums[i]; // Bail if the given character cannot be a jump label. if outer > labels.len() { doc_mut!(cx.editor, &doc).remove_jump_labels(view); @@ -6204,13 +6246,17 @@ fn jump_to_label(cx: &mut Context, labels: Vec, behaviour: Movement) { } cx.on_next_key(move |cx, event| { doc_mut!(cx.editor, &doc).remove_jump_labels(view); - let alphabet = &cx.editor.config().jump_label_alphabet; - let Some(inner) = event - .char() - .and_then(|ch| alphabet.iter().position(|&it| it == ch)) - else { + let Some(inner) = event.char().and_then(|ch| { + jump_label_lookup + .follow_whitelist + .get(&first_ch) + .unwrap_or(&vec![]) + .iter() + .position(|&it| it == ch) + }) else { return; }; + if let Some(mut range) = labels.get(outer + inner).copied() { range = if behaviour == Movement::Extend { let anchor = if range.anchor < range.head { @@ -6242,7 +6288,13 @@ fn jump_to_word(cx: &mut Context, behaviour: Movement) { // Calculate the jump candidates: ranges for any visible words with two or // more characters. let alphabet = &cx.editor.config().jump_label_alphabet; - let jump_label_limit = alphabet.len() * alphabet.len(); + let follow_blacklist = &cx.editor.config().jump_label_follow_blacklist; + let jump_label_lookup = follow_blacklist.get_allow_list(alphabet); + let jump_label_limit = jump_label_lookup + .follow_whitelist + .values() + .map(|v| v.len()) + .sum(); let mut words = Vec::with_capacity(jump_label_limit); let (view, doc) = current_ref!(cx.editor); let text = doc.text().slice(..); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 26dea3a21..7085d40ef 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -21,7 +21,7 @@ use helix_lsp::{Call, LanguageServerId}; use tokio_stream::wrappers::UnboundedReceiverStream; use std::{ - borrow::Cow, + borrow::{Borrow, Cow}, cell::Cell, collections::{BTreeMap, HashMap, HashSet}, fs, @@ -342,11 +342,117 @@ pub struct Config { deserialize_with = "deserialize_alphabet" )] pub jump_label_alphabet: Vec, + // characters not allowed to follow each starting character + pub jump_label_follow_blacklist: JumpLabelFollowBlacklist, /// Display diagnostic below the line they occur. pub inline_diagnostics: InlineDiagnosticsConfig, pub end_of_line_diagnostics: DiagnosticFilter, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JumpLabelFollowBlacklist(HashMap>); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JumpLabelLookup { + pub alphabet: Vec, + pub follow_whitelist: HashMap>, +} + +impl JumpLabelFollowBlacklist { + pub fn get(&self, c: char) -> Option<&Vec> { + self.0.get(&c) + } + + pub fn get_allow_list(&self, alphabet: &Vec) -> JumpLabelLookup { + let follow_whitelist: HashMap<_, _> = alphabet + .iter() + .filter_map(|c| { + let unique_chars: HashSet<_> = alphabet.iter().copied().collect(); + let blacklist_chars: Option> = + self.get(*c).and_then(|v| Some(v.iter().copied().collect())); + + let whitelist_chars = if let Some(blacklist_chars) = blacklist_chars { + unique_chars + .symmetric_difference(&blacklist_chars) + .map(|c| *c) + .collect() + } else { + alphabet.clone() + }; + + if whitelist_chars.len() > 0 { + Some((*c, whitelist_chars)) + } else { + None + } + }) + .collect(); + + let alphabet = alphabet + .iter() + .filter_map(|c| { + if follow_whitelist.contains_key(c) { + Some(*c) + } else { + None + } + }) + .collect(); + + JumpLabelLookup { + alphabet, + follow_whitelist, + } + } +} + +impl Serialize for JumpLabelFollowBlacklist { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0 + .iter() + .map(|(k, v)| (*k, v.iter().collect::())) + .collect::>() + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for JumpLabelFollowBlacklist { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let map = HashMap::::deserialize(deserializer)?; + + let map = map + .into_iter() + .map(|(k, v)| { + let chars: Vec<_> = v.chars().collect(); + let unique_chars: HashSet<_> = chars.iter().copied().collect(); + if unique_chars.len() != chars.len() { + return Err(::custom( + "jump-label-follow-blacklist lists must contain unique characters", + )); + } + + Ok((k, chars)) + }) + .collect::, _>>()?; + + Ok(Self(map)) + } +} + +impl Default for JumpLabelFollowBlacklist { + fn default() -> Self { + Self(HashMap::new()) + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)] #[serde(rename_all = "kebab-case", default)] pub struct SmartTabConfig { @@ -980,6 +1086,7 @@ impl Default for Config { popup_border: PopupBorderConfig::None, indent_heuristic: IndentationHeuristic::default(), jump_label_alphabet: ('a'..='z').collect(), + jump_label_follow_blacklist: JumpLabelFollowBlacklist::default(), inline_diagnostics: InlineDiagnosticsConfig::default(), end_of_line_diagnostics: DiagnosticFilter::Disable, }