Files
Marcos Paulo 53b8dc470b feat: rebrand to PaperCraft; variable modes, lint_design, Design tab, icon, docs and skills
Product, crate and binaries are now PaperCraft/papercraft (papercraft-cli, papercraft-mcp);
projects, icon sets and theme in the old paper-rs locations are still read and .paper-rs files
still open. set_variables {mode} and set_variable_mode switch whole themes; lint_design checks
contrast, type scale, 4px grid, touch targets, layer names and hard-coded colors; get_variables
exports css/json tokens; the sidebar gets a Design tab for variables, modes and components.
New app icon, README, docs, ROADMAP and agent skills.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 12:08:55 -03:00

318 lines
10 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.
//! Milestones 1–2: local projects, the home screen, autosave and pages.
mod common;
use common::*;
use papercraft::demo;
use papercraft::editor::Editor;
use papercraft::geom::{Rect, v2};
use papercraft::model::{Color, Node, NodeId, NodeKind};
use papercraft::project::{PageData, Project, Store};
use papercraft::render::frame;
use papercraft::session::{self, Session};
use papercraft::ui::UiState;
use papercraft::ui::home::{HomeAction, HomeView};
fn temp_store(tag: &str) -> Store {
let root = std::env::temp_dir().join(format!("papercraft-{tag}-{:016x}", fastrand::u64(..)));
Store::open(root).unwrap()
}
/// A few projects with different content, as a user would have them.
fn populate(store: &Store) -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
let showcase = demo::showcase();
let boards: Vec<(&str, papercraft::model::Document)> = vec![
("Launch plan", showcase.doc.clone()),
("Brand moodboard", demo::moodboard().doc),
("Onboarding flow", demo::flow().doc),
("Empty ideas", papercraft::model::Document::default()),
];
for (i, (name, doc)) in boards.into_iter().enumerate() {
let p = store
.create_with_pages(
name,
vec![PageData {
id: papercraft::project::new_id(),
name: "Page 1".into(),
doc,
camera: None,
}],
)
.unwrap();
let mut m = Project::open(&p.dir).unwrap();
// Stagger modification times so the grid order is deterministic.
m.manifest.modified = 1_760_000_000_000 - i as u64 * 3_600_000 * (i as u64 + 1);
m.write_manifest().unwrap();
dirs.push(p.dir);
}
dirs
}
#[test]
fn m1_home_screen_lists_projects_with_thumbnails() {
let store = temp_store("home");
let dirs = populate(&store);
let Some(g) = gpu() else { return };
for d in &dirs {
let p = Project::open(d).unwrap();
let doc = &p.load_pages().unwrap()[0].doc;
if let Some(png) = session::render_thumbnail(g, doc).unwrap() {
std::fs::write(p.thumbnail_path(), png).unwrap();
}
}
// A trashed project shows up in the trash view.
let (old, _) = store.create("Old sketches").unwrap();
store.trash(&old.dir).unwrap();
let mut st = UiState {
home: Some(HomeView::load(&store, 1_760_000_600_000)),
..Default::default()
};
let hv = st.home.as_ref().unwrap();
let names: Vec<&str> = hv
.entries
.iter()
.map(|e| e.manifest.name.as_str())
.collect();
assert_eq!(
names,
[
"Launch plan",
"Brand moodboard",
"Onboarding flow",
"Empty ideas"
]
);
assert_eq!(
hv.entries.iter().filter(|e| e.thumbnail.is_some()).count(),
3
);
assert_eq!(hv.trash.len(), 1);
let mut ed = Editor::default();
let img = frame::capture(g, &mut ed, &mut st, [1280.0, 800.0], 2.0, |_| {});
img.save(shot("milestone1_home.png")).unwrap();
// Thumbnails are drawn: the first card has non-background pixels (the demo frames).
let card = Rect::new(220.0, 70.0, 232.0, 148.0);
assert!(count(&img, card, |p| p[0] < 200 || p[2] < 200) > 200);
st.home.as_mut().unwrap().show_trash = true;
let img = frame::capture(g, &mut ed, &mut st, [1280.0, 800.0], 2.0, |_| {});
img.save(shot("milestone1_trash.png")).unwrap();
std::fs::remove_dir_all(&store.root).ok();
}
#[test]
fn m1_autosave_survives_reopen_and_crash_temp_files() {
let store = temp_store("autosave");
let mut sess = Session::new(store.clone());
let mut ed = Editor::default();
sess.create(&mut ed, "Crashy").unwrap();
let dir = sess.project.as_ref().unwrap().dir.clone();
ed.add_node(
Node::new(NodeId(0), NodeKind::Rect, Rect::new(0.0, 0.0, 50.0, 40.0)),
None,
);
let t = std::time::Instant::now();
assert!(!sess.autosave_due(&ed, t), "debounced");
assert!(sess.autosave_due(&ed, t + session::AUTOSAVE_DELAY));
sess.save(&mut ed).unwrap();
// Simulate a crash in the middle of the next write: a stray temp file next to the page.
let page = Project::open(&dir).unwrap().page_path(ed.active_page_id());
let tmp = page.with_file_name(format!(
".{}.123-deadbeef.tmp",
page.file_name().unwrap().to_string_lossy()
));
std::fs::write(&tmp, "{ truncated").unwrap();
let mut ed2 = Editor::default();
Session::new(store.clone()).open(&mut ed2, &dir).unwrap();
assert_eq!(ed2.doc.nodes.len(), 1);
assert!(!tmp.exists(), "stale temp files are cleaned on open");
std::fs::remove_dir_all(&store.root).ok();
}
#[test]
fn m1_home_actions_rename_duplicate_trash() {
let store = temp_store("actions");
let dirs = populate(&store);
let mut hv = HomeView::load(&store, papercraft::project::now_ms());
hv.actions = vec![
HomeAction::Rename(dirs[0].clone(), "Q4 launch".into()),
HomeAction::Duplicate(dirs[1].clone()),
HomeAction::Trash(dirs[3].clone()),
];
for a in std::mem::take(&mut hv.actions) {
match a {
HomeAction::Rename(d, n) => {
store.rename(&d, &n).unwrap();
}
HomeAction::Duplicate(d) => {
store.duplicate(&d).unwrap();
}
HomeAction::Trash(d) => {
store.trash(&d).unwrap();
}
_ => unreachable!(),
}
}
hv.refresh(&store, papercraft::project::now_ms());
let mut names: Vec<String> = hv.entries.iter().map(|e| e.manifest.name.clone()).collect();
names.sort();
assert_eq!(
names,
[
"Brand moodboard",
"Brand moodboard copy",
"Onboarding flow",
"Q4 launch"
]
);
assert_eq!(hv.trash[0].manifest.name, "Empty ideas");
std::fs::remove_dir_all(&store.root).ok();
}
#[test]
fn m2_pages_keep_separate_canvases_history_and_cameras() {
let mut ed = editor(1000.0, 700.0);
ed.add_node(
Node::new(NodeId(0), NodeKind::Rect, Rect::new(0.0, 0.0, 100.0, 100.0)),
None,
);
ed.camera.pan = v2(-50.0, -50.0);
let p2 = ed.add_page();
assert_eq!(ed.active_page, p2);
assert!(
ed.doc.nodes.is_empty(),
"a new page is a fresh infinite canvas"
);
ed.add_node(
Node::new(
NodeId(0),
NodeKind::Ellipse,
Rect::new(5000.0, -3000.0, 10.0, 10.0),
),
None,
);
ed.add_node(
Node::new(
NodeId(0),
NodeKind::Ellipse,
Rect::new(-90000.0, 7e5, 10.0, 10.0),
),
None,
);
ed.switch_page(0);
assert_eq!(ed.doc.nodes.len(), 1);
assert_eq!(ed.camera.pan, v2(-50.0, -50.0), "camera is per page");
ed.undo();
assert!(ed.doc.nodes.is_empty(), "undo is per page");
ed.switch_page(1);
assert_eq!(ed.doc.nodes.len(), 2);
ed.undo();
assert_eq!(ed.doc.nodes.len(), 1);
ed.redo();
assert_eq!(ed.doc.nodes.len(), 2);
let b = ed.doc.content_bounds().unwrap();
assert!(
b.w > 90000.0 && b.h > 7e5,
"content spans a huge canvas: {b:?}"
);
ed.rename_page(1, "Far away");
let dup = ed.duplicate_page(1);
assert_eq!(ed.pages[dup].name, "Far away copy");
assert_eq!(ed.doc.nodes.len(), 2);
ed.move_page(dup, 0);
assert_eq!(ed.pages[0].name, "Far away copy");
assert_eq!(ed.active_page, 0);
assert!(ed.delete_page(0).is_some());
assert_eq!(ed.pages.len(), 2);
assert_eq!(ed.page_name(), "Page 1");
assert!(ed.delete_page(0).is_some());
assert!(
ed.delete_page(0).is_none(),
"the last page can't be deleted"
);
}
#[test]
fn m2_copy_paste_between_pages_and_projects() {
let mut ed = editor(1000.0, 700.0);
let mut n = Node::new(NodeId(0), NodeKind::Rect, Rect::new(10.0, 20.0, 30.0, 40.0));
n.style.set_fill(Some(Color::rgb(200, 10, 10)));
let id = ed.add_node(n, None);
ed.selection = vec![id];
key(
&mut ed,
papercraft::editor::Key::Char('c'),
papercraft::editor::Modifiers::cmd(),
);
ed.add_page();
key(
&mut ed,
papercraft::editor::Key::Char('v'),
papercraft::editor::Modifiers::cmd(),
);
assert_eq!(ed.doc.nodes.len(), 1);
let pasted = ed.doc.get(ed.selection[0]).unwrap();
assert_eq!(pasted.style.fill(), Some(Color::rgb(200, 10, 10)));
assert_eq!(
pasted.rect.min(),
v2(10.0, 20.0),
"paste on another page keeps the position"
);
// Another project: the clipboard survives opening a different project.
let store = temp_store("clip");
let mut sess = Session::new(store.clone());
sess.create(&mut ed, "Other").unwrap();
assert!(ed.doc.nodes.is_empty());
key(
&mut ed,
papercraft::editor::Key::Char('v'),
papercraft::editor::Modifiers::cmd(),
);
assert_eq!(ed.doc.nodes.len(), 1);
std::fs::remove_dir_all(&store.root).ok();
}
#[test]
fn m2_pages_panel_screenshot() {
let Some(g) = gpu() else { return };
let page = |name: &str, doc: papercraft::model::Document| PageData {
id: papercraft::project::new_id(),
name: name.into(),
doc,
camera: None,
};
let mut ed = Editor::default();
ed.open_pages(
vec![
page("Launch plan", demo::showcase().doc),
page("Moodboard", demo::moodboard().doc),
page("Flows", demo::flow().doc),
page("Archive", papercraft::model::Document::default()),
],
None,
);
ed.switch_page(2);
assert_eq!(ed.page_name(), "Flows");
let mut st = UiState {
project_name: "Q4 launch".into(),
save_state: "Saved".into(),
..Default::default()
};
let img = frame::capture(g, &mut ed, &mut st, [1440.0, 900.0], 2.0, |ed| {
ed.zoom_to_fit()
});
img.save(shot("milestone2_pages.png")).unwrap();
// The canvas shows the Flows page: its blue buttons are on screen.
let blue = Color::rgb(0x2f, 0x6f, 0xf3);
assert!(
count(&img, Rect::new(260.0, 60.0, 900.0, 800.0), |p| close(
p, blue, 12
)) > 500
);
}