fix: refine thumbnail spacing and indicate excerpt overflow
This commit is contained in:
+88
-8
@@ -17,9 +17,9 @@
|
|||||||
//! Esc closes the popup; j/k loads the selected video's description and i returns to editing.
|
//! 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.
|
//! 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, aligned one column from the text.
|
||||||
//! The right pane has a bold title above a dimmed author · publication date and description excerpt.
|
//! A blank row separates the bold title from the dimmed author · publication date.
|
||||||
//! Text wraps two columns before the right border; the excerpt is clipped to the remaining height.
|
//! 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.
|
//! 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`.
|
||||||
@@ -1027,13 +1027,14 @@ impl App {
|
|||||||
let block = Block::bordered().border_style(Style::new().fg(Color::Cyan));
|
let block = Block::bordered().border_style(Style::new().fg(Color::Cyan));
|
||||||
let [image, _, mut details] = Layout::horizontal([
|
let [image, _, mut details] = Layout::horizontal([
|
||||||
Constraint::Fill(3),
|
Constraint::Fill(3),
|
||||||
Constraint::Length(2),
|
Constraint::Length(1),
|
||||||
Constraint::Fill(2),
|
Constraint::Fill(2),
|
||||||
])
|
])
|
||||||
.areas(block.inner(popup));
|
.areas(block.inner(popup));
|
||||||
details.width = details.width.saturating_sub(2);
|
details.width = details.width.saturating_sub(2);
|
||||||
let metadata = Paragraph::new(vec![
|
let metadata = Paragraph::new(vec![
|
||||||
Line::raw(video.title.as_str()).bold(),
|
Line::raw(video.title.as_str()).bold(),
|
||||||
|
Line::default(),
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
video.channel.as_deref().unwrap_or("Unknown").into(),
|
video.channel.as_deref().unwrap_or("Unknown").into(),
|
||||||
" · ".into(),
|
" · ".into(),
|
||||||
@@ -1067,10 +1068,40 @@ impl App {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("Loading full description..."),
|
.unwrap_or("Loading full description..."),
|
||||||
};
|
};
|
||||||
frame.render_widget(
|
let paragraph = Paragraph::new(description).wrap(Wrap { trim: false }).dim();
|
||||||
Paragraph::new(description).wrap(Wrap { trim: false }).dim(),
|
let overflow = video
|
||||||
excerpt,
|
.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.
|
// 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(
|
||||||
@@ -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]
|
#[test]
|
||||||
fn thumbnail_text_has_contrast_a_right_gutter_and_a_clipped_excerpt() {
|
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};
|
||||||
|
|||||||
+4
-29
@@ -108,7 +108,7 @@ impl Thumbnail {
|
|||||||
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
|
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
|
||||||
frame.render_stateful_widget(
|
frame.render_stateful_widget(
|
||||||
StatefulImage::new().resize(resize),
|
StatefulImage::new().resize(resize),
|
||||||
centered(area, size),
|
right_aligned(area, size),
|
||||||
&mut image.protocol,
|
&mut image.protocol,
|
||||||
);
|
);
|
||||||
if image.protocol.protocol_type().is_some() {
|
if image.protocol.protocol_type().is_some() {
|
||||||
@@ -235,11 +235,11 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
|
|||||||
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
|
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn centered(area: Rect, size: Size) -> Rect {
|
fn right_aligned(area: Rect, size: Size) -> Rect {
|
||||||
let width = size.width.min(area.width);
|
let width = size.width.min(area.width);
|
||||||
let height = size.height.min(area.height);
|
let height = size.height.min(area.height);
|
||||||
Rect::new(
|
Rect::new(
|
||||||
area.x + (area.width - width) / 2,
|
area.x + area.width - width,
|
||||||
area.y + (area.height - height) / 2,
|
area.y + (area.height - height) / 2,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
@@ -332,9 +332,9 @@ mod tests {
|
|||||||
let top = pixels.iter().map(|&(_, y)| y).min().unwrap();
|
let top = pixels.iter().map(|&(_, y)| y).min().unwrap();
|
||||||
let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1;
|
let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1;
|
||||||
assert!(left >= area.x && right <= area.right());
|
assert!(left >= area.x && right <= area.right());
|
||||||
|
assert_eq!(right, area.right()); // Keep the image against the metadata-facing edge.
|
||||||
assert!(top >= area.y && bottom <= area.bottom());
|
assert!(top >= area.y && bottom <= area.bottom());
|
||||||
assert!(right - left == area.width || bottom - top == area.height);
|
assert!(right - left == area.width || bottom - top == area.height);
|
||||||
assert!((left - area.x).abs_diff(area.right() - right) <= 1);
|
|
||||||
assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1);
|
assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1);
|
||||||
let width = u32::from(right - left) * u32::from(font.width);
|
let width = u32::from(right - left) * u32::from(font.width);
|
||||||
let height = u32::from(bottom - top) * u32::from(font.height);
|
let height = u32::from(bottom - top) * u32::from(font.height);
|
||||||
@@ -344,29 +344,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resized_image_preserves_aspect_and_stays_inside_popup() {
|
|
||||||
let picker = Picker::halfblocks();
|
|
||||||
let font = picker.font_size();
|
|
||||||
let protocol = picker.new_resize_protocol(image::DynamicImage::new_rgb8(480, 360));
|
|
||||||
let area = Rect::new(7, 3, 80, 10);
|
|
||||||
let size = protocol.size_for(Resize::Fit(None), area.into());
|
|
||||||
let image_area = centered(area, size);
|
|
||||||
assert!(image_area.x >= area.x && image_area.right() <= area.right());
|
|
||||||
assert!(image_area.y >= area.y && image_area.bottom() <= area.bottom());
|
|
||||||
let left = image_area.x - area.x;
|
|
||||||
let right = area.right() - image_area.right();
|
|
||||||
let top = image_area.y - area.y;
|
|
||||||
let bottom = area.bottom() - image_area.bottom();
|
|
||||||
assert!(left.abs_diff(right) <= 1 && top.abs_diff(bottom) <= 1);
|
|
||||||
let pixel_width = u32::from(image_area.width) * u32::from(font.width);
|
|
||||||
let pixel_height = u32::from(image_area.height) * u32::from(font.height);
|
|
||||||
// Cell rounding may add at most one cell to either fitted pixel dimension.
|
|
||||||
assert!(
|
|
||||||
pixel_width.abs_diff(pixel_height * 4 / 3)
|
|
||||||
<= u32::from(font.width) + u32::from(font.height) * 4 / 3
|
|
||||||
);
|
|
||||||
assert_eq!(centered(Rect::new(7, 3, 0, 0), size), Rect::new(7, 3, 0, 0));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user