From c4d3f5ba4d7f75a5e0620cfb7cae149e7641c16b Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Aug 2022 23:10:56 +0200 Subject: [PATCH] Move cursor in message editor vertically --- src/ui/chat/tree.rs | 12 +-- src/ui/room.rs | 8 +- src/ui/rooms.rs | 10 ++- src/ui/widgets.rs | 1 + src/ui/widgets/editor.rs | 177 +++++++++++++++++++++++++++++++++------ 5 files changed, 170 insertions(+), 38 deletions(-) diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index 23b3012..5ad7040 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -191,12 +191,14 @@ impl> InnerTreeViewState { KeyEvent { code: KeyCode::Enter, .. - } => self.editor.insert_char('\n'), + } => self.editor.insert_char(terminal.frame(), '\n'), - key!(Char ch) => self.editor.insert_char(ch), - key!(Left) => self.editor.move_cursor_left(), - key!(Right) => self.editor.move_cursor_right(), - key!(Backspace) => self.editor.backspace(), + key!(Char ch) => self.editor.insert_char(terminal.frame(), ch), + key!(Left) => self.editor.move_cursor_left(terminal.frame()), + key!(Right) => self.editor.move_cursor_right(terminal.frame()), + key!(Up) => self.editor.move_cursor_up(terminal.frame()), + key!(Down) => self.editor.move_cursor_down(terminal.frame()), + key!(Backspace) => self.editor.backspace(terminal.frame()), key!(Delete) => self.editor.delete(), key!(Ctrl + 'e') => self.editor.edit_externally(terminal, crossterm_lock), key!(Ctrl + 'l') => self.editor.clear(), diff --git a/src/ui/room.rs b/src/ui/room.rs index 6609bbb..d18d03f 100644 --- a/src/ui/room.rs +++ b/src/ui/room.rs @@ -385,10 +385,10 @@ impl EuphRoom { } self.state = State::Normal; } - key!(Char ch) => ed.insert_char(ch), - key!(Backspace) => ed.backspace(), - key!(Left) => ed.move_cursor_left(), - key!(Right) => ed.move_cursor_right(), + key!(Char ch) => ed.insert_char(terminal.frame(), ch), + key!(Backspace) => ed.backspace(terminal.frame()), + key!(Left) => ed.move_cursor_left(terminal.frame()), + key!(Right) => ed.move_cursor_right(terminal.frame()), key!(Delete) => ed.delete(), _ => return false, } diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs index 87e0236..a6365e0 100644 --- a/src/ui/rooms.rs +++ b/src/ui/rooms.rs @@ -311,10 +311,12 @@ impl Rooms { self.state = State::ShowRoom(name); } } - key!(Char ch) if ch.is_ascii_alphanumeric() || ch == '_' => ed.insert_char(ch), - key!(Left) => ed.move_cursor_left(), - key!(Right) => ed.move_cursor_right(), - key!(Backspace) => ed.backspace(), + key!(Char ch) if ch.is_ascii_alphanumeric() || ch == '_' => { + ed.insert_char(terminal.frame(), ch) + } + key!(Left) => ed.move_cursor_left(terminal.frame()), + key!(Right) => ed.move_cursor_right(terminal.frame()), + key!(Backspace) => ed.backspace(terminal.frame()), key!(Delete) => ed.delete(), _ => return false, }, diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 6263233..960cf8e 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -1,5 +1,6 @@ // Since the widget module is effectively a library and will probably be moved // to toss later, warnings about unused functions are mostly inaccurate. +// TODO Restrict this a bit more? #![allow(dead_code)] pub mod background; diff --git a/src/ui/widgets/editor.rs b/src/ui/widgets/editor.rs index 3ba0042..6eadde2 100644 --- a/src/ui/widgets/editor.rs +++ b/src/ui/widgets/editor.rs @@ -34,6 +34,10 @@ struct InnerEditorState { /// Must point to a valid grapheme boundary. idx: usize, + /// Column of the cursor on the screen just after it was last moved + /// horizontally. + col: usize, + /// Width of the text when the editor was last rendered. /// /// Does not include additional column for cursor. @@ -44,11 +48,16 @@ impl InnerEditorState { fn new(text: String) -> Self { Self { idx: text.len(), + col: 0, last_width: 0, text, } } + /////////////////////////////// + // Grapheme helper functions // + /////////////////////////////// + fn grapheme_boundaries(&self) -> Vec { self.text .grapheme_indices(true) @@ -57,9 +66,10 @@ impl InnerEditorState { .collect() } - /// Ensure the cursor index lies on a grapheme boundary. + /// Ensure the cursor index lies on a grapheme boundary. If it doesn't, it + /// is moved to the next grapheme boundary. /// - /// If it doesn't, it is moved to the next grapheme boundary. + /// Can handle arbitrary cursor index. fn move_cursor_to_grapheme_boundary(&mut self) { for i in self.grapheme_boundaries() { #[allow(clippy::comparison_chain)] @@ -74,32 +84,114 @@ impl InnerEditorState { } } - // This loop should always return since the index behind the last - // grapheme is included in the grapheme boundary iterator. - panic!("cursor index out of bounds"); + // The cursor was out of bounds, so move it to the last valid index. + self.idx = self.text.len(); } - fn set_text(&mut self, text: String) { + /////////////////////////////// + // Line/col helper functions // + /////////////////////////////// + + /// Like [`Self::grapheme_boundaries`] but for lines. + /// + /// Note that the last line can have a length of 0 if the text ends with a + /// newline. + fn line_boundaries(&self) -> Vec { + let newlines = self + .text + .char_indices() + .filter(|(_, c)| *c == '\n') + .map(|(i, _)| i + 1); // utf-8 encodes '\n' as a single byte + iter::once(0) + .chain(newlines) + .chain(iter::once(self.text.len())) + .collect() + } + + /// Find the cursor's current line. + /// + /// Returns `(line_nr, start_idx, end_idx)`. + fn cursor_line(&self, boundaries: &[usize]) -> (usize, usize, usize) { + let mut result = (0, 0, 0); + for (i, (start, end)) in boundaries.iter().zip(boundaries.iter().skip(1)).enumerate() { + if self.idx >= *start { + result = (i, *start, *end); + } else { + break; + } + } + result + } + + fn cursor_col(&self, frame: &mut Frame, line_start: usize) -> usize { + frame.width(&self.text[line_start..self.idx]) + } + + fn line(&self, line: usize) -> (usize, usize) { + let boundaries = self.line_boundaries(); + boundaries + .iter() + .copied() + .zip(boundaries.iter().copied().skip(1)) + .nth(line) + .expect("line exists") + } + + fn move_cursor_to_line_col(&mut self, frame: &mut Frame, line: usize, col: usize) { + let (start, end) = self.line(line); + let line = &self.text[start..end]; + + self.idx = start; + let mut width = 0; + for (gi, g) in line.grapheme_indices(true) { + self.idx = start + gi; + if col > width { + width += frame.grapheme_width(g, width) as usize; + } else { + break; + } + } + } + + fn record_cursor_col(&mut self, frame: &mut Frame) { + let boundaries = self.line_boundaries(); + let (_, start, _) = self.cursor_line(&boundaries); + self.col = self.cursor_col(frame, start); + } + + ///////////// + // Editing // + ///////////// + + fn clear(&mut self) { + self.text = String::new(); + self.idx = 0; + self.col = 0; + } + + fn set_text(&mut self, frame: &mut Frame, text: String) { self.text = text; - self.idx = self.idx.min(self.text.len()); self.move_cursor_to_grapheme_boundary(); + self.record_cursor_col(frame); } /// Insert a character at the current cursor position and move the cursor /// accordingly. - fn insert_char(&mut self, ch: char) { + fn insert_char(&mut self, frame: &mut Frame, ch: char) { self.text.insert(self.idx, ch); self.idx += 1; self.move_cursor_to_grapheme_boundary(); + self.record_cursor_col(frame); } /// Delete the grapheme before the cursor position. - fn backspace(&mut self) { + fn backspace(&mut self, frame: &mut Frame) { let boundaries = self.grapheme_boundaries(); for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { if *end == self.idx { self.text.replace_range(start..end, ""); self.idx = *start; + self.record_cursor_col(frame); break; } } @@ -116,25 +208,52 @@ impl InnerEditorState { } } - fn move_cursor_left(&mut self) { + ///////////////////// + // Cursor movement // + ///////////////////// + + fn move_cursor_left(&mut self, frame: &mut Frame) { let boundaries = self.grapheme_boundaries(); for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { if *end == self.idx { self.idx = *start; + self.record_cursor_col(frame); break; } } } - fn move_cursor_right(&mut self) { + fn move_cursor_right(&mut self, frame: &mut Frame) { let boundaries = self.grapheme_boundaries(); for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { if *start == self.idx { self.idx = *end; + self.record_cursor_col(frame); break; } } } + + fn move_cursor_up(&mut self, frame: &mut Frame) { + let boundaries = self.line_boundaries(); + let (line, _, _) = self.cursor_line(&boundaries); + if line > 0 { + self.move_cursor_to_line_col(frame, line - 1, self.col); + } + } + + fn move_cursor_down(&mut self, frame: &mut Frame) { + let boundaries = self.line_boundaries(); + + // There's always at least one line, and always at least two line + // boundaries at 0 and self.text.len(). + let amount_of_lines = boundaries.len() - 1; + + let (line, _, _) = self.cursor_line(&boundaries); + if line + 1 < amount_of_lines { + self.move_cursor_to_line_col(frame, line + 1, self.col); + } + } } pub struct EditorState(Arc>); @@ -163,21 +282,21 @@ impl EditorState { self.0.lock().text.clone() } - pub fn set_text(&self, text: String) { - self.0.lock().set_text(text); - } - pub fn clear(&self) { - self.set_text(String::new()); + self.0.lock().clear(); } - pub fn insert_char(&self, ch: char) { - self.0.lock().insert_char(ch); + pub fn set_text(&self, frame: &mut Frame, text: String) { + self.0.lock().set_text(frame, text); + } + + pub fn insert_char(&self, frame: &mut Frame, ch: char) { + self.0.lock().insert_char(frame, ch); } /// Delete the grapheme before the cursor position. - pub fn backspace(&self) { - self.0.lock().backspace(); + pub fn backspace(&self, frame: &mut Frame) { + self.0.lock().backspace(frame); } /// Delete the grapheme after the cursor position. @@ -185,18 +304,26 @@ impl EditorState { self.0.lock().delete(); } - pub fn move_cursor_left(&self) { - self.0.lock().move_cursor_left(); + pub fn move_cursor_left(&self, frame: &mut Frame) { + self.0.lock().move_cursor_left(frame); } - pub fn move_cursor_right(&self) { - self.0.lock().move_cursor_right(); + pub fn move_cursor_right(&self, frame: &mut Frame) { + self.0.lock().move_cursor_right(frame); + } + + pub fn move_cursor_up(&self, frame: &mut Frame) { + self.0.lock().move_cursor_up(frame); + } + + pub fn move_cursor_down(&self, frame: &mut Frame) { + self.0.lock().move_cursor_down(frame); } pub fn edit_externally(&self, terminal: &mut Terminal, crossterm_lock: &Arc>) { let mut guard = self.0.lock(); if let Some(text) = util::prompt(terminal, crossterm_lock, &guard.text) { - guard.set_text(text); + guard.set_text(terminal.frame(), text); } }