Repository: kkawakam/rustyline (rust)

Issue:
Cursor up in multi-line mode doesn't move to the previous line
In most other shells I've used cursor keys work up and down when inside the multi-line string and only switch history on the first and last lines of that input respectively. Is there a configuration that allows that in rustyline?

Pull request description:
Add LineUp/LineDown movement commands
This PR also changes `j/k` and `+/-` in vi mode to use these movements

For the vi command mode, Ctrl+N and Ctrl+P switch history. And Up/Down array keys switch history in insert mode.

The emacs mode is left intact in this PR.

This partially fixes #317

Patch:
diff --git a/src/edit.rs b/src/edit.rs
index 5b264cf..37e334d 100644
--- a/src/edit.rs
+++ b/src/edit.rs
@@ -517,6 +517,24 @@ impl<'out, 'prompt, H: Helper> State<'out, 'prompt, H> {
         }
     }
 
+    /// Moves the cursor to the same column in the line above
+    pub fn edit_move_line_up(&mut self, n: RepeatCount) -> Result<()> {
+        if self.line.move_to_line_up(n) {
+            self.move_cursor()
+        } else {
+            Ok(())
+        }
+    }
+
+    /// Moves the cursor to the same column in the line above
+    pub fn edit_move_line_down(&mut self, n: RepeatCount) -> Result<()> {
+        if self.line.move_to_line_down(n) {
+            self.move_cursor()
+        } else {
+            Ok(())
+        }
+    }
+
     pub fn edit_move_to(&mut self, cs: CharSearch, n: RepeatCount) -> Result<()> {
         if self.line.move_to(cs, n) {
             self.move_cursor()
diff --git a/src/keymap.rs b/src/keymap.rs
index 59b6455..5682195 100644
--- a/src/keymap.rs
+++ b/src/keymap.rs
@@ -242,6 +242,10 @@ pub enum Movement {
     BackwardChar(RepeatCount),
     /// forward-char
     ForwardChar(RepeatCount),
+    /// move to the same column on the previous line
+    LineUp(RepeatCount),
+    /// move to the same column on the next line
+    LineDown(RepeatCount),
 }
 
 impl Movement {
@@ -263,6 +267,8 @@ impl Movement {
             }
             Movement::BackwardChar(previous) => Movement::BackwardChar(repeat_count(previous, new)),
             Movement::ForwardChar(previous) => Movement::ForwardChar(repeat_count(previous, new)),
+            Movement::LineUp(previous) => Movement::LineUp(repeat_count(previous, new)),
+            Movement::LineDown(previous) => Movement::LineDown(repeat_count(previous, new)),
         }
     }
 }
@@ -690,10 +696,12 @@ impl InputState {
             KeyPress::Char(' ') => Cmd::Move(Movement::ForwardChar(n)),
             KeyPress::Ctrl('L') => Cmd::ClearScreen,
             KeyPress::Char('+') |
-            KeyPress::Char('j') | // TODO: move to the start of the line.
+            KeyPress::Char('j') => Cmd::Move(Movement::LineDown(n)),
+            // TODO: move to the start of the line.
             KeyPress::Ctrl('N') => Cmd::NextHistory,
             KeyPress::Char('-') |
-            KeyPress::Char('k') | // TODO: move to the start of the line.
+            KeyPress::Char('k') => Cmd::Move(Movement::LineUp(n)),
+            // TODO: move to the start of the line.
             KeyPress::Ctrl('P') => Cmd::PreviousHistory,
             KeyPress::Ctrl('R') => {
                 self.input_mode = InputMode::Insert; // TODO Validate
@@ -804,6 +812,8 @@ impl InputState {
                 Some(Movement::BackwardChar(n))
             }
             KeyPress::Char('l') | KeyPress::Char(' ') => Some(Movement::ForwardChar(n)),
+            KeyPress::Char('j') | KeyPress::Char('+') => Some(Movement::LineDown(n)),
+            KeyPress::Char('k') | KeyPress::Char('-') => Some(Movement::LineUp(n)),
             KeyPress::Char('w') => {
                 // 'cw' is 'ce'
                 if key == KeyPress::Char('c') {
diff --git a/src/lib.rs b/src/lib.rs
index 8daef67..c2e4c8a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -561,6 +561,12 @@ fn readline_edit<H: Helper>(
                 // move forwards one word
                 s.edit_move_to_next_word(at, word_def, n)?
             }
+            Cmd::Move(Movement::LineUp(n)) => {
+                s.edit_move_line_up(n)?
+            }
+            Cmd::Move(Movement::LineDown(n)) => {
+                s.edit_move_line_down(n)?
+            }
             Cmd::DowncaseWord => {
                 // lowercase word after point
                 s.edit_word(WordAction::LOWERCASE)?
diff --git a/src/line_buffer.rs b/src/line_buffer.rs
index 1536c7b..8163680 100644
--- a/src/line_buffer.rs
+++ b/src/line_buffer.rs
@@ -480,6 +480,106 @@ impl LineBuffer {
         }
     }
 
+    /// Moves the cursor to the same column in the line above
+    pub fn move_to_line_up(&mut self, n: RepeatCount) -> bool {
+        match self.buf[..self.pos].rfind('\n') {
+            Some(off) => {
+                let column = self.buf[off+1..self.pos].graphemes(true).count();
+
+                let mut dest_start = self.buf[..off].rfind('\n').unwrap_or(0);
+                let mut dest_end = off;
+                for _ in 1..n {
+                    if dest_start == 0 { break; }
+                    dest_end = dest_start-1;
+                    dest_start = self.buf[..dest_end].rfind('\n')
+                        .unwrap_or(0);
+                }
+
+                self.pos = self.buf[dest_start..dest_end]
+                    .grapheme_indices(true)
+                    .nth(column)
+                    .map(|(idx, _)| dest_start + idx)
+                    .unwrap_or(off); // if there's no enough columns
+                return true;
+            }
+            None => return false,
+        }
+    }
+
+    /// N lines up starting from the current one
+    ///
+    /// Fails if the cursor is on the first line
+    fn n_lines_up(&self, n: RepeatCount) -> Option<(usize, usize)> {
+        let mut start = if let Some(off) = self.buf[..self.pos].rfind('\n') {
+            off+1
+        } else {
+            return None;
+        };
+        let end = self.buf[self.pos..].find('\n')
+            .map(|x| self.pos + x + 1).unwrap_or(self.buf.len());
+        for _ in 0..n {
+            if let Some(off) = self.buf[..start-1].rfind('\n') {
+                start = off+1
+            } else {
+                start = 0;
+                break;
+            }
+        }
+        return Some((start, end));
+    }
+
+    /// N lines down starting from the current one
+    ///
+    /// Fails if the cursor is on the last line
+    fn n_lines_down(&self, n: RepeatCount) -> Option<(usize, usize)> {
+        let mut end = if let Some(off) = self.buf[self.pos..].find('\n') {
+            self.pos + off + 1
+        } else {
+            return None;
+        };
+        let start = self.buf[..self.pos].rfind('\n').unwrap_or(0);
+        for _ in 0..n {
+            if let Some(off) = self.buf[end..].find('\n') {
+                end = end + off + 1
+            } else {
+                end = self.buf.len();
+                break;
+            };
+        }
+        return Some((start, end));
+    }
+
+    /// Moves the cursor to the same column in the line above
+    pub fn move_to_line_down(&mut self, n: RepeatCount) -> bool {
+        match self.buf[self.pos..].find('\n') {
+            Some(off) => {
+                let line_start = self.buf[..self.pos].rfind('\n')
+                    .unwrap_or(0);
+                let column = self.buf[line_start..self.pos]
+                    .graphemes(true).count();
+                let mut dest_start = self.pos + off+1;
+                let mut dest_end = self.buf[dest_start..].find('\n')
+                    .map(|v| dest_start + v)
+                    .unwrap_or(self.buf.len());
+                for _ in 1..n {
+                    if dest_end == self.buf.len() { break; }
+                    dest_start = dest_end+1;
+                    dest_end = self.buf[dest_start..].find('\n')
+                        .map(|v| dest_start + v)
+                        .unwrap_or(self.buf.len());
+                }
+                self.pos = self.buf[dest_start..dest_end]
+                    .grapheme_indices(true)
+                    .nth(column)
+                    .map(|(idx, _)| dest_start + idx)
+                    .unwrap_or(dest_end); // if there's no enough columns
+                debug_assert!(self.pos <= self.buf.len());
+                return true;
+            }
+            None => return false,
+        }
+    }
+
     fn search_char_pos(&self, cs: CharSearch, n: RepeatCount) -> Option<usize> {
         let mut shift = 0;
         let search_result = match cs {
@@ -785,6 +885,20 @@ impl LineBuffer {
                     None
                 }
             }
+            Movement::LineUp(n) => {
+                if let Some((start, end)) = self.n_lines_up(n) {
+                    Some(self.buf[start..end].to_owned())
+                } else {
+                    None
+                }
+            }
+            Movement::LineDown(n) => {
+                if let Some((start, end)) = self.n_lines_down(n) {
+                    Some(self.buf[start..end].to_owned())
+                } else {
+                    None
+                }
+            }
         }
     }
 
@@ -829,6 +943,22 @@ impl LineBuffer {
                 self.delete_word(at, word_def, n)
             }
             Movement::ViCharSearch(n, cs) => self.delete_to(cs, n),
+            Movement::LineUp(n) => {
+                if let Some((start, end)) = self.n_lines_up(n) {
+                    self.delete_range(start..end);
+                    true
+                } else {
+                    false
+                }
+            }
+            Movement::LineDown(n) => {
+                if let Some((start, end)) = self.n_lines_down(n) {
+                    self.delete_range(start..end);
+                    true
+                } else {
+                    false
+                }
+            }
             Movement::ViFirstPrint => {
                 false // TODO
             }


Test patch:
diff --git a/src/test/vi_cmd.rs b/src/test/vi_cmd.rs
index 1c2c143..c04ef85 100644
--- a/src/test/vi_cmd.rs
+++ b/src/test/vi_cmd.rs
@@ -429,6 +429,69 @@ fn j() {
     for key in &[
         KeyPress::Char('j'),
         KeyPress::Char('+'),
+    ] {
+        assert_cursor(
+            EditMode::Vi,
+            ("Hel", "lo,\nworld!"),
+            // NOTE: escape moves backwards on char
+            &[KeyPress::Esc, *key, KeyPress::Enter],
+            ("Hello,\nwo", "rld!"),
+        );
+        assert_cursor(
+            EditMode::Vi,
+            ("", "One\nTwo\nThree"),
+            &[KeyPress::Esc, KeyPress::Char('2'), *key, KeyPress::Enter],
+            ("One\nTwo\n", "Three"),
+        );
+        assert_cursor(
+            EditMode::Vi,
+            ("Hel", "lo,\nworld!"),
+            // NOTE: escape moves backwards on char
+            &[KeyPress::Esc, KeyPress::Char('7'), *key, KeyPress::Enter],
+            ("Hello,\nwo", "rld!"),
+        );
+    }
+}
+
+#[test]
+fn k() {
+    for key in &[
+        KeyPress::Char('k'),
+        KeyPress::Char('-'),
+    ] {
+        assert_cursor(
+            EditMode::Vi,
+            ("Hello,\nworl", "d!"),
+            // NOTE: escape moves backwards on char
+            &[KeyPress::Esc, *key, KeyPress::Enter],
+            ("Hel", "lo,\nworld!"),
+        );
+        assert_cursor(
+            EditMode::Vi,
+            ("One\nTwo\nT", "hree"),
+            // NOTE: escape moves backwards on char
+            &[KeyPress::Esc, KeyPress::Char('2'), *key, KeyPress::Enter],
+            ("", "One\nTwo\nThree"),
+        );
+        assert_cursor(
+            EditMode::Vi,
+            ("Hello,\nworl", "d!"),
+            // NOTE: escape moves backwards on char
+            &[KeyPress::Esc, KeyPress::Char('5'), *key, KeyPress::Enter],
+            ("Hel", "lo,\nworld!"),
+        );
+        assert_cursor(
+            EditMode::Vi,
+            ("first line\nshort\nlong line", ""),
+            &[KeyPress::Esc, *key, KeyPress::Enter],
+            ("first line\nshort", "\nlong line"),
+        );
+    }
+}
+
+#[test]
+fn ctrl_n() {
+    for key in &[
         KeyPress::Ctrl('N'),
     ] {
         assert_history(
@@ -448,10 +511,8 @@ fn j() {
 }
 
 #[test]
-fn k() {
+fn ctrl_p() {
     for key in &[
-        KeyPress::Char('k'),
-        KeyPress::Char('-'),
         KeyPress::Ctrl('P'),
     ] {
         assert_history(