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 {
|
||||
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.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UiError {
|
||||
#[error("{0}")]
|
||||
Vault(#[from] vault::tokio::Error),
|
||||
#[error("{0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
|
|
|||
139
src/ui/chat2.rs
139
src/ui/chat2.rs
|
|
@ -1,4 +1,143 @@
|
|||
mod blocks;
|
||||
mod cursor;
|
||||
mod renderer;
|
||||
mod tree;
|
||||
mod widgets;
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use toss::widgets::{BoxedAsync, EditorState};
|
||||
use toss::{Terminal, WidgetExt};
|
||||
|
||||
use crate::store::{Msg, MsgStore};
|
||||
|
||||
use self::cursor::Cursor;
|
||||
use self::tree::TreeViewState;
|
||||
|
||||
use super::input::{InputEvent, KeyBindingsList};
|
||||
use super::{ChatMsg, UiError};
|
||||
|
||||
pub enum Mode {
|
||||
Tree,
|
||||
}
|
||||
|
||||
pub struct ChatState<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::euph;
|
||||
use crate::macros::logging_unwrap;
|
||||
use crate::ui::chat::{ChatState, Reaction};
|
||||
use crate::ui::chat2::{ChatState, Reaction};
|
||||
use crate::ui::input::{key, InputEvent, KeyBindingsList};
|
||||
use crate::ui::widgets::WidgetWrapper;
|
||||
use crate::ui::widgets2::ListState;
|
||||
|
|
@ -150,12 +150,12 @@ impl EuphRoom {
|
|||
if let Some(id_rx) = &mut self.last_msg_sent {
|
||||
match id_rx.try_recv() {
|
||||
Ok(id) => {
|
||||
self.chat.sent(Some(id)).await;
|
||||
self.chat.send_successful(id);
|
||||
self.last_msg_sent = None;
|
||||
}
|
||||
Err(TryRecvError::Empty) => {} // Wait a bit longer
|
||||
Err(TryRecvError::Closed) => {
|
||||
self.chat.sent(None).await;
|
||||
self.chat.send_failed();
|
||||
self.last_msg_sent = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -243,7 +243,7 @@ impl EuphRoom {
|
|||
chat: &mut EuphChatState,
|
||||
status_widget: impl AsyncWidget<UiError> + Send + Sync + 'static,
|
||||
) -> BoxedAsync<'_, UiError> {
|
||||
let chat_widget = WidgetWrapper::new(chat.widget(String::new(), true));
|
||||
let chat_widget = chat.widget(String::new(), true);
|
||||
|
||||
Join2::vertical(
|
||||
status_widget.segment().with_fixed(true),
|
||||
|
|
@ -264,8 +264,7 @@ impl EuphRoom {
|
|||
.with_right(1)
|
||||
.border();
|
||||
|
||||
let chat_widget =
|
||||
WidgetWrapper::new(chat.widget(joined.session.name.clone(), focus == Focus::Chat));
|
||||
let chat_widget = chat.widget(joined.session.name.clone(), focus == Focus::Chat);
|
||||
|
||||
Join2::horizontal(
|
||||
Join2::vertical(
|
||||
|
|
@ -350,7 +349,7 @@ impl EuphRoom {
|
|||
if let Some(room) = &self.room {
|
||||
match room.send(parent, content) {
|
||||
Ok(id_rx) => self.last_msg_sent = Some(id_rx),
|
||||
Err(_) => self.chat.sent(None).await,
|
||||
Err(_) => self.chat.send_failed(),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -437,16 +436,16 @@ impl EuphRoom {
|
|||
// Always applicable
|
||||
match event {
|
||||
key!('i') => {
|
||||
if let Some(id) = self.chat.cursor().await {
|
||||
if let Some(msg) = logging_unwrap!(self.vault().full_msg(id).await) {
|
||||
if let Some(id) = self.chat.cursor() {
|
||||
if let Some(msg) = logging_unwrap!(self.vault().full_msg(*id).await) {
|
||||
self.state = State::InspectMessage(msg);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
key!('I') => {
|
||||
if let Some(id) = self.chat.cursor().await {
|
||||
if let Some(msg) = logging_unwrap!(self.vault().msg(id).await) {
|
||||
if let Some(id) = self.chat.cursor() {
|
||||
if let Some(msg) = logging_unwrap!(self.vault().msg(*id).await) {
|
||||
self.state = State::Links(LinksState::new(&msg.content));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue