fix: refine thumbnail spacing and indicate excerpt overflow

This commit is contained in:
2026-10-11 11:38:11 +02:00
parent 01f51be18e
commit 4205b56653
2 changed files with 92 additions and 37 deletions
+88 -8
View File
@@ -17,9 +17,9 @@
//! Esc closes the popup; j/k loads the selected video's description and i returns to editing.
//! 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.
//! Thumbnails scale proportionally in the left pane without cropping.
//! 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.
//! Thumbnails scale proportionally in the left pane, aligned one column from the text.
//! A blank row separates the bold title from the dimmed author · publication date.
//! Text retains a two-column right gutter; overflowing excerpts end with … without splitting wide glyphs.
//! 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.
//! Channel tags live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
@@ -1027,13 +1027,14 @@ impl App {
let block = Block::bordered().border_style(Style::new().fg(Color::Cyan));
let [image, _, mut details] = Layout::horizontal([
Constraint::Fill(3),
Constraint::Length(2),
Constraint::Length(1),
Constraint::Fill(2),
])
.areas(block.inner(popup));
details.width = details.width.saturating_sub(2);
let metadata = Paragraph::new(vec![
Line::raw(video.title.as_str()).bold(),
Line::default(),
Line::from(vec![
video.channel.as_deref().unwrap_or("Unknown").into(),
" · ".into(),
@@ -1067,10 +1068,40 @@ impl App {
.as_deref()
.unwrap_or("Loading full description..."),
};
frame.render_widget(
Paragraph::new(description).wrap(Wrap { trim: false }).dim(),
excerpt,
);
let paragraph = Paragraph::new(description).wrap(Wrap { trim: false }).dim();
let overflow = video
.description
.as_deref()
.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().dim());
}
// Drawing polls completion and starts resize work before we choose the title state.
self.thumbnail.draw(frame, image);
frame.render_widget(
@@ -1278,6 +1309,55 @@ mod tests {
)
}
#[test]
fn thumbnail_ellipsis_marks_only_hidden_content_and_preserves_wide_characters() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = app(&["tubular"]);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Heading","channel":"Creator","upload_date":"20050423"}]}"#,
).unwrap().videos;
app.views[0].selection.select(Some(0));
app.thumbnail_open = true;
for (last_line, hidden, marker) in [
("line", false, None),
("line", true, Some(50)),
("abcdefghijklmnopqrs", true, Some(64)),
("a界界界界界界界界界", true, Some(63)),
("", true, Some(46)),
] {
app.views[0].videos[0].description = Some(format!(
"{}{last_line}{}",
"line\n".repeat(6),
if hidden { "\nhidden content" } else { "" },
));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let buffer = terminal.backend().buffer();
// The blank row separates the title from its byline.
for x in 46..65 {
assert_eq!(buffer.cell((x, 7)).unwrap().symbol(), " ");
}
assert_eq!(buffer.cell((46, 8)).unwrap().symbol(), "C");
if let Some(x) = marker {
assert_eq!(buffer.cell((x, 17)).unwrap().symbol(), "…");
if last_line.starts_with("a界") {
assert_eq!(buffer.cell((61, 17)).unwrap().symbol(), "界");
assert_eq!(buffer.cell((64, 17)).unwrap().symbol(), " ");
}
} else {
let row: String = (46..65)
.map(|x| buffer.cell((x, 17)).unwrap().symbol())
.collect();
assert_eq!(row.trim_end(), "line");
}
assert_eq!(buffer.cell((65, 17)).unwrap().symbol(), " ");
assert_eq!(buffer.cell((66, 17)).unwrap().symbol(), " ");
}
}
#[test]
fn thumbnail_text_has_contrast_a_right_gutter_and_a_clipped_excerpt() {
use ratatui::{Terminal, backend::TestBackend, style::Modifier};