diff --git a/src/ui/chat/blocks.rs b/src/ui/chat/blocks.rs index 2644225..5484282 100644 --- a/src/ui/chat/blocks.rs +++ b/src/ui/chat/blocks.rs @@ -7,24 +7,24 @@ use crate::macros::some_or_return; use crate::ui::widgets::BoxedWidget; pub struct Block { - id: I, - top_line: i32, - height: i32, + pub id: I, + pub top_line: i32, + pub height: i32, /// The lines of the block that should be made visible if the block is /// focused on. By default, the focus encompasses the entire block. /// /// If not all of these lines can be made visible, the top of the range /// should be preferred over the bottom. - focus: Range, - widget: BoxedWidget, + pub focus: Range, + pub widget: BoxedWidget, } impl Block { - pub fn new>(frame: &mut Frame, width: u16, id: I, widget: W) -> Self { + pub fn new>(frame: &mut Frame, id: I, widget: W) -> Self { // Interestingly, rust-analyzer fails to deduce the type of `widget` // here but rustc knows it's a `BoxedWidget`. let widget = widget.into(); - let size = widget.size(frame, Some(width), None); + let size = widget.size(frame, Some(frame.size().width), None); let height = size.height.into(); Self { id, @@ -97,15 +97,55 @@ impl Blocks { self.push_back(block); } } + + pub fn set_top_line(&mut self, line: i32) { + self.top_line = line; + + if let Some(first_block) = self.blocks.front_mut() { + first_block.top_line = self.top_line; + } + + for i in 1..self.blocks.len() { + self.blocks[i].top_line = self.blocks[i - 1].top_line + self.blocks[i - 1].height; + } + + self.bottom_line = self + .blocks + .back() + .map(|b| b.top_line + b.height - 1) + .unwrap_or(self.top_line - 1); + } + + pub fn set_bottom_line(&mut self, line: i32) { + self.bottom_line = line; + + if let Some(last_block) = self.blocks.back_mut() { + last_block.top_line = self.bottom_line + 1 - last_block.height; + } + + for i in (1..self.blocks.len()).rev() { + self.blocks[i - 1].top_line = self.blocks[i].top_line - self.blocks[i - 1].height; + } + + self.top_line = self + .blocks + .front() + .map(|b| b.top_line) + .unwrap_or(self.bottom_line + 1) + } } impl Blocks { - pub fn recalculate_offsets(&mut self, id: I, top_line: i32) { + pub fn find(&self, id: &I) -> Option<&Block> { + self.blocks.iter().find(|b| b.id == *id) + } + + pub fn recalculate_offsets(&mut self, id: &I, top_line: i32) { let idx = some_or_return!(self .blocks .iter() .enumerate() - .find(|(_, b)| b.id == id) + .find(|(_, b)| b.id == *id) .map(|(i, _)| i)); self.blocks[idx].top_line = top_line; diff --git a/src/ui/chat/tree.rs b/src/ui/chat/tree.rs index bbce2f9..f078465 100644 --- a/src/ui/chat/tree.rs +++ b/src/ui/chat/tree.rs @@ -1,4 +1,7 @@ +mod layout; mod time; +mod tree_blocks; +mod widgets; use std::sync::Arc; @@ -6,17 +9,20 @@ use async_trait::async_trait; use crossterm::event::KeyEvent; use parking_lot::FairMutex; use tokio::sync::Mutex; -use toss::frame::{Frame, Size}; +use toss::frame::{Frame, Pos, Size}; use toss::terminal::Terminal; use crate::store::{Msg, MsgStore}; use crate::ui::widgets::editor::EditorState; use crate::ui::widgets::Widget; +use self::tree_blocks::TreeBlocks; + /////////// // State // /////////// +#[derive(Debug, Clone, Copy)] pub enum Cursor { Bottom, Msg(I), @@ -24,6 +30,24 @@ pub enum Cursor { Pseudo(Option), } +impl Cursor { + pub fn refers_to(&self, id: &I) -> bool { + if let Self::Msg(own_id) = self { + own_id == id + } else { + false + } + } + + pub fn refers_to_last_child_of(&self, id: &I) -> bool { + if let Self::Editor(Some(parent)) | Self::Pseudo(Some(parent)) = self { + parent == id + } else { + false + } + } +} + struct InnerTreeViewState> { store: S, @@ -113,5 +137,16 @@ where async fn render(self: Box, frame: &mut Frame) { let mut guard = self.0.lock().await; + let blocks = guard.relayout(frame).await; + + let size = frame.size(); + for block in blocks.into_blocks().blocks { + frame.push( + Pos::new(0, block.top_line), + Size::new(size.width, block.height as u16), + ); + block.widget.render(frame).await; + frame.pop(); + } } } diff --git a/src/ui/chat/tree/layout.rs b/src/ui/chat/tree/layout.rs index 2e5fc71..d21f1a0 100644 --- a/src/ui/chat/tree/layout.rs +++ b/src/ui/chat/tree/layout.rs @@ -1,292 +1,310 @@ -//! Arranging messages as blocks. - -use toss::frame::{Frame, Size}; +use toss::frame::Frame; use crate::store::{Msg, MsgStore, Path, Tree}; +use crate::ui::chat::blocks::Block; +use crate::ui::widgets::empty::Empty; +use crate::ui::widgets::text::Text; -use super::blocks::{Block, BlockBody, Blocks, MsgBlock}; -use super::cursor::Cursor; -use super::{util, InnerTreeViewState}; +use super::tree_blocks::{BlockId, Root, TreeBlocks}; +use super::{widgets, Cursor, InnerTreeViewState}; impl> InnerTreeViewState { async fn cursor_path(&self, cursor: &Cursor) -> Path { match cursor { - Cursor::Bottom => match self.store.last_tree_id().await { - Some(id) => Path::new(vec![id]), - None => Path::new(vec![M::last_possible_id()]), - }, Cursor::Msg(id) => self.store.path(id).await, - Cursor::Compose(lc) | Cursor::Placeholder(lc) => match &lc.after { - None => Path::new(vec![M::last_possible_id()]), - Some(id) => { - let mut path = self.store.path(id).await; - path.push(M::last_possible_id()); - path - } - }, + Cursor::Bottom | Cursor::Editor(None) | Cursor::Pseudo(None) => { + Path::new(vec![M::last_possible_id()]) + } + Cursor::Editor(Some(parent)) | Cursor::Pseudo(Some(parent)) => { + let mut path = self.store.path(parent).await; + path.push(M::last_possible_id()); + path + } } } - fn cursor_tree_id<'a>( - cursor: &Cursor, - cursor_path: &'a Path, - ) -> Option<&'a M::Id> { - match cursor { - Cursor::Bottom => None, - Cursor::Msg(_) => Some(cursor_path.first()), - Cursor::Compose(lc) | Cursor::Placeholder(lc) => match &lc.after { - None => None, - Some(_) => Some(cursor_path.first()), - }, - } - } - - fn cursor_line( - last_blocks: &Blocks, - cursor: &Cursor, - cursor_path: &Path, - last_cursor_path: &Path, - size: Size, - ) -> i32 { - if matches!(cursor, Cursor::Bottom) { - // Ensures that a Cursor::Bottom is always at the bottom of the - // screen. Will be scroll-clamped to the bottom later. + fn cursor_line(&self, blocks: &TreeBlocks) -> i32 { + if let Cursor::Bottom = self.cursor { + // The value doesn't matter as it will always be ignored. 0 - } else if let Some(block) = last_blocks.find(|b| cursor.matches_block(b)) { - block.line - } else if last_cursor_path < cursor_path { - // If the cursor is bottom, the bottom marker needs to be located at - // the line below the last visible line. If it is a normal message - // cursor, it will be made visible again one way or another later. - size.height.into() } else { - 0 + blocks + .blocks() + .find(&BlockId::from_cursor(&self.cursor)) + .expect("cursor is visible") + .top_line } } - fn msg_to_block(frame: &mut Frame, indent: usize, msg: &M) -> Block { - let size = frame.size(); - - let nick = msg.nick(); - let content = msg.content(); - - let content_width = size.width as i32 - util::after_nick(frame, indent, &nick); - if content_width < util::MIN_CONTENT_WIDTH as i32 { - Block::placeholder(Some(msg.time()), indent, msg.id()) - } else { - let content_width = content_width as usize; - let breaks = frame.wrap(&content.text(), content_width); - let lines = content.split_at_indices(&breaks); - Block::msg(msg.time(), indent, msg.id(), nick, lines) - } + fn contains_cursor(&self, blocks: &TreeBlocks) -> bool { + blocks + .blocks() + .find(&BlockId::from_cursor(&self.cursor)) + .is_some() } fn layout_subtree( + &self, frame: &mut Frame, tree: &Tree, indent: usize, id: &M::Id, - result: &mut Blocks, + blocks: &mut TreeBlocks, ) { - let block = if let Some(msg) = tree.msg(id) { - Self::msg_to_block(frame, indent, msg) - } else { - Block::placeholder(None, indent, id.clone()) - }; - result.push_back(block); + // Ghost cursor in front, for positioning according to last cursor line + if self.last_cursor.refers_to(id) { + let block = Block::new(frame, BlockId::LastCursor, Empty); + blocks.blocks_mut().push_back(block); + } + // Main message body + let highlighted = self.cursor.refers_to(id); + let widget = if let Some(msg) = tree.msg(id) { + widgets::msg(highlighted, indent, msg) + } else { + widgets::msg_placeholder(highlighted, indent) + }; + 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) { for child in children { - Self::layout_subtree(frame, tree, indent + 1, child, result); + self.layout_subtree(frame, tree, indent + 1, child, blocks); } } - result.push_back(Block::after(indent, id.clone())) + // Trailing ghost cursor, for positioning according to last cursor line + if self.last_cursor.refers_to_last_child_of(id) { + let block = Block::new(frame, BlockId::LastCursor, Empty); + blocks.blocks_mut().push_back(block); + } + + // Trailing editor or pseudomessage + if self.cursor.refers_to_last_child_of(id) { + // TODO Render proper editor or pseudocursor + let block = Block::new(frame, BlockId::Cursor, Text::new("TODO")); + blocks.blocks_mut().push_back(block); + } } - fn layout_tree(frame: &mut Frame, tree: Tree) -> Blocks { - let mut blocks = Blocks::new(); - Self::layout_subtree(frame, &tree, 0, tree.root(), &mut blocks); - blocks.roots = Some((tree.root().clone(), tree.root().clone())); + fn layout_tree(&self, frame: &mut Frame, tree: Tree) -> TreeBlocks { + let root = Root::Tree(tree.root().clone()); + let mut blocks = TreeBlocks::new(root.clone(), root); + self.layout_subtree(frame, &tree, 0, tree.root(), &mut blocks); blocks } - /// Create a [`Blocks`] of the current cursor's immediate surroundings. - async fn layout_cursor_surroundings(&self, frame: &mut Frame) -> Blocks { - let size = frame.size(); + fn layout_bottom(&self, frame: &mut Frame) -> TreeBlocks { + let mut blocks = TreeBlocks::new(Root::Bottom, Root::Bottom); - let cursor_path = self.cursor_path(&self.cursor).await; - let last_cursor_path = self.cursor_path(&self.last_cursor).await; - let tree_id = Self::cursor_tree_id(&self.cursor, &cursor_path); - let cursor_line = Self::cursor_line( - &self.last_blocks, - &self.cursor, - &cursor_path, - &last_cursor_path, - size, - ); - - if let Some(tree_id) = tree_id { - let tree = self.store.tree(tree_id).await; - let mut blocks = Self::layout_tree(frame, tree); - blocks.recalculate_offsets(|b| { - if self.cursor.matches_block(b) { - Some(cursor_line) - } else { - None - } - }); - blocks - } else { - Blocks::new_bottom(cursor_line) + // Ghost cursor, for positioning according to last cursor line + if let Cursor::Editor(None) | Cursor::Pseudo(None) = self.last_cursor { + let block = Block::new(frame, BlockId::LastCursor, Empty); + blocks.blocks_mut().push_back(block); } + + // Editor or pseudomessage + if let Cursor::Editor(None) | Cursor::Pseudo(None) = self.cursor { + // TODO Render proper editor or pseudocursor + let block = Block::new(frame, BlockId::Cursor, Text::new("TODO")); + blocks.blocks_mut().push_back(block); + } + + blocks } - fn scroll_so_cursor_is_visible(blocks: &mut Blocks, cursor: &Cursor, size: Size) { - if !matches!(cursor, Cursor::Msg(_)) { - // In all other cases, there is special scrolling behaviour, so - // let's not interfere. - return; - } - - let block = blocks - .find(|b| cursor.matches_block(b)) - // This should never happen since we always start rendering the - // blocks from the cursor. - .expect("no cursor found"); - - let min_line = 0; - let max_line = size.height as i32 - block.height(); - if block.line < min_line { - blocks.offset(min_line - block.line); - } else if block.line > max_line { - blocks.offset(max_line - block.line); - } - } - - /// Try to obtain a [`Cursor::Msg`] pointing to the block. - fn as_msg_cursor(block: &Block) -> Option> { - match &block.body { - BlockBody::Msg(MsgBlock { id, .. }) => Some(Cursor::Msg(id.clone())), - _ => None, - } - } - - fn move_cursor_so_it_is_visible( - blocks: &mut Blocks, - cursor: &mut Cursor, - size: Size, - ) { - if !matches!(cursor, Cursor::Msg(_)) { - // In all other cases, there is special scrolling behaviour, so - // let's not interfere. - return; - } - - let block = blocks - .find(|b| cursor.matches_block(b)) - // This should never happen since we always start rendering the - // blocks from the cursor. - .expect("no cursor found"); - - let min_line = 1 - block.height(); - let max_line = size.height as i32 - 1; - - let new_cursor = if block.line < min_line { - // Move cursor to first possible visible block - blocks - .iter() - .filter(|b| b.line >= min_line) - .find_map(Self::as_msg_cursor) - } else if block.line > max_line { - // Move cursor to last possible visible block - blocks - .iter() - .rev() - .filter(|b| b.line <= max_line) - .find_map(Self::as_msg_cursor) - } else { - None - }; - - if let Some(new_cursor) = new_cursor { - *cursor = new_cursor; - } - } - - async fn expand_blocks_up(&self, frame: &mut Frame, blocks: &mut Blocks) { - while blocks.top_line > 0 { - let tree_id = if let Some((root_top, _)) = &blocks.roots { - self.store.prev_tree_id(root_top).await - } else { - self.store.last_tree_id().await - }; - - if let Some(tree_id) = tree_id { - let tree = self.store.tree(&tree_id).await; - blocks.prepend(Self::layout_tree(frame, tree)); - } else { - break; - } - } - } - - async fn expand_blocks_down(&self, frame: &mut Frame, blocks: &mut Blocks) { - while blocks.bottom_line < frame.size().height as i32 { - let tree_id = if let Some((_, root_bot)) = &blocks.roots { - self.store.next_tree_id(root_bot).await - } else { - // We assume that a Blocks without roots is at the bottom of the - // room's history. Therefore, there are no more messages below. - break; - }; - - if let Some(tree_id) = tree_id { - let tree = self.store.tree(&tree_id).await; - blocks.append(Self::layout_tree(frame, tree)); - } else { - break; - } - } - } - - async fn clamp_scrolling(&self, frame: &mut Frame, blocks: &mut Blocks) { - let size = frame.size(); + async fn expand_to_top(&self, frame: &mut Frame, blocks: &mut TreeBlocks) { let top_line = 0; - let bottom_line = size.height as i32 - 1; - self.expand_blocks_up(frame, blocks).await; - - if blocks.top_line > top_line { - blocks.offset(top_line - blocks.top_line); + while blocks.blocks().top_line > top_line { + let top_root = blocks.top_root(); + let prev_tree_id = match top_root { + Root::Bottom => self.store.last_tree_id().await, + Root::Tree(tree_id) => self.store.prev_tree_id(tree_id).await, + }; + let prev_tree_id = match prev_tree_id { + Some(tree_id) => tree_id, + None => break, + }; + let prev_tree = self.store.tree(&prev_tree_id).await; + blocks.prepend(self.layout_tree(frame, prev_tree)); } - - self.expand_blocks_down(frame, blocks).await; - - if blocks.bottom_line < bottom_line { - blocks.offset(bottom_line - blocks.bottom_line); - } - - self.expand_blocks_up(frame, blocks).await; } - pub async fn relayout(&mut self, frame: &mut Frame) { - let size = frame.size(); + async fn expand_to_bottom(&self, frame: &mut Frame, blocks: &mut TreeBlocks) { + let bottom_line = frame.size().height as i32 - 1; - let mut blocks = self.layout_cursor_surroundings(frame).await; + while blocks.blocks().bottom_line < bottom_line { + let bottom_root = blocks.bottom_root(); + let next_tree_id = match bottom_root { + Root::Bottom => break, + Root::Tree(tree_id) => self.store.prev_tree_id(tree_id).await, + }; + if let Some(next_tree_id) = next_tree_id { + let next_tree = self.store.tree(&next_tree_id).await; + blocks.append(self.layout_tree(frame, next_tree)); + } else { + blocks.append(self.layout_bottom(frame)); + } + } + } + + async fn fill_screen_and_clamp_scrolling( + &self, + frame: &mut Frame, + blocks: &mut TreeBlocks, + ) { + let top_line = 0; + let bottom_line = frame.size().height as i32 - 1; + + self.expand_to_top(frame, blocks).await; + + if blocks.blocks().top_line > top_line { + blocks.blocks_mut().set_top_line(0); + } + + self.expand_to_bottom(frame, blocks).await; + + if blocks.blocks().bottom_line < bottom_line { + blocks.blocks_mut().set_bottom_line(bottom_line); + } + + self.expand_to_top(frame, blocks).await; + } + + async fn layout_last_cursor_seed( + &self, + frame: &mut Frame, + last_cursor_path: &Path, + ) -> TreeBlocks { + match &self.last_cursor { + Cursor::Bottom => { + let mut blocks = self.layout_bottom(frame); + + let bottom_line = frame.size().height as i32 - 1; + blocks.blocks_mut().set_bottom_line(bottom_line); + + blocks + } + Cursor::Editor(None) | Cursor::Pseudo(None) => { + let mut blocks = self.layout_bottom(frame); + + blocks + .blocks_mut() + .recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line); + + blocks + } + Cursor::Msg(_) | Cursor::Editor(Some(_)) | Cursor::Pseudo(Some(_)) => { + let root = last_cursor_path.first(); + let tree = self.store.tree(root).await; + let mut blocks = self.layout_tree(frame, tree); + + blocks + .blocks_mut() + .recalculate_offsets(&BlockId::LastCursor, self.last_cursor_line); + + blocks + } + } + } + + async fn layout_cursor_seed( + &self, + frame: &mut Frame, + last_cursor_path: &Path, + cursor_path: &Path, + ) -> TreeBlocks { + let bottom_line = frame.size().height as i32 - 1; + + match &self.cursor { + Cursor::Bottom | Cursor::Editor(None) | Cursor::Pseudo(None) => { + let mut blocks = self.layout_bottom(frame); + + blocks.blocks_mut().set_bottom_line(bottom_line); + + blocks + } + Cursor::Msg(_) | Cursor::Editor(Some(_)) | Cursor::Pseudo(Some(_)) => { + let root = cursor_path.first(); + let tree = self.store.tree(root).await; + let mut blocks = self.layout_tree(frame, tree); + + let cursor_above_last = cursor_path < last_cursor_path; + let cursor_line = if cursor_above_last { 0 } else { bottom_line }; + blocks + .blocks_mut() + .recalculate_offsets(&BlockId::from_cursor(&self.cursor), cursor_line); + + blocks + } + } + } + + async fn layout_initial_seed( + &self, + frame: &mut Frame, + last_cursor_path: &Path, + cursor_path: &Path, + ) -> TreeBlocks { + if let Cursor::Bottom = self.cursor { + self.layout_cursor_seed(frame, last_cursor_path, cursor_path) + .await + } else { + self.layout_last_cursor_seed(frame, last_cursor_path).await + } + } + + pub async fn relayout(&mut self, frame: &mut Frame) -> TreeBlocks { + // The basic idea is this: + // + // First, layout a full screen of blocks around self.last_cursor, using + // self.last_cursor_line for offset positioning. + // + // Then, check if self.cursor is somewhere in these blocks. If it is, we + // now know the position of our own cursor. If it is not, it has jumped + // too far away from self.last_cursor and we'll need to render a new + // full screen of blocks around self.cursor before proceeding, using the + // cursor paths to determine the position of self.cursor on the screen. + // + // Now that we have a more-or-less accurate screen position of + // self.cursor, we can perform the actual cursor logic, i.e. make the + // cursor visible or move it so it is visible. + // + // This entire process is complicated by the different kinds of cursors. + + let last_cursor_path = self.cursor_path(&self.last_cursor).await; + let cursor_path = self.cursor_path(&self.cursor).await; + + let mut blocks = self + .layout_initial_seed(frame, &last_cursor_path, &cursor_path) + .await; + self.fill_screen_and_clamp_scrolling(frame, &mut blocks) + .await; + + if !self.contains_cursor(&blocks) { + blocks = self + .layout_cursor_seed(frame, &last_cursor_path, &cursor_path) + .await; + self.fill_screen_and_clamp_scrolling(frame, &mut blocks) + .await; + } if self.make_cursor_visible { - Self::scroll_so_cursor_is_visible(&mut blocks, &self.cursor, size); + // self.make_cursor_visible(&mut blocks).await; // TODO + self.fill_screen_and_clamp_scrolling(frame, &mut blocks) + .await; + } else { + // self.move_cursor_so_it_is_visible(&mut blocks); // TODO + self.fill_screen_and_clamp_scrolling(frame, &mut blocks) + .await; } - self.clamp_scrolling(frame, &mut blocks).await; - - if !self.make_cursor_visible { - Self::move_cursor_so_it_is_visible(&mut blocks, &mut self.cursor, size); - } - - self.last_blocks = blocks; self.last_cursor = self.cursor.clone(); + self.last_cursor_line = self.cursor_line(&blocks); self.make_cursor_visible = false; + + blocks } } diff --git a/src/ui/chat/tree/time.rs b/src/ui/chat/tree/time.rs index 96c82f4..037e39f 100644 --- a/src/ui/chat/tree/time.rs +++ b/src/ui/chat/tree/time.rs @@ -1,8 +1,8 @@ use crossterm::style::{ContentStyle, Stylize}; use time::format_description::FormatItem; use time::macros::format_description; +use time::OffsetDateTime; -use crate::euph::api::Time; use crate::ui::widgets::background::Background; use crate::ui::widgets::text::Text; use crate::ui::widgets::BoxedWidget; @@ -18,9 +18,9 @@ fn style_inverted() -> ContentStyle { ContentStyle::default().black().on_white() } -pub fn widget(time: Option