From 67ac9f1dfb2bfd4d16e6d026bebe5a905fcd26f1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:46:43 -0400 Subject: [PATCH] UI: fidelity pass 2 (Properties sections, spinner fields, task bar, inverse text selection, Pages/Layers panels) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 127 +++ ROADMAP.md | 2 +- crates/engine/src/cmd/object.rs | 31 +- crates/engine/src/tests.rs | 17 + crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/examples/ui_shot.rs | 79 ++ crates/ui-egui/src/canvas.rs | 165 ++- crates/ui-egui/src/chrome.rs | 4 +- crates/ui-egui/src/icons.rs | 285 +++++ crates/ui-egui/src/lib.rs | 17 + crates/ui-egui/src/menus.rs | 5 +- crates/ui-egui/src/panels/layers.rs | 200 +++- crates/ui-egui/src/panels/mod.rs | 13 + crates/ui-egui/src/panels/pages.rs | 238 ++-- crates/ui-egui/src/panels/properties.rs | 1348 ++++++++++++++++++----- crates/ui-egui/src/taskbar.rs | 253 +++++ crates/ui-egui/src/theme.rs | 4 +- crates/ui-egui/src/toolbar.rs | 193 +++- crates/ui-egui/src/widgets.rs | 629 ++++++++++- docs/control-protocol.md | 2 + 20 files changed, 3108 insertions(+), 508 deletions(-) create mode 100644 crates/ui-egui/examples/ui_shot.rs create mode 100644 crates/ui-egui/src/taskbar.rs diff --git a/Cargo.lock b/Cargo.lock index 65113e7..9ef9a37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.4.2" @@ -691,6 +697,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.8" @@ -1028,6 +1044,7 @@ dependencies = [ "designcraft-render", "designcraft-tools", "egui", + "egui_kittest", "log", "serde", "serde_json", @@ -1050,6 +1067,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1218,6 +1248,27 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + [[package]] name = "either" version = "1.18.0" @@ -1517,6 +1568,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1885,6 +1945,25 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.15.0" @@ -2006,6 +2085,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + [[package]] name = "krilla" version = "0.8.2" @@ -2047,6 +2136,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.189" @@ -2616,6 +2711,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "orbclient" version = "0.3.55" @@ -3374,6 +3479,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3721,6 +3835,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/ROADMAP.md b/ROADMAP.md index dbedbd3..2e1dcc0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes. -- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark). +- Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index daf1c45..4c4a42a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -96,6 +96,31 @@ pub fn specs() -> Vec { cmd!("object.unlockAll", "Unlock All on Spread", ["Object"], Some("Cmd+Alt+L"), "{}", has_doc, |s, _| all_flag(s, |i| i.locked = false)), cmd!("object.hide", "Hide", ["Object"], Some("Cmd+3"), "{ids?}", has_selection, |s, p| set_flag(s, p, |i| i.hidden = true, true)), cmd!("object.showAll", "Show All on Spread", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, |s, _| all_flag(s, |i| i.hidden = false)), + cmd!( + "object.setFlags", + "Set Visibility / Lock", + [], + None, + "{ids: [id], hidden?: bool, locked?: bool} — Layers panel eye/lock per object", + has_doc, + |s, p| { + let hidden = p.get("hidden").and_then(Value::as_bool); + let locked = p.get("locked").and_then(Value::as_bool); + set_flag( + s, + p, + move |i| { + if let Some(h) = hidden { + i.hidden = h; + } + if let Some(l) = locked { + i.locked = l; + } + }, + hidden == Some(true) || locked == Some(true), + ) + } + ), cmd!("object.fill", "Fill", [], None, "{swatch, tint?: 0..1, ids?}", has_selection, |s, p| { let sw = str_param(p, "swatch").ok_or_else(|| bad("object.fill", "missing swatch"))?.to_string(); let tint = f64_or(p, "tint", 1.0) as f32; @@ -210,7 +235,7 @@ pub fn specs() -> Vec { "Text Wrap", ["Window"], Some("Cmd+Alt+W"), - "{mode: none|boundingBox|contour|jumpObject|jumpToNextColumn, offset?: number|[t,l,b,r], ids?}", + "{mode: none|boundingBox|contour|jumpObject|jumpToNextColumn, offset?: number|[t,l,b,r], invert?: bool, ids?}", has_selection, |s, p| { let mode = p.get("mode").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(designcraft_doc::WrapMode::BoundingBox); @@ -219,6 +244,7 @@ pub fn specs() -> Vec { Some(v) => serde_json::from_value(v.clone()).ok(), None => None, }; + let invert = p.get("invert").and_then(Value::as_bool); set_flag( s, p, @@ -227,6 +253,9 @@ pub fn specs() -> Vec { if let Some(o) = off { i.wrap.offsets = o; } + if let Some(v) = invert { + i.wrap.invert = v; + } }, false, ) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 46587f5..6386b24 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -283,3 +283,20 @@ fn export_pdf_of_the_sample() { assert!(s.execute("file.exportPdf", &json!({"pages": "9"})).is_err()); assert!(s.execute("file.exportPdf", &json!({"standard": "bogus"})).is_err()); } + +#[test] +fn object_set_flags_and_wrap_invert() { + let mut s = session(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap(); + let id = r["id"].as_u64().unwrap(); + let item = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).cloned().unwrap(); + s.execute("object.setFlags", &json!({"ids": [id], "hidden": true})).unwrap(); + assert!(item(&s).hidden); + s.execute("object.setFlags", &json!({"ids": [id], "hidden": false, "locked": true})).unwrap(); + assert!(!item(&s).hidden && item(&s).locked); + s.execute("object.setFlags", &json!({"ids": [id], "locked": false})).unwrap(); + assert!(!item(&s).locked); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.textWrap", &json!({"mode": "boundingBox", "invert": true})).unwrap(); + assert!(item(&s).wrap.invert); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 3cb2c37..cadfdf0 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,3 +21,7 @@ egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } + +[dev-dependencies] +# Headless (offscreen wgpu) rendering of the whole window for UI screenshots: `examples/ui_shot.rs`. +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs new file mode 100644 index 0000000..465c3e4 --- /dev/null +++ b/crates/ui-egui/examples/ui_shot.rs @@ -0,0 +1,79 @@ +//! Headless UI screenshots: renders the whole DesignCraft window offscreen (wgpu) — works with a +//! locked screen or a hidden window, unlike `ui.screenshot`. +//! +//! `cargo run -p designcraft-ui-egui --example ui_shot -- script.jsonl` +//! +//! The script is JSON lines. Each line is a control-channel request (`{"method": …, "params": …}`, +//! see `docs/control-protocol.md`), or `{"shot": "/abs/out.png"}` to save the window as PNG, or +//! `{"steps": n}` to run extra frames. The window is 1440×900 pt at 2× and opens `file.newSample` +//! (unless the first line is `{"empty": true}`). + +use designcraft_engine::Session; +use designcraft_ui_egui::{ControlRequest, DesignApp, Services}; + +static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +fn main() { + let path = std::env::args().nth(1).expect("usage: ui_shot script.jsonl"); + let script = std::fs::read_to_string(&path).expect("read script"); + let services = Services { + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), + ..Default::default() + }; + let (tx, rx) = std::sync::mpsc::channel::(); + let mut app = DesignApp::new(Session::new(), services).with_control(rx); + app.integrated_titlebar = true; + let lines: Vec = + script.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str(l).expect("json line")).collect(); + if lines.first().is_none_or(|l| l.get("empty").is_none()) { + let _ = app.run("file.newSample", serde_json::json!({})); + } + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(1440.0, 900.0)) + .with_pixels_per_point(2.0) + .with_max_steps(1_000_000) + .wgpu() + .build_ui_state( + |ui, app: &mut DesignApp| { + // The builder runs frames before `max_texture_side` can be raised: wait for it. + if !READY.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + READY.store(true, std::sync::atomic::Ordering::Relaxed); + harness.run_steps(6); + for l in lines { + if let Some(p) = l.get("shot").and_then(|v| v.as_str()) { + harness.run_steps(3); + match harness.render() { + Ok(img) => { + img.save(p).expect("save png"); + println!("{p}"); + } + Err(e) => eprintln!("render failed: {e}"), + } + } else if let Some(n) = l.get("steps").and_then(|v| v.as_u64()) { + harness.run_steps(n as usize); + } else if let Some(m) = l.get("method").and_then(|v| v.as_str()) { + let (req, reply) = ControlRequest::new(m, l.get("params").cloned().unwrap_or_default()); + tx.send(req).expect("send"); + // Pointer/keyboard input is injected one event per frame. + for _ in 0..40 { + harness.step(); + if let Ok(r) = reply.try_recv() { + let s = r.to_string(); + println!("{m}: {}", &s[..s.len().min(300)]); + break; + } + } + harness.run_steps(2); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 241c654..8e66924 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -205,11 +205,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { draw_hidden_characters(app, &painter, &xf, &doc, &layout); } } - draw_selection(app, &painter, &xf, &doc, &layout); + let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout); draw_tool_overlays(app, &painter, &xf); if rulers { draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t); } + if let Some(r) = sel_rect + && !preview + && r.intersects(rect) + { + crate::taskbar::show(app, &ui.ctx().clone(), r, rect); + } // Cursor. if let Some(p) = resp.hover_pos().filter(|p| rect.contains(*p)) { let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text(); @@ -248,8 +254,10 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let Some(st) = app.session.active() else { return }; let ppp = ctx.pixels_per_point() as f64; let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview); - // Overscan: 25% of the viewport on every side. - let (ow, oh) = (rect.width() * 0.25, rect.height() * 0.25); + // Overscan: 25% of the viewport on every side, within the GPU's maximum texture size. + let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32; + let (ow, oh) = + ((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0)); let want = |origin: Point| crate::Shown { doc: doc_key, origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), @@ -515,12 +523,23 @@ fn layer_color(doc: &Document, it: &Item) -> Color32 { doc.layer(it.layer).map(|l| c32(l.color)).unwrap_or(Color32::from_rgb(79, 153, 255)) } -fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { - let Some(st) = app.session.active() else { return }; +/// Draw selection chrome; returns the selection's screen bounds (for the Contextual Task Bar). +fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) -> Option { + let st = app.session.active()?; let sel: &Selection = &st.selection; + let mut text_rect = None; // Text selection / caret. if let Some(ts) = sel.text { draw_text_selection(app, painter, xf, doc, layout, ts); + let fid = ts.frame.or_else(|| { + let cs = app.session.cache.get(doc, ts.story, None); + compose::caret(&cs, ts.focus).and_then(|c| cs.frames.get(c.0)).map(|f| f.frame) + }); + if let Some(fid) = fid + && let (Some(it), Some((a, _))) = (doc.item(fid), item_canvas_xf(doc, layout, fid)) + { + text_rect = Some(xf.rect(a.transform_rect_bbox(it.bounds()))); + } } let mut union: Option = None; let mut color = Color32::from_rgb(79, 153, 255); @@ -610,6 +629,7 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } } } + text_rect.or(union) } /// Port location (canvas): out port at bottom-right, in port at top-left. @@ -663,7 +683,8 @@ fn draw_ports( fn draw_text_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, ts: designcraft_doc::TextSel) { let cs = app.session.cache.get(doc, ts.story, None); let range = ts.range(); - for ft in &cs.frames { + let mut runs: Vec<(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)> = Vec::new(); + for (fi, ft) in cs.frames.iter().enumerate() { let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue }; let Some(it) = doc.item(ft.frame) else { continue }; let m = a * it.xf; @@ -671,24 +692,37 @@ fn draw_text_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: & for poly in path_screen(it, xf, a) { painter.add(egui::Shape::line(poly, Stroke::new(1.0, layer_color(doc, it).gamma_multiply(0.6)))); } - if !range.is_empty() { - for l in &ft.lines { - let s = range.start.max(l.range.start); - let e = range.end.min(l.range.end); - if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) { - continue; + if range.is_empty() { + continue; + } + let mut quads = Vec::new(); + let mut glyphs = Vec::new(); + for (li, l) in ft.lines.iter().enumerate() { + let s = range.start.max(l.range.start); + let e = range.end.min(l.range.end); + if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) { + continue; + } + let x0 = caret_x(l, s); + let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { caret_x(l, e) }; + quads.push([ + Point::new(x0, l.baseline - l.ascent), + Point::new(x1, l.baseline - l.ascent), + Point::new(x1, l.baseline + l.descent), + Point::new(x0, l.baseline + l.descent), + ]); + for (gi, g) in l.glyphs.iter().enumerate() { + if g.visible && g.len > 0 && g.byte >= s && g.byte < e { + glyphs.push((li, gi)); } - let x0 = caret_x(l, s); - let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { caret_x(l, e) }; - let q = [ - xf.to_screen(m * Point::new(x0, l.baseline - l.ascent)), - xf.to_screen(m * Point::new(x1, l.baseline - l.ascent)), - xf.to_screen(m * Point::new(x1, l.baseline + l.descent)), - xf.to_screen(m * Point::new(x0, l.baseline + l.descent)), - ]; - painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); } } + if !quads.is_empty() { + runs.push((fi, m, quads, glyphs)); + } + } + if !runs.is_empty() { + draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs); } if ts.is_caret() && let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus) @@ -706,6 +740,95 @@ fn draw_text_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: & } } +/// InDesign draws selected text inverted: a black highlight with the selected glyphs in the +/// inverse of their colour (white for black text). The highlight and glyph outlines are +/// rasterised with `vello_cpu` into a small screen-space texture over the selection. +fn draw_inverse_highlight( + app: &DesignApp, + painter: &egui::Painter, + xf: &Xf, + doc: &Document, + cs: &compose::ComposedStory, + story: designcraft_doc::StoryId, + runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)], +) { + use designcraft_render::vello_cpu::{self, kurbo, peniko}; + let ctx = painter.ctx(); + let ppp = ctx.pixels_per_point(); + let to_screen = |m: Affine, p: Point| xf.to_screen(m * p); + let mut bbox = Rect::NOTHING; + for (_, m, quads, _) in runs { + for q in quads { + for p in q { + bbox.extend_with(to_screen(*m, *p)); + } + } + } + let bbox = bbox.intersect(painter.clip_rect()); + if !bbox.is_positive() { + return; + } + // Snap to device pixels so the texture maps 1:1. + let bbox = Rect::from_min_max( + pos2((bbox.min.x * ppp).floor() / ppp, (bbox.min.y * ppp).floor() / ppp), + pos2((bbox.max.x * ppp).ceil() / ppp, (bbox.max.y * ppp).ceil() / ppp), + ); + let (w, h) = (((bbox.width() * ppp).round() as u32).clamp(1, 8192), ((bbox.height() * ppp).round() as u32).clamp(1, 8192)); + let Some(st) = app.session.active() else { return }; + let key = egui::Id::new(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0)) + .with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::(), w, h)) + .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)))); + let cache_id = egui::Id::new("inverse_sel_tex"); + let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id)); + let tex = match cached { + Some((k, t)) if k == key => t, + _ => { + let view = xf.affine(ppp as f64, bbox.min); + let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + for (_, m, quads, _) in runs { + rc.set_transform(view * *m); + for q in quads { + let mut bp = kurbo::BezPath::new(); + bp.move_to(q[0]); + for p in &q[1..] { + bp.line_to(*p); + } + bp.close_path(); + rc.fill_path(&bp); + } + } + let db = designcraft_fonts::FontDb::global(); + for (fi, m, _, glyphs) in runs { + let Some(ft) = cs.frames.get(*fi) else { continue }; + for &(li, gi) in glyphs { + let Some(l) = ft.lines.get(li) else { continue }; + let Some(g) = l.glyphs.get(gi) else { continue }; + let Some(style) = cs.styles.get(g.style as usize) else { continue }; + let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue }; + let [r, gg, b, _] = c.to_rgba8(1.0); + rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255)); + let outline = db.outline(&g.face, g.gid); + let skew = + if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let ga = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy); + rc.set_transform(view * *m * ga); + rc.fill_path(&outline); + } + } + rc.flush(); + let mut pm = vello_cpu::Pixmap::new(w as u16, h as u16); + let mut res = vello_cpu::Resources::new(); + rc.render(&mut pm, &mut res); + let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pm.data_as_u8_slice()); + let t = ctx.load_texture("inverse_selection", ci, egui::TextureOptions::NEAREST); + ctx.data_mut(|d| d.insert_temp(cache_id, (key, t.clone()))); + t + } + }; + painter.image(tex.id(), bbox, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); +} + fn caret_x(l: &compose::Line, pos: usize) -> f64 { for g in &l.glyphs { if g.len > 0 && pos >= g.byte && pos < g.byte + g.len { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a74f2d0..77d7575 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -362,7 +362,9 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { let titles: Vec<(String, bool)> = app.session.documents().iter().map(|d| (d.title(), d.is_dirty())).collect(); for (i, (title, dirty)) in titles.iter().enumerate() { let is_active = Some(i) == active; - let label = format!("{}{} @ {:.0}%", if *dirty { "*" } else { "" }, title, zoom * 100.0); + // `*` = unsaved; the view-mode suffix only in Preview (InDesign shows its GPU mode there). + let suffix = if is_active && app.ui.screen_mode == crate::ScreenMode::Preview { " [Preview]" } else { "" }; + let label = format!("{}{} @ {:.0}%{suffix}", if *dirty { "*" } else { "" }, title, zoom * 100.0); let galley = ui.painter().layout_no_wrap(label, semibold(11.5), if is_active { t.text_strong } else { t.text_dim }); let w = (galley.size().x + 44.0).max(if is_active { 210.0 } else { 150.0 }); let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 28.0)); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9f9519f..7ad8b02 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -430,6 +430,291 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { _ => {} } } + // ---- Properties panel glyphs (original drawings) + "orient-portrait" => { + pen.rect(5.5, 3.0, 14.5, 17.0); + pen.fcircle(10.0, 8.0, 1.8); + pen.frect(7.5, 11.0, 12.5, 15.0); + } + "orient-landscape" => { + pen.rect(3.0, 5.5, 17.0, 14.5); + pen.fcircle(13.0, 10.0, 1.8); + pen.frect(5.0, 8.0, 9.0, 12.0); + } + "page-count" => { + pen.closed(&[(10.0, 5.0), (3.0, 3.0), (3.0, 15.0), (10.0, 17.0)]); + pen.closed(&[(10.0, 5.0), (17.0, 3.0), (17.0, 15.0), (10.0, 17.0)]); + pen.line(&[(10.0, 2.0), (10.0, 18.0)]); + } + "margin-top" => { + pen.frect(2.0, 3.0, 18.0, 7.0); + pen.rect(2.5, 9.0, 17.5, 17.0); + } + "margin-bottom" => { + pen.rect(2.5, 3.0, 17.5, 11.0); + pen.frect(2.0, 13.0, 18.0, 17.0); + } + "margin-inside" => { + pen.frect(2.0, 3.0, 6.0, 17.0); + pen.rect(8.0, 3.5, 17.5, 16.5); + } + "margin-outside" => { + pen.rect(2.5, 3.5, 12.0, 16.5); + pen.frect(14.0, 3.0, 18.0, 17.0); + } + "link" | "link-broken" => { + let broken = name == "link-broken"; + let (a, b) = if broken { (7.5, 12.5) } else { (9.0, 11.0) }; + pen.closed(&[(8.0, 3.0), (12.0, 3.0), (12.0, a), (8.0, a)]); + pen.closed(&[(8.0, b), (12.0, b), (12.0, 17.0), (8.0, 17.0)]); + if broken { + pen.line(&[(4.0, 4.0), (16.0, 16.0)]); + } else { + pen.line(&[(10.0, 6.0), (10.0, 14.0)]); + } + } + "rulers" => { + pen.closed(&[(3.0, 3.0), (17.0, 3.0), (17.0, 7.0), (7.0, 7.0), (7.0, 17.0), (3.0, 17.0)]); + for k in 0..4 { + let v = 9.0 + k as f32 * 2.2; + pen.line(&[(v, 3.0), (v, 5.0)]); + pen.line(&[(3.0, v), (5.0, v)]); + } + } + "grid-baseline" => { + pen.rect(3.0, 3.0, 17.0, 17.0); + for y in [6.5, 10.0, 13.5] { + pen.line(&[(3.0, y), (17.0, y)]); + } + } + "grid-document" => { + pen.rect(3.0, 3.0, 17.0, 17.0); + for v in [7.7, 12.3] { + pen.line(&[(3.0, v), (17.0, v)]); + pen.line(&[(v, 3.0), (v, 17.0)]); + } + } + "guides-show" | "guides-lock" | "guides-smart" => { + pen.line(&[(6.0, 2.0), (6.0, 18.0)]); + pen.line(&[(10.0, 2.0), (10.0, 18.0)]); + pen.line(&[(2.0, 7.0), (14.0, 7.0)]); + pen.line(&[(2.0, 12.0), (14.0, 12.0)]); + match name { + "guides-lock" => { + pen.frect(12.5, 13.5, 18.0, 18.0); + pen.line(&[(13.5, 13.5), (13.5, 11.5), (17.0, 11.5), (17.0, 13.5)]); + } + "guides-smart" => { + pen.fill(&[(16.5, 9.0), (13.0, 14.0), (16.5, 14.0)]); + pen.fill(&[(15.0, 14.0), (18.0, 14.0), (14.5, 19.0)]); + } + _ => {} + } + } + "align-to" | "frame-dashed" => { + pen.w = 1.0; + for (a, b) in [((3.0, 3.0), (15.0, 3.0)), ((15.0, 3.0), (15.0, 15.0)), ((15.0, 15.0), (3.0, 15.0)), ((3.0, 15.0), (3.0, 3.0))] { + let n = 4; + for k in 0..n { + let f0 = k as f32 / n as f32; + let f1 = f0 + 0.6 / n as f32; + pen.line(&[(a.0 + (b.0 - a.0) * f0, a.1 + (b.1 - a.1) * f0), (a.0 + (b.0 - a.0) * f1, a.1 + (b.1 - a.1) * f1)]); + } + } + pen.fill(&[(16.0, 17.0), (19.5, 17.0), (17.75, 19.0)]); + } + "objalign-left" | "objalign-hcenter" | "objalign-right" => { + let x = match name { + "objalign-left" => 4.0, + "objalign-hcenter" => 10.0, + _ => 16.0, + }; + pen.line(&[(x, 2.0), (x, 18.0)]); + let (a0, a1, b0, b1) = match name { + "objalign-left" => (4.0, 15.0, 4.0, 11.0), + "objalign-hcenter" => (4.5, 15.5, 6.5, 13.5), + _ => (5.0, 16.0, 9.0, 16.0), + }; + pen.frect(a0, 4.5, a1, 8.5); + pen.frect(b0, 11.5, b1, 15.5); + } + "objalign-top" | "objalign-vcenter" | "objalign-bottom" => { + let y = match name { + "objalign-top" => 4.0, + "objalign-vcenter" => 10.0, + _ => 16.0, + }; + pen.line(&[(2.0, y), (18.0, y)]); + let (a0, a1, b0, b1) = match name { + "objalign-top" => (4.0, 15.0, 4.0, 11.0), + "objalign-vcenter" => (4.5, 15.5, 6.5, 13.5), + _ => (5.0, 16.0, 9.0, 16.0), + }; + pen.frect(4.5, a0, 8.5, a1); + pen.frect(11.5, b0, 15.5, b1); + } + "palign-left" + | "palign-center" + | "palign-right" + | "palign-justify-left" + | "palign-justify-center" + | "palign-justify-right" + | "palign-justify-all" + | "palign-spine-towards" + | "palign-spine-away" => { + pen.w = 1.1; + let rows = 5; + for i in 0..rows { + let y = 4.0 + i as f32 * 3.0; + let last = i == rows - 1; + let short = [14.0, 11.0, 13.0, 10.0, 12.0][i]; + let (x0, x1) = match name { + "palign-left" => (3.0, 3.0 + short), + "palign-center" => (10.0 - short / 2.0, 10.0 + short / 2.0), + "palign-right" => (17.0 - short, 17.0), + "palign-justify-left" if last => (3.0, 11.0), + "palign-justify-center" if last => (6.0, 14.0), + "palign-justify-right" if last => (9.0, 17.0), + "palign-spine-towards" => (if i % 2 == 0 { 3.0 } else { 6.0 }, 14.0), + "palign-spine-away" => (3.0, if i % 2 == 0 { 14.0 } else { 11.0 }), + _ => (3.0, 17.0), + }; + pen.line(&[(x0, y), (x1, y)]); + } + if name == "palign-spine-towards" || name == "palign-spine-away" { + pen.line(&[(17.0, 2.0), (17.0, 18.0)]); + } + if name == "palign-spine-away" { + pen.line(&[(15.5, 2.0), (15.5, 18.0)]); + } + } + "text-columns" => { + pen.rect(2.5, 3.5, 17.5, 16.5); + pen.line(&[(7.5, 3.5), (7.5, 16.5)]); + pen.line(&[(12.5, 3.5), (12.5, 16.5)]); + } + "text-gutter" => { + pen.rect(2.5, 3.5, 17.5, 16.5); + pen.line(&[(8.0, 3.5), (8.0, 16.5)]); + pen.line(&[(12.0, 3.5), (12.0, 16.5)]); + pen.line(&[(9.0, 10.0), (11.0, 10.0)]); + } + "font-size" => { + pen.text(6.0, 12.5, "T", 10.0); + pen.text(13.0, 10.5, "T", 17.0); + } + "leading" => { + pen.text(13.0, 6.0, "A", 9.0); + pen.text(13.0, 14.5, "A", 9.0); + pen.line(&[(4.0, 3.0), (4.0, 17.0)]); + pen.line(&[(2.5, 5.0), (4.0, 3.0), (5.5, 5.0)]); + pen.line(&[(2.5, 15.0), (4.0, 17.0), (5.5, 15.0)]); + } + "kerning" | "tracking" => { + pen.text(10.0, 8.0, "VA", 10.0); + if name == "kerning" { + pen.line(&[(10.0, 13.0), (10.0, 18.0)]); + pen.line(&[(4.0, 15.5), (16.0, 15.5)]); + } else { + pen.line(&[(3.0, 15.5), (17.0, 15.5)]); + pen.line(&[(5.0, 13.5), (3.0, 15.5), (5.0, 17.5)]); + pen.line(&[(15.0, 13.5), (17.0, 15.5), (15.0, 17.5)]); + } + } + "corner" => { + pen.w = 1.0; + for (x, y) in [(3.0, 3.0), (14.0, 3.0), (3.0, 14.0), (14.0, 14.0)] { + pen.frect(x, y, x + 3.0, y + 3.0); + } + pen.line(&[(7.0, 4.5), (13.0, 4.5)]); + pen.line(&[(7.0, 15.5), (13.0, 15.5)]); + pen.line(&[(4.5, 7.0), (4.5, 13.0)]); + pen.line(&[(15.5, 7.0), (15.5, 13.0)]); + } + "opacity" => { + for i in 0..4 { + for j in 0..4 { + if (i + j) % 2 == 0 { + pen.frect(3.0 + i as f32 * 3.5, 3.0 + j as f32 * 3.5, 6.5 + i as f32 * 3.5, 6.5 + j as f32 * 3.5); + } + } + } + pen.w = 0.8; + pen.rect(3.0, 3.0, 17.0, 17.0); + } + "fx" => { + pen.text(9.0, 10.0, "fx", 11.0); + pen.fill(&[(16.0, 15.0), (19.0, 15.0), (17.5, 17.0)]); + } + "bullets" => { + for (k, y) in [5.0, 10.0, 15.0].iter().enumerate() { + pen.fcircle(4.0, *y, 1.5); + pen.line(&[(7.5, *y), (if k == 1 { 14.0 } else { 17.0 }, *y)]); + } + } + "numbering" => { + for (k, y) in [5.0, 10.0, 15.0].iter().enumerate() { + pen.text(4.0, *y, ["1", "2", "3"][k], 6.0); + pen.line(&[(7.5, *y), (if k == 1 { 14.0 } else { 17.0 }, *y)]); + } + } + "pilcrow-menu" => { + pen.text(9.0, 10.0, "¶", 14.0); + pen.fill(&[(14.0, 15.0), (17.0, 15.0), (15.5, 17.0)]); + } + "style-new" => { + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.line(&[(10.0, 7.0), (10.0, 13.0)]); + pen.line(&[(7.0, 10.0), (13.0, 10.0)]); + } + "style-clear" => { + pen.text(8.0, 10.0, "¶", 13.0); + pen.line(&[(13.0, 5.0), (17.0, 9.0)]); + pen.line(&[(17.0, 5.0), (13.0, 9.0)]); + } + "style-load" => { + pen.line(&[(2.0, 8.0), (9.0, 8.0)]); + pen.line(&[(6.5, 5.5), (9.0, 8.0), (6.5, 10.5)]); + pen.closed(&[(11.0, 3.0), (17.0, 3.0), (17.0, 17.0), (8.0, 17.0), (8.0, 12.0)]); + } + "grip" => { + for i in 0..3 { + for j in 0..2 { + pen.fcircle(8.0 + j as f32 * 4.0, 5.0 + i as f32 * 5.0, 1.1); + } + } + } + "fit-text" => { + pen.line(&[(10.0, 2.0), (10.0, 18.0)]); + pen.line(&[(7.0, 5.0), (10.0, 2.0), (13.0, 5.0)]); + pen.line(&[(7.0, 15.0), (10.0, 18.0), (13.0, 15.0)]); + pen.line(&[(3.0, 8.0), (7.0, 8.0)]); + pen.line(&[(3.0, 12.0), (7.0, 12.0)]); + pen.line(&[(13.0, 8.0), (17.0, 8.0)]); + pen.line(&[(13.0, 12.0), (17.0, 12.0)]); + } + "frame-options" | "content-collector-frame" => { + pen.w = 1.1; + pen.rect(3.0, 3.0, 17.0, 17.0); + pen.line(&[(6.0, 7.0), (14.0, 7.0)]); + pen.line(&[(6.0, 10.0), (14.0, 10.0)]); + pen.line(&[(6.0, 13.0), (11.0, 13.0)]); + } + "default-colors" => { + pen.w = 1.2; + pen.p.rect_filled(Rect::from_min_max(pen.pt(8.0, 8.0), pen.pt(19.0, 19.0)), 0.0, Color32::BLACK); + pen.p.rect_filled(Rect::from_min_max(pen.pt(11.0, 11.0), pen.pt(16.0, 16.0)), 0.0, Color32::from_gray(0x53)); + pen.rect(8.0, 8.0, 19.0, 19.0); + pen.p.rect_filled(Rect::from_min_max(pen.pt(1.0, 1.0), pen.pt(12.0, 12.0)), 0.0, Color32::WHITE); + pen.p.line_segment([pen.pt(1.0, 12.0), pen.pt(12.0, 1.0)], Stroke::new(1.2 * pen.s(), Color32::from_rgb(230, 30, 30))); + pen.rect(1.0, 1.0, 12.0, 12.0); + } + "format-container" => pen.rect(4.0, 4.0, 16.0, 16.0), + "format-text" => pen.text(10.0, 10.5, "T", 15.0), + "screen-mode" => { + pen.rect(3.0, 4.0, 17.0, 16.0); + pen.line(&[(3.0, 7.0), (17.0, 7.0)]); + } _ => { pen.rect(4.0, 4.0, 16.0, 16.0); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9cdbbc6..ba04096 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod menus; pub mod panels; pub mod render_worker; pub mod story_editor; +pub mod taskbar; pub mod theme; pub mod toolbar; pub mod widgets; @@ -90,6 +91,16 @@ pub struct UiState { pub dock_expanded: bool, pub units: Unit, pub workspace: String, + /// Transform reference point (0..8, row-major; 0 = top-left). + pub ref_point: u8, + /// Align To target (`selection`, `keyObject`, `margins`, `page`, `spread`). + pub align_to: String, + /// Properties > Text Style tab: 0 = Paragraph Styles, 1 = Character Styles. + pub text_style_tab: u8, + pub guides_locked: bool, + pub smart_guides: bool, + /// Window > Contextual Task Bar. + pub task_bar: bool, #[serde(skip)] pub status: String, #[serde(skip)] @@ -119,6 +130,12 @@ impl Default for UiState { dock_expanded: true, units: Unit::Picas, workspace: "Essentials".into(), + ref_point: 0, + align_to: "selection".into(), + text_style_tab: 0, + guides_locked: false, + smart_guides: true, + task_bar: true, status: String::new(), dialog: None, palette: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a1f6ae9..118dc7b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -40,6 +40,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links}", ), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), + ("window.taskBar", "Contextual Task Bar", None, "{}"), ("window.toolsDoubleColumn", "Tools: Double Column", None, "{}"), ( "window.workspace", @@ -167,7 +168,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.baselineGrid", ], ), - ("Window", &["ui:window.controlBar", "ui:window.toolsDoubleColumn", "-", "ui:window.panel", "-", "ui:window.brightness"]), + ("Window", &["ui:window.controlBar", "ui:window.taskBar", "ui:window.toolsDoubleColumn", "-", "ui:window.panel", "-", "ui:window.brightness"]), ("Help", &["cmd:file.newSample"]), ]; @@ -296,6 +297,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.control_bar), + "window.taskBar" => flag(&mut app.ui.task_bar), "window.toolsDoubleColumn" => flag(&mut app.ui.tools_double_column), "window.workspace" => { let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials"); @@ -503,6 +505,7 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "view.textThreads" => app.ui.text_threads, "view.hiddenCharacters" => app.ui.hidden_characters, "window.controlBar" => app.ui.control_bar, + "window.taskBar" => app.ui.task_bar, "window.toolsDoubleColumn" => app.ui.tools_double_column, "view.togglePreview" => app.ui.screen_mode == crate::ScreenMode::Preview, "window.brightness" => params.get("brightness").and_then(Value::as_str) == Some(app.ui.brightness.id()), diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index afc8f62..1ec29e3 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1,47 +1,142 @@ -//! Layers panel. +//! Layers panel: expandable layer rows listing the current spread's objects (``, +//! `` for text frames), with eye / lock columns per layer and per object and a +//! selection square in the layer colour. -use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; +use designcraft_doc::{Content, Document, Item, ItemId}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use crate::theme::Tokens; use crate::{DesignApp, icons}; -pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { +const ROW_H: f32 = 24.0; +/// Width of the eye and lock columns. +const COL_W: f32 = 22.0; + +/// Display name of an item: its own name, the first words of a text frame's story, or the kind. +pub fn item_label(doc: &Document, it: &Item) -> String { + if !it.name.is_empty() { + return it.name.clone(); + } + if let Content::Text(tf) = &it.content + && let Some(story) = doc.story(tf.story) + { + let words: String = + story.text.chars().map(|c| if c.is_control() || (c as u32) >= 0xE000 && (c as u32) <= 0xF8FF { ' ' } else { c }).collect(); + let words = words.split_whitespace().collect::>().join(" "); + if !words.is_empty() { + // Whole words up to about 24 characters. + let mut short = String::new(); + for w in words.split(' ') { + if !short.is_empty() && short.chars().count() + 1 + w.chars().count() > 24 { + break; + } + if !short.is_empty() { + short.push(' '); + } + short.push_str(w); + } + let short: String = short.chars().take(24).collect(); + let ell = if short.chars().count() < words.chars().count() { "…" } else { "" }; + return format!("<{short}{ell}>"); + } + } + it.default_label().to_string() +} + +fn c32(c: [u8; 3]) -> Color32 { + Color32::from_rgb(c[0], c[1], c[2]) +} + +/// Eye and lock cells (with column rules). Returns (eye clicked, lock clicked). +fn eye_lock(ui: &mut Ui, row: Rect, visible: bool, locked: bool, dim: bool, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> (bool, bool) { + let t = Tokens::get(ui.ctx()); + let eye = Rect::from_min_size(row.min, vec2(COL_W, ROW_H)); + let lock = Rect::from_min_size(row.min + vec2(COL_W, 0.0), vec2(COL_W, ROW_H)); + for r in [eye, lock] { + ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); + } + let col = if dim { t.text_disabled } else { t.icon }; + if visible { + icons::paint(ui.painter(), Rect::from_center_size(eye.center(), vec2(15.0, 15.0)), "eye", col); + } + if locked { + icons::paint(ui.painter(), Rect::from_center_size(lock.center(), vec2(13.0, 13.0)), "lock", col); + } + let e = ui.interact(eye, ui.id().with(("eye", key)), Sense::click()).on_hover_text(if visible { "Hide" } else { "Show" }); + let l = ui.interact(lock, ui.id().with(("lock", key)), Sense::click()).on_hover_text(if locked { "Unlock" } else { "Lock" }); + (e.clicked(), l.clicked()) +} + +/// The selection square at the right of a row: filled = selected; hollow on hover. +fn selection_square(ui: &mut Ui, row: Rect, color: Color32, selected: bool, small: bool, key: impl std::hash::Hash + std::fmt::Debug) -> bool { + let s = if small { 7.0 } else { 8.0 }; + let r = Rect::from_center_size(pos2(row.max.x - 12.0, row.center().y), vec2(s, s)); + let resp = ui.interact(r.expand(4.0), ui.id().with(("selsq", key)), Sense::click()); + if selected { + ui.painter().rect_filled(r, 0.0, color); + } else if resp.hovered() { + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); + } + resp.on_hover_text("Select").clicked() +} + +pub fn show(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let active = st.active_layer; - let sel_layers: Vec<_> = st.selection.items.iter().filter_map(|i| doc.item(*i).map(|it| it.layer)).collect(); - for l in &doc.layers { - let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let selected: Vec = st.selection.items.clone(); + let sel_layers: Vec<_> = selected.iter().filter_map(|i| doc.item(*i).map(|it| it.layer)).collect(); + // Objects on the current spread, topmost first. + let cur = crate::canvas::current_page(app).unwrap_or(0); + let spread_items: Vec> = + doc.page_loc(cur).and_then(|(si, _)| doc.spreads.get(si)).map(|sp| sp.items.iter().rev().cloned().collect()).unwrap_or_default(); + ui.spacing_mut().item_spacing.y = 0.0; + for l in doc.layers.iter().rev() { + let open_id = egui::Id::new(("layer_open", l.id.0)); + let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(l.id == active); + let lc = c32(l.color); + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); if l.id == active { ui.painter().rect_filled(row, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(row, 0.0, t.hover); } - let eye = egui::Rect::from_min_size(row.min + vec2(2.0, 4.0), vec2(18.0, 18.0)); - let lock = egui::Rect::from_min_size(row.min + vec2(24.0, 4.0), vec2(18.0, 18.0)); - if l.visible { - icons::paint(ui.painter(), eye, "eye", t.icon); + let (eye, lock) = eye_lock(ui, row, l.visible, l.locked, false, ("layer", l.id.0)); + // Disclosure triangle. + let tri = Rect::from_min_size(row.min + vec2(2.0 * COL_W + 2.0, 0.0), vec2(14.0, ROW_H)); + let c = tri.center(); + let pts = if open { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, t.icon, Stroke::NONE)); + let tri_r = ui.interact(tri, ui.id().with(("tri", l.id.0)), Sense::click()); + // Layer colour chip and name. + let chip = Rect::from_min_size(row.min + vec2(2.0 * COL_W + 18.0, 7.0), vec2(4.0, 10.0)); + ui.painter().rect_filled(chip, 0.0, lc); + ui.painter().text( + row.min + vec2(2.0 * COL_W + 28.0, ROW_H / 2.0), + egui::Align2::LEFT_CENTER, + &l.name, + egui::FontId::proportional(11.5), + t.text_strong, + ); + if l.id == active { + icons::paint(ui.painter(), Rect::from_center_size(pos2(row.max.x - 32.0, row.center().y), vec2(13.0, 13.0)), "tool-pen", t.icon); } - if l.locked { - icons::paint(ui.painter(), lock, "lock", t.icon); - } - ui.painter().rect_stroke(lock.expand(1.0), 0.0, Stroke::new(1.0, t.divider), StrokeKind::Inside); - let chip = egui::Rect::from_min_size(row.min + vec2(48.0, 8.0), vec2(4.0, 10.0)); - ui.painter().rect_filled(chip, 0.0, Color32::from_rgb(l.color[0], l.color[1], l.color[2])); - ui.painter().text(row.min + vec2(58.0, 13.0), egui::Align2::LEFT_CENTER, &l.name, egui::FontId::proportional(12.5), t.text); - // Selection square in the layer colour. - if sel_layers.contains(&l.id) { - let sq = egui::Rect::from_center_size(egui::pos2(row.max.x - 12.0, row.center().y), vec2(7.0, 7.0)); - ui.painter().rect_filled(sq, 0.0, Color32::from_rgb(l.color[0], l.color[1], l.color[2])); - } - let eye_r = ui.interact(eye, ui.id().with(("eye", l.id.0)), Sense::click()); - let lock_r = ui.interact(lock, ui.id().with(("lock", l.id.0)), Sense::click()); - if eye_r.clicked() { + let sq = selection_square(ui, row, lc, sel_layers.contains(&l.id), false, ("layer", l.id.0)); + if eye { let _ = app.run("layer.set", json!({"id": l.id.0, "visible": !l.visible})); - } else if lock_r.clicked() { + } else if lock { let _ = app.run("layer.set", json!({"id": l.id.0, "locked": !l.locked})); + } else if tri_r.clicked() { + ui.data_mut(|d| d.insert_temp(open_id, !open)); + } else if sq { + let ids: Vec = spread_items.iter().filter(|i| i.layer == l.id).map(|i| i.id.0).collect(); + let _ = app.run("selection.set", json!({"ids": ids})); } else if resp.clicked() { let _ = app.run("layer.activate", json!({"id": l.id.0})); } @@ -55,11 +150,43 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.close(); } }); + if !open { + continue; + } + for it in spread_items.iter().filter(|i| i.layer == l.id) { + let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + let is_sel = selected.contains(&it.id); + if resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + let (eye, lock) = eye_lock(ui, row, !it.hidden, it.locked, !l.visible, ("item", it.id.0)); + let name = item_label(&doc, it); + let clip = Rect::from_min_max(row.min, pos2(row.max.x - 26.0, row.max.y)); + ui.painter().with_clip_rect(clip).text( + row.min + vec2(2.0 * COL_W + 30.0, ROW_H / 2.0), + egui::Align2::LEFT_CENTER, + &name, + egui::FontId::proportional(11.0), + if it.hidden { t.text_dim } else { t.text }, + ); + let sq = selection_square(ui, row, lc, is_sel, true, ("item", it.id.0)); + if eye { + let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "hidden": !it.hidden})); + } else if lock { + let _ = app.run("object.setFlags", json!({"ids": [it.id.0], "locked": !it.locked})); + } else if sq || resp.clicked() { + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + let _ = app.run("selection.set", json!({"ids": [it.id.0], "add": add})); + } + } } ui.add_space(6.0); + ui.spacing_mut().item_spacing.y = 4.0; ui.horizontal(|ui| { ui.label( - egui::RichText::new(format!("{} Layer{}", doc.layers.len(), if doc.layers.len() == 1 { "" } else { "s" })).size(11.0).color(t.text_dim), + egui::RichText::new(format!("Page: {}, {} Layer{}", doc.page_name(cur), doc.layers.len(), if doc.layers.len() == 1 { "" } else { "s" })) + .size(11.0) + .color(t.text_dim), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, "trash", 20.0, false, "Delete Layer").clicked() { @@ -71,3 +198,22 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { }); }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_frames_are_labelled_with_their_first_words() { + let mut s = designcraft_engine::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + s.execute("text.insert", &json!({"text": "Every page begins as an empty field of possibility"})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [36, 236, 300, 400]})).unwrap(); + let doc = &s.doc().unwrap().doc; + let tf = doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item_label(doc, tf), ""); + let rr = doc.item(ItemId(rect["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item_label(doc, rr), ""); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b9e3a2c..ff106a8 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -31,6 +31,12 @@ pub struct SelInfo { pub kind: &'static str, pub is_graphic: bool, pub is_text: bool, + /// Stroke type id (`solid`, `dashed`, `thickThin`, …). + pub stroke_kind: String, + /// Uniform corner (shape, size) — the first corner. + pub corner: (designcraft_geom::corners::CornerShape, f64), + pub wrap_invert: bool, + pub locked: bool, } pub fn sel_info(app: &DesignApp) -> Option { @@ -71,6 +77,13 @@ pub fn sel_info(app: &DesignApp) -> Option { kind: first.default_label(), is_graphic: matches!(first.content, Content::Graphic(_)), is_text: first.is_text_frame(), + stroke_kind: serde_json::to_value(&first.stroke.kind) + .ok() + .and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| v.as_str().map(str::to_string))) + .unwrap_or_else(|| "solid".into()), + corner: (first.corners.corners[0].shape, first.corners.corners[0].size), + wrap_invert: first.wrap.invert, + locked: first.locked, }) } diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 2afade0..29586e3 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -3,11 +3,10 @@ use std::collections::HashMap; use std::sync::Mutex; -use designcraft_doc::SpreadRef; use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; use serde_json::json; -use crate::theme::{Tokens, semibold}; +use crate::theme::Tokens; use crate::{DesignApp, icons}; type ThumbKey = (u64, u64, usize); @@ -43,65 +42,127 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option Some(tex) } +/// What is being dragged in the Pages panel. +#[derive(Clone, Debug, PartialEq)] +enum PageDrag { + /// A document page (absolute index). + Page(usize), + /// A parent (prefix; `None` = [None]). + Parent(Option), +} + +/// Selected-page tint (multiplied over the thumbnail). +const SELECTED_TINT: Color32 = Color32::from_rgb(0x88, 0xc7, 0xfb); +/// Page-number badge of the selected page. +const BADGE: Color32 = Color32::from_rgb(0x2f, 0x73, 0xe0); +/// Thumbnail height (InDesign: about 28×37 pt for Letter). +const THUMB_H: f32 = 37.0; +/// Parent rows. +const ROW_H: f32 = 29.0; + +/// Paint a blank page icon of `n` pages (parents list), right-aligned at `right`. +fn blank_spread(p: &egui::Painter, right: egui::Pos2, n: usize, aspect: f32, t: &Tokens) { + let h = 24.0; + let w = h * aspect; + let n = n.max(1); + for k in 0..n { + let r = egui::Rect::from_min_size(egui::pos2(right.x - w * (n - k) as f32, right.y - h / 2.0), vec2(w, h)); + p.rect_filled(r, 0.0, Color32::WHITE); + p.rect_stroke(r, 0.0, Stroke::new(0.5, t.border), StrokeKind::Inside); + } +} + pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let editing_parents = st.editing_parents; - // Parents. - ui.label(egui::RichText::new("Parents").font(semibold(11.5)).color(t.text_dim)); - ui.horizontal_wrapped(|ui| { - let none = ui.add(egui::Button::new("[None]").frame(false)); - if none.clicked() { - let _ = app.run("layout.parents.edit", json!({"on": false})); + let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32; + let mut drop: Option<(PageDrag, usize)> = None; + ui.spacing_mut().item_spacing.y = 0.0; + // ---- Parents: [None] and each parent, 29 pt rows with a blank thumbnail at the right. + let mut rows: Vec<(String, Option, usize)> = vec![("[None]".into(), None, 1)]; + for p in &doc.parents { + let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default(); + rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len())); + } + for (label, prefix, n) in rows { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); + let active = editing_parents && prefix.is_some(); + if active { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); } - for p in &doc.parents { - let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default(); - let resp = - ui.add(egui::Button::new(egui::RichText::new(&label).color(if editing_parents { t.text_strong } else { t.text })).frame(false)); - if resp.double_clicked() || resp.clicked() { - let _ = app.run("layout.parents.edit", json!({"on": true})); - app.views.clear(); - } + ui.painter().text(r.left_center() + vec2(3.0, 0.0), egui::Align2::LEFT_CENTER, &label, egui::FontId::proportional(11.5), t.text_strong); + blank_spread(ui.painter(), egui::pos2(r.max.x - 10.0, r.center().y), n, aspect, &t); + resp.dnd_set_drag_payload(PageDrag::Parent(prefix.clone())); + if resp.double_clicked() && prefix.is_some() { + let _ = app.run("layout.parents.edit", json!({"on": true})); + app.views.clear(); } - }); + if resp.dragged() { + drag_ghost(ui, &label, &t); + } + } + ui.add_space(4.0); let r = ui.available_rect_before_wrap(); - ui.painter().line_segment([egui::pos2(r.min.x, r.min.y + 2.0), egui::pos2(r.max.x, r.min.y + 2.0)], Stroke::new(1.0, t.divider)); - ui.add_space(8.0); + ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(r.min.x - 10.0, r.min.y), vec2(r.width() + 20.0, 1.5)), 0.0, t.section_divider); + ui.add_space(12.0); + // ---- Document spreads. let cur = crate::canvas::current_page(app).unwrap_or(0); - let th = 76.0; + let cur_spread = doc.page_loc(cur).map(|l| l.0); let center = ui.available_width() / 2.0; + let dragging = egui::DragAndDrop::has_any_payload(ui.ctx()); for (si, sp) in doc.spreads.iter().enumerate() { let first = doc.first_page_of_spread(si); - let scale = th / doc.settings.page_height.max(1.0) as f32; - // Pages relative to the spine. + let scale = THUMB_H / doc.settings.page_height.max(1.0) as f32; let spine = sp.spine_x() as f32; - let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), th + 22.0), Sense::hover()); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + THUMB_H + 22.0), Sense::hover()); + let top = row.min.y + 8.0; + // Marker above the current spread. + if cur_spread == Some(si) && !editing_parents { + let c = egui::pos2(row.min.x + center, row.min.y + 3.5); + ui.painter().add(egui::Shape::convex_polygon(vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)], t.icon, Stroke::NONE)); + } for (pi, p) in sp.pages.iter().enumerate() { let abs = first + pi; let x0 = row.min.x + center + (p.x as f32 - spine) * scale; - let pr = egui::Rect::from_min_size(egui::pos2(x0, row.min.y + 2.0), vec2(p.width as f32 * scale, p.height as f32 * scale)); - let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click()); - if let Some(tex) = thumb(app, ui.ctx(), abs, th) { - ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); - } else { - ui.painter().rect_filled(pr, 0.0, Color32::WHITE); - } + let pr = egui::Rect::from_min_size(egui::pos2(x0, top), vec2(p.width as f32 * scale, p.height as f32 * scale)); + let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click_and_drag()); let selected = abs == cur && !editing_parents; - ui.painter().rect_stroke( - pr, - 0.0, - Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.divider }), - StrokeKind::Outside, - ); - // Parent prefix in the top corner. + let tint = if selected { SELECTED_TINT } else { Color32::WHITE }; + if let Some(tex) = thumb(app, ui.ctx(), abs, THUMB_H) { + ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint); + } else { + ui.painter().rect_filled(pr, 0.0, tint); + } + ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside); + // Parent prefix in the outer top corner (black on white). if let Some(pid) = p.parent && let Some(pp) = doc.parents.iter().find(|x| x.id == pid) { let prefix = pp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default(); - let corner = - if p.side == designcraft_doc::PageSide::Left { pr.left_top() + vec2(3.0, 2.0) } else { pr.right_top() + vec2(-9.0, 2.0) }; - ui.painter().text(corner, egui::Align2::LEFT_TOP, prefix, egui::FontId::proportional(9.0), Color32::from_gray(90)); + let g = ui.painter().layout_no_wrap(prefix, crate::theme::semibold(9.5), Color32::BLACK); + let w = g.size().x + 2.0; + let br = if p.side == designcraft_doc::PageSide::Left { + egui::Rect::from_min_size(pr.left_top(), vec2(w, 10.0)) + } else { + egui::Rect::from_min_size(pr.right_top() - vec2(w, 0.0), vec2(w, 10.0)) + }; + ui.painter().rect_filled(br, 0.0, Color32::WHITE); + ui.painter().galley(br.min + vec2(1.0, -0.5), g, Color32::BLACK); + } + // Drag and drop: pages reorder, parents apply. + resp.dnd_set_drag_payload(PageDrag::Page(abs)); + if resp.dragged() { + drag_ghost(ui, &doc.page_name(abs), &t); + } + if dragging && resp.contains_pointer() { + ui.painter().rect_stroke(pr.expand(1.5), 0.0, Stroke::new(2.0, BADGE), StrokeKind::Outside); + } + if let Some(payload) = resp.dnd_release_payload::() { + drop = Some(((*payload).clone(), abs)); } if resp.clicked() || resp.double_clicked() { if editing_parents { @@ -110,48 +171,20 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } crate::canvas::go_to_page(app, abs); } - resp.context_menu(|ui| { - if ui.button("Insert Page After").clicked() { - let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); - ui.close(); - } - if ui.button("Delete Page").clicked() { - let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); - ui.close(); - } - if ui.button("Duplicate Spread").clicked() { - let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si})); - ui.close(); - } - ui.menu_button("Apply Parent", |ui| { - if ui.button("[None]").clicked() { - let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); - ui.close(); - } - for pp in &doc.parents { - let l = pp.parent.as_ref().map(|i| i.label()).unwrap_or_default(); - if ui.button(&l).clicked() { - let _ = - app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": pp.parent.as_ref().map(|i| i.prefix.clone())})); - ui.close(); - } - } - }); - }); + resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si)); + // Page number under the page: white on a blue badge when selected. + let name = doc.page_name(abs); + let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE); + let c = egui::pos2(pr.center().x, pr.max.y + 11.0); + if selected { + let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5)); + ui.painter().rect_filled(br, 0.0, BADGE); + } + ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE); } - // Page numbers under the spread. - let names: Vec = (0..sp.pages.len()).map(|i| doc.page_name(first + i)).collect(); - let label = if names.len() > 1 { format!("{}–{}", names[0], names[names.len() - 1]) } else { names[0].clone() }; - ui.painter().text( - egui::pos2(row.min.x + center, row.max.y - 8.0), - egui::Align2::CENTER_CENTER, - label, - egui::FontId::proportional(11.0), - t.text_dim, - ); - let _ = SpreadRef::Doc(si); } ui.add_space(6.0); + ui.spacing_mut().item_spacing.y = 4.0; ui.horizontal(|ui| { ui.label(egui::RichText::new(format!("{} Pages in {} Spreads", doc.page_count(), doc.spreads.len())).size(11.0).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -163,4 +196,51 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } }); }); + match drop { + Some((PageDrag::Page(from), to)) if from != to => { + let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); + } + Some((PageDrag::Parent(prefix), to)) => { + let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix})); + } + _ => {} + } +} + +fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Document, abs: usize, si: usize) { + if ui.button("Insert Page After").clicked() { + let _ = app.run("layout.pages.insert", json!({"after": abs, "count": 1})); + ui.close(); + } + if ui.button("Delete Page").clicked() { + let _ = app.run("layout.pages.delete", json!({"pages": [abs]})); + ui.close(); + } + if ui.button("Duplicate Spread").clicked() { + let _ = app.run("layout.pages.duplicateSpread", json!({"spread": si})); + ui.close(); + } + ui.menu_button("Apply Parent", |ui| { + if ui.button("[None]").clicked() { + let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); + ui.close(); + } + for pp in &doc.parents { + let l = pp.parent.as_ref().map(|i| i.label()).unwrap_or_default(); + if ui.button(&l).clicked() { + let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": pp.parent.as_ref().map(|i| i.prefix.clone())})); + ui.close(); + } + } + }); +} + +/// A small label following the pointer while dragging. +fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { + let Some(p) = ui.ctx().pointer_interact_pos() else { return }; + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("page_drag"))); + let g = painter.layout_no_wrap(label.to_string(), egui::FontId::proportional(11.0), t.text_strong); + let r = egui::Rect::from_min_size(p + vec2(10.0, 8.0), g.size() + vec2(10.0, 6.0)); + painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 150a594..7ed95bc 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1,326 +1,1128 @@ -//! The Properties panel (context-sensitive) and small single-purpose panels (Stroke, Character, -//! Paragraph, Text Wrap, Align, Links, Info). +//! The Properties panel (context-sensitive, laid out as InDesign 2026's: selection-type band, +//! sections per selection state — `plan/indesign/11-observed-ui.md` §4.1) and small +//! single-purpose panels (Stroke, Character, Paragraph, Text Wrap, Align, Links, Info). use designcraft_doc::Align; use designcraft_geom::Unit; -use egui::vec2; -use serde_json::json; +use designcraft_geom::corners::CornerShape; +use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; use super::{SelInfo, sel_info, text_attrs}; use crate::theme::Tokens; -use crate::widgets::{caption, divider, measure, number, section}; +use crate::widgets::{ + self, FIELD_H, NumField, button_pair, caption, divider, dropdown, full_width, icon_toggle, link_label, measure, more_options, number, + outline_button, rule, section, section_row, selection_band, +}; use crate::{DesignApp, icons}; fn units(app: &DesignApp) -> Unit { app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } -pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { - ui.label(egui::RichText::new("No document open").color(t.text_dim)); - return; - }; - let has_text = st.selection.text.is_some(); - let info = sel_info(app); - if has_text { - ui.label(egui::RichText::new("Text").color(t.text_dim)); - character_panel(app, ui); - divider(ui); - paragraph_panel(app, ui); - divider(ui); - if section(ui, "Text Frame", false) { - text_frame_section(app, ui); - } - return; +/// Allocate a full-width block `h` tall. +fn block(ui: &mut Ui, h: f32) -> Rect { + ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::hover()).0 +} + +/// A sub-rect of a block at (`x`, `y`) relative to its top-left. +fn sub(b: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect { + Rect::from_min_size(b.min + vec2(x, y), vec2(w, h)) +} + +/// Run `f` in a child ui occupying `r` (left-to-right, vertically centred). The parent's cursor +/// does not move: callers allocate the whole block first with [`block`]. +pub(crate) fn place(ui: &mut Ui, r: Rect, f: impl FnOnce(&mut Ui) -> R) -> R { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r).layout(egui::Layout::left_to_right(egui::Align::Center))); + f(&mut child) +} + +fn temp_bool(ui: &Ui, key: &str, default: bool) -> bool { + ui.data(|d| d.get_temp::(egui::Id::new(key))).unwrap_or(default) +} + +fn set_temp_bool(ui: &Ui, key: &str, v: bool) { + ui.data_mut(|d| d.insert_temp(egui::Id::new(key), v)); +} + +/// Label of the current selection for the selection-type band. +fn selection_title(app: &DesignApp, info: &Option) -> String { + let Some(st) = app.session.active() else { return String::new() }; + if st.selection.text.is_some() { + return "Characters".into(); } match info { - Some(i) => { - let kind = if i.count > 1 { format!("{} objects", i.count) } else { i.kind.trim_matches(['<', '>']).to_string() }; - ui.label(egui::RichText::new(capitalize(&kind)).color(t.text_dim)); - if section(ui, "Transform", true) { - transform_section(app, ui, &i); - } - divider(ui); - if section(ui, "Appearance", true) { - appearance_section(app, ui, &i); - } - divider(ui); - if i.count > 1 && section(ui, "Align", true) { - align_panel(app, ui); - divider(ui); - } - if i.is_text { - if section(ui, "Text Frame", true) { - text_frame_section(app, ui); - } - divider(ui); - } - if i.is_graphic { - if section(ui, "Frame Fitting", true) { - ui.horizontal(|ui| { - for (icon, mode, tip) in [ - ("fit-fill", "fillProportionally", "Fill Frame Proportionally"), - ("fit-prop", "fitProportionally", "Fit Content Proportionally"), - ("fit-content", "fitContentToFrame", "Fit Content to Frame"), - ("fit-frame", "fitFrameToContent", "Fit Frame to Content"), - ("fit-center", "centerContent", "Center Content"), - ] { - if icons::button(ui, icon, 24.0, false, tip).clicked() { - let _ = app.run("object.fit", json!({"mode": mode})); - } - } - }); - } - divider(ui); - } - if section(ui, "Text Wrap", false) { - wrap_panel(app, ui); - } - divider(ui); - if section(ui, "Quick Actions", true) { - ui.horizontal_wrapped(|ui| { - for (label, cmd) in [ - ("Group", "object.group"), - ("Ungroup", "object.ungroup"), - ("Lock", "object.lock"), - ("Bring to Front", "object.bringToFront"), - ("Send to Back", "object.sendToBack"), - ] { - if ui.button(label).clicked() { - let _ = app.run(cmd, json!({})); - } - } - if i.is_text && ui.button("Fill with Placeholder Text").clicked() { - let _ = app.run("type.fillWithPlaceholder", json!({})); - } - }); - } - } - None => document_section(app, ui), + None => "No Selection".into(), + Some(i) if i.count > 1 => "Multiple Objects".into(), + Some(i) => match i.kind { + "" => "Text Frame".into(), + "" => "Group".into(), + "" => "Image".into(), + "" => "Rectangle".into(), + "" => "Ellipse".into(), + "" => "Polygon".into(), + "" => "Line".into(), + _ => "Path".into(), + }, } } -fn capitalize(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - Some(f) => f.to_uppercase().collect::() + c.as_str(), - None => String::new(), - } -} - -fn document_section(app: &mut DesignApp, ui: &mut egui::Ui) { +pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + if app.session.active().is_none() { + ui.label(egui::RichText::new("No document open").color(t.text_dim)); + return; + } + ui.spacing_mut().item_spacing = vec2(6.0, 5.0); + let info = sel_info(app); + let has_text = app.session.active().is_some_and(|s| s.selection.text.is_some()); + selection_band(ui, &selection_title(app, &info)); + if has_text { + text_style_section(app, ui); + divider(ui); + section(ui, "Appearance", true); + text_appearance_section(app, ui); + divider(ui); + section(ui, "Character", true); + character_section(app, ui); + divider(ui); + section(ui, "Paragraph", true); + paragraph_section(app, ui); + divider(ui); + section(ui, "Bullets and Numbering", true); + bullets_section(app, ui); + divider(ui); + section(ui, "Quick Actions", true); + quick_actions_text(app, ui); + return; + } + let Some(i) = info else { + document_sections(app, ui); + return; + }; + section(ui, "Transform", true); + transform_section(app, ui, &i); + divider(ui); + if i.is_text && i.count == 1 { + text_style_section(app, ui); + divider(ui); + } + section(ui, "Appearance", true); + appearance_section(app, ui, &i); + divider(ui); + if i.is_text && i.count == 1 { + section(ui, "Character", true); + character_section(app, ui); + divider(ui); + section(ui, "Paragraph", true); + paragraph_section(app, ui); + divider(ui); + } + section(ui, "Align", true); + align_row(app, ui); + divider(ui); + if i.is_text && i.count == 1 { + section(ui, "Text Frame", true); + text_frame_section(app, ui); + divider(ui); + } else { + if i.is_graphic { + section(ui, "Frame Fitting", true); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + for (icon, mode, tip) in [ + ("fit-fill", "fillProportionally", "Fill Frame Proportionally"), + ("fit-prop", "fitProportionally", "Fit Content Proportionally"), + ("fit-content", "fitContentToFrame", "Fit Content to Frame"), + ("fit-frame", "fitFrameToContent", "Fit Frame to Content"), + ("fit-center", "centerContent", "Center Content"), + ] { + if icon_toggle(ui, icon, false, tip).clicked() { + let _ = app.run("object.fit", json!({"mode": mode})); + } + } + }); + divider(ui); + } + section(ui, "Text Wrap", true); + wrap_section(app, ui, &i); + divider(ui); + } + section(ui, "Quick Actions", true); + quick_actions_object(app, ui, &i); +} + +// ---------------------------------------------------------------- No Selection + +/// Page-size presets for the Document dropdown (portrait, points). +const PAGE_PRESETS: &[(&str, f64, f64)] = &[ + ("Letter", 612.0, 792.0), + ("Legal", 612.0, 1008.0), + ("Tabloid", 792.0, 1224.0), + ("Letter - Half", 396.0, 612.0), + ("Legal - Half", 504.0, 612.0), + ("A3", 841.89, 1190.55), + ("A4", 595.276, 841.89), + ("A5", 419.528, 595.276), + ("B5", 498.898, 708.661), +]; + +fn preset_name(w: f64, h: f64) -> &'static str { + let (a, b) = (w.min(h), w.max(h)); + PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") +} + +fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; let d = st.doc.clone(); - ui.label(egui::RichText::new("Document").color(t.text_dim)); - if section(ui, "Document", true) { - egui::Grid::new("docgrid").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| { - caption(ui, "W"); - if let Some(v) = measure(ui, "dw", Some(d.settings.page_width), u, 70.0) { - let _ = app.run("layout.documentSetup", json!({"width": v})); + let (pw, ph) = (d.settings.page_width, d.settings.page_height); + section(ui, "Document", true); + // Preset dropdown + orientation. + let b = block(ui, FIELD_H); + let mut preset = None; + place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { + let resp = dropdown(ui, preset_name(pw, ph), 93.0); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(140.0); + for p in PAGE_PRESETS { + if ui.selectable_label(preset_name(pw, ph) == p.0, p.0).clicked() { + preset = Some((p.1, p.2)); + ui.close(); + } } - caption(ui, "H"); - if let Some(v) = measure(ui, "dh", Some(d.settings.page_height), u, 70.0) { - let _ = app.run("layout.documentSetup", json!({"height": v})); - } - ui.end_row(); - caption(ui, "Pages"); - ui.label(d.page_count().to_string()); - caption(ui, ""); - let mut facing = d.settings.facing_pages; - if ui.checkbox(&mut facing, "Facing").changed() { - let _ = app.run("layout.documentSetup", json!({"facingPages": facing})); - } - ui.end_row(); }); - if ui.button("Document Setup…").clicked() { - app.ui.dialog = Some(crate::dialogs::Dialog::new("documentSetup", json!({}))); + }); + let landscape = pw > ph; + let mut orient = None; + place(ui, sub(b, 126.0, 0.0, 52.0, FIELD_H), |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if icon_toggle(ui, "orient-portrait", !landscape, "Portrait").clicked() && landscape { + orient = Some(()); + } + if icon_toggle(ui, "orient-landscape", landscape, "Landscape").clicked() && !landscape { + orient = Some(()); + } + }); + if let Some((w, h)) = preset { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + let _ = app.run("layout.documentSetup", json!({"width": w, "height": h})); + } + if orient.is_some() { + let _ = app.run("layout.documentSetup", json!({"width": ph, "height": pw})); + } + ui.add_space(3.0); + // W / H, page count, facing pages. + let b = block(ui, 2.0 * FIELD_H + 5.0); + for (row, (label, v, key)) in [("W:", pw, "width"), ("H:", ph, "height")].into_iter().enumerate() { + let y = row as f32 * 26.0; + place(ui, sub(b, 0.0, y, 20.0, FIELD_H), |ui| widgets::caption_w(ui, label, 20.0)); + let nv = + place(ui, sub(b, 21.0, y, 72.0, FIELD_H), |ui| NumField::measure(key, Some(v), u).width(72.0).spinner().range(1.0, 15552.0).show(ui)); + if let Some(nv) = nv { + let _ = app.run("layout.documentSetup", json!({key: nv})); } } - divider(ui); - if section(ui, "Margins", true) { - let p = d.page(crate::canvas::current_page(app).unwrap_or(0)).cloned(); - if let Some(p) = p { - egui::Grid::new("margins").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| { - for (row, (a, av, b, bv)) in - [("Top", p.margins.top, "Bottom", p.margins.bottom), ("Inside", p.margins.inside, "Outside", p.margins.outside)] - .into_iter() - .enumerate() - { - caption(ui, a); - if let Some(v) = measure(ui, &format!("m{row}a"), Some(av), u, 64.0) { - let mut m = p.margins; - if row == 0 { - m.top = v - } else { - m.inside = v - } - let _ = app.run("layout.marginsAndColumns", json!({"margins": m})); - } - caption(ui, b); - if let Some(v) = measure(ui, &format!("m{row}b"), Some(bv), u, 64.0) { - let mut m = p.margins; - if row == 0 { - m.bottom = v - } else { - m.outside = v - } - let _ = app.run("layout.marginsAndColumns", json!({"margins": m})); - } - ui.end_row(); - } - }); - ui.horizontal(|ui| { - caption(ui, "Columns"); - if let Some(v) = number(ui, "cols", Some(p.columns.count as f64), "", 36.0, 0) { - let _ = app.run("layout.marginsAndColumns", json!({"columns": v.max(1.0) as u64})); - } - caption(ui, "Gutter"); - if let Some(v) = measure(ui, "gut", Some(p.columns.gutter), u, 56.0) { - let _ = app.run("layout.marginsAndColumns", json!({"gutter": v})); - } - }); + let n = d.page_count(); + let t = Tokens::get(ui.ctx()); + icons::paint(ui.painter(), sub(b, 127.0, 2.0, 17.0, 17.0), "page-count", t.icon); + let nv = place(ui, sub(b, 150.0, 0.0, 68.0, FIELD_H), |ui| number(ui, "pagecount", Some(n as f64), "", 68.0, 0)); + if let Some(nv) = nv { + let want = (nv.round() as usize).clamp(1, 9999); + if want > n { + let _ = app.run("layout.pages.insert", json!({"count": want - n, "after": n - 1})); + } else if want < n { + let _ = app.run("layout.pages.delete", json!({"pages": (want..n).collect::>()})); } } - divider(ui); - if section(ui, "Rulers & Grids", true) { - ui.horizontal(|ui| { - ui.checkbox(&mut app.ui.rulers, "Rulers"); - ui.checkbox(&mut app.ui.baseline_grid, "Baseline grid"); + let mut facing = d.settings.facing_pages; + let changed = place(ui, sub(b, 126.0, 26.0, 100.0, FIELD_H), |ui| widgets::checkbox(ui, &mut facing, "Facing Pages").changed()); + if changed { + let _ = app.run("layout.documentSetup", json!({"facingPages": facing})); + } + // Margins. + section(ui, "Margins", true); + let cur = crate::canvas::current_page(app).unwrap_or(0); + if let Some(p) = d.page(cur).cloned() { + let linked = temp_bool(ui, "margins_linked", true); + let b = block(ui, 2.0 * FIELD_H + 5.0); + let mut set: Option = None; + let fields = [ + ("margin-top", "Top", p.margins.top, 0.0, 0.0), + ("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0), + ("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0), + ("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0), + ]; + for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.id().with(("micon", k)), Sense::hover()).on_hover_text(tip); + let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| { + NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui) + }); + if let Some(nv) = nv { + let mut m = p.margins; + if linked { + m = designcraft_doc::Margins { top: nv, bottom: nv, inside: nv, outside: nv }; + } else { + match k { + 0 => m.top = nv, + 1 => m.bottom = nv, + 2 => m.outside = nv, + _ => m.inside = nv, + } + } + set = Some(m); + } + } + let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H); + let clicked = place(ui, lr, |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0)) + .clicked() }); + if clicked { + set_temp_bool(ui, "margins_linked", !linked); + } + if let Some(m) = set { + let _ = app.run("layout.marginsAndColumns", json!({"margins": m})); + } + } + ui.add_space(6.0); + let fw = full_width(ui); + if outline_button(ui, "Adjust Layout", fw).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("documentSetup", json!({}))); } divider(ui); - if section(ui, "Guides", true) { - ui.horizontal(|ui| { - ui.checkbox(&mut app.ui.guides, "Guides"); - ui.checkbox(&mut app.ui.frame_edges, "Frame edges"); - }); + // Page. + section(ui, "Page", true); + let b = block(ui, FIELD_H); + let names: Vec = (0..d.page_count()).map(|i| d.page_name(i)).collect(); + let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| widgets::dropdown_list(ui, &d.page_name(cur), 93.0, &names, Some(cur))); + if let Some(pg) = picked { + crate::canvas::go_to_page(app, pg); + } + let (add, del) = place(ui, sub(b, 172.0, 0.0, 50.0, FIELD_H), |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let a = widgets::icon_toggle_sized(ui, "style-new", false, "Insert Page", vec2(22.0, 21.0)).clicked(); + let d = widgets::icon_toggle_sized(ui, "trash", false, "Delete Page", vec2(22.0, 21.0)).clicked(); + (a, d) + }); + if add { + let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); + } + if del { + let _ = app.run("layout.pages.delete", json!({"pages": [cur]})); + } + ui.add_space(3.0); + if outline_button(ui, "Edit Page", fw).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("documentSetup", json!({}))); } divider(ui); - if section(ui, "Quick Actions", true) { - ui.horizontal_wrapped(|ui| { - if ui.button("Insert Page").clicked() { - let _ = app.run("layout.pages.insert", json!({"count": 1})); - } - if ui.button("Place…").clicked() { - let _ = app.run("app.placeDialog", json!({})); - } - if ui.button("Export PNG…").clicked() { - let _ = app.run("app.exportPng", json!({})); - } - }); + // Rulers & Grids / Guides: icon toggle rows. + let (mut rulers, mut bl, mut dg) = (app.ui.rulers, app.ui.baseline_grid, app.ui.document_grid); + section_row(ui, "Rulers & Grids", |ui| { + ui.spacing_mut().item_spacing.x = 14.0; + if icon_toggle(ui, "grid-document", dg, "Show Document Grid").clicked() { + dg = !dg; + } + if icon_toggle(ui, "grid-baseline", bl, "Show Baseline Grid").clicked() { + bl = !bl; + } + if icon_toggle(ui, "rulers", rulers, "Show Rulers").clicked() { + rulers = !rulers; + } + }); + if rulers != app.ui.rulers { + let _ = app.run("view.rulers", json!({})); + } + if bl != app.ui.baseline_grid { + let _ = app.run("view.baselineGrid", json!({})); + } + app.ui.document_grid = dg; + rule(ui); + let (mut guides, mut locked, mut smart) = (app.ui.guides, app.ui.guides_locked, app.ui.smart_guides); + section_row(ui, "Guides", |ui| { + ui.spacing_mut().item_spacing.x = 14.0; + if icon_toggle(ui, "guides-smart", smart, "Smart Guides").clicked() { + smart = !smart; + } + if icon_toggle(ui, "guides-lock", locked, "Lock Guides").clicked() { + locked = !locked; + } + if icon_toggle(ui, "guides-show", guides, "Show Guides").clicked() { + guides = !guides; + } + }); + if guides != app.ui.guides { + let _ = app.run("view.guides", json!({})); + } + app.ui.guides_locked = locked; + app.ui.smart_guides = smart; + rule(ui); + section(ui, "Quick Actions", true); + let (a, b) = button_pair(ui, "Import File", "Insert Page"); + if a { + let _ = app.run("app.placeDialog", json!({})); + } + if b { + let _ = app.run("layout.pages.insert", json!({"after": cur, "count": 1})); } } -fn transform_section(app: &mut DesignApp, ui: &mut egui::Ui, i: &SelInfo) { +// ---------------------------------------------------------------- Objects + +/// The 9-point reference-point proxy; returns a newly picked point (0..8, row-major). +fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out = None; + let pitch = r.width() / 3.0; + for i in 0..9u8 { + let c = pos2(r.min.x + pitch * (i % 3) as f32 + pitch / 2.0, r.min.y + pitch * (i / 3) as f32 + pitch / 2.0); + let sq = Rect::from_center_size(c, vec2(6.0, 6.0)); + let resp = ui.interact(sq.expand(1.5), ui.id().with(("refpt", i)), Sense::click()); + if i == cur { + ui.painter().rect_filled(sq.expand(0.5), 0.0, egui::Color32::WHITE); + } else { + ui.painter().rect_stroke(sq, 0.0, Stroke::new(1.0, if resp.hovered() { t.text_strong } else { t.icon }), StrokeKind::Inside); + } + if resp.clicked() { + out = Some(i); + } + } + // Connecting lines between the squares. + for k in 0..3 { + let y = r.min.y + pitch * k as f32 + pitch / 2.0; + let x = r.min.x + pitch * k as f32 + pitch / 2.0; + for j in 0..2 { + let a = r.min.x + pitch * j as f32 + pitch / 2.0 + 3.0; + ui.painter().line_segment([pos2(a, y), pos2(a + pitch - 6.0, y)], Stroke::new(1.0, t.icon)); + let a = r.min.y + pitch * j as f32 + pitch / 2.0 + 3.0; + ui.painter().line_segment([pos2(x, a), pos2(x, a + pitch - 6.0)], Stroke::new(1.0, t.icon)); + } + } + out +} + +fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let u = units(app); - egui::Grid::new("xf").num_columns(4).spacing(vec2(6.0, 4.0)).show(ui, |ui| { - caption(ui, "X"); - if let Some(v) = measure(ui, "px", Some(i.page_rect.x0), u, 70.0) { - let _ = app.run("transform.set", json!({"x": v})); + let rp = app.ui.ref_point; + let b = block(ui, 2.0 * FIELD_H + 4.0); + // Reference-point position of the bounds. + let r = i.page_rect; + let (fx, fy) = ((rp % 3) as f64 / 2.0, (rp / 3) as f64 / 2.0); + let (x, y) = (r.x0 + r.width() * fx, r.y0 + r.height() * fy); + if let Some(p) = ref_point_proxy(ui, sub(b, 5.0, 10.0, 26.0, 26.0), rp) { + app.ui.ref_point = p; + } + let constrain = temp_bool(ui, "constrain_wh", false); + let mut set = serde_json::Map::new(); + for (row, (lx, vx, kx, lw, vw, kw)) in + [("X:", x, "x", "W:", r.width(), "width"), ("Y:", y, "y", "H:", r.height(), "height")].into_iter().enumerate() + { + let yy = row as f32 * 25.0; + place(ui, sub(b, 40.0, yy, 18.0, FIELD_H), |ui| widgets::caption_w(ui, lx, 18.0)); + if let Some(v) = place(ui, sub(b, 59.0, yy, 54.0, FIELD_H), |ui| measure(ui, kx, Some(vx), u, 54.0)) { + set.insert(kx.into(), json!(v)); } - caption(ui, "W"); - if let Some(v) = measure(ui, "pw", Some(i.page_rect.width()), u, 70.0) { - let _ = app.run("transform.set", json!({"width": v})); - } - ui.end_row(); - caption(ui, "Y"); - if let Some(v) = measure(ui, "py", Some(i.page_rect.y0), u, 70.0) { - let _ = app.run("transform.set", json!({"y": v})); - } - caption(ui, "H"); - if let Some(v) = measure(ui, "ph", Some(i.page_rect.height()), u, 70.0) { - let _ = app.run("transform.set", json!({"height": v})); - } - ui.end_row(); - }); - ui.horizontal(|ui| { - caption(ui, "Rotate"); - if let Some(v) = number(ui, "rot", Some(0.0), "°", 46.0, 1) { - let _ = app.run("transform.rotate", json!({"angle": v})); - } - if ui.small_button("⇋").on_hover_text("Flip Horizontal").clicked() { - let _ = app.run("transform.flip", json!({"axis": "horizontal"})); - } - if ui.small_button("⇵").on_hover_text("Flip Vertical").clicked() { - let _ = app.run("transform.flip", json!({"axis": "vertical"})); + place(ui, sub(b, 120.0, yy, 19.0, FIELD_H), |ui| widgets::caption_w(ui, lw, 19.0)); + if let Some(v) = place(ui, sub(b, 140.0, yy, 54.0, FIELD_H), |ui| measure(ui, kw, Some(vw), u, 54.0)) { + set.insert(kw.into(), json!(v)); + if constrain && vw > 0.0 { + let k = v / vw; + let (ok, ov) = if kw == "width" { ("height", r.height()) } else { ("width", r.width()) }; + set.insert(ok.into(), json!(ov * k)); + } } + } + let clicked = place(ui, sub(b, 200.0, 12.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized( + ui, + if constrain { "link" } else { "link-broken" }, + constrain, + "Constrain proportions for width and height", + vec2(22.0, 21.0), + ) + .clicked() }); + if clicked { + set_temp_bool(ui, "constrain_wh", !constrain); + } + if !set.is_empty() { + set.insert("ref".into(), json!(rp)); + let _ = app.run("transform.set", Value::Object(set)); + } + if more_options(ui).clicked() { + app.ui.open_panel = Some("info".into()); + } } -fn appearance_section(app: &mut DesignApp, ui: &mut egui::Ui, i: &SelInfo) { - ui.horizontal(|ui| { - caption(ui, "Fill "); - super::swatch_picker(app, ui, "pfill", Some(i.fill.clone()), |app, n| { - let _ = app.run("object.fill", json!({"swatch": n})); - }); - ui.label(&i.fill); - }); - ui.horizontal(|ui| { - caption(ui, "Stroke"); - super::swatch_picker(app, ui, "pstroke", Some(i.stroke.clone()), |app, n| { - let _ = app.run("object.stroke", json!({"swatch": n})); - }); - if let Some(v) = number(ui, "psw", Some(i.stroke_weight), " pt", 50.0, 3) { - let _ = app.run("object.stroke", json!({"weight": v})); - } - }); - ui.horizontal(|ui| { - caption(ui, "Opacity"); - let mut o = (i.opacity * 100.0) as f32; - if ui.add(egui::Slider::new(&mut o, 0.0..=100.0).suffix("%").integer()).drag_stopped() { - let _ = app.run("object.opacity", json!({"opacity": o / 100.0})); - } - }); - ui.horizontal(|ui| { - let mut on = i.shadow; - if ui.checkbox(&mut on, "Drop shadow").changed() { - let _ = app.run("object.dropShadow", json!({"on": on})); - } - }); - ui.horizontal(|ui| { - caption(ui, "Corners"); - for (label, shape) in [("None", "none"), ("Rounded", "rounded"), ("Bevel", "bevel"), ("Inset", "inset")] { - if ui.small_button(label).clicked() { - let _ = app.run("object.cornerOptions", json!({"shape": shape, "size": 12.0})); +/// A stroke-type preview dropdown (light box with the line style). +fn stroke_type_dropdown(app: &mut DesignApp, ui: &mut Ui, cur: &str, w: f32) { + const TYPES: &[(&str, &str)] = &[ + ("Solid", "solid"), + ("Dashed", "dashed"), + ("Dotted", "dotted"), + ("Thick - Thin", "thickThin"), + ("Thin - Thick", "thinThick"), + ("Thin - Thin", "thinThin"), + ("Thick - Thick", "thickThick"), + ]; + let resp = preview_dropdown(ui, w, |p, r| paint_stroke_type(p, r, cur)); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(140.0); + for (label, kind) in TYPES { + if ui.selectable_label(*kind == cur, *label).clicked() { + let ty = if *kind == "dashed" { json!({"kind": "dashed", "pattern": [12.0, 4.0]}) } else { json!({"kind": kind}) }; + let _ = app.run("object.stroke", json!({"type": ty})); + ui.close(); } } }); } -fn text_frame_section(app: &mut DesignApp, ui: &mut egui::Ui) { +fn paint_stroke_type(p: &egui::Painter, r: Rect, kind: &str) { + let c = egui::Color32::BLACK; + let (x0, x1, y) = (r.min.x + 5.0, r.max.x - 5.0, r.center().y); + match kind { + "dashed" => { + let mut x = x0; + while x < x1 { + p.line_segment([pos2(x, y), pos2((x + 6.0).min(x1), y)], Stroke::new(4.0, c)); + x += 9.0; + } + } + "dotted" => { + let mut x = x0 + 2.0; + while x < x1 { + p.circle_filled(pos2(x, y), 2.0, c); + x += 6.0; + } + } + "thickThin" | "thinThick" | "thinThin" | "thickThick" => { + let (a, b) = match kind { + "thickThin" => (3.0, 1.0), + "thinThick" => (1.0, 3.0), + "thinThin" => (1.0, 1.0), + _ => (2.5, 2.5), + }; + p.line_segment([pos2(x0, y - 3.0), pos2(x1, y - 3.0)], Stroke::new(a, c)); + p.line_segment([pos2(x0, y + 3.0), pos2(x1, y + 3.0)], Stroke::new(b, c)); + } + _ => { + p.line_segment([pos2(x0, y), pos2(x1, y)], Stroke::new(5.0, c)); + } + } +} + +/// A dropdown whose face is a light preview box (stroke type, corner shape). +fn preview_dropdown(ui: &mut Ui, w: f32, paint: impl FnOnce(&egui::Painter, Rect)) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); + let face = Rect::from_min_max(r.min, pos2(r.max.x - widgets::SEG_W, r.max.y)); + ui.painter().rect_filled(face, 0.0, egui::Color32::from_gray(0xdf)); + paint(ui.painter(), face); + let seg = Rect::from_min_max(pos2(face.max.x, r.min.y), r.max); + ui.painter().rect_filled(seg, 0.0, if resp.hovered() { t.hover } else { t.input }); + widgets::chevron_down(ui.painter(), seg.center(), t.icon); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + resp +} + +fn paint_corner_shape(p: &egui::Painter, r: Rect, shape: CornerShape) { + let c = egui::Color32::BLACK; + let s = Stroke::new(1.5, c); + let (x0, y0, x1, y1) = (r.min.x + 4.0, r.min.y + 4.0, r.max.x - 9.0, r.max.y - 3.0); + let k = 8.0; + let pts: Vec = match shape { + CornerShape::None => vec![pos2(x0, y0), pos2(x1, y0), pos2(x1, y1)], + CornerShape::Bevel => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1, y0 + k), pos2(x1, y1)], + CornerShape::Inset => vec![pos2(x0, y0), pos2(x1 - k, y0), pos2(x1 - k, y0 + k), pos2(x1, y0 + k), pos2(x1, y1)], + CornerShape::Rounded | CornerShape::InverseRounded | CornerShape::Fancy => { + let mut v = vec![pos2(x0, y0)]; + for i in 0..=8 { + let a = i as f32 / 8.0 * std::f32::consts::FRAC_PI_2; + v.push(if shape == CornerShape::InverseRounded { + pos2(x1 - k * a.cos(), y0 + k * a.sin()) + } else { + pos2(x1 - k + k * a.sin(), y0 + k - k * a.cos()) + }); + } + v.push(pos2(x1, y1)); + v + } + }; + p.add(egui::Shape::line(pts, s)); +} + +fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let t = Tokens::get(ui.ctx()); + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + // Fill. + let b = block(ui, FIELD_H); + let (fc, fg) = widgets::swatch_colors(&doc, &i.fill, 1.0); + let chip = sub(b, 4.0, 2.0, 17.0, 17.0); + widgets::paint_chip(ui.painter(), chip, fc, fg); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let resp = ui.interact(chip, ui.id().with("fillchip"), Sense::click()).on_hover_text(format!("Fill: {}", i.fill)); + swatch_menu(app, &resp, &doc, &i.fill, |app, n| { + let _ = app.run("object.fill", json!({"swatch": n})); + }); + ui.painter().text(b.min + vec2(34.0, FIELD_H / 2.0), egui::Align2::LEFT_CENTER, "Fill", egui::FontId::proportional(11.5), t.text); + ui.add_space(4.0); + // Stroke. + let b = block(ui, FIELD_H); + let (sc, _) = widgets::swatch_colors(&doc, &i.stroke, 1.0); + let chip = sub(b, 4.0, 2.0, 17.0, 17.0); + widgets::paint_stroke_chip(ui.painter(), chip, sc, egui::Color32::from_gray(20)); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let resp = ui.interact(chip, ui.id().with("strokechip"), Sense::click()).on_hover_text(format!("Stroke: {}", i.stroke)); + swatch_menu(app, &resp, &doc, &i.stroke, |app, n| { + let _ = app.run("object.stroke", json!({"swatch": n})); + }); + if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, "Stroke", 56.0)).clicked() { + app.ui.open_panel = Some("stroke".into()); + } + const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0]; + if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { + NumField::number("sw", Some(i.stroke_weight), " pt", 3).width(69.0).spinner().presets(WEIGHTS).range(0.0, 800.0).show(ui) + }) { + let _ = app.run("object.stroke", json!({"weight": v})); + } + let kind = i.stroke_kind.clone(); + place(ui, sub(b, 171.0, 0.0, 54.0, FIELD_H), |ui| stroke_type_dropdown(app, ui, &kind, 54.0)); + ui.add_space(4.0); + // Corner. + let b = block(ui, FIELD_H); + icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "corner", t.accent); + if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, "Corner", 56.0)).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("frameSize", json!({}))); + } + let (shape, size) = i.corner; + if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { + NumField::measure("corner", Some(size), Unit::Points).width(69.0).spinner().range(0.0, 1000.0).show(ui) + }) { + let sh = if shape == CornerShape::None { CornerShape::Rounded } else { shape }; + let _ = app.run("object.cornerOptions", json!({"shape": sh, "size": v})); + } + place(ui, sub(b, 171.0, 0.0, 54.0, FIELD_H), |ui| { + let resp = preview_dropdown(ui, 54.0, |p, r| paint_corner_shape(p, r, shape)); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(140.0); + for s in CornerShape::ALL { + if ui.selectable_label(s == shape, s.label()).clicked() { + let _ = app.run("object.cornerOptions", json!({"shape": s, "size": if size > 0.0 { size } else { 12.0 }})); + ui.close(); + } + } + }); + }); + ui.add_space(4.0); + // Opacity. + let b = block(ui, FIELD_H); + icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "opacity", t.icon); + if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, "Opacity", 56.0)).clicked() { + app.ui.open_panel = Some("effects".into()); + } + if let Some(v) = place(ui, sub(b, 96.0, 0.0, 51.0, FIELD_H), |ui| { + NumField::number("opacity", Some((i.opacity * 100.0).round()), "%", 0).width(51.0).range(0.0, 100.0).show(ui) + }) { + let _ = app.run("object.opacity", json!({"opacity": v / 100.0})); + } + let more = sub(b, 147.0, 0.0, 18.0, FIELD_H); + let mresp = ui.interact(more, ui.id().with("opmore"), Sense::click()); + ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); + if mresp.clicked() { + app.ui.open_panel = Some("effects".into()); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + if icon_toggle(ui, "frame-dashed", false, "Frame Fitting").clicked() { + app.ui.open_panel = Some("textWrap".into()); + } + if icon_toggle(ui, "fx", i.shadow, "Effects: Drop Shadow").clicked() { + let _ = app.run("object.dropShadow", json!({"on": !i.shadow})); + } + }); +} + +/// Swatch list popup attached to `resp`. +fn swatch_menu(app: &mut DesignApp, resp: &egui::Response, doc: &designcraft_doc::Document, cur: &str, on_pick: impl FnOnce(&mut DesignApp, String)) { + let mut picked = None; + egui::Popup::menu(resp).show(|ui| { + ui.set_min_width(200.0); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for sw in &doc.swatches { + let (c, g) = widgets::swatch_colors(doc, &sw.name, 1.0); + let row = ui.horizontal(|ui| { + let (cr, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); + widgets::paint_chip(ui.painter(), cr, c, g); + ui.add(egui::Button::new(&sw.name).frame(false).selected(sw.name == cur)) + }); + if row.inner.clicked() { + picked = Some(sw.name.clone()); + ui.close(); + } + } + }); + }); + if let Some(p) = picked { + on_pick(app, p); + } +} + +fn align_row(app: &mut DesignApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + let to = app.ui.align_to.clone(); + let resp = icon_toggle(ui, "align-to", false, &format!("Align To: {to}")); + egui::Popup::menu(&resp).show(|ui| { + for (label, id) in [ + ("Align to Selection", "selection"), + ("Align to Key Object", "keyObject"), + ("Align to Margins", "margins"), + ("Align to Page", "page"), + ("Align to Spread", "spread"), + ] { + if ui.selectable_label(app.ui.align_to == id, label).clicked() { + app.ui.align_to = id.into(); + ui.close(); + } + } + }); + let (r, _) = ui.allocate_exact_size(vec2(1.0, FIELD_H), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.section_divider.gamma_multiply(1.6))); + for (edge, icon, tip) in [ + ("left", "objalign-left", "Align left edges"), + ("hcenter", "objalign-hcenter", "Align horizontal centers"), + ("right", "objalign-right", "Align right edges"), + ("top", "objalign-top", "Align top edges"), + ("vcenter", "objalign-vcenter", "Align vertical centers"), + ("bottom", "objalign-bottom", "Align bottom edges"), + ] { + if icon_toggle(ui, icon, false, tip).clicked() { + let _ = app.run("object.align", json!({"edge": edge, "to": to})); + } + } + }); +} + +fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 7.0; + for (icon, mode, tip) in [ + ("wrap-none", "none", "No Text Wrap"), + ("wrap-bbox", "boundingBox", "Wrap Around Bounding Box"), + ("wrap-contour", "contour", "Wrap Around Object Shape"), + ("wrap-jump", "jumpObject", "Jump Object"), + ("wrap-next", "jumpToNextColumn", "Jump to Next Column"), + ] { + if icon_toggle(ui, icon, i.wrap == mode, tip).clicked() { + let _ = app.run("object.textWrap", json!({"mode": mode})); + } + } + ui.add_space(2.0); + let mut inv = i.wrap_invert; + let enabled = i.wrap != "none"; + let r = ui.add_enabled_ui(enabled, |ui| widgets::checkbox(ui, &mut inv, "Invert")).inner; + if r.changed() { + let _ = app.run("object.textWrap", json!({"mode": i.wrap, "invert": inv})); + } + }); + if more_options(ui).clicked() { + app.ui.open_panel = Some("textWrap".into()); + } +} + +fn arrange_menu(app: &mut DesignApp, resp: &egui::Response) { + egui::Popup::menu(resp).show(|ui| { + for (label, cmd) in [ + ("Bring to Front", "object.bringToFront"), + ("Bring Forward", "object.bringForward"), + ("Send Backward", "object.sendBackward"), + ("Send to Back", "object.sendToBack"), + ] { + if ui.button(label).clicked() { + let _ = app.run(cmd, json!({})); + ui.close(); + } + } + }); +} + +fn convert_shape_menu(app: &mut DesignApp, resp: &egui::Response) { + egui::Popup::menu(resp).show(|ui| { + for (label, shape) in + [("Rectangle", "none"), ("Rounded Rectangle", "rounded"), ("Beveled Rectangle", "bevel"), ("Inverse Rounded Rectangle", "inverseRounded")] + { + if ui.button(label).clicked() { + let _ = app.run("object.cornerOptions", json!({"shape": shape, "size": 12.0})); + ui.close(); + } + } + }); +} + +fn quick_actions_object(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { + let fw = full_width(ui); + let hw = (fw - 10.0) / 2.0; + if i.is_text { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + let r = outline_button(ui, "Convert Shape", hw); + convert_shape_menu(app, &r); + let r = outline_button(ui, "Arrange", hw); + arrange_menu(app, &r); + }); + ui.add_space(4.0); + if outline_button(ui, "Fill with Placeholder Text", fw).clicked() { + let _ = app.run("type.fillWithPlaceholder", json!({})); + } + ui.add_space(4.0); + if outline_button(ui, "Edit in Story Editor", fw).clicked() { + let _ = app.run("app.storyEditor", json!({})); + } + return; + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + let r = outline_button(ui, "Arrange", hw); + arrange_menu(app, &r); + if outline_button(ui, if i.count > 1 { "Group" } else { "Lock" }, hw).clicked() { + let _ = app.run(if i.count > 1 { "object.group" } else { "object.lock" }, json!({})); + } + }); + ui.add_space(4.0); + if i.kind == "" { + if outline_button(ui, "Ungroup", fw).clicked() { + let _ = app.run("object.ungroup", json!({})); + } + } else { + let r = outline_button(ui, "Convert Shape", fw); + convert_shape_menu(app, &r); + } +} + +// ---------------------------------------------------------------- Text + +fn text_style_section(app: &mut DesignApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + section(ui, "Text Style", true); + let Some(a) = text_attrs(app) else { return }; + let tab = app.ui.text_style_tab; + let fw = full_width(ui); + if let Some(k) = widgets::segmented(ui, &["Paragraph Styles", "Character Styles"], tab as usize, fw) { + app.ui.text_style_tab = k as u8; + } + ui.add_space(5.0); + let (names, cur): (Vec, String) = { + let st = app.session.active().expect("doc"); + if tab == 0 { + ( + st.doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| n != designcraft_doc::NO_PARA_STYLE).collect(), + a["paragraphStyle"].as_str().unwrap_or("").to_string(), + ) + } else { + (st.doc.styles.character.iter().map(|p| p.name.clone()).collect(), a["characterStyle"].as_str().unwrap_or("[None]").to_string()) + } + }; + let ov = if tab == 0 { a["paraOverrides"].as_u64().unwrap_or(0) + a["charOverrides"].as_u64().unwrap_or(0) } else { 0 }; + let label = if ov > 0 { format!("{cur}+") } else { cur.clone() }; + let resp = widgets::dropdown_h(ui, "", fw, 34.0); + // "Ag" swatch + style name. + let ag = Rect::from_min_size(resp.rect.min + vec2(6.0, 6.0), vec2(22.0, 22.0)); + ui.painter().rect_filled(ag, 1.0, egui::Color32::WHITE); + ui.painter().text(ag.center(), egui::Align2::CENTER_CENTER, "Ag", egui::FontId::new(13.0, egui::FontFamily::Proportional), egui::Color32::BLACK); + ui.painter().with_clip_rect(resp.rect.shrink2(vec2(20.0, 0.0))).text( + pos2(ag.max.x + 8.0, resp.rect.center().y), + egui::Align2::LEFT_CENTER, + &label, + egui::FontId::proportional(11.5), + t.text, + ); + let mut pick = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(fw); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + pick = Some(n.clone()); + ui.close(); + } + } + }); + }); + if let Some(n) = pick { + if tab == 0 { + let _ = app.run("style.paragraph.apply", json!({"name": n, "clearOverrides": false})); + } else { + let _ = app.run("style.character.apply", json!({"name": n})); + } + } + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + if icon_toggle(ui, "pilcrow-menu", false, if tab == 0 { "Paragraph Styles panel" } else { "Character Styles panel" }).clicked() { + app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into()); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 12.0; + ui.add_space(4.0); + if icon_toggle(ui, "style-new", false, "Create new style").clicked() { + let cmd = if tab == 0 { "style.paragraph.create" } else { "style.character.create" }; + let _ = app.run(cmd, json!({})); + } + if icon_toggle(ui, "style-clear", false, "Clear overrides").clicked() && tab == 0 && !cur.is_empty() { + let _ = app.run("style.paragraph.apply", json!({"name": cur, "clearOverrides": true})); + } + if icon_toggle(ui, "style-load", false, "Styles panel").clicked() { + app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into()); + } + }); + }); +} + +fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(a) = text_attrs(app) else { return }; + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let c = &a["chars"]; + for (k, (key, label)) in [("fill", "Fill"), ("stroke", "Stroke")].into_iter().enumerate() { + let sw = c[key].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string(); + let b = block(ui, FIELD_H); + let (col, g) = widgets::swatch_colors(&doc, &sw, 1.0); + let chip = sub(b, 4.0, 2.0, 17.0, 17.0); + if k == 0 { + widgets::paint_chip(ui.painter(), chip, col, g); + } else { + widgets::paint_stroke_chip(ui.painter(), chip, col, egui::Color32::from_gray(20)); + } + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + // The "T" marks text formatting. + let tc = if col.is_some_and(|c| c.r() as u32 + c.g() as u32 + c.b() as u32 > 380) { egui::Color32::BLACK } else { egui::Color32::WHITE }; + if k == 0 { + ui.painter().text(chip.center(), egui::Align2::CENTER_CENTER, "T", crate::theme::semibold(12.0), tc); + } + let resp = ui.interact(chip, ui.id().with(("tchip", k)), Sense::click()).on_hover_text(format!("{label}: {sw}")); + swatch_menu(app, &resp, &doc, &sw, |app, n| { + let _ = app.run("type.char", json!({"attrs": {key: n}})); + }); + if k == 0 { + ui.painter().text(b.min + vec2(34.0, FIELD_H / 2.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.5), t.text); + } else { + if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, label, 56.0)).clicked() { + app.ui.open_panel = Some("stroke".into()); + } + const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; + if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { + NumField::number("tsw", c["strokeWeight"].as_f64(), " pt", 3).width(69.0).spinner().presets(WEIGHTS).range(0.0, 100.0).show(ui) + }) { + let _ = app.run("type.char", json!({"attrs": {"strokeWeight": v}})); + } + } + ui.add_space(4.0); + } +} + +/// Character section: font family (with search), style, size/leading, kerning/tracking, •••. +fn character_section(app: &mut DesignApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(a) = text_attrs(app) else { return }; + let c = a["chars"].clone(); + let fam = c["fontFamily"].as_str().unwrap_or("").to_string(); + let sty = c["fontStyle"].as_str().unwrap_or("").to_string(); + let fw = full_width(ui); + // Family dropdown with a search segment on the left. + let fams = designcraft_fonts::FontDb::global().families(); + let resp = widgets::dropdown(ui, "", fw); + let r = resp.rect; + icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(3.0, 3.0), vec2(14.0, 14.0)), "search", t.icon); + widgets::chevron_down(ui.painter(), r.min + vec2(22.0, 10.5), t.icon); + ui.painter().text( + r.min + vec2(30.0, 10.5), + egui::Align2::LEFT_CENTER, + if fam.is_empty() { "—" } else { &fam }, + egui::FontId::proportional(11.5), + t.text, + ); + let mut pick_fam = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(fw); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for f in &fams { + if ui.selectable_label(*f == fam, f).clicked() { + pick_fam = Some(f.clone()); + ui.close(); + } + } + }); + }); + if let Some(f) = pick_fam { + let styles = designcraft_fonts::FontDb::global().styles(&f); + let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; + let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); + } + ui.add_space(1.0); + let styles = designcraft_fonts::FontDb::global().styles(&fam); + let cur = styles.iter().position(|s| *s == sty); + if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, fw, &styles, cur) { + let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}})); + } + ui.add_space(1.0); + const SIZES: &[f64] = &[6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 21.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0]; + let half = (fw - 10.0) / 2.0; + let b = block(ui, 2.0 * FIELD_H + 5.0); + let size = c["size"].as_f64(); + let auto = c["leading"]["kind"].as_str() != Some("points"); + let lv = if auto { size.map(|s| s * a["para"]["autoLeading"].as_f64().unwrap_or(1.2)) } else { c["leading"]["value"].as_f64() }; + icons::paint(ui.painter(), sub(b, 0.0, 1.0, 19.0, 19.0), "font-size", t.icon); + if let Some(v) = place(ui, sub(b, 24.0, 0.0, half - 24.0, FIELD_H), |ui| { + NumField::number("csize", size, " pt", 2).width(half - 24.0).spinner().presets(SIZES).range(0.1, 1296.0).show(ui) + }) { + let _ = app.run("type.char", json!({"attrs": {"size": v}})); + } + icons::paint(ui.painter(), sub(b, half + 10.0, 1.0, 19.0, 19.0), "leading", t.icon); + let lw = half - 24.0; + let lshow = lv.map(|v| (v * 100.0).round() / 100.0); + if let Some(v) = place(ui, sub(b, half + 34.0, 0.0, lw, FIELD_H), |ui| { + let key = if auto { "cleadauto" } else { "clead" }; + // Auto leading is shown in parentheses, like "(14.4 pt)". + NumField::number(key, lshow, " pt", 2).width(lw).spinner().presets(SIZES).range(0.0, 5000.0).parens(auto).show(ui) + }) { + let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}})); + } + // Kerning / tracking. + icons::paint(ui.painter(), sub(b, 0.0, 27.0, 19.0, 19.0), "kerning", t.icon); + let kern = c["kerning"]["kind"].as_str().unwrap_or("metrics").to_string(); + let kern_label = match kern.as_str() { + "optical" => "Optical".to_string(), + "none" => "0".to_string(), + "manual" => format!("{}", c["kerning"]["value"].as_f64().unwrap_or(0.0)), + _ => "Metrics".to_string(), + }; + place(ui, sub(b, 24.0, 26.0, half - 24.0, FIELD_H), |ui| { + let resp = widgets::dropdown(ui, &kern_label, half - 24.0); + egui::Popup::menu(&resp).show(|ui| { + for (label, v) in [("Metrics", json!({"kind": "metrics"})), ("Optical", json!({"kind": "optical"})), ("0", json!({"kind": "none"}))] { + if ui.selectable_label(kern_label == label, label).clicked() { + let _ = app.run("type.char", json!({"attrs": {"kerning": v}})); + ui.close(); + } + } + }); + }); + icons::paint(ui.painter(), sub(b, half + 10.0, 27.0, 19.0, 19.0), "tracking", t.icon); + const TRACK: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; + if let Some(v) = place(ui, sub(b, half + 34.0, 26.0, lw, FIELD_H), |ui| { + NumField::number("ctrack", c["tracking"].as_f64(), "", 0).width(lw).spinner().presets(TRACK).step(5.0).range(-1000.0, 10000.0).show(ui) + }) { + let _ = app.run("type.char", json!({"attrs": {"tracking": v}})); + } + if more_options(ui).clicked() { + app.ui.open_panel = Some("character".into()); + } +} + +/// Paragraph section: the 9 alignment buttons and •••. +fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) { + let Some(a) = text_attrs(app) else { return }; + let cur: Align = serde_json::from_value(a["para"]["align"].clone()).unwrap_or_default(); + let fw = full_width(ui); + let pitch = fw / 9.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + for (al, icon) in [ + (Align::Left, "palign-left"), + (Align::Center, "palign-center"), + (Align::Right, "palign-right"), + (Align::LeftJustified, "palign-justify-left"), + (Align::CenterJustified, "palign-justify-center"), + (Align::RightJustified, "palign-justify-right"), + (Align::FullyJustified, "palign-justify-all"), + (Align::TowardsSpine, "palign-spine-towards"), + (Align::AwayFromSpine, "palign-spine-away"), + ] { + if widgets::icon_toggle_sized(ui, icon, cur == al, al.label(), vec2(pitch.min(23.0), 21.0)).clicked() { + let _ = app.run("type.para", json!({"attrs": {"align": al}})); + } + if pitch > 23.0 { + ui.add_space(pitch - 23.0); + } + } + }); + if more_options(ui).clicked() { + app.ui.open_panel = Some("paragraph".into()); + } +} + +fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { + let Some(a) = text_attrs(app) else { return }; + let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string(); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + for (icon, kind, tip) in [("bullets", "bullets", "Bulleted List"), ("numbering", "numbers", "Numbered List")] { + let on = lt == kind; + if icon_toggle(ui, icon, on, tip).clicked() { + let _ = app.run("type.para", json!({"attrs": {"listType": if on { "none" } else { kind }}})); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(8.0); + if outline_button(ui, "Options", 66.0).clicked() { + app.ui.open_panel = Some("paragraph".into()); + } + }); + }); +} + +fn quick_actions_text(app: &mut DesignApp, ui: &mut Ui) { + let fw = full_width(ui); + let hw = (fw - 10.0) / 2.0; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + let r = outline_button(ui, "Change Case", hw); + egui::Popup::menu(&r).show(|ui| { + for (label, v) in [("UPPERCASE", "allCaps"), ("Small Caps", "smallCaps"), ("Normal", "normal")] { + if ui.button(label).clicked() { + let _ = app.run("type.char", json!({"attrs": {"capitalization": v}})); + ui.close(); + } + } + }); + if outline_button(ui, "Story Editor", hw).clicked() { + let _ = app.run("app.storyEditor", json!({})); + } + }); + ui.add_space(4.0); + if outline_button(ui, "Fill with Placeholder Text", fw).clicked() { + let _ = app.run("type.fillWithPlaceholder", json!({})); + } +} + +/// Text Frame section: columns, gutter, [Options]. +fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); let u = units(app); let Some(st) = app.session.active() else { return }; let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame)); let Some(opts) = fid.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()).map(|t| t.options.clone()) else { return }; - ui.horizontal(|ui| { - caption(ui, "Columns"); - if let Some(v) = number(ui, "tfc", Some(opts.columns as f64), "", 36.0, 0) { - let _ = app.run("object.textFrameOptions", json!({"columns": v.max(1.0) as u64})); - } - caption(ui, "Gutter"); - if let Some(v) = measure(ui, "tfg", Some(opts.gutter), u, 56.0) { - let _ = app.run("object.textFrameOptions", json!({"gutter": v})); - } - }); - ui.horizontal(|ui| { - caption(ui, "Inset"); - if let Some(v) = measure(ui, "tfi", Some(opts.inset[0]), u, 56.0) { - let _ = app.run("object.textFrameOptions", json!({"inset": v})); - } - }); - ui.horizontal(|ui| { - caption(ui, "Align"); - for (label, vj) in [("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")] { - let on = serde_json::to_value(opts.vertical_justification).ok().and_then(|v| v.as_str().map(|s| s == vj)).unwrap_or(false); - if ui.selectable_label(on, label).clicked() { - let _ = app.run("object.textFrameOptions", json!({"verticalJustification": vj})); - } - } - }); - if ui.button("Text Frame Options…").clicked() { + let b = block(ui, FIELD_H); + icons::paint(ui.painter(), sub(b, 0.0, 1.0, 19.0, 19.0), "text-columns", t.icon); + if let Some(v) = place(ui, sub(b, 22.0, 0.0, 54.0, FIELD_H), |ui| { + NumField::number("tfcols", Some(opts.columns as f64), "", 0).width(54.0).spinner().range(1.0, 40.0).show(ui) + }) { + let _ = app.run("object.textFrameOptions", json!({"columns": v.max(1.0) as u64})); + } + icons::paint(ui.painter(), sub(b, 83.0, 1.0, 19.0, 19.0), "text-gutter", t.icon); + if let Some(v) = place(ui, sub(b, 105.0, 0.0, 54.0, FIELD_H), |ui| { + NumField::measure("tfgut", Some(opts.gutter), u).width(54.0).spinner().range(0.0, 1440.0).show(ui) + }) { + let _ = app.run("object.textFrameOptions", json!({"gutter": v})); + } + let ow = (b.max.x - 8.0) - (b.min.x + 164.0); + if place(ui, sub(b, 164.0, 0.5, ow, 20.0), |ui| outline_button(ui, "Options", ow)).clicked() { app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); } } diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs new file mode 100644 index 0000000..c3a3aad --- /dev/null +++ b/crates/ui-egui/src/taskbar.rs @@ -0,0 +1,253 @@ +//! Contextual Task Bar (InDesign 2026 §6): a floating 36 pt HUD just below the selection with the +//! most common actions for it. Window > Contextual Task Bar toggles it (on by default). + +use egui::{Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::json; + +use crate::theme::Tokens; +use crate::widgets::{self, FIELD_H, NumField}; +use crate::{DesignApp, icons, panels}; + +pub const HEIGHT: f32 = 36.0; + +/// Show the bar under `sel` (screen rect of the selection), kept inside `canvas`. +pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { + if !app.ui.task_bar || ctx.input(|i| i.pointer.any_down() && !ctx.is_pointer_over_egui()) { + return; + } + let Some(st) = app.session.active() else { return }; + let has_text = st.selection.text.is_some(); + if st.selection.items.is_empty() && !has_text { + return; + } + let t = Tokens::get(ctx); + let id = egui::Id::new("task_bar"); + // Width from the previous frame (content-sized). + let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0); + let mut x = sel.center().x - w / 2.0; + x = x.clamp(canvas.min.x + 8.0, (canvas.max.x - w - 8.0).max(canvas.min.x + 8.0)); + let mut y = sel.max.y + 14.0; + if y + HEIGHT > canvas.max.y - 8.0 { + y = (sel.min.y - HEIGHT - 14.0).max(canvas.min.y + 8.0); + } + if y + HEIGHT > canvas.max.y - 4.0 { + y = canvas.max.y - HEIGHT - 8.0; + } + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos2(x, y)).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(6) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: egui::Color32::from_black_alpha(80) }) + .inner_margin(egui::Margin { left: 4, right: 8, top: 0, bottom: 0 }) + .show(ui, |ui| { + ui.set_height(HEIGHT); + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let (g, _) = ui.allocate_exact_size(vec2(12.0, 20.0), Sense::hover()); + icons::paint(ui.painter(), g, "grip", t.icon); + let text_frame = panels::sel_info(app).is_some_and(|i| i.is_text && i.count == 1); + if has_text || text_frame { + text_controls(app, ui, has_text); + } else { + object_controls(app, ui); + } + more(app, ui); + }); + }); + }); +} + +fn split_button(ui: &mut Ui, icon: &str, label: &str) -> (egui::Response, egui::Response) { + let t = Tokens::get(ui.ctx()); + let g = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(11.0), t.text); + let w = 26.0 + g.size().x + 8.0 + widgets::SEG_W; + let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); + let main = Rect::from_min_max(r.min, pos2(r.max.x - widgets::SEG_W, r.max.y)); + let seg = Rect::from_min_max(pos2(main.max.x, r.min.y), r.max); + let mr = ui.interact(main, ui.id().with(("split", label)), Sense::click()); + let sr = ui.interact(seg, ui.id().with(("splitseg", label)), Sense::click()); + for (rr, resp) in [(main, &mr), (seg, &sr)] { + if resp.hovered() { + ui.painter().rect_filled(rr, 0.0, t.hover); + } + } + icons::paint(ui.painter(), Rect::from_min_size(main.min + vec2(5.0, 2.5), vec2(16.0, 16.0)), icon, t.icon); + ui.painter().galley(pos2(main.min.x + 26.0, main.center().y - g.size().y / 2.0), g, t.text); + widgets::chevron_down(ui.painter(), seg.center(), t.icon); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + ui.painter().line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border)); + (mr, sr) +} + +fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { + let t = Tokens::get(ui.ctx()); + if !in_text { + let (main, seg) = split_button(ui, "fit-text", "Fit Text"); + if main.on_hover_text("Fit frame height to text").clicked() { + let _ = app.run("object.textFrameOptions", json!({"autoSize": "heightOnly"})); + } + egui::Popup::menu(&seg).show(|ui| { + for (label, v) in [("Off", "off"), ("Height Only", "heightOnly"), ("Width Only", "widthOnly"), ("Height and Width", "heightAndWidth")] { + if ui.button(label).clicked() { + let _ = app.run("object.textFrameOptions", json!({"autoSize": v})); + ui.close(); + } + } + }); + sep(ui); + } + let Some(a) = panels::text_attrs(app) else { return }; + let c = a["chars"].clone(); + let fam = c["fontFamily"].as_str().unwrap_or("").to_string(); + let sty = c["fontStyle"].as_str().unwrap_or("").to_string(); + let fams = designcraft_fonts::FontDb::global().families(); + let cur = fams.iter().position(|f| *f == fam); + if let Some(k) = widgets::dropdown_list(ui, if fam.is_empty() { "—" } else { &fam }, 150.0, &fams, cur) { + let f = fams[k].clone(); + let styles = designcraft_fonts::FontDb::global().styles(&f); + let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; + let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); + } + let styles = designcraft_fonts::FontDb::global().styles(&fam); + let cur = styles.iter().position(|s| *s == sty); + if let Some(k) = widgets::dropdown_list(ui, if sty.is_empty() { "—" } else { &sty }, 90.0, &styles, cur) { + let _ = app.run("type.char", json!({"attrs": {"fontStyle": styles[k]}})); + } + let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + icons::paint(ui.painter(), r, "font-size", t.icon); + const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0]; + if let Some(v) = NumField::number("tb_size", c["size"].as_f64(), " pt", 2).width(70.0).presets(SIZES).range(0.1, 1296.0).show(ui) { + let _ = app.run("type.char", json!({"attrs": {"size": v}})); + } + sep(ui); + // Text fill. + if let Some(doc) = app.session.active().map(|d| d.doc.clone()) { + let sw = c["fill"].as_str().unwrap_or("[Black]").to_string(); + let (col, g) = widgets::swatch_colors(&doc, &sw, 1.0); + let resp = widgets::swatch_chip(ui, 17.0, col, g).on_hover_text(format!("Fill: {sw}")); + let mut picked = None; + egui::Popup::menu(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for s in &doc.swatches { + if ui.selectable_label(s.name == sw, &s.name).clicked() { + picked = Some(s.name.clone()); + ui.close(); + } + } + }); + }); + if let Some(p) = picked { + let _ = app.run("type.char", json!({"attrs": {"fill": p}})); + } + } + // Paragraph alignment. + let al = a["para"]["align"].as_str().unwrap_or("left").to_string(); + let icon = match al.as_str() { + "center" => "palign-center", + "right" => "palign-right", + "leftJustified" | "centerJustified" | "rightJustified" | "fullyJustified" => "palign-justify-left", + _ => "palign-left", + }; + let resp = widgets::icon_toggle(ui, icon, false, "Paragraph alignment"); + egui::Popup::menu(&resp).show(|ui| { + for (label, v) in [ + ("Align Left", "left"), + ("Align Center", "center"), + ("Align Right", "right"), + ("Justify", "leftJustified"), + ("Justify All", "fullyJustified"), + ] { + if ui.selectable_label(al == v, label).clicked() { + let _ = app.run("type.para", json!({"attrs": {"align": v}})); + ui.close(); + } + } + }); + if widgets::icon_toggle(ui, "frame-options", false, "Text Frame Options").clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); + } +} + +fn object_controls(app: &mut DesignApp, ui: &mut Ui) { + let Some(i) = panels::sel_info(app) else { return }; + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + if i.is_graphic { + for (icon, mode, tip) in [ + ("fit-fill", "fillProportionally", "Fill Frame Proportionally"), + ("fit-prop", "fitProportionally", "Fit Content Proportionally"), + ("fit-frame", "fitFrameToContent", "Fit Frame to Content"), + ("fit-center", "centerContent", "Center Content"), + ] { + if widgets::icon_toggle(ui, icon, false, tip).clicked() { + let _ = app.run("object.fit", json!({"mode": mode})); + } + } + sep(ui); + } + for (k, (cmd, sw)) in [("object.fill", i.fill.clone()), ("object.stroke", i.stroke.clone())].into_iter().enumerate() { + let (col, g) = widgets::swatch_colors(&doc, &sw, 1.0); + let (r, resp) = ui.allocate_exact_size(vec2(17.0, 17.0), Sense::click()); + if k == 0 { + widgets::paint_chip(ui.painter(), r, col, g); + } else { + widgets::paint_stroke_chip(ui.painter(), r, col, egui::Color32::from_gray(20)); + } + let resp = resp.on_hover_text(format!("{}: {sw}", if k == 0 { "Fill" } else { "Stroke" })); + let mut picked = None; + egui::Popup::menu(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for s in &doc.swatches { + if ui.selectable_label(s.name == sw, &s.name).clicked() { + picked = Some(s.name.clone()); + ui.close(); + } + } + }); + }); + if let Some(p) = picked { + let _ = app.run(cmd, json!({"swatch": p})); + } + } + const WEIGHTS: &[f64] = &[0.25, 0.5, 1.0, 2.0, 3.0, 4.0, 6.0, 8.0, 10.0]; + if let Some(v) = NumField::number("tb_sw", Some(i.stroke_weight), " pt", 3).width(64.0).spinner().presets(WEIGHTS).range(0.0, 800.0).show(ui) { + let _ = app.run("object.stroke", json!({"weight": v})); + } + sep(ui); + for (cmd, icon, tip) in [("object.bringToFront", "objalign-top", "Bring to Front"), ("object.sendToBack", "objalign-bottom", "Send to Back")] { + if widgets::icon_toggle(ui, icon, false, tip).clicked() { + let _ = app.run(cmd, json!({})); + } + } + if i.count > 1 && widgets::outline_button(ui, "Group", 52.0).clicked() { + let _ = app.run("object.group", json!({})); + } +} + +fn more(app: &mut DesignApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(22.0, FIELD_H), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + for k in 0..3 { + ui.painter().circle_filled(pos2(r.min.x + 4.0 + k as f32 * 7.0, r.center().y), 1.8, t.icon); + } + let resp = resp.on_hover_text("More Options"); + egui::Popup::menu(&resp).show(|ui| { + if ui.button("Hide Bar").clicked() { + app.ui.task_bar = false; + ui.close(); + } + if ui.button("Properties Panel").clicked() { + let _ = app.run("window.panel", json!({"panel": "properties"})); + ui.close(); + } + }); +} + +fn sep(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(1.0, 24.0), Sense::hover()); + ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.border)); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6737fa5..fbde8c5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -232,8 +232,8 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) { ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, *t)); let text_styles: std::collections::BTreeMap = [ (TextStyle::Small, FontId::proportional(10.0)), - (TextStyle::Body, FontId::proportional(11.5)), - (TextStyle::Button, FontId::proportional(11.5)), + (TextStyle::Body, FontId::proportional(11.0)), + (TextStyle::Button, FontId::proportional(11.0)), (TextStyle::Heading, FontId::new(15.0, FontFamily::Name("semibold".into()))), (TextStyle::Monospace, FontId::monospace(11.5)), ] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 9098049..dab803e 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -157,58 +157,169 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Fill/stroke proxy squares with default/swap, and apply color / gradient / none. +/// The area under the tools (InDesign 2026 §3 group 5): mini Default Fill/Stroke and Swap, the +/// overlapping Fill/Stroke proxy, Formatting affects container / text, the Apply None well, a +/// utility button and the Screen Mode well. fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32) { let t = Tokens::get(ui.ctx()); let info = crate::panels::sel_info(app); let doc = app.session.active().map(|d| d.doc.clone()); - let (r, _) = ui.allocate_exact_size(vec2(width, 40.0), Sense::hover()); - let base = egui::pos2(r.center().x - 15.0, r.min.y + 4.0); - let fill_r = egui::Rect::from_min_size(base, vec2(20.0, 20.0)); - let stroke_r = egui::Rect::from_min_size(base + vec2(10.0, 10.0), vec2(20.0, 20.0)); - let (fc, fg) = match (&doc, &info) { - (Some(d), Some(i)) => crate::widgets::swatch_colors(d, &i.fill, 1.0), - _ => (None, None), + let stroke_front_id = egui::Id::new("proxy_stroke_front"); + let text_id = egui::Id::new("proxy_text"); + let stroke_front: bool = ui.data(|d| d.get_temp(stroke_front_id)).unwrap_or(false); + let text_mode: bool = ui.data(|d| d.get_temp(text_id)).unwrap_or(false); + let (fill_sw, stroke_sw) = match &info { + Some(i) => (i.fill.clone(), i.stroke.clone()), + None => ("[None]".to_string(), "[Black]".to_string()), }; - let (sc, _) = match (&doc, &info) { - (Some(d), Some(i)) => crate::widgets::swatch_colors(d, &i.stroke, 1.0), - _ => (Some(Color32::BLACK), None), + let x0 = ui.max_rect().min.x + (width - 30.0) / 2.0; + // Row 1: default colours + swap. + let (row, _) = ui.allocate_exact_size(vec2(width, 11.0), Sense::hover()); + let def = egui::Rect::from_min_size(egui::pos2(x0, row.min.y), vec2(10.0, 10.0)); + icons::paint(ui.painter(), def, "default-colors", t.icon); + if ui.interact(def, ui.id().with("proxy_default"), Sense::click()).on_hover_text("Default Fill and Stroke (D)").clicked() { + let _ = app.run("object.fill", json!({"swatch": "[None]"})); + let _ = app.run("object.stroke", json!({"swatch": "[Black]"})); + } + let swap = egui::Rect::from_min_size(egui::pos2(x0 + 19.0, row.min.y), vec2(11.0, 11.0)); + icons::paint(ui.painter(), swap, "swap", t.icon); + if ui.interact(swap, ui.id().with("proxy_swap_btn"), Sense::click()).on_hover_text("Swap Fill and Stroke (Shift+X)").clicked() { + let _ = app.run("object.fill", json!({"swatch": stroke_sw})); + let _ = app.run("object.stroke", json!({"swatch": fill_sw})); + } + ui.add_space(2.0); + // Proxy: 19.5 pt squares, fill top-left, stroke bottom-right. + let (r, _) = ui.allocate_exact_size(vec2(width, 31.0), Sense::hover()); + let fill_r = egui::Rect::from_min_size(egui::pos2(x0, r.min.y), vec2(19.5, 19.5)); + let stroke_r = egui::Rect::from_min_size(egui::pos2(x0 + 10.0, r.min.y + 10.0), vec2(19.5, 19.5)); + let (fc, fg) = match &doc { + Some(d) => crate::widgets::swatch_colors(d, &fill_sw, 1.0), + None => (None, None), }; + let sc = doc.as_ref().and_then(|d| crate::widgets::swatch_colors(d, &stroke_sw, 1.0).0); let p = ui.painter(); - // Stroke square (ring) behind, fill square in front. - match sc { - Some(c) => { - p.rect_filled(stroke_r, 0.0, c); - p.rect_filled(stroke_r.shrink(5.0), 0.0, t.panel); - } - None => crate::widgets::paint_chip(p, stroke_r, None, None), - } - p.rect_stroke(stroke_r, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - crate::widgets::paint_chip(p, fill_r, fc, fg); - p.rect_stroke(fill_r, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); - let resp = ui.interact(r, ui.id().with("proxy_swap"), Sense::click()); - if resp.double_clicked() { - let _ = app.run("window.panel", json!({"panel": "swatches"})); - } - // Apply Color / Gradient / None, stacked (InDesign 2026 single column). - ui.vertical_centered(|ui| { - ui.spacing_mut().item_spacing.y = 2.0; - for (icon, tip, sw) in [("fill-proxy", "Apply Color (,)", "[Black]"), ("none", "Apply None (/)", "[None]")] { - if tool_button(ui, icon, false, tip).clicked() && !sw.is_empty() { - let _ = app.run("object.fill", json!({"swatch": sw})); + let paint_stroke = |p: &egui::Painter| { + match sc { + Some(c) => { + p.rect_filled(stroke_r, 0.0, c); + p.rect_filled(stroke_r.shrink(5.5), 0.0, t.panel); + p.rect_stroke(stroke_r.shrink(5.5), 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + } + None => { + crate::widgets::paint_chip(p, stroke_r, None, None); + p.rect_filled(stroke_r.shrink(5.5), 0.0, t.panel); } } - ui.add_space(4.0); - let r = ui.available_rect_before_wrap(); - ui.painter().line_segment([egui::pos2(r.min.x + 6.0, r.min.y), egui::pos2(r.max.x - 6.0, r.min.y)], Stroke::new(1.0, t.divider)); - ui.add_space(4.0); - let preview = app.ui.screen_mode == crate::ScreenMode::Preview; - let tip = if preview { "Preview (W)" } else { "Normal (W)" }; - if tool_button(ui, if preview { "screen-preview" } else { "screen-normal" }, true, tip).clicked() { - app.ui.screen_mode = if preview { crate::ScreenMode::Normal } else { crate::ScreenMode::Preview }; + p.rect_stroke(stroke_r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + }; + let paint_fill = |p: &egui::Painter| { + crate::widgets::paint_chip(p, fill_r, fc, fg); + p.rect_stroke(fill_r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); + }; + if stroke_front { + paint_fill(p); + paint_stroke(p); + } else { + paint_stroke(p); + paint_fill(p); + } + let fresp = ui.interact(fill_r, ui.id().with("proxy_fill"), Sense::click()).on_hover_text("Fill (X)"); + let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_text("Stroke (X)"); + if fresp.clicked() || sresp.clicked() { + let front = + if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) }; + ui.data_mut(|d| d.insert_temp(stroke_front_id, front)); + } + if fresp.double_clicked() || sresp.double_clicked() { + let _ = app.run("window.panel", json!({"panel": "swatches"})); + } + ui.add_space(3.0); + // Formatting affects container / text. + let (row, _) = ui.allocate_exact_size(vec2(width, 13.0), Sense::hover()); + for (k, (icon, tip)) in + [("format-container", "Formatting affects container (J)"), ("format-text", "Formatting affects text (J)")].into_iter().enumerate() + { + let r = egui::Rect::from_min_size(egui::pos2(x0 - 1.0 + k as f32 * 18.0, row.min.y), vec2(13.0, 13.0)); + let on = text_mode == (k == 1); + if on { + ui.painter().rect(r, 1.5, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Inside); + } + icons::paint(ui.painter(), r.shrink(1.5), icon, if on { t.text_strong } else { t.icon }); + if ui.interact(r, ui.id().with(("proxy_fmt", k)), Sense::click()).on_hover_text(tip).clicked() { + ui.data_mut(|d| d.insert_temp(text_id, k == 1)); + } + } + ui.add_space(5.0); + // Apply None well (flyout: Apply Color / Gradient / None). + let (row, _) = ui.allocate_exact_size(vec2(width, 21.0), Sense::hover()); + let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); + let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click()).on_hover_text("Apply None (/)"); + ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + let chip = egui::Rect::from_center_size(well.center(), vec2(14.0, 14.0)); + crate::widgets::paint_chip(ui.painter(), chip, None, None); + flyout_triangle(ui.painter(), well, t.icon); + let target = if stroke_front { "object.stroke" } else { "object.fill" }; + if resp.clicked() { + let _ = app.run(target, json!({"swatch": "[None]"})); + } + egui::Popup::context_menu(&resp).show(|ui| { + for (label, sw) in [("Apply Color", "[Black]"), ("Apply None", "[None]")] { + if ui.button(label).clicked() { + let _ = app.run(target, json!({"swatch": sw})); + ui.close(); + } } }); - let _ = width; + ui.add_space(6.0); + separator(ui, &t); + // Utility (frame options). + ui.vertical_centered(|ui| { + if tool_button(ui, "frame-options", false, "Text Frame Options").clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); + } + }); + separator(ui, &t); + // Screen Mode well with flyout. + let (row, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover()); + let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0)); + let resp = ui.interact(well, ui.id().with("screen_mode"), Sense::click()); + ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside); + let preview = app.ui.screen_mode == crate::ScreenMode::Preview; + icons::paint( + ui.painter(), + egui::Rect::from_center_size(well.center(), vec2(15.0, 15.0)), + if preview { "screen-preview" } else { "screen-normal" }, + t.text_strong, + ); + flyout_triangle(ui.painter(), well, t.icon); + let resp = resp.on_hover_text(if preview { "Preview (W)" } else { "Normal (W)" }); + if resp.clicked() { + let _ = app.run("view.togglePreview", json!({})); + } + egui::Popup::context_menu(&resp).show(|ui| { + for (label, mode) in [("Normal", "normal"), ("Preview", "preview"), ("Bleed", "bleed"), ("Slug", "slug"), ("Presentation", "presentation")] { + if ui.button(label).clicked() { + let _ = app.run("view.screenMode", json!({"mode": mode})); + ui.close(); + } + } + }); +} + +fn flyout_triangle(p: &egui::Painter, r: egui::Rect, c: Color32) { + let q = r.right_bottom() - vec2(2.0, 2.0); + p.add(egui::Shape::convex_polygon(vec![q, q - vec2(4.0, 0.0), q - vec2(0.0, 4.0)], c, Stroke::NONE)); +} + +fn separator(ui: &mut egui::Ui, t: &Tokens) { + ui.add_space(4.0); + let r = ui.available_rect_before_wrap(); + ui.painter().line_segment( + [egui::pos2(r.min.x + 6.0, r.min.y), egui::pos2(r.max.x - 6.0, r.min.y)], + Stroke::new(1.0, Color32::from_rgb(0x4c, 0x4b, 0x4b)), + ); + let _ = t; + ui.add_space(5.0); } /// A Tools-panel button: 24 pt pitch; the active tool sits in a 28×20 pt `#303030` well with a rim. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 152137b..c7059ee 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,94 +1,440 @@ -//! Small InDesign-style widgets: measurement fields with units and arithmetic, labelled -//! fields, swatch chips, section headers. +//! Small InDesign-style widgets (measured from InDesign 2026, `plan/indesign/11-observed-ui.md` §4): +//! 21 pt fields (`#454545` fill, 1 pt `#747474` border) with an optional 18 pt spinner segment on +//! the **left** of the value and an optional preset chevron segment on the right, 20 pt outline +//! buttons, underlined link labels, `•••` more-options, 21 pt icon toggles in a `#303030` well, +//! section headers and 1.5 pt section rules. use designcraft_geom::Unit; use designcraft_geom::units::{format_measure_prec, parse_measure}; -use egui::{Color32, Response, Sense, Stroke, StrokeKind, Ui, vec2}; +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use crate::theme::Tokens; -/// A measurement field: shows `value` (points) in `unit`; on Enter/focus loss parses any unit and -/// arithmetic. Returns the new value in points when edited. -pub fn measure(ui: &mut Ui, id: &str, value: Option, unit: Unit, width: f32) -> Option { - let t = Tokens::get(ui.ctx()); - let key = ui.id().with(id); - let mut buf: String = - ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| value.map(|v| format_measure_prec(v, unit, 3)).unwrap_or_default()); - let r = ui.add_sized( - [width, 21.0], - egui::TextEdit::singleline(&mut buf).font(egui::FontId::proportional(12.0)).background_color(t.input).margin(egui::Margin::symmetric(4, 1)), - ); - let mut out = None; - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(key, buf.clone())); - } - if r.lost_focus() { - if let Ok(v) = parse_measure(&buf, unit) - && value.is_none_or(|old| (old - v).abs() > 1e-9) - { - out = Some(v); +/// Field height including the 1 pt border. +pub const FIELD_H: f32 = 21.0; +/// Width of the spinner (⌃⌄) and preset (⌄) segments. +pub const SEG_W: f32 = 18.0; +/// Outline button height. +pub const BUTTON_H: f32 = 20.0; +/// UI label size (InDesign: about 10.5–11 pt). +pub const LABEL_SIZE: f32 = 11.0; + +fn label_font() -> egui::FontId { + egui::FontId::proportional(LABEL_SIZE) +} + +/// What a numeric field holds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum NumKind<'a> { + /// A length in points shown in `Unit`; accepts any unit and arithmetic. + Measure(Unit), + /// A plain number with a display suffix (`%`, `°`, ` pt`). + Number { suffix: &'a str, decimals: usize }, +} + +impl NumKind<'_> { + pub fn format(&self, v: f64) -> String { + match *self { + NumKind::Measure(u) => format_measure_prec(v, u, 3), + NumKind::Number { suffix, decimals } => { + let s = format!("{v:.decimals$}"); + let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; + let s = if s == "-0" { "0".to_string() } else { s }; + format!("{s}{suffix}") + } } - ui.data_mut(|d| d.remove::(key)); - } else if !r.has_focus() { - ui.data_mut(|d| d.remove::(key)); } - out + pub fn parse(&self, s: &str) -> Option { + match *self { + NumKind::Measure(u) => parse_measure(s, u).ok(), + NumKind::Number { .. } => { + // Numbers accept the same arithmetic as measures (`12+3`, `50%*2`): evaluate as points. + let cleaned: String = + s.chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | '*' | '/' | '(' | ')' | ' ')).collect(); + parse_measure(cleaned.trim(), Unit::Points).ok().or_else(|| cleaned.trim().parse().ok()) + } + } + } + /// One spinner click (points for measures). + pub fn step(&self) -> f64 { + match *self { + NumKind::Measure(u) => match u { + Unit::Points | Unit::Picas | Unit::Ciceros | Unit::Pixels | Unit::Agates => 1.0, + Unit::Inches | Unit::InchesDecimal => 72.0 / 16.0, + Unit::Millimeters | Unit::Centimeters | Unit::Q | Unit::Ha => Unit::Millimeters.to_pt(1.0), + }, + NumKind::Number { .. } => 1.0, + } + } +} + +/// A numeric field. Build with [`NumField::measure`] / [`NumField::number`], then [`NumField::show`]. +pub struct NumField<'a> { + id: &'a str, + value: Option, + kind: NumKind<'a>, + width: f32, + spinner: bool, + presets: &'a [f64], + step: Option, + range: (f64, f64), + enabled: bool, + parens: bool, +} + +impl<'a> NumField<'a> { + pub fn measure(id: &'a str, value: Option, unit: Unit) -> Self { + Self::new(id, value, NumKind::Measure(unit)) + } + pub fn number(id: &'a str, value: Option, suffix: &'a str, decimals: usize) -> Self { + Self::new(id, value, NumKind::Number { suffix, decimals }) + } + fn new(id: &'a str, value: Option, kind: NumKind<'a>) -> Self { + NumField { id, value, kind, width: 72.0, spinner: false, presets: &[], step: None, range: (f64::MIN, f64::MAX), enabled: true, parens: false } + } + /// Total width (spinner and preset segments included). + pub fn width(mut self, w: f32) -> Self { + self.width = w; + self + } + /// Show the ⌃⌄ spinner segment on the left. + pub fn spinner(mut self) -> Self { + self.spinner = true; + self + } + /// Show a ⌄ preset segment on the right listing `presets`. + pub fn presets(mut self, p: &'a [f64]) -> Self { + self.presets = p; + self + } + pub fn step(mut self, s: f64) -> Self { + self.step = Some(s); + self + } + pub fn range(mut self, min: f64, max: f64) -> Self { + self.range = (min, max); + self + } + /// Show the value in parentheses (an automatic value, like auto leading "(14.4 pt)"). + pub fn parens(mut self, on: bool) -> Self { + self.parens = on; + self + } + fn fmt(&self, v: f64) -> String { + if self.parens { format!("({})", self.kind.format(v)) } else { self.kind.format(v) } + } + fn parse(&self, s: &str) -> Option { + self.kind.parse(s.trim().trim_start_matches('(').trim_end_matches(')')) + } + pub fn enabled(mut self, on: bool) -> Self { + self.enabled = on; + self + } + + /// Draw the field; returns the new value when the user commits an edit (Enter, focus loss, + /// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset). + pub fn show(self, ui: &mut Ui) -> Option { + let t = Tokens::get(ui.ctx()); + let key = ui.id().with(self.id); + let (rect, _) = ui.allocate_exact_size(vec2(self.width, FIELD_H), Sense::hover()); + let step = self.step.unwrap_or_else(|| self.kind.step()); + let clamp = |v: f64| v.clamp(self.range.0, self.range.1); + let mut out: Option = None; + let mut text_rect = rect; + let focused_id = key.with("te"); + let focused = ui.memory(|m| m.has_focus(focused_id)); + let (bg, fg, border) = + if focused { (Color32::WHITE, Color32::from_gray(20), Color32::from_rgb(0x45, 0xa0, 0xf5)) } else { (t.input, t.text, t.field_border) }; + let fg = if self.enabled { fg } else { t.text_disabled }; + // Spinner segment. + if self.spinner { + let seg = Rect::from_min_size(rect.min, vec2(SEG_W, FIELD_H)); + text_rect.min.x = seg.max.x; + ui.painter().rect_filled(seg, 0.0, t.input); + let up = Rect::from_min_max(seg.min, pos2(seg.max.x, seg.center().y)); + let down = Rect::from_min_max(pos2(seg.min.x, seg.center().y), seg.max); + for (r, dir) in [(up, 1.0), (down, -1.0)] { + let resp = ui.interact(r, key.with(("spin", dir as i32)), if self.enabled { Sense::click() } else { Sense::hover() }); + if resp.hovered() && self.enabled { + ui.painter().rect_filled(r.shrink(1.0), 0.0, t.hover); + } + let c = r.center(); + let (a, b) = if dir > 0.0 { (2.5, -1.5) } else { (-2.5, 1.5) }; + ui.painter().line_segment( + [pos2(c.x - 4.0, c.y + a * 0.6), pos2(c.x, c.y + b)], + Stroke::new(1.0, if self.enabled { t.icon } else { t.text_disabled }), + ); + ui.painter().line_segment( + [pos2(c.x, c.y + b), pos2(c.x + 4.0, c.y + a * 0.6)], + Stroke::new(1.0, if self.enabled { t.icon } else { t.text_disabled }), + ); + if resp.clicked() { + let mul = if ui.input(|i| i.modifiers.shift) { 10.0 } else { 1.0 }; + out = Some(clamp(self.value.unwrap_or(0.0) + dir * step * mul)); + } + } + } + // Preset segment. + let mut preset_resp = None; + if !self.presets.is_empty() { + let seg = Rect::from_min_max(pos2(rect.max.x - SEG_W, rect.min.y), rect.max); + text_rect.max.x = seg.min.x; + let resp = ui.interact(seg, key.with("presets"), Sense::click()); + ui.painter().rect_filled(seg, 0.0, if resp.hovered() { t.hover } else { t.input }); + chevron_down(ui.painter(), seg.center(), t.icon); + preset_resp = Some(resp); + } + // Value. + ui.painter().rect_filled(text_rect, 0.0, bg); + let mut buf: String = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| self.value.map(|v| self.fmt(v)).unwrap_or_default()); + let te = egui::TextEdit::singleline(&mut buf) + .id(focused_id) + .frame(egui::Frame::NONE) + .font(label_font()) + .text_color(fg) + .margin(egui::Margin::ZERO) + .vertical_align(egui::Align::Center); + let r = ui + .add_enabled_ui(self.enabled, |ui| ui.put(Rect::from_min_max(text_rect.min + vec2(5.0, 1.0), text_rect.max - vec2(1.0, 1.0)), te)) + .inner; + if r.has_focus() { + // ↑/↓ step the value while typing. + let (up, down, shift) = ui.input_mut(|i| { + let s = i.modifiers.shift; + let up = i.consume_key(egui::Modifiers::SHIFT, egui::Key::ArrowUp) || i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp); + let down = i.consume_key(egui::Modifiers::SHIFT, egui::Key::ArrowDown) || i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown); + (up, down, s) + }); + if up || down { + let base = self.parse(&buf).or(self.value).unwrap_or(0.0); + let v = clamp(base + if up { step } else { -step } * if shift { 10.0 } else { 1.0 }); + buf = self.fmt(v); + out = Some(v); + } + ui.data_mut(|d| d.insert_temp(key, buf.clone())); + } + if r.lost_focus() { + if let Some(v) = self.parse(&buf).map(clamp) + && self.value.is_none_or(|old| (old - v).abs() > 1e-9) + { + out = Some(v); + } + ui.data_mut(|d| d.remove::(key)); + } else if !r.has_focus() { + ui.data_mut(|d| d.remove::(key)); + } + if out.is_some() && !r.has_focus() { + ui.data_mut(|d| d.remove::(key)); + } + // Frame: outer border plus segment rules. + let p = ui.painter(); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, border), StrokeKind::Inside); + if self.spinner { + p.line_segment([pos2(rect.min.x + SEG_W, rect.min.y), pos2(rect.min.x + SEG_W, rect.max.y)], Stroke::new(1.0, t.field_border)); + } + if !self.presets.is_empty() { + p.line_segment([pos2(rect.max.x - SEG_W, rect.min.y), pos2(rect.max.x - SEG_W, rect.max.y)], Stroke::new(1.0, t.field_border)); + } + if let Some(resp) = preset_resp { + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(self.width); + for v in self.presets { + if ui.selectable_label(self.value.is_some_and(|c| (c - v).abs() < 1e-9), self.kind.format(*v)).clicked() { + out = Some(*v); + ui.close(); + } + } + }); + } + out + } +} + +/// A plain measurement field (no spinner), e.g. Transform X/Y/W/H. +pub fn measure(ui: &mut Ui, id: &str, value: Option, unit: Unit, width: f32) -> Option { + NumField::measure(id, value, unit).width(width).show(ui) +} + +/// A measurement field with the spinner segment. +pub fn spin_measure(ui: &mut Ui, id: &str, value: Option, unit: Unit, width: f32) -> Option { + NumField::measure(id, value, unit).width(width).spinner().show(ui) } /// A plain number field (percent, degrees, counts). pub fn number(ui: &mut Ui, id: &str, value: Option, suffix: &str, width: f32, decimals: usize) -> Option { + NumField::number(id, value, suffix, decimals).width(width).show(ui) +} + +/// A number field with the spinner segment. +pub fn spin_number(ui: &mut Ui, id: &str, value: Option, suffix: &str, width: f32, decimals: usize) -> Option { + NumField::number(id, value, suffix, decimals).width(width).spinner().show(ui) +} + +pub fn chevron_down(p: &egui::Painter, c: egui::Pos2, col: Color32) { + p.line_segment([pos2(c.x - 4.0, c.y - 2.0), pos2(c.x, c.y + 2.0)], Stroke::new(1.0, col)); + p.line_segment([pos2(c.x, c.y + 2.0), pos2(c.x + 4.0, c.y - 2.0)], Stroke::new(1.0, col)); +} + +/// A dropdown box (field style, ⌄ segment on the right). Returns the response; open a popup on it +/// with [`egui::Popup::menu`]. +pub fn dropdown(ui: &mut Ui, text: &str, width: f32) -> Response { + dropdown_h(ui, text, width, FIELD_H) +} + +pub fn dropdown_h(ui: &mut Ui, text: &str, width: f32, h: f32) -> Response { let t = Tokens::get(ui.ctx()); - let key = ui.id().with(id); - let show = |v: f64| { - let s = format!("{v:.decimals$}"); - let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; - format!("{s}{suffix}") - }; - let mut buf: String = ui.data(|d| d.get_temp::(key)).unwrap_or_else(|| value.map(show).unwrap_or_default()); - let r = ui.add_sized( - [width, 21.0], - egui::TextEdit::singleline(&mut buf).font(egui::FontId::proportional(12.0)).background_color(t.input).margin(egui::Margin::symmetric(4, 1)), - ); + let (r, resp) = ui.allocate_exact_size(vec2(width, h), Sense::click()); + let p = ui.painter(); + p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input }); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let seg = Rect::from_min_max(pos2(r.max.x - SEG_W, r.min.y), r.max); + p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border)); + chevron_down(p, seg.center(), t.icon); + let clip = Rect::from_min_max(r.min, pos2(seg.min.x - 2.0, r.max.y)); + p.with_clip_rect(clip).text(pos2(r.min.x + 7.0, r.center().y), egui::Align2::LEFT_CENTER, text, label_font(), t.text); + resp +} + +/// A dropdown that lists `items` and returns the picked index. +pub fn dropdown_list(ui: &mut Ui, text: &str, width: f32, items: &[String], current: Option) -> Option { + let resp = dropdown(ui, text, width); let mut out = None; - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(key, buf.clone())); - } - if r.lost_focus() { - let cleaned: String = buf.chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | '-')).collect(); - if let Ok(v) = cleaned.parse::() - && value.is_none_or(|old| (old - v).abs() > 1e-9) - { - out = Some(v); - } - ui.data_mut(|d| d.remove::(key)); - } else if !r.has_focus() { - ui.data_mut(|d| d.remove::(key)); - } + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(width); + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (i, it) in items.iter().enumerate() { + if ui.selectable_label(current == Some(i), it).clicked() { + out = Some(i); + ui.close(); + } + } + }); + }); out } +/// A 20 pt outline button (1 pt `#747474`, transparent fill, centred label). +pub fn outline_button(ui: &mut Ui, label: &str, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(width, BUTTON_H), Sense::click()); + let p = ui.painter(); + if resp.is_pointer_button_down_on() { + p.rect_filled(r, 2.0, t.well); + } else if resp.hovered() { + p.rect_filled(r, 2.0, t.hover); + } + p.rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + p.text(r.center(), egui::Align2::CENTER_CENTER, label, label_font(), t.text); + resp +} + +/// Two half-width buttons side by side (10 pt gutter); returns which was clicked. +pub fn button_pair(ui: &mut Ui, a: &str, b: &str) -> (bool, bool) { + let w = (full_width(ui) - 10.0) / 2.0; + let mut out = (false, false); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + out.0 = outline_button(ui, a, w).clicked(); + out.1 = outline_button(ui, b, w).clicked(); + }); + out +} + +/// Width of a full-width row in the Properties panel (214 pt in a 253 pt panel). +pub fn full_width(ui: &Ui) -> f32 { + (ui.available_width() - 8.0).max(60.0) +} + +/// An underlined link label (`Stroke`, `Corner`, `Opacity`) that opens the full panel. +pub fn link_label(ui: &mut Ui, text: &str, width: f32) -> Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click()); + let g = ui.painter().layout_no_wrap(text.to_string(), label_font(), t.text); + let pos = pos2(r.min.x, r.center().y - g.size().y / 2.0); + let w = g.size().x; + ui.painter().galley(pos, g, t.text); + let y = r.center().y + 6.5; + ui.painter().line_segment([pos2(r.min.x, y), pos2(r.min.x + w, y)], Stroke::new(1.0, if resp.hovered() { t.text_strong } else { t.text_dim })); + resp.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// `•••` more-options, right-aligned on its own row at the bottom of a section. +pub fn more_options(ui: &mut Ui) -> Response { + let t = Tokens::get(ui.ctx()); + let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover()); + let r = Rect::from_min_max(pos2(row.max.x - 26.0, row.min.y), pos2(row.max.x - 4.0, row.max.y)); + let resp = ui.interact(r, ui.id().with(("more", row.min.y as i32)), Sense::click()); + for i in 0..3 { + ui.painter().circle_filled(pos2(r.min.x + 4.0 + i as f32 * 7.0, r.center().y), 1.9, if resp.hovered() { t.text_strong } else { t.icon }); + } + resp.on_hover_text("More Options") +} + /// A small dim label (field captions like `X:` or `W:`). pub fn caption(ui: &mut Ui, s: &str) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(s).size(11.5).color(t.text_dim)); + ui.label(egui::RichText::new(s).size(LABEL_SIZE).color(t.text)); +} + +/// A right-aligned caption of a fixed width (`W:` before a spinner). +pub fn caption_w(ui: &mut Ui, s: &str, w: f32) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); + ui.painter().text(pos2(r.max.x - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, s, egui::FontId::proportional(11.5), t.text); +} + +/// The selection-type line at the top of the Properties panel ("No Selection", "Rectangle", …): +/// a 33 pt band followed by a section rule. +pub fn selection_band(ui: &mut Ui, title: &str) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover()); + ui.painter().text(pos2(r.min.x, r.center().y + 1.0), egui::Align2::LEFT_CENTER, title, crate::theme::semibold(11.5), t.text_strong); + rule(ui); } /// Panel section header (InDesign 2026: regular-weight white title, no disclosure chevron; a /// 1.5 pt rule separates sections). Returns whether to show the section body (always true). pub fn section(ui: &mut Ui, title: &str, _default_open: bool) -> bool { let t = Tokens::get(ui.ctx()); - ui.add_space(6.0); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); + ui.add_space(7.0); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 16.0), Sense::hover()); ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong); + ui.add_space(3.0); true } -pub fn divider(ui: &mut Ui) { +/// A section header whose controls sit on the same row (Rulers & Grids, Guides, Flex Layout): +/// returns the remaining rect to the right of the title. +pub fn section_row(ui: &mut Ui, title: &str, add: impl FnOnce(&mut Ui)) { let t = Tokens::get(ui.ctx()); - let r = ui.available_rect_before_wrap(); - ui.painter() - .line_segment([egui::pos2(r.min.x - 10.0, r.min.y + 4.0), egui::pos2(r.max.x + 10.0, r.min.y + 4.0)], Stroke::new(1.5, t.section_divider)); ui.add_space(8.0); + ui.horizontal(|ui| { + ui.set_min_height(23.0); + let (r, _) = ui.allocate_exact_size(vec2(100.0, 23.0), Sense::hover()); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, title, egui::FontId::proportional(11.5), t.text_strong); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + add(ui) + }); + }); + ui.add_space(6.0); +} + +/// The 1.5 pt section rule, full panel width. +pub fn rule(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.5), Sense::hover()); + let clip = ui.clip_rect(); + ui.painter().with_clip_rect(clip.expand2(vec2(12.0, 0.0))).rect_filled( + Rect::from_min_max(pos2(r.min.x - 12.0, r.min.y), pos2(r.max.x + 12.0, r.max.y)), + 0.0, + t.section_divider, + ); +} + +pub fn divider(ui: &mut Ui) { + ui.add_space(8.0); + rule(ui); } /// A swatch chip (colour square, [None] slash, gradient ramp). @@ -125,6 +471,20 @@ pub fn paint_chip(p: &egui::Painter, r: egui::Rect, color: Option, grad } } +/// A stroke chip: a ring of the colour around a dark centre (None = white with a red slash). +pub fn paint_stroke_chip(p: &egui::Painter, r: egui::Rect, color: Option, inner: Color32) { + match color { + Some(c) => { + p.rect_filled(r, 0.0, c); + p.rect_filled(r.shrink(r.width() * 0.3), 0.0, inner); + } + None => { + paint_chip(p, r, None, None); + p.rect_stroke(r.shrink(r.width() * 0.3), 0.0, Stroke::new(1.0, Color32::from_gray(40)), StrokeKind::Middle); + } + } +} + /// Resolve a swatch to a display colour (and gradient end colours). pub fn swatch_colors(doc: &designcraft_doc::Document, name: &str, tint: f32) -> (Option, Option<(Color32, Color32)>) { let conv = |c: designcraft_color::Color| { @@ -139,7 +499,154 @@ pub fn swatch_colors(doc: &designcraft_doc::Document, name: &str, tint: f32) -> (doc.resolve_color(name, tint).map(conv), None) } -/// An icon toggle button (selected = darker well). +/// A 21 pt icon toggle (active = `#303030` well with a 1 pt rim). pub fn icon_toggle(ui: &mut Ui, icon: &str, on: bool, tip: &str) -> Response { - crate::icons::button(ui, icon, 22.0, on, tip) + icon_toggle_sized(ui, icon, on, tip, vec2(23.0, 21.0)) +} + +pub fn icon_toggle_sized(ui: &mut Ui, icon: &str, on: bool, tip: &str, size: egui::Vec2) -> Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(size, Sense::click()); + if on { + ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r, 2.0, t.hover); + } + let s = (size.y - 4.0).min(17.0); + crate::icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, if on { t.text_strong } else { t.icon }); + if tip.is_empty() { resp } else { resp.on_hover_text(tip) } +} + +/// An InDesign checkbox: 12 pt rounded box, white check, label. +pub fn checkbox(ui: &mut Ui, on: &mut bool, label: &str) -> Response { + let t = Tokens::get(ui.ctx()); + let g = ui.painter().layout_no_wrap(label.to_string(), label_font(), t.text); + let (r, mut resp) = ui.allocate_exact_size(vec2(18.0 + g.size().x, FIELD_H), Sense::click()); + let b = Rect::from_center_size(pos2(r.min.x + 6.5, r.center().y), vec2(12.0, 12.0)); + if *on { + ui.painter().rect_filled(b, 2.0, t.icon); + ui.painter().line_segment([b.min + vec2(2.5, 6.0), b.min + vec2(5.0, 8.5)], Stroke::new(1.5, t.panel)); + ui.painter().line_segment([b.min + vec2(5.0, 8.5), b.min + vec2(9.5, 3.5)], Stroke::new(1.5, t.panel)); + } else { + ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + ui.painter().galley(pos2(r.min.x + 18.0, r.center().y - g.size().y / 2.0), g, t.text); + if resp.clicked() { + *on = !*on; + resp.mark_changed(); + } + resp +} + +/// A two-segment control ([Paragraph Styles | Character Styles]); returns the clicked index. +pub fn segmented(ui: &mut Ui, labels: &[&str], active: usize, width: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(width, BUTTON_H), Sense::hover()); + let w = width / labels.len() as f32; + let mut out = None; + for (i, l) in labels.iter().enumerate() { + let sr = Rect::from_min_size(pos2(r.min.x + w * i as f32, r.min.y), vec2(w, BUTTON_H)); + let resp = ui.interact(sr, ui.id().with(("seg", l)), Sense::click()); + if i == active { + ui.painter().rect_filled(sr, 0.0, t.well); + } else if resp.hovered() { + ui.painter().rect_filled(sr, 0.0, t.hover); + } + ui.painter().text(sr.center(), egui::Align2::CENTER_CENTER, *l, label_font(), if i == active { t.accent } else { t.text }); + if i > 0 { + ui.painter().line_segment([sr.left_top(), sr.left_bottom()], Stroke::new(1.0, t.field_border)); + } + if resp.clicked() { + out = Some(i); + } + } + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(ctx: &egui::Context, events: Vec, mods: egui::Modifiers, f: &mut dyn FnMut(&mut Ui)) { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), + events: std::iter::once(egui::Event::ModifiersChanged(mods)).chain(events).collect(), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| f(ui)); + out.textures_delta.clear(); + } + + fn click(p: egui::Pos2, mods: egui::Modifiers) -> Vec> { + vec![ + vec![egui::Event::PointerMoved(p)], + vec![egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }], + vec![egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: mods }], + ] + } + + /// Drive a spinner field through frames; returns every committed value and the field rect. + fn drive(frames: Vec<(Vec, egui::Modifiers)>, value: f64) -> (Vec, Rect) { + let ctx = egui::Context::default(); + let mut got = vec![]; + let mut rect = Rect::NOTHING; + let mut all = vec![(vec![], egui::Modifiers::NONE)]; + all.extend(frames); + for (ev, m) in all { + run(&ctx, ev, m, &mut |ui| { + rect = ui.available_rect_before_wrap(); + if let Some(v) = NumField::measure("w", Some(value), Unit::Points).width(80.0).spinner().show(ui) { + got.push(v); + } + }); + } + (got, rect) + } + + #[test] + fn spinner_up_down_and_shift() { + let (_, r) = drive(vec![], 10.0); + let up = r.min + vec2(9.0, 5.0); + let down = r.min + vec2(9.0, 16.0); + let (got, _) = drive(click(up, egui::Modifiers::NONE).into_iter().map(|e| (e, egui::Modifiers::NONE)).collect(), 10.0); + assert_eq!(got, vec![11.0]); + let (got, _) = drive(click(down, egui::Modifiers::NONE).into_iter().map(|e| (e, egui::Modifiers::NONE)).collect(), 10.0); + assert_eq!(got, vec![9.0]); + let (got, _) = drive(click(up, egui::Modifiers::SHIFT).into_iter().map(|e| (e, egui::Modifiers::SHIFT)).collect(), 10.0); + assert_eq!(got, vec![20.0]); + } + + #[test] + fn arrow_keys_step_and_typing_parses_arithmetic() { + let (_, r) = drive(vec![], 10.0); + let text = r.min + vec2(50.0, 10.0); + let mut frames: Vec<(Vec, egui::Modifiers)> = + click(text, egui::Modifiers::NONE).into_iter().map(|e| (e, egui::Modifiers::NONE)).collect(); + let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frames.push((vec![key(egui::Key::ArrowUp)], egui::Modifiers::NONE)); + let (got, _) = drive(frames.clone(), 10.0); + assert_eq!(got, vec![11.0]); + // Select all, type an expression, press Enter. + frames.truncate(3); + frames.push(( + vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }], + egui::Modifiers::COMMAND, + )); + frames.push((vec![egui::Event::Text("6+6".into())], egui::Modifiers::NONE)); + frames.push((vec![key(egui::Key::Enter)], egui::Modifiers::NONE)); + let (got, _) = drive(frames, 10.0); + assert_eq!(got, vec![12.0]); + } + + #[test] + fn number_kind_formats_and_parses() { + let k = NumKind::Number { suffix: "%", decimals: 0 }; + assert_eq!(k.format(100.0), "100%"); + assert_eq!(k.parse("50%"), Some(50.0)); + assert_eq!(k.parse("10*3"), Some(30.0)); + let m = NumKind::Measure(Unit::Picas); + assert_eq!(m.parse("1p0+0p6"), Some(18.0)); + assert_eq!(m.step(), 1.0); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 68f5836..d1a9ef7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,4 +26,6 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The serve | `ui.render` | `{path?, page?, scale?, bleed?}` | Render a page headlessly (PNG; base64 if no path) | | `app.open` / `app.save` / `app.export` / `app.quit` | | Files | +Headless window screenshots (locked screen, hidden window): `cargo run -p designcraft-ui-egui --example ui_shot -- script.jsonl`, where each line is one of the requests above, `{"shot": "/abs/out.png"}` or `{"steps": n}` (renders the whole UI offscreen with wgpu). + The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents.