diff --git a/book/src/keymap.md b/book/src/keymap.md index 905ec48fa8d0..0a21aa515d40 100644 --- a/book/src/keymap.md +++ b/book/src/keymap.md @@ -304,7 +304,11 @@ Keys to use within picker. Remapping currently not supported. | Key | Description | | ----- | ------------- | | `Up`, `Ctrl-k`, `Ctrl-p` | Previous entry | +| `PageUp`, `Ctrl-b` | Page up | | `Down`, `Ctrl-j`, `Ctrl-n` | Next entry | +| `PageDown`, `Ctrl-f` | Page down | +| `Home` | Go to first entry | +| `End` | Go to last entry | | `Ctrl-space` | Filter options | | `Enter` | Open selected | | `Ctrl-s` | Open horizontally | diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index ae154a24d406..50600df4fdb8 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -9,7 +9,7 @@ use crate::{ compositor::Compositor, config::Config, job::Jobs, - ui, + ui::{self, overlay::overlayed}, }; use log::{error, warn}; @@ -138,7 +138,8 @@ impl Application { if first.is_dir() { std::env::set_current_dir(&first)?; editor.new_file(Action::VerticalSplit); - compositor.push(Box::new(ui::file_picker(".".into(), &config.editor))); + let picker = ui::file_picker(".".into(), &config.editor); + compositor.push(Box::new(overlayed(picker))); } else { let nr_of_files = args.files.len(); editor.open(first.to_path_buf(), Action::VerticalSplit)?; diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 6bc2b9b6c29b..4ad0fa42ccdd 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -39,7 +39,7 @@ use movement::Movement; use crate::{ args, compositor::{self, Component, Compositor}, - ui::{self, FilePicker, Popup, Prompt, PromptEvent}, + ui::{self, overlay::overlayed, FilePicker, Popup, Prompt, PromptEvent}, }; use crate::job::{self, Job, Jobs}; @@ -1785,7 +1785,7 @@ fn global_search(cx: &mut Context) { }, |_editor, (line_num, path)| Some((path.clone(), Some((*line_num, *line_num)))), ); - compositor.push(Box::new(picker)); + compositor.push(Box::new(overlayed(picker))); }); Ok(call) }; @@ -3251,7 +3251,7 @@ fn file_picker(cx: &mut Context) { // We don't specify language markers, root will be the root of the current git repo let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./")); let picker = ui::file_picker(root, &cx.editor.config); - cx.push_layer(Box::new(picker)); + cx.push_layer(Box::new(overlayed(picker))); } fn buffer_picker(cx: &mut Context) { @@ -3319,7 +3319,7 @@ fn buffer_picker(cx: &mut Context) { Some((meta.path.clone()?, Some((line, line)))) }, ); - cx.push_layer(Box::new(picker)); + cx.push_layer(Box::new(overlayed(picker))); } fn symbol_picker(cx: &mut Context) { @@ -3397,7 +3397,7 @@ fn symbol_picker(cx: &mut Context) { }, ); picker.truncate_start = false; - compositor.push(Box::new(picker)) + compositor.push(Box::new(overlayed(picker))) } }, ) @@ -3457,7 +3457,7 @@ fn workspace_symbol_picker(cx: &mut Context) { }, ); picker.truncate_start = false; - compositor.push(Box::new(picker)) + compositor.push(Box::new(overlayed(picker))) } }, ) @@ -4117,7 +4117,7 @@ fn goto_impl( Some((path, line)) }, ); - compositor.push(Box::new(picker)); + compositor.push(Box::new(overlayed(picker))); } } } diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 5d650c6592eb..89a14e4b885e 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod editor; mod info; mod markdown; pub mod menu; +pub mod overlay; mod picker; mod popup; mod prompt; diff --git a/helix-term/src/ui/overlay.rs b/helix-term/src/ui/overlay.rs new file mode 100644 index 000000000000..9f522e355547 --- /dev/null +++ b/helix-term/src/ui/overlay.rs @@ -0,0 +1,73 @@ +use crossterm::event::Event; +use helix_core::Position; +use helix_view::{ + graphics::{CursorKind, Rect}, + Editor, +}; +use tui::buffer::Buffer; + +use crate::compositor::{Component, Context, EventResult}; + +/// Contains a component placed in the center of the parent component +pub struct Overlay { + /// Child component + pub content: T, + /// Function to compute the size and position of the child component + pub calc_child_size: Box Rect>, +} + +/// Surrounds the component with a margin of 5% on each side, and an additional 2 rows at the bottom +pub fn overlayed(content: T) -> Overlay { + Overlay { + content, + calc_child_size: Box::new(|rect: Rect| clip_rect_relative(rect.clip_bottom(2), 90, 90)), + } +} + +fn clip_rect_relative(rect: Rect, percent_horizontal: u8, percent_vertical: u8) -> Rect { + fn mul_and_cast(size: u16, factor: u8) -> u16 { + ((size as u32) * (factor as u32) / 100).try_into().unwrap() + } + + let inner_w = mul_and_cast(rect.width, percent_horizontal); + let inner_h = mul_and_cast(rect.height, percent_vertical); + + let offset_x = rect.width.saturating_sub(inner_w) / 2; + let offset_y = rect.height.saturating_sub(inner_h) / 2; + + Rect { + x: rect.x + offset_x, + y: rect.y + offset_y, + width: inner_w, + height: inner_h, + } +} + +impl Component for Overlay { + fn render(&mut self, area: Rect, frame: &mut Buffer, ctx: &mut Context) { + let dimensions = (self.calc_child_size)(area); + self.content.render(dimensions, frame, ctx) + } + + fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> { + let area = Rect { + x: 0, + y: 0, + width, + height, + }; + let dimensions = (self.calc_child_size)(area); + let viewport = (dimensions.width, dimensions.height); + let _ = self.content.required_size(viewport)?; + Some((width, height)) + } + + fn handle_event(&mut self, event: Event, ctx: &mut Context) -> EventResult { + self.content.handle_event(event, ctx) + } + + fn cursor(&self, area: Rect, ctx: &Editor) -> (Option, CursorKind) { + let dimensions = (self.calc_child_size)(area); + self.content.cursor(dimensions, ctx) + } +} diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 4068a2d43df1..334f8410e93d 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -21,14 +21,14 @@ use std::{ }; use crate::ui::{Prompt, PromptEvent}; -use helix_core::Position; +use helix_core::{movement::Direction, Position}; use helix_view::{ editor::Action, graphics::{Color, CursorKind, Margin, Rect, Style}, Document, Editor, }; -pub const MIN_SCREEN_WIDTH_FOR_PREVIEW: u16 = 80; +pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72; /// Biggest file size to preview in bytes pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024; @@ -90,7 +90,7 @@ impl FilePicker { preview_fn: impl Fn(&Editor, &T) -> Option + 'static, ) -> Self { Self { - picker: Picker::new(false, options, format_fn, callback_fn), + picker: Picker::new(options, format_fn, callback_fn), truncate_start: true, preview_cache: HashMap::new(), read_buffer: Vec::with_capacity(1024), @@ -160,8 +160,7 @@ impl Component for FilePicker { // | | | | // +---------+ +---------+ - let render_preview = area.width > MIN_SCREEN_WIDTH_FOR_PREVIEW; - let area = inner_rect(area); + let render_preview = area.width > MIN_AREA_WIDTH_FOR_PREVIEW; // -- Render the frame: // clear area let background = cx.editor.theme.get("ui.background"); @@ -260,6 +259,16 @@ impl Component for FilePicker { fn cursor(&self, area: Rect, ctx: &Editor) -> (Option, 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)) + } } pub struct Picker { @@ -271,11 +280,12 @@ pub struct Picker { /// Filter over original options. filters: Vec, // could be optimized into bit but not worth it now + /// Current height of the completions box + completion_height: u16, + cursor: usize, // pattern: String, prompt: Prompt, - /// Whether to render in the middle of the area - render_centered: bool, /// Wheather to truncate the start (default true) pub truncate_start: bool, @@ -285,7 +295,6 @@ pub struct Picker { impl Picker { pub fn new( - render_centered: bool, options: Vec, format_fn: impl Fn(&T) -> Cow + 'static, callback_fn: impl Fn(&mut Editor, &T, Action) + 'static, @@ -306,10 +315,10 @@ impl Picker { filters: Vec::new(), cursor: 0, prompt, - render_centered, truncate_start: true, format_fn: Box::new(format_fn), callback_fn: Box::new(callback_fn), + completion_height: 0, }; // TODO: scoring on empty input should just use a fastpath @@ -346,22 +355,38 @@ impl Picker { self.cursor = 0; } - pub fn move_up(&mut self) { - if self.matches.is_empty() { - return; - } + /// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`) + pub fn move_by(&mut self, amount: usize, direction: Direction) { let len = self.matches.len(); - let pos = ((self.cursor + len.saturating_sub(1)) % len) % len; - self.cursor = pos; - } - pub fn move_down(&mut self) { - if self.matches.is_empty() { - return; + match direction { + Direction::Forward => { + self.cursor = self.cursor.saturating_add(amount) % len; + } + Direction::Backward => { + self.cursor = self.cursor.saturating_add(len).saturating_sub(amount) % len; + } } - let len = self.matches.len(); - let pos = (self.cursor + 1) % len; - self.cursor = pos; + } + + /// Move the cursor down by exactly one page. After the last page comes the first page. + pub fn page_up(&mut self) { + self.move_by(self.completion_height as usize, Direction::Backward); + } + + /// Move the cursor up by exactly one page. After the first page comes the last page. + pub fn page_down(&mut self) { + self.move_by(self.completion_height as usize, Direction::Forward); + } + + /// Move the cursor to the first entry + pub fn to_start(&mut self) { + self.cursor = 0; + } + + /// Move the cursor to the last entry + pub fn to_end(&mut self) { + self.cursor = self.matches.len().saturating_sub(1); } pub fn selection(&self) -> Option<&T> { @@ -384,23 +409,10 @@ impl Picker { // - on input change: // - score all the names in relation to input -fn inner_rect(area: Rect) -> Rect { - let margin = Margin { - vertical: area.height * 10 / 100, - horizontal: area.width * 10 / 100, - }; - area.inner(&margin) -} - impl Component for Picker { fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { - let max_width = 50.min(viewport.0); - let max_height = 10.min(viewport.1.saturating_sub(2)); // add some spacing in the viewport - - let height = (self.options.len() as u16 + 4) // add some spacing for input + padding - .min(max_height); - let width = max_width; - Some((width, height)) + self.completion_height = viewport.1.saturating_sub(4); + Some(viewport) } fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { @@ -417,10 +429,22 @@ impl Component for Picker { match key_event.into() { shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => { - self.move_up(); + self.move_by(1, Direction::Backward); } key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => { - self.move_down(); + self.move_by(1, Direction::Forward); + } + key!(PageDown) | ctrl!('f') => { + self.page_down(); + } + key!(PageUp) | ctrl!('b') => { + self.page_up(); + } + key!(Home) => { + self.to_start(); + } + key!(End) => { + self.to_end(); } key!(Esc) | ctrl!('c') => { return close_fn; @@ -458,12 +482,6 @@ impl Component for Picker { } fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { - let area = if self.render_centered { - inner_rect(area) - } else { - area - }; - let text_style = cx.editor.theme.get("ui.text"); // -- Render the frame: @@ -538,8 +556,6 @@ impl Component for Picker { } fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) { - // TODO: this is mostly duplicate code - let area = inner_rect(area); let block = Block::default().borders(Borders::ALL); // calculate the inner area inside the box let inner = block.inner(area);