Print: preserve native workers across upstream platform shutdown changes
This commit is contained in:
@@ -33,7 +33,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest]
|
||||
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
|
||||
Generated
+19
@@ -5018,19 +5018,25 @@ version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-print",
|
||||
"dispatch2",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"getrandom 0.3.4",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"pollster 0.4.0",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"windows",
|
||||
"winresource",
|
||||
"wordcraft-control-key",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5132,6 +5138,7 @@ dependencies = [
|
||||
"skrifa 0.47.0",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"wuff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5275,6 +5282,7 @@ dependencies = [
|
||||
"web-sys",
|
||||
"wordcraft-doc",
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
]
|
||||
|
||||
@@ -5311,6 +5319,17 @@ version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
|
||||
[[package]]
|
||||
name = "wuff"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "200a7b806377b65f1ff9a985874332f15357c87e5dd7855f35f06dade95b8b3d"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"bytes",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
|
||||
@@ -47,6 +47,9 @@ regex-automata = { version = "0.4", default-features = false, features = ["std",
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
|
||||
# (brotli-decompressor, flate2) are already in the tree.
|
||||
wuff = "0.2.9"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
|
||||
+14
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (security review fixes landed; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Animation effects setting landed; previously trivial: every measurement follows the Options unit, #755; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -16,7 +16,7 @@ agent control. This page is the summary; the assessment is
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **482 / 497 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **483 / 498 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
|
||||
@@ -135,6 +135,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
|
||||
| 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) |
|
||||
| 2026-10-11 | Every measurement follows File › Options › Advanced › Measurement units (#755): the Line Numbers "From text" distance and the Hyphenation zone, still fixed in inches after #485, now convert like every other dialog |
|
||||
| 2026-10-11 | Review › Track Changes Options › Show revisions in balloons: tracked deletions in the body leave the text for "Deleted: …" balloons in the margin, joined to where they were (the option was saved but drew nothing new before) |
|
||||
| 2026-10-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) |
|
||||
| 2026-10-11 | Design tab: the style set gallery gets a box and gaps of its own, so it no longer runs into Colors and Fonts (#638) |
|
||||
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
|
||||
@@ -150,8 +154,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written |
|
||||
| 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly |
|
||||
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
|
||||
| 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents |
|
||||
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
|
||||
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
|
||||
| 2026-10-11 | Add fonts without rebuilding (#582): Home › Font › Add Font… (end of the font list), dropping a TTF/OTF/TTC/WOFF/WOFF2 file, or `font.add`/`font.list`/`font.remove` for agents. Files are checked (50 MB cap, every face must parse and map characters), never replace a bundled or loaded face, and are kept between runs (desktop: `fonts` in the settings folder; web: IndexedDB). Linking a font by URL is still to come |
|
||||
| 2026-10-11 | Print Markup (#589): File › Print/Export checkbox, `file.printMarkup`, `markup` on save/export commands and `wordcraft-cli convert --markup` print comments (with replies) in balloons beside each page, scaled to the paper like Word's "Document showing markup"; HTML, Markdown and text copies end with the comments |
|
||||
| 2026-10-11 | Custom colours (#606): every colour drop-down gains Recent Colors and More Colors…, which opens a Colors dialog with a Standard honeycomb and a Custom hue/saturation field, lightness slider, RGB/HSL numbers and a hex box |
|
||||
| 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time |
|
||||
@@ -174,8 +180,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color |
|
||||
| 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field |
|
||||
| 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip |
|
||||
| 2026-10-12 | Custom XML backing stores preserved as opaque data and related properties through edited DOCX saves; content-control markup is handled by the existing reader/writer and #532 bibliography I/O remains separate; binding evaluation and readiness unchanged |
|
||||
| 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) |
|
||||
| 2026-10-11 | Customize Keyboard: assign, remove and reset keyboard shortcuts; custom keys override built-ins and are kept between runs (#368) |
|
||||
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
|
||||
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
|
||||
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
|
||||
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
|
||||
@@ -199,6 +207,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Progress log: Animation effects setting |
|
||||
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
|
||||
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
|
||||
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
|
||||
@@ -223,9 +233,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) |
|
||||
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
|
||||
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
|
||||
| 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits |
|
||||
| 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) |
|
||||
| 2026-10-11 | trivial | Omitted `w:pPrDefault` stays omitted across saves; explicit empty/default properties and later spacing edits remain writable. Synthetic preservation checks only; readiness estimates unchanged |
|
||||
| 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) |
|
||||
| 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
|
||||
|
||||
@@ -20,6 +20,7 @@ egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
@@ -28,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList"] }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>WordCraft</title>
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/wordcraft.ico" />
|
||||
<link data-trunk rel="rust" data-bin="wordcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e6e6e6; }
|
||||
|
||||
@@ -11,6 +11,7 @@ use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
|
||||
mod font_store;
|
||||
const CANVAS_ID: &str = "wordcraft_canvas";
|
||||
const LOADING_ID: &str = "wordcraft_loading";
|
||||
|
||||
@@ -43,6 +44,8 @@ pub fn start() {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("wordcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
// Fonts the user added on an earlier visit (#582); the app notices them arrive.
|
||||
font_store::load_all(cc.egui_ctx.clone());
|
||||
let inbox: Inbox = Inbox::default();
|
||||
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
|
||||
log::error!("pasting pictures is unavailable: {e}");
|
||||
@@ -158,6 +161,8 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services
|
||||
let ctx = ctx.clone();
|
||||
let dialog = if purpose == "picture" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Pictures", IMAGE_EXTS)
|
||||
} else if purpose == "font" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS)
|
||||
} else if purpose == "recipients" {
|
||||
rfd::AsyncFileDialog::new().add_filter("Recipient lists", RECIPIENT_EXTS)
|
||||
} else {
|
||||
@@ -178,10 +183,21 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services
|
||||
print: Some(Box::new(print_pdf)),
|
||||
inbox: Some(inbox),
|
||||
on_dirty: Some(Box::new(move |d| dirty.set(d))),
|
||||
animations_enabled: Some(animations_enabled()),
|
||||
store_font: Some(Box::new(font_store::store)),
|
||||
forget_font: Some(Box::new(font_store::forget)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the system has animation effects on, as the browser passes it on: off while the
|
||||
/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The
|
||||
/// query is made once and asked every frame; it follows the system's setting by itself.
|
||||
fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
|
||||
let reduce = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
Box::new(move || reduce.as_ref().map(|query| !query.matches()))
|
||||
}
|
||||
|
||||
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
|
||||
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Fonts the user added in the browser (#582), kept in IndexedDB (`localStorage` holds about
|
||||
//! 5 MB, less than one CJK font) so they are there again after a reload. Every failure is logged
|
||||
//! and skipped: a font that can't be stored still works for the session.
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::{IdbDatabase, IdbRequest, IdbTransactionMode};
|
||||
|
||||
const DB_NAME: &str = "wordcraft";
|
||||
const STORE: &str = "fonts";
|
||||
|
||||
fn js(e: JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
|
||||
/// Wait for an IndexedDB request; its result.
|
||||
async fn done(req: &IdbRequest) -> Result<JsValue, String> {
|
||||
let promise = js_sys::Promise::new(&mut |resolve: js_sys::Function, reject: js_sys::Function| {
|
||||
let ok_req = req.clone();
|
||||
let ok = Closure::once_into_js(move || {
|
||||
let _ = resolve.call1(&JsValue::NULL, &ok_req.result().unwrap_or(JsValue::UNDEFINED));
|
||||
});
|
||||
let err = Closure::once_into_js(move || {
|
||||
let _ = reject.call1(&JsValue::NULL, &JsValue::from_str("IndexedDB request failed"));
|
||||
});
|
||||
req.set_onsuccess(Some(ok.unchecked_ref()));
|
||||
req.set_onerror(Some(err.unchecked_ref()));
|
||||
});
|
||||
wasm_bindgen_futures::JsFuture::from(promise).await.map_err(js)
|
||||
}
|
||||
|
||||
/// The database, with its font store made on first use.
|
||||
async fn open() -> Result<IdbDatabase, String> {
|
||||
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB is unavailable")?;
|
||||
let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?;
|
||||
let upgrade_req = req.clone();
|
||||
let upgrade = Closure::<dyn FnMut()>::new(move || {
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>().map_err(JsValue::from))
|
||||
&& !db.object_store_names().contains(STORE)
|
||||
&& let Err(e) = db.create_object_store(STORE)
|
||||
{
|
||||
log::error!("couldn't make the font store: {e:?}");
|
||||
}
|
||||
});
|
||||
req.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
|
||||
let db = done(&req).await;
|
||||
req.set_onupgradeneeded(None);
|
||||
drop(upgrade);
|
||||
db?.dyn_into::<IdbDatabase>().map_err(js)
|
||||
}
|
||||
|
||||
async fn store_async(name: String, bytes: Vec<u8>) -> Result<(), String> {
|
||||
let db = open().await?;
|
||||
let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?;
|
||||
let req = store.put_with_key(&js_sys::Uint8Array::from(bytes.as_slice()), &JsValue::from_str(&name)).map_err(js)?;
|
||||
done(&req).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn forget_async(name: String) -> Result<(), String> {
|
||||
let db = open().await?;
|
||||
let store = db.transaction_with_str_and_mode(STORE, IdbTransactionMode::Readwrite).map_err(js)?.object_store(STORE).map_err(js)?;
|
||||
let req = store.delete(&JsValue::from_str(&name)).map_err(js)?;
|
||||
done(&req).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Keep `bytes` (a font file as the user gave it) under `name` (its stored file name).
|
||||
pub fn store(name: &str, bytes: &[u8]) {
|
||||
let (name, bytes) = (name.to_string(), bytes.to_vec());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = store_async(name.clone(), bytes).await {
|
||||
log::error!("couldn't keep the font {name} for the next visit: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Forget the font stored under `name`.
|
||||
pub fn forget(name: &str) {
|
||||
let name = name.to_string();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = forget_async(name.clone()).await {
|
||||
log::error!("couldn't forget the stored font {name}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Add every stored font to the font database (each checked like a new one; one that fails is
|
||||
/// logged and skipped), then repaint: the app lays out again when fonts change.
|
||||
pub fn load_all(ctx: egui::Context) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let r: Result<(), String> = async {
|
||||
let db = open().await?;
|
||||
let store = db.transaction_with_str(STORE).map_err(js)?.object_store(STORE).map_err(js)?;
|
||||
let keys = done(&store.get_all_keys().map_err(js)?).await?;
|
||||
let values = done(&store.get_all().map_err(js)?).await?;
|
||||
let (keys, values) = (js_sys::Array::from(&keys), js_sys::Array::from(&values));
|
||||
for i in 0..keys.length().min(values.length()) {
|
||||
let Some(name) = keys.get(i).as_string() else { continue };
|
||||
let Ok(bytes) = values.get(i).dyn_into::<js_sys::Uint8Array>() else { continue };
|
||||
if bytes.length() as usize > wordcraft_fonts::MAX_FONT_FILE_BYTES {
|
||||
log::warn!("stored font {name} is too large; skipped");
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = wordcraft_fonts::FontDb::global().install_font(&name, &bytes.to_vec()) {
|
||||
log::warn!("stored font {name} skipped: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(e) = r {
|
||||
log::warn!("no stored fonts: {e}");
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,8 @@ workspace = true
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-engine = { workspace = true }
|
||||
# Fonts the user added (#582) load from the settings folder at start-up.
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The control key file's location (shared with the MCP bridge).
|
||||
wordcraft-control-key = { workspace = true }
|
||||
@@ -31,6 +33,26 @@ winresource = "0.1"
|
||||
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
|
||||
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
|
||||
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
|
||||
# at this version; this adds only the `NSAccessibility` bindings.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] }
|
||||
objc2 = "0.6"
|
||||
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
|
||||
# inside the AppKit run loop (see `schedule_macos_terminate`).
|
||||
dispatch2 = "0.3"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
# The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over
|
||||
# D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API.
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through
|
||||
# the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our
|
||||
# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds
|
||||
# only the `UI_ViewManagement` bindings.
|
||||
windows = { version = "0.62", features = ["UI_ViewManagement"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The system's animation setting, which File › Options › Accessibility › Animation effects
|
||||
//! follows when set to "Use system setting" (`Services::animations_enabled`).
|
||||
//!
|
||||
//! - Windows: Settings › Accessibility › Visual effects › Animation effects, read through the
|
||||
//! Windows Runtime's `UISettings`.
|
||||
//! - macOS: System Settings › Accessibility › Display › Reduce motion, read through `NSWorkspace`.
|
||||
//! - Linux and the BSDs: the desktop's setting, read through the settings portal ([`desktop`]).
|
||||
//!
|
||||
//! All three are safe APIs of crates already in the build (our workspace forbids unsafe). The UI
|
||||
//! asks every frame, so the answer always comes from memory: Windows and macOS are asked again at
|
||||
//! most once a second, and the Linux/BSD side keeps its answer up to date on a thread of its own.
|
||||
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
mod desktop;
|
||||
|
||||
/// The answer to "does the system have animation effects on?" for the UI: `None` when the system
|
||||
/// doesn't say. `ctx` is asked to redraw when an answer that arrives later changes it.
|
||||
pub fn hook(ctx: &egui::Context) -> Box<dyn Fn() -> Option<bool>> {
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
{
|
||||
let _ = ctx;
|
||||
Box::new(remembered(system_enabled, REFRESH))
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
Box::new(desktop::watch(ctx.clone()))
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", unix)))]
|
||||
{
|
||||
let _ = ctx;
|
||||
Box::new(|| None)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long an answer from Windows or macOS is kept before the system is asked again.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
const REFRESH: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// `read`, asked at most once per `every` and answered from memory in between.
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
fn remembered(read: impl Fn() -> Option<bool>, every: std::time::Duration) -> impl Fn() -> Option<bool> {
|
||||
let last = std::cell::Cell::new(None::<(std::time::Instant, Option<bool>)>);
|
||||
move || {
|
||||
if let Some((at, answer)) = last.get()
|
||||
&& at.elapsed() < every
|
||||
{
|
||||
return answer;
|
||||
}
|
||||
let answer = read();
|
||||
last.set(Some((std::time::Instant::now(), answer)));
|
||||
answer
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Windows has animation effects on; `None` when it can't be asked.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn system_enabled() -> Option<bool> {
|
||||
windows::UI::ViewManagement::UISettings::new()
|
||||
.and_then(|settings| settings.AnimationsEnabled())
|
||||
.map_err(|e| log::debug!("system animation setting: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Whether macOS plays animations: Reduce motion is off.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn system_enabled() -> Option<bool> {
|
||||
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::Cell;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::remembered;
|
||||
|
||||
/// The UI asks every frame; the system is asked once per interval and the answer kept between.
|
||||
#[test]
|
||||
fn the_system_is_asked_once_per_interval() {
|
||||
let asked = Cell::new(0);
|
||||
let system = Cell::new(Some(false));
|
||||
let read = || {
|
||||
asked.set(asked.get() + 1);
|
||||
system.get()
|
||||
};
|
||||
let kept = remembered(read, Duration::from_secs(3600));
|
||||
assert_eq!((kept(), kept(), kept()), (Some(false), Some(false), Some(false)));
|
||||
system.set(Some(true));
|
||||
assert_eq!(kept(), Some(false), "within the interval the remembered answer stands");
|
||||
assert_eq!(asked.get(), 1);
|
||||
|
||||
// Once the interval has passed (here: at once) the system is asked again.
|
||||
let fresh = remembered(read, Duration::ZERO);
|
||||
assert_eq!(fresh(), Some(true));
|
||||
system.set(None);
|
||||
assert_eq!(fresh(), None);
|
||||
assert_eq!(asked.get(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! The desktop's animation setting on Linux and the BSDs.
|
||||
//!
|
||||
//! Asked of the XDG settings portal over D-Bus (`org.freedesktop.portal.Settings`), which GNOME,
|
||||
//! KDE Plasma and the other desktops with a portal answer, inside Flatpak too. Three settings say
|
||||
//! whether animations play ([`KEYS`]); any one of them saying no turns them off. Where no portal
|
||||
//! answers, or it knows none of the three (a bare window manager), the user's own KDE and GTK
|
||||
//! settings files are read instead, once.
|
||||
//!
|
||||
//! A thread asks at start-up and then follows the portal's change signal, so the UI's question is
|
||||
//! always answered from memory and a change in the desktop's settings shows without a restart.
|
||||
|
||||
/// What a setting holds, whichever way it was stored.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Reading {
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
/// A setting as the portal names it: (namespace, key).
|
||||
type Key = (&'static str, &'static str);
|
||||
|
||||
/// The cross-desktop preference: 1 asks for reduced motion; 0, and values not defined yet, are
|
||||
/// no preference.
|
||||
const REDUCED_MOTION: Key = ("org.freedesktop.appearance", "reduced-motion");
|
||||
/// GNOME's switch, the one GTK follows.
|
||||
const GNOME_ANIMATIONS: Key = ("org.gnome.desktop.interface", "enable-animations");
|
||||
/// KDE Plasma's animation speed, a factor on every animation's length: 0 makes them instant.
|
||||
const KDE_ANIMATION_SPEED: Key = ("org.kde.kdeglobals.KDE", "AnimationDurationFactor");
|
||||
|
||||
/// The settings that say whether animations play.
|
||||
pub const KEYS: [Key; 3] = [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED];
|
||||
|
||||
/// Whether animations play according to one of [`KEYS`]; `None` for any other setting, or a
|
||||
/// value that isn't one it holds.
|
||||
pub fn says(namespace: &str, key: &str, value: &Reading) -> Option<bool> {
|
||||
match (namespace, key) {
|
||||
k if k == REDUCED_MOTION => number(value).map(|n| n != 1.0),
|
||||
k if k == GNOME_ANIMATIONS => truth(value),
|
||||
k if k == KDE_ANIMATION_SPEED => number(value).map(|n| n > 0.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(value: &Reading) -> Option<f64> {
|
||||
let n = match value {
|
||||
Reading::Number(n) => *n,
|
||||
Reading::Text(t) => t.trim().parse().ok()?,
|
||||
Reading::Bool(_) => return None,
|
||||
};
|
||||
n.is_finite().then_some(n)
|
||||
}
|
||||
|
||||
fn truth(value: &Reading) -> Option<bool> {
|
||||
match value {
|
||||
Reading::Bool(b) => Some(*b),
|
||||
Reading::Number(n) => n.is_finite().then_some(*n != 0.0),
|
||||
Reading::Text(t) => match t.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "1" => Some(true),
|
||||
"false" | "0" => Some(false),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Several settings' answers as one: off as soon as one says off, on when at least one says on,
|
||||
/// `None` when none says anything.
|
||||
pub fn combine(answers: impl IntoIterator<Item = Option<bool>>) -> Option<bool> {
|
||||
let mut any = None;
|
||||
for on in answers.into_iter().flatten() {
|
||||
if !on {
|
||||
return Some(false);
|
||||
}
|
||||
any = Some(true);
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// The value of `key` in `[section]` of a settings file in INI form (KDE's `kdeglobals`, GTK's
|
||||
/// `settings.ini`). The last one wins, as the toolkits read it.
|
||||
pub fn ini_value<'a>(text: &'a str, section: &str, key: &str) -> Option<&'a str> {
|
||||
let mut inside = false;
|
||||
let mut found = None;
|
||||
for line in text.lines().map(str::trim) {
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
inside = name.trim() == section;
|
||||
} else if inside
|
||||
&& !line.starts_with(['#', ';'])
|
||||
&& let Some((k, v)) = line.split_once('=')
|
||||
&& k.trim() == key
|
||||
{
|
||||
found = Some(v.trim());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// What the user's own settings files say, given their text (`None` for a file that isn't
|
||||
/// there): KDE Plasma's `kdeglobals` and GTK's `settings.ini`s.
|
||||
pub fn files_say(kdeglobals: Option<&str>, gtk_settings: [Option<&str>; 2]) -> Option<bool> {
|
||||
let (namespace, key) = KDE_ANIMATION_SPEED;
|
||||
let kde = kdeglobals.and_then(|text| ini_value(text, "KDE", key)).and_then(|v| says(namespace, key, &Reading::Text(v.to_string())));
|
||||
let gtk = gtk_settings
|
||||
.into_iter()
|
||||
.map(|text| text.and_then(|t| ini_value(t, "Settings", "gtk-enable-animations")).and_then(|v| truth(&Reading::Text(v.to_string()))));
|
||||
combine(std::iter::once(kde).chain(gtk))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub use watching::watch;
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod watching {
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use zbus::blocking::{Connection, Proxy};
|
||||
use zbus::zvariant::{OwnedValue, Value};
|
||||
|
||||
use super::{KEYS, Reading, combine, files_say, says};
|
||||
|
||||
// The latest answer, as one byte the UI thread reads without waiting.
|
||||
const UNKNOWN: u8 = 0;
|
||||
const ON: u8 = 1;
|
||||
const OFF: u8 = 2;
|
||||
|
||||
/// Start following the desktop's animation setting. The returned function gives the latest
|
||||
/// answer from memory (`None` until the first one arrives, and when the desktop says nothing);
|
||||
/// `ctx` is asked to redraw when the answer changes.
|
||||
pub fn watch(ctx: egui::Context) -> impl Fn() -> Option<bool> {
|
||||
let latest = Arc::new(AtomicU8::new(UNKNOWN));
|
||||
let shared = latest.clone();
|
||||
let spawned = std::thread::Builder::new().name("animation-setting".into()).spawn(move || {
|
||||
let publish = |answer: Option<bool>| {
|
||||
let code = match answer {
|
||||
Some(true) => ON,
|
||||
Some(false) => OFF,
|
||||
None => UNKNOWN,
|
||||
};
|
||||
if shared.swap(code, Ordering::Relaxed) != code {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
};
|
||||
if let Err(e) = follow_portal(&publish) {
|
||||
log::debug!("desktop animation setting: no settings portal ({e}); reading the settings files");
|
||||
publish(read_files());
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("desktop animation setting: {e}");
|
||||
}
|
||||
move || match latest.load(Ordering::Relaxed) {
|
||||
ON => Some(true),
|
||||
OFF => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish what the settings portal says, now and after every change it announces, until the
|
||||
/// connection ends; an error when there is no session bus to ask on. While the portal knows
|
||||
/// none of the settings, the files' answer stands.
|
||||
fn follow_portal(publish: &dyn Fn(Option<bool>)) -> zbus::Result<()> {
|
||||
let connection = Connection::session()?;
|
||||
let portal = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
|
||||
// Listen before the first read, so a change between the two isn't missed.
|
||||
let changes = portal.receive_signal("SettingChanged")?;
|
||||
// The files are read once, the first time the portal has nothing to say.
|
||||
let mut files = None;
|
||||
let mut ask = || ask_portal(&portal).or_else(|| *files.get_or_insert_with(read_files));
|
||||
publish(ask());
|
||||
// A setting can turn up later (KDE's has no value while the speed is at its default), so
|
||||
// the changes are followed whatever the portal knew at first. Every setting is asked
|
||||
// again, not just the one announced: KDE announces a new animation speed only as the
|
||||
// cross-desktop preference, and its own setting must not be left at the old value.
|
||||
for change in changes {
|
||||
let ours = change
|
||||
.body()
|
||||
.deserialize::<(String, String, OwnedValue)>()
|
||||
.is_ok_and(|(namespace, key, _)| KEYS.contains(&(namespace.as_str(), key.as_str())));
|
||||
if ours {
|
||||
publish(ask());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the portal's settings come to; `None` when it has none of them.
|
||||
fn ask_portal(portal: &Proxy<'_>) -> Option<bool> {
|
||||
combine(KEYS.map(|(namespace, key)| read_setting(portal, namespace, key).and_then(|value| says(namespace, key, &value))))
|
||||
}
|
||||
|
||||
/// One setting from the portal; `None` when it doesn't have it.
|
||||
fn read_setting(portal: &Proxy<'_>, namespace: &str, key: &str) -> Option<Reading> {
|
||||
// `ReadOne` is the current call; portals from before it only have `Read`.
|
||||
let value: OwnedValue = portal.call("ReadOne", &(namespace, key)).or_else(|_| portal.call("Read", &(namespace, key))).ok()?;
|
||||
reading(&value, 0)
|
||||
}
|
||||
|
||||
fn reading(value: &Value<'_>, depth: u8) -> Option<Reading> {
|
||||
Some(match value {
|
||||
Value::Bool(b) => Reading::Bool(*b),
|
||||
Value::U8(n) => Reading::Number(f64::from(*n)),
|
||||
Value::I16(n) => Reading::Number(f64::from(*n)),
|
||||
Value::U16(n) => Reading::Number(f64::from(*n)),
|
||||
Value::I32(n) => Reading::Number(f64::from(*n)),
|
||||
Value::U32(n) => Reading::Number(f64::from(*n)),
|
||||
Value::I64(n) => Reading::Number(*n as f64),
|
||||
Value::U64(n) => Reading::Number(*n as f64),
|
||||
Value::F64(n) => Reading::Number(*n),
|
||||
Value::Str(s) => Reading::Text(s.as_str().to_string()),
|
||||
// `Read` wraps its answer in a second variant.
|
||||
Value::Value(inner) if depth < 4 => return reading(inner, depth + 1),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// What the user's settings files say (see [`files_say`]).
|
||||
fn read_files() -> Option<bool> {
|
||||
let config = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.filter(|dir| !dir.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
|
||||
let text = |file: &str| read_capped(&config.join(file));
|
||||
let (kdeglobals, gtk4, gtk3) = (text("kdeglobals"), text("gtk-4.0/settings.ini"), text("gtk-3.0/settings.ini"));
|
||||
files_say(kdeglobals.as_deref(), [gtk4.as_deref(), gtk3.as_deref()])
|
||||
}
|
||||
|
||||
/// A settings file's text: at most 256 KiB of it (they are a few kilobytes).
|
||||
fn read_capped(path: &Path) -> Option<String> {
|
||||
use std::io::Read as _;
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(256 * 1024).read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn text(s: &str) -> Reading {
|
||||
Reading::Text(s.to_string())
|
||||
}
|
||||
|
||||
/// Each desktop's setting, as the portal hands it over, comes to animations on or off.
|
||||
#[test]
|
||||
fn each_desktop_setting_says_on_or_off() {
|
||||
let ((appearance, reduced), (gnome, enable), (kde, factor)) = (REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED);
|
||||
assert_eq!(KEYS, [REDUCED_MOTION, GNOME_ANIMATIONS, KDE_ANIMATION_SPEED]);
|
||||
// The cross-desktop preference: only 1 asks for reduced motion.
|
||||
assert_eq!(says(appearance, reduced, &Reading::Number(1.0)), Some(false));
|
||||
assert_eq!(says(appearance, reduced, &Reading::Number(0.0)), Some(true));
|
||||
assert_eq!(says(appearance, reduced, &Reading::Number(7.0)), Some(true), "a value not defined yet is no preference");
|
||||
assert_eq!(says(appearance, reduced, &Reading::Bool(true)), None);
|
||||
// GNOME's switch.
|
||||
assert_eq!(says(gnome, enable, &Reading::Bool(false)), Some(false));
|
||||
assert_eq!(says(gnome, enable, &Reading::Bool(true)), Some(true));
|
||||
assert_eq!(says(gnome, enable, &text("nonsense")), None);
|
||||
// KDE Plasma's animation speed arrives as text: 0 is instant.
|
||||
assert_eq!(says(kde, factor, &text("0")), Some(false));
|
||||
assert_eq!(says(kde, factor, &text(" 0.5 ")), Some(true));
|
||||
assert_eq!(says(kde, factor, &Reading::Number(1.0)), Some(true));
|
||||
for junk in [text(""), text("fast"), Reading::Number(f64::NAN), text("inf")] {
|
||||
assert_eq!(says(kde, factor, &junk), None, "{junk:?}");
|
||||
}
|
||||
// Any other setting says nothing.
|
||||
assert_eq!(says(gnome, "color-scheme", &Reading::Bool(false)), None);
|
||||
assert_eq!(says("org.example", enable, &Reading::Bool(false)), None);
|
||||
}
|
||||
|
||||
/// One setting saying off is enough; none saying anything is no answer.
|
||||
#[test]
|
||||
fn any_setting_saying_off_turns_animations_off() {
|
||||
assert_eq!(combine([]), None);
|
||||
assert_eq!(combine([None, None]), None);
|
||||
assert_eq!(combine([None, Some(true)]), Some(true));
|
||||
assert_eq!(combine([Some(true), None, Some(false)]), Some(false));
|
||||
assert_eq!(combine([Some(false), Some(true)]), Some(false));
|
||||
}
|
||||
|
||||
/// Without a portal the user's own settings files answer: KDE's animation speed and GTK's
|
||||
/// `gtk-enable-animations`.
|
||||
#[test]
|
||||
fn settings_files_answer_without_a_portal() {
|
||||
let kde_instant = "[General]\nColorScheme=Breeze\n\n[KDE]\nAnimationDurationFactor=0\nSingleClick=false\n";
|
||||
let kde_slow = "[KDE]\nAnimationDurationFactor = 2\n";
|
||||
let gtk_off = "[Settings]\ngtk-theme-name=Adwaita\ngtk-enable-animations=0\n";
|
||||
let gtk_on = "# written by hand\n[Settings]\ngtk-enable-animations=true\n";
|
||||
assert_eq!(files_say(None, [None, None]), None);
|
||||
assert_eq!(files_say(Some("[KDE]\nSingleClick=false\n"), [Some("[Settings]\n"), None]), None, "files that don't mention it");
|
||||
assert_eq!(files_say(Some(kde_instant), [None, None]), Some(false));
|
||||
assert_eq!(files_say(Some(kde_slow), [None, None]), Some(true));
|
||||
assert_eq!(files_say(None, [Some(gtk_off), None]), Some(false));
|
||||
assert_eq!(files_say(None, [None, Some(gtk_on)]), Some(true));
|
||||
assert_eq!(files_say(Some(kde_slow), [Some(gtk_on), Some(gtk_off)]), Some(false), "one file saying off is enough");
|
||||
|
||||
// The key counts only in its own section, comments don't, and the last value wins.
|
||||
assert_eq!(ini_value("[Other]\nAnimationDurationFactor=0\n[KDE]\nSingleClick=true\n", "KDE", "AnimationDurationFactor"), None);
|
||||
assert_eq!(ini_value("[KDE]\n#AnimationDurationFactor=0\n;AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None);
|
||||
assert_eq!(ini_value("[KDE]\r\nAnimationDurationFactor=0\r\nAnimationDurationFactor=1\r\n", "KDE", "AnimationDurationFactor"), Some("1"));
|
||||
assert_eq!(ini_value("AnimationDurationFactor=0\n", "KDE", "AnimationDurationFactor"), None, "outside any section");
|
||||
assert_eq!(ini_value("", "KDE", "AnimationDurationFactor"), None);
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,25 @@
|
||||
//! is queued; the frame (which has the window) builds an `rfd::AsyncFileDialog` parented to the
|
||||
//! window, and a background thread waits for the answer, hands it to the app and wakes the UI.
|
||||
//!
|
||||
//! On Linux and the BSDs rfd asks the xdg-desktop-portal FileChooser and, failing that, runs
|
||||
//! `zenity`; with neither (a minimal Wayland compositor such as labwc with only
|
||||
//! xdg-desktop-portal-wlr, #576) it answers "cancelled" and nothing ever shows. So the waiting
|
||||
//! thread first checks that one of them is there and otherwise answers with an error, and the app
|
||||
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
|
||||
//!
|
||||
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
use wordcraft_ui_egui::FileDialogRequest;
|
||||
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
|
||||
|
||||
/// A dialog the app asked for, with the channel its answer goes back through.
|
||||
type Request = (FileDialogRequest, Sender<Option<String>>);
|
||||
type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
|
||||
|
||||
/// The `Services::file_dialog` hook.
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<Option<String>>>;
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
|
||||
|
||||
/// A picker's answer, waited for off the UI thread.
|
||||
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
|
||||
@@ -51,11 +57,18 @@ impl Launcher {
|
||||
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
|
||||
let picking: Picking = match req {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
|
||||
FileDialogRequest::Save { name } => Box::pin(dialog.set_file_name(name).save_file()),
|
||||
FileDialogRequest::Save { name } => {
|
||||
let exts = save_exts(&name);
|
||||
Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file())
|
||||
}
|
||||
};
|
||||
let ctx = self.ctx.clone();
|
||||
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
let picked = pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string());
|
||||
// The picker only starts when first polled: without a system dialog it never does.
|
||||
let picked = match no_system_dialog() {
|
||||
Some(reason) => Err(reason),
|
||||
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
|
||||
};
|
||||
let _ = answer.send(picked);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
@@ -67,16 +80,102 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types Open offers: pictures for Insert/Change Picture, recipient lists for Select
|
||||
/// Recipients › Use an Existing List…, documents otherwise.
|
||||
fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
|
||||
if purpose == "picture" {
|
||||
d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"])
|
||||
} else if purpose == "recipients" {
|
||||
d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"])
|
||||
} else {
|
||||
d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"])
|
||||
.add_filter("Word document", &["docx"])
|
||||
.add_filter("All files", &["*"])
|
||||
/// The file types Open offers (shared with the built-in browser).
|
||||
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
|
||||
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
|
||||
d = d.add_filter(*name, exts);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Why no system file dialog can be shown, if it can't (#576); run off the UI thread.
|
||||
fn no_system_dialog() -> Option<String> {
|
||||
if std::env::var_os("WORDCRAFT_FILE_DIALOG").is_some_and(|v| v == "builtin") {
|
||||
return Some("WORDCRAFT_FILE_DIALOG=builtin".into());
|
||||
}
|
||||
portal_or_zenity_missing()
|
||||
}
|
||||
|
||||
/// Linux and the BSDs: rfd needs a file chooser portal or zenity. When `dbus-send` can't ask
|
||||
/// the session bus, the answer is unknown and rfd gets to try.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn portal_or_zenity_missing() -> Option<String> {
|
||||
if on_path("zenity") {
|
||||
return None;
|
||||
}
|
||||
let portal = std::process::Command::new("dbus-send")
|
||||
.args([
|
||||
"--session",
|
||||
"--print-reply=literal",
|
||||
"--reply-timeout=10000",
|
||||
"--dest=org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
"org.freedesktop.DBus.Properties.Get",
|
||||
"string:org.freedesktop.portal.FileChooser",
|
||||
"string:version",
|
||||
])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
match portal {
|
||||
Ok(status) if !status.success() => Some("no xdg-desktop-portal FileChooser on the session bus and no zenity".into()),
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
log::debug!("couldn't ask D-Bus for the file chooser portal: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows and macOS always have their system dialog.
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn portal_or_zenity_missing() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether an executable named `program` is in a `PATH` folder.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn on_path(program: &str) -> bool {
|
||||
std::env::var_os("PATH").is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(program).is_file()))
|
||||
}
|
||||
|
||||
/// The file types Save As offers: every format the engine saves, with the suggested name's own
|
||||
/// format first. rfd makes a filter's first extension the dialog's default, which the Windows,
|
||||
/// macOS and Linux panels append to a file name the user typed without one; with no filter at
|
||||
/// all the name came back bare and the save failed, since the format is chosen by extension
|
||||
/// (#689).
|
||||
fn save_exts(name: &str) -> Vec<String> {
|
||||
let suggested = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
|
||||
exts.push(suggested.clone());
|
||||
exts.extend(wordcraft_engine::io::SAVE_EXTS.iter().filter(|e| **e != suggested).map(|e| (*e).to_string()));
|
||||
exts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::save_exts;
|
||||
|
||||
/// #689: the Save dialog's default extension is the suggested name's own format, so a file
|
||||
/// name the user types without one is saved in the document's format instead of coming back
|
||||
/// bare and failing.
|
||||
#[test]
|
||||
fn save_dialog_defaults_to_the_suggested_format() {
|
||||
assert_eq!(save_exts("novel.docx")[0], "docx");
|
||||
assert_eq!(save_exts("report.odt")[0], "odt");
|
||||
assert_eq!(save_exts("annual report.pdf")[0], "pdf");
|
||||
// A suggested name always has an extension; anything unreadable falls back to Word's.
|
||||
assert_eq!(save_exts("no extension")[0], "docx");
|
||||
assert_eq!(save_exts("mystery.rtf2")[0], "docx");
|
||||
// Every savable format is still offered, once.
|
||||
let exts = save_exts("novel.docx");
|
||||
assert!(wordcraft_engine::io::SAVE_EXTS.iter().all(|e| exts.iter().any(|x| x == e)));
|
||||
assert_eq!(exts.len(), wordcraft_engine::io::SAVE_EXTS.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod animations;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
@@ -48,11 +49,23 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.0.close_requested() {
|
||||
let closing = ctx.input(|i| i.viewport().close_requested());
|
||||
if closing && !self.0.close_requested() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
if self.0.quit_requested && !self.0.printing.busy() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
|
||||
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
|
||||
// `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once
|
||||
// the close is allowed, take that path instead of dropping the window on this turn.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.0.quit_requested = false;
|
||||
schedule_macos_terminate(&self.0, self.2.as_ref());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -71,6 +84,40 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Quit the way Command-Q does, on the next turn of the main queue.
|
||||
///
|
||||
/// Called when the window is closing. Doing it on the next turn lets the close
|
||||
/// button's display cycle finish while the window still exists; `terminate:`
|
||||
/// then ends the process. Preferences are written and the control key file is
|
||||
/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]
|
||||
/// skips `on_exit`. A second close while a quit is already scheduled is ignored.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
if app.printing.busy() {
|
||||
return;
|
||||
}
|
||||
static QUIT: AtomicBool = AtomicBool::new(false);
|
||||
if QUIT.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
log::info!("window close: quitting");
|
||||
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
|
||||
print::finish_pending_jobs();
|
||||
save_prefs(app);
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
}
|
||||
dispatch2::DispatchQueue::main().exec_async(|| {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new() {
|
||||
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
|
||||
}
|
||||
// `terminate:` does not return when it succeeds. If it did, leave anyway:
|
||||
// staying up after the close was cancelled would trap the user in the window.
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
/// `ui.json` in the settings folder, which the MCP bridge also uses to find the control key.
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
wordcraft_control_key::settings_dir().map(|b| b.join("ui.json"))
|
||||
@@ -98,6 +145,19 @@ fn load_prefs(app: &mut WordApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fonts the user added (#582) live in `fonts` in the settings folder: load them, and store fonts
|
||||
/// added later there. Runs without preferences neither read nor keep them.
|
||||
fn load_user_fonts() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = wordcraft_control_key::settings_dir().map(|d| d.join("fonts")) else { return };
|
||||
let (loaded, skipped) = wordcraft_fonts::FontDb::global().load_user_fonts(&dir);
|
||||
if !loaded.is_empty() || !skipped.is_empty() {
|
||||
log::info!("user fonts: {} loaded, {} skipped from {}", loaded.len(), skipped.len(), dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
if !prefs_enabled() {
|
||||
@@ -122,13 +182,16 @@ fn save_prefs(app: &WordApp) {
|
||||
}
|
||||
|
||||
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
|
||||
/// through the system spooler (`print`), and pictures on the system clipboard (#45).
|
||||
fn services(file_dialog: file_dialogs::Hook) -> Services {
|
||||
/// through the system spooler (`print`), pictures/text on the system clipboard (#45), and
|
||||
/// the system's animation setting (`animations`).
|
||||
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
Services {
|
||||
file_dialog: Some(file_dialog),
|
||||
printers: Some(print::discover()),
|
||||
print_native: Some(print::submit()),
|
||||
clipboard_picture: Some(Box::new(clipboard_picture)),
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -213,6 +276,7 @@ fn main() -> eframe::Result {
|
||||
// DLL of ours (#603; see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
load_user_fonts();
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
@@ -229,7 +293,7 @@ fn main() -> eframe::Result {
|
||||
Box::new(move |cc| {
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
||||
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
|
||||
let mut app = WordApp::new(Session::new(doc), services(file_dialog));
|
||||
let mut app = WordApp::new(Session::new(doc), services(file_dialog, &cc.egui_ctx));
|
||||
load_prefs(&mut app);
|
||||
app.ui.window = restored;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
|
||||
@@ -38,12 +38,22 @@ real_name = "Dmitrii Khlapov"
|
||||
display_name = "Dima Khlapov"
|
||||
sync_github_name = false
|
||||
|
||||
[people.arcanii]
|
||||
real_name = "Bryan Mark"
|
||||
display_name = "Arcanii"
|
||||
sync_github_name = false
|
||||
|
||||
[people.henno]
|
||||
sync_github_name = true
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
[people.DevCoderMax]
|
||||
real_name = "Gleyson do Nascimento Carvalho"
|
||||
display_name = "Gleyson Carvalho"
|
||||
sync_github_name = false
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.edurbs]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -445,6 +445,10 @@ pub struct Settings {
|
||||
/// Equation options (`m:mathPr`), when the document has them.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub math: Option<math::MathProps>,
|
||||
/// Citation and bibliography style (References › Style) by name, when the document
|
||||
/// records one (OOXML's `b:Sources/@StyleName`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bib_style: Option<String>,
|
||||
/// The template the document is attached to (Tools › Templates and Add-ins): the target of
|
||||
/// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -525,6 +529,7 @@ impl Default for Settings {
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
math: None,
|
||||
bib_style: None,
|
||||
attached_template: None,
|
||||
link_styles: false,
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod rt {
|
||||
pub const CORE: &str = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
|
||||
pub const APP: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties";
|
||||
pub const CUSTOM: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties";
|
||||
pub const CUSTOM_XML_PROPS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXmlProps";
|
||||
pub const STYLES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
|
||||
pub const NUMBERING: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
|
||||
pub const SETTINGS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
|
||||
@@ -39,6 +40,7 @@ pub mod rt {
|
||||
pub const DIAGRAM_LAYOUT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramLayout";
|
||||
pub const DIAGRAM_QUICK_STYLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle";
|
||||
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
|
||||
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
||||
}
|
||||
|
||||
/// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run).
|
||||
@@ -55,6 +57,9 @@ pub const MAX_VBA_RELATED: usize = 64;
|
||||
/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its
|
||||
/// part name. Not a package part: WordCraft's own bookkeeping.
|
||||
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
|
||||
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
|
||||
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
|
||||
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
|
||||
/// Most parts we carry along for a file's embedded objects.
|
||||
pub const MAX_EMBEDDED_PARTS: usize = 4096;
|
||||
/// Most relationships we keep for one such part.
|
||||
|
||||
@@ -13,13 +13,37 @@ use std::sync::Arc;
|
||||
use wordcraft_doc::graphic::{Embedded, EmbeddedRef};
|
||||
|
||||
use super::Reader;
|
||||
use crate::package::{ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok};
|
||||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, ContentTypes, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, MAX_EMBEDDED_XML, Rel, Rels, manifest_field_ok, rel_is, rt,
|
||||
};
|
||||
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
|
||||
|
||||
/// Most relationship ids one object's markup may use.
|
||||
const MAX_IDS: usize = 64;
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Keep custom XML stores reached from the main document or package, without evaluating
|
||||
/// bindings. A bibliography already loaded into the source model belongs to its writer.
|
||||
pub(super) fn keep_custom_xml(&mut self, package: &Rels, document: &Rels) {
|
||||
let mut roots = String::new();
|
||||
for (owner, rels) in [("P", package), ("D", document)] {
|
||||
for rel in rels.list.iter().filter(|r| !r.external && rel_is(&r.kind, rt::CUSTOM_XML)).take(MAX_EMBEDDED_RELS) {
|
||||
if ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s)) {
|
||||
continue;
|
||||
}
|
||||
if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") {
|
||||
continue;
|
||||
}
|
||||
if self.keep_part_tree(&rel.target) {
|
||||
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !roots.is_empty() {
|
||||
self.doc.passthrough.insert(CUSTOM_XML_ROOTS.into(), Arc::new(roots.into_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `el` (an object's `a:graphic` or `w:object`, read with `rels`) and the parts it refers
|
||||
/// to, so the object can be written back. `extra` names relationships the object's parts use
|
||||
/// by id although its markup doesn't (a SmartArt drawing, named in the diagram's data part).
|
||||
|
||||
@@ -36,6 +36,20 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's
|
||||
/// charts and diagrams; later ones are left empty.
|
||||
const MAX_GRAPHIC_WORK: usize = 2_000_000;
|
||||
/// Bibliography sources kept from a file (Word's own source manager holds far fewer).
|
||||
const MAX_SOURCES: usize = 10_000;
|
||||
/// Custom XML parts looked at for bibliography sources.
|
||||
const MAX_BIBLIOGRAPHY_PARTS: usize = 64;
|
||||
/// Characters kept of a bibliography's citation style name.
|
||||
const MAX_STYLE_NAME: usize = 64;
|
||||
|
||||
/// Bibliography sources already read: tags, and (title, author) of untagged ones.
|
||||
#[derive(Default)]
|
||||
struct BibSeen {
|
||||
tags: HashSet<String>,
|
||||
untagged: HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
/// Longest attached-template path we keep (bytes); a longer one is not a real path.
|
||||
const MAX_TEMPLATE_PATH: usize = 32 * 1024;
|
||||
|
||||
@@ -181,6 +195,31 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
let ex = part(rt::COMMENTS_EX);
|
||||
lenient("comments", r.read_comments(&p, ex.as_deref()));
|
||||
}
|
||||
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
|
||||
// package root or the main part; Word's own files sometimes leave the relationship out, so
|
||||
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
|
||||
let mut bib_parts: Vec<String> = Vec::new();
|
||||
for rel in root_rels.list.iter().chain(rels.list.iter()) {
|
||||
if rel_is(&rel.kind, rt::CUSTOM_XML) && !rel.external && !bib_parts.contains(&rel.target) {
|
||||
bib_parts.push(rel.target.clone());
|
||||
if bib_parts.len() >= MAX_BIBLIOGRAPHY_PARTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 1..=16 {
|
||||
let path = format!("customXml/item{i}.xml");
|
||||
if bib_parts.len() < MAX_BIBLIOGRAPHY_PARTS && pkg.get(&path).is_some() && !bib_parts.contains(&path) {
|
||||
bib_parts.push(path);
|
||||
}
|
||||
}
|
||||
let mut seen_sources = BibSeen::default();
|
||||
for path in &bib_parts {
|
||||
if r.doc.sources.len() >= MAX_SOURCES {
|
||||
break;
|
||||
}
|
||||
lenient("customXml bibliography", r.read_bibliography(path, &mut seen_sources));
|
||||
}
|
||||
|
||||
if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) {
|
||||
r.doc.settings.page_color = Some(bg);
|
||||
@@ -213,6 +252,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec()));
|
||||
r.read_vba_related(&v);
|
||||
}
|
||||
r.keep_custom_xml(&root_rels, &rels);
|
||||
if !r.embedded.parts.is_empty() {
|
||||
let manifest = r.embedded.to_bytes();
|
||||
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
|
||||
@@ -1067,6 +1107,130 @@ impl Reader<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sources from a `b:Sources` custom XML part (other custom XML is left alone); at most
|
||||
/// [`MAX_SOURCES`] in all, and the document's citation style from `StyleName`.
|
||||
fn read_bibliography(&mut self, path: &str, seen: &mut BibSeen) -> Result<(), DocxError> {
|
||||
let Some(root) = self.xml(path)? else { return Ok(()) };
|
||||
if root.local() != "Sources" {
|
||||
return Ok(());
|
||||
}
|
||||
if self.doc.settings.bib_style.is_none()
|
||||
&& let Some(name) = root.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some("StyleName")).map(|(_, v)| v.trim())
|
||||
&& !name.is_empty()
|
||||
{
|
||||
self.doc.settings.bib_style = Some(name.chars().take(MAX_STYLE_NAME).collect());
|
||||
}
|
||||
let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default();
|
||||
let parse_persons = |parent: &El| -> String {
|
||||
let mut names = Vec::new();
|
||||
let person_els: Vec<&El> = {
|
||||
let in_lists: Vec<&El> =
|
||||
parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect();
|
||||
if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists }
|
||||
};
|
||||
for name_el in person_els {
|
||||
let last = child_text(name_el, "Last");
|
||||
let first = child_text(name_el, "First");
|
||||
let middle = child_text(name_el, "Middle");
|
||||
let name = if !first.is_empty() && !middle.is_empty() {
|
||||
format!("{last}, {first} {middle}")
|
||||
} else if !first.is_empty() {
|
||||
format!("{last}, {first}")
|
||||
} else {
|
||||
last
|
||||
};
|
||||
if !name.is_empty() {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
names.join("; ")
|
||||
};
|
||||
|
||||
for src_el in root.els().filter(|e| e.local() == "Source") {
|
||||
if self.doc.sources.len() >= MAX_SOURCES {
|
||||
log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped");
|
||||
break;
|
||||
}
|
||||
let tag = child_text(src_el, "Tag");
|
||||
let raw_kind = child_text(src_el, "SourceType");
|
||||
let kind = crate::write::from_source_type(&raw_kind);
|
||||
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
|
||||
Some(a) => {
|
||||
let auth =
|
||||
a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
|
||||
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
|
||||
(auth, ed)
|
||||
}
|
||||
None => (String::new(), String::new()),
|
||||
};
|
||||
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
|
||||
let title = child_text(src_el, "Title");
|
||||
let year = child_text(src_el, "Year");
|
||||
let month = child_text(src_el, "Month");
|
||||
let day = child_text(src_el, "Day");
|
||||
let city = child_text(src_el, "City");
|
||||
let publisher = child_text(src_el, "Publisher");
|
||||
let journal = child_text(src_el, "JournalName");
|
||||
let edition = child_text(src_el, "Edition");
|
||||
let volume = child_text(src_el, "Volume");
|
||||
let issue = child_text(src_el, "Issue");
|
||||
let pages = child_text(src_el, "Pages");
|
||||
let url = child_text(src_el, "URL");
|
||||
let accessed = match child_text(src_el, "Accessed") {
|
||||
a if !a.is_empty() => a,
|
||||
_ => {
|
||||
let y = child_text(src_el, "YearAccessed");
|
||||
let m = child_text(src_el, "MonthAccessed");
|
||||
let d = child_text(src_el, "DayAccessed");
|
||||
if !y.is_empty() && !m.is_empty() && !d.is_empty() {
|
||||
format!("{y}-{m}-{d}")
|
||||
} else if !y.is_empty() && !m.is_empty() {
|
||||
format!("{y}-{m}")
|
||||
} else {
|
||||
y
|
||||
}
|
||||
}
|
||||
};
|
||||
let doi = match child_text(src_el, "DOI") {
|
||||
d if !d.is_empty() => d,
|
||||
_ => child_text(src_el, "StandardNumber"),
|
||||
};
|
||||
|
||||
// Keep sources with some content, once each (by tag, or title and author untagged).
|
||||
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let fresh = if tag.is_empty() {
|
||||
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
|
||||
} else {
|
||||
seen.tags.insert(tag.clone())
|
||||
};
|
||||
if fresh {
|
||||
self.doc.sources.push(wordcraft_doc::Source {
|
||||
tag,
|
||||
kind,
|
||||
author,
|
||||
title,
|
||||
year,
|
||||
city,
|
||||
publisher,
|
||||
journal,
|
||||
volume,
|
||||
pages,
|
||||
url,
|
||||
editor,
|
||||
edition,
|
||||
issue,
|
||||
month,
|
||||
day,
|
||||
accessed,
|
||||
doi,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_core(&mut self, path: &str) {
|
||||
let Ok(Some(root)) = self.xml(path) else { return };
|
||||
let c = &mut self.doc.core;
|
||||
|
||||
@@ -13,7 +13,9 @@ use wordcraft_doc::graphic::Embedded;
|
||||
use wordcraft_doc::para::Float;
|
||||
|
||||
use super::PartRels;
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, VBA_PROJECT_PART, VBA_RELATED, relative};
|
||||
use crate::package::{
|
||||
CUSTOM_XML_ROOTS, EMBEDDED_PARTS, EmbeddedManifest, MAX_EMBEDDED_PARTS, MAX_EMBEDDED_RELS, VBA_PROJECT_PART, VBA_RELATED, rel_is, relative, rt,
|
||||
};
|
||||
use crate::units::emu;
|
||||
use crate::xml::{self, El, MAX_DEPTH};
|
||||
|
||||
@@ -64,6 +66,10 @@ impl<'d> EmbedWriter<'d> {
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
|
||||
// The bibliography writer uses these names when sources are present (#532).
|
||||
if !doc.sources.is_empty() {
|
||||
taken.extend(["customxml/item1.xml".into(), "customxml/itemprops1.xml".into()]);
|
||||
}
|
||||
// The macro project's parts keep their names (see `write_as`).
|
||||
if let Some(m) = doc.passthrough.get(VBA_RELATED) {
|
||||
for line in String::from_utf8_lossy(m).lines() {
|
||||
@@ -101,6 +107,9 @@ impl<'d> EmbedWriter<'d> {
|
||||
|
||||
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
|
||||
fn alloc(&mut self, orig: &str) -> String {
|
||||
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
|
||||
// part needs a different base name, otherwise every candidate would be reserved.
|
||||
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
|
||||
let lower = orig.to_ascii_lowercase();
|
||||
if !Self::reserved(&lower) && self.taken.insert(lower) {
|
||||
return orig.to_string();
|
||||
@@ -157,6 +166,25 @@ impl<'d> EmbedWriter<'d> {
|
||||
self.manifest.parts.contains_key(name) && self.passthrough.contains_key(name)
|
||||
}
|
||||
|
||||
/// Reattach implicit custom XML roots, sharing their kept parts across both owners.
|
||||
pub fn custom_xml_rels(&mut self, owner: &str, rels: &mut PartRels) {
|
||||
let Some(roots) = self.passthrough.get(CUSTOM_XML_ROOTS).cloned() else { return };
|
||||
let mut memo = std::mem::take(&mut self.first);
|
||||
let mut written = Vec::new();
|
||||
for line in String::from_utf8_lossy(&roots).lines().take(MAX_EMBEDDED_RELS * 2) {
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
let [source, kind, target] = fields.as_slice() else { continue };
|
||||
if *source != owner || !rel_is(kind, rt::CUSTOM_XML) {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = self.copy_tree(target, &mut memo, &mut written) {
|
||||
let source_part = if owner == "P" { "" } else { STORY_PART };
|
||||
rels.add(kind, &relative(source_part, &name), false);
|
||||
}
|
||||
}
|
||||
self.first = memo;
|
||||
}
|
||||
|
||||
/// What copying the parts reached from `roots` costs against the copy budget.
|
||||
fn tree_cost(&self, roots: &[&str]) -> u64 {
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
|
||||
@@ -170,6 +170,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
let mut overrides: Vec<(String, String)> = Vec::new();
|
||||
let mut rels = PartRels::default();
|
||||
let mut root = PartRels::default();
|
||||
|
||||
// Comments get paragraph ids before any story is written.
|
||||
let mut comment_paras: Vec<(u32, String)> = Vec::new();
|
||||
@@ -380,7 +381,9 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
}
|
||||
}
|
||||
|
||||
// Parts of charts, diagrams and OLE objects kept from a file, as the objects written need them.
|
||||
wr.embeds.custom_xml_rels("D", &mut rels);
|
||||
wr.embeds.custom_xml_rels("P", &mut root);
|
||||
// Parts of embedded objects and custom XML stores kept from the file.
|
||||
for (name, bytes, ct, prels) in std::mem::take(&mut wr.embeds.parts) {
|
||||
push_part(&mut entries, &mut overrides, &name, bytes, &ct, prels);
|
||||
}
|
||||
@@ -427,7 +430,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
entries.insert(1, ("word/_rels/document.xml.rels".into(), rels.xml()));
|
||||
|
||||
// Package-level parts.
|
||||
let mut root = PartRels::default();
|
||||
root.add(rt::OFFICE_DOC, "word/document.xml", false);
|
||||
root.add(rt::CORE, "docProps/core.xml", false);
|
||||
root.add(rt::APP, "docProps/app.xml", false);
|
||||
@@ -440,6 +442,27 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
entries.push(("docProps/custom.xml".into(), crate::custom::write(&doc.custom_props)));
|
||||
overrides.push(("/docProps/custom.xml".into(), "application/vnd.openxmlformats-officedocument.custom-properties+xml".into()));
|
||||
}
|
||||
if !doc.sources.is_empty() {
|
||||
root.add(rt::CUSTOM_XML, "customXml/item1.xml", false);
|
||||
let mut item_rels = PartRels::default();
|
||||
item_rels.add(rt::CUSTOM_XML_PROPS, "itemProps1.xml", false);
|
||||
push_part(
|
||||
&mut entries,
|
||||
&mut overrides,
|
||||
"customXml/item1.xml",
|
||||
bibliography_xml(doc),
|
||||
"application/vnd.openxmlformats-officedocument.customXml+xml",
|
||||
item_rels,
|
||||
);
|
||||
push_part(
|
||||
&mut entries,
|
||||
&mut overrides,
|
||||
"customXml/itemProps1.xml",
|
||||
bibliography_props_xml(),
|
||||
"application/vnd.openxmlformats-officedocument.customXmlProperties+xml",
|
||||
PartRels::default(),
|
||||
);
|
||||
}
|
||||
entries.insert(0, ("_rels/.rels".into(), root.xml()));
|
||||
|
||||
// Content types.
|
||||
@@ -1058,3 +1081,211 @@ fn app_xml(doc: &Document) -> Vec<u8> {
|
||||
w.close("Properties");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
fn to_source_type(kind: &str) -> &str {
|
||||
match kind.to_ascii_lowercase().as_str() {
|
||||
"book" => "Book",
|
||||
"booksection" | "chapter" => "BookSection",
|
||||
"article" | "journal" | "journalarticle" => "JournalArticle",
|
||||
"periodical" | "articleinaperiodical" => "ArticleInAPeriodical",
|
||||
"website" | "site" | "internetsite" => "InternetSite",
|
||||
"documentfrominternetsite" => "DocumentFromInternetSite",
|
||||
"electronicsource" => "ElectronicSource",
|
||||
"report" => "Report",
|
||||
"conference" | "proceedings" | "conferenceproceedings" => "ConferenceProceedings",
|
||||
"case" => "Case",
|
||||
"art" => "Art",
|
||||
"film" => "Film",
|
||||
"interview" => "Interview",
|
||||
"patent" => "Patent",
|
||||
"performance" => "Performance",
|
||||
"soundrecording" => "SoundRecording",
|
||||
"standard" => "Standard",
|
||||
"thesis" => "Thesis",
|
||||
"other" | "misc" => "Misc",
|
||||
_ => "Book",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_source_type(st: &str) -> String {
|
||||
match st.trim() {
|
||||
"Book" | "book" => "book".into(),
|
||||
"BookSection" | "booksection" => "bookSection".into(),
|
||||
"JournalArticle" | "journalarticle" | "article" => "article".into(),
|
||||
"ArticleInAPeriodical" | "articleinaperiodical" => "periodical".into(),
|
||||
"InternetSite" | "internetsite" | "website" => "website".into(),
|
||||
"DocumentFromInternetSite" | "documentfrominternetsite" => "website".into(),
|
||||
"ElectronicSource" | "electronicsource" => "electronicsource".into(),
|
||||
"Report" | "report" => "report".into(),
|
||||
"ConferenceProceedings" | "conferenceproceedings" => "conference".into(),
|
||||
"Case" | "case" => "case".into(),
|
||||
"SoundRecording" | "soundrecording" => "soundrecording".into(),
|
||||
"Film" | "film" => "film".into(),
|
||||
"Interview" | "interview" => "interview".into(),
|
||||
"Patent" | "patent" => "patent".into(),
|
||||
"Performance" | "performance" => "performance".into(),
|
||||
"Standard" | "standard" => "standard".into(),
|
||||
"Thesis" | "thesis" => "thesis".into(),
|
||||
"Misc" | "misc" => "other".into(),
|
||||
"" => "book".into(),
|
||||
other => other.to_ascii_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's citation style, APA when it has none, and a style sheet file name made from
|
||||
/// it (`\APA.XSL`, `\MLA.XSL`).
|
||||
fn bib_style(doc: &Document) -> (&str, String) {
|
||||
let name = doc.settings.bib_style.as_deref().map(str::trim).filter(|n| !n.is_empty()).unwrap_or("APA");
|
||||
let file: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
|
||||
(name, format!("\\{}.XSL", if file.is_empty() { "APA" } else { &file }))
|
||||
}
|
||||
|
||||
fn write_name_list(w: &mut W, names: &str) {
|
||||
w.open("b:NameList", &[]);
|
||||
let persons: Vec<_> = names.split(';').map(str::trim).filter(|s| !s.is_empty()).collect();
|
||||
for a in persons {
|
||||
w.open("b:Person", &[]);
|
||||
// Parse "Last, First" or "Last, First Middle"
|
||||
let (last, first_middle) = match a.split_once(',') {
|
||||
Some((l, f)) => (l.trim(), f.trim()),
|
||||
None => (a, ""),
|
||||
};
|
||||
w.leaf("b:Last", &[], last);
|
||||
if !first_middle.is_empty() {
|
||||
let parts: Vec<&str> = first_middle.split_whitespace().collect();
|
||||
if let Some(first) = parts.first() {
|
||||
w.leaf("b:First", &[], first);
|
||||
}
|
||||
if let Some(slice) = parts.get(1..).filter(|s| !s.is_empty()) {
|
||||
let mid = slice.join(" ");
|
||||
w.leaf("b:Middle", &[], &mid);
|
||||
}
|
||||
}
|
||||
w.close("b:Person");
|
||||
}
|
||||
w.close("b:NameList");
|
||||
}
|
||||
|
||||
fn split_accessed_date(s: &str) -> (Option<&str>, Option<&str>, Option<&str>) {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return (None, None, None);
|
||||
}
|
||||
let parts: Vec<&str> = if s.contains('-') {
|
||||
s.split('-').collect()
|
||||
} else if s.contains('/') {
|
||||
s.split('/').collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let [y, m, d] = parts.as_slice() {
|
||||
(Some(y.trim()), Some(m.trim()), Some(d.trim()))
|
||||
} else if let [y, m] = parts.as_slice() {
|
||||
(Some(y.trim()), Some(m.trim()), None)
|
||||
} else {
|
||||
(Some(s), None, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn bibliography_xml(doc: &Document) -> Vec<u8> {
|
||||
let (style_name, selected) = bib_style(doc);
|
||||
let mut w = W::new();
|
||||
w.open(
|
||||
"b:Sources",
|
||||
&[
|
||||
("SelectedStyle", selected.as_str()),
|
||||
("StyleName", style_name),
|
||||
("xmlns:b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
|
||||
("xmlns", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
|
||||
],
|
||||
);
|
||||
for src in &doc.sources {
|
||||
w.open("b:Source", &[]);
|
||||
w.leaf("b:Tag", &[], &src.tag);
|
||||
w.leaf("b:SourceType", &[], to_source_type(&src.kind));
|
||||
// Author & Editor
|
||||
if !src.author.is_empty() || !src.editor.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
if !src.author.is_empty() {
|
||||
w.open("b:Author", &[]);
|
||||
write_name_list(&mut w, &src.author);
|
||||
w.close("b:Author");
|
||||
}
|
||||
if !src.editor.is_empty() {
|
||||
w.open("b:Editor", &[]);
|
||||
write_name_list(&mut w, &src.editor);
|
||||
w.close("b:Editor");
|
||||
}
|
||||
w.close("b:Author");
|
||||
}
|
||||
if !src.title.is_empty() {
|
||||
w.leaf("b:Title", &[], &src.title);
|
||||
}
|
||||
if !src.year.is_empty() {
|
||||
w.leaf("b:Year", &[], &src.year);
|
||||
}
|
||||
if !src.month.is_empty() {
|
||||
w.leaf("b:Month", &[], &src.month);
|
||||
}
|
||||
if !src.day.is_empty() {
|
||||
w.leaf("b:Day", &[], &src.day);
|
||||
}
|
||||
if !src.city.is_empty() {
|
||||
w.leaf("b:City", &[], &src.city);
|
||||
}
|
||||
if !src.publisher.is_empty() {
|
||||
w.leaf("b:Publisher", &[], &src.publisher);
|
||||
}
|
||||
if !src.journal.is_empty() {
|
||||
w.leaf("b:JournalName", &[], &src.journal);
|
||||
}
|
||||
if !src.edition.is_empty() {
|
||||
w.leaf("b:Edition", &[], &src.edition);
|
||||
}
|
||||
if !src.volume.is_empty() {
|
||||
w.leaf("b:Volume", &[], &src.volume);
|
||||
}
|
||||
if !src.issue.is_empty() {
|
||||
w.leaf("b:Issue", &[], &src.issue);
|
||||
}
|
||||
if !src.pages.is_empty() {
|
||||
w.leaf("b:Pages", &[], &src.pages);
|
||||
}
|
||||
if !src.url.is_empty() {
|
||||
w.leaf("b:URL", &[], &src.url);
|
||||
}
|
||||
if !src.accessed.is_empty() {
|
||||
let (y, m, d) = split_accessed_date(&src.accessed);
|
||||
if let Some(y) = y.filter(|s| !s.is_empty()) {
|
||||
w.leaf("b:YearAccessed", &[], y);
|
||||
}
|
||||
if let Some(m) = m.filter(|s| !s.is_empty()) {
|
||||
w.leaf("b:MonthAccessed", &[], m);
|
||||
}
|
||||
if let Some(d) = d.filter(|s| !s.is_empty()) {
|
||||
w.leaf("b:DayAccessed", &[], d);
|
||||
}
|
||||
w.leaf("b:Accessed", &[], &src.accessed);
|
||||
}
|
||||
if !src.doi.is_empty() {
|
||||
w.leaf("b:DOI", &[], &src.doi);
|
||||
w.leaf("b:StandardNumber", &[], &src.doi);
|
||||
}
|
||||
w.close("b:Source");
|
||||
}
|
||||
w.close("b:Sources");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
fn bibliography_props_xml() -> Vec<u8> {
|
||||
let mut w = W::new();
|
||||
w.open(
|
||||
"ds:datastoreItem",
|
||||
&[("xmlns:ds", "http://schemas.openxmlformats.org/officeDocument/2006/customXml"), ("ds:itemID", "{3315B9A8-2323-4554-B8B2-1234567890AB}")],
|
||||
);
|
||||
w.open("ds:schemaRefs", &[]);
|
||||
w.empty("ds:schemaRef", &[("ds:uri", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography")]);
|
||||
w.close("ds:schemaRefs");
|
||||
w.close("ds:datastoreItem");
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ pub const DECORATIVE_NS: &str = "http://schemas.microsoft.com/office/drawing/201
|
||||
|
||||
/// Namespaces that only appear in non-body parts.
|
||||
const OTHER_NAMESPACES: &[(&str, &str)] = &[
|
||||
("b", "http://schemas.openxmlformats.org/officeDocument/2006/bibliography"),
|
||||
("rel", "http://schemas.openxmlformats.org/package/2006/relationships"),
|
||||
("ct", "http://schemas.openxmlformats.org/package/2006/content-types"),
|
||||
("cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"),
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Original synthetic custom XML stores, written from the public OOXML package contract.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use wordcraft_doc::{Path, StoryRef};
|
||||
|
||||
const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/";
|
||||
const DATA: &str =
|
||||
"<?xml version=\"1.0\"?><c:customer xmlns:c=\"urn:original:customer\"><c:name>Atlas Studios</c:name><c:ref>CASE-17</c:ref></c:customer>";
|
||||
const PROPS: &str = r#"<ds:datastoreItem xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml" ds:itemID="{22222222-2222-2222-2222-222222222222}"><ds:schemaRefs><ds:schemaRef ds:uri="urn:original:customer"/></ds:schemaRefs></ds:datastoreItem>"#;
|
||||
|
||||
fn rels(rows: &[(&str, &str, &str, bool)]) -> String {
|
||||
let mut out = format!(r#"<Relationships xmlns="{REL_NS}">"#);
|
||||
for (id, kind, target, external) in rows {
|
||||
let mode = if *external { r#" TargetMode="External""# } else { "" };
|
||||
out.push_str(&format!(r#"<Relationship Id="{id}" Type="{RT}{kind}" Target="{target}"{mode}/>"#));
|
||||
}
|
||||
out + "</Relationships>"
|
||||
}
|
||||
|
||||
fn fixture() -> BTreeMap<String, Vec<u8>> {
|
||||
let doc = r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Independent edit.</w:t></w:r></w:p><w:p><w:sdt><w:sdtPr><w:id w:val="42"/><w:dataBinding w:xpath="/c:customer/c:name" w:prefixMappings="xmlns:c='urn:original:customer'" w:storeItemID="{22222222-2222-2222-2222-222222222222}"/><w:text/></w:sdtPr><w:sdtContent><w:r><w:t>Atlas Studios</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body></w:document>"#;
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/></Types>"#;
|
||||
[
|
||||
("[Content_Types].xml", types.to_string()),
|
||||
("_rels/.rels", rels(&[("office", "officeDocument", "word/document.xml", false)])),
|
||||
("word/document.xml", doc.to_string()),
|
||||
("word/_rels/document.xml.rels", rels(&[("data", "customXml", "../customXml/item1.xml", false)])),
|
||||
("customXml/item1.xml", DATA.to_string()),
|
||||
("customXml/itemProps1.xml", PROPS.to_string()),
|
||||
("customXml/_rels/item1.xml.rels", rels(&[("properties", "customXmlProps", "itemProps1.xml", false)])),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(p, s)| (p.into(), s.into_bytes()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn zip(parts: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, bytes) in parts {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut parts = BTreeMap::new();
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut bytes = Vec::new();
|
||||
f.read_to_end(&mut bytes).unwrap();
|
||||
assert!(parts.insert(name, bytes).is_none(), "duplicate ZIP entry");
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn text(parts: &BTreeMap<String, Vec<u8>>, name: &str) -> String {
|
||||
String::from_utf8(parts[name].clone()).unwrap()
|
||||
}
|
||||
|
||||
fn edited_cycles(input: &BTreeMap<String, Vec<u8>>, check: impl Fn(&BTreeMap<String, Vec<u8>>)) {
|
||||
let mut doc = wordcraft_docx::read(&zip(input)).unwrap();
|
||||
for _ in 0..2 {
|
||||
doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_text(0, "Unrelated edit. ", &Default::default()).unwrap();
|
||||
let bytes = wordcraft_docx::write(&doc).unwrap();
|
||||
check(&unzip(&bytes));
|
||||
doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_store_bytes_properties_and_relationships_survive_two_edited_saves() {
|
||||
edited_cycles(&fixture(), |p| {
|
||||
let document = text(p, "word/document.xml");
|
||||
assert!(document.contains("<w:dataBinding"), "the control keeps its store binding");
|
||||
assert!(document.contains(r#"w:storeItemID="{22222222-2222-2222-2222-222222222222}""#));
|
||||
assert!(document.contains(r#"w:xpath="/c:customer/c:name""#));
|
||||
assert_eq!(p.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(p.get("customXml/itemProps1.xml").map(Vec::as_slice), Some(PROPS.as_bytes()));
|
||||
assert!(text(p, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/item1.xml""#));
|
||||
assert!(text(p, "customXml/_rels/item1.xml.rels").contains(r#"Id="properties""#));
|
||||
assert!(text(p, "customXml/_rels/item1.xml.rels").contains(r#"Target="itemProps1.xml""#));
|
||||
assert!(text(p, "[Content_Types].xml").contains("application/vnd.openxmlformats-officedocument.customXmlProperties+xml"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_and_document_roots_share_parts_and_keep_multiple_stores() {
|
||||
let mut p = fixture();
|
||||
p.insert(
|
||||
"_rels/.rels".into(),
|
||||
rels(&[("office", "officeDocument", "word/document.xml", false), ("data", "customXml", "customXml/item1.xml", false)]).into_bytes(),
|
||||
);
|
||||
p.insert(
|
||||
"word/_rels/document.xml.rels".into(),
|
||||
rels(&[("one", "customXml", "../customXml/item1.xml", false), ("two", "customXml", "../customXml/other.xml", false)]).into_bytes(),
|
||||
);
|
||||
p.insert("customXml/other.xml".into(), b"<other>Independent store</other>".to_vec());
|
||||
p.insert("customXml/_rels/other.xml.rels".into(), rels(&[("shared", "customXmlProps", "itemProps1.xml", false)]).into_bytes());
|
||||
// A cycle must not cause repeated parts, and an external schema stays external.
|
||||
p.insert(
|
||||
"customXml/_rels/itemProps1.xml.rels".into(),
|
||||
rels(&[("cycle", "customXml", "item1.xml", false), ("schema", "schema", "https://example.invalid/schema", true)]).into_bytes(),
|
||||
);
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(out.get("customXml/other.xml"), p.get("customXml/other.xml"));
|
||||
assert!(text(out, "_rels/.rels").contains(r#"Target="customXml/item1.xml""#));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/other.xml""#));
|
||||
assert!(text(out, "customXml/_rels/itemProps1.xml.rels").contains(r#"TargetMode="External""#));
|
||||
assert!(text(out, "customXml/_rels/other.xml.rels").contains(r#"Target="itemProps1.xml""#));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_external_stores_are_not_fetched_or_written() {
|
||||
let mut p = fixture();
|
||||
p.insert(
|
||||
"word/_rels/document.xml.rels".into(),
|
||||
rels(&[
|
||||
("missing", "customXml", "../customXml/missing.xml", false),
|
||||
("external", "customXml", "https://example.invalid/store.xml", true),
|
||||
("valid", "customXml", "../customXml/item1.xml", false),
|
||||
])
|
||||
.into_bytes(),
|
||||
);
|
||||
p.insert("customXml/_rels/item1.xml.rels".into(), rels(&[("missing", "customXmlProps", "missing.xml", false)]).into_bytes());
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
let roots = text(out, "word/_rels/document.xml.rels");
|
||||
assert!(!roots.contains("missing.xml"));
|
||||
assert!(!roots.contains("example.invalid"));
|
||||
assert!(!out.contains_key("customXml/_rels/item1.xml.rels"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_stores_do_not_shadow_reserved_bibliography_names() {
|
||||
let mut doc = wordcraft_docx::read(&zip(&fixture())).unwrap();
|
||||
doc.sources.push(wordcraft_doc::Source { tag: "OriginalSource".into(), title: "Original synthetic reference".into(), ..Default::default() });
|
||||
let out = unzip(&wordcraft_docx::write(&doc).unwrap());
|
||||
assert_eq!(out.get("customXml/item1_2.xml").map(Vec::as_slice), Some(DATA.as_bytes()));
|
||||
assert_eq!(out.get("customXml/itemProps1_2.xml").map(Vec::as_slice), Some(PROPS.as_bytes()));
|
||||
assert!(text(&out, "word/_rels/document.xml.rels").contains(r#"Target="../customXml/item1_2.xml""#));
|
||||
assert!(text(&out, "customXml/_rels/item1_2.xml.rels").contains(r#"Target="itemProps1_2.xml""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_non_utf8_data_and_strict_relationship_types_are_preserved() {
|
||||
let mut p = fixture();
|
||||
let data: Vec<u8> =
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-16\"?><customer>Atlas Studios</customer>".encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let mut bytes = vec![0xff, 0xfe];
|
||||
bytes.extend(data);
|
||||
p.insert("customXml/item1.xml".into(), bytes.clone());
|
||||
let strict =
|
||||
rels(&[("strict", "customXml", "../customXml/item1.xml", false)]).replace(RT, "http://purl.oclc.org/ooxml/officeDocument/relationships/");
|
||||
p.insert("word/_rels/document.xml.rels".into(), strict.into_bytes());
|
||||
edited_cycles(&p, |out| {
|
||||
assert_eq!(out.get("customXml/item1.xml"), Some(&bytes));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains("http://purl.oclc.org/ooxml/officeDocument/relationships/customXml"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_part_targets_are_renamed_instead_of_shadowing_or_looping() {
|
||||
let mut p = fixture();
|
||||
p.insert("word/_rels/document.xml.rels".into(), rels(&[("hostile", "customXml", "../docProps/foreign.xml", false)]).into_bytes());
|
||||
p.insert("docProps/foreign.xml".into(), DATA.as_bytes().to_vec());
|
||||
edited_cycles(&p, |out| {
|
||||
assert!(out.values().any(|b| b == DATA.as_bytes()));
|
||||
assert!(!out.contains_key("docProps/foreign.xml"));
|
||||
assert!(text(out, "word/_rels/document.xml.rels").contains(r#"Target="embeddings/part.xml""#));
|
||||
assert!(text(out, "docProps/core.xml").contains("coreProperties"));
|
||||
});
|
||||
}
|
||||
@@ -163,6 +163,51 @@ const SNIPPETS: &[&str] = &[
|
||||
"<w:sectPr/>",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn malformed_or_hostile_bibliography_never_panics() {
|
||||
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
|
||||
let cases: &[&[u8]] = &[
|
||||
b"<not_sources/>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><unclosed",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source/></b:Sources>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:NameList><b:Person/></b:NameList></b:Author></b:Source></b:Sources>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag></b:Tag><b:SourceType></b:SourceType><b:Author><b:NameList><b:Person><b:Last>,,,,</b:Last><b:First></b:First></b:Person></b:NameList></b:Author></b:Source></b:Sources>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Author><b:Editor><b:NameList><b:Person><b:Last>Ed</b:Last></b:Person></b:NameList></b:Editor></b:Author><b:Edition>1st</b:Edition><b:Issue>99</b:Issue><b:Month>13</b:Month><b:Day>32</b:Day><b:YearAccessed>2025</b:YearAccessed><b:MonthAccessed>02</b:MonthAccessed><b:DayAccessed>30</b:DayAccessed><b:StandardNumber>doi:10.0/0</b:StandardNumber></b:Source></b:Sources>",
|
||||
b"<b:Sources xmlns:b=\"http://schemas.openxmlformats.org/officeDocument/2006/bibliography\"><b:Source><b:Tag>Hostile1</b:Tag><b:Editor><b:Person/></b:Editor><b:Accessed>---///---</b:Accessed></b:Source></b:Sources>",
|
||||
b"\xff\xfe<\0b\0:\0S\0",
|
||||
];
|
||||
let root_rels_bib = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
|
||||
for case in cases {
|
||||
let z = zip(&[("_rels/.rels", root_rels_bib.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", case)]);
|
||||
let res = read(&z);
|
||||
if let Ok(d) = res {
|
||||
let _ = write(&d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file with a huge number of bibliography sources (duplicates and distinct ones) reads in
|
||||
/// linear time and keeps at most 10,000 of them.
|
||||
#[test]
|
||||
fn hostile_bibliography_source_count_is_capped() {
|
||||
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
|
||||
let mut sources = String::from(r#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography" StyleName="MLA">"#);
|
||||
for i in 0..60_000 {
|
||||
sources.push_str(&format!("<b:Source><b:Tag>T{i}</b:Tag><b:Title>Title {i}</b:Title></b:Source>"));
|
||||
// Untagged duplicates all collapse into one source.
|
||||
sources.push_str("<b:Source><b:Title>Same</b:Title></b:Source>");
|
||||
}
|
||||
sources.push_str("</b:Sources>");
|
||||
let root_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml" Target="customXml/item1.xml"/></Relationships>"#;
|
||||
let z = zip(&[("_rels/.rels", root_rels.as_bytes()), ("word/document.xml", doc.as_bytes()), ("customXml/item1.xml", sources.as_bytes())]);
|
||||
let start = std::time::Instant::now();
|
||||
let d = read(&z).unwrap();
|
||||
assert_eq!(d.sources.len(), 10_000);
|
||||
assert_eq!(d.sources.iter().filter(|s| s.tag.is_empty()).count(), 1, "untagged duplicates are kept once");
|
||||
assert_eq!(d.settings.bib_style.as_deref(), Some("MLA"));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 300, ..ProptestConfig::default() })]
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ use wordcraft_doc::props::{
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Watermark, para_block};
|
||||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block};
|
||||
|
||||
fn rt(doc: &Document) -> Document {
|
||||
let bytes = wordcraft_docx::write(doc).expect("write");
|
||||
@@ -1260,6 +1260,21 @@ fn kitchen_sink() -> Document {
|
||||
blocks.push(Arc::new(Block::Table(t)));
|
||||
blocks.push(para_block(Paragraph::new()));
|
||||
d.body = blocks;
|
||||
d.sources.push(Source {
|
||||
tag: "Sink2026".into(),
|
||||
kind: "book".into(),
|
||||
author: "Author, First".into(),
|
||||
title: "Title".into(),
|
||||
year: "2026".into(),
|
||||
editor: "Editor, Chief".into(),
|
||||
edition: "3rd".into(),
|
||||
issue: "2".into(),
|
||||
month: "08".into(),
|
||||
day: "24".into(),
|
||||
accessed: "2026-08-25".into(),
|
||||
doi: "10.1234/sink.2026".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
@@ -1275,6 +1290,7 @@ fn second_generation_is_stable() {
|
||||
assert_eq!(d2.revisions, d1.revisions);
|
||||
assert_eq!(d2.settings, d1.settings);
|
||||
assert_eq!(d2.media, d1.media);
|
||||
assert_eq!(d2.sources, d1.sources);
|
||||
assert_eq!(d2, d1);
|
||||
}
|
||||
|
||||
@@ -1703,6 +1719,154 @@ fn rotated_group_and_shadow_rotation_round_trip() {
|
||||
assert_eq!(got, [(20.0, Some(true)), (20.0, Some(false))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bibliography_sources_round_trip() {
|
||||
let mut d = Document::new();
|
||||
d.sources.push(Source {
|
||||
tag: "Rivera2021".into(),
|
||||
kind: "book".into(),
|
||||
author: "Rivera, Alex; Chen, Mei".into(),
|
||||
title: "Shared Spaces".into(),
|
||||
year: "2021".into(),
|
||||
publisher: "Harbor Books".into(),
|
||||
city: "Portland".into(),
|
||||
editor: "Johnson, Robert".into(),
|
||||
edition: "2nd".into(),
|
||||
month: "10".into(),
|
||||
day: "15".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Smith2023".into(),
|
||||
kind: "article".into(),
|
||||
author: "Smith, John David".into(),
|
||||
title: "Modern Typography".into(),
|
||||
year: "2023".into(),
|
||||
journal: "Design Quarterly".into(),
|
||||
volume: "12".into(),
|
||||
pages: "45-60".into(),
|
||||
url: "https://example.com/typo".into(),
|
||||
issue: "4".into(),
|
||||
doi: "10.1000/182".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "WebSite2024".into(),
|
||||
kind: "website".into(),
|
||||
author: "Solo".into(),
|
||||
title: "Open Fonts".into(),
|
||||
year: "2024".into(),
|
||||
url: "https://example.org".into(),
|
||||
accessed: "2024-05-15".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Chapter2025".into(),
|
||||
kind: "bookSection".into(),
|
||||
author: "Lee, Harper".into(),
|
||||
title: "Chapter One".into(),
|
||||
year: "2025".into(),
|
||||
journal: "Anthology of Prose".into(),
|
||||
pages: "1-20".into(),
|
||||
..Default::default()
|
||||
});
|
||||
d.sources.push(Source {
|
||||
tag: "Misc2026".into(),
|
||||
kind: "other".into(),
|
||||
title: "Archive Manuscript".into(),
|
||||
year: "2026".into(),
|
||||
city: "Oxford".into(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let bytes = wordcraft_docx::write(&d).expect("write");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
|
||||
// Verify package-root customXml parts exist
|
||||
assert!(zip.by_name("customXml/item1.xml").is_ok());
|
||||
assert!(zip.by_name("customXml/itemProps1.xml").is_ok());
|
||||
assert!(zip.by_name("customXml/_rels/item1.xml.rels").is_ok());
|
||||
|
||||
let mut rels_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("_rels/.rels").unwrap(), &mut rels_xml).unwrap();
|
||||
assert!(rels_xml.contains("relationships/customXml"), "{rels_xml}");
|
||||
assert!(rels_xml.contains("Target=\"customXml/item1.xml\""), "{rels_xml}");
|
||||
|
||||
let mut ct_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("[Content_Types].xml").unwrap(), &mut ct_xml).unwrap();
|
||||
assert!(ct_xml.contains("PartName=\"/customXml/item1.xml\""), "{ct_xml}");
|
||||
assert!(ct_xml.contains("PartName=\"/customXml/itemProps1.xml\""), "{ct_xml}");
|
||||
|
||||
let mut item_xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("customXml/item1.xml").unwrap(), &mut item_xml).unwrap();
|
||||
assert!(item_xml.contains("SelectedStyle=\"\\APA.XSL\""), "{item_xml}");
|
||||
assert!(item_xml.contains("StyleName=\"APA\""), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>Book</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>JournalArticle</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>InternetSite</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>BookSection</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:SourceType>Misc</b:SourceType>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Author><b:Author><b:NameList><b:Person>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Rivera</b:Last><b:First>Alex</b:First>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Smith</b:Last><b:First>John</b:First><b:Middle>David</b:Middle>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Solo</b:Last>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Editor>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Last>Johnson</b:Last><b:First>Robert</b:First>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Edition>2nd</b:Edition>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Month>10</b:Month>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Day>15</b:Day>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Issue>4</b:Issue>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:DOI>10.1000/182</b:DOI>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:YearAccessed>2024</b:YearAccessed>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:MonthAccessed>05</b:MonthAccessed>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:DayAccessed>15</b:DayAccessed>"), "{item_xml}");
|
||||
assert!(item_xml.contains("<b:Accessed>2024-05-15</b:Accessed>"), "{item_xml}");
|
||||
|
||||
let back = wordcraft_docx::read(&bytes).expect("read");
|
||||
assert_eq!(back.sources.len(), 5);
|
||||
assert_eq!(back.sources[0].tag, "Rivera2021");
|
||||
assert_eq!(back.sources[0].kind, "book");
|
||||
assert_eq!(back.sources[0].author, "Rivera, Alex; Chen, Mei");
|
||||
assert_eq!(back.sources[0].title, "Shared Spaces");
|
||||
assert_eq!(back.sources[0].year, "2021");
|
||||
assert_eq!(back.sources[0].publisher, "Harbor Books");
|
||||
assert_eq!(back.sources[0].city, "Portland");
|
||||
assert_eq!(back.sources[0].editor, "Johnson, Robert");
|
||||
assert_eq!(back.sources[0].edition, "2nd");
|
||||
assert_eq!(back.sources[0].month, "10");
|
||||
assert_eq!(back.sources[0].day, "15");
|
||||
|
||||
assert_eq!(back.sources[1].tag, "Smith2023");
|
||||
assert_eq!(back.sources[1].kind, "article");
|
||||
assert_eq!(back.sources[1].author, "Smith, John David");
|
||||
assert_eq!(back.sources[1].title, "Modern Typography");
|
||||
assert_eq!(back.sources[1].journal, "Design Quarterly");
|
||||
assert_eq!(back.sources[1].volume, "12");
|
||||
assert_eq!(back.sources[1].pages, "45-60");
|
||||
assert_eq!(back.sources[1].url, "https://example.com/typo");
|
||||
assert_eq!(back.sources[1].issue, "4");
|
||||
assert_eq!(back.sources[1].doi, "10.1000/182");
|
||||
|
||||
assert_eq!(back.sources[2].tag, "WebSite2024");
|
||||
assert_eq!(back.sources[2].kind, "website");
|
||||
assert_eq!(back.sources[2].author, "Solo");
|
||||
assert_eq!(back.sources[2].title, "Open Fonts");
|
||||
assert_eq!(back.sources[2].url, "https://example.org");
|
||||
assert_eq!(back.sources[2].accessed, "2024-05-15");
|
||||
|
||||
assert_eq!(back.sources[3].tag, "Chapter2025");
|
||||
assert_eq!(back.sources[3].kind, "bookSection");
|
||||
assert_eq!(back.sources[3].author, "Lee, Harper");
|
||||
assert_eq!(back.sources[3].title, "Chapter One");
|
||||
assert_eq!(back.sources[3].journal, "Anthology of Prose");
|
||||
assert_eq!(back.sources[3].pages, "1-20");
|
||||
|
||||
assert_eq!(back.sources[4].tag, "Misc2026");
|
||||
assert_eq!(back.sources[4].kind, "other");
|
||||
assert_eq!(back.sources[4].title, "Archive Manuscript");
|
||||
assert_eq!(back.sources[4].city, "Oxford");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_polygons_round_trip() {
|
||||
use wordcraft_doc::wrap::{WrapPolygon, shape_outline};
|
||||
|
||||
@@ -255,6 +255,7 @@ 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|Table of Authorities|Update Table|references.updateAuthorities
|
||||
References|Fields|Update Fields|references.updateFields
|
||||
Mailings|Create|Envelopes|mailings.envelopes
|
||||
Mailings|Create|Labels|mailings.labels
|
||||
|
||||
@@ -297,6 +297,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CmdError::Params(format!("style must be one of {:?}", STYLES.iter().map(|x| x.name).collect::<Vec<_>>()))
|
||||
})?;
|
||||
s.bib_style = st.name.to_string();
|
||||
// Recorded in the document too, so a save keeps it.
|
||||
s.doc.settings.bib_style = Some(st.name.to_string());
|
||||
update_citations(s)?;
|
||||
Ok(json!({"style": s.bib_style}))
|
||||
})
|
||||
@@ -334,6 +336,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
generated_list(s, "TOA", "Table of Authorities")?;
|
||||
sel_result(s)
|
||||
}),
|
||||
CommandSpec::new("references.updateAuthorities", "Update Table", "References › Table of Authorities", |s, _| {
|
||||
update_generated(s)?;
|
||||
sel_result(s)
|
||||
}),
|
||||
CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref)
|
||||
.params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#),
|
||||
CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params(
|
||||
@@ -972,6 +978,71 @@ mod tests {
|
||||
assert!(s.doc.plain_text(StoryRef::Body).contains("[1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bibliography_round_trip_docx() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("text.insert", &json!({"text": "Studios matter "})).unwrap();
|
||||
s.run(
|
||||
"references.citation",
|
||||
&json!({
|
||||
"source": {
|
||||
"author": "Rivera, Alex; Chen, Mei",
|
||||
"title": "Shared Spaces",
|
||||
"year": "2021",
|
||||
"publisher": "Harbor Books",
|
||||
"city": "Portland",
|
||||
"editor": "Johnson, Robert",
|
||||
"edition": "2nd",
|
||||
"issue": "4",
|
||||
"month": "10",
|
||||
"day": "15",
|
||||
"accessed": "2024-05-15",
|
||||
"doi": "10.1000/182"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.bibliography", &json!({})).unwrap();
|
||||
|
||||
// Serialize to DOCX and back — sources must survive.
|
||||
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
|
||||
let doc = wordcraft_docx::read(&bytes).expect("docx read");
|
||||
assert_eq!(doc.sources.len(), 1, "round-tripped sources: {:?}", doc.sources);
|
||||
assert_eq!(doc.sources[0].author, "Rivera, Alex; Chen, Mei");
|
||||
assert_eq!(doc.sources[0].title, "Shared Spaces");
|
||||
assert_eq!(doc.sources[0].editor, "Johnson, Robert");
|
||||
assert_eq!(doc.sources[0].edition, "2nd");
|
||||
assert_eq!(doc.sources[0].issue, "4");
|
||||
assert_eq!(doc.sources[0].month, "10");
|
||||
assert_eq!(doc.sources[0].day, "15");
|
||||
assert_eq!(doc.sources[0].accessed, "2024-05-15");
|
||||
assert_eq!(doc.sources[0].doi, "10.1000/182");
|
||||
|
||||
// Round-tripped document in a new session works with citation styles.
|
||||
let mut s2 = Session::new(doc);
|
||||
s2.run("references.citationStyle", &json!({"style": "Harvard (Anglia)"})).unwrap();
|
||||
let t = s2.doc.plain_text(StoryRef::Body);
|
||||
assert!(t.contains("(Rivera and Chen, 2021)"), "{t}");
|
||||
assert!(t.contains("Rivera, A. and Chen, M., 2021. Shared Spaces. Portland: Harbor Books."), "{t}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn citation_style_survives_docx_save_and_reopen() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("references.citation", &json!({"source": {"author": "Rivera, Alex", "title": "Shared Spaces", "year": "2021"}})).unwrap();
|
||||
s.run("references.citationStyle", &json!({"style": "Harvard (Anglia)"})).unwrap();
|
||||
let bytes = wordcraft_docx::write(&s.doc).expect("docx write");
|
||||
let doc = wordcraft_docx::read(&bytes).expect("docx read");
|
||||
assert_eq!(doc.settings.bib_style.as_deref(), Some("Harvard (Anglia)"));
|
||||
assert_eq!(Session::new(doc.clone()).bib_style, "Harvard (Anglia)");
|
||||
let mut other = Session::new(wordcraft_doc::Document::new());
|
||||
other.set_document(doc);
|
||||
assert_eq!(other.bib_style, "Harvard (Anglia)");
|
||||
// A document without a recorded style is APA.
|
||||
assert_eq!(Session::new(wordcraft_doc::Document::new()).bib_style, "APA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_and_figures() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Kilns are hot.\nPresses are heavy.\nMore kilns."));
|
||||
@@ -999,6 +1070,24 @@ mod tests {
|
||||
assert!(fields(&s).contains(&(format!("REF {name} \\h"), "Figure 1".to_string())), "{:?}", fields(&s));
|
||||
}
|
||||
|
||||
/// #661: References › Table of Authorities › Update Table picks up citations marked after the table.
|
||||
#[test]
|
||||
fn table_of_authorities_updates() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Smith v. Jones held it.\nDoe v. Roe agreed."));
|
||||
s.run("select.text", &json!({"text": "Smith v. Jones"})).unwrap();
|
||||
s.run("references.markCitation", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("text.newParagraph", &json!({})).unwrap();
|
||||
s.run("references.tableOfAuthorities", &json!({})).unwrap();
|
||||
let t = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(t.contains("Smith v. Jones") && !t.contains("Doe v. Roe, "), "{t}");
|
||||
s.run("select.text", &json!({"text": "Doe v. Roe"})).unwrap();
|
||||
s.run("references.markCitation", &json!({})).unwrap();
|
||||
s.run("references.updateAuthorities", &json!({})).unwrap();
|
||||
let after = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(after.matches("Doe v. Roe").count() > t.matches("Doe v. Roe").count(), "{after}");
|
||||
}
|
||||
|
||||
/// Field instructions and results in the body, in order.
|
||||
fn fields(s: &Session) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Fonts the user adds without rebuilding (#582): `font.add`, `font.list`, `font.remove`.
|
||||
//!
|
||||
//! An added font joins the process-wide font database on top of the bundled and system fonts
|
||||
//! (never replacing a face already there), so it shows in the font lists, lays out, renders and
|
||||
//! embeds in PDFs like any other. Where it is kept between runs is [`FontDb::load_user_fonts`]'s
|
||||
//! folder (desktop); without one it lasts for the session (the web shell stores it itself).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_fonts::{FontDb, UserFont};
|
||||
|
||||
use crate::cmd::insert::base64_decode;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("font.add", "Add Font…", "Home › Font", add)
|
||||
.params(r#"{"path"?: string, "data"?: base64, "name"?: string (file name for "data")} — a TTF, OTF, TTC, WOFF or WOFF2 file; none: pick a file"#)
|
||||
.pure(),
|
||||
CommandSpec::new("font.list", "Added Fonts", "Home › Font", list).params(r#"{"all"?: bool (also every available family)}"#).pure(),
|
||||
CommandSpec::new("font.remove", "Remove Added Font", "Home › Font", remove).params(r#"{"family": string}"#).pure(),
|
||||
]
|
||||
}
|
||||
|
||||
fn font_json(f: &UserFont) -> Value {
|
||||
json!({
|
||||
"file": f.file,
|
||||
"families": f.families(),
|
||||
"faces": f.faces.iter().map(|(family, style)| json!({"family": family, "style": style})).collect::<Vec<_>>(),
|
||||
"path": f.path.as_ref().map(|p| p.to_string_lossy().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a font file named by the user, refusing one larger than the cap before reading it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_font_file(path: &str) -> Result<Vec<u8>, CmdError> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))?;
|
||||
if !meta.is_file() {
|
||||
return Err(CmdError::Failed(format!("{path}: not a file")));
|
||||
}
|
||||
if meta.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES as u64 {
|
||||
return Err(CmdError::Failed(format!("{path}: the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20)));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| CmdError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "url").is_some() {
|
||||
return Err(CmdError::Failed(
|
||||
"adding a font from a link isn't available yet: download the font file and add it with {\"path\"} or {\"data\"}".into(),
|
||||
));
|
||||
}
|
||||
let (name, bytes) = if let Some(d) = p::str(v, "data") {
|
||||
// Base64 is a third larger than the file: refuse before decoding an oversized one.
|
||||
if d.len() / 4 * 3 > wordcraft_fonts::MAX_FONT_FILE_BYTES + 3 {
|
||||
return Err(CmdError::Failed(format!("the font file is larger than {} MB", wordcraft_fonts::MAX_FONT_FILE_BYTES >> 20)));
|
||||
}
|
||||
let bytes = base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?;
|
||||
(p::str(v, "name").unwrap_or("font").to_string(), bytes)
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
(path.to_string(), read_font_file(path)?)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
return Err(CmdError::Failed("reading files by path isn't available on the web".into()));
|
||||
}
|
||||
} else {
|
||||
// From the ribbon: the UI shows a file picker and runs this again with the file.
|
||||
s.ui_requests.push(json!({"open": "addFont"}));
|
||||
return Ok(json!({"pending": true}));
|
||||
};
|
||||
let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
// Text already set in the family (shown in a fallback until now) takes the new font.
|
||||
s.relayout();
|
||||
let families = r.font.families().join(", ");
|
||||
s.status = match &r.warning {
|
||||
Some(w) => format!("Added {families} ({w})"),
|
||||
None => format!("Added {families}"),
|
||||
};
|
||||
let mut out = font_json(&r.font);
|
||||
if let Some(w) = r.warning {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, v: &Value) -> CmdResult {
|
||||
let db = FontDb::global();
|
||||
let mut out = json!({
|
||||
"fonts": db.user_fonts().iter().map(font_json).collect::<Vec<_>>(),
|
||||
"folder": db.user_font_dir().map(|p| p.to_string_lossy().to_string()),
|
||||
});
|
||||
if p::bool(v, "all") == Some(true) {
|
||||
out["families"] = json!(db.families());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn remove(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?;
|
||||
let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
s.relayout();
|
||||
s.status = format!("Removed {}", family.trim());
|
||||
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod fields;
|
||||
pub mod file;
|
||||
pub mod find_pattern;
|
||||
pub mod fmt_revisions;
|
||||
pub mod font;
|
||||
pub mod format;
|
||||
pub mod index;
|
||||
pub mod ink_shape;
|
||||
@@ -60,6 +61,7 @@ pub fn registry() -> Registry {
|
||||
v.extend(edit::specs());
|
||||
v.extend(paste::specs());
|
||||
v.extend(format::specs());
|
||||
v.extend(font::specs());
|
||||
v.extend(phonetic::specs());
|
||||
v.extend(para::specs());
|
||||
v.extend(lists::specs());
|
||||
|
||||
@@ -576,6 +576,90 @@ pub fn has_format_changes(doc: &wordcraft_doc::Document) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// A tracked deletion in the body shown in a balloon (Show revisions in balloons).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DeletionBalloon {
|
||||
/// Where the deleted text was: the balloon's anchor.
|
||||
pub at: Pos,
|
||||
pub author: String,
|
||||
/// The deleted text, a deleted paragraph mark as ¶, shortened to [`DELETION_TEXT_CHARS`].
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// At most this many characters of deleted text show in a balloon (a deleted page shows its start).
|
||||
pub const DELETION_TEXT_CHARS: usize = 200;
|
||||
/// At most this many deletion balloons (a document of many small deletions can't flood the area).
|
||||
const MAX_DELETION_BALLOONS: usize = 500;
|
||||
|
||||
/// The tracked deletions in the body, for balloons, in document order. Deleted text running on
|
||||
/// across runs, revisions and paragraph marks by the same author is one balloon. The source of a
|
||||
/// move is left out: it has its "Moved from" balloon ([`move_changes`]).
|
||||
pub fn deletion_balloons(s: &Session) -> Vec<DeletionBalloon> {
|
||||
let doc = &s.doc;
|
||||
let author = |rev: u32| doc.revisions.get(rev as usize).map(|r| r.author.clone()).unwrap_or_default();
|
||||
let mk = |path: &wordcraft_doc::Path, off| Pos { story: StoryRef::Body, path: path.clone(), off };
|
||||
let mut out = Vec::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
for (r, c) in p.run_ranges() {
|
||||
// Inserted and then deleted text is a deletion (it's drawn and left out as one).
|
||||
let Some(rev) = plain_deletion(doc, c.del) else { continue };
|
||||
push_deletion(&mut out, mk(&path, r.start), mk(&path, r.end), author(rev), p.text.get(r).unwrap_or(""));
|
||||
}
|
||||
if let Some(rev) = plain_deletion(doc, p.mark.del) {
|
||||
let at = mk(&path, p.len());
|
||||
let next = path.with_last(path.last().saturating_add(1));
|
||||
let end = if super::has_next_para(s, StoryRef::Body, &path) { mk(&next, 0) } else { at.clone() };
|
||||
push_deletion(&mut out, at, end, author(rev), "¶");
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|(d, _, _)| d).collect()
|
||||
}
|
||||
|
||||
/// Add `author`'s deleted `text` (from `at` to `end`) to `out`: to the last balloon when that
|
||||
/// deletion ends at `at` and is theirs, else as a new one. Each balloon carries where its deletion
|
||||
/// ends and how many characters its text has.
|
||||
fn push_deletion(out: &mut Vec<(DeletionBalloon, Pos, usize)>, at: Pos, end: Pos, author: String, text: &str) {
|
||||
let joins = out.last().is_some_and(|(d, e, _)| *e == at && d.author == author);
|
||||
if !joins {
|
||||
if out.len() >= MAX_DELETION_BALLOONS {
|
||||
return;
|
||||
}
|
||||
out.push((DeletionBalloon { at, author, text: String::new() }, end.clone(), 0));
|
||||
}
|
||||
let Some((d, e, chars)) = out.last_mut() else { return };
|
||||
*e = end;
|
||||
for c in text.chars() {
|
||||
if *chars > DELETION_TEXT_CHARS {
|
||||
break;
|
||||
}
|
||||
let c = match c {
|
||||
// Pictures, fields and other objects show no text of their own.
|
||||
wordcraft_doc::para::OBJ => continue,
|
||||
'\t' | wordcraft_doc::para::LINE_BREAK | wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK => ' ',
|
||||
c if c.is_control() => continue,
|
||||
c => c,
|
||||
};
|
||||
*chars += 1;
|
||||
d.text.push(if *chars > DELETION_TEXT_CHARS { '…' } else { c });
|
||||
}
|
||||
}
|
||||
|
||||
/// A deletion revision that is not the source of a move (`w:moveFrom` is a deletion revision
|
||||
/// with a move name).
|
||||
fn plain_deletion(doc: &wordcraft_doc::Document, del: Option<u32>) -> Option<u32> {
|
||||
del.filter(|_| doc.move_name(del).is_none())
|
||||
}
|
||||
|
||||
/// Whether the body has any tracked deletion other than a move's source (cheap: stops at the
|
||||
/// first).
|
||||
pub fn has_deletions(doc: &wordcraft_doc::Document) -> bool {
|
||||
doc.para_paths(StoryRef::Body).iter().any(|path| {
|
||||
doc.para(StoryRef::Body, path)
|
||||
.is_some_and(|p| plain_deletion(doc, p.mark.del).is_some() || p.runs.iter().any(|r| plain_deletion(doc, r.props.del).is_some()))
|
||||
})
|
||||
}
|
||||
|
||||
fn nav_change(s: &mut Session, dir: i32) -> CmdResult {
|
||||
let list = changes(s);
|
||||
let caret = s.sel.ordered();
|
||||
@@ -899,6 +983,183 @@ mod tests {
|
||||
assert_eq!(s.run("review.trackingOptions", &json!({})).unwrap()["insertColor"], "00AA00");
|
||||
}
|
||||
|
||||
fn glyph_text(d: &[Draw]) -> String {
|
||||
d.iter().filter_map(|x| if let Draw::Glyphs { text, .. } = x { Some(text.as_str()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// Show revisions in balloons: a tracked deletion leaves the text for a balloon at its place
|
||||
/// and keeps its change bar; it comes back inline in the other modes and wherever no balloons
|
||||
/// are shown (here the Comments pane, which changes the view without a relayout call).
|
||||
#[test]
|
||||
fn revisions_in_balloons_take_deletions_out_of_the_text() {
|
||||
use super::{DeletionBalloon, deletion_balloons};
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.author = "Ana".into();
|
||||
s.run("document.setText", &json!({"text": "keep gone stay"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 10}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
|
||||
// The default (only comments and formatting in balloons): struck through inline.
|
||||
assert!(!s.revisions_in_balloons());
|
||||
let (d, _) = draws(&mut s);
|
||||
assert!(glyph_text(&d).contains("gone"), "{:?}", glyph_text(&d));
|
||||
|
||||
s.run("review.trackingOptions", &json!({"balloons": "revisions"})).unwrap();
|
||||
assert!(s.revisions_in_balloons());
|
||||
let (d, body_x) = draws(&mut s);
|
||||
assert!(!glyph_text(&d).contains("gone"), "the deletion left the text: {:?}", glyph_text(&d));
|
||||
let horizontal = lines(&d).into_iter().filter(|(_, y0, _, y1)| y0 == y1).count();
|
||||
assert_eq!(horizontal, 0, "no strikethrough left behind: {:?}", lines(&d));
|
||||
assert_eq!(lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count(), 1, "the changed line keeps its bar");
|
||||
let at = wordcraft_doc::Pos::body(0, 5);
|
||||
assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: at.clone(), author: "Ana".into(), text: "gone ".into() }]);
|
||||
// The balloon's anchor is where the deletion was: between "keep " and "stay".
|
||||
let l = s.layout();
|
||||
let (anchor, stay) = (l.caret(&at).unwrap().x, l.caret(&wordcraft_doc::Pos::body(0, 10)).unwrap().x);
|
||||
assert!((anchor - stay).abs() < 0.01, "the deletion takes no room: {anchor} vs {stay}");
|
||||
|
||||
// No balloons beside the page while the Comments pane lists comments: inline again.
|
||||
s.run("view.commentsPane", &json!({"value": true})).unwrap();
|
||||
assert!(!s.revisions_in_balloons());
|
||||
assert!(glyph_text(&draws(&mut s).0).contains("gone"));
|
||||
s.run("view.commentsPane", &json!({"value": false})).unwrap();
|
||||
assert!(!glyph_text(&draws(&mut s).0).contains("gone"));
|
||||
s.run("review.trackingOptions", &json!({"balloons": "inline"})).unwrap();
|
||||
assert!(!s.revisions_in_balloons());
|
||||
assert!(glyph_text(&draws(&mut s).0).contains("gone"));
|
||||
}
|
||||
|
||||
/// Deleted text running on across runs, separate deletions and a paragraph mark is one balloon
|
||||
/// per author; a long deletion shows its start.
|
||||
#[test]
|
||||
fn deletion_balloons_join_runs_and_paragraph_marks_and_stay_short() {
|
||||
use super::{DELETION_TEXT_CHARS, deletion_balloons};
|
||||
let mut s = Session::new(Document::new());
|
||||
s.author = "Ana".into();
|
||||
s.run("document.setText", &json!({"text": "one two\nthree four"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
// "two", then the paragraph mark and "three" in a second deletion: one balloon.
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 4}, "focus": {"block": 0, "off": 7}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
s.run("select.range", &json!({"anchor": {"block": 0, "off": 7}, "focus": {"block": 1, "off": 5}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
// Bo's deletion right after Ana's is a balloon of its own.
|
||||
s.author = "Bo".into();
|
||||
s.run("select.range", &json!({"anchor": {"block": 1, "off": 5}, "focus": {"block": 1, "off": 10}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
let b = deletion_balloons(&s);
|
||||
let got: Vec<(&str, &str)> = b.iter().map(|d| (d.author.as_str(), d.text.as_str())).collect();
|
||||
assert_eq!(got, vec![("Ana", "two¶three"), ("Bo", " four")]);
|
||||
assert_eq!(b[0].at, wordcraft_doc::Pos::body(0, 4));
|
||||
|
||||
// A deletion far longer than a balloon shows: its first characters and an ellipsis.
|
||||
let mut s = Session::new(Document::new());
|
||||
let long = "word ".repeat(400);
|
||||
s.run("document.setText", &json!({"text": long})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
let b = deletion_balloons(&s);
|
||||
assert_eq!(b.len(), 1);
|
||||
assert_eq!(b[0].text.chars().count(), DELETION_TEXT_CHARS + 1);
|
||||
assert!(b[0].text.starts_with("word word") && b[0].text.ends_with('…'));
|
||||
}
|
||||
|
||||
/// #404: the source of a move is a deletion revision with a move name. With revisions in
|
||||
/// balloons it leaves the text like a deletion but has only its "Moved from" balloon, not a
|
||||
/// "Deleted: …" one as well; a plain deletion right after it is a balloon of its own.
|
||||
#[test]
|
||||
fn a_moves_source_gets_no_deletion_balloon() {
|
||||
use super::{DeletionBalloon, deletion_balloons, has_deletions, move_changes};
|
||||
use wordcraft_doc::Pos;
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("document.setText", &json!({"text": "First one.\nSecond one.\nThird one."})).unwrap();
|
||||
s.run("file.setAuthor", &json!({"name": "Ana"})).unwrap();
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
// The second paragraph, with its mark, cut and pasted before the first: a move.
|
||||
s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 2, "off": 0}})).unwrap();
|
||||
s.run("edit.cut", &json!({})).unwrap();
|
||||
s.run("caret.set", &json!({"pos": {"block": 0, "off": 0}})).unwrap();
|
||||
s.run("edit.paste", &json!({})).unwrap();
|
||||
s.run("review.trackingOptions", &json!({"balloons": "revisions"})).unwrap();
|
||||
assert!(s.revisions_in_balloons());
|
||||
|
||||
let ends: Vec<(Pos, bool)> = move_changes(&s).into_iter().map(|(at, _, from)| (at, from)).collect();
|
||||
assert_eq!(ends, vec![(Pos::body(0, 0), false), (Pos::body(2, 0), true)]);
|
||||
assert!(deletion_balloons(&s).is_empty(), "the move's source has its \"Moved from\" balloon only: {:?}", deletion_balloons(&s));
|
||||
assert!(!has_deletions(&s.doc), "a move alone needs no deletion balloons");
|
||||
assert_eq!(glyph_text(&draws(&mut s).0), "Second one.First one.Third one.", "the moved-away text left the text");
|
||||
|
||||
// "Third " deleted right where the moved-away paragraph ends: not joined to it.
|
||||
s.run("select.range", &json!({"anchor": {"block": 3, "off": 0}, "focus": {"block": 3, "off": 6}})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
assert!(has_deletions(&s.doc));
|
||||
assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: Pos::body(3, 0), author: "Ana".into(), text: "Third ".into() }]);
|
||||
assert_eq!(move_changes(&s).len(), 2, "the move keeps both its balloons");
|
||||
}
|
||||
|
||||
/// Revisions in balloons take deletions out of the body only, where their balloons are:
|
||||
/// headers, footers, footnotes, endnotes and text boxes get no balloons, so their deletions
|
||||
/// stay in the text, struck through, as in the other modes.
|
||||
#[test]
|
||||
fn deletions_outside_the_body_stay_inline_when_revisions_go_in_balloons() {
|
||||
use super::{DeletionBalloon, deletion_balloons};
|
||||
use wordcraft_doc::{Path, Pos, StoryRef};
|
||||
let mut s = Session::new(Document::new());
|
||||
s.view.proofing = false;
|
||||
s.author = "Ana".into();
|
||||
s.run("document.setText", &json!({"text": "Body zeta keep"})).unwrap();
|
||||
let part = |v: serde_json::Value, key: &str| StoryRef::Part(v[key].as_u64().unwrap() as u32);
|
||||
let header = part(s.run("insert.header", &json!({"text": "Head alpha"})).unwrap(), "story");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
let footer = part(s.run("insert.footer", &json!({"text": "Foot beta"})).unwrap(), "story");
|
||||
s.run("insert.closeHeader", &json!({})).unwrap();
|
||||
let in_body = json!({"pos": {"block": 0, "off": 4}});
|
||||
s.run("caret.set", &in_body).unwrap();
|
||||
let footnote = part(s.run("references.footnote", &json!({"text": "Note gamma"})).unwrap(), "id");
|
||||
s.run("caret.set", &in_body).unwrap();
|
||||
let endnote = part(s.run("references.endnote", &json!({"text": "End delta"})).unwrap(), "id");
|
||||
s.run("caret.set", &in_body).unwrap();
|
||||
let text_box = part(s.run("insert.textBox", &json!({"text": "Box epsilon"})).unwrap(), "story");
|
||||
|
||||
// With Track Changes on, a word deleted in each story.
|
||||
s.run("review.trackChanges", &json!({"value": true})).unwrap();
|
||||
let words = [(StoryRef::Body, "zeta"), (header, "alpha"), (footer, "beta"), (footnote, "gamma"), (endnote, "delta"), (text_box, "epsilon")];
|
||||
for (story, word) in words {
|
||||
let at = s.doc.para(story, &Path::top(0)).and_then(|p| p.text.find(word)).unwrap();
|
||||
let pos = |off: usize| json!({"story": story, "path": [0], "off": off});
|
||||
s.run("select.range", &json!({"anchor": pos(at), "focus": pos(at + word.len())})).unwrap();
|
||||
s.run("text.delete", &json!({})).unwrap();
|
||||
let p = s.doc.para(story, &Path::top(0)).unwrap();
|
||||
assert!(p.run_ranges().any(|(r, c)| c.del.is_some() && p.text.get(r) == Some(word)), "{word} is a tracked deletion");
|
||||
}
|
||||
let struck = |d: &[Draw]| lines(d).into_iter().filter(|(_, y0, _, y1)| y0 == y1).count();
|
||||
|
||||
// Comments and formatting in balloons: every deletion struck through where it was.
|
||||
let (d, _) = draws(&mut s);
|
||||
let text = glyph_text(&d);
|
||||
for (_, word) in words {
|
||||
assert!(text.contains(word), "{word} in {text:?}");
|
||||
}
|
||||
let all_struck = struck(&d);
|
||||
|
||||
// Revisions in balloons: only the body's deletion leaves the text, for its balloon.
|
||||
s.run("review.trackingOptions", &json!({"balloons": "revisions"})).unwrap();
|
||||
assert!(s.revisions_in_balloons());
|
||||
let (d, _) = draws(&mut s);
|
||||
let text = glyph_text(&d);
|
||||
assert!(!text.contains("zeta"), "the body's deletion left the text: {text:?}");
|
||||
for (_, word) in &words[1..] {
|
||||
assert!(text.contains(word), "{word} stays inline: {text:?}");
|
||||
}
|
||||
assert_eq!(struck(&d), all_struck - 1, "the other deletions stay struck through");
|
||||
// Its balloon is the only one: the notes' and text box's objects sit before "zeta".
|
||||
let at = s.doc.para(StoryRef::Body, &Path::top(0)).and_then(|p| p.text.find("zeta")).unwrap();
|
||||
assert_eq!(deletion_balloons(&s), vec![DeletionBalloon { at: Pos::body(0, at), author: "Ana".into(), text: "zeta".into() }]);
|
||||
}
|
||||
|
||||
fn body_para(s: &Session) -> &wordcraft_doc::Paragraph {
|
||||
s.doc.para(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod safe_save;
|
||||
|
||||
/// Formats WordCraft opens.
|
||||
pub const OPEN_EXTS: &[&str] = &[
|
||||
"docx",
|
||||
@@ -320,10 +323,7 @@ pub fn save_path(path: &std::path::Path, doc: &Document) -> Result<(), String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&str>, opts: &ExportOptions) -> Result<(), String> {
|
||||
let bytes = save_bytes_with(&path.to_string_lossy(), doc, password, opts)?;
|
||||
// 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()))
|
||||
safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Sibling temporary files owned by one save. The destination changes only after preparation
|
||||
//! succeeds; concurrent saves have separate files and the last successful replacement wins.
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let permissions = match fs::metadata(path) {
|
||||
Ok(meta) if meta.is_file() => Some(meta.permissions()),
|
||||
Ok(_) => None,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// A new document gets the usual mode (0666 less the umask); a replaced one keeps its own,
|
||||
// and its temporary file stays private until that mode is applied.
|
||||
let private = permissions.is_some();
|
||||
replace_with(path, private, |file| {
|
||||
file.write_all(bytes)?;
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
// Synchronize the prepared file before replacing the destination. Directory durability
|
||||
// and network-filesystem/power-loss guarantees are outside this operation's contract.
|
||||
file.sync_all()
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
|
||||
let mut temp = Temporary::create(path, private)?;
|
||||
prepare(temp.file()?)?;
|
||||
temp.commit(path)
|
||||
}
|
||||
|
||||
struct Temporary {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
committed: bool,
|
||||
}
|
||||
|
||||
impl Temporary {
|
||||
/// `private`: create the file readable by its owner only (0600) rather than with the default
|
||||
/// mode (0666 less the umask).
|
||||
fn create(destination: &Path, private: bool) -> io::Result<Self> {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
if destination.file_name().is_none() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "save destination needs a file name"));
|
||||
}
|
||||
// A short name also works beside a destination already at the filename-length limit.
|
||||
for _ in 0..128 {
|
||||
let id = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let path = destination.with_file_name(format!(".wordcraft-save-{}-{id}.tmp", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
if private {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
match options.open(&path) {
|
||||
Ok(file) => return Ok(Self { path, file: Some(file), committed: false }),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, "could not create a save temporary file"))
|
||||
}
|
||||
|
||||
fn file(&mut self) -> io::Result<&mut File> {
|
||||
self.file.as_mut().ok_or_else(|| io::Error::other("save temporary file is closed"))
|
||||
}
|
||||
|
||||
fn commit(mut self, destination: &Path) -> io::Result<()> {
|
||||
// Close before replacement, including on Windows.
|
||||
drop(self.file.take());
|
||||
fs::rename(&self.path, destination)?;
|
||||
self.committed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Temporary {
|
||||
fn drop(&mut self) {
|
||||
drop(self.file.take());
|
||||
if !self.committed {
|
||||
// If filesystem permissions change, cleanup can fail too. Never remove a neighbour
|
||||
// or the destination to compensate for a failed save.
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("wordcraft-save-owner-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn target(&self) -> PathBuf {
|
||||
self.0.join("original.docx")
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_and_complete_preparation_failures_leave_original_and_no_owned_temp() {
|
||||
for partial in [true, false] {
|
||||
let d = Scratch::new();
|
||||
let path = d.target();
|
||||
fs::write(&path, b"Original bytes.").unwrap();
|
||||
let err = replace_with(&path, true, |file| {
|
||||
file.write_all(if partial { b"Partial" } else { b"Complete replacement bytes." })?;
|
||||
Err(io::Error::other("injected preparation failure"))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("injected"));
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.");
|
||||
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_prepared_saves_keep_separate_bytes_and_commit_complete_files() {
|
||||
let d = Scratch::new();
|
||||
let path = d.target();
|
||||
fs::write(&path, b"Original bytes.").unwrap();
|
||||
let mut first = Temporary::create(&path, true).unwrap();
|
||||
let mut second = Temporary::create(&path, true).unwrap();
|
||||
assert_ne!(first.path, second.path);
|
||||
first.file().unwrap().write_all(b"First complete save.").unwrap();
|
||||
second.file().unwrap().write_all(b"Second complete save.").unwrap();
|
||||
first.file().unwrap().sync_all().unwrap();
|
||||
second.file().unwrap().sync_all().unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Original bytes.");
|
||||
first.commit(&path).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"First complete save.");
|
||||
second.commit(&path).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Second complete save.");
|
||||
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_temporary_files_are_private_until_prepared() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = Scratch::new();
|
||||
let temp = Temporary::create(&d.target(), true).unwrap();
|
||||
assert_eq!(fs::metadata(&temp.path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
/// A new document gets the default mode, as `fs::write` would give it — not 0600; a replaced
|
||||
/// document keeps its own mode.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_documents_get_the_default_mode_and_replaced_ones_keep_theirs() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = Scratch::new();
|
||||
let reference = d.0.join("reference.docx");
|
||||
fs::write(&reference, b"x").unwrap();
|
||||
let default_mode = fs::metadata(&reference).unwrap().permissions().mode() & 0o777;
|
||||
let path = d.target();
|
||||
write(&path, b"New document.").unwrap();
|
||||
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, default_mode);
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
|
||||
write(&path, b"Saved again.").unwrap();
|
||||
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640);
|
||||
assert_eq!(fs::read(&path).unwrap(), b"Saved again.");
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_fonts;
|
||||
#[cfg(test)]
|
||||
mod tests_typing;
|
||||
#[cfg(test)]
|
||||
mod tests_wordart;
|
||||
|
||||
@@ -268,7 +268,9 @@ pub struct Session {
|
||||
/// mail merge, recover), never on an edit.
|
||||
document_id: u64,
|
||||
cache: LayoutCache,
|
||||
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool, Option<wordcraft_layout::outline::OutlineView>)>,
|
||||
/// (revision, web width, view, layout, proofing, outline, (deletions left out, body
|
||||
/// deletions in balloons)).
|
||||
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool, Option<wordcraft_layout::outline::OutlineView>, (bool, bool))>,
|
||||
/// Picture edits: edited media key → original media key (Reset Picture).
|
||||
pub originals: std::collections::HashMap<String, String>,
|
||||
/// Last mutating command (Repeat).
|
||||
@@ -400,12 +402,19 @@ impl Prefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// The citation style a document records (References › Style), APA when it records none or
|
||||
/// one we don't know.
|
||||
fn doc_bib_style(doc: &Document) -> String {
|
||||
doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string()
|
||||
}
|
||||
|
||||
/// Maximum undo depth.
|
||||
const MAX_UNDO: usize = 500;
|
||||
|
||||
impl Session {
|
||||
pub fn new(doc: Document) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
Session {
|
||||
doc,
|
||||
sel: Selection::caret(start),
|
||||
@@ -441,7 +450,7 @@ impl Session {
|
||||
versions: Vec::new(),
|
||||
building_blocks: Default::default(),
|
||||
autosave: true,
|
||||
bib_style: "APA".into(),
|
||||
bib_style,
|
||||
merge: Default::default(),
|
||||
ui_requests: Vec::new(),
|
||||
math: None,
|
||||
@@ -523,6 +532,27 @@ impl Session {
|
||||
self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Whether the markup area beside the page can show balloons: markup shown in Print Layout
|
||||
/// (not Read Mode or pages side by side), comments not listed in the Comments pane instead,
|
||||
/// and Track Changes Options not set to show every revision inline.
|
||||
pub fn balloons_shown(&self) -> bool {
|
||||
let v = &self.view;
|
||||
v.show_markup
|
||||
&& !v.comments_pane
|
||||
&& !v.read_mode
|
||||
&& !v.multi_page
|
||||
&& v.mode == ViewMode::Print
|
||||
&& self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline
|
||||
}
|
||||
|
||||
/// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave
|
||||
/// the text for balloons in the markup area. Where no balloons can be shown they stay inline,
|
||||
/// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons.
|
||||
pub fn revisions_in_balloons(&self) -> bool {
|
||||
let m = &self.prefs.markup;
|
||||
self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions
|
||||
}
|
||||
|
||||
/// Initials for new comments: the ones set in File › Options, else the user name's.
|
||||
pub fn user_initials(&self) -> String {
|
||||
let set = self.initials.trim();
|
||||
@@ -532,12 +562,17 @@ impl Session {
|
||||
/// The current layout (recomputed when the document or view changed).
|
||||
pub fn layout(&mut self) -> Arc<DocLayout> {
|
||||
let ww = self.view.web_width;
|
||||
// Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so
|
||||
// a view change that moves them in or out of the text lays it out again.
|
||||
let hide_deleted = !self.view.show_markup || self.prefs.markup.hides_deletions();
|
||||
let deleted = (hide_deleted, self.revisions_in_balloons());
|
||||
let outline = (self.view.mode == ViewMode::Outline).then(|| self.view.outline.view());
|
||||
if let Some((r, w, m, l, pf, o)) = &self.layout
|
||||
if let Some((r, w, m, l, pf, o, hd)) = &self.layout
|
||||
&& *r == self.rev
|
||||
&& (*w == ww || self.view.mode == ViewMode::Print)
|
||||
&& *m == self.view.mode
|
||||
&& *pf == self.view.proofing
|
||||
&& *hd == deleted
|
||||
&& *o == outline
|
||||
{
|
||||
return l.clone();
|
||||
@@ -546,7 +581,8 @@ impl Session {
|
||||
view: self.view.mode,
|
||||
web_width: ww,
|
||||
show_hidden: self.view.marks || self.prefs.marks.hidden,
|
||||
hide_deleted: !self.view.show_markup || self.prefs.markup.hides_deletions(),
|
||||
hide_deleted,
|
||||
deletions_in_balloons: deleted.1,
|
||||
proofing: self.view.proofing,
|
||||
comment_balloons: false,
|
||||
field_codes: self.view.field_codes,
|
||||
@@ -554,7 +590,7 @@ impl Session {
|
||||
proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() },
|
||||
};
|
||||
let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts));
|
||||
self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline));
|
||||
self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline, deleted));
|
||||
l
|
||||
}
|
||||
/// A layout for output (PDF, images, print): no proofing marks, print view.
|
||||
@@ -680,6 +716,7 @@ impl Session {
|
||||
/// Replace the document (open/new).
|
||||
pub fn set_document(&mut self, doc: Document) {
|
||||
self.document_id = self.document_id.wrapping_add(1);
|
||||
self.bib_style = doc_bib_style(&doc);
|
||||
self.doc = doc;
|
||||
self.doc.ensure_nonempty();
|
||||
self.password = None;
|
||||
|
||||
@@ -496,6 +496,15 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows PowerShell by its full path, `<SystemRoot>\System32\WindowsPowerShell\v1.0\powershell.exe`
|
||||
/// (`C:\Windows` when `SystemRoot` is unset), rather than whichever `powershell` the program search
|
||||
/// finds first.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_powershell(system_root: Option<std::ffi::OsString>) -> std::path::PathBuf {
|
||||
let root = system_root.filter(|r| !r.is_empty()).unwrap_or_else(|| "C:\\Windows".into());
|
||||
std::path::Path::new(&root).join("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe")
|
||||
}
|
||||
|
||||
/// Speak one sentence with the system's speech program, stopping early when `changed()`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken {
|
||||
@@ -517,7 +526,7 @@ fn speak(text: &str, rate: f32, changed: &dyn Fn() -> bool) -> Spoken {
|
||||
let script = format!(
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate = {r}; $s.Speak([Console]::In.ReadToEnd())"
|
||||
);
|
||||
let mut c = Command::new("powershell");
|
||||
let mut c = Command::new(windows_powershell(std::env::var_os("SystemRoot")));
|
||||
c.args(["-NoProfile", "-NonInteractive", "-Command", &script]);
|
||||
tries.push(c);
|
||||
}
|
||||
@@ -571,6 +580,18 @@ mod tests {
|
||||
v.iter().map(|u| u.text.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_is_launched_from_the_system_folder() {
|
||||
let tail = std::path::Path::new("System32").join("WindowsPowerShell").join("v1.0").join("powershell.exe");
|
||||
let p = windows_powershell(Some("D:\\Win".into()));
|
||||
assert!(p.starts_with("D:\\Win") && p.ends_with(&tail), "{}", p.display());
|
||||
// No SystemRoot, or an empty one: the default Windows folder.
|
||||
for root in [None, Some("".into())] {
|
||||
let p = windows_powershell(root);
|
||||
assert!(p.starts_with("C:\\Windows") && p.ends_with(&tail), "{}", p.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_sentences_but_not_abbreviations() {
|
||||
let t = "Smith et al. found 3.5 times more (see Fig. 2). J. Smith agreed! Did they? Yes… Pick one, e.g. this one.";
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Fonts added at run time (#582): `font.add`, `font.list`, `font.remove`.
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::Pos;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::insert::base64_encode;
|
||||
|
||||
/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it
|
||||
/// is a font the database doesn't have.
|
||||
fn renamed(file: &str, original: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), original.len());
|
||||
let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
/// The caret's x after `text` set in `font`.
|
||||
fn text_end_x(s: &mut Session, font: &str, text: &str) -> f32 {
|
||||
s.run("document.setText", &json!({"text": text})).unwrap();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("format.font", &json!({"name": font})).unwrap();
|
||||
s.layout().caret_on(&Pos::body(0, text.len()), 0).unwrap().x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_font_is_listed_and_lays_out_text_already_set_in_it() {
|
||||
// A monospaced face, so it measures unlike whatever proportional font the system falls back
|
||||
// to (Source Serif 4 itself where no system fonts are installed, as on the FreeBSD runner).
|
||||
// The name has no Serif/Sans/Mono in it, which would pick a fallback of that kind.
|
||||
const FAMILY: &str = "Cmdfont Face 4";
|
||||
// Narrow letters: set monospaced they take far more room than in any proportional font.
|
||||
const TEXT: &str = "illicit little lilies fill tiny jill";
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let mono = text_end_x(&mut s, "JetBrains Mono", TEXT);
|
||||
// Not installed yet: the text falls back to another font.
|
||||
let missing = text_end_x(&mut s, FAMILY, TEXT);
|
||||
assert!((missing - mono).abs() > 1.0, "the fallback measures differently: {missing} vs {mono}");
|
||||
|
||||
let data = base64_encode(&renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", FAMILY));
|
||||
let r = s.run("font.add", &json!({"data": data, "name": "CmdfontFace4-Regular.ttf"})).unwrap();
|
||||
assert_eq!(r["families"], json!([FAMILY]));
|
||||
assert_eq!(r["faces"][0]["style"], "Regular");
|
||||
// The text already set in it re-lays out with it, without touching the document.
|
||||
let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x;
|
||||
assert!((x - mono).abs() < 0.01, "measured with the added font: {x} vs {mono} (missing {missing})");
|
||||
|
||||
let list = s.run("font.list", &json!({"all": true})).unwrap();
|
||||
assert!(list["fonts"].as_array().unwrap().iter().any(|f| f["families"] == json!([FAMILY])));
|
||||
assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY));
|
||||
// It exports to PDF like any other font.
|
||||
assert!(crate::io::save_bytes("out.pdf", &s.doc).unwrap().starts_with(b"%PDF"));
|
||||
|
||||
// Adding it again changes nothing.
|
||||
let again = s.run("font.add", &json!({"data": data, "name": "again.ttf"}));
|
||||
assert!(again.unwrap_err().to_string().contains("already available"));
|
||||
|
||||
let removed = s.run("font.remove", &json!({"family": FAMILY})).unwrap();
|
||||
assert_eq!(removed["removed"].as_array().unwrap().len(), 1);
|
||||
let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x;
|
||||
assert!((x - missing).abs() < 0.01, "falls back again once removed");
|
||||
assert!(s.run("font.remove", &json!({"family": FAMILY})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_font_never_changes_text_that_does_not_use_it() {
|
||||
const FAMILY: &str = "Metric Serif 4";
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let before = s.layout();
|
||||
let positions = |s: &mut Session| {
|
||||
let l = s.layout();
|
||||
(0..s.doc.body.len().min(12)).filter_map(|b| l.caret_on(&Pos::body(b, 0), 0).map(|c| (c.page, c.x, c.top))).collect::<Vec<_>>()
|
||||
};
|
||||
let a = positions(&mut s);
|
||||
s.run("font.add", &json!({"data": base64_encode(&renamed("SourceSerif4-Bold.ttf", "Source Serif 4", FAMILY)), "name": "m.ttf"})).unwrap();
|
||||
assert_eq!(positions(&mut s), a);
|
||||
assert_eq!(s.layout().pages.len(), before.pages.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_font_input_is_an_error() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
for p in [
|
||||
json!({"data": base64_encode(b"definitely not a font")}),
|
||||
json!({"data": "%%% not base64"}),
|
||||
json!({"data": base64_encode(b"wOF2\0\x01\0\0\xff\xff\xff\xff")}),
|
||||
json!({"path": "/nonexistent/font.ttf"}),
|
||||
json!({"path": env!("CARGO_MANIFEST_DIR")}),
|
||||
json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")}),
|
||||
json!({"url": "https://fonts.googleapis.com/css2?family=Roboto"}),
|
||||
] {
|
||||
assert!(s.run("font.add", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.run("font.remove", &json!({})).is_err());
|
||||
assert!(s.run("font.remove", &json!({"family": "Source Sans 3"})).is_err(), "the bundled fonts stay");
|
||||
// No file: the UI is asked for a picker.
|
||||
let r = s.run("font.add", &json!({})).unwrap();
|
||||
assert_eq!(r["pending"], true);
|
||||
assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont"));
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Original synthetic save cases. Every test owns its directory and document copies.
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{Document, StoryRef};
|
||||
use wordcraft_engine::{Session, io};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!("wordcraft-safe-save-test-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.0.join(name)
|
||||
}
|
||||
fn names(&self) -> Vec<String> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(&self.0).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn original(path: &Path) -> Vec<u8> {
|
||||
let bytes = io::save_bytes("original.docx", &Document::from_text("Original synthetic contract.")).unwrap();
|
||||
std::fs::write(path, &bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
fn edited() -> Document {
|
||||
Document::from_text("Original synthetic contract. Edited sentinel.")
|
||||
}
|
||||
fn saved(path: &Path) {
|
||||
assert_eq!(io::open_path(path).unwrap().plain_text(StoryRef::Body), edited().plain_text(StoryRef::Body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_temporary_file_is_not_overwritten_or_removed() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
let neighbour = d.path("contract.docx.tmp");
|
||||
std::fs::write(&neighbour, b"Keep this existing sibling.").unwrap();
|
||||
io::save_path(&path, &edited()).unwrap();
|
||||
saved(&path);
|
||||
assert_eq!(std::fs::read(&neighbour).unwrap(), b"Keep this existing sibling.");
|
||||
assert_eq!(d.names(), ["contract.docx", "contract.docx.tmp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_temporary_directory_does_not_block_a_save() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
let neighbour = d.path("contract.docx.tmp");
|
||||
std::fs::create_dir(&neighbour).unwrap();
|
||||
std::fs::write(neighbour.join("keep"), b"Keep this directory.").unwrap();
|
||||
io::save_path(&path, &edited()).unwrap();
|
||||
saved(&path);
|
||||
assert_eq!(std::fs::read(neighbour.join("keep")).unwrap(), b"Keep this directory.");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn temporary_links_cannot_redirect_a_save_into_the_original_or_a_neighbour() {
|
||||
for link_to_original in [false, true] {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
let other = d.path("neighbour.txt");
|
||||
std::fs::write(&other, b"Keep this neighbour.").unwrap();
|
||||
let link = d.path("contract.docx.tmp");
|
||||
let target = if link_to_original { &path } else { &other };
|
||||
std::os::unix::fs::symlink(target, &link).unwrap();
|
||||
io::save_path(&path, &edited()).unwrap();
|
||||
assert!(!path.is_symlink());
|
||||
saved(&path);
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), *target);
|
||||
assert_eq!(std::fs::read(&other).unwrap(), b"Keep this neighbour.");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_save_as_keeps_original_dirty_session_and_destination() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("original.docx");
|
||||
let bytes = original(&path);
|
||||
let blocked = d.path("blocked.docx");
|
||||
std::fs::create_dir(&blocked).unwrap();
|
||||
std::fs::write(blocked.join("keep"), b"Keep this destination.").unwrap();
|
||||
let mut s = Session::new(edited());
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = true;
|
||||
let before = s.doc.plain_text(StoryRef::Body);
|
||||
assert!(s.run("file.saveAs", &json!({"path": blocked})).is_err());
|
||||
assert!(s.dirty);
|
||||
assert_eq!(s.path.as_ref(), Some(&path));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(std::fs::read(blocked.join("keep")).unwrap(), b"Keep this destination.");
|
||||
assert_eq!(d.names(), ["blocked.docx", "original.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_save_as_keeps_original_and_updates_session_only_after_saving() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("original.docx");
|
||||
let bytes = original(&path);
|
||||
let copy = d.path("copy.docx");
|
||||
let mut s = Session::new(edited());
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = true;
|
||||
s.run("file.saveAs", &json!({"path": copy})).unwrap();
|
||||
assert!(!s.dirty);
|
||||
assert_eq!(s.path.as_ref(), Some(©));
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
saved(©);
|
||||
assert_eq!(d.names(), ["copy.docx", "original.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_parent_keeps_original_and_session_unchanged() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("original.docx");
|
||||
let bytes = original(&path);
|
||||
let mut s = Session::new(edited());
|
||||
s.path = Some(path.clone());
|
||||
s.dirty = true;
|
||||
assert!(s.run("file.saveAs", &json!({"path": d.path("missing/copy.docx")})).is_err());
|
||||
assert!(s.dirty);
|
||||
assert_eq!(s.path, Some(path.clone()));
|
||||
assert_eq!(std::fs::read(path).unwrap(), bytes);
|
||||
assert_eq!(d.names(), ["original.docx"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_save_reopens_only_with_password_and_leaves_neighbours_alone() {
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
let neighbour = d.path("contract.docx.tmp");
|
||||
std::fs::write(&neighbour, b"Keep this sibling.").unwrap();
|
||||
io::save_path_with(&path, &edited(), Some("synthetic-test-password"), &io::ExportOptions::default()).unwrap();
|
||||
assert!(io::needs_password(&io::open_path(&path).unwrap_err()));
|
||||
let (doc, encrypted) = io::open_path_with(&path, Some("synthetic-test-password")).unwrap();
|
||||
assert!(encrypted);
|
||||
assert_eq!(doc.plain_text(StoryRef::Body), edited().plain_text(StoryRef::Body));
|
||||
assert_eq!(std::fs::read(&neighbour).unwrap(), b"Keep this sibling.");
|
||||
assert_eq!(d.names(), ["contract.docx", "contract.docx.tmp"]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn replacement_keeps_existing_unix_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let d = Scratch::new();
|
||||
let path = d.path("contract.docx");
|
||||
original(&path);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
io::save_path(&path, &edited()).unwrap();
|
||||
saved(&path);
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
wuff = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
unicode-script = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! time a lookup needs them, native only), outline cache.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
@@ -12,6 +12,9 @@ use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
|
||||
mod user;
|
||||
pub use user::{FONT_FILE_EXTS, FontFormat, Installed, MAX_FONT_FILE_BYTES, UserFont, UserFontError, decode_font_file, is_font_file_name, sniff};
|
||||
|
||||
/// The family used when a requested family is unknown (the UI sans).
|
||||
pub const FALLBACK_FAMILY: &str = "Source Sans 3";
|
||||
|
||||
@@ -293,6 +296,10 @@ pub struct FontDb {
|
||||
fallbacks: RwLock<FallbackCache>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// Fonts the user added at run time, and the folder they are stored in (#582).
|
||||
user: RwLock<user::UserFonts>,
|
||||
/// Bumped whenever user fonts are added or removed ([`FontDb::generation`]).
|
||||
generation: AtomicU64,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
font_dirs: Vec<std::path::PathBuf>,
|
||||
@@ -839,6 +846,8 @@ impl FontDb {
|
||||
fallbacks: RwLock::new(FallbackCache::default()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
user: RwLock::new(user::UserFonts::default()),
|
||||
generation: AtomicU64::new(0),
|
||||
font_dirs,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
cataloged: std::sync::OnceLock::new(),
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
//! Fonts an end user adds at run time, without rebuilding (#582).
|
||||
//!
|
||||
//! The bundled faces (and the system fonts) stay the baseline: a user font only adds faces, never
|
||||
//! replaces one (a face whose family and style are already loaded is skipped), so documents that
|
||||
//! don't name it keep their metrics. A file is checked before anything is added: its size is
|
||||
//! capped, WOFF and WOFF2 are unwrapped, and every face must parse and map characters.
|
||||
//!
|
||||
//! Desktop: [`FontDb::load_user_fonts`] names a folder (the app's `fonts` folder); the fonts there
|
||||
//! load at start-up and each font added later is copied there (unwrapped from WOFF/WOFF2). Without
|
||||
//! a folder (the web, tests, `WORDCRAFT_NO_PREFS`) added fonts last for the session; the web
|
||||
//! shell keeps them in the browser itself.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::raw::FileRef;
|
||||
|
||||
use super::{FontBytes, FontDb, enumerate_faces, make_face};
|
||||
|
||||
/// Largest font file accepted (as given: TTF, OTF, TTC, WOFF or WOFF2).
|
||||
pub const MAX_FONT_FILE_BYTES: usize = 50 << 20;
|
||||
/// Largest font a WOFF/WOFF2 file may unwrap to.
|
||||
const MAX_DECODED_BYTES: usize = 128 << 20;
|
||||
/// Most faces one file may add (collections, variable fonts' named instances).
|
||||
const MAX_FACES_PER_FILE: usize = 256;
|
||||
/// Most user fonts at once.
|
||||
const MAX_USER_FONTS: usize = 1000;
|
||||
/// File extensions [`FontDb::load_user_fonts`] reads (fonts are stored unwrapped).
|
||||
const STORED_EXTS: &[&str] = &["ttf", "otf", "ttc", "otc"];
|
||||
/// Extensions a font file the user picks or drops may have.
|
||||
pub const FONT_FILE_EXTS: &[&str] = &["ttf", "otf", "ttc", "otc", "woff", "woff2"];
|
||||
|
||||
/// Is `name` (a file name or path) named like a font file?
|
||||
pub fn is_font_file_name(name: &str) -> bool {
|
||||
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
|
||||
ext.is_some_and(|e| FONT_FILE_EXTS.contains(&e.as_str()))
|
||||
}
|
||||
|
||||
/// Why a font couldn't be added or removed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum UserFontError {
|
||||
Empty,
|
||||
TooLarge {
|
||||
bytes: usize,
|
||||
},
|
||||
/// Not a font file this reads (TrueType, OpenType, collections, WOFF, WOFF2).
|
||||
NotAFont,
|
||||
/// A WOFF/WOFF2 file that couldn't be unwrapped.
|
||||
BadWebFont(String),
|
||||
/// A font file that doesn't parse, or has no usable face.
|
||||
Unusable(String),
|
||||
/// Every face in the file is already available.
|
||||
AlreadyAvailable(Vec<String>),
|
||||
TooMany,
|
||||
/// The family isn't one the user added (bundled and system fonts can't be removed).
|
||||
NotUserFont(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserFontError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
UserFontError::Empty => write!(f, "the font file is empty"),
|
||||
UserFontError::TooLarge { bytes } => {
|
||||
write!(f, "the font file is too large ({} MB; at most {} MB)", bytes.div_ceil(1 << 20), MAX_FONT_FILE_BYTES >> 20)
|
||||
}
|
||||
UserFontError::NotAFont => write!(f, "not a font file (TrueType, OpenType, TTC, WOFF or WOFF2)"),
|
||||
UserFontError::BadWebFont(e) => write!(f, "the web font couldn't be read: {e}"),
|
||||
UserFontError::Unusable(e) => write!(f, "the font couldn't be read: {e}"),
|
||||
UserFontError::AlreadyAvailable(families) => write!(f, "already available: {}", families.join(", ")),
|
||||
UserFontError::TooMany => write!(f, "too many added fonts (at most {MAX_USER_FONTS}); remove some first"),
|
||||
UserFontError::NotUserFont(family) => write!(f, "{family:?} isn't a font you added"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for UserFontError {}
|
||||
|
||||
/// A font file the user added.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct UserFont {
|
||||
/// Its file name in the user font folder (unique among the user fonts), also the key the web
|
||||
/// shell stores it under.
|
||||
pub file: String,
|
||||
/// The faces it added: (typographic family, style).
|
||||
pub faces: Vec<(String, String)>,
|
||||
/// Where it is stored (desktop); `None` when it lasts for the session only.
|
||||
pub path: Option<PathBuf>,
|
||||
face_ids: Vec<u32>,
|
||||
}
|
||||
|
||||
impl UserFont {
|
||||
/// The families it added (typographic names, in file order, each once).
|
||||
pub fn families(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = Vec::new();
|
||||
for (family, _) in &self.faces {
|
||||
if !v.iter().any(|f| f.eq_ignore_ascii_case(family)) {
|
||||
v.push(family.clone());
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`FontDb::install_font`] did.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Installed {
|
||||
pub font: UserFont,
|
||||
/// The font was added for this session but couldn't be stored for the next one.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct UserFonts {
|
||||
fonts: Vec<UserFont>,
|
||||
dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The kind of font file `bytes` holds, from its signature.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FontFormat {
|
||||
TrueType,
|
||||
OpenType,
|
||||
Collection,
|
||||
Woff,
|
||||
Woff2,
|
||||
}
|
||||
|
||||
pub fn sniff(bytes: &[u8]) -> Option<FontFormat> {
|
||||
match bytes.get(..4)? {
|
||||
[0, 1, 0, 0] | b"true" | b"typ1" => Some(FontFormat::TrueType),
|
||||
b"OTTO" => Some(FontFormat::OpenType),
|
||||
b"ttcf" => Some(FontFormat::Collection),
|
||||
b"wOFF" => Some(FontFormat::Woff),
|
||||
b"wOF2" => Some(FontFormat::Woff2),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The font file in `bytes` as plain OpenType (TTF/OTF/TTC) bytes: WOFF and WOFF2 are unwrapped.
|
||||
/// Checks the size and the signature only; [`FontDb::install_font`] checks the faces.
|
||||
pub fn decode_font_file(bytes: &[u8]) -> Result<Vec<u8>, UserFontError> {
|
||||
if bytes.is_empty() {
|
||||
return Err(UserFontError::Empty);
|
||||
}
|
||||
if bytes.len() > MAX_FONT_FILE_BYTES {
|
||||
return Err(UserFontError::TooLarge { bytes: bytes.len() });
|
||||
}
|
||||
let decoded = match sniff(bytes).ok_or(UserFontError::NotAFont)? {
|
||||
FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection => return Ok(bytes.to_vec()),
|
||||
FontFormat::Woff => unwrap_web_font(|| wuff::decompress_woff1(bytes))?,
|
||||
FontFormat::Woff2 => unwrap_web_font(|| wuff::decompress_woff2(bytes))?,
|
||||
};
|
||||
if decoded.len() > MAX_DECODED_BYTES {
|
||||
return Err(UserFontError::TooLarge { bytes: decoded.len() });
|
||||
}
|
||||
match sniff(&decoded) {
|
||||
Some(FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection) => Ok(decoded),
|
||||
_ => Err(UserFontError::BadWebFont("it doesn't hold a TrueType or OpenType font".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a WOFF/WOFF2 decoder. The decoder checks its input, but it is third-party code reading
|
||||
/// hostile bytes: a panic in it is caught (native) and reported as a damaged file.
|
||||
fn unwrap_web_font(decode: impl FnOnce() -> Result<Vec<u8>, wuff::WuffErr>) -> Result<Vec<u8>, UserFontError> {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(decode)) {
|
||||
Ok(Ok(v)) => Ok(v),
|
||||
Ok(Err(e)) => Err(UserFontError::BadWebFont(format!("{e:?}"))),
|
||||
Err(_) => Err(UserFontError::BadWebFont("damaged file".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name for a user font from the name it came with: letters, digits, spaces, `-` and `_`
|
||||
/// of its stem (others become `_`, at most 64), and the extension of its unwrapped format.
|
||||
fn stored_name(name: &str, sfnt: &[u8]) -> String {
|
||||
// The last component, whichever separator the name came with.
|
||||
let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
|
||||
let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
|
||||
let mut clean: String = stem.chars().take(64).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '_' }).collect();
|
||||
clean = clean.trim().to_string();
|
||||
if clean.is_empty() || clean.starts_with('-') {
|
||||
clean = format!("font{clean}");
|
||||
}
|
||||
let ext = match sniff(sfnt) {
|
||||
Some(FontFormat::OpenType) => "otf",
|
||||
Some(FontFormat::Collection) => "ttc",
|
||||
_ => "ttf",
|
||||
};
|
||||
format!("{clean}.{ext}")
|
||||
}
|
||||
|
||||
impl FontDb {
|
||||
/// A counter that changes whenever user fonts are added or removed: a UI caching the font
|
||||
/// list or a layout redraws when it moves.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn read_user(&self) -> std::sync::RwLockReadGuard<'_, UserFonts> {
|
||||
self.user.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn write_user(&self) -> std::sync::RwLockWriteGuard<'_, UserFonts> {
|
||||
self.user.write().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The fonts the user added, in the order added.
|
||||
pub fn user_fonts(&self) -> Vec<UserFont> {
|
||||
self.read_user().fonts.clone()
|
||||
}
|
||||
|
||||
/// The folder user fonts are stored in, if any ([`FontDb::load_user_fonts`]).
|
||||
pub fn user_font_dir(&self) -> Option<PathBuf> {
|
||||
self.read_user().dir.clone()
|
||||
}
|
||||
|
||||
/// Add the font file `bytes` (TTF, OTF, TTC, WOFF or WOFF2) named `name` (its file name, for
|
||||
/// the stored copy). Every face is checked first; faces whose family and style are already
|
||||
/// available are skipped (the bundled baseline is never replaced). With a user font folder the
|
||||
/// font is stored there too, for the next start.
|
||||
pub fn install_font(&self, name: &str, bytes: &[u8]) -> Result<Installed, UserFontError> {
|
||||
let sfnt = decode_font_file(bytes)?;
|
||||
let mut font = self.add_user_faces(name, sfnt.clone(), None)?;
|
||||
let warning = match self.user_font_dir() {
|
||||
Some(dir) => match write_unique(&dir, &font.file, &sfnt) {
|
||||
Ok(path) => {
|
||||
font.file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(font.file);
|
||||
font.path = Some(path);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("couldn't store the font {name} in {}: {e}", dir.display());
|
||||
Some(format!("added for this session only: couldn't store it in {}: {e}", dir.display()))
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
if let Some(f) = self.write_user().fonts.iter_mut().find(|f| f.face_ids == font.face_ids) {
|
||||
f.file.clone_from(&font.file);
|
||||
f.path.clone_from(&font.path);
|
||||
}
|
||||
Ok(Installed { font, warning })
|
||||
}
|
||||
|
||||
/// Check the faces in `sfnt` and add the new ones, recording them as a user font.
|
||||
fn add_user_faces(&self, name: &str, sfnt: Vec<u8>, path: Option<PathBuf>) -> Result<UserFont, UserFontError> {
|
||||
if self.read_user().fonts.len() >= MAX_USER_FONTS {
|
||||
return Err(UserFontError::TooMany);
|
||||
}
|
||||
if FileRef::new(&sfnt).is_err() {
|
||||
return Err(UserFontError::Unusable("not a valid OpenType file".into()));
|
||||
}
|
||||
let found = enumerate_faces(&sfnt);
|
||||
if found.is_empty() {
|
||||
return Err(UserFontError::Unusable("no face with a family name".into()));
|
||||
}
|
||||
if found.len() > MAX_FACES_PER_FILE {
|
||||
return Err(UserFontError::Unusable(format!("more than {MAX_FACES_PER_FILE} faces")));
|
||||
}
|
||||
let file = stored_name(name, &sfnt);
|
||||
let data = Arc::new(sfnt);
|
||||
let mut new = Vec::new();
|
||||
for f in found {
|
||||
let label = format!("{} {}", f.family, f.style);
|
||||
let face = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords)
|
||||
.ok_or_else(|| UserFontError::Unusable(format!("{label} doesn't parse")))?;
|
||||
if face.skrifa().is_none_or(|s| s.charmap().mappings().next().is_none()) {
|
||||
return Err(UserFontError::Unusable(format!("{label} maps no characters")));
|
||||
}
|
||||
new.push(Arc::new(face));
|
||||
}
|
||||
let families = |faces: &[Arc<super::FontFace>]| {
|
||||
let mut v: Vec<String> = faces.iter().map(|f| f.family.clone()).collect();
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let mut user = self.write_user();
|
||||
// Unique among the user fonts (the folder is checked when writing).
|
||||
let file = unique_name(&file, |n| user.fonts.iter().any(|f| f.file.eq_ignore_ascii_case(n)));
|
||||
let added: Vec<Arc<super::FontFace>> = {
|
||||
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
|
||||
let fresh: Vec<_> = new
|
||||
.iter()
|
||||
.filter(|f| !faces.iter().any(|g| g.family.eq_ignore_ascii_case(&f.family) && g.style.eq_ignore_ascii_case(&f.style)))
|
||||
.cloned()
|
||||
.collect();
|
||||
faces.extend(fresh.iter().cloned());
|
||||
fresh
|
||||
};
|
||||
if added.is_empty() {
|
||||
return Err(UserFontError::AlreadyAvailable(families(&new)));
|
||||
}
|
||||
let font = UserFont {
|
||||
file,
|
||||
faces: added.iter().map(|f| (f.family.clone(), f.style.clone())).collect(),
|
||||
path,
|
||||
face_ids: added.iter().map(|f| f.id).collect(),
|
||||
};
|
||||
user.fonts.push(font.clone());
|
||||
drop(user);
|
||||
self.fonts_changed();
|
||||
Ok(font)
|
||||
}
|
||||
|
||||
/// Forget cached fallback choices and tell watchers ([`FontDb::generation`]).
|
||||
fn fonts_changed(&self) {
|
||||
self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear();
|
||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
/// Remove the user fonts that added `family` (a typographic or legacy family name): their
|
||||
/// faces leave the font list and their stored files are deleted. Text set in the family falls
|
||||
/// back as for any font that isn't installed. Bundled and system fonts can't be removed.
|
||||
pub fn remove_user_font(&self, family: &str) -> Result<Vec<UserFont>, UserFontError> {
|
||||
let mut user = self.write_user();
|
||||
let ids: Vec<u32> = {
|
||||
let faces = self.read_faces();
|
||||
faces.iter().filter(|f| f.named(family)).map(|f| f.id).collect()
|
||||
};
|
||||
let (gone, kept): (Vec<UserFont>, Vec<UserFont>) = std::mem::take(&mut user.fonts)
|
||||
.into_iter()
|
||||
.partition(|u| u.faces.iter().any(|(f, _)| f.eq_ignore_ascii_case(family)) || u.face_ids.iter().any(|i| ids.contains(i)));
|
||||
user.fonts = kept;
|
||||
drop(user);
|
||||
if gone.is_empty() {
|
||||
return Err(UserFontError::NotUserFont(family.to_string()));
|
||||
}
|
||||
let drop_ids: Vec<u32> = gone.iter().flat_map(|u| u.face_ids.iter().copied()).collect();
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| !drop_ids.contains(&f.id));
|
||||
for u in &gone {
|
||||
if let Some(p) = &u.path
|
||||
&& let Err(e) = std::fs::remove_file(p)
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
log::warn!("couldn't delete the font file {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
self.fonts_changed();
|
||||
Ok(gone)
|
||||
}
|
||||
|
||||
/// Use `dir` as the user font folder: load the fonts stored there (each checked like an added
|
||||
/// font; one that fails is logged and skipped) and store fonts added later there. The folder is
|
||||
/// made when the first font is stored. Returns the fonts loaded and the files skipped (with
|
||||
/// why).
|
||||
pub fn load_user_fonts(&self, dir: &Path) -> (Vec<UserFont>, Vec<(String, String)>) {
|
||||
self.write_user().dir = Some(dir.to_path_buf());
|
||||
let mut loaded = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return (loaded, skipped) };
|
||||
let mut paths: Vec<PathBuf> = rd
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()).is_some_and(|e| STORED_EXTS.contains(&e.to_ascii_lowercase().as_str())))
|
||||
.collect();
|
||||
paths.sort();
|
||||
for p in paths {
|
||||
let file = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let read = match std::fs::metadata(&p) {
|
||||
Ok(m) if m.len() > MAX_FONT_FILE_BYTES as u64 => {
|
||||
Err(UserFontError::TooLarge { bytes: usize::try_from(m.len()).unwrap_or(usize::MAX) })
|
||||
}
|
||||
Ok(_) => std::fs::read(&p).map_err(|e| UserFontError::Unusable(e.to_string())),
|
||||
Err(e) => Err(UserFontError::Unusable(e.to_string())),
|
||||
};
|
||||
let r = read.and_then(|b| decode_font_file(&b)).and_then(|sfnt| self.add_user_faces(&file, sfnt, Some(p.clone())));
|
||||
match r {
|
||||
Ok(mut f) => {
|
||||
// Its own file name, so removing it deletes this file.
|
||||
if let Some(u) = self.write_user().fonts.iter_mut().find(|u| u.face_ids == f.face_ids) {
|
||||
u.file.clone_from(&file);
|
||||
}
|
||||
f.file = file;
|
||||
loaded.push(f);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("skipped the user font {}: {e}", p.display());
|
||||
skipped.push((file, e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
(loaded, skipped)
|
||||
}
|
||||
}
|
||||
|
||||
/// `name`, or `name` with `-2`, `-3`… before its extension, the first that `taken` refuses.
|
||||
fn unique_name(name: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
if !taken(name) {
|
||||
return name.to_string();
|
||||
}
|
||||
let (stem, ext) = name.rsplit_once('.').unwrap_or((name, ""));
|
||||
(2..10_000).map(|n| format!("{stem}-{n}.{ext}")).find(|n| !taken(n)).unwrap_or_else(|| format!("{stem}-x.{ext}"))
|
||||
}
|
||||
|
||||
/// Write `bytes` to a new file in `dir` named `name` (or a numbered variant when that exists),
|
||||
/// through a temporary file so a crash never leaves half a font.
|
||||
fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let name = unique_name(name, |n| dir.join(n).exists());
|
||||
let path = dir.join(&name);
|
||||
let tmp = dir.join(format!(".{name}.part"));
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FAMILY: &str = "Userfont Sans";
|
||||
|
||||
/// A bundled Source Sans 3 file renamed `family` (as long as the original name).
|
||||
pub(crate) fn renamed(file: &str, family: &str) -> Vec<u8> {
|
||||
assert_eq!(family.len(), "Source Sans 3".len());
|
||||
let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
||||
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn db() -> FontDb {
|
||||
FontDb::with_font_dirs(vec![])
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wc-userfonts-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
/// The tables of a TrueType/OpenType font: (tag, data), in directory order.
|
||||
fn tables(font: &[u8]) -> Vec<([u8; 4], Vec<u8>)> {
|
||||
let n = u16::from_be_bytes([font[4], font[5]]) as usize;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let r = &font[12 + i * 16..28 + i * 16];
|
||||
let off = u32::from_be_bytes(r[8..12].try_into().unwrap()) as usize;
|
||||
let len = u32::from_be_bytes(r[12..16].try_into().unwrap()) as usize;
|
||||
(r[..4].try_into().unwrap(), font[off..off + len].to_vec())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `font` as a WOFF 1.0 file with every table stored uncompressed (the format allows it).
|
||||
fn woff1(font: &[u8]) -> Vec<u8> {
|
||||
let mut t = tables(font);
|
||||
t.sort_by_key(|x| x.0);
|
||||
let mut out = Vec::new();
|
||||
let dir_end = 44 + 20 * t.len();
|
||||
let mut body = Vec::new();
|
||||
let mut dir = Vec::new();
|
||||
let mut sfnt_size = 12 + 16 * t.len();
|
||||
for (tag, data) in &t {
|
||||
let off = dir_end + body.len();
|
||||
dir.extend_from_slice(tag);
|
||||
dir.extend_from_slice(&(off as u32).to_be_bytes());
|
||||
dir.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
dir.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
dir.extend_from_slice(&0u32.to_be_bytes());
|
||||
body.extend_from_slice(data);
|
||||
body.resize(body.len().next_multiple_of(4), 0);
|
||||
sfnt_size += data.len().next_multiple_of(4);
|
||||
}
|
||||
out.extend_from_slice(b"wOFF");
|
||||
out.extend_from_slice(&font[..4]);
|
||||
out.extend_from_slice(&((dir_end + body.len()) as u32).to_be_bytes());
|
||||
out.extend_from_slice(&(t.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(&0u16.to_be_bytes());
|
||||
out.extend_from_slice(&(sfnt_size as u32).to_be_bytes());
|
||||
out.extend_from_slice(&[0, 1, 0, 0]); // version 1.0
|
||||
out.extend_from_slice(&[0; 20]); // no metadata, no private data
|
||||
out.extend(dir);
|
||||
out.extend(body);
|
||||
out
|
||||
}
|
||||
|
||||
/// `data` as a brotli stream of uncompressed meta-blocks (RFC 7932 §9.2).
|
||||
fn brotli_stored(data: &[u8]) -> Vec<u8> {
|
||||
struct Bits {
|
||||
out: Vec<u8>,
|
||||
acc: u64,
|
||||
n: u32,
|
||||
}
|
||||
impl Bits {
|
||||
fn put(&mut self, v: u64, bits: u32) {
|
||||
self.acc |= v << self.n;
|
||||
self.n += bits;
|
||||
while self.n >= 8 {
|
||||
self.out.push(self.acc as u8);
|
||||
self.acc >>= 8;
|
||||
self.n -= 8;
|
||||
}
|
||||
}
|
||||
fn align(&mut self) {
|
||||
if self.n > 0 {
|
||||
self.put(0, 8 - self.n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut b = Bits { out: Vec::new(), acc: 0, n: 0 };
|
||||
b.put(0, 1); // WBITS = 16
|
||||
for chunk in data.chunks(1 << 16) {
|
||||
b.put(0, 1); // ISLAST = 0
|
||||
b.put(0, 2); // MNIBBLES = 4
|
||||
b.put(chunk.len() as u64 - 1, 16);
|
||||
b.put(1, 1); // ISUNCOMPRESSED
|
||||
b.align();
|
||||
b.out.extend_from_slice(chunk);
|
||||
}
|
||||
b.put(1, 1); // ISLAST
|
||||
b.put(1, 1); // ISLASTEMPTY
|
||||
b.align();
|
||||
b.out
|
||||
}
|
||||
|
||||
/// WOFF2's UIntBase128.
|
||||
fn base128(mut v: u32, out: &mut Vec<u8>) {
|
||||
let mut bytes = vec![(v & 0x7f) as u8];
|
||||
v >>= 7;
|
||||
while v > 0 {
|
||||
bytes.push((v & 0x7f) as u8 | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out.extend(bytes.into_iter().rev());
|
||||
}
|
||||
|
||||
/// `font` (TrueType) as a WOFF2 file with no table transformed (glyf/loca use the null
|
||||
/// transform, version 3) and the table data in stored brotli blocks.
|
||||
fn woff2(font: &[u8]) -> Vec<u8> {
|
||||
let t = tables(font);
|
||||
let mut dir = Vec::new();
|
||||
let mut stream = Vec::new();
|
||||
for (tag, data) in &t {
|
||||
let null = if tag == b"glyf" || tag == b"loca" { 3 << 6 } else { 0 };
|
||||
dir.push(63 | null);
|
||||
dir.extend_from_slice(tag);
|
||||
base128(data.len() as u32, &mut dir);
|
||||
stream.extend_from_slice(data);
|
||||
}
|
||||
let compressed = brotli_stored(&stream);
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"wOF2");
|
||||
out.extend_from_slice(&font[..4]);
|
||||
let total = (48 + dir.len() + compressed.len()).next_multiple_of(4);
|
||||
out.extend_from_slice(&(total as u32).to_be_bytes());
|
||||
out.extend_from_slice(&(t.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(&0u16.to_be_bytes());
|
||||
let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::<usize>();
|
||||
out.extend_from_slice(&(sfnt as u32).to_be_bytes());
|
||||
out.extend_from_slice(&(compressed.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&[0, 1, 0, 0]);
|
||||
out.extend_from_slice(&[0; 20]);
|
||||
out.extend(dir);
|
||||
out.extend(compressed);
|
||||
out.resize(total, 0);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_font_is_listed_resolved_and_measured() {
|
||||
let db = db();
|
||||
let gen0 = db.generation();
|
||||
assert!(!db.has_family(FAMILY));
|
||||
let r = db.install_font("Userfont-Regular.ttf", &renamed("SourceSans3-Regular.ttf", FAMILY)).unwrap();
|
||||
assert_eq!(r.font.families(), vec![FAMILY.to_string()]);
|
||||
assert_eq!(r.font.file, "Userfont-Regular.ttf");
|
||||
assert!(r.font.path.is_none() && r.warning.is_none(), "no folder: kept for the session only");
|
||||
assert!(db.generation() > gen0);
|
||||
assert!(db.families().iter().any(|f| f == FAMILY));
|
||||
let face = db.face(FAMILY, "Regular");
|
||||
assert_eq!(face.family, FAMILY);
|
||||
// Measured with its own glyphs: the copy has Source Sans 3's metrics.
|
||||
let src = db.face("Source Sans 3", "Regular");
|
||||
let g = face.glyph_for('W');
|
||||
assert!(g != 0);
|
||||
assert_eq!(face.advance(g), src.advance(src.glyph_for('W')));
|
||||
assert_eq!(db.user_fonts().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bundled_baseline_is_never_replaced() {
|
||||
let db = db();
|
||||
let before = db.face("Source Sans 3", "Regular").id();
|
||||
let bytes = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
|
||||
assert!(matches!(db.install_font("x.ttf", &bytes), Err(UserFontError::AlreadyAvailable(_))));
|
||||
assert_eq!(db.face("Source Sans 3", "Regular").id(), before);
|
||||
assert!(db.user_fonts().is_empty());
|
||||
assert!(matches!(db.remove_user_font("Source Sans 3"), Err(UserFontError::NotUserFont(_))));
|
||||
assert!(db.has_family("Source Sans 3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_is_rejected_without_adding_anything() {
|
||||
let db = db();
|
||||
let n = db.families().len();
|
||||
let regular = renamed("SourceSans3-Regular.ttf", FAMILY);
|
||||
let mut cases: Vec<Vec<u8>> = vec![
|
||||
vec![],
|
||||
b"not a font at all".to_vec(),
|
||||
b"\0\x01\0\0".to_vec(),
|
||||
b"\0\x01\0\0\xff\xff\0\0\0\0\0\0".to_vec(),
|
||||
b"ttcf\0\x01\0\0\xff\xff\xff\xff".to_vec(),
|
||||
b"OTTO\0\x01".to_vec(),
|
||||
b"wOFF\0\x01\0\0\0\0\0\x08".to_vec(),
|
||||
b"wOF2\0\x01\0\0\xff\xff\xff\xff\0\x05".to_vec(),
|
||||
regular[..regular.len() / 3].to_vec(),
|
||||
];
|
||||
// Damaged web fonts: every prefix, and flipped bytes through the headers and directory.
|
||||
for packed in [woff1(®ular), woff2(®ular)] {
|
||||
for cut in [8, 20, 44, 47, 48, 60, 100, 300, packed.len() / 2, packed.len() - 1] {
|
||||
cases.push(packed[..cut.min(packed.len())].to_vec());
|
||||
}
|
||||
for at in 4..200 {
|
||||
let mut b = packed.clone();
|
||||
b[at] ^= 0xA5;
|
||||
cases.push(b);
|
||||
}
|
||||
}
|
||||
for (i, c) in cases.iter().enumerate() {
|
||||
let r = db.install_font("junk.ttf", c);
|
||||
// A flipped byte inside a table can leave a usable font; the cut-off files can't.
|
||||
assert!(i >= 9 || r.is_err(), "case {i}");
|
||||
}
|
||||
assert_eq!(db.families().len(), n + db.user_fonts().iter().map(|u| u.families().len()).sum::<usize>());
|
||||
let mut big = regular.clone();
|
||||
big.resize(MAX_FONT_FILE_BYTES + 1, 0);
|
||||
assert!(matches!(db.install_font("big.ttf", &big), Err(UserFontError::TooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn woff_and_woff2_are_unwrapped() {
|
||||
let regular = renamed("SourceSans3-Regular.ttf", FAMILY);
|
||||
for (name, packed) in [("a.woff", woff1(®ular)), ("a.woff2", woff2(®ular))] {
|
||||
assert!(is_font_file_name(name));
|
||||
let db = db();
|
||||
let r = db.install_font(name, &packed).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(r.font.file, "a.ttf", "stored unwrapped");
|
||||
let face = db.face(FAMILY, "Regular");
|
||||
assert_eq!(face.family, FAMILY);
|
||||
assert!(face.glyph_for('g') != 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_fonts_are_stored_loaded_and_removed() {
|
||||
let dir = temp_dir("store");
|
||||
let db = db();
|
||||
let (loaded, skipped) = db.load_user_fonts(&dir);
|
||||
assert!(loaded.is_empty() && skipped.is_empty(), "a missing folder is empty");
|
||||
let r = db.install_font("../../evil/Userfont Bold?.woff2", &woff2(&renamed("SourceSans3-Bold.ttf", FAMILY))).unwrap();
|
||||
assert!(r.warning.is_none());
|
||||
let path = r.font.path.clone().unwrap();
|
||||
assert_eq!(path.parent(), Some(dir.as_path()), "a file name can't leave the folder");
|
||||
assert_eq!(r.font.file, "Userfont Bold_.ttf");
|
||||
assert!(path.exists());
|
||||
db.install_font("Userfont-Regular.ttf", &renamed("SourceSans3-Regular.ttf", FAMILY)).unwrap();
|
||||
// A damaged file in the folder is skipped, never fatal.
|
||||
std::fs::write(dir.join("damaged.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap();
|
||||
std::fs::write(dir.join("notes.txt"), b"hello").unwrap();
|
||||
|
||||
// The next start loads both from the folder.
|
||||
let next = super::super::FontDb::with_font_dirs(vec![]);
|
||||
let (loaded, skipped) = next.load_user_fonts(&dir);
|
||||
assert_eq!(loaded.len(), 2, "{skipped:?}");
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert_eq!(next.styles(FAMILY), vec!["Regular".to_string(), "Bold".to_string()]);
|
||||
assert_eq!(next.face(FAMILY, "Bold").style, "Bold");
|
||||
|
||||
// Removing the family deletes both files; text in it falls back.
|
||||
let gone = next.remove_user_font(FAMILY).unwrap();
|
||||
assert_eq!(gone.len(), 2);
|
||||
assert!(!next.has_family(FAMILY));
|
||||
assert_eq!(next.face(FAMILY, "Regular").family, super::super::FALLBACK_FAMILY);
|
||||
assert!(!path.exists());
|
||||
assert!(next.user_fonts().is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_names_are_safe_and_unique() {
|
||||
let ttf = renamed("SourceSans3-Regular.ttf", FAMILY);
|
||||
assert_eq!(stored_name("C:\\x\\..\\a.b.TTF", &ttf), "a_b.ttf");
|
||||
assert_eq!(stored_name("", &ttf), "font.ttf");
|
||||
assert_eq!(stored_name("-rf.otf", b"OTTO"), "font-rf.otf");
|
||||
assert_eq!(unique_name("a.ttf", |n| n == "a.ttf" || n == "a-2.ttf"), "a-3.ttf");
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ mod script;
|
||||
mod ui_fallback;
|
||||
pub mod word;
|
||||
|
||||
pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LocalName, base_style, bundled, name_language_matches, system_font_dirs};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FONT_FILE_EXTS, FaceRef, FontDb, FontFace, FontFormat, Installed, LocalName, MAX_FONT_FILE_BYTES, UserFont, UserFontError,
|
||||
base_style, bundled, decode_font_file, is_font_file_name, name_language_matches, sniff, system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
|
||||
pub use kurbo::BezPath;
|
||||
|
||||
+42
-16
@@ -174,15 +174,24 @@ fn style_name(bold: bool, italic: bool) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `family` with bold/italic to a face (memoised).
|
||||
/// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed,
|
||||
/// [`FontDb::generation`]).
|
||||
pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
static CACHE: OnceLock<Mutex<HashMap<(String, bool, bool), Resolved>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let key = (family.to_string(), bold, italic);
|
||||
if let Some(r) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return *r;
|
||||
}
|
||||
type Memo = (u64, HashMap<(String, bool, bool), Resolved>);
|
||||
static CACHE: OnceLock<Mutex<Memo>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new((0, HashMap::new())));
|
||||
let db = FontDb::global();
|
||||
let generation = db.generation();
|
||||
let key = (family.to_string(), bold, italic);
|
||||
{
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.0 != generation {
|
||||
*c = (generation, HashMap::new());
|
||||
}
|
||||
if let Some(r) = c.1.get(&key) {
|
||||
return *r;
|
||||
}
|
||||
}
|
||||
let (fam, substituted) = if db.has_family(family) {
|
||||
(family.to_string(), false)
|
||||
} else {
|
||||
@@ -191,7 +200,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
};
|
||||
let face = db.face(&fam, style_name(bold, italic));
|
||||
let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) };
|
||||
cache.lock().unwrap_or_else(|e| e.into_inner()).insert(key, r);
|
||||
let mut c = cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.0 == generation {
|
||||
c.1.insert(key, r);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
@@ -223,9 +235,15 @@ pub fn symbol_font_char(family: &str, c: char) -> Option<char> {
|
||||
Some(u)
|
||||
}
|
||||
|
||||
/// A line's (ascent, descent) from hhea values, both positive: the line gap goes above the ascent.
|
||||
fn hhea_line(ascender: i16, descender: i16, line_gap: i16) -> (f64, f64) {
|
||||
(f64::from(ascender) + f64::from(line_gap.max(0)), f64::from(descender).abs())
|
||||
}
|
||||
|
||||
/// Line metrics for a face in font units: (ascent, descent), both positive, the way word
|
||||
/// processors measure single line spacing (OS/2 usWinAscent/usWinDescent, falling back to the
|
||||
/// hhea values plus line gap).
|
||||
/// processors measure single line spacing: the hhea values with the line gap above the ascent
|
||||
/// (Word puts the first baseline that far below the line top), falling back to OS/2
|
||||
/// usWinAscent/usWinDescent.
|
||||
pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) {
|
||||
static CACHE: OnceLock<Mutex<HashMap<u32, (f64, f64)>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
@@ -234,12 +252,9 @@ pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) {
|
||||
}
|
||||
let mut v = (face.ascent.max(1.0), face.descent.abs());
|
||||
if let Some(f) = face.skrifa() {
|
||||
// hhea ascender + descender + line gap (Word on macOS); OS/2 win metrics only when the
|
||||
// hhea values are missing, and never more than twice the em.
|
||||
let hhea = f.hhea().ok().map(|h| {
|
||||
let gap = h.line_gap().to_i16().max(0) as f64;
|
||||
(h.ascender().to_i16() as f64 + gap / 2.0, (h.descender().to_i16() as f64).abs() + gap / 2.0)
|
||||
});
|
||||
// hhea ascender + descender + line gap (Word on macOS), the gap above the ascent; OS/2
|
||||
// win metrics only when the hhea values are missing, and never more than twice the em.
|
||||
let hhea = f.hhea().ok().map(|h| hhea_line(h.ascender().to_i16(), h.descender().to_i16(), h.line_gap().to_i16()));
|
||||
let win = f.os2().ok().map(|o| (o.us_win_ascent() as f64, o.us_win_descent() as f64));
|
||||
let ok = |m: &(f64, f64)| m.0 > 0.0 && m.0 + m.1 <= face.upem * 2.0;
|
||||
if let Some(m) = hhea.filter(ok) {
|
||||
@@ -256,6 +271,17 @@ pub fn line_metrics(face: &crate::FontFace) -> (f64, f64) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_line_gap_goes_above_the_ascent() {
|
||||
// Times New Roman (hhea 1825 / -443, gap 87), and a font with a large gap: Word puts the
|
||||
// first baseline the ascent plus the whole gap below the line top.
|
||||
assert_eq!(hhea_line(1825, -443, 87), (1912.0, 443.0));
|
||||
assert_eq!(hhea_line(1536, -512, 452), (1988.0, 512.0));
|
||||
// A negative gap adds nothing; the most negative descender doesn't overflow.
|
||||
assert_eq!(hhea_line(1000, -300, -50), (1000.0, 300.0));
|
||||
assert_eq!(hhea_line(1000, i16::MIN, 0), (1000.0, 32768.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_family_substitutes() {
|
||||
let r = resolve("Definitely Not A Font 123", false, false);
|
||||
|
||||
@@ -276,8 +276,11 @@ named! {
|
||||
named! {
|
||||
/// What the markup area beside the page shows.
|
||||
BalloonMode {
|
||||
/// Deletions, formatting and comments in balloons (drawn like `CommentsAndFormatting`
|
||||
/// for now: revisions stay inline).
|
||||
/// Deletions, formatting and comments in balloons; insertions stay inline. Where the
|
||||
/// editor shows balloons, deleted body text leaves the layout (`deletions_in_balloons`)
|
||||
/// and the editor draws a "Deleted: …" balloon at its place; elsewhere (no markup area,
|
||||
/// PDF, and headers, footers, notes and text boxes, which get no balloons) it is drawn
|
||||
/// inline as in `CommentsAndFormatting`.
|
||||
Revisions = "revisions",
|
||||
/// Everything inline, no markup area.
|
||||
Inline = "inline",
|
||||
|
||||
@@ -60,6 +60,10 @@ pub struct LayoutOptions {
|
||||
pub show_hidden: bool,
|
||||
/// Final text ("No Markup"): tracked deletions take no space and draw nothing.
|
||||
pub hide_deleted: bool,
|
||||
/// Show revisions in balloons: tracked deletions in the body take no space and draw nothing
|
||||
/// (the editor shows them in balloons beside the page). Headers, footers, notes and text
|
||||
/// boxes get no balloons, so their deletions stay inline.
|
||||
pub deletions_in_balloons: bool,
|
||||
/// Check spelling as you type (squiggles).
|
||||
pub proofing: bool,
|
||||
/// Print Markup (PDF, print, page images): widen each page by [`balloons::MARKUP_AREA`]
|
||||
@@ -89,6 +93,13 @@ impl Default for ProofOptions {
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutOptions {
|
||||
/// Whether tracked deletions in `story` take no space and draw nothing.
|
||||
pub fn hides_deleted(&self, story: StoryRef) -> bool {
|
||||
self.hide_deleted || (self.deletions_in_balloons && story == StoryRef::Body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Something placed on a page (page coordinates, points, y down).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Placed {
|
||||
@@ -350,6 +361,7 @@ struct Key {
|
||||
excl: u64,
|
||||
eq: u32,
|
||||
grid: (u32, u32, u32, bool),
|
||||
hide_deleted: bool,
|
||||
}
|
||||
|
||||
/// Memoised paragraph layouts.
|
||||
@@ -395,6 +407,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
|
||||
s,
|
||||
opts.show_hidden,
|
||||
opts.hide_deleted,
|
||||
opts.deletions_in_balloons,
|
||||
opts.proofing,
|
||||
opts.field_codes,
|
||||
opts.proof,
|
||||
@@ -485,6 +498,9 @@ struct Ctx<'a> {
|
||||
chains: textbox::Chains,
|
||||
/// The current section's document grid (body text only: none for headers and footers).
|
||||
grid: para::Grid,
|
||||
/// Whether tracked deletions are left out in the story being laid out
|
||||
/// ([`LayoutOptions::hides_deleted`]): the body's, or the story of the [`layout_box`] at work.
|
||||
hide_deleted: bool,
|
||||
/// Whether body text (or its notes) laid out so far shows a page total (`NUMPAGES`,
|
||||
/// `SECTIONPAGES`), which is only known once the whole body is paginated.
|
||||
uses_totals: bool,
|
||||
@@ -584,6 +600,7 @@ impl Ctx<'_> {
|
||||
excl: if exclusions.is_empty() { 0 } else { hash_of(&format!("{exclusions:?}")) },
|
||||
eq: if eq_here > 0 { eq_number } else { 0 },
|
||||
grid: self.grid.key(),
|
||||
hide_deleted: self.hide_deleted,
|
||||
};
|
||||
self.cache.used.insert(key.clone());
|
||||
if let Some(pl) = self.cache.paras.get(&key) {
|
||||
@@ -598,7 +615,7 @@ impl Ctx<'_> {
|
||||
fields: &self.fields,
|
||||
show_hidden: self.opts.show_hidden,
|
||||
field_codes: self.opts.field_codes,
|
||||
hide_deleted: self.opts.hide_deleted,
|
||||
hide_deleted: self.hide_deleted,
|
||||
table,
|
||||
proofing: self.opts.proofing.then_some(self.opts.proof),
|
||||
exclusions,
|
||||
@@ -741,6 +758,9 @@ fn layout_box(
|
||||
depth: usize,
|
||||
frame: Option<PageFrame>,
|
||||
) -> BoxLayout {
|
||||
// Deletions in balloons leave the body's text only: a header, note or text box keeps its own
|
||||
// inline (restored for the story around this box when it's done).
|
||||
let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story));
|
||||
let mut items = Vec::new();
|
||||
// Pictures behind the text go first; `behind` counts them.
|
||||
let mut behind = 0;
|
||||
@@ -769,7 +789,7 @@ fn layout_box(
|
||||
// Floating objects anchored here: place them, then wrap the text around them.
|
||||
// One left out of the layout (hidden, or deleted in the final text) takes no room.
|
||||
let mut floats = HashMap::new();
|
||||
let left_out = left_out_objects(ctx.doc, p, table.map(|t| &t.chr), ctx.opts.show_hidden, ctx.opts.hide_deleted);
|
||||
let left_out = left_out_objects(ctx.doc, p, table.map(|t| &t.chr), ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
for (oi, o) in p.objects.iter().enumerate() {
|
||||
let Some((w, h, float)) = floating(o) else { continue };
|
||||
if left_out(oi) {
|
||||
@@ -835,6 +855,7 @@ fn layout_box(
|
||||
}
|
||||
}
|
||||
let floats_bottom = float_tables.iter().map(|r| r.bottom()).fold(0.0, f32::max);
|
||||
ctx.hide_deleted = outer_hide;
|
||||
BoxLayout { items, height: y.max(0.0), floats_bottom }
|
||||
}
|
||||
|
||||
@@ -1529,7 +1550,8 @@ fn layout_pass(
|
||||
cache.env = env;
|
||||
}
|
||||
cache.used.clear();
|
||||
let notes = note_info(doc, opts.hide_deleted, note_pages);
|
||||
// Note references are counted as the body shows them.
|
||||
let notes = note_info(doc, opts.hides_deleted(StoryRef::Body), note_pages);
|
||||
let notes_hash = hash_of(&{
|
||||
let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, b.n, b.fmt)).collect();
|
||||
v.sort_by_key(|(a, ..)| *a);
|
||||
@@ -1539,6 +1561,7 @@ fn layout_pass(
|
||||
doc,
|
||||
cache,
|
||||
opts,
|
||||
hide_deleted: opts.hides_deleted(StoryRef::Body),
|
||||
counters: Counters::default(),
|
||||
fields: FieldCtx {
|
||||
page: 1,
|
||||
@@ -2050,7 +2073,11 @@ fn body_top_for(ctx: &mut Ctx, sect: &SectionProps, header: Option<u32>) -> f32
|
||||
fn foot_top_for(ctx: &mut Ctx, sect: &SectionProps, footer: Option<u32>) -> f32 {
|
||||
let Some(part) = footer.and_then(|id| ctx.doc.parts.get(&id).map(|p| (id, p))) else { return f32::MAX };
|
||||
let (id, blocks) = (part.0, part.1.blocks.clone());
|
||||
// A page total in the footer is filled in after pagination, like the header's: it doesn't
|
||||
// make the body lay out again.
|
||||
let track = std::mem::replace(&mut ctx.track_totals, false);
|
||||
let h = layout_box(ctx, StoryRef::Part(id), &blocks, &[], sect.text_width(), None, 0, None).height;
|
||||
ctx.track_totals = track;
|
||||
sect.page_h - sect.footer - h
|
||||
}
|
||||
|
||||
@@ -2240,7 +2267,7 @@ fn anchor_floats(
|
||||
let frame = PageFrame { sect: pb.sect, origin: (0.0, 0.0) };
|
||||
// An object left out of the layout (hidden, or deleted in the final text) is not placed, so
|
||||
// it takes no room either.
|
||||
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.opts.hide_deleted);
|
||||
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
for (oi, o) in p.objects.iter().enumerate() {
|
||||
let Some((w, h, float)) = floating(o) else { continue };
|
||||
if left_out(oi) {
|
||||
@@ -2290,7 +2317,7 @@ fn place_objects(
|
||||
let (mut behind, mut front) = (Vec::new(), Vec::new());
|
||||
let Some(fl) = pl.lines.get(l0) else { return (behind, front) };
|
||||
// Objects left out of the layout (hidden, or deleted in the final text) aren't placed.
|
||||
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.opts.hide_deleted);
|
||||
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted);
|
||||
for li in l0..l1 {
|
||||
let Some(line) = pl.lines.get(li) else { continue };
|
||||
for k in line.c0..line.c1 {
|
||||
@@ -2685,7 +2712,7 @@ fn next_first_line(ctx: &mut Ctx, block: usize, width: f32) -> f32 {
|
||||
fields: &ctx.fields,
|
||||
show_hidden: ctx.opts.show_hidden,
|
||||
field_codes: ctx.opts.field_codes,
|
||||
hide_deleted: ctx.opts.hide_deleted,
|
||||
hide_deleted: ctx.opts.hides_deleted(StoryRef::Body),
|
||||
table: None,
|
||||
proofing: None,
|
||||
exclusions: &[],
|
||||
|
||||
@@ -1711,7 +1711,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
|
||||
let up = asc.max(obj_asc);
|
||||
top + ((height - up - desc) / 2.0).max(0.0) + up
|
||||
} else {
|
||||
top + height - desc
|
||||
top + baseline_offset(rp.line_spacing, asc, height, desc).max(obj_asc.min(height - desc))
|
||||
};
|
||||
|
||||
// Alignment / justification (trailing spaces hang).
|
||||
@@ -1840,7 +1840,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
|
||||
lines.push(Line {
|
||||
top,
|
||||
height: h,
|
||||
baseline: top + h - desc,
|
||||
baseline: top + if grid.is_some() { h - desc } else { baseline_offset(rp.line_spacing, asc, h, desc) },
|
||||
c0: n,
|
||||
c1: n,
|
||||
xs: vec![x0],
|
||||
@@ -1958,6 +1958,18 @@ fn reorder_line(pl: &ParaLayout, line: &mut Line, width: f32) {
|
||||
/// paragraph beside a floating table in a 21.3pt gap but not in an 18.6pt one.
|
||||
const MIN_SPAN: f32 = 20.0;
|
||||
|
||||
/// How far below a line's top its baseline sits. Multiple spacing at or above single keeps the
|
||||
/// text at the top of the line and puts the extra leading below the baseline; below single it
|
||||
/// shrinks the whole box, ascent included (Word's output for docxide-pdf's fixtures: 1.15-spaced
|
||||
/// Times New Roman 12pt sits one ascent below the line top, not 1.8pt lower). Exact and at-least
|
||||
/// spacing keep the descent below the baseline and the rest above it.
|
||||
fn baseline_offset(spacing: LineSpacing, asc: f32, height: f32, desc: f32) -> f32 {
|
||||
match spacing {
|
||||
LineSpacing::Multiple(m) => asc * m.min(1.0),
|
||||
LineSpacing::AtLeast(_) | LineSpacing::Exactly(_) => height - desc,
|
||||
}
|
||||
}
|
||||
|
||||
/// The spans of a row at `top` (about `h` tall) between `lo` and `hi` that text can use around
|
||||
/// the exclusions, left to right (Square wrapping uses both sides of an object). `Err(y)`: none
|
||||
/// here, try again at `y` (below what's in the way).
|
||||
|
||||
@@ -2010,6 +2010,40 @@ fn line_spacing_does_not_scale_pictures() {
|
||||
assert!((line.baseline - line.top - 144.0).abs() < 0.01, "the picture stands on the baseline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_line_spacing_adds_its_leading_below_the_text() {
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
// (spacing, expected baseline below the line top as a share of the ascent)
|
||||
for (spacing, share) in [(LineSpacing::Multiple(1.15), 1.0), (LineSpacing::Multiple(2.0), 1.0), (LineSpacing::Multiple(0.8), 0.8)] {
|
||||
let mut d = Document::from_text(&"Several words wrap onto more lines. ".repeat(30));
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.props.line_spacing = Some(spacing);
|
||||
}
|
||||
let l = lay(&d);
|
||||
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
|
||||
let asc = para.styles[0].ascent;
|
||||
assert!(para.lines.len() > 1);
|
||||
for line in ¶.lines {
|
||||
// Word's 1.15-spaced Times New Roman 12pt sits one ascent below the line top; the
|
||||
// 0.15 extra goes below the descent (docxide-pdf's hyphenation fixtures).
|
||||
assert!(
|
||||
(line.baseline - line.top - asc * share).abs() < 0.01,
|
||||
"{spacing:?}: baseline {} below the top, ascent {asc}",
|
||||
line.baseline - line.top
|
||||
);
|
||||
}
|
||||
}
|
||||
// Exact spacing still keeps the descent below the baseline.
|
||||
let mut d = Document::from_text("Exact");
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
|
||||
p.props.line_spacing = Some(LineSpacing::Exactly(30.0));
|
||||
}
|
||||
let l = lay(&d);
|
||||
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
|
||||
let line = ¶.lines[0];
|
||||
assert!((line.top + line.height - line.baseline - para.styles[0].descent).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_starts_right_below_a_tall_header() {
|
||||
let mut d = Document::from_text("Body");
|
||||
@@ -3889,3 +3923,28 @@ fn east_asian_text_takes_the_asian_font_and_latin_text_the_latin_font() {
|
||||
assert_eq!(face, want, "{ch:?} in {}", if ch.is_ascii() { latin } else { &asian });
|
||||
}
|
||||
}
|
||||
|
||||
/// A page total in the footer ("Page X of Y") is filled in after pagination, so it doesn't make
|
||||
/// the body lay out a second time; one in the body still does.
|
||||
#[test]
|
||||
fn footer_page_total_needs_one_body_pass() {
|
||||
let field = || InlineObject::Field { instr: "NUMPAGES".into(), result: "9".into(), locked: false, code: false };
|
||||
let mut d = Document::from_text("Body-1\nBody-2\nBody-3");
|
||||
for i in 1..3 {
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(true);
|
||||
}
|
||||
let mut fp = wordcraft_doc::Paragraph::new();
|
||||
fp.insert_object(0, field(), &Default::default()).unwrap();
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Footer, vec![wordcraft_doc::para_block(fp)]);
|
||||
d.last_section.footers.default = Some(id);
|
||||
let first = |d: &Document| {
|
||||
let totals = Totals::first_guess(d.sections().len());
|
||||
layout_pass(d, &mut LayoutCache::new(), &LayoutOptions::default(), &HashMap::new(), &totals, true)
|
||||
};
|
||||
assert!(matches!(first(&d), Pass::Done(l) if l.pages.len() == 3), "the footer's total asked for another pass");
|
||||
// The same field in the body is only known once every page is laid out.
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
let n = p.len();
|
||||
p.insert_object(n, field(), &Default::default()).unwrap();
|
||||
assert!(matches!(first(&d), Pass::Again(t) if t.pages == 3));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK};
|
||||
|
||||
pub(crate) const MM_TEXT: u32 = 1;
|
||||
const MM_ISOTROPIC: u32 = 7;
|
||||
const MM_ANISOTROPIC: u32 = 8;
|
||||
const STOCK: u32 = 0x8000_0000;
|
||||
pub(crate) const PS_NULL: u32 = 5;
|
||||
/// Pen style bits; the rest of the style word holds end-cap, join and type flags.
|
||||
@@ -15,6 +16,8 @@ pub(crate) const PS_STYLE_MASK: u32 = 0x0F;
|
||||
const BS_SOLID: u32 = 0;
|
||||
pub(crate) const BS_NULL: u32 = 1;
|
||||
const WINDING: u32 = 2;
|
||||
/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing.
|
||||
const R2_NOP: u32 = 11;
|
||||
const PATCOPY: u32 = 0x00F0_0021;
|
||||
const BLACKNESS: u32 = 0x0000_0042;
|
||||
const WHITENESS: u32 = 0x00FF_0062;
|
||||
@@ -135,6 +138,19 @@ impl Mapping {
|
||||
det * (sx.abs() + sy.abs()) / 2.0
|
||||
}
|
||||
|
||||
/// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes).
|
||||
pub fn uses_extents(&self) -> bool {
|
||||
matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC)
|
||||
}
|
||||
|
||||
pub fn window_ext(&self) -> P {
|
||||
self.we
|
||||
}
|
||||
|
||||
pub fn viewport_ext(&self) -> P {
|
||||
self.ve
|
||||
}
|
||||
|
||||
/// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame
|
||||
/// of a plain WMF.
|
||||
pub fn window_rect(&self) -> [f64; 4] {
|
||||
@@ -160,6 +176,7 @@ struct Saved {
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
nop: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Canvas {
|
||||
@@ -167,6 +184,8 @@ pub(crate) struct Canvas {
|
||||
pub map: Mapping,
|
||||
/// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use.
|
||||
pub window_set: bool,
|
||||
/// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]).
|
||||
pub viewport_set: bool,
|
||||
saved: Vec<Saved>,
|
||||
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
|
||||
used: usize,
|
||||
@@ -174,6 +193,8 @@ pub(crate) struct Canvas {
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
even_odd: bool,
|
||||
/// The mix mode is R2_NOP: shapes draw nothing.
|
||||
nop: bool,
|
||||
pos: P,
|
||||
in_path: bool,
|
||||
path: Vec<Op>,
|
||||
@@ -186,12 +207,14 @@ impl Canvas {
|
||||
items: Vec::new(),
|
||||
map: Mapping::identity(),
|
||||
window_set: false,
|
||||
viewport_set: false,
|
||||
saved: Vec::new(),
|
||||
used: 0,
|
||||
objects: Vec::new(),
|
||||
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
|
||||
brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] },
|
||||
even_odd: true,
|
||||
nop: false,
|
||||
pos: (0.0, 0.0),
|
||||
in_path: false,
|
||||
path: Vec::new(),
|
||||
@@ -232,6 +255,22 @@ impl Canvas {
|
||||
|
||||
pub fn set_viewport_ext(&mut self, e: P) {
|
||||
self.map.ve = e;
|
||||
self.viewport_set = true;
|
||||
}
|
||||
|
||||
/// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic,
|
||||
/// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's
|
||||
/// own window records then replace the window; the viewport stays unless the file sets one.
|
||||
/// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport
|
||||
/// origin stay the file's.
|
||||
pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) {
|
||||
if let Some([x, y, w, h]) = window {
|
||||
self.map.mode = MM_ANISOTROPIC;
|
||||
self.map.wo = (x, y);
|
||||
self.map.we = (w, h);
|
||||
self.map.vo = (0.0, 0.0);
|
||||
}
|
||||
self.map.ve = size;
|
||||
}
|
||||
|
||||
pub fn set_map_mode(&mut self, mode: u32) {
|
||||
@@ -249,6 +288,11 @@ impl Canvas {
|
||||
self.even_odd = mode != WINDING;
|
||||
}
|
||||
|
||||
/// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy.
|
||||
pub fn set_rop2(&mut self, mode: u32) {
|
||||
self.nop = mode == R2_NOP;
|
||||
}
|
||||
|
||||
/// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set).
|
||||
/// An unknown mode leaves the world transform as it was.
|
||||
pub fn modify_world(&mut self, x: Xform, mode: u32) {
|
||||
@@ -265,7 +309,7 @@ impl Canvas {
|
||||
/// could no longer match the file.
|
||||
pub fn save_dc(&mut self) {
|
||||
if self.saved.len() < MAX_STACK {
|
||||
self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd });
|
||||
self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop });
|
||||
} else {
|
||||
self.full = true;
|
||||
}
|
||||
@@ -281,6 +325,7 @@ impl Canvas {
|
||||
self.pen = s.pen;
|
||||
self.brush = s.brush;
|
||||
self.even_odd = s.even_odd;
|
||||
self.nop = s.nop;
|
||||
}
|
||||
|
||||
/// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table).
|
||||
@@ -381,6 +426,9 @@ impl Canvas {
|
||||
if self.in_path {
|
||||
return self.push_path_ops(dev);
|
||||
}
|
||||
if self.nop {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_color() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
if (fill.is_none() && stroke.is_none()) || dev.len() < 2 {
|
||||
@@ -460,6 +508,9 @@ impl Canvas {
|
||||
/// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it.
|
||||
pub fn paint_path(&mut self, fill: bool, stroke: bool) {
|
||||
let ops = std::mem::take(&mut self.path);
|
||||
if self.nop {
|
||||
return;
|
||||
}
|
||||
let fill = if fill { self.fill_color() } else { None };
|
||||
let stroke = if stroke { self.stroke_pen() } else { None };
|
||||
if ops.len() < 2 || (fill.is_none() && stroke.is_none()) {
|
||||
|
||||
@@ -88,6 +88,7 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
|
||||
12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)),
|
||||
17 => r.u32(8).map(|m| cv.set_map_mode(m)),
|
||||
19 => r.u32(8).map(|m| cv.set_polyfill(m)),
|
||||
20 => r.u32(8).map(|m| cv.set_rop2(m)),
|
||||
33 => {
|
||||
cv.save_dc();
|
||||
Some(())
|
||||
|
||||
@@ -927,3 +927,92 @@ fn take_pixels_returns_only_drawable_bitmaps() {
|
||||
assert!(pic(1.0, 2.0).has_size());
|
||||
assert!(!pic(0.0, 2.0).has_size() && !pic(f32::NAN, 2.0).has_size() && !pic(1.0, f32::INFINITY).has_size());
|
||||
}
|
||||
|
||||
/// The rectangle a WMF RECTANGLE record covers, from its path's corners.
|
||||
fn rect_extent(item: &Item) -> (f32, f32, f32, f32) {
|
||||
let (segs, ..) = path_segs(item);
|
||||
let pts: Vec<(f32, f32)> = segs
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Seg::Move(x, y) | Seg::Line(x, y) => Some((*x, *y)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let (xs, ys): (Vec<f32>, Vec<f32>) = pts.into_iter().unzip();
|
||||
let lo = |v: &[f32]| v.iter().copied().fold(f32::MAX, f32::min);
|
||||
let hi = |v: &[f32]| v.iter().copied().fold(f32::MIN, f32::max);
|
||||
(lo(&xs), lo(&ys), hi(&xs), hi(&ys))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_anisotropic_wmf_without_a_viewport_fills_its_window() {
|
||||
// Anisotropic, window 200 x 100, no viewport (a logo strip saved without a placeable header): the
|
||||
// window is the target, not a 1 x 1 viewport that squeezes the picture to a point.
|
||||
let recs = [
|
||||
wrec(0x0103, &h16(8)),
|
||||
wrec(0x020B, &cat(vec![h16(0), h16(0)])),
|
||||
wrec(0x020C, &cat(vec![h16(100), h16(200)])),
|
||||
wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])),
|
||||
];
|
||||
let p = parse(&wmf(&recs, None)).unwrap();
|
||||
assert_eq!((p.width, p.height), (200.0, 100.0));
|
||||
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
|
||||
assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeable_wmf_window_records_replace_the_bounds() {
|
||||
// The placeable bounds say 1440 square (72 points); the file then draws in a window centred on the
|
||||
// origin, as a coat of arms saved by a drawing program does. The window maps onto the 72-point
|
||||
// target, so the drawing fills it instead of being blown up around the bounds.
|
||||
let recs = [
|
||||
wrec(0x020C, &cat(vec![h16(2000), h16(2000)])),
|
||||
wrec(0x020B, &cat(vec![h16(-1000), h16(-1000)])),
|
||||
wrec(0x041B, &cat(vec![h16(1000), h16(1000), h16(-1000), h16(-1000)])),
|
||||
];
|
||||
let p = parse(&wmf(&recs, Some([238, 539, 1678, 1979]))).unwrap();
|
||||
assert_eq!((p.width, p.height), (72.0, 72.0));
|
||||
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
|
||||
assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 72.0) && close(y1, 72.0), "{x0} {y0} {x1} {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeable_wmf_keeps_its_target_when_the_window_changes_shape() {
|
||||
// Bounds 600 x 300 (30 x 15 points), then a portrait window 100 x 200 for a bitmap-sized drawing:
|
||||
// the drawing still fills the target (Word stretches it to the picture's box).
|
||||
let recs = [wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(200), h16(100)])), wrec(0x041B, &cat(vec![h16(200), h16(100), h16(0), h16(0)]))];
|
||||
let p = parse(&wmf(&recs, Some([0, 0, 600, 300]))).unwrap();
|
||||
assert_eq!((p.width, p.height), (30.0, 15.0));
|
||||
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
|
||||
assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 30.0) && close(y1, 15.0), "{x0} {y0} {x1} {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rop2_nop_draws_nothing_until_the_mode_changes() {
|
||||
// A coat of arms that first draws its bounding rectangle with the R2_NOP mix mode (no output),
|
||||
// then its shapes with R2_COPYPEN. Restoring a saved state brings R2_NOP back.
|
||||
let rect = |n: i32| wrec(0x041B, &cat(vec![h16(n), h16(n), h16(0), h16(0)]));
|
||||
let recs = [wrec(0x0104, &h16(11)), rect(100), wrec(0x001E, &[]), wrec(0x0104, &h16(13)), rect(50), wrec(0x0127, &h16(-1)), rect(20)];
|
||||
let p = parse(&wmf(&recs, Some([0, 0, 100, 100]))).unwrap();
|
||||
assert_eq!(p.items.len(), 1, "{:?}", p.items);
|
||||
let (_, _, x1, y1) = rect_extent(&p.items[0]);
|
||||
// 50 of 100 units in a 5-point picture.
|
||||
assert!(close(x1, 2.5) && close(y1, 2.5), "{x1} {y1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_wmf_target_comes_from_a_window_that_was_read() {
|
||||
// A window record too short to read, then the real one: the target is the real window, not the
|
||||
// 1 x 1 default the short record left in place. A viewport origin set before it is kept.
|
||||
let recs = [
|
||||
wrec(0x0103, &h16(8)),
|
||||
wrec(0x020D, &cat(vec![h16(0), h16(0)])),
|
||||
wrec(0x020C, &[]),
|
||||
wrec(0x020C, &cat(vec![h16(100), h16(200)])),
|
||||
wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])),
|
||||
];
|
||||
let p = parse(&wmf(&recs, None)).unwrap();
|
||||
assert_eq!((p.width, p.height), (200.0, 100.0));
|
||||
let (x0, y0, x1, y1) = rect_extent(&p.items[0]);
|
||||
assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}");
|
||||
}
|
||||
|
||||
@@ -37,7 +37,13 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
|
||||
return Err(Error::BadHeader);
|
||||
}
|
||||
let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?;
|
||||
// Played into a target like Word does: the placeable bounds are the first window, the target (the
|
||||
// picture size) the viewport. A plain WMF's target is its first window.
|
||||
let mut cv = Canvas::new();
|
||||
if let Some((c, size)) = bounds {
|
||||
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
|
||||
}
|
||||
let mut player_viewport = bounds.is_some();
|
||||
let mut parsed = 0usize;
|
||||
while !cv.full() {
|
||||
let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break };
|
||||
@@ -47,6 +53,11 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
|
||||
break;
|
||||
}
|
||||
record(&mut cv, func, Bytes(rec));
|
||||
if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set {
|
||||
let we = cv.map.window_ext();
|
||||
cv.play_into(None, (we.0.abs(), we.1.abs()));
|
||||
player_viewport = true;
|
||||
}
|
||||
parsed += 1;
|
||||
let Some(next) = pos.checked_add(len) else { break };
|
||||
pos = next;
|
||||
@@ -55,12 +66,19 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
|
||||
return Err(Error::NoRecords);
|
||||
}
|
||||
let frame = match bounds {
|
||||
// The drawing is in device units, so the logical bounds go through the final mapping.
|
||||
// Played into its target: the frame is the target.
|
||||
Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }),
|
||||
// The file set its own viewport, so the drawing is in its device units and the logical bounds
|
||||
// go through the final mapping.
|
||||
Some((c, size)) => {
|
||||
let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3])));
|
||||
let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
|
||||
(rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) })
|
||||
}
|
||||
None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => {
|
||||
let ve = cv.map.viewport_ext();
|
||||
Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None })
|
||||
}
|
||||
None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }),
|
||||
None => None,
|
||||
};
|
||||
@@ -74,6 +92,7 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) {
|
||||
let _ = match func {
|
||||
0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))),
|
||||
0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))),
|
||||
0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))),
|
||||
// Y is stored before X in these records.
|
||||
0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)),
|
||||
0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)),
|
||||
|
||||
@@ -51,7 +51,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
);
|
||||
resp
|
||||
};
|
||||
if back.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
// Escape in the built-in file browser closes only the browser.
|
||||
if back.clicked() || (!app.file_dialog_open() && ui.input(|i| i.key_pressed(egui::Key::Escape))) {
|
||||
app.ui.backstage = false;
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
@@ -262,6 +263,16 @@ fn open_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() {
|
||||
let _ = app.run("ui.openFileDialog", json!({}));
|
||||
}
|
||||
// Linux and the BSDs (#576): the system dialog needs a file chooser portal or zenity; when it
|
||||
// doesn't show up, WordCraft's own browser is always one click away.
|
||||
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
|
||||
if ui
|
||||
.button(egui::RichText::new(tl!("Browse in WordCraft…")).font(medium(14.0)))
|
||||
.on_hover_text(tl!("Open a file with WordCraft's own browser when the system file dialog doesn't appear."))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("ui.fileBrowser", json!({}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
open_list(app, ui);
|
||||
}
|
||||
|
||||
+399
-99
@@ -18,6 +18,15 @@ pub const PX_PER_PT: f32 = 96.0 / 72.0;
|
||||
const GAP: f32 = 18.0;
|
||||
const RULER: f32 = 22.0;
|
||||
|
||||
/// Drag-select auto-scroll (#688): the pointer within this many points of the viewport's edge
|
||||
/// starts scrolling, like Word.
|
||||
const EDGE_MARGIN: f32 = 32.0;
|
||||
/// Auto-scroll speed (points/s) at the margin's inner boundary and at (or past) the edge.
|
||||
const EDGE_SPEED_MIN: f32 = 45.0;
|
||||
const EDGE_SPEED_MAX: f32 = 720.0;
|
||||
/// Longest frame time trusted for auto-scroll (a stall shouldn't jump the page).
|
||||
const EDGE_MAX_DT: f32 = 0.1;
|
||||
|
||||
pub struct CanvasState {
|
||||
textures: HashMap<usize, (u64, TextureHandle)>,
|
||||
pub scroll_to_caret: bool,
|
||||
@@ -65,6 +74,9 @@ pub struct CanvasState {
|
||||
/// The scroll offset and its maximum at the end of last frame.
|
||||
pub(crate) scroll_offset: egui::Vec2,
|
||||
scroll_max: egui::Vec2,
|
||||
/// The viewport (the content's visible rect on screen) at the end of last frame; the
|
||||
/// drag-select edge auto-scroll decides this frame's scroll from it (#688).
|
||||
pub(crate) viewport: Option<egui::Rect>,
|
||||
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
|
||||
pub cols: usize,
|
||||
/// The comment whose balloon is selected (its text is editable in place, like Word's).
|
||||
@@ -134,6 +146,7 @@ impl Default for CanvasState {
|
||||
wheel: Default::default(),
|
||||
scroll_offset: egui::Vec2::ZERO,
|
||||
scroll_max: egui::Vec2::ZERO,
|
||||
viewport: None,
|
||||
cols: 1,
|
||||
balloon: None,
|
||||
balloon_focus: false,
|
||||
@@ -239,19 +252,17 @@ fn on_page_gap(rects: &[Rect], bands: &[(f32, f32)], p: Pos2) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Width of the markup area beside each page for comment balloons (points), or 0.
|
||||
/// Width of the markup area beside each page for comment and revision balloons (points), or 0.
|
||||
pub fn markup_width(app: &WordApp) -> f32 {
|
||||
let v = &app.session.view;
|
||||
// Word shows comments either in balloons (contextual) or in the Comments pane (list).
|
||||
let on = v.show_markup && !v.comments_pane && !v.read_mode && !v.multi_page && v.mode == wordcraft_layout::ViewMode::Print;
|
||||
// Track Changes Options: comments and formatting hidden, or every revision inline, leave
|
||||
// no markup area.
|
||||
let m = &app.session.prefs.markup;
|
||||
let on = on && m.balloons != wordcraft_layout::display::BalloonMode::Inline;
|
||||
let comments = m.comments && !app.session.doc.comments.is_empty();
|
||||
let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&app.session.doc);
|
||||
let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&app.session.doc);
|
||||
if on && (comments || formats || moves) { 216.0 } else { 0.0 }
|
||||
// Word shows comments either in balloons (contextual) or in the Comments pane (list); Track
|
||||
// Changes Options can put every revision inline (`Session::balloons_shown`).
|
||||
let s = &app.session;
|
||||
let m = &s.prefs.markup;
|
||||
let comments = m.comments && !s.doc.comments.is_empty();
|
||||
let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&s.doc);
|
||||
let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&s.doc);
|
||||
let deletions = s.revisions_in_balloons() && wordcraft_engine::cmd::review::has_deletions(&s.doc);
|
||||
if s.balloons_shown() && (comments || formats || moves || deletions) { 216.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A page dimension (points) safe to lay out: finite, at least 1 pt, at most `cap`.
|
||||
@@ -468,12 +479,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.canvas_rect = Some(area);
|
||||
// Ctrl/⌘ + wheel (and trackpad pinch) zooms the document, like Word. egui reports both as a
|
||||
// zoom factor and doesn't scroll for them. Start from the shown scale so fit modes zoom too.
|
||||
let zoom_delta = ui.input(|i| i.zoom_delta());
|
||||
// With animation effects off the wheel zooms in whole steps, each at once.
|
||||
let animate = app.animations_on();
|
||||
let opts = ui.ctx().options(|o| o.input_options);
|
||||
let zoom_delta = ui.input(|i| app.canvas.wheel.zoom(i, &opts, animate));
|
||||
if (zoom_delta - 1.0).abs() > 1e-3 && ui.rect_contains_pointer(area) {
|
||||
let pct = (app.canvas.scale / PX_PER_PT * zoom_delta * 100.0).clamp(10.0, 500.0);
|
||||
let _ = app.run("view.zoom", json!({"value": pct}));
|
||||
}
|
||||
let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0));
|
||||
// The status bar's zoom readout and slider were laid out before this, from the last frame's
|
||||
// scale: after a zoom change draw again at once, so they follow in the next frame rather than
|
||||
// whenever something else next repaints (the caret's blink).
|
||||
if (geo.scale - app.canvas.scale).abs() > 1e-6 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
app.canvas.scale = geo.scale;
|
||||
sync_fit_zoom(app, geo.scale);
|
||||
// Zooming or resizing reflowed the pages into a different number of columns: the caret's page
|
||||
@@ -503,21 +523,40 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let mut origin = area.min;
|
||||
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
// The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with
|
||||
// momentum, wheel notches eased in. Applied before drawing, so it shows this frame.
|
||||
// momentum, wheel notches eased in (or all at once with animation effects off). Applied
|
||||
// before drawing, so it shows this frame.
|
||||
let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none();
|
||||
let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT);
|
||||
let opts = ui.ctx().options(|o| o.input_options);
|
||||
let delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height()));
|
||||
let wheel_delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height(), animate));
|
||||
// Dragging a selection near the viewport's edge scrolls on (#688): from a crawl at the margin
|
||||
// to full speed at the edge, through the same offset the wheel writes.
|
||||
let (down, pointer, dt) = ui.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.stable_dt));
|
||||
let auto_delta = if app.canvas.drag_selecting()
|
||||
&& down
|
||||
&& let Some(vp) = app.canvas.viewport
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
vec2(edge_scroll(p.x, vp.min.x, vp.max.x, dt), edge_scroll(p.y, vp.min.y, vp.max.y, dt))
|
||||
} else {
|
||||
egui::Vec2::ZERO
|
||||
};
|
||||
let delta = wheel_delta + auto_delta;
|
||||
let mut scroll_area = egui::ScrollArea::both()
|
||||
.id_salt("canvas_scroll")
|
||||
.auto_shrink([false, false])
|
||||
// Bringing the caret into view glides there, or jumps with animation effects off.
|
||||
.animated(animate)
|
||||
.scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() });
|
||||
if delta != egui::Vec2::ZERO {
|
||||
let before = app.canvas.scroll_offset;
|
||||
let after = (before - delta).clamp(egui::Vec2::ZERO, app.canvas.scroll_max);
|
||||
if after == before {
|
||||
let after = clamped_scroll(before, delta, app.canvas.scroll_max);
|
||||
if after == before && wheel_delta != egui::Vec2::ZERO {
|
||||
app.canvas.wheel.hit_edge();
|
||||
}
|
||||
if auto_delta != egui::Vec2::ZERO && after != before {
|
||||
// The scroll keeps going without new input.
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
scroll_area = scroll_area.scroll_offset(after);
|
||||
}
|
||||
if app.canvas.wheel.is_animating() {
|
||||
@@ -543,29 +582,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height()));
|
||||
let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05);
|
||||
let key = page_key(app, page, scale_px, dim_body);
|
||||
let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
let current_scale = geo.scale * ppp;
|
||||
let visual_sr = if fresh && (scale_px - current_scale).abs() <= 0.0001_f32.max(current_scale.abs() * 0.0001) {
|
||||
app.canvas.textures.get(&i).map_or(sr, |(_, tex)| texel_aligned_rect(sr, tex.size_vec2(), ppp))
|
||||
} else {
|
||||
sr
|
||||
};
|
||||
rects.push(visual_sr);
|
||||
let mut fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
// Hide White Space shows only a band of the page; everything below paints into it.
|
||||
let band =
|
||||
Rect::from_min_max(pos2(visual_sr.min.x - 4.0, visual_sr.min.y + band_top), pos2(visual_sr.max.x + 4.0, visual_sr.min.y + band_bot));
|
||||
if !band.intersects(ui.clip_rect().expand(200.0)) {
|
||||
continue;
|
||||
}
|
||||
let painter = if band_top <= 0.0 && band_bot >= sr.height() {
|
||||
painter.clone()
|
||||
} else {
|
||||
painter.with_clip_rect(painter.clip_rect().intersect(band))
|
||||
};
|
||||
// Shadow and paper.
|
||||
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
|
||||
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
|
||||
if !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
||||
let band_of = |r: Rect| Rect::from_min_max(pos2(r.min.x - 4.0, r.min.y + band_top), pos2(r.max.x + 4.0, r.min.y + band_bot));
|
||||
let visible = band_of(sr).intersects(ui.clip_rect().expand(200.0));
|
||||
// Render before placing the page: a page re-rendered in this frame (a zoom step, an
|
||||
// edit) then sits on the pixel grid in this frame too, rather than a frame later.
|
||||
if visible && !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
||||
let mut opts = screen_render_options();
|
||||
opts.display.marks = app.session.view.marks;
|
||||
opts.display.always = app.session.prefs.marks;
|
||||
@@ -592,9 +615,29 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
rendered += 1;
|
||||
} else if !fresh {
|
||||
fresh = true;
|
||||
} else if visible && !fresh {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let current_scale = geo.scale * ppp;
|
||||
let visual_sr = if fresh && (scale_px - current_scale).abs() <= 0.0001_f32.max(current_scale.abs() * 0.0001) {
|
||||
app.canvas.textures.get(&i).map_or(sr, |(_, tex)| texel_aligned_rect(sr, tex.size_vec2(), ppp))
|
||||
} else {
|
||||
sr
|
||||
};
|
||||
rects.push(visual_sr);
|
||||
if !visible {
|
||||
continue;
|
||||
}
|
||||
let band = band_of(visual_sr);
|
||||
let painter = if band_top <= 0.0 && band_bot >= sr.height() {
|
||||
painter.clone()
|
||||
} else {
|
||||
painter.with_clip_rect(painter.clip_rect().intersect(band))
|
||||
};
|
||||
// Shadow and paper.
|
||||
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
|
||||
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
|
||||
if let Some((_, tex)) = app.canvas.textures.get(&i) {
|
||||
painter.image(tex.id(), visual_sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
@@ -739,8 +782,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let y0 = pr.min.y + (base - slot.y - slot.a) * s;
|
||||
let y1 = pr.min.y + (base - slot.y + slot.d) * s;
|
||||
let since = crate::now_ms() - app.canvas.caret_visible_since;
|
||||
if ((since / 530.0) as u64).is_multiple_of(2) && focused {
|
||||
painter.line_segment([pos2(x, y0), pos2(x, y1)], Stroke::new(1.5, t.caret));
|
||||
// The caret glides and fades like Word's (`caret_glide`).
|
||||
let on = ((since / 530.0) as u64).is_multiple_of(2);
|
||||
let look = crate::caret_glide::look(app, ui, content_origin, &geo, [pos2(x, y0), pos2(x, y1)], focused, since, on);
|
||||
if look.opacity > 0.0 && focused {
|
||||
painter.line_segment(look.bar, Stroke::new(1.5, t.caret.gamma_multiply(look.opacity)));
|
||||
}
|
||||
if focused {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530)));
|
||||
@@ -766,14 +812,17 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let y1 = y0 + c.height * scale;
|
||||
let since = crate::now_ms() - app.canvas.caret_visible_since;
|
||||
let on = ((since / 530.0) as u64).is_multiple_of(2);
|
||||
if app.session.sel.is_collapsed() && on && focused {
|
||||
let bar = if c.width > 0.0 {
|
||||
// Turned text (a table cell's text direction): the caret lies across the page.
|
||||
[pos2(x, y0.round() + 0.5), pos2(x + c.width * scale, y0.round() + 0.5)]
|
||||
} else {
|
||||
[pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)]
|
||||
};
|
||||
painter.line_segment(bar, Stroke::new(1.5, caret_color));
|
||||
let bar = if c.width > 0.0 {
|
||||
// Turned text (a table cell's text direction): the caret lies across the page.
|
||||
[pos2(x, y0.round() + 0.5), pos2(x + c.width * scale, y0.round() + 0.5)]
|
||||
} else {
|
||||
[pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)]
|
||||
};
|
||||
// The caret glides and fades like Word's (`caret_glide`).
|
||||
let shown = app.session.sel.is_collapsed() && focused;
|
||||
let look = crate::caret_glide::look(app, ui, content_origin, &geo, bar, shown, since, on);
|
||||
if shown && look.opacity > 0.0 {
|
||||
painter.line_segment(look.bar, Stroke::new(1.5, caret_color.gamma_multiply(look.opacity)));
|
||||
}
|
||||
if focused {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530)));
|
||||
@@ -812,6 +861,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
app.canvas.scroll_offset = out.state.offset;
|
||||
crate::ink::replay_bar(app, ui, out.inner_rect);
|
||||
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
|
||||
// The viewport barely moves between frames; the auto-scroll decides this frame's scroll before
|
||||
// the scroll area is built, so it reads last frame's viewport.
|
||||
app.canvas.viewport = Some(out.inner_rect);
|
||||
let (resp, rects) = out.inner;
|
||||
// A click on the page leaves the selected comment balloon.
|
||||
if app.canvas.balloon.is_some() && ui.input(|i| i.pointer.any_pressed()) && resp.contains_pointer() {
|
||||
@@ -928,8 +980,9 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
||||
replies.entry(p).or_default().push(*id);
|
||||
}
|
||||
}
|
||||
// (page, anchor x, anchor y, item) for each anchored, unresolved-or-not comment, and each
|
||||
// tracked formatting change (Word's default shows those in balloons too).
|
||||
// (page, anchor x, anchor y, item) for each anchored, unresolved-or-not comment, each
|
||||
// tracked formatting change (Word's default shows those in balloons too) and, when revisions
|
||||
// go in balloons, each tracked deletion.
|
||||
let mut by_page: HashMap<usize, Vec<(f32, f32, Balloon, Pos)>> = HashMap::new();
|
||||
let m = &app.session.prefs.markup;
|
||||
let comments = if m.comments { wordcraft_engine::cmd::review::comment_list(&app.session) } else { Vec::new() };
|
||||
@@ -955,6 +1008,13 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
||||
let text = if from { tl!("Moved from") } else { tl!("Moved to") }.to_string();
|
||||
by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(author, text), pos));
|
||||
}
|
||||
// Show revisions in balloons: each tracked deletion, taken out of the text, at its place.
|
||||
let deletions = if app.session.revisions_in_balloons() { wordcraft_engine::cmd::review::deletion_balloons(&app.session) } else { Vec::new() };
|
||||
for d in deletions {
|
||||
let Some(c) = layout.caret_on(&d.at, app.session.page_hint) else { continue };
|
||||
let text = format!("{}: {}", tl!("Deleted"), d.text);
|
||||
by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(d.author, text), d.at));
|
||||
}
|
||||
let palette = [t.blue, Color32::from_rgb(0xB0, 0x3A, 0x2E), Color32::from_rgb(0x2E, 0x7D, 0x32), Color32::from_rgb(0x8E, 0x44, 0xAD), t.orange];
|
||||
let mut authors: Vec<String> = Vec::new();
|
||||
let mut clicked: Option<(u32, Pos)> = None;
|
||||
@@ -1089,7 +1149,8 @@ fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Re
|
||||
/// What a balloon in the markup area shows.
|
||||
enum Balloon {
|
||||
Comment(u32),
|
||||
/// A tracked formatting change or move: its author and "Formatted: …" / "Moved from".
|
||||
/// A tracked formatting change, move or deletion: its author and "Formatted: …" /
|
||||
/// "Moved from" / "Deleted: …".
|
||||
Format(String, String),
|
||||
}
|
||||
|
||||
@@ -1227,6 +1288,32 @@ pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option<usize> {
|
||||
best.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// The drag auto-scroll along one axis for a pointer at `p` inside the viewport band `lo..hi`, in
|
||||
/// the wheel's convention (positive = content moves down the screen, toward the document's start):
|
||||
/// nothing while the pointer is [`EDGE_MARGIN`] or more inside, then ramping from
|
||||
/// [`EDGE_SPEED_MIN`] at the margin to [`EDGE_SPEED_MAX`] at the edge (and capping beyond it),
|
||||
/// applied over `dt` seconds. Junk numbers scroll nothing.
|
||||
fn edge_scroll(p: f32, lo: f32, hi: f32, dt: f32) -> f32 {
|
||||
if !(p.is_finite() && lo.is_finite() && hi.is_finite() && dt.is_finite()) || hi <= lo {
|
||||
return 0.0;
|
||||
}
|
||||
let dt = dt.clamp(0.0, EDGE_MAX_DT);
|
||||
let speed = |into: f32| egui::emath::lerp(EDGE_SPEED_MIN..=EDGE_SPEED_MAX, (into / EDGE_MARGIN).clamp(0.0, 1.0));
|
||||
if p < lo + EDGE_MARGIN {
|
||||
speed(lo + EDGE_MARGIN - p) * dt
|
||||
} else if p > hi - EDGE_MARGIN {
|
||||
-speed(p - (hi - EDGE_MARGIN)) * dt
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The scroll offset after `delta` (the wheel's convention) reaches `before`: clamped to the
|
||||
/// scrollable range, so a document shorter than the viewport never moves.
|
||||
fn clamped_scroll(before: egui::Vec2, delta: egui::Vec2, max: egui::Vec2) -> egui::Vec2 {
|
||||
(before - delta).clamp(egui::Vec2::ZERO, max)
|
||||
}
|
||||
|
||||
fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
||||
// A pen or the eraser (Draw tab) owns the pointer.
|
||||
if crate::ink::pointer(app, ui, resp, rects, layout, scale) {
|
||||
@@ -1241,6 +1328,17 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
||||
return;
|
||||
}
|
||||
let Some(p) = pointer else { return };
|
||||
// While drag-selecting, the pointer may sit past the viewport's edge (or the window's):
|
||||
// hit-test where it would be inside the view, so as the edge auto-scroll moves the content the
|
||||
// selection keeps growing under a stationary pointer (#688).
|
||||
let p = if app.canvas.dragging
|
||||
&& ui.input(|i| i.pointer.primary_down())
|
||||
&& let Some(vp) = app.canvas.viewport
|
||||
{
|
||||
vp.clamp(p)
|
||||
} else {
|
||||
p
|
||||
};
|
||||
let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return };
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let story = app.session.sel.focus.story;
|
||||
@@ -1422,7 +1520,8 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
|
||||
app.session.page_hint = page;
|
||||
app.canvas.caret_visible_since = crate::now_ms();
|
||||
}
|
||||
// Auto-scroll near the edges is handled by egui's scroll area drag.
|
||||
// The pointer was clamped into the viewport above, so when the edge auto-scroll moves the
|
||||
// content under it, this hit-test keeps the selection growing (#688).
|
||||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
|
||||
app.canvas.dragging = false;
|
||||
app.canvas.column_drag = None;
|
||||
@@ -1463,41 +1562,26 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
||||
hp.rect_filled(bar, 0.0, t.ruler_margin);
|
||||
let text = Rect::from_min_max(pos2(x0 + page.body.x * scale, bar.min.y), pos2(x0 + page.body.right() * scale, bar.max.y));
|
||||
hp.rect_filled(text, 0.0, t.ruler);
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|para| app.session.doc.styles.resolve_para(¶.props));
|
||||
// Graduations in the File › Options unit.
|
||||
let (unit, div, step) = ruler_scale(app.session.prefs.units);
|
||||
let origin = page.body.x;
|
||||
let mut k = -((origin / unit).ceil() as i32) * div;
|
||||
loop {
|
||||
let xpt = origin + k as f32 * unit / div as f32;
|
||||
if xpt > page.w {
|
||||
break;
|
||||
}
|
||||
if xpt >= 0.0 {
|
||||
let sx = x0 + xpt * scale;
|
||||
let (len, label) = if k % div == 0 {
|
||||
(0.0, Some(k / div * step))
|
||||
} else if k % (div / 2) == 0 {
|
||||
(5.0, None)
|
||||
} else if div >= 8 && k % (div / 4) == 0 {
|
||||
(3.0, None)
|
||||
} else {
|
||||
(1.5, None)
|
||||
};
|
||||
match label {
|
||||
Some(n) if n != 0 => {
|
||||
hp.text(pos2(sx, bar.center().y), egui::Align2::CENTER_CENTER, n.abs().to_string(), regular(9.5), t.ruler_tick);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if scale * unit / div as f32 > 4.0 || k % 2 == 0 {
|
||||
hp.line_segment([pos2(sx, bar.center().y - len / 2.0), pos2(sx, bar.center().y + len / 2.0)], Stroke::new(1.0, t.ruler_tick));
|
||||
}
|
||||
}
|
||||
// The caret paragraph's reading direction anchors the whole scale (#692): for a right-to-left
|
||||
// paragraph it runs from the page's right edge and the numbers increase right-to-left, so the
|
||||
// ruler matches the indent and tab markers, which sit at the paragraph's start edge.
|
||||
let rtl = rp.as_ref().is_some_and(|rp| rp.bidi);
|
||||
let origin = if rtl { page.w - page.body.right() } else { page.body.x };
|
||||
// A distance along the reading direction (points from the page's start edge) and back.
|
||||
let sx = |d: f32| ruler_x(d, x0, scale, page.w, rtl);
|
||||
let s_at = |x: f32| ruler_d(x, x0, scale, page.w, rtl);
|
||||
for (d, mark) in ruler_marks(origin, page.w, (unit, div, step), scale) {
|
||||
let x = sx(d);
|
||||
match mark {
|
||||
RulerMark::Label(n) => {
|
||||
hp.text(pos2(x, bar.center().y), egui::Align2::CENTER_CENTER, n.abs().to_string(), regular(9.5), t.ruler_tick);
|
||||
}
|
||||
RulerMark::Tick(len) => {
|
||||
hp.line_segment([pos2(x, bar.center().y - len / 2.0), pos2(x, bar.center().y + len / 2.0)], Stroke::new(1.0, t.ruler_tick));
|
||||
}
|
||||
}
|
||||
k += 1;
|
||||
if k > 400 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Margins: the edges of the white text area drag the section's margins. Registered before
|
||||
@@ -1511,17 +1595,15 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
||||
}
|
||||
// Indent markers for the caret's paragraph (relative to its column).
|
||||
let col_x = page.body.x;
|
||||
if let Some(para) = app.session.doc.para_at(&app.session.sel.focus) {
|
||||
let rp = app.session.doc.styles.resolve_para(¶.props);
|
||||
if let Some(rp) = rp {
|
||||
// Indents and tabs are measured from the paragraph's start edge: the right margin of a
|
||||
// right-to-left paragraph, where the start-indent markers sit.
|
||||
let rtl = rp.bidi;
|
||||
let w = page.body.w;
|
||||
let sx = |s: f32| if rtl { x0 + (col_x + w - s) * scale } else { x0 + (col_x + s) * scale };
|
||||
let s_at = |x: f32| if rtl { col_x + w - (x - x0) / scale } else { (x - x0) / scale - col_x };
|
||||
let first = sx(rp.indent_left + rp.indent_first);
|
||||
let left = sx(rp.indent_left);
|
||||
let right = sx(w - rp.indent_right);
|
||||
let px = |s: f32| sx(origin + s);
|
||||
let dist_at = |x: f32| s_at(x) - origin;
|
||||
let first = px(rp.indent_left + rp.indent_first);
|
||||
let left = px(rp.indent_left);
|
||||
let right = px(w - rp.indent_right);
|
||||
let c = t.text_dim;
|
||||
hp.add(egui::Shape::convex_polygon(
|
||||
vec![pos2(first - 4.5, bar.min.y), pos2(first + 4.5, bar.min.y), pos2(first, bar.min.y + 5.0)],
|
||||
@@ -1547,7 +1629,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
||||
));
|
||||
let mut open_tabs = false;
|
||||
for (i, tab) in rp.tabs.iter().enumerate() {
|
||||
let tx = sx(tab.pos);
|
||||
let tx = px(tab.pos);
|
||||
let foot = if rtl { -4.0 } else { 4.0 };
|
||||
hp.line_segment([pos2(tx, bar.max.y - 6.0), pos2(tx, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||||
hp.line_segment([pos2(tx, bar.max.y - 1.0), pos2(tx + foot, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||||
@@ -1565,7 +1647,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
||||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = s_at(pp.x).clamp(-col_x, page.body.w - 18.0);
|
||||
let pt = dist_at(pp.x).clamp(-col_x, page.body.w - 18.0);
|
||||
let snapped = (pt / 4.5).round() * 4.5;
|
||||
if !r.drag_started() {
|
||||
app.session.join_next_undo();
|
||||
@@ -1577,7 +1659,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
||||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = s_at(pp.x) - rp.indent_left;
|
||||
let pt = dist_at(pp.x) - rp.indent_left;
|
||||
if !r.drag_started() {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
@@ -1588,7 +1670,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
||||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = page.body.w - s_at(pp.x);
|
||||
let pt = page.body.w - dist_at(pp.x);
|
||||
if !r.drag_started() {
|
||||
app.session.join_next_undo();
|
||||
}
|
||||
@@ -1632,6 +1714,65 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo
|
||||
}
|
||||
}
|
||||
|
||||
/// One mark drawn along a horizontal ruler.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum RulerMark {
|
||||
/// A whole-inch number, `n` inches from the start margin.
|
||||
Label(i32),
|
||||
/// A tick line, `len` screen points long.
|
||||
Tick(f32),
|
||||
}
|
||||
|
||||
/// The marks of a horizontal ruler with their distance from the page's start edge (points along
|
||||
/// the reading direction): ticks every 1/8 inch past `origin` (the start margin, so the margin is
|
||||
/// numbered backwards to the page edge) out to `width`, their labels counting whole inches from
|
||||
/// the start margin. Sub-half-inch ticks drop out when they'd sit closer than ~4 screen points.
|
||||
fn ruler_marks(origin: f32, width: f32, (unit, div, step): (f32, i32, i32), scale: f32) -> Vec<(f32, RulerMark)> {
|
||||
let div = div.max(2);
|
||||
let mut marks = Vec::new();
|
||||
let mut k = -((origin / unit).ceil() as i32) * div;
|
||||
loop {
|
||||
let d = origin + k as f32 * unit / div as f32;
|
||||
if d > width {
|
||||
break;
|
||||
}
|
||||
if d >= 0.0 {
|
||||
let mark = if k % div == 0 {
|
||||
(k != 0).then_some(RulerMark::Label(k / div * step))
|
||||
} else if scale * unit / div as f32 > 4.0 || k % 2 == 0 {
|
||||
Some(RulerMark::Tick(if k % (div / 2) == 0 {
|
||||
5.0
|
||||
} else if div >= 8 && k % (div / 4) == 0 {
|
||||
3.0
|
||||
} else {
|
||||
1.5
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(mark) = mark {
|
||||
marks.push((d, mark));
|
||||
}
|
||||
}
|
||||
k += 1;
|
||||
if k > 400 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
marks
|
||||
}
|
||||
|
||||
/// Screen x of a point `d` points from the page's start edge, on a page whose left edge sits at
|
||||
/// `x0` drawn at `scale`. A right-to-left paragraph reads from the right, so its scale mirrors.
|
||||
fn ruler_x(d: f32, x0: f32, scale: f32, page_w: f32, rtl: bool) -> f32 {
|
||||
if rtl { x0 + (page_w - d) * scale } else { x0 + d * scale }
|
||||
}
|
||||
|
||||
/// The inverse of [`ruler_x`]: the distance from the page's start edge under screen `x`.
|
||||
fn ruler_d(x: f32, x0: f32, scale: f32, page_w: f32, rtl: bool) -> f32 {
|
||||
if rtl { page_w - (x - x0) / scale } else { (x - x0) / scale }
|
||||
}
|
||||
|
||||
/// A ruler's graduation for a unit: points per numbered step, divisions per step, and how much
|
||||
/// each step counts (millimetre rulers number every centimetre as 10, 20…).
|
||||
fn ruler_scale(u: wordcraft_geom::Unit) -> (f32, i32, i32) {
|
||||
@@ -2001,6 +2142,26 @@ mod tests {
|
||||
assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).margin_left, sect.margin_left);
|
||||
}
|
||||
|
||||
/// Show revisions in balloons: a document whose only revision is a deletion gets the markup
|
||||
/// area for its balloon; the other modes have nothing to show there, and neither does the view
|
||||
/// while the Comments pane is open.
|
||||
#[test]
|
||||
fn a_deletion_alone_opens_the_markup_area_when_revisions_go_in_balloons() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("document.setText", json!({"text": "keep gone"})).unwrap();
|
||||
a.run("review.trackChanges", json!({"value": true})).unwrap();
|
||||
a.run("select.range", json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 9}})).unwrap();
|
||||
a.run("text.delete", json!({})).unwrap();
|
||||
assert_eq!(markup_width(&a), 0.0, "comments and formatting only: nothing to show");
|
||||
a.run("review.trackingOptions", json!({"balloons": "revisions"})).unwrap();
|
||||
assert!(markup_width(&a) > 0.0, "the deletion's balloon needs the markup area");
|
||||
a.run("view.commentsPane", json!({"value": true})).unwrap();
|
||||
assert_eq!(markup_width(&a), 0.0);
|
||||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||||
a.run("review.trackingOptions", json!({"insertionsDeletions": false})).unwrap();
|
||||
assert_eq!(markup_width(&a), 0.0, "deletions not shown at all");
|
||||
}
|
||||
|
||||
/// #549: presses count as double/triple as they go down; slow or far presses start again.
|
||||
#[test]
|
||||
fn presses_count_up_to_three() {
|
||||
@@ -2200,6 +2361,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves
|
||||
/// the scrollable range.
|
||||
#[test]
|
||||
fn edge_auto_scroll_ramps_from_the_margins_and_clamps() {
|
||||
let dt = 1.0 / 60.0;
|
||||
let (lo, hi) = (100.0, 500.0);
|
||||
// Nothing while the pointer is inside, including just within the margin.
|
||||
for y in [(lo + hi) / 2.0, lo + EDGE_MARGIN, hi - EDGE_MARGIN] {
|
||||
assert_eq!(edge_scroll(y, lo, hi, dt), 0.0, "y = {y}");
|
||||
}
|
||||
// Direction: above the top edge scrolls back (positive), below the bottom scrolls on
|
||||
// (negative), in the wheel's convention.
|
||||
assert!(edge_scroll(lo + 1.0, lo, hi, dt) > 0.0);
|
||||
assert!(edge_scroll(hi - 1.0, lo, hi, dt) < 0.0);
|
||||
// The ramp grows monotonically from the margin to the edge and caps past it.
|
||||
let above: Vec<f32> = (0..=8).map(|k| edge_scroll(lo + EDGE_MARGIN - k as f32 * EDGE_MARGIN / 8.0, lo, hi, dt)).collect();
|
||||
assert_eq!(above[0], 0.0);
|
||||
assert!(above.windows(2).all(|w| w[0] < w[1] && w[1] > 0.0), "{above:?}");
|
||||
assert_eq!(above[8], EDGE_SPEED_MAX * dt, "the hard edge scrolls at full speed");
|
||||
assert_eq!(edge_scroll(lo - 3.0 * EDGE_MARGIN, lo, hi, dt), above[8], "past the edge the speed caps");
|
||||
let below: Vec<f32> = (0..=4).map(|k| edge_scroll(hi - EDGE_MARGIN + k as f32 * EDGE_MARGIN / 4.0, lo, hi, dt)).collect();
|
||||
assert_eq!(below[0], 0.0);
|
||||
assert!(below.windows(2).all(|w| w[0] > w[1] && w[1] < 0.0), "{below:?}");
|
||||
assert_eq!(below[4], -EDGE_SPEED_MAX * dt);
|
||||
assert_eq!(edge_scroll(hi + 3.0 * EDGE_MARGIN, lo, hi, dt), below[4], "past the edge the speed caps");
|
||||
// A stalled frame doesn't jump the page.
|
||||
assert!(edge_scroll(hi - 1.0, lo, hi, 100.0) <= EDGE_SPEED_MAX * EDGE_MAX_DT + 1e-3);
|
||||
// Junk in, nothing out; neither an empty (or inverted) band.
|
||||
for (p, lo, hi, dt) in
|
||||
[(f32::NAN, lo, hi, dt), (200.0, f32::NAN, hi, dt), (200.0, lo, f32::INFINITY, dt), (200.0, lo, hi, f32::NAN), (200.0, 500.0, 100.0, dt)]
|
||||
{
|
||||
assert_eq!(edge_scroll(p, lo, hi, dt), 0.0);
|
||||
}
|
||||
|
||||
// Applying it clamps to the scrollable range; a document that fits never scrolls.
|
||||
let max = vec2(0.0, 400.0);
|
||||
assert_eq!(clamped_scroll(egui::Vec2::ZERO, vec2(0.0, 50.0), max).y, 0.0, "never past the top");
|
||||
assert_eq!(clamped_scroll(vec2(0.0, 400.0), vec2(0.0, -50.0), max).y, 400.0, "never past the bottom");
|
||||
assert_eq!(clamped_scroll(vec2(0.0, 300.0), vec2(0.0, 50.0), max).y, 250.0);
|
||||
assert_eq!(clamped_scroll(egui::Vec2::ZERO, vec2(0.0, 50.0), egui::Vec2::ZERO).y, 0.0, "nothing to scroll");
|
||||
}
|
||||
|
||||
/// Issue #123: zoomed out, pages sit side by side in rows when the width allows; at 100% a
|
||||
/// letter page in an ordinary window stays one per row.
|
||||
#[test]
|
||||
@@ -2315,6 +2518,103 @@ mod tests {
|
||||
assert_eq!(visual, layout);
|
||||
}
|
||||
|
||||
/// #692: a right-to-left paragraph mirrors the horizontal ruler's scale: the inch numbers
|
||||
/// count from the right margin, and reading a pointer position back inverts the mapping
|
||||
/// exactly, so the indent drags keep editing the same indents.
|
||||
#[test]
|
||||
fn the_ruler_scale_mirrors_for_right_to_left() {
|
||||
let (x0, scale, page_w, unit) = (100.0, 1.25, 612.0, 72.0);
|
||||
// 0 sits on the page's right edge and the inches rise leftward.
|
||||
assert_eq!(ruler_x(0.0, x0, scale, page_w, true), x0 + page_w * scale);
|
||||
assert_eq!(ruler_x(page_w, x0, scale, page_w, true), x0);
|
||||
// The inches count from the start margin, which is the right one: on an asymmetric page
|
||||
// (body from 72 to 468) 1 sits an inch left of the right body edge, mirroring left-to-right.
|
||||
let (body_x, body_right) = (72.0, 468.0);
|
||||
assert_eq!(ruler_x(body_x + unit, x0, scale, page_w, false), x0 + (body_x + unit) * scale);
|
||||
assert_eq!(ruler_x(page_w - body_right + unit, x0, scale, page_w, true), x0 + (body_right - unit) * scale);
|
||||
// Reading the pointer back inverts placing exactly, both ways: drags see the same indents.
|
||||
for rtl in [false, true] {
|
||||
for d in [0.0, 9.0, 72.0, 306.0, 612.0] {
|
||||
let x = ruler_x(d, x0, scale, page_w, rtl);
|
||||
assert!((ruler_d(x, x0, scale, page_w, rtl) - d).abs() < 1e-3, "rtl={rtl}: {x} reads back near {d}");
|
||||
}
|
||||
}
|
||||
|
||||
// The marks: inches labelled from the start margin (the page edge counts backwards into
|
||||
// the margin, the margin itself has no mark), half inches the long ticks, ending on the
|
||||
// far page edge.
|
||||
let marks = ruler_marks(72.0, page_w, (unit, 8, 1), scale);
|
||||
let at = |d: f32| marks.iter().find(|(td, _)| (*td - d).abs() < 1e-4).map(|(_, m)| *m);
|
||||
assert_eq!(marks.first().map(|(d, _)| *d), Some(0.0));
|
||||
assert_eq!(marks.last().map(|(d, _)| *d), Some(page_w));
|
||||
assert_eq!(at(0.0), Some(RulerMark::Label(-1)));
|
||||
assert_eq!(at(72.0), None);
|
||||
assert_eq!(at(72.0 + unit), Some(RulerMark::Label(1)));
|
||||
assert_eq!(at(72.0 + 2.0 * unit), Some(RulerMark::Label(2)));
|
||||
assert_eq!(at(72.0 + unit / 2.0), Some(RulerMark::Tick(5.0)));
|
||||
assert_eq!(at(72.0 + unit / 8.0), Some(RulerMark::Tick(1.5)));
|
||||
// Zoomed out, sub-half-inch ticks drop out rather than smudge together.
|
||||
let coarse = ruler_marks(72.0, page_w, (unit, 8, 1), 0.3);
|
||||
assert!(!coarse.iter().any(|(_, m)| matches!(m, RulerMark::Tick(1.5))));
|
||||
assert!(coarse.iter().any(|(_, m)| matches!(m, RulerMark::Tick(3.0))));
|
||||
// Laid on screen, the same ticks run the other way when mirrored.
|
||||
let xs = |rtl| marks.iter().map(|(d, _)| ruler_x(*d, x0, scale, page_w, rtl)).collect::<Vec<_>>();
|
||||
assert!(xs(false).windows(2).all(|w| w[0] < w[1]));
|
||||
assert!(xs(true).windows(2).all(|w| w[0] > w[1]));
|
||||
}
|
||||
|
||||
/// #692: Home › Paragraph › Right-to-Left Text Direction flips the ruler on screen: the
|
||||
/// inch numbers mirror about the page's centre and rise right-to-left.
|
||||
#[test]
|
||||
fn setting_a_paragraph_rtl_mirrors_the_ruler_numbers() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "Hello"})).unwrap();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0));
|
||||
let mut t = 0.0;
|
||||
// The horizontal ruler's inch labels: (text, centre x).
|
||||
let mut labels = |a: &mut WordApp| {
|
||||
t += 1.0 / 60.0;
|
||||
let input = egui::RawInput { time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
let out = ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
});
|
||||
// The first frames only install fonts; the canvas appears after them.
|
||||
let found = a.canvas.canvas_rect.as_ref().map_or_else(Vec::new, |area| {
|
||||
// Ruler labels sit in the strip above the canvas, right of the vertical ruler.
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|clipped| match &clipped.shape {
|
||||
egui::Shape::Text(text) if text.pos.y >= area.min.y - RULER && text.pos.y < area.min.y && text.pos.x >= area.min.x => {
|
||||
Some((text.galley.text().to_owned(), text.pos.x + text.galley.size().x / 2.0))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<(String, f32)>>()
|
||||
});
|
||||
out.drop_without_applying_deltas();
|
||||
found
|
||||
};
|
||||
let inch =
|
||||
|labels: &[(String, f32)], n: &str| labels.iter().find(|(s, _)| s == n).map(|(_, x)| *x).unwrap_or_else(|| panic!("the ruler shows {n}"));
|
||||
// The first frames install fonts and lay the page out.
|
||||
for _ in 0..4 {
|
||||
labels(&mut a);
|
||||
}
|
||||
let centre = a.canvas.page_rects[a.session.page_hint].center().x;
|
||||
let before = labels(&mut a);
|
||||
let (before1, before2) = (inch(&before, "1"), inch(&before, "2"));
|
||||
assert!(before1 < centre && before1 < before2, "left to right: the numbers rise away from the left margin");
|
||||
|
||||
a.run("para.rtl", json!({})).unwrap();
|
||||
let after = labels(&mut a);
|
||||
let (after1, after2) = (inch(&after, "1"), inch(&after, "2"));
|
||||
assert!(after1 > centre && after1 > after2, "right to left: the numbers rise away from the right margin");
|
||||
assert!((after1 - (2.0 * centre - before1)).abs() < 1.0, "1 mirrors about the page centre: {before1} → {after1}");
|
||||
assert!((after2 - (2.0 * centre - before2)).abs() < 1.0, "2 mirrors about the page centre: {before2} → {after2}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_white_space_stacks_page_bands_and_keeps_hit_testing() {
|
||||
let scale = 1.0;
|
||||
|
||||
@@ -0,0 +1,877 @@
|
||||
//! The caret moves and blinks the way Word's does on Windows, instead of jumping and switching
|
||||
//! on and off.
|
||||
//!
|
||||
//! Measured from Word for Windows (version 16.0.20326, at 100% zoom on a 144 Hz display) as a
|
||||
//! user sees it: a synthetic document, Word's window captured frame by frame while keys, typing
|
||||
//! and clicks moved the caret. Nothing was read from the program. Word for Mac was not measured.
|
||||
//!
|
||||
//! - **A key or typing moves the caret:** it glides from where it was to its new place in
|
||||
//! [`GLIDE_SECS`], fast at first and settling ([`ease`]). The time is the same for one
|
||||
//! character and for a whole line.
|
||||
//! - **Another move before it arrives:** it snaps to where it was heading and glides from there.
|
||||
//! - **A click moves the caret:** no glide; it is drawn at its new place.
|
||||
//! - **A command from a script or the control channel:** no glide, no fade.
|
||||
//! - **The blink is a fade:** each time the caret turns on or off it takes [`GLIDE_SECS`] to get
|
||||
//! there. A move by a key or a click turns it on the same way, from however visible it was, so
|
||||
//! a caret that was off fades in while it glides.
|
||||
//!
|
||||
//! What never glides: a caret that moved because the view changed (zoom, a resized window,
|
||||
//! scrolling, another document), one that wasn't drawn the frame before (a selection, the canvas
|
||||
//! unfocused), and moves that start or end off screen. Our choice; Word wasn't measured there.
|
||||
//!
|
||||
//! With animations off the caret jumps and blinks on and off, as it always did: File ▸ Options ▸
|
||||
//! Advanced ▸ Animation (the `ui.caretAnimation` command) turns them off, and so do egui's
|
||||
//! `animation_time` at 0 (the headless test harness, so scripted screenshots never catch a caret
|
||||
//! in flight) and `WORDCRAFT_CARET_GLIDE_MS=0`. The variable also sets the duration, for looking
|
||||
//! at the motion slowed down.
|
||||
//!
|
||||
//! The glide runs in content space (screen points from the top-left of the scrolled content), so
|
||||
//! scrolling under a moving caret doesn't bend its path.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use egui::{Pos2, Rect, Ui};
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::canvas::Geometry;
|
||||
|
||||
/// How long a glide, and each fade of the blink, takes (seconds).
|
||||
pub const GLIDE_SECS: f64 = 0.10;
|
||||
/// The caret is on for this long, then off for this long (seconds).
|
||||
pub const BLINK_SECS: f64 = 0.53;
|
||||
/// The longest glide `WORDCRAFT_CARET_GLIDE_MS` can ask for.
|
||||
const MAX_GLIDE_SECS: f64 = 10.0;
|
||||
/// Moves shorter than this (screen points) aren't worth a glide.
|
||||
const MIN_MOVE: f32 = 0.5;
|
||||
/// How far outside the visible canvas a glide may start or end (screen points).
|
||||
const OFFSCREEN_SLACK: f32 = 48.0;
|
||||
|
||||
/// The caret's bar: its two ends.
|
||||
pub type Bar = [Pos2; 2];
|
||||
|
||||
/// What the canvas knows about the caret in one frame.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frame {
|
||||
/// Where the caret belongs, in content space.
|
||||
pub target: Bar,
|
||||
/// Fingerprint of what puts the caret there: the document's revision and the selection.
|
||||
pub cause: u64,
|
||||
/// Fingerprint of how the document is shown: zoom, page arrangement, which document.
|
||||
pub view: u64,
|
||||
/// The caret is drawn this frame (the canvas has focus and nothing is selected).
|
||||
pub shown: bool,
|
||||
/// The part of content space on screen.
|
||||
pub viewport: Rect,
|
||||
/// egui's clock, in seconds.
|
||||
pub now: f64,
|
||||
/// egui's frame counter: a caret that skipped a frame doesn't glide from its stale place.
|
||||
pub frame_nr: u64,
|
||||
/// A key was pressed or text arrived this frame.
|
||||
pub keys: bool,
|
||||
/// A mouse button is down, or was pressed or released this frame.
|
||||
pub pointer: bool,
|
||||
/// Seconds since the blink was restarted (by the last command or click).
|
||||
pub since_blink: f64,
|
||||
}
|
||||
|
||||
/// How to draw the caret this frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Look {
|
||||
/// Where, in the space the target was given in.
|
||||
pub bar: Bar,
|
||||
/// How strongly: 0 (not at all) to 1.
|
||||
pub opacity: f32,
|
||||
/// It is gliding or fading in after a move: repaint every frame.
|
||||
pub moving: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Glide {
|
||||
from: Bar,
|
||||
to: Bar,
|
||||
started: f64,
|
||||
}
|
||||
|
||||
/// The caret turning on after a move, from however visible it was.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct FadeIn {
|
||||
from: f32,
|
||||
started: f64,
|
||||
}
|
||||
|
||||
/// The caret's motion between frames. Time-based, so it looks the same at any frame rate.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CaretGlide {
|
||||
secs: f64,
|
||||
/// Where the caret belonged last frame (content space); `None` when it wasn't drawn.
|
||||
target: Option<Bar>,
|
||||
/// Where and how strongly it was drawn last frame.
|
||||
drawn: Option<Bar>,
|
||||
opacity: f32,
|
||||
glide: Option<Glide>,
|
||||
fade: Option<FadeIn>,
|
||||
/// A key or a mouse button was pressed last frame: the canvas handles its keys and its mouse
|
||||
/// at the end of a frame, so the move they make is seen one frame later.
|
||||
keys: bool,
|
||||
pointer: bool,
|
||||
cause: u64,
|
||||
view: u64,
|
||||
frame_nr: u64,
|
||||
}
|
||||
|
||||
impl Default for CaretGlide {
|
||||
fn default() -> Self {
|
||||
CaretGlide::with_secs(glide_secs())
|
||||
}
|
||||
}
|
||||
|
||||
impl CaretGlide {
|
||||
/// A caret whose glides and fades take `secs` seconds (0 or less: it jumps and blinks hard).
|
||||
pub fn with_secs(secs: f64) -> Self {
|
||||
let secs = if secs.is_finite() { secs.clamp(0.0, MAX_GLIDE_SECS) } else { GLIDE_SECS };
|
||||
CaretGlide {
|
||||
secs,
|
||||
target: None,
|
||||
drawn: None,
|
||||
opacity: 0.0,
|
||||
glide: None,
|
||||
fade: None,
|
||||
keys: false,
|
||||
pointer: false,
|
||||
cause: 0,
|
||||
view: 0,
|
||||
frame_nr: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to this frame; returns where and how strongly to draw the caret (content space).
|
||||
pub fn frame(&mut self, f: Frame) -> Look {
|
||||
let continuous = f.frame_nr.wrapping_sub(self.frame_nr) <= 1;
|
||||
let was = self.target.filter(|_| continuous);
|
||||
let was_opacity = if was.is_some() { self.opacity } else { 0.0 };
|
||||
let edited = f.cause != self.cause;
|
||||
let same_view = f.view == self.view;
|
||||
let by_keys = f.keys || (self.keys && continuous);
|
||||
let by_pointer = f.pointer || (self.pointer && continuous);
|
||||
self.cause = f.cause;
|
||||
self.view = f.view;
|
||||
self.frame_nr = f.frame_nr;
|
||||
self.keys = f.keys;
|
||||
self.pointer = f.pointer;
|
||||
let sane = is_finite(f.target) && f.now.is_finite();
|
||||
if !(f.shown && sane) {
|
||||
self.reset();
|
||||
return Look { bar: f.target, opacity: 0.0, moving: false };
|
||||
}
|
||||
if self.secs <= 0.0 {
|
||||
// Animations off: in place, and on or off.
|
||||
self.glide = None;
|
||||
self.fade = None;
|
||||
let opacity = if blink(f.since_blink, 0.0) > 0.5 { 1.0 } else { 0.0 };
|
||||
return self.draw(f.target, f.target, opacity);
|
||||
}
|
||||
if !same_view {
|
||||
self.glide = None;
|
||||
}
|
||||
// Where the caret was heading: its place last frame, or the end of the glide under way.
|
||||
let heading = self.glide.map(|g| g.to).or(was);
|
||||
let moved = heading.is_none_or(|h| distance(h, f.target) >= MIN_MOVE);
|
||||
if edited && moved && (by_keys || by_pointer) {
|
||||
// A key or a click turns the caret on, from however visible it was.
|
||||
self.fade = Some(FadeIn { from: was_opacity, started: f.now });
|
||||
self.glide = match heading {
|
||||
// Keys glide, from where the caret was heading (not from where it has got to).
|
||||
Some(from) if by_keys && !by_pointer && same_view => {
|
||||
let seen = f.viewport.expand(OFFSCREEN_SLACK);
|
||||
let on_screen = |b: Bar| seen.intersects(Rect::from_two_pos(b[0], b[1]));
|
||||
(on_screen(from) && on_screen(f.target)).then_some(Glide { from, to: f.target, started: f.now })
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if edited && moved {
|
||||
// A script moved it: it is simply there.
|
||||
self.glide = None;
|
||||
self.fade = None;
|
||||
}
|
||||
// The text shifted under a glide (pixel snapping while scrolling): carry the glide along.
|
||||
if let Some(g) = &mut self.glide
|
||||
&& g.to != f.target
|
||||
{
|
||||
let d = f.target[0] - g.to[0];
|
||||
g.from = [g.from[0] + d, g.from[1] + d];
|
||||
g.to = f.target;
|
||||
}
|
||||
let bar = match self.glide {
|
||||
Some(g) => match progress(f.now, g.started, self.secs) {
|
||||
Some(s) => lerp(g.from, g.to, ease(s)),
|
||||
None => {
|
||||
self.glide = None;
|
||||
f.target
|
||||
}
|
||||
},
|
||||
None => f.target,
|
||||
};
|
||||
let opacity = match self.fade {
|
||||
Some(fade) => match progress(f.now, fade.started, self.secs) {
|
||||
Some(s) => fade.from + (1.0 - fade.from) * s,
|
||||
None => {
|
||||
self.fade = None;
|
||||
1.0
|
||||
}
|
||||
},
|
||||
// A gliding caret stays lit; a resting one blinks.
|
||||
None if self.glide.is_some() => 1.0,
|
||||
None => blink(f.since_blink, self.secs),
|
||||
};
|
||||
self.draw(f.target, bar, opacity)
|
||||
}
|
||||
|
||||
fn draw(&mut self, target: Bar, bar: Bar, opacity: f32) -> Look {
|
||||
let opacity = if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 1.0 };
|
||||
self.target = Some(target);
|
||||
self.drawn = Some(bar);
|
||||
self.opacity = opacity;
|
||||
Look { bar, opacity, moving: self.glide.is_some() || self.fade.is_some() }
|
||||
}
|
||||
|
||||
/// The caret is on its way somewhere.
|
||||
pub fn is_gliding(&self) -> bool {
|
||||
self.glide.is_some()
|
||||
}
|
||||
|
||||
/// Where the caret was drawn last frame (content space), if it was drawn.
|
||||
pub fn drawn(&self) -> Option<Bar> {
|
||||
self.drawn
|
||||
}
|
||||
|
||||
/// How strongly the caret was drawn last frame (0 when it wasn't).
|
||||
pub fn opacity(&self) -> f32 {
|
||||
self.opacity
|
||||
}
|
||||
|
||||
/// Forget the caret: it appears wherever it belongs next frame.
|
||||
pub fn reset(&mut self) {
|
||||
self.glide = None;
|
||||
self.fade = None;
|
||||
self.target = None;
|
||||
self.drawn = None;
|
||||
self.opacity = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// How far along an animation that started at `started` and lasts `secs` is at `now`: 0 to 1, or
|
||||
/// `None` once it is over (or the clock went backwards).
|
||||
fn progress(now: f64, started: f64, secs: f64) -> Option<f32> {
|
||||
let s = (now - started) / secs;
|
||||
(0.0..1.0).contains(&s).then_some(s as f32)
|
||||
}
|
||||
|
||||
/// Word's curve for the glide, `cubic-bezier(0, 0, 0, 1)`: more than a third of the way after
|
||||
/// 7% of the time, nine tenths after half of it. For these control points the curve has a closed
|
||||
/// form: time is `u³` and distance `3u² − 2u³`.
|
||||
pub fn ease(s: f32) -> f32 {
|
||||
let s = if s.is_finite() { s.clamp(0.0, 1.0) } else { 1.0 };
|
||||
3.0 * s.powf(2.0 / 3.0) - 2.0 * s
|
||||
}
|
||||
|
||||
/// A resting caret's opacity `since` seconds after its blink was restarted: on for
|
||||
/// [`BLINK_SECS`], off for as long, and so on, taking `fade` seconds over each change (the first
|
||||
/// turn-on isn't faded here: the move that restarted the blink does that).
|
||||
pub fn blink(since: f64, fade: f64) -> f32 {
|
||||
if !since.is_finite() || since < BLINK_SECS {
|
||||
return 1.0;
|
||||
}
|
||||
let phase = since % (2.0 * BLINK_SECS);
|
||||
let (into, turning_on) = if phase < BLINK_SECS { (phase, true) } else { (phase - BLINK_SECS, false) };
|
||||
let done = if fade > 0.0 { (into / fade).clamp(0.0, 1.0) as f32 } else { 1.0 };
|
||||
if turning_on { done } else { 1.0 - done }
|
||||
}
|
||||
|
||||
/// The blink is part-way through a fade: keep repainting.
|
||||
fn blink_fading(since: f64, fade: f64) -> bool {
|
||||
since.is_finite() && since >= BLINK_SECS && (since % BLINK_SECS) < fade
|
||||
}
|
||||
|
||||
fn lerp(a: Bar, b: Bar, t: f32) -> Bar {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
||||
}
|
||||
|
||||
/// How far the bar's farthest end moves.
|
||||
fn distance(a: Bar, b: Bar) -> f32 {
|
||||
(b[0] - a[0]).length().max((b[1] - a[1]).length())
|
||||
}
|
||||
|
||||
fn is_finite(b: Bar) -> bool {
|
||||
b[0].x.is_finite() && b[0].y.is_finite() && b[1].x.is_finite() && b[1].y.is_finite()
|
||||
}
|
||||
|
||||
/// The glide's duration: [`GLIDE_SECS`], or `WORDCRAFT_CARET_GLIDE_MS` when set (tests ignore it).
|
||||
fn glide_secs() -> f64 {
|
||||
if cfg!(test) {
|
||||
return GLIDE_SECS;
|
||||
}
|
||||
static SECS: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
|
||||
*SECS.get_or_init(|| parse_ms(std::env::var("WORDCRAFT_CARET_GLIDE_MS").ok().as_deref()))
|
||||
}
|
||||
|
||||
fn parse_ms(value: Option<&str>) -> f64 {
|
||||
match value.and_then(|s| s.trim().parse::<f64>().ok()) {
|
||||
Some(ms) if ms.is_finite() => (ms / 1000.0).clamp(0.0, MAX_GLIDE_SECS),
|
||||
_ => GLIDE_SECS,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_id() -> egui::Id {
|
||||
egui::Id::new("caret_glide")
|
||||
}
|
||||
|
||||
/// What puts the caret where it is: the document's revision and the caret's position in it.
|
||||
fn cause_key(app: &WordApp) -> u64 {
|
||||
let s = &app.session;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(s.rev(), &s.sel.focus).hash(&mut h);
|
||||
if let Some(m) = &s.math {
|
||||
format!("{:?}", m.pos).hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// How the document is shown: which document, the zoom and where the pages sit.
|
||||
fn view_key(app: &WordApp, geo: &Geometry) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(app.session.document_id(), geo.scale.to_bits(), geo.cols, geo.size.x.to_bits()).hash(&mut h);
|
||||
geo.rects.first().map(|r| (r.min.x.to_bits(), r.min.y.to_bits())).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The canvas's hook: `target` is where the caret belongs on screen this frame, `shown` whether
|
||||
/// it is drawn at all, `since_ms` how long ago its blink was restarted and `on` what the plain
|
||||
/// on-and-off blink says. Returns where and how strongly to draw it. `content_origin` is the scrolled
|
||||
/// content's top-left on screen.
|
||||
pub fn look(app: &WordApp, ui: &Ui, content_origin: Pos2, geo: &Geometry, target: Bar, shown: bool, since_ms: f64, on: bool) -> Look {
|
||||
// Turned off in Options, Animation effects off (the setting, or the system's under "Use system
|
||||
// setting"), or egui's own switch for "no animations": the caret jumps and blinks on and off.
|
||||
if !app.ui.caret_animation || !app.animations_on() || ui.style().animation_time <= 0.0 || glide_secs() <= 0.0 {
|
||||
ui.ctx().data_mut(|d| d.get_temp_mut_or_default::<CaretGlide>(state_id()).reset());
|
||||
return Look { bar: target, opacity: if on { 1.0 } else { 0.0 }, moving: false };
|
||||
}
|
||||
let off = content_origin.to_vec2();
|
||||
let since = since_ms / 1000.0;
|
||||
let (keys, pointer, now) = ui.input(|i| {
|
||||
let typed = |e: &egui::Event| {
|
||||
matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Ime(_))
|
||||
};
|
||||
(i.events.iter().any(typed), i.pointer.any_down() || i.pointer.any_pressed() || i.pointer.any_released(), i.time)
|
||||
});
|
||||
let frame = Frame {
|
||||
target: [target[0] - off, target[1] - off],
|
||||
cause: cause_key(app),
|
||||
view: view_key(app, geo),
|
||||
shown,
|
||||
viewport: ui.clip_rect().translate(-off),
|
||||
now,
|
||||
frame_nr: ui.ctx().cumulative_frame_nr(),
|
||||
keys,
|
||||
pointer,
|
||||
since_blink: since,
|
||||
};
|
||||
let (mut look, gliding, secs) = ui.ctx().data_mut(|d| {
|
||||
let state = d.get_temp_mut_or_default::<CaretGlide>(state_id());
|
||||
(state.frame(frame), state.is_gliding(), state.secs)
|
||||
});
|
||||
// At rest the caller's own pixel-snapped bar is drawn, untouched.
|
||||
look.bar = if gliding { [look.bar[0] + off, look.bar[1] + off] } else { target };
|
||||
if look.moving {
|
||||
ui.ctx().request_repaint();
|
||||
} else if shown && blink_fading(since, secs) {
|
||||
// The blink's fade needs a handful of frames, not every one.
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
look
|
||||
}
|
||||
|
||||
/// The caret's glide state this frame (for tests and tools).
|
||||
pub fn state(ctx: &egui::Context) -> CaretGlide {
|
||||
ctx.data(|d| d.get_temp::<CaretGlide>(state_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
const DT: f64 = 1.0 / 60.0;
|
||||
|
||||
fn bar(x: f32, y: f32) -> Bar {
|
||||
[pos2(x, y), pos2(x, y + 16.0)]
|
||||
}
|
||||
|
||||
/// A caret driven frame by frame, with the bookkeeping a canvas would do.
|
||||
struct Rig {
|
||||
glide: CaretGlide,
|
||||
f: Frame,
|
||||
/// When the blink was last restarted.
|
||||
restarted: f64,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
fn new(at: Bar) -> Self {
|
||||
let f = Frame {
|
||||
target: at,
|
||||
cause: 1,
|
||||
view: 1,
|
||||
shown: true,
|
||||
viewport: Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0)),
|
||||
now: 100.0,
|
||||
frame_nr: 10,
|
||||
keys: false,
|
||||
pointer: false,
|
||||
since_blink: 0.0,
|
||||
};
|
||||
let mut rig = Rig { glide: CaretGlide::with_secs(GLIDE_SECS), f, restarted: 100.0 };
|
||||
assert_eq!(rig.glide.frame(rig.f), Look { bar: at, opacity: 1.0, moving: false }, "the first frame draws the caret in place");
|
||||
rig
|
||||
}
|
||||
|
||||
/// The next frame, `dt` seconds later. Input lasts one frame.
|
||||
fn step(&mut self, dt: f64) -> Look {
|
||||
self.f.now += dt;
|
||||
self.f.frame_nr += 1;
|
||||
self.f.since_blink = self.f.now - self.restarted;
|
||||
let look = self.glide.frame(self.f);
|
||||
self.f.keys = false;
|
||||
self.f.pointer = false;
|
||||
look
|
||||
}
|
||||
|
||||
/// A key (arrow, typing) moves the caret: the command restarts the blink.
|
||||
fn key_to(&mut self, to: Bar) {
|
||||
self.f.target = to;
|
||||
self.f.cause += 1;
|
||||
self.f.keys = true;
|
||||
self.restarted = self.f.now + DT;
|
||||
}
|
||||
|
||||
/// A script or the control channel moves the caret.
|
||||
fn script_to(&mut self, to: Bar) {
|
||||
self.f.target = to;
|
||||
self.f.cause += 1;
|
||||
self.restarted = self.f.now + DT;
|
||||
}
|
||||
|
||||
/// A click moves the caret. The canvas handles the mouse at the end of its frame, so the
|
||||
/// button is seen a frame before the caret moves.
|
||||
fn click_to(&mut self, to: Bar) {
|
||||
self.f.pointer = true;
|
||||
self.step(DT);
|
||||
self.f.target = to;
|
||||
self.f.cause += 1;
|
||||
self.restarted = self.f.now + DT;
|
||||
}
|
||||
|
||||
/// Let `secs` pass at rest.
|
||||
fn rest(&mut self, secs: f64) -> Look {
|
||||
let mut look = self.step(DT);
|
||||
for _ in 1..(secs / DT).round() as usize {
|
||||
look = self.step(DT);
|
||||
}
|
||||
look
|
||||
}
|
||||
}
|
||||
|
||||
/// The curve is Word's: these are the caret's measured places, frame by frame at 144 Hz, as
|
||||
/// fractions of the way (the same for moves of 6 px and of 372 px).
|
||||
#[test]
|
||||
fn the_glide_follows_words_measured_curve() {
|
||||
let measured = [0.369, 0.529, 0.640, 0.724, 0.790, 0.843, 0.885, 0.918, 0.945, 0.965, 0.980, 0.991, 0.997, 1.0];
|
||||
for (k, want) in measured.iter().enumerate() {
|
||||
let s = (k + 1) as f32 * (1.0 / 144.0) / GLIDE_SECS as f32;
|
||||
assert!((ease(s) - want).abs() < 0.004, "frame {}: {} vs measured {want}", k + 1, ease(s));
|
||||
}
|
||||
assert_eq!((ease(0.0), ease(1.0)), (0.0, 1.0));
|
||||
assert!(ease(0.5) > 0.88, "nine tenths of the way in half the time");
|
||||
assert_eq!((ease(-3.0), ease(7.0), ease(f32::NAN)), (0.0, 1.0, 1.0));
|
||||
let mut last = 0.0;
|
||||
for i in 0..=1000 {
|
||||
let p = ease(i as f32 / 1000.0);
|
||||
assert!(p >= last && p <= 1.0, "never backwards, never past the end");
|
||||
last = p;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_move_glides_and_lands_exactly() {
|
||||
let mut r = Rig::new(bar(100.0, 50.0));
|
||||
r.key_to(bar(200.0, 90.0));
|
||||
let first = r.step(DT);
|
||||
assert_eq!(first.bar, bar(100.0, 50.0), "the glide starts from where the caret was");
|
||||
assert!(first.moving && r.glide.is_gliding());
|
||||
let mut last = 100.0;
|
||||
let mut frames = 0;
|
||||
while r.glide.is_gliding() {
|
||||
let look = r.step(DT);
|
||||
let b = look.bar;
|
||||
assert!(b[0].x >= last && b[0].x <= 200.0, "moves toward the target without overshooting: {b:?}");
|
||||
assert!((b[1].y - b[0].y - 16.0).abs() < 1e-3, "the bar keeps its height: {b:?}");
|
||||
assert_eq!(look.opacity, 1.0, "a caret that was lit stays lit on the way");
|
||||
last = b[0].x;
|
||||
frames += 1;
|
||||
assert!(frames < 60);
|
||||
}
|
||||
assert_eq!(r.glide.drawn(), Some(bar(200.0, 90.0)));
|
||||
// 0.1 s at 60 Hz.
|
||||
assert!((5..=7).contains(&frames), "{frames} frames");
|
||||
assert_eq!(r.step(DT), Look { bar: bar(200.0, 90.0), opacity: 1.0, moving: false }, "then it rests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_glide_is_the_same_at_any_frame_rate() {
|
||||
let at = |hz: f64| {
|
||||
let mut r = Rig::new(bar(0.0, 0.0));
|
||||
r.key_to(bar(300.0, 0.0));
|
||||
r.step(1.0 / hz);
|
||||
let mut b = bar(0.0, 0.0);
|
||||
// 0.05 s in: three frames at 60 Hz, twelve at 240 Hz.
|
||||
for _ in 0..(hz / 20.0).round() as usize {
|
||||
b = r.step(1.0 / hz).bar;
|
||||
}
|
||||
b[0].x
|
||||
};
|
||||
let expected = 300.0 * ease((0.05 / GLIDE_SECS) as f32);
|
||||
assert!((at(60.0) - expected).abs() < 0.5, "{} vs {expected}", at(60.0));
|
||||
assert!((at(240.0) - expected).abs() < 0.5, "{} vs {expected}", at(240.0));
|
||||
}
|
||||
|
||||
/// Word: a second key before the caret arrives makes it snap to where it was heading and set
|
||||
/// off from there (held arrow keys, quick typing).
|
||||
#[test]
|
||||
fn a_new_key_move_sets_off_from_where_the_caret_was_heading() {
|
||||
let mut r = Rig::new(bar(0.0, 0.0));
|
||||
r.key_to(bar(100.0, 0.0));
|
||||
r.step(DT);
|
||||
let mid = r.step(DT * 2.0).bar;
|
||||
assert!(mid[0].x > 0.0 && mid[0].x < 100.0);
|
||||
r.key_to(bar(110.0, 0.0));
|
||||
assert_eq!(r.step(DT).bar, bar(100.0, 0.0), "it snaps to the first move's end");
|
||||
let next = r.step(DT).bar;
|
||||
assert!(next[0].x > 100.0 && next[0].x < 110.0);
|
||||
r.rest(0.2);
|
||||
assert_eq!(r.glide.drawn(), Some(bar(110.0, 0.0)));
|
||||
assert!(!r.glide.is_gliding());
|
||||
// Something else changed the document but left the caret alone: nothing to do.
|
||||
r.f.cause += 1;
|
||||
r.f.keys = true;
|
||||
assert_eq!(r.step(DT).bar, bar(110.0, 0.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
// The canvas reads its keys at the end of a frame: the move shows up one frame after the
|
||||
// key, and still glides. Two frames after, it is somebody else's move.
|
||||
r.rest(0.2);
|
||||
r.f.keys = true;
|
||||
r.step(DT);
|
||||
r.f.target = bar(160.0, 0.0);
|
||||
r.f.cause += 1;
|
||||
assert_eq!(r.step(DT).bar, bar(110.0, 0.0));
|
||||
assert!(r.glide.is_gliding());
|
||||
r.rest(0.2);
|
||||
r.f.keys = true;
|
||||
r.step(DT);
|
||||
r.step(DT);
|
||||
r.f.target = bar(210.0, 0.0);
|
||||
r.f.cause += 1;
|
||||
assert_eq!(r.step(DT).bar, bar(210.0, 0.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
}
|
||||
|
||||
/// Word: a click puts the caret where it was clicked, a script's move likewise; only keys glide.
|
||||
#[test]
|
||||
fn clicks_and_scripts_do_not_glide() {
|
||||
let mut r = Rig::new(bar(100.0, 100.0));
|
||||
r.click_to(bar(400.0, 300.0));
|
||||
let look = r.step(DT);
|
||||
assert_eq!(look.bar, bar(400.0, 300.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
assert_eq!(look.opacity, 1.0, "a lit caret stays lit");
|
||||
r.rest(0.2);
|
||||
r.script_to(bar(50.0, 50.0));
|
||||
assert_eq!(r.step(DT), Look { bar: bar(50.0, 50.0), opacity: 1.0, moving: false });
|
||||
// A script's move during a glide ends the glide.
|
||||
r.key_to(bar(250.0, 50.0));
|
||||
r.step(DT);
|
||||
r.step(DT);
|
||||
assert!(r.glide.is_gliding());
|
||||
r.script_to(bar(20.0, 20.0));
|
||||
assert_eq!(r.step(DT), Look { bar: bar(20.0, 20.0), opacity: 1.0, moving: false });
|
||||
// A click during a glide too.
|
||||
r.key_to(bar(250.0, 20.0));
|
||||
r.step(DT);
|
||||
r.click_to(bar(300.0, 300.0));
|
||||
assert_eq!(r.step(DT).bar, bar(300.0, 300.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
}
|
||||
|
||||
/// Word: the blink is a fade, and a move turns the caret on from however visible it was.
|
||||
#[test]
|
||||
fn the_blink_fades_and_a_move_turns_the_caret_on() {
|
||||
// On for 0.53 s, a 0.1 s fade out, off, a 0.1 s fade in.
|
||||
assert_eq!((blink(0.0, 0.1), blink(0.3, 0.1), blink(0.529, 0.1)), (1.0, 1.0, 1.0));
|
||||
assert!((blink(0.58, 0.1) - 0.5).abs() < 0.01);
|
||||
assert_eq!((blink(0.64, 0.1), blink(1.0, 0.1)), (0.0, 0.0));
|
||||
assert!((blink(1.06 + 0.025, 0.1) - 0.25).abs() < 0.01);
|
||||
assert_eq!(blink(1.3, 0.1), 1.0);
|
||||
assert!((blink(1.59 + 0.075, 0.1) - 0.25).abs() < 0.01);
|
||||
assert!(blink_fading(0.55, 0.1) && blink_fading(1.1, 0.1) && !blink_fading(0.3, 0.1) && !blink_fading(0.8, 0.1) && !blink_fading(0.05, 0.1));
|
||||
// With no fade it is the plain on-and-off blink.
|
||||
assert_eq!((blink(0.52, 0.0), blink(0.54, 0.0), blink(1.05, 0.0), blink(1.07, 0.0)), (1.0, 0.0, 0.0, 1.0));
|
||||
assert_eq!((blink(f64::NAN, 0.1), blink(-5.0, 0.1), blink(f64::INFINITY, 0.1)), (1.0, 1.0, 1.0));
|
||||
|
||||
// At rest the caret fades out and back in.
|
||||
let mut r = Rig::new(bar(100.0, 100.0));
|
||||
assert_eq!(r.rest(0.4).opacity, 1.0);
|
||||
let fading = r.rest(0.18).opacity;
|
||||
assert!(fading > 0.2 && fading < 0.8, "half-way through the fade out: {fading}");
|
||||
assert_eq!(r.rest(0.2).opacity, 0.0);
|
||||
// A key while it is off: it fades in over the glide, evenly.
|
||||
r.key_to(bar(200.0, 100.0));
|
||||
let mut seen = vec![r.step(DT).opacity];
|
||||
while r.glide.is_gliding() {
|
||||
seen.push(r.step(DT).opacity);
|
||||
}
|
||||
assert_eq!((seen[0], seen[seen.len() - 1]), (0.0, 1.0));
|
||||
assert!(seen.windows(2).all(|w| w[1] >= w[0]) && seen[1] > 0.1 && seen[2] > seen[1], "{seen:?}");
|
||||
assert!((seen[3] - 0.5).abs() < 0.02, "half lit half-way: {seen:?}");
|
||||
assert_eq!(r.step(DT).opacity, 1.0);
|
||||
// A click while it is off fades in too, in place.
|
||||
r.rest(0.8);
|
||||
assert_eq!(r.glide.opacity(), 0.0);
|
||||
r.click_to(bar(300.0, 200.0));
|
||||
let look = r.step(DT);
|
||||
assert_eq!((look.bar, look.opacity, look.moving), (bar(300.0, 200.0), 0.0, true));
|
||||
let look = r.rest(0.05);
|
||||
assert!(look.opacity > 0.3 && look.opacity < 0.7 && look.bar == bar(300.0, 200.0), "{look:?}");
|
||||
assert_eq!(r.rest(0.1).opacity, 1.0);
|
||||
// A script's move while it is off: lit at once.
|
||||
r.rest(0.7);
|
||||
assert_eq!(r.glide.opacity(), 0.0);
|
||||
r.script_to(bar(10.0, 10.0));
|
||||
assert_eq!(r.step(DT), Look { bar: bar(10.0, 10.0), opacity: 1.0, moving: false });
|
||||
// A key while it is half faded: on from there.
|
||||
r.rest(0.56);
|
||||
let half = r.glide.opacity();
|
||||
assert!(half > 0.1 && half < 0.9, "{half}");
|
||||
r.key_to(bar(60.0, 10.0));
|
||||
assert!((r.step(DT).opacity - half).abs() < 1e-6);
|
||||
assert!(r.step(DT).opacity > half);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_changes_and_scrolling_never_glide() {
|
||||
// Zoom or a resized window: the caret stays glued to its text.
|
||||
let mut r = Rig::new(bar(100.0, 100.0));
|
||||
r.f.target = bar(180.0, 150.0);
|
||||
r.f.view += 1;
|
||||
assert_eq!(r.step(DT).bar, bar(180.0, 150.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
// …even when a key press in the same frame also moved the caret.
|
||||
r.key_to(bar(40.0, 40.0));
|
||||
r.f.view += 1;
|
||||
assert_eq!(r.step(DT).bar, bar(40.0, 40.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
r.rest(0.2);
|
||||
// The target shifting with nothing edited (pixel snapping while scrolling) is followed.
|
||||
r.f.target = bar(40.4, 40.0);
|
||||
assert_eq!(r.step(DT).bar, bar(40.4, 40.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
// The same shift under a glide moves the whole glide; it doesn't restart it.
|
||||
r.key_to(bar(140.0, 40.0));
|
||||
r.step(DT);
|
||||
let before = r.step(DT).bar;
|
||||
r.f.target = bar(140.0, 40.5);
|
||||
let after = r.step(DT).bar;
|
||||
assert!(after[0].x > before[0].x && (after[0].y - 40.5).abs() < 1e-3, "{after:?}");
|
||||
r.rest(0.2);
|
||||
assert_eq!(r.glide.drawn(), Some(bar(140.0, 40.5)));
|
||||
// A view change during a glide ends it.
|
||||
r.key_to(bar(340.0, 40.5));
|
||||
r.step(DT);
|
||||
r.step(DT);
|
||||
r.f.view += 1;
|
||||
assert_eq!(r.step(DT).bar, bar(340.0, 40.5));
|
||||
assert!(!r.glide.is_gliding());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_stale_or_off_screen_caret_appears_in_place() {
|
||||
// Hidden (a selection, or the canvas lost focus), then back somewhere else.
|
||||
let mut r = Rig::new(bar(100.0, 100.0));
|
||||
r.f.shown = false;
|
||||
r.key_to(bar(300.0, 100.0));
|
||||
assert_eq!(r.step(DT).opacity, 0.0);
|
||||
assert_eq!(r.glide.drawn(), None);
|
||||
r.f.shown = true;
|
||||
r.key_to(bar(500.0, 100.0));
|
||||
let look = r.step(DT);
|
||||
assert_eq!(look.bar, bar(500.0, 100.0));
|
||||
assert!(!r.glide.is_gliding() && look.moving, "it fades in where it is");
|
||||
r.rest(0.2);
|
||||
// The canvas skipped frames (Backstage open): no glide from the stale place.
|
||||
r.f.frame_nr += 30;
|
||||
r.key_to(bar(200.0, 100.0));
|
||||
assert_eq!(r.step(DT).bar, bar(200.0, 100.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
r.rest(0.2);
|
||||
// A jump to somewhere off screen (Ctrl+End), and back from there.
|
||||
r.key_to(bar(200.0, 5000.0));
|
||||
assert_eq!(r.step(DT).bar, bar(200.0, 5000.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
r.key_to(bar(200.0, 100.0));
|
||||
assert_eq!(r.step(DT).bar, bar(200.0, 100.0));
|
||||
assert!(!r.glide.is_gliding());
|
||||
r.rest(0.2);
|
||||
// Just past the edge (the next line while the view scrolls to it) still glides.
|
||||
r.key_to(bar(200.0, 810.0));
|
||||
r.step(DT);
|
||||
assert!(r.glide.is_gliding());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_off_or_hostile_numbers_never_glide_or_panic() {
|
||||
// Off: it jumps, and blinks hard.
|
||||
let mut off = Rig::new(bar(0.0, 0.0));
|
||||
off.glide = CaretGlide::with_secs(0.0);
|
||||
off.step(DT);
|
||||
off.key_to(bar(90.0, 0.0));
|
||||
assert_eq!(off.step(DT), Look { bar: bar(90.0, 0.0), opacity: 1.0, moving: false });
|
||||
assert_eq!(off.rest(0.5).opacity, 1.0);
|
||||
assert_eq!(off.rest(0.06).opacity, 0.0);
|
||||
assert_eq!(off.rest(0.5).opacity, 1.0);
|
||||
|
||||
assert_eq!(CaretGlide::with_secs(f64::NAN).secs, GLIDE_SECS);
|
||||
assert_eq!(CaretGlide::with_secs(-3.0).secs, 0.0);
|
||||
assert_eq!(CaretGlide::with_secs(f64::INFINITY).secs, GLIDE_SECS);
|
||||
assert_eq!(parse_ms(None), GLIDE_SECS);
|
||||
assert_eq!(parse_ms(Some("junk")), GLIDE_SECS);
|
||||
assert_eq!(parse_ms(Some("NaN")), GLIDE_SECS);
|
||||
assert_eq!(parse_ms(Some(" 0 ")), 0.0);
|
||||
assert_eq!(parse_ms(Some("-5")), 0.0);
|
||||
assert_eq!(parse_ms(Some("250")), 0.25);
|
||||
assert_eq!(parse_ms(Some("1e300")), MAX_GLIDE_SECS);
|
||||
|
||||
let mut r = Rig::new(bar(10.0, 10.0));
|
||||
let junk = [pos2(f32::NAN, f32::INFINITY), pos2(f32::NEG_INFINITY, 0.0)];
|
||||
r.key_to(junk);
|
||||
assert_eq!(r.step(DT).opacity, 0.0);
|
||||
assert!(!r.glide.is_gliding() && r.glide.drawn().is_none());
|
||||
r.key_to(bar(50.0, 10.0));
|
||||
assert_eq!(r.step(DT).bar, bar(50.0, 10.0), "back from junk: in place");
|
||||
r.rest(0.2);
|
||||
// The clock stops, jumps back or goes bad mid-glide.
|
||||
r.key_to(bar(150.0, 10.0));
|
||||
r.step(DT);
|
||||
assert!(is_finite(r.step(0.0).bar));
|
||||
assert_eq!(r.step(-50.0).bar, bar(150.0, 10.0));
|
||||
r.key_to(bar(250.0, 10.0));
|
||||
r.step(DT);
|
||||
assert_eq!(r.step(f64::NAN).opacity, 0.0, "a bad clock: nothing is drawn that frame");
|
||||
r.f.now = 200.0;
|
||||
r.key_to(bar(350.0, 10.0));
|
||||
r.step(DT);
|
||||
r.step(f64::INFINITY);
|
||||
// A blink clock gone bad leaves the caret lit.
|
||||
let mut r = Rig::new(bar(0.0, 0.0));
|
||||
r.restarted = f64::NAN;
|
||||
let look = r.step(DT);
|
||||
assert!(look.opacity == 1.0 && is_finite(look.bar));
|
||||
r.restarted = f64::INFINITY;
|
||||
assert_eq!(r.step(DT).opacity, 1.0);
|
||||
// Coordinates at the edge of f32.
|
||||
let mut r = Rig::new(bar(0.0, 0.0));
|
||||
r.f.viewport = Rect::EVERYTHING;
|
||||
r.key_to(bar(f32::MAX, 0.0));
|
||||
r.step(DT);
|
||||
for _ in 0..12 {
|
||||
let look = r.step(DT);
|
||||
assert!((0.0..=1.0).contains(&look.opacity));
|
||||
}
|
||||
r.glide.reset();
|
||||
assert!(r.glide.drawn().is_none() && !r.glide.is_gliding() && r.glide.opacity() == 0.0);
|
||||
}
|
||||
|
||||
fn key(key: egui::Key) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// The whole path: a key in the real app glides the drawn caret across the canvas; a command
|
||||
/// run by a script and a change of zoom don't.
|
||||
#[test]
|
||||
fn the_canvas_caret_glides_after_a_key() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||||
a.run("text.insert", json!({"text": "Some text here"})).unwrap();
|
||||
// Animation effects off: the caret follows that switch.
|
||||
a.run("ui.animations", json!({"value": "off"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).with_step_dt(DT as f32).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
assert!(state(&h.ctx).drawn().is_none(), "no animations: the glide stands down");
|
||||
h.state_mut().run("ui.animations", json!({"value": "on"})).unwrap();
|
||||
h.ctx.all_styles_mut(|s| s.animation_time = 0.1);
|
||||
for _ in 0..30 {
|
||||
h.step();
|
||||
}
|
||||
let near = |a: Bar, b: Bar| distance(a, b) < 0.01;
|
||||
let end = state(&h.ctx).drawn().expect("the focused canvas draws a caret");
|
||||
assert!(!state(&h.ctx).is_gliding());
|
||||
// Home: the caret glides to the start of the line.
|
||||
// (The canvas reads the key at the end of its frame; the caret sets off in the next.)
|
||||
h.event(key(egui::Key::Home));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
}
|
||||
let mid = state(&h.ctx);
|
||||
assert!(mid.is_gliding(), "the caret is on its way");
|
||||
let x = mid.drawn().unwrap()[0].x;
|
||||
for _ in 0..20 {
|
||||
h.step();
|
||||
}
|
||||
let start = state(&h.ctx).drawn().unwrap();
|
||||
assert!(!state(&h.ctx).is_gliding(), "and comes to rest");
|
||||
assert_eq!(h.state().session.sel.focus.off, 0, "Home moved the caret");
|
||||
assert!(start[0].x + 20.0 < end[0].x, "to the start of the line: {start:?} from {end:?}");
|
||||
assert!(x > start[0].x && x < end[0].x, "on the way it was drawn in between: {x}");
|
||||
// A command run by a script puts it there at once.
|
||||
h.state_mut().run("caret.docEnd", json!({})).unwrap();
|
||||
h.step();
|
||||
assert!(!state(&h.ctx).is_gliding());
|
||||
assert!(near(state(&h.ctx).drawn().unwrap(), end), "back at the end of the text");
|
||||
// Zooming moves the caret on screen but doesn't glide it.
|
||||
h.state_mut().run("view.zoom", json!({"value": 150})).unwrap();
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
assert!(!state(&h.ctx).is_gliding());
|
||||
}
|
||||
assert!(!near(state(&h.ctx).drawn().unwrap(), end));
|
||||
// Turned off in Options: the caret jumps.
|
||||
h.state_mut().run("ui.caretAnimation", json!({"value": false})).unwrap();
|
||||
h.step();
|
||||
assert!(state(&h.ctx).drawn().is_none(), "the animation stands down");
|
||||
h.event(key(egui::Key::Home));
|
||||
for _ in 0..3 {
|
||||
h.step();
|
||||
assert!(!state(&h.ctx).is_gliding());
|
||||
}
|
||||
assert_eq!(h.state().session.sel.focus.off, 0, "Home still moved the caret");
|
||||
// And on again.
|
||||
h.state_mut().run("ui.caretAnimation", json!({"value": true})).unwrap();
|
||||
h.step();
|
||||
assert!(state(&h.ctx).drawn().is_some());
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
|
||||
let (fill, stroke, knob_fill) = match (&block, on) {
|
||||
(Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)),
|
||||
(None, true) => (t.accent, t.accent, egui::Color32::WHITE),
|
||||
(None, true) => (t.accent, t.accent, t.on_accent),
|
||||
(None, false) => (t.input, t.border_strong, t.text_dim),
|
||||
};
|
||||
ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
|
||||
@@ -161,7 +161,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);
|
||||
let initials: String = app.session.user_initials().chars().take(2).collect();
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), egui::Color32::WHITE);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent);
|
||||
resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author));
|
||||
ui.add_space(8.0);
|
||||
if app.ui.show_discord {
|
||||
|
||||
@@ -1242,35 +1242,41 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the open dialog's top edge sits, as (window id, offset below the top of the screen).
|
||||
/// The open dialog, as (window id, frames it has been centred for).
|
||||
fn dialog_top_key() -> egui::Id {
|
||||
egui::Id::new("dialog_top")
|
||||
}
|
||||
|
||||
/// Anchors a dialog: centred on the frame it opens, then with its top edge held where that put
|
||||
/// it, so a taller or shorter tab (Paragraph, Font, Borders and Shading…) only moves the bottom
|
||||
/// edge and the tab strip stays under the mouse (#507). egui keeps it on screen if it grows past
|
||||
/// the bottom.
|
||||
/// Frames a dialog stays centred: the first one measures it, the second centres that size.
|
||||
const DIALOG_CENTRED_FRAMES: u8 = 2;
|
||||
|
||||
/// Places a dialog: centred for its first frames, then left where it is with its top-centre point
|
||||
/// as the pivot, so a taller or shorter tab (Paragraph, Font, Borders and Shading…) only moves
|
||||
/// the bottom edge and the tab strip stays under the mouse (#507). It is no longer anchored after
|
||||
/// that, so dragging the title bar moves it (#732). egui keeps it on screen if it grows past the
|
||||
/// bottom.
|
||||
fn place_dialog<'a>(ctx: &egui::Context, id: egui::Id, w: egui::Window<'a>) -> egui::Window<'a> {
|
||||
match ctx.data(|d| d.get_temp::<(egui::Id, f32)>(dialog_top_key())) {
|
||||
Some((open, top)) if open == id => w.anchor(egui::Align2::CENTER_TOP, vec2(0.0, top)),
|
||||
let w = w.pivot(egui::Align2::CENTER_TOP);
|
||||
match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) {
|
||||
Some((open, frames)) if open == id && frames >= DIALOG_CENTRED_FRAMES => w,
|
||||
_ => w.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// After a dialog's first frame: holds its top edge where centring its measured size put it.
|
||||
fn remember_dialog_top(ctx: &egui::Context, id: egui::Id, rect: egui::Rect) {
|
||||
if ctx.data(|d| d.get_temp::<(egui::Id, f32)>(dialog_top_key())).is_some_and(|(open, _)| open == id) {
|
||||
return;
|
||||
/// After a frame of a dialog: counts the frames it has been centred for.
|
||||
fn count_dialog_frame(ctx: &egui::Context, id: egui::Id) {
|
||||
let frames = match ctx.data(|d| d.get_temp::<(egui::Id, u8)>(dialog_top_key())) {
|
||||
Some((open, frames)) if open == id => frames,
|
||||
_ => 0,
|
||||
};
|
||||
if frames < DIALOG_CENTRED_FRAMES {
|
||||
ctx.data_mut(|d| d.insert_temp(dialog_top_key(), (id, frames + 1)));
|
||||
}
|
||||
let screen = ctx.content_rect();
|
||||
let top = (screen.center().y - 40.0 - rect.height() / 2.0 - screen.top()).max(0.0);
|
||||
ctx.data_mut(|d| d.insert_temp(dialog_top_key(), (id, top)));
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else {
|
||||
ctx.data_mut(|d| d.remove::<(egui::Id, f32)>(dialog_top_key()));
|
||||
ctx.data_mut(|d| d.remove::<(egui::Id, u8)>(dialog_top_key()));
|
||||
return;
|
||||
};
|
||||
let mut open = true;
|
||||
@@ -1333,8 +1339,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
ui.set_min_width(340.0);
|
||||
close = body(app, ui, &mut d);
|
||||
});
|
||||
if let Some(r) = shown {
|
||||
remember_dialog_top(ctx, id, r.response.rect);
|
||||
if shown.is_some() {
|
||||
count_dialog_frame(ctx, id);
|
||||
}
|
||||
if open && !close {
|
||||
app.dialog = Some(d);
|
||||
@@ -3414,4 +3420,43 @@ mod tests {
|
||||
let c = app.session.doc.para_at(&wordcraft_doc::Pos::body(0, 0)).map(|p| p.props_of_char(0).clone()).unwrap_or_default();
|
||||
assert_eq!((c.small_caps, c.caps, c.size), (Some(false), Some(true), Some(14.0)));
|
||||
}
|
||||
|
||||
/// A dialog was pinned by its anchor, so dragging its title bar did nothing (#732).
|
||||
#[test]
|
||||
fn dragging_a_dialog_title_bar_moves_it() {
|
||||
let mut app = app_with("Some text");
|
||||
app.dialog = Dialog::open("paragraph", &mut app);
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
let id = egui::Id::new(("dialog", "Paragraph"));
|
||||
let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open");
|
||||
let screen = h.ctx.content_rect();
|
||||
assert!((before.center().x - screen.center().x).abs() <= 1.0, "opens centred: {before:?} in {screen:?}");
|
||||
let grab = egui::pos2(before.center().x, before.top() + 10.0);
|
||||
let drop = grab + vec2(-200.0, 120.0);
|
||||
let button =
|
||||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
h.event(egui::Event::PointerMoved(grab));
|
||||
h.step();
|
||||
h.event(button(grab, true));
|
||||
h.step();
|
||||
h.event(egui::Event::PointerMoved(drop));
|
||||
h.step();
|
||||
h.event(button(drop, false));
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
let after = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is still open");
|
||||
assert!((after.min.x - (before.min.x - 200.0)).abs() <= 1.0, "moved left: {before:?} -> {after:?}");
|
||||
assert!((after.min.y - (before.min.y + 120.0)).abs() <= 1.0, "moved down: {before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ fn run(app: &mut WordApp, id: &str, params: Value) -> bool {
|
||||
}
|
||||
|
||||
fn unit() -> wordcraft_geom::Unit {
|
||||
wordcraft_geom::Unit::default()
|
||||
crate::options::unit()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -682,6 +682,29 @@ mod tests {
|
||||
assert!((s.hyphenation_zone - 18.0).abs() < 0.01, "zone untouched: {}", s.hyphenation_zone);
|
||||
}
|
||||
|
||||
/// #755: the Line Numbers "From text" distance and the Hyphenation zone follow the
|
||||
/// File › Options unit (centimeters here); `params` sends points whatever the unit.
|
||||
#[test]
|
||||
fn line_numbers_and_hyphenation_measure_in_the_options_unit() {
|
||||
let mut a = app();
|
||||
a.run("file.options", json!({"units": "centimeters"})).unwrap();
|
||||
a.run("layout.lineNumbers", json!({"value": "restartPage", "start": 1, "countBy": 1, "distance": 18.0})).unwrap();
|
||||
a.run("ui.dialog", json!({"name": "lineNumbers"})).unwrap();
|
||||
let LayoutDialog::LineNumbers(f) = form(&mut a) else { panic!("line numbers") };
|
||||
assert!(!f.auto_distance);
|
||||
let cm = 18.0 / wordcraft_geom::PT_PER_CM;
|
||||
assert!((f.distance - cm).abs() < 0.01, "distance was {} cm", f.distance);
|
||||
let sent = f.params()["distance"].as_f64().unwrap_or_default();
|
||||
assert!((sent - 18.0).abs() < 0.05, "params sent {sent} pt");
|
||||
|
||||
a.run("layout.hyphenation", json!({"value": true, "zone": 36.0})).unwrap();
|
||||
a.run("ui.dialog", json!({"name": "hyphenation"})).unwrap();
|
||||
let LayoutDialog::Hyphenation(h) = form(&mut a) else { panic!("hyphenation") };
|
||||
assert!((h.zone - 36.0 / wordcraft_geom::PT_PER_CM).abs() < 0.01, "zone was {} cm", h.zone);
|
||||
let sent = h.params()["zone"].as_f64().unwrap_or_default();
|
||||
assert!((sent - 36.0).abs() < 0.05, "params sent {sent} pt");
|
||||
}
|
||||
|
||||
/// #407: the Language button opens the dialog (scripts with a language never see it); OK
|
||||
/// marks the selection and "do not check", Set As Default changes the document default.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
//! The built-in file browser (#576): Open, Save As, Export, Insert Picture and recipient lists
|
||||
//! when the system's file dialog can't be shown — a Linux desktop with neither a file chooser
|
||||
//! portal nor zenity (labwc, sway… with only xdg-desktop-portal-wlr) — or when the user asks for
|
||||
//! it (File › Open › Browse in WordCraft…).
|
||||
//!
|
||||
//! A folder path field with Up and Home folder, the folder's subfolders and the files of the
|
||||
//! chosen type, a file name field and the file types. It only lists folders the user navigates to
|
||||
//! and never reads a file: the picked path goes to the same follow-up as the system dialog's.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use egui::{Align2, Rect, Sense, Ui, pos2, vec2};
|
||||
|
||||
use crate::file_dialogs::{AfterPick, FileDialogRequest, open_filters};
|
||||
use crate::theme::{APP_COLOR, Tokens, regular};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
/// At most this many entries are listed; a bigger folder shows the first ones and says so.
|
||||
const MAX_LISTED: usize = 5000;
|
||||
/// At most this many folder entries are read, so a huge folder can't stall the window.
|
||||
const MAX_SCANNED: usize = 100_000;
|
||||
const ROW: f32 = 24.0;
|
||||
|
||||
/// The browser while it is open, and the folder it showed last (where it opens next time).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
pub(crate) open: Option<Box<FileBrowser>>,
|
||||
last_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// One subfolder or file in the listing.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Entry {
|
||||
/// The name as shown (a name that isn't valid UTF-8 is shown with replacement characters).
|
||||
pub(crate) name: String,
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) dir: bool,
|
||||
}
|
||||
|
||||
/// A folder's subfolders (first) and files, by name.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Listing {
|
||||
pub(crate) entries: Vec<Entry>,
|
||||
/// The folder holds more than was listed.
|
||||
pub(crate) truncated: bool,
|
||||
}
|
||||
|
||||
/// Whether `path` has one of `exts` (case-insensitive); `*` takes any file.
|
||||
pub(crate) fn matches(path: &Path, exts: &[String]) -> bool {
|
||||
exts.iter().any(|e| e == "*") || path.extension().and_then(|e| e.to_str()).is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// The subfolders and the files with one of `exts` in `dir`, at most `cap` of them, folders first
|
||||
/// and each by name. Hidden entries (a leading dot) are left out; entries that can't be read are
|
||||
/// skipped.
|
||||
pub(crate) fn list_dir(dir: &Path, exts: &[String], cap: usize) -> std::io::Result<Listing> {
|
||||
let mut listing = Listing::default();
|
||||
for (i, item) in std::fs::read_dir(dir)?.enumerate() {
|
||||
if i >= MAX_SCANNED {
|
||||
listing.truncated = true;
|
||||
break;
|
||||
}
|
||||
let Ok(item) = item else { continue };
|
||||
let name = item.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let path = item.path();
|
||||
// A symbolic link to a folder is a folder.
|
||||
let is_dir = match item.file_type() {
|
||||
Ok(t) if t.is_symlink() => std::fs::metadata(&path).is_ok_and(|m| m.is_dir()),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !is_dir && !matches(&path, exts) {
|
||||
continue;
|
||||
}
|
||||
if listing.entries.len() >= cap {
|
||||
listing.truncated = true;
|
||||
break;
|
||||
}
|
||||
listing.entries.push(Entry { name, path, dir: is_dir });
|
||||
}
|
||||
listing.entries.sort_by_cached_key(|e| (!e.dir, e.name.to_lowercase()));
|
||||
Ok(listing)
|
||||
}
|
||||
|
||||
/// The user's home folder.
|
||||
pub(crate) fn home() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).filter(|h| !h.is_empty()).map(PathBuf::from)
|
||||
}
|
||||
|
||||
/// What the user typed in the file name field, as a path: relative to `dir`, absolute, or under
|
||||
/// the home folder (`~/…`). `None` when nothing was typed.
|
||||
pub(crate) fn resolve(dir: &Path, typed: &str) -> Option<PathBuf> {
|
||||
let typed = typed.trim();
|
||||
if typed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if typed == "~" {
|
||||
return home();
|
||||
}
|
||||
if let Some(rest) = typed.strip_prefix("~/") {
|
||||
return home().map(|h| h.join(rest));
|
||||
}
|
||||
Some(dir.join(typed))
|
||||
}
|
||||
|
||||
/// What OK (or Enter, or a double click) did.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum Step {
|
||||
/// The browser stays open (it moved to a folder, or has something to say).
|
||||
Stay,
|
||||
/// The file to open or save to.
|
||||
Pick(PathBuf),
|
||||
}
|
||||
|
||||
/// A message under the listing.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Note {
|
||||
None,
|
||||
NotFound(String),
|
||||
Replace(PathBuf),
|
||||
}
|
||||
|
||||
/// The open browser.
|
||||
pub(crate) struct FileBrowser {
|
||||
req: FileDialogRequest,
|
||||
pub(crate) after: AfterPick,
|
||||
/// Opened because the system dialog couldn't be shown: say why, and how to get it.
|
||||
unavailable: bool,
|
||||
dir: PathBuf,
|
||||
/// The folder field (edited by the user, applied on Enter).
|
||||
location: String,
|
||||
/// The file name field.
|
||||
pub(crate) name: String,
|
||||
/// The file types, as (label, extensions).
|
||||
filters: Vec<(String, Vec<String>)>,
|
||||
filter: usize,
|
||||
listing: Result<Listing, String>,
|
||||
/// The highlighted entry.
|
||||
selected: Option<PathBuf>,
|
||||
note: Note,
|
||||
focus_name: bool,
|
||||
}
|
||||
|
||||
impl FileBrowser {
|
||||
pub(crate) fn new(req: FileDialogRequest, after: AfterPick, start: PathBuf, unavailable: bool) -> Self {
|
||||
let (name, filters) = match &req {
|
||||
FileDialogRequest::Open { purpose } => {
|
||||
(String::new(), open_filters(purpose).iter().map(|(l, e)| ((*l).to_string(), e.iter().map(|x| (*x).to_string()).collect())).collect())
|
||||
}
|
||||
FileDialogRequest::Save { name } => {
|
||||
let ext = Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty());
|
||||
let mut f: Vec<(String, Vec<String>)> = ext.map(|e| (format!("*.{e}"), vec![e.to_string()])).into_iter().collect();
|
||||
f.push(("All files".into(), vec!["*".into()]));
|
||||
(name.clone(), f)
|
||||
}
|
||||
};
|
||||
let mut b = FileBrowser {
|
||||
req,
|
||||
after,
|
||||
unavailable,
|
||||
dir: start.clone(),
|
||||
location: String::new(),
|
||||
name,
|
||||
filters,
|
||||
filter: 0,
|
||||
listing: Ok(Listing::default()),
|
||||
selected: None,
|
||||
note: Note::None,
|
||||
focus_name: true,
|
||||
};
|
||||
b.navigate(start);
|
||||
b
|
||||
}
|
||||
|
||||
fn exts(&self) -> &[String] {
|
||||
self.filters.get(self.filter).map_or(&[], |(_, e)| e.as_slice())
|
||||
}
|
||||
|
||||
/// Show the folder `dir` (an unreadable folder shows why, and Up still works).
|
||||
pub(crate) fn navigate(&mut self, dir: PathBuf) {
|
||||
self.listing = list_dir(&dir, self.exts(), MAX_LISTED).map_err(|e| e.to_string());
|
||||
self.location = dir.to_string_lossy().into_owned();
|
||||
self.dir = dir;
|
||||
self.selected = None;
|
||||
self.note = Note::None;
|
||||
}
|
||||
|
||||
fn up(&mut self) {
|
||||
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
|
||||
self.navigate(parent);
|
||||
}
|
||||
}
|
||||
|
||||
/// OK: a highlighted folder, or a typed folder, is opened; otherwise the typed name is the
|
||||
/// pick. Save adds the chosen type's extension to a name without one and asks once more
|
||||
/// before replacing a file; Open wants a file that exists.
|
||||
pub(crate) fn accept(&mut self) -> Step {
|
||||
if let Some(dir) = self.selected.take().filter(|p| p.is_dir()) {
|
||||
self.navigate(dir);
|
||||
return Step::Stay;
|
||||
}
|
||||
let Some(mut path) = resolve(&self.dir, &self.name) else { return Step::Stay };
|
||||
if path.is_dir() {
|
||||
self.name.clear();
|
||||
self.navigate(path);
|
||||
return Step::Stay;
|
||||
}
|
||||
match self.req {
|
||||
FileDialogRequest::Open { .. } => {
|
||||
if path.is_file() {
|
||||
return Step::Pick(path);
|
||||
}
|
||||
self.note = Note::NotFound(self.name.trim().to_string());
|
||||
Step::Stay
|
||||
}
|
||||
FileDialogRequest::Save { .. } => {
|
||||
if path.extension().is_none()
|
||||
&& let Some(ext) = self.exts().first().filter(|e| e.as_str() != "*")
|
||||
{
|
||||
path.set_extension(ext);
|
||||
}
|
||||
if path.exists() && self.note != Note::Replace(path.clone()) {
|
||||
self.note = Note::Replace(path);
|
||||
return Step::Stay;
|
||||
}
|
||||
Step::Pick(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on an entry: highlight it, and a file's name goes into the file name field.
|
||||
fn select(&mut self, e: &Entry) {
|
||||
self.selected = Some(e.path.clone());
|
||||
if !e.dir {
|
||||
self.name = e.name.clone();
|
||||
}
|
||||
self.note = Note::None;
|
||||
}
|
||||
|
||||
fn title(&self) -> &'static str {
|
||||
match self.req {
|
||||
FileDialogRequest::Open { .. } => "Open",
|
||||
FileDialogRequest::Save { .. } => "Save As",
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_label(&self) -> &'static str {
|
||||
match self.req {
|
||||
FileDialogRequest::Open { .. } => "Open",
|
||||
FileDialogRequest::Save { .. } => "Save",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WordApp {
|
||||
/// Ask for a file in the built-in browser and do `after` with the answer when it closes.
|
||||
pub(crate) fn show_file_browser(&mut self, req: FileDialogRequest, after: AfterPick, unavailable: bool) {
|
||||
let start = self
|
||||
.file_browser
|
||||
.last_dir
|
||||
.clone()
|
||||
.filter(|d| d.is_dir())
|
||||
.or_else(|| self.session.path.as_ref().and_then(|p| p.parent()).map(Path::to_path_buf).filter(|d| d.is_dir()))
|
||||
.or_else(home)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("/"));
|
||||
self.file_browser.open = Some(Box::new(FileBrowser::new(req, after, start, unavailable)));
|
||||
}
|
||||
|
||||
/// `ui.fileBrowser`: the built-in browser for `purpose` (document, picture, recipients, save),
|
||||
/// even when a system dialog was asked for and never showed up (it is given up as cancelled).
|
||||
pub(crate) fn browse_builtin(&mut self, purpose: &str) {
|
||||
self.give_up_file_dialog();
|
||||
if self.file_browser.open.is_some() {
|
||||
return;
|
||||
}
|
||||
let (req, after) = match purpose {
|
||||
"picture" => (FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture),
|
||||
"recipients" => (FileDialogRequest::Open { purpose: "recipients".into() }, AfterPick::Recipients),
|
||||
"save" => (FileDialogRequest::Save { name: self.default_save_name() }, AfterPick::SaveAs(crate::file_dialogs::AfterSave::Nothing)),
|
||||
_ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument),
|
||||
};
|
||||
self.show_file_browser(req, after, false);
|
||||
}
|
||||
|
||||
/// Close the browser with this answer (`None`: cancelled) and finish what it was for.
|
||||
pub(crate) fn close_file_browser(&mut self, picked: Option<PathBuf>) {
|
||||
let Some(b) = self.file_browser.open.take() else { return };
|
||||
self.file_browser.last_dir = Some(b.dir.clone());
|
||||
let picked = picked.map(|p| p.to_string_lossy().into_owned());
|
||||
if let Err(e) = self.finish_pick(b.after, picked) {
|
||||
log::warn!("file browser: {e}");
|
||||
}
|
||||
self.canvas.want_focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the browser, if it is open.
|
||||
pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let Some(mut b) = app.file_browser.open.take() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
let mut done: Option<Option<PathBuf>> = None;
|
||||
egui::Window::new(tl!(b.title()))
|
||||
.id(egui::Id::new("builtin_file_browser"))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0))
|
||||
.open(&mut open)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(620.0);
|
||||
done = body(ui, &mut b, &t);
|
||||
});
|
||||
if !open {
|
||||
done = Some(None);
|
||||
}
|
||||
app.file_browser.open = Some(b);
|
||||
if let Some(picked) = done {
|
||||
app.close_file_browser(picked);
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's contents; `Some` once it closes (`Some(None)`: cancelled).
|
||||
fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>> {
|
||||
if b.unavailable {
|
||||
ui.label(
|
||||
egui::RichText::new(tl!(
|
||||
"The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity."
|
||||
))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
let mut location_done = false;
|
||||
ui.horizontal(|ui| {
|
||||
if icon_button(ui, t, "chevronUp", tl!("Up one level"), b.dir.parent().is_some()) {
|
||||
b.up();
|
||||
}
|
||||
if icon_button(ui, t, "homeFolder", tl!("Home folder"), true)
|
||||
&& let Some(h) = home()
|
||||
{
|
||||
b.navigate(h);
|
||||
}
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut b.location).desired_width(f32::INFINITY));
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
location_done = true;
|
||||
let typed = b.location.clone();
|
||||
if let Some(dir) = resolve(&b.dir, &typed) {
|
||||
b.navigate(dir);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let mut pick = false;
|
||||
let mut go: Option<PathBuf> = None;
|
||||
let mut clicked: Option<Entry> = None;
|
||||
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
|
||||
let entries: &[Entry] = b.listing.as_ref().map(|l| l.entries.as_slice()).unwrap_or(&[]);
|
||||
egui::ScrollArea::vertical().max_height(300.0).min_scrolled_height(300.0).auto_shrink([false, false]).show_rows(
|
||||
ui,
|
||||
ROW,
|
||||
entries.len(),
|
||||
|ui, range| {
|
||||
for e in entries.get(range).unwrap_or(&[]) {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click());
|
||||
if b.selected.as_ref() == Some(&e.path) {
|
||||
ui.painter().rect_filled(r, 2.0, t.checked);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
let icon = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(18.0, 18.0));
|
||||
icons::paint(ui.painter(), icon, if e.dir { "folder" } else { "file" }, t.icon, t.accent);
|
||||
ui.painter().with_clip_rect(r).text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &e.name, regular(13.0), t.text);
|
||||
if resp.double_clicked() {
|
||||
if e.dir {
|
||||
go = Some(e.path.clone());
|
||||
} else {
|
||||
clicked = Some(e.clone());
|
||||
pick = true;
|
||||
}
|
||||
} else if resp.clicked() {
|
||||
clicked = Some(e.clone());
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
if let Some(e) = clicked {
|
||||
b.select(&e);
|
||||
}
|
||||
if let Some(dir) = go {
|
||||
b.navigate(dir);
|
||||
}
|
||||
let message = match (&b.listing, &b.note) {
|
||||
(_, Note::NotFound(name)) => Some((crate::i18n::fmt(tl!("{name} wasn't found."), &[("name", name)]), t.red)),
|
||||
(_, Note::Replace(p)) => {
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Some((crate::i18n::fmt(tl!("{name} already exists. Choose Save again to replace it."), &[("name", &name)]), t.red))
|
||||
}
|
||||
(Err(e), _) => Some((crate::i18n::fmt(tl!("This folder can't be read: {error}"), &[("error", e)]), t.red)),
|
||||
(Ok(l), _) if l.truncated => {
|
||||
Some((crate::i18n::fmt(tl!("Showing the first {count} items."), &[("count", &l.entries.len().to_string())]), t.text_dim))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((text, color)) = message {
|
||||
ui.label(egui::RichText::new(text).color(color));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let mut refilter = false;
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("File name:"));
|
||||
// The name field takes what the label (longer in some languages) and the type list leave.
|
||||
let width = (ui.available_width() - 170.0).max(120.0);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut b.name).desired_width(width)).labelled_by(label.id);
|
||||
if b.focus_name {
|
||||
r.request_focus();
|
||||
b.focus_name = false;
|
||||
}
|
||||
if r.changed() {
|
||||
b.selected = None;
|
||||
b.note = Note::None;
|
||||
}
|
||||
let before = b.filter;
|
||||
let current = b.filters.get(b.filter).map(|(l, _)| tl!(l.as_str()).to_string()).unwrap_or_default();
|
||||
egui::ComboBox::from_id_salt("builtin_file_browser_type").selected_text(current).width(150.0).show_ui(ui, |ui| {
|
||||
for (i, (l, _)) in b.filters.iter().enumerate() {
|
||||
ui.selectable_value(&mut b.filter, i, tl!(l.as_str()));
|
||||
}
|
||||
});
|
||||
refilter = b.filter != before;
|
||||
});
|
||||
if refilter {
|
||||
b.navigate(b.dir.clone());
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let enter = !location_done && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
cancel |= ui.button(tl!("Cancel")).clicked();
|
||||
let ok = ui.add(egui::Button::new(egui::RichText::new(tl!(b.ok_label())).color(egui::Color32::WHITE)).fill(APP_COLOR));
|
||||
pick |= ok.clicked() || enter;
|
||||
});
|
||||
});
|
||||
if cancel {
|
||||
return Some(None);
|
||||
}
|
||||
if pick && let Step::Pick(path) = b.accept() {
|
||||
return Some(Some(path));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A small button with a drawn icon and a tooltip; true when clicked.
|
||||
fn icon_button(ui: &mut Ui, t: &Tokens, icon: &str, tip: &str, enabled: bool) -> bool {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(28.0, 24.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
if enabled && resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let c = if enabled { t.icon } else { t.text_disabled };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(18.0, 18.0)), icon, c, if enabled { t.accent } else { c });
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
use serde_json::json;
|
||||
use std::sync::mpsc::channel;
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-ui-browser-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn exts(e: &[&str]) -> Vec<String> {
|
||||
e.iter().map(|x| x.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Folders come first, then the files of the chosen type (any case), each by name; hidden
|
||||
/// entries and other types are left out, and a big folder is cut off and says so.
|
||||
#[test]
|
||||
fn listing_filters_sorts_and_caps() {
|
||||
let dir = scratch("list");
|
||||
for f in ["b.docx", "A.DOCX", "notes.txt", ".hidden.docx", "c.docx"] {
|
||||
std::fs::write(dir.join(f), b"x").unwrap();
|
||||
}
|
||||
std::fs::create_dir(dir.join("zeta")).unwrap();
|
||||
std::fs::create_dir(dir.join(".git")).unwrap();
|
||||
let l = list_dir(&dir, &exts(&["docx"]), 100).unwrap();
|
||||
let names: Vec<&str> = l.entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["zeta", "A.DOCX", "b.docx", "c.docx"]);
|
||||
assert!(!l.truncated);
|
||||
assert_eq!(list_dir(&dir, &exts(&["*"]), 100).unwrap().entries.len(), 5);
|
||||
let capped = list_dir(&dir, &exts(&["docx"]), 2).unwrap();
|
||||
assert_eq!(capped.entries.len(), 2);
|
||||
assert!(capped.truncated);
|
||||
assert!(list_dir(&dir.join("missing"), &exts(&["*"]), 10).is_err(), "an unreadable folder is an error, not a panic");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Save As in the browser: a typed folder is opened, a bare name gets the type's extension,
|
||||
/// and an existing file is replaced only on the second OK.
|
||||
#[test]
|
||||
fn save_adds_the_extension_and_asks_before_replacing() {
|
||||
let dir = scratch("save");
|
||||
std::fs::create_dir(dir.join("drafts")).unwrap();
|
||||
std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap();
|
||||
let req = FileDialogRequest::Save { name: "Document1.docx".into() };
|
||||
let mut b = FileBrowser::new(req, AfterPick::OpenDocument, dir.clone(), false);
|
||||
b.name = "drafts".into();
|
||||
assert_eq!(b.accept(), Step::Stay);
|
||||
assert_eq!(b.dir, dir.join("drafts"));
|
||||
b.name = "chapter".into();
|
||||
assert_eq!(b.accept(), Step::Pick(dir.join("drafts").join("chapter.docx")));
|
||||
b.name = "old".into();
|
||||
assert_eq!(b.accept(), Step::Stay, "asks before replacing");
|
||||
assert_eq!(b.accept(), Step::Pick(dir.join("drafts").join("old.docx")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The host can't show a system dialog (#576): Save As carries on in the built-in browser,
|
||||
/// and its answer saves the document.
|
||||
#[test]
|
||||
fn no_system_dialog_falls_back_to_the_built_in_browser() {
|
||||
let dir = scratch("fallback");
|
||||
let services = Services {
|
||||
file_dialog: Some(Box::new(|_req| {
|
||||
let (tx, rx) = channel();
|
||||
tx.send(Err("no portal".to_string())).unwrap();
|
||||
rx
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
|
||||
a.run("text.insert", json!({"text": "Saved without a portal"})).unwrap();
|
||||
a.save_as_dialog();
|
||||
assert!(a.file_dialog_open());
|
||||
let b = a.file_browser.open.as_mut().expect("the built-in browser is open");
|
||||
assert!(b.unavailable);
|
||||
b.navigate(dir.clone());
|
||||
b.name = "novel".into();
|
||||
let Step::Pick(path) = b.accept() else { panic!("picked") };
|
||||
a.close_file_browser(Some(path));
|
||||
assert!(!a.file_dialog_open());
|
||||
assert!(!a.session.dirty);
|
||||
let saved = wordcraft_engine::io::open_path(&dir.join("novel.docx")).unwrap();
|
||||
assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("Saved without a portal"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
//! pending New/Open/Close, export, insert the picture). A host with only the blocking
|
||||
//! `pick_open` / `pick_save` gets the same follow-up straight away. One dialog at a time: asking
|
||||
//! for another while one is open does nothing.
|
||||
//!
|
||||
//! When the host can't show a system dialog at all (a Linux desktop with neither a file chooser
|
||||
//! portal nor zenity, #576) it answers with an error, and the app finishes the job in its own
|
||||
//! [`file_browser`](crate::file_browser) instead.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
@@ -16,13 +20,32 @@ use crate::WordApp;
|
||||
/// A file dialog the app asks the host to show ([`Services::file_dialog`](crate::Services::file_dialog)).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileDialogRequest {
|
||||
/// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`), as for
|
||||
/// Pick a file to open; `purpose` says what for (`document`, `picture`, `recipients`, `font`), as for
|
||||
/// `pick_open`.
|
||||
Open { purpose: String },
|
||||
/// Pick a path to save to, given a suggested file name.
|
||||
Save { name: String },
|
||||
}
|
||||
|
||||
/// The host's answer to a [`FileDialogRequest`]: the picked path, `Ok(None)` when the user
|
||||
/// cancelled, or an error when no system dialog could be shown (the app then shows its own).
|
||||
pub type FileDialogAnswer = Result<Option<String>, String>;
|
||||
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
|
||||
/// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change
|
||||
/// Picture, recipient lists for Select Recipients › Use an Existing List…, documents otherwise.
|
||||
/// The host's system dialog and the built-in browser offer the same.
|
||||
pub fn open_filters(purpose: &str) -> &'static [(&'static str, &'static [&'static str])] {
|
||||
match purpose {
|
||||
"picture" => &[("Pictures", PICTURES)],
|
||||
"font" => &[("Fonts", wordcraft_fonts::FONT_FILE_EXTS)],
|
||||
"recipients" => &[("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]), ("All files", &["*"])],
|
||||
_ => &[("Documents", DOCUMENTS), ("Word document", &["docx"]), ("All files", &["*"])],
|
||||
}
|
||||
}
|
||||
|
||||
/// What to do with the path picked in a dialog.
|
||||
pub(crate) enum AfterPick {
|
||||
/// Open the picked document.
|
||||
@@ -37,6 +60,8 @@ pub(crate) enum AfterPick {
|
||||
PrintPdf { settings: Value },
|
||||
/// Load the picked CSV/TSV/text file or spreadsheet as the mail-merge recipients (#240, #334).
|
||||
Recipients,
|
||||
/// Add the picked font file (#582).
|
||||
Font,
|
||||
/// Hand the picked path to the open dialog (`WordApp::dialog_pick`): Templates and Add-ins,
|
||||
/// Organizer (#377).
|
||||
ForDialog,
|
||||
@@ -60,7 +85,8 @@ pub(crate) enum AfterSave {
|
||||
|
||||
/// A dialog the host is showing; the answer arrives on `rx` (`None`: cancelled).
|
||||
pub(crate) struct PendingDialog {
|
||||
rx: Receiver<Option<String>>,
|
||||
rx: Receiver<FileDialogAnswer>,
|
||||
req: FileDialogRequest,
|
||||
after: AfterPick,
|
||||
}
|
||||
|
||||
@@ -75,22 +101,32 @@ pub(crate) enum Asked {
|
||||
}
|
||||
|
||||
impl WordApp {
|
||||
/// Whether a file dialog is open (its answer hasn't arrived yet).
|
||||
/// Whether a file dialog is open (its answer hasn't arrived yet), the host's or the built-in one.
|
||||
pub fn file_dialog_open(&self) -> bool {
|
||||
self.file_dialog.is_some()
|
||||
self.file_dialog.is_some() || self.file_browser_open()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_browser_open(&self) -> bool {
|
||||
self.file_browser.open.is_some()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn file_browser_open(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Ask the host for a file and do `after` with the answer: now for a blocking dialog, on a
|
||||
/// later frame for an asynchronous one. Ignored while another dialog is open.
|
||||
pub(crate) fn ask_file(&mut self, req: FileDialogRequest, after: AfterPick) -> Asked {
|
||||
if self.file_dialog.is_some() {
|
||||
if self.file_dialog_open() {
|
||||
log::debug!("file dialog already open; ignoring {req:?}");
|
||||
self.status(tl!("A file dialog is already open."));
|
||||
return Asked::Busy;
|
||||
}
|
||||
if let Some(show) = &self.services.file_dialog {
|
||||
let rx = show(req);
|
||||
self.file_dialog = Some(PendingDialog { rx, after });
|
||||
let rx = show(req.clone());
|
||||
self.file_dialog = Some(PendingDialog { rx, req, after });
|
||||
// A host that answered at once (or failed to show anything) is handled right away.
|
||||
return self.poll_file_dialog().map_or(Asked::Pending, Asked::Done);
|
||||
}
|
||||
@@ -104,18 +140,46 @@ impl WordApp {
|
||||
/// Pick up the answer of an open file dialog, if it has arrived (every frame, from `logic`),
|
||||
/// and finish what it was for. `None` while there is nothing to finish.
|
||||
pub(crate) fn poll_file_dialog(&mut self) -> Option<Result<Value, String>> {
|
||||
let picked = match self.file_dialog.as_ref().map(|d| d.rx.try_recv()) {
|
||||
let answer = match self.file_dialog.as_ref().map(|d| d.rx.try_recv()) {
|
||||
None | Some(Err(TryRecvError::Empty)) => return None,
|
||||
Some(Ok(picked)) => picked,
|
||||
Some(Ok(answer)) => answer,
|
||||
// The host dropped the dialog without an answer: as good as cancelled.
|
||||
Some(Err(TryRecvError::Disconnected)) => None,
|
||||
Some(Err(TryRecvError::Disconnected)) => Ok(None),
|
||||
};
|
||||
let d = self.file_dialog.take()?;
|
||||
Some(self.finish_pick(d.after, picked))
|
||||
match answer {
|
||||
Ok(picked) => Some(self.finish_pick(d.after, picked)),
|
||||
Err(reason) => {
|
||||
log::warn!("no system file dialog ({reason}); showing the built-in file browser");
|
||||
self.dialog_unavailable(d.req, d.after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn give_up_file_dialog(&mut self) {
|
||||
if let Some(d) = self.file_dialog.take() {
|
||||
let _ = self.finish_pick(d.after, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// The host couldn't show a system dialog (#576): ask in the built-in browser instead, which
|
||||
/// finishes the job later. `None` while it is open.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn dialog_unavailable(&mut self, req: FileDialogRequest, after: AfterPick) -> Option<Result<Value, String>> {
|
||||
self.show_file_browser(req, after, true);
|
||||
None
|
||||
}
|
||||
|
||||
/// The browser has no built-in file browser (it can't list the user's folders): cancelled.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn dialog_unavailable(&mut self, _req: FileDialogRequest, after: AfterPick) -> Option<Result<Value, String>> {
|
||||
Some(self.finish_pick(after, None))
|
||||
}
|
||||
|
||||
/// Do what the dialog was for with the picked path (`None`: the user cancelled).
|
||||
fn finish_pick(&mut self, after: AfterPick, picked: Option<String>) -> Result<Value, String> {
|
||||
pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option<String>) -> Result<Value, String> {
|
||||
match after {
|
||||
AfterPick::OpenDocument => {
|
||||
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
|
||||
@@ -130,6 +194,10 @@ impl WordApp {
|
||||
};
|
||||
self.insert_or_change_picture(json!({"path": path}))
|
||||
}
|
||||
AfterPick::Font => {
|
||||
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
|
||||
self.run("font.add", json!({"path": path}))
|
||||
}
|
||||
AfterPick::ForDialog => {
|
||||
let picked = picked.filter(|p| !p.is_empty());
|
||||
let r = json!({"picked": picked});
|
||||
@@ -226,6 +294,15 @@ impl WordApp {
|
||||
}
|
||||
let _ = self.ask_file(FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture);
|
||||
}
|
||||
|
||||
/// Pick a font file to add (Add Font…; web: it arrives through the inbox).
|
||||
pub(crate) fn pick_font(&mut self) {
|
||||
if let Some(f) = &self.services.open_async {
|
||||
f("font");
|
||||
return;
|
||||
}
|
||||
let _ = self.ask_file(FileDialogRequest::Open { purpose: "font".into() }, AfterPick::Font);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -241,7 +318,7 @@ mod tests {
|
||||
const UNSAVED: &str = "My unsaved novel chapter";
|
||||
|
||||
/// Dialogs the fake host was asked to show, each with the channel its answer goes through.
|
||||
type Shown = Rc<RefCell<Vec<(FileDialogRequest, Sender<Option<String>>)>>>;
|
||||
type Shown = Rc<RefCell<Vec<(FileDialogRequest, Sender<FileDialogAnswer>)>>>;
|
||||
|
||||
/// An app whose host shows dialogs asynchronously: nothing is answered until the test does.
|
||||
fn async_app() -> (WordApp, Shown) {
|
||||
@@ -261,7 +338,7 @@ mod tests {
|
||||
/// Answer the dialog the host is showing (the last one asked for).
|
||||
fn answer(shown: &Shown, picked: Option<&str>) {
|
||||
let (_, tx) = shown.borrow_mut().pop().unwrap();
|
||||
tx.send(picked.map(str::to_string)).unwrap();
|
||||
tx.send(Ok(picked.map(str::to_string))).unwrap();
|
||||
}
|
||||
|
||||
fn typed(a: &mut WordApp) {
|
||||
@@ -306,6 +383,35 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #689: Save As honours the filename typed in the dialog, not just the folder: a document
|
||||
/// opened as `a/one.docx` and saved as `b/two.docx` is written there and takes that path.
|
||||
#[test]
|
||||
fn save_as_with_a_new_filename_saves_under_it() {
|
||||
let dir = scratch("rename");
|
||||
let opened = dir.join("one.docx");
|
||||
let picked = dir.join("sub").join("two.docx");
|
||||
std::fs::create_dir_all(dir.join("sub")).unwrap();
|
||||
wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("original"))
|
||||
.run("file.save", &json!({"path": opened.to_string_lossy()}))
|
||||
.unwrap();
|
||||
let (mut a, shown) = async_app();
|
||||
a.run("file.open", json!({"path": opened.to_string_lossy()})).unwrap();
|
||||
assert_eq!(a.session.path.as_deref(), Some(opened.as_path()));
|
||||
a.save_as_dialog();
|
||||
answer(&shown, Some(&picked.to_string_lossy()));
|
||||
let r = a.poll_file_dialog().unwrap().unwrap();
|
||||
assert_eq!(r, json!({"saved": true}), "the save-as succeeded");
|
||||
assert_eq!(a.session.path.as_deref(), Some(picked.as_path()), "the picked filename is the document's path");
|
||||
assert!(picked.is_file(), "the file is written under the picked name");
|
||||
assert!(saved_text(&picked).contains("original"));
|
||||
// F12 (file.saveAs without a path) lands in the same dialog, suggesting the new name.
|
||||
let r = a.run("file.saveAs", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"saved": false}));
|
||||
assert!(a.file_dialog_open(), "F12's saveAs request opens the dialog");
|
||||
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "two.docx".into() });
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// While a dialog is open, more Save As / Open / Export / Picture clicks don't open a second one.
|
||||
#[test]
|
||||
fn a_second_dialog_while_one_is_open_is_ignored() {
|
||||
|
||||
@@ -808,6 +808,9 @@
|
||||
Show only comments and formatting in balloons Mostrar solo comentarios y formato en globos
|
||||
Show revisions in balloons Mostrar revisiones en globos
|
||||
Show rulers Mostrar reglas
|
||||
Animation Animación
|
||||
Animate the caret (it glides and fades) Animar el cursor (se desliza y se desvanece)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Activado: el cursor se desliza hasta su nueva posición al escribir o usar las flechas, y su parpadeo se desvanece. Desactivado: salta y parpadea de golpe.
|
||||
Show the community button in the title bar Mostrar el botón de comunidad en la barra de título
|
||||
Show/Hide ¶ Mostrar u ocultar ¶
|
||||
Show\nComments Mostrar\ncomentarios
|
||||
@@ -1413,6 +1416,7 @@
|
||||
Selected Fields Campos seleccionados
|
||||
Edit Field… Editar campo…
|
||||
Formatted Con formato
|
||||
Deleted Eliminado
|
||||
Interface size: Tamaño de la interfaz:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte.
|
||||
Print Markup Imprimir marcas
|
||||
@@ -1537,6 +1541,9 @@
|
||||
New Group Nuevo grupo
|
||||
Rename… Cambiar nombre…
|
||||
Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido:
|
||||
Add Font… Agregar fuente…
|
||||
Added Fonts Fuentes agregadas
|
||||
Remove Added Font Quitar fuente agregada
|
||||
Recent Colors Colores recientes
|
||||
More Colors… Más colores…
|
||||
Standard Estándar
|
||||
@@ -1698,6 +1705,22 @@
|
||||
Continuous numbering Numeración continua
|
||||
Apply Aplicar
|
||||
Convert Notes Convertir notas
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Documentos
|
||||
Word document Documento de Word
|
||||
All files Todos los archivos
|
||||
Recipient lists Listas de destinatarios
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. El cuadro de diálogo de archivos del sistema no está disponible, así que WordCraft muestra el suyo. Para usar el del sistema, instala un portal selector de archivos (como xdg-desktop-portal-gtk) o zenity.
|
||||
Up one level Subir un nivel
|
||||
Home folder Carpeta personal
|
||||
{name} wasn't found. No se encontró {name}.
|
||||
{name} already exists. Choose Save again to replace it. {name} ya existe. Elige Guardar otra vez para reemplazarlo.
|
||||
This folder can't be read: {error} No se puede leer esta carpeta: {error}
|
||||
Showing the first {count} items. Se muestran los primeros {count} elementos.
|
||||
File name: Nombre de archivo:
|
||||
Browse in WordCraft… Examinar en WordCraft…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Abre un archivo con el explorador propio de WordCraft cuando no aparece el cuadro de diálogo de archivos del sistema.
|
||||
Hide White Space Ocultar espacio en blanco
|
||||
All pages Todas las páginas
|
||||
Current page Página actual
|
||||
@@ -2019,3 +2042,11 @@
|
||||
Turkish Turco
|
||||
Ukrainian Ucraniano
|
||||
{language} is now the document's default language. {language} es ahora el idioma predeterminado del documento.
|
||||
Visual effects Efectos visuales
|
||||
Animation effects: Efectos de animación:
|
||||
On Activado
|
||||
Off Desactivado
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Desplazamiento y zoom suaves, y menús y botones que aparecen gradualmente. Con «Desactivado», cada cambio se muestra al instante.
|
||||
Your system has animation effects turned on. Tu sistema tiene activados los efectos de animación.
|
||||
Your system has animation effects turned off. Tu sistema tiene desactivados los efectos de animación.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Tu sistema no indica ningún ajuste de animación, así que los efectos de animación siguen activados.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -692,6 +692,9 @@
|
||||
Show Notes Kuva märkused
|
||||
Show all formatting marks Kuva kõik vormindusmärgid
|
||||
Show rulers Kuva joonlauad
|
||||
Animation Animatsioon
|
||||
Animate the caret (it glides and fades) Animeeri tekstikursorit (libiseb ja hajub)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Sees: tekstikursor libiseb tippimisel või nooleklahvide kasutamisel uude kohta ja selle vilkumine hajub sujuvalt. Väljas: kursor hüppab ja vilgub järsult.
|
||||
Show the community button in the title bar Kuva tiitliribal kogukonna nupp
|
||||
Show the mini toolbar when text is selected Kuva teksti valimisel minitööriistariba
|
||||
Show/Hide ¶ Kuva/peida ¶
|
||||
@@ -1399,6 +1402,7 @@
|
||||
Choose the sheet that holds the recipients. Valige leht, millel on adressaadid.
|
||||
First row of data contains column headers Andmete esimene rida sisaldab veerupäiseid
|
||||
Formatted Vormindatud
|
||||
Deleted Kustutatud
|
||||
Interface size: Kasutajaliidese suurus:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi.
|
||||
Print Markup Prindi märgistus
|
||||
@@ -1523,6 +1527,9 @@
|
||||
Ribbon Lint
|
||||
Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba:
|
||||
Ribbon tabs Lindi vahekaardid
|
||||
Add Font… Lisa font…
|
||||
Added Fonts Lisatud fondid
|
||||
Remove Added Font Eemalda lisatud font
|
||||
Recent Colors Viimati kasutatud värvid
|
||||
More Colors… Rohkem värve…
|
||||
Standard Standard
|
||||
@@ -1659,6 +1666,22 @@
|
||||
Continuous numbering Pidev nummerdus
|
||||
Apply Rakenda
|
||||
Convert Notes Teisenda märkused
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Dokumendid
|
||||
Word document Wordi dokument
|
||||
All files Kõik failid
|
||||
Recipient lists Adressaatide loendid
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Süsteemi failidialoog pole saadaval, seega näitab WordCraft enda oma. Süsteemi dialoogi saamiseks paigalda failivalija portaal (näiteks xdg-desktop-portal-gtk) või zenity.
|
||||
Up one level Taseme võrra üles
|
||||
Home folder Kodukaust
|
||||
{name} wasn't found. {name} ei leitud.
|
||||
{name} already exists. Choose Save again to replace it. {name} on juba olemas. Selle asendamiseks vali uuesti Salvesta.
|
||||
This folder can't be read: {error} Seda kausta ei saa lugeda: {error}
|
||||
Showing the first {count} items. Kuvatud on ainult esimesed kirjed: {count}.
|
||||
File name: Faili nimi:
|
||||
Browse in WordCraft… Sirvi WordCraftis…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Ava fail WordCrafti enda failisirvijaga, kui süsteemi failidialoog ei ilmu.
|
||||
Hide White Space Peida tühi ala lehtede vahel
|
||||
All pages Kõik leheküljed
|
||||
Current page Praegune lehekülg
|
||||
@@ -2013,3 +2036,11 @@
|
||||
Turkish Türgi
|
||||
Ukrainian Ukraina
|
||||
{language} is now the document's default language. {language} on nüüd dokumendi vaikekeel.
|
||||
Visual effects Visuaalefektid
|
||||
Animation effects: Animatsiooniefektid:
|
||||
On Sees
|
||||
Off Väljas
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Sujuv kerimine ja suumimine ning menüüde ja nuppude sujuv ilmumine. Valikuga „Väljas“ kuvatakse iga muudatus kohe.
|
||||
Your system has animation effects turned on. Süsteemis on animatsiooniefektid sisse lülitatud.
|
||||
Your system has animation effects turned off. Süsteemis on animatsiooniefektid välja lülitatud.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Süsteem ei teata animatsiooniseadet, seega jäävad animatsiooniefektid sisse.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 445 and column 13.
|
@@ -786,6 +786,9 @@
|
||||
Show only comments and formatting in balloons コメントと書式のみを吹き出しに表示
|
||||
Show revisions in balloons 変更履歴を吹き出しに表示
|
||||
Show rulers ルーラーを表示する
|
||||
Animation アニメーション
|
||||
Animate the caret (it glides and fades) カーソルをアニメーション表示する(滑らかに移動してフェードする)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. オン: 入力や矢印キーの操作でカーソルが新しい位置へ滑らかに移動し、点滅もフェードします。オフ: カーソルは瞬時に移動し、点滅は表示と非表示が切り替わるだけです。
|
||||
Show the community button in the title bar タイトル バーにコミュニティ ボタンを表示する
|
||||
Show/Hide ¶ 編集記号の表示/非表示 ¶
|
||||
Show\nComments コメントの表示
|
||||
@@ -1397,6 +1400,7 @@
|
||||
Selected Fields 選択したフィールド
|
||||
Edit Field… フィールドの編集…
|
||||
Formatted 書式変更
|
||||
Deleted 削除
|
||||
Interface size: 表示サイズ:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。
|
||||
Print Markup 変更履歴とコメントを印刷
|
||||
@@ -1521,6 +1525,9 @@
|
||||
New Group 新しいグループ
|
||||
Rename… 名前の変更…
|
||||
Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー:
|
||||
Add Font… フォントの追加…
|
||||
Added Fonts 追加したフォント
|
||||
Remove Added Font 追加したフォントの削除
|
||||
Recent Colors 最近使用した色
|
||||
More Colors… その他の色…
|
||||
Standard 標準
|
||||
@@ -1682,6 +1689,22 @@
|
||||
Continuous numbering 通し番号
|
||||
Apply 適用
|
||||
Convert Notes 注の変換
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents ドキュメント
|
||||
Word document Word 文書
|
||||
All files すべてのファイル
|
||||
Recipient lists 宛先リスト
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. システムのファイル ダイアログを利用できないため、WordCraft 独自のダイアログを表示しています。システムのダイアログを使うには、ファイル選択ポータル (xdg-desktop-portal-gtk など) または zenity をインストールしてください。
|
||||
Up one level 1 つ上のフォルダー
|
||||
Home folder ホーム フォルダー
|
||||
{name} wasn't found. {name} が見つかりません。
|
||||
{name} already exists. Choose Save again to replace it. {name} は既に存在します。置き換えるには、もう一度 [保存] を選んでください。
|
||||
This folder can't be read: {error} このフォルダーを読み取れません: {error}
|
||||
Showing the first {count} items. 最初の {count} 項目を表示しています。
|
||||
File name: ファイル名:
|
||||
Browse in WordCraft… WordCraft で参照…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. システムのファイル ダイアログが表示されないときに、WordCraft 独自のブラウザーでファイルを開きます。
|
||||
Hide White Space 余白を非表示
|
||||
All pages すべてのページ
|
||||
Current page 現在のページ
|
||||
@@ -2003,3 +2026,11 @@
|
||||
Turkish トルコ語
|
||||
Ukrainian ウクライナ語
|
||||
{language} is now the document's default language. {language} がこの文書の既定の言語になりました。
|
||||
Visual effects 視覚効果
|
||||
Animation effects: アニメーション効果:
|
||||
On オン
|
||||
Off オフ
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. スクロールやズームのなめらかな動きと、メニューやボタンのフェード表示です。「オフ」にすると、すべての変化がすぐに表示されます。
|
||||
Your system has animation effects turned on. システムのアニメーション効果はオンです。
|
||||
Your system has animation effects turned off. システムのアニメーション効果はオフです。
|
||||
Your system doesn't report an animation setting, so animation effects stay on. システムからアニメーションの設定を取得できないため、アニメーション効果はオンのままです。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 553 and column 13.
|
@@ -787,6 +787,9 @@
|
||||
Show only comments and formatting in balloons Mostrar apenas comentários e formatação em balões
|
||||
Show revisions in balloons Mostrar revisões em balões
|
||||
Show rulers Mostrar réguas
|
||||
Animation Animação
|
||||
Animate the caret (it glides and fades) Animar o ponto de inserção (desliza e esmaece)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Ativado: o ponto de inserção desliza até a nova posição quando você digita ou usa as setas, e o piscar é suave. Desativado: ele salta e pisca de forma brusca.
|
||||
Show the community button in the title bar Mostrar o botão da comunidade na barra de título
|
||||
Show/Hide ¶ Mostrar/ocultar ¶
|
||||
Show\nComments Mostrar\ncomentários
|
||||
@@ -1409,6 +1412,7 @@
|
||||
Selected Fields Campos selecionados
|
||||
Edit Field… Editar campo…
|
||||
Formatted Formatado
|
||||
Deleted Excluído
|
||||
Interface size: Tamanho da interface:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado.
|
||||
Print Markup Imprimir marcação
|
||||
@@ -1533,6 +1537,9 @@
|
||||
New Group Novo Grupo
|
||||
Rename… Renomear…
|
||||
Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido:
|
||||
Add Font… Adicionar fonte…
|
||||
Added Fonts Fontes adicionadas
|
||||
Remove Added Font Remover fonte adicionada
|
||||
Recent Colors Cores recentes
|
||||
More Colors… Mais cores…
|
||||
Standard Padrão
|
||||
@@ -1694,6 +1701,22 @@
|
||||
Continuous numbering Numeração contínua
|
||||
Apply Aplicar
|
||||
Convert Notes Converter notas
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Documentos
|
||||
Word document Documento do Word
|
||||
All files Todos os arquivos
|
||||
Recipient lists Listas de destinatários
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. A caixa de diálogo de arquivos do sistema não está disponível, então o WordCraft mostra a sua própria. Para usar a do sistema, instale um portal seletor de arquivos (como xdg-desktop-portal-gtk) ou o zenity.
|
||||
Up one level Subir um nível
|
||||
Home folder Pasta pessoal
|
||||
{name} wasn't found. {name} não foi encontrado.
|
||||
{name} already exists. Choose Save again to replace it. {name} já existe. Escolha Salvar novamente para substituí-lo.
|
||||
This folder can't be read: {error} Não é possível ler esta pasta: {error}
|
||||
Showing the first {count} items. Mostrando os primeiros {count} itens.
|
||||
File name: Nome do arquivo:
|
||||
Browse in WordCraft… Procurar no WordCraft…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Abra um arquivo com o navegador próprio do WordCraft quando a caixa de diálogo de arquivos do sistema não aparecer.
|
||||
Hide White Space Ocultar espaço em branco
|
||||
All pages Todas as páginas
|
||||
Current page Página atual
|
||||
@@ -2015,3 +2038,11 @@
|
||||
Turkish Turco
|
||||
Ukrainian Ucraniano
|
||||
{language} is now the document's default language. {language} agora é o idioma padrão do documento.
|
||||
Visual effects Efeitos visuais
|
||||
Animation effects: Efeitos de animação:
|
||||
On Ativado
|
||||
Off Desativado
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Rolagem e zoom suaves, e menus e botões que aparecem gradualmente. Com “Desativado”, cada alteração é mostrada de imediato.
|
||||
Your system has animation effects turned on. Seu sistema está com os efeitos de animação ativados.
|
||||
Your system has animation effects turned off. Seu sistema está com os efeitos de animação desativados.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Seu sistema não informa uma configuração de animação, então os efeitos de animação continuam ativados.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -660,6 +660,9 @@
|
||||
Show Markup Показать исправления
|
||||
Show Notes Показать сноски
|
||||
Show rulers Показывать линейки
|
||||
Animate the caret (it glides and fades) Анимировать курсор (плавно перемещается и угасает)
|
||||
Animation Анимация
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Вкл.: при вводе текста и нажатии клавиш со стрелками курсор плавно перемещается на новое место, а его мигание плавно угасает. Выкл.: курсор перескакивает и резко мигает.
|
||||
Show the community button in the title bar Показывать кнопку сообщества в заголовке окна
|
||||
Show/Hide ¶ Показать/скрыть ¶
|
||||
Show\nComments Показать\nкомментарии
|
||||
@@ -1706,6 +1709,7 @@ preview Title Пример
|
||||
Centered as Group По центру как группа
|
||||
✓ None ✓ Нет
|
||||
Formatted Отформатировано
|
||||
Deleted Удалено
|
||||
Interface size: Размер интерфейса:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Увеличивает или уменьшает ленту, меню и диалоговые окна поверх системного масштабирования экрана. Масштаб документа настраивается отдельно.
|
||||
Copyright Sign Знак авторского права
|
||||
@@ -1790,6 +1794,9 @@ preview Title Пример
|
||||
Left Only Только слева
|
||||
Right Only Только справа
|
||||
Largest Only Только по большей стороне
|
||||
Add Font… Добавить шрифт…
|
||||
Added Fonts Добавленные шрифты
|
||||
Remove Added Font Удалить добавленный шрифт
|
||||
Print Markup Печатать исправления и примечания
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. Примечания рядом с каждой страницей и отслеживаемые изменения в PDF, при печати и в изображениях; список примечаний в конце веб-страниц, Markdown и текста.
|
||||
Recent Colors Последние цвета
|
||||
@@ -2127,6 +2134,22 @@ preview Title Пример
|
||||
Continuous numbering Сквозная нумерация
|
||||
Apply Применить
|
||||
Convert Notes Преобразовать сноски
|
||||
|
||||
# Built-in file browser (#576).
|
||||
Documents Документы
|
||||
Word document Документ Word
|
||||
All files Все файлы
|
||||
Recipient lists Списки получателей
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Системное диалоговое окно выбора файлов недоступно, поэтому WordCraft показывает собственное. Чтобы пользоваться системным, установите портал выбора файлов (например, xdg-desktop-portal-gtk) или zenity.
|
||||
Up one level На уровень вверх
|
||||
Home folder Домашняя папка
|
||||
{name} wasn't found. {name} не найден.
|
||||
{name} already exists. Choose Save again to replace it. {name} уже существует. Чтобы заменить его, снова выберите «Сохранить».
|
||||
This folder can't be read: {error} Не удаётся прочитать папку: {error}
|
||||
Showing the first {count} items. Показаны первые элементы: {count}.
|
||||
File name: Имя файла:
|
||||
Browse in WordCraft… Обзор в WordCraft…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Открыть файл во встроенном обозревателе WordCraft, если системное диалоговое окно выбора файлов не появляется.
|
||||
Hide White Space Скрыть пустое место между страницами
|
||||
All pages Все страницы
|
||||
Current page Текущая страница
|
||||
@@ -2143,7 +2166,6 @@ preview Title Пример
|
||||
The printer, two-sided printing and pages per sheet are chosen in that dialog. Принтер, двусторонняя печать и число страниц на листе выбираются в этом диалоге.
|
||||
Previous Page Предыдущая страница
|
||||
Print to PDF Печать в PDF
|
||||
|
||||
# Word XML Document (#548).
|
||||
Word XML Document (*.xml) Документ Word XML (*.xml)
|
||||
# Find and Replace special characters and wildcards (#543).
|
||||
@@ -2396,3 +2418,11 @@ preview Title Пример
|
||||
# Asian and Latin text fonts (#505).
|
||||
Asian text font: Шрифт азиатского текста:
|
||||
Latin text font: Шрифт латинского текста:
|
||||
Visual effects Визуальные эффекты
|
||||
Animation effects: Эффекты анимации:
|
||||
On Включено
|
||||
Off Выключено
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Плавная прокрутка и масштабирование, а также плавное появление меню и кнопок. Если выбрано «Выключено», все изменения отображаются сразу.
|
||||
Your system has animation effects turned on. В системе эффекты анимации включены.
|
||||
Your system has animation effects turned off. В системе эффекты анимации выключены.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Система не сообщает о настройке анимации, поэтому эффекты анимации остаются включёнными.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -836,6 +836,9 @@
|
||||
Show or Hide Style Prikaži ili sakrij stil
|
||||
Show revisions in balloons Prikaži izmene u oblačićima
|
||||
Show rulers Prikaži lenjire
|
||||
Animation Animacija
|
||||
Animate the caret (it glides and fades) Animiraj kursor (klizi i postepeno se gasi)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Uključeno: kursor klizi na novo mesto dok kucate ili koristite strelice, a njegovo treptanje se postepeno gasi. Isključeno: skače i trepće naglo.
|
||||
Show the community button in the title bar Prikaži dugme zajednice u traci naslova
|
||||
Show/Hide ¶ Prikaži/sakrij ¶
|
||||
Show\nComments Prikaži\nkomentare
|
||||
@@ -1407,6 +1410,7 @@
|
||||
Selected Fields Izabrana polja
|
||||
Edit Field… Uredi polje…
|
||||
Formatted Formatirano
|
||||
Deleted Izbrisano
|
||||
Interface size: Veličina interfejsa:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno.
|
||||
Print Markup Štampaj oznake
|
||||
@@ -1531,6 +1535,9 @@
|
||||
New Group Nova grupa
|
||||
Rename… Preimenuj…
|
||||
Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup:
|
||||
Add Font… Dodaj font…
|
||||
Added Fonts Dodati fontovi
|
||||
Remove Added Font Ukloni dodati font
|
||||
Recent Colors Nedavne boje
|
||||
More Colors… Još boja…
|
||||
Standard Standardne
|
||||
@@ -1692,6 +1699,22 @@
|
||||
Continuous numbering Neprekidna numeracija
|
||||
Apply Primeni
|
||||
Convert Notes Pretvori napomene
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Dokumenti
|
||||
Word document Word dokument
|
||||
All files Sve datoteke
|
||||
Recipient lists Liste primalaca
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Sistemski dijalog za datoteke nije dostupan, pa WordCraft prikazuje svoj. Da biste dobili sistemski dijalog, instalirajte portal za izbor datoteka (na primer xdg-desktop-portal-gtk) ili zenity.
|
||||
Up one level Jedan nivo gore
|
||||
Home folder Lična fascikla
|
||||
{name} wasn't found. {name} nije pronađen.
|
||||
{name} already exists. Choose Save again to replace it. {name} već postoji. Izaberite Sačuvaj ponovo da biste zamenili datoteku.
|
||||
This folder can't be read: {error} Ova fascikla ne može da se pročita: {error}
|
||||
Showing the first {count} items. Prikazane su samo prve stavke: {count}.
|
||||
File name: Ime datoteke:
|
||||
Browse in WordCraft… Potraži u WordCraft-u…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Otvorite datoteku pomoću WordCraft-ovog pregledača kada se sistemski dijalog za datoteke ne pojavi.
|
||||
Hide White Space Sakrij prazan prostor
|
||||
All pages Sve stranice
|
||||
Current page Trenutna stranica
|
||||
@@ -2013,3 +2036,11 @@
|
||||
Turkish Turski
|
||||
Ukrainian Ukrajinski
|
||||
{language} is now the document's default language. {language} je sada podrazumevani jezik dokumenta.
|
||||
Visual effects Vizuelni efekti
|
||||
Animation effects: Efekti animacije:
|
||||
On Uključeno
|
||||
Off Isključeno
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Glatko pomeranje i zumiranje, kao i postepeno pojavljivanje menija i dugmadi. Uz „Isključeno“ svaka promena se prikazuje odmah.
|
||||
Your system has animation effects turned on. U sistemu su efekti animacije uključeni.
|
||||
Your system has animation effects turned off. U sistemu su efekti animacije isključeni.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Sistem ne prijavljuje podešavanje animacije, pa efekti animacije ostaju uključeni.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -840,6 +840,9 @@
|
||||
Show or Hide Style Прикажи или сакриј стил
|
||||
Show revisions in balloons Прикажи измене у облачићима
|
||||
Show rulers Прикажи лењире
|
||||
Animation Анимација
|
||||
Animate the caret (it glides and fades) Анимирај курсор (клизи и постепено се гаси)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Укључено: курсор клизи на ново место док куцате или користите стрелице, а његово трептање се постепено гаси. Искључено: скаче и трепће нагло.
|
||||
Show the community button in the title bar Прикажи дугме заједнице у траци наслова
|
||||
Show/Hide ¶ Прикажи/сакриј ¶
|
||||
Show\nComments Прикажи\nкоментаре
|
||||
@@ -1411,6 +1414,7 @@
|
||||
Selected Fields Изабрана поља
|
||||
Edit Field… Уреди поље…
|
||||
Formatted Форматирано
|
||||
Deleted Избрисано
|
||||
Interface size: Величина интерфејса:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно.
|
||||
Print Markup Штампај ознаке
|
||||
@@ -1535,6 +1539,9 @@
|
||||
New Group Нова група
|
||||
Rename… Преименуј…
|
||||
Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ:
|
||||
Add Font… Додај фонт…
|
||||
Added Fonts Додати фонтови
|
||||
Remove Added Font Уклони додати фонт
|
||||
Recent Colors Недавне боје
|
||||
More Colors… Још боја…
|
||||
Standard Стандардне
|
||||
@@ -1696,6 +1703,22 @@
|
||||
Continuous numbering Непрекидна нумерација
|
||||
Apply Примени
|
||||
Convert Notes Претвори напомене
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Документи
|
||||
Word document Word документ
|
||||
All files Све датотеке
|
||||
Recipient lists Листе прималаца
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Системски дијалог за датотеке није доступан, па WordCraft приказује свој. Да бисте добили системски дијалог, инсталирајте портал за избор датотека (на пример xdg-desktop-portal-gtk) или zenity.
|
||||
Up one level Један ниво горе
|
||||
Home folder Лична фасцикла
|
||||
{name} wasn't found. {name} није пронађен.
|
||||
{name} already exists. Choose Save again to replace it. {name} већ постоји. Изаберите Сачувај поново да бисте заменили датотеку.
|
||||
This folder can't be read: {error} Ова фасцикла не може да се прочита: {error}
|
||||
Showing the first {count} items. Приказане су само прве ставке: {count}.
|
||||
File name: Име датотеке:
|
||||
Browse in WordCraft… Потражи у WordCraft-у…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Отворите датотеку помоћу WordCraft-овог прегледача када се системски дијалог за датотеке не појави.
|
||||
Hide White Space Сакриј празан простор
|
||||
All pages Све странице
|
||||
Current page Тренутна страница
|
||||
@@ -2017,3 +2040,11 @@
|
||||
Turkish Турски
|
||||
Ukrainian Украјински
|
||||
{language} is now the document's default language. {language} је сада подразумевани језик документа.
|
||||
Visual effects Визуелни ефекти
|
||||
Animation effects: Ефекти анимације:
|
||||
On Укључено
|
||||
Off Искључено
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Глатко померање и зумирање, као и постепено појављивање менија и дугмади. Уз „Искључено“ свака промена се приказује одмах.
|
||||
Your system has animation effects turned on. У систему су ефекти анимације укључени.
|
||||
Your system has animation effects turned off. У систему су ефекти анимације искључени.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Систем не пријављује подешавање анимације, па ефекти анимације остају укључени.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -663,6 +663,9 @@
|
||||
Show Notes Notları Göster
|
||||
Show all formatting marks Tüm biçimlendirme işaretlerini göster
|
||||
Show rulers Cetvelleri göster
|
||||
Animate the caret (it glides and fades) İmleci canlandır (kayarak ilerler ve solarak yanıp söner)
|
||||
Animation Animasyon
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Açık: yazarken veya ok tuşlarını kullanırken imleç yeni yerine kayarak gider ve yanıp sönerken solar. Kapalı: imleç yeni yerine atlar, keskin biçimde yanıp söner.
|
||||
Show the community button in the title bar Başlık çubuğunda topluluk düğmesini göster
|
||||
Show/Hide ¶ Göster/Gizle ¶
|
||||
Show\nComments Yorumları\nGöster
|
||||
@@ -1322,6 +1325,7 @@
|
||||
Flip Vertical Dikey Çevir
|
||||
Flip Horizontal Yatay Çevir
|
||||
Formatted Biçimlendirildi
|
||||
Deleted Silindi
|
||||
Copyright Sign Telif Hakkı İşareti
|
||||
Demote Düzey Düşür
|
||||
Edit Field Code Alan Kodunu Düzenle
|
||||
@@ -1408,6 +1412,9 @@
|
||||
Left Only Yalnızca Sol
|
||||
Right Only Yalnızca Sağ
|
||||
Largest Only Yalnızca En Geniş
|
||||
Add Font… Yazı Tipi Ekle…
|
||||
Added Fonts Eklenen Yazı Tipleri
|
||||
Remove Added Font Eklenen Yazı Tipini Kaldır
|
||||
Print Markup İşaretlemeyi Yazdır
|
||||
Comments beside each page and tracked changes in PDFs, prints and images; a comment list at the end of web pages, Markdown and text. PDF'lerde, baskılarda ve görüntülerde her sayfanın yanında açıklamalar ve izlenen değişiklikler; web sayfalarının, Markdown ve metnin sonunda açıklama listesi.
|
||||
Recent Colors Son Kullanılan Renkler
|
||||
@@ -1749,6 +1756,22 @@
|
||||
Continuous numbering Sürekli numaralandırma
|
||||
Apply Uygula
|
||||
Convert Notes Notları Dönüştür
|
||||
|
||||
# Built-in file browser (#576).
|
||||
Documents Belgeler
|
||||
Word document Word belgesi
|
||||
All files Tüm dosyalar
|
||||
Recipient lists Alıcı listeleri
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Sistemin dosya iletişim kutusu kullanılamadığı için WordCraft kendi iletişim kutusunu gösteriyor. Sistem iletişim kutusunu kullanmak için bir dosya seçici portalı (örneğin xdg-desktop-portal-gtk) ya da zenity yükleyin.
|
||||
Up one level Bir düzey yukarı
|
||||
Home folder Ana klasör
|
||||
{name} wasn't found. {name} bulunamadı.
|
||||
{name} already exists. Choose Save again to replace it. {name} zaten var. Değiştirmek için Kaydet'i yeniden seçin.
|
||||
This folder can't be read: {error} Bu klasör okunamıyor: {error}
|
||||
Showing the first {count} items. İlk {count} öğe gösteriliyor.
|
||||
File name: Dosya adı:
|
||||
Browse in WordCraft… WordCraft'ta Gözat…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Sistemin dosya iletişim kutusu açılmadığında dosyayı WordCraft'ın kendi dosya tarayıcısıyla açın.
|
||||
Hide White Space Boşluğu gizle
|
||||
All pages Tüm sayfalar
|
||||
Current page Geçerli sayfa
|
||||
@@ -1765,7 +1788,6 @@
|
||||
The printer, two-sided printing and pages per sheet are chosen in that dialog. Yazıcı, çift taraflı yazdırma ve yaprak başına sayfa sayısı o iletişim kutusunda seçilir.
|
||||
Previous Page Önceki sayfa
|
||||
Print to PDF PDF'ye yazdır
|
||||
|
||||
# Word XML Document (#548).
|
||||
Word XML Document (*.xml) Word XML Belgesi (*.xml)
|
||||
# Find and Replace special characters and wildcards (#543).
|
||||
@@ -2018,3 +2040,11 @@
|
||||
# Asian and Latin text fonts (#505).
|
||||
Asian text font: Asya metni yazı tipi:
|
||||
Latin text font: Latin metni yazı tipi:
|
||||
Visual effects Görsel efektler
|
||||
Animation effects: Animasyon efektleri:
|
||||
On Açık
|
||||
Off Kapalı
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Yumuşak kaydırma ve yakınlaştırma ile menülerin ve düğmelerin yavaşça belirmesi. Kapalı seçildiğinde her değişiklik anında gösterilir.
|
||||
Your system has animation effects turned on. Sisteminizde animasyon efektleri açık.
|
||||
Your system has animation effects turned off. Sisteminizde animasyon efektleri kapalı.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Sisteminiz bir animasyon ayarı bildirmiyor, bu nedenle animasyon efektleri açık kalır.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 431 and column 13.
|
@@ -810,6 +810,9 @@
|
||||
Show only comments and formatting in balloons Показувати у виносках лише коментарі й форматування
|
||||
Show revisions in balloons Показувати виправлення у виносках
|
||||
Show rulers Показати лінійки
|
||||
Animation Анімація
|
||||
Animate the caret (it glides and fades) Анімувати каретку (плавно рухається та згасає)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. Увімкнено: каретка плавно переходить на нове місце, коли ви вводите текст або користуєтеся стрілками, а її блимання згасає поступово. Вимкнено: вона перестрибує й блимає різко.
|
||||
Show the community button in the title bar Показувати кнопку спільноти в заголовку вікна
|
||||
Show/Hide ¶ Показати/приховати ¶
|
||||
Show\nComments Показати\nкоментарі
|
||||
@@ -1409,6 +1412,7 @@
|
||||
Selected Fields Вибрані поля
|
||||
Edit Field… Змінити поле…
|
||||
Formatted Відформатовано
|
||||
Deleted Видалено
|
||||
Interface size: Розмір інтерфейсу:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо.
|
||||
Print Markup Друкувати примітки та виправлення
|
||||
@@ -1533,6 +1537,9 @@
|
||||
New Group Нова група
|
||||
Rename… Перейменувати…
|
||||
Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу:
|
||||
Add Font… Додати шрифт…
|
||||
Added Fonts Додані шрифти
|
||||
Remove Added Font Видалити доданий шрифт
|
||||
Recent Colors Останні кольори
|
||||
More Colors… Інші кольори…
|
||||
Standard Стандартні
|
||||
@@ -1694,6 +1701,22 @@
|
||||
Continuous numbering Наскрізна нумерація
|
||||
Apply Застосувати
|
||||
Convert Notes Перетворити примітки
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents Документи
|
||||
Word document Документ Word
|
||||
All files Усі файли
|
||||
Recipient lists Списки одержувачів
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Системне діалогове вікно файлів недоступне, тож WordCraft показує власне. Щоб отримати системне вікно, установіть портал вибору файлів (наприклад, xdg-desktop-portal-gtk) або zenity.
|
||||
Up one level На рівень вище
|
||||
Home folder Домашня тека
|
||||
{name} wasn't found. {name} не знайдено.
|
||||
{name} already exists. Choose Save again to replace it. {name} уже існує. Щоб замінити файл, виберіть «Зберегти» ще раз.
|
||||
This folder can't be read: {error} Не вдається прочитати цю теку: {error}
|
||||
Showing the first {count} items. Показано лише перші елементи: {count}.
|
||||
File name: Ім'я файлу:
|
||||
Browse in WordCraft… Огляд у WordCraft…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Відкрити файл власним оглядачем WordCraft, коли системне діалогове вікно файлів не з'являється.
|
||||
Hide White Space Приховати порожній простір
|
||||
All pages Усі сторінки
|
||||
Current page Поточна сторінка
|
||||
@@ -2015,3 +2038,11 @@
|
||||
Turkish Турецька
|
||||
Ukrainian Українська
|
||||
{language} is now the document's default language. {language} тепер типова мова документа.
|
||||
Visual effects Візуальні ефекти
|
||||
Animation effects: Ефекти анімації:
|
||||
On Увімкнено
|
||||
Off Вимкнено
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. Плавне прокручування й масштабування, а також поступова поява меню та кнопок. Якщо вибрано «Вимкнено», усі зміни відображаються одразу.
|
||||
Your system has animation effects turned on. У вашій системі ефекти анімації ввімкнено.
|
||||
Your system has animation effects turned off. У вашій системі ефекти анімації вимкнено.
|
||||
Your system doesn't report an animation setting, so animation effects stay on. Система не повідомляє про налаштування анімації, тому ефекти анімації залишаються ввімкненими.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -798,6 +798,9 @@
|
||||
Show only comments and formatting in balloons 仅在批注框中显示批注和格式
|
||||
Show revisions in balloons 在批注框中显示修订
|
||||
Show rulers 显示标尺
|
||||
Animation 动画
|
||||
Animate the caret (it glides and fades) 为光标添加动画(平滑移动并淡入淡出)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. 开启:输入或使用方向键时,光标平滑移动到新位置,闪烁时淡入淡出。关闭:光标直接跳转,闪烁时直接显示和隐藏。
|
||||
Show the community button in the title bar 在标题栏中显示社区按钮
|
||||
Show/Hide ¶ 显示/隐藏 ¶
|
||||
Show\nComments 显示批注
|
||||
@@ -1409,6 +1412,7 @@
|
||||
Selected Fields 所选域
|
||||
Edit Field… 编辑域…
|
||||
Formatted 已设置格式
|
||||
Deleted 已删除
|
||||
Interface size: 界面大小:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。
|
||||
Print Markup 打印标记
|
||||
@@ -1533,6 +1537,9 @@
|
||||
New Group 新建组
|
||||
Rename… 重命名…
|
||||
Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏:
|
||||
Add Font… 添加字体…
|
||||
Added Fonts 已添加的字体
|
||||
Remove Added Font 删除已添加的字体
|
||||
Recent Colors 最近使用的颜色
|
||||
More Colors… 其他颜色…
|
||||
Standard 标准
|
||||
@@ -1694,6 +1701,22 @@
|
||||
Continuous numbering 连续编号
|
||||
Apply 应用
|
||||
Convert Notes 转换注释
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents 文档
|
||||
Word document Word 文档
|
||||
All files 所有文件
|
||||
Recipient lists 收件人列表
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. 系统文件对话框不可用,因此 WordCraft 显示自己的对话框。要使用系统对话框,请安装文件选择器门户(例如 xdg-desktop-portal-gtk)或 zenity。
|
||||
Up one level 向上一级
|
||||
Home folder 主文件夹
|
||||
{name} wasn't found. 未找到 {name}。
|
||||
{name} already exists. Choose Save again to replace it. {name} 已存在。再次选择“保存”即可替换它。
|
||||
This folder can't be read: {error} 无法读取此文件夹:{error}
|
||||
Showing the first {count} items. 仅显示前 {count} 项。
|
||||
File name: 文件名:
|
||||
Browse in WordCraft… 在 WordCraft 中浏览…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. 当系统文件对话框没有出现时,使用 WordCraft 自带的浏览器打开文件。
|
||||
Hide White Space 隐藏空白
|
||||
All pages 所有页面
|
||||
Current page 当前页
|
||||
@@ -2015,3 +2038,11 @@
|
||||
Turkish 土耳其语
|
||||
Ukrainian 乌克兰语
|
||||
{language} is now the document's default language. {language} 现在是此文档的默认语言。
|
||||
Visual effects 视觉效果
|
||||
Animation effects: 动画效果:
|
||||
On 开
|
||||
Off 关
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. 平滑滚动和缩放,以及菜单和按钮的淡入效果。选择“关”后,所有更改会立即显示。
|
||||
Your system has animation effects turned on. 系统已打开动画效果。
|
||||
Your system has animation effects turned off. 系统已关闭动画效果。
|
||||
Your system doesn't report an animation setting, so animation effects stay on. 系统未提供动画设置,因此动画效果保持打开。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -787,6 +787,9 @@
|
||||
Show only comments and formatting in balloons 僅在註解方塊中顯示註解和格式設定
|
||||
Show revisions in balloons 在註解方塊中顯示修訂
|
||||
Show rulers 顯示尺規
|
||||
Animation 動畫
|
||||
Animate the caret (it glides and fades) 為游標加上動畫(平滑移動並淡入淡出)
|
||||
On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off. 開啟:輸入或使用方向鍵時,游標會平滑移動到新位置,閃爍時淡入淡出。關閉:游標直接跳到新位置,閃爍時直接顯示和隱藏。
|
||||
Show the community button in the title bar 在標題列中顯示社群按鈕
|
||||
Show/Hide ¶ 顯示/隱藏 ¶
|
||||
Show\nComments 顯示註解
|
||||
@@ -1398,6 +1401,7 @@
|
||||
Selected Fields 選取的功能變數
|
||||
Edit Field… 編輯功能變數…
|
||||
Formatted 已設定格式
|
||||
Deleted 已刪除
|
||||
Interface size: 介面大小:
|
||||
Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。
|
||||
Print Markup 列印標記
|
||||
@@ -1522,6 +1526,9 @@
|
||||
New Group 新增群組
|
||||
Rename… 重新命名…
|
||||
Ribbon and Quick Access Toolbar: 功能區和快速存取工具列:
|
||||
Add Font… 新增字型…
|
||||
Added Fonts 已新增的字型
|
||||
Remove Added Font 移除已新增的字型
|
||||
Recent Colors 最近使用的色彩
|
||||
More Colors… 其他色彩…
|
||||
Standard 標準
|
||||
@@ -1683,6 +1690,22 @@
|
||||
Continuous numbering 連續編號
|
||||
Apply 套用
|
||||
Convert Notes 轉換附註
|
||||
|
||||
# Built-in file browser when the system file dialog is unavailable (#576)
|
||||
Documents 文件
|
||||
Word document Word 文件
|
||||
All files 所有檔案
|
||||
Recipient lists 收件者清單
|
||||
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. 系統檔案對話方塊無法使用,因此 WordCraft 顯示自己的對話方塊。若要使用系統對話方塊,請安裝檔案選擇器入口(例如 xdg-desktop-portal-gtk)或 zenity。
|
||||
Up one level 上移一層
|
||||
Home folder 個人資料夾
|
||||
{name} wasn't found. 找不到 {name}。
|
||||
{name} already exists. Choose Save again to replace it. {name} 已存在。再次選擇「儲存」即可取代它。
|
||||
This folder can't be read: {error} 無法讀取此資料夾:{error}
|
||||
Showing the first {count} items. 僅顯示前 {count} 個項目。
|
||||
File name: 檔案名稱:
|
||||
Browse in WordCraft… 在 WordCraft 中瀏覽…
|
||||
Open a file with WordCraft's own browser when the system file dialog doesn't appear. 當系統檔案對話方塊沒有出現時,使用 WordCraft 內建的瀏覽器開啟檔案。
|
||||
Hide White Space 隱藏空白
|
||||
All pages 所有頁面
|
||||
Current page 目前頁面
|
||||
@@ -2004,3 +2027,11 @@
|
||||
Turkish 土耳其文
|
||||
Ukrainian 烏克蘭文
|
||||
{language} is now the document's default language. {language} 現在是此文件的預設語言。
|
||||
Visual effects 視覺效果
|
||||
Animation effects: 動畫效果:
|
||||
On 開
|
||||
Off 關
|
||||
Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once. 平滑捲動與縮放,以及功能表與按鈕的淡入效果。選擇「關」後,所有變更會立即顯示。
|
||||
Your system has animation effects turned on. 系統已開啟動畫效果。
|
||||
Your system has animation effects turned off. 系統已關閉動畫效果。
|
||||
Your system doesn't report an animation setting, so animation effects stay on. 系統未提供動畫設定,因此動畫效果保持開啟。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 554 and column 13.
|
@@ -1298,6 +1298,22 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
|
||||
}
|
||||
}
|
||||
|
||||
// The built-in file browser (#576): a folder, a file and the home folder.
|
||||
"folder" => {
|
||||
pen.frect(2.5, 4.5, 8.5, 7.0, yellow);
|
||||
pen.frect(2.5, 6.5, 17.5, 16.0, yellow);
|
||||
pen.closed(&[(2.5, 4.5), (8.0, 4.5), (9.5, 6.5), (17.5, 6.5), (17.5, 16.0), (2.5, 16.0)], c);
|
||||
}
|
||||
"file" => {
|
||||
pen.page(4.5, 2.0, 15.5, 18.0);
|
||||
pen.lines(7.0, 13.0, &[9.0, 12.0, 15.0]);
|
||||
}
|
||||
"homeFolder" => {
|
||||
pen.closed(&[(3.0, 9.5), (10.0, 3.5), (17.0, 9.5)], c);
|
||||
pen.closed(&[(5.0, 8.5), (5.0, 17.0), (15.0, 17.0), (15.0, 8.5)], c);
|
||||
pen.frect(8.5, 12.0, 11.5, 17.0, a);
|
||||
}
|
||||
|
||||
// Outlining tab: arrows moving a paragraph between outline levels and along the outline.
|
||||
"outlinePromote" | "outlineDemote" => {
|
||||
pen.lines(9.0, 17.0, &[6.0, 10.0, 14.0]);
|
||||
|
||||
@@ -215,6 +215,14 @@ fn to_screen(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Opt
|
||||
Some(move |[x, y]: [f32; 2]| pos2(r.min.x + x * s, r.min.y + y * s))
|
||||
}
|
||||
|
||||
/// The on-screen colour of ink: on a dark page (View > Switch Modes) it is mapped like every other
|
||||
/// document colour, so a stroke looks the same while it is drawn as after it is saved.
|
||||
fn screen_ink(dark: bool, c: wordcraft_doc::Rgb, alpha: u8) -> Color32 {
|
||||
let opts = wordcraft_render::RenderOptions { dark, ..Default::default() };
|
||||
let wordcraft_doc::Rgb(r, g, b) = opts.ink(c);
|
||||
Color32::from_rgba_unmultiplied(r, g, b, alpha)
|
||||
}
|
||||
|
||||
/// The live stroke, the lasso and its selection, and the replayed ink, drawn over the pages.
|
||||
pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
||||
paint_replay(app, painter, rects, layout, scale);
|
||||
@@ -224,7 +232,7 @@ pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayou
|
||||
let s = page_scale(rects, layout, scale, d.page).unwrap_or(scale);
|
||||
let set = app.session.view.draw.settings(d.tool);
|
||||
let alpha = (d.tool.alpha() * 255.0).round() as u8;
|
||||
let color = Color32::from_rgba_unmultiplied(set.color.0, set.color.1, set.color.2, alpha);
|
||||
let color = screen_ink(app.session.view.dark_mode, set.color, alpha);
|
||||
let pts: Vec<Pos2> = d.pts.iter().map(|p| map(*p)).collect();
|
||||
let width = (set.width * s).max(1.0);
|
||||
if let [only] = pts.as_slice() {
|
||||
@@ -331,6 +339,8 @@ fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLa
|
||||
let s = page_scale(rects, layout, scale, k.page).unwrap_or(scale);
|
||||
let frac = if k.dur > 0.0 { ((r.t - k.start) / k.dur).clamp(0.0, 1.0) } else { 1.0 };
|
||||
let total: f32 = k.paths.iter().map(|p| path_len(p)).sum();
|
||||
let [cr, cg, cb, ca] = k.color.to_srgba_unmultiplied();
|
||||
let color = screen_ink(app.session.view.dark_mode, wordcraft_doc::Rgb(cr, cg, cb), ca);
|
||||
let mut left = total * frac;
|
||||
let width = (k.width * s).max(1.0);
|
||||
for path in &k.paths {
|
||||
@@ -353,11 +363,11 @@ fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLa
|
||||
}
|
||||
match (pts.as_slice(), path.as_slice()) {
|
||||
(_, [only]) => {
|
||||
painter.circle_filled(map(*only), width / 2.0, k.color);
|
||||
painter.circle_filled(map(*only), width / 2.0, color);
|
||||
}
|
||||
([], _) => {}
|
||||
_ => {
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(width, k.color)));
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(width, color)));
|
||||
}
|
||||
}
|
||||
if left <= 0.0 {
|
||||
@@ -428,3 +438,17 @@ pub fn replay_bar(app: &mut WordApp, ui: &Ui, area: Rect) {
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn live_ink_follows_the_dark_page() {
|
||||
let black = wordcraft_doc::Rgb(0, 0, 0);
|
||||
assert_eq!(screen_ink(false, black, 255), Color32::from_rgba_unmultiplied(0, 0, 0, 255));
|
||||
// Black pen on a dark page turns light, like the saved stroke.
|
||||
let dark = screen_ink(true, black, 128);
|
||||
assert_eq!(dark, Color32::from_rgba_unmultiplied(255, 255, 255, 128));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,17 +112,29 @@ fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
|
||||
let _ = app.run(spec.id, json!({}));
|
||||
return true;
|
||||
}
|
||||
// Shift extends caret movement.
|
||||
// Shift extends caret movement; Shift+Backspace still erases (#707).
|
||||
if m.shift
|
||||
&& let Some(spec) = app.session.keymap.resolve(®, &combo(m, name, false))
|
||||
&& spec.id.starts_with("caret.")
|
||||
&& let Some(params) = shift_variant_params(spec.id)
|
||||
{
|
||||
let _ = app.run(spec.id, json!({"extend": true}));
|
||||
let _ = app.run(spec.id, params);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// How a command bound to a plain key runs when Shift is also held and nothing is bound to the
|
||||
/// shifted combo: caret moves extend the selection, Backspace erases as usual (#707).
|
||||
fn shift_variant_params(id: &str) -> Option<Value> {
|
||||
if id.starts_with("caret.") {
|
||||
Some(json!({"extend": true}))
|
||||
} else if id == "text.backspace" {
|
||||
Some(json!({}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// F1–F12: shortcuts on these work without the canvas focused.
|
||||
fn is_function_key(key: Key) -> bool {
|
||||
matches!(key, Key::F1 | Key::F2 | Key::F3 | Key::F4 | Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 | Key::F11 | Key::F12)
|
||||
@@ -322,6 +334,13 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shift_backspace_still_erases() {
|
||||
assert_eq!(shift_variant_params("text.backspace"), Some(json!({})));
|
||||
assert_eq!(shift_variant_params("caret.left"), Some(json!({"extend": true})));
|
||||
assert_eq!(shift_variant_params("text.delete"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_plain_paste_releases_paste_a_clipboard_picture() {
|
||||
let m = |command, shift, alt| egui::Modifiers { command, shift, alt, ..Default::default() };
|
||||
|
||||
+462
-5
@@ -17,6 +17,7 @@ pub mod a11y;
|
||||
pub mod a11y_pane;
|
||||
pub mod backstage;
|
||||
pub mod canvas;
|
||||
pub mod caret_glide;
|
||||
pub mod chrome;
|
||||
pub mod colors;
|
||||
pub mod content_controls;
|
||||
@@ -37,6 +38,9 @@ pub mod dialogs_ribbon;
|
||||
pub mod dialogs_smart_art;
|
||||
pub mod dialogs_templates;
|
||||
pub mod equation_tab;
|
||||
/// The built-in file browser, for desktops without a system file dialog (#576).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod file_browser;
|
||||
pub mod file_dialogs;
|
||||
pub mod frame;
|
||||
pub mod i18n;
|
||||
@@ -67,7 +71,7 @@ use serde_json::{Value, json};
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
pub use control::ControlRequest;
|
||||
pub use file_dialogs::FileDialogRequest;
|
||||
pub use file_dialogs::{FileDialogAnswer, FileDialogRequest, open_filters};
|
||||
|
||||
/// Platform services injected by the host (file dialogs, file I/O).
|
||||
#[derive(Default)]
|
||||
@@ -81,10 +85,11 @@ pub struct Services {
|
||||
/// Pick a path to save to, given a suggested name.
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
/// Desktop: show a native file dialog without blocking the UI thread (#94); used instead of
|
||||
/// `pick_open` / `pick_save` when set. The host sends the picked path (`None` when cancelled)
|
||||
/// through the returned channel once the user answers, and wakes the UI; a dropped sender
|
||||
/// counts as cancelled. The app shows one dialog at a time ([`file_dialogs`]).
|
||||
pub file_dialog: Option<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<Option<String>>>>,
|
||||
/// `pick_open` / `pick_save` when set. The host sends the picked path (`Ok(None)` when
|
||||
/// cancelled) through the returned channel once the user answers, and wakes the UI; a dropped
|
||||
/// sender counts as cancelled. An `Err` says no system dialog could be shown, and the app shows
|
||||
/// its built-in file browser instead (#576). The app shows one dialog at a time ([`file_dialogs`]).
|
||||
pub file_dialog: Option<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<FileDialogAnswer>>>,
|
||||
/// Web: open a file picker; the file arrives later through `inbox`.
|
||||
pub open_async: Option<Box<dyn Fn(&str)>>,
|
||||
/// Web: files (name, bytes) delivered asynchronously (picker, drag and drop).
|
||||
@@ -101,6 +106,20 @@ pub struct Services {
|
||||
/// Desktop: the picture on the system clipboard, if any — egui's paste only carries text
|
||||
/// (#45). Read when Paste finds no text; see [`paste_picture`].
|
||||
pub clipboard_picture: Option<Box<dyn Fn() -> Option<paste_picture::ClipboardPicture>>>,
|
||||
/// Whether the system has animation effects on (Windows: Animation effects; macOS: Reduce
|
||||
/// motion; Linux and the BSDs: the desktop's setting; browsers: `prefers-reduced-motion`), or
|
||||
/// `None` when it doesn't say. What "Use system setting" follows ([`theme::Animations`]).
|
||||
/// Asked every frame, so a change in the system's settings shows at once: the host answers
|
||||
/// from memory and does any slow asking elsewhere. Without the hook, animation effects stay on.
|
||||
pub animations_enabled: Option<Box<dyn Fn() -> Option<bool>>>,
|
||||
/// Desktop: the text on the system clipboard, if any. Ctrl+V gets it from egui's paste event;
|
||||
/// the ribbon and context-menu Paste read it here (#584).
|
||||
pub clipboard_text: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
/// Web: keep a font the user added (stored file name, original bytes) for the next visit
|
||||
/// (#582). Desktop fonts are stored by the font database itself, in the app's font folder.
|
||||
pub store_font: Option<Box<dyn Fn(&str, &[u8])>>,
|
||||
/// Web: forget a stored font (its stored file name) once the user removes it.
|
||||
pub forget_font: Option<Box<dyn Fn(&str)>>,
|
||||
}
|
||||
|
||||
/// Files delivered asynchronously.
|
||||
@@ -127,6 +146,9 @@ pub struct UiState {
|
||||
/// Interface size in percent (#475): scales the whole interface on top of the system's
|
||||
/// display scale. Junk or out-of-range saved values read as 100 % or are clamped to 80–200 %.
|
||||
pub interface_size: theme::InterfaceSize,
|
||||
/// Animation effects (smooth scrolling, fades): on, off, or follow the system's setting, which
|
||||
/// is the default. An unknown saved value reads as the default.
|
||||
pub animations: theme::Animations,
|
||||
/// The dark-mode switch `ui.json` held before `theme` (#115): read once to migrate, never written.
|
||||
#[serde(rename = "dark", skip_serializing)]
|
||||
pub(crate) legacy_dark: Option<bool>,
|
||||
@@ -153,6 +175,10 @@ pub struct UiState {
|
||||
/// View › Switch Modes: show pages dark (white text on black), kept between runs. Only the
|
||||
/// pages: the interface follows [`UiState::theme`] (#312).
|
||||
pub dark_page: bool,
|
||||
/// File › Options › Advanced › Animation: the caret glides to its new place and its blink
|
||||
/// fades, as Word's does on Windows (on, the default), or it jumps and blinks on and off
|
||||
/// (off).
|
||||
pub caret_animation: bool,
|
||||
/// Custom keyboard shortcuts (Customize Keyboard, #368); the session owns them while running.
|
||||
pub keyboard: wordcraft_engine::KeyMap,
|
||||
/// Custom ribbon tabs and groups, hidden tabs and the Quick Access Toolbar (Customize Ribbon,
|
||||
@@ -200,6 +226,7 @@ impl Default for UiState {
|
||||
recent_colors: Vec::new(),
|
||||
theme: theme::Appearance::default(),
|
||||
interface_size: theme::InterfaceSize::default(),
|
||||
animations: theme::Animations::default(),
|
||||
legacy_dark: None,
|
||||
nav_tab: "headings".into(),
|
||||
show_discord: true,
|
||||
@@ -212,6 +239,7 @@ impl Default for UiState {
|
||||
keytips: crate::keytips::Phase::Off,
|
||||
alt_chord_used: false,
|
||||
dark_page: false,
|
||||
caret_animation: true,
|
||||
keyboard: wordcraft_engine::KeyMap::default(),
|
||||
ribbon: wordcraft_engine::RibbonLayout::default(),
|
||||
caption_labels: Vec::new(),
|
||||
@@ -264,6 +292,9 @@ pub struct WordApp {
|
||||
/// Web: a recipient list was asked for (Select Recipients › Use an Existing List…), so the
|
||||
/// next text file from the picker loads as recipients instead of opening (#240).
|
||||
pub recipient_list_pending: bool,
|
||||
/// The font database's [`generation`](wordcraft_fonts::FontDb::generation) last seen: when
|
||||
/// fonts are added or removed (by any route) the font lists and the layout are refreshed.
|
||||
font_generation: u64,
|
||||
/// A file picked for the open dialog (Templates and Add-ins › Attach…, Organizer ›
|
||||
/// Browse…); the dialog takes it on its next frame.
|
||||
pub(crate) dialog_pick: Option<String>,
|
||||
@@ -287,6 +318,10 @@ pub struct WordApp {
|
||||
fonts_frames: u32,
|
||||
/// The interface theme setting last installed in egui ([`theme::apply`]).
|
||||
applied_theme: Option<theme::Appearance>,
|
||||
/// What the system last said about its animation effects ([`Services::animations_enabled`]).
|
||||
system_animations: Option<bool>,
|
||||
/// Whether egui's own animations were last switched on or off ([`theme::apply_animations`]).
|
||||
applied_animations: Option<bool>,
|
||||
pub frame_ms: f64,
|
||||
/// The window title last sent; a viewport command schedules a repaint, so only send changes.
|
||||
sent_title: String,
|
||||
@@ -315,6 +350,9 @@ pub struct WordApp {
|
||||
autosave_path: Option<std::path::PathBuf>,
|
||||
/// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]).
|
||||
file_dialog: Option<file_dialogs::PendingDialog>,
|
||||
/// The built-in file browser, when it is open, and the folder it showed last (#576).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) file_browser: file_browser::State,
|
||||
/// File › Info: the property field being typed in and the document revision after its last
|
||||
/// keystroke, so one visit's keystrokes make a single undo step (and anything else in
|
||||
/// between, such as an Undo, starts a new one).
|
||||
@@ -368,6 +406,8 @@ impl WordApp {
|
||||
want_system_cjk: false,
|
||||
fonts_frames: 0,
|
||||
applied_theme: None,
|
||||
system_animations: None,
|
||||
applied_animations: None,
|
||||
frame_ms: 0.0,
|
||||
sent_title: String::new(),
|
||||
quit_requested: false,
|
||||
@@ -385,8 +425,11 @@ impl WordApp {
|
||||
autosave_path: None,
|
||||
change_picture_target: None,
|
||||
recipient_list_pending: false,
|
||||
font_generation: wordcraft_fonts::FontDb::global().generation(),
|
||||
dialog_pick: None,
|
||||
file_dialog: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
file_browser: file_browser::State::default(),
|
||||
info_editing: None,
|
||||
}
|
||||
}
|
||||
@@ -464,6 +507,25 @@ impl WordApp {
|
||||
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then: id.to_string(), params, document: self.session.document_id() });
|
||||
return Ok(json!({"pending": "saveChanges"}));
|
||||
}
|
||||
// The ribbon and context-menu Paste commands send no text: read the system clipboard's, as
|
||||
// Ctrl+V's paste event carries it (#584).
|
||||
let mut params = params;
|
||||
if matches!(id, "edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial")
|
||||
&& params.get("text").is_none()
|
||||
&& let Some(text) = self.services.clipboard_text.as_ref().and_then(|f| f()).filter(|t| !t.is_empty())
|
||||
&& let Some(o) = params.as_object_mut()
|
||||
{
|
||||
// Picture files copied in a file manager reach us as their paths: paste the pictures,
|
||||
// as Ctrl+V does.
|
||||
if id == "edit.paste"
|
||||
&& self.session.math.is_none()
|
||||
&& paste_picture::names_picture_files(&text)
|
||||
&& let Some(r) = paste_picture::paste(self)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
o.insert("text".into(), Value::String(text));
|
||||
}
|
||||
// Paste without system clipboard text: a picture there is newer than our own copy (copying
|
||||
// here replaces it with text), so it wins (#45).
|
||||
if id == "edit.paste"
|
||||
@@ -567,6 +629,9 @@ impl WordApp {
|
||||
{
|
||||
self.autosave_path = self.session.path.clone();
|
||||
}
|
||||
if let Ok(v) = &r {
|
||||
self.keep_web_font(id, ¶ms, v);
|
||||
}
|
||||
self.after_command(id);
|
||||
if let Err(e) = &r {
|
||||
self.status(e.clone());
|
||||
@@ -574,6 +639,44 @@ impl WordApp {
|
||||
r
|
||||
}
|
||||
|
||||
/// Web: store a font added from bytes, or forget the removed ones ([`Services::store_font`]).
|
||||
fn keep_web_font(&self, id: &str, params: &Value, result: &Value) {
|
||||
match id {
|
||||
"font.add" => {
|
||||
if let (Some(store), Some(file), Some(data)) =
|
||||
(&self.services.store_font, result.get("file").and_then(Value::as_str), params.get("data").and_then(Value::as_str))
|
||||
&& let Some(bytes) = wordcraft_engine::cmd::insert::base64_decode(data)
|
||||
{
|
||||
store(file, &bytes);
|
||||
}
|
||||
}
|
||||
"font.remove" => {
|
||||
if let Some(forget) = &self.services.forget_font {
|
||||
for f in result.get("removed").and_then(Value::as_array).into_iter().flatten() {
|
||||
if let Some(file) = f.get("file").and_then(Value::as_str) {
|
||||
forget(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fonts were added or removed (a command, the web shell loading stored fonts): lay out
|
||||
/// again and refresh the font lists and their previews.
|
||||
fn watch_fonts(&mut self, ctx: &egui::Context) {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let generation = db.generation();
|
||||
if generation == self.font_generation {
|
||||
return;
|
||||
}
|
||||
self.font_generation = generation;
|
||||
self.session.relayout();
|
||||
self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Answer the Save Changes prompt: Save (through Save As for a new document) and carry on,
|
||||
/// carry on without saving, or cancel. A failed or cancelled save cancels too.
|
||||
fn answer_save_changes(&mut self, choice: SaveChoice) -> Result<Value, String> {
|
||||
@@ -685,6 +788,7 @@ impl WordApp {
|
||||
self.save_as_dialog();
|
||||
}
|
||||
"insertPicture" => self.pick_picture(),
|
||||
"addFont" => self.pick_font(),
|
||||
"print" => {
|
||||
self.ui.backstage = true;
|
||||
self.ui.backstage_page = "print".into();
|
||||
@@ -710,6 +814,22 @@ impl WordApp {
|
||||
self.ui.theme.is_dark(self.ctx.as_ref().and_then(egui::Context::system_theme))
|
||||
}
|
||||
|
||||
/// Whether animation effects are on: the Animation effects setting, where `System` follows
|
||||
/// what the system last reported (on when it reports nothing).
|
||||
pub fn animations_on(&self) -> bool {
|
||||
self.ui.animations.enabled(self.system_animations)
|
||||
}
|
||||
|
||||
/// What the system last said about its animation effects (`None`: it doesn't say).
|
||||
pub fn system_animations(&self) -> Option<bool> {
|
||||
self.system_animations
|
||||
}
|
||||
|
||||
/// Ask the host for the system's animation setting.
|
||||
fn read_system_animations(&mut self) {
|
||||
self.system_animations = self.services.animations_enabled.as_ref().and_then(|ask| ask());
|
||||
}
|
||||
|
||||
/// Commands that live in the UI layer.
|
||||
fn ui_command(&mut self, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
@@ -800,6 +920,18 @@ impl WordApp {
|
||||
}
|
||||
json!({"percent": self.ui.interface_size.percent()})
|
||||
}
|
||||
"ui.animations" => {
|
||||
// `system` (follow the system's animation setting), `on` or `off`; no value reads
|
||||
// it. `enabled` is what that comes to now, `system` what the system reports.
|
||||
if let Some(v) = s("value") {
|
||||
match theme::Animations::from_code(v) {
|
||||
Some(a) => self.ui.animations = a,
|
||||
None => return Some(Err(format!("unknown animations setting {v:?}; use system, on or off"))),
|
||||
}
|
||||
}
|
||||
self.read_system_animations();
|
||||
json!({"animations": self.ui.animations.code(), "enabled": self.animations_on(), "system": self.system_animations})
|
||||
}
|
||||
"ui.language" => {
|
||||
// `auto` (follow the system) or a language code; anything else is an error.
|
||||
if let Some(v) = s("value") {
|
||||
@@ -824,6 +956,14 @@ impl WordApp {
|
||||
self.open_dialog();
|
||||
json!({})
|
||||
}
|
||||
// The built-in file browser (#576), for when the system's file dialog doesn't show up
|
||||
// (desktop only): `purpose` is document (default), picture, recipients or save. A system
|
||||
// dialog still waiting for an answer is given up as cancelled.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"ui.fileBrowser" => {
|
||||
self.browse_builtin(s("purpose").unwrap_or("document"));
|
||||
json!({"open": self.file_browser.open.is_some()})
|
||||
}
|
||||
// Mailings › Select Recipients › Use an Existing List…: pick a CSV/TSV/text file and
|
||||
// load it as the mail-merge recipients. Opens system UI, like `ui.openFileDialog`.
|
||||
"ui.openRecipientList" => {
|
||||
@@ -837,6 +977,16 @@ impl WordApp {
|
||||
self.canvas.open_url = Some("https://discord.gg/artcraft".into());
|
||||
json!({})
|
||||
}
|
||||
"ui.caretAnimation" => {
|
||||
// File › Options › Advanced › Animation: `value` true or false turns the caret's
|
||||
// glide and fading blink on or off; no value reads it.
|
||||
match p.get("value") {
|
||||
None => {}
|
||||
Some(Value::Bool(on)) => self.ui.caret_animation = *on,
|
||||
Some(_) => return Some(Err("`value` must be true or false".into())),
|
||||
}
|
||||
json!({"caretAnimation": self.ui.caret_animation})
|
||||
}
|
||||
// Shape Format › Text › Create Link: the next click on an empty text box links the
|
||||
// selected box to it (`shape.link` with that box as the target); Esc cancels.
|
||||
"ui.linkTextBox" => {
|
||||
@@ -992,6 +1142,14 @@ impl WordApp {
|
||||
if ctx.zoom_factor() != zoom {
|
||||
ctx.set_zoom_factor(zoom);
|
||||
}
|
||||
// Animation effects: the host is asked every frame what the system says (it answers from
|
||||
// memory), so a change made in the system's settings is followed without a restart.
|
||||
self.read_system_animations();
|
||||
let animate = self.animations_on();
|
||||
if self.applied_animations != Some(animate) {
|
||||
theme::apply_animations(ctx, animate);
|
||||
self.applied_animations = Some(animate);
|
||||
}
|
||||
if self.ctx.is_none() {
|
||||
self.ctx = Some(ctx.clone());
|
||||
}
|
||||
@@ -1002,6 +1160,7 @@ impl WordApp {
|
||||
self.clear_stale_change_picture();
|
||||
let _ = self.poll_file_dialog();
|
||||
self.drain_inbox();
|
||||
self.watch_fonts(ctx);
|
||||
self.autosave_tick(now_ms());
|
||||
if self.autosaves() && self.session.dirty {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(1000));
|
||||
@@ -1023,6 +1182,8 @@ impl WordApp {
|
||||
} else if sheet {
|
||||
// A spreadsheet isn't a document: it's a recipient list (#334).
|
||||
self.load_recipients(json!({"path": path}))
|
||||
} else if wordcraft_fonts::is_font_file_name(&lp) {
|
||||
self.run("font.add", json!({"path": path}))
|
||||
} else {
|
||||
self.run("file.open", json!({"path": path}))
|
||||
};
|
||||
@@ -1113,6 +1274,11 @@ impl WordApp {
|
||||
let t0 = now_ms();
|
||||
let ctx = ui.ctx().clone();
|
||||
i18n::set_current(i18n::Lang::from_pref(&self.ui.language));
|
||||
// Animation effects off: egui's scroll areas get a wheel notch whole, not eased in.
|
||||
if !self.animations_on() {
|
||||
let opts = ctx.options(|o| o.input_options);
|
||||
ctx.input_mut(|i| i.smooth_scroll_delta = scroll::unsmoothed_scroll(i, &opts));
|
||||
}
|
||||
// Fonts installed by `logic` take effect on the next frame.
|
||||
if self.fonts_frames < 2 {
|
||||
self.fonts_frames += 1;
|
||||
@@ -1136,6 +1302,8 @@ impl WordApp {
|
||||
}
|
||||
content_controls::popup(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
file_browser::show(self, &ctx);
|
||||
colors::show(self, &ctx);
|
||||
zotero::show_alert(self, &ctx);
|
||||
read_aloud::show(self, &ctx);
|
||||
@@ -1169,6 +1337,8 @@ impl WordApp {
|
||||
self.recipient_list_pending = asked && !list && !sheet;
|
||||
let r = if sheet {
|
||||
self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
|
||||
} else if wordcraft_fonts::is_font_file_name(&lower) {
|
||||
self.run("font.add", json!({"name": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
|
||||
} else if list {
|
||||
self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)}))
|
||||
} else if img {
|
||||
@@ -1453,6 +1623,28 @@ mod tests {
|
||||
WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default())
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Animation: the caret's animation is a UI command, kept between
|
||||
/// runs.
|
||||
#[test]
|
||||
fn caret_animation_is_a_ui_command_and_survives_a_restart() {
|
||||
let mut a = app();
|
||||
assert_eq!(a.run("ui.caretAnimation", json!({})).unwrap(), json!({"caretAnimation": true}), "on by default; no value reads it");
|
||||
assert_eq!(a.run("ui.caretAnimation", json!({"value": false})).unwrap(), json!({"caretAnimation": false}));
|
||||
for junk in [json!({"value": "yes"}), json!({"value": 1}), json!({"value": null}), json!({"value": [true]})] {
|
||||
assert!(a.run("ui.caretAnimation", junk).is_err());
|
||||
}
|
||||
assert!(!a.ui.caret_animation, "a rejected value keeps the setting");
|
||||
let saved = serde_json::to_string(&a.prefs()).unwrap();
|
||||
assert!(saved.contains(r#""caretAnimation":false"#), "{saved}");
|
||||
let mut b = app();
|
||||
b.apply_prefs(serde_json::from_str(&saved).unwrap());
|
||||
assert!(!b.ui.caret_animation);
|
||||
// Settings saved before the option existed leave it on.
|
||||
let old: UiState = serde_json::from_str(r#"{"tab":"Insert"}"#).unwrap();
|
||||
assert!(old.caret_animation && old.tab == "Insert");
|
||||
assert_eq!(a.run("ui.caretAnimation", json!({"value": true})).unwrap()["caretAnimation"], true);
|
||||
}
|
||||
|
||||
/// Insert › Icons (#309) opens the icon picker from the ribbon; an id inserts without it.
|
||||
#[test]
|
||||
fn icons_button_opens_the_picker_and_ids_insert_directly() {
|
||||
@@ -1643,6 +1835,227 @@ mod tests {
|
||||
assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at);
|
||||
}
|
||||
|
||||
/// An app whose host reports the system's animation setting as `system` holds it.
|
||||
fn app_with_system_animations(system: &std::rc::Rc<std::cell::Cell<Option<bool>>>) -> WordApp {
|
||||
let system = system.clone();
|
||||
let services = Services { animations_enabled: Some(Box::new(move || system.get())), ..Default::default() };
|
||||
WordApp::new(Session::new(wordcraft_doc::Document::new()), services)
|
||||
}
|
||||
|
||||
/// Animation effects: "Use system setting" (the default) follows what the host reports and is
|
||||
/// on when it reports nothing; On and Off ignore the system. The choice is kept between runs.
|
||||
#[test]
|
||||
fn animation_effects_setting_follows_the_system_and_survives_a_restart() {
|
||||
use theme::Animations;
|
||||
for (setting, system, on) in [
|
||||
(Animations::System, None, true),
|
||||
(Animations::System, Some(true), true),
|
||||
(Animations::System, Some(false), false),
|
||||
(Animations::On, Some(false), true),
|
||||
(Animations::Off, Some(true), false),
|
||||
(Animations::Off, None, false),
|
||||
] {
|
||||
assert_eq!(setting.enabled(system), on, "{setting:?} with the system at {system:?}");
|
||||
}
|
||||
// No host hook (tests, headless screenshots): nothing is reported, so they are on.
|
||||
let mut a = app();
|
||||
assert_eq!(a.run("ui.animations", json!({})).unwrap(), json!({"animations": "system", "enabled": true, "system": null}));
|
||||
// The system has them off, as Windows reports with Animation effects switched off.
|
||||
let system = std::rc::Rc::new(std::cell::Cell::new(Some(false)));
|
||||
let mut a = app_with_system_animations(&system);
|
||||
assert_eq!(a.run("ui.animations", json!({})).unwrap(), json!({"animations": "system", "enabled": false, "system": false}));
|
||||
assert_eq!(a.run("ui.animations", json!({"value": "On"})).unwrap()["enabled"], true);
|
||||
system.set(Some(true));
|
||||
assert_eq!(a.run("ui.animations", json!({"value": "off"})).unwrap(), json!({"animations": "off", "enabled": false, "system": true}));
|
||||
assert!(a.run("ui.animations", json!({"value": "sometimes"})).is_err());
|
||||
assert_eq!(a.ui.animations, Animations::Off, "a rejected value keeps the setting");
|
||||
let saved = serde_json::to_string(&a.prefs()).unwrap();
|
||||
assert!(saved.contains(r#""animations":"off""#), "{saved}");
|
||||
let mut b = app();
|
||||
b.apply_prefs(serde_json::from_str(&saved).unwrap());
|
||||
assert_eq!(b.ui.animations, Animations::Off);
|
||||
assert!(!b.animations_on());
|
||||
// Preferences from before the setting, or with a value it doesn't know, follow the system.
|
||||
for raw in [r#"{"tab": "Insert"}"#, r#"{"animations": "wobbly", "tab": "Insert"}"#, r#"{"animations": 3, "tab": "Insert"}"#] {
|
||||
let ui = UiState::from_json(raw.as_bytes()).unwrap();
|
||||
assert_eq!((ui.animations, ui.tab.as_str()), (Animations::System, "Insert"), "{raw}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With the system's animation effects off (what Windows reports with Settings › Accessibility ›
|
||||
/// Visual effects › Animation effects off), a wheel notch scrolls the page its whole distance
|
||||
/// in one frame and egui's own fades and scroll glides are off. A change of the system's
|
||||
/// setting is followed from the next frame.
|
||||
#[test]
|
||||
fn wheel_notches_land_at_once_with_animation_effects_off() {
|
||||
let ctx = egui::Context::default();
|
||||
let system = std::rc::Rc::new(std::cell::Cell::new(Some(false)));
|
||||
let mut a = app_with_system_animations(&system);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0));
|
||||
let mut t = 0.0;
|
||||
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
assert!(!a.animations_on());
|
||||
assert_eq!(ctx.global_style().animation_time, 0.0, "no fades");
|
||||
assert_eq!(ctx.global_style().scroll_animation, egui::style::ScrollAnimation::none(), "no glide to the caret");
|
||||
let over_page = a.canvas.canvas_rect.unwrap().center();
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
|
||||
let notch_down = || egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -1.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
};
|
||||
let notch = crate::scroll::notch_px(a.canvas.scale / crate::canvas::PX_PER_PT);
|
||||
let at = a.canvas.scroll_offset.y;
|
||||
frame(&mut a, vec![notch_down()]);
|
||||
let landed = a.canvas.scroll_offset.y;
|
||||
assert!((landed - at - notch).abs() < 0.5, "the whole notch ({notch}) lands in its frame: {}", landed - at);
|
||||
for _ in 0..30 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
assert_eq!(a.canvas.scroll_offset.y, landed, "nothing is left to ease in");
|
||||
// Animation effects are switched back on in the system's settings: the next frame has it.
|
||||
system.set(Some(true));
|
||||
frame(&mut a, Vec::new());
|
||||
assert!(a.animations_on());
|
||||
assert_eq!(ctx.global_style().animation_time, egui::Style::default().animation_time);
|
||||
assert_eq!(ctx.global_style().scroll_animation, egui::Style::default().scroll_animation);
|
||||
frame(&mut a, vec![notch_down()]);
|
||||
let first = a.canvas.scroll_offset.y - landed;
|
||||
assert!(first > 0.0 && first < notch, "a notch eases in again: {first}");
|
||||
}
|
||||
|
||||
/// With animation effects off a zoom step is finished in its own frame: Ctrl+wheel zooms the
|
||||
/// notch's whole step at once, the page is already placed and drawn as it will stay, and the
|
||||
/// status bar's zoom readout follows in the very next frame. Turns that arrive in small pieces
|
||||
/// (a touchpad pinch on Windows) add up to a step first rather than each zooming a little.
|
||||
#[test]
|
||||
fn zoom_steps_land_finished_with_animation_effects_off() {
|
||||
let ctx = egui::Context::default();
|
||||
let system = std::rc::Rc::new(std::cell::Cell::new(Some(false)));
|
||||
let mut a = app_with_system_animations(&system);
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0));
|
||||
let mut t = 0.0;
|
||||
// One frame; returns how soon it asked to be drawn again.
|
||||
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
let out = ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
});
|
||||
let again = out.viewport_output.values().map(|v| v.repaint_delay).min();
|
||||
out.drop_without_applying_deltas();
|
||||
again
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let over_page = a.canvas.canvas_rect.unwrap().center();
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
|
||||
frame(&mut a, Vec::new());
|
||||
let ctrl_wheel = |notches: f32| egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, notches),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::COMMAND,
|
||||
};
|
||||
let opts = egui::InputOptions::default();
|
||||
let notch_zoom = (opts.scroll_zoom_speed * opts.line_scroll_speed).exp();
|
||||
let before = a.canvas.scale;
|
||||
frame(&mut a, vec![ctrl_wheel(1.0)]);
|
||||
let (scale, pages) = (a.canvas.scale, a.canvas.page_rects.clone());
|
||||
assert!((scale / before - notch_zoom).abs() < 1e-3, "the whole notch in its frame: {before} -> {scale}");
|
||||
for _ in 0..30 {
|
||||
frame(&mut a, Vec::new());
|
||||
assert_eq!(a.canvas.scale, scale, "nothing is left to ease in");
|
||||
assert_eq!(a.canvas.page_rects, pages, "the page was already where and how it stays");
|
||||
}
|
||||
// Quarter notches: a step of about 10% with every second one, nothing in between.
|
||||
frame(&mut a, vec![ctrl_wheel(-0.25)]);
|
||||
assert_eq!(a.canvas.scale, scale, "a quarter notch isn't a step yet");
|
||||
frame(&mut a, vec![ctrl_wheel(-0.25)]);
|
||||
let stepped = a.canvas.scale;
|
||||
assert!((stepped / scale - (-0.1_f32).exp()).abs() < 1e-3, "{scale} -> {stepped}");
|
||||
for _ in 0..30 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
assert_eq!(a.canvas.scale, stepped);
|
||||
// Zoom Out (the status bar's minus, View › Zoom Out): the page changes in that frame, and
|
||||
// the frame asks for another at once so the readout beside the slider isn't left behind.
|
||||
assert_ne!(frame(&mut a, Vec::new()), Some(std::time::Duration::ZERO), "idle: nothing to redraw at once");
|
||||
a.run("view.zoomOut", json!({})).unwrap();
|
||||
assert_eq!(frame(&mut a, Vec::new()), Some(std::time::Duration::ZERO));
|
||||
let (scale, pages) = (a.canvas.scale, a.canvas.page_rects.clone());
|
||||
assert!(scale < stepped);
|
||||
frame(&mut a, Vec::new());
|
||||
assert_eq!((a.canvas.scale, &a.canvas.page_rects), (scale, &pages));
|
||||
}
|
||||
|
||||
/// Issue #688: dragging a selection past the viewport's bottom edge scrolls on, and the
|
||||
/// selection keeps growing as the content moves under the stationary pointer.
|
||||
#[test]
|
||||
fn dragging_a_selection_past_the_viewport_edge_auto_scrolls() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut a = app();
|
||||
// A few pages of text, so there is content below the fold.
|
||||
a.run("text.insert", json!({"text": "lorem ipsum dolor sit amet consectetur ".repeat(1200)})).unwrap();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0));
|
||||
let mut t = 0.0;
|
||||
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
|
||||
t += 1.0 / 60.0;
|
||||
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| {
|
||||
a.logic(ui.ctx());
|
||||
a.ui(ui);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
};
|
||||
for _ in 0..3 {
|
||||
frame(&mut a, Vec::new());
|
||||
}
|
||||
let (Some(vp), Some(page)) = (a.canvas.viewport, a.canvas.page_rects.first()) else { panic!("the canvas laid out") };
|
||||
let start = page.left_top() + egui::vec2(120.0, 150.0);
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(start)]);
|
||||
frame(
|
||||
&mut a,
|
||||
vec![egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }],
|
||||
);
|
||||
assert!(a.canvas.drag_selecting(), "the press started a drag-select");
|
||||
let base = a.session.sel.focus.clone();
|
||||
// Hold the pointer just past the viewport's bottom edge and let the frames run.
|
||||
let below = egui::pos2(vp.center().x, vp.max.y + 40.0);
|
||||
for _ in 0..30 {
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(below)]);
|
||||
}
|
||||
assert!(a.canvas.scroll_offset.y > 0.0, "the view scrolled toward later content");
|
||||
assert!(a.session.sel.focus > base, "the selection followed the content");
|
||||
// The pointer never moves; the content keeps scrolling under it, so the selection must
|
||||
// keep growing.
|
||||
let grown = a.session.sel.focus.clone();
|
||||
for _ in 0..15 {
|
||||
frame(&mut a, vec![egui::Event::PointerMoved(below)]);
|
||||
}
|
||||
assert!(a.session.sel.focus > grown, "the selection keeps growing under a stationary pointer");
|
||||
frame(
|
||||
&mut a,
|
||||
vec![egui::Event::PointerButton { pos: below, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }],
|
||||
);
|
||||
assert!(!a.canvas.drag_selecting());
|
||||
assert!(!a.session.sel.is_collapsed(), "the drag left a selection");
|
||||
}
|
||||
|
||||
/// Issue #123: zoomed out, pages sit side by side, and clicks map to the page under the pointer.
|
||||
#[test]
|
||||
fn zoomed_out_pages_sit_side_by_side_and_clicks_land_on_them() {
|
||||
@@ -2910,6 +3323,50 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Add Font… (#582), web: the picker asks for fonts, the picked file arrives through the
|
||||
/// inbox and is added, listed in the font menu and kept in the browser; removing it forgets it.
|
||||
#[test]
|
||||
fn a_font_picked_on_the_web_is_added_listed_and_kept() {
|
||||
const FAMILY: &str = "Uifont Sans 3";
|
||||
let mut font = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] {
|
||||
let mut i = 0;
|
||||
while let Some(at) = font[i..].windows(from.len()).position(|w| w == from) {
|
||||
font[i + at..i + at + to.len()].copy_from_slice(&to);
|
||||
i += at + to.len();
|
||||
}
|
||||
}
|
||||
let mut a = typed();
|
||||
let asked = std::rc::Rc::new(std::cell::RefCell::new(String::new()));
|
||||
let seen = asked.clone();
|
||||
a.services.open_async = Some(Box::new(move |purpose| *seen.borrow_mut() = purpose.to_string()));
|
||||
let kept = std::rc::Rc::new(std::cell::RefCell::new(Vec::<(String, usize)>::new()));
|
||||
let (k1, k2) = (kept.clone(), kept.clone());
|
||||
a.services.store_font = Some(Box::new(move |name, bytes| k1.borrow_mut().push((name.to_string(), bytes.len()))));
|
||||
a.services.forget_font = Some(Box::new(move |name| k2.borrow_mut().retain(|(n, _)| n != name)));
|
||||
let inbox: Inbox = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
a.services.inbox = Some(inbox.clone());
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!a.previews.families().iter().any(|f| f == FAMILY));
|
||||
a.run("font.add", json!({})).unwrap();
|
||||
assert_eq!(*asked.borrow(), "font");
|
||||
inbox.lock().unwrap().push(("Uifont-Regular.ttf".into(), font.clone()));
|
||||
a.drain_inbox();
|
||||
a.watch_fonts(&ctx);
|
||||
assert!(a.previews.families().iter().any(|f| f == FAMILY), "listed in the font menu at once");
|
||||
assert_eq!(*kept.borrow(), vec![("Uifont-Regular.ttf".to_string(), font.len())]);
|
||||
assert!(body_text(&a).contains(UNSAVED), "the document stays open");
|
||||
// Junk named like a font is refused, and nothing is kept.
|
||||
inbox.lock().unwrap().push(("junk.woff2".into(), b"wOF2junk".to_vec()));
|
||||
a.drain_inbox();
|
||||
assert_eq!(kept.borrow().len(), 1);
|
||||
a.run("font.remove", json!({"family": FAMILY})).unwrap();
|
||||
a.watch_fonts(&ctx);
|
||||
assert!(kept.borrow().is_empty());
|
||||
assert!(!a.previews.families().iter().any(|f| f == FAMILY));
|
||||
}
|
||||
|
||||
/// Web: the picked file arrives through the inbox and loads as recipients, even as .txt; a
|
||||
/// CSV dropped on the page does too. Anything else afterwards opens as before.
|
||||
#[test]
|
||||
|
||||
@@ -132,8 +132,12 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let fams = app.previews.families();
|
||||
let prev = app.previews.font_preview_fn();
|
||||
if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) {
|
||||
let f = app.previews.font_stored(&f);
|
||||
let _ = app.run("format.font", json!({"name": f}));
|
||||
let _ = if f == crate::widgets::ADD_FONT {
|
||||
app.run("font.add", json!({}))
|
||||
} else {
|
||||
let f = app.previews.font_stored(&f);
|
||||
app.run("format.font", json!({"name": f}))
|
||||
};
|
||||
}
|
||||
let size =
|
||||
st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default();
|
||||
|
||||
@@ -274,6 +274,9 @@ fn language(app: &mut WordApp, ui: &mut Ui) {
|
||||
|
||||
fn accessibility(app: &mut WordApp, ui: &mut Ui) {
|
||||
use wordcraft_engine::speech::{MAX_RATE, MIN_RATE};
|
||||
section(ui, "Visual effects");
|
||||
animations_picker(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Read Aloud");
|
||||
let mut rate = app.session.read_aloud.rate();
|
||||
ui.horizontal(|ui| {
|
||||
@@ -316,6 +319,18 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Animation");
|
||||
let mut caret = app.ui.caret_animation;
|
||||
if ui
|
||||
.checkbox(&mut caret, tl!("Animate the caret (it glides and fades)"))
|
||||
.on_hover_text(tl!(
|
||||
"On: the caret glides to its new place when you type or use the arrow keys, and its blink fades. Off: it jumps and blinks on and off."
|
||||
))
|
||||
.changed()
|
||||
{
|
||||
let _ = app.run("ui.caretAnimation", json!({"value": caret}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Customize");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Keyboard shortcuts:"));
|
||||
@@ -374,6 +389,31 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
);
|
||||
}
|
||||
|
||||
/// Animation effects: on, off, or follow the system's setting (the default). Under "Use system
|
||||
/// setting" a line says what the system reports, so it is plain what the choice comes to here.
|
||||
fn animations_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::theme::Animations;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Animation effects:"));
|
||||
egui::ComboBox::from_id_salt("animation_effects").selected_text(tl!(app.ui.animations.label())).width(220.0).show_ui(ui, |ui| {
|
||||
for a in Animations::ALL {
|
||||
if ui.selectable_label(app.ui.animations == a, tl!(a.label())).clicked() {
|
||||
let _ = app.run("ui.animations", json!({"value": a.code()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
note(ui, "Smooth scrolling and zooming, and menus and buttons that fade in. Off shows every change at once.");
|
||||
if app.ui.animations == Animations::System {
|
||||
let reported = match app.system_animations() {
|
||||
Some(true) => "Your system has animation effects turned on.",
|
||||
Some(false) => "Your system has animation effects turned off.",
|
||||
None => "Your system doesn't report an animation setting, so animation effects stay on.",
|
||||
};
|
||||
note(ui, reported);
|
||||
}
|
||||
}
|
||||
|
||||
/// Interface language: follow the system (the default) or pick one (#8).
|
||||
fn language_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::i18n::{AUTO, Lang};
|
||||
@@ -518,4 +558,61 @@ mod tests {
|
||||
assert!(!h.state().session.prefs.typing_replaces_selection);
|
||||
assert!(h.query_by_label("Measurement units:").is_some());
|
||||
}
|
||||
|
||||
/// File › Options › Accessibility › Animation effects: Use system setting (the default), On
|
||||
/// or Off, with a line saying what the system's own setting is while it is followed.
|
||||
#[test]
|
||||
fn animation_effects_is_an_accessibility_option() {
|
||||
use crate::theme::Animations;
|
||||
let services = Services { animations_enabled: Some(Box::new(|| Some(false))), ..Default::default() };
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
|
||||
a.run("file.options", json!({"pane": "accessibility"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.animations, Animations::System);
|
||||
assert!(h.query_by_label("Animation effects:").is_some());
|
||||
assert!(h.query_by_label("Your system has animation effects turned off.").is_some());
|
||||
assert!(!h.state().animations_on());
|
||||
h.get_by_role(egui::accesskit::Role::ComboBox).click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("On").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.animations, Animations::On);
|
||||
assert!(h.state().animations_on());
|
||||
assert!(h.query_by_label("Your system has animation effects turned off.").is_none(), "only said while the system is followed");
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Animation: the checkbox is the `ui.caretAnimation` setting,
|
||||
/// and the section is translated.
|
||||
#[test]
|
||||
fn advanced_animation_turns_the_caret_animation_off_and_on() {
|
||||
let mut a = app();
|
||||
a.run("file.options", json!({"pane": "advanced"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
a,
|
||||
);
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("Animation").is_some(), "the section is there");
|
||||
h.get_by_label("Animate the caret (it glides and fades)").click();
|
||||
h.run_steps(4);
|
||||
assert!(!h.state().ui.caret_animation, "the checkbox turns it off");
|
||||
h.get_by_label("Animate the caret (it glides and fades)").click();
|
||||
h.run_steps(4);
|
||||
assert!(h.state().ui.caret_animation, "and on again");
|
||||
h.state_mut().run("ui.language", json!({"value": "es"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("Animación").is_some() && h.query_by_label("Animation").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,4 +162,46 @@ mod tests {
|
||||
let mut a = app_with(|| Some(ClipboardPicture::Files(vec![PathBuf::from("/tmp/notes.txt")])));
|
||||
assert_eq!(a.run("edit.paste", json!({})).unwrap_err(), "the clipboard is empty");
|
||||
}
|
||||
|
||||
/// The ribbon and context-menu Paste commands send no text; the system clipboard's text is
|
||||
/// pasted, as Ctrl+V pastes it, even with a picture copied alongside (#584).
|
||||
#[test]
|
||||
fn paste_without_text_reads_the_system_clipboard() {
|
||||
let services = Services {
|
||||
clipboard_text: Some(Box::new(|| Some("outside".to_string()))),
|
||||
clipboard_picture: Some(Box::new(|| Some(ClipboardPicture::Pixels { width: 4, height: 4, rgba: vec![200; 4 * 4 * 4] }))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
|
||||
a.run("edit.paste", json!({})).unwrap();
|
||||
a.run("edit.pasteText", json!({})).unwrap();
|
||||
a.run("edit.pasteMerge", json!({})).unwrap();
|
||||
assert_eq!(pictures(&mut a), 0);
|
||||
let focus = a.session.sel.focus.clone();
|
||||
assert_eq!(a.session.doc.para_at(&focus).map(|p| p.text.clone()), Some("outside".repeat(3)));
|
||||
// Paste Special offers the clipboard's text too.
|
||||
let r = a.run("edit.pasteSpecial", json!({})).unwrap();
|
||||
assert!(r["formats"].as_array().is_some_and(|f| f.iter().any(|x| x["id"] == "text")), "{r}");
|
||||
}
|
||||
|
||||
/// Ribbon Paste after copying a picture file in a file manager inserts the picture, not its
|
||||
/// path, as Ctrl+V does.
|
||||
#[test]
|
||||
fn ribbon_paste_of_copied_picture_files_inserts_them() {
|
||||
let path = std::env::temp_dir().join(format!("wc-ribbon-paste-{}.png", std::process::id()));
|
||||
std::fs::write(&path, png_from_rgba(4, 4, vec![200; 4 * 4 * 4]).unwrap()).unwrap();
|
||||
let (text, file) = (path.to_string_lossy().into_owned(), path.clone());
|
||||
let services = Services {
|
||||
clipboard_text: Some(Box::new(move || Some(text.clone()))),
|
||||
clipboard_picture: Some(Box::new(move || Some(ClipboardPicture::Files(vec![file.clone()])))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
|
||||
let r = a.run("edit.paste", json!({}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
r.unwrap();
|
||||
assert_eq!(pictures(&mut a), 1);
|
||||
let focus = a.session.sel.focus.clone();
|
||||
assert_eq!(a.session.doc.para_at(&focus).map(|p| p.text.contains("wc-ribbon-paste")), Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,17 @@ impl Previews {
|
||||
self.english_names.get(shown).cloned().unwrap_or_else(|| shown.to_string())
|
||||
}
|
||||
|
||||
/// Fonts were added or removed: list the families again and redraw the entries of
|
||||
/// `families` (an entry drawn before its font was added shows the fallback face).
|
||||
pub fn fonts_changed(&mut self, ctx: &egui::Context, families: impl Iterator<Item = String>) {
|
||||
self.families = None;
|
||||
ctx.data_mut(|d| {
|
||||
for f in families {
|
||||
d.remove::<TextureHandle>(egui::Id::new(format!("font:{f}")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A closure drawing a font-menu entry in its own face.
|
||||
pub fn font_preview_fn(&mut self) -> Box<dyn Fn(&mut Ui, &str) -> Response> {
|
||||
Box::new(|ui: &mut Ui, name: &str| {
|
||||
|
||||
@@ -163,10 +163,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)),
|
||||
"share",
|
||||
egui::Color32::WHITE,
|
||||
egui::Color32::WHITE,
|
||||
t.on_accent,
|
||||
t.on_accent,
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), egui::Color32::WHITE);
|
||||
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent);
|
||||
if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
|
||||
}
|
||||
@@ -414,8 +414,12 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
let fams = app.previews.families();
|
||||
let prev = app.previews.font_preview_fn();
|
||||
if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) {
|
||||
let f = app.previews.font_stored(&f);
|
||||
let _ = app.run("format.font", json!({"name": f}));
|
||||
let _ = if f == crate::widgets::ADD_FONT {
|
||||
app.run("font.add", json!({}))
|
||||
} else {
|
||||
let f = app.previews.font_stored(&f);
|
||||
app.run("format.font", json!({"name": f}))
|
||||
};
|
||||
}
|
||||
let size = st
|
||||
.get("size")
|
||||
@@ -1274,7 +1278,19 @@ fn references(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
group(ui, "Table of Authorities", None, app, |ui, app| {
|
||||
big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false);
|
||||
small(ui, app, "tableOfAuthorities", None, "Insert Table of Authorities", "references.tableOfAuthorities", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(
|
||||
ui,
|
||||
app,
|
||||
"tableOfAuthorities",
|
||||
Some("Insert Table of Authorities"),
|
||||
"Insert Table of Authorities",
|
||||
"references.tableOfAuthorities",
|
||||
json!({}),
|
||||
false,
|
||||
);
|
||||
small(ui, app, "update", Some("Update Table"), "Update Table", "references.updateAuthorities", json!({}), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
//! - after a touchpad flick the page coasts and slows down ([`Momentum`]), on platforms whose
|
||||
//! system doesn't already send momentum events (not macOS, Windows or the web);
|
||||
//! - a mouse-wheel notch scrolls about three text lines and eases in over ~0.1 s, like egui.
|
||||
//!
|
||||
//! With animation effects off ([`crate::theme::Animations`]) nothing eases: a notch scrolls the
|
||||
//! page its whole distance at once, and so do egui's own scroll areas ([`unsmoothed_scroll`]).
|
||||
//! Ctrl/⌘+wheel zooms in whole steps, each at once ([`CanvasScroll::zoom`]). Touchpad momentum
|
||||
//! stays: it carries on the fingers' own movement, as the momentum macOS and Windows send does
|
||||
//! whatever their animation setting.
|
||||
|
||||
use egui::{Event, InputOptions, Modifiers, MouseWheelUnit, TouchPhase, Vec2};
|
||||
|
||||
@@ -37,6 +43,17 @@ const MAX_SPEED: f32 = 8000.0;
|
||||
/// Exponential decay rate of the coast (1/s): speed falls to 1/e every ~0.33 s.
|
||||
const DECAY: f32 = 3.0;
|
||||
|
||||
/// With animation effects off, Ctrl/⌘+wheel zooms in steps of this size (the natural logarithm of
|
||||
/// the factor: about 10%). A mouse-wheel notch is two steps at once, the same zoom as with
|
||||
/// animation effects on. Turns that arrive in small pieces (a touchpad pinch that Windows reports
|
||||
/// as Ctrl+wheel, a free-spinning or high-resolution wheel) add up to a step first, so the zoom
|
||||
/// moves in a few plain steps instead of gliding.
|
||||
const ZOOM_STEP: f32 = 0.1;
|
||||
/// Part of a zoom step is forgotten after this many seconds without more Ctrl/⌘+wheel input.
|
||||
const ZOOM_STEP_IDLE: f64 = 0.75;
|
||||
/// Most zoom steps taken from one frame's input (the zoom itself is clamped to 10–500%).
|
||||
const MAX_ZOOM_STEPS: f32 = 64.0;
|
||||
|
||||
/// Kinetic scrolling for touchpads: tracks the scroll velocity while the fingers move and keeps
|
||||
/// scrolling with exponentially decaying speed after they lift. Time-based, so frame-rate
|
||||
/// independent. Deltas are in screen points, positive = content moves down/right.
|
||||
@@ -129,19 +146,25 @@ impl Momentum {
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas's scrolling state: momentum plus the not-yet-scrolled part of wheel notches.
|
||||
/// The canvas's scrolling state: momentum plus the not-yet-scrolled part of wheel notches, and
|
||||
/// the part of a zoom step the wheel has turned so far.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CanvasScroll {
|
||||
momentum: Momentum,
|
||||
/// Wheel distance still to scroll, eased out over a few frames.
|
||||
pending: Vec2,
|
||||
/// Ctrl/⌘+wheel zoom not yet applied (natural logarithm, less than a [`ZOOM_STEP`]), and the
|
||||
/// input time it last grew. Only used with animation effects off.
|
||||
zoom_pending: f32,
|
||||
zoom_time: f64,
|
||||
}
|
||||
|
||||
impl CanvasScroll {
|
||||
/// This frame's scroll delta in screen points (positive = content moves down/right) from the
|
||||
/// wheel/touchpad events in `input`. `hovered`: the pointer is over the canvas (only then do
|
||||
/// its wheel events scroll it). `notch`: points per wheel notch. `page`: viewport height.
|
||||
pub fn frame(&mut self, input: &egui::InputState, opts: &InputOptions, hovered: bool, notch: f32, page: f32) -> Vec2 {
|
||||
/// `animate`: animation effects are on, so wheel notches ease in rather than land at once.
|
||||
pub fn frame(&mut self, input: &egui::InputState, opts: &InputOptions, hovered: bool, notch: f32, page: f32, animate: bool) -> Vec2 {
|
||||
let notch = if notch.is_finite() { notch.clamp(1.0, 1000.0) } else { NOTCH_PX };
|
||||
let page = if page.is_finite() { page.clamp(0.0, 100_000.0) } else { 0.0 };
|
||||
// Browsers report lines (Firefox: three per notch) where native platforms report notches.
|
||||
@@ -196,9 +219,10 @@ impl CanvasScroll {
|
||||
if SYNTH_MOMENTUM && !fed {
|
||||
now += self.momentum.tick(dt);
|
||||
}
|
||||
// Ease wheel notches in: 90% of the way in 0.1 s (egui's curve).
|
||||
// Ease wheel notches in: 90% of the way in 0.1 s (egui's curve). Without animation effects
|
||||
// the whole distance lands this frame.
|
||||
if self.pending != Vec2::ZERO {
|
||||
let t = if dt.is_finite() { egui::emath::exponential_smooth_factor(0.9, 0.1, dt.clamp(0.0, MAX_DT)) } else { 1.0 };
|
||||
let t = if animate && dt.is_finite() { egui::emath::exponential_smooth_factor(0.9, 0.1, dt.clamp(0.0, MAX_DT)) } else { 1.0 };
|
||||
let ease = |pending: f32| if pending.abs() < 1.0 { pending } else { pending * t };
|
||||
let step = Vec2::new(ease(self.pending.x), ease(self.pending.y));
|
||||
now += step;
|
||||
@@ -217,6 +241,42 @@ impl CanvasScroll {
|
||||
pub fn is_animating(&self) -> bool {
|
||||
self.pending != Vec2::ZERO || self.momentum.is_active()
|
||||
}
|
||||
|
||||
/// This frame's zoom factor from Ctrl/⌘+wheel and pinch gestures (1.0: none). With `animate`
|
||||
/// it is egui's, which eases a wheel notch's zoom in over a few frames. Without, the wheel
|
||||
/// zooms in whole steps ([`ZOOM_STEP`]), each at once, however finely the turns arrive; a
|
||||
/// pinch the system reports as a pinch still follows the fingers.
|
||||
pub fn zoom(&mut self, input: &egui::InputState, opts: &InputOptions, animate: bool) -> f32 {
|
||||
// Fingers on a touch screen are measured directly, never eased.
|
||||
if animate || input.multi_touch().is_some() {
|
||||
self.zoom_pending = 0.0;
|
||||
return input.zoom_delta();
|
||||
}
|
||||
let mut zoom = 1.0_f32;
|
||||
let mut turned = 0.0_f32;
|
||||
for event in &input.events {
|
||||
match event {
|
||||
Event::Zoom(factor) => zoom *= factor,
|
||||
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } if modifiers.matches_any(opts.zoom_modifier) => {
|
||||
let d = egui_points(*unit, *delta, input, opts);
|
||||
turned += opts.scroll_zoom_speed * (d.x + d.y);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if turned != 0.0 {
|
||||
if input.time - self.zoom_time > ZOOM_STEP_IDLE {
|
||||
self.zoom_pending = 0.0;
|
||||
}
|
||||
self.zoom_time = input.time;
|
||||
let total = self.zoom_pending + turned;
|
||||
// A hair of tolerance: a notch's two steps mustn't come out as 1.9999.
|
||||
let steps = if total.is_finite() { (total / ZOOM_STEP + 1e-3_f32.copysign(total)).trunc() } else { 0.0 };
|
||||
self.zoom_pending = if total.is_finite() { total - steps * ZOOM_STEP } else { 0.0 };
|
||||
zoom *= (steps.clamp(-MAX_ZOOM_STEPS, MAX_ZOOM_STEPS) * ZOOM_STEP).exp();
|
||||
}
|
||||
if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift scrolls sideways and Alt scrolls vertically, like egui's own scroll areas.
|
||||
@@ -230,6 +290,33 @@ fn axis_lock(d: Vec2, modifiers: Modifiers, opts: &InputOptions) -> Vec2 {
|
||||
}
|
||||
}
|
||||
|
||||
/// A wheel event's delta in points the way egui's own scroll areas count it: a line is
|
||||
/// `line_scroll_speed` points and a page is the window's height.
|
||||
fn egui_points(unit: MouseWheelUnit, delta: Vec2, input: &egui::InputState, opts: &InputOptions) -> Vec2 {
|
||||
let d = match unit {
|
||||
MouseWheelUnit::Point => delta,
|
||||
MouseWheelUnit::Line => delta * opts.line_scroll_speed,
|
||||
MouseWheelUnit::Page => delta * input.viewport_rect().height(),
|
||||
};
|
||||
if d.is_finite() { d } else { Vec2::ZERO }
|
||||
}
|
||||
|
||||
/// This frame's wheel scrolling for egui's own scroll areas (panes, lists, dialogs, Backstage)
|
||||
/// with nothing held back: egui eases a wheel notch in over the next frames
|
||||
/// (`InputState::smooth_scroll_delta`) and has no switch for it, so with animation effects off the
|
||||
/// app puts this in its place every frame. Ctrl/⌘+wheel is left out, as in egui: it zooms.
|
||||
pub fn unsmoothed_scroll(input: &egui::InputState, opts: &InputOptions) -> Vec2 {
|
||||
let mut total = Vec2::ZERO;
|
||||
for event in &input.events {
|
||||
if let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } = event
|
||||
&& !modifiers.matches_any(opts.zoom_modifier)
|
||||
{
|
||||
total += axis_lock(egui_points(*unit, *delta, input, opts), *modifiers, opts);
|
||||
}
|
||||
}
|
||||
if total.is_finite() { total } else { Vec2::ZERO }
|
||||
}
|
||||
|
||||
/// Points per wheel notch at `zoom` (1.0 = 100%): more when zoomed in, less when zoomed out, but
|
||||
/// gently, so zoomed-out overviews don't crawl and close-ups don't jump.
|
||||
pub fn notch_px(zoom: f32) -> f32 {
|
||||
@@ -309,6 +396,111 @@ mod tests {
|
||||
assert!(!m.is_active());
|
||||
}
|
||||
|
||||
fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event {
|
||||
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers }
|
||||
}
|
||||
|
||||
/// One 60 fps frame over a tall egui scroll area with the pointer on it; `animate` false puts
|
||||
/// [`unsmoothed_scroll`] in place of egui's eased delta, as the app does. Returns the scroll
|
||||
/// area's offset and the frame's [`CanvasScroll::zoom`] (its state is kept with the context).
|
||||
fn egui_frame(ctx: &egui::Context, frame_no: &mut u32, animate: bool, mut events: Vec<Event>) -> (f32, f32) {
|
||||
*frame_no += 1;
|
||||
events.insert(0, Event::PointerMoved(egui::pos2(50.0, 50.0)));
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0));
|
||||
let input = egui::RawInput { events, time: Some(f64::from(*frame_no) / 60.0), screen_rect: Some(screen), ..Default::default() };
|
||||
let mut seen = (0.0, 1.0);
|
||||
ctx.run_ui(input, |ui| {
|
||||
let opts = ui.ctx().options(|o| o.input_options);
|
||||
if !animate {
|
||||
ui.ctx().input_mut(|i| i.smooth_scroll_delta = unsmoothed_scroll(i, &opts));
|
||||
}
|
||||
let id = egui::Id::new("canvas scroll under test");
|
||||
let mut scroll = ui.data(|d| d.get_temp::<CanvasScroll>(id)).unwrap_or_default();
|
||||
let zoom = ui.input(|i| scroll.zoom(i, &opts, animate));
|
||||
ui.data_mut(|d| d.insert_temp(id, scroll));
|
||||
let out = egui::ScrollArea::vertical().show(ui, |ui| ui.allocate_space(egui::vec2(100.0, 5000.0)));
|
||||
seen = (out.state.offset.y, zoom);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
seen
|
||||
}
|
||||
|
||||
/// egui's own scroll areas ease a wheel notch in over several frames; with animation effects
|
||||
/// off the notch lands whole in its frame, and nothing more arrives afterwards.
|
||||
#[test]
|
||||
fn egui_scroll_areas_take_a_wheel_notch_at_once_without_animation() {
|
||||
let line = InputOptions::default().line_scroll_speed;
|
||||
let notch = || vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, -1.0), Modifiers::NONE)];
|
||||
let settle = |ctx: &egui::Context, n: &mut u32, animate| (0..40).map(|_| egui_frame(ctx, n, animate, Vec::new()).0).last().unwrap();
|
||||
|
||||
let (ctx, mut n) = (egui::Context::default(), 0);
|
||||
egui_frame(&ctx, &mut n, true, Vec::new());
|
||||
let first = egui_frame(&ctx, &mut n, true, notch()).0;
|
||||
assert!(first > 0.0 && first < line, "egui eases the notch in: {first}");
|
||||
assert!((settle(&ctx, &mut n, true) - line).abs() < 0.5);
|
||||
|
||||
let (ctx, mut n) = (egui::Context::default(), 0);
|
||||
egui_frame(&ctx, &mut n, false, Vec::new());
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, notch()).0, line, "the whole notch in one frame");
|
||||
assert_eq!(settle(&ctx, &mut n, false), line, "and none of egui's leftover easing after it");
|
||||
// Shift turns the notch sideways, and Ctrl/⌘+wheel zooms rather than scrolls, as in egui.
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, -1.0), Modifiers::SHIFT)]).0, line);
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, -1.0), Modifiers::COMMAND)]).0, line);
|
||||
// Touchpad deltas pass through as they are.
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, vec![wheel(MouseWheelUnit::Point, Vec2::new(0.0, -3.0), Modifiers::NONE)]).0, line + 3.0);
|
||||
// A junk delta scrolls nothing. Last: egui's own wheel state doesn't recover from it.
|
||||
let junk = vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, f32::NEG_INFINITY), Modifiers::NONE)];
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, junk).0, line + 3.0);
|
||||
}
|
||||
|
||||
/// Ctrl/⌘+wheel: egui spreads a notch's zoom over several frames. With animation effects off
|
||||
/// the wheel zooms in whole steps, each at once: a notch is two steps in its own frame, and
|
||||
/// turns that arrive in small pieces add up to a step first rather than each zooming a little.
|
||||
#[test]
|
||||
fn wheel_zoom_steps_at_once_without_animation() {
|
||||
let opts = InputOptions::default();
|
||||
let notch_zoom = (opts.scroll_zoom_speed * opts.line_scroll_speed).exp();
|
||||
let step = ZOOM_STEP.exp();
|
||||
let turn = |notches: f32| vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, notches), Modifiers::COMMAND)];
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1e-5;
|
||||
|
||||
let (ctx, mut n) = (egui::Context::default(), 0);
|
||||
egui_frame(&ctx, &mut n, true, Vec::new());
|
||||
let first = egui_frame(&ctx, &mut n, true, turn(1.0)).1;
|
||||
assert!(first > 1.0 && first < notch_zoom, "egui eases the zoom in: {first} of {notch_zoom}");
|
||||
|
||||
let (ctx, mut n) = (egui::Context::default(), 0);
|
||||
egui_frame(&ctx, &mut n, false, Vec::new());
|
||||
let zoom = egui_frame(&ctx, &mut n, false, turn(1.0)).1;
|
||||
assert!(near(zoom, notch_zoom) && near(zoom, step * step), "a notch is two steps at once: {zoom}");
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, Vec::new()).1, 1.0, "nothing left for later frames");
|
||||
assert!(near(egui_frame(&ctx, &mut n, false, turn(-1.0)).1, 1.0 / notch_zoom));
|
||||
// Quarter notches, as a touchpad pinch or a free-spinning wheel sends them: a step with
|
||||
// every second one and nothing in between, never a little with each.
|
||||
let zooms: Vec<f32> = (0..4).map(|_| egui_frame(&ctx, &mut n, false, turn(0.25)).1).collect();
|
||||
assert!(zooms[0] == 1.0 && near(zooms[1], step) && zooms[2] == 1.0 && near(zooms[3], step), "{zooms:?}");
|
||||
let zooms: Vec<f32> = (0..4).map(|_| egui_frame(&ctx, &mut n, false, turn(-0.25)).1).collect();
|
||||
assert!(zooms[0] == 1.0 && near(zooms[1], 1.0 / step) && zooms[2] == 1.0 && near(zooms[3], 1.0 / step), "{zooms:?}");
|
||||
// Part of a step doesn't wait for ever: after a pause the count starts again.
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, turn(0.25)).1, 1.0);
|
||||
for _ in 0..60 {
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, Vec::new()).1, 1.0);
|
||||
}
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, turn(0.25)).1, 1.0, "the quarter turn of a second ago has lapsed");
|
||||
assert!(near(egui_frame(&ctx, &mut n, false, turn(0.25)).1, step));
|
||||
// A pinch the system reports as a pinch arrives as a zoom factor of its own and follows the
|
||||
// fingers; a plain notch doesn't zoom.
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, vec![Event::Zoom(1.5)]).1, 1.5);
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, vec![wheel(MouseWheelUnit::Line, Vec2::new(0.0, 1.0), Modifiers::NONE)]).1, 1.0);
|
||||
// An absurd turn is a bounded zoom, and junk never comes out as a zoom factor (the endless
|
||||
// wheel delta last, as above).
|
||||
let huge = egui_frame(&ctx, &mut n, false, turn(1e6)).1;
|
||||
assert!(huge > notch_zoom && huge <= (MAX_ZOOM_STEPS * ZOOM_STEP).exp() * 1.001, "{huge}");
|
||||
for junk in [Event::Zoom(f32::NAN), Event::Zoom(0.0), wheel(MouseWheelUnit::Page, Vec2::new(0.0, f32::INFINITY), Modifiers::COMMAND)] {
|
||||
assert_eq!(egui_frame(&ctx, &mut n, false, vec![junk]).1, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_are_ignored() {
|
||||
let mut m = Momentum::default();
|
||||
|
||||
+117
-3
@@ -128,6 +128,72 @@ impl From<InterfaceSize> for u16 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Animation effects setting (File › Options › Accessibility, `ui.animations`): on, off, or
|
||||
/// `System`, which follows the system's own setting as the host reports it
|
||||
/// ([`Services::animations_enabled`](crate::Services::animations_enabled): Windows' Animation
|
||||
/// effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's
|
||||
/// `prefers-reduced-motion`).
|
||||
/// Off: wheel notches scroll and zoom at once ([`crate::scroll`]) and nothing fades or glides
|
||||
/// ([`apply_animations`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum Animations {
|
||||
#[default]
|
||||
System,
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
impl Animations {
|
||||
/// In menu order.
|
||||
pub const ALL: [Animations; 3] = [Animations::System, Animations::On, Animations::Off];
|
||||
|
||||
/// The value used in `ui.json` and by the `ui.animations` command.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
Animations::System => "system",
|
||||
Animations::On => "on",
|
||||
Animations::Off => "off",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<Animations> {
|
||||
Animations::ALL.into_iter().find(|a| a.code().eq_ignore_ascii_case(code.trim()))
|
||||
}
|
||||
|
||||
/// The English label (translated with `tl!` where shown).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Animations::System => "Use system setting",
|
||||
Animations::On => "On",
|
||||
Animations::Off => "Off",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether animation effects are on, given what the system reports (`None`: it doesn't say).
|
||||
/// `System` falls back to on when the system reports nothing.
|
||||
pub fn enabled(self, system: Option<bool>) -> bool {
|
||||
match self {
|
||||
Animations::System => system.unwrap_or(true),
|
||||
Animations::On => true,
|
||||
Animations::Off => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown saved value reads as the default rather than discarding the whole `ui.json`.
|
||||
impl From<String> for Animations {
|
||||
fn from(s: String) -> Self {
|
||||
Animations::from_code(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Animations> for String {
|
||||
fn from(a: Animations) -> Self {
|
||||
a.code().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every colour the UI uses; widgets never hard-code colours.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Tokens {
|
||||
@@ -194,7 +260,7 @@ impl Tokens {
|
||||
accent_text: APP_INK,
|
||||
on_accent: Color32::WHITE,
|
||||
input: Color32::WHITE,
|
||||
input_border: Color32::from_rgb(0xC7, 0xC7, 0xC7),
|
||||
input_border: Color32::from_rgb(0x8A, 0x8A, 0x8A),
|
||||
group_label: Color32::from_rgb(0x70, 0x70, 0x70),
|
||||
ruler: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||
ruler_margin: Color32::from_rgb(0xD4, 0xD4, 0xD4),
|
||||
@@ -232,9 +298,10 @@ impl Tokens {
|
||||
checked: Color32::from_rgb(0x2E, 0x3A, 0x66),
|
||||
accent: Color32::from_rgb(0x7A, 0x93, 0xF0),
|
||||
accent_text: Color32::from_rgb(0x9C, 0xB0, 0xF5),
|
||||
on_accent: Color32::WHITE,
|
||||
// Dark text on the light dark-theme accent: white on #7a93f0 is only 2.9:1 (#655).
|
||||
on_accent: Color32::from_rgb(0x11, 0x16, 0x2E),
|
||||
input: Color32::from_rgb(0x1E, 0x1E, 0x1E),
|
||||
input_border: Color32::from_rgb(0x58, 0x58, 0x58),
|
||||
input_border: Color32::from_rgb(0x7A, 0x7A, 0x7A),
|
||||
group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0),
|
||||
ruler: Color32::from_rgb(0x2E, 0x2E, 0x2E),
|
||||
ruler_margin: Color32::from_rgb(0x45, 0x45, 0x45),
|
||||
@@ -511,6 +578,17 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Turn egui's own animations on or off: hover and press fades, menus, tooltips and windows fading
|
||||
/// in, sections sliding open, and the glide when a scroll area brings the caret or a list item
|
||||
/// into view. Off, each of them happens at once.
|
||||
pub fn apply_animations(ctx: &egui::Context, on: bool) {
|
||||
let defaults = egui::Style::default();
|
||||
ctx.all_styles_mut(|s| {
|
||||
s.animation_time = if on { defaults.animation_time } else { 0.0 };
|
||||
s.scroll_animation = if on { defaults.scroll_animation } else { egui::style::ScrollAnimation::none() };
|
||||
});
|
||||
}
|
||||
|
||||
/// egui's visuals for one of our palettes.
|
||||
fn visuals(t: &Tokens) -> Visuals {
|
||||
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
|
||||
@@ -555,6 +633,42 @@ pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// WCAG contrast ratio between two opaque colours.
|
||||
pub(super) fn contrast(a: egui::Color32, b: egui::Color32) -> f32 {
|
||||
let lum = |c: egui::Color32| {
|
||||
let ch = |v: u8| {
|
||||
let v = f32::from(v) / 255.0;
|
||||
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b())
|
||||
};
|
||||
let (x, y) = (lum(a), lum(b));
|
||||
(x.max(y) + 0.05) / (x.min(y) + 0.05)
|
||||
}
|
||||
|
||||
/// #655: text and knobs on filled accent controls (Share, avatar, AutoSave, keytips) reach 4.5:1
|
||||
/// on the accent and on its hover shade, in both themes.
|
||||
#[test]
|
||||
fn on_accent_reads_on_the_accent() {
|
||||
for t in [super::Tokens::light(), super::Tokens::dark()] {
|
||||
for (name, bg) in [("accent", t.accent), ("hover", t.accent_text)] {
|
||||
let r = contrast(t.on_accent, bg);
|
||||
assert!(r >= 4.5, "dark={}: on_accent on {name} is {r:.2}:1", t.dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #657: text field and combo outlines reach 3:1 against the field and everything it sits on.
|
||||
#[test]
|
||||
fn input_borders_reach_three_to_one() {
|
||||
for t in [super::Tokens::light(), super::Tokens::dark()] {
|
||||
for (name, bg) in [("input", t.input), ("ribbon", t.ribbon), ("panel", t.panel), ("menu", t.menu), ("title bar", t.title_bar)] {
|
||||
let r = contrast(t.input_border, bg);
|
||||
assert!(r >= 3.0, "dark={}: input_border on {name} is {r:.2}:1", t.dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #565: the composition of an input method (Bangla through ibus) drew as boxes because only the
|
||||
/// interface fonts were used. Faces found for the uncovered characters are added as fallbacks,
|
||||
/// once, and not for characters the interface fonts draw.
|
||||
|
||||
@@ -425,7 +425,12 @@ pub fn combo(
|
||||
combo_impl(ui, id, width, current, items, preview, false)
|
||||
}
|
||||
|
||||
/// What [`font_combo`] returns when its list's last entry, Add Font…, is chosen (#582): the
|
||||
/// caller runs `font.add`, which shows a file picker. Not a family name anyone can type.
|
||||
pub const ADD_FONT: &str = "\u{1}font.add";
|
||||
|
||||
/// The font box: a [`combo`] where typing lists the matching families under the field (#359).
|
||||
/// Its list ends with Add Font… ([`ADD_FONT`]).
|
||||
/// Up/Down move the highlight, Enter or a click applies the highlighted family ("arial" applies
|
||||
/// "Arial"), Escape restores the current font. Text matching no family is applied as typed, as
|
||||
/// Word allows fonts that are not installed.
|
||||
@@ -566,6 +571,13 @@ fn combo_impl(
|
||||
out = Some(v);
|
||||
ui.close();
|
||||
}
|
||||
if search {
|
||||
ui.separator();
|
||||
if ui.button(tl!("Add Font…")).clicked() {
|
||||
out = Some(ADD_FONT.to_string());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if s.open && !s.matches.is_empty() {
|
||||
let matches = s.matches.clone();
|
||||
@@ -673,7 +685,8 @@ fn list_rows<'a>(
|
||||
/// How a drawn button looks this frame. Hover fades in over egui's animation time and the
|
||||
/// pressed state blends in while the pointer is down, as on egui's own buttons. Framed buttons
|
||||
/// take egui's widget visuals (fill, stroke, text colour, expansion) so they match `ui.button`;
|
||||
/// frameless ones take the ribbon's hover/pressed tokens, like [`small`] and [`big`].
|
||||
/// frameless ones take the ribbon's hover/pressed tokens, like [`small`] and [`big`]. With
|
||||
/// animation effects off (egui's animation time is zero) both states show at once.
|
||||
pub struct DrawnState {
|
||||
pub fill: Color32,
|
||||
pub stroke: Stroke,
|
||||
@@ -684,7 +697,7 @@ pub struct DrawnState {
|
||||
pub fn drawn_state(ui: &Ui, resp: &Response, framed: bool) -> DrawnState {
|
||||
let pressed = resp.is_pointer_button_down_on();
|
||||
let h = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() || pressed);
|
||||
let p = ui.ctx().animate_bool_with_time(resp.id.with("press"), pressed, 0.06);
|
||||
let p = ui.ctx().animate_bool_with_time(resp.id.with("press"), pressed, ui.style().animation_time.min(0.06));
|
||||
if framed {
|
||||
let w = &ui.visuals().widgets;
|
||||
let mix = |a: Color32, b: Color32, c: Color32| a.lerp_to_gamma(b, h).lerp_to_gamma(c, p);
|
||||
|
||||
@@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`)…) |
|
||||
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) |
|
||||
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
|
||||
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (native save temporary-file ownership and failure protection; previously trivial: Print Markup: comments in PDF/print/copies, #589; previously trivial: SmartArt written from WordCraft's model, #497; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -70,7 +70,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
|
||||
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
|
||||
| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read; attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles |
|
||||
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) |
|
||||
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties round-trip; bibliography model I/O remains separate (#532 open) |
|
||||
| Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) |
|
||||
| Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language |
|
||||
| Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), paragraph `w:snapToGrid` | ✅ | ✅ | #391; run-level `w:snapToGrid` not read |
|
||||
@@ -99,6 +99,29 @@ The [public OOXML reference](https://learn.microsoft.com/en-us/dotnet/api/docume
|
||||
describes omission as leaving paragraph defaults application-defined. Real-world corpus
|
||||
coverage and WordCraft layout matching remain separate gaps.
|
||||
|
||||
## Custom XML store preservation
|
||||
|
||||
A reduced original bound-control DOCX loses its backing data, item properties and relationship
|
||||
on main, both without editing and after unrelated edits. Main's content-control support (#381)
|
||||
preserves control metadata, including binding attributes, while this custom XML path keeps the
|
||||
backing store through the bounded opaque part graph: payload bytes, content types and
|
||||
related-part IDs survive two edited save/reopen cycles.
|
||||
|
||||
The public [dataBinding contract](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.databinding?view=openxml-3.0.1)
|
||||
maps a control to a custom XML data part; its [item properties](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.customxmldataproperties.datastoreitem?view=openxml-3.0.1)
|
||||
identify the store. Keeping displayed text alone does not preserve that mapping. This change
|
||||
preserves stores without evaluating XPath or synchronizing control edits into XML.
|
||||
|
||||
Focused original tests cover package/main roots, multiple/shared stores, cycles, external schema
|
||||
references, missing parts, UTF-16 payloads, strict relationship types and reserved filenames.
|
||||
The bibliography names used by #532 are reserved; bibliography already loaded into the source
|
||||
model belongs to its writer. Word-derived files are not fixtures. No readiness estimate rises
|
||||
from these synthetic package checks, and native Word rendering/binding behavior remains untested.
|
||||
|
||||
## Native save-path guarantees
|
||||
Native `io::save_path_with` serializes (and, for password-protected DOCX, encrypts) before creating an exclusively owned sibling temporary file. It writes and synchronizes that file, closes it, then replaces the destination. Failed preparation or replacement keeps the original bytes; cleanup removes only the save's temporary file, on a best-effort basis. Existing `<name>.tmp` files, directories and links are untouched. A new document gets the default mode (`0666` less the umask). When replacing an existing regular file, the Unix temporary file starts with mode `0600` and then takes the existing file's basic permissions. Serialization and format support are unchanged.
|
||||
Synthetic CLI cases and `engine/tests/safe_save.rs` cover occupied names, links, blocked replacement, missing directories, Save As session state, password-protected saves and original-file protection on macOS. Unit tests inject preparation errors and interleave two prepared saves. Independent saves use separate files; the last successful replacement wins. This is not document locking or conflict detection.
|
||||
Windows/Linux runtime behavior, ACLs and extended metadata, real disk-full conditions, power loss and network filesystems are not verified by these tests. The containing directory is not explicitly synchronized. Cleanup can fail when storage or permissions change, and process termination can leave a temporary file. No Word observation was needed for this filesystem change; it makes no new format-fidelity claim.
|
||||
## What decides beta for formats
|
||||
|
||||
1. A **real-world DOCX corpus** opened, rendered and round-tripped, compared page by page with
|
||||
@@ -134,6 +157,8 @@ coverage and WordCraft layout matching remain separate gaps.
|
||||
| 2026-10-10 | trivial | Right-to-left tables (`w:bidiVisual`) read and written (#362) |
|
||||
| 2026-10-10 | trivial | Drawing canvas (`wpc:wpc`) read and written (#344); group shapes row brought up to date (#267) |
|
||||
| 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) |
|
||||
| 2026-10-12 | minor | Preserve opaque custom XML stores and related properties; bindings and control support remain separate |
|
||||
| 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` |
|
||||
| 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) |
|
||||
| 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) |
|
||||
|
||||
+12
-3
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Animation effects setting landed, following the system's setting on Windows, macOS, Linux/BSD and the web; previously trivial: Word XML Document and Word 2003 XML landed, #548; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -9,6 +9,8 @@ The work list. One entry per shortfall, ranked by how much it stops a Word user
|
||||
wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs
|
||||
first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
|
||||
Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees).
|
||||
|
||||
## Ranked
|
||||
|
||||
| # | Gap | B | Evidence | Impact | Hours | Doc |
|
||||
@@ -17,7 +19,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
|
||||
| 2 | **Ink and drawing canvases are dropped on open and lost on save, silently**; OLE objects show only their picture | B | No ink or drawing-canvas (`wpc`) handling in `crates/docx`; group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Documents with diagrams drawn in Word or pen notes lose content without warning | 4–8 to preserve ink and drawing canvases; 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) |
|
||||
| 3 | **Crashes and freezes in the field**: startup crash on Intel UHD Graphics (#170) (lag on GPUs without a DirectX 12 driver fixed, #316), freezes (#77, #59 Thai fonts, #31 font selector), no writes without admin on Windows 11 (#218), broken shape tool (#142), images can't be moved (#82) | B | 62 open issues, ~15 of them crashes, hangs or data-path failures | Anyone hit loses work or trust; the never-crash standard covers panics, not hangs or GPU driver faults | 20–30 | [target-app-parity.md](target-app-parity.md) (Stability) |
|
||||
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft. Opaque custom XML backing stores now survive saves; binding evaluation remains absent. | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
|
||||
| 7 | **Dialog depth**: 54 modal dialogs vs Word's ~100 | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
|
||||
| 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) |
|
||||
@@ -56,7 +58,10 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
re-implementing). Paste Special, Advanced Find, Column Selection, Style Inspector, custom table
|
||||
styles, and Draw Table and Eraser (#303) landed on 2026-10-10.
|
||||
- **UI/UX gaps:** #7, #18, #24; crop handles on the canvas (rotation handle landed, #332); table column drag on the
|
||||
ruler (#49, #217); context menus for objects and tables; Linux title bar theming (#78).
|
||||
ruler (#49, #217); context menus for objects and tables; Linux title bar theming (#78); File ›
|
||||
Options › Animation effects › Use system setting reads macOS (Reduce motion) and the Linux/BSD
|
||||
desktops (`apps/wordcraft/src/animations.rs`) but hasn't been tried on a Mac or in a real
|
||||
GNOME or KDE session yet.
|
||||
- **File-format gaps:** #1, #2, #5, #9, #14, #19, #23, #26; embedded fonts; glossary/building blocks.
|
||||
- **Hardware gaps:** #6, #12, #28; scanner/Continuity Camera insert; KDE soft text (#140).
|
||||
- **Localization gaps:** #8, #11, #16, #17; native-speaker review of every catalog (human).
|
||||
@@ -66,6 +71,8 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains |
|
||||
| 2026-10-11 | trivial | Animation effects setting landed (File › Options › Accessibility); UI/UX gaps list: the system's animation setting isn't read on macOS or Linux/BSD yet |
|
||||
| 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts |
|
||||
@@ -85,8 +92,10 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
| 2026-10-11 | trivial | #4: columns balance before a continuous section break (#350) |
|
||||
| 2026-10-11 | trivial | #21: Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets as recipient lists (#334); hours 6–10 → 4–8 |
|
||||
| 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap |
|
||||
| 2026-10-12 | minor | Preserve opaque custom XML data/properties through saves; gap #5 and readiness remain unchanged |
|
||||
| 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain |
|
||||
| 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain |
|
||||
| 2026-10-11 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
|
||||
| 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) |
|
||||
| 2026-10-10 | trivial | #24: 125 of ~250 keyboard shortcuts after #330 |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**482 of 497 catalog features have commands (97.0%).**
|
||||
**483 of 498 catalog features have commands (97.0%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
@@ -13,7 +13,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
| Draw | 10 | 11 |
|
||||
| Design | 10 | 10 |
|
||||
| Layout | 22 | 22 |
|
||||
| References | 24 | 24 |
|
||||
| References | 25 | 25 |
|
||||
| Mailings | 17 | 17 |
|
||||
| Review | 29 | 31 |
|
||||
| View | 25 | 29 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WordCraft parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Insert › SmartArt landed, #497; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Show revisions in balloons draws deletion balloons; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are
|
||||
@@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Ribbon/menu catalog coverage | **482 / 497 (97.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
|
||||
| Ribbon/menu catalog coverage | **483 / 498 (97.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
|
||||
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
|
||||
| Feature depth (weighted by use, table below) | **~68%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
|
||||
@@ -65,7 +65,7 @@ full parity for the area.
|
||||
| Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. |
|
||||
| Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). |
|
||||
| References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. Caption, Index, Mark Entry, Custom TOC and Create/Edit Source dialogs; updates honour the TOC (`\o \u \t \f \n \p \h`), INDEX (`\c \e \h \r`), XE (`\b \i \t \r`) and SEQ/STYLEREF chapter switches (#402). All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); bibliography sources not yet in DOCX (#169 open). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), Compare / Combine with comparison settings (case, white space, formatting, tables, word or character level, #412), Restrict Editing pane with formatting and editing restrictions, exceptions for everyone and a password (#412). Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Moves (#400): kept in DOCX, recorded on a tracked cut and paste, accepted/rejected at either end, green markup and "Moved from/to" balloons. Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (insertions stay inline, as in Word; deletions in headers, footers, notes and text boxes, and in print and PDF, stay inline). Missing: drag-and-drop moves, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). |
|
||||
| Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. |
|
||||
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |
|
||||
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), WordArt: text fill (solid, gradient, none), outline, shadow, glow and reflection with our own styles, and Transform warps along arch, circle, wave, slant, inflate/deflate, chevron and triangle (other presets kept and drawn flat), connectors (straight, elbow, curved, arrowheads) glued to shapes' connection sites and re-routed when those move (#416). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt 3-D/bevel and editing text in its bent form, connector routing around shapes, Drawing Canvas, Icons, online pictures, screenshot. |
|
||||
@@ -251,6 +251,7 @@ can be used without committing Word output.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Review: Show revisions in balloons takes tracked deletions in the body out of the text into "Deleted: …" balloons (it drew like the comments-and-formatting mode before); Review row updated, no percentage change |
|
||||
| 2026-10-11 | trivial | Insert › SmartArt (#497): charts/SmartArt row updated; catalog 429/451 → 437/458 (95.4%); no percentage change |
|
||||
| 2026-10-11 | trivial | Outline view depth (#493): View row updated; catalog 428/450 → 441/463 (95.2%) with the Outlining tab rows |
|
||||
| 2026-10-11 | trivial | Screen readers can read the document canvas (#488): Accessibility row updated; no percentage change |
|
||||
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (More Colors… and the Colors dialog, #606; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Animation effects setting; previously trivial: every measurement follows the Options unit, #755; previously trivial: Find and Replace special characters and wildcards, #543; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch.
|
||||
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
|
||||
@@ -28,9 +28,12 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 |
|
||||
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
|
||||
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
|
||||
| Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 |
|
||||
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — |
|
||||
| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — |
|
||||
| Interface size (File › Options › General, 80–200 %, on top of the system display scale; `ui.interfaceSize`) (#475) | ✅ | system display scaling only | 100% | — |
|
||||
| Animation effects (File › Options › Accessibility: Use system setting, On, Off; `ui.animations`). Off: a wheel notch scrolls at once; Ctrl+wheel zooms in whole steps, each drawn finished in its own frame (a notch at once; touchpad pinches and high-resolution wheels in steps of about 10% rather than gliding); menus, buttons and the scroll to the caret neither fade nor glide | ✅ "Use system setting" reads Windows (Animation effects), macOS (Reduce motion), the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers) and browsers (`prefers-reduced-motion`), and follows a change while running. Run on Windows and against a stand-in portal on Linux; not yet tried on a Mac or a real GNOME or KDE session | not compared | 90% | 0.5–1 |
|
||||
| File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — |
|
||||
| Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 |
|
||||
| Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); not yet tried with real screen readers | full | 50% | 5–8 |
|
||||
| Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization |
|
||||
@@ -39,6 +42,11 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Animation effects › Use system setting also reads macOS (Reduce motion) and the Linux/BSD desktops (settings portal: the cross-desktop reduced-motion preference, GNOME's switch, KDE's animation speed; the GTK and KDE settings files where no portal answers), and follows a change without a restart |
|
||||
| 2026-10-11 | trivial | Animation effects setting in File › Options › Accessibility (Use system setting, On, Off), kept between runs: the app eased wheel scrolling, zooming and its fades whatever the system's setting was; off, they land at once. Follows the system's setting on Windows and in browsers; `ui.animations` for agents |
|
||||
| 2026-10-11 | trivial | Built-in file browser when the system file dialog can't be shown on Linux/BSD; `ui.fileBrowser` (#576) |
|
||||
| 2026-10-11 | trivial | Every measurement now follows File › Options › Advanced › Measurement units: the Line Numbers "From text" distance and the Hyphenation zone were still fixed in inches (#755) |
|
||||
| 2026-10-11 | trivial | Track Changes Options › Show revisions in balloons works: tracked deletions in the body leave the text for "Deleted: …" balloons in the markup area, joined to where they were; inline again where no balloons show (Comments pane, Read Mode, side by side, Web Layout), and always inline in headers, footers, notes and text boxes |
|
||||
| 2026-10-11 | trivial | Mini toolbar in two rows like Word's (font, size, grow, shrink; B I U S, highlight, font colour, four alignments) with every button inside the bar; font and size lists drop from the box's left edge; dropdown arrows a readable size; font list entries take the theme's text colour in dark mode (#535) |
|
||||
| 2026-10-11 | trivial | Find and Replace: Special ▾ menu (^p, ^t, ^?, ^#, ^$, ^w, ^&, ^c … ; a shorter wildcard list), Use wildcards (`?` `*` `[a-z]` `[!x]` `{n,m}` `@` `<` `>` `( )`, `\1` in Replace with), Match prefix/suffix, Ignore punctuation and white space; `^p` matches and splits paragraphs, Replace All is one undo step (#543) |
|
||||
| 2026-10-11 | trivial | Update Style to Match Selection takes how the selection looks (its paragraph and character styles as well as direct formatting) and applies the style to it; Home › Styles gallery tiles get a right-click menu with Update to Match Selection and Modify… (#555) |
|
||||
@@ -46,6 +54,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| 2026-10-11 | trivial | File › Options as panes (General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents), every setting wired; `file.options` headless (#485) |
|
||||
| 2026-10-11 | trivial | Right-click on a picture, shape or text box selects it and offers Wrap Text: the seven wrapping styles (current one checked), Move with Text and Fix Position on Page (`arrange.moveWithText`) (#513) |
|
||||
| 2026-10-11 | trivial | Paragraph dialog: Mirror indents (Inside/Outside labels), Don't add space between paragraphs of the same style, Formatting exceptions (Suppress line numbers, Don't hyphenate) (#512) |
|
||||
| 2026-10-11 | trivial | The caret glides to its new place for keys and typing and its blink fades, as measured from Word for Windows; File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents |
|
||||
| 2026-10-11 | trivial | Screen readers: the document canvas exposes paragraphs, headings, lists, tables, links, pictures and the caret through AccessKit (#488); 25% → 50% |
|
||||
| 2026-10-11 | trivial | More Colors… in every colour drop-down and a Colors dialog (Standard and Custom tabs, RGB/HSL numbers, hex box) with Recent Colors (#606) |
|
||||
| 2026-10-11 | trivial | Selection by units: double-click and drag extends by whole words, triple-click and drag by paragraphs, Ctrl/⌘+click and drag by sentences; `select.dragUnit` (#549) |
|
||||
@@ -53,13 +62,17 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
|
||||
| 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one |
|
||||
| 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) |
|
||||
| 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) |
|
||||
| 2026-10-11 | trivial | Dark theme: Share, the avatar initials, the AutoSave knob and keytips use dark `on_accent` text on the light accent (6.2:1, 8.4:1 on hover; white was 2.9:1) (#655) |
|
||||
| 2026-10-11 | trivial | Text field and combo outlines reach 3:1 against their fill and surroundings in both themes (light #8A8A8A, dark #7A7A7A); a test pins the ratios (#657) |
|
||||
| 2026-10-11 | trivial | Font dialog: Asian text font and Latin text font boxes, each with its own list and type-to-find, shown for a Chinese/Japanese interface or East Asian text in the selection; OK sets only the fonts changed; `format.font {"eastAsia"}` (#505) |
|
||||
| 2026-10-11 | trivial | References › Table of Authorities: Insert Table of Authorities is labelled and joined by Update Table (`references.updateAuthorities`), as in the Index group (#661) |
|
||||
| 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) |
|
||||
| 2026-10-11 | trivial | Customize Keyboard dialog (Tools › Customize Keyboard, File › Options › Keyboard shortcuts): custom keys override built-ins and persist (#368) |
|
||||
| 2026-10-11 | minor | Keyboard shortcuts 101 → 125 commands (#330): Reset Paragraph/Character Formatting, symbols, DATE/TIME/PAGE fields, view switches, Word Count, mail merge, F4 Repeat now reachable |
|
||||
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
|
||||
| 2026-10-11 | trivial | Dialogs keep their top edge in place when switching tabs (Paragraph, Font, Borders and Shading…): only the bottom edge moves (#507) |
|
||||
| 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) |
|
||||
| 2026-10-11 | trivial | Font list ends with Add Font… (TTF, OTF, TTC, WOFF, WOFF2; also by dropping the file); added fonts are listed at once and kept between runs (#582) |
|
||||
| 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) |
|
||||
| 2026-10-11 | trivial | Templates and Add-ins (template path, Attach…, automatically update document styles) and Organizer (copy styles from another file, delete, rename) dialogs (#377); styles update from the template only when the user asks (OK with the box ticked, or Update Styles), never on open; the three Modal dialogs rows merged into one |
|
||||
| 2026-10-11 | trivial | Field codes (#339): View › Field Codes (Alt+F9), Toggle Field Codes (Shift+F9), a field-code popup (double-click, right-click › Edit Field…; Ctrl+F9 opens the Field dialog), unlink/lock/unlock and next/previous field keys; field items in the context menu |
|
||||
|
||||
Reference in New Issue
Block a user