diff --git a/src/main.rs b/src/main.rs index 2f845fb..93027e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ //! 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 up or down proportionally to fill the popup without cropping. //! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread. //! Channel categories live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`. //! Under `[categories]`, write `Rust = ["@RustVideos"]`; channel URLs and UC IDs also work. diff --git a/src/thumbnail.rs b/src/thumbnail.rs index 09dcee4..2973560 100644 --- a/src/thumbnail.rs +++ b/src/thumbnail.rs @@ -95,7 +95,7 @@ impl Thumbnail { let mut message = self.error.as_deref().unwrap_or("Loading thumbnail..."); if let Some(image) = self.image.as_mut() { - let resize = Resize::Fit(Some(FilterType::Triangle)); + let resize = Resize::Scale(Some(FilterType::Triangle)); if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) { frame.render_stateful_widget( StatefulImage::new().resize(resize), @@ -274,6 +274,66 @@ mod tests { assert!(thumbnail.error.is_none()); } + #[test] + fn small_thumbnail_fills_popup_proportionally_and_resizes() { + use ratatui::{Terminal, backend::TestBackend, style::Color}; + use std::time::Duration; + + let mut thumbnail = Thumbnail::new(); + let font = thumbnail.picker.font_size(); + let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 48, + 36, + image::Rgb([240, 40, 70]), + )); + let (requests_tx, requests_rx) = mpsc::channel::(); + let (replies_tx, replies_rx) = mpsc::channel(); + thumbnail.selected = Some("thumbnail".into()); + thumbnail.image = Some(ThumbnailImage { + id: "thumbnail".into(), + protocol: ThreadProtocol::new( + requests_tx, + Some(thumbnail.picker.new_resize_protocol(image)), + ), + replies: replies_rx, + 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)] { + terminal.draw(|frame| thumbnail.draw(frame, area)).unwrap(); + let response = requests_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .resize_encode() + .unwrap(); + replies_tx.send(Ok(response)).unwrap(); + terminal.draw(|frame| thumbnail.draw(frame, area)).unwrap(); + let buffer = terminal.backend().buffer(); + let pixels: Vec<_> = (0..40) + .flat_map(|y| (0..100).map(move |x| (x, y))) + .filter(|&(x, y)| { + let cell = &buffer[(x, y)]; + matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..)) + }) + .collect(); + let left = pixels.iter().map(|&(x, _)| x).min().unwrap(); + let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1; + let top = pixels.iter().map(|&(_, y)| y).min().unwrap(); + let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1; + assert!(left >= area.x && right <= area.right()); + assert!(top >= area.y && bottom <= area.bottom()); + 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); + let width = u32::from(right - left) * u32::from(font.width); + let height = u32::from(bottom - top) * u32::from(font.height); + assert!( + width.abs_diff(height * 4 / 3) + <= u32::from(font.width) + u32::from(font.height) * 4 / 3 + ); + } + } + #[test] fn resized_image_preserves_aspect_and_stays_inside_popup() { let picker = Picker::halfblocks();