68dd899844
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>
184 lines
8.0 KiB
Rust
184 lines
8.0 KiB
Rust
//! 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");
|
||
}
|
||
}
|