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:
authored and GitHub committed 2026-10-10 17:56:54 -04:00
1 parent 8204c5cb49
commit 15f2f01475
19 files changed
+1148 -53

No files matched your search

+1
View File
@@ -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 |
+26 -10
View File
@@ -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)
}
+98 -11
View File
@@ -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);
+79
View File
@@ -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"))]
+8 -1
View File
@@ -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");
+32 -3
View File
@@ -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() {
+709
View File
@@ -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})));
}
}
+23 -3
View File
@@ -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.
+23 -3
View File
@@ -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.
+23 -3
View File
@@ -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.
+23 -3
View File
@@ -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.
+23 -3
View File
@@ -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.
+23 -3
View File
@@ -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.
+23 -3
View File
@@ -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.
+23 -3
View File
@@ -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.
+5
View File
@@ -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.
+2 -4
View File
@@ -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);
});
}
+2
View File
@@ -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) |
+2
View File
@@ -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) |