diff --git a/ASSETS.md b/ASSETS.md index 76052b5..bcf2ddc 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -60,10 +60,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | `assets/app-icon/hicolor/512x512/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) | | `assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png` | DesignCraft project owner (drawn in ArtCraft) | rendered from `assets/app-icon/designcraft.svg` by `packaging/icons.sh` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) | | `assets/app-icon/hicolor/scalable/apps/ai.storyteller.designcraft.svg` | DesignCraft project owner (drawn in ArtCraft) | owner's ArtCraft drawing, vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | App icon: calico cat in a polka-dot scarf (see `assets/app-icon/README.md`) | -| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | ArtCraft wordmark (README header) | -| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Wordmark for dark backgrounds | -| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | The mark; its outline is drawn in code by `crates/ui-egui/src/about.rs` (About splash, start screen) | -| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft / Storyteller | ArtCraft brand kit (project owner) | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Mark for light backgrounds | | `docs/brand/LICENSE-brand.txt` | (licence text) | ArtCraft Team | — | Terms for the ArtCraft Marks in `docs/brand/` | | `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the DesignCraft app icon (`assets/app-icon/designcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | DesignCraft macOS DMG window background for the Finder window; `packaging/macos/dmg/generate.py` renders it to `background.tiff` (2026-10-08) | | `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") | diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 0000000..1f258b7 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1 @@ +Community build for designcraft.run, based on DesignCraft v0.8.0 by its upstream contributors. Adds a guarded browser Agent bridge. Original licenses and notices retained; upstream trademark artwork omitted. Not an official upstream build. diff --git a/README.md b/README.md index 0ec6540..9056576 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@

- - - ArtCraft - + ArtCraft upstream project

@@ -299,6 +296,6 @@ Forks and modified versions must remove them.

- ArtCraft
+ ArtCraft upstream project
Made by the ArtCraft team and community.

diff --git a/apps/designcraft-web/Cargo.toml b/apps/designcraft-web/Cargo.toml index b1131c7..a794d36 100644 --- a/apps/designcraft-web/Cargo.toml +++ b/apps/designcraft-web/Cargo.toml @@ -30,4 +30,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget"] } diff --git a/apps/designcraft-web/src/api.rs b/apps/designcraft-web/src/api.rs new file mode 100644 index 0000000..3643b7b --- /dev/null +++ b/apps/designcraft-web/src/api.rs @@ -0,0 +1,130 @@ +//! Community web command bridge for designcraft.run (2026-10-17). +//! Uses the existing UI control queue; no network listener or file-upload service. +use designcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use wasm_bindgen::prelude::*; + +type Waiting = (Receiver, js_sys::Function, js_sys::Function); +thread_local! { + static DIRTY: std::cell::Cell = const { std::cell::Cell::new(false) }; + static TX: RefCell>> = const { RefCell::new(None) }; + static WAITING: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +fn from_js(v: &JsValue) -> Result { + if v.is_null() || v.is_undefined() { + return Ok(json!({})); + } + let text = js_sys::JSON::stringify(v)?.as_string().ok_or_else(|| js_sys::Error::new("parameters must be JSON"))?; + if text.len() > 1_048_576 { + return Err(js_sys::Error::new("request exceeds 1 MiB").into()); + } + let value: Value = serde_json::from_str(&text).map_err(|e| js_sys::Error::new(&e.to_string()))?; + if !value.is_object() { + return Err(js_sys::Error::new("parameters must be an object").into()); + } + Ok(value) +} + +fn request(method: String, params: Value) -> js_sys::Promise { + if !matches!(method.as_str(), "engine.execute" | "agent.execute" | "engine.commands" | "agent.state" | "agent.export") { + return js_sys::Promise::reject(&js_sys::Error::new("unsupported web method")); + } + if WAITING.with(|w| w.borrow().len() >= 32) { + return js_sys::Promise::reject(&js_sys::Error::new("too many pending requests")); + } + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let (req, rx) = ControlRequest::new(method.clone(), params.clone()); + if TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok())) { + WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject))); + } else { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("DesignCraft is not ready")); + } + }); + CTX.with(|c| { + if let Some(ctx) = c.borrow().as_ref() { + ctx.request_repaint(); + } + }); + promise +} + +pub fn poll_replies(dirty: bool) { + DIRTY.with(|d| d.set(dirty)); + WAITING.with(|w| { + w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(v) => { + if v.get("ok").and_then(Value::as_bool) == Some(true) { + match js_sys::JSON::parse(&v.get("result").unwrap_or(&Value::Null).to_string()) { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::NULL, &error); + } + } + } else { + let error = js_sys::Error::new(v.get("error").and_then(Value::as_str).unwrap_or("command failed")); + let _ = reject.call1(&JsValue::NULL, &error); + } + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("editor closed")); + false + } + }) + }); +} + +pub fn install(ctx: egui::Context) -> Result, String> { + let (tx, rx) = std::sync::mpsc::channel(); + TX.with(|t| *t.borrow_mut() = Some(tx)); + CTX.with(|c| *c.borrow_mut() = Some(ctx)); + let obj = js_sys::Object::new(); + let set = |name: &str, value: JsValue| js_sys::Reflect::set(&obj, &name.into(), &value).map_err(|_| format!("could not install {name}")); + set("apiVersion", "craft-online-1".into())?; + set("hasUnsavedChanges", Closure:: bool>::new(|| DIRTY.with(|d| d.get())).into_js_value())?; + set( + "request", + Closure:: js_sys::Promise>::new(|method: String, params: JsValue| match from_js(¶ms) { + Ok(p) => request(method, p), + Err(e) => js_sys::Promise::reject(&e), + }) + .into_js_value(), + )?; + set( + "execute", + Closure:: js_sys::Promise>::new(|command: String, params: JsValue, expected: JsValue| { + let parsed = from_js(¶ms).and_then(|p| { + let mut value = json!({"command": command, "params": p}); + if !expected.is_undefined() && !expected.is_null() { + value["expected"] = from_js(&expected)?; + } + Ok(value) + }); + match parsed { + Ok(p) => request(if p.get("expected").is_some() { "agent.execute" } else { "engine.execute" }.into(), p), + Err(e) => js_sys::Promise::reject(&e), + } + }) + .into_js_value(), + )?; + set("commands", Closure:: js_sys::Promise>::new(|| request("engine.commands".into(), json!({}))).into_js_value())?; + set("inspect", Closure:: js_sys::Promise>::new(|| request("agent.state".into(), json!({}))).into_js_value())?; + let win = web_sys::window().ok_or("no window")?; + use wasm_bindgen::JsCast as _; + let guard = Closure::::new(|event: web_sys::Event| { + if DIRTY.with(|d| d.get()) { + event.prevent_default(); + } + }); + win.add_event_listener_with_callback("beforeunload", guard.as_ref().unchecked_ref()).map_err(|_| "could not install unsaved-work guard")?; + guard.forget(); + js_sys::Reflect::set(&win, &"designcraft".into(), &obj).map_err(|_| "could not install window.designcraft")?; + Ok(rx) +} diff --git a/apps/designcraft-web/src/main.rs b/apps/designcraft-web/src/main.rs index 527baff..bd451ac 100644 --- a/apps/designcraft-web/src/main.rs +++ b/apps/designcraft-web/src/main.rs @@ -19,6 +19,8 @@ #[cfg(any(target_arch = "wasm32", test))] mod browser_policy; +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index fe7cca4..0321e11 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -57,6 +57,9 @@ pub fn start() { } let inbox: Inbox = Inbox::default(); let mut app = DesignApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); + if has_query_flag(&query(), "agent") { + app = app.with_control(crate::api::install(cc.egui_ctx.clone())?); + } if has_query_flag(&query(), "sample") { let _ = app.run("file.newSample", serde_json::json!({})); } @@ -103,6 +106,7 @@ impl eframe::App for WebShell { }); } self.app.logic(ctx); + crate::api::poll_replies(self.app.session.documents().iter().any(|d| d.is_dirty())); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { diff --git a/crates/ui-egui/src/community_agent.rs b/crates/ui-egui/src/community_agent.rs new file mode 100644 index 0000000..e94378a --- /dev/null +++ b/crates/ui-egui/src/community_agent.rs @@ -0,0 +1,277 @@ +//! Community browser Agent: bounded reads and guarded commands on the live layout. +use crate::DesignApp; +use designcraft_doc::{Content, Document, HyperlinkSource, Item, ItemId, Selection, Story}; +use serde_json::{Value, json}; +fn integer(p: &Value, key: &str) -> Result { + p.get(key) + .and_then(Value::as_u64) + .filter(|n| *n <= 9_007_199_254_740_991) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| format!("{key} must be a safe non-negative integer")) +} +fn number(p: &Value, key: &str) -> Result { + p.get(key) + .and_then(Value::as_f64) + .filter(|n| n.is_finite() && n.abs() <= 100000.0) + .ok_or_else(|| format!("{key} must be finite and within 100000 points")) +} +fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str, String> { + p.get(key) + .and_then(Value::as_str) + .filter(|s| s.len() <= 16000 && !s.chars().any(|c| ('\u{e000}'..='\u{f8ff}').contains(&c))) + .ok_or_else(|| format!("{key} must be plain text, at most 16000 UTF-8 bytes")) +} +fn plain_story<'a>(doc: &'a Document, it: &Item) -> Option<&'a Story> { + let Content::Text(f) = &it.content else { return None }; + let s = doc.story(f.story)?; + let linked = doc.stories.values().any(|other| other.link.is_some_and(|(parent, _)| parent == s.id)); + let merge_field = doc + .data_merge + .placeholders + .iter() + .any(|p| matches!(&p.anchor, designcraft_doc::datamerge::PlaceholderAnchor::Text {story,..} if *story==s.id)); + let hyper = doc.hyperlinks.iter().any(|h| matches!(&h.source,HyperlinkSource::Text{story,..} if *story==s.id)); + (s.frames.as_slice() == [it.id] + && s.text.len() <= 16000 + && s.tables.is_empty() + && s.notes.is_empty() + && s.endnotes.is_empty() + && s.anchors.is_empty() + && s.xrefs.is_empty() + && s.index_refs.is_empty() + && s.objects.is_empty() + && s.editorial.is_empty() + && s.link.is_none() + && !linked + && !merge_field + && doc.conditions.is_empty() + && !hyper + && doc.settings.primary_story != Some(s.id) + && !s.text.chars().any(|c| ('\u{e000}'..='\u{f8ff}').contains(&c))) + .then_some(s) +} +fn editable(doc: &Document, it: &Item) -> bool { + !it.locked + && !it.hidden + && it.overrides.is_none() + && it.form_field.is_none() + && it.button.is_none() + && it.live_caption.is_none() + && doc.layer(it.layer).is_some_and(|l| l.visible && !l.locked) + && match it.content { + Content::Unassigned | Content::Graphic(_) => true, + Content::Text(_) => plain_story(doc, it).is_some(), + Content::Group { .. } => false, + } +} +fn rect(it: &Item) -> [f64; 4] { + let r = it.bounds(); + [r.x0, r.y0, r.width(), r.height()] +} +fn fits(pages: &[designcraft_doc::Page], r: [f64; 4]) -> bool { + let [x, y, w, h] = r; + w >= 0.5 && h >= 0.5 && pages.iter().any(|p| x >= p.x && y >= 0.0 && x + w <= p.x + p.width && y + h <= p.height) +} +impl DesignApp { + fn agent_token(&self) -> Result { + if self.ui.dialog.is_some() || self.story_editor.is_some() || self.pending_pdf.is_some() { + return Err("Finish or cancel the dialog or Story Editor before using the Agent.".into()); + } + let d = self.session.active().ok_or("Open or create a DesignCraft document first.")?; + // Upstream runs primary-story autoflow after ANY undoable command; it can add or + // remove pages unrelated to the requested frame. Refuse this mode before billing. + if d.doc.settings.primary_story.is_some() && self.session.prefs.smart_text_reflow { + return Err("Finish or cancel automatic text reflow: disable Smart Text Reflow before using the Agent with a primary story.".into()); + } + if d.interaction.is_some() || d.editing_parents || d.preview_stash.is_some() || d.selection.text.is_some() || d.selection.cells.is_some() { + return Err("Finish or cancel the current edit and return to normal document pages before using the Agent.".into()); + } + Ok(json!({"uid":d.uid.to_string(),"revision":d.revision.to_string()})) + } + fn agent_state(&self, p: &Value) -> Result { + let token = self.agent_token()?; + let d = self.session.active().ok_or("No document")?; + let at = if p.get("spread").is_some() { integer(p, "spread")?.checked_sub(1).ok_or("spread starts at 1")? } else { 0 }; + let sp = d.doc.spreads.get(at).ok_or("spread is outside this document")?; + let start = if p.get("start").is_some() { integer(p, "start")? } else { 0 }; + let count = if p.get("count").is_some() { integer(p, "count")? } else { 20 }; + if !(1..=40).contains(&count) { + return Err("count must be 1–40".into()); + } + let mut budget = 24000usize; + let frames:Vec=sp.items.iter().skip(start).take(count).map(|it|{ + let story=if let Content::Text(f)=&it.content{d.doc.story(f.story)}else{None};let complete=story.is_none_or(|s|s.text.len()<=16000&&s.text.len()<=budget); + let value=story.filter(|_|complete).map(|s|{budget-=s.text.len();s.text.as_str()}); + json!({"id":it.id.0,"name":it.name.chars().take(160).collect::(),"kind":match it.content{Content::Text(_)=>"text",Content::Graphic(_)=>"graphic",Content::Group{..}=>"group",Content::Unassigned=>"shape"},"rect":rect(it),"text":value,"truncated":!complete,"movable":editable(&d.doc,it),"editableText":editable(&d.doc,it)&&complete&&plain_story(&d.doc,it).is_some()}) + }).collect(); + Ok( + json!({"token":token,"document":{"title":d.title().chars().take(200).collect::(),"spreadCount":d.doc.spreads.len(),"pageCount":d.doc.page_count(),"canUndo":!d.history.undo.is_empty(),"dirty":d.is_dirty(),"spread":{"number":at+1,"pages":sp.pages.iter().take(40).enumerate().map(|(i,p)|json!({"number":d.doc.first_page_of_spread(at)+i+1,"rect":[p.x,0.0,p.width,p.height]})).collect::>(),"totalPages":sp.pages.len(),"start":start,"totalFrames":sp.items.len(),"frames":frames}}}), + ) + } + pub(crate) fn community_agent(&mut self, method: &str, p: &Value) -> Result { + let token = self.agent_token()?; + if p.get("expected").is_some_and(|v| v != &token) { + return Err("document changed since inspection; start a new request".into()); + } + if method == "agent.state" { + return self.agent_state(p); + } + if p.get("expected").is_none() { + return Err("expected document state required".into()); + } + if method == "agent.export" { + return self.run("app.exportIdml", json!({})); + } + let cmd = p.get("command").and_then(Value::as_str).ok_or("missing command")?; + let a = p.get("params").filter(|v| v.is_object()).ok_or("params must be an object")?; + let d = self.session.active().ok_or("No document")?; + let previous = d.selection.clone(); + let at = if cmd == "undo" { None } else { Some(integer(a, "spread")?.checked_sub(1).ok_or("spread starts at 1")?) }; + let (native, params, target) = if let Some(at) = at { + let sp = d.doc.spreads.get(at).ok_or("spread is outside this document")?; + match cmd { + "add_text" | "add_shape" => { + if !d.doc.layer(d.active_layer).is_some_and(|l| l.visible && !l.locked) { + return Err("Select a visible unlocked layer before adding a frame.".into()); + } + let r = [number(a, "x")?, number(a, "y")?, number(a, "width")?, number(a, "height")?]; + if !fits(&sp.pages, r) { + return Err("Frame must fit inside one page of this spread.".into()); + } + let [x, y, w, h] = r; + let mut q = json!({"spread":at,"rect":[x,y,x+w,y+h],"content":if cmd=="add_text"{"text"}else{"unassigned"},"caret":false}); + if cmd == "add_text" { + let t = text(a, "text")?; + if t.trim().is_empty() { + return Err("New text must not be empty".into()); + } + q["text"] = json!(t); + } + ("frame.create", q, None) + } + "set_text" | "move_frame" => { + let id = ItemId(integer(a, "id")? as u64); + let it = sp.items.iter().find(|i| i.id == id).ok_or("Frame is not a top-level item in the inspected spread")?; + if !editable(&d.doc, it) { + return Err("Frame is locked, hidden, inherited, grouped or contains unsupported complex text.".into()); + } + if cmd == "set_text" { + let st = plain_story(&d.doc, it).ok_or("Only a plain single-frame story is editable")?; + let value = text(a, "text")?; + if st.text != text(a, "expected_text")? || st.text == value { + return Err("Pass exact expected_text and a changed replacement".into()); + } + ("story.setText", json!({"story":st.id.0,"text":value}), None) + } else { + let (dx, dy) = (number(a, "dx")?, number(a, "dy")?); + let [x, y, w, h] = rect(it); + if !fits(&sp.pages, [x + dx, y + dy, w, h]) { + return Err("Moved frame must fit inside one page of this spread.".into()); + } + ("transform.move", json!({"ids":[id.0],"dx":dx,"dy":dy}), Some(id)) + } + } + _ => return Err("unsupported Agent command".into()), + } + } else { + if d.history.undo.is_empty() { + return Err("There is no edit to undo.".into()); + } + ("edit.undo", json!({}), None) + }; + if let Some(id) = target + && let Some(d) = self.session.active_mut() + { + d.selection = Selection::items(vec![id]); + } + let result = self.run(native, params); + if result.is_err() + && let Some(d) = self.session.active_mut() + { + d.selection = previous; + } + let result = result?; + let read = at.map_or_else(|| json!({}), |n| json!({"spread":n+1})); + Ok(json!({"result":result,"state":self.agent_state(&read)?})) + } +} +#[cfg(test)] +mod tests { + use super::*; + fn app() -> DesignApp { + let mut a = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + a.run("file.new", json!({"width":500,"height":600,"pages":1,"facingPages":false})).unwrap(); + a + } + fn edit(a: &mut DesignApp, c: &str, p: Value) -> Value { + a.community_agent("agent.execute", &json!({"expected":a.agent_token().unwrap(),"command":c,"params":p})).unwrap() + } + #[test] + fn real_story_move_and_undo() { + let mut a = app(); + let added = edit(&mut a, "add_text", json!({"spread":1,"x":40,"y":40,"width":200,"height":100,"text":"Original 中文"})); + let id = added["result"]["id"].clone(); + let stale = a.agent_token().unwrap(); + let changed = edit(&mut a, "set_text", json!({"spread":1,"id":id,"expected_text":"Original 中文","text":"Revised 中文"})); + assert_eq!(changed["state"]["document"]["spread"]["frames"][0]["text"], "Revised 中文"); + assert!(a.community_agent("agent.execute", &json!({"expected":stale,"command":"undo","params":{}})).is_err()); + let moved = edit(&mut a, "move_frame", json!({"spread":1,"id":id,"dx":20,"dy":20})); + assert_eq!(moved["state"]["document"]["spread"]["frames"][0]["rect"][0], 60.0); + edit(&mut a, "undo", json!({})); + let undone = edit(&mut a, "undo", json!({})); + assert_eq!(undone["state"]["document"]["spread"]["frames"][0]["text"], "Original 中文"); + } + #[test] + fn rejects_stale_document_wrong_text_and_locked_layer() { + let mut a = app(); + let added = edit(&mut a, "add_text", json!({"spread":1,"x":40,"y":40,"width":200,"height":100,"text":"Keep"})); + let id = added["result"]["id"].clone(); + let token = a.agent_token().unwrap(); + assert!( + a.community_agent( + "agent.execute", + &json!({"expected":token,"command":"set_text","params":{"spread":1,"id":id,"expected_text":"Wrong","text":"Bad"}}) + ) + .is_err() + ); + let d = a.session.active_mut().unwrap(); + let layer = d.active_layer; + std::sync::Arc::make_mut(&mut d.doc).layer_mut(layer).unwrap().locked = true; + assert!( + a.community_agent( + "agent.execute", + &json!({"expected":a.agent_token().unwrap(),"command":"add_shape","params":{"spread":1,"x":10,"y":10,"width":20,"height":20}}) + ) + .is_err() + ); + a.run("file.new", json!({})).unwrap(); + assert!(a.community_agent("agent.state", &json!({"expected":token})).is_err()); + } + #[test] + fn protects_threaded_stories_and_bounds() { + let mut a = app(); + let added = edit(&mut a, "add_text", json!({"spread":1,"x":40,"y":40,"width":200,"height":100,"text":"Keep"})); + let sid = designcraft_doc::StoryId(added["result"]["story"].as_u64().unwrap()); + let d = a.session.active_mut().unwrap(); + std::sync::Arc::make_mut(&mut d.doc).story_mut(sid).unwrap().frames.push(ItemId(9999)); + assert_eq!(a.agent_state(&json!({})).unwrap()["document"]["spread"]["frames"][0]["editableText"], false); + assert!(a.agent_state(&json!({"count":41})).is_err()); + assert!( + a.community_agent( + "agent.execute", + &json!({"expected":a.agent_token().unwrap(),"command":"add_shape","params":{"spread":1,"x":-10,"y":10,"width":20,"height":20}}) + ) + .is_err() + ); + } + #[test] + fn primary_story_auto_reflow_is_blocked() { + let mut a = app(); + let r = edit(&mut a, "add_text", json!({"spread":1,"x":40,"y":40,"width":200,"height":100,"text":"Keep"})); + let sid = designcraft_doc::StoryId(r["result"]["story"].as_u64().unwrap()); + std::sync::Arc::make_mut(&mut a.session.active_mut().unwrap().doc).settings.primary_story = Some(sid); + assert!(a.agent_token().unwrap_err().contains("Smart Text Reflow")); + a.session.prefs.smart_text_reflow = false; + assert!(a.agent_token().is_ok()); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 15da79d..8d08b6d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -113,6 +113,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { + "agent.state" | "agent.execute" | "agent.export" => wrap(app.community_agent(&req.method, p)), "ui.menu.invoke" if p.get("params").is_none_or(Value::is_null) => { // Exactly like choosing the menu item (commands ending in "…" open their dialog). let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1db2f59..1d2edd1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ pub mod about; pub mod canvas; pub mod chrome; +mod community_agent; pub mod control; pub mod credits; pub mod dialogs;