feat: lazy page loading, annotation layout, chat canvas tools

- Lazy project loading: the page in view first, the rest in the background;
  fix the endless repaint and memoise layer heights.
- Annotations: placement off screens, straight routing inside the target's
  screen, zoom-stable lines, badges below 60% zoom, Tidy, custom templates
  (upsert/delete_annotation_template).
- Chat: design-system digest each turn, Actions menu on a selection (fix lint,
  bind to tokens, use components, rename layers, agent prompts), Pick / area
  chips with crops, ⌥-click, ⌘K Ask, live agent outlines and counts, one run
  per MCP client with per-run revert, Stop shown as Stopped.
- MCP: set_viewport, viewport in get_basic_info/get_selection, screenshots
  with annotations, find_nodes substring search, get_tree_summary fullText,
  annotate tidy returns tidied count, not-an-instance lint.
- Fixes: write_html links cross-page components instead of copying them;
  instance roots keep their own layout when synced or swapped; CLI saves
  screenshots itself (the app writing to a removable volume froze it behind a
  macOS prompt), offline write lock, version check, --online.
- tools/install-local.sh installs the CLI and signs with a stable identity
  when present; Dockerfile adds the D-Bus packages the relay needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-10-24 01:54:55 -03:00
parent dcecdc31b5
commit 872b693373
63 changed files with 6012 additions and 550 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ FROM rust:slim AS build
# Must match the wasm-bindgen crate in Cargo.lock.
ARG WASM_BINDGEN=0.2.129
RUN rustup target add wasm32-unknown-unknown \
&& apt-get update && apt-get install -y --no-install-recommends binaryen pkg-config libssl-dev \
&& apt-get update && apt-get install -y --no-install-recommends binaryen pkg-config libssl-dev libdbus-1-dev \
&& rm -rf /var/lib/apt/lists/* \
&& cargo install wasm-bindgen-cli --version ${WASM_BINDGEN} --locked
COPY --from=vendor crates /vendor
@@ -21,7 +21,7 @@ COPY . .
RUN sh web/build.sh && cargo build --release --locked --bin papercraft-relay
FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends libssl3 ca-certificates \
RUN apt-get update && apt-get install -y --no-install-recommends libssl3 libdbus-1-3 ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 10001 papercraft
COPY --from=build /src/target/release/papercraft-relay /usr/local/bin/papercraft-relay
+25 -2
View File
@@ -423,6 +423,7 @@ impl App {
/// Export through a save (or folder) dialog.
fn export(&mut self, spec: crate::io::export::Spec) {
use crate::io::export;
self.ed.finish_loading();
let names: Vec<String> = self.ed.pages.iter().map(|p| p.name.clone()).collect();
let pages: Vec<(&str, &crate::model::Document)> = names
.iter()
@@ -706,6 +707,11 @@ impl App {
fn redraw(&mut self) {
let now = self.now();
self.ed.tick(now);
if let Some((act, keys)) = self.st.pending_action.take() {
let mut runs = std::mem::take(&mut self.runs);
crate::ui::actions::run(self, &mut runs, act, &keys, crate::project::format::now_ms());
self.runs = runs;
}
let dir = self
.session
.project
@@ -720,8 +726,11 @@ impl App {
// A chat turn that edited is over: close its run now, not after the 30 s idle timeout.
self.runs.finish(crate::project::format::now_ms(), &self.ed);
}
self.st.sync_runs(&mut self.runs);
self.st.sync_runs(&mut self.runs, &self.ed);
self.st.chat.tools = self.tool_dispatch().map(crate::chat::events::ToolDispatch);
if self.st.chat.gpu.is_none() {
self.st.chat.gpu = self.offscreen();
}
let Some(g) = self.gfx.as_mut() else { return };
let input = g.egui.take_egui_input(&g.window);
let mut l = frame::layout(&self.ctx, input, &mut self.ed, &mut self.st);
@@ -819,6 +828,12 @@ impl App {
}
// The laser trail fades out frame by frame, then the loop goes idle again.
let animating = self.ed.animating(now);
// Pages are still being read in the background: look again soon to show them.
let repaint_delay = if self.ed.loading() {
repaint_delay.min(Duration::from_millis(30))
} else {
repaint_delay
};
if repaint_delay.is_zero() || pending || animating || !self.ed.requests.is_empty() {
g.window.request_redraw();
self.next_repaint = None;
@@ -1459,7 +1474,12 @@ impl App {
}
for c in calls {
let mut runs = std::mem::take(&mut self.runs);
let r = runs.call(self, &c.name, &c.args, crate::project::format::now_ms());
let r = runs.call(self, &c.client, &c.name, &c.args, crate::project::format::now_ms());
let touched = runs.take_touched();
if r.is_ok() {
let who = runs.label(&c.client);
self.st.mark_agent(&self.ed, who, &c.name, &c.args, &touched);
}
self.runs = runs;
self.st.log_agent(&c.name, r.as_ref().err().cloned());
if let Err(e) = &r {
@@ -1562,6 +1582,9 @@ pub fn run(start: Start) -> Result<(), String> {
crate::chat::path::init();
let store = Store::open(Store::default_root())
.map_err(|e| format!("cannot create the projects folder: {e}"))?;
// Tells papercraft-cli the app is up (or starting), so it waits for the server instead of
// writing the projects offline. Released by the OS when the process ends.
let _running = crate::cli::hold_app_lock(&store.root);
let mut builder = EventLoop::<UserEvent>::with_user_event();
#[cfg(target_os = "linux")]
{
+79 -6
View File
@@ -13,17 +13,21 @@ use rmcp::transport::StreamableHttpClientTransport;
use serde_json::{Value, json};
use std::io::{BufRead, IsTerminal, Read, Write};
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
const USAGE: &str = "\
papercraft-cli: call the running papercraft app's tools from a shell
USAGE:
papercraft-cli list tool names and one-line descriptions (works offline)
papercraft-cli list tool names and one-line descriptions (the running app's,
else the built-in list offline)
papercraft-cli schema <tool> a tool's full description and JSON input schema
papercraft-cli call <tool> [JSON|-] run a tool; JSON arguments inline, or `-` for stdin
[-o|--out FILE] where to save an image result (default: <tool>.png, or {\"out\": \"FILE\"})
[--offline] [--project NAME|ID|PATH] work without the app, see below
[--online] fail instead of working offline when the app is down
(also PAPERCRAFT_CLI_NO_OFFLINE=1)
papercraft-cli shell interactive session; reads lines from a pipe too (see below)
papercraft-cli icons list installed and installable open icon sets
papercraft-cli icons add <set>... download sets (lucide tabler phosphor heroicons material)
@@ -41,7 +45,9 @@ With the app running and a project open, calls go to the app. Otherwise (app not
--offline) the tools run in this process on the project store ($PAPERCRAFT_HOME, else the
default folder) and every editing call is saved. --project opens that project first; without it
the most recently modified one is used (create_file and open_file work as usual). --offline is
refused while the app has the same project open.
refused while the app has the same project open. While PaperCraft is starting, calls wait up to
15 s for its server. An offline session locks the project it writes (.papercraft-cli.lock); a
second one waits 5 s, then fails.
shell [--project X] [--offline]: one session, many calls. A line is `<tool> <json>` (JSON may
span lines until its braces close) or a command:
@@ -59,8 +65,30 @@ fn fail(msg: impl std::fmt::Display) -> ExitCode {
ExitCode::FAILURE
}
/// The running app's tools, so tools newer than this CLI show too; the built-in list offline.
fn tool_list() -> Vec<rmcp::model::Tool> {
if app_reachable()
&& let Ok(rt) = runtime()
&& let Ok(client) = connect(&rt)
&& let Ok(tools) = rt.block_on(client.list_all_tools())
{
return tools;
}
server::tool_list()
}
/// Warn once when the app is another version than this CLI (its tool list may differ).
fn check_version(client: &Client) {
let ours = env!("CARGO_PKG_VERSION");
if let Some(app) = client.peer_info().and_then(|i| i.server_info.as_ref().map(|s| s.version.clone()))
&& app != ours
{
eprintln!("papercraft-cli {ours} \u{2260} app {app}; reinstall the CLI");
}
}
fn list() {
for t in server::tool_list() {
for t in tool_list() {
let d = t.description.as_deref().unwrap_or("");
let first = d.split(['.', '\n']).next().unwrap_or("");
println!("{}\t{first}", t.name);
@@ -205,7 +233,7 @@ fn icons_cmd(args: &[String]) -> ExitCode {
}
fn schema(name: &str) -> bool {
let Some(t) = server::tool_list().into_iter().find(|t| t.name == name) else {
let Some(t) = tool_list().into_iter().find(|t| t.name == name) else {
eprintln!("papercraft-cli: no tool named {name} (see `papercraft-cli list`)");
return false;
};
@@ -284,6 +312,35 @@ fn connect(rt: &tokio::runtime::Runtime) -> Result<Client, String> {
.map_err(|e| format!("cannot reach papercraft at {url}: {e}. Is the app running?"))
}
/// `call`/`shell` fail instead of working offline when the app is down (`--online`).
static ONLINE_ONLY: AtomicBool = AtomicBool::new(false);
const NO_OFFLINE_ENV: &str = "PAPERCRAFT_CLI_NO_OFFLINE";
/// How long to wait for the server of an app that is still starting.
const APP_START_WAIT: Duration = Duration::from_secs(15);
const APP_START_POLL: Duration = Duration::from_millis(250);
/// The app holds the projects folder but its server is not up yet: wait for it. Ok(true) once
/// it answers, Ok(false) when no app runs, Err when one runs but never answers (writing its
/// projects offline then would race it).
fn app_starting() -> Result<bool, String> {
if !cli::app_running(&Store::default_root()) {
return Ok(false);
}
eprintln!("papercraft-cli: PaperCraft is starting; waiting for its server");
let start = std::time::Instant::now();
while start.elapsed() < APP_START_WAIT {
if app_reachable() {
return Ok(true);
}
std::thread::sleep(APP_START_POLL);
}
Err(format!(
"PaperCraft is running but its server at {} does not answer (PAPERCRAFT_MCP=0, or \
another PAPERCRAFT_MCP_PORT?)",
mcp::url(mcp::port())
))
}
fn app_reachable() -> bool {
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, mcp::port()));
@@ -340,14 +397,21 @@ fn open_backend(
}
return open_offline(project);
}
if app_reachable() {
if app_reachable() || app_starting()? {
let rt = runtime()?;
let client = connect(&rt)?;
check_version(&client);
if let Some(p) = project {
rt.block_on(online_call(&client, "open_file", &json!({"fileId": p})))?;
}
return Ok(Backend::Online(rt, client));
}
if ONLINE_ONLY.load(Ordering::Relaxed) || std::env::var_os(NO_OFFLINE_ENV).is_some_and(|v| v == "1") {
return Err(format!(
"cannot reach papercraft at {} and offline work is off (--online / {NO_OFFLINE_ENV}=1); start the app",
mcp::url(mcp::port())
));
}
eprintln!("papercraft-cli: app not running; working offline");
open_offline(project)
}
@@ -398,6 +462,14 @@ fn call_once(
project: Option<String>,
) -> Result<bool, String> {
let out = out_arg(&args, out);
// The CLI saves image results itself; passing `out` on would make the app write the file
// too, on its UI thread, where a macOS privacy prompt for that folder freezes it.
let mut args = args;
if name == "get_screenshot"
&& let Some(m) = args.as_object_mut()
{
m.remove("out");
}
let mut be = open_backend(
offline,
project.as_deref(),
@@ -671,7 +743,8 @@ fn main() -> ExitCode {
let out = take_flag(&mut args, "--out", Some("-o"));
let project = take_flag(&mut args, "--project", None);
let offline = args.iter().any(|a| a == "--offline");
args.retain(|a| a != "--offline");
ONLINE_ONLY.store(args.iter().any(|a| a == "--online"), Ordering::Relaxed);
args.retain(|a| a != "--offline" && a != "--online");
match args.first().map(String::as_str) {
Some("list") => list(),
Some("schema") => match args.get(1) {
+29 -6
View File
@@ -123,6 +123,11 @@ const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); //
#[cfg(not(target_arch = "wasm32"))]
const HELP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
#[cfg(not(target_arch = "wasm32"))]
const PROBE_POLL: std::time::Duration = std::time::Duration::from_millis(10);
/// How long a probe's output may lag behind the program's exit.
#[cfg(not(target_arch = "wasm32"))]
const PROBE_DRAIN: std::time::Duration = std::time::Duration::from_millis(200);
#[cfg(not(target_arch = "wasm32"))]
const PROBE_MAX_BYTES: u64 = 64 * 1024;
/// The override from Settings, if any. The old `claude_path`/`codex_path` fields win for their
@@ -220,20 +225,38 @@ fn probe(bin: &std::path::Path, arg: &str, path: &std::ffi::OsStr, timeout: std:
for pipe in [child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>), child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>)].into_iter().flatten() {
let tx = tx.clone();
std::thread::spawn(move || {
let mut s = String::new();
let _ = pipe.take(PROBE_MAX_BYTES).read_to_string(&mut s);
let _ = tx.send(s);
// Chunks, not one read to EOF: a pipe end can leak into an unrelated process
// spawned at the same moment (macOS has no atomic pipe+CLOEXEC) and then never
// reaches EOF until that process exits.
let (mut pipe, mut buf) = (pipe.take(PROBE_MAX_BYTES), [0u8; 4096]);
while let Ok(n @ 1..) = pipe.read(&mut buf) {
if tx.send(String::from_utf8_lossy(&buf[..n]).into_owned()).is_err() {
break;
}
}
});
}
drop(tx);
// Both pipes close when the program exits; a hung program hits the deadline and is killed.
// Done when the program exits (its output then gets a short grace to arrive) or when both
// pipes close; a hung program hits the deadline and is killed.
let deadline = std::time::Instant::now() + timeout;
let mut exited_at = None;
let mut out = String::new();
let done = loop {
match rx.recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) {
let now = std::time::Instant::now();
if exited_at.is_none() && child.try_wait().ok().flatten().is_some() {
exited_at = Some(now);
}
if exited_at.is_some_and(|t| now >= t + PROBE_DRAIN) {
break true;
}
if now >= deadline {
break false;
}
match rx.recv_timeout(PROBE_POLL.min(deadline - now)) {
Ok(s) => out.push_str(&s),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break true,
Err(_) => break false,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
};
let _ = child.kill();
+14 -4
View File
@@ -244,10 +244,15 @@ mod api {
"messages": messages,
});
if !cfg.system.is_empty() {
body["system"] = if claude {
json!([{"type": "text", "text": cfg.system, "cache_control": {"type": "ephemeral"}}])
} else {
json!(cfg.system)
// The digest changes more often than the skill: its own breakpoint keeps the skill cached.
body["system"] = match (claude, cfg.ds_digest.is_empty()) {
(true, true) => json!([{"type": "text", "text": cfg.system, "cache_control": {"type": "ephemeral"}}]),
(true, false) => json!([
{"type": "text", "text": cfg.system, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": cfg.ds_digest, "cache_control": {"type": "ephemeral"}},
]),
(false, true) => json!(cfg.system),
(false, false) => json!(format!("{}\n\n{}", cfg.system, cfg.ds_digest)),
};
}
if claude {
@@ -619,6 +624,11 @@ mod api {
assert!(b.get(k).is_some(), "{k}");
}
assert_eq!(b["system"][0]["cache_control"], json!({"type": "ephemeral"}));
let c = TurnConfig { system: "S".into(), ds_digest: "DS".into(), ..Default::default() };
let b = build_body(&c, &tools_json(true), &[]);
assert_eq!(b["system"][1], json!({"type": "text", "text": "DS", "cache_control": {"type": "ephemeral"}}), "the digest is its own block");
let other = TurnConfig { endpoint: crate::chat::endpoint::Endpoint { base_url: "http://h".into(), ..Default::default() }, ..c };
assert_eq!(build_body(&other, &[], &[])["system"], "S\n\nDS");
}
#[test]
+333
View File
@@ -0,0 +1,333 @@
//! A short summary of the open project's design system (variables, tokens, components,
//! libraries, annotation types) that goes to the chat agent with every turn, so it reuses what is
//! there instead of drawing it again. Built from the editor in-process; the tools that return the
//! full data are `get_variables`, `get_components` and `get_annotation_template`.
use crate::editor::Editor;
use crate::libraries::EnabledStatus;
use crate::model::{Document, NodeId, Source};
use crate::variables::{self, Collection, DesignSystem, Kind, PageModes, Value};
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
/// About 1,500 tokens (4 bytes a token).
pub const DIGEST_MAX_BYTES: usize = 6_000;
/// Bytes per token for the context meter's estimate.
pub const BYTES_PER_TOKEN: usize = 4;
pub const REUSE_RULE: &str = "Reuse: use `create_instance`/`swap_instance` for anything matching a component; colors must be `var(--…)`; spacing on tokens.";
const HEAD: &str = "Design system of the project (summary). A variable `group: leaf=value` is var(--group-leaf), with / written as -.";
const MORE: &str = "Truncated: call get_components / get_variables for the rest.";
/// Of the budget left after the fixed lines, the share collapsed variable groups may take.
const VARS_SHARE: usize = 2;
/// The project design system; a project that never went through the session reads as its first
/// page does (as `get_variables` does).
fn design_of(ed: &Editor) -> DesignSystem {
if ed.design.collections.is_empty() && !ed.pages.is_empty() {
return variables::migrate_from_document(ed.page_doc(0));
}
ed.design.clone()
}
/// One main component: where it lives, what it is called and how often it is used.
struct Comp {
key: String,
name: String,
w: f64,
h: f64,
uses: usize,
}
/// Page mains and enabled-library mains, most used first, then by name.
fn components(ed: &Editor) -> Vec<Comp> {
// Instance roots anywhere, by where their main lives (page index or library id) and its node.
let mut page_uses: HashMap<(usize, NodeId), usize> = HashMap::new();
let mut lib_uses: HashMap<(&str, NodeId), usize> = HashMap::new();
for page in 0..ed.pages.len() {
for n in ed.page_doc(page).nodes.values() {
let Some(l) = &n.instance else { continue };
match &l.source {
None if n.id != l.main => *page_uses.entry((page, l.main)).or_default() += 1,
None => {}
Some(Source::Page(p)) => {
if let Some(i) = ed.pages.iter().position(|s| s.id == *p) {
*page_uses.entry((i, l.main)).or_default() += 1;
}
}
Some(Source::Library(lib)) => *lib_uses.entry((lib.as_str(), l.main)).or_default() += 1,
}
}
}
let mut out = Vec::new();
let mut add = |doc: &Document, uses: &dyn Fn(NodeId) -> usize, key: &dyn Fn(NodeId) -> String| {
for c in doc.nodes.values().filter(|n| n.component) {
let key = c.component_key.clone().unwrap_or_else(|| key(c.id));
out.push(Comp { key, name: c.name.clone(), w: c.rect.w, h: c.rect.h, uses: uses(c.id) });
}
};
for page in 0..ed.pages.len() {
let uses = |id| page_uses.get(&(page, id)).copied().unwrap_or(0);
add(ed.page_doc(page), &uses, &|id| crate::mcp::exec::node_key(ed, page, id));
}
for (lib, doc) in &ed.library_docs {
let uses = |id| lib_uses.get(&(lib.as_str(), id)).copied().unwrap_or(0);
add(doc, &uses, &|id| format!("{lib}:{}", id.0));
}
out.sort_by(|a, b| b.uses.cmp(&a.uses).then_with(|| a.name.cmp(&b.name)).then_with(|| a.key.cmp(&b.key)));
// Copies imported to other pages share the name: one line, the most used main's key, all uses.
let mut merged: Vec<Comp> = Vec::with_capacity(out.len());
for c in out {
match merged.iter_mut().find(|m| m.name == c.name) {
Some(m) => m.uses += c.uses,
None => merged.push(c),
}
}
merged.sort_by(|a, b| b.uses.cmp(&a.uses).then_with(|| a.name.cmp(&b.name)));
merged
}
/// Hash of what the digest is made of: variables and tokens, component keys and names, enabled
/// library versions and the annotation template. Moving or editing other nodes leaves it as is.
pub fn key(ed: &Editor) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
serde_json::to_string(&design_of(ed).collections).unwrap_or_default().hash(&mut h);
for l in &ed.design.libraries {
(&l.library_id, l.version).hash(&mut h);
}
for c in components(ed) {
(c.key, c.name, c.w.to_bits(), c.h.to_bits()).hash(&mut h);
}
if !ed.pages.is_empty() {
ed.active_template(ed.active_page).id.hash(&mut h);
}
h.finish()
}
/// `color/brand/primary` -> (`color/brand`, `primary`); `spacing-md` -> (`spacing`, `md`): both
/// print as `group: leaf=…`, and `var(--group-leaf)` is the CSS name either way.
fn split(name: &str) -> (&str, &str) {
name.rsplit_once('/').or_else(|| name.split_once('-')).unwrap_or(("", name))
}
/// Values in each mode, `#a/#b` (one value when every mode agrees). None for strings and booleans.
fn values(ds: &DesignSystem, c: &Collection, var_id: &str) -> Option<String> {
let per_mode: Vec<String> = c
.modes
.iter()
.filter_map(|m| {
let modes: PageModes = [(c.id.clone(), m.id.clone())].into();
match ds.resolve(var_id, &modes).ok()? {
Value::Color(col) => Some(col.to_hex()),
Value::Number(n) => Some(format!("{}", (n * 100.0).round() / 100.0)),
_ => None,
}
})
.collect();
if per_mode.is_empty() {
return None;
}
Some(if per_mode.iter().all(|v| *v == per_mode[0]) { per_mode[0].clone() } else { per_mode.join("/") })
}
/// One line per variable group of each collection; `collapsed` gives counts instead of names.
fn variable_lines(ds: &DesignSystem, collapsed: bool) -> Vec<String> {
let mut out = Vec::new();
for c in &ds.collections {
let shown: Vec<_> = c.variables.iter().filter(|v| !v.hidden).collect();
if shown.is_empty() {
continue;
}
let modes = c.modes.iter().map(|m| m.name.as_str()).collect::<Vec<_>>().join("/");
let lib = c.library.as_deref().map(|l| format!(", library {l}")).unwrap_or_default();
out.push(format!("{} (modes {modes}{lib}):", c.name));
let mut groups: Vec<(&str, Vec<String>)> = Vec::new();
for v in shown {
let (group, leaf) = split(&v.name);
let item = match (v.kind, values(ds, c, &v.id)) {
(Kind::Color | Kind::Number, Some(val)) => format!("{leaf}={val}"),
_ => leaf.to_owned(),
};
match groups.iter_mut().find(|g| g.0 == group) {
Some(g) => g.1.push(item),
None => groups.push((group, vec![item])),
}
}
for (group, items) in groups {
let n = items.len();
let label = if group.is_empty() { String::new() } else { format!("{group}: ") };
out.push(if collapsed { format!(" {label}{n} variables") } else { format!(" {label}{}", items.join(" ")) });
}
}
out
}
fn component_line(c: &Comp) -> String {
format!(" {} \u{b7} {} \u{b7} {}\u{d7}{} \u{b7} {}", c.key, c.name, c.w.round(), c.h.round(), c.uses)
}
fn library_line(s: &EnabledStatus) -> String {
let state = match s.source_version {
None => "source missing".to_owned(),
Some(v) if s.update_available => format!("update to v{v} available"),
Some(_) => "up to date".to_owned(),
};
format!(" {} v{} ({state})", s.name, s.local_version)
}
/// The digest, at most [`DIGEST_MAX_BYTES`]. Over the cap, variable groups collapse to counts and
/// only the most used components are kept, with a line saying where the rest is.
pub fn build(ed: &Editor, libs: &[EnabledStatus]) -> String {
let ds = design_of(ed);
let comps = components(ed);
let head = HEAD.to_owned();
let mut tail = Vec::new();
if !libs.is_empty() {
tail.push("Libraries:".to_owned());
tail.extend(libs.iter().map(library_line));
}
if !ed.pages.is_empty() {
let t = ed.active_template(ed.active_page);
tail.push(format!("Annotation types ({}): {}", t.name, t.enabled_keys().join(", ")));
}
tail.push(REUSE_RULE.to_owned());
let comp_head = "Components (componentKey \u{b7} name \u{b7} w\u{d7}h \u{b7} uses):".to_owned();
let section = |lines: &[String]| lines.iter().map(|l| l.len() + 1).sum::<usize>();
let vars = variable_lines(&ds, false);
let all_comps: Vec<String> = comps.iter().map(component_line).collect();
let mut lines = vec![head.clone()];
lines.extend(vars);
if !all_comps.is_empty() {
lines.push(comp_head.clone());
lines.extend(all_comps.iter().cloned());
}
lines.extend(tail.iter().cloned());
if section(&lines) <= DIGEST_MAX_BYTES {
return lines.join("\n");
}
// ponytail: greedy fill, variables first up to a share of the room, then components by use.
let fixed = section(&[head.clone(), comp_head.clone(), MORE.to_owned()]) + section(&tail);
let room = DIGEST_MAX_BYTES.saturating_sub(fixed);
let mut out = vec![head];
let mut used = 0;
for l in variable_lines(&ds, true) {
if used + l.len() + 1 > room / VARS_SHARE {
break;
}
used += l.len() + 1;
out.push(l);
}
out.push(comp_head);
for l in all_comps {
if used + l.len() + 1 > room {
break;
}
used += l.len() + 1;
out.push(l);
}
out.extend(tail);
out.push(MORE.to_owned());
out.join("\n")
}
/// The digest with its key, rebuilt only when the key changes.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Cached {
pub key: u64,
pub text: String,
}
impl Cached {
/// Rebuilds when the design system changed; `libs` is only asked for then (it reads the
/// library store). Returns whether it rebuilt.
pub fn refresh(&mut self, ed: &Editor, libs: impl FnOnce() -> Vec<EnabledStatus>) -> bool {
let k = key(ed);
if k == self.key && !self.text.is_empty() {
return false;
}
*self = Cached { key: k, text: build(ed, &libs()) };
true
}
/// Estimated tokens, for the context meter.
pub fn tokens(&self) -> u64 {
(self.text.len() / BYTES_PER_TOKEN) as u64
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::{Out, exec};
use serde_json::{Value as J, json};
fn host() -> exec::SessionHost {
let dir = std::env::temp_dir().join(format!("papercraft-digest-{:016x}", fastrand::u64(..)));
let mut h = exec::SessionHost::new(crate::project::Store::open(dir).unwrap());
h.session.create(&mut h.ed, "Digest").unwrap();
h
}
fn call(h: &mut exec::SessionHost, name: &str, args: J) -> J {
match exec::call(h, name, &args).unwrap_or_else(|e| panic!("{name}: {e}")).into_iter().next() {
Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(J::String(t)),
_ => J::Null,
}
}
/// A project with two-mode colors, spacing tokens and `n` components; the first is used 3 times.
fn fixture(n: usize) -> exec::SessionHost {
let mut h = host();
call(&mut h, "set_variables", json!({"variables": {"accent": "#cba6f7", "text": "#111111"}}));
call(&mut h, "set_tokens", json!({"tokens": {"spacing-sm": "8px", "spacing-md": "16px", "radius-md": "8px"}}));
let ab = call(&mut h, "create_artboard", json!({"name": "Screen", "styles": {"width": "400px", "height": "400px", "display": "flex"}}));
let mut first = None;
for i in 0..n {
let r = call(&mut h, "write_html", json!({"targetNodeId": ab["id"], "mode": "insert-children",
"html": format!("<div layer-name=\"Part {i:03} with a fairly long descriptive name\" style=\"display:flex;padding:8px\"><div style=\"font-size:14px\">x</div></div>")}));
let c = call(&mut h, "create_component", json!({"nodeId": r["createdNodeIds"][0], "name": format!("Part {i:03} with a fairly long descriptive name")}));
first.get_or_insert(c["id"].clone());
}
for _ in 0..3 {
call(&mut h, "create_instance", json!({"componentId": first.clone().unwrap(), "parentId": ab["id"]}));
}
h
}
#[test]
fn digest_lists_the_design_system_and_stays_under_the_cap() {
let h = fixture(3);
let d = build(&h.ed, &[]);
assert!(d.len() <= DIGEST_MAX_BYTES, "{}", d.len());
assert!(d.contains("accent=#cba6f7") && d.contains(" accent=#cba6f7 text=#111111\n") && d.contains("spacing: md=16 sm=8"), "{d}");
assert!(d.contains("Part 000 with a fairly long descriptive name \u{b7} ") && d.contains(" \u{b7} 3\n"), "{d}");
assert!(d.contains("Annotation types (") && d.ends_with(REUSE_RULE) && !d.contains(MORE), "{d}");
let h = fixture(120);
let d = build(&h.ed, &[]);
assert!(d.len() <= DIGEST_MAX_BYTES, "{}", d.len());
assert!(d.contains(MORE) && d.contains("variables") && !d.contains("accent=#"), "{d}");
// The most used component is kept first; the least used tail is dropped.
let comps: Vec<&str> = d.lines().skip_while(|l| !l.starts_with("Components")).skip(1).take_while(|l| l.starts_with(" ")).collect();
assert!(comps[0].contains("Part 000") && comps[0].ends_with(" 3"), "{comps:?}");
assert!(comps.len() < 120 && !d.contains("Part 119"), "{}", comps.len());
}
#[test]
fn key_ignores_unrelated_moves_and_follows_the_design_system() {
let mut h = fixture(2);
let k = key(&h.ed);
let ab = call(&mut h, "create_artboard", json!({"name": "Other", "styles": {"width": "50px", "height": "50px"}}));
let k2 = key(&h.ed);
call(&mut h, "update_styles", json!({"updates": [{"nodeIds": [ab["id"]], "styles": {"left": "900px", "top": "40px"}}]}));
assert_eq!(key(&h.ed), k2, "moving a node keeps the key");
assert_eq!(k, k2, "a new plain frame keeps the key");
call(&mut h, "set_variables", json!({"variables": {"accent": "#000000"}}));
assert_ne!(key(&h.ed), k2, "a variable change changes it");
let mut c = Cached::default();
assert!(c.refresh(&h.ed, Vec::new));
assert!(!c.refresh(&h.ed, || panic!("not rebuilt without a change")));
assert!(c.tokens() > 0);
}
}
+15
View File
@@ -27,6 +27,18 @@ pub enum Event {
#[derive(Clone)]
pub struct ToolDispatch(pub std::sync::Arc<dyn Fn(crate::mcp::Call) -> bool + Send + Sync>);
#[cfg(not(target_arch = "wasm32"))]
impl ToolDispatch {
/// Tags every call with the chat, so the turn's edits land in that chat's own run.
pub fn for_chat(self, chat: &str) -> ToolDispatch {
let client = crate::mcp::Client::chat(chat);
ToolDispatch(std::sync::Arc::new(move |mut c: crate::mcp::Call| {
c.client = client.clone();
(self.0)(c)
}))
}
}
#[cfg(not(target_arch = "wasm32"))]
impl std::fmt::Debug for ToolDispatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -51,6 +63,9 @@ pub struct TurnConfig {
pub mcp_url: String,
/// PaperCraft skill text and project context, appended to the system prompt.
pub system: String,
/// API backend: the design-system summary, sent after `system` as its own block. CLI and ACP
/// backends get it inside `system` or in front of the prompt instead.
pub ds_digest: String,
/// Model id; None = the backend's default (API: the endpoint's model).
pub model: Option<String>,
/// `low` | `medium` | `high`; None = the backend's default.
+1
View File
@@ -7,6 +7,7 @@ pub mod acp;
pub mod anthropic;
pub mod claude_code;
pub mod codex;
pub mod ds_digest;
pub mod endpoint;
pub mod events;
#[cfg(not(target_arch = "wasm32"))]
+2 -1
View File
@@ -197,7 +197,8 @@ mod api {
let model = cfg.model.as_deref().unwrap_or(&cfg.endpoint.model);
let mut all = vec![];
if !cfg.system.is_empty() {
all.push(json!({"role": "system", "content": cfg.system}));
let text = if cfg.ds_digest.is_empty() { cfg.system.clone() } else { format!("{}\n\n{}", cfg.system, cfg.ds_digest) };
all.push(json!({"role": "system", "content": text}));
}
all.extend(messages.iter().cloned());
json!({
+1 -1
View File
@@ -188,7 +188,7 @@ impl Ctl<'_> {
}
let Some(d) = self.dispatch else { return Some(Err("tools are not available".into())) };
let (reply, mut rx) = tokio::sync::oneshot::channel();
if !(d.0)(crate::mcp::Call { name: name.into(), args: args.clone(), reply }) {
if !(d.0)(crate::mcp::Call { name: name.into(), args: args.clone(), client: Default::default(), reply }) {
return Some(Err("the app is not accepting tool calls".into()));
}
loop {
+78 -1
View File
@@ -7,7 +7,56 @@ use crate::mcp::{Out, ToolResult, tools};
use crate::project::Store;
use base64::Engine;
use serde_json::{Value, json};
use std::path::Path;
use std::fs::{File, TryLockError};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
/// Held (exclusive advisory lock) by a running app in its projects folder, from start-up on.
pub const APP_LOCK: &str = ".papercraft-app.lock";
/// Held by an offline CLI session in the project folder while it may write the project.
pub const PROJECT_LOCK: &str = ".papercraft-cli.lock";
/// How long an offline session waits for another one to release the project.
pub const PROJECT_LOCK_WAIT: Duration = Duration::from_secs(5);
const LOCK_POLL: Duration = Duration::from_millis(100);
fn lock_file(path: &Path) -> std::io::Result<File> {
File::options().create(true).truncate(false).write(true).open(path)
}
/// Mark the app as running for `papercraft-cli` (see [`app_running`]); keep the file alive.
pub fn hold_app_lock(root: &Path) -> Option<File> {
let f = lock_file(&root.join(APP_LOCK)).ok()?;
f.try_lock().ok()?;
Some(f)
}
/// An app holds the projects folder at `root` (it may still be starting its server).
pub fn app_running(root: &Path) -> bool {
lock_file(&root.join(APP_LOCK))
.is_ok_and(|f| matches!(f.try_lock(), Err(TryLockError::WouldBlock)))
}
/// Take the offline write lock of project `dir`, waiting up to `wait` for another holder.
pub fn lock_project(dir: &Path, wait: Duration) -> Result<File, String> {
let path = dir.join(PROJECT_LOCK);
let f = lock_file(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let start = Instant::now();
loop {
match f.try_lock() {
Ok(()) => return Ok(f),
Err(TryLockError::WouldBlock) if start.elapsed() < wait => std::thread::sleep(LOCK_POLL),
Err(TryLockError::WouldBlock) => {
return Err(format!(
"another papercraft-cli is writing {} (waited {} s); retry when it is done, or \
open the project in the app so calls go through it",
dir.display(),
wait.as_secs()
));
}
Err(TryLockError::Error(e)) => return Err(format!("{}: {e}", path.display())),
}
}
}
/// Tools that work with no project open, so they never trigger the default-project open.
const NO_PROJECT_TOOLS: [&str; 6] = [
@@ -28,6 +77,8 @@ pub struct Offline {
/// Checked with `open_file`'s `fileId` before it runs (the binary refuses projects the app has open).
pub open_guard: Option<Box<dyn Fn(&str) -> Result<(), String>>>,
default_tried: bool,
/// The project this session may write, and its lock ([`PROJECT_LOCK`]).
lock: Option<(PathBuf, File)>,
}
impl Offline {
@@ -39,6 +90,7 @@ impl Offline {
last_touched: None,
open_guard: None,
default_tried: project.is_some(),
lock: None,
};
if let Some(p) = project {
exec::call(&mut o.host, "open_file", &json!({"fileId": p}))?;
@@ -58,6 +110,9 @@ impl Offline {
if edits && self.host.project().is_none() {
return Err("no project is open; run create_file, then open_file".into());
}
if edits {
self.lock_for_write()?;
}
let r = exec::call(&mut self.host, name, args)?;
if edits {
self.host.save()?;
@@ -66,6 +121,28 @@ impl Offline {
Ok(r)
}
/// Before the first write to a project: take its lock, then reload it, so writes another
/// session saved before the lock was ours are not overwritten.
fn lock_for_write(&mut self) -> Result<(), String> {
let Some(dir) = self.host.project().map(|p| p.dir.clone()) else {
return Ok(());
};
if self.lock.as_ref().is_some_and(|(d, _)| *d == dir) {
return Ok(());
}
let f = lock_project(&dir, PROJECT_LOCK_WAIT)?;
self.lock = Some((dir.clone(), f));
let page = self.host.ed.active_page_id().to_string();
self.host
.session
.open(&mut self.host.ed, &dir)
.map_err(|e| format!("{}: {e}", dir.display()))?;
if let Ok(i) = exec::find_page(&self.host.ed, &page) {
self.host.ed.switch_page(i);
}
Ok(())
}
fn open_default(&mut self) {
if self.host.project().is_some() {
return;
+32 -11
View File
@@ -57,6 +57,24 @@ impl Editor {
self.edit("Edit annotation", |tx| tx.update_annotation(id, f))
}
/// Re-run placement for the notes on `frame` (the whole page for `None`) as one undo step.
/// Returns how many notes moved.
pub fn tidy_annotations(&mut self, frame: Option<crate::model::NodeId>) -> usize {
let moves = crate::model::annotation_layout::tidy(&self.doc, frame);
if moves.is_empty() {
return 0;
}
self.edit("Tidy notes", |tx| {
for (id, p) in &moves {
tx.update_annotation(*id, |a| {
a.position = p.position;
a.anchor = p.anchor;
});
}
});
moves.len()
}
pub fn delete_annotation(&mut self, id: u64) -> bool {
self.edit("Delete annotation", |tx| tx.delete_annotation(id))
}
@@ -292,20 +310,23 @@ mod tests {
}
#[test]
fn auto_position_sits_right_of_frame_and_stacks() {
fn auto_place_stays_clear_of_nodes_and_stacks() {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(100.0, 100.0, 400.0, 400.0));
let c = child(&mut e, f, Rect::new(10.0, 20.0, 100.0, 50.0));
let p1 = annotation::auto_position(&e.doc, c);
let mut a = Annotation::new(Some(c), "1", "rule");
a.position = p1;
e.add_annotation(a);
let w1 = annotation::card_world(&e.doc, &e.doc.annotations[0]);
assert_eq!(w1, v2(524.0, 120.0));
let p2 = annotation::auto_position(&e.doc, c);
let w2 = v2(110.0, 120.0) + p2;
assert_eq!(w2.x, w1.x);
assert!(w2.y >= w1.y + annotation::CARD_H, "stacked below, no overlap");
let mut cards = Vec::new();
for i in 0..2 {
let mut a = Annotation::new(Some(c), &format!("note {i}"), "rule");
annotation::auto_place(&e.doc, &mut a);
e.add_annotation(a);
let a = e.doc.annotations.last().unwrap();
let at = annotation::card_world(&e.doc, a);
let s = crate::model::annotation_layout::card_size(a);
cards.push(Rect::new(at.x, at.y, s.x, s.y));
}
let target = e.doc.world_rect(c).unwrap();
assert!(cards.iter().all(|r| !r.intersects(&target)));
assert!(!cards[0].intersects(&cards[1]), "stacked without overlap");
}
#[test]
+2
View File
@@ -19,6 +19,8 @@ pub enum UiEvent {
OpenVariables,
/// Open the New annotation popover on this layer.
Annotate(NodeId),
/// Open the chat's Ask box on the selection (⌘K).
Ask,
}
impl Editor {
+5 -1
View File
@@ -58,6 +58,7 @@ impl Editor {
pre: Option<&dyn Fn(&mut crate::history::Tx)>,
) -> Result<(R, Fills), Error> {
self.commit();
self.finish_loading();
// A project that never went through the session (no design.json yet) starts from what
// its first page already holds.
if self.design.collections.is_empty() {
@@ -124,6 +125,7 @@ impl Editor {
/// Rebuild every page's cache, bound fills and bindings from `Editor::design` after an undo
/// or redo, with no history of their own. The pages are marked unsaved so the files follow.
fn rebuild_all_silently(&mut self, renames: &BTreeMap<String, String>) {
self.finish_loading();
for i in 0..self.pages.len() {
let cache = self.design.cache_for(&self.pages[i].modes);
self.with_page(i, |ed| ed.rebuild_cache("", &cache, renames, None, None, |_| {}, false));
@@ -166,7 +168,7 @@ impl Editor {
}
match self.rec.take() {
Some((rec, key)) if keep => {
if self.history.commit(&self.doc, rec, key) {
if self.log_touched(&rec) && self.history.commit(&self.doc, rec, key) {
self.revision += 1;
}
}
@@ -196,6 +198,7 @@ impl Editor {
if !c.modes.iter().any(|m| m.id == mode_id) {
return Err(Error::NotFound(mode_id.into()));
}
self.ensure_loaded(page);
let old = self.pages[page].modes.clone();
let mut modes = old.clone();
modes.insert(collection_id.into(), mode_id.into());
@@ -214,6 +217,7 @@ impl Editor {
/// Set (or clear, with `None`) page `page`'s annotation template override. One undo step.
pub fn set_page_template_on(&mut self, page: usize, template: Option<&str>) {
self.ensure_loaded(page);
let old = self.pages[page].modes.clone();
let mut modes = old.clone();
match template {
+41 -1
View File
@@ -279,6 +279,8 @@ pub struct Editor {
pub(crate) touched_mains: Vec<NodeId>,
pub(crate) cross_pending: Vec<(usize, Vec<NodeId>)>,
pub(crate) cross_busy: bool,
/// Reads the pages the project was opened without (see [`PageSlot::pending`]).
pub(crate) loader: Option<crate::project::loader::PageLoader>,
/// Pen tool in progress.
pub pen: Option<vector_ops::PenState>,
/// Path point editing in progress.
@@ -303,6 +305,11 @@ pub struct Editor {
/// Text we last put on the system clipboard (to recognize our own copy).
clipboard_written: Option<String>,
clipboard_style: Option<(crate::model::Style, crate::model::TextStyle)>,
/// When user input last moved the camera (agents leave the view alone for a while after).
pub cam_moved: Option<web_time::Instant>,
/// While set, every committed change appends the (page id, node) pairs it changed: the agent
/// run tracker attributes nodes to the run that made the call.
pub touch_log: Option<Vec<(String, NodeId)>>,
}
impl Default for Editor {
@@ -363,6 +370,7 @@ impl Editor {
touched_mains: Vec::new(),
cross_pending: Vec::new(),
cross_busy: false,
loader: None,
pen: None,
path_edit: None,
measures: Vec::new(),
@@ -374,6 +382,8 @@ impl Editor {
sticky_color: Color::rgb(0xff, 0xdf, 0x6b),
ui_events: Vec::new(),
clipboard_style: None,
cam_moved: None,
touch_log: None,
clipboard_io: None,
clipboard_written: None,
}
@@ -406,6 +416,7 @@ impl Editor {
self.fixup();
let mains = std::mem::take(&mut self.touched_mains);
if let Some((rec, key)) = self.rec.take()
&& self.log_touched(&rec)
&& self.history.commit(&self.doc, rec, key)
{
self.revision += 1;
@@ -416,6 +427,24 @@ impl Editor {
self.flush_cross_pages();
}
/// Appends the nodes `rec` changed to `touch_log`. Always true, so it chains before a commit.
pub(crate) fn log_touched(&mut self, rec: &Recording) -> bool {
let Some(page) = self.touch_log.as_ref().and_then(|_| self.pages.get(self.edit_page_index())) else {
return true;
};
let page = page.id.clone();
let changed: Vec<_> = rec
.touched()
.iter()
.filter(|id| rec.before(**id).map(Option::as_ref) != Some(self.doc.nodes.get(id)))
.map(|id| (page.clone(), *id))
.collect();
if let Some(log) = &mut self.touch_log {
log.extend(changed);
}
true
}
/// Run one edit as a complete transaction.
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut Tx) -> R) -> R {
self.begin(label);
@@ -429,6 +458,7 @@ impl Editor {
self.begin(label);
let result = f(&mut self.tx());
if let Some((rec, key)) = self.rec.take()
&& self.log_touched(&rec)
&& self.history.commit(&self.doc, rec, key)
{
self.revision += 1;
@@ -659,6 +689,14 @@ impl Editor {
// ------------------------------------------------------------------ input
pub fn handle(&mut self, ev: InputEvent) {
let cam = self.camera;
self.handle_event(ev);
if self.camera != cam {
self.cam_moved = Some(web_time::Instant::now());
}
}
fn handle_event(&mut self, ev: InputEvent) {
match ev {
InputEvent::Modifiers(m) => {
self.mods = m;
@@ -1057,7 +1095,7 @@ impl Editor {
}
/// The layer a click on `hit` selects: the outermost group or instance not yet entered.
fn click_target(&self, hit: NodeId, double: bool) -> NodeId {
pub fn click_target(&self, hit: NodeId, double: bool) -> NodeId {
let mut chain = self.doc.ancestors(hit);
chain.reverse();
chain.push(hit);
@@ -2031,6 +2069,8 @@ impl Editor {
A::PlaceImage => self.requests.push(AppRequest::PlaceImage),
A::ToggleSketch => self.toggle_sketch(),
A::CommandPalette => self.ui_events.push(commands::UiEvent::TogglePalette),
A::Ask if self.selection.is_empty() => self.ui_events.push(commands::UiEvent::TogglePalette),
A::Ask => self.ui_events.push(commands::UiEvent::Ask),
A::Rename => {
if let Some(id) = self.selection.first() {
self.ui_events.push(commands::UiEvent::Rename(*id));
+130 -2
View File
@@ -26,6 +26,13 @@ pub struct PageSlot {
pub stash: Option<Box<PageStash>>,
/// History generation at the last save.
pub saved_gen: u64,
/// The project was opened without reading this page yet: `stash` holds an empty stand-in
/// that is never saved. The loader installs the real page (`Editor::poll_loading`), or code
/// that needs it now reads it first (`Editor::ensure_loaded`).
pub pending: bool,
/// Why a pending page could not be read. Such a page stays a stand-in: not shown, not saved,
/// so the file on disk is left for the user to repair.
pub failed: Option<String>,
/// Active mode id per collection id on this page.
pub modes: crate::variables::PageModes,
}
@@ -37,6 +44,8 @@ impl PageSlot {
name: name.into(),
stash: None,
saved_gen: 0,
pending: false,
failed: None,
modes: Default::default(),
}
}
@@ -80,6 +89,8 @@ impl Editor {
})
}),
saved_gen: 0,
pending: false,
failed: None,
modes: Default::default(),
})
.collect();
@@ -102,6 +113,9 @@ impl Editor {
if i == self.active_page || i >= self.pages.len() {
return;
}
if !self.ensure_loaded(i) {
return;
}
self.cancel_gesture();
self.end_text_edit();
self.commit();
@@ -180,6 +194,8 @@ impl Editor {
needs_fit: true,
})),
saved_gen: u64::MAX,
pending: false,
failed: None,
modes: Default::default(),
});
self.meta_dirty = true;
@@ -233,7 +249,7 @@ impl Editor {
}
pub fn duplicate_page(&mut self, i: usize) -> usize {
if i >= self.pages.len() {
if i >= self.pages.len() || !self.ensure_loaded(i) {
return self.active_page;
}
self.commit();
@@ -396,7 +412,7 @@ impl Editor {
/// Pages whose content changed since their last save.
pub fn dirty_pages(&self) -> Vec<usize> {
(0..self.pages.len())
.filter(|i| self.page_gen(*i) != self.pages[*i].saved_gen)
.filter(|i| !self.pages[*i].pending && self.page_gen(*i) != self.pages[*i].saved_gen)
.collect()
}
@@ -440,6 +456,7 @@ impl Editor {
if i == self.active_page || i >= self.pages.len() {
return f(self);
}
self.ensure_loaded(i);
self.commit();
let mut s = self.pages[i]
.stash
@@ -472,6 +489,8 @@ impl Editor {
if self.cross_busy || self.edit_page.is_some() {
return;
}
// Instances on a page that is still being read must follow the change too.
self.finish_loading();
self.cross_busy = true;
for _ in 0..MAX_ROUNDS {
let Some((src, mains)) = (!self.cross_pending.is_empty())
@@ -510,6 +529,115 @@ impl Editor {
self.cross_busy = false;
}
/// Hand over the pages `open_pages` got as stand-ins (`pending`: indexes into the page list)
/// to a background reader for the project in `dir`.
pub fn load_pages_in_background(&mut self, dir: &std::path::Path, pending: &[usize]) {
if pending.is_empty() {
return;
}
let refs = pending
.iter()
.map(|&i| crate::project::PageRef {
id: self.pages[i].id.clone(),
name: self.pages[i].name.clone(),
})
.collect();
for &i in pending {
self.pages[i].pending = true;
}
self.loader = Some(crate::project::loader::PageLoader::spawn(dir.to_path_buf(), refs));
}
/// Pages are still being read in the background.
pub fn loading(&self) -> bool {
self.pages.iter().any(|s| s.pending && s.failed.is_none())
}
/// Install the pages the background reader finished. Never blocks.
pub fn poll_loading(&mut self) {
while let Some(got) = self.loader.as_ref().and_then(|l| l.rx.try_recv().ok()) {
self.install_page(&got.0, got.1);
}
}
/// Wait for every page to be read. Call before anything that looks at or changes all pages
/// (agent tools, design edits, cross-page syncing).
pub fn finish_loading(&mut self) {
while self.loading() {
match self.loader.as_ref().and_then(|l| l.rx.recv().ok()) {
Some(got) => self.install_page(&got.0, got.1),
None => {
// The reader is gone: read what is left here.
for i in 0..self.pages.len() {
if self.pages[i].pending && self.pages[i].failed.is_none() {
self.read_page_now(i);
}
}
break;
}
}
}
}
/// Make sure page `i` is read. False when it could not be (the reason is in the status).
pub fn ensure_loaded(&mut self, i: usize) -> bool {
if !self.pages[i].pending {
return true;
}
self.poll_loading();
if self.pages[i].pending && self.pages[i].failed.is_none() {
self.read_page_now(i);
}
!self.pages[i].pending
}
/// Read page `i` here, ahead of the background reader (whose copy is dropped on arrival).
fn read_page_now(&mut self, i: usize) {
let Some(dir) = self.loader.as_ref().map(|l| l.dir.clone()) else {
self.pages[i].failed = Some("the project folder is unknown".into());
return;
};
let r = crate::project::PageRef {
id: self.pages[i].id.clone(),
name: self.pages[i].name.clone(),
};
let path = dir.join(crate::project::PAGES_DIR).join(format!("{}.json", r.id));
let read = crate::project::read_page_file(&path, &r);
self.install_page(&r.id, read);
}
fn install_page(
&mut self,
id: &str,
read: Result<crate::project::loader::ReadPage, crate::project::FormatError>,
) {
let Some(i) = self.pages.iter().position(|s| s.id == id && s.pending) else {
return;
};
let (mut p, mut modes) = match read {
Ok(x) => x,
Err(e) => {
let msg = format!("Could not read page {}: {e}", self.pages[i].name);
self.status.clone_from(&msg);
self.pages[i].failed = Some(msg);
return;
}
};
let changed = crate::project::finalize_page(&self.design, &mut p, &mut modes);
let slot = &mut self.pages[i];
let Some(stash) = slot.stash.as_mut() else { return };
// The manifest owns the name, and it may have been renamed meanwhile.
p.doc.name.clone_from(&slot.name);
stash.doc = p.doc;
stash.camera = p.camera.unwrap_or_default();
stash.needs_fit = p.camera.is_none();
slot.modes = modes;
slot.pending = false;
// Read from disk is saved by definition, unless the load itself changed the page.
slot.saved_gen = if changed { u64::MAX } else { stash.history.generation() };
self.revision += 1;
}
/// The document of page `i` (active or parked).
pub fn page_doc(&self, i: usize) -> &Document {
if i == self.active_page {
+4 -1
View File
@@ -55,6 +55,8 @@ pub enum Action {
PlaceImage,
ToggleSketch,
CommandPalette,
/// ⌘K: the Ask box on the selection, the command palette when nothing is selected.
Ask,
Rename,
ToggleUi,
ToggleChat,
@@ -158,7 +160,7 @@ fn bindings() -> Vec<(Key, Chord, Action, &'static str)> {
A::ToggleSketch,
"Hand-drawn style on/off",
),
(Char('k'), CMD, A::CommandPalette, "Command palette"),
(Char('k'), CMD, A::Ask, "Ask about the selection (palette when none)"),
(Char('p'), CMD_ALT, A::ExportPdf, "Export PDF"),
(Char('c'), CMD_SHIFT, A::CopyAsPng, "Copy as PNG"),
(Char('j'), CMD, A::ToggleChat, "Design / Chat mode"),
@@ -416,6 +418,7 @@ pub fn category(a: Action) -> &'static str {
| A::PasteProps
| A::Rename
| A::CommandPalette
| A::Ask
| A::Escape
| A::EditText
| A::SelectNext
+47 -8
View File
@@ -97,7 +97,10 @@ fn copy_props(main: &Node, inst: &mut Node, overrides: &[String], is_root: bool)
}
if !ov("layout") {
inst.layout = main.layout;
inst.layout_child = main.layout_child;
// How a root sits in its own parent (absolute, fill/hug) is the instance's, not the main's.
if !is_root {
inst.layout_child = main.layout_child;
}
}
if !is_root && !ov("position") {
inst.rect.x = main.rect.x;
@@ -556,6 +559,7 @@ impl Editor {
/// Clear overrides on the selected instance nodes and resync from the main component.
pub fn reset_overrides(&mut self) {
self.finish_loading();
let ids = self.top_selection();
let roots: Vec<NodeId> = ids
.iter()
@@ -601,6 +605,11 @@ impl Editor {
doc.get(l.main).map(|n| (page, n))
}
/// The main of `l` is on a page that is still being read, so it cannot be looked at yet.
fn link_pending(&self, l: &InstanceLink) -> bool {
matches!(&l.source, Some(Source::Page(p)) if self.pages.iter().any(|s| s.id == *p && s.pending))
}
/// A clone of the document holding the main of instance node `id`, when it is another page.
pub(super) fn source_doc(&self, id: NodeId) -> Option<Document> {
let l = self.doc.get(id)?.instance.as_ref()?;
@@ -623,7 +632,7 @@ impl Editor {
n.instance.as_ref().is_some_and(|l| match &l.source {
// A library instance whose library or component is gone still counts (detach, warning).
Some(Source::Library(lib)) => self.library_main_ok(lib, l.main) || self.is_main_missing(id),
_ => self.link_main(l).is_some_and(|(_, m)| m.component),
_ => self.link_pending(l) || self.link_main(l).is_some_and(|(_, m)| m.component),
})
})
}
@@ -638,7 +647,7 @@ impl Editor {
.is_none_or(|p| p.instance.is_none());
root && match &l.source {
Some(Source::Library(lib)) => !self.library_main_ok(lib, l.main),
_ => self.link_main(l).is_none_or(|(_, m)| !m.component),
_ => !self.link_pending(l) && self.link_main(l).is_none_or(|(_, m)| !m.component),
}
})
}
@@ -658,11 +667,14 @@ impl Editor {
.is_none_or(|p| p.instance.is_none());
let found = match &l.source {
None => doc.get(l.main),
Some(Source::Page(p)) => self
.pages
.iter()
.position(|s| s.id == *p)
.and_then(|i| self.page_doc(i).get(l.main)),
Some(Source::Page(p)) => {
let src = self.pages.iter().position(|s| s.id == *p);
// Still being read: its mains cannot be told apart from missing ones.
if src.is_some_and(|i| self.pages[i].pending) {
return false;
}
src.and_then(|i| self.page_doc(i).get(l.main))
}
Some(Source::Library(lib)) => return root && !self.library_main_ok(lib, l.main),
};
root && found.is_none_or(|m| !m.component)
@@ -1044,6 +1056,33 @@ mod tests {
assert!(!red(&e, 1, inst), "undo on B restores B");
}
#[test]
fn swap_and_main_edit_keep_how_the_root_sits_in_its_parent() {
let (mut e, comp, inst) = setup();
e.edit("absolute main", |tx| tx.node_mut(comp).unwrap().layout_child.absolute = true);
let src = e.doc.clone();
let source = Some(Source::Page(e.pages[0].id.clone()));
let other = e.add_node(
Node::new(NodeId(0), NodeKind::Frame { clip: false }, Rect::new(0., 0., 40., 40.)),
None,
);
e.selection = vec![other];
e.create_component();
let src2 = e.doc.clone();
e.with_page(1, |e| {
e.edit("in flow", |tx| {
let n = tx.node_mut(inst).unwrap();
n.layout_child.absolute = false;
n.layout_child.sizing = [Sizing::Fill, Sizing::Hug];
});
e.edit("sync", |tx| sync_node(tx, inst, true, Some(&src)));
e.edit("swap", |tx| swap_instance_tx(tx, Some(&src2), source, inst, other));
let lc = e.doc.get(inst).unwrap().layout_child;
assert!(!lc.absolute, "root stays in auto layout");
assert_eq!(lc.sizing, [Sizing::Fill, Sizing::Hug]);
});
}
#[test]
fn deleting_main_keeps_instance_and_lists_it() {
let (mut e, comp, inst) = setup();
+1
View File
@@ -227,6 +227,7 @@ impl Editor {
}
if let Some(c) = probe.color {
n.style.set_fill(Some(c));
n.bindings.remove("text-color");
}
if let Some(z) = probe.size
&& let NodeKind::Text { font_size, .. } | NodeKind::Sticky { font_size, .. } =
+1
View File
@@ -474,6 +474,7 @@ impl Editor {
/// Drop faded laser points (call with the current time before drawing).
pub fn tick(&mut self, now: f64) {
self.now_ms = now;
self.poll_loading();
self.laser.retain(|(_, t)| now - t < LASER_DECAY_MS);
}
+17
View File
@@ -619,6 +619,23 @@ impl History {
pub fn generation(&self) -> u64 {
self.generation
}
/// Folds the steps committed since the stack held `len` into one step named `label`, so an
/// action made of several tool calls undoes at once.
// ponytail: counts steps, so a stack trimmed at MAX_DEPTH meanwhile folds one step too many.
pub fn squash_since(&mut self, len: usize, label: &str) {
if self.undo.len() <= len {
return;
}
let mut steps = self.undo.split_off(len).into_iter();
let mut first = steps.next().expect("at least one step");
for c in steps {
merge(&mut first, c);
}
first.label = label.into();
first.coalesce_key = None;
self.undo.push(first);
}
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
+20 -3
View File
@@ -1,7 +1,7 @@
//! PNG export through the offscreen GPU renderer.
use super::svg::export_bounds;
use crate::geom::{Camera, V2, v2};
use crate::geom::{Camera, Rect, V2, v2};
use crate::model::{Color, Document, NodeId};
use crate::render::gpu::Headless;
use crate::render::scene::{self, View};
@@ -22,11 +22,16 @@ fn plan(
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
clip: Option<Rect>,
) -> Result<Plan, String> {
let roots: Vec<NodeId> = roots
.map(|r| r.to_vec())
.unwrap_or_else(|| doc.root.clone());
let b = export_bounds(doc, &roots).ok_or("nothing to export")?;
let b = match clip {
Some(c) => b.intersection(&c).ok_or("nothing to export")?,
None => b,
};
let scale = scale.min(MAX_EDGE / b.w.max(b.h)).max(1e-3);
let size = [
(b.w * scale).ceil().max(1.0) as u32,
@@ -66,7 +71,19 @@ pub fn render(
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background)?;
render_clip(gpu, doc, roots, scale, background, None)
}
/// [`render`] limited to `clip` (world units): only the part of the roots inside it is drawn.
pub fn render_clip(
gpu: &Headless,
doc: &Document,
roots: Option<&[NodeId]>,
scale: f64,
background: Option<Color>,
clip: Option<Rect>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background, clip)?;
let mut img = gpu.render_frame(&p.frame, p.size, 1.0, p.clear);
if p.transparent {
unpremultiply_edges(&mut img);
@@ -82,7 +99,7 @@ pub async fn render_async(
scale: f64,
background: Option<Color>,
) -> Result<image::RgbaImage, String> {
let p = plan(doc, roots, scale, background)?;
let p = plan(doc, roots, scale, background, None)?;
let mut img = gpu
.render_frame_async(&p.frame, p.size, 1.0, p.clear)
.await?;
+2
View File
@@ -51,6 +51,8 @@ fn load_editor(path: Option<&str>, demo: bool) -> Result<Editor, String> {
Session::new(store)
.open(&mut ed, &p)
.map_err(|e| format!("{}: {e}", p.display()))?;
// The commands that use this look at every page.
ed.finish_loading();
return Ok(ed);
}
let (doc, cam) = file::load(&p).map_err(|e| format!("{}: {e}", p.display()))?;
+3
View File
@@ -1239,6 +1239,9 @@ fn apply_resolved(n: &mut Node, styles: &[(String, String)], ctx: &Context) -> A
set_text_flag(n, |s| s.color = Some(c));
} else {
n.style.set_fill(Some(c));
// A new colour replaces a `text-color` binding; left in place, the next
// change of that variable would overwrite it and unlink the new one.
n.bindings.remove("text-color");
}
true
}
+447 -60
View File
@@ -10,8 +10,9 @@ use super::css::{self, Context, Styles};
use super::html::{self, Element, Html};
use super::{Out, ToolResult, tools};
use crate::editor::{Editor, design};
use crate::geom::{Rect, V2, v2};
use crate::geom::{MAX_ZOOM, MIN_ZOOM, Rect, V2, v2};
use crate::history::Tx;
use crate::model::annotation::AnnotationCategory;
use crate::model::{
AutoLayout, Binding, Color, Direction, Document, Head, ImageMode, Node, NodeId, NodeKind,
Paint, PathPoint, Route, Sizing, Source, SubPath, TextAlign,
@@ -267,12 +268,15 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
| "disable_library" => {
check_file(host, &a)?;
host.editor().commit_pending();
host.editor().finish_loading();
library_tool(host, name, &a)
}
_ => {
check_file(host, &a)?;
let ed = host.editor();
ed.commit_pending();
// Tools may name any page, and a call must see the whole project.
ed.finish_loading();
let scope = page_arg(ed, &a).unwrap_or(ed.active_page);
css::set_variables_in_scope(&ed.page_doc(scope).variables, &ed.page_doc(scope).tokens);
// The first content on the page the user is looking at is brought into view.
@@ -293,6 +297,8 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"get_annotations" => get_annotations(ed, &a),
"get_annotation_template" => get_annotation_template(ed, &a),
"set_annotation_template" => set_annotation_template(ed, &a),
"upsert_annotation_template" => upsert_annotation_template(ed, &a),
"delete_annotation_template" => delete_annotation_template(ed, &a),
"update_annotation" => update_annotation(ed, &a),
"delete_annotations" => delete_annotations(ed, &a),
"verify_code" => verify_code(ed, &a),
@@ -332,6 +338,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
"duplicate_nodes" => duplicate_nodes(ed, &a),
"move_nodes" => move_nodes(ed, &a),
"select_nodes" => select_nodes(ed, &a),
"set_viewport" => set_viewport(ed, &a),
"undo" | "redo" => undo_redo(ed, &a, name == "undo"),
"finish_working_on_nodes" => {
host.save()?;
@@ -340,7 +347,7 @@ pub fn call(host: &mut dyn Host, name: &str, args: &Value) -> ToolResult {
_ => Err(format!("unknown tool `{name}`")),
};
let ed = host.editor();
if r.is_ok() && was_empty && !ed.doc.root.is_empty() {
if r.is_ok() && was_empty && !ed.doc.root.is_empty() && name != "set_viewport" {
ed.zoom_to_fit();
}
r
@@ -544,6 +551,8 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
)
});
let ed = host.editor();
// Reports every page's node count (also reached right after `open_file`).
ed.finish_loading();
let page = page_arg(ed, a)?;
let doc = ed.page_doc(page);
let mut artboards = Vec::new();
@@ -594,9 +603,20 @@ fn basic_info(host: &mut dyn Host, a: &Args) -> ToolResult {
.map(|p| ed.page_doc(p).nodes.values().map(|n| n.resolved_comments()).sum::<usize>())
.sum::<usize>(),
"nodeIdFormat": "\"<page>:<n>\", e.g. \"".to_string() + &node_key(ed, page, NodeId(1)) + "\"",
"viewport": viewport_json(ed),
}))
}
/// What the user sees: the active page, the visible world rect and the zoom.
fn viewport_json(ed: &Editor) -> Value {
let v = ed.camera.visible_world(ed.viewport);
json!({
"pageId": ed.pages[ed.active_page].id,
"rect": [r2(v.x), r2(v.y), r2(v.w), r2(v.h)],
"zoom": (ed.camera.zoom * 1000.0).round() / 1000.0,
})
}
fn comments_json(n: &Node, with_resolved: bool) -> Vec<Value> {
n.comments
.iter()
@@ -770,7 +790,7 @@ fn selection(ed: &mut Editor) -> ToolResult {
v
})
.collect();
text(json!({"pageId": ed.pages[page].id, "selection": items}))
text(json!({"pageId": ed.pages[page].id, "selection": items, "viewport": viewport_json(ed)}))
}
fn node_info(ed: &mut Editor, a: &Args) -> ToolResult {
@@ -822,17 +842,21 @@ fn children(ed: &mut Editor, a: &Args) -> ToolResult {
text(json!({"children": kids}))
}
/// Text shown per text layer in `get_tree_summary` unless `fullText` is set.
const TREE_TEXT_CHARS: usize = 40;
fn tree_summary(ed: &mut Editor, a: &Args) -> ToolResult {
let def = page_arg(ed, a)?;
let key = a.req_str("nodeId")?;
let depth = a.num("depth").unwrap_or(3.0).clamp(1.0, 10.0) as usize;
let full = a.bool("fullText").unwrap_or(false);
let (page, roots) = if key == ROOT {
(def, ed.page_doc(def).root.clone())
} else {
let (p, id) = resolve(ed, key, def)?;
(p, vec![id])
};
fn walk(ed: &Editor, page: usize, id: NodeId, d: usize, max: usize, out: &mut String) {
fn walk(ed: &Editor, page: usize, id: NodeId, d: usize, max: usize, full: bool, out: &mut String) {
let doc = ed.page_doc(page);
let n = doc.get(id).unwrap();
let size = if matches!(n.kind, NodeKind::Text { .. }) {
@@ -854,7 +878,7 @@ fn tree_summary(ed: &mut Editor, a: &Args) -> ToolResult {
size
);
if let Some(t) = n.kind.text() {
let short: String = t.chars().take(40).collect();
let short: String = if full { t.clone() } else { t.chars().take(TREE_TEXT_CHARS).collect() };
line += &format!(" {:?}", short);
}
if !n.visible {
@@ -871,12 +895,12 @@ fn tree_summary(ed: &mut Editor, a: &Args) -> ToolResult {
return;
}
for c in &n.children {
walk(ed, page, *c, d + 1, max, out);
walk(ed, page, *c, d + 1, max, full, out);
}
}
let mut out = String::new();
for r in roots {
walk(ed, page, r, 0, depth, &mut out);
walk(ed, page, r, 0, depth, full, &mut out);
}
if out.is_empty() {
out = "(empty page)".into();
@@ -897,6 +921,9 @@ pub struct ShotRequest {
pub page: usize,
pub roots: Vec<NodeId>,
pub scale: f64,
/// Draw the notes on the captured roots (see [`with_notes`]).
#[serde(default)]
pub annotations: bool,
}
impl ShotRequest {
@@ -950,19 +977,24 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
}
None => (def, ed.page_doc(def).root.clone()),
};
let doc = ed.page_doc(page);
let b = crate::io::svg::export_bounds(doc, &roots).ok_or("nothing to capture (empty)")?;
let annotations = a.bool("annotations").unwrap_or(false);
let noted = annotations.then(|| with_notes(ed.page_doc(page), &roots, &ed.design.categories()));
let (doc, shot_roots) = match &noted {
Some((d, r)) => (d, r),
None => (ed.page_doc(page), &roots),
};
let b = crate::io::svg::export_bounds(doc, shot_roots).ok_or("nothing to capture (empty)")?;
let want = a.num("scale").unwrap_or(1.0).clamp(0.1, 4.0);
let mut scale = want.min(SHOT_MAX_EDGE / b.w.max(b.h).max(1.0));
let Some(gpu) = gpu else {
let req = ShotRequest { page, roots, scale };
let req = ShotRequest { page, roots, scale, annotations };
return Err(format!(
"{NEEDS_RENDER}{}",
serde_json::to_string(&req).map_err(|e| e.to_string())?
));
};
loop {
let img = crate::io::png::render(&gpu, doc, Some(&roots), scale, Some(Color::WHITE))?;
let img = crate::io::png::render(&gpu, doc, Some(shot_roots), scale, Some(Color::WHITE))?;
if let Some(mut out) = shot_out(&img, &mut scale)? {
if let Some(p) = &out_path {
if let Some(parent) = p.parent().filter(|d| !d.as_os_str().is_empty()) {
@@ -985,18 +1017,163 @@ fn screenshot(host: &mut dyn Host, a: &Args) -> ToolResult {
/// Finish a deferred screenshot: the same rendering as [`screenshot`], awaiting the readback.
/// `doc` is the page the [`ShotRequest`] named, as it is now.
pub async fn screenshot_async(gpu: &Headless, doc: &Document, req: &ShotRequest) -> ToolResult {
pub async fn screenshot_async(
gpu: &Headless,
doc: &Document,
cats: &[AnnotationCategory],
req: &ShotRequest,
) -> ToolResult {
let noted = req.annotations.then(|| with_notes(doc, &req.roots, cats));
let (doc, roots) = match &noted {
Some((d, r)) => (d, r),
None => (doc, &req.roots),
};
let mut scale = req.scale;
loop {
let img =
crate::io::png::render_async(gpu, doc, Some(&req.roots), scale, Some(Color::WHITE))
.await?;
let img = crate::io::png::render_async(gpu, doc, Some(roots), scale, Some(Color::WHITE))
.await?;
if let Some(out) = shot_out(&img, &mut scale)? {
return Ok(out);
}
}
}
/// A copy of `doc` with the note cards, badges and leader lines of the notes on `roots` added
/// as ordinary layers (placed and routed as the canvas draws them), and the roots to render:
/// `roots`, then the lines, then the cards. The renderer needs nothing else.
pub fn with_notes(
doc: &Document,
roots: &[NodeId],
cats: &[AnnotationCategory],
) -> (Document, Vec<NodeId>) {
use crate::model::annotation::{Side, anchor_world, card_world, numbers};
use crate::model::annotation_layout::{self as layout, BADGE, BODY_LH, BODY_PT, BODY_W, HEAD_GAP, LABEL_LH, LABEL_PT, LABEL_W, PAD};
const RADIUS: f64 = 10.0;
const LINE_W: f64 = 1.5;
const DOT_R: f64 = 3.5;
const NUMBER_PT: f64 = 11.0;
let card_fill = Color::WHITE;
let card_border = Color::rgb(0xe0, 0xe0, 0xe0);
let body_color = Color::rgb(0x6b, 0x72, 0x80);
let notes: Vec<&crate::model::annotation::Annotation> = doc
.annotations
.iter()
.filter(|a| !a.detached)
.filter(|a| a.target.is_some_and(|t| roots.iter().any(|r| *r == t || doc.is_ancestor(*r, t))))
.collect();
let mut out = doc.clone();
let mut shot = roots.to_vec();
if notes.is_empty() {
return (out, shot);
}
let nums = numbers(doc);
let cards: Vec<Rect> = notes
.iter()
.map(|a| {
let at = card_world(doc, a);
let s = layout::card_size(a);
Rect::new(at.x, at.y, s.x, s.y)
})
.collect();
let add = |out: &mut Document, mut n: Node, parent: Option<NodeId>| {
n.parent = parent;
let id = n.id;
if let Some(p) = parent.and_then(|p| out.nodes.get_mut(&p)) {
p.children.push(id);
}
out.nodes.insert(id, n);
id
};
let text_node = |out: &mut Document, s: &str, pt: f64, lh: f64, color: Color, r: Rect| {
let mut n = Node::new(
out.alloc_id(),
NodeKind::Text { text: s.into(), font_size: pt, auto_width: false, align: TextAlign::Left },
r,
);
n.style = crate::model::Style::with_fill(color);
n.text_style.line_height = Some(lh / pt);
n
};
let mut card_ids = Vec::new();
for (i, a) in notes.iter().enumerate() {
let target = a.target.unwrap();
let Some(t) = doc.world_rect(target) else { continue };
let card = cards[i];
let color = cats
.iter()
.find(|c| c.id == a.category)
.or(cats.first())
.map_or(Color::rgb(0x88, 0x88, 0x88), |c| c.color);
// Leader line, routed like the canvas does it.
let end = if a.anchor.side == Side::Center {
layout::anchor_point(t, layout::anchor_toward(t, card))
} else {
anchor_world(doc, a)
};
let mut obs = layout::route_obstacles(doc, target);
obs.extend(cards.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, r)| *r));
let pts = layout::route(card, t, end, &obs);
let bb = Rect::from_points(pts[0], pts[0]);
let bb = pts.iter().fold(bb, |b, p| b.union(&Rect::from_points(*p, *p)));
let mut line = Node::new(
out.alloc_id(),
NodeKind::Path {
subpaths: vec![SubPath {
points: pts.iter().map(|p| PathPoint::corner(*p - bb.min())).collect(),
closed: false,
}],
fill_rule: Default::default(),
},
bb,
);
line.style = crate::model::Style { stroke: Some(color), stroke_width: LINE_W, ..Default::default() };
shot.push(add(&mut out, line, None));
let mut dot = Node::new(
out.alloc_id(),
NodeKind::Ellipse,
Rect::new(end.x - DOT_R, end.y - DOT_R, DOT_R * 2.0, DOT_R * 2.0),
);
dot.style = crate::model::Style::with_fill(color);
shot.push(add(&mut out, dot, None));
// Card: frame, number badge, label, body.
let mut frame = Node::new(out.alloc_id(), NodeKind::Frame { clip: true }, card);
frame.name = format!("Note {}", nums.get(&a.id).map_or(0, |n| n.1));
frame.style = crate::model::Style {
stroke: Some(card_border),
stroke_width: 1.0,
corner_radius: RADIUS,
..crate::model::Style::with_fill(card_fill)
};
let f = add(&mut out, frame, None);
card_ids.push(f);
let mut badge = Node::new(out.alloc_id(), NodeKind::Ellipse, Rect::new(PAD, PAD, BADGE, BADGE));
badge.style = crate::model::Style::with_fill(color);
add(&mut out, badge, Some(f));
let n = nums.get(&a.id).map_or(0, |n| n.1).to_string();
let mut num = text_node(&mut out, &n, NUMBER_PT, BADGE, Color::WHITE, Rect::new(PAD, PAD, BADGE, BADGE));
num.text_style.bold = true;
if let NodeKind::Text { align, .. } = &mut num.kind {
*align = TextAlign::Center;
}
add(&mut out, num, Some(f));
let l = layout::card_lines(&a.label, &a.body, false);
let head = BADGE.max(l.label as f64 * LABEL_LH);
let label_r = Rect::new(PAD + BADGE + 8.0, PAD, LABEL_W, l.label as f64 * LABEL_LH);
let mut label = text_node(&mut out, &a.label, LABEL_PT, LABEL_LH, Color::INK, label_r);
label.text_style.bold = true;
add(&mut out, label, Some(f));
if !a.body.is_empty() {
// ponytail: a long body is cut by the card's clip, without the canvas's "+N more lines".
let body_r = Rect::new(PAD, PAD + head + HEAD_GAP, BODY_W, (l.body + usize::from(l.more > 0)) as f64 * BODY_LH);
let b = text_node(&mut out, &a.body, BODY_PT, BODY_LH, body_color, body_r);
add(&mut out, b, Some(f));
}
}
shot.extend(card_ids);
out.root.extend(shot[roots.len()..].iter().copied());
(out, shot)
}
fn parent_layout(doc: &Document, n: &Node) -> Option<AutoLayout> {
n.parent.and_then(|p| doc.get(p)).and_then(|p| p.layout)
}
@@ -1118,7 +1295,7 @@ fn find_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
let mut matched = Vec::new();
if let Some(q) = text_q {
match n.kind.text() {
Some(t) if glob(q, t) => matched.push(json!({"textValue": t})),
Some(t) if text_matches(q, t) => matched.push(json!({"textValue": t})),
_ => continue,
}
}
@@ -1153,6 +1330,22 @@ fn find_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
text(json!({"count": results.len(), "results": results}))
}
/// Case- and spacing-insensitive text match: a substring unless `q` has `*` wildcards, which
/// match the whole value. Line breaks, non-breaking spaces and curly quotes compare as typed.
fn text_matches(q: &str, t: &str) -> bool {
let norm = |s: &str| {
s.split(|c: char| c.is_whitespace() || c == '\u{a0}')
.filter(|w| !w.is_empty())
.collect::<Vec<_>>()
.join(" ")
.replace(['\u{2018}', '\u{2019}'], "'")
.replace(['\u{201c}', '\u{201d}'], "\"")
.to_lowercase()
};
let (q, t) = (norm(q), norm(t));
if q.contains('*') { glob(&q, &t) } else { t.contains(&q) }
}
fn font_info(a: &Args) -> ToolResult {
let fams: Vec<Value> = a
.strs("familyNames")?
@@ -1262,10 +1455,17 @@ fn enabled_category(ed: &Editor, page: usize, key: &str) -> Result<String, Strin
}
fn annotate(ed: &mut Editor, a: &Args) -> ToolResult {
use crate::model::annotation::{Annotation, Author, auto_position, numbers};
use crate::model::annotation::{Annotation, Author, auto_place, numbers};
let page = page_arg(ed, a)?;
let notes = a
.arr("notes")?
let tidy = a.get("tidy").and_then(Value::as_bool).unwrap_or(false);
let frame = a
.str("frame")
.filter(|_| tidy)
.map(|f| annotation_node(ed, f, page))
.transpose()?;
let none = Vec::new();
let raw = if tidy && a.get("notes").is_none() { &none } else { a.arr("notes")? };
let notes = raw
.iter()
.map(|v| {
let args = Args(v.as_object().ok_or("notes must be objects")?);
@@ -1287,17 +1487,34 @@ fn annotate(ed: &mut Editor, a: &Args) -> ToolResult {
Ok(note)
})
.collect::<Result<Vec<_>, String>>()?;
let ids = ed.with_page(page, |ed| {
let only_tidy = tidy && notes.is_empty();
let (ids, moved) = ed.with_page(page, |ed| {
ed.edit("Annotate", |tx| {
notes
let ids = notes
.into_iter()
.map(|mut note| {
note.position = auto_position(tx.doc(), note.target.unwrap());
auto_place(tx.doc(), &mut note);
tx.add_annotation(note)
})
.collect::<Vec<_>>()
.collect::<Vec<_>>();
let mut moved = 0;
if tidy {
for (id, p) in crate::model::annotation_layout::tidy(tx.doc(), frame) {
let same = tx.doc().annotations.iter().any(|n| {
n.id == id && n.position == p.position && n.anchor == p.anchor
});
if !same {
tx.update_annotation(id, |n| (n.position, n.anchor) = (p.position, p.anchor));
moved += 1;
}
}
}
(ids, moved)
})
});
if only_tidy {
return text(json!({"tidied": moved}));
}
let doc = ed.page_doc(page);
let numbers = numbers(doc);
let cats = ed.design.categories();
@@ -1348,6 +1565,129 @@ fn set_annotation_template(ed: &mut Editor, a: &Args) -> ToolResult {
text(json!({"template": t.name, "scope": a.str("scope").unwrap_or("page")}))
}
fn template_types(t: &crate::model::annotation::AnnotationTemplate) -> Value {
json!({"id": t.id, "name": t.name, "types": t.categories.iter().filter(|c| c.enabled)
.map(|c| json!([c.key, c.prompt, c.fields])).collect::<Vec<_>>()})
}
fn upsert_annotation_template(ed: &mut Editor, a: &Args) -> ToolResult {
use crate::model::annotation::{AnnotationCategory, AnnotationTemplate, Display, TemplateCategory, category_by_key};
use crate::model::Color;
let spec = a.get("template").and_then(Value::as_object).ok_or("missing required object argument `template`")?;
let t = Args(spec);
let name = t.req_str("name")?;
let page = page_arg(ed, a)?;
let mut cats = ed.design.categories();
for v in a.get("categories").map(|v| v.as_array().ok_or("categories must be an array")).transpose()?.into_iter().flatten() {
let c = Args(v.as_object().ok_or("categories must be objects")?);
let key = c.req_str("key")?.to_lowercase();
if key.is_empty() || !key.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-') {
return Err(format!("category key `{key}` must be letters, digits and dashes"));
}
let color = c.str("color").map(|s| Color::from_hex(s).ok_or(format!("bad color `{s}`; use #rrggbb"))).transpose()?;
let icon = c.str("icon").map(|i| {
if crate::theme::icon_by_name(i).is_some() { Ok(i.to_string()) }
else { Err(format!("unknown icon `{i}`; valid: {}", crate::theme::ICON_NAMES.join(", "))) }
}).transpose()?;
let existing = cats.iter().position(|x| x.key().eq_ignore_ascii_case(&key));
match existing {
Some(i) => {
let x = &mut cats[i];
if let Some(n) = c.str("name") { x.name = n.into(); }
if let Some(col) = color { x.color = col; }
if let Some(ic) = icon { x.icon = ic; }
}
None => cats.push(AnnotationCategory {
id: key.clone(),
key: key.clone(),
name: c.req_str("name")?.into(),
color: color.ok_or("new category needs `color`")?,
icon: icon.ok_or("new category needs `icon`")?,
}),
}
}
let all = ed.design.templates();
let id = match t.str("id") {
Some(id) => id.to_string(),
None => match all.iter().find(|x| x.name.eq_ignore_ascii_case(name)) {
Some(x) => x.id.clone(),
None => {
let base = match crate::design_system::slug(name) { s if s.is_empty() => "template".into(), s => s };
let mut id = base.clone();
let mut n = 1;
while all.iter().any(|x| x.id == id) { n += 1; id = format!("{base}-{n}"); }
id
}
},
};
let display = match t.str("display") {
None | Some("full") => Display::Full,
Some("badges") => Display::Badges,
Some(d) => return Err(format!("display must be full or badges, got `{d}`")),
};
let mut tcats: Vec<TemplateCategory> = Vec::new();
for v in t.arr("categories")? {
let c = Args(v.as_object().ok_or("template categories must be objects")?);
let key = c.req_str("key")?;
let Some(cat) = category_by_key(&cats, key) else {
return Err(format!(
"unknown category key `{key}`; known: {} (add it in `categories`)",
cats.iter().map(AnnotationCategory::key).collect::<Vec<_>>().join(", ")
));
};
if tcats.iter().any(|x| x.key == cat.key()) {
return Err(format!("category `{key}` listed twice"));
}
tcats.push(TemplateCategory {
key: cat.key().into(),
enabled: c.bool("enabled").unwrap_or(true),
prompt: c.str("guidance").unwrap_or_default().into(),
fields: if c.get("fields").is_some() { c.strs("fields")? } else { Vec::new() },
});
}
let tpl = AnnotationTemplate { id: id.clone(), name: name.into(), categories: tcats, display };
let set_as = match a.str("setAs") {
None => None,
Some(s @ ("project" | "page")) => Some(s),
Some(s) => return Err(format!("setAs must be project or page, got `{s}`")),
};
let out = template_types(&tpl);
let project = set_as == Some("project");
ed.edit_design("Edit annotation template", |d| {
let mut list = d.templates();
match list.iter_mut().find(|x| x.id == id) {
Some(x) => *x = tpl,
None => list.push(tpl),
}
d.annotation_templates = list;
d.annotation_categories = cats;
if project {
d.annotation_template = Some(id.clone());
}
Ok(())
})
.map_err(|e| e.to_string())?;
if set_as == Some("page") {
ed.set_page_template(page, Some(&id)).map_err(|e| e.to_string())?;
}
text(out)
}
fn delete_annotation_template(ed: &mut Editor, a: &Args) -> ToolResult {
let r = a.req_str("template")?;
let t = ed.design.template_by_ref(r).ok_or_else(|| {
format!(
"unknown template `{r}`; available: {}",
ed.design.templates().iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ")
)
})?;
if ed.design.templates().len() < 2 {
return Err("cannot delete the last template".into());
}
ed.delete_template(&t.id).map_err(|e| e.to_string())?;
text(json!({"deleted": t.name}))
}
fn get_annotations(ed: &Editor, a: &Args) -> ToolResult {
let default_page = page_arg(ed, a)?;
let scope = a
@@ -2715,16 +3055,18 @@ impl Builder<'_, '_> {
named.sort_by_key(|n| n.id);
named.first().map(|n| n.id)
});
// If not on this page, look across other pages and auto-import a copy.
// Not on this page: link to the main on another page, like create_instance does.
let mut ext = None;
if main.is_none() {
let ext = self.all_pages.iter().find_map(|(pid, pname, doc)| {
ext = self.all_pages.iter().find_map(|(pid, pname, doc)| {
if pid == &self.page_id {
return None;
}
if let (Some(p), Some(id)) = (page_part, num_part) {
if match_page_key(pid, pname, p) && doc.get(id).is_some_and(|n| n.component) {
return Some((doc, id));
}
if let (Some(p), Some(id)) = (page_part, num_part)
&& match_page_key(pid, pname, p)
&& doc.get(id).is_some_and(|n| n.component)
{
return Some((pid, doc, id));
}
let mut named: Vec<&Node> = doc
.nodes
@@ -2732,35 +3074,9 @@ impl Builder<'_, '_> {
.filter(|n| n.component && n.name.eq_ignore_ascii_case(key))
.collect();
named.sort_by_key(|n| n.id);
named.first().map(|n| (doc, n.id))
named.first().map(|n| (pid, doc, n.id))
});
if let Some((src_doc, src_comp)) = ext {
let right = self
.tx
.doc()
.root
.iter()
.filter_map(|r| self.tx.doc().world_rect(*r))
.map(|r| r.right())
.fold(f64::MIN, f64::max);
let x = if right == f64::MIN {
0.0
} else {
right + 160.0
};
let w = src_doc
.world_rect(src_comp)
.unwrap_or_else(|| src_doc.get(src_comp).unwrap().rect);
let (imported_id, _) = clone_subtree_from_doc(
self.tx,
src_doc,
src_comp,
None,
None,
v2(x, 0.0) - w.min(),
);
main = Some(imported_id);
}
main = ext.map(|(_, _, id)| id);
}
let Some(main) = main else {
self.warnings.push(format!(
@@ -2769,7 +3085,15 @@ impl Builder<'_, '_> {
));
return None;
};
let id = crate::editor::structure::instantiate(self.tx, main, parent.id, *index, None);
let id = crate::editor::structure::instantiate_from(
self.tx,
ext.map(|(_, d, _)| d),
ext.map(|(pid, _, _)| Source::Page(pid.clone())),
main,
parent.id,
*index,
None,
);
if let Some(i) = index {
*i += 1;
}
@@ -4114,7 +4438,8 @@ fn lint_design(ed: &mut Editor, a: &Args) -> ToolResult {
0
};
let doc = ed.page_doc(page);
let issues = super::lint::lint(doc, &roots, &scale);
let mut issues = super::lint::lint(doc, &roots, &scale);
issues.extend(super::lint::not_an_instance(doc, &roots, &super::lint::component_mains(ed)));
let count = |sev: &str| issues.iter().filter(|i| i.severity == sev).count();
let mut by_rule: Map<String, Value> = Map::new();
for i in &issues {
@@ -5124,7 +5449,11 @@ fn select_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
let page = ed.active_page;
let (p, ids) = resolve_all(ed, &a.strs("nodeIds")?, page)?;
if p != page {
return Err("only nodes on the page the user is viewing can be selected".into());
return Err(
"only nodes on the page the user is viewing can be selected; call set_viewport \
with that pageId first to switch the user's page"
.into(),
);
}
ed.select(ids.clone());
if a.get("zoom").and_then(Value::as_bool).unwrap_or(false)
@@ -5140,6 +5469,64 @@ fn select_nodes(ed: &mut Editor, a: &Args) -> ToolResult {
text(json!({"selected": ids.len()}))
}
/// How long after the user moved the camera agents leave it alone (as chat's follow does).
pub const USER_CAMERA_QUIET: std::time::Duration = std::time::Duration::from_secs(10);
/// Screen margin around what `set_viewport` fits, and the most it zooms in to fit.
const FIT_PADDING: f64 = 64.0;
const FIT_MAX_ZOOM: f64 = 8.0;
fn set_viewport(ed: &mut Editor, a: &Args) -> ToolResult {
let force = a.bool("force").unwrap_or(false);
if !force && ed.cam_moved.is_some_and(|t| t.elapsed() < USER_CAMERA_QUIET) {
return Err("user is moving the canvas; try again later, or pass force: true".into());
}
let page = page_arg(ed, a)?;
let target = match (a.get("nodeIds"), a.get("rect")) {
(Some(_), _) => {
let (p, ids) = resolve_all(ed, &a.strs("nodeIds")?, page)?;
if p != page && a.str("pageId").is_some_and(|s| !s.is_empty()) {
return Err("nodeIds are on another page than pageId".into());
}
let doc = ed.page_doc(p);
let rects: Vec<Rect> = ids.iter().filter_map(|i| doc.world_rect(*i)).collect();
Some((p, Rect::union_all(&rects).ok_or("no nodes to fit")?))
}
(None, Some(r)) => {
let n: Vec<f64> = r
.as_array()
.map(|v| v.iter().filter_map(Value::as_f64).collect())
.unwrap_or_default();
let [x, y, w, h] = n[..] else {
return Err("rect must be [x, y, width, height] in world units".into());
};
Some((page, Rect::new(x, y, w.max(1.0), h.max(1.0))))
}
(None, None) => None,
};
let page = target.map_or(page, |(p, _)| p);
ed.switch_page(page);
if ed.active_page != page {
return Err("cannot switch to that page".into());
}
let zoom = a.num("zoom").map(|z| z.clamp(MIN_ZOOM, MAX_ZOOM));
let pad = a.num("padding").unwrap_or(FIT_PADDING).max(0.0);
match (target, zoom) {
(Some((_, r)), Some(z)) => {
ed.camera.zoom = z;
ed.camera.pan = r.center() - ed.viewport / (2.0 * z);
}
(Some((_, r)), None) => ed.camera.fit(&r, ed.viewport, pad, FIT_MAX_ZOOM),
(None, Some(z)) => ed.camera.zoom_about(ed.viewport * 0.5, z),
(None, None) => {}
}
if target.is_some() || zoom.is_some() {
// A page opened for the first time would refit on the next frame.
ed.pending_fit = false;
}
ed.revision += 1;
text(json!({"viewport": viewport_json(ed)}))
}
fn undo_redo(ed: &mut Editor, a: &Args, undo: bool) -> ToolResult {
let page = page_arg(ed, a)?;
let label = ed.with_page(page, |ed| {
+117 -1
View File
@@ -79,7 +79,7 @@ fn backdrop(doc: &Document, id: NodeId) -> Color {
.fold(Color::rgb(255, 255, 255), blend)
}
fn is_default_name(name: &str) -> bool {
pub(crate) fn is_default_name(name: &str) -> bool {
let base = name.trim_end_matches(|c: char| c.is_ascii_digit() || c == ' ');
matches!(
base,
@@ -219,8 +219,11 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
);
}
// ---- colors that should be variables
// A `text-color` binding (bind_variable) follows its variable without a fill link.
let bound_text = n.kind.text().is_some() && n.bindings.contains_key("text-color");
if let Some((c, var)) = solid(n)
&& var.is_none()
&& !bound_text
&& c.a == 255
{
if let Some((name, _)) = vars.iter().find(|(_, v)| **v == c) {
@@ -248,6 +251,68 @@ pub fn lint(doc: &Document, roots: &[NodeId], scale: &[f64]) -> Vec<Issue> {
out
}
/// Largest width or height difference at which a frame still counts as a copy of a component.
const COPY_SIZE_TOLERANCE: f64 = 4.0;
/// Text size difference at which two texts still have the same role.
const TEXT_SIZE_TOLERANCE: f64 = 0.5;
/// Corner radius difference at which two frames still look the same.
const COPY_RADIUS_TOLERANCE: f64 = 1.0;
/// Same kinds and child counts all the way down (children in order, as `diff_frames` pairs
/// them), frames that look alike (fill, radius, stroke), and texts with the same role (size
/// and weight). Without the look a tab strip and a button of the same size would match.
fn same_shape(a: &Document, ia: NodeId, b: &Document, ib: NodeId) -> bool {
let (Some(na), Some(nb)) = (a.get(ia), b.get(ib)) else { return false };
if na.kind.type_name() != nb.kind.type_name() || na.children.len() != nb.children.len() {
return false;
}
if na.is_frame()
&& (na.style.fills.first() != nb.style.fills.first()
|| (na.style.corner_radius - nb.style.corner_radius).abs() > COPY_RADIUS_TOLERANCE
|| (na.style.stroke.is_some() != nb.style.stroke.is_some()))
{
return false;
}
if let (Some(sa), Some(sb)) = (na.kind.font_size(), nb.kind.font_size())
&& ((sa - sb).abs() > TEXT_SIZE_TOLERANCE || na.text_style.bold != nb.text_style.bold)
{
return false;
}
na.children.iter().zip(&nb.children).all(|(ca, cb)| same_shape(a, *ca, b, *cb))
}
/// Every main component the editor knows: on any page and in enabled libraries.
pub fn component_mains(ed: &crate::editor::Editor) -> Vec<(&Document, NodeId)> {
let docs = (0..ed.pages.len()).map(|p| ed.page_doc(p)).chain(ed.library_docs.values());
docs.flat_map(|d| d.nodes.values().filter(|n| n.component).map(move |n| (d, n.id))).collect()
}
/// `not-an-instance`: frames under `roots` built by hand to look like one of `mains` (same
/// structure, size within 4px) instead of being an instance of it. `lint_design {fix}` leaves
/// these alone; swapping is the user's call.
pub fn not_an_instance(doc: &Document, roots: &[NodeId], mains: &[(&Document, NodeId)]) -> Vec<Issue> {
let mut out = Vec::new();
for id in roots.iter().flat_map(|r| doc.subtree(*r)) {
let Some(n) = doc.get(id) else { continue };
if !n.visible || !n.is_frame() || n.component || n.instance.is_some() || n.children.is_empty() {
continue;
}
let near = |m: &Node| (m.rect.w - n.rect.w).abs() <= COPY_SIZE_TOLERANCE && (m.rect.h - n.rect.h).abs() <= COPY_SIZE_TOLERANCE;
let hit = mains.iter().find(|(d, m)| d.get(*m).is_some_and(near) && same_shape(doc, id, d, *m));
let Some(m) = hit.and_then(|(d, m)| d.get(*m)) else { continue };
let key = m.component_key.as_deref().unwrap_or(&m.name);
out.push(Issue {
rule: "not-an-instance",
severity: "warning",
node: id,
message: format!("`{}` looks like component `{}` but is not an instance of it", n.name, m.name),
fix: format!("swap_instance with componentKey {key}"),
});
}
out
}
fn snap4(v: f64) -> f64 {
if v <= 0.0 {
0.0
@@ -383,6 +448,57 @@ mod tests {
assert!(is_default_name("Frame 12") && !is_default_name("Task Row"));
}
#[test]
fn text_colour_binding_is_not_hard_coded() {
let mut doc = Document::default();
let c = Color::rgb(0x8a, 0x6d, 0);
doc.variables.insert("tag-text".into(), c);
let id = doc.alloc_id();
let mut n = Node::new(
id,
crate::model::NodeKind::Text { text: "Hi".into(), font_size: 16.0, auto_width: true, align: Default::default() },
crate::geom::Rect::new(0.0, 0.0, 40.0, 20.0),
);
n.name = "Label".into();
n.style.fills = vec![Paint::solid(c)];
doc.nodes.insert(id, n);
doc.root.push(id);
let hard = |d: &Document| lint(d, &[id], &DEFAULT_SCALE).iter().filter(|i| i.rule == "hard-coded-color").count();
assert_eq!(hard(&doc), 1);
doc.nodes.get_mut(&id).unwrap().bindings.insert("text-color".into(), "tag-text".into());
assert_eq!(hard(&doc), 0);
}
#[test]
fn hand_made_copy_of_a_component_is_flagged_and_an_instance_is_not() {
use crate::mcp::{Out, exec};
use serde_json::{Value, json};
let dir = std::env::temp_dir().join(format!("papercraft-lint-{:016x}", fastrand::u64(..)));
let mut h = exec::SessionHost::new(crate::project::Store::open(dir).unwrap());
h.session.create(&mut h.ed, "Lint").unwrap();
let mut call = |name: &str, args: Value| match exec::call(&mut h, name, &args).unwrap_or_else(|e| panic!("{name}: {e}")).remove(0) {
Out::Text(t) => serde_json::from_str::<Value>(&t).unwrap(),
_ => Value::Null,
};
let button = |name: &str, label: &str| format!("<div layer-name=\"{name}\" style=\"display:flex;padding:12px 16px\"><div style=\"font-size:16px\">{label}</div></div>");
let ab = call("create_artboard", json!({"name": "Screen", "styles": {"width": "400px", "height": "400px", "display": "flex", "gap": "8px"}}));
let r = call("write_html", json!({"targetNodeId": ab["id"], "mode": "insert-children", "html": button("Button", "Save")}));
let main = call("create_component", json!({"nodeId": r["createdNodeIds"][0], "name": "Button"}));
call("create_instance", json!({"componentId": main["id"], "parentId": ab["id"]}));
let copy = call("write_html", json!({"targetNodeId": ab["id"], "mode": "insert-children", "html": button("Save copy", "Send")}));
call("write_html", json!({"targetNodeId": ab["id"], "mode": "insert-children", "html": "<div layer-name=\"Other\" style=\"display:flex;padding:12px 16px\"><div style=\"font-size:24px\">Big</div></div>"}));
// Same structure and size but a different look (a filled tab): not a copy of Button.
call("write_html", json!({"targetNodeId": ab["id"], "mode": "insert-children", "html": "<div layer-name=\"Tab\" style=\"display:flex;padding:12px 16px;background:#1677ff\"><div style=\"font-size:16px\">Code</div></div>"}));
let hits = |r: &Value| r["issues"].as_array().unwrap().iter().filter(|i| i["rule"] == "not-an-instance").cloned().collect::<Vec<_>>();
let r = call("lint_design", json!({}));
let found = hits(&r);
assert_eq!(found.len(), 1, "only the hand-made copy: {r}");
assert_eq!(found[0]["node"], copy["createdNodeIds"][0]);
assert!(found[0]["fix"].as_str().unwrap().starts_with("swap_instance with componentKey "), "{}", found[0]);
let r = call("lint_design", json!({"fix": true}));
assert_eq!(hits(&r).len(), 1, "fix does not swap: {r}");
}
#[test]
fn autofix_snaps_grid_and_links_variables() {
let mut doc = Document::default();
+21
View File
@@ -53,10 +53,31 @@ pub enum Out {
pub type ToolResult = Result<Vec<Out>, String>;
/// Name of the client for calls made by an in-app chat turn; its session is the chat id.
pub const CHAT_CLIENT: &str = "Chat";
/// Client of the Actions menu's in-process actions (Bind to tokens, Use components, ...).
pub const ACTIONS_CLIENT: &str = "Actions";
/// Who made a tool call: each (name, session) pair gets its own agent run.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Client {
/// The MCP `clientInfo` title or name ("Claude Code"), or [`CHAT_CLIENT`].
pub name: String,
/// The MCP session id, or the chat id.
pub session: String,
}
impl Client {
pub fn chat(id: &str) -> Client {
Client { name: CHAT_CLIENT.into(), session: id.into() }
}
}
/// A tool call travelling from the HTTP thread to the editor thread.
pub struct Call {
pub name: String,
pub args: serde_json::Value,
pub client: Client,
pub reply: tokio::sync::oneshot::Sender<ToolResult>,
}
+26 -2
View File
@@ -5,7 +5,7 @@
//! `Accept` header (some clients omit it and then misread the 406 as an auth failure) and
//! answers unknown routes with a plain JSON hint rather than an OAuth-looking error.
use super::{Call, ENDPOINT, Out, ToolResult, tools};
use super::{Call, Client, ENDPOINT, Out, ToolResult, tools};
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
@@ -85,12 +85,13 @@ impl ServerHandler for PaperServer {
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
let (reply, rx) = tokio::sync::oneshot::channel();
let call = Call {
name: request.name.to_string(),
args: serde_json::Value::Object(request.arguments.unwrap_or_default()),
client: client_of(&context),
reply,
};
let r = if !(self.dispatch)(call) {
@@ -106,6 +107,29 @@ impl ServerHandler for PaperServer {
}
}
/// Who is calling: an in-app chat turn when the URL carries `?chat=<id>` (the chat's CLI
/// backends connect that way), else the MCP client's title or name and its session id.
fn client_of(context: &RequestContext<RoleServer>) -> Client {
let parts = context.extensions.get::<axum::http::request::Parts>();
if let Some(id) = parts.and_then(|p| p.uri.query()).and_then(chat_of) {
return Client::chat(id);
}
let name = context
.client_info()
.map(|i| i.title.filter(|t| !t.is_empty()).unwrap_or(i.name))
.unwrap_or_default();
let session = parts
.and_then(|p| p.headers.get("mcp-session-id"))
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
Client { name, session: session.into() }
}
/// The chat id in an MCP URL query (`chat=<id>`).
pub fn chat_of(query: &str) -> Option<&str> {
query.split('&').find_map(|kv| kv.strip_prefix("chat="))
}
/// Add the `Accept` header the streamable HTTP transport requires when a client omits it.
/// Shared with the relay, which serves the same transport to remote agents.
pub(crate) async fn fix_accept(
+62 -20
View File
@@ -87,14 +87,28 @@ pub fn definitions() -> Vec<Value> {
let node_id = |d: &str| json!({"type": "string", "description": d});
let ids = |d: &str| json!({"type": "array", "items": {"type": "string"}, "description": d});
vec![
tool("annotate", "Add notes to node ids or exact layer names on a page in one undo step. First text line is label; remaining lines are body. Type is an allowed category key (default rule); put fields as \"Name: value\" lines in text. Placement is automatic. Returns [number, node, type, label] rows.",
tool("annotate", "Add notes to node ids or exact layer names on a page in one undo step. First text line is label; remaining lines are body. Type is an allowed category key (default rule); put fields as \"Name: value\" lines in text. Placement is automatic (tidy re-lays-out). Returns [number, node, type, label] rows; tidy alone returns {tidied: moved}.",
json!({"pageId": page_id(), "notes": {"type": "array", "items": {
"type": "object", "properties": {"node": {"type": "string"},
"text": {"type": "string"}, "type": {"type": "string"}}, "required": ["node", "text"]}}}), &["notes"], false),
"text": {"type": "string"}, "type": {"type": "string"}}, "required": ["node", "text"]}},
"tidy": {"type": "boolean", "description": "Re-layout all notes (of frame, else the page) as part of this undo step; notes may then be omitted."},
"frame": {"type": "string"}}), &[], false),
tool("get_annotation_template", "Read the page's annotation template: [type key, guidance, field names] for each allowed type. Call once before annotate.",
json!({"pageId": page_id()}), &[], true),
tool("set_annotation_template", "Switch the annotation template by id or name, for one page or the whole project. Annotate only accepts the template's types.",
json!({"template": {"type": "string"}, "scope": {"type": "string", "enum": ["page", "project"]}, "pageId": page_id()}), &["template"], false),
tool("upsert_annotation_template", "Create or replace an annotation template, optionally defining new categories (key, name, #hex color, icon name) first. Reuse existing keys freely. One undo step for the edit; setAs page adds a second. Returns {id, name, types}.",
json!({"template": {"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"},
"display": {"type": "string", "enum": ["full", "badges"]},
"categories": {"type": "array", "items": {"type": "object", "properties": {"key": {"type": "string"},
"enabled": {"type": "boolean"}, "guidance": {"type": "string"},
"fields": {"type": "array", "items": {"type": "string"}}}, "required": ["key"]}}},
"required": ["name", "categories"]},
"categories": {"type": "array", "items": {"type": "object", "properties": {"key": {"type": "string"},
"name": {"type": "string"}, "color": {"type": "string"}, "icon": {"type": "string"}}, "required": ["key"]}},
"setAs": {"type": "string", "enum": ["project", "page"]}, "pageId": page_id()}), &["template"], false),
tool("delete_annotation_template", "Delete an annotation template by id or name. The last template cannot be deleted; a project default pointing at it reverts to Product spec.",
json!({"template": {"type": "string"}}), &["template"], false),
tool("get_annotations", "Read annotations as [number, nodeName, type, text] rows. Text includes label and first body line, limited to 120 characters. Use full for complete objects, nodeId for a subtree, or pageId for another page.",
json!({"nodeId": {"type": "string"}, "pageId": page_id(), "full": {"type": "boolean"}}), &[], true),
tool("update_annotation", "Update annotation text or category key by id or frame reference such as Sign in #3. First text line is label; remaining lines are body. One undo step.",
@@ -167,7 +181,8 @@ pub fn definitions() -> Vec<Value> {
"get_basic_info",
"Essential context about the open project: project name, the page, node count, \
artboards (top-level frames) with ids, sizes and world positions, other top-level \
layers, fonts, and `pages` with the active one marked isActive.\n\
layers, fonts, `pages` with the active one marked isActive, and `viewport` \
({pageId, rect: [x, y, w, h] world, zoom}: what the user sees).\n\
- worldX/worldY are world positions; x/y are relative to the parent.\n\
- Pass pageId to inspect another page without switching the user's view.",
json!({"pageId": page_id()}),
@@ -177,7 +192,8 @@ pub fn definitions() -> Vec<Value> {
tool(
"get_selection",
"The selected layers on the page the user is viewing: ids, names, types, sizes, \
positions and the artboard each belongs to.",
positions and the artboard each belongs to, plus `viewport` ({pageId, rect: \
[x, y, w, h] world, zoom}).",
json!({}),
&[],
true,
@@ -204,7 +220,8 @@ pub fn definitions() -> Vec<Value> {
cheaper than get_jsx for orientation. nodeId \"root\" summarizes the whole page.",
json!({
"nodeId": node_id("Root of the summary, or \"root\"."),
"depth": {"type": "number", "description": "Maximum depth (default 3, max 10)."},
"depth": {"type": "number", "description": "Levels shown, counting nodeId (or each top-level layer for \"root\") as level 1 (default 3, max 10). Deeper levels collapse to \"… N children\"."},
"fullText": {"type": "boolean", "description": "Show each text layer's complete text instead of its first 40 characters (for copy review)."},
"pageId": page_id(),
}),
&["nodeId"],
@@ -219,6 +236,7 @@ pub fn definitions() -> Vec<Value> {
"nodeId": node_id("The node to capture; omit for the whole page."),
"scale": {"type": "number", "description": "Render scale: 1 (default) or 2."},
"out": {"type": "string", "description": "Optional file path to save the PNG to (also used by papercraft-cli)."},
"annotations": {"type": "boolean", "description": "Also draw the note cards, badges and leader lines of notes on the captured layers; the image grows to include the cards."},
"pageId": page_id(),
}),
&[],
@@ -288,8 +306,9 @@ pub fn definitions() -> Vec<Value> {
tool(
"find_nodes",
"Find nodes by text content and/or computed style. Searches the whole project by \
default; pass pageId for one page or nodeId for a subtree. `textValue` matches text \
case-insensitively with * wildcards anchored to the whole value. `filters` are \
default; pass pageId for one page or nodeId for a subtree. `textValue` finds text \
containing it, ignoring case and line breaks; with * wildcards it must match the \
whole value (\"Get *\"). `filters` are \
{styleName, styleValue} pairs (AND), values may use * wildcards; colors match by value.",
json!({
"pageId": page_id(),
@@ -669,11 +688,11 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"import_components",
"Copy the main components anywhere on `fromPageId` (including inside artboards, \
typically on a `Design System` page) onto this one, so you can place instances here \
with create_instance or <x-paper-instance component=\"Name\"/> / component-id=\"ID\". \
Components with the same name already here are skipped. Variables and tokens are \
already shared by every page; components are copies, not live links.",
"Copies mains onto this page (detached from the source); only for moving a design \
system, not for using components: create_instance and swap_instance take a main on \
any page or an enabled library directly, with no import. Copies the main components \
anywhere on `fromPageId` (including inside artboards); components with the same name \
already here are skipped. Variables and tokens are already shared by every page.",
json!({"fromPageId": {"type": "string"}, "pageId": page_id(),
"names": {"type": "array", "items": {"type": "string"},
"description": "Only these components (default all)."}}),
@@ -693,9 +712,11 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"create_instance",
"Place an instance of a component. Edit text and styles on the instance's descendants \
with set_text_content / update_styles (they become overrides); edits to the main \
component flow to every instance. Returns the instance id and its text layers.",
"Place an instance of a component. componentKey may be a component on any page or an \
enabled library; no import needed (the instance stays linked to that main). Edit text \
and styles on the instance's descendants with set_text_content / update_styles (they \
become overrides); edits to the main component flow to every instance. Returns the \
instance id and its text layers.",
json!({"componentId": {"type": "string", "description": "A main on any page (\"pageId:num\")."},
"componentKey": {"type": "string", "description": "Instead of componentId: the key from get_components."},
"parentId": {"type": "string", "description": "Parent frame; omit or \"root\" for the page."},
@@ -708,8 +729,9 @@ pub fn definitions() -> Vec<Value> {
),
tool(
"swap_instance",
"Swap an instance root to point at a different main component (any page), \
preserving overrides on descendants matched by name and layer type.",
"Swap an instance root to point at a different main component, preserving overrides \
on descendants matched by name and layer type. componentKey may be a component on \
any page or an enabled library; no import needed.",
json!({"nodeId": node_id("The instance root node to swap."),
"componentId": node_id("The new main component id."),
"componentKey": {"type": "string", "description": "Instead of componentId: a key from get_components, including a library component."},
@@ -800,11 +822,31 @@ pub fn definitions() -> Vec<Value> {
tool(
"select_nodes",
"Select nodes on the page the user is viewing so they can see what you mean; \
zoom: true also frames them in the viewport. An empty list clears the selection.",
zoom: true also frames them in the viewport (at most 100%). An empty list clears \
the selection. For another page or a closer zoom use set_viewport.",
json!({"nodeIds": ids("Nodes to select."), "zoom": {"type": "boolean"}}),
&["nodeIds"],
false,
),
tool(
"set_viewport",
"Move the user's view: switch to pageId (as clicking it in the page list does) and \
fit nodeIds or a world rect, optionally at an explicit zoom (0.01 to 256). With only \
zoom it zooms about the view's centre. Refused while the user is moving the canvas \
(within 10 s of their last pan or zoom) unless force: true. Returns the new viewport \
({pageId, rect: [x, y, w, h] in world units, zoom}); get_basic_info and \
get_selection report the current one.",
json!({
"pageId": {"type": "string", "description": "Page to show; defaults to the page of nodeIds, else the current one."},
"nodeIds": ids("Nodes to fit in the view."),
"rect": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4, "description": "World rect [x, y, width, height] to fit, instead of nodeIds."},
"zoom": {"type": "number", "description": "Explicit zoom level (1 = 100%); centres the nodes or rect at this zoom instead of fitting."},
"padding": {"type": "number", "description": "Screen margin when fitting, in px (default 64)."},
"force": {"type": "boolean", "description": "Move the view even if the user is panning or zooming."},
}),
&[],
false,
),
tool(
"undo",
"Undo the last change on a page (the user's or an agent's).",
@@ -918,12 +960,12 @@ fn classify(name: &str, args: &Value) -> Option<bool> {
| "get_computed_styles" | "get_fill_image" | "find_nodes" | "get_font_family_info"
| "get_annotations" | "get_annotation_template" | "get_guide" | "get_feedback" | "get_variables" | "get_components" | "search_icons"
| "diff_frames" | "publish_library" | "list_libraries" => false,
"select_nodes" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
"select_nodes" | "set_viewport" | "export" | "export_combined_pdf" | "export_design_system" | "open_file"
| "create_file" | "finish_working_on_nodes" => false,
"lint_design" => args["fix"] == true,
"create_page" | "rename_pages" | "delete_pages" | "create_artboard" | "write_html"
| "create_shape" | "update_styles" | "set_text_content" | "rename_nodes"
| "annotate" | "set_annotation_template" | "update_annotation" | "delete_annotations" | "add_feedback" | "resolve_feedback" | "set_variables" | "set_tokens"
| "annotate" | "set_annotation_template" | "upsert_annotation_template" | "delete_annotation_template" | "update_annotation" | "delete_annotations" | "add_feedback" | "resolve_feedback" | "set_variables" | "set_tokens"
| "create_collection" | "delete_variables" | "bind_variable"
| "enable_library" | "update_library" | "disable_library"
| "set_variable_mode" | "import_design_system" | "import_components" | "create_component"
+9 -20
View File
@@ -109,28 +109,17 @@ pub fn category_by_key<'a>(cats: &'a [AnnotationCategory], key: &str) -> Option<
cats.iter().find(|c| c.key().eq_ignore_ascii_case(key))
}
/// Size the canvas gives a note card, used to stack automatically placed notes.
pub const CARD_W: f64 = 220.0;
pub const CARD_H: f64 = 72.0;
const CARD_GAP: f64 = 24.0;
/// Card width and minimum height in world units; the real height grows with the text (see
/// [`super::annotation_layout::card_size`]).
pub const CARD_W: f64 = 280.0;
pub const CARD_H: f64 = 44.0;
/// Where a new note on `target` goes, as a `position` (relative to the target's top-left):
/// right of the target's top-level frame, level with the target, pushed down past any
/// existing note card so none overlap.
pub fn auto_position(doc: &Document, target: NodeId) -> V2 {
let Some(t) = doc.world_rect(target) else { return V2::ZERO };
let frame = frame_of(doc, target)
.and_then(|f| doc.world_rect(f))
.unwrap_or(t);
let x = frame.right() + CARD_GAP;
let mut y = t.y;
let cards: Vec<V2> = doc.annotations.iter().map(|a| card_world(doc, a)).collect();
while cards.iter().any(|c| {
x < c.x + CARD_W && c.x < x + CARD_W && y < c.y + CARD_H && c.y < y + CARD_H
}) {
y += CARD_H + CARD_GAP / 2.0;
/// Set `a`'s card position and anchor next to its target, clear of every node and note.
pub fn auto_place(doc: &Document, a: &mut Annotation) {
if let Some(p) = super::annotation_layout::place(doc, a) {
a.position = p.position;
a.anchor = p.anchor;
}
V2 { x: x, y: y } - t.min()
}
/// The six built-in categories, with stable ids.
+584
View File
@@ -0,0 +1,584 @@
//! Note card sizing, placement and leader-line routing. Pure, in world units (card scale 1).
//!
//! Cards grow to their text (label up to 2 lines, body up to 8, then "+N more lines"); placement
//! picks the free slot nearest the target that overlaps no node and no other card; leader lines
//! are routed orthogonally around obstacle rects with a small grid A*.
use super::annotation::{Anchor, Annotation, CARD_W, Side, card_world, frame_of};
use super::document::Document;
use super::node::NodeId;
use crate::geom::{Rect, V2, v2};
use std::cmp::Reverse;
use std::collections::BinaryHeap;
pub const PAD: f64 = 12.0;
pub const BADGE: f64 = 18.0;
pub const ICON: f64 = 14.0;
pub const LABEL_PT: f64 = 13.0;
pub const BODY_PT: f64 = 11.0;
pub const LABEL_LH: f64 = 16.0;
pub const BODY_LH: f64 = 14.0;
pub const LABEL_LINES: usize = 2;
pub const BODY_LINES: usize = 8;
pub const HEAD_GAP: f64 = 6.0;
/// Free space kept between a card and any node or other card.
pub const MARGIN: f64 = 12.0;
const ROUTE_PAD: f64 = 6.0;
const ROUTE_WINDOW: f64 = 300.0;
const ROUTE_MAX_OBSTACLES: usize = 160;
const BEND_COST: f64 = 24.0;
const OUTSIDE_FRAME_COST: f64 = 120.0;
const STRADDLE_COST: f64 = 1200.0;
const CROSS_COST: f64 = 600.0;
const LINE_COST: f64 = 0.6;
const BEND_PENALTY: f64 = 40.0;
const FEASIBLE_TRIED: usize = 24;
const RINGS: [(f64, f64); 3] = [(360.0, 12.0), (1200.0, 40.0), (4000.0, 120.0)];
/// Width available to the label and to the body, in world units.
pub const LABEL_W: f64 = CARD_W - 2.0 * PAD - BADGE - 8.0 - ICON - 6.0;
pub const BODY_W: f64 = CARD_W - 2.0 * PAD;
fn char_w(c: char, pt: f64) -> f64 {
pt * match c {
' ' => 0.28,
'i' | 'l' | 'j' | 't' | 'f' | 'I' | '.' | ',' | ':' | ';' | '\'' | '!' | '|' | '(' | ')' | '[' | ']' => 0.32,
'm' | 'w' | 'M' | 'W' | '@' => 0.85,
c if c.is_uppercase() => 0.64,
_ => 0.52,
}
}
/// Estimated wrapped line count of `text` at `width` (word wrap, long words break, `\n` splits).
pub fn wrap_lines(text: &str, width: f64, pt: f64) -> usize {
text.split('\n')
.map(|para| {
let (mut lines, mut x) = (1usize, 0.0);
for word in para.split_whitespace() {
let w: f64 = word.chars().map(|c| char_w(c, pt)).sum();
let space = char_w(' ', pt);
if x > 0.0 && x + space + w <= width {
x += space + w;
continue;
}
if x > 0.0 {
lines += 1;
}
let extra = (w / width).ceil().max(1.0) - 1.0;
lines += extra as usize;
x = if extra > 0.0 { w - extra * width } else { w };
}
lines
})
.sum()
}
/// What a card shows: label rows, body rows, and body rows cut off (`+N more lines`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Lines {
pub label: usize,
pub body: usize,
pub more: usize,
}
pub fn card_lines(label: &str, body: &str, expanded: bool) -> Lines {
let label_n = wrap_lines(label, LABEL_W, LABEL_PT);
let body_n = if body.is_empty() { 0 } else { wrap_lines(body, BODY_W, BODY_PT) };
if expanded {
return Lines { label: label_n, body: body_n, more: 0 };
}
let shown = body_n.min(BODY_LINES);
Lines { label: label_n.min(LABEL_LINES), body: shown, more: body_n - shown }
}
/// Card height from its measured row heights (`head` = tallest of badge and label).
pub fn height_of(head: f64, body: f64, has_body: bool) -> f64 {
PAD + head + if has_body { HEAD_GAP + body } else { 0.0 } + PAD
}
/// Estimated card size in world units.
pub fn card_size(a: &Annotation) -> V2 {
let l = card_lines(&a.label, &a.body, false);
let head = BADGE.max(l.label as f64 * LABEL_LH);
let body = (l.body + usize::from(l.more > 0)) as f64 * BODY_LH;
v2(CARD_W, height_of(head, body, !a.body.is_empty()))
}
fn overlaps(a: &Rect, b: &Rect) -> bool {
a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
}
fn strictly_inside(r: &Rect, p: V2) -> bool {
p.x > r.x && p.x < r.right() && p.y > r.y && p.y < r.bottom()
}
/// Where a leader line ends on `target`: the edge facing `card`, level with it where they
/// overlap in span so the line is straight.
pub fn anchor_toward(target: Rect, card: Rect) -> Anchor {
let span = |lo: f64, hi: f64, clo: f64, chi: f64, c: f64| {
let (a, b) = (lo.max(clo), hi.min(chi));
if a <= b { (a + b) / 2.0 } else { c.clamp(lo, hi) }
};
let c = card.center();
if card.x >= target.right() {
let y = span(target.y, target.bottom(), card.y, card.bottom(), c.y);
Anchor { side: Side::Right, t: (y - target.y) / target.h.max(1.0) }
} else if card.right() <= target.x {
let y = span(target.y, target.bottom(), card.y, card.bottom(), c.y);
Anchor { side: Side::Left, t: (y - target.y) / target.h.max(1.0) }
} else {
let x = span(target.x, target.right(), card.x, card.right(), c.x);
let side = if card.y >= target.bottom() { Side::Bottom } else if card.bottom() <= target.y { Side::Top } else { return Anchor::default() };
Anchor { side, t: (x - target.x) / target.w.max(1.0) }
}
}
pub fn anchor_point(target: Rect, a: Anchor) -> V2 {
let t = a.t.clamp(0.0, 1.0);
match a.side {
Side::Top => v2(target.x + t * target.w, target.y),
Side::Bottom => v2(target.x + t * target.w, target.bottom()),
Side::Left => v2(target.x, target.y + t * target.h),
Side::Right => v2(target.right(), target.y + t * target.h),
Side::Center => target.center(),
}
}
/// Simple orthogonal leader line from `card` to `end`: straight when level, else one elbow.
pub fn elbow(card: Rect, end: V2) -> Vec<V2> {
if end.x >= card.x && end.x <= card.right() {
let y = if end.y < card.y { card.y } else { card.bottom() };
return vec![v2(end.x, y), end];
}
let sx = if end.x > card.right() { card.right() } else { card.x };
let sy = if end.y >= card.y && end.y <= card.bottom() { end.y } else { card.center().y };
let s = v2(sx, sy);
if (s.y - end.y).abs() < 0.5 {
return vec![s, end];
}
let mx = (s.x + end.x) / 2.0;
vec![s, v2(mx, s.y), v2(mx, end.y), end]
}
fn seg_box(a: V2, b: V2) -> Rect {
Rect::from_points(a, b)
}
fn crosses_rect(path: &[V2], r: &Rect) -> bool {
path.windows(2).any(|w| overlaps(&seg_box(w[0], w[1]).inflate(0.5), r))
}
fn paths_cross(a: &[V2], b: &[V2]) -> bool {
a.windows(2).any(|s| {
let sb = seg_box(s[0], s[1]);
b.windows(2).any(|t| {
let tb = seg_box(t[0], t[1]);
sb.x <= tb.right() && tb.x <= sb.right() && sb.y <= tb.bottom() && tb.y <= sb.bottom()
})
})
}
// ------------------------------------------------------------------------------ obstacles
/// Rects a card or line must keep clear of when annotating `target`: every node on the page
/// except `target` itself and its ancestors (their other children count, as whole nodes).
pub fn obstacles(doc: &Document, target: Option<NodeId>) -> Vec<Rect> {
fn walk(doc: &Document, ids: &[NodeId], target: Option<NodeId>, out: &mut Vec<Rect>) {
for &id in ids {
let Some(n) = doc.get(id) else { continue };
if !n.visible || Some(id) == target {
continue;
}
if target.is_some_and(|t| doc.is_ancestor(id, t)) {
walk(doc, &n.children, target, out);
} else if let Some(r) = doc.world_rect(id) {
out.push(r);
}
}
}
let mut out = Vec::new();
walk(doc, doc.children(None), target, &mut out);
out
}
/// The screen holding `target`: its outermost ancestor below the top-level frame that does not
/// hug its width (rows and grids of screens hug, a screen has a set width).
pub fn screen_of(doc: &Document, target: NodeId) -> Option<Rect> {
let anc = doc.ancestors(target);
anc.iter()
.rev()
.skip(1)
.find(|id| doc.get(**id).is_some_and(|n| n.layout_child.sizing[0] != crate::model::paint::Sizing::Hug))
.and_then(|s| doc.world_rect(*s))
}
/// Obstacles a leader line routes around: as `obstacles`, minus what lies inside the target's
/// own screen (a line has to enter that screen anyway, so it goes straight to the target).
pub fn route_obstacles(doc: &Document, target: NodeId) -> Vec<Rect> {
let screen = screen_of(doc, target);
let mut obs = obstacles(doc, Some(target));
obs.retain(|o| !screen.is_some_and(|s| s.contains_rect(o)));
obs
}
/// A note already on the page: its card and its (simple) leader line.
#[derive(Debug, Clone)]
pub struct Placed {
pub id: u64,
pub rect: Rect,
pub line: Vec<V2>,
}
pub fn placed_of(doc: &Document, a: &Annotation) -> Placed {
let at = card_world(doc, a);
let s = card_size(a);
let rect = Rect::new(at.x, at.y, s.x, s.y);
let line = match a.target.and_then(|t| doc.world_rect(t)) {
Some(t) if !a.detached => elbow(rect, anchor_point(t, anchor_toward(t, rect))),
_ => Vec::new(),
};
Placed { id: a.id, rect, line }
}
pub fn existing(doc: &Document, skip: impl Fn(u64) -> bool) -> Vec<Placed> {
doc.annotations.iter().filter(|a| !skip(a.id)).map(|a| placed_of(doc, a)).collect()
}
// ------------------------------------------------------------------------------ placement
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Placement {
/// Card top-left relative to the target's top-left.
pub position: V2,
pub anchor: Anchor,
}
fn gap_distance(a: &Rect, b: &Rect) -> f64 {
let dx = (a.x - b.right()).max(b.x - a.right()).max(0.0);
let dy = (a.y - b.bottom()).max(b.y - a.bottom()).max(0.0);
dx + dy
}
/// Place `a`'s card (its target must exist) beside the target, clear of all nodes and `placed`.
pub fn place_among(doc: &Document, a: &Annotation, placed: &[Placed]) -> Option<Placement> {
let target = a.target?;
let t = doc.world_rect(target)?;
let size = card_size(a);
let frame = frame_of(doc, target)
.filter(|f| *f != target)
.and_then(|f| doc.world_rect(f));
let mut obs = obstacles(doc, Some(target));
obs.push(t);
// The screen holding the target: a card never sits on its
// background, only the line may enter it.
let screen = screen_of(doc, target);
let route_obs = route_obstacles(doc, target);
let blocked = |r: &Rect| {
let g = r.inflate(MARGIN);
obs.iter().chain(&screen).any(|o| overlaps(&g, o)) || placed.iter().any(|p| overlaps(&g, &p.rect))
};
let card_at = |x: f64, y: f64| Rect::new(x, y, size.x, size.y);
let mut found: Option<Rect> = None;
let mut best = f64::MAX;
for (radius, step) in RINGS {
let win = t.inflate(radius);
let near: Vec<Rect> = obs.iter().chain(placed.iter().map(|p| &p.rect)).filter(|o| overlaps(&win.inflate(size.x), o)).copied().collect();
let mut xs = vec![t.right() + MARGIN, t.x - MARGIN - size.x, t.x, t.right() - size.x, t.center().x - size.x / 2.0];
let mut ys = vec![t.bottom() + MARGIN, t.y - MARGIN - size.y, t.y, t.bottom() - size.y, t.center().y - size.y / 2.0];
for o in near.iter().chain(&screen) {
xs.extend([o.right() + MARGIN, o.x - MARGIN - size.x, o.x]);
ys.extend([o.bottom() + MARGIN, o.y - MARGIN - size.y, o.y]);
}
let mut g = win.x;
while g <= win.right() - size.x {
xs.push(g);
g += step;
}
let mut g = win.y;
while g <= win.bottom() - size.y {
ys.push(g);
g += step;
}
let keep = |v: &mut Vec<f64>, lo: f64, hi: f64| {
v.retain(|p| *p >= lo && *p <= hi);
v.sort_by(f64::total_cmp);
v.dedup_by(|a, b| (*a - *b).abs() < 0.5);
};
keep(&mut xs, win.x, win.right() - size.x);
keep(&mut ys, win.y, win.bottom() - size.y);
let mut cands: Vec<(f64, Rect)> = Vec::with_capacity(xs.len() * ys.len());
for &x in &xs {
for &y in &ys {
let r = card_at(x, y);
let frame_cost = frame.map_or(0.0, |f| {
if f.inflate(-MARGIN).contains_rect(&r) {
0.0
} else if overlaps(&f, &r) {
STRADDLE_COST // straddling the frame's edge
} else {
OUTSIDE_FRAME_COST
}
});
let c = gap_distance(&r, &t) + 0.3 * (r.center() - t.center()).len() + frame_cost;
cands.push((c, r));
}
}
cands.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut tried = 0;
for (base, r) in cands {
if tried >= FEASIBLE_TRIED || base >= best {
break;
}
if blocked(&r) {
continue;
}
tried += 1;
let end = anchor_point(t, anchor_toward(t, r));
let mut around = route_obs.clone();
around.extend(placed.iter().map(|p| p.rect));
let line = route(r, t, end, &around);
let crossings = around.iter().filter(|o| crosses_rect(&line, o)).count()
+ placed.iter().filter(|p| paths_cross(&line, &p.line) || crosses_rect(&p.line, &r)).count();
let len: f64 = line.windows(2).map(|w| (w[1] - w[0]).len()).sum();
let bends = line.len().saturating_sub(2) as f64;
let cost = base + LINE_COST * len + BEND_PENALTY * bends + CROSS_COST * crossings as f64;
if cost < best {
best = cost;
found = Some(r);
}
}
// A card straddling the frame's edge is worth a wider search.
if best < STRADDLE_COST {
break;
}
}
let r = found.unwrap_or_else(|| {
// Nothing free nearby: below everything on the page, level with the target's x.
let all = Rect::union_all(obs.iter().chain(placed.iter().map(|p| &p.rect))).unwrap_or(t);
card_at(t.x, all.bottom() + MARGIN * 2.0)
});
Some(Placement { position: r.min() - t.min(), anchor: anchor_toward(t, r) })
}
/// Place `a` among every other note on the page.
pub fn place(doc: &Document, a: &Annotation) -> Option<Placement> {
place_among(doc, a, &existing(doc, |id| id == a.id))
}
/// New positions for the notes on `frame` (all targeted notes when `None`), in reading order,
/// each placed clear of the others.
pub fn tidy(doc: &Document, frame: Option<NodeId>) -> Vec<(u64, Placement)> {
let mut todo: Vec<(&Annotation, Rect)> = doc
.annotations
.iter()
.filter(|a| !a.detached)
.filter_map(|a| Some((a, doc.world_rect(a.target?)?)))
.filter(|(a, _)| frame.is_none_or(|f| a.target.and_then(|t| frame_of(doc, t)) == Some(f)))
.collect();
todo.sort_by(|a, b| (a.1.y, a.1.x, a.0.id).partial_cmp(&(b.1.y, b.1.x, b.0.id)).unwrap_or(std::cmp::Ordering::Equal));
let moving: Vec<u64> = todo.iter().map(|(a, _)| a.id).collect();
let mut placed = existing(doc, |id| moving.contains(&id));
let mut out = Vec::new();
for (a, t) in todo {
let Some(p) = place_among(doc, a, &placed) else { continue };
let mut moved = a.clone();
moved.position = p.position;
moved.anchor = p.anchor;
let at = t.min() + p.position;
let s = card_size(a);
let rect = Rect::new(at.x, at.y, s.x, s.y);
let line = elbow(rect, anchor_point(t, p.anchor));
placed.push(Placed { id: a.id, rect, line });
out.push((a.id, p));
}
out
}
// ------------------------------------------------------------------------------ routing
/// Orthogonal leader line from `card` to `end` (on `target`'s edge), around `obstacles` (which
/// must not include the card or target). Falls back to the simple elbow when no path exists.
pub fn route(card: Rect, target: Rect, end: V2, obstacles: &[Rect]) -> Vec<V2> {
let simple = elbow(card, end);
let start = simple[0];
let win = card.union(&target).inflate(ROUTE_WINDOW);
let mut obs: Vec<Rect> = obstacles
.iter()
.filter(|o| overlaps(&win, o))
.map(|o| o.inflate(ROUTE_PAD))
.filter(|o| !strictly_inside(o, start) && !strictly_inside(o, end))
.collect();
if obs.len() > ROUTE_MAX_OBSTACLES {
return simple;
}
obs.push(card);
obs.push(target);
if !obs[..obs.len() - 2].iter().any(|o| crosses_rect(&simple, o)) {
return simple;
}
astar(start, end, &obs, win).unwrap_or(simple)
}
fn axis(vals: impl Iterator<Item = f64>) -> Vec<f64> {
let mut v: Vec<f64> = vals.collect();
v.sort_by(f64::total_cmp);
v.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
v
}
fn idx(v: &[f64], x: f64) -> usize {
v.partition_point(|p| *p < x - 1e-6)
}
fn astar(start: V2, end: V2, obs: &[Rect], win: Rect) -> Option<Vec<V2>> {
let xs = axis(obs.iter().flat_map(|o| [o.x, o.right()]).chain([start.x, end.x, win.x, win.right()]));
let ys = axis(obs.iter().flat_map(|o| [o.y, o.bottom()]).chain([start.y, end.y, win.y, win.bottom()]));
let (nx, ny) = (xs.len(), ys.len());
let at = |i: usize, j: usize| j * nx + i;
let mut node = vec![false; nx * ny];
let mut hedge = vec![false; nx * ny];
let mut vedge = vec![false; nx * ny];
for o in obs {
let (i0, i1, j0, j1) = (idx(&xs, o.x), idx(&xs, o.right()), idx(&ys, o.y), idx(&ys, o.bottom()));
for j in j0..=j1.min(ny - 1) {
for i in i0..=i1.min(nx - 1) {
let (in_x, in_y) = (i > i0 && i < i1, j > j0 && j < j1);
if in_x && in_y {
node[at(i, j)] = true;
}
if i < i1 && in_y {
hedge[at(i, j)] = true;
}
if j < j1 && in_x {
vedge[at(i, j)] = true;
}
}
}
}
let (si, sj, ei, ej) = (idx(&xs, start.x), idx(&ys, start.y), idx(&xs, end.x), idx(&ys, end.y));
const DIRS: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];
let state = |i: usize, j: usize, d: usize| at(i, j) * 5 + d;
let mut dist = vec![f64::MAX; nx * ny * 5];
let mut parent = vec![usize::MAX; nx * ny * 5];
let mut heap = BinaryHeap::new();
let key = |f: f64| Reverse((f * 1000.0) as u64);
let h = |i: usize, j: usize| (xs[i] - end.x).abs() + (ys[j] - end.y).abs();
dist[state(si, sj, 4)] = 0.0;
heap.push((key(h(si, sj)), si, sj, 4usize));
let mut goal = None;
while let Some((_, i, j, d)) = heap.pop() {
if (i, j) == (ei, ej) {
goal = Some(state(i, j, d));
break;
}
let cur = dist[state(i, j, d)];
for (nd, (di, dj)) in DIRS.iter().enumerate() {
let (ni, nj) = (i as i32 + di, j as i32 + dj);
if ni < 0 || nj < 0 || ni as usize >= nx || nj as usize >= ny {
continue;
}
let (ni, nj) = (ni as usize, nj as usize);
let blocked = node[at(ni, nj)]
|| match nd {
0 => hedge[at(i, j)],
1 => hedge[at(ni, nj)],
2 => vedge[at(i, j)],
_ => vedge[at(ni, nj)],
};
if blocked {
continue;
}
let len = (xs[ni] - xs[i]).abs() + (ys[nj] - ys[j]).abs();
let cost = cur + len + if d != 4 && d != nd { BEND_COST } else { 0.0 };
let s = state(ni, nj, nd);
if cost < dist[s] {
dist[s] = cost;
parent[s] = state(i, j, d);
heap.push((key(cost + h(ni, nj)), ni, nj, nd));
}
}
}
let mut s = goal?;
let mut pts = Vec::new();
loop {
let cell = s / 5;
pts.push(v2(xs[cell % nx], ys[cell / nx]));
if parent[s] == usize::MAX {
break;
}
s = parent[s];
}
pts.reverse();
pts.dedup_by(|a, b| (a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6);
let mut out: Vec<V2> = Vec::new();
for p in pts {
while out.len() >= 2 {
let (a, b) = (out[out.len() - 2], out[out.len() - 1]);
let collinear = ((a.x - b.x).abs() < 1e-6 && (b.x - p.x).abs() < 1e-6) || ((a.y - b.y).abs() < 1e-6 && (b.y - p.y).abs() < 1e-6);
if !collinear {
break;
}
out.pop();
}
out.push(p);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_estimate_counts_words_and_newlines() {
assert_eq!(wrap_lines("", 200.0, 11.0), 1);
assert_eq!(wrap_lines("short", 200.0, 11.0), 1);
assert_eq!(wrap_lines("a\nb\nc", 200.0, 11.0), 3);
let long = "word ".repeat(60);
let n = wrap_lines(&long, BODY_W, BODY_PT);
assert!((5..=9).contains(&n), "{n}");
assert!(wrap_lines(&"x".repeat(200), 100.0, 11.0) >= 6);
}
#[test]
fn card_grows_then_caps_with_more_lines() {
let mut a = Annotation::new(None, "One Primary button per view and nothing else here", "rule");
let small = card_size(&a);
assert!(small.y >= PAD * 2.0 + LABEL_LH * 2.0 - 1.0, "label wraps to 2 rows: {small:?}");
a.body = "Danger only for destructive actions\nsecond".into();
assert!(card_size(&a).y > small.y);
let l = card_lines(&a.label, &a.body, false);
assert_eq!((l.label, l.body, l.more), (2, 2, 0));
a.body = (1..=20).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
let l = card_lines(&a.label, &a.body, false);
assert_eq!((l.body, l.more), (BODY_LINES, 12));
assert_eq!(card_lines(&a.label, &a.body, true).body, 20);
assert!(card_size(&a).y < 400.0);
}
#[test]
fn route_goes_around_a_wall_and_straight_when_clear() {
let card = Rect::new(0.0, 0.0, 100.0, 40.0);
let target = Rect::new(400.0, 0.0, 40.0, 40.0);
let end = v2(400.0, 20.0);
let direct = route(card, target, end, &[]);
assert_eq!(direct.len(), 2);
let wall = Rect::new(200.0, -200.0, 40.0, 400.0);
let r = route(card, target, end, &[wall]);
assert!(r.len() > 2);
assert!(r.windows(2).all(|w| !overlaps(&seg_box(w[0], w[1]).inflate(0.1), &wall)));
assert!(r.windows(2).all(|w| (w[0].x - w[1].x).abs() < 1e-6 || (w[0].y - w[1].y).abs() < 1e-6), "orthogonal");
assert_eq!(*r.last().unwrap(), v2(400.0, 20.0));
}
#[test]
fn anchor_faces_the_card() {
let t = Rect::new(100.0, 100.0, 200.0, 50.0);
let right = Rect::new(400.0, 300.0, 50.0, 20.0);
assert_eq!(anchor_toward(t, right).side, Side::Right);
let below = Rect::new(150.0, 200.0, 280.0, 20.0);
let a = anchor_toward(t, below);
assert_eq!(a.side, Side::Bottom);
assert!((anchor_point(t, a).x - 225.0).abs() < 1e-6);
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod annotation;
pub mod annotation_layout;
pub mod document;
pub mod node;
pub mod paint;
+24 -3
View File
@@ -226,9 +226,30 @@ pub fn page_from_json(s: &str) -> Result<PageData, FormatError> {
/// Like [`page_from_json`], plus the page's active mode per collection.
pub fn page_from_json_modes(s: &str) -> Result<(PageData, PageModes), FormatError> {
let v: Value = serde_json::from_str(s).map_err(FormatError::Json)?;
let v = migrate(v, PAGE_FORMAT, PAGE_VERSION, PAGE_ALIASES, PAGE_STEPS)?;
let mut f: PageFile = serde_json::from_value(v).map_err(FormatError::Json)?;
// A current page is read straight into its types; going through a `Value` tree first
// (needed only to migrate old files) doubles the parse time of a large page.
let f: PageFile = if is_current_page(s) {
serde_json::from_str(s).map_err(FormatError::Json)?
} else {
let v: Value = serde_json::from_str(s).map_err(FormatError::Json)?;
let v = migrate(v, PAGE_FORMAT, PAGE_VERSION, PAGE_ALIASES, PAGE_STEPS)?;
serde_json::from_value(v).map_err(FormatError::Json)?
};
page_from_file(f)
}
/// The file's format tag and version are the current ones (nothing to migrate).
fn is_current_page(s: &str) -> bool {
#[derive(Deserialize)]
struct Head {
format: Option<String>,
version: Option<u32>,
}
serde_json::from_str::<Head>(s)
.is_ok_and(|h| h.format.as_deref() == Some(PAGE_FORMAT) && h.version == Some(PAGE_VERSION))
}
fn page_from_file(mut f: PageFile) -> Result<(PageData, PageModes), FormatError> {
if let Some(t) = f.annotation_template.take() {
f.modes_by_collection.insert(PAGE_TEMPLATE_KEY.into(), t);
}
+57
View File
@@ -0,0 +1,57 @@
//! Reads the pages of a project in the background, so a large project opens as soon as the page
//! in view is read. The editor installs what arrives (`Editor::poll_loading`) and, when something
//! needs a page that is still on its way, reads it itself.
use super::{FormatError, PageData, PageRef};
use crate::variables::PageModes;
use std::path::PathBuf;
use std::sync::mpsc::Receiver;
/// A page read from disk, not yet finalized against the design system.
pub type ReadPage = (PageData, PageModes);
/// Most threads reading pages at once.
#[cfg(not(target_arch = "wasm32"))]
const MAX_WORKERS: usize = 3;
#[derive(Debug)]
pub struct PageLoader {
/// The project folder, for reads the editor does itself.
pub dir: PathBuf,
pub rx: Receiver<(String, Result<ReadPage, FormatError>)>,
}
impl PageLoader {
/// Start reading `pages` (in this order). The browser has no threads: there `Project`
/// never leaves pages pending, so this is not reached.
pub fn spawn(dir: PathBuf, pages: Vec<PageRef>) -> PageLoader {
let (tx, rx) = std::sync::mpsc::channel();
#[cfg(not(target_arch = "wasm32"))]
{
let queue = std::sync::Arc::new(std::sync::Mutex::new(
pages.into_iter().collect::<std::collections::VecDeque<_>>(),
));
let workers = queue.lock().map_or(1, |q| q.len()).clamp(1, MAX_WORKERS);
for _ in 0..workers {
let (queue, tx, dir) = (queue.clone(), tx.clone(), dir.clone());
std::thread::spawn(move || {
loop {
let Some(r) = queue.lock().ok().and_then(|mut q| q.pop_front()) else {
return;
};
let path = dir.join(super::PAGES_DIR).join(format!("{}.json", r.id));
// The editor is gone (closed or another project opened): stop reading.
if tx.send((r.id.clone(), super::read_page_file(&path, &r))).is_err() {
return;
}
}
});
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = (&tx, pages);
}
PageLoader { dir, rx }
}
}
+118 -57
View File
@@ -15,6 +15,7 @@
//! All writes go through [`crate::io::atomic`], so a crash never leaves a torn file.
pub mod format;
pub mod loader;
use crate::io::atomic;
pub use format::{FormatError, Manifest, PageData, PageRef, new_id, now_ms};
@@ -92,6 +93,9 @@ pub struct Loaded {
pub changed: Vec<usize>,
/// `design.json` did not exist: it was built from the first page.
pub design_new: bool,
/// Pages not read yet (see [`Project::load_first`]): their entry in `pages` is an empty
/// placeholder, read later by a [`loader::PageLoader`].
pub pending: Vec<usize>,
}
/// File-system safe folder name for a project name.
@@ -353,6 +357,65 @@ impl Store {
}
}
/// An empty stand-in for a page that is read later.
fn placeholder_page(r: &PageRef) -> PageData {
PageData {
id: r.id.clone(),
name: r.name.clone(),
doc: crate::model::Document { name: r.name.clone(), ..Default::default() },
camera: None,
}
}
/// Read the page file `path` of manifest entry `r`; the manifest owns the name, and a missing
/// file is an empty page.
pub fn read_page_file(path: &Path, r: &PageRef) -> Result<(PageData, PageModes), FormatError> {
let (mut p, m) = match crate::vfs::read_to_string(path)
.map_err(FormatError::from)
.and_then(|s| format::page_from_json_modes(&s))
{
Ok(x) => x,
Err(FormatError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
(placeholder_page(r), PageModes::new())
}
Err(e) => return Err(e),
};
// The manifest owns page names (renames touch only the manifest).
p.name = r.name.clone();
p.doc.name = r.name.clone();
Ok((p, m))
}
/// Give a freshly read page the variable cache `design` derives for its modes and every main
/// component a key. True when that changed the page, so the next save must write it.
pub fn finalize_page(design: &DesignSystem, p: &mut PageData, m: &mut PageModes) -> bool {
// A page without per-collection modes keeps the mode its old `mode` field names.
if m.is_empty()
&& let Some(name) = &p.doc.mode
&& let Some(c) = design.collections.iter().find(|c| c.name == COLORS_COLLECTION)
&& let Some(mode) = c.modes.iter().find(|x| &x.name == name)
{
m.insert(c.id.clone(), mode.id.clone());
}
let cache = design.cache_for(m);
let mut dirty = p.doc.variables != cache.variables
|| p.doc.modes != cache.modes
|| p.doc.mode != cache.mode
|| p.doc.tokens != cache.tokens;
p.doc.variables = cache.variables;
p.doc.modes = cache.modes;
p.doc.mode = cache.mode;
p.doc.tokens = cache.tokens;
for n in p.doc.nodes.values_mut() {
if n.component && n.component_key.is_none() {
// From the page and node ids, so every device agrees on the key.
n.component_key = Some(format!("{}:{}", p.id, n.id.0));
dirty = true;
}
}
dirty
}
fn read_manifest(dir: &Path) -> Result<Manifest, FormatError> {
Manifest::from_json(&crate::vfs::read_to_string(dir.join(MANIFEST))?)
}
@@ -420,80 +483,77 @@ impl Project {
Ok(())
}
/// `design.json`, or `None` when it is missing. A damaged one must not lock the project:
/// the bad file is kept for recovery and the design is rebuilt from the page caches (which
/// hold every value). The next save rewrites it.
fn read_design(&self) -> Result<Option<DesignSystem>, FormatError> {
match self.load_design() {
Ok(d) => Ok(d),
Err(FormatError::Json(e)) => {
let backup = self.dir.join(format!("{DESIGN}.broken-{}", now_ms()));
log::warn!("{DESIGN} is malformed ({e}); saved as {backup:?}, rebuilding");
crate::vfs::rename(self.design_path(), backup)?;
Ok(None)
}
Err(e) => Err(e),
}
}
/// One page of the manifest read from disk: a missing file comes back empty (e.g. after a
/// crash between creating the page and its first save). Nothing is derived from the design
/// yet, see [`finalize_page`].
pub fn read_page(&self, r: &PageRef) -> Result<(PageData, PageModes), FormatError> {
read_page_file(&self.page_path(&r.id), r)
}
/// Load the design system and every page listed in the manifest. A page whose file is
/// missing comes back empty (e.g. after a crash between creating it and its first save).
/// A project without `design.json` gets one built from the first page; pages get the
/// derived variable cache and every main component a key. Nothing is written here:
/// `Loaded::changed` and `Loaded::design_new` say what the next save must write.
pub fn load_all(&self) -> Result<Loaded, FormatError> {
self.load_pages_with(self.read_design()?, None)
}
/// Like [`Self::load_all`], but reads only the design system and the page named `active`
/// (the first when it is unknown); the other pages come back as empty placeholders listed in
/// `Loaded::pending`, for a [`loader::PageLoader`] to read in the background. A project
/// without `design.json` needs every page to build it, and the browser has no threads, so
/// both load everything here.
pub fn load_first(&self, active: Option<&str>) -> Result<Loaded, FormatError> {
let first = active
.and_then(|a| self.manifest.pages.iter().position(|r| r.id == a))
.unwrap_or(0);
self.load_pages_with(self.read_design()?, Some(first))
}
fn load_pages_with(
&self,
stored: Option<DesignSystem>,
first: Option<usize>,
) -> Result<Loaded, FormatError> {
let lazy = first.filter(|_| stored.is_some() && !cfg!(target_arch = "wasm32"));
let mut pages = Vec::new();
let mut modes = Vec::new();
for r in &self.manifest.pages {
let (mut p, m) = match crate::vfs::read_to_string(self.page_path(&r.id))
.map_err(FormatError::from)
.and_then(|s| format::page_from_json_modes(&s))
{
Ok(x) => x,
Err(FormatError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => (
PageData {
id: r.id.clone(),
name: r.name.clone(),
doc: crate::model::Document::default(),
camera: None,
},
PageModes::new(),
),
Err(e) => return Err(e),
};
// The manifest owns page names (renames touch only the manifest).
p.name = r.name.clone();
p.doc.name = r.name.clone();
let mut pending = Vec::new();
for (i, r) in self.manifest.pages.iter().enumerate() {
if lazy.is_some_and(|f| f != i) {
pages.push(placeholder_page(r));
modes.push(PageModes::new());
pending.push(i);
continue;
}
let (p, m) = self.read_page(r)?;
pages.push(p);
modes.push(m);
}
let stored = match self.load_design() {
Ok(d) => d,
// A damaged design.json must not lock the project: keep the bad file for recovery
// and rebuild from the page caches (which hold every value). Next save rewrites it.
Err(FormatError::Json(e)) => {
let backup = self.dir.join(format!("{DESIGN}.broken-{}", now_ms()));
log::warn!("{DESIGN} is malformed ({e}); saved as {backup:?}, rebuilding");
crate::vfs::rename(self.design_path(), backup)?;
None
}
Err(e) => return Err(e),
};
let design_new = stored.is_none();
let design = stored.unwrap_or_else(|| {
crate::variables::migrate_from_document(&union_doc(&pages))
});
let mut changed = Vec::new();
for (i, (p, m)) in pages.iter_mut().zip(&mut modes).enumerate() {
// A page without per-collection modes keeps the mode its old `mode` field names.
if m.is_empty()
&& let Some(name) = &p.doc.mode
&& let Some(c) = design.collections.iter().find(|c| c.name == COLORS_COLLECTION)
&& let Some(mode) = c.modes.iter().find(|x| &x.name == name)
{
m.insert(c.id.clone(), mode.id.clone());
}
let cache = design.cache_for(m);
let mut dirty = p.doc.variables != cache.variables
|| p.doc.modes != cache.modes
|| p.doc.mode != cache.mode
|| p.doc.tokens != cache.tokens;
p.doc.variables = cache.variables;
p.doc.modes = cache.modes;
p.doc.mode = cache.mode;
p.doc.tokens = cache.tokens;
for n in p.doc.nodes.values_mut() {
if n.component && n.component_key.is_none() {
// From the page and node ids, so every device agrees on the key.
n.component_key = Some(format!("{}:{}", p.id, n.id.0));
dirty = true;
}
}
if dirty {
if !pending.contains(&i) && finalize_page(&design, p, m) {
changed.push(i);
}
}
@@ -503,6 +563,7 @@ impl Project {
modes,
changed,
design_new,
pending,
})
}
+404 -124
View File
@@ -4,8 +4,8 @@
use crate::editor::Editor;
use crate::mcp::exec::{Host, find_page};
use crate::mcp::{
ToolResult,
diff::{NodeDiff, diff_pages},
CHAT_CLIENT, Client, ToolResult,
diff::{NodeDiff, PageDiff, diff_pages},
tools::edits_doc,
};
use crate::model::{Document, NodeId};
@@ -36,6 +36,20 @@ pub struct RunPage {
/// Auto-version taken before the run touched the page; None when the run created the page.
#[serde(default)]
pub version: Option<String>,
/// Nodes the run changed on the page, sorted. Empty for older runs and for runs that only
/// changed variables, notes or the page itself.
#[serde(default)]
pub nodes: Vec<NodeId>,
}
impl RunPage {
/// The diff row is about a node this run changed.
fn touches(&self, n: &NodeDiff) -> bool {
[n.id_a, n.id_b]
.into_iter()
.flatten()
.any(|id| self.nodes.binary_search(&id).is_ok())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
@@ -62,6 +76,93 @@ pub struct Run {
pub changed: usize,
#[serde(default)]
pub removed: usize,
/// The agent client that made it ("Claude Code", "Claude Code · 2" for its second session,
/// "Chat"); empty for older runs and unnamed clients.
#[serde(default)]
pub client: String,
}
impl Run {
/// Layers the run changed so far, without a diff: the live count while it is open.
pub fn touched(&self) -> usize {
self.pages.iter().map(|p| p.nodes.len()).sum()
}
}
/// Added, changed and removed, and whether the run changed nothing at all.
pub type Counts = (usize, usize, usize, bool);
/// What `run` changed, against its snapshots. On a page where the run recorded its nodes, rows of
/// nodes it did not change (another agent's, the user's) do not count.
pub fn count(project: &Path, ed: &Editor, run: &Run) -> Counts {
let (mut added, mut changed, mut removed, mut empty) = (0, 0, 0, true);
for p in &run.pages {
let page = ed.pages.iter().position(|s| s.id == p.page_id);
let after = page.map(|i| ed.page_doc(i)).cloned().unwrap_or_default();
let Some(v) = &p.version else {
if page.is_some() {
added += after.root.len().max(1);
empty = false;
}
continue;
};
let Some(before) = crate::versions::load(project, v) else {
empty = false;
continue;
};
let mut d = diff_pages(&before, &after);
keep_own(&mut d, p);
let (a, m, r) = (d.added, d.modified, d.removed);
let renamed = page.is_some_and(|i| ed.pages[i].name != before.name);
let deleted = page.is_none();
added += a;
changed += m + usize::from(renamed);
removed += r + usize::from(deleted);
empty &= a + m + r == 0 && !renamed && !deleted;
}
(added, changed, removed, empty)
}
/// Leaves out of `d` the rows of nodes the run did not change on page `p` (another agent's, the
/// user's). A page without recorded nodes keeps every row.
pub fn keep_own(d: &mut PageDiff, p: &RunPage) {
if p.nodes.is_empty() {
return;
}
for g in &mut d.groups {
g.nodes.retain(|n| {
let own = p.touches(n);
if !own {
match n.status {
"added" => d.added -= 1,
"removed" => d.removed -= 1,
_ => d.modified -= 1,
}
}
own
});
}
d.groups.retain(|g| !g.nodes.is_empty());
}
/// `r` was still going when `run` started, or came after it: its edits sit on top of `run`'s.
fn not_before(r: &Run, run: &Run) -> bool {
r.id != run.id && r.state != RunState::Reverted && r.ended.is_none_or(|e| e > run.started)
}
/// A later (or overlapping) run that changed some of the nodes `run` changed: reverting `run`
/// alone would undo part of that one too, so only "Restore to here" may. Returns its number.
pub fn blocker(runs: &[Run], run: &Run) -> Option<u32> {
runs.iter()
.filter(|r| not_before(r, run))
.find(|r| {
r.pages.iter().any(|p| {
run.pages.iter().any(|q| {
q.page_id == p.page_id && p.nodes.iter().any(|n| q.nodes.binary_search(n).is_ok())
})
})
})
.map(|r| r.number)
}
fn dir(project: &Path) -> PathBuf {
@@ -78,15 +179,29 @@ pub fn save(project: &Path, run: &Run) -> std::io::Result<()> {
crate::vfs::write(file_of(project, &run.id), bytes)
}
/// Restore every page touched by a run, loading all versions before changing the editor.
/// Restore every page touched by a run, loading all versions before changing the editor. A page
/// another run (later or overlapping) also edited only gets this run's nodes back, so that run's
/// nodes stay. Refused when a later run changed the same nodes (see [`blocker`]).
pub fn revert(project: &Path, ed: &mut Editor, run: &mut Run) -> Result<(), String> {
let others = list(project);
if let Some(n) = blocker(&others, run) {
return Err(format!(
"run {n} changed the same layers afterwards; use Restore to here to revert both"
));
}
let shared = |p: &RunPage| {
!p.nodes.is_empty()
&& others
.iter()
.any(|r| not_before(r, run) && r.pages.iter().any(|q| q.page_id == p.page_id))
};
let snapshots: Vec<_> = run
.pages
.iter()
.filter_map(|p| {
p.version.as_deref().map(|version| {
crate::versions::load(project, version)
.map(|doc| (p.page_id.clone(), doc))
.map(|doc| (p, doc))
.ok_or_else(|| {
let name = ed
.pages
@@ -99,20 +214,42 @@ pub fn revert(project: &Path, ed: &mut Editor, run: &mut Run) -> Result<(), Stri
})
.collect::<Result<_, _>>()?;
let active = ed.active_page_id().to_string();
for (id, snap) in snapshots {
for (page, snap) in snapshots {
let id = &page.page_id;
let i = ed
.pages
.iter()
.position(|p| p.id == id)
.unwrap_or_else(|| ed.add_page_with_id(&id, &snap));
.position(|p| &p.id == id)
.unwrap_or_else(|| ed.add_page_with_id(id, &snap));
ed.switch_page(i);
if shared(page) {
let mut d = diff_pages(&snap, &ed.doc);
keep_own(&mut d, page);
for row in d.groups.into_iter().flat_map(|g| g.nodes) {
// ponytail: a row whose node went with an earlier row's subtree is skipped.
let _ = revert_node(ed, &snap, &row);
}
continue;
}
ed.restore_snapshot(&snap);
ed.rename_page(i, &snap.name);
}
for page in run.pages.iter().filter(|p| p.version.is_none()) {
if let Some(i) = ed.pages.iter().position(|p| p.id == page.page_id) {
let Some(i) = ed.pages.iter().position(|p| p.id == page.page_id) else {
continue;
};
if !shared(page) {
ed.delete_page(i);
continue;
}
ed.switch_page(i);
ed.edit("Revert agent run", |tx| {
for id in &page.nodes {
if tx.doc().get(*id).is_some() {
tx.remove(*id);
}
}
});
}
if let Some(i) = ed.pages.iter().position(|p| p.id == active) {
ed.switch_page(i);
@@ -307,56 +444,99 @@ fn recover(project: &Path) {
}
}
/// The run being recorded, with what the tracker needs between calls.
/// A run being recorded, with what the tracker needs between calls.
struct Open {
run: Run,
/// The client whose calls go into this run.
key: Client,
/// Clock of the last successful editing call.
last: u64,
/// Activity verb of the first editing call, the title unless `finish_working_on_nodes` names one.
verb: &'static str,
}
/// Groups the agent's editing tool calls into runs. Hosts route every call through [`Tracker::call`]
/// and call [`Tracker::tick`] once per frame. `now` is Unix milliseconds, injected so tests can fake it.
/// Groups each agent client's editing tool calls into runs: one open run per client (MCP client
/// and session, or chat), so parallel agents get runs of their own. Hosts route every call through
/// [`Tracker::call`] and call [`Tracker::tick`] once per frame. `now` is Unix milliseconds,
/// injected so tests can fake it.
#[derive(Default)]
pub struct Tracker {
dir: Option<PathBuf>,
open: Option<Open>,
finished: Option<Run>,
open: Vec<Open>,
finished: Vec<Run>,
/// Clients in first-call order, to number the sessions of one client.
// ponytail: never pruned; a few entries per app session.
clients: Vec<Client>,
/// Nodes the last call changed, as (page id, node), for the canvas's live outlines.
touched: Vec<(String, NodeId)>,
}
impl Tracker {
/// The run being recorded, if any.
pub fn open_run(&self) -> Option<&Run> {
self.open.as_ref().map(|o| &o.run)
/// The runs being recorded, oldest first.
pub fn open_runs(&self) -> impl Iterator<Item = &Run> {
self.open.iter().map(|o| &o.run)
}
/// The run that ended last, once (for the finish banner).
/// The runs that ended, one per call (for the finish banner).
pub fn take_finished(&mut self) -> Option<Run> {
self.finished.take()
(!self.finished.is_empty()).then(|| self.finished.remove(0))
}
/// Runs `exec::call`, recording it in the open run when it edits the document.
pub fn call(&mut self, host: &mut dyn Host, name: &str, args: &Value, now: u64) -> ToolResult {
fn open_of(&self, client: &Client) -> Option<usize> {
self.open.iter().position(|o| &o.key == client)
}
/// Takes the nodes the last call changed (empty for reads and failed calls).
pub fn take_touched(&mut self) -> Vec<(String, NodeId)> {
std::mem::take(&mut self.touched)
}
/// The client's name for the run, numbered from its second session on.
pub fn label(&mut self, client: &Client) -> String {
if !self.clients.contains(client) {
self.clients.push(client.clone());
}
let n = self
.clients
.iter()
.filter(|c| c.name == client.name)
.position(|c| c == client)
.map_or(1, |i| i + 1);
match n {
1 => client.name.clone(),
n => format!("{} \u{b7} {n}", client.name),
}
}
/// Runs `exec::call`, recording it in `client`'s open run when it edits the document.
pub fn call(
&mut self,
host: &mut dyn Host,
client: &Client,
name: &str,
args: &Value,
now: u64,
) -> ToolResult {
let dir = host.project().map(|p| p.dir.clone());
self.sync_project(now, dir.as_deref());
let Some(dir) = dir else {
return crate::mcp::exec::call(host, name, args);
};
let outgoing = (name == "open_file")
.then(|| self.counts(host.editor()))
.flatten();
// Snapshots and diffs below look at every page the call may touch.
host.editor().finish_loading();
let outgoing = (name == "open_file").then(|| self.counts(host.editor()));
let edits = edits_doc(name, args);
let mut before = None;
if edits {
if self.open.as_ref().is_some_and(|o| idle(o.last, now)) {
self.end(now, Some(host.editor()), None);
if self.open_of(client).is_some_and(|i| idle(self.open[i].last, now)) {
self.end(now, Some(host.editor()), None, |o| &o.key == client);
}
let opened = self.open.is_none();
match self.before(&dir, host.editor(), name, args, now) {
let opened = self.open_of(client).is_none();
match self.before(&dir, host.editor(), client, name, args, now) {
Ok(saved) => before = Some(saved),
Err(err) => {
if opened && let Some(open) = self.open.take() {
if opened && let Some(i) = self.open_of(client) {
let open = self.open.remove(i);
for version in open.run.pages.iter().filter_map(|p| p.version.as_deref()) {
crate::versions::delete(&dir, version);
}
@@ -364,75 +544,85 @@ impl Tracker {
return Err(err);
}
}
host.editor().touch_log = Some(Vec::new());
}
let r = crate::mcp::exec::call(host, name, args);
let touched = host.editor().touch_log.take().unwrap_or_default();
let current = host.project().map(|p| p.dir.clone());
if current.as_deref() != Some(dir.as_path()) {
self.end_counted(now, outgoing, None);
self.end_counted(now, outgoing.unwrap_or_default(), None);
self.sync_project(now, current.as_deref());
}
self.touched = if before.is_some() && r.is_ok() { touched.clone() } else { Vec::new() };
if let Some((opened, ids)) = before {
self.after(&dir, host.editor(), r.is_ok(), opened, &ids, now);
} else if name == "finish_working_on_nodes" && self.open.is_some() {
self.after(&dir, host.editor(), client, r.is_ok(), opened, &ids, touched, now);
} else if name == "finish_working_on_nodes" && self.open_of(client).is_some() {
let ed = host.editor();
let title = finish_title(ed, args);
self.end(now, Some(ed), title);
self.end(now, Some(ed), title, |o| &o.key == client);
}
r
}
/// Closes the open run now (a chat turn is over), the way `finish_working_on_nodes` does,
/// instead of waiting `RUN_IDLE_SECS`.
/// Closes the chat's open run now (its turn is over), the way `finish_working_on_nodes` does,
/// instead of waiting `RUN_IDLE_SECS`. Other agents' runs stay open.
pub fn finish(&mut self, now: u64, ed: &Editor) {
self.end(now, Some(ed), None);
self.end(now, Some(ed), None, |o| o.key.name == CHAT_CLIENT);
}
/// Per frame: closes the run after `RUN_IDLE_SECS` idle or when the user switched project.
/// Closes `client`'s open run now under `title` (an action that made several calls).
pub fn finish_client(&mut self, now: u64, ed: &Editor, client: &Client, title: String) {
self.end(now, Some(ed), Some(title), |o| &o.key == client);
}
/// Per frame: closes runs idle for `RUN_IDLE_SECS`, and all of them when the user switched project.
pub fn tick(&mut self, now: u64, dir: Option<&Path>, ed: &Editor) {
self.sync_project(now, dir);
if self.open.as_ref().is_some_and(|o| idle(o.last, now)) {
self.end(now, Some(ed), None);
}
self.end(now, Some(ed), None, |o| idle(o.last, now));
}
/// Notices a project change: the old run ends (its pages are gone, so no diff), the new project's
/// stale `Running` records are recovered.
/// Notices a project change: the old runs end (their pages are gone, so no diff), the new
/// project's stale `Running` records are recovered.
fn sync_project(&mut self, now: u64, dir: Option<&Path>) {
if self.dir.as_deref() == dir {
return;
}
self.end(now, None, None);
self.end(now, None, None, |_| true);
self.dir = dir.map(Path::to_path_buf);
if let Some(d) = dir {
recover(d);
}
}
/// Opens the run if needed and snapshots the pages the call targets. Returns whether this call
/// opened the run, and the page ids before the call.
/// Opens the client's run if needed and snapshots the pages the call targets. Returns whether
/// this call opened the run, and the page ids before the call.
fn before(
&mut self,
dir: &Path,
ed: &Editor,
client: &Client,
name: &str,
args: &Value,
now: u64,
) -> Result<(bool, Vec<String>), String> {
let opened = self.open.is_none();
let open = self.open.get_or_insert_with(|| {
let opened = self.open_of(client).is_none();
if opened {
let number = next_number(dir);
let run = Run {
id: format!("r{number}-{now}"),
number,
started: now,
client: self.label(client),
..Run::default()
};
Open {
self.open.push(Open {
run,
key: client.clone(),
last: now,
verb: crate::ui::agent_verb(name),
}
});
});
}
let open = self.open.iter_mut().find(|o| &o.key == client).expect("opened above");
for i in target_pages(ed, name, args) {
let page_id = &ed.pages[i].id;
if open.run.pages.iter().any(|p| &p.page_id == page_id) {
@@ -445,76 +635,89 @@ impl Tracker {
open.run.pages.push(RunPage {
page_id: page_id.clone(),
version: Some(v.id),
nodes: Vec::new(),
});
}
let _ = save(dir, &open.run);
Ok((opened, ed.pages.iter().map(|p| p.id.clone()).collect()))
}
fn after(&mut self, dir: &Path, ed: &Editor, ok: bool, opened: bool, ids: &[String], now: u64) {
let Some(open) = self.open.as_mut() else {
#[allow(clippy::too_many_arguments)]
fn after(
&mut self,
dir: &Path,
ed: &Editor,
client: &Client,
ok: bool,
opened: bool,
ids: &[String],
touched: Vec<(String, NodeId)>,
now: u64,
) {
let Some(i) = self.open_of(client) else {
return;
};
if !ok {
if opened {
delete(dir, &open.run.id);
self.open = None;
delete(dir, &self.open[i].run.id);
self.open.remove(i);
}
return;
}
let open = &mut self.open[i];
open.run.calls += 1;
open.last = now;
for p in ed.pages.iter().filter(|p| !ids.contains(&p.id)) {
open.run.pages.push(RunPage {
page_id: p.id.clone(),
version: None,
nodes: Vec::new(),
});
}
for (page, id) in touched {
if let Some(p) = open.run.pages.iter_mut().find(|p| p.page_id == page)
&& let Err(k) = p.nodes.binary_search(&id)
{
p.nodes.insert(k, id);
}
}
let _ = save(dir, &open.run);
}
/// Closes the open run: no changes drops it with its versions, otherwise it waits for review.
/// Without `ed` (the project is gone) the changes cannot be counted and the run is kept.
fn end(&mut self, now: u64, ed: Option<&Editor>, title: Option<String>) {
let counts = ed.and_then(|ed| self.counts(ed));
self.end_counted(now, counts, title);
}
pub(crate) fn counts(&self, ed: &Editor) -> Option<(usize, usize, usize, bool)> {
let (open, dir) = (self.open.as_ref()?, self.dir.as_ref()?);
let (mut added, mut changed, mut removed, mut empty) = (0, 0, 0, true);
for p in &open.run.pages {
let page = ed.pages.iter().position(|s| s.id == p.page_id);
let after = page.map(|i| ed.page_doc(i)).cloned().unwrap_or_default();
let Some(v) = &p.version else {
if page.is_some() {
added += after.root.len().max(1);
empty = false;
}
/// Closes the open runs `pick` selects: no changes drops one with its versions, otherwise it
/// waits for review. Without `ed` (the project is gone) the changes cannot be counted and the
/// run is kept.
fn end(&mut self, now: u64, ed: Option<&Editor>, title: Option<String>, pick: impl Fn(&Open) -> bool) {
let mut i = 0;
while i < self.open.len() {
if !pick(&self.open[i]) {
i += 1;
continue;
};
let Some(before) = crate::versions::load(dir, v) else {
empty = false;
continue;
};
let d = diff_pages(&before, &after);
let renamed = page.is_some_and(|i| ed.pages[i].name != before.name);
let deleted = page.is_none();
added += d.added;
changed += d.modified + usize::from(renamed);
removed += d.removed + usize::from(deleted);
empty &= d.is_empty() && !renamed && !deleted;
}
let counts = ed
.zip(self.dir.as_deref())
.map(|(ed, dir)| count(dir, ed, &self.open[i].run));
let open = self.open.remove(i);
self.close(open, now, counts, title.clone());
}
Some((added, changed, removed, empty))
}
pub(crate) fn end_counted(
&mut self,
now: u64,
counts: Option<(usize, usize, usize, bool)>,
title: Option<String>,
) {
let (Some(open), Some(dir)) = (self.open.take(), self.dir.clone()) else {
/// Counts of every open run, in order, for [`Tracker::end_counted`] after the editor moved on.
pub(crate) fn counts(&self, ed: &Editor) -> Vec<Counts> {
let Some(dir) = &self.dir else { return Vec::new() };
self.open.iter().map(|o| count(dir, ed, &o.run)).collect()
}
/// Closes every open run with counts taken earlier ([`Tracker::counts`]); a run without
/// counts is kept.
pub(crate) fn end_counted(&mut self, now: u64, counts: Vec<Counts>, title: Option<String>) {
for (k, open) in std::mem::take(&mut self.open).into_iter().enumerate() {
self.close(open, now, counts.get(k).copied(), title.clone());
}
}
fn close(&mut self, open: Open, now: u64, counts: Option<Counts>, title: Option<String>) {
let Some(dir) = self.dir.clone() else {
return;
};
let mut run = open.run;
@@ -530,7 +733,7 @@ impl Tracker {
run.ended = Some(now);
let _ = save(&dir, &run);
prune(&dir);
self.finished = Some(run);
self.finished.push(run);
}
}
@@ -655,6 +858,7 @@ mod tests {
RunPage {
page_id: page.id,
version: Some(version.id),
nodes: Vec::new(),
}
}
@@ -847,6 +1051,7 @@ mod tests {
RunPage {
page_id: created_id.clone(),
version: None,
nodes: Vec::new(),
},
saved,
];
@@ -870,12 +1075,14 @@ mod tests {
r.pages.push(RunPage {
page_id: ed.pages[created].id.clone(),
version: None,
nodes: Vec::new(),
});
let missing = ed.add_page();
ed.rename_page(missing, "Missing page");
r.pages.push(RunPage {
page_id: ed.pages[missing].id.clone(),
version: Some("missing".into()),
nodes: Vec::new(),
});
save(&dir, &r).unwrap();
let original = r.clone();
@@ -1010,10 +1217,12 @@ mod tests {
RunPage {
page_id: "p1".into(),
version: Some("p1__1".into()),
nodes: Vec::new(),
},
RunPage {
page_id: "p2".into(),
version: None,
nodes: Vec::new(),
},
],
state: RunState::Review,
@@ -1046,6 +1255,7 @@ mod tests {
r.pages = vec![RunPage {
page_id: "p1".into(),
version: Some(auto.id.clone()),
nodes: Vec::new(),
}];
save(&dir, &r).unwrap();
delete(&dir, &r.id);
@@ -1089,7 +1299,7 @@ mod tests {
fn write(t: &mut Tracker, h: &mut SessionHost, now: u64) {
let args = json!({"targetNodeId": "root", "html": "<div style=\"width: 50px; height: 50px\"></div>"});
t.call(h, "write_html", &args, now).unwrap();
t.call(h, &Client::default(), "write_html", &args, now).unwrap();
}
#[test]
@@ -1100,9 +1310,9 @@ mod tests {
t.take_finished().unwrap();
let node = h.ed.doc.root[0].0.to_string();
let notes = json!({"notes": [{"node": node, "text": "Rule one"}, {"node": node, "text": "Rule two"}]});
t.call(&mut h, "annotate", &notes, T0 + GAP + 2).unwrap();
t.call(&mut h, &Client::default(), "annotate", &notes, T0 + GAP + 2).unwrap();
assert_eq!(h.ed.doc.annotations.len(), 2);
assert_eq!(t.counts(&h.ed), Some((2, 0, 0, false)));
assert_eq!(t.counts(&h.ed), vec![(2, 0, 0, false)]);
t.tick(T0 + 2 * GAP + 3, Some(&dir_of(&h)), &h.ed);
let mut run = t.take_finished().expect("annotation-only run kept");
assert_eq!((run.added, run.changed, run.removed), (2, 0, 0));
@@ -1185,6 +1395,7 @@ mod tests {
let err = t
.call(
&mut h,
&Client::default(),
"write_html",
&json!({"targetNodeId": "root", "html": "<div>Hi</div>"}),
T0,
@@ -1192,11 +1403,11 @@ mod tests {
.unwrap_err();
assert!(err.contains("snapshot"), "{err}");
assert_content(&h.ed.doc, &before);
assert_eq!(t.open_run().map_or(0, |r| r.calls), 0);
assert_eq!(t.open_runs().next().map_or(0, |r| r.calls), 0);
std::fs::remove_file(dir.join("versions")).unwrap();
write(&mut t, &mut h, T0 + 1);
assert_eq!(t.open_run().unwrap().calls, 1);
assert_eq!(t.open_run().unwrap().pages.len(), 1);
assert_eq!(t.open_runs().next().unwrap().calls, 1);
assert_eq!(t.open_runs().next().unwrap().pages.len(), 1);
}
#[test]
@@ -1206,18 +1417,17 @@ mod tests {
write(&mut t, &mut h, T0);
h.ed.undo();
let dir = dir_of(&h);
let missing = t.open_run().unwrap().pages[0].version.clone().unwrap();
let missing = t.open_runs().next().unwrap().pages[0].version.clone().unwrap();
h.ed.add_page_in_background(Some("Other"));
let other = h.ed.pages[1].id.clone();
t.call(
&mut h,
t.call(&mut h, &Client::default(),
"rename_pages",
&json!({"updates": [{"pageId": other, "name": "Changed"}]}),
T0 + 1,
)
.unwrap();
h.ed.rename_page(1, "Other");
let remaining = t.open_run().unwrap().pages[1].version.clone().unwrap();
let remaining = t.open_runs().next().unwrap().pages[1].version.clone().unwrap();
if corrupt {
std::fs::write(
dir.join("versions").join(format!("{missing}.json")),
@@ -1241,11 +1451,10 @@ mod tests {
let page = h.ed.pages[0].id.clone();
if deleted {
h.ed.add_page_in_background(Some("Other"));
t.call(&mut h, "delete_pages", &json!({"pageIds": [page]}), T0)
t.call(&mut h, &Client::default(), "delete_pages", &json!({"pageIds": [page]}), T0)
.unwrap();
} else {
t.call(
&mut h,
t.call(&mut h, &Client::default(),
"rename_pages",
&json!({"updates": [{"pageId": page, "name": "Renamed"}]}),
T0,
@@ -1267,7 +1476,7 @@ mod tests {
for i in 0..6 {
write(&mut t, &mut h, T0 + i * 1000);
}
assert_eq!(t.open_run().unwrap().calls, 6);
assert_eq!(t.open_runs().next().unwrap().calls, 6);
t.tick(T0 + GAP + 5000, Some(&dir_of(&h)), &h.ed);
let run = t.take_finished().unwrap();
let dir = dir_of(&h);
@@ -1278,6 +1487,20 @@ mod tests {
assert_eq!(crate::versions::list(&dir, &h.ed.pages[0].id).len(), 1);
}
#[test]
fn each_edit_hands_over_its_touched_layers_and_the_run_counts_them_live() {
let (mut h, mut t) = (host(), Tracker::default());
write(&mut t, &mut h, T0);
let first = t.take_touched();
assert!(!first.is_empty());
assert!(t.take_touched().is_empty(), "taken once");
write(&mut t, &mut h, T0 + 1000);
let both = t.open_runs().next().unwrap().touched();
assert!(both > first.len(), "{both}");
let _ = t.call(&mut h, &Client::default(), "get_basic_info", &json!({}), T0 + 2000);
assert!(t.take_touched().is_empty(), "reads touch nothing");
}
#[test]
fn gap_starts_next_run() {
let (mut h, mut t) = (host(), Tracker::default());
@@ -1285,7 +1508,7 @@ mod tests {
std::thread::sleep(std::time::Duration::from_millis(3));
write(&mut t, &mut h, T0 + GAP);
assert_eq!(t.take_finished().unwrap().number, 1);
assert_eq!(t.open_run().unwrap().number, 2);
assert_eq!(t.open_runs().next().unwrap().number, 2);
}
#[test]
@@ -1296,24 +1519,24 @@ mod tests {
("lint_design", json!({})),
("select_nodes", json!({"nodeIds": []})),
] {
let _ = t.call(&mut h, n, &a, T0);
let _ = t.call(&mut h, &Client::default(), n, &a, T0);
}
assert!(t.open_run().is_none());
assert!(t.open_runs().next().is_none());
assert!(list(&dir_of(&h)).is_empty());
}
#[test]
fn lint_fix_opens_a_run() {
let (mut h, mut t) = (host(), Tracker::default());
let _ = t.call(&mut h, "lint_design", &json!({"fix": true}), T0);
assert_eq!(t.open_run().map(|r| r.calls), Some(1));
let _ = t.call(&mut h, &Client::default(), "lint_design", &json!({"fix": true}), T0);
assert_eq!(t.open_runs().next().map(|r| r.calls), Some(1));
}
#[test]
fn create_page_is_recorded_without_version() {
let (mut h, mut t) = (host(), Tracker::default());
t.call(&mut h, "create_page", &json!({}), T0).unwrap();
let run = t.open_run().unwrap();
t.call(&mut h, &Client::default(), "create_page", &json!({}), T0).unwrap();
let run = t.open_runs().next().unwrap();
assert_eq!(run.pages.len(), 1);
assert_eq!(run.pages[0].page_id, h.ed.pages[1].id);
assert_eq!(run.pages[0].version, None);
@@ -1325,14 +1548,13 @@ mod tests {
write(&mut t, &mut h, T0);
let old_dir = dir_of(&h);
let (other, _) = h.session.store.create("Other").unwrap();
t.call(
&mut h,
t.call(&mut h, &Client::default(),
"open_file",
&json!({"fileId": other.manifest.id}),
T0 + 1,
)
.unwrap();
assert!(t.open_run().is_none());
assert!(t.open_runs().next().is_none());
let finished = t.take_finished().unwrap();
assert_eq!((finished.number, finished.added), (1, 1));
assert_eq!(list(&old_dir), vec![finished]);
@@ -1371,17 +1593,17 @@ mod tests {
let (mut h, mut t) = (host(), Tracker::default());
write(&mut t, &mut h, T0);
let dir = dir_of(&h);
t.call(&mut h, "create_file", &json!({"name": "Other"}), T0 + 1)
t.call(&mut h, &Client::default(), "create_file", &json!({"name": "Other"}), T0 + 1)
.unwrap();
assert!(t.open_run().is_some());
assert!(t.open_runs().next().is_some());
assert!(
t.call(&mut h, "open_file", &json!({"fileId": "missing"}), T0 + 2)
t.call(&mut h, &Client::default(), "open_file", &json!({"fileId": "missing"}), T0 + 2)
.is_err()
);
assert!(t.open_run().is_some());
t.call(&mut h, "open_file", &json!({"fileId": dir}), T0 + 3)
assert!(t.open_runs().next().is_some());
t.call(&mut h, &Client::default(), "open_file", &json!({"fileId": dir}), T0 + 3)
.unwrap();
assert!(t.open_run().is_some());
assert!(t.open_runs().next().is_some());
assert!(t.take_finished().is_none());
}
@@ -1389,7 +1611,7 @@ mod tests {
fn write_then_undo_drops_the_run_and_its_version() {
let (mut h, mut t) = (host(), Tracker::default());
write(&mut t, &mut h, T0);
t.call(&mut h, "undo", &json!({}), T0 + 1).unwrap();
t.call(&mut h, &Client::default(), "undo", &json!({}), T0 + 1).unwrap();
t.tick(T0 + GAP, Some(&dir_of(&h)), &h.ed);
assert!(t.take_finished().is_none());
let dir = dir_of(&h);
@@ -1397,12 +1619,70 @@ mod tests {
assert!(crate::versions::list(&dir, &h.ed.pages[0].id).is_empty());
}
fn who(name: &str, session: &str) -> Client {
Client { name: name.into(), session: session.into() }
}
fn frame_as(t: &mut Tracker, h: &mut SessionHost, c: &Client, now: u64) -> NodeId {
let args = json!({"targetNodeId": "root", "html": "<div style=\"width: 50px; height: 50px\"></div>"});
t.call(h, c, "write_html", &args, now).unwrap();
*h.ed.doc.root.last().unwrap()
}
#[test]
fn each_client_session_gets_its_own_numbered_run() {
let (mut h, mut t) = (host(), Tracker::default());
let (a, b) = (who("Claude Code", "s1"), who("Claude Code", "s2"));
frame_as(&mut t, &mut h, &a, T0);
frame_as(&mut t, &mut h, &b, T0 + 1);
frame_as(&mut t, &mut h, &a, T0 + 2);
let open: Vec<_> = t.open_runs().map(|r| (r.client.clone(), r.calls)).collect();
assert_eq!(open, [("Claude Code".to_string(), 2), ("Claude Code \u{b7} 2".to_string(), 1)]);
t.tick(T0 + GAP + 2, Some(&dir_of(&h)), &h.ed);
let (ra, rb) = (t.take_finished().unwrap(), t.take_finished().unwrap());
assert_eq!((ra.added, rb.added), (2, 1), "each run counts only its own frames");
}
#[test]
fn chat_turn_end_closes_only_the_chat_run() {
let (mut h, mut t) = (host(), Tracker::default());
frame_as(&mut t, &mut h, &who("Codex", "x"), T0);
frame_as(&mut t, &mut h, &Client::chat("c1"), T0 + 1);
t.finish(T0 + 2, &h.ed);
assert_eq!(t.take_finished().unwrap().client, CHAT_CLIENT);
assert_eq!(t.open_runs().map(|r| r.client.as_str()).collect::<Vec<_>>(), ["Codex"]);
}
#[test]
fn revert_is_refused_when_a_later_run_changed_the_same_nodes() {
let (mut h, mut t) = (host(), Tracker::default());
let dir = dir_of(&h);
let (a, b) = (who("A", "1"), who("B", "1"));
let node = frame_as(&mut t, &mut h, &a, T0);
t.tick(T0 + GAP, Some(&dir), &h.ed);
let mut first = t.take_finished().unwrap();
let styles = json!({"updates": [{"nodeIds": [node.0.to_string()], "styles": {"opacity": 0.5}}]});
t.call(&mut h, &b, "update_styles", &styles, T0 + GAP + 1).unwrap();
frame_as(&mut t, &mut h, &a, T0 + GAP + 2);
t.tick(T0 + 2 * GAP + 2, Some(&dir), &h.ed);
assert_eq!(blocker(&list(&dir), &first), Some(2));
let err = revert(&dir, &mut h.ed, &mut first).unwrap_err();
assert!(err.contains("run 2"), "{err}");
assert_eq!(h.ed.doc.root.len(), 2, "nothing reverted");
// The third run (A again, a new frame) does not touch B's node: it reverts alone.
let mut third = list(&dir).into_iter().find(|r| r.number == 3).unwrap();
revert(&dir, &mut h.ed, &mut third).unwrap();
assert_eq!(h.ed.doc.root, [node], "B's edit and the first frame stay");
assert_eq!(revert_all(&dir, &mut h.ed, runs_from(&list(&dir), &first.id)).unwrap(), 2);
assert!(h.ed.doc.root.is_empty());
}
#[test]
fn running_record_on_disk_becomes_review() {
let (mut h, mut t) = (host(), Tracker::default());
let dir = dir_of(&h);
save(&dir, &run(1)).unwrap();
let _ = t.call(&mut h, "get_basic_info", &json!({}), T0);
let _ = t.call(&mut h, &Client::default(), "get_basic_info", &json!({}), T0);
assert_eq!(list(&dir)[0].state, RunState::Review);
}
}
+10 -1
View File
@@ -57,7 +57,7 @@ impl Session {
pub fn open(&mut self, ed: &mut Editor, dir: &Path) -> Result<(), FormatError> {
let p = Project::open(dir)?;
crate::assets::load_dir(&dir.join(crate::project::ASSETS_DIR));
let loaded = p.load_all()?;
let loaded = p.load_first(p.manifest.active_page.as_deref())?;
ed.open_pages(loaded.pages, p.manifest.active_page.as_deref());
ed.design = loaded.design;
crate::libraries::sweep_orphans(dir, &ed.design.libraries);
@@ -77,6 +77,8 @@ impl Session {
for &i in &loaded.changed {
ed.pages[i].saved_gen = u64::MAX;
}
// The other pages are read in the background; the editor installs them as they arrive.
ed.load_pages_in_background(dir, &loaded.pending);
self.design_disk = (!loaded.design_new).then(|| ed.design.clone());
ed.meta_dirty = loaded.design_new && !ed.design.collections.is_empty();
self.project = Some(p);
@@ -287,6 +289,10 @@ impl Session {
let Some(p) = &self.project else {
return Ok(());
};
// The thumbnail shows the first page: wait until it is read, or it would come out empty.
if ed.pages[0].pending {
return Ok(());
}
self.thumb_stale = false;
self.last_thumb = Some(Instant::now());
let doc = ed.page_doc(0);
@@ -304,6 +310,9 @@ impl Session {
/// asynchronously and then calls [`finish_thumbnail`]).
pub fn start_thumbnail(&mut self, ed: &Editor) -> Option<ThumbJob> {
let p = self.project.as_ref()?;
if ed.pages[0].pending {
return None;
}
self.thumb_stale = false;
self.last_thumb = Some(Instant::now());
Some(ThumbJob {
+8
View File
@@ -660,6 +660,14 @@ pub enum Icon {
Zap,
}
/// Every Lucide name [`icon_by_name`] knows.
pub const ICON_NAMES: &[&str] = &[
"chevron-right", "chevron-down", "chevron-up", "plus", "minus", "trash-2", "lock", "unlock", "eye",
"eye-off", "panel-left", "hand", "mouse-pointer-2", "pencil", "pen", "eraser", "type", "square",
"circle", "image", "star", "scale", "zap", "database", "accessibility", "code", "grid-3x3",
"ellipsis", "download", "circle-question-mark",
];
/// The app's painted icon for a Lucide name, when it has one.
#[must_use]
pub fn icon_by_name(name: &str) -> Option<Icon> {
+630
View File
@@ -0,0 +1,630 @@
//! The Actions menu on the selection (canvas right-click, the composer's Actions chip, the
//! command palette). Deterministic actions run in-process through `runs::Tracker::call`, as one
//! run and one undo step each; intent actions put a scoped prompt and a selection chip in the
//! chat composer and send it.
use super::UiState;
use super::chat_msgs::ACTION_PROMPTS;
use crate::editor::Editor;
use crate::mcp::exec::{Host, find_page, node_key};
use crate::mcp::{ACTIONS_CLIENT, Client, Out};
use crate::model::{Color, Direction, Document, NodeId, Paint};
use crate::runs::Tracker;
use egui::Ui;
use serde_json::{Value, json};
use std::collections::BTreeMap;
/// Largest RGB distance at which a hard-coded color still binds to a variable.
pub const BIND_COLOR_TOLERANCE: f64 = 12.0;
/// Largest px difference at which a spacing or radius still binds to a token.
pub const BIND_SIZE_TOLERANCE: f64 = 2.0;
/// Longest layer name Rename layers takes from text content.
const NAME_CHARS: usize = 30;
/// Unbound values listed in the status line.
const LEFT_SHOWN: usize = 6;
/// Token names spacing binds to, and radius binds to.
const SPACE_WORDS: [&str; 4] = ["space", "spacing", "gap", "pad"];
const RADIUS_WORDS: [&str; 2] = ["radius", "round"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Act {
FixLint,
BindTokens,
UseComponents,
RenameLayers,
Annotate,
AnnotateAi,
/// An entry of `chat_msgs::ACTION_PROMPTS`.
Prompt(usize),
}
/// Menu rows in order: the deterministic lane, then the intent lane.
pub fn items() -> Vec<(Act, &'static str)> {
let mut v = vec![
(Act::FixLint, "Fix lint here"),
(Act::BindTokens, "Bind to tokens"),
(Act::UseComponents, "Use components"),
(Act::RenameLayers, "Rename layers"),
(Act::Annotate, "Annotate"),
(Act::Prompt(0), ACTION_PROMPTS[0].0),
(Act::Prompt(1), ACTION_PROMPTS[1].0),
(Act::AnnotateAi, "Annotate with AI"),
];
v.extend((2..ACTION_PROMPTS.len()).map(|i| (Act::Prompt(i), ACTION_PROMPTS[i].0)));
v
}
fn is_intent(a: Act) -> bool {
matches!(a, Act::AnnotateAi | Act::Prompt(_))
}
/// The menu rows; returns the one clicked. Off without a selection.
pub fn menu(ui: &mut Ui, ed: &Editor) -> Option<Act> {
let mut out = None;
ui.add_enabled_ui(!ed.selection.is_empty(), |ui| {
let mut lane = false;
for (act, label) in items() {
if is_intent(act) != lane {
lane = true;
ui.separator();
}
if ui.button(label).clicked() {
out = Some(act);
}
}
});
out
}
/// The composer's "Actions" chip and its menu.
pub fn chip(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
let r = super::kit::chip(ui, "Actions", false).on_hover_text("Actions on the selection");
let mut act = None;
egui::Popup::menu(&r).width(200.0).show(|ui| {
act = menu(ui, ed);
if act.is_some() {
ui.close();
}
});
if let Some(a) = act {
dispatch(ed, st, a);
}
}
/// Starts `act` on the selection: deterministic actions are queued for the host
/// (`UiState::pending_action`), intents fill the composer and send.
pub fn dispatch(ed: &mut Editor, st: &mut UiState, act: Act) {
let page = ed.active_page;
let top = ed.top_selection();
let Some(first) = top.first().copied() else {
ed.status = "Select a layer first".into();
return;
};
match act {
Act::Annotate => super::annotations::open_popover(ed, &mut st.ann_ui, first),
Act::AnnotateAi => {
super::annotations::annotate_with_ai(ed, st);
st.chat.send_draft = true;
}
Act::Prompt(i) => {
super::chat::add_selection(ed, &mut st.chat);
st.chat.draft = ACTION_PROMPTS[i].1.into();
st.set_chat_mode(true);
st.chat.send_draft = true;
}
_ => st.pending_action = Some((act, top.iter().map(|id| node_key(ed, page, *id)).collect())),
}
}
// ---------------------------------------------------------------------------- deterministic lane
/// Runs a deterministic action on `keys` as one run of the Actions client and one undo step.
/// The run's title is the summary the toast shows; nothing changed (or an error) goes to the
/// status line.
pub fn run(host: &mut dyn Host, runs: &mut Tracker, act: Act, keys: &[String], now: u64) {
let client = Client { name: ACTIONS_CLIENT.into(), session: String::new() };
let undo_from = host.editor().history.undo_len();
let mut tool = |host: &mut dyn Host, name: &str, args: Value| -> Result<Value, String> {
let out = runs.call(host, &client, name, &args, now)?;
Ok(match out.first() {
Some(Out::Text(t)) => serde_json::from_str(t).unwrap_or(Value::Null),
_ => Value::Null,
})
};
let (label, r) = match act {
Act::FixLint => ("Fix lint", fix_lint(host, &mut tool, keys)),
Act::BindTokens => ("Bind to tokens", bind_tokens(host, &mut tool, keys)),
Act::UseComponents => ("Use components", use_components(host, &mut tool, keys)),
Act::RenameLayers => ("Rename layers", rename_layers(host, &mut tool, keys)),
Act::Annotate | Act::AnnotateAi | Act::Prompt(_) => return,
};
host.editor().history.squash_since(undo_from, label);
let ed = host.editor();
match r {
Ok((summary, changed)) => {
runs.finish_client(now, ed, &client, summary.clone());
if !changed {
ed.status = summary;
}
}
Err(e) => {
runs.finish_client(now, ed, &client, label.into());
ed.status = format!("{label}: {e}");
}
}
}
/// (summary, whether anything changed)
type Outcome = Result<(String, bool), String>;
type Tool<'a> = dyn FnMut(&mut dyn Host, &str, Value) -> Result<Value, String> + 'a;
/// The page and node ids of `keys` (dropped when gone).
fn resolve(ed: &Editor, keys: &[String]) -> (usize, Vec<NodeId>) {
let mut page = ed.active_page;
let ids = keys
.iter()
.filter_map(|k| {
let (p, n) = k.split_once(':')?;
page = find_page(ed, p).ok()?;
let id = NodeId(n.parse().ok()?);
ed.page_doc(page).get(id).map(|_| id)
})
.collect();
(page, ids)
}
/// Every layer under `roots` that is not part of an instance, roots first.
fn own_layers(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
roots.iter().flat_map(|r| doc.subtree(*r)).filter(|id| doc.get(*id).is_some_and(|n| n.instance.is_none())).collect()
}
fn fix_lint(host: &mut dyn Host, tool: &mut Tool, keys: &[String]) -> Outcome {
let mut fixed = 0;
for k in keys {
fixed += tool(host, "lint_design", json!({"nodeId": k, "fix": true}))?["fixedNodes"].as_u64().unwrap_or(0);
}
Ok(match fixed {
0 => ("Nothing to fix".into(), false),
n => (format!("Fixed {}", super::panels::count(n as usize, "layer")), true),
})
}
fn color_distance(a: Color, b: Color) -> f64 {
let d = |x: u8, y: u8| f64::from(x) - f64::from(y);
(d(a.r, b.r).powi(2) + d(a.g, b.g).powi(2) + d(a.b, b.b).powi(2)).sqrt()
}
/// The variable closest to `c` within `BIND_COLOR_TOLERANCE`.
pub fn nearest_color(vars: &BTreeMap<String, Color>, c: Color) -> Option<&str> {
vars.iter()
.map(|(n, v)| (n, color_distance(*v, c)))
.filter(|(_, d)| *d <= BIND_COLOR_TOLERANCE)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(n, _)| n.as_str())
}
/// The token closest to `v` within `BIND_SIZE_TOLERANCE`.
pub fn nearest_size(tokens: &[(String, f64)], v: f64) -> Option<&str> {
tokens
.iter()
.map(|(n, t)| (n, (t - v).abs()))
.filter(|(_, d)| *d <= BIND_SIZE_TOLERANCE)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(n, _)| n.as_str())
}
/// Px tokens whose name has one of `words`.
fn px_tokens(doc: &Document, words: &[&str]) -> Vec<(String, f64)> {
doc.tokens
.iter()
.filter(|(n, _)| words.iter().any(|w| n.contains(w)))
.filter_map(|(n, v)| Some((n.clone(), v.trim().strip_suffix("px").unwrap_or(v.trim()).trim().parse().ok()?)))
.collect()
}
fn bind_tokens(host: &mut dyn Host, tool: &mut Tool, keys: &[String]) -> Outcome {
let ed = host.editor();
let (page, roots) = resolve(ed, keys);
let page_id = ed.pages[page].id.clone();
let doc = ed.page_doc(page);
let (spaces, radii) = (px_tokens(doc, &SPACE_WORDS), px_tokens(doc, &RADIUS_WORDS));
// (field, variable) -> layers, for one bind_variable call each.
let mut binds: BTreeMap<(&str, String), Vec<String>> = BTreeMap::new();
let mut fills = Vec::new();
let (mut colors, mut space, mut radius) = (0, 0, 0);
let mut left = Vec::new();
for id in own_layers(doc, &roots) {
let n = doc.get(id).expect("own layer");
let key = node_key(ed, page, id);
let text = n.kind.text().is_some();
if let Some(Paint::Solid { color, var: None }) = n.style.fills.last()
&& color.a == 255
&& !(text && n.bindings.contains_key("text-color"))
{
match nearest_color(&doc.variables, *color) {
Some(v) => {
let prop = if text { "color" } else { "backgroundColor" };
fills.push(json!({"nodeIds": [key], "styles": {prop: format!("var(--{v})")}}));
colors += 1;
}
None => left.push(format!("{} on {}", color.to_hex(), n.name)),
}
}
if let Some(s) = n.style.stroke.filter(|s| s.a == 255 && !n.bindings.contains_key("stroke")) {
match nearest_color(&doc.variables, s) {
Some(v) => {
binds.entry(("stroke", v.to_string())).or_default().push(key.clone());
colors += 1;
}
None => left.push(format!("stroke {} on {}", s.to_hex(), n.name)),
}
}
let mut field = |field: &'static str, v: f64, tokens: &[(String, f64)], count: &mut usize| {
if v <= 0.0 || n.bindings.contains_key(field) {
return;
}
match nearest_size(tokens, v) {
Some(t) => {
binds.entry((field, t.to_string())).or_default().push(key.clone());
*count += 1;
}
None => left.push(format!("{field} {v} on {}", n.name)),
}
};
if let Some(l) = &n.layout {
field("gap", l.gap, &spaces, &mut space);
if l.padding.iter().all(|p| *p == l.padding[0]) {
field("padding", l.padding[0], &spaces, &mut space);
} else {
for (side, p) in ["padding-top", "padding-right", "padding-bottom", "padding-left"].into_iter().zip(l.padding) {
field(side, p, &spaces, &mut space);
}
}
}
if n.style.radii.is_none() {
field("radius", n.style.corner_radius, &radii, &mut radius);
}
}
if !fills.is_empty() {
tool(host, "update_styles", json!({"updates": fills}))?;
}
for ((field, var), ids) in binds {
tool(host, "bind_variable", json!({"nodeIds": ids, "field": field, "variable": var, "pageId": page_id}))?;
}
let mut parts = Vec::new();
if colors > 0 {
parts.push(super::panels::count(colors, "color"));
}
if space > 0 {
parts.push(super::panels::count(space, "space"));
}
if radius > 0 {
parts.push(format!("{radius} {}", if radius == 1 { "radius" } else { "radii" }));
}
let changed = !parts.is_empty();
let mut summary = if changed { format!("Bound {}", parts.join(" \u{b7} ")) } else { "Nothing to bind".into() };
if !left.is_empty() {
let more = left.len().saturating_sub(LEFT_SHOWN);
left.truncate(LEFT_SHOWN);
if more > 0 {
left.push(format!("{more} more"));
}
let note = format!("no variable near: {}", left.join(", "));
host.editor().status = note.clone();
if !changed {
summary = format!("{summary}; {note}");
}
}
Ok((summary, changed))
}
/// How `create_instance` names the main a hand-made copy matched.
enum MainRef {
Id(String),
Key(String),
}
fn use_components(host: &mut dyn Host, tool: &mut Tool, keys: &[String]) -> Outcome {
let ed = host.editor();
let (page, roots) = resolve(ed, keys);
let page_id = ed.pages[page].id.clone();
let doc = ed.page_doc(page);
// Same order as `lint::component_mains`: pages, then enabled libraries.
let mut mains: Vec<(MainRef, &Document, NodeId)> = Vec::new();
for p in 0..ed.pages.len() {
let d = ed.page_doc(p);
mains.extend(d.nodes.values().filter(|n| n.component).map(|n| (MainRef::Id(node_key(ed, p, n.id)), d, n.id)));
}
for d in ed.library_docs.values() {
mains.extend(d.nodes.values().filter(|n| n.component).filter_map(|n| Some((MainRef::Key(n.component_key.clone()?), d, n.id))));
}
// Each hand-made copy goes to the first main it matches.
let mut hits: Vec<(NodeId, usize)> = Vec::new();
for (i, (_, d, m)) in mains.iter().enumerate() {
for issue in crate::mcp::lint::not_an_instance(doc, &roots, &[(*d, *m)]) {
if !hits.iter().any(|h| h.0 == issue.node) {
hits.push((issue.node, i));
}
}
}
// A copy inside another copy goes with its outer one.
let outer: Vec<NodeId> = hits.iter().map(|h| h.0).collect();
hits.retain(|(id, _)| !doc.ancestors(*id).iter().any(|a| outer.contains(a)));
struct Plan {
copy: String,
args: Value,
/// left/top when the parent does not lay it out.
place: Option<(f64, f64)>,
texts: Vec<String>,
}
let plans: Vec<Plan> = hits
.iter()
.map(|(id, i)| {
let n = doc.get(*id).expect("hit");
let mut args = json!({"name": n.name, "pageId": page_id});
match &mains[*i].0 {
MainRef::Id(k) => args["componentId"] = json!(k),
MainRef::Key(k) => args["componentKey"] = json!(k),
}
let parent = n.parent.and_then(|p| doc.get(p));
let place = match parent {
None => {
args["x"] = json!(n.rect.x);
args["y"] = json!(n.rect.y);
None
}
Some(p) => {
args["parentId"] = json!(node_key(ed, page, p.id));
args["index"] = json!(p.children.iter().position(|c| c == id).unwrap_or(0));
(p.layout.is_none() || n.layout_child.absolute).then_some((n.rect.x, n.rect.y))
}
};
let texts = doc.subtree(*id).iter().filter_map(|t| doc.get(*t)?.kind.text().cloned()).collect();
Plan { copy: node_key(ed, page, *id), args, place, texts }
})
.collect();
for p in &plans {
let made = tool(host, "create_instance", p.args.clone())?;
let inst = made["id"].as_str().unwrap_or_default().to_string();
let updates: Vec<Value> = made["textLayers"]
.as_array()
.into_iter()
.flatten()
.zip(&p.texts)
.filter(|(l, t)| l["text"].as_str() != Some(t.as_str()))
.map(|(l, t)| json!({"nodeId": l["id"], "textContent": t}))
.collect();
if !updates.is_empty() {
tool(host, "set_text_content", json!({"updates": updates}))?;
}
if let Some((x, y)) = p.place {
tool(host, "update_styles", json!({"updates": [{"nodeIds": [inst], "styles": {"left": format!("{x}px"), "top": format!("{y}px")}}]}))?;
}
tool(host, "delete_nodes", json!({"nodeIds": [p.copy]}))?;
}
Ok(match plans.len() {
0 => ("No hand-made copies of components".into(), false),
n => (format!("Replaced {} with instances", super::panels::count(n, "copy").replace("copys", "copies")), true),
})
}
/// A name the app or an import gave by default: `Frame 12`, `Text`, `Rectangle 3`.
fn default_name(name: &str) -> bool {
crate::mcp::lint::is_default_name(name) || name.trim_end_matches(|c: char| c.is_ascii_digit() || c == ' ') == "Text"
}
/// A name from the layer's text (or its first text inside) and, for frames, its role.
pub fn suggest_name(doc: &Document, id: NodeId) -> Option<String> {
let n = doc.get(id)?;
let text = doc.subtree(id).into_iter().find_map(|t| doc.get(t)?.kind.text().cloned())?;
let line: String = text.lines().map(str::trim).find(|l| !l.is_empty())?.chars().take(NAME_CHARS).collect();
if n.kind.text().is_some() {
return Some(line);
}
let role = match n.layout.map(|l| l.direction) {
Some(Direction::Horizontal) => "Row",
Some(Direction::Vertical) => "Stack",
None => "Group",
};
Some(format!("{line} {role}"))
}
fn rename_layers(host: &mut dyn Host, tool: &mut Tool, keys: &[String]) -> Outcome {
let ed = host.editor();
let (page, roots) = resolve(ed, keys);
let doc = ed.page_doc(page);
let updates: Vec<Value> = own_layers(doc, &roots)
.into_iter()
.filter(|id| doc.get(*id).is_some_and(|n| default_name(&n.name)))
.filter_map(|id| Some(json!({"nodeId": node_key(ed, page, id), "name": suggest_name(doc, id)?})))
.collect();
if updates.is_empty() {
return Ok(("No default layer names".into(), false));
}
let n = tool(host, "rename_nodes", json!({"updates": updates}))?["renamed"].as_u64().unwrap_or(0);
Ok((format!("Renamed {}", super::panels::count(n as usize, "layer")), true))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::exec::{self, SessionHost};
fn host() -> (SessionHost, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("papercraft-actions-{:016x}", fastrand::u64(..)));
let mut h = SessionHost::new(crate::project::Store::open(&dir).unwrap());
h.session.create(&mut h.ed, "Actions").unwrap();
(h, dir)
}
fn call(h: &mut SessionHost, name: &str, args: Value) -> Value {
match exec::call(h, name, &args).unwrap_or_else(|e| panic!("{name}: {e}")).remove(0) {
Out::Text(t) => serde_json::from_str(&t).unwrap(),
_ => Value::Null,
}
}
/// Runs `act` on `key` and checks it made one undo step and one Actions run.
fn act(h: &mut SessionHost, act: Act, key: &str) -> crate::runs::Run {
let mut t = Tracker::default();
let undo = h.ed.history.undo_len();
run(h, &mut t, act, &[key.to_string()], 1_000);
assert_eq!(h.ed.history.undo_len(), undo + 1, "one undo step");
let r = t.take_finished().expect("one run");
assert!(t.take_finished().is_none() && t.open_runs().next().is_none(), "exactly one run, closed");
assert_eq!(r.client, ACTIONS_CLIENT);
r
}
fn lint(h: &mut SessionHost, key: &str, rule: &str) -> usize {
call(h, "lint_design", json!({"nodeId": key}))["issues"].as_array().unwrap().iter().filter(|i| i["rule"] == rule).count()
}
#[test]
fn nearest_matching_respects_the_tolerance() {
let vars: BTreeMap<String, Color> = [("ink".to_string(), Color::rgb(20, 20, 20)), ("brand".to_string(), Color::rgb(0x3b, 0x82, 0xf6))].into();
assert_eq!(nearest_color(&vars, Color::rgb(0x3c, 0x80, 0xf0)), Some("brand"));
assert_eq!(nearest_color(&vars, Color::rgb(200, 0, 0)), None);
let toks = vec![("space-2".to_string(), 8.0), ("space-4".to_string(), 16.0)];
assert_eq!(nearest_size(&toks, 15.0), Some("space-4"));
assert_eq!(nearest_size(&toks, 12.0), None);
}
#[test]
fn bind_to_tokens_binds_near_values_in_one_step_and_run() {
let (mut h, dir) = host();
call(&mut h, "set_variables", json!({"variables": {"brand": "#3b82f6", "ink": "#111111"}}));
call(&mut h, "set_tokens", json!({"tokens": {"space-4": "16px", "radius-md": "8px"}}));
let ab = call(&mut h, "create_artboard", json!({"name": "Card", "styles": {"width": "300px", "height": "200px", "display": "flex", "gap": "15px", "padding": "16px", "borderRadius": "9px", "backgroundColor": "#3c80f0"}}));
let key = ab["id"].as_str().unwrap().to_string();
call(&mut h, "write_html", json!({"targetNodeId": key, "mode": "insert-children", "html": "<div style=\"color:#121212;font-size:16px\">Hi</div><div style=\"color:#ff0000;font-size:16px\">Red</div>"}));
assert_eq!(lint(&mut h, &key, "hard-coded-color"), 3, "near blue, near ink, red");
let r = act(&mut h, Act::BindTokens, &key);
assert_eq!(r.title, "Bound 2 colors \u{b7} 2 spaces \u{b7} 1 radius");
assert_eq!(lint(&mut h, &key, "hard-coded-color"), 1, "only the red is left");
let (page, id) = (h.ed.active_page, resolve(&h.ed, std::slice::from_ref(&key)).1[0]);
let n = h.ed.page_doc(page).get(id).unwrap();
assert_eq!(n.style.fills.last().and_then(Paint::var), Some("brand"));
assert_eq!(n.bindings.get("gap").map(String::as_str), Some("space-4"));
assert_eq!(n.layout.unwrap().gap, 16.0, "the value follows the token");
assert_eq!(n.bindings.get("radius").map(String::as_str), Some("radius-md"));
assert!(h.ed.status.contains("#FF0000") || h.ed.status.to_lowercase().contains("#ff0000"), "the red is listed: {}", h.ed.status);
h.ed.undo();
let n = h.ed.page_doc(page).get(id).unwrap();
assert!(n.bindings.is_empty() && n.style.fills.last().and_then(Paint::var).is_none(), "one undo takes it all back");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn use_components_swaps_copies_for_instances_and_keeps_their_text() {
let (mut h, dir) = host();
let button = |name: &str, label: &str| format!("<div layer-name=\"{name}\" style=\"display:flex;padding:12px 16px\"><div style=\"font-size:16px\">{label}</div></div>");
let ab = call(&mut h, "create_artboard", json!({"name": "Screen", "styles": {"width": "400px", "height": "400px", "display": "flex", "gap": "8px"}}));
let key = ab["id"].as_str().unwrap().to_string();
let r = call(&mut h, "write_html", json!({"targetNodeId": key, "mode": "insert-children", "html": button("Button", "Save")}));
call(&mut h, "create_component", json!({"nodeId": r["createdNodeIds"][0], "name": "Button"}));
call(&mut h, "write_html", json!({"targetNodeId": key, "mode": "insert-children", "html": button("Send copy", "Send")}));
assert_eq!(lint(&mut h, &key, "not-an-instance"), 1);
let r = act(&mut h, Act::UseComponents, &key);
assert_eq!(r.title, "Replaced 1 copy with instances");
assert_eq!(lint(&mut h, &key, "not-an-instance"), 0);
let (page, id) = (h.ed.active_page, resolve(&h.ed, std::slice::from_ref(&key)).1[0]);
let doc = h.ed.page_doc(page);
let kids = &doc.get(id).unwrap().children;
assert_eq!(kids.len(), 2);
let inst = doc.get(kids[1]).unwrap();
assert!(inst.instance.is_some(), "in the copy's place");
assert_eq!(inst.name, "Send copy");
let text = doc.subtree(inst.id).into_iter().find_map(|t| doc.get(t)?.kind.text().cloned());
assert_eq!(text.as_deref(), Some("Send"), "the copy's text is kept as an override");
h.ed.undo();
assert_eq!(lint(&mut h, &key, "not-an-instance"), 1, "one undo brings the copy back");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn rename_layers_names_default_layers_from_their_text() {
let (mut h, dir) = host();
let ab = call(&mut h, "create_artboard", json!({"name": "Screen", "styles": {"width": "400px", "height": "400px", "display": "flex", "flexDirection": "column"}}));
let key = ab["id"].as_str().unwrap().to_string();
call(&mut h, "write_html", json!({"targetNodeId": key, "mode": "insert-children", "html": "<div layer-name=\"Frame 12\" style=\"display:flex\"><div layer-name=\"Text\">Sign in</div></div><div layer-name=\"Kept\">x</div>"}));
let r = act(&mut h, Act::RenameLayers, &key);
assert_eq!(r.title, "Renamed 2 layers");
let (page, id) = (h.ed.active_page, resolve(&h.ed, std::slice::from_ref(&key)).1[0]);
let doc = h.ed.page_doc(page);
let names: Vec<&str> = doc.subtree(id).iter().skip(1).map(|i| doc.get(*i).unwrap().name.as_str()).collect();
assert_eq!(names, ["Sign in Row", "Kept", "Sign in"]);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn fix_lint_here_is_one_run() {
let (mut h, dir) = host();
let ab = call(&mut h, "create_artboard", json!({"name": "Screen", "styles": {"width": "400px", "height": "400px", "display": "flex", "gap": "10px", "padding": "6px"}}));
let key = ab["id"].as_str().unwrap().to_string();
let r = act(&mut h, Act::FixLint, &key);
assert_eq!(r.title, "Fixed 1 layer");
assert_eq!(lint(&mut h, &key, "spacing-grid"), 0);
let mut t = Tracker::default();
run(&mut h, &mut t, Act::FixLint, &[key], 2_000);
assert!(t.take_finished().is_none(), "nothing left to fix: no run");
assert_eq!(h.ed.status, "Nothing to fix");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn deterministic_actions_queue_and_intents_fill_the_composer() {
use crate::geom::Rect;
use crate::model::{Node, NodeKind};
let mut e = Editor::default();
let mut st = UiState::default();
dispatch(&mut e, &mut st, Act::BindTokens);
assert!(st.pending_action.is_none() && e.status == "Select a layer first");
let f = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 100.0, 100.0)), None);
e.edit("name", |tx| tx.node_mut(f).unwrap().name = "Card".into());
e.select(vec![f]);
dispatch(&mut e, &mut st, Act::BindTokens);
assert_eq!(st.pending_action, Some((Act::BindTokens, vec![node_key(&e, 0, f)])));
dispatch(&mut e, &mut st, Act::Prompt(0));
assert_eq!(st.chat.draft, ACTION_PROMPTS[0].1);
assert_eq!(st.chat.sels.len(), 1);
assert_eq!(st.chat.sels[0].keys, [node_key(&e, 0, f)]);
assert!(st.chat.send_draft);
assert_eq!(items().iter().filter(|(a, _)| is_intent(*a)).count(), ACTION_PROMPTS.len() + 1);
}
#[cfg(unix)]
#[test]
fn an_intent_is_sent_with_its_prompt_and_chip() {
use crate::geom::Rect;
use crate::model::{Node, NodeKind};
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("papercraft-actions-send-{:016x}", fastrand::u64(..)));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("claude");
std::fs::write(&bin, "#!/bin/sh\ncat >/dev/null\necho '{\"type\":\"result\",\"session_id\":\"s\"}'\n").unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut e = Editor::default();
let f = e.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 100.0, 100.0)), None);
e.edit("name", |tx| tx.node_mut(f).unwrap().name = "Card".into());
e.select(vec![f]);
let mut st = UiState::default();
st.chat.project = Some(dir.clone());
st.chat.prefs.claude_path = Some(bin.display().to_string());
st.chat.mcp_url = "http://127.0.0.1:1/mcp".into();
dispatch(&mut e, &mut st, Act::Prompt(1));
let ctx = egui::Context::default();
super::super::chat::send_draft(&ctx, &e, &mut st.chat);
assert!(st.chat.send_draft, "waits for the agent scan");
st.chat.detect = Some(Default::default());
super::super::chat::send_draft(&ctx, &e, &mut st.chat);
assert!(!st.chat.send_draft && st.chat.error.is_none(), "{:?}", st.chat.error);
let sent = &st.chat.chat.as_ref().unwrap().messages[0].text;
let (context, text) = super::super::chat_msgs::split_context(sent);
assert_eq!(text, ACTION_PROMPTS[1].1);
assert_eq!(context, [format!("[Selection] Card ({})", node_key(&e, 0, f))]);
assert!(st.chat.draft.is_empty() && st.chat.sels.is_empty());
st.chat.stop();
std::fs::remove_dir_all(dir).ok();
}
}
+114 -19
View File
@@ -137,8 +137,7 @@ pub fn build_annotation(
) -> Annotation {
let mut a = Annotation::new(Some(target), label.trim(), category);
a.body = compose_body(fields, body);
a.position = annotation::auto_position(doc, target);
a.anchor = Anchor { side: Side::Right, t: 0.5 };
annotation::auto_place(doc, &mut a);
a
}
@@ -331,11 +330,16 @@ pub fn open_popover(ed: &mut Editor, ann: &mut AnnotationsUi, target: NodeId) {
/// Screen position of the popover: right of the target, or left / below when out of room.
fn popover_pos(ed: &Editor, canvas: egui::Rect, target: NodeId) -> egui::Pos2 {
let Some(r) = ed.doc.world_rect(target) else { return canvas.center() };
let s = ed.camera.world_rect_to_screen(&r);
anchor_pos(ed, canvas, &r, POPOVER_W)
}
/// Screen position of a `width`-wide popover next to the world rect `r` (the chat's Ask box too).
pub fn anchor_pos(ed: &Editor, canvas: egui::Rect, r: &crate::geom::Rect, width: f32) -> egui::Pos2 {
let s = ed.camera.world_rect_to_screen(r);
let (min, max) = (s.min(), V2 { x: s.x + s.w, y: s.y + s.h });
let pos = egui::pos2(canvas.min.x + max.x as f32 + 12.0, canvas.min.y + min.y as f32);
if pos.x + POPOVER_W > canvas.max.x {
return egui::pos2(canvas.min.x + min.x as f32 - POPOVER_W - 12.0, pos.y);
if pos.x + width > canvas.max.x {
return egui::pos2(canvas.min.x + min.x as f32 - width - 12.0, pos.y);
}
pos
}
@@ -436,31 +440,34 @@ fn popover(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi, canvas
}
/// Right-click menu on the canvas: Annotate the selection.
fn canvas_menu(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi, canvas: egui::Rect) {
/// The right-click Actions menu on the selection. Annotate opens the popover here; the other
/// actions come back for `actions::dispatch`.
fn canvas_menu(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi, canvas: egui::Rect) -> Option<super::actions::Act> {
let clicked = ctx.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
if clicked && !ed.selection.is_empty() {
let at = ctx.input(|i| i.pointer.interact_pos());
ann.menu = at.filter(|p| canvas.contains(*p));
}
let Some(pos) = ann.menu else { return };
let mut run = false;
let pos = ann.menu?;
let mut act = None;
let area = egui::Area::new("annotation-menu".into()).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(140.0);
if ui.button("Annotate").clicked() {
run = true;
}
ui.set_min_width(180.0);
act = super::actions::menu(ui, ed);
});
});
let away = ctx.input(|i| i.pointer.any_pressed()) && !area.response.contains_pointer();
if run {
if act == Some(super::actions::Act::Annotate) {
act = None;
if let Some(t) = ed.top_selection().first().copied() {
open_popover(ed, ann, t);
}
}
if run || away || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
ann.menu = None;
}
if act.is_some() || away || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
ann.menu = None;
}
act
}
// ---------------------------------------------------------------------------- shared note editor
@@ -556,15 +563,57 @@ pub fn annotate_with_ai(ed: &mut Editor, st: &mut UiState) {
let name = frame.and_then(|f| ed.doc.get(f)).map(|n| n.name.clone());
let subject = name.as_ref().map_or_else(|| "this page".to_string(), |n| format!("the frame “{n}”"));
st.chat.draft = ai_prompt(&subject, &tpl.name);
if let (Some(f), Some(n)) = (frame, name) {
let chip = super::chat::SelChip { label: n, keys: vec![crate::mcp::exec::node_key(ed, page, f)] };
if !st.chat.sels.contains(&chip) {
if let Some(f) = frame {
let chip = super::chat::layer_chip(ed, st.chat.gpu.as_ref(), page, &[f]);
if !st.chat.sels.iter().any(|c| c.keys == chip.keys) {
st.chat.sels.push(chip);
}
}
st.set_chat_mode(true);
}
/// Annotation types "Send open notes to chat" picks up, besides open feedback.
const OPEN_NOTE_TYPES: [&str; 2] = ["todo", "question"];
/// The frame's open notes as one prompt: its chip, the context lines (open feedback, then
/// `todo`/`question` annotations from `selection_notes`) and the ask. None when nothing is open.
pub fn open_notes_message(ed: &Editor, frame: NodeId) -> Option<(super::chat::SelChip, Vec<String>, String)> {
let page = ed.active_page;
let name = ed.doc.get(frame)?.name.clone();
let chip = super::chat::SelChip { label: name.clone(), keys: vec![crate::mcp::exec::node_key(ed, page, frame)], ..Default::default() };
let feedback = ed.doc.subtree(frame).into_iter().filter_map(|id| Some((id, ed.doc.get(id)?))).flat_map(|(id, n)| {
let key = crate::mcp::exec::node_key(ed, page, id);
n.comments.iter().filter(|c| !c.resolved).map(move |c| format!("feedback #{} on {key} ({}): {}", c.id, n.name, c.text.trim()))
});
let typed = super::chat::selection_notes(ed, std::slice::from_ref(&chip)).into_iter().filter(|l| {
l.split_once("] on ").is_some_and(|(head, _)| OPEN_NOTE_TYPES.iter().any(|k| head.to_lowercase().ends_with(&format!("[{k}"))))
});
let notes: Vec<String> = feedback.chain(typed).collect();
if notes.is_empty() {
return None;
}
let ask = format!(
"Work through the {} open notes on the frame “{name}”: make each feedback and todo change, answer each question in a reply. Resolve the feedback you handled (resolve_feedback) and keep to the design system.",
notes.len()
);
Some((chip, notes, ask))
}
/// "Send open notes to chat": sends the frame's open notes as one batched message with its chip
/// (an explicit action, so it sends) and opens Chat mode. The composer's own draft and chips stay.
pub fn send_open_notes(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, frame: NodeId) {
let Some((chip, notes, ask)) = open_notes_message(ed, frame) else {
ed.status = "No open notes on this frame".into();
return;
};
st.set_chat_mode(true);
let kept = (std::mem::replace(&mut st.chat.sels, vec![chip]), std::mem::replace(&mut st.chat.draft, ask));
st.chat.sel_notes = notes;
st.chat.refresh_ds(ed, true);
let _ = st.chat.submit(ctx);
(st.chat.sels, st.chat.draft) = kept;
}
fn template_picker(ui: &mut Ui, ed: &mut Editor, ann: &mut AnnotationsUi) {
let page = ed.active_page;
let templates = ed.design.templates();
@@ -680,6 +729,15 @@ pub fn panel(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ann.customize.open = true;
ann.customize.draft = None;
}
let tidy_frame = ed.selection.first().and_then(|n| annotation::frame_of(&ed.doc, *n));
ui.add_enabled_ui(!ed.doc.annotations.is_empty(), |ui| {
if kit::button_wide(ui, kit::ButtonKind::Secondary, None, "Tidy notes")
.on_hover_text("Re-place the notes of the selected frame (or the page) beside their targets")
.clicked()
{
ed.tidy_annotations(tidy_frame);
}
});
let can_chat = !cfg!(target_arch = "wasm32") && st.project_dir.is_some();
ui.add_enabled_ui(can_chat, |ui| {
if kit::button_wide(ui, kit::ButtonKind::Primary, None, "Annotate with AI")
@@ -796,6 +854,15 @@ pub fn inspector_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: Nod
open_popover(ed, &mut ann, id);
}
put(st, ann);
let frame = annotation::frame_of(&ed.doc, id).filter(|f| *f == id);
let can_chat = !cfg!(target_arch = "wasm32") && st.project_dir.is_some() && !st.chat.is_running();
if let Some(f) = frame.filter(|f| can_chat && open_notes_message(ed, *f).is_some())
&& kit::button_wide(ui, kit::ButtonKind::Secondary, None, "Send open notes to chat")
.on_hover_text("Open feedback and todo/question notes on this frame, as one chat message")
.clicked()
{
send_open_notes(&ui.ctx().clone(), ed, st, f);
}
}
// ---------------------------------------------------------------------------- customize modal
@@ -1037,7 +1104,7 @@ fn customize(ctx: &egui::Context, ed: &mut Editor, ann: &mut AnnotationsUi) {
/// Popover, right-click menu, Customize modal and delete confirm. Called once per frame.
pub fn overlays(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egui::Rect) {
let mut ann = take(st);
canvas_menu(ctx, ed, &mut ann, canvas);
let act = canvas_menu(ctx, ed, &mut ann, canvas);
popover(ctx, ed, &mut ann, canvas);
customize(ctx, ed, &mut ann);
if let Some(id) = ann.pending_delete {
@@ -1059,6 +1126,9 @@ pub fn overlays(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas:
}
}
put(st, ann);
if let Some(a) = act {
super::actions::dispatch(ed, st, a);
}
}
#[cfg(test)]
@@ -1147,6 +1217,31 @@ mod tests {
assert!(st.chat.turn.is_none());
}
#[test]
fn open_notes_batch_feedback_todos_and_questions_with_a_chip() {
let mut e = Editor::default();
let f = frame(&mut e, "Sign in", Rect::new(0.0, 0.0, 300.0, 300.0));
let c = child(&mut e, f, Rect::new(10.0, 10.0, 20.0, 20.0));
assert!(open_notes_message(&e, f).is_none(), "nothing open");
let comment = |id, text: &str, resolved| crate::model::Comment { id, text: text.into(), author: "user".into(), resolved, reply: None };
e.edit("fb", |tx| {
let n = tx.node_mut(c).unwrap();
n.comments.push(comment(1, "Bigger button", false));
n.comments.push(comment(2, "Done already", true));
});
e.add_annotation(Annotation::new(Some(c), "Empty state", "todo"));
e.add_annotation(Annotation::new(Some(f), "Which locale?", "Question"));
e.add_annotation(Annotation::new(Some(c), "Reads users.email", "data"));
let (chip, notes, ask) = open_notes_message(&e, f).unwrap();
assert_eq!(chip.label, "Sign in");
assert_eq!(chip.keys, [crate::mcp::exec::node_key(&e, 0, f)]);
assert_eq!(notes.len(), 3, "{notes:?}");
assert!(notes[0].starts_with("feedback #1 on ") && notes[0].ends_with("Bigger button"));
assert!(notes[1..].iter().any(|n| n.contains("[todo]") && n.contains("Empty state")));
assert!(notes[1..].iter().any(|n| n.contains("Which locale?")));
assert!(ask.contains("3 open notes") && ask.contains("“Sign in”"));
}
fn run(f: impl FnMut(&mut Ui)) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
+155 -92
View File
@@ -6,8 +6,9 @@ use crate::editor::Editor;
use crate::editor::shortcuts;
use crate::geom::V2;
use crate::model::annotation::{
Annotation, AnnotationCategory, CARD_H, CARD_W, Display, Side, anchor_world, card_world, numbers,
Annotation, AnnotationCategory, CARD_W, Display, Side, anchor_world, card_world, numbers,
};
use crate::model::annotation_layout::{self as layout, BADGE, BODY_LH, BODY_LINES, HEAD_GAP, LABEL_LINES, LABEL_W};
use crate::theme::{self, Icon};
use egui::{Align2, Color32, Galley, Pos2, Rect, Sense, Stroke, vec2};
use std::collections::{HashMap, HashSet};
@@ -41,7 +42,10 @@ impl From<Display> for Mode {
}
}
type GalleyKey = (u64, u64, u32, bool);
/// (annotation, text hash, scale bucket, kind): 0 label, 1 label in full, 2 body in full,
/// 3 body capped at [`BODY_LINES`] rows.
type GalleyKey = (u64, u64, u32, u8);
type RouteKey = (u64, u64);
/// View state (never saved): global mode, hidden categories, collapsed notes, the drag in
/// progress and the text layout cache.
@@ -51,9 +55,14 @@ pub struct AnnotationsView {
pub mode: Option<Mode>,
pub hidden_categories: HashSet<String>,
pub collapsed: HashSet<u64>,
/// Notes showing every body line instead of "+N more lines".
pub expanded: HashSet<u64>,
drag: Option<(u64, V2)>,
galleys: HashMap<GalleyKey, Arc<Galley>>,
nums: Option<(u64, Arc<Numbers>)>,
/// Leader routes in world space, keyed by annotation; valid while the key (doc revision,
/// card rects around it) is unchanged.
routes: HashMap<u64, (RouteKey, Vec<V2>)>,
}
pub type Numbers = HashMap<u64, (Option<crate::model::NodeId>, u32)>;
@@ -86,11 +95,10 @@ const SCALE_MAX: f64 = 1.5;
const SCALE_STEP: f64 = 0.1;
const GALLEY_CACHE_MAX: usize = 512;
const PAD: f32 = 12.0;
const BADGE_R: f32 = 9.0;
const BADGE_R: f32 = BADGE as f32 / 2.0;
const BADGE_ONLY: f32 = 22.0;
const LABEL_PT: f32 = 13.0;
const BODY_PT: f32 = 11.0;
const BODY_ROWS: usize = 2;
const ICON_PX: f32 = 14.0;
const RADIUS: f32 = 10.0;
const LINE_W: f32 = 1.5;
@@ -110,34 +118,6 @@ fn bucket_scale(b: u32) -> f32 {
(f64::from(b) * SCALE_STEP) as f32
}
/// Orthogonal leader line from a note card (or badge) rect to `anchor`: straight down/up when the
/// anchor is above or below the card, else out of the facing side with one elbow.
pub fn leader_route(card: Rect, anchor: Pos2) -> Vec<Pos2> {
if anchor.x >= card.left() && anchor.x <= card.right() {
let y = if anchor.y < card.top() { card.top() } else { card.bottom() };
return vec![Pos2::new(anchor.x, y), anchor];
}
let sx = if anchor.x > card.right() { card.right() } else { card.left() };
let s = Pos2::new(sx, card.center().y);
if (s.y - anchor.y).abs() < 0.5 {
return vec![s, anchor];
}
let mx = (s.x + anchor.x) / 2.0;
vec![s, Pos2::new(mx, s.y), Pos2::new(mx, anchor.y), anchor]
}
/// Point on `target`'s edge facing `from`: the side `from` lies beside, level with it (clamped).
fn edge_toward(target: Rect, from: Pos2) -> Pos2 {
if from.x > target.right() {
Pos2::new(target.right(), from.y.clamp(target.top(), target.bottom()))
} else if from.x < target.left() {
Pos2::new(target.left(), from.y.clamp(target.top(), target.bottom()))
} else {
let y = if from.y < target.top() { target.top() } else { target.bottom() };
Pos2::new(from.x, y)
}
}
fn near_line(pts: &[Pos2], p: Pos2) -> bool {
pts.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
@@ -161,6 +141,12 @@ struct Item<'a> {
badge_only: bool,
route: Vec<Pos2>,
target: Option<Rect>,
label: Option<Arc<Galley>>,
body: Option<Arc<Galley>>,
/// Body rows cut off by the cap (shown as "+N more lines").
more: usize,
/// Head row height (screen): the taller of badge and label.
head_h: f32,
}
fn text_hash(a: &Annotation) -> u64 {
@@ -171,7 +157,7 @@ fn text_hash(a: &Annotation) -> u64 {
}
fn galley(
ui: &egui::Ui,
ctx: &egui::Context,
cache: &mut HashMap<GalleyKey, Arc<Galley>>,
key: GalleyKey,
text: &str,
@@ -188,12 +174,22 @@ fn galley(
let mut job = egui::text::LayoutJob::simple(text.to_owned(), font, Color32::WHITE, wrap);
job.wrap.max_rows = rows;
job.wrap.break_anywhere = false;
job.wrap.overflow_character = Some('…');
ui.fonts_mut(|f| f.layout_job(job))
job.wrap.overflow_character = (key.3 == 0).then_some('…');
ctx.fonts_mut(|f| f.layout_job(job))
})
.clone()
}
fn to_pos(canvas: Rect, cam: &crate::geom::Camera, w: V2) -> Pos2 {
let v = cam.world_to_screen(w);
canvas.min + vec2(v.x as f32, v.y as f32)
}
fn world_rect(canvas: Rect, cam: &crate::geom::Camera, r: Rect) -> crate::geom::Rect {
let min = cam.screen_to_world(V2 { x: f64::from(r.min.x - canvas.min.x), y: f64::from(r.min.y - canvas.min.y) });
crate::geom::Rect::new(min.x, min.y, f64::from(r.width()) / cam.zoom, f64::from(r.height()) / cam.zoom)
}
/// Draw the overlay and the bottom pill; apply a finished drag as one undo step.
pub fn draw(
ctx: &egui::Context,
@@ -212,7 +208,13 @@ pub fn draw(
}
pill(ctx, ed, view, canvas, mode);
if let Some((id, off)) = commit {
ed.update_annotation(id, |a| a.position = a.position + off);
let doc = ed.doc.clone();
ed.update_annotation(id, |a| {
a.position = a.position + off;
let (Some(t), Some(at)) = (a.target.and_then(|t| doc.world_rect(t)), Some(card_world(&doc, a) + off)) else { return };
let s = layout::card_size(a);
a.anchor = layout::anchor_toward(t, crate::geom::Rect::new(at.x, at.y, s.x, s.y));
});
}
}
@@ -229,10 +231,7 @@ fn cards(
let s = bucket_scale(bucket);
let cats: Vec<AnnotationCategory> = ed.design.categories();
let nums = view.numbers(ed);
let to_screen = |w: V2| {
let v = cam.world_to_screen(w);
canvas.min + vec2(v.x as f32, v.y as f32)
};
let to_screen = |w: V2| to_pos(canvas, &cam, w);
let drag = view.drag;
let mut items: Vec<Item> = Vec::new();
for a in &ed.doc.annotations {
@@ -244,15 +243,37 @@ fn cards(
(c32(c.color), theme::icon_by_name(&c.icon).unwrap_or(Icon::Sticky))
});
let mut at = card_world(&ed.doc, a);
if let Some((id, off)) = drag.filter(|(id, _)| *id == a.id) {
let _ = id;
if let Some((_, off)) = drag.filter(|(id, _)| *id == a.id) {
at = at + off;
}
let badge_only = mode == Mode::Badges || view.collapsed.contains(&a.id);
// Below SCALE_MIN a full card would outgrow the footprint placement kept free for it.
let badge_only = mode == Mode::Badges || view.collapsed.contains(&a.id) || cam.zoom < SCALE_MIN;
let (mut label, mut body, mut more, mut head_h) = (None, None, 0, BADGE as f32 * s);
let size = if badge_only {
vec2(BADGE_ONLY * s, BADGE_ONLY * s)
} else {
vec2(CARD_W as f32 * s, CARD_H as f32 * s)
let th = text_hash(a);
let expanded = view.expanded.contains(&a.id);
let lw = LABEL_W as f32 * s;
let (lk, lrows) = if expanded { (1, usize::MAX) } else { (0, LABEL_LINES) };
let lg = galley(ctx, &mut view.galleys, (a.id, th, bucket, lk), &a.label, theme::semibold(LABEL_PT * s), lw, lrows);
head_h = head_h.max(lg.size().y);
let mut h = (layout::PAD * 2.0) as f32 * s + head_h;
if !a.body.is_empty() {
let bw = (CARD_W - 2.0 * layout::PAD) as f32 * s;
let font = egui::FontId::proportional(BODY_PT * s);
let full = galley(ctx, &mut view.galleys, (a.id, th, bucket, 2), &a.body, font.clone(), bw, usize::MAX);
let bg = if expanded || full.rows.len() <= BODY_LINES {
full
} else {
more = full.rows.len() - BODY_LINES;
galley(ctx, &mut view.galleys, (a.id, th, bucket, 3), &a.body, font, bw, BODY_LINES)
};
h += HEAD_GAP as f32 * s + bg.size().y + if more > 0 { BODY_LH as f32 * s } else { 0.0 };
body = Some(bg);
}
label = Some(lg);
vec2(CARD_W as f32 * s, h)
};
let rect = Rect::from_min_size(to_screen(at), size);
let lineless = a.detached || (a.target.is_none() && a.point.is_none());
@@ -263,14 +284,7 @@ fn cards(
vec2(r.w as f32, r.h as f32),
)
});
let mut anchor = to_screen(anchor_world(&ed.doc, a));
if a.anchor.side == Side::Center
&& let Some(t) = target
{
anchor = edge_toward(t, rect.center());
}
let route = if lineless { Vec::new() } else { leader_route(rect, anchor) };
let bounds = route.iter().fold(rect, |r, p| r.union(Rect::from_min_max(*p, *p)));
let bounds = target.map_or(rect, |t| rect.union(t));
if !canvas.intersects(bounds) {
continue;
}
@@ -281,10 +295,15 @@ fn cards(
icon,
rect,
badge_only,
route,
route: if lineless { Vec::new() } else { vec![Pos2::ZERO] },
target,
label,
body,
more,
head_h,
});
}
route_items(ed, view, &mut items, canvas);
let mut commit = None;
let pointer = ctx.pointer_hover_pos();
@@ -325,7 +344,7 @@ fn cards(
if resp.hovered() || on_line {
ctx.set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::PointingHand });
}
if !it.badge_only {
if let Some(lg) = &it.label {
painter.rect_filled(it.rect, RADIUS * s, p.popover);
let stroke = if is_sel {
Stroke::new(LINE_W, it.color)
@@ -333,24 +352,36 @@ fn cards(
Stroke::new(1.0, p.separator)
};
painter.rect_stroke(it.rect, RADIUS * s, stroke, egui::StrokeKind::Inside);
let th = text_hash(a);
let text_x = badge_rect.right() + 8.0 * s;
let icon_rect = Rect::from_center_size(
Pos2::new(it.rect.right() - (PAD + ICON_PX / 2.0) * s, badge_rect.center().y),
vec2(ICON_PX, ICON_PX) * s,
);
let label_w = (icon_rect.left() - 6.0 * s - text_x).max(10.0);
let lg = galley(ui, &mut view.galleys, (a.id, th, bucket, true), &a.label,
theme::semibold(LABEL_PT * s), label_w, 1);
let head_top = it.rect.top() + PAD * s;
painter.galley_with_override_text_color(
Pos2::new(text_x, badge_rect.center().y - lg.size().y / 2.0), lg, p.text);
Pos2::new(text_x, head_top + (it.head_h - lg.size().y) / 2.0), lg.clone(), p.text);
theme::paint_icon(&painter, icon_rect, it.icon, it.color);
if !a.body.is_empty() {
let bw = it.rect.width() - PAD * 2.0 * s;
let bg = galley(ui, &mut view.galleys, (a.id, th, bucket, false), &a.body,
egui::FontId::proportional(BODY_PT * s), bw, BODY_ROWS);
let body_top = head_top + it.head_h + HEAD_GAP as f32 * s;
if let Some(bg) = &it.body {
painter.galley_with_override_text_color(
Pos2::new(it.rect.left() + PAD * s, badge_rect.bottom() + 6.0 * s), bg, p.secondary);
Pos2::new(it.rect.left() + PAD * s, body_top), bg.clone(), p.secondary);
if it.more > 0 {
let more_rect = Rect::from_min_size(
Pos2::new(it.rect.left() + PAD * s, body_top + bg.size().y),
vec2(it.rect.width() - PAD * 2.0 * s, BODY_LH as f32 * s),
);
let r = ui.interact(more_rect, egui::Id::new(("annotation-more", a.id)), Sense::click());
let text = format!("+{} more lines", it.more);
painter.text(more_rect.left_center(), Align2::LEFT_CENTER, text,
theme::semibold(BODY_PT * s), if r.hovered() { p.text } else { it.color });
if r.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked() {
*selected = Some(a.id);
view.expanded.insert(a.id);
}
}
}
}
let detached = a.detached || (a.target.is_none() && a.point.is_none());
@@ -388,6 +419,66 @@ fn cards(
commit
}
/// Fill each item's screen-space leader route. Routes go around the page's nodes and the other
/// cards, are computed in world space from each card's layout size (so they do not change with
/// zoom) and cached until the document or a nearby card changes. The drawn card or badge joins
/// the route with an elbow from its centre, hidden under a full card.
fn route_items(ed: &Editor, view: &mut AnnotationsView, items: &mut [Item], canvas: Rect) {
let cam = ed.camera;
let cards: Vec<(u64, crate::geom::Rect)> = items
.iter()
.map(|it| {
let at = world_rect(canvas, &cam, it.rect).min();
let s = layout::card_size(it.a);
(it.a.id, crate::geom::Rect::new(at.x, at.y, s.x, s.y))
})
.collect();
view.routes.retain(|id, _| items.iter().any(|it| it.a.id == *id));
for (i, it) in items.iter_mut().enumerate() {
if it.route.is_empty() {
continue;
}
let a = it.a;
let card = cards[i].1;
let Some(trect) = a.target.and_then(|t| ed.doc.world_rect(t)) else {
let end = anchor_world(&ed.doc, a);
it.route = layout::elbow(card, end).into_iter().map(|w| to_pos(canvas, &cam, w)).collect();
continue;
};
let end = if a.anchor.side == Side::Center {
layout::anchor_point(trect, layout::anchor_toward(trect, card))
} else {
anchor_world(&ed.doc, a)
};
let win = card.union(&trect).inflate(300.0);
let mut h = std::collections::hash_map::DefaultHasher::new();
(card.x.to_bits(), card.y.to_bits(), card.w.to_bits(), card.h.to_bits(), end.x.to_bits(), end.y.to_bits()).hash(&mut h);
let others: Vec<crate::geom::Rect> = cards
.iter()
.enumerate()
.filter(|(j, (_, r))| *j != i && r.intersects(&win))
.map(|(_, (_, r))| *r)
.collect();
for r in &others {
(r.x.to_bits(), r.y.to_bits(), r.w.to_bits(), r.h.to_bits()).hash(&mut h);
}
let key = (ed.doc_stamp, h.finish());
let world = match view.routes.get(&a.id) {
Some((k, r)) if *k == key => r.clone(),
_ => {
let mut obs = layout::route_obstacles(&ed.doc, a.target.unwrap());
obs.extend(others);
let r = layout::route(card, trect, end, &obs);
view.routes.insert(a.id, (key, r.clone()));
r
}
};
let route: Vec<Pos2> = world.into_iter().map(|w| to_pos(canvas, &cam, w)).collect();
let c = it.rect.center();
it.route = [c, Pos2::new(route[0].x, c.y)].into_iter().chain(route).collect();
}
}
fn pill(ctx: &egui::Context, ed: &Editor, view: &mut AnnotationsView, canvas: Rect, mode: Mode) {
let chord = shortcuts::chord_text(shortcuts::Action::ToggleAnnotations);
let text = if mode == Mode::Hidden {
@@ -443,26 +534,6 @@ mod tests {
assert_eq!(v.effective(Display::Badges), Mode::Hidden);
}
#[test]
fn route_shapes() {
let card = Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 40.0));
// Anchor right of the card, different y: H-V-H elbow from the right edge.
let r = leader_route(card, Pos2::new(300.0, 100.0));
assert_eq!(r.len(), 4);
assert_eq!(r[0], Pos2::new(100.0, 20.0));
assert_eq!(r[3], Pos2::new(300.0, 100.0));
assert_eq!(r[1].y, r[0].y);
assert_eq!(r[2].x, r[1].x);
assert_eq!(r[2].y, 100.0);
// Level anchor: straight.
assert_eq!(leader_route(card, Pos2::new(300.0, 20.0)).len(), 2);
// Left of the card: leaves the left edge.
assert_eq!(leader_route(card, Pos2::new(-50.0, 90.0))[0].x, 0.0);
// Below the card: vertical from the bottom.
let r = leader_route(card, Pos2::new(40.0, 200.0));
assert_eq!(r, vec![Pos2::new(40.0, 40.0), Pos2::new(40.0, 200.0)]);
}
#[test]
fn scale_is_bucketed_and_clamped() {
assert_eq!(scale_bucket(0.01), scale_bucket(0.6));
@@ -470,14 +541,6 @@ mod tests {
assert_eq!(scale_bucket(1.0), 10);
}
#[test]
fn edge_anchor_faces_card() {
let t = Rect::from_min_size(Pos2::new(100.0, 100.0), vec2(200.0, 50.0));
assert_eq!(edge_toward(t, Pos2::new(400.0, 500.0)), Pos2::new(300.0, 150.0));
assert_eq!(edge_toward(t, Pos2::new(0.0, 120.0)), Pos2::new(100.0, 120.0));
assert_eq!(edge_toward(t, Pos2::new(150.0, 0.0)), Pos2::new(150.0, 100.0));
}
#[test]
fn line_hit_test() {
let pts = [Pos2::new(0.0, 0.0), Pos2::new(100.0, 0.0)];
+178 -37
View File
@@ -8,6 +8,9 @@
use super::chat_msgs::{self, Acts, View};
#[path = "chat_agents.rs"]
pub mod chat_agents;
#[path = "chat_pick.rs"]
pub mod chat_pick;
pub use chat_pick::{layer_chip, open_ask};
use chat_agents::{Act, PanelIn};
use crate::chat::events::{Event, ImageAttachment};
use crate::chat::agents::{self, AgentKind, AgentState, AgentStatus};
@@ -39,6 +42,8 @@ pub use crate::chat::TMP_DIR;
/// (events reach the UI a frame after the tool ran).
const RUN_SLACK_MS: u64 = 5_000;
const REPAINT: Duration = Duration::from_millis(50);
/// How often the column checks the design system for changes (the summary's key).
const DS_CHECK: Duration = Duration::from_secs(1);
#[cfg(not(target_arch = "wasm32"))]
const TITLE_CHARS: usize = 40;
const MAX_IMAGES: usize = 4;
@@ -197,10 +202,16 @@ fn scan(prefs: &super::settings::ChatPrefs) -> Detect {
}
/// The current selection as a composer chip.
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct SelChip {
pub label: String,
pub keys: Vec<String>,
/// An area chip (Pick, Shift+drag): its world rect `[x, y, w, h]`; `keys` are the layers inside.
pub rect: Option<[f64; 4]>,
/// Name of the page an area chip is on.
pub page: String,
/// PNG crop of the layers (or of the area), sent with the message like an attached image.
pub image: Option<Vec<u8>>,
}
/// An image waiting in the composer.
@@ -247,6 +258,8 @@ pub struct ChatState {
turn_chat: Option<String>,
/// Stop was pressed; the turn stays occupied until the backend's last event.
pub stopping: bool,
/// The last turn ended because the user stopped it: the list says "Stopped", not an error.
pub stopped: bool,
window: Option<TurnWindow>,
/// Runs the tracker finished while a window was open, waiting for `settle_claims`.
unclaimed: Vec<crate::runs::Run>,
@@ -291,6 +304,8 @@ pub struct ChatState {
/// Annotation lines of the chips' layers, refreshed from the editor just before a send.
pub sel_notes: Vec<String>,
pub images: Vec<Attachment>,
/// An Actions-menu prompt is in the composer: the column sends it once the agents are known.
pub send_draft: bool,
/// Text of the title field while renaming.
pub renaming: Option<String>,
/// Chat waiting for the delete confirm.
@@ -312,6 +327,17 @@ pub struct ChatState {
#[cfg(not(target_arch = "wasm32"))]
pending_images: Vec<ImageAttachment>,
width_dirty: bool,
/// The design-system summary, rebuilt when the design system changes (`refresh_ds`).
pub ds: crate::chat::ds_digest::Cached,
ds_checked: Option<web_time::Instant>,
/// CLI/ACP chats: the summary key their agent session last got, so a change is sent once.
ds_sent: HashMap<String, u64>,
/// Offscreen renderer for chip crops; set by the app each frame (None in tests and the web).
pub gpu: Option<crate::render::gpu::Headless>,
/// Pick mode: the next canvas click (or Shift+drag) becomes a chip.
pub picking: bool,
/// The on-canvas Ask box (⌘K on a selection).
pub ask: Option<chat_pick::Ask>,
}
#[derive(Default, Serialize, Deserialize)]
@@ -542,7 +568,10 @@ impl ChatState {
}
Event::Error(e) => {
self.thought_secs = self.thinking_since.take().map(|t| t.elapsed().as_secs());
self.error = Some(e);
// A backend killed by Stop reports how it died; that is the stop, not a failure.
if !self.stopping {
self.error = Some(e);
}
return true;
}
}
@@ -590,7 +619,7 @@ impl ChatState {
fn finish_turn(&mut self) {
self.turn = None;
self.rx = None;
self.stopping = false;
self.stopped = std::mem::take(&mut self.stopping);
self.approvals.clear();
self.save();
// The agent run does not wait for its 30 s idle timeout once the turn that made it is over.
@@ -608,6 +637,26 @@ impl ChatState {
)
}
/// The summary to send with turns: on in Settings and not empty.
fn ds_text(&self) -> &str {
if self.prefs.ds_summary { &self.ds.text } else { "" }
}
/// Rebuilds the design-system summary when the project's design system changed. `force`
/// skips the once-a-second throttle (a send).
pub fn refresh_ds(&mut self, ed: &Editor, force: bool) {
if !force && self.ds_checked.is_some_and(|t| t.elapsed() < DS_CHECK) {
return;
}
self.ds_checked = Some(web_time::Instant::now());
let dir = self.project.clone();
self.ds.refresh(ed, || {
dir.as_deref()
.and_then(|d| Some(crate::libraries::status(&crate::project::Store::open(d.parent()?).ok()?, d, ed)))
.unwrap_or_default()
});
}
/// Adds `prompt` as a user message and starts a turn on the chat's backend. Errors (no CLI,
/// no key) come back as text and are also kept in `error` for the error row.
#[cfg(not(target_arch = "wasm32"))]
@@ -623,7 +672,8 @@ impl ChatState {
return Err("A turn is already running".into());
}
self.ensure_chat();
let system = self.system_prompt();
let mut system = self.system_prompt();
let digest = self.ds_text().to_owned();
let (Some(dir), Some(chat)) = (self.project.clone(), self.chat.as_mut()) else {
return Err("Open a project to chat".into());
};
@@ -634,6 +684,14 @@ impl ChatState {
}
let turn_chat = chat.id.clone();
let Backend { kind, model, effort, endpoint, cli_session, agent } = chat.backend.clone();
// API: the summary is its own system block, current every request. CLI/ACP read the system
// prompt when a session starts; a resumed one hears of a change in front of the prompt.
let api = kind == BackendKind::Anthropic;
let ds_stale = !api && !digest.is_empty() && cli_session.is_some() && self.ds_sent.get(&turn_chat) != Some(&self.ds.key);
let sent = if ds_stale { format!("[Design system changed] {digest}\n\n{prompt}") } else { prompt.to_owned() };
if !api && !digest.is_empty() {
system = format!("{system}\n\n{digest}");
}
let ep = self.prefs.endpoint(endpoint.as_deref()).clone();
// Another host's model is whatever the endpoint says now; the Claude host keeps the chat's pick.
let model = if ep.is_claude_host() { model.or_else(|| Some(ep.model.clone())) } else { Some(ep.model.clone()) };
@@ -642,28 +700,30 @@ impl ChatState {
endpoint: ep.clone(),
api_history: Some(dir.join("chats").join(format!("{}.api.json", chat.id))),
project_dir: dir,
mcp_url: self.mcp_url.clone(),
// Calls from a CLI backend name the chat, so they land in this chat's run.
mcp_url: format!("{}?chat={turn_chat}", self.mcp_url),
model,
effort,
session: cli_session,
tools: self.tools.clone(),
tools: self.tools.clone().map(|d| d.for_chat(&turn_chat)),
images: std::mem::take(&mut self.pending_images),
system,
ds_digest: if api { digest.clone() } else { String::new() },
binary: cli_binary(&self.prefs, kind),
..Default::default()
};
let (tx, rx) = std::sync::mpsc::channel();
let started = match kind {
BackendKind::ClaudeCode => claude_code::start(&cfg, prompt, tx).map(TurnHandle::Cli),
BackendKind::Codex => codex::start(&cfg, prompt, tx).map(TurnHandle::Cli),
BackendKind::ClaudeCode => claude_code::start(&cfg, &sent, tx).map(TurnHandle::Cli),
BackendKind::Codex => codex::start(&cfg, &sent, tx).map(TurnHandle::Cli),
BackendKind::Anthropic => match ep.format {
ApiFormat::Anthropic => anthropic::start(&cfg, prompt, tx),
ApiFormat::OpenAi => openai::start(&cfg, prompt, tx),
ApiFormat::Anthropic => anthropic::start(&cfg, &sent, tx),
ApiFormat::OpenAi => openai::start(&cfg, &sent, tx),
}
.map(TurnHandle::Api),
BackendKind::Acp => start_acp(&cfg, &self.prefs, agent.as_deref(), prompt, tx),
BackendKind::Acp => start_acp(&cfg, &self.prefs, agent.as_deref(), &sent, tx),
};
(self.error, self.start_failed, self.failed_prompt) = (None, false, None);
(self.error, self.start_failed, self.failed_prompt, self.stopped) = (None, false, None, false);
match started {
Ok(t) => {
self.thinking.clear();
@@ -672,6 +732,9 @@ impl ChatState {
self.stopping = false;
self.live_context = None;
self.window = Some(TurnWindow { chat: turn_chat.clone(), start: now_ms(), edits: None });
if !digest.is_empty() {
self.ds_sent.insert(turn_chat.clone(), self.ds.key);
}
self.turn_chat = Some(turn_chat);
(self.turn, self.rx) = (Some(t), Some(rx));
ctx.request_repaint();
@@ -878,17 +941,45 @@ impl ChatState {
if text.is_empty() {
return Ok(());
}
let (sels, notes, images) = (self.sels.clone(), self.sel_notes.clone(), self.images.clone());
let r = self.send_with(ctx, &sels, &notes, &images, &text);
if r.is_ok() {
(self.sels, self.images) = (Vec::new(), Vec::new());
self.draft.clear();
}
r
}
/// Sends `text` with the chips (context lines and crops) and the images. The composer and the
/// Ask box both send through here, so they give the same message.
#[cfg(not(target_arch = "wasm32"))]
pub fn send_with(&mut self, ctx: &egui::Context, chips: &[SelChip], notes: &[String], images: &[Attachment], text: &str) -> Result<(), String> {
let prompt = self.compose(chips, notes, images, text)?;
let r = self.send(ctx, &prompt);
self.pending_images.clear();
r
}
/// The message: the chips' context lines, the images, then the text. API images (crops too)
/// go to `pending_images` as blocks; CLI images are saved under the project and named by path.
#[cfg(not(target_arch = "wasm32"))]
fn compose(&mut self, chips: &[SelChip], notes: &[String], images: &[Attachment], text: &str) -> Result<String, String> {
self.ensure_chat();
let (Some(dir), Some(chat)) = (self.project.clone(), self.chat.as_ref()) else {
return Err("Open a project to chat".into());
};
let (api, chat_id) = (chat.backend.kind == BackendKind::Anthropic, chat.id.clone());
let mut lines = selection_lines(&self.sels, &self.sel_notes);
let mut lines = selection_lines(chips, notes);
let mut pending = Vec::new();
for a in &self.images {
let crops = chips.iter().filter_map(|c| {
let data = c.image.clone()?;
let what = if c.rect.is_some() { "area".to_owned() } else { c.keys.first().map_or_else(String::new, |k| k.replace(':', "-")) };
Some(Attachment { name: format!("crop-{what}.png"), img: ImageAttachment { mime: "image/png".into(), data } })
});
for a in images.iter().cloned().chain(crops) {
if api {
lines.push(format!("[Attached] {}", a.name));
pending.push(a.img.clone());
pending.push(a.img);
continue;
}
let folder = store::tmp_of(&dir, &chat_id).ok_or("invalid chat id")?;
@@ -896,15 +987,13 @@ impl ChatState {
crate::vfs::create_dir_all(&folder).and_then(|()| crate::vfs::write(&path, &a.img.data)).map_err(|e| format!("could not save the image: {e}"))?;
lines.push(format!("[Attached] {}", path.display()));
}
let prompt = if lines.is_empty() { text } else { format!("{}\n\n{text}", lines.join("\n")) };
self.pending_images = pending;
let r = self.send(ctx, &prompt);
self.pending_images.clear();
if r.is_ok() {
(self.sels, self.images) = (Vec::new(), Vec::new());
self.draft.clear();
}
r
Ok(if lines.is_empty() { text.to_owned() } else { format!("{}\n\n{text}", lines.join("\n")) })
}
#[cfg(target_arch = "wasm32")]
pub fn send_with(&mut self, _ctx: &egui::Context, _chips: &[SelChip], _notes: &[String], _images: &[Attachment], _text: &str) -> Result<(), String> {
Err(WEB_NOTE.into())
}
#[cfg(target_arch = "wasm32")]
@@ -927,9 +1016,12 @@ impl ChatState {
/// Context in use at the latest model call and the window it counts against (never above it).
/// None when the backend reports no per-call usage (Codex): the meter is hidden.
/// Before the first turn it is the design-system summary that the first turn will carry.
pub fn context_use(&self) -> Option<(u64, u64)> {
let saved = self.chat.as_ref().and_then(|c| c.messages.iter().rev().find_map(|m| m.context));
let used = self.live_context.filter(|_| self.turn.is_some()).or(saved)?;
let fresh = self.chat.as_ref().is_some_and(|c| c.messages.is_empty()) && !self.ds_text().is_empty();
let first = fresh.then(|| self.ds.tokens());
let used = self.live_context.filter(|_| self.turn.is_some()).or(saved).or(first)?;
Some((used.min(CONTEXT_WINDOW), CONTEXT_WINDOW))
}
}
@@ -997,9 +1089,9 @@ fn chip_label(first: &str, count: usize) -> String {
if count > 1 { format!("{first} \u{b7} {count} layers") } else { first.to_owned() }
}
/// Adds a chip unless one with the same layers is already there.
/// Adds a chip unless one with the same layers (and area) is already there.
fn push_chip(chips: &mut Vec<SelChip>, chip: SelChip) {
if !chips.iter().any(|c| c.keys == chip.keys) {
if !chips.iter().any(|c| c.keys == chip.keys && c.rect == chip.rect) {
chips.push(chip);
}
}
@@ -1007,7 +1099,15 @@ fn push_chip(chips: &mut Vec<SelChip>, chip: SelChip) {
/// The `[Selection]` context lines of the chips, one each, with node ids.
#[cfg(not(target_arch = "wasm32"))]
fn selection_lines(chips: &[SelChip], notes: &[String]) -> Vec<String> {
chips.iter().map(|s| format!("[Selection] {} ({})", s.label, s.keys.join(", "))).chain(notes.iter().map(|n| format!("[Selection] {n}"))).collect()
chips.iter().map(chip_line).chain(notes.iter().map(|n| format!("[Selection] {n}"))).collect()
}
#[cfg(not(target_arch = "wasm32"))]
/// `[Selection] Card (a:1)`, or for an area `[Area] page P, rect x,y,w,h, contains: a:1, a:2`.
fn chip_line(c: &SelChip) -> String {
let Some([x, y, w, h]) = c.rect else { return format!("[Selection] {} ({})", c.label, c.keys.join(", ")) };
let ids = if c.keys.is_empty() { "nothing".to_owned() } else { c.keys.join(", ") };
format!("[Area] page {}, rect {x:.0},{y:.0},{w:.0},{h:.0}, contains: {ids}", c.page)
}
/// One line per annotation on the chips' layers (and their children): number, type key, label,
@@ -1040,16 +1140,25 @@ pub fn selection_notes(ed: &Editor, chips: &[SelChip]) -> Vec<String> {
}
/// The current selection becomes a chip (Cmd+L or the @ button); each click adds one.
fn add_selection(ed: &mut Editor, st: &mut ChatState) {
pub(super) fn add_selection(ed: &mut Editor, st: &mut ChatState) {
let ids = ed.top_selection();
let Some(first) = ids.first() else {
if ids.is_empty() {
ed.status = "Select a layer to add it to the chat".into();
return;
};
let page = ed.active_page;
let keys = ids.iter().map(|id| crate::mcp::exec::node_key(ed, page, *id)).collect();
let name = ed.doc.get(*first).map(|n| n.name.clone()).unwrap_or_default();
push_chip(&mut st.sels, SelChip { label: chip_label(&name, ids.len()), keys });
}
push_chip(&mut st.sels, layer_chip(ed, st.gpu.as_ref(), ed.active_page, &ids));
}
/// Sends the composer an Actions-menu intent filled in, once detection is done (so the chat has
/// its backend); a failed start shows the usual error row.
pub(super) fn send_draft(ctx: &egui::Context, ed: &Editor, c: &mut ChatState) {
if !c.send_draft || c.detect.is_none() || c.is_running() {
return;
}
c.send_draft = false;
c.sel_notes = selection_notes(ed, &c.sels);
c.refresh_ds(ed, true);
let _ = c.submit(ctx);
}
/// Pans the canvas to a node the agent touched, unless the user moved the camera lately.
@@ -1168,7 +1277,9 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
}
st.chat.poll_detect(&ctx, &st.settings.chat);
st.chat.ensure_defaults(&st.settings.chat);
send_draft(&ctx, ed, &mut st.chat);
follow(ed, &mut st.chat);
st.chat.refresh_ds(ed, false);
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::L)) {
add_selection(ed, &mut st.chat);
}
@@ -1209,7 +1320,9 @@ fn column(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
runs: &st.runs,
approvals: &st.chat.approvals,
error: st.chat.error.as_deref(),
stopped: st.chat.stopped,
signin: signin.as_ref(),
live_run: st.run_open.iter().find(|r| r.client.split(" \u{b7} ").next() == Some(crate::mcp::CHAT_CLIENT)),
};
chat_msgs::list(ui, chat, &view, &mut acts);
}
@@ -1262,11 +1375,13 @@ fn apply_acts(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, acts: Acts
}
if acts.retry_last && st.chat.start_failed {
st.chat.sel_notes = selection_notes(ed, &st.chat.sels);
st.chat.refresh_ds(ed, true);
st.chat.retry_start(ctx);
return;
}
let again = acts.retry_last.then(|| st.chat.last_prompt()).flatten();
if let Some(text) = acts.send.or(again) {
st.chat.refresh_ds(ed, true);
let _ = st.chat.send(ctx, &text);
}
}
@@ -1308,6 +1423,7 @@ fn header(ui: &mut Ui, st: &mut UiState) -> Option<egui::Rect> {
_ => String::new(),
};
let context = st.chat.context_use();
let ds_tokens = if st.chat.ds_text().is_empty() { 0 } else { st.chat.ds.tokens() };
let mut rect = None;
ui.horizontal(|ui| {
let r = kit::chip_ink(ui, &label, st.chat.picker_open.then(theme::accent));
@@ -1318,7 +1434,11 @@ fn header(ui: &mut Ui, st: &mut UiState) -> Option<egui::Rect> {
}
let Some((used, window)) = context else { return };
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(RichText::new(format!("{} / {}", fmt_tokens(used), fmt_tokens(window))).size(theme::SECONDARY).color(p.secondary));
let tip = format!("Includes the design-system summary, about {} tokens", fmt_tokens(ds_tokens));
let r = ui.label(RichText::new(format!("{} / {}", fmt_tokens(used), fmt_tokens(window))).size(theme::SECONDARY).color(p.secondary));
if ds_tokens > 0 {
r.on_hover_text(tip);
}
let (bar, _) = ui.allocate_exact_size(egui::vec2(36.0, 3.0), egui::Sense::hover());
ui.painter().rect_filled(bar, 2.0, p.separator);
let frac = (used as f32 / window as f32).clamp(0.0, 1.0);
@@ -1494,6 +1614,11 @@ fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect,
if kit::chip(ui, "@ Selection", false).on_hover_text("Add the selection (Cmd+L)").clicked() {
add_selection(ed, &mut st.chat);
}
let tip = "Point at the canvas: click a layer or Shift+drag an area (Esc cancels). Option+click adds a layer too.";
if kit::chip(ui, "Pick", st.chat.picking).on_hover_text(tip).clicked() {
st.chat.picking = !st.chat.picking;
}
super::actions::chip(ui, ed, st);
#[cfg(not(target_arch = "wasm32"))]
if theme::icon_button(ui, Icon::Image, 24.0, false).on_hover_text("Attach an image (or paste, or drop one)").clicked()
&& let Some(path) = rfd::FileDialog::new().add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"]).pick_file()
@@ -1522,6 +1647,7 @@ fn composer(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, column: egui::Rect,
if (submit || enter) && !running && backend_ok && !no_project {
let ctx = ui.ctx().clone();
st.chat.sel_notes = selection_notes(ed, &st.chat.sels);
st.chat.refresh_ds(ed, true);
let _ = st.chat.submit(&ctx);
}
}
@@ -1624,6 +1750,21 @@ mod tests {
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_stopped_backend_that_dies_shows_stopped_not_an_error() {
use std::sync::atomic::Ordering;
let (mut st, tx, running, dir) = running_turn();
let ctx = egui::Context::default();
st.stop();
tx.send(Event::Error("Codex exited with signal: 15".into())).unwrap();
running.store(false, Ordering::SeqCst);
st.pump(&ctx);
assert!(!st.is_running());
assert_eq!(st.error, None);
assert!(st.stopped);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn a_running_chat_cannot_be_deleted_and_events_stay_with_it() {
use std::sync::atomic::Ordering;
@@ -1806,7 +1947,7 @@ mod tests {
}
fn chip(label: &str, keys: &[&str]) -> SelChip {
SelChip { label: label.into(), keys: keys.iter().map(|k| (*k).into()).collect() }
SelChip { label: label.into(), keys: keys.iter().map(|k| (*k).into()).collect(), ..Default::default() }
}
#[test]
@@ -1836,7 +1977,7 @@ mod tests {
a.id = 1;
a.body = "first line\nsecond".into();
ed.doc.annotations.push(a);
let chips = [SelChip { label: "x".into(), keys: vec![crate::mcp::exec::node_key(&ed, page, id)] }];
let chips = [SelChip { label: "x".into(), keys: vec![crate::mcp::exec::node_key(&ed, page, id)], ..Default::default() }];
let notes = selection_notes(&ed, &chips);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("[rule]") && notes[0].contains("Use 8pt grid \u{2014} first line") && !notes[0].contains("second"), "{notes:?}");
+23 -4
View File
@@ -26,6 +26,13 @@ pub const SUGGESTIONS: [(Icon, &str); 3] = [
(Icon::Text, "Lint this page and fix warnings"),
(Icon::Eye, "Make a dark mode"),
];
/// The Actions menu's intent lane: label and the prompt sent with a chip of the selection.
pub const ACTION_PROMPTS: [(&str, &str); 4] = [
("Optimize", "Tighten layout and spacing to the token scale, fix contrast to AA, keep the content. Prefer existing components."),
("Make consistent with DS", "Replace hand-made parts with instances of the listed components, bind every color and space to variables."),
("Write states", "Add the empty, loading and error states of the selection as frames next to it. Reuse its components and variables."),
("Variations \u{d7}3", "Make 3 variations of the selection side by side: compact, spacious and bold. Duplicate it to the right (offset by its width + 80), name them `<Frame> \u{b7} Variant A/B/C`, keep the content."),
];
const AFTER_RUN: [(&str, &str); 3] = [
("Lint this", "Lint this page and fix warnings"),
("Make a dark mode", "Make a dark mode"),
@@ -53,7 +60,11 @@ pub struct View<'a> {
pub runs: &'a [Run],
pub approvals: &'a [Approval],
pub error: Option<&'a str>,
/// The last turn was stopped by the user.
pub stopped: bool,
pub signin: Option<&'a super::chat::chat_agents::SignIn>,
/// The chat's run while it is being recorded, for the live count on the step group.
pub live_run: Option<&'a Run>,
}
// ---------------------------------------------------------------------------------- pure helpers
@@ -524,13 +535,18 @@ fn step_row(ui: &mut Ui, t: &ToolCall, state: ToolState) {
});
}
fn tool_group(ui: &mut Ui, calls: &[&ToolCall], running: bool, approvals: &[Approval], acts: &mut Acts) {
fn tool_group(ui: &mut Ui, calls: &[&ToolCall], v: &View, acts: &mut Acts) {
let (running, approvals) = (v.running, v.approvals);
let p = theme::pal(ui);
let states: Vec<ToolState> = calls.iter().map(|t| tool_state(t, running, approvals)).collect();
let live = states.iter().any(|s| matches!(s, ToolState::Running | ToolState::Pending));
let id = Id::new(("chat-group", &calls[0].id));
let open = get_open(ui, id, live);
let title = group_title(calls, live);
let mut title = group_title(calls, live);
if let Some(r) = v.live_run.filter(|r| live && r.touched() > 0) {
let n = r.touched();
title = format!("{title} \u{b7} {} change{}", super::grouped(n), if n == 1 { "" } else { "s" });
}
if header(
ui,
id.with("hdr"),
@@ -641,7 +657,8 @@ fn run_card(ui: &mut Ui, run: &Run, acts: &mut Acts) {
ui.spacing_mut().item_spacing.y = 1.0;
ui.label(RichText::new(format!("Agent finished, {}", super::panels::count(changes, "change"))).font(theme::semibold(theme::BODY)));
let secs = run.ended.unwrap_or(run.started).saturating_sub(run.started) / 1000;
muted(ui, format!("Snapshot saved before run {} \u{b7} {secs} s", run.number));
let by = if run.client.is_empty() { String::new() } else { format!("{} \u{b7} ", run.client) };
muted(ui, format!("{by}Snapshot saved before run {} \u{b7} {secs} s", run.number));
});
});
ui.add_space(4.0);
@@ -775,7 +792,7 @@ fn assistant_segment(ui: &mut Ui, md_id: Id, m: &Message, v: &View, acts: &mut A
tool_card(ui, t, tool_state(t, v.running, v.approvals), acts);
}
}
calls => tool_group(ui, calls, v.running, v.approvals, acts),
calls => tool_group(ui, calls, v, acts),
}
}
}
@@ -834,6 +851,8 @@ pub fn list(ui: &mut Ui, chat: &Chat, v: &View, acts: &mut Acts) {
}
if let Some(e) = v.error {
error_row(ui, e, v.signin, acts);
} else if v.stopped && !v.running {
muted(ui, "Stopped");
}
ui.add_space(4.0);
if jump {
+361
View File
@@ -0,0 +1,361 @@
//! Pointing at the canvas from chat: Pick mode (click a layer, Shift+drag an area), Option+click
//! in Chat mode, the crops chips carry, and the on-canvas Ask box (⌘K on a selection).
use super::{SelChip, chip_label, push_chip};
use crate::editor::{Editor, InputEvent};
use crate::geom::{Rect, v2};
use crate::model::{Color, Document, NodeId};
use crate::render::gpu::Headless;
use crate::theme;
use crate::ui::{UiState, kit};
use egui::RichText;
/// Longest edge of a chip crop, in pixels (keeps the image cheap to send).
const CROP_MAX_EDGE: f64 = 1024.0;
/// Small layers are cropped at up to this many pixels per unit, so text stays legible.
const CROP_MAX_SCALE: f64 = 2.0;
/// Most layer ids an area chip lists.
const AREA_MAX_IDS: usize = 40;
const ASK_W: f32 = 320.0;
const INPUT_ID: &str = "chat-input";
/// The Ask box: one line of text sent to chat with the selection as a chip.
#[derive(Debug, Clone, Default)]
pub struct Ask {
pub text: String,
focus: bool,
}
/// PNG of `roots` on `page` with their notes drawn (as `get_screenshot {annotations: true}`),
/// limited to `clip` when given.
pub fn crop(gpu: &Headless, ed: &Editor, page: usize, roots: &[NodeId], clip: Option<Rect>) -> Option<Vec<u8>> {
let (doc, roots) = crate::mcp::exec::with_notes(ed.page_doc(page), roots, &ed.design.categories());
let b = crate::io::svg::export_bounds(&doc, &roots)?;
let b = match clip {
Some(c) => b.intersection(&c)?,
None => b,
};
let scale = (CROP_MAX_EDGE / b.w.max(b.h).max(1.0)).min(CROP_MAX_SCALE);
let img = crate::io::png::render_clip(gpu, &doc, Some(&roots), scale, Some(Color::WHITE), clip).ok()?;
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).ok()?;
Some(out.into_inner())
}
/// A chip of layers on `page`, with their crop when a GPU is at hand.
pub fn layer_chip(ed: &Editor, gpu: Option<&Headless>, page: usize, ids: &[NodeId]) -> SelChip {
let name = ids.first().and_then(|id| ed.page_doc(page).get(*id)).map(|n| n.name.clone()).unwrap_or_default();
SelChip {
label: chip_label(&name, ids.len()),
keys: ids.iter().map(|id| crate::mcp::exec::node_key(ed, page, *id)).collect(),
image: gpu.and_then(|g| crop(g, ed, page, ids, None)),
..Default::default()
}
}
/// A chip of the world rect `r` on `page`: the layers inside it, and a crop of the area.
pub fn area_chip(ed: &Editor, gpu: Option<&Headless>, page: usize, r: Rect) -> SelChip {
let doc = ed.page_doc(page);
let roots: Vec<NodeId> = doc.root.iter().copied().filter(|id| doc.world_rect(*id).is_some_and(|w| w.intersects(&r))).collect();
SelChip {
label: format!("Area \u{b7} {:.0}\u{d7}{:.0}", r.w, r.h),
keys: inside(doc, r).into_iter().map(|id| crate::mcp::exec::node_key(ed, page, id)).collect(),
rect: Some([r.x, r.y, r.w, r.h]),
page: ed.pages[page].name.clone(),
image: gpu.filter(|_| !roots.is_empty()).and_then(|g| crop(g, ed, page, &roots, Some(r))),
}
}
/// The topmost visible layers wholly inside `r`; a layer that only overlaps it is looked into.
fn inside(doc: &Document, r: Rect) -> Vec<NodeId> {
let mut out = Vec::new();
let mut todo: Vec<NodeId> = doc.root.iter().rev().copied().collect();
while let Some(id) = todo.pop() {
if out.len() >= AREA_MAX_IDS {
break;
}
let Some(w) = doc.world_rect(id).filter(|w| w.intersects(&r) && doc.is_effectively_visible(id)) else { continue };
if r.contains_rect(&w) {
out.push(id);
} else if let Some(n) = doc.get(id) {
todo.extend(n.children.iter().rev());
}
}
out
}
/// The layer a click at `world` picks, as a click would select it (groups and instances whole).
fn pick_at(ed: &Editor, world: crate::geom::V2) -> Option<NodeId> {
crate::editor::hit::hit_test(&ed.doc, world, ed.camera.zoom).map(|h| ed.click_target(h, false))
}
/// Writes `@name ` into the draft at the composer's cursor (at the end when it has none) and
/// moves the cursor after it.
fn insert_mention(ctx: &egui::Context, draft: &mut String, name: &str) {
use egui::text::{CCursor, CCursorRange};
let id = egui::Id::new(INPUT_ID);
let mut state = egui::text_edit::TextEditState::load(ctx, id).unwrap_or_default();
let len = draft.chars().count();
let at = state.cursor.char_range().map_or(len, |r| r.primary.index.0).min(len);
let byte = draft.char_indices().nth(at).map_or(draft.len(), |(b, _)| b);
let lead = if draft[..byte].chars().last().is_some_and(|c| !c.is_whitespace()) { " " } else { "" };
let text = format!("{lead}@{name} ");
draft.insert_str(byte, &text);
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(at + text.chars().count()))));
state.store(ctx, id);
ctx.memory_mut(|m| m.request_focus(id));
}
/// Adds a picked chip, names it at the composer cursor, and leaves Pick mode.
fn add_pick(ctx: &egui::Context, st: &mut UiState, chip: SelChip) {
let name = chip.label.split(" \u{b7} ").next().unwrap_or_default().to_owned();
if !st.chat.sels.iter().any(|c| c.keys == chip.keys && c.rect == chip.rect) {
insert_mention(ctx, &mut st.chat.draft, &name);
}
push_chip(&mut st.chat.sels, chip);
st.chat.picking = false;
}
/// Pick mode, and Option held in Chat mode: a clear layer over the canvas takes the click instead
/// of the editor, so the user's selection stays as it is. Scrolling still pans and zooms.
pub fn overlay(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egui::Rect) {
if !st.chat_mode || !(st.chat.picking || ctx.input(|i| i.modifiers.alt)) {
return;
}
if st.chat.picking && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
st.chat.picking = false;
return;
}
let to_world = |ed: &Editor, p: egui::Pos2| ed.camera.screen_to_world(v2((p.x - canvas.min.x) as f64, (p.y - canvas.min.y) as f64));
let mut picked = None;
egui::Area::new("chat-pick".into()).fixed_pos(canvas.min).fade_in(false).show(ctx, |ui| {
let (_, resp) = ui.allocate_exact_size(canvas.size(), egui::Sense::click_and_drag());
if st.chat.picking && ctx.memory(|m| m.focused().is_none()) {
// Keys (Esc) come to egui, not to the editor, which would clear the selection.
resp.request_focus();
}
if resp.hovered() || resp.dragged() {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
}
let painter = ui.painter_at(canvas);
let stroke = egui::Stroke::new(1.5, theme::accent());
let screen = |ed: &Editor, r: &Rect| {
let s = ed.camera.world_rect_to_screen(r);
egui::Rect::from_min_size(canvas.min + egui::vec2(s.x as f32, s.y as f32), egui::vec2(s.w as f32, s.h as f32))
};
let (origin, shift, scroll, zoom) = ui.input(|i| (i.pointer.press_origin(), i.modifiers.shift, i.smooth_scroll_delta, i.zoom_delta()));
let at = resp.interact_pointer_pos().or(resp.hover_pos());
match (origin, at) {
(Some(a), Some(b)) if shift && (resp.dragged() || resp.drag_stopped()) => {
let r = egui::Rect::from_two_pos(a, b);
painter.rect_filled(r, 0.0, theme::accent().gamma_multiply(0.08));
painter.rect_stroke(r, 0.0, stroke, egui::StrokeKind::Inside);
if resp.drag_stopped() {
let r = Rect::from_points(to_world(ed, a), to_world(ed, b));
picked = Some(area_chip(ed, st.chat.gpu.as_ref(), ed.active_page, r));
}
}
(_, Some(p)) => {
let hit = pick_at(ed, to_world(ed, p));
if let Some(r) = hit.and_then(|id| ed.doc.world_rect(id)) {
painter.rect_stroke(screen(ed, &r), 0.0, stroke, egui::StrokeKind::Outside);
}
if resp.clicked()
&& let Some(id) = hit
{
picked = Some(layer_chip(ed, st.chat.gpu.as_ref(), ed.active_page, &[id]));
}
}
_ => {}
}
if let Some(p) = resp.hover_pos() {
let pos = v2((p.x - canvas.min.x) as f64, (p.y - canvas.min.y) as f64);
if zoom != 1.0 {
ed.handle(InputEvent::Zoom { factor: zoom as f64, pos });
} else if scroll != egui::Vec2::ZERO {
ed.handle(InputEvent::Scroll { delta: v2(-scroll.x as f64, -scroll.y as f64), pos });
}
}
});
if let Some(chip) = picked {
add_pick(ctx, st, chip);
}
}
/// ⌘K (or Ask… in the Actions menu): the Ask box on the selection; the palette when none.
pub fn open_ask(ed: &mut Editor, st: &mut UiState) {
if ed.selection.is_empty() {
st.palette.toggle();
return;
}
st.chat.ask = Some(Ask { text: String::new(), focus: true });
}
/// The chips the Ask box sends: the selection, as `@ Selection` would add it.
fn ask_chips(ed: &Editor, gpu: Option<&Headless>) -> Vec<SelChip> {
vec![layer_chip(ed, gpu, ed.active_page, &ed.top_selection())]
}
/// Sends the Ask box's text to chat with the selection chip and its crop. The mode stays as it is;
/// the reply streams into the chat column.
fn send_ask(ctx: &egui::Context, ed: &Editor, st: &mut UiState, text: &str) -> Result<(), String> {
let chips = ask_chips(ed, st.chat.gpu.as_ref());
let notes = super::selection_notes(ed, &chips);
st.chat.poll_detect(ctx, &st.settings.chat);
st.chat.ensure_chat();
st.chat.ensure_defaults(&st.settings.chat);
st.chat.refresh_ds(ed, true);
st.chat.send_with(ctx, &chips, &notes, &[], text.trim())
}
/// The Ask box, beside the selection where the New annotation popover opens. Enter sends, Esc closes.
pub fn ask_popover(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egui::Rect) {
let Some(mut ask) = st.chat.ask.take() else { return };
let Some(bounds) = ed.selection_bounds() else { return };
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
return;
}
let name = ed.top_selection().first().and_then(|id| ed.doc.get(*id)).map(|n| n.name.clone()).unwrap_or_default();
let pos = super::super::annotations::anchor_pos(ed, canvas, &bounds, ASK_W);
let (mut send, mut close) = (false, false);
egui::Area::new("chat-ask".into()).order(egui::Order::Foreground).constrain_to(canvas).fixed_pos(pos).show(ctx, |ui| {
let p = theme::pal(ui);
kit::card_frame(ui).inner_margin(12).show(ui, |ui| {
ui.set_width(ASK_W - 24.0);
ui.spacing_mut().item_spacing.y = 8.0;
ui.horizontal(|ui| {
ui.label(RichText::new("@").font(theme::semibold(theme::BODY)).color(theme::accent()));
ui.add(egui::Label::new(RichText::new(&name).font(theme::semibold(theme::BODY))).truncate());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| close = kit::close_button(ui).clicked());
});
let r = ui.add(egui::TextEdit::singleline(&mut ask.text).hint_text("Ask about this layer").desired_width(f32::INFINITY).margin(theme::INPUT_MARGIN));
if std::mem::take(&mut ask.focus) {
r.request_focus();
}
send = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !ask.text.trim().is_empty();
ui.label(RichText::new("Enter sends to chat \u{b7} Esc closes").size(theme::SECONDARY).color(p.tertiary));
});
});
if close {
return;
}
if send {
match send_ask(ctx, ed, st, &ask.text) {
Ok(()) => return,
Err(e) => {
ed.status = e;
ask.focus = true;
}
}
}
st.chat.ask = Some(ask);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Node, NodeKind};
/// A frame at (0,0) 400×300 with a card (20,20 100×80) and a button (300,200 80×40) in it.
fn fixture() -> (Editor, NodeId, NodeId, NodeId) {
let mut ed = Editor::default();
let f = ed.add_node(Node::new(NodeId(0), NodeKind::Frame { clip: true }, Rect::new(0.0, 0.0, 400.0, 300.0)), None);
let card = ed.add_node(Node::new(NodeId(0), NodeKind::Rect, Rect::new(20.0, 20.0, 100.0, 80.0)), Some(f));
let button = ed.add_node(Node::new(NodeId(0), NodeKind::Rect, Rect::new(300.0, 200.0, 80.0, 40.0)), Some(f));
(ed, f, card, button)
}
#[test]
fn an_area_chip_lists_the_layers_inside_and_its_rect() {
let (ed, f, card, button) = fixture();
let key = |id| crate::mcp::exec::node_key(&ed, 0, id);
let chip = area_chip(&ed, None, 0, Rect::new(10.0, 10.0, 150.0, 120.0));
assert_eq!(chip.keys, [key(card)], "the frame only overlaps, so its card is listed");
let line = super::super::chip_line(&chip);
assert_eq!(line, format!("[Area] page {}, rect 10,10,150,120, contains: {}", ed.pages[0].name, key(card)));
let all = area_chip(&ed, None, 0, Rect::new(-10.0, -10.0, 500.0, 400.0));
assert_eq!(all.keys, [key(f)]);
assert!(area_chip(&ed, None, 0, Rect::new(5000.0, 5000.0, 10.0, 10.0)).keys.is_empty());
let _ = button;
}
#[test]
fn several_picks_give_several_chips_and_mentions() {
let (ed, _, card, button) = fixture();
let ctx = egui::Context::default();
let mut st = UiState::default();
add_pick(&ctx, &mut st, layer_chip(&ed, None, 0, &[card]));
add_pick(&ctx, &mut st, area_chip(&ed, None, 0, Rect::new(290.0, 190.0, 100.0, 60.0)));
add_pick(&ctx, &mut st, layer_chip(&ed, None, 0, &[button]));
add_pick(&ctx, &mut st, layer_chip(&ed, None, 0, &[card]));
assert_eq!(st.chat.sels.len(), 3, "a layer picked twice is one chip");
assert!(st.chat.sels[1].rect.is_some());
assert_eq!(st.chat.draft.matches('@').count(), 3);
assert!(!st.chat.picking);
assert!(st.chat.sels.iter().all(|c| c.image.is_none()), "no GPU, no crop");
}
#[test]
fn a_mention_goes_in_at_the_cursor() {
use egui::text::{CCursor, CCursorRange};
let ctx = egui::Context::default();
let id = egui::Id::new(INPUT_ID);
let mut state = egui::text_edit::TextEditState::default();
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(5))));
state.store(&ctx, id);
let mut draft = "make bigger".to_owned();
insert_mention(&ctx, &mut draft, "Card");
assert_eq!(draft, "make @Card bigger");
}
#[test]
fn picking_does_not_touch_the_selection() {
let (mut ed, _, card, button) = fixture();
ed.selection = vec![button];
let ctx = egui::Context::default();
let mut st = UiState::default();
let id = pick_at(&ed, v2(50.0, 50.0)).unwrap();
add_pick(&ctx, &mut st, layer_chip(&ed, None, 0, &[id]));
assert_eq!(ed.selection, [button]);
assert_eq!(st.chat.sels[0].keys, [crate::mcp::exec::node_key(&ed, 0, card)]);
}
#[test]
fn the_ask_box_sends_what_chip_and_composer_send() {
let (mut ed, _, card, _) = fixture();
ed.selection = vec![card];
let dir = std::env::temp_dir().join(format!("papercraft-ask-{:016x}", fastrand::u64(..)));
let mut c = super::super::ChatState { project: Some(dir.clone()), ..Default::default() };
// Composer: @ Selection, then the text.
super::super::add_selection(&mut ed, &mut c);
let notes = super::super::selection_notes(&ed, &c.sels);
let (sels, text) = (c.sels.clone(), "Make it pop");
let composer = c.compose(&sels, &notes, &[], text).unwrap();
// Ask box: the same text on the selection.
let chips = ask_chips(&ed, None);
let ask = c.compose(&chips, &super::super::selection_notes(&ed, &chips), &[], text).unwrap();
assert_eq!(ask, composer);
assert!(ask.starts_with("[Selection] ") && ask.ends_with(text));
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn crops_become_attachments_and_an_area_crop_is_clipped() {
let Ok(gpu) = Headless::new() else { return };
let (mut ed, _, card, _) = fixture();
ed.selection.clear();
let area = area_chip(&ed, Some(&gpu), 0, Rect::new(10.0, 10.0, 150.0, 120.0));
let png = image::load_from_memory(area.image.as_deref().expect("an area crop")).unwrap();
assert_eq!((png.width(), png.height()), (300, 240), "the crop is the area at 2x, not the whole frame");
let layer = layer_chip(&ed, Some(&gpu), 0, &[card]);
assert!(layer.image.is_some(), "layer chips carry a crop too");
let dir = std::env::temp_dir().join(format!("papercraft-crop-{:016x}", fastrand::u64(..)));
let mut c = super::super::ChatState { project: Some(dir.clone()), ..Default::default() };
let msg = c.compose(&[area, layer], &[], &[], "Why?").unwrap();
let attached: Vec<&str> = msg.lines().filter(|l| l.starts_with("[Attached] ")).collect();
assert_eq!(attached.len(), 2, "{msg}");
assert!(attached.iter().all(|l| std::path::Path::new(&l["[Attached] ".len()..]).exists()), "CLI chats get the crops as files");
assert!(msg.starts_with("[Area] page "), "{msg}");
std::fs::remove_dir_all(dir).ok();
}
}
+2
View File
@@ -315,6 +315,8 @@ fn draw(ctx: &egui::Context, ed: &mut Editor, st: &UiState, lib: &mut LibrariesU
}
fn apply(act: Act, ed: &mut Editor, lib: &mut LibrariesUi, dir: Option<&Path>) {
// Publishing, enabling and disabling look at every page.
ed.finish_loading();
lib.error = None;
lib.note = None;
let Some(dir) = dir else { return };
+470 -29
View File
@@ -1,6 +1,7 @@
//! egui chrome: menu bar, layers panel, design (properties) panel, floating toolbar, and the
//! projects home screen.
pub mod actions;
pub mod agent;
pub mod annotations;
pub mod annotations_canvas;
@@ -30,7 +31,7 @@ use crate::editor::{Editor, Tool};
use crate::model::{Color, NodeId, NodeKind};
use crate::theme::{self, Icon};
use egui::{Align2, Color32, RichText, Ui};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
#[derive(Clone, Copy)]
pub enum WinCmd {
@@ -106,6 +107,11 @@ pub struct UiState {
pub layer_drag: Option<(NodeId, Option<LayerDrop>)>,
/// Row rects of the last layers-panel frame (for drop targeting).
pub layer_rows: Vec<(NodeId, egui::Rect, usize)>,
/// Scroll offset of the layers list at the last frame, and whether rows may pop in now.
pub layer_offset: f32,
pub layer_animate: bool,
/// `layer_subtree_height` of the rows seen this frame, so each subtree is measured once.
pub layer_heights: HashMap<NodeId, f32>,
/// Selection seen by the last layers-panel frame, to notice changes made on the canvas.
pub last_selection: Vec<NodeId>,
/// A layer the layers panel should scroll into view (and unfold its parents for).
@@ -120,16 +126,23 @@ pub struct UiState {
pub agent: Option<(String, web_time::Instant)>,
/// Recent agent tool calls, oldest first (the Agent view).
pub agent_log: Vec<AgentCall>,
/// Layers agents touched in the last [`AGENT_MARK_SECS`], oldest first: the live outlines and
/// the agent cursor.
pub agent_marks: Vec<AgentMark>,
/// Saved agent runs of the open project, newest first; re-read when `runs_key` stops matching
/// the project folder (run finished, project changed).
pub runs: Vec<crate::runs::Run>,
pub runs_key: Option<std::path::PathBuf>,
/// The run being recorded now, copied from the tracker each frame.
pub run_open: Option<crate::runs::Run>,
/// The runs being recorded now (one per agent client), copied from the tracker each frame.
pub run_open: Vec<crate::runs::Run>,
/// (active page, history generation) when the review runs were last recounted.
pub runs_gen: Option<(usize, u64)>,
/// The run that just ended with changes: the Agent view's "Agent finished" banner.
pub run_banner: Option<crate::runs::Run>,
/// Canvas toast for the same run: (run id, change count, shown at).
pub run_toast: Option<(String, usize, web_time::Instant)>,
/// Canvas toast for the same run: (run id, text, shown at).
pub run_toast: Option<(String, String, web_time::Instant)>,
/// An Actions-menu action and the layers it runs on, for the host to run as one run.
pub pending_action: Option<(actions::Act, Vec<String>)>,
/// Run (id) the user chose to review; the review view draws it.
pub review_run: Option<String>,
/// Diff of the open review, cached until the document changes; the canvas overlay reads it.
@@ -247,6 +260,8 @@ fn account_footer(ui: &mut Ui, st: &mut UiState) {
});
}
/// Review runs recounted after an edit (newest first).
const RECOUNT_MAX: usize = 3;
/// Most calls the Agent view keeps.
const AGENT_LOG_MAX: usize = 60;
@@ -258,11 +273,64 @@ pub struct AgentCall {
pub error: Option<String>,
}
/// A layer an agent just touched, outlined on the canvas while it fades.
pub struct AgentMark {
pub page_id: String,
pub node: NodeId,
pub verb: &'static str,
/// The run's client label ("Claude Code · 2", "Chat"); empty for unnamed clients.
pub client: String,
pub at: web_time::Instant,
}
/// How long an agent's outline stays on the canvas, fading out.
const AGENT_MARK_SECS: f32 = 3.0;
/// Outlines kept from one call (its topmost changed layers) and in all.
const AGENT_MARKS_PER_CALL: usize = 12;
const AGENT_MARKS_MAX: usize = 48;
impl UiState {
/// Per frame, after `Tracker::tick`: mirrors the open run and picks up a finished one.
/// Records the layers a successful agent call pointed at (`follow_node`) or changed
/// (`touched`, the tracker's per-call log) for the live outlines.
pub fn mark_agent(
&mut self,
ed: &Editor,
client: String,
name: &str,
args: &serde_json::Value,
touched: &[(String, NodeId)],
) {
let key = chat_msgs::follow_node(args).and_then(|k| {
let (prefix, num) = k.trim_start_matches('#').split_once(':')?;
let page = crate::mcp::exec::find_page(ed, prefix).ok()?;
Some((ed.pages[page].id.clone(), NodeId(num.parse().ok()?)))
});
// Topmost changed layers only: a write_html of 500 nodes is one outline, not 500.
let roots = touched.iter().filter(|(page, id)| {
let Some(i) = ed.pages.iter().position(|p| &p.id == page) else { return false };
let parent = ed.page_doc(i).get(*id).and_then(|n| n.parent);
parent.is_none_or(|par| !touched.iter().any(|(pg, t)| pg == page && *t == par))
});
let at = web_time::Instant::now();
let verb = agent_verb(name);
for (page_id, node) in key.into_iter().chain(roots.cloned()).take(AGENT_MARKS_PER_CALL) {
self.agent_marks.retain(|m| (&m.page_id, m.node) != (&page_id, node));
self.agent_marks.push(AgentMark { page_id, node, verb, client: client.clone(), at });
}
let over = self.agent_marks.len().saturating_sub(AGENT_MARKS_MAX);
self.agent_marks.drain(..over);
}
/// Drops faded marks; true while any is still showing.
fn live_marks(&mut self) -> bool {
self.agent_marks.retain(|m| m.at.elapsed().as_secs_f32() < AGENT_MARK_SECS);
!self.agent_marks.is_empty()
}
/// Per frame, after `Tracker::tick`: mirrors the open runs and picks up a finished one.
/// Returns true when a run just ended with changes.
pub fn sync_runs(&mut self, t: &mut crate::runs::Tracker) -> bool {
self.run_open = t.open_run().cloned();
pub fn sync_runs(&mut self, t: &mut crate::runs::Tracker, ed: &Editor) -> bool {
self.run_open = t.open_runs().cloned().collect();
let finished = t.take_finished();
if let Some(run) = &finished {
self.chat.note_run(run);
@@ -276,17 +344,46 @@ impl UiState {
self.runs = crate::runs::list(&dir);
self.runs_key = Some(dir);
}
self.recount_runs(ed);
let Some(run) = finished else {
return false;
};
let changed = run.added + run.changed + run.removed > 0;
if changed {
self.run_toast = Some((run.id.clone(), run.added + run.changed + run.removed, web_time::Instant::now()));
let text = if run.client == crate::mcp::ACTIONS_CLIENT {
run.title.clone()
} else {
format!("Agent finished \u{b7} {}", panels::count(run.added + run.changed + run.removed, "change"))
};
self.run_toast = Some((run.id.clone(), text, web_time::Instant::now()));
self.run_banner = Some(run);
}
changed
}
/// After undo, redo or an edit on the page, the review runs on it get their counts again, so
/// their cards do not keep showing changes that are gone. Saved, so a reload agrees.
fn recount_runs(&mut self, ed: &Editor) {
let key = (ed.active_page, ed.history.generation());
if !self.run_open.is_empty() || self.runs_gen == Some(key) {
return;
}
self.runs_gen = Some(key);
let Some(dir) = &self.project_dir else { return };
let page = ed.active_page_id();
let review = self.runs.iter_mut().filter(|r| {
r.state == crate::runs::RunState::Review && r.pages.iter().any(|p| p.page_id == page)
});
// ponytail: newest few only, each recount reads its snapshots from disk.
for r in review.take(RECOUNT_MAX) {
let (added, changed, removed, _) = crate::runs::count(dir, ed, r);
if (added, changed, removed) != (r.added, r.changed, r.removed) {
(r.added, r.changed, r.removed) = (added, changed, removed);
let _ = crate::runs::save(dir, r);
}
}
}
/// Records an agent's tool call for the toolbar hint and the Agent view.
pub fn log_agent(&mut self, name: &str, error: Option<String>) {
let at = web_time::Instant::now();
@@ -304,6 +401,8 @@ impl UiState {
/// How long after its last tool call an agent still counts as working.
const AGENT_WORKING_SECS: u64 = 6;
/// Repaint interval of the activity pill's dots.
const AGENT_PILL_FRAME_MS: u64 = 33;
/// What an agent's tool call means in plain words, for the activity pill.
pub(crate) fn agent_verb(tool: &str) -> &'static str {
@@ -341,7 +440,8 @@ fn agent_pill(ui: &mut Ui, st: &UiState, with_text: bool) {
let Some(verb) = st.agent_working() else {
return;
};
ui.ctx().request_repaint();
// The dots bounce at 30 fps: a full-canvas frame per vsync for this alone burns CPU.
ui.ctx().request_repaint_after(std::time::Duration::from_millis(AGENT_PILL_FRAME_MS));
let p = theme::pal(ui);
let t = ui.ctx().input(|i| i.time) as f32;
let accent = theme::accent();
@@ -361,8 +461,12 @@ fn agent_pill(ui: &mut Ui, st: &UiState, with_text: bool) {
.circle_filled(egui::pos2(x, r.center().y - hop), 2.5, accent);
}
if with_text {
let text = match run_progress(&st.run_open) {
Some(n) => format!("{verb} \u{b7} {n}"),
None => format!("{verb}…"),
};
ui.label(
RichText::new(format!("{verb}…"))
RichText::new(text)
.size(theme::SECONDARY)
.color(p.text),
);
@@ -371,6 +475,31 @@ fn agent_pill(ui: &mut Ui, st: &UiState, with_text: bool) {
});
}
/// `1340` as `1,340`.
pub(crate) fn grouped(n: usize) -> String {
let s = n.to_string();
let mut out = String::new();
for (i, c) in s.chars().enumerate() {
if i > 0 && (s.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
/// Live size of the open runs, all clients together: "212 calls · 1,340 changes". None when no
/// run is open.
pub(crate) fn run_progress(open: &[crate::runs::Run]) -> Option<String> {
if open.is_empty() {
return None;
}
let calls: usize = open.iter().map(|r| r.calls as usize).sum();
let changes: usize = open.iter().map(crate::runs::Run::touched).sum();
let unit = |n: usize, u: &str| format!("{} {u}{}", grouped(n), if n == 1 { "" } else { "s" });
Some(format!("{} \u{b7} {}", unit(calls, "call"), unit(changes, "change")))
}
/// A soft accent ring around the canvas while an agent works.
fn agent_glow(ctx: &egui::Context, st: &UiState, canvas: egui::Rect) {
if st.agent_working().is_none() {
@@ -641,7 +770,12 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
st.chat.mcp_url.clone_from(&st.agent_endpoint);
}
st.chat.project_name.clone_from(&st.project_name);
let turn = st.chat.turn.is_some();
st.chat.pump(ui.ctx());
if turn && st.chat.turn.is_none() {
// The turn is over: the "Working" pill goes with it, not 6 s after its last tool call.
st.agent = None;
}
if st.custom_titlebar {
let p = theme::pal(ui);
egui::Panel::top("titlebar")
@@ -686,9 +820,10 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
}
UiEvent::OpenVariables => st.variables.open = !st.variables.open,
UiEvent::Annotate(id) => annotations::open_popover(ed, &mut st.ann_ui, id),
UiEvent::Ask => chat::open_ask(ed, st),
}
}
if st.run_open.is_some() {
if !st.run_open.is_empty() {
// So the tracker's idle timeout fires even when nothing else repaints.
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
@@ -835,6 +970,8 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
}
toolbar(ui.ctx(), ed, canvas);
annotations::overlays(ui.ctx(), ed, st, canvas);
chat::chat_pick::overlay(ui.ctx(), ed, st, canvas);
chat::chat_pick::ask_popover(ui.ctx(), ed, st, canvas);
if st.chat_mode && st.chat.inspector {
chat::inspector_overlay(ui.ctx(), ed, st, canvas);
}
@@ -844,6 +981,7 @@ pub fn draw(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) -> egui::Rect {
agent_glow(ui.ctx(), st, canvas);
status(ui.ctx(), ed, canvas);
review_overlay(ui.ctx(), ed, st, canvas);
agent_overlay(ui.ctx(), ed, st, canvas);
annotations_canvas::draw(ui.ctx(), ed, &mut st.annotations, &mut st.selected_annotation, canvas);
run_toast(ui.ctx(), st, canvas);
palette::draw(ui.ctx(), ed, st, canvas);
@@ -1448,11 +1586,14 @@ fn layers(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
);
}
st.layer_rows.clear();
st.layer_heights.clear();
// Rows that come into view by scrolling just appear; the pop-in is for a list that
// was not on screen, or rows that were unfolded.
let offset = ui.clip_rect().top() - ui.min_rect().top();
st.layer_animate = (offset - st.layer_offset).abs() < 0.5;
st.layer_offset = offset;
for id in roots.iter().rev() {
let index = st.layer_rows.len();
motion::pop_in(ui, ("layer-pop", *id), index, |ui| {
layer_row(ui, ed, st, *id, 0)
});
layer_item(ui, ed, st, *id, 0);
}
layer_drag(ui, ed, st);
});
@@ -1589,6 +1730,55 @@ fn kind_icon(k: &NodeKind) -> Icon {
}
}
/// Height of a layer row.
const LAYER_ROW_HEIGHT: f32 = 30.0;
/// Space between layer rows (and between a row and the rows below it).
const LAYER_ROW_GAP: f32 = 1.0;
/// Rows this far outside the visible list are still built, so a scroll never shows a gap.
const LAYER_OFFSCREEN_MARGIN: f32 = 200.0;
/// Height of the layer rows of `id` and everything unfolded below it, as `layer_row` lays them
/// out (each child is drawn in its own scope, followed by one gap).
fn layer_subtree_height(
doc: &crate::model::Document,
collapsed: &HashSet<NodeId>,
id: NodeId,
memo: &mut HashMap<NodeId, f32>,
) -> f32 {
if let Some(h) = memo.get(&id) {
return *h;
}
let Some(n) = doc.get(id) else {
return 0.0;
};
let mut h = LAYER_ROW_HEIGHT;
if !collapsed.contains(&id) {
for c in &n.children {
h += LAYER_ROW_GAP + layer_subtree_height(doc, collapsed, *c, memo);
}
}
memo.insert(id, h);
h
}
/// A layer row and its unfolded children. A page can hold thousands of layers, but only a
/// screenful is visible: rows far outside the list are not built, their space is just reserved.
fn layer_item(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth: usize) {
// A filter builds the whole tree (matches can be anywhere), and so does scrolling to a layer.
if st.layer_filter.trim().is_empty() && st.reveal.is_none() {
let h = layer_subtree_height(&ed.doc, &st.collapsed, id, &mut st.layer_heights);
let top = ui.cursor().top();
let view = ui.clip_rect();
if top + h < view.top() - LAYER_OFFSCREEN_MARGIN || top > view.bottom() + LAYER_OFFSCREEN_MARGIN {
ui.add_space(h + LAYER_ROW_GAP);
return;
}
}
let index = st.layer_rows.len();
let animate = st.layer_animate;
motion::pop_in_if(ui, ("layer-pop", id), index, animate, |ui| layer_row(ui, ed, st, id, depth));
}
/// One sidebar row, drawn like an NSOutlineView row: 24pt tall, 6pt radius selection in the
/// accent colour, subtle hover, disclosure chevron, symbol, name, and eye/lock on hover.
/// Whether `id` or anything below it has a name containing `needle` (lowercase).
@@ -1614,7 +1804,7 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
let selected = ed.selection.contains(&id);
let width = ui.available_width();
let (row, resp) =
ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::click_and_drag());
ui.allocate_exact_size(egui::vec2(width, LAYER_ROW_HEIGHT), egui::Sense::click_and_drag());
st.layer_rows.push((id, row, depth));
if st.reveal == Some(id) {
ui.scroll_to_rect(row, None);
@@ -1764,10 +1954,7 @@ fn layer_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, id: NodeId, depth:
}
if !needle.is_empty() || !st.collapsed.contains(&id) {
for c in n.children.iter().rev() {
let index = st.layer_rows.len();
motion::pop_in(ui, ("layer-pop", *c), index, |ui| {
layer_row(ui, ed, st, *c, depth + 1)
});
layer_item(ui, ed, st, *c, depth + 1);
}
}
}
@@ -1937,9 +2124,72 @@ fn review_overlay(ctx: &egui::Context, ed: &Editor, st: &UiState, canvas: egui::
});
}
const AGENT_OUTLINE: f32 = 2.0;
/// Size of the agent cursor's arrow (screen px).
const AGENT_CURSOR: f32 = 12.0;
/// While agents work: accent outlines on the layers they just touched, fading over
/// [`AGENT_MARK_SECS`], and an agent cursor (verb and client) on the newest. When the newest is
/// on another page, a chip "Agent on page X →" switches to it. Repaints only while a mark fades.
fn agent_overlay(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, canvas: egui::Rect) {
if !st.live_marks() {
return;
}
ctx.request_repaint_after(std::time::Duration::from_millis(AGENT_PILL_FRAME_MS));
let accent = theme::accent();
let page = ed.active_page_id().to_string();
let to_screen = |r: &crate::geom::Rect| {
let s = ed.camera.world_rect_to_screen(r);
egui::Rect::from_min_size(
canvas.min + egui::vec2(s.x as f32, s.y as f32),
egui::vec2(s.w as f32, s.h as f32),
)
};
let fade = |m: &AgentMark| (1.0 - m.at.elapsed().as_secs_f32() / AGENT_MARK_SECS).clamp(0.0, 1.0);
egui::Area::new("agent-overlay".into())
.order(egui::Order::Foreground)
.fixed_pos(canvas.min)
.interactable(false)
.show(ctx, |ui| {
let painter = ui.painter().with_clip_rect(canvas);
let rect = |m: &AgentMark| (m.page_id == page).then(|| ed.doc.world_rect(m.node)).flatten().map(|r| to_screen(&r));
for m in &st.agent_marks {
let Some(r) = rect(m) else { continue };
let stroke = egui::Stroke::new(AGENT_OUTLINE, accent.gamma_multiply(fade(m)));
painter.rect_stroke(r, REVIEW_RADIUS, stroke, egui::StrokeKind::Outside);
}
let Some((m, r)) = st.agent_marks.last().and_then(|m| Some((m, rect(m)?))) else { return };
let a = fade(m);
let tip = r.left_top();
let arrow = vec![tip, tip + egui::vec2(0.0, AGENT_CURSOR), tip + egui::vec2(AGENT_CURSOR * 0.75, AGENT_CURSOR * 0.75)];
painter.add(egui::Shape::convex_polygon(arrow, accent.gamma_multiply(a), egui::Stroke::NONE));
let text = if m.client.is_empty() { m.verb.to_string() } else { format!("{} \u{b7} {}", m.verb, m.client) };
let ink = kit::ink_on(accent);
let galley = painter.layout_no_wrap(text, theme::semibold(theme::SECONDARY), ink.gamma_multiply(a));
let at = tip + egui::vec2(AGENT_CURSOR * 0.6, AGENT_CURSOR * 0.9);
let tag = egui::Rect::from_min_size(at, galley.size() + egui::vec2(12.0, 4.0));
painter.rect_filled(tag, 255, accent.gamma_multiply(a));
painter.galley(tag.min + egui::vec2(6.0, 2.0), galley, ink);
});
let Some(m) = st.agent_marks.last().filter(|m| m.page_id != page) else { return };
let Some(i) = ed.pages.iter().position(|p| p.id == m.page_id) else { return };
let text = format!("Agent on page {} \u{2192}", ed.pages[i].name);
let mut go = false;
egui::Area::new("agent-elsewhere".into())
.order(egui::Order::Foreground)
.pivot(Align2::CENTER_TOP)
.fixed_pos(canvas.center_top() + egui::vec2(0.0, 14.0))
.show(ctx, |ui| {
go = kit::chip(ui, &text, true).clicked();
});
if go {
ed.switch_page(i);
}
}
/// Bottom-centre toast when an agent run ends with changes: Review, Revert, auto-hides.
fn run_toast(ctx: &egui::Context, st: &mut UiState, canvas: egui::Rect) {
let Some((id, changes, at)) = st.run_toast.clone() else {
let Some((id, text, at)) = st.run_toast.clone() else {
return;
};
if at.elapsed().as_secs_f32() > RUN_TOAST_SECS {
@@ -1970,13 +2220,7 @@ fn run_toast(ctx: &egui::Context, st: &mut UiState, canvas: egui::Rect) {
let (r, _) =
ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
theme::paint_icon(ui.painter(), r, Icon::Pen, p.secondary);
ui.label(
RichText::new(format!(
"Agent finished · {}",
panels::count(changes, "change")
))
.font(theme::semibold(theme::BODY)),
);
ui.label(RichText::new(text).font(theme::semibold(theme::BODY)));
ui.add_space(8.0);
if kit::button(ui, kit::ButtonKind::Primary, None, "Review").clicked() {
act = Some(false);
@@ -2111,3 +2355,200 @@ fn help(ctx: &egui::Context, st: &mut UiState, canvas: egui::Rect) {
st.show_help = false;
}
}
#[cfg(test)]
mod layer_tests {
use super::*;
use crate::geom::Rect;
use crate::model::Node;
fn frame(ctx: &egui::Context, ed: &mut Editor, st: &mut UiState, i: u32, h: f32) -> std::time::Duration {
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, h))),
time: Some(f64::from(i) / 60.0),
predicted_dt: 1.0 / 60.0,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
draw(ui, ed, st);
});
out.textures_delta.clear();
out.viewport_output[&egui::ViewportId::ROOT].repaint_delay
}
fn rect(ed: &mut Editor, parent: Option<NodeId>, frame: bool) -> NodeId {
let kind = if frame { NodeKind::Frame { clip: false } } else { NodeKind::Rect };
ed.add_node(Node::new(NodeId(0), kind, Rect::new(0.0, 0.0, 10.0, 10.0)), parent)
}
fn many_roots(n: usize) -> Editor {
let mut ed = Editor::default();
for _ in 0..n {
rect(&mut ed, None, false);
}
ed
}
#[test]
fn a_resting_layers_list_does_not_repaint() {
let ctx = egui::Context::default();
setup_style(&ctx);
let mut ed = many_roots(300);
let mut st = UiState::default();
let mut last = std::time::Duration::ZERO;
for i in 0..180 {
last = frame(&ctx, &mut ed, &mut st, i, 800.0);
}
assert!(!last.is_zero(), "rows keep asking for frames after their pop-in");
}
#[test]
fn an_agent_pill_is_the_only_thing_repainting_while_it_works() {
let ctx = egui::Context::default();
setup_style(&ctx);
let mut ed = many_roots(300);
let mut st = UiState::default();
st.log_agent("get_basic_info", None);
for i in 0..180 {
frame(&ctx, &mut ed, &mut st, i, 800.0);
}
let causes: Vec<String> = ctx.repaint_causes().iter().map(|c| c.to_string()).collect();
assert!(causes.iter().all(|c| !c.contains("motion.rs")), "{causes:?}");
}
fn shown(ctx: &egui::Context, area: &str) -> bool {
ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, egui::Id::new(area))))
}
#[test]
fn agent_outlines_show_after_a_call_and_go_after_three_seconds_without_repainting() {
let ctx = egui::Context::default();
setup_style(&ctx);
let mut ed = many_roots(3);
let mut st = UiState::default();
let mut idle = std::time::Duration::ZERO;
for i in 0..120 {
idle = frame(&ctx, &mut ed, &mut st, i, 800.0);
}
let quiet = std::time::Duration::from_millis(AGENT_PILL_FRAME_MS);
assert!(idle > quiet, "settled canvas still repaints: {idle:?}");
let page = ed.active_page_id().to_string();
let id = ed.doc.root[0];
st.mark_agent(&ed, "Claude Code".into(), "update_styles", &serde_json::json!({}), &[(page, id)]);
assert!(frame(&ctx, &mut ed, &mut st, 120, 800.0) <= quiet);
frame(&ctx, &mut ed, &mut st, 121, 800.0);
assert!(shown(&ctx, "agent-overlay"));
assert!(!shown(&ctx, "agent-elsewhere"));
assert_eq!((st.agent_marks[0].verb, st.agent_marks[0].client.as_str()), ("Styling", "Claude Code"));
let old = web_time::Instant::now() - std::time::Duration::from_secs_f32(AGENT_MARK_SECS + 0.1);
st.agent_marks[0].at = old;
let mut last = std::time::Duration::ZERO;
for i in 122..240 {
last = frame(&ctx, &mut ed, &mut st, i, 800.0);
}
assert!(st.agent_marks.is_empty());
assert!(!shown(&ctx, "agent-overlay"));
assert!(last > quiet, "faded outlines keep repainting: {last:?}");
}
#[test]
fn agent_on_another_page_shows_a_chip_that_switches_to_it() {
let ctx = egui::Context::default();
setup_style(&ctx);
let mut ed = many_roots(1);
let other = ed.add_page();
ed.switch_page(other);
let id = rect(&mut ed, None, true);
let page = ed.active_page_id().to_string();
ed.switch_page(0);
let mut st = UiState::default();
let key = crate::mcp::exec::node_key(&ed, other, id);
st.mark_agent(&ed, String::new(), "get_screenshot", &serde_json::json!({ "nodeId": key }), &[]);
assert_eq!((st.agent_marks.len(), st.agent_marks[0].page_id.as_str()), (1, page.as_str()));
for i in 0..2 {
frame(&ctx, &mut ed, &mut st, i, 800.0);
}
assert!(shown(&ctx, "agent-elsewhere"));
}
#[test]
fn write_html_outlines_only_its_top_layer_and_counts_group_digits() {
let mut ed = many_roots(0);
let top = rect(&mut ed, None, true);
let kids: Vec<_> = (0..5).map(|_| rect(&mut ed, Some(top), false)).collect();
let page = ed.active_page_id().to_string();
let touched: Vec<_> = std::iter::once(top).chain(kids).map(|id| (page.clone(), id)).collect();
let mut st = UiState::default();
st.mark_agent(&ed, "Chat".into(), "write_html", &serde_json::json!({}), &touched);
assert_eq!(st.agent_marks.iter().map(|m| m.node).collect::<Vec<_>>(), [top]);
assert_eq!((grouped(7), grouped(1340), grouped(1_234_567)), ("7".into(), "1,340".into(), "1,234,567".into()));
let run = crate::runs::Run {
calls: 212,
pages: vec![crate::runs::RunPage { nodes: (0..1340).map(NodeId).collect(), ..Default::default() }],
..Default::default()
};
assert_eq!(run_progress(&[run]).unwrap(), "212 calls \u{b7} 1,340 changes");
assert!(run_progress(&[]).is_none());
}
#[test]
fn only_the_visible_layer_rows_are_built() {
let ctx = egui::Context::default();
setup_style(&ctx);
let mut ed = many_roots(3000);
let mut st = UiState::default();
for i in 0..3 {
frame(&ctx, &mut ed, &mut st, i, 600.0);
}
assert!(!st.layer_rows.is_empty());
assert!(st.layer_rows.len() < 60, "{} rows built", st.layer_rows.len());
}
/// Measuring the tree walks each unfolded node once (a row's subtree is a memo hit for
/// the rows below it), not once per ancestor.
#[test]
fn layer_heights_measure_each_node_once() {
let mut ed = Editor::default();
let mut parent = rect(&mut ed, None, true);
let top = parent;
for _ in 0..200 {
parent = rect(&mut ed, Some(parent), true);
}
let mut memo = HashMap::new();
let h = layer_subtree_height(&ed.doc, &HashSet::new(), top, &mut memo);
assert_eq!(memo.len(), 201);
assert!((h - (201.0 * LAYER_ROW_HEIGHT + 200.0 * LAYER_ROW_GAP)).abs() < 0.01);
}
/// Rows that are not built are replaced by space computed from `layer_subtree_height`; it
/// must match what drawing them would take, or the list's scroll range drifts.
#[test]
fn skipped_layer_space_matches_drawn_rows() {
let ctx = egui::Context::default();
setup_style(&ctx);
let mut ed = Editor::default();
for _ in 0..3 {
let f = rect(&mut ed, None, true);
let inner = rect(&mut ed, Some(f), true);
rect(&mut ed, Some(inner), false);
rect(&mut ed, Some(inner), false);
rect(&mut ed, Some(f), false);
}
let mut st = UiState::default();
// Fold the middle frame, so the model has to honour it.
let middle = ed.doc.root[1];
st.collapsed.insert(middle);
// Long enough for the pop-in to settle: rows are displaced while it runs.
for i in 0..90 {
frame(&ctx, &mut ed, &mut st, i, 2000.0);
}
let tops: HashMap<NodeId, f32> =
st.layer_rows.iter().map(|(id, r, _)| (*id, r.top())).collect();
let roots: Vec<NodeId> = ed.doc.root.iter().rev().copied().collect();
let mut expected = tops[&roots[0]];
for r in &roots {
assert!((tops[r] - expected).abs() < 0.6, "root {r:?} at {} not {expected}", tops[r]);
expected += layer_subtree_height(&ed.doc, &st.collapsed, *r, &mut HashMap::new()) + LAYER_ROW_GAP;
}
}
}
+22 -3
View File
@@ -224,14 +224,33 @@ pub fn pop_in<R>(
id: impl std::hash::Hash + std::fmt::Debug,
index: usize,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
pop_in_if(ui, id, index, true, add)
}
/// [`pop_in`], or with `animate` false the same row already in place (and counted as seen, so
/// it does not pop in later): for rows that come into view by scrolling.
pub fn pop_in_if<R>(
ui: &mut egui::Ui,
id: impl std::hash::Hash + std::fmt::Debug,
index: usize,
animate: bool,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
if !enabled(ui.ctx()) {
return add(ui);
}
let delay = STAGGER * f32::from(u8::try_from(index.min(STAGGER_ROWS)).unwrap_or(0));
let raw = appear(ui.ctx(), id, delay + SLOW, |t| t);
let t = ((raw * (delay + SLOW) - delay) / SLOW).clamp(0.0, 1.0);
if t < 1.0 {
let key = Id::new(&id);
let mut raw = appear(ui.ctx(), id, delay + SLOW, |t| t);
if !animate {
ui.ctx().animate_value_with_time(key, 1.0, 0.0);
raw = 1.0;
}
// `raw` reaching 1 is the end: `t` below can land a hair under 1 from rounding and would
// repaint forever.
let t = if raw >= 1.0 { 1.0 } else { ((raw * (delay + SLOW) - delay) / SLOW).clamp(0.0, 1.0) };
if raw < 1.0 {
ui.ctx().request_repaint();
}
let drop = (1.0 - ease_out_back(t)) * POP_DROP;
+7 -1
View File
@@ -363,7 +363,13 @@ fn page_row(ui: &mut Ui, ed: &mut Editor, st: &mut UiState, i: usize) {
};
let g = ui
.painter()
.layout(ed.pages[i].name.clone(), font, p.text, f32::INFINITY);
.layout(
ed.pages[i].name.clone(),
font,
// Dimmed while the page is still being read in the background.
if ed.pages[i].pending { p.secondary } else { p.text },
f32::INFINITY,
);
ui.painter()
.with_clip_rect(name_rect.intersect(ui.clip_rect()))
.galley(
+12
View File
@@ -52,6 +52,8 @@ pub enum Command {
ReviewRun(String),
/// Revert a saved agent run.
RevertRun(String),
/// An Actions-menu action on the selection.
Act(super::actions::Act),
}
pub struct Entry {
@@ -195,6 +197,15 @@ pub fn entries(ed: &Editor, st: &UiState) -> Vec<Entry> {
});
}
}
if !ed.selection.is_empty() {
for (act, label) in super::actions::items() {
out.push(Entry {
label: format!("Actions: {label}"),
detail: "Selection".into(),
cmd: Command::Act(act),
});
}
}
out.push(Entry {
label: "Libraries…".into(),
detail: "Publish and enable".into(),
@@ -314,6 +325,7 @@ pub fn run(ed: &mut Editor, st: &mut UiState, cmd: Command) {
st.show_library = false;
st.show_design = false;
}
Command::Act(a) => super::actions::dispatch(ed, st, a),
Command::Settings => st.settings.open = true,
Command::Variables => st.variables.open = true,
Command::Libraries => st.libraries.open = true,
+48 -17
View File
@@ -573,8 +573,7 @@ fn runs_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
ui.add_space(10.0);
}
revert_dialog(ui.ctx(), ed, st, &dir);
let open_id = st.run_open.as_ref().map(|r| r.id.clone());
let saved = st.runs.iter().filter(|r| open_id.as_ref() != Some(&r.id));
let saved = st.runs.iter().filter(|r| !st.run_open.iter().any(|o| o.id == r.id));
let all: Vec<_> = st.run_open.iter().chain(saved).cloned().collect();
if all.is_empty() {
return;
@@ -584,7 +583,10 @@ fn runs_section(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
for run in &all {
let selected = st.review_run.as_ref() == Some(&run.id);
let changes = run.added + run.changed + run.removed;
let calls = count(run.calls as usize, "call");
let calls = match run.client.as_str() {
"" => count(run.calls as usize, "call"),
client => format!("{client} \u{b7} {}", count(run.calls as usize, "call")),
};
let (label, sub) = match run.state {
RunState::Running => ("Running", format!("{calls} · in progress")),
state => {
@@ -660,17 +662,31 @@ pub(super) fn revert_dialog(ctx: &egui::Context, ed: &mut Editor, st: &mut UiSta
let Some(id) = st.pending_run_revert.clone() else {
return;
};
let answer = super::confirm::dialog(
ctx,
"run-revert",
"Revert agent run?",
"Every page the agent changed goes back to how it was before the run, including any edits you made to those pages since.",
"Revert",
);
// A later run changed the same layers: this one only goes back together with it.
let blocker = st.runs.iter().find(|r| r.id == id).and_then(|r| crate::runs::blocker(&st.runs, r));
let answer = match blocker {
Some(n) => super::confirm::dialog(
ctx,
"run-revert",
"Revert agent run?",
&format!("Run {n} changed the same layers afterwards, so this run cannot be reverted on its own. Restore to here reverts it and every later run."),
"Restore to here",
),
None => super::confirm::dialog(
ctx,
"run-revert",
"Revert agent run?",
"Every page the agent changed goes back to how it was before the run, including any edits you made to those pages since.",
"Revert",
),
};
let Some(yes) = answer else {
return;
};
st.pending_run_revert = None;
if yes && blocker.is_some() {
return restore_to(ed, st, dir, &id);
}
let Some(mut run) = st.runs.iter().find(|r| r.id == id).cloned().filter(|_| yes) else {
return;
};
@@ -698,10 +714,18 @@ pub(super) fn restore_dialog(ctx: &egui::Context, ed: &mut Editor, st: &mut UiSt
return;
};
st.pending_restore = None;
if !yes || runs.is_empty() {
if yes {
restore_to(ed, st, dir, &id);
}
}
/// Reverts run `id` and every later run, newest first.
fn restore_to(ed: &mut Editor, st: &mut UiState, dir: &std::path::Path, id: &str) {
let runs = crate::runs::runs_from(&st.runs, id);
if runs.is_empty() {
return;
}
if st.run_open.is_some() {
if !st.run_open.is_empty() {
ed.status = "Wait for the agent run in progress to finish".into();
return;
}
@@ -769,7 +793,8 @@ pub struct ReviewPage {
/// document only re-runs the in-memory diff.
pub struct ReviewDiff {
pub run: String,
stamp: u64,
/// `doc_stamp` and history generation it was built at: an undo refreshes it too.
stamp: (u64, u64),
pub snaps: Vec<Option<crate::model::Document>>,
pub pages: Vec<ReviewPage>,
}
@@ -790,7 +815,7 @@ fn build_review(ed: &Editor, run: &crate::runs::Run, dir: &std::path::Path) -> R
.collect();
let mut r = ReviewDiff {
run: run.id.clone(),
stamp: 0,
stamp: (0, 0),
snaps,
pages: Vec::new(),
};
@@ -799,7 +824,7 @@ fn build_review(ed: &Editor, run: &crate::runs::Run, dir: &std::path::Path) -> R
}
fn refresh_review(r: &mut ReviewDiff, ed: &Editor, run: &crate::runs::Run) {
r.stamp = ed.doc_stamp;
r.stamp = (ed.doc_stamp, ed.history.generation());
r.pages = run
.pages
.iter()
@@ -813,7 +838,13 @@ fn refresh_review(r: &mut ReviewDiff, ed: &Editor, run: &crate::runs::Run) {
ReviewPage {
page_id: rp.page_id.clone(),
name,
review: page_review(rp.version.is_some(), snap.as_ref(), live_doc(ed, &rp.page_id)),
review: match page_review(rp.version.is_some(), snap.as_ref(), live_doc(ed, &rp.page_id)) {
PageReview::Diff(mut d) => {
crate::runs::keep_own(&mut d, rp);
PageReview::Diff(d)
}
other => other,
},
}
})
.collect();
@@ -937,7 +968,7 @@ pub(super) fn review(ui: &mut Ui, ed: &mut Editor, st: &mut UiState) {
};
match &mut st.review_diff {
Some(r) if r.run == id => {
if r.stamp != ed.doc_stamp {
if r.stamp != (ed.doc_stamp, ed.history.generation()) {
refresh_review(r, ed, &run);
}
}
+10
View File
@@ -88,6 +88,8 @@ pub struct ChatPrefs {
/// Old settings files: read once into the default endpoint's model, never written.
#[serde(skip_serializing)]
pub api_model: Option<String>,
/// Send the design-system summary (`chat::ds_digest`) with chat turns. It costs tokens.
pub ds_summary: bool,
}
impl Default for ChatPrefs {
@@ -102,6 +104,7 @@ impl Default for ChatPrefs {
default_endpoint: endpoint::DEFAULT_ENDPOINT_ID.into(),
api_effort: DEFAULT_EFFORT.into(),
api_model: None,
ds_summary: true,
}
}
}
@@ -1042,6 +1045,12 @@ mod native {
}
note(ui, "Sent to api.anthropic.com only.");
});
field_row(ui, "Design system", |ui| {
if kit::checkbox(ui, &mut prefs.ds_summary, "Send design-system summary").changed() {
ed.save = true;
}
note(ui, "Variables, components and libraries with each turn, about 1,500 tokens.");
});
}
}
@@ -1078,6 +1087,7 @@ mod tests {
default_endpoint: "ep-1".into(),
api_effort: "high".into(),
api_model: None,
ds_summary: false,
};
assert_eq!(ChatPrefs::from_json(&prefs.to_json()), prefs);
assert_eq!(ChatPrefs::from_json(&ChatPrefs::default().to_json()), ChatPrefs::default());
+5 -1
View File
@@ -49,7 +49,11 @@ pub fn save(
doc: &Document,
) -> std::io::Result<Version> {
crate::vfs::create_dir_all(dir(project))?;
let saved = now_ms();
let mut saved = now_ms();
// Two runs (parallel agents) may snapshot a page in the same millisecond: never share a file.
while crate::vfs::exists(&file_of(project, &format!("{page_id}__{saved}"))) {
saved += 1;
}
let id = format!("{page_id}__{saved}");
let name = name.trim();
let name = if name.is_empty() {
+14 -2
View File
@@ -422,10 +422,15 @@ impl Web {
let mut runs = std::mem::take(&mut self.runs);
let result = runs.call(
self,
&crate::mcp::Client::default(),
&call.name,
&call.args,
crate::project::format::now_ms(),
);
let touched = runs.take_touched();
if result.is_ok() {
self.st.mark_agent(&self.ed, String::new(), &call.name, &call.args, &touched);
}
self.runs = runs;
self.st.project_name = self.session.project_name();
self.st.project_dir = self.session.project.as_ref().map(|p| p.dir.clone());
@@ -449,9 +454,10 @@ impl Web {
/// Render a screenshot offscreen and answer call `id` when the readback lands.
fn start_screenshot(&mut self, id: u64, req: ShotRequest) {
let doc = self.ed.page_doc(req.page).clone();
let cats = self.ed.design.categories();
let gpu = self.gpu_handle();
self.finish(
async move { exec::screenshot_async(&gpu, &doc, &req).await },
async move { exec::screenshot_async(&gpu, &doc, &cats, &req).await },
move |w, r: ToolResult| {
let reply = match r {
Ok(out) => Reply::ok(id, out),
@@ -706,6 +712,12 @@ impl Web {
fn frame(&mut self) {
let now = now_ms();
self.pump_calls();
if let Some((act, keys)) = self.st.pending_action.take() {
let mut runs = std::mem::take(&mut self.runs);
crate::ui::actions::run(self, &mut runs, act, &keys, crate::project::format::now_ms());
self.runs = runs;
self.dirty = true;
}
let dir = self
.session
.project
@@ -716,7 +728,7 @@ impl Web {
dir.map(|p| p.dir.as_path()),
&self.ed,
);
self.dirty |= self.st.sync_runs(&mut self.runs);
self.dirty |= self.st.sync_runs(&mut self.runs, &self.ed);
if self.relay.tick(now) {
self.dirty = true;
}
+323
View File
@@ -0,0 +1,323 @@
//! What agents see and steer: the viewport (`get_basic_info`/`get_selection`, `set_viewport`),
//! screenshots with notes, `annotate {tidy}` and the CLI's version check and live tool list.
use papercraft::editor::InputEvent;
use papercraft::geom::v2;
use papercraft::mcp::{Out, exec};
use papercraft::model::NodeId;
use papercraft::project::Store;
use serde_json::{Value, json};
struct Fixture {
host: exec::SessionHost,
path: std::path::PathBuf,
}
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("papercraft-agent-view-{:016x}", fastrand::u64(..)));
let mut host = exec::SessionHost::new(Store::open(&path).unwrap());
host.session.create(&mut host.ed, "View").unwrap();
Self { host, path }
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value {
let o = exec::call(h, name, &args).unwrap_or_else(|e| panic!("{name} failed: {e}"));
match o.into_iter().next() {
Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(Value::String(t)),
_ => Value::Null,
}
}
fn artboard(h: &mut exec::SessionHost, name: &str, x: f64) -> Value {
call(h, "create_artboard", json!({"name": name,
"styles": {"left": format!("{x}px"), "top": "0px", "width": "300px", "height": "200px"}}))["id"].clone()
}
fn num(id: &Value) -> NodeId {
NodeId(id.as_str().unwrap().rsplit(':').next().unwrap().parse().unwrap())
}
#[test]
fn viewport_is_reported_by_basic_info_and_selection() {
let mut f = Fixture::new();
let h = &mut f.host;
h.ed.camera.pan = v2(100.0, 50.0);
h.ed.camera.zoom = 2.0;
let page = h.ed.pages[0].id.clone();
let want = json!({"pageId": page, "rect": [100.0, 50.0, h.ed.viewport.x / 2.0, h.ed.viewport.y / 2.0], "zoom": 2.0});
assert_eq!(call(h, "get_basic_info", json!({}))["viewport"], want);
assert_eq!(call(h, "get_selection", json!({}))["viewport"], want);
}
#[test]
fn set_viewport_switches_page_fits_and_zooms() {
let mut f = Fixture::new();
let h = &mut f.host;
let a = artboard(h, "A", 0.0);
let b = call(h, "create_page", json!({"name": "B"}))["pageId"].clone();
let on_b = call(h, "create_artboard", json!({"pageId": b, "name": "Far",
"styles": {"left": "5000px", "top": "5000px", "width": "100px", "height": "50px"}}))["id"].clone();
assert_eq!(h.ed.active_page, 0);
// Another page's node: select_nodes refuses and points at set_viewport, which switches.
let e = exec::call(h, "select_nodes", &json!({"nodeIds": [on_b]})).unwrap_err();
assert!(e.contains("set_viewport"), "{e}");
let v = call(h, "set_viewport", json!({"nodeIds": [on_b]}))["viewport"].clone();
assert_eq!(h.ed.active_page, 1);
assert_eq!(v["pageId"], b);
let r = h.ed.doc.world_rect(num(&on_b)).unwrap();
let vis = h.ed.camera.visible_world(h.ed.viewport);
assert!(vis.contains_rect(&r), "{vis:?} fits {r:?}");
assert!(h.ed.camera.zoom > 1.0, "a small node fits above the 1.0 cap: {}", h.ed.camera.zoom);
// Explicit zoom centres the rect at that level, beyond select_nodes' cap.
let first = h.ed.pages[0].id.clone();
call(h, "set_viewport", json!({"pageId": first, "nodeIds": [a], "zoom": 12}));
assert_eq!(h.ed.active_page, 0);
assert_eq!(h.ed.camera.zoom, 12.0);
let c = h.ed.doc.world_rect(num(&a)).unwrap().center();
let vis = h.ed.camera.visible_world(h.ed.viewport);
assert!((vis.center().x - c.x).abs() < 1e-6 && (vis.center().y - c.y).abs() < 1e-6);
// A world rect, and zoom limits.
call(h, "set_viewport", json!({"rect": [-100, -100, 200, 200], "padding": 0}));
let vis = h.ed.camera.visible_world(h.ed.viewport);
assert!(vis.contains_rect(&papercraft::geom::Rect::new(-100.0, -100.0, 200.0, 200.0)));
call(h, "set_viewport", json!({"zoom": 100000}));
assert_eq!(h.ed.camera.zoom, papercraft::geom::MAX_ZOOM);
assert!(exec::call(h, "set_viewport", &json!({"rect": [1, 2]})).is_err());
}
#[test]
fn set_viewport_waits_while_the_user_moves_the_canvas() {
let mut f = Fixture::new();
let h = &mut f.host;
artboard(h, "A", 0.0);
h.ed.handle(InputEvent::Scroll { delta: v2(0.0, 40.0), pos: v2(10.0, 10.0) });
let cam = h.ed.camera;
let e = exec::call(h, "set_viewport", &json!({"zoom": 3})).unwrap_err();
assert!(e.contains("user is moving the canvas"), "{e}");
assert_eq!(h.ed.camera, cam);
call(h, "set_viewport", json!({"zoom": 3, "force": true}));
assert_eq!(h.ed.camera.zoom, 3.0);
}
#[test]
fn annotate_tidy_without_notes_reports_moves() {
let mut f = Fixture::new();
let h = &mut f.host;
let id = artboard(h, "Sign in", 0.0);
call(h, "annotate", json!({"notes": [{"node": id, "text": "One"}, {"node": id, "text": "Two"}]}));
assert_eq!(call(h, "annotate", json!({"tidy": true})), json!({"tidied": 0}));
let note = h.ed.doc.annotations[0].id;
h.ed.update_annotation(note, |a| a.position = v2(2000.0, 2000.0));
assert_eq!(call(h, "annotate", json!({"tidy": true})), json!({"tidied": 1}));
}
#[test]
fn notes_in_screenshots_grow_the_bounds_to_the_cards() {
use papercraft::model::annotation::card_world;
use papercraft::model::annotation_layout::card_size;
let mut f = Fixture::new();
let h = &mut f.host;
let id = artboard(h, "Card", 0.0);
let other = artboard(h, "Other", 2000.0);
call(h, "annotate", json!({"notes": [
{"node": id, "text": "Primary action\nOnly one per view"},
{"node": id, "text": "Second note", "type": "content"},
{"node": other, "text": "Not captured"},
]}));
let doc = h.ed.page_doc(0).clone();
let roots = [num(&id)];
let (noted, shot) = exec::with_notes(&doc, &roots, &h.ed.design.categories());
let b = papercraft::io::svg::export_bounds(&noted, &shot).unwrap();
for a in doc.annotations.iter().filter(|a| a.target == Some(num(&id))) {
let (at, s) = (card_world(&doc, a), card_size(a));
let card = papercraft::geom::Rect::new(at.x, at.y, s.x, s.y);
assert!(b.inflate(0.5).contains_rect(&card), "{b:?} holds {card:?}");
}
// Two lines, two dots, two cards; the other frame's note stays out.
assert_eq!(shot.len(), 1 + 6);
assert!(!b.intersects(&doc.world_rect(num(&other)).unwrap()));
assert!(noted.validate().is_ok());
if papercraft::render::gpu::Headless::new().is_err() {
eprintln!("no GPU adapter; skipping the render half");
return;
}
let png = |h: &mut exec::SessionHost, args: Value| match exec::call(h, "get_screenshot", &args).unwrap().remove(0) {
Out::Image { data, .. } => data,
o => panic!("{o:?}"),
};
let plain = png(h, json!({"nodeId": id}));
assert_eq!(plain, png(h, json!({"nodeId": id, "annotations": false})), "off is byte-identical");
let with = image::load_from_memory(&png(h, json!({"nodeId": id, "annotations": true}))).unwrap();
let plain = image::load_from_memory(&plain).unwrap();
assert_eq!((plain.width(), plain.height()), (300, 200));
let dims = (with.width(), with.height());
assert!(dims.0 as f64 >= b.w.floor() && dims.1 as f64 >= b.h.floor() && dims != (300, 200), "{dims:?} for {b:?}");
}
// ------------------------------------------------------------------------------ CLI
mod fake {
use rmcp::model::{
CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation,
ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig,
};
use rmcp::service::RequestContext;
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use rmcp::{ErrorData, RoleServer, ServerHandler};
pub const VERSION: &str = "0.0.1-fake";
pub const NEW_TOOL: &str = "brand_new_tool";
/// An app of another version that serves one tool the CLI's built-in list lacks.
#[derive(Clone)]
pub struct App;
impl ServerHandler for App {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("PaperCraft", VERSION))
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
let mut tools = papercraft::mcp::server::tool_list();
tools.push(
serde_json::from_value(serde_json::json!({"name": NEW_TOOL,
"description": "Only the app has it.",
"inputSchema": {"type": "object", "properties": {}}}))
.unwrap(),
);
Ok(ListToolsResult::with_all_items(tools))
}
async fn call_tool(
&self,
_request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResponse, ErrorData> {
Ok(CallToolResult::success(vec![ContentBlock::text("{}")]).into())
}
}
/// Serve [`App`] on a free loopback port; the thread lives until the test process ends.
pub fn spawn() -> u16 {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
rt.block_on(async move {
let service: StreamableHttpService<App, LocalSessionManager> = StreamableHttpService::new(
|| Ok(App),
Default::default(),
StreamableHttpServerConfig::default(),
);
let app = axum::Router::new().nest_service(papercraft::mcp::ENDPOINT, service);
let l = tokio::net::TcpListener::from_std(listener).unwrap();
axum::serve(l, app).await.unwrap();
});
});
port
}
}
#[test]
fn cli_warns_on_version_mismatch_and_lists_the_apps_tools() {
let port = fake::spawn();
let cli = |args: &[&str]| {
std::process::Command::new(env!("CARGO_BIN_EXE_papercraft-cli"))
.env("PAPERCRAFT_MCP_PORT", port.to_string())
.args(args)
.output()
.unwrap()
};
let o = cli(&["call", "get_basic_info"]);
let err = String::from_utf8_lossy(&o.stderr);
let want = format!("papercraft-cli {} \u{2260} app {}; reinstall the CLI", env!("CARGO_PKG_VERSION"), fake::VERSION);
assert_eq!(err.matches(&want).count(), 1, "{err}");
assert!(o.status.success());
let list = String::from_utf8_lossy(&cli(&["list"]).stdout).to_string();
assert!(list.lines().any(|l| l.starts_with(&format!("{}\t", fake::NEW_TOOL))), "{list}");
assert!(papercraft::mcp::server::tool_list().iter().all(|t| t.name != fake::NEW_TOOL));
assert!(cli(&["schema", fake::NEW_TOOL]).status.success());
}
#[test]
fn find_nodes_finds_phrases_inside_text_on_any_page() {
let mut f = Fixture::new();
let h = &mut f.host;
// A component whose text is rich (a bold span) and nested two frames deep.
let main = artboard(h, "Banner", 0.0);
call(h, "write_html", json!({"targetNodeId": main, "mode": "insert-children",
"html": "<div><div><p>Welcome back. <strong>Elena lets you in</strong> when it starts.</p></div></div>"}));
call(h, "create_component", json!({"nodeId": main}));
// On another page, not the active one: an instance and a text broken across lines.
let cand = call(h, "create_page", json!({"name": "Candidate"}))["pageId"].clone();
let screen = call(h, "create_artboard", json!({"pageId": cand, "name": "Lobby",
"styles": {"width": "400px", "height": "300px"}}))["id"].clone();
call(h, "write_html", json!({"pageId": cand, "targetNodeId": screen, "mode": "insert-children",
"html": "<section><div><p>Elena lets\nyou\u{a0}in shortly</p></div></section>"}));
call(h, "create_instance", json!({"pageId": cand, "componentId": main, "parentId": screen}));
assert_eq!(h.ed.active_page, 0);
let found = call(h, "find_nodes", json!({"pageId": cand, "textValue": "elena LETS you in"}));
assert_eq!(found["count"], 2, "{found}");
assert!(found["results"].as_array().unwrap().iter().all(|r| r["pageId"] == cand));
// Whole project, wildcards still anchor to the whole value.
assert_eq!(call(h, "find_nodes", json!({"textValue": "Elena lets you in"}))["count"], 3);
assert_eq!(call(h, "find_nodes", json!({"textValue": "Welcome*starts."}))["count"], 2);
assert_eq!(call(h, "find_nodes", json!({"textValue": "Elena*"}))["count"], 1);
}
#[test]
fn tree_summary_full_text_on_request() {
let mut f = Fixture::new();
let h = &mut f.host;
let ab = artboard(h, "Copy", 0.0);
let long = "This sentence is much longer than forty characters, so it gets cut.";
call(h, "write_html", json!({"targetNodeId": ab, "mode": "insert-children", "html": format!("<p>{long}</p>")}));
let short = call(h, "get_tree_summary", json!({"nodeId": ab}));
assert!(!short.as_str().unwrap().contains(long), "{short}");
let full = call(h, "get_tree_summary", json!({"nodeId": ab, "fullText": true}));
assert!(full.as_str().unwrap().contains(long), "{full}");
}
#[test]
fn cli_online_only_refuses_to_work_offline() {
let home = std::env::temp_dir().join(format!("papercraft-online-{:016x}", fastrand::u64(..)));
std::fs::create_dir_all(&home).unwrap();
// A port nothing listens on: bind, read it, release.
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let cli = |env: Option<&str>, args: &[&str]| {
let mut c = std::process::Command::new(env!("CARGO_BIN_EXE_papercraft-cli"));
c.env("PAPERCRAFT_HOME", &home).env("PAPERCRAFT_MCP_PORT", port.to_string()).env_remove("PAPERCRAFT_CLI_NO_OFFLINE");
if let Some(v) = env {
c.env("PAPERCRAFT_CLI_NO_OFFLINE", v);
}
c.args(args).output().unwrap()
};
for o in [cli(Some("1"), &["call", "list_resources"]), cli(None, &["--online", "call", "list_resources"])] {
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("offline work is off"), "{}", String::from_utf8_lossy(&o.stderr));
}
assert!(cli(None, &["call", "list_resources"]).status.success(), "the default still works offline");
let _ = std::fs::remove_dir_all(&home);
}
+266
View File
@@ -0,0 +1,266 @@
//! Note placement and leader routing on dense layouts: cards never sit on a node or on each
//! other, leader lines avoid other nodes, and Tidy is one undo step.
use papercraft::editor::Editor;
use papercraft::geom::{Rect, V2};
use papercraft::model::annotation::{Annotation, auto_place, card_world, frame_of};
use papercraft::model::annotation_layout::{card_size, obstacles, route, anchor_point, anchor_toward};
use papercraft::model::{Document, Node, NodeId, NodeKind};
fn add(e: &mut Editor, parent: Option<NodeId>, kind: NodeKind, r: Rect) -> NodeId {
e.add_node(Node::new(NodeId(0), kind, r), parent)
}
fn frame(e: &mut Editor, r: Rect) -> NodeId {
add(e, None, NodeKind::Frame { clip: true }, r)
}
const LONG_BODY: &str = "Only one Primary button per view.\nDanger is only for destructive actions and always asks to confirm.\nDisabled buttons keep their label and explain why on hover.\nLoading keeps the width.\nIcon-only buttons need an aria-label.\nMinimum touch target is 44 by 44.\nGroup related buttons with 8 px gaps.\nNever stack more than two.\nSecondary sits left of Primary.\nTertiary is a text link.";
fn note(e: &mut Editor, target: NodeId, label: &str, body: &str) {
let mut a = Annotation::new(Some(target), label, "rule");
a.body = body.into();
auto_place(&e.doc, &mut a);
e.add_annotation(a);
}
/// A 5x5 grid of buttons in one frame, three notes in different rows.
fn button_grid() -> Editor {
let mut e = Editor::default();
let f = frame(&mut e, Rect::new(0.0, 0.0, 760.0, 360.0));
let mut btn = Vec::new();
for row in 0..5 {
for col in 0..5 {
let r = Rect::new(40.0 + col as f64 * 140.0, 40.0 + row as f64 * 60.0, 120.0, 40.0);
btn.push(add(&mut e, Some(f), NodeKind::Rect, r));
}
}
note(&mut e, btn[7], "One Primary button per view, and it is always the rightmost", LONG_BODY);
note(&mut e, btn[13], "Danger only for destructive actions", "Always asks to confirm.\nNever the default.");
note(&mut e, btn[21], "Disabled state", "Keeps its label.");
e
}
/// Design System page: a column of group frames on the left, a dense grid of loose components
/// to the right of it.
fn design_system() -> Editor {
let mut e = Editor::default();
let mut targets = Vec::new();
for g in 0..4 {
let f = frame(&mut e, Rect::new(0.0, g as f64 * 320.0, 300.0, 280.0));
for i in 0..4 {
let r = Rect::new(20.0 + (i % 2) as f64 * 140.0, 20.0 + (i / 2) as f64 * 70.0, 120.0, 50.0);
targets.push(add(&mut e, Some(f), NodeKind::Rect, r));
}
}
for row in 0..8 {
for col in 0..6 {
add(&mut e, None, NodeKind::Rect, Rect::new(360.0 + col as f64 * 150.0, row as f64 * 100.0, 130.0, 80.0));
}
}
for (i, t) in [0, 3, 5, 9, 10, 14].into_iter().enumerate() {
note(&mut e, targets[t], &format!("Component rule number {i}: keep this exact"), "Body line one\nBody line two that is a little longer than the first\nthree");
}
e
}
fn card_rect(doc: &Document, a: &Annotation) -> Rect {
let (at, s) = (card_world(doc, a), card_size(a));
Rect::new(at.x, at.y, s.x, s.y)
}
fn strictly_overlap(a: &Rect, b: &Rect) -> bool {
a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
}
fn path_hits(path: &[V2], r: &Rect) -> bool {
path.windows(2).any(|w| strictly_overlap(&Rect::from_points(w[0], w[1]).inflate(0.5), r))
}
fn check_layout(e: &Editor, name: &str) {
let doc = &e.doc;
let cards: Vec<Rect> = doc.annotations.iter().map(|a| card_rect(doc, a)).collect();
for (i, a) in doc.annotations.iter().enumerate() {
let t = doc.world_rect(a.target.unwrap()).unwrap();
let mut obs = obstacles(doc, a.target);
assert!(!strictly_overlap(&cards[i], &t), "{name}: card {i} on its target");
for o in &obs {
assert!(!strictly_overlap(&cards[i], o), "{name}: card {i} on a node {o:?}");
}
for (j, c) in cards.iter().enumerate().filter(|(j, _)| *j != i) {
assert!(!strictly_overlap(&cards[i], c), "{name}: cards {i} and {j} overlap");
obs.push(*c);
}
let end = anchor_point(t, anchor_toward(t, cards[i]));
let path = route(cards[i], t, end, &obs);
for o in &obs {
assert!(!path_hits(&path, o), "{name}: line {i} crosses {o:?}");
}
}
}
#[test]
fn grid_of_25_buttons_with_3_notes_keeps_clear() {
let e = button_grid();
check_layout(&e, "grid");
}
#[test]
fn design_system_layout_keeps_clear() {
let e = design_system();
check_layout(&e, "design-system");
}
#[test]
fn tidy_is_one_undo_step_and_fixes_a_mess() {
let mut e = design_system();
// Pile every card onto its target.
let ids: Vec<u64> = e.doc.annotations.iter().map(|a| a.id).collect();
for id in &ids {
e.update_annotation(*id, |a| a.position = V2 { x: 5.0, y: 5.0 });
}
let messy = e.doc.clone();
assert_eq!(e.tidy_annotations(None), ids.len());
check_layout(&e, "tidied");
e.undo();
assert_eq!(e.doc, messy, "one undo step restores the mess");
e.redo();
check_layout(&e, "redone");
}
/// A section frame packed with screens 32 apart, notes on small nodes in the screens' middles:
/// no card may sit on any screen's background (the Interview Candidate page).
#[test]
fn notes_on_packed_screens_stay_off_every_screen() {
let mut e = Editor::default();
let section = frame(&mut e, Rect::new(0.0, 0.0, 3.0 * 1472.0 + 32.0, 2.0 * 932.0 + 32.0));
let mut screens = Vec::new();
for row in 0..2 {
for col in 0..3 {
let r = Rect::new(32.0 + col as f64 * 1472.0, 32.0 + row as f64 * 932.0, 1440.0, 900.0);
let s = add(&mut e, Some(section), NodeKind::Frame { clip: true }, r);
let t = add(&mut e, Some(s), NodeKind::Rect, Rect::new(600.0, 400.0, 240.0, 48.0));
note(&mut e, t, "Invite link works only while the invite is valid", "Applies when: candidate opens the link\nEnforced by: server");
screens.push(s);
}
}
e.tidy_annotations(Some(section));
let doc = &e.doc;
for (i, a) in doc.annotations.iter().enumerate() {
let c = card_rect(doc, a);
for s in &screens {
assert!(!strictly_overlap(&c, &doc.world_rect(*s).unwrap()), "card {i} sits on a screen");
}
}
check_layout(&e, "packed-screens");
}
/// A line into the target's own screen goes straight past the screen's header and panels
/// instead of detouring around them.
#[test]
fn line_into_its_screen_takes_no_detour() {
let mut e = Editor::default();
let section = frame(&mut e, Rect::new(0.0, 0.0, 1504.0, 964.0));
let s = add(&mut e, Some(section), NodeKind::Frame { clip: true }, Rect::new(32.0, 32.0, 1440.0, 900.0));
add(&mut e, Some(s), NodeKind::Rect, Rect::new(0.0, 0.0, 1440.0, 120.0));
add(&mut e, Some(s), NodeKind::Rect, Rect::new(0.0, 120.0, 240.0, 780.0));
let t = add(&mut e, Some(s), NodeKind::Rect, Rect::new(600.0, 400.0, 240.0, 48.0));
note(&mut e, t, "Unknown session id shows 'Session not found'", "Trigger: bad id\nResult: error card");
let doc = &e.doc;
let a = &doc.annotations[0];
let (card, tr) = (card_rect(doc, a), doc.world_rect(t).unwrap());
let end = anchor_point(tr, anchor_toward(tr, card));
let path = route(card, tr, end, &papercraft::model::annotation_layout::route_obstacles(doc, t));
let len: f64 = path.windows(2).map(|w| (w[1].x - w[0].x).abs() + (w[1].y - w[0].y).abs()).sum();
let direct = (path[0].x - end.x).abs() + (path[0].y - end.y).abs();
assert!(len <= direct + 1.0, "detour: {len} vs {direct}, {path:?}");
}
/// Writes a schematic PNG (nodes, cards, routes) when PAPERCRAFT_LAYOUT_PNG names a directory.
#[test]
fn schematic_png() {
let Ok(dir) = std::env::var("PAPERCRAFT_LAYOUT_PNG") else { return };
for (name, e, before) in [
("grid", button_grid(), false),
("design-system", design_system(), false),
("grid-before", button_grid(), true),
("design-system-before", design_system(), true),
] {
let doc = &e.doc;
let mut cards: Vec<Rect> = doc.annotations.iter().map(|a| card_rect(doc, a)).collect();
if before {
// The old placement: a fixed 220x72 card right of the top-level frame, level with the
// target, pushed down past earlier cards.
cards.clear();
for a in &doc.annotations {
let t = doc.world_rect(a.target.unwrap()).unwrap();
let fr = doc.world_rect(frame_of(doc, a.target.unwrap()).unwrap()).unwrap();
let mut c = Rect::new(fr.right() + 24.0, t.y, 220.0, 72.0);
while cards.iter().any(|o| strictly_overlap(&c, o)) {
c.y += 84.0;
}
cards.push(c);
}
}
let all: Vec<Rect> = doc.nodes.keys().filter_map(|id| doc.world_rect(*id)).chain(cards.iter().copied()).collect();
let b = Rect::union_all(all.iter()).unwrap().inflate(20.0);
let k = 0.8;
let mut img = image::RgbaImage::from_pixel((b.w * k) as u32, (b.h * k) as u32, image::Rgba([250, 250, 250, 255]));
let mut fill = |r: &Rect, c: [u8; 4], solid: bool| {
let (x0, y0, x1, y1) = (((r.x - b.x) * k) as i64, ((r.y - b.y) * k) as i64, ((r.right() - b.x) * k) as i64, ((r.bottom() - b.y) * k) as i64);
for y in y0..y1 {
for x in x0..x1 {
let edge = x == x0 || y == y0 || x == x1 - 1 || y == y1 - 1;
if (solid || edge) && x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() {
img.put_pixel(x as u32, y as u32, image::Rgba(c));
}
}
}
};
for id in doc.paint_order() {
let leaf = doc.get(id).unwrap().children.is_empty();
fill(&doc.world_rect(id).unwrap(), if leaf { [190, 200, 215, 255] } else { [120, 120, 120, 255] }, leaf);
}
for c in &cards {
fill(c, [255, 224, 150, 255], true);
fill(c, [200, 120, 0, 255], false);
}
for (i, a) in doc.annotations.iter().enumerate() {
let t = doc.world_rect(a.target.unwrap()).unwrap();
fill(&t, [124, 92, 255, 255], false);
let mut obs = obstacles(doc, a.target);
obs.extend(cards.iter().enumerate().filter(|(j, _)| *j != i).map(|(_, c)| *c));
let end = anchor_point(t, anchor_toward(t, cards[i]));
let path = if before { papercraft::model::annotation_layout::elbow(cards[i], end) } else { route(cards[i], t, end, &obs) };
for w in path.windows(2) {
fill(&Rect::from_points(w[0], w[1]).inflate(1.0), [124, 92, 255, 255], true);
}
}
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
}
}
/// Diagnostics on a real page file: PAPERCRAFT_LAYOUT_PAGE=<page.json> PAPERCRAFT_LAYOUT_FRAME=<node number>.
/// Tidies the frame's notes and prints each note's leader length and bends.
#[test]
fn real_page_report() {
let (Ok(path), Ok(frame)) = (std::env::var("PAPERCRAFT_LAYOUT_PAGE"), std::env::var("PAPERCRAFT_LAYOUT_FRAME")) else { return };
let page = papercraft::project::format::page_from_json(&std::fs::read_to_string(path).unwrap()).unwrap();
let mut doc = page.doc;
let frame = NodeId(frame.parse().unwrap());
for (id, p) in papercraft::model::annotation_layout::tidy(&doc, Some(frame)) {
let a = doc.annotations.iter_mut().find(|a| a.id == id).unwrap();
(a.position, a.anchor) = (p.position, p.anchor);
}
let cards: Vec<(u64, Rect)> = doc.annotations.iter().map(|a| (a.id, card_rect(&doc, a))).collect();
for a in doc.annotations.iter().filter(|a| a.target.and_then(|t| frame_of(&doc, t)) == Some(frame)) {
let t = doc.world_rect(a.target.unwrap()).unwrap();
let c = card_rect(&doc, a);
let mut obs = papercraft::model::annotation_layout::route_obstacles(&doc, a.target.unwrap());
obs.extend(cards.iter().filter(|(i, _)| *i != a.id).map(|(_, r)| *r));
let end = anchor_point(t, anchor_toward(t, c));
let path = route(c, t, end, &obs);
let len: f64 = path.windows(2).map(|w| (w[1].x - w[0].x).abs() + (w[1].y - w[0].y).abs()).sum();
println!("{:>6} len {:>6.0} bends {} card ({:.0},{:.0}) target ({:.0},{:.0}) {}", a.id, len, path.len().saturating_sub(2), c.x, c.y, t.x, t.y, a.label);
}
}
+89 -2
View File
@@ -69,7 +69,8 @@ fn frame(h: &mut exec::SessionHost, name: &str) -> Value {
#[test]
fn annotation_batch_by_id_and_name_is_one_undo_step() {
use papercraft::model::annotation::{Author, CARD_H, card_world};
use papercraft::model::annotation::{Author, card_world};
use papercraft::model::annotation_layout::card_size;
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
@@ -83,7 +84,11 @@ fn annotation_batch_by_id_and_name_is_one_undo_step() {
assert_eq!(notes.len(), 2);
assert_eq!(notes[0].body, "For 15 minutes");
assert_eq!(notes[0].author, Author::Agent);
assert!(card_world(&h.ed.doc, &notes[1]).y >= card_world(&h.ed.doc, &notes[0]).y + CARD_H);
let rect = |n: &papercraft::model::annotation::Annotation| {
let (at, s) = (card_world(&h.ed.doc, n), card_size(n));
papercraft::geom::Rect::new(at.x, at.y, s.x, s.y)
};
assert!(!rect(&notes[1]).intersects(&rect(&notes[0])), "cards overlap");
h.ed.undo();
assert_eq!(h.ed.doc, before);
h.ed.redo();
@@ -186,6 +191,7 @@ fn annotation_tool_classification_and_description_budget() {
use papercraft::mcp::tools;
for (name, edits) in [("annotate", true), ("get_annotations", false),
("get_annotation_template", false), ("set_annotation_template", true),
("upsert_annotation_template", true), ("delete_annotation_template", true),
("update_annotation", true), ("delete_annotations", true)] {
assert_eq!(tools::edits_doc(name, &json!({})), edits);
let def = tools::definitions().into_iter().find(|d| d["name"] == name).unwrap();
@@ -364,3 +370,84 @@ fn update_annotation_rejects_type_disabled_by_template() {
call(h, "update_annotation", json!({"ref": "Sign in #1", "type": "data"}));
assert_eq!(h.ed.doc.annotations[0].category, "data");
}
fn err(h: &mut exec::SessionHost, name: &str, args: Value) -> String {
exec::call(h, name, &args).err().unwrap_or_else(|| panic!("{name} should fail"))
}
fn review() -> Value {
json!({"name": "Legal review", "display": "badges", "categories": [
{"key": "legal", "guidance": "Legal risk.", "fields": ["Owner", "Law"]},
{"key": "rule"},
{"key": "data", "enabled": false}]})
}
#[test]
fn upsert_template_creates_category_applies_to_page_and_gates_annotate() {
let mut f = Fixture::new();
let h = &mut f.host;
let id = frame(h, "Sign in");
let undo = h.ed.history.undo_len();
let r = call(h, "upsert_annotation_template", json!({"template": review(), "setAs": "page",
"categories": [{"key": "legal", "name": "Legal", "color": "#aa3355", "icon": "scale"}]}));
assert_eq!(r["id"], "legal-review");
assert_eq!(r["types"], json!([["legal", "Legal risk.", ["Owner", "Law"]], ["rule", "", []]]));
assert_eq!(h.ed.design.category_by_key("legal").unwrap().color.to_hex(), "#aa3355");
assert_eq!(h.ed.history.undo_len(), undo + 2, "edit + page override");
assert_eq!(call(h, "get_annotation_template", json!({}))["template"], "Legal review");
call(h, "annotate", json!({"notes": [{"node": id, "type": "legal", "text": "GDPR\nOwner: me"}]}));
assert_eq!(h.ed.doc.annotations[0].category, "legal");
assert!(err(h, "annotate", json!({"notes": [{"node": id, "type": "data", "text": "x"}]})).contains("legal, rule"));
// update by name: same id, category restyled
let r = call(h, "upsert_annotation_template", json!({"template": {"name": "legal REVIEW", "categories": [{"key": "legal"}]},
"categories": [{"key": "legal", "color": "#112233"}]}));
assert_eq!(r["id"], "legal-review");
assert_eq!(h.ed.design.templates().iter().filter(|t| t.id == "legal-review").count(), 1);
assert_eq!(h.ed.design.category_by_key("legal").unwrap().name, "Legal");
assert_eq!(h.ed.design.category_by_key("legal").unwrap().color.to_hex(), "#112233");
// second template with the same slug gets a unique id; project default
let r = call(h, "upsert_annotation_template", json!({"setAs": "project",
"template": {"id": "x", "name": "Legal Review!", "categories": [{"key": "rule"}]}}));
assert_eq!(r["id"], "x");
assert_eq!(h.ed.design.annotation_template.as_deref(), Some("x"));
}
#[test]
fn upsert_template_is_one_undo_step_and_errors_change_nothing() {
let mut f = Fixture::new();
let h = &mut f.host;
let base = h.ed.design.clone();
let undo = h.ed.history.undo_len();
call(h, "upsert_annotation_template", json!({"template": review(), "setAs": "project",
"categories": [{"key": "legal", "name": "Legal", "color": "#aa3355", "icon": "scale"}]}));
assert_eq!(h.ed.history.undo_len(), undo + 1);
h.ed.undo();
assert_eq!(h.ed.design, base);
let cat = |c: Value| json!({"template": {"name": "T", "categories": [{"key": "rule"}]}, "categories": [c]});
let e = err(h, "upsert_annotation_template", cat(json!({"key": "z", "name": "Z", "color": "#aa3355", "icon": "nope"})));
assert!(e.contains("unknown icon `nope`") && e.contains("scale") && e.contains("zap"), "{e}");
assert!(err(h, "upsert_annotation_template", cat(json!({"key": "z", "name": "Z", "color": "red", "icon": "zap"}))).contains("bad color"));
assert!(err(h, "upsert_annotation_template", cat(json!({"key": "z", "name": "Z", "icon": "zap"}))).contains("color"));
let e = err(h, "upsert_annotation_template", json!({"template": {"name": "T", "categories": [{"key": "ghost"}]}}));
assert!(e.contains("ghost") && e.contains("rule, behaviour"), "{e}");
assert!(err(h, "upsert_annotation_template", json!({"template": {"name": "T", "categories": [{"key": "rule"}]}, "setAs": "x"})).contains("setAs"));
assert_eq!(h.ed.design, base);
assert_eq!(h.ed.history.undo_len(), undo);
}
#[test]
fn delete_template_rules() {
let mut f = Fixture::new();
let h = &mut f.host;
call(h, "set_annotation_template", json!({"template": "Minimal", "scope": "project"}));
assert_eq!(call(h, "delete_annotation_template", json!({"template": "minimal"}))["deleted"], "Minimal");
assert_eq!(h.ed.design.annotation_template, None);
assert!(err(h, "delete_annotation_template", json!({"template": "zzz"})).contains("Product spec"));
for t in ["dev-handoff", "accessibility-review", "content-review"] {
call(h, "delete_annotation_template", json!({"template": t}));
}
call(h, "upsert_annotation_template", json!({"template": {"name": "Mine", "categories": [{"key": "rule"}]}}));
call(h, "delete_annotation_template", json!({"template": "product-spec"}));
assert!(err(h, "delete_annotation_template", json!({"template": "Mine"})).contains("last"));
assert_eq!(h.ed.design.templates().len(), 1);
}
+53
View File
@@ -241,3 +241,56 @@ fn offline_matches_app_placement_and_saves_no_camera() {
let _ = std::fs::remove_dir_all(dir_a.parent().unwrap());
let _ = std::fs::remove_dir_all(dir_b.parent().unwrap());
}
#[test]
fn offline_sessions_lock_the_project_and_never_lose_writes() {
use papercraft::cli::{PROJECT_LOCK_WAIT, lock_project};
let store = temp_store("lock");
let root = store.root.clone();
let (p, _) = store.create("Shared").unwrap();
let id = p.manifest.id.clone();
let open = || Offline::new(Store::open(&root).unwrap(), Some(&id)).unwrap();
let artboard = |o: &mut Offline, name: &str| {
o.call("create_artboard", &json!({"name": name, "styles": {"width": "100px", "height": "100px"}}))
};
// Both load the project; B writes first and finishes, then A writes: B's work survives.
let mut a = open();
let mut b = open();
artboard(&mut b, "From B").unwrap();
drop(b);
artboard(&mut a, "From A").unwrap();
// While A holds the project another writer waits, then fails with a clear message.
let mut c = open();
let t = std::time::Instant::now();
let e = artboard(&mut c, "From C").unwrap_err();
assert!(e.contains("another papercraft-cli is writing"), "{e}");
assert!(t.elapsed() >= PROJECT_LOCK_WAIT);
// Reads never wait.
c.call("get_basic_info", &Value::Null).unwrap();
drop(a);
artboard(&mut c, "From C").unwrap();
let names: Vec<String> = json_of(c.call("get_basic_info", &Value::Null).unwrap())["artboards"]
.as_array()
.unwrap()
.iter()
.map(|v| v["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(names, ["From B", "From A", "From C"]);
drop(c);
let held = lock_project(&p.dir, std::time::Duration::ZERO).unwrap();
assert!(lock_project(&p.dir, std::time::Duration::ZERO).is_err());
drop(held);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn app_lock_marks_the_projects_folder_in_use() {
use papercraft::cli::{app_running, hold_app_lock};
let root = temp_store("applock").root.clone();
assert!(!app_running(&root));
let held = hold_app_lock(&root).unwrap();
assert!(app_running(&root));
drop(held);
assert!(!app_running(&root));
let _ = std::fs::remove_dir_all(&root);
}
+23
View File
@@ -116,3 +116,26 @@ fn duplicating_keeps_cross_page_links_and_gives_copied_mains_fresh_keys() {
assert_eq!(keys.len(), 2);
assert_ne!(keys[0], keys[1], "copied main gets its own key");
}
#[test]
fn write_html_instance_of_another_pages_main_links_instead_of_copying() {
use papercraft::model::Source;
let mut h = host();
let (a, b, comp) = setup(&mut h);
let screen = call(&mut h, "create_artboard", json!({"pageId": b, "name": "Screen",
"styles": {"width": "300px", "height": "200px", "display": "flex"}}));
let before = h.ed.page_doc(1).nodes.len();
call(&mut h, "write_html", json!({"pageId": b, "targetNodeId": screen["id"], "mode": "insert-children",
"html": "<x-paper-instance component=\"Pill\" layer-name=\"Pill / Here\"/>"}));
let doc = h.ed.page_doc(1);
assert!(doc.nodes.values().all(|n| !n.component), "no main copied onto B");
assert_eq!(doc.nodes.len(), before + 1, "only the instance root was added");
let inst = doc.nodes.values().find(|n| n.name == "Pill / Here").unwrap();
let link = inst.instance.as_ref().unwrap();
assert_eq!(link.main, num(&comp));
assert_eq!(link.source, Some(Source::Page(a.as_str().unwrap().to_string())));
let key = Value::String(format!("{}:{}", &b.as_str().unwrap()[..6], inst.id.0));
assert!(bg_red(&h, 1, &key));
call(&mut h, "update_styles", json!({"pageId": a, "updates": [{"nodeIds": [comp], "styles": {"background": "#0000ff"}}]}));
assert!(!bg_red(&h, 1, &key), "the instance follows the main on A");
}
+102
View File
@@ -931,3 +931,105 @@ fn export_design_system_writes_groups_and_round_trips() {
assert_eq!(doc.tokens["space-md"], "16px");
assert_eq!(doc.tokens["font"], "Inter");
}
/// A text colour bound by `update_styles {color: var(--x)}` or `bind_variable text-color` keeps
/// following the variable through `set_variables`, on every page, in both entry shapes.
#[test]
fn text_colour_bindings_survive_set_variables() {
let mut h = host();
call(
&mut h,
"set_variables",
json!({"mode": "Light", "variables": {"color/tag/gold/text": "#8a6d00"}}),
);
call(
&mut h,
"set_variables",
json!({"mode": "Dark", "variables": {"color/tag/gold/text": "#e0c060"}}),
);
let p2 = call(&mut h, "create_page", json!({"name": "Second"}));
let mut texts: Vec<(Value, usize)> = Vec::new(); // (node id, page index)
for (pi, page) in [Value::Null, p2["pageId"].clone()].into_iter().enumerate() {
let mut args = json!({"name": "Screen", "styles": {"width": "200px", "height": "100px"}});
if !page.is_null() {
args["pageId"] = page.clone();
}
let ab = call(&mut h, "create_artboard", args);
let r = call(
&mut h,
"write_html",
json!({"targetNodeId": ab["id"], "mode": "insert-children",
"html": "<div layer-name=\"A\" style=\"font-size:14px\">A</div><div layer-name=\"B\" style=\"font-size:14px\">B</div>"}),
);
let ids = r["createdNodeIds"].as_array().unwrap().clone();
call(
&mut h,
"update_styles",
json!({"updates": [{"nodeIds": [ids[0]], "styles": {"color": "var(--color-tag-gold-text)"}}]}),
);
call(
&mut h,
"bind_variable",
json!({"nodeIds": [ids[1]], "field": "text-color", "variable": "color-tag-gold-text"}),
);
texts.push((ids[0].clone(), pi));
texts.push((ids[1].clone(), pi));
}
let check = |h: &exec::SessionHost, want: Color| {
for (i, (id, pi)) in texts.iter().enumerate() {
let n = h.ed.page_doc(*pi).get(node(id)).unwrap();
let (c, var) = match n.style.fills.last() {
Some(Paint::Solid { color, var }) => (*color, var.clone()),
o => panic!("not solid: {o:?}"),
};
let bound = var.as_deref() == Some("color-tag-gold-text")
|| n.bindings.get("text-color").map(String::as_str) == Some("color-tag-gold-text");
assert!(bound, "text {i} lost its binding (var {var:?}, bindings {:?})", n.bindings);
assert_eq!(c, want, "text {i}");
}
};
call(
&mut h,
"set_variables",
json!({"variables": {"color/tag/gold/text": "#4a3a00"}}),
);
check(&h, Color::rgb(0x4a, 0x3a, 0));
call(
&mut h,
"set_variables",
json!({"variables": {"color/tag/gold/text": {"value": "#2a2000"}}}),
);
check(&h, Color::rgb(0x2a, 0x20, 0));
}
/// Re-colouring a text layer that was bound with `bind_variable text-color` replaces that
/// binding, so a later change of the old variable cannot overwrite the new colour or unlink it.
#[test]
fn update_styles_colour_replaces_a_text_colour_binding() {
let mut h = host();
call(
&mut h,
"set_variables",
json!({"variables": {"old": "#111111", "new": "#222222"}}),
);
let ab = call(
&mut h,
"create_artboard",
json!({"name": "Screen", "styles": {"width": "200px", "height": "100px"}}),
);
let r = call(
&mut h,
"write_html",
json!({"targetNodeId": ab["id"], "mode": "insert-children",
"html": "<div layer-name=\"A\" style=\"font-size:14px\">A</div>"}),
);
let t = r["createdNodeIds"][0].clone();
call(&mut h, "bind_variable", json!({"nodeIds": [t], "field": "text-color", "variable": "old"}));
call(
&mut h,
"update_styles",
json!({"updates": [{"nodeIds": [t], "styles": {"color": "var(--new)"}}]}),
);
call(&mut h, "set_variables", json!({"variables": {"old": "#333333"}}));
assert_eq!(fill(&h, &t), (Color::rgb(0x22, 0x22, 0x22), Some("new".into())));
}
+244
View File
@@ -0,0 +1,244 @@
//! Opening a large project reads the page in view first; the other pages are read in the
//! background and must never be saved empty, stale or half read.
use papercraft::editor::Editor;
use papercraft::geom::Rect;
use papercraft::mcp::{Out, exec};
use papercraft::model::{Color, Document, Node, NodeId, NodeKind};
use papercraft::project::{PageData, Project, Store};
use papercraft::session::Session;
use serde_json::{Value, json};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const BIG_PAGE_NODES: usize = 3000;
fn store() -> Store {
Store::open(std::env::temp_dir().join(format!("papercraft-lazy-{:016x}", fastrand::u64(..))))
.unwrap()
}
fn page(id: &str, name: &str, nodes: usize) -> PageData {
let mut doc = Document {
name: name.into(),
..Default::default()
};
for i in 0..nodes {
let id = doc.alloc_id();
let mut n = Node::new(
id,
NodeKind::Rect,
Rect::new((i % 60) as f64 * 30.0, (i / 60) as f64 * 30.0, 24.0, 24.0),
);
n.name = format!("{name} {i}");
n.style.set_fill(Some(Color::rgb((i % 255) as u8, 90, 160)));
doc.nodes.insert(id, n);
doc.root.push(id);
}
PageData {
id: id.into(),
name: name.into(),
doc,
camera: None,
}
}
/// A saved project (design.json included, so it opens lazily): a one-node first page followed
/// by `big` pages of `BIG_PAGE_NODES` nodes.
fn project(big: usize) -> (Store, PathBuf) {
let s = store();
let mut pages = vec![page("p0", "Small", 1)];
pages.extend((1..=big).map(|i| page(&format!("p{i}"), &format!("Big {i}"), BIG_PAGE_NODES)));
let dir = s.create_with_pages("Lazy", pages).unwrap().dir;
// The first open builds design.json from the pages (eagerly); saving writes it.
let mut ed = Editor::default();
let mut sess = Session::new(s.clone());
sess.open(&mut ed, &dir).unwrap();
sess.save(&mut ed).unwrap();
assert!(dir.join("design.json").is_file());
(s, dir)
}
fn open(s: &Store, dir: &PathBuf) -> (Session, Editor) {
let mut ed = Editor::default();
ed.headless = true;
let mut sess = Session::new(s.clone());
sess.open(&mut ed, dir).unwrap();
(sess, ed)
}
fn page_files(dir: &PathBuf) -> Vec<(String, Vec<u8>)> {
let mut v: Vec<_> = std::fs::read_dir(dir.join("pages"))
.unwrap()
.flatten()
.map(|e| (e.file_name().to_string_lossy().to_string(), std::fs::read(e.path()).unwrap()))
.collect();
v.sort();
v
}
#[test]
fn background_load_gives_the_same_documents_as_a_full_load() {
let (s, dir) = project(3);
let all = Project::open(&dir).unwrap().load_all().unwrap();
let (_sess, mut ed) = open(&s, &dir);
assert!(ed.pages.iter().any(|p| p.pending), "the other pages are not read at open");
assert!(!ed.pages[0].pending, "the page in view is");
// Without any blocking call, the loader's pages arrive on their own.
let t0 = Instant::now();
while ed.loading() {
ed.poll_loading();
assert!(t0.elapsed() < Duration::from_secs(120), "pages never arrived");
std::thread::sleep(Duration::from_millis(5));
}
assert!(!ed.project_dirty(), "reading a page does not make it unsaved");
for (i, p) in all.pages.iter().enumerate() {
let got = ed.page_data(i);
assert_eq!(got.id, p.id);
assert_eq!(got.name, p.name);
assert_eq!(got.doc, p.doc, "page {i}");
assert_eq!(ed.pages[i].modes, all.modes[i], "modes of page {i}");
assert!(!ed.pages[i].pending);
}
}
#[test]
fn open_returns_before_the_large_pages_are_read() {
let (s, dir) = project(3);
let t = Instant::now();
Project::open(&dir).unwrap().load_all().unwrap();
let full = t.elapsed();
let t = Instant::now();
let (_sess, mut ed) = open(&s, &dir);
let lazy = t.elapsed();
eprintln!("open: {lazy:?} lazy, {full:?} full");
assert!(ed.loading());
assert!(lazy * 2 < full, "lazy open {lazy:?} vs full load {full:?}");
ed.finish_loading();
assert!(!ed.loading());
}
#[test]
fn saving_while_pages_load_keeps_the_unread_pages_as_they_are() {
let (s, dir) = project(3);
let before = page_files(&dir);
let (mut sess, mut ed) = open(&s, &dir);
assert!(ed.pages.iter().any(|p| p.pending));
// Edit the page in view and save while the rest is still on its way.
ed.add_node(Node::new(NodeId(0), NodeKind::Ellipse, Rect::new(0.0, 0.0, 9.0, 9.0)), None);
assert!(ed.pages.iter().skip(1).all(|p| p.pending) || !ed.loading());
assert_eq!(sess.save(&mut ed).unwrap(), 1, "only the edited page is written");
let after = page_files(&dir);
for (b, a) in before.iter().zip(&after) {
if b.0 != "p0.json" {
assert_eq!(b, a, "{} was rewritten", b.0);
}
}
let manifest = Project::open(&dir).unwrap().manifest;
assert_eq!(manifest.pages.len(), 4, "the manifest still lists every page");
// Everything survives a reload.
ed.finish_loading();
let all = Project::open(&dir).unwrap().load_all().unwrap();
assert_eq!(all.pages[0].doc.nodes.len(), 2);
for i in 1..4 {
assert_eq!(all.pages[i].doc.nodes.len(), BIG_PAGE_NODES);
assert_eq!(ed.page_doc(i), &all.pages[i].doc);
}
assert!(!ed.project_dirty());
}
#[test]
fn rename_and_delete_of_an_unread_page_are_kept() {
let (s, dir) = project(2);
let (mut sess, mut ed) = open(&s, &dir);
ed.rename_page(1, "Renamed");
ed.delete_page(2);
sess.save(&mut ed).unwrap();
ed.finish_loading();
assert_eq!(ed.pages.len(), 2);
assert_eq!(ed.pages[1].name, "Renamed");
assert_eq!(ed.page_doc(1).name, "Renamed");
assert_eq!(ed.page_doc(1).nodes.len(), BIG_PAGE_NODES, "the real page arrived");
sess.save(&mut ed).unwrap();
let p = Project::open(&dir).unwrap();
assert!(!p.page_path("p2").exists(), "the deleted page's file is gone");
let pages = p.load_all().unwrap().pages;
assert_eq!(pages[1].name, "Renamed");
assert_eq!(pages[1].doc.nodes.len(), BIG_PAGE_NODES);
}
#[test]
fn switching_to_an_unread_page_reads_it_first() {
let (s, dir) = project(2);
let (_sess, mut ed) = open(&s, &dir);
assert!(ed.pages[2].pending);
ed.switch_page(2);
assert_eq!(ed.active_page, 2);
assert_eq!(ed.doc.nodes.len(), BIG_PAGE_NODES);
assert!(!ed.pages[2].pending);
}
fn call(h: &mut exec::SessionHost, name: &str, args: Value) -> Value {
match exec::call(h, name, &args) {
Ok(outs) => match outs.into_iter().next() {
Some(Out::Text(t)) => serde_json::from_str(&t).unwrap_or(Value::String(t)),
_ => Value::Null,
},
Err(e) => panic!("{name} failed: {e}"),
}
}
#[test]
fn an_mcp_call_on_an_unread_page_waits_for_it() {
let (s, dir) = project(2);
let mut h = exec::SessionHost::new(s);
h.session.open(&mut h.ed, &dir).unwrap();
assert!(h.ed.pages[2].pending);
// A read of the last page sees all of it.
let info = call(&mut h, "get_basic_info", json!({"pageId": "p2"}));
let counts: Vec<u64> = info["pages"]
.as_array()
.unwrap()
.iter()
.map(|p| p["nodeCount"].as_u64().unwrap())
.collect();
assert_eq!(counts, [1, BIG_PAGE_NODES as u64, BIG_PAGE_NODES as u64]);
// A write to an unread page lands in the real page, not in an empty stand-in.
let (s2, dir2) = project(2);
let mut h = exec::SessionHost::new(s2);
h.session.open(&mut h.ed, &dir2).unwrap();
assert!(h.ed.pages[2].pending);
call(&mut h, "rename_nodes", json!({"updates": [{"nodeId": "p2:1", "name": "Renamed by agent"}]}));
assert!(!h.ed.pages[2].pending);
assert_eq!(h.ed.page_doc(2).nodes.len(), BIG_PAGE_NODES);
assert_eq!(h.ed.page_doc(2).get(NodeId(1)).unwrap().name, "Renamed by agent");
h.session.save(&mut h.ed).unwrap();
let saved = Project::open(&dir2).unwrap().load_all().unwrap();
assert_eq!(saved.pages[2].doc.nodes.len(), BIG_PAGE_NODES);
assert_eq!(saved.pages[2].doc.get(NodeId(1)).unwrap().name, "Renamed by agent");
}
#[test]
fn a_page_that_cannot_be_read_is_never_written_over() {
let (s, dir) = project(2);
let bad = dir.join("pages/p2.json");
std::fs::write(&bad, "{ not a page").unwrap();
let (mut sess, mut ed) = open(&s, &dir);
ed.finish_loading();
assert!(ed.pages[2].failed.is_some());
assert!(!ed.loading(), "a failed page is not waited for");
ed.switch_page(2);
assert_eq!(ed.active_page, 0, "cannot switch to a page that is not there");
// An edit through the stand-in is not saved either.
ed.with_page(2, |e| {
e.add_node(Node::new(NodeId(0), NodeKind::Rect, Rect::new(0.0, 0.0, 5.0, 5.0)), None)
});
ed.add_node(Node::new(NodeId(0), NodeKind::Rect, Rect::new(0.0, 0.0, 5.0, 5.0)), None);
sess.save(&mut ed).unwrap();
assert_eq!(std::fs::read_to_string(&bad).unwrap(), "{ not a page");
}
+67 -1
View File
@@ -422,6 +422,10 @@ fn screenshot_returns_png() {
// ------------------------------------------------------------------------------ end to end
/// Held while a test relies on a port staying unused, and while `spawn_app` binds one: the OS
/// may hand a just-freed ephemeral port to the next `bind(0)` in this process.
static PORT_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Serve MCP on a free port from a thread that owns a headless session, like the app does.
/// The worker hands back the final document and undo depth when the server stops.
fn spawn_app() -> (
@@ -435,6 +439,7 @@ fn spawn_app() -> (
(h.ed.doc.clone(), h.ed.history.undo_len())
});
let tx = std::sync::Mutex::new(tx);
let _bind = PORT_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let running = mcp::server::spawn(
0,
std::sync::Arc::new(move |c| tx.lock().unwrap().send(c).is_ok()),
@@ -615,7 +620,8 @@ fn stdio_bridge_results_carry_result_type_for_2026_clients() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stdio_bridge_lists_tools_without_the_app() {
// A port nothing listens on.
// A port nothing listens on: no other test may bind one until this test is done.
let _guard = PORT_GUARD.lock().unwrap_or_else(|e| e.into_inner());
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
@@ -777,3 +783,63 @@ fn delete_pages_guards_layers_and_the_last_page() {
.unwrap_err();
assert!(err.contains("at least one"), "{err}");
}
/// Two MCP sessions editing at once, plus a chat's CLI backend (`?chat=<id>` on the URL): each
/// gets its own run with its own counts, and reverting one leaves the others' nodes.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn parallel_sessions_get_their_own_runs() {
use papercraft::runs::{self, RUN_IDLE_SECS};
let (tx, rx) = std::sync::mpsc::channel::<mcp::Call>();
let worker = std::thread::spawn(move || {
let mut h = host();
let mut t = runs::Tracker::default();
while let Ok(c) = rx.recv() {
let now = papercraft::project::format::now_ms();
let _ = c.reply.send(t.call(&mut h, &c.client, &c.name, &c.args, now));
}
let dir = h.session.project.as_ref().unwrap().dir.clone();
let later = papercraft::project::format::now_ms() + RUN_IDLE_SECS * 1000 + 1;
t.tick(later, Some(&dir), &h.ed);
let all = runs::list(&dir);
// Revert B's run alone.
if let Some(mut rb) = all.iter().find(|r| r.client == "Agent B").cloned() {
runs::revert(&dir, &mut h.ed, &mut rb).unwrap();
}
let names: Vec<String> = h.ed.doc.root.iter().map(|id| h.ed.doc.get(*id).unwrap().name.clone()).collect();
(all, names)
});
let tx = std::sync::Mutex::new(tx);
let running = {
let _bind = PORT_GUARD.lock().unwrap_or_else(|e| e.into_inner());
mcp::server::spawn(0, std::sync::Arc::new(move |c| tx.lock().unwrap().send(c).is_ok())).unwrap()
};
let connect = |name: &'static str, url: String| async move {
let info = rmcp::model::ClientConfig::new(
Default::default(),
rmcp::model::Implementation::new(name, "1"),
);
info.serve(rmcp::transport::StreamableHttpClientTransport::from_uri(url))
.await
.unwrap()
};
let a = connect("Agent A", running.url()).await;
let b = connect("Agent B", running.url()).await;
let chat = connect("Claude Code", format!("{}?chat=c1", running.url())).await;
let frame = |name: &str| tool("create_artboard", json!({"name": name, "styles": {"width": "100px", "height": "100px"}}));
// Interleaved: A, B, A, chat.
for (client, name) in [(&a, "A1"), (&b, "B1"), (&a, "A2"), (&chat, "C1")] {
text_of(&client.call_tool(frame(name)).await.unwrap());
}
for c in [a, b, chat] {
c.cancel().await.unwrap();
}
running.stop();
let (all, names) = worker.join().unwrap();
let by = |client: &str| all.iter().find(|r| r.client == client).unwrap_or_else(|| panic!("no run of {client}: {all:?}")).clone();
let (ra, rb, rc) = (by("Agent A"), by("Agent B"), by("Chat"));
assert_eq!(all.len(), 3);
assert_eq!((ra.calls, ra.added, ra.changed, ra.removed), (2, 2, 0, 0));
assert_eq!((rb.calls, rb.added), (1, 1));
assert_eq!((rc.calls, rc.added), (1, 1));
assert_eq!(names, ["A1", "A2", "C1"], "only B's frame is gone");
}
+3 -1
View File
@@ -72,7 +72,9 @@ fn layer_search_filters_rows() {
assert!(names.iter().any(|n| n.contains("progress")));
st.layer_filter.clear();
run(&ctx, &mut e, &mut st);
assert_eq!(st.layer_rows.len(), all);
// The list is back (rows far outside the 400px window are not built, so how many are
// is not the point).
assert!(st.layer_rows.len() >= all);
}
/// Every Design-tab control runs on a document with variables, modes, tokens, feedback and a
+19 -3
View File
@@ -23,6 +23,7 @@ Options:
Environment:
CARGO_TARGET_DIR Build directory (default: $HOME/Library/Caches/PaperCraft/build).
PAPERCRAFT_SIGN_IDENTITY Code-signing identity (default: "PaperCraft Local" when present, else ad hoc).
PAPERCRAFT_APP_PATH Install destination (default: /Applications/PaperCraft.app).
The previous bundle is kept next to the new one as PaperCraft.app.backup-<timestamp>.
@@ -96,7 +97,7 @@ main() {
if [ "$skip_build" -eq 0 ]; then
echo "==> building papercraft $version ($target_triple)"
(cd "$ROOT" && CARGO_TARGET_DIR="$target_dir" MACOSX_DEPLOYMENT_TARGET=11.0 \
cargo build --release --locked --bin papercraft --target "$target_triple")
cargo build --release --locked --bin papercraft --bin papercraft-cli --target "$target_triple")
else
echo "==> skipping the build (--skip-build)"
if [ ! -f "$bin" ]; then
@@ -121,9 +122,16 @@ main() {
printf 'APPL????' >"$staged_app/Contents/PkgInfo"
plutil -lint "$staged_app/Contents/Info.plist" >/dev/null
codesign --force --sign - --timestamp=none --options runtime \
# A stable identity keeps macOS privacy grants (e.g. removable volumes) across rebuilds;
# ad-hoc signing ("-") makes every build a new app that asks again.
local sign_id="${PAPERCRAFT_SIGN_IDENTITY:-}"
if [ -z "$sign_id" ] && security find-identity -p codesigning 2>/dev/null | grep -q '"PaperCraft Local"'; then
sign_id="PaperCraft Local"
fi
sign_id="${sign_id:--}"
codesign --force --sign "$sign_id" --timestamp=none --options runtime \
--entitlements "$PKG_DIR/entitlements.plist" "$staged_app/Contents/MacOS/$APP_NAME"
codesign --force --sign - --timestamp=none --options runtime \
codesign --force --sign "$sign_id" --timestamp=none --options runtime \
--entitlements "$PKG_DIR/entitlements.plist" "$staged_app"
codesign --verify --strict --deep --verbose=2 "$staged_app"
echo "==> signed and verified $staged_app"
@@ -195,6 +203,14 @@ main() {
echo "==> installed $dest"
# Keep the CLI agents call in step with the app (a stale one hides newer tools).
local cli="${bin%/papercraft}/papercraft-cli"
if [ -f "$cli" ]; then
mkdir -p "$HOME/.local/bin"
cp "$cli" "$HOME/.local/bin/papercraft-cli"
echo "==> installed $HOME/.local/bin/papercraft-cli"
fi
if [ "$no_launch" -eq 0 ]; then
open "$dest"
echo "==> launched"