Files
Marcos Paulo 77d538dd97
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: Bases Cards, List, embeds, Map view, Maps settings and inline cell editing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 04:45:32 -03:00

212 lines
7.6 KiB
Rust

//! Bases inside notes, driven headless over a fixture vault: a ```` ```base ```` block renders a
//! table, `![[x.base#Reading list]]` picks the view, a self-embed shows the cycle chip, a base too
//! deep in embeds shows the depth chip, and `this` is the embedding note.
use std::sync::Arc;
use egui::Vec2;
use egui_kittest::kittest::Queryable;
use notescraft_engine::{Registry, Session, register_core};
use notescraft_themes::builtin;
use notescraft_ui_egui::base_view::embedded::Host;
use notescraft_ui_egui::note_view::{CalloutFolds, EmbedNotes, set_embeds, show_note};
use notescraft_ui_egui::theme::Tokens;
struct Page {
doc: notescraft_markdown::Document,
tokens: Tokens,
session: Session,
registry: Registry<Session>,
host: Host,
folds: CalloutFolds,
warmed: bool,
clicked: Vec<String>,
}
const BOOKS: &str = "views:
- type: table
name: Everything
order:
- file.name
- note.year
- type: table
name: Reading list
filters:
and:
- file.inFolder(\"Books\")
order:
- file.name
- note.year
limit: 5
";
const MINE: &str = "filters:
and:
- file.inFolder(this.file.folder)
views:
- type: list
name: Siblings
order:
- file.name
";
fn vault(name: &str, notes: &[(&str, &str)]) -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!("notescraft-base-embed-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Books")).expect("books folder");
std::fs::create_dir_all(root.join("Notes")).expect("notes folder");
for i in 0..8 {
std::fs::write(root.join(format!("Books/book-{i}.md")), format!("---\nyear: {}\n---\nBook {i}\n", 1950 + i)).expect("book");
}
std::fs::write(root.join("Books.base"), BOOKS).expect("base");
for (path, text) in notes {
std::fs::write(root.join(path), text).expect("note");
}
root
}
fn page(name: &str, source: &str, extra: &[(&str, &str)]) -> egui_kittest::Harness<'static, Page> {
let mut notes = vec![("Notes/Page.md", source)];
notes.extend_from_slice(extra);
let session = Session::open(&vault(name, &notes)).expect("the vault opens");
let mut registry = Registry::new();
register_core(&mut registry).expect("commands register");
let doc = notescraft_markdown::parse(source);
let embeds = EmbedNotes::load(&session, "Notes/Page.md", &doc);
let state = Page {
doc,
tokens: Tokens::new(builtin("catppuccin-mocha").expect("theme")),
session,
registry,
host: Host::default(),
folds: CalloutFolds::default(),
warmed: false,
clicked: Vec::new(),
};
egui_kittest::Harness::builder().with_size(Vec2::new(900.0, 900.0)).with_max_steps(300).build_ui_state(
move |ui, page: &mut Page| {
if !page.warmed {
notescraft_ui_egui::fonts::install(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
page.warmed = true;
return;
}
set_embeds(ui.ctx(), Some(Arc::new(embeds.clone())));
page.host.publish(ui.ctx());
let shown = show_note(ui, &page.tokens, &page.doc, &|_: &str| true, &mut page.folds);
page.clicked.extend(shown.clicked_link.map(|link| link.target));
let Page { session, registry, host, .. } = page;
if host.pump(&mut |id, params| registry.run(id, session, params).map_err(|error| error.to_string())) {
ui.ctx().request_repaint();
}
},
state,
)
}
/// Every text the last frame painted, joined.
fn painted_text(h: &egui_kittest::Harness<'_, Page>) -> String {
h.output()
.shapes
.iter()
.filter_map(|clipped| if let egui::epaint::Shape::Text(text) = &clipped.shape { Some(text.galley.text().to_string()) } else { None })
.collect::<Vec<_>>()
.join("\n")
}
fn settle(h: &mut egui_kittest::Harness<'_, Page>, key: &str) {
for _ in 0..40 {
h.run();
if h.state().host.rows_painted(key).is_some_and(|rows| rows > 0) {
return;
}
}
}
const INLINE: &str = "# Page\n\n```base\nviews:\n - type: table\n name: Years\n order:\n - file.name\n - note.year\n filters:\n and:\n - file.inFolder(\"Books\")\n```\n\nafter\n";
#[test]
fn a_base_block_renders_a_table() {
let mut h = page("inline", INLINE, &[]);
settle(&mut h, "inline");
assert_eq!(h.state().host.len(), 1);
assert_eq!(h.state().host.rows_painted("inline"), Some(8), "the eight books");
h.get_by_label("Years");
assert!(painted_text(&h).contains("after"), "the text after the block still shows");
}
#[test]
fn a_file_embed_picks_the_view_after_the_hash() {
let mut h = page("pick", "![[Books.base#Reading list]]\n", &[]);
settle(&mut h, "Books.base");
assert_eq!(h.state().host.shown_view("Books.base"), Some(1));
let rows = h.state().host.rows_painted("Books.base").unwrap_or(0);
assert!((5..=6).contains(&rows), "the view's limit of 5 rows (and the spare one the scroller paints): {rows}");
h.get_by_label("Open base").click();
h.run();
assert_eq!(h.state().clicked, ["Books.base"]);
}
#[test]
fn a_file_embed_without_a_view_shows_tabs_for_the_first_view() {
let mut h = page("first", "![[Books.base]]\n", &[]);
settle(&mut h, "Books.base");
assert_eq!(h.state().host.shown_view("Books.base"), Some(0));
assert!(h.state().host.rows_painted("Books.base").is_some_and(|rows| rows >= 8), "every note and file shows");
}
#[test]
fn this_is_the_embedding_note() {
let source = format!("```base\n{MINE}```\n");
let mut h = page("this", &source, &[("Notes/Other.md", "o\n")]);
for _ in 0..40 {
h.run();
if h.state().host.rows_painted("inline").is_some_and(|rows| rows > 0) {
break;
}
}
assert_eq!(h.state().host.rows_painted("inline"), Some(2), "the two notes next to Notes/Page.md");
}
#[test]
fn a_self_embed_shows_the_cycle_chip_not_a_second_table() {
let source = format!("{INLINE}\n![[Page]]\n");
let mut h = page("cycle", &source, &[]);
settle(&mut h, "inline");
h.run();
assert!(painted_text(&h).contains("already open above"), "{}", painted_text(&h));
assert_eq!(h.state().host.len(), 1, "one table, the embed of itself does not draw another");
}
#[test]
fn a_base_nested_too_deep_shows_the_depth_chip() {
let extra = [("Notes/B.md", "![[C]]\n"), ("Notes/C.md", "![[D]]\n"), ("Notes/D.md", "![[Books.base]]\n")];
let mut h = page("depth", "![[B]]\n", &extra);
for _ in 0..10 {
h.run();
}
assert!(painted_text(&h).contains("nested too deeply"), "{}", painted_text(&h));
assert!(h.state().host.is_empty(), "no table was started");
}
fn save(h: &mut egui_kittest::Harness<'_, Page>, dir: &str, name: &str) {
let image = h.render().expect("renders");
std::fs::create_dir_all(dir).expect("dir");
image.save(std::path::Path::new(dir).join(name)).expect("png");
}
/// Renders a note with embedded bases: `NOTESCRAFT_BASE_PNG_DIR=<dir> cargo test --test base_embed -- --ignored`.
#[test]
#[ignore = "writes PNGs for a visual check"]
fn render_pngs() {
let Ok(dir) = std::env::var("NOTESCRAFT_BASE_PNG_DIR") else { return };
let source =
format!("# Reading notes\n\nSome words before the table.\n\n{INLINE}\nAnd the file, on its second view:\n\n![[Books.base#Reading list]]\n\nThe end.\n");
let mut h = page("png", &source, &[]);
settle(&mut h, "inline");
for _ in 0..10 {
h.run();
}
save(&mut h, &dir, "note-with-bases.png");
}