From cb988aef83f52963fe2de505fff334734c511799 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 21:31:06 +0200 Subject: [PATCH 1/4] Show Hidden Characters marks table cells, as InDesign Every text cell now shows its own hidden characters: spaces, tabs, paragraph returns and an end-of-story # after its text (an empty cell shows just the #). The table paragraph's return sits after the table's bottom right corner. Cells showed no marks at all, so an empty table looked like plain lines. The table marks come from table_hidden_marks (frame space), drawn with the story's own marks. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 65 +++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..520b7bf52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-23** · Show Hidden Characters marks table cells as InDesign does: every cell shows its own spaces, tabs and paragraph returns and an end-of-story # after its text (an empty cell shows just the #), and the table paragraph's ¶ sits after the table's bottom right corner. Cells showed no marks. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..388244b78 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2744,6 +2744,8 @@ fn ime_output(app: &mut DesignApp, ctx: &egui::Context, xf: &Xf) { } /// View → Show Hidden Characters: ¶ paragraph ends, » tabs, · spaces, ¬ forced line breaks, # end of story. +/// As InDesign, every table cell shows its own marks (an empty cell its #), and a table's +/// paragraph return sits after the table's bottom right corner. fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { for story in doc.stories.values() { let cs = app.session.cache.get(doc, story.id, None); @@ -2751,19 +2753,39 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); let col = layer_color(doc, it); - for l in &ft.lines { - let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; - let font = egui::FontId::proportional(size); - for (x, mark, centered) in hidden_marks(&story.text, l) { - let p = xf.to_screen(m * Point::new(x, l.baseline)); - let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; - painter.text(p, align, mark, font.clone(), col); - } + let lines = ft + .lines + .iter() + .flat_map(|l| hidden_marks(&story.text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))); + for (p, mark, centered, ascent) in lines.chain(ft.tables.iter().flat_map(table_hidden_marks)) { + let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; + let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; + painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col); } } } } +/// A table fragment's hidden-character marks in frame space (position at the baseline, mark, +/// centered, the line's ascent): each text cell's own marks, its # where its text ends, and the +/// table paragraph's ¶ after the last row's bottom right corner. +fn table_hidden_marks(t: &compose::TableFrag) -> Vec<(Point, &'static str, bool, f64)> { + let mut out = Vec::new(); + for c in t.cells.iter().filter(|c| c.graphic.is_none()) { + for l in c.text.frames.first().map_or(&[][..], |f| &f.lines) { + out.extend( + hidden_marks(&c.source, l) + .into_iter() + .map(|(x, mark, centered)| (c.origin + designcraft_geom::Vec2::new(x, l.baseline), mark, centered, l.ascent)), + ); + } + } + if t.last { + out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); + } + out +} + /// The hidden-character marks on line `l` of a story with text `text`: frame-space x, mark, and /// whether the mark is centered on x (spaces) or starts at it. fn hidden_marks(text: &str, l: &compose::Line) -> Vec<(f64, &'static str, bool)> { @@ -5668,6 +5690,33 @@ mod tests { assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); } } + + /// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph + /// ends, and the table paragraph's ¶ just after the table's bottom right corner. + #[test] + fn table_cells_show_their_hidden_characters() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); + let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; + let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); + d.story_mut(sid).unwrap().insert_table(0, t); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let frag = &cs.frames[0].tables[0]; + let marks = table_hidden_marks(frag); + let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); + assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's: {marks:?}"); + // Each # is inside its cell, after the cell's text. + for c in &frag.cells { + let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); + assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); + } + let name = frag.cell(0, 0).unwrap(); + assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); + let last = marks.last().unwrap(); + assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); + } } #[cfg(test)] From e1aa6b7f15964835cd3900e1f669d546f75c1b6c Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 23 Oct 2026 22:15:39 +0200 Subject: [PATCH 2/4] Hidden characters: one table mark, nested tables marked too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A table's glyph-less line drew its own end mark at the table's bottom right, sized to the table's height, under the new one: table lines now have no line marks (the drawn ¶ is the table paragraph's). - Marks come from frame_hidden_marks, which visits a cell's own tables, so nested tables' cells are marked too. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 51 ++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 388244b78..ba4ccfe15 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2753,11 +2753,7 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue }; let m = a * doc.text_xf(it); let col = layer_color(doc, it); - let lines = ft - .lines - .iter() - .flat_map(|l| hidden_marks(&story.text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))); - for (p, mark, centered, ascent) in lines.chain(ft.tables.iter().flat_map(table_hidden_marks)) { + for (p, mark, centered, ascent) in frame_hidden_marks(&story.text, ft) { let size = (ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32; let align = if centered { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM }; painter.text(xf.to_screen(m * p), align, mark, egui::FontId::proportional(size), col); @@ -2766,22 +2762,28 @@ fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc } } -/// A table fragment's hidden-character marks in frame space (position at the baseline, mark, -/// centered, the line's ascent): each text cell's own marks, its # where its text ends, and the -/// table paragraph's ¶ after the last row's bottom right corner. -fn table_hidden_marks(t: &compose::TableFrag) -> Vec<(Point, &'static str, bool, f64)> { - let mut out = Vec::new(); - for c in t.cells.iter().filter(|c| c.graphic.is_none()) { - for l in c.text.frames.first().map_or(&[][..], |f| &f.lines) { - out.extend( - hidden_marks(&c.source, l) - .into_iter() - .map(|(x, mark, centered)| (c.origin + designcraft_geom::Vec2::new(x, l.baseline), mark, centered, l.ascent)), - ); +/// A frame's hidden-character marks in frame space (position at the baseline, mark, centered, +/// the line's ascent): its lines' marks, then its tables' — each text cell's own marks (nested +/// tables included) with its # where its text ends, and a table paragraph's ¶ after the last +/// row's bottom right corner. A table's own glyph-less line has no marks: its ¶ is that one. +fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { + let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); + let mut out: Vec<_> = ft + .lines + .iter() + .enumerate() + .filter(|(i, _)| !table_lines.contains(i)) + .flat_map(|(_, l)| hidden_marks(text, l).into_iter().map(move |(x, mark, c)| (Point::new(x, l.baseline), mark, c, l.ascent))) + .collect(); + for t in &ft.tables { + for c in t.cells.iter().filter(|c| c.graphic.is_none()) { + if let Some(cf) = c.text.frames.first() { + out.extend(frame_hidden_marks(&c.source, cf).into_iter().map(|(p, mark, centered, a)| (p + c.origin.to_vec2(), mark, centered, a))); + } + } + if t.last { + out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); } - } - if t.last { - out.push((Point::new(t.rect.x1 + 1.0, t.rect.y1), "¶", false, 12.0)); } out } @@ -5703,10 +5705,15 @@ mod tests { d.story_mut(sid).unwrap().insert_table(0, t); let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); let frag = &cs.frames[0].tables[0]; - let marks = table_hidden_marks(frag); + // The whole frame: the table's own line adds no second ¶; the story's last paragraph + // (after the table) has its #. + let story = d.story(sid).unwrap().text.clone(); + let all = frame_hidden_marks(&story, &cs.frames[0]); + let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); - assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); + assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); // Each # is inside its cell, after the cell's text. for c in &frag.cells { let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); From e779d9eea2a0d34a8502de686c71f505a679766e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 12:19:27 +0100 Subject: [PATCH 3/4] Hidden characters: the test reaches frame_hidden_marks in its new module Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas/text_overlay.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3a1f8c733..5c0926b44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -56,7 +56,7 @@ use scroll::{mods, navigation_space, scrollbars, spring_zoom_mode}; pub use text_overlay::selected_text; use text_overlay::{draw_dynamic_spelling, draw_hidden_characters}; #[cfg(test)] -use text_overlay::{draw_text_selection, hidden_marks}; +use text_overlay::{draw_text_selection, frame_hidden_marks, hidden_marks}; use tool_overlays::draw_tool_overlays; pub(crate) use view::page_in_view; use view::present_fit; diff --git a/crates/ui-egui/src/canvas/text_overlay.rs b/crates/ui-egui/src/canvas/text_overlay.rs index 6af92d842..ebf2a6349 100644 --- a/crates/ui-egui/src/canvas/text_overlay.rs +++ b/crates/ui-egui/src/canvas/text_overlay.rs @@ -288,7 +288,7 @@ pub(super) fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, x /// the line's ascent): its lines' marks, then its tables' — each text cell's own marks (nested /// tables included) with its # where its text ends, and a table paragraph's ¶ after the last /// row's bottom right corner. A table's own glyph-less line has no marks: its ¶ is that one. -fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { +pub(super) fn frame_hidden_marks(text: &str, ft: &compose::FrameText) -> Vec<(Point, &'static str, bool, f64)> { let table_lines: Vec = ft.tables.iter().map(|t| t.line).collect(); let mut out: Vec<_> = ft .lines From 1698f69d683e58419565a4e241f38bf502223739 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 15:30:59 +0100 Subject: [PATCH 4/4] Hidden characters test: in canvas/tests.rs, out of the canvas paint function Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 32 ------------------------------ crates/ui-egui/src/canvas/tests.rs | 32 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5c0926b44..1b1e10d13 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -194,38 +194,6 @@ fn paint_render(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Documen painter.add(egui::Shape::mesh(mesh)); } } - - /// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph - /// ends, and the table paragraph's ¶ just after the table's bottom right corner. - #[test] - fn table_cells_show_their_hidden_characters() { - let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); - let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; - let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); - d.story_mut(sid).unwrap().insert_table(0, t); - let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); - let frag = &cs.frames[0].tables[0]; - // The whole frame: the table's own line adds no second ¶; the story's last paragraph - // (after the table) has its #. - let story = d.story(sid).unwrap().text.clone(); - let all = frame_hidden_marks(&story, &cs.frames[0]); - let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); - let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); - assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); - assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); - assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); - // Each # is inside its cell, after the cell's text. - for c in &frag.cells { - let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); - assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); - } - let name = frag.cell(0, 0).unwrap(); - assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); - let last = marks.last().unwrap(); - assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); - } } /// Presentation: a click shows the next spread, Shift-click the previous one. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 01300bdd0..f36269479 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2837,3 +2837,35 @@ fn trailing_spaces_show_dots_before_the_paragraph_mark() { assert!(para_x >= space.x + space.adv, "{mark} at {para_x} overlaps the last space"); } } + +/// As InDesign: an end-of-story # in every cell (after its text), a ¶ where a cell paragraph +/// ends, and the table paragraph's ¶ just after the table's bottom right corner. +#[test] +fn table_cells_show_their_hidden_characters() { + let mut d = Document::new(&designcraft_doc::build::NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), DRect::new(0.0, 0.0, 300.0, 300.0), lid, "", Default::default()).unwrap(); + let data = vec![vec!["Name:".to_string(), "".into()], vec!["a\nb".into(), "".into()]]; + let t = designcraft_doc::Table::from_strings(d.alloc(), &data, 200.0, &Default::default(), &Default::default()); + d.story_mut(sid).unwrap().insert_table(0, t); + let cs = compose::compose_story(&d, sid, &compose::ComposeOptions::default()); + let frag = &cs.frames[0].tables[0]; + // The whole frame: the table's own line adds no second ¶; the story's last paragraph + // (after the table) has its #. + let story = d.story(sid).unwrap().text.clone(); + let all = frame_hidden_marks(&story, &cs.frames[0]); + let marks: Vec<_> = all.iter().filter(|m| frag.rect.inflate(2.0, 2.0).contains(m.0)).cloned().collect(); + let ends = |m: &str| marks.iter().filter(|x| x.1 == m).count(); + assert_eq!(ends("#"), 4, "one per cell: {marks:?}"); + assert_eq!(ends("¶"), 2, "the cell's first paragraph and the table's, once: {marks:?}"); + assert_eq!(all.iter().filter(|m| m.1 == "¶" && m.3 > 20.0).count(), 0, "no ¶ the size of the table: {all:?}"); + // Each # is inside its cell, after the cell's text. + for c in &frag.cells { + let inside = marks.iter().filter(|m| m.1 == "#" && c.rect.contains(m.0)).count(); + assert_eq!(inside, 1, "cell {},{}: {marks:?}", c.row, c.col); + } + let name = frag.cell(0, 0).unwrap(); + assert!(marks.iter().any(|m| m.1 == "#" && name.rect.contains(m.0) && m.0.x > name.origin.x + 20.0), "after Name:"); + let last = marks.last().unwrap(); + assert_eq!((last.0, last.1), (designcraft_geom::Point::new(frag.rect.x1 + 1.0, frag.rect.y1), "¶")); +}