From d4b85ce18d8a9bb535eaeae9e2c7421ef81c81e9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bla=C5=BE=20Hrastnik?= <blaz@mxxn.io>
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 d307456e..dd7778dd 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 3111900d..8570bee1 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();
+                    }
+                    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
+                                );
+                            }
                         }
+
+                        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?
                     }
-                } 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);
-                    }
-                }
-
-                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 b1b92a71..3fee1214 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<Position> {
+    fn cursor_position(&self, area: Rect, ctx: &Editor) -> Option<Position> {
         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<C>) 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<Box<dyn Component>>,
 }
@@ -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 b841dff4..773bc44d 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<Position> {
+    fn cursor_position(&self, area: Rect, editor: &Editor) -> Option<Position> {
         // 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 00000000..7053a179
--- /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<T> {
+    options: Vec<T>,
+
+    cursor: usize,
+
+    position: Position,
+
+    format_fn: Box<dyn Fn(&T) -> Cow<str>>,
+    callback_fn: Box<dyn Fn(&mut Editor, Option<&T>, MenuEvent)>,
+}
+
+impl<T> Menu<T> {
+    // TODO: it's like a slimmed down picker, share code? (picker = menu + prompt with different
+    // rendering)
+    pub fn new(
+        options: Vec<T>,
+        format_fn: impl Fn(&T) -> Cow<str> + '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<T> Component for Menu<T> {
+    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 7c12b918..29483705 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 60828b6f..d8da052a 100644
--- a/helix-term/src/ui/picker.rs
+++ b/helix-term/src/ui/picker.rs
@@ -257,7 +257,7 @@ impl<T> Component for Picker<T> {
         }
     }
 
-    fn cursor_position(&self, area: Rect, ctx: &mut Context) -> Option<Position> {
-        self.prompt.cursor_position(area, ctx)
+    fn cursor_position(&self, area: Rect, editor: &Editor) -> Option<Position> {
+        self.prompt.cursor_position(area, editor)
     }
 }
diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs
index 5a47bf12..7228b38c 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<Position> {
+    fn cursor_position(&self, area: Rect, editor: &Editor) -> Option<Position> {
         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 809ec05d..ad15f6f2 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 24b50d81..02eda72f 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<Self, Error> {