use std::collections::HashMap;
use std::ops::Range;
use std::time::SystemTime;
use chrono::{Datelike, Local, TimeZone};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui_image::FontSize;
use ratatui_image::sliced::{SignedPosition, SlicedImage};
use unicode_width::UnicodeWidthStr;
use super::truncate;
use crate::buttons::Keyboard;
use crate::chats::{Chats, Presence, Seen};
use crate::images::Images;
use crate::messages::{
Card, Fetched, Format, Msg, OpenChat, Origin, Preview, Replied, ReplyTo, SPOILER, ScrollAnchor,
SendState, Sender, Styled,
};
use crate::reactions::{self, Reaction};
use crate::search;
use crate::secret::{self, SecretState};
use crate::service::Part;
use crate::theme::Colors;
use crate::topics::Topic;
use crate::voice::{self, Playback, Voice};
const BUBBLE_WIDTH_PERCENT: usize = 75;
const TOPIC_MARKS: usize = 12;
const MAX_PHOTO_COLS: usize = 40;
const MAX_PHOTO_ROWS: usize = 16;
const MAX_STICKER_COLS: usize = 20;
const MAX_STICKER_ROWS: usize = 10;
const META_GAP: usize = 3;
const CHIP_GAP: usize = 1;
const REPLY_BUTTONS: &str = "⌨ ";
const PIN: &str = "📌";
pub struct Names<'a> {
pub users: &'a HashMap<i64, String>,
pub chats: &'a Chats,
}
impl Names<'_> {
pub(super) fn get(&self, sender: Sender) -> String {
let name = match sender {
Sender::User(id) => self.users.get(&id).cloned(),
Sender::Chat(id) => self.chats.get(id).map(|c| c.title.clone()),
};
name.unwrap_or_else(|| "Unknown".into())
}
pub(super) fn origin(&self, origin: &Origin) -> String {
match origin {
Origin::User(id) => self.get(Sender::User(*id)),
Origin::Hidden(name) => name.clone(),
Origin::Chat { chat_id, signature } => {
let title = self.get(Sender::Chat(*chat_id));
if signature.is_empty() {
title
} else {
format!("{title} ({signature})")
}
}
}
}
pub(super) fn author(&self, sender: Sender, outgoing: bool) -> String {
let mut name = self.get(sender);
if outgoing {
name.push_str(" (me)");
}
name
}
}
struct Quote {
name: String,
color: Option<Color>,
text: Option<String>,
}
struct Header {
name: Option<(String, Color)>,
forward: Option<String>,
quote: Option<Quote>,
}
impl Header {
fn rows(&self) -> usize {
let quote = self
.quote
.as_ref()
.map_or(0, |q| 1 + usize::from(q.text.is_some()));
usize::from(self.name.is_some()) + usize::from(self.forward.is_some()) + quote
}
}
#[derive(Clone)]
struct Placed {
id: i64,
start: usize,
bubble_start: usize,
end: usize,
day: usize,
}
pub(super) struct PhotoSlot {
pub line: usize,
pub x: u16,
pub cols: u16,
pub rows: u16,
pub photo: Preview,
}
#[allow(clippy::too_many_arguments)]
pub fn draw(
frame: &mut Frame,
area: Rect,
open: &mut OpenChat,
topic: Option<&Topic>,
names: &Names,
images: &mut Images,
focused: bool,
covered: bool,
colors: &Colors,
block_gaps: bool,
playing: Option<Playback>,
) {
let chat = names.chats.get(open.chat_id);
let secret = names.chats.secret(open.chat_id);
let mut title = vec![Span::from(" ")];
let is_secret = names.chats.is_secret(open.chat_id);
if is_secret {
title.extend(super::lock_spans(colors));
}
let badge = names.chats.badge(open.chat_id);
let room = usize::from(area.width.saturating_sub(4))
.saturating_sub(if is_secret { super::LOCK_WIDTH } else { 0 })
.saturating_sub(badge.map_or(0, |b| b.mark().width()));
let topic_name = topic.map(|t| truncate(&t.name, room.saturating_sub(TOPIC_MARKS) / 2));
let topic_w = topic_name.as_ref().map_or(0, |n| n.width() + TOPIC_MARKS);
let chat_title = names.chats.title(open.chat_id).unwrap_or_default();
title.push(
Span::from(truncate(chat_title, room.saturating_sub(topic_w))).style(super::title_style(
names.chats,
open.chat_id,
colors,
)),
);
if let Some(badge) = badge {
title.push(super::badge_span(badge, colors));
}
if let (Some(topic), Some(name)) = (topic, topic_name) {
title.push(Span::from(" › ").fg(colors.muted));
let color = match topic.general {
true => colors.fg,
false => colors.names[topic.accent()],
};
title.push(Span::from(name).fg(color).bold());
if topic.closed {
title.push(Span::from(" · closed").fg(colors.muted));
}
}
title.push(Span::from(" "));
let timer = names.chats.auto_delete(open.chat_id);
if secret.is_some() && timer > 0 {
let label = format!("· 🔥 {} ", secret::timer_label(timer));
title.push(Span::from(label).fg(colors.secret));
}
if let Some(secret) = secret.filter(|s| s.state != SecretState::Ready) {
let (label, color) = match secret.state {
SecretState::Pending => (
format!(
"· waiting for {} to come online ",
names.chats.title(open.chat_id).unwrap_or("them")
),
colors.warning,
),
_ => ("· ended ".into(), colors.muted),
};
title.push(Span::from(label).fg(color));
} else if let Some(doing) = chat.and_then(|c| super::activity(c, names, topic)) {
title.push(Span::from(format!("· {doing} ")).fg(colors.activity));
} else if let Some(seen) = names.chats.seen(open.chat_id) {
let (label, online) = seen_label(seen, Local::now().timestamp());
let color = if online {
colors.activity
} else {
colors.muted
};
title.push(Span::from(format!("· {label} ")).fg(color));
}
if let Some(search) = &open.search {
title.push(Span::from(format!(
"· /{} {} ",
search.query,
search.position()
)));
}
if open.loading.is_some() {
title.push(Span::from("· loading… "));
}
let border = super::border(focused, colors);
let block = Block::bordered()
.title(Line::from(title))
.border_style(border);
let inner = block.inner(area);
frame.render_widget(block, area);
let inner = match open.pinned.first() {
Some(newest) => {
let [bar, rule, rest] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(inner);
let line = pinned_bar(newest, open.pinned.len(), bar.width.into(), colors);
frame.render_widget(line, bar);
let rule_line = format!("├{}┤", "─".repeat(rule.width.into()));
let rule = Rect {
x: area.x,
width: rule.width + 2,
..rule
};
frame.render_widget(Line::styled(rule_line, border), rule);
rest
}
None => inner,
};
if open.messages.is_empty() {
let text = if open.loading.is_some() {
"Loading…"
} else {
"No messages yet"
};
frame.render_widget(Paragraph::new(text).fg(colors.muted).centered(), inner);
return;
}
let [left, body, right] = Layout::horizontal([
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(inner);
let show_names = chat.is_none_or(|c| !c.is_channel);
let font = images.font_size();
let mut laid = measure(
open,
names,
show_names,
block_gaps,
body.width as usize,
font,
images.draws_photos(),
colors,
playing,
);
let height = body.height as usize;
let placed = laid.placed.clone();
let selected = match open.selected {
Some(id) => placed.iter().find(|p| p.id == id),
None => placed.last(),
};
let (top, anchor) = scroll_top(open, &placed, selected, laid.total, height);
let date = (height > 3).then(|| laid.date_above(top)).flatten();
let (first, rows) = match (&date, selected) {
(None, _) => (top, height),
(Some(_), Some(sel)) if sel.start == top => (top, height - 1),
(Some(_), _) => (top + 1, height - 1),
};
let photos = std::mem::take(&mut laid.photos);
let visible = laid.lines(first, rows, block_gaps, colors);
open.scroll = anchor;
if let Some(date) = date {
let row = Rect { height: 1, ..body };
frame.render_widget(Line::from(date).fg(colors.muted).centered(), row);
}
let pad = (rows - visible.len()) as u16 + (height - rows) as u16;
let shift = |r: Rect| Rect {
y: r.y + pad,
height: r.height - pad,
..r
};
frame.render_widget(Paragraph::new(visible), shift(body));
draw_photos(frame, shift(body), &photos, first, images, covered, colors);
let gutters = [shift(left), shift(right)];
if let Some(reply) = &open.reply
&& let Some(target) = placed.iter().find(|p| p.id == reply.id)
{
mark(frame, gutters, target, first, colors.reply);
}
if let Some(editing) = &open.editing
&& let Some(target) = placed.iter().find(|p| p.id == editing.id)
{
mark(frame, gutters, target, first, colors.edit);
}
if focused && let Some(sel) = selected {
mark(frame, gutters, sel, first, colors.accent);
}
}
fn seen_label(seen: Seen, now: i64) -> (String, bool) {
let presence = match seen {
Seen::Bot => return ("bot".into(), false),
Seen::Person(presence) => presence,
};
let was_online = match presence {
Presence::Online(until) if i64::from(until) > now => return ("online".into(), true),
Presence::Online(at) | Presence::Offline(at) => i64::from(at),
Presence::Recently => return ("last seen recently".into(), false),
Presence::LastWeek => return ("last seen within a week".into(), false),
Presence::LastMonth => return ("last seen within a month".into(), false),
Presence::LongAgo => return ("last seen a long time ago".into(), false),
};
let ago = (now - was_online).max(0);
let label = match ago {
0..60 => "last seen just now".into(),
60..120 => "last seen a minute ago".into(),
120..3600 => format!("last seen {} minutes ago", ago / 60),
_ => {
let (Some(then), Some(today)) = (
Local.timestamp_opt(was_online, 0).single(),
Local.timestamp_opt(now, 0).single(),
) else {
return ("last seen a long time ago".into(), false);
};
let days = (today.date_naive() - then.date_naive()).num_days();
match days {
0 => format!("last seen today at {}", then.format("%H:%M")),
1 => format!("last seen yesterday at {}", then.format("%H:%M")),
_ if then.year() == today.year() => format!("last seen {}", then.format("%-d %b")),
_ => format!("last seen {}", then.format("%-d %b %Y")),
}
}
};
(label, false)
}
fn mark(frame: &mut Frame, gutters: [Rect; 2], msg: &Placed, top: usize, color: Color) {
let height = usize::from(gutters[0].height);
let rows = msg.bubble_start.max(top)..msg.end.min(top + height);
if rows.is_empty() {
return;
}
let offset = (rows.start - top) as u16;
for (gutter, bar) in gutters.into_iter().zip(["▌", "▐"]) {
let area = Rect {
y: gutter.y + offset,
height: rows.len() as u16,
..gutter
};
frame.render_widget(
Paragraph::new(vec![Line::from(bar); rows.len()]).fg(color),
area,
);
}
}
pub(super) fn draw_photos(
frame: &mut Frame,
area: Rect,
photos: &[PhotoSlot],
top: usize,
images: &mut Images,
covered: bool,
colors: &Colors,
) {
for slot in photos {
let y = slot.line as i64 - top as i64;
if y >= i64::from(area.height) || y + i64::from(slot.rows) <= 0 {
continue;
}
images.want(&slot.photo, slot.cols, slot.rows);
if covered && images.paints_over() {
continue;
}
if let Some(image) = images.get(&slot.photo, slot.cols, slot.rows) {
let position = SignedPosition::from((slot.x as i16, y as i16));
frame.render_widget(SlicedImage::new(image, position), area);
continue;
}
let label = if images.is_broken(&slot.photo) {
"Preview unavailable"
} else {
"Loading…"
};
let middle = y + i64::from(slot.rows / 2);
if label.width() <= usize::from(slot.cols) && (0..i64::from(area.height)).contains(&middle)
{
let row = Rect {
x: area.x + slot.x,
y: area.y + middle as u16,
width: slot.cols.min(area.width.saturating_sub(slot.x)),
height: 1,
};
frame.render_widget(Line::from(label).fg(colors.subtle).centered(), row);
}
}
}
fn photo_cells(photo: &Preview, max_cols: usize, font: FontSize) -> (u16, u16) {
let (fw, fh) = (f64::from(font.width.max(1)), f64::from(font.height.max(1)));
let (pw, ph) = (f64::from(photo.width), f64::from(photo.height));
let (limit_cols, limit_rows) = if photo.sticker {
(MAX_STICKER_COLS, MAX_STICKER_ROWS)
} else {
(MAX_PHOTO_COLS, MAX_PHOTO_ROWS)
};
let mut cols = (max_cols.min(limit_cols) as f64).min(pw / fw);
let mut rows = cols * fw * ph / pw / fh;
if rows > limit_rows as f64 {
rows = limit_rows as f64;
cols = rows * fh * pw / ph / fw;
}
(cols.round().max(1.0) as u16, rows.round().max(1.0) as u16)
}
fn scroll_top(
open: &OpenChat,
placed: &[Placed],
selected: Option<&Placed>,
total: usize,
height: usize,
) -> (usize, Option<ScrollAnchor>) {
let max_top = total.saturating_sub(height);
let mut top = match (open.selected, open.scroll) {
(Some(_), Some(anchor)) => placed
.iter()
.find(|p| p.id == anchor.msg_id)
.map_or(max_top, |p| p.start + anchor.offset),
_ => max_top,
};
if let Some(sel) = selected {
if sel.start < top {
top = sel.start;
} else if sel.end > top + height {
top = if sel.end - sel.start > height {
sel.start
} else {
sel.end - height
};
}
}
let top = top.min(max_top);
let anchor = placed
.iter()
.rev()
.find(|p| p.start <= top)
.map(|p| ScrollAnchor {
msg_id: p.id,
offset: top - p.start,
});
(top, anchor)
}
#[allow(clippy::too_many_arguments)]
fn measure<'a>(
open: &'a OpenChat,
names: &Names,
show_names: bool,
gaps: bool,
width: usize,
font: FontSize,
thumbnails: bool,
colors: &Colors,
playing: Option<Playback>,
) -> Laid<'a> {
let max_text = (width * BUBBLE_WIDTH_PERCENT / 100)
.max(12)
.min(width)
.saturating_sub(2)
.max(1);
let query = open.search.as_ref().map_or("", |s| s.ask.words.as_str());
let read_outbox = names
.chats
.get(open.chat_id)
.filter(|c| !c.is_channel && !names.chats.is_saved(open.chat_id))
.map(|c| c.read_outbox);
let now = SystemTime::now();
let mut measured = Vec::with_capacity(open.messages.len());
let mut prev_day = None;
let mut prev_sender = None;
let mut prev_sticker = false;
let messages: Vec<(i64, &Msg)> = open.messages.iter().map(|(&id, m)| (id, m)).collect();
let albums = albums(&messages);
for ((id, msg), album) in messages.into_iter().zip(albums) {
let time = Local.timestamp_opt(i64::from(msg.date), 0).single();
let day = time.map(|t| t.date_naive());
let separator = (day != prev_day).then(|| {
prev_sender = None;
time.map_or(String::new(), |t| t.format(" %a %-d %b %Y ").to_string())
});
let name = (show_names && prev_sender != Some(msg.sender)).then(|| {
(
names.author(msg.sender, msg.outgoing),
name_color(msg.sender, colors),
)
});
let header = Header {
name,
forward: msg
.forwarded
.as_ref()
.filter(|_| album.is_none_or(|a| a.first))
.map(|origin| format!("Forwarded from {}", names.origin(origin))),
quote: msg
.reply_to
.as_ref()
.filter(|_| album.is_none_or(|a| a.first))
.map(|reply| quote(open, id, reply, names, colors)),
};
let caption = match album {
Some(InAlbum {
caption: Some((holder_id, holder)),
last,
..
}) => {
if last {
Some(holder)
} else {
(holder_id != id).then_some(msg)
}
}
_ => Some(msg),
};
let edited = msg.edited || caption.is_some_and(|c| c.edited);
let pinned = msg.pinned || caption.is_some_and(|c| c.pinned);
let meta = match msg.state {
_ if msg.state == SendState::Sent && album.is_some_and(|a| !a.last) => None,
SendState::Sent => Some(time.map_or(String::new(), |t| {
let time = t.format("%H:%M");
let mut meta = if edited {
format!("edited {time}")
} else {
time.to_string()
};
if pinned {
meta = format!("{PIN} {meta}");
}
if let Some(destruct) = msg.destruct {
meta = format!("{} {meta}", destruct.label(now));
}
if msg.outgoing
&& let Some(read) = read_outbox
{
meta.push_str(if id <= read { " ✓✓" } else { " ✓" });
}
meta
})),
SendState::Pending => Some(match open.upload_progress(msg) {
Some(done) => format!("sending {done}%"),
None => "sending…".into(),
}),
SendState::Failed => Some("not sent".into()),
};
let photo = msg.preview.as_ref().map(|p| photo_cells(p, max_text, font));
let matches = caption.map_or(Vec::new(), |c| search::find(&c.text, query));
let reactions = match album {
Some(a) if a.last => reactions::merge(
open.messages
.values()
.filter(|m| m.album == msg.album)
.map(|m| m.reactions.as_slice()),
),
Some(_) => Vec::new(),
None => msg.reactions.clone(),
};
let chips = chip_rows(&reactions, max_text);
let card_photo = caption
.and_then(|c| c.card.as_ref())
.and_then(|card| card.image.as_ref())
.filter(|_| thumbnails);
let card_image = card_photo.map(|p| (p, thumbnail_cells(p, font)));
let voice = msg.voice.as_ref().map(|v| {
let playback = playing.filter(|p| p.message_id == id);
VoiceRow::new(v, msg.outgoing, playback, max_text)
});
let bubble = Bubble::new(
msg, caption, header, photo, voice, card_image, meta, chips, matches, max_text,
);
let service = msg.service.as_ref().map(|service| {
let actor = match msg.outgoing {
true => Part::You,
false => Part::Name(names.get(msg.sender)),
};
let parts = service.sentence(msg.sender, actor, |id| names.get(Sender::User(id)));
service_lines(&parts, max_text, colors)
});
let sticker = bubble.sticker();
let joined = show_names
&& prev_sender == Some(msg.sender)
&& !sticker
&& !prev_sticker
&& service.is_none();
prev_sender = service.is_none().then_some(msg.sender);
measured.push(Measured {
id,
separator,
joined,
bubble,
service,
});
prev_day = day;
prev_sticker = sticker;
}
let mut widths = Vec::with_capacity(measured.len());
for block in measured.chunk_by(|_, next| next.joined) {
let widest = block.iter().map(|m| m.bubble.width).max().unwrap_or(0);
widths.extend(std::iter::repeat_n(widest, block.len()));
}
let mut laid = Laid {
messages: Vec::with_capacity(measured.len()),
placed: Vec::with_capacity(measured.len()),
photos: Vec::new(),
total: 0,
};
let mut day = 0;
for (m, inner) in measured.into_iter().zip(widths) {
let start = laid.total;
if m.separator.is_some() {
day = laid.placed.len();
}
let mut bubble_start = start + usize::from(!m.joined || gaps);
if m.separator.is_some() {
bubble_start += 1 + usize::from(start > 0);
}
let msg = m.bubble.msg;
let bubble_x = if msg.outgoing {
width.saturating_sub(inner + 2)
} else {
0
};
let photo_rows = m.bubble.photo.map_or(0, |(_, rows)| usize::from(rows));
if let (Some(photo), Some((cols, rows))) = (&msg.preview, m.bubble.photo) {
laid.photos.push(PhotoSlot {
line: bubble_start + m.bubble.header.rows(),
x: (bubble_x + 1) as u16,
cols,
rows,
photo: photo.clone(),
});
}
if let Some((photo, (cols, rows))) = m.bubble.card_image {
let voice_rows = usize::from(m.bubble.voice.is_some());
laid.photos.push(PhotoSlot {
line: bubble_start
+ m.bubble.header.rows()
+ photo_rows
+ voice_rows
+ m.bubble.text.len(),
x: (bubble_x + 3) as u16,
cols,
rows,
photo: photo.clone(),
});
}
let end = bubble_start + m.service.as_ref().map_or(m.bubble.height(), Vec::len);
laid.placed.push(Placed {
id: m.id,
start,
bubble_start,
end,
day,
});
laid.messages.push((m, inner));
laid.total = end;
}
laid
}
struct Laid<'a> {
messages: Vec<(Measured<'a>, usize)>,
placed: Vec<Placed>,
photos: Vec<PhotoSlot>,
total: usize,
}
impl Laid<'_> {
fn date_above(&self, line: usize) -> Option<String> {
let at = self.placed.iter().position(|p| p.end > line)?;
let first = self.placed[at].day;
let start = self.placed[first].start;
let date_row = start + usize::from(start > 0);
(date_row < line)
.then(|| self.messages[first].0.separator.clone())
.flatten()
}
fn lines(self, top: usize, height: usize, gaps: bool, colors: &Colors) -> Vec<Line<'static>> {
let bottom = top.saturating_add(height);
let mut out = Vec::new();
for ((m, inner), placed) in self.messages.into_iter().zip(&self.placed) {
if placed.end <= top || placed.start >= bottom {
continue;
}
let mut lines = Vec::with_capacity(placed.end - placed.start);
if let Some(label) = m.separator {
if placed.start > 0 {
lines.push(Line::default());
}
lines.push(Line::from(label).fg(colors.muted).centered());
}
if !m.joined {
lines.push(Line::default());
} else if gaps {
lines.push(m.bubble.gap(inner, colors));
}
match m.service {
Some(rows) => lines.extend(rows.into_iter().map(Line::centered)),
None => lines.extend(m.bubble.rows(inner, colors)),
}
debug_assert_eq!(lines.len(), placed.end - placed.start, "measured right");
let from = top.saturating_sub(placed.start);
let to = bottom.min(placed.end) - placed.start;
out.extend(lines.into_iter().take(to).skip(from));
}
out
}
}
#[cfg(test)]
fn layout(
open: &OpenChat,
names: &Names,
show_names: bool,
gaps: bool,
width: usize,
font: FontSize,
colors: &Colors,
) -> (Vec<Line<'static>>, Vec<Placed>, Vec<PhotoSlot>) {
let mut laid = measure(
open, names, show_names, gaps, width, font, true, colors, None,
);
let placed = laid.placed.clone();
let photos = std::mem::take(&mut laid.photos);
let total = laid.total;
(laid.lines(0, total, gaps, colors), placed, photos)
}
#[derive(Clone, Copy)]
struct InAlbum<'a> {
first: bool,
last: bool,
caption: Option<(i64, &'a Msg)>,
}
fn albums<'a>(messages: &[(i64, &'a Msg)]) -> Vec<Option<InAlbum<'a>>> {
let mut out = Vec::with_capacity(messages.len());
for run in messages.chunk_by(|(_, a), (_, b)| a.album != 0 && a.album == b.album) {
let media = run.len() > 1
&& run
.iter()
.all(|(_, m)| m.preview.as_ref().is_some_and(|p| !p.sticker));
if !media {
out.extend(run.iter().map(|_| None));
continue;
}
let mut captioned = run.iter().filter(|(_, m)| !m.source_text.is_empty());
let caption = match (captioned.next(), captioned.next()) {
(Some(&(id, msg)), None) => Some((id, msg)),
_ => None,
};
out.extend((0..run.len()).map(|i| {
Some(InAlbum {
first: i == 0,
last: i + 1 == run.len(),
caption,
})
}));
}
out
}
struct Measured<'a> {
id: i64,
separator: Option<String>,
joined: bool,
bubble: Bubble<'a>,
service: Option<Vec<Line<'static>>>,
}
fn service_lines(parts: &[Part], width: usize, colors: &Colors) -> Vec<Line<'static>> {
let mut text = String::new();
let mut styles: Vec<(Range<usize>, Style)> = Vec::new();
for part in parts {
let style = match part {
Part::Words(_) => Style::new().fg(colors.muted),
Part::Name(_) => Style::new().fg(colors.fg),
Part::You => Style::new().fg(colors.primary),
Part::Quote(_) => Style::new().fg(colors.fg).italic(),
};
let start = text.len();
text.push_str(&part.text().replace(['\n', '\t'], " "));
styles.push((start..text.len(), style));
}
wrap(&text, width)
.into_iter()
.map(|(line, at)| {
let end = at + line.len();
if text.get(at..end) != Some(line.as_str()) {
return Line::from(line).fg(colors.fg);
}
let spans: Vec<Span> = styles
.iter()
.filter_map(|(range, style)| {
let (from, to) = (range.start.max(at), range.end.min(end));
let piece = text.get(from..to).filter(|p| !p.is_empty())?;
Some(Span::styled(piece.to_string(), *style))
})
.collect();
Line::from(spans)
})
.collect()
}
fn name_color(sender: Sender, colors: &Colors) -> Color {
let id = match sender {
Sender::User(id) | Sender::Chat(id) => id,
};
colors.names[id.rem_euclid(colors.names.len() as i64) as usize]
}
fn quote(open: &OpenChat, id: i64, reply: &ReplyTo, names: &Names, colors: &Colors) -> Quote {
let fetched = open.replied.get(&id);
let replied = reply
.message_id
.and_then(|answered| open.messages.get_key_value(&answered))
.map(|(&answered, msg)| Replied::new(answered, msg))
.or_else(|| match fetched {
Some(Fetched::Found(replied)) => Some(replied.clone()),
_ => None,
});
match replied {
Some(replied) => Quote {
name: names.author(replied.sender, replied.outgoing),
color: Some(name_color(replied.sender, colors)),
text: Some(reply.quote.clone().unwrap_or(replied.snippet)),
},
None => Quote {
name: match fetched {
Some(Fetched::Missing) => "Deleted message".into(),
_ => "Loading…".into(),
},
color: None,
text: reply.quote.clone(),
},
}
}
struct Chip {
label: String,
chosen: bool,
}
impl Chip {
fn width(&self) -> usize {
self.label.width() + 2
}
}
fn chips_width(row: &[Chip]) -> usize {
let gaps = row.len().saturating_sub(1) * CHIP_GAP;
row.iter().map(Chip::width).sum::<usize>() + gaps
}
fn chip_rows(reactions: &[Reaction], max: usize) -> Vec<Vec<Chip>> {
let mut rows: Vec<Vec<Chip>> = Vec::new();
let mut used = 0;
for reaction in reactions {
let chip = Chip {
label: format!(
"{} {}",
reaction.kind.label(),
reactions::count_label(reaction.count)
),
chosen: reaction.chosen,
};
let w = chip.width();
match rows.last_mut() {
Some(row) if used + CHIP_GAP + w <= max => {
used += CHIP_GAP + w;
row.push(chip);
}
_ => {
used = w;
rows.push(vec![chip]);
}
}
}
rows
}
const CARD_LINES: usize = 3;
#[derive(Clone, Copy, PartialEq, Eq)]
enum CardRow {
Host,
Title,
Description,
}
const THUMBNAIL_ROWS: u16 = 4;
const THUMBNAIL_MAX_COLS: u16 = 15;
pub(super) fn thumbnail_cells(photo: &Preview, font: FontSize) -> (u16, u16) {
let (fw, fh) = (f64::from(font.width.max(1)), f64::from(font.height.max(1)));
let (pw, ph) = (f64::from(photo.width), f64::from(photo.height));
let rows = f64::from(THUMBNAIL_ROWS);
let cols = rows * fh * pw / ph / fw;
if cols <= f64::from(THUMBNAIL_MAX_COLS) {
return (cols.round().max(1.0) as u16, THUMBNAIL_ROWS);
}
let cols = f64::from(THUMBNAIL_MAX_COLS);
let rows = cols * fw * ph / pw / fh;
(THUMBNAIL_MAX_COLS, rows.round().max(1.0) as u16)
}
fn card_rows(card: &Card, width: usize, image: Option<(u16, u16)>) -> Vec<(String, CardRow)> {
let width = width.saturating_sub(image.map_or(0, |(cols, _)| usize::from(cols) + 1));
let mut rows = vec![(truncate(&card.host, width), CardRow::Host)];
if !card.title.is_empty() {
rows.push((truncate(&card.title, width), CardRow::Title));
}
let lines = wrap(&card.description, width);
let more = lines.len() > CARD_LINES;
for (i, (line, _)) in lines.into_iter().take(CARD_LINES).enumerate() {
let line = if more && i + 1 == CARD_LINES {
truncate(&format!("{line} …"), width)
} else {
line
};
rows.push((line, CardRow::Description));
}
let tall = image.map_or(0, |(_, rows)| usize::from(rows));
while rows.len() < tall {
rows.push((String::new(), CardRow::Description));
}
rows
}
fn pinned_bar(
newest: &crate::pins::Pinned,
count: usize,
width: usize,
colors: &Colors,
) -> Line<'static> {
let label = match count {
1 => format!(" {PIN} Pinned "),
n => format!(" {PIN} {n} pinned "),
};
let (key, more) = (" gp ", "list ");
let room = width.saturating_sub(label.width() + key.width() + more.width() + 2);
let snippet = truncate(&newest.snippet, room);
let pad = room.saturating_sub(snippet.width());
Line::from(vec![
Span::from(label).fg(colors.accent).bold(),
Span::from(snippet),
Span::from(" ".repeat(pad + 2)),
Span::from(key).fg(colors.accent).bold(),
Span::from(more).fg(colors.muted),
])
}
fn keyboard_width(keyboard: &Keyboard, max: usize) -> usize {
let wanted = if keyboard.reply {
reply_buttons_line(keyboard).width()
} else {
keyboard
.rows
.iter()
.map(|row| {
let gaps = row.len().saturating_sub(1) * CHIP_GAP;
row.iter().map(|b| b.label.width() + 2).sum::<usize>() + gaps
})
.max()
.unwrap_or(0)
};
wanted.min(max)
}
fn reply_buttons_line(keyboard: &Keyboard) -> String {
let words: Vec<String> = keyboard
.rows
.iter()
.flatten()
.map(|b| format!("[{}]", b.label))
.collect();
format!("{REPLY_BUTTONS}{}", words.join(" "))
}
fn keyboard_rows(keyboard: Option<&Keyboard>) -> usize {
match keyboard {
Some(k) if k.reply => 1,
Some(k) => k.rows.len(),
None => 0,
}
}
struct VoiceRow {
playing: bool,
bars: String,
heard: Option<usize>,
time: String,
unheard: bool,
}
impl VoiceRow {
fn new(voice: &Voice, outgoing: bool, playback: Option<Playback>, max: usize) -> Self {
let length = crate::messages::duration(voice.duration);
let time = match playback {
Some(p) => {
let at = crate::messages::duration(p.at.as_secs().min(i32::MAX as u64) as i32);
format!("{at:>0$}", length.width())
}
None => length,
};
let unheard = !outgoing && !voice.listened;
let around = 4 + time.width() + if unheard { 2 } else { 0 };
let columns = voice.columns().min(max.saturating_sub(around)).max(1);
let heard = playback.map(|p| {
let part = p.at.as_secs_f64() / f64::from(voice.duration.max(1));
((part * columns as f64) as usize).min(columns)
});
Self {
playing: playback.is_some_and(|p| !p.paused),
bars: voice::bars(&voice.levels, columns),
heard,
time,
unheard,
}
}
fn width(&self) -> usize {
4 + self.bars.chars().count() + self.time.width() + if self.unheard { 2 } else { 0 }
}
fn spans(self, style: Style, faded: Color, accent: Color) -> Vec<Span<'static>> {
let icon = if self.playing { "▌▌" } else { "▶ " };
let heard = self.heard.unwrap_or(0);
let split = self
.bars
.char_indices()
.nth(heard)
.map_or(self.bars.len(), |(i, _)| i);
let mut spans = vec![
Span::styled(format!("{icon} "), style.fg(accent)),
Span::styled(self.bars[..split].to_string(), style.fg(accent)),
Span::styled(self.bars[split..].to_string(), style.fg(faded)),
Span::styled(format!(" {}", self.time), style.fg(faded)),
];
if self.unheard {
spans.push(Span::styled(" •", style.fg(accent)));
}
spans
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MetaAt {
Text,
Voice,
Reactions,
Own,
}
struct Bubble<'a> {
msg: &'a Msg,
links: &'a [Range<usize>],
styles: &'a [Styled],
revealed: bool,
header: Header,
photo: Option<(u16, u16)>,
voice: Option<VoiceRow>,
meta: Option<String>,
chips: Vec<Vec<Chip>>,
matches: Vec<Range<usize>>,
text: Vec<(String, usize)>,
card: Vec<(String, CardRow)>,
card_image: Option<(&'a Preview, (u16, u16))>,
meta_at: MetaAt,
width: usize,
}
impl<'a> Bubble<'a> {
#[allow(clippy::too_many_arguments)]
fn new(
msg: &'a Msg,
caption: Option<&'a Msg>,
header: Header,
photo: Option<(u16, u16)>,
voice: Option<VoiceRow>,
card_image: Option<(&'a Preview, (u16, u16))>,
meta: Option<String>,
chips: Vec<Vec<Chip>>,
matches: Vec<Range<usize>>,
max_text: usize,
) -> Self {
let source = caption.map_or("", |c| c.text.as_str());
let text = if source.is_empty() && (meta.is_none() || !chips.is_empty() || voice.is_some())
{
Vec::new()
} else {
wrap(source, max_text)
};
let image = card_image.map(|(_, cells)| cells);
let card = caption
.and_then(|c| c.card.as_ref())
.map_or(Vec::new(), |card| {
card_rows(card, max_text.saturating_sub(2), image)
});
let card_indent = 2 + image.map_or(0, |(cols, _)| usize::from(cols) + 1);
let meta_w = meta.as_ref().map_or(0, |m| m.width());
let last_w = match (chips.last(), text.last(), &voice) {
(Some(row), _, _) => chips_width(row),
(None, Some((l, _)), _) => l.width(),
(None, None, Some(voice)) => voice.width(),
(None, None, None) => 0,
};
let meta_at = match () {
_ if meta.is_none() || last_w + META_GAP + meta_w > max_text => MetaAt::Own,
_ if chips.is_empty() && !card.is_empty() => MetaAt::Own,
_ if chips.is_empty() && text.is_empty() && voice.is_some() => MetaAt::Voice,
_ if chips.is_empty() => MetaAt::Text,
_ => MetaAt::Reactions,
};
let mut width = text
.iter()
.map(|(l, _)| l.width())
.chain(voice.as_ref().map(VoiceRow::width))
.chain(chips.iter().map(|row| chips_width(row)))
.chain(card.iter().map(|(l, _)| card_indent + l.width()))
.max()
.unwrap_or(0)
.max(meta_w);
if meta_at != MetaAt::Own {
width = width.max(last_w + META_GAP + meta_w);
}
let name = header
.name
.map(|(n, color)| (truncate(&n, max_text), color));
if let Some((n, _)) = &name {
width = width.max(n.width());
}
let forward = header.forward.map(|f| truncate(&f, max_text));
if let Some(f) = &forward {
width = width.max(f.width());
}
let quote = header.quote.map(|q| Quote {
name: truncate(&q.name, max_text.saturating_sub(2)),
text: q.text.map(|t| truncate(&t, max_text.saturating_sub(2))),
..q
});
if let Some(q) = "e {
let text_w = q.text.as_ref().map_or(0, |t| t.width());
width = width.max(2 + q.name.width().max(text_w));
}
if let Some((cols, _)) = photo {
width = width.max(usize::from(cols));
}
if let Some(keyboard) = &msg.keyboard {
width = width.max(keyboard_width(keyboard, max_text));
}
Bubble {
msg,
links: caption.map_or(&[], |c| c.link_ranges.as_slice()),
styles: caption.map_or(&[], |c| c.styles.as_slice()),
revealed: caption.is_some_and(|c| c.revealed),
header: Header {
name,
forward,
quote,
},
photo,
voice,
meta,
chips,
matches,
text,
card,
card_image,
meta_at,
width,
}
}
fn height(&self) -> usize {
self.header.rows()
+ self.photo.map_or(0, |(_, rows)| usize::from(rows))
+ usize::from(self.voice.is_some())
+ self.text.len()
+ self.card.len()
+ self.chips.len()
+ usize::from(self.meta_at == MetaAt::Own && self.meta.is_some())
+ keyboard_rows(self.msg.keyboard.as_ref())
}
fn sticker(&self) -> bool {
self.msg.preview.as_ref().is_some_and(|p| p.sticker)
}
fn gap(&self, inner: usize, colors: &Colors) -> Line<'static> {
let (bg, _) = bubble_colors(self.msg.outgoing, colors);
let row = " ".repeat(inner.max(self.width) + 2);
let line = Line::from(Span::styled(row, Style::new().bg(bg)));
if self.msg.outgoing {
line.right_aligned()
} else {
line
}
}
fn rows(self, inner: usize, colors: &Colors) -> Vec<Line<'static>> {
let sticker = self.sticker();
let Bubble {
msg,
links,
styles,
revealed,
header,
photo,
voice,
meta,
chips,
matches,
text,
card,
card_image,
meta_at,
width,
} = self;
let inner = inner.max(width);
let (bg, meta_fg) = bubble_colors(msg.outgoing, colors);
let style = if sticker {
Style::new()
} else {
Style::new().fg(colors.fg).bg(bg)
};
let faded = if sticker { colors.muted } else { meta_fg };
let meta_color = match msg.state {
SendState::Failed => colors.error,
_ => faded,
};
let meta_style = style.fg(meta_color);
let meta_w = meta.as_ref().map_or(0, |m| m.width());
let paint = Paint {
links,
matches: &matches,
styles,
revealed,
style,
found: super::match_style(colors),
code: colors.code,
faded,
};
let spans = |line: &str, start: usize| line_spans(line, start, &paint);
let row = |mut spans: Vec<Span<'static>>, used: usize| {
spans.insert(0, Span::styled(" ", style));
spans.push(Span::styled(" ".repeat(inner - used + 1), style));
let line = Line::from(spans);
if msg.outgoing {
line.right_aligned()
} else {
line
}
};
let mut out = Vec::new();
if let Some((n, color)) = header.name {
let w = n.width();
out.push(row(vec![Span::styled(n, style.fg(color).bold())], w));
}
if let Some(forward) = header.forward {
let w = forward.width();
out.push(row(
vec![Span::styled(forward, style.fg(faded).italic())],
w,
));
}
if let Some(q) = header.quote {
let accent = style.fg(q.color.unwrap_or(faded));
let bar = || Span::styled("▎ ", accent);
let w = q.name.width();
out.push(row(vec![bar(), Span::styled(q.name, accent.bold())], 2 + w));
if let Some(text) = q.text {
let w = text.width();
out.push(row(vec![bar(), Span::styled(text, style.fg(faded))], 2 + w));
}
}
if let Some((cols, rows)) = photo {
for _ in 0..rows {
let blank = " ".repeat(usize::from(cols));
out.push(row(vec![Span::styled(blank, style)], usize::from(cols)));
}
}
if let Some(voice) = voice {
let w = voice.width();
let mut spans = voice.spans(style, faded, colors.accent);
if meta_at == MetaAt::Voice
&& let Some(meta) = &meta
{
spans.push(Span::styled(" ".repeat(inner - w - meta_w), style));
spans.push(Span::styled(meta.clone(), meta_style));
out.push(row(spans, inner));
} else {
out.push(row(spans, w));
}
}
let count = text.len();
for (i, (line, start)) in text.into_iter().enumerate() {
let w = line.width();
let mut line_spans = spans(&line, start);
if i + 1 == count
&& meta_at == MetaAt::Text
&& let Some(meta) = &meta
{
line_spans.push(Span::styled(" ".repeat(inner - w - meta_w), style));
line_spans.push(Span::styled(meta.clone(), meta_style));
out.push(row(line_spans, inner));
} else {
out.push(row(line_spans, w));
}
}
let image_cols = card_image.map_or(0, |(_, (cols, _))| usize::from(cols) + 1);
for (line, kind) in card {
let w = line.width();
let text_style = match kind {
CardRow::Host => style.fg(faded),
CardRow::Title => style.bold(),
CardRow::Description => style,
};
let spans = vec![
Span::styled("▎ ", style.fg(faded)),
Span::styled(" ".repeat(image_cols), style),
Span::styled(line, text_style),
];
out.push(row(spans, 2 + image_cols + w));
}
let chip_style = |chosen| {
if chosen {
Style::new().fg(colors.bg).bg(colors.your_reaction)
} else if msg.outgoing {
Style::new().fg(colors.fg).bg(colors.own_reaction)
} else {
Style::new().fg(colors.fg).bg(colors.other_reaction)
}
};
let count = chips.len();
for (i, chips) in chips.into_iter().enumerate() {
let w = chips_width(&chips);
let mut spans = Vec::new();
for (j, chip) in chips.into_iter().enumerate() {
if j > 0 {
spans.push(Span::styled(" ".repeat(CHIP_GAP), style));
}
spans.push(Span::styled(
format!(" {} ", chip.label),
chip_style(chip.chosen),
));
}
if i + 1 == count
&& meta_at == MetaAt::Reactions
&& let Some(meta) = &meta
{
spans.push(Span::styled(" ".repeat(inner - w - meta_w), style));
spans.push(Span::styled(meta.clone(), meta_style));
out.push(row(spans, inner));
} else {
out.push(row(spans, w));
}
}
if meta_at == MetaAt::Own
&& let Some(meta) = meta
{
out.push(row(
vec![
Span::styled(" ".repeat(inner - meta_w), style),
Span::styled(meta, meta_style),
],
inner,
));
}
match &msg.keyboard {
Some(keyboard) if keyboard.reply => {
let line = truncate(&reply_buttons_line(keyboard), inner);
let w = line.width();
out.push(row(vec![Span::styled(line, style.fg(faded))], w));
}
Some(keyboard) => {
let look = |_, _: &_| (style.fg(faded), style);
for buttons in &keyboard.rows {
out.push(row(button_row(buttons, inner, style, look), inner));
}
}
None => {}
}
out
}
}
pub(super) fn button_row(
buttons: &[crate::buttons::Button],
width: usize,
gap: Style,
look: impl Fn(usize, &crate::buttons::Button) -> (Style, Style),
) -> Vec<Span<'static>> {
let count = buttons.len().max(1);
let free = width.saturating_sub((count - 1) * CHIP_GAP);
let (each, extra) = (free / count, free % count);
let mut spans = Vec::new();
for (i, b) in buttons.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ".repeat(CHIP_GAP), gap));
}
let (brackets, words) = look(i, b);
let cell = each + usize::from(i < extra);
let label = truncate(&b.label, cell.saturating_sub(2));
let pad = cell.saturating_sub(label.width() + 2);
let (left, right) = (pad / 2, pad - pad / 2);
spans.push(Span::styled(format!("[{}", " ".repeat(left)), brackets));
spans.push(Span::styled(label, words));
spans.push(Span::styled(format!("{}]", " ".repeat(right)), brackets));
}
spans
}
fn bubble_colors(outgoing: bool, colors: &Colors) -> (Color, Color) {
if outgoing {
(colors.own_bubble, colors.own_meta)
} else {
(colors.other_bubble, colors.other_meta)
}
}
pub(super) fn wrap(text: &str, width: usize) -> Vec<(String, usize)> {
let options = textwrap::Options::new(width).break_words(true);
let mut out = Vec::new();
let mut line_start = 0;
for line in text.split('\n') {
if line.is_empty() {
out.push((String::new(), line_start));
} else {
let mut cursor = 0;
for piece in textwrap::wrap(line, &options) {
let at = line[cursor..]
.find(piece.as_ref())
.map_or(cursor, |i| cursor + i);
cursor = (at + piece.len()).min(line.len());
out.push((piece.into_owned(), line_start + at));
}
}
line_start += line.len() + 1;
}
out
}
struct Paint<'a> {
links: &'a [Range<usize>],
matches: &'a [Range<usize>],
styles: &'a [Styled],
revealed: bool,
style: Style,
found: Style,
code: Color,
faded: Color,
}
impl Paint<'_> {
fn format(&self, style: Style, format: Format) -> Style {
let mut style = style;
if format.bold {
style = style.bold();
}
if format.italic || format.quote {
style = style.italic();
}
if format.underline {
style = style.underlined();
}
if format.strike {
style = style.crossed_out();
}
if format.code {
style = style.fg(self.code);
}
if format.quote || (format.spoiler && !self.revealed) {
style = style.fg(self.faded);
}
style
}
}
fn line_spans(line: &str, start: usize, paint: &Paint) -> Vec<Span<'static>> {
let end = start + line.len();
let links = overlapping(paint.links, start, end, |r| r);
let matches = overlapping(paint.matches, start, end, |r| r);
let styles = overlapping(paint.styles, start, end, |s| &s.range);
let mut cuts = vec![0, line.len()];
let edges = links
.iter()
.chain(matches)
.chain(styles.iter().map(|s| &s.range));
for range in edges {
for at in [range.start, range.end] {
if at > start && at < end {
cuts.push(at - start);
}
}
}
cuts.sort_unstable();
cuts.dedup();
let inside = |ranges: &[Range<usize>], at: usize| ranges.iter().any(|r| r.contains(&at));
let mut spans: Vec<Span<'static>> = cuts
.windows(2)
.map(|w| {
let at = start + w[0];
let mut text = line[w[0]..w[1]].to_string();
let mut s = paint.style;
if let Some(styled) = styles.iter().find(|s| s.range.contains(&at)) {
s = paint.format(s, styled.format);
if styled.format.spoiler && !paint.revealed {
text = SPOILER.repeat(text.width());
}
}
if inside(links, at) {
s = s.underlined();
}
if inside(matches, at) {
s = s.patch(paint.found);
}
Span::styled(text, s)
})
.collect();
if spans.is_empty() {
spans.push(Span::styled(String::new(), paint.style));
}
spans
}
fn overlapping<T>(
items: &[T],
start: usize,
end: usize,
range: impl Fn(&T) -> &Range<usize>,
) -> &[T] {
let first = items.partition_point(|i| range(i).end <= start);
let count = items[first..].partition_point(|i| range(i).start < end);
&items[first..first + count]
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui_image::picker::Picker;
use super::*;
use crate::images::Key;
use crate::messages::Editable;
use crate::settings::Settings;
fn images() -> Images {
Images::new(
Picker::halfblocks(),
tokio::sync::mpsc::unbounded_channel().0,
)
}
fn msg(outgoing: bool, date: i32, text: &str) -> Msg {
Msg {
sender: Sender::User(if outgoing { 1 } else { 2 }),
outgoing,
date,
text: text.into(),
source_text: text.into(),
preview: None,
file: None,
photo: None,
links: Vec::new(),
link_ranges: Vec::new(),
styles: Vec::new(),
revealed: false,
forwarded: None,
poll: None,
card: None,
state: SendState::Sent,
reply_to: None,
editable: Editable::Text,
formatted: false,
edited: false,
album: 0,
reactions: Vec::new(),
keyboard: None,
pinned: false,
destruct: None,
hidden: None,
saveable: true,
voice: None,
service: None,
}
}
fn render(open: &mut OpenChat, focused: bool) -> Vec<String> {
render_with(open, focused, &mut images())
}
fn render_with(open: &mut OpenChat, focused: bool, images: &mut Images) -> Vec<String> {
let buf = render_buffer(open, focused, images);
(0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
}
fn render_buffer(
open: &mut OpenChat,
focused: bool,
images: &mut Images,
) -> ratatui::buffer::Buffer {
render_in(open, &Chats::default(), focused, images)
}
fn render_in(
open: &mut OpenChat,
chats: &Chats,
focused: bool,
images: &mut Images,
) -> ratatui::buffer::Buffer {
render_playing(open, chats, focused, images, None)
}
fn render_playing(
open: &mut OpenChat,
chats: &Chats,
focused: bool,
images: &mut Images,
playing: Option<Playback>,
) -> ratatui::buffer::Buffer {
let users = HashMap::new();
let names = Names {
users: &users,
chats,
};
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
terminal
.draw(|f| {
draw(
f,
f.area(),
open,
None,
&names,
images,
focused,
false,
&Colors::default(),
Settings::default().block_gaps,
playing,
)
})
.unwrap();
terminal.backend().buffer().clone()
}
fn sample() -> OpenChat {
let mut open = OpenChat::new(42);
let day1 = 1_790_000_000;
let day2 = day1 + 86_400;
open.messages.insert(1, msg(false, day1, "hi there"));
open.messages
.insert(2, msg(true, day1 + 60, "hello from me"));
open.messages.insert(
3,
msg(
false,
day2,
"a much longer message that has to wrap onto more than one line inside its bubble",
),
);
open.messages.insert(4, msg(true, day2 + 60, "ok"));
open
}
#[test]
fn own_messages_sit_on_the_right_and_others_on_the_left() {
let rows = render(&mut sample(), false);
let row = |needle: &str| rows.iter().find(|r| r.contains(needle)).unwrap().clone();
let mine = row("hello from me");
let theirs = row("hi there");
assert_eq!(
theirs.chars().position(|c| c == 'h'),
Some(3),
"incoming bubble starts at the left edge"
);
let mine: Vec<char> = mine.chars().collect();
let end = mine.len() - 2; assert_eq!(mine[end - 1], ' ', "own bubble has right padding");
assert!(mine[end - 2].is_ascii_digit(), "time is flush right");
}
#[test]
fn the_time_keeps_its_distance_from_the_text() {
let rows = render(&mut sample(), false);
let row = rows.iter().find(|r| r.contains("hi there")).unwrap();
let after = &row[row.find("hi there").unwrap() + "hi there".len()..];
assert!(
after.starts_with(" ") && after[3..].starts_with(char::is_numeric),
"{row}"
);
}
#[test]
fn pending_messages_show_sending() {
let mut open = sample();
open.messages.insert(
5,
Msg {
state: SendState::Pending,
..msg(true, 1_790_086_500, "on its way")
},
);
let rows = render(&mut open, false);
let at = |needle: &str| rows.iter().position(|r| r.contains(needle)).unwrap();
assert!(rows[at("on its way")].contains("sending…"));
}
#[test]
fn group_names_mark_own_messages_with_me() {
let users = HashMap::from([(1, "Eric".to_string()), (2, "Chardy".to_string())]);
let chats = Chats::default();
let names = Names {
users: &users,
chats: &chats,
};
let font = FontSize {
width: 10,
height: 20,
};
let colors = Colors::default();
let (lines, _, _) = layout(&sample(), &names, true, true, 58, font, &colors);
let text: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(text.iter().any(|l| l.contains("Eric (me)")));
assert!(
text.iter()
.any(|l| l.contains("Chardy") && !l.contains("(me)"))
);
}
#[test]
fn names_show_once_per_run_and_not_in_channels() {
let users = HashMap::from([(1, "Eric".to_string()), (2, "Chardy".to_string())]);
let chats = Chats::default();
let names = Names {
users: &users,
chats: &chats,
};
let font = FontSize {
width: 10,
height: 20,
};
let colors = Colors::default();
let mut open = sample();
open.messages
.insert(0, msg(false, 1_790_000_000 - 60, "first"));
let text = |show_names| -> Vec<String> {
let (lines, _, _) = layout(&open, &names, show_names, true, 58, font, &colors);
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
};
let chat = text(true);
let count = |name: &str| chat.iter().filter(|l| l.contains(name)).count();
assert_eq!(count("Chardy"), 2, "once per run of messages");
assert_eq!(count("Eric (me)"), 2);
let channel = text(false);
assert!(
!channel
.iter()
.any(|l| l.contains("Chardy") || l.contains("Eric"))
);
}
#[test]
fn your_messages_get_one_tick_when_sent_and_two_once_read() {
let mut open = OpenChat::new(1);
open.messages.insert(1, msg(true, 1_790_000_000, "seen it"));
open.messages.insert(2, msg(false, 1_790_000_060, "answer"));
open.messages.insert(3, msg(true, 1_790_000_120, "not yet"));
let render = |chats: &Chats, open: &mut OpenChat| -> Vec<String> {
let buf = render_in(open, chats, false, &mut images());
(0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
};
let row =
|rows: &[String], text: &str| rows.iter().find(|r| r.contains(text)).cloned().unwrap();
let mut chats = Chats::default();
chats.add_local(1, "Chardy", None).read_outbox = 2;
let rows = render(&chats, &mut open);
assert!(row(&rows, "seen it").contains(" ✓✓"), "{rows:#?}");
let unread = row(&rows, "not yet");
assert!(unread.contains(" ✓") && !unread.contains("✓✓"), "{unread}");
assert!(!row(&rows, "answer").contains('✓'), "not on theirs");
chats.add_local(1, "News", None).is_channel = true;
assert!(!render(&chats, &mut open).iter().any(|r| r.contains('✓')));
let mut saved = Chats::default();
saved.add_local(1, "Saved Messages", None);
saved.set_my_id(1);
assert!(!render(&saved, &mut open).iter().any(|r| r.contains('✓')));
}
#[test]
fn a_secret_chats_title_has_a_lock_its_timer_and_whether_it_can_be_written_in() {
use crate::chats::Peer;
use crate::secret::Secret;
let mut chats = Chats::default();
let chat = chats.add_local(42, "Chardy", None);
(chat.is_private, chat.secret_id, chat.peer) = (true, Some(7), Some(Peer::User(2)));
let secret = |state| Secret {
user_id: 2,
state,
outbound: true,
key_hash: Vec::new(),
};
chats.set_secret(7, secret(SecretState::Pending));
chats.set_auto_delete(42, 30);
let title = |chats: &Chats| -> String {
let buf = render_in(&mut sample(), chats, false, &mut images());
let row: String = (0..buf.area.width).map(|x| buf[(x, 0)].symbol()).collect();
row.replace("🔒 ", "🔒").replace("🔥 ", "🔥")
};
let pending = title(&chats);
assert!(pending.contains("🔒 Chardy"), "{pending}");
assert!(pending.contains("🔥 30s"), "{pending}");
assert!(
pending.contains("waiting for Chardy to come online"),
"{pending}"
);
chats.set_secret(7, secret(SecretState::Ready));
assert!(!title(&chats).contains("waiting"));
chats.set_secret(7, secret(SecretState::Closed));
assert!(title(&chats).contains("· ended"));
chats.set_auto_delete(42, 0);
assert!(!title(&chats).contains('🔥'));
}
#[test]
fn messages_that_self_destruct_show_how_long_they_last_by_the_time() {
use crate::secret::Destruct;
let mut open = OpenChat::new(42);
let timer = Destruct {
after: 30,
on_open: false,
ends: None,
};
open.messages.insert(
1,
Msg {
destruct: Some(timer),
..msg(false, 1_790_000_000, "gone soon")
},
);
let rows = render(&mut open, false);
let row = rows.iter().find(|r| r.contains("gone soon")).unwrap();
assert!(row.contains("🔥 30s"), "{row}");
}
#[test]
fn the_title_says_when_the_other_person_is_typing() {
let mut chats = Chats::default();
chats.add_local(42, "Chardy", None).is_private = true;
let typing = tdlib_rs::enums::ChatAction::Typing;
let chardy =
tdlib_rs::enums::MessageSender::User(tdlib_rs::types::MessageSenderUser { user_id: 2 });
chats.set_action(42, None, &chardy, &typing);
let buf = render_in(&mut sample(), &chats, false, &mut images());
let title: String = (0..buf.area.width).map(|x| buf[(x, 0)].symbol()).collect();
assert!(title.contains(" Chardy · typing… "), "{title}");
}
#[test]
fn messages_in_a_row_from_one_sender_form_one_even_block() {
let mut open = OpenChat::new(42);
let day = 1_790_000_000;
open.messages.insert(1, msg(false, day, "short"));
open.messages
.insert(2, msg(false, day + 60, "a somewhat longer message"));
open.messages
.insert(3, msg(true, day + 120, "a longer one of mine"));
open.messages.insert(4, msg(true, day + 180, "ok"));
let colors = Colors::default();
let buf = render_buffer(&mut open, false, &mut images());
let row = |needle: &str| {
(0..buf.area.height)
.find(|&y| {
let text: String = (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect();
text.contains(needle)
})
.unwrap()
};
let painted = |y: u16, bg: Color| -> Vec<u16> {
(0..buf.area.width)
.filter(|&x| buf[(x, y)].bg == bg)
.collect()
};
let (short, longer) = (row("short"), row("somewhat"));
assert_eq!(longer, short + 2, "a row between them");
let bubble = painted(longer, colors.other_bubble);
assert_eq!(
painted(short, colors.other_bubble),
bubble,
"as wide as the widest"
);
assert_eq!(
painted(short + 1, colors.other_bubble),
bubble,
"the row between keeps the background"
);
let (mine, ok) = (row("one of mine"), row("ok "));
assert!(
painted(longer + 1, colors.other_bubble).is_empty(),
"a blank gap between blocks"
);
assert_eq!(ok, mine + 2);
let bubble = painted(ok, colors.own_bubble);
assert_eq!(painted(mine, colors.own_bubble), bubble);
assert_eq!(painted(mine + 1, colors.own_bubble), bubble);
}
#[test]
fn the_gap_inside_a_block_can_be_turned_off() {
let users = HashMap::new();
let chats = Chats::default();
let names = Names {
users: &users,
chats: &chats,
};
let font = FontSize {
width: 10,
height: 20,
};
let colors = Colors::default();
let mut open = OpenChat::new(42);
open.messages.insert(1, msg(false, 1_790_000_000, "one"));
open.messages.insert(2, msg(false, 1_790_000_060, "two"));
let rows = |gaps| layout(&open, &names, true, gaps, 58, font, &colors).0.len();
assert_eq!(rows(true), rows(false) + 1);
}
#[test]
fn an_album_is_one_bubble_with_the_caption_and_time_under_the_last_photo() {
let users = HashMap::new();
let chats = Chats::default();
let names = Names {
users: &users,
chats: &chats,
};
let font = FontSize {
width: 10,
height: 20,
};
let colors = Colors::default();
let photo = |file_id, caption: &str| Msg {
preview: Some(Preview {
file_id,
width: 800,
height: 600,
thumbnail: None,
sticker: false,
}),
album: 5,
..msg(true, 1_790_000_000, caption)
};
let mut open = OpenChat::new(42);
open.messages.insert(1, photo(7, "Sunrise at the top"));
open.messages.insert(2, photo(8, ""));
open.messages.insert(3, photo(9, ""));
open.messages
.insert(4, msg(true, 1_790_000_000, "and a text after"));
let (lines, placed, photos) = layout(&open, &names, true, true, 58, font, &colors);
let text: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let time = Local
.timestamp_opt(1_790_000_000, 0)
.unwrap()
.format("%H:%M")
.to_string();
assert_eq!(photos.len(), 3);
let caption = text.iter().position(|l| l.contains("Sunrise")).unwrap();
let last = &photos[2];
assert_eq!(
caption,
last.line + usize::from(last.rows),
"right under the last photo"
);
assert!(
placed[2].start <= caption && caption < placed[2].end,
"drawn with the last photo"
);
assert_eq!(
placed[0].end,
photos[0].line + usize::from(photos[0].rows),
"nothing under the first"
);
assert!(
text[caption].contains(&time),
"the time once, after the caption"
);
assert_eq!(
text.iter().filter(|l| l.contains(&time)).count(),
2,
"and once for the text after"
);
}
#[test]
fn sixel_photos_wait_while_a_popup_is_over_them() {
use ratatui_image::picker::ProtocolType;
let mut open = sample();
let photo = Preview {
file_id: 7,
width: 800,
height: 600,
thumbnail: None,
sticker: false,
};
open.messages.insert(
5,
Msg {
preview: Some(photo.clone()),
..msg(true, 1_790_086_500, "look")
},
);
let mut picker = Picker::halfblocks();
picker.set_protocol_type(ProtocolType::Sixel);
let mut images = Images::new(picker, tokio::sync::mpsc::unbounded_channel().0);
let (cols, rows) = photo_cells(&photo, 56, images.font_size());
let key = Key {
file_id: 7,
cols,
rows,
thumbnail: false,
avatar: false,
};
let red = image::RgbImage::from_pixel(80, 60, image::Rgb([255, 0, 0]));
images.insert_ready(key, red.into());
let mut painted = |covered| {
let users = HashMap::new();
let chats = Chats::default();
let names = Names {
users: &users,
chats: &chats,
};
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
terminal
.draw(|f| {
let colors = Colors::default();
draw(
f,
f.area(),
&mut open,
None,
&names,
&mut images,
false,
covered,
&colors,
true,
None,
)
})
.unwrap();
let buf = terminal.backend().buffer().clone();
buf.content().iter().any(|c| c.symbol().contains('\x1b'))
};
assert!(painted(false), "drawn as usual");
assert!(!painted(true), "held back under a popup");
}
#[test]
fn photos_show_a_placeholder_then_the_image() {
let mut open = sample();
let photo = Preview {
file_id: 7,
width: 800,
height: 600,
thumbnail: None,
sticker: false,
};
open.messages.insert(
5,
Msg {
preview: Some(photo.clone()),
..msg(true, 1_790_086_500, "look")
},
);
let mut images = images();
let rows = render_with(&mut open, false, &mut images);
assert!(rows.iter().any(|r| r.contains("Loading…")));
let caption = rows.iter().position(|r| r.contains("look")).unwrap();
assert!(
rows[caption - 1].contains(" "),
"photo rows sit above the caption"
);
let (cols, rows_) = photo_cells(&photo, 56, images.font_size());
assert_eq!((cols, rows_), (40, 15));
let red = image::RgbImage::from_pixel(80, 60, image::Rgb([255, 0, 0]));
let key = Key {
file_id: 7,
cols,
rows: rows_,
thumbnail: false,
avatar: false,
};
images.insert_ready(key, red.into());
let buf = render_buffer(&mut open, false, &mut images);
let red_cells = buf
.content()
.iter()
.filter(|c| c.bg == Color::Rgb(255, 0, 0) || c.fg == Color::Rgb(255, 0, 0))
.count();
assert_eq!(red_cells, 40 * 15, "the whole 40x15 photo is drawn");
}
#[test]
fn photos_cut_off_at_the_top_show_their_visible_rows() {
let photo = Preview {
file_id: 7,
width: 800,
height: 600,
thumbnail: None,
sticker: false,
};
let mut open = OpenChat::new(42);
open.messages.insert(
1,
Msg {
preview: Some(photo.clone()),
..msg(false, 1_790_000_000, "")
},
);
for i in 2..6 {
open.messages
.insert(i, msg(i % 2 == 0, 1_790_000_000 + i as i32, "text"));
}
let mut images = images();
let (cols, rows) = photo_cells(&photo, 56, images.font_size());
let red = image::RgbImage::from_pixel(80, 60, image::Rgb([255, 0, 0]));
let key = Key {
file_id: 7,
cols,
rows,
thumbnail: false,
avatar: false,
};
images.insert_ready(key, red.into());
let buf = render_buffer(&mut open, false, &mut images);
let red_rows = (0..buf.area.height)
.filter(|&y| (0..buf.area.width).any(|x| buf[(x, y)].bg == Color::Rgb(255, 0, 0)))
.count();
assert!(
red_rows > 0 && red_rows < usize::from(rows),
"partly visible: {red_rows} rows"
);
}
#[test]
fn wrapped_lines_know_where_they_start() {
let text = "one two three\n\nfour";
let lines = wrap(text, 8);
let starts: Vec<(&str, usize)> = lines.iter().map(|(l, s)| (l.as_str(), *s)).collect();
assert_eq!(
starts,
[("one two", 0), ("three", 8), ("", 14), ("four", 15)]
);
for (line, start) in &lines {
assert_eq!(&text[*start..*start + line.len()], line);
}
}
fn paint<'a>(
links: &'a [Range<usize>],
matches: &'a [Range<usize>],
styles: &'a [Styled],
) -> Paint<'a> {
Paint {
links,
matches,
styles,
revealed: false,
style: Style::new(),
found: Style::new().bg(Color::Yellow),
code: Color::Green,
faded: Color::Gray,
}
}
#[test]
fn links_are_underlined_even_across_a_wrap() {
let text = "see https://example.com/a/long/path ok";
let link = 4..text.find(" ok").unwrap();
let mut underlined = String::new();
for (line, start) in wrap(text, 20) {
let links = std::slice::from_ref(&link);
for span in line_spans(&line, start, &paint(links, &[], &[])) {
if span
.style
.add_modifier
.contains(ratatui::style::Modifier::UNDERLINED)
{
underlined.push_str(&span.content);
}
}
}
assert_eq!(underlined, "https://example.com/a/long/path");
}
#[test]
fn a_link_preview_sits_under_the_text_with_the_links_real_host() {
let mut open = OpenChat::new(42);
let mut m = msg(false, 1_790_000_000, "look https://example.com/a");
m.card = Some(Card {
host: "example.com".into(),
title: "Example Domain".into(),
description: "word ".repeat(60),
image: None,
});
open.messages.insert(1, m);
let rows = render(&mut open, false);
let at = |needle: &str| {
rows.iter()
.position(|r| r.contains(needle))
.unwrap_or_else(|| panic!("{needle}: {rows:#?}"))
};
let (text, host, title) = (
at("look https"),
at("▎ example.com"),
at("▎ Example Domain"),
);
assert!(text < host && host < title);
let description = rows.iter().filter(|r| r.contains("▎ word")).count();
assert_eq!(description, CARD_LINES, "a few lines of it");
assert!(rows[title + CARD_LINES].contains('…'), "then it stops");
}
#[test]
fn a_link_previews_picture_sits_left_of_its_text() {
let mut open = OpenChat::new(42);
let mut m = msg(false, 1_790_000_000, "look");
let square = Preview {
file_id: 9,
width: 300,
height: 300,
thumbnail: None,
sticker: false,
};
m.card = Some(Card {
host: "example.com".into(),
title: "Example Domain".into(),
description: "Short".into(),
image: Some(square),
});
open.messages.insert(1, m);
let users = HashMap::new();
let chats = Chats::default();
let names = Names {
users: &users,
chats: &chats,
};
let font = FontSize {
width: 10,
height: 20,
};
let colors = Colors::default();
let (lines, _, photos) = layout(&open, &names, false, true, 58, font, &colors);
let rows: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
let [slot] = photos.as_slice() else {
panic!("one picture: {}", photos.len());
};
assert_eq!((slot.cols, slot.rows), (8, 4));
assert_eq!(slot.x, 3, "after the bubble's padding and the bar");
assert!(
rows[slot.line].contains("▎ example.com"),
"{rows:#?}"
);
assert!(rows[slot.line + 1].contains("Example Domain"));
assert!(
rows[slot.line + 3].trim_end().ends_with('▎'),
"as tall as the picture"
);
let wide = Preview {
width: 1200,
height: 300,
..slot.photo.clone()
};
assert_eq!(
thumbnail_cells(&wide, font),
(15, 2),
"a wide one is less tall"
);
let page = Preview {
width: 1200,
height: 630,
..slot.photo.clone()
};
assert_eq!(
thumbnail_cells(&page, font),
(15, 4),
"a web page's picture"
);
}
#[test]
fn the_title_says_when_the_other_person_was_last_seen() {
let now = Local
.with_ymd_and_hms(2026, 10, 6, 15, 0, 0)
.unwrap()
.timestamp();
let ago = |seconds: i64| (now - seconds) as i32;
let seen = |presence| seen_label(Seen::Person(presence), now).0;
assert_eq!(
seen_label(Seen::Person(Presence::Online(ago(-60))), now),
("online".into(), true)
);
assert_eq!(
seen(Presence::Online(ago(30))),
"last seen just now",
"online ran out"
);
assert_eq!(seen(Presence::Offline(ago(90))), "last seen a minute ago");
assert_eq!(
seen(Presence::Offline(ago(5 * 60))),
"last seen 5 minutes ago"
);
assert_eq!(
seen(Presence::Offline(ago(3 * 3600))),
"last seen today at 12:00"
);
assert_eq!(
seen(Presence::Offline(ago(20 * 3600))),
"last seen yesterday at 19:00"
);
assert_eq!(seen(Presence::Offline(ago(10 * 86400))), "last seen 26 Sep");
assert_eq!(
seen(Presence::Offline(ago(400 * 86400))),
"last seen 1 Sep 2025"
);
assert_eq!(seen(Presence::Recently), "last seen recently");
assert_eq!(seen_label(Seen::Bot, now), ("bot".into(), false));
}
#[test]
fn formatting_shows_in_the_bubble_and_spoilers_only_once_revealed() {
let mut open = OpenChat::new(42);
let mut m = msg(false, 1_790_000_000, "bold code secret");
let styled = |range, format| Styled { range, format };
m.styles = vec![
styled(
0..4,
Format {
bold: true,
..Format::default()
},
),
styled(
5..9,
Format {
code: true,
..Format::default()
},
),
styled(
10..16,
Format {
spoiler: true,
..Format::default()
},
),
];
open.messages.insert(1, m);
let rows = |buf: &ratatui::buffer::Buffer| -> Vec<String> {
(0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
};
let buf = render_buffer(&mut open, false, &mut images());
assert!(
rows(&buf).iter().any(|r| r.contains("bold code ⠿⠿⠿⠿⠿⠿")),
"{:#?}",
rows(&buf)
);
assert!(!rows(&buf).iter().any(|r| r.contains("secret")));
let cell = |buf: &ratatui::buffer::Buffer, symbol: &str| {
buf.content()
.iter()
.find(|c| c.symbol() == symbol)
.cloned()
.unwrap()
};
assert!(
cell(&buf, "b")
.modifier
.contains(ratatui::style::Modifier::BOLD)
);
assert_eq!(cell(&buf, "c").fg, Colors::default().code);
open.messages.get_mut(&1).unwrap().revealed = true;
let buf = render_buffer(&mut open, false, &mut images());
assert!(
rows(&buf).iter().any(|r| r.contains("bold code secret")),
"the same width, so nothing moves"
);
}
#[test]
fn forwarded_messages_say_where_from_once_per_album() {
let mut open = OpenChat::new(42);
let mut m = msg(true, 1_790_000_000, "look at this");
m.forwarded = Some(Origin::Hidden("Bob".into()));
open.messages.insert(1, m);
let mut post = msg(false, 1_790_000_100, "news");
post.forwarded = Some(Origin::Chat {
chat_id: 5,
signature: "Ann".into(),
});
open.messages.insert(2, post);
let rows = render(&mut open, false);
assert!(
rows.iter().any(|r| r.contains("Forwarded from Bob")),
"{rows:#?}"
);
assert!(
rows.iter()
.any(|r| r.contains("Forwarded from Unknown (Ann)")),
"a signed post from a chat that isn't loaded"
);
let mut album = OpenChat::new(42);
for id in 1..=2 {
let mut photo = msg(false, 1_790_000_000, "");
photo.album = 9;
photo.forwarded = Some(Origin::Hidden("Bob".into()));
photo.preview = Some(Preview {
file_id: id as i32,
width: 100,
height: 100,
thumbnail: None,
sticker: false,
});
album.messages.insert(id, photo);
}
let rows = render(&mut album, false);
let said = rows.iter().filter(|r| r.contains("Forwarded from")).count();
assert_eq!(said, 1, "{rows:#?}");
}
#[test]
fn a_flood_of_line_breaks_draws_only_what_is_on_screen() {
let mut open = OpenChat::new(42);
let text = format!("a{}b", "\n".repeat(4094));
for id in 1..=200 {
open.messages.insert(id, msg(false, 1_790_000_000, &text));
}
let started = std::time::Instant::now();
let buf = render_buffer(&mut open, false, &mut images());
let took = started.elapsed();
let rows: Vec<String> = (0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect();
assert!(
rows.iter().any(|r| r.contains("│ a ")),
"the newest message: {rows:#?}"
);
eprintln!("200 messages of 4095 rows: {took:?}");
assert!(took < std::time::Duration::from_secs(5), "{took:?}");
}
#[test]
fn thousands_of_links_in_one_message_are_drawn_quickly() {
let text = "ab".repeat(2048);
let links: Vec<_> = (0..2048).map(|i| 2 * i..2 * i + 1).collect();
let started = std::time::Instant::now();
let mut underlined = 0;
for (line, start) in wrap(&text, 71) {
for span in line_spans(&line, start, &paint(&links, &[], &[])) {
if span
.style
.add_modifier
.contains(ratatui::style::Modifier::UNDERLINED)
{
underlined += span.content.len();
}
}
}
assert_eq!(underlined, 2048, "every 'a', and nothing else");
assert!(started.elapsed() < std::time::Duration::from_secs(1));
}
#[test]
fn search_matches_are_highlighted_and_counted_in_the_title() {
let mut open = sample();
let ask = crate::search::parse("HELLO").unwrap();
let mut search = crate::search::MessageSearch::new("HELLO".into(), ask);
search.results = vec![2];
search.current = Some(0);
open.search = Some(search);
let buf = render_buffer(&mut open, false, &mut images());
let rows: Vec<String> = (0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect();
assert!(rows[0].contains("/HELLO 1 of 1"), "title: {}", rows[0]);
let colors = Colors::default();
let lit: String = buf
.content()
.iter()
.filter(|c| c.bg == colors.search)
.map(|c| c.symbol())
.collect();
assert_eq!(lit, "hello", "only the match, in the message's own case");
}
#[test]
fn a_match_inside_a_link_is_both_highlighted_and_underlined() {
let text = "see https://hello.dev now";
let link = 4..text.find(" now").unwrap();
let matches = crate::search::find(text, "hello");
let spans = line_spans(text, 0, &paint(&[link], &matches, &[]));
let parts: Vec<(&str, bool, bool)> = spans
.iter()
.map(|s| {
let underlined = s
.style
.add_modifier
.contains(ratatui::style::Modifier::UNDERLINED);
(
s.content.as_ref(),
s.style.bg == Some(Color::Yellow),
underlined,
)
})
.collect();
assert_eq!(
parts,
[
("see ", false, false),
("https://", false, true),
("hello", true, true),
(".dev", false, true),
(" now", false, false),
]
);
}
#[test]
fn stickers_have_no_bubble() {
let mut open = OpenChat::new(42);
let sticker = Preview {
file_id: 3,
width: 512,
height: 512,
thumbnail: None,
sticker: true,
};
open.messages.insert(
1,
Msg {
preview: Some(sticker.clone()),
..msg(true, 1_790_000_000, "")
},
);
let mut images = images();
assert_eq!(photo_cells(&sticker, 56, images.font_size()), (20, 10));
let buf = render_buffer(&mut open, false, &mut images);
let own = Colors::default().own_bubble;
assert!(
buf.content().iter().all(|c| c.bg != own),
"no bubble color anywhere"
);
}
#[test]
fn replies_show_who_and_what_they_answer() {
let users = HashMap::from([(1, "Eric".to_string()), (2, "Chardy".to_string())]);
let chats = Chats::default();
let names = Names {
users: &users,
chats: &chats,
};
let font = FontSize {
width: 10,
height: 20,
};
let colors = Colors::default();
let reply = |message_id, quote: Option<&str>| {
Some(ReplyTo {
message_id,
quote: quote.map(Into::into),
})
};
let at = 1_790_086_500;
let mut open = sample();
open.messages.insert(
5,
Msg {
reply_to: reply(Some(1), None),
..msg(true, at, "yes!")
},
);
open.messages.insert(
6,
Msg {
reply_to: reply(Some(0), Some("the plan")),
..msg(false, at, "and you?")
},
);
open.replied.insert(6, Fetched::Missing);
open.messages.insert(
7,
Msg {
reply_to: reply(None, None),
preview: Some(Preview {
file_id: 7,
width: 800,
height: 600,
thumbnail: None,
sticker: false,
}),
..msg(false, at, "")
},
);
let (lines, _, photos) = layout(&open, &names, true, true, 58, font, &colors);
let text: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let at = |needle: &str| text.iter().position(|l| l.contains(needle)).unwrap();
let yes = at("yes!");
assert!(text[yes - 2].contains("▎ Chardy"), "{}", text[yes - 2]);
assert!(text[yes - 1].contains("▎ hi there"), "{}", text[yes - 1]);
let bar = &lines[yes - 2].spans[1];
assert_eq!(bar.content, "▎ ");
assert_eq!(bar.style.fg, Some(name_color(Sender::User(2), &colors)));
let and_you = at("and you?");
assert!(text[and_you - 2].contains("▎ Deleted message"));
assert!(
text[and_you - 1].contains("▎ the plan"),
"the quote, not the text"
);
let faded = &lines[and_you - 2].spans[1];
assert_eq!(faded.style.fg, Some(colors.other_meta));
assert_eq!(
photos[0].line,
at("▎ Loading…") + 1,
"the photo goes under the quote"
);
}
#[test]
fn the_message_being_replied_to_stays_marked_while_typing() {
let mut open = sample();
open.reply = Some(crate::messages::Replied::new(1, &open.messages[&1]));
let buf = render_buffer(&mut open, false, &mut images());
let colors = Colors::default();
let y = (0..buf.area.height)
.find(|&y| {
let row: String = (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect();
row.contains("hi there")
})
.unwrap();
let (left, right) = (buf[(1, y)].clone(), buf[(buf.area.width - 2, y)].clone());
assert_eq!((left.symbol(), left.fg), ("▌", colors.reply));
assert_eq!((right.symbol(), right.fg), ("▐", colors.reply));
open.reply = None;
let buf = render_buffer(&mut open, false, &mut images());
assert_eq!(buf[(1, y)].symbol(), " ", "no marker without a reply");
}
#[test]
fn edited_messages_say_so_and_the_one_being_edited_is_marked() {
let mut open = sample();
open.set_edited(2);
open.editing = Some(crate::messages::Editing {
id: 2,
snippet: "hello from me".into(),
original: "hello from me".into(),
editable: Editable::Text,
draft: String::new(),
reply: None,
attachments: Vec::new(),
});
let buf = render_buffer(&mut open, false, &mut images());
let colors = Colors::default();
let (y, row) = (0..buf.area.height)
.map(|y| {
let row: String = (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect();
(y, row)
})
.find(|(_, row)| row.contains("hello from me"))
.unwrap();
assert!(row.contains("edited "), "{row}");
let left = &buf[(1, y)];
assert_eq!((left.symbol(), left.fg), ("▌", colors.edit));
}
fn reaction(emoji: &str, count: i32, chosen: bool) -> Reaction {
Reaction {
kind: crate::reactions::ReactionKind::Emoji(emoji.into()),
count,
chosen,
}
}
#[test]
fn reactions_sit_under_the_text_with_the_time_beside_them_and_yours_filled_in() {
let mut open = sample();
open.messages.get_mut(&1).unwrap().reactions =
vec![reaction("👍", 3, true), reaction("❤", 1, false)];
let buf = render_buffer(&mut open, false, &mut images());
let colors = Colors::default();
let rows: Vec<String> = (0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect();
let text = rows.iter().position(|r| r.contains("hi there")).unwrap();
let time = Local
.timestamp_opt(1_790_000_000, 0)
.unwrap()
.format("%H:%M")
.to_string();
let chips = &rows[text + 1];
assert!(!rows[text].contains(&time), "{}", rows[text]);
assert!(chips.contains('👍') && chips.contains(&time), "{chips}");
let y = (text + 1) as u16;
let at = |symbol: &str| (0..buf.area.width).find(|&x| buf[(x, y)].symbol() == symbol);
let thumbs = at("👍").unwrap();
assert_eq!(buf[(thumbs, y)].bg, colors.your_reaction);
let heart = at("❤\u{FE0F}").expect("❤ drawn two columns wide");
assert_eq!(buf[(heart, y)].bg, colors.other_reaction);
assert_eq!(buf[(heart + 3, y)].symbol(), "1");
}
#[test]
fn a_bots_buttons_go_under_its_message_sharing_the_bubbles_width() {
use crate::buttons::{Button, Keyboard, Press};
let button = |label: &str| Button {
label: label.into(),
press: Press::Callback(label.into()),
};
let colors = Colors::default();
let mut open = OpenChat::new(42);
let mut pick = msg(false, 1_790_000_000, "Pick a size");
pick.keyboard = Some(Keyboard {
rows: vec![
vec![button("Small"), button("Large")],
vec![button("Cancel the whole order")],
],
reply: false,
});
open.messages.insert(1, pick);
let buf = render_buffer(&mut open, false, &mut images());
let rows: Vec<String> = (0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect();
let text = rows.iter().position(|r| r.contains("Pick a size")).unwrap();
let (sizes, cancel) = (&rows[text + 1], &rows[text + 2]);
assert!(sizes.contains("[ Small ] [ Large ]"), "{sizes}");
assert!(cancel.contains("[Cancel the whole order]"), "{cancel}");
let col = |row: &str, s: &str| row.chars().take(row.find(s).unwrap()).count() as u16;
let y = (text + 1) as u16;
let (bracket, small) = (col(sizes, "["), col(sizes, "Small"));
assert_eq!(buf[(bracket, y)].fg, colors.other_meta, "faded brackets");
assert_eq!(buf[(small, y)].fg, colors.fg);
assert_eq!(buf[(small, y)].bg, colors.other_bubble, "on the bubble");
}
#[test]
fn pinned_messages_show_in_a_bar_over_the_chat_and_by_their_time() {
let mut open = sample();
open.set_pinned(1, true);
let rows = render(&mut open, false);
assert!(
rows[1].contains("📌") && rows[1].contains(" Pinned hi there"),
"{}",
rows[1]
);
assert!(rows[1].trim_end().ends_with("gp list │"), "{}", rows[1]);
assert!(
rows[2].starts_with('├') && rows[2].ends_with('┤'),
"{}",
rows[2]
);
let text = rows.iter().rposition(|r| r.contains("hi there")).unwrap();
assert!(rows[text].contains("📌 "), "by its time: {}", rows[text]);
open.set_pinned(4, true);
let rows = render(&mut open, false);
assert!(rows[1].contains(" 2 pinned ok"), "the newest: {}", rows[1]);
}
#[test]
fn reply_buttons_are_one_line_of_their_words() {
use crate::buttons::{Button, Keyboard, Press};
let button = |label: &str| Button {
label: label.into(),
press: Press::Send(label.into()),
};
let mut open = OpenChat::new(42);
let mut menu = msg(false, 1_790_000_000, "What next?");
menu.keyboard = Some(Keyboard {
rows: vec![vec![button("Yes"), button("No")], vec![button("Later")]],
reply: true,
});
open.messages.insert(1, menu);
let rows = render(&mut open, false);
let text = rows.iter().position(|r| r.contains("What next?")).unwrap();
assert!(rows[text + 1].contains("⌨ [Yes] [No] [Later]"), "{rows:#?}");
}
#[test]
fn reactions_wrap_onto_more_rows_when_they_dont_fit() {
let list = [
reaction("👍", 12, false),
reaction("🔥", 12, false),
reaction("🎉", 12, false),
];
let rows = chip_rows(&list, 15);
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [2, 1]);
}
#[test]
fn an_album_shows_the_reactions_of_all_its_photos_once_under_the_last() {
let mut open = OpenChat::new(42);
for id in [1, 2] {
let mut photo = msg(false, 1_790_000_000, "");
photo.album = 7;
photo.preview = Some(Preview {
file_id: id as i32,
width: 100,
height: 100,
thumbnail: None,
sticker: false,
});
photo.reactions = vec![reaction("🔥", 1, false)];
open.messages.insert(id, photo);
}
let rows = render(&mut open, false);
let fire: Vec<&String> = rows.iter().filter(|r| r.contains('🔥')).collect();
assert_eq!(fire.len(), 1, "{rows:#?}");
let words: Vec<&str> = fire[0].split_whitespace().collect();
assert!(words.windows(2).any(|w| w == ["🔥", "2"]), "{}", fire[0]);
}
#[test]
fn cursor_moves_scroll_the_view() {
let mut open = OpenChat::new(42);
for i in 0..40 {
open.messages.insert(
i,
msg(
i % 2 == 0,
1_790_000_000 + i as i32,
&format!("message {i}"),
),
);
}
let rows = render(&mut open, true);
assert!(
rows.iter().any(|r| r.contains("message 39")),
"starts at the newest"
);
assert!(!rows.iter().any(|r| r.contains("message 0 ")));
open.move_cursor(isize::MIN);
let rows = render(&mut open, true);
assert!(
rows.iter().any(|r| r.contains("message 0 ")),
"gg shows the oldest"
);
assert!(
rows.iter()
.any(|r| r.contains("▌") && r.contains("message 0 ")),
"cursor marks it"
);
}
fn voice_message(outgoing: bool, caption: &str) -> Msg {
Msg {
voice: Some(Voice {
file_id: 9,
size: 0,
duration: 7,
levels: vec![0, 8, 16, 31, 16, 8, 0, 4],
ogg: true,
listened: false,
}),
..msg(outgoing, 1_790_086_500, caption)
}
}
fn rows_of(buf: &ratatui::buffer::Buffer) -> Vec<String> {
(0..buf.area.height)
.map(|y| (0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect())
.collect()
}
#[test]
fn a_voice_message_shows_its_waveform_and_length_with_the_time_beside_them() {
let mut open = sample();
open.messages.insert(5, voice_message(false, ""));
let rows = render(&mut open, false);
let row = rows.iter().find(|r| r.contains('▶')).unwrap();
let time = Local
.timestamp_opt(1_790_086_500, 0)
.unwrap()
.format("%H:%M")
.to_string();
assert!(row.contains("█") && row.contains("0:07 •"), "{row}");
assert!(row.contains(&time), "on one row: {row}");
open.messages
.get_mut(&5)
.unwrap()
.voice
.as_mut()
.unwrap()
.listened = true;
open.messages.insert(6, voice_message(true, ""));
let rows = render(&mut open, false);
let voices: Vec<&String> = rows.iter().filter(|r| r.contains('▶')).collect();
assert_eq!(voices.len(), 2);
assert!(voices.iter().all(|r| !r.contains('•')), "{voices:?}");
}
#[test]
fn a_voice_messages_caption_goes_under_its_waveform() {
let mut open = sample();
open.messages
.insert(5, voice_message(false, "listen to this"));
let rows = render(&mut open, false);
let waveform = rows.iter().position(|r| r.contains('▶')).unwrap();
assert!(rows[waveform + 1].contains("listen to this"), "{rows:#?}");
}
#[test]
fn the_voice_message_playing_shows_how_far_it_got() {
let mut open = sample();
open.messages.insert(5, voice_message(false, ""));
let playing = |at: f64, paused| Playback {
message_id: 5,
at: std::time::Duration::from_secs_f64(at),
paused,
};
let draw = |open: &mut OpenChat, playback| {
render_playing(
open,
&Chats::default(),
false,
&mut images(),
Some(playback),
)
};
let colors = Colors::default();
let buf = draw(&mut open, playing(3.5, false));
let rows = rows_of(&buf);
let y = rows
.iter()
.position(|r| r.contains("▌▌"))
.expect("pause shown");
let row = &rows[y];
assert!(row.contains("0:03"), "{row}");
assert!(
!row.contains("0:07"),
"the time played replaces the length: {row}"
);
let bars: Vec<u16> = (0..buf.area.width)
.filter(|&x| ('▁'..='█').any(|c| buf[(x, y as u16)].symbol() == c.to_string()))
.collect();
let accent = bars
.iter()
.filter(|&&x| buf[(x, y as u16)].fg == colors.accent);
assert_eq!(accent.count(), bars.len() / 2, "{row}");
let rows = rows_of(&draw(&mut open, playing(3.5, true)));
assert!(rows.iter().any(|r| r.contains("▶ ") && r.contains("0:03")));
let rows = rows_of(&draw(
&mut open,
Playback {
message_id: 4,
..playing(1.0, false)
},
));
assert!(
!rows.iter().any(|r| r.contains("▌▌")),
"only on its own message"
);
}
#[test]
fn service_messages_sit_in_the_middle_like_a_date_without_a_bubble() {
use crate::service::Service;
let mut open = sample();
let at = 1_790_086_500;
open.messages.insert(
5,
Msg {
service: Some(Service::Did("pinned a message".into())),
..msg(false, at, "Pinned a message")
},
);
open.messages.insert(6, msg(false, at + 60, "see above"));
let buf = render_buffer(&mut open, false, &mut images());
let rows = rows_of(&buf);
let y = rows
.iter()
.position(|r| r.contains("Unknown pinned a message"))
.expect("a sentence, with who did it");
let row = &rows[y];
let (left, right) = (
row.len() - row.trim_start().len(),
row.len() - row.trim_end().len(),
);
assert!(left.abs_diff(right) <= 2, "in the middle: {row:?}");
let time = Local
.timestamp_opt(i64::from(at), 0)
.unwrap()
.format("%H:%M")
.to_string();
assert!(!row.contains(&time), "no time, like a date");
let x = row.find("pinned").unwrap() as u16;
let colors = Colors::default();
assert_ne!(buf[(x, y as u16)].bg, colors.other_bubble, "no bubble");
assert_eq!(buf[(x, y as u16)].fg, colors.muted);
assert!(rows[y + 2].contains("Unknown"), "{:?}", &rows[y..]);
}
#[test]
fn a_name_in_a_service_message_cant_pass_for_a_date() {
use crate::chats::Peer;
use crate::service::Service;
let mut open = OpenChat::new(42);
let at = 1_790_086_500;
open.messages.insert(
5,
Msg {
service: Some(Service::Added(vec![7])),
..msg(false, at, "Joined the group")
},
);
let mut chats = Chats::default();
chats.add_local(42, "Group", None).peer = Some(Peer::Supergroup(1));
let users = HashMap::from([(7, "Thu 8 Oct 2026".to_string())]);
let names = Names {
users: &users,
chats: &chats,
};
let colors = Colors::default();
let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap();
terminal
.draw(|f| {
draw(
f,
f.area(),
&mut open,
None,
&names,
&mut images(),
false,
false,
&colors,
true,
None,
)
})
.unwrap();
let buf = terminal.backend().buffer();
let rows = rows_of(buf);
let y = rows
.iter()
.position(|r| r.contains("added Thu 8 Oct 2026"))
.expect("the sentence");
let date = rows[y].find("Thu").unwrap() as u16;
let words = rows[y].find("added").unwrap() as u16;
assert_eq!(buf[(words, y as u16)].fg, colors.muted, "tuigram's words");
assert_ne!(buf[(date, y as u16)].fg, colors.muted, "someone's name");
}
#[test]
fn a_long_chat_name_is_cut_so_telegrams_verdict_still_shows() {
use crate::chats::{Badge, Peer};
let mut open = sample();
let mut chats = Chats::default();
let name = "Official Wallet Support and Refunds for Every Customer Worldwide";
chats.add_local(42, name, None).peer = Some(Peer::Supergroup(1));
chats.set_badge(Peer::Supergroup(1), Some(Badge::Scam));
let buf = render_in(&mut open, &chats, false, &mut images());
let rows = rows_of(&buf);
assert!(rows[0].contains("… SCAM"), "{:?}", rows[0]);
}
fn long_day() -> (OpenChat, String) {
let mut open = OpenChat::new(42);
let day = 1_790_000_000;
for i in 0..40 {
let text = format!("message {i}");
open.messages
.insert(i, msg(false, day + i as i32 * 60, &text));
}
let date = Local.timestamp_opt(i64::from(day), 0).unwrap();
(open, date.format("%a %-d %b %Y").to_string())
}
#[test]
fn the_date_stays_on_top_while_a_long_day_scrolls_by() {
let (mut open, date) = long_day();
let rows = render(&mut open, false);
assert_eq!(rows[1].trim_matches(['│', ' ']), date, "{rows:#?}");
assert!(rows.iter().any(|r| r.contains("message 39")), "the newest");
assert_eq!(rows.iter().filter(|r| r.contains(&date)).count(), 1, "once");
open.selected = Some(5);
open.scroll = None;
let rows = render(&mut open, true);
assert_eq!(rows[1].trim_matches(['│', ' ']), date);
let at = rows.iter().position(|r| r.contains("message 5 ")).unwrap();
assert_eq!(at, 3, "right under the date: {rows:#?}");
assert_eq!(rows[2].trim_matches(['│', ' ']), "");
}
#[test]
fn a_date_already_on_screen_isnt_repeated_on_top() {
let rows = render(&mut sample(), false);
let day1 = Local.timestamp_opt(1_790_000_000, 0).unwrap();
let date = day1.format("%a %-d %b %Y").to_string();
assert_eq!(rows.iter().filter(|r| r.contains(&date)).count(), 1);
}
}