diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 4df3278b8bba..704742d2305c 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -5,7 +5,6 @@ pub(crate) mod typed; pub use dap::*; use helix_vcs::Hunk; pub use lsp::*; -use tui::widgets::Row; pub use typed::*; use helix_core::{ @@ -50,8 +49,7 @@ use crate::{ compositor::{self, Component, Compositor}, filter_picker_entry, job::Callback, - keymap::ReverseKeymap, - ui::{self, overlay::overlaid, Picker, Popup, Prompt, PromptEvent}, + ui::{self, overlay::overlaid, Picker, PickerColumn, Popup, Prompt, PromptEvent}, }; use crate::job::{self, Jobs}; @@ -2151,32 +2149,15 @@ fn global_search(cx: &mut Context) { path: PathBuf, /// 0 indexed lines line_num: usize, + line_content: String, } impl FileResult { - fn new(path: &Path, line_num: usize) -> Self { + fn new(path: &Path, line_num: usize, line_content: String) -> Self { Self { path: path.to_path_buf(), line_num, - } - } - } - - impl ui::menu::Item for FileResult { - type Data = Option; - - fn format(&self, current_path: &Self::Data) -> Row { - let relative_path = helix_stdx::path::get_relative_path(&self.path) - .to_string_lossy() - .into_owned(); - if current_path - .as_ref() - .map(|p| p == &self.path) - .unwrap_or(false) - { - format!("{} (*)", relative_path).into() - } else { - relative_path.into() + line_content, } } } @@ -2222,7 +2203,29 @@ fn global_search(cx: &mut Context) { return; } - let (picker, injector) = Picker::stream(current_path); + let columns = vec![ + PickerColumn::new( + "path", + |item: &FileResult, current_path: &Option| { + let relative_path = helix_stdx::path::get_relative_path(&item.path) + .to_string_lossy() + .into_owned(); + if current_path + .as_ref() + .map(|p| p == &item.path) + .unwrap_or(false) + { + format!("{} (*)", relative_path).into() + } else { + relative_path.into() + } + }, + ), + PickerColumn::new("contents", |item: &FileResult, _| { + item.line_content.as_str().into() + }), + ]; + let (picker, injector) = Picker::stream(columns, current_path); let dedup_symlinks = file_picker_config.deduplicate_links; let absolute_root = search_root @@ -2248,85 +2251,88 @@ fn global_search(cx: &mut Context) { .max_depth(file_picker_config.max_depth) .filter_entry(move |entry| { filter_picker_entry(entry, &absolute_root, dedup_symlinks) - }); + }) + .add_custom_ignore_filename(helix_loader::config_dir().join("ignore")) + .add_custom_ignore_filename(".helix/ignore") + .build_parallel() + .run(|| { + let mut searcher = searcher.clone(); + let matcher = matcher.clone(); + let injector = injector_.clone(); + let documents = &documents; + Box::new(move |entry: Result| -> WalkState { + let entry = match entry { + Ok(entry) => entry, + Err(_) => return WalkState::Continue, + }; - walk_builder - .add_custom_ignore_filename(helix_loader::config_dir().join("ignore")); - walk_builder.add_custom_ignore_filename(".helix/ignore"); - - walk_builder.build_parallel().run(|| { - let mut searcher = searcher.clone(); - let matcher = matcher.clone(); - let injector = injector_.clone(); - let documents = &documents; - Box::new(move |entry: Result| -> WalkState { - let entry = match entry { - Ok(entry) => entry, - Err(_) => return WalkState::Continue, - }; - - match entry.file_type() { - Some(entry) if entry.is_file() => {} - // skip everything else - _ => return WalkState::Continue, - }; - - let mut stop = false; - let sink = sinks::UTF8(|line_num, _| { - stop = injector - .push(FileResult::new(entry.path(), line_num as usize - 1)) - .is_err(); - - Ok(!stop) - }); - let doc = documents.iter().find(|&(doc_path, _)| { - doc_path - .as_ref() - .map_or(false, |doc_path| doc_path == entry.path()) - }); - - let result = if let Some((_, doc)) = doc { - // there is already a buffer for this file - // search the buffer instead of the file because it's faster - // and captures new edits without requiring a save - if searcher.multi_line_with_matcher(&matcher) { - // in this case a continous buffer is required - // convert the rope to a string - let text = doc.to_string(); - searcher.search_slice(&matcher, text.as_bytes(), sink) + match entry.file_type() { + Some(entry) if entry.is_file() => {} + // skip everything else + _ => return WalkState::Continue, + }; + + let mut stop = false; + let sink = sinks::UTF8(|line_num, line_content| { + stop = injector + .push(FileResult::new( + entry.path(), + line_num as usize - 1, + line_content.to_string(), + )) + .is_err(); + + Ok(!stop) + }); + let doc = documents.iter().find(|&(doc_path, _)| { + doc_path + .as_ref() + .map_or(false, |doc_path| doc_path == entry.path()) + }); + + let result = if let Some((_, doc)) = doc { + // there is already a buffer for this file + // search the buffer instead of the file because it's faster + // and captures new edits without requiring a save + if searcher.multi_line_with_matcher(&matcher) { + // in this case a continous buffer is required + // convert the rope to a string + let text = doc.to_string(); + searcher.search_slice(&matcher, text.as_bytes(), sink) + } else { + searcher.search_reader( + &matcher, + RopeReader::new(doc.slice(..)), + sink, + ) + } } else { - searcher.search_reader( - &matcher, - RopeReader::new(doc.slice(..)), - sink, - ) + searcher.search_path(&matcher, entry.path(), sink) + }; + + if let Err(err) = result { + log::error!( + "Global search error: {}, {}", + entry.path().display(), + err + ); } - } else { - searcher.search_path(&matcher, entry.path(), sink) - }; - - if let Err(err) = result { - log::error!( - "Global search error: {}, {}", - entry.path().display(), - err - ); - } - if stop { - WalkState::Quit - } else { - WalkState::Continue - } - }) - }); + if stop { + WalkState::Quit + } else { + WalkState::Continue + } + }) + }); }); cx.jobs.callback(async move { let call = move |_: &mut Editor, compositor: &mut Compositor| { let picker = Picker::with_stream( picker, + 0, injector, - move |cx, FileResult { path, line_num }, action| { + move |cx, FileResult { path, line_num, .. }, action| { let doc = match cx.editor.open(path, action) { Ok(id) => doc_mut!(cx.editor, &id), Err(e) => { @@ -2358,7 +2364,7 @@ fn global_search(cx: &mut Context) { }, ) .with_preview( - |_editor, FileResult { path, line_num }| { + |_editor, FileResult { path, line_num, .. }| { Some((path.clone().into(), Some((*line_num, *line_num)))) }, ); @@ -2744,31 +2750,6 @@ fn buffer_picker(cx: &mut Context) { focused_at: std::time::Instant, } - impl ui::menu::Item for BufferMeta { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - let path = self - .path - .as_deref() - .map(helix_stdx::path::get_relative_path); - let path = match path.as_deref().and_then(Path::to_str) { - Some(path) => path, - None => SCRATCH_BUFFER_NAME, - }; - - let mut flags = String::new(); - if self.is_modified { - flags.push('+'); - } - if self.is_current { - flags.push('*'); - } - - Row::new([self.id.to_string(), flags, path.to_string()]) - } - } - let new_meta = |doc: &Document| BufferMeta { id: doc.id(), path: doc.path().cloned(), @@ -2787,7 +2768,31 @@ fn buffer_picker(cx: &mut Context) { // mru items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at)); - let picker = Picker::new(items, (), |cx, meta, action| { + let columns = vec![ + PickerColumn::new("id", |meta: &BufferMeta, _| meta.id.to_string().into()), + PickerColumn::new("flags", |meta: &BufferMeta, _| { + let mut flags = String::new(); + if meta.is_modified { + flags.push('+'); + } + if meta.is_current { + flags.push('*'); + } + flags.into() + }), + PickerColumn::new("path", |meta: &BufferMeta, _| { + let path = meta + .path + .as_deref() + .map(helix_stdx::path::get_relative_path); + path.as_deref() + .and_then(Path::to_str) + .unwrap_or(SCRATCH_BUFFER_NAME) + .to_string() + .into() + }), + ]; + let picker = Picker::new(columns, 2, items, (), |cx, meta, action| { cx.editor.switch(meta.id, action); }) .with_preview(|editor, meta| { @@ -2811,33 +2816,6 @@ fn jumplist_picker(cx: &mut Context) { is_current: bool, } - impl ui::menu::Item for JumpMeta { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - let path = self - .path - .as_deref() - .map(helix_stdx::path::get_relative_path); - let path = match path.as_deref().and_then(Path::to_str) { - Some(path) => path, - None => SCRATCH_BUFFER_NAME, - }; - - let mut flags = Vec::new(); - if self.is_current { - flags.push("*"); - } - - let flag = if flags.is_empty() { - "".into() - } else { - format!(" ({})", flags.join("")) - }; - format!("{} {}{} {}", self.id, path, flag, self.text).into() - } - } - for (view, _) in cx.editor.tree.views_mut() { for doc_id in view.jumps.iter().map(|e| e.0).collect::>().iter() { let doc = doc_mut!(cx.editor, doc_id); @@ -2864,7 +2842,37 @@ fn jumplist_picker(cx: &mut Context) { } }; + let columns = vec![ + ui::PickerColumn::new("id", |item: &JumpMeta, _| item.id.to_string().into()), + ui::PickerColumn::new("path", |item: &JumpMeta, _| { + let path = item + .path + .as_deref() + .map(helix_stdx::path::get_relative_path); + path.as_deref() + .and_then(Path::to_str) + .unwrap_or(SCRATCH_BUFFER_NAME) + .to_string() + .into() + }), + ui::PickerColumn::new("flags", |item: &JumpMeta, _| { + let mut flags = Vec::new(); + if item.is_current { + flags.push("*"); + } + + if flags.is_empty() { + "".into() + } else { + format!(" ({})", flags.join("")).into() + } + }), + ui::PickerColumn::new("contents", |item: &JumpMeta, _| item.text.as_str().into()), + ]; + let picker = Picker::new( + columns, + 1, // path cx.editor .tree .views() @@ -2893,35 +2901,6 @@ fn jumplist_picker(cx: &mut Context) { cx.push_layer(Box::new(overlaid(picker))); } -impl ui::menu::Item for MappableCommand { - type Data = ReverseKeymap; - - fn format(&self, keymap: &Self::Data) -> Row { - let fmt_binding = |bindings: &Vec>| -> String { - bindings.iter().fold(String::new(), |mut acc, bind| { - if !acc.is_empty() { - acc.push(' '); - } - for key in bind { - acc.push_str(&key.key_sequence_format()); - } - acc - }) - }; - - match self { - MappableCommand::Typable { doc, name, .. } => match keymap.get(name as &String) { - Some(bindings) => format!("{} ({}) [:{}]", doc, fmt_binding(bindings), name).into(), - None => format!("{} [:{}]", doc, name).into(), - }, - MappableCommand::Static { doc, name, .. } => match keymap.get(*name) { - Some(bindings) => format!("{} ({}) [{}]", doc, fmt_binding(bindings), name).into(), - None => format!("{} [{}]", doc, name).into(), - }, - } - } -} - pub fn command_palette(cx: &mut Context) { let register = cx.register; let count = cx.count; @@ -2941,7 +2920,35 @@ pub fn command_palette(cx: &mut Context) { } })); - let picker = Picker::new(commands, keymap, move |cx, command, _action| { + let columns = vec![ + ui::PickerColumn::new("name", |item, _| match item { + MappableCommand::Typable { name, .. } => format!(":{name}").into(), + MappableCommand::Static { name, .. } => (*name).into(), + }), + ui::PickerColumn::new( + "bindings", + |item: &MappableCommand, keymap: &crate::keymap::ReverseKeymap| { + keymap + .get(item.name()) + .map(|bindings| { + bindings.iter().fold(String::new(), |mut acc, bind| { + if !acc.is_empty() { + acc.push(' '); + } + for key in bind { + acc.push_str(&key.key_sequence_format()); + } + acc + }) + }) + .unwrap_or_default() + .into() + }, + ), + ui::PickerColumn::new("doc", |item: &MappableCommand, _| item.doc().into()), + ]; + + let picker = Picker::new(columns, 0, commands, keymap, move |cx, command, _action| { let mut ctx = Context { register, count, diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index d62b0a4e5b4e..52d20b3e3cf8 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -12,7 +12,7 @@ use helix_view::{editor::Breakpoint, graphics::Margin}; use serde_json::{to_value, Value}; use tokio_stream::wrappers::UnboundedReceiverStream; -use tui::{text::Spans, widgets::Row}; +use tui::text::Spans; use std::collections::HashMap; use std::future::Future; @@ -22,38 +22,6 @@ use anyhow::{anyhow, bail}; use helix_view::handlers::dap::{breakpoints_changed, jump_to_stack_frame, select_thread_id}; -impl ui::menu::Item for StackFrame { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - self.name.as_str().into() // TODO: include thread_states in the label - } -} - -impl ui::menu::Item for DebugTemplate { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - self.name.as_str().into() - } -} - -impl ui::menu::Item for Thread { - type Data = ThreadStates; - - fn format(&self, thread_states: &Self::Data) -> Row { - format!( - "{} ({})", - self.name, - thread_states - .get(&self.id) - .map(|state| state.as_str()) - .unwrap_or("unknown") - ) - .into() - } -} - fn thread_picker( cx: &mut Context, callback_fn: impl Fn(&mut Editor, &dap::Thread) + Send + 'static, @@ -73,9 +41,23 @@ fn thread_picker( let debugger = debugger!(editor); let thread_states = debugger.thread_states.clone(); - let picker = Picker::new(threads, thread_states, move |cx, thread, _action| { - callback_fn(cx.editor, thread) - }) + let columns = vec![ + ui::PickerColumn::new("name", |item: &Thread, _| item.name.as_str().into()), + ui::PickerColumn::new("state", |item: &Thread, thread_states: &ThreadStates| { + thread_states + .get(&item.id) + .map(|state| state.as_str()) + .unwrap_or("unknown") + .into() + }), + ]; + let picker = Picker::new( + columns, + 0, + threads, + thread_states, + move |cx, thread, _action| callback_fn(cx.editor, thread), + ) .with_preview(move |editor, thread| { let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?; let frame = frames.first()?; @@ -268,7 +250,14 @@ pub fn dap_launch(cx: &mut Context) { let templates = config.templates.clone(); + let columns = vec![ui::PickerColumn::new( + "template", + |item: &DebugTemplate, _| item.name.as_str().into(), + )]; + cx.push_layer(Box::new(overlaid(Picker::new( + columns, + 0, templates, (), |cx, template, _action| { @@ -735,7 +724,10 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { let frames = debugger.stack_frames[&thread_id].clone(); - let picker = Picker::new(frames, (), move |cx, frame, _action| { + let columns = vec![ui::PickerColumn::new("frame", |item: &StackFrame, _| { + item.name.as_str().into() // TODO: include thread_states in the label + })]; + let picker = Picker::new(columns, 0, frames, (), move |cx, frame, _action| { let debugger = debugger!(cx.editor); // TODO: this should be simpler to find let pos = debugger.stack_frames[&thread_id] diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index de2f0e5ec289..8b5385fa83fd 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -9,10 +9,7 @@ use helix_lsp::{ Client, OffsetEncoding, }; use tokio_stream::StreamExt; -use tui::{ - text::{Span, Spans}, - widgets::Row, -}; +use tui::{text::Span, widgets::Row}; use super::{align_view, push_jump, Align, Context, Editor}; @@ -38,7 +35,6 @@ use std::{ collections::{BTreeMap, HashSet}, fmt::Write, future::Future, - path::PathBuf, }; /// Gets the first language server that is attached to a document which supports a specific feature. @@ -63,69 +59,11 @@ macro_rules! language_server_with_feature { }}; } -impl ui::menu::Item for lsp::Location { - /// Current working directory. - type Data = PathBuf; - - fn format(&self, cwdir: &Self::Data) -> Row { - // The preallocation here will overallocate a few characters since it will account for the - // URL's scheme, which is not used most of the time since that scheme will be "file://". - // Those extra chars will be used to avoid allocating when writing the line number (in the - // common case where it has 5 digits or less, which should be enough for a cast majority - // of usages). - let mut res = String::with_capacity(self.uri.as_str().len()); - - if self.uri.scheme() == "file" { - // With the preallocation above and UTF-8 paths already, this closure will do one (1) - // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`. - let mut write_path_to_res = || -> Option<()> { - let path = self.uri.to_file_path().ok()?; - res.push_str(&path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy()); - Some(()) - }; - write_path_to_res(); - } else { - // Never allocates since we declared the string with this capacity already. - res.push_str(self.uri.as_str()); - } - - // Most commonly, this will not allocate, especially on Unix systems where the root prefix - // is a simple `/` and not `C:\` (with whatever drive letter) - write!(&mut res, ":{}", self.range.start.line + 1) - .expect("Will only failed if allocating fail"); - res.into() - } -} - struct SymbolInformationItem { symbol: lsp::SymbolInformation, offset_encoding: OffsetEncoding, } -impl ui::menu::Item for SymbolInformationItem { - /// Path to currently focussed document - type Data = Option; - - fn format(&self, current_doc_path: &Self::Data) -> Row { - if current_doc_path.as_ref() == Some(&self.symbol.location.uri) { - self.symbol.name.as_str().into() - } else { - match self.symbol.location.uri.to_file_path() { - Ok(path) => { - let get_relative_path = path::get_relative_path(path.as_path()); - format!( - "{} ({})", - &self.symbol.name, - get_relative_path.to_string_lossy() - ) - .into() - } - Err(_) => format!("{} ({})", &self.symbol.name, &self.symbol.location.uri).into(), - } - } - } -} - struct DiagnosticStyles { hint: Style, info: Style, @@ -139,49 +77,6 @@ struct PickerDiagnostic { offset_encoding: OffsetEncoding, } -impl ui::menu::Item for PickerDiagnostic { - type Data = (DiagnosticStyles, DiagnosticsFormat); - - fn format(&self, (styles, format): &Self::Data) -> Row { - let mut style = self - .diag - .severity - .map(|s| match s { - DiagnosticSeverity::HINT => styles.hint, - DiagnosticSeverity::INFORMATION => styles.info, - DiagnosticSeverity::WARNING => styles.warning, - DiagnosticSeverity::ERROR => styles.error, - _ => Style::default(), - }) - .unwrap_or_default(); - - // remove background as it is distracting in the picker list - style.bg = None; - - let code = match self.diag.code.as_ref() { - Some(NumberOrString::Number(n)) => format!(" ({n})"), - Some(NumberOrString::String(s)) => format!(" ({s})"), - None => String::new(), - }; - - let path = match format { - DiagnosticsFormat::HideSourcePath => String::new(), - DiagnosticsFormat::ShowSourcePath => { - let file_path = self.url.to_file_path().unwrap(); - let path = path::get_truncated_path(file_path); - format!("{}: ", path.to_string_lossy()) - } - }; - - Spans::from(vec![ - Span::raw(path), - Span::styled(&self.diag.message, style), - Span::styled(code, style), - ]) - .into() - } -} - fn location_to_file_location(location: &lsp::Location) -> FileLocation { let path = location.uri.to_file_path().unwrap(); let line = Some(( @@ -234,18 +129,91 @@ fn jump_to_location( } } -type SymbolPicker = Picker; +fn display_symbol_kind(kind: lsp::SymbolKind) -> &'static str { + match kind { + lsp::SymbolKind::FILE => "file", + lsp::SymbolKind::MODULE => "module", + lsp::SymbolKind::NAMESPACE => "namespace", + lsp::SymbolKind::PACKAGE => "package", + lsp::SymbolKind::CLASS => "class", + lsp::SymbolKind::METHOD => "method", + lsp::SymbolKind::PROPERTY => "property", + lsp::SymbolKind::FIELD => "field", + lsp::SymbolKind::CONSTRUCTOR => "construct", + lsp::SymbolKind::ENUM => "enum", + lsp::SymbolKind::INTERFACE => "interface", + lsp::SymbolKind::FUNCTION => "function", + lsp::SymbolKind::VARIABLE => "variable", + lsp::SymbolKind::CONSTANT => "constant", + lsp::SymbolKind::STRING => "string", + lsp::SymbolKind::NUMBER => "number", + lsp::SymbolKind::BOOLEAN => "boolean", + lsp::SymbolKind::ARRAY => "array", + lsp::SymbolKind::OBJECT => "object", + lsp::SymbolKind::KEY => "key", + lsp::SymbolKind::NULL => "null", + lsp::SymbolKind::ENUM_MEMBER => "enummem", + lsp::SymbolKind::STRUCT => "struct", + lsp::SymbolKind::EVENT => "event", + lsp::SymbolKind::OPERATOR => "operator", + lsp::SymbolKind::TYPE_PARAMETER => "typeparam", + _ => { + log::warn!("Unknown symbol kind: {:?}", kind); + "" + } + } +} -fn sym_picker(symbols: Vec, current_path: Option) -> SymbolPicker { - // TODO: drop current_path comparison and instead use workspace: bool flag? - Picker::new(symbols, current_path, move |cx, item, action| { - jump_to_location( - cx.editor, - &item.symbol.location, - item.offset_encoding, - action, - ); - }) +type SymbolPicker = Picker; + +fn sym_picker(symbols: Vec, workspace: bool) -> SymbolPicker { + let symbol_kind_column = ui::PickerColumn::new("kind", |item: &SymbolInformationItem, _| { + display_symbol_kind(item.symbol.kind).into() + }); + + let columns = if workspace { + vec![ + symbol_kind_column, + ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| { + item.symbol.name.as_str().into() + }) + .without_filtering(), + ui::PickerColumn::new("path", |item: &SymbolInformationItem, _| { + match item.symbol.location.uri.to_file_path() { + Ok(path) => path::get_relative_path(path.as_path()) + .to_string_lossy() + .to_string() + .into(), + Err(_) => item.symbol.location.uri.to_string().into(), + } + }), + ] + } else { + vec![ + symbol_kind_column, + // Some symbols in the document symbol picker may have a URI that isn't + // the current file. It should be rare though, so we concatenate that + // URI in with the symbol name in this picker. + ui::PickerColumn::new("name", |item: &SymbolInformationItem, _| { + item.symbol.name.as_str().into() + }), + ] + }; + + Picker::new( + columns, + 1, // name column + symbols, + (), + move |cx, item, action| { + jump_to_location( + cx.editor, + &item.symbol.location, + item.offset_encoding, + action, + ); + }, + ) .with_preview(move |_editor, item| Some(location_to_file_location(&item.symbol.location))) .truncate_start(false) } @@ -256,12 +224,14 @@ enum DiagnosticsFormat { HideSourcePath, } +type DiagnosticsPicker = Picker; + fn diag_picker( cx: &Context, diagnostics: BTreeMap>, _current_path: Option, format: DiagnosticsFormat, -) -> Picker { +) -> DiagnosticsPicker { // TODO: drop current_path comparison and instead use workspace: bool flag? // flatten the map to a vec of (url, diag) pairs @@ -287,9 +257,51 @@ fn diag_picker( error: cx.editor.theme.get("error"), }; + let mut columns = vec![ + ui::PickerColumn::new( + "severity", + |item: &PickerDiagnostic, styles: &DiagnosticStyles| { + match item.diag.severity { + Some(DiagnosticSeverity::HINT) => Span::styled("HINT", styles.hint), + Some(DiagnosticSeverity::INFORMATION) => Span::styled("INFO", styles.info), + Some(DiagnosticSeverity::WARNING) => Span::styled("WARN", styles.warning), + Some(DiagnosticSeverity::ERROR) => Span::styled("ERROR", styles.error), + _ => Span::raw(""), + } + .into() + }, + ), + ui::PickerColumn::new("code", |item: &PickerDiagnostic, _| { + match item.diag.code.as_ref() { + Some(NumberOrString::Number(n)) => n.to_string().into(), + Some(NumberOrString::String(s)) => s.as_str().into(), + None => "".into(), + } + }), + ui::PickerColumn::new("message", |item: &PickerDiagnostic, _| { + item.diag.message.as_str().into() + }), + ]; + let mut primary_column = 2; // message + + if format == DiagnosticsFormat::ShowSourcePath { + columns.insert( + // between message code and message + 2, + ui::PickerColumn::new("path", |item: &PickerDiagnostic, _| { + let file_path = item.url.to_file_path().unwrap(); + let path = path::get_truncated_path(file_path); + path.to_string_lossy().to_string().into() + }), + ); + primary_column += 1; + } + Picker::new( + columns, + primary_column, flat_diag, - (styles, format), + styles, move |cx, PickerDiagnostic { url, @@ -376,7 +388,6 @@ pub fn symbol_picker(cx: &mut Context) { } }) .collect(); - let current_url = doc.url(); if futures.is_empty() { cx.editor @@ -391,7 +402,7 @@ pub fn symbol_picker(cx: &mut Context) { symbols.append(&mut lsp_items); } let call = move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = sym_picker(symbols, current_url); + let picker = sym_picker(symbols, false); compositor.push(Box::new(overlaid(picker))) }; @@ -453,13 +464,12 @@ pub fn workspace_symbol_picker(cx: &mut Context) { .boxed() }; - let current_url = doc.url(); let initial_symbols = get_symbols("".to_owned(), cx.editor); cx.jobs.callback(async move { let symbols = initial_symbols.await?; let call = move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = sym_picker(symbols, current_url); + let picker = sym_picker(symbols, true); let dyn_picker = DynamicPicker::new(picker, Box::new(get_symbols)); compositor.push(Box::new(overlaid(dyn_picker))) }; @@ -757,13 +767,6 @@ pub fn code_action(cx: &mut Context) { }); } -impl ui::menu::Item for lsp::Command { - type Data = (); - fn format(&self, _data: &Self::Data) -> Row { - self.title.as_str().into() - } -} - pub fn execute_lsp_command(editor: &mut Editor, language_server_id: usize, cmd: lsp::Command) { // the command is executed on the server and communicated back // to the client asynchronously using workspace edits @@ -1023,7 +1026,38 @@ fn goto_impl( editor.set_error("No definition found."); } _locations => { - let picker = Picker::new(locations, cwdir, move |cx, location, action| { + let columns = vec![ui::PickerColumn::new( + "location", + |item: &lsp::Location, cwdir: &std::path::PathBuf| { + // The preallocation here will overallocate a few characters since it will account for the + // URL's scheme, which is not used most of the time since that scheme will be "file://". + // Those extra chars will be used to avoid allocating when writing the line number (in the + // common case where it has 5 digits or less, which should be enough for a cast majority + // of usages). + let mut res = String::with_capacity(item.uri.as_str().len()); + + if item.uri.scheme() == "file" { + // With the preallocation above and UTF-8 paths already, this closure will do one (1) + // allocation, for `to_file_path`, else there will be two (2), with `to_string_lossy`. + if let Ok(path) = item.uri.to_file_path() { + res.push_str( + &path.strip_prefix(cwdir).unwrap_or(&path).to_string_lossy(), + ); + } + } else { + // Never allocates since we declared the string with this capacity already. + res.push_str(item.uri.as_str()); + } + + // Most commonly, this will not allocate, especially on Unix systems where the root prefix + // is a simple `/` and not `C:\` (with whatever drive letter) + write!(&mut res, ":{}", item.range.start.line + 1) + .expect("Will only failed if allocating fail"); + res.into() + }, + )]; + + let picker = Picker::new(columns, 0, locations, cwdir, move |cx, location, action| { jump_to_location(cx.editor, location, offset_encoding, action) }) .with_preview(move |_editor, location| Some(location_to_file_location(location))); diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index ee02a7d25e4d..417a6f5d440d 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1392,9 +1392,14 @@ fn lsp_workspace_command( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { - let picker = ui::Picker::new(commands, (), move |cx, command, _action| { - execute_lsp_command(cx.editor, language_server_id, command.clone()); - }); + let columns = vec![ui::PickerColumn::new( + "title", + |command: &helix_lsp::lsp::Command, _| command.title.as_str().into(), + )]; + let picker = + ui::Picker::new(columns, 0, commands, (), move |cx, command, _action| { + execute_lsp_command(cx.editor, language_server_id, command.clone()); + }); compositor.push(Box::new(overlaid(picker))) }, )); diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 64127e3af8b3..b3dba40740af 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, cmp::Reverse, path::PathBuf}; +use std::{borrow::Cow, cmp::Reverse}; use crate::{ compositor::{Callback, Component, Compositor, Context, Event, EventResult}, @@ -38,18 +38,6 @@ pub trait Item: Sync + Send + 'static { } } -impl Item for PathBuf { - /// Root prefix to strip. - type Data = PathBuf; - - fn format(&self, root_path: &Self::Data) -> Row { - self.strip_prefix(root_path) - .unwrap_or(self) - .to_string_lossy() - .into() - } -} - pub type MenuCallback = Box, MenuEvent)>; pub struct Menu { diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index efa2473e01ed..b4ee7eb9c6c4 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -20,7 +20,7 @@ pub use completion::{Completion, CompletionItem}; pub use editor::EditorView; pub use markdown::Markdown; pub use menu::Menu; -pub use picker::{DynamicPicker, FileLocation, Picker}; +pub use picker::{Column as PickerColumn, DynamicPicker, FileLocation, Picker}; pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; pub use spinner::{ProgressSpinners, Spinner}; @@ -155,7 +155,9 @@ pub fn regex_prompt( cx.push_layer(Box::new(prompt)); } -pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker { +type FilePicker = Picker; + +pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker { use ignore::{types::TypesBuilder, WalkBuilder}; use std::time::Instant; @@ -202,16 +204,31 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker }); log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); - let picker = Picker::new(Vec::new(), root, move |cx, path: &PathBuf, action| { - if let Err(e) = cx.editor.open(path, action) { - let err = if let Some(err) = e.source() { - format!("{}", err) - } else { - format!("unable to open \"{}\"", path.display()) - }; - cx.editor.set_error(err); - } - }) + let columns = vec![PickerColumn::new( + "path", + |item: &PathBuf, root: &PathBuf| { + item.strip_prefix(root) + .unwrap_or(item) + .to_string_lossy() + .into() + }, + )]; + let picker = Picker::new( + columns, + 0, + Vec::new(), + root, + move |cx, path: &PathBuf, action| { + if let Err(e) = cx.editor.open(path, action) { + let err = if let Some(err) = e.source() { + format!("{}", err) + } else { + format!("unable to open \"{}\"", path.display()) + }; + cx.editor.set_error(err); + } + }, + ) .with_preview(|_editor, path| Some((path.clone().into(), None))); let injector = picker.injector(); let timeout = std::time::Instant::now() + std::time::Duration::from_millis(30); diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 4be5a11ef647..647650f84c0f 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -17,12 +17,13 @@ use tui::{ buffer::Buffer as Surface, layout::Constraint, text::{Span, Spans}, - widgets::{Block, BorderType, Borders, Cell, Table}, + widgets::{Block, BorderType, Borders, Cell, Row, Table}, }; use tui::widgets::Widget; use std::{ + borrow::Cow, collections::HashMap, io::Read, path::PathBuf, @@ -47,7 +48,7 @@ use helix_view::{ }; pub const ID: &str = "picker"; -use super::{menu::Item, overlay::Overlay}; +use super::overlay::Overlay; pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72; /// Biggest file size to preview in bytes @@ -123,38 +124,36 @@ impl Preview<'_, '_> { } } -fn item_to_nucleo(item: T, editor_data: &T::Data) -> Option<(T, Utf32String)> { - let row = item.format(editor_data); - let mut cells = row.cells.iter(); - let mut text = String::with_capacity(row.cell_text().map(|cell| cell.len()).sum()); - let cell = cells.next()?; - if let Some(cell) = cell.content.lines.first() { - for span in &cell.0 { - text.push_str(&span.content); +fn inject_nucleo_item( + injector: &nucleo::Injector, + columns: &[Column], + item: T, + editor_data: &D, +) { + let column_texts: Vec = columns + .iter() + .filter(|column| column.filter) + .map(|column| column.format_text(&item, editor_data).into()) + .collect(); + injector.push(item, |dst| { + for (i, text) in column_texts.into_iter().enumerate() { + dst[i] = text; } - } - - for cell in cells { - text.push(' '); - if let Some(cell) = cell.content.lines.first() { - for span in &cell.0 { - text.push_str(&span.content); - } - } - } - Some((item, text.into())) + }); } -pub struct Injector { +pub struct Injector { dst: nucleo::Injector, - editor_data: Arc, + columns: Arc>>, + editor_data: Arc, shutown: Arc, } -impl Clone for Injector { +impl Clone for Injector { fn clone(&self) -> Self { Injector { dst: self.dst.clone(), + columns: self.columns.clone(), editor_data: self.editor_data.clone(), shutown: self.shutown.clone(), } @@ -163,21 +162,57 @@ impl Clone for Injector { pub struct InjectorShutdown; -impl Injector { +impl Injector { pub fn push(&self, item: T) -> Result<(), InjectorShutdown> { if self.shutown.load(atomic::Ordering::Relaxed) { return Err(InjectorShutdown); } - if let Some((item, matcher_text)) = item_to_nucleo(item, &self.editor_data) { - self.dst.push(item, |dst| dst[0] = matcher_text); - } + inject_nucleo_item(&self.dst, &self.columns, item, &self.editor_data); Ok(()) } } -pub struct Picker { - editor_data: Arc, +type ColumnFormatFn = for<'a> fn(&'a T, &'a D) -> Cell<'a>; + +pub struct Column { + name: &'static str, + format: ColumnFormatFn, + /// Whether the column should be passed to nucleo for matching and filtering. + /// `DynamicPicker` uses this so that the dynamic column (for example regex in + /// global search) is not used for filtering twice. + filter: bool, +} + +impl Column { + pub fn new(name: &'static str, format: ColumnFormatFn) -> Self { + Self { + name, + format, + filter: true, + } + } + + pub fn without_filtering(mut self) -> Self { + self.filter = false; + self + } + + fn format<'a>(&self, item: &'a T, data: &'a D) -> Cell<'a> { + (self.format)(item, data) + } + + fn format_text<'a>(&self, item: &'a T, data: &'a D) -> Cow<'a, str> { + let text: String = self.format(item, data).content.into(); + text.into() + } +} + +pub struct Picker { + column_names: Vec<&'static str>, + columns: Arc>>, + primary_column: usize, + editor_data: Arc, shutdown: Arc, matcher: Nucleo, @@ -186,7 +221,7 @@ pub struct Picker { cursor: u32, prompt: Prompt, - previous_pattern: String, + query: HashMap<&'static str, String>, /// Whether to show the preview panel (default true) show_preview: bool, @@ -203,16 +238,19 @@ pub struct Picker { file_fn: Option>, } -impl Picker { - pub fn stream(editor_data: T::Data) -> (Nucleo, Injector) { +impl Picker { + pub fn stream(columns: Vec>, editor_data: D) -> (Nucleo, Injector) { + let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32; + assert!(matcher_columns > 0); let matcher = Nucleo::new( Config::DEFAULT, Arc::new(helix_event::request_redraw), None, - 1, + matcher_columns, ); let streamer = Injector { dst: matcher.injector(), + columns: Arc::new(columns), editor_data: Arc::new(editor_data), shutown: Arc::new(AtomicBool::new(false)), }; @@ -220,24 +258,28 @@ impl Picker { } pub fn new( + columns: Vec>, + primary_column: usize, options: Vec, - editor_data: T::Data, + editor_data: D, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, ) -> Self { + let matcher_columns = columns.iter().filter(|col| col.filter).count() as u32; + assert!(matcher_columns > 0); let matcher = Nucleo::new( Config::DEFAULT, Arc::new(helix_event::request_redraw), None, - 1, + matcher_columns, ); let injector = matcher.injector(); for item in options { - if let Some((item, matcher_text)) = item_to_nucleo(item, &editor_data) { - injector.push(item, |dst| dst[0] = matcher_text); - } + inject_nucleo_item(&injector, &columns, item, &editor_data); } Self::with( matcher, + Arc::new(columns), + primary_column, Arc::new(editor_data), Arc::new(AtomicBool::new(false)), callback_fn, @@ -246,18 +288,30 @@ impl Picker { pub fn with_stream( matcher: Nucleo, - injector: Injector, + primary_column: usize, + injector: Injector, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, ) -> Self { - Self::with(matcher, injector.editor_data, injector.shutown, callback_fn) + Self::with( + matcher, + injector.columns, + primary_column, + injector.editor_data, + injector.shutown, + callback_fn, + ) } fn with( matcher: Nucleo, - editor_data: Arc, + columns: Arc>>, + default_column: usize, + editor_data: Arc, shutdown: Arc, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, ) -> Self { + assert!(!columns.is_empty()); + let prompt = Prompt::new( "".into(), None, @@ -265,27 +319,37 @@ impl Picker { |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {}, ); + let column_names: Vec<_> = columns.iter().map(|column| column.name).collect(); + let widths = columns + .iter() + .map(|column| Constraint::Length(column.name.chars().count() as u16)) + .collect(); + Self { + column_names, + columns, + primary_column: default_column, matcher, editor_data, shutdown, cursor: 0, prompt, - previous_pattern: String::new(), + query: HashMap::default(), truncate_start: true, show_preview: true, callback_fn: Box::new(callback_fn), completion_height: 0, - widths: Vec::new(), + widths, preview_cache: HashMap::new(), read_buffer: Vec::with_capacity(1024), file_fn: None, } } - pub fn injector(&self) -> Injector { + pub fn injector(&self) -> Injector { Injector { dst: self.matcher.injector(), + columns: self.columns.clone(), editor_data: self.editor_data.clone(), shutown: self.shutdown.clone(), } @@ -307,13 +371,16 @@ impl Picker { self } + pub fn with_line(mut self, line: String, editor: &Editor) -> Self { + self.prompt.set_line(line, editor); + self + } + pub fn set_options(&mut self, new_options: Vec) { self.matcher.restart(false); let injector = self.matcher.injector(); for item in new_options { - if let Some((item, matcher_text)) = item_to_nucleo(item, &self.editor_data) { - injector.push(item, |dst| dst[0] = matcher_text); - } + inject_nucleo_item(&injector, &self.columns, item, &self.editor_data); } } @@ -367,22 +434,55 @@ impl Picker { .map(|item| item.data) } + fn primary_query(&self) -> Option<&str> { + self.query + .get(self.column_names[self.primary_column]) + .map(AsRef::as_ref) + } + + fn header_height(&self) -> u16 { + if self.columns.len() > 1 { + // The header is one line for the column names and + // one line for the separator bars. + 2 + } else { + 0 + } + } + pub fn toggle_preview(&mut self) { self.show_preview = !self.show_preview; } fn prompt_handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { if let EventResult::Consumed(_) = self.prompt.handle_event(event, cx) { - let pattern = self.prompt.line(); // TODO: better track how the pattern has changed - if pattern != &self.previous_pattern { - self.matcher.pattern.reparse( - 0, - pattern, - CaseMatching::Smart, - pattern.starts_with(&self.previous_pattern), - ); - self.previous_pattern = pattern.clone(); + let line = self.prompt.line(); + let new_query = parse_query(&self.column_names, self.primary_column, line); + if new_query != self.query { + for (i, column) in self + .columns + .iter() + .filter(|column| column.filter) + .enumerate() + { + let pattern = new_query + .get(column.name) + .map(|pattern| pattern.as_str()) + .unwrap_or_default(); + let append = self + .query + .get(column.name) + .map(|old_pattern| { + pattern.starts_with(old_pattern) && !old_pattern.ends_with('\\') + }) + .unwrap_or(false); + + self.matcher + .pattern + .reparse(i, pattern, CaseMatching::Smart, append); + } + self.query = new_query; } } EventResult::Consumed(None) @@ -477,7 +577,7 @@ impl Picker { let picker = match compositor.find::>() { Some(Overlay { content, .. }) => Some(content), None => compositor - .find::>>() + .find::>>() .map(|overlay| &mut overlay.content.file_picker), }; let Some(picker) = picker else { @@ -576,7 +676,7 @@ impl Picker { // -- Render the contents: // subtract area of prompt from top let inner = inner.clip_top(2); - let rows = inner.height as u32; + let rows = inner.height.saturating_sub(self.header_height()) as u32; let offset = self.cursor - (self.cursor % std::cmp::max(1, rows)); let cursor = self.cursor.saturating_sub(offset); let end = offset @@ -590,83 +690,108 @@ impl Picker { } let options = snapshot.matched_items(offset..end).map(|item| { - snapshot.pattern().column_pattern(0).indices( - item.matcher_columns[0].slice(..), - &mut matcher, - &mut indices, - ); - indices.sort_unstable(); - indices.dedup(); - let mut row = item.data.format(&self.editor_data); - - let mut grapheme_idx = 0u32; - let mut indices = indices.drain(..); - let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX); - if self.widths.len() < row.cells.len() { - self.widths.resize(row.cells.len(), Constraint::Length(0)); - } let mut widths = self.widths.iter_mut(); - for cell in &mut row.cells { + let mut matcher_index = 0; + + Row::new(self.columns.iter().map(|column| { let Some(Constraint::Length(max_width)) = widths.next() else { unreachable!(); }; - - // merge index highlights on top of existing hightlights - let mut span_list = Vec::new(); - let mut current_span = String::new(); - let mut current_style = Style::default(); - let mut width = 0; - - let spans: &[Span] = cell.content.lines.first().map_or(&[], |it| it.0.as_slice()); - for span in spans { - // this looks like a bug on first glance, we are iterating - // graphemes but treating them as char indices. The reason that - // this is correct is that nucleo will only ever consider the first char - // of a grapheme (and discard the rest of the grapheme) so the indices - // returned by nucleo are essentially grapheme indecies - for grapheme in span.content.graphemes(true) { - let style = if grapheme_idx == next_highlight_idx { - next_highlight_idx = indices.next().unwrap_or(u32::MAX); - span.style.patch(highlight_style) - } else { - span.style - }; - if style != current_style { - if !current_span.is_empty() { - span_list.push(Span::styled(current_span, current_style)) + let mut cell = column.format(item.data, &self.editor_data); + let width = if column.filter { + snapshot.pattern().column_pattern(matcher_index).indices( + item.matcher_columns[matcher_index].slice(..), + &mut matcher, + &mut indices, + ); + indices.sort_unstable(); + indices.dedup(); + let mut indices = indices.drain(..); + let mut next_highlight_idx = indices.next().unwrap_or(u32::MAX); + let mut span_list = Vec::new(); + let mut current_span = String::new(); + let mut current_style = Style::default(); + let mut grapheme_idx = 0u32; + let mut width = 0; + + let spans: &[Span] = + cell.content.lines.first().map_or(&[], |it| it.0.as_slice()); + for span in spans { + // this looks like a bug on first glance, we are iterating + // graphemes but treating them as char indices. The reason that + // this is correct is that nucleo will only ever consider the first char + // of a grapheme (and discard the rest of the grapheme) so the indices + // returned by nucleo are essentially grapheme indecies + for grapheme in span.content.graphemes(true) { + let style = if grapheme_idx == next_highlight_idx { + next_highlight_idx = indices.next().unwrap_or(u32::MAX); + span.style.patch(highlight_style) + } else { + span.style + }; + if style != current_style { + if !current_span.is_empty() { + span_list.push(Span::styled(current_span, current_style)) + } + current_span = String::new(); + current_style = style; } - current_span = String::new(); - current_style = style; + current_span.push_str(grapheme); + grapheme_idx += 1; } - current_span.push_str(grapheme); - grapheme_idx += 1; + width += span.width(); } - width += span.width(); - } - span_list.push(Span::styled(current_span, current_style)); + span_list.push(Span::styled(current_span, current_style)); + cell = Cell::from(Spans::from(span_list)); + matcher_index += 1; + width + } else { + cell.content + .lines + .first() + .map(|line| line.width()) + .unwrap_or_default() + }; + if width as u16 > *max_width { *max_width = width as u16; } - *cell = Cell::from(Spans::from(span_list)); - - // spacer - if grapheme_idx == next_highlight_idx { - next_highlight_idx = indices.next().unwrap_or(u32::MAX); - } - grapheme_idx += 1; - } - row + cell + })) }); - let table = Table::new(options) + let mut table = Table::new(options) .style(text_style) .highlight_style(selected) .highlight_symbol(" > ") .column_spacing(1) .widths(&self.widths); + // -- Header + if self.columns.len() > 1 { + let header_text_style = cx.editor.theme.get("ui.picker.header.text"); + let header_separator_style = cx.editor.theme.get("ui.picker.header.separator"); + + table = table.header( + Row::new(self.columns.iter().zip(self.widths.iter()).map( + |(column, constraint)| { + let separator_len = constraint.apply(inner.width); + let separator = borders.horizontal.repeat(separator_len as usize); + + Cell::from(tui::text::Text { + lines: vec![ + Span::styled(column.name, header_text_style).into(), + Span::styled(separator, header_separator_style).into(), + ], + }) + }, + )) + .height(2), + ); + } + use tui::widgets::TableState; table.render_table( @@ -797,7 +922,7 @@ impl Picker { } } -impl Component for Picker { +impl Component for Picker { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // +---------+ +---------+ // |prompt | |preview | @@ -929,7 +1054,7 @@ impl Component for Picker { } fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> { - self.completion_height = height.saturating_sub(4); + self.completion_height = height.saturating_sub(4 + self.header_height()); Some((width, height)) } @@ -937,7 +1062,7 @@ impl Component for Picker { Some(ID) } } -impl Drop for Picker { +impl Drop for Picker { fn drop(&mut self) { // ensure we cancel any ongoing background threads streaming into the picker self.shutdown.store(true, atomic::Ordering::Relaxed) @@ -946,6 +1071,83 @@ impl Drop for Picker { type PickerCallback = Box; +fn parse_query( + column_names: &[&'static str], + primary_column: usize, + input: &str, +) -> HashMap<&'static str, String> { + let mut fields: HashMap<&'static str, String> = HashMap::new(); + let primary_field = column_names[primary_column]; + let mut escaped = false; + let mut quoted = false; + let mut in_field = false; + let mut field = None; + let mut text = String::new(); + + macro_rules! finish_field { + () => { + let key = field.take().unwrap_or(primary_field); + + if let Some(pattern) = fields.get_mut(key) { + pattern.push(' '); + pattern.push_str(&text); + text.clear(); + } else { + fields.insert(key, std::mem::take(&mut text)); + } + }; + } + + for ch in input.chars() { + match ch { + // Backslash escaping + '\\' => escaped = !escaped, + _ if escaped => { + // Keep backslash escapes for special nucleo syntax. + if matches!(ch, '^' | '$' | '!' | '\'') { + text.push('\\'); + } + text.push(ch); + escaped = false; + } + // Double quoting + '"' => quoted = !quoted, + ' ' if quoted => { + text.push('\\'); + text.push(' '); + } + '%' | ':' if quoted => text.push(ch), + // Space either completes the current word if no field is specified + // or field if one is specified. + '%' | ' ' if !text.is_empty() => { + finish_field!(); + in_field = ch == '%'; + } + '%' => in_field = true, + ' ' => (), + ':' if in_field => { + // Go over all columns and their indices, find all that starts with field key, + // select a column that fits key the most. + field = column_names + .iter() + .filter(|col| col.starts_with(&text)) + // select "fittest" column + .min_by_key(|col| col.len()) + .copied(); + text.clear(); + in_field = false; + } + _ => text.push(ch), + } + } + + if !in_field && !text.is_empty() { + finish_field!(); + } + + fields +} + /// Returns a new list of options to replace the contents of the picker /// when called with the current picker query, pub type DynQueryCallback = @@ -953,14 +1155,14 @@ pub type DynQueryCallback = /// A picker that updates its contents via a callback whenever the /// query string changes. Useful for live grep, workspace symbols, etc. -pub struct DynamicPicker { - file_picker: Picker, +pub struct DynamicPicker { + file_picker: Picker, query_callback: DynQueryCallback, query: String, } -impl DynamicPicker { - pub fn new(file_picker: Picker, query_callback: DynQueryCallback) -> Self { +impl DynamicPicker { + pub fn new(file_picker: Picker, query_callback: DynQueryCallback) -> Self { Self { file_picker, query_callback, @@ -969,20 +1171,22 @@ impl DynamicPicker { } } -impl Component for DynamicPicker { +impl Component for DynamicPicker { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { self.file_picker.render(area, surface, cx); } fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { let event_result = self.file_picker.handle_event(event, cx); - let current_query = self.file_picker.prompt.line(); + let Some(current_query) = self.file_picker.primary_query() else { + return event_result; + }; if !matches!(event, Event::IdleTimeout) || self.query == *current_query { return event_result; } - self.query.clone_from(current_query); + self.query = current_query.to_string(); let new_options = (self.query_callback)(current_query.to_owned(), cx.editor); @@ -991,7 +1195,7 @@ impl Component for DynamicPicker { let callback = Callback::EditorCompositor(Box::new(move |editor, compositor| { // Wrapping of pickers in overlay is done outside the picker code, // so this is fragile and will break if wrapped in some other widget. - let picker = match compositor.find_id::>>(ID) { + let picker = match compositor.find_id::>(ID) { Some(overlay) => &mut overlay.content.file_picker, None => return, }; @@ -1015,3 +1219,111 @@ impl Component for DynamicPicker { Some(ID) } } + +#[cfg(test)] +mod test { + use helix_core::hashmap; + + use super::*; + + #[test] + fn parse_query_test() { + let columns = &["primary", "field1", "field2", "another", "anode"]; + let primary_column = 0; + + // Basic field splitting + assert_eq!( + parse_query(columns, primary_column, "hello world"), + hashmap!( + "primary" => "hello world".to_string(), + ) + ); + assert_eq!( + parse_query(columns, primary_column, "hello %field1:world %field2:!"), + hashmap!( + "primary" => "hello".to_string(), + "field1" => "world".to_string(), + "field2" => "!".to_string(), + ) + ); + assert_eq!( + parse_query(columns, primary_column, "%field1:abc %field2:def xyz"), + hashmap!( + "primary" => "xyz".to_string(), + "field1" => "abc".to_string(), + "field2" => "def".to_string(), + ) + ); + + // Trailing space is trimmed + assert_eq!( + parse_query(columns, primary_column, "hello "), + hashmap!( + "primary" => "hello".to_string(), + ) + ); + + // Trailing fields are trimmed. + assert_eq!( + parse_query(columns, primary_column, "hello %foo"), + hashmap!( + "primary" => "hello".to_string(), + ) + ); + + // Quoting + assert_eq!( + parse_query(columns, primary_column, "hello %field1:\"a b c\""), + hashmap!( + "primary" => "hello".to_string(), + "field1" => "a\\ b\\ c".to_string(), + ) + ); + + // Escaping + assert_eq!( + parse_query(columns, primary_column, "hello \\%field1:world"), + hashmap!( + "primary" => "hello %field1:world".to_string(), + ) + ); + assert_eq!( + // hello %field1:"a\"b" + parse_query(columns, primary_column, "hello %field1:\"a\\\"b\""), + hashmap!( + "primary" => "hello".to_string(), + "field1" => "a\"b".to_string(), + ) + ); + + // Prefix + assert_eq!( + parse_query(columns, primary_column, "hello %anot:abc"), + hashmap!( + "primary" => "hello".to_string(), + "another" => "abc".to_string(), + ) + ); + assert_eq!( + parse_query(columns, primary_column, "hello %ano:abc"), + hashmap!( + "primary" => "hello".to_string(), + "anode" => "abc".to_string() + ) + ); + assert_eq!( + parse_query(columns, primary_column, "hello %field1:xyz %fie:abc"), + hashmap!( + "primary" => "hello".to_string(), + "field1" => "xyz abc".to_string() + ) + ); + assert_eq!( + parse_query(columns, primary_column, "hello %fie:abc"), + hashmap!( + "primary" => "hello".to_string(), + "field1" => "abc".to_string() + ) + ); + } +} diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 702a6e6714ad..1b8a3d398c47 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -91,11 +91,15 @@ impl Prompt { } pub fn with_line(mut self, line: String, editor: &Editor) -> Self { + self.set_line(line, editor); + self + } + + pub fn set_line(&mut self, line: String, editor: &Editor) { let cursor = line.len(); self.line = line; self.cursor = cursor; self.recalculate_completion(editor); - self } pub fn with_language(mut self, language: &'static str, loader: Arc) -> Self {