M1: ruler guides (drag out/move/delete, Create Guides); fix task-bar deadlock while dragging

- engine: guide.add/move/delete/deleteAll/list, layout.createGuides (rows/columns with gutters,
  margins or page) (+ test)
- UI: drag a guide out of either ruler (page guide, or spread guide on the pasteboard), drag one
  with the selection tools to move it, drop it on a ruler to delete it; spread guides cross the
  pasteboard; Layout › Create Guides…, View › Grids & Guides › Delete All Guides on Spread
- fix: the Contextual Task Bar called is_pointer_over_egui inside ctx.input — a deadlock that
  froze the app while dragging with a selection
- ui_shot injects synthetic input (ui.click/ui.drag/ui.key now work offscreen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 00:39:44 -04:00
parent 252ae0df32
commit 9b8983ada2
7 changed files with 403 additions and 8 deletions

View File

@ -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.

View File

@ -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<CommandSpec> {
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<Orientation> {
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<Value> {
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<Value> {
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<Value> {
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<f64> {
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<Value> {
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<f64> = 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);
}
}

View File

@ -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());

View File

@ -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);
}
}

View File

@ -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::<GuideDrag>(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<String> {
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<Pos2>,
}
/// 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<GuideDrag> = 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::<GuideDrag>(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
}

View File

@ -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<Result<Value,
app.ui.dialog = Some(crate::dialogs::Dialog::new("insertXref", json!({})));
Ok(Value::Null)
}
"app.deleteAllGuides" => {
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);

View File

@ -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 };