Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c4c387e5c | |||
| a44d95bb71 |
Generated
+1
@@ -1454,6 +1454,7 @@ dependencies = [
|
||||
"time",
|
||||
"toml_edit",
|
||||
"unicode-normalization",
|
||||
"unicode-segmentation",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
toml_edit = "0.25.17"
|
||||
time = "0.3.55"
|
||||
unicode-normalization = "0.1.25"
|
||||
unicode-segmentation = "1.13.3"
|
||||
ureq = { version = "3.4.2", default-features = false, features = ["rustls"] }
|
||||
|
||||
[target."cfg(unix)".dependencies]
|
||||
|
||||
@@ -46,6 +46,8 @@ 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 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:
|
||||
|
||||
```sh
|
||||
|
||||
+29
-2
@@ -122,6 +122,7 @@ mod keys;
|
||||
mod miruro_browser;
|
||||
mod miruro_ui;
|
||||
mod thumbnail;
|
||||
mod titles;
|
||||
mod ui;
|
||||
mod video_state;
|
||||
mod youtube;
|
||||
@@ -939,6 +940,7 @@ struct App {
|
||||
preview: Option<PreviewMode>,
|
||||
help_popup: Option<HelpPopup>,
|
||||
thumbnail: thumbnail::Thumbnail,
|
||||
titles: titles::Titles,
|
||||
description_job: Option<(String, Job)>,
|
||||
description_error: Option<String>,
|
||||
// ponytail: 64-entry linear lookup; use a keyed LRU if the buffer grows.
|
||||
@@ -998,6 +1000,7 @@ impl App {
|
||||
preview: None,
|
||||
help_popup: None,
|
||||
thumbnail: thumbnail::Thumbnail::new(),
|
||||
titles: titles::Titles::default(),
|
||||
description_job: None,
|
||||
description_error: None,
|
||||
descriptions: std::collections::VecDeque::with_capacity(DESCRIPTION_CACHE_SIZE),
|
||||
@@ -2926,6 +2929,10 @@ impl App {
|
||||
}
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
|
||||
self.draw_at(frame, elapsed, Instant::now());
|
||||
}
|
||||
|
||||
fn draw_at(&mut self, frame: &mut Frame, elapsed: Duration, now: Instant) {
|
||||
let spinner = ui::spinner(elapsed);
|
||||
let area = frame.area();
|
||||
let mut tab_lines = vec![Line::default()];
|
||||
@@ -2979,6 +2986,25 @@ impl App {
|
||||
self.mode == Mode::Insert && self.category_dialog.is_none(),
|
||||
);
|
||||
let view = &mut self.views[self.view_index];
|
||||
let rows = usize::from(results.height.saturating_sub(2) / 2);
|
||||
let mut offset = view
|
||||
.selection
|
||||
.offset()
|
||||
.min(view.videos.len().saturating_sub(rows));
|
||||
if let Some(selected) = view
|
||||
.selection
|
||||
.selected()
|
||||
.filter(|&index| index < view.videos.len())
|
||||
{
|
||||
if selected < offset {
|
||||
offset = selected;
|
||||
} else if rows > 0 && selected >= offset + rows {
|
||||
offset = selected + 1 - rows;
|
||||
}
|
||||
}
|
||||
*view.selection.offset_mut() = offset;
|
||||
self.titles
|
||||
.update(&mut view.videos, offset..offset.saturating_add(rows), now);
|
||||
let table = Table::new(
|
||||
view.videos.iter().map(|video| {
|
||||
let tagged = self
|
||||
@@ -2986,7 +3012,7 @@ impl App {
|
||||
.categories
|
||||
.values()
|
||||
.any(|channels| video_in_channels(video, channels, &self.config));
|
||||
let title = Span::raw(video.title.as_str());
|
||||
let title = Span::raw(self.titles.title(video));
|
||||
let channel = Span::raw(video.channel.as_deref().unwrap_or("Unknown"));
|
||||
Row::new([
|
||||
Cell::from(if self.video_state.is_watched(&video.id) {
|
||||
@@ -3555,7 +3581,8 @@ impl App {
|
||||
loop {
|
||||
self.update();
|
||||
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
let refresh = Duration::from_millis(if self.titles.active() { 20 } else { 100 });
|
||||
if event::poll(refresh)? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
if self.key(key) {
|
||||
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
ops::Range,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ratatui::text::Span;
|
||||
use serde::Deserialize;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::youtube::{self, Job, Video};
|
||||
|
||||
const CACHE_SIZE: usize = 256;
|
||||
const STEP: Duration = Duration::from_millis(20);
|
||||
|
||||
struct CachedTitle {
|
||||
id: String,
|
||||
// Cache failures too, so a failed extractor is not restarted every frame.
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
struct Replacement {
|
||||
columns: usize,
|
||||
old_end: usize,
|
||||
target_end: usize,
|
||||
}
|
||||
|
||||
struct Animation {
|
||||
id: String,
|
||||
target: String,
|
||||
target_len: usize,
|
||||
display: String,
|
||||
replacements: Vec<Replacement>,
|
||||
replaced: usize,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl Animation {
|
||||
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,
|
||||
replacements,
|
||||
replaced: 0,
|
||||
started: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Return false at the final step: the caller then displays the unpadded canonical title.
|
||||
fn advance(&mut self, now: Instant) -> bool {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn full_title(output: &str, expected_id: &str) -> Option<String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Details {
|
||||
id: String,
|
||||
title: String,
|
||||
}
|
||||
let mut details: Details = serde_json::from_str(output).ok()?;
|
||||
if !youtube::valid_video_id(expected_id) || details.id != expected_id {
|
||||
return None;
|
||||
}
|
||||
// Match listing extraction's terminal-safety rules, then reject unusable metadata.
|
||||
details.title.retain(|character| !character.is_control());
|
||||
(!details.title.trim().is_empty()).then_some(details.title)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Titles {
|
||||
fetch: Option<(String, Job)>,
|
||||
cached: VecDeque<CachedTitle>,
|
||||
animations: Vec<Animation>,
|
||||
}
|
||||
|
||||
impl Titles {
|
||||
/// Poll metadata, apply canonical titles, and animate only the current visible rows.
|
||||
pub fn update(&mut self, videos: &mut [Video], visible: Range<usize>, now: Instant) {
|
||||
#[cfg(not(test))]
|
||||
self.update_with(videos, visible, now, Job::description);
|
||||
// Ordinary app render tests must never launch an external network extractor.
|
||||
#[cfg(test)]
|
||||
self.update_with(videos, visible, now, |_| {
|
||||
Err(std::io::ErrorKind::NotFound.into())
|
||||
});
|
||||
}
|
||||
|
||||
fn update_with(
|
||||
&mut self,
|
||||
videos: &mut [Video],
|
||||
visible: Range<usize>,
|
||||
now: Instant,
|
||||
mut start_job: impl FnMut(&str) -> std::io::Result<Job>,
|
||||
) {
|
||||
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.target_len]
|
||||
}) && animation.advance(now)
|
||||
});
|
||||
|
||||
if let Some(result) = self.fetch.as_mut().and_then(|(_, job)| job.poll()) {
|
||||
let (id, _) = self.fetch.take().unwrap();
|
||||
self.finish(id, result);
|
||||
}
|
||||
|
||||
for (index, video) in videos.iter_mut().enumerate() {
|
||||
let Some(title) = self
|
||||
.cached
|
||||
.iter()
|
||||
.find(|entry| entry.id == video.id)
|
||||
.and_then(|entry| entry.title.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if video.title == *title {
|
||||
continue;
|
||||
}
|
||||
let old = std::mem::replace(&mut video.title, title.clone());
|
||||
if visible.contains(&index) {
|
||||
self.animations.retain(|animation| animation.id != video.id);
|
||||
self.animations
|
||||
.push(Animation::new(video.id.clone(), old, title.clone(), now));
|
||||
}
|
||||
}
|
||||
|
||||
if self.fetch.is_none()
|
||||
&& let Some(video) = videos[visible.clone()].iter().find(|video| {
|
||||
youtube::valid_video_id(&video.id)
|
||||
&& !self.cached.iter().any(|entry| entry.id == video.id)
|
||||
})
|
||||
{
|
||||
match start_job(&video.id) {
|
||||
Ok(job) => self.fetch = Some((video.id.clone(), job)),
|
||||
Err(error) => self.finish(video.id.clone(), Err(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Keep visible successes and failures pinned; even a very tall viewport cannot thrash.
|
||||
let capacity = CACHE_SIZE.max(visible.len());
|
||||
while self.cached.len() > capacity {
|
||||
let index = self
|
||||
.cached
|
||||
.iter()
|
||||
.position(|entry| {
|
||||
!videos[visible.clone()]
|
||||
.iter()
|
||||
.any(|video| video.id == entry.id)
|
||||
})
|
||||
.unwrap();
|
||||
self.cached.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, id: String, output: Result<String, String>) {
|
||||
let title = output.ok().and_then(|output| full_title(&output, &id));
|
||||
self.cached.push_back(CachedTitle { id, title });
|
||||
}
|
||||
|
||||
pub fn title<'a>(&'a self, video: &'a Video) -> &'a str {
|
||||
self.animations
|
||||
.iter()
|
||||
.find(|animation| animation.id == video.id)
|
||||
.map_or(&video.title, |animation| animation.display.as_str())
|
||||
}
|
||||
|
||||
/// Pending extraction or visible animation requires another event-loop tick.
|
||||
pub fn active(&self) -> bool {
|
||||
self.fetch.is_some() || !self.animations.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ID: &str = "abcdefghijk";
|
||||
const OTHER_ID: &str = "lmnopqrstuv";
|
||||
|
||||
fn video(id: &str, title: &str) -> Video {
|
||||
serde_json::from_value(serde_json::json!({ "id": id, "title": title })).unwrap()
|
||||
}
|
||||
|
||||
fn metadata(id: &str, title: &str) -> String {
|
||||
serde_json::json!({ "id": id, "title": title }).to_string()
|
||||
}
|
||||
|
||||
fn seeded(old: &str, target: &str, now: Instant) -> (Titles, Vec<Video>) {
|
||||
let mut titles = Titles::default();
|
||||
titles.finish(ID.into(), Ok(metadata(ID, target)));
|
||||
let mut videos = vec![video(ID, old)];
|
||||
titles.update(&mut videos, 0..1, now);
|
||||
(titles, videos)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transition_uses_injected_time_and_updates_canonical_title_immediately() {
|
||||
let now = Instant::now();
|
||||
let (mut titles, mut videos) = seeded("abcd", "WXYZ", now);
|
||||
assert_eq!(videos[0].title, "WXYZ");
|
||||
assert_eq!(videos[0].id, ID);
|
||||
assert_eq!(titles.title(&videos[0]), "abcd");
|
||||
titles.update(&mut videos, 0..1, now + Duration::from_millis(19));
|
||||
assert_eq!(titles.title(&videos[0]), "abcd");
|
||||
titles.update(&mut videos, 0..1, now + STEP);
|
||||
assert_eq!(titles.title(&videos[0]), "Wbcd");
|
||||
titles.update(&mut videos, 0..1, now + STEP * 3);
|
||||
assert_eq!(titles.title(&videos[0]), "WXYd");
|
||||
assert!(titles.active());
|
||||
titles.update(&mut videos, 0..1, now + STEP * 4);
|
||||
assert_eq!(titles.title(&videos[0]), "WXYZ");
|
||||
assert!(!titles.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 [
|
||||
(1, "Xbcde"),
|
||||
(2, "XYcde"),
|
||||
(3, "XY de"),
|
||||
(4, "XY e"),
|
||||
(5, "XY"),
|
||||
] {
|
||||
titles.update(&mut videos, 0..1, now + STEP * step);
|
||||
assert_eq!(titles.title(&videos[0]), expected);
|
||||
}
|
||||
assert!(!titles.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_title_appends_one_grapheme_per_step() {
|
||||
let now = Instant::now();
|
||||
let (mut titles, mut videos) = seeded("ab", "WXYZ", now);
|
||||
for (step, expected) in [(1, "Wb"), (2, "WX"), (3, "WXY"), (4, "WXYZ")] {
|
||||
titles.update(&mut videos, 0..1, now + STEP * step);
|
||||
assert_eq!(titles.title(&videos[0]), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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}👩🏽💻🇩🇰!"),
|
||||
(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();
|
||||
let (mut titles, mut videos) = seeded("old", "Original", now);
|
||||
titles.update(&mut videos, 0..0, now + STEP);
|
||||
assert_eq!(titles.title(&videos[0]), "Original");
|
||||
assert!(!titles.active());
|
||||
titles.finish(OTHER_ID.into(), Ok(metadata(OTHER_ID, "Offscreen")));
|
||||
videos.push(video(OTHER_ID, "flat"));
|
||||
titles.update(&mut videos, 0..1, now + STEP * 2);
|
||||
assert_eq!(videos[1].title, "Offscreen");
|
||||
assert_eq!(titles.title(&videos[1]), "Offscreen");
|
||||
titles.update(&mut videos, 1..2, now + STEP * 3);
|
||||
assert!(!titles.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_metadata_and_extractor_errors_preserve_flat_title_without_retries() {
|
||||
let now = Instant::now();
|
||||
for output in [
|
||||
Err("extractor failed".into()),
|
||||
Ok("not json".into()),
|
||||
Ok(metadata(OTHER_ID, "Wrong video")),
|
||||
Ok(metadata(ID, "\u{1b}\n\t")),
|
||||
Ok(metadata(ID, " ")),
|
||||
Ok(serde_json::json!({"id": ID}).to_string()),
|
||||
] {
|
||||
let mut titles = Titles::default();
|
||||
titles.finish(ID.into(), output);
|
||||
let mut videos = vec![video(ID, "Keep this")];
|
||||
titles.update(&mut videos, 0..1, now);
|
||||
titles.update(&mut videos, 0..1, now + STEP * 100);
|
||||
assert_eq!(videos[0].title, "Keep this");
|
||||
assert_eq!(titles.title(&videos[0]), "Keep this");
|
||||
assert!(!titles.active());
|
||||
}
|
||||
assert_eq!(
|
||||
full_title(&metadata(ID, "Safe\n\u{1b} title"), ID).as_deref(),
|
||||
Some("Safe title")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_is_bounded_and_visible_failures_are_not_evicted() {
|
||||
let now = Instant::now();
|
||||
let mut titles = Titles::default();
|
||||
titles.finish(ID.into(), Err("failed".into()));
|
||||
for index in 0..CACHE_SIZE {
|
||||
titles.finish(format!("{index:011}"), Err("failed".into()));
|
||||
}
|
||||
let mut videos = vec![video(ID, "Keep this")];
|
||||
titles.update(&mut videos, 0..1, now);
|
||||
assert_eq!(titles.cached.len(), CACHE_SIZE);
|
||||
assert!(titles.cached.iter().any(|entry| entry.id == ID));
|
||||
assert!(!titles.active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ids_and_empty_or_out_of_bounds_viewports_never_fetch() {
|
||||
let now = Instant::now();
|
||||
let mut titles = Titles::default();
|
||||
let mut videos = vec![video("not/youtube", "Local")];
|
||||
titles.update(&mut videos, 0..1, now);
|
||||
titles.update(&mut videos, 20..30, now);
|
||||
titles.update(&mut videos, 1..0, now);
|
||||
assert!(!titles.active());
|
||||
assert_eq!(titles.title(&videos[0]), "Local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduling_only_attempts_visible_valid_uncached_ids_once_after_failure() {
|
||||
let now = Instant::now();
|
||||
let mut titles = Titles::default();
|
||||
let mut videos = vec![
|
||||
video(ID, "Offscreen"),
|
||||
video("not/youtube", "Local"),
|
||||
video(OTHER_ID, "Visible"),
|
||||
];
|
||||
let mut requested = Vec::new();
|
||||
for step in 0..3 {
|
||||
titles.update_with(&mut videos, 1..3, now + STEP * step, |id| {
|
||||
requested.push(id.to_owned());
|
||||
Err(std::io::ErrorKind::NotFound.into())
|
||||
});
|
||||
}
|
||||
assert_eq!(requested, vec![OTHER_ID.to_owned()]);
|
||||
assert_eq!(videos[2].title, "Visible");
|
||||
assert!(!titles.active());
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,77 @@ fn app(args: &[&str]) -> App {
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
|
||||
let mut app = app(&["tubular"]);
|
||||
let template =
|
||||
youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Listing title"}]}"#)
|
||||
.unwrap()
|
||||
.videos
|
||||
.remove(0);
|
||||
app.views[0].videos = (0..12)
|
||||
.map(|index| {
|
||||
let mut video = template.clone();
|
||||
// Non-YouTube IDs keep this rendering check entirely offline.
|
||||
video.id = format!("offline:{index}");
|
||||
video.title = format!("Row {index:02}");
|
||||
video
|
||||
})
|
||||
.collect();
|
||||
app.views[0].selection.select(Some(9));
|
||||
let now = Instant::now();
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| app.draw_at(frame, Duration::ZERO, now))
|
||||
.unwrap();
|
||||
assert_eq!(app.views[0].selection.offset(), 3);
|
||||
assert_eq!(
|
||||
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
|
||||
"offline:9"
|
||||
);
|
||||
assert!(!app.titles.active());
|
||||
|
||||
terminal.backend_mut().resize(80, 17);
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
app.draw_at(
|
||||
frame,
|
||||
Duration::from_millis(20),
|
||||
now + Duration::from_millis(20),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(app.views[0].selection.offset(), 7);
|
||||
assert_eq!(
|
||||
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
|
||||
"offline:9"
|
||||
);
|
||||
let rendered: String = terminal
|
||||
.backend()
|
||||
.buffer()
|
||||
.content
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect();
|
||||
assert!(rendered.contains("Row 09"));
|
||||
|
||||
app.views[0].selection.select(Some(0));
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
app.draw_at(
|
||||
frame,
|
||||
Duration::from_millis(40),
|
||||
now + Duration::from_millis(40),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(app.views[0].selection.offset(), 0);
|
||||
assert_eq!(
|
||||
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
|
||||
"offline:0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn youtube_collections_isolate_mixed_provider_rows_and_removal_ids() {
|
||||
let mut app = app(&["tubular"]);
|
||||
|
||||
Reference in New Issue
Block a user