From 3fa4d72d671ac2cd57e5a2a55c0e35afa76073a0 Mon Sep 17 00:00:00 2001 From: pepen Date: Wed, 14 Oct 2026 03:16:00 +0200 Subject: [PATCH 1/2] fix(ui): text frames inside a selected group show their ports A selected group drew no in/out ports for the text frames in it, so an overset frame inside a group had no red mark. The selection walks groups (bounded depth) and draws each nested text frame's ports with the group's transform. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 17a3cf9bd1c695a585e94fce125ed9d3a3a5929d) --- crates/ui-egui/src/canvas.rs | 54 ++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 40b146f56..f690a6b65 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -947,9 +947,12 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum } let b = xf.rect(a.transform_rect_bbox(it.bounds())); union = Some(union.map_or(b, |u| u.union(b))); - // Text frame ports. - if let Content::Text(tf) = &it.content { - draw_ports(app, painter, xf, doc, layout, it, tf.story, color); + // Text frame ports, also of the frames inside a selected group (so an overset frame in a + // group shows its red mark). + let mut frames = Vec::new(); + text_frames_in(it, a, 0, &mut frames); + for (frame, story, _) in frames { + draw_ports(app, painter, xf, doc, layout, frame, story, color); } if let Content::Graphic(g) = &it.content && sel.content @@ -1024,6 +1027,23 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum text_rect.or(union) } +/// How deep a selected group is walked for the text frames whose ports are drawn. +const PORT_GROUP_DEPTH: usize = 32; + +/// `it` when it is a text frame, or every text frame nested in it when it is a group, each with +/// its story and the transform of its parent (`a` is the transform of `it`'s parent). +fn text_frames_in<'a>(it: &'a Item, a: Affine, depth: usize, out: &mut Vec<(&'a Item, designcraft_doc::StoryId, Affine)>) { + match &it.content { + Content::Text(tf) => out.push((it, tf.story, a)), + Content::Group { items } if depth < PORT_GROUP_DEPTH => { + for child in items { + text_frames_in(child, a * it.xf, depth + 1, out); + } + } + _ => {} + } +} + /// Port location (canvas): out port at bottom-right, in port at top-left. fn port_pos(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId, out: bool) -> Option { let it = doc.item(id)?; @@ -2481,6 +2501,34 @@ mod tests { } } + /// A selected group lists the text frames inside it for ports, with the group's transform. + #[test] + fn grouped_text_frames_get_ports() { + let mut s = designcraft_engine::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [10, 10, 110, 60], "content": "text"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [10, 80, 110, 130]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [a, b]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("transform.move", &json!({"dx": 5, "dy": 7, "ids": [g]})).unwrap(); + let doc = &s.doc().unwrap().doc; + let group = doc.item(designcraft_doc::ItemId(g)).unwrap(); + let mut out = Vec::new(); + text_frames_in(group, Affine::IDENTITY, 0, &mut out); + assert_eq!(out.len(), 1, "one text frame in the group"); + assert_eq!(out[0].0.id.0, a); + assert_eq!(out[0].2, group.xf); + // A plain text frame lists itself with the given parent transform. + out.clear(); + text_frames_in(out_frame(doc, a), Affine::translate((1.0, 2.0)), 0, &mut out); + assert_eq!(out.len(), 1); + assert_eq!(out[0].2, Affine::translate((1.0, 2.0))); + } + + fn out_frame(doc: &Document, id: u64) -> &Item { + doc.item(designcraft_doc::ItemId(id)).unwrap() + } + #[test] fn right_clicking_a_ruler_sets_that_rulers_units() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From ffc0aa3629e96af5a7b7be40ff52760b89d04cda Mon Sep 17 00:00:00 2001 From: pepen Date: Wed, 14 Oct 2026 19:33:37 +0200 Subject: [PATCH 2/2] feat(ui): overset mark on every overset frame, groups included With frame edges shown, every text frame that ends an overset story carries the red mark at its out port, selected or not and inside groups too. InDesign marks only the selected frame, so an overflowing label inside a grouped object stayed invisible until Preflight. Hidden frames and hidden layers are skipped; the group walk is bounded. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit d9ef96dd4b8189b423b4fc32bbb4bf6e3c5c6727) (cherry picked from commit b60500660ba1dd02bd5c09dd35e0e3238b6ae21b) --- ROADMAP.md | 3 ++ crates/ui-egui/src/canvas.rs | 72 ++++++++++++++++++++++++++++++++++++ docs/ui-parity.md | 1 + 3 files changed, 76 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..3db564851 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -410,6 +410,9 @@ Newest first. - **2026-10-13** · Text recomposes when the document baseline grid changes, when a layer holding a text-wrap object is shown or hidden, or when a section's start number, numbering style, prefix or marker changes; the composition cache keyed only on the section count and item identities and kept the old layout. No readiness percentage change. - **2026-10-13** · Reflowable EPUB and HTML export include tables (header and footer rows, merged cells), footnotes (a linked note after the paragraph that refers to it) and endnotes (a linked section at the end of the book). No readiness percentage change. - **2026-10-13** · Word import: table cells read text by the same rules as body text, so deleted text, moved-away text and field codes stay out (#721). Word's Normal comes in as its own paragraph style "Normal" over the document defaults, so text formatted only by Normal keeps its look; it conflicts only with a document's own Normal, and the style map can send it to [Basic Paragraph] (#722). No readiness percentage change. +- **2026-10-14** · Overset text is marked on every frame that ends an overset story, selected or not and inside groups too (the red mark at the out port, with frame edges shown). InDesign shows it only on the selected frame, which hides overset labels in grouped objects until Preflight. No readiness percentage change. +- **2026-10-14** · Three fixes from a real job: objects snap to the bleed guide's four sides while moving, resizing or drawing (`Spread::bleed_rect` is shared by the canvas and `snap.rs`); a selected group shows the in/out ports of the text frames inside it, so an overset frame in a group shows its red mark; the status bar's error count is a menu whose Preflight Panel… entry opens the panel. No readiness percentage change. +- **2026-10-14** · Direct Selection (A) moves a placed graphic inside its frame: `transform.move` honours the content selection (or `content: true`) and translates the graphic in the frame's inner space, also for rotated frames; the frame stays. Text frames, copies and moves to another spread move as a whole. Before, the frame moved with the graphic. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change. - **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f690a6b65..61fd2001c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -863,6 +863,21 @@ fn draw_frames(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document for it in &sp.items { draw_item_edges(painter, xf, doc, it, a); } + // Every overset frame, selected or not and inside groups too, shows its red mark at the + // out port (InDesign shows it only on the selected frame; a report in Preflight is easy + // to miss while laying out). + let mut overset = Vec::new(); + overset_frames(app, doc, &sp.items, a, 0, &mut overset); + for (it, m) in overset { + let r = it.inner_bounds(); + let c = xf.to_screen(m * Point::new(r.x1, r.y1)) - vec2(0.0, 12.0); + let s = 8.5; + painter.rect_filled(Rect::from_center_size(c, vec2(s, s)), 0.0, Color32::WHITE); + painter.rect_stroke(Rect::from_center_size(c, vec2(s, s)), 0.0, Stroke::new(hair(painter), layer_color(doc, it)), StrokeKind::Inside); + let red = Color32::from_rgb(230, 20, 20); + painter.line_segment([c - vec2(2.5, 0.0), c + vec2(2.5, 0.0)], Stroke::new(1.5, red)); + painter.line_segment([c - vec2(0.0, 2.5), c + vec2(0.0, 2.5)], Stroke::new(1.5, red)); + } // Tagged frames in their tag's colour. if app.ui.tagged_frames { for top in &sp.items { @@ -1030,6 +1045,36 @@ fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Docum /// How deep a selected group is walked for the text frames whose ports are drawn. const PORT_GROUP_DEPTH: usize = 32; +/// The visible text frames among `items` (groups walked) that end an overset story, each with +/// its full transform (`a` is the transform of the items' parent). +fn overset_frames<'a>( + app: &DesignApp, + doc: &Document, + items: &'a [std::sync::Arc], + a: Affine, + depth: usize, + out: &mut Vec<(&'a Item, Affine)>, +) { + if depth > PORT_GROUP_DEPTH { + return; + } + for it in items { + if it.hidden || doc.layer(it.layer).is_some_and(|l| !l.visible) { + continue; + } + match &it.content { + Content::Text(tf) => { + let Some(story) = doc.story(tf.story) else { continue }; + if story.frames.last() == Some(&it.id) && app.session.cache.get(doc, tf.story, None).is_overset() { + out.push((it, a * it.xf)); + } + } + Content::Group { items } => overset_frames(app, doc, items, a * it.xf, depth + 1, out), + _ => {} + } + } +} + /// `it` when it is a text frame, or every text frame nested in it when it is a group, each with /// its story and the transform of its parent (`a` is the transform of `it`'s parent). fn text_frames_in<'a>(it: &'a Item, a: Affine, depth: usize, out: &mut Vec<(&'a Item, designcraft_doc::StoryId, Affine)>) { @@ -2529,6 +2574,33 @@ mod tests { doc.item(designcraft_doc::ItemId(id)).unwrap() } + /// An overset frame inside a group is listed for the red mark even when nothing is + /// selected; a frame with room, or a hidden one, is not. + #[test] + fn overset_frames_are_found_inside_groups() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let s = &mut app.session; + let frame_with = |s: &mut designcraft_engine::Session, rect: [f64; 4], text: &str| { + s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text})).unwrap()["id"].as_u64().unwrap() + }; + let tiny = frame_with(s, [10.0, 10.0, 40.0, 16.0], "far too much text for a frame this small, over and over again"); + let roomy = frame_with(s, [10.0, 100.0, 300.0, 300.0], "fits"); + s.execute("selection.set", &json!({"ids": [tiny, roomy]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let mut out = Vec::new(); + overset_frames(&app, &doc, &doc.spreads[0].items, Affine::IDENTITY, 0, &mut out); + assert_eq!(out.iter().map(|(it, _)| it.id.0).collect::>(), vec![tiny]); + // Hidden: no mark. + app.session.execute("object.hide", &json!({"ids": [tiny]})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + out.clear(); + overset_frames(&app, &doc, &doc.spreads[0].items, Affine::IDENTITY, 0, &mut out); + assert!(out.is_empty()); + } + #[test] fn right_clicking_a_ruler_sets_that_rulers_units() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 30512bdf9..26f80c22c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -32,6 +32,7 @@ basics of editing fail for too many users on Windows and Linux. | Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) | | Live corners (yellow handle) | ✓ | ✓ | ~ | not working for some users (#186) | | Content grabber, frame fitting by double-click | ✓ | ✓ | ✓ | | +| Overset mark (red + at the out port) | selected frame only | every overset frame, groups included, while frame edges show | ✓ | a deliberate step past InDesign: grouped labels that overflow were invisible until Preflight | | Smart guides, snapping to guides/grid/baseline | ✓ | ✓ (2,049-line `snap.rs`) | ~ | vertical smart spacing between two rectangles (#188) | | Nudging with arrows, Shift ×10 | ✓ | ✓ | ✓ | | | Document views: scrollbars (each split pane), Hand tool by Space, middle drag, or Alt while editing text | ✓ | ✓ | ✓ | `canvas.rs` `scrollbar_tests` |