Migrate chat to AsyncWidget
This commit is contained in:
parent
ecc4995397
commit
f69d88bf4a
8 changed files with 1359 additions and 12 deletions
|
|
@ -32,7 +32,11 @@ impl<I> Path<I> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn first(&self) -> &I {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ const EVENT_PROCESSING_TIME: Duration = Duration::from_millis(1000 / 15); // 15
|
||||||
/// Error for anything that can go wrong while rendering.
|
/// Error for anything that can go wrong while rendering.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum UiError {
|
pub enum UiError {
|
||||||
|
#[error("{0}")]
|
||||||
|
Vault(#[from] vault::tokio::Error),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
src/ui/chat2.rs
139
src/ui/chat2.rs
|
|
@ -1,4 +1,143 @@
|
||||||
mod blocks;
|
mod blocks;
|
||||||
mod cursor;
|
mod cursor;
|
||||||
mod renderer;
|
mod renderer;
|
||||||
|
mod tree;
|
||||||
mod widgets;
|
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<M: Msg, S: MsgStore<M>> {
|
||||||
|
store: S,
|
||||||
|
|
||||||
|
cursor: Cursor<M::Id>,
|
||||||
|
editor: EditorState,
|
||||||
|
|
||||||
|
mode: Mode,
|
||||||
|
tree: TreeViewState<M, S>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M: Msg, S: MsgStore<M> + Clone> ChatState<M, S> {
|
||||||
|
pub fn new(store: S) -> Self {
|
||||||
|
Self {
|
||||||
|
cursor: Cursor::Bottom,
|
||||||
|
editor: EditorState::new(),
|
||||||
|
|
||||||
|
mode: Mode::Tree,
|
||||||
|
tree: TreeViewState::new(store.clone()),
|
||||||
|
|
||||||
|
store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M: Msg, S: MsgStore<M>> ChatState<M, S> {
|
||||||
|
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<S::Error>,
|
||||||
|
{
|
||||||
|
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<FairMutex<()>>,
|
||||||
|
event: &InputEvent,
|
||||||
|
can_compose: bool,
|
||||||
|
) -> Result<Reaction<M>, 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<M: Msg> {
|
||||||
|
NotHandled,
|
||||||
|
Handled,
|
||||||
|
Composed {
|
||||||
|
parent: Option<M::Id>,
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
ComposeError(io::Error),
|
||||||
|
}
|
||||||
|
|
|
||||||
491
src/ui/chat2/tree.rs
Normal file
491
src/ui/chat2/tree.rs
Normal file
|
|
@ -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<M: Msg, S: MsgStore<M>> {
|
||||||
|
store: S,
|
||||||
|
|
||||||
|
last_size: Size,
|
||||||
|
last_nick: String,
|
||||||
|
last_cursor: Cursor<M::Id>,
|
||||||
|
last_cursor_top: i32,
|
||||||
|
last_visible_msgs: Vec<M::Id>,
|
||||||
|
|
||||||
|
folded: HashSet<M::Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<M: Msg, S: MsgStore<M>> TreeViewState<M, S> {
|
||||||
|
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<M::Id>,
|
||||||
|
editor: &mut EditorState,
|
||||||
|
) -> Result<bool, S::Error>
|
||||||
|
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<bool, S::Error> {
|
||||||
|
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<M::Id>,
|
||||||
|
id: Option<M::Id>,
|
||||||
|
) -> Result<bool, S::Error> {
|
||||||
|
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<M::Id>,
|
||||||
|
editor: &mut EditorState,
|
||||||
|
can_compose: bool,
|
||||||
|
id: Option<M::Id>,
|
||||||
|
) -> Result<bool, S::Error>
|
||||||
|
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<FairMutex<()>>,
|
||||||
|
event: &InputEvent,
|
||||||
|
cursor: &mut Cursor<M::Id>,
|
||||||
|
editor: &mut EditorState,
|
||||||
|
coming_from: Option<M::Id>,
|
||||||
|
parent: Option<M::Id>,
|
||||||
|
) -> Reaction<M> {
|
||||||
|
// 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<M::Id>,
|
||||||
|
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<FairMutex<()>>,
|
||||||
|
event: &InputEvent,
|
||||||
|
cursor: &mut Cursor<M::Id>,
|
||||||
|
editor: &mut EditorState,
|
||||||
|
can_compose: bool,
|
||||||
|
) -> Result<Reaction<M>, 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<M::Id>,
|
||||||
|
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<M>> {
|
||||||
|
state: &'a mut TreeViewState<M, S>,
|
||||||
|
|
||||||
|
cursor: &'a mut Cursor<M::Id>,
|
||||||
|
editor: &'a mut EditorState,
|
||||||
|
|
||||||
|
nick: String,
|
||||||
|
focused: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<M, S> AsyncWidget<UiError> for TreeView<'_, M, S>
|
||||||
|
where
|
||||||
|
M: Msg + ChatMsg + Send + Sync,
|
||||||
|
M::Id: Send + Sync,
|
||||||
|
S: MsgStore<M> + Send + Sync,
|
||||||
|
S::Error: Send,
|
||||||
|
UiError: From<S::Error>,
|
||||||
|
{
|
||||||
|
async fn size(
|
||||||
|
&self,
|
||||||
|
_widthdb: &mut WidthDb,
|
||||||
|
_max_width: Option<u16>,
|
||||||
|
_max_height: Option<u16>,
|
||||||
|
) -> Result<Size, UiError> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
463
src/ui/chat2/tree/renderer.rs
Normal file
463
src/ui/chat2/tree/renderer.rs
Normal file
|
|
@ -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<Id> {
|
||||||
|
/// 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<Id: Clone> TreeBlockId<Id> {
|
||||||
|
pub fn from_cursor(cursor: &Cursor<Id>) -> 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<Id> = Block<TreeBlockId<Id>>;
|
||||||
|
type TreeBlocks<Id> = Blocks<TreeBlockId<Id>>;
|
||||||
|
|
||||||
|
pub struct TreeContext<Id> {
|
||||||
|
pub size: Size,
|
||||||
|
pub nick: String,
|
||||||
|
pub focused: bool,
|
||||||
|
pub last_cursor: Cursor<Id>,
|
||||||
|
pub last_cursor_top: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TreeRenderer<'a, M: Msg, S: MsgStore<M>> {
|
||||||
|
context: TreeContext<M::Id>,
|
||||||
|
|
||||||
|
store: &'a S,
|
||||||
|
cursor: &'a mut Cursor<M::Id>,
|
||||||
|
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<M::Id>,
|
||||||
|
/// 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<M::Id>,
|
||||||
|
|
||||||
|
blocks: TreeBlocks<M::Id>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, M, S> TreeRenderer<'a, M, S>
|
||||||
|
where
|
||||||
|
M: Msg + ChatMsg + Send + Sync,
|
||||||
|
M::Id: Send + Sync,
|
||||||
|
S: MsgStore<M> + Send + Sync,
|
||||||
|
S::Error: Send,
|
||||||
|
{
|
||||||
|
/// You must call [`Self::prepare_blocks`] immediately after calling
|
||||||
|
/// this function.
|
||||||
|
pub fn new(
|
||||||
|
context: TreeContext<M::Id>,
|
||||||
|
store: &'a S,
|
||||||
|
cursor: &'a mut Cursor<M::Id>,
|
||||||
|
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<W>(widget: W, size: Size, widthdb: &mut WidthDb) -> Predrawn
|
||||||
|
where
|
||||||
|
W: AsyncWidget<Infallible> + 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<M::Id> {
|
||||||
|
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<M::Id> {
|
||||||
|
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::<M>(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<M::Id> {
|
||||||
|
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::<M>(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<M::Id> {
|
||||||
|
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<M::Id> {
|
||||||
|
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<M::Id> {
|
||||||
|
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<M>,
|
||||||
|
indent: usize,
|
||||||
|
msg_id: &M::Id,
|
||||||
|
blocks: &mut TreeBlocks<M::Id>,
|
||||||
|
) {
|
||||||
|
// 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<M>) -> TreeBlocks<M::Id> {
|
||||||
|
let mut blocks = Blocks::new(0);
|
||||||
|
self.layout_subtree(&tree, 0, tree.root(), &mut blocks)
|
||||||
|
.await;
|
||||||
|
blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root_id(&self, id: &TreeBlockId<M::Id>) -> Result<Option<M::Id>, 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<M::Id>) -> 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<M::Id>,
|
||||||
|
last_cursor_top: &mut i32,
|
||||||
|
last_visible_msgs: &mut Vec<M::Id>,
|
||||||
|
) {
|
||||||
|
*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<Item = (Range<i32>, Block<TreeBlockId<M::Id>>)> {
|
||||||
|
let area = renderer::visible_area(&self);
|
||||||
|
self.blocks
|
||||||
|
.into_iter()
|
||||||
|
.filter(move |(range, block)| overlaps(area, block.focus(*range)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<M, S> Renderer<TreeBlockId<M::Id>> for TreeRenderer<'_, M, S>
|
||||||
|
where
|
||||||
|
M: Msg + ChatMsg + Send + Sync,
|
||||||
|
M::Id: Send + Sync,
|
||||||
|
S: MsgStore<M> + 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<M::Id> {
|
||||||
|
&self.blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocks_mut(&mut self) -> &mut TreeBlocks<M::Id> {
|
||||||
|
&mut self.blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_blocks(self) -> TreeBlocks<M::Id> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/ui/chat2/tree/scroll.rs
Normal file
68
src/ui/chat2/tree/scroll.rs
Normal file
|
|
@ -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<M, S> TreeViewState<M, S>
|
||||||
|
where
|
||||||
|
M: Msg + ChatMsg + Send + Sync,
|
||||||
|
M::Id: Send + Sync,
|
||||||
|
S: MsgStore<M> + Send + Sync,
|
||||||
|
S::Error: Send,
|
||||||
|
{
|
||||||
|
fn last_context(&self) -> TreeContext<M::Id> {
|
||||||
|
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<M::Id>,
|
||||||
|
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<M::Id>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/ui/chat2/tree/widgets.rs
Normal file
181
src/ui/chat2/tree/widgets.rs
Normal file
|
|
@ -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<M: Msg + ChatMsg>(
|
||||||
|
highlighted: bool,
|
||||||
|
indent: usize,
|
||||||
|
msg: &M,
|
||||||
|
folded_info: Option<usize>,
|
||||||
|
) -> 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<usize>,
|
||||||
|
) -> 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()
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ use toss::{AsyncWidget, Style, Styled, Terminal, WidgetExt};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::euph;
|
use crate::euph;
|
||||||
use crate::macros::logging_unwrap;
|
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::input::{key, InputEvent, KeyBindingsList};
|
||||||
use crate::ui::widgets::WidgetWrapper;
|
use crate::ui::widgets::WidgetWrapper;
|
||||||
use crate::ui::widgets2::ListState;
|
use crate::ui::widgets2::ListState;
|
||||||
|
|
@ -150,12 +150,12 @@ impl EuphRoom {
|
||||||
if let Some(id_rx) = &mut self.last_msg_sent {
|
if let Some(id_rx) = &mut self.last_msg_sent {
|
||||||
match id_rx.try_recv() {
|
match id_rx.try_recv() {
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
self.chat.sent(Some(id)).await;
|
self.chat.send_successful(id);
|
||||||
self.last_msg_sent = None;
|
self.last_msg_sent = None;
|
||||||
}
|
}
|
||||||
Err(TryRecvError::Empty) => {} // Wait a bit longer
|
Err(TryRecvError::Empty) => {} // Wait a bit longer
|
||||||
Err(TryRecvError::Closed) => {
|
Err(TryRecvError::Closed) => {
|
||||||
self.chat.sent(None).await;
|
self.chat.send_failed();
|
||||||
self.last_msg_sent = None;
|
self.last_msg_sent = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +243,7 @@ impl EuphRoom {
|
||||||
chat: &mut EuphChatState,
|
chat: &mut EuphChatState,
|
||||||
status_widget: impl AsyncWidget<UiError> + Send + Sync + 'static,
|
status_widget: impl AsyncWidget<UiError> + Send + Sync + 'static,
|
||||||
) -> BoxedAsync<'_, UiError> {
|
) -> BoxedAsync<'_, UiError> {
|
||||||
let chat_widget = WidgetWrapper::new(chat.widget(String::new(), true));
|
let chat_widget = chat.widget(String::new(), true);
|
||||||
|
|
||||||
Join2::vertical(
|
Join2::vertical(
|
||||||
status_widget.segment().with_fixed(true),
|
status_widget.segment().with_fixed(true),
|
||||||
|
|
@ -264,8 +264,7 @@ impl EuphRoom {
|
||||||
.with_right(1)
|
.with_right(1)
|
||||||
.border();
|
.border();
|
||||||
|
|
||||||
let chat_widget =
|
let chat_widget = chat.widget(joined.session.name.clone(), focus == Focus::Chat);
|
||||||
WidgetWrapper::new(chat.widget(joined.session.name.clone(), focus == Focus::Chat));
|
|
||||||
|
|
||||||
Join2::horizontal(
|
Join2::horizontal(
|
||||||
Join2::vertical(
|
Join2::vertical(
|
||||||
|
|
@ -350,7 +349,7 @@ impl EuphRoom {
|
||||||
if let Some(room) = &self.room {
|
if let Some(room) = &self.room {
|
||||||
match room.send(parent, content) {
|
match room.send(parent, content) {
|
||||||
Ok(id_rx) => self.last_msg_sent = Some(id_rx),
|
Ok(id_rx) => self.last_msg_sent = Some(id_rx),
|
||||||
Err(_) => self.chat.sent(None).await,
|
Err(_) => self.chat.send_failed(),
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -437,16 +436,16 @@ impl EuphRoom {
|
||||||
// Always applicable
|
// Always applicable
|
||||||
match event {
|
match event {
|
||||||
key!('i') => {
|
key!('i') => {
|
||||||
if let Some(id) = self.chat.cursor().await {
|
if let Some(id) = self.chat.cursor() {
|
||||||
if let Some(msg) = logging_unwrap!(self.vault().full_msg(id).await) {
|
if let Some(msg) = logging_unwrap!(self.vault().full_msg(*id).await) {
|
||||||
self.state = State::InspectMessage(msg);
|
self.state = State::InspectMessage(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
key!('I') => {
|
key!('I') => {
|
||||||
if let Some(id) = self.chat.cursor().await {
|
if let Some(id) = self.chat.cursor() {
|
||||||
if let Some(msg) = logging_unwrap!(self.vault().msg(id).await) {
|
if let Some(msg) = logging_unwrap!(self.vault().msg(*id).await) {
|
||||||
self.state = State::Links(LinksState::new(&msg.content));
|
self.state = State::Links(LinksState::new(&msg.content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue