From 9b08ee0515729d8fbb88c7df0f80ff47f7ed905a Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Sun, 3 Sep 2023 16:09:50 -0500 Subject: [PATCH 1/3] Refactor Picker in terms of columns `menu::Item` is replaced with column configurations for each picker which control how a column is displayed and whether it is passed to nucleo for filtering. (This is used for dynamic pickers so that we can filter those items with the dynamic picker callback rather than nucleo.) The picker has a new lucene-like syntax that can be used to filter the picker only on certain criteria. If a filter is not specified, the text in the prompt applies to the picker's configured "primary" column. Adding column configurations for each picker is left for the child commit. --- helix-term/src/commands.rs | 14 +- helix-term/src/commands/dap.rs | 18 +- helix-term/src/commands/lsp.rs | 35 +- helix-term/src/commands/typed.rs | 8 +- helix-term/src/ui/mod.rs | 33 +- helix-term/src/ui/picker.rs | 530 +++++++++++++++++++++++-------- helix-term/src/ui/prompt.rs | 6 +- 7 files changed, 484 insertions(+), 160 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 4df3278b8bba..06014cf74dbb 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2222,7 +2222,9 @@ fn global_search(cx: &mut Context) { return; } - let (picker, injector) = Picker::stream(current_path); + // TODO + let columns = vec![]; + let (picker, injector) = Picker::stream(columns, current_path); let dedup_symlinks = file_picker_config.deduplicate_links; let absolute_root = search_root @@ -2325,6 +2327,7 @@ fn global_search(cx: &mut Context) { let call = move |_: &mut Editor, compositor: &mut Compositor| { let picker = Picker::with_stream( picker, + 0, injector, move |cx, FileResult { path, line_num }, action| { let doc = match cx.editor.open(path, action) { @@ -2787,7 +2790,8 @@ 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![]; + let picker = Picker::new(columns, 0, items, (), |cx, meta, action| { cx.editor.switch(meta.id, action); }) .with_preview(|editor, meta| { @@ -2864,7 +2868,10 @@ fn jumplist_picker(cx: &mut Context) { } }; + let columns = vec![]; let picker = Picker::new( + columns, + 0, cx.editor .tree .views() @@ -2941,7 +2948,8 @@ pub fn command_palette(cx: &mut Context) { } })); - let picker = Picker::new(commands, keymap, move |cx, command, _action| { + let columns = vec![]; + 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..546722635e4a 100644 --- a/helix-term/src/commands/dap.rs +++ b/helix-term/src/commands/dap.rs @@ -73,9 +73,14 @@ 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![]; + 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 +273,11 @@ pub fn dap_launch(cx: &mut Context) { let templates = config.templates.clone(); + let columns = vec![]; + cx.push_layer(Box::new(overlaid(Picker::new( + columns, + 0, templates, (), |cx, template, _action| { @@ -735,7 +744,8 @@ 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![]; + 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..4bf25a853fdd 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -234,18 +234,25 @@ fn jump_to_location( } } -type SymbolPicker = Picker; +type SymbolPicker = Picker>; 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, - ); - }) + let columns = vec![]; + Picker::new( + columns, + 0, + symbols, + current_path, + 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 +263,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,7 +296,10 @@ fn diag_picker( error: cx.editor.theme.get("error"), }; + let columns = vec![]; Picker::new( + columns, + 0, flat_diag, (styles, format), move |cx, @@ -1023,7 +1035,8 @@ fn goto_impl( editor.set_error("No definition found."); } _locations => { - let picker = Picker::new(locations, cwdir, move |cx, location, action| { + let columns = vec![]; + 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..b7bd77dfcbaf 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1392,9 +1392,11 @@ 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![]; + 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/mod.rs b/helix-term/src/ui/mod.rs index efa2473e01ed..08042483c284 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,23 @@ 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![]; + 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..71b32298b502 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) @@ -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,79 @@ 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 => { + field = column_names + .iter() + .position(|name| name == &text) + .map(|idx| column_names[idx]); + 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 +1151,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 +1167,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 +1191,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 +1215,81 @@ 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"]; + 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(), + ) + ); + } +} 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 { From d5d73204201ca73277df3221b62a247cde4bc265 Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Sun, 3 Sep 2023 14:47:17 -0500 Subject: [PATCH 2/3] Add column configurations for existing pickers This removes the menu::Item implementations for picker item types and adds `Vec>` configurations. --- helix-term/src/commands.rs | 361 +++++++++++++++---------------- helix-term/src/commands/dap.rs | 54 ++--- helix-term/src/commands/lsp.rs | 277 +++++++++++++----------- helix-term/src/commands/typed.rs | 5 +- helix-term/src/ui/menu.rs | 14 +- helix-term/src/ui/mod.rs | 10 +- helix-term/src/ui/picker.rs | 2 +- 7 files changed, 362 insertions(+), 361 deletions(-) diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 06014cf74dbb..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,8 +2203,28 @@ fn global_search(cx: &mut Context) { return; } - // TODO - let columns = vec![]; + 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; @@ -2250,77 +2251,79 @@ 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 { @@ -2329,7 +2332,7 @@ fn global_search(cx: &mut Context) { 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) => { @@ -2361,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)))) }, ); @@ -2747,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(), @@ -2790,8 +2768,31 @@ fn buffer_picker(cx: &mut Context) { // mru items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at)); - let columns = vec![]; - let picker = Picker::new(columns, 0, 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| { @@ -2815,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); @@ -2868,10 +2842,37 @@ fn jumplist_picker(cx: &mut Context) { } }; - let columns = vec![]; + 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, - 0, + 1, // path cx.editor .tree .views() @@ -2900,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; @@ -2948,7 +2920,34 @@ pub fn command_palette(cx: &mut Context) { } })); - let columns = vec![]; + 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, diff --git a/helix-term/src/commands/dap.rs b/helix-term/src/commands/dap.rs index 546722635e4a..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,7 +41,16 @@ fn thread_picker( let debugger = debugger!(editor); let thread_states = debugger.thread_states.clone(); - let columns = vec![]; + 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, @@ -273,7 +250,10 @@ pub fn dap_launch(cx: &mut Context) { let templates = config.templates.clone(); - let columns = vec![]; + let columns = vec![ui::PickerColumn::new( + "template", + |item: &DebugTemplate, _| item.name.as_str().into(), + )]; cx.push_layer(Box::new(overlaid(Picker::new( columns, @@ -744,7 +724,9 @@ pub fn dap_switch_stack_frame(cx: &mut Context) { let frames = debugger.stack_frames[&thread_id].clone(); - let columns = vec![]; + 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 diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index 4bf25a853fdd..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,16 +129,82 @@ 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); + "" + } + } +} + +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() + }), + ] + }; -fn sym_picker(symbols: Vec, current_path: Option) -> SymbolPicker { - // TODO: drop current_path comparison and instead use workspace: bool flag? - let columns = vec![]; Picker::new( columns, - 0, + 1, // name column symbols, - current_path, + (), move |cx, item, action| { jump_to_location( cx.editor, @@ -263,7 +224,7 @@ enum DiagnosticsFormat { HideSourcePath, } -type DiagnosticsPicker = Picker; +type DiagnosticsPicker = Picker; fn diag_picker( cx: &Context, @@ -296,12 +257,51 @@ fn diag_picker( error: cx.editor.theme.get("error"), }; - let columns = vec![]; + 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, - 0, + primary_column, flat_diag, - (styles, format), + styles, move |cx, PickerDiagnostic { url, @@ -388,7 +388,6 @@ pub fn symbol_picker(cx: &mut Context) { } }) .collect(); - let current_url = doc.url(); if futures.is_empty() { cx.editor @@ -403,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))) }; @@ -465,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))) }; @@ -769,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 @@ -1035,7 +1026,37 @@ fn goto_impl( editor.set_error("No definition found."); } _locations => { - let columns = vec![]; + 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) }) diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index b7bd77dfcbaf..417a6f5d440d 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -1392,7 +1392,10 @@ fn lsp_workspace_command( let callback = async move { let call: job::Callback = Callback::EditorCompositor(Box::new( move |_editor: &mut Editor, compositor: &mut Compositor| { - let columns = vec![]; + 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()); 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 08042483c284..b4ee7eb9c6c4 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -204,7 +204,15 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi }); log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); - let columns = vec![]; + 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, diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 71b32298b502..d0194a33cf25 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -577,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 { From 3a8ce2198be5f43547bde4f9a19b590d2b6e0674 Mon Sep 17 00:00:00 2001 From: Michael Davis Date: Tue, 23 Jan 2024 10:19:25 -0500 Subject: [PATCH 3/3] Select fields by substring in the picker query So you can select a field by a substring of its name, like `%p:syntax.rs` to specify the `path` field as "syntax.rs". Co-authored-by: ItsEthra <107059409+ItsEthra@users.noreply.github.com> --- helix-term/src/ui/picker.rs | 40 ++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index d0194a33cf25..647650f84c0f 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1126,10 +1126,14 @@ fn parse_query( '%' => 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() - .position(|name| name == &text) - .map(|idx| column_names[idx]); + .filter(|col| col.starts_with(&text)) + // select "fittest" column + .min_by_key(|col| col.len()) + .copied(); text.clear(); in_field = false; } @@ -1224,7 +1228,7 @@ mod test { #[test] fn parse_query_test() { - let columns = &["primary", "field1", "field2"]; + let columns = &["primary", "field1", "field2", "another", "anode"]; let primary_column = 0; // Basic field splitting @@ -1291,5 +1295,35 @@ mod test { "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() + ) + ); } }