M0.7: engine — session, undo, 200+ commands, Word feature catalog, samples

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-05 22:47:26 -04:00
co-authored by Claude Opus 5.5
parent d1e80df4da
commit b8dd46ccb5
23 changed files with 6112 additions and 0 deletions
Generated
+48
View File
@@ -8,6 +8,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -623,6 +632,29 @@ dependencies = [
"once_cell",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -916,6 +948,22 @@ dependencies = [
"wordcraft-geom",
]
[[package]]
name = "wordcraft-engine"
version = "0.1.0"
dependencies = [
"log",
"regex",
"serde",
"serde_json",
"thiserror",
"wordcraft-doc",
"wordcraft-fonts",
"wordcraft-geom",
"wordcraft-layout",
"wordcraft-render",
]
[[package]]
name = "wordcraft-fonts"
version = "0.1.0"
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "wordcraft-engine"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "WordCraft engine: session, selection, undo, the command registry (every action is a command), Word's command catalog, file I/O dispatch"
[lints]
workspace = true
[dependencies]
wordcraft-geom = { workspace = true }
wordcraft-doc = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
wordcraft-render = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
regex = { workspace = true }
+482
View File
@@ -0,0 +1,482 @@
//! The word-processor feature catalog: the incumbent's ribbon and menu commands (feature names
//! only) mapped to our command ids. `parity()` compares it with the registry; the gap is the
//! work list (`cargo xtask parity` → docs/parity.md).
use serde_json::{Value, json};
use crate::Registry;
/// `Tab|Group|Feature|command id`
pub const CATALOG: &str = "\
File|Backstage|New|file.new
File|Backstage|Open|file.open
File|Backstage|Save|file.save
File|Backstage|Save As|file.saveAs
File|Backstage|Print|file.print
File|Backstage|Close|file.close
File|Backstage|Info|file.info
File|Backstage|Properties|file.properties
File|Backstage|Export PDF|file.exportPdf
File|Backstage|Export Image|file.exportPng
File|Backstage|Options|file.options
File|Backstage|Share|file.share
File|Backstage|Protect Document|file.protect
File|Backstage|Inspect Document|file.inspect
File|Backstage|Check Accessibility|file.accessibility
File|Backstage|Check Compatibility|file.compatibility
File|Backstage|Version History|file.versions
File|Backstage|Recover Unsaved|file.recover
File|Backstage|New from Template|file.newFromTemplate
File|Backstage|Save as Template|file.saveTemplate
Quick Access|Toolbar|Undo|edit.undo
Quick Access|Toolbar|Redo|edit.redo
Quick Access|Toolbar|Repeat|edit.repeat
Quick Access|Toolbar|AutoSave|file.autosave
Home|Clipboard|Paste|edit.paste
Home|Clipboard|Paste Keep Text Only|edit.pasteText
Home|Clipboard|Paste Merge Formatting|edit.pasteMerge
Home|Clipboard|Paste Special|edit.pasteSpecial
Home|Clipboard|Cut|edit.cut
Home|Clipboard|Copy|edit.copy
Home|Clipboard|Format Painter|edit.formatPainter
Home|Clipboard|Copy Formatting|edit.copyFormat
Home|Clipboard|Paste Formatting|edit.pasteFormat
Home|Clipboard|Clipboard Pane|edit.clipboardPane
Home|Font|Font|format.font
Home|Font|Font Size|format.size
Home|Font|Increase Font Size|format.growFont
Home|Font|Decrease Font Size|format.shrinkFont
Home|Font|Change Case|format.changeCase
Home|Font|Clear All Formatting|format.clear
Home|Font|Bold|format.bold
Home|Font|Italic|format.italic
Home|Font|Underline|format.underline
Home|Font|Double Underline|format.doubleUnderline
Home|Font|Strikethrough|format.strikethrough
Home|Font|Subscript|format.subscript
Home|Font|Superscript|format.superscript
Home|Font|Text Effects Outline|format.outline
Home|Font|Text Effects Shadow|format.shadow
Home|Font|Text Highlight Color|format.highlight
Home|Font|Font Color|format.color
Home|Font|Character Shading|format.shading
Home|Font|Font Dialog|format.fontDialog
Home|Font|Double Strikethrough|format.doubleStrikethrough
Home|Font|Small Caps|format.smallCaps
Home|Font|All Caps|format.allCaps
Home|Font|Hidden|format.hidden
Home|Font|Emboss|format.emboss
Home|Font|Engrave|format.engrave
Home|Font|Character Spacing|format.spacing
Home|Font|Character Scale|format.scale
Home|Font|Character Position|format.position
Home|Font|Character Border|format.border
Home|Font|Phonetic Guide|format.phonetic
Home|Font|Enclose Characters|format.enclose
Home|Paragraph|Bullets|para.bullets
Home|Paragraph|Numbering|para.numbering
Home|Paragraph|Multilevel List|para.multilevel
Home|Paragraph|Change List Level|para.listLevel
Home|Paragraph|Restart Numbering|para.restartNumbering
Home|Paragraph|Define New Bullet|para.defineBullet
Home|Paragraph|Define New Number Format|para.defineNumber
Home|Paragraph|Set Numbering Value|para.setNumberingValue
Home|Paragraph|Decrease Indent|para.outdent
Home|Paragraph|Increase Indent|para.indent
Home|Paragraph|Sort|para.sort
Home|Paragraph|Show/Hide Marks|view.marks
Home|Paragraph|Align Left|para.alignLeft
Home|Paragraph|Center|para.alignCenter
Home|Paragraph|Align Right|para.alignRight
Home|Paragraph|Justify|para.justify
Home|Paragraph|Distributed|para.distribute
Home|Paragraph|Line Spacing|para.lineSpacing
Home|Paragraph|Add Space Before|para.addSpaceBefore
Home|Paragraph|Remove Space After|para.removeSpaceAfter
Home|Paragraph|Shading|para.shading
Home|Paragraph|Borders|para.borders
Home|Paragraph|Horizontal Line|insert.horizontalLine
Home|Paragraph|Paragraph Dialog|para.dialog
Home|Paragraph|Tabs|para.tabs
Home|Paragraph|Keep with Next|para.keepNext
Home|Paragraph|Keep Lines Together|para.keepLines
Home|Paragraph|Page Break Before|para.pageBreakBefore
Home|Paragraph|Widow/Orphan Control|para.widowControl
Home|Paragraph|Outline Level|para.outlineLevel
Home|Paragraph|Hanging Indent|para.hangingIndent
Home|Paragraph|Text Direction RTL|para.rtl
Home|Paragraph|Asian Typography|para.asianTypography
Home|Styles|Styles Gallery|para.style
Home|Styles|Normal|para.normal
Home|Styles|Heading 1|para.heading1
Home|Styles|Heading 2|para.heading2
Home|Styles|Heading 3|para.heading3
Home|Styles|Create a Style|styles.create
Home|Styles|Modify Style|styles.modify
Home|Styles|Update Style to Match|styles.updateToMatch
Home|Styles|Delete Style|styles.delete
Home|Styles|Styles Pane|styles.pane
Home|Styles|Style Inspector|styles.inspector
Home|Styles|Manage Styles|styles.manage
Home|Styles|Apply Styles|styles.apply
Home|Styles|Character Style|format.charStyle
Home|Editing|Find|edit.find
Home|Editing|Find Next|edit.findNext
Home|Editing|Find Previous|edit.findPrevious
Home|Editing|Advanced Find|edit.advancedFind
Home|Editing|Replace|edit.replace
Home|Editing|Replace All|edit.replaceAll
Home|Editing|Go To|edit.goto
Home|Editing|Select All|select.all
Home|Editing|Select Objects|select.objects
Home|Editing|Select Similar Formatting|select.similar
Home|Voice|Dictate|tools.dictate
Home|Editor|Editor|review.editor
Insert|Pages|Cover Page|insert.coverPage
Insert|Pages|Blank Page|insert.blankPage
Insert|Pages|Page Break|insert.pageBreak
Insert|Tables|Insert Table|insert.table
Insert|Tables|Draw Table|table.draw
Insert|Tables|Convert Text to Table|table.fromText
Insert|Tables|Excel Spreadsheet|insert.spreadsheet
Insert|Tables|Quick Tables|table.quick
Insert|Illustrations|Pictures|insert.picture
Insert|Illustrations|Online Pictures|insert.onlinePicture
Insert|Illustrations|Shapes|insert.shape
Insert|Illustrations|Icons|insert.icon
Insert|Illustrations|3D Models|insert.model3d
Insert|Illustrations|SmartArt|insert.smartArt
Insert|Illustrations|Chart|insert.chart
Insert|Illustrations|Screenshot|insert.screenshot
Insert|Illustrations|Drawing Canvas|insert.canvas
Insert|Add-ins|Get Add-ins|addins.get
Insert|Media|Online Video|insert.video
Insert|Links|Link|insert.link
Insert|Links|Remove Link|insert.removeLink
Insert|Links|Bookmark|insert.bookmark
Insert|Links|Cross-reference|insert.crossReference
Insert|Comments|Comment|review.newComment
Insert|Header & Footer|Header|insert.header
Insert|Header & Footer|Footer|insert.footer
Insert|Header & Footer|Page Number|insert.pageNumber
Insert|Header & Footer|Edit Header|insert.editHeader
Insert|Header & Footer|Edit Footer|insert.editFooter
Insert|Header & Footer|Remove Header|insert.removeHeader
Insert|Header & Footer|Remove Footer|insert.removeFooter
Insert|Header & Footer|Format Page Numbers|layout.pageNumberFormat
Insert|Header & Footer|Close Header and Footer|insert.closeHeader
Insert|Header & Footer|Different First Page|layout.differentFirstPage
Insert|Header & Footer|Different Odd & Even|layout.differentOddEven
Insert|Header & Footer|Link to Previous|layout.linkToPrevious
Insert|Text|Text Box|insert.textBox
Insert|Text|Quick Parts|insert.quickParts
Insert|Text|AutoText|insert.autoText
Insert|Text|Document Property|insert.docProperty
Insert|Text|Field|insert.field
Insert|Text|WordArt|insert.wordArt
Insert|Text|Drop Cap|insert.dropCap
Insert|Text|Signature Line|insert.signatureLine
Insert|Text|Date & Time|insert.dateTime
Insert|Text|Object|insert.object
Insert|Text|Text from File|insert.textFromFile
Insert|Symbols|Equation|insert.equation
Insert|Symbols|Ink Equation|insert.inkEquation
Insert|Symbols|Symbol|insert.symbol
Insert|Symbols|Nonbreaking Space|text.nbsp
Insert|Symbols|Nonbreaking Hyphen|text.nbHyphen
Insert|Symbols|Optional Hyphen|text.optionalHyphen
Draw|Tools|Select|draw.select
Draw|Tools|Lasso Select|draw.lasso
Draw|Tools|Eraser|draw.eraser
Draw|Pens|Pen|draw.pen
Draw|Pens|Pencil|draw.pencil
Draw|Pens|Highlighter|draw.highlighter
Draw|Pens|Add Pen|draw.addPen
Draw|Convert|Ink to Shape|draw.inkToShape
Draw|Convert|Ink to Math|draw.inkToMath
Draw|Insert|Drawing Canvas|insert.canvas
Draw|Replay|Ink Replay|draw.replay
Design|Document Formatting|Themes|design.theme
Design|Document Formatting|Style Set|design.styleSet
Design|Document Formatting|Colors|design.themeColors
Design|Document Formatting|Fonts|design.themeFonts
Design|Document Formatting|Paragraph Spacing|design.paragraphSpacing
Design|Document Formatting|Effects|design.effects
Design|Document Formatting|Set as Default|design.setDefault
Design|Page Background|Watermark|design.watermark
Design|Page Background|Page Color|design.pageColor
Design|Page Background|Page Borders|design.pageBorders
Layout|Page Setup|Margins|layout.margins
Layout|Page Setup|Orientation|layout.orientation
Layout|Page Setup|Size|layout.size
Layout|Page Setup|Columns|layout.columns
Layout|Page Setup|Breaks|layout.break
Layout|Page Setup|Line Numbers|layout.lineNumbers
Layout|Page Setup|Hyphenation|layout.hyphenation
Layout|Page Setup|Page Setup Dialog|layout.pageSetup
Layout|Page Setup|Vertical Alignment|layout.verticalAlign
Layout|Paragraph|Indent|para.indents
Layout|Paragraph|Spacing|para.spacing
Layout|Arrange|Position|arrange.position
Layout|Arrange|Wrap Text|arrange.wrap
Layout|Arrange|Bring Forward|arrange.bringForward
Layout|Arrange|Send Backward|arrange.sendBackward
Layout|Arrange|Selection Pane|arrange.selectionPane
Layout|Arrange|Align|arrange.align
Layout|Arrange|Group|arrange.group
Layout|Arrange|Rotate|arrange.rotate
References|Table of Contents|Table of Contents|references.toc
References|Table of Contents|Add Text|references.addText
References|Table of Contents|Update Table|references.updateToc
References|Table of Contents|Remove Table of Contents|references.removeToc
References|Footnotes|Insert Footnote|references.footnote
References|Footnotes|Insert Endnote|references.endnote
References|Footnotes|Next Footnote|references.nextFootnote
References|Footnotes|Show Notes|references.notes
References|Footnotes|Footnote Options|references.noteOptions
References|Research|Researcher|references.researcher
References|Citations|Insert Citation|references.citation
References|Citations|Manage Sources|references.sources
References|Citations|Style|references.citationStyle
References|Citations|Bibliography|references.bibliography
References|Captions|Insert Caption|references.caption
References|Captions|Insert Table of Figures|references.tableOfFigures
References|Captions|Update Table of Figures|references.updateFigures
References|Captions|Cross-reference|insert.crossReference
References|Index|Mark Entry|references.markEntry
References|Index|Insert Index|references.index
References|Index|Update Index|references.updateIndex
References|Table of Authorities|Mark Citation|references.markCitation
References|Table of Authorities|Insert Table of Authorities|references.tableOfAuthorities
References|Fields|Update Fields|references.updateFields
Mailings|Create|Envelopes|mailings.envelopes
Mailings|Create|Labels|mailings.labels
Mailings|Start Mail Merge|Start Mail Merge|mailings.start
Mailings|Start Mail Merge|Select Recipients|mailings.recipients
Mailings|Start Mail Merge|Edit Recipient List|mailings.editRecipients
Mailings|Write & Insert Fields|Highlight Merge Fields|mailings.highlightFields
Mailings|Write & Insert Fields|Address Block|mailings.addressBlock
Mailings|Write & Insert Fields|Greeting Line|mailings.greetingLine
Mailings|Write & Insert Fields|Insert Merge Field|mailings.insertField
Mailings|Write & Insert Fields|Rules|mailings.rules
Mailings|Write & Insert Fields|Match Fields|mailings.matchFields
Mailings|Preview Results|Preview Results|mailings.preview
Mailings|Preview Results|Next Record|mailings.next
Mailings|Preview Results|Previous Record|mailings.previous
Mailings|Preview Results|Find Recipient|mailings.findRecipient
Mailings|Preview Results|Check for Errors|mailings.checkErrors
Mailings|Finish|Finish & Merge|mailings.finish
Review|Proofing|Spelling & Grammar|review.spelling
Review|Proofing|Thesaurus|review.thesaurus
Review|Proofing|Word Count|review.wordCount
Review|Speech|Read Aloud|review.readAloud
Review|Accessibility|Check Accessibility|file.accessibility
Review|Language|Translate|review.translate
Review|Language|Language|review.language
Review|Comments|New Comment|review.newComment
Review|Comments|Delete|review.deleteComment
Review|Comments|Previous|review.previousComment
Review|Comments|Next|review.nextComment
Review|Comments|Show Comments|review.comments
Review|Comments|Resolve|review.resolveComment
Review|Comments|Reply|review.reply
Review|Tracking|Track Changes|review.trackChanges
Review|Tracking|Display for Review|review.markup
Review|Tracking|Show Markup|review.showMarkup
Review|Tracking|Reviewing Pane|review.changes
Review|Changes|Accept|review.accept
Review|Changes|Reject|review.reject
Review|Changes|Accept All|review.acceptAll
Review|Changes|Reject All|review.rejectAll
Review|Changes|Previous Change|review.previousChange
Review|Changes|Next Change|review.nextChange
Review|Compare|Compare|review.compare
Review|Compare|Combine|review.combine
Review|Protect|Block Authors|review.blockAuthors
Review|Protect|Restrict Editing|review.restrict
Review|Ink|Hide Ink|review.hideInk
View|Views|Read Mode|view.readMode
View|Views|Print Layout|view.printLayout
View|Views|Web Layout|view.webLayout
View|Views|Outline|view.outline
View|Views|Draft|view.draft
View|Immersive|Focus|view.focus
View|Immersive|Immersive Reader|view.immersive
View|Page Movement|Vertical|view.vertical
View|Page Movement|Side to Side|view.sideToSide
View|Show|Ruler|view.ruler
View|Show|Gridlines|view.gridlines
View|Show|Navigation Pane|view.navigationPane
View|Zoom|Zoom|view.zoom
View|Zoom|100%|view.zoom100
View|Zoom|One Page|view.onePage
View|Zoom|Multiple Pages|view.multiplePages
View|Zoom|Page Width|view.pageWidth
View|Zoom|Zoom In|view.zoomIn
View|Zoom|Zoom Out|view.zoomOut
View|Dark Mode|Switch Modes|view.darkMode
View|Window|New Window|view.newWindow
View|Window|Arrange All|view.arrangeAll
View|Window|Split|view.split
View|Window|View Side by Side|view.sideBySide
View|Window|Synchronous Scrolling|view.syncScroll
View|Window|Switch Windows|view.switchWindows
View|Macros|Macros|tools.macros
View|Macros|Record Macro|tools.recordMacro
View|SharePoint|Properties|file.properties
Table Design|Table Style Options|Table Style Options|table.look
Table Design|Table Styles|Table Styles|table.style
Table Design|Table Styles|Shading|table.shading
Table Design|Borders|Borders|table.borders
Table Design|Borders|Border Painter|table.borderPainter
Table Layout|Table|Select Table|table.selectTable
Table Layout|Table|Select Row|table.selectRow
Table Layout|Table|Select Cell|table.selectCell
Table Layout|Table|View Gridlines|view.gridlines
Table Layout|Table|Properties|table.properties
Table Layout|Draw|Draw Table|table.draw
Table Layout|Draw|Eraser|table.eraser
Table Layout|Rows & Columns|Delete Cells|table.deleteCells
Table Layout|Rows & Columns|Delete Rows|table.deleteRow
Table Layout|Rows & Columns|Delete Columns|table.deleteColumn
Table Layout|Rows & Columns|Delete Table|table.deleteTable
Table Layout|Rows & Columns|Insert Above|table.insertRowAbove
Table Layout|Rows & Columns|Insert Below|table.insertRowBelow
Table Layout|Rows & Columns|Insert Left|table.insertColumnLeft
Table Layout|Rows & Columns|Insert Right|table.insertColumnRight
Table Layout|Merge|Merge Cells|table.merge
Table Layout|Merge|Split Cells|table.split
Table Layout|Merge|Split Table|table.splitTable
Table Layout|Cell Size|AutoFit|table.autofit
Table Layout|Cell Size|Row Height|table.rowHeight
Table Layout|Cell Size|Column Width|table.columnWidth
Table Layout|Cell Size|Distribute Rows|table.distributeRows
Table Layout|Cell Size|Distribute Columns|table.distributeColumns
Table Layout|Alignment|Alignment|table.cellAlign
Table Layout|Alignment|Text Direction|table.textDirection
Table Layout|Alignment|Cell Margins|table.cellMargins
Table Layout|Data|Sort|table.sort
Table Layout|Data|Repeat Header Rows|table.repeatHeader
Table Layout|Data|Convert to Text|table.toText
Table Layout|Data|Formula|table.formula
Picture Format|Adjust|Remove Background|picture.removeBackground
Picture Format|Adjust|Corrections|picture.corrections
Picture Format|Adjust|Color|picture.color
Picture Format|Adjust|Artistic Effects|picture.effects
Picture Format|Adjust|Transparency|picture.transparency
Picture Format|Adjust|Compress Pictures|picture.compress
Picture Format|Adjust|Change Picture|picture.change
Picture Format|Adjust|Reset Picture|picture.reset
Picture Format|Picture Styles|Picture Styles|picture.style
Picture Format|Picture Styles|Picture Border|picture.border
Picture Format|Accessibility|Alt Text|picture.altText
Picture Format|Size|Crop|picture.crop
Picture Format|Size|Size|picture.size
Shape Format|Shape Styles|Shape Fill|shape.fill
Shape Format|Shape Styles|Shape Outline|shape.outline
Shape Format|Shape Styles|Shape Effects|shape.effects
Shape Format|Text|Text Direction|shape.textDirection
Shape Format|Text|Align Text|shape.alignText
Shape Format|Text|Create Link|shape.link
Header & Footer|Navigation|Go to Header|insert.editHeader
Header & Footer|Navigation|Go to Footer|insert.editFooter
Header & Footer|Navigation|Previous Section|hf.previous
Header & Footer|Navigation|Next Section|hf.next
Header & Footer|Position|Header from Top|hf.position
Editing|Keyboard|Type Text|text.insert
Editing|Keyboard|New Paragraph|text.newParagraph
Editing|Keyboard|Line Break|text.lineBreak
Editing|Keyboard|Column Break|text.columnBreak
Editing|Keyboard|Tab|text.tab
Editing|Keyboard|Backspace|text.backspace
Editing|Keyboard|Delete|text.delete
Editing|Keyboard|Delete Previous Word|text.deleteWordBack
Editing|Keyboard|Delete Next Word|text.deleteWordForward
Editing|Navigation|Word Left|caret.wordLeft
Editing|Navigation|Word Right|caret.wordRight
Editing|Navigation|Line Start|caret.home
Editing|Navigation|Line End|caret.end
Editing|Navigation|Paragraph Up|caret.paraUp
Editing|Navigation|Paragraph Down|caret.paraDown
Editing|Navigation|Document Start|caret.docStart
Editing|Navigation|Document End|caret.docEnd
Editing|Navigation|Page Up|caret.pageUp
Editing|Navigation|Page Down|caret.pageDown
Editing|Selection|Select Word|select.word
Editing|Selection|Select Sentence|select.sentence
Editing|Selection|Select Paragraph|select.paragraph
Editing|Selection|Extend Selection (F8)|select.extend
Editing|Selection|Column Selection|select.column
Tools|Proofing|AutoCorrect Options|tools.autocorrect
Tools|Proofing|Set Proofing Language|review.language
Tools|Customize|Customize Ribbon|tools.customizeRibbon
Tools|Customize|Customize Keyboard|tools.customizeKeyboard
Tools|Protection|Encrypt with Password|file.encrypt
Tools|Templates|Templates and Add-ins|tools.templates
";
/// Catalog entries: (tab, group, feature, id).
pub fn entries() -> Vec<(&'static str, &'static str, &'static str, &'static str)> {
CATALOG
.lines()
.filter_map(|l| {
let mut it = l.split('|');
Some((it.next()?, it.next()?, it.next()?, it.next()?))
})
.collect()
}
/// Parity summary: per tab and overall, with the missing features.
pub fn parity(reg: &Registry) -> Value {
let e = entries();
let mut tabs: Vec<(&str, usize, usize, Vec<String>)> = Vec::new();
for (tab, group, label, id) in &e {
let have = reg.get(id).is_some();
let t = match tabs.iter_mut().find(|t| t.0 == *tab) {
Some(t) => t,
None => {
tabs.push((tab, 0, 0, Vec::new()));
let n = tabs.len() - 1;
match tabs.get_mut(n) {
Some(t) => t,
None => continue,
}
}
};
t.2 += 1;
if have {
t.1 += 1;
} else {
t.3.push(format!("{group} › {label} (`{id}`)"));
}
}
let live = tabs.iter().map(|t| t.1).sum::<usize>();
let total = e.len();
json!({
"live": live,
"total": total,
"percent": if total == 0 { 0.0 } else { (live as f64 * 1000.0 / total as f64).round() / 10.0 },
"tabs": tabs.iter().map(|t| json!({"tab": t.0, "live": t.1, "total": t.2, "missing": t.3})).collect::<Vec<_>>(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_parses_and_ids_unique_per_feature() {
let e = entries();
assert!(e.len() > 300);
assert!(e.iter().all(|x| x.3.contains('.')));
}
#[test]
fn parity_floor() {
let reg = crate::cmd::registry();
let p = parity(&reg);
let pct = p["percent"].as_f64().unwrap();
// The floor only ever rises.
assert!(pct >= 60.0, "parity {pct}%");
}
}
+284
View File
@@ -0,0 +1,284 @@
//! Caret movement and selection.
use serde_json::Value;
use wordcraft_doc::{Pos, StoryRef};
use super::{parse_pos, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
const EXTEND: &str = r#"{"extend"?: bool}"#;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("caret.left", "Left", "Navigation", |s, v| lr(s, v, true)).key("Left").params(EXTEND).pure(),
CommandSpec::new("caret.right", "Right", "Navigation", |s, v| lr(s, v, false)).key("Right").params(EXTEND).pure(),
CommandSpec::new("caret.up", "Up", "Navigation", |s, v| vert(s, v, -1)).key("Up").params(EXTEND).pure(),
CommandSpec::new("caret.down", "Down", "Navigation", |s, v| vert(s, v, 1)).key("Down").params(EXTEND).pure(),
CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, word_left)).key("Mod+Left / Alt+Left").params(EXTEND).pure(),
CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, word_right)).key("Mod+Right / Alt+Right").params(EXTEND).pure(),
CommandSpec::new("caret.home", "Line Start", "Navigation", |s, v| line(s, v, true)).key("Home").params(EXTEND).pure(),
CommandSpec::new("caret.end", "Line End", "Navigation", |s, v| line(s, v, false)).key("End").params(EXTEND).pure(),
CommandSpec::new("caret.paraUp", "Paragraph Up", "Navigation", |s, v| mv(s, v, para_up)).key("Mod+Up / Alt+Up").params(EXTEND).pure(),
CommandSpec::new("caret.paraDown", "Paragraph Down", "Navigation", |s, v| mv(s, v, para_down)).key("Mod+Down / Alt+Down").params(EXTEND).pure(),
CommandSpec::new("caret.docStart", "Document Start", "Navigation", |s, v| mv(s, v, |s, _| s.doc.start_of(s.sel.focus.story))).key("Mod+Home").params(EXTEND).pure(),
CommandSpec::new("caret.docEnd", "Document End", "Navigation", |s, v| mv(s, v, |s, _| s.doc.end_of(s.sel.focus.story))).key("Mod+End").params(EXTEND).pure(),
CommandSpec::new("caret.pageUp", "Page Up", "Navigation", |s, v| page(s, v, -1)).key("PageUp").params(EXTEND).pure(),
CommandSpec::new("caret.pageDown", "Page Down", "Navigation", |s, v| page(s, v, 1)).key("PageDown").params(EXTEND).pure(),
CommandSpec::new("caret.set", "Set Caret", "Navigation", set).params(r#"{"pos": Pos, "extend"?: bool} | {"page": n, "x": pt, "y": pt}"#).pure(),
CommandSpec::new("select.all", "Select All", "Home › Editing › Select", select_all).key("Mod+A").pure(),
CommandSpec::new("select.range", "Select Range", "Navigation", select_range).params(r#"{"anchor": Pos, "focus": Pos}"#).pure(),
CommandSpec::new("select.word", "Select Word", "Navigation", select_word).pure(),
CommandSpec::new("select.sentence", "Select Sentence", "Navigation", select_sentence).pure(),
CommandSpec::new("select.paragraph", "Select Paragraph", "Navigation", select_paragraph).pure(),
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).pure(),
CommandSpec::new("select.text", "Select Text", "Navigation", select_text).params(r#"{"text": string, "occurrence"?: n}"#).pure(),
CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| {
let to_end = p::bool(v, "end").unwrap_or(false);
let (a, b) = s.sel.ordered();
s.sel = Selection::caret(if to_end { b } else { a });
sel_result(s)
})
.key("Escape")
.pure(),
]
}
fn extend(v: &Value) -> bool {
p::bool(v, "extend").unwrap_or(false)
}
fn apply(s: &mut Session, to: Pos, ext: bool) {
if ext {
s.sel.focus = to;
} else {
s.sel = Selection::caret(to);
}
s.pending = None;
}
fn mv(s: &mut Session, v: &Value, f: fn(&mut Session, &Pos) -> Pos) -> CmdResult {
let ext = extend(v);
s.goal_x = None;
let from = s.sel.focus.clone();
let to = f(s, &from);
apply(s, to, ext);
sel_result(s)
}
/// Left/Right: with a selection and no Shift, collapse to its edge.
fn lr(s: &mut Session, v: &Value, is_left: bool) -> CmdResult {
if !extend(v) && !s.sel.is_collapsed() {
let (a, b) = s.sel.ordered();
s.sel = Selection::caret(if is_left { a } else { b });
s.goal_x = None;
return sel_result(s);
}
mv(s, v, if is_left { left } else { right })
}
fn left(s: &mut Session, p: &Pos) -> Pos {
if let Some(para) = s.doc.para_at(p)
&& p.off > 0
{
return Pos { off: para.prev_boundary(p.off), ..p.clone() };
}
match s.doc.prev_para(p.story, &p.path) {
Some(q) => {
let off = s.doc.para(p.story, &q).map(|x| x.len()).unwrap_or(0);
Pos { story: p.story, path: q, off }
}
None => p.clone(),
}
}
fn right(s: &mut Session, p: &Pos) -> Pos {
if let Some(para) = s.doc.para_at(p)
&& p.off < para.len()
{
return Pos { off: para.next_boundary(p.off), ..p.clone() };
}
match s.doc.next_para(p.story, &p.path) {
Some(q) => Pos { story: p.story, path: q, off: 0 },
None => p.clone(),
}
}
fn word_left(s: &mut Session, p: &Pos) -> Pos {
match s.doc.para_at(p) {
Some(para) if p.off > 0 => Pos { off: para.word_start(p.off), ..p.clone() },
_ => left(s, p),
}
}
fn word_right(s: &mut Session, p: &Pos) -> Pos {
match s.doc.para_at(p) {
Some(para) if p.off < para.len() => Pos { off: para.word_end(p.off), ..p.clone() },
_ => right(s, p),
}
}
fn para_up(s: &mut Session, p: &Pos) -> Pos {
if p.off > 0 {
return Pos { off: 0, ..p.clone() };
}
match s.doc.prev_para(p.story, &p.path) {
Some(q) => Pos { story: p.story, path: q, off: 0 },
None => p.clone(),
}
}
fn para_down(s: &mut Session, p: &Pos) -> Pos {
match s.doc.next_para(p.story, &p.path) {
Some(q) => Pos { story: p.story, path: q, off: 0 },
None => {
let off = s.doc.para_at(p).map(|x| x.len()).unwrap_or(0);
Pos { off, ..p.clone() }
}
}
}
fn vert(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
let ext = extend(v);
let l = s.layout();
let from = s.sel.focus.clone();
let to = match l.vertical(&from, s.goal_x, dir, s.page_hint) {
Some((p, gx)) => {
s.goal_x = Some(gx);
p
}
None => {
// Top/bottom of the story: go to its start/end.
if dir < 0 { s.doc.start_of(from.story) } else { s.doc.end_of(from.story) }
}
};
if let Some(c) = l.caret_on(&to, s.page_hint) {
s.page_hint = c.page;
}
let gx = s.goal_x;
apply(s, to, ext);
s.goal_x = gx;
sel_result(s)
}
fn line(s: &mut Session, v: &Value, home: bool) -> CmdResult {
let ext = extend(v);
let l = s.layout();
let from = s.sel.focus.clone();
let to = match l.line_bounds(&from, s.page_hint) {
Some((a, b)) => {
if home {
a
} else {
b
}
}
None => from,
};
s.goal_x = None;
apply(s, to, ext);
sel_result(s)
}
fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
let ext = extend(v);
let l = s.layout();
let from = s.sel.focus.clone();
let Some(c) = l.caret_on(&from, s.page_hint) else { return sel_result(s) };
let np = if dir < 0 { c.page.saturating_sub(1) } else { (c.page + 1).min(l.pages.len().saturating_sub(1)) };
let to = l.hit(np, c.x, c.top + 1.0, from.story).unwrap_or(from);
s.page_hint = np;
apply(s, to, ext);
sel_result(s)
}
fn set(s: &mut Session, v: &Value) -> CmdResult {
let ext = extend(v);
let to = if let Some(pv) = v.get("pos") {
parse_pos(pv).ok_or_else(|| CmdError::Params("bad `pos`".into()))?
} else if let (Some(pg), Some(x), Some(y)) = (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) {
let story = super::story_param(s, v);
let l = s.layout();
s.page_hint = pg as usize;
l.hit(pg as usize, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?
} else {
return Err(CmdError::Params("`pos` or `page`/`x`/`y` required".into()));
};
let to = s.doc.clamp(&to);
s.goal_x = None;
apply(s, to, ext);
sel_result(s)
}
fn select_all(s: &mut Session, _: &Value) -> CmdResult {
let st = s.sel.focus.story;
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
sel_result(s)
}
fn select_range(s: &mut Session, v: &Value) -> CmdResult {
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
sel_result(s)
}
fn select_word(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(p) = s.doc.para_at(&f) {
let (a, b) = p.word_at(f.off);
s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } };
}
sel_result(s)
}
fn select_sentence(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(p) = s.doc.para_at(&f) {
let (a, b) = p.sentence_at(f.off);
s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } };
}
sel_result(s)
}
fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
let len = s.doc.para_at(&f).map(|p| p.len()).unwrap_or(0);
// Word includes the paragraph mark: extend to the start of the next paragraph when there is one.
let end = match s.doc.next_para(f.story, &f.path) {
Some(q) if q.parent() == f.path.parent() => Pos { story: f.story, path: q, off: 0 },
_ => Pos { off: len, ..f.clone() },
};
s.sel = Selection { anchor: Pos { off: 0, ..f }, focus: end };
sel_result(s)
}
fn select_line(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
if let Some((a, b)) = l.line_bounds(&s.sel.focus, s.page_hint) {
s.sel = Selection { anchor: a, focus: b };
}
sel_result(s)
}
/// Select the n-th occurrence of `text` in the body (agents: "select the word X").
fn select_text(s: &mut Session, v: &Value) -> CmdResult {
let needle = p::req_str(v, "text")?;
let n = p::u64(v, "occurrence").unwrap_or(1).max(1) as usize;
if needle.is_empty() {
return Err(CmdError::Params("`text` is empty".into()));
}
let mut count = 0;
let story = super::story_param(s, v);
for path in s.doc.para_paths(story) {
let Some(p) = s.doc.para(story, &path) else { continue };
for (i, _) in p.text.match_indices(needle) {
count += 1;
if count == n {
s.sel = Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
return sel_result(s);
}
}
}
let _ = StoryRef::Body;
Err(CmdError::Failed(format!("`{needle}` not found")))
}
+183
View File
@@ -0,0 +1,183 @@
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Our own themes: (name, heading font, body font, accent colours).
pub const THEMES: &[(&str, &str, &str, [Rgb; 6])] = &[
("Craft", styles::HEADING_FONT, styles::BODY_FONT, [Rgb(0x15, 0x60, 0x82), Rgb(0xE9, 0x71, 0x32), Rgb(0x19, 0x6B, 0x24), Rgb(0x0F, 0x9E, 0xD5), Rgb(0xA0, 0x2B, 0x93), Rgb(0x4E, 0xA7, 0x2E)]),
("Studio", "Georgia", "Georgia", [Rgb(0x8C, 0x2F, 0x39), Rgb(0xC9, 0x8B, 0x2E), Rgb(0x3E, 0x5C, 0x76), Rgb(0x6B, 0x8F, 0x71), Rgb(0x9A, 0x6F, 0xB0), Rgb(0x4A, 0x4A, 0x4A)]),
("Gallery", "Helvetica Neue", "Helvetica Neue", [Rgb(0x22, 0x22, 0x22), Rgb(0xE6, 0x39, 0x46), Rgb(0x45, 0x7B, 0x9D), Rgb(0x1D, 0x35, 0x57), Rgb(0xA8, 0xDA, 0xDC), Rgb(0xF4, 0xA2, 0x61)]),
("Atelier", "Palatino", "Palatino", [Rgb(0x6D, 0x59, 0x7A), Rgb(0xB5, 0x65, 0x76), Rgb(0xE5, 0x6B, 0x6F), Rgb(0xEA, 0xAC, 0x8B), Rgb(0x35, 0x50, 0x70), Rgb(0x8E, 0x9A, 0xAF)]),
("Darkroom", "Futura", "Avenir Next", [Rgb(0x26, 0x46, 0x53), Rgb(0x2A, 0x9D, 0x8F), Rgb(0xE9, 0xC4, 0x6A), Rgb(0xF4, 0xA2, 0x61), Rgb(0xE7, 0x6F, 0x51), Rgb(0x5E, 0x54, 0x8E)]),
("Sketchbook", "Gill Sans", "Gill Sans", [Rgb(0x58, 0x81, 0x57), Rgb(0x3A, 0x5A, 0x40), Rgb(0xA3, 0xB1, 0x8A), Rgb(0xDA, 0xD7, 0xCD), Rgb(0x34, 0x4E, 0x41), Rgb(0xBC, 0x6C, 0x25)]),
];
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("design.theme", "Themes", "Design › Document Formatting", theme).params(r#"{"name": string}"#),
CommandSpec::new("design.themeFonts", "Fonts", "Design › Document Formatting", |s, v| {
let heading = p::req_str(v, "heading")?.to_string();
let body = p::str(v, "body").unwrap_or(&heading).to_string();
set_fonts(s, &heading, &body);
sel_result(s)
})
.params(r#"{"heading": string, "body"?: string}"#),
CommandSpec::new("design.themeColors", "Colors", "Design › Document Formatting", |s, v| {
let name = p::req_str(v, "name")?;
let t = THEMES.iter().find(|t| t.0.eq_ignore_ascii_case(name)).ok_or_else(|| CmdError::Params(format!("unknown colours `{name}`")))?;
set_accent(s, t.3[0]);
sel_result(s)
}),
CommandSpec::new("design.styleSet", "Style Set", "Design › Document Formatting", style_set).params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing)
.params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#),
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark).params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse);
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.setDefault", "Set as Default", "Design › Document Formatting", |s, _| {
s.status = "These settings will be used for new documents.".into();
sel_result(s)
})
.pure(),
CommandSpec::new("design.themes", "Theme List", "Design › Document Formatting", |_, _| Ok(json!(THEMES.iter().map(|t| t.0).collect::<Vec<_>>()))).pure(),
]
}
fn set_fonts(s: &mut Session, heading: &str, body: &str) {
s.doc.settings.major_font = heading.into();
s.doc.settings.minor_font = body.into();
s.doc.styles.default_chr.font = Some(body.into());
for st in &mut s.doc.styles.styles {
if let Some(f) = &st.chr.font
&& (f == styles::HEADING_FONT || st.id.starts_with("Heading") || st.id == "Title")
{
st.chr.font = Some(heading.into());
} else if st.chr.font.is_some() && st.chr.font.as_deref() != Some(heading) {
st.chr.font = Some(body.into());
}
}
}
fn set_accent(s: &mut Session, accent: Rgb) {
for st in &mut s.doc.styles.styles {
if st.id.starts_with("Heading") && st.id.len() == 8 && st.id.as_bytes().get(7).is_some_and(|d| *d <= b'5') || st.id == "IntenseQuote" || st.id == "IntenseEmphasis" || st.id == "IntenseReference" {
st.chr.color = Some(TextColor::Rgb(accent));
if let Some(b) = st.para.borders.as_mut() {
for e in [&mut b.top, &mut b.bottom].into_iter().flatten() {
e.color = Some(accent);
}
}
}
}
if let Some(c) = s.doc.settings.theme_colors.get_mut(4) {
*c = accent;
}
}
fn theme(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?;
let t = THEMES.iter().find(|t| t.0.eq_ignore_ascii_case(name)).ok_or_else(|| CmdError::Params(format!("unknown theme `{name}`")))?;
set_fonts(s, t.1, t.2);
set_accent(s, t.3[0]);
s.doc.settings.theme_name = t.0.into();
let mut colors = s.doc.settings.theme_colors.clone();
for (i, c) in t.3.iter().enumerate() {
if let Some(x) = colors.get_mut(4 + i) {
*x = *c;
}
}
s.doc.settings.theme_colors = colors;
sel_result(s)
}
fn style_set(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?;
let accent = s.doc.settings.theme_colors.get(4).copied().unwrap_or(styles::HEADING_BLUE);
let mut fresh = wordcraft_doc::StyleSheet::builtin();
let line = Border { style: BorderStyle::Single, width: 0.75, color: Some(accent), space: 1.0 };
for st in &mut fresh.styles {
let heading = st.id.starts_with("Heading") || st.id == "Title";
match name {
"basic" if heading => {
st.chr.color = Some(TextColor::Rgb(Rgb(0x26, 0x26, 0x26)));
st.chr.bold = Some(true);
}
"lines" if st.id == "Heading1" || st.id == "Title" => st.para.borders = Some(Borders { bottom: Some(line), ..Default::default() }),
"shaded" if st.id == "Heading1" => {
st.para.shading = Some(accent);
st.chr.color = Some(TextColor::Rgb(Rgb::WHITE));
}
"casual" if heading => st.chr.italic = Some(true),
"centered" if heading => st.para.align = Some(wordcraft_doc::Align::Center),
"minimalist" if heading => {
st.chr.caps = Some(true);
st.chr.spacing = Some(1.0);
st.chr.color = Some(TextColor::Rgb(Rgb(0x40, 0x40, 0x40)));
}
"title" if st.id == "Title" => st.chr.size = Some(36.0),
_ => {}
}
}
// Keep custom styles and fonts.
let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect();
fresh.styles.extend(customs);
fresh.default_chr.font = s.doc.styles.default_chr.font.clone();
s.doc.styles = fresh;
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
set_fonts(s, &h, &b);
set_accent(s, accent);
sel_result(s)
}
fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
let (before, after, line) = match p::req_str(v, "value")? {
"none" => (0.0, 0.0, 1.0),
"compact" => (0.0, 4.0, 1.0),
"tight" => (0.0, 6.0, 1.15),
"open" => (0.0, 10.0, 1.15),
"relaxed" => (0.0, 6.0, 1.5),
"double" => (0.0, 8.0, 2.0),
_ => (0.0, 8.0, 1.15),
};
s.doc.styles.default_para.space_before = Some(before);
s.doc.styles.default_para.space_after = Some(after);
s.doc.styles.default_para.line_spacing = Some(LineSpacing::Multiple(line));
sel_result(s)
}
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "remove").unwrap_or(false) {
s.doc.settings.watermark = None;
return sel_result(s);
}
let mut wm = s.doc.settings.watermark.clone().unwrap_or_default();
if let Some(t) = p::str(v, "text") {
wm.text = t.chars().take(200).collect();
}
if let Some(d) = p::bool(v, "diagonal") {
wm.diagonal = d;
}
if let Some(c) = p::str(v, "color").and_then(Rgb::parse) {
wm.color = c;
}
s.doc.settings.watermark = Some(Watermark { ..wm });
sel_result(s)
}
+299
View File
@@ -0,0 +1,299 @@
//! Undo, clipboard, find & replace, Go To, Format Painter.
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Block, Pos, StoryRef};
use super::{delete_selection, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
fn has_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
s.undo();
sel_result(s)
})
.key("Mod+Z")
.pure(),
CommandSpec::new("edit.redo", "Redo", "Quick Access Toolbar", |s, _| {
s.redo();
sel_result(s)
})
.key("Mod+Y / Mod+Shift+Z")
.pure(),
CommandSpec::new("edit.cut", "Cut", "Home › Clipboard", cut).key("Mod+X").when(has_sel),
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C").pure(),
CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V").params(r#"{"text"?: string}"#),
CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#).key("Mod+Shift+Alt+V"),
CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_text).params(r#"{"text"?: string}"#),
CommandSpec::new("edit.find", "Find", "Home › Editing", find).key("Mod+F").params(r#"{"text": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#).pure(),
CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3").pure(),
CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1)).key("Mod+Shift+G / Shift+F3").pure(),
CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string}"#),
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all).params(r#"{"text": string, "with": string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#),
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto).key("Mod+Alt+G / F5").params(r#"{"page"?: n, "bookmark"?: string, "paragraph"?: n}"#).pure(),
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
painter_pick(s);
sel_result(s)
})
.key("Mod+Shift+C")
.pure(),
CommandSpec::new("edit.pasteFormat", "Paste Formatting", "Home › Clipboard", paste_format).key("Mod+Shift+V"),
]
}
fn copy(s: &mut Session, _: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(json!({"text": ""}));
}
let f = s.doc.copy_range(&a, &b);
s.clipboard_text = f.plain_text();
s.clipboard = Some(f);
Ok(json!({"text": s.clipboard_text}))
}
fn cut(s: &mut Session, v: &Value) -> CmdResult {
let r = copy(s, v)?;
delete_selection(s)?;
Ok(r)
}
fn paste(s: &mut Session, v: &Value) -> CmdResult {
// An explicit text param (from the system clipboard) wins unless it matches our own copy.
let frag = match (p::str(v, "text"), &s.clipboard) {
(Some(t), Some(f)) if f.plain_text() == t.replace("\r\n", "\n") => f.clone(),
(Some(t), _) => Fragment::from_text(&t.replace("\r\n", "\n")),
(None, Some(f)) => f.clone(),
(None, None) => return Err(CmdError::Failed("the clipboard is empty".into())),
};
if let Some(images) = v.get("image").and_then(Value::as_str) {
let _ = images;
}
let at = delete_selection(s)?;
let mut frag = frag;
if s.doc.settings.track_changes {
let rid = super::new_revision(s, wordcraft_doc::RevisionKind::Insert);
for b in &mut frag.blocks {
if let Block::Para(p) = b {
for r in &mut p.runs {
r.props.ins = Some(rid);
}
}
}
}
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
sel_result(s)
}
fn paste_text(s: &mut Session, v: &Value) -> CmdResult {
let text = match p::str(v, "text") {
Some(t) => t.to_string(),
None => s.clipboard.as_ref().map(Fragment::plain_text).unwrap_or_default(),
};
super::type_text(s, &text.replace("\r\n", "\r").replace('\n', "\r"))?;
sel_result(s)
}
/// All matches of the find state in the current story.
fn search(s: &Session, story: StoryRef) -> Result<Vec<(Pos, Pos)>, CmdError> {
let f = &s.find;
if f.query.is_empty() {
return Ok(Vec::new());
}
let pat = if f.regex { f.query.clone() } else { regex::escape(&f.query) };
let pat = if f.whole_word { format!(r"\b{pat}\b") } else { pat };
let re = regex::RegexBuilder::new(&pat).case_insensitive(!f.match_case).size_limit(1 << 20).build().map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
let mut out = Vec::new();
for path in s.doc.para_paths(story) {
let Some(p) = s.doc.para(story, &path) else { continue };
for m in re.find_iter(&p.text) {
if m.start() == m.end() {
continue;
}
out.push((Pos { story, path: path.clone(), off: m.start() }, Pos { story, path: path.clone(), off: m.end() }));
if out.len() >= 100_000 {
return Ok(out);
}
}
}
Ok(out)
}
fn read_opts(s: &mut Session, v: &Value) {
if let Some(t) = p::str(v, "text") {
s.find.query = t.to_string();
}
if let Some(b) = p::bool(v, "matchCase") {
s.find.match_case = b;
}
if let Some(b) = p::bool(v, "wholeWord") {
s.find.whole_word = b;
}
if let Some(b) = p::bool(v, "regex") {
s.find.regex = b;
}
if let Some(t) = p::str(v, "with") {
s.find.replace = t.to_string();
}
}
fn find(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
if p::str(v, "text").is_none() {
s.view.nav_pane = true;
s.ui_requests.push(json!({"open": "find"}));
return Ok(json!({"count": s.find.results.len()}));
}
let story = s.sel.focus.story;
s.find.results = search(s, story)?;
// Select the first match after the caret.
let caret = s.sel.focus.clone();
let idx = s.find.results.iter().position(|(a, _)| *a >= caret).unwrap_or(0);
s.find.current = idx;
if let Some((a, b)) = s.find.results.get(idx).cloned() {
s.sel = Selection { anchor: a, focus: b };
}
Ok(json!({
"count": s.find.results.len(),
"matches": s.find.results.iter().take(200).map(|(a, b)| json!({"start": pos_json(a), "end": pos_json(b)})).collect::<Vec<_>>(),
}))
}
fn step(s: &mut Session, dir: i64) -> CmdResult {
let story = s.sel.focus.story;
s.find.results = search(s, story)?;
let n = s.find.results.len();
if n == 0 {
return Err(CmdError::Failed("no matches".into()));
}
let caret = if dir > 0 { s.sel.ordered().1 } else { s.sel.ordered().0 };
let idx = if dir > 0 {
s.find.results.iter().position(|(a, _)| *a >= caret).unwrap_or(0)
} else {
s.find.results.iter().rposition(|(_, b)| *b <= caret).unwrap_or(n - 1)
};
s.find.current = idx;
if let Some((a, b)) = s.find.results.get(idx).cloned() {
s.sel = Selection { anchor: a, focus: b };
}
Ok(json!({"index": idx, "count": n}))
}
fn replace(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
let story = s.sel.focus.story;
let results = search(s, story)?;
let (a, b) = s.sel.ordered();
// Replace the current match if selected, then move to the next.
if results.iter().any(|(x, y)| *x == a && *y == b) {
let with = s.find.replace.clone();
super::type_text(s, &with)?;
}
let _ = step(s, 1);
Ok(json!({"remaining": search(s, story)?.len()}))
}
fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
read_opts(s, v);
let story = s.sel.focus.story;
let results = search(s, story)?;
let with = s.find.replace.clone();
let n = results.len();
// Replace from the end so earlier positions stay valid.
for (a, b) in results.into_iter().rev() {
let props = s.doc.para_at(&a).map(|p| p.props_of_char(a.off).clone()).unwrap_or_default();
s.doc.delete_range(&a, &b)?;
s.doc.insert_text(&a, &with, &props)?;
}
s.status = format!("All done. We made {n} replacements.");
Ok(json!({"replaced": n}))
}
fn goto(s: &mut Session, v: &Value) -> CmdResult {
if let Some(pg) = p::u64(v, "page") {
let l = s.layout();
let idx = (pg.max(1) as usize - 1).min(l.pages.len().saturating_sub(1));
let target = l.pages.get(idx).and_then(|page| {
page.items.iter().find_map(|it| match it {
wordcraft_layout::Placed::Lines { story, path, para, l0, .. } if *story == StoryRef::Body => {
para.lines.get(*l0).map(|ln| Pos { story: *story, path: path.clone(), off: ln.start })
}
_ => None,
})
});
if let Some(t) = target {
s.sel = Selection::caret(t);
s.page_hint = idx;
}
s.ui_requests.push(json!({"scrollTo": "caret"}));
return sel_result(s);
}
if let Some(name) = p::str(v, "bookmark") {
let bm = s.doc.bookmarks().into_iter().find(|(n, _)| n == name).ok_or_else(|| CmdError::Failed(format!("no bookmark `{name}`")))?;
s.sel = Selection::caret(bm.1);
return sel_result(s);
}
if let Some(n) = p::u64(v, "paragraph") {
let paths = s.doc.para_paths(StoryRef::Body);
if let Some(path) = paths.get((n.max(1) - 1) as usize) {
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
}
return sel_result(s);
}
s.ui_requests.push(json!({"open": "goto"}));
sel_result(s)
}
fn painter_pick(s: &mut Session) {
let f = s.sel.ordered().0;
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone()).unwrap_or_default();
let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default();
s.painter = Some((chr, para, false));
}
fn painter(s: &mut Session, v: &Value) -> CmdResult {
if s.painter.is_some() {
s.painter = None;
return Ok(json!({"active": false}));
}
painter_pick(s);
if let Some((_, _, sticky)) = s.painter.as_mut() {
*sticky = p::bool(v, "sticky").unwrap_or(false);
}
Ok(json!({"active": true}))
}
/// Apply the format painter's formatting to the selection.
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) };
let (a, b) = s.sel.ordered();
if a == b {
// No selection: apply paragraph formatting (and word formatting like Word).
let f = s.sel.focus.clone();
if let Some(pp) = s.doc.para_at(&f) {
let (x, y) = pp.word_at(f.off);
s.doc.format_range(&Pos { off: x, ..f.clone() }, &Pos { off: y, ..f.clone() }, &|c| *c = chr.cleared().overlaid(&chr))?;
}
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
} else {
s.doc.format_range(&a, &b, &|c| {
let link = c.link.clone();
*c = c.cleared().overlaid(&chr);
c.link = link;
})?;
if a.path != b.path || a.off == 0 {
s.doc.format_paragraphs(&a, &b, &|p| *p = para.clone())?;
}
}
if !sticky {
s.painter = None;
}
sel_result(s)
}
+254
View File
@@ -0,0 +1,254 @@
//! File tab (Backstage): new, open, save, export, print, properties; document inspection.
use serde_json::{Value, json};
use wordcraft_doc::{Block, Document, StoryRef};
use super::{pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
CommandSpec::new("file.open", "Open", "File", open).key("Mod+O").params(r#"{"path": string}"#).pure(),
CommandSpec::new("file.save", "Save", "File", save).key("Mod+S").params(r#"{"path"?: string}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12 / Mod+Shift+S").params(r#"{"path": string}"#).pure(),
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
let path = p::req_str(v, "path")?;
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
Ok(json!({"path": path}))
})
.params(r#"{"path": string}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png).params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt}"#).pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
s.ui_requests.push(json!({"open": "print"}));
sel_result(s)
})
.key("Mod+P")
.pure(),
CommandSpec::new("file.close", "Close", "File", |s, _| {
s.ui_requests.push(json!({"close": true}));
sel_result(s)
})
.key("Mod+W")
.pure(),
CommandSpec::new("file.properties", "Properties", "File › Info", properties).params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?}"#),
CommandSpec::new("file.info", "Info", "File", info).pure(),
CommandSpec::new("file.options", "Options", "File", |s, _| {
s.ui_requests.push(json!({"open": "options"}));
sel_result(s)
})
.pure(),
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", |s, v| {
s.author = p::req_str(v, "name")?.to_string();
sel_result(s)
})
.pure(),
CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(),
CommandSpec::new("document.text", "Document Text", "Agents", |s, v| {
let story = super::story_param(s, v);
Ok(json!({"text": s.doc.plain_text(story)}))
})
.pure(),
CommandSpec::new("document.paragraph", "Paragraph Details", "Agents", paragraph).params(r#"{"path": [n], "story"?: Story}"#).pure(),
CommandSpec::new("document.selection", "Selection", "Agents", |s, _| {
let mut r = sel_result(s)?;
if let Some(o) = r.as_object_mut() {
o.insert("text".into(), json!(s.selected_text()));
o.insert("format".into(), super::format::state(s));
}
Ok(r)
})
.pure(),
CommandSpec::new("document.layout", "Layout Summary", "Agents", layout_summary).pure(),
CommandSpec::new("document.setText", "Replace Document Text", "Agents", |s, v| {
let text = p::req_str(v, "text")?;
let d = Document::from_text(text);
s.doc.body = d.body;
s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
.params(r#"{"text": string}"#),
]
}
fn new(s: &mut Session, v: &Value) -> CmdResult {
let doc = match p::str(v, "template").unwrap_or("blank") {
"sample" => crate::sample::sample_document(),
"letter" => crate::sample::letter(),
"resume" => crate::sample::resume(),
"report" => crate::sample::report(),
_ => Document::new(),
};
s.set_document(doc);
s.path = None;
sel_result(s)
}
fn open(s: &mut Session, v: &Value) -> CmdResult {
let Some(path) = p::str(v, "path") else {
s.ui_requests.push(json!({"open": "openFile"}));
return sel_result(s);
};
let doc = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
crate::io::open_bytes(path, &bytes).map_err(CmdError::Failed)?
} else {
crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?
};
s.set_document(doc);
s.path = Some(path.into());
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count()}))
}
fn save(s: &mut Session, v: &Value) -> CmdResult {
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
Some(p) => p,
None => {
s.ui_requests.push(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
};
s.doc.core.modified = super::now_iso();
if s.doc.core.created.is_empty() {
s.doc.core.created = s.doc.core.modified.clone();
}
s.doc.core.last_modified_by = s.author.clone();
s.doc.core.revision = s.doc.core.revision.saturating_add(1);
crate::io::save_path(&path, &s.doc).map_err(CmdError::Failed)?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if ["docx", "odt", "rtf", "json"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
}
Ok(json!({"saved": true, "path": path.to_string_lossy()}))
}
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "path").is_none() {
s.ui_requests.push(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
save(s, v)
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
let path = p::req_str(v, "path")?;
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.layout();
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
let img = wordcraft_render::render_page(&s.doc, pg, scale, &Default::default());
let png = img.to_png();
#[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &png).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let c = &mut s.doc.core;
let set = |dst: &mut String, k: &str| {
if let Some(x) = p::str(v, k) {
*dst = x.to_string();
}
};
set(&mut c.title, "title");
set(&mut c.subject, "subject");
set(&mut c.creator, "author");
set(&mut c.keywords, "keywords");
set(&mut c.description, "comments");
set(&mut c.category, "category");
Ok(serde_json::to_value(&s.doc.core).unwrap_or(Value::Null))
}
fn info(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
Ok(json!({
"path": s.path.as_ref().map(|p| p.to_string_lossy().to_string()),
"dirty": s.dirty,
"pages": l.pages.len(),
"words": s.doc.word_count(),
"paragraphs": s.doc.paragraph_count(),
"properties": s.doc.core,
"trackChanges": s.doc.settings.track_changes,
"comments": s.doc.comments.len(),
"sections": s.doc.sections().len(),
"layoutMs": l.ms,
}))
}
/// Structure for agents: blocks with text and formatting summary, selection, stats.
fn inspect(s: &mut Session, v: &Value) -> CmdResult {
let with_text = p::bool(v, "text").unwrap_or(true);
let mut blocks = Vec::new();
for (i, b) in s.doc.body.iter().enumerate().take(2000) {
match &**b {
Block::Para(p) => {
let style = p.props.style.clone().unwrap_or_else(|| "Normal".into());
let mut o = json!({"index": i, "type": "paragraph", "style": style, "len": p.len()});
if with_text {
o["text"] = json!(p.plain_text());
}
if let Some(n) = p.props.numbering.filter(|n| n.num != 0) {
o["list"] = json!({"num": n.num, "level": n.level});
}
if !p.props.is_empty() {
o["props"] = serde_json::to_value(&p.props).unwrap_or(Value::Null);
}
let runs: Vec<Value> = p.run_ranges().filter(|(_, c)| !c.is_empty()).map(|(r, c)| json!({"start": r.start, "end": r.end, "props": c})).take(50).collect();
if !runs.is_empty() {
o["runs"] = json!(runs);
}
if !p.objects.is_empty() {
o["objects"] = serde_json::to_value(&p.objects).unwrap_or(Value::Null);
}
if p.section.is_some() {
o["sectionBreak"] = json!(true);
}
blocks.push(o);
}
Block::Table(t) => {
let cells: Vec<Vec<String>> = t.rows.iter().map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n")).collect()).collect();
blocks.push(json!({"index": i, "type": "table", "rows": t.rows.len(), "cols": t.cols(), "style": t.props.style, "cells": cells}));
}
}
}
let l = s.layout();
let parts: Vec<Value> = s.doc.parts.iter().map(|(id, p)| json!({"id": id, "kind": p.kind, "text": s.doc.plain_text(StoryRef::Part(*id))})).collect();
Ok(json!({
"blocks": blocks,
"parts": parts,
"selection": {"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus), "text": s.selected_text()},
"pages": l.pages.len(),
"words": s.doc.word_count(),
"sections": s.doc.sections().iter().map(|(end, sp)| json!({"endBlock": end, "props": sp})).collect::<Vec<_>>(),
"comments": s.doc.comments.len(),
"trackChanges": s.doc.settings.track_changes,
"properties": s.doc.core,
"canUndo": s.can_undo(),
"canRedo": s.can_redo(),
}))
}
fn paragraph(s: &mut Session, v: &Value) -> CmdResult {
let path: Vec<u32> = serde_json::from_value(v.get("path").cloned().unwrap_or(json!([0]))).map_err(|e| CmdError::Params(e.to_string()))?;
let story = super::story_param(s, v);
let p = s.doc.para(story, &wordcraft_doc::Path(path)).ok_or_else(|| CmdError::Params("no paragraph there".into()))?;
let resolved = s.doc.styles.resolve_para(&p.props);
Ok(json!({"paragraph": p, "resolved": resolved}))
}
fn layout_summary(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
let pages: Vec<Value> = l
.pages
.iter()
.map(|p| {
let lines: usize = p.items.iter().map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 }).sum();
json!({"number": p.number, "w": p.w, "h": p.h, "section": p.section, "lines": lines, "firstBlock": p.first_block, "header": p.header_story, "footer": p.footer_story})
})
.collect();
let caret = l.caret_on(&s.sel.focus, s.page_hint).map(|c| json!({"page": c.page, "x": c.x, "y": c.top, "h": c.height}));
Ok(json!({"pages": pages, "caret": caret, "ms": l.ms}))
}
+395
View File
@@ -0,0 +1,395 @@
//! Character formatting (Home › Font).
use serde_json::{Value, json};
use wordcraft_doc::Pos;
use wordcraft_doc::props::{CharProps, Highlight, Rgb, TextColor, Underline, VertAlign};
use wordcraft_doc::resolve::ResolvedChar;
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Word's font size list.
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| toggle(s, v, |r| r.bold, |c, on| c.bold = Some(on))).key("Mod+B").params(r#"{"value"?: bool}"#),
CommandSpec::new("format.italic", "Italic", "Home › Font", |s, v| toggle(s, v, |r| r.italic, |c, on| c.italic = Some(on))).key("Mod+I").params(r#"{"value"?: bool}"#),
CommandSpec::new("format.underline", "Underline", "Home › Font", underline).key("Mod+U").params(r#"{"value"?: bool, "style"?: "single|double|thick|dotted|dash|dotDash|dotDotDash|wave|words"}"#),
CommandSpec::new("format.doubleUnderline", "Double Underline", "Home › Font › Underline", |s, _| {
underline(s, &json!({"style": "double"}))
})
.key("Mod+Shift+D"),
CommandSpec::new("format.wordUnderline", "Underline Words Only", "Home › Font › Underline", |s, _| underline(s, &json!({"style": "words"}))).key("Mod+Shift+W"),
CommandSpec::new("format.strikethrough", "Strikethrough", "Home › Font", |s, v| toggle(s, v, |r| r.strike, |c, on| c.strike = Some(on))).params(r#"{"value"?: bool}"#),
CommandSpec::new("format.doubleStrikethrough", "Double Strikethrough", "Home › Font › Font", |s, v| {
toggle(s, v, |r| r.double_strike, |c, on| c.double_strike = Some(on))
}),
CommandSpec::new("format.subscript", "Subscript", "Home › Font", |s, v| {
toggle(s, v, |r| r.vert_align == VertAlign::Subscript, |c, on| c.vert_align = Some(if on { VertAlign::Subscript } else { VertAlign::Baseline }))
})
.key("Mod+="),
CommandSpec::new("format.superscript", "Superscript", "Home › Font", |s, v| {
toggle(s, v, |r| r.vert_align == VertAlign::Superscript, |c, on| c.vert_align = Some(if on { VertAlign::Superscript } else { VertAlign::Baseline }))
})
.key("Mod+Shift+="),
CommandSpec::new("format.allCaps", "All Caps", "Home › Font › Font", |s, v| toggle(s, v, |r| r.caps, |c, on| c.caps = Some(on))).key("Mod+Shift+A"),
CommandSpec::new("format.smallCaps", "Small Caps", "Home › Font › Font", |s, v| toggle(s, v, |r| r.small_caps, |c, on| c.small_caps = Some(on))).key("Mod+Shift+K"),
CommandSpec::new("format.hidden", "Hidden", "Home › Font › Font", |s, v| toggle(s, v, |r| r.hidden, |c, on| c.hidden = Some(on))).key("Mod+Shift+H"),
CommandSpec::new("format.outline", "Outline", "Home › Font › Text Effects", |s, v| toggle(s, v, |r| r.outline, |c, on| c.outline = Some(on))),
CommandSpec::new("format.shadow", "Shadow", "Home › Font › Text Effects", |s, v| toggle(s, v, |r| r.shadow, |c, on| c.shadow = Some(on))),
CommandSpec::new("format.emboss", "Emboss", "Home › Font › Font", |s, v| toggle(s, v, |r| r.emboss, |c, on| c.emboss = Some(on))),
CommandSpec::new("format.engrave", "Engrave", "Home › Font › Font", |s, v| toggle(s, v, |r| r.engrave, |c, on| c.engrave = Some(on))),
CommandSpec::new("format.font", "Font", "Home › Font", font).key("Mod+Shift+F").params(r#"{"name": string}"#),
CommandSpec::new("format.size", "Font Size", "Home › Font", size).key("Mod+Shift+P").params(r#"{"size": number}"#),
CommandSpec::new("format.growFont", "Increase Font Size", "Home › Font", |s, _| step_size(s, 1)).key("Mod+Shift+. / Mod+Shift+>"),
CommandSpec::new("format.shrinkFont", "Decrease Font Size", "Home › Font", |s, _| step_size(s, -1)).key("Mod+Shift+, / Mod+Shift+<"),
CommandSpec::new("format.growFont1", "Grow Font 1 Point", "Home › Font", |s, _| nudge_size(s, 1.0)).key("Mod+]"),
CommandSpec::new("format.shrinkFont1", "Shrink Font 1 Point", "Home › Font", |s, _| nudge_size(s, -1.0)).key("Mod+["),
CommandSpec::new("format.color", "Font Color", "Home › Font", color).params(r#"{"color": "RRGGBB" | "auto"}"#),
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight).params(r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#),
CommandSpec::new("format.shading", "Character Shading", "Home › Font", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
apply(s, &|x| x.shading = c)
})
.params(r#"{"color": "RRGGBB" | null}"#),
CommandSpec::new("format.changeCase", "Change Case", "Home › Font", change_case).key("Shift+F3").params(r#"{"mode"?: "sentence|lower|upper|title|toggle"}"#),
CommandSpec::new("format.clear", "Clear All Formatting", "Home › Font", clear).key("Mod+Space"),
CommandSpec::new("format.spacing", "Character Spacing", "Home › Font › Font › Advanced", |s, v| {
let x = p::req_f32(v, "points")?.clamp(-100.0, 100.0);
apply(s, &|c| c.spacing = Some(x))
})
.params(r#"{"points": number}"#),
CommandSpec::new("format.scale", "Character Scale", "Home › Font › Font › Advanced", |s, v| {
let x = p::req_f32(v, "percent")?.clamp(1.0, 600.0);
apply(s, &|c| c.scale = Some(x))
})
.params(r#"{"percent": number}"#),
CommandSpec::new("format.position", "Character Position", "Home › Font › Font › Advanced", |s, v| {
let x = p::req_f32(v, "points")?.clamp(-1584.0, 1584.0);
apply(s, &|c| c.position = Some(x))
})
.params(r#"{"points": number}"#),
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
s.ui_requests.push(json!({"open": "font"}));
sel_result(s)
})
.key("Mod+D")
.pure(),
CommandSpec::new("format.state", "Formatting at Selection", "Home › Font", |s, _| Ok(state(s))).pure(),
]
}
/// The selection to format: the selection, or the word around a collapsed caret (Word
/// formats the whole word when the caret is inside one). `None` = caret between words.
fn target(s: &Session) -> Option<(Pos, Pos)> {
let (a, b) = s.sel.ordered();
if a != b {
return Some((a, b));
}
let p = s.doc.para_at(&a)?;
let is_w = |c: char| c.is_alphanumeric() || c == '\'' || c == '_';
let before = p.text.get(..a.off)?.chars().next_back();
let after = p.text.get(a.off..)?.chars().next();
if before.is_some_and(is_w) && after.is_some_and(is_w) {
let (x, y) = p.word_at(a.off);
// word_at includes trailing spaces; Word formats just the word.
let word_end = p.text.get(x..y).map(|w| x + w.trim_end().len()).unwrap_or(y);
return Some((Pos { off: x, ..a.clone() }, Pos { off: word_end, ..a }));
}
None
}
/// Apply a change to the selection (or the pending caret formatting).
pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
match target(s) {
Some((a, b)) => {
s.doc.format_range(&a, &b, f)?;
}
None => {
let mut pend = s.typing_props();
f(&mut pend);
s.pending = Some(pend);
// An empty paragraph's mark takes the formatting too (so it shows the new size).
let fpos = s.sel.focus.clone();
if s.doc.para_at(&fpos).is_some_and(|p| p.is_empty())
&& let Ok(para) = s.doc.para_mut(fpos.story, &fpos.path)
{
f(&mut para.mark);
para.touch();
}
}
}
sel_result(s)
}
/// Resolved formatting of every character in the target (or the caret).
fn resolved(s: &Session) -> Vec<ResolvedChar> {
let mut out = Vec::new();
let Some((a, b)) = target(s) else {
let f = &s.sel.focus;
let style = s.doc.para_at(f).and_then(|p| p.props.style.clone());
out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()));
return out;
};
for path in s.doc.paths_between(&a, &b) {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
out.push(s.doc.styles.resolve_char(p.props.style.as_deref(), c));
}
}
if out.len() > 10_000 {
break;
}
}
if out.is_empty() {
let style = s.doc.para_at(&a).and_then(|p| p.props.style.clone());
out.push(s.doc.styles.resolve_char(style.as_deref(), &s.typing_props()));
}
out
}
fn toggle(s: &mut Session, v: &Value, get: fn(&ResolvedChar) -> bool, set: fn(&mut CharProps, bool)) -> CmdResult {
let on = match p::bool(v, "value") {
Some(b) => b,
None => !resolved(s).iter().all(get),
};
apply(s, &|c| set(c, on))
}
fn underline(s: &mut Session, v: &Value) -> CmdResult {
let style = p::str(v, "style").map(Underline::from_ooxml);
let on = match (p::bool(v, "value"), style) {
(Some(b), _) => b,
(None, Some(_)) => true,
(None, None) => !resolved(s).iter().all(|r| r.underline != Underline::None),
};
let u = if on { style.unwrap_or(Underline::Single) } else { Underline::None };
apply(s, &|c| c.underline = Some(u))
}
fn font(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim().to_string();
if name.is_empty() || name.len() > 128 {
return Err(CmdError::Params("bad font name".into()));
}
apply(s, &|c| c.font = Some(name.clone()))
}
fn size(s: &mut Session, v: &Value) -> CmdResult {
let sz = p::req_f32(v, "size")?;
if !(1.0..=1638.0).contains(&sz) {
return Err(CmdError::Params("size must be between 1 and 1638".into()));
}
// Word rounds to half points.
let sz = (sz * 2.0).round() / 2.0;
apply(s, &|c| c.size = Some(sz))
}
fn current_size(s: &Session) -> f32 {
resolved(s).first().map(|r| r.size).unwrap_or(12.0)
}
fn step_size(s: &mut Session, dir: i32) -> CmdResult {
let cur = current_size(s);
let next = if dir > 0 {
SIZES.iter().copied().find(|x| *x > cur + 0.01).unwrap_or(((cur / 10.0).floor() + 1.0) * 10.0)
} else {
SIZES.iter().rev().copied().find(|x| *x < cur - 0.01).unwrap_or(1.0)
};
let next = next.clamp(1.0, 1638.0);
// Each run steps from its own size when the selection mixes sizes.
apply(s, &|c| c.size = Some(next))
}
fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
let next = (current_size(s) + d).clamp(1.0, 1638.0);
apply(s, &|c| c.size = Some(next))
}
fn color(s: &mut Session, v: &Value) -> CmdResult {
let c = p::req_str(v, "color")?;
let tc = if c.eq_ignore_ascii_case("auto") || c.eq_ignore_ascii_case("automatic") {
TextColor::Auto
} else {
TextColor::Rgb(Rgb::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))?)
};
apply(s, &|x| x.color = Some(tc))
}
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
let c = p::str(v, "color").unwrap_or("yellow");
let h = Highlight::ALL
.iter()
.copied()
.find(|h| h.ooxml().eq_ignore_ascii_case(c) || h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], "")) || format!("{h:?}").eq_ignore_ascii_case(c))
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
apply(s, &|x| x.highlight = Some(h))
}
fn change_case(s: &mut Session, v: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
let (a, b) = if a == b { target(s).unwrap_or((a, b)) } else { (a, b) };
if a == b {
return sel_result(s);
}
let text = s.doc.copy_range(&a, &b).plain_text();
let mode = p::str(v, "mode").map(str::to_string).unwrap_or_else(|| {
// Shift+F3 cycles: lower → UPPER → Title → lower.
if text.chars().any(char::is_lowercase) && text.chars().any(char::is_uppercase) && !is_title(&text) {
"lower".into()
} else if !text.chars().any(char::is_uppercase) {
"upper".into()
} else if text.chars().any(char::is_lowercase) {
"lower".into()
} else {
"title".into()
}
});
for path in s.doc.paths_between(&a, &b) {
let (from, to, src) = {
let Some(p) = s.doc.para(a.story, &path) else { continue };
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
(from, to, p.text.get(from..to).unwrap_or("").to_string())
};
let new = convert(&src, &mode, from == 0);
if new.len() != src.len() {
// Lengths differ (ß → SS): replace text keeping the first run's formatting.
let para = s.doc.para_mut(a.story, &path)?;
let props = para.props_of_char(from).clone();
para.delete(from, to)?;
para.insert_text(from, &new, &props)?;
} else {
let para = s.doc.para_mut(a.story, &path)?;
if let Some(slice) = para.text.get(from..to)
&& slice.len() == new.len()
{
para.text.replace_range(from..to, &new);
para.touch();
}
}
}
sel_result(s)
}
fn is_title(t: &str) -> bool {
t.split_whitespace().all(|w| w.chars().next().is_some_and(char::is_uppercase))
}
fn convert(src: &str, mode: &str, para_start: bool) -> String {
match mode {
"upper" => src.to_uppercase(),
"lower" => src.to_lowercase(),
"toggle" => src.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::<String>() } else { c.to_uppercase().collect() }).collect(),
"title" => {
let mut out = String::with_capacity(src.len());
let mut up = true;
for c in src.chars() {
if up && c.is_alphabetic() {
out.extend(c.to_uppercase());
up = false;
} else {
out.extend(c.to_lowercase());
}
if c.is_whitespace() {
up = true;
}
}
out
}
_ => {
// Sentence case.
let mut out = String::with_capacity(src.len());
let mut up = para_start;
for c in src.chars() {
if up && c.is_alphabetic() {
out.extend(c.to_uppercase());
up = false;
} else {
out.extend(c.to_lowercase());
}
if matches!(c, '.' | '!' | '?') {
up = true;
}
}
out
}
}
}
fn clear(s: &mut Session, _: &Value) -> CmdResult {
match target(s) {
Some((a, b)) => {
s.doc.format_range(&a, &b, &|c| *c = c.cleared())?;
// Clear Formatting also resets the paragraph to Normal.
s.doc.format_paragraphs(&a, &b, &|p| {
let num = p.numbering;
*p = Default::default();
p.numbering = num.filter(|_| false);
})?;
}
None => {
s.pending = Some(CharProps::default());
let f = s.sel.focus.clone();
s.doc.format_paragraphs(&f, &f, &|p| *p = Default::default())?;
}
}
sel_result(s)
}
fn char_style(s: &mut Session, v: &Value) -> CmdResult {
let st = p::req_str(v, "style")?;
let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{st}`")))?;
apply(s, &|c| c.style = Some(id.clone()))
}
fn set(s: &mut Session, v: &Value) -> CmdResult {
let props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
apply(s, &|c| c.overlay(&props))
}
/// What the ribbon shows: the formatting at the selection (mixed values are null).
pub fn state(s: &Session) -> Value {
let all = resolved(s);
let first = all.first().cloned();
let same = |f: &dyn Fn(&ResolvedChar) -> Value| -> Value {
let Some(r) = &first else { return Value::Null };
let v0 = f(r);
if all.iter().all(|r| f(r) == v0) { v0 } else { Value::Null }
};
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.clone()).unwrap_or_else(|| "Normal".into());
let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone());
json!({
"font": same(&|r| json!(r.font)),
"size": same(&|r| json!(r.size)),
"bold": all.iter().all(|r| r.bold),
"italic": all.iter().all(|r| r.italic),
"underline": all.iter().all(|r| r.underline != Underline::None),
"strike": all.iter().all(|r| r.strike),
"subscript": all.iter().all(|r| r.vert_align == VertAlign::Subscript),
"superscript": all.iter().all(|r| r.vert_align == VertAlign::Superscript),
"color": same(&|r| json!(match r.color { TextColor::Auto => "auto".to_string(), TextColor::Rgb(c) => c.hex() })),
"highlight": same(&|r| json!(r.highlight.map(|c| c.hex()))),
"style": style,
"styleName": style_name,
})
}
#[cfg(test)]
mod tests {
use super::convert;
#[test]
fn case_conversions() {
assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again");
assert_eq!(convert("hello WORLD", "title", true), "Hello World");
assert_eq!(convert("Hello", "toggle", true), "hELLO");
assert_eq!(convert("straße", "upper", true), "STRASSE");
}
}
+554
View File
@@ -0,0 +1,554 @@
//! Insert tab: pages, tables, pictures, shapes, links, bookmarks, headers/footers, text, symbols.
use serde_json::{Value, json};
use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor};
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
use super::{delete_selection, sel_result, split_para, type_text};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| {
type_text(s, "\u{000C}")?;
sel_result(s)
}),
CommandSpec::new("insert.blankPage", "Blank Page", "Insert › Pages", |s, _| {
type_text(s, "\u{000C}")?;
let at = s.sel.focus.clone();
let at = split_para(s, &at)?;
s.doc.insert_text(&at, "\u{000C}", &CharProps::default())?;
s.sel = Selection::caret(at);
sel_result(s)
}),
CommandSpec::new("insert.coverPage", "Cover Page", "Insert › Pages", cover_page).params(r#"{"title"?: string, "subtitle"?: string, "author"?: string}"#),
CommandSpec::new("insert.table", "Table", "Insert › Tables", table).params(r#"{"rows": n, "cols": n, "style"?: string}"#),
CommandSpec::new("insert.picture", "Pictures", "Insert › Illustrations", picture).params(r#"{"path"?: string, "data"?: base64, "width"?: pt, "alt"?: string}"#),
CommandSpec::new("insert.shape", "Shapes", "Insert › Illustrations", shape)
.params(r#"{"kind": "rectangle|roundedRectangle|ellipse|triangle|diamond|line|arrow|star|heart", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB"}"#),
CommandSpec::new("insert.textBox", "Text Box", "Insert › Text", text_box).params(r#"{"text"?: string, "width"?: pt, "height"?: pt}"#),
CommandSpec::new("insert.link", "Link", "Insert › Links", link).key("Mod+K").params(r#"{"url": string, "text"?: string}"#),
CommandSpec::new("insert.removeLink", "Remove Hyperlink", "Insert › Links", |s, _| super::format::apply(s, &|c| {
c.link = None;
if c.style.as_deref() == Some("Hyperlink") {
c.style = None;
}
})),
CommandSpec::new("insert.bookmark", "Bookmark", "Insert › Links", bookmark).params(r#"{"name": string}"#),
CommandSpec::new("insert.header", "Header", "Insert › Header & Footer", |s, v| header_footer(s, v, true)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|title"}"#),
CommandSpec::new("insert.footer", "Footer", "Insert › Header & Footer", |s, v| header_footer(s, v, false)).params(r#"{"text"?: string, "preset"?: "blank|blankThree|pageNumber"}"#),
CommandSpec::new("insert.pageNumber", "Page Number", "Insert › Header & Footer", page_number).params(r#"{"position"?: "top|bottom|current", "align"?: "left|center|right", "format"?: "x of y"}"#),
CommandSpec::new("insert.editHeader", "Edit Header", "Insert › Header & Footer › Header", |s, _| edit_hf(s, true)).pure(),
CommandSpec::new("insert.editFooter", "Edit Footer", "Insert › Header & Footer › Footer", |s, _| edit_hf(s, false)).pure(),
CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| {
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
.pure(),
CommandSpec::new("insert.removeHeader", "Remove Header", "Insert › Header & Footer › Header", |s, _| {
s.doc.last_section.headers = Default::default();
for path in s.doc.para_paths(StoryRef::Body) {
if s.doc.para(StoryRef::Body, &path).is_some_and(|p| p.section.is_some())
&& let Ok(p) = s.doc.para_mut(StoryRef::Body, &path)
&& let Some(sec) = p.section.as_mut()
{
sec.headers = Default::default();
}
}
sel_result(s)
}),
CommandSpec::new("insert.removeFooter", "Remove Footer", "Insert › Header & Footer › Footer", |s, _| {
s.doc.last_section.footers = Default::default();
sel_result(s)
}),
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#),
CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", |s, v| {
let c = p::req_str(v, "char")?;
type_text(s, c)?;
sel_result(s)
})
.params(r#"{"char": string}"#),
CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", |s, v| {
let lin = p::str(v, "linear").unwrap_or("a^2+b^2=c^2").to_string();
let props = s.typing_props();
let at = delete_selection(s)?;
let end = s.doc.insert_object(&at, InlineObject::Equation { linear: lin, display: false }, &props)?;
s.sel = Selection::caret(end);
sel_result(s)
})
.key("Alt+=")
.params(r#"{"linear"?: string}"#),
CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#),
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", |s, v| {
let lines = p::u64(v, "lines").unwrap_or(3).min(10) as u8;
super::para::fmt(s, &|p| p.drop_cap = Some(lines))
})
.params(r#"{"lines"?: n (0 = none)}"#),
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
super::para::fmt(s, &|p| {
p.borders = Some(wordcraft_doc::props::Borders { bottom: Some(wordcraft_doc::props::Border::single(1.5)), ..Default::default() })
})
}),
CommandSpec::new("insert.wordArt", "WordArt", "Insert › Text", |s, v| {
let text = p::str(v, "text").unwrap_or("Your text here").to_string();
let props = CharProps { size: Some(36.0), bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), outline: Some(false), ..Default::default() };
let at = delete_selection(s)?;
let end = s.doc.insert_text(&at, &text, &props)?;
s.sel = Selection { anchor: at, focus: end };
sel_result(s)
}),
CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| {
let path = p::req_str(v, "path")?;
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
let frag = wordcraft_doc::edit::Fragment { blocks: doc.body.iter().map(|b| (**b).clone()).collect() };
let at = delete_selection(s)?;
let end = s.doc.insert_fragment(&at, &frag)?;
s.sel = Selection::caret(end);
sel_result(s)
})
.params(r#"{"path": string}"#),
]
}
/// Insert a block after the caret's paragraph (splitting it if the caret is mid-paragraph).
fn insert_block_at_caret(s: &mut Session, block: Block) -> Result<Path, CmdError> {
let at = delete_selection(s)?;
let len = s.doc.para_at(&at).map(|p| p.len()).unwrap_or(0);
let insert_at = if at.off == 0 && len > 0 {
at.path.clone()
} else if at.off >= len && len == 0 {
at.path.clone()
} else {
let new = s.doc.split_paragraph(&at)?;
if at.off >= len { at.path.with_last(at.path.last() + 1) } else { new.path }
};
s.doc.insert_block(at.story, &insert_at, block)?;
// Word keeps a paragraph after a table.
let next = insert_at.with_last(insert_at.last() + 1);
if s.doc.block(at.story, &next).is_none() {
s.doc.insert_block(at.story, &next, Block::Para(Paragraph::new()))?;
}
// An empty paragraph we inserted before? Remove if the table took its place at the start.
Ok(insert_at)
}
fn table(s: &mut Session, v: &Value) -> CmdResult {
let rows = p::u64(v, "rows").unwrap_or(2).clamp(1, 1000) as usize;
let cols = p::u64(v, "cols").unwrap_or(2).clamp(1, 63) as usize;
let width = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
let mut t = Table::new(rows, cols, width);
if let Some(st) = p::str(v, "style") {
let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
t.props.style = Some(id);
}
let story = s.sel.focus.story;
let path = insert_block_at_caret(s, Block::Table(t))?;
let mut first = path.0.clone();
first.extend([0, 0, 0]);
s.sel = Selection::caret(Pos { story, path: Path(first), off: 0 });
Ok(json!({"table": path.0}))
}
/// Minimal base64 (standard alphabet, padding optional, whitespace ignored).
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let mut buf = 0u32;
let mut n = 0;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' | b' ' | b'\n' | b'\r' | b'\t' => continue,
_ => return None,
} as u32;
buf = (buf << 6) | v;
n += 6;
if n >= 8 {
n -= 8;
out.push((buf >> n) as u8);
buf &= (1 << n) - 1;
}
}
Some(out)
}
pub fn base64_encode(b: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
for ch in b.chunks(3) {
let n = (ch.first().copied().unwrap_or(0) as u32) << 16 | (ch.get(1).copied().unwrap_or(0) as u32) << 8 | ch.get(2).copied().unwrap_or(0) as u32;
for i in 0..4 {
if i <= ch.len() {
out.push(T[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn picture(s: &mut Session, v: &Value) -> CmdResult {
let bytes = if let Some(d) = p::str(v, "data") {
base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?
} else if let Some(path) = p::str(v, "path") {
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
return Err(CmdError::Failed("reading files by path isn't available on the web".into()));
}
} else {
s.ui_requests.push(json!({"open": "insertPicture"}));
return sel_result(s);
};
if bytes.len() > 200 << 20 {
return Err(CmdError::Failed("image is larger than 200 MB".into()));
}
let (pw, ph) = wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP)".into()))?;
let ext = match bytes.get(..4) {
Some([0x89, b'P', b'N', b'G']) => "png",
Some([0xFF, 0xD8, ..]) => "jpeg",
Some([b'G', b'I', b'F', _]) => "gif",
Some([b'R', b'I', b'F', b'F']) => "webp",
Some([b'B', b'M', ..]) => "bmp",
_ => "png",
};
let key = s.doc.add_media(bytes, ext);
// Natural size at 96 ppi, fitted to the text width.
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0);
let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75);
if let Some(want) = p::f32(v, "width") {
let k = want.max(4.0) / w.max(1.0);
w *= k;
h *= k;
}
if w > max_w {
h *= max_w / w;
w = max_w;
}
let props = s.typing_props();
let at = delete_selection(s)?;
let obj = InlineObject::Image { media: key.clone(), w, h, alt: p::str(v, "alt").unwrap_or("").to_string(), float: Float::default(), crop: [0.0; 4] };
let end = s.doc.insert_object(&at, obj, &props)?;
s.sel = Selection { anchor: at, focus: end };
Ok(json!({"media": key, "width": w, "height": h}))
}
fn shape(s: &mut Session, v: &Value) -> CmdResult {
let kind: ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
let w = p::f32(v, "width").unwrap_or(108.0).clamp(4.0, 2000.0);
let h = p::f32(v, "height").unwrap_or(if kind == ShapeKind::Line { 1.0 } else { 72.0 }).clamp(1.0, 2000.0);
let fill = match p::str(v, "fill") {
Some(c) => Rgb::parse(c),
None if kind == ShapeKind::Line => None,
None => Some(Rgb(0x15, 0x60, 0x82)),
};
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
let props = s.typing_props();
let at = delete_selection(s)?;
let obj = InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None };
let end = s.doc.insert_object(&at, obj, &props)?;
s.sel = Selection { anchor: at, focus: end };
sel_result(s)
}
fn text_box(s: &mut Session, v: &Value) -> CmdResult {
let text = p::str(v, "text").unwrap_or("").to_string();
let w = p::f32(v, "width").unwrap_or(144.0).clamp(18.0, 2000.0);
let h = p::f32(v, "height").unwrap_or(72.0).clamp(18.0, 2000.0);
let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))]);
let props = s.typing_props();
let at = delete_selection(s)?;
let obj = InlineObject::Shape { kind: ShapeKind::TextBox, w, h, fill: Some(Rgb::WHITE), stroke: Some(Rgb::BLACK), stroke_width: 0.75, float: Float::default(), story: Some(id) };
let end = s.doc.insert_object(&at, obj, &props)?;
s.sel = Selection::caret(end);
Ok(json!({"story": id}))
}
fn link(s: &mut Session, v: &Value) -> CmdResult {
let Some(url) = p::str(v, "url").map(str::to_string) else {
s.ui_requests.push(json!({"open": "link"}));
return sel_result(s);
};
if url.is_empty() || url.len() > 4096 {
return Err(CmdError::Params("bad url".into()));
}
let style = Some("Hyperlink".to_string());
if s.sel.is_collapsed() {
let text = p::str(v, "text").unwrap_or(&url).to_string();
let mut props = s.typing_props();
props.link = Some(url.clone());
props.style = style;
let at = s.sel.focus.clone();
let end = s.doc.insert_text(&at, &text, &props)?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else {
let (a, b) = s.sel.ordered();
s.doc.format_range(&a, &b, &|c| {
c.link = Some(url.clone());
c.style = style.clone();
})?;
}
sel_result(s)
}
fn bookmark(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim().to_string();
if name.is_empty() || name.contains(' ') || name.len() > 40 || name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Err(CmdError::Params("bookmark names start with a letter, have no spaces and at most 40 characters".into()));
}
// Replace an existing bookmark of the same name.
for (_, pos) in s.doc.bookmarks().into_iter().filter(|(n, _)| *n == name).rev() {
let para = s.doc.para_mut(pos.story, &pos.path)?;
para.delete(pos.off, pos.off + wordcraft_doc::para::OBJ.len_utf8())?;
let offs: Vec<usize> = para.object_offsets();
if let Some(e) = offs.into_iter().find(|o| matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if *n == name)) {
para.delete(e, e + wordcraft_doc::para::OBJ.len_utf8())?;
}
}
let (a, b) = s.sel.ordered();
let props = CharProps::default();
let end = s.doc.insert_object(&b, InlineObject::BookmarkEnd { name: name.clone() }, &props)?;
s.doc.insert_object(&a, InlineObject::BookmarkStart { name: name.clone() }, &props)?;
let _ = end;
sel_result(s)
}
fn hf_part(s: &mut Session, header: bool) -> u32 {
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
let sect = s.doc.section_mut(block).clone();
let existing = if header { sect.headers.default } else { sect.footers.default };
if let Some(id) = existing.filter(|id| s.doc.parts.contains_key(id)) {
return id;
}
let style = if header { "Header" } else { "Footer" };
let id = s.doc.add_part(if header { PartKind::Header } else { PartKind::Footer }, vec![para_block(Paragraph::new().styled(style))]);
let sect = s.doc.section_mut(block);
if header {
sect.headers.default = Some(id);
} else {
sect.footers.default = Some(id);
}
id
}
fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult {
let id = hf_part(s, header);
let style = if header { "Header" } else { "Footer" };
if let Some(text) = p::str(v, "text") {
let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect();
s.doc.set_story(StoryRef::Part(id), blocks)?;
} else if let Some(preset) = p::str(v, "preset") {
let mut para = Paragraph::new().styled(style);
match preset {
"blankThree" => {
para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?;
}
"title" => {
para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?;
para.props.align = Some(Align::Center);
}
"pageNumber" => {
para.props.align = Some(Align::Center);
para.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default())?;
}
_ => {
para.insert_text(0, "[Type here]", &CharProps::default())?;
}
}
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Part(id)));
Ok(json!({"story": id}))
}
fn edit_hf(s: &mut Session, header: bool) -> CmdResult {
let id = hf_part(s, header);
s.touch();
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
Ok(json!({"story": id}))
}
fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let position = p::str(v, "position").unwrap_or("bottom");
let align = match p::str(v, "align").unwrap_or("center") {
"left" => Align::Left,
"right" => Align::Right,
_ => Align::Center,
};
let x_of_y = p::str(v, "format").is_some_and(|f| f.contains("of"));
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false };
if position == "current" {
let props = s.typing_props();
let at = delete_selection(s)?;
let end = s.doc.insert_object(&at, field, &props)?;
s.sel = Selection::caret(end);
return sel_result(s);
}
let header = position == "top";
let id = hf_part(s, header);
let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(end, InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false }, &CharProps { bold: Some(true), ..Default::default() })?;
} else {
para.insert_object(0, field, &CharProps::default())?;
}
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
Ok(json!({"story": id}))
}
fn date_time(s: &mut Session, v: &Value) -> CmdResult {
let fmt = p::str(v, "format").unwrap_or("M/d/yyyy");
let text = format_date(fmt);
if p::bool(v, "update").unwrap_or(false) {
let props = s.typing_props();
let at = delete_selection(s)?;
let end = s.doc.insert_object(&at, InlineObject::Field { instr: format!("DATE \\@ \"{fmt}\""), result: text, locked: false }, &props)?;
s.sel = Selection::caret(end);
} else {
type_text(s, &text)?;
}
sel_result(s)
}
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
pub fn format_date(fmt: &str) -> String {
let iso = super::now_iso();
let y: i64 = iso.get(0..4).and_then(|x| x.parse().ok()).unwrap_or(2026);
let m: usize = iso.get(5..7).and_then(|x| x.parse().ok()).unwrap_or(1);
let d: u32 = iso.get(8..10).and_then(|x| x.parse().ok()).unwrap_or(1);
let hh: u32 = iso.get(11..13).and_then(|x| x.parse().ok()).unwrap_or(0);
let mm: u32 = iso.get(14..16).and_then(|x| x.parse().ok()).unwrap_or(0);
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"];
let days_since = {
// Days since epoch for weekday.
let secs = {
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
{
0u64
}
};
(secs / 86_400) as usize
};
let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January");
let day = DAYS.get(days_since % 7).copied().unwrap_or("Monday");
let mut out = String::new();
let chars: Vec<char> = fmt.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars.get(i).copied().unwrap_or(' ');
let mut n = 1;
while chars.get(i + n) == Some(&c) {
n += 1;
}
match (c, n) {
('y', 4..) => out.push_str(&format!("{y:04}")),
('y', _) => out.push_str(&format!("{:02}", y % 100)),
('M', 4..) => out.push_str(month),
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
('M', 2) => out.push_str(&format!("{m:02}")),
('M', 1) => out.push_str(&m.to_string()),
('d', 4..) => out.push_str(day),
('d', 3) => out.push_str(day.get(..3).unwrap_or(day)),
('d', 2) => out.push_str(&format!("{d:02}")),
('d', 1) => out.push_str(&d.to_string()),
('H', 2..) => out.push_str(&format!("{hh:02}")),
('H', _) => out.push_str(&hh.to_string()),
('h', _) => out.push_str(&(if hh % 12 == 0 { 12 } else { hh % 12 }).to_string()),
('m', 2..) => out.push_str(&format!("{mm:02}")),
('m', _) => out.push_str(&mm.to_string()),
('a' | 'A', _) => out.push_str(if hh < 12 { "AM" } else { "PM" }),
_ => {
for _ in 0..n {
out.push(c);
}
}
}
i += n;
}
out
}
fn field(s: &mut Session, v: &Value) -> CmdResult {
let instr = p::req_str(v, "instr")?.trim().to_string();
if instr.is_empty() || instr.len() > 4096 {
return Err(CmdError::Params("bad field code".into()));
}
let result = p::str(v, "result").unwrap_or("").to_string();
let props = s.typing_props();
let at = delete_selection(s)?;
let end = s.doc.insert_object(&at, InlineObject::Field { instr, result, locked: false }, &props)?;
s.sel = Selection::caret(end);
let _ = super::references::update_fields(s);
sel_result(s)
}
fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
let title = p::str(v, "title").unwrap_or("Document Title").to_string();
let subtitle = p::str(v, "subtitle").unwrap_or("Document subtitle").to_string();
let author = p::str(v, "author").unwrap_or(&s.author).to_string();
let accent = Rgb(0x15, 0x60, 0x82);
let mut blocks = Vec::new();
for _ in 0..8 {
blocks.push(Block::Para(Paragraph::new()));
}
let mut t = Paragraph::with_text(&title, CharProps { size: Some(44.0), color: Some(TextColor::Rgb(accent)), ..Default::default() }).styled("Title");
t.props.borders = Some(wordcraft_doc::props::Borders {
bottom: Some(wordcraft_doc::props::Border { style: wordcraft_doc::props::BorderStyle::Single, width: 2.0, color: Some(accent), space: 6.0 }),
..Default::default()
});
blocks.push(Block::Para(t));
blocks.push(Block::Para(Paragraph::with_text(&subtitle, CharProps::default()).styled("Subtitle")));
for _ in 0..14 {
blocks.push(Block::Para(Paragraph::new()));
}
blocks.push(Block::Para(Paragraph::with_text(&author, CharProps { bold: Some(true), color: Some(TextColor::Rgb(accent)), ..Default::default() })));
let mut date = Paragraph::with_text(&format_date("MMMM d, yyyy"), CharProps::default());
date.insert_text(date.len(), "\u{000C}", &CharProps::default())?;
blocks.push(Block::Para(date));
let frag = wordcraft_doc::edit::Fragment { blocks };
let start = s.doc.start_of(StoryRef::Body);
let at = s.doc.split_paragraph(&start)?;
let _ = at;
s.doc.insert_fragment(&start, &frag)?;
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_round_trip() {
let data = b"hello world!?";
assert_eq!(base64_decode(&base64_encode(data)).unwrap(), data);
assert!(base64_decode("!!!").is_none());
}
#[test]
fn date_pictures() {
let d = format_date("yyyy-MM-dd");
assert_eq!(d.len(), 10);
assert!(format_date("MMMM d, yyyy").contains(", "));
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Command implementations, one module per area. Each module exposes `specs()`.
pub mod caret;
pub mod design;
pub mod edit;
pub mod file;
pub mod format;
pub mod insert;
pub mod page;
pub mod para;
pub mod references;
pub mod review;
pub mod table;
pub mod text;
pub mod view;
use serde_json::{Value, json};
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Pos, Revision, RevisionKind, StoryRef};
use crate::{CmdError, CmdResult, Registry, Session};
/// The full registry.
pub fn registry() -> Registry {
let mut v = Vec::new();
v.extend(text::specs());
v.extend(caret::specs());
v.extend(edit::specs());
v.extend(format::specs());
v.extend(para::specs());
v.extend(view::specs());
v.extend(insert::specs());
v.extend(page::specs());
v.extend(table::specs());
v.extend(review::specs());
v.extend(file::specs());
v.extend(design::specs());
v.extend(references::specs());
Registry::new(v)
}
/// Parse a position from JSON (`{"story":"body","path":[0],"off":3}` or `{"block":0,"off":3}`).
pub fn parse_pos(v: &Value) -> Option<Pos> {
if let Ok(p) = serde_json::from_value::<Pos>(v.clone()) {
return Some(p);
}
let block = v.get("block")?.as_u64()? as usize;
let off = v.get("off").and_then(Value::as_u64).unwrap_or(0) as usize;
Some(Pos::body(block, off))
}
pub fn pos_json(p: &Pos) -> Value {
serde_json::to_value(p).unwrap_or(Value::Null)
}
/// The standard result: the selection after the command.
pub fn sel_result(s: &Session) -> CmdResult {
Ok(json!({"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus)}))
}
/// Delete the selection (respecting track changes); returns the collapsed position.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(a);
}
let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? };
s.sel = crate::Selection::caret(at.clone());
Ok(at)
}
/// A revision record for the current author.
pub fn new_revision(s: &mut Session, kind: RevisionKind) -> u32 {
let date = now_iso();
if let Some((i, _)) = s.doc.revisions.iter().enumerate().rev().find(|(_, r)| r.kind == kind && r.author == s.author && r.date == date) {
return i as u32;
}
s.doc.revisions.push(Revision { kind, author: s.author.clone(), date });
(s.doc.revisions.len() - 1) as u32
}
/// Tracked deletion: own insertions are removed, other text is marked deleted.
fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
let rid = new_revision(s, RevisionKind::Delete);
let author = s.author.clone();
// Remove text this author inserted (it never existed for the reader); mark the rest.
let paths = s.doc.paths_between(a, b);
for path in paths.iter().rev() {
let Some(p) = s.doc.para(a.story, path) else { continue };
let from = if *path == a.path { a.off } else { 0 };
let to = if *path == b.path { b.off } else { p.len() };
let ranges: Vec<(usize, usize, bool)> = p
.run_ranges()
.filter(|(r, _)| r.end > from && r.start < to)
.map(|(r, c)| {
let own = c.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == author);
(r.start.max(from), r.end.min(to), own)
})
.collect();
let para = s.doc.para_mut(a.story, path)?;
for (x, y, own) in ranges.into_iter().rev() {
if own {
para.delete(x, y)?;
} else {
para.format(x, y, &|c| c.del = Some(rid))?;
}
}
}
Ok(a.clone())
}
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
let mut props = s.typing_props();
delete_selection(s)?;
if s.doc.settings.track_changes {
props.ins = Some(new_revision(s, RevisionKind::Insert));
props.del = None;
} else {
props.ins = None;
props.del = None;
}
let mut at = s.sel.focus.clone();
let mut first = true;
for part in text.split(['\r', '\u{2029}']) {
if !first {
at = split_para(s, &at)?;
}
first = false;
let part = part.replace('\u{000B}', "\n");
if !part.is_empty() {
at = s.doc.insert_text(&at, &part, &props)?;
}
}
s.sel = crate::Selection::caret(at);
s.goal_x = None;
Ok(())
}
/// Split the paragraph at `at` like Enter does: an empty list paragraph leaves the list, the
/// next paragraph gets the style's "next" style when Enter is at the end.
pub fn split_para(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
let (at_end, style, empty_list) = match s.doc.para_at(at) {
Some(p) => (at.off >= p.len(), p.props.style.clone(), p.is_empty() && p.props.numbering.is_some()),
None => return Err(CmdError::Failed("no paragraph at caret".into())),
};
if empty_list {
// Enter on an empty list item ends the list (Word behaviour).
let para = s.doc.para_mut(at.story, &at.path)?;
para.props.numbering = Some(wordcraft_doc::props::NumRef { num: 0, level: 0 });
para.props.indent_left = None;
para.props.indent_first = None;
para.touch();
return Ok(at.clone());
}
let new = s.doc.split_paragraph(at)?;
if at_end && let Some(st) = style.as_deref() {
let next = s.doc.styles.get(st).and_then(|x| x.next.clone());
if let Some(n) = next
&& n != st
{
let p = s.doc.para_mut(new.story, &new.path)?;
p.props.style = Some(n);
p.props.numbering = None;
// A heading's character formatting doesn't carry into body text.
p.mark = CharProps::default();
}
}
if s.doc.settings.track_changes {
let rid = new_revision(s, RevisionKind::Insert);
let p = s.doc.para_mut(at.story, &at.path)?;
p.mark.ins = Some(rid);
}
Ok(new)
}
/// ISO-8601 timestamp (UTC, seconds).
pub fn now_iso() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let days = (secs / 86_400) as i64;
let (y, m, d) = civil_from_days(days);
let t = secs % 86_400;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60)
}
#[cfg(target_arch = "wasm32")]
{
"2026-01-01T00:00:00Z".to_string()
}
}
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's.
pub fn story_param(s: &Session, v: &Value) -> StoryRef {
v.get("story").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or(s.sel.focus.story)
}
+211
View File
@@ -0,0 +1,211 @@
//! Layout tab: page setup (margins, orientation, size, columns), breaks, line numbers, hyphenation.
use serde_json::{Value, json};
use wordcraft_doc::section::{Columns, LineNumbering, SectionProps, SectionStart};
use wordcraft_doc::{Block, Pos};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("layout.margins", "Margins", "Layout › Page Setup", margins)
.params(r#"{"preset"?: "normal|narrow|moderate|wide|mirrored|office2003", "top"?: pt, "bottom"?: pt, "left"?: pt, "right"?: pt, "gutter"?: pt}"#),
CommandSpec::new("layout.orientation", "Orientation", "Layout › Page Setup", |s, v| {
let land = match p::str(v, "value") {
Some("landscape") => true,
Some("portrait") => false,
_ => !sect(s).landscape,
};
with_sect(s, |x| x.set_landscape(land))
})
.params(r#"{"value": "portrait|landscape"}"#),
CommandSpec::new("layout.size", "Size", "Layout › Page Setup", size).params(r#"{"name"?: "Letter|Legal|A4|…", "width"?: pt, "height"?: pt}"#),
CommandSpec::new("layout.columns", "Columns", "Layout › Page Setup", columns).params(r#"{"count": 1-12, "space"?: pt, "separator"?: bool, "preset"?: "left|right"}"#),
CommandSpec::new("layout.break", "Breaks", "Layout › Page Setup", breaks).params(r#"{"kind": "page|column|textWrapping|nextPage|continuous|evenPage|oddPage"}"#),
CommandSpec::new("layout.lineNumbers", "Line Numbers", "Layout › Page Setup", |s, v| {
let mode = p::str(v, "value").unwrap_or("continuous");
with_sect(s, |x| {
x.line_numbers = match mode {
"none" => None,
"restartPage" => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Page, ..Default::default() }),
"restartSection" => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Section, ..Default::default() }),
_ => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Continuous, ..Default::default() }),
}
})
})
.params(r#"{"value": "none|continuous|restartPage|restartSection"}"#),
CommandSpec::new("layout.hyphenation", "Hyphenation", "Layout › Page Setup", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.doc.settings.auto_hyphenation);
s.doc.settings.auto_hyphenation = on;
Ok(json!({"value": on}))
}),
CommandSpec::new("layout.pageSetup", "Page Setup", "Layout › Page Setup", |s, v| {
if let Some(props) = v.get("section") {
let new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?;
return with_sect(s, |x| *x = new.clone());
}
s.ui_requests.push(json!({"open": "pageSetup"}));
Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))
})
.params(r#"{"section"?: SectionProps}"#),
CommandSpec::new("layout.verticalAlign", "Vertical Alignment", "Layout › Page Setup › Layout", |s, v| {
let va = match p::str(v, "value") {
Some("center") => wordcraft_doc::props::VAlign::Center,
Some("bottom") => wordcraft_doc::props::VAlign::Bottom,
_ => wordcraft_doc::props::VAlign::Top,
};
with_sect(s, |x| x.valign = va)
}),
CommandSpec::new("layout.differentFirstPage", "Different First Page", "Header & Footer › Options", |s, v| {
let on = p::bool(v, "value").unwrap_or(!sect(s).title_page);
with_sect(s, |x| x.title_page = on)
}),
CommandSpec::new("layout.differentOddEven", "Different Odd & Even Pages", "Header & Footer › Options", |s, v| {
s.doc.settings.even_odd_headers = p::bool(v, "value").unwrap_or(!s.doc.settings.even_odd_headers);
sel_result(s)
}),
CommandSpec::new("layout.pageNumberFormat", "Format Page Numbers", "Insert › Header & Footer › Page Number", |s, v| {
let fmt = p::str(v, "format").map(wordcraft_doc::section::NumFormat::from_ooxml);
let start = p::u64(v, "start").map(|x| x.min(100_000) as u32);
with_sect(s, |x| {
if let Some(f) = fmt {
x.page_num_format = f;
}
x.page_num_start = start;
})
})
.params(r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n}"#),
CommandSpec::new("layout.section", "Section Properties", "Layout › Page Setup", |s, _| Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))).pure(),
]
}
fn block_of(s: &Session) -> usize {
s.sel.focus.path.0.first().copied().unwrap_or(0) as usize
}
pub fn sect(s: &Session) -> SectionProps {
let i = s.doc.section_index_of(block_of(s));
s.doc.sections().get(i).map(|(_, x)| (*x).clone()).unwrap_or_default()
}
/// Apply to the section(s) of the selection.
fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdResult {
let (a, b) = s.sel.ordered();
let (i0, i1) = (a.path.0.first().copied().unwrap_or(0) as usize, b.path.0.first().copied().unwrap_or(0) as usize);
let ends: Vec<usize> = s.doc.sections().iter().map(|(e, _)| *e).collect();
let first = ends.iter().position(|e| i0 <= *e).unwrap_or(0);
let last = ends.iter().position(|e| i1 <= *e).unwrap_or(first);
for k in first..=last {
let block = ends.get(k).copied().unwrap_or(0);
f(s.doc.section_mut(block));
}
Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))
}
fn margins(s: &mut Session, v: &Value) -> CmdResult {
let preset = p::str(v, "preset");
let (t, b, l, r) = match preset {
Some("normal") => (72.0, 72.0, 72.0, 72.0),
Some("narrow") => (36.0, 36.0, 36.0, 36.0),
Some("moderate") => (72.0, 72.0, 54.0, 54.0),
Some("wide") => (72.0, 72.0, 144.0, 144.0),
Some("mirrored") => (72.0, 72.0, 90.0, 72.0),
Some("office2003") => (72.0, 72.0, 90.0, 90.0),
Some(x) => return Err(CmdError::Params(format!("unknown preset `{x}`"))),
None => {
let c = sect(s);
(
p::f32(v, "top").unwrap_or(c.margin_top),
p::f32(v, "bottom").unwrap_or(c.margin_bottom),
p::f32(v, "left").unwrap_or(c.margin_left),
p::f32(v, "right").unwrap_or(c.margin_right),
)
}
};
let gutter = p::f32(v, "gutter");
let cur = sect(s);
if l + r + gutter.unwrap_or(cur.gutter) > cur.page_w - 36.0 || t + b > cur.page_h - 36.0 {
return Err(CmdError::Params("margins leave no room for text".into()));
}
s.doc.settings.mirror_margins = preset == Some("mirrored");
with_sect(s, |x| {
x.margin_top = t.max(0.0);
x.margin_bottom = b.max(0.0);
x.margin_left = l.max(0.0);
x.margin_right = r.max(0.0);
if let Some(g) = gutter {
x.gutter = g.max(0.0);
}
})
}
fn size(s: &mut Session, v: &Value) -> CmdResult {
let (w, h) = if let Some(n) = p::str(v, "name") {
let (_, w, h) = wordcraft_geom::PAPER_SIZES.iter().find(|(name, _, _)| name.eq_ignore_ascii_case(n)).ok_or_else(|| CmdError::Params(format!("unknown paper `{n}`")))?;
(*w, *h)
} else {
(p::req_f32(v, "width")?, p::req_f32(v, "height")?)
};
if !(72.0..=1584.0).contains(&w) || !(72.0..=1584.0).contains(&h) {
return Err(CmdError::Params("page size must be 1\"–22\"".into()));
}
with_sect(s, |x| {
let (pw, ph) = if x.landscape { (w.max(h), w.min(h)) } else { (w.min(h), w.max(h)) };
x.page_w = pw;
x.page_h = ph;
})
}
fn columns(s: &mut Session, v: &Value) -> CmdResult {
let preset = p::str(v, "preset");
let count = p::u64(v, "count").unwrap_or(match preset {
Some("left") | Some("right") => 2,
_ => 1,
});
if !(1..=12).contains(&count) {
return Err(CmdError::Params("columns must be 1–12".into()));
}
let space = p::f32(v, "space").unwrap_or(36.0).clamp(0.0, 300.0);
let sep = p::bool(v, "separator").unwrap_or(false);
let tw = sect(s).text_width();
let widths = match preset {
Some("left") => vec![((tw - space) / 3.0, space), ((tw - space) * 2.0 / 3.0, 0.0)],
Some("right") => vec![((tw - space) * 2.0 / 3.0, space), ((tw - space) / 3.0, 0.0)],
_ => Vec::new(),
};
with_sect(s, |x| x.columns = Columns { count: count as u32, space, separator: sep, widths: widths.clone() })
}
fn breaks(s: &mut Session, v: &Value) -> CmdResult {
let kind = p::str(v, "kind").unwrap_or("page");
let start = match kind {
"page" => return super::text::specs().iter().find(|c| c.id == "text.pageBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)),
"column" => return super::text::specs().iter().find(|c| c.id == "text.columnBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)),
"textWrapping" => return super::text::specs().iter().find(|c| c.id == "text.lineBreak").map(|c| (c.run)(s, v)).unwrap_or_else(|| sel_result(s)),
"nextPage" => SectionStart::NextPage,
"continuous" => SectionStart::Continuous,
"evenPage" => SectionStart::EvenPage,
"oddPage" => SectionStart::OddPage,
x => return Err(CmdError::Params(format!("unknown break `{x}`"))),
};
if s.sel.focus.story != wordcraft_doc::StoryRef::Body || s.sel.focus.path.depth() > 0 {
return Err(CmdError::Failed("section breaks go in the main text".into()));
}
// Section break: the paragraph before the caret ends a section with the current props; the
// new section (after) takes them too and starts as requested.
let at = super::delete_selection(s)?;
let cur = sect(s);
let new = s.doc.split_paragraph(&at)?;
let ended = at.path.clone();
let para = s.doc.para_mut(at.story, &ended)?;
let mut props = cur;
// The start type belongs to the section that begins after the break.
let next_block = new.path.last();
para.section = Some(Box::new(props.clone()));
props.start = start;
*s.doc.section_mut(next_block) = props;
s.sel = Selection::caret(Pos { off: 0, ..new });
let _ = Block::Para;
sel_result(s)
}
+521
View File
@@ -0,0 +1,521 @@
//! Paragraph formatting, lists and styles (Home › Paragraph, Home › Styles).
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::styles::{Style, StyleKind};
use wordcraft_doc::{Block, Pos};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("para.alignLeft", "Align Left", "Home › Paragraph", |s, _| align(s, Align::Left)).key("Mod+L"),
CommandSpec::new("para.alignCenter", "Center", "Home › Paragraph", |s, _| align(s, Align::Center)).key("Mod+E"),
CommandSpec::new("para.alignRight", "Align Right", "Home › Paragraph", |s, _| align(s, Align::Right)).key("Mod+R"),
CommandSpec::new("para.justify", "Justify", "Home › Paragraph", |s, _| align(s, Align::Justify)).key("Mod+J"),
CommandSpec::new("para.distribute", "Distributed", "Home › Paragraph", |s, _| align(s, Align::Distribute)).key("Mod+Shift+J"),
CommandSpec::new("para.align", "Alignment", "Home › Paragraph", |s, v| {
let a = match p::req_str(v, "value")? {
"left" => Align::Left,
"center" => Align::Center,
"right" => Align::Right,
"justify" | "both" => Align::Justify,
"distribute" => Align::Distribute,
x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))),
};
align(s, a)
})
.params(r#"{"value": "left|center|right|justify|distribute"}"#),
CommandSpec::new("para.indent", "Increase Indent", "Home › Paragraph", indent).key("Mod+M"),
CommandSpec::new("para.outdent", "Decrease Indent", "Home › Paragraph", outdent).key("Mod+Shift+M"),
CommandSpec::new("para.hangingIndent", "Hanging Indent", "Home › Paragraph › Paragraph", |s, _| {
fmt(s, &|p| {
let l = p.indent_left.unwrap_or(0.0) + 36.0;
p.indent_left = Some(l);
p.indent_first = Some(-36.0);
})
})
.key("Mod+T"),
CommandSpec::new("para.removeHanging", "Reduce Hanging Indent", "Home › Paragraph › Paragraph", |s, _| {
fmt(s, &|p| {
p.indent_left = Some((p.indent_left.unwrap_or(0.0) - 36.0).max(0.0));
p.indent_first = Some(0.0);
})
})
.key("Mod+Shift+T"),
CommandSpec::new("para.indents", "Indents", "Layout › Paragraph", |s, v| {
let (l, r, f) = (p::f32(v, "left"), p::f32(v, "right"), p::f32(v, "firstLine"));
fmt(s, &|p| {
if let Some(x) = l {
p.indent_left = Some(x.clamp(-1584.0, 1584.0));
}
if let Some(x) = r {
p.indent_right = Some(x.clamp(-1584.0, 1584.0));
}
if let Some(x) = f {
p.indent_first = Some(x.clamp(-1584.0, 1584.0));
}
})
})
.params(r#"{"left"?: pt, "right"?: pt, "firstLine"?: pt (negative = hanging)}"#),
CommandSpec::new("para.lineSpacing", "Line and Paragraph Spacing", "Home › Paragraph", line_spacing)
.params(r#"{"value": number (multiple) } | {"atLeast": pt} | {"exactly": pt}"#),
CommandSpec::new("para.single", "Single Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(1.0))).key("Mod+1"),
CommandSpec::new("para.double", "Double Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(2.0))).key("Mod+2"),
CommandSpec::new("para.oneAndHalf", "1.5 Line Spacing", "Home › Paragraph › Line Spacing", |s, _| ls(s, LineSpacing::Multiple(1.5))).key("Mod+5"),
CommandSpec::new("para.spacing", "Paragraph Spacing", "Layout › Paragraph", |s, v| {
let (b, a) = (p::f32(v, "before"), p::f32(v, "after"));
fmt(s, &|p| {
if let Some(x) = b {
p.space_before = Some(x.clamp(0.0, 1584.0));
}
if let Some(x) = a {
p.space_after = Some(x.clamp(0.0, 1584.0));
}
})
})
.params(r#"{"before"?: pt, "after"?: pt}"#),
CommandSpec::new("para.addSpaceBefore", "Add Space Before Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
let has = cur(s).space_before > 0.0;
fmt(s, &|p| p.space_before = Some(if has { 0.0 } else { 12.0 }))
})
.key("Mod+0"),
CommandSpec::new("para.removeSpaceAfter", "Remove Space After Paragraph", "Home › Paragraph › Line Spacing", |s, _| {
let has = cur(s).space_after > 0.0;
fmt(s, &|p| p.space_after = Some(if has { 0.0 } else { 8.0 }))
}),
CommandSpec::new("para.style", "Apply Style", "Home › Styles", apply_style).params(r#"{"style": string (name or id)}"#).key("Mod+Shift+S"),
CommandSpec::new("para.normal", "Normal Style", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Normal"}))).key("Mod+Shift+N"),
CommandSpec::new("para.heading1", "Heading 1", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading1"}))).key("Mod+Alt+1"),
CommandSpec::new("para.heading2", "Heading 2", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading2"}))).key("Mod+Alt+2"),
CommandSpec::new("para.heading3", "Heading 3", "Home › Styles", |s, _| apply_style(s, &json!({"style": "Heading3"}))).key("Mod+Alt+3"),
CommandSpec::new("para.bullets", "Bullets", "Home › Paragraph", |s, v| list(s, v, ListKind::Bullet)).params(r#"{"kind"?: "bullet" | single char, "off"?: bool}"#).key("Mod+Shift+L"),
CommandSpec::new("para.numbering", "Numbering", "Home › Paragraph", |s, v| list(s, v, ListKind::Numbered))
.params(r#"{"kind"?: "numbered|numberedParen|upperLetter|lowerLetter|lowerRoman|outline", "off"?: bool}"#),
CommandSpec::new("para.multilevel", "Multilevel List", "Home › Paragraph", |s, v| list(s, v, ListKind::Legal)).params(r#"{"kind"?: "legal|outline"}"#),
CommandSpec::new("para.listLevel", "Change List Level", "Home › Paragraph › Bullets", |s, v| {
let lvl = p::u64(v, "level").unwrap_or(0).min(8) as u8;
fmt(s, &|p| {
if let Some(n) = p.numbering.as_mut() {
n.level = lvl;
}
})
})
.params(r#"{"level": 0-8}"#),
CommandSpec::new("para.restartNumbering", "Restart at 1", "Home › Paragraph › Numbering", restart_numbering),
CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
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.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))),
CommandSpec::new("para.pageBreakBefore", "Page Break Before", "Home › Paragraph › Line and Page Breaks", |s, v| {
tog(s, v, |p| p.page_break_before, |p, b| p.page_break_before = Some(b))
}),
CommandSpec::new("para.widowControl", "Widow/Orphan Control", "Home › Paragraph › Line and Page Breaks", |s, v| {
tog(s, v, |p| p.widow_control, |p, b| p.widow_control = Some(b))
}),
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.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)
}),
CommandSpec::new("para.set", "Set Paragraph Formatting", "Home › Paragraph › Paragraph", |s, v| {
let props: ParaProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
fmt(s, &|p| p.overlay(&props))
})
.params(r#"{"props": ParaProps}"#),
CommandSpec::new("para.dialog", "Paragraph Settings", "Home › Paragraph", |s, _| {
s.ui_requests.push(json!({"open": "paragraph"}));
sel_result(s)
})
.pure(),
CommandSpec::new("styles.create", "Create a Style", "Home › Styles", create_style).params(r#"{"name": string, "basedOn"?: string, "fromSelection"?: bool}"#),
CommandSpec::new("styles.modify", "Modify Style", "Home › Styles", modify_style).params(r#"{"style": string, "chr"?: CharProps, "para"?: ParaProps, "name"?: string, "next"?: string}"#),
CommandSpec::new("styles.updateToMatch", "Update Style to Match Selection", "Home › Styles", update_to_match).params(r#"{"style"?: string}"#),
CommandSpec::new("styles.delete", "Delete Style", "Home › Styles", |s, v| {
let id = style_id(s, p::req_str(v, "style")?)?;
if s.doc.styles.get(&id).is_some_and(|st| st.builtin) {
return Err(CmdError::Failed("built-in styles can't be deleted".into()));
}
s.doc.styles.styles.retain(|st| st.id != id);
sel_result(s)
}),
CommandSpec::new("styles.list", "Styles", "Home › Styles", |s, _| {
Ok(Value::Array(
s.doc.styles.styles.iter().map(|st| json!({"id": st.id, "name": st.name, "kind": st.kind, "quick": st.quick, "basedOn": st.based_on})).collect(),
))
})
.pure(),
CommandSpec::new("styles.pane", "Styles Pane", "Home › Styles", |s, _| {
s.view.styles_pane = !s.view.styles_pane;
sel_result(s)
})
.key("Mod+Alt+Shift+S")
.pure(),
CommandSpec::new("styles.addToGallery", "Add to Style Gallery", "Home › Styles", |s, v| {
let id = style_id(s, p::req_str(v, "style")?)?;
let on = p::bool(v, "value").unwrap_or(true);
if let Some(st) = s.doc.styles.get_mut(&id) {
st.quick = on;
}
sel_result(s)
}),
]
}
/// Resolved paragraph props at the caret.
fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
let props = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
s.doc.styles.resolve_para(&props)
}
pub fn fmt(s: &mut Session, f: &dyn Fn(&mut ParaProps)) -> CmdResult {
let (a, b) = s.sel.ordered();
s.doc.format_paragraphs(&a, &b, f)?;
sel_result(s)
}
fn align(s: &mut Session, a: Align) -> CmdResult {
// Clicking the active alignment again returns to left (Word).
let now = cur(s).align;
let target = if now == a && a != Align::Left { Align::Left } else { a };
fmt(s, &|p| p.align = Some(target))
}
fn tog(s: &mut Session, v: &Value, get: fn(&ParaProps) -> Option<bool>, set: fn(&mut ParaProps, bool)) -> CmdResult {
let on = p::bool(v, "value").unwrap_or_else(|| {
let r = cur(s);
let props = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default();
let _ = r;
!get(&props).unwrap_or(false)
});
fmt(s, &|p| set(p, on))
}
pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
let paths = s.doc.paths_between(&a, &b);
let tab = s.doc.settings.default_tab.max(1.0);
for path in paths {
let (num, left) = {
let Some(p) = s.doc.para(a.story, &path) else { continue };
(p.props.numbering.filter(|n| n.num != 0), s.doc.styles.resolve_para(&p.props).indent_left)
};
let para = s.doc.para_mut(a.story, &path)?;
match num {
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
None => para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab),
}
para.touch();
}
sel_result(s)
}
pub fn outdent(s: &mut Session, _: &Value) -> CmdResult {
let (a, b) = s.sel.ordered();
let paths = s.doc.paths_between(&a, &b);
let tab = s.doc.settings.default_tab.max(1.0);
for path in paths {
let (num, left) = {
let Some(p) = s.doc.para(a.story, &path) else { continue };
(p.props.numbering.filter(|n| n.num != 0), s.doc.styles.resolve_para(&p.props).indent_left)
};
let para = s.doc.para_mut(a.story, &path)?;
match num {
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_sub(1) }),
None => para.props.indent_left = Some((((left / tab).ceil() - 1.0) * tab).max(0.0)),
}
para.touch();
}
sel_result(s)
}
fn ls(s: &mut Session, l: LineSpacing) -> CmdResult {
fmt(s, &|p| p.line_spacing = Some(l))
}
fn line_spacing(s: &mut Session, v: &Value) -> CmdResult {
let l = if let Some(x) = p::f32(v, "atLeast") {
LineSpacing::AtLeast(x.clamp(0.0, 1584.0))
} else if let Some(x) = p::f32(v, "exactly") {
LineSpacing::Exactly(x.clamp(0.7, 1584.0))
} else {
LineSpacing::Multiple(p::req_f32(v, "value")?.clamp(0.06, 132.0))
};
ls(s, l)
}
fn style_id(s: &Session, name: &str) -> Result<String, CmdError> {
s.doc.styles.find(name).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no style `{name}`")))
}
fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "style")?;
let id = style_id(s, name)?;
let kind = s.doc.styles.get(&id).map(|x| x.kind).unwrap_or_default();
if kind == StyleKind::Character {
return super::format::apply(s, &|c| c.style = Some(id.clone()));
}
let (a, b) = s.sel.ordered();
s.doc.format_paragraphs(&a, &b, &|p| {
p.style = Some(id.clone());
// Applying a heading or Normal removes direct list numbering (except list styles).
if id.starts_with("Heading") || id == "Title" || id == "Normal" {
if p.numbering.is_some_and(|n| n.num != 0) && id == "Normal" {
p.numbering = None;
}
}
})?;
// A paragraph style resets direct character formatting that matches nothing (Word keeps
// direct formatting unless it covers most of the paragraph; we clear font/size/colour).
if a.path == b.path || a == b {
let paths = s.doc.paths_between(&a, &b);
for path in paths {
let para = s.doc.para_mut(a.story, &path)?;
let len = para.len();
para.format(0, len, &|c| {
c.font = None;
c.size = None;
c.color = None;
})?;
para.mark.font = None;
para.mark.size = None;
para.mark.color = None;
}
}
s.pending = None;
sel_result(s)
}
fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default);
let off = p::bool(v, "off").unwrap_or(false);
let (a, b) = s.sel.ordered();
// Toggle: if every selected paragraph already has this kind of list, remove it.
let current: Vec<Option<NumRef>> = s.doc.paths_between(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect();
let same_kind = |n: &NumRef| {
s.doc.numbering.level(n.num, 0).is_some_and(|l| {
let want = wordcraft_doc::numbering::levels_for(kind);
want.first().is_some_and(|w| (w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet) && (p::str(v, "kind").is_none() || w.text == l.text))
})
};
let remove = off || (!current.is_empty() && current.iter().all(|n| n.as_ref().is_some_and(same_kind)));
if remove {
return fmt(s, &|p| {
p.numbering = Some(NumRef { num: 0, level: 0 });
p.indent_left = None;
p.indent_first = None;
if p.style.as_deref() == Some("ListParagraph") {
p.style = None;
}
});
}
// Continue the list of the previous paragraph if it has the same kind; else a new list.
let prev_num = s.doc.prev_para(a.story, &a.path).and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering)).filter(|n| n.num != 0 && same_kind(n));
let num = match prev_num {
Some(n) => n.num,
None => match kind {
ListKind::Bullet | ListKind::BulletChar(_) => s.doc.numbering.find_kind(kind).unwrap_or_else(|| s.doc.numbering.add_list(kind)),
_ => s.doc.numbering.add_list(kind),
},
};
fmt(s, &|p| {
let level = p.numbering.filter(|n| n.num != 0).map(|n| n.level).unwrap_or(0);
p.numbering = Some(NumRef { num, level });
p.indent_left = None;
p.indent_first = None;
if p.style.is_none() || p.style.as_deref() == Some("Normal") {
p.style = Some("ListParagraph".into());
}
})
}
fn restart_numbering(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
let Some(n) = s.doc.para_at(&f).and_then(|p| p.props.numbering).filter(|n| n.num != 0) else {
return Err(CmdError::Failed("not in a numbered list".into()));
};
let new = s.doc.numbering.restart(n.num).ok_or_else(|| CmdError::Failed("bad list".into()))?;
// This paragraph and the following ones of the same list use the new instance.
let paths: Vec<_> = s.doc.para_paths(f.story).into_iter().filter(|p| *p >= f.path).collect();
for path in paths {
let para = s.doc.para_mut(f.story, &path)?;
match para.props.numbering {
Some(x) if x.num == n.num => {
para.props.numbering = Some(NumRef { num: new, level: x.level });
para.touch();
}
_ => {
if para.props.numbering.is_none() && !para.is_empty() {
break;
}
}
}
}
sel_result(s)
}
fn borders(s: &mut Session, v: &Value) -> CmdResult {
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);
let style = p::str(v, "style").map(BorderStyle::from_ooxml).unwrap_or(BorderStyle::Single);
let b = Border { style, width, color, space: if kind == "left" || kind == "right" { 4.0 } else { 1.0 } };
if kind == "horizontalLine" {
// A paragraph with a bottom border, like Word's Horizontal Line.
return fmt(s, &|p| p.borders = Some(Borders { bottom: Some(Border { width: 1.5, ..b }), ..Default::default() }));
}
fmt(s, &|p| {
let mut cur = p.borders.unwrap_or_default();
match kind {
"none" => cur = Borders::default(),
"all" => cur = Borders::all(b),
"outside" => {
cur.top = Some(b);
cur.bottom = Some(b);
cur.left = Some(b);
cur.right = Some(b);
}
"inside" => cur.between = Some(b),
"top" => cur.top = if cur.top.is_some_and(|x| x.is_visible()) { None } else { Some(b) },
"bottom" => cur.bottom = if cur.bottom.is_some_and(|x| x.is_visible()) { None } else { Some(b) },
"left" => cur.left = if cur.left.is_some_and(|x| x.is_visible()) { None } else { Some(b) },
"right" => cur.right = if cur.right.is_some_and(|x| x.is_visible()) { None } else { Some(b) },
_ => {}
}
p.borders = if cur.any_visible() { Some(cur) } else { Some(Borders::default()) };
})
}
/// Sort the selected paragraphs (same container) alphabetically.
fn sort(s: &mut Session, v: &Value) -> CmdResult {
let desc = p::bool(v, "descending").unwrap_or(false);
let (a, b) = s.sel.ordered();
if a.path.parent() != b.path.parent() {
return Err(CmdError::Failed("select paragraphs in one place to sort".into()));
}
let (i0, i1) = (a.path.last(), b.path.last());
let bl = s.doc.container_mut(a.story, &a.path)?;
let Some(slice) = bl.get_mut(i0..=i1) else { return Err(CmdError::Failed("bad range".into())) };
if slice.iter().any(|b| !matches!(**b, Block::Para(_))) {
return Err(CmdError::Failed("sorting tables isn't supported here; use Table › Sort".into()));
}
let key = |b: &std::sync::Arc<Block>| b.as_para().map(|p| p.plain_text().to_lowercase()).unwrap_or_default();
slice.sort_by(|x, y| {
let o = natural_cmp(&key(x), &key(y));
if desc { o.reverse() } else { o }
});
// Keep a section break on the last paragraph of the range where it was.
s.sel = crate::Selection { anchor: Pos { off: 0, ..a }, focus: b };
sel_result(s)
}
/// Compare strings with embedded numbers numerically ("item 2" < "item 10").
fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {
let num = |s: &str| s.trim().split(|c: char| !c.is_ascii_digit()).next().and_then(|n| n.parse::<f64>().ok());
match (num(a), num(b)) {
(Some(x), Some(y)) if x != y => x.total_cmp(&y),
_ => a.cmp(b),
}
}
fn create_style(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim().to_string();
if name.is_empty() {
return Err(CmdError::Params("name is empty".into()));
}
if s.doc.styles.find(&name).is_some() {
return Err(CmdError::Failed(format!("a style named `{name}` already exists")));
}
let id = s.doc.styles.new_id(&name);
let based = p::str(v, "basedOn").and_then(|b| s.doc.styles.find(b).map(|x| x.id.clone())).or(Some("Normal".into()));
let mut st = Style { id: id.clone(), name, kind: StyleKind::Paragraph, based_on: based, next: Some(id.clone()), quick: true, priority: Some(50), ..Default::default() };
if p::bool(v, "fromSelection").unwrap_or(true)
&& let Some(para) = s.doc.para_at(&s.sel.focus)
{
st.para = para.props.clone();
st.para.style = None;
st.para.numbering = None;
st.chr = para.props_at(s.sel.focus.off).clone();
st.chr.style = None;
st.chr.link = None;
st.chr.ins = None;
st.chr.del = None;
}
s.doc.styles.upsert(st);
apply_style(s, &json!({"style": id}))?;
Ok(json!({"id": id}))
}
fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
let id = style_id(s, p::req_str(v, "style")?)?;
let chr: Option<wordcraft_doc::CharProps> = v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
let para: Option<ParaProps> = v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;
let next = p::str(v, "next").map(str::to_string);
let name = p::str(v, "name").map(str::to_string);
let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;
if let Some(c) = chr {
st.chr.overlay(&c);
}
if let Some(pp) = para {
st.para.overlay(&pp);
}
if let Some(n) = next {
st.next = Some(n);
}
if let Some(n) = name.filter(|n| !n.trim().is_empty()) {
st.name = n;
}
// Every paragraph's layout depends on styles: bump all revisions.
touch_all(s);
sel_result(s)
}
fn update_to_match(s: &mut Session, v: &Value) -> CmdResult {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f).cloned() else { return sel_result(s) };
let id = match p::str(v, "style") {
Some(n) => style_id(s, n)?,
None => para.props.style.clone().unwrap_or_else(|| "Normal".into()),
};
let mut chr = para.props_at(f.off).clone();
chr.style = None;
chr.link = None;
chr.ins = None;
chr.del = None;
let mut pp = para.props.clone();
pp.style = None;
pp.numbering = None;
if let Some(st) = s.doc.styles.get_mut(&id) {
st.chr.overlay(&chr);
st.para.overlay(&pp);
}
// Remove the now-redundant direct formatting from the paragraph.
let p = s.doc.para_mut(f.story, &f.path)?;
let len = p.len();
p.format(0, len, &|c| *c = c.cleared())?;
let style = p.props.style.clone();
p.props = ParaProps { style, numbering: p.props.numbering, ..Default::default() };
touch_all(s);
sel_result(s)
}
/// Style edits re-lay out every paragraph: the layout cache is keyed on the style sheet, so
/// nothing needs touching here (kept as the one place to hook style-change side effects).
pub fn touch_all(s: &mut Session) {
s.dirty = true;
}
+273
View File
@@ -0,0 +1,273 @@
//! References tab: table of contents, footnotes/endnotes, captions; field updates.
use serde_json::{Value, json};
use wordcraft_doc::para::{InlineObject, NoteKind};
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, para_block};
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc).params(r#"{"levels"?: 1-9, "title"?: string}"#),
CommandSpec::new("references.updateToc", "Update Table", "References › Table of Contents", |s, _| {
update_toc(s)?;
sel_result(s)
}),
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
if let Some((start, end)) = toc_range(s) {
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
s.clamp_selection();
}
sel_result(s)
}),
CommandSpec::new("references.addText", "Add Text", "References › Table of Contents", |s, v| {
let level = p::u64(v, "level").unwrap_or(0).min(9) as u8;
super::para::fmt(s, &|pp| pp.outline_level = if level == 0 { Some(9) } else { Some(level - 1) })
})
.params(r#"{"level": 0 (do not show) | 1-9}"#),
CommandSpec::new("references.footnote", "Insert Footnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Footnote)).key("Mod+Alt+F").params(r#"{"text"?: string}"#),
CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote)).key("Mod+Alt+D").params(r#"{"text"?: string}"#),
CommandSpec::new("references.nextFootnote", "Next Footnote", "References › Footnotes", |s, _| {
let caret = s.sel.focus.clone();
let refs = note_refs(s);
if let Some(p) = refs.iter().find(|p| **p > caret).or(refs.first()) {
s.sel = Selection::caret(p.clone());
}
sel_result(s)
})
.pure(),
CommandSpec::new("references.notes", "Show Notes", "References › Footnotes", |s, _| {
let mut out = Vec::new();
for (id, part) in &s.doc.parts {
if matches!(part.kind, PartKind::Footnote | PartKind::Endnote) {
out.push(json!({"id": id, "kind": part.kind, "text": s.doc.plain_text(StoryRef::Part(*id))}));
}
}
Ok(Value::Array(out))
})
.pure(),
CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption).params(r#"{"label"?: "Figure|Table|Equation", "text"?: string}"#),
CommandSpec::new("references.updateFields", "Update Field", "References", |s, _| {
update_fields(s)?;
sel_result(s)
})
.key("F9"),
]
}
/// Headings (outline level < levels) in the body: (path, level, text).
fn headings(s: &Session, levels: u8) -> Vec<(Path, u8, String)> {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
if path.depth() > 0 {
continue;
}
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
let style = p.props.style.as_deref().unwrap_or("Normal");
if style.starts_with("TOC") {
continue;
}
let rp = s.doc.styles.resolve_para(&p.props);
if let Some(l) = rp.outline_level
&& l < levels
{
let t = p.plain_text().replace(['\u{000C}', '\n', '\t'], " ").trim().to_string();
if !t.is_empty() {
v.push((path, l, t));
}
}
}
v
}
/// The TOC block range in the body: the paragraph holding the TOC field and the TOC entries after it.
fn toc_range(s: &Session) -> Option<(usize, usize)> {
let start = s.doc.body.iter().position(|b| {
b.as_para().is_some_and(|p| p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, .. } if instr.trim_start().to_ascii_uppercase().starts_with("TOC"))))
})?;
let mut end = start;
while let Some(Block::Para(p)) = s.doc.body.get(end + 1).map(|b| &**b) {
if p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading") {
end += 1;
} else {
break;
}
}
Some((start, end))
}
fn toc_levels(s: &Session, start: usize) -> u8 {
s.doc
.body
.get(start)
.and_then(|b| b.as_para())
.and_then(|p| {
p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => instr.split("\\o").nth(1).and_then(|r| r.trim().trim_matches('"').split('-').nth(1)).and_then(|n| n.trim_matches('"').parse::<u8>().ok()),
_ => None,
})
})
.unwrap_or(3)
.clamp(1, 9)
}
fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
if toc_range(s).is_some() {
update_toc(s)?;
return sel_result(s);
}
let levels = p::u64(v, "levels").unwrap_or(3).clamp(1, 9) as u8;
let title = p::str(v, "title").unwrap_or("Contents").to_string();
let at = delete_selection(s)?;
if at.story != StoryRef::Body || at.path.depth() > 0 {
return Err(CmdError::Failed("a table of contents goes in the main text".into()));
}
// Title paragraph carrying the TOC field marker.
let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading");
head.insert_object(head.len(), InlineObject::Field { instr: format!("TOC \\o \"1-{levels}\" \\h \\z \\u"), result: String::new(), locked: false }, &CharProps::default())?;
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() };
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
update_toc(s)?;
sel_result(s)
}
/// Regenerate the TOC entries with page numbers (two layout passes: entries change pagination).
pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
let Some((start, end)) = toc_range(s) else { return Ok(()) };
let levels = toc_levels(s, start);
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
let width = super::page::sect(s).text_width();
let entries = headings(s, levels);
for pass in 0..2 {
if pass == 1 {
// Replace the page numbers now that the entries exist.
for _ in 0..entries.len() {
s.doc.remove_block(StoryRef::Body, &Path::top(start + 1))?;
}
}
s.touch();
let l = s.layout();
let shift = if pass == 0 { 0 } else { entries.len() };
for (k, (path, level, text)) in entries.iter().enumerate() {
let target = Path::top(path.last() + shift);
let page = l.caret(&Pos { story: StoryRef::Body, path: target, off: 0 }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
let mut para = Paragraph::with_text(&format!("{text}\t{page}"), CharProps::default()).styled(&format!("TOC{}", level + 1));
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]);
s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?;
}
}
s.touch();
Ok(())
}
fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult {
let text = p::str(v, "text").unwrap_or("").to_string();
let (pk, style, refstyle) = match kind {
NoteKind::Footnote => (PartKind::Footnote, "FootnoteText", "FootnoteReference"),
NoteKind::Endnote => (PartKind::Endnote, "EndnoteText", "EndnoteReference"),
};
let mut para = Paragraph::new().styled(style);
let id = s.doc.add_part(pk, Vec::new());
para.insert_object(0, InlineObject::NoteRef { kind, id, custom: String::new() }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?;
let len = para.len();
para.insert_text(len, &format!(" {text}"), &CharProps::default())?;
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
let at = delete_selection(s)?;
let end = s.doc.insert_object(&at, InlineObject::NoteRef { kind, id, custom: String::new() }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?;
s.sel = Selection::caret(end);
Ok(json!({"id": id}))
}
fn note_refs(s: &Session) -> Vec<Pos> {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for o in p.object_offsets() {
if matches!(p.object_at(o), Some(InlineObject::NoteRef { .. })) {
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: o });
}
}
}
v
}
fn caption(s: &mut Session, v: &Value) -> CmdResult {
let label = p::str(v, "label").unwrap_or("Figure").to_string();
let text = p::str(v, "text").unwrap_or("").to_string();
// Number = existing captions with this label before the caret + 1.
let caret = s.sel.focus.clone();
let n = s
.doc
.para_paths(StoryRef::Body)
.into_iter()
.filter(|q| *q < caret.path)
.filter(|q| s.doc.para(StoryRef::Body, q).is_some_and(|p| p.props.style.as_deref() == Some("Caption") && p.text.starts_with(&label)))
.count()
+ 1;
let f = s.sel.focus.clone();
let len = s.doc.para_at(&f).map(|p| p.len()).unwrap_or(0);
let at = Pos { off: len, ..f };
let new = s.doc.split_paragraph(&at)?;
let para = s.doc.para_mut(new.story, &new.path)?;
para.props = wordcraft_doc::ParaProps { style: Some("Caption".into()), ..Default::default() };
para.mark = CharProps::default();
let base = format!("{label} ");
para.insert_text(0, &base, &CharProps::default())?;
para.insert_object(base.len(), InlineObject::Field { instr: format!("SEQ {label} \\* ARABIC"), result: n.to_string(), locked: false }, &CharProps::default())?;
let end = para.len();
if !text.is_empty() {
para.insert_text(end, &format!(": {text}"), &CharProps::default())?;
}
let off = para.len();
s.sel = Selection::caret(Pos { off, ..new });
sel_result(s)
}
/// Update fields in the body: dates, SEQ numbering, TOC.
pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
let mut seq: std::collections::HashMap<String, u32> = Default::default();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
continue;
}
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let name = wordcraft_layout::fields::field_name(instr);
match name.as_str() {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
let pic = instr.split("\\@").nth(1).map(|x| x.trim().trim_matches('"').to_string()).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, super::insert::format_date(&pic)));
}
"SEQ" => {
let id = instr.split_whitespace().nth(1).unwrap_or("").to_string();
let n = seq.entry(id).or_insert(0);
*n += 1;
updates.push((k, n.to_string()));
}
"TITLE" => updates.push((k, s.doc.core.title.clone())),
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
_ => {}
}
}
if updates.is_empty() {
continue;
}
let para = s.doc.para_mut(StoryRef::Body, &path)?;
for (k, r) in updates {
if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) {
*result = r;
}
}
para.touch();
}
update_toc(s)
}
+312
View File
@@ -0,0 +1,312 @@
//! Review tab: comments, track changes, word count, spelling (via the proofing hook).
use serde_json::{Value, json};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::{Comment, Paragraph, PartKind, Pos, StoryRef, para_block};
use super::{now_iso, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key("Mod+Alt+M").params(r#"{"text": string}"#),
CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#),
CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#),
CommandSpec::new("review.resolveComment", "Resolve", "Review › Comments", |s, v| {
let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("`id` required".into()))?;
let c = s.doc.comments.get_mut(&id).ok_or_else(|| CmdError::Params("no such comment".into()))?;
c.resolved = p::bool(v, "value").unwrap_or(!c.resolved);
sel_result(s)
}),
CommandSpec::new("review.nextComment", "Next", "Review › Comments", |s, _| nav_comment(s, 1)).pure(),
CommandSpec::new("review.previousComment", "Previous", "Review › Comments", |s, _| nav_comment(s, -1)).pure(),
CommandSpec::new("review.comments", "Show Comments", "Review › Comments", list_comments).pure(),
CommandSpec::new("review.trackChanges", "Track Changes", "Review › Tracking", |s, v| {
s.doc.settings.track_changes = p::bool(v, "value").unwrap_or(!s.doc.settings.track_changes);
Ok(json!({"value": s.doc.settings.track_changes}))
})
.key("Mod+Shift+E"),
CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, _| resolve_all(s, true)),
CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, _| resolve_all(s, false)),
CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, _| resolve_sel(s, true)),
CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, _| resolve_sel(s, false)),
CommandSpec::new("review.nextChange", "Next Change", "Review › Changes", |s, _| nav_change(s, 1)).pure(),
CommandSpec::new("review.previousChange", "Previous Change", "Review › Changes", |s, _| nav_change(s, -1)).pure(),
CommandSpec::new("review.markup", "Display for Review", "Review › Tracking", |s, v| {
s.view.show_markup = p::str(v, "value").map(|m| m != "noMarkup" && m != "original").unwrap_or(!s.view.show_markup);
Ok(json!({"showMarkup": s.view.show_markup}))
})
.pure(),
CommandSpec::new("review.wordCount", "Word Count", "Review › Proofing", word_count).pure(),
CommandSpec::new("review.changes", "Reviewing Pane", "Review › Tracking", list_changes).pure(),
]
}
fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
let text = p::str(v, "text").unwrap_or("").to_string();
let (a, b) = s.sel.ordered();
let (a, b) = if a == b {
// Comment on the word at the caret.
match s.doc.para_at(&a) {
Some(para) if !para.is_empty() => {
let (x, y) = para.word_at(a.off);
let y = para.text.get(x..y).map(|w| x + w.trim_end().len()).unwrap_or(y);
(Pos { off: x, ..a.clone() }, Pos { off: y, ..a })
}
_ => (a.clone(), a),
}
} else {
(a, b)
};
let part = s.doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))]);
let id = s.doc.comments.keys().next_back().map(|k| k + 1).unwrap_or(0);
let initials: String = s.author.split_whitespace().filter_map(|w| w.chars().next()).collect();
s.doc.comments.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: None, resolved: false, part });
let props = Default::default();
s.doc.insert_object(&b, InlineObject::CommentEnd { id }, &props)?;
s.doc.insert_object(&a, InlineObject::CommentStart { id }, &props)?;
s.view.comments_pane = true;
Ok(json!({"id": id, "story": part}))
}
fn reply(s: &mut Session, v: &Value) -> CmdResult {
let parent = p::u64(v, "id").map(|x| x as u32).ok_or_else(|| CmdError::Params("`id` required".into()))?;
if !s.doc.comments.contains_key(&parent) {
return Err(CmdError::Params("no such comment".into()));
}
let text = p::req_str(v, "text")?;
let part = s.doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))]);
let id = s.doc.comments.keys().next_back().map(|k| k + 1).unwrap_or(0);
let initials: String = s.author.split_whitespace().filter_map(|w| w.chars().next()).collect();
s.doc.comments.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: Some(parent), resolved: false, part });
Ok(json!({"id": id}))
}
/// Comment anchored around the caret.
fn comment_at_caret(s: &Session) -> Option<u32> {
let f = &s.sel.focus;
let p = s.doc.para_at(f)?;
let mut open: Vec<u32> = Vec::new();
for off in p.object_offsets() {
if off > f.off {
break;
}
match p.object_at(off) {
Some(InlineObject::CommentStart { id }) => open.push(*id),
Some(InlineObject::CommentEnd { id }) => open.retain(|x| x != id),
_ => {}
}
}
open.last().copied().or_else(|| {
p.object_offsets().into_iter().find_map(|o| match p.object_at(o) {
Some(InlineObject::CommentStart { id }) | Some(InlineObject::CommentEnd { id }) => Some(*id),
_ => None,
})
})
}
fn remove_anchors(s: &mut Session, ids: &[u32]) -> Result<(), CmdError> {
for story in [StoryRef::Body] {
for path in s.doc.para_paths(story) {
let offs: Vec<usize> = s
.doc
.para(story, &path)
.map(|p| {
p.object_offsets()
.into_iter()
.filter(|o| matches!(p.object_at(*o), Some(InlineObject::CommentStart { id }) | Some(InlineObject::CommentEnd { id }) if ids.contains(id)))
.collect()
})
.unwrap_or_default();
if offs.is_empty() {
continue;
}
let para = s.doc.para_mut(story, &path)?;
for o in offs.into_iter().rev() {
para.delete(o, o + wordcraft_doc::para::OBJ.len_utf8())?;
}
}
}
Ok(())
}
fn delete_comment(s: &mut Session, v: &Value) -> CmdResult {
let ids: Vec<u32> = if p::bool(v, "all").unwrap_or(false) {
s.doc.comments.keys().copied().collect()
} else {
let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("no comment here".into()))?;
// The comment and its replies.
let mut v = vec![id];
v.extend(s.doc.comments.iter().filter(|(_, c)| c.parent == Some(id)).map(|(k, _)| *k));
v
};
remove_anchors(s, &ids)?;
for id in &ids {
if let Some(c) = s.doc.comments.remove(id) {
s.doc.parts.remove(&c.part);
}
}
s.clamp_selection();
Ok(json!({"deleted": ids}))
}
/// Comments in document order with their anchors.
pub fn comment_list(s: &Session) -> Vec<(u32, Option<Pos>)> {
let mut found: Vec<(u32, Option<Pos>)> = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for o in p.object_offsets() {
if let Some(InlineObject::CommentStart { id }) = p.object_at(o) {
found.push((*id, Some(Pos { story: StoryRef::Body, path: path.clone(), off: o })));
}
}
}
for id in s.doc.comments.keys() {
if !found.iter().any(|(x, _)| x == id) {
found.push((*id, None));
}
}
found
}
fn list_comments(s: &mut Session, _: &Value) -> CmdResult {
let list = comment_list(s);
Ok(Value::Array(
list.iter()
.filter_map(|(id, pos)| {
let c = s.doc.comments.get(id)?;
Some(json!({
"id": id, "author": c.author, "date": c.date, "resolved": c.resolved, "parent": c.parent,
"text": s.doc.plain_text(StoryRef::Part(c.part)),
"anchor": pos.as_ref().map(pos_json),
}))
})
.collect(),
))
}
fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
let list: Vec<Pos> = comment_list(s).into_iter().filter_map(|(_, p)| p).collect();
let caret = s.sel.focus.clone();
let t = if dir > 0 { list.iter().find(|p| **p > caret).or(list.first()) } else { list.iter().rev().find(|p| **p < caret).or(list.last()) };
if let Some(p) = t {
s.sel = Selection::caret(p.clone());
}
sel_result(s)
}
/// Accept or reject a revision range in one paragraph.
fn resolve_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc::Path, from: usize, to: usize, accept: bool) -> Result<(), CmdError> {
let para = s.doc.para_mut(story, path)?;
let ranges: Vec<(usize, usize, bool, bool)> =
para.run_ranges().filter(|(r, _)| r.end > from && r.start < to).map(|(r, c)| (r.start.max(from), r.end.min(to), c.ins.is_some(), c.del.is_some())).collect();
for (a, b, ins, del) in ranges.into_iter().rev() {
if (del && accept) || (ins && !accept) {
para.delete(a, b)?;
} else if ins || del {
para.format(a, b, &|c| {
c.ins = None;
c.del = None;
})?;
}
}
if para.mark.ins.is_some() {
para.mark.ins = None;
}
Ok(())
}
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for st in stories {
for path in s.doc.para_paths(st).into_iter().rev() {
let len = s.doc.para(st, &path).map(|p| p.len()).unwrap_or(0);
resolve_para(s, st, &path, 0, len, accept)?;
}
}
s.doc.revisions.clear();
s.clamp_selection();
sel_result(s)
}
fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult {
let (a, b) = s.sel.ordered();
let (a, b) = if a == b {
// The change at the caret: the run around it.
let found = s.doc.para_at(&a).and_then(|p| p.run_ranges().find(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some())).map(|(r, _)| r));
match found {
Some(r) => (Pos { off: r.start, ..a.clone() }, Pos { off: r.end, ..a }),
None => return nav_change(s, 1),
}
} else {
(a, b)
};
for path in s.doc.paths_between(&a, &b).into_iter().rev() {
let len = s.doc.para(a.story, &path).map(|p| p.len()).unwrap_or(0);
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { len };
resolve_para(s, a.story, &path, from, to, accept)?;
}
s.sel = Selection::caret(a);
s.clamp_selection();
sel_result(s)
}
fn changes(s: &Session) -> Vec<(Pos, Pos, &'static str, Option<u32>)> {
let mut out = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for (r, c) in p.run_ranges() {
let kind = if c.ins.is_some() {
"insert"
} else if c.del.is_some() {
"delete"
} else {
continue;
};
let mk = |off| Pos { story: StoryRef::Body, path: path.clone(), off };
out.push((mk(r.start), mk(r.end), kind, c.ins.or(c.del)));
}
}
out
}
fn nav_change(s: &mut Session, dir: i32) -> CmdResult {
let list = changes(s);
let caret = s.sel.ordered();
let t = if dir > 0 { list.iter().find(|c| c.0 >= caret.1).or(list.first()) } else { list.iter().rev().find(|c| c.1 <= caret.0).or(list.last()) };
if let Some((a, b, _, _)) = t {
s.sel = Selection { anchor: a.clone(), focus: b.clone() };
}
sel_result(s)
}
fn list_changes(s: &mut Session, _: &Value) -> CmdResult {
let list = changes(s);
Ok(Value::Array(
list.iter()
.map(|(a, b, kind, rid)| {
let rev = rid.and_then(|r| s.doc.revisions.get(r as usize));
json!({
"kind": kind, "start": pos_json(a), "end": pos_json(b),
"text": s.doc.copy_range(a, b).plain_text(),
"author": rev.map(|r| r.author.clone()), "date": rev.map(|r| r.date.clone()),
})
})
.collect(),
))
}
fn word_count(s: &mut Session, _: &Value) -> CmdResult {
let text = if s.sel.is_collapsed() { s.doc.plain_text(StoryRef::Body) } else { s.selected_text() };
let words = wordcraft_doc::count_words(&text);
let chars = text.chars().filter(|c| *c != '\n').count();
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
let lines: usize = {
let l = s.layout();
l.pages.iter().flat_map(|p| p.items.iter()).map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, story: StoryRef::Body, .. } = it { l1 - l0 } else { 0 }).sum()
};
let pages = s.layout().pages.len();
Ok(json!({"pages": pages, "words": words, "characters": chars_no_spaces, "charactersWithSpaces": chars, "paragraphs": paragraphs, "lines": lines}))
}
+556
View File
@@ -0,0 +1,556 @@
//! Table Design and Table Layout tabs.
use serde_json::{Value, json};
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, Rgb, VAlign};
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef, Table, para_block};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
fn in_table(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") }
}
pub fn specs() -> Vec<CommandSpec> {
let t = |c: CommandSpec| c.when(in_table);
vec![
t(CommandSpec::new("table.insertRowAbove", "Insert Above", "Table Layout › Rows & Columns", |s, _| row(s, false))),
t(CommandSpec::new("table.insertRowBelow", "Insert Below", "Table Layout › Rows & Columns", |s, _| row(s, true))),
t(CommandSpec::new("table.insertColumnLeft", "Insert Left", "Table Layout › Rows & Columns", |s, _| col(s, false))),
t(CommandSpec::new("table.insertColumnRight", "Insert Right", "Table Layout › Rows & Columns", |s, _| col(s, true))),
t(CommandSpec::new("table.deleteRow", "Delete Rows", "Table Layout › Rows & Columns › Delete", del_row)),
t(CommandSpec::new("table.deleteColumn", "Delete Columns", "Table Layout › Rows & Columns › Delete", del_col)),
t(CommandSpec::new("table.deleteTable", "Delete Table", "Table Layout › Rows & Columns › Delete", del_table)),
t(CommandSpec::new("table.deleteCells", "Delete Cells", "Table Layout › Rows & Columns › Delete", |s, _| {
let (tp, r, c) = cell(s)?;
let story = s.sel.focus.story;
let t = s.doc.table_mut(story, &tp)?;
if let Some(row) = t.rows.get_mut(r)
&& row.cells.len() > 1
&& c < row.cells.len()
{
row.cells.remove(c);
}
fix_caret(s, &tp, r, 0)
})),
t(CommandSpec::new("table.merge", "Merge Cells", "Table Layout › Merge", merge)),
t(CommandSpec::new("table.split", "Split Cells", "Table Layout › Merge", |s, v| {
let n = p::u64(v, "columns").unwrap_or(2).clamp(1, 63) as usize;
let (tp, r, c) = cell(s)?;
s.doc.table_mut(s.sel.focus.story, &tp)?.split_cell(r, c, n);
fix_caret(s, &tp, r, c)
})
.params(r#"{"columns"?: n}"#)),
t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)),
t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| {
let st = p::req_str(v, "style")?;
let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?;
with_table(s, |t| t.props.style = Some(id.clone()))
})
.params(r#"{"style": string}"#)),
t(CommandSpec::new("table.look", "Table Style Options", "Table Design › Table Style Options", |s, v| {
let set = |k: &str, cur: bool| p::bool(v, k).unwrap_or(cur);
with_table(s, |t| {
let l = &mut t.props.look;
l.header_row = set("headerRow", l.header_row);
l.total_row = set("totalRow", l.total_row);
l.banded_rows = set("bandedRows", l.banded_rows);
l.first_column = set("firstColumn", l.first_column);
l.last_column = set("lastColumn", l.last_column);
l.banded_columns = set("bandedColumns", l.banded_columns);
})
})
.params(r#"{"headerRow"?: bool, "totalRow"?: bool, "bandedRows"?: bool, "firstColumn"?: bool, "lastColumn"?: bool, "bandedColumns"?: bool}"#)),
t(CommandSpec::new("table.shading", "Shading", "Table Design › Table Styles", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
with_cells(s, |cl| cl.props.shading = c)
})
.params(r#"{"color": "RRGGBB" | null}"#)),
t(CommandSpec::new("table.borders", "Borders", "Table Design › Borders", borders).params(r#"{"kind": "all|outside|inside|none|top|bottom|left|right", "width"?: pt, "color"?: "RRGGBB"}"#)),
t(CommandSpec::new("table.cellAlign", "Alignment", "Table Layout › Alignment", |s, v| {
let (h, va) = match p::str(v, "value").unwrap_or("topLeft") {
"topLeft" => (Align::Left, VAlign::Top),
"topCenter" => (Align::Center, VAlign::Top),
"topRight" => (Align::Right, VAlign::Top),
"centerLeft" => (Align::Left, VAlign::Center),
"center" => (Align::Center, VAlign::Center),
"centerRight" => (Align::Right, VAlign::Center),
"bottomLeft" => (Align::Left, VAlign::Bottom),
"bottomCenter" => (Align::Center, VAlign::Bottom),
"bottomRight" => (Align::Right, VAlign::Bottom),
x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))),
};
with_cells(s, |cl| {
cl.props.valign = va;
for b in &mut cl.blocks {
if let Block::Para(p) = std::sync::Arc::make_mut(b) {
p.props.align = Some(h);
p.touch();
}
}
})
})
.params(r#"{"value": "topLeft|topCenter|…|bottomRight"}"#)),
t(CommandSpec::new("table.autofit", "AutoFit", "Table Layout › Cell Size", |s, v| {
let mode = p::str(v, "mode").unwrap_or("window");
let tw = super::page::sect(s).text_width();
with_table(s, |t| match mode {
"fixed" => t.props.fixed = true,
"contents" => {
t.props.fixed = false;
t.props.width = None;
t.props.width_pct = None;
}
_ => {
t.props.fixed = false;
t.props.width_pct = Some(100.0);
let n = t.grid.len().max(1) as f32;
t.grid.iter_mut().for_each(|g| *g = tw / n);
}
})
})
.params(r#"{"mode": "contents|window|fixed"}"#)),
t(CommandSpec::new("table.distributeColumns", "Distribute Columns", "Table Layout › Cell Size", |s, _| {
with_table(s, |t| {
let total: f32 = t.grid.iter().sum();
let n = t.grid.len().max(1) as f32;
t.grid.iter_mut().for_each(|g| *g = total / n);
})
})),
t(CommandSpec::new("table.distributeRows", "Distribute Rows", "Table Layout › Cell Size", |s, _| {
with_table(s, |t| {
let h = t.rows.iter().filter_map(|r| r.props.height).fold(0.0f32, f32::max).max(18.0);
for r in &mut t.rows {
r.props.height = Some(h);
}
})
})),
t(CommandSpec::new("table.columnWidth", "Table Column Width", "Table Layout › Cell Size", |s, v| {
let w = p::req_f32(v, "width")?.clamp(6.0, 1584.0);
let (_, r, c) = cell(s)?;
with_table(s, |t| {
let g = t.grid_col(r, c);
if let Some(x) = t.grid.get_mut(g) {
*x = w;
}
})
})
.params(r#"{"width": pt}"#)),
t(CommandSpec::new("table.rowHeight", "Table Row Height", "Table Layout › Cell Size", |s, v| {
let h = p::req_f32(v, "height")?.clamp(1.0, 1584.0);
let (_, r, _) = cell(s)?;
with_table(s, |t| {
if let Some(row) = t.rows.get_mut(r) {
row.props.height = Some(h);
row.props.height_rule = wordcraft_doc::props::HeightRule::AtLeast;
}
})
})
.params(r#"{"height": pt}"#)),
t(CommandSpec::new("table.repeatHeader", "Repeat Header Rows", "Table Layout › Data", |s, v| {
let (_, r, _) = cell(s)?;
let on = p::bool(v, "value");
with_table(s, |t| {
if let Some(row) = t.rows.get_mut(r) {
row.props.header = on.unwrap_or(!row.props.header);
}
})
})),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table).params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| {
let (tp, _, _) = cell(s)?;
select_cells(s, &tp, None)
})
.pure()),
t(CommandSpec::new("table.selectRow", "Select Row", "Table Layout › Table › Select", |s, _| {
let (tp, r, _) = cell(s)?;
select_cells(s, &tp, Some((r, None)))
})
.pure()),
t(CommandSpec::new("table.selectCell", "Select Cell", "Table Layout › Table › Select", |s, _| {
let (tp, r, c) = cell(s)?;
select_cells(s, &tp, Some((r, Some(c))))
})
.pure()),
t(CommandSpec::new("table.properties", "Properties", "Table Layout › Table", |s, v| {
if let Some(a) = p::str(v, "align") {
let al = match a {
"center" => Align::Center,
"right" => Align::Right,
_ => Align::Left,
};
return with_table(s, |t| t.props.align = Some(al));
}
let (tp, _, _) = cell(s)?;
let t = s.doc.table(s.sel.focus.story, &tp).cloned().unwrap_or_default();
Ok(serde_json::to_value(&t.props).unwrap_or(Value::Null))
})
.params(r#"{"align"?: "left|center|right"}"#)),
CommandSpec::new("table.fromText", "Convert Text to Table", "Insert › Tables", from_text).params(r#"{"separator"?: "tab|comma"}"#),
CommandSpec::new("table.quick", "Quick Tables", "Insert › Tables", quick_table).params(r#"{"kind"?: "calendar|tabular|matrix"}"#),
]
}
fn cell(s: &Session) -> Result<(Path, usize, usize), CmdError> {
s.sel.focus.path.cell().ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))
}
fn with_table(s: &mut Session, f: impl Fn(&mut Table)) -> CmdResult {
let (tp, _, _) = cell(s)?;
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
f(t);
touch_cells(s, &tp)?;
sel_result(s)
}
/// Selected cells: (row, cell) pairs covered by the selection (at least the caret's cell).
fn selected_cells(s: &Session) -> Vec<(usize, usize)> {
let (a, b) = s.sel.ordered();
let (Some((ta, ra, ca)), Some((tb, rb, cb))) = (a.path.cell(), b.path.cell()) else { return Vec::new() };
if ta != tb {
return vec![(rb, cb)];
}
let (r0, r1) = (ra.min(rb), ra.max(rb));
let (c0, c1) = (ca.min(cb), ca.max(cb));
let mut v = Vec::new();
for r in r0..=r1 {
for c in c0..=c1 {
v.push((r, c));
}
}
v
}
fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResult {
let (tp, _, _) = cell(s)?;
let cells = selected_cells(s);
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for (r, c) in cells {
if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) {
f(cl);
}
}
touch_cells(s, &tp)?;
sel_result(s)
}
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
let story = s.sel.focus.story;
let paths: Vec<Path> = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect();
for p in paths {
s.doc.para_mut(story, &p)?.touch();
}
Ok(())
}
fn fix_caret(s: &mut Session, tp: &Path, r: usize, c: usize) -> CmdResult {
let story = s.sel.focus.story;
let Some(t) = s.doc.table(story, tp) else {
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp.clone(), off: 0 }));
return sel_result(s);
};
let r = r.min(t.rows.len().saturating_sub(1));
let c = c.min(t.rows.get(r).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0));
let mut path = tp.0.clone();
path.extend([r as u32, c as u32, 0]);
s.sel = Selection::caret(Pos { story, path: Path(path), off: 0 });
sel_result(s)
}
fn row(s: &mut Session, below: bool) -> CmdResult {
let (tp, r, c) = cell(s)?;
let cells = selected_cells(s);
let n = cells.iter().map(|x| x.0).collect::<std::collections::BTreeSet<_>>().len().max(1);
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for _ in 0..n {
t.insert_row(if below { r + 1 } else { r }, r);
}
fix_caret(s, &tp, if below { r + 1 } else { r }, c)
}
fn col(s: &mut Session, right: bool) -> CmdResult {
let (tp, r, c) = cell(s)?;
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
let g = t.grid_col(r, c);
let span = t.rows.get(r).and_then(|x| x.cells.get(c)).map(|x| x.span()).unwrap_or(1);
t.insert_col(if right { g + span } else { g });
// Keep the table within its width: shrink all columns proportionally.
let total: f32 = t.grid.iter().sum();
let before: f32 = total - t.grid.get(if right { g + span } else { g }).copied().unwrap_or(0.0);
if before > 0.0 {
let k = before / total;
t.grid.iter_mut().for_each(|w| *w *= k);
}
fix_caret(s, &tp, r, if right { c + 1 } else { c })
}
fn del_row(s: &mut Session, _: &Value) -> CmdResult {
let (tp, r, _) = cell(s)?;
let rows: std::collections::BTreeSet<usize> = selected_cells(s).into_iter().map(|x| x.0).collect();
let story = s.sel.focus.story;
let t = s.doc.table_mut(story, &tp)?;
if rows.len() >= t.rows.len() {
return del_table(s, &Value::Null);
}
for rr in rows.iter().rev() {
t.delete_row(*rr);
}
fix_caret(s, &tp, r, 0)
}
fn del_col(s: &mut Session, _: &Value) -> CmdResult {
let (tp, r, c) = cell(s)?;
let story = s.sel.focus.story;
let t = s.doc.table_mut(story, &tp)?;
if t.grid.len() <= 1 {
return del_table(s, &Value::Null);
}
let g = t.grid_col(r, c);
t.delete_col(g);
fix_caret(s, &tp, r, c.saturating_sub(1))
}
fn del_table(s: &mut Session, _: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
let story = s.sel.focus.story;
s.doc.remove_block(story, &tp)?;
s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: tp, off: 0 }));
sel_result(s)
}
fn merge(s: &mut Session, _: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
let cells = selected_cells(s);
if cells.len() < 2 {
return Err(CmdError::Failed("select two or more cells to merge".into()));
}
let story = s.sel.focus.story;
let (r0, r1) = (cells.iter().map(|x| x.0).min().unwrap_or(0), cells.iter().map(|x| x.0).max().unwrap_or(0));
let t = s.doc.table_mut(story, &tp)?;
let c0 = cells.iter().map(|x| x.1).min().unwrap_or(0);
let c1 = cells.iter().map(|x| x.1).max().unwrap_or(0);
let g0 = t.grid_col(r0, c0);
let g1 = t.grid_col(r0, c1) + t.rows.get(r0).and_then(|r| r.cells.get(c1)).map(|c| c.span()).unwrap_or(1) - 1;
t.merge(r0, r1, g0, g1);
fix_caret(s, &tp, r0, c0)
}
fn split_table(s: &mut Session, _: &Value) -> CmdResult {
let (tp, r, _) = cell(s)?;
if r == 0 {
return Err(CmdError::Failed("can't split at the first row".into()));
}
let story = s.sel.focus.story;
let t = s.doc.table_mut(story, &tp)?;
let tail_rows = t.rows.split_off(r);
let tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows };
let after = tp.with_last(tp.last() + 1);
s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?;
s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?;
s.sel = Selection::caret(Pos { story, path: after, off: 0 });
sel_result(s)
}
fn borders(s: &mut Session, v: &Value) -> CmdResult {
let kind = p::str(v, "kind").unwrap_or("all");
let w = p::f32(v, "width").unwrap_or(0.5).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: 0.0 };
let none = Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 };
with_table(s, |t| {
let mut cur = t.props.borders.unwrap_or_default();
match kind {
"all" => cur = Borders::all(b),
"none" => cur = Borders::all(none),
"outside" => {
cur.top = Some(b);
cur.bottom = Some(b);
cur.left = Some(b);
cur.right = Some(b);
}
"inside" => {
cur.between = Some(b);
cur.inside_v = Some(b);
}
"top" => cur.top = Some(b),
"bottom" => cur.bottom = Some(b),
"left" => cur.left = Some(b),
"right" => cur.right = Some(b),
_ => {}
}
t.props.borders = Some(cur);
for r in &mut t.rows {
for c in &mut r.cells {
c.props.borders = None;
}
}
})
}
fn cell_text(c: &wordcraft_doc::Cell) -> String {
c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n")
}
fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
let col = p::u64(v, "column").unwrap_or(0) as usize;
let desc = p::bool(v, "descending").unwrap_or(false);
let header = p::bool(v, "header");
with_table(s, |t| {
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header)));
let Some(body) = t.rows.get_mut(skip..) else { return };
body.sort_by(|a, b| {
let ka = a.cells.get(col).map(cell_text).unwrap_or_default();
let kb = b.cells.get(col).map(cell_text).unwrap_or_default();
let o = match (ka.trim().replace(',', "").parse::<f64>(), kb.trim().replace(',', "").parse::<f64>()) {
(Ok(x), Ok(y)) => x.total_cmp(&y),
_ => ka.to_lowercase().cmp(&kb.to_lowercase()),
};
if desc { o.reverse() } else { o }
});
})
}
fn to_text(s: &mut Session, v: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
let sep = match p::str(v, "separator").unwrap_or("tab") {
"comma" => ",",
"paragraph" => "\n",
_ => "\t",
};
let story = s.sel.focus.story;
let t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
s.doc.remove_block(story, &tp)?;
let mut i = tp.last();
for row in &t.rows {
let text: Vec<String> = row.cells.iter().map(cell_text).collect();
for line in text.join(sep).split('\n') {
s.doc.insert_block(story, &tp.with_last(i), Block::Para(Paragraph::with_text(line, Default::default())))?;
i += 1;
}
}
s.sel = Selection::caret(Pos { story, path: tp, off: 0 });
sel_result(s)
}
/// `=SUM(ABOVE)`, `=SUM(LEFT)`, `=AVERAGE(ABOVE)`, `=COUNT(...)`, `=MAX`, `=MIN`, `=PRODUCT`.
fn formula(s: &mut Session, v: &Value) -> CmdResult {
let (tp, r, c) = cell(s)?;
let f = p::str(v, "formula").unwrap_or("=SUM(ABOVE)").to_ascii_uppercase();
let t = s.doc.table(s.sel.focus.story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?;
let nums: Vec<f64> = if f.contains("LEFT") {
t.rows.get(r).map(|row| row.cells.iter().take(c).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect()).unwrap_or_default()
} else {
t.rows.iter().take(r).filter_map(|row| row.cells.get(c)).filter_map(|x| cell_text(x).trim().replace([',', '$'], "").parse().ok()).collect()
};
let val = if f.starts_with("=AVERAGE") {
if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 }
} else if f.starts_with("=COUNT") {
nums.len() as f64
} else if f.starts_with("=MAX") {
nums.iter().copied().fold(f64::MIN, f64::max)
} else if f.starts_with("=MIN") {
nums.iter().copied().fold(f64::MAX, f64::min)
} else if f.starts_with("=PRODUCT") {
nums.iter().product()
} else {
nums.iter().sum()
};
let text = if val.fract() == 0.0 && val.abs() < 1e15 { format!("{}", val as i64) } else { format!("{val:.2}") };
let props = s.typing_props();
let at = s.sel.focus.clone();
let end = s.doc.insert_object(&at, wordcraft_doc::InlineObject::Field { instr: f.clone(), result: text.clone(), locked: false }, &props)?;
s.sel = Selection::caret(end);
Ok(json!({"result": text}))
}
fn select_cells(s: &mut Session, tp: &Path, which: Option<(usize, Option<usize>)>) -> CmdResult {
let story = s.sel.focus.story;
let Some(t) = s.doc.table(story, tp) else { return sel_result(s) };
let (r0, r1, c0, c1) = match which {
None => (0, t.rows.len().saturating_sub(1), 0, usize::MAX),
Some((r, None)) => (r, r, 0, usize::MAX),
Some((r, Some(c))) => (r, r, c, c),
};
let last_c = t.rows.get(r1).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0).min(c1);
let mut a = tp.0.clone();
a.extend([r0 as u32, c0 as u32, 0]);
let mut b = tp.0.clone();
b.extend([r1 as u32, last_c as u32]);
let end_path = s.doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&b)).last().unwrap_or_else(|| {
let mut x = b.clone();
x.push(0);
Path(x)
});
let off = s.doc.para(story, &end_path).map(|x| x.len()).unwrap_or(0);
s.sel = Selection { anchor: Pos { story, path: Path(a), off: 0 }, focus: Pos { story, path: end_path, off } };
sel_result(s)
}
fn from_text(s: &mut Session, v: &Value) -> CmdResult {
let sep = if p::str(v, "separator") == Some("comma") { ',' } else { '\t' };
let (a, b) = s.sel.ordered();
if a.path.parent() != b.path.parent() || a.story != b.story {
return Err(CmdError::Failed("select paragraphs to convert".into()));
}
let lines: Vec<String> = (a.path.last()..=b.path.last()).filter_map(|i| s.doc.para(a.story, &a.path.with_last(i)).map(|p| p.plain_text())).collect();
let cols = lines.iter().map(|l| l.split(sep).count()).max().unwrap_or(1).clamp(1, 63);
let width = super::page::sect(s).text_width();
let mut t = Table::new(lines.len().max(1), cols, width);
for (r, l) in lines.iter().enumerate() {
for (c, txt) in l.split(sep).enumerate() {
if let Some(cl) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) {
cl.blocks = vec![para_block(Paragraph::with_text(txt.trim(), Default::default()))];
}
}
}
for i in (a.path.last()..=b.path.last()).rev() {
s.doc.remove_block(a.story, &a.path.with_last(i))?;
}
s.doc.insert_block(a.story, &a.path, Block::Table(t))?;
let mut first = a.path.0.clone();
first.extend([0, 0, 0]);
s.sel = Selection::caret(Pos { story: a.story, path: Path(first), off: 0 });
sel_result(s)
}
fn quick_table(s: &mut Session, v: &Value) -> CmdResult {
let kind = p::str(v, "kind").unwrap_or("tabular");
let data: Vec<Vec<&str>> = match kind {
"calendar" => {
let mut rows = vec![vec!["M", "T", "W", "T", "F", "S", "S"]];
let days = ["", "", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "", "", ""];
for ch in days.chunks(7) {
rows.push(ch.to_vec());
}
rows
}
"matrix" => vec![vec!["", "Column A", "Column B", "Column C"], vec!["Row 1", "1", "2", "3"], vec!["Row 2", "4", "5", "6"], vec!["Row 3", "7", "8", "9"]],
_ => vec![vec!["Item", "Needed"], vec!["Books", "1"], vec!["Magazines", "3"], vec!["Notebooks", "1"], vec!["Paper pads", "1"], vec!["Pens", "3"], vec!["Pencils", "2"]],
};
let cols = data.iter().map(Vec::len).max().unwrap_or(1);
let width = super::page::sect(s).text_width();
let mut t = Table::new(data.len(), cols, width);
t.props.style = Some(if kind == "calendar" { "GridTable1Light" } else { "GridTable4AccentBlue" }.into());
for (r, row) in data.iter().enumerate() {
for (c, txt) in row.iter().enumerate() {
if let Some(cl) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) {
cl.blocks = vec![para_block(Paragraph::with_text(txt, Default::default()))];
}
}
}
if let Some(r) = t.rows.first_mut() {
r.props.header = true;
}
let story = s.sel.focus.story;
let at = super::delete_selection(s)?;
let new = s.doc.split_paragraph(&at)?;
s.doc.insert_block(story, &new.path, Block::Table(t))?;
let mut first = new.path.0.clone();
first.extend([0, 0, 0]);
s.sel = Selection::caret(Pos { story, path: Path(first), off: 0 });
let _ = StoryRef::Body;
sel_result(s)
}
+321
View File
@@ -0,0 +1,321 @@
//! Typing and deleting.
use serde_json::Value;
use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, PAGE_BREAK, SOFT_HYPHEN};
use wordcraft_doc::props::NumRef;
use wordcraft_doc::{Block, ListKind, Pos};
use super::{delete_selection, sel_result, split_para, type_text};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string}"#),
CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"),
CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"),
CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"),
CommandSpec::new("text.columnBreak", "Column Break", "Layout › Breaks", |s, _| ins_char(s, COLUMN_BREAK)).key("Mod+Shift+Enter"),
CommandSpec::new("text.tab", "Tab", "Editing", tab).key("Tab"),
CommandSpec::new("text.backTab", "Shift+Tab", "Editing", back_tab).key("Shift+Tab"),
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
CommandSpec::new("text.optionalHyphen", "Optional Hyphen", "Insert › Symbols", |s, _| ins_char(s, SOFT_HYPHEN)).key("Mod+-"),
]
}
/// Smart quotes and AutoFormat-as-you-type (lists from "* " / "1. ", dashes).
fn autoformat(s: &mut Session, text: &str) -> Option<String> {
if text.chars().count() != 1 {
return None;
}
let c = text.chars().next()?;
let f = &s.sel.focus;
let para = s.doc.para_at(f)?;
let before = para.text.get(..f.off)?;
let prev = before.chars().next_back();
let opening = prev.is_none_or(|p| p.is_whitespace() || "([{<\u{2014}\u{2013}".contains(p));
match c {
'"' => Some(if opening { "\u{201C}" } else { "\u{201D}" }.into()),
'\'' => Some(if opening { "\u{2018}" } else { "\u{2019}" }.into()),
_ => None,
}
}
fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?;
let raw = p::bool(v, "raw").unwrap_or(false);
let t = if raw { None } else { autoformat(s, text) };
type_text(s, t.as_deref().unwrap_or(text))?;
if !raw && text == " " {
list_autoformat(s)?;
dash_autoformat(s)?;
}
sel_result(s)
}
/// "* " / "- " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph.
fn list_autoformat(s: &mut Session) -> Result<(), CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
if para.props.numbering.is_some_and(|n| n.num != 0) || f.off != para.len() {
return Ok(());
}
let kind = match para.text.as_str() {
"* " | "- " | "> " => ListKind::Bullet,
"1. " => ListKind::Numbered,
"1) " => ListKind::NumberedParen,
"a) " => ListKind::LowerLetter,
"A. " => ListKind::UpperLetter,
"i. " => ListKind::LowerRoman,
_ => return Ok(()),
};
let num = s.doc.numbering.find_kind(kind).unwrap_or_else(|| s.doc.numbering.add_list(kind));
let para = s.doc.para_mut(f.story, &f.path)?;
let len = para.len();
para.delete(0, len)?;
para.props.numbering = Some(NumRef { num, level: 0 });
para.props.style = Some("ListParagraph".into());
s.sel = Selection::caret(Pos { off: 0, ..f });
Ok(())
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" - ")
&& !before.trim_end()[i + 3..].contains(' ')
{
let p = s.doc.para_mut(f.story, &f.path)?;
let props = p.props_at(i + 1).clone();
p.delete(i + 1, i + 2)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 2, ..f });
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind("--")
&& i > 0
&& before.trim_end().get(i - 1..i).is_some_and(|c| c != " ")
{
let p = s.doc.para_mut(f.story, &f.path)?;
let props = p.props_at(i).clone();
p.delete(i, i + 2)?;
p.insert_text(i, "\u{2014}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
}
Ok(())
}
fn ins_char(s: &mut Session, c: char) -> CmdResult {
let mut b = [0u8; 4];
type_text(s, c.encode_utf8(&mut b))?;
sel_result(s)
}
fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
let at = delete_selection(s)?;
let new = split_para(s, &at)?;
s.sel = Selection::caret(new);
s.goal_x = None;
sel_result(s)
}
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
fn tab(s: &mut Session, _: &Value) -> CmdResult {
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
let story = s.sel.focus.story;
let Some(t) = s.doc.table(story, &tpath) else { return ins_char(s, '\t') };
let row_len = t.rows.get(r).map(|x| x.cells.len()).unwrap_or(0);
let (nr, nc) = if c + 1 < row_len { (r, c + 1) } else { (r + 1, 0) };
if nr >= t.rows.len() {
let t = s.doc.table_mut(story, &tpath)?;
t.insert_row(nr, r);
}
let mut path = tpath.0.clone();
path.extend([nr as u32, nc as u32, 0]);
let start = Pos { story, path: wordcraft_doc::Path(path), off: 0 };
// Select the cell's content like Word.
let end_path = s.doc.para_paths(story).into_iter().filter(|q| q.0.starts_with(&start.path.0[..start.path.0.len() - 1])).last();
let end = end_path.map(|q| {
let off = s.doc.para(story, &q).map(|x| x.len()).unwrap_or(0);
Pos { story, path: q, off }
});
s.sel = Selection { anchor: start.clone(), focus: end.unwrap_or(start) };
return sel_result(s);
}
let f = s.sel.focus.clone();
if s.sel.is_collapsed()
&& f.off == 0
&& let Some(n) = s.doc.para_at(&f).and_then(|x| x.props.numbering)
&& n.num != 0
{
let para = s.doc.para_mut(f.story, &f.path)?;
para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) });
para.touch();
return sel_result(s);
}
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
// Multiple paragraphs selected: increase indent.
return super::para::indent(s, &Value::Null);
}
ins_char(s, '\t')
}
fn back_tab(s: &mut Session, _: &Value) -> CmdResult {
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
let story = s.sel.focus.story;
let (nr, nc) = if c > 0 {
(r, c - 1)
} else if r > 0 {
(r - 1, s.doc.table(story, &tpath).and_then(|t| t.rows.get(r - 1)).map(|x| x.cells.len().saturating_sub(1)).unwrap_or(0))
} else {
return sel_result(s);
};
let mut path = tpath.0.clone();
path.extend([nr as u32, nc as u32, 0]);
s.sel = Selection::caret(Pos { story, path: wordcraft_doc::Path(path), off: 0 });
return sel_result(s);
}
let f = s.sel.focus.clone();
if let Some(n) = s.doc.para_at(&f).and_then(|x| x.props.numbering).filter(|n| n.num != 0) {
let para = s.doc.para_mut(f.story, &f.path)?;
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_sub(1) });
para.touch();
return sel_result(s);
}
super::para::outdent(s, &Value::Null)
}
fn backspace(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
delete_selection(s)?;
return sel_result(s);
}
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
if f.off > 0 {
let a = Pos { off: para.prev_boundary(f.off), ..f.clone() };
s.sel = Selection { anchor: a, focus: f };
delete_selection(s)?;
return sel_result(s);
}
// At the start of a paragraph.
if para.props.numbering.is_some_and(|n| n.num != 0) {
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.numbering = Some(NumRef { num: 0, level: 0 });
p.touch();
return sel_result(s);
}
if para.props.indent_first.is_some_and(|x| x > 0.0) {
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.indent_first = Some(0.0);
p.touch();
return sel_result(s);
}
let i = f.path.last();
if i == 0 {
return sel_result(s); // start of a story or cell
}
let prev_path = f.path.with_last(i - 1);
match s.doc.block(f.story, &prev_path) {
Some(Block::Para(prev)) => {
let a = Pos { story: f.story, path: prev_path, off: prev.len() };
// An empty paragraph before a non-empty one takes the latter's formatting (Word).
if s.doc.settings.track_changes {
s.sel = Selection::caret(a);
return sel_result(s);
}
let at = s.doc.delete_range(&a, &f)?;
s.sel = Selection::caret(at);
}
Some(Block::Table(_)) => {
// Move into the table's last cell.
let last = s.doc.para_paths(f.story).into_iter().filter(|q| q.0.first() == prev_path.0.first() && q.0.len() > 1).last();
if let Some(q) = last {
let off = s.doc.para(f.story, &q).map(|x| x.len()).unwrap_or(0);
if s.doc.para_at(&f).is_some_and(|x| x.is_empty()) && s.doc.container(f.story, &f.path).is_some_and(|c| c.len() > i + 1) {
s.doc.remove_block(f.story, &f.path)?;
}
s.sel = Selection::caret(Pos { story: f.story, path: q, off });
}
}
None => {}
}
sel_result(s)
}
fn delete(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
delete_selection(s)?;
return sel_result(s);
}
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
if f.off < para.len() {
let b = Pos { off: para.next_boundary(f.off), ..f.clone() };
s.sel = Selection { anchor: f, focus: b };
delete_selection(s)?;
return sel_result(s);
}
// At the end: join with the next paragraph in the same container.
let next = f.path.with_last(f.path.last() + 1);
if let Some(Block::Para(_)) = s.doc.block(f.story, &next) {
if s.doc.settings.track_changes {
return sel_result(s);
}
let b = Pos { story: f.story, path: next, off: 0 };
let at = s.doc.delete_range(&f, &b)?;
s.sel = Selection::caret(at);
} else if let Some(Block::Table(_)) = s.doc.block(f.story, &next) {
// An empty paragraph before a table is removed; else nothing.
if para.is_empty() && f.path.last() > 0 {
s.doc.remove_block(f.story, &f.path)?;
let mut path = next.0.clone();
if let Some(l) = path.last_mut() {
*l -= 1;
}
path.extend([0, 0, 0]);
s.sel = Selection::caret(Pos { story: f.story, path: wordcraft_doc::Path(path), off: 0 });
}
}
sel_result(s)
}
fn delete_word_back(s: &mut Session, v: &Value) -> CmdResult {
if !s.sel.is_collapsed() {
return backspace(s, v);
}
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
if f.off == 0 {
return backspace(s, v);
}
let a = Pos { off: para.word_start(f.off), ..f.clone() };
s.sel = Selection { anchor: a, focus: f };
delete_selection(s)?;
sel_result(s)
}
fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
if !s.sel.is_collapsed() {
return delete(s, v);
}
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
if f.off >= para.len() {
return delete(s, v);
}
let b = Pos { off: para.word_end(f.off), ..f.clone() };
s.sel = Selection { anchor: f, focus: b };
delete_selection(s)?;
sel_result(s)
}
+99
View File
@@ -0,0 +1,99 @@
//! View tab and status bar: views, zoom, show/hide, panes.
use serde_json::{Value, json};
use wordcraft_layout::ViewMode;
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult {
let cur = *f(&mut s.view);
*f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur);
Ok(json!({"value": *f(&mut s.view)}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| {
let r = toggle(s, v, |x| &mut x.marks);
s.relayout();
r
})
.key("Mod+Shift+8 / Mod+8")
.pure(),
CommandSpec::new("view.printLayout", "Print Layout", "View › Views", |s, _| mode(s, ViewMode::Print, false)).pure(),
CommandSpec::new("view.webLayout", "Web Layout", "View › Views", |s, _| mode(s, ViewMode::Web, false)).pure(),
CommandSpec::new("view.draft", "Draft", "View › Views", |s, _| mode(s, ViewMode::Draft, false)).pure(),
CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| {
mode(s, ViewMode::Draft, false)?;
s.view.nav_pane = true;
sel_result(s)
})
.pure(),
CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(),
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(),
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)).pure(),
CommandSpec::new("view.navigationPane", "Navigation Pane", "View › Show", |s, v| toggle(s, v, |x| &mut x.nav_pane)).pure(),
CommandSpec::new("view.zoom", "Zoom", "View › Zoom", zoom).params(r#"{"value": percent (10-500) | "pageWidth" | "onePage" | "multiplePages"}"#).pure(),
CommandSpec::new("view.zoom100", "100%", "View › Zoom", |s, _| zoom(s, &json!({"value": 100}))).pure(),
CommandSpec::new("view.zoomIn", "Zoom In", "Status Bar", |s, _| {
let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0);
zoom(s, &json!({"value": z * 100.0}))
})
.key("Mod+Shift+=")
.pure(),
CommandSpec::new("view.zoomOut", "Zoom Out", "Status Bar", |s, _| {
let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1);
zoom(s, &json!({"value": z * 100.0}))
})
.key("Mod+-")
.pure(),
CommandSpec::new("view.onePage", "One Page", "View › Zoom", |s, _| zoom(s, &json!({"value": "onePage"}))).pure(),
CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(),
CommandSpec::new("view.pageWidth", "Page Width", "View › Zoom", |s, _| zoom(s, &json!({"value": "pageWidth"}))).pure(),
CommandSpec::new("view.darkMode", "Switch Modes", "View › Dark Mode", |s, v| toggle(s, v, |x| &mut x.dark_mode)).pure(),
CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(),
CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(),
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
s.ui_requests.push(json!({"open": "newWindow"}));
sel_result(s)
})
.pure(),
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
s.ui_requests.push(json!({"toggle": "split"}));
sel_result(s)
})
.pure(),
CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string()))).pure(),
]
}
fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
s.view.mode = m;
s.view.read_mode = read;
s.relayout();
Ok(json!({"mode": m, "readMode": read}))
}
fn zoom(s: &mut Session, v: &Value) -> CmdResult {
match v.get("value") {
Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => {
s.view.fit = f.clone();
s.view.multi_page = f == "multiplePages";
}
Some(x) => {
let pct = x.as_f64().ok_or_else(|| CmdError::Params("zoom percent or fit mode".into()))? as f32;
if !(10.0..=500.0).contains(&pct) {
return Err(CmdError::Params("zoom must be 10–500%".into()));
}
s.view.zoom = pct / 100.0;
s.view.fit.clear();
s.view.multi_page = false;
}
None => {
s.ui_requests.push(json!({"open": "zoom"}));
}
}
Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit}))
}
+86
View File
@@ -0,0 +1,86 @@
//! File format dispatch by extension. Format crates plug in here.
use wordcraft_doc::Document;
/// Formats WordCraft opens.
pub const OPEN_EXTS: &[&str] = &["docx", "docm", "dotx", "txt", "md", "markdown", "html", "htm", "rtf", "odt", "wcraft.json", "json"];
/// Formats WordCraft saves (Save As).
pub const SAVE_EXTS: &[&str] = &["docx", "pdf", "txt", "md", "html", "rtf", "odt", "png", "json"];
fn ext_of(name: &str) -> String {
let lower = name.to_ascii_lowercase();
lower.rsplit('.').next().unwrap_or("").to_string()
}
/// Parse a document from bytes; `name` gives the format by extension.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<Document, String> {
let ext = ext_of(name);
let mut doc = match ext.as_str() {
"txt" | "text" | "" => Document::from_text(&decode_text(bytes)),
"json" => serde_json::from_slice::<Document>(bytes).map_err(|e| format!("{name}: {e}"))?,
other => match crate::io_ext::open(other, bytes) {
Some(r) => r?,
None => return Err(format!("{name}: unsupported format `.{other}`")),
},
};
doc.ensure_nonempty();
Ok(doc)
}
/// Serialise a document; `name` gives the format by extension.
pub fn save_bytes(name: &str, doc: &Document) -> Result<Vec<u8>, String> {
let ext = ext_of(name);
match ext.as_str() {
"txt" | "text" => Ok(doc.plain_text(wordcraft_doc::StoryRef::Body).replace('\n', "\r\n").into_bytes()),
"json" => serde_json::to_vec_pretty(doc).map_err(|e| e.to_string()),
other => match crate::io_ext::save(other, doc) {
Some(r) => r,
None => Err(format!("{name}: can't save as `.{other}`")),
},
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if meta.len() > 2 << 30 {
return Err(format!("{}: file is larger than 2 GB", path.display()));
}
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
open_bytes(&path.to_string_lossy(), &bytes)
}
#[cfg(target_arch = "wasm32")]
pub fn open_path(path: &std::path::Path) -> Result<Document, String> {
Err(format!("{}: files are opened through the browser on the web", path.display()))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
let bytes = save_bytes(&path.to_string_lossy(), doc)?;
// Write atomically: temp file next to the target, then rename.
let tmp = path.with_extension(format!("{}.tmp", ext_of(&path.to_string_lossy())));
std::fs::write(&tmp, &bytes).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(target_arch = "wasm32")]
pub fn save_path(path: &std::path::Path, _doc: &Document) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
/// UTF-8 (with or without BOM), UTF-16 with BOM, else Latin-1.
pub fn decode_text(b: &[u8]) -> String {
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return String::from_utf8_lossy(rest).into_owned();
}
if b.len() >= 2 && (b[0] == 0xFF && b[1] == 0xFE || b[0] == 0xFE && b[1] == 0xFF) {
let le = b[0] == 0xFF;
let units: Vec<u16> = b[2..].chunks_exact(2).map(|c| if le { u16::from_le_bytes([c[0], c[1]]) } else { u16::from_be_bytes([c[0], c[1]]) }).collect();
return String::from_utf16_lossy(&units);
}
match std::str::from_utf8(b) {
Ok(s) => s.to_string(),
Err(_) => b.iter().map(|c| *c as char).collect(),
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Format crates registered with the I/O dispatcher.
use wordcraft_doc::Document;
/// Open a format other than plain text / JSON.
pub fn open(ext: &str, _bytes: &[u8]) -> Option<Result<Document, String>> {
match ext {
_ => None,
}
}
/// Save to a format other than plain text / JSON.
pub fn save(ext: &str, doc: &Document) -> Option<Result<Vec<u8>, String>> {
match ext {
"png" => Some(render_png(doc, 0, 2.0)),
_ => None,
}
}
/// Render page `page` as PNG at `scale` px/pt.
pub fn render_png(doc: &Document, page: usize, scale: f32) -> Result<Vec<u8>, String> {
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &Default::default());
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
Ok(img.to_png())
}
+155
View File
@@ -0,0 +1,155 @@
//! WordCraft engine: the editing session and its commands.
//!
//! **Everything is a command.** Each user-visible action is a [`CommandSpec`] with a stable id
//! (`format.bold`, `insert.table`), a label, the ribbon/menu location, a default shortcut, a
//! params description and a `run` function. Menus, ribbon, shortcuts, the command palette, the
//! CLI, the JSON control channel and the MCP server all call [`Session::run`].
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod catalog;
pub mod cmd;
pub mod io;
mod io_ext;
pub mod sample;
mod session;
use std::collections::HashMap;
use serde_json::Value;
pub use session::{FindState, Selection, Session, ViewState};
pub use wordcraft_doc as doc;
pub use wordcraft_layout as layout;
pub use wordcraft_render as render;
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum CmdError {
#[error("unknown command `{0}`")]
Unknown(String),
#[error("bad parameters: {0}")]
Params(String),
#[error("not available: {0}")]
Disabled(String),
#[error("{0}")]
Failed(String),
}
impl From<wordcraft_doc::DocError> for CmdError {
fn from(e: wordcraft_doc::DocError) -> Self {
CmdError::Failed(e.to_string())
}
}
pub type CmdResult = Result<Value, CmdError>;
/// A registered command.
#[derive(Clone)]
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Where it lives in the UI: `Home › Font`, `Insert › Tables`, `File`…
pub location: &'static str,
/// Default shortcut in a portable form (`Mod+B`, `Mod+Shift+>`, `F7`). `Mod` = Ctrl, or ⌘ on macOS.
pub shortcut: &'static str,
/// Parameter description for agents (`{"size": number}`).
pub params: &'static str,
pub mutates: bool,
/// `None` = enabled; `Some(reason)` = disabled.
pub enabled: fn(&Session) -> Option<&'static str>,
pub run: fn(&mut Session, &Value) -> CmdResult,
}
fn always(_: &Session) -> Option<&'static str> {
None
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, location: &'static str, run: fn(&mut Session, &Value) -> CmdResult) -> Self {
CommandSpec { id, label, location, shortcut: "", params: "{}", mutates: true, enabled: always, run }
}
pub const fn key(mut self, s: &'static str) -> Self {
self.shortcut = s;
self
}
pub const fn params(mut self, p: &'static str) -> Self {
self.params = p;
self
}
/// Doesn't change the document (navigation, view, export).
pub const fn pure(mut self) -> Self {
self.mutates = false;
self
}
pub const fn when(mut self, f: fn(&Session) -> Option<&'static str>) -> Self {
self.enabled = f;
self
}
}
/// All commands by id.
pub struct Registry {
specs: Vec<CommandSpec>,
by_id: HashMap<&'static str, usize>,
}
impl Registry {
pub fn new(specs: Vec<CommandSpec>) -> Self {
let mut by_id = HashMap::new();
for (i, s) in specs.iter().enumerate() {
by_id.insert(s.id, i);
}
Registry { specs, by_id }
}
pub fn get(&self, id: &str) -> Option<&CommandSpec> {
self.by_id.get(id).and_then(|i| self.specs.get(*i))
}
pub fn all(&self) -> &[CommandSpec] {
&self.specs
}
/// Command list as JSON (for agents: `commands` / `list_commands`).
pub fn describe(&self) -> Value {
Value::Array(
self.specs
.iter()
.map(|s| serde_json::json!({"id": s.id, "label": s.label, "location": s.location, "shortcut": s.shortcut, "params": s.params, "mutates": s.mutates}))
.collect(),
)
}
/// The command bound to a shortcut, if any.
pub fn by_shortcut(&self, key: &str) -> Option<&CommandSpec> {
self.specs.iter().find(|s| !s.shortcut.is_empty() && s.shortcut.split(" / ").any(|k| k.eq_ignore_ascii_case(key)))
}
}
/// Parameter helpers.
pub mod p {
use serde_json::Value;
use crate::CmdError;
pub fn str<'a>(v: &'a Value, k: &str) -> Option<&'a str> {
v.get(k).and_then(Value::as_str)
}
pub fn req_str<'a>(v: &'a Value, k: &str) -> Result<&'a str, CmdError> {
str(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (string) is required")))
}
pub fn f32(v: &Value, k: &str) -> Option<f32> {
v.get(k).and_then(Value::as_f64).map(|x| x as f32).filter(|x| x.is_finite())
}
pub fn req_f32(v: &Value, k: &str) -> Result<f32, CmdError> {
f32(v, k).ok_or_else(|| CmdError::Params(format!("`{k}` (number) is required")))
}
pub fn u64(v: &Value, k: &str) -> Option<u64> {
v.get(k).and_then(|x| x.as_u64().or_else(|| x.as_f64().filter(|f| *f >= 0.0 && f.is_finite()).map(|f| f as u64)))
}
pub fn bool(v: &Value, k: &str) -> Option<bool> {
v.get(k).and_then(Value::as_bool)
}
/// The `value` param, or the first positional-ish string.
pub fn value<'a>(v: &'a Value) -> Option<&'a Value> {
v.get("value")
}
}
#[cfg(test)]
mod tests;
+195
View File
@@ -0,0 +1,195 @@
//! Built-in sample documents and templates (original text, generated in code).
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CharProps, NumRef, Rgb, TabAlign, TabLeader, TabStop, TextColor};
use wordcraft_doc::{Block, Document, ListKind, Paragraph, PartKind, Table, para_block};
fn para(text: &str) -> Paragraph {
Paragraph::with_text(text, CharProps::default())
}
fn styled(text: &str, style: &str) -> Paragraph {
para(text).styled(style)
}
/// Append text with formatting to a paragraph.
fn add(p: &mut Paragraph, text: &str, props: CharProps) {
let at = p.len();
let _ = p.insert_text(at, text, &props);
}
fn bold() -> CharProps {
CharProps { bold: Some(true), ..Default::default() }
}
fn italic() -> CharProps {
CharProps { italic: Some(true), ..Default::default() }
}
/// The showcase document: a short studio handbook with headings, lists, a table, a quote,
/// a footer with page numbers.
pub fn sample_document() -> Document {
let mut d = Document::new();
d.core.title = "The Open Studio Handbook".into();
d.core.creator = "WordCraft".into();
let mut b: Vec<Block> = Vec::new();
b.push(Block::Para(styled("The Open Studio Handbook", "Title")));
b.push(Block::Para(styled("How a small community of makers shares space, tools and time", "Subtitle")));
let mut intro = para("Every Thursday evening the old print shop on Alder Street fills with people who make things. ");
add(&mut intro, "Painters", bold());
add(&mut intro, " set up by the north windows, ", CharProps::default());
add(&mut intro, "letterpress", italic());
add(&mut intro, " regulars claim the back room, and somebody always brings bread. This handbook collects what we have learned about running a shared studio: the routines that keep it pleasant, the tools we pool, and the small agreements that let thirty strangers become a crew.", CharProps::default());
b.push(Block::Para(intro));
b.push(Block::Para(styled("Why share a studio?", "Heading1")));
b.push(Block::Para(para("Rent is the obvious reason, but it is not the best one. A shared room puts your work next to work you would never have seen otherwise. A ceramicist explains glaze chemistry to a comic artist; a photographer borrows a weaver's loom for a still life. The cross-talk is the point.")));
let mut quote = styled("A studio is a place where unfinished things are welcome.", "Quote");
quote.props.space_before = Some(12.0);
b.push(Block::Para(quote));
b.push(Block::Para(styled("What we agree on", "Heading2")));
let mut num = d.numbering.clone();
let bullets = num.add_list(ListKind::Bullet);
let steps = num.add_list(ListKind::Numbered);
d.numbering = num;
for (lead, rest) in [
("Clean as you go. ", "Leave a table the way you would like to find it."),
("Label what is yours. ", "Unlabelled supplies on the commons shelf are fair game."),
("Ask before you borrow. ", "Even from the shelf, a quick note helps everyone keep track."),
("Quiet hours ", "run from 9 to 11 on weekday mornings."),
] {
let mut p = Paragraph::new().styled("ListParagraph");
add(&mut p, lead, bold());
add(&mut p, rest, CharProps::default());
p.props.numbering = Some(NumRef { num: bullets, level: 0 });
b.push(Block::Para(p));
}
b.push(Block::Para(styled("Shared equipment", "Heading2")));
b.push(Block::Para(para("The table below lists what the membership owns together and who keeps each item in working order.")));
let rows = [
["Equipment", "Location", "Steward", "Booking"],
["Etching press", "Back room", "Mara O.", "Sign-up sheet"],
["Kiln (cone 10)", "Yard shed", "Theo P.", "48 h notice"],
["Large-format printer", "Office", "Ines R.", "Calendar"],
["Light table", "North windows", "Anyone", "First come"],
];
let mut t = Table::new(rows.len(), 4, 468.0);
t.props.style = Some("GridTable4AccentBlue".into());
for (r, row) in rows.iter().enumerate() {
for (c, txt) in row.iter().enumerate() {
if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) {
cell.blocks = vec![para_block(para(txt))];
}
}
}
if let Some(r) = t.rows.first_mut() {
r.props.header = true;
}
b.push(Block::Table(t));
b.push(Block::Para(styled("Opening the studio", "Heading2")));
for s in ["Switch on the main breaker by the door.", "Open both skylights unless it is raining.", "Start the kettle. This step is not optional.", "Check the booking sheet for the kiln and press."] {
let mut p = para(s).styled("ListParagraph");
p.props.numbering = Some(NumRef { num: steps, level: 0 });
b.push(Block::Para(p));
}
b.push(Block::Para(styled("Membership", "Heading1")));
let mut m = para("Membership is ");
add(&mut m, "pay what you can", CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0x15, 0x60, 0x82))), ..Default::default() });
add(&mut m, ", reviewed every season. New members shadow a regular for their first two visits, and everyone takes one cleaning shift a month. If you have never made anything before, you are exactly who we hoped would show up.", CharProps::default());
b.push(Block::Para(m));
let mut h = para("Questions go to the front desk, or to the notice board by the sink. ");
add(&mut h, "Visit the community board", CharProps { style: Some("Hyperlink".into()), link: Some("https://getartcraft.com/".into()), ..Default::default() });
add(&mut h, " for events.", CharProps::default());
b.push(Block::Para(h));
d.body = b.into_iter().map(std::sync::Arc::new).collect();
// Footer: centred page number.
let mut f = Paragraph::new().styled("Footer");
f.props.align = Some(Align::Center);
let _ = f.insert_object(0, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false }, &CharProps::default());
let fid = d.add_part(PartKind::Footer, vec![para_block(f)]);
d.last_section.footers.default = Some(fid);
let mut hd = styled("The Open Studio Handbook\t\tSpring edition", "Header");
hd.props.borders = Some(Borders { bottom: Some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0xBF, 0xBF, 0xBF)), space: 4.0 }), ..Default::default() });
let hid = d.add_part(PartKind::Header, vec![para_block(hd)]);
d.last_section.headers.default = Some(hid);
d
}
/// A block-style letter.
pub fn letter() -> Document {
let mut d = Document::new();
let lines: Vec<Paragraph> = vec![
styled("Your Name", "Title"),
para("123 Your Street · City, ST 00000 · you@example.com"),
para(""),
para(&crate::cmd::insert::format_date("MMMM d, yyyy")),
para(""),
para("Recipient Name"),
para("Title, Company"),
para("Street Address"),
para(""),
para("Dear Recipient,"),
para("Start with a sentence that says why you are writing. Keep paragraphs short and specific, and close with what you would like to happen next."),
para("Thank you for your time."),
para("Sincerely,"),
para(""),
para("Your Name"),
];
d.body = lines.into_iter().map(para_block).collect();
d
}
/// A one-page résumé.
pub fn resume() -> Document {
let mut d = Document::new();
let mut b: Vec<Block> = vec![Block::Para(styled("Alex Rivera", "Title")), Block::Para(para("Illustrator · alex@example.com · portfolio.example.com"))];
for (head, items) in [
("Experience", vec![("Lead Illustrator, Harbor Books", "2021 – present"), ("Freelance Illustrator", "2016 – 2021")]),
("Education", vec![("BFA Illustration, Coastal College of Art", "2016")]),
("Skills", vec![("Ink, gouache, digital painting, book layout, lettering", "")]),
] {
b.push(Block::Para(styled(head, "Heading1")));
for (what, when) in items {
let mut p = para(what);
p.props.tabs = Some(vec![TabStop { pos: 468.0, align: TabAlign::Right, leader: TabLeader::None }]);
if !when.is_empty() {
add(&mut p, &format!("\t{when}"), italic());
}
b.push(Block::Para(p));
}
}
d.body = b.into_iter().map(std::sync::Arc::new).collect();
d
}
/// A report with a cover heading and sections.
pub fn report() -> Document {
let mut d = Document::new();
let b: Vec<Block> = vec![
Block::Para(styled("Report Title", "Title")),
Block::Para(styled("Subtitle or date", "Subtitle")),
Block::Para(styled("Summary", "Heading1")),
Block::Para(para("Summarise the findings in two or three sentences.")),
Block::Para(styled("Background", "Heading1")),
Block::Para(para("Describe the context and the question the report answers.")),
Block::Para(styled("Findings", "Heading1")),
Block::Para(styled("First finding", "Heading2")),
Block::Para(para("Explain the evidence.")),
Block::Para(styled("Recommendations", "Heading1")),
Block::Para(para("List what should happen next.")),
];
d.body = b.into_iter().map(std::sync::Arc::new).collect();
d
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn samples_are_valid() {
for d in [sample_document(), letter(), resume(), report()] {
assert!(d.word_count() > 5);
let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default());
assert!(!l.pages.is_empty());
}
}
}
+343
View File
@@ -0,0 +1,343 @@
//! The editing session: document, selection, undo/redo, layout, view state.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Path, Pos, StoryRef};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode};
use crate::{CmdError, Registry};
/// A selection: anchor (where it started) and focus (where the caret is).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Selection {
pub anchor: Pos,
pub focus: Pos,
}
impl Selection {
pub fn caret(p: Pos) -> Self {
Selection { anchor: p.clone(), focus: p }
}
pub fn is_collapsed(&self) -> bool {
self.anchor == self.focus
}
/// (start, end) in document order.
pub fn ordered(&self) -> (Pos, Pos) {
if self.anchor <= self.focus { (self.anchor.clone(), self.focus.clone()) } else { (self.focus.clone(), self.anchor.clone()) }
}
}
/// View state that commands can change (ribbon View tab, status bar).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewState {
pub zoom: f32,
pub mode: ViewMode,
pub read_mode: bool,
pub focus_mode: bool,
pub marks: bool,
pub ruler: bool,
pub gridlines: bool,
pub nav_pane: bool,
pub styles_pane: bool,
pub comments_pane: bool,
pub multi_page: bool,
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
pub fit: String,
pub web_width: f32,
pub dark_mode: bool,
pub show_markup: bool,
pub track_changes_pane: bool,
}
impl Default for ViewState {
fn default() -> Self {
ViewState {
zoom: 1.0,
mode: ViewMode::Print,
read_mode: false,
focus_mode: false,
marks: false,
ruler: true,
gridlines: false,
nav_pane: false,
styles_pane: false,
comments_pane: false,
multi_page: false,
fit: String::new(),
web_width: 800.0,
dark_mode: false,
show_markup: true,
track_changes_pane: false,
}
}
}
/// Find/replace state.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FindState {
pub query: String,
pub replace: String,
pub match_case: bool,
pub whole_word: bool,
pub regex: bool,
/// Matches from the last search: (start, end).
#[serde(skip)]
pub results: Vec<(Pos, Pos)>,
pub current: usize,
}
struct Undo {
label: String,
doc: Document,
sel: Selection,
}
/// An editing session.
pub struct Session {
pub doc: Document,
pub sel: Selection,
pub view: ViewState,
/// Formatting picked with a collapsed caret, applied to the next typed text.
pub pending: Option<CharProps>,
pub path: Option<std::path::PathBuf>,
pub dirty: bool,
pub clipboard: Option<Fragment>,
/// Plain text mirror of the clipboard (for the system clipboard).
pub clipboard_text: String,
pub find: FindState,
/// Page x the caret tries to keep on Up/Down.
pub goal_x: Option<f32>,
/// Page the caret was last on (headers/footers repeat on many pages).
pub page_hint: usize,
pub registry: Arc<Registry>,
/// Author name for comments and tracked changes.
pub author: String,
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
/// Last message for the status bar / agents.
pub status: String,
history: Vec<Undo>,
redo: Vec<Undo>,
/// Typing is coalesced into one undo step until something else happens.
typing_open: bool,
rev: u64,
cache: LayoutCache,
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>)>,
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
pub ui_requests: Vec<Value>,
}
/// Maximum undo depth.
const MAX_UNDO: usize = 500;
impl Session {
pub fn new(doc: Document) -> Self {
let start = doc.start_of(StoryRef::Body);
Session {
doc,
sel: Selection::caret(start),
view: ViewState::default(),
pending: None,
path: None,
dirty: false,
clipboard: None,
clipboard_text: String::new(),
find: FindState::default(),
goal_x: None,
page_hint: 0,
registry: Arc::new(crate::cmd::registry()),
author: "WordCraft User".into(),
painter: None,
status: String::new(),
history: Vec::new(),
redo: Vec::new(),
typing_open: false,
rev: 1,
cache: LayoutCache::new(),
layout: None,
ui_requests: Vec::new(),
}
}
/// Document revision (bumped by every change).
pub fn rev(&self) -> u64 {
self.rev
}
pub fn touch(&mut self) {
self.rev = self.rev.wrapping_add(1);
self.dirty = true;
}
/// The current layout (recomputed when the document or view changed).
pub fn layout(&mut self) -> Arc<DocLayout> {
let ww = self.view.web_width;
if let Some((r, w, m, l)) = &self.layout
&& *r == self.rev
&& (*w == ww || self.view.mode == ViewMode::Print)
&& *m == self.view.mode
{
return l.clone();
}
let opts = LayoutOptions { view: self.view.mode, web_width: ww, show_hidden: self.view.marks };
let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts));
self.layout = Some((self.rev, ww, self.view.mode, l.clone()));
l
}
/// Invalidate the cached layout (fonts changed etc.).
pub fn relayout(&mut self) {
self.layout = None;
self.cache.clear();
}
/// Snapshot for undo before a change.
pub fn checkpoint(&mut self, label: &str) {
if label == "Typing" && self.typing_open {
return;
}
self.typing_open = label == "Typing";
self.history.push(Undo { label: label.to_string(), doc: self.doc.clone(), sel: self.sel.clone() });
if self.history.len() > MAX_UNDO {
self.history.remove(0);
}
self.redo.clear();
}
/// Close an open typing group (caret moved, other command).
pub fn close_typing(&mut self) {
self.typing_open = false;
}
pub fn can_undo(&self) -> bool {
!self.history.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo_label(&self) -> Option<&str> {
self.history.last().map(|u| u.label.as_str())
}
pub fn redo_label(&self) -> Option<&str> {
self.redo.last().map(|u| u.label.as_str())
}
pub fn undo_labels(&self) -> Vec<String> {
self.history.iter().rev().map(|u| u.label.clone()).collect()
}
pub fn undo(&mut self) -> bool {
self.typing_open = false;
let Some(u) = self.history.pop() else { return false };
let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) };
self.redo.push(cur);
self.touch();
true
}
pub fn redo(&mut self) -> bool {
self.typing_open = false;
let Some(u) = self.redo.pop() else { return false };
let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) };
self.history.push(cur);
self.touch();
true
}
/// Drop history (after open / new).
pub fn reset_history(&mut self) {
self.history.clear();
self.redo.clear();
self.typing_open = false;
}
/// Replace the document (open/new).
pub fn set_document(&mut self, doc: Document) {
self.doc = doc;
self.doc.ensure_nonempty();
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
self.pending = None;
self.reset_history();
self.touch();
self.dirty = false;
self.relayout();
}
/// Make the selection valid for the current document.
pub fn clamp_selection(&mut self) {
self.sel.anchor = self.doc.clamp(&self.sel.anchor);
self.sel.focus = self.doc.clamp(&self.sel.focus);
}
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
let reg = self.registry.clone();
let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) };
if let Some(why) = (spec.enabled)(self) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
}
let before_doc = if spec.mutates { Some((self.doc.clone(), self.sel.clone(), self.history.len(), self.typing_open)) } else { None };
if spec.mutates && spec.id != "text.insert" {
self.typing_open = false;
}
if spec.mutates {
let label = if spec.id == "text.insert" { "Typing" } else { spec.label };
self.checkpoint(label);
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
// Non-mutating commands other than caret movement keep the typing group.
} else if spec.id.starts_with("caret.") {
self.typing_open = false;
}
let run = spec.run;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
let result = match result {
Ok(r) => r,
Err(p) => {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
Err(CmdError::Failed(format!("{id} panicked: {msg}")))
}
};
match &result {
Ok(_) => {
if spec.mutates {
self.touch();
self.doc.ensure_nonempty();
}
self.clamp_selection();
}
Err(e) => {
if let Some((d, s, h, t)) = before_doc {
self.doc = d;
self.sel = s;
self.history.truncate(h);
self.typing_open = t;
}
self.status = e.to_string();
}
}
result
}
/// The paragraph path the caret is in.
pub fn caret_path(&self) -> &Path {
&self.sel.focus.path
}
pub fn story(&self) -> StoryRef {
self.sel.focus.story
}
/// Formatting typing would use at the caret.
pub fn typing_props(&self) -> CharProps {
if let Some(p) = &self.pending {
return p.clone();
}
let f = &self.sel.focus;
self.doc.para_at(f).map(|p| p.props_at(f.off).clone()).unwrap_or_default()
}
/// Selected plain text.
pub fn selected_text(&self) -> String {
let (a, b) = self.sel.ordered();
self.doc.copy_range(&a, &b).plain_text()
}
}
+284
View File
@@ -0,0 +1,284 @@
use serde_json::json;
use wordcraft_doc::{Pos, StoryRef};
use crate::{Session, cmd};
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
#[test]
fn every_command_has_unique_id() {
let reg = cmd::registry();
let mut ids: Vec<&str> = reg.all().iter().map(|c| c.id).collect();
let n = ids.len();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), n, "duplicate command ids");
assert!(n > 150, "{n} commands");
}
#[test]
fn typing_enter_undo() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
run(&mut s, "text.insert", json!({"text": " world"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Second"}));
assert_eq!(text(&s), "Hello world\nSecond");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "Hello world\n");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "Hello world");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "Hello world");
}
#[test]
fn backspace_and_delete() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "abc"}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(text(&s), "ab");
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "cd"}));
run(&mut s, "caret.home", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(text(&s), "abcd");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(text(&s), "bcd");
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "text.deleteWordBack", json!({}));
assert_eq!(text(&s), "");
}
#[test]
fn bold_toggles_selection_and_caret_word() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "make this bold"}));
run(&mut s, "select.text", json!({"text": "this"}));
run(&mut s, "format.bold", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(5).bold, Some(true));
assert_eq!(p.props_of_char(0).bold, None);
run(&mut s, "format.bold", json!({}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(5).bold, Some(false));
// Caret inside a word formats the word.
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [0], "off": 11}}));
run(&mut s, "format.italic", json!({}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(10).italic, Some(true));
assert_eq!(p.props_of_char(13).italic, Some(true));
assert_eq!(p.props_of_char(9).italic, None);
}
#[test]
fn pending_format_applies_to_typing() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "a "}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "text.insert", json!({"text": "b"}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(2).bold, Some(true));
assert_eq!(p.props_of_char(0).bold, None);
}
#[test]
fn styles_and_lists() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Title"}));
run(&mut s, "para.heading1", json!({}));
run(&mut s, "text.newParagraph", json!({}));
// Heading's next style is Normal.
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.style.as_deref(), Some("Normal"));
run(&mut s, "para.bullets", json!({}));
run(&mut s, "text.insert", json!({"text": "one"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "two"}));
let n1 = s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.unwrap();
let n2 = s.doc.para_at(&Pos::body(2, 0)).unwrap().props.numbering.unwrap();
assert_eq!(n1.num, n2.num);
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.newParagraph", json!({})); // empty item ends the list
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.map(|n| n.num), Some(0));
// "1. " autoformat.
run(&mut s, "text.insert", json!({"text": "1."}));
run(&mut s, "text.insert", json!({"text": " "}));
assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_some_and(|n| n.num != 0));
}
#[test]
fn find_replace() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "cat dog cat\nCat bird"}));
let r = run(&mut s, "edit.find", json!({"text": "cat"}));
assert_eq!(r["count"], 3);
let r = run(&mut s, "edit.replaceAll", json!({"text": "cat", "with": "fox", "matchCase": true}));
assert_eq!(r["replaced"], 2);
assert_eq!(text(&s), "fox dog fox\nCat bird");
let r = run(&mut s, "edit.find", json!({"text": "\\b\\w{3}\\b", "regex": true, "matchCase": false}));
assert_eq!(r["count"], 4);
}
#[test]
fn clipboard_round_trip() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "alpha beta\ngamma"}));
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 1, "off": 2}}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard_text, "beta\nga");
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(text(&s), "alpha beta\ngammabeta\nga");
run(&mut s, "select.all", json!({}));
run(&mut s, "edit.cut", json!({}));
assert_eq!(text(&s), "");
run(&mut s, "edit.paste", json!({"text": "plain\ntext"}));
assert_eq!(text(&s), "plain\ntext");
}
#[test]
fn tables_commands() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
assert!(s.sel.focus.path.cell().is_some());
run(&mut s, "text.insert", json!({"text": "A1"}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "B1"}));
run(&mut s, "table.insertRowBelow", json!({}));
run(&mut s, "table.insertColumnRight", json!({}));
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.rows.len(), 3);
assert_eq!(t.cols(), 4);
run(&mut s, "table.deleteTable", json!({}));
assert!(s.doc.body.iter().all(|b| b.as_table().is_none()));
assert!(s.run("table.merge", &json!({})).is_err());
}
#[test]
fn page_setup_and_breaks() {
let mut s = s();
run(&mut s, "layout.orientation", json!({"value": "landscape"}));
assert!(s.doc.last_section.landscape);
run(&mut s, "layout.size", json!({"name": "A4"}));
assert!((s.doc.last_section.page_w - 841.89).abs() < 0.1);
run(&mut s, "layout.margins", json!({"preset": "narrow"}));
assert_eq!(s.doc.last_section.margin_left, 36.0);
run(&mut s, "text.insert", json!({"text": "one"}));
run(&mut s, "layout.break", json!({"kind": "nextPage"}));
run(&mut s, "text.insert", json!({"text": "two"}));
assert_eq!(s.doc.sections().len(), 2);
assert_eq!(s.layout().pages.len(), 2);
assert!(s.run("layout.margins", &json!({"left": 1000.0})).is_err());
}
#[test]
fn track_changes_and_accept() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "original"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.insert", json!({"text": " added"}));
run(&mut s, "select.text", json!({"text": "orig"}));
s.author = "Someone Else".into();
run(&mut s, "text.delete", json!({}));
// Deleted text stays (marked) until accepted.
assert_eq!(text(&s), "original added");
let ch = run(&mut s, "review.changes", json!({}));
assert_eq!(ch.as_array().unwrap().len(), 2);
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "inal added");
}
#[test]
fn comments() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Some text here"}));
run(&mut s, "select.text", json!({"text": "text"}));
let r = run(&mut s, "review.newComment", json!({"text": "Nice"}));
let id = r["id"].as_u64().unwrap();
let l = run(&mut s, "review.comments", json!({}));
assert_eq!(l[0]["text"], "Nice");
assert_eq!(text(&s), "Some text here");
run(&mut s, "review.deleteComment", json!({"id": id}));
assert!(s.doc.comments.is_empty());
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().objects.len(), 0);
}
#[test]
fn toc_and_fields() {
let mut s = Session::new(crate::sample::report());
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "references.toc", json!({}));
let t = text(&s);
assert!(t.contains("Contents"), "{t}");
assert!(t.contains("Summary\t1"), "{t}");
run(&mut s, "references.updateToc", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
}
#[test]
fn failed_command_leaves_document_unchanged() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "keep"}));
let before = text(&s);
assert!(s.run("format.size", &json!({"size": -3})).is_err());
assert!(s.run("no.such", &json!({})).is_err());
assert!(s.run("text.insert", &json!({})).is_err());
assert_eq!(text(&s), before);
}
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
let junk = [json!(null), json!({}), json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}), json!({"value": -1e308, "rows": 1e9})];
for spec in reg.all() {
if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" {
continue;
}
for j in &junk {
let mut s = Session::new(crate::sample::sample_document());
let _ = s.run(spec.id, j);
s.clamp_selection();
let _ = s.layout();
}
}
}
#[test]
fn inspect_reports_structure() {
let mut s = Session::new(crate::sample::sample_document());
let r = run(&mut s, "document.inspect", json!({}));
assert!(r["pages"].as_u64().unwrap() >= 1);
assert!(r["blocks"].as_array().unwrap().iter().any(|b| b["type"] == "table"));
let f = run(&mut s, "format.state", json!({}));
assert_eq!(f["styleName"], "Title");
}
#[test]
fn caret_navigation() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "first line\nsecond line"}));
run(&mut s, "caret.down", json!({}));
assert_eq!(s.sel.focus.path.last(), 1);
run(&mut s, "caret.end", json!({}));
assert_eq!(s.sel.focus.off, 11);
run(&mut s, "caret.up", json!({"extend": true}));
assert_eq!(s.sel.focus.path.last(), 0);
assert!(!s.sel.is_collapsed());
run(&mut s, "caret.wordLeft", json!({}));
run(&mut s, "caret.docEnd", json!({}));
assert_eq!(s.sel.focus, Pos::body(1, 11));
run(&mut s, "caret.left", json!({}));
assert_eq!(s.sel.focus.off, 10);
}