Merge pull request #7264 from the-mikedavis/merge-picker-and-filepicker

Merge FilePicker into Picker
pull/7382/head
Pascal Kuthe 1 year ago committed by GitHub
commit 06d63d6ac1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -55,8 +55,8 @@ use crate::{
job::Callback, job::Callback,
keymap::ReverseKeymap, keymap::ReverseKeymap,
ui::{ ui::{
self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, self, editor::InsertEvent, lsp::SignatureHelp, overlay::overlaid, CompletionItem, Picker,
FilePicker, Picker, Popup, Prompt, PromptEvent, Popup, Prompt, PromptEvent,
}, },
}; };
@ -2156,7 +2156,7 @@ fn global_search(cx: &mut Context) {
return; return;
} }
let picker = FilePicker::new( let picker = Picker::new(
all_matches, all_matches,
current_path, current_path,
move |cx, FileResult { path, line_num }, action| { move |cx, FileResult { path, line_num }, action| {
@ -2184,11 +2184,9 @@ fn global_search(cx: &mut Context) {
doc.set_selection(view.id, Selection::single(start, end)); doc.set_selection(view.id, Selection::single(start, end));
align_view(doc, view, Align::Center); align_view(doc, view, Align::Center);
}, }).with_preview(|_editor, FileResult { path, line_num }| {
|_editor, FileResult { path, line_num }| {
Some((path.clone().into(), Some((*line_num, *line_num)))) Some((path.clone().into(), Some((*line_num, *line_num))))
}, });
);
compositor.push(Box::new(overlaid(picker))); compositor.push(Box::new(overlaid(picker)));
}, },
)); ));
@ -2579,22 +2577,18 @@ fn buffer_picker(cx: &mut Context) {
// mru // mru
items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at)); items.sort_unstable_by_key(|item| std::cmp::Reverse(item.focused_at));
let picker = FilePicker::new( let picker = Picker::new(items, (), |cx, meta, action| {
items, cx.editor.switch(meta.id, action);
(), })
|cx, meta, action| { .with_preview(|editor, meta| {
cx.editor.switch(meta.id, action); let doc = &editor.documents.get(&meta.id)?;
}, let &view_id = doc.selections().keys().next()?;
|editor, meta| { let line = doc
let doc = &editor.documents.get(&meta.id)?; .selection(view_id)
let &view_id = doc.selections().keys().next()?; .primary()
let line = doc .cursor_line(doc.text().slice(..));
.selection(view_id) Some((meta.id.into(), Some((line, line))))
.primary() });
.cursor_line(doc.text().slice(..));
Some((meta.id.into(), Some((line, line))))
},
);
cx.push_layer(Box::new(overlaid(picker))); cx.push_layer(Box::new(overlaid(picker)));
} }
@ -2660,7 +2654,7 @@ fn jumplist_picker(cx: &mut Context) {
} }
}; };
let picker = FilePicker::new( let picker = Picker::new(
cx.editor cx.editor
.tree .tree
.views() .views()
@ -2678,12 +2672,12 @@ fn jumplist_picker(cx: &mut Context) {
doc.set_selection(view.id, meta.selection.clone()); doc.set_selection(view.id, meta.selection.clone());
view.ensure_cursor_in_view_center(doc, config.scrolloff); view.ensure_cursor_in_view_center(doc, config.scrolloff);
}, },
|editor, meta| { )
let doc = &editor.documents.get(&meta.id)?; .with_preview(|editor, meta| {
let line = meta.selection.primary().cursor_line(doc.text().slice(..)); let doc = &editor.documents.get(&meta.id)?;
Some((meta.id.into(), Some((line, line)))) let line = meta.selection.primary().cursor_line(doc.text().slice(..));
}, Some((meta.id.into(), Some((line, line))))
); });
cx.push_layer(Box::new(overlaid(picker))); cx.push_layer(Box::new(overlaid(picker)));
} }

@ -2,7 +2,7 @@ use super::{Context, Editor};
use crate::{ use crate::{
compositor::{self, Compositor}, compositor::{self, Compositor},
job::{Callback, Jobs}, job::{Callback, Jobs},
ui::{self, overlay::overlaid, FilePicker, Picker, Popup, Prompt, PromptEvent, Text}, ui::{self, overlay::overlaid, Picker, Popup, Prompt, PromptEvent, Text},
}; };
use dap::{StackFrame, Thread, ThreadStates}; use dap::{StackFrame, Thread, ThreadStates};
use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate}; use helix_core::syntax::{DebugArgumentValue, DebugConfigCompletion, DebugTemplate};
@ -73,21 +73,19 @@ fn thread_picker(
let debugger = debugger!(editor); let debugger = debugger!(editor);
let thread_states = debugger.thread_states.clone(); let thread_states = debugger.thread_states.clone();
let picker = FilePicker::new( let picker = Picker::new(threads, thread_states, move |cx, thread, _action| {
threads, callback_fn(cx.editor, thread)
thread_states, })
move |cx, thread, _action| callback_fn(cx.editor, thread), .with_preview(move |editor, thread| {
move |editor, thread| { let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?;
let frames = editor.debugger.as_ref()?.stack_frames.get(&thread.id)?; let frame = frames.get(0)?;
let frame = frames.get(0)?; let path = frame.source.as_ref()?.path.clone()?;
let path = frame.source.as_ref()?.path.clone()?; let pos = Some((
let pos = Some(( frame.line.saturating_sub(1),
frame.line.saturating_sub(1), frame.end_line.unwrap_or(frame.line).saturating_sub(1),
frame.end_line.unwrap_or(frame.line).saturating_sub(1), ));
)); Some((path.into(), pos))
Some((path.into(), pos)) });
},
);
compositor.push(Box::new(picker)); compositor.push(Box::new(picker));
}, },
); );
@ -728,39 +726,35 @@ pub fn dap_switch_stack_frame(cx: &mut Context) {
let frames = debugger.stack_frames[&thread_id].clone(); let frames = debugger.stack_frames[&thread_id].clone();
let picker = FilePicker::new( let picker = Picker::new(frames, (), move |cx, frame, _action| {
frames, let debugger = debugger!(cx.editor);
(), // TODO: this should be simpler to find
move |cx, frame, _action| { let pos = debugger.stack_frames[&thread_id]
let debugger = debugger!(cx.editor); .iter()
// TODO: this should be simpler to find .position(|f| f.id == frame.id);
let pos = debugger.stack_frames[&thread_id] debugger.active_frame = pos;
.iter()
.position(|f| f.id == frame.id); let frame = debugger.stack_frames[&thread_id]
debugger.active_frame = pos; .get(pos.unwrap_or(0))
.cloned();
let frame = debugger.stack_frames[&thread_id] if let Some(frame) = &frame {
.get(pos.unwrap_or(0)) jump_to_stack_frame(cx.editor, frame);
.cloned(); }
if let Some(frame) = &frame { })
jump_to_stack_frame(cx.editor, frame); .with_preview(move |_editor, frame| {
} frame
}, .source
move |_editor, frame| { .as_ref()
frame .and_then(|source| source.path.clone())
.source .map(|path| {
.as_ref() (
.and_then(|source| source.path.clone()) path.into(),
.map(|path| { Some((
( frame.line.saturating_sub(1),
path.into(), frame.end_line.unwrap_or(frame.line).saturating_sub(1),
Some(( )),
frame.line.saturating_sub(1), )
frame.end_line.unwrap_or(frame.line).saturating_sub(1), })
)), });
)
})
},
);
cx.push_layer(Box::new(picker)) cx.push_layer(Box::new(picker))
} }

@ -31,8 +31,8 @@ use crate::{
compositor::{self, Compositor}, compositor::{self, Compositor},
job::Callback, job::Callback,
ui::{ ui::{
self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, FilePicker, self, lsp::SignatureHelp, overlay::overlaid, DynamicPicker, FileLocation, Picker, Popup,
Popup, PromptEvent, PromptEvent,
}, },
}; };
@ -236,48 +236,44 @@ fn jump_to_location(
align_view(doc, view, Align::Center); align_view(doc, view, Align::Center);
} }
type SymbolPicker = FilePicker<SymbolInformationItem>; type SymbolPicker = Picker<SymbolInformationItem>;
fn sym_picker(symbols: Vec<SymbolInformationItem>, current_path: Option<lsp::Url>) -> SymbolPicker { fn sym_picker(symbols: Vec<SymbolInformationItem>, current_path: Option<lsp::Url>) -> SymbolPicker {
// TODO: drop current_path comparison and instead use workspace: bool flag? // TODO: drop current_path comparison and instead use workspace: bool flag?
FilePicker::new( Picker::new(symbols, current_path.clone(), move |cx, item, action| {
symbols, let (view, doc) = current!(cx.editor);
current_path.clone(), push_jump(view, doc);
move |cx, item, action| {
let (view, doc) = current!(cx.editor); if current_path.as_ref() != Some(&item.symbol.location.uri) {
push_jump(view, doc); let uri = &item.symbol.location.uri;
let path = match uri.to_file_path() {
if current_path.as_ref() != Some(&item.symbol.location.uri) { Ok(path) => path,
let uri = &item.symbol.location.uri; Err(_) => {
let path = match uri.to_file_path() { let err = format!("unable to convert URI to filepath: {}", uri);
Ok(path) => path,
Err(_) => {
let err = format!("unable to convert URI to filepath: {}", uri);
cx.editor.set_error(err);
return;
}
};
if let Err(err) = cx.editor.open(&path, action) {
let err = format!("failed to open document: {}: {}", uri, err);
log::error!("{}", err);
cx.editor.set_error(err); cx.editor.set_error(err);
return; return;
} }
};
if let Err(err) = cx.editor.open(&path, action) {
let err = format!("failed to open document: {}: {}", uri, err);
log::error!("{}", err);
cx.editor.set_error(err);
return;
} }
}
let (view, doc) = current!(cx.editor); let (view, doc) = current!(cx.editor);
if let Some(range) = if let Some(range) =
lsp_range_to_range(doc.text(), item.symbol.location.range, item.offset_encoding) lsp_range_to_range(doc.text(), item.symbol.location.range, item.offset_encoding)
{ {
// we flip the range so that the cursor sits on the start of the symbol // we flip the range so that the cursor sits on the start of the symbol
// (for example start of the function). // (for example start of the function).
doc.set_selection(view.id, Selection::single(range.head, range.anchor)); doc.set_selection(view.id, Selection::single(range.head, range.anchor));
align_view(doc, view, Align::Center); align_view(doc, view, Align::Center);
} }
}, })
move |_editor, item| Some(location_to_file_location(&item.symbol.location)), .with_preview(move |_editor, item| Some(location_to_file_location(&item.symbol.location)))
)
.truncate_start(false) .truncate_start(false)
} }
@ -292,7 +288,7 @@ fn diag_picker(
diagnostics: BTreeMap<lsp::Url, Vec<(lsp::Diagnostic, usize)>>, diagnostics: BTreeMap<lsp::Url, Vec<(lsp::Diagnostic, usize)>>,
current_path: Option<lsp::Url>, current_path: Option<lsp::Url>,
format: DiagnosticsFormat, format: DiagnosticsFormat,
) -> FilePicker<PickerDiagnostic> { ) -> Picker<PickerDiagnostic> {
// TODO: drop current_path comparison and instead use workspace: bool flag? // TODO: drop current_path comparison and instead use workspace: bool flag?
// flatten the map to a vec of (url, diag) pairs // flatten the map to a vec of (url, diag) pairs
@ -318,7 +314,7 @@ fn diag_picker(
error: cx.editor.theme.get("error"), error: cx.editor.theme.get("error"),
}; };
FilePicker::new( Picker::new(
flat_diag, flat_diag,
(styles, format), (styles, format),
move |cx, move |cx,
@ -345,11 +341,11 @@ fn diag_picker(
align_view(doc, view, Align::Center); align_view(doc, view, Align::Center);
} }
}, },
move |_editor, PickerDiagnostic { url, diag, .. }| {
let location = lsp::Location::new(url.clone(), diag.range);
Some(location_to_file_location(&location))
},
) )
.with_preview(move |_editor, PickerDiagnostic { url, diag, .. }| {
let location = lsp::Location::new(url.clone(), diag.range);
Some(location_to_file_location(&location))
})
.truncate_start(false) .truncate_start(false)
} }
@ -1047,14 +1043,10 @@ fn goto_impl(
editor.set_error("No definition found."); editor.set_error("No definition found.");
} }
_locations => { _locations => {
let picker = FilePicker::new( let picker = Picker::new(locations, cwdir, move |cx, location, action| {
locations, jump_to_location(cx.editor, location, offset_encoding, action)
cwdir, })
move |cx, location, action| { .with_preview(move |_editor, location| Some(location_to_file_location(location)));
jump_to_location(cx.editor, location, offset_encoding, action)
},
move |_editor, location| Some(location_to_file_location(location)),
);
compositor.push(Box::new(overlaid(picker))); compositor.push(Box::new(overlaid(picker)));
} }
} }

@ -21,7 +21,7 @@ pub use completion::{Completion, CompletionItem};
pub use editor::EditorView; pub use editor::EditorView;
pub use markdown::Markdown; pub use markdown::Markdown;
pub use menu::Menu; pub use menu::Menu;
pub use picker::{DynamicPicker, FileLocation, FilePicker, Picker}; pub use picker::{DynamicPicker, FileLocation, Picker};
pub use popup::Popup; pub use popup::Popup;
pub use prompt::{Prompt, PromptEvent}; pub use prompt::{Prompt, PromptEvent};
pub use spinner::{ProgressSpinners, Spinner}; pub use spinner::{ProgressSpinners, Spinner};
@ -158,7 +158,7 @@ pub fn regex_prompt(
cx.push_layer(Box::new(prompt)); cx.push_layer(Box::new(prompt));
} }
pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePicker<PathBuf> { pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> Picker<PathBuf> {
use ignore::{types::TypesBuilder, WalkBuilder}; use ignore::{types::TypesBuilder, WalkBuilder};
use std::time::Instant; use std::time::Instant;
@ -217,21 +217,17 @@ pub fn file_picker(root: PathBuf, config: &helix_view::editor::Config) -> FilePi
log::debug!("file_picker init {:?}", Instant::now().duration_since(now)); log::debug!("file_picker init {:?}", Instant::now().duration_since(now));
FilePicker::new( Picker::new(files, root, move |cx, path: &PathBuf, action| {
files, if let Err(e) = cx.editor.open(path, action) {
root, let err = if let Some(err) = e.source() {
move |cx, path: &PathBuf, action| { format!("{}", err)
if let Err(e) = cx.editor.open(path, action) { } else {
let err = if let Some(err) = e.source() { format!("unable to open \"{}\"", path.display())
format!("{}", err) };
} else { cx.editor.set_error(err);
format!("unable to open \"{}\"", path.display()) }
}; })
cx.editor.set_error(err); .with_preview(|_editor, path| Some((path.clone().into(), None)))
}
},
|_editor, path| Some((path.clone().into(), None)),
)
} }
pub mod completers { pub mod completers {

@ -77,16 +77,6 @@ type FileCallback<T> = Box<dyn Fn(&Editor, &T) -> Option<FileLocation>>;
/// File path and range of lines (used to align and highlight lines) /// File path and range of lines (used to align and highlight lines)
pub type FileLocation = (PathOrId, Option<(usize, usize)>); pub type FileLocation = (PathOrId, Option<(usize, usize)>);
pub struct FilePicker<T: Item> {
picker: Picker<T>,
pub truncate_start: bool,
/// Caches paths to documents
preview_cache: HashMap<PathBuf, CachedPreview>,
read_buffer: Vec<u8>,
/// Given an item in the picker, return the file path and line number to display.
file_fn: FileCallback<T>,
}
pub enum CachedPreview { pub enum CachedPreview {
Document(Box<Document>), Document(Box<Document>),
Binary, Binary,
@ -124,325 +114,6 @@ impl Preview<'_, '_> {
} }
} }
impl<T: Item + 'static> FilePicker<T> {
pub fn new(
options: Vec<T>,
editor_data: T::Data,
callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
) -> Self {
let truncate_start = true;
let mut picker = Picker::new(options, editor_data, callback_fn);
picker.truncate_start = truncate_start;
Self {
picker,
truncate_start,
preview_cache: HashMap::new(),
read_buffer: Vec::with_capacity(1024),
file_fn: Box::new(preview_fn),
}
}
pub fn truncate_start(mut self, truncate_start: bool) -> Self {
self.truncate_start = truncate_start;
self.picker.truncate_start = truncate_start;
self
}
fn current_file(&self, editor: &Editor) -> Option<FileLocation> {
self.picker
.selection()
.and_then(|current| (self.file_fn)(editor, current))
.and_then(|(path_or_id, line)| path_or_id.get_canonicalized().ok().zip(Some(line)))
}
/// Get (cached) preview for a given path. If a document corresponding
/// to the path is already open in the editor, it is used instead.
fn get_preview<'picker, 'editor>(
&'picker mut self,
path_or_id: PathOrId,
editor: &'editor Editor,
) -> Preview<'picker, 'editor> {
match path_or_id {
PathOrId::Path(path) => {
let path = &path;
if let Some(doc) = editor.document_by_path(path) {
return Preview::EditorDocument(doc);
}
if self.preview_cache.contains_key(path) {
return Preview::Cached(&self.preview_cache[path]);
}
let data = std::fs::File::open(path).and_then(|file| {
let metadata = file.metadata()?;
// Read up to 1kb to detect the content type
let n = file.take(1024).read_to_end(&mut self.read_buffer)?;
let content_type = content_inspector::inspect(&self.read_buffer[..n]);
self.read_buffer.clear();
Ok((metadata, content_type))
});
let preview = data
.map(
|(metadata, content_type)| match (metadata.len(), content_type) {
(_, content_inspector::ContentType::BINARY) => CachedPreview::Binary,
(size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => {
CachedPreview::LargeFile
}
_ => {
// TODO: enable syntax highlighting; blocked by async rendering
Document::open(path, None, None, editor.config.clone())
.map(|doc| CachedPreview::Document(Box::new(doc)))
.unwrap_or(CachedPreview::NotFound)
}
},
)
.unwrap_or(CachedPreview::NotFound);
self.preview_cache.insert(path.to_owned(), preview);
Preview::Cached(&self.preview_cache[path])
}
PathOrId::Id(id) => {
let doc = editor.documents.get(&id).unwrap();
Preview::EditorDocument(doc)
}
}
}
fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult {
let Some((current_file, _)) = self.current_file(cx.editor) else {
return EventResult::Consumed(None)
};
// Try to find a document in the cache
let doc = match &current_file {
PathOrId::Id(doc_id) => doc_mut!(cx.editor, doc_id),
PathOrId::Path(path) => match self.preview_cache.get_mut(path) {
Some(CachedPreview::Document(ref mut doc)) => doc,
_ => return EventResult::Consumed(None),
},
};
let mut callback: Option<compositor::Callback> = None;
// Then attempt to highlight it if it has no language set
if doc.language_config().is_none() {
if let Some(language_config) = doc.detect_language_config(&cx.editor.syn_loader) {
doc.language = Some(language_config.clone());
let text = doc.text().clone();
let loader = cx.editor.syn_loader.clone();
let job = tokio::task::spawn_blocking(move || {
let syntax = language_config
.highlight_config(&loader.scopes())
.and_then(|highlight_config| Syntax::new(&text, highlight_config, loader));
let callback = move |editor: &mut Editor, compositor: &mut Compositor| {
let Some(syntax) = syntax else {
log::info!("highlighting picker item failed");
return
};
let Some(Overlay { content: picker, .. }) = compositor.find::<Overlay<Self>>() else {
log::info!("picker closed before syntax highlighting finished");
return
};
// Try to find a document in the cache
let doc = match current_file {
PathOrId::Id(doc_id) => doc_mut!(editor, &doc_id),
PathOrId::Path(path) => match picker.preview_cache.get_mut(&path) {
Some(CachedPreview::Document(ref mut doc)) => doc,
_ => return,
},
};
doc.syntax = Some(syntax);
};
Callback::EditorCompositor(Box::new(callback))
});
let tmp: compositor::Callback = Box::new(move |_, ctx| {
ctx.jobs
.callback(job.map(|res| res.map_err(anyhow::Error::from)))
});
callback = Some(Box::new(tmp))
}
}
// QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now
// but it could be interesting in the future
EventResult::Consumed(callback)
}
}
impl<T: Item + 'static> Component for FilePicker<T> {
fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
// +---------+ +---------+
// |prompt | |preview |
// +---------+ | |
// |picker | | |
// | | | |
// +---------+ +---------+
let render_preview = self.picker.show_preview && area.width > MIN_AREA_WIDTH_FOR_PREVIEW;
// -- Render the frame:
// clear area
let background = cx.editor.theme.get("ui.background");
let text = cx.editor.theme.get("ui.text");
surface.clear_with(area, background);
let picker_width = if render_preview {
area.width / 2
} else {
area.width
};
let picker_area = area.with_width(picker_width);
self.picker.render(picker_area, surface, cx);
if !render_preview {
return;
}
let preview_area = area.clip_left(picker_width);
// don't like this but the lifetime sucks
let block = Block::default().borders(Borders::ALL);
// calculate the inner area inside the box
let inner = block.inner(preview_area);
// 1 column gap on either side
let margin = Margin::horizontal(1);
let inner = inner.inner(&margin);
block.render(preview_area, surface);
if let Some((path, range)) = self.current_file(cx.editor) {
let preview = self.get_preview(path, cx.editor);
let doc = match preview.document() {
Some(doc) => doc,
None => {
let alt_text = preview.placeholder();
let x = inner.x + inner.width.saturating_sub(alt_text.len() as u16) / 2;
let y = inner.y + inner.height / 2;
surface.set_stringn(x, y, alt_text, inner.width as usize, text);
return;
}
};
// align to middle
let first_line = range
.map(|(start, end)| {
let height = end.saturating_sub(start) + 1;
let middle = start + (height.saturating_sub(1) / 2);
middle.saturating_sub(inner.height as usize / 2).min(start)
})
.unwrap_or(0);
let offset = ViewPosition {
anchor: doc.text().line_to_char(first_line),
horizontal_offset: 0,
vertical_offset: 0,
};
let mut highlights = EditorView::doc_syntax_highlights(
doc,
offset.anchor,
area.height,
&cx.editor.theme,
);
for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) {
if spans.is_empty() {
continue;
}
highlights = Box::new(helix_core::syntax::merge(highlights, spans));
}
let mut decorations: Vec<Box<dyn LineDecoration>> = Vec::new();
if let Some((start, end)) = range {
let style = cx
.editor
.theme
.try_get("ui.highlight")
.unwrap_or_else(|| cx.editor.theme.get("ui.selection"));
let draw_highlight = move |renderer: &mut TextRenderer, pos: LinePos| {
if (start..=end).contains(&pos.doc_line) {
let area = Rect::new(
renderer.viewport.x,
renderer.viewport.y + pos.visual_line,
renderer.viewport.width,
1,
);
renderer.surface.set_style(area, style)
}
};
decorations.push(Box::new(draw_highlight))
}
render_document(
surface,
inner,
doc,
offset,
// TODO: compute text annotations asynchronously here (like inlay hints)
&TextAnnotations::default(),
highlights,
&cx.editor.theme,
&mut decorations,
&mut [],
);
}
}
fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult {
if let Event::IdleTimeout = event {
return self.handle_idle_timeout(ctx);
}
// TODO: keybinds for scrolling preview
self.picker.handle_event(event, ctx)
}
fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind) {
self.picker.cursor(area, ctx)
}
fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> {
let picker_width = if width > MIN_AREA_WIDTH_FOR_PREVIEW {
width / 2
} else {
width
};
self.picker.required_size((picker_width, height))?;
Some((width, height))
}
fn id(&self) -> Option<&'static str> {
Some("file-picker")
}
}
#[derive(PartialEq, Eq, Debug)]
struct PickerMatch {
score: i64,
index: usize,
len: usize,
}
impl PickerMatch {
fn key(&self) -> impl Ord {
(cmp::Reverse(self.score), self.len, self.index)
}
}
impl PartialOrd for PickerMatch {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for PickerMatch {
fn cmp(&self, other: &Self) -> Ordering {
self.key().cmp(&other.key())
}
}
type PickerCallback<T> = Box<dyn Fn(&mut Context, &T, Action)>;
pub struct Picker<T: Item> { pub struct Picker<T: Item> {
options: Vec<T>, options: Vec<T>,
editor_data: T::Data, editor_data: T::Data,
@ -457,17 +128,22 @@ pub struct Picker<T: Item> {
// pattern: String, // pattern: String,
prompt: Prompt, prompt: Prompt,
previous_pattern: (String, FuzzyQuery), previous_pattern: (String, FuzzyQuery),
/// Whether to truncate the start (default true)
pub truncate_start: bool,
/// Whether to show the preview panel (default true) /// Whether to show the preview panel (default true)
show_preview: bool, show_preview: bool,
/// Constraints for tabular formatting /// Constraints for tabular formatting
widths: Vec<Constraint>, widths: Vec<Constraint>,
callback_fn: PickerCallback<T>, callback_fn: PickerCallback<T>,
pub truncate_start: bool,
/// Caches paths to documents
preview_cache: HashMap<PathBuf, CachedPreview>,
read_buffer: Vec<u8>,
/// Given an item in the picker, return the file path and line number to display.
file_fn: Option<FileCallback<T>>,
} }
impl<T: Item> Picker<T> { impl<T: Item + 'static> Picker<T> {
pub fn new( pub fn new(
options: Vec<T>, options: Vec<T>,
editor_data: T::Data, editor_data: T::Data,
@ -493,6 +169,9 @@ impl<T: Item> Picker<T> {
callback_fn: Box::new(callback_fn), callback_fn: Box::new(callback_fn),
completion_height: 0, completion_height: 0,
widths: Vec::new(), widths: Vec::new(),
preview_cache: HashMap::new(),
read_buffer: Vec::with_capacity(1024),
file_fn: None,
}; };
picker.calculate_column_widths(); picker.calculate_column_widths();
@ -513,6 +192,19 @@ impl<T: Item> Picker<T> {
picker picker
} }
pub fn truncate_start(mut self, truncate_start: bool) -> Self {
self.truncate_start = truncate_start;
self
}
pub fn with_preview(
mut self,
preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
) -> Self {
self.file_fn = Some(Box::new(preview_fn));
self
}
pub fn set_options(&mut self, new_options: Vec<T>) { pub fn set_options(&mut self, new_options: Vec<T>) {
self.options = new_options; self.options = new_options;
self.cursor = 0; self.cursor = 0;
@ -679,92 +371,127 @@ impl<T: Item> Picker<T> {
} }
EventResult::Consumed(None) EventResult::Consumed(None)
} }
}
// process: fn current_file(&self, editor: &Editor) -> Option<FileLocation> {
// - read all the files into a list, maxed out at a large value self.selection()
// - on input change: .and_then(|current| (self.file_fn.as_ref()?)(editor, current))
// - score all the names in relation to input .and_then(|(path_or_id, line)| path_or_id.get_canonicalized().ok().zip(Some(line)))
impl<T: Item + 'static> Component for Picker<T> {
fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
self.completion_height = viewport.1.saturating_sub(4);
Some(viewport)
} }
fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { /// Get (cached) preview for a given path. If a document corresponding
let key_event = match event { /// to the path is already open in the editor, it is used instead.
Event::Key(event) => *event, fn get_preview<'picker, 'editor>(
Event::Paste(..) => return self.prompt_handle_event(event, cx), &'picker mut self,
Event::Resize(..) => return EventResult::Consumed(None), path_or_id: PathOrId,
_ => return EventResult::Ignored(None), editor: &'editor Editor,
}; ) -> Preview<'picker, 'editor> {
match path_or_id {
let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _cx| { PathOrId::Path(path) => {
// remove the layer let path = &path;
compositor.last_picker = compositor.pop(); if let Some(doc) = editor.document_by_path(path) {
}))); return Preview::EditorDocument(doc);
// So that idle timeout retriggers
cx.editor.reset_idle_timer();
match key_event {
shift!(Tab) | key!(Up) | ctrl!('p') => {
self.move_by(1, Direction::Backward);
}
key!(Tab) | key!(Down) | ctrl!('n') => {
self.move_by(1, Direction::Forward);
}
key!(PageDown) | ctrl!('d') => {
self.page_down();
}
key!(PageUp) | ctrl!('u') => {
self.page_up();
}
key!(Home) => {
self.to_start();
}
key!(End) => {
self.to_end();
}
key!(Esc) | ctrl!('c') => {
return close_fn;
}
alt!(Enter) => {
if let Some(option) = self.selection() {
(self.callback_fn)(cx, option, Action::Load);
}
}
key!(Enter) => {
if let Some(option) = self.selection() {
(self.callback_fn)(cx, option, Action::Replace);
}
return close_fn;
}
ctrl!('s') => {
if let Some(option) = self.selection() {
(self.callback_fn)(cx, option, Action::HorizontalSplit);
} }
return close_fn;
} if self.preview_cache.contains_key(path) {
ctrl!('v') => { return Preview::Cached(&self.preview_cache[path]);
if let Some(option) = self.selection() {
(self.callback_fn)(cx, option, Action::VerticalSplit);
} }
return close_fn;
let data = std::fs::File::open(path).and_then(|file| {
let metadata = file.metadata()?;
// Read up to 1kb to detect the content type
let n = file.take(1024).read_to_end(&mut self.read_buffer)?;
let content_type = content_inspector::inspect(&self.read_buffer[..n]);
self.read_buffer.clear();
Ok((metadata, content_type))
});
let preview = data
.map(
|(metadata, content_type)| match (metadata.len(), content_type) {
(_, content_inspector::ContentType::BINARY) => CachedPreview::Binary,
(size, _) if size > MAX_FILE_SIZE_FOR_PREVIEW => {
CachedPreview::LargeFile
}
_ => {
// TODO: enable syntax highlighting; blocked by async rendering
Document::open(path, None, None, editor.config.clone())
.map(|doc| CachedPreview::Document(Box::new(doc)))
.unwrap_or(CachedPreview::NotFound)
}
},
)
.unwrap_or(CachedPreview::NotFound);
self.preview_cache.insert(path.to_owned(), preview);
Preview::Cached(&self.preview_cache[path])
} }
ctrl!('t') => { PathOrId::Id(id) => {
self.toggle_preview(); let doc = editor.documents.get(&id).unwrap();
Preview::EditorDocument(doc)
} }
_ => { }
self.prompt_handle_event(event, cx); }
fn handle_idle_timeout(&mut self, cx: &mut Context) -> EventResult {
let Some((current_file, _)) = self.current_file(cx.editor) else {
return EventResult::Consumed(None)
};
// Try to find a document in the cache
let doc = match &current_file {
PathOrId::Id(doc_id) => doc_mut!(cx.editor, doc_id),
PathOrId::Path(path) => match self.preview_cache.get_mut(path) {
Some(CachedPreview::Document(ref mut doc)) => doc,
_ => return EventResult::Consumed(None),
},
};
let mut callback: Option<compositor::Callback> = None;
// Then attempt to highlight it if it has no language set
if doc.language_config().is_none() {
if let Some(language_config) = doc.detect_language_config(&cx.editor.syn_loader) {
doc.language = Some(language_config.clone());
let text = doc.text().clone();
let loader = cx.editor.syn_loader.clone();
let job = tokio::task::spawn_blocking(move || {
let syntax = language_config
.highlight_config(&loader.scopes())
.and_then(|highlight_config| Syntax::new(&text, highlight_config, loader));
let callback = move |editor: &mut Editor, compositor: &mut Compositor| {
let Some(syntax) = syntax else {
log::info!("highlighting picker item failed");
return
};
let Some(Overlay { content: picker, .. }) = compositor.find::<Overlay<Self>>() else {
log::info!("picker closed before syntax highlighting finished");
return
};
// Try to find a document in the cache
let doc = match current_file {
PathOrId::Id(doc_id) => doc_mut!(editor, &doc_id),
PathOrId::Path(path) => match picker.preview_cache.get_mut(&path) {
Some(CachedPreview::Document(ref mut doc)) => doc,
_ => return,
},
};
doc.syntax = Some(syntax);
};
Callback::EditorCompositor(Box::new(callback))
});
let tmp: compositor::Callback = Box::new(move |_, ctx| {
ctx.jobs
.callback(job.map(|res| res.map_err(anyhow::Error::from)))
});
callback = Some(Box::new(tmp))
} }
} }
EventResult::Consumed(None) // QUESTION: do we want to compute inlay hints in pickers too ? Probably not for now
// but it could be interesting in the future
EventResult::Consumed(callback)
} }
fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { fn render_picker(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
let text_style = cx.editor.theme.get("ui.text"); let text_style = cx.editor.theme.get("ui.text");
let selected = cx.editor.theme.get("ui.text.focus"); let selected = cx.editor.theme.get("ui.text.focus");
let highlight_style = cx.editor.theme.get("special").add_modifier(Modifier::BOLD); let highlight_style = cx.editor.theme.get("special").add_modifier(Modifier::BOLD);
@ -930,6 +657,178 @@ impl<T: Item + 'static> Component for Picker<T> {
); );
} }
fn render_preview(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
// -- Render the frame:
// clear area
let background = cx.editor.theme.get("ui.background");
let text = cx.editor.theme.get("ui.text");
surface.clear_with(area, background);
// don't like this but the lifetime sucks
let block = Block::default().borders(Borders::ALL);
// calculate the inner area inside the box
let inner = block.inner(area);
// 1 column gap on either side
let margin = Margin::horizontal(1);
let inner = inner.inner(&margin);
block.render(area, surface);
if let Some((path, range)) = self.current_file(cx.editor) {
let preview = self.get_preview(path, cx.editor);
let doc = match preview.document() {
Some(doc) => doc,
None => {
let alt_text = preview.placeholder();
let x = inner.x + inner.width.saturating_sub(alt_text.len() as u16) / 2;
let y = inner.y + inner.height / 2;
surface.set_stringn(x, y, alt_text, inner.width as usize, text);
return;
}
};
// align to middle
let first_line = range
.map(|(start, end)| {
let height = end.saturating_sub(start) + 1;
let middle = start + (height.saturating_sub(1) / 2);
middle.saturating_sub(inner.height as usize / 2).min(start)
})
.unwrap_or(0);
let offset = ViewPosition {
anchor: doc.text().line_to_char(first_line),
horizontal_offset: 0,
vertical_offset: 0,
};
let mut highlights = EditorView::doc_syntax_highlights(
doc,
offset.anchor,
area.height,
&cx.editor.theme,
);
for spans in EditorView::doc_diagnostics_highlights(doc, &cx.editor.theme) {
if spans.is_empty() {
continue;
}
highlights = Box::new(helix_core::syntax::merge(highlights, spans));
}
let mut decorations: Vec<Box<dyn LineDecoration>> = Vec::new();
if let Some((start, end)) = range {
let style = cx
.editor
.theme
.try_get("ui.highlight")
.unwrap_or_else(|| cx.editor.theme.get("ui.selection"));
let draw_highlight = move |renderer: &mut TextRenderer, pos: LinePos| {
if (start..=end).contains(&pos.doc_line) {
let area = Rect::new(
renderer.viewport.x,
renderer.viewport.y + pos.visual_line,
renderer.viewport.width,
1,
);
renderer.surface.set_style(area, style)
}
};
decorations.push(Box::new(draw_highlight))
}
render_document(
surface,
inner,
doc,
offset,
// TODO: compute text annotations asynchronously here (like inlay hints)
&TextAnnotations::default(),
highlights,
&cx.editor.theme,
&mut decorations,
&mut [],
);
}
}
fn handle_event(&mut self, event: &Event, ctx: &mut Context) -> EventResult {
if let Event::IdleTimeout = event {
return self.handle_idle_timeout(ctx);
}
// TODO: keybinds for scrolling preview
let key_event = match event {
Event::Key(event) => *event,
Event::Paste(..) => return self.prompt_handle_event(event, ctx),
Event::Resize(..) => return EventResult::Consumed(None),
_ => return EventResult::Ignored(None),
};
let close_fn =
EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _ctx| {
// remove the layer
compositor.last_picker = compositor.pop();
})));
// So that idle timeout retriggers
ctx.editor.reset_idle_timer();
match key_event {
shift!(Tab) | key!(Up) | ctrl!('p') => {
self.move_by(1, Direction::Backward);
}
key!(Tab) | key!(Down) | ctrl!('n') => {
self.move_by(1, Direction::Forward);
}
key!(PageDown) | ctrl!('d') => {
self.page_down();
}
key!(PageUp) | ctrl!('u') => {
self.page_up();
}
key!(Home) => {
self.to_start();
}
key!(End) => {
self.to_end();
}
key!(Esc) | ctrl!('c') => {
return close_fn;
}
alt!(Enter) => {
if let Some(option) = self.selection() {
(self.callback_fn)(ctx, option, Action::Load);
}
}
key!(Enter) => {
if let Some(option) = self.selection() {
(self.callback_fn)(ctx, option, Action::Replace);
}
return close_fn;
}
ctrl!('s') => {
if let Some(option) = self.selection() {
(self.callback_fn)(ctx, option, Action::HorizontalSplit);
}
return close_fn;
}
ctrl!('v') => {
if let Some(option) = self.selection() {
(self.callback_fn)(ctx, option, Action::VerticalSplit);
}
return close_fn;
}
ctrl!('t') => {
self.toggle_preview();
}
_ => {
self.prompt_handle_event(event, ctx);
}
}
EventResult::Consumed(None)
}
fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) { fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) {
let block = Block::default().borders(Borders::ALL); let block = Block::default().borders(Borders::ALL);
// calculate the inner area inside the box // calculate the inner area inside the box
@ -940,8 +839,67 @@ impl<T: Item + 'static> Component for Picker<T> {
self.prompt.cursor(area, editor) self.prompt.cursor(area, editor)
} }
fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> {
self.completion_height = height.saturating_sub(4);
Some((width, height))
}
}
impl<T: Item + 'static> Component for Picker<T> {
fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
// +---------+ +---------+
// |prompt | |preview |
// +---------+ | |
// |picker | | |
// | | | |
// +---------+ +---------+
let render_preview = self.show_preview && area.width > MIN_AREA_WIDTH_FOR_PREVIEW;
let picker_width = if render_preview {
area.width / 2
} else {
area.width
};
let picker_area = area.with_width(picker_width);
self.render_picker(picker_area, surface, cx);
if render_preview {
let preview_area = area.clip_left(picker_width);
self.render_preview(preview_area, surface, cx);
}
}
}
#[derive(PartialEq, Eq, Debug)]
struct PickerMatch {
score: i64,
index: usize,
len: usize,
}
impl PickerMatch {
fn key(&self) -> impl Ord {
(cmp::Reverse(self.score), self.len, self.index)
}
}
impl PartialOrd for PickerMatch {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
} }
impl Ord for PickerMatch {
fn cmp(&self, other: &Self) -> Ordering {
self.key().cmp(&other.key())
}
}
type PickerCallback<T> = Box<dyn Fn(&mut Context, &T, Action)>;
/// Returns a new list of options to replace the contents of the picker /// Returns a new list of options to replace the contents of the picker
/// when called with the current picker query, /// when called with the current picker query,
pub type DynQueryCallback<T> = pub type DynQueryCallback<T> =
@ -950,7 +908,7 @@ pub type DynQueryCallback<T> =
/// A picker that updates its contents via a callback whenever the /// A picker that updates its contents via a callback whenever the
/// query string changes. Useful for live grep, workspace symbols, etc. /// query string changes. Useful for live grep, workspace symbols, etc.
pub struct DynamicPicker<T: ui::menu::Item + Send> { pub struct DynamicPicker<T: ui::menu::Item + Send> {
file_picker: FilePicker<T>, file_picker: Picker<T>,
query_callback: DynQueryCallback<T>, query_callback: DynQueryCallback<T>,
query: String, query: String,
} }
@ -958,7 +916,7 @@ pub struct DynamicPicker<T: ui::menu::Item + Send> {
impl<T: ui::menu::Item + Send> DynamicPicker<T> { impl<T: ui::menu::Item + Send> DynamicPicker<T> {
pub const ID: &'static str = "dynamic-picker"; pub const ID: &'static str = "dynamic-picker";
pub fn new(file_picker: FilePicker<T>, query_callback: DynQueryCallback<T>) -> Self { pub fn new(file_picker: Picker<T>, query_callback: DynQueryCallback<T>) -> Self {
Self { Self {
file_picker, file_picker,
query_callback, query_callback,
@ -974,7 +932,7 @@ impl<T: Item + Send + 'static> Component for DynamicPicker<T> {
fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
let event_result = self.file_picker.handle_event(event, cx); let event_result = self.file_picker.handle_event(event, cx);
let current_query = self.file_picker.picker.prompt.line(); let current_query = self.file_picker.prompt.line();
if !matches!(event, Event::IdleTimeout) || self.query == *current_query { if !matches!(event, Event::IdleTimeout) || self.query == *current_query {
return event_result; return event_result;
@ -990,7 +948,7 @@ impl<T: Item + Send + 'static> Component for DynamicPicker<T> {
// Wrapping of pickers in overlay is done outside the picker code, // Wrapping of pickers in overlay is done outside the picker code,
// so this is fragile and will break if wrapped in some other widget. // so this is fragile and will break if wrapped in some other widget.
let picker = match compositor.find_id::<Overlay<DynamicPicker<T>>>(Self::ID) { let picker = match compositor.find_id::<Overlay<DynamicPicker<T>>>(Self::ID) {
Some(overlay) => &mut overlay.content.file_picker.picker, Some(overlay) => &mut overlay.content.file_picker,
None => return, None => return,
}; };
picker.set_options(new_options); picker.set_options(new_options);

Loading…
Cancel
Save