Files
Marcos Paulo 68dd899844 feat: viewers round 2: pause, seek with hover preview, draw bar, PDF actions, note lightbox with zoom animation
Video: real pause, interactive seek track with ffmpeg hover thumbnails, skip, speed, loop, mute, open/reveal/copy, decode width follows the tab, probe and spawn off the UI thread. PDF: page jump, zoom, fit width/page, open/reveal/copy. Draw bar redesigned as one card. WebP opens in the tab lightbox. Note embeds open a lightbox with zoom-in/out animation from the thumbnail, a tools bar, a source link, and the same for videos, PDFs and other files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 23:59:14 -03:00

184 lines
8.0 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The image lightbox, driven headless: the picture loads off the UI thread,
//! the wheel zooms it, the drag pans it, the arrow steps to the neighbour and
//! a file that is not a picture shows the error card.
use std::path::Path;
use egui_kittest::kittest::Queryable;
use notescraft_engine::Session;
use notescraft_themes::builtin;
use notescraft_ui_egui::image_view::{self, View};
use notescraft_ui_egui::theme::Tokens;
/// The state the harness keeps between the frames of one lightbox.
struct Lightbox {
/// The state of the lightbox itself.
view: View,
/// The theme of the app.
tokens: Tokens,
/// The vault the picture is in.
session: Session,
/// The picture the tab shows.
path: &'static str,
/// Whether the fonts are in, which takes one pass.
warmed: bool,
}
/// A vault with the given pictures and a lightbox over `path`.
fn lightbox(name: &str, path: &'static str, pictures: &[(&str, bool)]) -> egui_kittest::Harness<'static, Lightbox> {
lightbox_with(name, path, |root| {
for (file, whole) in pictures {
if *whole {
write_png(&root.join(file), 64, 48);
} else {
std::fs::write(root.join(file), b"this is not a picture").expect("the broken file is written");
}
}
})
}
/// A vault that `fill` puts its files in, and a lightbox over `path`.
fn lightbox_with(name: &str, path: &'static str, fill: impl FnOnce(&Path)) -> egui_kittest::Harness<'static, Lightbox> {
let root = std::env::temp_dir().join(format!("notescraft-image-view-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("the temp vault is made");
fill(&root);
let session = Session::open(&root).expect("the temp vault opens");
let tokens = Tokens::new(builtin("catppuccin-mocha").expect("the theme is there"));
egui_kittest::Harness::builder().with_size(egui::Vec2::new(800.0, 600.0)).with_max_steps(400).build_ui_state(
|ui, state: &mut Lightbox| {
if !state.warmed {
// Fonts bind one pass after installing; the first pass warms only.
notescraft_ui_egui::fonts::install(ui.ctx());
state.warmed = true;
return;
}
image_view::show(ui, &state.tokens, &state.session, state.path, &mut state.view);
},
Lightbox { view: View::default(), tokens, session, path, warmed: false },
)
}
/// A PNG of the given size, in the vault.
fn write_png(path: &Path, width: u32, height: u32) {
let picture = image::RgbImage::from_pixel(width, height, image::Rgb([40, 90, 160]));
picture.save_with_format(path, image::ImageFormat::Png).expect("the png is written");
}
/// The zoom of the picture now.
fn zoom(harness: &egui_kittest::Harness<'_, Lightbox>) -> f32 {
harness.state().view.placement().zoom
}
#[test]
fn the_picture_loads_and_the_caption_names_its_size() {
let mut harness = lightbox("loads", "shot.png", &[("shot.png", true)]);
harness.run();
assert!(harness.get_all_by_label_contains("shot.png").count() >= 1, "the name of the picture is shown");
assert!(harness.get_all_by_label_contains("64 × 48").count() >= 1, "the caption names the size in pixels");
assert!(harness.get_all_by_label_contains("PNG").count() >= 1, "the caption names the type");
}
#[test]
fn the_wheel_zooms_the_picture_in() {
let mut harness = lightbox("wheel", "shot.png", &[("shot.png", true)]);
harness.run();
let before = zoom(&harness);
harness.hover_at(egui::pos2(400.0, 300.0));
harness.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, 1.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
});
harness.run();
let after = zoom(&harness);
assert!(after > before, "the mouse wheel zooms in: {before} then {after}");
assert!(after < before * 1.2, "one notch is a small step: {before} then {after}");
}
#[test]
fn a_two_finger_scroll_pans_and_does_not_zoom() {
let mut harness = lightbox("trackpad", "shot.png", &[("shot.png", true)]);
harness.run();
let (zoom_before, offset_before) = (zoom(&harness), harness.state().view.placement().offset);
harness.hover_at(egui::pos2(400.0, 300.0));
harness.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, 120.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
});
harness.run();
assert!((zoom(&harness) - zoom_before).abs() < 1e-6, "a trackpad scroll leaves the zoom alone");
assert!(harness.state().view.placement().offset.y > offset_before.y + 50.0, "it pans the picture");
}
#[test]
fn a_fast_ctrl_wheel_zooms_by_a_capped_step_per_frame() {
let mut harness = lightbox("ctrl-wheel", "shot.png", &[("shot.png", true)]);
harness.run();
let before = zoom(&harness);
harness.hover_at(egui::pos2(400.0, 300.0));
harness.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, 2000.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::COMMAND,
});
harness.step();
let after = zoom(&harness);
assert!(after > before && after <= before * 1.19, "capped: {before} then {after}");
}
#[test]
fn the_drag_pans_the_picture() {
let mut harness = lightbox("drag", "shot.png", &[("shot.png", true)]);
harness.run();
let before = harness.state().view.placement().offset;
harness.drag_at(egui::pos2(400.0, 300.0));
harness.event(egui::Event::PointerMoved(egui::pos2(460.0, 340.0)));
harness.drop_at(egui::pos2(460.0, 340.0));
harness.run();
let after = harness.state().view.placement().offset;
assert!((after.x - before.x).abs() > 1.0 || (after.y - before.y).abs() > 1.0, "the drag pans: {before:?} then {after:?}");
}
#[test]
fn the_arrow_steps_to_the_neighbour() {
let mut harness = lightbox("neighbour", "second.png", &[("first.png", true), ("second.png", true)]);
harness.run();
assert!(harness.get_all_by_label("2 / 2").count() == 1, "the second of two pictures is shown");
harness.key_press(egui::Key::ArrowLeft);
harness.run();
assert!(harness.get_all_by_label_contains("first.png").count() >= 1, "the earlier picture is shown");
assert!(harness.get_all_by_label("1 / 2").count() == 1, "the first of two pictures is shown");
}
#[test]
fn a_file_that_is_not_a_picture_shows_the_error_card() {
let mut harness = lightbox("broken", "broken.png", &[("broken.png", false)]);
harness.run();
assert!(harness.get_all_by_label_contains("cannot be shown").count() >= 1, "the card names the problem");
assert!(harness.get_all_by_label_contains("not a picture").count() >= 1, "the card says why");
}
#[test]
fn every_decodable_format_opens_in_the_tab_lightbox() {
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
for (extension, format) in
[("png", ImageFormat::Png), ("jpg", ImageFormat::Jpeg), ("gif", ImageFormat::Gif), ("webp", ImageFormat::WebP), ("bmp", ImageFormat::Bmp)]
{
let name = format!("format-{extension}");
let path: &'static str = Box::leak(format!("p.{extension}").into_boxed_str());
let picture = DynamicImage::ImageRgba8(RgbaImage::from_pixel(32, 24, Rgba([10, 120, 200, 255])));
let picture = if format == ImageFormat::Jpeg { DynamicImage::ImageRgb8(picture.to_rgb8()) } else { picture };
let mut bytes = std::io::Cursor::new(Vec::new());
picture.write_to(&mut bytes, format).expect("the encoder writes the picture");
let mut harness = lightbox_with(&name, path, |root| std::fs::write(root.join(path), bytes.into_inner()).expect("the picture is written"));
harness.run();
assert!(harness.get_all_by_label_contains("32 × 24").count() >= 1, "{extension} opens and its caption names the size");
assert_eq!(harness.query_all_by_label("This picture cannot be shown").count(), 0, "{extension} shows no error card");
}
}