use std::sync::OnceLock;
use fframes::Svgr;
use crate::core::{Component, ComponentMeta};
use crate::ctx::RenderCtx;
use crate::ui::{Mode, UiTheme, mix_oklch, text, to_hex, with_alpha};
pub const REF_W: f32 = 638.0;
pub const REF_H: f32 = 354.0;
pub const NAME_GAP: f32 = 37.59;
pub const MSG_GAP: f32 = 46.01;
pub const GROUP_GAP: f32 = 69.0;
pub const START_Y: f32 = 158.9;
pub const ANCHOR: f32 = 219.48;
pub const AVATAR: f32 = 70.0;
pub const AVATAR_X: f32 = 119.5;
pub const AVATAR_RISE: f32 = 28.0;
pub const AVATAR_RADIUS: f32 = 8.0;
pub const AVATAR_LETTER: f32 = 0.45;
pub const AVATAR_WEIGHT: u16 = 600;
pub const TEXT_X: f32 = 202.0;
pub const SIZE: f32 = 31.2;
pub const TIME_SIZE: f32 = 24.9;
pub const ASC: f32 = 28.95;
pub const BODY_WEIGHT: u16 = 400;
pub const NAME_WEIGHT: u16 = 700;
pub const TIME_WEIGHT: u16 = 500;
pub const TIME_GAP: f32 = 13.4;
pub const DOT: f32 = 5.0;
pub const DOT_GAP: f32 = 8.4;
pub const DOT_RISE: f32 = 5.7;
pub const DOT_INSET: f32 = 3.3;
pub const DOT_BEAT: f32 = 0.1;
pub const DOT_MIX: f32 = 0.3;
pub const AVATAR_TINT: f32 = 0.16;
pub const LEAD: f32 = 3.0;
pub const NEXT_MSG: f32 = 24.0;
pub const NEXT_OPEN: f32 = 24.0;
pub const SCROLL: &[(f32, f32)] = &[
(0.0, 0.0),
(0.0167, 0.004),
(0.05, 0.016),
(0.0833, 0.044),
(0.1167, 0.093),
(0.15, 0.178),
(0.1833, 0.289),
(0.2167, 0.398),
(0.25, 0.486),
(0.2833, 0.557),
(0.3167, 0.616),
(0.35, 0.663),
(0.3833, 0.702),
(0.4167, 0.737),
(0.45, 0.767),
(0.4833, 0.791),
(0.5167, 0.814),
(0.55, 0.834),
(0.5833, 0.852),
(0.6167, 0.867),
(0.65, 0.881),
(0.6917, 0.893),
(0.7167, 0.903),
(0.75, 0.912),
(0.7833, 0.919),
(1.1, 1.0),
];
pub const FADE: &[(f32, f32)] = &[
(10.0, 0.098),
(25.0, 0.125),
(40.0, 0.149),
(55.0, 0.216),
(70.0, 0.357),
(85.0, 0.518),
(100.0, 0.651),
(115.0, 0.843),
(140.0, 0.98),
(175.0, 1.0),
];
const BEATS: &[f32] = &[0.0, 12.0, 60.0, 84.0];
const OPENS: &[f32] = &[0.0, 12.0, 37.0, 72.0];
pub const AVATAR_ONE: &str = "avatar1.png";
pub const AVATAR_TWO: &str = "avatar2.png";
pub const DEFAULT_MESSAGES: &[(&str, &str, &str, &str)] = &[
("rhea", "9:41 AM", AVATAR_ONE, "Launch video by Thursday?"),
("rhea", "9:41 AM", AVATAR_ONE, "We have nothing shot."),
("sam", "9:42 AM", AVATAR_TWO, "Already done."),
("sam", "9:42 AM", AVATAR_TWO, "Built it with fframes."),
];
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ThreadMessage {
pub text: String,
pub author: String,
pub time: String,
pub avatar: String,
pub at: Option<u32>,
pub opens: Option<u32>,
}
impl ThreadMessage {
#[must_use]
pub fn new(author: &str, time: &str, text: &str) -> Self {
Self {
text: text.to_owned(),
author: author.to_owned(),
time: time.to_owned(),
avatar: String::new(),
at: None,
opens: None,
}
}
#[must_use]
pub fn with_avatar(author: &str, time: &str, avatar: &str, text: &str) -> Self {
Self {
avatar: avatar.to_owned(),
..Self::new(author, time, text)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThreadGroup {
pub person: String,
pub time: String,
pub avatar: String,
pub lines: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Line {
pub y: f32,
pub name_y: f32,
pub group: usize,
pub head: bool,
pub text: String,
pub at: f32,
pub opens: f32,
}
#[must_use]
pub fn read(table: &[(f32, f32)], x: f32) -> f32 {
let (Some(first), Some(last)) = (table.first(), table.last()) else {
return 0.0;
};
if x <= first.0 {
return first.1;
}
if x >= last.0 {
return last.1;
}
for pair in table.windows(2) {
let (lo, hi) = (pair[0], pair[1]);
if x <= hi.0 {
let span = hi.0 - lo.0;
if span == 0.0 {
return hi.1;
}
return lo.1 + (hi.1 - lo.1) * (x - lo.0) / span;
}
}
last.1
}
#[must_use]
pub fn parse_messages(input: &str) -> Vec<ThreadMessage> {
let mut head = (String::new(), String::new(), String::new());
let mut out = Vec::new();
for part in input.split('|') {
if part.trim().is_empty() {
continue;
}
let mut fields: Vec<String> = part
.split('>')
.map(|field| field.trim().to_owned())
.collect();
let text = fields.pop().unwrap_or_default();
if let Some(who) = fields.pop() {
let cut = who.find(' ');
head = match cut {
Some(index) => (
who[..index].to_owned(),
who[index + 1..].to_owned(),
fields.pop().unwrap_or_default(),
),
None => (who, String::new(), fields.pop().unwrap_or_default()),
};
}
if text.trim().is_empty() {
continue;
}
out.push(ThreadMessage {
text,
author: head.0.clone(),
time: head.1.clone(),
avatar: head.2.clone(),
at: None,
opens: None,
});
}
out
}
#[must_use]
pub fn group_messages(messages: &[ThreadMessage]) -> Vec<ThreadGroup> {
let mut groups: Vec<ThreadGroup> = Vec::new();
for message in messages {
if message.text.trim().is_empty() {
continue;
}
match groups.last_mut() {
Some(group)
if group.person == message.author
&& group.time == message.time
&& group.avatar == message.avatar =>
{
group.lines.push(message.text.trim().to_owned());
}
_ => groups.push(ThreadGroup {
person: message.author.clone(),
time: message.time.clone(),
avatar: message.avatar.clone(),
lines: vec![message.text.trim().to_owned()],
}),
}
}
groups
}
#[must_use]
pub fn layout(groups: &[ThreadGroup]) -> Vec<Line> {
let mut out = Vec::new();
let mut last = START_Y - NAME_GAP;
let mut first = true;
for (index, group) in groups.iter().enumerate() {
for (line_index, text) in group.lines.iter().enumerate() {
let head = line_index == 0;
let name_y = if head {
if first { last } else { last + GROUP_GAP }
} else {
0.0
};
let y = if head {
name_y + NAME_GAP
} else {
last + MSG_GAP
};
out.push(Line {
y,
name_y,
group: index,
head,
text: text.clone(),
at: 0.0,
opens: 0.0,
});
last = y;
first = false;
}
}
out
}
#[must_use]
pub fn land_frame(index: usize, message: Option<&ThreadMessage>) -> f32 {
if let Some(frame) = message.and_then(|message| message.at) {
return frame as f32;
}
match BEATS.get(index) {
Some(frame) => *frame,
None => 84.0 + NEXT_MSG * (index as f32 - 3.0),
}
}
#[must_use]
pub fn open_frame(index: usize, message: Option<&ThreadMessage>) -> f32 {
if index == 0 {
return f32::NEG_INFINITY;
}
if let Some(frame) = message.and_then(|message| message.opens) {
return frame as f32;
}
match OPENS.get(index) {
Some(frame) => *frame,
None => 72.0 + NEXT_OPEN * (index as f32 - 3.0),
}
}
#[must_use]
pub fn schedule(lines: &[Line], messages: &[ThreadMessage]) -> Vec<Line> {
lines
.iter()
.enumerate()
.map(|(index, line)| Line {
at: land_frame(index, messages.get(index)),
opens: open_frame(index, messages.get(index)),
..line.clone()
})
.collect()
}
#[must_use]
pub fn scene(messages: &[ThreadMessage], frame: f32, fps: f32) -> ThreadFrame {
let lines = schedule(&layout(&group_messages(messages)), messages);
ThreadFrame::resolve(lines, frame, fps)
}
#[must_use]
pub fn scroll_want(line: &Line) -> f32 {
(line.y - ANCHOR).max(0.0)
}
#[must_use]
pub fn scroll_rate(step: f32) -> f32 {
(step / MSG_GAP).powf(0.68)
}
#[must_use]
pub fn dot_step(since: f32) -> f32 {
let beat = if DOT_BEAT > 0.0 { DOT_BEAT } else { 0.1 };
let beats = (since / beat).floor();
beats.rem_euclid(4.0)
}
#[must_use]
pub fn scroll_at(lines: &[Line], frame: f32, fps: f32) -> f32 {
let now = frame / fps;
let fps = if fps > 0.0 { fps } else { 30.0 };
let mut scroll = 0.0;
let mut target = 0.0;
for line in lines {
let want = scroll_want(line);
let step = want - target;
target = want;
if step <= 0.0 {
continue;
}
let rate = scroll_rate(step);
scroll += step * read(SCROLL, (now - line.opens / fps) * rate);
}
scroll
}
#[derive(Debug, Clone, PartialEq)]
pub struct ThreadFrame {
pub lines: Vec<Line>,
pub scroll: f32,
pub visible: Vec<bool>,
}
impl ThreadFrame {
#[must_use]
pub fn resolve(lines: Vec<Line>, frame: f32, fps: f32) -> Self {
let scroll = scroll_at(&lines, frame, fps);
let visible = lines
.iter()
.map(|line| {
let y = line.y - scroll;
(-MSG_GAP..=REF_H + MSG_GAP).contains(&y)
})
.collect();
Self {
lines,
scroll,
visible,
}
}
#[must_use]
pub fn baseline(&self, index: usize) -> Option<f32> {
self.lines.get(index).map(|line| line.y - self.scroll)
}
#[must_use]
pub fn body_alpha(&self, index: usize) -> f32 {
self.lines
.get(index)
.map_or(0.0, |line| read(FADE, line.y - self.scroll))
}
#[must_use]
pub fn avatar_alpha(&self, index: usize) -> f32 {
self.lines.get(index).map_or(0.0, |line| {
read(
FADE,
line.y - NAME_GAP - AVATAR_RISE + AVATAR / 2.0 - self.scroll,
)
})
}
}
#[must_use]
pub fn baseline_px(reference_baseline: f32, unit: f32) -> f32 {
reference_baseline * unit
}
#[must_use]
pub fn scene_unit(canvas_height: f32) -> f32 {
canvas_height / REF_H
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChannelThreadConfig {
pub messages: Vec<ThreadMessage>,
pub mode: Mode,
pub theme: Option<UiTheme>,
pub ink: Option<String>,
pub dim_color: Option<String>,
pub font_family: Option<String>,
pub duration: usize,
}
impl Default for ChannelThreadConfig {
fn default() -> Self {
Self {
messages: DEFAULT_MESSAGES
.iter()
.map(|(author, time, avatar, text)| {
ThreadMessage::with_avatar(author, time, avatar, text)
})
.collect(),
mode: Mode::Dark,
theme: None,
ink: None,
dim_color: None,
font_family: None,
duration: 110,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ChannelThread {
pub config: ChannelThreadConfig,
groups: OnceLock<Vec<ThreadGroup>>,
}
impl ChannelThread {
#[must_use]
pub fn new(config: ChannelThreadConfig) -> Self {
Self {
config,
groups: OnceLock::new(),
}
}
#[must_use]
pub fn meta_static() -> ComponentMeta {
ComponentMeta {
id: "channel-thread",
name: "Channel Thread",
summary: "A work chat fills itself in and scrolls once per arrival",
feature: "channel-thread",
}
}
fn groups(&self) -> &[ThreadGroup] {
self.groups
.get_or_init(|| group_messages(&self.config.messages))
}
}
fn monogram(name: &str) -> String {
name.chars()
.next()
.map(|first| first.to_uppercase().collect())
.unwrap_or_default()
}
impl Component for ChannelThread {
fn meta(&self) -> ComponentMeta {
Self::meta_static()
}
fn duration(&self) -> usize {
self.config.duration
}
fn background(&self) -> Option<&'static str> {
Some("#0a0a0b")
}
fn show(&self, ctx: &mut RenderCtx<'_>) -> Svgr<'_> {
let config = &self.config;
let theme = config
.theme
.clone()
.unwrap_or_else(|| UiTheme::for_mode(config.mode));
let ink = config
.ink
.clone()
.unwrap_or_else(|| theme.foreground.clone());
let dim = config
.dim_color
.clone()
.unwrap_or_else(|| theme.muted_foreground.clone());
let family =
text::resolve_face(config.font_family.as_ref(), theme.font_family.as_ref(), ctx);
let unit = scene_unit(ctx.height());
let fps = if ctx.info.fps > 0.0 {
ctx.info.fps
} else {
30.0
};
let frame = ctx.frame();
let lines = schedule(&layout(self.groups()), &config.messages);
if lines.is_empty() {
return Svgr::empty();
}
let state = ThreadFrame::resolve(lines, frame, fps);
let box_left = (ctx.width() - REF_W * unit) / 2.0;
let text_x = box_left + TEXT_X * unit;
let size = SIZE * unit;
let time_size = TIME_SIZE * unit;
let dot_colour = to_hex(mix_oklch(&dim, &ink, DOT_MIX));
let now = frame / fps;
let mut nodes: Vec<Svgr<'static>> = Vec::new();
for (index, line) in state.lines.iter().enumerate() {
if !state.visible.get(index).copied().unwrap_or(false) {
continue;
}
if frame < line.opens {
continue;
}
let y = line.y - state.scroll;
let arrived = frame >= line.opens + LEAD;
let body_alpha = state.body_alpha(index);
let group = self.groups().get(line.group);
if line.head
&& arrived
&& let Some(group) = group
{
let tile_alpha = state.avatar_alpha(index);
let avatar_left = box_left + AVATAR_X * unit;
let avatar_top = (y - NAME_GAP - AVATAR_RISE) * unit;
let avatar_side = AVATAR * unit;
if tile_alpha > 0.0 {
if group.avatar.is_empty() {
let tile = fframes::svgr!(
<rect x={avatar_left} y={avatar_top} width={avatar_side}
height={avatar_side} rx={AVATAR_RADIUS * unit}
fill={with_alpha(&theme.primary, AVATAR_TINT)}
opacity={tile_alpha} />
);
nodes.push(tile);
let letter = monogram(&group.person);
let letter_size = AVATAR * AVATAR_LETTER * unit;
let letter_centre = avatar_top + avatar_side / 2.0;
let letter_baseline = text::baseline_for_centred_box(
ctx,
&family,
letter_size,
letter_centre,
);
nodes.push(text::text_node(
ctx,
&letter,
avatar_left + avatar_side / 2.0,
letter_baseline,
family.clone(),
letter_size,
AVATAR_WEIGHT,
0.0,
theme.primary.clone(),
tile_alpha,
));
} else {
let clip = format!("channel-thread-avatar-{index}");
nodes.push(fframes::svgr!(
<defs>
<clipPath id={clip.clone()}>
<rect x={avatar_left} y={avatar_top} width={avatar_side}
height={avatar_side} rx={AVATAR_RADIUS * unit} />
</clipPath>
</defs>
));
nodes.push(fframes::svgr!(
<image href={group.avatar.clone()} x={avatar_left} y={avatar_top}
width={avatar_side} height={avatar_side}
preserveAspectRatio="xMidYMid slice"
clip-path={format!("url(#{clip})")} opacity={tile_alpha} />
));
}
}
let name_alpha = read(FADE, y - NAME_GAP);
let name_left = box_left + TEXT_X * unit;
let name_width =
text::tracked_width_at(ctx, &family, NAME_WEIGHT, size, &group.person, 0.0);
let baseline = baseline_px(y - NAME_GAP, unit);
if name_alpha > 0.0 {
nodes.push(text::text_node(
ctx,
&group.person,
name_left + name_width / 2.0,
baseline,
family.clone(),
size,
NAME_WEIGHT,
0.0,
ink.clone(),
name_alpha,
));
}
if !group.time.is_empty() {
let time_width = text::tracked_width_at(
ctx,
&family,
TIME_WEIGHT,
time_size,
&group.time,
0.0,
);
nodes.push(text::text_node(
ctx,
&group.time,
name_left + name_width + TIME_GAP * unit + time_width / 2.0,
baseline,
family.clone(),
time_size,
TIME_WEIGHT,
0.0,
dim.clone(),
name_alpha,
));
}
}
if frame >= line.at {
let body_width =
text::tracked_width_at(ctx, &family, BODY_WEIGHT, size, &line.text, 0.0);
nodes.push(text::text_node(
ctx,
&line.text,
text_x + body_width / 2.0,
baseline_px(y, unit),
family.clone(),
size,
BODY_WEIGHT,
0.0,
ink.clone(),
body_alpha,
));
} else if line.head && arrived && group.is_some() {
let since = now - (line.opens + LEAD) / fps;
let step = dot_step(since);
let dots_y = (y - DOT_RISE) * unit;
for index in 0..3 {
if f32::from(u8::try_from(index).unwrap_or(0)) >= step {
continue;
}
let dot_left =
box_left + (TEXT_X + DOT_INSET) * unit + index as f32 * DOT_GAP * unit;
nodes.push(fframes::svgr!(
<circle cx={dot_left + DOT * unit / 2.0} cy={dots_y}
r={DOT * unit / 2.0} fill={dot_colour.clone()}
opacity={body_alpha} />
));
}
}
}
let nodes = Svgr::from(nodes);
fframes::svgr!(
<defs>
<clipPath id="channel-thread-stage">
<rect x={0.0} y={0.0} width={ctx.width()} height={ctx.height()} />
</clipPath>
</defs>
<g clip-path="url(#channel-thread-stage)">{nodes}</g>
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn script() -> Vec<ThreadGroup> {
vec![
ThreadGroup {
person: "abby".to_owned(),
time: "1:01 PM".to_owned(),
avatar: String::new(),
lines: vec!["one".to_owned(), "two".to_owned()],
},
ThreadGroup {
person: "finn".to_owned(),
time: "1:02 PM".to_owned(),
avatar: String::new(),
lines: vec!["three".to_owned(), "four".to_owned()],
},
]
}
fn default_lines() -> Vec<Line> {
scene(&ChannelThreadConfig::default().messages, 0.0, 30.0).lines
}
fn default_scene(frame: f32) -> ThreadFrame {
scene(&ChannelThreadConfig::default().messages, frame, 30.0)
}
#[test]
fn duration_defaults_to_the_source_composition_length() {
assert_eq!(ChannelThread::default().duration(), 110);
assert_eq!(ChannelThread::default().meta().id, "channel-thread");
assert_eq!(ChannelThread::default().meta().feature, "channel-thread");
}
#[test]
fn the_source_defaults_are_reproduced_exactly() {
let config = ChannelThreadConfig::default();
assert_eq!(config.messages.len(), 4);
assert_eq!(
config.messages[0],
ThreadMessage::with_avatar("rhea", "9:41 AM", AVATAR_ONE, "Launch video by Thursday?")
);
assert_eq!(
config.messages[1],
ThreadMessage::with_avatar("rhea", "9:41 AM", AVATAR_ONE, "We have nothing shot.")
);
assert_eq!(
config.messages[2],
ThreadMessage::with_avatar("sam", "9:42 AM", AVATAR_TWO, "Already done.")
);
assert_eq!(
config.messages[3],
ThreadMessage::with_avatar("sam", "9:42 AM", AVATAR_TWO, "Built it with fframes.")
);
assert!(config.messages.iter().all(|message| message.at.is_none()));
assert!(
config
.messages
.iter()
.all(|message| message.opens.is_none())
);
assert_eq!(config.mode, Mode::Dark);
assert_eq!(config.theme, None);
assert_eq!(config.ink, None);
assert_eq!(config.dim_color, None);
assert_eq!(config.font_family, None);
assert_eq!(config.duration, 110);
assert_eq!(ChannelThread::default().background(), Some("#0a0a0b"));
}
#[test]
fn the_demo_pictures_are_the_files_the_crate_embeds() {
let media = crate::ComponentsMedia::new().expect("embedded media");
let embedded = [
(AVATAR_ONE, media.avatar1_png, (199_u32, 199_u32)),
(AVATAR_TWO, media.avatar2_png, (199, 200)),
];
for (name, image, (width, height)) in embedded {
assert_eq!(image.metadata.width, width, "{name} width");
assert_eq!(image.metadata.height, height, "{name} height");
}
assert_eq!(
DEFAULT_MESSAGES
.iter()
.map(|(.., avatar, _)| *avatar)
.collect::<Vec<_>>(),
vec![AVATAR_ONE, AVATAR_ONE, AVATAR_TWO, AVATAR_TWO]
);
let groups = group_messages(&ChannelThreadConfig::default().messages);
assert_eq!(groups[0].avatar, AVATAR_ONE);
assert_eq!(groups[1].avatar, AVATAR_TWO);
}
#[test]
fn the_source_string_form_continues_the_speaker_above() {
assert_eq!(
parse_messages("/a.jpg > rhea 9:41 AM > Hi | There | sam > Yo"),
vec![
ThreadMessage::with_avatar("rhea", "9:41 AM", "/a.jpg", "Hi"),
ThreadMessage::with_avatar("rhea", "9:41 AM", "/a.jpg", "There"),
ThreadMessage::new("sam", "", "Yo"),
]
);
}
#[test]
fn the_string_form_is_the_configs_own_demo_copy() {
let parsed = parse_messages(
"avatar1.png > rhea 9:41 AM > Launch video by Thursday? | We have nothing shot. \
| avatar2.png > sam 9:42 AM > Already done. | Built it with fframes.",
);
assert_eq!(parsed, ChannelThreadConfig::default().messages);
assert_eq!(group_messages(&parsed).len(), 2);
let source = parse_messages(
"/avatars/07.jpg > rhea 9:41 AM > Launch video by Thursday? | We have nothing shot. \
| /avatars/13.jpg > sam 9:42 AM > Already done. | Built it with fframes.",
);
assert_eq!(source.len(), 4);
assert_eq!(source[0].avatar, "/avatars/07.jpg");
assert_eq!(source[2].avatar, "/avatars/13.jpg");
assert_eq!(source[0].text, parsed[0].text);
assert_eq!(source[3].text, parsed[3].text);
}
#[test]
fn a_second_message_from_the_same_person_joins_the_group() {
let groups = group_messages(&ChannelThreadConfig::default().messages);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].person, "rhea");
assert_eq!(
groups[0].lines,
vec!["Launch video by Thursday?", "We have nothing shot."]
);
assert_eq!(groups[1].person, "sam");
assert_eq!(
groups[1].lines,
vec!["Already done.", "Built it with fframes."]
);
let shifted: Vec<ThreadMessage> = ChannelThreadConfig::default()
.messages
.iter()
.map(|message| {
let mut next = message.clone();
next.time = "9:43 AM".to_owned();
next
})
.collect();
assert_eq!(group_messages(&shifted).len(), 2);
let mut split = shifted;
split[1].time = "9:44 AM".to_owned();
assert_eq!(group_messages(&split).len(), 3);
let mut refaced = ChannelThreadConfig::default().messages;
refaced[1].avatar = AVATAR_TWO.to_owned();
assert_eq!(group_messages(&refaced).len(), 3);
let refaced = group_messages(&refaced);
assert_eq!(refaced[0].lines, vec!["Launch video by Thursday?"]);
assert_eq!(refaced[1].avatar, AVATAR_TWO);
assert_eq!(refaced[1].lines, vec!["We have nothing shot."]);
let mut blank = ChannelThreadConfig::default().messages;
blank.push(ThreadMessage::new("ghost", "9:50 AM", " "));
assert_eq!(group_messages(&blank).len(), 2);
}
#[test]
fn a_message_with_no_picture_takes_the_monogram_path() {
let plain = ThreadMessage::new("rhea", "9:41 AM", "Hi");
assert!(plain.avatar.is_empty());
let named = ThreadMessage::with_avatar("rhea", "9:41 AM", AVATAR_TWO, "Hi");
assert_eq!(named.avatar, AVATAR_TWO);
assert_eq!(named.text, plain.text);
assert_eq!(named.author, plain.author);
assert_eq!(ThreadMessage::default(), ThreadMessage::new("", "", ""));
assert_eq!(group_messages(&[plain])[0].avatar, "");
assert_eq!(group_messages(&[named])[0].avatar, AVATAR_TWO);
}
#[test]
fn the_transcript_stacks_on_the_measured_grid() {
let lines = layout(&script());
assert_eq!(
lines.iter().map(|line| line.y).collect::<Vec<_>>(),
vec![
START_Y,
START_Y + MSG_GAP,
START_Y + MSG_GAP + GROUP_GAP + NAME_GAP,
START_Y + MSG_GAP + GROUP_GAP + NAME_GAP + MSG_GAP,
]
);
assert!((lines[2].y - 311.5).abs() < 0.01, "{}", lines[2].y);
assert!((lines[3].y - 357.51).abs() < 0.01, "{}", lines[3].y);
}
#[test]
fn a_group_is_headed_by_its_name_and_never_repeats_it() {
let lines = layout(&script());
assert_eq!(
lines.iter().map(|line| line.head).collect::<Vec<_>>(),
vec![true, false, true, false]
);
assert!((lines[0].name_y - (START_Y - NAME_GAP)).abs() < 1e-5);
assert!((lines[1].name_y).abs() < 1e-6);
assert_eq!(
lines.iter().map(|line| line.group).collect::<Vec<_>>(),
vec![0, 0, 1, 1]
);
}
#[test]
fn only_the_rows_that_reach_the_anchor_scroll() {
let lines = layout(&script());
let want: Vec<f32> = lines.iter().map(scroll_want).collect();
assert!((want[0]).abs() < 1e-6);
assert!((want[1]).abs() < 1e-6);
assert!((want[2] - 92.02).abs() < 0.01, "{}", want[2]);
assert!((want[3] - 138.03).abs() < 0.01, "{}", want[3]);
assert!((START_Y - want[3] - 20.87).abs() < 0.01);
}
#[test]
fn the_measured_recording_arithmetic_closes_to_the_pixel() {
let lines = default_lines();
let scroll = scroll_at(&lines, 219.0, 30.0);
assert!((scroll - 138.03).abs() < 0.01, "scroll {scroll}");
let oldest = lines[0].y - scroll;
assert!((oldest - 20.87).abs() < 0.01, "oldest {oldest}");
assert!((lines[3].y - 357.51).abs() < 0.01, "{}", lines[3].y);
assert!((lines[3].y - scroll - 219.48).abs() < 0.01);
assert!(
(lines[2].name_y - 273.91).abs() < 0.01,
"{}",
lines[2].name_y
);
assert!((lines[2].name_y - scroll - 135.88).abs() < 0.02);
assert!((lines[3].name_y).abs() < 1e-6);
}
#[test]
fn the_scroll_table_rolls_forwards_only_and_arrives() {
assert!((read(SCROLL, 0.0)).abs() < 1e-6);
assert!((read(SCROLL, 5.0) - 1.0).abs() < 1e-6);
assert!((read(SCROLL, -1.0)).abs() < 1e-6);
for pair in SCROLL.windows(2) {
assert!(pair[1].0 > pair[0].0, "the seconds repeat at {}", pair[1].0);
assert!(pair[1].1 > pair[0].1, "the share repeats at {}", pair[1].1);
}
assert!((SCROLL[0].1).abs() < 1e-6);
assert!((SCROLL[SCROLL.len() - 1].1 - 1.0).abs() < 1e-6);
}
#[test]
fn the_fade_rises_toward_the_top_of_the_frame_and_stops() {
assert!(read(FADE, 0.0) < 0.11);
assert!((read(FADE, 200.0) - 1.0).abs() < 1e-6);
assert!((read(FADE, 1000.0) - 1.0).abs() < 1e-6);
for pair in FADE.windows(2) {
assert!(pair[1].1 > pair[0].1, "the fade repeats at {}", pair[1].0);
}
assert!((read(FADE, REF_H) - 1.0).abs() < 1e-6);
assert!(FADE[0].1 > 0.09);
}
#[test]
fn an_empty_table_reads_zero_rather_than_panicking() {
assert!((read(&[], 0.5)).abs() < 1e-6);
}
#[test]
fn the_message_schedule_is_the_measured_one() {
assert!((land_frame(0, None) - 0.0).abs() < 1e-6);
assert!((land_frame(1, None) - 12.0).abs() < 1e-6);
assert!((land_frame(2, None) - 60.0).abs() < 1e-6);
assert!((land_frame(3, None) - 84.0).abs() < 1e-6);
assert!((land_frame(4, None) - 108.0).abs() < 1e-6);
assert!((land_frame(5, None) - 132.0).abs() < 1e-6);
assert_eq!(open_frame(0, None), f32::NEG_INFINITY);
assert!((open_frame(1, None) - 12.0).abs() < 1e-6);
assert!((open_frame(2, None) - 37.0).abs() < 1e-6);
assert!((open_frame(3, None) - 72.0).abs() < 1e-6);
assert!((open_frame(4, None) - 96.0).abs() < 1e-6);
let mut message = ThreadMessage::new("x", "1:00", "hi");
message.at = Some(7);
message.opens = Some(5);
assert!((land_frame(9, Some(&message)) - 7.0).abs() < 1e-6);
assert!((open_frame(9, Some(&message)) - 5.0).abs() < 1e-6);
assert_eq!(open_frame(0, Some(&message)), f32::NEG_INFINITY);
}
#[test]
fn the_dots_window_is_the_next_four_beats() {
assert!((dot_step(0.0)).abs() < 1e-6);
assert!((dot_step(0.05)).abs() < 1e-6);
assert!((dot_step(0.1) - 1.0).abs() < 1e-6);
assert!((dot_step(0.2) - 2.0).abs() < 1e-6);
assert!((dot_step(0.3) - 3.0).abs() < 1e-6);
assert!((dot_step(0.4)).abs() < 1e-6);
assert!((dot_step(0.5) - 1.0).abs() < 1e-6);
for tenth in 0..40 {
let step = dot_step(tenth as f32 * 0.02);
assert!((0.0..=3.0).contains(&step), "step {step}");
}
}
#[test]
fn the_transcript_is_perfectly_still_between_arrivals() {
let reference = default_scene(12.0).scroll;
assert!(
(reference).abs() < 1e-6,
"the second message must move nothing"
);
for frame in 12..37_u32 {
let here = default_scene(frame as f32).scroll;
assert!(
(here - reference).abs() < 1e-9,
"frame {frame} moved the stack to {here}"
);
}
let settled = default_scene(105.0).scroll;
for frame in 105..110_u32 {
let here = default_scene(frame as f32).scroll;
assert!(
(here - settled).abs() < 1e-9,
"frame {frame} moved to {here}"
);
}
assert!((settled - 138.03).abs() < 0.01, "settled {settled}");
}
#[test]
fn the_scroll_starts_on_the_frame_a_row_opens_and_lands_once() {
let scroll = |frame: f32| default_scene(frame).scroll;
let lines = default_lines();
assert!((scroll(36.0)).abs() < 1e-6);
assert!(
(scroll(37.0)).abs() < 1e-6,
"the opening frame itself must not have moved"
);
assert!(scroll(38.0) > 0.0);
assert!(scroll(38.0) < scroll_want(&lines[2]));
assert!((scroll(70.0) - 92.02).abs() < 0.01);
let settled_by = (37..70)
.find(|frame| (scroll(*frame as f32) - 92.02).abs() < 0.01)
.expect("the one-row move arrives inside its own window");
assert!(
(55..=60).contains(&settled_by),
"a 0.7 s move at 30 fps should land near frame 58, got {settled_by}"
);
let at_fourth = scroll(72.0);
assert!((at_fourth - 92.02).abs() < 0.01, "at fourth {at_fourth}");
let settled = scroll(109.0);
assert!((settled - 138.03).abs() < 0.01, "settled {settled}");
assert!(settled > at_fourth);
assert!(
(settled - scroll_want(&lines[3])).abs() < 0.02,
"the total travel is the largest target: {settled} vs {}",
scroll_want(&lines[3])
);
}
#[test]
fn a_longer_move_is_read_faster() {
assert!((scroll_rate(MSG_GAP) - 1.0).abs() < 1e-6);
assert!(
(scroll_rate(2.0 * MSG_GAP) - 1.60).abs() < 0.01,
"{}",
scroll_rate(2.0 * MSG_GAP)
);
assert!(scroll_rate(3.0 * MSG_GAP) > scroll_rate(2.0 * MSG_GAP));
}
#[test]
fn the_default_scroll_settles_the_recorded_last_frame() {
let last = default_scene(109.0).scroll;
let after = default_scene(110.0).scroll;
assert!((last - after).abs() < 1e-9);
assert!((last - 138.03).abs() < 0.01, "settled {last}");
let wants: Vec<f32> = default_scene(0.0).lines.iter().map(scroll_want).collect();
assert!((last - wants[3]).abs() < 0.02);
}
#[test]
fn the_visible_window_is_the_sources_own_band() {
let state = default_scene(0.0);
assert!(state.visible[3]);
assert!(state.visible.iter().all(|visible| *visible));
let mut pushed = state.lines.clone();
for line in &mut pushed {
line.y -= 600.0;
}
let pushed = ThreadFrame::resolve(pushed, 0.0, 30.0);
assert!(pushed.visible.iter().all(|visible| !*visible));
let state = default_scene(109.0);
assert!(state.visible.iter().all(|visible| *visible));
assert!(
(state.body_alpha(0) - 0.2).abs() < 0.1,
"{}",
state.body_alpha(0)
);
assert!((state.body_alpha(3) - 1.0).abs() < 1e-6);
assert!(state.body_alpha(0) < state.body_alpha(3));
assert!((state.lines[0].y - state.scroll - 20.87).abs() < 0.01);
assert!((state.lines[3].y - state.scroll - ANCHOR).abs() < 0.01);
}
#[test]
fn the_source_constants_are_the_measured_ones() {
assert!((REF_W - 638.0).abs() < 1e-6);
assert!((REF_H - 354.0).abs() < 1e-6);
assert!((NAME_GAP - 37.59).abs() < 1e-6);
assert!((MSG_GAP - 46.01).abs() < 1e-6);
assert!((GROUP_GAP - 69.0).abs() < 1e-6);
assert!((START_Y - 158.9).abs() < 1e-6);
assert!((ANCHOR - 219.48).abs() < 1e-6);
assert!((AVATAR - 70.0).abs() < 1e-6);
assert!((AVATAR_X - 119.5).abs() < 1e-6);
assert!((AVATAR_RISE - 28.0).abs() < 1e-6);
assert!((TEXT_X - 202.0).abs() < 1e-6);
assert!((SIZE - 31.2).abs() < 1e-6);
assert!((TIME_SIZE - 24.9).abs() < 1e-6);
assert!((ASC - 28.95).abs() < 1e-6);
assert!((DOT - 5.0).abs() < 1e-6);
assert!((DOT_GAP - 8.4).abs() < 1e-6);
assert!((DOT_RISE - 5.7).abs() < 1e-6);
assert!((DOT_BEAT - 0.1).abs() < 1e-6);
assert!((LEAD - 3.0).abs() < 1e-6);
assert_eq!(AVATAR_RADIUS, 8.0);
assert_eq!(AVATAR_LETTER, 0.45);
assert_eq!(AVATAR_WEIGHT, 600);
assert_eq!(BODY_WEIGHT, 400);
assert_eq!(NAME_WEIGHT, 700);
assert_eq!(TIME_WEIGHT, 500);
assert!((TIME_GAP - 13.4).abs() < 1e-6);
assert!((DOT_INSET - 3.3).abs() < 1e-6);
}
#[test]
fn the_scene_scales_by_the_recordings_own_ratio_not_the_family_unit() {
let hd = scene_unit(1080.0);
assert!((hd - 1080.0 / 354.0).abs() < 1e-5);
assert!((hd - 3.050_847).abs() < 1e-5, "{hd}");
assert!(
hd > 3.0,
"the family unit of 1.5 would draw this at half size"
);
assert!((scene_unit(720.0) - 2.033_898).abs() < 1e-5);
assert!((scene_unit(540.0) - 1.525_423_7).abs() < 1e-5);
assert!((REF_W * hd - 1946.44).abs() < 0.05, "{}", REF_W * hd);
assert!(((1920.0 - REF_W * hd) / 2.0 + 13.22).abs() < 0.05);
assert!((REF_W * scene_unit(720.0) - 1297.62).abs() < 0.05);
assert!((scene_unit(REF_H) - 1.0).abs() < 1e-6);
}
#[test]
fn the_demo_frames_own_ink_positions_are_reproduced() {
let unit = scene_unit(540.0);
let family = (960.0_f32).min(540.0) / 100.0 / crate::motion::REFERENCE_VMIN;
assert!((family - 0.75).abs() < 1e-6);
assert!((scene_unit(540.0) - 1.525_423_7).abs() < 1e-5);
let rows: Vec<f32> = default_scene(219.0)
.lines
.iter()
.map(|line| line.y - default_scene(219.0).scroll)
.collect();
assert!((rows[0] - 20.87).abs() < 0.01, "{}", rows[0]);
assert!((rows[1] - 66.88).abs() < 0.01, "{}", rows[1]);
let label = default_scene(219.0).lines[2].name_y - default_scene(219.0).scroll;
let expected = [rows[0], rows[1], label, rows[2], rows[3]];
let measured = [31.8_f32, 102.2, 207.3, 264.6, 334.8];
for (index, (source, expected)) in expected.iter().zip(measured).enumerate() {
let mine = source * unit;
let family_offset = source * family;
assert!(
(mine - expected).abs() < 0.6,
"row {index}: height/REF_H gives {mine}, the recording measures {expected}"
);
assert!(
(mine - expected).abs() < (family_offset - expected).abs(),
"row {index}: height/REF_H ({mine}) must beat vmin/7.2 ({family_offset}) \
against the recording's {expected}"
);
}
let box_left = (960.0 - REF_W * unit) / 2.0;
assert!(
(box_left - -6.61).abs() < 0.05,
"the box is wider than the frame: {box_left}"
);
assert!(
(box_left + AVATAR_X * unit - 175.68).abs() < 0.05,
"tile left edge"
);
assert!(
(box_left + TEXT_X * unit - 301.50).abs() < 0.05,
"text box left edge"
);
assert!(((box_left + AVATAR_X * unit) - 175.5).abs() < 0.5);
let hd = scene_unit(1080.0);
let hd_left = (1920.0 - REF_W * hd) / 2.0;
assert!((hd - 3.050_847_5).abs() < 1e-5);
assert!(
(hd_left - -13.22).abs() < 0.01,
"the box overhangs: {hd_left}"
);
assert!((hd_left + AVATAR_X * hd - 351.36).abs() < 0.01);
assert!((AVATAR * hd - 213.56).abs() < 0.01);
assert!((hd_left + TEXT_X * hd - 603.05).abs() < 0.01);
let demo_left = (960.0 - REF_W * unit) / 2.0;
assert!((demo_left + AVATAR_X * unit - 175.7).abs() < 0.1);
assert!((demo_left + TEXT_X * unit - 301.5).abs() < 0.1);
}
#[test]
fn the_monogram_is_the_names_first_letter_uppercased() {
assert_eq!(monogram("rhea"), "R");
assert_eq!(monogram("sam"), "S");
assert_eq!(monogram(""), "");
assert_eq!(monogram("élise"), "É");
}
#[test]
fn the_baseline_mapping_applies_no_box_top_correction() {
let unit = scene_unit(1080.0);
assert!((baseline_px(START_Y, unit) - 484.77).abs() < 0.05);
assert!((baseline_px(0.0, unit)).abs() < 1e-6);
assert!((baseline_px(START_Y, 1.0) - START_Y).abs() < 1e-6);
let shift_at = |height: f32| {
let unit = scene_unit(height);
let size = SIZE * unit;
(size * 1.2 + size - 2.0 * size * 0.75) / 2.0
};
let shift720 = shift_at(720.0);
let shift1080 = shift_at(1080.0);
assert!((shift720 - 22.21).abs() < 0.3, "720 {shift720}");
assert!((shift1080 - 33.31).abs() < 0.3, "1080 {shift1080}");
assert!(
shift1080 > 30.0,
"the forbidden shift is not negligible: {shift1080}"
);
let delta = 559.2_f32 - 484.77_f32;
assert!(
(delta - 74.43).abs() < 0.05,
"the applied error was {delta} px"
);
assert!(delta > 20.0, "the error was visible, not a rounding");
}
#[test]
fn the_drawn_baselines_are_the_measured_ones() {
let unit = scene_unit(1080.0);
let state = default_scene(0.0);
assert!((baseline_px(state.lines[0].y - state.scroll, unit) - 484.77).abs() < 0.05);
assert!(
(baseline_px(state.lines[0].y - NAME_GAP - state.scroll, unit) - 370.10).abs() < 0.05
);
let settled = default_scene(219.0);
let demo = unit / 1.525_423_7;
let drawn: Vec<f32> = settled
.lines
.iter()
.map(|line| baseline_px(line.y - settled.scroll, unit) / demo)
.collect();
let label = baseline_px(settled.lines[2].name_y - settled.scroll, unit) / demo;
assert!((drawn[0] - 31.84).abs() < 0.6, "{}", drawn[0]);
assert!((drawn[1] - 102.04).abs() < 0.6, "{}", drawn[1]);
assert!((label - 207.29).abs() < 0.6, "{label}");
assert!((drawn[2] - 264.67).abs() < 0.6, "{}", drawn[2]);
assert!((drawn[3] - 334.79).abs() < 0.6, "{}", drawn[3]);
}
#[test]
fn the_drawn_output_is_byte_identical_between_arrivals() {
let config = ChannelThreadConfig::default();
let reference = scene(&config.messages, 12.0, 30.0);
for frame in 12..37_u32 {
assert!(
scene(&config.messages, frame as f32, 30.0) == reference,
"frame {frame} differs from frame 12"
);
}
let reference = scene(&config.messages, 105.0, 30.0);
for frame in 105..=110_u32 {
assert!(
scene(&config.messages, frame as f32, 30.0) == reference,
"frame {frame} differs from frame 105"
);
}
assert!(scene(&config.messages, 36.0, 30.0) == scene(&config.messages, 37.0, 30.0));
}
#[test]
fn the_last_frames_are_byte_identical() {
let settled = default_scene(105.0).scroll;
for frame in 105..=110_u32 {
assert!((default_scene(frame as f32).scroll - settled).abs() < 1e-9);
}
let step = |frame: u32| {
default_scene(frame as f32 + 1.0).scroll - default_scene(frame as f32).scroll
};
assert!(step(89) < 0.9, "frame 89 -> 90 moved {}", step(89));
assert!(step(97) < 0.4, "frame 97 -> 98 moved {}", step(97));
for frame in 89..110_u32 {
assert!(step(frame) < 0.9, "frame {frame} moved {}", step(frame));
}
for frame in 105..110_u32 {
assert!(step(frame) < 0.1, "frame {frame} moved {}", step(frame));
}
let target = scroll_want(&default_scene(0.0).lines[3]);
let mut previous = 0.0;
for frame in 0..=110_u32 {
let here = default_scene(frame as f32).scroll;
assert!(here >= previous - 1e-6, "frame {frame} moved backwards");
assert!(here <= target + 1e-3, "frame {frame} overshot the target");
previous = here;
}
}
#[test]
fn an_empty_conversation_draws_nothing_and_does_not_divide_by_zero() {
let groups = group_messages(&[]);
assert!(groups.is_empty());
let lines = layout(&groups);
assert!(lines.is_empty());
assert!((scroll_at(&[], 0.0, 0.0)).abs() < 1e-6);
let state = ThreadFrame::resolve(Vec::new(), 0.0, 30.0);
assert!((state.body_alpha(0)).abs() < 1e-6);
assert!((state.avatar_alpha(0)).abs() < 1e-6);
assert!(state.baseline(0).is_none());
}
}