From f69d88bf4a59ebcb91b0c9a23956d818984486ce Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 13 Apr 2023 21:12:29 +0200 Subject: [PATCH] Migrate chat to AsyncWidget --- src/store.rs | 6 +- src/ui.rs | 2 + src/ui/chat2.rs | 139 ++++++++++ src/ui/chat2/tree.rs | 491 ++++++++++++++++++++++++++++++++++ src/ui/chat2/tree/renderer.rs | 463 ++++++++++++++++++++++++++++++++ src/ui/chat2/tree/scroll.rs | 68 +++++ src/ui/chat2/tree/widgets.rs | 181 +++++++++++++ src/ui/euph/room.rs | 21 +- 8 files changed, 1359 insertions(+), 12 deletions(-) create mode 100644 src/ui/chat2/tree.rs create mode 100644 src/ui/chat2/tree/renderer.rs create mode 100644 src/ui/chat2/tree/scroll.rs create mode 100644 src/ui/chat2/tree/widgets.rs diff --git a/src/store.rs b/src/store.rs index d762752..35e02a6 100644 --- a/src/store.rs +++ b/src/store.rs @@ -32,7 +32,11 @@ impl Path { } pub fn first(&self) -> &I { - self.0.first().expect("path is not empty") + self.0.first().expect("path is empty") + } + + pub fn into_first(self) -> I { + self.0.into_iter().next().expect("path is empty") } } diff --git a/src/ui.rs b/src/ui.rs index 14ad1fc..33bc4a0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -38,6 +38,8 @@ const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15 /// Error for anything that can go wrong while rendering. #[derive(Debug, thiserror::Error)] pub enum UiError { + #[error("{0}")] + Vault(#[from] vault::tokio::Error), #[error("{0}")] Io(#[from] io::Error), } diff --git a/src/ui/chat2.rs b/src/ui/chat2.rs index a1d3865..9ac7409 100644 --- a/src/ui/chat2.rs +++ b/src/ui/chat2.rs @@ -1,4 +1,143 @@ mod blocks; mod cursor; mod renderer; +mod tree; mod widgets; + +use std::io; +use std::sync::Arc; + +use parking_lot::FairMutex; +use toss::widgets::{BoxedAsync, EditorState}; +use toss::{Terminal, WidgetExt}; + +use crate::store::{Msg, MsgStore}; + +use self::cursor::Cursor; +use self::tree::TreeViewState; + +use super::input::{InputEvent, KeyBindingsList}; +use super::{ChatMsg, UiError}; + +pub enum Mode { + Tree, +} + +pub struct ChatState> { + store: S, + + cursor: Cursor, + editor: EditorState, + + mode: Mode, + tree: TreeViewState, +} + +impl + Clone> ChatState { + pub fn new(store: S) -> Self { + Self { + cursor: Cursor::Bottom, + editor: EditorState::new(), + + mode: Mode::Tree, + tree: TreeViewState::new(store.clone()), + + store, + } + } +} + +impl> ChatState { + pub fn store(&self) -> &S { + &self.store + } + + pub fn widget(&mut self, nick: String, focused: bool) -> BoxedAsync<'_, UiError> + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + UiError: From, + { + match self.mode { + Mode::Tree => self + .tree + .widget(&mut self.cursor, &mut self.editor, nick, focused) + .boxed_async(), + } + } + + pub async fn list_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { + match self.mode { + Mode::Tree => self + .tree + .list_key_bindings(bindings, &self.cursor, can_compose), + } + todo!() + } + + pub async fn handle_input_event( + &mut self, + terminal: &mut Terminal, + crossterm_lock: &Arc>, + event: &InputEvent, + can_compose: bool, + ) -> Result, S::Error> + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + { + match self.mode { + Mode::Tree => { + self.tree + .handle_input_event( + terminal, + crossterm_lock, + event, + &mut self.cursor, + &mut self.editor, + can_compose, + ) + .await + } + } + } + + pub fn cursor(&self) -> Option<&M::Id> { + match &self.cursor { + Cursor::Msg(id) => Some(id), + Cursor::Bottom | Cursor::Editor { .. } | Cursor::Pseudo { .. } => None, + } + } + + /// A [`Reaction::Composed`] message was sent successfully. + pub fn send_successful(&mut self, id: M::Id) { + if let Cursor::Pseudo { .. } = &self.cursor { + self.cursor = Cursor::Msg(id); + self.editor.clear(); + } + } + + /// A [`Reaction::Composed`] message failed to be sent. + pub fn send_failed(&mut self) { + if let Cursor::Pseudo { coming_from, .. } = &self.cursor { + self.cursor = match coming_from { + Some(id) => Cursor::Msg(id.clone()), + None => Cursor::Bottom, + }; + } + } +} + +pub enum Reaction { + NotHandled, + Handled, + Composed { + parent: Option, + content: String, + }, + ComposeError(io::Error), +} diff --git a/src/ui/chat2/tree.rs b/src/ui/chat2/tree.rs new file mode 100644 index 0000000..dc5a02b --- /dev/null +++ b/src/ui/chat2/tree.rs @@ -0,0 +1,491 @@ +//! Rendering messages as full trees. + +// TODO Focusing on sub-trees + +mod renderer; +mod scroll; +mod widgets; + +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use parking_lot::FairMutex; +use toss::widgets::EditorState; +use toss::{AsyncWidget, Frame, Pos, Size, Terminal, WidthDb}; + +use crate::store::{Msg, MsgStore}; +use crate::ui::input::{key, InputEvent, KeyBindingsList}; +use crate::ui::{util2, ChatMsg, UiError}; +use crate::util::InfallibleExt; + +use self::renderer::{TreeContext, TreeRenderer}; + +use super::cursor::Cursor; +use super::Reaction; + +pub struct TreeViewState> { + store: S, + + last_size: Size, + last_nick: String, + last_cursor: Cursor, + last_cursor_top: i32, + last_visible_msgs: Vec, + + folded: HashSet, +} + +impl> TreeViewState { + pub fn new(store: S) -> Self { + Self { + store, + last_size: Size::ZERO, + last_nick: String::new(), + last_cursor: Cursor::Bottom, + last_cursor_top: 0, + last_visible_msgs: vec![], + folded: HashSet::new(), + } + } + + 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("p/P", "move cursor to parent/root"); + 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"); + bindings.binding("ctrl+u/d", "scroll up/down half a screen"); + bindings.binding("ctrl+b/f, page up/down", "scroll up/down one screen"); + bindings.binding("z", "center cursor on screen"); + // TODO Bindings inspired by vim's ()/[]/{} bindings? + } + + async fn handle_movement_input_event( + &mut self, + frame: &mut Frame, + event: &InputEvent, + cursor: &mut Cursor, + editor: &mut EditorState, + ) -> Result + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + { + let chat_height: i32 = (frame.size().height - 3).into(); + let widthdb = frame.widthdb(); + + match event { + key!('k') | key!(Up) => cursor.move_up_in_tree(&self.store, &self.folded).await?, + key!('j') | key!(Down) => cursor.move_down_in_tree(&self.store, &self.folded).await?, + key!('K') | key!(Ctrl + Up) => cursor.move_to_prev_sibling(&self.store).await?, + key!('J') | key!(Ctrl + Down) => cursor.move_to_next_sibling(&self.store).await?, + key!('p') => cursor.move_to_parent(&self.store).await?, + key!('P') => cursor.move_to_root(&self.store).await?, + key!('h') | key!(Left) => cursor.move_to_older_msg(&self.store).await?, + key!('l') | key!(Right) => cursor.move_to_newer_msg(&self.store).await?, + key!('H') | key!(Ctrl + Left) => cursor.move_to_older_unseen_msg(&self.store).await?, + key!('L') | key!(Ctrl + Right) => cursor.move_to_newer_unseen_msg(&self.store).await?, + key!('g') | key!(Home) => cursor.move_to_top(&self.store).await?, + key!('G') | key!(End) => cursor.move_to_bottom(), + key!(Ctrl + 'y') => self.scroll_by(cursor, editor, widthdb, 1).await?, + key!(Ctrl + 'e') => self.scroll_by(cursor, editor, widthdb, -1).await?, + key!(Ctrl + 'u') => { + let delta = chat_height / 2; + self.scroll_by(cursor, editor, widthdb, delta).await?; + } + key!(Ctrl + 'd') => { + let delta = -(chat_height / 2); + self.scroll_by(cursor, editor, widthdb, delta).await?; + } + key!(Ctrl + 'b') | key!(PageUp) => { + let delta = chat_height.saturating_sub(1); + self.scroll_by(cursor, editor, widthdb, delta).await?; + } + key!(Ctrl + 'f') | key!(PageDown) => { + let delta = -chat_height.saturating_sub(1); + self.scroll_by(cursor, editor, widthdb, delta).await?; + } + key!('z') => self.center_cursor(cursor, editor, widthdb).await?, + _ => return Ok(false), + } + + Ok(true) + } + + 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"); + } + + async fn handle_action_input_event( + &mut self, + event: &InputEvent, + id: Option<&M::Id>, + ) -> Result { + match event { + key!(' ') => { + if let Some(id) = id { + if !self.folded.remove(id) { + self.folded.insert(id.clone()); + } + return Ok(true); + } + } + 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?; + } + return Ok(true); + } + } + key!('S') => { + for id in &self.last_visible_msgs { + self.store.set_seen(id, true).await?; + } + return Ok(true); + } + 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 Ok(true); + } + _ => {} + } + Ok(false) + } + + pub fn list_edit_initiating_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.binding("r", "reply to message (inline if possible, else directly)"); + bindings.binding("R", "reply to message (opposite of R)"); + bindings.binding("t", "start a new thread"); + } + + async fn handle_edit_initiating_input_event( + &mut self, + event: &InputEvent, + cursor: &mut Cursor, + id: Option, + ) -> Result { + match event { + key!('r') => { + if let Some(parent) = cursor.parent_for_normal_tree_reply(&self.store).await? { + *cursor = Cursor::Editor { + coming_from: id, + parent, + }; + } + } + key!('R') => { + if let Some(parent) = cursor.parent_for_alternate_tree_reply(&self.store).await? { + *cursor = Cursor::Editor { + coming_from: id, + parent, + }; + } + } + key!('t') | key!('T') => { + *cursor = Cursor::Editor { + coming_from: id, + parent: None, + }; + } + _ => return Ok(false), + } + + Ok(true) + } + + pub fn list_normal_key_bindings(&self, bindings: &mut KeyBindingsList, can_compose: bool) { + self.list_movement_key_bindings(bindings); + bindings.empty(); + self.list_action_key_bindings(bindings); + if can_compose { + bindings.empty(); + self.list_edit_initiating_key_bindings(bindings); + } + } + + async fn handle_normal_input_event( + &mut self, + frame: &mut Frame, + event: &InputEvent, + cursor: &mut Cursor, + editor: &mut EditorState, + can_compose: bool, + id: Option, + ) -> Result + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + { + #[allow(clippy::if_same_then_else)] + Ok( + if self + .handle_movement_input_event(frame, event, cursor, editor) + .await? + { + true + } else if self.handle_action_input_event(event, id.as_ref()).await? { + true + } else if can_compose { + self.handle_edit_initiating_input_event(event, cursor, id) + .await? + } else { + false + }, + ) + } + + fn list_editor_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.binding("esc", "close editor"); + bindings.binding("enter", "send message"); + util2::list_editor_key_bindings_allowing_external_editing(bindings, |_| true); + } + + #[allow(clippy::too_many_arguments)] + fn handle_editor_input_event( + &mut self, + terminal: &mut Terminal, + crossterm_lock: &Arc>, + event: &InputEvent, + cursor: &mut Cursor, + editor: &mut EditorState, + coming_from: Option, + parent: Option, + ) -> Reaction { + // TODO Tab-completion + + match event { + key!(Esc) => { + *cursor = coming_from.map(Cursor::Msg).unwrap_or(Cursor::Bottom); + return Reaction::Handled; + } + + key!(Enter) => { + let content = editor.text().to_string(); + if !content.trim().is_empty() { + *cursor = Cursor::Pseudo { + coming_from, + parent: parent.clone(), + }; + return Reaction::Composed { parent, content }; + } + } + + _ => { + let handled = util2::handle_editor_input_event_allowing_external_editing( + editor, + terminal, + crossterm_lock, + event, + |_| true, + ); + match handled { + Ok(true) => {} + Ok(false) => return Reaction::NotHandled, + Err(e) => return Reaction::ComposeError(e), + } + } + } + + Reaction::Handled + } + + pub fn list_key_bindings( + &self, + bindings: &mut KeyBindingsList, + cursor: &Cursor, + can_compose: bool, + ) { + bindings.heading("Chat"); + match cursor { + Cursor::Bottom | Cursor::Msg(_) => { + self.list_normal_key_bindings(bindings, can_compose); + } + Cursor::Editor { .. } => self.list_editor_key_bindings(bindings), + Cursor::Pseudo { .. } => { + self.list_normal_key_bindings(bindings, false); + } + } + } + + pub async fn handle_input_event( + &mut self, + terminal: &mut Terminal, + crossterm_lock: &Arc>, + event: &InputEvent, + cursor: &mut Cursor, + editor: &mut EditorState, + can_compose: bool, + ) -> Result, S::Error> + where + M: ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: Send + Sync, + S::Error: Send, + { + Ok(match cursor { + Cursor::Bottom => { + if self + .handle_normal_input_event( + terminal.frame(), + event, + cursor, + editor, + can_compose, + None, + ) + .await? + { + Reaction::Handled + } else { + Reaction::NotHandled + } + } + Cursor::Msg(id) => { + let id = id.clone(); + if self + .handle_normal_input_event( + terminal.frame(), + event, + cursor, + editor, + can_compose, + Some(id), + ) + .await? + { + Reaction::Handled + } else { + Reaction::NotHandled + } + } + Cursor::Editor { + coming_from, + parent, + } => { + let coming_from = coming_from.clone(); + let parent = parent.clone(); + self.handle_editor_input_event( + terminal, + crossterm_lock, + event, + cursor, + editor, + coming_from, + parent, + ) + } + Cursor::Pseudo { .. } => { + if self + .handle_movement_input_event(terminal.frame(), event, cursor, editor) + .await? + { + Reaction::Handled + } else { + Reaction::NotHandled + } + } + }) + } + + pub fn widget<'a>( + &'a mut self, + cursor: &'a mut Cursor, + editor: &'a mut EditorState, + nick: String, + focused: bool, + ) -> TreeView<'a, M, S> { + TreeView { + state: self, + cursor, + editor, + nick, + focused, + } + } +} + +pub struct TreeView<'a, M: Msg, S: MsgStore> { + state: &'a mut TreeViewState, + + cursor: &'a mut Cursor, + editor: &'a mut EditorState, + + nick: String, + focused: bool, +} + +#[async_trait] +impl AsyncWidget for TreeView<'_, M, S> +where + M: Msg + ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: MsgStore + Send + Sync, + S::Error: Send, + UiError: From, +{ + async fn size( + &self, + _widthdb: &mut WidthDb, + _max_width: Option, + _max_height: Option, + ) -> Result { + Ok(Size::ZERO) + } + + async fn draw(self, frame: &mut Frame) -> Result<(), UiError> { + let size = frame.size(); + + let context = TreeContext { + size, + nick: self.nick.clone(), + focused: self.focused, + last_cursor: self.state.last_cursor.clone(), + last_cursor_top: self.state.last_cursor_top, + }; + + let mut renderer = TreeRenderer::new( + context, + &self.state.store, + self.cursor, + self.editor, + frame.widthdb(), + ); + + renderer.prepare_blocks_for_drawing().await?; + + self.state.last_size = size; + self.state.last_nick = self.nick; + renderer.update_render_info( + &mut self.state.last_cursor, + &mut self.state.last_cursor_top, + &mut self.state.last_visible_msgs, + ); + + for (range, block) in renderer.into_visible_blocks() { + let widget = block.into_widget(); + frame.push(Pos::new(0, range.top), widget.size()); + widget.draw(frame).await.infallible(); + frame.pop(); + } + + Ok(()) + } +} diff --git a/src/ui/chat2/tree/renderer.rs b/src/ui/chat2/tree/renderer.rs new file mode 100644 index 0000000..0f58f4e --- /dev/null +++ b/src/ui/chat2/tree/renderer.rs @@ -0,0 +1,463 @@ +//! A [`BlockProvider`] for message trees. + +use std::convert::Infallible; + +use async_recursion::async_recursion; +use async_trait::async_trait; +use toss::widgets::{EditorState, Empty, Predrawn, Resize}; +use toss::{AsyncWidget, Size, WidthDb}; + +use crate::store::{Msg, MsgStore, Tree}; +use crate::ui::chat2::blocks::{Block, Blocks, Range}; +use crate::ui::chat2::cursor::Cursor; +use crate::ui::chat2::renderer::{self, overlaps, Renderer}; +use crate::ui::ChatMsg; +use crate::util::InfallibleExt; + +use super::widgets; + +/// When rendering messages as full trees, special ids and zero-height messages +/// are used for robust scrolling behaviour. +#[derive(PartialEq, Eq)] +pub enum TreeBlockId { + /// There is a zero-height block at the very bottom of the chat that has + /// this id. It is used for positioning [`Cursor::Bottom`]. + Bottom, + /// Normal messages have this id. It is used for positioning + /// [`Cursor::Msg`]. + Msg(Id), + /// After all children of a message, a zero-height block with this id is + /// rendered. It is used for positioning [`Cursor::Editor`] and + /// [`Cursor::Pseudo`]. + After(Id), +} + +impl TreeBlockId { + pub fn from_cursor(cursor: &Cursor) -> Self { + match cursor { + Cursor::Bottom + | Cursor::Editor { parent: None, .. } + | Cursor::Pseudo { parent: None, .. } => Self::Bottom, + + Cursor::Msg(id) => Self::Msg(id.clone()), + + Cursor::Editor { + parent: Some(id), .. + } + | Cursor::Pseudo { + parent: Some(id), .. + } => Self::After(id.clone()), + } + } + + pub fn any_id(&self) -> Option<&Id> { + match self { + Self::Bottom => None, + Self::Msg(id) | Self::After(id) => Some(id), + } + } + + pub fn msg_id(&self) -> Option<&Id> { + match self { + Self::Bottom | Self::After(_) => None, + Self::Msg(id) => Some(id), + } + } +} + +type TreeBlock = Block>; +type TreeBlocks = Blocks>; + +pub struct TreeContext { + pub size: Size, + pub nick: String, + pub focused: bool, + pub last_cursor: Cursor, + pub last_cursor_top: i32, +} + +pub struct TreeRenderer<'a, M: Msg, S: MsgStore> { + context: TreeContext, + + store: &'a S, + cursor: &'a mut Cursor, + editor: &'a mut EditorState, + widthdb: &'a mut WidthDb, + + /// Root id of the topmost tree in the blocks. When set to `None`, only the + /// bottom of the chat history has been rendered. + top_root_id: Option, + /// Root id of the bottommost tree in the blocks. When set to `None`, only + /// the bottom of the chat history has been rendered. + bottom_root_id: Option, + + blocks: TreeBlocks, +} + +impl<'a, M, S> TreeRenderer<'a, M, S> +where + M: Msg + ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: MsgStore + Send + Sync, + S::Error: Send, +{ + /// You must call [`Self::prepare_blocks`] immediately after calling + /// this function. + pub fn new( + context: TreeContext, + store: &'a S, + cursor: &'a mut Cursor, + editor: &'a mut EditorState, + widthdb: &'a mut WidthDb, + ) -> Self { + Self { + context, + store, + cursor, + editor, + widthdb, + top_root_id: None, + bottom_root_id: None, + blocks: Blocks::new(0), + } + } + + async fn predraw(widget: W, size: Size, widthdb: &mut WidthDb) -> Predrawn + where + W: AsyncWidget + Send + Sync, + { + Predrawn::new_async(Resize::new(widget).with_max_width(size.width), widthdb) + .await + .infallible() + } + + async fn zero_height_block(&mut self, parent: Option<&M::Id>) -> TreeBlock { + let id = match parent { + Some(parent) => TreeBlockId::After(parent.clone()), + None => TreeBlockId::Bottom, + }; + + let widget = Self::predraw(Empty::new(), self.context.size, self.widthdb).await; + Block::new(id, widget, false) + } + + async fn editor_block(&mut self, indent: usize, parent: Option<&M::Id>) -> TreeBlock { + let id = match parent { + Some(parent) => TreeBlockId::After(parent.clone()), + None => TreeBlockId::Bottom, + }; + + // TODO Unhighlighted version when focusing on nick list + let widget = widgets::editor::(indent, &self.context.nick, self.editor); + let widget = Self::predraw(widget, self.context.size, self.widthdb).await; + let mut block = Block::new(id, widget, false); + + // Since the editor was rendered when the `Predrawn` was created, the + // last cursor pos is accurate now. + let cursor_line = self.editor.last_cursor_pos().y; + block.set_focus(Range::new(cursor_line, cursor_line)); + + block + } + + async fn pseudo_block(&mut self, indent: usize, parent: Option<&M::Id>) -> TreeBlock { + let id = match parent { + Some(parent) => TreeBlockId::After(parent.clone()), + None => TreeBlockId::Bottom, + }; + + // TODO Unhighlighted version when focusing on nick list + let widget = widgets::pseudo::(indent, &self.context.nick, self.editor); + let widget = Self::predraw(widget, self.context.size, self.widthdb).await; + Block::new(id, widget, false) + } + + async fn message_block(&mut self, indent: usize, msg: &M) -> TreeBlock { + let msg_id = msg.id(); + + let highlighted = match self.cursor { + Cursor::Msg(id) => *id == msg_id, + _ => false, + }; + + // TODO Amount of folded messages + let widget = widgets::msg(self.context.focused && highlighted, indent, msg, None); + let widget = Self::predraw(widget, self.context.size, self.widthdb).await; + Block::new(TreeBlockId::Msg(msg_id), widget, true) + } + + async fn message_placeholder_block( + &mut self, + indent: usize, + msg_id: &M::Id, + ) -> TreeBlock { + let highlighted = match self.cursor { + Cursor::Msg(id) => id == msg_id, + _ => false, + }; + + // TODO Amount of folded messages + let widget = widgets::msg_placeholder(self.context.focused && highlighted, indent, None); + let widget = Self::predraw(widget, self.context.size, self.widthdb).await; + Block::new(TreeBlockId::Msg(msg_id.clone()), widget, true) + } + + async fn layout_bottom(&mut self) -> TreeBlocks { + let mut blocks = Blocks::new(0); + + match self.cursor { + Cursor::Editor { parent: None, .. } => { + blocks.push_bottom(self.editor_block(0, None).await) + } + Cursor::Pseudo { parent: None, .. } => { + blocks.push_bottom(self.pseudo_block(0, None).await) + } + _ => blocks.push_bottom(self.zero_height_block(None).await), + } + + blocks + } + + #[async_recursion] + async fn layout_subtree( + &mut self, + tree: &Tree, + indent: usize, + msg_id: &M::Id, + blocks: &mut TreeBlocks, + ) { + // Message itself + let block = if let Some(msg) = tree.msg(msg_id) { + self.message_block(indent, msg).await + } else { + self.message_placeholder_block(indent, msg_id).await + }; + blocks.push_bottom(block); + + // Children, recursively + if let Some(children) = tree.children(msg_id) { + for child in children { + self.layout_subtree(tree, indent + 1, child, blocks).await; + } + } + + // After message (zero-height block, editor, or placeholder) + let block = match self.cursor { + Cursor::Editor { + parent: Some(id), .. + } if id == msg_id => self.editor_block(indent + 1, Some(msg_id)).await, + + Cursor::Pseudo { + parent: Some(id), .. + } if id == msg_id => self.pseudo_block(indent + 1, Some(msg_id)).await, + + _ => self.zero_height_block(Some(msg_id)).await, + }; + blocks.push_bottom(block); + } + + async fn layout_tree(&mut self, tree: Tree) -> TreeBlocks { + let mut blocks = Blocks::new(0); + self.layout_subtree(&tree, 0, tree.root(), &mut blocks) + .await; + blocks + } + + async fn root_id(&self, id: &TreeBlockId) -> Result, S::Error> { + let Some(id) = id.any_id() else { return Ok(None); }; + let path = self.store.path(id).await?; + Ok(Some(path.into_first())) + } + + async fn prepare_initial_tree(&mut self, root_id: &Option) -> Result<(), S::Error> { + self.top_root_id = root_id.clone(); + self.bottom_root_id = root_id.clone(); + + let blocks = if let Some(root_id) = root_id { + let tree = self.store.tree(root_id).await?; + self.layout_tree(tree).await + } else { + self.layout_bottom().await + }; + self.blocks.append_bottom(blocks); + + Ok(()) + } + + fn make_cursor_visible(&mut self) { + let cursor_id = TreeBlockId::from_cursor(self.cursor); + if *self.cursor == self.context.last_cursor { + // Cursor did not move, so we just need to ensure it overlaps the + // scroll area + renderer::scroll_so_block_focus_overlaps_scroll_area(self, &cursor_id); + } else { + // Cursor moved, so it should fully overlap the scroll area + renderer::scroll_so_block_focus_fully_overlaps_scroll_area(self, &cursor_id); + } + } + + pub async fn prepare_blocks_for_drawing(&mut self) -> Result<(), S::Error> { + let cursor_id = TreeBlockId::from_cursor(self.cursor); + let cursor_root_id = self.root_id(&cursor_id).await?; + + // Render cursor and blocks around it until screen is filled as long as + // the cursor is visible, regardless of how the screen is scrolled. + self.prepare_initial_tree(&cursor_root_id).await?; + renderer::expand_to_fill_screen_around_block(self, &cursor_id).await?; + + // Scroll based on last cursor position + let last_cursor_id = TreeBlockId::from_cursor(&self.context.last_cursor); + if !renderer::scroll_to_set_block_top(self, &last_cursor_id, self.context.last_cursor_top) { + // Since the last cursor is not within scrolling distance of our + // current cursor, we need to estimate whether the last cursor was + // above or below the current cursor. + let last_cursor_root_id = self.root_id(&cursor_id).await?; + if last_cursor_root_id <= cursor_root_id { + renderer::scroll_blocks_fully_below_screen(self); + } else { + renderer::scroll_blocks_fully_above_screen(self); + } + } + + // Fulfill scroll constraints + self.make_cursor_visible(); + renderer::clamp_scroll_biased_downwards(self); + + Ok(()) + } + + fn move_cursor_so_it_is_visible(&mut self) { + let cursor_id = TreeBlockId::from_cursor(self.cursor); + if matches!(cursor_id, TreeBlockId::Bottom | TreeBlockId::Msg(_)) { + match renderer::find_cursor_starting_at(self, &cursor_id) { + Some(TreeBlockId::Bottom) => *self.cursor = Cursor::Bottom, + Some(TreeBlockId::Msg(id)) => *self.cursor = Cursor::Msg(id.clone()), + _ => {} + } + } + } + + pub async fn scroll_by(&mut self, delta: i32) -> Result<(), S::Error> { + self.blocks.shift(delta); + renderer::expand_to_fill_visible_area(self).await?; + renderer::clamp_scroll_biased_downwards(self); + + self.move_cursor_so_it_is_visible(); + + self.make_cursor_visible(); + renderer::clamp_scroll_biased_downwards(self); + + Ok(()) + } + + pub fn center_cursor(&mut self) { + let cursor_id = TreeBlockId::from_cursor(self.cursor); + renderer::scroll_so_block_is_centered(self, &cursor_id); + + self.make_cursor_visible(); + renderer::clamp_scroll_biased_downwards(self); + } + + pub fn update_render_info( + &self, + last_cursor: &mut Cursor, + last_cursor_top: &mut i32, + last_visible_msgs: &mut Vec, + ) { + *last_cursor = self.cursor.clone(); + + let cursor_id = TreeBlockId::from_cursor(self.cursor); + let (range, _) = self.blocks.find_block(&cursor_id).unwrap(); + *last_cursor_top = range.top; + + let area = renderer::visible_area(self); + *last_visible_msgs = self + .blocks + .iter() + .filter(|(range, _)| overlaps(area, *range)) + .filter_map(|(_, block)| block.id().msg_id()) + .cloned() + .collect() + } + + pub fn into_visible_blocks( + self, + ) -> impl Iterator, Block>)> { + let area = renderer::visible_area(&self); + self.blocks + .into_iter() + .filter(move |(range, block)| overlaps(area, block.focus(*range))) + } +} + +#[async_trait] +impl Renderer> for TreeRenderer<'_, M, S> +where + M: Msg + ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: MsgStore + Send + Sync, + S::Error: Send, +{ + type Error = S::Error; + + fn size(&self) -> Size { + self.context.size + } + + fn scrolloff(&self) -> i32 { + 2 // TODO Make configurable + } + + fn blocks(&self) -> &TreeBlocks { + &self.blocks + } + + fn blocks_mut(&mut self) -> &mut TreeBlocks { + &mut self.blocks + } + + fn into_blocks(self) -> TreeBlocks { + self.blocks + } + + async fn expand_top(&mut self) -> Result<(), Self::Error> { + let prev_root_id = if let Some(top_root_id) = &self.top_root_id { + self.store.prev_root_id(top_root_id).await? + } else { + self.store.last_root_id().await? + }; + + if let Some(prev_root_id) = prev_root_id { + let tree = self.store.tree(&prev_root_id).await?; + let blocks = self.layout_tree(tree).await; + self.blocks.append_top(blocks); + self.top_root_id = Some(prev_root_id); + } else { + self.blocks.end_top(); + } + + Ok(()) + } + + async fn expand_bottom(&mut self) -> Result<(), Self::Error> { + let Some(bottom_root_id) = &self.bottom_root_id else { + self.blocks.end_bottom(); + return Ok(()) + }; + + let next_root_id = self.store.next_root_id(bottom_root_id).await?; + if let Some(next_root_id) = next_root_id { + let tree = self.store.tree(&next_root_id).await?; + let blocks = self.layout_tree(tree).await; + self.blocks.append_bottom(blocks); + self.bottom_root_id = Some(next_root_id); + } else { + let blocks = self.layout_bottom().await; + self.blocks.append_bottom(blocks); + self.blocks.end_bottom(); + self.bottom_root_id = None; + }; + + Ok(()) + } +} diff --git a/src/ui/chat2/tree/scroll.rs b/src/ui/chat2/tree/scroll.rs new file mode 100644 index 0000000..40007f1 --- /dev/null +++ b/src/ui/chat2/tree/scroll.rs @@ -0,0 +1,68 @@ +use toss::widgets::EditorState; +use toss::WidthDb; + +use crate::store::{Msg, MsgStore}; +use crate::ui::chat2::cursor::Cursor; +use crate::ui::ChatMsg; + +use super::renderer::{TreeContext, TreeRenderer}; +use super::TreeViewState; + +impl TreeViewState +where + M: Msg + ChatMsg + Send + Sync, + M::Id: Send + Sync, + S: MsgStore + Send + Sync, + S::Error: Send, +{ + fn last_context(&self) -> TreeContext { + TreeContext { + size: self.last_size, + nick: self.last_nick.clone(), + focused: true, + last_cursor: self.last_cursor.clone(), + last_cursor_top: self.last_cursor_top, + } + } + + pub async fn scroll_by( + &mut self, + cursor: &mut Cursor, + editor: &mut EditorState, + widthdb: &mut WidthDb, + delta: i32, + ) -> Result<(), S::Error> { + let context = self.last_context(); + let mut renderer = TreeRenderer::new(context, &self.store, cursor, editor, widthdb); + renderer.prepare_blocks_for_drawing().await?; + + renderer.scroll_by(delta).await?; + + renderer.update_render_info( + &mut self.last_cursor, + &mut self.last_cursor_top, + &mut self.last_visible_msgs, + ); + Ok(()) + } + + pub async fn center_cursor( + &mut self, + cursor: &mut Cursor, + editor: &mut EditorState, + widthdb: &mut WidthDb, + ) -> Result<(), S::Error> { + let context = self.last_context(); + let mut renderer = TreeRenderer::new(context, &self.store, cursor, editor, widthdb); + renderer.prepare_blocks_for_drawing().await?; + + renderer.center_cursor(); + + renderer.update_render_info( + &mut self.last_cursor, + &mut self.last_cursor_top, + &mut self.last_visible_msgs, + ); + Ok(()) + } +} diff --git a/src/ui/chat2/tree/widgets.rs b/src/ui/chat2/tree/widgets.rs new file mode 100644 index 0000000..d0ff5f7 --- /dev/null +++ b/src/ui/chat2/tree/widgets.rs @@ -0,0 +1,181 @@ +use std::convert::Infallible; + +use crossterm::style::Stylize; +use toss::widgets::{BoxedAsync, EditorState, Join2, Join4, Join5, Text}; +use toss::{Style, Styled, WidgetExt}; + +use crate::store::Msg; +use crate::ui::chat2::widgets::{Indent, Seen, Time}; +use crate::ui::ChatMsg; + +pub const PLACEHOLDER: &str = "[...]"; + +pub fn style_placeholder() -> Style { + Style::new().dark_grey() +} + +fn style_time(highlighted: bool) -> Style { + if highlighted { + Style::new().black().on_white() + } else { + Style::new().grey() + } +} + +fn style_indent(highlighted: bool) -> Style { + if highlighted { + Style::new().black().on_white() + } else { + Style::new().dark_grey() + } +} + +fn style_info() -> Style { + Style::new().italic().dark_grey() +} + +fn style_editor_highlight() -> Style { + Style::new().black().on_cyan() +} + +fn style_pseudo_highlight() -> Style { + Style::new().black().on_yellow() +} + +pub fn msg( + highlighted: bool, + indent: usize, + msg: &M, + folded_info: Option, +) -> BoxedAsync<'static, Infallible> { + let (nick, mut content) = msg.styled(); + + if let Some(amount) = folded_info { + content = content + .then_plain("\n") + .then(format!("[{amount} more]"), style_info()); + } + + Join5::horizontal( + Seen::new(msg.seen()).segment().with_fixed(true), + Time::new(Some(msg.time()), style_time(highlighted)) + .padding() + .with_right(1) + .with_stretch(true) + .segment() + .with_fixed(true), + Indent::new(indent, style_indent(highlighted)) + .segment() + .with_fixed(true), + Join2::vertical( + Text::new(nick) + .padding() + .with_right(1) + .segment() + .with_fixed(true), + Indent::new(1, style_indent(false)).segment(), + ) + .segment() + .with_fixed(true), + // TODO Minimum content width + // TODO Minimizing and maximizing messages + Text::new(content).segment(), + ) + .boxed_async() +} + +pub fn msg_placeholder( + highlighted: bool, + indent: usize, + folded_info: Option, +) -> BoxedAsync<'static, Infallible> { + 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()); + } + + Join4::horizontal( + Seen::new(true).segment().with_fixed(true), + Time::new(None, style_time(highlighted)) + .padding() + .with_right(1) + .with_stretch(true) + .segment() + .with_fixed(true), + Indent::new(indent, style_indent(highlighted)) + .segment() + .with_fixed(true), + Text::new(content).segment(), + ) + .boxed_async() +} + +pub fn editor<'a, M: ChatMsg>( + indent: usize, + nick: &str, + editor: &'a mut EditorState, +) -> BoxedAsync<'a, Infallible> { + let (nick, content) = M::edit(nick, editor.text()); + let editor = editor.widget().with_highlight(|_| content); + + Join5::horizontal( + Seen::new(true).segment().with_fixed(true), + Time::new(None, style_editor_highlight()) + .padding() + .with_right(1) + .with_stretch(true) + .segment() + .with_fixed(true), + Indent::new(indent, style_editor_highlight()) + .segment() + .with_fixed(true), + Join2::vertical( + Text::new(nick) + .padding() + .with_right(1) + .segment() + .with_fixed(true), + Indent::new(1, style_indent(false)).segment(), + ) + .segment() + .with_fixed(true), + editor.segment(), + ) + .boxed_async() +} + +pub fn pseudo<'a, M: ChatMsg>( + indent: usize, + nick: &str, + editor: &'a mut EditorState, +) -> BoxedAsync<'a, Infallible> { + let (nick, content) = M::edit(nick, editor.text()); + + Join5::horizontal( + Seen::new(true).segment().with_fixed(true), + Time::new(None, style_pseudo_highlight()) + .padding() + .with_right(1) + .with_stretch(true) + .segment() + .with_fixed(true), + Indent::new(indent, style_pseudo_highlight()) + .segment() + .with_fixed(true), + Join2::vertical( + Text::new(nick) + .padding() + .with_right(1) + .segment() + .with_fixed(true), + Indent::new(1, style_indent(false)).segment(), + ) + .segment() + .with_fixed(true), + Text::new(content).segment(), + ) + .boxed_async() +} diff --git a/src/ui/euph/room.rs b/src/ui/euph/room.rs index b2c575f..c611362 100644 --- a/src/ui/euph/room.rs +++ b/src/ui/euph/room.rs @@ -14,7 +14,7 @@ use toss::{AsyncWidget, Style, Styled, Terminal, WidgetExt}; use crate::config; use crate::euph; use crate::macros::logging_unwrap; -use crate::ui::chat::{ChatState, Reaction}; +use crate::ui::chat2::{ChatState, Reaction}; use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::WidgetWrapper; use crate::ui::widgets2::ListState; @@ -150,12 +150,12 @@ impl EuphRoom { if let Some(id_rx) = &mut self.last_msg_sent { match id_rx.try_recv() { Ok(id) => { - self.chat.sent(Some(id)).await; + self.chat.send_successful(id); self.last_msg_sent = None; } Err(TryRecvError::Empty) => {} // Wait a bit longer Err(TryRecvError::Closed) => { - self.chat.sent(None).await; + self.chat.send_failed(); self.last_msg_sent = None; } } @@ -243,7 +243,7 @@ impl EuphRoom { chat: &mut EuphChatState, status_widget: impl AsyncWidget + Send + Sync + 'static, ) -> BoxedAsync<'_, UiError> { - let chat_widget = WidgetWrapper::new(chat.widget(String::new(), true)); + let chat_widget = chat.widget(String::new(), true); Join2::vertical( status_widget.segment().with_fixed(true), @@ -264,8 +264,7 @@ impl EuphRoom { .with_right(1) .border(); - let chat_widget = - WidgetWrapper::new(chat.widget(joined.session.name.clone(), focus == Focus::Chat)); + let chat_widget = chat.widget(joined.session.name.clone(), focus == Focus::Chat); Join2::horizontal( Join2::vertical( @@ -350,7 +349,7 @@ impl EuphRoom { if let Some(room) = &self.room { match room.send(parent, content) { Ok(id_rx) => self.last_msg_sent = Some(id_rx), - Err(_) => self.chat.sent(None).await, + Err(_) => self.chat.send_failed(), } return true; } @@ -437,16 +436,16 @@ impl EuphRoom { // Always applicable match event { key!('i') => { - if let Some(id) = self.chat.cursor().await { - if let Some(msg) = logging_unwrap!(self.vault().full_msg(id).await) { + if let Some(id) = self.chat.cursor() { + if let Some(msg) = logging_unwrap!(self.vault().full_msg(*id).await) { self.state = State::InspectMessage(msg); } } return true; } key!('I') => { - if let Some(id) = self.chat.cursor().await { - if let Some(msg) = logging_unwrap!(self.vault().msg(id).await) { + if let Some(id) = self.chat.cursor() { + if let Some(msg) = logging_unwrap!(self.vault().msg(*id).await) { self.state = State::Links(LinksState::new(&msg.content)); } }