mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 04:47:10 -06:00
Merge origin/main into feat/2738-underline-menu
Also: the underline-colour test picks a Standard Colors swatch through the harness instead of running format.underlineColor directly (review nitpick). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+17
-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-18 · **Change:** trivial (progress log: Home › Underline drop-down, #2738; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial (table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-18 · **Change:** trivial (progress log: Home › Underline drop-down, #2738; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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.
|
||||
|
||||
@@ -134,9 +134,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-15 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) |
|
||||
| 2026-10-17 | Home › Underline's drop-down shows each underline as a sample of its line, adds Dot-dot-dash, None and More Underlines…, and an Underline Color submenu backed by the new `format.underlineColor` command (catalog 490/505) (#2738) |
|
||||
| 2026-10-15 | Font box: a Recently Used Fonts section at the top of the list (the fonts applied lately, most recent first, ten at most, kept between runs), then All Fonts, as in Word (#2068) |
|
||||
| 2026-10-15 | A missing Thai document font (TH Sarabun New, Angsana New, Cordia New, the UPC families…) is substituted by one Thai face for the whole run, Latin letters and digits included, as Word substitutes Cordia New; Thai no longer sits in one font next to larger Latin in another (#1549) |
|
||||
|---|---|
|
||||
| 2026-10-17 | Paste from LibreOffice: a paragraph indented on both sides (HTML `<blockquote>` on the clipboard) pastes as an indented Normal paragraph with LibreOffice's margins, not centred and italic in the Quote style (#1270). |
|
||||
| 2026-10-17 | DrawingML shape outlines retain explicit preset/custom dashes on screen and in PDF, including zero-width path strokes. |
|
||||
| 2026-10-17 | Missing Arial Unicode MS tries Arial and its proportional substitutes before code-font heuristics. |
|
||||
@@ -150,6 +155,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-16 | Indonesian proofing (#498): id-ID spelling reads the installed Hunspell `id_ID` dictionary (`hunspell-id` on Debian/Ubuntu, LibreOffice's `dict-id`, or the Dictionaries folder), suggestions use its MAP/REP/TRY/KEY tables, generic repeated-word (offering `anak-anak`) and spacing checks explain in Indonesian; Review › Language, Set Proofing Language and File › Options › Proofing offer Indonesian. No dictionary data bundled; hyphenation keeps the English fallback; parity estimates unchanged |
|
||||
| 2026-10-17 | A table inserted in a right-to-left paragraph is a right-to-left table, like Word, so Tab moves right to left through its cells (#2634) |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-15 | Layout › Columns with text selected puts only that text in columns, as in Word (#2024): a continuous section break goes where the selection starts and one where it ends (splitting a paragraph there; none at the document's start or end, or where a section already begins or ends), and the columns go on the section between, one undo step; More Columns… offers "Selected text" and picks it. Before, the whole section took the columns |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
@@ -415,12 +421,18 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-05 | M0–M12 first versions: model, layout, render, engine with 200+ commands, Word-style UI, DOCX I/O, PDF and other formats, references, review, mailings, CLI, MCP, web |
|
||||
| 2026-10-12 | Mail-merge recipients (#778): legacy Excel .xls workbooks; CSV/TXT lists in GBK, Big5, Shift_JIS, UTF-16 and other encodings read correctly (plain-text import too); Edit Recipient List filters (equal, not equal, less, greater, blank, contains… joined by And/Or) and sorts, and preview, Finish & Merge and labels use only the matching records in that order (`mailings.filterRecipients`, `mailings.sortRecipients`) |
|
||||
| 2026-10-13 | The interface-translation audit runs in CI again (#1128): the size labels of Envelopes, Labels and Layout › Size are translated (`4,13 × 9,5 дюйма`) and use the region's decimal separator, and status-bar messages (table split and merge, ink replay, citations, SmartArt, Researcher, Design defaults), the Reading Mode toolbar, Go To and Search's Suggested heading are translated in every interface language, and the Read Aloud player no longer shows a speech error a frame before the status bar does, the race behind the audit's rare failure under load |
|
||||
| 2026-10-15 | Lists (#1683): a bullet stored as a private-use character its font can't draw (the font isn't installed) shows the level's default bullet (• ○ ▪); it used to be whatever an installed icon font has at that code, such as Font Awesome's Twitter logo |
|
||||
| 2026-10-15 | The web app keeps its preferences between visits (#2132): theme (dark mode), interface size, editing options and the rest of what the desktop app saves in `ui.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
|
||||
| 2026-10-15 | trivial | Progress log: undrawable private-use list bullets show the level's default bullet (#1683) |
|
||||
| 2026-10-15 | trivial | Progress log: web preferences kept between visits (#2132) |
|
||||
| 2026-10-15 | trivial | Progress log: Recently Used Fonts in the font box (#2068) |
|
||||
| 2026-10-15 | trivial | Progress log: one Thai face for a run in a missing Thai font (#1549) |
|
||||
| 2026-10-18 | trivial | Progress log: Different First Page headers reachable from page 1 (#2826) |
|
||||
| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) |
|
||||
| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) |
|
||||
@@ -434,6 +446,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-16 | trivial | Progress log: comments readable in the dark theme (#2045) |
|
||||
| 2026-10-16 | trivial | Progress log: Indonesian proofing (#498) |
|
||||
| 2026-10-16 | trivial | Progress log: legacy outline, shadow, emboss and imprint text effects drawn (#2534) |
|
||||
| 2026-10-15 | trivial | Progress log: Columns on selected text (#2024) |
|
||||
| 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) |
|
||||
| 2026-10-14 | trivial | Progress log: row table border exceptions |
|
||||
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
|
||||
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
|
||||
@@ -621,3 +635,4 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
|
||||
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
|
||||
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
|
||||
| 2026-10-15 | trivial | Progress log: dialog close box (#1885) |
|
||||
|
||||
@@ -29,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", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList"] }
|
||||
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", "Storage"] }
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
//!
|
||||
//! Differences from the desktop app: no TCP control channel; Open and Insert › Pictures use the
|
||||
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
|
||||
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod prefs;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Preferences between visits (#2132): the same [`UiState`] the desktop app keeps in `ui.json`
|
||||
//! (theme, interface size, recent symbols, editing options…), kept in the browser's
|
||||
//! `localStorage` under [`KEY`]. Restored at startup; written back whenever it changes, so a
|
||||
//! reload or a closed tab keeps the last setting (browsers don't reliably run exit code).
|
||||
|
||||
use wordcraft_ui_egui::{UiState, WordApp};
|
||||
|
||||
/// The `localStorage` key.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub const KEY: &str = "wordcraft.ui";
|
||||
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
|
||||
const MAX_STORED: usize = 4 << 20;
|
||||
|
||||
/// Apply preferences stored on an earlier visit. Missing, oversized or unreadable values leave
|
||||
/// the defaults.
|
||||
pub fn restore(app: &mut WordApp, stored: Option<&str>) {
|
||||
if let Some(s) = stored.filter(|s| s.len() <= MAX_STORED)
|
||||
&& let Some(ui) = UiState::from_json(s.as_bytes())
|
||||
{
|
||||
app.apply_prefs(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks what was last written, so the store is written only when a preference changed.
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
last: Option<String>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(stored: Option<String>) -> Self {
|
||||
Self { last: stored }
|
||||
}
|
||||
|
||||
/// The JSON to store when the app's preferences differ from what was last written.
|
||||
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
|
||||
let json = match serde_json::to_string(&app.prefs()) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("preferences: cannot serialize: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if self.last.as_deref() == Some(json.as_str()) {
|
||||
return None;
|
||||
}
|
||||
self.last = Some(json.clone());
|
||||
Some(json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use wordcraft_ui_egui::Services;
|
||||
|
||||
fn app() -> WordApp {
|
||||
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
|
||||
}
|
||||
|
||||
/// #2132: dark mode chosen on one visit is there after a reload.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
let mut first = app();
|
||||
let mut sync = PrefsSync::new(None);
|
||||
let initial = sync.changed(&first);
|
||||
assert!(initial.is_some(), "first frame stores the preferences");
|
||||
assert_eq!(sync.changed(&first), None, "unchanged preferences are not rewritten");
|
||||
first.run("ui.theme", json!({"value": "dark"})).unwrap();
|
||||
let stored = sync.changed(&first).expect("the theme change is stored");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, Some(&stored));
|
||||
assert_eq!(reloaded.ui.theme, wordcraft_ui_egui::theme::Appearance::Dark);
|
||||
assert_eq!(PrefsSync::new(Some(stored)).changed(&reloaded), None, "restoring doesn't write back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_storage_keeps_defaults() {
|
||||
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"theme\":42}")] {
|
||||
let mut a = app();
|
||||
restore(&mut a, junk);
|
||||
assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::Light);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,11 +65,21 @@ pub fn start() {
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
app.autosave = false;
|
||||
// Preferences from an earlier visit (#2132), as the desktop app reads `ui.json`.
|
||||
let stored = stored_prefs();
|
||||
crate::prefs::restore(&mut app, stored.as_deref());
|
||||
// A region chosen in File › Options replaces the browser's for the first blank
|
||||
// document too (#838).
|
||||
if !sample && app.session.prefs.region.is_some() {
|
||||
let doc = app.session.blank_document();
|
||||
app.session.set_document(doc);
|
||||
}
|
||||
let prefs = crate::prefs::PrefsSync::new(stored);
|
||||
// No document to show: start on File › Home, as the desktop app does (#766).
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, messages }))
|
||||
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -92,6 +102,16 @@ struct WebShell {
|
||||
app: WordApp,
|
||||
inbox: Inbox,
|
||||
messages: Rc<RefCell<Vec<String>>>,
|
||||
prefs: crate::prefs::PrefsSync,
|
||||
}
|
||||
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok()?
|
||||
}
|
||||
|
||||
/// The preferences stored on an earlier visit, if the browser allows storage.
|
||||
fn stored_prefs() -> Option<String> {
|
||||
local_storage()?.get_item(crate::prefs::KEY).ok()?
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -116,6 +136,17 @@ impl eframe::App for WebShell {
|
||||
self.app.status(msg);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
|
||||
if let Some(json) = self.prefs.changed(&self.app) {
|
||||
match local_storage() {
|
||||
Some(store) => {
|
||||
if let Err(e) = store.set_item(crate::prefs::KEY, &json) {
|
||||
log::warn!("preferences: cannot save: {e:?}");
|
||||
}
|
||||
}
|
||||
None => log::warn!("preferences: browser storage is unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
|
||||
@@ -392,14 +392,36 @@ impl Document {
|
||||
pb.clone()
|
||||
};
|
||||
let bl = self.container_mut(a.story, &a.path)?;
|
||||
// Section breaks on the marks in between go with their paragraphs.
|
||||
let between: Vec<_> = bl
|
||||
.get(ia + 1..ib)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|b| match &**b {
|
||||
Block::Para(p) => p.section.clone(),
|
||||
Block::Table(_) => None,
|
||||
})
|
||||
.collect();
|
||||
if ib < bl.len() && ia < ib {
|
||||
bl.drain(ia + 1..=ib);
|
||||
}
|
||||
tail.props = ParaProps::default();
|
||||
// The deleted marks include `a`'s, so a section break there goes too: the joined
|
||||
// paragraph ends where `b`'s mark did, and its text takes the following section's
|
||||
// setup, as in Word (#2127).
|
||||
let sect = tail.section.take();
|
||||
let pa = self.para_mut(a.story, &a.path)?;
|
||||
let keep_props = pa.props.clone();
|
||||
pa.append(tail);
|
||||
pa.props = keep_props;
|
||||
let before = std::mem::replace(&mut pa.section, sect);
|
||||
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
|
||||
// Nearest first, as "link to previous" resolved them before the delete.
|
||||
let following = self.section_mut(a.path.last());
|
||||
for s in between.iter().rev().chain(before.iter()) {
|
||||
following.absorb(s);
|
||||
}
|
||||
}
|
||||
return Ok(a);
|
||||
}
|
||||
// Different containers (range enters or leaves a table): clear text in every paragraph
|
||||
@@ -757,6 +779,29 @@ mod tests {
|
||||
assert_eq!(d.body.len(), 1);
|
||||
}
|
||||
|
||||
/// #2127: a range over two section breaks joins three sections into the last; the headers
|
||||
/// and footers it showed by linking to earlier ones stay, each from the nearest section that
|
||||
/// named it, as they resolved before the delete.
|
||||
#[test]
|
||||
fn delete_over_two_section_breaks_keeps_the_linked_headers() {
|
||||
let mut d = doc(&["A", "B", "C"]);
|
||||
let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() };
|
||||
let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() };
|
||||
let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() };
|
||||
let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() };
|
||||
for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] {
|
||||
if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) {
|
||||
p.section = Some(Box::new(s));
|
||||
}
|
||||
}
|
||||
assert_eq!(d.sections().len(), 4);
|
||||
d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap();
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "AC");
|
||||
let sections = d.sections();
|
||||
assert_eq!(sections.len(), 2);
|
||||
assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_across() {
|
||||
let mut d = doc(&["abc", "def"]);
|
||||
|
||||
@@ -1022,6 +1022,18 @@ impl Document {
|
||||
&mut self.last_section
|
||||
}
|
||||
|
||||
/// Remove the section break on the mark of top-level body paragraph `block`: its section
|
||||
/// joins the following one ([`SectionProps::absorb`]). False when there is no break there.
|
||||
pub fn remove_section_break(&mut self, block: usize) -> bool {
|
||||
if !matches!(self.body.get(block).map(|b| &**b), Some(Block::Para(p)) if p.section.is_some()) {
|
||||
return false;
|
||||
}
|
||||
let Some(Block::Para(p)) = self.body.get_mut(block).map(Arc::make_mut) else { return false };
|
||||
let Some(before) = p.section.take() else { return false };
|
||||
self.section_mut(block).absorb(&before);
|
||||
true
|
||||
}
|
||||
|
||||
/// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more
|
||||
/// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part.
|
||||
pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 {
|
||||
|
||||
@@ -424,13 +424,15 @@ impl Lp {
|
||||
"underline" => one(MNode::Bar { top: false, e: self.arg() }),
|
||||
"boxed" | "fbox" => one(MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.arg() }),
|
||||
"phantom" => one(MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.arg() }),
|
||||
"overbrace" | "underbrace" | "overparen" | "underparen" => {
|
||||
"overbrace" | "underbrace" | "overparen" | "underparen" | "overbracket" | "underbracket" => {
|
||||
let top = c.starts_with("over");
|
||||
let chr = match c {
|
||||
"overbrace" => '⏞',
|
||||
"underbrace" => '⏟',
|
||||
"overparen" => '⏜',
|
||||
_ => '⏝',
|
||||
"underparen" => '⏝',
|
||||
"overbracket" => '⎴',
|
||||
_ => '⎵',
|
||||
};
|
||||
let e = self.arg();
|
||||
let g = MNode::GroupChr { chr, top, e };
|
||||
@@ -774,6 +776,17 @@ fn delim_tex(c: Option<char>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn group_chr_tex(chr: char, top: bool) -> &'static str {
|
||||
match chr {
|
||||
'⏜' => "\\overparen",
|
||||
'⏝' => "\\underparen",
|
||||
'⎴' => "\\overbracket",
|
||||
'⎵' => "\\underbracket",
|
||||
_ if top => "\\overbrace",
|
||||
_ => "\\underbrace",
|
||||
}
|
||||
}
|
||||
|
||||
fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => {
|
||||
@@ -901,11 +914,10 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
s.push('_');
|
||||
braced(lim, s, depth);
|
||||
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
|
||||
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(inner, s, depth);
|
||||
s.push(if *upper { '^' } else { '_' });
|
||||
braced(lim, s, depth);
|
||||
let _ = chr;
|
||||
} else {
|
||||
s.push_str(if *upper { "\\overset" } else { "\\underset" });
|
||||
braced(lim, s, depth);
|
||||
@@ -941,8 +953,8 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::Boxed { e } => braced(e, s, depth),
|
||||
MNode::GroupChr { top, e, .. } => {
|
||||
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
|
||||
MNode::GroupChr { chr, top, e } => {
|
||||
s.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, s, depth);
|
||||
}
|
||||
MNode::EqArr { rows } => {
|
||||
@@ -1034,6 +1046,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_character_latex_roundtrip() {
|
||||
for cmd in ["\\overparen{a+b}", "\\underparen{a+b}", "\\overbracket{x}", "\\underbracket{x}"] {
|
||||
let n = parse_latex(cmd);
|
||||
let tex = to_latex(&n);
|
||||
assert_eq!(tex, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_latex_never_panics() {
|
||||
for s in ["", "\\", "{", "}", "^", "_", "\\frac", "\\sqrt[", "\\left(", "\\right)", "\\begin{matrix", "&&\\\\", "{{{{", "\\end{x}", "a^^b__c"]
|
||||
|
||||
@@ -156,6 +156,11 @@ impl ListKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bullet a level of a plain bulleted list shows: • ○ ▪ repeating (`level` is zero-based).
|
||||
pub fn default_bullet(level: usize) -> char {
|
||||
['•', '○', '▪'][level % 3]
|
||||
}
|
||||
|
||||
fn bullet_level(i: usize, c: char) -> Level {
|
||||
Level {
|
||||
format: NumFormat::Bullet,
|
||||
@@ -188,8 +193,8 @@ pub fn levels_for(kind: ListKind) -> Vec<Level> {
|
||||
let lvl = |format: NumFormat, text: String| Level { format, text, indent: ind, hanging: 18.0, ..Level::default() };
|
||||
let n = i + 1;
|
||||
match kind {
|
||||
ListKind::Bullet => bullet_level(i, ['•', '○', '▪'][i % 3]),
|
||||
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { ['•', '○', '▪'][i % 3] }),
|
||||
ListKind::Bullet => bullet_level(i, default_bullet(i)),
|
||||
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { default_bullet(i) }),
|
||||
ListKind::Numbered => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
|
||||
ListKind::NumberedParen => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n})")),
|
||||
ListKind::UpperLetter => lvl(cyc(NumFormat::UpperLetter, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
|
||||
|
||||
@@ -795,6 +795,16 @@ fn is_horizontal(d: &crate::props::TextDirection) -> bool {
|
||||
}
|
||||
|
||||
impl SectionProps {
|
||||
/// Join the section before this one into it, as when the break between them is deleted:
|
||||
/// this section's setup stays, as in Word, and the headers and footers it leaves out (linked
|
||||
/// to the previous section) are taken from `before`, so its text keeps showing them.
|
||||
pub fn absorb(&mut self, before: &SectionProps) {
|
||||
for (own, prev) in [(&mut self.headers, &before.headers), (&mut self.footers, &before.footers)] {
|
||||
own.default = own.default.or(prev.default);
|
||||
own.first = own.first.or(prev.first);
|
||||
own.even = own.even.or(prev.even);
|
||||
}
|
||||
}
|
||||
/// Width of the text area, points.
|
||||
pub fn text_width(&self) -> f32 {
|
||||
(self.page_w - self.margin_left - self.margin_right - self.gutter).max(18.0)
|
||||
|
||||
@@ -188,11 +188,17 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
|
||||
};
|
||||
let l = s.layout();
|
||||
let page = l.caret(&f.at).and_then(|c| l.pages.get(c.page));
|
||||
let section = page.map(|p| (p.section + 1) as u32).unwrap_or(1);
|
||||
let section_pages = page
|
||||
.map(|p| l.pages.iter().filter(|pg| pg.section == p.section).count() as u32)
|
||||
.unwrap_or_else(|| u32::try_from(l.pages.len()).unwrap_or(u32::MAX));
|
||||
let page_format = page.and_then(|p| s.doc.sections().get(p.section).map(|(_, sec)| sec.page_num_format)).unwrap_or_default();
|
||||
let ctx = wordcraft_layout::fields::FieldCtx {
|
||||
page: page.map(|p| p.number).unwrap_or(1),
|
||||
page_format,
|
||||
pages: u32::try_from(l.pages.len()).unwrap_or(u32::MAX),
|
||||
section_pages: u32::try_from(l.pages.len()).unwrap_or(u32::MAX),
|
||||
section: 1,
|
||||
section_pages,
|
||||
section,
|
||||
title: s.doc.core.title.as_str().into(),
|
||||
author: s.doc.core.creator.as_str().into(),
|
||||
filename: s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default().into(),
|
||||
@@ -384,4 +390,20 @@ mod tests {
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
assert!(s.run("fields.lock", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlink_uses_section_context() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
// Insert section break so there are 2 sections
|
||||
s.run("layout.break", &json!({"kind": "nextPage"})).unwrap();
|
||||
// In section 2: insert SECTION and SECTIONPAGES fields
|
||||
s.run("insert.field", &json!({"instr": "SECTION"})).unwrap();
|
||||
s.run("text.insert", &json!({"text": " of "})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "SECTIONPAGES"})).unwrap();
|
||||
// Select both and unlink
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("fields.unlink", &json!({})).unwrap();
|
||||
let text = s.doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert!(text.contains("2 of 1"), "unlinked text should reflect section 2: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,9 +361,14 @@ pub fn split_para(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
|
||||
|
||||
/// [`split_para`]; without `enter` an empty list item is split like any other paragraph.
|
||||
fn split_para_as(s: &mut Session, at: &Pos, enter: bool) -> Result<Pos, CmdError> {
|
||||
// `num: 0` means "explicitly not in a list", so it isn't a list item.
|
||||
// `num: 0` means "explicitly not in a list", so it isn't a list item. The list may come from
|
||||
// the paragraph's style (List Number 2 in a Word file) rather than the paragraph itself.
|
||||
let (at_end, style, empty_list) = match s.doc.para_at(at) {
|
||||
Some(p) => (at.off >= p.len(), p.props.style.clone(), p.props.numbering.filter(|n| enter && n.num != 0 && p.is_empty())),
|
||||
Some(p) => (
|
||||
at.off >= p.len(),
|
||||
p.props.style.clone(),
|
||||
p.props.numbering.or_else(|| s.doc.styles.resolve_para(&p.props).numbering).filter(|n| enter && n.num != 0 && p.is_empty()),
|
||||
),
|
||||
None => return Err(CmdError::Failed("no paragraph at caret".into())),
|
||||
};
|
||||
if let Some(n) = empty_list {
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("layout.size", "Size", "Layout › Page Setup", size).params(r#"{"name"?: "Letter|Legal|A4|…", "width"?: pt, "height"?: pt} (each side clamped to 7.2–1584 pt, 0.1–22 in; the section keeps its orientation)"#),
|
||||
CommandSpec::new("layout.columns", "Columns", "Layout › Page Setup", columns)
|
||||
.params(
|
||||
r#"{"count": 1-12, "space"?: pt, "separator"?: bool, "preset"?: "left|right", "widths"?: [[width pt, space after pt], …] (unequal columns; sets the count), "apply"?: "section|document|forward"}"#,
|
||||
r#"{"count": 1-12, "space"?: pt, "separator"?: bool, "preset"?: "left|right", "widths"?: [[width pt, space after pt], …] (unequal columns; sets the count), "apply"?: "section|document|forward|selection"} (default: selection when text is selected in the main text — section breaks around it — else section)"#,
|
||||
),
|
||||
CommandSpec::new("layout.break", "Breaks", "Layout › Page Setup", breaks)
|
||||
.params(r#"{"kind": "page|column|textWrapping|nextPage|continuous|evenPage|oddPage"}"#),
|
||||
@@ -569,7 +569,9 @@ fn columns(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// Equal columns keep one spacing; unequal ones remember the first gap for a later switch back.
|
||||
let space = widths.first().map(|w| w.1).unwrap_or(space);
|
||||
let cols = Columns { count: count as u32, space, separator: sep, widths };
|
||||
match p::str(v, "apply").unwrap_or("section") {
|
||||
// With text selected the gallery works on that text, as in Word (#2024).
|
||||
let default = if selection_sectionable(s) { "selection" } else { "section" };
|
||||
match p::str(v, "apply").unwrap_or(default) {
|
||||
"section" => with_sect(s, |x| x.columns = cols.clone()),
|
||||
"document" => {
|
||||
let ends: Vec<usize> = s.doc.sections().iter().map(|(e, _)| *e).collect();
|
||||
@@ -585,10 +587,61 @@ fn columns(s: &mut Session, v: &Value) -> CmdResult {
|
||||
breaks(s, &json!({"kind": "continuous"}))?;
|
||||
with_sect(s, |x| x.columns = cols.clone())
|
||||
}
|
||||
x => Err(CmdError::Params(format!("unknown apply `{x}` (section, document or forward)"))),
|
||||
"selection" => {
|
||||
selection_to_sections(s)?;
|
||||
with_sect(s, |x| x.columns = cols.clone())
|
||||
}
|
||||
x => Err(CmdError::Params(format!("unknown apply `{x}` (section, document, forward or selection)"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the selection can become sections of its own: text selected in the main text, outside
|
||||
/// tables.
|
||||
pub fn selection_sectionable(s: &Session) -> bool {
|
||||
let (a, b) = s.sel.ordered();
|
||||
!s.sel.is_collapsed() && [a, b].iter().all(|p| p.story == StoryRef::Body && p.path.depth() == 0)
|
||||
}
|
||||
|
||||
/// Columns for the selected text (#2024), as Word does it: a continuous section break where the
|
||||
/// selection starts and one where it ends, splitting paragraphs there, unless that end already
|
||||
/// meets a section boundary (the document's start or end, or a section break). The selection keeps
|
||||
/// its text, which is then the section(s) between.
|
||||
fn selection_to_sections(s: &mut Session) -> Result<(), CmdError> {
|
||||
if !selection_sectionable(s) {
|
||||
return Err(CmdError::Params("selected text in the main text, outside tables, is needed".into()));
|
||||
}
|
||||
let old = s.sel.clone();
|
||||
let (a, mut b) = old.ordered();
|
||||
let block = |p: &Pos| p.path.0.first().copied().unwrap_or(0) as usize;
|
||||
let len = |s: &Session, p: &Pos| s.doc.para_at(p).map(|q| q.len()).unwrap_or(0);
|
||||
let ends: Vec<usize> = s.doc.sections().iter().map(|(e, _)| *e).collect();
|
||||
let after_break = |i: usize| i.checked_sub(1).is_some_and(|j| ends.contains(&j));
|
||||
// A selection that took a section break with it ends before that break.
|
||||
if b.off == 0 && block(&b) > block(&a) && after_break(block(&b)) {
|
||||
let prev = Pos { path: wordcraft_doc::Path::top(block(&b) - 1), off: 0, ..b.clone() };
|
||||
b = Pos { off: len(s, &prev), ..prev };
|
||||
}
|
||||
let last_block = s.doc.body.len().saturating_sub(1);
|
||||
let end_break = !(b.off == len(s, &b) && (ends.contains(&block(&b)) || block(&b) == last_block));
|
||||
let start_break = !(a.off == 0 && (block(&a) == 0 || after_break(block(&a))));
|
||||
// The end first: the start's position doesn't move.
|
||||
if end_break {
|
||||
s.sel = Selection::caret(b.clone());
|
||||
breaks(s, &json!({"kind": "continuous"}))?;
|
||||
}
|
||||
let (first, last) = if start_break {
|
||||
s.sel = Selection::caret(a.clone());
|
||||
breaks(s, &json!({"kind": "continuous"}))?;
|
||||
let first = s.sel.focus.clone();
|
||||
let last = shifted_by_split(&b, &a, &first);
|
||||
(first, last)
|
||||
} else {
|
||||
(a, b)
|
||||
};
|
||||
s.sel = if old.anchor <= old.focus { Selection { anchor: first, focus: last } } else { Selection { anchor: last, focus: first } };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `[[width, space after], …]` in points: 1–12 columns, each at least 0.25", that fit the text
|
||||
/// width (`tw`).
|
||||
fn column_widths(v: &Value, tw: f32) -> Result<Vec<(f32, f32)>, CmdError> {
|
||||
@@ -956,6 +1009,84 @@ mod tests {
|
||||
assert!(s.run("layout.columns", &json!({"count": 2, "apply": "elsewhere"})).is_err());
|
||||
}
|
||||
|
||||
/// Each section's paragraphs (a section break's own paragraph shows as "") and column count.
|
||||
fn sections_of(s: &Session) -> Vec<(Vec<String>, u32)> {
|
||||
let mut from = 0;
|
||||
let mut out = Vec::new();
|
||||
for (end, x) in s.doc.sections() {
|
||||
let paras = (from..=end).map(|i| s.doc.para_at(&Pos::body(i, 0)).map(|p| p.text.clone()).unwrap_or_default()).collect();
|
||||
out.push((paras, x.columns.count));
|
||||
from = end + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, from: (usize, usize), to: (usize, usize)) {
|
||||
s.sel = Selection { anchor: Pos::body(from.0, from.1), focus: Pos::body(to.0, to.1) };
|
||||
}
|
||||
|
||||
fn strs(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|x| x.to_string()).collect()
|
||||
}
|
||||
|
||||
/// Columns on selected text (#2024), as observed in Word: a continuous section break before
|
||||
/// and after the selection, the columns on the section between, the rest left alone.
|
||||
#[test]
|
||||
fn columns_apply_to_the_selected_text() {
|
||||
let five = || Session::new(wordcraft_doc::Document::from_text("p1\np2\nalpha beta gamma\np4\np5"));
|
||||
// A whole paragraph (a triple-click takes its mark): each break gets a paragraph of its own.
|
||||
let mut s = five();
|
||||
select(&mut s, (2, 0), (3, 0));
|
||||
let whole = s.selected_text();
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!(sections_of(&s), vec![(strs(&["p1", "p2", ""]), 1), (strs(&["alpha beta gamma", ""]), 2), (strs(&["p4", "p5"]), 1)]);
|
||||
assert!(s.doc.sections().iter().skip(1).all(|(_, x)| x.start == SectionStart::Continuous));
|
||||
assert_eq!(s.selected_text(), whole, "the paragraph stays selected");
|
||||
// The same paragraph again: its section changes, no new breaks.
|
||||
select(&mut s, (3, 0), (4, 0));
|
||||
s.run("layout.columns", &json!({"count": 3})).unwrap();
|
||||
assert_eq!(sections_of(&s).iter().map(|(_, n)| *n).collect::<Vec<_>>(), vec![1, 3, 1]);
|
||||
// Taking the section break along changes nothing more.
|
||||
select(&mut s, (3, 0), (5, 0));
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!(sections_of(&s).iter().map(|(_, n)| *n).collect::<Vec<_>>(), vec![1, 2, 1]);
|
||||
// One undo step takes the breaks and the columns away.
|
||||
let mut s = five();
|
||||
select(&mut s, (2, 0), (3, 0));
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(sections_of(&s), vec![(strs(&["p1", "p2", "alpha beta gamma", "p4", "p5"]), 1)]);
|
||||
|
||||
// Words inside a paragraph: the breaks split it there.
|
||||
let mut s = five();
|
||||
select(&mut s, (2, 10), (2, 6));
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!(sections_of(&s), vec![(strs(&["p1", "p2", "alpha "]), 1), (strs(&["beta"]), 2), (strs(&[" gamma", "p4", "p5"]), 1)]);
|
||||
assert_eq!(s.selected_text(), "beta");
|
||||
assert!(s.sel.anchor > s.sel.focus, "a backward selection stays backward");
|
||||
|
||||
// At the document's start no break goes before it, at its end none after.
|
||||
let mut s = five();
|
||||
select(&mut s, (0, 0), (1, 0));
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!(sections_of(&s), vec![(strs(&["p1", ""]), 2), (strs(&["p2", "alpha beta gamma", "p4", "p5"]), 1)]);
|
||||
let mut s = five();
|
||||
select(&mut s, (4, 0), (4, 2));
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!(sections_of(&s), vec![(strs(&["p1", "p2", "alpha beta gamma", "p4", ""]), 1), (strs(&["p5"]), 2)]);
|
||||
// Select All: the whole document, no breaks.
|
||||
let mut s = five();
|
||||
s.run("select.all", &json!({})).unwrap();
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!(sections_of(&s), vec![(strs(&["p1", "p2", "alpha beta gamma", "p4", "p5"]), 2)]);
|
||||
|
||||
// Without a selection the caret's section changes; "selection" then needs one.
|
||||
let mut s = five();
|
||||
s.run("layout.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!(sections_of(&s).len(), 1);
|
||||
assert!(s.run("layout.columns", &json!({"count": 2, "apply": "selection"})).is_err());
|
||||
}
|
||||
|
||||
/// #407 Line Numbers options: start, count by, distance and restart; options alone keep the
|
||||
/// restart mode, `none` turns numbering off.
|
||||
#[test]
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"to": "endnote" (footnotes become endnotes) | "footnote" (endnotes become footnotes) | "swap"}"#),
|
||||
CommandSpec::new("references.nextFootnote", "Next Footnote", "References › Footnotes", |s, _| {
|
||||
let caret = s.sel.focus.clone();
|
||||
let refs = note_refs(s);
|
||||
let refs = note_refs(s, Some(NoteKind::Footnote));
|
||||
if let Some(p) = refs.iter().find(|p| **p > caret).or(refs.first()) {
|
||||
s.sel = Selection::caret(p.clone());
|
||||
}
|
||||
@@ -719,12 +719,14 @@ pub(super) fn note_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}))
|
||||
}
|
||||
|
||||
fn note_refs(s: &Session) -> Vec<Pos> {
|
||||
fn note_refs(s: &Session, kind: Option<NoteKind>) -> Vec<Pos> {
|
||||
let mut v = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
for o in p.object_offsets() {
|
||||
if matches!(p.object_at(o), Some(InlineObject::NoteRef { .. })) {
|
||||
if let Some(InlineObject::NoteRef { kind: k, .. }) = p.object_at(o)
|
||||
&& (kind.is_none() || kind == Some(*k))
|
||||
{
|
||||
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: o });
|
||||
}
|
||||
}
|
||||
@@ -851,14 +853,31 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`).
|
||||
fn has_switch(instr: &str, sw: char) -> bool {
|
||||
instr.split('\\').skip(1).any(|s| {
|
||||
let mut chars = s.chars();
|
||||
chars.next().is_some_and(|c| c.eq_ignore_ascii_case(&sw)) && chars.next().is_none_or(|c| !c.is_alphanumeric())
|
||||
})
|
||||
}
|
||||
|
||||
/// Update fields in the body: dates, SEQ numbering, cross-references, TOC.
|
||||
pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
update_seq(s)?;
|
||||
let mut pages: std::collections::HashMap<String, u32> = Default::default();
|
||||
for (name, pos) in s.doc.bookmarks() {
|
||||
let mut pages: std::collections::HashMap<String, (u32, NumFormat)> = Default::default();
|
||||
let formats: Vec<NumFormat> = s.doc.sections().into_iter().map(|(_, sec)| sec.page_num_format).collect();
|
||||
let marks = s.doc.bookmarks();
|
||||
for (name, pos) in marks {
|
||||
let l = s.layout();
|
||||
let page = l.caret(&pos).and_then(|c| l.pages.get(c.page)).map(|p| p.number).unwrap_or(1);
|
||||
pages.entry(name).or_insert(page);
|
||||
let page_info = l
|
||||
.caret(&pos)
|
||||
.and_then(|c| l.pages.get(c.page))
|
||||
.map(|p| {
|
||||
let format = formats.get(p.section).copied().unwrap_or_default();
|
||||
(p.number, format)
|
||||
})
|
||||
.unwrap_or((1, NumFormat::Decimal));
|
||||
pages.entry(name).or_insert(page_info);
|
||||
}
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
|
||||
@@ -889,8 +908,18 @@ pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
|
||||
updates.push((k, super::insert::format_date(&pic)));
|
||||
}
|
||||
"REF" | "PAGEREF" => {
|
||||
let target = instr.split_whitespace().nth(1).unwrap_or("");
|
||||
let r = if name == "REF" { bookmark_text(&s.doc, target) } else { pages.get(target).map(u32::to_string) };
|
||||
let f = FieldCode::parse(instr);
|
||||
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
|
||||
let r = if name == "REF" {
|
||||
if !has_switch(instr, 'p') { bookmark_text(&s.doc, target) } else { None }
|
||||
} else if !has_switch(instr, 'p') {
|
||||
pages.get(target).map(|(num, page_fmt)| {
|
||||
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(*page_fmt);
|
||||
fmt.format(*num)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(r) = r {
|
||||
updates.push((k, r));
|
||||
}
|
||||
@@ -1147,4 +1176,26 @@ mod tests {
|
||||
s.run("references.caption", &json!({"label": "Table", "excludeLabel": true, "format": "ROMAN"})).unwrap();
|
||||
assert_eq!(s.doc.body.last().unwrap().as_para().unwrap().plain_text(), "I");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_fields_ref_and_pageref() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Bookmark target text here.\nSecond paragraph."));
|
||||
s.run("select.text", &json!({"text": "Bookmark target text"})).unwrap();
|
||||
s.run("insert.bookmark", &json!({"name": "TargetMark"})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "REF \"TargetMark\" \\h", "result": "old"})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "REF TargetMark\\h", "result": "old"})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "PAGEREF \"TargetMark\" \\* Roman", "result": "0"})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "PAGEREF TargetMark \\p", "result": "above"})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "REF TargetMark \\p", "result": "above"})).unwrap();
|
||||
|
||||
update_fields(&mut s).unwrap();
|
||||
|
||||
let res: Vec<(String, String)> = fields(&s);
|
||||
assert_eq!(res[0], ("REF \"TargetMark\" \\h".into(), "Bookmark target text".into()));
|
||||
assert_eq!(res[1], ("REF TargetMark\\h".into(), "Bookmark target text".into()));
|
||||
assert_eq!(res[2], ("PAGEREF \"TargetMark\" \\* Roman".into(), "I".into()));
|
||||
assert_eq!(res[3], ("PAGEREF TargetMark \\p".into(), "above".into()));
|
||||
assert_eq!(res[4], ("REF TargetMark \\p".into(), "above".into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,7 +806,7 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let chars = text.chars().filter(|c| *c != '\n').count();
|
||||
let chars = text.chars().filter(|c| *c != '\n' && *c != '\r').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
let lines: usize = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Table Design and Table Layout tabs.
|
||||
|
||||
use super::table_formula::parse_number;
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::collate::{SortKey, sort_key};
|
||||
use wordcraft_doc::locale_number::parse_number;
|
||||
use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign};
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block};
|
||||
|
||||
|
||||
@@ -55,7 +55,12 @@ fn autoformat(s: &mut Session, text: &str) -> Option<String> {
|
||||
let para = s.doc.para_at(f)?;
|
||||
let before = para.text.get(..f.off)?;
|
||||
let prev = before.chars().next_back();
|
||||
let opening = prev.is_none_or(|p| p.is_whitespace() || "([{<\u{2014}\u{2013}".contains(p));
|
||||
let opening = prev.is_none_or(|p| {
|
||||
p.is_whitespace()
|
||||
|| "([{<\u{2014}\u{2013}\u{00AB}\u{2039}\u{201E}\u{201A}".contains(p)
|
||||
|| (c == '"' && p == '\u{2018}')
|
||||
|| (c == '\'' && p == '\u{201C}')
|
||||
});
|
||||
match c {
|
||||
'"' => Some(if opening { "\u{201C}" } else { "\u{201D}" }.into()),
|
||||
'\'' => Some(if opening { "\u{2018}" } else { "\u{2019}" }.into()),
|
||||
@@ -792,6 +797,10 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
path.extend([0, 0, 0]);
|
||||
s.sel = Selection::caret(Pos { story: f.story, path: wordcraft_doc::Path(path), off: 0 });
|
||||
}
|
||||
} else if f.story == StoryRef::Body && f.path.0.len() == 1 && !s.doc.settings.track_changes {
|
||||
// The last paragraph: no paragraph to join, but a section break on its mark (the last
|
||||
// section then holds no paragraph) still goes, as in Word (#2127).
|
||||
s.doc.remove_section_break(f.path.last());
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -3077,6 +3077,17 @@ fn word_count_includes_numbered_list_labels_in_words_but_not_characters() {
|
||||
assert_eq!(v["charactersWithSpaces"].as_u64(), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_excludes_carriage_returns_from_character_counts() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello\r\nWorld"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let v = run(&mut s, "review.wordCount", json!({}));
|
||||
assert_eq!(v["words"].as_u64(), Some(2));
|
||||
assert_eq!(v["characters"].as_u64(), Some(10));
|
||||
assert_eq!(v["charactersWithSpaces"].as_u64(), Some(10));
|
||||
}
|
||||
|
||||
/// The laid-out area of the only text box.
|
||||
fn box_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
|
||||
let l = s.layout();
|
||||
@@ -5952,6 +5963,26 @@ fn note_options_per_section_and_convert_notes() {
|
||||
assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_footnote_skips_endnotes() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "A"}));
|
||||
run(&mut s, "references.footnote", json!({"text": "Fn1"}));
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
run(&mut s, "text.insert", json!({"text": "B"}));
|
||||
run(&mut s, "references.endnote", json!({"text": "En1"}));
|
||||
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
|
||||
run(&mut s, "text.insert", json!({"text": "C"}));
|
||||
run(&mut s, "references.footnote", json!({"text": "Fn2"}));
|
||||
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 0));
|
||||
run(&mut s, "references.nextFootnote", json!({}));
|
||||
assert_eq!(s.sel.focus.off, 1);
|
||||
|
||||
run(&mut s, "references.nextFootnote", json!({}));
|
||||
assert_eq!(s.sel.focus.off, 9);
|
||||
}
|
||||
|
||||
/// A tiny synthetic Polish Hunspell dictionary (written for these tests). Real dictionaries are
|
||||
/// installed on the system, never bundled; every Polish test installs this same one.
|
||||
fn polish_test_dictionary() {
|
||||
@@ -6799,6 +6830,10 @@ fn ordinals_and_untagged_text_sort_and_sum_as_numbers() {
|
||||
run(&mut t, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&t), ["1.", "2.", "10."]);
|
||||
|
||||
let mut t = column_table("en-US", &["10", "(50)", "20"]);
|
||||
run(&mut t, "table.sort", json!({"column": 0, "header": false}));
|
||||
assert_eq!(column_texts(&t), ["(50)", "10", "20"]);
|
||||
|
||||
let mut s = s();
|
||||
s.set_editing_language(Some("pl-PL"));
|
||||
s.doc.styles.default_chr.lang = None;
|
||||
|
||||
@@ -177,6 +177,74 @@ fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() {
|
||||
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]);
|
||||
}
|
||||
|
||||
/// #2143: in a Word file whose list comes from the paragraph styles (List Number, List Number 2
|
||||
/// with `w:numPr` in styles.xml, none on the paragraphs), Enter on an empty nested item moves it
|
||||
/// up a level and then ends the list, as with direct numbering.
|
||||
#[test]
|
||||
fn enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level() {
|
||||
use std::io::Write;
|
||||
let w = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
let style = |id: &str, name: &str, ilvl: u8| {
|
||||
format!(
|
||||
r#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{name}"/><w:pPr><w:numPr><w:ilvl w:val="{ilvl}"/><w:numId w:val="1"/></w:numPr></w:pPr></w:style>"#
|
||||
)
|
||||
};
|
||||
let styles = format!(
|
||||
r#"<w:styles {w}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{}{}</w:styles>"#,
|
||||
style("ListNumber", "List Number", 0),
|
||||
style("ListNumber2", "List Number 2", 1)
|
||||
);
|
||||
let numbering = format!(
|
||||
r#"<w:numbering {w}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:start w:val="1"/><w:numFmt w:val="lowerLetter"/><w:lvlText w:val="%2."/><w:pPr><w:ind w:left="1440" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
|
||||
);
|
||||
let p = |st: &str, text: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
|
||||
let body: String =
|
||||
[p("ListNumber", "Lorem"), p("ListNumber", "Ipsum"), p("ListNumber2", "Latin"), p("ListNumber2", "Nonsense"), p("ListNumber2", "")].concat();
|
||||
let document = format!(r#"<w:document {w}><w:body>{body}</w:body></w:document>"#);
|
||||
let rel = |id: &str, kind: &str, target: &str| {
|
||||
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{kind}" Target="{target}"/>"#)
|
||||
};
|
||||
let rels_ns = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
|
||||
let main = format!("<Relationships {rels_ns}>{}</Relationships>", rel("rId1", "officeDocument", "word/document.xml"));
|
||||
let doc_rels =
|
||||
format!("<Relationships {rels_ns}>{}{}</Relationships>", rel("rId1", "styles", "styles.xml"), rel("rId2", "numbering", "numbering.xml"));
|
||||
let ct = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/></Types>"#;
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (name, data) in [
|
||||
("[Content_Types].xml", ct),
|
||||
("_rels/.rels", main.as_str()),
|
||||
("word/_rels/document.xml.rels", doc_rels.as_str()),
|
||||
("word/document.xml", document.as_str()),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
("word/numbering.xml", numbering.as_str()),
|
||||
] {
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
let doc = crate::io::open_bytes("t.docx", &z.finish().unwrap().into_inner()).unwrap();
|
||||
let mut s = Session::new(doc);
|
||||
// The list level each paragraph shows, wherever its numbering comes from.
|
||||
let levels = |s: &Session| -> Vec<Option<u8>> {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.map(|p| s.doc.styles.resolve_para(&p.props).numbering.filter(|n| n.num != 0).map(|n| n.level))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]);
|
||||
s.sel = crate::Selection::caret(Pos::body(4, 0));
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(0)]);
|
||||
assert_eq!(s.sel.focus, Pos::body(4, 0));
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), None]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(levels(&s), [Some(0), Some(0), Some(1), Some(1), Some(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_tab_and_tab_change_list_levels() {
|
||||
let mut s = typed("1. One\nTwo\n\tSub a\nSub b\n");
|
||||
@@ -276,6 +344,18 @@ fn autocorrect_as_you_type() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_quotes_nested_and_opening_delimiters() {
|
||||
let s = typed("\"'nested'\" '\"inner\"' (\"'parens'\")");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "“‘nested’” ‘“inner”’ (“‘parens’”)");
|
||||
// Empty quotes preserve open then close
|
||||
let s = typed("\"\" ''");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "“” ‘’");
|
||||
// After opening guillemets and low quotes
|
||||
let s = typed("«'french'» „'german'“");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "«‘french’» „‘german’“");
|
||||
}
|
||||
|
||||
/// #835: the English replacement list applies only to English text. Polish "i" ("and") stays
|
||||
/// lowercase; symbols still change; English and untagged text still get "I".
|
||||
#[test]
|
||||
@@ -1177,3 +1257,131 @@ fn tab_in_a_table_inserted_in_right_to_left_text_moves_left() {
|
||||
assert_eq!(caret_cell_x(&mut s), (0, x0), "{keys:?}: Shift+Tab comes back");
|
||||
}
|
||||
}
|
||||
|
||||
/// A .docx of two paragraphs in two sections, as Word writes it: the first section's properties
|
||||
/// in the first paragraph's mark (portrait), the last section's in the body (landscape, starting
|
||||
/// as `kind`).
|
||||
fn two_section_docx(kind: &str) -> Vec<u8> {
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
let files = [
|
||||
(
|
||||
"[Content_Types].xml",
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#.to_string(),
|
||||
),
|
||||
(
|
||||
"_rels/.rels",
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><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"/></Relationships>"#.to_string(),
|
||||
),
|
||||
(
|
||||
"word/document.xml",
|
||||
format!(
|
||||
r#"<w:document xmlns:w="{W}"><w:body><w:p><w:pPr><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:pPr><w:r><w:t>One.</w:t></w:r></w:p><w:p><w:r><w:t>Two.</w:t></w:r></w:p><w:sectPr><w:type w:val="{kind}"/><w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/></w:sectPr></w:body></w:document>"#
|
||||
),
|
||||
),
|
||||
];
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, data) in files {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// #2127: Backspace just after a section break, or Delete just before it, removes the break, as
|
||||
/// in Word: the two paragraphs join, the text before the break takes the following section's
|
||||
/// page setup, and Undo brings the break back. Imported breaks and ones made here alike.
|
||||
#[test]
|
||||
fn backspace_and_delete_remove_a_section_break() {
|
||||
let made_here = || {
|
||||
let mut s = typed("One.Two.");
|
||||
s.sel = crate::Selection::caret(Pos::body(0, 4));
|
||||
run(&mut s, "layout.break", json!({"kind": "nextPage"}));
|
||||
s
|
||||
};
|
||||
for source in ["nextPage", "continuous", "made here"] {
|
||||
for (key, caret) in [("text.backspace", Pos::body(1, 0)), ("text.delete", Pos::body(0, 4))] {
|
||||
let mut s = match source {
|
||||
"made here" => made_here(),
|
||||
kind => Session::new(crate::io::open_bytes("s.docx", &two_section_docx(kind)).unwrap()),
|
||||
};
|
||||
assert_eq!(s.doc.sections().len(), 2, "{source}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source}");
|
||||
let after = s.doc.sections().last().map(|(_, sp)| (**sp).clone()).unwrap();
|
||||
s.sel = crate::Selection::caret(caret);
|
||||
run(&mut s, key, json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One.Two.", "{source} {key}");
|
||||
let sections = s.doc.sections();
|
||||
assert_eq!(sections.len(), 1, "{source} {key}: the break is gone");
|
||||
assert_eq!(*sections[0].1, after, "{source} {key}: the following section's setup");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One.\nTwo.", "{source} {key}: undo");
|
||||
assert_eq!(s.doc.sections().len(), 2, "{source} {key}: undo brings the break back");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A .docx laid out like the one attached to #2127: a continuous section break, with the
|
||||
/// document's only header, on the mark of an empty paragraph after "One.", and the last
|
||||
/// section (nextPage, no header of its own, so linked to the previous one) in the body.
|
||||
/// `then` adds a paragraph after the break; without it the break is on the last paragraph and
|
||||
/// the last section has none.
|
||||
fn break_on_the_last_paragraph_docx(then: Option<&str>) -> Vec<u8> {
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let then = then.map(|t| format!("<w:p><w:r><w:t>{t}</w:t></w:r></w:p>")).unwrap_or_default();
|
||||
let files = [
|
||||
(
|
||||
"[Content_Types].xml",
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/></Types>"#.to_string(),
|
||||
),
|
||||
(
|
||||
"_rels/.rels",
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><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"/></Relationships>"#.to_string(),
|
||||
),
|
||||
(
|
||||
"word/_rels/document.xml.rels",
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/></Relationships>"#.to_string(),
|
||||
),
|
||||
("word/header1.xml", format!(r#"<w:hdr xmlns:w="{W}"><w:p><w:r><w:t>Lab report</w:t></w:r></w:p></w:hdr>"#)),
|
||||
(
|
||||
"word/document.xml",
|
||||
format!(
|
||||
r#"<w:document xmlns:w="{W}" xmlns:r="{R}"><w:body><w:p><w:r><w:t>One.</w:t></w:r></w:p><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rId2"/><w:type w:val="continuous"/><w:pgSz w:w="11920" w:h="16850"/><w:pgMar w:top="1320" w:right="1275" w:bottom="280" w:left="1417" w:header="480" w:footer="0" w:gutter="0"/></w:sectPr></w:pPr></w:p>{then}<w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="11920" w:h="16850"/><w:pgMar w:top="1320" w:right="1275" w:bottom="280" w:left="1417" w:header="480" w:footer="0" w:gutter="0"/></w:sectPr></w:body></w:document>"#
|
||||
),
|
||||
),
|
||||
];
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, data) in files {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
std::io::Write::write_all(&mut zip, data.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// #2127: Delete at the end of the last paragraph removes a section break on its mark too,
|
||||
/// though there's no paragraph after it to join. The text takes the last section's setup, and
|
||||
/// the header the last section showed by linking to the removed one stays. Removing the break
|
||||
/// by joining two paragraphs keeps that header as well. Undo brings the break back.
|
||||
#[test]
|
||||
fn delete_removes_a_section_break_on_the_last_paragraph() {
|
||||
for then in [None, Some("Two.")] {
|
||||
let mut s = Session::new(crate::io::open_bytes("s.docx", &break_on_the_last_paragraph_docx(then)).unwrap());
|
||||
let sections = s.doc.sections();
|
||||
assert_eq!(sections.len(), 2, "{then:?}");
|
||||
let header = sections[0].1.headers.default;
|
||||
assert!(header.is_some() && sections[1].1.headers.default.is_none(), "{then:?}: the header is on the break's section");
|
||||
let mut after = sections[1].1.clone();
|
||||
after.headers.default = header;
|
||||
s.sel = crate::Selection::caret(Pos::body(1, 0));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
let text = if then.is_some() { "One.\nTwo." } else { "One.\n" };
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), text, "{then:?}");
|
||||
let sections = s.doc.sections();
|
||||
assert_eq!(sections.len(), 1, "{then:?}: the break is gone");
|
||||
assert_eq!(*sections[0].1, after, "{then:?}: the last section's setup, with the header");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let sections = s.doc.sections();
|
||||
assert_eq!(sections.len(), 2, "{then:?}: undo brings the break back");
|
||||
assert_eq!((sections[0].1.headers.default, sections[1].1.headers.default), (header, None), "{then:?}: undo");
|
||||
}
|
||||
}
|
||||
|
||||
+72
-34
@@ -310,7 +310,7 @@ pub struct FontDb {
|
||||
/// search sorts every loaded face, so without this text in a script the requested font lacks
|
||||
/// (Thai, #59) got slower with every font loaded.
|
||||
fallbacks: RwLock<FallbackCache>,
|
||||
ea_fallbacks: RwLock<EaFallbackCache>,
|
||||
run_fallbacks: RwLock<RunFallbackCache>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// Fonts the user added at run time, and the folder they are stored in (#582).
|
||||
@@ -546,10 +546,10 @@ struct FallbackCache {
|
||||
map: HashMap<(char, u32), Option<Arc<FontFace>>>,
|
||||
}
|
||||
|
||||
/// [`FontDb::fallback_for_east_asian`] results: (sorted, deduplicated chars, excluded face, bold,
|
||||
/// [`FontDb::fallback_for_run`] results: (sorted, deduplicated chars, excluded face, bold,
|
||||
/// italic) → face, valid while the loaded face count is `faces`.
|
||||
#[derive(Default)]
|
||||
struct EaFallbackCache {
|
||||
struct RunFallbackCache {
|
||||
faces: usize,
|
||||
/// Chars held in the keys (bounds memory: hostile runs can be long).
|
||||
chars: usize,
|
||||
@@ -557,19 +557,24 @@ struct EaFallbackCache {
|
||||
map: HashMap<(Vec<char>, u32, bool, bool), Option<Arc<FontFace>>>,
|
||||
}
|
||||
|
||||
/// Most cached run-level East Asian fallbacks, and most chars in their keys, before the cache is
|
||||
/// reset.
|
||||
const EA_FALLBACK_CACHE_MAX: usize = 4096;
|
||||
const EA_FALLBACK_CACHE_CHARS: usize = 1 << 20;
|
||||
/// Most cached run-level fallbacks, and most chars in their keys, before the cache is reset.
|
||||
const RUN_FALLBACK_CACHE_MAX: usize = 4096;
|
||||
const RUN_FALLBACK_CACHE_CHARS: usize = 1 << 20;
|
||||
|
||||
/// Loaded faces other than `exclude`, in fallback order: for right-to-left text the fonts made
|
||||
/// for the script first (so a word isn't split across fonts, which breaks Arabic joining), then
|
||||
/// the fallback family, upright, nearest to regular weight.
|
||||
fn fallback_order(faces: &[Arc<FontFace>], exclude: u32, rtl: bool) -> Vec<&Arc<FontFace>> {
|
||||
/// for the script first (so a word isn't split across fonts, which breaks Arabic joining); for a
|
||||
/// complex-script character `complex` (Thai, Myanmar…) faces with OpenType layout tables, the
|
||||
/// script's own families first, as the per-character fallback ranks them; then the fallback
|
||||
/// family, upright, nearest to regular weight.
|
||||
fn fallback_order(faces: &[Arc<FontFace>], exclude: u32, rtl: bool, complex: Option<char>) -> Vec<&Arc<FontFace>> {
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
let rank =
|
||||
|f: &FontFace| if rtl { RTL_FALLBACKS.iter().position(|n| f.family.eq_ignore_ascii_case(n)).unwrap_or(RTL_FALLBACKS.len()) } else { 0 };
|
||||
order.sort_by_key(|f| (rank(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
let shaping = |f: &FontFace| match complex {
|
||||
Some(c) => (!f.has_layout_tables(), crate::fallback_rank(c, &f.family)),
|
||||
None => (false, 0),
|
||||
};
|
||||
order.sort_by_key(|f| (rank(f), shaping(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order
|
||||
}
|
||||
|
||||
@@ -917,7 +922,7 @@ impl FontDb {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
fallbacks: RwLock::new(FallbackCache::default()),
|
||||
ea_fallbacks: RwLock::new(EaFallbackCache::default()),
|
||||
run_fallbacks: RwLock::new(RunFallbackCache::default()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
user: RwLock::new(user::UserFonts::default()),
|
||||
@@ -947,7 +952,7 @@ impl FontDb {
|
||||
}
|
||||
// Misses remembered while it was off may be found now.
|
||||
self.fallbacks.write().unwrap_or_else(|e| e.into_inner()).map.clear();
|
||||
*self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = EaFallbackCache::default();
|
||||
*self.run_fallbacks.write().unwrap_or_else(|e| e.into_inner()) = RunFallbackCache::default();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
}
|
||||
@@ -1531,16 +1536,20 @@ impl FontDb {
|
||||
(order.into_iter().find(|f| f.covers(c)).cloned(), faces.len())
|
||||
}
|
||||
|
||||
/// One fallback face for a run's East Asian characters: the first face in the fallback order
|
||||
/// that covers **every** given character, so a whole run draws in a single CJK face instead of
|
||||
/// mixing faces character by character (a bundled Japanese Mincho for JIS-covered ideographs
|
||||
/// beside a Simplified-Chinese sans for the rest). Like [`Self::fallback_for`], a system font
|
||||
/// is loaded lazily the first time no loaded face covers a character. `None` when no single
|
||||
/// face covers them all, and the caller keeps the per-character fallback.
|
||||
/// One fallback face for a run: the first face in the fallback order that covers **every**
|
||||
/// given character, so a whole run draws in a single face instead of mixing faces character
|
||||
/// by character. East Asian runs: a bundled Japanese Mincho for JIS-covered ideographs beside
|
||||
/// a Simplified-Chinese sans for the rest. A missing Thai font (#1549): the Thai letters in
|
||||
/// one face and the Latin letters and digits beside them in another, at another size; a Thai
|
||||
/// face covers them all, so the run is given every character, Latin ones included.
|
||||
/// Complex-script characters prefer the script's own families with OpenType layout tables, as
|
||||
/// [`Self::fallback_for`] does. Like it, a system font is loaded lazily the first time no
|
||||
/// loaded face covers a character. `None` when no single face covers them all, and the caller
|
||||
/// keeps the per-character fallback.
|
||||
///
|
||||
/// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces
|
||||
/// change, like [`Self::fallback_for`], so relayout doesn't repeat the search.
|
||||
pub fn fallback_for_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
pub fn fallback_for_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
if chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1550,18 +1559,18 @@ impl FontDb {
|
||||
let key = (set, exclude, bold, italic);
|
||||
let n = self.read_faces().len();
|
||||
{
|
||||
let cache = self.ea_fallbacks.read().unwrap_or_else(|e| e.into_inner());
|
||||
let cache = self.run_fallbacks.read().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.faces == n
|
||||
&& let Some(hit) = cache.map.get(&key)
|
||||
{
|
||||
return hit.clone();
|
||||
}
|
||||
}
|
||||
let found = self.search_east_asian(&key.0, exclude, bold, italic);
|
||||
let found = self.search_run(&key.0, exclude, bold, italic);
|
||||
// Keyed by the face count after the search (a system fallback may have loaded fonts).
|
||||
let n = self.read_faces().len();
|
||||
let mut cache = self.ea_fallbacks.write().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.faces != n || cache.map.len() >= EA_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > EA_FALLBACK_CACHE_CHARS {
|
||||
let mut cache = self.run_fallbacks.write().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.faces != n || cache.map.len() >= RUN_FALLBACK_CACHE_MAX || cache.chars.saturating_add(key.0.len()) > RUN_FALLBACK_CACHE_CHARS {
|
||||
cache.map.clear();
|
||||
cache.faces = n;
|
||||
cache.chars = 0;
|
||||
@@ -1571,7 +1580,7 @@ impl FontDb {
|
||||
found
|
||||
}
|
||||
|
||||
fn search_east_asian(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
fn search_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
|
||||
let mut base = self.loaded_covering(chars, exclude);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1611,7 +1620,10 @@ impl FontDb {
|
||||
fn loaded_covering(&self, chars: &[char], exclude: u32) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let rtl = chars.iter().any(|c| crate::is_rtl(*c) || arabic_block(*c));
|
||||
fallback_order(&faces, exclude, rtl).into_iter().find(|f| chars.iter().all(|c| f.covers(*c))).cloned()
|
||||
// A complex-script character picks the families its script prefers (the lowest one: the
|
||||
// run's characters come sorted).
|
||||
let complex = chars.iter().copied().find(|c| crate::needs_layout_tables(*c));
|
||||
fallback_order(&faces, exclude, rtl, complex).into_iter().find(|f| chars.iter().all(|c| f.covers(*c))).cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
@@ -1752,15 +1764,15 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn east_asian_run_fallback_is_cached_per_char_set() {
|
||||
fn run_fallback_is_cached_per_char_set() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let latin = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let a = db.fallback_for_east_asian(&['分', '类', '分'], latin.id(), false, false).map(|f| f.id());
|
||||
let a = db.fallback_for_run(&['分', '类', '分'], latin.id(), false, false).map(|f| f.id());
|
||||
// The same set in another order (and without the repeat) is the same entry.
|
||||
assert_eq!(db.fallback_for_east_asian(&['类', '分'], latin.id(), false, false).map(|f| f.id()), a);
|
||||
assert_eq!(db.fallback_for_run(&['类', '分'], latin.id(), false, false).map(|f| f.id()), a);
|
||||
{
|
||||
let cache = db.ea_fallbacks.read().unwrap();
|
||||
let cache = db.run_fallbacks.read().unwrap();
|
||||
assert_eq!(cache.faces, db.read_faces().len());
|
||||
assert_eq!(cache.map.len(), 1);
|
||||
assert!(cache.map.contains_key(&(vec!['分', '类'], latin.id(), false, false)));
|
||||
@@ -1768,14 +1780,40 @@ mod tests {
|
||||
// Long hostile runs keep the cache bounded.
|
||||
let many: Vec<char> = (0x4E00u32..0x4E00 + 50_000).filter_map(char::from_u32).collect();
|
||||
for i in 0..30 {
|
||||
let _ = db.fallback_for_east_asian(many.get(i..).unwrap_or(&[]), latin.id(), false, false);
|
||||
let _ = db.fallback_for_run(many.get(i..).unwrap_or(&[]), latin.id(), false, false);
|
||||
}
|
||||
let cache = db.ea_fallbacks.read().unwrap();
|
||||
assert!(cache.chars <= EA_FALLBACK_CACHE_CHARS, "{}", cache.chars);
|
||||
let cache = db.run_fallbacks.read().unwrap();
|
||||
assert!(cache.chars <= RUN_FALLBACK_CACHE_CHARS, "{}", cache.chars);
|
||||
// Turning the system fallback on or off forgets remembered misses.
|
||||
drop(cache);
|
||||
db.set_system_fallback(false);
|
||||
assert!(db.ea_fallbacks.read().unwrap().map.is_empty());
|
||||
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
|
||||
}
|
||||
|
||||
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
|
||||
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
||||
|
||||
#[test]
|
||||
fn a_thai_run_with_latin_falls_back_to_one_face() {
|
||||
let db = FontDb::global();
|
||||
if db.fallback_for('น', 0).is_none() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
let latin = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let chars: Vec<char> = THAI_RUN.chars().filter(|c| !c.is_whitespace()).collect();
|
||||
let f = db.fallback_for_run(&chars, latin.id(), false, false).expect("one face covers the run");
|
||||
assert_ne!(f.id, latin.id);
|
||||
for c in &chars {
|
||||
assert!(f.covers(*c), "{} covers {c}", f.family);
|
||||
}
|
||||
// A Thai font with OpenType tables, as the per-character fallback prefers.
|
||||
assert!(f.has_layout_tables(), "{} shapes Thai", f.family);
|
||||
// Without system fonts no bundled face covers Thai: the caller keeps the per-character path.
|
||||
let bundled = FontDb::with_font_dirs(Vec::new());
|
||||
bundled.set_system_fallback(false);
|
||||
let latin = bundled.face(FALLBACK_FAMILY, "Regular");
|
||||
assert!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -385,20 +385,20 @@ mod tests {
|
||||
let latin = db.face(DEFAULT_FAMILY, "Regular");
|
||||
if CRAFT_FONTS.is_empty() {
|
||||
// No bundled CJK font and no system fallback: nothing can draw the run.
|
||||
assert!(db.fallback_for_east_asian(&['分', '类'], latin.id(), false, false).is_none());
|
||||
assert!(db.fallback_for_run(&['分', '类'], latin.id(), false, false).is_none());
|
||||
return;
|
||||
}
|
||||
// JIS-covered ideographs alone would fall back to a Mincho and simplified-only ones to the
|
||||
// SC sans: the run-level pick must cover every character in one face, or a run mixes
|
||||
// serif and sans glyphs (the per-character fallback still applies when it returns None).
|
||||
let run: Vec<char> = "分类监督学习".chars().collect();
|
||||
let f = db.fallback_for_east_asian(&run, latin.id(), false, false).expect("a CJK face covers the run");
|
||||
let f = db.fallback_for_run(&run, latin.id(), false, false).expect("a CJK face covers the run");
|
||||
for c in &run {
|
||||
assert!(f.covers(*c), "{f:?} covers {c}");
|
||||
}
|
||||
// Pure Japanese still gets the document serif (Mincho first).
|
||||
let jp: Vec<char> = "日本語".chars().collect();
|
||||
let f2 = db.fallback_for_east_asian(&jp, latin.id(), false, false).expect("a CJK face");
|
||||
let f2 = db.fallback_for_run(&jp, latin.id(), false, false).expect("a CJK face");
|
||||
assert!(f2.family.contains("Mincho"), "{f2:?}");
|
||||
}
|
||||
|
||||
|
||||
+185
-2
@@ -49,6 +49,25 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
const HEBREW: &[&str] = &["Helvetica", "Noto Sans Hebrew", "Arial", "Tahoma", "Geeza Pro", "DejaVu Sans", "Source Sans 3"];
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
const HEBREW: &[&str] = &["Noto Sans Hebrew", "Segoe UI", "Arial", "Tahoma", "DejaVu Sans", "Source Sans 3"];
|
||||
// Thai document fonts (TH Sarabun New, Cordia New, the UPC families…): Thai faces, every one
|
||||
// with Thai letters; [`substitute`] prefers one with Latin letters and digits too, so a run
|
||||
// mixing Thai and Latin keeps one font at one size, as Word substitutes Cordia New for the
|
||||
// whole run (#1549). Windows' own Thai fonts first, then the Mac's, then the open Noto and Thai
|
||||
// Linux Working Group families. The Mac's Thonburi and Sathu carry Apple's `morx` tables
|
||||
// instead of OpenType ones; the shaper applies those, so their marks stack as they should.
|
||||
#[cfg(windows)]
|
||||
const THAI: &[&str] = &["Cordia New", "Leelawadee UI", "Leelawadee", "Tahoma", "Noto Sans Thai", "Browallia New", "Angsana New"];
|
||||
#[cfg(target_os = "macos")]
|
||||
const THAI: &[&str] = &["Thonburi", "Ayuthaya", "Sathu", "Krungthep", "Noto Sans Thai", "Tahoma"];
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
const THAI: &[&str] = &["Noto Sans Thai", "Laksaman", "Garuda", "Waree", "Tlwg Typo", "Loma", "Umpush", "Noto Serif Thai"];
|
||||
// Serif Thai document fonts (Angsana New, EucrosiaUPC, Norasi…) prefer a serif Thai face.
|
||||
#[cfg(windows)]
|
||||
const THAI_SERIF: &[&str] = &["Angsana New", "AngsanaUPC", "Noto Serif Thai", "Cordia New", "Leelawadee UI", "Tahoma", "Noto Sans Thai"];
|
||||
#[cfg(target_os = "macos")]
|
||||
const THAI_SERIF: &[&str] = &["Sathu", "Noto Serif Thai", "Thonburi", "Ayuthaya", "Krungthep", "Noto Sans Thai", "Tahoma"];
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
const THAI_SERIF: &[&str] = &["Noto Serif Thai", "Norasi", "Kinnari", "Noto Sans Thai", "Laksaman", "Garuda", "Waree"];
|
||||
// Palatino Linotype / Book Antiqua: macOS ships the same design as "Palatino"; open
|
||||
// look-alikes with matching metrics (TeX Gyre Pagella, URW P052 / Palladio) on Linux.
|
||||
const PALATINO: &[&str] = &[
|
||||
@@ -68,6 +87,12 @@ fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
"Source Serif 4",
|
||||
];
|
||||
let f = family.to_ascii_lowercase();
|
||||
if is_thai_serif(&f) {
|
||||
return THAI_SERIF;
|
||||
}
|
||||
if is_thai(&f) {
|
||||
return THAI;
|
||||
}
|
||||
if is_hebrew(&f) {
|
||||
return HEBREW;
|
||||
}
|
||||
@@ -196,12 +221,86 @@ fn is_persian_sans(f: &str) -> bool {
|
||||
NAMES.contains(&f) || f.starts_with("iransans") || f.starts_with("iranyekan")
|
||||
}
|
||||
|
||||
/// Thai document fonts: Word's Thai families (Cordia New, Angsana New, Browallia New, the UPC
|
||||
/// families, Leelawadee), the Thai national fonts (TH Sarabun New and the other "TH …" fonts),
|
||||
/// the Mac's, the Thai Linux Working Group's and Noto's. Matched by name only.
|
||||
fn is_thai(f: &str) -> bool {
|
||||
const NAMES: &[&str] = &[
|
||||
"cordia new",
|
||||
"cordiaupc",
|
||||
"angsana new",
|
||||
"angsanaupc",
|
||||
"browallia new",
|
||||
"browalliaupc",
|
||||
"leelawadee",
|
||||
"leelawadee ui",
|
||||
"leelawadee ui semilight",
|
||||
"th sarabun new",
|
||||
"th sarabunpsk",
|
||||
"th sarabun psk",
|
||||
"th sarabun",
|
||||
"thonburi",
|
||||
"ayuthaya",
|
||||
"sathu",
|
||||
"krungthep",
|
||||
"silom",
|
||||
"sukhumvit set",
|
||||
"norasi",
|
||||
"kinnari",
|
||||
"garuda",
|
||||
"laksaman",
|
||||
"loma",
|
||||
"purisa",
|
||||
"sawasdee",
|
||||
"umpush",
|
||||
"waree",
|
||||
"tlwg typo",
|
||||
"tlwg typist",
|
||||
"tlwg mono",
|
||||
];
|
||||
NAMES.contains(&f) || f.ends_with("upc") || f.starts_with("th ") || f.contains("thai")
|
||||
}
|
||||
|
||||
/// Serif Thai document fonts.
|
||||
fn is_thai_serif(f: &str) -> bool {
|
||||
const NAMES: &[&str] = &["angsana new", "angsanaupc", "eucrosiaupc", "jasmineupc", "kodchiangupc", "dilleniaupc", "norasi", "kinnari"];
|
||||
NAMES.contains(&f) || (f.contains("thai") && f.contains("serif"))
|
||||
}
|
||||
|
||||
/// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the
|
||||
/// whole run, never in a Latin substitute (#1549).
|
||||
pub fn is_thai_family(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
is_thai(&f) || is_thai_serif(&f)
|
||||
}
|
||||
|
||||
/// Hebrew document fonts.
|
||||
fn is_hebrew(f: &str) -> bool {
|
||||
const NAMES: &[&str] = &["david", "miriam", "noto sans hebrew", "arial hebrew", "calibri hebrew"];
|
||||
NAMES.contains(&f) || f.contains("hebrew")
|
||||
}
|
||||
|
||||
/// The first installed substitute for a missing `family`. For a Thai font, the first with Latin
|
||||
/// letters and digits as well as Thai ones (a Thai face without Latin would leave a mixed run in
|
||||
/// two fonts, #1549), else the first with Thai letters, else the first installed.
|
||||
fn substitute(db: &FontDb, family: &str) -> Option<&'static str> {
|
||||
let mut installed = substitutes(family).iter().copied().filter(|s| db.has_family(s));
|
||||
if !is_thai_family(family) {
|
||||
return installed.next();
|
||||
}
|
||||
let installed: Vec<&'static str> = installed.collect();
|
||||
let covers = |name: &str, chars: &[char]| {
|
||||
let face = db.face(name, "Regular");
|
||||
face.named(name) && chars.iter().all(|c| face.covers(*c))
|
||||
};
|
||||
installed
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|s| covers(s, &['\u{0E19}', 'A', 'z', '0', '9']))
|
||||
.or_else(|| installed.iter().copied().find(|s| covers(s, &['\u{0E19}'])))
|
||||
.or_else(|| installed.first().copied())
|
||||
}
|
||||
|
||||
fn style_name(bold: bool, italic: bool) -> &'static str {
|
||||
match (bold, italic) {
|
||||
(false, false) => "Regular",
|
||||
@@ -237,8 +336,7 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved {
|
||||
// Windows maps the charset names to the font itself: not a substitution.
|
||||
(base.to_string(), false)
|
||||
} else {
|
||||
let s = substitutes(base).iter().find(|s| db.has_family(s)).copied().unwrap_or(crate::FALLBACK_FAMILY);
|
||||
(s.to_string(), true)
|
||||
(substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true)
|
||||
};
|
||||
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) };
|
||||
@@ -390,4 +488,89 @@ mod tests {
|
||||
// Generic names with "Hebrew" in them also get Hebrew substitutions
|
||||
assert!(substitutes("SomeFont Hebrew").contains(&"Noto Sans Hebrew"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_fonts_substitute_thai_faces() {
|
||||
// Every Thai document family maps to a list of Thai faces, never to the Latin lists.
|
||||
for f in [
|
||||
"TH Sarabun New",
|
||||
"TH SarabunPSK",
|
||||
"Angsana New",
|
||||
"AngsanaUPC",
|
||||
"Cordia New",
|
||||
"CordiaUPC",
|
||||
"Browallia New",
|
||||
"BrowalliaUPC",
|
||||
"JasmineUPC",
|
||||
"KodchiangUPC",
|
||||
"LilyUPC",
|
||||
"DilleniaUPC",
|
||||
"EucrosiaUPC",
|
||||
"FreesiaUPC",
|
||||
"IrisUPC",
|
||||
"Leelawadee",
|
||||
"Leelawadee UI",
|
||||
"Noto Sans Thai",
|
||||
"TH Niramit AS",
|
||||
] {
|
||||
assert!(is_thai_family(f), "{f}");
|
||||
let list = substitutes(f);
|
||||
assert!(list.iter().any(|s| s.contains("Thai")), "{f}: {list:?}");
|
||||
assert!(!list.contains(&"Carlito") && !list.contains(&"Caladea"), "{f}: {list:?}");
|
||||
}
|
||||
// Serif Thai families prefer a serif Thai face; the sans families a sans one.
|
||||
let pos = |family: &str, name: &str| substitutes(family).iter().position(|s| *s == name).unwrap_or(usize::MAX);
|
||||
assert!(pos("Angsana New", "Noto Serif Thai") < pos("Angsana New", "Noto Sans Thai"));
|
||||
assert!(pos("TH Sarabun New", "Noto Sans Thai") < pos("TH Sarabun New", "Noto Serif Thai"));
|
||||
// Latin and other script families are unchanged.
|
||||
assert!(!is_thai_family("Arial") && !is_thai_family("Tahoma") && !is_thai_family("Calibri") && !is_thai_family("David"));
|
||||
assert_eq!(substitutes("Calibri").first(), Some(&"Carlito"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_thai_font_resolves_to_an_installed_thai_face() {
|
||||
// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht
|
||||
// sign in one run of a Thai font that isn't installed.
|
||||
const RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
||||
let db = FontDb::global();
|
||||
for family in ["TH Sarabun New", "Angsana New", "Cordia New", "BrowalliaUPC"] {
|
||||
if db.has_family(family) {
|
||||
continue;
|
||||
}
|
||||
let r = resolve(family, false, false);
|
||||
let face = r.face.get();
|
||||
if !face.covers('\u{0E19}') {
|
||||
eprintln!("skipped {family}: no Thai face from its substitute list is installed");
|
||||
continue;
|
||||
}
|
||||
assert!(r.substituted, "{family}");
|
||||
let thai: Vec<char> = RUN.chars().filter(|c| ('\u{0E00}'..='\u{0E7F}').contains(c) && !face.covers(*c)).collect();
|
||||
assert!(thai.is_empty(), "{family} → {} lacks {thai:?}", face.family);
|
||||
// A listed Thai face with Latin letters and digits wins over one without them.
|
||||
let latin = |name: &str| {
|
||||
let f = db.face(name, "Regular");
|
||||
f.named(name) && f.covers('\u{0E19}') && f.covers('D') && f.covers('5')
|
||||
};
|
||||
if substitutes(family).iter().any(|s| db.has_family(s) && latin(s)) {
|
||||
let missing: Vec<char> = RUN.chars().filter(|c| !c.is_whitespace() && !face.covers(*c)).collect();
|
||||
assert!(missing.is_empty(), "{family} → {} lacks {missing:?}", face.family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thonburi_stacks_thai_marks_through_its_apple_tables() {
|
||||
// Thonburi has `morx` but no GSUB: the shaper must still move a tone mark up over an upper
|
||||
// vowel (ปั่) instead of drawing it where it sits on a bare consonant (ป่).
|
||||
let db = FontDb::global();
|
||||
if !db.has_family("Thonburi") {
|
||||
eprintln!("skipped: Thonburi not installed");
|
||||
return;
|
||||
}
|
||||
let face = db.face("Thonburi", "Regular");
|
||||
let tone = |text: &str| crate::shape(&face, text, &[], |c| c).into_iter().last().map(|g| (g.gid, g.y_offset));
|
||||
let (bare, stacked) = (tone("\u{0E1B}\u{0E48}"), tone("\u{0E1B}\u{0E31}\u{0E48}"));
|
||||
assert!(bare.is_some() && stacked.is_some());
|
||||
assert_ne!(bare, stacked, "the tone mark over ั is raised or swapped");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,6 +447,7 @@ impl DocLayout {
|
||||
let first = para.lines.get(*l0)?;
|
||||
let l = para.lines.get(li)?;
|
||||
let cx = para.x_of(li, pos.off)?;
|
||||
let cx = para.caret_limit(li).map_or(cx, |m| cx.min(m));
|
||||
// In the lines' own frame (turned text turns it onto the page).
|
||||
let top = l.top - first.top;
|
||||
// Caret spans the text height (ascent + descent), not the full line spacing.
|
||||
|
||||
@@ -9,6 +9,7 @@ use wordcraft_doc::numbering::{Level, LevelSuffix};
|
||||
use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, RubyAlign, SOFT_HYPHEN, WrapText};
|
||||
use wordcraft_doc::props::{Align, CharProps, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop};
|
||||
use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara};
|
||||
use wordcraft_doc::section::NumFormat;
|
||||
use wordcraft_doc::{Document, Paragraph};
|
||||
use wordcraft_fonts::FaceRef;
|
||||
|
||||
@@ -62,6 +63,21 @@ impl<'a> SymbolRun<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a bullet level's `text` has a private-use character that no installed font draws:
|
||||
/// its font isn't installed (or lacks it) and it isn't a symbol-font code with a Unicode
|
||||
/// equivalent. Private-use codes mean something different in every font, so another face's
|
||||
/// glyph there is an unrelated icon, not a bullet.
|
||||
fn undrawable_bullet(text: &str, lvl: &Level, rc: &ResolvedChar) -> bool {
|
||||
let private = |c: char| matches!(u32::from(c), 0xE000..=0xF8FF | 0xF0000..=0x10FFFF);
|
||||
lvl.format == NumFormat::Bullet
|
||||
&& text.chars().any(|c| {
|
||||
private(c) && wordcraft_doc::symbol_fonts::symbol_char(&rc.font, c).is_none() && {
|
||||
let r = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic);
|
||||
r.substituted || !r.face.covers(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A face at a size with the formatting needed to draw it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StyleRun {
|
||||
@@ -671,10 +687,12 @@ fn east_asian(c: char) -> bool {
|
||||
const MISSING_ROMAN_FONT: &str = "Cambria";
|
||||
|
||||
/// The family to look `font` up as: [`MISSING_ROMAN_FONT`] for a missing roman font, else `font`.
|
||||
/// A missing Thai font keeps its name: it is drawn in a Thai face, not in Cambria (#1549).
|
||||
fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font));
|
||||
if roman
|
||||
&& !wordcraft_fonts::word::is_thai_family(font)
|
||||
&& !wordcraft_doc::symbol_fonts::is_symbol_font(font)
|
||||
&& !db.has_family(font)
|
||||
&& !db.has_family(wordcraft_fonts::word::charset_base_family(font))
|
||||
@@ -685,6 +703,28 @@ fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str {
|
||||
}
|
||||
}
|
||||
|
||||
/// One face for a run in a Thai font that isn't installed (#1549): Word substitutes one font
|
||||
/// (Cordia New) for the whole run, so the Latin letters, digits and ฿ keep the Thai letters' face
|
||||
/// and size. `None` unless `font` is a Thai family, it was substituted, and its substitute
|
||||
/// `primary` lacks some of the run's Thai or Latin characters; then the first face covering all of
|
||||
/// them, if one does. Other families keep Latin text in their own substitute, as Word does.
|
||||
pub(crate) fn thai_run_face(font: &str, substituted: bool, primary: FaceRef, text: &str, bold: bool, italic: bool) -> Option<FaceRef> {
|
||||
if !substituted || !wordcraft_fonts::word::is_thai_family(font) {
|
||||
return None;
|
||||
}
|
||||
let run: Vec<char> = text.chars().filter(|c| thai_or_latin(*c)).collect();
|
||||
if run.iter().all(|c| primary.covers(*c)) {
|
||||
return None;
|
||||
}
|
||||
wordcraft_fonts::FontDb::global().fallback_for_run(&run, primary.id(), bold, italic).map(|f| FaceRef::of(&f))
|
||||
}
|
||||
|
||||
/// Text a Thai substitute draws: Thai letters, digits and signs (฿), and Latin letters, digits
|
||||
/// and punctuation.
|
||||
fn thai_or_latin(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0021..=0x024F | 0x0E00..=0x0E7F) && !c.is_whitespace() && !c.is_control() && c != SOFT_HYPHEN
|
||||
}
|
||||
|
||||
impl<'a> Builder<'a> {
|
||||
/// Displayed form of `c` at run-relative byte `at` (Word › Numeral): one char in, one
|
||||
/// char out. `rtl` is the shaping direction; in Context mode `context` (from
|
||||
@@ -780,6 +820,13 @@ impl<'a> Builder<'a> {
|
||||
.map(|r| r.face);
|
||||
let use_levels = para_text && !self.levels.is_empty();
|
||||
let context = if self.env.numeral == NumeralMode::Context && use_levels { context_digit_dirs(text, &self.levels, base) } else { Vec::new() };
|
||||
// A missing Thai font: one face for the run's Thai and Latin text (#1549). A run with its
|
||||
// own complex-script font keeps that font for the Thai letters.
|
||||
let run_face: Option<FaceRef> = if primary_cs.is_none_or(|f| f.id() == primary.id()) {
|
||||
thai_run_face(&rc.font, resolved.substituted, primary, text, rc.bold, rc.italic)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// One fallback face for the run's East Asian characters: the first face in fallback order
|
||||
// covering every character the primary font lacks, so the run draws in a single CJK face
|
||||
// instead of mixing faces per character (e.g. a bundled Japanese Mincho for JIS-covered
|
||||
@@ -793,7 +840,7 @@ impl<'a> Builder<'a> {
|
||||
if ea.is_empty() {
|
||||
None
|
||||
} else {
|
||||
wordcraft_fonts::FontDb::global().fallback_for_east_asian(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f))
|
||||
wordcraft_fonts::FontDb::global().fallback_for_run(&ea, primary.id(), rc.bold, rc.italic).map(|f| FaceRef::of(&f))
|
||||
}
|
||||
};
|
||||
// Split by font coverage (fallback faces), small caps case, script formatting and direction.
|
||||
@@ -817,6 +864,8 @@ impl<'a> Builder<'a> {
|
||||
let face = match cur {
|
||||
Some(k) if mark => k.face,
|
||||
_ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
|
||||
// The run's one substitute face for a missing Thai font (#1549).
|
||||
_ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,
|
||||
_ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
|
||||
// Stay in the fallback face the word started in while it covers the letters.
|
||||
Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),
|
||||
@@ -1528,6 +1577,12 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
let label = env.label.as_ref().filter(|_| !break_only).map(|(text, lvl)| {
|
||||
let lc = p.mark.clone().overlaid(&lvl.chr);
|
||||
let rc = resolve(&lc);
|
||||
// A bullet no installed font can draw would come from whatever icon font has its
|
||||
// private-use code (Font Awesome's Twitter logo at U+F081, #1683): it shows the level's
|
||||
// default bullet instead.
|
||||
let fixed = undrawable_bullet(text, lvl, &rc)
|
||||
.then(|| wordcraft_doc::numbering::default_bullet(usize::from(rp.numbering.map_or(0, |n| n.level))).to_string());
|
||||
let text: &str = fixed.as_deref().unwrap_or(text);
|
||||
// A Symbol/Wingdings bullet (U+F0B7…) is drawn as its Unicode equivalent when the font
|
||||
// isn't installed (shaping maps it); the label's text reads as that character too.
|
||||
let shown = |t: &str| -> String { wordcraft_doc::symbol_fonts::map_text(&rc.font, t) };
|
||||
@@ -2545,6 +2600,21 @@ impl ParaLayout {
|
||||
}
|
||||
l.xs.last().copied()
|
||||
}
|
||||
/// The farthest right a caret goes on left-to-right line `li`: spaces at the end of a line
|
||||
/// hang past its right edge, but the caret after them stays at the edge, as in Word (#2146);
|
||||
/// text that overflows the line still carries it. `None` on bidirectional lines.
|
||||
pub fn caret_limit(&self, li: usize) -> Option<f32> {
|
||||
let l = self.lines.get(li)?;
|
||||
if !l.vis.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let ink = (l.c0..l.c1).rev().find(|&k| self.clusters.get(k).is_some_and(|c| c.kind != ClKind::Space));
|
||||
let end = match ink {
|
||||
Some(k) => l.xs.get(k + 1 - l.c0).copied(),
|
||||
None => l.xs.first().copied(),
|
||||
};
|
||||
Some(end.unwrap_or(l.left).max(l.right))
|
||||
}
|
||||
/// The line holding offset `off` (a position at a wrap point belongs to the next line).
|
||||
pub fn line_of(&self, off: usize) -> usize {
|
||||
for (i, l) in self.lines.iter().enumerate() {
|
||||
|
||||
@@ -1054,6 +1054,25 @@ fn pcl6_symbol_draws_greater_equal() {
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "\u{F0B3}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_use_bullet_no_font_draws_shows_the_levels_default_bullet() {
|
||||
// #1683: a level-2 bullet stored as a private-use code in a font that isn't installed was
|
||||
// drawn from an installed icon font (U+F081 is the Twitter logo in Font Awesome).
|
||||
let mut d = Document::from_text("one");
|
||||
let id = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
||||
for a in &mut d.numbering.abstracts {
|
||||
if let Some(l) = a.levels.get_mut(1) {
|
||||
l.text = "\u{F081}".into();
|
||||
l.chr.font = Some("WordCraft Missing Font 1683".into());
|
||||
}
|
||||
}
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 1 })).unwrap();
|
||||
let l = lay(&d);
|
||||
let label = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { para.label.clone() } else { None }).unwrap();
|
||||
assert_eq!(label.text, "\u{25CB}");
|
||||
assert_eq!(label.glyphs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wingdings_text_draws_and_reads_as_its_unicode_symbols() {
|
||||
// Word stores symbol-font text both ways: plain codes ("J" = smiley) and private-use codes
|
||||
@@ -1336,6 +1355,39 @@ fn headers_and_page_fields() {
|
||||
assert!(c.top > 700.0);
|
||||
}
|
||||
|
||||
/// Spaces typed at the end of a line hang past the right margin (they don't wrap), but the caret
|
||||
/// after them stays at the margin, as in Word, instead of running off the page (#2146). A letter
|
||||
/// typed after them wraps to the next line. Same in the body and in a header.
|
||||
#[test]
|
||||
fn caret_after_hanging_spaces_stays_at_the_margin() {
|
||||
let spaces = format!("a{}", " ".repeat(200));
|
||||
for header in [false, true] {
|
||||
for text in [spaces.clone(), format!("{spaces}b")] {
|
||||
let mut d = Document::new();
|
||||
let p = wordcraft_doc::Paragraph::with_text(&text, Default::default());
|
||||
let pos = if header {
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(p)]);
|
||||
d.last_section.headers.default = Some(id);
|
||||
Pos { story: StoryRef::Part(id), path: Path::top(0), off: text.len() }
|
||||
} else {
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
Pos::body(0, text.len())
|
||||
};
|
||||
let right = d.last_section.page_w - d.last_section.margin_right;
|
||||
let l = lay(&d);
|
||||
let items = if header { &l.pages[0].header } else { &l.pages[0].items };
|
||||
let lines = items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.lines.len()) } else { None }).unwrap();
|
||||
let c = l.caret(&pos).unwrap();
|
||||
let typed = text.ends_with('b');
|
||||
assert_eq!(lines, if typed { 2 } else { 1 }, "header {header}, letter typed {typed}");
|
||||
assert!(c.x <= right + 0.5, "header {header}, letter typed {typed}: caret at {} past the margin at {right}", c.x);
|
||||
if !typed {
|
||||
assert!(c.x >= right - 0.5, "header {header}: the caret sits at the margin, after the spaces ({})", c.x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_first_page_header_pushes_body_down() {
|
||||
let mut d = Document::from_text(&"para\n".repeat(120));
|
||||
|
||||
@@ -109,3 +109,160 @@ fn myanmar_in_a_latin_font_falls_back_to_a_regular_myanmar_face() {
|
||||
let x0 = l.caret(&Pos::body(0, 0)).map(|c| c.x).unwrap_or(0.0);
|
||||
assert!(x6 > x0);
|
||||
}
|
||||
|
||||
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
|
||||
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
|
||||
|
||||
/// Is a font with Thai letters installed? `false` skips the test.
|
||||
fn thai_font_installed() -> bool {
|
||||
wordcraft_fonts::FontDb::global().fallback_for('\u{0E19}', 0).is_some()
|
||||
}
|
||||
|
||||
/// Body paragraph 0 of `text`, its run set in `font` at 16 pt (`None` keeps the document's
|
||||
/// default font), and the face of every cluster: (cluster start, face).
|
||||
fn cluster_faces(text: &str, font: Option<&str>) -> Vec<(usize, wordcraft_fonts::FaceRef)> {
|
||||
let mut d = Document::from_text(text);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.runs[0].props.font = font.map(Into::into);
|
||||
p.runs[0].props.size = Some(16.0);
|
||||
let l = lay(&d);
|
||||
let pl = para_layout(&l, 0);
|
||||
pl.clusters.iter().map(|c| (c.start, pl.styles[c.style as usize].face)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_thai_font_draws_the_whole_run_in_one_thai_face() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// Word substitutes one font (Cordia New) for a missing Thai font, so the Latin letters, the
|
||||
// digits and the baht sign keep the Thai letters' face and size. A Thai name the lists don't
|
||||
// spell out ("TH Niramit AS") is matched by its "TH " prefix and substituted the same way.
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
for font in ["TH Sarabun New", "Angsana New", "TH Niramit AS"] {
|
||||
if db.has_family(font) {
|
||||
continue;
|
||||
}
|
||||
let faces = cluster_faces(THAI_RUN, Some(font));
|
||||
let first = faces[0].1;
|
||||
let names: Vec<&str> = faces.iter().map(|(_, f)| f.family.as_str()).collect();
|
||||
assert!(faces.iter().all(|(_, f)| f.id() == first.id()), "{font}: one face for the run, not {names:?}");
|
||||
assert!(first.covers('\u{0E19}') && first.covers('D') && first.covers('1') && first.covers('\u{0E3F}'), "{font} → {}", first.family);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// When the substitute lacks the run's Thai letters (no listed Thai font is installed, so the
|
||||
// Latin fallback family stands in) or its Latin ones, the run moves to one face covering all.
|
||||
let latin = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
let face = crate::para::thai_run_face("TH Sarabun New", true, latin, THAI_RUN, false, false).expect("one face covers the run");
|
||||
assert_ne!(face.id(), latin.id());
|
||||
for c in THAI_RUN.chars().filter(|c| !c.is_whitespace()) {
|
||||
assert!(face.covers(c), "{} covers {c}", face.family);
|
||||
}
|
||||
// Only for a Thai family that was substituted.
|
||||
assert!(crate::para::thai_run_face("Calibri", true, latin, THAI_RUN, false, false).is_none());
|
||||
assert!(crate::para::thai_run_face("TH Sarabun New", false, latin, THAI_RUN, false, false).is_none());
|
||||
// A substitute that already covers the run keeps it.
|
||||
assert!(crate::para::thai_run_face("TH Sarabun New", true, face, THAI_RUN, false, false).is_none());
|
||||
// Text that is neither Thai nor Latin doesn't make the run move.
|
||||
assert!(crate::para::thai_run_face("TH Sarabun New", true, latin, "abc \u{2603}", false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_text_in_a_missing_latin_font_stays_in_its_substitute_beside_thai() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// Word keeps Latin text in the Latin font's substitute; only the Thai (or Myanmar) letters
|
||||
// fall back. A missing Latin family (and the default body font, Aptos, wherever it is
|
||||
// missing) must not move "Hello" into a Thai face.
|
||||
let myanmar = myanmar_face().is_some();
|
||||
for font in [Some("No Such Latin Font 1549"), None] {
|
||||
let requested = font.unwrap_or(wordcraft_doc::styles::BODY_FONT);
|
||||
let latin = wordcraft_fonts::word::resolve(requested, false, false).face;
|
||||
for text in ["Hello \u{0E2A}\u{0E27}\u{0E31}\u{0E2A}\u{0E14}\u{0E35}", "Hello \u{1019}\u{103C}\u{1014}\u{103A}\u{1019}\u{102C}"] {
|
||||
if text.contains('\u{1019}') && !myanmar {
|
||||
continue;
|
||||
}
|
||||
let faces = cluster_faces(text, font);
|
||||
let hello: Vec<_> = faces.iter().filter(|(at, _)| *at < 5).collect();
|
||||
assert_eq!(hello.len(), 5, "{faces:?}");
|
||||
assert!(
|
||||
hello.iter().all(|(_, f)| f.id() == latin.id()),
|
||||
"{requested}: Hello in {}, not {:?}",
|
||||
latin.family,
|
||||
hello.iter().map(|(_, f)| f.family.as_str()).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(faces.iter().any(|(at, f)| *at >= 6 && f.id() != latin.id()), "the other script falls back");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_in_an_installed_latin_font_keeps_the_latin_face_for_latin() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// The family exists, so nothing is substituted: Latin stays in it and only the Thai letters
|
||||
// fall back, character by character, as before.
|
||||
let faces = cluster_faces(THAI_RUN, Some("Source Sans 3"));
|
||||
let latin = wordcraft_fonts::word::resolve("Source Sans 3", false, false).face;
|
||||
assert!(faces.iter().any(|(_, f)| f.id() == latin.id()), "Latin clusters stay in Source Sans 3");
|
||||
assert!(faces.iter().any(|(_, f)| f.id() != latin.id() && f.covers('\u{0E19}')), "Thai clusters fall back to a Thai face");
|
||||
assert_eq!(faces[0].1.id(), latin.id(), "[D] opens the run in Source Sans 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_latin_font_with_a_thai_complex_script_font_keeps_both() {
|
||||
if !thai_font_installed() {
|
||||
eprintln!("skipped: no Thai font installed");
|
||||
return;
|
||||
}
|
||||
// A run with its own complex-script font (`w:cs`) draws the Thai letters in that font's
|
||||
// substitute and the Latin letters in the Latin font's substitute, as Word does.
|
||||
let mut d = Document::from_text(THAI_RUN);
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.runs[0].props.font = Some("No Such Latin Font 1549".into());
|
||||
p.runs[0].props.font_cs = Some("TH Sarabun New".into());
|
||||
let l = lay(&d);
|
||||
let pl = para_layout(&l, 0);
|
||||
let faces: Vec<_> = pl.clusters.iter().map(|c| pl.styles[c.style as usize].face).collect();
|
||||
let latin = wordcraft_fonts::word::resolve("No Such Latin Font 1549", false, false).face;
|
||||
let thai = wordcraft_fonts::word::resolve("TH Sarabun New", false, false).face;
|
||||
assert_eq!(faces[0].id(), latin.id(), "[D] in the Latin substitute");
|
||||
assert!(faces.iter().any(|f| f.id() == thai.id()), "Thai letters in the Thai substitute {}", thai.family);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
|
||||
// Word draws a missing font the font table classes as roman in Cambria, but a missing Thai
|
||||
// font keeps its Thai substitute: the same face whether or not the table calls it roman.
|
||||
let db = wordcraft_fonts::FontDb::global();
|
||||
if db.has_family("TH Sarabun New") {
|
||||
eprintln!("skipped: TH Sarabun New is installed");
|
||||
return;
|
||||
}
|
||||
let face = |roman: bool| {
|
||||
let mut d = Document::from_text("\u{0E19}\u{0E49}\u{0E33} Thai");
|
||||
if roman {
|
||||
d.settings.roman_fonts = vec!["TH Sarabun New".into()];
|
||||
}
|
||||
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.runs[0].props.font = Some("TH Sarabun New".into());
|
||||
let l = lay(&d);
|
||||
let pl = para_layout(&l, 0);
|
||||
pl.styles[pl.clusters.last().expect("a cluster").style as usize].face
|
||||
};
|
||||
let cambria = wordcraft_fonts::word::resolve("Cambria", false, false).face;
|
||||
assert_ne!(face(true).id(), cambria.id());
|
||||
assert_eq!(face(true).id(), face(false).id());
|
||||
}
|
||||
|
||||
@@ -1236,7 +1236,6 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
ctx.data_mut(|d| d.remove::<(egui::Id, u8)>(dialog_top_key()));
|
||||
return;
|
||||
};
|
||||
let mut open = true;
|
||||
let mut close = false;
|
||||
let title = match &d {
|
||||
Dialog::RenameDocument { .. } => "Rename Document",
|
||||
@@ -1292,17 +1291,19 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
Dialog::Layout(d) => d.title(),
|
||||
};
|
||||
let id = egui::Id::new(("dialog", title));
|
||||
let shown = place_dialog(ctx, id, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false)).open(&mut open).show(ctx, |ui| {
|
||||
let window = crate::window_close::window(ctx, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false));
|
||||
let shown = place_dialog(ctx, id, window).show(ctx, |ui| {
|
||||
ui.set_min_width(340.0);
|
||||
close = body(app, ui, &mut d);
|
||||
});
|
||||
if shown.is_some() {
|
||||
if let Some(shown) = &shown {
|
||||
count_dialog_frame(ctx, id);
|
||||
close |= crate::window_close::show(ctx, &shown.response, &Tokens::get(ctx));
|
||||
}
|
||||
if open && !close {
|
||||
app.dialog = Some(d);
|
||||
} else {
|
||||
if close {
|
||||
app.canvas.want_focus = true;
|
||||
} else {
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4208,6 +4209,52 @@ mod tests {
|
||||
assert!((after.min.y - (before.min.y + 120.0)).abs() <= 1.0, "moved down: {before:?} -> {after:?}");
|
||||
}
|
||||
|
||||
/// #1885: the close control is the whole box at the title bar's right end, as on a Windows
|
||||
/// dialog, not only the ✕. A click 6 px from the window's right edge, mid title bar, closes
|
||||
/// the dialog; one just under the title bar leaves it open; a drag that starts on the box
|
||||
/// doesn't move the dialog. The box spans the title bar egui lays out.
|
||||
#[test]
|
||||
fn the_title_bar_close_box_is_the_whole_corner() {
|
||||
fn press(h: &mut egui_kittest::Harness<'_, WordApp>, from: egui::Pos2, to: egui::Pos2) {
|
||||
let button =
|
||||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
h.event(egui::Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(button(from, true));
|
||||
h.step();
|
||||
if to != from {
|
||||
h.event(egui::Event::PointerMoved(to));
|
||||
h.step();
|
||||
}
|
||||
h.event(button(to, false));
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
let mut app = app_with("Some text");
|
||||
app.dialog = Dialog::open("wordCount", &mut app);
|
||||
let mut h = app_harness(app);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
let id = egui::Id::new(("dialog", "Word Count"));
|
||||
let before = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is open");
|
||||
let bx = crate::window_close::close_rect(before, 1.0, crate::window_close::geometry(&h.ctx));
|
||||
let title = h.ctx.read_response(id.with("__title_click")).expect("egui's title bar").rect;
|
||||
assert!((bx.top() - before.top()).abs() <= 1.5 && (bx.bottom() - title.bottom()).abs() <= 1.5, "box {bx:?} spans the title bar {title:?}");
|
||||
assert!(bx.width() >= 1.5 * bx.height() && bx.right() + 1.5 >= before.right(), "a wide box at the right end: {bx:?} in {before:?}");
|
||||
let under = egui::pos2(before.right() - 6.0, bx.bottom() + 4.0);
|
||||
press(&mut h, under, under);
|
||||
assert!(h.state().dialog.is_some(), "a click under the title bar keeps the dialog");
|
||||
let at = egui::pos2(before.right() - 6.0, bx.center().y);
|
||||
press(&mut h, at, at + vec2(-150.0, 90.0));
|
||||
assert!(h.state().dialog.is_some(), "a drag from the box isn't a click");
|
||||
let after = h.ctx.memory(|m| m.area_rect(id)).expect("dialog is still open");
|
||||
assert!((after.min - before.min).length() <= 1.0, "the box isn't a drag handle: {before:?} -> {after:?}");
|
||||
press(&mut h, at, at);
|
||||
assert!(h.state().dialog.is_none(), "a click in the box, away from the X, closes the dialog");
|
||||
}
|
||||
|
||||
fn app_harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
|
||||
@@ -112,12 +112,14 @@ pub struct ColumnsForm {
|
||||
pub cols: Vec<(f32, f32)>,
|
||||
/// Line between columns.
|
||||
pub separator: bool,
|
||||
/// `section`, `document` or `forward` (this point forward).
|
||||
/// `section`, `document`, `forward` (this point forward) or `selection` (selected text).
|
||||
pub apply: String,
|
||||
/// The section's text width, in the interface unit.
|
||||
pub text_width: f32,
|
||||
/// The document has more than one section (offer "This section").
|
||||
pub sections: bool,
|
||||
/// Text is selected that can take columns of its own (offer "Selected text", #2024).
|
||||
pub selection: bool,
|
||||
}
|
||||
|
||||
impl ColumnsForm {
|
||||
@@ -131,14 +133,21 @@ impl ColumnsForm {
|
||||
let c = &sp.columns;
|
||||
let count = c.count.clamp(1, 12);
|
||||
let sections = app.session.doc.sections().len() > 1;
|
||||
let selection = wordcraft_engine::cmd::page::selection_sectionable(&app.session);
|
||||
let apply = match (selection, sections) {
|
||||
(true, _) => "selection",
|
||||
(false, true) => "section",
|
||||
(false, false) => "document",
|
||||
};
|
||||
let mut f = ColumnsForm {
|
||||
count,
|
||||
equal: true,
|
||||
cols: Vec::new(),
|
||||
separator: c.separator,
|
||||
apply: if sections { "section" } else { "document" }.into(),
|
||||
apply: apply.into(),
|
||||
text_width: sp.text_width() / k,
|
||||
sections,
|
||||
selection,
|
||||
};
|
||||
if c.widths.len() == count as usize && count > 1 {
|
||||
f.equal = false;
|
||||
@@ -438,6 +447,9 @@ fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) -> bool {
|
||||
if f.sections {
|
||||
options.insert(0, ("section", "This section"));
|
||||
}
|
||||
if f.selection {
|
||||
options.push(("selection", "Selected text"));
|
||||
}
|
||||
let shown = options.iter().find(|(k, _)| *k == f.apply).map(|(_, l)| *l).unwrap_or("This section");
|
||||
egui::ComboBox::from_id_salt("columns_apply").selected_text(tl!(shown)).width(180.0).show_ui(ui, |ui| {
|
||||
for (k, l) in &options {
|
||||
@@ -1357,6 +1369,21 @@ mod tests {
|
||||
assert_eq!(wordcraft_engine::cmd::page::sect(&a.session).columns.count, 1);
|
||||
}
|
||||
|
||||
/// With text selected, More Columns applies to the selected text by default, as in Word
|
||||
/// (#2024): the other paragraphs keep one column.
|
||||
#[test]
|
||||
fn columns_dialog_applies_to_the_selected_text() {
|
||||
let mut a = app();
|
||||
assert!(!ColumnsForm::read(&a).selection, "no selection, no Selected text");
|
||||
a.run("select.paragraph", json!({})).unwrap();
|
||||
let mut f = ColumnsForm::read(&a);
|
||||
assert_eq!((f.selection, f.apply.as_str()), (true, "selection"));
|
||||
f.preset("two");
|
||||
a.run("layout.columns", f.params()).unwrap();
|
||||
let counts: Vec<u32> = a.session.doc.sections().iter().map(|(_, x)| x.columns.count).collect();
|
||||
assert_eq!(counts, vec![2, 1]);
|
||||
}
|
||||
|
||||
/// The Symbol dialog inserts in the chosen font and remembers the symbol.
|
||||
#[test]
|
||||
fn symbol_dialog_inserts_with_its_font() {
|
||||
|
||||
@@ -302,19 +302,17 @@ impl WordApp {
|
||||
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()))
|
||||
let window = 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 {
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0));
|
||||
let shown = crate::window_close::window(ctx, window).show(ctx, |ui| {
|
||||
ui.set_width(620.0);
|
||||
done = body(ui, &mut b, &t);
|
||||
});
|
||||
if shown.is_some_and(|s| crate::window_close::show(ctx, &s.response, &t)) {
|
||||
done = Some(None);
|
||||
}
|
||||
app.file_browser.open = Some(b);
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
Apply to: Прилагане към:
|
||||
Whole document Целия документ
|
||||
This section Този раздел
|
||||
Selected text Избрания текст
|
||||
Fill Запълване
|
||||
Setting: Настройка:
|
||||
3-D 3-D
|
||||
@@ -2637,3 +2638,5 @@
|
||||
Other: Друго:
|
||||
The number must be between 1 and {max}. Числото трябва да е между 1 и {max}.
|
||||
The measurement must be between {min} and {max}. Размерът трябва да е между {min} и {max}.
|
||||
Recently Used Fonts Наскоро използвани шрифтове
|
||||
All Fonts Всички шрифтове
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1242,6 +1242,7 @@
|
||||
This font has no characters to show. Toto písmo neobsahuje žádné znaky k zobrazení.
|
||||
This point forward Od tohoto místa dále
|
||||
This section Tento oddíl
|
||||
Selected text Vybraný text
|
||||
Three Tři
|
||||
Tight Těsné
|
||||
Title from the document properties Název z vlastností dokumentu
|
||||
@@ -2640,3 +2641,5 @@ preview Title Ukázka
|
||||
Other: Jiné:
|
||||
The number must be between 1 and {max}. Číslo musí být mezi 1 a {max}.
|
||||
The measurement must be between {min} and {max}. Míra musí být mezi {min} a {max}.
|
||||
Recently Used Fonts Naposledy použitá písma
|
||||
All Fonts Všechna písma
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -159,6 +159,7 @@
|
||||
Apply to: Übernehmen für:
|
||||
Whole document Gesamtes Dokument
|
||||
This section Dieser Abschnitt
|
||||
Selected text Markierter Text
|
||||
Fill Füllung
|
||||
Setting: Einstellung:
|
||||
3-D 3D
|
||||
@@ -2248,3 +2249,5 @@
|
||||
Other: Andere:
|
||||
The number must be between 1 and {max}. Die Zahl muss zwischen 1 und {max} liegen.
|
||||
The measurement must be between {min} and {max}. Das Maß muss zwischen {min} und {max} liegen.
|
||||
Recently Used Fonts Zuletzt verwendete Schriftarten
|
||||
All Fonts Alle Schriftarten
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -198,6 +198,7 @@
|
||||
Use the Insert key to switch overtype on and off Usar la tecla Insert para activar y desactivar la sobrescritura
|
||||
Whole document Todo el documento
|
||||
This section Esta sección
|
||||
Selected text Texto seleccionado
|
||||
Fill Relleno de color
|
||||
Setting: Valor:
|
||||
3-D 3D
|
||||
@@ -2615,3 +2616,5 @@ preview Title Título
|
||||
Other: Otro:
|
||||
The number must be between 1 and {max}. El número debe estar entre 1 y {max}.
|
||||
The measurement must be between {min} and {max}. La medida debe estar entre {min} y {max}.
|
||||
Recently Used Fonts Fuentes usadas recientemente
|
||||
All Fonts Todas las fuentes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1331,6 +1331,7 @@
|
||||
Apply to: Rakenda:
|
||||
Whole document Kogu dokument
|
||||
This section See jaotis
|
||||
Selected text Valitud tekst
|
||||
Fill Täide
|
||||
Setting: Säte:
|
||||
3-D Ruumiline
|
||||
@@ -2283,3 +2284,5 @@
|
||||
Other: Muu:
|
||||
The number must be between 1 and {max}. Arv peab olema vahemikus 1 kuni {max}.
|
||||
The measurement must be between {min} and {max}. Mõõt peab olema vahemikus {min} kuni {max}.
|
||||
Recently Used Fonts Viimati kasutatud fondid
|
||||
All Fonts Kõik fondid
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 841 and column 23.
|
@@ -145,6 +145,7 @@
|
||||
Apply to: Appliquer à :
|
||||
Whole document Tout le document
|
||||
This section Cette section
|
||||
Selected text Texte sélectionné
|
||||
Fill Remplissage
|
||||
Setting: Type :
|
||||
3-D 3-D
|
||||
@@ -2256,3 +2257,5 @@
|
||||
Other: Autre :
|
||||
The number must be between 1 and {max}. Le nombre doit être compris entre 1 et {max}.
|
||||
The measurement must be between {min} and {max}. La mesure doit être comprise entre {min} et {max}.
|
||||
Recently Used Fonts Polices récemment utilisées
|
||||
All Fonts Toutes les polices
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -2257,6 +2257,7 @@ template Letter Pismo
|
||||
This password type isn't supported. Ova vrsta lozinke nije podržana.
|
||||
This point forward Od ove točke nadalje
|
||||
This section Ovaj odjeljak
|
||||
Selected text Odabrani tekst
|
||||
Three Tri
|
||||
Through Kroz
|
||||
Tight Usko
|
||||
@@ -2648,3 +2649,5 @@ preview Title Naslov
|
||||
Other: Ostalo:
|
||||
The number must be between 1 and {max}. Broj mora biti između 1 i {max}.
|
||||
The measurement must be between {min} and {max}. Mjera mora biti između {min} i {max}.
|
||||
Recently Used Fonts Nedavno korišteni fontovi
|
||||
All Fonts Svi fontovi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1642,6 +1642,7 @@
|
||||
Apply to: Alkalmazás erre:
|
||||
Whole document Teljes dokumentum
|
||||
This section Ez a szakasz
|
||||
Selected text Kijelölt szöveg
|
||||
Fill Kitöltés
|
||||
Setting: Beállítás:
|
||||
3-D Térhatású
|
||||
@@ -2644,3 +2645,5 @@
|
||||
Other: Egyéb:
|
||||
The number must be between 1 and {max}. A számnak 1 és {max} között kell lennie.
|
||||
The measurement must be between {min} and {max}. A méretnek {min} és {max} között kell lennie.
|
||||
Recently Used Fonts Legutóbb használt betűtípusok
|
||||
All Fonts Összes betűtípus
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -144,6 +144,7 @@
|
||||
Apply to: Applica a:
|
||||
Whole document Tutto il documento
|
||||
This section Questa sezione
|
||||
Selected text Testo selezionato
|
||||
Fill Riempimento
|
||||
Setting: Impostazione:
|
||||
3-D 3D
|
||||
@@ -2657,3 +2658,5 @@ preview Title Esempio
|
||||
Other: Altro:
|
||||
The number must be between 1 and {max}. Il numero deve essere compreso tra 1 e {max}.
|
||||
The measurement must be between {min} and {max}. La misura deve essere compresa tra {min} e {max}.
|
||||
Recently Used Fonts Caratteri usati di recente
|
||||
All Fonts Tutti i caratteri
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -213,6 +213,7 @@
|
||||
Use the Insert key to switch overtype on and off Insert キーで上書きのオンとオフを切り替える
|
||||
Whole document 文書全体
|
||||
This section このセクション
|
||||
Selected text 選択したテキスト
|
||||
Fill 塗りつぶし
|
||||
Setting: 種類:
|
||||
3-D 立体
|
||||
@@ -2249,3 +2250,5 @@
|
||||
Other: その他:
|
||||
The number must be between 1 and {max}. 1 から {max} までの数値を指定してください。
|
||||
The measurement must be between {min} and {max}. {min} から {max} までの値を指定してください。
|
||||
Recently Used Fonts 最近使用したフォント
|
||||
All Fonts すべてのフォント
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 919 and column 23.
|
@@ -176,6 +176,7 @@
|
||||
Use the Insert key to switch overtype on and off Insert 키를 눌러 겹쳐쓰기 켜기/끄기
|
||||
Whole document 문서 전체
|
||||
This section 이 구역
|
||||
Selected text 선택한 텍스트
|
||||
Fill 채우기
|
||||
Setting: 설정:
|
||||
3-D 3차원
|
||||
@@ -2504,3 +2505,5 @@ preview Title 제목
|
||||
Other: 기타:
|
||||
The number must be between 1 and {max}. 숫자는 1에서 {max} 사이여야 합니다.
|
||||
The measurement must be between {min} and {max}. 측정값은 {min}에서 {max} 사이여야 합니다.
|
||||
Recently Used Fonts 최근 사용한 글꼴
|
||||
All Fonts 모든 글꼴
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 925 and column 23.
|
@@ -1329,6 +1329,7 @@
|
||||
This font has no characters to show. Ta czcionka nie zawiera znaków do pokazania.
|
||||
This point forward Od tego miejsca
|
||||
This section Ta sekcja
|
||||
Selected text Zaznaczony tekst
|
||||
Title from the document properties Tytuł z właściwości dokumentu
|
||||
Total Razem
|
||||
Track Changes Options Opcje śledzenia zmian
|
||||
@@ -2638,3 +2639,5 @@ preview Title Tytuł
|
||||
Other: Inny:
|
||||
The number must be between 1 and {max}. Liczba musi być z zakresu od 1 do {max}.
|
||||
The measurement must be between {min} and {max}. Miara musi być z zakresu od {min} do {max}.
|
||||
Recently Used Fonts Ostatnio używane czcionki
|
||||
All Fonts Wszystkie czcionki
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -193,6 +193,7 @@
|
||||
Use the Insert key to switch overtype on and off Usar a tecla Insert para ativar e desativar a sobrescrita
|
||||
Whole document Documento inteiro
|
||||
This section Esta seção
|
||||
Selected text Texto selecionado
|
||||
Fill Cor de fundo
|
||||
Setting: Tipo:
|
||||
3-D 3D
|
||||
@@ -2261,3 +2262,5 @@
|
||||
Other: Outro:
|
||||
The number must be between 1 and {max}. O número deve estar entre 1 e {max}.
|
||||
The measurement must be between {min} and {max}. A medida deve estar entre {min} e {max}.
|
||||
Recently Used Fonts Fontes usadas recentemente
|
||||
All Fonts Todas as fontes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -1620,6 +1620,7 @@ preview Title Пример
|
||||
Apply to: Применить к:
|
||||
Whole document Ко всему документу
|
||||
This section К этому разделу
|
||||
Selected text К выделенному тексту
|
||||
Fill Заливка
|
||||
Setting: Тип:
|
||||
3-D Объёмная
|
||||
@@ -2646,3 +2647,5 @@ preview Title Пример
|
||||
Other: Другой:
|
||||
The number must be between 1 and {max}. Число должно быть от 1 до {max}.
|
||||
The measurement must be between {min} and {max}. Размер должен быть от {min} до {max}.
|
||||
Recently Used Fonts Недавно использованные шрифты
|
||||
All Fonts Все шрифты
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -270,6 +270,7 @@
|
||||
Use the Insert key to switch overtype on and off Taster Insert uključuje i isključuje prepisivanje
|
||||
Whole document Ceo dokument
|
||||
This section Ovaj odeljak
|
||||
Selected text Izabrani tekst
|
||||
Fill Ispuna
|
||||
Setting: Vrsta:
|
||||
3-D 3D
|
||||
@@ -2259,3 +2260,5 @@
|
||||
Other: Drugo:
|
||||
The number must be between 1 and {max}. Broj mora biti između 1 i {max}.
|
||||
The measurement must be between {min} and {max}. Mera mora biti između {min} i {max}.
|
||||
Recently Used Fonts Nedavno korišćeni fontovi
|
||||
All Fonts Svi fontovi
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -274,6 +274,7 @@
|
||||
Use the Insert key to switch overtype on and off Тастер Insert укључује и искључује преписивање
|
||||
Whole document Цео документ
|
||||
This section Овај одељак
|
||||
Selected text Изабрани текст
|
||||
Fill Испуна
|
||||
Setting: Врста:
|
||||
3-D 3D
|
||||
@@ -2263,3 +2264,5 @@
|
||||
Other: Друго:
|
||||
The number must be between 1 and {max}. Број мора бити између 1 и {max}.
|
||||
The measurement must be between {min} and {max}. Мера мора бити између {min} и {max}.
|
||||
Recently Used Fonts Недавно коришћени фонтови
|
||||
All Fonts Сви фонтови
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
@@ -191,6 +191,7 @@
|
||||
Use the Insert key to switch overtype on and off Använd Insert-tangenten för att slå på och av överskrivning
|
||||
Whole document Hela dokumentet
|
||||
This section Det här avsnittet
|
||||
Selected text Markerad text
|
||||
Fill Fyllning
|
||||
Setting: Inställning:
|
||||
3-D 3D
|
||||
@@ -2663,3 +2664,5 @@ preview Title Titel
|
||||
Other: Annat:
|
||||
The number must be between 1 and {max}. Talet måste vara mellan 1 och {max}.
|
||||
The measurement must be between {min} and {max}. Måttet måste vara mellan {min} och {max}.
|
||||
Recently Used Fonts Senast använda typsnitt
|
||||
All Fonts Alla typsnitt
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -1619,6 +1619,7 @@ preview Title Örnek
|
||||
Apply to: Uygula:
|
||||
Whole document Tüm belge
|
||||
This section Bu bölüm
|
||||
Selected text Seçili metin
|
||||
Fill Dolgu
|
||||
Setting: Ayar:
|
||||
3-D 3B
|
||||
@@ -2645,3 +2646,5 @@ preview Title Örnek
|
||||
Other: Diğer:
|
||||
The number must be between 1 and {max}. Sayı 1 ile {max} arasında olmalıdır.
|
||||
The measurement must be between {min} and {max}. Ölçü {min} ile {max} arasında olmalıdır.
|
||||
Recently Used Fonts Son Kullanılan Yazı Tipleri
|
||||
All Fonts Tüm Yazı Tipleri
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 793 and column 23.
|
@@ -195,6 +195,7 @@
|
||||
Use the Insert key to switch overtype on and off Клавіша Insert вмикає та вимикає режим заміни
|
||||
Whole document Увесь документ
|
||||
This section Цей розділ
|
||||
Selected text До виділеного тексту
|
||||
Fill Колір заливки
|
||||
Setting: Тип:
|
||||
3-D Об'ємна
|
||||
@@ -2261,3 +2262,5 @@
|
||||
Other: Інше:
|
||||
The number must be between 1 and {max}. Число має бути від 1 до {max}.
|
||||
The measurement must be between {min} and {max}. Розмір має бути від {min} до {max}.
|
||||
Recently Used Fonts Нещодавно використані шрифти
|
||||
All Fonts Усі шрифти
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -141,6 +141,7 @@
|
||||
Apply to: Áp dụng cho:
|
||||
Whole document Toàn bộ tài liệu
|
||||
This section Phần này
|
||||
Selected text Văn bản đã chọn
|
||||
Fill Tô nền
|
||||
Setting: Thiết lập:
|
||||
3-D 3-D
|
||||
@@ -2688,3 +2689,5 @@ template Letter Thư
|
||||
Other: Khác:
|
||||
The number must be between 1 and {max}. Số phải nằm trong khoảng từ 1 đến {max}.
|
||||
The measurement must be between {min} and {max}. Số đo phải nằm trong khoảng từ {min} đến {max}.
|
||||
Recently Used Fonts Phông chữ dùng gần đây
|
||||
All Fonts Tất cả phông chữ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -225,6 +225,7 @@
|
||||
Use the Insert key to switch overtype on and off 用 Insert 键切换改写模式
|
||||
Whole document 整篇文档
|
||||
This section 本节
|
||||
Selected text 所选文字
|
||||
Fill 填充
|
||||
Setting: 设置:
|
||||
3-D 三维
|
||||
@@ -2261,3 +2262,5 @@
|
||||
Other: 其他字符:
|
||||
The number must be between 1 and {max}. 数字必须介于 1 和 {max} 之间。
|
||||
The measurement must be between {min} and {max}. 度量值必须介于 {min} 和 {max} 之间。
|
||||
Recently Used Fonts 最近使用的字体
|
||||
All Fonts 所有字体
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
@@ -214,6 +214,7 @@
|
||||
Use the Insert key to switch overtype on and off 使用 Insert 鍵切換取代模式
|
||||
Whole document 整份文件
|
||||
This section 本節
|
||||
Selected text 選取的文字
|
||||
Fill 填滿
|
||||
Setting: 設定:
|
||||
3-D 立體
|
||||
@@ -2250,3 +2251,5 @@
|
||||
Other: 其他:
|
||||
The number must be between 1 and {max}. 數字必須介於 1 和 {max} 之間。
|
||||
The measurement must be between {min} and {max}. 度量值必須介於 {min} 和 {max} 之間。
|
||||
Recently Used Fonts 最近使用的字型
|
||||
All Fonts 所有字型
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 920 and column 23.
|
+223
-1
@@ -80,6 +80,7 @@ pub mod table_resize;
|
||||
pub mod table_ruler;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
pub mod window_close;
|
||||
pub mod window_geometry;
|
||||
pub mod wordart_menus;
|
||||
pub mod zotero;
|
||||
@@ -241,6 +242,10 @@ pub struct UiState {
|
||||
pub recent_symbols: Vec<dialogs_insert::RecentSymbol>,
|
||||
/// Colours picked in the Colors dialog (More Colors…), most recent first (#606).
|
||||
pub recent_colors: Vec<String>,
|
||||
/// Fonts applied from the font box, the mini toolbar or the Font dialog, most recent first:
|
||||
/// the Recently Used Fonts section at the top of the font list (#2068).
|
||||
#[serde(deserialize_with = "lenient_recent_fonts")]
|
||||
pub recent_fonts: Vec<String>,
|
||||
/// Interface theme: light, dark, or follow the OS appearance (#115).
|
||||
pub theme: theme::Appearance,
|
||||
/// Interface size (#475, #822): Auto (the display scale the system reports) or an absolute
|
||||
@@ -338,6 +343,10 @@ pub const DEFAULT_TAB: &str = "Home";
|
||||
pub const MAX_RECENT: usize = 50;
|
||||
/// Most custom dictionary words read back from `ui.json`.
|
||||
pub const MAX_DICTIONARY: usize = 10_000;
|
||||
/// Fonts kept under Recently Used Fonts, about as many as Word shows.
|
||||
pub const MAX_RECENT_FONTS: usize = 10;
|
||||
/// The longest family name Recently Used Fonts keeps, the longest `format.font` accepts.
|
||||
pub const MAX_FONT_NAME_BYTES: usize = 128;
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
@@ -350,6 +359,7 @@ impl Default for UiState {
|
||||
recent: Vec::new(),
|
||||
recent_symbols: Vec::new(),
|
||||
recent_colors: Vec::new(),
|
||||
recent_fonts: Vec::new(),
|
||||
theme: theme::Appearance::default(),
|
||||
interface_size: theme::InterfaceSize::default(),
|
||||
legacy_interface_size: None,
|
||||
@@ -407,6 +417,62 @@ impl UiState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `name` first in the Recently Used Fonts list, dropping an earlier entry for the same
|
||||
/// family (ignoring case); the list keeps [`MAX_RECENT_FONTS`] names. A blank name, or one longer
|
||||
/// than [`MAX_FONT_NAME_BYTES`], is not added.
|
||||
pub fn push_recent_font(list: &mut Vec<String>, name: &str) {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.len() > MAX_FONT_NAME_BYTES {
|
||||
return;
|
||||
}
|
||||
let key = name.to_lowercase();
|
||||
list.retain(|f| f.to_lowercase() != key);
|
||||
list.insert(0, name.to_string());
|
||||
list.truncate(MAX_RECENT_FONTS);
|
||||
}
|
||||
|
||||
/// A Recently Used Fonts list from saved `names`: trimmed, without blanks, names longer than
|
||||
/// [`MAX_FONT_NAME_BYTES`] or repeats (ignoring case); the first [`MAX_RECENT_FONTS`] that remain.
|
||||
pub fn recent_fonts_from<'a>(names: impl IntoIterator<Item = &'a str>) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for f in names {
|
||||
if out.len() >= MAX_RECENT_FONTS {
|
||||
break;
|
||||
}
|
||||
let f = f.trim();
|
||||
if f.is_empty() || f.len() > MAX_FONT_NAME_BYTES {
|
||||
continue;
|
||||
}
|
||||
let key = f.to_lowercase();
|
||||
if !keys.contains(&key) {
|
||||
keys.push(key);
|
||||
out.push(f.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `recentFonts` as saved, through [`recent_fonts_from`]: an entry that isn't a name is dropped,
|
||||
/// not the whole list.
|
||||
fn lenient_recent_fonts<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
Ok(v.as_array().map(|a| recent_fonts_from(a.iter().filter_map(Value::as_str))).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The family a command applies: `format.font {"name"}` (the font box, the mini toolbar, command
|
||||
/// search) or `format.set {"props": {"font"}}` (the Font dialog). It joins Recently Used Fonts
|
||||
/// once the command succeeds.
|
||||
fn applied_font(id: &str, params: &Value) -> Option<String> {
|
||||
let name = match id {
|
||||
"format.font" => params.get("name"),
|
||||
"format.set" => params.get("props").and_then(|p| p.get("font")),
|
||||
_ => None,
|
||||
}?;
|
||||
let name = name.as_str()?.trim();
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// Settings saved before #838 kept the measurement unit as `editing.units`, which defaulted to
|
||||
/// inches; it now lives in `editing.measurementUnits`, and no value there follows the region. A
|
||||
/// unit other than inches under the old key was the user's own choice, so it carries over (an
|
||||
@@ -700,6 +766,8 @@ impl WordApp {
|
||||
// Word opens on Home every launch, so the tab `ui.json` holds is not restored. Going File
|
||||
// and back to the editor within the run still returns to the tab that was showing.
|
||||
self.ui.tab = DEFAULT_TAB.into();
|
||||
let recent_fonts = recent_fonts_from(self.ui.recent_fonts.iter().map(String::as_str));
|
||||
self.ui.recent_fonts = recent_fonts;
|
||||
// A `ui.json` from before the theme setting: its dark-mode switch picks Dark or Light.
|
||||
if let Some(dark) = self.ui.legacy_dark.take() {
|
||||
self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light };
|
||||
@@ -796,7 +864,11 @@ impl WordApp {
|
||||
{
|
||||
return r;
|
||||
}
|
||||
let applied = applied_font(id, ¶ms);
|
||||
let r = self.execute_user(id, params);
|
||||
if let (Ok(_), Some(name)) = (&r, applied) {
|
||||
push_recent_font(&mut self.ui.recent_fonts, &name);
|
||||
}
|
||||
if matches!(id, "insert.picture" | "insert.icon") {
|
||||
self.select_inserted_picture(&r);
|
||||
}
|
||||
@@ -1194,7 +1266,7 @@ impl WordApp {
|
||||
// Drawing leaves the caret where it was: following it would pull the view off the ink.
|
||||
if self.session.selects_whole_story() || id == "select.all" {
|
||||
self.canvas.scroll_to_caret = false;
|
||||
} else if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") && !id.starts_with("draw.") {
|
||||
} else if !["ui.", "view.", "document.", "format.state", "draw."].iter().any(|p| id.starts_with(p)) {
|
||||
self.canvas.scroll_to_caret = true;
|
||||
}
|
||||
for req in std::mem::take(&mut self.session.ui_requests) {
|
||||
@@ -1225,6 +1297,7 @@ impl WordApp {
|
||||
// Ctrl+F: type straight into the Navigation pane's search box (#712).
|
||||
if req.get("focus").and_then(Value::as_str) == Some("navSearch") {
|
||||
self.nav_search_focus = true;
|
||||
self.ui.nav_tab = "results".into();
|
||||
}
|
||||
if let Some(what) = req.get("open").and_then(Value::as_str) {
|
||||
match what {
|
||||
@@ -4893,6 +4966,132 @@ mod tests {
|
||||
assert_eq!(body_text(h.state()), before, "typing went into the font size box, not the page");
|
||||
}
|
||||
|
||||
/// #2068: fonts applied from the font box or the mini toolbar (`format.font`) and from the
|
||||
/// Font dialog (`format.set`) join Recently Used Fonts, most recent first, once each, ten at
|
||||
/// most; the list is kept in `ui.json` and read back tidied.
|
||||
#[test]
|
||||
fn applied_fonts_join_recently_used_fonts() {
|
||||
let mut a = typed();
|
||||
assert!(a.ui.recent_fonts.is_empty());
|
||||
a.run("format.font", json!({"name": "Courier New"})).unwrap();
|
||||
a.run("format.font", json!({"name": "Georgia"})).unwrap();
|
||||
assert_eq!(a.ui.recent_fonts, ["Georgia", "Courier New"]);
|
||||
// Applied again: to the front, once, as typed.
|
||||
a.run("format.font", json!({"name": "courier new"})).unwrap();
|
||||
assert_eq!(a.ui.recent_fonts, ["courier new", "Georgia"]);
|
||||
// The Font dialog applies through format.set.
|
||||
a.run("format.set", json!({"props": {"font": "Verdana", "bold": true}})).unwrap();
|
||||
assert_eq!(a.ui.recent_fonts, ["Verdana", "courier new", "Georgia"]);
|
||||
// A refused command, or one applying no Latin font, adds nothing.
|
||||
assert!(a.run("format.font", json!({"name": " "})).is_err());
|
||||
a.run("format.font", json!({"eastAsia": "SimSun"})).unwrap();
|
||||
a.run("format.set", json!({"props": {"italic": true}})).unwrap();
|
||||
assert_eq!(a.ui.recent_fonts.len(), 3);
|
||||
for i in 0..12 {
|
||||
a.run("format.font", json!({"name": format!("Font {i}")})).unwrap();
|
||||
}
|
||||
assert_eq!(a.ui.recent_fonts.len(), MAX_RECENT_FONTS);
|
||||
assert_eq!(a.ui.recent_fonts.first().map(String::as_str), Some("Font 11"));
|
||||
assert_eq!(a.ui.recent_fonts.last().map(String::as_str), Some("Font 2"));
|
||||
// Kept between runs.
|
||||
let saved = serde_json::to_string(&a.prefs()).unwrap();
|
||||
assert!(saved.contains(r#""recentFonts":["Font 11","Font 10""#), "{saved}");
|
||||
let mut b = app();
|
||||
b.apply_prefs(UiState::from_json(saved.as_bytes()).unwrap());
|
||||
assert_eq!(b.ui.recent_fonts, a.ui.recent_fonts);
|
||||
// A ui.json from before the list has nothing recent; junk in the list is dropped and the
|
||||
// rest kept once each, trimmed, ten at most; a value of the wrong type keeps the default.
|
||||
assert!(UiState::from_json(br#"{"tab": "Insert"}"#).unwrap().recent_fonts.is_empty());
|
||||
let mut list: Vec<String> = ["", " Arial ", "ARIAL", "Georgia"].map(String::from).to_vec();
|
||||
list.extend((0..12).map(|i| format!("F{i}")));
|
||||
let raw = json!({ "recentFonts": list });
|
||||
let tidy = UiState::from_json(raw.to_string().as_bytes()).unwrap().recent_fonts;
|
||||
assert_eq!(tidy.len(), MAX_RECENT_FONTS);
|
||||
assert_eq!(&tidy[..3], ["Arial", "Georgia", "F0"]);
|
||||
assert!(UiState::from_json(br#"{"recentFonts": "Arial"}"#).unwrap().recent_fonts.is_empty());
|
||||
// One entry that isn't a name drops that entry, not the list.
|
||||
let mixed = UiState::from_json(br#"{"recentFonts": ["Arial", 3, null, {"a": 1}, "Georgia"]}"#).unwrap();
|
||||
assert_eq!(mixed.recent_fonts, ["Arial", "Georgia"]);
|
||||
}
|
||||
|
||||
/// #2068 review: a hand-edited `ui.json` with a huge list or huge names read back slowly and
|
||||
/// kept a multi-megabyte name, and the Font dialog applies names of any length; neither is
|
||||
/// kept. Reading stops at ten names.
|
||||
#[test]
|
||||
fn recently_used_fonts_refuse_overlong_names_and_huge_lists() {
|
||||
let long = "N".repeat(MAX_FONT_NAME_BYTES + 1);
|
||||
let mut a = typed();
|
||||
a.run("format.font", json!({"name": "Georgia"})).unwrap();
|
||||
a.run("format.set", json!({"props": {"font": long}})).unwrap();
|
||||
assert_eq!(a.ui.recent_fonts, ["Georgia"], "the dialog's overlong name is applied but not kept");
|
||||
let mut list = vec!["Arial".to_string()];
|
||||
push_recent_font(&mut list, &long);
|
||||
push_recent_font(&mut list, &"M".repeat(MAX_FONT_NAME_BYTES));
|
||||
assert_eq!(list.len(), 2, "a name of the longest length is kept");
|
||||
// A huge saved list: overlong names skipped, the first ten others kept, quickly.
|
||||
let mut saved: Vec<String> = vec![long.clone(), "x".repeat(4 << 20)];
|
||||
saved.extend((0..200_000).map(|i| format!("Family {i}")));
|
||||
let raw = json!({ "recentFonts": saved }).to_string();
|
||||
let started = std::time::Instant::now();
|
||||
let ui = UiState::from_json(raw.as_bytes()).unwrap();
|
||||
assert_eq!(ui.recent_fonts.len(), MAX_RECENT_FONTS);
|
||||
assert_eq!(ui.recent_fonts.first().map(String::as_str), Some("Family 0"));
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20), "read in {:?}", started.elapsed());
|
||||
let mut b = app();
|
||||
b.apply_prefs(UiState { recent_fonts: vec![long, " Arial ".into(), "ARIAL".into()], ..UiState::default() });
|
||||
assert_eq!(b.ui.recent_fonts, ["Arial"]);
|
||||
}
|
||||
|
||||
/// #2068: the font box lists the fonts applied lately under Recently Used Fonts, above All
|
||||
/// Fonts, while nothing is typed; a query lists its matches only; Down and Enter apply a
|
||||
/// recent one.
|
||||
#[test]
|
||||
fn the_font_box_lists_recently_used_fonts_first() {
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::{By, Queryable};
|
||||
let mut a = typed();
|
||||
a.run("format.font", json!({"name": "Recent Font Alpha"})).unwrap();
|
||||
a.run("format.font", json!({"name": "Recent Font Beta"})).unwrap();
|
||||
let mut h = harness(a);
|
||||
h.run_steps(6);
|
||||
h.get(By::new().role(Role::TextInput).value("Recent Font Beta")).focus();
|
||||
h.step();
|
||||
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
h.event(key(egui::Key::A, egui::Modifiers::COMMAND));
|
||||
h.step();
|
||||
h.event(egui::Event::Text("x".into()));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Recently Used Fonts").is_none(), "a query lists its matches only");
|
||||
assert_eq!(h.query_all_by_label("Recent Font Alpha").count(), 0);
|
||||
h.event(key(egui::Key::Backspace, egui::Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Recently Used Fonts").is_some(), "the section shows while nothing is typed");
|
||||
assert!(h.query_by_label("All Fonts").is_some());
|
||||
assert_eq!(h.query_all_by_label("Recent Font Beta").count(), 1);
|
||||
assert_eq!(h.query_all_by_label("Recent Font Alpha").count(), 1);
|
||||
// Down moves to the second recent font; Enter applies it.
|
||||
h.event(key(egui::Key::ArrowDown, egui::Modifiers::NONE));
|
||||
h.step();
|
||||
h.event(key(egui::Key::Enter, egui::Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
let st = h.state_mut().session.run("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st.get("font").and_then(Value::as_str), Some("Recent Font Alpha"));
|
||||
assert_eq!(h.state().ui.recent_fonts, ["Recent Font Alpha", "Recent Font Beta"]);
|
||||
// The arrow's list has the sections too; a click on a heading leaves it open.
|
||||
let field = h.get(By::new().role(Role::TextInput).value("Recent Font Alpha")).rect();
|
||||
let arrow = egui::pos2(field.max.x + 8.0, field.center().y);
|
||||
for pressed in [true, false] {
|
||||
h.hover_at(arrow);
|
||||
h.event(egui::Event::PointerButton { pos: arrow, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert!(egui::Popup::is_any_open(&h.ctx), "the arrow opened the list");
|
||||
h.get_by_label("All Fonts").click();
|
||||
h.run_steps(3);
|
||||
assert!(egui::Popup::is_any_open(&h.ctx), "a click on a heading keeps the list open");
|
||||
assert!(h.query_by_label("Recently Used Fonts").is_some());
|
||||
}
|
||||
|
||||
/// #330: Word's shortcuts work from the keyboard — Ctrl+Alt+1 (⌘⌥1) applies Heading 1, and
|
||||
/// an Alt shortcut doesn't also type the character its key would produce.
|
||||
#[test]
|
||||
@@ -6727,6 +6926,17 @@ mod tests {
|
||||
assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view");
|
||||
}
|
||||
|
||||
/// #2054: clicking a ribbon tab while a footer is being edited scrolled the page a little.
|
||||
#[test]
|
||||
fn ribbon_tab_click_does_not_follow_the_caret() {
|
||||
let mut a = typed();
|
||||
a.canvas.scroll_to_caret = false;
|
||||
a.run("ui.tab", json!({"tab": "Design"})).unwrap();
|
||||
assert!(!a.canvas.scroll_to_caret, "switching tabs left the view alone");
|
||||
a.run("text.insert", json!({"text": "x"})).unwrap();
|
||||
assert!(a.canvas.scroll_to_caret, "typing still brings the caret into view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_does_not_scroll_to_the_end() {
|
||||
let mut a = typed();
|
||||
@@ -6854,6 +7064,7 @@ mod tests {
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.state().session.view.nav_pane, "the Navigation pane opened");
|
||||
assert_eq!(h.state().ui.nav_tab, "results", "Find exposes its results (#2096)");
|
||||
assert!(h.state().dialog.is_none(), "no dialog: {:?}", h.state().dialog.as_ref().map(|d| d.name()));
|
||||
h.event(key(egui::Key::C, egui::Modifiers::NONE));
|
||||
h.event(egui::Event::Text("cool".into()));
|
||||
@@ -6862,7 +7073,17 @@ mod tests {
|
||||
assert_eq!(nav_query(&h), "cool", "typed into the pane's search box");
|
||||
assert_eq!(h.state().session.find.results.len(), 1);
|
||||
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns fire clay. Kilns cool.");
|
||||
// Manual navigation remains available until the next search edit.
|
||||
h.state_mut().ui.nav_tab = "headings".into();
|
||||
h.step();
|
||||
assert_eq!(h.state().ui.nav_tab, "headings");
|
||||
h.event(egui::Event::Text("missing".into()));
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.state().ui.nav_tab, "results", "editing the query reveals even an empty result list");
|
||||
assert!(h.state().session.find.results.is_empty());
|
||||
// With text selected, Ctrl+F searches for it.
|
||||
h.state_mut().ui.nav_tab = "pages".into();
|
||||
h.state_mut().run("select.text", json!({"text": "Kilns"})).unwrap();
|
||||
h.state_mut().canvas.want_focus = true;
|
||||
h.step();
|
||||
@@ -6870,6 +7091,7 @@ mod tests {
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(nav_query(&h), "Kilns");
|
||||
assert_eq!(h.state().ui.nav_tab, "results", "Find leaves Pages to show the selected-text matches");
|
||||
assert_eq!(h.state().session.find.results.len(), 2);
|
||||
// Ctrl+H: the Replace tab; Go To: the Go To tab of the same dialog.
|
||||
h.state_mut().canvas.want_focus = true;
|
||||
|
||||
@@ -215,8 +215,9 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let font = app.previews.font_shown(&font);
|
||||
let fams = app.previews.families();
|
||||
let recent: Vec<String> = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect();
|
||||
let prev = app.previews.font_preview_fn();
|
||||
if let Some(f) = widgets::font_combo(ui, "mini_font", COMBO_FONT_W, &font, &fams, Some(&*prev)) {
|
||||
if let Some(f) = widgets::font_combo_with_recent(ui, "mini_font", COMBO_FONT_W, &font, &fams, &recent, Some(&*prev)) {
|
||||
let _ = if f == crate::widgets::ADD_FONT {
|
||||
app.run("font.add", json!({}))
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
|
||||
@@ -127,6 +127,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
|
||||
out.state.store(ui.ctx(), r.id);
|
||||
}
|
||||
if r.changed() || picked.is_some() {
|
||||
app.ui.nav_tab = "results".into();
|
||||
ui.data_mut(|d| d.insert_temp(qid, q.clone()));
|
||||
if !q.is_empty() {
|
||||
let _ = app.session.run("edit.find", &json!({"text": q}));
|
||||
|
||||
@@ -228,6 +228,7 @@ impl Previews {
|
||||
let key = format!("font:{name}");
|
||||
let tex: Option<TextureHandle> = ui.ctx().data(|d| d.get_temp::<TextureHandle>(egui::Id::new(&key)));
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(260.0, crate::widgets::COMBO_PREVIEW_ROW_H), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), name));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
|
||||
@@ -57,84 +57,82 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let state = app.session.read_aloud.state();
|
||||
let (index, count) = app.session.read_aloud.progress();
|
||||
let mut open = true;
|
||||
let mut run: Option<(&str, serde_json::Value)> = None;
|
||||
let right_top = app.canvas.canvas_rect.map_or(ctx.content_rect().right_top() + vec2(-24.0, 160.0), |r| r.right_top() + vec2(-24.0, 16.0));
|
||||
egui::Window::new(tl!("Read Aloud"))
|
||||
let window = egui::Window::new(tl!("Read Aloud"))
|
||||
.id(egui::Id::new("read-aloud-player"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::RIGHT_TOP)
|
||||
.default_pos(right_top)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(236.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
if button(ui, &t, "mediaPrev", tl!("Previous sentence"), count > 0 && index > 0) {
|
||||
run = Some(("readAloud.previous", json!({})));
|
||||
}
|
||||
let (icon, tip) = if state == State::Playing { ("mediaPause", tl!("Pause")) } else { ("mediaPlay", tl!("Play")) };
|
||||
if button(ui, &t, icon, tip, true) {
|
||||
run = Some(("readAloud.playPause", json!({})));
|
||||
}
|
||||
if button(ui, &t, "mediaNext", tl!("Next sentence"), index + 1 < count) {
|
||||
run = Some(("readAloud.next", json!({})));
|
||||
}
|
||||
if button(ui, &t, "mediaStop", tl!("Stop"), true) {
|
||||
run = Some(("readAloud.stop", json!({})));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let what = match state {
|
||||
State::Playing => format!("{} {} / {}", tl!("Sentence"), (index + 1).min(count), count),
|
||||
State::Paused => tl!("Paused").to_string(),
|
||||
State::Stopped if count > 0 && index >= count => tl!("Finished").to_string(),
|
||||
State::Stopped => tl!("Stopped").to_string(),
|
||||
};
|
||||
ui.label(egui::RichText::new(what).color(t.text_dim).small());
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Speed: applied when the slider is let go, so dragging doesn't restart the sentence
|
||||
// on every frame.
|
||||
let id = egui::Id::new("read-aloud-rate");
|
||||
let mut rate = ui.data(|d| d.get_temp::<f32>(id)).unwrap_or_else(|| app.session.read_aloud.rate());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Speed"));
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
let r = ui.add(
|
||||
egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE)
|
||||
.step_by(0.05)
|
||||
.trailing_fill(true)
|
||||
.custom_formatter(|v, _| format!("{v:.2}×"))
|
||||
.custom_parser(|s| s.trim().trim_end_matches(['×', 'x']).trim().parse().ok()),
|
||||
);
|
||||
if r.dragged() {
|
||||
ui.data_mut(|d| d.insert_temp(id, rate));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<f32>(id));
|
||||
if r.changed() || r.drag_stopped() {
|
||||
run = Some(("readAloud.speed", json!({"value": rate})));
|
||||
}
|
||||
}
|
||||
if ui.small_button("1×").on_hover_text(tl!("Normal speed")).clicked() {
|
||||
run = Some(("readAloud.speed", json!({"value": 1.0})));
|
||||
}
|
||||
});
|
||||
let mut skip = app.session.read_aloud.skip_citations;
|
||||
if ui
|
||||
.checkbox(&mut skip, tl!("Skip citations & bibliography"))
|
||||
.on_hover_text(tl!("Don't read Zotero, Mendeley or EndNote citations and bibliographies"))
|
||||
.changed()
|
||||
{
|
||||
run = Some(("readAloud.skipCitations", json!({"value": skip})));
|
||||
.default_pos(right_top);
|
||||
let shown = crate::window_close::window(ctx, window).show(ctx, |ui| {
|
||||
ui.set_width(236.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
if button(ui, &t, "mediaPrev", tl!("Previous sentence"), count > 0 && index > 0) {
|
||||
run = Some(("readAloud.previous", json!({})));
|
||||
}
|
||||
// The error `poll` saw this frame, not the worker's latest: the player thread can set
|
||||
// one between the two, and the window would show a message the status bar hasn't.
|
||||
if let Some(e) = app.read_aloud_error.as_ref().filter(|e| app.session.read_aloud.error().as_ref() == Some(*e)) {
|
||||
ui.label(egui::RichText::new(e.as_str()).color(t.red).small());
|
||||
let (icon, tip) = if state == State::Playing { ("mediaPause", tl!("Pause")) } else { ("mediaPlay", tl!("Play")) };
|
||||
if button(ui, &t, icon, tip, true) {
|
||||
run = Some(("readAloud.playPause", json!({})));
|
||||
}
|
||||
if button(ui, &t, "mediaNext", tl!("Next sentence"), index + 1 < count) {
|
||||
run = Some(("readAloud.next", json!({})));
|
||||
}
|
||||
if button(ui, &t, "mediaStop", tl!("Stop"), true) {
|
||||
run = Some(("readAloud.stop", json!({})));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let what = match state {
|
||||
State::Playing => format!("{} {} / {}", tl!("Sentence"), (index + 1).min(count), count),
|
||||
State::Paused => tl!("Paused").to_string(),
|
||||
State::Stopped if count > 0 && index >= count => tl!("Finished").to_string(),
|
||||
State::Stopped => tl!("Stopped").to_string(),
|
||||
};
|
||||
ui.label(egui::RichText::new(what).color(t.text_dim).small());
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
// Speed: applied when the slider is let go, so dragging doesn't restart the sentence
|
||||
// on every frame.
|
||||
let id = egui::Id::new("read-aloud-rate");
|
||||
let mut rate = ui.data(|d| d.get_temp::<f32>(id)).unwrap_or_else(|| app.session.read_aloud.rate());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Speed"));
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
let r = ui.add(
|
||||
egui::Slider::new(&mut rate, MIN_RATE..=MAX_RATE)
|
||||
.step_by(0.05)
|
||||
.trailing_fill(true)
|
||||
.custom_formatter(|v, _| format!("{v:.2}×"))
|
||||
.custom_parser(|s| s.trim().trim_end_matches(['×', 'x']).trim().parse().ok()),
|
||||
);
|
||||
if r.dragged() {
|
||||
ui.data_mut(|d| d.insert_temp(id, rate));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<f32>(id));
|
||||
if r.changed() || r.drag_stopped() {
|
||||
run = Some(("readAloud.speed", json!({"value": rate})));
|
||||
}
|
||||
}
|
||||
if ui.small_button("1×").on_hover_text(tl!("Normal speed")).clicked() {
|
||||
run = Some(("readAloud.speed", json!({"value": 1.0})));
|
||||
}
|
||||
});
|
||||
if !open {
|
||||
let mut skip = app.session.read_aloud.skip_citations;
|
||||
if ui
|
||||
.checkbox(&mut skip, tl!("Skip citations & bibliography"))
|
||||
.on_hover_text(tl!("Don't read Zotero, Mendeley or EndNote citations and bibliographies"))
|
||||
.changed()
|
||||
{
|
||||
run = Some(("readAloud.skipCitations", json!({"value": skip})));
|
||||
}
|
||||
// The error `poll` saw this frame, not the worker's latest: the player thread can set
|
||||
// one between the two, and the window would show a message the status bar hasn't.
|
||||
if let Some(e) = app.read_aloud_error.as_ref().filter(|e| app.session.read_aloud.error().as_ref() == Some(*e)) {
|
||||
ui.label(egui::RichText::new(e.as_str()).color(t.red).small());
|
||||
}
|
||||
});
|
||||
if shown.is_some_and(|s| crate::window_close::show(ctx, &s.response, &t)) {
|
||||
run = Some(("readAloud.stop", json!({})));
|
||||
}
|
||||
if let Some((id, params)) = run {
|
||||
|
||||
+244
-31
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
|
||||
use crate::colors::{Remember, Target};
|
||||
use crate::options::MeasureExt;
|
||||
use crate::theme::{Tokens, medium, regular, semibold};
|
||||
use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, combo, font_combo, group, menu_button, small, split};
|
||||
use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, combo, font_combo_with_recent, group, menu_button, small, split};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
pub const TABS: [&str; 13] = wordcraft_engine::ribbon::TABS;
|
||||
@@ -159,6 +159,88 @@ pub fn fit_tabs(tabs: &[(f32, bool)], avail: f32, more: f32) -> Vec<bool> {
|
||||
fits
|
||||
}
|
||||
|
||||
/// One tab as the strip planner sees it (#2026).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TabSpec {
|
||||
/// Width of the label at full size.
|
||||
pub text_w: f32,
|
||||
/// File: always on the strip.
|
||||
pub file: bool,
|
||||
/// The tab being shown.
|
||||
pub active: bool,
|
||||
/// A contextual tab (Table Design, Picture Format…).
|
||||
pub contextual: bool,
|
||||
}
|
||||
|
||||
/// How the tab strip is laid out (#2026).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StripPlan {
|
||||
/// Padding each side of a tab label.
|
||||
pub pad: f32,
|
||||
/// How much of Comments / Editing / Share is spelled out: 0 all, 1 Share only, 2 none.
|
||||
pub buttons: usize,
|
||||
/// Whether each tab is on the strip (the others are in the » menu).
|
||||
pub fits: Vec<bool>,
|
||||
/// Width each tab takes on the strip (a label may be cut with …).
|
||||
pub widths: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Smallest a tab label is cut down to before the tab goes into the » menu.
|
||||
const MIN_TAB_W: f32 = 44.0;
|
||||
|
||||
/// Lays the tabs and the buttons on their right out on `avail` points, giving way in this order:
|
||||
/// the padding between tabs, the Comments / Editing labels, the Share label, the plain tabs (from
|
||||
/// the end, into »), the labels of the active and contextual tabs (cut with …), and last those tabs
|
||||
/// themselves. The buttons' room is always kept and File never leaves the strip. `buttons` is the
|
||||
/// width of the three buttons at each level (pure, tested).
|
||||
pub fn plan_strip(tabs: &[TabSpec], buttons: [f32; 3], avail: f32, more: f32) -> StripPlan {
|
||||
const STEPS: [(f32, usize); 4] = [(18.0, 0), (10.0, 0), (10.0, 1), (10.0, 2)];
|
||||
let widths_at = |pad: f32| -> Vec<f32> { tabs.iter().map(|t| t.text_w.max(0.0) + pad + 2.0).collect() };
|
||||
for (pad, level) in STEPS {
|
||||
let widths = widths_at(pad);
|
||||
if widths.iter().sum::<f32>() <= avail - buttons[level] {
|
||||
return StripPlan { pad, buttons: level, fits: vec![true; tabs.len()], widths };
|
||||
}
|
||||
}
|
||||
let (pad, level) = (10.0, 2);
|
||||
let mut widths = widths_at(pad);
|
||||
let room = avail - buttons[level];
|
||||
let specs: Vec<(f32, bool)> = widths.iter().zip(tabs).map(|(w, t)| (*w, t.file || t.active || t.contextual)).collect();
|
||||
let mut fits = fit_tabs(&specs, room, more);
|
||||
// The tabs that must stay share what the plain ones left; each is cut down to its share.
|
||||
let kept = |fits: &[bool], i: usize| fits.get(i).copied().unwrap_or(false) && tabs.get(i).is_some_and(|t| !t.file);
|
||||
loop {
|
||||
let n = (0..tabs.len()).filter(|&i| kept(&fits, i)).count();
|
||||
let others: f32 =
|
||||
(0..tabs.len()).filter(|&i| fits.get(i) == Some(&true) && !kept(&fits, i)).map(|i| widths.get(i).copied().unwrap_or(0.0)).sum();
|
||||
let own: f32 = (0..tabs.len()).filter(|&i| kept(&fits, i)).map(|i| widths.get(i).copied().unwrap_or(0.0)).sum();
|
||||
let extra = if fits.iter().all(|f| *f) { 0.0 } else { more };
|
||||
let share_room = room - extra - others;
|
||||
if n == 0 || own <= share_room {
|
||||
break;
|
||||
}
|
||||
if share_room >= n as f32 * MIN_TAB_W {
|
||||
let share = share_room / n as f32;
|
||||
for (i, w) in widths.iter_mut().enumerate() {
|
||||
if kept(&fits, i) {
|
||||
*w = w.min(share);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Not even cut down: the last of them (the active tab last of all) goes into ».
|
||||
let last = (0..tabs.len())
|
||||
.rev()
|
||||
.find(|&i| kept(&fits, i) && tabs.get(i).is_some_and(|t| !t.active))
|
||||
.or_else(|| (0..tabs.len()).rev().find(|&i| kept(&fits, i)));
|
||||
match last.and_then(|i| fits.get_mut(i)) {
|
||||
Some(f) => *f = false,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
StripPlan { pad, buttons: level, fits, widths }
|
||||
}
|
||||
|
||||
/// Whether the caret is inside a table (so the contextual tabs and their badges are shown).
|
||||
pub fn in_table_public(app: &WordApp) -> bool {
|
||||
app.session.sel.focus.path.cell().is_some()
|
||||
@@ -220,32 +302,65 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// Share, Editing mode and Comments keep their room; the tabs get what is left of it
|
||||
// (#897, #2026), and the buttons give up their labels before a tab is drawn over them.
|
||||
let share = tl!("Share");
|
||||
let editing_label = if app.session.doc.settings.track_changes { tl!("Reviewing") } else { tl!("Editing") };
|
||||
let comments = tl!("Comments");
|
||||
let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x);
|
||||
let spaced = |w: f32| w + 2.0;
|
||||
let button_widths = |level: usize| -> f32 {
|
||||
let labels = level == 0;
|
||||
let share_full = (share_w + 36.0).max(74.0);
|
||||
let share_button = if level < 2 { share_full } else { 30.0 };
|
||||
let editing = crate::widgets::icon_text_button_width(ui, editing_label, regular(12.0), true, labels);
|
||||
let comment = crate::widgets::icon_text_button_width(ui, comments, regular(12.0), false, labels);
|
||||
// The gaps (6 + 4 + 8) and the strip's margin.
|
||||
spaced(share_button) + spaced(editing) + spaced(comment) + 18.0 + 12.0
|
||||
};
|
||||
let button_levels = [button_widths(0), button_widths(1), button_widths(2)];
|
||||
let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect();
|
||||
let specs: Vec<TabSpec> = tabs
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|((tab, custom), shown)| TabSpec {
|
||||
text_w: ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x),
|
||||
file: tab == "File",
|
||||
active: *tab == app.ui.tab,
|
||||
contextual: !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str()),
|
||||
})
|
||||
.collect();
|
||||
let plan = plan_strip(&specs, button_levels, ui.available_width(), MORE_TABS_W + 2.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// No document open (#1129): nothing to share, edit or comment on.
|
||||
if !app.has_document() {
|
||||
ui.disable();
|
||||
}
|
||||
// Share, Editing mode, Comments, drawn first so the tabs get only the room
|
||||
// left of them (#897).
|
||||
let share = tl!("Share");
|
||||
let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2((share_w + 36.0).max(74.0), 24.0), Sense::click());
|
||||
let (share_size, share_text) = if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
let icon_x = if share_text { r.min.x + 14.0 } else { r.center().x };
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)),
|
||||
Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)),
|
||||
"share",
|
||||
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), t.on_accent);
|
||||
if share_text {
|
||||
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, share));
|
||||
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"}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let track = app.session.doc.settings.track_changes;
|
||||
let mode =
|
||||
crate::widgets::icon_text_menu_button(ui, "pencil", if track { tl!("Reviewing") } else { tl!("Editing") }, regular(12.0));
|
||||
let mode = if plan.buttons == 0 {
|
||||
crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0))
|
||||
} else {
|
||||
crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true)
|
||||
};
|
||||
egui::Popup::menu(&mode).show(|ui| {
|
||||
if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": false}));
|
||||
@@ -257,30 +372,24 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if crate::widgets::icon_text_button(ui, "comment", tl!("Comments"), regular(12.0)).clicked() {
|
||||
let comments_button = if plan.buttons == 0 {
|
||||
crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0))
|
||||
} else {
|
||||
crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false)
|
||||
};
|
||||
if comments_button.clicked() {
|
||||
let _ = app.run("view.commentsPane", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
// The tabs that don't fit go into a » menu at the end of the strip. File, the
|
||||
// active tab and the contextual tabs (appended last) stay on the strip (#897).
|
||||
let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect();
|
||||
let widths: Vec<(f32, bool)> = tabs
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.map(|((tab, custom), shown)| {
|
||||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||||
let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str());
|
||||
(w + 2.0, tab == "File" || *tab == app.ui.tab || contextual)
|
||||
})
|
||||
.collect();
|
||||
let fits = fit_tabs(&widths, ui.available_width(), MORE_TABS_W + 2.0);
|
||||
for ((tab, custom), _) in tabs.iter().zip(&fits).filter(|(_, fits)| **fits) {
|
||||
// The tabs that don't fit go into a » menu at the end of the strip (#897, #2026).
|
||||
for (i, ((tab, custom), _)) in tabs.iter().zip(&plan.fits).enumerate().filter(|(_, (_, fits))| **fits) {
|
||||
let (tab, custom) = (tab.as_str(), *custom);
|
||||
let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab);
|
||||
// A custom tab's name is the user's own text: shown as typed.
|
||||
let shown = if custom { tab } else { tl!(tab) };
|
||||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||||
let w = plan.widths.get(i).copied().unwrap_or(0.0) - 2.0;
|
||||
let cut = specs.get(i).is_some_and(|sp| sp.text_w + plan.pad > w + 0.5);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
if app.ui.keytips == crate::keytips::Phase::Tabs {
|
||||
crate::keytips::record_tab(r, tab, &app.ui.keytip_prefix, &mut app.keytip_rects);
|
||||
@@ -291,7 +400,29 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
let color = if contextual || active { t.accent_text } else { t.text };
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, if active { semibold(12.5) } else { medium(12.5) }, color);
|
||||
if cut {
|
||||
let mut job = egui::text::LayoutJob::single_section(
|
||||
shown.to_string(),
|
||||
egui::text::TextFormat::simple(if active { semibold(12.5) } else { medium(12.5) }, color),
|
||||
);
|
||||
job.wrap = egui::text::TextWrapping {
|
||||
max_width: (w - plan.pad).max(1.0),
|
||||
max_rows: 1,
|
||||
break_anywhere: true,
|
||||
overflow_character: Some('…'),
|
||||
};
|
||||
let galley = ui.painter().layout_job(job);
|
||||
ui.painter().galley(r.center() - galley.size() / 2.0, galley, color);
|
||||
} else {
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
shown,
|
||||
if active { semibold(12.5) } else { medium(12.5) },
|
||||
color,
|
||||
);
|
||||
}
|
||||
let resp = if cut { resp.on_hover_text(shown) } else { resp };
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
ui.painter().rect_filled(u, 2.0, t.accent);
|
||||
@@ -307,7 +438,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let left_out: Vec<(&str, &str)> = tabs
|
||||
.iter()
|
||||
.zip(&labels)
|
||||
.zip(&fits)
|
||||
.zip(&plan.fits)
|
||||
.filter(|(_, fits)| !**fits)
|
||||
.map(|(((tab, _), shown), _)| (tab.as_str(), *shown))
|
||||
.collect();
|
||||
@@ -316,10 +447,18 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "»", medium(12.5), t.text);
|
||||
// The active tab is in the menu: the button says so.
|
||||
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"»",
|
||||
medium(12.5),
|
||||
if holds_active { t.accent_text } else { t.text },
|
||||
);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for (tab, shown) in left_out {
|
||||
if ui.selectable_label(false, shown).clicked() {
|
||||
if ui.selectable_label(app.ui.tab == tab, shown).clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
ui.close();
|
||||
}
|
||||
@@ -882,8 +1021,9 @@ fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let font = app.previews.font_shown(&font);
|
||||
let fams = app.previews.families();
|
||||
let recent: Vec<String> = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect();
|
||||
let prev = app.previews.font_preview_fn();
|
||||
if let Some(f) = font_combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) {
|
||||
if let Some(f) = font_combo_with_recent(ui, "font", 150.0, &font, &fams, &recent, Some(&*prev)) {
|
||||
let _ = if f == crate::widgets::ADD_FONT {
|
||||
app.run("font.add", json!({}))
|
||||
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
|
||||
@@ -3118,6 +3258,79 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2026: at every width and in the longest languages no tab is drawn over Comments /
|
||||
/// Editing / Share, the tabs stay inside the window, and the buttons keep their room.
|
||||
#[test]
|
||||
fn tab_strip_never_overlaps_the_right_buttons() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
for lang in ["en", "pl", "de"] {
|
||||
for width in [300.0, 420.0, 520.0, 640.0, 765.0, 1280.0] {
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Text")), crate::Services::default());
|
||||
app.run("insert.table", json!({"rows": 3, "cols": 3})).unwrap();
|
||||
app.run("ui.language", json!({"value": lang})).unwrap();
|
||||
app.run("ui.tab", json!({"tab": "Table Layout"})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(width, 600.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
let buttons: Vec<Rect> =
|
||||
[tl!("Comments"), tl!("Editing"), tl!("Share")].iter().filter_map(|b| h.query_by_label(b).map(|n| n.rect())).collect();
|
||||
assert_eq!(buttons.len(), 3, "{lang} {width}: the three buttons are on the strip");
|
||||
assert!(buttons.iter().all(|b| b.right() <= width + 0.5 && b.left() >= 0.0), "{lang} {width}: buttons inside {buttons:?}");
|
||||
for tab in crate::ribbon::TABS.iter().copied().chain(["Table Design", "Table Layout"]) {
|
||||
let Some(node) = h.query_by_label(tl!(tab)) else { continue };
|
||||
let r = node.rect();
|
||||
if r.top() > 40.0 {
|
||||
continue; // a button of the ribbon with the same name, not the tab
|
||||
}
|
||||
assert!(r.right() <= width + 0.5, "{lang} {width}: {tab} {r:?} past the window");
|
||||
assert!(buttons.iter().all(|b| !b.intersects(r)), "{lang} {width}: {tab} {r:?} under {buttons:?}");
|
||||
}
|
||||
assert!(h.query_by_label(tl!("File")).is_some(), "{lang} {width}: File stays on the strip");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_strip_gives_way_in_order() {
|
||||
let spec = |text_w, file, active, contextual| TabSpec { text_w, file, active, contextual };
|
||||
let tabs = [spec(20.0, true, false, false), spec(30.0, false, true, false), spec(30.0, false, false, false), spec(40.0, false, false, true)];
|
||||
let buttons = [200.0, 150.0, 60.0];
|
||||
// Plenty of room: nothing gives way.
|
||||
let p = plan_strip(&tabs, buttons, 600.0, 26.0);
|
||||
assert_eq!((p.pad, p.buttons, p.fits.iter().all(|f| *f)), (18.0, 0, true));
|
||||
// Tighter: the padding goes first, then the Comments / Editing labels, then Share's.
|
||||
let p = plan_strip(&tabs, buttons, 380.0, 26.0);
|
||||
assert_eq!((p.pad, p.buttons), (10.0, 0));
|
||||
let p = plan_strip(&tabs, buttons, 330.0, 26.0);
|
||||
assert_eq!((p.pad, p.buttons), (10.0, 1));
|
||||
let p = plan_strip(&tabs, buttons, 240.0, 26.0);
|
||||
assert_eq!((p.pad, p.buttons), (10.0, 2));
|
||||
// Then the plain tab goes into »; File, the active and the contextual tab stay.
|
||||
let p = plan_strip(&tabs, buttons, 215.0, 26.0);
|
||||
assert_eq!(p.fits, [true, true, false, true]);
|
||||
// Then the labels of the active and contextual tabs are cut down.
|
||||
let p = plan_strip(&tabs, buttons, 208.0, 26.0);
|
||||
assert_eq!(p.fits, [true, true, false, true]);
|
||||
assert!(p.widths[3] < 52.0 && p.widths[3] >= MIN_TAB_W - 0.01, "{:?}", p.widths);
|
||||
// No room for that either: the contextual one goes into », the active one last.
|
||||
let p = plan_strip(&tabs, buttons, 190.0, 26.0);
|
||||
assert_eq!(p.fits, [true, true, false, false]);
|
||||
let p = plan_strip(&tabs, buttons, 140.0, 26.0);
|
||||
assert_eq!(p.fits, [true, false, false, false]);
|
||||
// The buttons' room is never taken, and hostile numbers don't panic.
|
||||
for avail in [0.0, -5.0, f32::NAN, f32::INFINITY] {
|
||||
let _ = plan_strip(&tabs, buttons, avail, 26.0);
|
||||
}
|
||||
let _ = plan_strip(&[], buttons, 100.0, 26.0);
|
||||
let _ = plan_strip(&[spec(f32::NAN, false, true, false)], [f32::NAN; 3], 100.0, f32::NAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_tabs_keeps_the_kept_tabs_and_drops_from_the_end() {
|
||||
let tabs = [(40.0, true), (50.0, false), (50.0, false), (50.0, false), (60.0, true)];
|
||||
|
||||
@@ -288,6 +288,11 @@ pub struct Tokens {
|
||||
pub red: Color32,
|
||||
pub green: Color32,
|
||||
pub orange: Color32,
|
||||
/// A window's close box while the pointer is over it (the red of a Windows caption button),
|
||||
/// while pressed, and the ✕ drawn on it then (#1885).
|
||||
pub close_hover: Color32,
|
||||
pub close_pressed: Color32,
|
||||
pub on_close: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
@@ -349,6 +354,9 @@ impl Tokens {
|
||||
red: Color32::from_rgb(0xD1, 0x3B, 0x3B),
|
||||
green: Color32::from_rgb(0x2E, 0x8B, 0x57),
|
||||
orange: Color32::from_rgb(0xE0, 0x7B, 0x1F),
|
||||
close_hover: Color32::from_rgb(0xC4, 0x2B, 0x1C),
|
||||
close_pressed: Color32::from_rgb(0xA6, 0x23, 0x16),
|
||||
on_close: Color32::WHITE,
|
||||
}
|
||||
}
|
||||
pub fn dark() -> Self {
|
||||
@@ -411,6 +419,10 @@ impl Tokens {
|
||||
red: Color32::from_rgb(0xEE, 0x6A, 0x6A),
|
||||
green: Color32::from_rgb(0x5B, 0xC0, 0x8A),
|
||||
orange: Color32::from_rgb(0xF0, 0xA0, 0x50),
|
||||
// A little lighter than the light theme's red, so it reads against the dark window.
|
||||
close_hover: Color32::from_rgb(0xD6, 0x3C, 0x2C),
|
||||
close_pressed: Color32::from_rgb(0xB3, 0x30, 0x23),
|
||||
on_close: Color32::WHITE,
|
||||
}
|
||||
}
|
||||
pub fn get(ctx: &egui::Context) -> Tokens {
|
||||
@@ -780,6 +792,17 @@ mod tests {
|
||||
(x.max(y) + 0.05) / (x.min(y) + 0.05)
|
||||
}
|
||||
|
||||
/// #1885: the ✕ on a window's close box reads on its hover and pressed reds in both themes.
|
||||
#[test]
|
||||
fn close_box_glyph_reads_on_its_reds() {
|
||||
for t in [super::Tokens::light(), super::Tokens::dark()] {
|
||||
for (name, bg) in [("close_hover", t.close_hover), ("close_pressed", t.close_pressed)] {
|
||||
let r = contrast(t.on_close, bg);
|
||||
assert!(r >= 4.5, "dark={}: on_close on {name} is {r:.2}:1", t.dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #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]
|
||||
|
||||
+275
-24
@@ -492,7 +492,7 @@ pub fn combo(
|
||||
items: &[String],
|
||||
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
|
||||
) -> Option<String> {
|
||||
combo_impl(ui, id, width, current, items, preview, false)
|
||||
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
|
||||
@@ -516,7 +516,24 @@ pub fn font_combo(
|
||||
items: &[String],
|
||||
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
|
||||
) -> Option<String> {
|
||||
combo_impl(ui, id, width, current, items, preview, true)
|
||||
combo_impl(ui, id, width, current, items, &[], preview, true)
|
||||
}
|
||||
|
||||
/// [`font_combo`] with the `recent` families (most recent first) in a Recently Used Fonts
|
||||
/// section above the list, then All Fonts, as in Word (#2068). The sections show when nothing is
|
||||
/// typed; a query lists its matches only. Up/Down walk the recent entries, then the list; the
|
||||
/// headings are skipped. A recent font that is no longer installed stays listed and is applied as
|
||||
/// typed.
|
||||
pub fn font_combo_with_recent(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
width: f32,
|
||||
current: &str,
|
||||
items: &[String],
|
||||
recent: &[String],
|
||||
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
|
||||
) -> Option<String> {
|
||||
combo_impl(ui, id, width, current, items, recent, preview, true)
|
||||
}
|
||||
|
||||
/// Indices of the `items` matching `query`, ignoring case: exact matches, then the names starting
|
||||
@@ -554,8 +571,12 @@ struct ComboSearch {
|
||||
query: String,
|
||||
items_len: usize,
|
||||
matches: std::sync::Arc<Vec<usize>>,
|
||||
/// Index into `matches`.
|
||||
/// Index among the entries: the recent families shown, then `matches`.
|
||||
highlight: usize,
|
||||
/// Up or Down moved the highlight since the query last changed.
|
||||
moved: bool,
|
||||
/// Rows the list drew last frame; 0 while it is hidden.
|
||||
rows: usize,
|
||||
/// Where the list was drawn last frame, its scroll offset and visible height.
|
||||
popup: Option<Rect>,
|
||||
offset: f32,
|
||||
@@ -568,6 +589,7 @@ fn combo_impl(
|
||||
width: f32,
|
||||
current: &str,
|
||||
items: &[String],
|
||||
recent: &[String],
|
||||
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
|
||||
search: bool,
|
||||
) -> Option<String> {
|
||||
@@ -612,8 +634,11 @@ fn combo_impl(
|
||||
if search && resp.changed() {
|
||||
s.open = true;
|
||||
s.highlight = 0;
|
||||
s.moved = false;
|
||||
reveal = true;
|
||||
}
|
||||
// The recent section shows while nothing is typed; a query lists its matches only.
|
||||
let recent_shown: &[String] = if text.trim().is_empty() { recent } else { &[] };
|
||||
let mut regain_focus = false;
|
||||
if s.open {
|
||||
if s.query != text || s.items_len != items.len() {
|
||||
@@ -621,7 +646,8 @@ fn combo_impl(
|
||||
s.query = text.clone();
|
||||
s.items_len = items.len();
|
||||
}
|
||||
let last = s.matches.len().saturating_sub(1);
|
||||
let last = recent_shown.len().saturating_add(s.matches.len()).saturating_sub(1);
|
||||
s.moved |= step != 0;
|
||||
s.highlight = match step {
|
||||
1.. => s.highlight.saturating_add(1),
|
||||
0 => s.highlight,
|
||||
@@ -629,9 +655,12 @@ fn combo_impl(
|
||||
}
|
||||
.min(last);
|
||||
if enter {
|
||||
let picked = s.matches.get(s.highlight).and_then(|&i| items.get(i));
|
||||
let picked = recent_shown
|
||||
.get(s.highlight)
|
||||
.or_else(|| s.highlight.checked_sub(recent_shown.len()).and_then(|k| s.matches.get(k)).and_then(|&i| items.get(i)));
|
||||
// A cleared box commits as typed (nothing) unless Up or Down picked an entry.
|
||||
out = Some(match picked {
|
||||
Some(name) if !text.trim().is_empty() => name.clone(),
|
||||
Some(name) if !text.trim().is_empty() || s.moved => name.clone(),
|
||||
_ => text.clone(),
|
||||
});
|
||||
s.open = false;
|
||||
@@ -659,8 +688,12 @@ fn combo_impl(
|
||||
ui.painter().set(arrow_bg, arrow_box);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(DROPDOWN, DROPDOWN)), "dropdown", t.icon, t.accent);
|
||||
// The list drops from the box's left edge, as in Word, not from the arrow (#535).
|
||||
egui::Popup::menu(&aresp).anchor(resp.rect.union(ar)).show(|ui| {
|
||||
if let Some(v) = combo_list(ui, width, current, items, preview) {
|
||||
// The font list closes on a chosen entry or a click outside it, not on its headings.
|
||||
let close = if search { egui::PopupCloseBehavior::CloseOnClickOutside } else { egui::PopupCloseBehavior::CloseOnClick };
|
||||
egui::Popup::menu(&aresp).anchor(resp.rect.union(ar)).close_behavior(close).show(|ui| {
|
||||
let item = |k: usize| items.get(k);
|
||||
let row = |k: usize| section_row(k, recent, &item);
|
||||
if let Some(v) = list_rows(ui, width, current, section_rows(recent.len(), items.len()), &row, None, None, preview).0 {
|
||||
out = Some(v);
|
||||
ui.close();
|
||||
}
|
||||
@@ -677,14 +710,27 @@ fn combo_impl(
|
||||
}
|
||||
}
|
||||
});
|
||||
if s.open && !s.matches.is_empty() {
|
||||
let n = section_rows(recent_shown.len(), s.matches.len());
|
||||
if s.open && n > 0 {
|
||||
let matches = s.matches.clone();
|
||||
let scroll = reveal.then_some((s.offset, s.view_h));
|
||||
let shown = egui::Area::new(search_id.with("list")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom()).show(ui.ctx(), |ui| {
|
||||
let lit = highlight_row(s.highlight, recent_shown.len());
|
||||
// A list of a new length is sized again (an area keeps its last size, so a list that
|
||||
// once held one match stayed one row tall). That pass draws nothing, so it is
|
||||
// discarded and the frame drawn again, as egui does for its own sizing passes.
|
||||
let list_id = search_id.with("list");
|
||||
let resize = n != s.rows;
|
||||
if resize || egui::AreaState::load(ui.ctx(), list_id).is_none() {
|
||||
ui.ctx().request_discard("font list resized");
|
||||
}
|
||||
s.rows = n;
|
||||
let area = egui::Area::new(list_id).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom()).sizing_pass(resize);
|
||||
let shown = area.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style())
|
||||
.show(ui, |ui| {
|
||||
let item = |k: usize| matches.get(k).and_then(|&i| items.get(i));
|
||||
list_rows(ui, width, current, matches.len(), &item, Some(s.highlight), scroll, preview)
|
||||
let row = |k: usize| section_row(k, recent_shown, &item);
|
||||
list_rows(ui, width, current, n, &row, Some(lit), scroll, preview)
|
||||
})
|
||||
.inner
|
||||
});
|
||||
@@ -699,6 +745,7 @@ fn combo_impl(
|
||||
}
|
||||
} else {
|
||||
s.popup = None;
|
||||
s.rows = 0;
|
||||
}
|
||||
if regain_focus && s.open {
|
||||
ui.memory_mut(|m| m.request_focus(te_id));
|
||||
@@ -725,10 +772,67 @@ pub const COMBO_PREVIEW_ROW_H: f32 = 24.0;
|
||||
/// family and each preview loads that font, so building all rows loaded every font on the system
|
||||
/// (gigabytes on large font collections) and froze the app (#121).
|
||||
pub fn combo_list(ui: &mut Ui, width: f32, current: &str, items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>) -> Option<String> {
|
||||
list_rows(ui, width, current, items.len(), &|k| items.get(k), None, None, preview).0
|
||||
list_rows(ui, width, current, items.len(), &|k| items.get(k).map(Row::Item), None, None, preview).0
|
||||
}
|
||||
|
||||
/// The rows of [`combo_list`], virtualised, for `n` items fetched by index. With a `highlight`
|
||||
/// A row of a combo's list: a dim section heading nothing picks, or an entry.
|
||||
enum Row<'a> {
|
||||
Heading(&'a str),
|
||||
Item(&'a String),
|
||||
}
|
||||
|
||||
/// Rows in a list of `n` entries headed by `recent` recent ones: the two headings and the recent
|
||||
/// entries come on top when there are any.
|
||||
fn section_rows(recent: usize, n: usize) -> usize {
|
||||
if recent == 0 { n } else { n.saturating_add(recent).saturating_add(2) }
|
||||
}
|
||||
|
||||
/// Row `k` of a list headed by the `recent` families: Recently Used Fonts, those families, All
|
||||
/// Fonts, then `item(0..)`. Without recent families the rows are the items.
|
||||
fn section_row<'a>(k: usize, recent: &'a [String], item: &dyn Fn(usize) -> Option<&'a String>) -> Option<Row<'a>> {
|
||||
if recent.is_empty() {
|
||||
return item(k).map(Row::Item);
|
||||
}
|
||||
if k == 0 {
|
||||
return Some(Row::Heading(tl!("Recently Used Fonts")));
|
||||
}
|
||||
if let Some(r) = recent.get(k.saturating_sub(1)) {
|
||||
return Some(Row::Item(r));
|
||||
}
|
||||
if k == recent.len().saturating_add(1) {
|
||||
return Some(Row::Heading(tl!("All Fonts")));
|
||||
}
|
||||
item(k.saturating_sub(recent.len()).saturating_sub(2)).map(Row::Item)
|
||||
}
|
||||
|
||||
/// The row of entry `entry` in a list headed by `recent` recent families ([`section_row`]).
|
||||
fn highlight_row(entry: usize, recent: usize) -> usize {
|
||||
if recent == 0 {
|
||||
entry
|
||||
} else if entry < recent {
|
||||
entry.saturating_add(1)
|
||||
} else {
|
||||
entry.saturating_add(2)
|
||||
}
|
||||
}
|
||||
|
||||
/// A section heading's row of `size`: dim text, a rule across the list above it after the
|
||||
/// first row, nothing to click. The row asks for no more than `size`: the list's width is its
|
||||
/// entries' (a sizing pass offers an unbounded width).
|
||||
fn heading_row(ui: &mut Ui, text: &str, size: egui::Vec2, rule: bool) {
|
||||
let weak = ui.visuals().weak_text_color();
|
||||
let line = ui.visuals().widgets.noninteractive.bg_stroke;
|
||||
if rule {
|
||||
ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top(), line);
|
||||
}
|
||||
ui.allocate_ui_with_layout(size, egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_size(size);
|
||||
ui.add_space(6.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).small().color(weak)).selectable(false));
|
||||
});
|
||||
}
|
||||
|
||||
/// The rows of [`combo_list`], virtualised, for `n` rows fetched by index. With a `highlight`
|
||||
/// that row is marked instead of `current`; `scroll` (last frame's offset and visible height)
|
||||
/// scrolls the highlight into view. Returns the clicked item, the scroll offset and the height.
|
||||
fn list_rows<'a>(
|
||||
@@ -736,7 +840,7 @@ fn list_rows<'a>(
|
||||
width: f32,
|
||||
current: &str,
|
||||
n: usize,
|
||||
item: &dyn Fn(usize) -> Option<&'a String>,
|
||||
row: &dyn Fn(usize) -> Option<Row<'a>>,
|
||||
highlight: Option<usize>,
|
||||
scroll: Option<(f32, f32)>,
|
||||
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
|
||||
@@ -760,7 +864,14 @@ fn list_rows<'a>(
|
||||
let shown = area.show_rows(ui, row_h, n, |ui, range| {
|
||||
ui.set_min_width(width + 60.0);
|
||||
for k in range {
|
||||
let Some(it) = item(k) else { continue };
|
||||
let it = match row(k) {
|
||||
Some(Row::Item(it)) => it,
|
||||
Some(Row::Heading(h)) => {
|
||||
heading_row(ui, h, vec2(width + 60.0, row_h), k > 0);
|
||||
continue;
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
let lit = highlight == Some(k);
|
||||
let clicked = match preview {
|
||||
Some(p) => {
|
||||
@@ -840,21 +951,40 @@ pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response {
|
||||
/// fonts and would draw as empty boxes, so icons are painted (`icons.rs`) rather than typed.
|
||||
/// It looks and reacts like `ui.button` (see [`drawn_state`]).
|
||||
pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) -> Response {
|
||||
icon_text_button_with(ui, icon, text, font, false)
|
||||
icon_text_button_with(ui, icon, text, font, false, true)
|
||||
}
|
||||
|
||||
/// [`icon_text_button`] that opens a menu: the drop-down marker follows the text.
|
||||
pub fn icon_text_menu_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) -> Response {
|
||||
icon_text_button_with(ui, icon, text, font, true)
|
||||
icon_text_button_with(ui, icon, text, font, true, true)
|
||||
}
|
||||
|
||||
fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId, marker: bool) -> Response {
|
||||
/// The icon-only form of [`icon_text_button`] / [`icon_text_menu_button`] for a crowded strip
|
||||
/// (#2026): `label` is the accessible name and the tooltip, not drawn.
|
||||
pub fn icon_only_button(ui: &mut Ui, icon: &str, label: &str, font: egui::FontId, marker: bool) -> Response {
|
||||
icon_text_button_with(ui, icon, label, font, marker, false).on_hover_text(label)
|
||||
}
|
||||
|
||||
/// Width [`icon_text_button`] (or its menu / icon-only forms) takes.
|
||||
pub fn icon_text_button_width(ui: &Ui, text: &str, font: egui::FontId, marker: bool, show_text: bool) -> f32 {
|
||||
let text_w = if show_text { ui.ctx().fonts_mut(|f| f.layout_no_wrap(text.to_string(), font, Color32::PLACEHOLDER).size().x) } else { 0.0 };
|
||||
icon_text_button_size(ui, text_w, marker, show_text)
|
||||
}
|
||||
|
||||
fn icon_text_button_size(ui: &Ui, text_w: f32, marker: bool, show_text: bool) -> f32 {
|
||||
let pad = ui.spacing().button_padding;
|
||||
let gap = if show_text { 4.0 } else { 0.0 };
|
||||
let marker_w = if marker { DROPDOWN + 4.0 } else { 0.0 };
|
||||
pad.x + 16.0 + gap + text_w + marker_w + pad.x
|
||||
}
|
||||
|
||||
fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId, marker: bool, show_text: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = ui.painter().layout_no_wrap(text.to_string(), font, Color32::PLACEHOLDER);
|
||||
let pad = ui.spacing().button_padding;
|
||||
let icon_w = 16.0;
|
||||
let marker_w = if marker { DROPDOWN + 4.0 } else { 0.0 };
|
||||
let size = vec2(pad.x + icon_w + 4.0 + galley.size().x + marker_w + pad.x, galley.size().y.max(icon_w) + 2.0 * pad.y);
|
||||
let text_w = if show_text { galley.size().x } else { 0.0 };
|
||||
let size = vec2(icon_text_button_size(ui, text_w, marker, show_text), galley.size().y.max(icon_w) + 2.0 * pad.y);
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), text));
|
||||
let st = drawn_state(ui, &resp, true);
|
||||
@@ -867,9 +997,10 @@ fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId
|
||||
t.icon,
|
||||
t.accent,
|
||||
);
|
||||
let text_x = r.min.x + pad.x + icon_w + 4.0;
|
||||
let text_w = galley.size().x;
|
||||
ui.painter().galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, st.text);
|
||||
let text_x = r.min.x + pad.x + icon_w + if show_text { 4.0 } else { 0.0 };
|
||||
if show_text {
|
||||
ui.painter().galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, st.text);
|
||||
}
|
||||
if marker {
|
||||
let c = pos2(text_x + text_w + 4.0 + DROPDOWN / 2.0, r.center().y);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(DROPDOWN, DROPDOWN)), "dropdown", st.text, st.text);
|
||||
@@ -959,6 +1090,11 @@ mod tests {
|
||||
|
||||
/// Focuses a fresh combo, types `typed`, presses `keys` (one per frame) and returns its value.
|
||||
fn type_into_combo(search: bool, items: &[String], typed: &str, keys: &[egui::Key]) -> Option<String> {
|
||||
type_into(search, items, &[], typed, keys)
|
||||
}
|
||||
|
||||
/// [`type_into_combo`] for a font box headed by the `recent` families.
|
||||
fn type_into(search: bool, items: &[String], recent: &[String], typed: &str, keys: &[egui::Key]) -> Option<String> {
|
||||
let ctx = egui::Context::default();
|
||||
let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
let mut frames = vec![vec![], vec![egui::Event::Text(typed.into())]];
|
||||
@@ -969,7 +1105,7 @@ mod tests {
|
||||
if n == 0 {
|
||||
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
|
||||
}
|
||||
let v = if search { font_combo(ui, "f", 150.0, "", items, None) } else { combo(ui, "f", 150.0, "", items, None) };
|
||||
let v = if search { font_combo_with_recent(ui, "f", 150.0, "", items, recent, None) } else { combo(ui, "f", 150.0, "", items, None) };
|
||||
out = out.take().or(v);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
@@ -992,6 +1128,121 @@ mod tests {
|
||||
assert_eq!(type_into_combo(false, &sizes, "1", &[Enter]).as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
/// #2068: with nothing typed, the recent families head the list and Enter applies the one
|
||||
/// highlighted; Down walks past them into All Fonts; a query lists its matches only.
|
||||
#[test]
|
||||
fn recent_fonts_head_the_typed_list() {
|
||||
use egui::Key::{ArrowDown, ArrowUp, Backspace, Enter};
|
||||
let fonts: Vec<String> = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec();
|
||||
let recent: Vec<String> = ["Georgia", "Courier New"].map(String::from).to_vec();
|
||||
// Typing, then clearing the box, leaves the list open on the recent fonts. Enter alone
|
||||
// applies nothing the user didn't pick; Down picks the second recent font.
|
||||
assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, Enter]).as_deref(), Some(""));
|
||||
assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, ArrowUp, Enter]).as_deref(), Some("Georgia"));
|
||||
assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, Enter]).as_deref(), Some("Courier New"));
|
||||
// Past the recent entries comes All Fonts; the headings are skipped.
|
||||
assert_eq!(type_into(true, &fonts, &recent, "x", &[Backspace, ArrowDown, ArrowDown, Enter]).as_deref(), Some("Arial"));
|
||||
// A query lists the matching installed families only.
|
||||
assert_eq!(type_into(true, &fonts, &recent, "cour", &[Enter]).as_deref(), Some("Courier New"));
|
||||
assert_eq!(type_into(true, &fonts, &recent, "arial", &[ArrowDown, Enter]).as_deref(), Some("Arial Black"));
|
||||
// Nothing recent, nothing typed and no key moved the highlight: the text is committed as before.
|
||||
assert_eq!(type_into(true, &fonts, &[], "x", &[Backspace, Enter]).as_deref(), Some(""));
|
||||
}
|
||||
|
||||
/// The typed list once held one match and stayed one row tall when the query then matched
|
||||
/// many: an area keeps its last size unless a changed query sizes it again.
|
||||
#[test]
|
||||
fn the_typed_list_grows_when_the_query_matches_more() {
|
||||
let fonts: Vec<String> = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec();
|
||||
let recent: Vec<String> = ["Georgia", "Courier New"].map(String::from).to_vec();
|
||||
let ctx = egui::Context::default();
|
||||
let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
let erase = || vec![key(egui::Key::Backspace)];
|
||||
let frames = vec![vec![], vec![egui::Event::Text("cour".into())], vec![], erase(), erase(), erase(), erase(), vec![]];
|
||||
let mut area = None;
|
||||
let mut heights = Vec::new();
|
||||
for (n, events) in frames.into_iter().enumerate() {
|
||||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
|
||||
if n == 0 {
|
||||
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
|
||||
}
|
||||
area = Some(ui.id().with(("combo_search", "f")).with("list"));
|
||||
font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
heights.push(area.and_then(|id| egui::AreaState::load(&ctx, id)).and_then(|s| s.size).map(|s| s.y));
|
||||
}
|
||||
let one = heights[2].expect("one match listed");
|
||||
let all = heights[7].expect("every font listed");
|
||||
assert!(all > 4.0 * one, "one match: {one} tall; two headings, two recent and four fonts: {all}");
|
||||
}
|
||||
|
||||
/// The typed list was built invisibly (a sizing pass) on each keystroke that changed its
|
||||
/// length, so it blinked out for a frame. It is drawn on the keystroke's own frame.
|
||||
#[test]
|
||||
fn the_typed_list_shows_on_the_frame_of_the_keystroke() {
|
||||
fn has_text(shape: &egui::Shape, s: &str) -> bool {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => t.galley.text() == s,
|
||||
egui::Shape::Vec(v) => v.iter().any(|x| has_text(x, s)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
let fonts: Vec<String> = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec();
|
||||
let recent: Vec<String> = ["Georgia", "Courier New"].map(String::from).to_vec();
|
||||
let ctx = egui::Context::default();
|
||||
let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
let erase = || vec![key(egui::Key::Backspace)];
|
||||
// Each keystroke and a row the list must draw on that frame: one match, the four
|
||||
// queries on the way back to a cleared box (whose list shows the recent fonts), then two
|
||||
// matches. The field itself shows only the typed text.
|
||||
let frames = [
|
||||
(vec![egui::Event::Text("cour".into())], "Courier New"),
|
||||
(erase(), "Courier New"),
|
||||
(erase(), "Courier New"),
|
||||
(erase(), "Arial Black"),
|
||||
(erase(), "Recently Used Fonts"),
|
||||
(vec![egui::Event::Text("ar".into())], "Arial Black"),
|
||||
];
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
|
||||
font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None);
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
for (n, (events, row)) in frames.into_iter().enumerate() {
|
||||
let out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
|
||||
font_combo_with_recent(ui, "f", 150.0, "", &fonts, &recent, None);
|
||||
});
|
||||
let drawn = out.shapes.iter().any(|c| has_text(&c.shape, row));
|
||||
out.drop_without_applying_deltas();
|
||||
assert!(drawn, "keystroke {n}: {row} is drawn");
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows of a sectioned font list, and where the keyboard highlight lands among them.
|
||||
#[test]
|
||||
fn font_list_sections_skip_their_headings() {
|
||||
let items: Vec<String> = ["Arial", "Courier New"].map(String::from).to_vec();
|
||||
let recent: Vec<String> = ["Georgia"].map(String::from).to_vec();
|
||||
fn names<'a>(items: &'a [String], recent: &'a [String], n: usize) -> Vec<String> {
|
||||
let item = |k: usize| items.get(k);
|
||||
(0..n)
|
||||
.map(|k| match section_row(k, recent, &item) {
|
||||
Some(Row::Heading(h)) => format!("[{h}]"),
|
||||
Some(Row::Item(i)) => i.clone(),
|
||||
None => "-".into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
assert_eq!(section_rows(1, 2), 5);
|
||||
assert_eq!(names(&items, &recent, 6), ["[Recently Used Fonts]", "Georgia", "[All Fonts]", "Arial", "Courier New", "-"]);
|
||||
assert_eq!([highlight_row(0, 1), highlight_row(1, 1), highlight_row(2, 1)], [1, 3, 4]);
|
||||
// Nothing recent: no headings, the rows are the items.
|
||||
assert_eq!(section_rows(0, 2), 2);
|
||||
assert_eq!(names(&items, &[], 3), ["Arial", "Courier New", "-"]);
|
||||
assert_eq!(highlight_row(1, 0), 1);
|
||||
}
|
||||
|
||||
/// The field was rounded on all four corners and the arrow on none, so the two looked apart,
|
||||
/// and their borders stood side by side as a double-width divider. The field is rounded only
|
||||
/// on the left, the arrow only on the right, and the arrow's box lies under the field by one
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The close control of a floating window (dialogs, the Read Aloud player, the file browser),
|
||||
//! as on a Word dialog on Windows (#1885): a box at the right end of the title bar, the full
|
||||
//! title-bar height and about 1.6× that wide, all of it clickable. It fills red with a white ✕
|
||||
//! while the pointer is over it and darker red while pressed. Escape closes dialogs as before.
|
||||
|
||||
use egui::{CornerRadius, Margin, Rect, Response, Sense, Stroke, TextStyle, Ui, UiBuilder, pos2, vec2};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// The box's width as a multiple of the title bar height.
|
||||
const WIDTH: f32 = 1.6;
|
||||
/// Half the ✕'s width and height.
|
||||
const GLYPH: f32 = 5.0;
|
||||
|
||||
/// The title bar's measurements for the current style.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Geometry {
|
||||
/// Title bar height inside the frame line: the heading row plus the window margin.
|
||||
pub title_h: f32,
|
||||
/// The box's width, a whole number so the title bar's margin can reserve it.
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
pub(crate) fn geometry(ctx: &egui::Context) -> Geometry {
|
||||
let style = ctx.global_style();
|
||||
let heading = ctx.fonts_mut(|f| f.row_height(&TextStyle::Heading.resolve(&style)));
|
||||
let m = style.spacing.window_margin;
|
||||
let title_h = heading + f32::from(m.top) + f32::from(m.bottom);
|
||||
Geometry { title_h, width: (WIDTH * title_h).round().clamp(0.0, 127.0) }
|
||||
}
|
||||
|
||||
/// Reserves the box's room at the right end of `w`'s title bar. Pair with [`show`] after
|
||||
/// `Window::show`; the window must not use egui's own close button (`Window::open`).
|
||||
pub(crate) fn window<'a>(ctx: &egui::Context, w: egui::Window<'a>) -> egui::Window<'a> {
|
||||
let style = ctx.global_style();
|
||||
let m = style.spacing.window_margin;
|
||||
let right = geometry(ctx).width as i8;
|
||||
w.title_frame(egui::Frame::window(&style).inner_margin(Margin { right, ..m }))
|
||||
}
|
||||
|
||||
/// The box for a window whose outer rect is `window` (frame line included), with a title bar
|
||||
/// `g.title_h` tall inside a `stroke` wide frame line.
|
||||
pub(crate) fn close_rect(window: Rect, stroke: f32, g: Geometry) -> Rect {
|
||||
let top = window.top() + stroke;
|
||||
let right = window.right() - stroke;
|
||||
Rect::from_min_max(pos2(right - g.width, top), pos2(right, top + g.title_h))
|
||||
}
|
||||
|
||||
/// Draws the box over `window` (the response `Window::show` returned) and handles the pointer.
|
||||
/// True when the window should close: the box was clicked, or something inside asked for it.
|
||||
pub(crate) fn show(ctx: &egui::Context, window: &Response, t: &Tokens) -> bool {
|
||||
let style = ctx.global_style();
|
||||
let stroke = style.visuals.window_stroke.width;
|
||||
let rect = close_rect(window.rect, stroke, geometry(ctx));
|
||||
let id = window.layer_id.id.with("close_box");
|
||||
let ui = Ui::new(ctx.clone(), id, UiBuilder::new().layer_id(window.layer_id).max_rect(rect));
|
||||
// Pointer-only, like a caption button: Tab never lands on it.
|
||||
let r = ui.interact(rect, id.with("hit"), Sense::CLICK | Sense::DRAG);
|
||||
let label = tl!("Close");
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
let r = r.on_hover_text(label);
|
||||
let down = r.is_pointer_button_down_on() && r.contains_pointer();
|
||||
let hot = down || r.hovered();
|
||||
let painter = ctx.layer_painter(window.layer_id);
|
||||
if hot {
|
||||
let ne = style.visuals.window_corner_radius.ne.saturating_sub(stroke.round() as u8);
|
||||
painter.rect_filled(rect, CornerRadius { ne, ..CornerRadius::ZERO }, if down { t.close_pressed } else { t.close_hover });
|
||||
}
|
||||
let c = pos2(painter.round_to_pixel_center(rect.center().x), painter.round_to_pixel_center(rect.center().y));
|
||||
let s = Stroke::new(1.0, if hot { t.on_close } else { t.text });
|
||||
painter.line_segment([c + vec2(-GLYPH, -GLYPH), c + vec2(GLYPH, GLYPH)], s);
|
||||
painter.line_segment([c + vec2(-GLYPH, GLYPH), c + vec2(GLYPH, -GLYPH)], s);
|
||||
r.clicked() || window.should_close()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::WordApp;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(app: WordApp) -> egui_kittest::Harness<'static, WordApp> {
|
||||
egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
/// Clicks the close box of the window area `id`: 6 px from its right edge, mid title bar,
|
||||
/// well outside the ✕.
|
||||
fn click_close_box(h: &mut egui_kittest::Harness<'_, WordApp>, id: egui::Id) {
|
||||
let window = h.ctx.memory(|m| m.area_rect(id)).expect("window is open");
|
||||
let at = pos2(window.right() - 6.0, close_rect(window, 1.0, geometry(&h.ctx)).center().y);
|
||||
let button =
|
||||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
h.event(egui::Event::PointerMoved(at));
|
||||
h.step();
|
||||
h.event(button(true));
|
||||
h.step();
|
||||
h.event(button(false));
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// The box fills the title bar's height inside the frame line and is as wide as the
|
||||
/// geometry says, flush with the window's right edge.
|
||||
#[test]
|
||||
fn the_box_is_the_title_bars_right_end() {
|
||||
let g = Geometry { title_h: 30.0, width: 48.0 };
|
||||
let r = close_rect(Rect::from_min_max(pos2(100.0, 50.0), pos2(400.0, 200.0)), 1.0, g);
|
||||
assert_eq!(r, Rect::from_min_max(pos2(351.0, 51.0), pos2(399.0, 81.0)));
|
||||
}
|
||||
|
||||
/// With the app's style, the box is the heading row plus the window margin tall and about
|
||||
/// 1.6× as wide, a whole number of points.
|
||||
#[test]
|
||||
fn geometry_follows_the_style() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions(false, false));
|
||||
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
|
||||
ctx.begin_pass(Default::default());
|
||||
let g = geometry(&ctx);
|
||||
let m = ctx.global_style().spacing.window_margin;
|
||||
let font = TextStyle::Heading.resolve(&ctx.global_style());
|
||||
let heading = ctx.fonts_mut(|f| f.row_height(&font));
|
||||
ctx.end_pass().drop_without_applying_deltas();
|
||||
assert!(heading > 12.0, "a heading row: {heading}");
|
||||
assert_eq!(g.title_h, heading + f32::from(m.top) + f32::from(m.bottom));
|
||||
assert_eq!(g.width, g.width.round());
|
||||
assert!((g.width / g.title_h - WIDTH).abs() < 0.05, "{g:?}");
|
||||
}
|
||||
|
||||
/// The Read Aloud player and the file browser use the same box, and close from it.
|
||||
#[test]
|
||||
fn the_player_and_the_file_browser_close_from_the_box() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
app.session.read_aloud.open = true;
|
||||
let mut h = harness(app);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
click_close_box(&mut h, egui::Id::new("read-aloud-player"));
|
||||
assert!(!h.state().session.read_aloud.open, "the player stops and closes");
|
||||
let _ = h.state_mut().run("ui.fileBrowser", json!({"purpose": "document"}));
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
assert!(h.state().file_browser.open.is_some(), "the browser opened");
|
||||
click_close_box(&mut h, egui::Id::new("builtin_file_browser"));
|
||||
assert!(h.state().file_browser.open.is_none(), "the browser is cancelled");
|
||||
}
|
||||
|
||||
/// The box is pointer-only, like a Windows caption button: Tab never lands on it, so Enter
|
||||
/// can't close a dialog by accident.
|
||||
#[test]
|
||||
fn the_box_is_not_in_the_tab_order() {
|
||||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
let mut h = harness(app);
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
let hit = egui::Id::new(("dialog", "Word Count")).with("close_box").with("hit");
|
||||
let r = h.ctx.read_response(hit).expect("the close box is a widget");
|
||||
assert!(r.sense.senses_click() && !r.sense.is_focusable(), "pointer-only: {:?}", r.sense);
|
||||
for _ in 0..12 {
|
||||
h.key_press(egui::Key::Tab);
|
||||
h.step();
|
||||
assert_ne!(h.ctx.memory(|m| m.focused()), Some(hit), "Tab skips the close box");
|
||||
}
|
||||
assert!(h.state().dialog.is_some(), "the dialog is still open");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Layout and pagination parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
|
||||
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
|
||||
@@ -21,13 +21,16 @@ font can't match Word's metrics.
|
||||
| 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. |
|
||||
| Line breaking (UAX #14 with Word's exceptions: no break after `/`) | ✅ | `layout::tests::no_line_break_right_after_a_slash` | — |
|
||||
| Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109 | — |
|
||||
| Spaces at the end of a line hang past the right margin instead of wrapping; the caret after them stays at the margin, in the body and in headers and footers | ✅ | `layout::tests::caret_after_hanging_spaces_stays_at_the_margin` (#2146): a letter typed after them wraps to the next line | — |
|
||||
| Complex scripts (Myanmar, Khmer, Indic): shaped per script run with OpenType fallback fonts, one cluster per syllable, Myanmar wraps between syllables | 🟡 | `layout::tests_complex` (#604). Khmer, Thai and Lao still wrap only at spaces (need a dictionary) | 4–8 |
|
||||
| Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 |
|
||||
| Missing Thai document fonts (TH Sarabun New, Angsana New, Cordia New, Browallia New, the UPC families, Leelawadee): one Thai substitute face for the whole run, Latin letters, digits and ฿ included, so the run keeps one font at one size as Word's Cordia New substitution does | ✅ | `fonts::word::tests::a_missing_thai_font_resolves_to_an_installed_thai_face`, `fonts::fontdb::tests::a_thai_run_with_latin_falls_back_to_one_face`, `layout::tests_complex::a_missing_thai_font_draws_the_whole_run_in_one_thai_face`, `layout::tests_complex::a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it` (#1549). Windows: Cordia New, then Leelawadee UI, Tahoma, Noto Sans Thai; macOS: Thonburi (Sathu for the serif families); Linux: Noto Sans Thai, then the TLWG fonts. The first listed face with Latin letters and digits as well as Thai wins. Other Thai names (`TH …`, `…UPC`, names with `Thai`) use the same lists. When no listed face is installed, or the one found lacks the run's Thai or Latin letters, the run moves to one installed face covering its Thai and Latin text if there is one. Only Thai fonts: a missing Latin font keeps Latin text in its own substitute, and a run with its own complex-script font keeps it for the Thai letters, as Word does | — |
|
||||
| Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 |
|
||||
| Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Missing Arial Unicode MS tries Arial then the Arial substitutes, case-insensitively (`fonts::word::tests::unicode_arial_is_proportional`); glyph coverage and exact Word line height can differ. Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner |
|
||||
| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 |
|
||||
| Full font names in document font requests | ✅ | Exact English (or first available) OpenType name ID4 lookup in loaded faces and the native lazy catalog; family and legacy-family matches keep priority. No style-suffix guessing; named variable-instance aliases remain unsupported | 0 |
|
||||
| Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol | 0 |
|
||||
| List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — |
|
||||
| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — |
|
||||
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); other line-spacing combinations remain (#108, #135 open) | 3–5 |
|
||||
| Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — |
|
||||
@@ -184,6 +187,9 @@ Theme-inherited dash styles, line caps, ink and open freeform strokes are not co
|
||||
| 2026-10-16 | trivial | Exact OpenType full names (name ID4) select installed static faces after family names; variable-instance aliases remain unsupported |
|
||||
| 2026-10-16 | trivial | Symbol (PCL6) uses the existing Symbol mapping and Symbol face/fallback despite stale Roman font metadata; stored codes/fonts stay unchanged |
|
||||
| 2026-10-16 | trivial | Prefer a covering Times New Roman face for an uncovered black-circle glyph; retain normal label-ascent and other fallback rules |
|
||||
| 2026-10-15 | trivial | The caret after spaces hanging past the right margin stays at the margin instead of running off the page (#2146) |
|
||||
| 2026-10-15 | trivial | A missing Thai document font is substituted by one Thai face for the whole run, Latin letters and digits included (#1549) |
|
||||
| 2026-10-15 | trivial | A list bullet no installed font can draw (a private-use code) shows the level's default bullet instead of an icon from an unrelated font (#1683) |
|
||||
| 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows |
|
||||
| 2026-10-14 | trivial | Consistent cell widths override a stale table grid |
|
||||
| 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Typing parity with Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** trivial (a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
What Word does when you type, observed black-box (same keystrokes typed into Word and
|
||||
WordCraft, results read back paragraph by paragraph) and pinned by tests in
|
||||
@@ -30,7 +30,7 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp
|
||||
|---|---|---|
|
||||
| `* item` Enter Enter `text` | the second Enter ends the list; `text` is a **Normal** paragraph (no list indent left behind) | `enter_twice_after_a_list_item_ends_the_list`, `the_paragraph_after_a_list_is_normal_text` |
|
||||
| … Enter ×3, ×4 | each further Enter adds a blank Normal paragraph (never swallowed) | `more_enters_after_a_list_make_blank_paragraphs` |
|
||||
| Enter on an empty **nested** item | moves it up one level; only a top-level empty item leaves the list | `enter_on_an_empty_nested_item_moves_it_up_a_level_first` |
|
||||
| Enter on an empty **nested** item | moves it up one level; only a top-level empty item leaves the list. The same when the list comes from the paragraph style (List Number 2 in a Word file, #2143) | `enter_on_an_empty_nested_item_moves_it_up_a_level_first`, `enter_on_an_empty_item_numbered_by_its_style_moves_it_up_a_level` |
|
||||
| `* ` Enter (nothing typed) | list cancelled, Normal paragraph | `enter_right_after_starting_a_list_cancels_it` |
|
||||
| Tab / Shift+Tab at the start of an item | demote / promote | `shift_tab_and_tab_change_list_levels` |
|
||||
| Backspace at the start of an item | 1st: number removed, indent kept; 2nd: Normal paragraph — **not** joined to the one above | `backspace_at_the_start_of_an_item_removes_the_number_then_the_indent` |
|
||||
@@ -57,6 +57,7 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp
|
||||
| Paste of several paragraphs (tracking on) | every paragraph mark the paste adds is a tracked insertion; Reject All restores the one paragraph, Accept All keeps the pasted ones | `reject_all_takes_out_the_paragraph_breaks_a_tracked_paste_inserted` |
|
||||
| Backspace at a paragraph start / Delete at a paragraph end (tracking on) | the paragraph mark is marked deleted (still shown); the caret moves before it (Backspace) or past it (Delete). Accept joins the paragraphs, Reject keeps them. A mark you inserted yourself is just removed | `tracked_enter_and_backspace_track_the_paragraph_mark`, `tests::tracked_backspace_and_delete_mark_a_paragraph_break_deleted` |
|
||||
| Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` |
|
||||
| Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` |
|
||||
| Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` |
|
||||
| Table Layout › Delete Table / Rows / Columns / Cells (tracking on) | like Word deleting a selected table: the cells' text is marked deleted and the table structure stays (WordCraft has no tracked row or column deletion, `w:trPr/w:del`, so rows and columns keep their cells too, where Word's Accept removes the rows); text you inserted yourself is just removed; Reject All restores the text. A table, row or column with no text at all (empty, or its deletion already accepted) is removed as with tracking off; one whose text is all already marked deleted is left alone with a message (accept or reject first). Delete Columns leaves a merged cell that also spans a kept column alone (it narrows, keeping its text). A content control locked against deletion, or content locked against editing, refuses the delete with tracking on or off | `tests_revision_checks::tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it`, `tracked_row_column_and_cell_deletes_mark_their_text_deleted`, `tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original`, `table_deletes_refuse_a_control_locked_against_deletion`, `tracked_delete_table_removes_a_table_with_no_live_text`, `delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone`, `tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original` |
|
||||
| Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` |
|
||||
@@ -88,7 +89,9 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin
|
||||
- First letter of a sentence (paragraph or list item start, after `. ! ?`) is capitalised; not
|
||||
after abbreviations (`e.g.`, `Dr.`, …) or initials (`J.`), and not for mixed-case words
|
||||
(`iPhone`) or single-letter labels (`a.`). `teh`→`the`, `i`→`I`, `(c)`→`©`, `(tm)`→`™`,
|
||||
`wait...`→`wait…`, smart quotes (`autocorrect_as_you_type`).
|
||||
`wait...`→`wait…`, smart quotes (`autocorrect_as_you_type`); a quote opens after a space,
|
||||
an opening bracket, a dash, `«` `‹` `„` `‚`, or the other kind of opening quote, so nested
|
||||
quotes curl correctly (`smart_quotes_nested_and_opening_delimiters`).
|
||||
- Correct TWo INitial CApitals: a finished word of three or more letters with two capitals and
|
||||
the rest lowercase loses its second capital (`WOrd`→`Word`, `TEh`→`The`); not the exceptions
|
||||
(`IDs`, `CDs`, `PCs`, `TVs`, … plus the user's, `tools.autocorrect {"addInitialCapsException"}`)
|
||||
@@ -201,6 +204,9 @@ Word.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) |
|
||||
| 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) |
|
||||
| 2026-10-15 | trivial | Enter on an empty nested list item whose numbering comes from its paragraph style (List Number 2) moves it up a level instead of adding another item (#2143) |
|
||||
| 2026-10-18 | trivial | Smart quotes open after `«` `‹` `„` `‚` and after the other kind of opening quote, so nested quotes curl correctly |
|
||||
| 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) |
|
||||
| 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) |
|
||||
| 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) |
|
||||
|
||||
+14
-4
@@ -1,6 +1,6 @@
|
||||
# UI parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Home › Underline drop-down shows its lines, #2738; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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-18 · **Change:** trivial (Home › Underline drop-down shows its lines, #2738; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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
|
||||
@@ -12,9 +12,9 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
|
||||
| Area | WordCraft | Word | Parity | Hours |
|
||||
|---|---|---|---|---|
|
||||
| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math. The app opens on Home every launch, as in Word: the active tab is not restored between runs, but going File and back to the editor returns to the tab that was showing, contextual tabs included | Same plus Draw | 85% | (Draw in features) |
|
||||
| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape (lines, arrows, rectangles, ellipses, polygons), Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math. The app opens on Home every launch, as in Word: the active tab is not restored between runs, but going File and back to the editor returns to the tab that was showing, contextual tabs included. Home › Font: the font box lists Recently Used Fonts (the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, ten at most, kept between runs) above All Fonts, as in Word; the sections show while nothing is typed (#2068) | Same plus Draw | 85% | (Draw in features) |
|
||||
| Keytips (Alt / F10) | Ribbon badges (#43) and numbered Quick Access Toolbar badges in the user's button order (#620); File › Options › Advanced › Display switch saved per user (`ui.keytips`); Alt leaves macOS Option typing alone | ✅ | 85% | 1–2 |
|
||||
| Mini-toolbar on selection | ✅ (#43, two rows #535) | ✅ | 80% | 1–2 |
|
||||
| Mini-toolbar on selection | ✅ (#43, two rows #535; Recently Used Fonts in its font box #2068) | ✅ | 80% | 1–2 |
|
||||
| Colour drop-downs: theme/standard colours, Recent Colors, More Colors… → Colors dialog (Standard honeycomb, Custom hue/saturation field with lightness, RGB/HSL and hex) | ✅ Font Color (ribbon, mini toolbar, Font dialog), Shading, Page Color, Shape Fill/Outline/Glow, pen colours, dialog colour fields (#606); no Gradient or eyedropper | ✅ | 90% | 1 |
|
||||
| Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Undo's ▾ list: recent steps newest first, hover highlights the steps a click undoes ("Undo 3 Actions"), one click undoes them all (#701; `edit.undoTo`, `edit.undoHistory`). Not shown below the ribbon | customizable | 90% | 0.5 |
|
||||
| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 |
|
||||
@@ -26,7 +26,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
| Modal dialogs | **55** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 |
|
||||
| Modal dialogs | **58** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, its height or 1 × 1 cutting it back into rows, more refused with Word's message; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Modal dialogs | **58** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, its height or 1 × 1 cutting it back into rows, more refused with Word's message; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 |
|
||||
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
|
||||
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 |
|
||||
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344) | | 68% | 4–6 |
|
||||
@@ -58,11 +58,17 @@ Document title opens Rename Document; the account badge opens Options › Genera
|
||||
|
||||
The Language dialog reads effective character formatting, including document defaults, paragraph styles and character styles. Accepting it unchanged keeps inherited language and no-proof choices (#2717); explicit direct formatting still takes precedence. Other proofing and Windows-specific reports remain separate.
|
||||
|
||||
## Find navigation (#2096)
|
||||
|
||||
Find selects the Results tab, including when it prefills selected text. Editing the search query also reveals matching or empty results. Manual Headings and Pages navigation remains available until the next search action.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | trivial | Home › Underline's drop-down (#2738): each style is a sample of its own line (Single, Double, Thick, Dotted, Dashed, Dot-dash, Dot-dot-dash, Wave), named for tooltips and assistive technology; Words only, None, More Underlines… (the Font dialog) and Underline Color › (Automatic, theme and standard colours, More Colors…) on the new `format.underlineColor` |
|
||||
| 2026-10-15 | trivial | Dialogs, the Read Aloud player and the file browser: the close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable; red with a white ✕ while the pointer is over it, darker red pressed, in both themes; Escape as before (#1885) |
|
||||
| 2026-10-15 | trivial | Font box (ribbon and mini toolbar): a Recently Used Fonts section at the top of the list holds the fonts applied from the box, the mini toolbar or the Font dialog, most recent first, once each, ten at most, kept between runs in `ui.json`; All Fonts follows; the sections show while nothing is typed and a query lists its matches only; Up/Down walk the recent entries then the list, Enter applies the highlighted one; a recent font no longer installed stays listed (#2068) |
|
||||
| 2026-10-16 | trivial | Web Layout text fills the window width and re-wraps once a resize settles or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876) |
|
||||
| 2026-10-18 | trivial | Different First Page: double-clicking page 1's header while editing another one goes into page 1's own; Insert › Header/Footer/Page Number and Edit Header/Footer use the header the caret's page shows (first, even or default) instead of always the default (#2826) |
|
||||
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
|
||||
@@ -72,6 +78,8 @@ The Language dialog reads effective character formatting, including document def
|
||||
| 2026-10-16 | trivial | Font list: each entry's capitals are centred in its row, so a font with a very tall ascent (Amiri, Amiri Quran) no longer sits low or is cut in half, and the list reads as one even list (#2570) |
|
||||
| 2026-10-16 | trivial | Read Mode keeps the keyboard on entering, shows only whole columns, centred, and ends them above the page-turn bar (#1772) |
|
||||
| 2026-10-16 | trivial | Comment and revision balloons are readable in the dark theme: the card, the markup area, the author stripes and the leader lines come from theme tokens, and the text reaches 4.5:1 (#2045) |
|
||||
| 2026-10-15 | trivial | Layout › Columns works on the selected text, as in Word: section breaks around it, the rest of the document unchanged; More Columns… adds "Selected text" to Apply to and picks it when text is selected (#2024) |
|
||||
| 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) |
|
||||
| 2026-10-13 | trivial | New/Modify Table Style: No Border on a part that inherits its lines hides them; emptying the font box makes the part's font inherited again (#1288) |
|
||||
| 2026-10-14 | trivial | Tab stops edited on the ruler: corner kind selector, click to set, drag to move or off to clear, per-kind markers mirrored for RTL; each gesture one `para.tabs` call and one undo step; Rulers 85% → 90% |
|
||||
| 2026-10-12 | trivial | Tables on the page: one selected cell (Select Cell, or a click along its left edge) is a cell selection when its border is dragged: the border moves in its row only (#962) |
|
||||
@@ -91,6 +99,7 @@ The Language dialog reads effective character formatting, including document def
|
||||
| 2026-10-13 | trivial | Magnifiers: a line's end mark (paragraph mark or line break) takes room in what the canvas tells assistive technology, so Windows Magnifier, which places the caret by the character after it, follows the caret onto an empty line after Enter or Shift+Enter instead of staying on the line above (#1119) |
|
||||
| 2026-10-14 | trivial | Save Changes prompt: Save takes focus from the page too, so after typing the arrow keys, Tab and Enter answer it instead of moving the caret behind it (#1597) |
|
||||
| 2026-10-14 | trivial | Copy and Cut put HTML beside the text on the system clipboard for every selection, not only one holding an equation, so pasting into a browser, mail client or another word processor keeps bold, italic, fonts and sizes (desktop, through arboard; Wayland without data-control and the web: text only) (#1523) |
|
||||
| 2026-10-15 | trivial | Web: preferences (theme, interface size, editing options…) are kept in the browser's `localStorage` between visits, as the desktop app keeps `ui.json`; before, a reload brought back light mode and the defaults (#2132) |
|
||||
| 2026-10-14 | trivial | The app opens on the Home tab every launch, as in Word: the active ribbon tab is no longer restored from `ui.json` between runs. Going File and back to the editor still returns to the tab that was showing, contextual tabs included |
|
||||
| 2026-10-13 | trivial | The caret is black or white by contrast with the paper, page colour, dark page, shading or highlight under it (#299) |
|
||||
| 2026-10-13 | trivial | Clicks on ribbon and status-bar chrome that run no command button (Styles and style-set gallery tiles, tab names, empty ribbon space, View check boxes, the status bar's view buttons and language menu) no longer take keyboard focus from the page: the caret stays and typing continues, as in Word; text fields such as the font size box still take focus. Tabs, gallery tiles and the status bar's view buttons report their name and state to screen readers (#1093) |
|
||||
@@ -205,3 +214,4 @@ labels unambiguous. Those extra-number assignments have not been compared with W
|
||||
| 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) |
|
||||
| 2026-10-12 | trivial | Recipient List dialog: Filter and Sort section (conditions joined by And/Or, three sort keys, merge order column), .xls in the picker (#778) |
|
||||
| 2026-10-13 | trivial | Double-clicking the top or bottom margin of a page with no header or footer creates the one that page shows and edits it, as in Word; one left empty is taken away again on leaving; `insert.editHeader`/`insert.editFooter {"page"}` for agents (#960); making one is an undo step (`insert.createHeader`/`insert.createFooter`), refused read-only, linked from the first section |
|
||||
| 2026-10-15 | trivial | Narrow windows: the tab strip keeps the room of Comments / Editing / Share and gives way in order (tab padding, Comments and Editing labels, Share label, plain tabs into », active and contextual labels cut with …, last the active tab into »); File never leaves the strip. Public sources say Word keeps those buttons in view and shrinks the tabs; the » menu is ours (#2026) |
|
||||
|
||||
Reference in New Issue
Block a user