fix: left-align thumbnails and tuck text beside the image

This commit is contained in:
2026-10-11 11:48:29 +02:00
parent 4205b56653
commit 755c87f918
2 changed files with 28 additions and 17 deletions
+6 -3
View File
@@ -17,7 +17,8 @@
//! 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, aligned one column from the text. //! Thumbnails scale proportionally and align to the popup's left inner edge.
//! Left-aligned text starts one column after the actual image width, including height-limited images.
//! A blank row separates the bold title from the dimmed author · publication date. //! 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. //! 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.
@@ -1031,6 +1032,10 @@ impl App {
Constraint::Fill(2), Constraint::Fill(2),
]) ])
.areas(block.inner(popup)); .areas(block.inner(popup));
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.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(),
@@ -1102,8 +1107,6 @@ impl App {
.set_symbol("…") .set_symbol("…")
.set_style(Style::new().dim()); .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( frame.render_widget(
block.title(loading_title( block.title(loading_title(
" Thumbnail ", " Thumbnail ",
+22 -14
View File
@@ -96,23 +96,25 @@ impl Thumbnail {
.is_some_and(|image| image.complete && image.protocol.protocol_type().is_some()) .is_some_and(|image| image.complete && image.protocol.protocol_type().is_some())
} }
pub fn draw(&mut self, frame: &mut Frame, area: Rect) { /// Draw the image left-aligned and return its occupied width for adjacent content.
pub fn draw(&mut self, frame: &mut Frame, mut area: Rect) -> u16 {
self.poll(); self.poll();
if area.width == 0 || area.height == 0 || self.selected.is_none() { if area.width == 0 || area.height == 0 || self.selected.is_none() {
return; return area.width;
} }
let mut message = self.error.as_deref().unwrap_or("Loading thumbnail..."); let mut message = self.error.as_deref().unwrap_or("Loading thumbnail...");
if let Some(image) = self.image.as_mut() { if let Some(image) = self.image.as_mut() {
let resize = Resize::Scale(Some(FilterType::Triangle)); let resize = Resize::Scale(Some(FilterType::Triangle));
if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) { if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) {
area = left_aligned(area, size);
frame.render_stateful_widget( frame.render_stateful_widget(
StatefulImage::new().resize(resize), StatefulImage::new().resize(resize),
right_aligned(area, size), area,
&mut image.protocol, &mut image.protocol,
); );
if image.protocol.protocol_type().is_some() { if image.protocol.protocol_type().is_some() {
return; return area.width;
} }
} }
message = "Preparing thumbnail..."; message = "Preparing thumbnail...";
@@ -130,6 +132,7 @@ impl Thumbnail {
area.height - area.height / 2, area.height - area.height / 2,
), ),
); );
area.width
} }
fn poll(&mut self) { fn poll(&mut self) {
@@ -235,15 +238,10 @@ 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 right_aligned(area: Rect, size: Size) -> Rect { fn left_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.y + (area.height - height) / 2, width, height)
area.x + area.width - width,
area.y + (area.height - height) / 2,
width,
height,
)
} }
#[cfg(test)] #[cfg(test)]
@@ -309,7 +307,11 @@ mod tests {
}); });
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
for area in [Rect::new(3, 2, 80, 18), Rect::new(7, 4, 30, 8)] { for area in [Rect::new(3, 2, 80, 18), Rect::new(7, 4, 30, 8)] {
terminal.draw(|frame| thumbnail.draw(frame, area)).unwrap(); terminal
.draw(|frame| {
thumbnail.draw(frame, area);
})
.unwrap();
assert!(thumbnail.is_loading()); assert!(thumbnail.is_loading());
let response = requests_rx let response = requests_rx
.recv_timeout(Duration::from_secs(2)) .recv_timeout(Duration::from_secs(2))
@@ -317,7 +319,12 @@ mod tests {
.resize_encode() .resize_encode()
.unwrap(); .unwrap();
replies_tx.send(Ok(response)).unwrap(); replies_tx.send(Ok(response)).unwrap();
terminal.draw(|frame| thumbnail.draw(frame, area)).unwrap(); let mut occupied_width = 0;
terminal
.draw(|frame| {
occupied_width = thumbnail.draw(frame, area);
})
.unwrap();
assert!(!thumbnail.is_loading()); assert!(!thumbnail.is_loading());
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
let pixels: Vec<_> = (0..40) let pixels: Vec<_> = (0..40)
@@ -332,7 +339,8 @@ 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_eq!(left, area.x);
assert_eq!(occupied_width, right - left);
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!((top - area.y).abs_diff(area.bottom() - bottom) <= 1); assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1);