mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
Merge track 4 (pages, tables)
Conflicts: dialogs/tests.rs and i18n/*.rs: adjacent additions, both kept.
This commit is contained in:
@@ -439,6 +439,8 @@ Newest first.
|
||||
- **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-23** · Tables: with the Type tool in a cell, Shift+Tab goes back a cell (it went on to the next one) and Esc toggles between the text and the cell, as in InDesign: the insertion point selects its cell, and selected cells select the text of the first one (`table.toggleSelection`). Esc left the table for the frame. No readiness percentage change.
|
||||
- **2026-10-23** · Direct Selection shows a path's direction handles as InDesign: both handles of each selected anchor and, on its neighbours, the handle shaping the segment to it; no handles with no anchor selected, and handles that don't show can't be grabbed. The tool draws the path and its points without the bounding box. Found in the Learn Skills Daily course, chapter 43. No readiness percentage change.
|
||||
- **2026-10-23** · Tables: Convert Text to Table… and Convert Table to Text… open InDesign's dialogs (they converted at once, with tabs and paragraphs only). Column and Row Separator take Tab, Comma, Paragraph or any characters typed; Number of Columns, available when both separators are the same, fills rows of that many cells (a list of paragraphs into two columns); Convert Text to Table also applies a Table Style. `table.convertFromText` takes `rowSeparator`, `columns` and `tableStyle`, and `table.convertToText` its separators. Convert Text to Table is available only with text selected outside a table. No readiness percentage change.
|
||||
- **2026-10-23** · Tables: the Table menu follows InDesign's order (Cell Options before Insert, Delete and Select; Merge, Unmerge and Split before Convert Cell Type and Convert Rows; Distribute Rows Evenly before Distribute Columns Evenly), and its first item is Insert Table… with InDesign's Cmd+Alt+Shift+T, which opens the dialog (the shortcut ran `table.insert` with its defaults, behind the dialog). The Table panel stays in Window › Type & Tables (Shift+F9). The Insert Table dialog is laid out as InDesign's: Body Rows and Columns, then Header and Footer Rows, as numbers with spinners and right-aligned labels, and a Table Style applied to the new table. No readiness percentage change.
|
||||
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. No readiness percentage change.
|
||||
- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. Opened and imported files keep their own setting, so their layouts don't change. No readiness percentage change.
|
||||
- **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change.
|
||||
@@ -501,6 +503,8 @@ Newest first.
|
||||
- **2026-10-21** · Swatches panel: a swatch is renamed in place, as in InDesign: click it, then click its name again (a slow double-click), type, and Enter (Escape keeps the name). Everything that uses it follows; a double-click still opens Swatch Options, and [None], [Registration], [Paper] and [Black] keep their names. Found in the Learn Skills Daily course, chapter 23. No readiness percentage change.
|
||||
- **2026-10-20** · The loaded place cursor shows a thumbnail of the graphic, as InDesign's does; dragging with it draws the frame at the graphic's proportions from where the drag started, with its scale beside the pointer, and places exactly that frame. No readiness percentage change.
|
||||
- **2026-10-21** · Drop Shadow, Inner Shadow and Outer Glow have a blend mode, as InDesign's (Effects dialog › Blending › Mode; `mode` on `object.dropShadow`, `object.innerShadow`, `object.outerGlow`; IDML `BlendMode`). New shadows start Multiply and new outer glows Screen in white ([Paper]); effects saved before keep Normal, so documents look as they did. Found in the Learn Skills Daily course, chapter 14. No readiness percentage change.
|
||||
- **2026-10-14** · The Pages panel labels spreads as InDesign does ("2–3", "2, 1" across a section start, "8, A"), marks each section's first page with a triangle, and scrolls to the page in view. No readiness percentage change.
|
||||
- **2026-10-18** · Double-clicking a section triangle in the Pages panel opens that page’s numbering options, including from parent-page editing. No readiness percentage change.
|
||||
- **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
|
||||
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
|
||||
|
||||
@@ -156,6 +156,19 @@ mod tests {
|
||||
assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order");
|
||||
}
|
||||
|
||||
/// A short last row's padding cells take the converted text's paragraph and character format,
|
||||
/// like the cells with text.
|
||||
#[test]
|
||||
fn from_strings_formats_the_padding_cells_too() {
|
||||
let data = vec![vec!["a".to_string(), "b".to_string()], vec!["c".to_string()]];
|
||||
let para = ParaFormat { style: "Body".into(), ..Default::default() };
|
||||
let fmt = CharFormat { style: "Strong".into(), ..Default::default() };
|
||||
let t = Table::from_strings(1, &data, 200.0, ¶, &fmt);
|
||||
let pad = &t.cell(1, 1).unwrap().text;
|
||||
assert_eq!((pad.paras[0].style.as_str(), pad.chars[0].format.style.as_str()), ("Body", "Strong"));
|
||||
assert_eq!(pad.text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_table_gets_own_paragraph() {
|
||||
let mut s = story("before after");
|
||||
|
||||
@@ -55,12 +55,15 @@ impl Table {
|
||||
pub fn from_strings(id: u64, data: &[Vec<String>], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table {
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width);
|
||||
for (r, row) in data.iter().enumerate() {
|
||||
for (c, s) in row.iter().enumerate() {
|
||||
// Every cell takes the text's format, the padding of a short row too.
|
||||
for r in 0..t.nrows() {
|
||||
for c in 0..t.ncols() {
|
||||
let Some(cell) = t.cell_mut(r, c) else { continue };
|
||||
cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() });
|
||||
cell.text.chars[0].format = fmt.clone();
|
||||
cell.text.insert(0, s);
|
||||
if let Some(s) = data.get(r).and_then(|row| row.get(c)) {
|
||||
cell.text.insert(0, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
t
|
||||
@@ -180,14 +183,19 @@ impl Table {
|
||||
|
||||
/// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents).
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.text_with("\t", "\n")
|
||||
}
|
||||
|
||||
/// The cells' text, columns separated by `col` and rows by `row` (Convert Table to Text).
|
||||
pub fn text_with(&self, col: &str, row: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for r in 0..self.nrows() {
|
||||
if r > 0 {
|
||||
out.push('\n');
|
||||
out.push_str(row);
|
||||
}
|
||||
for c in 0..self.ncols() {
|
||||
if c > 0 {
|
||||
out.push('\t');
|
||||
out.push_str(col);
|
||||
}
|
||||
if let Some(cell) = self.cell(r, c) {
|
||||
out.push_str(&cell.text.text.replace('\n', " "));
|
||||
|
||||
@@ -29,3 +29,4 @@ mod target;
|
||||
|
||||
pub use registry::specs;
|
||||
pub(crate) use selection::{one_selected_cell_to_text, step_cell};
|
||||
pub use structure::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_CONVERTED_CELLS, MAX_HEADER_FOOTER_ROWS};
|
||||
|
||||
@@ -82,8 +82,9 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"table.insert",
|
||||
"Create Table",
|
||||
[],
|
||||
Some("Cmd+Alt+Shift+T"),
|
||||
"{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point",
|
||||
// InDesign's Cmd+Alt+Shift+T opens the Insert Table dialog (`app.insertTableDialog`).
|
||||
None,
|
||||
"{rows?: body rows (4, at most 500), cols? | columns? (4, at most 200), headerRows? (0), footerRows? (0, each at most 100), tableStyle?: name, width?} — at the text insertion point, styled in the same step",
|
||||
has_text,
|
||||
insert
|
||||
),
|
||||
@@ -92,7 +93,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Text to Table",
|
||||
["Table"],
|
||||
None,
|
||||
"{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows",
|
||||
"{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), columns?: number of columns when both separators are the same (1, at most 200), tableStyle?: name, width?: table width (default: the column)} — the selected paragraphs become a table, as InDesign's Convert Text to Table; the words tab, comma and paragraph name those separators, any other text is the separator itself",
|
||||
has_text,
|
||||
convert_from_text
|
||||
),
|
||||
@@ -101,7 +102,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Convert Table to Text",
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs",
|
||||
"{columnSeparator?: tab|comma|paragraph|text (tab), rowSeparator?: paragraph|tab|comma|text (paragraph), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the table becomes its cells' text, as InDesign's Convert Table to Text",
|
||||
in_table,
|
||||
convert_to_text
|
||||
),
|
||||
@@ -207,7 +208,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"table.placeGraphic",
|
||||
"Convert Cell to Graphic Cell",
|
||||
["Table", "Convert Cell Type"],
|
||||
["Table"],
|
||||
None,
|
||||
"{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)",
|
||||
in_table,
|
||||
@@ -216,7 +217,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"table.textCell",
|
||||
"Convert Cell to Text Cell",
|
||||
["Table", "Convert Cell Type"],
|
||||
["Table"],
|
||||
None,
|
||||
"{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics",
|
||||
in_table,
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
use super::target::{column_width, count, edit_table, in_cell, para_direction, target};
|
||||
use super::{CellAddr, Result, RowKind, Selection, Session, Table, TextSel, Value, bad, json, ok, str_param};
|
||||
|
||||
/// The most body rows, columns, and header or footer rows `table.insert` makes (the Insert Table
|
||||
/// dialog's limits too).
|
||||
pub const MAX_BODY_ROWS: u64 = 500;
|
||||
pub const MAX_COLUMNS: u64 = 200;
|
||||
pub const MAX_HEADER_FOOTER_ROWS: u64 = 100;
|
||||
/// The most cells `table.convertFromText` makes: as many as the largest table `table.insert`
|
||||
/// makes, however the rows and columns divide them (a long list can make a long table).
|
||||
pub const MAX_CONVERTED_CELLS: u64 = MAX_BODY_ROWS * MAX_COLUMNS;
|
||||
|
||||
/// A command's `tableStyle`: none for an absent one or [Basic Table] (a new table's look), else the
|
||||
/// resolved style and the cell styles it names. Anything but an existing style's name is an error.
|
||||
fn table_style_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<(designcraft_doc::TableStyle, Vec<designcraft_doc::CellStyle>)>> {
|
||||
let name = match p.get("tableStyle") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::String(n)) if n == designcraft_doc::BASIC_TABLE => return Ok(None),
|
||||
Some(Value::String(n)) => n,
|
||||
Some(_) => return Err(bad(cmd, "`tableStyle` must be a table style's name")),
|
||||
};
|
||||
let styles = &s.doc()?.doc.styles;
|
||||
if !styles.table.iter().any(|x| x.name == *name) {
|
||||
return Err(bad(cmd, format!("no table style `{name}`")));
|
||||
}
|
||||
Ok(Some((styles.resolve_table_style(name), styles.cell.clone())))
|
||||
}
|
||||
|
||||
pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?;
|
||||
if t.cell.is_some() {
|
||||
@@ -20,10 +45,11 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
}
|
||||
};
|
||||
let rows = given("rows", None, 4, 1..=500)?;
|
||||
let cols = given("cols", Some("columns"), 4, 1..=200)?;
|
||||
let header = given("headerRows", None, 0, 0..=100)?;
|
||||
let footer = given("footerRows", None, 0, 0..=100)?;
|
||||
let rows = given("rows", None, 4, 1..=MAX_BODY_ROWS)?;
|
||||
let cols = given("cols", Some("columns"), 4, 1..=MAX_COLUMNS)?;
|
||||
let header = given("headerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?;
|
||||
let footer = given("footerRows", None, 0, 0..=MAX_HEADER_FOOTER_ROWS)?;
|
||||
let style = table_style_param(s, p, "table.insert")?;
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
|
||||
s.edit(|d, sel| {
|
||||
let id = d.alloc();
|
||||
@@ -37,6 +63,9 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() };
|
||||
c.text.chars[0].format = cf.clone();
|
||||
}
|
||||
if let Some((ts, cells)) = &style {
|
||||
ts.apply_to(&mut table, cells);
|
||||
}
|
||||
let r = t.range();
|
||||
st.delete(r.clone());
|
||||
st.insert_table(r.start, table);
|
||||
@@ -45,12 +74,41 @@ pub(super) fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad("table.convertFromText", "select text outside tables"));
|
||||
/// A Convert Text to Table / Convert Table to Text separator: `tab`, `comma`, `paragraph` or the
|
||||
/// separator's own text (InDesign's fields take any characters).
|
||||
fn separator(p: &Value, key: &str, default: &str, cmd: &str) -> Result<String> {
|
||||
let sep = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(default.to_string()),
|
||||
Some(Value::String(s)) => match s.as_str() {
|
||||
"tab" => "\t",
|
||||
"comma" => ",",
|
||||
"paragraph" => "\n",
|
||||
other => other,
|
||||
},
|
||||
Some(_) => "",
|
||||
};
|
||||
if sep.is_empty() {
|
||||
return Err(bad(cmd, format!("`{key}` must be tab, comma, paragraph or the separator's characters")));
|
||||
}
|
||||
let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' };
|
||||
Ok(sep.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "table.convertFromText";
|
||||
let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?;
|
||||
if t.cell.is_some() {
|
||||
return Err(bad(ID, "select text outside tables"));
|
||||
}
|
||||
let (col_sep, row_sep) = (separator(p, "columnSeparator", "\t", ID)?, separator(p, "rowSeparator", "\n", ID)?);
|
||||
// With one separator for both, InDesign fills rows of Number of Columns cells.
|
||||
let per_row = match p.get("columns") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.filter(|n| (1..=MAX_COLUMNS).contains(n))
|
||||
.ok_or_else(|| bad(ID, format!("`columns` must be a whole number from 1 to {MAX_COLUMNS}")))? as usize,
|
||||
};
|
||||
let style = table_style_param(s, p, ID)?;
|
||||
let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t));
|
||||
s.edit(|d, sel| {
|
||||
let id = d.alloc();
|
||||
@@ -62,11 +120,28 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("table.convertFromText", "the selection contains a table"));
|
||||
}
|
||||
let (a, b) = (ranges[pa].start, ranges[pb].end);
|
||||
let data: Vec<Vec<String>> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect();
|
||||
let text = &st.text[a..b];
|
||||
let data: Vec<Vec<String>> = if col_sep == row_sep {
|
||||
let cells: Vec<String> = text.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect();
|
||||
cells.chunks(per_row).map(<[String]>::to_vec).collect()
|
||||
} else {
|
||||
text.split(row_sep.as_str()).map(|row| row.split(col_sep.as_str()).map(|c| c.trim().to_string()).collect()).collect()
|
||||
};
|
||||
// The table is as wide as its widest row; refuse what no table here could hold.
|
||||
let cols = data.iter().map(Vec::len).max().unwrap_or(1) as u64;
|
||||
if cols > MAX_COLUMNS {
|
||||
return Err(bad(ID, format!("the text makes {cols} columns; a table has at most {MAX_COLUMNS}")));
|
||||
}
|
||||
if cols * data.len() as u64 > MAX_CONVERTED_CELLS {
|
||||
return Err(bad(ID, format!("the text makes more than {MAX_CONVERTED_CELLS} cells")));
|
||||
}
|
||||
let pf = st.paras[pa].clone();
|
||||
let cf = st.format_after(a).clone();
|
||||
let mut table = Table::from_strings(id, &data, width, &pf, &cf);
|
||||
table.options.direction = direction;
|
||||
if let Some((ts, cells)) = &style {
|
||||
ts.apply_to(&mut table, cells);
|
||||
}
|
||||
st.delete(a..b);
|
||||
let anchor = st.insert_table(a, table);
|
||||
*sel = Selection::text(TextSel::caret(t.story, anchor));
|
||||
@@ -76,9 +151,11 @@ pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = target(s, p, "table.convertToText")?;
|
||||
let col_sep = separator(p, "columnSeparator", "\t", "table.convertToText")?;
|
||||
let row_sep = separator(p, "rowSeparator", "\n", "table.convertToText")?;
|
||||
s.edit(|d, sel| {
|
||||
let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?;
|
||||
let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default();
|
||||
let text = st.tables.get(&g.table).map(|t| t.text_with(&col_sep, &row_sep)).unwrap_or_default();
|
||||
let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?;
|
||||
st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text);
|
||||
*sel = Selection::text(TextSel::caret(g.story, a + text.len()));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Session, doc};
|
||||
use crate::Result;
|
||||
|
||||
fn session_with_frame() -> (Session, u64, u64) {
|
||||
let mut s = Session::new();
|
||||
@@ -492,6 +493,26 @@ fn rows_columns_merge_and_options() {
|
||||
assert_eq!(st.doc.story(sid).unwrap().text, "Intro");
|
||||
}
|
||||
|
||||
/// `table.insert` styles the table in the same step; a style that isn't there, or isn't a name,
|
||||
/// leaves the story alone. Its dimensions stop at the documented limits.
|
||||
#[test]
|
||||
fn insert_table_with_a_style_is_one_step() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
s.execute("style.table.create", &json!({"name": "Grid"})).unwrap();
|
||||
for bad in [json!("Missing"), json!(7)] {
|
||||
assert!(s.execute("table.insert", &json!({"tableStyle": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty());
|
||||
let r = s.execute("table.insert", &json!({"rows": 900, "cols": 300, "headerRows": 150, "tableStyle": "Grid"})).unwrap();
|
||||
let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();
|
||||
assert_eq!(t["style"], "Grid");
|
||||
let n = |k: &str| t[k].as_array().unwrap().len() as u64;
|
||||
use crate::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS};
|
||||
assert_eq!((n("rows"), n("columns")), (MAX_BODY_ROWS + MAX_HEADER_FOOTER_ROWS, MAX_COLUMNS));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables.is_empty(), "one undo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_text_to_table_and_back() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
@@ -510,6 +531,71 @@ fn convert_text_to_table_and_back() {
|
||||
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Ink\tCoverage\nPlum\t80%\nSunset\t45%\nAfter");
|
||||
}
|
||||
|
||||
/// InDesign's Convert Text to Table and Convert Table to Text separators: tab, comma, paragraph or
|
||||
/// any characters; with one separator for both, rows of Number of Columns cells; a Table Style.
|
||||
#[test]
|
||||
fn convert_text_to_table_with_indesigns_separators() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
let convert = |s: &mut Session, text: &str, p: Value| -> Result<Value> {
|
||||
s.execute("story.setText", &json!({"story": sid, "text": format!("{text}\nAfter")})).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap();
|
||||
s.execute("table.convertFromText", &p)
|
||||
};
|
||||
let cells = |s: &mut Session, r: &Value| -> (usize, Vec<String>) {
|
||||
let t = s.execute("table.get", &json!({"table": r["table"]})).unwrap();
|
||||
let texts = t["cells"].as_array().unwrap().iter().map(|c| c["text"].as_str().unwrap().to_string()).collect();
|
||||
(t["columns"].as_array().unwrap().len(), texts)
|
||||
};
|
||||
// Comma-separated columns, paragraphs for rows.
|
||||
let r = convert(&mut s, "Ink, Coverage\nPlum, 80%", json!({"columnSeparator": "comma"})).unwrap();
|
||||
assert_eq!(cells(&mut s, &r), (2, vec!["Ink".into(), "Coverage".into(), "Plum".into(), "80%".into()]));
|
||||
check(&s);
|
||||
// The lesson's list: one paragraph per cell; with two columns, two cells a row.
|
||||
let list = "Name:\nAddress:\nCity:\nState:";
|
||||
let r = convert(&mut s, list, json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap();
|
||||
assert_eq!(r["rows"], 2);
|
||||
assert_eq!(cells(&mut s, &r).0, 2);
|
||||
let r = convert(&mut s, list, json!({"columnSeparator": "tab"})).unwrap();
|
||||
assert_eq!((r["rows"].as_u64(), cells(&mut s, &r).0), (Some(4), 1), "a column of paragraphs");
|
||||
// Any characters separate: here " | " between columns and ";" between rows.
|
||||
let r = convert(&mut s, "a | b;c | d", json!({"columnSeparator": " | ", "rowSeparator": ";"})).unwrap();
|
||||
assert_eq!(cells(&mut s, &r), (2, vec!["a".into(), "b".into(), "c".into(), "d".into()]));
|
||||
// A table style is applied to the new table.
|
||||
s.execute("style.table.create", &json!({"name": "Grid"})).unwrap();
|
||||
let r = convert(&mut s, "x\ty", json!({"tableStyle": "Grid"})).unwrap();
|
||||
assert_eq!(s.execute("table.get", &json!({"table": r["table"]})).unwrap()["style"], "Grid");
|
||||
// A short last row: its padding cell takes the text's format too.
|
||||
let r = convert(&mut s, "a\nb\nc", json!({"columnSeparator": "paragraph", "rowSeparator": "paragraph", "columns": 2})).unwrap();
|
||||
let t = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().tables[&r["table"].as_u64().unwrap()].clone();
|
||||
let fmt = |c: usize| t.cell(1, c).unwrap().text.chars[0].format.clone();
|
||||
assert_eq!(fmt(1), fmt(0), "the padding cell's format is the text's");
|
||||
for bad in [
|
||||
json!({"columnSeparator": ""}),
|
||||
json!({"rowSeparator": 3}),
|
||||
json!({"columns": 0}),
|
||||
json!({"columns": crate::cmd::table::MAX_COLUMNS + 1}),
|
||||
json!({"tableStyle": "Missing"}),
|
||||
json!({"tableStyle": 123}),
|
||||
] {
|
||||
assert!(convert(&mut s, "p\tq", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
// Text that would make too wide or too big a table is refused, and stays as it was.
|
||||
use crate::cmd::table::{MAX_COLUMNS, MAX_CONVERTED_CELLS};
|
||||
let wide = vec!["x"; MAX_COLUMNS as usize + 1].join("\t");
|
||||
assert!(convert(&mut s, &wide, json!({})).is_err(), "a row wider than a table can be");
|
||||
let long = vec!["a\tb"; MAX_CONVERTED_CELLS as usize / 2 + 1].join("\n");
|
||||
assert!(convert(&mut s, &long, json!({})).is_err(), "more cells than a table can have");
|
||||
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, format!("{long}\nAfter"));
|
||||
let at_most = vec!["x"; MAX_COLUMNS as usize].join("\t");
|
||||
assert!(convert(&mut s, &at_most, json!({})).is_ok(), "a row as wide as a table can be");
|
||||
check(&s);
|
||||
// Back to text with a comma between columns and a tab between rows.
|
||||
let r = convert(&mut s, "Plum\t80%\nSunset\t45%", json!({})).unwrap();
|
||||
s.execute("table.convertToText", &json!({"table": r["table"], "columnSeparator": "comma", "rowSeparator": "tab"})).unwrap();
|
||||
check(&s);
|
||||
assert_eq!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text, "Plum,80%\tSunset,45%\nAfter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_placement_in_cells_and_cell_formatting() {
|
||||
let (mut s, sid, fid) = session_with_frame();
|
||||
|
||||
+190
-22
@@ -36,6 +36,8 @@ impl Dialog {
|
||||
"epubExport" => json!({"split": false, "mode": "style", "style": designcraft_doc::BASIC_PARAGRAPH}),
|
||||
"qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}),
|
||||
"insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}),
|
||||
"convertTextToTable" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph", "columns": 1}),
|
||||
"convertTableToText" => json!({"columnSeparator": "tab", "rowSeparator": "paragraph"}),
|
||||
"insertXref" => json!({"linkTo": "paragraph", "style": "", "target": "", "format": ""}),
|
||||
"findChange" => json!({"find": "", "change": "", "grep": false, "caseSensitive": false, "wholeWord": false, "scope": "document"}),
|
||||
"documentSetup" => json!({}),
|
||||
@@ -201,6 +203,151 @@ fn insert_pages_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Separators the Convert dialogs offer by name; anything else typed in the field is the
|
||||
/// separator's own characters.
|
||||
const SEPARATORS: [(&str, &str); 3] = [("tab", "Tab"), ("comma", "Comma"), ("paragraph", "Paragraph")];
|
||||
|
||||
/// The characters a separator field stands for (`table.convertFromText` resolves it the same
|
||||
/// way), so "comma" and a typed "," are the same separator.
|
||||
fn resolved_separator(v: &str) -> &str {
|
||||
match v {
|
||||
"tab" => "\t",
|
||||
"comma" => ",",
|
||||
"paragraph" => "\n",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the Convert dialog's two separators are the same (Number of Columns then applies).
|
||||
fn same_separators(d: &Dialog) -> bool {
|
||||
resolved_separator(&d.s("columnSeparator")) == resolved_separator(&d.s("rowSeparator"))
|
||||
}
|
||||
|
||||
/// A separator field, as InDesign's: type any characters, or pick Tab, Comma or Paragraph from
|
||||
/// its menu. Stored as `tab`/`comma`/`paragraph` or the typed text.
|
||||
fn separator_field(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
|
||||
let cur = d.s(key);
|
||||
let name = |v: &str| SEPARATORS.iter().find(|s| s.0 == v).map(|s| crate::i18n::tr(lang, s.1).to_string());
|
||||
let mut shown = name(&cur).unwrap_or_else(|| cur.clone());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
if ui.add(egui::TextEdit::singleline(&mut shown).id_salt(("sep", key)).desired_width(150.0)).changed() {
|
||||
let v = SEPARATORS.iter().find(|s| crate::i18n::tr(lang, s.1) == shown).map_or(shown.clone(), |s| s.0.to_string());
|
||||
d.fields.insert(key.into(), json!(v));
|
||||
}
|
||||
ui.menu_button("▾", |ui| {
|
||||
for (v, label) in SEPARATORS {
|
||||
if ui.selectable_label(cur == v, crate::i18n::tr(lang, label)).clicked() {
|
||||
d.fields.insert(key.into(), json!(v));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Table › Convert Text to Table and Convert Table to Text, laid out as InDesign's: Column and
|
||||
/// Row Separator; converting text to a table also has Number of Columns (only when both
|
||||
/// separators are the same: the cells then fill rows of that many) and Table Style.
|
||||
fn convert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let lang = &app.ui.language;
|
||||
let to_table = d.id == "convertTextToTable";
|
||||
let label = |ui: &mut egui::Ui, text: &str, on: bool| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_enabled(on, egui::Label::new(crate::rtl::widget(ui, format!("{}:", crate::i18n::tr(lang, text)))));
|
||||
});
|
||||
};
|
||||
egui::Grid::new(("convert_table", to_table)).num_columns(2).min_col_width(120.0).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
label(ui, "Column Separator", true);
|
||||
separator_field(lang, ui, d, "columnSeparator");
|
||||
ui.end_row();
|
||||
label(ui, "Row Separator", true);
|
||||
separator_field(lang, ui, d, "rowSeparator");
|
||||
ui.end_row();
|
||||
if !to_table {
|
||||
return;
|
||||
}
|
||||
let same = same_separators(d);
|
||||
label(ui, "Number of Columns", same);
|
||||
let max = designcraft_engine::cmd::table::MAX_COLUMNS as f64;
|
||||
let field = crate::widgets::NumField::number("columns", d.n("columns"), "", 0).width(70.0).range(1.0, max).spinner().enabled(same);
|
||||
if let Some(v) = field.show(ui) {
|
||||
d.fields.insert("columns".into(), json!(v.round()));
|
||||
}
|
||||
ui.end_row();
|
||||
label(ui, "Table Style", true);
|
||||
let names: Vec<String> = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default();
|
||||
let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string());
|
||||
craft_ui::ComboBox::from_id_salt("convert_table_style").selected_text(&cur).width(170.0).show_ui(ui, |ui| {
|
||||
for n in &names {
|
||||
if ui.selectable_label(*n == cur, n).clicked() {
|
||||
d.fields.insert("tableStyle".into(), json!(n));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
/// The dialog's chosen table style: none when unset, null or empty (a new table's [Basic Table]).
|
||||
fn table_style_field(d: &Dialog) -> Option<String> {
|
||||
d.fields.get("tableStyle").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// The dialog's `tableStyle` as the table commands take it: null for none, the style's name, or
|
||||
/// a value that is neither (set through `ui.dialog.set`) as it is, for the command to refuse.
|
||||
fn table_style_value(d: &Dialog) -> Value {
|
||||
match d.fields.get("tableStyle") {
|
||||
None | Some(Value::String(_)) => table_style_field(d).map_or(Value::Null, Value::String),
|
||||
Some(v) => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Table › Insert Table, laid out as InDesign's: Table Dimensions (body rows and columns, then
|
||||
/// header and footer rows, each a number with a spinner, labels on the right edge of their
|
||||
/// column) and the Table Style to apply.
|
||||
fn insert_table_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let lang = &app.ui.language;
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Table Dimensions")).font(semibold(12.0)));
|
||||
use designcraft_engine::cmd::table::{MAX_BODY_ROWS, MAX_COLUMNS, MAX_HEADER_FOOTER_ROWS};
|
||||
// `table.insert`'s limits, so the table made is the one the fields show.
|
||||
let rows = [
|
||||
("bodyRows", crate::i18n::tr(lang, "Body Rows"), 1.0, MAX_BODY_ROWS),
|
||||
("columns", crate::i18n::tr_context(lang, "Columns", "table"), 1.0, MAX_COLUMNS),
|
||||
("headerRows", crate::i18n::tr(lang, "Header Rows"), 0.0, MAX_HEADER_FOOTER_ROWS),
|
||||
("footerRows", crate::i18n::tr(lang, "Footer Rows"), 0.0, MAX_HEADER_FOOTER_ROWS),
|
||||
];
|
||||
// Body rows and columns, then (after a gap) header and footer rows; one label width for both.
|
||||
for (g, pair) in rows.chunks(2).enumerate() {
|
||||
if g > 0 {
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
egui::Grid::new(("ins_table", g)).num_columns(2).min_col_width(100.0).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
for (key, label, min, max) in pair {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| crate::rtl::label(ui, format!("{label}:")));
|
||||
let field = crate::widgets::NumField::number(key, d.n(key), "", 0).width(70.0).range(*min, *max as f64).spinner();
|
||||
if let Some(v) = field.show(ui) {
|
||||
d.fields.insert((*key).into(), json!(v.round()));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let names: Vec<String> = app.session.active().map(|st| st.doc.styles.table.iter().map(|s| s.name.clone()).collect()).unwrap_or_default();
|
||||
let cur = table_style_field(d).unwrap_or_else(|| designcraft_doc::BASIC_TABLE.to_string());
|
||||
ui.horizontal(|ui| {
|
||||
crate::rtl::label(ui, format!("{}:", crate::i18n::tr(lang, "Table Style")));
|
||||
craft_ui::ComboBox::from_id_salt("ins_table_style").selected_text(&cur).width(160.0).show_ui(ui, |ui| {
|
||||
for n in &names {
|
||||
if ui.selectable_label(*n == cur, n).clicked() {
|
||||
d.fields.insert("tableStyle".into(), json!(n));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Extract once when opened. Cancel is read-only; confirmation adds exactly these colours.
|
||||
pub(crate) fn open_color_theme(app: &mut DesignApp, params: Value) {
|
||||
match app.run("color.extractTheme", params) {
|
||||
@@ -2580,7 +2727,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
"frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"),
|
||||
"goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"),
|
||||
"insertPages" => crate::i18n::tr(&app.ui.language, "Insert Pages"),
|
||||
"insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"),
|
||||
"insertTable" => crate::i18n::tr(&app.ui.language, "Insert Table…").trim_end_matches('…').trim_end(),
|
||||
// The menu items' translations (every language has them), without the ellipsis.
|
||||
"convertTextToTable" => crate::i18n::tr(&app.ui.language, "Convert Text to Table…").trim_end_matches('…').trim_end(),
|
||||
"convertTableToText" => crate::i18n::tr(&app.ui.language, "Convert Table to Text…").trim_end_matches('…').trim_end(),
|
||||
"cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"),
|
||||
"createGuides" => crate::i18n::tr(&app.ui.language, "Create Guides"),
|
||||
"marginsAndColumns" => crate::i18n::tr(&app.ui.language, "Margins and Columns"),
|
||||
@@ -2652,8 +2802,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0));
|
||||
let preferred_width: f32 = match d.id.as_str() {
|
||||
"alert" => 440.0,
|
||||
"closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" => 380.0,
|
||||
"closeDocument" | "updateLink" | "deletePages" | "newWorkspace" | "pagesPanelOptions" | "insertTable" | "convertTableToText" => 380.0,
|
||||
"insertPages" => 440.0,
|
||||
"convertTextToTable" => 420.0,
|
||||
"newDocument" => 960.0,
|
||||
"hyphenation" => 560.0,
|
||||
"numberingSection" => 560.0,
|
||||
@@ -3401,21 +3552,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
"ruby" => style_cjk::ruby_dialog(app, ui, &mut d),
|
||||
"insertTable" => {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Table Dimensions")).font(semibold(12.0)));
|
||||
egui::Grid::new("ins_table").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Body Rows"));
|
||||
text_field(ui, &mut d, "bodyRows", 60.0);
|
||||
crate::rtl::label(ui, crate::i18n::tr_context(&app.ui.language, "Columns", "table"));
|
||||
text_field(ui, &mut d, "columns", 60.0);
|
||||
ui.end_row();
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Header Rows"));
|
||||
text_field(ui, &mut d, "headerRows", 60.0);
|
||||
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Footer Rows"));
|
||||
text_field(ui, &mut d, "footerRows", 60.0);
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
"insertTable" => insert_table_dialog(app, ui, &mut d),
|
||||
"convertTextToTable" | "convertTableToText" => convert_table_dialog(app, ui, &mut d),
|
||||
"cellOptions" => cell_options(app, ui, &mut d),
|
||||
"createGuides" => create_guides_dialog(app, ui, &mut d),
|
||||
"marginsAndColumns" => margins_and_columns_dialog(app, ui, &mut d),
|
||||
@@ -4004,11 +4142,41 @@ fn confirm_part1(app: &mut DesignApp, d: Dialog) -> Result<Value, String> {
|
||||
None => Err(format!("no page \"{typed}\"")),
|
||||
}
|
||||
}
|
||||
"insertTable" => app.run(
|
||||
"table.insert",
|
||||
json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64,
|
||||
"headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64}),
|
||||
),
|
||||
"convertTextToTable" | "convertTableToText" => {
|
||||
let r = if d.id == "convertTextToTable" {
|
||||
let same = same_separators(&d);
|
||||
let style = table_style_value(&d);
|
||||
app.run(
|
||||
"table.convertFromText",
|
||||
json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator"),
|
||||
"columns": if same { d.fields.get("columns").cloned().unwrap_or(json!(1)) } else { Value::Null }, "tableStyle": style}),
|
||||
)
|
||||
} else {
|
||||
app.run("table.convertToText", json!({"columnSeparator": d.s("columnSeparator"), "rowSeparator": d.s("rowSeparator")}))
|
||||
};
|
||||
// On an error (an emptied separator field) the dialog stays open to be corrected.
|
||||
if let Err(e) = &r {
|
||||
app.status(e.clone());
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
r
|
||||
}
|
||||
"insertTable" => {
|
||||
// The table and its style in one step; a style that is gone leaves the document alone,
|
||||
// and the dialog stays open to be corrected.
|
||||
let style = table_style_value(&d);
|
||||
let r = app.run(
|
||||
"table.insert",
|
||||
json!({"rows": d.n("bodyRows").unwrap_or(4.0).max(1.0) as u64, "cols": d.n("columns").unwrap_or(4.0).max(1.0) as u64,
|
||||
"headerRows": d.n("headerRows").unwrap_or(0.0).max(0.0) as u64, "footerRows": d.n("footerRows").unwrap_or(0.0).max(0.0) as u64,
|
||||
"tableStyle": style}),
|
||||
);
|
||||
if let Err(e) = &r {
|
||||
app.status(e.clone());
|
||||
app.ui.dialog = Some(d);
|
||||
}
|
||||
r
|
||||
}
|
||||
"numberingSection" => {
|
||||
// On an error the dialog stays open to be corrected.
|
||||
let r = crate::section_options::confirm(app, &d);
|
||||
|
||||
@@ -3381,7 +3381,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() {
|
||||
assert!((centred.center() - egui::pos2(600.0, 400.0)).length() < 1.0, "{centred:?}");
|
||||
assert!(centred.width() < 1000.0 && centred.height() < 700.0, "the area is the dialog, not the backdrop: {centred:?}");
|
||||
|
||||
let title = h.get_by_label("Create Table").rect().center();
|
||||
let title = h.get_by_label("Insert Table").rect().center();
|
||||
drag(&mut h, title, title + vec2(100.0, 60.0));
|
||||
let moved = rect(&h);
|
||||
assert!((moved.min - centred.min - vec2(100.0, 60.0)).length() < 2.0, "{centred:?} -> {moved:?}");
|
||||
@@ -3391,7 +3391,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() {
|
||||
assert_eq!(rect(&h), moved, "a drag in the body leaves the dialog in place");
|
||||
|
||||
// The title row stays inside the top edge.
|
||||
let title = h.get_by_label("Create Table").rect().center();
|
||||
let title = h.get_by_label("Insert Table").rect().center();
|
||||
drag(&mut h, title, title - vec2(0.0, 1000.0));
|
||||
assert!(rect(&h).top().abs() < 1.0, "{:?}", rect(&h));
|
||||
|
||||
@@ -3403,7 +3403,7 @@ fn dragging_the_title_moves_the_dialog_and_dragging_the_body_does_not() {
|
||||
assert!((rect(&h).center() - centred.center()).length() < 1.0, "{:?}", rect(&h));
|
||||
|
||||
// Past the left edge, about 100 px of the title row stays inside.
|
||||
let title = h.get_by_label("Create Table").rect().center();
|
||||
let title = h.get_by_label("Insert Table").rect().center();
|
||||
drag(&mut h, title, title - vec2(900.0, 0.0));
|
||||
assert!((rect(&h).right() - 100.0).abs() < 2.0, "{:?}", rect(&h));
|
||||
}
|
||||
@@ -4506,3 +4506,93 @@ fn change_and_change_find_change_the_found_text() {
|
||||
assert_eq!((text(&h), selected(&h)), ("blue blue | red".to_string(), 5..9));
|
||||
assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["status"], json!("1 replacement(s) made"));
|
||||
}
|
||||
|
||||
/// Insert Table: the dimensions as typed, and the Table Style chosen is applied to the new
|
||||
/// table; [Basic Table] (the default) leaves it unstyled.
|
||||
#[test]
|
||||
fn insert_table_applies_its_dimensions_and_table_style() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("style.table.create", json!({"name": "Grid"})).unwrap();
|
||||
for (style, expected) in [("Grid", "Grid"), (designcraft_doc::BASIC_TABLE, "")] {
|
||||
app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap();
|
||||
assert_eq!(app.run("app.insertTableDialog", json!({})).unwrap(), Value::Null);
|
||||
for (field, value) in [("bodyRows", json!(5)), ("columns", json!(3)), ("headerRows", json!(1)), ("tableStyle", json!(style))] {
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value}));
|
||||
}
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||||
let t = app.run("table.get", json!({})).unwrap();
|
||||
let len = |k: &str| t[k].as_array().map_or(0, Vec::len);
|
||||
assert_eq!((len("rows"), len("columns"), t["headerRows"].as_u64()), (6, 3, Some(1)), "{t}");
|
||||
assert_eq!(t["style"].as_str().unwrap_or(""), expected, "{t}");
|
||||
// One Insert Table, one undo.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(app.run("table.get", json!({})).is_err(), "the table is gone");
|
||||
}
|
||||
// A null style is no style: the table is made, unstyled.
|
||||
app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap();
|
||||
app.run("app.insertTableDialog", json!({})).unwrap();
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": null}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||||
assert_eq!(app.run("table.get", json!({})).unwrap()["style"].as_str().unwrap_or(""), "");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
// A style that no longer exists changes nothing.
|
||||
let tables = |app: &DesignApp| app.session.active().unwrap().doc.stories.values().map(|s| s.tables.len()).sum::<usize>();
|
||||
app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text"})).unwrap();
|
||||
app.run("app.insertTableDialog", json!({})).unwrap();
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": "Gone"}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
|
||||
assert_eq!(tables(&app), 0);
|
||||
let d = app.ui.dialog.as_ref().expect("the dialog stays open to be corrected");
|
||||
assert_eq!((d.id.as_str(), d.s("tableStyle")), ("insertTable", "Gone".to_string()));
|
||||
// So does a style that isn't a name: the command refuses it, rather than making the table
|
||||
// unstyled.
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "tableStyle", "value": 42}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
|
||||
assert_eq!(tables(&app), 0);
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("insertTable"), "the dialog stays open");
|
||||
}
|
||||
|
||||
/// Convert Text to Table: available with text selected (not with a caret alone); its
|
||||
/// separators and Number of Columns shape the table. Convert Table to Text takes it back with
|
||||
/// the separators chosen there.
|
||||
#[test]
|
||||
fn convert_dialogs_use_their_separators() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let text = "Name:\nAddress:\nCity:\nState:";
|
||||
let sid = app.run("frame.create", json!({"rect": [72, 72, 372, 372], "content": "text", "text": text})).unwrap()["story"].clone();
|
||||
app.run("text.select", json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
|
||||
assert!(!crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"), "a caret alone");
|
||||
assert!(!crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"), "no table");
|
||||
app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap();
|
||||
assert!(crate::menus::menu_enabled(&app, "app.convertTextToTableDialog"));
|
||||
app.run("app.convertTextToTableDialog", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable"));
|
||||
// "comma" and a typed "," are the same separator, so Number of Columns applies; a count of
|
||||
// 0 is refused, not taken as 1, and the dialog stays open.
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": "comma"}));
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ","}));
|
||||
assert!(same_separators(app.ui.dialog.as_ref().unwrap()));
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "columns", "value": 0}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("convertTextToTable"));
|
||||
// An emptied separator: the command refuses it and the dialog stays open, as typed.
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "rowSeparator", "value": ""}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false);
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| (d.id.as_str(), d.s("rowSeparator"))), Some(("convertTextToTable", String::new())));
|
||||
for (field, value) in [("columnSeparator", json!("paragraph")), ("rowSeparator", json!("paragraph")), ("columns", json!(2))] {
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": field, "value": value}));
|
||||
}
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||||
let t = app.run("table.get", json!({})).unwrap();
|
||||
let len = |k: &str| t[k].as_array().map_or(0, Vec::len);
|
||||
assert_eq!((len("rows"), len("columns")), (2, 2), "{t}");
|
||||
assert!(crate::menus::menu_enabled(&app, "app.convertTableToTextDialog"));
|
||||
app.run("app.convertTableToTextDialog", json!({})).unwrap();
|
||||
ctl(&mut app, "ui.dialog.set", json!({"field": "columnSeparator", "value": ", "}));
|
||||
assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true);
|
||||
let story = app.session.active().unwrap().doc.story(designcraft_doc::StoryId(sid.as_u64().unwrap())).unwrap().text.clone();
|
||||
// (The empty paragraph after a table that ends its story stays after the text.)
|
||||
assert_eq!(story.trim_end(), "Name:, Address:\nCity:, State:");
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "نقطة (نقاط) الارتكاز"),
|
||||
("Convert Points", "تحويل النقاط"),
|
||||
("Delete Point(s)", "حذف النقطة (النقاط)"),
|
||||
("Convert Text to Table…", "تحويل النص إلى جدول…"),
|
||||
("Convert Table to Text…", "تحويل الجدول إلى نص…"),
|
||||
("Column Separator", "فاصل الأعمدة"),
|
||||
("Row Separator", "فاصل الصفوف"),
|
||||
("Number of Columns", "عدد الأعمدة"),
|
||||
("Comma", "فاصلة"),
|
||||
("Restart Numbering", "إعادة الترقيم"),
|
||||
("Continue Numbering", "متابعة الترقيم"),
|
||||
("Keep Options…", "خيارات الإبقاء…"),
|
||||
|
||||
@@ -89,6 +89,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "Kotevní body"),
|
||||
("Convert Points", "Převést body"),
|
||||
("Delete Point(s)", "Odstranit body"),
|
||||
("Convert Text to Table…", "Převést text na tabulku…"),
|
||||
("Convert Table to Text…", "Převést tabulku na text…"),
|
||||
("Column Separator", "Oddělovač sloupců"),
|
||||
("Row Separator", "Oddělovač řádků"),
|
||||
("Number of Columns", "Počet sloupců"),
|
||||
("Comma", "Čárka"),
|
||||
("Restart Numbering", "Začít číslování znovu"),
|
||||
("Continue Numbering", "Pokračovat v číslování"),
|
||||
("Keep Options…", "Volby zachování…"),
|
||||
|
||||
@@ -50,6 +50,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "Ankerpunkt(e)"),
|
||||
("Convert Points", "Punkte konvertieren"),
|
||||
("Delete Point(s)", "Punkt(e) löschen"),
|
||||
("Convert Text to Table…", "Text in Tabelle umwandeln …"),
|
||||
("Convert Table to Text…", "Tabelle in Text umwandeln …"),
|
||||
("Column Separator", "Spaltentrennzeichen"),
|
||||
("Row Separator", "Zeilentrennzeichen"),
|
||||
("Number of Columns", "Spaltenanzahl"),
|
||||
("Comma", "Komma"),
|
||||
("Restart Numbering", "Nummerierung neu beginnen"),
|
||||
("Continue Numbering", "Nummerierung fortführen"),
|
||||
("Keep Options…", "Umbruchoptionen …"),
|
||||
|
||||
@@ -343,7 +343,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Subscript:", "Indice :"),
|
||||
("Superscript:", "Exposant :"),
|
||||
("Swap Text Fill and Stroke", "Permuter le fond et le contour du texte"),
|
||||
("Tab", "Tabulation"),
|
||||
("Table of Contents", "Table des matières"),
|
||||
("Table/Convert Cell Type", "Tableau/Convertir le type de cellule"),
|
||||
("Table/Delete", "Tableau/Supprimer"),
|
||||
|
||||
@@ -83,6 +83,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "Punti di ancoraggio"),
|
||||
("Convert Points", "Converti punti"),
|
||||
("Delete Point(s)", "Elimina punti"),
|
||||
("Tab", "Tabulazione"),
|
||||
("Convert Text to Table…", "Converti testo in tabella…"),
|
||||
("Convert Table to Text…", "Converti tabella in testo…"),
|
||||
("Column Separator", "Separatore colonna"),
|
||||
("Row Separator", "Separatore riga"),
|
||||
("Number of Columns", "Numero di colonne"),
|
||||
("Comma", "Virgola"),
|
||||
("Restart Numbering", "Riavvia numerazione"),
|
||||
("Continue Numbering", "Continua numerazione"),
|
||||
("Keep Options…", "Opzioni di separazione…"),
|
||||
|
||||
@@ -47,6 +47,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "アンカーポイント"),
|
||||
("Convert Points", "ポイントを変換"),
|
||||
("Delete Point(s)", "ポイントを削除"),
|
||||
("Convert Text to Table…", "テキストを表に変換…"),
|
||||
("Convert Table to Text…", "表をテキストに変換…"),
|
||||
("Column Separator", "列分解文字"),
|
||||
("Row Separator", "行分解文字"),
|
||||
("Number of Columns", "列数"),
|
||||
("Comma", "カンマ"),
|
||||
("Restart Numbering", "番号の振り直し"),
|
||||
("Continue Numbering", "番号の継続"),
|
||||
("Keep Options…", "段落分離禁止オプション…"),
|
||||
|
||||
@@ -83,6 +83,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "საყრდენი წერტილ(ებ)ი"),
|
||||
("Convert Points", "წერტილების გარდაქმნა"),
|
||||
("Delete Point(s)", "წერტილ(ებ)ის წაშლა"),
|
||||
("Convert Text to Table…", "ტექსტის ცხრილად გარდაქმნა…"),
|
||||
("Convert Table to Text…", "ცხრილის ტექსტად გარდაქმნა…"),
|
||||
("Column Separator", "სვეტების გამყოფი"),
|
||||
("Row Separator", "სტრიქონების გამყოფი"),
|
||||
("Number of Columns", "სვეტების რაოდენობა"),
|
||||
("Comma", "მძიმე"),
|
||||
("Restart Numbering", "ნუმერაციის თავიდან დაწყება"),
|
||||
("Continue Numbering", "ნუმერაციის გაგრძელება"),
|
||||
("Keep Options…", "შენარჩუნების პარამეტრები…"),
|
||||
|
||||
@@ -87,6 +87,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "Punkty kontrolne"),
|
||||
("Convert Points", "Konwertuj punkty"),
|
||||
("Delete Point(s)", "Usuń punkty"),
|
||||
("Convert Text to Table…", "Zamień tekst na tabelę…"),
|
||||
("Convert Table to Text…", "Zamień tabelę na tekst…"),
|
||||
("Column Separator", "Separator kolumn"),
|
||||
("Row Separator", "Separator wierszy"),
|
||||
("Number of Columns", "Liczba kolumn"),
|
||||
("Comma", "Przecinek"),
|
||||
("Restart Numbering", "Zacznij numerowanie od nowa"),
|
||||
("Continue Numbering", "Kontynuuj numerowanie"),
|
||||
("Keep Options…", "Opcje utrzymywania…"),
|
||||
|
||||
@@ -86,6 +86,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "Ponto(s) de ancoragem"),
|
||||
("Convert Points", "Converter pontos"),
|
||||
("Delete Point(s)", "Excluir ponto(s)"),
|
||||
("Tab", "Tabulação"),
|
||||
("Convert Text to Table…", "Converter texto em tabela…"),
|
||||
("Convert Table to Text…", "Converter tabela em texto…"),
|
||||
("Column Separator", "Separador de coluna"),
|
||||
("Row Separator", "Separador de linha"),
|
||||
("Number of Columns", "Número de colunas"),
|
||||
("Comma", "Vírgula"),
|
||||
("Restart Numbering", "Reiniciar numeração"),
|
||||
("Continue Numbering", "Continuar numeração"),
|
||||
("Keep Options…", "Opções de manutenção…"),
|
||||
|
||||
@@ -91,6 +91,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "Опорные точки"),
|
||||
("Convert Points", "Преобразовать точки"),
|
||||
("Delete Point(s)", "Удалить точки"),
|
||||
("Convert Text to Table…", "Преобразовать текст в таблицу…"),
|
||||
("Convert Table to Text…", "Преобразовать таблицу в текст…"),
|
||||
("Column Separator", "Разделитель столбцов"),
|
||||
("Row Separator", "Разделитель строк"),
|
||||
("Number of Columns", "Число столбцов"),
|
||||
("Comma", "Запятая"),
|
||||
("Restart Numbering", "Начать нумерацию заново"),
|
||||
("Continue Numbering", "Продолжить нумерацию"),
|
||||
("Keep Options…", "Параметры удержания…"),
|
||||
|
||||
@@ -1248,6 +1248,19 @@ pub(super) const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Top Right Corner", ["Ecke oben rechts", "Angle supérieur droit", "Esquina superior derecha", "右上角", "右上角"]),
|
||||
("Bottom Left Corner", ["Ecke unten links", "Angle inférieur gauche", "Esquina inferior izquierda", "左下角", "左下角"]),
|
||||
("Bottom Right Corner", ["Ecke unten rechts", "Angle inférieur droit", "Esquina inferior derecha", "右下角", "右下角"]),
|
||||
("Tab", ["Tabulator", "Tabulation", "Tabulación", "タブ", "制表符"]),
|
||||
(
|
||||
"Convert Text to Table…",
|
||||
["Text in Tabelle umwandeln …", "Convertir le texte en tableau…", "Convertir texto en tabla…", "テキストを表に変換…", "将文本转换为表格…"],
|
||||
),
|
||||
(
|
||||
"Convert Table to Text…",
|
||||
["Tabelle in Text umwandeln …", "Convertir le tableau en texte…", "Convertir tabla en texto…", "表をテキストに変換…", "将表格转换为文本…"],
|
||||
),
|
||||
("Column Separator", ["Spaltentrennzeichen", "Séparateur de colonnes", "Separador de columnas", "列分解文字", "列分隔符"]),
|
||||
("Row Separator", ["Zeilentrennzeichen", "Séparateur de rangs", "Separador de filas", "行分解文字", "行分隔符"]),
|
||||
("Number of Columns", ["Spaltenanzahl", "Nombre de colonnes", "Número de columnas", "列数", "列数"]),
|
||||
("Comma", ["Komma", "Virgule", "Coma", "カンマ", "逗号"]),
|
||||
("Restart Numbering", ["Nummerierung neu beginnen", "Recommencer la numérotation", "Reiniciar numeración", "番号の振り直し", "重新开始编号"]),
|
||||
("Continue Numbering", ["Nummerierung fortführen", "Poursuivre la numérotation", "Continuar numeración", "番号の継続", "继续编号"]),
|
||||
("Keep Options…", ["Umbruchoptionen …", "Options d'enchaînement…", "Opciones de separación…", "段落分離禁止オプション…", "保持选项…"]),
|
||||
|
||||
@@ -84,6 +84,13 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
("Anchor Point(s)", "Опорні точки"),
|
||||
("Convert Points", "Перетворити точки"),
|
||||
("Delete Point(s)", "Видалити точки"),
|
||||
("Tab", "Табуляція"),
|
||||
("Convert Text to Table…", "Перетворити текст на таблицю…"),
|
||||
("Convert Table to Text…", "Перетворити таблицю на текст…"),
|
||||
("Column Separator", "Роздільник стовпців"),
|
||||
("Row Separator", "Роздільник рядків"),
|
||||
("Number of Columns", "Кількість стовпців"),
|
||||
("Comma", "Кома"),
|
||||
("Restart Numbering", "Почати нумерацію знову"),
|
||||
("Continue Numbering", "Продовжити нумерацію"),
|
||||
("Keep Options…", "Параметри утримання…"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::export::{download_document, export_bytes, export_epub, export_idml, export_pdf, export_png, separations_view};
|
||||
use super::invoke::guide_spread;
|
||||
use super::state::{is_window_control, window_control_available};
|
||||
use super::state::{is_window_control, menu_enabled, window_control_available};
|
||||
use super::tables::{CLASSIC_GROUPS, TYPOGRAPHY_GROUPS, workspace_open_panel};
|
||||
use super::tree::recent_label;
|
||||
use super::{DesignApp, ScreenMode, Value, json};
|
||||
@@ -349,6 +349,20 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("findChange", json!({})));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.convertTextToTableDialog" => {
|
||||
if !menu_enabled(app, id) {
|
||||
return Some(Err("select the text to convert to a table".into()));
|
||||
}
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTextToTable", p.clone()));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.convertTableToTextDialog" => {
|
||||
if !menu_enabled(app, id) {
|
||||
return Some(Err("place the insertion point in a table to convert it to text".into()));
|
||||
}
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("convertTableToText", p.clone()));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.insertTableDialog" => {
|
||||
if app.session.active().is_none_or(|d| d.selection.text.is_none()) {
|
||||
return Some(Err("place the insertion point in a text frame to create a table".into()));
|
||||
|
||||
@@ -175,6 +175,15 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
|
||||
if id == "guide.selectAll" && (!app.ui.guides || app.ui.guides_locked) {
|
||||
return false;
|
||||
}
|
||||
// As InDesign's: Convert Text to Table wants selected text outside a table, Convert Table to
|
||||
// Text a table.
|
||||
if id == "app.convertTextToTableDialog" {
|
||||
let selected = app.session.active().and_then(|d| d.selection.text).is_some_and(|t| t.cell.is_none() && !t.is_caret());
|
||||
return selected && enabled(app, "table.convertFromText");
|
||||
}
|
||||
if id == "app.convertTableToTextDialog" {
|
||||
return enabled(app, "table.convertToText");
|
||||
}
|
||||
if id == "view.goBack" || id == "view.goForward" {
|
||||
return crate::canvas::can_go_through_history(app, if id == "view.goBack" { -1 } else { 1 });
|
||||
}
|
||||
|
||||
@@ -421,11 +421,16 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
],
|
||||
),
|
||||
(
|
||||
// InDesign's order. The Table panel is in Window › Type & Tables (Shift+F9).
|
||||
"Table",
|
||||
&[
|
||||
"ui:app.insertTableDialog",
|
||||
"cmd:table.convertFromText",
|
||||
"cmd:table.convertToText",
|
||||
"ui:app.convertTextToTableDialog",
|
||||
"ui:app.convertTableToTextDialog",
|
||||
"-",
|
||||
">Cell Options",
|
||||
"ui:app.cellOptionsDialog",
|
||||
"<",
|
||||
"-",
|
||||
">Insert",
|
||||
"cmd:table.insertRowAbove",
|
||||
@@ -444,26 +449,20 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"cmd:table.selectTable",
|
||||
"<",
|
||||
"-",
|
||||
">Cell Options",
|
||||
"ui:app.cellOptionsDialog",
|
||||
"<",
|
||||
"cmd:table.merge",
|
||||
"cmd:table.unmerge",
|
||||
">Convert Cell Type",
|
||||
"cmd:table.splitHorizontally",
|
||||
"cmd:table.splitVertically",
|
||||
"ui:app.graphicCell|Convert Cell to Graphic Cell…",
|
||||
"cmd:table.textCell",
|
||||
"<",
|
||||
">Convert Rows",
|
||||
"cmd:table.convertRowsToHeader",
|
||||
"cmd:table.convertRowsToBody",
|
||||
"cmd:table.convertRowsToFooter",
|
||||
"<",
|
||||
"cmd:table.splitHorizontally",
|
||||
"cmd:table.splitVertically",
|
||||
"cmd:table.distributeColumns",
|
||||
"cmd:table.distributeRows",
|
||||
"-",
|
||||
"ui:app.tablePanel",
|
||||
"cmd:table.distributeRows",
|
||||
"cmd:table.distributeColumns",
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -2052,7 +2052,7 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() {
|
||||
let mut scratch = designcraft_engine::Session::new();
|
||||
scratch.execute("file.new", &json!({})).unwrap();
|
||||
let pdf = scratch.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone();
|
||||
let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected"];
|
||||
let contexts = ["nothing selected", "text insertion point", "text frame selected", "table cell", "placed PDF selected", "text selected"];
|
||||
// A two-page document, a saved workspace and non-default preferences, a host with window
|
||||
// controls, and file dialogs that are dismissed.
|
||||
let setup = |context: usize| {
|
||||
@@ -2086,6 +2086,11 @@ fn searched_ui_commands_run_without_parameters_and_keep_preferences() {
|
||||
app.run("file.place", json!({"base64": pdf, "name": "a.pdf", "frame": f})).unwrap();
|
||||
app.run("selection.set", json!({"ids": [f]})).unwrap();
|
||||
}
|
||||
// Convert Text to Table needs text selected.
|
||||
5 => {
|
||||
let f = app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "text": "One Two"})).unwrap();
|
||||
app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 7})).unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
@@ -2206,6 +2211,48 @@ fn context_menus_hold_commands_and_menu_bar_submenus() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_menu_follows_indesigns_order() {
|
||||
// InDesign's Table menu with a text insertion point (Adobe's "How to make a table").
|
||||
let table = menu_tree().into_iter().find(|(m, _)| *m == "Table").unwrap().1;
|
||||
let labels: Vec<String> = table
|
||||
.iter()
|
||||
.map(|i| match i {
|
||||
Item::Sep => "-".to_string(),
|
||||
Item::Sub(n, _) => format!("{n} >"),
|
||||
Item::Cmd { label, .. } => label.clone(),
|
||||
})
|
||||
.collect();
|
||||
let expected = [
|
||||
"Insert Table…",
|
||||
"Convert Text to Table…",
|
||||
"Convert Table to Text…",
|
||||
"-",
|
||||
"Cell Options >",
|
||||
"-",
|
||||
"Insert >",
|
||||
"Delete >",
|
||||
"Select >",
|
||||
"-",
|
||||
"Merge Cells",
|
||||
"Unmerge Cells",
|
||||
"Split Cell Horizontally",
|
||||
"Split Cell Vertically",
|
||||
"Convert Cell to Graphic Cell…",
|
||||
"Convert Cell to Text Cell",
|
||||
"Convert Rows >",
|
||||
"-",
|
||||
"Distribute Rows Evenly",
|
||||
"Distribute Columns Evenly",
|
||||
];
|
||||
assert_eq!(labels, expected);
|
||||
// Cmd+Alt+Shift+T opens the dialog, as InDesign's; the engine command has no shortcut of
|
||||
// its own. The Table panel is in Window › Type & Tables.
|
||||
assert_eq!(ui_label("app.insertTableDialog").and_then(|c| c.1), Some("Cmd+Alt+Shift+T"));
|
||||
assert_eq!(designcraft_engine::find_command("table.insert").unwrap().shortcut, None);
|
||||
assert_eq!(ui_label("app.tablePanel").and_then(|c| c.1), Some("Shift+F9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_type_menu_follows_indesigns_order() {
|
||||
// InDesign's Type menu, as Chris Nielsen opens it in "Numbering Page Sections" (2:09).
|
||||
|
||||
@@ -109,7 +109,14 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
"{path | text} — a set saved by InDesign's Show Set (.txt), imported as a new shortcut set, which becomes active: matched commands, panels (Window menu items) and tools (Tools items) take its Default shortcuts (an item without Default keys takes its Text keys), or none when every item matched to them has none; other commands lose keys it takes unless they share them as defaults → {set, imported, changed [{id, label, item, line, from, to}], conflictsCleared [{id, label, shortcut}], skipped [{id, item, line, shortcut, takenBy, reason}] (an earlier item whose keys a later one took, or a reserved key), failed [{id, label, change, shortcuts}], unmatched [{item, line, keys, reason: noMatch | ambiguous | tool | context}], unmatchedWithoutShortcut, unreadable [{item, line, keys, reason}], approximated [{item, line, keys, note}] (numeric keypad keys bound as the main keys), suggestions [{item, line, candidates}] (never applied), matchedWithoutChange [{id, label, item, line, reason}] (a keyless panel-menu item that clears nothing), truncated, shortcutSet (the new set's name)}; ids are command keys (`window.panel {\"panel\":\"layers\"}`)",
|
||||
),
|
||||
("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"),
|
||||
("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"),
|
||||
("app.insertTableDialog", "Insert Table…", Some("Cmd+Alt+Shift+T"), "{} — Insert Table dialog (body/header/footer rows, columns, table style)"),
|
||||
(
|
||||
"app.convertTextToTableDialog",
|
||||
"Convert Text to Table…",
|
||||
None,
|
||||
"{} — Convert Text to Table dialog (column and row separators, number of columns, table style)",
|
||||
),
|
||||
("app.convertTableToTextDialog", "Convert Table to Text…", None, "{} — Convert Table to Text dialog (column and row separators)"),
|
||||
(
|
||||
"app.cellOptionsDialog",
|
||||
"Strokes and Fills…",
|
||||
|
||||
@@ -204,6 +204,25 @@ const THUMB_H: f32 = 37.0;
|
||||
/// Parent rows.
|
||||
const ROW_H: f32 = 29.0;
|
||||
|
||||
/// A spread's label under its thumbnails, as InDesign's: one name for a single page, "2–3" for
|
||||
/// pages numbered on in one section (an en dash), and the names apart ("2, 1", "8, A") when a section starts
|
||||
/// inside the spread.
|
||||
pub(crate) fn spread_label(app: &DesignApp, doc: &designcraft_doc::Document, first: usize, n: usize) -> String {
|
||||
let names: Vec<String> = (first..first + n).map(|abs| app.session.page_label(abs)).collect();
|
||||
let starts = |abs: usize| abs == 0 || doc.sections.iter().any(|s| s.start == abs);
|
||||
match (names.first(), names.last()) {
|
||||
(Some(a), Some(b)) if names.len() > 1 => {
|
||||
if !app.session.prefs.absolute_page_numbers && (first + 1..first + n).any(starts) {
|
||||
names.join(", ")
|
||||
} else {
|
||||
format!("{a}\u{2013}{b}")
|
||||
}
|
||||
}
|
||||
(Some(a), _) => a.clone(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the Pages panel's parents list: [None] (`parent` = `None`) or a parent.
|
||||
struct ParentRow {
|
||||
label: String,
|
||||
@@ -263,6 +282,7 @@ fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, row: &Pare
|
||||
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let uid = st.uid;
|
||||
let doc = st.doc.clone();
|
||||
let editing_parents = st.editing_parents;
|
||||
let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32;
|
||||
@@ -407,6 +427,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
// ---- Document spreads.
|
||||
let cur = crate::canvas::current_page(app).unwrap_or(0);
|
||||
let cur_spread = doc.page_loc(cur).map(|l| l.0);
|
||||
let followed_id = egui::Id::unique("pages_panel_followed");
|
||||
let dragging = egui::DragAndDrop::has_any_payload(ui.ctx());
|
||||
let tallest = doc.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.height)).fold(doc.settings.page_height, f64::max).max(1.0);
|
||||
let th = opts.page_size.page_h();
|
||||
@@ -428,22 +449,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let spine = sp.spine_x() as f32;
|
||||
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0 + row_h + 22.0), Sense::hover());
|
||||
let top = row.min.y + 8.0;
|
||||
// Marker above the current spread.
|
||||
if cur_spread == Some(si) && !editing_parents {
|
||||
let c = egui::pos2(row.min.x + center, row.min.y + 3.5);
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
vec![c + vec2(-5.0, -3.0), c + vec2(5.0, -3.0), c + vec2(0.0, 3.0)],
|
||||
t.icon,
|
||||
Stroke::NONE,
|
||||
));
|
||||
let key = (uid, cur);
|
||||
if ui.data(|d| d.get_temp::<(u64, usize)>(followed_id)) != Some(key) {
|
||||
ui.scroll_to_rect(row, None);
|
||||
ui.data_mut(|d| d.insert_temp(followed_id, key));
|
||||
}
|
||||
}
|
||||
// Pages narrower than their numbers ("10" under an Extra Small page) are labelled
|
||||
// once for the spread.
|
||||
let narrow = sp.pages.len() > 1
|
||||
&& (first..first + sp.pages.len()).zip(&sp.pages).any(|(abs, p)| {
|
||||
let w = ui.painter().layout_no_wrap(app.session.page_label(abs), crate::theme::semibold(11.0), Color32::WHITE).size().x;
|
||||
(p.width as f32 * scale) < w + 6.0
|
||||
});
|
||||
for (pi, p) in sp.pages.iter().enumerate() {
|
||||
let abs = first + pi;
|
||||
let x0 = row.min.x + center + (p.x as f32 - spine) * scale;
|
||||
@@ -464,6 +476,35 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
ui.painter().rect_stroke(pr, 0.0, Stroke::new(0.5, Color32::from_gray(0x4a)), StrokeKind::Outside);
|
||||
if abs == 0 || doc.sections.iter().any(|s| s.start == abs) {
|
||||
let c = egui::pos2(pr.center().x, row.min.y + 4.0);
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
vec![c + vec2(-4.5, -3.0), c + vec2(4.5, -3.0), c + vec2(0.0, 3.0)],
|
||||
t.icon,
|
||||
Stroke::NONE,
|
||||
));
|
||||
let marker =
|
||||
ui.interact(egui::Rect::from_center_size(c, vec2(12.0, 8.0)), ui.scope_id().with(("section", abs)), Sense::click());
|
||||
let label = format!(
|
||||
"{}: {}",
|
||||
crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'),
|
||||
app.session.page_label(abs)
|
||||
);
|
||||
marker.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
// A double click, or the keyboard or a screen reader activating it (a
|
||||
// single pointer click only selects, as on a page).
|
||||
let activated = marker.double_clicked() || marker.clicked() && !marker.clicked_by(egui::PointerButton::Primary);
|
||||
if activated {
|
||||
if editing_parents {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
if let Some(view) = app.view_mut() {
|
||||
view.fitted = false;
|
||||
}
|
||||
}
|
||||
crate::canvas::go_to_page(app, abs);
|
||||
crate::menus::activate(app, "layout.section", &serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
// Parent prefix in the outer top corner (black on white).
|
||||
if let Some(pid) = p.parent
|
||||
&& let Some(pp) = doc.parents.iter().find(|x| x.id == pid)
|
||||
@@ -501,33 +542,29 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
crate::canvas::go_to_page(app, abs);
|
||||
}
|
||||
resp.context_menu(|ui| page_menu(app, ui, &doc, abs, si));
|
||||
// Page number under the page: white on a blue badge when selected. Pages too
|
||||
// narrow for their numbers share one label under the spread (below).
|
||||
if narrow {
|
||||
continue;
|
||||
}
|
||||
let name = app.session.page_label(abs);
|
||||
let g = ui.painter().layout_no_wrap(name, crate::theme::semibold(11.0), Color32::WHITE);
|
||||
let c = egui::pos2(pr.center().x, pr.max.y + 11.0);
|
||||
if selected {
|
||||
let br = egui::Rect::from_center_size(c, vec2(g.size().x.max(7.0) + 7.0, 12.5));
|
||||
ui.painter().rect_filled(br, 0.0, BADGE);
|
||||
}
|
||||
ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE);
|
||||
}
|
||||
if narrow {
|
||||
let names: Vec<String> = (first..first + sp.pages.len()).map(|abs| app.session.page_label(abs)).collect();
|
||||
let label = match (names.first(), names.last()) {
|
||||
(Some(a), Some(b)) if names.len() > 1 => format!("{a}\u{2013}{b}"),
|
||||
(Some(a), _) => a.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let g = ui.painter().layout_no_wrap(label, crate::theme::semibold(11.0), Color32::WHITE);
|
||||
let c = egui::pos2(row.min.x + center, top + row_h + 11.0);
|
||||
if cur_spread == Some(si) && !editing_parents {
|
||||
ui.painter().rect_filled(egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5)), 0.0, BADGE);
|
||||
let label = spread_label(app, &doc, first, sp.pages.len());
|
||||
// White on the badge; the theme's text colour elsewhere, so it reads on a light panel.
|
||||
let badge = cur_spread == Some(si) && !editing_parents;
|
||||
let ink = if badge { Color32::WHITE } else { t.text_strong };
|
||||
let g = ui.painter().layout_no_wrap(label.clone(), crate::theme::semibold(11.0), ink);
|
||||
let c = egui::pos2(row.min.x + center, top + row_h + 11.0);
|
||||
let br = egui::Rect::from_center_size(c, vec2(g.size().x + 7.0, 12.5));
|
||||
if badge {
|
||||
ui.painter().rect_filled(br, 0.0, BADGE);
|
||||
}
|
||||
ui.painter().galley(c - g.size() / 2.0, g, ink);
|
||||
let lr = ui.interact(br, ui.scope_id().with(("spread_label", si)), Sense::click());
|
||||
// A click goes to the spread, so assistive technology hears a button.
|
||||
lr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
if lr.clicked() {
|
||||
if editing_parents {
|
||||
let _ = app.run("layout.parents.edit", json!({"on": false}));
|
||||
if let Some(view) = app.view_mut() {
|
||||
view.fitted = false;
|
||||
}
|
||||
}
|
||||
ui.painter().galley(c - g.size() / 2.0, g, Color32::WHITE);
|
||||
crate::canvas::go_to_page(app, first);
|
||||
}
|
||||
}
|
||||
// Right-click on the empty part of the panel: the short menu.
|
||||
@@ -780,3 +817,140 @@ pub(crate) fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod spread_label_tests {
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Section-marker activation targets its page and leaves parent editing without editing the document.
|
||||
#[test]
|
||||
fn double_clicking_a_section_marker_opens_that_pages_numbering_options() {
|
||||
let mut app = app();
|
||||
app.run("layout.section", json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap();
|
||||
app.run("layout.section", json!({"page": 3, "startNumber": 1, "style": "arabic"})).unwrap();
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0)));
|
||||
app.run("layout.parents.edit", json!({"on": true})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
let key = egui::Id::unique("section_marker_test_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(key, true));
|
||||
return;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
let pos = h.get_by_label("Numbering & Section Options: 1").rect().center();
|
||||
let before = h.state().session.doc().unwrap().doc.clone();
|
||||
for _ in 0..2 {
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerMoved(pos));
|
||||
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
let dialog = h.state().ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.id, "numberingSection");
|
||||
assert_eq!(dialog.fields.get("page"), Some(&json!(3)));
|
||||
assert!(!h.state().session.doc().unwrap().editing_parents);
|
||||
assert!(std::sync::Arc::ptr_eq(&h.state().session.doc().unwrap().doc, &before), "opening options does not edit the section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_marker_opens_its_options_from_the_keyboard_but_not_on_one_click() {
|
||||
let mut app = app();
|
||||
app.run("layout.section", json!({"page": 3, "startNumber": 5})).unwrap();
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1000.0, 800.0)));
|
||||
let mut h = Harness::builder().with_size(vec2(280.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
let key = egui::Id::unique("section_marker_key_test_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(key, true));
|
||||
return;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Numbering & Section Options: 5").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.dialog.is_none(), "one pointer click doesn't open the options");
|
||||
h.run_steps(30);
|
||||
h.get_by_label("Numbering & Section Options: 5").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.dialog.as_ref().map(|d| d.id.as_str()), Some("numberingSection"));
|
||||
}
|
||||
|
||||
/// Create a facing-page document for section navigation tests.
|
||||
fn app() -> DesignApp {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"facingPages": true, "pages": 9})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// Read the label displayed for each document spread.
|
||||
fn labels(app: &DesignApp) -> Vec<String> {
|
||||
let doc = app.session.doc().unwrap().doc.clone();
|
||||
(0..doc.spreads.len()).map(|si| spread_label(app, &doc, doc.first_page_of_spread(si), doc.spreads[si].pages.len())).collect()
|
||||
}
|
||||
|
||||
/// Section boundaries split a spread label instead of implying continuous numbering.
|
||||
#[test]
|
||||
fn spreads_are_labelled_as_indesign_labels_them() {
|
||||
// Chris Nielsen's "Numbering Page Sections": sections starting at 1 on pages 3 and 7,
|
||||
// the second in letters, label their spreads "2, 1" and "4, A".
|
||||
let mut app = app();
|
||||
assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]);
|
||||
app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap();
|
||||
app.run("layout.section", json!({"page": 7, "startNumber": 1, "style": "upperLetters"})).unwrap();
|
||||
assert_eq!(labels(&app), ["1", "2, 1", "2–3", "4, A", "B–C"]);
|
||||
// Absolute numbering: positions, always a range.
|
||||
app.session.prefs.absolute_page_numbers = true;
|
||||
assert_eq!(labels(&app), ["1", "2–3", "4–5", "6–7", "8–9"]);
|
||||
}
|
||||
|
||||
/// Labels navigate to document spreads, including when parent editing was active.
|
||||
#[test]
|
||||
fn the_panel_shows_spread_labels_and_a_click_on_one_goes_to_its_spread() {
|
||||
let mut app = app();
|
||||
app.run("layout.section", json!({"page": 3, "startNumber": 1})).unwrap();
|
||||
// The canvas the panel's clicks move (not drawn here).
|
||||
app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 800.0)));
|
||||
let mut h = Harness::builder().with_size(vec2(260.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut DesignApp| {
|
||||
let key = egui::Id::unique("test_fonts");
|
||||
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
|
||||
crate::theme::install_fonts(ui.ctx(), "");
|
||||
ui.data_mut(|d| d.insert_temp(key, true));
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().show(ui, |ui| show(app, ui));
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
// A spread label is a button: a click goes to the spread.
|
||||
let _ = h.get_by(|n| n.role() == egui::accesskit::Role::Button && n.label().as_deref() == Some("2, 1"));
|
||||
assert!(h.query_by_label("2 1").is_none());
|
||||
// Each page is named for screen readers: two pages are numbered 1.
|
||||
assert_eq!(h.query_all_by_label("Page 1").count(), 2);
|
||||
h.state_mut().run("layout.parents.edit", json!({"on": true})).unwrap();
|
||||
if let Some(view) = h.state_mut().view_mut() {
|
||||
view.fitted = true;
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.get_by_label("2–3").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.doc().unwrap().editing_parents);
|
||||
assert!(!h.state().view().unwrap().fitted);
|
||||
assert_eq!(h.state().session.doc().unwrap().doc.page_loc(crate::canvas::current_page(h.state()).unwrap()).unwrap().0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
|
||||
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions.
|
||||
- IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories; attributes a file leaves unset take InDesign's defaults (Keep Lines Together at start/end of paragraph, 12 pt corners, paragraph rules in Text Color, auto-sizing from the centre, numbered lists restarting in each story, alternating table fills Black 20 % / None; a tint of −1 is the colour's own; frames take the fill, stroke, corners and Text Frame Options they don't write from their object style chain, then [None]) — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`).
|
||||
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands.
|
||||
- Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu (in InDesign's order), Table panel, Insert Table dialog (with Table Style), Convert Text to Table and Convert Table to Text dialogs (any separators, Number of Columns, Table Style), caret/typing/Tab navigation and cell selection in cells; Type tool drags of row and column borders (Shift keeps the table size, or scales every row/column on the outer edge; `table.resizeBorder`); `table.*` commands.
|
||||
- Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline.
|
||||
- EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`).
|
||||
- Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options.
|
||||
|
||||
@@ -135,3 +135,6 @@ covers checking the sampled and redefined settings in the styles tutorial (2:45
|
||||
| 2026-10-11 | minor | Added held Cmd+Space / Cmd+Alt+Space Zoom navigation and canvas input regression tests |
|
||||
| 2026-10-10 | minor | Threading row corrected to ✗ after checking the code (no out-port interaction, no thread command) |
|
||||
| 2026-10-10 | major | Created: measured menu coverage, 27-row interaction checklist from the code and issues |
|
||||
|
||||
Double-clicking a section triangle in the Pages panel opens Numbering & Section Options for
|
||||
that section’s first page. It leaves parent-page editing and does not edit section data until confirmed.
|
||||
|
||||
Reference in New Issue
Block a user