Properly format day delimiter

This commit is contained in:
2023-11-05 17:11:32 +01:00
parent 06e3228bbc
commit 1a34a4ce5e
7 changed files with 171 additions and 76 deletions

View File

@ -39,6 +39,7 @@ color-eyre = "0.6.2"
human-panic = "1.2.0" human-panic = "1.2.0"
# Internal # Internal
enum_dispatch = "0.3.12"
inventory = "0.3" inventory = "0.3"
itertools = "0.11.0" itertools = "0.11.0"
lazy_static = "1.4.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 # UI
ansi-to-tui = "3.1.0" ansi-to-tui = "3.1.0"
chrono = "0.4.31"
crossterm = { version = "0.27.0", features = ["serde", "event-stream"] } crossterm = { version = "0.27.0", features = ["serde", "event-stream"] }
ratatui = { version = "0.24.0", features = ["serde", "macros"] } ratatui = { version = "0.24.0", features = ["serde", "macros"] }
strip-ansi-escapes = "0.2.0" strip-ansi-escapes = "0.2.0"

View File

@ -24,7 +24,7 @@ use tokio::sync::mpsc::UnboundedReceiver;
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_subscriber::prelude::*; use tracing_subscriber::prelude::*;
use super::{Buffer, BufferItem}; use super::{Buffer, BufferItem, BufferItemContent};
use crate::widgets::Prerender; use crate::widgets::Prerender;
/// Maximum number of log lines to be stored in memory /// Maximum number of log lines to be stored in memory
@ -71,10 +71,10 @@ impl Buffer for LogBuffer {
.chain(slice2.into_iter()) .chain(slice2.into_iter())
.rev() .rev()
.map(|(line, prerender)| BufferItem { .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); tracing::error!("Could not convert line from ANSI codes to ratatui: {}", e);
Text::raw(line) Text::raw(line)
}), })),
prerender, prerender,
}), }),
) )

View File

@ -26,8 +26,15 @@ pub use log::LogBuffer;
mod room; mod room;
pub use room::RoomBuffer; pub use room::RoomBuffer;
#[derive(Debug, Clone)]
pub enum BufferItemContent<'buf> {
Text(Text<'buf>),
Divider(Text<'buf>),
Empty,
}
pub struct BufferItem<'buf> { pub struct BufferItem<'buf> {
pub text: Text<'buf>, pub content: BufferItemContent<'buf>,
pub prerender: &'buf Prerender, pub prerender: &'buf Prerender,
} }

View File

@ -17,6 +17,7 @@
use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use chrono::{offset::Local, DateTime};
use color_eyre::eyre::{eyre, Result}; use color_eyre::eyre::{eyre, Result};
use eyeball_im::VectorDiff; use eyeball_im::VectorDiff;
use futures::{FutureExt, Stream, StreamExt}; use futures::{FutureExt, Stream, StreamExt};
@ -32,13 +33,28 @@ use matrix_sdk_ui::timeline::{
use ratatui::text::Text; use ratatui::text::Text;
use smallvec::SmallVec; use smallvec::SmallVec;
use super::{Buffer, BufferItem}; use super::{Buffer, BufferItem, BufferItemContent};
use crate::widgets::Prerender; 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 { pub struct SingleClientRoomBuffer {
room_id: OwnedRoomId, room_id: OwnedRoomId,
client: Client, 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 // TODO: get rid of this trait object, we know it's matrix_sdk_ui::timeline::TimelineStream
stream: Box<dyn Stream<Item = Vec<VectorDiff<Arc<TimelineItem>>>> + Send + Sync + Unpin>, stream: Box<dyn Stream<Item = Vec<VectorDiff<Arc<TimelineItem>>>> + Send + Sync + Unpin>,
timeline: Arc<Timeline>, timeline: Arc<Timeline>,
@ -61,7 +77,7 @@ impl SingleClientRoomBuffer {
} }
} }
fn format_timeline_item(&self, tl_item: impl AsRef<TimelineItem>) -> String { fn format_timeline_item(&self, tl_item: impl AsRef<TimelineItem>) -> OwnedBufferItemContent {
match tl_item.as_ref().kind() { match tl_item.as_ref().kind() {
TimelineItemKind::Event(event) => { TimelineItemKind::Event(event) => {
use matrix_sdk_ui::timeline::TimelineItemContent::*; use matrix_sdk_ui::timeline::TimelineItemContent::*;
@ -71,54 +87,54 @@ impl SingleClientRoomBuffer {
.strip_prefix("@") .strip_prefix("@")
.expect("missing @ prefix"); .expect("missing @ prefix");
match event.content() { match event.content() {
Message(message) => format!(" <{}> {}", sender, message.body().replace("\n", "\n ")), Message(message) => text!(" <{}> {}", sender, message.body().replace("\n", "\n ")),
RedactedMessage => format!("xx <{}> [redacted]", sender), RedactedMessage => text!("xx <{}> [redacted]", sender),
Sticker(sticker) => format!("st <{}> {}", sender, sticker.content().body), Sticker(sticker) => text!("st <{}> {}", sender, sticker.content().body),
UnableToDecrypt(_) => format!("xx <{}> [unable to decrypt]", sender), UnableToDecrypt(_) => text!("xx <{}> [unable to decrypt]", sender),
MembershipChange(change) => { MembershipChange(change) => {
use matrix_sdk_ui::timeline::MembershipChange::*; use matrix_sdk_ui::timeline::MembershipChange::*;
if change.user_id() == event.sender() { if change.user_id() == event.sender() {
let Some(change_kind) = change.change() else { 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 { match change_kind {
None => format!("--- {} made no discernable changes to themselves", sender), None => text!("--- {} made no discernable changes to themselves", sender),
Error => format!( Error => text!(
"xxx {} made a change to themselves that made matrix-sdk-ui error", "xxx {} made a change to themselves that made matrix-sdk-ui error",
sender sender
), ),
Joined => format!("--> {} joined", sender), Joined => text!("--> {} joined", sender),
Left => format!("<-- {} left", sender), Left => text!("<-- {} left", sender),
Banned => format!("-x- {} banned themselves", sender), Banned => text!("-x- {} banned themselves", sender),
Unbanned => format!("-x- {} unbanned themselves", sender), Unbanned => text!("-x- {} unbanned themselves", sender),
Kicked => format!("<!- {} kicked themselves", sender), Kicked => text!("<!- {} kicked themselves", sender),
Invited => format!("-o- {} invited themselves", sender), Invited => text!("-o- {} invited themselves", sender),
KickedAndBanned => format!("<!x {} kicked and banned themselves", sender), KickedAndBanned => text!("<!x {} kicked and banned themselves", sender),
InvitationAccepted => format!("-o> {} accepted an invite", sender), InvitationAccepted => text!("-o> {} accepted an invite", sender),
InvitationRejected => format!("-ox {} rejected an invite", sender), InvitationRejected => text!("-ox {} rejected an invite", sender),
InvitationRevoked => format!("--x {} revoked an invite", sender), InvitationRevoked => text!("--x {} revoked an invite", sender),
Knocked => format!("-?> {} knocked", sender), Knocked => text!("-?> {} knocked", sender),
KnockAccepted => format!("-?o {} accepted a knock", sender), KnockAccepted => text!("-?o {} accepted a knock", sender),
KnockRetracted => format!("-?x {} retracted a knock", sender), KnockRetracted => text!("-?x {} retracted a knock", sender),
KnockDenied => format!("-?x {} denied a knock", sender), KnockDenied => text!("-?x {} denied a knock", sender),
NotImplemented => format!( NotImplemented => text!(
"xxx {} made a change matrix-sdk-ui does not support yet", "xxx {} made a change matrix-sdk-ui does not support yet",
sender sender
), ),
} }
} else if change.user_id() == "" { } else if change.user_id() == "" {
let Some(change_kind) = change.change() else { let Some(change_kind) = change.change() else {
return format!("--- {} made incomprehensible changes", sender); return text!("--- {} made incomprehensible changes", sender);
}; };
match change_kind { match change_kind {
None => format!("--- {} made no discernable changes", sender), None => text!("--- {} made no discernable changes", sender),
Error => format!("xxx {} made a change that made matrix-sdk-ui error", sender), Error => text!("xxx {} made a change that made matrix-sdk-ui error", sender),
Joined | Left | Banned | Unbanned | Kicked | Invited | KickedAndBanned Joined | Left | Banned | Unbanned | Kicked | Invited | KickedAndBanned
| InvitationAccepted | InvitationRejected | InvitationRevoked | Knocked | InvitationAccepted | InvitationRejected | InvitationRevoked | Knocked
| KnockAccepted | KnockRetracted | KnockDenied => { | 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", "xxx {} made a change matrix-sdk-ui does not support yet",
sender sender
), ),
@ -130,44 +146,48 @@ impl SingleClientRoomBuffer {
.strip_prefix("@") .strip_prefix("@")
.expect("missing @ prefix"); .expect("missing @ prefix");
let Some(change_kind) = change.change() else { 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 { match change_kind {
None => format!("--- {} made no discernable changes to {}", sender, target), None => text!("--- {} made no discernable changes to {}", sender, target),
Error => format!( Error => text!(
"xxx {} made a change to {} that made matrix-sdk-ui error", "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 {}: {:?}", "--> {} made a non-sensical change to {}: {:?}",
sender, target, change sender,
target,
change
), ),
Banned => format!("-x- {} banned {}", sender, target), Banned => text!("-x- {} banned {}", sender, target),
Unbanned => format!("-x- {} unbanned {}", sender, target), Unbanned => text!("-x- {} unbanned {}", sender, target),
Kicked => format!("<!- {} kicked {}", sender, target), Kicked => text!("<!- {} kicked {}", sender, target),
Invited => format!("-o- {} invited {}", sender, target), Invited => text!("-o- {} invited {}", sender, target),
KickedAndBanned => format!("<!x {} kicked and banned {}", sender, target), KickedAndBanned => text!("<!x {} kicked and banned {}", sender, target),
InvitationAccepted => format!("-o> {} accepted an invite to {}", sender, target), InvitationAccepted => text!("-o> {} accepted an invite to {}", sender, target),
InvitationRejected => format!("-ox {} rejected an invite to {}", sender, target), InvitationRejected => text!("-ox {} rejected an invite to {}", sender, target),
InvitationRevoked => format!("--x {} revoked an invite to {}", sender, target), InvitationRevoked => text!("--x {} revoked an invite to {}", sender, target),
Knocked => format!("-?> {} made {} knock", sender, target), Knocked => text!("-?> {} made {} knock", sender, target),
KnockAccepted => format!("-?o {} accepted {}'s knock", sender, target), KnockAccepted => text!("-?o {} accepted {}'s knock", sender, target),
KnockRetracted => format!("-?x {} retracted {}'s knock", sender, target), KnockRetracted => text!("-?x {} retracted {}'s knock", sender, target),
KnockDenied => format!("-?x {} denied {}'s knock", sender, target), KnockDenied => text!("-?x {} denied {}'s knock", sender, target),
NotImplemented => format!( NotImplemented => text!(
"xxx {} made a change to {} that matrix-sdk-ui does not support yet", "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) => { OtherState(state) => {
if state.state_key() == "" { if state.state_key() == "" {
format!("--- {} changed the room: {:?}", sender, state.content()) text!("--- {} changed the room: {:?}", sender, state.content())
} else { } else {
format!( text!(
"--- {} changed {}: {:?}", "--- {} changed {}: {:?}",
sender, sender,
state.state_key(), 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: {:?}", "xxx {} sent a {} message that made matrix-sdk-ui error: {:?}",
sender, event_type, error sender,
event_type,
error
), ),
FailedToParseState { FailedToParseState {
event_type, event_type,
state_key, state_key,
error, error,
} => { } => {
format!( text!(
"xxx {} made a {} change to {} that made matrix-sdk-ui error: {:?}", "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) => { TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => {
format!("---- read marker ----") OwnedBufferItemContent::Divider(format!("read marker"))
}, },
TimelineItemKind::Virtual(VirtualTimelineItem::DayDivider(day_divider)) => { TimelineItemKind::Virtual(VirtualTimelineItem::DayDivider(day_divider)) => {
format!("---- day divider: {:?} ----", day_divider) match day_divider.to_system_time() {
Some(system_time) => {
let datetime: DateTime<Local> = 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), timeline: Arc::new(timeline),
items: items // FIXME: it's always empty. why? items: items // FIXME: it's always empty. why?
.into_iter() .into_iter()
.map(|item| (format!("Initial item: {:#?}", item), Prerender::new())) .map(|item| (text!("Initial item: {:#?}", item), Prerender::new()))
.collect(), .collect(),
stream: Box::new(stream), stream: Box::new(stream),
back_pagination_request: AtomicU16::new(0), back_pagination_request: AtomicU16::new(0),
@ -326,7 +360,11 @@ impl Buffer for RoomBuffer {
.iter() .iter()
.rev() .rev()
.map(|(line, prerender)| BufferItem { .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, prerender,
}), }),
) )

View File

@ -18,14 +18,17 @@ use std::ops::DerefMut;
use color_eyre::eyre::{Result, WrapErr}; use color_eyre::eyre::{Result, WrapErr};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use enum_dispatch::enum_dispatch;
use ratatui::{prelude::*, widgets::*}; use ratatui::{prelude::*, widgets::*};
use crate::components::Action; use crate::components::Action;
use crate::widgets::prerender::{PrerenderInner, PrerenderValue}; use crate::widgets::prerender::{PrerenderInner, PrerenderValue};
use crate::widgets::{BottomAlignedParagraph, OverlappableWidget}; use crate::widgets::{
BacklogItemWidget, BottomAlignedParagraph, Divider, EmptyWidget, OverlappableWidget,
};
use super::Component; use super::Component;
use crate::buffers::BufferItem; use crate::buffers::{BufferItem, BufferItemContent};
#[derive(Default)] #[derive(Default)]
pub struct Backlog { pub struct Backlog {
@ -83,6 +86,21 @@ impl Backlog {
pub fn scroll_down(&mut self, lines: u64) { pub fn scroll_down(&mut self, lines: u64) {
self.scroll = self.scroll.saturating_sub(20); 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. /// Returns how many lines are unused at the top of the area.
pub fn draw_items<'a>( pub fn draw_items<'a>(
&mut self, &mut self,
@ -111,7 +129,7 @@ impl Backlog {
value: PrerenderValue::NotRendered(height), value: PrerenderValue::NotRendered(height),
}) if *key == text_area.width => *height, }) if *key == text_area.width => *height,
prerender => { 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); let expected_height = widget.height(text_area.width);
*prerender = Some(PrerenderInner { *prerender = Some(PrerenderInner {
key: text_area.width, key: text_area.width,
@ -128,7 +146,7 @@ impl Backlog {
} }
// TODO: cache this // 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); let (_, height) = widget.render_overlap(text_area, frame_buffer);
text_area.height = text_area.height.saturating_sub(height); text_area.height = text_area.height.saturating_sub(height);
@ -173,7 +191,7 @@ impl Backlog {
buf.area().height buf.area().height
}, },
prerender => { 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); let (drawn_width, height) = widget.render_overlap(text_area, frame_buffer);
assert!(drawn_width <= text_area.width); assert!(drawn_width <= text_area.width);

View File

@ -75,14 +75,13 @@ impl<'a> BottomAlignedParagraph<'a> {
.take(u16::MAX as usize) // Avoid overflows in ratatui for excessively long messages .take(u16::MAX as usize) // Avoid overflows in ratatui for excessively long messages
.collect() .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> { 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) { 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 // Inspired by https://github.com/ratatui-org/ratatui/blob/9f371000968044e09545d66068c4ed4ea4b35d8a/src/widgets/paragraph.rs#L214-L275
let lines = self.wrap_lines(area.width); let lines = self.wrap_lines(area.width);

View File

@ -14,6 +14,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
use enum_dispatch::enum_dispatch;
use ratatui::prelude::*; use ratatui::prelude::*;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
@ -23,15 +24,45 @@ pub use bottom_aligned_paragraph::BottomAlignedParagraph;
pub(crate) mod prerender; pub(crate) mod prerender;
pub use prerender::Prerender; pub use prerender::Prerender;
mod divider;
pub use divider::Divider;
#[rustfmt::skip] // reflow is vendored from ratatui, let's avoid changes #[rustfmt::skip] // reflow is vendored from ratatui, let's avoid changes
mod reflow; mod reflow;
/// A [`Widget`] that returns how many columns and lines it needs to draw everything /// 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) /// (which is the number of lines it actually drew if it fits on screen)
#[enum_dispatch]
pub trait OverlappableWidget { 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); 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<W: OverlappableWidget> Widget for W { impl<W: OverlappableWidget> Widget for W {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {