From e188a99f2a9858584028ff256e99bf8afb0e23be Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 8 Jul 2022 09:26:51 +0200 Subject: [PATCH] Implement simple single-line editor --- Cargo.lock | 3 +- Cargo.toml | 3 +- src/ui.rs | 1 + src/ui/editor.rs | 161 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/ui/editor.rs diff --git a/Cargo.lock b/Cargo.lock index c110dba..df16c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -208,6 +208,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toss", + "unicode-segmentation", "unicode-width", ] @@ -1245,7 +1246,7 @@ dependencies = [ [[package]] name = "toss" version = "0.1.0" -source = "git+https://github.com/Garmelon/toss.git?rev=26bf89023e254778b9dcb826840f677ed7105292#26bf89023e254778b9dcb826840f677ed7105292" +source = "git+https://github.com/Garmelon/toss.git?rev=d693712dab61d806c3ac36083d27016e67794154#d693712dab61d806c3ac36083d27016e67794154" dependencies = [ "crossterm", "unicode-linebreak", diff --git a/Cargo.toml b/Cargo.toml index b538d8d..392667f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ serde = { version = "1.0.138", features = ["derive"] } serde_json = "1.0.82" thiserror = "1.0.31" tokio = { version = "1.19.2", features = ["full"] } +unicode-segmentation = "1.9.0" unicode-width = "0.1.9" [dependencies.tokio-tungstenite] @@ -29,4 +30,4 @@ features = ["rustls-tls-native-roots"] [dependencies.toss] git = "https://github.com/Garmelon/toss.git" -rev = "26bf89023e254778b9dcb826840f677ed7105292" +rev = "d693712dab61d806c3ac36083d27016e67794154" diff --git a/src/ui.rs b/src/ui.rs index cf73fa4..37cbe49 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,5 @@ mod chat; +mod editor; mod list; mod room; mod rooms; diff --git a/src/ui/editor.rs b/src/ui/editor.rs new file mode 100644 index 0000000..61ad802 --- /dev/null +++ b/src/ui/editor.rs @@ -0,0 +1,161 @@ +use std::iter; +use std::ops::Range; + +use crossterm::style::ContentStyle; +use toss::frame::{Frame, Pos}; +use toss::styled::Styled; +use unicode_segmentation::UnicodeSegmentation; + +pub struct Editor { + text: String, + + /// Index of the cursor in the text. + /// + /// Must point to a valid grapheme boundary. + idx: usize, +} + +impl Editor { + pub fn new() -> Self { + Self { + text: String::new(), + idx: 0, + } + } + + fn grapheme_boundaries(&self) -> Vec { + self.text + .grapheme_indices(true) + .map(|(i, _)| i) + .chain(iter::once(self.text.len())) + .collect() + } + + /// Ensure the cursor index lies on a grapheme boundary. + /// + /// If it doesn't, it is moved to the next grapheme boundary. + fn move_cursor_to_grapheme_boundary(&mut self) { + for i in self.grapheme_boundaries() { + #[allow(clippy::comparison_chain)] + if i == self.idx { + // We're at a valid grapheme boundary already + return; + } else if i > self.idx { + // There was no valid grapheme boundary at our cursor index, so + // we'll take the next one we can get. + self.idx = i; + return; + } + } + + // 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"); + } + + /// Insert a character at the current cursor position and move the cursor + /// accordingly. + pub fn insert_char(&mut self, ch: char) { + self.text.insert(self.idx, ch); + self.idx += 1; + self.move_cursor_to_grapheme_boundary(); + } + + /// Delete the grapheme before the cursor position. + pub fn backspace(&mut self) { + 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; + break; + } + } + } + + /// Delete the grapheme after the cursor position. + pub fn delete(&mut self) { + let boundaries = self.grapheme_boundaries(); + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *start == self.idx { + self.text.replace_range(start..end, ""); + break; + } + } + } + + pub fn move_cursor_left(&mut self) { + let boundaries = self.grapheme_boundaries(); + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *end == self.idx { + self.idx = *start; + break; + } + } + } + + pub fn move_cursor_right(&mut self) { + let boundaries = self.grapheme_boundaries(); + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *start == self.idx { + self.idx = *end; + break; + } + } + } + + fn wrap(&self, frame: &mut Frame, width: usize) -> Vec> { + let mut rows = vec![]; + let mut start = 0; + let mut col = 0; + for (i, g) in self.text.grapheme_indices(true) { + let grapheme_width = if g == "\t" { + frame.tab_width_at_column(col) + } else { + frame.grapheme_width(g) + } as usize; + + if col + grapheme_width > width { + rows.push(start..i); + start = i; + col = grapheme_width; + } else { + col += grapheme_width; + } + } + rows.push(start..self.text.len()); + rows + } + + pub fn render_highlighted(&self, frame: &mut Frame, pos: Pos, width: usize, highlight: F) + where + F: Fn(&str) -> Styled, + { + let text = highlight(&self.text); + let row_ranges = self.wrap(frame, width); + let breakpoints = row_ranges + .iter() + .skip(1) + .map(|r| r.start) + .collect::>(); + let rows = text.split_at_indices(&breakpoints); + for (i, row) in rows.into_iter().enumerate() { + let pos = pos + Pos::new(0, i as i32); + frame.write(pos, row); + } + } + + pub fn render_with_style( + &self, + frame: &mut Frame, + pos: Pos, + width: usize, + style: ContentStyle, + ) { + self.render_highlighted(frame, pos, width, |s| Styled::new((s, style))); + } + + pub fn render(&self, frame: &mut Frame, pos: Pos, width: usize) { + self.render_highlighted(frame, pos, width, |s| Styled::new(s)); + } +}