feat: show Miruro series artwork in descriptions
This commit is contained in:
+8
-63
@@ -3086,23 +3086,6 @@ impl App {
|
||||
.as_deref()
|
||||
.unwrap_or("Loading full description..."),
|
||||
};
|
||||
let mut details = if mode == PreviewMode::Description {
|
||||
inner.inner(ratatui::layout::Margin::new(1, 0))
|
||||
} else {
|
||||
let [_, image, _, mut details] = Layout::horizontal([
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(3),
|
||||
Constraint::Length(2),
|
||||
Constraint::Fill(2),
|
||||
])
|
||||
.areas(inner);
|
||||
let image_width = self.thumbnail.draw(frame, image);
|
||||
let reclaimed = image.width - image_width;
|
||||
details.x -= reclaimed;
|
||||
details.width += reclaimed;
|
||||
details.width = details.width.saturating_sub(2);
|
||||
details
|
||||
};
|
||||
let metadata = [
|
||||
Line::raw(video.title.as_str()).bold(),
|
||||
Line::default(),
|
||||
@@ -3118,6 +3101,7 @@ impl App {
|
||||
.dim(),
|
||||
];
|
||||
if mode == PreviewMode::Description {
|
||||
let details = inner.inner(ratatui::layout::Margin::new(1, 0));
|
||||
let mut content = Text::from(text);
|
||||
content.lines.splice(
|
||||
0..0,
|
||||
@@ -3142,52 +3126,13 @@ impl App {
|
||||
self.description_scroll = self.description_scroll.min(self.description_max_scroll);
|
||||
frame.render_widget(paragraph.scroll((self.description_scroll, 0)), details);
|
||||
} else {
|
||||
let metadata = Paragraph::new(Vec::from(metadata)).wrap(Wrap { trim: true });
|
||||
let height = if details.is_empty() {
|
||||
0
|
||||
} else {
|
||||
metadata
|
||||
.line_count(details.width)
|
||||
.min(details.height as usize) as u16
|
||||
};
|
||||
let mut excerpt = details;
|
||||
let gap = (excerpt.height - height).min(1);
|
||||
excerpt.y += height + gap;
|
||||
excerpt.height -= height + gap;
|
||||
details.height = height;
|
||||
frame.render_widget(metadata, details);
|
||||
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
|
||||
let overflow = cached.is_some_and(|text| !text.is_empty())
|
||||
&& !excerpt.is_empty()
|
||||
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
|
||||
frame.render_widget(paragraph, excerpt);
|
||||
if overflow {
|
||||
let buffer = frame.buffer_mut();
|
||||
let y = excerpt.bottom() - 1;
|
||||
let end = (excerpt.x..excerpt.right())
|
||||
.rev()
|
||||
.find_map(|x| {
|
||||
let symbol = buffer
|
||||
.cell((x, y))
|
||||
.expect("excerpt is inside frame")
|
||||
.symbol();
|
||||
(!symbol.trim().is_empty())
|
||||
.then(|| x.saturating_add(Span::raw(symbol).width() as u16))
|
||||
})
|
||||
.unwrap_or(excerpt.x);
|
||||
let mut x = end.min(excerpt.right() - 1);
|
||||
// Do not leave half of a wide character beneath the ellipsis.
|
||||
if x > excerpt.x
|
||||
&& Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1
|
||||
{
|
||||
x -= 1;
|
||||
}
|
||||
buffer
|
||||
.cell_mut((x, y))
|
||||
.unwrap()
|
||||
.set_symbol("…")
|
||||
.set_style(Style::new());
|
||||
}
|
||||
ui::info_preview(
|
||||
frame,
|
||||
inner,
|
||||
Some(&mut self.thumbnail),
|
||||
Vec::from(metadata),
|
||||
text,
|
||||
);
|
||||
}
|
||||
frame.render_widget(
|
||||
block.title(loading_title(
|
||||
|
||||
+155
-61
@@ -24,9 +24,10 @@ use serde_json::Value;
|
||||
use tubular::miruro::Identity;
|
||||
|
||||
use crate::{
|
||||
Options, config,
|
||||
Options, PreviewMode, config,
|
||||
keys::{Action, Context},
|
||||
miruro_browser::{self, Episode, Media, Request, Response, Worker},
|
||||
thumbnail::Thumbnail,
|
||||
ui,
|
||||
video_state::{CollectionKind, State, WatchStatus},
|
||||
youtube::{PlayerLaunch, Video},
|
||||
@@ -130,14 +131,14 @@ impl Screen {
|
||||
Some(self.selection.selected().unwrap_or(0).min(self.len() - 1))
|
||||
});
|
||||
}
|
||||
fn selected_media(&self) -> Option<Media> {
|
||||
fn selected_media(&self) -> Option<&Media> {
|
||||
let index = self.selection.selected();
|
||||
match &self.content {
|
||||
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)).cloned(),
|
||||
Content::Series { media, .. } => Some(media.clone()),
|
||||
Content::Library { shows, .. } => index
|
||||
.and_then(|i| shows.get(i))
|
||||
.map(|show| show.media.clone()),
|
||||
Content::Catalog { media, .. } => index.and_then(|i| media.get(i)),
|
||||
Content::Series { media, .. } => Some(media),
|
||||
Content::Library { shows, .. } => {
|
||||
index.and_then(|i| shows.get(i)).map(|show| &show.media)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -301,7 +302,8 @@ struct Browser {
|
||||
screens: Vec<Screen>,
|
||||
index: usize,
|
||||
input: Option<String>,
|
||||
description_focus: bool,
|
||||
preview: Option<PreviewMode>,
|
||||
thumbnail: Thumbnail,
|
||||
help: Option<u16>,
|
||||
removal: Option<(CollectionKind, Vec<String>)>,
|
||||
modal: Option<LibraryModal>,
|
||||
@@ -394,7 +396,8 @@ impl Browser {
|
||||
})],
|
||||
index: 0,
|
||||
input: None,
|
||||
description_focus: false,
|
||||
preview: None,
|
||||
thumbnail: Thumbnail::new(),
|
||||
help: None,
|
||||
removal: None,
|
||||
modal: None,
|
||||
@@ -433,7 +436,7 @@ impl Browser {
|
||||
self.screens.truncate(self.index + 1);
|
||||
self.screens.push(screen);
|
||||
self.index += 1;
|
||||
self.description_focus = false;
|
||||
self.preview = None;
|
||||
self.status.clear();
|
||||
}
|
||||
fn history(&mut self, forward: bool) {
|
||||
@@ -447,7 +450,7 @@ impl Browser {
|
||||
}
|
||||
self.invalidate();
|
||||
self.index = index;
|
||||
self.description_focus = false;
|
||||
self.preview = None;
|
||||
self.status.clear();
|
||||
self.rebuild_collections();
|
||||
if matches!(&self.screen().content, Content::Series { data: None, .. }) {
|
||||
@@ -469,7 +472,7 @@ impl Browser {
|
||||
if let Content::Catalog { query, .. } = &self.screen().content {
|
||||
self.input = Some(query.clone());
|
||||
}
|
||||
self.description_focus = false;
|
||||
self.preview = None;
|
||||
}
|
||||
fn search(&mut self) {
|
||||
let Content::Catalog { query, .. } = &self.screen().content else {
|
||||
@@ -517,7 +520,7 @@ impl Browser {
|
||||
}
|
||||
}
|
||||
fn open(&mut self) {
|
||||
if let Some(media) = self.screen().selected_media()
|
||||
if let Some(media) = self.screen().selected_media().cloned()
|
||||
&& !matches!(self.screen().content, Content::Series { .. })
|
||||
{
|
||||
self.push(Screen::new(Content::Series { media, data: None }));
|
||||
@@ -671,7 +674,7 @@ impl Browser {
|
||||
.collect()
|
||||
}
|
||||
fn tags(&mut self) {
|
||||
let Some(media) = self.screen().selected_media() else {
|
||||
let Some(media) = self.screen().selected_media().cloned() else {
|
||||
return;
|
||||
};
|
||||
let checked = self
|
||||
@@ -684,7 +687,7 @@ impl Browser {
|
||||
let names: Vec<_> = self.categories().into_iter().collect();
|
||||
let mut picker = Picker::default();
|
||||
picker.refilter(&names);
|
||||
self.description_focus = false;
|
||||
self.preview = None;
|
||||
self.modal = Some(LibraryModal::Tags {
|
||||
media,
|
||||
checked,
|
||||
@@ -699,7 +702,7 @@ impl Browser {
|
||||
names.extend(self.categories());
|
||||
let mut picker = Picker::default();
|
||||
picker.refilter(&names);
|
||||
self.description_focus = false;
|
||||
self.preview = None;
|
||||
self.modal = Some(LibraryModal::Categories {
|
||||
names,
|
||||
checked: BTreeSet::new(),
|
||||
@@ -946,7 +949,8 @@ impl Browser {
|
||||
fn remove(&mut self, bulk: bool) {
|
||||
if matches!(self.screen().content, Content::Library { .. }) {
|
||||
if !bulk && let Some(media) = self.screen().selected_media() {
|
||||
match self.state.remove_show(&media.id) {
|
||||
let id = media.id.clone();
|
||||
match self.state.remove_show(&id) {
|
||||
Ok(()) => {
|
||||
self.rebuild_library();
|
||||
self.status = "Removed saved show; episode state unchanged.".into();
|
||||
@@ -974,7 +978,7 @@ impl Browser {
|
||||
self.invalidate();
|
||||
if bulk {
|
||||
self.removal = Some((kind, ids));
|
||||
self.description_focus = false;
|
||||
self.preview = None;
|
||||
} else {
|
||||
self.remove_ids(kind, ids);
|
||||
}
|
||||
@@ -1000,7 +1004,10 @@ impl Browser {
|
||||
fn refresh(&mut self) {
|
||||
self.invalidate();
|
||||
match config::load(&self.config_path) {
|
||||
Ok(config) => self.config = config,
|
||||
Ok(config) => {
|
||||
self.config = config;
|
||||
self.thumbnail.clear_cache();
|
||||
}
|
||||
Err(error) => {
|
||||
self.fail(error);
|
||||
return;
|
||||
@@ -1043,15 +1050,17 @@ impl Browser {
|
||||
self.screen().content,
|
||||
Content::Collection { .. } | Content::Library { .. }
|
||||
) {
|
||||
if self.description_focus {
|
||||
Context::CollectionDescription
|
||||
} else {
|
||||
Context::CollectionNormal
|
||||
match self.preview {
|
||||
Some(PreviewMode::Info) => Context::CollectionInfo,
|
||||
Some(PreviewMode::Description) => Context::CollectionDescription,
|
||||
None => Context::CollectionNormal,
|
||||
}
|
||||
} else if self.description_focus {
|
||||
Context::Description
|
||||
} else {
|
||||
Context::Normal
|
||||
match self.preview {
|
||||
Some(PreviewMode::Info) => Context::Info,
|
||||
Some(PreviewMode::Description) => Context::Description,
|
||||
None => Context::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn select(&mut self, action: Action) {
|
||||
@@ -1176,10 +1185,9 @@ impl Browser {
|
||||
| Action::VideoFirst
|
||||
| Action::VideoLast),
|
||||
) => self.select(action),
|
||||
Some(Action::InfoOverview | Action::InfoDescription) => {
|
||||
self.description_focus = !self.description_focus
|
||||
}
|
||||
Some(Action::InfoHide) => self.description_focus = false,
|
||||
Some(Action::InfoOverview) => self.preview = Some(PreviewMode::Info),
|
||||
Some(Action::InfoDescription) => self.preview = Some(PreviewMode::Description),
|
||||
Some(Action::InfoHide) => self.preview = None,
|
||||
Some(Action::DescriptionNext) => {
|
||||
self.screen_mut().description_scroll =
|
||||
self.screen().description_scroll.saturating_add(1)
|
||||
@@ -1297,7 +1305,10 @@ impl Browser {
|
||||
(Action::WatchLaterBrowse, "Watch later episodes"),
|
||||
(Action::SavedVideoBrowse, "Saved episodes"),
|
||||
(Action::WatchingBrowse, "Currently watching episodes"),
|
||||
(Action::InfoOverview, "Show/hide metadata"),
|
||||
(
|
||||
Action::InfoOverview,
|
||||
"Show series/episode info with cover art",
|
||||
),
|
||||
(Action::Help, "Keyboard help"),
|
||||
(Action::Quit, "Exit"),
|
||||
(Action::Interrupt, "Exit immediately"),
|
||||
@@ -1312,10 +1323,11 @@ impl Browser {
|
||||
"Remove selected saved show only",
|
||||
));
|
||||
}
|
||||
if self.config.keys.label(Action::InfoDescription)
|
||||
!= self.config.keys.label(Action::InfoOverview)
|
||||
if self.preview.is_some()
|
||||
|| self.config.keys.label(Action::InfoDescription)
|
||||
!= self.config.keys.label(Action::InfoOverview)
|
||||
{
|
||||
bindings.push((Action::InfoDescription, "Show/hide metadata"));
|
||||
bindings.push((Action::InfoDescription, "Show full synopsis/metadata"));
|
||||
}
|
||||
if matches!(
|
||||
self.screen().content,
|
||||
@@ -1340,9 +1352,11 @@ impl Browser {
|
||||
bindings.push((Action::WatchLaterFilter, "Cycle All / Unwatched / Watched"));
|
||||
}
|
||||
}
|
||||
if self.description_focus {
|
||||
if self.preview.is_some() {
|
||||
bindings.push((Action::InfoHide, "Close preview"));
|
||||
}
|
||||
if self.preview == Some(PreviewMode::Description) {
|
||||
bindings.extend([
|
||||
(Action::InfoHide, "Close metadata"),
|
||||
(Action::DescriptionNext, "Scroll metadata down"),
|
||||
(Action::DescriptionPrevious, "Scroll metadata up"),
|
||||
(Action::DescriptionPageNext, "Metadata page down"),
|
||||
@@ -1364,11 +1378,12 @@ impl Browser {
|
||||
&& self.help.is_none()
|
||||
&& self.input.is_none()
|
||||
&& self.removal.is_none()
|
||||
&& !self.description_focus
|
||||
&& self.preview.is_none()
|
||||
{
|
||||
let mut actions = vec![
|
||||
(Action::Help, "help"),
|
||||
(Action::SearchEdit, "search"),
|
||||
(Action::InfoOverview, "info"),
|
||||
(
|
||||
Action::VideoPlay,
|
||||
if matches!(
|
||||
@@ -1425,10 +1440,20 @@ impl Browser {
|
||||
(Action::DeleteWord, "delete word"),
|
||||
],
|
||||
)
|
||||
} else if self.description_focus {
|
||||
} else if self.preview == Some(PreviewMode::Info) {
|
||||
(
|
||||
"INFO",
|
||||
&[
|
||||
(Action::InfoDescription, "description"),
|
||||
(Action::InfoHide, "close"),
|
||||
(Action::Help, "help"),
|
||||
],
|
||||
)
|
||||
} else if self.preview == Some(PreviewMode::Description) {
|
||||
(
|
||||
"DESCRIPTION",
|
||||
&[
|
||||
(Action::InfoOverview, "info"),
|
||||
(Action::InfoHide, "close"),
|
||||
(Action::Help, "help"),
|
||||
(Action::WatchStatusWatching, "mark watching"),
|
||||
@@ -1676,29 +1701,92 @@ impl Browser {
|
||||
);
|
||||
frame.render_widget(Paragraph::new(self.hints()).dim(), hints);
|
||||
let popup = crate::preview_area(area);
|
||||
if self.description_focus {
|
||||
let block = Block::bordered()
|
||||
.title(" Description ")
|
||||
.border_style(ui::ACCENT);
|
||||
let inner = block.inner(popup).inner(Margin::new(1, 0));
|
||||
let mut metadata = ratatui::text::Text::from(self.screen().description.as_str());
|
||||
if let Some(key) = self.screen().selected_key() {
|
||||
metadata.lines.push(Line::from(self.markers(key)));
|
||||
if let Some(mode) = self.preview {
|
||||
let cover = self.screens[self.index]
|
||||
.selected_media()
|
||||
.and_then(|media| media.metadata.get("cover_url"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|url| !url.trim().is_empty());
|
||||
if mode == PreviewMode::Info {
|
||||
if let Some(url) = cover {
|
||||
self.thumbnail.select_url(url);
|
||||
} else {
|
||||
self.thumbnail.cancel();
|
||||
}
|
||||
}
|
||||
let paragraph = Paragraph::new(metadata).wrap(Wrap { trim: true });
|
||||
let max_scroll = if inner.is_empty() {
|
||||
0
|
||||
} else {
|
||||
paragraph
|
||||
.line_count(inner.width)
|
||||
.saturating_sub(usize::from(inner.height))
|
||||
.min(u16::MAX as usize) as u16
|
||||
};
|
||||
let scroll = self.screen().description_scroll.min(max_scroll);
|
||||
frame.render_widget(Clear, popup);
|
||||
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
|
||||
let block = Block::bordered()
|
||||
.title(crate::loading_title(
|
||||
if mode == PreviewMode::Info {
|
||||
" Info "
|
||||
} else {
|
||||
" Description "
|
||||
},
|
||||
"",
|
||||
(mode == PreviewMode::Info && self.thumbnail.is_loading())
|
||||
.then(|| ui::spinner(elapsed)),
|
||||
))
|
||||
.border_style(ui::ACCENT);
|
||||
let inner = block.inner(popup);
|
||||
let screen = &self.screens[self.index];
|
||||
let row = screen.selection.selected().and_then(|i| screen.rows.get(i));
|
||||
let mut metadata = Vec::new();
|
||||
let mut text = screen.description.as_str();
|
||||
if let Some(row) = row {
|
||||
metadata.extend([
|
||||
Line::from(row.title.as_str()).bold(),
|
||||
Line::default(),
|
||||
Line::from(row.details.as_str()).dim(),
|
||||
]);
|
||||
text = text
|
||||
.strip_prefix(&row.title)
|
||||
.unwrap_or(text)
|
||||
.trim_start_matches('\n');
|
||||
text = text
|
||||
.strip_prefix(&row.details)
|
||||
.unwrap_or(text)
|
||||
.trim_start_matches('\n');
|
||||
}
|
||||
if let Some(key) = screen.selected_key() {
|
||||
let markers = self.markers(key);
|
||||
if !markers.is_empty() {
|
||||
metadata.push(Line::from(markers).dim());
|
||||
}
|
||||
}
|
||||
if mode == PreviewMode::Info {
|
||||
ui::info_preview(
|
||||
frame,
|
||||
inner,
|
||||
cover.map(|_| &mut self.thumbnail),
|
||||
metadata,
|
||||
text,
|
||||
);
|
||||
} else {
|
||||
let inner = inner.inner(Margin::new(1, 0));
|
||||
let mut content = ratatui::text::Text::from(text);
|
||||
content.lines.splice(
|
||||
0..0,
|
||||
[Line::default()]
|
||||
.into_iter()
|
||||
.chain(metadata)
|
||||
.chain([Line::default()]),
|
||||
);
|
||||
let paragraph = Paragraph::new(content).wrap(Wrap { trim: false });
|
||||
let max_scroll = if inner.is_empty() {
|
||||
0
|
||||
} else {
|
||||
paragraph
|
||||
.line_count(inner.width)
|
||||
.saturating_sub(usize::from(inner.height))
|
||||
.min(u16::MAX as usize) as u16
|
||||
};
|
||||
let scroll = screen.description_scroll.min(max_scroll);
|
||||
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
|
||||
self.screen_mut().description_scroll = scroll;
|
||||
}
|
||||
frame.render_widget(block, popup);
|
||||
self.screen_mut().description_scroll = scroll;
|
||||
} else {
|
||||
self.thumbnail.cancel();
|
||||
}
|
||||
if let Some((kind, ids)) = &self.removal {
|
||||
frame.render_widget(Clear, popup);
|
||||
@@ -2007,6 +2095,7 @@ pub fn run(
|
||||
SetCursorStyle::SteadyBlock
|
||||
)?;
|
||||
let mut browser = Browser::new(options, config_path, config, state);
|
||||
browser.thumbnail.query_terminal();
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
browser.update();
|
||||
@@ -2140,6 +2229,9 @@ mod tests {
|
||||
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Description);
|
||||
terminal
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
@@ -2164,8 +2256,10 @@ mod tests {
|
||||
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||
.unwrap();
|
||||
assert!(popup_text(&terminal).contains("TAIL"));
|
||||
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
||||
assert_eq!(browser.context(), Context::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(!browser.description_focus);
|
||||
assert!(browser.preview.is_none());
|
||||
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
|
||||
browser.screen_mut().description_scroll = 0;
|
||||
}
|
||||
@@ -2290,7 +2384,7 @@ mod tests {
|
||||
.save(CollectionKind::Watching, &youtube)
|
||||
.unwrap();
|
||||
browser.save(CollectionKind::WatchLater);
|
||||
browser.description_focus = true;
|
||||
browser.preview = Some(PreviewMode::Info);
|
||||
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
|
||||
assert!(
|
||||
browser
|
||||
@@ -2298,7 +2392,7 @@ mod tests {
|
||||
.contains(CollectionKind::Watching, &episode.id)
|
||||
);
|
||||
assert!(!browser.state.is_watched(&episode.id));
|
||||
browser.description_focus = false;
|
||||
browser.preview = None;
|
||||
browser.collection(CollectionKind::Watching);
|
||||
browser.open();
|
||||
assert!(browser.pending.is_some());
|
||||
|
||||
+128
-15
@@ -4,7 +4,7 @@ use std::{
|
||||
thread,
|
||||
};
|
||||
|
||||
use image::{ImageFormat, ImageReader, Limits};
|
||||
use image::{ImageReader, Limits};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Rect, Size},
|
||||
@@ -30,6 +30,7 @@ struct ThumbnailImage {
|
||||
pub struct Thumbnail {
|
||||
picker: Picker,
|
||||
selected: Option<String>,
|
||||
aspect: (u32, u32),
|
||||
fetch: Option<ThumbnailJob>,
|
||||
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
|
||||
image: Option<ThumbnailImage>,
|
||||
@@ -43,6 +44,7 @@ impl Thumbnail {
|
||||
Self {
|
||||
picker: Picker::halfblocks(),
|
||||
selected: None,
|
||||
aspect: (4, 3),
|
||||
fetch: None,
|
||||
decoder: None,
|
||||
image: None,
|
||||
@@ -61,6 +63,19 @@ impl Thumbnail {
|
||||
}
|
||||
|
||||
pub fn select(&mut self, id: &str) {
|
||||
self.select_with(id, (4, 3), ThumbnailJob::start);
|
||||
}
|
||||
|
||||
pub fn select_url(&mut self, url: &str) {
|
||||
self.select_with(url, (2, 3), ThumbnailJob::from_url);
|
||||
}
|
||||
|
||||
fn select_with(
|
||||
&mut self,
|
||||
id: &str,
|
||||
aspect: (u32, u32),
|
||||
start: fn(&str) -> std::io::Result<ThumbnailJob>,
|
||||
) {
|
||||
if self.selected.as_deref() == Some(id) {
|
||||
return;
|
||||
}
|
||||
@@ -72,11 +87,12 @@ impl Thumbnail {
|
||||
.and_then(|index| self.cached.remove(index));
|
||||
self.cancel();
|
||||
self.selected = Some(id.to_owned());
|
||||
self.aspect = aspect;
|
||||
if let Some(image) = cached {
|
||||
self.image = Some(image);
|
||||
return;
|
||||
}
|
||||
match ThumbnailJob::start(id) {
|
||||
match start(id) {
|
||||
Ok(job) => self.fetch = Some(job),
|
||||
Err(error) => self.error = Some(format!("Could not load thumbnail: {error}")),
|
||||
}
|
||||
@@ -118,13 +134,17 @@ impl Thumbnail {
|
||||
if area.width == 0 || area.height == 0 || self.selected.is_none() {
|
||||
return area.width;
|
||||
}
|
||||
let slot = left_aligned(area, thumbnail_size(area, self.picker.font_size()));
|
||||
let slot = left_aligned(
|
||||
area,
|
||||
thumbnail_size(area, self.picker.font_size(), self.aspect),
|
||||
);
|
||||
|
||||
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
|
||||
if let Some(image) = self.image.as_mut() {
|
||||
let resize = Resize::Scale(Some(FilterType::Triangle));
|
||||
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
|
||||
let image_area = left_aligned(area, size);
|
||||
let bounds = if self.aspect == (4, 3) { area } else { slot };
|
||||
if let Some(size) = image.protocol.size_for(resize.clone(), bounds.into()) {
|
||||
let image_area = left_aligned(bounds, size);
|
||||
frame.render_stateful_widget(
|
||||
StatefulImage::new().resize(resize),
|
||||
image_area,
|
||||
@@ -158,7 +178,7 @@ impl Thumbnail {
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
let picker = self.picker.clone();
|
||||
let id = self.selected.clone().expect("A fetch has a selected video");
|
||||
let id = self.selected.clone().expect("A fetch has a selected image");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
match thread::Builder::new()
|
||||
.name("thumbnail-decode".into())
|
||||
@@ -243,7 +263,9 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
|
||||
if bytes.len() > 2 * 1024 * 1024 {
|
||||
return Err("Thumbnail exceeds the 2 MiB download limit.".into());
|
||||
}
|
||||
let mut reader = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg);
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|error| format!("Could not identify thumbnail format: {error}"))?;
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(4096);
|
||||
limits.max_image_height = Some(4096);
|
||||
@@ -251,21 +273,28 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
|
||||
reader.limits(limits);
|
||||
reader
|
||||
.decode()
|
||||
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
|
||||
.map_err(|error| format!("Could not decode thumbnail: {error}"))
|
||||
}
|
||||
|
||||
fn thumbnail_size(area: Rect, font: FontSize) -> Size {
|
||||
fn thumbnail_size(area: Rect, font: FontSize, aspect: (u32, u32)) -> Size {
|
||||
if area.width == 0 || area.height == 0 || font.width == 0 || font.height == 0 {
|
||||
return Size::default();
|
||||
}
|
||||
// HQ thumbnails are 480x360. Match Scale's nearest-pixel rounding, then
|
||||
// round up to cells without allocating a placeholder image.
|
||||
// Match Scale's nearest-pixel rounding, then round up to cells without
|
||||
// allocating a placeholder image. YouTube is 4:3; series covers reserve 2:3.
|
||||
let (aspect_width, aspect_height) = (u64::from(aspect.0), u64::from(aspect.1));
|
||||
let width = u64::from(area.width) * u64::from(font.width);
|
||||
let height = u64::from(area.height) * u64::from(font.height);
|
||||
let (width, height) = if width * 3 <= height * 4 {
|
||||
(width, ((width * 3 + 2) / 4).max(1))
|
||||
let (width, height) = if width * aspect_height <= height * aspect_width {
|
||||
(
|
||||
width,
|
||||
((width * aspect_height + aspect_width / 2) / aspect_width).max(1),
|
||||
)
|
||||
} else {
|
||||
(((height * 4 + 1) / 3).max(1), height)
|
||||
(
|
||||
((height * aspect_width + aspect_height / 2) / aspect_height).max(1),
|
||||
height,
|
||||
)
|
||||
};
|
||||
Size::new(
|
||||
width
|
||||
@@ -362,10 +391,94 @@ mod tests {
|
||||
assert_eq!(loading_width, error_width);
|
||||
assert!(thumbnail.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corrupt_and_oversized_jpeg_input() {
|
||||
fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() {
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
use std::time::Duration;
|
||||
|
||||
let mut thumbnail = Thumbnail::new();
|
||||
let url = "https://example.test/cover.png";
|
||||
thumbnail.selected = Some(url.into());
|
||||
thumbnail.aspect = (2, 3);
|
||||
let area = Rect::new(2, 2, 80, 8);
|
||||
let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3));
|
||||
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
|
||||
let mut loading_width = 0;
|
||||
terminal
|
||||
.draw(|frame| loading_width = thumbnail.draw(frame, area))
|
||||
.unwrap();
|
||||
assert_eq!(loading_width, slot.width);
|
||||
// The artwork need not exactly match the placeholder's 2:3 aspect.
|
||||
let protocol = thumbnail
|
||||
.picker
|
||||
.new_resize_protocol(image::DynamicImage::new_rgb8(60, 100));
|
||||
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
|
||||
let (replies_tx, replies_rx) = mpsc::channel();
|
||||
thumbnail.image = Some(ThumbnailImage {
|
||||
id: url.into(),
|
||||
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
|
||||
replies: replies_rx,
|
||||
complete: false,
|
||||
});
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
let response = requests_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.unwrap()
|
||||
.resize_encode()
|
||||
.unwrap();
|
||||
replies_tx.send(Ok(response)).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!thumbnail.is_loading());
|
||||
let size = thumbnail
|
||||
.image
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.protocol
|
||||
.size_for(Resize::Scale(None), slot)
|
||||
.unwrap();
|
||||
assert!(size.width <= slot.width && size.height <= slot.height);
|
||||
let font = thumbnail.picker.font_size();
|
||||
let width = u32::from(size.width) * u32::from(font.width);
|
||||
let height = u32::from(size.height) * u32::from(font.height);
|
||||
assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height));
|
||||
thumbnail.cancel();
|
||||
thumbnail.select_url(url);
|
||||
assert!(thumbnail.fetch.is_none());
|
||||
assert_eq!(thumbnail.selected.as_deref(), Some(url));
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
assert_eq!(thumbnail.draw(frame, area), loading_width);
|
||||
})
|
||||
.unwrap();
|
||||
thumbnail.clear_cache();
|
||||
assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() {
|
||||
assert!(decode(b"not a JPEG".to_vec()).is_err());
|
||||
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
|
||||
for format in [
|
||||
image::ImageFormat::Jpeg,
|
||||
image::ImageFormat::Png,
|
||||
image::ImageFormat::WebP,
|
||||
] {
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
image::DynamicImage::new_rgb8(2, 3)
|
||||
.write_to(&mut bytes, format)
|
||||
.unwrap();
|
||||
let image = decode(bytes.into_inner()).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (2, 3));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -4,8 +4,8 @@ use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Paragraph},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
pub const ACCENT: Style = Style::new().fg(Color::Cyan);
|
||||
@@ -50,3 +50,72 @@ pub fn spinner(elapsed: Duration) -> &'static str {
|
||||
const FRAMES: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
|
||||
FRAMES[(elapsed.as_millis() / 100 % FRAMES.len() as u128) as usize]
|
||||
}
|
||||
|
||||
pub fn info_preview(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
thumbnail: Option<&mut crate::thumbnail::Thumbnail>,
|
||||
metadata: Vec<Line<'_>>,
|
||||
text: &str,
|
||||
) {
|
||||
let mut details = if let Some(thumbnail) = thumbnail {
|
||||
let [_, image, _, mut details] = Layout::horizontal([
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(3),
|
||||
Constraint::Length(2),
|
||||
Constraint::Fill(2),
|
||||
])
|
||||
.areas(area);
|
||||
let reclaimed = image.width - thumbnail.draw(frame, image);
|
||||
details.x -= reclaimed;
|
||||
details.width += reclaimed;
|
||||
details.width = details.width.saturating_sub(2);
|
||||
details
|
||||
} else {
|
||||
area.inner(ratatui::layout::Margin::new(1, 0))
|
||||
};
|
||||
let metadata = Paragraph::new(metadata).wrap(Wrap { trim: true });
|
||||
let height = if details.is_empty() {
|
||||
0
|
||||
} else {
|
||||
metadata
|
||||
.line_count(details.width)
|
||||
.min(details.height as usize) as u16
|
||||
};
|
||||
let mut excerpt = details;
|
||||
let gap = (excerpt.height - height).min(1);
|
||||
excerpt.y += height + gap;
|
||||
excerpt.height -= height + gap;
|
||||
details.height = height;
|
||||
frame.render_widget(metadata, details);
|
||||
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
|
||||
let overflow = !text.is_empty()
|
||||
&& !excerpt.is_empty()
|
||||
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
|
||||
frame.render_widget(paragraph, excerpt);
|
||||
if overflow {
|
||||
let buffer = frame.buffer_mut();
|
||||
let y = excerpt.bottom() - 1;
|
||||
let end = (excerpt.x..excerpt.right())
|
||||
.rev()
|
||||
.find_map(|x| {
|
||||
let symbol = buffer
|
||||
.cell((x, y))
|
||||
.expect("excerpt is inside frame")
|
||||
.symbol();
|
||||
(!symbol.trim().is_empty())
|
||||
.then(|| x.saturating_add(Span::raw(symbol).width() as u16))
|
||||
})
|
||||
.unwrap_or(excerpt.x);
|
||||
let mut x = end.min(excerpt.right() - 1);
|
||||
// Do not leave half of a wide character beneath the ellipsis.
|
||||
if x > excerpt.x && Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1 {
|
||||
x -= 1;
|
||||
}
|
||||
buffer
|
||||
.cell_mut((x, y))
|
||||
.unwrap()
|
||||
.set_symbol("…")
|
||||
.set_style(Style::new());
|
||||
}
|
||||
}
|
||||
|
||||
+35
-1
@@ -679,6 +679,25 @@ impl ThumbnailJob {
|
||||
"Invalid YouTube video ID",
|
||||
));
|
||||
}
|
||||
Self::from_url(&format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg"))
|
||||
}
|
||||
|
||||
pub fn from_url(url: &str) -> io::Result<Self> {
|
||||
let uri: ureq::http::Uri = url
|
||||
.parse()
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Invalid artwork URL"))?;
|
||||
if uri.scheme_str() != Some("https")
|
||||
|| uri.host().is_none_or(str::is_empty)
|
||||
|| uri
|
||||
.authority()
|
||||
.is_some_and(|authority| authority.as_str().contains('@'))
|
||||
|| url.chars().any(char::is_control)
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Artwork URLs must be absolute HTTPS without credentials",
|
||||
));
|
||||
}
|
||||
let mut child = isolated_command(OsStr::new("curl"))
|
||||
.args([
|
||||
"--disable",
|
||||
@@ -686,6 +705,7 @@ impl ThumbnailJob {
|
||||
"--show-error",
|
||||
"--fail",
|
||||
"--location",
|
||||
"--globoff",
|
||||
"--proto",
|
||||
"=https",
|
||||
"--proto-redir",
|
||||
@@ -697,7 +717,7 @@ impl ThumbnailJob {
|
||||
"--max-filesize",
|
||||
"2097152", // 2 MiB; read_thumbnail also enforces the limit.
|
||||
])
|
||||
.arg(format!("https://i.ytimg.com/vi/{id}/hqdefault.jpg"))
|
||||
.arg(url)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -1570,6 +1590,20 @@ mod tests {
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
for url in [
|
||||
"http://example.test/cover.jpg",
|
||||
"file:///tmp/cover.jpg",
|
||||
"ftp://example.test/cover.jpg",
|
||||
"//example.test/cover.jpg",
|
||||
"https://",
|
||||
"https://user:secret@example.test/cover.jpg",
|
||||
"https://example.test/\ncover.jpg",
|
||||
] {
|
||||
assert!(matches!(
|
||||
ThumbnailJob::from_url(url),
|
||||
Err(error) if error.kind() == io::ErrorKind::InvalidInput
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user