diff options
Diffstat (limited to 'helix-term/src')
-rw-r--r-- | helix-term/src/application.rs | 26 | ||||
-rw-r--r-- | helix-term/src/args.rs | 10 | ||||
-rw-r--r-- | helix-term/src/commands.rs | 277 | ||||
-rw-r--r-- | helix-term/src/commands/lsp.rs | 8 | ||||
-rw-r--r-- | helix-term/src/commands/typed.rs | 130 | ||||
-rw-r--r-- | helix-term/src/keymap/default.rs | 42 | ||||
-rw-r--r-- | helix-term/src/ui/completion.rs | 12 | ||||
-rw-r--r-- | helix-term/src/ui/editor.rs | 191 | ||||
-rw-r--r-- | helix-term/src/ui/fuzzy_match.rs | 74 | ||||
-rw-r--r-- | helix-term/src/ui/fuzzy_match/test.rs | 47 | ||||
-rw-r--r-- | helix-term/src/ui/lsp.rs | 3 | ||||
-rw-r--r-- | helix-term/src/ui/menu.rs | 2 | ||||
-rw-r--r-- | helix-term/src/ui/mod.rs | 42 | ||||
-rw-r--r-- | helix-term/src/ui/picker.rs | 64 | ||||
-rw-r--r-- | helix-term/src/ui/statusline.rs | 18 |
15 files changed, 669 insertions, 277 deletions
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index cd499f1c..4bb36b59 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -224,8 +224,8 @@ impl Application { #[cfg(windows)] let signals = futures_util::stream::empty(); #[cfg(not(windows))] - let signals = - Signals::new(&[signal::SIGTSTP, signal::SIGCONT]).context("build signal handler")?; + let signals = Signals::new(&[signal::SIGTSTP, signal::SIGCONT, signal::SIGUSR1]) + .context("build signal handler")?; let app = Self { compositor, @@ -426,23 +426,22 @@ impl Application { self.compositor.load_cursor(); self.render(); } + signal::SIGUSR1 => { + self.refresh_config(); + self.render(); + } _ => unreachable!(), } } pub fn handle_idle_timeout(&mut self) { - use crate::compositor::EventResult; - let editor_view = self - .compositor - .find::<ui::EditorView>() - .expect("expected at least one EditorView"); - let mut cx = crate::compositor::Context { editor: &mut self.editor, jobs: &mut self.jobs, scroll: None, }; - if let EventResult::Consumed(_) = editor_view.handle_idle_timeout(&mut cx) { + let should_render = self.compositor.handle_event(&Event::IdleTimeout, &mut cx); + if should_render { self.render(); } } @@ -866,9 +865,16 @@ impl Application { })); self.event_loop(input_stream).await; - self.close().await?; + + let err = self.close().await.err(); + restore_term()?; + if let Some(err) = err { + self.editor.exit_code = 1; + eprintln!("Error: {}", err); + } + Ok(self.editor.exit_code) } diff --git a/helix-term/src/args.rs b/helix-term/src/args.rs index 48c86633..dd787f1f 100644 --- a/helix-term/src/args.rs +++ b/helix-term/src/args.rs @@ -32,8 +32,14 @@ impl Args { "--version" => args.display_version = true, "--help" => args.display_help = true, "--tutor" => args.load_tutor = true, - "--vsplit" => args.split = Some(Layout::Vertical), - "--hsplit" => args.split = Some(Layout::Horizontal), + "--vsplit" => match args.split { + Some(_) => anyhow::bail!("can only set a split once of a specific type"), + None => args.split = Some(Layout::Vertical), + }, + "--hsplit" => match args.split { + Some(_) => anyhow::bail!("can only set a split once of a specific type"), + None => args.split = Some(Layout::Horizontal), + }, "--health" => { args.health = true; args.health_arg = argv.next_if(|opt| !opt.starts_with('-')); diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index fb1a4b38..5073651b 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -27,6 +27,7 @@ use helix_core::{ SmallVec, Tendril, Transaction, }; use helix_view::{ + apply_transaction, clipboard::ClipboardType, document::{FormatterError, Mode, SCRATCH_BUFFER_NAME}, editor::{Action, Motion}, @@ -273,8 +274,8 @@ impl MappableCommand { diagnostics_picker, "Open diagnostic picker", workspace_diagnostics_picker, "Open workspace diagnostic picker", last_picker, "Open last picker", - prepend_to_line, "Insert at start of line", - append_to_line, "Append to end of line", + insert_at_line_start, "Insert at start of line", + insert_at_line_end, "Insert at end of line", open_below, "Open new line below selection", open_above, "Open new line above selection", normal_mode, "Enter normal mode", @@ -346,6 +347,7 @@ impl MappableCommand { unindent, "Unindent selection", format_selections, "Format selection", join_selections, "Join lines inside selection", + join_selections_space, "Join lines inside selection and select spaces", keep_selections, "Keep selections matching regex", remove_selections, "Remove selections matching regex", align_selections, "Align selections in column", @@ -858,7 +860,7 @@ fn align_selections(cx: &mut Context) { changes.sort_unstable_by_key(|(from, _, _)| *from); let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn goto_window(cx: &mut Context, align: Align) { @@ -885,8 +887,12 @@ fn goto_window(cx: &mut Context, align: Align) { .min(last_line.saturating_sub(scrolloff)); let pos = doc.text().line_to_char(line); - - doc.set_selection(view.id, Selection::point(pos)); + let text = doc.text().slice(..); + let selection = doc + .selection(view.id) + .clone() + .transform(|range| range.put_cursor(text, pos, cx.editor.mode == Mode::Select)); + doc.set_selection(view.id, selection); } fn goto_window_top(cx: &mut Context) { @@ -1284,7 +1290,7 @@ fn replace(cx: &mut Context) { } }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } }) } @@ -1301,7 +1307,7 @@ where (range.from(), range.to(), Some(text)) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn switch_case(cx: &mut Context) { @@ -1511,7 +1517,8 @@ fn select_regex(cx: &mut Context) { "select:".into(), Some(reg), ui::completers::none, - move |view, doc, regex, event| { + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -1532,7 +1539,8 @@ fn split_selection(cx: &mut Context) { "split:".into(), Some(reg), ui::completers::none, - move |view, doc, regex, event| { + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -1556,15 +1564,16 @@ fn split_selection_on_newline(cx: &mut Context) { #[allow(clippy::too_many_arguments)] fn search_impl( - doc: &mut Document, - view: &mut View, + editor: &mut Editor, contents: &str, regex: &Regex, movement: Movement, direction: Direction, scrolloff: usize, wrap_around: bool, + show_warnings: bool, ) { + let (view, doc) = current!(editor); let text = doc.text().slice(..); let selection = doc.selection(view.id); @@ -1594,17 +1603,29 @@ fn search_impl( Direction::Backward => regex.find_iter(&contents[..start]).last(), }; - if wrap_around && mat.is_none() { - mat = match direction { - Direction::Forward => regex.find(contents), - Direction::Backward => { - offset = start; - regex.find_iter(&contents[start..]).last() + if mat.is_none() { + if wrap_around { + mat = match direction { + Direction::Forward => regex.find(contents), + Direction::Backward => { + offset = start; + regex.find_iter(&contents[start..]).last() + } + }; + } + if show_warnings { + if wrap_around && mat.is_some() { + editor.set_status("Wrapped around document"); + } else { + editor.set_error("No more matches"); } } - // TODO: message on wraparound } + let (view, doc) = current!(editor); + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + if let Some(mat) = mat { let start = text.byte_to_char(mat.start() + offset); let end = text.byte_to_char(mat.end() + offset); @@ -1680,19 +1701,19 @@ fn searcher(cx: &mut Context, direction: Direction) { .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) .collect() }, - move |view, doc, regex, event| { + move |editor, regex, event| { if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } search_impl( - doc, - view, + editor, &contents, ®ex, Movement::Move, direction, scrolloff, wrap_around, + false, ); }, ); @@ -1702,7 +1723,7 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir let count = cx.count(); let config = cx.editor.config(); let scrolloff = config.scrolloff; - let (view, doc) = current!(cx.editor); + let (_, doc) = current!(cx.editor); let registers = &cx.editor.registers; if let Some(query) = registers.read('/').and_then(|query| query.last()) { let contents = doc.text().slice(..).to_string(); @@ -1720,14 +1741,14 @@ fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Dir { for _ in 0..count { search_impl( - doc, - view, + cx.editor, &contents, ®ex, movement, direction, scrolloff, wrap_around, + true, ); } } else { @@ -1825,7 +1846,7 @@ fn global_search(cx: &mut Context) { .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone()))) .collect() }, - move |_view, _doc, regex, event| { + move |_editor, regex, event| { if event != PromptEvent::Validate { return; } @@ -1844,10 +1865,15 @@ fn global_search(cx: &mut Context) { .hidden(file_picker_config.hidden) .parents(file_picker_config.parents) .ignore(file_picker_config.ignore) + .follow_links(file_picker_config.follow_symlinks) .git_ignore(file_picker_config.git_ignore) .git_global(file_picker_config.git_global) .git_exclude(file_picker_config.git_exclude) .max_depth(file_picker_config.max_depth) + // We always want to ignore the .git directory, otherwise if + // `ignore` is turned off above, we end up with a lot of noise + // in our picker. + .filter_entry(|entry| entry.file_name() != ".git") .build_parallel() .run(|| { let mut searcher = searcher.clone(); @@ -2092,7 +2118,7 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) { let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { (range.from(), range.to(), None) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); match op { Operation::Delete => { @@ -2106,14 +2132,11 @@ fn delete_selection_impl(cx: &mut Context, op: Operation) { } #[inline] -fn delete_selection_insert_mode(doc: &mut Document, view: &View, selection: &Selection) { - let view_id = view.id; - - // then delete +fn delete_selection_insert_mode(doc: &mut Document, view: &mut View, selection: &Selection) { let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { (range.from(), range.to(), None) }); - doc.apply(&transaction, view_id); + apply_transaction(&transaction, doc, view); } fn delete_selection(cx: &mut Context) { @@ -2161,10 +2184,7 @@ fn ensure_selections_forward(cx: &mut Context) { let selection = doc .selection(view.id) .clone() - .transform(|r| match r.direction() { - Direction::Forward => r, - Direction::Backward => r.flip(), - }); + .transform(|r| r.with_direction(Direction::Forward)); doc.set_selection(view.id, selection); } @@ -2207,12 +2227,12 @@ fn append_mode(cx: &mut Context) { .iter() .last() .expect("selection should always have at least one range"); - if !last_range.is_empty() && last_range.head == end { + if !last_range.is_empty() && last_range.to() == end { let transaction = Transaction::change( doc.text(), [(end, end, Some(doc.line_ending.as_str().into()))].into_iter(), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } let selection = doc.selection(view.id).clone().transform(|range| { @@ -2410,12 +2430,12 @@ impl ui::menu::Item for MappableCommand { match self { MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) { - Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(), - None => doc.as_str().into(), + Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), + None => format!("{} [{}]", doc, name).into(), }, MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { - Some(bindings) => format!("{} ({})", doc, fmt_binding(bindings)).into(), - None => (*doc).into(), + Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), + None => format!("{} [{}]", doc, name).into(), }, } } @@ -2465,13 +2485,13 @@ fn last_picker(cx: &mut Context) { } // I inserts at the first nonwhitespace character of each line with a selection -fn prepend_to_line(cx: &mut Context) { +fn insert_at_line_start(cx: &mut Context) { goto_first_nonwhitespace(cx); enter_insert_mode(cx); } // A inserts at the end of each line with a selection -fn append_to_line(cx: &mut Context) { +fn insert_at_line_end(cx: &mut Context) { enter_insert_mode(cx); let (view, doc) = current!(cx.editor); @@ -2512,7 +2532,7 @@ async fn make_format_callback( let doc = doc_mut!(editor, &doc_id); let view = view_mut!(editor); if doc.version() == doc_version { - doc.apply(&format, view.id); + apply_transaction(&format, doc, view); doc.append_changes_to_history(view.id); doc.detect_indent_and_line_ending(); view.ensure_cursor_in_view(doc, scrolloff); @@ -2599,7 +2619,7 @@ fn open(cx: &mut Context, open: Open) { transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } // o inserts a new line after each line with a selection @@ -2620,7 +2640,7 @@ fn normal_mode(cx: &mut Context) { cx.editor.mode = Mode::Normal; let (view, doc) = current!(cx.editor); - try_restore_indent(doc, view.id); + try_restore_indent(doc, view); // if leaving append mode, move cursor back by 1 if doc.restore_cursor { @@ -2637,7 +2657,7 @@ fn normal_mode(cx: &mut Context) { } } -fn try_restore_indent(doc: &mut Document, view_id: ViewId) { +fn try_restore_indent(doc: &mut Document, view: &mut View) { use helix_core::chars::char_is_whitespace; use helix_core::Operation; @@ -2656,18 +2676,18 @@ fn try_restore_indent(doc: &mut Document, view_id: ViewId) { let doc_changes = doc.changes().changes(); let text = doc.text().slice(..); - let range = doc.selection(view_id).primary(); + let range = doc.selection(view.id).primary(); let pos = range.cursor(text); let line_end_pos = line_end_char_index(&text, range.cursor_line(text)); if inserted_a_new_blank_line(doc_changes, pos, line_end_pos) { // Removes tailing whitespaces. let transaction = - Transaction::change_by_selection(doc.text(), doc.selection(view_id), |range| { + Transaction::change_by_selection(doc.text(), doc.selection(view.id), |range| { let line_start_pos = text.line_to_char(range.cursor_line(text)); (line_start_pos, pos, None) }); - doc.apply(&transaction, view_id); + apply_transaction(&transaction, doc, view); } } @@ -2865,7 +2885,7 @@ pub mod insert { /// Exclude the cursor in range. fn exclude_cursor(text: RopeSlice, range: Range, cursor: Range) -> Range { - if range.to() == cursor.to() { + if range.to() == cursor.to() && text.len_chars() != cursor.to() { Range::new( range.from(), graphemes::prev_grapheme_boundary(text, cursor.to()), @@ -2981,7 +3001,7 @@ pub mod insert { let (view, doc) = current!(cx.editor); if let Some(t) = transaction { - doc.apply(&t, view.id); + apply_transaction(&t, doc, view); } // TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc) @@ -3003,7 +3023,7 @@ pub mod insert { &doc.selection(view.id).clone().cursors(doc.text().slice(..)), indent, ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } pub fn insert_newline(cx: &mut Context) { @@ -3090,7 +3110,7 @@ pub mod insert { transaction = transaction.with_selection(Selection::new(ranges, selection.primary_index())); let (view, doc) = current!(cx.editor); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } pub fn delete_char_backward(cx: &mut Context) { @@ -3184,7 +3204,7 @@ pub mod insert { } }); let (view, doc) = current!(cx.editor); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } @@ -3202,7 +3222,7 @@ pub mod insert { None, ) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); lsp::signature_help_impl(cx, SignatureHelpInvoked::Automatic); } @@ -3243,7 +3263,7 @@ fn undo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); for _ in 0..count { - if !doc.undo(view.id) { + if !doc.undo(view) { cx.editor.set_status("Already at oldest change"); break; } @@ -3254,7 +3274,7 @@ fn redo(cx: &mut Context) { let count = cx.count(); let (view, doc) = current!(cx.editor); for _ in 0..count { - if !doc.redo(view.id) { + if !doc.redo(view) { cx.editor.set_status("Already at newest change"); break; } @@ -3266,7 +3286,7 @@ fn earlier(cx: &mut Context) { let (view, doc) = current!(cx.editor); for _ in 0..count { // rather than doing in batch we do this so get error halfway - if !doc.earlier(view.id, UndoKind::Steps(1)) { + if !doc.earlier(view, UndoKind::Steps(1)) { cx.editor.set_status("Already at oldest change"); break; } @@ -3278,7 +3298,7 @@ fn later(cx: &mut Context) { let (view, doc) = current!(cx.editor); for _ in 0..count { // rather than doing in batch we do this so get error halfway - if !doc.later(view.id, UndoKind::Steps(1)) { + if !doc.later(view, UndoKind::Steps(1)) { cx.editor.set_status("Already at newest change"); break; } @@ -3330,9 +3350,15 @@ fn yank_joined_to_clipboard_impl( .map(Cow::into_owned) .collect(); + let clipboard_text = match clipboard_type { + ClipboardType::Clipboard => "system clipboard", + ClipboardType::Selection => "primary clipboard", + }; + let msg = format!( - "joined and yanked {} selection(s) to system clipboard", + "joined and yanked {} selection(s) to {}", values.len(), + clipboard_text, ); let joined = values.join(separator); @@ -3361,6 +3387,11 @@ fn yank_main_selection_to_clipboard_impl( let (view, doc) = current!(editor); let text = doc.text().slice(..); + let message_text = match clipboard_type { + ClipboardType::Clipboard => "yanked main selection to system clipboard", + ClipboardType::Selection => "yanked main selection to primary clipboard", + }; + let value = doc.selection(view.id).primary().fragment(text); if let Err(e) = editor @@ -3370,7 +3401,7 @@ fn yank_main_selection_to_clipboard_impl( bail!("Couldn't set system clipboard content: {}", e); } - editor.set_status("yanked main selection to system clipboard"); + editor.set_status(message_text); Ok(()) } @@ -3396,7 +3427,7 @@ enum Paste { Cursor, } -fn paste_impl(values: &[String], doc: &mut Document, view: &View, action: Paste, count: usize) { +fn paste_impl(values: &[String], doc: &mut Document, view: &mut View, action: Paste, count: usize) { let repeat = std::iter::repeat( values .last() @@ -3439,7 +3470,7 @@ fn paste_impl(values: &[String], doc: &mut Document, view: &View, action: Paste, }; (pos, pos, values.next()) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } pub(crate) fn paste_bracketed_value(cx: &mut Context, contents: String) { @@ -3531,7 +3562,7 @@ fn replace_with_yanked(cx: &mut Context) { } }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } } } @@ -3554,7 +3585,7 @@ fn replace_selections_with_clipboard_impl( ) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); Ok(()) } @@ -3624,7 +3655,7 @@ fn indent(cx: &mut Context) { Some((pos, pos, Some(indent.clone()))) }), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn unindent(cx: &mut Context) { @@ -3663,7 +3694,7 @@ fn unindent(cx: &mut Context) { let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn format_selections(cx: &mut Context) { @@ -3710,11 +3741,11 @@ fn format_selections(cx: &mut Context) { // language_server.offset_encoding(), // ); - // doc.apply(&transaction, view.id); + // apply_transaction(&transaction, doc, view); } } -fn join_selections(cx: &mut Context) { +fn join_selections_inner(cx: &mut Context, select_space: bool) { use movement::skip_while; let (view, doc) = current!(cx.editor); let text = doc.text(); @@ -3749,11 +3780,23 @@ fn join_selections(cx: &mut Context) { // TODO: joining multiple empty lines should be replaced by a single space. // need to merge change ranges that touch - let transaction = Transaction::change(doc.text(), changes.into_iter()); - // TODO: select inserted spaces - // .with_selection(selection); + // select inserted spaces + let transaction = if select_space { + let ranges: SmallVec<_> = changes + .iter() + .scan(0, |offset, change| { + let range = Range::point(change.0 - *offset); + *offset += change.1 - change.0 - 1; // -1 because cursor is 0-sized + Some(range) + }) + .collect(); + let selection = Selection::new(ranges, 0); + Transaction::change(doc.text(), changes.into_iter()).with_selection(selection) + } else { + Transaction::change(doc.text(), changes.into_iter()) + }; - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { @@ -3764,7 +3807,8 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { if remove { "remove:" } else { "keep:" }.into(), Some(reg), ui::completers::none, - move |view, doc, regex, event| { + move |editor, regex, event| { + let (view, doc) = current!(editor); if !matches!(event, PromptEvent::Update | PromptEvent::Validate) { return; } @@ -3779,6 +3823,14 @@ fn keep_or_remove_selections_impl(cx: &mut Context, remove: bool) { ) } +fn join_selections(cx: &mut Context) { + join_selections_inner(cx, false) +} + +fn join_selections_space(cx: &mut Context) { + join_selections_inner(cx, true) +} + fn keep_selections(cx: &mut Context) { keep_or_remove_selections_impl(cx, false) } @@ -3897,7 +3949,7 @@ fn toggle_comments(cx: &mut Context) { .map(|tc| tc.as_ref()); let transaction = comment::toggle_line_comments(doc.text(), doc.selection(view.id), token); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); exit_select_mode(cx); } @@ -3953,7 +4005,7 @@ fn rotate_selection_contents(cx: &mut Context, direction: Direction) { .map(|(range, fragment)| (range.from(), range.to(), Some(fragment))), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } fn rotate_selection_contents_forward(cx: &mut Context) { @@ -4268,7 +4320,7 @@ fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direct let root = syntax.tree().root_node(); let selection = doc.selection(view.id).clone().transform(|range| { - movement::goto_treesitter_object( + let new_range = movement::goto_treesitter_object( text, range, object, @@ -4276,7 +4328,19 @@ fn goto_ts_object_impl(cx: &mut Context, object: &'static str, direction: Direct root, lang_config, count, - ) + ); + + if editor.mode == Mode::Select { + let head = if new_range.head < range.anchor { + new_range.anchor + } else { + new_range.head + }; + + Range::new(range.anchor, head) + } else { + new_range.with_direction(direction) + } }); doc.set_selection(view.id, selection); @@ -4341,7 +4405,6 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { cx.on_next_key(move |cx, event| { cx.editor.autoinfo = None; - cx.editor.pseudo_pending = None; if let Some(ch) = event.char() { let textobject = move |editor: &mut Editor| { let (view, doc) = current!(editor); @@ -4390,33 +4453,25 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { } }); - if let Some((title, abbrev)) = match objtype { - textobject::TextObject::Inside => Some(("Match inside", "mi")), - textobject::TextObject::Around => Some(("Match around", "ma")), + let title = match objtype { + textobject::TextObject::Inside => "Match inside", + textobject::TextObject::Around => "Match around", _ => return, - } { - let help_text = [ - ("w", "Word"), - ("W", "WORD"), - ("p", "Paragraph"), - ("c", "Class (tree-sitter)"), - ("f", "Function (tree-sitter)"), - ("a", "Argument/parameter (tree-sitter)"), - ("o", "Comment (tree-sitter)"), - ("t", "Test (tree-sitter)"), - ("m", "Closest surrounding pair to cursor"), - (" ", "... or any character acting as a pair"), - ]; - - cx.editor.autoinfo = Some(Info::new( - title, - help_text - .into_iter() - .map(|(col1, col2)| (col1.to_string(), col2.to_string())) - .collect(), - )); - cx.editor.pseudo_pending = Some(abbrev.to_string()); }; + let help_text = [ + ("w", "Word"), + ("W", "WORD"), + ("p", "Paragraph"), + ("c", "Class (tree-sitter)"), + ("f", "Function (tree-sitter)"), + ("a", "Argument/parameter (tree-sitter)"), + ("o", "Comment (tree-sitter)"), + ("t", "Test (tree-sitter)"), + ("m", "Closest surrounding pair to cursor"), + (" ", "... or any character acting as a pair"), + ]; + + cx.editor.autoinfo = Some(Info::new(title, &help_text)); } fn surround_add(cx: &mut Context) { @@ -4440,7 +4495,7 @@ fn surround_add(cx: &mut Context) { } let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); }) } @@ -4479,7 +4534,7 @@ fn surround_replace(cx: &mut Context) { (pos, pos + 1, Some(t)) }), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); }); }) } @@ -4506,7 +4561,7 @@ fn surround_delete(cx: &mut Context) { let transaction = Transaction::change(doc.text(), change_pos.into_iter().map(|p| (p, p + 1, None))); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); }) } @@ -4681,7 +4736,7 @@ fn shell(cx: &mut compositor::Context, cmd: &str, behavior: &ShellBehavior) { if behavior != &ShellBehavior::Ignore { let transaction = Transaction::change(doc.text(), changes.into_iter()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); } @@ -4744,7 +4799,7 @@ fn add_newline_impl(cx: &mut Context, open: Open) { }); let transaction = Transaction::change(text, changes); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } /// Increment object under cursor by count. @@ -4837,7 +4892,7 @@ fn increment_impl(cx: &mut Context, amount: i64) { let transaction = Transaction::change(doc.text(), changes); let transaction = transaction.with_selection(selection.clone()); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } } diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 1113b44e..3fa5c96f 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -9,7 +9,7 @@ use tui::text::{Span, Spans}; use super::{align_view, push_jump, Align, Context, Editor, Open}; use helix_core::{path, Selection}; -use helix_view::{editor::Action, theme::Style}; +use helix_view::{apply_transaction, editor::Action, theme::Style}; use crate::{ compositor::{self, Compositor}, @@ -596,9 +596,7 @@ pub fn apply_workspace_edit( } }; - let doc = editor - .document_mut(doc_id) - .expect("Document for document_changes not found"); + let doc = doc_mut!(editor, &doc_id); // Need to determine a view for apply/append_changes_to_history let selections = doc.selections(); @@ -619,7 +617,7 @@ pub fn apply_workspace_edit( text_edits, offset_encoding, ); - doc.apply(&transaction, view_id); + apply_transaction(&transaction, doc, view_mut!(editor, view_id)); doc.append_changes_to_history(view_id); }; diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 6d0ced65..1bfc8153 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -2,7 +2,10 @@ use std::ops::Deref; use super::*; -use helix_view::editor::{Action, ConfigEvent}; +use helix_view::{ + apply_transaction, + editor::{Action, CloseError, ConfigEvent}, +}; use ui::completers::{self, Completer}; #[derive(Clone)] @@ -71,8 +74,29 @@ fn buffer_close_by_ids_impl( doc_ids: &[DocumentId], force: bool, ) -> anyhow::Result<()> { - for &doc_id in doc_ids { - editor.close_document(doc_id, force)?; + let (modified_ids, modified_names): (Vec<_>, Vec<_>) = doc_ids + .iter() + .filter_map(|&doc_id| { + if let Err(CloseError::BufferModified(name)) = editor.close_document(doc_id, force) { + Some((doc_id, name)) + } else { + None + } + }) + .unzip(); + + if let Some(first) = modified_ids.first() { + let current = doc!(editor); + // If the current document is unmodified, and there are modified + // documents, switch focus to the first modified doc. + if !modified_ids.contains(¤t.id()) { + editor.switch(*first, Action::Replace); + } + bail!( + "{} unsaved buffer(s) remaining: {:?}", + modified_names.len(), + modified_names + ); } Ok(()) @@ -441,7 +465,7 @@ fn set_line_ending( } }), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); Ok(()) @@ -459,7 +483,7 @@ fn earlier( let uk = args.join(" ").parse::<UndoKind>().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - let success = doc.earlier(view.id, uk); + let success = doc.earlier(view, uk); if !success { cx.editor.set_status("Already at oldest change"); } @@ -478,7 +502,7 @@ fn later( let uk = args.join(" ").parse::<UndoKind>().map_err(|s| anyhow!(s))?; let (view, doc) = current!(cx.editor); - let success = doc.later(view.id, uk); + let success = doc.later(view, uk); if !success { cx.editor.set_status("Already at newest change"); } @@ -513,23 +537,26 @@ fn force_write_quit( force_quit(cx, &[], event) } -/// Results an error if there are modified buffers remaining and sets editor error, -/// otherwise returns `Ok(())` +/// Results in an error if there are modified buffers remaining and sets editor +/// error, otherwise returns `Ok(())`. If the current document is unmodified, +/// and there are modified documents, switches focus to one of them. pub(super) fn buffers_remaining_impl(editor: &mut Editor) -> anyhow::Result<()> { - let modified: Vec<_> = editor + let (modified_ids, modified_names): (Vec<_>, Vec<_>) = editor .documents() .filter(|doc| doc.is_modified()) - .map(|doc| { - doc.relative_path() - .map(|path| path.to_string_lossy().to_string()) - .unwrap_or_else(|| SCRATCH_BUFFER_NAME.into()) - }) - .collect(); - if !modified.is_empty() { + .map(|doc| (doc.id(), doc.display_name())) + .unzip(); + if let Some(first) = modified_ids.first() { + let current = doc!(editor); + // If the current document is unmodified, and there are modified + // documents, switch focus to the first modified doc. + if !modified_ids.contains(¤t.id()) { + editor.switch(*first, Action::Replace); + } bail!( "{} unsaved buffer(s) remaining: {:?}", - modified.len(), - modified + modified_names.len(), + modified_names ); } Ok(()) @@ -859,7 +886,7 @@ fn replace_selections_with_clipboard_impl( (range.from(), range.to(), Some(contents.as_str().into())) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); Ok(()) } @@ -980,7 +1007,7 @@ fn reload( let scrolloff = cx.editor.config().scrolloff; let (view, doc) = current!(cx.editor); - doc.reload(view.id).map(|_| { + doc.reload(view).map(|_| { view.ensure_cursor_in_view(doc, scrolloff); }) } @@ -1000,7 +1027,7 @@ fn lsp_restart( .context("LSP not defined for the current document")?; let scope = config.scope.clone(); - cx.editor.language_servers.restart(config)?; + cx.editor.language_servers.restart(config, doc.path())?; // This collect is needed because refresh_language_server would need to re-borrow editor. let document_ids_to_refresh: Vec<DocumentId> = cx @@ -1033,7 +1060,21 @@ fn tree_sitter_scopes( let pos = doc.selection(view.id).primary().cursor(text); let scopes = indent::get_scopes(doc.syntax(), text, pos); - cx.editor.set_status(format!("scopes: {:?}", &scopes)); + + let contents = format!("```json\n{:?}\n````", scopes); + + let callback = async move { + let call: job::Callback = + Box::new(move |editor: &mut Editor, compositor: &mut Compositor| { + let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); + let popup = Popup::new("hover", contents).auto_close(true); + compositor.replace_or_push("hover", popup); + }); + Ok(call) + }; + + cx.jobs.callback(callback); + Ok(()) } @@ -1196,18 +1237,41 @@ pub(super) fn goto_line_number( args: &[Cow<str>], event: PromptEvent, ) -> anyhow::Result<()> { - if event != PromptEvent::Validate { - return Ok(()); + match event { + PromptEvent::Abort => { + if let Some(line_number) = cx.editor.last_line_number { + goto_line_impl(cx.editor, NonZeroUsize::new(line_number)); + let (view, doc) = current!(cx.editor); + view.ensure_cursor_in_view(doc, line_number); + cx.editor.last_line_number = None; + } + return Ok(()); + } + PromptEvent::Validate => { + ensure!(!args.is_empty(), "Line number required"); + cx.editor.last_line_number = None; + } + PromptEvent::Update => { + if args.is_empty() { + if let Some(line_number) = cx.editor.last_line_number { + // When a user hits backspace and there are no numbers left, + // we can bring them back to their original line + goto_line_impl(cx.editor, NonZeroUsize::new(line_number)); + let (view, doc) = current!(cx.editor); + view.ensure_cursor_in_view(doc, line_number); + cx.editor.last_line_number = None; + } + return Ok(()); + } + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + let line = doc.selection(view.id).primary().cursor_line(text); + cx.editor.last_line_number.get_or_insert(line + 1); + } } - - ensure!(!args.is_empty(), "Line number required"); - let line = args[0].parse::<usize>()?; - goto_line_impl(cx.editor, NonZeroUsize::new(line)); - let (view, doc) = current!(cx.editor); - view.ensure_cursor_in_view(doc, line); Ok(()) } @@ -1351,7 +1415,7 @@ fn sort_impl( .map(|(s, fragment)| (s.from(), s.to(), Some(fragment))), ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); Ok(()) @@ -1395,7 +1459,7 @@ fn reflow( (range.from(), range.to(), Some(reflowed_text)) }); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); doc.append_changes_to_history(view.id); view.ensure_cursor_in_view(doc, scrolloff); @@ -2021,7 +2085,7 @@ pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "insert-output", aliases: &[], - doc: "Run shell command, inserting output after each selection.", + doc: "Run shell command, inserting output before each selection.", fun: insert_output, completer: None, }, diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs index f07d4028..118764d9 100644 --- a/helix-term/src/keymap/default.rs +++ b/helix-term/src/keymap/default.rs @@ -59,9 +59,9 @@ pub fn default() -> HashMap<Mode, Keymap> { ":" => command_mode, "i" => insert_mode, - "I" => prepend_to_line, + "I" => insert_at_line_start, "a" => append_mode, - "A" => append_to_line, + "A" => insert_at_line_end, "o" => open_below, "O" => open_above, @@ -144,6 +144,7 @@ pub fn default() -> HashMap<Mode, Keymap> { "<" => unindent, "=" => format_selections, "J" => join_selections, + "A-J" => join_selections_space, "K" => keep_selections, "A-K" => remove_selections, @@ -208,11 +209,11 @@ pub fn default() -> HashMap<Mode, Keymap> { "j" => jumplist_picker, "s" => symbol_picker, "S" => workspace_symbol_picker, - "g" => diagnostics_picker, - "G" => workspace_diagnostics_picker, + "d" => diagnostics_picker, + "D" => workspace_diagnostics_picker, "a" => code_action, "'" => last_picker, - "d" => { "Debug (experimental)" sticky=true + "g" => { "Debug (experimental)" sticky=true "l" => dap_launch, "b" => dap_toggle_breakpoint, "c" => dap_continue, @@ -342,24 +343,27 @@ pub fn default() -> HashMap<Mode, Keymap> { let insert = keymap!({ "Insert mode" "esc" => normal_mode, - "backspace" => delete_char_backward, - "C-h" => delete_char_backward, - "del" => delete_char_forward, - "C-d" => delete_char_forward, - "ret" => insert_newline, - "C-j" => insert_newline, - "tab" => insert_tab, - "C-w" => delete_word_backward, - "A-backspace" => delete_word_backward, - "A-d" => delete_word_forward, - "A-del" => delete_word_forward, "C-s" => commit_undo_checkpoint, + "C-x" => completion, + "C-r" => insert_register, - "C-k" => kill_to_line_end, + "C-w" | "A-backspace" => delete_word_backward, + "A-d" | "A-del" => delete_word_forward, "C-u" => kill_to_line_start, + "C-k" => kill_to_line_end, + "C-h" | "backspace" => delete_char_backward, + "C-d" | "del" => delete_char_forward, + "C-j" | "ret" => insert_newline, + "tab" => insert_tab, - "C-x" => completion, - "C-r" => insert_register, + "up" => move_line_up, + "down" => move_line_down, + "left" => move_char_left, + "right" => move_char_right, + "pageup" => page_up, + "pagedown" => page_down, + "home" => goto_line_start, + "end" => goto_line_end_newline, }); hashmap!( Mode::Normal => Keymap::new(normal), diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 2d7d4f92..7348dcf4 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -1,5 +1,5 @@ use crate::compositor::{Component, Context, Event, EventResult}; -use helix_view::editor::CompleteAction; +use helix_view::{apply_transaction, editor::CompleteAction}; use tui::buffer::Buffer as Surface; use tui::text::Spans; @@ -143,11 +143,11 @@ impl Completion { let (view, doc) = current!(editor); // if more text was entered, remove it - doc.restore(view.id); + doc.restore(view); match event { PromptEvent::Abort => { - doc.restore(view.id); + doc.restore(view); editor.last_completion = None; } PromptEvent::Update => { @@ -164,7 +164,7 @@ impl Completion { // initialize a savepoint doc.savepoint(); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); editor.last_completion = Some(CompleteAction { trigger_offset, @@ -183,7 +183,7 @@ impl Completion { trigger_offset, ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); editor.last_completion = Some(CompleteAction { trigger_offset, @@ -213,7 +213,7 @@ impl Completion { additional_edits.clone(), offset_encoding, // TODO: should probably transcode in Client ); - doc.apply(&transaction, view.id); + apply_transaction(&transaction, doc, view); } } } diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 7cb29c3b..3cd2130a 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -13,9 +13,10 @@ use helix_core::{ movement::Direction, syntax::{self, HighlightEvent}, unicode::width::UnicodeWidthStr, - LineEnding, Position, Range, Selection, Transaction, + visual_coords_at_pos, LineEnding, Position, Range, Selection, Transaction, }; use helix_view::{ + apply_transaction, document::{Mode, SCRATCH_BUFFER_NAME}, editor::{CompleteAction, CursorShapeConfig}, graphics::{Color, CursorKind, Modifier, Rect, Style}, @@ -23,7 +24,7 @@ use helix_view::{ keyboard::{KeyCode, KeyModifiers}, Document, Editor, Theme, View, }; -use std::{borrow::Cow, path::PathBuf}; +use std::{borrow::Cow, cmp::min, path::PathBuf}; use tui::buffer::Buffer as Surface; @@ -33,6 +34,7 @@ use super::statusline; pub struct EditorView { pub keymaps: Keymaps, on_next_key: Option<Box<dyn FnOnce(&mut commands::Context, KeyEvent)>>, + pseudo_pending: Vec<KeyEvent>, last_insert: (commands::MappableCommand, Vec<InsertEvent>), pub(crate) completion: Option<Completion>, spinners: ProgressSpinners, @@ -56,6 +58,7 @@ impl EditorView { Self { keymaps, on_next_key: None, + pseudo_pending: Vec::new(), last_insert: (commands::MappableCommand::normal_mode, Vec::new()), completion: None, spinners: ProgressSpinners::default(), @@ -116,9 +119,19 @@ impl EditorView { if is_focused && editor.config().cursorline { Self::highlight_cursorline(doc, view, surface, theme); } + if is_focused && editor.config().cursorcolumn { + Self::highlight_cursorcolumn(doc, view, surface, theme); + } - let highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); - let highlights = syntax::merge(highlights, Self::doc_diagnostics_highlights(doc, theme)); + let mut highlights = Self::doc_syntax_highlights(doc, view.offset, inner.height, theme); + for diagnostic in Self::doc_diagnostics_highlights(doc, theme) { + // Most of the `diagnostic` Vecs are empty most of the time. Skipping + // a merge for any empty Vec saves a significant amount of work. + if diagnostic.is_empty() { + continue; + } + highlights = Box::new(syntax::merge(highlights, diagnostic)); + } let highlights: Box<dyn Iterator<Item = HighlightEvent>> = if is_focused { Box::new(syntax::merge( highlights, @@ -262,7 +275,7 @@ impl EditorView { pub fn doc_diagnostics_highlights( doc: &Document, theme: &Theme, - ) -> Vec<(usize, std::ops::Range<usize>)> { + ) -> [Vec<(usize, std::ops::Range<usize>)>; 5] { use helix_core::diagnostic::Severity; let get_scope_of = |scope| { theme @@ -283,22 +296,38 @@ impl EditorView { let error = get_scope_of("diagnostic.error"); let r#default = get_scope_of("diagnostic"); // this is a bit redundant but should be fine - doc.diagnostics() - .iter() - .map(|diagnostic| { - let diagnostic_scope = match diagnostic.severity { - Some(Severity::Info) => info, - Some(Severity::Hint) => hint, - Some(Severity::Warning) => warning, - Some(Severity::Error) => error, - _ => r#default, - }; - ( - diagnostic_scope, - diagnostic.range.start..diagnostic.range.end, - ) - }) - .collect() + let mut default_vec: Vec<(usize, std::ops::Range<usize>)> = Vec::new(); + let mut info_vec = Vec::new(); + let mut hint_vec = Vec::new(); + let mut warning_vec = Vec::new(); + let mut error_vec = Vec::new(); + + for diagnostic in doc.diagnostics() { + // Separate diagnostics into different Vecs by severity. + let (vec, scope) = match diagnostic.severity { + Some(Severity::Info) => (&mut info_vec, info), + Some(Severity::Hint) => (&mut hint_vec, hint), + Some(Severity::Warning) => (&mut warning_vec, warning), + Some(Severity::Error) => (&mut error_vec, error), + _ => (&mut default_vec, r#default), + }; + + // If any diagnostic overlaps ranges with the prior diagnostic, + // merge the two together. Otherwise push a new span. + match vec.last_mut() { + Some((_, range)) if diagnostic.range.start <= range.end => { + // This branch merges overlapping diagnostics, assuming that the current + // diagnostic starts on range.start or later. If this assertion fails, + // we will discard some part of `diagnostic`. This implies that + // `doc.diagnostics()` is not sorted by `diagnostic.range`. + debug_assert!(range.start <= diagnostic.range.start); + range.end = diagnostic.range.end.max(range.end) + } + _ => vec.push((scope, diagnostic.range.start..diagnostic.range.end)), + } + } + + [default_vec, info_vec, hint_vec, warning_vec, error_vec] } /// Get highlight spans for selections in a document view. @@ -399,7 +428,7 @@ impl EditorView { let characters = &whitespace.characters; let mut spans = Vec::new(); - let mut visual_x = 0u16; + let mut visual_x = 0usize; let mut line = 0u16; let tab_width = doc.tab_width(); let tab = if whitespace.render.tab() == WhitespaceRenderValue::All { @@ -436,17 +465,22 @@ impl EditorView { return; } - let starting_indent = (offset.col / tab_width) as u16; - // TODO: limit to a max indent level too. It doesn't cause visual artifacts but it would avoid some - // extra loops if the code is deeply nested. - - for i in starting_indent..(indent_level / tab_width as u16) { - surface.set_string( - viewport.x + (i * tab_width as u16) - offset.col as u16, - viewport.y + line, - &indent_guide_char, - indent_guide_style, - ); + let starting_indent = + (offset.col / tab_width) + config.indent_guides.skip_levels as usize; + + // Don't draw indent guides outside of view + let end_indent = min( + indent_level, + // Add tab_width - 1 to round up, since the first visible + // indent might be a bit after offset.col + offset.col + viewport.width as usize + (tab_width - 1), + ) / tab_width; + + for i in starting_indent..end_indent { + let x = (viewport.x as usize + (i * tab_width) - offset.col) as u16; + let y = viewport.y + line; + debug_assert!(surface.in_bounds(x, y)); + surface.set_string(x, y, &indent_guide_char, indent_guide_style); } }; @@ -488,14 +522,14 @@ 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; + let out_of_bounds = offset.col > (visual_x as usize) + || (visual_x as usize) >= viewport.width as usize + offset.col; if LineEnding::from_rope_slice(&grapheme).is_some() { if !out_of_bounds { // we still want to render an empty cell with the style surface.set_string( - viewport.x + visual_x - offset.col as u16, + (viewport.x as usize + visual_x - offset.col) as u16, viewport.y + line, &newline, style.patch(whitespace_style), @@ -543,7 +577,7 @@ impl EditorView { if !out_of_bounds { // if we're offscreen just keep going until we hit a new line surface.set_string( - viewport.x + visual_x - offset.col as u16, + (viewport.x as usize + visual_x - offset.col) as u16, viewport.y + line, display_grapheme, if is_whitespace { @@ -576,7 +610,7 @@ impl EditorView { last_line_indent_level = visual_x; } - visual_x = visual_x.saturating_add(width as u16); + visual_x = visual_x.saturating_add(width); } } } @@ -696,6 +730,7 @@ impl EditorView { let mut offset = 0; let gutter_style = theme.get("ui.gutter"); + let gutter_selected_style = theme.get("ui.gutter.selected"); // avoid lots of small allocations by reusing a text buffer for each line let mut text = String::with_capacity(8); @@ -708,6 +743,12 @@ impl EditorView { let x = viewport.x + offset; let y = viewport.y + i as u16; + let gutter_style = if selected { + gutter_selected_style + } else { + gutter_style + }; + if let Some(style) = gutter(line, selected, &mut text) { surface.set_stringn(x, y, &text, *width, gutter_style.patch(style)); } else { @@ -820,6 +861,53 @@ impl EditorView { } } + /// Apply the highlighting on the columns where a cursor is active + pub fn highlight_cursorcolumn( + doc: &Document, + view: &View, + surface: &mut Surface, + theme: &Theme, + ) { + let text = doc.text().slice(..); + + // Manual fallback behaviour: + // ui.cursorcolumn.{p/s} -> ui.cursorcolumn -> ui.cursorline.{p/s} + let primary_style = theme + .try_get_exact("ui.cursorcolumn.primary") + .or_else(|| theme.try_get_exact("ui.cursorcolumn")) + .unwrap_or_else(|| theme.get("ui.cursorline.primary")); + let secondary_style = theme + .try_get_exact("ui.cursorcolumn.secondary") + .or_else(|| theme.try_get_exact("ui.cursorcolumn")) + .unwrap_or_else(|| theme.get("ui.cursorline.secondary")); + + let inner_area = view.inner_area(); + let offset = view.offset.col; + + let selection = doc.selection(view.id); + let primary = selection.primary(); + for range in selection.iter() { + let is_primary = primary == *range; + + let Position { row: _, col } = + visual_coords_at_pos(text, range.cursor(text), doc.tab_width()); + // if the cursor is horizontally in the view + if col >= offset && inner_area.width > (col - offset) as u16 { + let area = Rect::new( + inner_area.x + (col - offset) as u16, + view.area.y, + 1, + view.area.height, + ); + if is_primary { + surface.set_style(area, primary_style) + } else { + surface.set_style(area, secondary_style) + } + } + } + } + /// Handle events by looking them up in `self.keymaps`. Returns None /// if event was handled (a command was executed or a subkeymap was /// activated). Only KeymapResult::{NotFound, Cancelled} is returned @@ -831,6 +919,7 @@ impl EditorView { event: KeyEvent, ) -> Option<KeymapResult> { let mut last_mode = mode; + self.pseudo_pending.extend(self.keymaps.pending()); let key_result = self.keymaps.get(mode, event); cxt.editor.autoinfo = self.keymaps.sticky().map(|node| node.infobox()); @@ -927,7 +1016,7 @@ impl EditorView { InsertEvent::CompletionApply(compl) => { let (view, doc) = current!(cxt.editor); - doc.restore(view.id); + doc.restore(view); let text = doc.text().slice(..); let cursor = doc.selection(view.id).primary().cursor(text); @@ -941,7 +1030,7 @@ impl EditorView { (shift_position(start), shift_position(end), t) }), ); - doc.apply(&tx, view.id); + apply_transaction(&tx, doc, view); } InsertEvent::TriggerCompletion => { let (_, doc) = current!(cxt.editor); @@ -1005,7 +1094,7 @@ impl EditorView { editor.clear_idle_timer(); // don't retrigger } - pub fn handle_idle_timeout(&mut self, cx: &mut crate::compositor::Context) -> EventResult { + pub fn handle_idle_timeout(&mut self, cx: &mut commands::Context) -> EventResult { if self.completion.is_some() || cx.editor.mode != Mode::Insert || !cx.editor.config().auto_completion @@ -1013,15 +1102,7 @@ impl EditorView { return EventResult::Ignored(None); } - let mut cx = commands::Context { - register: None, - editor: cx.editor, - jobs: cx.jobs, - count: None, - callback: None, - on_next_key_callback: None, - }; - crate::commands::insert::idle_completion(&mut cx); + crate::commands::insert::idle_completion(cx); EventResult::Consumed(None) } @@ -1308,6 +1389,11 @@ impl Component for EditorView { } self.on_next_key = cx.on_next_key_callback.take(); + match self.on_next_key { + Some(_) => self.pseudo_pending.push(key), + None => self.pseudo_pending.clear(), + } + // appease borrowck let callback = cx.callback.take(); @@ -1337,6 +1423,7 @@ impl Component for EditorView { } Event::Mouse(event) => self.handle_mouse_event(event, &mut cx), + Event::IdleTimeout => self.handle_idle_timeout(&mut cx), Event::FocusGained | Event::FocusLost => EventResult::Ignored(None), } } @@ -1408,8 +1495,8 @@ impl Component for EditorView { for key in self.keymaps.pending() { disp.push_str(&key.key_sequence_format()); } - if let Some(pseudo_pending) = &cx.editor.pseudo_pending { - disp.push_str(pseudo_pending.as_str()) + for key in &self.pseudo_pending { + disp.push_str(&key.key_sequence_format()); } let style = cx.editor.theme.get("ui.text"); let macro_width = if cx.editor.macro_recording.is_some() { diff --git a/helix-term/src/ui/fuzzy_match.rs b/helix-term/src/ui/fuzzy_match.rs new file mode 100644 index 00000000..e25d7328 --- /dev/null +++ b/helix-term/src/ui/fuzzy_match.rs @@ -0,0 +1,74 @@ +use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; +use fuzzy_matcher::FuzzyMatcher; + +#[cfg(test)] +mod test; + +pub struct FuzzyQuery { + queries: Vec<String>, +} + +impl FuzzyQuery { + pub fn new(query: &str) -> FuzzyQuery { + let mut saw_backslash = false; + let queries = query + .split(|c| { + saw_backslash = match c { + ' ' if !saw_backslash => return true, + '\\' => true, + _ => false, + }; + false + }) + .filter_map(|query| { + if query.is_empty() { + None + } else { + Some(query.replace("\\ ", " ")) + } + }) + .collect(); + FuzzyQuery { queries } + } + + pub fn fuzzy_match(&self, item: &str, matcher: &Matcher) -> Option<i64> { + // use the rank of the first query for the rank, because merging ranks is not really possible + // this behaviour matches fzf and skim + let score = matcher.fuzzy_match(item, self.queries.get(0)?)?; + if self + .queries + .iter() + .any(|query| matcher.fuzzy_match(item, query).is_none()) + { + return None; + } + Some(score) + } + + pub fn fuzzy_indicies(&self, item: &str, matcher: &Matcher) -> Option<(i64, Vec<usize>)> { + if self.queries.len() == 1 { + return matcher.fuzzy_indices(item, &self.queries[0]); + } + + // use the rank of the first query for the rank, because merging ranks is not really possible + // this behaviour matches fzf and skim + let (score, mut indicies) = matcher.fuzzy_indices(item, self.queries.get(0)?)?; + + // fast path for the common case of not using a space + // during matching this branch should be free thanks to branch prediction + if self.queries.len() == 1 { + return Some((score, indicies)); + } + + for query in &self.queries[1..] { + let (_, matched_indicies) = matcher.fuzzy_indices(item, query)?; + indicies.extend_from_slice(&matched_indicies); + } + + // deadup and remove duplicate matches + indicies.sort_unstable(); + indicies.dedup(); + + Some((score, indicies)) + } +} diff --git a/helix-term/src/ui/fuzzy_match/test.rs b/helix-term/src/ui/fuzzy_match/test.rs new file mode 100644 index 00000000..3f90ef68 --- /dev/null +++ b/helix-term/src/ui/fuzzy_match/test.rs @@ -0,0 +1,47 @@ +use crate::ui::fuzzy_match::FuzzyQuery; +use crate::ui::fuzzy_match::Matcher; + +fn run_test<'a>(query: &str, items: &'a [&'a str]) -> Vec<String> { + let query = FuzzyQuery::new(query); + let matcher = Matcher::default(); + items + .iter() + .filter_map(|item| { + let (_, indicies) = query.fuzzy_indicies(item, &matcher)?; + let matched_string = indicies + .iter() + .map(|&pos| item.chars().nth(pos).unwrap()) + .collect(); + Some(matched_string) + }) + .collect() +} + +#[test] +fn match_single_value() { + let matches = run_test("foo", &["foobar", "foo", "bar"]); + assert_eq!(matches, &["foo", "foo"]) +} + +#[test] +fn match_multiple_values() { + let matches = run_test( + "foo bar", + &["foo bar", "foo bar", "bar foo", "bar", "foo"], + ); + assert_eq!(matches, &["foobar", "foobar", "barfoo"]) +} + +#[test] +fn space_escape() { + let matches = run_test(r"foo\ bar", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["foo bar"]) +} + +#[test] +fn trim() { + let matches = run_test(r" foo bar ", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["barfoo", "foobar", "foobar"]); + let matches = run_test(r" foo bar\ ", &["bar foo", "foo bar", "foobar"]); + assert_eq!(matches, &["bar foo"]) +} diff --git a/helix-term/src/ui/lsp.rs b/helix-term/src/ui/lsp.rs index f2854551..393d24c4 100644 --- a/helix-term/src/ui/lsp.rs +++ b/helix-term/src/ui/lsp.rs @@ -68,8 +68,9 @@ impl Component for SignatureHelp { let (_, sig_text_height) = crate::ui::text::required_size(&sig_text, area.width); let sig_text_area = area.clip_top(1).with_height(sig_text_height); + let sig_text_area = sig_text_area.inner(&margin).intersection(surface.area); let sig_text_para = Paragraph::new(sig_text).wrap(Wrap { trim: false }); - sig_text_para.render(sig_text_area.inner(&margin), surface); + sig_text_para.render(sig_text_area, surface); if self.signature_doc.is_none() { return; diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 1d247b1a..f77f5e80 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -105,7 +105,7 @@ impl<T: Item> Menu<T> { .iter() .enumerate() .filter_map(|(index, option)| { - let text: String = option.filter_text(&self.editor_data).into(); + let text = option.filter_text(&self.editor_data); // TODO: using fuzzy_indices could give us the char idx for match highlighting self.matcher .fuzzy_match(&text, pattern) diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 60ad3b24..6ac4dbb7 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,5 +1,6 @@ mod completion; pub(crate) mod editor; +mod fuzzy_match; mod info; pub mod lsp; mod markdown; @@ -12,6 +13,8 @@ mod spinner; mod statusline; mod text; +use crate::compositor::{Component, Compositor}; +use crate::job; pub use completion::Completion; pub use editor::EditorView; pub use markdown::Markdown; @@ -24,7 +27,7 @@ pub use text::Text; use helix_core::regex::Regex; use helix_core::regex::RegexBuilder; -use helix_view::{Document, Editor, View}; +use helix_view::Editor; use std::path::PathBuf; @@ -59,7 +62,7 @@ pub fn regex_prompt( prompt: std::borrow::Cow<'static, str>, history_register: Option<char>, completion_fn: impl FnMut(&Editor, &str) -> Vec<prompt::Completion> + 'static, - fun: impl Fn(&mut View, &mut Document, Regex, PromptEvent) + 'static, + fun: impl Fn(&mut Editor, Regex, PromptEvent) + 'static, ) { let (view, doc) = current!(cx.editor); let doc_id = view.doc; @@ -106,11 +109,42 @@ pub fn regex_prompt( view.jumps.push((doc_id, snapshot.clone())); } - fun(view, doc, regex, event); + fun(cx.editor, regex, event); + let (view, doc) = current!(cx.editor); view.ensure_cursor_in_view(doc, config.scrolloff); } - Err(_err) => (), // TODO: mark command line as error + Err(err) => { + let (view, doc) = current!(cx.editor); + doc.set_selection(view.id, snapshot.clone()); + view.offset = offset_snapshot; + + if event == PromptEvent::Validate { + let callback = async move { + let call: job::Callback = Box::new( + move |_editor: &mut Editor, compositor: &mut Compositor| { + let contents = Text::new(format!("{}", err)); + let size = compositor.size(); + let mut popup = Popup::new("invalid-regex", contents) + .position(Some(helix_core::Position::new( + size.height as usize - 2, // 2 = statusline + commandline + 0, + ))) + .auto_close(true); + popup.required_size((size.width, size.height)); + + compositor.replace_or_push("invalid-regex", popup); + }, + ); + Ok(call) + }; + + cx.jobs.callback(callback); + } else { + // Update + // TODO: mark command line as error + } + } } } } diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index a56455d7..c7149c61 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,7 +1,7 @@ use crate::{ compositor::{Component, Compositor, Context, Event, EventResult}, ctrl, key, shift, - ui::{self, EditorView}, + ui::{self, fuzzy_match::FuzzyQuery, EditorView}, }; use tui::{ buffer::Buffer as Surface, @@ -9,7 +9,6 @@ use tui::{ }; use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; -use fuzzy_matcher::FuzzyMatcher; use tui::widgets::Widget; use std::time::Instant; @@ -161,6 +160,27 @@ impl<T: Item> FilePicker<T> { self.preview_cache.insert(path.to_owned(), preview); Preview::Cached(&self.preview_cache[path]) } + + fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult { + // Try to find a document in the cache + let doc = self + .current_file(cx.editor) + .and_then(|(path, _range)| self.preview_cache.get_mut(&path)) + .and_then(|cache| match cache { + CachedPreview::Document(doc) => Some(doc), + _ => None, + }); + + // Then attempt to highlight it if it has no language set + if let Some(doc) = doc { + if doc.language_config().is_none() { + let loader = cx.editor.syn_loader.clone(); + doc.detect_language(loader); + } + } + + EventResult::Consumed(None) + } } impl<T: Item + 'static> Component for FilePicker<T> { @@ -261,6 +281,9 @@ impl<T: Item + 'static> Component for FilePicker<T> { } fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult { + if let Event::IdleTimeout = event { + return self.handle_idle_timeout(ctx); + } // TODO: keybinds for scrolling preview self.picker.handle_event(event, ctx) } @@ -287,8 +310,6 @@ pub struct Picker<T: Item> { matcher: Box<Matcher>, /// (index, score) matches: Vec<(usize, i64)>, - /// Filter over original options. - filters: Vec<usize>, // could be optimized into bit but not worth it now /// Current height of the completions box completion_height: u16, @@ -323,7 +344,6 @@ impl<T: Item> Picker<T> { editor_data, matcher: Box::new(Matcher::default()), matches: Vec::new(), - filters: Vec::new(), cursor: 0, prompt, previous_pattern: String::new(), @@ -365,13 +385,14 @@ impl<T: Item> Picker<T> { .map(|(index, _option)| (index, 0)), ); } else if pattern.starts_with(&self.previous_pattern) { + let query = FuzzyQuery::new(pattern); // optimization: if the pattern is a more specific version of the previous one // then we can score the filtered set. self.matches.retain_mut(|(index, score)| { let option = &self.options[*index]; let text = option.sort_text(&self.editor_data); - match self.matcher.fuzzy_match(&text, pattern) { + match query.fuzzy_match(&text, &self.matcher) { Some(s) => { // Update the score *score = s; @@ -384,23 +405,17 @@ impl<T: Item> Picker<T> { self.matches .sort_unstable_by_key(|(_, score)| Reverse(*score)); } else { + let query = FuzzyQuery::new(pattern); self.matches.clear(); self.matches.extend( self.options .iter() .enumerate() .filter_map(|(index, option)| { - // filter options first before matching - if !self.filters.is_empty() { - // TODO: this filters functionality seems inefficient, - // instead store and operate on filters if any - self.filters.binary_search(&index).ok()?; - } - let text = option.filter_text(&self.editor_data); - self.matcher - .fuzzy_match(&text, pattern) + query + .fuzzy_match(&text, &self.matcher) .map(|score| (index, score)) }), ); @@ -460,14 +475,6 @@ impl<T: Item> Picker<T> { .map(|(index, _score)| &self.options[*index]) } - pub fn save_filter(&mut self, cx: &Context) { - self.filters.clear(); - self.filters - .extend(self.matches.iter().map(|(index, _)| *index)); - self.filters.sort_unstable(); // used for binary search later - self.prompt.clear(cx.editor); - } - pub fn toggle_preview(&mut self) { self.show_preview = !self.show_preview; } @@ -505,6 +512,9 @@ impl<T: Item + 'static> Component for Picker<T> { compositor.last_picker = compositor.pop(); }))); + // So that idle timeout retriggers + cx.editor.reset_idle_timer(); + match key_event { shift!(Tab) | key!(Up) | ctrl!('p') => { self.move_by(1, Direction::Backward); @@ -545,9 +555,6 @@ impl<T: Item + 'static> Component for Picker<T> { } return close_fn; } - ctrl!(' ') => { - self.save_filter(cx); - } ctrl!('t') => { self.toggle_preview(); } @@ -630,9 +637,8 @@ impl<T: Item + 'static> Component for Picker<T> { } let spans = option.label(&self.editor_data); - let (_score, highlights) = self - .matcher - .fuzzy_indices(&String::from(&spans), self.prompt.line()) + let (_score, highlights) = FuzzyQuery::new(self.prompt.line()) + .fuzzy_indicies(&String::from(&spans), &self.matcher) .unwrap_or_default(); spans.0.into_iter().fold(inner, |pos, span| { diff --git a/helix-term/src/ui/statusline.rs b/helix-term/src/ui/statusline.rs index 365e1ca9..b0e8ec5d 100644 --- a/helix-term/src/ui/statusline.rs +++ b/helix-term/src/ui/statusline.rs @@ -144,6 +144,7 @@ where helix_view::editor::StatusLineElement::Selections => render_selections, helix_view::editor::StatusLineElement::Position => render_position, helix_view::editor::StatusLineElement::PositionPercentage => render_position_percentage, + helix_view::editor::StatusLineElement::TotalLineNumbers => render_total_line_numbers, helix_view::editor::StatusLineElement::Separator => render_separator, helix_view::editor::StatusLineElement::Spacer => render_spacer, } @@ -154,16 +155,16 @@ where F: Fn(&mut RenderContext, String, Option<Style>) + Copy, { let visible = context.focused; - + let modenames = &context.editor.config().statusline.mode; write( context, format!( " {} ", if visible { match context.editor.mode() { - Mode::Insert => "INS", - Mode::Select => "SEL", - Mode::Normal => "NOR", + Mode::Insert => &modenames.insert, + Mode::Select => &modenames.select, + Mode::Normal => &modenames.normal, } } else { // If not focused, explicitly leave an empty space instead of returning None. @@ -276,6 +277,15 @@ where ); } +fn render_total_line_numbers<F>(context: &mut RenderContext, write: F) +where + F: Fn(&mut RenderContext, String, Option<Style>) + Copy, +{ + let total_line_numbers = context.doc.text().len_lines(); + + write(context, format!(" {} ", total_line_numbers), None); +} + fn render_position_percentage<F>(context: &mut RenderContext, write: F) where F: Fn(&mut RenderContext, String, Option<Style>) + Copy, |