diff --git a/book/src/keymap.md b/book/src/keymap.md index 6d90d802d41d..89dd6619c201 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -209,6 +209,8 @@ Jumps to various locations. | `n` | Go to next buffer | `goto_next_buffer` | | `p` | Go to previous buffer | `goto_previous_buffer` | | `.` | Go to last modification in current file | `goto_last_modification` | +| `j` | Word-wise jump mode | `jump_mode_word` | +| `J` | Character search jump mode | `jump_mode_search` | #### Match mode diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index c87ad0ca229c..4be02547a3af 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -433,6 +433,9 @@ impl MappableCommand { decrement, "Decrement item under cursor", record_macro, "Record macro", replay_macro, "Replay macro", + jump_mode_word, "Jump mode: word-wise", + jump_mode_search, "Jump mode: character search", + extend_jump_mode_search, "Jump mode: extending character search", command_palette, "Open command palette", ); } @@ -1629,7 +1632,11 @@ fn search_impl( doc.set_selection(view.id, selection); // TODO: is_cursor_in_view does the same calculation as ensure_cursor_in_view - if view.is_cursor_in_view(doc, 0) { + let cursor = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + if view.is_cursor_in_view(cursor, doc, 0) { view.ensure_cursor_in_view(doc, scrolloff); } else { align_view(doc, view, Align::Center) @@ -4903,3 +4910,315 @@ fn replay_macro(cx: &mut Context) { cx.editor.macro_replaying.pop(); })); } + +fn jump_mode_word(cx: &mut Context) { + let (view, doc) = current!(cx.editor); + + let text = doc.text().slice(..); + let range = doc.selection(view.id).primary(); + + let mut forward_jump_locations = Vec::new(); + for n in 1.. { + let next = movement::move_next_word_start(text, range, n); + // Check that the cursor is within the file before attempting further operations. + if next.cursor(text) >= text.len_chars() { + break; + } + // Use a `b` operation to position the cursor at the first character of words, + // rather than in between them. + let next = movement::move_prev_word_start(text, next, 1); + let cursor_pos = next.cursor(text); + let row = visual_coords_at_pos(doc.text().slice(..), cursor_pos, doc.tab_width()).row; + if row >= view.offset.row + view.inner_area().height as usize { + break; + } + if !view.is_cursor_in_view(cursor_pos, doc, 0) { + continue; + } + // Avoid adjacent jump locations + if forward_jump_locations + .last() + .map(|(pos, _)| cursor_pos - pos <= 1) + .unwrap_or(false) + { + continue; + } + forward_jump_locations.push((cursor_pos, next.anchor)); + } + + let mut backward_jump_locations = Vec::new(); + for n in 1.. { + let next = movement::move_prev_word_start(text, range, n); + let cursor_pos = next.cursor(text); + let row = visual_coords_at_pos(doc.text().slice(..), cursor_pos, doc.tab_width()).row; + if row < view.offset.row { + break; + } + if !view.is_cursor_in_view(cursor_pos, doc, 0) { + if cursor_pos == 0 { + break; + } + continue; + } + if backward_jump_locations + .last() + .map(|(pos, _)| pos - cursor_pos <= 1) + .unwrap_or(false) + { + continue; + } + backward_jump_locations.push((cursor_pos, next.anchor)); + if cursor_pos == 0 { + break; + } + } + + jump_mode_impl(cx, forward_jump_locations, backward_jump_locations); +} + +fn jump_mode_search(cx: &mut Context) { + jump_mode_search_impl(cx, false); +} + +fn extend_jump_mode_search(cx: &mut Context) { + jump_mode_search_impl(cx, true); +} + +fn jump_mode_search_impl(cx: &mut Context, extend: bool) { + cx.on_next_key(move |cx, event| { + let c = match event.char() { + Some(c) => c, + _ => return, + }; + + let (view, doc) = current!(cx.editor); + + let text = doc.text().slice(..); + let (cursor, anchor) = { + let range = doc.selection(view.id).primary(); + (range.cursor(text), range.anchor) + }; + + let mut forward_jump_locations = Vec::new(); + for n in 1.. { + let next = search::find_nth_next(text, c, cursor + 1, n); + match next { + Some(pos) => { + let row = visual_coords_at_pos(doc.text().slice(..), pos, doc.tab_width()).row; + if row >= view.offset.row + view.inner_area().height as usize { + break; + } + if !view.is_cursor_in_view(pos, doc, 0) { + continue; + } + forward_jump_locations.push((pos, if extend { anchor } else { pos })); + } + _ => break, + } + } + let mut backward_jump_locations = Vec::new(); + for n in 1.. { + let next = search::find_nth_prev(text, c, cursor, n); + match next { + Some(pos) => { + let row = visual_coords_at_pos(doc.text().slice(..), pos, doc.tab_width()).row; + if row < view.offset.row { + break; + } + if !view.is_cursor_in_view(pos, doc, 0) { + continue; + } + backward_jump_locations.push((pos, if extend { anchor } else { pos })); + } + _ => break, + } + } + + jump_mode_impl(cx, forward_jump_locations, backward_jump_locations); + }); +} + +fn jump_mode_impl( + cx: &mut Context, + forward_jumps: Vec<(usize, usize)>, + backward_jumps: Vec<(usize, usize)>, +) { + const JUMP_KEYS: &[u8] = b"asdghklqwertyuiopzxcvbnmfj;"; + + let jump_locations = forward_jumps + .into_iter() + .map(Some) + .chain(std::iter::repeat(None)) + .zip( + backward_jumps + .into_iter() + .map(Some) + .chain(std::iter::repeat(None)), + ) + .take_while(|tup| *tup != (None, None)) + .flat_map(|(fwd, bck)| [fwd, bck]) + .flatten() + .collect::>(); + + if jump_locations.is_empty() { + return; + } + + // Optimize the quantity of keys to use for multikey jumps to maximize the + // number of jumps accessible within one keystroke without compromising on + // making enough jumps accessible within two keystrokes. + let sep_idx = JUMP_KEYS.len() - { + let k = JUMP_KEYS.len() as f32; + // Clamp input to the domain (0, (k^2 + 2k + 1) / 4]. + let n = (jump_locations.len() as f32).min((k.powi(2) + 2.0 * k + 1.0) / 4.0); + // Within the domain (0, (k^2 + 2k + 1) / 4], this function returns values + // in the range (-1, k/2]. As such, when `.ceil()` is called on the output, + // the result is in the range [0, k/2]. + ((k - 1.0 - (k.powi(2) + 2.0 * k - 4.0 * n + 1.0).sqrt()) / 2.0).ceil() as usize + }; + + enum Jump { + Final(usize, usize), + Multi(HashMap), + } + + let mut jump_seqs = JUMP_KEYS[..sep_idx] + .iter() + .copied() + .map(|b| vec![b]) + .collect::>(); + loop { + if jump_seqs.len() >= jump_locations.len() { + break; + } + let last_len = jump_seqs.last().map(|seq| seq.len()).unwrap_or(1); + let mut seq_iter = jump_seqs + .iter() + .zip((1..=jump_seqs.len()).rev()) + .skip_while(|(seq, _)| seq.len() < last_len) + .map(|(seq, len)| (seq.clone(), len)) + .peekable(); + let subset_len = seq_iter.peek().map(|(_, len)| *len).unwrap_or(1); + let mut new_seqs = std::iter::repeat(seq_iter.map(|(seq, _)| seq)) + .take( + // Add 1 less than the divisor to essentially ceil the integer division. + (jump_locations.len().saturating_sub(jump_seqs.len()) + subset_len - 1) + / subset_len, + ) + .zip(JUMP_KEYS[sep_idx..].iter().copied()) + .flat_map(|(iter, k)| { + iter.map(move |mut seq| { + seq.insert(0, k); + seq + }) + }) + .collect(); + jump_seqs.append(&mut new_seqs); + } + + let mut jumps = HashMap::new(); + for (seq, pos) in jump_seqs.into_iter().zip(jump_locations) { + let mut current = &mut jumps; + for &k in &seq[..seq.len() - 1] { + current = match current + .entry(k) + .or_insert_with(|| Jump::Multi(HashMap::new())) + { + Jump::Multi(map) => map, + _ => unreachable!(), + }; + } + current.insert(*seq.last().unwrap(), Jump::Final(pos.0, pos.1)); + } + + use helix_view::decorations::{TextAnnotation, TextAnnotationKind}; + use helix_view::graphics::{Color, Modifier, Style}; + + fn annotations_impl(label: u8, jump: &Jump) -> Box + '_> { + match jump { + Jump::Final(pos, _) => Box::new(std::iter::once(((label as char).into(), *pos))), + Jump::Multi(map) => Box::new( + map.iter() + .flat_map(|(&label, jump)| annotations_impl(label, jump)) + .map(move |(mut label_, jump)| { + ( + { + label_.insert(0, label as char); + label_ + }, + jump, + ) + }), + ), + } + } + fn annotations( + doc: &Document, + theme: &helix_view::Theme, + jumps: &HashMap, + ) -> impl Iterator { + let single_style = theme + .try_get("ui.jump.single") + .unwrap_or_else(|| Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)); + let multi_style = theme.try_get("ui.jump.multi").unwrap_or_else(|| { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + }); + jumps + .iter() + .flat_map(|(&label, jump)| annotations_impl(label, jump)) + .map(|(annot, pos)| { + let text = doc.text(); + let line = text.char_to_line(pos); + let offset = pos - text.line_to_char(line); + let style = match annot.len() { + 2.. => multi_style, + _ => single_style, + }; + TextAnnotation { + text: annot.into(), + style, + line, + kind: TextAnnotationKind::Overlay(offset), + } + }) + // Collect to satisfy 'static lifetime. + .collect::>() + .into_iter() + } + + let doc = doc_mut!(cx.editor); + + let annots = annotations(doc, &cx.editor.theme, &jumps); + doc.push_text_annotations("jump_mode", annots); + + fn handle_key(mut jumps: HashMap, cx: &mut Context, event: KeyEvent) { + let doc = doc_mut!(cx.editor); + doc.clear_text_annotations("jump_mode"); + if let Some(jump) = event + .char() + .and_then(|c| c.try_into().ok()) + .and_then(|c| jumps.remove(&c)) + { + match jump { + Jump::Multi(jumps) => { + let annots = annotations(doc, &cx.editor.theme, &jumps); + doc.push_text_annotations("jump_mode", annots); + cx.on_next_key(move |cx, event| handle_key(jumps, cx, event)); + } + Jump::Final(mut cursor, anchor) => { + let (view, doc) = current!(cx.editor); + push_jump(view, doc); + // Fixes off-by-one errors when extending with jump mode + if cursor >= anchor { + cursor += 1 + } + doc.set_selection(view.id, Selection::single(anchor, cursor)); + } + } + } + } + + cx.on_next_key(move |cx, event| handle_key(jumps, cx, event)); +} diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index f07d4028ccde..6927a794312f 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -55,6 +55,8 @@ pub fn default() -> HashMap { "n" => goto_next_buffer, "p" => goto_previous_buffer, "." => goto_last_modification, + "j" => jump_mode_word, + "J" => jump_mode_search, }, ":" => command_mode, @@ -337,6 +339,10 @@ pub fn default() -> HashMap { "end" => extend_to_line_end, "esc" => exit_select_mode, + "g" => { "Goto" + "J" => extend_jump_mode_search, + }, + "v" => normal_mode, })); let insert = keymap!({ "Insert mode" diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 7cb29c3b1ecb..c6e5ed759cca 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -12,10 +12,11 @@ use helix_core::{ }, movement::Direction, syntax::{self, HighlightEvent}, - unicode::width::UnicodeWidthStr, + unicode::{segmentation::UnicodeSegmentation, width::UnicodeWidthStr}, LineEnding, Position, Range, Selection, Transaction, }; use helix_view::{ + decorations::{TextAnnotation, TextAnnotationKind}, document::{Mode, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, @@ -395,6 +396,11 @@ impl EditorView { // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). let text = doc.text().slice(..); + let last_line = std::cmp::min( + // Saturating subs to make it inclusive zero indexing. + (offset.row + viewport.height as usize).saturating_sub(1), + doc.text().len_lines().saturating_sub(1), + ); let characters = &whitespace.characters; @@ -450,6 +456,37 @@ impl EditorView { } }; + // It's slightly more efficient to produce a full RopeSlice from the Rope, then slice that a bunch + // of times than it is to always call Rope::slice/get_slice (it will internally always hit RSEnum::Light). + let text = text.slice(..); + let out_of_bounds = |visual_x: u16| { + visual_x < offset.col as u16 || visual_x >= viewport.width + offset.col as u16 + }; + + let render_annotation = + |annot: &TextAnnotation, line: u16, pos: u16, surface: &mut Surface| { + let mut visual_x = pos; + for grapheme in annot.text.graphemes(true) { + if out_of_bounds(visual_x) { + break; + } + surface.set_string( + viewport.x + visual_x - offset.col as u16, + viewport.y + line, + grapheme, + annot.style, + ); + visual_x = visual_x.saturating_add(grapheme.width() as u16); + } + }; + + let text_annotations = doc + .text_annotations() + .iter() + .flat_map(|(_, annots)| annots) + .filter(|annot| (offset.row..last_line).contains(&annot.line)) + .collect::>(); + 'outer: for event in highlights { match event { HighlightEvent::HighlightStart(span) => { @@ -488,11 +525,8 @@ impl EditorView { use helix_core::graphemes::{grapheme_width, RopeGraphemes}; for grapheme in RopeGraphemes::new(text) { - let out_of_bounds = visual_x < offset.col as u16 - || visual_x >= viewport.width + offset.col as u16; - if LineEnding::from_rope_slice(&grapheme).is_some() { - if !out_of_bounds { + if !out_of_bounds(visual_x) { // we still want to render an empty cell with the style surface.set_string( viewport.x + visual_x - offset.col as u16, @@ -500,10 +534,18 @@ impl EditorView { &newline, style.patch(whitespace_style), ); + visual_x += 1; } draw_indent_guides(last_line_indent_level, line, surface); + if let Some(annot) = text_annotations + .iter() + .find(|t| t.kind.is_eol() && t.line == offset.row + line as usize) + { + render_annotation(annot, line, visual_x, surface); + } + visual_x = 0; line += 1; is_in_indent_area = true; @@ -540,7 +582,7 @@ impl EditorView { let cut_off_start = offset.col.saturating_sub(visual_x as usize); - if !out_of_bounds { + if !out_of_bounds(visual_x) { // if we're offscreen just keep going until we hit a new line surface.set_string( viewport.x + visual_x - offset.col as u16, @@ -582,6 +624,13 @@ impl EditorView { } } } + + for annot in &text_annotations { + if let TextAnnotationKind::Overlay(visual_x) = annot.kind { + let line = (annot.line - offset.row) as u16; + render_annotation(annot, line, visual_x as u16, surface); + } + } } /// Render brace match, etc (meant for the focused view only) diff --git a/helix-view/src/decorations.rs b/helix-view/src/decorations.rs new file mode 100644 index 000000000000..7ad0b261badd --- /dev/null +++ b/helix-view/src/decorations.rs @@ -0,0 +1,32 @@ +use std::borrow::Cow; + +use crate::graphics::Style; + +#[derive(Clone, Copy, PartialEq)] +pub enum TextAnnotationKind { + /// Add to end of line + Eol, + /// Replace actual text or arbitary cells with annotations. + /// Specifies an offset from the 0th column. + Overlay(usize), +} + +impl TextAnnotationKind { + pub fn is_eol(&self) -> bool { + *self == Self::Eol + } + + pub fn is_overlay(&self) -> bool { + matches!(*self, Self::Overlay(_)) + } +} + +/// Namespaces and identifes similar annotations +pub type TextAnnotationGroup = &'static str; + +pub struct TextAnnotation { + pub text: Cow<'static, str>, + pub style: Style, + pub line: usize, + pub kind: TextAnnotationKind, +} diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 2ef99c6ad136..948b6e6d4d72 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -23,7 +23,9 @@ use helix_core::{ DEFAULT_LINE_ENDING, }; -use crate::{DocumentId, Editor, ViewId}; +use crate::decorations::TextAnnotationGroup; +use crate::Editor; +use crate::{decorations::TextAnnotation, DocumentId, ViewId}; /// 8kB of buffer space for encoding and decoding `Rope`s. const BUF_SIZE: usize = 8192; @@ -119,6 +121,7 @@ pub struct Document { pub(crate) modified_since_accessed: bool, diagnostics: Vec, + text_annotations: HashMap>, language_server: Option>, } @@ -351,6 +354,7 @@ impl Document { language: None, changes, old_state, + text_annotations: HashMap::new(), diagnostics: Vec::new(), version: 0, history: Cell::new(History::default()), @@ -1038,6 +1042,31 @@ impl Document { .map(helix_core::path::get_relative_path) } + pub fn text_annotations(&self) -> &HashMap> { + &self.text_annotations + } + + pub fn push_text_annotations>( + &mut self, + group: TextAnnotationGroup, + annots: I, + ) { + self.text_annotations + .entry(group) + .or_default() + .extend(annots); + } + + pub fn clear_text_annotations(&mut self, group: TextAnnotationGroup) { + if let Some(annots) = self.text_annotations.get_mut(group) { + annots.clear() + } + } + + // pub fn slice(&self, range: R) -> RopeSlice where R: RangeBounds { + // self.state.doc.slice + // } + // transact(Fn) ? // -- LSP methods diff --git a/helix-view/src/lib.rs b/helix-view/src/lib.rs index 788304bce360..6bd2e099a55c 100644 --- a/helix-view/src/lib.rs +++ b/helix-view/src/lib.rs @@ -2,6 +2,7 @@ pub mod macros; pub mod clipboard; +pub mod decorations; pub mod document; pub mod editor; pub mod graphics; diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index 3df533dfc6d9..24d0436337cb 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -154,14 +154,10 @@ impl View { // pub fn offset_coords_to_in_view( &self, + cursor: usize, doc: &Document, scrolloff: usize, ) -> Option<(usize, usize)> { - let cursor = doc - .selection(self.id) - .primary() - .cursor(doc.text().slice(..)); - let Position { col, row: line } = visual_coords_at_pos(doc.text().slice(..), cursor, doc.tab_width()); @@ -202,14 +198,19 @@ impl View { } pub fn ensure_cursor_in_view(&mut self, doc: &Document, scrolloff: usize) { - if let Some((row, col)) = self.offset_coords_to_in_view(doc, scrolloff) { + let cursor = doc + .selection(self.id) + .primary() + .cursor(doc.text().slice(..)); + if let Some((row, col)) = self.offset_coords_to_in_view(cursor, doc, scrolloff) { self.offset.row = row; self.offset.col = col; } } - pub fn is_cursor_in_view(&mut self, doc: &Document, scrolloff: usize) -> bool { - self.offset_coords_to_in_view(doc, scrolloff).is_none() + pub fn is_cursor_in_view(&self, cursor: usize, doc: &Document, scrolloff: usize) -> bool { + self.offset_coords_to_in_view(cursor, doc, scrolloff) + .is_none() } /// Calculates the last visible line on screen