UI: fidelity pass 2 (Properties sections, spinner fields, task bar, inverse text selection, Pages/Layers panels)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6086ceb497
commit
67ac9f1dfb
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -96,6 +96,31 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
"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<CommandSpec> {
|
|||
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<CommandSpec> {
|
|||
if let Some(o) = off {
|
||||
i.wrap.offsets = o;
|
||||
}
|
||||
if let Some(v) = invert {
|
||||
i.wrap.invert = v;
|
||||
}
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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::<ControlRequest>();
|
||||
let mut app = DesignApp::new(Session::new(), services).with_control(rx);
|
||||
app.integrated_titlebar = true;
|
||||
let lines: Vec<serde_json::Value> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Rect> {
|
||||
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<Rect> = 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::<usize>(), 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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Result<Value,
|
|||
Ok(Value::Null)
|
||||
}
|
||||
"window.controlBar" => 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<bool> {
|
|||
"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()),
|
||||
|
|
|
|||
|
|
@ -1,47 +1,142 @@
|
|||
//! Layers panel.
|
||||
//! Layers panel: expandable layer rows listing the current spread's objects (`<rectangle>`,
|
||||
//! `<first words…>` 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::<Vec<_>>().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<ItemId> = 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<std::sync::Arc<Item>> =
|
||||
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<u64> = 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), "<Every page begins as an…>");
|
||||
let rr = doc.item(ItemId(rect["id"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(item_label(doc, rr), "<rectangle>");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SelInfo> {
|
||||
|
|
@ -71,6 +77,13 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> {
|
|||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>),
|
||||
}
|
||||
|
||||
/// 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<String>, 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::<PageDrag>() {
|
||||
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<String> = (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);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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, FontId> = [
|
||||
(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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<f64>, unit: Unit, width: f32) -> Option<f64> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let key = ui.id().with(id);
|
||||
let mut buf: String =
|
||||
ui.data(|d| d.get_temp::<String>(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::<String>(key));
|
||||
} else if !r.has_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(key));
|
||||
}
|
||||
out
|
||||
pub fn parse(&self, s: &str) -> Option<f64> {
|
||||
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<f64>,
|
||||
kind: NumKind<'a>,
|
||||
width: f32,
|
||||
spinner: bool,
|
||||
presets: &'a [f64],
|
||||
step: Option<f64>,
|
||||
range: (f64, f64),
|
||||
enabled: bool,
|
||||
parens: bool,
|
||||
}
|
||||
|
||||
impl<'a> NumField<'a> {
|
||||
pub fn measure(id: &'a str, value: Option<f64>, unit: Unit) -> Self {
|
||||
Self::new(id, value, NumKind::Measure(unit))
|
||||
}
|
||||
pub fn number(id: &'a str, value: Option<f64>, suffix: &'a str, decimals: usize) -> Self {
|
||||
Self::new(id, value, NumKind::Number { suffix, decimals })
|
||||
}
|
||||
fn new(id: &'a str, value: Option<f64>, 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<f64> {
|
||||
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<f64> {
|
||||
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<f64> = 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::<String>(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::<String>(key));
|
||||
} else if !r.has_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(key));
|
||||
}
|
||||
if out.is_some() && !r.has_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(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<f64>, unit: Unit, width: f32) -> Option<f64> {
|
||||
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<f64>, unit: Unit, width: f32) -> Option<f64> {
|
||||
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<f64>, suffix: &str, width: f32, decimals: usize) -> Option<f64> {
|
||||
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<f64>, suffix: &str, width: f32, decimals: usize) -> Option<f64> {
|
||||
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::<String>(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<usize>) -> Option<usize> {
|
||||
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::<f64>()
|
||||
&& value.is_none_or(|old| (old - v).abs() > 1e-9)
|
||||
{
|
||||
out = Some(v);
|
||||
}
|
||||
ui.data_mut(|d| d.remove::<String>(key));
|
||||
} else if !r.has_focus() {
|
||||
ui.data_mut(|d| d.remove::<String>(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<Color32>, 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<Color32>, 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<Color32>, 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<usize> {
|
||||
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<egui::Event>, 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<egui::Event>> {
|
||||
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::Event>, egui::Modifiers)>, value: f64) -> (Vec<f64>, 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::Event>, 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,6 @@ Start the app with `--control <port>` (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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue