Move chat to ui module

This commit is contained in:
Joscha 2022-07-05 09:50:44 +02:00
parent 603876738f
commit 446e3e885a
11 changed files with 7 additions and 12 deletions

107
src/ui/chat.rs Normal file
View file

@ -0,0 +1,107 @@
mod tree;
use std::sync::Arc;
use crossterm::event::KeyEvent;
use parking_lot::FairMutex;
use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use self::tree::TreeView;
pub enum Mode {
Tree,
// Thread,
// Flat,
}
pub struct Cursor<I> {
id: I,
/// Where on the screen the cursor is visible (`0.0` = first line, `1.0` =
/// last line).
proportion: f32,
}
impl<I> Cursor<I> {
/// Create a new cursor with arbitrary proportion.
pub fn new(id: I) -> Self {
Self {
id,
proportion: 0.0,
}
}
}
pub struct Chat<M: Msg, S: MsgStore<M>> {
store: S,
cursor: Option<Cursor<M::Id>>,
mode: Mode,
tree: TreeView<M>,
// thread: ThreadView,
// flat: FlatView,
}
impl<M: Msg, S: MsgStore<M>> Chat<M, S> {
pub fn new(store: S) -> Self {
Self {
store,
cursor: None,
mode: Mode::Tree,
tree: TreeView::new(),
}
}
pub fn store(&self) -> &S {
&self.store
}
}
impl<M: Msg, S: MsgStore<M>> Chat<M, S> {
pub async fn handle_navigation(
&mut self,
terminal: &mut Terminal,
size: Size,
event: KeyEvent,
) {
match self.mode {
Mode::Tree => {
self.tree
.handle_navigation(&mut self.store, &mut self.cursor, terminal, size, event)
.await
}
}
}
pub async fn handle_messaging(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent,
) -> Option<(Option<M::Id>, String)> {
match self.mode {
Mode::Tree => {
self.tree
.handle_messaging(
&mut self.store,
&mut self.cursor,
terminal,
crossterm_lock,
event,
)
.await
}
}
}
pub async fn render(&mut self, frame: &mut Frame, pos: Pos, size: Size) {
match self.mode {
Mode::Tree => {
self.tree
.render(&mut self.store, &self.cursor, frame, pos, size)
.await
}
}
}
}

84
src/ui/chat/tree.rs Normal file
View file

@ -0,0 +1,84 @@
mod action;
mod blocks;
mod cursor;
mod layout;
mod render;
mod util;
use std::marker::PhantomData;
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent};
use parking_lot::FairMutex;
use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use super::Cursor;
pub struct TreeView<M: Msg> {
// pub focus: Option<M::Id>,
// pub folded: HashSet<M::Id>,
// pub minimized: HashSet<M::Id>,
phantom: PhantomData<M::Id>, // TODO Remove
}
impl<M: Msg> TreeView<M> {
pub fn new() -> Self {
Self {
phantom: PhantomData,
}
}
pub async fn handle_navigation<S: MsgStore<M>>(
&mut self,
s: &mut S,
c: &mut Option<Cursor<M::Id>>,
t: &mut Terminal,
z: Size,
event: KeyEvent,
) {
match event.code {
KeyCode::Char('k') => self.move_up(s, c, t.frame(), z).await,
KeyCode::Char('j') => self.move_down(s, c, t.frame(), z).await,
KeyCode::Char('K') => self.move_up_sibling(s, c, t.frame(), z).await,
KeyCode::Char('J') => self.move_down_sibling(s, c, t.frame(), z).await,
KeyCode::Char('z') | KeyCode::Char('Z') => self.center_cursor(s, c, t.frame(), z).await,
KeyCode::Char('g') => self.move_to_first(s, c, t.frame(), z).await,
KeyCode::Char('G') => self.move_to_last(s, c, t.frame(), z).await,
KeyCode::Esc => *c = None, // TODO Make 'G' do the same thing?
_ => {}
}
}
pub async fn handle_messaging<S: MsgStore<M>>(
&mut self,
s: &mut S,
c: &mut Option<Cursor<M::Id>>,
t: &mut Terminal,
l: &Arc<FairMutex<()>>,
event: KeyEvent,
) -> Option<(Option<M::Id>, String)> {
match event.code {
KeyCode::Char('r') => Self::reply_normal(s, c, t, l).await,
KeyCode::Char('R') => Self::reply_alternate(s, c, t, l).await,
KeyCode::Char('t') | KeyCode::Char('T') => Self::create_new_thread(t, l).await,
_ => None,
}
}
pub async fn render<S: MsgStore<M>>(
&mut self,
store: &mut S,
cursor: &Option<Cursor<M::Id>>,
frame: &mut Frame,
pos: Pos,
size: Size,
) {
let blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
Self::render_blocks(frame, pos, size, blocks);
}
}

View file

@ -0,0 +1,96 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use super::{Cursor, TreeView};
impl<M: Msg> TreeView<M> {
fn prompt_msg(crossterm_lock: &Arc<FairMutex<()>>, terminal: &mut Terminal) -> Option<String> {
let content = {
let _guard = crossterm_lock.lock();
terminal.suspend().expect("could not suspend");
let content = edit::edit("").expect("could not edit");
terminal.unsuspend().expect("could not unsuspend");
content
};
if content.trim().is_empty() {
None
} else {
Some(content)
}
}
pub async fn reply_normal<S: MsgStore<M>>(
store: &S,
cursor: &Option<Cursor<M::Id>>,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
) -> Option<(Option<M::Id>, String)> {
if let Some(cursor) = cursor {
let tree = store.tree(store.path(&cursor.id).await.first()).await;
let parent_id = if tree.next_sibling(&cursor.id).is_some() {
// A reply to a message that has further siblings should be a
// direct reply. An indirect reply might end up a lot further
// down in the current conversation.
cursor.id.clone()
} else if let Some(parent) = tree.parent(&cursor.id) {
// A reply to a message without further siblings should be an
// indirect reply so as not to create unnecessarily deep
// threads. In the case that our message has children, this
// might get a bit confusing. I'm not sure yet how well this
// "smart" reply actually works in practice.
parent
} else {
// When replying to a top-level message, it makes sense to avoid
// creating unnecessary new threads.
cursor.id.clone()
};
if let Some(content) = Self::prompt_msg(crossterm_lock, terminal) {
return Some((Some(parent_id), content));
}
}
None
}
/// Does approximately the opposite of [`Self::reply_normal`].
pub async fn reply_alternate<S: MsgStore<M>>(
store: &S,
cursor: &Option<Cursor<M::Id>>,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
) -> Option<(Option<M::Id>, String)> {
if let Some(cursor) = cursor {
let tree = store.tree(store.path(&cursor.id).await.first()).await;
let parent_id = if tree.next_sibling(&cursor.id).is_none() {
// The opposite of replying normally
cursor.id.clone()
} else if let Some(parent) = tree.parent(&cursor.id) {
// The opposite of replying normally
parent
} else {
// The same as replying normally, still to avoid creating
// unnecessary new threads
cursor.id.clone()
};
if let Some(content) = Self::prompt_msg(crossterm_lock, terminal) {
return Some((Some(parent_id), content));
}
}
None
}
pub async fn create_new_thread(
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
) -> Option<(Option<M::Id>, String)> {
Self::prompt_msg(crossterm_lock, terminal).map(|c| (None, c))
}
}

179
src/ui/chat/tree/blocks.rs Normal file
View file

@ -0,0 +1,179 @@
//! Intermediate representation of messages as blocks of lines.
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use toss::styled::Styled;
use super::{util, Cursor};
pub struct Block<I> {
pub id: I,
pub line: i32,
pub height: i32,
pub cursor: bool,
pub time: Option<DateTime<Utc>>,
pub indent: usize,
pub body: BlockBody,
}
impl<I> Block<I> {
pub fn msg(
id: I,
indent: usize,
time: DateTime<Utc>,
nick: Styled,
lines: Vec<Styled>,
) -> Self {
Self {
id,
line: 0,
height: lines.len() as i32,
indent,
time: Some(time),
cursor: false,
body: BlockBody::Msg(MsgBlock { nick, lines }),
}
}
pub fn placeholder(id: I, indent: usize) -> Self {
Self {
id,
line: 0,
height: 1,
indent,
time: None,
cursor: false,
body: BlockBody::Placeholder,
}
}
pub fn time(mut self, time: DateTime<Utc>) -> Self {
self.time = Some(time);
self
}
}
pub enum BlockBody {
Msg(MsgBlock),
Placeholder,
}
pub struct MsgBlock {
pub nick: Styled,
pub lines: Vec<Styled>,
}
/// Pre-layouted messages as a sequence of blocks.
///
/// These blocks are straightforward to render, but also provide a level of
/// abstraction between the layouting and actual displaying of messages. This
/// might be useful in the future to ensure the cursor is always on a visible
/// message, for example.
///
/// The following equation describes the relationship between the
/// [`Blocks::top_line`] and [`Blocks::bottom_line`] fields:
///
/// `bottom_line - top_line + 1 = sum of all heights`
///
/// This ensures that `top_line` is always the first line and `bottom_line` is
/// always the last line in a nonempty [`Blocks`]. In an empty layout, the
/// equation simplifies to
///
/// `top_line = bottom_line + 1`
pub struct Blocks<I> {
pub blocks: VecDeque<Block<I>>,
/// The top line of the first block. Useful for prepending blocks,
/// especially to empty [`Blocks`]s.
pub top_line: i32,
/// The bottom line of the last block. Useful for appending blocks,
/// especially to empty [`Blocks`]s.
pub bottom_line: i32,
}
impl<I: PartialEq> Blocks<I> {
pub fn new() -> Self {
Self::new_below(0)
}
/// Create a new [`Blocks`] such that prepending a single line will result
/// in `top_line = bottom_line = line`.
pub fn new_below(line: i32) -> Self {
Self {
blocks: VecDeque::new(),
top_line: line + 1,
bottom_line: line,
}
}
fn mark_cursor(&mut self, id: &I) -> usize {
let mut cursor = None;
for (i, block) in self.blocks.iter_mut().enumerate() {
if &block.id == id {
block.cursor = true;
if cursor.is_some() {
panic!("more than one cursor in blocks");
}
cursor = Some(i);
}
}
cursor.expect("no cursor in blocks")
}
pub fn calculate_offsets_with_cursor(&mut self, cursor: &Cursor<I>, height: u16) {
let cursor_index = self.mark_cursor(&cursor.id);
let cursor_line = util::proportion_to_line(height, cursor.proportion);
// Propagate lines from cursor to both ends
self.blocks[cursor_index].line = cursor_line;
for i in (0..cursor_index).rev() {
// let succ_line = self.0[i + 1].line;
// let curr = &mut self.0[i];
// curr.line = succ_line - curr.height;
self.blocks[i].line = self.blocks[i + 1].line - self.blocks[i].height;
}
for i in (cursor_index + 1)..self.blocks.len() {
// let pred = &self.0[i - 1];
// self.0[i].line = pred.line + pred.height;
self.blocks[i].line = self.blocks[i - 1].line + self.blocks[i - 1].height;
}
self.top_line = self.blocks.front().expect("blocks nonempty").line;
let bottom = self.blocks.back().expect("blocks nonempty");
self.bottom_line = bottom.line + bottom.height - 1;
}
pub fn push_front(&mut self, mut block: Block<I>) {
self.top_line -= block.height;
block.line = self.top_line;
self.blocks.push_front(block);
}
pub fn push_back(&mut self, mut block: Block<I>) {
block.line = self.bottom_line + 1;
self.bottom_line += block.height;
self.blocks.push_back(block);
}
pub fn prepend(&mut self, mut layout: Self) {
while let Some(block) = layout.blocks.pop_back() {
self.push_front(block);
}
}
pub fn append(&mut self, mut layout: Self) {
while let Some(block) = layout.blocks.pop_front() {
self.push_back(block);
}
}
pub fn offset(&mut self, delta: i32) {
self.top_line += delta;
self.bottom_line += delta;
for block in &mut self.blocks {
block.line += delta;
}
}
pub fn find(&self, id: &I) -> Option<&Block<I>> {
self.blocks.iter().find(|b| &b.id == id)
}
}

381
src/ui/chat/tree/cursor.rs Normal file
View file

@ -0,0 +1,381 @@
//! Moving the cursor around.
use toss::frame::{Frame, Size};
use crate::store::{Msg, MsgStore, Tree};
use super::blocks::Blocks;
use super::{util, Cursor, TreeView};
impl<M: Msg> TreeView<M> {
#[allow(clippy::too_many_arguments)]
async fn correct_cursor_offset<S: MsgStore<M>>(
&mut self,
store: &S,
frame: &mut Frame,
size: Size,
old_blocks: &Blocks<M::Id>,
old_cursor_id: &Option<M::Id>,
cursor: &mut Cursor<M::Id>,
) {
if let Some(block) = old_blocks.find(&cursor.id) {
// The cursor is still visible in the old blocks, so we just need to
// adjust the proportion such that the blocks stay still.
cursor.proportion = util::line_to_proportion(size.height, block.line);
} else if let Some(old_cursor_id) = old_cursor_id {
// The cursor is not visible any more. However, we can estimate
// whether it is above or below the previous cursor position by
// lexicographically comparing both positions' paths.
let old_path = store.path(old_cursor_id).await;
let new_path = store.path(&cursor.id).await;
if new_path < old_path {
// Because we moved upwards, the cursor should appear at the top
// of the screen.
cursor.proportion = 0.0;
} else {
// Because we moved downwards, the cursor should appear at the
// bottom of the screen.
cursor.proportion = 1.0;
}
} else {
// We were scrolled all the way to the bottom, so the cursor must
// have been offscreen somewhere above.
cursor.proportion = 0.0;
}
// The cursor should be visible in its entirety on the screen now. If it
// isn't, we need to scroll the screen such that the cursor becomes fully
// visible again. To do this, we'll need to re-layout because the cursor
// could've moved anywhere.
let blocks = self.layout_blocks(store, Some(cursor), frame, size).await;
let cursor_block = blocks.find(&cursor.id).expect("cursor must be in blocks");
// First, ensure the cursor's last line is not below the bottom of the
// screen. Then, ensure its top line is not above the top of the screen.
// If the cursor has more lines than the screen, the user should still
// see the top of the cursor so they can start reading its contents.
let min_line = 0;
let max_line = size.height as i32 - cursor_block.height;
// Not using clamp because it is possible that max_line < min_line
let cursor_line = cursor_block.line.min(max_line).max(min_line);
cursor.proportion = util::line_to_proportion(size.height, cursor_line);
// There is no need to ensure the screen is not scrolled too far up or
// down. The messages in `blocks` are already scrolled correctly and
// this function will not scroll the wrong way. If the cursor moves too
// far up, the screen will only scroll down, not further up. The same
// goes for the other direction.
}
fn find_parent(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(parent) = tree.parent(id) {
*id = parent;
true
} else {
false
}
}
fn find_first_child(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(child) = tree.children(id).and_then(|c| c.first()) {
*id = child.clone();
true
} else {
false
}
}
fn find_last_child(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(child) = tree.children(id).and_then(|c| c.last()) {
*id = child.clone();
true
} else {
false
}
}
/// Move to the previous sibling, or don't move if this is not possible.
///
/// Always stays at the same level of indentation.
async fn find_prev_sibling<S: MsgStore<M>>(
&self,
store: &S,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
if let Some(prev_sibling) = tree.prev_sibling(id) {
*id = prev_sibling;
true
} else if tree.parent(id).is_none() {
// We're at the root of our tree, so we need to move to the root of
// the previous tree.
if let Some(prev_tree_id) = store.prev_tree(tree.root()).await {
*tree = store.tree(&prev_tree_id).await;
*id = prev_tree_id;
true
} else {
false
}
} else {
false
}
}
/// Move to the next sibling, or don't move if this is not possible.
///
/// Always stays at the same level of indentation.
async fn find_next_sibling<S: MsgStore<M>>(
&self,
store: &S,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
if let Some(next_sibling) = tree.next_sibling(id) {
*id = next_sibling;
true
} else if tree.parent(id).is_none() {
// We're at the root of our tree, so we need to move to the root of
// the next tree.
if let Some(next_tree_id) = store.next_tree(tree.root()).await {
*tree = store.tree(&next_tree_id).await;
*id = next_tree_id;
true
} else {
false
}
} else {
false
}
}
/// Move to the previous message, or don't move if this is not possible.
async fn find_prev_msg<S: MsgStore<M>>(
&self,
store: &S,
tree: &mut Tree<M>,
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) {}
true
} else {
Self::find_parent(tree, id)
}
}
/// Move to the next message, or don't move if this is not possible.
async fn find_next_msg<S: MsgStore<M>>(
&self,
store: &S,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
if Self::find_first_child(tree, id) {
return true;
}
if self.find_next_sibling(store, tree, id).await {
return true;
}
// Temporary id to avoid modifying the original one if no parent-sibling
// can be found.
let mut tmp_id = id.clone();
while Self::find_parent(tree, &mut tmp_id) {
if self.find_next_sibling(store, tree, &mut tmp_id).await {
*id = tmp_id;
return true;
}
}
false
}
pub async fn move_up<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
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 {
// We have a cursor to move around
let path = store.path(&cursor.id).await;
let mut tree = store.tree(path.first()).await;
self.find_prev_msg(store, &mut tree, &mut cursor.id).await;
} else if let Some(last_tree) = store.last_tree().await {
// We need to select the last message of the last tree
let tree = store.tree(&last_tree).await;
let mut id = last_tree;
while Self::find_last_child(&tree, &mut id) {}
*cursor = Some(Cursor::new(id));
}
// 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<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
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_msg(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;
}
}
pub async fn move_up_sibling<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
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<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
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;
}
}
pub async fn move_to_first<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
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(tree_id) = store.first_tree().await {
*cursor = Some(Cursor::new(tree_id));
}
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
pub async fn move_to_last<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
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(tree_id) = store.last_tree().await {
let tree = store.tree(&tree_id).await;
let mut id = tree_id;
while Self::find_last_child(&tree, &mut id) {}
*cursor = Some(Cursor::new(id));
}
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<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
if let Some(cursor) = cursor {
cursor.proportion = 0.5;
// Correcting the offset just to make sure that this function
// behaves nicely if the cursor has too many lines.
let old_blocks = self.layout_blocks(store, Some(cursor), frame, size).await;
let old_cursor_id = Some(cursor.id.clone());
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
}

167
src/ui/chat/tree/layout.rs Normal file
View file

@ -0,0 +1,167 @@
//! Arranging messages as blocks.
use toss::frame::{Frame, Size};
use crate::store::{Msg, MsgStore, Tree};
use super::blocks::{Block, Blocks};
use super::util::{self, MIN_CONTENT_WIDTH};
use super::{Cursor, TreeView};
fn msg_to_block<M: Msg>(frame: &mut Frame, size: Size, msg: &M, indent: usize) -> Block<M::Id> {
let nick = msg.nick();
let content = msg.content();
let content_width = size.width as i32 - util::after_nick(frame, indent, &nick.text());
if content_width < MIN_CONTENT_WIDTH as i32 {
Block::placeholder(msg.id(), indent).time(msg.time())
} 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.id(), indent, msg.time(), nick, lines)
}
}
fn layout_subtree<M: Msg>(
frame: &mut Frame,
size: Size,
tree: &Tree<M>,
indent: usize,
id: &M::Id,
result: &mut Blocks<M::Id>,
) {
let block = if let Some(msg) = tree.msg(id) {
msg_to_block(frame, size, msg, indent)
} else {
Block::placeholder(id.clone(), indent)
};
result.push_back(block);
if let Some(children) = tree.children(id) {
for child in children {
layout_subtree(frame, size, tree, indent + 1, child, result);
}
}
}
fn layout_tree<M: Msg>(frame: &mut Frame, size: Size, tree: Tree<M>) -> Blocks<M::Id> {
let mut blocks = Blocks::new();
layout_subtree(frame, size, &tree, 0, tree.root(), &mut blocks);
blocks
}
impl<M: Msg> TreeView<M> {
pub async fn expand_blocks_up<S: MsgStore<M>>(
store: &S,
frame: &mut Frame,
size: Size,
blocks: &mut Blocks<M::Id>,
tree_id: &mut Option<M::Id>,
) {
while blocks.top_line > 0 {
*tree_id = if let Some(tree_id) = tree_id {
store.prev_tree(tree_id).await
} else {
break;
};
if let Some(tree_id) = tree_id {
let tree = store.tree(tree_id).await;
blocks.prepend(layout_tree(frame, size, tree));
} else {
break;
}
}
}
pub async fn expand_blocks_down<S: MsgStore<M>>(
store: &S,
frame: &mut Frame,
size: Size,
blocks: &mut Blocks<M::Id>,
tree_id: &mut Option<M::Id>,
) {
while blocks.bottom_line < size.height as i32 {
*tree_id = if let Some(tree_id) = tree_id {
store.next_tree(tree_id).await
} else {
break;
};
if let Some(tree_id) = tree_id {
let tree = store.tree(tree_id).await;
blocks.append(layout_tree(frame, size, tree));
} else {
break;
}
}
}
// TODO Split up based on cursor presence
pub async fn layout_blocks<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: Option<&Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) -> Blocks<M::Id> {
if let Some(cursor) = cursor {
// TODO Ensure focus lies on cursor path, otherwise unfocus
// TODO Unfold all messages on path to cursor
// Layout cursor subtree (with correct offsets based on cursor)
let cursor_path = store.path(&cursor.id).await;
let cursor_tree_id = cursor_path.first();
let cursor_tree = store.tree(cursor_tree_id).await;
let mut blocks = layout_tree(frame, size, cursor_tree);
blocks.calculate_offsets_with_cursor(cursor, size.height);
// Expand upwards and downwards, ensuring the blocks are not
// scrolled too far in any direction.
//
// If the blocks fill the screen, scrolling stops when the topmost
// message is at the top of the screen or the bottommost message is
// at the bottom. If they don't fill the screen, the bottommost
// message should always be at the bottom.
//
// Because our helper functions always expand the blocks until they
// reach the top or bottom of the screen, we can determine that
// we're at the top/bottom if expansion stopped anywhere in the
// middle of the screen.
//
// TODO Don't expand if there is a focus
let mut top_tree_id = Some(cursor_tree_id.clone());
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut top_tree_id).await;
if blocks.top_line > 0 {
blocks.offset(-blocks.top_line);
}
let mut bot_tree_id = Some(cursor_tree_id.clone());
Self::expand_blocks_down(store, frame, size, &mut blocks, &mut bot_tree_id).await;
if blocks.bottom_line < size.height as i32 - 1 {
blocks.offset(size.height as i32 - 1 - blocks.bottom_line);
}
// If we only moved the blocks down, we need to expand upwards again
// to make sure we fill the screen.
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut top_tree_id).await;
blocks
} else {
// TODO Ensure there is no focus
// Start at the bottom of the screen
let mut blocks = Blocks::new_below(size.height as i32 - 1);
// Expand upwards from last tree
if let Some(last_tree_id) = store.last_tree().await {
let last_tree = store.tree(&last_tree_id).await;
blocks.prepend(layout_tree(frame, size, last_tree));
let mut tree_id = Some(last_tree_id);
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut tree_id).await;
}
blocks
}
}
}

View file

@ -0,0 +1,95 @@
//! Rendering blocks to a [`Frame`].
use chrono::{DateTime, Utc};
use toss::frame::{Frame, Pos, Size};
use toss::styled::Styled;
use crate::store::Msg;
use super::blocks::{Block, BlockBody, Blocks};
use super::util::{
self, style_indent, style_indent_inverted, style_placeholder, style_time, style_time_inverted,
INDENT, PLACEHOLDER, TIME_EMPTY, TIME_FORMAT,
};
use super::TreeView;
fn render_time(frame: &mut Frame, x: i32, y: i32, cursor: bool, time: Option<DateTime<Utc>>) {
let pos = Pos::new(x, y);
let style = if cursor {
style_time_inverted()
} else {
style_time()
};
if let Some(time) = time {
let time = format!("{}", time.format(TIME_FORMAT));
frame.write(pos, (&time, style));
} else {
frame.write(pos, (TIME_EMPTY, style));
}
}
fn render_indent(frame: &mut Frame, x: i32, y: i32, cursor: bool, indent: usize) {
let style = if cursor {
style_indent_inverted()
} else {
style_indent()
};
let mut styled = Styled::default();
for _ in 0..indent {
styled = styled.then((INDENT, style));
}
frame.write(Pos::new(x + util::after_indent(0), y), styled);
}
fn render_nick(frame: &mut Frame, x: i32, y: i32, indent: usize, nick: Styled) {
let nick_pos = Pos::new(x + util::after_indent(indent), y);
let styled = Styled::new("[").and_then(nick).then("]");
frame.write(nick_pos, styled);
}
fn render_block<M: Msg>(frame: &mut Frame, pos: Pos, size: Size, block: Block<M::Id>) {
match block.body {
BlockBody::Msg(msg) => {
let after_nick = util::after_nick(frame, block.indent, &msg.nick.text());
for (i, line) in msg.lines.into_iter().enumerate() {
let y = pos.y + block.line + i as i32;
if y < 0 || y >= pos.y + size.height as i32 {
continue;
}
if i == 0 {
render_indent(frame, pos.x, y, block.cursor, block.indent);
render_time(frame, pos.x, y, block.cursor, block.time);
render_nick(frame, pos.x, y, block.indent, msg.nick.clone());
} else {
render_indent(frame, pos.x, y, false, block.indent + 1);
render_indent(frame, pos.x, y, block.cursor, block.indent);
render_time(frame, pos.x, y, block.cursor, None);
}
let line_pos = Pos::new(pos.x + after_nick, y);
frame.write(line_pos, line);
}
}
BlockBody::Placeholder => {
let y = pos.y + block.line;
render_time(frame, pos.x, y, block.cursor, block.time);
render_indent(frame, pos.x, y, block.cursor, block.indent);
let pos = Pos::new(pos.x + util::after_indent(block.indent), y);
frame.write(pos, (PLACEHOLDER, style_placeholder()));
}
}
}
impl<M: Msg> TreeView<M> {
pub fn render_blocks(frame: &mut Frame, pos: Pos, size: Size, layout: Blocks<M::Id>) {
for block in layout.blocks {
render_block::<M>(frame, pos, size, block);
}
}
}

56
src/ui/chat/tree/util.rs Normal file
View file

@ -0,0 +1,56 @@
//! Constants and helper functions.
use crossterm::style::{ContentStyle, Stylize};
use toss::frame::Frame;
pub const TIME_FORMAT: &str = "%H:%M ";
pub const TIME_EMPTY: &str = " ";
pub const TIME_WIDTH: usize = 6;
pub fn style_time() -> ContentStyle {
ContentStyle::default().grey()
}
pub fn style_time_inverted() -> ContentStyle {
ContentStyle::default().black().on_white()
}
pub const INDENT: &str = "";
pub const INDENT_WIDTH: usize = 2;
pub fn style_indent() -> ContentStyle {
ContentStyle::default().dark_grey()
}
pub fn style_indent_inverted() -> ContentStyle {
ContentStyle::default().black().on_white()
}
pub const PLACEHOLDER: &str = "[...]";
pub fn style_placeholder() -> ContentStyle {
ContentStyle::default().dark_grey()
}
// Something like this should fit: [+, 1234 more]
pub const MIN_CONTENT_WIDTH: usize = 14;
pub fn after_indent(indent: usize) -> i32 {
(TIME_WIDTH + indent * INDENT_WIDTH) as i32
}
pub fn after_nick(frame: &mut Frame, indent: usize, nick: &str) -> i32 {
after_indent(indent) + 1 + frame.width(nick) as i32 + 2
}
pub fn proportion_to_line(height: u16, proportion: f32) -> i32 {
((height - 1) as f32 * proportion).round() as i32
}
pub fn line_to_proportion(height: u16, line: i32) -> f32 {
if height > 1 {
line as f32 / (height - 1) as f32
} else {
0.0
}
}

View file

@ -6,10 +6,10 @@ use tokio::sync::mpsc;
use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal;
use crate::chat::Chat;
use crate::euph::{self, Status};
use crate::vault::{EuphMsg, EuphVault};
use super::chat::Chat;
use super::{util, UiEvent};
pub struct EuphRoom {