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

664 lines
30 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.
//! Features driven the way a user does: real pointer and keyboard events through whole frames of the app.
use egui::{Context, Event, PointerButton, Pos2, RawInput};
use notescraft_engine::Session;
use notescraft_ui_egui::app::NotesApp;
use serde_json::json;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Driver {
app: NotesApp,
ctx: Context,
time: f64,
}
/// An app over a fresh vault holding `files`, with animations off.
fn driver(files: &[(&str, &str)]) -> Driver {
driver_with(files, &|_| {})
}
/// [`driver`] with `setup` run on the vault folder before the app opens it.
fn driver_with(files: &[(&str, &str)], setup: &dyn Fn(&std::path::Path)) -> Driver {
let root = std::env::temp_dir().join(format!("notescraft-driven-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&root).expect("root");
for (path, text) in files {
if let Some((folder, _)) = path.rsplit_once('/') {
std::fs::create_dir_all(root.join(folder)).expect("folder");
}
std::fs::write(root.join(path), text).expect("seed");
}
setup(&root);
let mut session = Session::open(&root).expect("session");
session.use_settings_dir(root.join("config"));
let mut app = NotesApp::new(session, notescraft_themes::builtin("catppuccin-mocha").expect("theme"));
app.state.animations = false;
Driver { app, ctx: Context::default(), time: 1.0 }
}
/// A painted text: what it says, where it starts and its galley (for the position of one character).
struct Painted {
text: String,
pos: Pos2,
galley: std::sync::Arc<egui::Galley>,
}
impl Driver {
/// One frame of 1440 x 900 with `events`; the texts it painted.
fn frame(&mut self, events: Vec<Event>) -> Vec<Painted> {
self.time += 0.05;
let input = RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1440.0, 900.0))),
time: Some(self.time),
events,
..Default::default()
};
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui.ctx()));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Text(text) => Some(Painted { text: text.galley.text().to_owned(), pos: text.pos, galley: text.galley.clone() }),
_ => None,
})
.collect()
}
fn settle(&mut self) -> Vec<Painted> {
let mut last = Vec::new();
for _ in 0..6 {
last = self.frame(Vec::new());
}
last
}
fn mouse(&mut self, at: Pos2, pressed: Option<bool>) -> Vec<Painted> {
let mut events = vec![Event::PointerMoved(at)];
if let Some(pressed) = pressed {
events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
}
self.frame(events)
}
fn click(&mut self, at: Pos2) {
self.mouse(at, None);
self.mouse(at, Some(true));
self.mouse(at, Some(false));
self.settle();
}
}
fn names(texts: &[Painted]) -> Vec<&str> {
texts.iter().map(|painted| painted.text.as_str()).collect()
}
/// The centre of the character at `index` of a painted text.
fn char_at(painted: &Painted, index: usize) -> Pos2 {
let rect = painted.galley.pos_from_cursor(egui::text::CCursor::new(index));
painted.pos + rect.center().to_vec2()
}
fn find<'a>(texts: &'a [Painted], wanted: &str) -> &'a Painted {
texts.iter().find(|painted| painted.text == wanted).unwrap_or_else(|| panic!("no {wanted:?} among {:?}", names(texts)))
}
#[test]
fn dragging_a_tab_onto_another_reorders_the_tabs() {
let mut d = driver(&[("a.md", "A"), ("b.md", "B"), ("c.md", "C")]);
for path in ["a.md", "b.md", "c.md"] {
d.app.open_note(path);
}
let texts = d.settle();
let in_strip = |name: &str| texts.iter().find(|p| p.text == name && p.pos.y < 70.0 && p.pos.x > 300.0).unwrap_or_else(|| panic!("no tab {name:?}")).pos;
let (from, to) = (in_strip("a") + egui::vec2(6.0, 8.0), in_strip("c") + egui::vec2(6.0, 8.0));
d.mouse(from, None);
d.mouse(from, Some(true));
for step in 1..=6_u8 {
let at = from + (to - from) * (f32::from(step) / 6.0);
d.mouse(at, None);
}
d.mouse(to, Some(false));
d.settle();
assert_eq!(d.app.tabs.open, ["b.md", "c.md", "a.md"], "a dropped on c takes c's place");
}
#[test]
fn a_pinned_tab_goes_to_the_front_and_is_saved() {
let mut d = driver(&[("a.md", "A"), ("b.md", "B")]);
d.app.open_note("a.md");
d.app.open_note("b.md");
d.settle();
d.app.tabs.toggle_pin("b.md");
d.settle();
assert_eq!(d.app.tabs.open, ["b.md", "a.md"]);
d.app.persist_ui();
assert_eq!(d.app.state.pinned_tabs, ["b.md"]);
}
#[test]
fn resting_on_a_link_shows_the_page_preview_after_the_dwell() {
let mut d = driver(&[("a.md", "See [[b]] now\n"), ("b.md", "# B\nThe body of the second note.\n")]);
d.app.open_note("a.md");
let texts = d.settle();
let row = find(&texts, "See b now");
let on_link = char_at(row, 5);
d.mouse(on_link, None);
assert!(d.frame(Vec::new()).iter().all(|p| !p.text.contains("body of the second")), "no card at once");
let mut shown = false;
for _ in 0..14 {
shown |= d.mouse(on_link, None).iter().any(|p| p.text.contains("The body of the second note."));
}
assert!(shown, "the card appears once the pointer has rested on the link");
let away = d.mouse(Pos2::new(900.0, 700.0), None);
assert!(away.iter().all(|p| !p.text.contains("The body of the second note.")), "and goes when the pointer leaves");
}
#[test]
fn a_list_item_folds_its_children_from_the_gutter_chevron() {
let mut d = driver(&[("l.md", "- parent\n - child\n- other\n")]);
d.app.open_note("l.md");
let texts = d.settle();
assert!(texts.iter().any(|p| p.text.contains("child")));
let row = texts.iter().find(|p| p.text.contains("parent")).expect("parent row");
// The chevron sits at the right end of the 720 pt readable column (here x = 1080), 11 pt in.
let chevron = Pos2::new(1080.0 - 11.0, row.pos.y + 12.0);
d.mouse(chevron, None);
d.click(chevron);
let folded = d.settle();
assert!(folded.iter().all(|p| !p.text.contains("child")), "the child is hidden: {:?}", names(&folded));
assert!(folded.iter().any(|p| p.text.contains("other")), "siblings stay");
}
#[test]
fn splitting_shows_two_panes_and_clicking_the_second_makes_it_the_editor() {
let mut d = driver(&[("a.md", "# Alpha\n"), ("b.md", "# Beta\n")]);
d.app.open_note("a.md");
d.app.open_note("b.md");
d.app.run_command("view.split", &json!({}));
let texts = d.settle();
assert_eq!(texts.iter().filter(|p| p.text == "Beta").count(), 2, "the note is shown in both panes: {:?}", names(&texts));
assert_eq!(d.app.tabs.active_path(), Some("b.md"));
let right = texts.iter().filter(|p| p.text == "Beta").map(|p| p.pos).fold(Pos2::ZERO, |best, p| if p.x > best.x { p } else { best });
d.click(right + egui::vec2(60.0, 120.0));
assert_eq!(d.app.tabs.open, ["b.md"], "the clicked pane's tabs became the main tabs");
d.app.run_command("view.split", &json!({}));
let single = d.settle();
assert_eq!(single.iter().filter(|p| p.text == "Beta").count(), 1, "the command toggles the pane off");
}
#[test]
fn hidden_notes_appear_in_the_explorer_only_when_the_setting_is_on() {
let mut d = driver(&[("shown.md", "x"), (".stash/old.md", "y")]);
d.settle();
assert!(!d.app.tree.folder_paths().contains(".stash"));
d.app.run_command("settings.set", &json!({"key": "session.files.show_hidden_files", "value": true}));
d.settle();
assert!(d.app.tree.folder_paths().contains(".stash"));
}
#[test]
fn the_find_bar_counts_regular_expression_matches() {
let mut d = driver(&[("n.md", "x1 y22 z\n")]);
d.app.open_note("n.md");
d.settle();
d.app.open_find();
d.settle();
d.frame(vec![Event::Text(r"\d+".to_owned())]);
let plain = d.settle();
assert!(plain.iter().any(|p| p.text == "No matches"), "as plain text the pattern is not in the note: {:?}", names(&plain));
let switch = find(&plain, ".*").pos + egui::vec2(-14.0, 6.0);
d.click(switch);
let texts = d.settle();
assert!(texts.iter().any(|p| p.text == "1 of 2"), "{:?}", names(&texts));
}
#[test]
fn dragging_a_note_onto_a_folder_in_the_explorer_moves_the_file() {
let mut d = driver(&[("n.md", "# N\n"), ("dir/x.md", "# X\n")]);
d.app.run_command("explorer.reveal_active", &json!({}));
let texts = d.settle();
let row = |name: &str| {
texts.iter().find(|p| p.text == name && p.pos.x < 300.0 && p.pos.y > 250.0).unwrap_or_else(|| panic!("no row {name:?} in {:?}", names(&texts))).pos
};
let (from, to) = (row("n") + egui::vec2(8.0, 8.0), row("dir") + egui::vec2(8.0, 8.0));
d.mouse(from, None);
d.mouse(from, Some(true));
for step in 1..=8_u8 {
d.mouse(from + (to - from) * (f32::from(step) / 8.0), None);
}
d.mouse(to, Some(false));
d.settle();
d.settle();
let root = d.app.session.vault().root().to_path_buf();
assert!(root.join("dir/n.md").exists(), "moved into the folder");
assert!(!root.join("n.md").exists());
}
#[test]
fn a_video_embed_plays_inside_the_note_when_ffmpeg_is_installed() {
let made_ok = std::cell::Cell::new(false);
let mut d = driver_with(&[("v.md", "# V\n\n![[clip.mp4]]\n")], &|root| {
let status = std::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=3:size=320x180:rate=24", "-pix_fmt", "yuv420p"])
.arg(root.join("clip.mp4"))
.status();
made_ok.set(status.is_ok_and(|status| status.success()));
});
if !made_ok.get() {
return;
}
d.app.open_note("v.md");
let texts = d.settle();
let play = find(&texts, "Play").pos + egui::vec2(8.0, 8.0);
d.click(play);
let mut frames_seen = 0;
for _ in 0..80 {
std::thread::sleep(std::time::Duration::from_millis(40));
d.frame(Vec::new());
if notescraft_ui_egui::app::video::state(&d.ctx).and_then(|state| state.playing).is_some() {
frames_seen += 1;
}
}
assert!(frames_seen > 5, "decoded frames reached the note: {frames_seen}");
}
#[test]
fn a_query_block_lists_the_matching_notes_and_follows_new_ones() {
let mut d = driver(&[("q.md", "# Q\n\n```query\nzebrafish\n```\n"), ("one.md", "a zebrafish here\n"), ("other.md", "nothing\n")]);
d.app.open_note("q.md");
let texts = d.settle();
assert!(texts.iter().any(|p| p.text == "one" && p.pos.x > 300.0), "the match is listed: {:?}", names(&texts));
assert!(texts.iter().all(|p| p.text != "other" || p.pos.x < 300.0), "a non-match is not");
d.app.run_command("note.create", &json!({"path": "two.md", "content": "zebrafish again\n"}));
let mut listed = false;
for _ in 0..60 {
std::thread::sleep(std::time::Duration::from_millis(50));
listed = d.frame(Vec::new()).iter().any(|p| p.text == "two" && p.pos.x > 300.0);
if listed {
break;
}
}
assert!(listed, "a note added later appears");
d.app.open_note("q.md");
let row = d.settle().iter().find(|p| p.text == "one" && p.pos.x > 300.0).expect("row").pos + egui::vec2(4.0, 6.0);
d.click(row);
assert_eq!(d.app.tabs.active_path(), Some("one.md"), "clicking a row opens the note");
}
#[test]
fn stacked_tabs_fold_the_others_into_strips_and_a_click_unfolds_one() {
let mut d = driver(&[("a.md", "# Alpha\n"), ("b.md", "# Beta\n"), ("c.md", "# Gamma\n")]);
for path in ["a.md", "b.md", "c.md"] {
d.app.open_note(path);
}
d.app.run_command("view.stacked_tabs", &json!({}));
let texts = d.settle();
assert!(texts.iter().any(|p| p.text == "Gamma"), "the active note is shown");
let strip = |texts: &[Painted], name: &str| texts.iter().find(|p| p.text == name && p.pos.x > 300.0 && p.pos.x < 420.0 && p.pos.y > 40.0).map(|p| p.pos);
let strip_a = strip(&texts, "a").expect("a strip titled a");
assert!(strip(&texts, "b").is_some(), "and one titled b");
assert!(strip(&texts, "c").is_none(), "the active tab has no strip");
d.click(strip_a + egui::vec2(4.0, -10.0));
assert_eq!(d.app.tabs.active_path(), Some("a.md"), "clicking a strip activates that tab");
let after = d.settle();
assert!(after.iter().any(|p| p.text == "Alpha"));
assert!(after.iter().any(|p| p.text == "c" && p.pos.x > 900.0 && p.pos.y > 40.0 && p.pos.y < 70.0), "the later tabs fold to the right of the note");
}
#[test]
fn ticking_a_task_inside_an_embed_changes_the_embedded_note() {
let mut d = driver(&[("host.md", "# Host\n\n![[tasks]]\n"), ("tasks.md", "- [ ] buy milk\n")]);
d.app.open_note("host.md");
let texts = d.settle();
let row = texts.iter().find(|p| p.text.contains("buy milk") && p.pos.x > 300.0).expect("the embedded task row").pos;
d.click(row + egui::vec2(8.0, 8.0));
d.settle();
let root = d.app.session.vault().root().to_path_buf();
let text = std::fs::read_to_string(root.join("tasks.md")).expect("tasks.md");
assert!(text.starts_with("- [x] buy milk"), "{text:?}");
}
#[test]
fn text_on_the_line_of_an_embed_is_drawn_around_it() {
let mut d = driver(&[("host.md", "# Host\n\nbefore-word ![[side]] after-word\n"), ("side.md", "embedded body\n")]);
d.app.open_note("host.md");
let texts = d.settle();
let at = |needle: &str| texts.iter().find(|p| p.text.contains(needle) && p.pos.x > 300.0).map(|p| p.pos.y);
let (before, body, after) = (at("before-word"), at("embedded body"), at("after-word"));
assert!(before.is_some() && body.is_some() && after.is_some(), "{:?}", names(&texts));
assert!(before < body && body < after, "in order: {before:?} {body:?} {after:?}");
}
#[test]
fn an_embed_follows_an_edit_made_to_the_embedded_note_in_its_own_tab() {
let mut d = driver(&[("host.md", "# Host\n\n![[side]]\n"), ("side.md", "old words\n")]);
d.app.open_note("side.md");
d.settle();
d.app.open_note("host.md");
assert!(d.settle().iter().any(|p| p.text.contains("old words")));
d.app.session.buffer_mut("side.md").expect("buffer").edit(0..3, "new").expect("edit");
let shown = d.settle();
assert!(shown.iter().any(|p| p.text.contains("new words")), "{:?}", names(&shown));
}
#[test]
fn the_block_menu_opens_from_the_keyboard_at_the_caret() {
let mut d = driver(&[("n.md", "first paragraph\n\nsecond paragraph\n")]);
d.app.open_note("n.md");
let texts = d.settle();
let row = find(&texts, "second paragraph").pos;
d.click(row + egui::vec2(20.0, 8.0));
assert!(notescraft_ui_egui::block_menu::current(&d.ctx).is_none(), "closed before the shortcut");
let key = Event::Key {
key: egui::Key::B,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { alt: true, command: true, ..Default::default() },
};
d.frame(vec![key]);
d.settle();
assert!(notescraft_ui_egui::block_menu::current(&d.ctx).is_some(), "the menu is open; toast {:?}", d.app.toast);
}
#[test]
fn a_note_pops_out_of_the_tabs_and_comes_back_when_its_window_closes() {
let mut d = driver(&[("a.md", "# Alpha\n"), ("b.md", "# Beta body\n")]);
d.app.open_note("a.md");
d.app.open_note("b.md");
d.app.pop_out_for_test();
assert_eq!(d.app.tabs.open, ["a.md"], "the main window no longer lists the note");
assert!(!d.app.tabs.is_open("b.md"));
d.app.close_popouts_for_test();
assert!(d.app.tabs.is_open("b.md"), "closing the window returns the note");
assert_eq!(d.app.tabs.active_path(), Some("b.md"));
// Where the harness cannot open a native window, the note goes straight back to its tab.
d.app.pop_out_for_test();
d.settle();
assert!(d.app.tabs.is_open("b.md"));
}
#[test]
fn a_hebrew_paragraph_sits_against_the_right_edge_and_an_english_one_stays_left() {
let mut d = driver(&[("h.md", "# H\n\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e2}\u{5d5}\u{5dc}\u{5dd}\n\nplain english\n")]);
d.app.open_note("h.md");
let texts = d.settle();
let hebrew = texts.iter().find(|p| p.text.contains('\u{5dd}') && p.pos.x > 300.0 && p.pos.y > 100.0).expect("the hebrew row");
let english = find(&texts, "plain english");
let right_end = hebrew.pos.x + hebrew.galley.size().x;
assert!(right_end > english.pos.x + 400.0, "the hebrew row ends far to the right: {right_end} vs {}", english.pos.x);
assert!(hebrew.pos.x > english.pos.x + 300.0, "and starts well right of the left edge");
assert_eq!(hebrew.text.chars().next(), Some('\u{5dd}'), "painted in screen order: the last letter first");
}
fn double_click_folder(d: &mut Driver, name: &str) {
d.app.run_command("explorer.reveal_active", &json!({}));
let texts = d.settle();
let at =
texts.iter().find(|p| p.text == name && p.pos.x < 300.0 && p.pos.y > 250.0).unwrap_or_else(|| panic!("no row {name:?} in {:?}", names(&texts))).pos
+ egui::vec2(8.0, 8.0);
d.mouse(at, None);
for _ in 0..2 {
d.mouse(at, Some(true));
d.mouse(at, Some(false));
}
d.settle();
}
#[test]
fn a_folder_with_a_folder_note_opens_that_note_and_one_without_opens_the_folder_view() {
let mut d = driver(&[("proj/proj.md", "# Project home\n"), ("proj/other.md", "x\n"), ("plain/a.md", "y\n")]);
double_click_folder(&mut d, "proj");
assert_eq!(d.app.tabs.active_path(), Some("proj/proj.md"));
double_click_folder(&mut d, "plain");
assert_eq!(d.app.tabs.active_path().map(|path| path.starts_with(notescraft_ui_egui::tabs::FOLDER_PREFIX)), Some(true), "no folder note: the folder view");
}
#[test]
fn clicking_a_tag_in_a_note_searches_for_it() {
let mut d = driver(&[("t.md", "Plan the #garden work\n"), ("u.md", "more #garden\n")]);
d.app.open_note("t.md");
let texts = d.settle();
let row = find(&texts, "Plan the #garden work");
d.click(char_at(row, 11));
assert_eq!(d.app.search.query, "tag:#garden");
assert_eq!(d.app.state.sidebar_tab, notescraft_ui_egui::sidebar_tabs::SidebarTab::Search);
}
#[test]
fn the_explorer_calendar_folds_to_its_title_row_and_gives_the_room_to_the_tree() {
let files: Vec<(String, String)> = (0..30).map(|n| (format!("n{n:02}.md"), String::from("x\n"))).collect();
let refs: Vec<(&str, &str)> = files.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
let mut d = driver(&refs);
let visible_notes = |texts: &[Painted]| texts.iter().filter(|p| p.text.starts_with('n') && p.text.len() == 3 && p.pos.x < 300.0 && p.pos.y > 250.0).count();
let before = d.settle();
assert!(before.iter().any(|p| p.text == "M"), "the calendar grid is drawn");
let rows_before = visible_notes(&before);
let month = before.iter().find(|p| p.text.ends_with("2026") && p.pos.x < 300.0).expect("the calendar title").pos;
d.click(month + egui::vec2(-20.0, 6.0));
let after = d.settle();
assert!(d.app.state.calendar_folded);
assert!(after.iter().all(|p| p.text != "M" || p.pos.x > 300.0), "the grid is gone");
assert!(after.iter().any(|p| p.text.ends_with("2026") && p.pos.x < 300.0), "the title row stays");
assert!(visible_notes(&after) > rows_before, "the tree gains rows: {rows_before} then {}", visible_notes(&after));
let folded_title = after.iter().find(|p| p.text.ends_with("2026") && p.pos.x < 300.0).expect("the title").pos;
d.click(folded_title + egui::vec2(-20.0, 6.0));
assert!(!d.app.state.calendar_folded, "and it unfolds again");
}
#[test]
fn the_performance_overlay_shows_frame_rate_memory_and_processor_use() {
let mut d = driver(&[("a.md", "# A\n")]);
d.settle();
assert!(d.settle().iter().all(|p| !p.text.contains(" fps")), "off by default");
d.app.run_command("view.performance", &json!({}));
let mut line = String::new();
for _ in 0..80 {
std::thread::sleep(std::time::Duration::from_millis(50));
line = d.frame(Vec::new()).iter().find(|p| p.text.contains(" fps")).map(|p| p.text.clone()).unwrap_or_default();
if cfg!(any(target_os = "macos", target_os = "linux")) && line.contains(" MB") {
break;
}
}
assert!(line.contains(" fps") && line.contains(" ms") && line.contains("CPU"), "{line:?}");
if cfg!(any(target_os = "macos", target_os = "linux")) {
assert!(line.contains(" MB"), "the process memory was read: {line:?}");
}
d.app.run_command("view.performance", &json!({}));
assert!(d.settle().iter().all(|p| !p.text.contains(" fps")), "and it goes away again");
}
#[test]
fn reading_mode_shows_the_note_but_a_click_and_typing_do_not_change_it() {
let mut d = driver(&[("r.md", "# R\n\nfirst paragraph\n")]);
d.app.open_note("r.md");
d.settle();
d.app.run_command("view.reading_mode", &json!({}));
let texts = d.settle();
let row = find(&texts, "first paragraph").pos + egui::vec2(20.0, 8.0);
d.click(row);
d.frame(vec![Event::Text("ZZZ".to_owned())]);
d.settle();
let text = d.app.session.buffer("r.md").map(|buffer| buffer.text().to_owned()).or_else(|| d.app.session.read_note("r.md").ok()).unwrap_or_default();
assert!(!text.contains("ZZZ"), "reading mode took the typing: {text:?}");
d.app.run_command("view.reading_mode", &json!({}));
d.settle();
let row = find(&d.settle(), "first paragraph").pos + egui::vec2(20.0, 8.0);
d.click(row);
d.frame(vec![Event::Text("ZZZ".to_owned())]);
d.settle();
let text = d.app.editing.tabs.get("r.md").map(|tab| tab.editor.text.clone()).unwrap_or_default();
assert!(text.contains("ZZZ"), "editing works again once reading mode is off: {text:?}");
}
#[test]
fn the_trash_tab_lists_a_deleted_note_restores_it_and_empties_on_the_second_click() {
let mut d = driver(&[("keep.md", "# Keep\n"), ("gone.md", "# Gone\n"), ("also.md", "# Also\n")]);
d.app.run_command("note.delete", &json!({"path": "gone.md"}));
d.app.run_command("note.delete", &json!({"path": "also.md"}));
d.settle();
d.app.run_command("view.trash", &json!({}));
let texts = d.settle();
assert!(texts.iter().any(|p| p.text.starts_with("Trash")), "{:?}", names(&texts));
assert!(texts.iter().any(|p| p.text == "gone" || p.text == "also"), "the deleted notes are listed: {:?}", names(&texts));
let restore = texts.iter().filter(|p| p.text == "Restore").map(|p| p.pos).next().expect("a Restore button");
d.click(restore + egui::vec2(10.0, 6.0));
d.settle();
let root = d.app.session.vault().root().to_path_buf();
assert!(root.join("gone.md").exists() != root.join("also.md").exists(), "one of the two came back");
let empty = find(&d.settle(), "Empty trash").pos + egui::vec2(10.0, 6.0);
d.click(empty);
assert_eq!(d.app.session.trash_list().expect("list").len(), 1, "the first click only arms the button");
let confirm = find(&d.settle(), "Click again to erase").pos + egui::vec2(10.0, 6.0);
d.click(confirm);
d.settle();
assert_eq!(d.app.session.trash_list().expect("list").len(), 0, "the second click erased it");
}
#[test]
fn search_results_can_be_ordered_by_name_and_collapsed_to_titles() {
let mut d =
driver(&[("mid.md", "zebrafish zebrafish zebrafish zebrafish\n"), ("zed.md", "zebrafish zebrafish\n"), ("alpha.md", "a lonely zebrafish line\n")]);
d.app.state.sidebar_tab = notescraft_ui_egui::sidebar_tabs::SidebarTab::Search;
d.app.search.set_query("zebrafish".to_owned(), 0.0);
let order = |texts: &[Painted]| {
let mut rows: Vec<(f32, &str)> = texts
.iter()
.filter(|p| p.pos.x < 300.0 && matches!(p.text.as_str(), "mid.md" | "zed.md" | "alpha.md"))
.map(|p| (p.pos.y, p.text.as_str()))
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
rows.into_iter().map(|(_, name)| name.to_owned()).collect::<Vec<_>>()
};
let mut texts = Vec::new();
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
for _ in 0..500 {
std::thread::sleep(std::time::Duration::from_millis(10));
texts = d.frame(Vec::new());
if order(&texts).len() == 3 {
break;
}
}
let ranked = order(&texts);
assert_eq!(ranked.len(), 3, "{:?}", names(&texts));
assert_eq!(ranked.first().map(String::as_str), Some("mid.md"), "most matches first: {ranked:?}");
let pill = |texts: &[Painted], label: &str| find(texts, label).pos + egui::vec2(8.0, 6.0);
d.click(pill(&texts, "Relevance"));
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
for _ in 0..500 {
std::thread::sleep(std::time::Duration::from_millis(10));
texts = d.frame(Vec::new());
if order(&texts) == ["alpha.md", "mid.md", "zed.md"] {
break;
}
}
assert_eq!(order(&texts), ["alpha.md", "mid.md", "zed.md"], "A to Z");
d.click(pill(&texts, "A to Z"));
// The search runs on a worker: pump frames until the expected rows show (a loaded machine needs longer than a fixed count).
for _ in 0..500 {
std::thread::sleep(std::time::Duration::from_millis(10));
texts = d.frame(Vec::new());
if order(&texts) == ["zed.md", "mid.md", "alpha.md"] {
break;
}
}
assert_eq!(order(&texts), ["zed.md", "mid.md", "alpha.md"], "Z to A");
assert!(texts.iter().any(|p| p.text.contains("lonely")), "the matching lines show");
d.click(pill(&texts, "Collapse"));
let collapsed = d.settle();
assert!(collapsed.iter().all(|p| !p.text.contains("lonely")), "collapsed: only titles remain");
assert!(collapsed.iter().any(|p| p.text == "Expand"));
}
#[test]
fn a_table_gets_a_row_and_a_column_from_the_controls_under_it() {
let mut d = driver(&[("t.md", "# T\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nafter\n")]);
d.app.open_note("t.md");
let texts = d.settle();
d.click(find(&texts, "+ row").pos + egui::vec2(8.0, 6.0));
let text = |d: &Driver| d.app.editing.tabs.get("t.md").map(|tab| tab.editor.text.clone()).unwrap_or_default();
assert!(text(&d).contains("| 1 | 2 |\n| | |\nafter") || text(&d).contains("| 1 | 2 |\n| | |\n\nafter"), "{:?}", text(&d));
let texts = d.settle();
d.click(find(&texts, "+ column").pos + egui::vec2(8.0, 6.0));
assert!(text(&d).starts_with("# T\n\n| a | b | |\n| --- | --- | --- |\n| 1 | 2 | |\n"), "{:?}", text(&d));
let texts = d.settle();
d.click(find(&texts, "− row").pos + egui::vec2(8.0, 6.0));
let texts = d.settle();
d.click(find(&texts, "− column").pos + egui::vec2(8.0, 6.0));
assert!(text(&d).starts_with("# T\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n"), "{:?}", text(&d));
assert!(text(&d).ends_with("after\n"));
}
#[test]
fn a_heading_folds_its_section_in_the_source_view_from_the_margin_chevron() {
let text = "# One\nintro\n## Two\nbody two\nmore two\n## Three\nbody three\n";
let mut d = driver(&[("f.md", text)]);
d.app.open_note("f.md");
d.settle();
d.app.editing.set_view("f.md", notescraft_ui_egui::center::ViewMode::Markdown);
let buffer_height = |texts: &[Painted]| texts.iter().find(|p| p.text == text).map(|p| p.galley.size().y);
let before = d.settle();
let tall = buffer_height(&before).expect("the source buffer is painted");
let two = d.app.editing.tabs.get("f.md").and_then(|tab| tab.editor.text.find("## Two")).expect("heading");
let row = before.iter().find(|p| p.text == text).expect("buffer");
let at = row.pos + row.galley.pos_from_cursor(egui::text::CCursor::new(two)).center().to_vec2();
d.click(Pos2::new(row.pos.x - 18.0, at.y));
assert!(d.app.editing.tabs.get("f.md").is_some_and(|tab| tab.editor.folded.contains("## Two")), "the heading is folded");
let short = buffer_height(&d.settle()).expect("painted");
assert!(short < tall - 20.0, "the section's lines take no room: {tall} then {short}");
let again = d.settle();
let row = again.iter().find(|p| p.text == text).expect("buffer");
let at = row.pos + row.galley.pos_from_cursor(egui::text::CCursor::new(two)).center().to_vec2();
d.click(Pos2::new(row.pos.x - 18.0, at.y));
assert!(d.app.editing.tabs.get("f.md").is_some_and(|tab| tab.editor.folded.is_empty()), "a second click unfolds it");
assert_eq!(d.app.editing.tabs.get("f.md").map(|tab| tab.editor.text.as_str()), Some(text), "folding never changes the text");
}
#[test]
fn the_versions_tab_lists_a_snapshot_and_restores_it_over_the_current_text() {
let mut d = driver(&[("n.md", "first\n")]);
d.app.session.open_buffer("n.md").expect("buffer");
d.app.session.buffer_mut("n.md").expect("buffer").edit(0..5, "second").expect("edit");
d.app.session.save_buffer("n.md").expect("save");
d.app.session.close_buffer("n.md", true).expect("close");
d.app.open_note("n.md");
d.settle();
if let Some(tab) = d.app.editing.tabs.get_mut("n.md") {
tab.editor.text = String::from("changed\n");
}
d.app.run_command("view.note_versions", &json!({}));
let texts = d.settle();
assert!(texts.iter().any(|p| p.text.starts_with("Versions of")), "{:?}", names(&texts));
let compare = find(&texts, "Compare").pos + egui::vec2(10.0, 6.0);
d.click(compare);
assert!(d.app.source.diff.is_some(), "Compare opens the diff page");
d.app.source.diff = None;
d.app.run_command("view.note_versions", &json!({}));
let texts = d.settle();
d.click(find(&texts, "Restore").pos + egui::vec2(10.0, 6.0));
d.settle();
assert_eq!(d.app.editing.tabs.get("n.md").map(|tab| tab.editor.text.as_str()), Some("second\n"), "the snapshot is back in the editor");
}
#[test]
fn cmd_enter_in_an_open_visual_block_ticks_the_task() {
let mut d = driver(&[("t.md", "# T\n\n- [ ] buy milk\n")]);
d.app.open_note("t.md");
let texts = d.settle();
let row = texts.iter().find(|p| p.text.contains("buy milk") && p.pos.x > 300.0).expect("the task row").pos;
d.click(row + egui::vec2(60.0, 8.0));
d.settle();
let key = Event::Key {
key: egui::Key::Enter,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers { command: true, ..Default::default() },
};
d.frame(vec![key]);
d.settle();
let text = d.app.editing.tabs.get("t.md").map(|tab| tab.editor.text.clone()).unwrap_or_default();
assert!(text.contains("- [x] buy milk"), "{text:?}");
}