fix: match title animation groups by terminal width

This commit is contained in:
2026-10-19 18:55:38 +02:00
parent a44d95bb71
commit 5c4c387e5c
2 changed files with 185 additions and 38 deletions
+184 -37
View File
@@ -4,6 +4,7 @@ use std::{
time::{Duration, Instant},
};
use ratatui::text::Span;
use serde::Deserialize;
use unicode_segmentation::UnicodeSegmentation;
@@ -18,30 +19,82 @@ struct CachedTitle {
title: Option<String>,
}
struct Replacement {
columns: usize,
old_end: usize,
target_end: usize,
}
struct Animation {
id: String,
target: String,
target_len: usize,
display: String,
old_widths: Vec<usize>,
target_offsets: Vec<usize>,
replacements: Vec<Replacement>,
replaced: usize,
started: Instant,
}
impl Animation {
fn new(id: String, old: String, target: String, now: Instant) -> Self {
let old_widths = old.graphemes(true).map(str::len).collect();
let mut target_offsets: Vec<_> = target
.grapheme_indices(true)
.map(|(index, _)| index)
.collect();
target_offsets.push(target.len());
fn new(id: String, mut old: String, mut target: String, now: Instant) -> Self {
// Use the same grapheme widths as Ratatui, keeping byte offsets only for splicing.
let boundaries = |text: &str| {
let mut columns = 0;
let mut ends: Vec<(usize, usize)> = Vec::new();
for (offset, grapheme) in text.grapheme_indices(true) {
columns += Span::raw(grapheme).width();
let end = offset + grapheme.len();
if let Some(previous) = ends.last_mut()
&& previous.0 == columns
{
// Keep zero-width graphemes with the group at their column.
previous.1 = end;
} else {
ends.push((columns, end));
}
}
ends
};
let mut old_boundaries = boundaries(&old);
let mut target_boundaries = boundaries(&target);
let old_columns = old_boundaries.last().map_or(0, |boundary| boundary.0);
let target_columns = target_boundaries.last().map_or(0, |boundary| boundary.0);
let columns = old_columns.max(target_columns);
let target_len = target.len();
// An exhausted title has a boundary at each remaining padding column.
old_boundaries.extend((old_columns + 1..=columns).map(|column| (column, old.len())));
for column in target_columns + 1..=columns {
target.push(' ');
target_boundaries.push((column, target.len()));
}
let mut replacements = Vec::new();
let (mut left, mut right) = (0, 0);
while left < old_boundaries.len() && right < target_boundaries.len() {
let (old_column, old_end) = old_boundaries[left];
let (target_column, target_end) = target_boundaries[right];
match old_column.cmp(&target_column) {
std::cmp::Ordering::Less => left += 1,
std::cmp::Ordering::Greater => right += 1,
std::cmp::Ordering::Equal => {
replacements.push(Replacement {
columns: old_column,
old_end,
target_end,
});
left += 1;
right += 1;
}
}
}
// Every frame is a target prefix plus an untouched old suffix; reserve once.
old.reserve(target.len());
Self {
id,
target,
target_len,
display: old,
old_widths,
target_offsets,
replacements,
replaced: 0,
started: now,
}
@@ -49,26 +102,30 @@ impl Animation {
// Return false at the final step: the caller then displays the unpadded canonical title.
fn advance(&mut self, now: Instant) -> bool {
let target_len = self.target_offsets.len() - 1;
let total = self.old_widths.len().max(target_len);
let steps = (now.saturating_duration_since(self.started).as_nanos() / STEP.as_nanos())
.min(total as u128) as usize;
if steps == total {
let columns = now.saturating_duration_since(self.started).as_nanos() / STEP.as_nanos();
if self
.replacements
.last()
.is_none_or(|step| columns >= step.columns as u128)
{
return false;
}
for index in self.replaced..steps {
let (cursor, replacement) = if index < target_len {
let start = self.target_offsets[index];
let end = self.target_offsets[index + 1];
(start, &self.target[start..end])
} else {
(self.target.len() + index - target_len, " ")
};
let width = self.old_widths.get(index).copied().unwrap_or(0);
self.display
.replace_range(cursor..cursor + width, replacement);
let mut next = self.replaced;
while next < self.replacements.len() && self.replacements[next].columns as u128 <= columns {
next += 1;
}
if next != self.replaced {
let (cursor, old_start) = self.replaced.checked_sub(1).map_or((0, 0), |index| {
let previous = &self.replacements[index];
(previous.target_end, previous.old_end)
});
let replacement = &self.replacements[next - 1];
self.display.replace_range(
cursor..cursor + replacement.old_end - old_start,
&self.target[cursor..replacement.target_end],
);
self.replaced = next;
}
self.replaced = steps;
true
}
}
@@ -117,10 +174,9 @@ impl Titles {
let start = visible.start.min(videos.len());
let visible = start..visible.end.min(videos.len()).max(start);
self.animations.retain_mut(|animation| {
videos[visible.clone()]
.iter()
.any(|video| video.id == animation.id && video.title == animation.target)
&& animation.advance(now)
videos[visible.clone()].iter().any(|video| {
video.id == animation.id && video.title == animation.target[..animation.target_len]
}) && animation.advance(now)
});
if let Some(result) = self.fetch.as_mut().and_then(|(_, job)| job.poll()) {
@@ -237,7 +293,7 @@ mod tests {
}
#[test]
fn shorter_title_blanks_old_graphemes_then_discards_padding() {
fn shorter_title_blanks_exhausted_columns_then_discards_padding() {
let now = Instant::now();
let (mut titles, mut videos) = seeded("abcde", "XY", now);
for (step, expected) in [
@@ -264,20 +320,111 @@ mod tests {
}
#[test]
fn combining_marks_and_joined_emoji_are_single_steps() {
fn combining_marks_and_joined_emoji_stay_whole_at_column_boundaries() {
let now = Instant::now();
let (mut titles, mut videos) = seeded("a\u{301}👨‍👩‍👧‍👦🇩🇰!", "e\u{301}👩🏽‍💻🇯🇵?", now);
for (step, expected) in [
(1, "e\u{301}👨‍👩‍👧‍👦🇩🇰!"),
(2, "e\u{301}👩🏽‍💻🇩🇰!"),
(3, "e\u{301}👩🏽‍💻🇯🇵!"),
(4, "e\u{301}👩🏽‍💻🇯🇵?"),
(2, "e\u{301}👨‍👩‍👧‍👦🇩🇰!"),
(3, "e\u{301}👩🏽‍💻🇩🇰!"),
(4, "e\u{301}👩🏽‍💻🇩🇰!"),
(5, "e\u{301}👩🏽‍💻🇯🇵!"),
(6, "e\u{301}👩🏽‍💻🇯🇵?"),
] {
titles.update(&mut videos, 0..1, now + STEP * step);
assert_eq!(titles.title(&videos[0]), expected);
}
}
#[test]
fn narrow_and_wide_replacements_wait_for_matching_columns_in_both_directions() {
let now = Instant::now();
for (old, target, frames) in [
(
"abcd!",
"界語?",
["abcd!", "界cd!", "界cd!", "界語!", "界語?"],
),
(
"界語!",
"abcd?",
["界語!", "ab語!", "ab語!", "abcd!", "abcd?"],
),
] {
let (mut titles, mut videos) = seeded(old, target, now);
for (index, expected) in frames.into_iter().enumerate() {
titles.update(&mut videos, 0..1, now + STEP * (index as u32 + 1));
assert_eq!(titles.title(&videos[0]), expected);
if index == 0 {
titles.update(&mut videos, 0..1, now + Duration::from_millis(39));
assert_eq!(titles.title(&videos[0]), old);
}
}
assert!(!titles.active());
}
}
#[test]
fn nonmatching_boundaries_replace_the_smallest_whole_group_without_blanks() {
let now = Instant::now();
for (old, target, expected) in [("a界b!", "語cd?", "語cb!"), ("語cd!", "a界b?", "a界d!")]
{
let (mut titles, mut videos) = seeded(old, target, now);
for step in 1..3 {
titles.update(&mut videos, 0..1, now + STEP * step);
assert_eq!(titles.title(&videos[0]), old);
}
titles.update(&mut videos, 0..1, now + STEP * 3);
assert_eq!(titles.title(&videos[0]), expected);
assert_eq!(Span::raw(expected).width(), Span::raw(old).width());
titles.update(&mut videos, 0..1, now + STEP * 5);
assert_eq!(titles.title(&videos[0]), target);
assert!(!titles.active());
}
}
#[test]
fn wide_shorter_and_longer_tails_use_columns_and_drop_only_final_padding() {
let now = Instant::now();
for (old, target, frames) in [
("界語!", "X", ["界語!", "X 語!", "X 語!", "X !", "X"]),
("X", "界語!", ["X", "界", "界", "界語", "界語!"]),
] {
let (mut titles, mut videos) = seeded(old, target, now);
for (index, expected) in frames.into_iter().enumerate() {
titles.update(&mut videos, 0..1, now + STEP * (index as u32 + 1));
assert_eq!(titles.title(&videos[0]), expected);
}
assert!(!titles.active());
}
}
#[test]
fn skipped_frames_apply_one_prefix_without_reallocating() {
let now = Instant::now();
let mut animation = Animation::new(ID.into(), "a界b!".into(), "語cd?".into(), now);
let capacity = animation.display.capacity();
assert!(animation.advance(now + STEP * 4));
assert_eq!(animation.display, "語cd!");
assert_eq!(animation.display.capacity(), capacity);
assert!(animation.advance(now + STEP * 4));
assert_eq!(animation.display, "語cd!");
assert!(!animation.advance(now + STEP * 5));
}
#[test]
fn zero_width_graphemes_stay_with_their_column_group() {
let now = Instant::now();
let (mut titles, mut videos) = seeded("a\u{200b}b!", "X\u{200b}Y?", now);
titles.update(&mut videos, 0..1, now + STEP);
assert_eq!(titles.title(&videos[0]), "X\u{200b}b!");
titles.update(&mut videos, 0..1, now + STEP * 2);
assert_eq!(titles.title(&videos[0]), "X\u{200b}Y!");
titles.update(&mut videos, 0..1, now + STEP * 3);
assert_eq!(titles.title(&videos[0]), "X\u{200b}Y?");
assert!(!titles.active());
}
#[test]
fn offscreen_completion_and_scrolling_finish_immediately() {
let now = Instant::now();