diff options
author | Gokul Soumya | 2021-08-12 07:00:42 +0000 |
---|---|---|
committer | GitHub | 2021-08-12 07:00:42 +0000 |
commit | d84f8b5fdef71da87ee108db07ba1167fc6a769b (patch) | |
tree | 057f87cb1107aae3eb966ff3e228b15d3e36ff2e /helix-term/src | |
parent | 7d51805e94a461834ce34e0829da5859d1f9db32 (diff) |
Show file preview in split pane in fuzzy finder (#534)
* Add preview pane for fuzzy finder
* Fix picker preview lag by caching
* Add picker preview for document symbols
* Cache picker preview per document instead of view
* Use line instead of range for preview doc
* Add picker preview for buffer picker
* Fix render bug and refactor picker
* Refactor picker preview rendering
* Split picker and preview and compose
The current selected item is cloned on every event, which is
undesirable
* Refactor out clones in previewed picker
* Retrieve doc from editor if possible in filepicker
* Disable syntax highlight for picker preview
Files already loaded in memory have syntax highlighting enabled
* Ignore directory symlinks in file picker
* Cleanup unnecessary pubs and derives
* Remove unnecessary highlight from file picker
* Reorganize buffer rendering
* Use normal picker for code actions
* Remove unnecessary generics and trait impls
* Remove prepare_for_render and make render mutable
* Skip picker preview if screen small, less padding
Diffstat (limited to 'helix-term/src')
-rw-r--r-- | helix-term/src/commands.rs | 33 | ||||
-rw-r--r-- | helix-term/src/compositor.rs | 6 | ||||
-rw-r--r-- | helix-term/src/ui/completion.rs | 4 | ||||
-rw-r--r-- | helix-term/src/ui/editor.rs | 408 | ||||
-rw-r--r-- | helix-term/src/ui/info.rs | 2 | ||||
-rw-r--r-- | helix-term/src/ui/markdown.rs | 2 | ||||
-rw-r--r-- | helix-term/src/ui/menu.rs | 2 | ||||
-rw-r--r-- | helix-term/src/ui/mod.rs | 42 | ||||
-rw-r--r-- | helix-term/src/ui/picker.rs | 191 | ||||
-rw-r--r-- | helix-term/src/ui/popup.rs | 2 | ||||
-rw-r--r-- | helix-term/src/ui/prompt.rs | 4 | ||||
-rw-r--r-- | helix-term/src/ui/text.rs | 2 |
12 files changed, 464 insertions, 234 deletions
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 2b1b859b..0d118dbc 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -27,7 +27,7 @@ use movement::Movement; use crate::{ compositor::{self, Component, Compositor}, - ui::{self, Picker, Popup, Prompt, PromptEvent}, + ui::{self, FilePicker, Picker, Popup, Prompt, PromptEvent}, }; use crate::job::{self, Job, Jobs}; @@ -2212,7 +2212,7 @@ fn file_picker(cx: &mut Context) { fn buffer_picker(cx: &mut Context) { let current = view!(cx.editor).doc; - let picker = Picker::new( + let picker = FilePicker::new( cx.editor .documents .iter() @@ -2234,6 +2234,15 @@ fn buffer_picker(cx: &mut Context) { |editor: &mut Editor, (id, _path): &(DocumentId, Option<PathBuf>), _action| { editor.switch(*id, Action::Replace); }, + |editor, (id, path)| { + let doc = &editor.documents.get(*id)?; + let &view_id = doc.selections().keys().next()?; + let line = doc + .selection(view_id) + .primary() + .cursor_line(doc.text().slice(..)); + Some((path.clone()?, Some(line))) + }, ); cx.push_layer(Box::new(picker)); } @@ -2287,7 +2296,7 @@ fn symbol_picker(cx: &mut Context) { } }; - let picker = Picker::new( + let picker = FilePicker::new( symbols, |symbol| (&symbol.name).into(), move |editor: &mut Editor, symbol, _action| { @@ -2297,10 +2306,15 @@ fn symbol_picker(cx: &mut Context) { if let Some(range) = lsp_range_to_range(doc.text(), symbol.location.range, offset_encoding) { - doc.set_selection(view.id, Selection::single(range.to(), range.from())); + doc.set_selection(view.id, Selection::single(range.anchor, range.head)); align_view(doc, view, Align::Center); } }, + move |_editor, symbol| { + let path = symbol.location.uri.to_file_path().unwrap(); + let line = Some(symbol.location.range.start.line as usize); + Some((path, line)) + }, ); compositor.push(Box::new(picker)) } @@ -2332,6 +2346,7 @@ pub fn code_action(cx: &mut Context) { response: Option<lsp::CodeActionResponse>| { if let Some(actions) = response { let picker = Picker::new( + true, actions, |action| match action { lsp::CodeActionOrCommand::CodeAction(action) => { @@ -2703,7 +2718,7 @@ fn goto_impl( editor.set_error("No definition found.".to_string()); } _locations => { - let picker = ui::Picker::new( + let picker = FilePicker::new( locations, move |location| { let file: Cow<'_, str> = (location.uri.scheme() == "file") @@ -2728,6 +2743,11 @@ fn goto_impl( move |editor: &mut Editor, location, action| { jump_to(editor, location, offset_encoding, action) }, + |_editor, location| { + let path = location.uri.to_file_path().unwrap(); + let line = Some(location.range.start.line as usize); + Some((path, line)) + }, ); compositor.push(Box::new(picker)); } @@ -3729,8 +3749,7 @@ fn keep_primary_selection(cx: &mut Context) { let (view, doc) = current!(cx.editor); let range = doc.selection(view.id).primary(); - let selection = Selection::single(range.anchor, range.head); - doc.set_selection(view.id, selection); + doc.set_selection(view.id, Selection::single(range.anchor, range.head)); } fn completion(cx: &mut Context) { diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index 628c4e13..36e54ede 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -46,7 +46,7 @@ pub trait Component: Any + AnyComponent { } /// Render the component onto the provided surface. - fn render(&self, area: Rect, frame: &mut Surface, ctx: &mut Context); + fn render(&mut self, area: Rect, frame: &mut Surface, ctx: &mut Context); /// Get cursor position and cursor kind. fn cursor(&self, _area: Rect, _ctx: &Editor) -> (Option<Position>, CursorKind) { @@ -152,8 +152,8 @@ impl Compositor { let area = *surface.area(); - for layer in &self.layers { - layer.render(area, surface, cx) + for layer in &mut self.layers { + layer.render(area, surface, cx); } let (pos, kind) = self.cursor(area, cx.editor); diff --git a/helix-term/src/ui/completion.rs b/helix-term/src/ui/completion.rs index 6d737652..4e01ce1c 100644 --- a/helix-term/src/ui/completion.rs +++ b/helix-term/src/ui/completion.rs @@ -241,7 +241,7 @@ impl Component for Completion { self.popup.required_size(viewport) } - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { self.popup.render(area, surface, cx); // if we have a selection, render a markdown popup on top/below with info @@ -263,7 +263,7 @@ impl Component for Completion { let cursor_pos = (helix_core::coords_at_pos(doc.text().slice(..), cursor_pos).row - view.first_line) as u16; - let doc = match &option.documentation { + let mut doc = match &option.documentation { Some(lsp::Documentation::String(contents)) | Some(lsp::Documentation::MarkupContent(lsp::MarkupContent { kind: lsp::MarkupKind::PlainText, diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index 96a4afe8..8c46eef9 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -34,7 +34,7 @@ pub struct EditorView { last_insert: (commands::Command, Vec<KeyEvent>), completion: Option<Completion>, spinners: ProgressSpinners, - pub autoinfo: Option<Info>, + autoinfo: Option<Info>, } pub const GUTTER_OFFSET: u16 = 7; // 1 diagnostic + 5 linenr + 1 gutter @@ -78,8 +78,26 @@ impl EditorView { view.area.width - GUTTER_OFFSET, view.area.height.saturating_sub(1), ); // - 1 for statusline + let offset = Position::new(view.first_line, view.first_col); + let height = view.area.height.saturating_sub(1); // - 1 for statusline - self.render_buffer(doc, view, area, surface, theme, is_focused, loader); + let highlights = Self::doc_syntax_highlights(doc, offset, height, theme, loader); + let highlights = syntax::merge(highlights, Self::doc_diagnostics_highlights(doc, theme)); + let highlights: Box<dyn Iterator<Item = HighlightEvent>> = if is_focused { + Box::new(syntax::merge( + highlights, + Self::doc_selection_highlights(doc, view, theme), + )) + } else { + Box::new(highlights) + }; + + Self::render_text_highlights(doc, offset, area, surface, theme, highlights); + Self::render_gutter(doc, view, area, surface, theme); + + if is_focused { + Self::render_focused_view_elements(view, doc, area, theme, surface); + } // if we're not at the edge of the screen, draw a right border if viewport.right() != view.area.right() { @@ -94,7 +112,7 @@ impl EditorView { } } - self.render_diagnostics(doc, view, area, surface, theme, is_focused); + self.render_diagnostics(doc, view, area, surface, theme); let area = Rect::new( view.area.x, @@ -105,31 +123,34 @@ impl EditorView { self.render_statusline(doc, view, area, surface, theme, is_focused); } + /// Get syntax highlights for a document in a view represented by the first line + /// and column (`offset`) and the last line. This is done instead of using a view + /// directly to enable rendering syntax highlighted docs anywhere (eg. picker preview) #[allow(clippy::too_many_arguments)] - pub fn render_buffer( - &self, - doc: &Document, - view: &View, - viewport: Rect, - surface: &mut Surface, + pub fn doc_syntax_highlights<'doc>( + doc: &'doc Document, + offset: Position, + height: u16, theme: &Theme, - is_focused: bool, loader: &syntax::Loader, - ) { + ) -> Box<dyn Iterator<Item = HighlightEvent> + 'doc> { let text = doc.text().slice(..); - - let last_line = view.last_line(doc); + let last_line = std::cmp::min( + // Saturating subs to make it inclusive zero indexing. + (offset.row + height as usize).saturating_sub(1), + doc.text().len_lines().saturating_sub(1), + ); let range = { // calculate viewport byte ranges - let start = text.line_to_byte(view.first_line); + let start = text.line_to_byte(offset.row); let end = text.line_to_byte(last_line + 1); start..end }; // TODO: range doesn't actually restrict source, just highlight range - let highlights: Vec<_> = match doc.syntax() { + let highlights = match doc.syntax() { Some(syntax) => { let scopes = theme.scopes(); syntax @@ -151,20 +172,16 @@ impl EditorView { Some(config_ref) }) }) + .map(|event| event.unwrap()) .collect() // TODO: we collect here to avoid holding the lock, fix later } - None => vec![Ok(HighlightEvent::Source { + None => vec![HighlightEvent::Source { start: range.start, end: range.end, - })], - }; - let mut spans = Vec::new(); - let mut visual_x = 0u16; - let mut line = 0u16; - let tab_width = doc.tab_width(); - let tab = " ".repeat(tab_width); - - let highlights = highlights.into_iter().map(|event| match event.unwrap() { + }], + } + .into_iter() + .map(move |event| match event { // convert byte offsets to char offset HighlightEvent::Source { start, end } => { let start = ensure_grapheme_boundary_next(text, text.byte_to_char(start)); @@ -174,13 +191,44 @@ impl EditorView { event => event, }); - let selections = doc.selection(view.id); - let primary_idx = selections.primary_index(); + Box::new(highlights) + } + + /// Get highlight spans for document diagnostics + pub fn doc_diagnostics_highlights( + doc: &Document, + theme: &Theme, + ) -> Vec<(usize, std::ops::Range<usize>)> { + let diagnostic_scope = theme + .find_scope_index("diagnostic") + .or_else(|| theme.find_scope_index("ui.cursor")) + .or_else(|| theme.find_scope_index("ui.selection")) + .expect("no selection scope found!"); + + doc.diagnostics() + .iter() + .map(|diagnostic| { + ( + diagnostic_scope, + diagnostic.range.start..diagnostic.range.end, + ) + }) + .collect() + } + + /// Get highlight spans for selections in a document view. + pub fn doc_selection_highlights( + doc: &Document, + view: &View, + theme: &Theme, + ) -> Vec<(usize, std::ops::Range<usize>)> { + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + let primary_idx = selection.primary_index(); let selection_scope = theme .find_scope_index("ui.selection") .expect("no selection scope found!"); - let base_cursor_scope = theme .find_scope_index("ui.cursor") .unwrap_or(selection_scope); @@ -192,64 +240,59 @@ impl EditorView { } .unwrap_or(base_cursor_scope); - let highlights: Box<dyn Iterator<Item = HighlightEvent>> = if is_focused { - // TODO: primary + insert mode patching: - // (ui.cursor.primary).patch(mode).unwrap_or(cursor) - let primary_cursor_scope = theme - .find_scope_index("ui.cursor.primary") - .unwrap_or(cursor_scope); - let primary_selection_scope = theme - .find_scope_index("ui.selection.primary") - .unwrap_or(selection_scope); - - // inject selections as highlight scopes - let mut spans: Vec<(usize, std::ops::Range<usize>)> = Vec::new(); - for (i, range) in selections.iter().enumerate() { - let (cursor_scope, selection_scope) = if i == primary_idx { - (primary_cursor_scope, primary_selection_scope) - } else { - (cursor_scope, selection_scope) - }; + let primary_cursor_scope = theme + .find_scope_index("ui.cursor.primary") + .unwrap_or(cursor_scope); + let primary_selection_scope = theme + .find_scope_index("ui.selection.primary") + .unwrap_or(selection_scope); - // Special-case: cursor at end of the rope. - if range.head == range.anchor && range.head == text.len_chars() { - spans.push((cursor_scope, range.head..range.head + 1)); - continue; - } + let mut spans: Vec<(usize, std::ops::Range<usize>)> = Vec::new(); + for (i, range) in selection.iter().enumerate() { + let (cursor_scope, selection_scope) = if i == primary_idx { + (primary_cursor_scope, primary_selection_scope) + } else { + (cursor_scope, selection_scope) + }; - let range = range.min_width_1(text); - if range.head > range.anchor { - // Standard case. - let cursor_start = prev_grapheme_boundary(text, range.head); - spans.push((selection_scope, range.anchor..cursor_start)); - spans.push((cursor_scope, cursor_start..range.head)); - } else { - // Reverse case. - let cursor_end = next_grapheme_boundary(text, range.head); - spans.push((cursor_scope, range.head..cursor_end)); - spans.push((selection_scope, cursor_end..range.anchor)); - } + // Special-case: cursor at end of the rope. + if range.head == range.anchor && range.head == text.len_chars() { + spans.push((cursor_scope, range.head..range.head + 1)); + continue; } - Box::new(syntax::merge(highlights, spans)) - } else { - Box::new(highlights) - }; + let range = range.min_width_1(text); + if range.head > range.anchor { + // Standard case. + let cursor_start = prev_grapheme_boundary(text, range.head); + spans.push((selection_scope, range.anchor..cursor_start)); + spans.push((cursor_scope, cursor_start..range.head)); + } else { + // Reverse case. + let cursor_end = next_grapheme_boundary(text, range.head); + spans.push((cursor_scope, range.head..cursor_end)); + spans.push((selection_scope, cursor_end..range.anchor)); + } + } + + spans + } + + pub fn render_text_highlights<H: Iterator<Item = HighlightEvent>>( + doc: &Document, + offset: Position, + viewport: Rect, + surface: &mut Surface, + theme: &Theme, + highlights: H, + ) { + let text = doc.text().slice(..); - // diagnostic injection - let diagnostic_scope = theme.find_scope_index("diagnostic").unwrap_or(cursor_scope); - let highlights = Box::new(syntax::merge( - highlights, - doc.diagnostics() - .iter() - .map(|diagnostic| { - ( - diagnostic_scope, - diagnostic.range.start..diagnostic.range.end, - ) - }) - .collect(), - )); + let mut spans = Vec::new(); + let mut visual_x = 0u16; + let mut line = 0u16; + let tab_width = doc.tab_width(); + let tab = " ".repeat(tab_width); 'outer: for event in highlights { match event { @@ -273,14 +316,14 @@ impl EditorView { }); for grapheme in RopeGraphemes::new(text) { - let out_of_bounds = visual_x < view.first_col as u16 - || visual_x >= viewport.width + view.first_col as u16; + let out_of_bounds = visual_x < offset.col as u16 + || visual_x >= viewport.width + offset.col as u16; if LineEnding::from_rope_slice(&grapheme).is_some() { if !out_of_bounds { // we still want to render an empty cell with the style surface.set_string( - viewport.x + visual_x - view.first_col as u16, + viewport.x + visual_x - offset.col as u16, viewport.y + line, " ", style, @@ -310,7 +353,7 @@ impl EditorView { if !out_of_bounds { // if we're offscreen just keep going until we hit a new line surface.set_string( - viewport.x + visual_x - view.first_col as u16, + viewport.x + visual_x - offset.col as u16, viewport.y + line, grapheme, style, @@ -323,14 +366,108 @@ impl EditorView { } } } + } + + /// Render brace match, selected line numbers, etc (meant for the focused view only) + pub fn render_focused_view_elements( + view: &View, + doc: &Document, + viewport: Rect, + theme: &Theme, + surface: &mut Surface, + ) { + let text = doc.text().slice(..); + let selection = doc.selection(view.id); + let last_line = view.last_line(doc); + let screen = { + let start = text.line_to_char(view.first_line); + let end = text.line_to_char(last_line + 1) + 1; // +1 for cursor at end of text. + Range::new(start, end) + }; + + // render selected linenr(s) + let linenr_select: Style = theme + .try_get("ui.linenr.selected") + .unwrap_or_else(|| theme.get("ui.linenr")); + + // Whether to draw the line number for the last line of the + // document or not. We only draw it if it's not an empty line. + let draw_last = text.line_to_byte(last_line) < text.len_bytes(); + + for selection in selection.iter().filter(|range| range.overlaps(&screen)) { + let head = view.screen_coords_at_pos( + doc, + text, + if selection.head > selection.anchor { + selection.head - 1 + } else { + selection.head + }, + ); + if let Some(head) = head { + // Highlight line number for selected lines. + let line_number = view.first_line + head.row; + let line_number_text = if line_number == last_line && !draw_last { + " ~".into() + } else { + format!("{:>5}", line_number + 1) + }; + surface.set_stringn( + viewport.x - GUTTER_OFFSET + 1, + viewport.y + head.row as u16, + line_number_text, + 5, + linenr_select, + ); + + // Highlight matching braces + // TODO: set cursor position for IME + if let Some(syntax) = doc.syntax() { + use helix_core::match_brackets; + let pos = doc + .selection(view.id) + .primary() + .cursor(doc.text().slice(..)); + let pos = match_brackets::find(syntax, doc.text(), pos) + .and_then(|pos| view.screen_coords_at_pos(doc, text, pos)); + + if let Some(pos) = pos { + // ensure col is on screen + if (pos.col as u16) < viewport.width + view.first_col as u16 + && pos.col >= view.first_col + { + let style = theme.try_get("ui.cursor.match").unwrap_or_else(|| { + Style::default() + .add_modifier(Modifier::REVERSED) + .add_modifier(Modifier::DIM) + }); + + surface + .get_mut(viewport.x + pos.col as u16, viewport.y + pos.row as u16) + .set_style(style); + } + } + } + } + } + } - // render gutters + #[allow(clippy::too_many_arguments)] + pub fn render_gutter( + doc: &Document, + view: &View, + viewport: Rect, + surface: &mut Surface, + theme: &Theme, + ) { + let text = doc.text().slice(..); + let last_line = view.last_line(doc); - let linenr: Style = theme.get("ui.linenr"); - let warning: Style = theme.get("warning"); - let error: Style = theme.get("error"); - let info: Style = theme.get("info"); - let hint: Style = theme.get("hint"); + let linenr = theme.get("ui.linenr"); + let warning = theme.get("warning"); + let error = theme.get("error"); + let info = theme.get("info"); + let hint = theme.get("hint"); // Whether to draw the line number for the last line of the // document or not. We only draw it if it's not an empty line. @@ -368,80 +505,6 @@ impl EditorView { linenr, ); } - - // render selections and selected linenr(s) - let linenr_select: Style = theme - .try_get("ui.linenr.selected") - .unwrap_or_else(|| theme.get("ui.linenr")); - - if is_focused { - let screen = { - let start = text.line_to_char(view.first_line); - let end = text.line_to_char(last_line + 1) + 1; // +1 for cursor at end of text. - Range::new(start, end) - }; - - let selection = doc.selection(view.id); - - for selection in selection.iter().filter(|range| range.overlaps(&screen)) { - let head = view.screen_coords_at_pos( - doc, - text, - if selection.head > selection.anchor { - selection.head - 1 - } else { - selection.head - }, - ); - if let Some(head) = head { - // Draw line number for selected lines. - let line_number = view.first_line + head.row; - let line_number_text = if line_number == last_line && !draw_last { - " ~".into() - } else { - format!("{:>5}", line_number + 1) - }; - surface.set_stringn( - viewport.x + 1 - GUTTER_OFFSET, - viewport.y + head.row as u16, - line_number_text, - 5, - linenr_select, - ); - - // TODO: set cursor position for IME - if let Some(syntax) = doc.syntax() { - use helix_core::match_brackets; - let pos = doc - .selection(view.id) - .primary() - .cursor(doc.text().slice(..)); - let pos = match_brackets::find(syntax, doc.text(), pos) - .and_then(|pos| view.screen_coords_at_pos(doc, text, pos)); - - if let Some(pos) = pos { - // ensure col is on screen - if (pos.col as u16) < viewport.width + view.first_col as u16 - && pos.col >= view.first_col - { - let style = theme.try_get("ui.cursor.match").unwrap_or_else(|| { - Style::default() - .add_modifier(Modifier::REVERSED) - .add_modifier(Modifier::DIM) - }); - - surface - .get_mut( - viewport.x + pos.col as u16, - viewport.y + pos.row as u16, - ) - .set_style(style); - } - } - } - } - } - } } pub fn render_diagnostics( @@ -451,7 +514,6 @@ impl EditorView { viewport: Rect, surface: &mut Surface, theme: &Theme, - _is_focused: bool, ) { use helix_core::diagnostic::Severity; use tui::{ @@ -469,10 +531,10 @@ impl EditorView { diagnostic.range.start <= cursor && diagnostic.range.end >= cursor }); - let warning: Style = theme.get("warning"); - let error: Style = theme.get("error"); - let info: Style = theme.get("info"); - let hint: Style = theme.get("hint"); + let warning = theme.get("warning"); + let error = theme.get("error"); + let info = theme.get("info"); + let hint = theme.get("hint"); // Vec::with_capacity(diagnostics.len()); // rough estimate let mut lines = Vec::new(); @@ -961,7 +1023,7 @@ impl Component for EditorView { } } - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { // clear with background color surface.set_style(area, cx.editor.theme.get("ui.background")); @@ -983,7 +1045,7 @@ impl Component for EditorView { ); } - if let Some(ref info) = self.autoinfo { + if let Some(ref mut info) = self.autoinfo { info.render(area, surface, cx); } @@ -1030,7 +1092,7 @@ impl Component for EditorView { ); } - if let Some(completion) = &self.completion { + if let Some(completion) = self.completion.as_mut() { completion.render(area, surface, cx); } } diff --git a/helix-term/src/ui/info.rs b/helix-term/src/ui/info.rs index 6e810b86..0f14260e 100644 --- a/helix-term/src/ui/info.rs +++ b/helix-term/src/ui/info.rs @@ -5,7 +5,7 @@ use tui::buffer::Buffer as Surface; use tui::widgets::{Block, Borders, Widget}; impl Component for Info { - fn render(&self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { let style = cx.editor.theme.get("ui.popup"); // Calculate the area of the terminal to modify. Because we want to diff --git a/helix-term/src/ui/markdown.rs b/helix-term/src/ui/markdown.rs index 6c79ca67..b7e29f21 100644 --- a/helix-term/src/ui/markdown.rs +++ b/helix-term/src/ui/markdown.rs @@ -198,7 +198,7 @@ fn parse<'a>( Text::from(lines) } impl Component for Markdown { - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { use tui::widgets::{Paragraph, Widget, Wrap}; let text = parse(&self.contents, Some(&cx.editor.theme), &self.config_loader); diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 26eff1d8..3e63db35 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -258,7 +258,7 @@ impl<T: Item + 'static> Component for Menu<T> { // TODO: required size should re-trigger when we filter items so we can draw a smaller menu - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { let style = cx.editor.theme.get("ui.text"); let selected = cx.editor.theme.get("ui.menu.selected"); diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index f68ad0a7..d1af0e48 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -13,7 +13,7 @@ pub use completion::Completion; pub use editor::EditorView; pub use markdown::Markdown; pub use menu::Menu; -pub use picker::Picker; +pub use picker::{FilePicker, Picker}; pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; pub use spinner::{ProgressSpinners, Spinner}; @@ -73,29 +73,26 @@ pub fn regex_prompt( ) } -pub fn file_picker(root: PathBuf) -> Picker<PathBuf> { +pub fn file_picker(root: PathBuf) -> FilePicker<PathBuf> { use ignore::Walk; use std::time; - let files = Walk::new(root.clone()).filter_map(|entry| match entry { - Ok(entry) => { - // filter dirs, but we might need special handling for symlinks! - if !entry.file_type().map_or(false, |entry| entry.is_dir()) { - let time = if let Ok(metadata) = entry.metadata() { - metadata - .accessed() - .or_else(|_| metadata.modified()) - .or_else(|_| metadata.created()) - .unwrap_or(time::UNIX_EPOCH) - } else { - time::UNIX_EPOCH - }; - - Some((entry.into_path(), time)) - } else { - None - } + let files = Walk::new(root.clone()).filter_map(|entry| { + let entry = entry.ok()?; + // Path::is_dir() traverses symlinks, so we use it over DirEntry::is_dir + if entry.path().is_dir() { + // Will give a false positive if metadata cannot be read (eg. permission error) + return None; } - Err(_err) => None, + + let time = entry.metadata().map_or(time::UNIX_EPOCH, |metadata| { + metadata + .accessed() + .or_else(|_| metadata.modified()) + .or_else(|_| metadata.created()) + .unwrap_or(time::UNIX_EPOCH) + }); + + Some((entry.into_path(), time)) }); let mut files: Vec<_> = if root.join(".git").is_dir() { @@ -109,7 +106,7 @@ pub fn file_picker(root: PathBuf) -> Picker<PathBuf> { let files = files.into_iter().map(|(path, _)| path).collect(); - Picker::new( + FilePicker::new( files, move |path: &PathBuf| { // format_fn @@ -124,6 +121,7 @@ pub fn file_picker(root: PathBuf) -> Picker<PathBuf> { .open(path.into(), action) .expect("editor.open failed"); }, + |_editor, path| Some((path.clone(), None)), ) } diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 0b67cd9c..9c6b328f 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -1,4 +1,7 @@ -use crate::compositor::{Component, Compositor, Context, EventResult}; +use crate::{ + compositor::{Component, Compositor, Context, EventResult}, + ui::EditorView, +}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use tui::{ buffer::Buffer as Surface, @@ -7,17 +10,153 @@ use tui::{ use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; use fuzzy_matcher::FuzzyMatcher; +use tui::widgets::Widget; -use std::borrow::Cow; +use std::{borrow::Cow, collections::HashMap, path::PathBuf}; use crate::ui::{Prompt, PromptEvent}; use helix_core::Position; use helix_view::{ + document::canonicalize_path, editor::Action, graphics::{Color, CursorKind, Rect, Style}, - Editor, + Document, Editor, }; +pub const MIN_SCREEN_WIDTH_FOR_PREVIEW: u16 = 80; + +/// File path and line number (used to align and highlight a line) +type FileLocation = (PathBuf, Option<usize>); + +pub struct FilePicker<T> { + picker: Picker<T>, + /// Caches paths to documents + preview_cache: HashMap<PathBuf, Document>, + /// Given an item in the picker, return the file path and line number to display. + file_fn: Box<dyn Fn(&Editor, &T) -> Option<FileLocation>>, +} + +impl<T> FilePicker<T> { + pub fn new( + options: Vec<T>, + format_fn: impl Fn(&T) -> Cow<str> + 'static, + callback_fn: impl Fn(&mut Editor, &T, Action) + 'static, + preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static, + ) -> Self { + Self { + picker: Picker::new(false, options, format_fn, callback_fn), + preview_cache: HashMap::new(), + file_fn: Box::new(preview_fn), + } + } + + fn current_file(&self, editor: &Editor) -> Option<FileLocation> { + self.picker + .selection() + .and_then(|current| (self.file_fn)(editor, current)) + .and_then(|(path, line)| canonicalize_path(&path).ok().zip(Some(line))) + } + + fn calculate_preview(&mut self, editor: &Editor) { + if let Some((path, _line)) = self.current_file(editor) { + if !self.preview_cache.contains_key(&path) && editor.document_by_path(&path).is_none() { + // TODO: enable syntax highlighting; blocked by async rendering + let doc = Document::open(&path, None, Some(&editor.theme), None).unwrap(); + self.preview_cache.insert(path, doc); + } + } + } +} + +impl<T: 'static> Component for FilePicker<T> { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + // +---------+ +---------+ + // |prompt | |preview | + // +---------+ | | + // |picker | | | + // | | | | + // +---------+ +---------+ + self.calculate_preview(cx.editor); + let render_preview = area.width > MIN_SCREEN_WIDTH_FOR_PREVIEW; + let area = inner_rect(area); + // -- Render the frame: + // clear area + let background = cx.editor.theme.get("ui.background"); + surface.clear_with(area, background); + + let picker_width = if render_preview { + area.width / 2 + } else { + area.width + }; + + let picker_area = Rect::new(area.x, area.y, picker_width, area.height); + self.picker.render(picker_area, surface, cx); + + if !render_preview { + return; + } + + let preview_area = Rect::new(area.x + picker_width, area.y, area.width / 2, area.height); + + // don't like this but the lifetime sucks + let block = Block::default().borders(Borders::ALL); + + // calculate the inner area inside the box + let mut inner = block.inner(preview_area); + // 1 column gap on either side + inner.x += 1; + inner.width = inner.width.saturating_sub(2); + + block.render(preview_area, surface); + + if let Some((doc, line)) = self.current_file(cx.editor).and_then(|(path, line)| { + cx.editor + .document_by_path(&path) + .or_else(|| self.preview_cache.get(&path)) + .zip(Some(line)) + }) { + // align to middle + let first_line = line.unwrap_or(0).saturating_sub(inner.height as usize / 2); + let offset = Position::new(first_line, 0); + + let highlights = EditorView::doc_syntax_highlights( + doc, + offset, + area.height, + &cx.editor.theme, + &cx.editor.syn_loader, + ); + EditorView::render_text_highlights( + doc, + offset, + inner, + surface, + &cx.editor.theme, + highlights, + ); + + // highlight the line + if let Some(line) = line { + for x in inner.left()..inner.right() { + surface + .get_mut(x, inner.y + line.saturating_sub(first_line) as u16) + .set_style(cx.editor.theme.get("ui.selection.primary")); + } + } + } + } + + fn handle_event(&mut self, event: Event, ctx: &mut Context) -> EventResult { + // 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) + } +} + pub struct Picker<T> { options: Vec<T>, // filter: String, @@ -30,6 +169,8 @@ pub struct Picker<T> { cursor: usize, // pattern: String, prompt: Prompt, + /// Whether to render in the middle of the area + render_centered: bool, format_fn: Box<dyn Fn(&T) -> Cow<str>>, callback_fn: Box<dyn Fn(&mut Editor, &T, Action)>, @@ -37,6 +178,7 @@ pub struct Picker<T> { impl<T> Picker<T> { pub fn new( + render_centered: bool, options: Vec<T>, format_fn: impl Fn(&T) -> Cow<str> + 'static, callback_fn: impl Fn(&mut Editor, &T, Action) + 'static, @@ -57,6 +199,7 @@ impl<T> Picker<T> { filters: Vec::new(), cursor: 0, prompt, + render_centered, format_fn: Box::new(format_fn), callback_fn: Box::new(callback_fn), }; @@ -139,8 +282,8 @@ impl<T> Picker<T> { // - score all the names in relation to input fn inner_rect(area: Rect) -> Rect { - let padding_vertical = area.height * 20 / 100; - let padding_horizontal = area.width * 20 / 100; + let padding_vertical = area.height * 10 / 100; + let padding_horizontal = area.width * 10 / 100; Rect::new( area.x + padding_horizontal, @@ -174,7 +317,9 @@ impl<T: 'static> Component for Picker<T> { | KeyEvent { code: KeyCode::Char('p'), modifiers: KeyModifiers::CONTROL, - } => self.move_up(), + } => { + self.move_up(); + } KeyEvent { code: KeyCode::Down, .. @@ -185,7 +330,9 @@ impl<T: 'static> Component for Picker<T> { | KeyEvent { code: KeyCode::Char('n'), modifiers: KeyModifiers::CONTROL, - } => self.move_down(), + } => { + self.move_down(); + } KeyEvent { code: KeyCode::Esc, .. } @@ -239,16 +386,18 @@ impl<T: 'static> Component for Picker<T> { EventResult::Consumed(None) } - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { - let area = inner_rect(area); + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + let area = if self.render_centered { + inner_rect(area) + } else { + area + }; // -- Render the frame: - // clear area let background = cx.editor.theme.get("ui.background"); surface.clear_with(area, background); - use tui::widgets::Widget; // don't like this but the lifetime sucks let block = Block::default().borders(Borders::ALL); @@ -263,21 +412,23 @@ impl<T: 'static> Component for Picker<T> { self.prompt.render(area, surface, cx); // -- Separator - let style = Style::default().fg(Color::Rgb(90, 89, 119)); - let symbols = BorderType::line_symbols(BorderType::Plain); + let sep_style = Style::default().fg(Color::Rgb(90, 89, 119)); + let borders = BorderType::line_symbols(BorderType::Plain); for x in inner.left()..inner.right() { surface .get_mut(x, inner.y + 1) - .set_symbol(symbols.horizontal) - .set_style(style); + .set_symbol(borders.horizontal) + .set_style(sep_style); } // -- Render the contents: + // subtract the area of the prompt (-2) and current item marker " > " (-3) + let inner = Rect::new(inner.x + 3, inner.y + 2, inner.width - 3, inner.height - 2); let style = cx.editor.theme.get("ui.text"); let selected = Style::default().fg(Color::Rgb(255, 255, 255)); - let rows = inner.height - 2; // -1 for search bar + let rows = inner.height; let offset = self.cursor / (rows as usize) * (rows as usize); let files = self.matches.iter().skip(offset).map(|(index, _score)| { @@ -286,14 +437,14 @@ impl<T: 'static> Component for Picker<T> { for (i, (_index, option)) in files.take(rows as usize).enumerate() { if i == (self.cursor - offset) { - surface.set_string(inner.x + 1, inner.y + 2 + i as u16, ">", selected); + surface.set_string(inner.x - 2, inner.y + i as u16, ">", selected); } surface.set_string_truncated( - inner.x + 3, - inner.y + 2 + i as u16, + inner.x, + inner.y + i as u16, (self.format_fn)(option), - (inner.width as usize).saturating_sub(3), // account for the " > " + inner.width as usize, if i == (self.cursor - offset) { selected } else { diff --git a/helix-term/src/ui/popup.rs b/helix-term/src/ui/popup.rs index 29ffb4ad..e31d4d7b 100644 --- a/helix-term/src/ui/popup.rs +++ b/helix-term/src/ui/popup.rs @@ -105,7 +105,7 @@ impl<T: Component> Component for Popup<T> { Some(self.size) } - fn render(&self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, viewport: Rect, surface: &mut Surface, cx: &mut Context) { cx.scroll = Some(self.scroll); let position = self diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 57daef3a..8ec3674e 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -352,7 +352,7 @@ impl Prompt { } if let Some(doc) = (self.doc_fn)(&self.line) { - let text = ui::Text::new(doc.to_string()); + let mut text = ui::Text::new(doc.to_string()); let viewport = area; let area = viewport.intersection(Rect::new( @@ -546,7 +546,7 @@ impl Component for Prompt { EventResult::Consumed(None) } - fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { self.render_prompt(area, surface, cx) } diff --git a/helix-term/src/ui/text.rs b/helix-term/src/ui/text.rs index 249cf89e..65a75a4a 100644 --- a/helix-term/src/ui/text.rs +++ b/helix-term/src/ui/text.rs @@ -13,7 +13,7 @@ impl Text { } } impl Component for Text { - fn render(&self, area: Rect, surface: &mut Surface, _cx: &mut Context) { + fn render(&mut self, area: Rect, surface: &mut Surface, _cx: &mut Context) { use tui::widgets::{Paragraph, Widget, Wrap}; let contents = tui::text::Text::from(self.contents.clone()); |