diff options
Diffstat (limited to 'helix-term/src')
-rw-r--r-- | helix-term/src/application.rs | 30 | ||||
-rw-r--r-- | helix-term/src/commands.rs | 760 | ||||
-rw-r--r-- | helix-term/src/compositor.rs | 26 | ||||
-rw-r--r-- | helix-term/src/keymap.rs | 55 | ||||
-rw-r--r-- | helix-term/src/lib.rs | 11 | ||||
-rw-r--r-- | helix-term/src/ui/completion.rs | 6 | ||||
-rw-r--r-- | helix-term/src/ui/editor.rs | 140 | ||||
-rw-r--r-- | helix-term/src/ui/markdown.rs | 6 | ||||
-rw-r--r-- | helix-term/src/ui/menu.rs | 4 | ||||
-rw-r--r-- | helix-term/src/ui/mod.rs | 1 | ||||
-rw-r--r-- | helix-term/src/ui/picker.rs | 14 | ||||
-rw-r--r-- | helix-term/src/ui/popup.rs | 29 | ||||
-rw-r--r-- | helix-term/src/ui/prompt.rs | 4 |
13 files changed, 729 insertions, 357 deletions
diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index a795a56e..3e0b6d59 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -76,17 +76,27 @@ impl Application { None => Ok(def_lang_conf), }; - let theme = if let Some(theme) = &config.theme { - match theme_loader.load(theme) { - Ok(theme) => theme, - Err(e) => { - log::warn!("failed to load theme `{}` - {}", theme, e); + let true_color = config.editor.true_color || crate::true_color(); + let theme = config + .theme + .as_ref() + .and_then(|theme| { + theme_loader + .load(theme) + .map_err(|e| { + log::warn!("failed to load theme `{}` - {}", theme, e); + e + }) + .ok() + .filter(|theme| (true_color || theme.is_16_color())) + }) + .unwrap_or_else(|| { + if true_color { theme_loader.default() + } else { + theme_loader.base16_default() } - } - } else { - theme_loader.default() - }; + }); let syn_loader_conf: helix_core::syntax::Configuration = lang_conf .and_then(|conf| conf.try_into()) @@ -265,7 +275,7 @@ impl Application { 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 { + if doc!(self.editor).mode != Mode::Insert || !self.config.editor.auto_completion { return; } let editor_view = self diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index a7179c30..cd566720 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -1,16 +1,16 @@ use helix_core::{ comment, coords_at_pos, find_first_non_whitespace_char, find_root, graphemes, history::UndoKind, + increment::date_time::DateTimeIncrementor, + increment::{number::NumberIncrementor, Increment}, indent, indent::IndentStyle, line_ending::{get_line_ending_of_str, line_end_char_index, str_is_line_ending}, match_brackets, movement::{self, Direction}, - numbers::NumberIncrementor, object, pos_at_coords, regex::{self, Regex, RegexBuilder}, - register::Register, - search, selection, surround, textobject, + search, selection, shellwords, surround, textobject, unicode::width::UnicodeWidthChar, LineEnding, Position, Range, Rope, RopeGraphemes, RopeSlice, Selection, SmallVec, Tendril, Transaction, @@ -25,7 +25,7 @@ use helix_view::{ Document, DocumentId, Editor, ViewId, }; -use anyhow::{anyhow, bail, Context as _}; +use anyhow::{anyhow, bail, ensure, Context as _}; use helix_lsp::{ block_on, lsp, util::{lsp_pos_to_pos, lsp_range_to_range, pos_to_lsp_pos, range_to_lsp_range}, @@ -41,7 +41,7 @@ use crate::{ use crate::job::{self, Job, Jobs}; use futures_util::{FutureExt, StreamExt}; -use std::num::NonZeroUsize; +use std::{collections::HashSet, num::NonZeroUsize}; use std::{fmt, future::Future}; use std::{ @@ -70,7 +70,7 @@ pub struct Context<'a> { impl<'a> Context<'a> { /// Push a new component onto the compositor. pub fn push_layer(&mut self, component: Box<dyn Component>) { - self.callback = Some(Box::new(|compositor: &mut Compositor| { + self.callback = Some(Box::new(|compositor: &mut Compositor, _| { compositor.push(component) })); } @@ -135,47 +135,76 @@ fn align_view(doc: &Document, view: &mut View, align: Align) { view.offset.row = line.saturating_sub(relative); } -/// A command is composed of a static name, and a function that takes the current state plus a count, -/// and does a side-effect on the state (usually by creating and applying a transaction). -#[derive(Copy, Clone)] -pub struct Command { - name: &'static str, - fun: fn(cx: &mut Context), - doc: &'static str, -} - -macro_rules! commands { +/// A MappableCommand is either a static command like "jump_view_up" or a Typable command like +/// :format. It causes a side-effect on the state (usually by creating and applying a transaction). +/// Both of these types of commands can be mapped with keybindings in the config.toml. +#[derive(Clone)] +pub enum MappableCommand { + Typable { + name: String, + args: Vec<String>, + doc: String, + }, + Static { + name: &'static str, + fun: fn(cx: &mut Context), + doc: &'static str, + }, +} + +macro_rules! static_commands { ( $($name:ident, $doc:literal,)* ) => { $( #[allow(non_upper_case_globals)] - pub const $name: Self = Self { + pub const $name: Self = Self::Static { name: stringify!($name), fun: $name, doc: $doc }; )* - pub const COMMAND_LIST: &'static [Self] = &[ + pub const STATIC_COMMAND_LIST: &'static [Self] = &[ $( Self::$name, )* ]; } } -impl Command { +impl MappableCommand { pub fn execute(&self, cx: &mut Context) { - (self.fun)(cx); + match &self { + MappableCommand::Typable { name, args, doc: _ } => { + let args: Vec<Cow<str>> = args.iter().map(Cow::from).collect(); + if let Some(command) = cmd::TYPABLE_COMMAND_MAP.get(name.as_str()) { + let mut cx = compositor::Context { + editor: cx.editor, + jobs: cx.jobs, + scroll: None, + }; + if let Err(e) = (command.fun)(&mut cx, &args[..], PromptEvent::Validate) { + cx.editor.set_error(format!("{}", e)); + } + } + } + MappableCommand::Static { fun, .. } => (fun)(cx), + } } - pub fn name(&self) -> &'static str { - self.name + pub fn name(&self) -> &str { + match &self { + MappableCommand::Typable { name, .. } => name, + MappableCommand::Static { name, .. } => name, + } } - pub fn doc(&self) -> &'static str { - self.doc + pub fn doc(&self) -> &str { + match &self { + MappableCommand::Typable { doc, .. } => doc, + MappableCommand::Static { doc, .. } => doc, + } } #[rustfmt::skip] - commands!( + static_commands!( no_op, "Do nothing", move_char_left, "Move left", move_char_right, "Move right", @@ -232,7 +261,9 @@ impl Command { extend_line, "Select current line, if already selected, extend to next line", extend_to_line_bounds, "Extend selection to line bounds (line-wise selection)", delete_selection, "Delete selection", + delete_selection_noyank, "Delete selection, without yanking", change_selection, "Change selection (delete and enter insert mode)", + change_selection_noyank, "Change selection (delete and enter insert mode, without yanking)", collapse_selection, "Collapse selection onto a single cursor", flip_selections, "Flip selection cursor and anchor", insert_mode, "Insert before selection", @@ -258,11 +289,15 @@ impl Command { goto_implementation, "Goto implementation", goto_file_start, "Goto file start/line", goto_file_end, "Goto file end", + goto_file, "Goto files in selection", + goto_file_hsplit, "Goto files in selection (hsplit)", + goto_file_vsplit, "Goto files in selection (vsplit)", goto_reference, "Goto references", goto_window_top, "Goto window top", - goto_window_middle, "Goto window middle", + goto_window_center, "Goto window center", goto_window_bottom, "Goto window bottom", goto_last_accessed_file, "Goto last accessed file", + goto_last_modified_file, "Goto last modified file", goto_last_modification, "Goto last modification", goto_line, "Goto line", goto_last_line, "Goto last line", @@ -327,6 +362,7 @@ impl Command { expand_selection, "Expand selection to parent syntax node", jump_forward, "Jump forward on jumplist", jump_backward, "Jump backward on jumplist", + save_selection, "Save the current selection to the 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", @@ -359,36 +395,56 @@ impl Command { rename_symbol, "Rename symbol", increment, "Increment", decrement, "Decrement", + record_macro, "Record macro", + play_macro, "Play macro", ); } -impl fmt::Debug for Command { +impl fmt::Debug for MappableCommand { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Command { name, .. } = self; - f.debug_tuple("Command").field(name).finish() + f.debug_tuple("MappableCommand") + .field(&self.name()) + .finish() } } -impl fmt::Display for Command { +impl fmt::Display for MappableCommand { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let Command { name, .. } = self; - f.write_str(name) + f.write_str(self.name()) } } -impl std::str::FromStr for Command { +impl std::str::FromStr for MappableCommand { type Err = anyhow::Error; fn from_str(s: &str) -> Result<Self, Self::Err> { - Command::COMMAND_LIST - .iter() - .copied() - .find(|cmd| cmd.name == s) - .ok_or_else(|| anyhow!("No command named '{}'", s)) + if let Some(suffix) = s.strip_prefix(':') { + let mut typable_command = suffix.split(' ').into_iter().map(|arg| arg.trim()); + let name = typable_command + .next() + .ok_or_else(|| anyhow!("Expected typable command name"))?; + let args = typable_command + .map(|s| s.to_owned()) + .collect::<Vec<String>>(); + cmd::TYPABLE_COMMAND_MAP + .get(name) + .map(|cmd| MappableCommand::Typable { + name: cmd.name.to_owned(), + doc: format!(":{} {:?}", cmd.name, args), + args, + }) + .ok_or_else(|| anyhow!("No TypableCommand named '{}'", s)) + } else { + MappableCommand::STATIC_COMMAND_LIST + .iter() + .cloned() + .find(|cmd| cmd.name() == s) + .ok_or_else(|| anyhow!("No command named '{}'", s)) + } } } -impl<'de> Deserialize<'de> for Command { +impl<'de> Deserialize<'de> for MappableCommand { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, @@ -398,9 +454,27 @@ impl<'de> Deserialize<'de> for Command { } } -impl PartialEq for Command { +impl PartialEq for MappableCommand { fn eq(&self, other: &Self) -> bool { - self.name() == other.name() + match (self, other) { + ( + MappableCommand::Typable { + name: first_name, .. + }, + MappableCommand::Typable { + name: second_name, .. + }, + ) => first_name == second_name, + ( + MappableCommand::Static { + name: first_name, .. + }, + MappableCommand::Static { + name: second_name, .. + }, + ) => first_name == second_name, + _ => false, + } } } @@ -599,8 +673,15 @@ fn kill_to_line_end(cx: &mut Context) { let selection = doc.selection(view.id).clone().transform(|range| { let line = range.cursor_line(text); - let pos = line_end_char_index(&text, line); - range.put_cursor(text, pos, true) + let line_end_pos = line_end_char_index(&text, line); + let pos = range.cursor(text); + + let mut new_range = range.put_cursor(text, line_end_pos, true); + // don't want to remove the line separator itself if the cursor doesn't reach the end of line. + if pos != line_end_pos { + new_range.head = line_end_pos; + } + new_range }); delete_selection_insert_mode(doc, view, &selection); } @@ -729,10 +810,12 @@ fn align_fragment_to_width(fragment: &str, width: usize, align_style: usize) -> } fn goto_window(cx: &mut Context, align: Align) { + let count = cx.count() - 1; let (view, doc) = current!(cx.editor); let height = view.inner_area().height as usize; + // respect user given count if any // - 1 so we have at least one gap in the middle. // a height of 6 with padding of 3 on each side will keep shifting the view back and forth // as we type @@ -741,10 +824,11 @@ fn goto_window(cx: &mut Context, align: Align) { let last_line = view.last_line(doc); let line = match align { - Align::Top => (view.offset.row + scrolloff), - Align::Center => (view.offset.row + (height / 2)), - Align::Bottom => last_line.saturating_sub(scrolloff), + Align::Top => (view.offset.row + scrolloff + count), + Align::Center => (view.offset.row + ((last_line - view.offset.row) / 2)), + Align::Bottom => last_line.saturating_sub(scrolloff + count), } + .max(view.offset.row + scrolloff) .min(last_line.saturating_sub(scrolloff)); let pos = doc.text().line_to_char(line); @@ -756,7 +840,7 @@ fn goto_window_top(cx: &mut Context) { goto_window(cx, Align::Top) } -fn goto_window_middle(cx: &mut Context) { +fn goto_window_center(cx: &mut Context) { goto_window(cx, Align::Center) } @@ -834,6 +918,49 @@ fn goto_file_end(cx: &mut Context) { doc.set_selection(view.id, selection); } +fn goto_file(cx: &mut Context) { + goto_file_impl(cx, Action::Replace); +} + +fn goto_file_hsplit(cx: &mut Context) { + goto_file_impl(cx, Action::HorizontalSplit); +} + +fn goto_file_vsplit(cx: &mut Context) { + goto_file_impl(cx, Action::VerticalSplit); +} + +fn goto_file_impl(cx: &mut Context, action: Action) { + let (view, doc) = current_ref!(cx.editor); + let text = doc.text(); + let selections = doc.selection(view.id); + let mut paths: Vec<_> = selections + .iter() + .map(|r| text.slice(r.from()..r.to()).to_string()) + .collect(); + let primary = selections.primary(); + if selections.len() == 1 && primary.to() - primary.from() == 1 { + let current_word = movement::move_next_long_word_start( + text.slice(..), + movement::move_prev_long_word_start(text.slice(..), primary, 1), + 1, + ); + paths.clear(); + paths.push( + text.slice(current_word.from()..current_word.to()) + .to_string(), + ); + } + for sel in paths { + let p = sel.trim(); + if !p.is_empty() { + if let Err(e) = cx.editor.open(PathBuf::from(p), action) { + cx.editor.set_error(format!("Open file failed: {:?}", e)); + } + } + } +} + fn extend_word_impl<F>(cx: &mut Context, extend_fn: F) where F: Fn(RopeSlice, Range, usize) -> Range, @@ -1693,19 +1820,42 @@ fn extend_to_line_bounds(cx: &mut Context) { ); } -fn delete_selection_impl(reg: &mut Register, doc: &mut Document, view_id: ViewId) { +enum Operation { + Delete, + Change, +} + +fn delete_selection_impl(cx: &mut Context, op: Operation) { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); - let selection = doc.selection(view_id); + let selection = doc.selection(view.id); - // first yank the selection - let values: Vec<String> = selection.fragments(text).map(Cow::into_owned).collect(); - reg.write(values); + if cx.register != Some('_') { + // first yank the selection + let values: Vec<String> = selection.fragments(text).map(Cow::into_owned).collect(); + let reg_name = cx.register.unwrap_or('"'); + let registers = &mut cx.editor.registers; + let reg = registers.get_mut(reg_name); + reg.write(values); + }; // then delete let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { (range.from(), range.to(), None) }); - doc.apply(&transaction, view_id); + doc.apply(&transaction, view.id); + + match op { + Operation::Delete => { + doc.append_changes_to_history(view.id); + // exit select mode, if currently in select mode + exit_select_mode(cx); + } + Operation::Change => { + enter_insert_mode(doc); + } + } } #[inline] @@ -1720,25 +1870,21 @@ fn delete_selection_insert_mode(doc: &mut Document, view: &View, selection: &Sel } fn delete_selection(cx: &mut Context) { - let reg_name = cx.register.unwrap_or('"'); - let (view, doc) = current!(cx.editor); - let registers = &mut cx.editor.registers; - let reg = registers.get_mut(reg_name); - delete_selection_impl(reg, doc, view.id); - - doc.append_changes_to_history(view.id); + delete_selection_impl(cx, Operation::Delete); +} - // exit select mode, if currently in select mode - exit_select_mode(cx); +fn delete_selection_noyank(cx: &mut Context) { + cx.register = Some('_'); + delete_selection_impl(cx, Operation::Delete); } fn change_selection(cx: &mut Context) { - let reg_name = cx.register.unwrap_or('"'); - let (view, doc) = current!(cx.editor); - let registers = &mut cx.editor.registers; - let reg = registers.get_mut(reg_name); - delete_selection_impl(reg, doc, view.id); - enter_insert_mode(doc); + delete_selection_impl(cx, Operation::Change); +} + +fn change_selection_noyank(cx: &mut Context) { + cx.register = Some('_'); + delete_selection_impl(cx, Operation::Change); } fn collapse_selection(cx: &mut Context) { @@ -1806,7 +1952,7 @@ fn append_mode(cx: &mut Context) { doc.set_selection(view.id, selection); } -mod cmd { +pub mod cmd { use super::*; use std::collections::HashMap; @@ -1819,13 +1965,13 @@ mod cmd { pub aliases: &'static [&'static str], pub doc: &'static str, // params, flags, helper, completer - pub fun: fn(&mut compositor::Context, &[&str], PromptEvent) -> anyhow::Result<()>, + pub fun: fn(&mut compositor::Context, &[Cow<str>], PromptEvent) -> anyhow::Result<()>, pub completer: Option<Completer>, } fn quit( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { // last view and we have unsaved changes @@ -1840,7 +1986,7 @@ mod cmd { fn force_quit( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor.close(view!(cx.editor).id); @@ -1850,17 +1996,19 @@ mod cmd { fn open( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - let path = args.get(0).context("wrong argument count")?; - let _ = cx.editor.open(path.into(), Action::Replace)?; + ensure!(!args.is_empty(), "wrong argument count"); + for arg in args { + let _ = cx.editor.open(arg.as_ref().into(), Action::Replace)?; + } Ok(()) } fn buffer_close( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let view = view!(cx.editor); @@ -1871,7 +2019,7 @@ mod cmd { fn force_buffer_close( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let view = view!(cx.editor); @@ -1880,15 +2028,12 @@ mod cmd { Ok(()) } - fn write_impl<P: AsRef<Path>>( - cx: &mut compositor::Context, - path: Option<P>, - ) -> anyhow::Result<()> { + fn write_impl(cx: &mut compositor::Context, path: Option<&Cow<str>>) -> anyhow::Result<()> { let jobs = &mut cx.jobs; let (_, doc) = current!(cx.editor); - if let Some(path) = path { - doc.set_path(Some(path.as_ref())) + if let Some(ref path) = path { + doc.set_path(Some(path.as_ref().as_ref())) .context("invalid filepath")?; } if doc.path().is_none() { @@ -1907,12 +2052,17 @@ mod cmd { }); let future = doc.format_and_save(fmt); cx.jobs.add(Job::new(future).wait_before_exiting()); + + if path.is_some() { + let id = doc.id(); + let _ = cx.editor.refresh_language_server(id); + } Ok(()) } fn write( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first()) @@ -1920,7 +2070,7 @@ mod cmd { fn new_file( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor.new_file(Action::Replace); @@ -1930,7 +2080,7 @@ mod cmd { fn format( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let (_, doc) = current!(cx.editor); @@ -1945,7 +2095,7 @@ mod cmd { } fn set_indent_style( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { use IndentStyle::*; @@ -1965,7 +2115,7 @@ mod cmd { // Attempt to parse argument as an indent style. let style = match args.get(0) { Some(arg) if "tabs".starts_with(&arg.to_lowercase()) => Some(Tabs), - Some(&"0") => Some(Tabs), + Some(Cow::Borrowed("0")) => Some(Tabs), Some(arg) => arg .parse::<u8>() .ok() @@ -1984,7 +2134,7 @@ mod cmd { /// Sets or reports the current document's line ending setting. fn set_line_ending( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { use LineEnding::*; @@ -2028,7 +2178,7 @@ mod cmd { fn earlier( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let uk = args.join(" ").parse::<UndoKind>().map_err(|s| anyhow!(s))?; @@ -2044,7 +2194,7 @@ mod cmd { fn later( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let uk = args.join(" ").parse::<UndoKind>().map_err(|s| anyhow!(s))?; @@ -2059,7 +2209,7 @@ mod cmd { fn write_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first())?; @@ -2068,7 +2218,7 @@ mod cmd { fn force_write_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], event: PromptEvent, ) -> anyhow::Result<()> { write_impl(cx, args.first())?; @@ -2099,7 +2249,7 @@ mod cmd { fn write_all_impl( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, quit: bool, force: bool, @@ -2135,7 +2285,7 @@ mod cmd { fn write_all( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, false, false) @@ -2143,7 +2293,7 @@ mod cmd { fn write_all_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, true, false) @@ -2151,7 +2301,7 @@ mod cmd { fn force_write_all_quit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], event: PromptEvent, ) -> anyhow::Result<()> { write_all_impl(cx, args, event, true, true) @@ -2159,7 +2309,7 @@ mod cmd { fn quit_all_impl( editor: &mut Editor, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, force: bool, ) -> anyhow::Result<()> { @@ -2178,23 +2328,23 @@ mod cmd { fn quit_all( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], event: PromptEvent, ) -> anyhow::Result<()> { - quit_all_impl(&mut cx.editor, args, event, false) + quit_all_impl(cx.editor, args, event, false) } fn force_quit_all( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], event: PromptEvent, ) -> anyhow::Result<()> { - quit_all_impl(&mut cx.editor, args, event, true) + quit_all_impl(cx.editor, args, event, true) } fn cquit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let exit_code = args @@ -2213,85 +2363,91 @@ mod cmd { fn theme( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - let theme = args.first().context("theme not provided")?; - cx.editor.set_theme_from_name(theme) + let theme = args.first().context("Theme not provided")?; + let theme = cx + .editor + .theme_loader + .load(theme) + .with_context(|| format!("Failed setting theme {}", theme))?; + let true_color = cx.editor.config.true_color || crate::true_color(); + if !(true_color || theme.is_16_color()) { + bail!("Unsupported theme: theme requires true color support"); + } + cx.editor.set_theme(theme); + Ok(()) } fn yank_main_selection_to_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard) + yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard) } fn yank_joined_to_clipboard( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let (_, doc) = current!(cx.editor); - let separator = args - .first() - .copied() - .unwrap_or_else(|| doc.line_ending.as_str()); - yank_joined_to_clipboard_impl(&mut cx.editor, separator, ClipboardType::Clipboard) + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); + yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Clipboard) } fn yank_main_selection_to_primary_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Selection) + yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection) } fn yank_joined_to_primary_clipboard( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let (_, doc) = current!(cx.editor); - let separator = args - .first() - .copied() - .unwrap_or_else(|| doc.line_ending.as_str()); - yank_joined_to_clipboard_impl(&mut cx.editor, separator, ClipboardType::Selection) + let default_sep = Cow::Borrowed(doc.line_ending.as_str()); + let separator = args.first().unwrap_or(&default_sep); + yank_joined_to_clipboard_impl(cx.editor, separator, ClipboardType::Selection) } fn paste_clipboard_after( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) } fn paste_clipboard_before( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Clipboard, 1) } fn paste_primary_clipboard_after( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) } fn paste_primary_clipboard_before( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { - paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection) + paste_clipboard_impl(cx.editor, Paste::After, ClipboardType::Selection, 1) } fn replace_selections_with_clipboard_impl( @@ -2318,7 +2474,7 @@ mod cmd { fn replace_selections_with_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { replace_selections_with_clipboard_impl(cx, ClipboardType::Clipboard) @@ -2326,7 +2482,7 @@ mod cmd { fn replace_selections_with_primary_clipboard( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { replace_selections_with_clipboard_impl(cx, ClipboardType::Selection) @@ -2334,7 +2490,7 @@ mod cmd { fn show_clipboard_provider( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { cx.editor @@ -2344,12 +2500,13 @@ mod cmd { fn change_current_directory( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let dir = helix_core::path::expand_tilde( args.first() .context("target directory not provided")? + .as_ref() .as_ref(), ); @@ -2367,7 +2524,7 @@ mod cmd { fn show_current_directory( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let cwd = std::env::current_dir().context("Couldn't get the new working directory")?; @@ -2379,7 +2536,7 @@ mod cmd { /// Sets the [`Document`]'s encoding.. fn set_encoding( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let (_, doc) = current!(cx.editor); @@ -2395,7 +2552,7 @@ mod cmd { /// Reload the [`Document`] from its source file. fn reload( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let (view, doc) = current!(cx.editor); @@ -2404,7 +2561,7 @@ mod cmd { fn tree_sitter_scopes( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let (view, doc) = current!(cx.editor); @@ -2418,15 +2575,18 @@ mod cmd { fn vsplit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let id = view!(cx.editor).doc; - if let Some(path) = args.get(0) { - cx.editor.open(path.into(), Action::VerticalSplit)?; - } else { + if args.is_empty() { cx.editor.switch(id, Action::VerticalSplit); + } else { + for arg in args { + cx.editor + .open(PathBuf::from(arg.as_ref()), Action::VerticalSplit)?; + } } Ok(()) @@ -2434,15 +2594,18 @@ mod cmd { fn hsplit( cx: &mut compositor::Context, - args: &[&str], + args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let id = view!(cx.editor).doc; - if let Some(path) = args.get(0) { - cx.editor.open(path.into(), Action::HorizontalSplit)?; - } else { + if args.is_empty() { cx.editor.switch(id, Action::HorizontalSplit); + } else { + for arg in args { + cx.editor + .open(PathBuf::from(arg.as_ref()), Action::HorizontalSplit)?; + } } Ok(()) @@ -2450,7 +2613,7 @@ mod cmd { fn tutor( cx: &mut compositor::Context, - _args: &[&str], + _args: &[Cow<str>], _event: PromptEvent, ) -> anyhow::Result<()> { let path = helix_core::runtime_dir().join("tutor.txt"); @@ -2460,6 +2623,24 @@ mod cmd { Ok(()) } + pub(super) fn goto_line_number( + cx: &mut compositor::Context, + args: &[Cow<str>], + _event: PromptEvent, + ) -> anyhow::Result<()> { + 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(()) + } + pub const TYPABLE_COMMAND_LIST: &[TypableCommand] = &[ TypableCommand { name: "quit", @@ -2513,7 +2694,7 @@ mod cmd { TypableCommand { name: "format", aliases: &["fmt"], - doc: "Format the file using a formatter.", + doc: "Format the file using the LSP formatter.", fun: format, completer: None, }, @@ -2604,7 +2785,7 @@ mod cmd { TypableCommand { name: "theme", aliases: &[], - doc: "Change the theme of current view. Requires theme name as argument (:theme <name>)", + doc: "Change the editor theme.", fun: theme, completer: Some(completers::theme), }, @@ -2688,7 +2869,7 @@ mod cmd { TypableCommand { name: "change-current-directory", aliases: &["cd"], - doc: "Change the current working directory (:cd <dir>).", + doc: "Change the current working directory.", fun: change_current_directory, completer: Some(completers::directory), }, @@ -2741,17 +2922,25 @@ mod cmd { fun: tutor, completer: None, }, + TypableCommand { + name: "goto", + aliases: &["g"], + doc: "Go to line number.", + fun: goto_line_number, + completer: None, + } ]; - pub static COMMANDS: Lazy<HashMap<&'static str, &'static TypableCommand>> = Lazy::new(|| { - TYPABLE_COMMAND_LIST - .iter() - .flat_map(|cmd| { - std::iter::once((cmd.name, cmd)) - .chain(cmd.aliases.iter().map(move |&alias| (alias, cmd))) - }) - .collect() - }); + pub static TYPABLE_COMMAND_MAP: Lazy<HashMap<&'static str, &'static TypableCommand>> = + Lazy::new(|| { + TYPABLE_COMMAND_LIST + .iter() + .flat_map(|cmd| { + std::iter::once((cmd.name, cmd)) + .chain(cmd.aliases.iter().map(move |&alias| (alias, cmd))) + }) + .collect() + }); } fn command_mode(cx: &mut Context) { @@ -2777,7 +2966,7 @@ fn command_mode(cx: &mut Context) { if let Some(cmd::TypableCommand { completer: Some(completer), .. - }) = cmd::COMMANDS.get(parts[0]) + }) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) { completer(part) .into_iter() @@ -2803,8 +2992,18 @@ fn command_mode(cx: &mut Context) { return; } - if let Some(cmd) = cmd::COMMANDS.get(parts[0]) { - if let Err(e) = (cmd.fun)(cx, &parts[1..], event) { + // If command is numeric, interpret as line number and go there. + if parts.len() == 1 && parts[0].parse::<usize>().ok().is_some() { + if let Err(e) = cmd::goto_line_number(cx, &[Cow::from(parts[0])], event) { + cx.editor.set_error(format!("{}", e)); + } + return; + } + + // Handle typable commands + if let Some(cmd) = cmd::TYPABLE_COMMAND_MAP.get(parts[0]) { + let args = shellwords::shellwords(input); + if let Err(e) = (cmd.fun)(cx, &args[1..], event) { cx.editor.set_error(format!("{}", e)); } } else { @@ -2816,7 +3015,7 @@ fn command_mode(cx: &mut Context) { prompt.doc_fn = Box::new(|input: &str| { let part = input.split(' ').next().unwrap_or_default(); - if let Some(cmd::TypableCommand { doc, .. }) = cmd::COMMANDS.get(part) { + if let Some(cmd::TypableCommand { doc, .. }) = cmd::TYPABLE_COMMAND_MAP.get(part) { return Some(doc); } @@ -3254,7 +3453,7 @@ fn apply_workspace_edit( fn last_picker(cx: &mut Context) { // TODO: last picker does not seem to work well with buffer_picker - cx.callback = Some(Box::new(|compositor: &mut Compositor| { + cx.callback = Some(Box::new(|compositor: &mut Compositor, _| { if let Some(picker) = compositor.last_picker.take() { compositor.push(picker); } @@ -3436,10 +3635,14 @@ fn push_jump(editor: &mut Editor) { } fn goto_line(cx: &mut Context) { - if let Some(count) = cx.count { - push_jump(cx.editor); + goto_line_impl(cx.editor, cx.count) +} - let (view, doc) = current!(cx.editor); +fn goto_line_impl(editor: &mut Editor, count: Option<NonZeroUsize>) { + if let Some(count) = count { + push_jump(editor); + + let (view, doc) = current!(editor); let max_line = if doc.text().line(doc.text().len_lines() - 1).len_chars() == 0 { // If the last line is blank, don't jump to it. doc.text().len_lines().saturating_sub(2) @@ -3498,6 +3701,20 @@ fn goto_last_modification(cx: &mut Context) { } } +fn goto_last_modified_file(cx: &mut Context) { + let view = view!(cx.editor); + let alternate_file = view + .last_modified_docs + .into_iter() + .flatten() + .find(|&id| id != view.doc); + if let Some(alt) = alternate_file { + cx.editor.switch(alt, Action::Replace); + } else { + cx.editor.set_error("no last modified buffer".to_owned()) + } +} + fn select_mode(cx: &mut Context) { let (view, doc) = current!(cx.editor); let text = doc.text().slice(..); @@ -3982,8 +4199,9 @@ pub mod insert { // The default insert hook: simply insert the character #[allow(clippy::unnecessary_wraps)] // need to use Option<> because of the Hook signature fn insert(doc: &Rope, selection: &Selection, ch: char) -> Option<Transaction> { + let cursors = selection.clone().cursors(doc.slice(..)); let t = Tendril::from_char(ch); - let transaction = Transaction::insert(doc, selection, t); + let transaction = Transaction::insert(doc, &cursors, t); Some(transaction) } @@ -3998,11 +4216,11 @@ pub mod insert { }; let text = doc.text(); - let selection = doc.selection(view.id).clone().cursors(text.slice(..)); + let selection = doc.selection(view.id); // run through insert hooks, stopping on the first one that returns Some(t) for hook in hooks { - if let Some(transaction) = hook(text, &selection, c) { + if let Some(transaction) = hook(text, selection, c) { doc.apply(&transaction, view.id); break; } @@ -4317,11 +4535,8 @@ fn yank_joined_to_clipboard_impl( fn yank_joined_to_clipboard(cx: &mut Context) { let line_ending = doc!(cx.editor).line_ending; - let _ = yank_joined_to_clipboard_impl( - &mut cx.editor, - line_ending.as_str(), - ClipboardType::Clipboard, - ); + let _ = + yank_joined_to_clipboard_impl(cx.editor, line_ending.as_str(), ClipboardType::Clipboard); exit_select_mode(cx); } @@ -4346,20 +4561,17 @@ fn yank_main_selection_to_clipboard_impl( } fn yank_main_selection_to_clipboard(cx: &mut Context) { - let _ = yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard); + let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Clipboard); } fn yank_joined_to_primary_clipboard(cx: &mut Context) { let line_ending = doc!(cx.editor).line_ending; - let _ = yank_joined_to_clipboard_impl( - &mut cx.editor, - line_ending.as_str(), - ClipboardType::Selection, - ); + let _ = + yank_joined_to_clipboard_impl(cx.editor, line_ending.as_str(), ClipboardType::Selection); } fn yank_main_selection_to_primary_clipboard(cx: &mut Context) { - let _ = yank_main_selection_to_clipboard_impl(&mut cx.editor, ClipboardType::Selection); + let _ = yank_main_selection_to_clipboard_impl(cx.editor, ClipboardType::Selection); exit_select_mode(cx); } @@ -4374,11 +4586,12 @@ fn paste_impl( doc: &mut Document, view: &View, action: Paste, + count: usize, ) -> Option<Transaction> { let repeat = std::iter::repeat( values .last() - .map(|value| Tendril::from_slice(value)) + .map(|value| Tendril::from(value.repeat(count))) .unwrap(), ); @@ -4393,7 +4606,7 @@ fn paste_impl( let mut values = values .iter() .map(|value| REGEX.replace_all(value, doc.line_ending.as_str())) - .map(|value| Tendril::from(value.as_ref())) + .map(|value| Tendril::from(value.as_ref().repeat(count))) .chain(repeat); let text = doc.text(); @@ -4413,7 +4626,7 @@ fn paste_impl( // paste append (Paste::After, false) => range.to(), }; - (pos, pos, Some(values.next().unwrap())) + (pos, pos, values.next()) }); Some(transaction) @@ -4423,13 +4636,14 @@ fn paste_clipboard_impl( editor: &mut Editor, action: Paste, clipboard_type: ClipboardType, + count: usize, ) -> anyhow::Result<()> { let (view, doc) = current!(editor); match editor .clipboard_provider .get_contents(clipboard_type) - .map(|contents| paste_impl(&[contents], doc, view, action)) + .map(|contents| paste_impl(&[contents], doc, view, action, count)) { Ok(Some(transaction)) => { doc.apply(&transaction, view.id); @@ -4442,22 +4656,43 @@ fn paste_clipboard_impl( } fn paste_clipboard_after(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Clipboard); + let _ = paste_clipboard_impl( + cx.editor, + Paste::After, + ClipboardType::Clipboard, + cx.count(), + ); } fn paste_clipboard_before(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::Before, ClipboardType::Clipboard); + let _ = paste_clipboard_impl( + cx.editor, + Paste::Before, + ClipboardType::Clipboard, + cx.count(), + ); } fn paste_primary_clipboard_after(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::After, ClipboardType::Selection); + let _ = paste_clipboard_impl( + cx.editor, + Paste::After, + ClipboardType::Selection, + cx.count(), + ); } fn paste_primary_clipboard_before(cx: &mut Context) { - let _ = paste_clipboard_impl(&mut cx.editor, Paste::Before, ClipboardType::Selection); + let _ = paste_clipboard_impl( + cx.editor, + Paste::Before, + ClipboardType::Selection, + cx.count(), + ); } fn replace_with_yanked(cx: &mut Context) { + let count = cx.count(); let reg_name = cx.register.unwrap_or('"'); let (view, doc) = current!(cx.editor); let registers = &mut cx.editor.registers; @@ -4467,12 +4702,12 @@ fn replace_with_yanked(cx: &mut Context) { let repeat = std::iter::repeat( values .last() - .map(|value| Tendril::from_slice(value)) + .map(|value| Tendril::from_slice(&value.repeat(count))) .unwrap(), ); let mut values = values .iter() - .map(|value| Tendril::from_slice(value)) + .map(|value| Tendril::from_slice(&value.repeat(count))) .chain(repeat); let selection = doc.selection(view.id); let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { @@ -4492,6 +4727,7 @@ fn replace_with_yanked(cx: &mut Context) { fn replace_selections_with_clipboard_impl( editor: &mut Editor, clipboard_type: ClipboardType, + count: usize, ) -> anyhow::Result<()> { let (view, doc) = current!(editor); @@ -4499,7 +4735,11 @@ fn replace_selections_with_clipboard_impl( Ok(contents) => { let selection = doc.selection(view.id); let transaction = Transaction::change_by_selection(doc.text(), selection, |range| { - (range.from(), range.to(), Some(contents.as_str().into())) + ( + range.from(), + range.to(), + Some(contents.repeat(count).as_str().into()), + ) }); doc.apply(&transaction, view.id); @@ -4511,21 +4751,22 @@ fn replace_selections_with_clipboard_impl( } fn replace_selections_with_clipboard(cx: &mut Context) { - let _ = replace_selections_with_clipboard_impl(&mut cx.editor, ClipboardType::Clipboard); + let _ = replace_selections_with_clipboard_impl(cx.editor, ClipboardType::Clipboard, cx.count()); } fn replace_selections_with_primary_clipboard(cx: &mut Context) { - let _ = replace_selections_with_clipboard_impl(&mut cx.editor, ClipboardType::Selection); + let _ = replace_selections_with_clipboard_impl(cx.editor, ClipboardType::Selection, cx.count()); } fn paste_after(cx: &mut Context) { + let count = cx.count(); let reg_name = cx.register.unwrap_or('"'); let (view, doc) = current!(cx.editor); let registers = &mut cx.editor.registers; if let Some(transaction) = registers .read(reg_name) - .and_then(|values| paste_impl(values, doc, view, Paste::After)) + .and_then(|values| paste_impl(values, doc, view, Paste::After, count)) { doc.apply(&transaction, view.id); doc.append_changes_to_history(view.id); @@ -4533,13 +4774,14 @@ fn paste_after(cx: &mut Context) { } fn paste_before(cx: &mut Context) { + let count = cx.count(); let reg_name = cx.register.unwrap_or('"'); let (view, doc) = current!(cx.editor); let registers = &mut cx.editor.registers; if let Some(transaction) = registers .read(reg_name) - .and_then(|values| paste_impl(values, doc, view, Paste::Before)) + .and_then(|values| paste_impl(values, doc, view, Paste::Before, count)) { doc.apply(&transaction, view.id); doc.append_changes_to_history(view.id); @@ -4935,8 +5177,12 @@ fn hover(cx: &mut Context) { // skip if contents empty let contents = ui::Markdown::new(contents, editor.syn_loader.clone()); - let popup = Popup::new(contents); - compositor.push(Box::new(popup)); + let popup = Popup::new("documentation", contents); + if let Some(doc_popup) = compositor.find_id("documentation") { + *doc_popup = popup; + } else { + compositor.push(Box::new(popup)); + } } }, ); @@ -5030,7 +5276,7 @@ fn expand_selection(cx: &mut Context) { doc.set_selection(view.id, selection); } }; - motion(&mut cx.editor); + motion(cx.editor); cx.editor.last_motion = Some(Motion(Box::new(motion))); } @@ -5086,6 +5332,12 @@ fn jump_backward(cx: &mut Context) { }; } +fn save_selection(cx: &mut Context) { + push_jump(cx.editor); + cx.editor + .set_status("Selection saved to jumplist".to_owned()); +} + fn rotate_view(cx: &mut Context) { cx.editor.focus_next() } @@ -5262,7 +5514,7 @@ fn select_textobject(cx: &mut Context, objtype: textobject::TextObject) { }); doc.set_selection(view.id, selection); }; - textobject(&mut cx.editor); + textobject(cx.editor); cx.editor.last_motion = Some(Motion(Box::new(textobject))); } }) @@ -5428,9 +5680,7 @@ fn shell_impl( ) -> anyhow::Result<(Tendril, bool)> { use std::io::Write; use std::process::{Command, Stdio}; - if shell.is_empty() { - bail!("No shell set"); - } + ensure!(!shell.is_empty(), "No shell set"); let mut process = match Command::new(&shell[0]) .args(&shell[1..]) @@ -5594,7 +5844,7 @@ fn rename_symbol(cx: &mut Context) { let task = language_server.rename_symbol(doc.identifier(), pos, input.to_string()); let edits = block_on(task).unwrap_or_default(); log::debug!("Edits from LSP: {:?}", edits); - apply_workspace_edit(&mut cx.editor, offset_encoding, &edits); + apply_workspace_edit(cx.editor, offset_encoding, &edits); }, ); cx.push_layer(Box::new(prompt)); @@ -5614,16 +5864,45 @@ fn decrement(cx: &mut Context) { fn increment_impl(cx: &mut Context, amount: i64) { let (view, doc) = current!(cx.editor); let selection = doc.selection(view.id); - let text = doc.text(); + let text = doc.text().slice(..); + + let changes: Vec<_> = selection + .ranges() + .iter() + .filter_map(|range| { + let incrementor: Box<dyn Increment> = + if let Some(incrementor) = DateTimeIncrementor::from_range(text, *range) { + Box::new(incrementor) + } else if let Some(incrementor) = NumberIncrementor::from_range(text, *range) { + Box::new(incrementor) + } else { + return None; + }; + + let (range, new_text) = incrementor.increment(amount); + + Some((range.from(), range.to(), Some(new_text))) + }) + .collect(); - let changes = selection.ranges().iter().filter_map(|range| { - let incrementor = NumberIncrementor::from_range(text.slice(..), *range)?; - let new_text = incrementor.incremented_text(amount); - Some(( - incrementor.range.from(), - incrementor.range.to(), - Some(new_text), - )) + // Overlapping changes in a transaction will panic, so we need to find and remove them. + // For example, if there are cursors on each of the year, month, and day of `2021-11-29`, + // incrementing will give overlapping changes, with each change incrementing a different part of + // the date. Since these conflict with each other we remove these changes from the transaction + // so nothing happens. + let mut overlapping_indexes = HashSet::new(); + for (i, changes) in changes.windows(2).enumerate() { + if changes[0].1 > changes[1].0 { + overlapping_indexes.insert(i); + overlapping_indexes.insert(i + 1); + } + } + let changes = changes.into_iter().enumerate().filter_map(|(i, change)| { + if overlapping_indexes.contains(&i) { + None + } else { + Some(change) + } }); if changes.clone().count() > 0 { @@ -5634,3 +5913,56 @@ fn increment_impl(cx: &mut Context, amount: i64) { doc.append_changes_to_history(view.id); } } + +fn record_macro(cx: &mut Context) { + if let Some((reg, mut keys)) = cx.editor.macro_recording.take() { + // Remove the keypress which ends the recording + keys.pop(); + let s = keys + .into_iter() + .map(|key| format!("{}", key)) + .collect::<Vec<_>>() + .join(" "); + cx.editor.registers.get_mut(reg).write(vec![s]); + cx.editor + .set_status(format!("Recorded to register {}", reg)); + } else { + let reg = cx.register.take().unwrap_or('@'); + cx.editor.macro_recording = Some((reg, Vec::new())); + cx.editor + .set_status(format!("Recording to register {}", reg)); + } +} + +fn play_macro(cx: &mut Context) { + let reg = cx.register.unwrap_or('@'); + let keys = match cx + .editor + .registers + .get(reg) + .and_then(|reg| reg.read().get(0)) + .context("Register empty") + .and_then(|s| { + s.split_whitespace() + .map(str::parse::<KeyEvent>) + .collect::<Result<Vec<_>, _>>() + .context("Failed to parse macro") + }) { + Ok(keys) => keys, + Err(e) => { + cx.editor.set_error(format!("{}", e)); + return; + } + }; + let count = cx.count(); + + cx.callback = Some(Box::new( + move |compositor: &mut Compositor, cx: &mut compositor::Context| { + for _ in 0..count { + for &key in keys.iter() { + compositor.handle_event(crossterm::event::Event::Key(key.into()), cx); + } + } + }, + )); +} diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 3a644750..321f56a5 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -7,7 +7,7 @@ use helix_view::graphics::{CursorKind, Rect}; use crossterm::event::Event; use tui::buffer::Buffer as Surface; -pub type Callback = Box<dyn FnOnce(&mut Compositor)>; +pub type Callback = Box<dyn FnOnce(&mut Compositor, &mut Context)>; // --> EventResult should have a callback that takes a context with methods like .popup(), // .prompt() etc. That way we can abstract it from the renderer. @@ -55,15 +55,20 @@ pub trait Component: Any + AnyComponent { /// May be used by the parent component to compute the child area. /// viewport is the maximum allowed area, and the child should stay within those bounds. + /// + /// The returned size might be larger than the viewport if the child is too big to fit. + /// In this case the parent can use the values to calculate scroll. fn required_size(&mut self, _viewport: (u16, u16)) -> Option<(u16, u16)> { - // TODO: for scrolling, the scroll wrapper should place a size + offset on the Context - // that way render can use it None } fn type_name(&self) -> &'static str { std::any::type_name::<Self>() } + + fn id(&self) -> Option<&'static str> { + None + } } use anyhow::Error; @@ -126,12 +131,17 @@ impl Compositor { } pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool { + // If it is a key event and a macro is being recorded, push the key event to the recording. + if let (Event::Key(key), Some((_, keys))) = (event, &mut cx.editor.macro_recording) { + keys.push(key.into()); + } + // propagate events through the layers until we either find a layer that consumes it or we // run out of layers (event bubbling) for layer in self.layers.iter_mut().rev() { match layer.handle_event(event, cx) { EventResult::Consumed(Some(callback)) => { - callback(self); + callback(self, cx); return true; } EventResult::Consumed(None) => return true, @@ -184,6 +194,14 @@ impl Compositor { .find(|component| component.type_name() == type_name) .and_then(|component| component.as_any_mut().downcast_mut()) } + + pub fn find_id<T: 'static>(&mut self, id: &'static str) -> Option<&mut T> { + let type_name = std::any::type_name::<T>(); + self.layers + .iter_mut() + .find(|component| component.type_name() == type_name && component.id() == Some(id)) + .and_then(|component| component.as_any_mut().downcast_mut()) + } } // View casting, taken straight from Cursive diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 7f6d0c6b..257d5f29 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -1,4 +1,4 @@ -pub use crate::commands::Command; +pub use crate::commands::MappableCommand; use crate::config::Config; use helix_core::hashmap; use helix_view::{document::Mode, info::Info, input::KeyEvent}; @@ -92,7 +92,7 @@ macro_rules! alt { #[macro_export] macro_rules! keymap { (@trie $cmd:ident) => { - $crate::keymap::KeyTrie::Leaf($crate::commands::Command::$cmd) + $crate::keymap::KeyTrie::Leaf($crate::commands::MappableCommand::$cmd) }; (@trie @@ -120,7 +120,7 @@ macro_rules! keymap { _key, keymap!(@trie $value) ); - debug_assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); + assert!(_duplicate.is_none(), "Duplicate key found: {:?}", _duplicate.unwrap()); _order.push(_key); )+ )* @@ -260,8 +260,8 @@ impl DerefMut for KeyTrieNode { #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(untagged)] pub enum KeyTrie { - Leaf(Command), - Sequence(Vec<Command>), + Leaf(MappableCommand), + Sequence(Vec<MappableCommand>), Node(KeyTrieNode), } @@ -304,9 +304,9 @@ impl KeyTrie { pub enum KeymapResultKind { /// Needs more keys to execute a command. Contains valid keys for next keystroke. Pending(KeyTrieNode), - Matched(Command), + Matched(MappableCommand), /// Matched a sequence of commands to execute. - MatchedSequence(Vec<Command>), + MatchedSequence(Vec<MappableCommand>), /// Key was not found in the root keymap NotFound, /// Key is invalid in combination with previous keys. Contains keys leading upto @@ -386,10 +386,10 @@ impl Keymap { }; let trie = match trie_node.search(&[*first]) { - Some(&KeyTrie::Leaf(cmd)) => { - return KeymapResult::new(KeymapResultKind::Matched(cmd), self.sticky()) + Some(KeyTrie::Leaf(ref cmd)) => { + return KeymapResult::new(KeymapResultKind::Matched(cmd.clone()), self.sticky()) } - Some(&KeyTrie::Sequence(ref cmds)) => { + Some(KeyTrie::Sequence(ref cmds)) => { return KeymapResult::new( KeymapResultKind::MatchedSequence(cmds.clone()), self.sticky(), @@ -408,9 +408,9 @@ impl Keymap { } KeymapResult::new(KeymapResultKind::Pending(map.clone()), self.sticky()) } - Some(&KeyTrie::Leaf(cmd)) => { + Some(&KeyTrie::Leaf(ref cmd)) => { self.state.clear(); - return KeymapResult::new(KeymapResultKind::Matched(cmd), self.sticky()); + return KeymapResult::new(KeymapResultKind::Matched(cmd.clone()), self.sticky()); } Some(&KeyTrie::Sequence(ref cmds)) => { self.state.clear(); @@ -512,6 +512,7 @@ impl Default for Keymaps { "g" => { "Goto" "g" => goto_file_start, "e" => goto_last_line, + "f" => goto_file, "h" => goto_line_start, "l" => goto_line_end, "s" => goto_first_nonwhitespace, @@ -520,9 +521,10 @@ impl Default for Keymaps { "r" => goto_reference, "i" => goto_implementation, "t" => goto_window_top, - "m" => goto_window_middle, + "c" => goto_window_center, "b" => goto_window_bottom, "a" => goto_last_accessed_file, + "m" => goto_last_modified_file, "n" => goto_next_buffer, "p" => goto_previous_buffer, "." => goto_last_modification, @@ -537,9 +539,9 @@ impl Default for Keymaps { "O" => open_above, "d" => delete_selection, - // TODO: also delete without yanking + "A-d" => delete_selection_noyank, "c" => change_selection, - // TODO: also change delete without yanking + "A-c" => change_selection_noyank, "C" => copy_selection_on_next_line, "A-C" => copy_selection_on_prev_line, @@ -591,6 +593,9 @@ impl Default for Keymaps { // paste_all "P" => paste_before, + "q" => record_macro, + "Q" => play_macro, + ">" => indent, "<" => unindent, "=" => format_selections, @@ -622,6 +627,8 @@ impl Default for Keymaps { "C-w" | "w" => rotate_view, "C-s" | "s" => hsplit, "C-v" | "v" => vsplit, + "f" => goto_file_hsplit, + "F" => goto_file_vsplit, "C-q" | "q" => wclose, "C-o" | "o" => wonly, "C-h" | "h" | "left" => jump_view_left, @@ -637,7 +644,7 @@ impl Default for Keymaps { "tab" => jump_forward, // tab == <C-i> "C-o" => jump_backward, - // "C-s" => save_selection, + "C-s" => save_selection, "space" => { "Space" "f" => file_picker, @@ -650,6 +657,8 @@ impl Default for Keymaps { "C-w" | "w" => rotate_view, "C-s" | "s" => hsplit, "C-v" | "v" => vsplit, + "f" => goto_file_hsplit, + "F" => goto_file_vsplit, "C-q" | "q" => wclose, "C-o" | "o" => wonly, "C-h" | "h" | "left" => jump_view_left, @@ -827,36 +836,36 @@ mod tests { let keymap = merged_config.keys.0.get_mut(&Mode::Normal).unwrap(); assert_eq!( keymap.get(key!('i')).kind, - KeymapResultKind::Matched(Command::normal_mode), + KeymapResultKind::Matched(MappableCommand::normal_mode), "Leaf should replace leaf" ); assert_eq!( keymap.get(key!('无')).kind, - KeymapResultKind::Matched(Command::insert_mode), + KeymapResultKind::Matched(MappableCommand::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), + KeymapResultKind::Matched(MappableCommand::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), + &KeyTrie::Leaf(MappableCommand::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), + &KeyTrie::Leaf(MappableCommand::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), + &KeyTrie::Leaf(MappableCommand::goto_last_line), "Old leaves in subnode should be present in merged node" ); @@ -890,7 +899,7 @@ mod tests { .root() .search(&[key!(' '), key!('s'), key!('v')]) .unwrap(), - &KeyTrie::Leaf(Command::vsplit), + &KeyTrie::Leaf(MappableCommand::vsplit), "Leaf should be present in merged subnode" ); // Make sure an order was set during merge diff --git a/helix-term/src/lib.rs b/helix-term/src/lib.rs index f5e3a8cd..58cb139c 100644 --- a/helix-term/src/lib.rs +++ b/helix-term/src/lib.rs @@ -9,3 +9,14 @@ pub mod config; pub mod job; pub mod keymap; pub mod ui; + +#[cfg(not(windows))] +fn true_color() -> bool { + std::env::var("COLORTERM") + .map(|v| matches!(v.as_str(), "truecolor" | "24bit")) + .unwrap_or(false) +} +#[cfg(windows)] +fn true_color() -> bool { + true +} diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index dd782d29..a55201ff 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -168,7 +168,7 @@ impl Completion { } }; }); - let popup = Popup::new(menu); + let popup = Popup::new("completion", menu); let mut completion = Self { popup, start_offset, @@ -328,8 +328,8 @@ impl Component for Completion { let y = popup_y; if let Some((rel_width, rel_height)) = markdown_doc.required_size((width, height)) { - width = rel_width; - height = rel_height; + width = rel_width.min(width); + height = rel_height.min(height); } Rect::new(x, y, width, height) } else { diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index e8f8fd9b..7d57e581 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -17,7 +17,6 @@ use helix_core::{ }; use helix_view::{ document::{Mode, SCRATCH_BUFFER_NAME}, - editor::LineNumber, graphics::{CursorKind, Modifier, Rect, Style}, info::Info, input::KeyEvent, @@ -32,7 +31,7 @@ use tui::buffer::Buffer as Surface; pub struct EditorView { keymaps: Keymaps, on_next_key: Option<Box<dyn FnOnce(&mut commands::Context, KeyEvent)>>, - last_insert: (commands::Command, Vec<KeyEvent>), + last_insert: (commands::MappableCommand, Vec<KeyEvent>), pub(crate) completion: Option<Completion>, spinners: ProgressSpinners, autoinfo: Option<Info>, @@ -49,7 +48,7 @@ impl EditorView { Self { keymaps, on_next_key: None, - last_insert: (commands::Command::normal_mode, Vec::new()), + last_insert: (commands::MappableCommand::normal_mode, Vec::new()), completion: None, spinners: ProgressSpinners::default(), autoinfo: None, @@ -310,17 +309,16 @@ impl EditorView { use helix_core::graphemes::{grapheme_width, RopeGraphemes}; - let style = spans.iter().fold(text_style, |acc, span| { - let style = theme.get(theme.scopes()[span.0].as_str()); - acc.patch(style) - }); - for grapheme in RopeGraphemes::new(text) { let out_of_bounds = visual_x < offset.col as u16 || visual_x >= viewport.width + offset.col as u16; if LineEnding::from_rope_slice(&grapheme).is_some() { if !out_of_bounds { + let style = spans.iter().fold(text_style, |acc, span| { + acc.patch(theme.highlight(span.0)) + }); + // we still want to render an empty cell with the style surface.set_string( viewport.x + visual_x - offset.col as u16, @@ -351,6 +349,10 @@ impl EditorView { }; if !out_of_bounds { + let style = spans.iter().fold(text_style, |acc, span| { + acc.patch(theme.highlight(span.0)) + }); + // if we're offscreen just keep going until we hit a new line surface.set_string( viewport.x + visual_x - offset.col as u16, @@ -417,22 +419,6 @@ impl EditorView { let text = doc.text().slice(..); let last_line = view.last_line(doc); - let linenr = theme.get("ui.linenr"); - let linenr_select: Style = theme.try_get("ui.linenr.selected").unwrap_or(linenr); - - let warning = theme.get("warning"); - let error = theme.get("error"); - let info = theme.get("info"); - let hint = theme.get("hint"); - - // Whether to draw the line number for the last line of the - // document or not. We only draw it if it's not an empty line. - let draw_last = text.line_to_byte(last_line) < text.len_bytes(); - - let current_line = doc - .text() - .char_to_line(doc.selection(view.id).primary().cursor(text)); - // it's used inside an iterator so the collect isn't needless: // https://github.com/rust-lang/rust-clippy/issues/6164 #[allow(clippy::needless_collect)] @@ -442,51 +428,31 @@ impl EditorView { .map(|range| range.cursor_line(text)) .collect(); - for (i, line) in (view.offset.row..(last_line + 1)).enumerate() { - use helix_core::diagnostic::Severity; - if let Some(diagnostic) = doc.diagnostics().iter().find(|d| d.line == line) { - surface.set_stringn( - viewport.x, - viewport.y + i as u16, - "●", - 1, - match diagnostic.severity { - Some(Severity::Error) => error, - Some(Severity::Warning) | None => warning, - Some(Severity::Info) => info, - Some(Severity::Hint) => hint, - }, - ); - } + let mut offset = 0; - let selected = cursors.contains(&line); + let gutter_style = theme.get("ui.gutter"); - let text = if line == last_line && !draw_last { - " ~".into() - } else { - let line = match config.line_number { - LineNumber::Absolute => line + 1, - LineNumber::Relative => { - if current_line == line { - line + 1 - } else { - abs_diff(current_line, line) - } - } - }; - format!("{:>5}", line) - }; - surface.set_stringn( - viewport.x + 1, - viewport.y + i as u16, - text, - 5, - if selected && is_focused { - linenr_select - } else { - linenr - }, - ); + // avoid lots of small allocations by reusing a text buffer for each line + let mut text = String::with_capacity(8); + + for (constructor, width) in view.gutters() { + let gutter = constructor(doc, view, theme, config, is_focused, *width); + text.reserve(*width); // ensure there's enough space for the gutter + for (i, line) in (view.offset.row..(last_line + 1)).enumerate() { + let selected = cursors.contains(&line); + + if let Some(style) = gutter(line, selected, &mut text) { + surface.set_stringn( + viewport.x + offset, + viewport.y + i as u16, + &text, + *width, + gutter_style.patch(style), + ); + } + text.clear(); + } + offset += *width as u16; } } @@ -916,7 +882,7 @@ impl EditorView { return EventResult::Ignored; } - commands::Command::yank_main_selection_to_primary_clipboard.execute(cxt); + commands::MappableCommand::yank_main_selection_to_primary_clipboard.execute(cxt); EventResult::Consumed(None) } @@ -934,7 +900,8 @@ impl EditorView { } if modifiers == crossterm::event::KeyModifiers::ALT { - commands::Command::replace_selections_with_primary_clipboard.execute(cxt); + commands::MappableCommand::replace_selections_with_primary_clipboard + .execute(cxt); return EventResult::Consumed(None); } @@ -948,7 +915,7 @@ impl EditorView { 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); + commands::MappableCommand::paste_primary_clipboard_before.execute(cxt); return EventResult::Consumed(None); } @@ -963,7 +930,7 @@ impl EditorView { impl Component for EditorView { fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { let mut cxt = commands::Context { - editor: &mut cx.editor, + editor: cx.editor, count: None, register: None, callback: None, @@ -1140,13 +1107,31 @@ impl Component for EditorView { disp.push_str(&s); } } + let style = cx.editor.theme.get("ui.text"); + let macro_width = if cx.editor.macro_recording.is_some() { + 3 + } else { + 0 + }; surface.set_string( - area.x + area.width.saturating_sub(key_width), + area.x + area.width.saturating_sub(key_width + macro_width), area.y + area.height.saturating_sub(1), disp.get(disp.len().saturating_sub(key_width as usize)..) .unwrap_or(&disp), - cx.editor.theme.get("ui.text"), + style, ); + if let Some((reg, _)) = cx.editor.macro_recording { + let disp = format!("[{}]", reg); + let style = style + .fg(helix_view::graphics::Color::Yellow) + .add_modifier(Modifier::BOLD); + surface.set_string( + area.x + area.width.saturating_sub(3), + area.y + area.height.saturating_sub(1), + &disp, + style, + ); + } } if let Some(completion) = self.completion.as_mut() { @@ -1172,12 +1157,3 @@ fn canonicalize_key(key: &mut KeyEvent) { key.modifiers.remove(KeyModifiers::SHIFT) } } - -#[inline] -const fn abs_diff(a: usize, b: usize) -> usize { - if a > b { - a - b - } else { - b - a - } -} diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index 649703b5..46657fb9 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -228,6 +228,7 @@ impl Component for Markdown { return None; } let contents = parse(&self.contents, None, &self.config_loader); + // TODO: account for tab width let max_text_width = (viewport.0 - padding).min(120); let mut text_width = 0; let mut height = padding; @@ -240,11 +241,6 @@ impl Component for Markdown { } else if content_width > text_width { text_width = content_width; } - - if height >= viewport.1 { - height = viewport.1; - break; - } } Some((text_width + padding, height)) diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index e891c149..69053db3 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -190,7 +190,7 @@ impl<T: Item + 'static> Component for Menu<T> { _ => return EventResult::Ignored, }; - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { // remove the layer compositor.pop(); }))); @@ -202,7 +202,7 @@ impl<T: Item + 'static> Component for Menu<T> { return close_fn; } // arrow up/ctrl-p/shift-tab prev completion choice (including updating the doc) - shift!(BackTab) | key!(Up) | ctrl!('p') | ctrl!('k') => { + shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => { self.move_up(); (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); return EventResult::Consumed(None); diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index cdf42311..f57e2e2b 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -186,6 +186,7 @@ pub mod completers { &helix_core::config_dir().join("themes"), )); names.push("default".into()); + names.push("base16_default".into()); let mut names: Vec<_> = names .into_iter() diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 6b1c5832..1ef94df0 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -46,7 +46,7 @@ pub struct FilePicker<T> { } pub enum CachedPreview { - Document(Document), + Document(Box<Document>), Binary, LargeFile, NotFound, @@ -140,7 +140,7 @@ impl<T> FilePicker<T> { _ => { // TODO: enable syntax highlighting; blocked by async rendering Document::open(path, None, Some(&editor.theme), None) - .map(CachedPreview::Document) + .map(|doc| CachedPreview::Document(Box::new(doc))) .unwrap_or(CachedPreview::NotFound) } }, @@ -404,13 +404,13 @@ impl<T: 'static> Component for Picker<T> { _ => return EventResult::Ignored, }; - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { // remove the layer compositor.last_picker = compositor.pop(); }))); match key_event.into() { - shift!(BackTab) | key!(Up) | ctrl!('p') | ctrl!('k') => { + shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => { self.move_up(); } key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => { @@ -421,19 +421,19 @@ impl<T: 'static> Component for Picker<T> { } key!(Enter) => { if let Some(option) = self.selection() { - (self.callback_fn)(&mut cx.editor, option, Action::Replace); + (self.callback_fn)(cx.editor, option, Action::Replace); } return close_fn; } ctrl!('s') => { if let Some(option) = self.selection() { - (self.callback_fn)(&mut cx.editor, option, Action::HorizontalSplit); + (self.callback_fn)(cx.editor, option, Action::HorizontalSplit); } return close_fn; } ctrl!('v') => { if let Some(option) = self.selection() { - (self.callback_fn)(&mut cx.editor, option, Action::VerticalSplit); + (self.callback_fn)(cx.editor, option, Action::VerticalSplit); } return close_fn; } diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 8f7921a1..bf7510a2 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -15,16 +15,20 @@ pub struct Popup<T: Component> { contents: T, position: Option<Position>, size: (u16, u16), + child_size: (u16, u16), scroll: usize, + id: &'static str, } impl<T: Component> Popup<T> { - pub fn new(contents: T) -> Self { + pub fn new(id: &'static str, contents: T) -> Self { Self { contents, position: None, size: (0, 0), + child_size: (0, 0), scroll: 0, + id, } } @@ -68,6 +72,9 @@ impl<T: Component> Popup<T> { pub fn scroll(&mut self, offset: usize, direction: bool) { if direction { self.scroll += offset; + + let max_offset = self.child_size.1.saturating_sub(self.size.1); + self.scroll = (self.scroll + offset).min(max_offset as usize); } else { self.scroll = self.scroll.saturating_sub(offset); } @@ -93,7 +100,7 @@ impl<T: Component> Component for Popup<T> { _ => return EventResult::Ignored, }; - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { // remove the layer compositor.pop(); }))); @@ -115,13 +122,21 @@ impl<T: Component> Component for Popup<T> { // tab/enter/ctrl-k or whatever will confirm the selection/ ctrl-n/ctrl-p for scroll. } - fn required_size(&mut self, _viewport: (u16, u16)) -> Option<(u16, u16)> { + fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { + let max_width = 120.min(viewport.0); + let max_height = 26.min(viewport.1.saturating_sub(2)); // add some spacing in the viewport + let (width, height) = self .contents - .required_size((120, 26)) // max width, max height + .required_size((max_width, max_height)) .expect("Component needs required_size implemented in order to be embedded in a popup"); - self.size = (width, height); + self.child_size = (width, height); + self.size = (width.min(max_width), height.min(max_height)); + + // re-clamp scroll offset + let max_offset = self.child_size.1.saturating_sub(self.size.1); + self.scroll = self.scroll.min(max_offset as usize); Some(self.size) } @@ -143,4 +158,8 @@ impl<T: Component> Component for Popup<T> { self.contents.render(area, surface, cx); } + + fn id(&self) -> Option<&'static str> { + Some(self.id) + } } diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index e90b0772..07e1b33c 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -426,7 +426,7 @@ impl Component for Prompt { _ => return EventResult::Ignored, }; - let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { // remove the layer compositor.pop(); }))); @@ -505,7 +505,7 @@ impl Component for Prompt { self.change_completion_selection(CompletionDirection::Forward); (self.callback_fn)(cx, &self.line, PromptEvent::Update) } - shift!(BackTab) => { + shift!(Tab) => { self.change_completion_selection(CompletionDirection::Backward); (self.callback_fn)(cx, &self.line, PromptEvent::Update) } |