diff --git a/Cargo.toml b/Cargo.toml index efe8a7e..31d385d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ color-eyre = "0.6.2" human-panic = "1.2.0" # Internal +enum_dispatch = "0.3.12" inventory = "0.3" itertools = "0.11.0" lazy_static = "1.4.0" @@ -60,6 +61,7 @@ matrix-sdk-ui = { git = "https://github.com/matrix-org/matrix-rust-sdk.git", rev # UI ansi-to-tui = "3.1.0" +chrono = "0.4.31" crossterm = { version = "0.27.0", features = ["serde", "event-stream"] } ratatui = { version = "0.24.0", features = ["serde", "macros"] } strip-ansi-escapes = "0.2.0" diff --git a/src/buffers/log.rs b/src/buffers/log.rs index 41b8d4b..667f69e 100644 --- a/src/buffers/log.rs +++ b/src/buffers/log.rs @@ -24,7 +24,7 @@ use tokio::sync::mpsc::UnboundedReceiver; use tracing_error::ErrorLayer; use tracing_subscriber::prelude::*; -use super::{Buffer, BufferItem}; +use super::{Buffer, BufferItem, BufferItemContent}; use crate::widgets::Prerender; /// Maximum number of log lines to be stored in memory @@ -71,10 +71,10 @@ impl Buffer for LogBuffer { .chain(slice2.into_iter()) .rev() .map(|(line, prerender)| BufferItem { - text: line.clone().into_text().unwrap_or_else(|e| { + content: BufferItemContent::Text(line.clone().into_text().unwrap_or_else(|e| { tracing::error!("Could not convert line from ANSI codes to ratatui: {}", e); Text::raw(line) - }), + })), prerender, }), ) diff --git a/src/buffers/mod.rs b/src/buffers/mod.rs index 85d2c6a..2558a6e 100644 --- a/src/buffers/mod.rs +++ b/src/buffers/mod.rs @@ -26,8 +26,15 @@ pub use log::LogBuffer; mod room; pub use room::RoomBuffer; +#[derive(Debug, Clone)] +pub enum BufferItemContent<'buf> { + Text(Text<'buf>), + Divider(Text<'buf>), + Empty, +} + pub struct BufferItem<'buf> { - pub text: Text<'buf>, + pub content: BufferItemContent<'buf>, pub prerender: &'buf Prerender, } diff --git a/src/buffers/room.rs b/src/buffers/room.rs index e4a9578..878a547 100644 --- a/src/buffers/room.rs +++ b/src/buffers/room.rs @@ -17,6 +17,7 @@ use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::{Arc, OnceLock}; +use chrono::{offset::Local, DateTime}; use color_eyre::eyre::{eyre, Result}; use eyeball_im::VectorDiff; use futures::{FutureExt, Stream, StreamExt}; @@ -32,13 +33,28 @@ use matrix_sdk_ui::timeline::{ use ratatui::text::Text; use smallvec::SmallVec; -use super::{Buffer, BufferItem}; +use super::{Buffer, BufferItem, BufferItemContent}; use crate::widgets::Prerender; +/// Like [`BufferItemContent`] but owned. +#[derive(Debug, Clone)] +pub enum OwnedBufferItemContent { + Text(String), + Divider(String), + Empty, +} + +/// Like `format!()` but returns OwnedBufferItemContent::Text +macro_rules! text { + ($($tokens:tt)*) => { + OwnedBufferItemContent::Text(format!($($tokens)*)) + } +} + pub struct SingleClientRoomBuffer { room_id: OwnedRoomId, client: Client, - items: imbl::vector::Vector<(String, Prerender)>, + items: imbl::vector::Vector<(OwnedBufferItemContent, Prerender)>, // TODO: get rid of this trait object, we know it's matrix_sdk_ui::timeline::TimelineStream stream: Box>>> + Send + Sync + Unpin>, timeline: Arc, @@ -61,7 +77,7 @@ impl SingleClientRoomBuffer { } } - fn format_timeline_item(&self, tl_item: impl AsRef) -> String { + fn format_timeline_item(&self, tl_item: impl AsRef) -> OwnedBufferItemContent { match tl_item.as_ref().kind() { TimelineItemKind::Event(event) => { use matrix_sdk_ui::timeline::TimelineItemContent::*; @@ -71,54 +87,54 @@ impl SingleClientRoomBuffer { .strip_prefix("@") .expect("missing @ prefix"); match event.content() { - Message(message) => format!(" <{}> {}", sender, message.body().replace("\n", "\n ")), - RedactedMessage => format!("xx <{}> [redacted]", sender), - Sticker(sticker) => format!("st <{}> {}", sender, sticker.content().body), - UnableToDecrypt(_) => format!("xx <{}> [unable to decrypt]", sender), + Message(message) => text!(" <{}> {}", sender, message.body().replace("\n", "\n ")), + RedactedMessage => text!("xx <{}> [redacted]", sender), + Sticker(sticker) => text!("st <{}> {}", sender, sticker.content().body), + UnableToDecrypt(_) => text!("xx <{}> [unable to decrypt]", sender), MembershipChange(change) => { use matrix_sdk_ui::timeline::MembershipChange::*; if change.user_id() == event.sender() { let Some(change_kind) = change.change() else { - return format!("--- {} made incomprehensible changes to themselves", sender); + return text!("--- {} made incomprehensible changes to themselves", sender); }; match change_kind { - None => format!("--- {} made no discernable changes to themselves", sender), - Error => format!( + None => text!("--- {} made no discernable changes to themselves", sender), + Error => text!( "xxx {} made a change to themselves that made matrix-sdk-ui error", sender ), - Joined => format!("--> {} joined", sender), - Left => format!("<-- {} left", sender), - Banned => format!("-x- {} banned themselves", sender), - Unbanned => format!("-x- {} unbanned themselves", sender), - Kicked => format!(" format!("-o- {} invited themselves", sender), - KickedAndBanned => format!(" format!("-o> {} accepted an invite", sender), - InvitationRejected => format!("-ox {} rejected an invite", sender), - InvitationRevoked => format!("--x {} revoked an invite", sender), - Knocked => format!("-?> {} knocked", sender), - KnockAccepted => format!("-?o {} accepted a knock", sender), - KnockRetracted => format!("-?x {} retracted a knock", sender), - KnockDenied => format!("-?x {} denied a knock", sender), - NotImplemented => format!( + Joined => text!("--> {} joined", sender), + Left => text!("<-- {} left", sender), + Banned => text!("-x- {} banned themselves", sender), + Unbanned => text!("-x- {} unbanned themselves", sender), + Kicked => text!(" text!("-o- {} invited themselves", sender), + KickedAndBanned => text!(" text!("-o> {} accepted an invite", sender), + InvitationRejected => text!("-ox {} rejected an invite", sender), + InvitationRevoked => text!("--x {} revoked an invite", sender), + Knocked => text!("-?> {} knocked", sender), + KnockAccepted => text!("-?o {} accepted a knock", sender), + KnockRetracted => text!("-?x {} retracted a knock", sender), + KnockDenied => text!("-?x {} denied a knock", sender), + NotImplemented => text!( "xxx {} made a change matrix-sdk-ui does not support yet", sender ), } } else if change.user_id() == "" { let Some(change_kind) = change.change() else { - return format!("--- {} made incomprehensible changes", sender); + return text!("--- {} made incomprehensible changes", sender); }; match change_kind { - None => format!("--- {} made no discernable changes", sender), - Error => format!("xxx {} made a change that made matrix-sdk-ui error", sender), + None => text!("--- {} made no discernable changes", sender), + Error => text!("xxx {} made a change that made matrix-sdk-ui error", sender), Joined | Left | Banned | Unbanned | Kicked | Invited | KickedAndBanned | InvitationAccepted | InvitationRejected | InvitationRevoked | Knocked | KnockAccepted | KnockRetracted | KnockDenied => { - format!("--> {} made a non-sensical change: {:?}", sender, change) + text!("--> {} made a non-sensical change: {:?}", sender, change) }, - NotImplemented => format!( + NotImplemented => text!( "xxx {} made a change matrix-sdk-ui does not support yet", sender ), @@ -130,44 +146,48 @@ impl SingleClientRoomBuffer { .strip_prefix("@") .expect("missing @ prefix"); let Some(change_kind) = change.change() else { - return format!("--- {} made incomprehensible changes to {}", sender, target); + return text!("--- {} made incomprehensible changes to {}", sender, target); }; match change_kind { - None => format!("--- {} made no discernable changes to {}", sender, target), - Error => format!( + None => text!("--- {} made no discernable changes to {}", sender, target), + Error => text!( "xxx {} made a change to {} that made matrix-sdk-ui error", - sender, target + sender, + target ), - Joined | Left => format!( + Joined | Left => text!( "--> {} made a non-sensical change to {}: {:?}", - sender, target, change + sender, + target, + change ), - Banned => format!("-x- {} banned {}", sender, target), - Unbanned => format!("-x- {} unbanned {}", sender, target), - Kicked => format!(" format!("-o- {} invited {}", sender, target), - KickedAndBanned => format!(" format!("-o> {} accepted an invite to {}", sender, target), - InvitationRejected => format!("-ox {} rejected an invite to {}", sender, target), - InvitationRevoked => format!("--x {} revoked an invite to {}", sender, target), - Knocked => format!("-?> {} made {} knock", sender, target), - KnockAccepted => format!("-?o {} accepted {}'s knock", sender, target), - KnockRetracted => format!("-?x {} retracted {}'s knock", sender, target), - KnockDenied => format!("-?x {} denied {}'s knock", sender, target), - NotImplemented => format!( + Banned => text!("-x- {} banned {}", sender, target), + Unbanned => text!("-x- {} unbanned {}", sender, target), + Kicked => text!(" text!("-o- {} invited {}", sender, target), + KickedAndBanned => text!(" text!("-o> {} accepted an invite to {}", sender, target), + InvitationRejected => text!("-ox {} rejected an invite to {}", sender, target), + InvitationRevoked => text!("--x {} revoked an invite to {}", sender, target), + Knocked => text!("-?> {} made {} knock", sender, target), + KnockAccepted => text!("-?o {} accepted {}'s knock", sender, target), + KnockRetracted => text!("-?x {} retracted {}'s knock", sender, target), + KnockDenied => text!("-?x {} denied {}'s knock", sender, target), + NotImplemented => text!( "xxx {} made a change to {} that matrix-sdk-ui does not support yet", - sender, target + sender, + target ), } } }, - ProfileChange(_) => format!("--- {} updated their profile", sender), + ProfileChange(_) => text!("--- {} updated their profile", sender), OtherState(state) => { if state.state_key() == "" { - format!("--- {} changed the room: {:?}", sender, state.content()) + text!("--- {} changed the room: {:?}", sender, state.content()) } else { - format!( + text!( "--- {} changed {}: {:?}", sender, state.state_key(), @@ -175,28 +195,42 @@ impl SingleClientRoomBuffer { ) } }, - FailedToParseMessageLike { event_type, error } => format!( + FailedToParseMessageLike { event_type, error } => text!( "xxx {} sent a {} message that made matrix-sdk-ui error: {:?}", - sender, event_type, error + sender, + event_type, + error ), FailedToParseState { event_type, state_key, error, } => { - format!( + text!( "xxx {} made a {} change to {} that made matrix-sdk-ui error: {:?}", - sender, event_type, state_key, error + sender, + event_type, + state_key, + error ) }, - Poll(_) => format!("-?- {} acted on a poll", sender), + Poll(_) => text!("-?- {} acted on a poll", sender), } }, TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => { - format!("---- read marker ----") + OwnedBufferItemContent::Divider(format!("read marker")) }, TimelineItemKind::Virtual(VirtualTimelineItem::DayDivider(day_divider)) => { - format!("---- day divider: {:?} ----", day_divider) + match day_divider.to_system_time() { + Some(system_time) => { + let datetime: DateTime = system_time.into(); + OwnedBufferItemContent::Divider(format!("{}", datetime.format("%Y-%m-%d"))) + }, + None => { + tracing::warn!("Could not convert {:?} to system time", day_divider); + OwnedBufferItemContent::Empty + }, + } }, } } @@ -279,7 +313,7 @@ impl RoomBuffer { timeline: Arc::new(timeline), items: items // FIXME: it's always empty. why? .into_iter() - .map(|item| (format!("Initial item: {:#?}", item), Prerender::new())) + .map(|item| (text!("Initial item: {:#?}", item), Prerender::new())) .collect(), stream: Box::new(stream), back_pagination_request: AtomicU16::new(0), @@ -326,7 +360,11 @@ impl Buffer for RoomBuffer { .iter() .rev() .map(|(line, prerender)| BufferItem { - text: Text::raw(line), + content: match line { + OwnedBufferItemContent::Text(text) => BufferItemContent::Text(Text::raw(text)), + OwnedBufferItemContent::Divider(text) => BufferItemContent::Divider(Text::raw(text)), + OwnedBufferItemContent::Empty => BufferItemContent::Empty, + }, prerender, }), ) diff --git a/src/components/backlog.rs b/src/components/backlog.rs index a8745d6..b80e627 100644 --- a/src/components/backlog.rs +++ b/src/components/backlog.rs @@ -18,14 +18,17 @@ use std::ops::DerefMut; use color_eyre::eyre::{Result, WrapErr}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use enum_dispatch::enum_dispatch; use ratatui::{prelude::*, widgets::*}; use crate::components::Action; use crate::widgets::prerender::{PrerenderInner, PrerenderValue}; -use crate::widgets::{BottomAlignedParagraph, OverlappableWidget}; +use crate::widgets::{ + BacklogItemWidget, BottomAlignedParagraph, Divider, EmptyWidget, OverlappableWidget, +}; use super::Component; -use crate::buffers::BufferItem; +use crate::buffers::{BufferItem, BufferItemContent}; #[derive(Default)] pub struct Backlog { @@ -83,6 +86,21 @@ impl Backlog { pub fn scroll_down(&mut self, lines: u64) { self.scroll = self.scroll.saturating_sub(20); } + + fn build_widget<'a>(&self, content: BufferItemContent<'a>, scroll: u64) -> BacklogItemWidget<'a> { + match content { + BufferItemContent::Text(text) => BottomAlignedParagraph::new(text).scroll(scroll).into(), + BufferItemContent::Divider(text) => { + if scroll == 0 { + Divider::new(Paragraph::new(text).alignment(Alignment::Center)).into() + } else { + EmptyWidget.into() + } + }, + BufferItemContent::Empty => EmptyWidget.into(), + } + } + /// Returns how many lines are unused at the top of the area. pub fn draw_items<'a>( &mut self, @@ -111,7 +129,7 @@ impl Backlog { value: PrerenderValue::NotRendered(height), }) if *key == text_area.width => *height, prerender => { - let widget = BottomAlignedParagraph::new(item.text.clone()); + let widget = self.build_widget(item.content.clone(), 0); let expected_height = widget.height(text_area.width); *prerender = Some(PrerenderInner { key: text_area.width, @@ -128,7 +146,7 @@ impl Backlog { } // TODO: cache this - let widget = BottomAlignedParagraph::new(item.text).scroll(scroll); + let widget = self.build_widget(item.content, scroll); let (_, height) = widget.render_overlap(text_area, frame_buffer); text_area.height = text_area.height.saturating_sub(height); @@ -173,7 +191,7 @@ impl Backlog { buf.area().height }, prerender => { - let widget = BottomAlignedParagraph::new(item.text); + let widget = self.build_widget(item.content, 0); let (drawn_width, height) = widget.render_overlap(text_area, frame_buffer); assert!(drawn_width <= text_area.width); diff --git a/src/widgets/bottom_aligned_paragraph.rs b/src/widgets/bottom_aligned_paragraph.rs index 222ac0b..9a6f49b 100644 --- a/src/widgets/bottom_aligned_paragraph.rs +++ b/src/widgets/bottom_aligned_paragraph.rs @@ -75,14 +75,13 @@ impl<'a> BottomAlignedParagraph<'a> { .take(u16::MAX as usize) // Avoid overflows in ratatui for excessively long messages .collect() } - - /// Returns how many lines it would actuall draw if rendered with the given height - pub fn height(&self, width: u16) -> u64 { - self.wrap_lines(width).len() as u64 - } } impl<'a> OverlappableWidget for BottomAlignedParagraph<'a> { + fn height(&self, width: u16) -> u64 { + self.wrap_lines(width).len() as u64 + } + fn render_overlap(self, area: Rect, buf: &mut Buffer) -> (u16, u16) { // Inspired by https://github.com/ratatui-org/ratatui/blob/9f371000968044e09545d66068c4ed4ea4b35d8a/src/widgets/paragraph.rs#L214-L275 let lines = self.wrap_lines(area.width); diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 0428cbe..762b0c7 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -14,6 +14,7 @@ * along with this program. If not, see . */ +use enum_dispatch::enum_dispatch; use ratatui::prelude::*; use ratatui::widgets::Widget; @@ -23,15 +24,45 @@ pub use bottom_aligned_paragraph::BottomAlignedParagraph; pub(crate) mod prerender; pub use prerender::Prerender; +mod divider; +pub use divider::Divider; + #[rustfmt::skip] // reflow is vendored from ratatui, let's avoid changes mod reflow; /// A [`Widget`] that returns how many columns and lines it needs to draw everything /// (which is the number of lines it actually drew if it fits on screen) +#[enum_dispatch] pub trait OverlappableWidget { + /// Returns how many lines, from the bottom of the area, this widget would actually draw + /// if rendered with the given width + fn height(&self, width: u16) -> u64; + + /// Render the widget from the bottom, and return the width and height of the area + /// actually drawn. fn render_overlap(self, area: Rect, buf: &mut Buffer) -> (u16, u16); } +/// Enum of [`OverlappableWidget`] implementors +#[enum_dispatch(OverlappableWidget)] +pub enum BacklogItemWidget<'a> { + Paragraph(BottomAlignedParagraph<'a>), + Divider(Divider<'a>), + Empty(EmptyWidget), +} + +#[derive(Clone)] +pub struct EmptyWidget; + +impl OverlappableWidget for EmptyWidget { + fn height(&self, _width: u16) -> u64 { + 0 + } + fn render_overlap(self, _area: Rect, _buf: &mut Buffer) -> (u16, u16) { + (0, 0) + } +} + /* impl Widget for W { fn render(self, area: Rect, buf: &mut Buffer) {