diff --git a/README.md b/README.md index 3885a4a..490cfc5 100644 --- a/README.md +++ b/README.md @@ -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. 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: diff --git a/src/titles.rs b/src/titles.rs index 2123fb7..0584c58 100644 --- a/src/titles.rs +++ b/src/titles.rs @@ -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, } +struct Replacement { + columns: usize, + old_end: usize, + target_end: usize, +} + struct Animation { id: String, target: String, + target_len: usize, display: String, - old_widths: Vec, - target_offsets: Vec, + replacements: Vec, 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();