Story Editor: a depth ruler beside the text, and View › Story Editor › Show/Hide Depth Ruler, as in InDesign

The Story Editor has a vertical depth ruler between the style column
and the text. It is marked in the document's vertical ruler units with
how deep the story's text fills its frames, counted through its
columns and threaded frames, and it is shaded down to the overset
point. Paragraph spacing adds to the depth. Lines set side by side, in
the sub-columns of a split paragraph or on both sides of a wrap
object, count once. Where several marks fall on one editor row, only
the deepest is drawn. A red line crosses the text, and the ruler when
it shows, above the first character that does not fit.

View › Story Editor › Show/Hide Depth Ruler (view.storyEditorDepthRuler)
shows or hides the ruler in the Story Editor, for every story opened in
it. The ruler is shown by default, and the setting is saved with the
preferences.
This commit is contained in:
dotCooCoo
2026-10-24 15:50:10 -07:00
parent 21a3f8bbb1
commit 5c103c280e
17 changed files with 359 additions and 6 deletions
+1
View File
@@ -364,6 +364,7 @@ Newest first.
- **2026-10-24** · Optical Margin Alignment and hanging punctuation apply to lines with a tab or a right indent tab, as both composers already measured them: the edge glyphs hang, and tab stops stay where they are. Such a line was set without the hang: a justified line that fit only because its period or closing quote hangs had its word spaces squeezed to make up for it, and text after a right indent tab stopped at the measure instead of hanging. No readiness percentage change.
- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change.
- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change.
- **2026-10-24** · The Story Editor has InDesign's vertical depth ruler between the style column and the text, and View › Story Editor › Show/Hide Depth Ruler (`view.storyEditorDepthRuler`) shows or hides it in the Story Editor, remembered with the preferences. The ruler is marked in the document's vertical ruler units with how deep the story's text fills its frames, counted through its columns and threaded frames (lines set side by side, in the sub-columns of a split paragraph or on both sides of a wrap object, count once), and is shaded down to the overset point, where a red line now crosses the editor. No readiness percentage change.
- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change.
- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change.
- **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change.
+4
View File
@@ -2247,6 +2247,10 @@ const TABLE: &[(&str, [&str; 5])] = &[
"显示/隐藏基线网格",
],
),
(
"Show/Hide Depth Ruler",
["Show/Hide Depth Ruler", "Afficher/Masquer la règle de profondeur", "Show/Hide Depth Ruler", "Show/Hide Depth Ruler", "显示/隐藏深度标尺"],
),
(
"Show/Hide Document Grid",
[
+1
View File
@@ -1553,6 +1553,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "تعيين الظهور / القفل"),
("Show Panel", "إظهار اللوحة"),
("Show/Hide Baseline Grid", "إظهار/إخفاء شبكة خط الأساس"),
("Show/Hide Depth Ruler", "إظهار/إخفاء مسطرة العمق"),
("Show/Hide Document Grid", "إظهار/إخفاء شبكة المستند"),
("Show/Hide Frame Edges", "إظهار/إخفاء حواف الإطارات"),
("Show/Hide Guides", "إظهار/إخفاء الأدلة"),
+1
View File
@@ -709,6 +709,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Nastavit viditelnost / zámek"),
("Show Panel", "Zobrazit panel"),
("Show/Hide Baseline Grid", "Zobrazit/skrýt mřížku účaří"),
("Show/Hide Depth Ruler", "Zobrazit/skrýt pravítko hloubky"),
("Show/Hide Frame Edges", "Zobrazit/skrýt okraje rámečků"),
("Show/Hide Guides", "Zobrazit/skrýt vodítka"),
("Show/Hide Hidden Characters", "Zobrazit/skrýt skryté znaky"),
+1
View File
@@ -746,6 +746,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Sichtbarkeit/Sperre festlegen"),
("Show Panel", "Bedienfeld einblenden"),
("Show/Hide Baseline Grid", "Grundlinienraster ein-/ausblenden"),
("Show/Hide Depth Ruler", "Tiefenlineal ein-/ausblenden"),
("Show/Hide Frame Edges", "Rahmenkanten ein-/ausblenden"),
("Show/Hide Guides", "Hilfslinien ein-/ausblenden"),
("Show/Hide Hidden Characters", "Verborgene Zeichen ein-/ausblenden"),
+1
View File
@@ -837,6 +837,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Imposta visibilità / blocco"),
("Show Panel", "Mostra pannello"),
("Show/Hide Baseline Grid", "Mostra/nascondi griglia linee di base"),
("Show/Hide Depth Ruler", "Mostra/nascondi righello profondità"),
("Show/Hide Document Grid", "Mostra/nascondi griglia documento"),
("Show/Hide Frame Edges", "Mostra/nascondi bordi cornice"),
("Show/Hide Guides", "Mostra/nascondi guide"),
+1
View File
@@ -938,6 +938,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "表示・ロックを設定"),
("Show Panel", "パネルを表示"),
("Show/Hide Baseline Grid", "ベースライングリッドを表示・非表示"),
("Show/Hide Depth Ruler", "深さ定規を表示・非表示"),
("Show/Hide Document Grid", "ドキュメントグリッドを表示・非表示"),
("Show/Hide Frame Edges", "フレームの枠を表示・非表示"),
("Show/Hide Guides", "ガイドを表示・非表示"),
+1
View File
@@ -765,6 +765,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "ხილვადობის / ჩაკეტვის დაყენება"),
("Show Panel", "პანელის ჩვენება"),
("Show/Hide Baseline Grid", "საბაზისო ხაზების ბადის ჩვენება/დამალვა"),
("Show/Hide Depth Ruler", "სიღრმის სახაზავის ჩვენება/დამალვა"),
("Show/Hide Frame Edges", "ჩარჩოს კიდეების ჩვენება/დამალვა"),
("Show/Hide Guides", "დამხმარე ხაზების ჩვენება/დამალვა"),
("Show/Hide Hidden Characters", "დამალული სიმბოლოების ჩვენება/დამალვა"),
+1
View File
@@ -716,6 +716,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Ustaw widoczność / blokadę"),
("Show Panel", "Pokaż panel"),
("Show/Hide Baseline Grid", "Pokaż/ukryj siatkę linii bazowych"),
("Show/Hide Depth Ruler", "Pokaż/ukryj linijkę głębokości"),
("Show/Hide Frame Edges", "Pokaż/ukryj krawędzie ramek"),
("Show/Hide Guides", "Pokaż/ukryj linie pomocnicze"),
("Show/Hide Hidden Characters", "Pokaż/ukryj ukryte znaki"),
+1
View File
@@ -1684,6 +1684,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Definir visibilidade / bloqueio"),
("Show Panel", "Mostrar painel"),
("Show/Hide Baseline Grid", "Mostrar/ocultar grade de linha de base"),
("Show/Hide Depth Ruler", "Mostrar/ocultar régua de profundidade"),
("Show/Hide Frame Edges", "Mostrar/ocultar bordas dos quadros"),
("Show/Hide Guides", "Mostrar/ocultar guias"),
("Show/Hide Hidden Characters", "Mostrar/ocultar caracteres ocultos"),
+1
View File
@@ -696,6 +696,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Задать видимость / блокировку"),
("Show Panel", "Показать панель"),
("Show/Hide Baseline Grid", "Показать/скрыть сетку базовых линий"),
("Show/Hide Depth Ruler", "Показать/скрыть линейку глубины"),
("Show/Hide Frame Edges", "Показать/скрыть границы рамок"),
("Show/Hide Guides", "Показать/скрыть направляющие"),
("Show/Hide Hidden Characters", "Показать/скрыть скрытые символы"),
+1
View File
@@ -840,6 +840,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Set Visibility / Lock", "Установити видимість / блокування"),
("Show Panel", "Показати панель"),
("Show/Hide Baseline Grid", "Показати/приховати сітку базових ліній"),
("Show/Hide Depth Ruler", "Показати/приховати лінійку глибини"),
("Show/Hide Document Grid", "Показати/приховати сітку документа"),
("Show/Hide Frame Edges", "Показати/приховати межі фреймів"),
("Show/Hide Guides", "Показати/приховати напрямні"),
+3
View File
@@ -241,6 +241,8 @@ pub struct UiState {
pub dynamic_spelling: bool,
/// Preferences › Story Editor Display: text size (points).
pub story_editor_size: f32,
/// View › Story Editor › Show/Hide Depth Ruler.
pub story_editor_depth_ruler: bool,
/// Preferences › General › Interface Language: supported codes are listed in `i18n::LANGUAGES`.
pub language: String,
/// Edit › Transparency Flattener Presets: "" (none), "high", "medium" or "low" for PDF export.
@@ -391,6 +393,7 @@ impl Default for UiState {
hidden_menu_items: Vec::new(),
show_full_menus: false,
story_editor_size: 14.0,
story_editor_depth_ruler: true,
scripts: vec![
("Number the pages".into(), "# A page-number frame at the bottom of the current page.\nframe.create {\"rect\": [288, 740, 324, 760], \"content\": \"text\", \"text\": \"\"}\ntext.select {\"story\": \"$0.story\", \"anchor\": 0, \"focus\": 0}\ntext.insert {\"text\": \"\\ue000\", \"raw\": true}".into()),
("Two-column grid".into(), "# Four text frames in a 2 × 2 grid.\nframe.grid {\"rect\": [36, 36, 576, 756], \"cols\": 2, \"rows\": 2, \"gutter\": 12}".into()),
+12
View File
@@ -215,6 +215,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.textThreads", "Show/Hide Text Threads", Some("Cmd+Alt+Y"), "{}"),
("view.hiddenCharacters", "Show Hidden Characters", Some("Cmd+Alt+I"), "{}"),
("view.taggedFrames", "Show/Hide Tagged Frames", None, "{} — XML-tagged frames outlined in their tag colour"),
(
"view.storyEditorDepthRuler",
"Show/Hide Depth Ruler",
None,
"{} — the Story Editor's vertical ruler of how deep the text reaches in its frames",
),
("window.hidePanels", "Show/Hide Panels", Some("Tab"), "{} — every panel, the Tools panel included"),
("window.hidePanelsExceptTools", "Show/Hide Panels Except Tools", Some("Shift+Tab"), "{}"),
("window.nextDocument", "Next Document", Some("Cmd+F6"), "{}"),
@@ -860,6 +866,9 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:view.taggedFrames",
"ui:view.tagMarkers",
"<",
">Story Editor",
"ui:view.storyEditorDepthRuler",
"<",
],
),
(
@@ -1578,6 +1587,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
"view.textThreads" => flag(&mut app.ui.text_threads),
"view.hiddenCharacters" => flag(&mut app.ui.hidden_characters),
"view.taggedFrames" => flag(&mut app.ui.tagged_frames),
"view.storyEditorDepthRuler" => flag(&mut app.ui.story_editor_depth_ruler),
"window.hidePanels" | "window.hidePanelsExceptTools" => {
let mode = if id == "window.hidePanels" { 1 } else { 2 };
app.ui.hidden_panels = if app.ui.hidden_panels == mode { 0 } else { mode };
@@ -2465,6 +2475,7 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> {
"view.textThreads" => app.ui.text_threads,
"view.hiddenCharacters" => app.ui.hidden_characters,
"view.taggedFrames" => app.ui.tagged_frames,
"view.storyEditorDepthRuler" => app.ui.story_editor_depth_ruler,
"changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes),
// Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies
// to (selected text, cells or text frames) has it.
@@ -3404,6 +3415,7 @@ mod tests {
"view.separations",
"view.taggedFrames",
"view.tagMarkers",
"view.storyEditorDepthRuler",
] {
assert!(view.contains(&id), "{id} is in View");
assert!(!typ.contains(&id), "{id} isn't in Type");
+327 -4
View File
@@ -1,15 +1,23 @@
//! Story Editor (Edit → Edit in Story Editor, ⌘Y): the story as plain text with each paragraph's
//! style in the left column and the overset point marked. Edits are applied as minimal range
//! replacements, so formatting around them is kept.
//! style in the left column, the depth ruler beside it and the overset point marked. Edits are
//! applied as minimal range replacements, so formatting around them is kept.
use egui::vec2;
use egui::{pos2, vec2};
use serde_json::json;
use designcraft_compose::ComposedStory;
use designcraft_doc::StoryId;
use crate::DesignApp;
use crate::theme::Tokens;
/// Width of the depth ruler, in screen points.
const RULER_WIDTH: f32 = 34.0;
/// Least distance between two of the depth ruler's labels, in screen points.
const RULER_LABEL_GAP: f32 = 10.0;
/// Color of the overset line and label.
const OVERSET_RED: egui::Color32 = egui::Color32::from_rgb(230, 40, 40);
/// The story open in the Story Editor. Story ids are counted per document, so the editor is bound
/// to the document the story belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -41,6 +49,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let cs = app.session.cache.get(&st.doc, sid, None);
let overset = cs.overset_at;
let depths = app.ui.story_editor_depth_ruler.then(|| line_depths(&cs));
let unit = st.doc.settings.vertical_units;
let mut open = true;
let mut edit: Option<(usize, usize, String)> = None;
let words = story.text.split_whitespace().count().to_string();
@@ -54,6 +64,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
// Style column: the labels are painted after the text, at the rows the text really wraps to.
let (column, _) = ui.allocate_exact_size(vec2(130.0, 0.0), egui::Sense::hover());
ui.separator();
// Depth ruler: painted after the text too, beside the rows the lines start on.
let ruler = depths.as_ref().map(|_| ui.allocate_exact_size(vec2(RULER_WIDTH, 0.0), egui::Sense::hover()).0);
let mut text = story.text.clone();
let out = egui::TextEdit::multiline(&mut text)
.font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0)))
@@ -71,6 +83,33 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
crate::rtl::label(ui, egui::RichText::new(name).size(11.0).color(t.text_dim));
});
}
let overset_y = overset.map(|o| {
let ci = char_offsets(&story.text, std::iter::once(o));
out.galley_pos.y + start_rows(&out.galley, &ci).first().map_or(0.0, |row| row.0)
});
if let (Some(ruler), Some(depths)) = (ruler, &depths) {
let bottom = overset_y.unwrap_or(out.galley_pos.y + out.galley.size().y);
let bar = egui::Rect::from_min_max(pos2(ruler.min.x, out.galley_pos.y), pos2(ruler.min.x + RULER_WIDTH, bottom));
ui.painter().rect_filled(bar, 0.0, t.ruler);
let ticks = depth_ticks(depths, unit.ruler_ticks(1.0).0);
let rows = start_rows(&out.galley, &char_offsets(&story.text, ticks.iter().map(|tick| tick.0)));
let marks = ticks.iter().zip(rows).map(|(&(_, depth), row)| (out.galley_pos.y + row.1, depth));
for (y, depth) in spaced_marks(marks, RULER_LABEL_GAP) {
ui.painter().line_segment([pos2(bar.max.x - 8.0, y), pos2(bar.max.x, y)], egui::Stroke::new(1.0, t.ruler_tick));
ui.painter().text(
pos2(bar.min.x + 2.0, y - 1.0),
egui::Align2::LEFT_BOTTOM,
crate::canvas::fmt_tick(unit.from_pt(depth)),
egui::FontId::proportional(9.0),
t.ruler_text,
);
}
}
// The overset line: across the text, and the ruler when it shows, above the first character that doesn't fit.
if let Some(y) = overset_y {
let x0 = ruler.map_or(r.rect.min.x, |rr| rr.min.x);
ui.painter().line_segment([pos2(x0, y), pos2(r.rect.max.x, y)], egui::Stroke::new(1.5, OVERSET_RED));
}
if r.changed() {
// Minimal diff: common prefix and suffix.
let old = &story.text;
@@ -88,7 +127,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
ui.label(crate::rtl::widget(
ui,
egui::RichText::new(crate::i18n::tr(&app.ui.language, "OVERSET")).color(egui::Color32::from_rgb(230, 40, 40)).strong(),
egui::RichText::new(crate::i18n::tr(&app.ui.language, "OVERSET")).color(OVERSET_RED).strong(),
));
crate::rtl::label(
ui,
@@ -125,6 +164,128 @@ fn paragraph_start_offsets(rows: impl Iterator<Item = (f32, bool)>) -> Vec<f32>
starts
}
/// The depth ruler's values: for each composed line that fits, its first byte and how deep the
/// story's text fills its frames at that line (points). Each line covers the band of its column
/// from one leading above its bottom (its ascent and descent when larger) to its bottom; the depth
/// is the height of the bands covered so far, added up over the story's columns and threaded
/// frames. A line set below the line before it in the same column also covers the space between
/// them (paragraph spacing, or the room a wrap object takes). Lines set side by side in one column
/// (the sub-columns of a split paragraph, or text on both sides of a wrap object) cover one band,
/// so they count once.
fn line_depths(cs: &ComposedStory) -> Vec<(usize, f64)> {
let mut out = Vec::new();
let mut depth = 0.0;
for ft in &cs.frames {
let mut covered: std::collections::BTreeMap<u32, Vec<(f64, f64)>> = std::collections::BTreeMap::new();
let mut before: Option<(u32, f64)> = None;
for line in &ft.lines {
let bottom = line.baseline + line.descent;
let mut top = bottom - line.leading.max(line.ascent + line.descent);
if let Some((column, above)) = before
&& column == line.column
&& above < top
{
top = above;
}
before = Some((line.column, bottom));
if top.is_finite() && bottom > top {
depth += cover(covered.entry(line.column).or_default(), top, bottom);
}
out.push((line.range.start, depth));
}
}
out
}
/// Adds the band `top..bottom` to `bands` (sorted, apart from one another) and returns the height
/// it covers that `bands` did not.
fn cover(bands: &mut Vec<(f64, f64)>, top: f64, bottom: f64) -> f64 {
let overlap: f64 = bands.iter().map(|&(a, b)| (b.min(bottom) - a.max(top)).max(0.0)).sum();
let (mut a, mut b) = (top, bottom);
bands.retain(|&(s, e)| {
let touches = s <= b + 1e-6 && e >= a - 1e-6;
if touches {
(a, b) = (a.min(s), b.max(e));
}
!touches
});
let at = bands.partition_point(|&(s, _)| s < a);
bands.insert(at, (a, b));
(bottom - top - overlap).max(0.0)
}
/// The depth ruler's marks, one at the first line whose depth reaches each multiple of `step`
/// (points): `(line's first byte, the multiple)`. A line that passes several multiples takes the
/// deepest.
fn depth_ticks(depths: &[(usize, f64)], step: f64) -> Vec<(usize, f64)> {
if !(step.is_finite() && step > 0.0) {
return Vec::new();
}
let mut out: Vec<(usize, f64)> = Vec::new();
let mut reached = 0.0;
for &(at, depth) in depths {
if !depth.is_finite() {
continue;
}
let n = (depth / step).floor();
if n > reached {
reached = n;
out.push((at, n * step));
}
}
out
}
/// The depth ruler's marks `(y, depth)` (y ascending) that are drawn: of the marks on one editor
/// row only the deepest, and a mark on a later row only when it is at least `gap` below the one
/// drawn before it.
fn spaced_marks(marks: impl Iterator<Item = (f32, f64)>, gap: f32) -> Vec<(f32, f64)> {
let mut out: Vec<(f32, f64)> = Vec::new();
for mark in marks {
match out.last_mut() {
Some(last) if (mark.0 - last.0).abs() < 0.5 => *last = mark,
Some(last) if mark.0 - last.0 < gap => {}
_ => out.push(mark),
}
}
out
}
/// The editor rows `(top, bottom)` (galley coordinates) that the characters `cis` (ascending) start
/// lines on, in one pass over the rows: at a soft wrap, the row a character starts rather than the
/// end of the row before.
fn start_rows(galley: &egui::Galley, cis: &[usize]) -> Vec<(f32, f32)> {
let mut rows = galley.rows.iter().peekable();
let mut start = 0;
let mut out = Vec::with_capacity(cis.len());
for &ci in cis {
while let Some(row) = rows.peek() {
let end = start + row.char_count_including_newline().0;
if ci < end || rows.len() == 1 {
break;
}
start = end;
rows.next();
}
out.push(rows.peek().map_or((0.0, 0.0), |row| (row.min_y(), row.max_y())));
}
out
}
/// Character indices of the byte offsets `bytes` (ascending) in `text`, for the editor's cursors.
fn char_offsets(text: &str, bytes: impl Iterator<Item = usize>) -> Vec<usize> {
let mut chars = text.char_indices().peekable();
let mut count = 0;
let mut out = Vec::new();
for byte in bytes {
while chars.next_if(|&(i, _)| i < byte).is_some() {
count += 1;
}
out.push(count);
}
out
}
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
@@ -144,6 +305,168 @@ mod tests {
assert_eq!(super::paragraph_start_offsets(rows.into_iter()), vec![0.0, 60.0, 80.0]);
}
/// The depth runs on through the columns: it never goes back, and the first line of the second
/// column is deeper than the last line of the first.
#[test]
fn depths_run_through_columns() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let text = "Depth ruler line of words. ".repeat(80);
let r = app.run("frame.create", json!({"rect": [72, 72, 372, 272], "content": "text", "text": text, "caret": false})).unwrap();
let (id, sid) = (r["id"].as_u64().unwrap(), designcraft_doc::StoryId(r["story"].as_u64().unwrap()));
app.run("object.textFrameOptions", json!({"ids": [id], "columns": 2, "gutter": 12})).unwrap();
let st = app.session.active().unwrap();
let cs = app.session.cache.get(&st.doc, sid, None);
assert!(cs.overset_at.is_some(), "the fixture overflows its frame");
let depths = super::line_depths(&cs);
let lines: Vec<_> = cs.frames[0].lines.iter().collect();
let second = lines.iter().position(|l| l.column == 1).expect("text reaches the second column");
assert!(second > 0 && depths.len() == lines.len(), "{} lines, {} depths", lines.len(), depths.len());
assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "depth never goes back: {depths:?}");
assert!(depths[second].1 > depths[second - 1].1, "the second column adds to the first: {depths:?}");
}
/// A split paragraph's sub-columns sit side by side: a line beside one already counted adds
/// nothing, whether the sub-columns hold several lines or one each, and the text after the
/// split paragraphs goes on from the deeper sub-column.
#[test]
fn split_paragraph_sub_columns_count_once() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let text =
format!("Opening line.\n{}\nMiddle line.\nTwo short lines set side by side.\nClosing line.", "Split column words go here. ".repeat(12));
let r = app.run("frame.create", json!({"rect": [72, 72, 372, 672], "content": "text", "text": text, "caret": false})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let mut doc = (*app.session.active().unwrap().doc).clone();
for i in [1, 3] {
doc.story_mut(sid).unwrap().paras[i].para.span_columns = Some(designcraft_doc::SpanColumns::Split(2));
}
let cs = designcraft_compose::compose_story(&doc, sid, &designcraft_compose::ComposeOptions::default());
let lines = &cs.frames[0].lines;
let depths = super::line_depths(&cs);
assert!(lines.windows(2).any(|w| w[0].para == 1 && w[1].para == 1 && w[1].baseline < w[0].baseline), "para 1 restarts higher");
let side =
(1..lines.len()).find(|&i| lines[i].para == 3 && lines[i - 1].para == 3 && (lines[i].baseline - lines[i - 1].baseline).abs() < 1e-6);
let para3: Vec<_> = lines.iter().filter(|l| l.para == 3).map(|l| (l.baseline, l.x0)).collect();
let side = side.unwrap_or_else(|| panic!("para 3 sets one line in each sub-column: {para3:?}"));
assert_eq!(depths[side].1, depths[side - 1].1, "the second sub-column's line adds nothing: {depths:?}");
for (i, l) in lines.iter().enumerate().filter(|(_, l)| l.para == 1).skip(1) {
let beside = lines[..i].iter().any(|o| o.para == 1 && o.baseline >= l.baseline - 1e-6);
if beside {
assert_eq!(depths[i].1, depths[i - 1].1, "line {i} sits beside one counted: {depths:?}");
}
}
let last = lines.len() - 1;
let col_top = cs.frames[0].columns[0].y0;
let closing = lines[last].baseline + lines[last].descent - col_top;
assert!(depths[last].1 <= closing + 1e-6, "no deeper than the frame holds it: {} > {closing}", depths[last].1);
assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "{depths:?}");
}
/// Text wrapping on both sides of an object sets one line as two segments on one baseline: the
/// second segment adds nothing to the depth.
#[test]
fn segments_beside_a_wrap_object_share_their_line_depth() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let text = "Words that wrap on both sides of the box in the middle. ".repeat(20);
let r = app.run("frame.create", json!({"rect": [72, 72, 432, 472], "content": "text", "text": text, "caret": false})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let b = app.run("frame.create", json!({"rect": [232, 150, 272, 300], "content": "graphic"})).unwrap();
app.run("object.textWrap", json!({"ids": [b["id"].as_u64().unwrap()], "mode": "boundingBox", "side": "bothSides"})).unwrap();
let st = app.session.active().unwrap();
let cs = app.session.cache.get(&st.doc, sid, None);
let lines = &cs.frames[0].lines;
let depths = super::line_depths(&cs);
let pairs: Vec<usize> = (1..lines.len()).filter(|&i| (lines[i].baseline - lines[i - 1].baseline).abs() < 1e-6).collect();
assert!(!pairs.is_empty(), "the fixture sets lines in two segments");
for i in pairs {
assert_eq!(depths[i].1, depths[i - 1].1, "segment {i} adds no depth: {depths:?}");
}
assert!(depths.windows(2).all(|w| w[1].1 >= w[0].1), "{depths:?}");
}
/// Space after a paragraph moves the next one down the frame, and the depth goes down with it.
#[test]
fn paragraph_spacing_adds_to_the_depth() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 372, 672], "content": "text", "text": "First.\nSecond.", "caret": false})).unwrap();
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
let mut doc = (*app.session.active().unwrap().doc).clone();
doc.story_mut(sid).unwrap().paras[0].para.space_after = Some(72.0);
let cs = designcraft_compose::compose_story(&doc, sid, &designcraft_compose::ComposeOptions::default());
let lines = &cs.frames[0].lines;
let depths = super::line_depths(&cs);
assert_eq!(lines.len(), 2);
let moved = (lines[1].baseline + lines[1].descent) - (lines[0].baseline + lines[0].descent);
assert!(moved > 72.0, "the space after moves the second paragraph down: {moved}");
assert!((depths[1].1 - depths[0].1 - moved).abs() < 1e-6, "the depth goes down as far: {depths:?}, {moved}");
}
/// A mark goes at the first line that reaches a multiple; a line that passes several takes only
/// the deepest.
#[test]
fn ticks_go_at_the_first_line_that_reaches_a_multiple() {
let depths = [(0, 10.0), (5, 30.0), (9, 70.0), (12, 100.0), (20, 200.0), (25, f64::NAN)];
assert_eq!(super::depth_ticks(&depths, 60.0), vec![(9, 60.0), (20, 180.0)]);
assert!(super::depth_ticks(&depths, 0.0).is_empty());
assert_eq!(super::char_offsets("aé b", [0, 1, 3, 5].into_iter()), vec![0, 1, 2, 4]);
}
/// Several lines can start on one editor row (narrow columns, large text): only the deepest of
/// their marks is drawn there, and marks on rows closer than a label skip, so labels never
/// overlap and the ones kept stay spread down the ruler.
#[test]
fn marks_on_one_row_show_only_the_deepest_and_keep_their_spacing() {
let marks = [(20.0, 60.0), (20.0, 120.0), (25.0, 180.0), (40.0, 240.0), (60.0, 300.0)];
assert_eq!(super::spaced_marks(marks.into_iter(), 10.0), vec![(20.0, 120.0), (40.0, 240.0), (60.0, 300.0)]);
let close = [(0.0, 60.0), (8.0, 120.0), (16.0, 180.0), (24.0, 240.0)];
assert_eq!(super::spaced_marks(close.into_iter(), 10.0), vec![(0.0, 60.0), (16.0, 180.0)]);
}
/// A line that starts at a soft wrap in the editor is on the wrapped row, not at the end of the
/// row before it, so the overset line and the marks sit beside the text they belong to.
#[test]
fn a_line_starting_at_a_soft_wrap_is_on_the_wrapped_row() {
let ctx = egui::Context::default();
let mut galley = None;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
galley = Some(ui.fonts_mut(|f| f.layout("aaaa bbbb".into(), egui::FontId::proportional(14.0), egui::Color32::WHITE, 40.0)));
});
output.textures_delta.clear();
let galley = galley.expect("laid out");
assert!(galley.rows.len() >= 2, "the fixture wraps: {} rows", galley.rows.len());
let rows = super::start_rows(&galley, &[0, 4, 5, 9, 50]);
let (first, second) = ((galley.rows[0].min_y(), galley.rows[0].max_y()), (galley.rows[1].min_y(), galley.rows[1].max_y()));
assert_eq!(rows, vec![first, first, second, second, (galley.rows.last().unwrap().min_y(), galley.rows.last().unwrap().max_y())]);
}
/// View › Story Editor › Show/Hide Depth Ruler: the ruler sits between the style column and the
/// text, which moves right by the ruler's width; the setting is remembered.
#[test]
fn depth_ruler_shows_and_hides_from_the_view_menu() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({})).unwrap();
let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ruler story", "caret": false})).unwrap();
app.run("app.storyEditor", json!({"story": r["story"].as_u64().unwrap()})).unwrap();
assert!(app.ui.story_editor_depth_ruler, "shown by default");
let mut h = crate::test_window::open(app, egui::vec2(1440.0, 900.0));
h.run_steps(2);
let text_x = |h: &egui_kittest::Harness<'_, crate::test_window::Window>| {
h.get_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput && n.value().as_deref() == Some("Ruler story")).rect().min.x
};
let with = text_x(&h);
h.state_mut().app.run("view.storyEditorDepthRuler", json!({})).unwrap();
h.run_steps(2);
let without = text_x(&h);
assert!((with - without - super::RULER_WIDTH).abs() < 10.0, "the text moves by the ruler: {with} vs {without}");
let app = &h.state().app;
assert_eq!(crate::menus::checked(app, "view.storyEditorDepthRuler", &json!({})), Some(false));
let saved = serde_json::to_vec(&app.ui).unwrap();
assert!(!crate::UiState::from_preferences_json(&saved).unwrap().story_editor_depth_ruler);
}
/// Story ids are counted per document: typing in the editor after switching documents edited
/// the other document's story with the same id.
#[test]
+1 -1
View File
@@ -188,7 +188,7 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
Profile / Edit Original, File ▸ File Info / Open PDF / Document and Print Presets / User
Settings, Type ▸ Size presets, Table ▸ Table Options / Cell Options submenus, Paste Before/After,
Convert Columns, Object ▸ Interactive ▸ Convert to (form fields),
View ▸ Extras badges and Story Editor options. Pages panel ▸ Parent Options (Based on Parent is
View ▸ Extras badges and Story Editor options (Show/Hide Depth Ruler is there). Pages panel ▸ Parent Options (Based on Parent is
the `layout.parents.options` command only); overriding a base parent's items on a parent spread
itself (overrides work on document pages only).
- **Estimate:** 40–70 h (most are thin wrappers over existing engine features).
+1 -1
View File
@@ -50,7 +50,7 @@ basics of editing fail for too many users on Windows and Linux.
| Panels: Pages, Layers, Swatches, Color, Stroke, Character, Paragraph, Styles, Links, Effects, Text Wrap, Align, Pathfinder, Table, Index, TOC, Hyperlinks, Bookmarks, Articles, Tags, Notes, Track Changes, Object States, Buttons and Forms, Preflight, Data Merge, Library, Scripts, Glyphs, Info, Tabs, Story | ✓ | all present | ~ | Navigator, Trap Presets, Animation, Timing missing; pull-down panels can't scroll on Linux (#267) |
| Dialogs: New Document, Document Setup, Preferences (all pages), Paragraph/Character/Object/Table/Cell Style Options, Export PDF, Print, Package, Find/Change | ✓ | present | ~ | generated command dialogs fill the long tail; depth varies |
| Workspaces | Essentials, Essentials Classic, Advanced, Book, Digital Publishing, Interactive for PDF, Printing and Proofing, Review, Typography, Touch | ✓ named workspaces (Essentials Classic: Control panel and iconic panel groups) + custom; Window › Workspace; no Review or Touch | ~ | tab bar overlaps menus in Book/Advanced (#182) |
| Story Editor | ✓ | ✓ | ~ | style-name column, depth ruler missing |
| Story Editor | ✓ | ✓ | ~ | depth ruler and View › Story Editor › Show/Hide Depth Ruler (`story_editor.rs` `depth_ruler_shows_and_hides_from_the_view_menu`); hiding or resizing the style-name column missing |
| Undo granularity (a drag = one step) | ✓ | ✓ (Begin/Preview/Commit) | ✓ | |
| Closing with unsaved changes asks | ✓ | ✓ File › Close and window/menu quit (#146, #545) | ✓ | macOS Dock Quit still bypasses the prompt (#544) |
| UI scaling, System appearance | ✓ | scaling ✓ (#281); follows macOS light/dark ✗ (#237) | ~ | |