Unnamed repository; edit this file 'description' to name the repository.
Diffstat (limited to 'helix-view/src/ui/prompt.rs')
| -rw-r--r-- | helix-view/src/ui/prompt.rs | 566 |
1 files changed, 566 insertions, 0 deletions
diff --git a/helix-view/src/ui/prompt.rs b/helix-view/src/ui/prompt.rs new file mode 100644 index 00000000..911ae2ef --- /dev/null +++ b/helix-view/src/ui/prompt.rs @@ -0,0 +1,566 @@ +use crate::compositor::{self, Component, Compositor, Context, Event, EventResult, RenderContext}; +use crate::input::KeyEvent; +use crate::keyboard::KeyCode; +use crate::{alt, ctrl, key, shift, ui}; +use std::{borrow::Cow, ops::RangeFrom}; + +use crate::{ + graphics::{CursorKind, Margin, Rect}, + Editor, +}; +use helix_core::{ + unicode::segmentation::GraphemeCursor, unicode::width::UnicodeWidthStr, Position, +}; + +pub type Completion = (RangeFrom<usize>, Cow<'static, str>); + +pub struct Prompt { + prompt: Cow<'static, str>, + line: String, + cursor: usize, + completion: Vec<Completion>, + selection: Option<usize>, + history_register: Option<char>, + history_pos: Option<usize>, + completion_fn: Box<dyn FnMut(&Editor, &str) -> Vec<Completion>>, + callback_fn: Box<dyn FnMut(&mut Context, &str, PromptEvent)>, + pub doc_fn: Box<dyn Fn(&str) -> Option<Cow<str>>>, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum PromptEvent { + /// The prompt input has been updated. + Update, + /// Validate and finalize the change. + Validate, + /// Abort the change, reverting to the initial state. + Abort, +} + +pub enum CompletionDirection { + Forward, + Backward, +} + +#[derive(Debug, Clone, Copy)] +pub enum Movement { + BackwardChar(usize), + BackwardWord(usize), + ForwardChar(usize), + ForwardWord(usize), + StartOfLine, + EndOfLine, + None, +} + +fn is_word_sep(c: char) -> bool { + c == std::path::MAIN_SEPARATOR || c.is_whitespace() +} + +impl Prompt { + pub fn new( + prompt: Cow<'static, str>, + history_register: Option<char>, + completion_fn: impl FnMut(&Editor, &str) -> Vec<Completion> + 'static, + callback_fn: impl FnMut(&mut Context, &str, PromptEvent) + 'static, + ) -> Self { + Self { + prompt, + line: String::new(), + cursor: 0, + completion: Vec::new(), + selection: None, + history_register, + history_pos: None, + completion_fn: Box::new(completion_fn), + callback_fn: Box::new(callback_fn), + doc_fn: Box::new(|_| None), + } + } + + pub fn line(&self) -> &String { + &self.line + } + + pub fn recalculate_completion(&mut self, editor: &Editor) { + self.completion = (self.completion_fn)(editor, &self.line); + } + + /// Compute the cursor position after applying movement + /// Taken from: https://github.com/wez/wezterm/blob/e0b62d07ca9bf8ce69a61e30a3c20e7abc48ce7e/termwiz/src/lineedit/mod.rs#L516-L611 + fn eval_movement(&self, movement: Movement) -> usize { + match movement { + Movement::BackwardChar(rep) => { + let mut position = self.cursor; + for _ in 0..rep { + let mut cursor = GraphemeCursor::new(position, self.line.len(), false); + if let Ok(Some(pos)) = cursor.prev_boundary(&self.line, 0) { + position = pos; + } else { + break; + } + } + position + } + Movement::BackwardWord(rep) => { + let char_indices: Vec<(usize, char)> = self.line.char_indices().collect(); + if char_indices.is_empty() { + return self.cursor; + } + let mut char_position = char_indices + .iter() + .position(|(idx, _)| *idx == self.cursor) + .unwrap_or(char_indices.len() - 1); + + for _ in 0..rep { + if char_position == 0 { + break; + } + + let mut found = None; + for prev in (0..char_position - 1).rev() { + if is_word_sep(char_indices[prev].1) { + found = Some(prev + 1); + break; + } + } + + char_position = found.unwrap_or(0); + } + char_indices[char_position].0 + } + Movement::ForwardWord(rep) => { + let char_indices: Vec<(usize, char)> = self.line.char_indices().collect(); + if char_indices.is_empty() { + return self.cursor; + } + let mut char_position = char_indices + .iter() + .position(|(idx, _)| *idx == self.cursor) + .unwrap_or(char_indices.len()); + + for _ in 0..rep { + // Skip any non-whitespace characters + while char_position < char_indices.len() + && !is_word_sep(char_indices[char_position].1) + { + char_position += 1; + } + + // Skip any whitespace characters + while char_position < char_indices.len() + && is_word_sep(char_indices[char_position].1) + { + char_position += 1; + } + + // We are now on the start of the next word + } + char_indices + .get(char_position) + .map(|(i, _)| *i) + .unwrap_or_else(|| self.line.len()) + } + Movement::ForwardChar(rep) => { + let mut position = self.cursor; + for _ in 0..rep { + let mut cursor = GraphemeCursor::new(position, self.line.len(), false); + if let Ok(Some(pos)) = cursor.next_boundary(&self.line, 0) { + position = pos; + } else { + break; + } + } + position + } + Movement::StartOfLine => 0, + Movement::EndOfLine => { + let mut cursor = + GraphemeCursor::new(self.line.len().saturating_sub(1), self.line.len(), false); + if let Ok(Some(pos)) = cursor.next_boundary(&self.line, 0) { + pos + } else { + self.cursor + } + } + Movement::None => self.cursor, + } + } + + pub fn insert_char(&mut self, c: char, cx: &Context) { + self.line.insert(self.cursor, c); + let mut cursor = GraphemeCursor::new(self.cursor, self.line.len(), false); + if let Ok(Some(pos)) = cursor.next_boundary(&self.line, 0) { + self.cursor = pos; + } + self.recalculate_completion(cx.editor); + self.exit_selection(); + } + + pub fn insert_str(&mut self, s: &str) { + self.line.insert_str(self.cursor, s); + self.cursor += s.len(); + } + + pub fn move_cursor(&mut self, movement: Movement) { + let pos = self.eval_movement(movement); + self.cursor = pos + } + + pub fn move_start(&mut self) { + self.cursor = 0; + } + + pub fn move_end(&mut self) { + self.cursor = self.line.len(); + } + + pub fn delete_char_backwards(&mut self, cx: &Context) { + let pos = self.eval_movement(Movement::BackwardChar(1)); + self.line.replace_range(pos..self.cursor, ""); + self.cursor = pos; + + self.exit_selection(); + self.recalculate_completion(cx.editor); + } + + pub fn delete_char_forwards(&mut self, cx: &Context) { + let pos = self.eval_movement(Movement::ForwardChar(1)); + self.line.replace_range(self.cursor..pos, ""); + + self.exit_selection(); + self.recalculate_completion(cx.editor); + } + + pub fn delete_word_backwards(&mut self, cx: &Context) { + let pos = self.eval_movement(Movement::BackwardWord(1)); + self.line.replace_range(pos..self.cursor, ""); + self.cursor = pos; + + self.exit_selection(); + self.recalculate_completion(cx.editor); + } + + pub fn delete_word_forwards(&mut self, cx: &Context) { + let pos = self.eval_movement(Movement::ForwardWord(1)); + self.line.replace_range(self.cursor..pos, ""); + + self.exit_selection(); + self.recalculate_completion(cx.editor); + } + + pub fn kill_to_start_of_line(&mut self, cx: &Context) { + let pos = self.eval_movement(Movement::StartOfLine); + self.line.replace_range(pos..self.cursor, ""); + self.cursor = pos; + + self.exit_selection(); + self.recalculate_completion(cx.editor); + } + + pub fn kill_to_end_of_line(&mut self, cx: &Context) { + let pos = self.eval_movement(Movement::EndOfLine); + self.line.replace_range(self.cursor..pos, ""); + + self.exit_selection(); + self.recalculate_completion(cx.editor); + } + + pub fn clear(&mut self, cx: &Context) { + self.line.clear(); + self.cursor = 0; + self.recalculate_completion(cx.editor); + self.exit_selection(); + } + + pub fn change_history(&mut self, register: &[String], direction: CompletionDirection) { + if register.is_empty() { + return; + } + + let end = register.len().saturating_sub(1); + + let index = match direction { + CompletionDirection::Forward => self.history_pos.map_or(0, |i| i + 1), + CompletionDirection::Backward => { + self.history_pos.unwrap_or(register.len()).saturating_sub(1) + } + } + .min(end); + + self.line = register[index].clone(); + + self.history_pos = Some(index); + + self.move_end(); + } + + pub fn change_completion_selection(&mut self, direction: CompletionDirection) { + if self.completion.is_empty() { + return; + } + + let index = match direction { + CompletionDirection::Forward => self.selection.map_or(0, |i| i + 1), + CompletionDirection::Backward => { + self.selection.unwrap_or(0) + self.completion.len() - 1 + } + } % self.completion.len(); + + self.selection = Some(index); + + let (range, item) = &self.completion[index]; + + self.line.replace_range(range.clone(), item); + + self.move_end(); + } + + pub fn exit_selection(&mut self) { + self.selection = None; + } +} + +const BASE_WIDTH: u16 = 30; + +#[cfg(feature = "term")] +impl compositor::term::Render for Prompt { + fn render(&mut self, area: Rect, cx: &mut RenderContext<'_>) { + use tui::widgets::{Block, Borders, Widget}; + + let theme = &cx.editor.theme; + let prompt_color = theme.get("ui.text"); + let completion_color = theme.get("ui.statusline"); + let selected_color = theme.get("ui.menu.selected"); + // completion + + let max_len = self + .completion + .iter() + .map(|(_, completion)| completion.len() as u16) + .max() + .unwrap_or(BASE_WIDTH) + .max(BASE_WIDTH); + + let cols = std::cmp::max(1, area.width / max_len); + let col_width = (area.width.saturating_sub(cols)) / cols; + + let height = ((self.completion.len() as u16 + cols - 1) / cols) + .min(10) // at most 10 rows (or less) + .min(area.height.saturating_sub(1)); + + let completion_area = Rect::new( + area.x, + (area.height - height).saturating_sub(1), + area.width, + height, + ); + + if !self.completion.is_empty() { + let area = completion_area; + let background = theme.get("ui.statusline"); + + let items = height as usize * cols as usize; + + let offset = self + .selection + .map(|selection| selection / items * items) + .unwrap_or_default(); + + cx.surface.clear_with(area, background); + + let mut row = 0; + let mut col = 0; + + for (i, (_range, completion)) in + self.completion.iter().enumerate().skip(offset).take(items) + { + let color = if Some(i) == self.selection { + selected_color // TODO: just invert bg + } else { + completion_color + }; + cx.surface.set_stringn( + area.x + col * (1 + col_width), + area.y + row, + &completion, + col_width.saturating_sub(1) as usize, + color, + ); + row += 1; + if row > area.height - 1 { + row = 0; + col += 1; + } + } + } + + if let Some(doc) = (self.doc_fn)(&self.line) { + let mut text = ui::Text::new(doc.to_string()); + + let max_width = BASE_WIDTH * 3; + let padding = 1; + + let viewport = area; + + let (_width, height) = ui::text::required_size(&text.contents, max_width); + + let area = viewport.intersection(Rect::new( + completion_area.x, + completion_area.y.saturating_sub(height + padding * 2), + max_width, + height + padding * 2, + )); + + let background = theme.get("ui.help"); + cx.surface.clear_with(area, background); + + let block = Block::default() + // .title(self.title.as_str()) + .borders(Borders::ALL) + .border_style(background); + + let inner = block.inner(area).inner(&Margin { + vertical: 0, + horizontal: 1, + }); + + block.render(area, cx.surface); + text.render(inner, cx); + } + + let line = area.height - 1; + // render buffer text + cx.surface + .set_string(area.x, area.y + line, &self.prompt, prompt_color); + cx.surface.set_string( + area.x + self.prompt.len() as u16, + area.y + line, + &self.line, + prompt_color, + ); + } + + fn cursor(&self, area: Rect, _editor: &Editor) -> (Option<Position>, CursorKind) { + let line = area.height as usize - 1; + ( + Some(Position::new( + area.y as usize + line, + area.x as usize + + self.prompt.len() + + UnicodeWidthStr::width(&self.line[..self.cursor]), + )), + CursorKind::Block, + ) + } +} + +#[cfg(feature = "ui")] +impl compositor::ui::Render for Prompt {} + +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(None), + }; + + let close_fn = EventResult::Consumed(Some(Box::new(|compositor: &mut Compositor, _| { + // remove the layer + compositor.pop(); + }))); + + match event { + ctrl!('c') | key!(Esc) => { + (self.callback_fn)(cx, &self.line, PromptEvent::Abort); + return close_fn; + } + alt!('b') | alt!(Left) => self.move_cursor(Movement::BackwardWord(1)), + alt!('f') | alt!(Right) => self.move_cursor(Movement::ForwardWord(1)), + ctrl!('b') | key!(Left) => self.move_cursor(Movement::BackwardChar(1)), + ctrl!('f') | key!(Right) => self.move_cursor(Movement::ForwardChar(1)), + ctrl!('e') | key!(End) => self.move_end(), + ctrl!('a') | key!(Home) => self.move_start(), + ctrl!('w') => self.delete_word_backwards(cx), + alt!('d') => self.delete_word_forwards(cx), + ctrl!('k') => self.kill_to_end_of_line(cx), + ctrl!('u') => self.kill_to_start_of_line(cx), + ctrl!('h') | key!(Backspace) => { + self.delete_char_backwards(cx); + (self.callback_fn)(cx, &self.line, PromptEvent::Update); + } + ctrl!('d') | key!(Delete) => { + self.delete_char_forwards(cx); + (self.callback_fn)(cx, &self.line, PromptEvent::Update); + } + ctrl!('s') => { + let (view, doc) = current!(cx.editor); + let text = doc.text().slice(..); + + use helix_core::textobject; + let range = textobject::textobject_word( + text, + doc.selection(view.id).primary(), + textobject::TextObject::Inside, + 1, + false, + ); + let line = text.slice(range.from()..range.to()).to_string(); + if !line.is_empty() { + self.insert_str(line.as_str()); + (self.callback_fn)(cx, &self.line, PromptEvent::Update); + } + } + key!(Enter) => { + if self.selection.is_some() && self.line.ends_with(std::path::MAIN_SEPARATOR) { + self.recalculate_completion(cx.editor); + self.exit_selection(); + } else { + (self.callback_fn)(cx, &self.line, PromptEvent::Validate); + + if let Some(register) = self.history_register { + // store in history + let register = cx.editor.registers.get_mut(register); + register.push(self.line.clone()); + } + return close_fn; + } + } + ctrl!('p') | key!(Up) => { + if let Some(register) = self.history_register { + let register = cx.editor.registers.get_mut(register); + self.change_history(register.read(), CompletionDirection::Backward); + (self.callback_fn)(cx, &self.line, PromptEvent::Update); + } + } + ctrl!('n') | key!(Down) => { + if let Some(register) = self.history_register { + let register = cx.editor.registers.get_mut(register); + self.change_history(register.read(), CompletionDirection::Forward); + (self.callback_fn)(cx, &self.line, PromptEvent::Update); + } + } + key!(Tab) => { + self.change_completion_selection(CompletionDirection::Forward); + (self.callback_fn)(cx, &self.line, PromptEvent::Update) + } + shift!(Tab) => { + self.change_completion_selection(CompletionDirection::Backward); + (self.callback_fn)(cx, &self.line, PromptEvent::Update) + } + ctrl!('q') => self.exit_selection(), + // any char event that's not mapped to any other combo + KeyEvent { + code: KeyCode::Char(c), + modifiers: _, + } => { + self.insert_char(c, cx); + (self.callback_fn)(cx, &self.line, PromptEvent::Update); + } + _ => (), + }; + + EventResult::Consumed(None) + } +} |