5e9b5d3cfb
Audio tab and note embeds with waveform, live spectrum (own FFT), seek, speed, loop and volume through ffplay/ffmpeg; code and text files open in an editable source editor with syntax highlighting, a Language chip and a banner to install, pick or ignore a grammar; ico decoding through the largest entry; info card as a clean grid; draw tools: arrows and lines clamped and clipped to the picture with a head, inline text tool, hotkeys; Activity tab with unread count and hotkey; calendar title shows a link icon and a pointing hand; explorer trash button works on press; right panel tab pills shrink so they never cover the collapse button; math: unknown command keeps a parser tooltip; test files grouped into tests/ folders (12-file rule); flaky search-order test waits for the rows. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, 4422 tests, layers, tree, i18n) on a copy of the working tree. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
361 lines
16 KiB
Rust
361 lines
16 KiB
Rust
//! The draw mode over a picture, driven headless: a stroke becomes a sidecar
|
|
//! next to the picture, undo and redo bring it back, the eraser takes it away,
|
|
//! a damaged sidecar is kept as it is and a read-only vault shows the marks
|
|
//! without the tools.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
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;
|
|
|
|
/// How long the app waits after the last mark before it writes the sidecar.
|
|
const SAVE_DELAY: Duration = Duration::from_millis(800);
|
|
|
|
/// 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 a picture and a lightbox over it.
|
|
fn lightbox(name: &str, read_only: bool) -> (PathBuf, egui_kittest::Harness<'static, Lightbox>) {
|
|
lightbox_of(name, read_only, |path| write_png(path, 64, 48))
|
|
}
|
|
|
|
/// A vault with the picture `make` writes and a lightbox over it.
|
|
fn lightbox_of(name: &str, read_only: bool, make: impl FnOnce(&Path)) -> (PathBuf, egui_kittest::Harness<'static, Lightbox>) {
|
|
let root = std::env::temp_dir().join(format!("notescraft-image-annotate-{name}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&root);
|
|
std::fs::create_dir_all(&root).expect("the temp vault is made");
|
|
make(&root.join("shot.png"));
|
|
let session =
|
|
if read_only { Session::open_read_only(&root).expect("the temp vault opens read-only") } else { Session::open(&root).expect("the temp vault opens") };
|
|
let tokens = Tokens::new(builtin("catppuccin-mocha").expect("the theme is there"));
|
|
let harness = 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: "shot.png", warmed: false },
|
|
);
|
|
(root, harness)
|
|
}
|
|
|
|
/// 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");
|
|
}
|
|
|
|
/// Opens the draw mode and waits for the picture.
|
|
fn open_draw_mode(harness: &mut egui_kittest::Harness<'_, Lightbox>) {
|
|
harness.run();
|
|
harness.get_by_label("Draw").click();
|
|
harness.run();
|
|
}
|
|
|
|
/// Draws a stroke with the pointer, from one point of the canvas to another.
|
|
fn draw_stroke(harness: &mut egui_kittest::Harness<'_, Lightbox>, from: egui::Pos2, to: egui::Pos2) {
|
|
harness.drag_at(from);
|
|
harness.run();
|
|
harness.event(egui::Event::PointerMoved(to));
|
|
harness.run();
|
|
harness.drop_at(to);
|
|
// The status chip spins while the marks wait to be saved, so the frames never settle: step a few.
|
|
harness.run_steps(5);
|
|
}
|
|
|
|
/// Waits for the sidecar to be written.
|
|
fn wait_for_save(harness: &mut egui_kittest::Harness<'_, Lightbox>) {
|
|
std::thread::sleep(SAVE_DELAY + Duration::from_millis(200));
|
|
harness.run_steps(8);
|
|
// Once the marks are saved the chip stops spinning and the frames settle.
|
|
harness.run();
|
|
}
|
|
|
|
/// The sidecar of the picture in the vault.
|
|
fn sidecar(root: &Path) -> PathBuf {
|
|
root.join("shot.png.annotations.json")
|
|
}
|
|
|
|
/// The marks the sidecar holds, as a count.
|
|
fn mark_count(root: &Path) -> usize {
|
|
let text = std::fs::read_to_string(sidecar(root)).expect("the sidecar is read");
|
|
let document: serde_json::Value = serde_json::from_str(&text).expect("the sidecar is json");
|
|
document.get("items").and_then(serde_json::Value::as_array).map_or(0, Vec::len)
|
|
}
|
|
|
|
#[test]
|
|
fn a_stroke_becomes_a_sidecar_and_the_picture_is_untouched() {
|
|
let (root, mut harness) = lightbox("stroke", false);
|
|
let before = std::fs::read(root.join("shot.png")).expect("the picture is read");
|
|
open_draw_mode(&mut harness);
|
|
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(420.0, 360.0));
|
|
wait_for_save(&mut harness);
|
|
assert!(sidecar(&root).exists(), "the sidecar is written next to the picture");
|
|
assert_eq!(mark_count(&root), 1, "the stroke is in the sidecar");
|
|
assert_eq!(std::fs::read(root.join("shot.png")).expect("the picture is read"), before, "the picture is byte for byte the same");
|
|
}
|
|
|
|
#[test]
|
|
fn undo_and_redo_bring_the_mark_back() {
|
|
let (root, mut harness) = lightbox("undo", false);
|
|
open_draw_mode(&mut harness);
|
|
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(420.0, 360.0));
|
|
wait_for_save(&mut harness);
|
|
assert_eq!(mark_count(&root), 1, "the stroke is in the sidecar");
|
|
harness.get_by_label_contains("Undo").click();
|
|
harness.run_steps(5);
|
|
wait_for_save(&mut harness);
|
|
assert!(!sidecar(&root).exists(), "the sidecar of an empty drawing is taken away");
|
|
harness.get_by_label_contains("Redo").click();
|
|
harness.run_steps(5);
|
|
wait_for_save(&mut harness);
|
|
assert_eq!(mark_count(&root), 1, "the mark is back");
|
|
}
|
|
|
|
#[test]
|
|
fn the_eraser_takes_the_mark_away() {
|
|
let (root, mut harness) = lightbox("eraser", false);
|
|
open_draw_mode(&mut harness);
|
|
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(420.0, 360.0));
|
|
wait_for_save(&mut harness);
|
|
assert_eq!(mark_count(&root), 1, "the stroke is in the sidecar");
|
|
harness.get_by_label_contains("Eraser").click();
|
|
harness.run_steps(5);
|
|
harness.drag_at(egui::pos2(360.0, 330.0));
|
|
harness.run_steps(5);
|
|
harness.drop_at(egui::pos2(360.0, 330.0));
|
|
harness.run_steps(5);
|
|
wait_for_save(&mut harness);
|
|
assert!(!sidecar(&root).exists(), "the mark the eraser took is gone");
|
|
}
|
|
|
|
#[test]
|
|
fn a_damaged_sidecar_is_kept_and_replaced_only_when_the_user_draws() {
|
|
let (root, mut harness) = lightbox("damaged", false);
|
|
std::fs::write(sidecar(&root), b"{ this is not a sidecar").expect("the damaged sidecar is written");
|
|
harness.run();
|
|
assert!(harness.get_all_by_label_contains("damaged").count() >= 1, "the viewer says the marks are damaged");
|
|
assert_eq!(std::fs::read(sidecar(&root)).expect("the sidecar is read"), b"{ this is not a sidecar", "the damaged sidecar is not touched");
|
|
open_draw_mode(&mut harness);
|
|
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(420.0, 360.0));
|
|
wait_for_save(&mut harness);
|
|
assert_eq!(mark_count(&root), 1, "the new mark is in a new sidecar");
|
|
assert!(root.join("shot.png.annotations.json.bad").exists(), "the damaged sidecar is kept as a `.bad` copy");
|
|
}
|
|
|
|
#[test]
|
|
fn a_read_only_vault_shows_the_marks_without_the_tools() {
|
|
let (root, mut harness) = lightbox("read-only", true);
|
|
harness.run();
|
|
// The query that does not shout when nothing is found: the tools must be
|
|
// gone, and that is what the count says.
|
|
assert_eq!(harness.query_all_by_label("Draw").count(), 0, "the draw mode cannot be opened");
|
|
assert_eq!(harness.query_all_by_label_contains("Pen").count(), 0, "there are no tools");
|
|
let _ = root;
|
|
}
|
|
|
|
#[test]
|
|
fn the_draw_bar_stays_inside_the_tab_and_every_tool_is_reachable() {
|
|
let (_root, mut harness) = lightbox("bar-inside", false);
|
|
open_draw_mode(&mut harness);
|
|
harness.run_steps(6);
|
|
for label in ["Pen", "Highlighter", "Line", "Arrow", "Rectangle", "Ellipse", "Text", "Eraser", "Undo", "Redo", "Clear", "Done", "Colour 1", "Width 1"] {
|
|
let rect = harness.get_by_label_contains(label).rect();
|
|
assert!(rect.min.y >= 0.0 && rect.max.y <= 600.0 && rect.max.x <= 800.0, "{label} is inside the 800x600 tab: {rect:?}");
|
|
}
|
|
harness.get_by_label("Done").click();
|
|
harness.run_steps(4);
|
|
assert_eq!(harness.query_all_by_label("Done").count(), 0, "Done closes the draw bar");
|
|
}
|
|
|
|
/// The marks the sidecar holds, as JSON.
|
|
fn marks(root: &Path) -> Vec<serde_json::Value> {
|
|
let text = std::fs::read_to_string(sidecar(root)).expect("the sidecar is read");
|
|
let document: serde_json::Value = serde_json::from_str(&text).expect("the sidecar is json");
|
|
document.get("items").and_then(serde_json::Value::as_array).cloned().unwrap_or_default()
|
|
}
|
|
|
|
/// A pair of numbers of a mark, such as `from`.
|
|
fn pair(mark: &serde_json::Value, key: &str) -> (f64, f64) {
|
|
let values = mark.get(key).and_then(serde_json::Value::as_array).expect("the mark has the pair");
|
|
(values[0].as_f64().expect("a number"), values[1].as_f64().expect("a number"))
|
|
}
|
|
|
|
#[test]
|
|
fn a_line_runs_from_the_press_to_the_release_inside_the_picture() {
|
|
let (root, mut harness) = lightbox("line", false);
|
|
open_draw_mode(&mut harness);
|
|
// The hotkey picks the tool; the drag ends far right of the 64 x 48 picture.
|
|
harness.key_press(egui::Key::L);
|
|
harness.run_steps(3);
|
|
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(760.0, 300.0));
|
|
wait_for_save(&mut harness);
|
|
let items = marks(&root);
|
|
assert_eq!(items.len(), 1);
|
|
assert_eq!(items[0]["type"], "line");
|
|
assert_eq!(items[0]["arrow"], false);
|
|
let (from, to) = (pair(&items[0], "from"), pair(&items[0], "to"));
|
|
assert!(from.0 > 0.0 && from.0 < 64.0 && from.1 > 0.0 && from.1 < 48.0, "the start is a point of the picture: {from:?}");
|
|
assert!((to.0 - 64.0).abs() < 1e-3, "the end is held at the right edge: {to:?}");
|
|
assert!((to.1 - from.1).abs() < 0.5, "a level drag makes a level line: {from:?} {to:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_arrow_tool_makes_a_line_with_a_head_and_a_click_makes_nothing() {
|
|
let (root, mut harness) = lightbox("arrow", false);
|
|
open_draw_mode(&mut harness);
|
|
harness.get_by_label_contains("Arrow").click();
|
|
harness.run_steps(3);
|
|
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(400.0, 340.0));
|
|
wait_for_save(&mut harness);
|
|
let items = marks(&root);
|
|
assert_eq!((items.len(), &items[0]["arrow"]), (1, &serde_json::Value::Bool(true)));
|
|
// A press and release on the same point makes no second mark.
|
|
draw_stroke(&mut harness, egui::pos2(450.0, 330.0), egui::pos2(450.0, 330.0));
|
|
wait_for_save(&mut harness);
|
|
assert_eq!(mark_count(&root), 1, "a click without a drag makes nothing");
|
|
}
|
|
|
|
#[test]
|
|
fn marks_are_painted_inside_the_picture_only() {
|
|
let (_root, mut harness) = lightbox("clip", false);
|
|
open_draw_mode(&mut harness);
|
|
harness.get_by_label_contains("Arrow").click();
|
|
harness.run_steps(3);
|
|
draw_stroke(&mut harness, egui::pos2(300.0, 300.0), egui::pos2(760.0, 40.0));
|
|
harness.run_steps(3);
|
|
let mut marks_seen = 0;
|
|
for clipped in &harness.output().shapes {
|
|
let egui::Shape::Vec(parts) = &clipped.shape else { continue };
|
|
if !matches!(parts.first(), Some(egui::Shape::LineSegment { .. })) {
|
|
continue;
|
|
}
|
|
marks_seen += 1;
|
|
let clip = clipped.clip_rect;
|
|
assert!(clip.width() < 800.0 && clip.height() < 600.0, "the mark is clipped to the picture, not the tab: {clip:?}");
|
|
// The picture is 4:3 and the canvas may cut its bottom, so the clip is at least as wide as 4:3.
|
|
assert!(clip.width() / clip.height() >= 64.0 / 48.0 - 0.02, "the clip is the picture, not the tab: {clip:?}");
|
|
}
|
|
assert_eq!(marks_seen, 1, "the arrow is one item");
|
|
}
|
|
|
|
#[test]
|
|
fn the_text_tool_types_in_place_and_enter_keeps_the_label() {
|
|
let (root, mut harness) = lightbox("text", false);
|
|
open_draw_mode(&mut harness);
|
|
harness.get_by_label_contains("Text").click();
|
|
harness.run_steps(3);
|
|
let click = egui::pos2(300.0, 300.0);
|
|
harness.drag_at(click);
|
|
harness.run_steps(2);
|
|
harness.drop_at(click);
|
|
harness.run_steps(4);
|
|
let field = harness.get_by_role(egui::accesskit::Role::TextInput);
|
|
let rect = field.rect();
|
|
assert!((rect.min - click).length() < 12.0, "the field stands on the click: {rect:?}");
|
|
assert_eq!(harness.query_all_by_label("Add").count(), 0, "there is no Add button");
|
|
field.type_text("Hi 你好");
|
|
harness.run_steps(3);
|
|
harness.key_press(egui::Key::Enter);
|
|
harness.run_steps(4);
|
|
assert_eq!(harness.query_all_by_role(egui::accesskit::Role::TextInput).count(), 0, "Enter closes the field");
|
|
assert_eq!(harness.query_all_by_label("Done").count(), 1, "Enter that closes a label leaves the draw mode on");
|
|
wait_for_save(&mut harness);
|
|
let items = marks(&root);
|
|
assert_eq!((items.len(), items[0]["type"].as_str(), items[0]["text"].as_str()), (1, Some("text"), Some("Hi 你好")));
|
|
let at = pair(&items[0], "at");
|
|
assert!(at.0 > 0.0 && at.0 < 64.0 && at.1 > 0.0 && at.1 < 48.0, "the label is anchored on a point of the picture: {at:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_and_an_empty_label_make_nothing() {
|
|
let (root, mut harness) = lightbox("text-cancel", false);
|
|
open_draw_mode(&mut harness);
|
|
harness.get_by_label_contains("Text").click();
|
|
harness.run_steps(3);
|
|
for typed in ["gone", ""] {
|
|
let click = egui::pos2(300.0, 300.0);
|
|
harness.drag_at(click);
|
|
harness.run_steps(2);
|
|
harness.drop_at(click);
|
|
harness.run_steps(4);
|
|
if typed.is_empty() {
|
|
harness.key_press(egui::Key::Enter);
|
|
} else {
|
|
harness.get_by_role(egui::accesskit::Role::TextInput).type_text(typed);
|
|
harness.run_steps(2);
|
|
harness.key_press(egui::Key::Escape);
|
|
}
|
|
harness.run_steps(4);
|
|
assert_eq!(harness.query_all_by_role(egui::accesskit::Role::TextInput).count(), 0);
|
|
}
|
|
wait_for_save(&mut harness);
|
|
assert!(!sidecar(&root).exists(), "no mark was made");
|
|
}
|
|
|
|
/// Renders the draw mode over a transparent picture with every kind of mark, to the PNG named by
|
|
/// `NOTESCRAFT_ANNOTATE_PNG`; without the variable there is nothing to render.
|
|
#[test]
|
|
fn the_draw_mode_renders_every_kind_of_mark() {
|
|
let Ok(out) = std::env::var("NOTESCRAFT_ANNOTATE_PNG") else { return };
|
|
let (_root, mut harness) = lightbox_of("render", false, |path| {
|
|
let mut picture = image::RgbaImage::from_pixel(200, 150, image::Rgba([0, 0, 0, 0]));
|
|
for (x, y, pixel) in picture.enumerate_pixels_mut() {
|
|
let (dx, dy) = (f64::from(x) - 100.0, f64::from(y) - 75.0);
|
|
if dx * dx / 3600.0 + dy * dy / 2500.0 < 1.0 {
|
|
*pixel = image::Rgba([150, 90, 220, 255]);
|
|
}
|
|
}
|
|
picture.save_with_format(path, image::ImageFormat::Png).expect("the png is written");
|
|
});
|
|
// The picture loads on a worker thread: step until it is there before the first settled run.
|
|
for _ in 0..50 {
|
|
harness.run_steps(4);
|
|
std::thread::sleep(Duration::from_millis(40));
|
|
}
|
|
open_draw_mode(&mut harness);
|
|
let drag = |harness: &mut egui_kittest::Harness<'_, Lightbox>, tool: &str, from: (f32, f32), to: (f32, f32)| {
|
|
harness.get_by_label_contains(tool).click();
|
|
harness.run_steps(3);
|
|
draw_stroke(harness, egui::pos2(from.0, from.1), egui::pos2(to.0, to.1));
|
|
wait_for_save(harness);
|
|
};
|
|
drag(&mut harness, "Rectangle", (230.0, 120.0), (330.0, 200.0));
|
|
drag(&mut harness, "Ellipse", (350.0, 120.0), (450.0, 200.0));
|
|
drag(&mut harness, "Line", (230.0, 240.0), (330.0, 310.0));
|
|
drag(&mut harness, "Arrow", (350.0, 240.0), (480.0, 310.0));
|
|
harness.get_by_label_contains("Text").click();
|
|
harness.run_steps(3);
|
|
let click = egui::pos2(500.0, 140.0);
|
|
harness.drag_at(click);
|
|
harness.run_steps(2);
|
|
harness.drop_at(click);
|
|
harness.run_steps(4);
|
|
harness.get_by_role(egui::accesskit::Role::TextInput).type_text("Hello 你好");
|
|
harness.run_steps(3);
|
|
harness.key_press(egui::Key::Enter);
|
|
harness.run_steps(6);
|
|
harness.run_steps(6);
|
|
let image = harness.render().expect("the frame renders");
|
|
image.save(out).expect("the png is saved");
|
|
}
|