fix: match title animation groups by terminal width
This commit is contained in:
+184
-37
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user