From 8b66de44e0649a5b2603dce15a1929cc81b16371 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Aug 2022 02:02:55 +0200 Subject: [PATCH 001/443] Increase delay between log requests --- CHANGELOG.md | 3 +++ src/euph/room.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b18c83..7ee6c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Changed +- Slowed down room history download speed + ## v0.1.0 - 2022-08-06 Initial release diff --git a/src/euph/room.rs b/src/euph/room.rs index 70afdae..3faebaf 100644 --- a/src/euph/room.rs +++ b/src/euph/room.rs @@ -144,7 +144,7 @@ impl State { async fn regularly_request_logs(event_tx: &mpsc::UnboundedSender) { loop { - tokio::time::sleep(Duration::from_secs(2)).await; // TODO Make configurable + tokio::time::sleep(Duration::from_secs(10)).await; // TODO Make configurable let _ = event_tx.send(Event::RequestLogs); } } From c4d3f5ba4d7f75a5e0620cfb7cae149e7641c16b Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Aug 2022 23:10:56 +0200 Subject: [PATCH 002/443] 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); } } From f48a4a64166eea5fec015a280863c1b13d7f95f6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Aug 2022 23:13:21 +0200 Subject: [PATCH 003/443] Remove trailing newline of externally edited text --- src/ui/widgets/editor.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/widgets/editor.rs b/src/ui/widgets/editor.rs index 6eadde2..03eb8a4 100644 --- a/src/ui/widgets/editor.rs +++ b/src/ui/widgets/editor.rs @@ -323,7 +323,11 @@ impl EditorState { 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(terminal.frame(), text); + if let Some(text) = text.strip_suffix('\n') { + guard.set_text(terminal.frame(), text.to_string()); + } else { + guard.set_text(terminal.frame(), text); + } } } From bfbdec4396905124d8475582ea411105da1dd476 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Aug 2022 23:38:47 +0200 Subject: [PATCH 004/443] Move editor key handling to one place --- src/ui/chat/tree.rs | 39 +++++++++++--------------- src/ui/input.rs | 5 +++- src/ui/room.rs | 47 ++++++++++++++++++-------------- src/ui/rooms.rs | 26 ++++++++++-------- src/ui/util.rs | 59 ++++++++++++++++++++++++++++++++++++++++ src/ui/widgets/editor.rs | 3 -- 6 files changed, 120 insertions(+), 59 deletions(-) diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index 5ad7040..34d18a5 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -14,6 +14,7 @@ use toss::terminal::Terminal; use crate::store::{Msg, MsgStore}; use crate::ui::input::{key, KeyBindingsList, KeyEvent}; +use crate::ui::util; use crate::ui::widgets::editor::EditorState; use crate::ui::widgets::Widget; @@ -150,14 +151,10 @@ impl> InnerTreeViewState { } } - pub fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) { + fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.binding("esc", "close editor"); bindings.binding("enter", "send message"); - bindings.binding("←/→", "move cursor left/right"); - bindings.binding("backspace", "delete before cursor"); - bindings.binding("delete", "delete after cursor"); - bindings.binding("ctrl+e", "edit in $EDITOR"); - bindings.binding("ctrl+l", "clear editor contents"); + util::list_editor_key_bindings(bindings, |_| true, true); } fn handle_editor_key_event( @@ -186,23 +183,19 @@ impl> InnerTreeViewState { } } - // Enter with *any* modifier pressed - if ctrl and shift don't - // work, maybe alt does - KeyEvent { - code: KeyCode::Enter, - .. - } => self.editor.insert_char(terminal.frame(), '\n'), - - 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(), - _ => return Reaction::NotHandled, + _ => { + let handled = util::handle_editor_key_event( + &self.editor, + terminal, + crossterm_lock, + event, + |_| true, + true, + ); + if !handled { + return Reaction::NotHandled; + } + } } self.correction = Some(Correction::MakeCursorVisible); diff --git a/src/ui/input.rs b/src/ui/input.rs index b6cda4d..bcd366f 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -107,7 +107,10 @@ impl KeyBindingsList { pub fn binding(&mut self, binding: &str, description: &str) { let widget = HJoin::new(vec![ - Segment::new(Resize::new(Text::new((binding, Self::binding_style()))).min_width(16)), + Segment::new( + Resize::new(Padding::new(Text::new((binding, Self::binding_style()))).right(1)) + .min_width(16), + ), Segment::new(Text::new(description)), ]); self.0.add_unsel(widget); diff --git a/src/ui/room.rs b/src/ui/room.rs index d18d03f..72bdd0d 100644 --- a/src/ui/room.rs +++ b/src/ui/room.rs @@ -26,7 +26,7 @@ use super::widgets::list::{List, ListState}; use super::widgets::padding::Padding; use super::widgets::text::Text; use super::widgets::BoxedWidget; -use super::UiEvent; +use super::{util, UiEvent}; enum State { Normal, @@ -302,6 +302,10 @@ impl EuphRoom { list.into() } + fn nick_char(c: char) -> bool { + c != '\n' + } + pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.heading("Room"); @@ -326,9 +330,7 @@ impl EuphRoom { State::ChooseNick(_) => { bindings.binding("esc", "abort"); bindings.binding("enter", "set nick"); - bindings.binding("←/→", "move cursor left/right"); - bindings.binding("backspace", "delete before cursor"); - bindings.binding("delete", "delete after cursor"); + util::list_editor_key_bindings(bindings, Self::nick_char, false); } } } @@ -376,24 +378,27 @@ impl EuphRoom { .await .handled() } - State::ChooseNick(ed) => { - match event { - key!(Esc) => self.state = State::Normal, - key!(Enter) => { - if let Some(room) = &self.room { - let _ = room.nick(ed.text()); - } - self.state = State::Normal; - } - 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, + State::ChooseNick(ed) => match event { + key!(Esc) => { + self.state = State::Normal; + true } - true - } + key!(Enter) => { + if let Some(room) = &self.room { + let _ = room.nick(ed.text()); + } + self.state = State::Normal; + true + } + _ => util::handle_editor_key_event( + ed, + terminal, + crossterm_lock, + event, + Self::nick_char, + false, + ), + }, } } } diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs index a6365e0..e73d625 100644 --- a/src/ui/rooms.rs +++ b/src/ui/rooms.rs @@ -25,7 +25,7 @@ use super::widgets::list::{List, ListState}; use super::widgets::padding::Padding; use super::widgets::text::Text; use super::widgets::BoxedWidget; -use super::UiEvent; +use super::{util, UiEvent}; enum State { ShowList, @@ -206,6 +206,10 @@ impl Rooms { list.into() } + fn room_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' + } + pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { match &self.state { State::ShowList => { @@ -239,9 +243,7 @@ impl Rooms { bindings.heading("Rooms"); bindings.binding("esc", "abort"); bindings.binding("enter", "connect to room"); - bindings.binding("←/→", "move cursor left/right"); - bindings.binding("backspace", "delete before cursor"); - bindings.binding("delete", "delete after cursor"); + util::list_editor_key_bindings(bindings, Self::room_char, false); } } } @@ -311,14 +313,16 @@ impl Rooms { self.state = State::ShowRoom(name); } } - key!(Char ch) if ch.is_ascii_alphanumeric() || ch == '_' => { - ed.insert_char(terminal.frame(), ch) + _ => { + return util::handle_editor_key_event( + ed, + terminal, + crossterm_lock, + event, + Self::room_char, + false, + ) } - 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/util.rs b/src/ui/util.rs index 7507ed3..2449459 100644 --- a/src/ui/util.rs +++ b/src/ui/util.rs @@ -1,8 +1,12 @@ use std::sync::Arc; +use crossterm::event::KeyCode; use parking_lot::FairMutex; use toss::terminal::Terminal; +use super::input::{key, KeyBindingsList, KeyEvent}; +use super::widgets::editor::EditorState; + pub fn prompt( terminal: &mut Terminal, crossterm_lock: &Arc>, @@ -25,3 +29,58 @@ pub fn prompt( Some(content) } } + +// TODO Support more of the emacs-y bindings, see bash as example + +pub fn list_editor_key_bindings( + bindings: &mut KeyBindingsList, + char_filter: impl Fn(char) -> bool, + can_edit_externally: bool, +) { + if char_filter('\n') { + bindings.binding("enter+", "insert newline"); + } + bindings.binding("backspace", "delete before cursor"); + bindings.binding("delete", "delete after cursor"); + bindings.binding("ctrl+l", "clear editor contents"); + if can_edit_externally { + bindings.binding("ctrl+e", "edit in $EDITOR"); + } + bindings.binding("arrow keys", "move cursor"); +} + +pub fn handle_editor_key_event( + editor: &EditorState, + terminal: &mut Terminal, + crossterm_lock: &Arc>, + event: KeyEvent, + char_filter: impl Fn(char) -> bool, + can_edit_externally: bool, +) -> bool { + match event { + // Enter with *any* modifier pressed - if ctrl and shift don't + // work, maybe alt does + key!(Enter) => return false, + KeyEvent { + code: KeyCode::Enter, + .. + } if char_filter('\n') => editor.insert_char(terminal.frame(), '\n'), + + // Editing + key!(Char ch) if char_filter(ch) => editor.insert_char(terminal.frame(), ch), + key!(Backspace) => editor.backspace(terminal.frame()), + key!(Delete) => editor.delete(), + key!(Ctrl + 'l') => editor.clear(), + key!(Ctrl + 'e') if can_edit_externally => editor.edit_externally(terminal, crossterm_lock), + + // Cursor movement + key!(Left) => editor.move_cursor_left(terminal.frame()), + key!(Right) => editor.move_cursor_right(terminal.frame()), + key!(Up) => editor.move_cursor_up(terminal.frame()), + key!(Down) => editor.move_cursor_down(terminal.frame()), + + _ => return false, + } + + true +} diff --git a/src/ui/widgets/editor.rs b/src/ui/widgets/editor.rs index 03eb8a4..53db6ec 100644 --- a/src/ui/widgets/editor.rs +++ b/src/ui/widgets/editor.rs @@ -330,9 +330,6 @@ impl EditorState { } } } - - // TODO Share key binding code - // TODO Support more of the emacs-y bindings, see bash as example } //////////// From 0d3131facde6c77e8277fa8a45eac022e690e264 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Aug 2022 23:54:22 +0200 Subject: [PATCH 005/443] Add more readline-like key bindings --- src/ui/util.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/ui/util.rs b/src/ui/util.rs index 2449459..d4fde12 100644 --- a/src/ui/util.rs +++ b/src/ui/util.rs @@ -40,13 +40,21 @@ pub fn list_editor_key_bindings( if char_filter('\n') { bindings.binding("enter+", "insert newline"); } - bindings.binding("backspace", "delete before cursor"); - bindings.binding("delete", "delete after cursor"); + + // Editing + bindings.binding("ctrl+h, backspace", "delete before cursor"); + bindings.binding("ctrl+d, delete", "delete after cursor"); bindings.binding("ctrl+l", "clear editor contents"); if can_edit_externally { bindings.binding("ctrl+e", "edit in $EDITOR"); } - bindings.binding("arrow keys", "move cursor"); + + bindings.empty(); + + // Cursor movement + bindings.binding("ctrl+b, ←", "move cursor left"); + bindings.binding("ctrl+f, →", "move cursor right"); + bindings.binding("↑/↓", "move cursor up/down"); } pub fn handle_editor_key_event( @@ -68,14 +76,14 @@ pub fn handle_editor_key_event( // Editing key!(Char ch) if char_filter(ch) => editor.insert_char(terminal.frame(), ch), - key!(Backspace) => editor.backspace(terminal.frame()), - key!(Delete) => editor.delete(), + key!(Ctrl + 'h') | key!(Backspace) => editor.backspace(terminal.frame()), + key!(Ctrl + 'd') | key!(Delete) => editor.delete(), key!(Ctrl + 'l') => editor.clear(), - key!(Ctrl + 'e') if can_edit_externally => editor.edit_externally(terminal, crossterm_lock), + key!(Ctrl + 'e') if can_edit_externally => editor.edit_externally(terminal, crossterm_lock), // TODO Change to some other binding // Cursor movement - key!(Left) => editor.move_cursor_left(terminal.frame()), - key!(Right) => editor.move_cursor_right(terminal.frame()), + key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.frame()), + key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.frame()), key!(Up) => editor.move_cursor_up(terminal.frame()), key!(Down) => editor.move_cursor_down(terminal.frame()), From ba35a606a80d0356dc55474fcc8c0a21774ed6a6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Aug 2022 23:54:43 +0200 Subject: [PATCH 006/443] Increase F1 key binding column width --- src/ui/input.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ui/input.rs b/src/ui/input.rs index bcd366f..fbcf8ad 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -67,6 +67,9 @@ pub(crate) use key; pub struct KeyBindingsList(List); impl KeyBindingsList { + /// Width of the left column of key bindings. + const BINDING_WIDTH: u16 = 20; + pub fn new(state: &ListState) -> Self { Self(state.widget()) } @@ -109,7 +112,7 @@ impl KeyBindingsList { let widget = HJoin::new(vec![ Segment::new( Resize::new(Padding::new(Text::new((binding, Self::binding_style()))).right(1)) - .min_width(16), + .min_width(Self::BINDING_WIDTH), ), Segment::new(Text::new(description)), ]); @@ -118,7 +121,7 @@ impl KeyBindingsList { pub fn binding_ctd(&mut self, description: &str) { let widget = HJoin::new(vec![ - Segment::new(Resize::new(Empty::new()).min_width(16)), + Segment::new(Resize::new(Empty::new()).min_width(Self::BINDING_WIDTH)), Segment::new(Text::new(description)), ]); self.0.add_unsel(widget); From 4bf6d8098885d86c4a8f437e43e4aaffce0004c8 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sat, 6 Aug 2022 23:54:53 +0200 Subject: [PATCH 007/443] Move to start/end of editor line --- src/ui/util.rs | 4 ++++ src/ui/widgets/editor.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/ui/util.rs b/src/ui/util.rs index d4fde12..67e258f 100644 --- a/src/ui/util.rs +++ b/src/ui/util.rs @@ -54,6 +54,8 @@ pub fn list_editor_key_bindings( // Cursor movement bindings.binding("ctrl+b, ←", "move cursor left"); bindings.binding("ctrl+f, →", "move cursor right"); + bindings.binding("ctrl+a, home", "move cursor to start of line"); + bindings.binding("ctrl+e, end", "move cursor to end of line"); bindings.binding("↑/↓", "move cursor up/down"); } @@ -84,6 +86,8 @@ pub fn handle_editor_key_event( // Cursor movement key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.frame()), key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.frame()), + key!(Ctrl + 'a') | key!(Home) => editor.move_cursor_to_start_of_line(terminal.frame()), + key!(Ctrl + 'e') | key!(End) => editor.move_cursor_to_end_of_line(terminal.frame()), key!(Up) => editor.move_cursor_up(terminal.frame()), key!(Down) => editor.move_cursor_down(terminal.frame()), diff --git a/src/ui/widgets/editor.rs b/src/ui/widgets/editor.rs index 53db6ec..e8a3d6e 100644 --- a/src/ui/widgets/editor.rs +++ b/src/ui/widgets/editor.rs @@ -234,6 +234,20 @@ impl InnerEditorState { } } + fn move_cursor_to_start_of_line(&mut self, frame: &mut Frame) { + let boundaries = self.line_boundaries(); + let (line, _, _) = self.cursor_line(&boundaries); + self.move_cursor_to_line_col(frame, line, 0); + self.record_cursor_col(frame); + } + + fn move_cursor_to_end_of_line(&mut self, frame: &mut Frame) { + let boundaries = self.line_boundaries(); + let (line, _, _) = self.cursor_line(&boundaries); + self.move_cursor_to_line_col(frame, line, usize::MAX); + self.record_cursor_col(frame); + } + fn move_cursor_up(&mut self, frame: &mut Frame) { let boundaries = self.line_boundaries(); let (line, _, _) = self.cursor_line(&boundaries); @@ -312,6 +326,14 @@ impl EditorState { self.0.lock().move_cursor_right(frame); } + pub fn move_cursor_to_start_of_line(&self, frame: &mut Frame) { + self.0.lock().move_cursor_to_start_of_line(frame); + } + + pub fn move_cursor_to_end_of_line(&self, frame: &mut Frame) { + self.0.lock().move_cursor_to_end_of_line(frame); + } + pub fn move_cursor_up(&self, frame: &mut Frame) { self.0.lock().move_cursor_up(frame); } From 51d03c6fe2e169c5af6cff38a1172770f6f65085 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Aug 2022 00:01:27 +0200 Subject: [PATCH 008/443] Fix moving to end of last line --- src/ui/widgets/editor.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ui/widgets/editor.rs b/src/ui/widgets/editor.rs index e8a3d6e..20f4a38 100644 --- a/src/ui/widgets/editor.rs +++ b/src/ui/widgets/editor.rs @@ -141,16 +141,19 @@ impl InnerEditorState { 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; + return; } } + + if !line.ends_with('\n') { + self.idx = end; + } } fn record_cursor_col(&mut self, frame: &mut Frame) { From 9ebe2361a98fca58ca2e363419733b8ee790c49b Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Aug 2022 00:25:53 +0200 Subject: [PATCH 009/443] Move cursor one word left/right --- src/ui/util.rs | 4 ++++ src/ui/widgets/editor.rs | 44 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/ui/util.rs b/src/ui/util.rs index 67e258f..402372f 100644 --- a/src/ui/util.rs +++ b/src/ui/util.rs @@ -54,6 +54,8 @@ pub fn list_editor_key_bindings( // Cursor movement bindings.binding("ctrl+b, ←", "move cursor left"); bindings.binding("ctrl+f, →", "move cursor right"); + bindings.binding("alt+b, ctrl+←", "move cursor left a word"); + bindings.binding("alt+f, ctrl+→", "move cursor right a word"); bindings.binding("ctrl+a, home", "move cursor to start of line"); bindings.binding("ctrl+e, end", "move cursor to end of line"); bindings.binding("↑/↓", "move cursor up/down"); @@ -86,6 +88,8 @@ pub fn handle_editor_key_event( // Cursor movement key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.frame()), key!(Ctrl + 'f') | key!(Right) => editor.move_cursor_right(terminal.frame()), + key!(Alt + 'b') | key!(Ctrl + Left) => editor.move_cursor_left_a_word(terminal.frame()), + key!(Alt + 'f') | key!(Ctrl + Right) => editor.move_cursor_right_a_word(terminal.frame()), key!(Ctrl + 'a') | key!(Home) => editor.move_cursor_to_start_of_line(terminal.frame()), key!(Ctrl + 'e') | key!(End) => editor.move_cursor_to_end_of_line(terminal.frame()), key!(Up) => editor.move_cursor_up(terminal.frame()), diff --git a/src/ui/widgets/editor.rs b/src/ui/widgets/editor.rs index 20f4a38..cae62ca 100644 --- a/src/ui/widgets/editor.rs +++ b/src/ui/widgets/editor.rs @@ -237,6 +237,42 @@ impl InnerEditorState { } } + fn move_cursor_left_a_word(&mut self, frame: &mut Frame) { + let boundaries = self.grapheme_boundaries(); + let mut encountered_word = false; + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)).rev() { + if *end == self.idx { + let g = &self.text[*start..*end]; + let whitespace = g.chars().all(|c| c.is_whitespace()); + if encountered_word && whitespace { + break; + } else if !whitespace { + encountered_word = true; + } + self.idx = *start; + } + } + self.record_cursor_col(frame); + } + + fn move_cursor_right_a_word(&mut self, frame: &mut Frame) { + let boundaries = self.grapheme_boundaries(); + let mut encountered_word = false; + for (start, end) in boundaries.iter().zip(boundaries.iter().skip(1)) { + if *start == self.idx { + let g = &self.text[*start..*end]; + let whitespace = g.chars().all(|c| c.is_whitespace()); + if encountered_word && whitespace { + break; + } else if !whitespace { + encountered_word = true; + } + self.idx = *end; + } + } + self.record_cursor_col(frame); + } + fn move_cursor_to_start_of_line(&mut self, frame: &mut Frame) { let boundaries = self.line_boundaries(); let (line, _, _) = self.cursor_line(&boundaries); @@ -329,6 +365,14 @@ impl EditorState { self.0.lock().move_cursor_right(frame); } + pub fn move_cursor_left_a_word(&self, frame: &mut Frame) { + self.0.lock().move_cursor_left_a_word(frame); + } + + pub fn move_cursor_right_a_word(&self, frame: &mut Frame) { + self.0.lock().move_cursor_right_a_word(frame); + } + pub fn move_cursor_to_start_of_line(&self, frame: &mut Frame) { self.0.lock().move_cursor_to_start_of_line(frame); } From de095e74aecbed764896377faa3db71298a0a4ce Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Aug 2022 00:28:22 +0200 Subject: [PATCH 010/443] Change binding for external editor In order to avoid collisions with ctrl+e, we need a new binding. In bash/readline, ctrl+x is used as a sort of leader key to initiate multi-key bindings. I don't think I'll implement multi-key combinations any time soon, so now ctrl+x stands for 'edit in eXternal editor'. --- src/ui/util.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/util.rs b/src/ui/util.rs index 402372f..1cab38c 100644 --- a/src/ui/util.rs +++ b/src/ui/util.rs @@ -46,7 +46,7 @@ pub fn list_editor_key_bindings( bindings.binding("ctrl+d, delete", "delete after cursor"); bindings.binding("ctrl+l", "clear editor contents"); if can_edit_externally { - bindings.binding("ctrl+e", "edit in $EDITOR"); + bindings.binding("ctrl+x", "edit in external editor"); } bindings.empty(); @@ -83,7 +83,7 @@ pub fn handle_editor_key_event( key!(Ctrl + 'h') | key!(Backspace) => editor.backspace(terminal.frame()), key!(Ctrl + 'd') | key!(Delete) => editor.delete(), key!(Ctrl + 'l') => editor.clear(), - key!(Ctrl + 'e') if can_edit_externally => editor.edit_externally(terminal, crossterm_lock), // TODO Change to some other binding + key!(Ctrl + 'x') if can_edit_externally => editor.edit_externally(terminal, crossterm_lock), // Cursor movement key!(Ctrl + 'b') | key!(Left) => editor.move_cursor_left(terminal.frame()), From d114857abd207f536f8ba28301dc14d6b1106c66 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Aug 2022 00:31:12 +0200 Subject: [PATCH 011/443] Update changelog --- CHANGELOG.md | 1 + src/ui/util.rs | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee6c63..d936c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased ### Changed +- Improved editor key bindings - Slowed down room history download speed ## v0.1.0 - 2022-08-06 diff --git a/src/ui/util.rs b/src/ui/util.rs index 1cab38c..c985862 100644 --- a/src/ui/util.rs +++ b/src/ui/util.rs @@ -30,8 +30,6 @@ pub fn prompt( } } -// TODO Support more of the emacs-y bindings, see bash as example - pub fn list_editor_key_bindings( bindings: &mut KeyBindingsList, char_filter: impl Fn(char) -> bool, From a2b9f57a097d9464e01abd74837b264109cf70a4 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Aug 2022 00:44:54 +0200 Subject: [PATCH 012/443] Fix room and nick dialog padding --- CHANGELOG.md | 3 +++ src/ui/room.rs | 16 ++++++++-------- src/ui/rooms.rs | 16 ++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d936c33..db875d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Improved editor key bindings - Slowed down room history download speed +### Fixed +- Spacing in some popups + ## v0.1.0 - 2022-08-06 Initial release diff --git a/src/ui/room.rs b/src/ui/room.rs index 72bdd0d..615d2f6 100644 --- a/src/ui/room.rs +++ b/src/ui/room.rs @@ -119,16 +119,16 @@ impl EuphRoom { State::Normal => chat, State::ChooseNick(ed) => Layer::new(vec![ chat, - Float::new(Border::new(Background::new( - Padding::new(VJoin::new(vec![ - Segment::new(Text::new("Choose nick ")), - Segment::new( + Float::new(Border::new(Background::new(VJoin::new(vec![ + Segment::new(Padding::new(Text::new("Choose nick")).horizontal(1)), + Segment::new( + Padding::new( ed.widget() .highlight(|s| Styled::new(s, euph::nick_style(s))), - ), - ])) - .left(1), - ))) + ) + .left(1), + ), + ])))) .horizontal(0.5) .vertical(0.5) .into(), diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs index e73d625..b2890af 100644 --- a/src/ui/rooms.rs +++ b/src/ui/rooms.rs @@ -103,16 +103,16 @@ impl Rooms { let room_style = ContentStyle::default().bold().blue(); Layer::new(vec![ self.rooms_widget().await, - Float::new(Border::new(Background::new( - Padding::new(VJoin::new(vec![ - Segment::new(Text::new("Connect to ")), - Segment::new(HJoin::new(vec![ + Float::new(Border::new(Background::new(VJoin::new(vec![ + Segment::new(Padding::new(Text::new("Connect to")).horizontal(1)), + Segment::new( + Padding::new(HJoin::new(vec![ Segment::new(Text::new(("&", room_style))), Segment::new(ed.widget().highlight(|s| Styled::new(s, room_style))), - ])), - ])) - .left(1), - ))) + ])) + .left(1), + ), + ])))) .horizontal(0.5) .vertical(0.5) .into(), From f430b0efc79a169202f172c693ee1aa7de9fc19d Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Aug 2022 00:53:11 +0200 Subject: [PATCH 013/443] Fix db inconsistencies when deleting a room Since the euph_trees table can't have any foreign key constraints pointing to the euph_rooms table, deleting a room wouldn't delete that room's trees in euph_trees. Upon reconnecting to the room, those trees would then be displayed as placeholder messages without children. --- CHANGELOG.md | 1 + src/vault/euph.rs | 16 ++++++++++++++-- src/vault/prepare.rs | 1 - 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db875d2..47abc39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Slowed down room history download speed ### Fixed +- Chat rendering when deleting and re-joining a room - Spacing in some popups ## v0.1.0 - 2022-08-06 diff --git a/src/vault/euph.rs b/src/vault/euph.rs index b56ddb9..26dc177 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -472,13 +472,25 @@ impl EuphRequest { } fn delete(conn: &mut Connection, room: String) -> rusqlite::Result<()> { - conn.execute( + let tx = conn.transaction()?; + + tx.execute( " DELETE FROM euph_rooms WHERE room = ? ", - [room], + [&room], )?; + + tx.execute( + " + DELETE FROM euph_trees + WHERE room = ? + ", + [&room], + )?; + + tx.commit()?; Ok(()) } diff --git a/src/vault/prepare.rs b/src/vault/prepare.rs index ab8cb9c..8f55fb0 100644 --- a/src/vault/prepare.rs +++ b/src/vault/prepare.rs @@ -4,7 +4,6 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { println!("Opening vault"); // This temporary table has no foreign key constraint on euph_rooms since // cross-schema constraints like that are not supported by SQLite. - // TODO Remove entries from this table whenever a room is deleted conn.execute_batch( " CREATE TEMPORARY TABLE euph_trees ( From 00f376c11bb8d5e13da1d10a0a7dfd5d6af8c156 Mon Sep 17 00:00:00 2001 From: Joscha Date: Sun, 7 Aug 2022 01:03:48 +0200 Subject: [PATCH 014/443] Add checklist for bumping version number --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47abc39..b721b1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +Procedure when bumping the version number: +1. Update dependencies in a separate commit +2. Set version number in `Cargo.toml` +3. Add new section in this changelog +4. Commit with message `Bump version to vX.Y.Z` +5. Create tag named `vX.Y.Z` +6. Fast-forward branch `latest` +7. Push `master`, `latest` and the new tag + ## Unreleased ### Changed From fdb8fc7bd04c95b6b9fba49677ee78e5d8d00b64 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 14:29:46 +0200 Subject: [PATCH 015/443] Add 'seen' flag to euph msgs in vault --- src/vault/migrate.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/vault/migrate.rs b/src/vault/migrate.rs index 5b9762f..7fb43c8 100644 --- a/src/vault/migrate.rs +++ b/src/vault/migrate.rs @@ -17,7 +17,7 @@ pub fn migrate(conn: &mut Connection) -> rusqlite::Result<()> { tx.commit() } -const MIGRATIONS: [fn(&mut Transaction<'_>) -> rusqlite::Result<()>; 1] = [m1]; +const MIGRATIONS: [fn(&mut Transaction<'_>) -> rusqlite::Result<()>; 2] = [m1, m2]; fn m1(tx: &mut Transaction<'_>) -> rusqlite::Result<()> { tx.execute_batch( @@ -80,3 +80,12 @@ fn m1(tx: &mut Transaction<'_>) -> rusqlite::Result<()> { ", ) } + +fn m2(tx: &mut Transaction<'_>) -> rusqlite::Result<()> { + tx.execute_batch( + " + ALTER TABLE euph_msgs + ADD COLUMN seen INTEGER NOT NULL DEFAULT TRUE + ", + ) +} From 20ec6ef3b39cdf0fd96e324ed15f2fb5f4253d3e Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 14:53:13 +0200 Subject: [PATCH 016/443] Set messages' seen status when adding to vault --- src/euph/room.rs | 22 ++++++++--- src/vault/euph.rs | 96 +++++++++++++++++++++++++++++++---------------- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/euph/room.rs b/src/euph/room.rs index 3faebaf..3e8df3b 100644 --- a/src/euph/room.rs +++ b/src/euph/room.rs @@ -19,8 +19,9 @@ use crate::macros::ok_or_return; use crate::ui::UiEvent; use crate::vault::{EuphVault, Vault}; -use super::api::{Data, Log, Nick, Send, Snowflake}; +use super::api::{Data, Log, Nick, Send, Snowflake, UserId}; use super::conn::{self, ConnRx, ConnTx, Status}; +use super::Joining; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -170,6 +171,13 @@ impl State { Ok(()) } + async fn own_user_id(&self) -> Option { + Some(match self.conn_tx.as_ref()?.status().await.ok()? { + Status::Joining(Joining { hello, .. }) => hello?.session.id, + Status::Joined(joined) => joined.session.id, + }) + } + async fn on_data(&mut self, data: Data) -> anyhow::Result<()> { match data { Data::BounceEvent(_) => {} @@ -202,9 +210,10 @@ impl State { ); } Data::SendEvent(d) => { + let own_user_id = self.own_user_id().await; if let Some(last_msg_id) = &mut self.last_msg_id { let id = d.0.id; - self.vault.add_message(d.0, *last_msg_id); + self.vault.add_message(d.0, *last_msg_id, own_user_id); *last_msg_id = Some(id); } else { bail!("send event before snapshot event"); @@ -214,15 +223,18 @@ impl State { info!("e&{}: successfully joined", self.name); self.vault.join(Time::now()); self.last_msg_id = Some(d.log.last().map(|m| m.id)); - self.vault.add_messages(d.log, None); + let own_user_id = self.own_user_id().await; + self.vault.add_messages(d.log, None, own_user_id); } Data::LogReply(d) => { - self.vault.add_messages(d.log, d.before); + let own_user_id = self.own_user_id().await; + self.vault.add_messages(d.log, d.before, own_user_id); } Data::SendReply(d) => { + let own_user_id = self.own_user_id().await; if let Some(last_msg_id) = &mut self.last_msg_id { let id = d.0.id; - self.vault.add_message(d.0, *last_msg_id); + self.vault.add_message(d.0, *last_msg_id, own_user_id); *last_msg_id = Some(id); } else { bail!("send reply before snapshot event"); diff --git a/src/vault/euph.rs b/src/vault/euph.rs index 26dc177..aa9fa9b 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -8,7 +8,7 @@ use rusqlite::{named_params, params, Connection, OptionalExtension, ToSql, Trans use time::OffsetDateTime; use tokio::sync::oneshot; -use crate::euph::api::{Message, Snowflake, Time}; +use crate::euph::api::{Message, Snowflake, Time, UserId}; use crate::euph::SmallMessage; use crate::store::{MsgStore, Path, Tree}; @@ -99,20 +99,32 @@ impl EuphVault { let _ = self.vault.tx.send(request.into()); } - pub fn add_message(&self, msg: Message, prev_msg: Option) { + pub fn add_message( + &self, + msg: Message, + prev_msg: Option, + own_user_id: Option, + ) { let request = EuphRequest::AddMsg { room: self.room.clone(), msg: Box::new(msg), prev_msg, + own_user_id, }; let _ = self.vault.tx.send(request.into()); } - pub fn add_messages(&self, msgs: Vec, next_msg: Option) { + pub fn add_messages( + &self, + msgs: Vec, + next_msg: Option, + own_user_id: Option, + ) { let request = EuphRequest::AddMsgs { room: self.room.clone(), msgs, next_msg, + own_user_id, }; let _ = self.vault.tx.send(request.into()); } @@ -280,11 +292,13 @@ pub(super) enum EuphRequest { room: String, msg: Box, prev_msg: Option, + own_user_id: Option, }, AddMsgs { room: String, msgs: Vec, next_msg: Option, + own_user_id: Option, }, GetLastSpan { room: String, @@ -350,12 +364,14 @@ impl EuphRequest { room, msg, prev_msg, - } => Self::add_msg(conn, room, *msg, prev_msg), + own_user_id, + } => Self::add_msg(conn, room, *msg, prev_msg, own_user_id), EuphRequest::AddMsgs { room, msgs, next_msg, - } => Self::add_msgs(conn, room, msgs, next_msg), + own_user_id, + } => Self::add_msgs(conn, room, msgs, next_msg, own_user_id), EuphRequest::GetLastSpan { room, result } => Self::get_last_span(conn, room, result), EuphRequest::GetPath { room, id, result } => Self::get_path(conn, room, id, result), EuphRequest::GetTree { room, root, result } => Self::get_tree(conn, room, root, result), @@ -494,16 +510,28 @@ impl EuphRequest { Ok(()) } - fn insert_msgs(tx: &Transaction<'_>, room: &str, msgs: Vec) -> rusqlite::Result<()> { + fn insert_msgs( + tx: &Transaction<'_>, + room: &str, + own_user_id: &Option, + msgs: Vec, + ) -> rusqlite::Result<()> { let mut insert_msg = tx.prepare( " INSERT OR REPLACE INTO euph_msgs ( room, id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, - user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address + user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address, + seen ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ? + :room, :id, :parent, :previous_edit_id, :time, :content, :encryption_key_id, :edited, :deleted, :truncated, + :user_id, :name, :server_id, :server_era, :session_id, :is_staff, :is_manager, :client_address, :real_client_address, + (:user_id == :own_user_id OR EXISTS( + SELECT 1 + FROM euph_rooms + WHERE room = :room + AND :time < first_joined + )) ) " )?; @@ -528,28 +556,30 @@ impl EuphRequest { ", )?; + let own_user_id = own_user_id.as_ref().map(|u| &u.0); for msg in msgs { - insert_msg.execute(params![ - room, - msg.id, - msg.parent, - msg.previous_edit_id, - msg.time, - msg.content, - msg.encryption_key_id, - msg.edited, - msg.deleted, - msg.truncated, - msg.sender.id.0, - msg.sender.name, - msg.sender.server_id, - msg.sender.server_era, - msg.sender.session_id, - msg.sender.is_staff, - msg.sender.is_manager, - msg.sender.client_address, - msg.sender.real_client_address, - ])?; + insert_msg.execute(named_params! { + ":room": room, + ":id": msg.id, + ":parent": msg.parent, + ":previous_edit_id": msg.previous_edit_id, + ":time": msg.time, + ":content": msg.content, + ":encryption_key_id": msg.encryption_key_id, + ":edited": msg.edited, + ":deleted": msg.deleted, + ":truncated": msg.truncated, + ":user_id": msg.sender.id.0, + ":name": msg.sender.name, + ":server_id": msg.sender.server_id, + ":server_era": msg.sender.server_era, + ":session_id": msg.sender.session_id, + ":is_staff": msg.sender.is_staff, + ":is_manager": msg.sender.is_manager, + ":client_address": msg.sender.client_address, + ":real_client_address": msg.sender.real_client_address, + ":own_user_id": own_user_id, // May be NULL + })?; if let Some(parent) = msg.parent { delete_trees.execute(params![room, msg.id])?; @@ -641,11 +671,12 @@ impl EuphRequest { room: String, msg: Message, prev_msg: Option, + own_user_id: Option, ) -> rusqlite::Result<()> { let tx = conn.transaction()?; let end = msg.id; - Self::insert_msgs(&tx, &room, vec![msg])?; + Self::insert_msgs(&tx, &room, &own_user_id, vec![msg])?; Self::add_span(&tx, &room, prev_msg, Some(end))?; tx.commit()?; @@ -657,6 +688,7 @@ impl EuphRequest { room: String, msgs: Vec, next_msg_id: Option, + own_user_id: Option, ) -> rusqlite::Result<()> { let tx = conn.transaction()?; @@ -666,7 +698,7 @@ impl EuphRequest { let first_msg_id = msgs.first().unwrap().id; let last_msg_id = msgs.last().unwrap().id; - Self::insert_msgs(&tx, &room, msgs)?; + Self::insert_msgs(&tx, &room, &own_user_id, msgs)?; let end = next_msg_id.unwrap_or(last_msg_id); Self::add_span(&tx, &room, Some(first_msg_id), Some(end))?; From ff4118e21d4320dfc7f95710fdaf8b50fcce7a8a Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 15:00:20 +0200 Subject: [PATCH 017/443] Query and set seen status via store --- src/euph/small_message.rs | 5 +++++ src/logger.rs | 6 ++++++ src/store.rs | 2 ++ src/vault/euph.rs | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/euph/small_message.rs b/src/euph/small_message.rs index 559e5c1..bd1ad18 100644 --- a/src/euph/small_message.rs +++ b/src/euph/small_message.rs @@ -94,6 +94,7 @@ pub struct SmallMessage { pub time: Time, pub nick: String, pub content: String, + pub seen: bool, } fn as_me(content: &str) -> Option<&str> { @@ -144,6 +145,10 @@ impl Msg for SmallMessage { self.parent } + fn seen(&self) -> bool { + self.seen + } + fn last_possible_id() -> Self::Id { Snowflake::MAX } diff --git a/src/logger.rs b/src/logger.rs index 6528591..fd4d30b 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -31,6 +31,10 @@ impl Msg for LogMsg { None } + fn seen(&self) -> bool { + true + } + fn last_possible_id() -> Self::Id { Self::Id::MAX } @@ -118,6 +122,8 @@ impl MsgStore for Logger { async fn newer_msg_id(&self, id: &usize) -> Option { self.next_tree_id(id).await } + + async fn set_seen(&self, _id: &usize, _seen: bool) {} } impl Log for Logger { diff --git a/src/store.rs b/src/store.rs index 0952757..0e6cc33 100644 --- a/src/store.rs +++ b/src/store.rs @@ -9,6 +9,7 @@ pub trait Msg { type Id: Clone + Debug + Hash + Eq + Ord; fn id(&self) -> Self::Id; fn parent(&self) -> Option; + fn seen(&self) -> bool; fn last_possible_id() -> Self::Id; } @@ -128,4 +129,5 @@ pub trait MsgStore { async fn newest_msg_id(&self) -> Option; async fn older_msg_id(&self, id: &M::Id) -> Option; async fn newer_msg_id(&self, id: &M::Id) -> Option; + async fn set_seen(&self, id: &M::Id, seen: bool); } diff --git a/src/vault/euph.rs b/src/vault/euph.rs index aa9fa9b..b194ed2 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -258,6 +258,15 @@ impl MsgStore for EuphVault { let _ = self.vault.tx.send(request.into()); rx.await.unwrap() } + + async fn set_seen(&self, id: &Snowflake, seen: bool) { + let request = EuphRequest::SetSeen { + room: self.room.clone(), + id: *id, + seen, + }; + let _ = self.vault.tx.send(request.into()); + } } pub(super) enum EuphRequest { @@ -350,6 +359,11 @@ pub(super) enum EuphRequest { id: Snowflake, result: oneshot::Sender>, }, + SetSeen { + room: String, + id: Snowflake, + seen: bool, + }, } impl EuphRequest { @@ -399,6 +413,7 @@ impl EuphRequest { EuphRequest::GetNewerMsgId { room, id, result } => { Self::get_newer_msg_id(conn, room, id, result) } + EuphRequest::SetSeen { room, id, seen } => Self::set_seen(conn, room, id, seen), }; if let Err(e) = result { // If an error occurs here, the rest of the UI will likely panic and @@ -778,7 +793,7 @@ impl EuphRequest { ON tree.room = euph_msgs.room AND tree.id = euph_msgs.parent ) - SELECT id, parent, time, name, content + SELECT id, parent, time, name, content, seen FROM euph_msgs JOIN tree USING (room, id) ORDER BY id ASC @@ -791,6 +806,7 @@ impl EuphRequest { time: row.get(2)?, nick: row.get(3)?, content: row.get(4)?, + seen: row.get(5)?, }) })? .collect::>()?; @@ -974,4 +990,22 @@ impl EuphRequest { let _ = result.send(tree); Ok(()) } + + fn set_seen( + conn: &Connection, + room: String, + id: Snowflake, + seen: bool, + ) -> rusqlite::Result<()> { + conn.execute( + " + UPDATE euph_msgs + SET seen = :seen + WHERE room = :room + AND id = :id + ", + named_params! {":room": room, ":id": id, ":seen": seen}, + )?; + Ok(()) + } } From 6166c5e366c519d8ac949643ea3db172b4857efd Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 15:09:58 +0200 Subject: [PATCH 018/443] Toggle messages' seen status --- src/ui/chat/tree.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index 34d18a5..41cf4a1 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -92,6 +92,28 @@ impl> InnerTreeViewState { true } + pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.binding("s", "toggle current message's seen status"); + // bindings.binding("S", "mark all visible messages as seen"); + // bindings.binding("ctrl+S", "mark all messages as seen"); + } + + async fn handle_action_key_event(&mut self, event: KeyEvent, id: Option<&M::Id>) -> bool { + if let Some(id) = id { + match event { + key!('s') => { + if let Some(msg) = self.store.tree(id).await.msg(id) { + self.store.set_seen(id, !msg.seen()).await; + } + true + } + _ => false, + } + } else { + false + } + } + pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.empty(); bindings.binding("r", "reply to message"); @@ -130,6 +152,7 @@ impl> InnerTreeViewState { pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { self.list_movement_key_bindings(bindings); + self.list_action_key_bindings(bindings); if can_compose { self.list_edit_initiating_key_bindings(bindings); } @@ -142,8 +165,11 @@ impl> InnerTreeViewState { can_compose: bool, id: Option, ) -> bool { + #[allow(clippy::if_same_then_else)] if self.handle_movement_key_event(frame, event).await { true + } else if self.handle_action_key_event(event, id.as_ref()).await { + true } else if can_compose { self.handle_edit_initiating_key_event(event, id).await } else { @@ -210,7 +236,7 @@ impl> InnerTreeViewState { } Cursor::Editor { .. } => self.list_editor_key_bindings(bindings), Cursor::Pseudo { .. } => { - self.list_movement_key_bindings(bindings); + self.list_normal_key_bindings(bindings, false); } } } From de569211f69ed10641942f71ff5c15c4d515c09c Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 15:13:55 +0200 Subject: [PATCH 019/443] Display seen status of messages --- src/ui/chat/tree/widgets.rs | 5 +++++ src/ui/chat/tree/widgets/seen.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/ui/chat/tree/widgets/seen.rs diff --git a/src/ui/chat/tree/widgets.rs b/src/ui/chat/tree/widgets.rs index 9314bfa..b39bd5c 100644 --- a/src/ui/chat/tree/widgets.rs +++ b/src/ui/chat/tree/widgets.rs @@ -1,6 +1,7 @@ // TODO Remove mut in &mut Frame wherever applicable in this entire module mod indent; +mod seen; mod time; use crossterm::style::{ContentStyle, Stylize}; @@ -50,6 +51,7 @@ fn style_pseudo_highlight() -> ContentStyle { pub fn msg(highlighted: bool, indent: usize, msg: &M) -> BoxedWidget { let (nick, content) = msg.styled(); HJoin::new(vec![ + Segment::new(seen::widget(msg.seen())), Segment::new( Padding::new(time::widget(Some(msg.time()), style_time(highlighted))) .stretch(true) @@ -69,6 +71,7 @@ pub fn msg(highlighted: bool, indent: usize, msg: &M) -> Boxed pub fn msg_placeholder(highlighted: bool, indent: usize) -> BoxedWidget { HJoin::new(vec![ + Segment::new(seen::widget(true)), Segment::new( Padding::new(time::widget(None, style_time(highlighted))) .stretch(true) @@ -91,6 +94,7 @@ pub fn editor( let cursor_row = editor.cursor_row(frame); let widget = HJoin::new(vec![ + Segment::new(seen::widget(true)), Segment::new( Padding::new(time::widget(None, style_editor_highlight())) .stretch(true) @@ -112,6 +116,7 @@ pub fn pseudo(indent: usize, nick: &str, editor: &EditorState) -> Bo let (nick, content) = M::edit(nick, &editor.text()); HJoin::new(vec![ + Segment::new(seen::widget(true)), Segment::new( Padding::new(time::widget(None, style_pseudo_highlight())) .stretch(true) diff --git a/src/ui/chat/tree/widgets/seen.rs b/src/ui/chat/tree/widgets/seen.rs new file mode 100644 index 0000000..8197afd --- /dev/null +++ b/src/ui/chat/tree/widgets/seen.rs @@ -0,0 +1,24 @@ +use crossterm::style::{ContentStyle, Stylize}; + +use crate::ui::widgets::background::Background; +use crate::ui::widgets::empty::Empty; +use crate::ui::widgets::text::Text; +use crate::ui::widgets::BoxedWidget; + +const UNSEEN: &str = "*"; +const WIDTH: u16 = 1; + +fn seen_style() -> ContentStyle { + ContentStyle::default().black().on_green() +} + +pub fn widget(seen: bool) -> BoxedWidget { + if seen { + Empty::new().width(WIDTH).into() + } else { + let style = seen_style(); + Background::new(Text::new((UNSEEN, style))) + .style(style) + .into() + } +} From 43247e2a5c9615799516fb4eeaf5a9966be6a38e Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 15:30:16 +0200 Subject: [PATCH 020/443] Mark all visible messages as seen --- src/ui/chat/tree.rs | 23 +++++++++++++++-------- src/ui/chat/tree/layout.rs | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index 41cf4a1..47cd576 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -36,6 +36,7 @@ struct InnerTreeViewState> { last_cursor: Cursor, last_cursor_line: i32, + last_visible_msgs: Vec, cursor: Cursor, @@ -53,6 +54,7 @@ impl> InnerTreeViewState { store, last_cursor: Cursor::Bottom, last_cursor_line: 0, + last_visible_msgs: vec![], cursor: Cursor::Bottom, scroll: 0, correction: None, @@ -94,24 +96,29 @@ impl> InnerTreeViewState { pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.binding("s", "toggle current message's seen status"); - // bindings.binding("S", "mark all visible messages as seen"); + bindings.binding("S", "mark all visible messages as seen"); // bindings.binding("ctrl+S", "mark all messages as seen"); } async fn handle_action_key_event(&mut self, event: KeyEvent, id: Option<&M::Id>) -> bool { - if let Some(id) = id { - match event { - key!('s') => { + match event { + key!('s') => { + if let Some(id) = id { if let Some(msg) = self.store.tree(id).await.msg(id) { self.store.set_seen(id, !msg.seen()).await; } - true + return true; } - _ => false, } - } else { - false + key!('S') => { + for id in &self.last_visible_msgs { + self.store.set_seen(id, true).await; + } + return true; + } + _ => {} } + false } pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) { diff --git a/src/ui/chat/tree/layout.rs b/src/ui/chat/tree/layout.rs index 6866142..6fb4911 100644 --- a/src/ui/chat/tree/layout.rs +++ b/src/ui/chat/tree/layout.rs @@ -420,6 +420,23 @@ impl> InnerTreeViewState { } } + fn visible_msgs(frame: &Frame, blocks: &TreeBlocks) -> Vec { + let height: i32 = frame.size().height.into(); + let first_line = 0; + let last_line = first_line + height - 1; + + let mut result = vec![]; + for block in blocks.blocks().iter() { + if Self::visible(block, first_line, last_line) { + if let BlockId::Msg(id) = &block.id { + result.push(id.clone()); + } + } + } + + result + } + pub async fn relayout(&mut self, nick: &str, frame: &mut Frame) -> TreeBlocks { // The basic idea is this: // @@ -472,6 +489,7 @@ impl> InnerTreeViewState { self.last_cursor = self.cursor.clone(); self.last_cursor_line = self.cursor_line(&blocks); + self.last_visible_msgs = Self::visible_msgs(frame, &blocks); self.scroll = 0; self.correction = None; @@ -488,6 +506,7 @@ impl> InnerTreeViewState { self.last_cursor = self.cursor.clone(); self.last_cursor_line = self.cursor_line(&blocks); + self.last_visible_msgs = Self::visible_msgs(frame, &blocks); self.scroll = 0; self.correction = None; From 573f23146667b23117106db607847cbacd5791cd Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 15:37:43 +0200 Subject: [PATCH 021/443] Mark all messages as seen --- src/logger.rs | 2 ++ src/store.rs | 1 + src/ui/chat/tree.rs | 7 ++++++- src/vault/euph.rs | 27 ++++++++++++++++++++++++++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/logger.rs b/src/logger.rs index fd4d30b..41a3d05 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -124,6 +124,8 @@ impl MsgStore for Logger { } async fn set_seen(&self, _id: &usize, _seen: bool) {} + + async fn set_all_seen(&self, _seen: bool) {} } impl Log for Logger { diff --git a/src/store.rs b/src/store.rs index 0e6cc33..81b32b6 100644 --- a/src/store.rs +++ b/src/store.rs @@ -130,4 +130,5 @@ pub trait MsgStore { async fn older_msg_id(&self, id: &M::Id) -> Option; async fn newer_msg_id(&self, id: &M::Id) -> Option; async fn set_seen(&self, id: &M::Id, seen: bool); + async fn set_all_seen(&self, seen: bool); } diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index 47cd576..ed740a9 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -97,7 +97,7 @@ impl> InnerTreeViewState { pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.binding("s", "toggle current message's seen status"); bindings.binding("S", "mark all visible messages as seen"); - // bindings.binding("ctrl+S", "mark all messages as seen"); + bindings.binding("ctrl+S", "mark all messages as seen"); } async fn handle_action_key_event(&mut self, event: KeyEvent, id: Option<&M::Id>) -> bool { @@ -116,6 +116,11 @@ impl> InnerTreeViewState { } return true; } + key!(Ctrl + 'S') => { + // Ctrl + Shift + s, extra hard to hit accidentally + self.store.set_all_seen(true).await; + return true; + } _ => {} } false diff --git a/src/vault/euph.rs b/src/vault/euph.rs index b194ed2..19a0a31 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -267,6 +267,14 @@ impl MsgStore for EuphVault { }; let _ = self.vault.tx.send(request.into()); } + + async fn set_all_seen(&self, seen: bool) { + let request = EuphRequest::SetAllSeen { + room: self.room.clone(), + seen, + }; + let _ = self.vault.tx.send(request.into()); + } } pub(super) enum EuphRequest { @@ -364,6 +372,10 @@ pub(super) enum EuphRequest { id: Snowflake, seen: bool, }, + SetAllSeen { + room: String, + seen: bool, + }, } impl EuphRequest { @@ -414,6 +426,7 @@ impl EuphRequest { Self::get_newer_msg_id(conn, room, id, result) } EuphRequest::SetSeen { room, id, seen } => Self::set_seen(conn, room, id, seen), + EuphRequest::SetAllSeen { room, seen } => Self::set_all_seen(conn, room, seen), }; if let Err(e) = result { // If an error occurs here, the rest of the UI will likely panic and @@ -1004,7 +1017,19 @@ impl EuphRequest { WHERE room = :room AND id = :id ", - named_params! {":room": room, ":id": id, ":seen": seen}, + named_params! { ":room": room, ":id": id, ":seen": seen }, + )?; + Ok(()) + } + + fn set_all_seen(conn: &Connection, room: String, seen: bool) -> rusqlite::Result<()> { + conn.execute( + " + UPDATE euph_msgs + SET seen = :seen + WHERE room = :room + ", + named_params! { ":room": room, ":seen": seen }, )?; Ok(()) } From cee91695e0f9cbf2f97c6bb5f19f64d7dc30b010 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 16:16:38 +0200 Subject: [PATCH 022/443] Mark older messages as seen instead --- src/logger.rs | 2 +- src/store.rs | 2 +- src/ui/chat/tree.rs | 13 +++++++++---- src/vault/euph.rs | 25 +++++++++++++++++++------ 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/logger.rs b/src/logger.rs index 41a3d05..1973cfb 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -125,7 +125,7 @@ impl MsgStore for Logger { async fn set_seen(&self, _id: &usize, _seen: bool) {} - async fn set_all_seen(&self, _seen: bool) {} + async fn set_older_seen(&self, _id: &usize, _seen: bool) {} } impl Log for Logger { diff --git a/src/store.rs b/src/store.rs index 81b32b6..14882cf 100644 --- a/src/store.rs +++ b/src/store.rs @@ -130,5 +130,5 @@ pub trait MsgStore { async fn older_msg_id(&self, id: &M::Id) -> Option; async fn newer_msg_id(&self, id: &M::Id) -> Option; async fn set_seen(&self, id: &M::Id, seen: bool); - async fn set_all_seen(&self, seen: bool); + async fn set_older_seen(&self, id: &M::Id, seen: bool); } diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index ed740a9..7e41382 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -97,7 +97,7 @@ impl> InnerTreeViewState { pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.binding("s", "toggle current message's seen status"); bindings.binding("S", "mark all visible messages as seen"); - bindings.binding("ctrl+S", "mark all messages as seen"); + bindings.binding("ctrl+s", "mark all older messages as seen"); } async fn handle_action_key_event(&mut self, event: KeyEvent, id: Option<&M::Id>) -> bool { @@ -116,9 +116,14 @@ impl> InnerTreeViewState { } return true; } - key!(Ctrl + 'S') => { - // Ctrl + Shift + s, extra hard to hit accidentally - self.store.set_all_seen(true).await; + key!(Ctrl + 's') => { + if let Some(id) = id { + self.store.set_older_seen(id, true).await; + } else { + self.store + .set_older_seen(&M::last_possible_id(), true) + .await; + } return true; } _ => {} diff --git a/src/vault/euph.rs b/src/vault/euph.rs index 19a0a31..dea7a49 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -268,9 +268,10 @@ impl MsgStore for EuphVault { let _ = self.vault.tx.send(request.into()); } - async fn set_all_seen(&self, seen: bool) { - let request = EuphRequest::SetAllSeen { + async fn set_older_seen(&self, id: &Snowflake, seen: bool) { + let request = EuphRequest::SetOlderSeen { room: self.room.clone(), + id: *id, seen, }; let _ = self.vault.tx.send(request.into()); @@ -372,8 +373,9 @@ pub(super) enum EuphRequest { id: Snowflake, seen: bool, }, - SetAllSeen { + SetOlderSeen { room: String, + id: Snowflake, seen: bool, }, } @@ -426,7 +428,9 @@ impl EuphRequest { Self::get_newer_msg_id(conn, room, id, result) } EuphRequest::SetSeen { room, id, seen } => Self::set_seen(conn, room, id, seen), - EuphRequest::SetAllSeen { room, seen } => Self::set_all_seen(conn, room, seen), + EuphRequest::SetOlderSeen { room, id, seen } => { + Self::set_older_seen(conn, room, id, seen) + } }; if let Err(e) = result { // If an error occurs here, the rest of the UI will likely panic and @@ -1022,14 +1026,23 @@ impl EuphRequest { Ok(()) } - fn set_all_seen(conn: &Connection, room: String, seen: bool) -> rusqlite::Result<()> { + fn set_older_seen( + conn: &Connection, + room: String, + id: Snowflake, + seen: bool, + ) -> rusqlite::Result<()> { + // TODO Speed up this update + // Maybe with an index on (room, id, seen) and a filter to only set seen + // where it isn't already set correctly? conn.execute( " UPDATE euph_msgs SET seen = :seen WHERE room = :room + AND id <= :id ", - named_params! { ":room": room, ":seen": seen }, + named_params! { ":room": room, ":id": id, ":seen": seen }, )?; Ok(()) } From 973a621a13c97c3882cfd0b73e386bfaad8eb3b3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 16:17:18 +0200 Subject: [PATCH 023/443] Fix type conversion error when cursor is at bottom --- src/euph/api/types.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/euph/api/types.rs b/src/euph/api/types.rs index e518083..8acc64b 100644 --- a/src/euph/api/types.rs +++ b/src/euph/api/types.rs @@ -285,7 +285,20 @@ pub struct SessionView { pub struct Snowflake(pub u64); impl Snowflake { - pub const MAX: Self = Snowflake(u64::MAX); + /// Maximum possible snowflake that can be safely handled by all of cove's + /// parts. + /// + /// In theory, euphoria's snowflakes are 64-bit values and can take + /// advantage of the full range. However, sqlite always stores integers as + /// signed, and uses a maximum of 8 bytes (64 bits). Because of this, using + /// [`u64::MAX`] here would lead to errors in some database interactions. + /// + /// For this reason, I'm limiting snowflakes to the range from `0` to + /// [`i64::MAX`]. The euphoria backend isn't likely to change its + /// representation of message ids to suddenly use the upper parts of the + /// range, and since message ids mostly consist of a timestamp, this + /// approach should last until at least 2075. + pub const MAX: Self = Snowflake(i64::MAX as u64); } impl Serialize for Snowflake { From 05ce0691216739e62d89cee30a3802ebe0b823e1 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 16:29:25 +0200 Subject: [PATCH 024/443] Fix reinserting existing messages overwriting seen --- src/vault/euph.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/vault/euph.rs b/src/vault/euph.rs index dea7a49..140fbdf 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -550,7 +550,7 @@ impl EuphRequest { ) -> rusqlite::Result<()> { let mut insert_msg = tx.prepare( " - INSERT OR REPLACE INTO euph_msgs ( + INSERT INTO euph_msgs ( room, id, parent, previous_edit_id, time, content, encryption_key_id, edited, deleted, truncated, user_id, name, server_id, server_era, session_id, is_staff, is_manager, client_address, real_client_address, seen @@ -565,6 +565,28 @@ impl EuphRequest { AND :time < first_joined )) ) + ON CONFLICT (room, id) DO UPDATE + SET + room = :room, + id = :id, + parent = :parent, + previous_edit_id = :previous_edit_id, + time = :time, + content = :content, + encryption_key_id = :encryption_key_id, + edited = :edited, + deleted = :deleted, + truncated = :truncated, + + user_id = :user_id, + name = :name, + server_id = :server_id, + server_era = :server_era, + session_id = :session_id, + is_staff = :is_staff, + is_manager = :is_manager, + client_address = :client_address, + real_client_address = :real_client_address " )?; let mut delete_trees = tx.prepare( From bfc221106d8cfb1f102bf1b5f56abecdc69b4aa6 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 16:59:24 +0200 Subject: [PATCH 025/443] Move to prev/next unseen message --- src/logger.rs | 16 ++++ src/store.rs | 4 + src/ui/chat/tree.rs | 3 + src/ui/chat/tree/cursor.rs | 34 ++++++++ src/vault/euph.rs | 168 +++++++++++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+) diff --git a/src/logger.rs b/src/logger.rs index 1973cfb..127811b 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -123,6 +123,22 @@ impl MsgStore for Logger { self.next_tree_id(id).await } + async fn oldest_unseen_msg_id(&self) -> Option { + None + } + + async fn newest_unseen_msg_id(&self) -> Option { + None + } + + async fn older_unseen_msg_id(&self, _id: &usize) -> Option { + None + } + + async fn newer_unseen_msg_id(&self, _id: &usize) -> Option { + None + } + async fn set_seen(&self, _id: &usize, _seen: bool) {} async fn set_older_seen(&self, _id: &usize, _seen: bool) {} diff --git a/src/store.rs b/src/store.rs index 14882cf..2a3c482 100644 --- a/src/store.rs +++ b/src/store.rs @@ -129,6 +129,10 @@ pub trait MsgStore { async fn newest_msg_id(&self) -> Option; async fn older_msg_id(&self, id: &M::Id) -> Option; async fn newer_msg_id(&self, id: &M::Id) -> Option; + async fn oldest_unseen_msg_id(&self) -> Option; + async fn newest_unseen_msg_id(&self) -> Option; + async fn older_unseen_msg_id(&self, id: &M::Id) -> Option; + async fn newer_unseen_msg_id(&self, id: &M::Id) -> Option; async fn set_seen(&self, id: &M::Id, seen: bool); async fn set_older_seen(&self, id: &M::Id, seen: bool); } diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index 7e41382..ee87a86 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -65,6 +65,7 @@ impl> InnerTreeViewState { pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.binding("j/k, ↓/↑", "move cursor up/down"); bindings.binding("h/l, ←/→", "move cursor chronologically"); + bindings.binding("H/L, ctrl+←/→", "move cursor to prev/next unseen message"); bindings.binding("g, home", "move cursor to top"); bindings.binding("G, end", "move cursor to bottom"); bindings.binding("ctrl+y/e", "scroll up/down a line"); @@ -80,6 +81,8 @@ impl> InnerTreeViewState { key!('j') | key!(Down) => self.move_cursor_down().await, key!('h') | key!(Left) => self.move_cursor_older().await, key!('l') | key!(Right) => self.move_cursor_newer().await, + key!('H') | key!(Ctrl + Left) => self.move_cursor_older_unseen().await, + key!('L') | key!(Ctrl + Right) => self.move_cursor_newer_unseen().await, key!('g') | key!(Home) => self.move_cursor_to_top().await, key!('G') | key!(End) => self.move_cursor_to_bottom().await, key!(Ctrl + 'y') => self.scroll_up(1), diff --git a/src/ui/chat/tree/cursor.rs b/src/ui/chat/tree/cursor.rs index 7f0effc..a2f940d 100644 --- a/src/ui/chat/tree/cursor.rs +++ b/src/ui/chat/tree/cursor.rs @@ -254,6 +254,40 @@ impl> InnerTreeViewState { self.correction = Some(Correction::MakeCursorVisible); } + pub async fn move_cursor_older_unseen(&mut self) { + match &mut self.cursor { + Cursor::Msg(id) => { + if let Some(prev_id) = self.store.older_unseen_msg_id(id).await { + *id = prev_id; + } + } + Cursor::Bottom | Cursor::Pseudo { .. } => { + if let Some(id) = self.store.newest_unseen_msg_id().await { + self.cursor = Cursor::Msg(id); + } + } + _ => {} + } + self.correction = Some(Correction::MakeCursorVisible); + } + + pub async fn move_cursor_newer_unseen(&mut self) { + match &mut self.cursor { + Cursor::Msg(id) => { + if let Some(prev_id) = self.store.newer_unseen_msg_id(id).await { + *id = prev_id; + } else { + self.cursor = Cursor::Bottom; + } + } + Cursor::Pseudo { .. } => { + self.cursor = Cursor::Bottom; + } + _ => {} + } + self.correction = Some(Correction::MakeCursorVisible); + } + pub async fn move_cursor_to_top(&mut self) { if let Some(first_tree_id) = self.store.first_tree_id().await { self.cursor = Cursor::Msg(first_tree_id); diff --git a/src/vault/euph.rs b/src/vault/euph.rs index 140fbdf..f9efb8f 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -259,6 +259,52 @@ impl MsgStore for EuphVault { rx.await.unwrap() } + async fn oldest_unseen_msg_id(&self) -> Option { + // TODO vault::Error + let (tx, rx) = oneshot::channel(); + let request = EuphRequest::GetOldestUnseenMsgId { + room: self.room.clone(), + result: tx, + }; + let _ = self.vault.tx.send(request.into()); + rx.await.unwrap() + } + + async fn newest_unseen_msg_id(&self) -> Option { + // TODO vault::Error + let (tx, rx) = oneshot::channel(); + let request = EuphRequest::GetNewestUnseenMsgId { + room: self.room.clone(), + result: tx, + }; + let _ = self.vault.tx.send(request.into()); + rx.await.unwrap() + } + + async fn older_unseen_msg_id(&self, id: &Snowflake) -> Option { + // TODO vault::Error + let (tx, rx) = oneshot::channel(); + let request = EuphRequest::GetOlderUnseenMsgId { + room: self.room.clone(), + id: *id, + result: tx, + }; + let _ = self.vault.tx.send(request.into()); + rx.await.unwrap() + } + + async fn newer_unseen_msg_id(&self, id: &Snowflake) -> Option { + // TODO vault::Error + let (tx, rx) = oneshot::channel(); + let request = EuphRequest::GetNewerUnseenMsgId { + room: self.room.clone(), + id: *id, + result: tx, + }; + let _ = self.vault.tx.send(request.into()); + rx.await.unwrap() + } + async fn set_seen(&self, id: &Snowflake, seen: bool) { let request = EuphRequest::SetSeen { room: self.room.clone(), @@ -368,6 +414,24 @@ pub(super) enum EuphRequest { id: Snowflake, result: oneshot::Sender>, }, + GetOlderUnseenMsgId { + room: String, + id: Snowflake, + result: oneshot::Sender>, + }, + GetOldestUnseenMsgId { + room: String, + result: oneshot::Sender>, + }, + GetNewestUnseenMsgId { + room: String, + result: oneshot::Sender>, + }, + GetNewerUnseenMsgId { + room: String, + id: Snowflake, + result: oneshot::Sender>, + }, SetSeen { room: String, id: Snowflake, @@ -427,6 +491,18 @@ impl EuphRequest { EuphRequest::GetNewerMsgId { room, id, result } => { Self::get_newer_msg_id(conn, room, id, result) } + EuphRequest::GetOldestUnseenMsgId { room, result } => { + Self::get_oldest_unseen_msg_id(conn, room, result) + } + EuphRequest::GetNewestUnseenMsgId { room, result } => { + Self::get_newest_unseen_msg_id(conn, room, result) + } + EuphRequest::GetOlderUnseenMsgId { room, id, result } => { + Self::get_older_unseen_msg_id(conn, room, id, result) + } + EuphRequest::GetNewerUnseenMsgId { room, id, result } => { + Self::get_newer_unseen_msg_id(conn, room, id, result) + } EuphRequest::SetSeen { room, id, seen } => Self::set_seen(conn, room, id, seen), EuphRequest::SetOlderSeen { room, id, seen } => { Self::set_older_seen(conn, room, id, seen) @@ -1030,6 +1106,98 @@ impl EuphRequest { Ok(()) } + fn get_oldest_unseen_msg_id( + conn: &Connection, + room: String, + result: oneshot::Sender>, + ) -> rusqlite::Result<()> { + let tree = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE room = ? + AND NOT seen + ORDER BY id ASC + LIMIT 1 + ", + )? + .query_row([room], |row| row.get(0)) + .optional()?; + let _ = result.send(tree); + Ok(()) + } + + fn get_newest_unseen_msg_id( + conn: &Connection, + room: String, + result: oneshot::Sender>, + ) -> rusqlite::Result<()> { + let tree = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE room = ? + AND NOT seen + ORDER BY id DESC + LIMIT 1 + ", + )? + .query_row([room], |row| row.get(0)) + .optional()?; + let _ = result.send(tree); + Ok(()) + } + + fn get_older_unseen_msg_id( + conn: &Connection, + room: String, + id: Snowflake, + result: oneshot::Sender>, + ) -> rusqlite::Result<()> { + let tree = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE room = ? + AND NOT seen + AND id < ? + ORDER BY id DESC + LIMIT 1 + ", + )? + .query_row(params![room, id], |row| row.get(0)) + .optional()?; + let _ = result.send(tree); + Ok(()) + } + + fn get_newer_unseen_msg_id( + conn: &Connection, + room: String, + id: Snowflake, + result: oneshot::Sender>, + ) -> rusqlite::Result<()> { + let tree = conn + .prepare( + " + SELECT id + FROM euph_msgs + WHERE room = ? + AND NOT seen + AND id > ? + ORDER BY id ASC + LIMIT 1 + ", + )? + .query_row(params![room, id], |row| row.get(0)) + .optional()?; + let _ = result.send(tree); + Ok(()) + } + fn set_seen( conn: &Connection, room: String, From 0490ce394d4b7bb83b7d221177709cdbd2143454 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 17:24:44 +0200 Subject: [PATCH 026/443] Improve unseen cursor movement performance It's only really noticeable when pressing H at the first unseen message --- src/vault/migrate.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vault/migrate.rs b/src/vault/migrate.rs index 7fb43c8..cbb4f6b 100644 --- a/src/vault/migrate.rs +++ b/src/vault/migrate.rs @@ -85,7 +85,10 @@ fn m2(tx: &mut Transaction<'_>) -> rusqlite::Result<()> { tx.execute_batch( " ALTER TABLE euph_msgs - ADD COLUMN seen INTEGER NOT NULL DEFAULT TRUE + ADD COLUMN seen INTEGER NOT NULL DEFAULT TRUE; + + CREATE INDEX euph_idx_msgs_room_id_seen + ON euph_msgs (room, id, seen); ", ) } From 9e99c0706aa791627fa8270673cbce026037d203 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 17:32:25 +0200 Subject: [PATCH 027/443] Improve mark-older-as-unseen performance --- src/vault/euph.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/vault/euph.rs b/src/vault/euph.rs index f9efb8f..f24ecd1 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -1222,15 +1222,13 @@ impl EuphRequest { id: Snowflake, seen: bool, ) -> rusqlite::Result<()> { - // TODO Speed up this update - // Maybe with an index on (room, id, seen) and a filter to only set seen - // where it isn't already set correctly? conn.execute( " UPDATE euph_msgs SET seen = :seen WHERE room = :room AND id <= :id + AND seen != :seen ", named_params! { ":room": room, ":id": id, ":seen": seen }, )?; From db7abaf0001850f1bb2e5a7137f17769555a0549 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 17:34:14 +0200 Subject: [PATCH 028/443] Update changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b721b1a..8d3064e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ Procedure when bumping the version number: ## Unreleased +### Added +- New messages are now marked as unseen +- Key bindings for navigating unseen messages +- Key bindings for marking messages as seen/unseen + ### Changed - Improved editor key bindings - Slowed down room history download speed From e00ce4ebbac2a0b817195d77b81f78d2fe4afed3 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 21:29:15 +0200 Subject: [PATCH 029/443] Warn about possible vault corruption --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 457090e..7cdfd17 100644 --- a/README.md +++ b/README.md @@ -33,19 +33,25 @@ $ rustup update ### Installing cove To install or update to the latest release of cove, run the following command: + ```bash $ cargo install --force --git https://github.com/Garmelon/cove --branch latest ``` If you like to live dangerously and want to install or update to the latest, bleeding-edge, possibly-broken commit from the repo's main branch, run the -following command: +following command. + +**Warning:** This could corrupt your vault. Make sure to make a backup before +running the command. + ```bash $ cargo install --force --git https://github.com/Garmelon/cove ``` To install a specific version of cove, run the following command and substitute in the full version you want to install: + ```bash $ cargo install --force --git https://github.com/Garmelon/cove --tag v0.1.0 ``` From 888870b7798866eb6465be2adfe490843aa306f7 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 8 Aug 2022 23:14:58 +0200 Subject: [PATCH 030/443] Show unseen message count in room list --- src/logger.rs | 4 ++++ src/store.rs | 1 + src/ui/room.rs | 8 +++++++ src/ui/rooms.rs | 60 +++++++++++++++++++++++++++++++++-------------- src/vault/euph.rs | 37 +++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/logger.rs b/src/logger.rs index 127811b..eb4b10b 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -139,6 +139,10 @@ impl MsgStore for Logger { None } + async fn unseen_msgs_count(&self) -> usize { + 0 + } + async fn set_seen(&self, _id: &usize, _seen: bool) {} async fn set_older_seen(&self, _id: &usize, _seen: bool) {} diff --git a/src/store.rs b/src/store.rs index 2a3c482..aba9279 100644 --- a/src/store.rs +++ b/src/store.rs @@ -133,6 +133,7 @@ pub trait MsgStore { async fn newest_unseen_msg_id(&self) -> Option; async fn older_unseen_msg_id(&self, id: &M::Id) -> Option; async fn newer_unseen_msg_id(&self, id: &M::Id) -> Option; + async fn unseen_msgs_count(&self) -> usize; async fn set_seen(&self, id: &M::Id, seen: bool); async fn set_older_seen(&self, id: &M::Id, seen: bool); } diff --git a/src/ui/room.rs b/src/ui/room.rs index 615d2f6..8bf4a2f 100644 --- a/src/ui/room.rs +++ b/src/ui/room.rs @@ -11,6 +11,7 @@ use toss::terminal::Terminal; use crate::euph::api::{SessionType, SessionView, Snowflake}; use crate::euph::{self, Joined, Status}; +use crate::store::MsgStore; use crate::vault::EuphVault; use super::chat::{ChatState, Reaction}; @@ -36,6 +37,7 @@ enum State { pub struct EuphRoom { ui_event_tx: mpsc::UnboundedSender, + vault: EuphVault, room: Option, state: State, @@ -50,6 +52,7 @@ impl EuphRoom { pub fn new(vault: EuphVault, ui_event_tx: mpsc::UnboundedSender) -> Self { Self { ui_event_tx, + vault: vault.clone(), room: None, state: State::Normal, chat: ChatState::new(vault), @@ -91,6 +94,10 @@ impl EuphRoom { } } + pub async fn unseen_msgs_count(&self) -> usize { + self.vault.unseen_msgs_count().await + } + async fn stabilize_pseudo_msg(&mut self) { if let Some(id_rx) = &mut self.last_msg_sent { match id_rx.try_recv() { @@ -169,6 +176,7 @@ impl EuphRoom { } fn status_widget(&self, status: &Option>) -> BoxedWidget { + // TODO Include unread message count let room = self.chat.store().room(); let room_style = ContentStyle::default().bold().blue(); let mut info = Styled::new(format!("&{room}"), room_style); diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs index b2890af..f4b7c90 100644 --- a/src/ui/rooms.rs +++ b/src/ui/rooms.rs @@ -154,12 +154,44 @@ impl Rooms { result.join(" ") } - fn format_status(status: &Option) -> String { - match status { - None => " (connecting)".to_string(), - Some(Status::Joining(j)) if j.bounce.is_some() => " (auth required)".to_string(), - Some(Status::Joining(_)) => " (joining)".to_string(), - Some(Status::Joined(j)) => format!(" ({})", Self::format_pbln(j)), + async fn format_status(room: &EuphRoom) -> Option { + match room.status().await { + None => None, + Some(None) => Some("connecting".to_string()), + Some(Some(Status::Joining(j))) if j.bounce.is_some() => { + Some("auth required".to_string()) + } + Some(Some(Status::Joining(_))) => Some("joining".to_string()), + Some(Some(Status::Joined(joined))) => Some(Self::format_pbln(&joined)), + } + } + + async fn format_unseen_msgs(room: &EuphRoom) -> Option { + let unseen = room.unseen_msgs_count().await; + if unseen == 0 { + None + } else { + Some(format!("{unseen}")) + } + } + + async fn format_room_info(room: &EuphRoom) -> Styled { + let unseen_style = ContentStyle::default().bold().green(); + + let status = Self::format_status(room).await; + let unseen = Self::format_unseen_msgs(room).await; + + match (status, unseen) { + (None, None) => Styled::default(), + (None, Some(u)) => Styled::new_plain(" (") + .then(&u, unseen_style) + .then_plain(")"), + (Some(s), None) => Styled::new_plain(" (").then_plain(&s).then_plain(")"), + (Some(s), Some(u)) => Styled::new_plain(" (") + .then_plain(&s) + .then_plain(", ") + .then(&u, unseen_style) + .then_plain(")"), } } @@ -176,26 +208,18 @@ impl Rooms { } for room in rooms { - let bg_style = ContentStyle::default(); - let bg_sel_style = ContentStyle::default().black().on_white(); let room_style = ContentStyle::default().bold().blue(); let room_sel_style = ContentStyle::default().bold().black().on_white(); let mut normal = Styled::new(format!("&{room}"), room_style); let mut selected = Styled::new(format!("&{room}"), room_sel_style); if let Some(room) = self.euph_rooms.get(&room) { - if let Some(status) = room.status().await { - let status = Self::format_status(&status); - normal = normal.then(status.clone(), bg_style); - selected = selected.then(status, bg_sel_style); - } + let info = Self::format_room_info(room).await; + normal = normal.and_then(info.clone()); + selected = selected.and_then(info); }; - list.add_sel( - room, - Text::new(normal), - Background::new(Text::new(selected)).style(bg_sel_style), - ); + list.add_sel(room, Text::new(normal), Text::new(selected)); } } diff --git a/src/vault/euph.rs b/src/vault/euph.rs index f24ecd1..1e9df1b 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -305,6 +305,17 @@ impl MsgStore for EuphVault { rx.await.unwrap() } + async fn unseen_msgs_count(&self) -> usize { + // TODO vault::Error + let (tx, rx) = oneshot::channel(); + let request = EuphRequest::GetUnseenMsgsCount { + room: self.room.clone(), + result: tx, + }; + let _ = self.vault.tx.send(request.into()); + rx.await.unwrap() + } + async fn set_seen(&self, id: &Snowflake, seen: bool) { let request = EuphRequest::SetSeen { room: self.room.clone(), @@ -432,6 +443,10 @@ pub(super) enum EuphRequest { id: Snowflake, result: oneshot::Sender>, }, + GetUnseenMsgsCount { + room: String, + result: oneshot::Sender, + }, SetSeen { room: String, id: Snowflake, @@ -503,6 +518,9 @@ impl EuphRequest { EuphRequest::GetNewerUnseenMsgId { room, id, result } => { Self::get_newer_unseen_msg_id(conn, room, id, result) } + EuphRequest::GetUnseenMsgsCount { room, result } => { + Self::get_unseen_msgs_count(conn, room, result) + } EuphRequest::SetSeen { room, id, seen } => Self::set_seen(conn, room, id, seen), EuphRequest::SetOlderSeen { room, id, seen } => { Self::set_older_seen(conn, room, id, seen) @@ -1198,6 +1216,25 @@ impl EuphRequest { Ok(()) } + fn get_unseen_msgs_count( + conn: &Connection, + room: String, + result: oneshot::Sender, + ) -> rusqlite::Result<()> { + let amount = conn + .prepare( + " + SELECT COUNT(*) + FROM euph_msgs + WHERE room = ? + AND NOT seen + ", + )? + .query_row(params![room], |row| row.get(0))?; + let _ = result.send(amount); + Ok(()) + } + fn set_seen( conn: &Connection, room: String, From 453233be9c79abe6eecbdbe93f613b427e1a6012 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 00:28:11 +0200 Subject: [PATCH 031/443] Cache unseen message count --- src/vault/euph.rs | 5 ++-- src/vault/prepare.rs | 71 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/vault/euph.rs b/src/vault/euph.rs index 1e9df1b..2517ba9 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -1224,10 +1224,9 @@ impl EuphRequest { let amount = conn .prepare( " - SELECT COUNT(*) - FROM euph_msgs + SELECT amount + FROM euph_unseen_counts WHERE room = ? - AND NOT seen ", )? .query_row(params![room], |row| row.get(0))?; diff --git a/src/vault/prepare.rs b/src/vault/prepare.rs index 8f55fb0..8a9e67b 100644 --- a/src/vault/prepare.rs +++ b/src/vault/prepare.rs @@ -2,6 +2,7 @@ use rusqlite::Connection; pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { println!("Opening vault"); + // This temporary table has no foreign key constraint on euph_rooms since // cross-schema constraints like that are not supported by SQLite. conn.execute_batch( @@ -28,5 +29,73 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { AND parents.id = euph_msgs.parent ); ", - ) + )?; + + // Cache amount of unseen messages per room because counting them takes far + // too long. Uses triggers to move as much of the updating logic as possible + // into SQLite. + conn.execute_batch( + " + CREATE TEMPORARY TABLE euph_unseen_counts ( + room TEXT NOT NULL, + amount INTEGER NOT NULL, + + PRIMARY KEY (room) + ) STRICT; + + -- There must be an entry for every existing room. + INSERT INTO euph_unseen_counts (room, amount) + SELECT room, 0 + FROM euph_rooms; + + INSERT OR REPLACE INTO euph_unseen_counts (room, amount) + SELECT room, COUNT(*) + FROM euph_msgs + WHERE NOT seen + GROUP BY room; + + CREATE TEMPORARY TRIGGER euc_insert_room + AFTER INSERT ON euph_rooms + BEGIN + INSERT INTO euph_unseen_counts (room, amount) + VALUES (new.room, 0); + END; + + CREATE TEMPORARY TRIGGER euc_delete_room + AFTER DELETE ON euph_rooms + BEGIN + DELETE FROM euph_unseen_counts + WHERE room = old.room; + END; + + CREATE TEMPORARY TRIGGER euc_insert_msg + AFTER INSERT ON euph_msgs + WHEN NOT new.seen + BEGIN + UPDATE euph_unseen_counts + SET amount = amount + 1 + WHERE room = new.room; + END; + + CREATE TEMPORARY TRIGGER euc_update_msg + AFTER UPDATE OF seen ON euph_msgs + WHEN old.seen != new.seen + BEGIN + UPDATE euph_unseen_counts + SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END + WHERE room = new.room; + END; + + CREATE TEMPORARY TRIGGER euc_delete_msg + AFTER DELETE ON euph_msgs + WHEN NOT old.seen + BEGIN + UPDATE euph_unseen_counts + SET amount = amount - 1 + WHERE room = old.room; + END; + ", + )?; + + Ok(()) } From 9314e29b0eedb629c7ab9e8ef7370ca8729e05f8 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 00:28:28 +0200 Subject: [PATCH 032/443] Fix unseen message count not appearing initially When launching cove, the euph_rooms hash map would be empty until interacting with a room for the first time. This led to the unseen message count only being displayed after interacting with a room. Now, missing rooms are inserted into euph_rooms during stabilization. --- src/ui/rooms.rs | 92 +++++++++++++++++++++++++------------------------ 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs index f4b7c90..574f544 100644 --- a/src/ui/rooms.rs +++ b/src/ui/rooms.rs @@ -54,7 +54,14 @@ impl Rooms { } } + fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom { + self.euph_rooms + .entry(name.clone()) + .or_insert_with(|| EuphRoom::new(self.vault.euph(name), self.ui_event_tx.clone())) + } + /// Remove rooms that are not running any more and can't be found in the db. + /// Insert rooms that are in the db but not yet in in the hash map. /// /// These kinds of rooms are either /// - failed connection attempts, or @@ -69,27 +76,11 @@ impl Rooms { self.euph_rooms .retain(|n, r| !r.stopped() || rooms_set.contains(n)); - for room in self.euph_rooms.values_mut() { - room.retain(); + for room in rooms_set { + self.get_or_insert_room(room).retain(); } } - async fn room_names(&self) -> Vec { - let mut rooms = self.vault.euph_rooms().await; - for room in self.euph_rooms.keys() { - rooms.push(room.clone()); - } - rooms.sort_unstable(); - rooms.dedup(); - rooms - } - - fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom { - self.euph_rooms - .entry(name.clone()) - .or_insert_with(|| EuphRoom::new(self.vault.euph(name), self.ui_event_tx.clone())) - } - pub async fn widget(&mut self) -> BoxedWidget { match &self.state { State::ShowRoom(_) => {} @@ -98,7 +89,13 @@ impl Rooms { match &self.state { State::ShowList => self.rooms_widget().await, - State::ShowRoom(name) => self.get_or_insert_room(name.clone()).widget().await, + State::ShowRoom(name) => { + self.euph_rooms + .get_mut(name) + .expect("room exists after stabilization") + .widget() + .await + } State::Connect(ed) => { let room_style = ContentStyle::default().bold().blue(); Layer::new(vec![ @@ -195,38 +192,39 @@ impl Rooms { } } - async fn render_rows(&self, list: &mut List, rooms: Vec) { + async fn render_rows(&self, list: &mut List) { let heading_style = ContentStyle::default().bold(); - let heading = Styled::new("Rooms", heading_style).then_plain(format!(" ({})", rooms.len())); + let amount = self.euph_rooms.len(); + let heading = Styled::new("Rooms", heading_style).then_plain(format!(" ({amount})")); list.add_unsel(Text::new(heading)); - if rooms.is_empty() { + if self.euph_rooms.is_empty() { list.add_unsel(Text::new(( "Press F1 for key bindings", ContentStyle::default().grey().italic(), ))) } - for room in rooms { + let mut rooms = self.euph_rooms.iter().collect::>(); + rooms.sort_by_key(|(n, _)| *n); + for (name, room) in rooms { let room_style = ContentStyle::default().bold().blue(); let room_sel_style = ContentStyle::default().bold().black().on_white(); - let mut normal = Styled::new(format!("&{room}"), room_style); - let mut selected = Styled::new(format!("&{room}"), room_sel_style); - if let Some(room) = self.euph_rooms.get(&room) { - let info = Self::format_room_info(room).await; - normal = normal.and_then(info.clone()); - selected = selected.and_then(info); - }; + let mut normal = Styled::new(format!("&{name}"), room_style); + let mut selected = Styled::new(format!("&{name}"), room_sel_style); - list.add_sel(room, Text::new(normal), Text::new(selected)); + let info = Self::format_room_info(room).await; + normal = normal.and_then(info.clone()); + selected = selected.and_then(info); + + list.add_sel(name.clone(), Text::new(normal), Text::new(selected)); } } async fn rooms_widget(&self) -> BoxedWidget { - let rooms = self.room_names().await; let mut list = self.list.widget().focus(true); - self.render_rows(&mut list, rooms).await; + self.render_rows(&mut list).await; list.into() } @@ -278,6 +276,8 @@ impl Rooms { crossterm_lock: &Arc>, event: KeyEvent, ) -> bool { + self.stabilize_rooms().await; + match &self.state { State::ShowList => match event { key!('k') | key!(Up) => self.list.move_cursor_up(), @@ -294,13 +294,17 @@ impl Rooms { } key!('c') => { if let Some(name) = self.list.cursor() { - self.get_or_insert_room(name).connect(); + if let Some(room) = self.euph_rooms.get_mut(&name) { + room.connect(); + } } } key!('C') => self.state = State::Connect(EditorState::new()), key!('d') => { if let Some(name) = self.list.cursor() { - self.get_or_insert_room(name).disconnect(); + if let Some(room) = self.euph_rooms.get_mut(&name) { + room.disconnect(); + } } } key!('D') => { @@ -313,17 +317,15 @@ impl Rooms { _ => return false, }, State::ShowRoom(name) => { - if self - .get_or_insert_room(name.clone()) - .handle_key_event(terminal, crossterm_lock, event) - .await - { - return true; - } + if let Some(room) = self.euph_rooms.get_mut(name) { + if room.handle_key_event(terminal, crossterm_lock, event).await { + return true; + } - if let key!(Esc) = event { - self.state = State::ShowList; - return true; + if let key!(Esc) = event { + self.state = State::ShowList; + return true; + } } return false; From fa7d904932a863aa12c353b85ca1f5ceea9d2f3e Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 00:50:21 +0200 Subject: [PATCH 033/443] Fix formatting --- src/vault/euph.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vault/euph.rs b/src/vault/euph.rs index 2517ba9..bc08c13 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -731,9 +731,9 @@ impl EuphRequest { if let Some(parent) = msg.parent { delete_trees.execute(params![room, msg.id])?; - insert_trees.execute(named_params! {":room": room,":id": parent})?; + insert_trees.execute(named_params! {":room": room, ":id": parent})?; } else { - insert_trees.execute(named_params! {":room": room,":id": msg.id})?; + insert_trees.execute(named_params! {":room": room, ":id": msg.id})?; } } From 84d0bc2bcab86fef06e16b7ba9a2bd1cbc73b0c5 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 00:54:03 +0200 Subject: [PATCH 034/443] Follow sqlite advice for temp triggers --- src/vault/prepare.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/vault/prepare.rs b/src/vault/prepare.rs index 8a9e67b..0875db7 100644 --- a/src/vault/prepare.rs +++ b/src/vault/prepare.rs @@ -55,21 +55,21 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { GROUP BY room; CREATE TEMPORARY TRIGGER euc_insert_room - AFTER INSERT ON euph_rooms + AFTER INSERT ON main.euph_rooms BEGIN INSERT INTO euph_unseen_counts (room, amount) VALUES (new.room, 0); END; CREATE TEMPORARY TRIGGER euc_delete_room - AFTER DELETE ON euph_rooms + AFTER DELETE ON main.euph_rooms BEGIN DELETE FROM euph_unseen_counts WHERE room = old.room; END; CREATE TEMPORARY TRIGGER euc_insert_msg - AFTER INSERT ON euph_msgs + AFTER INSERT ON main.euph_msgs WHEN NOT new.seen BEGIN UPDATE euph_unseen_counts @@ -78,7 +78,7 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { END; CREATE TEMPORARY TRIGGER euc_update_msg - AFTER UPDATE OF seen ON euph_msgs + AFTER UPDATE OF seen ON main.euph_msgs WHEN old.seen != new.seen BEGIN UPDATE euph_unseen_counts @@ -87,7 +87,7 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { END; CREATE TEMPORARY TRIGGER euc_delete_msg - AFTER DELETE ON euph_msgs + AFTER DELETE ON main.euph_msgs WHEN NOT old.seen BEGIN UPDATE euph_unseen_counts From 8a28ba7b6eb43f68cc49ac3e175f626162550689 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 01:09:20 +0200 Subject: [PATCH 035/443] Move euph_trees logic into sqlite triggers --- src/vault/euph.rs | 35 ----------------------------------- src/vault/prepare.rs | 38 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/src/vault/euph.rs b/src/vault/euph.rs index bc08c13..642f3c3 100644 --- a/src/vault/euph.rs +++ b/src/vault/euph.rs @@ -624,14 +624,6 @@ impl EuphRequest { [&room], )?; - tx.execute( - " - DELETE FROM euph_trees - WHERE room = ? - ", - [&room], - )?; - tx.commit()?; Ok(()) } @@ -683,26 +675,6 @@ impl EuphRequest { real_client_address = :real_client_address " )?; - let mut delete_trees = tx.prepare( - " - DELETE FROM euph_trees - WHERE room = ? AND id = ? - ", - )?; - let mut insert_trees = tx.prepare( - " - INSERT OR IGNORE INTO euph_trees (room, id) - SELECT * - FROM (VALUES (:room, :id)) - WHERE NOT EXISTS( - SELECT * - FROM euph_msgs - WHERE room = :room - AND id = :id - AND parent IS NOT NULL - ) - ", - )?; let own_user_id = own_user_id.as_ref().map(|u| &u.0); for msg in msgs { @@ -728,13 +700,6 @@ impl EuphRequest { ":real_client_address": msg.sender.real_client_address, ":own_user_id": own_user_id, // May be NULL })?; - - if let Some(parent) = msg.parent { - delete_trees.execute(params![room, msg.id])?; - insert_trees.execute(named_params! {":room": room, ":id": parent})?; - } else { - insert_trees.execute(named_params! {":room": room, ":id": msg.id})?; - } } Ok(()) diff --git a/src/vault/prepare.rs b/src/vault/prepare.rs index 0875db7..75f351f 100644 --- a/src/vault/prepare.rs +++ b/src/vault/prepare.rs @@ -3,8 +3,7 @@ use rusqlite::Connection; pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { println!("Opening vault"); - // This temporary table has no foreign key constraint on euph_rooms since - // cross-schema constraints like that are not supported by SQLite. + // Cache ids of tree roots. conn.execute_batch( " CREATE TEMPORARY TABLE euph_trees ( @@ -28,6 +27,41 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { WHERE parents.room = euph_msgs.room AND parents.id = euph_msgs.parent ); + + CREATE TEMPORARY TRIGGER et_delete_room + AFTER DELETE ON main.euph_rooms + BEGIN + DELETE FROM euph_trees + WHERE room = old.room; + END; + + CREATE TEMPORARY TRIGGER et_insert_msg_without_parent + AFTER INSERT ON main.euph_msgs + WHEN new.parent IS NULL + BEGIN + INSERT OR IGNORE INTO euph_trees (room, id) + VALUES (new.room, new.id); + END; + + CREATE TEMPORARY TRIGGER et_insert_msg_with_parent + AFTER INSERT ON main.euph_msgs + WHEN new.parent IS NOT NULL + BEGIN + DELETE FROM euph_trees + WHERE room = new.room + AND id = new.id; + + INSERT OR IGNORE INTO euph_trees (room, id) + SELECT * + FROM (VALUES (new.room, new.parent)) + WHERE NOT EXISTS( + SELECT * + FROM euph_msgs + WHERE room = new.room + AND id = new.parent + AND parent IS NOT NULL + ); + END; ", )?; From f17d4459d1a01d9dc813c1bdca9b981bbf9b4257 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 01:09:27 +0200 Subject: [PATCH 036/443] Remove unnecessary trigger --- src/vault/prepare.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/vault/prepare.rs b/src/vault/prepare.rs index 75f351f..fc45551 100644 --- a/src/vault/prepare.rs +++ b/src/vault/prepare.rs @@ -65,9 +65,7 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { ", )?; - // Cache amount of unseen messages per room because counting them takes far - // too long. Uses triggers to move as much of the updating logic as possible - // into SQLite. + // Cache amount of unseen messages per room. conn.execute_batch( " CREATE TEMPORARY TABLE euph_unseen_counts ( @@ -119,15 +117,6 @@ pub fn prepare(conn: &mut Connection) -> rusqlite::Result<()> { SET amount = CASE WHEN new.seen THEN amount - 1 ELSE amount + 1 END WHERE room = new.room; END; - - CREATE TEMPORARY TRIGGER euc_delete_msg - AFTER DELETE ON main.euph_msgs - WHEN NOT old.seen - BEGIN - UPDATE euph_unseen_counts - SET amount = amount - 1 - WHERE room = old.room; - END; ", )?; From 26923745adf0a20e745b4f18064b8367f7f1af00 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 01:18:12 +0200 Subject: [PATCH 037/443] Show unseen message count in room status info --- src/ui/room.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/ui/room.rs b/src/ui/room.rs index 8bf4a2f..65619d5 100644 --- a/src/ui/room.rs +++ b/src/ui/room.rs @@ -119,8 +119,8 @@ impl EuphRoom { let status = self.status().await; let chat = match &status { - Some(Some(Status::Joined(joined))) => self.widget_with_nick_list(&status, joined), - _ => self.widget_without_nick_list(&status), + Some(Some(Status::Joined(joined))) => self.widget_with_nick_list(&status, joined).await, + _ => self.widget_without_nick_list(&status).await, }; match &self.state { State::Normal => chat, @@ -144,10 +144,10 @@ impl EuphRoom { } } - fn widget_without_nick_list(&self, status: &Option>) -> BoxedWidget { + async fn widget_without_nick_list(&self, status: &Option>) -> BoxedWidget { VJoin::new(vec![ Segment::new(Border::new( - Padding::new(self.status_widget(status)).horizontal(1), + Padding::new(self.status_widget(status).await).horizontal(1), )), // TODO Use last known nick? Segment::new(self.chat.widget(String::new())).expanding(true), @@ -155,7 +155,7 @@ impl EuphRoom { .into() } - fn widget_with_nick_list( + async fn widget_with_nick_list( &self, status: &Option>, joined: &Joined, @@ -163,7 +163,7 @@ impl EuphRoom { HJoin::new(vec![ Segment::new(VJoin::new(vec![ Segment::new(Border::new( - Padding::new(self.status_widget(status)).horizontal(1), + Padding::new(self.status_widget(status).await).horizontal(1), )), Segment::new(self.chat.widget(joined.session.name.clone())).expanding(true), ])) @@ -175,11 +175,12 @@ impl EuphRoom { .into() } - fn status_widget(&self, status: &Option>) -> BoxedWidget { + async fn status_widget(&self, status: &Option>) -> BoxedWidget { // TODO Include unread message count let room = self.chat.store().room(); let room_style = ContentStyle::default().bold().blue(); let mut info = Styled::new(format!("&{room}"), room_style); + info = match status { None => info.then_plain(", archive"), Some(None) => info.then_plain(", connecting..."), @@ -197,6 +198,15 @@ impl EuphRoom { } } }; + + let unseen = self.unseen_msgs_count().await; + if unseen > 0 { + info = info + .then_plain(" (") + .then(format!("{unseen}"), ContentStyle::default().bold().green()) + .then_plain(")"); + } + Text::new(info).into() } From 0ad3432141b3888960f8f087db3d540c6c66555e Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 14:59:41 +0200 Subject: [PATCH 038/443] Fold subtrees --- src/store.rs | 4 ++++ src/ui/chat/tree.rs | 16 ++++++++++++++-- src/ui/chat/tree/layout.rs | 22 ++++++++++++++++++---- src/ui/chat/tree/widgets.rs | 37 +++++++++++++++++++++++++++++++++---- 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/store.rs b/src/store.rs index aba9279..89bb530 100644 --- a/src/store.rs +++ b/src/store.rs @@ -23,6 +23,10 @@ impl Path { Self(segments) } + pub fn parent_segments(&self) -> impl Iterator { + self.0.iter().take(self.0.len() - 1) + } + pub fn push(&mut self, segment: I) { self.0.push(segment) } diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index ee87a86..8ab9f2a 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -3,6 +3,7 @@ mod layout; mod tree_blocks; mod widgets; +use std::collections::HashSet; use std::sync::Arc; use async_trait::async_trait; @@ -39,13 +40,14 @@ struct InnerTreeViewState> { last_visible_msgs: Vec, cursor: Cursor, + editor: EditorState, /// Scroll the view on the next render. Positive values scroll up and /// negative values scroll down. scroll: i32, correction: Option, - editor: EditorState, + folded: HashSet, } impl> InnerTreeViewState { @@ -56,9 +58,10 @@ impl> InnerTreeViewState { last_cursor_line: 0, last_visible_msgs: vec![], cursor: Cursor::Bottom, + editor: EditorState::new(), scroll: 0, correction: None, - editor: EditorState::new(), + folded: HashSet::new(), } } @@ -98,6 +101,7 @@ impl> InnerTreeViewState { } pub fn list_action_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.binding("space", "fold current message's subtree"); bindings.binding("s", "toggle current message's seen status"); bindings.binding("S", "mark all visible messages as seen"); bindings.binding("ctrl+s", "mark all older messages as seen"); @@ -105,6 +109,14 @@ impl> InnerTreeViewState { async fn handle_action_key_event(&mut self, event: KeyEvent, id: Option<&M::Id>) -> bool { match event { + key!(' ') => { + if let Some(id) = id { + if !self.folded.remove(id) { + self.folded.insert(id.clone()); + } + return true; + } + } key!('s') => { if let Some(id) = id { if let Some(msg) = self.store.tree(id).await.msg(id) { diff --git a/src/ui/chat/tree/layout.rs b/src/ui/chat/tree/layout.rs index 6fb4911..32f8ecf 100644 --- a/src/ui/chat/tree/layout.rs +++ b/src/ui/chat/tree/layout.rs @@ -38,6 +38,12 @@ impl> InnerTreeViewState { } } + fn make_path_visible(&mut self, path: &Path) { + for segment in path.parent_segments() { + self.folded.remove(segment); + } + } + fn cursor_line(&self, blocks: &TreeBlocks) -> i32 { if let Cursor::Bottom = self.cursor { // The value doesn't matter as it will always be ignored. @@ -84,18 +90,25 @@ impl> InnerTreeViewState { blocks.blocks_mut().push_back(block); } + let folded = self.folded.contains(id); + let children = tree.children(id); + let folded_info = children + .filter(|_| folded) + .map(|c| c.len()) + .filter(|c| *c > 0); + // Main message body let highlighted = self.cursor.refers_to(id); let widget = if let Some(msg) = tree.msg(id) { - widgets::msg(highlighted, indent, msg) + widgets::msg(highlighted, indent, msg, folded_info) } else { - widgets::msg_placeholder(highlighted, indent) + widgets::msg_placeholder(highlighted, indent, folded_info) }; let block = Block::new(frame, BlockId::Msg(id.clone()), widget); blocks.blocks_mut().push_back(block); - // Children, recursively - if let Some(children) = tree.children(id) { + // Children recursively (if not folded) + if let Some(children) = children.filter(|_| !folded) { for child in children { self.layout_subtree(nick, frame, tree, indent + 1, child, blocks); } @@ -458,6 +471,7 @@ impl> InnerTreeViewState { let last_cursor_path = self.cursor_path(&self.last_cursor).await; let cursor_path = self.cursor_path(&self.cursor).await; + self.make_path_visible(&cursor_path); let mut blocks = self .layout_initial_seed(nick, frame, &last_cursor_path, &cursor_path) diff --git a/src/ui/chat/tree/widgets.rs b/src/ui/chat/tree/widgets.rs index b39bd5c..2ba3d14 100644 --- a/src/ui/chat/tree/widgets.rs +++ b/src/ui/chat/tree/widgets.rs @@ -6,6 +6,7 @@ mod time; use crossterm::style::{ContentStyle, Stylize}; use toss::frame::Frame; +use toss::styled::Styled; use super::super::ChatMsg; use crate::store::Msg; @@ -40,6 +41,10 @@ fn style_indent(highlighted: bool) -> ContentStyle { } } +fn style_info() -> ContentStyle { + ContentStyle::default().italic().dark_grey() +} + fn style_editor_highlight() -> ContentStyle { ContentStyle::default().black().on_cyan() } @@ -48,8 +53,20 @@ fn style_pseudo_highlight() -> ContentStyle { ContentStyle::default().black().on_yellow() } -pub fn msg(highlighted: bool, indent: usize, msg: &M) -> BoxedWidget { - let (nick, content) = msg.styled(); +pub fn msg( + highlighted: bool, + indent: usize, + msg: &M, + folded_info: Option, +) -> BoxedWidget { + let (nick, mut content) = msg.styled(); + + if let Some(amount) = folded_info { + content = content + .then_plain("\n") + .then(format!("[{amount} more]"), style_info()); + } + HJoin::new(vec![ Segment::new(seen::widget(msg.seen())), Segment::new( @@ -69,7 +86,19 @@ pub fn msg(highlighted: bool, indent: usize, msg: &M) -> Boxed .into() } -pub fn msg_placeholder(highlighted: bool, indent: usize) -> BoxedWidget { +pub fn msg_placeholder( + highlighted: bool, + indent: usize, + folded_info: Option, +) -> BoxedWidget { + let mut content = Styled::new(PLACEHOLDER, style_placeholder()); + + if let Some(amount) = folded_info { + content = content + .then_plain("\n") + .then(format!("[{amount} more]"), style_info()); + } + HJoin::new(vec![ Segment::new(seen::widget(true)), Segment::new( @@ -78,7 +107,7 @@ pub fn msg_placeholder(highlighted: bool, indent: usize) -> BoxedWidget { .right(1), ), Segment::new(Indent::new(indent, style_indent(highlighted))), - Segment::new(Text::new((PLACEHOLDER, style_placeholder()))), + Segment::new(Text::new(content)), ]) .into() } From 87a14eedf29b2a732dd25e8d57ddc2ede7bcc812 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 15:07:37 +0200 Subject: [PATCH 039/443] Move cursor over folded subtrees --- src/ui/chat/tree/cursor.rs | 44 +++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/ui/chat/tree/cursor.rs b/src/ui/chat/tree/cursor.rs index a2f940d..bc98357 100644 --- a/src/ui/chat/tree/cursor.rs +++ b/src/ui/chat/tree/cursor.rs @@ -1,5 +1,7 @@ //! Moving the cursor around. +use std::collections::HashSet; + use crate::store::{Msg, MsgStore, Tree}; use super::{Correction, InnerTreeViewState}; @@ -63,7 +65,11 @@ impl> InnerTreeViewState { } } - fn find_first_child(tree: &Tree, id: &mut M::Id) -> bool { + fn find_first_child(folded: &HashSet, tree: &Tree, id: &mut M::Id) -> bool { + if folded.contains(id) { + return false; + } + if let Some(child) = tree.children(id).and_then(|c| c.first()) { *id = child.clone(); true @@ -72,7 +78,11 @@ impl> InnerTreeViewState { } } - fn find_last_child(tree: &Tree, id: &mut M::Id) -> bool { + fn find_last_child(folded: &HashSet, tree: &Tree, id: &mut M::Id) -> bool { + if folded.contains(id) { + return false; + } + if let Some(child) = tree.children(id).and_then(|c| c.last()) { *id = child.clone(); true @@ -126,11 +136,16 @@ impl> InnerTreeViewState { } /// Move to the previous message, or don't move if this is not possible. - async fn find_prev_msg(store: &S, tree: &mut Tree, id: &mut M::Id) -> bool { + async fn find_prev_msg( + store: &S, + folded: &HashSet, + tree: &mut Tree, + id: &mut M::Id, + ) -> bool { // Move to previous sibling, then to its last child // If not possible, move to parent if Self::find_prev_sibling(store, tree, id).await { - while Self::find_last_child(tree, id) {} + while Self::find_last_child(folded, tree, id) {} true } else { Self::find_parent(tree, id) @@ -138,8 +153,13 @@ impl> InnerTreeViewState { } /// Move to the next message, or don't move if this is not possible. - async fn find_next_msg(store: &S, tree: &mut Tree, id: &mut M::Id) -> bool { - if Self::find_first_child(tree, id) { + async fn find_next_msg( + store: &S, + folded: &HashSet, + tree: &mut Tree, + id: &mut M::Id, + ) -> bool { + if Self::find_first_child(folded, tree, id) { return true; } @@ -166,14 +186,14 @@ impl> InnerTreeViewState { if let Some(last_tree_id) = self.store.last_tree_id().await { let tree = self.store.tree(&last_tree_id).await; let mut id = last_tree_id; - while Self::find_last_child(&tree, &mut id) {} + while Self::find_last_child(&self.folded, &tree, &mut id) {} self.cursor = Cursor::Msg(id); } } Cursor::Msg(ref mut msg) => { let path = self.store.path(msg).await; let mut tree = self.store.tree(path.first()).await; - Self::find_prev_msg(&self.store, &mut tree, msg).await; + Self::find_prev_msg(&self.store, &self.folded, &mut tree, msg).await; } Cursor::Editor { .. } => {} Cursor::Pseudo { @@ -182,7 +202,7 @@ impl> InnerTreeViewState { } => { let tree = self.store.tree(parent).await; let mut id = parent.clone(); - while Self::find_last_child(&tree, &mut id) {} + while Self::find_last_child(&self.folded, &tree, &mut id) {} self.cursor = Cursor::Msg(id); } } @@ -194,7 +214,7 @@ impl> InnerTreeViewState { Cursor::Msg(ref mut msg) => { let path = self.store.path(msg).await; let mut tree = self.store.tree(path.first()).await; - if !Self::find_next_msg(&self.store, &mut tree, msg).await { + if !Self::find_next_msg(&self.store, &self.folded, &mut tree, msg).await { self.cursor = Cursor::Bottom; } } @@ -207,9 +227,9 @@ impl> InnerTreeViewState { } => { let mut tree = self.store.tree(parent).await; let mut id = parent.clone(); - while Self::find_last_child(&tree, &mut id) {} + while Self::find_last_child(&self.folded, &tree, &mut id) {} // Now we're at the previous message - if Self::find_next_msg(&self.store, &mut tree, &mut id).await { + if Self::find_next_msg(&self.store, &self.folded, &mut tree, &mut id).await { self.cursor = Cursor::Msg(id); } else { self.cursor = Cursor::Bottom; From c41ab742d37051b9eef31f42b2f80e8a4b77c72c Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 15:12:49 +0200 Subject: [PATCH 040/443] Fix message count in folded info --- src/store.rs | 9 +++++++++ src/ui/chat/tree/layout.rs | 21 ++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/store.rs b/src/store.rs index 89bb530..24b95d0 100644 --- a/src/store.rs +++ b/src/store.rs @@ -94,6 +94,15 @@ impl Tree { self.children.get(id).map(|c| c as &[M::Id]) } + pub fn subtree_size(&self, id: &M::Id) -> usize { + let children = self.children(id).unwrap_or_default(); + let mut result = children.len(); + for child in children { + result += self.subtree_size(child); + } + result + } + pub fn siblings(&self, id: &M::Id) -> Option<&[M::Id]> { if let Some(parent) = self.parent(id) { self.children(&parent) diff --git a/src/ui/chat/tree/layout.rs b/src/ui/chat/tree/layout.rs index 32f8ecf..0122dcf 100644 --- a/src/ui/chat/tree/layout.rs +++ b/src/ui/chat/tree/layout.rs @@ -90,12 +90,13 @@ impl> InnerTreeViewState { blocks.blocks_mut().push_back(block); } + // Last part of message body if message is folded let folded = self.folded.contains(id); - let children = tree.children(id); - let folded_info = children - .filter(|_| folded) - .map(|c| c.len()) - .filter(|c| *c > 0); + let folded_info = if folded { + Some(tree.subtree_size(id)).filter(|s| *s > 0) + } else { + None + }; // Main message body let highlighted = self.cursor.refers_to(id); @@ -107,10 +108,12 @@ impl> InnerTreeViewState { let block = Block::new(frame, BlockId::Msg(id.clone()), widget); blocks.blocks_mut().push_back(block); - // Children recursively (if not folded) - if let Some(children) = children.filter(|_| !folded) { - for child in children { - self.layout_subtree(nick, frame, tree, indent + 1, child, blocks); + // Children, recursively + if !folded { + if let Some(children) = tree.children(id) { + for child in children { + self.layout_subtree(nick, frame, tree, indent + 1, child, blocks); + } } } From d65183e0aef96f1213552c6e05a74c5988bfea41 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 15:14:02 +0200 Subject: [PATCH 041/443] Update changelog --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3064e..e194222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,7 @@ Procedure when bumping the version number: ### Added - New messages are now marked as unseen -- Key bindings for navigating unseen messages -- Key bindings for marking messages as seen/unseen +- Sub-trees can now be folded ### Changed - Improved editor key bindings From a4b79d4e8118c0fb783d501b45f7d4d6760b2907 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 15:44:35 +0200 Subject: [PATCH 042/443] Move cursor to prev/next sibling --- CHANGELOG.md | 1 + src/ui/chat/tree.rs | 3 + src/ui/chat/tree/cursor.rs | 110 ++++++++++++++++--------------------- 3 files changed, 52 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e194222..7cd53ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Procedure when bumping the version number: ### Added - New messages are now marked as unseen - Sub-trees can now be folded +- Key bindings to move to prev/next sibling ### Changed - Improved editor key bindings diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index 8ab9f2a..e14a7c7 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -67,6 +67,7 @@ impl> InnerTreeViewState { pub fn list_movement_key_bindings(&self, bindings: &mut KeyBindingsList) { bindings.binding("j/k, ↓/↑", "move cursor up/down"); + bindings.binding("J/K, ctrl+↓/↑", "move cursor to prev/next sibling"); bindings.binding("h/l, ←/→", "move cursor chronologically"); bindings.binding("H/L, ctrl+←/→", "move cursor to prev/next unseen message"); bindings.binding("g, home", "move cursor to top"); @@ -82,6 +83,8 @@ impl> InnerTreeViewState { match event { key!('k') | key!(Up) => self.move_cursor_up().await, key!('j') | key!(Down) => self.move_cursor_down().await, + key!('K') | key!(Ctrl + Up) => self.move_cursor_up_sibling().await, + key!('J') | key!(Ctrl + Down) => self.move_cursor_down_sibling().await, key!('h') | key!(Left) => self.move_cursor_older().await, key!('l') | key!(Right) => self.move_cursor_newer().await, key!('H') | key!(Ctrl + Left) => self.move_cursor_older_unseen().await, diff --git a/src/ui/chat/tree/cursor.rs b/src/ui/chat/tree/cursor.rs index bc98357..fafa74c 100644 --- a/src/ui/chat/tree/cursor.rs +++ b/src/ui/chat/tree/cursor.rs @@ -240,6 +240,54 @@ impl> InnerTreeViewState { self.correction = Some(Correction::MakeCursorVisible); } + pub async fn move_cursor_up_sibling(&mut self) { + match &mut self.cursor { + Cursor::Bottom | Cursor::Pseudo { parent: None, .. } => { + if let Some(last_tree_id) = self.store.last_tree_id().await { + self.cursor = Cursor::Msg(last_tree_id); + } + } + Cursor::Msg(ref mut msg) => { + let path = self.store.path(msg).await; + let mut tree = self.store.tree(path.first()).await; + Self::find_prev_sibling(&self.store, &mut tree, msg).await; + } + Cursor::Editor { .. } => {} + Cursor::Pseudo { + parent: Some(parent), + .. + } => { + let path = self.store.path(parent).await; + let tree = self.store.tree(path.first()).await; + if let Some(children) = tree.children(parent) { + if let Some(last_child) = children.last() { + self.cursor = Cursor::Msg(last_child.clone()); + } + } + } + } + self.correction = Some(Correction::MakeCursorVisible); + } + + pub async fn move_cursor_down_sibling(&mut self) { + match &mut self.cursor { + Cursor::Msg(ref mut msg) => { + let path = self.store.path(msg).await; + let mut tree = self.store.tree(path.first()).await; + if !Self::find_next_sibling(&self.store, &mut tree, msg).await + && tree.parent(msg).is_none() + { + self.cursor = Cursor::Bottom; + } + } + Cursor::Pseudo { parent: None, .. } => { + self.cursor = Cursor::Bottom; + } + _ => {} + } + self.correction = Some(Correction::MakeCursorVisible); + } + pub async fn move_cursor_older(&mut self) { match &mut self.cursor { Cursor::Msg(id) => { @@ -385,68 +433,6 @@ impl> InnerTreeViewState { } /* - pub async fn move_up_sibling>( - &mut self, - store: &S, - cursor: &mut Option>, - frame: &mut Frame, - size: Size, - ) { - let old_blocks = self - .layout_blocks(store, cursor.as_ref(), frame, size) - .await; - let old_cursor_id = cursor.as_ref().map(|c| c.id.clone()); - - if let Some(cursor) = cursor { - let path = store.path(&cursor.id).await; - let mut tree = store.tree(path.first()).await; - self.find_prev_sibling(store, &mut tree, &mut cursor.id) - .await; - } else if let Some(last_tree) = store.last_tree().await { - // I think moving to the root of the last tree makes the most sense - // here. Alternatively, we could just not move the cursor, but that - // wouldn't be very useful. - *cursor = Some(Cursor::new(last_tree)); - } - // If neither condition holds, we can't set a cursor because there's no - // message to move to. - - if let Some(cursor) = cursor { - self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor) - .await; - } - } - - pub async fn move_down_sibling>( - &mut self, - store: &S, - cursor: &mut Option>, - frame: &mut Frame, - size: Size, - ) { - let old_blocks = self - .layout_blocks(store, cursor.as_ref(), frame, size) - .await; - let old_cursor_id = cursor.as_ref().map(|c| c.id.clone()); - - if let Some(cursor) = cursor { - let path = store.path(&cursor.id).await; - let mut tree = store.tree(path.first()).await; - self.find_next_sibling(store, &mut tree, &mut cursor.id) - .await; - } - // If that condition doesn't hold, we're already at the bottom in - // cursor-less mode and can't move further down anyways. - - if let Some(cursor) = cursor { - self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor) - .await; - } - } - - // TODO move_older[_unseen] - // TODO move_newer[_unseen] - pub async fn center_cursor>( &mut self, store: &S, From 5acb4c6396ed8240217274ee3b40458a4952f4c5 Mon Sep 17 00:00:00 2001 From: Joscha Date: Tue, 9 Aug 2022 15:51:47 +0200 Subject: [PATCH 043/443] Center cursor on screen --- CHANGELOG.md | 3 ++- src/ui/chat/tree.rs | 3 +++ src/ui/chat/tree/cursor.rs | 4 ++++ src/ui/chat/tree/layout.rs | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd53ad..3c4995a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,10 +17,11 @@ Procedure when bumping the version number: ### Added - New messages are now marked as unseen - Sub-trees can now be folded +- More readline-esque editor key bindings - Key bindings to move to prev/next sibling +- Key binding to center cursor on screen ### Changed -- Improved editor key bindings - Slowed down room history download speed ### Fixed diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index e14a7c7..67c7ed2 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -30,6 +30,7 @@ use super::{ChatMsg, Reaction}; enum Correction { MakeCursorVisible, MoveCursorToVisibleArea, + CenterCursor, } struct InnerTreeViewState> { @@ -75,6 +76,7 @@ impl> InnerTreeViewState { bindings.binding("ctrl+y/e", "scroll up/down a line"); bindings.binding("ctrl+u/d", "scroll up/down half a screen"); bindings.binding("ctrl+b/f", "scroll up/down one screen"); + bindings.binding("z", "center cursor on screen"); } async fn handle_movement_key_event(&mut self, frame: &mut Frame, event: KeyEvent) -> bool { @@ -97,6 +99,7 @@ impl> InnerTreeViewState { key!(Ctrl + 'd') => self.scroll_down((chat_height / 2).into()), key!(Ctrl + 'b') => self.scroll_up(chat_height.saturating_sub(1).into()), key!(Ctrl + 'f') => self.scroll_down(chat_height.saturating_sub(1).into()), + key!('z') => self.center_cursor(), _ => return false, } diff --git a/src/ui/chat/tree/cursor.rs b/src/ui/chat/tree/cursor.rs index fafa74c..aba8bc3 100644 --- a/src/ui/chat/tree/cursor.rs +++ b/src/ui/chat/tree/cursor.rs @@ -379,6 +379,10 @@ impl> InnerTreeViewState { self.correction = Some(Correction::MoveCursorToVisibleArea); } + pub fn center_cursor(&mut self) { + self.correction = Some(Correction::CenterCursor); + } + pub async fn parent_for_normal_reply(&self) -> Option> { match &self.cursor { Cursor::Bottom => Some(None), diff --git a/src/ui/chat/tree/layout.rs b/src/ui/chat/tree/layout.rs index 0122dcf..7e41f00 100644 --- a/src/ui/chat/tree/layout.rs +++ b/src/ui/chat/tree/layout.rs @@ -368,6 +368,33 @@ impl> InnerTreeViewState { } } + fn scroll_so_cursor_is_centered(&self, frame: &mut Frame, blocks: &mut TreeBlocks) { + if matches!(self.cursor, Cursor::Bottom) { + return; // Cursor is locked to bottom + } + + let block = blocks + .blocks() + .find(&BlockId::from_cursor(&self.cursor)) + .expect("no cursor found"); + + let height = frame.size().height as i32; + let scrolloff = scrolloff(height); + + let min_line = -block.focus.start + scrolloff; + let max_line = height - block.focus.end - scrolloff; + + // If the message is higher than the available space, the top of the + // message should always be visible. I'm not using top_line.clamp(...) + // because the order of the min and max matters. + let top_line = block.top_line; + let new_top_line = (height - block.height) / 2; + let new_top_line = new_top_line.min(max_line).max(min_line); + if new_top_line != top_line { + blocks.blocks_mut().offset(new_top_line - top_line); + } + } + /// Try to obtain a [`Cursor::Msg`] pointing to the block. fn msg_id(block: &Block>) -> Option { match &block.id { @@ -518,6 +545,11 @@ impl> InnerTreeViewState { .await; } } + Some(Correction::CenterCursor) => { + self.scroll_so_cursor_is_centered(frame, &mut blocks); + self.fill_screen_and_clamp_scrolling(nick, frame, &mut blocks) + .await; + } None => {} } From c6f879c2a5d7197ee7a870ef07f8576be365fc5d Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Aug 2022 00:30:34 +0200 Subject: [PATCH 044/443] Flush BufWriter before exiting --- src/export.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/export.rs b/src/export.rs index 2377bf0..52c6934 100644 --- a/src/export.rs +++ b/src/export.rs @@ -39,6 +39,7 @@ pub async fn export(vault: &Vault, room: String, file: &Path) -> anyhow::Result< } println!("Exported {exported_trees} trees, {exported_msgs} messages in total"); + file.flush()?; Ok(()) } From 44fce04a87d51c5c51d4235bb838259ccbf4fd05 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Aug 2022 00:33:45 +0200 Subject: [PATCH 045/443] Include version in clap output --- src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.rs b/src/main.rs index fef3cef..e940e5e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,7 @@ impl Default for Command { } #[derive(Debug, clap::Parser)] +#[clap(version)] struct Args { /// Path to a directory for cove to store its data in. #[clap(long, short)] From ed181a65180ca758073623874dd352b15df6f027 Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Aug 2022 01:58:25 +0200 Subject: [PATCH 046/443] Restructure export code and arg handling --- src/export.rs | 161 +++++++++++++++++++++++++-------------------- src/export/text.rs | 94 ++++++++++++++++++++++++++ src/main.rs | 4 +- 3 files changed, 186 insertions(+), 73 deletions(-) create mode 100644 src/export/text.rs diff --git a/src/export.rs b/src/export.rs index 52c6934..2430865 100644 --- a/src/export.rs +++ b/src/export.rs @@ -1,96 +1,115 @@ //! Export logs from the vault to plain text files. +mod text; + use std::fs::File; use std::io::{BufWriter, Write}; -use std::path::Path; -use time::format_description::FormatItem; -use time::macros::format_description; -use unicode_width::UnicodeWidthStr; - -use crate::euph::api::Snowflake; -use crate::euph::SmallMessage; -use crate::store::{MsgStore, Tree}; use crate::vault::Vault; -const TIME_FORMAT: &[FormatItem<'_>] = - format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); -const TIME_EMPTY: &str = " "; - -pub async fn export(vault: &Vault, room: String, file: &Path) -> anyhow::Result<()> { - println!("Exporting &{room} to {}", file.to_string_lossy()); - let mut file = BufWriter::new(File::create(file)?); - let vault = vault.euph(room); - - let mut exported_trees = 0; - let mut exported_msgs = 0; - let mut tree_id = vault.first_tree_id().await; - while let Some(some_tree_id) = tree_id { - let tree = vault.tree(&some_tree_id).await; - write_tree(&mut file, &tree, some_tree_id, 0)?; - tree_id = vault.next_tree_id(&some_tree_id).await; - - exported_trees += 1; - exported_msgs += tree.len(); - - if exported_trees % 10000 == 0 { - println!("Exported {exported_trees} trees, {exported_msgs} messages") - } - } - println!("Exported {exported_trees} trees, {exported_msgs} messages in total"); - - file.flush()?; - Ok(()) +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum Format { + /// Human-readable tree-structured messages. + Text, } -fn write_tree( - file: &mut BufWriter, - tree: &Tree, - id: Snowflake, - indent: usize, -) -> anyhow::Result<()> { - let indent_string = "| ".repeat(indent); +impl Format { + fn name(&self) -> &'static str { + match self { + Self::Text => "text", + } + } - if let Some(msg) = tree.msg(&id) { - write_msg(file, &indent_string, msg)?; + fn extension(&self) -> &'static str { + match self { + Self::Text => "txt", + } + } +} + +#[derive(Debug, clap::Parser)] +pub struct Args { + rooms: Vec, + + /// Export all rooms. + #[clap(long, short)] + all: bool, + + /// Format of the output file. + #[clap(long, short, value_enum, default_value_t = Format::Text)] + format: Format, + + /// Location of the output file + /// + /// May include the following placeholders: + /// `%r` - room name + /// `%e` - format extension + /// A literal `%` can be written as `%%`. + /// + /// If the value ends with a `/`, it is assumed to point to a directory and + /// `%r.%e` will be appended. + /// + /// Must be a valid utf-8 encoded string. + #[clap(long, short, default_value_t = Into::into("%r.%e"))] + #[clap(verbatim_doc_comment)] + out: String, +} + +pub async fn export(vault: &Vault, mut args: Args) -> anyhow::Result<()> { + if args.out.ends_with('/') { + args.out.push_str("%r.%e"); + } + + let rooms = if args.all { + let mut rooms = vault.euph_rooms().await; + rooms.sort_unstable(); + rooms } else { - write_placeholder(file, &indent_string)?; + let mut rooms = args.rooms.clone(); + rooms.dedup(); + rooms + }; + + if rooms.is_empty() { + println!("No rooms to export"); } - if let Some(children) = tree.children(&id) { - for child in children { - write_tree(file, tree, *child, indent + 1)?; + for room in rooms { + let out = format_out(&args.out, &room, args.format); + println!("Exporting &{room} as {} to {out}", args.format.name()); + + let mut file = BufWriter::new(File::create(out)?); + match args.format { + Format::Text => text::export_to_file(vault, room, &mut file).await?, } + file.flush()?; } Ok(()) } -fn write_msg( - file: &mut BufWriter, - indent_string: &str, - msg: &SmallMessage, -) -> anyhow::Result<()> { - let nick = &msg.nick; - let nick_empty = " ".repeat(nick.width()); +fn format_out(out: &str, room: &str, format: Format) -> String { + let mut result = String::new(); - for (i, line) in msg.content.lines().enumerate() { - if i == 0 { - let time = msg - .time - .0 - .format(TIME_FORMAT) - .expect("time can be formatted"); - writeln!(file, "{time} {indent_string}[{nick}] {line}")?; + let mut special = false; + for char in out.chars() { + if special { + match char { + 'r' => result.push_str(room), + 'e' => result.push_str(format.extension()), + '%' => result.push('%'), + _ => { + result.push('%'); + result.push(char); + } + } + special = false; + } else if char == '%' { + special = true; } else { - writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?; + result.push(char); } } - Ok(()) -} - -fn write_placeholder(file: &mut BufWriter, indent_string: &str) -> anyhow::Result<()> { - writeln!(file, "{TIME_EMPTY} {indent_string}[...]")?; - Ok(()) + result } diff --git a/src/export/text.rs b/src/export/text.rs new file mode 100644 index 0000000..7af4e89 --- /dev/null +++ b/src/export/text.rs @@ -0,0 +1,94 @@ +use std::fs::File; +use std::io::{BufWriter, Write}; + +use time::format_description::FormatItem; +use time::macros::format_description; +use unicode_width::UnicodeWidthStr; + +use crate::euph::api::Snowflake; +use crate::euph::SmallMessage; +use crate::store::{MsgStore, Tree}; +use crate::vault::Vault; + +const TIME_FORMAT: &[FormatItem<'_>] = + format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); +const TIME_EMPTY: &str = " "; + +pub async fn export_to_file( + vault: &Vault, + room: String, + file: &mut BufWriter, +) -> anyhow::Result<()> { + let vault = vault.euph(room); + + let mut exported_trees = 0; + let mut exported_msgs = 0; + let mut tree_id = vault.first_tree_id().await; + while let Some(some_tree_id) = tree_id { + let tree = vault.tree(&some_tree_id).await; + write_tree(file, &tree, some_tree_id, 0)?; + tree_id = vault.next_tree_id(&some_tree_id).await; + + exported_trees += 1; + exported_msgs += tree.len(); + + if exported_trees % 10000 == 0 { + println!(" {exported_trees} trees, {exported_msgs} messages") + } + } + println!(" {exported_trees} trees, {exported_msgs} messages in total"); + + Ok(()) +} + +fn write_tree( + file: &mut BufWriter, + tree: &Tree, + id: Snowflake, + indent: usize, +) -> anyhow::Result<()> { + let indent_string = "| ".repeat(indent); + + if let Some(msg) = tree.msg(&id) { + write_msg(file, &indent_string, msg)?; + } else { + write_placeholder(file, &indent_string)?; + } + + if let Some(children) = tree.children(&id) { + for child in children { + write_tree(file, tree, *child, indent + 1)?; + } + } + + Ok(()) +} + +fn write_msg( + file: &mut BufWriter, + indent_string: &str, + msg: &SmallMessage, +) -> anyhow::Result<()> { + let nick = &msg.nick; + let nick_empty = " ".repeat(nick.width()); + + for (i, line) in msg.content.lines().enumerate() { + if i == 0 { + let time = msg + .time + .0 + .format(TIME_FORMAT) + .expect("time can be formatted"); + writeln!(file, "{time} {indent_string}[{nick}] {line}")?; + } else { + writeln!(file, "{TIME_EMPTY} {indent_string}| {nick_empty} {line}")?; + } + } + + Ok(()) +} + +fn write_placeholder(file: &mut BufWriter, indent_string: &str) -> anyhow::Result<()> { + writeln!(file, "{TIME_EMPTY} {indent_string}[...]")?; + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index e940e5e..31d72d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -43,7 +43,7 @@ enum Command { /// Run the client interactively (default). Run, /// Export logs for a single room as a plain text file. - Export { room: String, file: PathBuf }, + Export(export::Args), /// Compact and clean up vault. Gc, /// Clear euphoria session cookies. @@ -87,7 +87,7 @@ async fn main() -> anyhow::Result<()> { match args.command.unwrap_or_default() { Command::Run => run(&vault, args.measure_widths).await?, - Command::Export { room, file } => export::export(&vault, room, &file).await?, + Command::Export(args) => export::export(&vault, args).await?, Command::Gc => { println!("Cleaning up and compacting vault"); println!("This may take a while..."); From 186ca5ea5a562691a02fb208e7e8b6e91fa5afbf Mon Sep 17 00:00:00 2001 From: Joscha Date: Wed, 10 Aug 2022 03:08:06 +0200 Subject: [PATCH 047/443] Add json export --- src/euph/api/types.rs | 13 ++++++-- src/export.rs | 6 ++++ src/export/json.rs | 47 ++++++++++++++++++++++++++ src/vault/euph.rs | 77 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/export/json.rs diff --git a/src/euph/api/types.rs b/src/euph/api/types.rs index 8acc64b..f56f0a1 100644 --- a/src/euph/api/types.rs +++ b/src/euph/api/types.rs @@ -39,9 +39,11 @@ pub struct Message { /// The id of the message (unique within a room). pub id: Snowflake, /// The id of the message's parent, or null if top-level. + #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option, /// The edit id of the most recent edit of this message, or null if it's /// never been edited. + #[serde(skip_serializing_if = "Option::is_none")] pub previous_edit_id: Option, /// The unix timestamp of when the message was posted. pub time: Time, @@ -50,15 +52,18 @@ pub struct Message { /// The content of the message (client-defined). pub content: String, /// The id of the key that encrypts the message in storage. + #[serde(skip_serializing_if = "Option::is_none")] pub encryption_key_id: Option, /// The unix timestamp of when the message was last edited. + #[serde(skip_serializing_if = "Option::is_none")] pub edited: Option