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
+1 -1
View File
@@ -46,7 +46,7 @@ After selection, the browser starts in **normal mode**. Press `i` to type a sear
Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. Entries use two lines: the title above dimmed metadata. YouTube shows the channel and publication date beneath each video title, without separate channel/date columns; tagged channels remain cyan. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `✓` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and title/episode metadata. Both providers use the same layout: a cyan view heading, bordered search and results, a bordered status line, and a dim one-line key hint bar. Entries use two lines: the title above dimmed metadata. YouTube shows the channel and publication date beneath each video title, without separate channel/date columns; tagged channels remain cyan. The selected row uses black text on cyan with a `> ` marker; saved titles are green, watch-later titles magenta, and `✓` marks watched entries. Metadata and help open in centered cyan-bordered popups; bulk-removal confirmations use yellow borders. Miruro keeps its catalog navigation and title/episode metadata.
YouTube's dimmed metadata row includes duration as `M:SS` or `H:MM:SS`, or `Unknown duration` when unavailable. Duration is retained in saved collections; older entries without duration remain readable. YouTube's dimmed metadata row includes duration as `M:SS` or `H:MM:SS`, or `Unknown duration` when unavailable. Duration is retained in saved collections; older entries without duration remain readable.
YouTube keeps fast flat listings, then resolves visible videos' original-preferred titles asynchronously, one metadata request at a time. Titles replace the listing text left to right, one user-perceived character about every 20 ms; shorter titles erase the old suffix with spaces before dropping that padding. Offscreen changes apply immediately, and selection stays on the same video. Results are cached by video ID for the session; failures keep the listing title without automatic retries. This adds background yt-dlp requests, and opening video info can make a separate metadata request. “Original-preferred” uses the full extractor response, not a guarantee that YouTube will never return localized text. YouTube keeps fast flat listings, then resolves visible videos' original-preferred titles asynchronously, one metadata request at a time. Titles replace the listing text left to right at 20 ms per terminal column, using the smallest groups of whole user-perceived characters with matching column widths. Two narrow characters and one wide character therefore swap together after 40 ms, without shifting the untouched suffix or inserting temporary blanks. Only an exhausted shorter title is padded with trailing spaces; that padding disappears when the animation finishes. Offscreen changes apply immediately, and selection stays on the same video. Results are cached by video ID for the session; failures keep the listing title without automatic retries. This adds background yt-dlp requests, and opening video info can make a separate metadata request. “Original-preferred” uses the full extractor response, not a guarantee that YouTube will never return localized text.
Selecting a YouTube video launches: Selecting a YouTube video launches:
+184 -37
View File
@@ -4,6 +4,7 @@ use std::{
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use ratatui::text::Span;
use serde::Deserialize; use serde::Deserialize;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
@@ -18,30 +19,82 @@ struct CachedTitle {
title: Option<String>, title: Option<String>,
} }
struct Replacement {
columns: usize,
old_end: usize,
target_end: usize,
}
struct Animation { struct Animation {
id: String, id: String,
target: String, target: String,
target_len: usize,
display: String, display: String,
old_widths: Vec<usize>, replacements: Vec<Replacement>,
target_offsets: Vec<usize>,
replaced: usize, replaced: usize,
started: Instant, started: Instant,
} }
impl Animation { impl Animation {
fn new(id: String, old: String, target: String, now: Instant) -> Self { fn new(id: String, mut old: String, mut target: String, now: Instant) -> Self {
let old_widths = old.graphemes(true).map(str::len).collect(); // Use the same grapheme widths as Ratatui, keeping byte offsets only for splicing.
let mut target_offsets: Vec<_> = target let boundaries = |text: &str| {
.grapheme_indices(true) let mut columns = 0;
.map(|(index, _)| index) let mut ends: Vec<(usize, usize)> = Vec::new();
.collect(); for (offset, grapheme) in text.grapheme_indices(true) {
target_offsets.push(target.len()); 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 { Self {
id, id,
target, target,
target_len,
display: old, display: old,
old_widths, replacements,
target_offsets,
replaced: 0, replaced: 0,
started: now, started: now,
} }
@@ -49,26 +102,30 @@ impl Animation {
// Return false at the final step: the caller then displays the unpadded canonical title. // Return false at the final step: the caller then displays the unpadded canonical title.
fn advance(&mut self, now: Instant) -> bool { fn advance(&mut self, now: Instant) -> bool {
let target_len = self.target_offsets.len() - 1; let columns = now.saturating_duration_since(self.started).as_nanos() / STEP.as_nanos();
let total = self.old_widths.len().max(target_len); if self
let steps = (now.saturating_duration_since(self.started).as_nanos() / STEP.as_nanos()) .replacements
.min(total as u128) as usize; .last()
if steps == total { .is_none_or(|step| columns >= step.columns as u128)
{
return false; return false;
} }
for index in self.replaced..steps { let mut next = self.replaced;
let (cursor, replacement) = if index < target_len { while next < self.replacements.len() && self.replacements[next].columns as u128 <= columns {
let start = self.target_offsets[index]; next += 1;
let end = self.target_offsets[index + 1]; }
(start, &self.target[start..end]) if next != self.replaced {
} else { let (cursor, old_start) = self.replaced.checked_sub(1).map_or((0, 0), |index| {
(self.target.len() + index - target_len, " ") let previous = &self.replacements[index];
}; (previous.target_end, previous.old_end)
let width = self.old_widths.get(index).copied().unwrap_or(0); });
self.display let replacement = &self.replacements[next - 1];
.replace_range(cursor..cursor + width, replacement); self.display.replace_range(
cursor..cursor + replacement.old_end - old_start,
&self.target[cursor..replacement.target_end],
);
self.replaced = next;
} }
self.replaced = steps;
true true
} }
} }
@@ -117,10 +174,9 @@ impl Titles {
let start = visible.start.min(videos.len()); let start = visible.start.min(videos.len());
let visible = start..visible.end.min(videos.len()).max(start); let visible = start..visible.end.min(videos.len()).max(start);
self.animations.retain_mut(|animation| { self.animations.retain_mut(|animation| {
videos[visible.clone()] videos[visible.clone()].iter().any(|video| {
.iter() video.id == animation.id && video.title == animation.target[..animation.target_len]
.any(|video| video.id == animation.id && video.title == animation.target) }) && animation.advance(now)
&& animation.advance(now)
}); });
if let Some(result) = self.fetch.as_mut().and_then(|(_, job)| job.poll()) { if let Some(result) = self.fetch.as_mut().and_then(|(_, job)| job.poll()) {
@@ -237,7 +293,7 @@ mod tests {
} }
#[test] #[test]
fn shorter_title_blanks_old_graphemes_then_discards_padding() { fn shorter_title_blanks_exhausted_columns_then_discards_padding() {
let now = Instant::now(); let now = Instant::now();
let (mut titles, mut videos) = seeded("abcde", "XY", now); let (mut titles, mut videos) = seeded("abcde", "XY", now);
for (step, expected) in [ for (step, expected) in [
@@ -264,20 +320,111 @@ mod tests {
} }
#[test] #[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 now = Instant::now();
let (mut titles, mut videos) = seeded("a\u{301}👨‍👩‍👧‍👦🇩🇰!", "e\u{301}👩🏽‍💻🇯🇵?", now); let (mut titles, mut videos) = seeded("a\u{301}👨‍👩‍👧‍👦🇩🇰!", "e\u{301}👩🏽‍💻🇯🇵?", now);
for (step, expected) in [ for (step, expected) in [
(1, "e\u{301}👨‍👩‍👧‍👦🇩🇰!"), (1, "e\u{301}👨‍👩‍👧‍👦🇩🇰!"),
(2, "e\u{301}👩🏽‍💻🇩🇰!"), (2, "e\u{301}👨‍👩‍👧‍👦🇩🇰!"),
(3, "e\u{301}👩🏽‍💻🇯🇵!"), (3, "e\u{301}👩🏽‍💻🇩🇰!"),
(4, "e\u{301}👩🏽‍💻🇯🇵?"), (4, "e\u{301}👩🏽‍💻🇩🇰!"),
(5, "e\u{301}👩🏽‍💻🇯🇵!"),
(6, "e\u{301}👩🏽‍💻🇯🇵?"),
] { ] {
titles.update(&mut videos, 0..1, now + STEP * step); titles.update(&mut videos, 0..1, now + STEP * step);
assert_eq!(titles.title(&videos[0]), expected); 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] #[test]
fn offscreen_completion_and_scrolling_finish_immediately() { fn offscreen_completion_and_scrolling_finish_immediately() {
let now = Instant::now(); let now = Instant::now();