fix: stabilize and unify video info previews

This commit is contained in:
2026-10-11 14:58:49 +02:00
parent 924d84b46b
commit b0c8fa18c1
2 changed files with 369 additions and 248 deletions
+171 -162
View File
File diff suppressed because one or more lines are too long
+125 -13
View File
@@ -11,7 +11,7 @@ use ratatui::{
widgets::{Paragraph, Wrap},
};
use ratatui_image::{
FilterType, Resize, StatefulImage,
FilterType, FontSize, Resize, StatefulImage,
picker::Picker,
thread::{ResizeRequest, ResizeResponse, ThreadProtocol},
};
@@ -112,25 +112,26 @@ impl Thumbnail {
.is_some_and(|image| image.complete && image.protocol.protocol_type().is_some())
}
/// 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 {
/// Draw the image left-aligned and reserve its fitted width for adjacent content.
pub fn draw(&mut self, frame: &mut Frame, area: Rect) -> u16 {
self.poll();
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 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()) {
area = left_aligned(area, size);
let image_area = left_aligned(area, size);
frame.render_stateful_widget(
StatefulImage::new().resize(resize),
area,
image_area,
&mut image.protocol,
);
if image.protocol.protocol_type().is_some() {
return area.width;
return slot.width;
}
}
message = "Preparing thumbnail...";
@@ -142,13 +143,13 @@ impl Thumbnail {
.alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
Rect::new(
area.x,
area.y + area.height / 2,
area.width,
area.height - area.height / 2,
slot.x,
slot.y + slot.height / 2,
slot.width,
slot.height - slot.height / 2,
),
);
area.width
slot.width
}
fn poll(&mut self) {
@@ -253,6 +254,29 @@ fn decode(bytes: Vec<u8>) -> Result<image::DynamicImage, String> {
.map_err(|error| format!("Could not decode thumbnail JPEG: {error}"))
}
fn thumbnail_size(area: Rect, font: FontSize) -> 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.
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))
} else {
(((height * 4 + 1) / 3).max(1), height)
};
Size::new(
width
.div_ceil(u64::from(font.width))
.min(u64::from(area.width)) as u16,
height
.div_ceil(u64::from(font.height))
.min(u64::from(area.height)) as u16,
)
}
fn left_aligned(area: Rect, size: Size) -> Rect {
let width = size.width.min(area.width);
let height = size.height.min(area.height);
@@ -263,6 +287,81 @@ fn left_aligned(area: Rect, size: Size) -> Rect {
mod tests {
use super::*;
#[test]
fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() {
use ratatui::{Terminal, backend::TestBackend};
use std::time::Duration;
let mut thumbnail = Thumbnail::new();
thumbnail.selected = Some("fixture".into());
let area = Rect::new(2, 2, 80, 8);
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();
let (_decode_tx, decode_rx) = mpsc::channel();
thumbnail.decoder = Some(decode_rx);
let mut decoding_width = 0;
terminal
.draw(|frame| decoding_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, decoding_width);
thumbnail.decoder = None;
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
let (replies_tx, replies_rx) = mpsc::channel();
thumbnail.image = Some(ThumbnailImage {
id: "fixture".into(),
protocol: ThreadProtocol::new(
requests_tx,
Some(
thumbnail
.picker
.new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)),
),
),
replies: replies_rx,
complete: false,
});
let mut preparing_width = 0;
terminal
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, preparing_width);
terminal
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, preparing_width);
replies_tx
.send(Ok(requests_rx
.recv_timeout(Duration::from_secs(2))
.unwrap()
.resize_encode()
.unwrap()))
.unwrap();
let mut ready_width = 0;
terminal
.draw(|frame| ready_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(
loading_width, ready_width,
"right-hand content must not move when the image finishes loading"
);
thumbnail.cancel();
thumbnail.select("fixture");
terminal
.draw(|frame| ready_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, ready_width);
replies_tx
.send(Err("fixture renderer failure".into()))
.unwrap();
let mut error_width = 0;
terminal
.draw(|frame| error_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, error_width);
assert!(thumbnail.error.is_some());
}
#[test]
fn rejects_corrupt_and_oversized_jpeg_input() {
assert!(decode(b"not a JPEG".to_vec()).is_err());
@@ -377,13 +476,25 @@ mod tests {
complete: false,
});
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),
Rect::new(3, 2, 9, 18),
Rect::new(7, 4, 30, 8),
] {
let mut pending_width = 0;
terminal
.draw(|frame| {
thumbnail.draw(frame, area);
pending_width = thumbnail.draw(frame, area);
})
.unwrap();
assert!(thumbnail.is_loading());
assert!(
!terminal.backend().buffer().content.iter().any(|cell| {
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
}),
"a resize must not render stale pixels"
);
let response = requests_rx
.recv_timeout(Duration::from_secs(2))
.unwrap()
@@ -397,6 +508,7 @@ mod tests {
})
.unwrap();
assert!(!thumbnail.is_loading());
assert_eq!(pending_width, occupied_width);
let buffer = terminal.backend().buffer();
let pixels: Vec<_> = (0..40)
.flat_map(|y| (0..100).map(move |x| (x, y)))