feat: improve thumbnail readability and add description excerpts
This commit is contained in:
+66
-25
@@ -18,7 +18,9 @@
|
|||||||
//! Normal-mode t toggles a thumbnail in the same-sized popup; j/k follows selection, Esc closes.
|
//! Normal-mode t toggles a thumbnail in the same-sized popup; j/k follows selection, Esc closes.
|
||||||
//! Images use native terminal graphics when supported, otherwise colored half-block characters.
|
//! Images use native terminal graphics when supported, otherwise colored half-block characters.
|
||||||
//! Thumbnails scale proportionally in the left pane without cropping.
|
//! Thumbnails scale proportionally in the left pane without cropping.
|
||||||
//! The right pane shows a wrapped title above author · publication date, dimmed like the footer.
|
//! The right pane has a bold title above a dimmed author · publication date and description excerpt.
|
||||||
|
//! Text wraps two columns before the right border; the excerpt is clipped to the remaining height.
|
||||||
|
//! Both previews share asynchronous full-description fetching and per-video caching.
|
||||||
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
|
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
|
||||||
//! Channel tags live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
|
//! Channel tags live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
|
||||||
//! `[categories]` stores flat, overlapping tags: one channel can appear in several arrays.
|
//! `[categories]` stores flat, overlapping tags: one channel can appear in several arrays.
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
//! Search pages contain 20 results; Feed/tag pages request 20 uploads per channel's /videos tab.
|
//! Search pages contain 20 results; Feed/tag pages request 20 uploads per channel's /videos tab.
|
||||||
//! In normal mode, approaching the loaded end fetches the next page without replacing rows/selection.
|
//! In normal mode, approaching the loaded end fetches the next page without replacing rows/selection.
|
||||||
//! Loading result/preview panes animate a spinner after their title, including additional pages.
|
//! Loading result/preview panes animate a spinner after their title, including additional pages.
|
||||||
//! Thumbnail spinners cover download, decode, and resize work; ready content and errors stop them.
|
//! Thumbnail spinners cover image download/decode/resize and description fetching; errors stop them.
|
||||||
//! Feed rows use approximate upload dates; older rows wait until newer channel pages catch up.
|
//! Feed rows use approximate upload dates; older rows wait until newer channel pages catch up.
|
||||||
//! Undated feed rows appear at exhaustion. Cached views retain their page position across switches.
|
//! Undated feed rows appear at exhaustion. Cached views retain their page position across switches.
|
||||||
//! Switching views cancels in-flight extraction; returning resumes the same pending range.
|
//! Switching views cancels in-flight extraction; returning resumes the same pending range.
|
||||||
@@ -822,9 +824,9 @@ impl App {
|
|||||||
view.selection
|
view.selection
|
||||||
.select(Some(index.min(view.videos.len() - 1)));
|
.select(Some(index.min(view.videos.len() - 1)));
|
||||||
}
|
}
|
||||||
if !self.description_open {
|
if !self.description_open && !self.thumbnail_open {
|
||||||
self.description_job = None;
|
self.description_job = None;
|
||||||
} else if !description_was_open
|
} else if (!description_was_open && !thumbnail_was_open)
|
||||||
|| self.views[self.view_index].selection.selected() != selected_before
|
|| self.views[self.view_index].selection.selected() != selected_before
|
||||||
{
|
{
|
||||||
self.load_description();
|
self.load_description();
|
||||||
@@ -1029,8 +1031,9 @@ impl App {
|
|||||||
Constraint::Fill(2),
|
Constraint::Fill(2),
|
||||||
])
|
])
|
||||||
.areas(block.inner(popup));
|
.areas(block.inner(popup));
|
||||||
|
details.width = details.width.saturating_sub(2);
|
||||||
let metadata = Paragraph::new(vec![
|
let metadata = Paragraph::new(vec![
|
||||||
Line::raw(video.title.as_str()),
|
Line::raw(video.title.as_str()).bold(),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
video.channel.as_deref().unwrap_or("Unknown").into(),
|
video.channel.as_deref().unwrap_or("Unknown").into(),
|
||||||
" · ".into(),
|
" · ".into(),
|
||||||
@@ -1039,10 +1042,10 @@ impl App {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("Unknown")
|
.unwrap_or("Unknown")
|
||||||
.into(),
|
.into(),
|
||||||
]),
|
|
||||||
])
|
])
|
||||||
.wrap(Wrap { trim: true })
|
.dim(),
|
||||||
.dim();
|
])
|
||||||
|
.wrap(Wrap { trim: true });
|
||||||
let height = if details.is_empty() {
|
let height = if details.is_empty() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
@@ -1050,16 +1053,32 @@ impl App {
|
|||||||
.line_count(details.width)
|
.line_count(details.width)
|
||||||
.min(details.height as usize) as u16
|
.min(details.height as usize) as u16
|
||||||
};
|
};
|
||||||
details.y += (details.height - height) / 2;
|
let mut excerpt = details;
|
||||||
|
let gap = (excerpt.height - height).min(1);
|
||||||
|
excerpt.y += height + gap;
|
||||||
|
excerpt.height -= height + gap;
|
||||||
details.height = height;
|
details.height = height;
|
||||||
frame.render_widget(metadata, details);
|
frame.render_widget(metadata, details);
|
||||||
|
let description = match video.description.as_deref() {
|
||||||
|
Some("") => "This video has no description.",
|
||||||
|
Some(description) => description,
|
||||||
|
None => self
|
||||||
|
.description_error
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Loading full description..."),
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(description).wrap(Wrap { trim: false }).dim(),
|
||||||
|
excerpt,
|
||||||
|
);
|
||||||
// Drawing polls completion and starts resize work before we choose the title state.
|
// Drawing polls completion and starts resize work before we choose the title state.
|
||||||
self.thumbnail.draw(frame, image);
|
self.thumbnail.draw(frame, image);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
block.title(loading_title(
|
block.title(loading_title(
|
||||||
" Thumbnail ",
|
" Thumbnail ",
|
||||||
"· t/Esc close ",
|
"· t/Esc close ",
|
||||||
self.thumbnail.is_loading().then_some(spinner),
|
(self.thumbnail.is_loading() || self.description_job.is_some())
|
||||||
|
.then_some(spinner),
|
||||||
)),
|
)),
|
||||||
popup,
|
popup,
|
||||||
);
|
);
|
||||||
@@ -1260,35 +1279,57 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn thumbnail_title_wraps_above_a_dimmed_byline_in_the_right_pane() {
|
fn thumbnail_text_has_contrast_a_right_gutter_and_a_clipped_excerpt() {
|
||||||
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
|
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
|
||||||
|
|
||||||
let mut app = app(&["tubular"]);
|
let mut app = app(&["tubular"]);
|
||||||
app.views[0].videos = youtube::search_results(
|
app.views[0].videos = youtube::search_results(
|
||||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First second third fourth fifth","channel":"Creator","upload_date":"20050423"}]}"#,
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First second third fourth fifth","channel":"Creator","upload_date":"20050423"}]}"#,
|
||||||
).unwrap().videos;
|
).unwrap().videos;
|
||||||
|
app.views[0].videos[0].description =
|
||||||
|
Some(format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)));
|
||||||
app.views[0].selection.select(Some(0));
|
app.views[0].selection.select(Some(0));
|
||||||
app.thumbnail_open = true;
|
app.thumbnail_open = true;
|
||||||
for (width, height, x, title_y, title_lines) in [
|
for (width, height) in [(80, 24), (120, 40), (20, 10)] {
|
||||||
(80, 24, 46, 10, vec!["First second third", "fourth fifth"]),
|
|
||||||
(120, 40, 69, 19, vec!["First second third fourth fifth"]),
|
|
||||||
] {
|
|
||||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| app.draw(frame, Duration::ZERO))
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
let mut lines = title_lines;
|
let popup = preview_area(Rect::new(0, 0, width, height));
|
||||||
lines.push("Creator · 2005-04-23");
|
let mut excerpt_rows = Vec::new();
|
||||||
for (row, text) in lines.iter().enumerate() {
|
for y in popup.y + 1..popup.bottom() - 1 {
|
||||||
for (column, character) in text.chars().enumerate() {
|
for x in popup.right() - 3..popup.right() - 1 {
|
||||||
let cell = buffer
|
assert_eq!(buffer.cell((x, y)).unwrap().symbol(), " ");
|
||||||
.cell((x + column as u16, title_y + row as u16))
|
}
|
||||||
|
let row: String = (popup.x + popup.width / 2..popup.right() - 3)
|
||||||
|
.map(|x| buffer.cell((x, y)).unwrap().symbol())
|
||||||
|
.collect();
|
||||||
|
assert!(!row.contains("CLIPPED SENTINEL"));
|
||||||
|
if row.contains("Excerpt line") {
|
||||||
|
excerpt_rows.push(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if width == 20 {
|
||||||
|
assert!(excerpt_rows.is_empty()); // Heading takes all available height.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = buffer
|
||||||
|
.cell((if width == 80 { 46 } else { 69 }, popup.y + 1))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(cell.symbol(), character.to_string());
|
assert_eq!(title.symbol(), "F");
|
||||||
assert!(cell.modifier.contains(Modifier::DIM));
|
assert!(title.modifier.contains(Modifier::BOLD));
|
||||||
}
|
assert!(!title.modifier.contains(Modifier::DIM));
|
||||||
}
|
let excerpt_y = *excerpt_rows
|
||||||
|
.last()
|
||||||
|
.expect("description fills remaining space");
|
||||||
|
assert_eq!(excerpt_y, popup.bottom() - 2);
|
||||||
|
let excerpt = buffer
|
||||||
|
.cell((if width == 80 { 46 } else { 69 }, excerpt_y))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(excerpt.symbol(), "E");
|
||||||
|
assert!(excerpt.modifier.contains(Modifier::DIM));
|
||||||
|
assert!(!excerpt.modifier.contains(Modifier::BOLD));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user