fix: stabilize and unify video info previews
This commit is contained in:
+244
-235
File diff suppressed because one or more lines are too long
+125
-13
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user