diff --git a/src/main.rs b/src/main.rs index 477340b..a4a3c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,9 +17,9 @@ //! 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 proportionally in the left pane without cropping. -//! The right pane has a bold title above a dimmed author · publication date and description excerpt. -//! Text wraps two columns before the right border; the excerpt is clipped to the remaining height. +//! Thumbnails scale proportionally in the left pane, aligned one column from the text. +//! 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. //! 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. //! 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 [image, _, mut details] = Layout::horizontal([ Constraint::Fill(3), - Constraint::Length(2), + Constraint::Length(1), Constraint::Fill(2), ]) .areas(block.inner(popup)); details.width = details.width.saturating_sub(2); let metadata = Paragraph::new(vec![ Line::raw(video.title.as_str()).bold(), + Line::default(), Line::from(vec![ video.channel.as_deref().unwrap_or("Unknown").into(), " · ".into(), @@ -1067,10 +1068,40 @@ impl App { .as_deref() .unwrap_or("Loading full description..."), }; - frame.render_widget( - Paragraph::new(description).wrap(Wrap { trim: false }).dim(), - excerpt, - ); + let paragraph = Paragraph::new(description).wrap(Wrap { trim: false }).dim(); + let overflow = video + .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. self.thumbnail.draw(frame, image); 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] fn thumbnail_text_has_contrast_a_right_gutter_and_a_clipped_excerpt() { use ratatui::{Terminal, backend::TestBackend, style::Modifier}; diff --git a/src/thumbnail.rs b/src/thumbnail.rs index 7bfd886..fc9739a 100644 --- a/src/thumbnail.rs +++ b/src/thumbnail.rs @@ -108,7 +108,7 @@ impl Thumbnail { if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) { frame.render_stateful_widget( StatefulImage::new().resize(resize), - centered(area, size), + right_aligned(area, size), &mut image.protocol, ); if image.protocol.protocol_type().is_some() { @@ -235,11 +235,11 @@ fn decode(bytes: Vec) -> Result { .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 height = size.height.min(area.height); Rect::new( - area.x + (area.width - width) / 2, + area.x + area.width - width, area.y + (area.height - height) / 2, width, height, @@ -332,9 +332,9 @@ mod tests { 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_eq!(right, area.right()); // Keep the image against the metadata-facing edge. 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); @@ -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)); - } }