Skip to content

Commit

Permalink
(8875) Add an amp-like jump command
Browse files Browse the repository at this point in the history
  • Loading branch information
flinesse committed Jan 31, 2024
1 parent bbb78d0 commit 87278fe
Show file tree
Hide file tree
Showing 12 changed files with 235 additions and 6 deletions.
1 change: 1 addition & 0 deletions book/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Its settings will be merged with the configuration directory `config.toml` and t
| `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` |
| `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"

### `[editor.statusline]` Section

Expand Down
1 change: 1 addition & 0 deletions book/src/keymap.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ Jumps to various locations.
| `.` | Go to last modification in current file | `goto_last_modification` |
| `j` | Move down textual (instead of visual) line | `move_line_down` |
| `k` | Move up textual (instead of visual) line | `move_line_up` |
| `w` | Show labels at each word and select the word that belongs to the entered labels | `goto_word` |

#### Match mode

Expand Down
1 change: 1 addition & 0 deletions book/src/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ These scopes are used for theming the editor interface:
| `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (LSPs are not required to set a kind) |
| `ui.virtual.inlay-hint.type` | Style for inlay hints of kind `type` (LSPs are not required to set a kind) |
| `ui.virtual.wrap` | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section]) |
| `ui.virtual.jump-label` | Style for virtual jump labels |
| `ui.menu` | Code and command completion menus |
| `ui.menu.selected` | Selected autocomplete item |
| `ui.menu.scroll` | `fg` sets thumb color, `bg` sets track color of scrollbar |
Expand Down
9 changes: 9 additions & 0 deletions helix-core/src/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,15 @@ impl IntoIterator for Selection {
}
}

impl From<Range> for Selection {
fn from(range: Range) -> Self {
Self {
ranges: smallvec![range],
primary_index: 0,
}
}
}

// TODO: checkSelection -> check if valid for doc length && sorted

pub fn keep_or_remove_matches(
Expand Down
158 changes: 156 additions & 2 deletions helix-term/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ use tui::widgets::Row;
pub use typed::*;

use helix_core::{
char_idx_at_visual_offset, comment,
char_idx_at_visual_offset,
chars::char_is_word,
comment,
doc_formatter::TextFormat,
encoding, find_first_non_whitespace_char, find_workspace, graphemes,
history::UndoKind,
Expand All @@ -23,7 +25,7 @@ use helix_core::{
search::{self, CharMatcher},
selection, shellwords, surround,
syntax::LanguageServerFeature,
text_annotations::TextAnnotations,
text_annotations::{Overlay, TextAnnotations},
textobject,
tree_sitter::Node,
unicode::width::UnicodeWidthChar,
Expand Down Expand Up @@ -493,6 +495,8 @@ impl MappableCommand {
record_macro, "Record macro",
replay_macro, "Replay macro",
command_palette, "Open command palette",
goto_word, "Jump to a two-character label",
extend_to_word, "Extend to a two-character label",
);
}

Expand Down Expand Up @@ -5597,3 +5601,153 @@ fn replay_macro(cx: &mut Context) {
cx.editor.macro_replaying.pop();
}));
}

fn goto_word(cx: &mut Context) {
jump_to_word(cx, Movement::Move)
}

fn extend_to_word(cx: &mut Context) {
jump_to_word(cx, Movement::Extend)
}

fn jump_to_label(cx: &mut Context, labels: Vec<Range>, 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
};

// Add label for each jump candidate to the View as virtual text.
let text = doc.text().slice(..);
let mut overlays: Vec<_> = labels
.iter()
.enumerate()
.flat_map(|(i, range)| {
[
Overlay::new(range.from(), alphabet_char(i / alphabet.len())),
Overlay::new(
graphemes::next_grapheme_boundary(text, range.from()),
alphabet_char(i % alphabet.len()),
),
]
})
.collect();
overlays.sort_unstable_by_key(|overlay| overlay.char_idx);
let (view, doc) = current!(cx.editor);
doc.set_jump_labels(view.id, overlays);

// Accept two characters matching a visible label. Jump to the candidate
// for that label if it exists.
let primary_selection = doc.selection(view.id).primary();
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 {
doc_mut!(cx.editor, &doc).remove_jump_labels(view);
return;
};
let outer = i * alphabet.len();
// Bail if the given character cannot be a jump label.
if outer > labels.len() {
doc_mut!(cx.editor, &doc).remove_jump_labels(view);
return;
}
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 {
return;
};
if let Some(mut range) = labels.get(outer + inner).copied() {
range = if behaviour == Movement::Extend {
let anchor = if range.anchor < range.head {
let from = primary_selection.from();
if range.anchor < from {
range.anchor
} else {
from
}
} else {
let to = primary_selection.to();
if range.anchor > to {
range.anchor
} else {
to
}
};
Range::new(anchor, range.head)
}else{
range.with_direction(Direction::Forward)
};
doc_mut!(cx.editor, &doc).set_selection(view, range.into());
}
});
});
}

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 mut words = Vec::with_capacity(jump_label_limit);
let (view, doc) = current_ref!(cx.editor);
let text = doc.text().slice(..);

// This is not necessarily exact if there is virtual text like soft wrap.
// It's ok though because the extra jump labels will not be rendered.
let start = text.line_to_char(text.char_to_line(view.offset.anchor));
let end = text.line_to_char(view.estimate_last_doc_line(doc) + 1);

let primary_selection = doc.selection(view.id).primary();
let cursor = primary_selection.cursor(text);
let cursor_fwd = movement::move_prev_word_start(text, Range::point(cursor), 1);
let mut cursor_fwd = if cursor_fwd.anchor == cursor {
Range::point(cursor_fwd.head)
} else {
Range::point(cursor)
};
let mut cursor_rev = cursor_fwd;
loop {
let mut changed = false;
if cursor_fwd.head < end {
cursor_fwd = movement::move_next_word_end(text, cursor_fwd, 1);
let range = movement::move_prev_word_start(text, cursor_fwd, 1);
// The cursor is on a word longer than 2 characters.
if RopeGraphemes::new(text.slice(range.head..))
.take(2)
.all(|g| g.chars().all(char_is_word))
{
words.push(range.flip());
}
changed = true;
if words.len() == jump_label_limit {
break;
}
}
if cursor_rev.head > start {
cursor_rev = movement::move_prev_word_start(text, cursor_rev, 1);
// The cursor is on a word longer than 2 characters.
if RopeGraphemes::new(text.slice(cursor_rev.head..))
.take(2)
.all(|g| g.chars().all(char_is_word))
{
words.push(movement::move_next_word_end(text, cursor_rev, 1).flip());
}
changed = true;
if words.len() == jump_label_limit {
break;
}
}
if !changed {
break;
}
}
jump_to_label(cx, words, behaviour)
}
2 changes: 2 additions & 0 deletions helix-term/src/keymap/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"k" => move_line_up,
"j" => move_line_down,
"." => goto_last_modification,
"w" => goto_word,
},
":" => command_mode,

Expand Down Expand Up @@ -357,6 +358,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
"g" => { "Goto"
"k" => extend_line_up,
"j" => extend_line_down,
"w" => extend_to_word,
},
}));
let insert = keymap!({ "Insert mode"
Expand Down
23 changes: 22 additions & 1 deletion helix-term/src/ui/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1030,13 +1030,33 @@ impl EditorView {
}

impl EditorView {
/// must be called whenever the editor processed input that
/// is not a `KeyEvent`. In these cases any pending keys/on next
/// key callbacks must be canceled.
fn handle_non_key_input(&mut self, cxt: &mut commands::Context) {
cxt.editor.status_msg = None;
cxt.editor.reset_idle_timer();
// HACKS: create a fake key event that will never trigger any actual map
// and therefore simply acts as "dismiss"
let null_key_event = KeyEvent {
code: KeyCode::Null,
modifiers: KeyModifiers::empty(),
};
// dismiss any pending keys
if let Some(on_next_key) = self.on_next_key.take() {
on_next_key(cxt, null_key_event);
}
self.handle_keymap_event(cxt.editor.mode, cxt, null_key_event);
self.pseudo_pending.clear();
}

fn handle_mouse_event(
&mut self,
event: &MouseEvent,
cxt: &mut commands::Context,
) -> EventResult {
if event.kind != MouseEventKind::Moved {
cxt.editor.reset_idle_timer();
self.handle_non_key_input(cxt)
}

let config = cxt.editor.config();
Expand Down Expand Up @@ -1245,6 +1265,7 @@ impl Component for EditorView {

match event {
Event::Paste(contents) => {
self.handle_non_key_input(&mut cx);
cx.count = cx.editor.count;
commands::paste_bracketed_value(&mut cx, contents.clone());
cx.editor.count = None;
Expand Down
13 changes: 12 additions & 1 deletion helix-view/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use helix_core::chars::char_is_word;
use helix_core::doc_formatter::TextFormat;
use helix_core::encoding::Encoding;
use helix_core::syntax::{Highlight, LanguageServerFeature};
use helix_core::text_annotations::InlineAnnotation;
use helix_core::text_annotations::{InlineAnnotation, Overlay};
use helix_lsp::util::lsp_pos_to_pos;
use helix_vcs::{DiffHandle, DiffProviderRegistry};

Expand Down Expand Up @@ -124,6 +124,7 @@ pub struct Document {
///
/// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`.
pub(crate) inlay_hints: HashMap<ViewId, DocumentInlayHints>,
pub(crate) jump_labels: HashMap<ViewId, Vec<Overlay>>,
/// Set to `true` when the document is updated, reset to `false` on the next inlay hints
/// update from the LSP
pub inlay_hints_oudated: bool,
Expand Down Expand Up @@ -664,6 +665,7 @@ impl Document {
version_control_head: None,
focused_at: std::time::Instant::now(),
readonly: false,
jump_labels: HashMap::new(),
}
}

Expand Down Expand Up @@ -1131,6 +1133,7 @@ impl Document {
pub fn remove_view(&mut self, view_id: ViewId) {
self.selections.remove(&view_id);
self.inlay_hints.remove(&view_id);
self.jump_labels.remove(&view_id);
}

/// Apply a [`Transaction`] to the [`Document`] to change its text.
Expand Down Expand Up @@ -1938,6 +1941,14 @@ impl Document {
self.inlay_hints.insert(view_id, inlay_hints);
}

pub fn set_jump_labels(&mut self, view_id: ViewId, labels: Vec<Overlay>) {
self.jump_labels.insert(view_id, labels);
}

pub fn remove_jump_labels(&mut self, view_id: ViewId) {
self.jump_labels.remove(&view_id);
}

/// Get the inlay hints for this document and `view_id`.
pub fn inlay_hints(&self, view_id: ViewId) -> Option<&DocumentInlayHints> {
self.inlay_hints.get(&view_id)
Expand Down
23 changes: 22 additions & 1 deletion helix-view/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
use std::{
borrow::Cow,
cell::Cell,
collections::{BTreeMap, HashMap},
collections::{BTreeMap, HashMap, HashSet},
fs,
io::{self, stdin},
num::NonZeroUsize,
Expand Down Expand Up @@ -210,6 +210,23 @@ impl Default for FilePickerConfig {
}
}

fn deserialize_alphabet<'de, D>(deserializer: D) -> Result<Vec<char>, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;

let str = String::deserialize(deserializer)?;
let chars: Vec<_> = str.chars().collect();
let unique_chars: HashSet<_> = chars.iter().copied().collect();
if unique_chars.len() != chars.len() {
return Err(<D::Error as Error>::custom(
"jump-label-alphabet must contain unique characters",
));
}
Ok(chars)
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
pub struct Config {
Expand Down Expand Up @@ -303,6 +320,9 @@ pub struct Config {
/// Which indent heuristic to use when a new line is inserted
#[serde(default)]
pub indent_heuristic: IndentationHeuristic,
/// labels characters used in jumpmode
#[serde(skip_serializing, deserialize_with = "deserialize_alphabet")]
pub jump_label_alphabet: Vec<char>,
}

#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
Expand Down Expand Up @@ -871,6 +891,7 @@ impl Default for Config {
smart_tab: Some(SmartTabConfig::default()),
popup_border: PopupBorderConfig::None,
indent_heuristic: IndentationHeuristic::default(),
jump_label_alphabet: ('a'..='z').collect(),
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions helix-view/src/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,13 @@ impl View {
) -> TextAnnotations<'a> {
let mut text_annotations = TextAnnotations::default();

if let Some(labels) = doc.jump_labels.get(&self.id) {
let style = theme
.and_then(|t| t.find_scope_index("ui.virtual.jump-label"))
.map(Highlight);
text_annotations.add_overlay(labels, style);
}

if let Some(DocumentInlayHints {
id: _,
type_inlay_hints,
Expand Down
Loading

0 comments on commit 87278fe

Please sign in to comment.