diff --git a/Cargo.lock b/Cargo.lock index e34ead70..4bdc1ab5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,34 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.23" @@ -804,6 +832,7 @@ dependencies = [ "eframe", "egui", "log", + "muda", "rfd", "serde_json", ] @@ -860,6 +889,7 @@ dependencies = [ "drawcraft-format", "drawcraft-geom", "drawcraft-pathops", + "drawcraft-pdf", "drawcraft-render", "drawcraft-svg", "drawcraft-text", @@ -1006,9 +1036,11 @@ version = "0.1.0" dependencies = [ "drawcraft-color", "drawcraft-doc", + "drawcraft-effects", "drawcraft-engine", "drawcraft-format", "drawcraft-geom", + "drawcraft-pdf", "drawcraft-render", "drawcraft-svg", "drawcraft-text", @@ -1985,6 +2017,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2194,6 +2236,25 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +dependencies = [ + "crossbeam-channel", + "dpi", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.18.1", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + [[package]] name = "naga" version = "30.0.1" @@ -2627,6 +2688,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-channel" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab" +dependencies = [ + "crossbeam-channel", +] + [[package]] name = "ordered-float" version = "5.5.0" @@ -3048,6 +3118,26 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "read-fonts" version = "0.39.2" @@ -3707,6 +3797,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -4041,9 +4140,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", + "crossbeam-channel", "glifo 0.3.0", "hashbrown 0.17.1", + "ordered-channel", "png 0.18.1", + "rayon", + "thread_local", "vello_common 0.2.0", ] diff --git a/apps/drawcraft/Cargo.toml b/apps/drawcraft/Cargo.toml index 7671f72e..9c4110c0 100644 --- a/apps/drawcraft/Cargo.toml +++ b/apps/drawcraft/Cargo.toml @@ -19,3 +19,6 @@ log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +muda = { version = "0.21", default-features = false } diff --git a/apps/drawcraft/src/main.rs b/apps/drawcraft/src/main.rs index 3457d9d3..ad561577 100644 --- a/apps/drawcraft/src/main.rs +++ b/apps/drawcraft/src/main.rs @@ -7,14 +7,25 @@ //! See `drawcraft_ui_egui::control` for the methods. mod control_server; +#[cfg(target_os = "macos")] +mod native_menu; use drawcraft_engine::Session; use drawcraft_ui_egui::{DrawcraftApp, Services}; -struct App(DrawcraftApp); +struct App(DrawcraftApp, #[cfg(target_os = "macos")] Option); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + #[cfg(target_os = "macos")] + { + if self.1.is_none() && std::env::var_os("DRAWCRAFT_NO_NATIVE_MENU").is_none() { + self.1 = Some(native_menu::NativeMenu::install(&mut self.0)); + } + if let Some(m) = &mut self.1 { + m.poll(&mut self.0); + } + } self.0.logic(ctx); if self.0.ui.status == "quit" { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -83,7 +94,11 @@ fn main() -> eframe::Result { eprintln!("drawcraft: {f}: {e}"); } } - Ok(Box::new(App(app))) + Ok(Box::new(App( + app, + #[cfg(target_os = "macos")] + None, + ))) }), ) } diff --git a/apps/drawcraft/src/native_menu.rs b/apps/drawcraft/src/native_menu.rs new file mode 100644 index 00000000..beb70965 --- /dev/null +++ b/apps/drawcraft/src/native_menu.rs @@ -0,0 +1,128 @@ +//! macOS: DrawCraft's menu tree as the native system menu bar (like Illustrator on the Mac). +//! Items dispatch through the same command path as the in-window menus; enablement, check marks +//! and dynamic labels ("Undo Move") are refreshed a few times per second. + +use std::collections::HashMap; +use std::str::FromStr; + +use drawcraft_ui_egui::DrawcraftApp; +use drawcraft_ui_egui::menus::{self, Item}; +use muda::accelerator::Accelerator; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use serde_json::Value; + +enum Handle { + Plain(MenuItem), + Check(CheckMenuItem), +} + +pub struct NativeMenu { + _menu: Menu, + items: HashMap, + last_refresh: f64, +} + +/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they +/// don't steal keys from text fields). +fn accel(sc: &str) -> Option { + if !(sc.contains("Cmd") || sc.contains("Ctrl") || sc.contains("Alt") || sc.starts_with('F')) { + return None; + } + Accelerator::from_str(&sc.replace("Cmd", "CMD").replace("Alt", "ALT").replace("Shift", "SHIFT").replace("Ctrl", "CTRL")).ok() +} + +impl NativeMenu { + pub fn install(app: &mut DrawcraftApp) -> Self { + let menu = Menu::new(); + let mut items = HashMap::new(); + let mut counter = 0usize; + for (title, entries) in menus::menu_tree() { + let sub = Submenu::new(title, true); + build(app, &sub, &entries, &mut items, &mut counter); + let _ = menu.append(&sub); + } + menu.init_for_nsapp(); + app.native_menu = true; + app.native_shortcuts = + items.values().filter(|(c, p, _)| p.is_null() && menus::shortcut_of(c).and_then(accel).is_some()).map(|(c, _, _)| c.clone()).collect(); + Self { _menu: menu, items, last_refresh: 0.0 } + } + + /// Dispatch clicked items and refresh state. + pub fn poll(&mut self, app: &mut DrawcraftApp) { + while let Ok(ev) = MenuEvent::receiver().try_recv() { + if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { + let p = if params.is_null() { serde_json::json!({}) } else { params.clone() }; + menus::invoke(app, cmd, p); + } + } + let now = drawcraft_ui_egui::now_ms(); + if now - self.last_refresh < 250.0 { + return; + } + self.last_refresh = now; + for (cmd, params, h) in self.items.values() { + let en = menus::enabled(app, cmd); + match h { + Handle::Plain(i) => { + i.set_enabled(en); + if matches!( + cmd.as_str(), + "edit.undo" + | "edit.redo" + | "view.outline" + | "view.rulers" + | "view.guides" + | "view.grid" + | "view.edges" + | "view.artboards" + | "view.boundingBox" + | "view.transparencyGrid" + ) { + i.set_text(menus::dynamic_label(app, cmd, "")); + } + } + Handle::Check(c) => { + c.set_enabled(en); + c.set_checked(menus::checked(app, cmd, params).unwrap_or(false)); + } + } + } + } +} + +fn build(app: &DrawcraftApp, parent: &Submenu, entries: &[Item], items: &mut HashMap, counter: &mut usize) { + for e in entries { + match e { + Item::Sep => { + let _ = parent.append(&PredefinedMenuItem::separator()); + } + Item::Header(h) => { + let _ = parent.append(&MenuItem::new(*h, false, None)); + } + Item::Todo(label, sc) => { + let _ = parent.append(&MenuItem::new(*label, false, accel(sc))); + } + Item::Sub(label, children) => { + let sub = Submenu::new(*label, true); + build(app, &sub, children, items, counter); + let _ = parent.append(&sub); + } + Item::Cmd(label, cmd, params) => { + *counter += 1; + let id = format!("dc{counter}"); + let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None }; + let handle = if menus::checked(app, cmd, params).is_some() { + let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc); + let _ = parent.append(&c); + Handle::Check(c) + } else { + let i = MenuItem::with_id(id.clone(), *label, true, sc); + let _ = parent.append(&i); + Handle::Plain(i) + }; + items.insert(id, (cmd.to_string(), params.clone(), handle)); + } + } + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index e25df036..6a1ba86f 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -18,6 +18,7 @@ drawcraft-render = { workspace = true } drawcraft-pathops = { workspace = true } drawcraft-svg = { workspace = true } drawcraft-format = { workspace = true } +drawcraft-pdf = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1455189c..ebb091e8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,13 +15,13 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64} → {index}; .drawcraft, .svg (PDF/.ai via drawcraft-pdf when available)", + "{path} or {name, dataBase64} → {index}; .drawcraft, .svg, .pdf, .ai (PDF-compatible)", always, open ), cmd!("document.save", "Save Document", [], None, "{path?} native .drawcraft (default: the document's path) → {path}", has_doc, save), - cmd!(query "document.serialize", "Serialize Document", [], None, "{format: drawcraft|svg} → {dataBase64 | text}", has_doc, serialize), - cmd!("document.export", "Export Document", [], None, "{format: svg|png|drawcraft, path, scale?: 1, artboard?: 0}", has_doc, export), + cmd!(query "document.serialize", "Serialize Document", [], None, "{format: drawcraft|svg|pdf|png} → {dataBase64 | text}", has_doc, serialize), + cmd!("document.export", "Export Document", [], None, "{format: svg|png|pdf|drawcraft, path, scale?: 1, artboard?: 0}", has_doc, export), cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch), ] } @@ -37,6 +37,12 @@ fn load(name: &str, bytes: &[u8]) -> Result { d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); return Ok(d); } + if lower.ends_with(".pdf") || lower.ends_with(".ai") || bytes.starts_with(b"%PDF") { + let r = drawcraft_pdf::import_with_report(bytes, &drawcraft_pdf::ImportOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?; + let mut d = r.document; + d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); + return Ok(d); + } Err(EngineError::Other(format!("unsupported file `{name}`"))) } @@ -74,6 +80,7 @@ pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Ok(match format { "drawcraft" => drawcraft_format::save(doc, true), "svg" => drawcraft_svg::export(doc, &drawcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(), + "pdf" => drawcraft_pdf::export(doc, &drawcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?, "png" => { let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; drawcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), false).to_png() diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index e425a74f..8413d834 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -78,3 +78,12 @@ fn save_and_open_path() { assert!(std::fs::read_to_string(&svg).unwrap().contains("= 2); +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 260fb235..088847ce 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -20,5 +20,8 @@ vello_common = { version = "0.2", default-features = false } image = { workspace = true } log = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +vello_cpu = { workspace = true, features = ["multithreading"] } + [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/examples/bench.rs b/crates/render/examples/bench.rs new file mode 100644 index 00000000..57d47dc0 --- /dev/null +++ b/crates/render/examples/bench.rs @@ -0,0 +1,60 @@ +//! `cargo run --release -p drawcraft-render --example bench` — render timing on synthetic documents. + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Appearance, Document, Node}; +use drawcraft_geom::{Affine, Point, Rect, shapes}; +use drawcraft_render::{RenderOptions, Renderer}; + +fn rng(seed: &mut u64) -> f64 { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + (*seed % 1_000_000) as f64 / 1_000_000.0 +} + +fn main() { + let n: usize = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(20_000); + let mut d = Document::new(1600.0, 1200.0); + let l = d.layers[0].id; + let mut seed = 42u64; + for i in 0..n { + let x = rng(&mut seed) * 1600.0; + let y = rng(&mut seed) * 1200.0; + let r = 3.0 + rng(&mut seed) * 22.0; + let c = Color::rgb(rng(&mut seed) as f32, rng(&mut seed) as f32, rng(&mut seed) as f32); + let id = d.alloc_id(); + let mut node = match i % 3 { + 0 => Node::path( + id, + shapes::ellipse(Rect::from_center_size(Point::new(x, y), (2.0 * r, 2.0 * r))), + Appearance::basic(Paint::solid(c), Paint::solid(Color::BLACK), 0.5), + ), + 1 => Node::path(id, shapes::rectangle(Rect::new(x, y, x + 2.0 * r, y + r)), Appearance::basic(Paint::solid(c), Paint::None, 0.0)), + _ => Node::path(id, shapes::star(Point::new(x, y), r, r / 2.0, 5, 0.0), Appearance::basic(Paint::None, Paint::solid(c), 2.0)), + }; + if i % 3 == 1 { + node.opacity = 0.8; + } + d.insert(Some(l), usize::MAX, node).unwrap(); + } + let mut r = Renderer::new(); + let opts = RenderOptions::default(); + for (label, w, h, view) in [ + ("fit 2880x1800", 2880u32, 1800u32, Affine::scale(1.6)), + ("zoom 400% 2880x1800", 2880, 1800, Affine::scale(6.4) * Affine::translate((-600.0, -400.0))), + ("thumb 400x300", 400, 300, Affine::scale(0.25)), + ] { + r.render(&d, w, h, view, &opts); // warm up + let t = std::time::Instant::now(); + let iters = 5; + for _ in 0..iters { + r.render(&d, w, h, view, &opts); + } + println!( + "{n} objects, {label}: {:.1} ms/frame (drawn {}, culled {})", + t.elapsed().as_secs_f64() * 1000.0 / iters as f64, + r.stats.drawn, + r.stats.culled + ); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index de0d702a..087c3c69 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -83,8 +83,26 @@ impl Rendered { } } +/// Cached per-node geometry, keyed by `Arc` identity. Structural sharing means an unchanged node +/// keeps its allocation across edits, so a pointer match (with the Arc kept alive here so the +/// address can't be reused) is an exact cache hit — no invalidation logic needed. +struct GeomEntry { + node: Arc, + bounds: Option, + path: Option>, + stamp: u64, +} + /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). pub struct Renderer { + /// Single-threaded context used when the document has raster filters (vello's filters require it). + ctx_st: Option, + /// Worker threads for the multithreaded rasterizer (0 = single-threaded). + pub threads: u16, + geom: HashMap, + stamp: u64, + /// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer). + alpha: f32, ctx: Option, resources: Resources, images: HashMap>, @@ -117,7 +135,17 @@ struct Frame<'a> { impl Renderer { pub fn new() -> Self { - Self { ctx: None, resources: Resources::new(), images: HashMap::new(), stats: FrameStats::default() } + Self { + ctx_st: None, + threads: default_threads(), + geom: HashMap::new(), + stamp: 0, + alpha: 1.0, + ctx: None, + resources: Resources::new(), + images: HashMap::new(), + stats: FrameStats::default(), + } } /// Render `doc` into a `width`×`height` image using `view` (document → pixel transform). @@ -125,12 +153,21 @@ impl Renderer { let start = now(); let w = width.clamp(1, u16::MAX as u32) as u16; let h = height.clamp(1, u16::MAX as u32) as u16; - let mut ctx = match self.ctx.take() { + // Raster filters (drop shadow, glows, blur) need the single-threaded pipeline. + let mut has_filters = false; + doc.walk(|n| { + if !has_filters && !n.appearance.effects.is_empty() && !drawcraft_effects::raster_effects(&n.appearance.effects).is_empty() { + has_filters = true; + } + }); + let threads = if has_filters { 0 } else { self.threads }; + let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() }; + let mut ctx = match slot { Some(mut c) if c.width() == w && c.height() == h => { c.reset(); c } - _ => RenderContext::new(w, h), + _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), }; self.stats = FrameStats::default(); let inv = view.inverse(); @@ -150,13 +187,23 @@ impl Renderer { ctx.fill_rect(&ab.rect); } } + self.stamp += 1; for layer in &doc.layers { - self.draw_node(&mut ctx, &frame, layer, false); + self.draw_arc(&mut ctx, &frame, layer); + } + // Drop cache entries not seen for a few frames. + let g = self.stamp; + if self.geom.len() > 1024 { + self.geom.retain(|_, e| g - e.stamp <= 3); } ctx.flush(); let mut pm = Pixmap::new(w, h); ctx.render(&mut pm, &mut self.resources); - self.ctx = Some(ctx); + if threads == 0 { + self.ctx_st = Some(ctx); + } else { + self.ctx = Some(ctx); + } self.stats.micros = now().saturating_sub(start); Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() } } @@ -186,11 +233,91 @@ impl Renderer { Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() }) } + /// Cached cull bounds of a node (containers union their cached children). + fn bounds_of(&mut self, a: &Arc) -> Option { + let key = Arc::as_ptr(a) as usize; + if let Some(e) = self.geom.get_mut(&key) + && Arc::ptr_eq(&e.node, a) + { + e.stamp = self.stamp; + return e.bounds; + } + let b = match &a.kind { + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => { + let mut acc: Option = None; + for c in children { + if c.visible { + acc = drawcraft_geom::union_opt(acc, self.bounds_of(c)); + } + } + acc + } + _ => fx::cull_bounds(a), + }; + self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp }); + b + } + + /// Cached BezPath of a path node. + fn path_of(&mut self, a: &Arc) -> Option> { + let key = Arc::as_ptr(a) as usize; + if let Some(e) = self.geom.get(&key) + && Arc::ptr_eq(&e.node, a) + && let Some(p) = &e.path + { + return Some(p.clone()); + } + let p = Arc::new(a.path_data()?.to_bezpath()); + let bounds = self.bounds_of(a); + self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp }); + Some(p) + } + + fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { + if !a.visible || f.opts.hidden.contains(&a.id) { + return; + } + match self.bounds_of(a) { + Some(b) => { + let pad = f.px * 2.0; + if !rects_overlap(b.inflate(pad, pad), f.visible) { + self.stats.culled += 1; + return; + } + // Level of detail: leaves smaller than a quarter pixel are invisible. + if !a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25 { + self.stats.culled += 1; + return; + } + } + None if !a.is_container() => return, + None => {} + } + // Fast path for plain paths: cached geometry, opacity folded into the paint. + if let NodeKind::Path { rule, guide: false, .. } = &a.kind + && !f.opts.outline + && a.blend == drawcraft_color::BlendMode::Normal + && !a.isolate + && !fx::has_fx(a) + && (a.opacity >= 1.0 || painted_items(a) == 1) + && let Some(bp) = self.path_of(a) + { + self.alpha = a.opacity.clamp(0.0, 1.0); + self.draw_shape(ctx, f, a, &bp, *rule); + self.alpha = 1.0; + self.stats.drawn += 1; + return; + } + self.draw_node(ctx, f, a, true); + } + fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) { if !force && (!n.visible || f.opts.hidden.contains(&n.id)) { return; } - if let Some(b) = fx::cull_bounds(n) { + if force { + // Bounds were already checked by draw_arc (or the caller wants it drawn regardless). + } else if let Some(b) = fx::cull_bounds(n) { let pad = f.px * 2.0; if !rects_overlap(b.inflate(pad, pad), f.visible) { self.stats.culled += n.count(); @@ -211,7 +338,7 @@ impl Renderer { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => { for c in children { - self.draw_node(ctx, f, c, false); + self.draw_arc(ctx, f, c); } } NodeKind::Group { children, clip: true } => { @@ -222,7 +349,7 @@ impl Renderer { ctx.set_fill_rule(peniko::Fill::NonZero); ctx.push_clip_layer(&clip); for c in children.iter().skip(1) { - self.draw_node(ctx, f, c, false); + self.draw_arc(ctx, f, c); } ctx.pop_layer(); } @@ -297,6 +424,7 @@ impl Renderer { } ctx.set_transform(f.view); if paint::set_paint(ctx, &fl.paint, bounds, f.doc) { + self.fold_alpha(ctx, &fl.paint); ctx.set_fill_rule(fill_rule(rule)); ctx.fill_path(bp); } @@ -314,6 +442,16 @@ impl Renderer { } } + /// Multiply the folded object opacity into a solid paint. + fn fold_alpha(&self, ctx: &mut RenderContext, p: &drawcraft_color::Paint) { + if self.alpha < 1.0 + && let drawcraft_color::Paint::Solid { color, .. } = p + { + let [r, g, b, a] = color.to_rgba8(self.alpha); + ctx.set_paint(peniko::Color::from_rgba8(r, g, b, a)); + } + } + fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { let layered = st.opacity < 1.0 || st.blend != drawcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside; if layered { @@ -357,6 +495,7 @@ impl Renderer { ctx.push_clip_layer(bp); } if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) { + self.fold_alpha(ctx, &st.paint); ctx.stroke_path(bp); } if inside { @@ -470,6 +609,34 @@ impl Renderer { } } +/// Number of visible painted fill/stroke items (opacity folding is exact only for one). +fn painted_items(n: &Node) -> usize { + n.appearance + .items + .iter() + .filter(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && matches!(f.paint, drawcraft_color::Paint::Solid { .. }), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, drawcraft_color::Paint::Solid { .. }) && s.dash.is_none(), + }) + .count() + .max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None))) { 2 } else { 0 }) +} + +/// Rasterizer worker threads: 0 on wasm; otherwise up to 4 (vello's sweet spot), leaving a core free. +fn default_threads() -> u16 { + #[cfg(target_arch = "wasm32")] + { + 0 + } + #[cfg(not(target_arch = "wasm32"))] + { + if std::env::var_os("DRAWCRAFT_RENDER_THREADS").is_some() { + return std::env::var("DRAWCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0); + } + std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(4) as u16).unwrap_or(0) + } +} + fn rects_overlap(a: Rect, b: Rect) -> bool { a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index dc508e92..f764c135 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,8 @@ drawcraft-text = { workspace = true } drawcraft-tools = { workspace = true } drawcraft-engine = { workspace = true } drawcraft-svg = { workspace = true } +drawcraft-effects = { workspace = true } +drawcraft-pdf = { workspace = true } drawcraft-format = { workspace = true } egui = { workspace = true } egui_extras = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d5bc96e8..8370665d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -694,6 +694,21 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) { } } // Bounding box with handles (Selection tool). + // Live corner widgets (Direct Selection on a single live rectangle). + if tool == "directSelection" + && st.selection.len() == 1 + && let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = + st.doc.node(st.selection.objects[0]).map(|n| &n.kind) + { + let color = c32(st.doc.layer_color(st.selection.objects[0])); + let inset = (radii[0].max(10.0 / xf.zoom)).min(w.min(*h) / 2.0); + for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] { + let sp = xf.to_screen(*lxf * Point::new(cx, cy)); + p.circle_filled(sp, 3.0, Color32::WHITE); + p.circle_stroke(sp, 3.0, Stroke::new(1.0, color)); + p.circle_filled(sp, 1.0, color); + } + } if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() { let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return }; let color = c32(st.doc.layer_color(st.selection.objects[0])); @@ -705,20 +720,6 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) { p.rect_filled(hr, 0.0, Color32::WHITE); p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); } - // Live corner widgets on single live rectangles. - if st.selection.len() == 1 - && let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = - st.doc.node(st.selection.objects[0]).map(|n| &n.kind) - && r.width() > 40.0 - && r.height() > 40.0 - { - let inset = (radii[0].max(20.0 / xf.zoom)).min(w.min(*h) / 2.0); - for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] { - let sp = xf.to_screen(*lxf * Point::new(cx, cy)); - p.circle_stroke(sp, 3.5, Stroke::new(1.0, color)); - p.circle_filled(sp, 1.3, color); - } - } } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 7a05ab3a..7d0f22da 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -25,7 +25,12 @@ pub fn app_bar(app: &mut DrawcraftApp, ui: &mut Ui) { app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}))); } ui.add_space(2.0); - menus::menu_bar(app, ui); + if app.native_menu { + let full = ui.max_rect(); + ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "DrawCraft", egui::FontId::proportional(13.5), t.text); + } else { + menus::menu_bar(app, ui); + } let full = ui.max_rect(); let right = egui::Rect::from_min_max(egui::pos2(full.right() - 340.0, full.top()), full.right_bottom()); let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center))); @@ -244,7 +249,6 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) { pub fn status_bar(app: &mut DrawcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - hint_bar(app, ui); egui::Panel::bottom("status_bar") .exact_size(24.0) .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d63823df..0c0bb071 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -45,6 +45,51 @@ fn origin_params(d: &Dialog, mut p: Value) -> Value { p } +/// Effect parameters from the dialog fields (drop UI-only keys). +fn effect_params(d: &Dialog) -> Value { + Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect()) +} + +/// Generic editor for an effect dialog: numbers, booleans, strings and colours. +fn effect_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let mut changed = false; + egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + let keys: Vec<(String, Value)> = + d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in keys { + ui.label(egui::RichText::new(humanize(&k)).color(t.text)); + match v { + Value::Number(n) => { + let mut x = n.as_f64().unwrap_or(0.0); + let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 }; + if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() { + d.fields.insert(k, json!(x)); + changed = true; + } + } + Value::Bool(mut b) => { + if ui.checkbox(&mut b, "").changed() { + d.fields.insert(k, json!(b)); + changed = true; + } + } + Value::String(mut s) => { + if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() { + d.fields.insert(k, json!(s)); + changed = true; + } + } + other => { + ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0)); + } + } + ui.end_row(); + } + }); + changed +} + fn title(kind: &str) -> &'static str { match kind { "newDocument" => "New Document", @@ -75,6 +120,20 @@ fn title(kind: &str) -> &'static str { pub fn confirm(app: &mut DrawcraftApp) -> Result { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; let copy = d.bool("copy"); + if d.kind == "effect" { + let effect = d.str("__effect"); + let params = effect_params(&d); + let r = if app.session.in_interaction() { + // Live preview already applied: keep it (the interaction commits as one undo step). + let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params})); + app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string()) + } else { + app.run("effect.apply", json!({"effect": effect, "params": params})) + }; + app.last_effect = Some((effect, params)); + app.ui.dialog = None; + return r; + } let r = match d.kind.as_str() { "newDocument" => { let r = app.run("file.new", json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()})); @@ -168,7 +227,7 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { let mut ok = false; let mut cancel = false; egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| { - ui.painter().rect_filled(ctx.content_rect(), 0.0, egui::Color32::from_black_alpha(90)); + // Illustrator's dialogs are modal but don't dim the canvas (previews stay readable). ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); }); egui::Window::new(title(&d.kind)) @@ -181,9 +240,24 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22))) .show(ctx, |ui| { ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 }); - ui.label(egui::RichText::new(title(&d.kind)).font(theme::semibold(16.0)).color(t.text)); + let heading = if d.kind == "effect" { d.str("__label") } else { title(&d.kind).to_string() }; + ui.label(egui::RichText::new(heading).font(theme::semibold(16.0)).color(t.text)); ui.add_space(12.0); match d.kind.as_str() { + "effect" => { + let changed = effect_fields(ui, &mut d); + ui.add_space(6.0); + let mut pv = d.bool("preview"); + let pv_changed = ui.checkbox(&mut pv, "Preview").changed(); + d.fields.insert("preview".into(), json!(pv)); + if pv && (changed || pv_changed || !app.session.in_interaction()) { + let label = d.str("__label"); + let _ = app.session.begin_interaction(&label); + let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": effect_params(&d)})); + } else if !pv && pv_changed { + let _ = app.session.cancel_interaction(); + } + } "newDocument" => new_document(ui, &mut d), "allTools" => { for g in drawcraft_tools::TOOL_GROUPS { @@ -234,8 +308,12 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) { if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" { ok = true; } + let is_effect = d.kind == "effect"; app.ui.dialog = Some(d); if cancel { + if is_effect { + let _ = app.session.cancel_interaction(); + } app.ui.dialog = None; } else if ok && let Err(e) = confirm(app) { app.status(e); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index f5f23cfb..22f79296 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -94,6 +94,14 @@ pub fn open_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8], path: Option app.status(format!("Opened with {} warning(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default())); } (d, false) + } else if e == "pdf" || e == "ai" || bytes.starts_with(b"%PDF") { + let drawcraft_pdf::ImportReport { document: mut d, warnings } = + drawcraft_pdf::import_with_report(bytes, &drawcraft_pdf::ImportOptions::default()).map_err(|e| e.to_string())?; + d.title = name.to_string(); + if !warnings.is_empty() { + app.status(format!("Opened with {} note(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default())); + } + (d, false) } else if ["png", "jpg", "jpeg", "gif", "webp"].contains(&e.as_str()) { (image_doc(name, bytes)?, false) } else { @@ -173,6 +181,7 @@ pub fn export(app: &mut DrawcraftApp, format: &str, path: Option, scale: }; let bytes = match format { "svg" => drawcraft_svg::export(&doc, &drawcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(), + "pdf" => drawcraft_pdf::export(&doc, &drawcraft_pdf::PdfOptions::default()).map_err(|e| e.to_string())?, "png" | "jpg" | "jpeg" => { let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?; let img = app.canvas.renderer.render_region(&doc, r, scale, format != "png"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4021406..cad3f223 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -95,6 +95,12 @@ pub struct DrawcraftApp { pub perf: Perf, /// macOS: draw our own title strip under the traffic lights. pub integrated_titlebar: bool, + /// The host installed a native menu bar (macOS): don't draw in-window menus. + pub native_menu: bool, + /// Last applied effect (Effect → Apply Last Effect). + pub last_effect: Option<(String, serde_json::Value)>, + /// Commands whose shortcuts the native menu handles (skip them in egui to avoid double firing). + pub native_shortcuts: std::collections::HashSet, control_rx: Option>, pending_screenshots: Vec<(u64, Option, Sender)>, queued_screenshots: Vec<(u64, f64, u32)>, @@ -129,6 +135,9 @@ impl DrawcraftApp { }, perf: Perf::default(), integrated_titlebar: false, + native_menu: false, + last_effect: None, + native_shortcuts: Default::default(), control_rx: None, pending_screenshots: vec![], queued_screenshots: vec![], @@ -372,6 +381,7 @@ impl DrawcraftApp { } if self.ui.status_bar && self.ui.screen_mode < 3 { chrome::status_bar(self, ui); + chrome::hint_bar(self, ui); } if self.ui.toolbar && self.ui.screen_mode < 3 { toolbar::show(self, ui); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7b22b6fc..511efe0c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -88,6 +88,9 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.newWindow", "New Window", "", "{}"), ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), ("tool.setOption", "Tool Option", "", "{key, value}"), + ("effect.dialog", "Effect…", "", "{effect: id} open the effect's dialog with live preview"), + ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), + ("file.export.pdf", "Save as PDF…", "", "{path?}"), ("help.about", "About DrawCraft", "", "{}"), ("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"), ("app.quit", "Quit DrawCraft", "Cmd+Q", "{}"), @@ -276,6 +279,25 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option { + let id = s("effect").unwrap_or_default(); + match drawcraft_effects::effect_catalog().into_iter().find(|e| e.id == id) { + Some(e) => { + let mut fields = e.defaults.as_object().cloned().unwrap_or_default(); + fields.insert("__effect".into(), json!(e.id)); + fields.insert("__label".into(), json!(e.label.trim_end_matches('…'))); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields }); + Ok(Value::Null) + } + None => Err(format!("unknown effect `{id}`")), + } + } + "effect.applyLast" => match app.last_effect.clone() { + Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})), + None => Err("no effect applied yet".into()), + }, + "file.export.pdf" => io::export(app, "pdf", s("path"), 1.0).map(|p| json!({"path": p})), "help.about" => { app.ui.about = true; Ok(Value::Null) @@ -324,7 +346,7 @@ pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option { } /// Label for toggles whose text flips (Outline/Preview, Hide/Show …). -fn dynamic_label(app: &DrawcraftApp, id: &str, label: &'static str) -> String { +pub fn dynamic_label(app: &DrawcraftApp, id: &str, label: &str) -> String { let v = &app.ui.view; match id { "view.outline" => if v.outline { "Preview" } else { "Outline" }.into(), @@ -368,6 +390,9 @@ pub fn enabled(app: &DrawcraftApp, id: &str) -> bool { | "view.fitAll" | "view.actualSize" => app.session.active().is_some(), "file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()), + "effect.dialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "effect.applyLast" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), + "file.export.pdf" => app.session.active().is_some(), _ => true, } } @@ -526,7 +551,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Show All", "object.showAll"), Sep, todo("Expand…"), - todo("Expand Appearance"), + c("Expand Appearance", "effect.expandAppearance"), todo("Crop Image"), todo("Rasterize…"), todo("Create Gradient Mesh…"), @@ -708,81 +733,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { todo("Edit Selection…"), ], ), - ( - "Effect", - vec![ - todos("Apply Last Effect", "Cmd+Shift+E"), - todos("Last Effect", "Cmd+Alt+Shift+E"), - Sep, - todo("Document Raster Effects Settings…"), - Sep, - Item::Header("Illustrator Effects"), - sub("3D and Materials", vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]), - sub("Convert to Shape", vec![todo("Rectangle…"), todo("Rounded Rectangle…"), todo("Ellipse…")]), - todo("Crop Marks"), - sub( - "Distort & Transform", - vec![ - todo("Free Distort…"), - todo("Pucker & Bloat…"), - todo("Roughen…"), - todo("Transform…"), - todo("Tweak…"), - todo("Twist…"), - todo("Zig Zag…"), - ], - ), - sub("Path", vec![todo("Offset Path…"), todo("Outline Object"), todo("Outline Stroke")]), - sub( - "Pathfinder", - vec![ - todo("Add"), - todo("Intersect"), - todo("Exclude"), - todo("Subtract"), - todo("Minus Back"), - Sep, - todo("Divide"), - todo("Trim"), - todo("Merge"), - todo("Crop"), - todo("Outline"), - ], - ), - todo("Rasterize…"), - sub( - "Stylize", - vec![todo("Drop Shadow…"), todo("Feather…"), todo("Inner Glow…"), todo("Outer Glow…"), todo("Round Corners…"), todo("Scribble…")], - ), - sub("SVG Filters", vec![todo("Apply SVG Filter…"), todo("Import SVG Filter…")]), - sub( - "Warp", - vec![ - todo("Arc…"), - todo("Arc Lower…"), - todo("Arc Upper…"), - todo("Arch…"), - todo("Bulge…"), - todo("Shell Lower…"), - todo("Shell Upper…"), - todo("Flag…"), - todo("Wave…"), - todo("Fish…"), - todo("Rise…"), - todo("Fisheye…"), - todo("Inflate…"), - todo("Squeeze…"), - todo("Twist…"), - ], - ), - Sep, - Item::Header("Raster Effects"), - todo("Effect Gallery…"), - sub("Blur", vec![todo("Gaussian Blur…"), todo("Radial Blur…"), todo("Smart Blur…")]), - sub("Pixelate", vec![todo("Color Halftone…"), todo("Crystallize…"), todo("Mezzotint…"), todo("Pointillize…")]), - sub("Stylize", vec![todo("Glowing Edges…")]), - ], - ), + ("Effect", effect_menu()), ( "View", vec![ @@ -1092,6 +1043,54 @@ pub fn menu_entries(app: &DrawcraftApp) -> Vec { out } +/// The Effect menu, built from the effects catalogue (Illustrator Effects), plus raster effects. +fn effect_menu() -> Vec { + let cat = drawcraft_effects::effect_catalog(); + let mut out = vec![ + c("Apply Last Effect", "effect.applyLast"), + todos("Last Effect", "Cmd+Alt+Shift+E"), + Sep, + todo("Document Raster Effects Settings…"), + Sep, + Item::Header("Illustrator Effects"), + ]; + // Submenus in Illustrator's order. + let order = ["3D and Materials", "Convert to Shape", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"]; + for sub_name in order { + let items: Vec = cat + .iter() + .filter(|e| e.menu.last().copied() == Some(sub_name)) + .map(|e| Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))) + .collect(); + if sub_name == "Blur" { + if !items.is_empty() { + out.push(Sep); + out.push(Item::Header("Raster Effects")); + out.push(sub(sub_name, items)); + } + continue; + } + if items.is_empty() { + let placeholder = match sub_name { + "3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")], + "Pathfinder" => vec![todo("Add"), todo("Intersect"), todo("Exclude"), todo("Subtract")], + "SVG Filters" => vec![todo("Apply SVG Filter…")], + _ => continue, + }; + out.push(sub(sub_name, placeholder)); + } else { + out.push(sub(sub_name, items)); + } + } + // Anything not placed above (future effects) still shows up. + for e in cat.iter().filter(|e| !e.menu.last().is_some_and(|m| order.contains(m))) { + out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); + } + out.push(Sep); + out.push(c("Expand Appearance", "effect.expandAppearance")); + out +} + #[cfg(test)] mod tests { use super::*; @@ -1131,3 +1130,4 @@ mod tests { assert_eq!(pretty_shortcut(""), ""); } } + diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 24f576bf..2595e9da 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -200,8 +200,8 @@ pub fn transform_section(app: &mut DrawcraftApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); } ui.add_space(6.0); - egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 5.0]).show(ui, |ui| { - let fw = 78.0; + let fw = ((ui.available_width() - 44.0) / 2.0).clamp(60.0, 110.0); + egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { dim_label(ui, "X:"); if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) { app.run("object.setBounds", json!({"x": v, "reference": refi})).ok(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0b773c16..885a3b10 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -130,6 +130,9 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { if plain && (sc.logical_key.name().len() == 1 || sc.logical_key == Key::Slash) { continue; } + if app.native_shortcuts.contains(id) { + continue; + } if ctx.input_mut(|i| i.consume_shortcut(&sc)) { fire = Some(id); break;