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.rs566
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)
+ }
+}