Files
Marcos Paulo 5e9b5d3cfb
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: audio player, code files with highlighting, ico, draw tools fixed, activity panel, calendar-title link, lightbox and shell fixes
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>
2026-10-21 02:31:52 -03:00

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");
}