From d4b85ce18d8a9bb535eaeae9e2c7421ef81c81e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Hrastnik?= Date: Tue, 9 Feb 2021 15:40:30 +0900 Subject: [PATCH] popup: wip work on completion popups --- helix-term/src/application.rs | 4 +- helix-term/src/commands.rs | 90 +++++++++++----- helix-term/src/compositor.rs | 42 ++------ helix-term/src/ui/editor.rs | 4 +- helix-term/src/ui/menu.rs | 189 ++++++++++++++++++++++++++++++++++ helix-term/src/ui/mod.rs | 2 + helix-term/src/ui/picker.rs | 4 +- helix-term/src/ui/prompt.rs | 2 +- helix-view/src/theme.rs | 1 + helix-view/src/view.rs | 1 + 10 files changed, 272 insertions(+), 67 deletions(-) create mode 100644 helix-term/src/ui/menu.rs diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index d307456e0..dd7778ddc 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -70,7 +70,7 @@ impl Application { let area = self.terminal.size().unwrap(); compositor.render(area, self.terminal.current_buffer_mut(), &mut cx); - let pos = compositor.cursor_position(area, &mut cx); + let pos = compositor.cursor_position(area, &editor); self.terminal.draw(); self.terminal.set_cursor(pos.col as u16, pos.row as u16); @@ -112,7 +112,7 @@ impl Application { .handle_event(Event::Resize(width, height), &mut cx) } Some(Ok(event)) => self.compositor.handle_event(event, &mut cx), - Some(Err(x)) => panic!(x), + Some(Err(x)) => panic!("{}", x), None => panic!(), }; diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs index 3111900d9..8570bee16 100644 --- a/helix-term/src/commands.rs +++ b/helix-term/src/commands.rs @@ -910,7 +910,8 @@ pub fn completion(cx: &mut Context) { // TODO: if no completion, show some message or something if !res.is_empty() { - let picker = ui::Picker::new( + let snapshot = cx.doc().state.clone(); + let mut menu = ui::Menu::new( res, |item| { // format_fn @@ -918,40 +919,75 @@ pub fn completion(cx: &mut Context) { // TODO: use item.filter_text for filtering }, - |editor: &mut Editor, item| { - use helix_lsp::{lsp, util}; - // determine what to insert: text_edit | insert_text | label - let edit = if let Some(edit) = &item.text_edit { - match edit { - lsp::CompletionTextEdit::Edit(edit) => edit.clone(), - lsp::CompletionTextEdit::InsertAndReplace(item) => { - unimplemented!("completion: insert_and_replace {:?}", item) - } + move |editor: &mut Editor, item, event| { + match event { + PromptEvent::Abort => { + // revert state + let doc = &mut editor.view_mut().doc; + doc.state = snapshot.clone(); } - } else { - item.insert_text.as_ref().unwrap_or(&item.label); - unimplemented!(); - // lsp::TextEdit::new(); TODO: calculate a TextEdit from insert_text - // and we insert at position. - }; + PromptEvent::Validate => { + let doc = &mut editor.view_mut().doc; + + // revert state to what it was before the last update + doc.state = snapshot.clone(); + + // extract as fn(doc, item): + + // TODO: need to apply without composing state... + // TODO: need to update lsp on accept/cancel by diffing the snapshot with + // the final state? + // -> on update simply update the snapshot, then on accept redo the call, + // finally updating doc.changes + notifying lsp. + // + // or we could simply use doc.undo + apply when changing between options + + let item = item.unwrap(); + + use helix_lsp::{lsp, util}; + // determine what to insert: text_edit | insert_text | label + let edit = if let Some(edit) = &item.text_edit { + match edit { + lsp::CompletionTextEdit::Edit(edit) => edit.clone(), + lsp::CompletionTextEdit::InsertAndReplace(item) => { + unimplemented!("completion: insert_and_replace {:?}", item) + } + } + } else { + item.insert_text.as_ref().unwrap_or(&item.label); + unimplemented!(); + // lsp::TextEdit::new(); TODO: calculate a TextEdit from insert_text + // and we insert at position. + }; + + // TODO: merge edit with additional_text_edits + if let Some(additional_edits) = &item.additional_text_edits { + if !additional_edits.is_empty() { + unimplemented!( + "completion: additional_text_edits: {:?}", + additional_edits + ); + } + } - // TODO: merge edit with additional_text_edits - if let Some(additional_edits) = &item.additional_text_edits { - if !additional_edits.is_empty() { - unimplemented!("completion: additional_text_edits: {:?}", additional_edits); + let transaction = + util::generate_transaction_from_edits(&doc.state, vec![edit]); + doc.apply(&transaction); + // TODO: append_changes_to_history(cx); if not in insert mode? } - } - - let doc = &mut editor.view_mut().doc; - let transaction = util::generate_transaction_from_edits(&doc.state, vec![edit]); - doc.apply(&transaction); - // TODO: append_changes_to_history(cx); if not in insert mode? + _ => (), + }; }, ); cx.callback = Some(Box::new( move |compositor: &mut Compositor, editor: &mut Editor| { - compositor.push(Box::new(picker)); + let area = tui::layout::Rect::default(); // TODO: unused remove from cursor_position + let mut pos = compositor.cursor_position(area, editor); + pos.row += 1; // shift down by one row + menu.set_position(pos); + + compositor.push(Box::new(menu)); }, )); diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs index b1b92a717..3fee12146 100644 --- a/helix-term/src/compositor.rs +++ b/helix-term/src/compositor.rs @@ -54,13 +54,11 @@ pub trait Component { fn render(&self, area: Rect, frame: &mut Surface, ctx: &mut Context); - fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option { + fn cursor_position(&self, area: Rect, ctx: &Editor) -> Option { None } } -// struct Editor { }; - // For v1: // Child views are something each view needs to handle on it's own for now, positioning and sizing // options, focus tracking. In practice this is simple: we only will need special solving for @@ -83,29 +81,6 @@ pub trait Component { // - a popup panel / dialog with it's own interactions // - an autocomplete popup that doesn't change focus -//fn main() { -// let root = Editor::new(); -// let compositor = Compositor::new(); - -// compositor.push(root); - -// // pos: clip to bottom of screen -// compositor.push_at(pos, Prompt::new( -// ":", -// (), -// |input: &str| match input {} -// )); // TODO: this Prompt needs to somehow call compositor.pop() on close, but it can't refer to parent -// // Cursive solves this by allowing to return a special result on process_event -// // that's either Ignore | Consumed(Opt) where C: fn (Compositor) -> () - -// // TODO: solve popup focus: we want to push autocomplete popups on top of the current layer -// // but retain the focus where it was. The popup will also need to update as we type into the -// // textarea. It should also capture certain input, such as tab presses etc -// // -// // 1) This could be faked by the top layer pushing down edits into the previous layer. -// // 2) Alternatively, -//} - pub struct Compositor { layers: Vec>, } @@ -124,14 +99,15 @@ impl Compositor { } pub fn handle_event(&mut self, event: Event, cx: &mut Context) -> bool { - // TODO: custom focus - if let Some(layer) = self.layers.last_mut() { - return match layer.handle_event(event, cx) { + // propagate events through the layers until we either find a layer that consumes it or we + // run out of layers (event bubbling) + for layer in self.layers.iter_mut().rev() { + match layer.handle_event(event, cx) { EventResult::Consumed(Some(callback)) => { callback(self, cx.editor); - true + return true; } - EventResult::Consumed(None) => true, + EventResult::Consumed(None) => return true, EventResult::Ignored => false, }; } @@ -144,9 +120,9 @@ impl Compositor { } } - pub fn cursor_position(&self, area: Rect, cx: &mut Context) -> Position { + pub fn cursor_position(&self, area: Rect, editor: &Editor) -> Position { for layer in self.layers.iter().rev() { - if let Some(pos) = layer.cursor_position(area, cx) { + if let Some(pos) = layer.cursor_position(area, editor) { return pos; } } diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs index b841dff49..773bc44d2 100644 --- a/helix-term/src/ui/editor.rs +++ b/helix-term/src/ui/editor.rs @@ -365,12 +365,12 @@ impl Component for EditorView { // TODO: drop unwrap } - fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option { + fn cursor_position(&self, area: Rect, editor: &Editor) -> Option { // match view.doc.mode() { // Mode::Insert => write!(stdout, "\x1B[6 q"), // mode => write!(stdout, "\x1B[2 q"), // }; - let view = ctx.editor.view(); + let view = editor.view(); let cursor = view.doc.state.selection().cursor(); let mut pos = view diff --git a/helix-term/src/ui/menu.rs b/helix-term/src/ui/menu.rs new file mode 100644 index 000000000..7053a1796 --- /dev/null +++ b/helix-term/src/ui/menu.rs @@ -0,0 +1,189 @@ +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 std::borrow::Cow; + +use helix_core::Position; +use helix_view::Editor; + +// TODO: factor out a popup component that we can reuse for displaying docs on autocomplete, +// diagnostics popups, etc. + +pub struct Menu { + options: Vec, + + cursor: usize, + + position: Position, + + format_fn: Box Cow>, + callback_fn: Box, MenuEvent)>, +} + +impl Menu { + // TODO: it's like a slimmed down picker, share code? (picker = menu + prompt with different + // rendering) + pub fn new( + options: Vec, + format_fn: impl Fn(&T) -> Cow + 'static, + callback_fn: impl Fn(&mut Editor, Option<&T>, MenuEvent) + 'static, + ) -> Self { + Self { + options, + cursor: 0, + position: Position::default(), + format_fn: Box::new(format_fn), + callback_fn: Box::new(callback_fn), + } + } + + pub fn set_position(&mut self, pos: Position) { + self.position = pos; + } + + 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.options.len() { + self.cursor += 1; + } + } + + pub fn selection(&self) -> Option<&T> { + self.options.get(self.cursor) + } +} + +use super::PromptEvent as MenuEvent; + +impl Component for Menu { + fn handle_event(&mut self, event: Event, cx: &mut Context) -> EventResult { + let event = match event { + Event::Key(event) => event, + _ => return EventResult::Ignored, + }; + + let close_fn = EventResult::Consumed(Some(Box::new( + |compositor: &mut Compositor, editor: &mut Editor| { + // remove the layer + compositor.pop(); + }, + ))); + + match event { + // esc or ctrl-c aborts the completion and closes the menu + KeyEvent { + code: KeyCode::Esc, .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + } => { + (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Abort); + return close_fn; + } + // arrow up/ctrl-p/shift-tab prev completion choice (including updating the doc) + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::SHIFT, + } + | KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + } => { + self.move_up(); + (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); + return EventResult::Consumed(None); + } + // arrow down/ctrl-n/tab advances completion choice (including updating the doc) + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + } + | KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + } => { + self.move_down(); + (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Update); + return EventResult::Consumed(None); + } + KeyEvent { + code: KeyCode::Enter, + .. + } => { + (self.callback_fn)(cx.editor, self.selection(), MenuEvent::Validate); + return close_fn; + } + // KeyEvent { + // code: KeyCode::Char(c), + // modifiers: KeyModifiers::NONE, + // } => { + // self.insert_char(c); + // (self.callback_fn)(cx.editor, &self.line, MenuEvent::Update); + // } + + // / -> edit_filter? + // + // enter confirms the match and closes the menu + // typing filters the menu + // if we run out of options the menu closes itself + _ => (), + } + // for some events, we want to process them but send ignore, specifically all input except + // tab/enter/ctrl-k or whatever will confirm the selection/ ctrl-n/ctrl-p for scroll. + // EventResult::Consumed(None) + EventResult::Ignored + } + fn render(&self, area: Rect, surface: &mut Surface, cx: &mut Context) { + // render a box at x, y. Width equal to max width of item. + // initially limit to n items, add support for scrolling + // + const MAX: usize = 5; + let rows = std::cmp::min(self.options.len(), MAX) as u16; + let area = Rect::new(self.position.col as u16, self.position.row as u16, 30, rows); + + // clear area + let background = cx.editor.theme.get("ui.popup"); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + let cell = surface.get_mut(x, y); + cell.reset(); + // cell.symbol.clear(); + cell.set_style(background); + } + } + + // -- Render the contents: + + let style = Style::default().fg(Color::Rgb(164, 160, 232)); // lavender + let selected = Style::default().fg(Color::Rgb(255, 255, 255)); + + for (i, option) in self.options.iter().take(rows as usize).enumerate() { + // TODO: set bg for the whole row if selected + surface.set_stringn( + area.x, + area.y + i as u16, + (self.format_fn)(option), + area.width as usize - 1, + if i == self.cursor { selected } else { style }, + ); + } + } +} diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs index 7c12b9182..29483705d 100644 --- a/helix-term/src/ui/mod.rs +++ b/helix-term/src/ui/mod.rs @@ -1,8 +1,10 @@ mod editor; +mod menu; mod picker; mod prompt; pub use editor::EditorView; +pub use menu::Menu; pub use picker::Picker; pub use prompt::{Prompt, PromptEvent}; diff --git a/helix-term/src/ui/picker.rs b/helix-term/src/ui/picker.rs index 60828b6f9..d8da052ad 100644 --- a/helix-term/src/ui/picker.rs +++ b/helix-term/src/ui/picker.rs @@ -257,7 +257,7 @@ impl Component for Picker { } } - fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option { - self.prompt.cursor_position(area, ctx) + fn cursor_position(&self, area: Rect, editor: &Editor) -> Option { + self.prompt.cursor_position(area, editor) } } diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs index 5a47bf128..7228b38cf 100644 --- a/helix-term/src/ui/prompt.rs +++ b/helix-term/src/ui/prompt.rs @@ -235,7 +235,7 @@ impl Component for Prompt { self.render_prompt(area, surface, &cx.editor.theme) } - fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option { + fn cursor_position(&self, area: Rect, editor: &Editor) -> Option { Some(Position::new( area.height as usize, area.x as usize + self.prompt.len() + self.cursor, diff --git a/helix-view/src/theme.rs b/helix-view/src/theme.rs index 809ec05d4..ad15f6f27 100644 --- a/helix-view/src/theme.rs +++ b/helix-view/src/theme.rs @@ -157,6 +157,7 @@ impl Default for Theme { "ui.background" => Style::default().bg(Color::Rgb(59, 34, 76)), // midnight "ui.linenr" => Style::default().fg(Color::Rgb(90, 89, 119)), // comet "ui.statusline" => Style::default().bg(Color::Rgb(40, 23, 51)), // revolver + "ui.popup" => Style::default().bg(Color::Rgb(40, 23, 51)), // revolver "warning" => Style::default().fg(Color::Rgb(255, 205, 28)), }; diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs index 24b50d81f..02eda72fa 100644 --- a/helix-view/src/view.rs +++ b/helix-view/src/view.rs @@ -19,6 +19,7 @@ pub struct View { pub first_line: usize, pub area: Rect, } +// TODO: popups should be a thing on the view with a rect + text impl View { pub fn new(doc: Document) -> Result {