diff --git a/ROADMAP.md b/ROADMAP.md index d849ae8..8ac3a29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. +- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. - Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page. - Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather. diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs new file mode 100644 index 0000000..dd3e70c --- /dev/null +++ b/crates/engine/src/cmd/guides.rs @@ -0,0 +1,217 @@ +//! Ruler guides: add (dragged from the rulers), move, delete, list; Layout › Create Guides. +//! +//! Guides are stored on their page in spread coordinates; a spread guide (dropped on the +//! pasteboard) lives on the spread's first page with `spread: true` and crosses the pasteboard. + +use designcraft_doc::{Document, Guide, Orientation, SpreadRef}; +use designcraft_geom::Rect; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, spread_param, str_param}; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "guide.add", + "New Guide", + [], + None, + "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool} → {page, index}", + has_doc, + add + ), + cmd!("guide.move", "Move Guide", [], None, "{spread?, page, index, position}", has_doc, move_guide), + cmd!("guide.delete", "Delete Guide", [], None, "{spread?, page, index}", has_doc, delete), + cmd!("guide.deleteAll", "Delete All Guides on Spread", ["View", "Grids & Guides"], None, "{spread?}", has_doc, |s, p| { + let r = spread_param(p, "spread"); + s.edit(|d, _| { + let sp = d.spread_mut(r).ok_or_else(|| bad("guide.deleteAll", "no such spread"))?; + let mut n = 0; + for pg in &mut sp.pages { + n += pg.guides.len(); + pg.guides.clear(); + } + Ok(json!({"deleted": n})) + }) + }), + cmd!(query "guide.list", "Guides", [], None, "{spread?} → [{page, index, orientation, position, spread}]", has_doc, |s, p| { + let d = &s.doc()?.doc; + let sp = d.spread(spread_param(p, "spread")).ok_or_else(|| bad("guide.list", "no such spread"))?; + let mut out = Vec::new(); + for (pi, pg) in sp.pages.iter().enumerate() { + for (i, g) in pg.guides.iter().enumerate() { + out.push(json!({"page": pi, "index": i, "orientation": g.orientation, "position": g.position, "spread": g.spread})); + } + } + Ok(Value::Array(out)) + }), + cmd!( + "layout.createGuides", + "Create Guides…", + ["Layout"], + None, + "{rows?: 0, columns?: 0, rowGutter?: 12, columnGutter?: 12, fitTo?: margins|page, removeExisting?: false, spread?, page? (index in the spread; default all pages)}", + has_doc, + create_guides + ), + ] +} + +fn orientation(p: &Value) -> Result { + match str_param(p, "orientation") { + Some("horizontal") => Ok(Orientation::Horizontal), + Some("vertical") => Ok(Orientation::Vertical), + _ => Err(bad("guide", "orientation: horizontal|vertical")), + } +} + +/// The page of a spread at a spread x coordinate (nearest page). +fn page_at(d: &Document, r: SpreadRef, x: f64) -> usize { + d.spread(r).and_then(|sp| sp.page_at_x(x)).unwrap_or(0) +} + +fn add(s: &mut Session, p: &Value) -> Result { + let o = orientation(p)?; + let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.add", "missing position"))?; + let r = spread_param(p, "spread"); + let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); + let d = &s.doc()?.doc; + let pi = match p.get("page").and_then(Value::as_u64) { + Some(pi) => pi as usize, + None if spread_guide => 0, + None => { + let x = match o { + Orientation::Vertical => pos, + Orientation::Horizontal => p.get("at").and_then(Value::as_f64).unwrap_or(0.0), + }; + page_at(d, r, x) + } + }; + s.edit(|d, _| { + let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?; + let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?; + pg.guides.push(Guide { orientation: o, position: pos, spread: spread_guide, locked: false }); + Ok(json!({"page": pi, "index": pg.guides.len() - 1})) + }) +} + +fn guide_ref(p: &Value, cmd: &str) -> Result<(SpreadRef, usize, usize)> { + let pi = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))? as usize; + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing index"))? as usize; + Ok((spread_param(p, "spread"), pi, i)) +} + +fn move_guide(s: &mut Session, p: &Value) -> Result { + let (r, pi, i) = guide_ref(p, "guide.move")?; + let pos = p.get("position").and_then(Value::as_f64).ok_or_else(|| bad("guide.move", "missing position"))?; + s.edit(|d, _| { + let g = d + .spread_mut(r) + .and_then(|sp| sp.pages.get_mut(pi)) + .and_then(|pg| pg.guides.get_mut(i)) + .ok_or_else(|| bad("guide.move", "no such guide"))?; + if g.locked { + return Err(bad("guide.move", "the guide is locked")); + } + g.position = pos; + Ok(Value::Null) + }) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let (r, pi, i) = guide_ref(p, "guide.delete")?; + s.edit(|d, _| { + let pg = d.spread_mut(r).and_then(|sp| sp.pages.get_mut(pi)).ok_or_else(|| bad("guide.delete", "no such page"))?; + if i >= pg.guides.len() { + return Err(bad("guide.delete", "no such guide")); + } + pg.guides.remove(i); + Ok(Value::Null) + }) +} + +/// Evenly spaced rows/columns with gutters inside `area`: the guide positions. +pub(crate) fn grid_positions(a: f64, b: f64, n: u32, gutter: f64) -> Vec { + if n < 2 { + return vec![]; + } + let cell = ((b - a) - gutter * (n - 1) as f64) / n as f64; + let mut v = Vec::new(); + for k in 1..n { + let edge = a + k as f64 * cell + (k - 1) as f64 * gutter; + v.push(edge); + if gutter > 0.0 { + v.push(edge + gutter); + } + } + v +} + +fn create_guides(s: &mut Session, p: &Value) -> Result { + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(0) as u32; + let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(0) as u32; + let rg = p.get("rowGutter").and_then(Value::as_f64).unwrap_or(12.0); + let cg = p.get("columnGutter").and_then(Value::as_f64).unwrap_or(12.0); + let to_page = str_param(p, "fitTo") == Some("page"); + let remove = p.get("removeExisting").and_then(Value::as_bool).unwrap_or(false); + let r = spread_param(p, "spread"); + let only = p.get("page").and_then(Value::as_u64).map(|v| v as usize); + s.edit(|d, _| { + let sp = d.spread_mut(r).ok_or_else(|| bad("layout.createGuides", "no such spread"))?; + let mut n = 0; + for (pi, pg) in sp.pages.iter_mut().enumerate() { + if only.is_some_and(|o| o != pi) { + continue; + } + if remove { + pg.guides.clear(); + } + let area: Rect = if to_page { pg.bounds() } else { pg.margin_rect() }; + for y in grid_positions(area.y0, area.y1, rows, rg) { + pg.guides.push(Guide { orientation: Orientation::Horizontal, position: y, spread: false, locked: false }); + n += 1; + } + for x in grid_positions(area.x0, area.x1, cols, cg) { + pg.guides.push(Guide { orientation: Orientation::Vertical, position: x, spread: false, locked: false }); + n += 1; + } + } + Ok(json!({"guides": n})) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn guides_add_move_delete_and_grid() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + // Spread 1 holds pages 2–3 side by side from x = 0. + let r = s.execute("guide.add", &json!({"spread": 1, "orientation": "vertical", "position": 100.0})).unwrap(); + assert_eq!(r["page"], 0); + let r = s.execute("guide.add", &json!({"spread": 1, "orientation": "horizontal", "position": 200.0, "at": 700.0})).unwrap(); + assert_eq!(r["page"], 1); + s.execute("guide.move", &json!({"spread": 1, "page": 1, "index": 0, "position": 250.0})).unwrap(); + let l = s.execute("guide.list", &json!({"spread": 1})).unwrap(); + assert_eq!(l.as_array().unwrap().len(), 2); + assert_eq!(l[1]["position"], 250.0); + s.execute("guide.delete", &json!({"spread": 1, "page": 0, "index": 0})).unwrap(); + assert_eq!(s.execute("guide.list", &json!({"spread": 1})).unwrap().as_array().unwrap().len(), 1); + // 3 columns with 12 pt gutters inside the margins: 4 guides; 2 rows without gutter: 1 guide. + let r = s.execute("layout.createGuides", &json!({"spread": 0, "columns": 3, "rows": 2, "rowGutter": 0.0})).unwrap(); + assert_eq!(r["guides"], 5); + let d = &s.doc().unwrap().doc; + let pg = &d.spreads[0].pages[0]; + let m = pg.margin_rect(); + let v: Vec = pg.guides.iter().filter(|g| g.orientation == Orientation::Vertical).map(|g| g.position).collect(); + let cell = (m.width() - 24.0) / 3.0; + assert!((v[0] - (m.x0 + cell)).abs() < 1e-9 && (v[1] - (m.x0 + cell + 12.0)).abs() < 1e-9); + s.execute("guide.deleteAll", &json!({"spread": 0})).unwrap(); + assert!(s.doc().unwrap().doc.spreads[0].pages[0].guides.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.spreads[0].pages[0].guides.len(), 5); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1225718..2e649dd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,6 +6,7 @@ mod edit; mod export; mod file; mod find; +mod guides; mod index; mod inspect; mod interactive; @@ -130,6 +131,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(xref::specs()); v.extend(index::specs()); v.extend(anchored::specs()); + v.extend(guides::specs()); v.extend(spelling::specs()); v.extend(datamerge::specs()); v.extend(interactive::specs()); diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index 465c3e4..c0bf389 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -48,10 +48,13 @@ fn main() { ); harness.input_mut().max_texture_side = Some(8192); READY.store(true, std::sync::atomic::Ordering::Relaxed); - harness.run_steps(6); + step_n(&mut harness, 6); for l in lines { + if std::env::var_os("UI_SHOT_TRACE").is_some() { + eprintln!("> {l}"); + } if let Some(p) = l.get("shot").and_then(|v| v.as_str()) { - harness.run_steps(3); + step_n(&mut harness, 3); match harness.render() { Ok(img) => { img.save(p).expect("save png"); @@ -60,20 +63,35 @@ fn main() { 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); + step_n(&mut harness, 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(); + step(&mut harness); if let Ok(r) = reply.try_recv() { let s = r.to_string(); println!("{m}: {}", &s[..s.len().min(300)]); break; } } - harness.run_steps(2); + step_n(&mut harness, 2); } } } + +/// One frame, with the app's synthetic input (`ui.click`, `ui.drag`, `ui.key`) injected like the +/// windowed app's raw-input hook does. +fn step(harness: &mut egui_kittest::Harness<'_, DesignApp>) { + let mut raw = std::mem::take(harness.input_mut()); + harness.state_mut().raw_input_hook(&mut raw); + *harness.input_mut() = raw; + harness.step(); +} + +fn step_n(harness: &mut egui_kittest::Harness<'_, DesignApp>, n: usize) { + for _ in 0..n { + step(harness); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ac23d27..eb4381a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -200,6 +200,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } if !preview { draw_guides(app, &painter, &xf, &doc, &layout, &t); + if let Some(g) = ui.data(|d| d.get_temp::(egui::Id::new(GUIDE_DRAG))) + && let Some(p) = g.at + { + let col = Color32::from_rgb(74, 227, 255); + let (a, b) = match g.orientation { + designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)), + designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)), + }; + painter.line_segment([a, b], Stroke::new(1.0, col)); + } draw_frames(app, &painter, &xf, &doc, &layout); if app.ui.hidden_characters { draw_hidden_characters(app, &painter, &xf, &doc, &layout); @@ -410,9 +420,14 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document } } for g in &p.guides { + // Spread guides cross the pasteboard; page guides their page. + let span = if g.spread { layout.pasteboard } else { pr }; let (a, bb) = match g.orientation { - designcraft_doc::Orientation::Horizontal => (Point::new(pr.x0, g.position + off.y), Point::new(pr.x1, g.position + off.y)), - designcraft_doc::Orientation::Vertical => (Point::new(g.position + off.x, pr.y0), Point::new(g.position + off.x, pr.y1)), + designcraft_doc::Orientation::Horizontal => (Point::new(span.x0, g.position + off.y), Point::new(span.x1, g.position + off.y)), + designcraft_doc::Orientation::Vertical => { + let span = if g.spread { slot.bounds.inflate(0.0, 36.0) } else { pr }; + (Point::new(g.position + off.x, span.y0), Point::new(g.position + off.x, span.y1)) + } }; painter.line_segment([xf.to_screen(a), xf.to_screen(bb)], Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255))); } @@ -1085,6 +1100,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r } return; } + if !space && guide_drag(app, ui, resp, rect, &xf) { + return; + } let m = ui.input(|i| mods(i, space)); let vi = app.view_info(); let pos = |p: Pos2| xf.to_canvas(p); @@ -1240,3 +1258,129 @@ pub fn selected_text(app: &DesignApp) -> Option { fn hair(painter: &egui::Painter) -> f32 { 1.0 / painter.ctx().pixels_per_point() } + +const GUIDE_DRAG: &str = "canvas_guide_drag"; + +/// A ruler guide being dragged: out of a ruler (`from` None) or an existing one. +#[derive(Clone, Debug)] +struct GuideDrag { + orientation: designcraft_doc::Orientation, + from: Option<(designcraft_doc::SpreadRef, usize, usize)>, + /// Pointer position (screen). + at: Option, +} + +/// The guide under a screen point (within 3 px), as (spread, page, index, orientation). +fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::SpreadRef, usize, usize, designcraft_doc::Orientation)> { + let st = app.session.active()?; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cp = xf.to_canvas(p); + let tol = 3.0 / xf.zoom; + for slot in &layout.slots { + let sp = st.doc.spread(slot.spread)?; + let local = cp - slot.offset; + for (pi, pg) in sp.pages.iter().enumerate() { + let pr = if pg.guides.iter().any(|g| g.spread) { layout.pasteboard - slot.offset } else { pg.bounds() }; + for (gi, g) in pg.guides.iter().enumerate() { + let r = if g.spread { pr } else { pg.bounds() }; + let hit = match g.orientation { + designcraft_doc::Orientation::Horizontal => (local.y - g.position).abs() <= tol && local.x >= r.x0 && local.x <= r.x1, + designcraft_doc::Orientation::Vertical => (local.x - g.position).abs() <= tol && local.y >= r.y0 - tol && local.y <= r.y1 + tol, + }; + if hit && !g.locked { + return Some((slot.spread, pi, gi, g.orientation)); + } + } + } + } + None +} + +/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools, not over an +/// object) to move it, drop it back on a ruler to delete it. Returns true while it owns the pointer. +fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { + let id = egui::Id::new(GUIDE_DRAG); + let (pressed, released, origin, latest) = + ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos())); + let mut drag: Option = ui.data(|d| d.get_temp(id)); + if drag.is_none() && pressed && resp.hovered() && app.ui.guides && !app.ui.guides_locked { + let Some(o) = origin else { return false }; + let full = resp.rect; + if full.contains(o) && !rect.contains(o) { + // In a ruler (not the corner). + let orientation = if o.y < rect.min.y && o.x >= rect.min.x { + Some(designcraft_doc::Orientation::Horizontal) + } else if o.x < rect.min.x && o.y >= rect.min.y { + Some(designcraft_doc::Orientation::Vertical) + } else { + None + }; + if let Some(orientation) = orientation { + drag = Some(GuideDrag { orientation, from: None, at: Some(o) }); + } + } else if rect.contains(o) + && matches!(app.session.tool_id(), "selection" | "directSelection") + && let Some((r, pi, gi, orientation)) = guide_at(app, xf, o) + { + // Objects take precedence over the guides behind them. + let over_item = app.session.active().is_some_and(|st| { + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + layout.spread_at(xf.to_canvas(o)).is_some_and(|(sr, p)| match sr { + designcraft_doc::SpreadRef::Doc(si) => st.doc.hit_item(si, p, 3.0 / xf.zoom).is_some(), + _ => false, + }) + }); + if !over_item { + drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o) }); + } + } + } + let Some(mut g) = drag else { return false }; + if let Some(p) = latest { + g.at = Some(p); + } + if !released { + ui.ctx().set_cursor_icon(match g.orientation { + designcraft_doc::Orientation::Horizontal => egui::CursorIcon::ResizeVertical, + designcraft_doc::Orientation::Vertical => egui::CursorIcon::ResizeHorizontal, + }); + ui.data_mut(|d| d.insert_temp(id, g)); + return true; + } + ui.data_mut(|d| d.remove::(id)); + let Some(p) = g.at else { return true }; + let on_canvas = rect.contains(p); + let target = app.session.active().and_then(|st| { + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let (r, sp) = layout.spread_at(xf.to_canvas(p))?; + let spread = st.doc.spread(r)?; + let on_page = spread.pages.iter().position(|pg| pg.bounds().contains(sp)); + Some((r, sp, on_page)) + }); + let pos_of = |sp: Point| match g.orientation { + designcraft_doc::Orientation::Horizontal => sp.y, + designcraft_doc::Orientation::Vertical => sp.x, + }; + let orient = match g.orientation { + designcraft_doc::Orientation::Horizontal => "horizontal", + designcraft_doc::Orientation::Vertical => "vertical", + }; + match (g.from, on_canvas, target) { + // Dropped back on a ruler: delete. + (Some((r, pi, gi)), false, _) => { + let _ = app.run("guide.delete", json!({"spread": r, "page": pi, "index": gi})); + } + (Some((r, pi, gi)), true, Some((tr, sp, _))) if tr == r => { + let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)})); + } + (None, true, Some((r, sp, on_page))) => { + let mut params = json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none()}); + if let Some(pi) = on_page { + params["page"] = json!(pi); + } + let _ = app.run("guide.add", params); + } + _ => {} + } + true +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 158642c..2e88d0c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -19,6 +19,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), ("app.footnoteOptionsDialog", "Document Footnote Options…", None, "{} — numbering, formatting and layout of footnotes"), ("app.insertXrefDialog", "Insert Cross-Reference…", None, "{} — New Cross-Reference dialog (paragraph or text anchor, format)"), + ("app.deleteAllGuides", "Delete All Guides on Spread", None, "{} — the spread in view"), ("app.deletePage", "Delete Page", None, "{} — deletes the page in view"), ("app.duplicateSpread", "Duplicate Spread", None, "{} — duplicates the spread in view (pages and items)"), ("app.tablePanel", "Table Panel", Some("Shift+F9"), "{}"), @@ -132,6 +133,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:layout.parents.new", "<", "cmd:layout.marginsAndColumns", + "cmd:layout.createGuides", "-", "ui:view.goToPage", "-", @@ -345,8 +347,12 @@ pub const MENUS: &[(&str, &[&str])] = &[ "ui:view.textThreads", "ui:view.hiddenCharacters", "-", + ">Grids & Guides", "ui:view.guides", "ui:view.baselineGrid", + "-", + "ui:app.deleteAllGuides", + "<", ], ), ( @@ -456,6 +462,12 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; + let page = crate::canvas::current_page(app).unwrap_or(0); + let spread = if st.editing_parents { json!({"kind": "parent", "index": 0}) } else { json!(st.doc.page_loc(page).map_or(0, |l| l.0)) }; + return Some(app.run("guide.deleteAll", json!({"spread": spread}))); + } "app.deletePage" | "app.duplicateSpread" => { let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; let page = crate::canvas::current_page(app).unwrap_or(0); diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index c3a3aad..0198ebf 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -12,7 +12,8 @@ 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()) { + // (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.) + 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 };