summaryrefslogtreecommitdiff
path: root/helix-term
diff options
context:
space:
mode:
Diffstat (limited to 'helix-term')
-rw-r--r--helix-term/Cargo.toml18
-rw-r--r--helix-term/src/application.rs26
-rw-r--r--helix-term/src/args.rs2
-rw-r--r--helix-term/src/commands.rs630
-rw-r--r--helix-term/src/compositor.rs2
-rw-r--r--helix-term/src/keymap.rs228
-rw-r--r--helix-term/src/main.rs8
-rw-r--r--helix-term/src/ui/completion.rs134
-rw-r--r--helix-term/src/ui/editor.rs134
-rw-r--r--helix-term/src/ui/menu.rs43
-rw-r--r--helix-term/src/ui/mod.rs23
-rw-r--r--helix-term/src/ui/picker.rs157
-rw-r--r--helix-term/src/ui/prompt.rs37
13 files changed, 974 insertions, 468 deletions
diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml
index 68ff260d..43268291 100644
--- a/helix-term/Cargo.toml
+++ b/helix-term/Cargo.toml
@@ -1,9 +1,9 @@
[package]
name = "helix-term"
-version = "0.4.1"
+version = "0.5.0"
description = "A post-modern text editor."
authors = ["Blaž Hrastnik <blaz@mxxn.io>"]
-edition = "2018"
+edition = "2021"
license = "MPL-2.0"
categories = ["editor", "command-line-utilities"]
repository = "https://github.com/helix-editor/helix"
@@ -21,10 +21,10 @@ name = "hx"
path = "src/main.rs"
[dependencies]
-helix-core = { version = "0.4", path = "../helix-core" }
-helix-view = { version = "0.4", path = "../helix-view" }
-helix-lsp = { version = "0.4", path = "../helix-lsp" }
-helix-dap = { version = "0.4", path = "../helix-dap" }
+helix-core = { version = "0.5", path = "../helix-core" }
+helix-view = { version = "0.5", path = "../helix-view" }
+helix-lsp = { version = "0.5", path = "../helix-lsp" }
+helix-dap = { version = "0.5", path = "../helix-dap" }
anyhow = "1"
once_cell = "1.8"
@@ -32,7 +32,7 @@ once_cell = "1.8"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "io-util", "io-std", "time", "process", "macros", "fs", "parking_lot"] }
num_cpus = "1"
tui = { path = "../helix-tui", package = "helix-tui", default-features = false, features = ["crossterm"] }
-crossterm = { version = "0.21", features = ["event-stream"] }
+crossterm = { version = "0.22", features = ["event-stream"] }
signal-hook = "0.3"
tokio-stream = "0.1"
futures-util = { version = "0.3", features = ["std", "async-await"], default-features = false }
@@ -45,10 +45,10 @@ log = "0.4"
# File picker
fuzzy-matcher = "0.3"
ignore = "0.4"
-# shellexpand = "2.1"
-# dirs-next = "2.0"
# markdown doc rendering
pulldown-cmark = { version = "0.8", default-features = false }
+# file type detection
+content_inspector = "0.2.4"
# config
toml = "0.5"
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs
index 27062a36..0fb4e479 100644
--- a/helix-term/src/application.rs
+++ b/helix-term/src/application.rs
@@ -99,12 +99,17 @@ impl Application {
let editor_view = Box::new(ui::EditorView::new(std::mem::take(&mut config.keys)));
compositor.push(editor_view);
- if !args.files.is_empty() {
+ if args.load_tutor {
+ let path = helix_core::runtime_dir().join("tutor.txt");
+ editor.open(path, Action::VerticalSplit)?;
+ // Unset path to prevent accidentally saving to the original tutor file.
+ doc_mut!(editor).set_path(None)?;
+ } else if !args.files.is_empty() {
let first = &args.files[0]; // we know it's not empty
if first.is_dir() {
std::env::set_current_dir(&first)?;
editor.new_file(Action::VerticalSplit);
- compositor.push(Box::new(ui::file_picker(first.clone())));
+ compositor.push(Box::new(ui::file_picker(".".into())));
} else {
let nr_of_files = args.files.len();
editor.open(first.to_path_buf(), Action::VerticalSplit)?;
@@ -240,7 +245,7 @@ impl Application {
}
pub fn handle_idle_timeout(&mut self) {
- use crate::commands::{completion, Context};
+ use crate::commands::{insert::idle_completion, Context};
use helix_view::document::Mode;
if doc_mut!(self.editor).mode != Mode::Insert || !self.config.editor.auto_completion {
@@ -267,7 +272,7 @@ impl Application {
callback: None,
on_next_key_callback: None,
};
- completion(&mut cx);
+ idle_completion(&mut cx);
self.render();
}
@@ -548,10 +553,11 @@ impl Application {
message: diagnostic.message,
severity: diagnostic.severity.map(
|severity| match severity {
- DiagnosticSeverity::Error => Error,
- DiagnosticSeverity::Warning => Warning,
- DiagnosticSeverity::Information => Info,
- DiagnosticSeverity::Hint => Hint,
+ DiagnosticSeverity::ERROR => Error,
+ DiagnosticSeverity::WARNING => Warning,
+ DiagnosticSeverity::INFORMATION => Info,
+ DiagnosticSeverity::HINT => Hint,
+ severity => unimplemented!("{:?}", severity),
},
),
// code
@@ -727,7 +733,9 @@ impl Application {
let mut stdout = stdout();
// reset cursor shape
write!(stdout, "\x1B[2 q")?;
- execute!(stdout, DisableMouseCapture)?;
+ // Ignore errors on disabling, this might trigger on windows if we call
+ // disable without calling enable previously
+ let _ = execute!(stdout, DisableMouseCapture);
execute!(stdout, terminal::LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
diff --git a/helix-term/src/args.rs b/helix-term/src/args.rs
index f0ef09eb..40113db9 100644
--- a/helix-term/src/args.rs
+++ b/helix-term/src/args.rs
@@ -5,6 +5,7 @@ use std::path::PathBuf;
pub struct Args {
pub display_help: bool,
pub display_version: bool,
+ pub load_tutor: bool,
pub verbosity: u64,
pub files: Vec<PathBuf>,
}
@@ -22,6 +23,7 @@ impl Args {
"--" => break, // stop parsing at this point treat the remaining as files
"--version" => args.display_version = true,
"--help" => args.display_help = true,
+ "--tutor" => args.load_tutor = true,
arg if arg.starts_with("--") => {
return Err(Error::msg(format!(
"unexpected double dash argument: {}",
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index f3761d7d..3616d6a8 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -16,8 +16,13 @@ use helix_core::{
};
use helix_view::{
- clipboard::ClipboardType, document::Mode, editor::Action, input::KeyEvent, keyboard::KeyCode,
- view::View, Document, DocumentId, Editor, ViewId,
+ clipboard::ClipboardType,
+ document::Mode,
+ editor::{Action, Motion},
+ input::KeyEvent,
+ keyboard::KeyCode,
+ view::View,
+ Document, DocumentId, Editor, ViewId,
};
use anyhow::{anyhow, bail, Context as _};
@@ -202,6 +207,7 @@ impl Command {
find_prev_char, "Move to previous occurance of char",
extend_till_prev_char, "Extend till previous occurance of char",
extend_prev_char, "Extend to previous occurance of char",
+ repeat_last_motion, "repeat last motion(extend_next_char, extend_till_char, find_next_char, find_till_char...)",
replace, "Replace with new char",
switch_case, "Switch (toggle) case",
switch_to_uppercase, "Switch to uppercase",
@@ -215,8 +221,11 @@ impl Command {
split_selection, "Split selection into subselections on regex matches",
split_selection_on_newline, "Split selection on newlines",
search, "Search for regex pattern",
+ rsearch, "Reverse search for regex pattern",
search_next, "Select next search match",
+ search_prev, "Select previous search match",
extend_search_next, "Add next search match to selection",
+ extend_search_prev, "Add previous search match to selection",
search_selection, "Use current selection as search pattern",
global_search, "Global Search in workspace folder",
extend_line, "Select current line, if already selected, extend to next line",
@@ -260,6 +269,8 @@ impl Command {
goto_prev_diag, "Goto previous diagnostic",
goto_line_start, "Goto line start",
goto_line_end, "Goto line end",
+ goto_next_buffer, "Goto next buffer",
+ goto_previous_buffer, "Goto previous buffer",
// TODO: different description ?
goto_line_end_newline, "Goto line end",
goto_first_nonwhitespace, "Goto first non-blank in line",
@@ -305,6 +316,10 @@ impl Command {
expand_selection, "Expand selection to parent syntax node",
jump_forward, "Jump forward on jumplist",
jump_backward, "Jump backward on jumplist",
+ jump_view_right, "Jump to the split to the right",
+ jump_view_left, "Jump to the split to the left",
+ jump_view_up, "Jump to the split above",
+ jump_view_down, "Jump to the split below",
rotate_view, "Goto next window",
hsplit, "Horizontal bottom split",
vsplit, "Vertical right split",
@@ -528,6 +543,39 @@ fn goto_line_start(cx: &mut Context) {
)
}
+fn goto_next_buffer(cx: &mut Context) {
+ goto_buffer(cx, Direction::Forward);
+}
+
+fn goto_previous_buffer(cx: &mut Context) {
+ goto_buffer(cx, Direction::Backward);
+}
+
+fn goto_buffer(cx: &mut Context, direction: Direction) {
+ let current = view!(cx.editor).doc;
+
+ let id = match direction {
+ Direction::Forward => {
+ let iter = cx.editor.documents.keys();
+ let mut iter = iter.skip_while(|id| *id != &current);
+ iter.next(); // skip current item
+ iter.next().or_else(|| cx.editor.documents.keys().next())
+ }
+ Direction::Backward => {
+ let iter = cx.editor.documents.keys();
+ let mut iter = iter.rev().skip_while(|id| *id != &current);
+ iter.next(); // skip current item
+ iter.next()
+ .or_else(|| cx.editor.documents.keys().rev().next())
+ }
+ }
+ .unwrap();
+
+ let id = *id;
+
+ cx.editor.switch(id, Action::Replace);
+}
+
fn extend_to_line_start(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
goto_line_start_impl(view, doc, Movement::Extend)
@@ -631,14 +679,25 @@ fn goto_file_start(cx: &mut Context) {
} else {
push_jump(cx.editor);
let (view, doc) = current!(cx.editor);
- doc.set_selection(view.id, Selection::point(0));
+ let text = doc.text().slice(..);
+ let selection = doc
+ .selection(view.id)
+ .clone()
+ .transform(|range| range.put_cursor(text, 0, doc.mode == Mode::Select));
+ doc.set_selection(view.id, selection);
}
}
fn goto_file_end(cx: &mut Context) {
push_jump(cx.editor);
let (view, doc) = current!(cx.editor);
- doc.set_selection(view.id, Selection::point(doc.text().len_chars()));
+ let text = doc.text().slice(..);
+ let pos = doc.text().len_chars();
+ let selection = doc
+ .selection(view.id)
+ .clone()
+ .transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select));
+ doc.set_selection(view.id, selection);
}
fn extend_word_impl<F>(cx: &mut Context, extend_fn: F)
@@ -681,8 +740,7 @@ fn extend_next_long_word_end(cx: &mut Context) {
extend_word_impl(cx, movement::move_next_long_word_end)
}
-#[inline]
-fn find_char_impl<F>(cx: &mut Context, search_fn: F, inclusive: bool, extend: bool)
+fn will_find_char<F>(cx: &mut Context, search_fn: F, inclusive: bool, extend: bool)
where
F: Fn(RopeSlice, char, usize, usize, bool) -> Option<usize> + 'static,
{
@@ -704,13 +762,7 @@ where
// usually mix line endings. But we should fix it eventually
// anyway.
{
- current!(cx.editor)
- .1
- .line_ending
- .as_str()
- .chars()
- .next()
- .unwrap()
+ doc!(cx.editor).line_ending.as_str().chars().next().unwrap()
}
KeyEvent {
@@ -720,29 +772,48 @@ where
_ => return,
};
- let (view, doc) = current!(cx.editor);
- let text = doc.text().slice(..);
+ find_char_impl(cx.editor, &search_fn, inclusive, extend, ch, count);
+ cx.editor.last_motion = Some(Motion(Box::new(move |editor: &mut Editor| {
+ find_char_impl(editor, &search_fn, inclusive, true, ch, 1);
+ })));
+ })
+}
- let selection = doc.selection(view.id).clone().transform(|range| {
- // TODO: use `Range::cursor()` here instead. However, that works in terms of
- // graphemes, whereas this function doesn't yet. So we're doing the same logic
- // here, but just in terms of chars instead.
- let search_start_pos = if range.anchor < range.head {
- range.head - 1
- } else {
- range.head
- };
+//
- search_fn(text, ch, search_start_pos, count, inclusive).map_or(range, |pos| {
- if extend {
- range.put_cursor(text, pos, true)
- } else {
- Range::point(range.cursor(text)).put_cursor(text, pos, true)
- }
- })
- });
- doc.set_selection(view.id, selection);
- })
+#[inline]
+fn find_char_impl<F>(
+ editor: &mut Editor,
+ search_fn: &F,
+ inclusive: bool,
+ extend: bool,
+ ch: char,
+ count: usize,
+) where
+ F: Fn(RopeSlice, char, usize, usize, bool) -> Option<usize> + 'static,
+{
+ let (view, doc) = current!(editor);
+ let text = doc.text().slice(..);
+
+ let selection = doc.selection(view.id).clone().transform(|range| {
+ // TODO: use `Range::cursor()` here instead. However, that works in terms of
+ // graphemes, whereas this function doesn't yet. So we're doing the same logic
+ // here, but just in terms of chars instead.
+ let search_start_pos = if range.anchor < range.head {
+ range.head - 1
+ } else {
+ range.head
+ };
+
+ search_fn(text, ch, search_start_pos, count, inclusive).map_or(range, |pos| {
+ if extend {
+ range.put_cursor(text, pos, true)
+ } else {
+ Range::point(range.cursor(text)).put_cursor(text, pos, true)
+ }
+ })
+ });
+ doc.set_selection(view.id, selection);
}
fn find_next_char_impl(
@@ -756,6 +827,10 @@ fn find_next_char_impl(
if inclusive {
search::find_nth_next(text, ch, pos, n)
} else {
+ let n = match text.get_char(pos) {
+ Some(next_ch) if next_ch == ch => n + 1,
+ _ => n,
+ };
search::find_nth_next(text, ch, pos, n).map(|n| n.saturating_sub(1))
}
}
@@ -770,80 +845,52 @@ fn find_prev_char_impl(
if inclusive {
search::find_nth_prev(text, ch, pos, n)
} else {
+ let n = match text.get_char(pos.saturating_sub(1)) {
+ Some(next_ch) if next_ch == ch => n + 1,
+ _ => n,
+ };
search::find_nth_prev(text, ch, pos, n).map(|n| (n + 1).min(text.len_chars()))
}
}
fn find_till_char(cx: &mut Context) {
- find_char_impl(
- cx,
- find_next_char_impl,
- false, /* inclusive */
- false, /* extend */
- )
+ will_find_char(cx, find_next_char_impl, false, false)
}
fn find_next_char(cx: &mut Context) {
- find_char_impl(
- cx,
- find_next_char_impl,
- true, /* inclusive */
- false, /* extend */
- )
+ will_find_char(cx, find_next_char_impl, true, false)
}
fn extend_till_char(cx: &mut Context) {
- find_char_impl(
- cx,
- find_next_char_impl,
- false, /* inclusive */
- true, /* extend */
- )
+ will_find_char(cx, find_next_char_impl, false, true)
}
fn extend_next_char(cx: &mut Context) {
- find_char_impl(
- cx,
- find_next_char_impl,
- true, /* inclusive */
- true, /* extend */
- )
+ will_find_char(cx, find_next_char_impl, true, true)
}
fn till_prev_char(cx: &mut Context) {
- find_char_impl(
- cx,
- find_prev_char_impl,
- false, /* inclusive */
- false, /* extend */
- )
+ will_find_char(cx, find_prev_char_impl, false, false)
}
fn find_prev_char(cx: &mut Context) {
- find_char_impl(
- cx,
- find_prev_char_impl,
- true, /* inclusive */
- false, /* extend */
- )
+ will_find_char(cx, find_prev_char_impl, true, false)
}
fn extend_till_prev_char(cx: &mut Context) {
- find_char_impl(
- cx,
- find_prev_char_impl,
- false, /* inclusive */
- true, /* extend */
- )
+ will_find_char(cx, find_prev_char_impl, false, true)
}
fn extend_prev_char(cx: &mut Context) {
- find_char_impl(
- cx,
- find_prev_char_impl,
- true, /* inclusive */
- true, /* extend */
- )
+ will_find_char(cx, find_prev_char_impl, true, true)
+}
+
+fn repeat_last_motion(cx: &mut Context) {
+ let last_motion = cx.editor.last_motion.take();
+ if let Some(m) = &last_motion {
+ m.run(cx.editor);
+ cx.editor.last_motion = last_motion;
+ }
}
fn replace(cx: &mut Context) {
@@ -1091,6 +1138,7 @@ fn select_regex(cx: &mut Context) {
cx,
"select:".into(),
Some(reg),
+ |_input: &str| Vec::new(),
move |view, doc, regex, event| {
if event != PromptEvent::Update {
return;
@@ -1113,6 +1161,7 @@ fn split_selection(cx: &mut Context) {
cx,
"split:".into(),
Some(reg),
+ |_input: &str| Vec::new(),
move |view, doc, regex, event| {
if event != PromptEvent::Update {
return;
@@ -1137,35 +1186,68 @@ fn split_selection_on_newline(cx: &mut Context) {
doc.set_selection(view.id, selection);
}
-fn search_impl(doc: &mut Document, view: &mut View, contents: &str, regex: &Regex, extend: bool) {
+fn search_impl(
+ doc: &mut Document,
+ view: &mut View,
+ contents: &str,
+ regex: &Regex,
+ movement: Movement,
+ direction: Direction,
+) {
let text = doc.text().slice(..);
let selection = doc.selection(view.id);
- // Get the right side of the primary block cursor.
- let start = text.char_to_byte(graphemes::next_grapheme_boundary(
- text,
- selection.primary().cursor(text),
- ));
+ // Get the right side of the primary block cursor for forward search, or the
+ //grapheme before the start of the selection for reverse search.
+ let start = match direction {
+ Direction::Forward => text.char_to_byte(graphemes::next_grapheme_boundary(
+ text,
+ selection.primary().to(),
+ )),
+ Direction::Backward => text.char_to_byte(graphemes::prev_grapheme_boundary(
+ text,
+ selection.primary().from(),
+ )),
+ };
+
+ //A regex::Match returns byte-positions in the str. In the case where we
+ //do a reverse search and wraparound to the end, we don't need to search
+ //the text before the current cursor position for matches, but by slicing
+ //it out, we need to add it back to the position of the selection.
+ let mut offset = 0;
// use find_at to find the next match after the cursor, loop around the end
// Careful, `Regex` uses `bytes` as offsets, not character indices!
- let mat = regex
- .find_at(contents, start)
- .or_else(|| regex.find(contents));
+ let mat = match direction {
+ Direction::Forward => regex
+ .find_at(contents, start)
+ .or_else(|| regex.find(contents)),
+ Direction::Backward => regex.find_iter(&contents[..start]).last().or_else(|| {
+ offset = start;
+ regex.find_iter(&contents[start..]).last()
+ }),
+ };
// TODO: message on wraparound
if let Some(mat) = mat {
- let start = text.byte_to_char(mat.start());
- let end = text.byte_to_char(mat.end());
+ let start = text.byte_to_char(mat.start() + offset);
+ let end = text.byte_to_char(mat.end() + offset);
if end == 0 {
// skip empty matches that don't make sense
return;
}
- let selection = if extend {
- selection.clone().push(Range::new(start, end))
+ // Determine range direction based on the primary range
+ let primary = selection.primary();
+ let range = if primary.head < primary.anchor {
+ Range::new(end, start)
} else {
- Selection::single(start, end)
+ Range::new(start, end)
+ };
+
+ let selection = match movement {
+ Movement::Extend => selection.clone().push(range),
+ Movement::Move => selection.clone().replace(selection.primary_index(), range),
};
doc.set_selection(view.id, selection);
@@ -1173,8 +1255,25 @@ fn search_impl(doc: &mut Document, view: &mut View, contents: &str, regex: &Rege
};
}
+fn search_completions(cx: &mut Context, reg: Option<char>) -> Vec<String> {
+ let mut items = reg
+ .and_then(|reg| cx.editor.registers.get(reg))
+ .map_or(Vec::new(), |reg| reg.read().iter().take(200).collect());
+ items.sort_unstable();
+ items.dedup();
+ items.into_iter().cloned().collect()
+}
+
// TODO: use one function for search vs extend
fn search(cx: &mut Context) {
+ searcher(cx, Direction::Forward)
+}
+
+fn rsearch(cx: &mut Context) {
+ searcher(cx, Direction::Backward)
+}
+// TODO: use one function for search vs extend
+fn searcher(cx: &mut Context, direction: Direction) {
let reg = cx.register.unwrap_or('/');
let (_, doc) = current!(cx.editor);
@@ -1183,23 +1282,31 @@ fn search(cx: &mut Context) {
// HAXX: sadly we can't avoid allocating a single string for the whole buffer since we can't
// feed chunks into the regex yet
let contents = doc.text().slice(..).to_string();
+ let completions = search_completions(cx, Some(reg));
let prompt = ui::regex_prompt(
cx,
"search:".into(),
Some(reg),
+ move |input: &str| {
+ completions
+ .iter()
+ .filter(|comp| comp.starts_with(input))
+ .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone())))
+ .collect()
+ },
move |view, doc, regex, event| {
if event != PromptEvent::Update {
return;
}
- search_impl(doc, view, &contents, &regex, false);
+ search_impl(doc, view, &contents, &regex, Movement::Move, direction);
},
);
cx.push_layer(Box::new(prompt));
}
-fn search_next_impl(cx: &mut Context, extend: bool) {
+fn search_next_or_prev_impl(cx: &mut Context, movement: Movement, direction: Direction) {
let (view, doc) = current!(cx.editor);
let registers = &cx.editor.registers;
if let Some(query) = registers.read('/') {
@@ -1214,7 +1321,7 @@ fn search_next_impl(cx: &mut Context, extend: bool) {
.case_insensitive(case_insensitive)
.build()
{
- search_impl(doc, view, &contents, &regex, extend);
+ search_impl(doc, view, &contents, &regex, movement, direction);
} else {
// get around warning `mutable_borrow_reservation_conflict`
// which will be a hard error in the future
@@ -1226,11 +1333,18 @@ fn search_next_impl(cx: &mut Context, extend: bool) {
}
fn search_next(cx: &mut Context) {
- search_next_impl(cx, false);
+ search_next_or_prev_impl(cx, Movement::Move, Direction::Forward);
}
+fn search_prev(cx: &mut Context) {
+ search_next_or_prev_impl(cx, Movement::Move, Direction::Backward);
+}
fn extend_search_next(cx: &mut Context) {
- search_next_impl(cx, true);
+ search_next_or_prev_impl(cx, Movement::Extend, Direction::Forward);
+}
+
+fn extend_search_prev(cx: &mut Context) {
+ search_next_or_prev_impl(cx, Movement::Extend, Direction::Backward);
}
fn search_selection(cx: &mut Context) {
@@ -1247,10 +1361,19 @@ fn global_search(cx: &mut Context) {
let (all_matches_sx, all_matches_rx) =
tokio::sync::mpsc::unbounded_channel::<(usize, PathBuf)>();
let smart_case = cx.editor.config.smart_case;
+
+ let completions = search_completions(cx, None);
let prompt = ui::regex_prompt(
cx,
"global search:".into(),
None,
+ move |input: &str| {
+ completions
+ .iter()
+ .filter(|comp| comp.starts_with(input))
+ .map(|comp| (0.., std::borrow::Cow::Owned(comp.clone())))
+ .collect()
+ },
move |_view, _doc, regex, event| {
if event != PromptEvent::Validate {
return;
@@ -1572,7 +1695,8 @@ mod cmd {
let (_, doc) = current!(cx.editor);
if let Some(path) = path {
- doc.set_path(path.as_ref()).context("invalid filepath")?;
+ doc.set_path(Some(path.as_ref()))
+ .context("invalid filepath")?;
}
if doc.path().is_none() {
bail!("cannot write a buffer without a filename");
@@ -1635,7 +1759,7 @@ mod cmd {
// If no argument, report current indent style.
if args.is_empty() {
- let style = current!(cx.editor).1.indent_style;
+ let style = doc!(cx.editor).indent_style;
cx.editor.set_status(match style {
Tabs => "tabs".into(),
Spaces(1) => "1 space".into(),
@@ -1674,7 +1798,7 @@ mod cmd {
// If no argument, report current line ending setting.
if args.is_empty() {
- let line_ending = current!(cx.editor).1.line_ending;
+ let line_ending = doc!(cx.editor).line_ending;
cx.editor.set_status(match line_ending {
Crlf => "crlf".into(),
LF => "line feed".into(),
@@ -1790,7 +1914,7 @@ mod cmd {
let mut errors = String::new();
// save all documents
- for (_, doc) in &mut cx.editor.documents {
+ for doc in &mut cx.editor.documents.values_mut() {
if doc.path().is_none() {
errors.push_str("cannot write a buffer without a filename\n");
continue;
@@ -2085,8 +2209,7 @@ mod cmd {
args: &[&str],
_event: PromptEvent,
) -> anyhow::Result<()> {
- let (_, doc) = current!(cx.editor);
- let id = doc.id();
+ let id = view!(cx.editor).doc;
if let Some(path) = args.get(0) {
cx.editor.open(path.into(), Action::VerticalSplit)?;
@@ -2102,8 +2225,7 @@ mod cmd {
args: &[&str],
_event: PromptEvent,
) -> anyhow::Result<()> {
- let (_, doc) = current!(cx.editor);
- let id = doc.id();
+ let id = view!(cx.editor).doc;
if let Some(path) = args.get(0) {
cx.editor.open(path.into(), Action::HorizontalSplit)?;
@@ -2188,6 +2310,18 @@ mod cmd {
Ok(())
}
+ fn tutor(
+ cx: &mut compositor::Context,
+ _args: &[&str],
+ _event: PromptEvent,
+ ) -> anyhow::Result<()> {
+ let path = helix_core::runtime_dir().join("tutor.txt");
+ cx.editor.open(path, Action::Replace)?;
+ // Unset path to prevent accidentally saving to the original tutor file.
+ doc_mut!(cx.editor).set_path(None)?;
+ Ok(())
+ }
+
pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[
TypableCommand {
name: "quit",
@@ -2199,7 +2333,7 @@ mod cmd {
TypableCommand {
name: "quit!",
aliases: &["q!"],
- doc: "Close the current view.",
+ doc: "Close the current view forcefully (ignoring unsaved changes).",
fun: force_quit,
completer: None,
},
@@ -2262,35 +2396,35 @@ mod cmd {
TypableCommand {
name: "write-quit",
aliases: &["wq", "x"],
- doc: "Writes changes to disk and closes the current view. Accepts an optional path (:wq some/path.txt)",
+ doc: "Write changes to disk and close the current view. Accepts an optional path (:wq some/path.txt)",
fun: write_quit,
completer: Some(completers::filename),
},
TypableCommand {
name: "write-quit!",
aliases: &["wq!", "x!"],
- doc: "Writes changes to disk and closes the current view forcefully. Accepts an optional path (:wq! some/path.txt)",
+ doc: "Write changes to disk and close the current view forcefully. Accepts an optional path (:wq! some/path.txt)",
fun: force_write_quit,
completer: Some(completers::filename),
},
TypableCommand {
name: "write-all",
aliases: &["wa"],
- doc: "Writes changes from all views to disk.",
+ doc: "Write changes from all views to disk.",
fun: write_all,
completer: None,
},
TypableCommand {
name: "write-quit-all",
aliases: &["wqa", "xa"],
- doc: "Writes changes from all views to disk and close all views.",
+ doc: "Write changes from all views to disk and close all views.",
fun: write_all_quit,
completer: None,
},
TypableCommand {
name: "write-quit-all!",
aliases: &["wqa!", "xa!"],
- doc: "Writes changes from all views to disk and close all views forcefully (ignoring unsaved changes).",
+ doc: "Write changes from all views to disk and close all views forcefully (ignoring unsaved changes).",
fun: force_write_all_quit,
completer: None,
},
@@ -2461,7 +2595,14 @@ mod cmd {
doc: "Open the file in a horizontal split.",
fun: hsplit,
completer: Some(completers::filename),
- }
+ },
+ TypableCommand {
+ name: "tutor",
+ aliases: &[],
+ doc: "Open the tutorial.",
+ fun: tutor,
+ completer: None,
+ },
];
pub static COMMANDS: Lazy<HashMap<&'static str, &'static TypableCommand>> = Lazy::new(|| {
@@ -2561,7 +2702,7 @@ fn buffer_picker(cx: &mut Context) {
cx.editor
.documents
.iter()
- .map(|(id, doc)| (id, doc.path().cloned()))
+ .map(|(id, doc)| (*id, doc.path().cloned()))
.collect(),
move |(id, path): &(DocumentId, Option<PathBuf>)| {
let path = path.as_deref().map(helix_core::path::get_relative_path);
@@ -2580,7 +2721,7 @@ fn buffer_picker(cx: &mut Context) {
editor.switch(*id, Action::Replace);
},
|editor, (id, path)| {
- let doc = &editor.documents.get(*id)?;
+ let doc = &editor.documents.get(id)?;
let &view_id = doc.selections().keys().next()?;
let line = doc
.selection(view_id)
@@ -2996,8 +3137,13 @@ fn goto_line(cx: &mut Context) {
doc.text().len_lines() - 1
};
let line_idx = std::cmp::min(count.get() - 1, max_line);
+ let text = doc.text().slice(..);
let pos = doc.text().line_to_char(line_idx);
- doc.set_selection(view.id, Selection::point(pos));
+ let selection = doc
+ .selection(view.id)
+ .clone()
+ .transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select));
+ doc.set_selection(view.id, selection);
}
}
@@ -3011,8 +3157,13 @@ fn goto_last_line(cx: &mut Context) {
} else {
doc.text().len_lines() - 1
};
+ let text = doc.text().slice(..);
let pos = doc.text().line_to_char(line_idx);
- doc.set_selection(view.id, Selection::point(pos));
+ let selection = doc
+ .selection(view.id)
+ .clone()
+ .transform(|range| range.put_cursor(text, pos, doc.mode == Mode::Select));
+ doc.set_selection(view.id, selection);
}
fn goto_last_accessed_file(cx: &mut Context) {
@@ -3306,26 +3457,24 @@ fn goto_first_diag(cx: &mut Context) {
let editor = &mut cx.editor;
let (_, doc) = current!(editor);
- let diag = if let Some(diag) = doc.diagnostics().first() {
- diag.range.start
- } else {
- return;
+ let pos = match doc.diagnostics().first() {
+ Some(diag) => diag.range.start,
+ None => return,
};
- goto_pos(editor, diag);
+ goto_pos(editor, pos);
}
fn goto_last_diag(cx: &mut Context) {
let editor = &mut cx.editor;
let (_, doc) = current!(editor);
- let diag = if let Some(diag) = doc.diagnostics().last() {
- diag.range.start
- } else {
- return;
+ let pos = match doc.diagnostics().last() {
+ Some(diag) => diag.range.start,
+ None => return,
};
- goto_pos(editor, diag);
+ goto_pos(editor, pos);
}
fn goto_next_diag(cx: &mut Context) {
@@ -3336,20 +3485,19 @@ fn goto_next_diag(cx: &mut Context) {
.selection(view.id)
.primary()
.cursor(doc.text().slice(..));
- let diag = if let Some(diag) = doc
+
+ let diag = doc
.diagnostics()
.iter()
- .map(|diag| diag.range.start)
- .find(|&pos| pos > cursor_pos)
- {
- diag
- } else if let Some(diag) = doc.diagnostics().first() {
- diag.range.start
- } else {
- return;
+ .find(|diag| diag.range.start > cursor_pos)
+ .or_else(|| doc.diagnostics().first());
+
+ let pos = match diag {
+ Some(diag) => diag.range.start,
+ None => return,
};
- goto_pos(editor, diag);
+ goto_pos(editor, pos);
}
fn goto_prev_diag(cx: &mut Context) {
@@ -3360,21 +3508,20 @@ fn goto_prev_diag(cx: &mut Context) {
.selection(view.id)
.primary()
.cursor(doc.text().slice(..));
- let diag = if let Some(diag) = doc
+
+ let diag = doc
.diagnostics()
.iter()
.rev()
- .map(|diag| diag.range.start)
- .find(|&pos| pos < cursor_pos)
- {
- diag
- } else if let Some(diag) = doc.diagnostics().last() {
- diag.range.start
- } else {
- return;
+ .find(|diag| diag.range.start < cursor_pos)
+ .or_else(|| doc.diagnostics().last());
+
+ let pos = match diag {
+ Some(diag) => diag.range.start,
+ None => return,
};
- goto_pos(editor, diag);
+ goto_pos(editor, pos);
}
fn signature_help(cx: &mut Context) {
@@ -3423,7 +3570,26 @@ pub mod insert {
pub type Hook = fn(&Rope, &Selection, char) -> Option<Transaction>;
pub type PostHook = fn(&mut Context, char);
- fn completion(cx: &mut Context, ch: char) {
+ // It trigger completion when idle timer reaches deadline
+ // Only trigger completion if the word under cursor is longer than n characters
+ pub fn idle_completion(cx: &mut Context) {
+ let (view, doc) = current!(cx.editor);
+ let text = doc.text().slice(..);
+ let cursor = doc.selection(view.id).primary().cursor(text);
+
+ use helix_core::chars::char_is_word;
+ let mut iter = text.chars_at(cursor);
+ iter.reverse();
+ for _ in 0..cx.editor.config.completion_trigger_len {
+ match iter.next() {
+ Some(c) if char_is_word(c) => {}
+ _ => return,
+ }
+ }
+ super::completion(cx);
+ }
+
+ fn language_server_completion(cx: &mut Context, ch: char) {
// if ch matches completion char, trigger completion
let doc = doc_mut!(cx.editor);
let language_server = match doc.language_server() {
@@ -3433,19 +3599,14 @@ pub mod insert {
let capabilities = language_server.capabilities();
- if let lsp::ServerCapabilities {
- completion_provider:
- Some(lsp::CompletionOptions {
- trigger_characters: Some(triggers),
- ..
- }),
+ if let Some(lsp::CompletionOptions {
+ trigger_characters: Some(triggers),
..
- } = capabilities
+ }) = &capabilities.completion_provider
{
// TODO: what if trigger is multiple chars long
- let is_trigger = triggers.iter().any(|trigger| trigger.contains(ch));
-
- if is_trigger {
+ if triggers.iter().any(|trigger| trigger.contains(ch)) {
+ cx.editor.clear_idle_timer();
super::completion(cx);
}
}
@@ -3527,7 +3688,8 @@ pub mod insert {
// TODO: need a post insert hook too for certain triggers (autocomplete, signature help, etc)
// this could also generically look at Transaction, but it's a bit annoying to look at
// Operation instead of Change.
- for hook in &[completion, signature_help] {
+ for hook in &[language_server_completion, signature_help] {
+ // for hook in &[signature_help] {
hook(cx, c);
}
}
@@ -3668,13 +3830,19 @@ pub mod insert {
fn undo(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let view_id = view.id;
- doc.undo(view_id);
+ let success = doc.undo(view_id);
+ if !success {
+ cx.editor.set_status("Already at oldest change".to_owned());
+ }
}
fn redo(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
let view_id = view.id;
- doc.redo(view_id);
+ let success = doc.redo(view_id);
+ if !success {
+ cx.editor.set_status("Already at newest change".to_owned());
+ }
}
// Yank / Paste
@@ -3735,7 +3903,7 @@ fn yank_joined_to_clipboard_impl(
}
fn yank_joined_to_clipboard(cx: &mut Context) {
- let line_ending = current!(cx.editor).1.line_ending;
+ let line_ending = doc!(cx.editor).line_ending;
let _ = yank_joined_to_clipboard_impl(
&mut cx.editor,
line_ending.as_str(),
@@ -3769,7 +3937,7 @@ fn yank_main_selection_to_clipboard(cx: &mut Context) {
}
fn yank_joined_to_primary_clipboard(cx: &mut Context) {
- let line_ending = current!(cx.editor).1.line_ending;
+ let line_ending = doc!(cx.editor).line_ending;
let _ = yank_joined_to_clipboard_impl(
&mut cx.editor,
line_ending.as_str(),
@@ -3882,11 +4050,21 @@ fn replace_with_yanked(cx: &mut Context) {
let registers = &mut cx.editor.registers;
if let Some(values) = registers.read(reg_name) {
- if let Some(yank) = values.first() {
+ if !values.is_empty() {
+ let repeat = std::iter::repeat(
+ values
+ .last()
+ .map(|value| Tendril::from_slice(value))
+ .unwrap(),
+ );
+ let mut values = values
+ .iter()
+ .map(|value| Tendril::from_slice(value))
+ .chain(repeat);
let selection = doc.selection(view.id);
let transaction = Transaction::change_by_selection(doc.text(), selection, |range| {
if !range.is_empty() {
- (range.from(), range.to(), Some(yank.as_str().into()))
+ (range.from(), range.to(), Some(values.next().unwrap()))
} else {
(range.from(), range.to(), None)
}
@@ -4128,6 +4306,7 @@ fn keep_selections(cx: &mut Context) {
cx,
"keep:".into(),
Some(reg),
+ |_input: &str| Vec::new(),
move |view, doc, regex, event| {
if event != PromptEvent::Update {
return;
@@ -4228,6 +4407,7 @@ pub fn completion(cx: &mut Context) {
iter.reverse();
let offset = iter.take_while(|ch| chars::char_is_word(*ch)).count();
let start_offset = cursor.saturating_sub(offset);
+ let prefix = text.slice(start_offset..cursor).to_string();
cx.callback(
future,
@@ -4240,7 +4420,7 @@ pub fn completion(cx: &mut Context) {
return;
}
- let items = match response {
+ let mut items = match response {
Some(lsp::CompletionResponse::Array(items)) => items,
// TODO: do something with is_incomplete
Some(lsp::CompletionResponse::List(lsp::CompletionList {
@@ -4250,6 +4430,18 @@ pub fn completion(cx: &mut Context) {
None => Vec::new(),
};
+ if !prefix.is_empty() {
+ items = items
+ .into_iter()
+ .filter(|item| {
+ item.filter_text
+ .as_ref()
+ .unwrap_or(&item.label)
+ .starts_with(&prefix)
+ })
+ .collect();
+ }
+
if items.is_empty() {
// editor.set_error("No completion available".to_string());
return;
@@ -4401,27 +4593,32 @@ fn rotate_selection_contents_backward(cx: &mut Context) {
// tree sitter node selection
fn expand_selection(cx: &mut Context) {
- let (view, doc) = current!(cx.editor);
+ let motion = |editor: &mut Editor| {
+ let (view, doc) = current!(editor);
- if let Some(syntax) = doc.syntax() {
- let text = doc.text().slice(..);
- let selection = object::expand_selection(syntax, text, doc.selection(view.id));
- doc.set_selection(view.id, selection);
- }
+ if let Some(syntax) = doc.syntax() {
+ let text = doc.text().slice(..);
+ let selection = object::expand_selection(syntax, text, doc.selection(view.id));
+ doc.set_selection(view.id, selection);
+ }
+ };
+ motion(&mut cx.editor);
+ cx.editor.last_motion = Some(Motion(Box::new(motion)));
}
fn match_brackets(cx: &mut Context) {
let (view, doc) = current!(cx.editor);
if let Some(syntax) = doc.syntax() {
- let pos = doc
- .selection(view.id)
- .primary()
- .cursor(doc.text().slice(..));
- if let Some(pos) = match_brackets::find(syntax, doc.text(), pos) {
- let selection = Selection::point(pos);
- doc.set_selection(view.id, selection);
- };
+ let text = doc.text().slice(..);
+ let selection = doc.selection(view.id).clone().transform(|range| {
+ if let Some(pos) = match_brackets::find(syntax, doc.text(), range.anchor) {
+ range.put_cursor(text, pos, doc.mode == Mode::Select)
+ } else {
+ range
+ }
+ });
+ doc.set_selection(view.id, selection);
}
}
@@ -4429,7 +4626,7 @@ fn match_brackets(cx: &mut Context) {
fn jump_forward(cx: &mut Context) {
let count = cx.count();
- let (view, _doc) = current!(cx.editor);
+ let view = view_mut!(cx.editor);
if let Some((id, selection)) = view.jumps.forward(count) {
view.doc = *id;
@@ -4463,6 +4660,22 @@ fn rotate_view(cx: &mut Context) {
cx.editor.focus_next()
}
+fn jump_view_right(cx: &mut Context) {
+ cx.editor.focus_right()
+}
+
+fn jump_view_left(cx: &mut Context) {
+ cx.editor.focus_left()
+}
+
+fn jump_view_up(cx: &mut Context) {
+ cx.editor.focus_up()
+}
+
+fn jump_view_down(cx: &mut Context) {
+ cx.editor.focus_down()
+}
+
// split helper, clear it later
fn split(cx: &mut Context, action: Action) {
let (view, doc) = current!(cx.editor);
@@ -4552,20 +4765,43 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) {
let count = cx.count();
cx.on_next_key(move |cx, event| {
if let Some(ch) = event.char() {
- let (view, doc) = current!(cx.editor);
- let text = doc.text().slice(..);
+ let textobject = move |editor: &mut Editor| {
+ let (view, doc) = current!(editor);
+ let text = doc.text().slice(..);
+
+ let textobject_treesitter = |obj_name: &str, range: Range| -> Range {
+ let (lang_config, syntax) = match doc.language_config().zip(doc.syntax()) {
+ Some(t) => t,
+ None => return range,
+ };
+ textobject::textobject_treesitter(
+ text,
+ range,
+ objtype,
+ obj_name,
+ syntax.tree().root_node(),
+ lang_config,
+ count,
+ )
+ };
- let selection = doc.selection(view.id).clone().transform(|range| {
- match ch {
- 'w' => textobject::textobject_word(text, range, objtype, count),
- // TODO: cancel new ranges if inconsistent surround matches across lines
- ch if !ch.is_ascii_alphanumeric() => {
- textobject::textobject_surround(text, range, objtype, ch, count)
+ let selection = doc.selection(view.id).clone().transform(|range| {
+ match ch {
+ 'w' => textobject::textobject_word(text, range, objtype, count),
+ 'c' => textobject_treesitter("class", range),
+ 'f' => textobject_treesitter("function", range),
+ 'p' => textobject_treesitter("parameter", range),
+ // TODO: cancel new ranges if inconsistent surround matches across lines
+ ch if !ch.is_ascii_alphanumeric() => {
+ textobject::textobject_surround(text, range, objtype, ch, count)
+ }
+ _ => range,
}
- _ => range,
- }
- });
- doc.set_selection(view.id, selection);
+ });
+ doc.set_selection(view.id, selection);
+ };
+ textobject(&mut cx.editor);
+ cx.editor.last_motion = Some(Motion(Box::new(textobject)));
}
})
}
@@ -4577,7 +4813,7 @@ fn surround_add(cx: &mut Context) {
let selection = doc.selection(view.id);
let (open, close) = surround::get_pair(ch);
- let mut changes = Vec::new();
+ let mut changes = Vec::with_capacity(selection.len() * 2);
for range in selection.iter() {
changes.push((range.from(), range.from(), Some(Tendril::from_char(open))));
changes.push((range.to(), range.to(), Some(Tendril::from_char(close))));
diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs
index cad1df05..dc8b91d7 100644
--- a/helix-term/src/compositor.rs
+++ b/helix-term/src/compositor.rs
@@ -207,7 +207,7 @@ pub trait AnyComponent {
///
/// ```rust
/// use helix_term::{ui::Text, compositor::Component};
- /// let boxed: Box<Component> = Box::new(Text::new("text".to_string()));
+ /// let boxed: Box<dyn Component> = Box::new(Text::new("text".to_string()));
/// let text: Box<Text> = boxed.as_boxed_any().downcast().unwrap();
/// ```
fn as_boxed_any(self: Box<Self>) -> Box<dyn Any>;
diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs
index e344457c..35dbce2f 100644
--- a/helix-term/src/keymap.rs
+++ b/helix-term/src/keymap.rs
@@ -5,20 +5,20 @@ use helix_view::{document::Mode, info::Info, input::KeyEvent};
use serde::Deserialize;
use std::{
borrow::Cow,
- collections::HashMap,
+ collections::{BTreeSet, HashMap},
ops::{Deref, DerefMut},
};
#[macro_export]
macro_rules! key {
($key:ident) => {
- KeyEvent {
+ ::helix_view::input::KeyEvent {
code: ::helix_view::keyboard::KeyCode::$key,
modifiers: ::helix_view::keyboard::KeyModifiers::NONE,
}
};
($($ch:tt)*) => {
- KeyEvent {
+ ::helix_view::input::KeyEvent {
code: ::helix_view::keyboard::KeyCode::Char($($ch)*),
modifiers: ::helix_view::keyboard::KeyModifiers::NONE,
}
@@ -78,19 +78,30 @@ macro_rules! keymap {
};
}
-#[derive(Debug, Clone, Deserialize)]
+#[derive(Debug, Clone)]
pub struct KeyTrieNode {
/// A label for keys coming under this node, like "Goto mode"
- #[serde(skip)]
name: String,
- #[serde(flatten)]
map: HashMap<KeyEvent, KeyTrie>,
- #[serde(skip)]
order: Vec<KeyEvent>,
- #[serde(skip)]
pub is_sticky: bool,
}
+impl<'de> Deserialize<'de> for KeyTrieNode {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let map = HashMap::<KeyEvent, KeyTrie>::deserialize(deserializer)?;
+ let order = map.keys().copied().collect::<Vec<_>>(); // NOTE: map.keys() has arbitrary order
+ Ok(Self {
+ map,
+ order,
+ ..Default::default()
+ })
+ }
+}
+
impl KeyTrieNode {
pub fn new(name: &str, map: HashMap<KeyEvent, KeyTrie>, order: Vec<KeyEvent>) -> Self {
Self {
@@ -118,7 +129,6 @@ impl KeyTrieNode {
}
self.map.insert(key, trie);
}
-
for &key in self.map.keys() {
if !self.order.contains(&key) {
self.order.push(key);
@@ -127,20 +137,29 @@ impl KeyTrieNode {
}
pub fn infobox(&self) -> Info {
- let mut body: Vec<(&str, Vec<KeyEvent>)> = Vec::with_capacity(self.len());
+ let mut body: Vec<(&str, BTreeSet<KeyEvent>)> = Vec::with_capacity(self.len());
for (&key, trie) in self.iter() {
let desc = match trie {
- KeyTrie::Leaf(cmd) => cmd.doc(),
+ KeyTrie::Leaf(cmd) => {
+ if cmd.name() == "no_op" {
+ continue;
+ }
+ cmd.doc()
+ }
KeyTrie::Node(n) => n.name(),
};
match body.iter().position(|(d, _)| d == &desc) {
- // FIXME: multiple keys are ordered randomly (use BTreeSet)
- Some(pos) => body[pos].1.push(key),
- None => body.push((desc, vec![key])),
+ Some(pos) => {
+ body[pos].1.insert(key);
+ }
+ None => body.push((desc, BTreeSet::from([key]))),
}
}
body.sort_unstable_by_key(|(_, keys)| {
- self.order.iter().position(|&k| k == keys[0]).unwrap()
+ self.order
+ .iter()
+ .position(|&k| k == *keys.iter().next().unwrap())
+ .unwrap()
});
let prefix = format!("{} ", self.name());
if body.iter().all(|(desc, _)| desc.starts_with(&prefix)) {
@@ -151,6 +170,11 @@ impl KeyTrieNode {
}
Info::new(self.name(), body)
}
+
+ /// Get a reference to the key trie node's order.
+ pub fn order(&self) -> &[KeyEvent] {
+ self.order.as_slice()
+ }
}
impl Default for KeyTrieNode {
@@ -235,6 +259,7 @@ pub enum KeymapResultKind {
/// Returned after looking up a key in [`Keymap`]. The `sticky` field has a
/// reference to the sticky node if one is currently active.
+#[derive(Debug)]
pub struct KeymapResult<'a> {
pub kind: KeymapResultKind,
pub sticky: Option<&'a KeyTrieNode>,
@@ -395,6 +420,7 @@ impl Default for Keymaps {
"F" => find_prev_char,
"r" => replace,
"R" => replace_with_yanked,
+ "A-." => repeat_last_motion,
"~" => switch_case,
"`" => switch_to_lowercase,
@@ -427,6 +453,8 @@ impl Default for Keymaps {
"m" => goto_window_middle,
"b" => goto_window_bottom,
"a" => goto_last_accessed_file,
+ "n" => goto_next_buffer,
+ "p" => goto_previous_buffer,
},
":" => command_mode,
@@ -476,10 +504,9 @@ impl Default for Keymaps {
},
"/" => search,
- // ? for search_reverse
+ "?" => rsearch,
"n" => search_next,
- "N" => extend_search_next,
- // N for search_prev
+ "N" => search_prev,
"*" => search_selection,
"u" => undo,
@@ -520,9 +547,13 @@ impl Default for Keymaps {
"C-w" => { "Window"
"C-w" | "w" => rotate_view,
- "C-h" | "h" => hsplit,
+ "C-s" | "s" => hsplit,
"C-v" | "v" => vsplit,
"C-q" | "q" => wclose,
+ "C-h" | "h" | "left" => jump_view_left,
+ "C-j" | "j" | "down" => jump_view_down,
+ "C-k" | "k" | "up" => jump_view_up,
+ "C-l" | "l" | "right" => jump_view_right,
},
// move under <space>c
@@ -621,6 +652,9 @@ impl Default for Keymaps {
"B" => extend_prev_long_word_start,
"E" => extend_next_long_word_end,
+ "n" => extend_search_next,
+ "N" => extend_search_prev,
+
"t" => extend_till_char,
"f" => extend_next_char,
"T" => extend_till_prev_char,
@@ -669,63 +703,101 @@ pub fn merge_keys(mut config: Config) -> Config {
config
}
-#[test]
-fn merge_partial_keys() {
- let config = Config {
- keys: Keymaps(hashmap! {
- Mode::Normal => Keymap::new(
- keymap!({ "Normal mode"
- "i" => normal_mode,
- "无" => insert_mode,
- "z" => jump_backward,
- "g" => { "Merge into goto mode"
- "$" => goto_line_end,
- "g" => delete_char_forward,
- },
- })
- )
- }),
- ..Default::default()
- };
- let mut merged_config = merge_keys(config.clone());
- assert_ne!(config, merged_config);
-
- let keymap = merged_config.keys.0.get_mut(&Mode::Normal).unwrap();
- assert_eq!(
- keymap.get(key!('i')).kind,
- KeymapResultKind::Matched(Command::normal_mode),
- "Leaf should replace leaf"
- );
- assert_eq!(
- keymap.get(key!('无')).kind,
- KeymapResultKind::Matched(Command::insert_mode),
- "New leaf should be present in merged keymap"
- );
- // Assumes that z is a node in the default keymap
- assert_eq!(
- keymap.get(key!('z')).kind,
- KeymapResultKind::Matched(Command::jump_backward),
- "Leaf should replace node"
- );
- // Assumes that `g` is a node in default keymap
- assert_eq!(
- keymap.root().search(&[key!('g'), key!('$')]).unwrap(),
- &KeyTrie::Leaf(Command::goto_line_end),
- "Leaf should be present in merged subnode"
- );
- // Assumes that `gg` is in default keymap
- assert_eq!(
- keymap.root().search(&[key!('g'), key!('g')]).unwrap(),
- &KeyTrie::Leaf(Command::delete_char_forward),
- "Leaf should replace old leaf in merged subnode"
- );
- // Assumes that `ge` is in default keymap
- assert_eq!(
- keymap.root().search(&[key!('g'), key!('e')]).unwrap(),
- &KeyTrie::Leaf(Command::goto_last_line),
- "Old leaves in subnode should be present in merged node"
- );
-
- assert!(merged_config.keys.0.get(&Mode::Normal).unwrap().len() > 1);
- assert!(merged_config.keys.0.get(&Mode::Insert).unwrap().len() > 0);
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn merge_partial_keys() {
+ let config = Config {
+ keys: Keymaps(hashmap! {
+ Mode::Normal => Keymap::new(
+ keymap!({ "Normal mode"
+ "i" => normal_mode,
+ "无" => insert_mode,
+ "z" => jump_backward,
+ "g" => { "Merge into goto mode"
+ "$" => goto_line_end,
+ "g" => delete_char_forward,
+ },
+ })
+ )
+ }),
+ ..Default::default()
+ };
+ let mut merged_config = merge_keys(config.clone());
+ assert_ne!(config, merged_config);
+
+ let keymap = merged_config.keys.0.get_mut(&Mode::Normal).unwrap();
+ assert_eq!(
+ keymap.get(key!('i')).kind,
+ KeymapResultKind::Matched(Command::normal_mode),
+ "Leaf should replace leaf"
+ );
+ assert_eq!(
+ keymap.get(key!('无')).kind,
+ KeymapResultKind::Matched(Command::insert_mode),
+ "New leaf should be present in merged keymap"
+ );
+ // Assumes that z is a node in the default keymap
+ assert_eq!(
+ keymap.get(key!('z')).kind,
+ KeymapResultKind::Matched(Command::jump_backward),
+ "Leaf should replace node"
+ );
+ // Assumes that `g` is a node in default keymap
+ assert_eq!(
+ keymap.root().search(&[key!('g'), key!('$')]).unwrap(),
+ &KeyTrie::Leaf(Command::goto_line_end),
+ "Leaf should be present in merged subnode"
+ );
+ // Assumes that `gg` is in default keymap
+ assert_eq!(
+ keymap.root().search(&[key!('g'), key!('g')]).unwrap(),
+ &KeyTrie::Leaf(Command::delete_char_forward),
+ "Leaf should replace old leaf in merged subnode"
+ );
+ // Assumes that `ge` is in default keymap
+ assert_eq!(
+ keymap.root().search(&[key!('g'), key!('e')]).unwrap(),
+ &KeyTrie::Leaf(Command::goto_last_line),
+ "Old leaves in subnode should be present in merged node"
+ );
+
+ assert!(merged_config.keys.0.get(&Mode::Normal).unwrap().len() > 1);
+ assert!(merged_config.keys.0.get(&Mode::Insert).unwrap().len() > 0);
+ }
+
+ #[test]
+ fn order_should_be_set() {
+ let config = Config {
+ keys: Keymaps(hashmap! {
+ Mode::Normal => Keymap::new(
+ keymap!({ "Normal mode"
+ "space" => { ""
+ "s" => { ""
+ "v" => vsplit,
+ "c" => hsplit,
+ },
+ },
+ })
+ )
+ }),
+ ..Default::default()
+ };
+ let mut merged_config = merge_keys(config.clone());
+ assert_ne!(config, merged_config);
+ let keymap = merged_config.keys.0.get_mut(&Mode::Normal).unwrap();
+ // Make sure mapping works
+ assert_eq!(
+ keymap
+ .root()
+ .search(&[key!(' '), key!('s'), key!('v')])
+ .unwrap(),
+ &KeyTrie::Leaf(Command::vsplit),
+ "Leaf should be present in merged subnode"
+ );
+ // Make sure an order was set during merge
+ let node = keymap.root().search(&[crate::key!(' ')]).unwrap();
+ assert!(!node.node().unwrap().order().is_empty())
+ }
}
diff --git a/helix-term/src/main.rs b/helix-term/src/main.rs
index 180dacd1..f746895c 100644
--- a/helix-term/src/main.rs
+++ b/helix-term/src/main.rs
@@ -16,6 +16,11 @@ fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> {
};
// Separate file config so we can include year, month and day in file logs
+ let file = std::fs::OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(logpath)?;
let file_config = fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
@@ -26,7 +31,7 @@ fn setup_logging(logpath: PathBuf, verbosity: u64) -> Result<()> {
message
))
})
- .chain(fern::log_file(logpath)?);
+ .chain(file);
base_config.chain(file_config).apply()?;
@@ -55,6 +60,7 @@ ARGS:
FLAGS:
-h, --help Prints help information
+ --tutor Loads the tutorial
-v Increases logging verbosity each use for up to 3 times
(default file: {})
-V, --version Prints version information
diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs
index c75b24f1..dd782d29 100644
--- a/helix-term/src/ui/completion.rs
+++ b/helix-term/src/ui/completion.rs
@@ -5,7 +5,7 @@ use tui::buffer::Buffer as Surface;
use std::borrow::Cow;
use helix_core::Transaction;
-use helix_view::{graphics::Rect, Document, Editor, View};
+use helix_view::{graphics::Rect, Document, Editor};
use crate::commands;
use crate::ui::{menu, Markdown, Menu, Popup, PromptEvent};
@@ -30,31 +30,32 @@ impl menu::Item for CompletionItem {
menu::Row::new(vec![
menu::Cell::from(self.label.as_str()),
menu::Cell::from(match self.kind {
- Some(lsp::CompletionItemKind::Text) => "text",
- Some(lsp::CompletionItemKind::Method) => "method",
- Some(lsp::CompletionItemKind::Function) => "function",
- Some(lsp::CompletionItemKind::Constructor) => "constructor",
- Some(lsp::CompletionItemKind::Field) => "field",
- Some(lsp::CompletionItemKind::Variable) => "variable",
- Some(lsp::CompletionItemKind::Class) => "class",
- Some(lsp::CompletionItemKind::Interface) => "interface",
- Some(lsp::CompletionItemKind::Module) => "module",
- Some(lsp::CompletionItemKind::Property) => "property",
- Some(lsp::CompletionItemKind::Unit) => "unit",
- Some(lsp::CompletionItemKind::Value) => "value",
- Some(lsp::CompletionItemKind::Enum) => "enum",
- Some(lsp::CompletionItemKind::Keyword) => "keyword",
- Some(lsp::CompletionItemKind::Snippet) => "snippet",
- Some(lsp::CompletionItemKind::Color) => "color",
- Some(lsp::CompletionItemKind::File) => "file",
- Some(lsp::CompletionItemKind::Reference) => "reference",
- Some(lsp::CompletionItemKind::Folder) => "folder",
- Some(lsp::CompletionItemKind::EnumMember) => "enum_member",
- Some(lsp::CompletionItemKind::Constant) => "constant",
- Some(lsp::CompletionItemKind::Struct) => "struct",
- Some(lsp::CompletionItemKind::Event) => "event",
- Some(lsp::CompletionItemKind::Operator) => "operator",
- Some(lsp::CompletionItemKind::TypeParameter) => "type_param",
+ Some(lsp::CompletionItemKind::TEXT) => "text",
+ Some(lsp::CompletionItemKind::METHOD) => "method",
+ Some(lsp::CompletionItemKind::FUNCTION) => "function",
+ Some(lsp::CompletionItemKind::CONSTRUCTOR) => "constructor",
+ Some(lsp::CompletionItemKind::FIELD) => "field",
+ Some(lsp::CompletionItemKind::VARIABLE) => "variable",
+ Some(lsp::CompletionItemKind::CLASS) => "class",
+ Some(lsp::CompletionItemKind::INTERFACE) => "interface",
+ Some(lsp::CompletionItemKind::MODULE) => "module",
+ Some(lsp::CompletionItemKind::PROPERTY) => "property",
+ Some(lsp::CompletionItemKind::UNIT) => "unit",
+ Some(lsp::CompletionItemKind::VALUE) => "value",
+ Some(lsp::CompletionItemKind::ENUM) => "enum",
+ Some(lsp::CompletionItemKind::KEYWORD) => "keyword",
+ Some(lsp::CompletionItemKind::SNIPPET) => "snippet",
+ Some(lsp::CompletionItemKind::COLOR) => "color",
+ Some(lsp::CompletionItemKind::FILE) => "file",
+ Some(lsp::CompletionItemKind::REFERENCE) => "reference",
+ Some(lsp::CompletionItemKind::FOLDER) => "folder",
+ Some(lsp::CompletionItemKind::ENUM_MEMBER) => "enum_member",
+ Some(lsp::CompletionItemKind::CONSTANT) => "constant",
+ Some(lsp::CompletionItemKind::STRUCT) => "struct",
+ Some(lsp::CompletionItemKind::EVENT) => "event",
+ Some(lsp::CompletionItemKind::OPERATOR) => "operator",
+ Some(lsp::CompletionItemKind::TYPE_PARAMETER) => "type_param",
+ Some(kind) => unimplemented!("{:?}", kind),
None => "",
}),
// self.detail.as_deref().unwrap_or("")
@@ -83,13 +84,13 @@ impl Completion {
start_offset: usize,
trigger_offset: usize,
) -> Self {
- // let items: Vec<CompletionItem> = Vec::new();
let menu = Menu::new(items, move |editor: &mut Editor, item, event| {
fn item_to_transaction(
doc: &Document,
- view: &View,
item: &CompletionItem,
offset_encoding: helix_lsp::OffsetEncoding,
+ start_offset: usize,
+ trigger_offset: usize,
) -> Transaction {
if let Some(edit) = &item.text_edit {
let edit = match edit {
@@ -105,63 +106,52 @@ impl Completion {
)
} else {
let text = item.insert_text.as_ref().unwrap_or(&item.label);
- let cursor = doc
- .selection(view.id)
- .primary()
- .cursor(doc.text().slice(..));
+ // Some LSPs just give you an insertText with no offset ¯\_(ツ)_/¯
+ // in these cases we need to check for a common prefix and remove it
+ let prefix = Cow::from(doc.text().slice(start_offset..trigger_offset));
+ let text = text.trim_start_matches::<&str>(&prefix);
Transaction::change(
doc.text(),
- vec![(cursor, cursor, Some(text.as_str().into()))].into_iter(),
+ vec![(trigger_offset, trigger_offset, Some(text.into()))].into_iter(),
)
}
}
+ let (view, doc) = current!(editor);
+
+ // if more text was entered, remove it
+ doc.restore(view.id);
+
match event {
PromptEvent::Abort => {}
PromptEvent::Update => {
- let (view, doc) = current!(editor);
-
// always present here
let item = item.unwrap();
- // if more text was entered, remove it
- // TODO: ideally to undo we should keep the last completion tx revert, and map it over new changes
- let cursor = doc
- .selection(view.id)
- .primary()
- .cursor(doc.text().slice(..));
- if trigger_offset < cursor {
- let remove = Transaction::change(
- doc.text(),
- vec![(trigger_offset, cursor, None)].into_iter(),
- );
- doc.apply(&remove, view.id);
- }
+ let transaction = item_to_transaction(
+ doc,
+ item,
+ offset_encoding,
+ start_offset,
+ trigger_offset,
+ );
+
+ // initialize a savepoint
+ doc.savepoint();
- let transaction = item_to_transaction(doc, view, item, offset_encoding);
doc.apply(&transaction, view.id);
}
PromptEvent::Validate => {
- let (view, doc) = current!(editor);
-
// always present here
let item = item.unwrap();
- // if more text was entered, remove it
- // TODO: ideally to undo we should keep the last completion tx revert, and map it over new changes
- let cursor = doc
- .selection(view.id)
- .primary()
- .cursor(doc.text().slice(..));
- if trigger_offset < cursor {
- let remove = Transaction::change(
- doc.text(),
- vec![(trigger_offset, cursor, None)].into_iter(),
- );
- doc.apply(&remove, view.id);
- }
-
- let transaction = item_to_transaction(doc, view, item, offset_encoding);
+ let transaction = item_to_transaction(
+ doc,
+ item,
+ offset_encoding,
+ start_offset,
+ trigger_offset,
+ );
doc.apply(&transaction, view.id);
if let Some(additional_edits) = &item.additional_text_edits {
@@ -210,7 +200,7 @@ impl Completion {
.selection(view.id)
.primary()
.cursor(doc.text().slice(..));
- if self.start_offset <= cursor {
+ if self.trigger_offset <= cursor {
let fragment = doc.text().slice(self.start_offset..cursor);
let text = Cow::from(fragment);
// TODO: logic is same as ui/picker
@@ -274,12 +264,10 @@ impl Component for Completion {
.language()
.and_then(|scope| scope.strip_prefix("source."))
.unwrap_or("");
- let cursor_pos = doc
- .selection(view.id)
- .primary()
- .cursor(doc.text().slice(..));
- let cursor_pos = (helix_core::coords_at_pos(doc.text().slice(..), cursor_pos).row
- - view.offset.row) as u16;
+ let text = doc.text().slice(..);
+ let cursor_pos = doc.selection(view.id).primary().cursor(text);
+ let coords = helix_core::visual_coords_at_pos(text, cursor_pos, doc.tab_width());
+ let cursor_pos = (coords.row - view.offset.row) as u16;
let mut markdown_doc = match &option.documentation {
Some(lsp::Documentation::String(contents))
| Some(lsp::Documentation::MarkupContent(lsp::MarkupContent {
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index 037f04b8..26a0358d 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -689,6 +689,8 @@ impl EditorView {
theme: &Theme,
is_focused: bool,
) {
+ use tui::text::{Span, Spans};
+
//-------------------------------
// Left side of the status line.
//-------------------------------
@@ -707,17 +709,17 @@ impl EditorView {
})
.unwrap_or("");
- let style = if is_focused {
+ let base_style = if is_focused {
theme.get("ui.statusline")
} else {
theme.get("ui.statusline.inactive")
};
// statusline
- surface.set_style(viewport.with_height(1), style);
+ surface.set_style(viewport.with_height(1), base_style);
if is_focused {
- surface.set_string(viewport.x + 1, viewport.y, mode, style);
+ surface.set_string(viewport.x + 1, viewport.y, mode, base_style);
}
- surface.set_string(viewport.x + 5, viewport.y, progress, style);
+ surface.set_string(viewport.x + 5, viewport.y, progress, base_style);
if let Some(path) = doc.relative_path() {
let path = path.to_string_lossy();
@@ -728,7 +730,7 @@ impl EditorView {
viewport.y,
title,
viewport.width.saturating_sub(6) as usize,
- style,
+ base_style,
);
}
@@ -736,8 +738,50 @@ impl EditorView {
// Right side of the status line.
//-------------------------------
- // Compute the individual info strings.
- let diag_count = format!("{}", doc.diagnostics().len());
+ let mut right_side_text = Spans::default();
+
+ // Compute the individual info strings and add them to `right_side_text`.
+
+ // Diagnostics
+ let diags = doc.diagnostics().iter().fold((0, 0), |mut counts, diag| {
+ use helix_core::diagnostic::Severity;
+ match diag.severity {
+ Some(Severity::Warning) => counts.0 += 1,
+ Some(Severity::Error) | None => counts.1 += 1,
+ _ => {}
+ }
+ counts
+ });
+ let (warnings, errors) = diags;
+ let warning_style = theme.get("warning");
+ let error_style = theme.get("error");
+ for i in 0..2 {
+ let (count, style) = match i {
+ 0 => (warnings, warning_style),
+ 1 => (errors, error_style),
+ _ => unreachable!(),
+ };
+ if count == 0 {
+ continue;
+ }
+ let style = base_style.patch(style);
+ right_side_text.0.push(Span::styled("●", style));
+ right_side_text
+ .0
+ .push(Span::styled(format!(" {} ", count), base_style));
+ }
+
+ // Selections
+ let sels_count = doc.selection(view.id).len();
+ right_side_text.0.push(Span::styled(
+ format!(
+ " {} sel{} ",
+ sels_count,
+ if sels_count == 1 { "" } else { "s" }
+ ),
+ base_style,
+ ));
+
// let indent_info = match doc.indent_style {
// IndentStyle::Tabs => "tabs",
// IndentStyle::Spaces(1) => "spaces:1",
@@ -750,29 +794,28 @@ impl EditorView {
// IndentStyle::Spaces(8) => "spaces:8",
// _ => "indent:ERROR",
// };
- let position_info = {
- let pos = coords_at_pos(
- doc.text().slice(..),
- doc.selection(view.id)
- .primary()
- .cursor(doc.text().slice(..)),
- );
- format!("{}:{}", pos.row + 1, pos.col + 1) // convert to 1-indexing
- };
- // Render them to the status line together.
- let right_side_text = format!(
- "{} {} ",
- &diag_count[..diag_count.len().min(4)],
- // indent_info,
- position_info
+ // Position
+ let pos = coords_at_pos(
+ doc.text().slice(..),
+ doc.selection(view.id)
+ .primary()
+ .cursor(doc.text().slice(..)),
);
- let text_len = right_side_text.len() as u16;
- surface.set_string(
- viewport.x + viewport.width.saturating_sub(text_len),
+ right_side_text.0.push(Span::styled(
+ format!(" {}:{} ", pos.row + 1, pos.col + 1), // Convert to 1-indexing.
+ base_style,
+ ));
+
+ // Render to the statusline.
+ surface.set_spans(
+ viewport.x
+ + viewport
+ .width
+ .saturating_sub(right_side_text.width() as u16),
viewport.y,
- right_side_text,
- style,
+ &right_side_text,
+ right_side_text.width() as u16,
);
}
@@ -984,7 +1027,7 @@ impl EditorView {
pub fn set_completion(
&mut self,
- editor: &Editor,
+ editor: &mut Editor,
items: Vec<helix_lsp::lsp::CompletionItem>,
offset_encoding: helix_lsp::OffsetEncoding,
start_offset: usize,
@@ -999,10 +1042,21 @@ impl EditorView {
return;
}
+ // Immediately initialize a savepoint
+ doc_mut!(editor).savepoint();
+
// TODO : propagate required size on resize to completion too
completion.required_size((size.width, size.height));
self.completion = Some(completion);
}
+
+ pub fn clear_completion(&mut self, editor: &mut Editor) {
+ self.completion = None;
+ // Clear any savepoints
+ let (_, doc) = current!(editor);
+ doc.savepoint = None;
+ editor.clear_idle_timer(); // don't retrigger
+ }
}
impl EditorView {
@@ -1022,12 +1076,12 @@ impl EditorView {
let editor = &mut cxt.editor;
let result = editor.tree.views().find_map(|(view, _focus)| {
- view.pos_at_screen_coords(&editor.documents[view.doc], row, column)
+ view.pos_at_screen_coords(&editor.documents[&view.doc], row, column)
.map(|pos| (pos, view.id))
});
if let Some((pos, view_id)) = result {
- let doc = &mut editor.documents[editor.tree.get(view_id).doc];
+ let doc = editor.document_mut(editor.tree.get(view_id).doc).unwrap();
if modifiers == crossterm::event::KeyModifiers::ALT {
let selection = doc.selection(view_id).clone();
@@ -1096,7 +1150,7 @@ impl EditorView {
};
let result = cxt.editor.tree.views().find_map(|(view, _focus)| {
- view.pos_at_screen_coords(&cxt.editor.documents[view.doc], row, column)
+ view.pos_at_screen_coords(&cxt.editor.documents[&view.doc], row, column)
.map(|_| view.id)
});
@@ -1182,12 +1236,12 @@ impl EditorView {
}
let result = editor.tree.views().find_map(|(view, _focus)| {
- view.pos_at_screen_coords(&editor.documents[view.doc], row, column)
+ view.pos_at_screen_coords(&editor.documents[&view.doc], row, column)
.map(|pos| (pos, view.id))
});
if let Some((pos, view_id)) = result {
- let doc = &mut editor.documents[editor.tree.get(view_id).doc];
+ let doc = editor.document_mut(editor.tree.get(view_id).doc).unwrap();
doc.set_selection(view_id, Selection::point(pos));
editor.tree.focus = view_id;
commands::Command::paste_primary_clipboard_before.execute(cxt);
@@ -1254,8 +1308,7 @@ impl Component for EditorView {
if callback.is_some() {
// assume close_fn
- self.completion = None;
- cxt.editor.clear_idle_timer(); // don't retrigger
+ self.clear_completion(cxt.editor);
}
}
}
@@ -1268,8 +1321,7 @@ impl Component for EditorView {
if let Some(completion) = &mut self.completion {
completion.update(&mut cxt);
if completion.is_empty() {
- self.completion = None;
- cxt.editor.clear_idle_timer(); // don't retrigger
+ self.clear_completion(cxt.editor);
}
}
}
@@ -1397,8 +1449,10 @@ impl Component for EditorView {
info.render(area, surface, cx);
}
- if let Some(ref mut info) = self.autoinfo {
- info.render(area, surface, cx);
+ if cx.editor.config.auto_info {
+ if let Some(ref mut info) = self.autoinfo {
+ info.render(area, surface, cx);
+ }
}
let key_width = 15u16; // for showing pending keys
@@ -1469,7 +1523,7 @@ fn canonicalize_key(key: &mut KeyEvent) {
}
#[inline]
-fn abs_diff(a: usize, b: usize) -> usize {
+const fn abs_diff(a: usize, b: usize) -> usize {
if a > b {
a - b
} else {
diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs
index 055593fd..3c492d14 100644
--- a/helix-term/src/ui/menu.rs
+++ b/helix-term/src/ui/menu.rs
@@ -64,25 +64,23 @@ impl<T: Item> Menu<T> {
}
pub fn score(&mut self, pattern: &str) {
- // need to borrow via pattern match otherwise it complains about simultaneous borrow
- let Self {
- ref mut matcher,
- ref mut matches,
- ref options,
- ..
- } = *self;
-
// reuse the matches allocation
- matches.clear();
- matches.extend(options.iter().enumerate().filter_map(|(index, option)| {
- let text = option.filter_text();
- // TODO: using fuzzy_indices could give us the char idx for match highlighting
- matcher
- .fuzzy_match(text, pattern)
- .map(|score| (index, score))
- }));
+ self.matches.clear();
+ self.matches.extend(
+ self.options
+ .iter()
+ .enumerate()
+ .filter_map(|(index, option)| {
+ let text = option.filter_text();
+ // TODO: using fuzzy_indices could give us the char idx for match highlighting
+ self.matcher
+ .fuzzy_match(text, pattern)
+ .map(|score| (index, score))
+ }),
+ );
// matches.sort_unstable_by_key(|(_, score)| -score);
- matches.sort_unstable_by_key(|(index, _score)| options[*index].sort_text());
+ self.matches
+ .sort_unstable_by_key(|(index, _score)| self.options[*index].sort_text());
// reset cursor position
self.cursor = None;
@@ -100,7 +98,8 @@ impl<T: Item> Menu<T> {
pub fn move_up(&mut self) {
let len = self.matches.len();
- let pos = self.cursor.map_or(0, |i| (i + len.saturating_sub(1)) % len) % len;
+ let max_index = len.saturating_sub(1);
+ let pos = self.cursor.map_or(max_index, |i| (i + max_index) % len) % len;
self.cursor = Some(pos);
self.adjust_scroll();
}
@@ -216,6 +215,10 @@ impl<T: Item + 'static> Component for Menu<T> {
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
+ }
+ | KeyEvent {
+ code: KeyCode::Char('k'),
+ modifiers: KeyModifiers::CONTROL,
} => {
self.move_up();
(self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update);
@@ -233,6 +236,10 @@ impl<T: Item + 'static> Component for Menu<T> {
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
+ }
+ | KeyEvent {
+ code: KeyCode::Char('j'),
+ modifiers: KeyModifiers::CONTROL,
} => {
self.move_down();
(self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update);
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index e66673ca..00c70cea 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -29,6 +29,7 @@ pub fn regex_prompt(
cx: &mut crate::commands::Context,
prompt: std::borrow::Cow<'static, str>,
history_register: Option<char>,
+ completion_fn: impl FnMut(&str) -> Vec<prompt::Completion> + 'static,
fun: impl Fn(&mut View, &mut Document, Regex, PromptEvent) + 'static,
) -> Prompt {
let (view, doc) = current!(cx.editor);
@@ -38,7 +39,7 @@ pub fn regex_prompt(
Prompt::new(
prompt,
history_register,
- |_input: &str| Vec::new(), // this is fine because Vec::new() doesn't allocate
+ completion_fn,
move |cx: &mut crate::compositor::Context, input: &str, event: PromptEvent| {
match event {
PromptEvent::Abort => {
@@ -92,9 +93,25 @@ pub fn regex_prompt(
}
pub fn file_picker(root: PathBuf) -> FilePicker<PathBuf> {
- use ignore::Walk;
+ use ignore::{types::TypesBuilder, WalkBuilder};
use std::time;
- let files = Walk::new(&root).filter_map(|entry| {
+
+ // We want to exclude files that the editor can't handle yet
+ let mut type_builder = TypesBuilder::new();
+ let mut walk_builder = WalkBuilder::new(&root);
+ let walk_builder = match type_builder.add(
+ "compressed",
+ "*.{zip,gz,bz2,zst,lzo,sz,tgz,tbz2,lz,lz4,lzma,lzo,z,Z,xz,7z,rar,cab}",
+ ) {
+ Err(_) => &walk_builder,
+ _ => {
+ type_builder.negate("all");
+ let excluded_types = type_builder.build().unwrap();
+ walk_builder.types(excluded_types)
+ }
+ };
+
+ let files = walk_builder.build().filter_map(|entry| {
let entry = entry.ok()?;
// Path::is_dir() traverses symlinks, so we use it over DirEntry::is_dir
if entry.path().is_dir() {
diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs
index 341235ee..3e805fac 100644
--- a/helix-term/src/ui/picker.rs
+++ b/helix-term/src/ui/picker.rs
@@ -12,7 +12,12 @@ use fuzzy_matcher::skim::SkimMatcherV2 as Matcher;
use fuzzy_matcher::FuzzyMatcher;
use tui::widgets::Widget;
-use std::{borrow::Cow, collections::HashMap, path::PathBuf};
+use std::{
+ borrow::Cow,
+ collections::HashMap,
+ io::Read,
+ path::{Path, PathBuf},
+};
use crate::ui::{Prompt, PromptEvent};
use helix_core::Position;
@@ -23,18 +28,58 @@ use helix_view::{
};
pub const MIN_SCREEN_WIDTH_FOR_PREVIEW: u16 = 80;
+/// Biggest file size to preview in bytes
+pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024;
-/// File path and line number (used to align and highlight a line)
+/// File path and range of lines (used to align and highlight lines)
type FileLocation = (PathBuf, Option<(usize, usize)>);
pub struct FilePicker<T> {
picker: Picker<T>,
/// Caches paths to documents
- preview_cache: HashMap<PathBuf, Document>,
+ preview_cache: HashMap<PathBuf, CachedPreview>,
+ read_buffer: Vec<u8>,
/// Given an item in the picker, return the file path and line number to display.
file_fn: Box<dyn Fn(&Editor, &T) -> Option<FileLocation>>,
}
+pub enum CachedPreview {
+ Document(Document),
+ Binary,
+ LargeFile,
+ NotFound,
+}
+
+// We don't store this enum in the cache so as to avoid lifetime constraints
+// from borrowing a document already opened in the editor.
+pub enum Preview<'picker, 'editor> {
+ Cached(&'picker CachedPreview),
+ EditorDocument(&'editor Document),
+}
+
+impl Preview<'_, '_> {
+ fn document(&self) -> Option<&Document> {
+ match self {
+ Preview::EditorDocument(doc) => Some(doc),
+ Preview::Cached(CachedPreview::Document(doc)) => Some(doc),
+ _ => None,
+ }
+ }
+
+ /// Alternate text to show for the preview.
+ fn placeholder(&self) -> &str {
+ match *self {
+ Self::EditorDocument(_) => "<File preview>",
+ Self::Cached(preview) => match preview {
+ CachedPreview::Document(_) => "<File preview>",
+ CachedPreview::Binary => "<Binary file>",
+ CachedPreview::LargeFile => "<File too large to preview>",
+ CachedPreview::NotFound => "<File not found>",
+ },
+ }
+ }
+}
+
impl<T> FilePicker<T> {
pub fn new(
options: Vec<T>,
@@ -45,6 +90,7 @@ impl<T> FilePicker<T> {
Self {
picker: Picker::new(false, options, format_fn, callback_fn),
preview_cache: HashMap::new(),
+ read_buffer: Vec::with_capacity(1024),
file_fn: Box::new(preview_fn),
}
}
@@ -60,14 +106,45 @@ impl<T> FilePicker<T> {
})
}
- fn calculate_preview(&mut self, editor: &Editor) {
- if let Some((path, _line)) = self.current_file(editor) {
- if !self.preview_cache.contains_key(&path) && editor.document_by_path(&path).is_none() {
- // TODO: enable syntax highlighting; blocked by async rendering
- let doc = Document::open(&path, None, Some(&editor.theme), None).unwrap();
- self.preview_cache.insert(path, doc);
- }
+ /// Get (cached) preview for a given path. If a document corresponding
+ /// to the path is already open in the editor, it is used instead.
+ fn get_preview<'picker, 'editor>(
+ &'picker mut self,
+ path: &Path,
+ editor: &'editor Editor,
+ ) -> Preview<'picker, 'editor> {
+ if let Some(doc) = editor.document_by_path(path) {
+ return Preview::EditorDocument(doc);
+ }
+
+ if self.preview_cache.contains_key(path) {
+ return Preview::Cached(&self.preview_cache[path]);
}
+
+ let data = std::fs::File::open(path).and_then(|file| {
+ let metadata = file.metadata()?;
+ // Read up to 1kb to detect the content type
+ let n = file.take(1024).read_to_end(&mut self.read_buffer)?;
+ let content_type = content_inspector::inspect(&self.read_buffer[..n]);
+ self.read_buffer.clear();
+ Ok((metadata, content_type))
+ });
+ let preview = data
+ .map(
+ |(metadata, content_type)| match (metadata.len(), content_type) {
+ (_, content_inspector::ContentType::BINARY) => CachedPreview::Binary,
+ (size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => CachedPreview::LargeFile,
+ _ => {
+ // TODO: enable syntax highlighting; blocked by async rendering
+ Document::open(path, None, Some(&editor.theme), None)
+ .map(CachedPreview::Document)
+ .unwrap_or(CachedPreview::NotFound)
+ }
+ },
+ )
+ .unwrap_or(CachedPreview::NotFound);
+ self.preview_cache.insert(path.to_owned(), preview);
+ Preview::Cached(&self.preview_cache[path])
}
}
@@ -79,12 +156,12 @@ impl<T: 'static> Component for FilePicker<T> {
// |picker | | |
// | | | |
// +---------+ +---------+
- self.calculate_preview(cx.editor);
let render_preview = area.width > MIN_SCREEN_WIDTH_FOR_PREVIEW;
let area = inner_rect(area);
// -- Render the frame:
// clear area
let background = cx.editor.theme.get("ui.background");
+ let text = cx.editor.theme.get("ui.text");
surface.clear_with(area, background);
let picker_width = if render_preview {
@@ -113,17 +190,23 @@ impl<T: 'static> Component for FilePicker<T> {
horizontal: 1,
};
let inner = inner.inner(&margin);
-
block.render(preview_area, surface);
- if let Some((doc, line)) = self.current_file(cx.editor).and_then(|(path, range)| {
- cx.editor
- .document_by_path(&path)
- .or_else(|| self.preview_cache.get(&path))
- .zip(Some(range))
- }) {
+ if let Some((path, range)) = self.current_file(cx.editor) {
+ let preview = self.get_preview(&path, cx.editor);
+ let doc = match preview.document() {
+ Some(doc) => doc,
+ None => {
+ let alt_text = preview.placeholder();
+ let x = inner.x + inner.width.saturating_sub(alt_text.len() as u16) / 2;
+ let y = inner.y + inner.height / 2;
+ surface.set_stringn(x, y, alt_text, inner.width as usize, text);
+ return;
+ }
+ };
+
// align to middle
- let first_line = line
+ let first_line = range
.map(|(start, end)| {
let height = end.saturating_sub(start) + 1;
let middle = start + (height.saturating_sub(1) / 2);
@@ -150,7 +233,7 @@ impl<T: 'static> Component for FilePicker<T> {
);
// highlight the line
- if let Some((start, end)) = line {
+ if let Some((start, end)) = range {
let offset = start.saturating_sub(first_line) as u16;
surface.set_style(
Rect::new(
@@ -234,37 +317,28 @@ impl<T> Picker<T> {
}
pub fn score(&mut self) {
- // need to borrow via pattern match otherwise it complains about simultaneous borrow
- let Self {
- ref mut matcher,
- ref mut matches,
- ref filters,
- ref format_fn,
- ..
- } = *self;
-
let pattern = &self.prompt.line;
// reuse the matches allocation
- matches.clear();
- matches.extend(
+ self.matches.clear();
+ self.matches.extend(
self.options
.iter()
.enumerate()
.filter_map(|(index, option)| {
// filter options first before matching
- if !filters.is_empty() {
- filters.binary_search(&index).ok()?;
+ if !self.filters.is_empty() {
+ self.filters.binary_search(&index).ok()?;
}
// TODO: maybe using format_fn isn't the best idea here
- let text = (format_fn)(option);
+ let text = (self.format_fn)(option);
// TODO: using fuzzy_indices could give us the char idx for match highlighting
- matcher
+ self.matcher
.fuzzy_match(&text, pattern)
.map(|score| (index, score))
}),
);
- matches.sort_unstable_by_key(|(_, score)| -score);
+ self.matches.sort_unstable_by_key(|(_, score)| -score);
// reset cursor position
self.cursor = 0;
@@ -338,6 +412,10 @@ impl<T: 'static> Component for Picker<T> {
..
}
| KeyEvent {
+ code: KeyCode::Char('k'),
+ modifiers: KeyModifiers::CONTROL,
+ }
+ | KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
} => {
@@ -351,6 +429,10 @@ impl<T: 'static> Component for Picker<T> {
code: KeyCode::Tab, ..
}
| KeyEvent {
+ code: KeyCode::Char('j'),
+ modifiers: KeyModifiers::CONTROL,
+ }
+ | KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
} => {
@@ -375,7 +457,7 @@ impl<T: 'static> Component for Picker<T> {
return close_fn;
}
KeyEvent {
- code: KeyCode::Char('h'),
+ code: KeyCode::Char('s'),
modifiers: KeyModifiers::CONTROL,
} => {
if let Some(option) = self.selection() {
@@ -485,6 +567,7 @@ impl<T: 'static> Component for Picker<T> {
text_style
},
true,
+ true,
);
}
}
diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs
index 56335fb3..593fd934 100644
--- a/helix-term/src/ui/prompt.rs
+++ b/helix-term/src/ui/prompt.rs
@@ -186,6 +186,11 @@ impl Prompt {
self.exit_selection();
}
+ pub fn insert_str(&mut self, s: &str) {
+ self.line.insert_str(self.cursor, s);
+ self.cursor += s.len();
+ }
+
pub fn move_cursor(&mut self, movement: Movement) {
let pos = self.eval_movement(movement);
self.cursor = pos
@@ -475,6 +480,26 @@ impl Component for Prompt {
(self.callback_fn)(cx, &self.line, PromptEvent::Update);
}
KeyEvent {
+ code: KeyCode::Char('s'),
+ modifiers: KeyModifiers::CONTROL,
+ } => {
+ let (view, doc) = current!(cx.editor);
+ let text = doc.text().slice(..);
+
+ use helix_core::textobject;
+ let range = textobject::textobject_word(
+ text,
+ doc.selection(view.id).primary(),
+ textobject::TextObject::Inside,
+ 1,
+ );
+ let line = text.slice(range.from()..range.to()).to_string();
+ if !line.is_empty() {
+ self.insert_str(line.as_str());
+ (self.callback_fn)(cx, &self.line, PromptEvent::Update);
+ }
+ }
+ KeyEvent {
code: KeyCode::Enter,
..
} => {
@@ -502,6 +527,7 @@ impl Component for Prompt {
if let Some(register) = self.history_register {
let register = cx.editor.registers.get_mut(register);
self.change_history(register.read(), CompletionDirection::Backward);
+ (self.callback_fn)(cx, &self.line, PromptEvent::Update);
}
}
KeyEvent {
@@ -515,15 +541,22 @@ impl Component for Prompt {
if let Some(register) = self.history_register {
let register = cx.editor.registers.get_mut(register);
self.change_history(register.read(), CompletionDirection::Forward);
+ (self.callback_fn)(cx, &self.line, PromptEvent::Update);
}
}
KeyEvent {
code: KeyCode::Tab, ..
- } => self.change_completion_selection(CompletionDirection::Forward),
+ } => {
+ self.change_completion_selection(CompletionDirection::Forward);
+ (self.callback_fn)(cx, &self.line, PromptEvent::Update)
+ }
KeyEvent {
code: KeyCode::BackTab,
..
- } => self.change_completion_selection(CompletionDirection::Backward),
+ } => {
+ self.change_completion_selection(CompletionDirection::Backward);
+ (self.callback_fn)(cx, &self.line, PromptEvent::Update)
+ }
KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,