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

379 lines
14 KiB
Rust

//! Editing the Bases table in place, driven headless over a 20-note vault: typed editors write the
//! note's frontmatter through `base.row.set`, selections copy and paste as TSV, Cmd+Z puts the old
//! value back, read-only cells and read-only vaults offer no editor, and the New button opens its row.
use std::path::PathBuf;
use egui::accesskit::Role;
use egui::{Event, Key, Modifiers, Rect, Vec2};
use egui_kittest::kittest::Queryable;
use notescraft_engine::{Registry, Session, register_core};
use notescraft_themes::builtin;
use notescraft_ui_egui::base_view::{self, BaseView};
use notescraft_ui_egui::theme::Tokens;
/// Columns of the fixture base, in order.
const COLUMNS: usize = 7;
const GENRE: usize = 1;
const YEAR: usize = 2;
const DONE: usize = 3;
const TAGS: usize = 4;
const DUE: usize = 5;
const SIZE: usize = 6;
const BASE: &str = "views:
- type: table
name: Notes
filters:
and:
- file.inFolder(\"Notes\")
order:
- file.name
- note.genre
- note.year
- note.done
- note.tags
- note.due
- file.size
";
struct Tab {
view: BaseView,
text: String,
tokens: Tokens,
session: Session,
registry: Registry<Session>,
writable: bool,
warmed: bool,
}
fn vault(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("notescraft-base-edit-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Notes")).expect("notes folder");
for i in 0..20 {
let genre = if i % 2 == 0 { "scifi" } else { "crime" };
let text = format!("---\ngenre: {genre}\nyear: {}\ndone: false\ntags:\n - book\ndue: 2024-01-01\n---\nBook {i}\n", 1950 + i * 5);
std::fs::write(root.join(format!("Notes/note-{i:02}.md")), text).expect("note");
}
std::fs::write(root.join("Notes.base"), BASE).expect("base");
root
}
fn open(name: &str, writable: bool) -> (egui_kittest::Harness<'static, Tab>, PathBuf) {
let root = vault(name);
let session = if writable { Session::open(&root) } else { Session::open_read_only(&root) }.expect("the vault opens");
let mut registry = Registry::new();
register_core(&mut registry).expect("commands register");
let state = Tab {
view: BaseView::new("Notes.base"),
text: BASE.to_owned(),
tokens: Tokens::new(builtin("catppuccin-mocha").expect("theme")),
session,
registry,
writable,
warmed: false,
};
let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(1500.0, 1000.0)).with_max_steps(300).build_ui_state(
|ui, tab: &mut Tab| {
if !tab.warmed {
notescraft_ui_egui::fonts::install(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
tab.warmed = true;
return;
}
base_view::show(ui, &tab.tokens, &mut tab.view, &mut tab.text, tab.writable);
let Tab { view, session, registry, .. } = tab;
if view.pump(&mut |id, params| registry.run(id, session, params).map_err(|error| error.to_string())) {
ui.ctx().request_repaint();
}
},
state,
);
for _ in 0..4 {
h.run();
}
(h, root)
}
/// The cell rectangles in row-major order.
fn cells(h: &egui_kittest::Harness<'static, Tab>) -> Vec<Rect> {
let mut rects: Vec<Rect> = h.get_all_by_role(Role::Cell).map(|n| n.rect()).collect();
rects.sort_by(|a, b| (a.top().round(), a.left()).partial_cmp(&(b.top().round(), b.left())).expect("finite"));
rects
}
fn cell(h: &egui_kittest::Harness<'static, Tab>, row: usize, col: usize) -> Rect {
cells(h)[row * COLUMNS + col]
}
fn click(h: &mut egui_kittest::Harness<'static, Tab>, row: usize, col: usize, modifiers: Modifiers) {
let at = cell(h, row, col).center();
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(at));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers });
}
h.run();
h.run();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run();
}
fn double_click(h: &mut egui_kittest::Harness<'static, Tab>, row: usize, col: usize) {
let at = cell(h, row, col).center();
h.input_mut().events.push(Event::PointerMoved(at));
for _ in 0..2 {
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
}
}
h.run();
h.run();
}
fn press(h: &mut egui_kittest::Harness<'static, Tab>, modifiers: Modifiers, key: Key) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers });
h.input_mut().events.push(Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers });
h.run();
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run();
h.run();
}
fn type_text(h: &mut egui_kittest::Harness<'static, Tab>, text: &str) {
press(h, Modifiers::COMMAND, Key::A);
h.input_mut().events.push(Event::Text(text.to_owned()));
h.run();
}
fn note(root: &std::path::Path, i: usize) -> String {
std::fs::read_to_string(root.join(format!("Notes/note-{i:02}.md"))).expect("the note")
}
fn settle(h: &mut egui_kittest::Harness<'static, Tab>) {
for _ in 0..5 {
h.run();
}
}
#[test]
fn editing_a_text_cell_writes_the_property_and_leaves_the_body() {
let (mut h, root) = open("text", true);
let before = note(&root, 0);
double_click(&mut h, 0, GENRE);
assert!(h.state().view.is_editing(), "a double click opens the editor");
type_text(&mut h, "western");
press(&mut h, Modifiers::NONE, Key::Enter);
settle(&mut h);
let after = note(&root, 0);
assert!(after.contains("genre: western"), "{after}");
assert_eq!(after.replace("genre: western", "genre: scifi"), before, "only that line changed, body identical");
assert!(!h.state().view.is_editing());
assert_eq!(h.state().view.cursor(), Some((1, GENRE)), "Enter moves the cursor down");
assert_eq!(h.state().view.data().and_then(|d| d.row(0)).and_then(|r| r.cells.get(GENRE)).and_then(|v| v.as_str()), Some("western"), "the row refreshed");
}
#[test]
fn enter_edits_the_cursor_cell_and_escape_cancels() {
let (mut h, root) = open("escape", true);
let before = note(&root, 0);
click(&mut h, 0, GENRE, Modifiers::NONE);
press(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().view.is_editing());
type_text(&mut h, "never written");
press(&mut h, Modifiers::NONE, Key::Escape);
settle(&mut h);
assert!(!h.state().view.is_editing());
assert_eq!(note(&root, 0), before);
}
#[test]
fn a_checkbox_click_toggles_the_property() {
let (mut h, root) = open("checkbox", true);
click(&mut h, 2, DONE, Modifiers::NONE);
settle(&mut h);
assert!(note(&root, 2).contains("done: true"), "{}", note(&root, 2));
assert!(note(&root, 3).contains("done: false"));
click(&mut h, 2, DONE, Modifiers::NONE);
settle(&mut h);
assert!(note(&root, 2).contains("done: false"));
}
#[test]
fn a_number_that_is_not_one_is_refused_with_a_message() {
let (mut h, root) = open("number", true);
let before = note(&root, 0);
double_click(&mut h, 0, YEAR);
type_text(&mut h, "abc");
press(&mut h, Modifiers::NONE, Key::Enter);
settle(&mut h);
h.get_by_label_contains("is not a number");
assert!(h.state().view.is_editing(), "the editor stays open on a refused value");
assert_eq!(note(&root, 0), before);
type_text(&mut h, "1999");
press(&mut h, Modifiers::NONE, Key::Enter);
settle(&mut h);
assert!(note(&root, 0).contains("year: 1999"), "{}", note(&root, 0));
}
#[test]
fn an_invalid_date_is_refused_and_nothing_changes() {
let (mut h, root) = open("date", true);
let before = note(&root, 0);
double_click(&mut h, 0, DUE);
type_text(&mut h, "2024-02-31");
press(&mut h, Modifiers::NONE, Key::Enter);
settle(&mut h);
h.get_by_label_contains("is not a date");
assert_eq!(note(&root, 0), before);
}
#[test]
fn a_tags_cell_adds_a_normalised_chip() {
let (mut h, root) = open("tags", true);
click(&mut h, 1, TAGS, Modifiers::NONE);
press(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().view.is_editing());
h.input_mut().events.push(Event::Text("#new tag".to_owned()));
h.run();
press(&mut h, Modifiers::NONE, Key::Enter);
h.get_by_label("Remove new-tag");
press(&mut h, Modifiers::NONE, Key::Enter);
settle(&mut h);
let after = note(&root, 1);
assert!(after.contains("tags: [book, new-tag]"), "{after}");
}
#[test]
fn file_properties_are_read_only() {
let (mut h, root) = open("readonly", true);
let before = note(&root, 0);
click(&mut h, 0, SIZE, Modifiers::NONE);
press(&mut h, Modifiers::NONE, Key::Enter);
assert_eq!(h.state().view.notice(), Some("File properties cannot be edited"));
double_click(&mut h, 0, SIZE);
assert!(!h.state().view.is_editing());
assert_eq!(note(&root, 0), before);
}
#[test]
fn a_read_only_vault_has_no_editors() {
let (mut h, root) = open("vault-readonly", false);
let before = note(&root, 0);
click(&mut h, 0, GENRE, Modifiers::NONE);
press(&mut h, Modifiers::NONE, Key::Enter);
double_click(&mut h, 0, YEAR);
click(&mut h, 0, DONE, Modifiers::NONE);
settle(&mut h);
assert!(!h.state().view.is_editing());
assert_eq!(note(&root, 0), before);
assert_eq!(h.state().view.cursor(), Some((0, DONE)), "cells still select");
}
#[test]
fn a_selection_copies_as_tsv() {
let (mut h, _root) = open("copy", true);
click(&mut h, 0, GENRE, Modifiers::NONE);
click(&mut h, 1, YEAR, Modifiers::SHIFT);
assert_eq!(h.state().view.selected_cells(), 4);
h.input_mut().events.push(Event::Copy);
h.run();
assert_eq!(h.state().view.last_copied(), "scifi\t1950\ncrime\t1955");
click(&mut h, 3, GENRE, Modifiers::COMMAND);
assert_eq!(h.state().view.selected_cells(), 5, "Cmd-click adds a cell");
click(&mut h, 3, GENRE, Modifiers::COMMAND);
assert_eq!(h.state().view.selected_cells(), 4, "and removes it again");
}
#[test]
fn pasting_a_block_writes_every_cell_and_cmd_z_takes_one_back() {
let (mut h, root) = open("paste", true);
click(&mut h, 0, GENRE, Modifiers::NONE);
h.input_mut().events.push(Event::Paste("western\t2001\nnoir\t2002".to_owned()));
h.run();
settle(&mut h);
assert!(note(&root, 0).contains("genre: western") && note(&root, 0).contains("year: 2001"), "{}", note(&root, 0));
assert!(note(&root, 1).contains("genre: noir") && note(&root, 1).contains("year: 2002"), "{}", note(&root, 1));
assert_eq!(h.state().view.notice(), Some("Pasted 4 cells"));
press(&mut h, Modifiers::COMMAND, Key::Z);
settle(&mut h);
assert!(note(&root, 1).contains("year: 1955"), "the last cell is back: {}", note(&root, 1));
assert!(note(&root, 1).contains("genre: noir"), "one step undoes one cell");
}
#[test]
fn a_paste_that_does_not_fit_writes_nothing() {
let (mut h, root) = open("paste-bad", true);
let before = note(&root, 19);
click(&mut h, 19, GENRE, Modifiers::NONE);
h.input_mut().events.push(Event::Paste("a\nb".to_owned()));
h.run();
settle(&mut h);
assert_eq!(note(&root, 19), before);
assert_eq!(h.state().view.notice(), Some("The pasted block does not fit in the table"));
}
#[test]
fn delete_clears_the_selected_cells() {
let (mut h, root) = open("clear", true);
click(&mut h, 0, GENRE, Modifiers::NONE);
press(&mut h, Modifiers::NONE, Key::Delete);
settle(&mut h);
assert!(!note(&root, 0).contains("genre"), "{}", note(&root, 0));
assert!(note(&root, 0).contains("year: 1950"));
}
#[test]
fn arrows_tab_and_paging_move_the_cursor() {
let (mut h, _root) = open("keys", true);
click(&mut h, 0, 0, Modifiers::NONE);
press(&mut h, Modifiers::NONE, Key::ArrowDown);
press(&mut h, Modifiers::NONE, Key::ArrowDown);
press(&mut h, Modifiers::NONE, Key::ArrowRight);
assert_eq!(h.state().view.cursor(), Some((2, 1)));
press(&mut h, Modifiers::NONE, Key::Tab);
assert_eq!(h.state().view.cursor(), Some((2, 2)));
press(&mut h, Modifiers::SHIFT, Key::Tab);
assert_eq!(h.state().view.cursor(), Some((2, 1)));
press(&mut h, Modifiers::NONE, Key::End);
assert_eq!(h.state().view.cursor(), Some((2, COLUMNS - 1)));
press(&mut h, Modifiers::NONE, Key::Home);
assert_eq!(h.state().view.cursor(), Some((2, 0)));
press(&mut h, Modifiers::NONE, Key::PageDown);
assert_eq!(h.state().view.cursor(), Some((19, 0)), "a page is longer than the 20 rows");
press(&mut h, Modifiers::NONE, Key::PageUp);
assert_eq!(h.state().view.cursor(), Some((0, 0)));
press(&mut h, Modifiers::COMMAND, Key::A);
assert_eq!(h.state().view.selected_cells(), 20 * COLUMNS);
}
#[test]
fn the_new_button_opens_the_first_editable_cell_of_its_row() {
let (mut h, root) = open("new", true);
h.get_by_label("New").click();
for _ in 0..8 {
h.run();
}
assert!(h.state().view.is_editing(), "the new row is being edited");
let (row, col) = h.state().view.cursor().expect("a cursor");
assert_eq!(col, 0);
let path = h.state().view.data().and_then(|d| d.row(row)).map(|r| r.path.clone()).expect("the new row");
assert!(root.join(&path).is_file(), "{path} exists");
assert_eq!(h.state().view.data().map(|d| d.total), Some(21));
}
#[test]
fn editing_the_file_name_renames_the_note() {
let (mut h, root) = open("rename", true);
click(&mut h, 0, 0, Modifiers::NONE);
press(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().view.is_editing());
type_text(&mut h, "renamed");
press(&mut h, Modifiers::NONE, Key::Enter);
settle(&mut h);
assert!(root.join("Notes/renamed.md").is_file());
assert!(!root.join("Notes/note-00.md").exists());
}