From 7c75ec04e8db825f24f311d0c26109310738183d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Hrastnik?= Date: Thu, 17 Dec 2020 18:08:16 +0900 Subject: [PATCH] File picker mockup, reuses the line editor work done on Prompt. --- Cargo.lock | 86 +++++++++++++++ helix-term/Cargo.toml | 4 + helix-term/src/commands.rs | 8 +- helix-term/src/keymap.rs | 2 + helix-term/src/ui/helix.log | 0 helix-term/src/ui/mod.rs | 2 + helix-term/src/ui/picker.rs | 205 ++++++++++++++++++++++++++++++++++++ helix-term/src/ui/prompt.rs | 10 +- 8 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 helix-term/src/ui/helix.log create mode 100644 helix-term/src/ui/picker.rs diff --git a/Cargo.lock b/Cargo.lock index 1c8c86c70..3f64bcdf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "bstr" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473fc6b38233f9af7baa94fb5852dca389e3d95b8e21c8e3719301462c5d9faf" +dependencies = [ + "memchr", +] + [[package]] name = "cache-padded" version = "1.1.1" @@ -339,6 +348,12 @@ dependencies = [ "log", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.0.0" @@ -429,6 +444,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "getrandom" version = "0.1.15" @@ -446,6 +470,19 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" +[[package]] +name = "globset" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c152169ef1e421390738366d2f796655fec62621dabbd0fd476f905934061e4a" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + [[package]] name = "hashbrown" version = "0.9.1" @@ -508,9 +545,11 @@ dependencies = [ "crossterm", "fern", "futures-util", + "fuzzy-matcher", "helix-core", "helix-lsp", "helix-view", + "ignore", "log", "num_cpus", "once_cell", @@ -551,6 +590,24 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b287fb45c60bb826a0dc68ff08742b9d88a2fea13d6e0c286b3172065aaf878c" +dependencies = [ + "crossbeam-utils", + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.6.0" @@ -929,6 +986,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -1228,6 +1294,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" +[[package]] +name = "walkdir" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777182bc735b6424e1a57516d35ed72cb8019d85c8c9bf536dccb3445c1a2f7d" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -1265,6 +1342,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/helix-term/Cargo.toml b/helix-term/Cargo.toml index b8eea7c2d..645ed155a 100644 --- a/helix-term/Cargo.toml +++ b/helix-term/Cargo.toml @@ -32,3 +32,7 @@ futures-util = "0.3" fern = "0.6" chrono = "0.4" log = "0.4" + +# File picker +fuzzy-matcher = "0.3" +ignore = "0.4" diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index cdd2ad346..4b2467217 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -10,7 +10,7 @@ use helix_core::{ use once_cell::sync::Lazy; use crate::compositor::Compositor; -use crate::ui::{Prompt, PromptEvent}; +use crate::ui::{self, Prompt, PromptEvent}; use helix_view::{ document::Mode, @@ -456,6 +456,12 @@ pub fn command_mode(cx: &mut Context) { compositor.push(Box::new(prompt)); })); } +pub fn file_picker(cx: &mut Context) { + cx.callback = Some(Box::new(|compositor: &mut Compositor| { + let picker = ui::Picker::new(); + compositor.push(Box::new(picker)); + })); +} // calculate line numbers for each selection range fn selection_lines(state: &State) -> Vec { diff --git a/helix-term/src/keymap.rs b/helix-term/src/keymap.rs index 884c04a1a..a31676e4d 100644 --- a/helix-term/src/keymap.rs +++ b/helix-term/src/keymap.rs @@ -183,6 +183,8 @@ pub fn default() -> Keymaps { }] => commands::page_down, vec![ctrl!('u')] => commands::half_page_up, vec![ctrl!('d')] => commands::half_page_down, + + vec![ctrl!('p')] => commands::file_picker, ), Mode::Insert => hashmap!( vec![Key { diff --git a/helix-term/src/ui/helix.log b/helix-term/src/ui/helix.log new file mode 100644 index 000000000..e69de29bb diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 9a70d1bd7..cb79a1d13 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,7 +1,9 @@ mod editor; +mod picker; mod prompt; pub use editor::EditorView; +pub use picker::Picker; pub use prompt::{Prompt, PromptEvent}; pub use tui::layout::Rect; diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs new file mode 100644 index 000000000..5046ef745 --- /dev/null +++ b/helix-term/src/ui/picker.rs @@ -0,0 +1,205 @@ +use crate::compositor::{Component, Compositor, Context, EventResult}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +use tui::buffer::Buffer as Surface; +use tui::{ + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders}, +}; + +use fuzzy_matcher::skim::SkimMatcherV2 as Matcher; +use fuzzy_matcher::FuzzyMatcher; +use ignore::Walk; + +use std::path::PathBuf; + +use crate::ui::{Prompt, PromptEvent}; +use helix_core::Position; +use helix_view::Editor; + +pub struct Picker { + files: Vec, + // filter: String, + matcher: Box, + + cursor: usize, + // pattern: String, + prompt: Prompt, +} + +impl Picker { + pub fn new() -> Self { + let files = Walk::new("./").filter_map(|entry| match entry { + Ok(entry) => { + // filter dirs, but we might need special handling for symlinks! + if !entry.file_type().unwrap().is_dir() { + Some(entry.into_path()) + } else { + None + } + } + Err(_err) => None, + }); + + let prompt = Prompt::new( + "".to_string(), + |pattern: &str| Vec::new(), + |editor: &mut Editor, pattern: &str, event: PromptEvent| { + // + }, + ); + + const MAX: usize = 1024; + + Self { + files: files.take(MAX).collect(), + matcher: Box::new(Matcher::default()), + cursor: 0, + prompt, + } + } + + pub fn score(&mut self, pattern: &str) { + self.files.iter().filter_map(|path| match path.to_str() { + // TODO: using fuzzy_indices could give us the char idx for match highlighting + Some(path) => (self.matcher.fuzzy_match(path, pattern)), + None => None, + }); + } + + pub fn move_up(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + pub fn move_down(&mut self) { + // TODO: len - 1 + if self.cursor < self.files.len() { + self.cursor += 1; + } + } +} + +// process: +// - read all the files into a list, maxed out at a large value +// - on input change: +// - score all the names in relation to input + +impl Component for Picker { + fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { + let key_event = match event { + Event::Key(event) => event, + Event::Resize(..) => return EventResult::Consumed(None), + _ => return EventResult::Ignored, + }; + + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor| { + // remove the layer + compositor.pop(); + }))); + + match key_event { + // KeyEvent { + // code: KeyCode::Char(c), + // modifiers: KeyModifiers::NONE, + // } => { + // self.insert_char(c); + // (self.callback_fn)(cx.editor, &self.line, PromptEvent::Update); + // } + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + } => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('j'), + modifiers: KeyModifiers::CONTROL, + } => self.move_down(), + KeyEvent { + code: KeyCode::Esc, .. + } => { + return close_fn; + } + _ => return self.prompt.handle_event(event, cx), + } + + EventResult::Consumed(None) + } + + fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + let padding_vertical = area.height * 20 / 100; + let padding_horizontal = area.width * 20 / 100; + + let area = Rect::new( + area.x + padding_horizontal, + area.y + padding_vertical, + area.width - padding_horizontal * 2, + area.height - padding_vertical * 2, + ); + + // -- Render the frame: + + // clear area + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + surface.get_mut(x, y).reset() + } + } + + use tui::widgets::Widget; + // 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); + + block.render(area, surface); + // TODO: abstract into a clear(area) fn + // surface.set_style(inner, Style::default().bg(Color::Rgb(150, 50, 0))); + + // -- Render the input bar: + + let area = Rect::new(inner.x + 1, inner.y, inner.width - 1, 1); + self.prompt.render(area, surface, cx); + + // -- Separator + use tui::widgets::BorderType; + let style = Style::default().fg(Color::Rgb(90, 89, 119)); + let symbols = 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); + } + + // -- Render the contents: + + let style = Style::default().fg(Color::Rgb(164, 160, 232)); // lavender + let selected = Style::default().fg(Color::Rgb(255, 255, 255)); + + let rows = inner.height - 2; // -1 for search bar + for (i, file) in self.files.iter().take(rows as usize).enumerate() { + if i == self.cursor { + surface.set_string(inner.x + 1, inner.y + 2 + i as u16, ">", selected); + } + + surface.set_stringn( + inner.x + 3, + inner.y + 2 + i as u16, + file.strip_prefix("./").unwrap().to_str().unwrap(), // TODO: render paths without ./ + inner.width as usize - 1, + if i == self.cursor { selected } else { style }, + ); + } + } + + fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option { + self.prompt.cursor_position(area, ctx) + } +} diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 07c0f9170..58efd5602 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -149,8 +149,13 @@ impl Prompt { } let line = area.height - 1; // render buffer text - surface.set_string(0, line, &self.prompt, text_color); - surface.set_string(self.prompt.len() as u16, line, &self.line, text_color); + surface.set_string(area.x, area.y + line, &self.prompt, text_color); + surface.set_string( + area.x + self.prompt.len() as u16, + area.y + line, + &self.line, + text_color, + ); } } @@ -158,6 +163,7 @@ impl Component for Prompt { fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { let event = match event { Event::Key(event) => event, + Event::Resize(..) => return EventResult::Consumed(None), _ => return EventResult::Ignored, };