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:
+2
-2
@@ -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
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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, .. } =
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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
@@ -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 ¬ed {
|
||||
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 ¬ed {
|
||||
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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
pub mod annotation;
|
||||
pub mod annotation_layout;
|
||||
pub mod document;
|
||||
pub mod node;
|
||||
pub mod paint;
|
||||
|
||||
+24
-3
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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", ¬es, T0 + GAP + 2).unwrap();
|
||||
t.call(&mut h, &Client::default(), "annotate", ¬es, 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
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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, ¬es, &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
@@ -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 {
|
||||
|
||||
@@ -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, ¬es, &[], 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, ¬es, &[], 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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(¬ed, &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);
|
||||
}
|
||||
@@ -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
@@ -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, ¬es[1]).y >= card_world(&h.ed.doc, ¬es[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(¬es[1]).intersects(&rect(¬es[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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user