diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs index 41cf96ac..6d311b59 100644 --- a/helix-term/src/ui/menu.rs +++ b/helix-term/src/ui/menu.rs @@ -35,6 +35,7 @@ pub struct Menu { size: (u16, u16), viewport: (u16, u16), recalculate: bool, + auto_close: bool, } impl Menu { @@ -59,6 +60,7 @@ impl Menu { size: (0, 0), viewport: (0, 0), recalculate: true, + auto_close: false, } } @@ -109,6 +111,11 @@ impl Menu { self.adjust_scroll(); } + pub fn auto_close(mut self, auto_close: bool) -> Self { + self.auto_close = auto_close; + self + } + fn recalculate_size(&mut self, viewport: (u16, u16)) { let n = self .options @@ -272,6 +279,10 @@ impl Component for Menu { // enter confirms the match and closes the menu // typing filters the menu // if we run out of options the menu closes itself + _ if self.auto_close => { + (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Abort); + return EventResult::Consumed(close_fn); + } _ => (), } // for some events, we want to process them but send ignore, specifically all input except diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 58b6fc00..416b9595 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -9,6 +9,7 @@ pub mod overlay; pub mod picker; pub mod popup; pub mod prompt; +mod select; mod spinner; mod statusline; mod text; @@ -26,6 +27,7 @@ pub use menu::Menu; pub use picker::{Column as PickerColumn, FileLocation, Picker}; pub use popup::Popup; pub use prompt::{Prompt, PromptEvent}; +pub use select::Select; pub use spinner::{ProgressSpinners, Spinner}; pub use text::Text; diff --git a/helix-term/src/ui/select.rs b/helix-term/src/ui/select.rs new file mode 100644 index 00000000..e85f629e --- /dev/null +++ b/helix-term/src/ui/select.rs @@ -0,0 +1,102 @@ +use std::borrow::Cow; + +use helix_view::{graphics::Rect, Editor}; +use tui::{ + buffer::Buffer as Surface, + widgets::{Block, Widget as _}, +}; + +use crate::compositor::{Component, Context, Event, EventResult}; + +use super::{menu::Item, Menu, PromptEvent, Text}; + +pub struct Select { + message: Text, + options: Menu, +} + +impl Select { + pub fn new(message: M, options: I, data: T::Data, callback: F) -> Self + where + M: Into>, + I: IntoIterator, + F: Fn(&mut Editor, &T, PromptEvent) + 'static, + { + let message = tui::text::Text::from(message.into()).into(); + let options: Vec<_> = options.into_iter().collect(); + assert!(!options.is_empty()); + let mut menu = Menu::new(options, data, move |editor, option, event| { + // Options are non-empty (asserted above) and an option is selected by default, + // so `option` must be Some here. + let option = &option.unwrap(); + callback(editor, option, event) + }) + .auto_close(true); + // Select the first option by default. + menu.move_down(); + + Self { + message, + options: menu, + } + } +} + +impl Component for Select { + fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult { + self.options.handle_event(event, cx) + } + + fn required_size(&mut self, viewport: (u16, u16)) -> Option<(u16, u16)> { + let (message_width, message_height) = self.message.required_size(viewport).unwrap(); + let (menu_width, menu_height) = self.options.required_size(viewport).unwrap(); + Some(( + menu_width.max(message_width + 2), + message_height + menu_height + 2, + )) + } + + fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) { + const BLOCK: Block<'_> = Block::bordered(); + + // +---------------------+ + // | message | + // +---------------------+ + // options menu + // + // + + // Limit the text width to 80% of the screen or 80 columns, whichever is + // smaller. + let max_width = 80.min(((area.width as u32) * 80u32 / 100) as u16); + let (message_width, message_height) = + super::text::required_size(&self.message.contents, max_width); + let (_, menu_height) = self + .options + .required_size((max_width, area.height)) + .unwrap(); + // + 2 for borders and another + 2 for horizontal padding + let width = message_width + 4; + let height = message_height + 2 + menu_height; + let area = Rect { + x: (area.width / 2) - width / 2, + y: (area.height / 2) - height / 2, + width, + height, + }; + + // Message + let background = cx.editor.theme.get("ui.background"); + let text = cx.editor.theme.get("ui.text"); + let message_box = area.with_height(message_height + 2); + surface.clear_with(message_box, background.patch(text)); + BLOCK.render(message_box, surface); + // Add horizontal padding so the message isn't too close to the border. + let message_area = BLOCK.inner(message_box).clip_left(1).clip_right(1); + self.message.render(message_area, surface, cx); + + // Options menu + let menu_area = area.clip_top(message_height + 2); + self.options.render(menu_area, surface, cx); + } +}