diff --git a/book/src/themes.md b/book/src/themes.md index e3b95c0a..a59df2fd 100644 --- a/book/src/themes.md +++ b/book/src/themes.md @@ -297,6 +297,7 @@ These scopes are used for theming the editor interface: | `ui.bufferline.background` | Style for bufferline background | | `ui.popup` | Documentation popups (e.g. Space + k) | | `ui.popup.info` | Prompt for multiple key options | +| `ui.picker.header` | Column names in pickers with multiple columns | | `ui.window` | Borderlines separating splits | | `ui.help` | Description box for commands | | `ui.text` | Default text style, command prompts, popup text, etc. | diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 69496eb6..5600a1e4 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -2317,7 +2317,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 @@ -2420,6 +2422,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) { @@ -2937,7 +2940,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| { @@ -3014,7 +3018,10 @@ fn jumplist_picker(cx: &mut Context) { } }; + let columns = vec![]; let picker = Picker::new( + columns, + 0, cx.editor .tree .views() @@ -3180,7 +3187,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 0e50377a..da2b60da 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| { @@ -736,7 +745,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 d585e1be..bf9747a4 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -241,18 +241,25 @@ fn jump_to_position( } } -type SymbolPicker = Picker<SymbolInformationItem>; +type SymbolPicker = Picker<SymbolInformationItem, Option<lsp::Url>>; fn sym_picker(symbols: Vec<SymbolInformationItem>, current_path: Option<lsp::Url>) -> 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) } @@ -263,11 +270,13 @@ enum DiagnosticsFormat { HideSourcePath, } +type DiagnosticsPicker = Picker<PickerDiagnostic, (DiagnosticStyles, DiagnosticsFormat)>; + fn diag_picker( cx: &Context, diagnostics: BTreeMap<PathBuf, Vec<(lsp::Diagnostic, LanguageServerId)>>, format: DiagnosticsFormat, -) -> Picker<PickerDiagnostic> { +) -> DiagnosticsPicker { // TODO: drop current_path comparison and instead use workspace: bool flag? // flatten the map to a vec of (url, diag) pairs @@ -293,7 +302,10 @@ fn diag_picker( error: cx.editor.theme.get("error"), }; + let columns = vec![]; Picker::new( + columns, + 0, flat_diag, (styles, format), move |cx, @@ -817,7 +829,8 @@ fn goto_impl( } [] => unreachable!("`locations` should be non-empty for `goto_impl`"), _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 ed1547f1..232e5846 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -9,7 +9,6 @@ use super::*; use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; use helix_core::{line_ending, shellwords::Shellwords}; -use helix_lsp::LanguageServerId; use helix_view::document::{read_to_string, DEFAULT_LANGUAGE_NAME}; use helix_view::editor::{CloseError, ConfigEvent}; use serde_json::Value; @@ -1378,16 +1377,6 @@ fn lsp_workspace_command( return Ok(()); } - struct LsIdCommand(LanguageServerId, helix_lsp::lsp::Command); - - impl ui::menu::Item for LsIdCommand { - type Data = (); - - fn format(&self, _data: &Self::Data) -> Row { - self.1.title.as_str().into() - } - } - let doc = doc!(cx.editor); let ls_id_commands = doc .language_servers_with_feature(LanguageServerFeature::WorkspaceCommand) @@ -1402,7 +1391,7 @@ fn lsp_workspace_command( if args.is_empty() { let commands = ls_id_commands .map(|(ls_id, command)| { - LsIdCommand( + ( ls_id, helix_lsp::lsp::Command { title: command.clone(), @@ -1415,10 +1404,13 @@ 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 picker = ui::Picker::new( + columns, + 0, commands, (), - move |cx, LsIdCommand(ls_id, command), _action| { + move |cx, (ls_id, command), _action| { execute_lsp_command(cx.editor, *ls_id, command.clone()); }, ); diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 0a65b12b..01b718d4 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -21,7 +21,7 @@ pub use editor::EditorView; use helix_stdx::rope; 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}; @@ -170,7 +170,9 @@ pub fn raw_regex_prompt( cx.push_layer(Box::new(prompt)); } -pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker<PathBuf> { +type FilePicker = Picker<PathBuf, PathBuf>; + +pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker { use ignore::{types::TypesBuilder, WalkBuilder}; use std::time::Instant; @@ -217,16 +219,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 cc86a4fa..ab8e4e15 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -21,12 +21,13 @@ use tui::{ buffer::Buffer as Surface, layout::Constraint, text::{Span, Spans}, - widgets::{Block, BorderType, Cell, Table}, + widgets::{Block, BorderType, Cell, Row, Table}, }; use tui::widgets::Widget; use std::{ + borrow::Cow, collections::HashMap, io::Read, path::{Path, PathBuf}, @@ -49,9 +50,9 @@ use helix_view::{ Document, DocumentId, Editor, }; -use self::handlers::PreviewHighlightHandler; +use super::overlay::Overlay; -use super::{menu::Item, overlay::Overlay}; +use self::handlers::PreviewHighlightHandler; pub const ID: &str = "picker"; @@ -129,38 +130,36 @@ impl Preview<'_, '_> { } } -fn item_to_nucleo<T: Item>(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<T, D>( + injector: &nucleo::Injector<T>, + columns: &[Column<T, D>], + item: T, + editor_data: &D, +) { + let column_texts: Vec<Utf32String> = 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<T: Item> { +pub struct Injector<T, D> { dst: nucleo::Injector<T>, - editor_data: Arc<T::Data>, + columns: Arc<[Column<T, D>]>, + editor_data: Arc<D>, shutown: Arc<AtomicBool>, } -impl<T: Item> Clone for Injector<T> { +impl<I, D> Clone for Injector<I, D> { fn clone(&self) -> Self { Injector { dst: self.dst.clone(), + columns: self.columns.clone(), editor_data: self.editor_data.clone(), shutown: self.shutown.clone(), } @@ -169,21 +168,56 @@ impl<T: Item> Clone for Injector<T> { pub struct InjectorShutdown; -impl<T: Item> Injector<T> { +impl<T, D> Injector<T, D> { 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<T: Item> { - editor_data: Arc<T::Data>, +type ColumnFormatFn<T, D> = for<'a> fn(&'a T, &'a D) -> Cell<'a>; + +pub struct Column<T, D> { + name: Arc<str>, + format: ColumnFormatFn<T, D>, + /// 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<T, D> Column<T, D> { + pub fn new(name: impl Into<Arc<str>>, format: ColumnFormatFn<T, D>) -> Self { + Self { + name: name.into(), + 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<T: 'static + Send + Sync, D: 'static> { + columns: Arc<[Column<T, D>]>, + primary_column: usize, + editor_data: Arc<D>, shutdown: Arc<AtomicBool>, matcher: Nucleo<T>, @@ -211,16 +245,19 @@ pub struct Picker<T: Item> { preview_highlight_handler: Sender<Arc<Path>>, } -impl<T: Item + 'static> Picker<T> { - pub fn stream(editor_data: T::Data) -> (Nucleo<T>, Injector<T>) { +impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Picker<T, D> { + pub fn stream(columns: Vec<Column<T, D>>, editor_data: D) -> (Nucleo<T>, Injector<T, D>) { + 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: columns.into(), editor_data: Arc::new(editor_data), shutown: Arc::new(AtomicBool::new(false)), }; @@ -228,24 +265,28 @@ impl<T: Item + 'static> Picker<T> { } pub fn new( + columns: Vec<Column<T, D>>, + primary_column: usize, options: Vec<T>, - 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, + columns.into(), + primary_column, Arc::new(editor_data), Arc::new(AtomicBool::new(false)), callback_fn, @@ -254,18 +295,30 @@ impl<T: Item + 'static> Picker<T> { pub fn with_stream( matcher: Nucleo<T>, - injector: Injector<T>, + primary_column: usize, + injector: Injector<T, D>, 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<T>, - editor_data: Arc<T::Data>, + columns: Arc<[Column<T, D>]>, + default_column: usize, + editor_data: Arc<D>, shutdown: Arc<AtomicBool>, callback_fn: impl Fn(&mut Context, &T, Action) + 'static, ) -> Self { + assert!(!columns.is_empty()); + let prompt = Prompt::new( "".into(), None, @@ -273,7 +326,14 @@ impl<T: Item + 'static> Picker<T> { |_editor: &mut Context, _pattern: &str, _event: PromptEvent| {}, ); + let widths = columns + .iter() + .map(|column| Constraint::Length(column.name.chars().count() as u16)) + .collect(); + Self { + columns, + primary_column: default_column, matcher, editor_data, shutdown, @@ -284,17 +344,18 @@ impl<T: Item + 'static> Picker<T> { 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, - preview_highlight_handler: PreviewHighlightHandler::<T>::default().spawn(), + preview_highlight_handler: PreviewHighlightHandler::<T, D>::default().spawn(), } } - pub fn injector(&self) -> Injector<T> { + pub fn injector(&self) -> Injector<T, D> { Injector { dst: self.matcher.injector(), + columns: self.columns.clone(), editor_data: self.editor_data.clone(), shutown: self.shutdown.clone(), } @@ -316,13 +377,17 @@ impl<T: Item + 'static> Picker<T> { self } + pub fn with_line(mut self, line: String, editor: &Editor) -> Self { + self.prompt.set_line(line, editor); + self.handle_prompt_change(); + self + } + pub fn set_options(&mut self, new_options: Vec<T>) { 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); } } @@ -376,27 +441,39 @@ impl<T: Item + 'static> Picker<T> { .map(|item| item.data) } + fn header_height(&self) -> u16 { + if self.columns.len() > 1 { + 1 + } 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(); - } + self.handle_prompt_change(); } EventResult::Consumed(None) } + fn handle_prompt_change(&mut self) { + 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(); + } + } + fn current_file(&self, editor: &Editor) -> Option<FileLocation> { self.selection() .and_then(|current| (self.file_fn.as_ref()?)(editor, current)) @@ -526,7 +603,7 @@ impl<T: Item + 'static> Picker<T> { // -- 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 @@ -540,83 +617,94 @@ impl<T: Item + 'static> Picker<T> { } 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!(); }; + 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; - // 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 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_style = cx.editor.theme.get("ui.picker.header"); + + table = table.header(Row::new(self.columns.iter().map(|column| { + Cell::from(Span::styled(Cow::from(&*column.name), header_style)) + }))); + } + use tui::widgets::TableState; table.render_table( @@ -746,7 +834,7 @@ impl<T: Item + 'static> Picker<T> { } } -impl<T: Item + 'static + Send + Sync> Component for Picker<T> { +impl<I: 'static + Send + Sync, D: 'static + Send + Sync> Component for Picker<I, D> { fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // +---------+ +---------+ // |prompt | |preview | @@ -872,7 +960,7 @@ impl<T: Item + 'static + Send + Sync> Component for Picker<T> { } 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)) } @@ -880,7 +968,7 @@ impl<T: Item + 'static + Send + Sync> Component for Picker<T> { Some(ID) } } -impl<T: Item> Drop for Picker<T> { +impl<T: 'static + Send + Sync, D> Drop for Picker<T, D> { fn drop(&mut self) { // ensure we cancel any ongoing background threads streaming into the picker self.shutdown.store(true, atomic::Ordering::Relaxed) @@ -896,14 +984,14 @@ pub type DynQueryCallback<T> = /// A picker that updates its contents via a callback whenever the /// query string changes. Useful for live grep, workspace symbols, etc. -pub struct DynamicPicker<T: ui::menu::Item + Send + Sync> { - file_picker: Picker<T>, +pub struct DynamicPicker<T: 'static + Send + Sync, D: 'static + Send + Sync> { + file_picker: Picker<T, D>, query_callback: DynQueryCallback<T>, query: String, } -impl<T: ui::menu::Item + Send + Sync> DynamicPicker<T> { - pub fn new(file_picker: Picker<T>, query_callback: DynQueryCallback<T>) -> Self { +impl<T: Send + Sync, D: Send + Sync> DynamicPicker<T, D> { + pub fn new(file_picker: Picker<T, D>, query_callback: DynQueryCallback<T>) -> Self { Self { file_picker, query_callback, @@ -912,20 +1000,22 @@ impl<T: ui::menu::Item + Send + Sync> DynamicPicker<T> { } } -impl<T: Item + Send + Sync + 'static> Component for DynamicPicker<T> { +impl<T: Send + Sync + 'static, D: Send + Sync + 'static> Component for DynamicPicker<T, D> { 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); @@ -934,7 +1024,7 @@ impl<T: Item + Send + Sync + 'static> Component for DynamicPicker<T> { 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::<Overlay<DynamicPicker<T>>>(ID) { + let picker = match compositor.find_id::<Overlay<Self>>(ID) { Some(overlay) => &mut overlay.content.file_picker, None => return, }; diff --git a/helix-term/src/ui/picker/handlers.rs b/helix-term/src/ui/picker/handlers.rs index 7a77efa4..f01c982a 100644 --- a/helix-term/src/ui/picker/handlers.rs +++ b/helix-term/src/ui/picker/handlers.rs @@ -3,19 +3,16 @@ use std::{path::Path, sync::Arc, time::Duration}; use helix_event::AsyncHook; use tokio::time::Instant; -use crate::{ - job, - ui::{menu::Item, overlay::Overlay}, -}; +use crate::{job, ui::overlay::Overlay}; use super::{CachedPreview, DynamicPicker, Picker}; -pub(super) struct PreviewHighlightHandler<T: Item> { +pub(super) struct PreviewHighlightHandler<T: 'static + Send + Sync, D: 'static + Send + Sync> { trigger: Option<Arc<Path>>, - phantom_data: std::marker::PhantomData<T>, + phantom_data: std::marker::PhantomData<(T, D)>, } -impl<T: Item> Default for PreviewHighlightHandler<T> { +impl<T: 'static + Send + Sync, D: 'static + Send + Sync> Default for PreviewHighlightHandler<T, D> { fn default() -> Self { Self { trigger: None, @@ -24,7 +21,9 @@ impl<T: Item> Default for PreviewHighlightHandler<T> { } } -impl<T: Item> AsyncHook for PreviewHighlightHandler<T> { +impl<T: 'static + Send + Sync, D: 'static + Send + Sync> AsyncHook + for PreviewHighlightHandler<T, D> +{ type Event = Arc<Path>; fn handle_event( @@ -51,9 +50,9 @@ impl<T: Item> AsyncHook for PreviewHighlightHandler<T> { }; job::dispatch_blocking(move |editor, compositor| { - let picker = match compositor.find::<Overlay<Picker<T>>>() { + let picker = match compositor.find::<Overlay<Picker<T, D>>>() { Some(Overlay { content, .. }) => content, - None => match compositor.find::<Overlay<DynamicPicker<T>>>() { + None => match compositor.find::<Overlay<DynamicPicker<T, D>>>() { Some(Overlay { content, .. }) => &mut content.file_picker, None => return, }, @@ -88,10 +87,10 @@ impl<T: Item> AsyncHook for PreviewHighlightHandler<T> { }; job::dispatch_blocking(move |editor, compositor| { - let picker = match compositor.find::<Overlay<Picker<T>>>() { + let picker = match compositor.find::<Overlay<Picker<T, D>>>() { Some(Overlay { content, .. }) => Some(content), None => compositor - .find::<Overlay<DynamicPicker<T>>>() + .find::<Overlay<DynamicPicker<T, D>>>() .map(|overlay| &mut overlay.content.file_picker), }; let Some(picker) = picker else { diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 14b242df..19183470 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -93,11 +93,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(