mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
Tabs and Borders and Shading dialogs (#327)
* Add Tabs and Borders and Shading dialogs (#320) Tabs: lists the paragraph's tab stops with position, alignment and leader, Set / Clear / Clear All and the default interval. Opens from the Paragraph dialog's Tabs… button, by double-clicking a tab marker on the ruler, or para.tabs without params. OK is one para.tabs call, which now takes set / clear / clearAll / default (clearing a style's stop records a clear stop over it). Borders and Shading: Borders (None / Box / Shadow / 3-D / Custom, style, colour, width, paragraph or text, preview with clickable sides), Page Border (same, whole document or this section) and Shading (fill, paragraph or text) tabs. Opens from Home › Paragraph › Borders › Borders and Shading… and Design › Page Borders. OK runs only the changed parts as one undo step. para.borders and design.pageBorders take exact "sides" (and design.pageBorders a style and applyTo). Shadow and 3-D are drawn as heavier sides because the model has no border shadow flag; shading patterns are omitted because the model keeps a solid fill only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep charts, SmartArt and OLE objects when saving DOCX (#319) Charts and SmartArt diagrams were drawn since #292 but the writer skipped them, and OLE objects kept only their picture, so opening and saving a document silently dropped them. The reader now keeps each object's own markup (the a:graphic of a chart or diagram, the whole w:object of an OLE object) on the model object, and every part it reaches through relationships (chart part, embedded workbook, colour and style parts; diagram data, layout, quick style, colours and drawing; OLE embedding and picture) as opaque bytes in Document::passthrough, with their content types and relationships in a manifest, as the VBA project already is. The writer emits the object with fresh relationship ids pointing at the re-added parts, keeping each part's own relationship ids and content types. A chart or diagram gets its frame from the object's current size and position; an OLE object gets its new size written into its VML shape. A part whose name the writer uses itself is renamed, and an object pasted more than once gets its own copies of every part (sharing past a size budget). Part counts, relationship counts, markup size and copy bytes are all capped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Columns, Symbol and Field dialogs (#321) Layout > Columns > More Columns... opens a Columns dialog: One/Two/Three/ Left/Right presets, number of columns, per-column width and spacing, Equal column width, Line between, a page preview and Apply to (whole document, this section, this point forward). OK is one layout.columns call, which now takes unequal widths and an apply target (forward adds a continuous section break first). Insert > Symbol > More Symbols... opens a Symbol dialog: font picker, Unicode subset list that jumps through a grid of only the characters the font has (drawn in that font), recently used symbols, a hex character code, and a Special Characters tab. insert.symbol takes a font for the symbol alone; typing after it goes on in the text's font. The ribbon's Symbol gallery shows recently used symbols first. Insert > Quick Parts > Field... opens a Field dialog: categories and the fields WordCraft computes, date pictures, number formats, bookmark / sequence / merge field names, preserve formatting, a live field code and a Field Codes view to type the code directly; OK runs insert.field. Also: date pictures read correctly when switches follow them, and am/pm and seconds work in date and time pictures. Strings in every catalog; gaps #7 and ui-parity updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #321: Field dialog descriptions in our own words (clean room) Reword the one-line field descriptions and the empty-selection prompt so none reads like Word's; translations keep their meaning under the new keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #320: test that a default-tab-only para.tabs change undoes and redoes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep charts, SmartArt and OLE objects when saving DOCX (#319) Charts and SmartArt diagrams were drawn since #292 but the writer skipped them, and OLE objects kept only their picture, so opening and saving a document silently dropped them. The reader now keeps each object's own markup (the a:graphic of a chart or diagram, the whole w:object of an OLE object) on the model object, and every part it reaches through relationships (chart part, embedded workbook, colour and style parts; diagram data, layout, quick style, colours and drawing; OLE embedding and picture) as opaque bytes in Document::passthrough, with their content types and relationships in a manifest, as the VBA project already is. The writer emits the object with fresh relationship ids pointing at the re-added parts, keeping each part's own relationship ids and content types. A chart or diagram gets its frame from the object's current size and position; an OLE object gets its new size written into its VML shape. A part whose name the writer uses itself is renamed, and an object pasted more than once gets its own copies of every part (sharing past a size budget). Part counts, relationship counts, markup size and copy bytes are all capped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Columns, Symbol and Field dialogs (#321) Layout > Columns > More Columns... opens a Columns dialog: One/Two/Three/ Left/Right presets, number of columns, per-column width and spacing, Equal column width, Line between, a page preview and Apply to (whole document, this section, this point forward). OK is one layout.columns call, which now takes unequal widths and an apply target (forward adds a continuous section break first). Insert > Symbol > More Symbols... opens a Symbol dialog: font picker, Unicode subset list that jumps through a grid of only the characters the font has (drawn in that font), recently used symbols, a hex character code, and a Special Characters tab. insert.symbol takes a font for the symbol alone; typing after it goes on in the text's font. The ribbon's Symbol gallery shows recently used symbols first. Insert > Quick Parts > Field... opens a Field dialog: categories and the fields WordCraft computes, date pictures, number formats, bookmark / sequence / merge field names, preserve formatting, a live field code and a Field Codes view to type the code directly; OK runs insert.field. Also: date pictures read correctly when switches follow them, and am/pm and seconds work in date and time pictures. Strings in every catalog; gaps #7 and ui-parity updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Merge fix: #340's rotation round-trip test builds an Image with #333's ole field Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add Tabs and Borders and Shading dialogs (#320) Tabs: lists the paragraph's tab stops with position, alignment and leader, Set / Clear / Clear All and the default interval. Opens from the Paragraph dialog's Tabs… button, by double-clicking a tab marker on the ruler, or para.tabs without params. OK is one para.tabs call, which now takes set / clear / clearAll / default (clearing a style's stop records a clear stop over it). Borders and Shading: Borders (None / Box / Shadow / 3-D / Custom, style, colour, width, paragraph or text, preview with clickable sides), Page Border (same, whole document or this section) and Shading (fill, paragraph or text) tabs. Opens from Home › Paragraph › Borders › Borders and Shading… and Design › Page Borders. OK runs only the changed parts as one undo step. para.borders and design.pageBorders take exact "sides" (and design.pageBorders a style and applyTo). Shadow and 3-D are drawn as heavier sides because the model has no border shadow flag; shading patterns are omitted because the model keeps a solid fill only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * cargo fmt after merging main --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
19 files changed
+1148
-53
No files matched your search
@@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); v0.4.0 released |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321); v0.4.0 released |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316); v0.4.0 released |
|
||||
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316); free rotation and flips for pictures, shapes and text boxes with a rotation handle (#332); v0.4.0 released |
|
||||
|
||||
@@ -74,16 +74,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null}"#),
|
||||
CommandSpec::new("design.pageBorders", "Page Borders", "Design › Page Background", |s, v| {
|
||||
let kind = p::str(v, "kind").unwrap_or("box");
|
||||
let w = p::f32(v, "width").unwrap_or(1.0).clamp(0.25, 6.0);
|
||||
let color = p::str(v, "color").and_then(Rgb::parse);
|
||||
let b = Border { style: BorderStyle::Single, width: w, color, space: 24.0 };
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
s.doc.section_mut(block).page_borders = if kind == "none" { None } else { Some(Borders::box_(b)) };
|
||||
sel_result(s)
|
||||
})
|
||||
.params(r#"{"kind": "box|none", "width"?: pt, "color"?: "RRGGBB"}"#),
|
||||
CommandSpec::new("design.pageBorders", "Page Borders", "Design › Page Background", page_borders).params(
|
||||
r#"{"kind"?: "box|none", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: {"top"|"left"|"bottom"|"right": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null} (exactly these sides, instead of `kind`), "applyTo"?: "section|document"}"#,
|
||||
),
|
||||
CommandSpec::new("design.setDefault", "Set as Default", "Design › Document Formatting", |s, _| {
|
||||
s.status = "These settings will be used for new documents.".into();
|
||||
sel_result(s)
|
||||
@@ -220,3 +213,26 @@ fn watermark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.doc.settings.watermark = Some(Watermark { ..wm });
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Page borders of the caret's section (or every section with `applyTo: "document"`).
|
||||
fn page_borders(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let borders = match v.get("sides") {
|
||||
Some(sides) => Some(super::para::border_sides(sides, 24.0, 24.0)?).filter(Borders::any_visible),
|
||||
None => {
|
||||
let kind = p::str(v, "kind").unwrap_or("box");
|
||||
let width = p::f32(v, "width").unwrap_or(1.0).clamp(0.25, 6.0);
|
||||
let color = p::str(v, "color").and_then(Rgb::parse);
|
||||
let style = p::str(v, "style").map(BorderStyle::from_ooxml).unwrap_or(BorderStyle::Single);
|
||||
(kind != "none").then(|| Borders::box_(Border { style, width, color, space: 24.0 }))
|
||||
}
|
||||
};
|
||||
let blocks: Vec<usize> = match p::str(v, "applyTo") {
|
||||
Some("document") => s.doc.sections().iter().map(|(end, _)| *end).collect(),
|
||||
Some("section") | None => vec![s.sel.focus.path.0.first().copied().unwrap_or(0) as usize],
|
||||
Some(x) => return Err(CmdError::Params(format!("unknown `applyTo` `{x}`"))),
|
||||
};
|
||||
for block in blocks {
|
||||
s.doc.section_mut(block).page_borders = borders;
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::numbering::ListKind;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabStop};
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, Rgb, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
use wordcraft_doc::{Block, Pos};
|
||||
|
||||
@@ -118,8 +118,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fmt(s, &|p| p.shading = c)
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB" | null}"#),
|
||||
CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders)
|
||||
.params(r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick"}"#),
|
||||
CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders).params(
|
||||
r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: Sides} where Sides = {"top"|"left"|"bottom"|"right"|"between": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null}: exactly these sides (others off), instead of `kind`"#,
|
||||
),
|
||||
CommandSpec::new("para.sort", "Sort", "Home › Paragraph", sort).params(r#"{"descending"?: bool}"#),
|
||||
CommandSpec::new("para.keepNext", "Keep with Next", "Home › Paragraph › Line and Page Breaks", |s, v| tog(s, v, |p| p.keep_next, |p, b| p.keep_next = Some(b))),
|
||||
CommandSpec::new("para.keepLines", "Keep Lines Together", "Home › Paragraph › Line and Page Breaks", |s, v| tog(s, v, |p| p.keep_lines, |p, b| p.keep_lines = Some(b))),
|
||||
@@ -132,14 +133,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("para.asianTypography", "Asian Typography", "Home › Paragraph › Asian Typography", asian_typography).params(
|
||||
r#"{"kinsoku"?: bool (East Asian line-breaking rules), "wordWrap"?: bool (false: Latin words may break at any character), "overflowPunct"?: bool (hanging punctuation), "topLinePunct"?: bool (compress punctuation at the start of a line), "autoSpaceDE"?: bool (space between Asian and Latin text), "autoSpaceDN"?: bool (space between Asian text and numbers)} — flags left out stay as they are; returns the flags at the caret"#,
|
||||
),
|
||||
CommandSpec::new("para.tabs", "Tabs", "Home › Paragraph › Paragraph", |s, v| {
|
||||
let tabs: Vec<TabStop> = serde_json::from_value(v.get("tabs").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
if tabs.len() > 64 {
|
||||
return Err(CmdError::Params("at most 64 tab stops".into()));
|
||||
}
|
||||
fmt(s, &|p| p.tabs = Some(tabs.clone()))
|
||||
})
|
||||
.params(r#"{"tabs": [{"pos": pt, "align": "left|center|right|decimal|bar", "leader": "none|dot|hyphen|underscore"}]}"#),
|
||||
CommandSpec::new("para.tabs", "Tabs", "Home › Paragraph › Paragraph", tabs).params(
|
||||
r#"{"tabs"?: [TabStop] (replace the direct stops), "set"?: [TabStop], "clear"?: [pt], "clearAll"?: bool, "default"?: pt} where TabStop = {"pos": pt, "align"?: "left|center|right|decimal|bar", "leader"?: "none|dot|hyphen|underscore|middleDot"}"#,
|
||||
),
|
||||
CommandSpec::new("para.outlineLevel", "Outline Level", "Home › Paragraph › Paragraph", |s, v| {
|
||||
let l = p::u64(v, "level").map(|x| x.min(9) as u8);
|
||||
fmt(s, &|p| p.outline_level = l)
|
||||
@@ -473,7 +469,98 @@ fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Most tab stops a paragraph keeps.
|
||||
const MAX_TABS: usize = 64;
|
||||
|
||||
/// Tab stops: replace the direct list, set and clear stops, and change the default interval, in
|
||||
/// one step. Clearing a stop the paragraph style defines records a clear stop over it.
|
||||
fn tabs(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let stops = |k: &str| -> Result<Option<Vec<TabStop>>, CmdError> {
|
||||
let Some(x) = v.get(k).filter(|x| !x.is_null()) else { return Ok(None) };
|
||||
let list: Vec<TabStop> = serde_json::from_value(x.clone()).map_err(|e| CmdError::Params(format!("`{k}`: {e}")))?;
|
||||
if list.len() > MAX_TABS {
|
||||
return Err(CmdError::Params(format!("at most {MAX_TABS} tab stops")));
|
||||
}
|
||||
if list.iter().any(|t| !t.pos.is_finite() || t.pos.abs() > 1584.0) {
|
||||
return Err(CmdError::Params("tab positions are points within ±1584".into()));
|
||||
}
|
||||
Ok(Some(list))
|
||||
};
|
||||
let replace = stops("tabs")?;
|
||||
let set = stops("set")?.unwrap_or_default();
|
||||
let clear: Vec<f32> = match v.get("clear").filter(|x| !x.is_null()) {
|
||||
None => Vec::new(),
|
||||
Some(x) => {
|
||||
let list: Vec<f32> = serde_json::from_value(x.clone()).map_err(|e| CmdError::Params(format!("`clear`: {e}")))?;
|
||||
if list.len() > MAX_TABS || list.iter().any(|p| !p.is_finite()) {
|
||||
return Err(CmdError::Params(format!("`clear` is at most {MAX_TABS} positions in points")));
|
||||
}
|
||||
list
|
||||
}
|
||||
};
|
||||
let clear_all = p::bool(v, "clearAll").unwrap_or(false);
|
||||
if let Some(d) = p::f32(v, "default") {
|
||||
s.doc.settings.default_tab = d.clamp(1.0, 1584.0);
|
||||
}
|
||||
if replace.is_none() && set.is_empty() && clear.is_empty() && !clear_all {
|
||||
return sel_result(s);
|
||||
}
|
||||
let styles = s.doc.styles.clone();
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.5;
|
||||
fmt(s, &|p| {
|
||||
// The stops the paragraph inherits from its style.
|
||||
let inherited = styles.resolve_para(&ParaProps { tabs: None, ..p.clone() }).tabs;
|
||||
let mut list = replace.clone().unwrap_or_else(|| p.tabs.clone().unwrap_or_default());
|
||||
let clear_at = |list: &mut Vec<TabStop>, pos: f32| {
|
||||
list.retain(|t| !near(t.pos, pos));
|
||||
if inherited.iter().any(|t| near(t.pos, pos)) {
|
||||
list.push(TabStop { pos, align: TabAlign::Clear, leader: TabLeader::None });
|
||||
}
|
||||
};
|
||||
if clear_all {
|
||||
list.clear();
|
||||
for t in &inherited {
|
||||
clear_at(&mut list, t.pos);
|
||||
}
|
||||
}
|
||||
for pos in &clear {
|
||||
clear_at(&mut list, *pos);
|
||||
}
|
||||
for t in &set {
|
||||
list.retain(|x| !near(x.pos, t.pos));
|
||||
list.push(*t);
|
||||
}
|
||||
list.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
list.truncate(MAX_TABS);
|
||||
p.tabs = Some(list);
|
||||
})
|
||||
}
|
||||
|
||||
/// A `sides` object: exactly the sides it names, each `{"style", "width", "color"}`.
|
||||
pub fn border_sides(v: &Value, space_tb: f32, space_lr: f32) -> Result<Borders, CmdError> {
|
||||
let obj = v.as_object().ok_or_else(|| CmdError::Params("`sides` is an object".into()))?;
|
||||
let side = |k: &str, space: f32| -> Option<Border> {
|
||||
let x = obj.get(k).filter(|x| x.is_object())?;
|
||||
let style = p::str(x, "style").map(BorderStyle::from_ooxml).unwrap_or(BorderStyle::Single);
|
||||
let width = p::f32(x, "width").unwrap_or(0.5).clamp(0.25, 6.0);
|
||||
Some(Border { style, width, color: p::str(x, "color").and_then(Rgb::parse), space })
|
||||
};
|
||||
Ok(Borders {
|
||||
top: side("top", space_tb),
|
||||
left: side("left", space_lr),
|
||||
bottom: side("bottom", space_tb),
|
||||
right: side("right", space_lr),
|
||||
between: side("between", space_tb),
|
||||
inside_v: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn borders(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(sides) = v.get("sides") {
|
||||
let b = border_sides(sides, 1.0, 4.0)?;
|
||||
// An explicit empty set keeps a style's borders off.
|
||||
return fmt(s, &|p| p.borders = Some(b));
|
||||
}
|
||||
let kind = p::str(v, "kind").unwrap_or("bottom");
|
||||
let width = p::f32(v, "width").unwrap_or(0.5).clamp(0.25, 6.0);
|
||||
let color = p::str(v, "color").and_then(Rgb::parse);
|
||||
|
||||
@@ -2353,6 +2353,85 @@ fn accessibility_reports_a_chart_without_alt_text() {
|
||||
assert!(issues.iter().any(|i| i["issue"] == "Chart or diagram has no alternative text"), "{issues:?}");
|
||||
}
|
||||
|
||||
/// #320: the Tabs dialog's command sets and clears stops (inherited ones too), changes the
|
||||
/// default interval, and is one undo step.
|
||||
#[test]
|
||||
fn para_tabs_set_clear_and_undo() {
|
||||
use wordcraft_doc::props::{TabAlign, TabLeader};
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Name\tPrice"}));
|
||||
run(&mut s, "para.style", json!({"style": "Header"}));
|
||||
let stops = |s: &Session| {
|
||||
let p = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
|
||||
s.doc.styles.resolve_para(&p).tabs.iter().map(|t| (t.pos, t.align, t.leader)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(stops(&s).len(), 2, "Header's own stops");
|
||||
run(
|
||||
&mut s,
|
||||
"para.tabs",
|
||||
json!({"set": [{"pos": 100.0, "align": "decimal", "leader": "dot"}, {"pos": 300.0}], "clear": [234.0], "default": 18.0}),
|
||||
);
|
||||
assert_eq!(
|
||||
stops(&s),
|
||||
vec![(100.0, TabAlign::Decimal, TabLeader::Dot), (300.0, TabAlign::Left, TabLeader::None), (468.0, TabAlign::Right, TabLeader::None)]
|
||||
);
|
||||
assert_eq!(s.doc.settings.default_tab, 18.0);
|
||||
// Setting a stop at an existing position replaces it.
|
||||
run(&mut s, "para.tabs", json!({"set": [{"pos": 300.0, "align": "bar"}]}));
|
||||
assert!(stops(&s).contains(&(300.0, TabAlign::Bar, TabLeader::None)));
|
||||
run(&mut s, "para.tabs", json!({"clearAll": true}));
|
||||
assert!(stops(&s).is_empty(), "Clear All removes inherited stops too");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(stops(&s).len(), 3);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(stops(&s).len(), 2);
|
||||
assert_eq!(s.doc.settings.default_tab, 36.0, "one undo step restores stops and default together");
|
||||
assert!(s.run("para.tabs", &json!({"set": [{"pos": f64::MAX}]})).is_err());
|
||||
assert!(s.run("para.tabs", &json!({"clear": "x"})).is_err());
|
||||
}
|
||||
|
||||
/// #320: changing only the default tab interval (a document setting) is its own undo step,
|
||||
/// and redo puts it back.
|
||||
#[test]
|
||||
fn para_tabs_default_only_undoes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "a\tb"}));
|
||||
let before = s.doc.settings.default_tab;
|
||||
run(&mut s, "para.tabs", json!({"default": 72.0}));
|
||||
assert_eq!(s.doc.settings.default_tab, 72.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.settings.default_tab, before, "undo restores the default interval");
|
||||
assert!(text(&s).contains("a\tb"), "only the setting was undone");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.settings.default_tab, 72.0, "redo sets it again");
|
||||
}
|
||||
|
||||
/// #320: exact border sides with a style, for paragraphs and page borders.
|
||||
#[test]
|
||||
fn border_sides_apply_exactly() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Boxed"}));
|
||||
let side = json!({"style": "double", "width": 1.5, "color": "C00000"});
|
||||
run(&mut s, "para.borders", json!({"sides": {"top": side, "bottom": side, "left": side, "right": side}}));
|
||||
let b = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.borders).unwrap();
|
||||
for x in [b.top, b.bottom, b.left, b.right] {
|
||||
let x = x.unwrap();
|
||||
assert_eq!((x.style, x.width, x.color), (BorderStyle::Double, 1.5, Some(wordcraft_doc::props::Rgb(0xC0, 0, 0))));
|
||||
}
|
||||
assert!(b.between.is_none());
|
||||
run(&mut s, "para.borders", json!({"sides": {}}));
|
||||
assert!(!s.doc.para_at(&s.sel.focus).and_then(|p| p.props.borders).unwrap().any_visible());
|
||||
run(&mut s, "design.pageBorders", json!({"sides": {"top": {"style": "dashed", "width": 3.0}}, "applyTo": "document"}));
|
||||
let pb = s.doc.last_section.page_borders.unwrap();
|
||||
assert_eq!(pb.top.map(|t| (t.style, t.width)), Some((BorderStyle::Dashed, 3.0)));
|
||||
assert!(pb.left.is_none());
|
||||
run(&mut s, "design.pageBorders", json!({"kind": "box", "style": "thick"}));
|
||||
assert_eq!(s.doc.last_section.page_borders.unwrap().left.map(|t| t.style), Some(BorderStyle::Thick));
|
||||
run(&mut s, "design.pageBorders", json!({"sides": {}}));
|
||||
assert!(s.doc.last_section.page_borders.is_none());
|
||||
}
|
||||
|
||||
/// Encrypt with Password (#55): a document saved with a password opens only with it, keeps it
|
||||
/// for the next save, and saves unencrypted once the password is removed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -1216,11 +1216,18 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
||||
t.ruler,
|
||||
Stroke::new(1.0, c),
|
||||
));
|
||||
for tab in &rp.tabs {
|
||||
let mut open_tabs = false;
|
||||
for (i, tab) in rp.tabs.iter().enumerate() {
|
||||
let tx = sx(tab.pos);
|
||||
let foot = if rtl { -4.0 } else { 4.0 };
|
||||
hp.line_segment([pos2(tx, bar.max.y - 6.0), pos2(tx, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||||
hp.line_segment([pos2(tx, bar.max.y - 1.0), pos2(tx + foot, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||||
// Double-clicking a tab marker opens the Tabs dialog (#320).
|
||||
let zone = Rect::from_center_size(pos2(tx, bar.max.y - 4.0), vec2(8.0, 9.0));
|
||||
open_tabs |= ui.interact(zone, ui.id().with(("ruler_tab", i)), Sense::click()).double_clicked();
|
||||
}
|
||||
if open_tabs {
|
||||
let _ = app.run("para.tabs", json!({}));
|
||||
}
|
||||
// Dragging the left-indent marker.
|
||||
let id = ui.id().with("ruler_left");
|
||||
|
||||
@@ -58,6 +58,14 @@ pub enum Dialog {
|
||||
#[serde(skip)]
|
||||
asian_was: [bool; 6],
|
||||
},
|
||||
/// Tabs (#320).
|
||||
Tabs {
|
||||
form: Box<crate::dialogs_para::TabsForm>,
|
||||
},
|
||||
/// Borders and Shading (#320).
|
||||
Borders {
|
||||
form: Box<crate::dialogs_para::BordersForm>,
|
||||
},
|
||||
Find {
|
||||
query: String,
|
||||
replace: String,
|
||||
@@ -575,7 +583,7 @@ fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value {
|
||||
}
|
||||
|
||||
/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none.
|
||||
fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
|
||||
pub(crate) fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {
|
||||
let c = wordcraft_doc::Rgb::parse(value);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
@@ -608,6 +616,8 @@ impl Dialog {
|
||||
match self {
|
||||
Dialog::Font { .. } => "font",
|
||||
Dialog::Paragraph { .. } => "paragraph",
|
||||
Dialog::Tabs { .. } => "tabs",
|
||||
Dialog::Borders { .. } => "borders",
|
||||
Dialog::Find { replace_mode: false, .. } => "find",
|
||||
Dialog::Find { .. } => "replace",
|
||||
Dialog::Goto { .. } => "goto",
|
||||
@@ -708,6 +718,15 @@ impl Dialog {
|
||||
asian_was: [rp.kinsoku, rp.word_wrap, rp.overflow_punct, rp.top_line_punct, rp.auto_space_de, rp.auto_space_dn],
|
||||
}
|
||||
}
|
||||
"tabs" => Dialog::Tabs { form: Box::new(crate::dialogs_para::TabsForm::read(app)?) },
|
||||
"borders" | "pageBorders" | "shading" => {
|
||||
let tab = match name {
|
||||
"pageBorders" => 1,
|
||||
"shading" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
Dialog::Borders { form: Box::new(crate::dialogs_para::BordersForm::read(app, tab)?) }
|
||||
}
|
||||
"find" | "replace" => Dialog::Find {
|
||||
query: if app.session.sel.is_collapsed() { app.session.find.query.clone() } else { app.session.selected_text() },
|
||||
replace: app.session.find.replace.clone(),
|
||||
@@ -881,6 +900,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let title = match &d {
|
||||
Dialog::Font { .. } => "Font",
|
||||
Dialog::Paragraph { .. } => "Paragraph",
|
||||
Dialog::Tabs { .. } => "Tabs",
|
||||
Dialog::Borders { .. } => "Borders and Shading",
|
||||
Dialog::Find { replace_mode: false, .. } => "Find",
|
||||
Dialog::Find { .. } => "Find and Replace",
|
||||
Dialog::Goto { .. } => "Go To",
|
||||
@@ -1116,13 +1137,21 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.checkbox(dn, tl!("Add space between Asian text and numbers"));
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let tabs = ui.button(tl!("Tabs…")).clicked();
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
if ok || tabs {
|
||||
apply_paragraph(app, *rtl, align, *left, *right, *first, *before, *after, *line, [*keep_next, *keep_lines, *page_break, *widow]);
|
||||
apply_asian(app, *asian, *asian_was);
|
||||
}
|
||||
ok || cancel
|
||||
// Tabs… keeps what was set here and moves on to the Tabs dialog.
|
||||
if tabs {
|
||||
app.dialog = Dialog::open("tabs", app);
|
||||
}
|
||||
ok || cancel || tabs
|
||||
}
|
||||
Dialog::Tabs { form } => crate::dialogs_para::tabs(app, ui, form),
|
||||
Dialog::Borders { form } => crate::dialogs_para::borders(app, ui, form),
|
||||
Dialog::Find { query, replace, match_case, whole_word, regex, replace_mode, message } => {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.selectable_label(!*replace_mode, tl!("Find")).clicked() {
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
//! Paragraph dialogs (#320): Tabs, and Borders and Shading. Each ends by running commands
|
||||
//! (`para.tabs`; `para.borders`, `para.shading`, `format.set`, `format.shading`,
|
||||
//! `design.pageBorders`) as one undo step, so agents get the same result without the dialog.
|
||||
|
||||
use egui::{Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, Rgb, TabAlign, TabLeader, TabStop};
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::dialogs::{buttons, color_menu};
|
||||
use crate::theme::{Tokens, semibold};
|
||||
|
||||
/// Stops closer than this (points) are the same stop.
|
||||
const SAME_POS: f32 = 0.5;
|
||||
|
||||
fn unit() -> wordcraft_geom::Unit {
|
||||
wordcraft_geom::Unit::default()
|
||||
}
|
||||
|
||||
/// The Tabs dialog's fields. Positions in `stops` and `cleared` are points; `pos` and
|
||||
/// `default_tab` are in the interface unit.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TabsForm {
|
||||
pub stops: Vec<TabStop>,
|
||||
#[serde(skip)]
|
||||
pub basis: Vec<TabStop>,
|
||||
pub pos: f32,
|
||||
pub align: TabAlign,
|
||||
pub leader: TabLeader,
|
||||
pub default_tab: f32,
|
||||
#[serde(skip)]
|
||||
pub basis_default: f32,
|
||||
/// Stops to be cleared when OK is pressed.
|
||||
pub cleared: Vec<f32>,
|
||||
pub clear_all: bool,
|
||||
pub selected: Option<usize>,
|
||||
}
|
||||
|
||||
impl TabsForm {
|
||||
/// The caret paragraph's stops and the document's default interval.
|
||||
pub fn read(app: &WordApp) -> Option<TabsForm> {
|
||||
let s = &app.session;
|
||||
let rp = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props))?;
|
||||
let k = unit().pt_per_unit();
|
||||
let stops: Vec<TabStop> = rp.tabs.iter().copied().filter(|t| t.align != TabAlign::Clear && t.pos.is_finite()).take(64).collect();
|
||||
let first = stops.first().copied();
|
||||
let default_tab = s.doc.settings.default_tab / k;
|
||||
Some(TabsForm {
|
||||
basis: stops.clone(),
|
||||
stops,
|
||||
pos: first.map(|t| t.pos / k).unwrap_or(0.0),
|
||||
align: first.map(|t| t.align).unwrap_or_default(),
|
||||
leader: first.map(|t| t.leader).unwrap_or_default(),
|
||||
default_tab,
|
||||
basis_default: default_tab,
|
||||
cleared: Vec::new(),
|
||||
clear_all: false,
|
||||
selected: first.map(|_| 0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set (add or replace) the stop the fields describe.
|
||||
pub fn set(&mut self) {
|
||||
let pos = self.pos * unit().pt_per_unit();
|
||||
if !pos.is_finite() || self.stops.len() >= 64 {
|
||||
return;
|
||||
}
|
||||
self.stops.retain(|t| (t.pos - pos).abs() >= SAME_POS);
|
||||
self.cleared.retain(|c| (c - pos).abs() >= SAME_POS);
|
||||
self.stops.push(TabStop { pos, align: self.align, leader: self.leader });
|
||||
self.stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
self.selected = self.stops.iter().position(|t| (t.pos - pos).abs() < SAME_POS);
|
||||
}
|
||||
|
||||
/// Clear the selected stop (or the one at the position field).
|
||||
pub fn clear(&mut self) {
|
||||
let pos = self.pos * unit().pt_per_unit();
|
||||
let i = self.selected.filter(|i| *i < self.stops.len()).or_else(|| self.stops.iter().position(|t| (t.pos - pos).abs() < SAME_POS));
|
||||
if let Some(i) = i {
|
||||
let t = self.stops.remove(i);
|
||||
self.cleared.push(t.pos);
|
||||
}
|
||||
self.selected = None;
|
||||
}
|
||||
|
||||
pub fn clear_all(&mut self) {
|
||||
self.cleared.extend(self.stops.drain(..).map(|t| t.pos));
|
||||
self.clear_all = true;
|
||||
self.selected = None;
|
||||
}
|
||||
|
||||
/// `para.tabs` parameters for what changed (`None` when nothing did).
|
||||
pub fn params(&self) -> Option<Value> {
|
||||
let mut v = serde_json::Map::new();
|
||||
if self.clear_all {
|
||||
v.insert("clearAll".into(), json!(true));
|
||||
if !self.stops.is_empty() {
|
||||
v.insert("set".into(), json!(self.stops));
|
||||
}
|
||||
} else {
|
||||
let set: Vec<&TabStop> = self.stops.iter().filter(|t| !self.basis.contains(t)).collect();
|
||||
let clear: Vec<f32> = self.basis.iter().filter(|b| !self.stops.iter().any(|t| (t.pos - b.pos).abs() < SAME_POS)).map(|b| b.pos).collect();
|
||||
if !set.is_empty() {
|
||||
v.insert("set".into(), json!(set));
|
||||
}
|
||||
if !clear.is_empty() {
|
||||
v.insert("clear".into(), json!(clear));
|
||||
}
|
||||
}
|
||||
if (self.default_tab - self.basis_default).abs() > 1e-4 {
|
||||
v.insert("default".into(), json!(self.default_tab * unit().pt_per_unit()));
|
||||
}
|
||||
(!v.is_empty()).then_some(Value::Object(v))
|
||||
}
|
||||
}
|
||||
|
||||
/// The Tabs dialog body. Returns true to close.
|
||||
pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
|
||||
let u = unit();
|
||||
let k = u.pt_per_unit();
|
||||
ui.horizontal(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Tab stop position:"));
|
||||
ui.add(egui::DragValue::new(&mut f.pos).speed(0.05).range(-22.0 * 72.0 / k..=22.0 * 72.0 / k).suffix(u.suffix()).max_decimals(2));
|
||||
let mut pick = None;
|
||||
egui::Frame::new().stroke(Stroke::new(1.0, Tokens::get(ui.ctx()).input_border)).inner_margin(4.0).show(ui, |ui| {
|
||||
ui.set_min_size(vec2(150.0, 96.0));
|
||||
egui::ScrollArea::vertical().max_height(96.0).show(ui, |ui| {
|
||||
for (i, t) in f.stops.iter().enumerate() {
|
||||
if ui.selectable_label(f.selected == Some(i), u.format(t.pos)).clicked() {
|
||||
pick = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(i) = pick
|
||||
&& let Some(t) = f.stops.get(i)
|
||||
{
|
||||
f.selected = Some(i);
|
||||
f.pos = t.pos / k;
|
||||
f.align = t.align;
|
||||
f.leader = t.leader;
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Default tab stops:"));
|
||||
ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).suffix(u.suffix()).max_decimals(2));
|
||||
ui.add_space(8.0);
|
||||
ui.label(tl!("Tab stops to be cleared:"));
|
||||
let list = if f.clear_all { tl!("All").to_string() } else { f.cleared.iter().map(|p| u.format(*p)).collect::<Vec<_>>().join(", ") };
|
||||
ui.label(egui::RichText::new(list).weak());
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Alignment")).font(semibold(12.5)));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (a, l) in [
|
||||
(TabAlign::Left, "Left"),
|
||||
(TabAlign::Center, "Center"),
|
||||
(TabAlign::Right, "Right"),
|
||||
(TabAlign::Decimal, "Decimal"),
|
||||
(TabAlign::Bar, "Bar"),
|
||||
] {
|
||||
ui.radio_value(&mut f.align, a, tl!(l));
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Leader")).font(semibold(12.5)));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (l, label) in [
|
||||
(TabLeader::None, "None"),
|
||||
(TabLeader::Dot, "1 ……"),
|
||||
(TabLeader::Hyphen, "2 ------"),
|
||||
(TabLeader::Underscore, "3 ______"),
|
||||
(TabLeader::MiddleDot, "4 ······"),
|
||||
] {
|
||||
ui.radio_value(&mut f.leader, l, tl!(label));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("Set")).clicked() {
|
||||
f.set();
|
||||
}
|
||||
if ui.add_enabled(!f.stops.is_empty(), egui::Button::new(tl!("Clear"))).clicked() {
|
||||
f.clear();
|
||||
}
|
||||
if ui.add_enabled(!f.stops.is_empty(), egui::Button::new(tl!("Clear All"))).clicked() {
|
||||
f.clear_all();
|
||||
}
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
// A position typed but not Set is set on OK, as the field shows it.
|
||||
let pos = f.pos * k;
|
||||
let typed = f.selected.is_none() && !f.clear_all && f.cleared.iter().all(|c| (c - pos).abs() >= SAME_POS) && pos > 0.0;
|
||||
if typed && !f.stops.iter().any(|t| (t.pos - pos).abs() < SAME_POS) {
|
||||
f.set();
|
||||
}
|
||||
if let Some(params) = f.params() {
|
||||
let _ = app.run("para.tabs", params);
|
||||
}
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
|
||||
/// A border setting: the presets on the left of the Borders and Page Border tabs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Setting {
|
||||
None,
|
||||
Box,
|
||||
/// A box with heavier bottom and right lines.
|
||||
Shadow,
|
||||
/// A box with heavier top and left lines.
|
||||
ThreeD,
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Border lines as the dialog edits them: one style, colour and width for the chosen sides.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LineForm {
|
||||
pub setting: Setting,
|
||||
pub style: BorderStyle,
|
||||
/// `RRGGBB`, empty for automatic.
|
||||
pub color: String,
|
||||
pub width: f32,
|
||||
/// Top, bottom, left, right, between.
|
||||
pub sides: [bool; 5],
|
||||
}
|
||||
|
||||
const TOP: usize = 0;
|
||||
const BOTTOM: usize = 1;
|
||||
const LEFT: usize = 2;
|
||||
const RIGHT: usize = 3;
|
||||
const BETWEEN: usize = 4;
|
||||
|
||||
const STYLES: [(BorderStyle, &str); 8] = [
|
||||
(BorderStyle::Single, "Single"),
|
||||
(BorderStyle::Dotted, "Dotted"),
|
||||
(BorderStyle::Dashed, "Dashed"),
|
||||
(BorderStyle::DotDash, "Dot dash"),
|
||||
(BorderStyle::Double, "Double"),
|
||||
(BorderStyle::Triple, "Triple"),
|
||||
(BorderStyle::Thick, "Thick"),
|
||||
(BorderStyle::Wave, "Wave"),
|
||||
];
|
||||
|
||||
const WIDTHS: [f32; 9] = [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0];
|
||||
|
||||
impl LineForm {
|
||||
fn read(b: Option<&Borders>) -> LineForm {
|
||||
let side = |x: Option<Border>| x.filter(Border::is_visible);
|
||||
let s = b.map(|b| [side(b.top), side(b.bottom), side(b.left), side(b.right), side(b.between)]).unwrap_or_default();
|
||||
let on = s.map(|x| x.is_some());
|
||||
let first = s.iter().flatten().next().copied();
|
||||
let w = |i: usize| s.get(i).copied().flatten().map(|x| x.width).unwrap_or(0.0);
|
||||
let four = on[TOP] && on[BOTTOM] && on[LEFT] && on[RIGHT] && !on[BETWEEN];
|
||||
let setting = if first.is_none() {
|
||||
Setting::None
|
||||
} else if four && w(TOP) == w(LEFT) && w(BOTTOM) == w(RIGHT) && w(BOTTOM) > w(TOP) {
|
||||
Setting::Shadow
|
||||
} else if four && w(TOP) == w(LEFT) && w(BOTTOM) == w(RIGHT) && w(TOP) > w(BOTTOM) {
|
||||
Setting::ThreeD
|
||||
} else if four && w(TOP) == w(BOTTOM) && w(LEFT) == w(RIGHT) && w(TOP) == w(LEFT) {
|
||||
Setting::Box
|
||||
} else {
|
||||
Setting::Custom
|
||||
};
|
||||
// The base width of a heavier-sided preset is the lighter side's.
|
||||
let width = match setting {
|
||||
Setting::Shadow => w(TOP),
|
||||
Setting::ThreeD => w(BOTTOM),
|
||||
_ => first.map(|f| f.width).unwrap_or(0.5),
|
||||
};
|
||||
LineForm {
|
||||
setting,
|
||||
style: first.map(|f| f.style).unwrap_or(BorderStyle::Single),
|
||||
color: first.and_then(|f| f.color).map(Rgb::hex).unwrap_or_default(),
|
||||
width: if width > 0.0 { width } else { 0.5 },
|
||||
sides: on,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_setting(&mut self, s: Setting) {
|
||||
self.setting = s;
|
||||
match s {
|
||||
Setting::None => self.sides = [false; 5],
|
||||
Setting::Box | Setting::Shadow | Setting::ThreeD => self.sides = [true, true, true, true, false],
|
||||
Setting::Custom if !self.sides.iter().any(|x| *x) => self.sides = [true, true, true, true, false],
|
||||
Setting::Custom => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(&mut self, side: usize) {
|
||||
if let Some(x) = self.sides.get_mut(side) {
|
||||
*x = !*x;
|
||||
}
|
||||
self.setting = if self.sides.iter().any(|x| *x) { Setting::Custom } else { Setting::None };
|
||||
}
|
||||
|
||||
/// The width of `side` under the setting (Shadow and 3-D double their heavier sides).
|
||||
fn side_width(&self, side: usize) -> f32 {
|
||||
let heavy = match self.setting {
|
||||
Setting::Shadow => side == BOTTOM || side == RIGHT,
|
||||
Setting::ThreeD => side == TOP || side == LEFT,
|
||||
_ => false,
|
||||
};
|
||||
let w = self.width.clamp(0.25, 6.0);
|
||||
if heavy { (w * 2.0).min(6.0) } else { w }
|
||||
}
|
||||
|
||||
fn border(&self, side: usize) -> Border {
|
||||
Border { style: self.style, width: self.side_width(side), color: Rgb::parse(&self.color), space: 0.0 }
|
||||
}
|
||||
|
||||
/// The `sides` parameter of `para.borders` / `design.pageBorders`.
|
||||
fn sides_param(&self) -> Value {
|
||||
let mut m = serde_json::Map::new();
|
||||
for (i, k) in ["top", "bottom", "left", "right", "between"].into_iter().enumerate() {
|
||||
if self.sides.get(i).copied().unwrap_or(false) {
|
||||
let mut side = json!({"style": self.style.ooxml(), "width": self.side_width(i)});
|
||||
if Rgb::parse(&self.color).is_some() {
|
||||
side["color"] = json!(self.color);
|
||||
}
|
||||
m.insert(k.into(), side);
|
||||
}
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Borders and Shading dialog's fields.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BordersForm {
|
||||
/// 0 Borders, 1 Page Border, 2 Shading.
|
||||
pub tab: u8,
|
||||
pub para: LineForm,
|
||||
/// Borders apply to the selected text (a character border) instead of the paragraphs.
|
||||
pub to_text: bool,
|
||||
pub page: LineForm,
|
||||
/// Page borders apply to the whole document instead of this section.
|
||||
pub whole_doc: bool,
|
||||
/// Fill `RRGGBB`, empty for none.
|
||||
pub fill: String,
|
||||
pub fill_to_text: bool,
|
||||
#[serde(skip)]
|
||||
basis: Option<Box<BordersForm>>,
|
||||
}
|
||||
|
||||
impl BordersForm {
|
||||
pub fn read(app: &WordApp, tab: u8) -> Option<BordersForm> {
|
||||
let s = &app.session;
|
||||
let rp = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props))?;
|
||||
let sect = wordcraft_engine::cmd::page::sect(s);
|
||||
let mut f = BordersForm {
|
||||
tab: tab.min(2),
|
||||
para: LineForm::read(rp.borders.as_ref()),
|
||||
to_text: false,
|
||||
page: LineForm::read(sect.page_borders.as_ref()),
|
||||
whole_doc: true,
|
||||
fill: rp.shading.map(Rgb::hex).unwrap_or_default(),
|
||||
fill_to_text: false,
|
||||
basis: None,
|
||||
};
|
||||
f.page.sides[BETWEEN] = false;
|
||||
f.basis = Some(Box::new(f.clone()));
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// The commands OK runs, in order: only the parts that changed.
|
||||
pub fn commands(&self) -> Vec<(&'static str, Value)> {
|
||||
let Some(b) = self.basis.as_deref() else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
if self.para != b.para || self.to_text != b.to_text {
|
||||
if self.to_text {
|
||||
let first = [TOP, LEFT, BOTTOM, RIGHT].into_iter().find(|i| self.para.sides.get(*i).copied().unwrap_or(false));
|
||||
let border = match first {
|
||||
Some(i) => self.para.border(i),
|
||||
None => Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 },
|
||||
};
|
||||
out.push(("format.set", json!({"props": {"border": border}})));
|
||||
} else {
|
||||
out.push(("para.borders", json!({"sides": self.para.sides_param()})));
|
||||
}
|
||||
}
|
||||
if self.page != b.page || self.whole_doc != b.whole_doc {
|
||||
let apply = if self.whole_doc { "document" } else { "section" };
|
||||
out.push(("design.pageBorders", json!({"sides": self.page.sides_param(), "applyTo": apply})));
|
||||
}
|
||||
if self.fill != b.fill || self.fill_to_text != b.fill_to_text {
|
||||
let color = Rgb::parse(&self.fill).map(|c| json!(c.hex())).unwrap_or(Value::Null);
|
||||
out.push((if self.fill_to_text { "format.shading" } else { "para.shading" }, json!({"color": color})));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run [`BordersForm::commands`] as one undo step.
|
||||
pub fn apply(&self, app: &mut WordApp) {
|
||||
for (i, (id, params)) in self.commands().into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
let _ = app.run(id, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Borders and Shading dialog body. Returns true to close.
|
||||
pub fn borders(app: &mut WordApp, ui: &mut Ui, f: &mut BordersForm) -> bool {
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in ["Borders", "Page Border", "Shading"].into_iter().enumerate() {
|
||||
if ui.selectable_label(f.tab == i as u8, tl!(l)).clicked() {
|
||||
f.tab = i as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
match f.tab {
|
||||
0 => {
|
||||
let to_text = f.to_text;
|
||||
lines(ui, "para", &mut f.para, &theme, Rgb::parse(&f.fill), Target::from(to_text));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Apply to:"));
|
||||
ui.radio_value(&mut f.to_text, false, tl!("Paragraph"));
|
||||
ui.radio_value(&mut f.to_text, true, tl!("Text"));
|
||||
});
|
||||
}
|
||||
1 => {
|
||||
lines(ui, "page", &mut f.page, &theme, None, Target::Page);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Apply to:"));
|
||||
ui.radio_value(&mut f.whole_doc, true, tl!("Whole document"));
|
||||
ui.radio_value(&mut f.whole_doc, false, tl!("This section"));
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
ui.label(egui::RichText::new(tl!("Fill")).font(semibold(12.5)));
|
||||
color_menu(ui, &theme, &mut f.fill);
|
||||
ui.add_space(6.0);
|
||||
let fill = Rgb::parse(&f.fill);
|
||||
preview(ui, &LineForm::read(None), fill, Target::from(f.fill_to_text));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Apply to:"));
|
||||
ui.radio_value(&mut f.fill_to_text, false, tl!("Paragraph"));
|
||||
ui.radio_value(&mut f.fill_to_text, true, tl!("Text"));
|
||||
});
|
||||
}
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, tl!("OK"));
|
||||
if ok {
|
||||
f.apply(app);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
|
||||
/// What the borders in a preview go around.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Target {
|
||||
Paragraph,
|
||||
Text,
|
||||
Page,
|
||||
}
|
||||
|
||||
impl From<bool> for Target {
|
||||
/// `true`: the selected text; `false`: the paragraphs.
|
||||
fn from(text: bool) -> Target {
|
||||
if text { Target::Text } else { Target::Paragraph }
|
||||
}
|
||||
}
|
||||
|
||||
/// Setting, style, colour and width on the left; the preview with its side toggles on the right.
|
||||
fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option<Rgb>, target: Target) {
|
||||
let between = target == Target::Paragraph;
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Setting:")).font(semibold(12.5)));
|
||||
for (s, label) in
|
||||
[(Setting::None, "None"), (Setting::Box, "Box"), (Setting::Shadow, "Shadow"), (Setting::ThreeD, "3-D"), (Setting::Custom, "Custom")]
|
||||
{
|
||||
if ui.radio(l.setting == s, tl!(label)).clicked() {
|
||||
l.apply_setting(s);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(tl!("Style:"));
|
||||
let current = STYLES.iter().find(|(s, _)| *s == l.style).map(|(_, n)| *n).unwrap_or("Single");
|
||||
egui::ComboBox::from_id_salt((id, "style")).selected_text(tl!(current)).width(120.0).show_ui(ui, |ui| {
|
||||
for (s, n) in STYLES {
|
||||
ui.selectable_value(&mut l.style, s, tl!(n));
|
||||
}
|
||||
});
|
||||
ui.label(tl!("Color:"));
|
||||
color_menu(ui, theme, &mut l.color);
|
||||
ui.label(tl!("Width:"));
|
||||
egui::ComboBox::from_id_salt((id, "width")).selected_text(format!("{} pt", l.width)).width(120.0).show_ui(ui, |ui| {
|
||||
for w in WIDTHS {
|
||||
ui.selectable_value(&mut l.width, w, format!("{w} pt"));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Preview")).font(semibold(12.5)));
|
||||
if let Some(side) = preview(ui, l, fill, target) {
|
||||
l.toggle(side);
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.set_max_width(180.0);
|
||||
let mut sides = vec![(TOP, "Top"), (BOTTOM, "Bottom"), (LEFT, "Left"), (RIGHT, "Right")];
|
||||
if between {
|
||||
sides.push((BETWEEN, "Between"));
|
||||
}
|
||||
for (i, n) in sides {
|
||||
if ui.selectable_label(l.sides.get(i).copied().unwrap_or(false), tl!(n)).clicked() {
|
||||
l.toggle(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A sample page with lines of text, the chosen borders and fill. A click near an edge returns
|
||||
/// that side.
|
||||
fn preview(ui: &mut Ui, l: &LineForm, fill: Option<Rgb>, target: Target) -> Option<usize> {
|
||||
let between = target == Target::Paragraph;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 120.0), Sense::click());
|
||||
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
let p = ui.painter_at(r);
|
||||
p.rect(r, 2.0, t.input, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
// Two paragraphs of grey lines; the borders go around the first (or its text).
|
||||
let bar = t.text_dim.gamma_multiply(0.45);
|
||||
let para = Rect::from_min_max(pos2(r.min.x + 22.0, r.min.y + 22.0), pos2(r.max.x - 22.0, r.min.y + 66.0));
|
||||
let line_rows = |y0: f32, n: usize, last: f32| {
|
||||
for i in 0..n {
|
||||
let y = y0 + i as f32 * 9.0;
|
||||
let w = if i + 1 == n { last } else { 1.0 };
|
||||
p.rect_filled(Rect::from_min_size(pos2(para.min.x + 4.0, y), vec2((para.width() - 8.0) * w, 4.0)), 1.0, bar);
|
||||
}
|
||||
};
|
||||
let area = match target {
|
||||
Target::Text => Rect::from_min_max(para.min, pos2(para.min.x + para.width() * 0.62, para.min.y + 14.0)),
|
||||
Target::Paragraph => para,
|
||||
Target::Page => r.shrink(10.0),
|
||||
};
|
||||
if let Some(c) = fill {
|
||||
p.rect_filled(area, 0.0, crate::theme::c32(c));
|
||||
}
|
||||
line_rows(para.min.y + 6.0, 4, 0.6);
|
||||
line_rows(para.max.y + 10.0, 3, 0.4);
|
||||
let color = Rgb::parse(&l.color).map(crate::theme::c32).unwrap_or(t.text);
|
||||
let edges = [
|
||||
(TOP, [area.left_top(), area.right_top()]),
|
||||
(BOTTOM, [area.left_bottom(), area.right_bottom()]),
|
||||
(LEFT, [area.left_top(), area.left_bottom()]),
|
||||
(RIGHT, [area.right_top(), area.right_bottom()]),
|
||||
(BETWEEN, [pos2(area.min.x, area.center().y), pos2(area.max.x, area.center().y)]),
|
||||
];
|
||||
for (side, seg) in edges {
|
||||
if l.sides.get(side).copied().unwrap_or(false) && (side != BETWEEN || between) {
|
||||
draw_line(&p, seg, l.style, l.side_width(side), color);
|
||||
}
|
||||
}
|
||||
if !resp.clicked() {
|
||||
return None;
|
||||
}
|
||||
let at = resp.interact_pointer_pos()?;
|
||||
let d = |side: usize| -> f32 { edges.iter().find(|(s, _)| *s == side).map(|(_, [a, b])| dist_to_segment(at, *a, *b)).unwrap_or(f32::MAX) };
|
||||
let mut sides = vec![TOP, BOTTOM, LEFT, RIGHT];
|
||||
if between {
|
||||
sides.push(BETWEEN);
|
||||
}
|
||||
sides.into_iter().map(|s| (s, d(s))).filter(|(_, dist)| *dist < 14.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(s, _)| s)
|
||||
}
|
||||
|
||||
fn dist_to_segment(p: egui::Pos2, a: egui::Pos2, b: egui::Pos2) -> f32 {
|
||||
let ab = b - a;
|
||||
let len2 = ab.length_sq();
|
||||
let k = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(p - (a + ab * k)).length()
|
||||
}
|
||||
|
||||
/// One border line in the preview, roughly as it prints.
|
||||
fn draw_line(p: &egui::Painter, [a, b]: [egui::Pos2; 2], style: BorderStyle, width: f32, color: egui::Color32) {
|
||||
let px = (width * 1.2).clamp(1.0, 6.0);
|
||||
let normal = (b - a).normalized().rot90();
|
||||
let stroke = Stroke::new(px, color);
|
||||
match style {
|
||||
BorderStyle::None => {}
|
||||
BorderStyle::Double | BorderStyle::Triple => {
|
||||
let n = if style == BorderStyle::Double { 2 } else { 3 };
|
||||
let thin = Stroke::new(1.0, color);
|
||||
for i in 0..n {
|
||||
let off = normal * ((i as f32 - (n as f32 - 1.0) / 2.0) * 3.0);
|
||||
p.line_segment([a + off, b + off], thin);
|
||||
}
|
||||
}
|
||||
BorderStyle::Dotted => {
|
||||
p.extend(egui::Shape::dashed_line(&[a, b], stroke, px, px * 1.5));
|
||||
}
|
||||
BorderStyle::Dashed => {
|
||||
p.extend(egui::Shape::dashed_line(&[a, b], stroke, px * 4.0, px * 2.0));
|
||||
}
|
||||
BorderStyle::DotDash => {
|
||||
p.extend(egui::Shape::dashed_line_with_offset(&[a, b], stroke, &[px * 4.0, px], &[px * 2.0, px * 2.0], 0.0));
|
||||
}
|
||||
BorderStyle::Wave => {
|
||||
let len = (b - a).length();
|
||||
let dir = (b - a).normalized();
|
||||
let n = (len / 4.0).ceil().max(1.0) as usize;
|
||||
let pts: Vec<egui::Pos2> =
|
||||
(0..=n.min(400)).map(|i| a + dir * (len * i as f32 / n as f32) + normal * if i % 2 == 0 { -1.5 } else { 1.5 }).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.0, color)));
|
||||
}
|
||||
BorderStyle::Single | BorderStyle::Thick => {
|
||||
p.line_segment([a, b], if style == BorderStyle::Thick { Stroke::new((px * 1.5).min(6.0), color) } else { stroke });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dialogs::Dialog;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
fn app_with(text: &str) -> WordApp {
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let _ = app.run("document.setText", json!({"text": text}));
|
||||
app
|
||||
}
|
||||
|
||||
/// #320: OK in Borders and Shading applies a box border with the chosen style, colour and
|
||||
/// width, and the page border and fill with it — one undo step for all three.
|
||||
#[test]
|
||||
fn borders_dialog_ok_applies_box_in_one_undo_step() {
|
||||
let mut app = app_with("Boxed paragraph");
|
||||
let _ = app.run("para.borders", json!({}));
|
||||
let Some(Dialog::Borders { form }) = app.dialog.take() else { panic!("Borders and Shading opens from the menu item") };
|
||||
let mut f = *form;
|
||||
assert_eq!(f.para.setting, Setting::None);
|
||||
f.para.apply_setting(Setting::Box);
|
||||
f.para.style = BorderStyle::Double;
|
||||
f.para.width = 1.5;
|
||||
f.para.color = "1F4E79".into();
|
||||
f.page.apply_setting(Setting::Shadow);
|
||||
f.fill = "DDEEFF".into();
|
||||
let undo_before = app.session.undo_labels().len();
|
||||
f.apply(&mut app);
|
||||
let props = app.session.doc.para_at(&app.session.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
|
||||
let b = props.borders.unwrap_or_default();
|
||||
for side in [b.top, b.bottom, b.left, b.right] {
|
||||
let side = side.unwrap_or_default();
|
||||
assert_eq!((side.style, side.width, side.color), (BorderStyle::Double, 1.5, Rgb::parse("1F4E79")));
|
||||
}
|
||||
assert!(b.between.is_none());
|
||||
assert_eq!(props.shading, Rgb::parse("DDEEFF"));
|
||||
let pb = app.session.doc.last_section.page_borders.unwrap_or_default();
|
||||
assert_eq!(pb.right.map(|x| x.width), Some(1.0), "Shadow doubles the right and bottom lines");
|
||||
assert_eq!(pb.top.map(|x| x.width), Some(0.5));
|
||||
assert_eq!(app.session.undo_labels().len(), undo_before + 1, "one undo step");
|
||||
let _ = app.run("edit.undo", json!({}));
|
||||
let props = app.session.doc.para_at(&app.session.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
|
||||
assert!(props.borders.is_none() && props.shading.is_none());
|
||||
assert!(app.session.doc.last_section.page_borders.is_none());
|
||||
// Reading the result back gives the same setting.
|
||||
assert_eq!(LineForm::read(Some(&pb)).setting, Setting::Shadow);
|
||||
// Unchanged, OK runs nothing.
|
||||
let f = BordersForm::read(&app, 0).unwrap_or_else(|| panic!("form"));
|
||||
assert!(f.commands().is_empty());
|
||||
}
|
||||
|
||||
/// #320: the Tabs dialog opens from the Paragraph dialog's command path, and Set / Clear /
|
||||
/// Clear All become one `para.tabs` call.
|
||||
#[test]
|
||||
fn tabs_form_sets_and_clears() {
|
||||
let mut app = app_with("a\tb");
|
||||
let _ = app.run("para.tabs", json!({}));
|
||||
let Some(Dialog::Tabs { form }) = app.dialog.take() else { panic!("Tabs opens without params") };
|
||||
let mut f = *form;
|
||||
assert!(f.stops.is_empty());
|
||||
let k = unit().pt_per_unit();
|
||||
f.pos = 72.0 / k;
|
||||
f.align = TabAlign::Right;
|
||||
f.leader = TabLeader::Dot;
|
||||
f.set();
|
||||
f.pos = 144.0 / k;
|
||||
f.set();
|
||||
f.selected = Some(1);
|
||||
f.clear();
|
||||
let params = f.params().unwrap_or_default();
|
||||
let _ = app.run("para.tabs", params);
|
||||
let props = app.session.doc.para_at(&app.session.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
|
||||
assert_eq!(props.tabs, Some(vec![TabStop { pos: 72.0, align: TabAlign::Right, leader: TabLeader::Dot }]));
|
||||
let mut f = TabsForm::read(&app).unwrap_or_else(|| panic!("form"));
|
||||
assert!(f.params().is_none());
|
||||
f.clear_all();
|
||||
assert_eq!(f.params(), Some(json!({"clearAll": true})));
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,29 @@
|
||||
Border Painter Pincel de bordes
|
||||
Border\nPainter Pincel de\nbordes
|
||||
Borders Bordes
|
||||
Tab stop position: Posición de la tabulación:
|
||||
Default tab stops: Tabulaciones predeterminadas:
|
||||
Tab stops to be cleared: Tabulaciones que se borrarán:
|
||||
All Todas
|
||||
Decimal Decimal
|
||||
Bar Barra
|
||||
Leader Relleno
|
||||
Set Fijar
|
||||
Page Border Borde de página
|
||||
Apply to: Aplicar a:
|
||||
Whole document Todo el documento
|
||||
This section Esta sección
|
||||
Fill Relleno de color
|
||||
Setting: Valor:
|
||||
3-D 3D
|
||||
Custom Personalizado
|
||||
Style: Estilo:
|
||||
Dot dash Punto y raya
|
||||
Triple Triple
|
||||
Between Entre párrafos
|
||||
Borders and Shading Bordes y sombreado
|
||||
Tabs… Tabulaciones…
|
||||
Borders and Shading… Bordes y sombreado…
|
||||
Bottom Border Borde inferior
|
||||
Bottom Margin Margen inferior
|
||||
Bottom of Page Parte inferior de la página
|
||||
@@ -1112,7 +1135,6 @@
|
||||
A mail merge field Un campo de combinación
|
||||
A numbered sequence (figures, tables…) Una secuencia numerada (figuras, tablas…)
|
||||
A table of contents Una tabla de contenido
|
||||
Apply to: Aplicar a:
|
||||
Categories: Categorías:
|
||||
Character Carácter
|
||||
Character code: Código de carácter:
|
||||
@@ -1179,11 +1201,9 @@
|
||||
This field has no options. Este campo no tiene opciones.
|
||||
This font has no characters to show. Esta fuente no tiene caracteres que mostrar.
|
||||
This point forward De aquí en adelante
|
||||
This section Esta sección
|
||||
The date it is today La fecha de hoy
|
||||
Trademark Marca comercial
|
||||
Type the field's name and its switches, for example PAGE \\* roman. Escriba el nombre del campo y sus modificadores, por ejemplo PAGE \\* roman.
|
||||
Whole document Todo el documento
|
||||
Width and spacing Ancho y espaciado
|
||||
from Unicode (hex) de Unicode (hex)
|
||||
Shape Effects Efectos de forma
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -127,6 +127,29 @@
|
||||
Border Painter 罫線の書式設定
|
||||
Border\nPainter 罫線の書式設定
|
||||
Borders 罫線
|
||||
Tab stop position: タブ位置:
|
||||
Default tab stops: 既定のタブ間隔:
|
||||
Tab stops to be cleared: 削除するタブ:
|
||||
All すべて
|
||||
Decimal 小数点揃え
|
||||
Bar 縦線
|
||||
Leader リーダー
|
||||
Set 設定
|
||||
Page Border ページ罫線の設定
|
||||
Apply to: 適用先:
|
||||
Whole document 文書全体
|
||||
This section このセクション
|
||||
Fill 塗りつぶし
|
||||
Setting: 種類:
|
||||
3-D 立体
|
||||
Custom カスタム
|
||||
Style: 線種:
|
||||
Dot dash 一点鎖線
|
||||
Triple 三重線
|
||||
Between 段落の間
|
||||
Borders and Shading 罫線と網かけ
|
||||
Tabs… タブ…
|
||||
Borders and Shading… 罫線と網かけ…
|
||||
Bottom Border 下罫線
|
||||
Bottom of Page ページの下部
|
||||
Bottom: 下:
|
||||
@@ -1096,7 +1119,6 @@
|
||||
A mail merge field 差し込み用のフィールド
|
||||
A numbered sequence (figures, tables…) 連番 (図、表…)
|
||||
A table of contents 目次
|
||||
Apply to: 適用先:
|
||||
Categories: 分類:
|
||||
Character 文字
|
||||
Character code: 文字コード:
|
||||
@@ -1163,11 +1185,9 @@
|
||||
This field has no options. このフィールドにはオプションがありません。
|
||||
This font has no characters to show. このフォントには表示できる文字がありません。
|
||||
This point forward これ以降
|
||||
This section このセクション
|
||||
The date it is today 今日の日付
|
||||
Trademark 商標
|
||||
Type the field's name and its switches, for example PAGE \\* roman. フィールド名とスイッチを入力します (例: PAGE \\* roman)。
|
||||
Whole document 文書全体
|
||||
Width and spacing 幅と間隔
|
||||
from Unicode (hex) Unicode (16 進)
|
||||
Shape Effects 図形の効果
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 493 and column 13.
|
@@ -121,6 +121,29 @@
|
||||
Border Painter Pincel de bordas
|
||||
Border\nPainter Pincel\nde bordas
|
||||
Borders Bordas
|
||||
Tab stop position: Posição da tabulação:
|
||||
Default tab stops: Tabulações padrão:
|
||||
Tab stops to be cleared: Tabulações a remover:
|
||||
All Todas
|
||||
Decimal Decimal
|
||||
Bar Barra
|
||||
Leader Preenchimento
|
||||
Set Definir
|
||||
Page Border Borda da página
|
||||
Apply to: Aplicar a:
|
||||
Whole document Documento inteiro
|
||||
This section Esta seção
|
||||
Fill Cor de fundo
|
||||
Setting: Tipo:
|
||||
3-D 3D
|
||||
Custom Personalizado
|
||||
Style: Estilo:
|
||||
Dot dash Traço e ponto
|
||||
Triple Tripla
|
||||
Between Entre parágrafos
|
||||
Borders and Shading Bordas e sombreamento
|
||||
Tabs… Tabulações…
|
||||
Borders and Shading… Bordas e sombreamento…
|
||||
Bottom Border Borda inferior
|
||||
Bottom Margin Margem inferior
|
||||
Bottom of Page Fim da página
|
||||
@@ -1108,7 +1131,6 @@
|
||||
A mail merge field Um campo de mesclagem
|
||||
A numbered sequence (figures, tables…) Uma sequência numerada (figuras, tabelas…)
|
||||
A table of contents Um sumário
|
||||
Apply to: Aplicar a:
|
||||
Categories: Categorias:
|
||||
Character Caractere
|
||||
Character code: Código do caractere:
|
||||
@@ -1175,11 +1197,9 @@
|
||||
This field has no options. Este campo não tem opções.
|
||||
This font has no characters to show. Esta fonte não tem caracteres para mostrar.
|
||||
This point forward Deste ponto em diante
|
||||
This section Esta seção
|
||||
The date it is today A data de hoje
|
||||
Trademark Marca comercial
|
||||
Type the field's name and its switches, for example PAGE \\* roman. Digite o nome do campo e suas opções, por exemplo PAGE \\* roman.
|
||||
Whole document Documento inteiro
|
||||
Width and spacing Largura e espaçamento
|
||||
from Unicode (hex) do Unicode (hex)
|
||||
Shape Effects Efeitos de forma
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -184,6 +184,29 @@
|
||||
Border Painter Kopiranje ivica
|
||||
Border\nPainter Kopiranje\nivica
|
||||
Borders Ivice
|
||||
Tab stop position: Položaj tabulatora:
|
||||
Default tab stops: Podrazumevani tabulatori:
|
||||
Tab stops to be cleared: Tabulatori za brisanje:
|
||||
All Svi
|
||||
Decimal Decimalni
|
||||
Bar Linija
|
||||
Leader Popuna
|
||||
Set Postavi
|
||||
Page Border Ivica strane
|
||||
Apply to: Primeni na:
|
||||
Whole document Ceo dokument
|
||||
This section Ovaj odeljak
|
||||
Fill Ispuna
|
||||
Setting: Vrsta:
|
||||
3-D 3D
|
||||
Custom Prilagođeno
|
||||
Style: Stil:
|
||||
Dot dash Tačka-crta
|
||||
Triple Trostruko
|
||||
Between Između pasusa
|
||||
Borders and Shading Ivice i senčenje
|
||||
Tabs… Tabulatori…
|
||||
Borders and Shading… Ivice i senčenje…
|
||||
Bottom Border Donja ivica
|
||||
Bottom of Page Dno strane
|
||||
Bottom: Dole:
|
||||
@@ -1161,7 +1184,6 @@
|
||||
A mail merge field Polje za spajanje pošte
|
||||
A numbered sequence (figures, tables…) Numerisani niz (slike, tabele…)
|
||||
A table of contents Sadržaj
|
||||
Apply to: Primeni na:
|
||||
Categories: Kategorije:
|
||||
Character Znak
|
||||
Character code: Kod znaka:
|
||||
@@ -1228,11 +1250,9 @@
|
||||
This field has no options. Ovo polje nema opcija.
|
||||
This font has no characters to show. Ovaj font nema znakove za prikaz.
|
||||
This point forward Od ovog mesta nadalje
|
||||
This section Ovaj odeljak
|
||||
The date it is today Današnji datum
|
||||
Trademark Žig
|
||||
Type the field's name and its switches, for example PAGE \\* roman. Unesite naziv polja i njegove prekidače, na primer PAGE \\* roman.
|
||||
Whole document Ceo dokument
|
||||
Width and spacing Širina i razmak
|
||||
from Unicode (hex) iz Unicode (heks.)
|
||||
Rotation Rotacija
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -188,6 +188,29 @@
|
||||
Border Painter Копирање ивица
|
||||
Border\nPainter Копирање\nивица
|
||||
Borders Ивице
|
||||
Tab stop position: Положај табулатора:
|
||||
Default tab stops: Подразумевани табулатори:
|
||||
Tab stops to be cleared: Табулатори за брисање:
|
||||
All Сви
|
||||
Decimal Децимални
|
||||
Bar Линија
|
||||
Leader Попуна
|
||||
Set Постави
|
||||
Page Border Ивица стране
|
||||
Apply to: Примени на:
|
||||
Whole document Цео документ
|
||||
This section Овај одељак
|
||||
Fill Испуна
|
||||
Setting: Врста:
|
||||
3-D 3D
|
||||
Custom Прилагођено
|
||||
Style: Стил:
|
||||
Dot dash Тачка-црта
|
||||
Triple Троструко
|
||||
Between Између пасуса
|
||||
Borders and Shading Ивице и сенчење
|
||||
Tabs… Табулатори…
|
||||
Borders and Shading… Ивице и сенчење…
|
||||
Bottom Border Доња ивица
|
||||
Bottom of Page Дно стране
|
||||
Bottom: Доле:
|
||||
@@ -1165,7 +1188,6 @@
|
||||
A mail merge field Поље за спајање поште
|
||||
A numbered sequence (figures, tables…) Нумерисани низ (слике, табеле…)
|
||||
A table of contents Садржај
|
||||
Apply to: Примени на:
|
||||
Categories: Категорије:
|
||||
Character Знак
|
||||
Character code: Код знака:
|
||||
@@ -1232,11 +1254,9 @@
|
||||
This field has no options. Ово поље нема опција.
|
||||
This font has no characters to show. Овај фонт нема знакове за приказ.
|
||||
This point forward Од овог места надаље
|
||||
This section Овај одељак
|
||||
The date it is today Данашњи датум
|
||||
Trademark Жиг
|
||||
Type the field's name and its switches, for example PAGE \\* roman. Унесите назив поља и његове прекидаче, на пример PAGE \\* roman.
|
||||
Whole document Цео документ
|
||||
Width and spacing Ширина и размак
|
||||
from Unicode (hex) из Unicode (хекс.)
|
||||
Rotation Ротација
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -123,6 +123,29 @@
|
||||
Border Painter Пензель меж
|
||||
Border\nPainter Пензель\nмеж
|
||||
Borders Межі
|
||||
Tab stop position: Позиція табуляції:
|
||||
Default tab stops: Типові позиції табуляції:
|
||||
Tab stops to be cleared: Позиції, які буде видалено:
|
||||
All Усі
|
||||
Decimal Десяткова
|
||||
Bar Риска
|
||||
Leader Заповнювач
|
||||
Set Встановити
|
||||
Page Border Межа сторінки
|
||||
Apply to: Застосувати до:
|
||||
Whole document Увесь документ
|
||||
This section Цей розділ
|
||||
Fill Колір заливки
|
||||
Setting: Тип:
|
||||
3-D Об'ємна
|
||||
Custom Власна
|
||||
Style: Стиль:
|
||||
Dot dash Штрихпунктирна
|
||||
Triple Потрійна
|
||||
Between Між абзацами
|
||||
Borders and Shading Межі й заливка
|
||||
Tabs… Табуляція…
|
||||
Borders and Shading… Межі й заливка…
|
||||
Bottom Border Нижня межа
|
||||
Bottom Margin Нижнє поле
|
||||
Bottom of Page Унизу сторінки
|
||||
@@ -1108,7 +1131,6 @@
|
||||
A mail merge field Поле злиття
|
||||
A numbered sequence (figures, tables…) Нумерована послідовність (рисунки, таблиці…)
|
||||
A table of contents Зміст
|
||||
Apply to: Застосувати до:
|
||||
Categories: Категорії:
|
||||
Character Символ
|
||||
Character code: Код символу:
|
||||
@@ -1175,11 +1197,9 @@
|
||||
This field has no options. Це поле не має параметрів.
|
||||
This font has no characters to show. Цей шрифт не має символів для показу.
|
||||
This point forward Від цього місця
|
||||
This section Цей розділ
|
||||
The date it is today Сьогоднішня дата
|
||||
Trademark Торговельна марка
|
||||
Type the field's name and its switches, for example PAGE \\* roman. Введіть назву поля та його ключі, наприклад PAGE \\* roman.
|
||||
Whole document Увесь документ
|
||||
Width and spacing Ширина й інтервал
|
||||
from Unicode (hex) з Unicode (шістн.)
|
||||
Shape Effects Ефекти фігури
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -138,6 +138,29 @@
|
||||
Border Painter 边框刷
|
||||
Border\nPainter 边框刷
|
||||
Borders 边框
|
||||
Tab stop position: 制表位位置:
|
||||
Default tab stops: 默认制表位:
|
||||
Tab stops to be cleared: 要清除的制表位:
|
||||
All 全部
|
||||
Decimal 小数点对齐
|
||||
Bar 竖线对齐
|
||||
Leader 前导符
|
||||
Set 设置
|
||||
Page Border 页面边框线
|
||||
Apply to: 应用于:
|
||||
Whole document 整篇文档
|
||||
This section 本节
|
||||
Fill 填充
|
||||
Setting: 设置:
|
||||
3-D 三维
|
||||
Custom 自定义
|
||||
Style: 样式:
|
||||
Dot dash 点划线
|
||||
Triple 三线
|
||||
Between 段落之间
|
||||
Borders and Shading 边框和底纹
|
||||
Tabs… 制表位…
|
||||
Borders and Shading… 边框和底纹…
|
||||
Bottom Border 下框线
|
||||
Bottom of Page 页面底端
|
||||
Bottom: 下:
|
||||
@@ -1107,7 +1130,6 @@
|
||||
A mail merge field 邮件合并域
|
||||
A numbered sequence (figures, tables…) 编号序列(图、表…)
|
||||
A table of contents 目录
|
||||
Apply to: 应用于:
|
||||
Categories: 类别:
|
||||
Character 字符
|
||||
Character code: 字符代码:
|
||||
@@ -1174,11 +1196,9 @@
|
||||
This field has no options. 此域没有选项。
|
||||
This font has no characters to show. 此字体没有可显示的字符。
|
||||
This point forward 插入点之后
|
||||
This section 本节
|
||||
The date it is today 今天的日期
|
||||
Trademark 商标
|
||||
Type the field's name and its switches, for example PAGE \\* roman. 键入域名及其开关,例如 PAGE \\* roman。
|
||||
Whole document 整篇文档
|
||||
Width and spacing 宽度和间距
|
||||
from Unicode (hex) 来自 Unicode(十六进制)
|
||||
Shape Effects 形状效果
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -128,6 +128,29 @@
|
||||
Border Painter 框線複製
|
||||
Border\nPainter 框線複製
|
||||
Borders 框線
|
||||
Tab stop position: 定位點位置:
|
||||
Default tab stops: 預設定位點間距:
|
||||
Tab stops to be cleared: 要清除的定位點:
|
||||
All 全部
|
||||
Decimal 小數點對齊
|
||||
Bar 豎線
|
||||
Leader 前置字元
|
||||
Set 設定
|
||||
Page Border 頁面框線設定
|
||||
Apply to: 套用至:
|
||||
Whole document 整份文件
|
||||
This section 本節
|
||||
Fill 填滿
|
||||
Setting: 設定:
|
||||
3-D 立體
|
||||
Custom 自訂
|
||||
Style: 樣式:
|
||||
Dot dash 點虛線
|
||||
Triple 三線
|
||||
Between 段落之間
|
||||
Borders and Shading 框線及網底
|
||||
Tabs… 定位點…
|
||||
Borders and Shading… 框線及網底…
|
||||
Bottom Border 下框線
|
||||
Bottom of Page 頁面底端
|
||||
Bottom: 下:
|
||||
@@ -1097,7 +1120,6 @@
|
||||
A mail merge field 合併列印欄位
|
||||
A numbered sequence (figures, tables…) 編號序列(圖、表…)
|
||||
A table of contents 目錄
|
||||
Apply to: 套用至:
|
||||
Categories: 類別:
|
||||
Character 字元
|
||||
Character code: 字元代碼:
|
||||
@@ -1164,11 +1186,9 @@
|
||||
This field has no options. 此功能變數沒有選項。
|
||||
This font has no characters to show. 此字型沒有可顯示的字元。
|
||||
This point forward 從這裡開始
|
||||
This section 本節
|
||||
The date it is today 今天的日期
|
||||
Trademark 商標
|
||||
Type the field's name and its switches, for example PAGE \\* roman. 輸入功能變數名稱及其參數,例如 PAGE \\* roman。
|
||||
Whole document 整份文件
|
||||
Width and spacing 寬度和間距
|
||||
from Unicode (hex) 來自 Unicode(十六進位)
|
||||
Shape Effects 圖案效果
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 494 and column 13.
|
@@ -21,6 +21,7 @@ pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod dialogs_insert;
|
||||
pub mod dialogs_lists;
|
||||
pub mod dialogs_para;
|
||||
pub mod equation_tab;
|
||||
pub mod file_dialogs;
|
||||
pub mod frame;
|
||||
@@ -1090,6 +1091,10 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
|
||||
"insert.field" if !has("instr") => Some("field"),
|
||||
// Table Properties without settings shows the dialog (with settings it applies them).
|
||||
"table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"),
|
||||
// Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320).
|
||||
"para.tabs" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tabs"),
|
||||
"para.borders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("borders"),
|
||||
"design.pageBorders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("pageBorders"),
|
||||
// `null` is an answer here: it removes the password.
|
||||
"file.encrypt" if params.get("password").is_none() => Some("encryptPassword"),
|
||||
// Define New Multilevel List and Track Changes Options without settings show their dialogs.
|
||||
|
||||
@@ -472,6 +472,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.separator();
|
||||
mi(ui, app, "Horizontal Line", "insert.horizontalLine", json!({}));
|
||||
mi(ui, app, "Borders and Shading…", "para.borders", json!({}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -719,10 +720,7 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
menu_button(ui, app, "pageBorders", Some("Page\nBorders"), "Page Borders", true, |ui, app| {
|
||||
mi(ui, app, "Box", "design.pageBorders", json!({"kind": "box"}));
|
||||
mi(ui, app, "None", "design.pageBorders", json!({"kind": "none"}));
|
||||
});
|
||||
big(ui, app, "pageBorders", "Page\nBorders", "design.pageBorders", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; columns don't balance; custom footnote continuation separators and notices aren't read; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Formatting revisions, move tracking and content controls** are lost (`w:rPrChange`/`w:pPrChange` absent; `w:sdt` unwrapped) | B | Issue #41; source grep | Legal and editorial review workflows; templates with form controls | 12–18 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 7 | **Dialog depth**: 22 modal dialogs vs Word's ~100 (Define Multilevel List, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Dialog depth**: 19 modal dialogs vs Word's ~100 (Tabs, Borders and Shading, Define Multilevel List, Track Changes Options, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
|
||||
| 7 | **Dialog depth**: 18 modal dialogs vs Word's ~100 (Tabs, Borders and Shading, Columns, Symbol, Field, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) |
|
||||
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
|
||||
@@ -65,6 +66,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list |
|
||||
| 2026-10-10 | trivial | Columns, Symbol and Field dialogs landed (#321); removed from the dialog-depth list |
|
||||
| 2026-10-10 | trivial | #2 narrowed: charts and SmartArt drawn (#292), and with OLE objects kept on save (#319); group shapes landed (#267); ink and drawing canvases remain |
|
||||
| 2026-10-10 | trivial | #4: long footnotes continue onto the next page (#352) |
|
||||
|
||||
@@ -16,6 +16,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| Quick Access Toolbar | Undo, Redo, Repeat, AutoSave | customizable | 50% | 2–3 |
|
||||
| Customize Ribbon / Customize Keyboard | ❌ | ✅ | 0% | 6–10 |
|
||||
| Keyboard shortcuts | **101** bound in `CommandSpec::key` (measured) | ~250 default shortcuts | ~60% | 4–6 |
|
||||
| Modal dialogs | **22** (`Dialog` enum: Font, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New/Modify Style, Commands, About, Save Changes; Columns, Symbol with Special Characters, Field (#321)) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Define Multilevel List, Track Changes Options, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, Mail Merge, AutoCorrect, Options panes…) | ~40% | 20–30 |
|
||||
| Modal dialogs | **20** (`Dialog` enum: Font, Paragraph, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New/Modify Style, Commands, About, Save Changes; Columns, Symbol with Special Characters, Field (#321)) plus panes and menus for the rest | ~100 (Tabs, Borders and Shading, Caption, Index, TOC options, Citation, Define Multilevel List, Track Changes Options, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, Mail Merge, AutoCorrect, Options panes…) | ~40% | 20–30 |
|
||||
| Modal dialogs | **19** (`Dialog` enum: Font, Paragraph, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New/Modify Style, Commands, About, Save Changes, Define New Multilevel List, Track Changes Options) plus panes and menus for the rest | ~100 (Tabs, Borders and Shading, Columns, Symbol, Field, Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, Mail Merge, AutoCorrect, Options panes…) | ~40% | 20–30 |
|
||||
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
|
||||
@@ -37,6 +38,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | trivial | Tabs dialog (Paragraph › Tabs…, double-click a ruler tab) and Borders and Shading with Borders, Page Border and Shading tabs (#320) |
|
||||
| 2026-10-10 | trivial | Columns (presets, unequal widths, line between, apply to), Symbol (font coverage grid by Unicode subset, recently used, character code, Special Characters) and Field (categories, options, field codes) dialogs (#321) |
|
||||
| 2026-10-10 | trivial | Define New Multilevel List (all nine levels, live preview, `list.define`) and Track Changes Options (show, balloons, insertion/deletion marks and colours, changed-line bars; `review.trackingOptions`, saved per user) (#328) |
|
||||
| 2026-10-10 | trivial | Rotation handle on pictures, shapes and text boxes; Rotate menu (90° and flips) and a Rotation box in Picture/Shape Format › Size (#332) |
|
||||
|
||||
Reference in new issue
Block a user