on master help sign in
Add `PageUp`, `PageDown`, `Ctrl-u`, `Ctrl-d`, `Home`, `End` keyboard shortcuts to file picker (#1612)
4429993Ludwig Stecher
book/src/keymap.md+4
@@ -304,7 +304,11 @@ Keys to use within picker. Remapping currently not supported.
304| Key | Description |
304| Key | Description |
305| ----- | ------------- |
305| ----- | ------------- |
306| `Up`, `Ctrl-k`, `Ctrl-p` | Previous entry |
306| `Up`, `Ctrl-k`, `Ctrl-p` | Previous entry |
307+| `PageUp`, `Ctrl-b` | Page up |
307| `Down`, `Ctrl-j`, `Ctrl-n` | Next entry |
308| `Down`, `Ctrl-j`, `Ctrl-n` | Next entry |
309+| `PageDown`, `Ctrl-f` | Page down |
310+| `Home` | Go to first entry |
311+| `End` | Go to last entry |
308| `Ctrl-space` | Filter options |
312| `Ctrl-space` | Filter options |
309| `Enter` | Open selected |
313| `Enter` | Open selected |
310| `Ctrl-s` | Open horizontally |
314| `Ctrl-s` | Open horizontally |
helix-term/src/application.rs+3−2
@@ -13,7 +13,7 @@ use crate::{
13 compositor::Compositor,
13 compositor::Compositor,
14 config::Config,
14 config::Config,
15 job::Jobs,
15 job::Jobs,
16− ui,
16+ ui::{self, overlay::overlayed},
17};
17};
18
18
19use log::{error, warn};
19use log::{error, warn};
@@ -124,7 +124,8 @@ impl Application {
124 if first.is_dir() {
124 if first.is_dir() {
125 std::env::set_current_dir(&first)?;
125 std::env::set_current_dir(&first)?;
126 editor.new_file(Action::VerticalSplit);
126 editor.new_file(Action::VerticalSplit);
127− compositor.push(Box::new(ui::file_picker(".".into(), &config.editor)));
127+ let picker = ui::file_picker(".".into(), &config.editor);
128+ compositor.push(Box::new(overlayed(picker)));
128 } else {
129 } else {
129 let nr_of_files = args.files.len();
130 let nr_of_files = args.files.len();
130 editor.open(first.to_path_buf(), Action::VerticalSplit)?;
131 editor.open(first.to_path_buf(), Action::VerticalSplit)?;
helix-term/src/commands.rs+7−7
@@ -44,7 +44,7 @@ use movement::Movement;
44use crate::{
44use crate::{
45 args,
45 args,
46 compositor::{self, Component, Compositor},
46 compositor::{self, Component, Compositor},
47− ui::{self, FilePicker, Popup, Prompt, PromptEvent},
47+ ui::{self, overlay::overlayed, FilePicker, Popup, Prompt, PromptEvent},
48};
48};
49
49
50use crate::job::{self, Job, Jobs};
50use crate::job::{self, Job, Jobs};
@@ -1824,7 +1824,7 @@ fn global_search(cx: &mut Context) {
1824 },
1824 },
1825 |_editor, (line_num, path)| Some((path.clone(), Some((*line_num, *line_num)))),
1825 |_editor, (line_num, path)| Some((path.clone(), Some((*line_num, *line_num)))),
1826 );
1826 );
1827− compositor.push(Box::new(picker));
1827+ compositor.push(Box::new(overlayed(picker)));
1828 });
1828 });
1829 Ok(call)
1829 Ok(call)
1830 };
1830 };
@@ -3359,7 +3359,7 @@ fn file_picker(cx: &mut Context) {
3359 // We don't specify language markers, root will be the root of the current git repo
3359 // We don't specify language markers, root will be the root of the current git repo
3360 let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./"));
3360 let root = find_root(None, &[]).unwrap_or_else(|| PathBuf::from("./"));
3361 let picker = ui::file_picker(root, &cx.editor.config);
3361 let picker = ui::file_picker(root, &cx.editor.config);
3362− cx.push_layer(Box::new(picker));
3362+ cx.push_layer(Box::new(overlayed(picker)));
3363}
3363}
3364
3364
3365fn buffer_picker(cx: &mut Context) {
3365fn buffer_picker(cx: &mut Context) {
@@ -3427,7 +3427,7 @@ fn buffer_picker(cx: &mut Context) {
3427 Some((meta.path.clone()?, Some((line, line))))
3427 Some((meta.path.clone()?, Some((line, line))))
3428 },
3428 },
3429 );
3429 );
3430− cx.push_layer(Box::new(picker));
3430+ cx.push_layer(Box::new(overlayed(picker)));
3431}
3431}
3432
3432
3433fn symbol_picker(cx: &mut Context) {
3433fn symbol_picker(cx: &mut Context) {
@@ -3505,7 +3505,7 @@ fn symbol_picker(cx: &mut Context) {
3505 },
3505 },
3506 );
3506 );
3507 picker.truncate_start = false;
3507 picker.truncate_start = false;
3508− compositor.push(Box::new(picker))
3508+ compositor.push(Box::new(overlayed(picker)))
3509 }
3509 }
3510 },
3510 },
3511 )
3511 )
@@ -3564,7 +3564,7 @@ fn workspace_symbol_picker(cx: &mut Context) {
3564 },
3564 },
3565 );
3565 );
3566 picker.truncate_start = false;
3566 picker.truncate_start = false;
3567− compositor.push(Box::new(picker))
3567+ compositor.push(Box::new(overlayed(picker)))
3568 }
3568 }
3569 },
3569 },
3570 )
3570 )
@@ -4225,7 +4225,7 @@ fn goto_impl(
4225 Some((path, line))
4225 Some((path, line))
4226 },
4226 },
4227 );
4227 );
4228− compositor.push(Box::new(picker));
4228+ compositor.push(Box::new(overlayed(picker)));
4229 }
4229 }
4230 }
4230 }
4231}
4231}
helix-term/src/ui/mod.rs+1
@@ -3,6 +3,7 @@ pub(crate) mod editor;
3mod info;
3mod info;
4mod markdown;
4mod markdown;
5pub mod menu;
5pub mod menu;
6+pub mod overlay;
6mod picker;
7mod picker;
7mod popup;
8mod popup;
8mod prompt;
9mod prompt;
helix-term/src/ui/overlay.rsnew file+73
@@ -0,0 +1,73 @@
1+use crossterm::event::Event;
2+use helix_core::Position;
3+use helix_view::{
4+ graphics::{CursorKind, Rect},
5+ Editor,
6+};
7+use tui::buffer::Buffer;
8+
9+use crate::compositor::{Component, Context, EventResult};
10+
11+/// Contains a component placed in the center of the parent component
12+pub struct Overlay<T> {
13+ /// Child component
14+ pub content: T,
15+ /// Function to compute the size and position of the child component
16+ pub calc_child_size: Box<dyn Fn(Rect) -> Rect>,
17+}
18+
19+/// Surrounds the component with a margin of 5% on each side, and an additional 2 rows at the bottom
20+pub fn overlayed<T>(content: T) -> Overlay<T> {
21+ Overlay {
22+ content,
23+ calc_child_size: Box::new(|rect: Rect| clip_rect_relative(rect.clip_bottom(2), 90, 90)),
24+ }
25+}
26+
27+fn clip_rect_relative(rect: Rect, percent_horizontal: u8, percent_vertical: u8) -> Rect {
28+ fn mul_and_cast(size: u16, factor: u8) -> u16 {
29+ ((size as u32) * (factor as u32) / 100).try_into().unwrap()
30+ }
31+
32+ let inner_w = mul_and_cast(rect.width, percent_horizontal);
33+ let inner_h = mul_and_cast(rect.height, percent_vertical);
34+
35+ let offset_x = rect.width.saturating_sub(inner_w) / 2;
36+ let offset_y = rect.height.saturating_sub(inner_h) / 2;
37+
38+ Rect {
39+ x: rect.x + offset_x,
40+ y: rect.y + offset_y,
41+ width: inner_w,
42+ height: inner_h,
43+ }
44+}
45+
46+impl<T: Component + 'static> Component for Overlay<T> {
47+ fn render(&mut self, area: Rect, frame: &mut Buffer, ctx: &mut Context) {
48+ let dimensions = (self.calc_child_size)(area);
49+ self.content.render(dimensions, frame, ctx)
50+ }
51+
52+ fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> {
53+ let area = Rect {
54+ x: 0,
55+ y: 0,
56+ width,
57+ height,
58+ };
59+ let dimensions = (self.calc_child_size)(area);
60+ let viewport = (dimensions.width, dimensions.height);
61+ let _ = self.content.required_size(viewport)?;
62+ Some((width, height))
63+ }
64+
65+ fn handle_event(&mut self, event: Event, ctx: &mut Context) -> EventResult {
66+ self.content.handle_event(event, ctx)
67+ }
68+
69+ fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind) {
70+ let dimensions = (self.calc_child_size)(area);
71+ self.content.cursor(dimensions, ctx)
72+ }
73+}
helix-term/src/ui/picker.rs+63−47
@@ -21,14 +21,14 @@ use std::{
21};
21};
22
22
23use crate::ui::{Prompt, PromptEvent};
23use crate::ui::{Prompt, PromptEvent};
24−use helix_core::Position;
24+use helix_core::{movement::Direction, Position};
25use helix_view::{
25use helix_view::{
26 editor::Action,
26 editor::Action,
27 graphics::{Color, CursorKind, Margin, Rect, Style},
27 graphics::{Color, CursorKind, Margin, Rect, Style},
28 Document, Editor,
28 Document, Editor,
29};
29};
30
30
31−pub const MIN_SCREEN_WIDTH_FOR_PREVIEW: u16 = 80;
31+pub const MIN_AREA_WIDTH_FOR_PREVIEW: u16 = 72;
32/// Biggest file size to preview in bytes
32/// Biggest file size to preview in bytes
33pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024;
33pub const MAX_FILE_SIZE_FOR_PREVIEW: u64 = 10 * 1024 * 1024;
34
34
@@ -90,7 +90,7 @@ impl<T> FilePicker<T> {
90 preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
90 preview_fn: impl Fn(&Editor, &T) -> Option<FileLocation> + 'static,
91 ) -> Self {
91 ) -> Self {
92 Self {
92 Self {
93− picker: Picker::new(false, options, format_fn, callback_fn),
93+ picker: Picker::new(options, format_fn, callback_fn),
94 truncate_start: true,
94 truncate_start: true,
95 preview_cache: HashMap::new(),
95 preview_cache: HashMap::new(),
96 read_buffer: Vec::with_capacity(1024),
96 read_buffer: Vec::with_capacity(1024),
@@ -160,8 +160,7 @@ impl<T: 'static> Component for FilePicker<T> {
160 // | | | |
160 // | | | |
161 // +---------+ +---------+
161 // +---------+ +---------+
162
162
163− let render_preview = area.width > MIN_SCREEN_WIDTH_FOR_PREVIEW;
163+ let render_preview = area.width > MIN_AREA_WIDTH_FOR_PREVIEW;
164− let area = inner_rect(area);
165 // -- Render the frame:
164 // -- Render the frame:
166 // clear area
165 // clear area
167 let background = cx.editor.theme.get("ui.background");
166 let background = cx.editor.theme.get("ui.background");
@@ -260,6 +259,16 @@ impl<T: 'static> Component for FilePicker<T> {
260 fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind) {
259 fn cursor(&self, area: Rect, ctx: &Editor) -> (Option<Position>, CursorKind) {
261 self.picker.cursor(area, ctx)
260 self.picker.cursor(area, ctx)
262 }
261 }
262+
263+ fn required_size(&mut self, (width, height): (u16, u16)) -> Option<(u16, u16)> {
264+ let picker_width = if width > MIN_AREA_WIDTH_FOR_PREVIEW {
265+ width / 2
266+ } else {
267+ width
268+ };
269+ self.picker.required_size((picker_width, height))?;
270+ Some((width, height))
271+ }
263}
272}
264
273
265pub struct Picker<T> {
274pub struct Picker<T> {
@@ -271,11 +280,12 @@ pub struct Picker<T> {
271 /// Filter over original options.
280 /// Filter over original options.
272 filters: Vec<usize>, // could be optimized into bit but not worth it now
281 filters: Vec<usize>, // could be optimized into bit but not worth it now
273
282
283+ /// Current height of the completions box
284+ completion_height: u16,
285+
274 cursor: usize,
286 cursor: usize,
275 // pattern: String,
287 // pattern: String,
276 prompt: Prompt,
288 prompt: Prompt,
277− /// Whether to render in the middle of the area
278− render_centered: bool,
279 /// Wheather to truncate the start (default true)
289 /// Wheather to truncate the start (default true)
280 pub truncate_start: bool,
290 pub truncate_start: bool,
281
291
@@ -285,7 +295,6 @@ pub struct Picker<T> {
285
295
286impl<T> Picker<T> {
296impl<T> Picker<T> {
287 pub fn new(
297 pub fn new(
288− render_centered: bool,
289 options: Vec<T>,
298 options: Vec<T>,
290 format_fn: impl Fn(&T) -> Cow<str> + 'static,
299 format_fn: impl Fn(&T) -> Cow<str> + 'static,
291 callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
300 callback_fn: impl Fn(&mut Context, &T, Action) + 'static,
@@ -306,10 +315,10 @@ impl<T> Picker<T> {
306 filters: Vec::new(),
315 filters: Vec::new(),
307 cursor: 0,
316 cursor: 0,
308 prompt,
317 prompt,
309− render_centered,
310 truncate_start: true,
318 truncate_start: true,
311 format_fn: Box::new(format_fn),
319 format_fn: Box::new(format_fn),
312 callback_fn: Box::new(callback_fn),
320 callback_fn: Box::new(callback_fn),
321+ completion_height: 0,
313 };
322 };
314
323
315 // TODO: scoring on empty input should just use a fastpath
324 // TODO: scoring on empty input should just use a fastpath
@@ -346,22 +355,38 @@ impl<T> Picker<T> {
346 self.cursor = 0;
355 self.cursor = 0;
347 }
356 }
348
357
349− pub fn move_up(&mut self) {
358+ /// Move the cursor by a number of lines, either down (`Forward`) or up (`Backward`)
350− if self.matches.is_empty() {
359+ pub fn move_by(&mut self, amount: usize, direction: Direction) {
351− return;
352− }
353 let len = self.matches.len();
360 let len = self.matches.len();
354− let pos = ((self.cursor + len.saturating_sub(1)) % len) % len;
355− self.cursor = pos;
356− }
357
361
358− pub fn move_down(&mut self) {
362+ match direction {
359− if self.matches.is_empty() {
363+ Direction::Forward => {
360− return;
364+ self.cursor = self.cursor.saturating_add(amount) % len;
365+ }
366+ Direction::Backward => {
367+ self.cursor = self.cursor.saturating_add(len).saturating_sub(amount) % len;
368+ }
361 }
369 }
370+ }
371+
372+ /// Move the cursor down by exactly one page. After the last page comes the first page.
373+ pub fn page_up(&mut self) {
374+ self.move_by(self.completion_height as usize, Direction::Backward);
375+ }
376+
377+ /// Move the cursor up by exactly one page. After the first page comes the last page.
378+ pub fn page_down(&mut self) {
379+ self.move_by(self.completion_height as usize, Direction::Forward);
380+ }
381+
382+ /// Move the cursor to the first entry
383+ pub fn to_start(&mut self) {
384+ self.cursor = 0;
385+ }
386+
387+ /// Move the cursor to the last entry
388+ pub fn to_end(&mut self) {
362− let len = self.matches.len();
389+ self.cursor = self.matches.len().saturating_sub(1);
363− let pos = (self.cursor + 1) % len;
364− self.cursor = pos;
365 }
390 }
366
391
367 pub fn selection(&self) -> Option<&T> {
392 pub fn selection(&self) -> Option<&T> {
@@ -384,23 +409,10 @@ impl<T> Picker<T> {
384// - on input change:
409// - on input change:
385// - score all the names in relation to input
410// - score all the names in relation to input
386
411
387−fn inner_rect(area: Rect) -> Rect {
388− let margin = Margin {
389− vertical: area.height * 10 / 100,
390− horizontal: area.width * 10 / 100,
391− };
392− area.inner(&margin)
393−}
394−
395impl<T: 'static> Component for Picker<T> {
412impl<T: 'static> Component for Picker<T> {
396 fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
413 fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> {
397− let max_width = 50.min(viewport.0);
398− let max_height = 10.min(viewport.1.saturating_sub(2)); // add some spacing in the viewport
414+ self.completion_height = viewport.1.saturating_sub(4);
399−
415+ Some(viewport)
400− let height = (self.options.len() as u16 + 4) // add some spacing for input + padding
401− .min(max_height);
402− let width = max_width;
403− Some((width, height))
404 }
416 }
405
417
406 fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
418 fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult {
@@ -417,10 +429,22 @@ impl<T: 'static> Component for Picker<T> {
417
429
418 match key_event.into() {
430 match key_event.into() {
419 shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => {
431 shift!(Tab) | key!(Up) | ctrl!('p') | ctrl!('k') => {
420− self.move_up();
432+ self.move_by(1, Direction::Backward);
421 }
433 }
422 key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => {
434 key!(Tab) | key!(Down) | ctrl!('n') | ctrl!('j') => {
423− self.move_down();
435+ self.move_by(1, Direction::Forward);
436+ }
437+ key!(PageDown) | ctrl!('f') => {
438+ self.page_down();
439+ }
440+ key!(PageUp) | ctrl!('b') => {
441+ self.page_up();
442+ }
443+ key!(Home) => {
444+ self.to_start();
445+ }
446+ key!(End) => {
447+ self.to_end();
424 }
448 }
425 key!(Esc) | ctrl!('c') => {
449 key!(Esc) | ctrl!('c') => {
426 return close_fn;
450 return close_fn;
@@ -458,12 +482,6 @@ impl<T: 'static> Component for Picker<T> {
458 }
482 }
459
483
460 fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
484 fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
461− let area = if self.render_centered {
462− inner_rect(area)
463− } else {
464− area
465− };
466−
467 let text_style = cx.editor.theme.get("ui.text");
485 let text_style = cx.editor.theme.get("ui.text");
468
486
469 // -- Render the frame:
487 // -- Render the frame:
@@ -538,8 +556,6 @@ impl<T: 'static> Component for Picker<T> {
538 }
556 }
539
557
540 fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) {
558 fn cursor(&self, area: Rect, editor: &Editor) -> (Option<Position>, CursorKind) {
541− // TODO: this is mostly duplicate code
542− let area = inner_rect(area);
543 let block = Block::default().borders(Borders::ALL);
559 let block = Block::default().borders(Borders::ALL);
544 // calculate the inner area inside the box
560 // calculate the inner area inside the box
545 let inner = block.inner(area);
561 let inner = block.inner(area);