Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor d9e2b92e41 Merge main into fix/missing-pack-bar
Keep the missing-pack-bar entry, the one-language-list entry and main's
craft-i18n 9d6d54d region-name pin in the parity changelog.
2026-11-03 21:39:38 -05:00
Dylan M. Taylor d97e2f3a29 Merge pull request #5513 from storytold/fix/one-language-list
CP4.3: one language list: Language pack languages in Set Proofing Language, Review › Language and the status bar
2026-11-03 21:39:17 -05:00
Dylan M. Taylor 03fba6cb10 Merge main into fix/one-language-list
Resolve the localization-parity changelog conflict by keeping both the
one-language-list entry and main's craft-i18n 9d6d54d region-name pin.
2026-11-03 21:18:15 -05:00
Martin BallandClaude Opus 5.5 ea25e5dd3a CP4.3: craft-i18n.rev at 9d6d54d (craft-i18n#145): region names and the Language (Region) template in 35 locales (#5522)
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 03:12:45 +01:00
Martin BallandClaude Sonnet 5.5 68512842aa CP4.3: merge fix/one-language-list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-04 01:24:26 +00:00
Martin BallandClaude Sonnet 5.5 3e9e36796c CP4.3: localization-parity proofing bullet reads once
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-04 01:24:26 +00:00
Martin BallandClaude Sonnet 5.5 fa37c9adbe CP4.3: gap 8 names the missing-pack bar on a fresh install and the caret's typing language
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-04 01:14:30 +00:00
Martin Ball 26c662961d CP4.3: merge fix/one-language-list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco

; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
;	docs/localization-parity.md
2026-11-04 01:14:25 +00:00
Martin BallandClaude Sonnet 5.5 29f99006e0 CP4.3: a language and region name is translated in parts, not looked up whole
Swedish (Finland) is built at run time, so no catalog row can match it. One helper
(ribbon::display_name) now translates the language, the region and the {language} ({region})
template separately and joins them; the Set Proofing Language list, the Language menu, the status
bar and the dialog all use it. English output is unchanged and matching never uses a name.
gap 8 no longer describes the missing-pack bar, which is in its own branch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-04 01:14:05 +00:00
Martin Ball 26a8182e91 CP4.3: merge origin/main (craft-i18n at cde4900)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco

; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
;	docs/localization-parity.md
2026-11-04 01:00:54 +00:00
Martin BallandClaude Opus 5.5 d64ef7c3f9 CP4.3: craft-i18n.rev at cde4900 (craft-i18n#136): Options › Language, missing-pack bar and Language pack language-name rows in 35 locales (#5518)
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 00:57:30 +00:00
Martin BallandClaude Sonnet 5.5 bff0d1d925 #3867 follow-up: header repeats count canvas members, one budget per layout; contours and chain widths don't depend on cache history (#4385)
* #3867: review — header repeats, picture outlines and linked-box widths bounded the same way every layout

- Header repeats: a Clip (floating Drawing Canvas) counts its members, depth-bounded, so a 1000-member canvas no longer counts as one item (CodeRabbit thread 4373103686); the budget is per layout (Ctx), not per table.
- Tight-wrap outlines: each cache entry stores its picture's pixel cost; a cached hit is charged to the layout's decode budget like a decode, so cold and warm layouts (and the CLI) wrap alike.
- Linked boxes: widths are budgeted by laid-out work (MAX_FLOW_WORK), not 16 widths, so ordinary stories keep exact widths for every box.
- header_rows.contains -> index compare; layout-parity doc revision.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2

* #3867: review — a picture that fails to decode still costs its pixels when cached; header repeats restored on retry

- contour: Alphas records the pixels each picture charged (costs map), also when the body then fails to decode, so the cache entry's cost is right and warm layouts charge it like cold ones.
- Snap saves and restores ctx.header_repeats so balance_pass retries don't keep their charge.
- Tests: truncated large PNG cold vs warm; canvas header row end to end through place_table; linked-chain bound <= 8; vertical box's layout differs from every other box's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2

* #3867: CodeRabbit — header and linked-box budgets count every clip descendant

placed_cost counts iteratively (explicit stack, stops past the limit) and is shared by flow_work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2

* #3867: docs — progress log, gap 20 note for layout resource caps and linear-time passes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2

* #3867: review — roadmap gives each cap its unit: header items, wrap-cache pixels, linked text box lines and items

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #3867: CodeRabbit — the vertical-box check runs past the work cap too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #3867: review — a linked chain's work counts the story it lays out, not only the lines it shows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #3867: review — header-row cost import follows main's explicit layout imports (#4501)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #3867: review — a test pins the header repeats that column balancing gives back; docs keep main's caps row and both gap status lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:45:19 +00:00
Martin Ball 9819939d98 Merge branch 'fix/one-language-list' into fix/missing-pack-bar 2026-11-04 00:12:20 +00:00
Martin BallandClaude Opus 5.5 94b89d95d6 CP4.3: review — Set Proofing Language lists every built-in and Language pack language, not 38 (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-04 00:12:18 +00:00
Martin BallandClaude Sonnet 5.5 efb5071526 CP4.3: the missing-pack bar works on a fresh install and counts the caret's language
With no list of packs yet the bar offers a language with no dictionary and Install fetches the
list, then the pack. The caret's typing language counts, the spelling context menu heading is in
the interface language, and closing the bar is remembered per document, pack locale and tag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 23:58:37 +00:00
Martin BallandClaude Sonnet 5.5 ae151ff341 CP4.3: one language list: Language pack languages in Set Proofing Language, Review > Language and the status bar
Every published pack is named in English, Valencian is ca-ES-valencia, and rows are matched by
tag (language, script, region, variant), never by the name shown. One region helper
(packs::is_region, packs::without_region) next to pack_for_tag serves the engine and the UI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 23:58:24 +00:00
FredAppsandClaude Opus 5.5 969df57ea2 About: credits tables fit without side-scrolling and their columns resize (#5506)
The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.

The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.

The About dialog opens 820 points wide, so every column fits at its natural width.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:12:07 +00:00
Martin BallandClaude Opus 5.5 682ce4318a CP4.3: craft-proof pin at craft-libs 01315d9: French elision suggestions first; Valencian reads its pack (#5509)
* CP4.3: craft-proof pin at craft-libs main 01315d9 (#589, #590): French elision suggestions first; ca-ES-valencia reads the ca-valencia pack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco

* CP4.3: review — ROADMAP has one status record; the older copy left by a merge is dropped (CodeRabbit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco

* CP4.3: review — French is proofed from its Language pack in the support tables, not "no proofing" (CodeRabbit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 23:06:35 +00:00
Martin BallandClaude Opus 5.5 ddf88c39df CP4.3: craft-i18n.rev at 20467b6 (#116 merged): builds embed the Language data language-packs-2 was built from (#5385)
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:10:18 +00:00
30 changed files with 2242 additions and 233 deletions
Generated
+35 -6
View File
@@ -1103,7 +1103,7 @@ dependencies = [
[[package]]
name = "craft-proof"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
dependencies = [
"log",
"miniz_oxide 0.9.1",
@@ -1520,6 +1520,19 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1587,6 +1600,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -7017,6 +7050,7 @@ dependencies = [
"craft-update",
"craft-window",
"egui",
"egui_extras",
"egui_kittest",
"image",
"js-sys",
@@ -7580,11 +7614,6 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+4 -3
View File
@@ -24,10 +24,11 @@ wordcraft-pdf = { path = "crates/pdf" }
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# craft-proof is storytold/craft-libs main at the merge of #590 (ca-ES-valencia reads the ca-valencia
# pack), after #589 (French elision suggestions first) and #497 (flag-based compounding), which also
# has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# `language-pack` kind is #486 (CP4.1).
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", version = "=0.1.0" }
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
wordcraft-engine = { path = "crates/engine" }
+26
View File
@@ -0,0 +1,26 @@
# WordCraft
A clean-room, Rust-native word processor. These are the words its interface, docs and code use for
the same things.
## Languages and proofing
**Interface language**:
The language of WordCraft's own menus, buttons and messages.
_Avoid_: Display language, UI locale
**Proofing language**:
The language a piece of text is marked as, which decides how its spelling, grammar and hyphenation
are checked. A language is told apart from another by its tag (language, script, region, variant),
never by the name a list shows for it.
_Avoid_: Text language, editing language, document language
**Language pack**:
A downloadable bundle of a language's spelling dictionary and hyphenation patterns.
_Avoid_: Dictionary download, language data (that is the embedded data)
**Language list**:
The proofing languages offered in Set Proofing Language, Review › Language and the status bar's
menu: the languages WordCraft names, plus those of every installed (and, in Set Proofing Language,
every available) Language pack, each once.
_Avoid_: Language menu (that is only one of the places it is shown)
+10 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
059cbf95c2a3eb95d637ca8584faca1129d17f48
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
+197
View File
@@ -229,6 +229,21 @@ mod tests {
assert_eq!(packs::pack_for_tag("pt-PT", &packs).map(|p| p.locale.as_str()), Some("pt-pt"));
assert_eq!(packs::pack_for_tag("pt_BR", &packs).map(|p| p.locale.as_str()), Some("pt"));
assert_eq!(packs::pack_for_tag("nl-BE", &packs).map(|p| p.locale.as_str()), Some("nl"));
// A script-qualified tag finds its script's pack before the language's (CP4.3: Serbian
// (Latin) chosen in the Language dialog is proofed by `sr-latn`, not the Cyrillic `sr`).
packs.push(Pack { locale: "sr".into(), ..packs[0].clone() });
packs.push(Pack { locale: "sr-latn".into(), ..packs[0].clone() });
assert_eq!(packs::pack_for_tag("sr-Latn-RS", &packs).map(|p| p.locale.as_str()), Some("sr-latn"));
assert_eq!(packs::pack_for_tag("sr-Cyrl-RS", &packs).map(|p| p.locale.as_str()), Some("sr"));
assert_eq!(packs::pack_for_tag("sr-latn", &packs).map(|p| p.locale.as_str()), Some("sr-latn"));
assert_eq!(packs::pack_for_tag("-", &packs), None);
assert_eq!(packs::pack_for_tag("nl--", &packs).map(|p| p.locale.as_str()), Some("nl"));
// A variant after the region is its language-and-variant pack's; the region alone isn't.
packs.push(Pack { locale: "ca-valencia".into(), ..packs[0].clone() });
assert_eq!(packs::pack_for_tag("ca-ES-valencia", &packs).map(|p| p.locale.as_str()), Some("ca-valencia"));
assert_eq!(packs::pack_for_tag("ca_es_VALENCIA", &packs).map(|p| p.locale.as_str()), Some("ca-valencia"));
assert_eq!(packs::pack_for_tag("ca-ES", &packs).map(|p| p.locale.as_str()), Some("ca"));
assert_eq!(packs::pack_for_tag("ca", &packs).map(|p| p.locale.as_str()), Some("ca"));
assert_eq!(packs::pack_for_tag("en-GB", &packs), None);
assert_eq!(packs::pack_for_tag("", &packs), None);
assert!(packs::valid_locale("sr-latn"));
@@ -486,5 +501,187 @@ mod tests {
assert_eq!(messages(pt_pt), messages(ProofLang::Pt), "Portugal text gets the Portuguese grammar checks");
}
/// The one region test and region-dropping step the engine and the UI share: two letters or
/// three digits after the language; hostile text never panics.
#[test]
fn a_region_is_two_letters_or_three_digits() {
for r in ["NL", "nl", "419", "ZZ"] {
assert!(packs::is_region(r), "{r}");
}
for r in ["", "n", "nld", "4190", "41", "Latn", "valencia", "é1", "\u{202e}\u{202e}"] {
assert!(!packs::is_region(r), "{r:?}");
}
for (tag, bare) in [
("nl-NL", "nl"),
("sr-Latn-RS", "sr-Latn"),
("ca-ES-valencia", "ca-valencia"),
("es_419", "es"),
("pt", "pt"),
("en-US-x-test", "en-x-test"),
("", ""),
("-", "-"),
] {
assert_eq!(packs::without_region(tag), bare, "{tag}");
}
// The language itself is never a region, even when it looks like one.
assert_eq!(packs::without_region("de"), "de");
let _ = packs::without_region(&"-".repeat(10_000));
let _ = packs::without_region("\u{0}-\u{202e}");
}
/// A font store that has never fetched the list (a fresh install), or whose cached list is
/// for an older pin: `index` has nothing until a fetch.
struct Uncached(FixedBackend);
impl Backend for Uncached {
fn index(&self) -> Result<Vec<Pack>, String> {
Ok(Vec::new())
}
fn fetch_index(&self, c: &packs::Cancel) -> Result<Vec<Pack>, String> {
self.0.fetch_index(c)
}
fn network_on(&self) -> bool {
self.0.network_on()
}
fn enable_network(&self) -> Result<(), String> {
self.0.enable_network()
}
fn install(&self, pack: &Pack, cancel: &packs::Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
self.0.install(pack, cancel, progress)
}
fn remove(&self, locale: &str) -> Result<(), String> {
self.0.remove(locale)
}
fn packs_dir(&self) -> Option<std::path::PathBuf> {
None
}
fn installed(&self) -> Vec<packs::Installed> {
self.0.installed()
}
}
/// The sample packs and Greenlandic (`kl`), a language no system has a dictionary for.
fn with_greenlandic() -> FixedBackend {
let mut b = FixedBackend::sample();
let kl = Pack { locale: "kl".into(), name: "Kalaallisut".into(), ..b.packs[0].clone() };
b.packs.push(kl);
b
}
fn with_uncached<T>(b: FixedBackend, f: impl FnOnce(&mut Session, &Uncached) -> T) -> T {
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = Reset;
let b = Arc::new(Uncached(b));
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
let mut s = Session::new(wordcraft_doc::Document::new());
f(&mut s, &b)
}
/// Martin's test of a fresh install: Greenlandic text, no list fetched yet. The bar still
/// offers the language (never English, nor one with a dictionary); getting it fetches the
/// list and installs its pack. A language the list then has no pack for (Klingon) stops
/// being offered.
#[test]
fn with_no_list_yet_the_bar_still_offers_the_language() {
with_uncached(with_greenlandic(), |s, b| {
s.run("document.setText", &json!({"text": "Hello there\nAluu, qanoq ippit?\ntlhIngan Hol"})).unwrap();
for (para, lang) in [(1, "kl-GL"), (2, "tlh")] {
s.run("caret.set", &json!({"pos": {"block": para, "off": 0}})).unwrap();
s.run("select.paragraph", &json!({})).unwrap();
s.run("review.language", &json!({"lang": lang})).unwrap();
}
assert!(packs::available().is_empty() && packs::missing(s).is_empty(), "no list: no pack to offer");
for tag in ["kl-GL", "tlh"] {
assert!(wordcraft_proof::missing_dictionary(Some(tag)).is_some(), "{tag} has no dictionary here");
}
assert_eq!(packs::unlisted(s), ["kl-GL", "tlh"], "English is never offered");
// Downloading off: getting it fails up front, and asks for nothing.
assert!(packs::start_get("kl-GL").is_err());
assert!(!packs::fetched());
b.0.network.store(true, Ordering::Relaxed);
assert_eq!(packs::start_get("kl-GL"), Ok(false), "fetched and installed here (no background thread)");
assert!(packs::is_installed("kl"));
assert!(packs::fetched());
assert!(packs::unlisted(s).is_empty(), "the list is known now: Klingon, which it lacks, isn't offered");
assert!(packs::missing(s).is_empty());
});
}
/// Review: a get refused because downloading is off isn't remembered, so a later fetch (the
/// person's Refresh in Options) doesn't install it unasked.
#[test]
fn a_refused_get_is_not_installed_by_a_later_fetch() {
with_uncached(with_greenlandic(), |_, b| {
assert!(packs::start_get("kl-GL").is_err(), "downloading off");
b.0.network.store(true, Ordering::Relaxed);
assert_eq!(packs::start_refresh(), Ok(false));
assert!(packs::find("kl").is_some(), "the list has it");
assert!(!packs::is_installed("kl"), "but nobody asked for it after the refusal");
});
}
/// The list was fetched, but lists nothing for the language: the bar stops offering it.
#[test]
fn a_language_the_fetched_list_lacks_is_no_longer_offered() {
with_uncached(FixedBackend::sample(), |s, b| {
s.run("document.setText", &json!({"text": "tlhIngan Hol"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("review.language", &json!({"lang": "tlh"})).unwrap();
assert_eq!(packs::unlisted(s), ["tlh"]);
b.0.network.store(true, Ordering::Relaxed);
assert_eq!(packs::start_get("tlh"), Ok(false));
assert!(packs::installed().is_empty(), "nothing to install");
assert!(packs::unlisted(s).is_empty() && packs::missing(s).is_empty());
});
}
/// With downloading already on and no list (or one for an older pin), the window fetches it
/// once in the background, asking nothing; never with downloading off, never twice.
#[test]
fn a_missing_list_is_fetched_once_when_downloading_is_on() {
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = Reset;
let b = Arc::new(Uncached(with_greenlandic()));
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
assert!(!packs::refresh_if_stale(), "no background thread (the CLI, MCP): never");
packs::set_background(Some(Arc::new(|| {})));
assert!(!packs::refresh_if_stale(), "downloading off: no request");
b.0.network.store(true, Ordering::Relaxed);
assert!(!packs::refresh_if_stale(), "asked once a session: turned on later, the person's own click fetches");
// A new session (the backend set again) with downloading on.
packs::set_backend(Some(b.clone() as Arc<dyn Backend>));
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "Aluu"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("review.language", &json!({"lang": "kl"})).unwrap();
let before = packs::finished();
assert!(packs::refresh_if_stale());
wait_for_finish(before);
assert!(packs::fetched());
assert_eq!(packs::missing(&s).into_iter().map(|p| p.locale).collect::<Vec<_>>(), ["kl"], "the bar offers the pack");
assert!(!packs::refresh_if_stale(), "once a session");
assert!(packs::installed().is_empty(), "nothing installed without a click");
}
/// Martin's test: choosing a language with nothing selected (the caret only) sets the typing
/// language; the bar offers its pack before anything is typed.
#[test]
fn the_caret_typing_language_asks_for_its_pack() {
with_backend(FixedBackend::sample(), |s, _| {
s.run("document.setText", &json!({"text": "Hello there"})).unwrap();
s.run("caret.set", &json!({"pos": {"block": 0, "off": 11}})).unwrap();
assert!(packs::missing(s).is_empty());
s.run("review.language", &json!({"lang": "nl-NL"})).unwrap();
assert!(s.sel.is_collapsed());
assert!(!packs::document_languages(s).iter().any(|l| l == "nl-NL"), "no Dutch text yet");
assert_eq!(packs::caret_language(s).as_deref(), Some("nl-NL"));
assert_eq!(packs::missing(s).into_iter().map(|p| p.locale).collect::<Vec<_>>(), ["nl"]);
// Do not check spelling or grammar at the caret: nothing asked.
s.run("review.language", &json!({"lang": "nl-NL", "noProof": true})).unwrap();
assert_eq!(packs::caret_language(s), None);
assert!(packs::missing(s).is_empty());
});
}
use crate::packs::Pack;
}
+181 -9
View File
@@ -143,6 +143,9 @@ pub fn set_backend(backend: Option<Arc<dyn Backend>>) {
let dir = backend.as_ref().and_then(|b| b.packs_dir());
*BACKEND.write().unwrap_or_else(PoisonError::into_inner) = backend;
set_index(None);
FETCHED.store(false, Ordering::Relaxed);
AUTO_REFRESHED.store(false, Ordering::Relaxed);
WANTED.lock().unwrap_or_else(PoisonError::into_inner).clear();
jobs().errors.clear();
wordcraft_proof::packs::set_packs_dir(dir);
}
@@ -219,6 +222,8 @@ pub fn refresh(cancel: &Cancel) -> Result<Vec<Pack>, String> {
}
let index = clean_index(b.fetch_index(cancel)?);
set_index(Some(index.clone()));
FETCHED.store(true, Ordering::Relaxed);
install_wanted(&index);
Ok(index)
}
@@ -575,15 +580,50 @@ pub fn licence_texts() -> Vec<(String, String)> {
/// About › Language data's title for SCOWL's notices.
pub const SCOWL_TITLE: &str = "English spelling dictionaries (SCOWL)";
/// The pack locale for a document language tag: the whole tag (`pt-pt`), else its language
/// (`nl` for `nl-NL`), whichever the index has. `None` for English and tags with no pack.
/// The pack locale for a document language tag: the whole tag (`pt-pt`), else each shorter
/// prefix of it (`sr-latn` for `sr-Latn-RS`, `nl` for `nl-NL`), the first the index has. A
/// variant after the region is also tried without the region, before the region alone
/// (`ca-ES-valencia` is the `ca-valencia` pack's), as craft-proof looks its dictionary up. `None`
/// for English and tags with no pack.
pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
let tag = tag.trim().replace('_', "-").to_ascii_lowercase();
let base = tag.split('-').next().unwrap_or("");
pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c))
}
/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or
/// three digits (`419`)? The one test of what a region is; the lists, the menus and
/// [`pack_for_tag`] all use it.
pub fn is_region(subtag: &str) -> bool {
(subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))
}
/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia`
/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept.
/// The language itself (the first subtag) is never a region.
pub fn without_region(tag: &str) -> String {
tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::<Vec<&str>>().join("-")
}
/// The pack locales [`pack_for_tag`] tries for `tag`, best first.
fn pack_candidates(tag: &str) -> Vec<String> {
// A tag is a handful of short subtags; anything longer is junk and only its start is read.
let tag: String = tag.trim().chars().take(64).collect::<String>().replace('_', "-").to_ascii_lowercase();
let parts: Vec<&str> = tag.split('-').collect();
let base = parts.first().copied().unwrap_or("");
if base.is_empty() || base == "en" {
return None;
return Vec::new();
}
packs.iter().find(|p| p.locale == tag).or_else(|| packs.iter().find(|p| p.locale == base))
let mut out: Vec<String> = Vec::new();
for n in (1..=parts.len()).rev() {
let prefix = parts.get(..n).unwrap_or_default().join("-");
// The prefix, then without its region: the variant's pack before the region alone.
let stripped = without_region(&prefix);
for c in [prefix, stripped] {
if !out.contains(&c) {
out.push(c);
}
}
}
out
}
/// The languages (`w:lang`, resolved through the styles) of the body text, in first-seen order.
@@ -671,8 +711,33 @@ fn has_pack_for_tag(tag: &str) -> bool {
PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some())
}
/// The packs the document's text asks for that are available and not installed: what the
/// missing-pack bar offers. Never a language without a pack, nor one proofing can't use.
/// The language new text at the caret gets (its typing language, resolved through the styles
/// and the document default), unless it is marked Do not check spelling or grammar: choosing a
/// language with nothing selected asks for its pack before anything is typed in it.
pub fn caret_language(s: &Session) -> Option<String> {
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
let rc = s.doc.styles.resolve_char(style, &s.typing_props());
if rc.no_proof {
return None;
}
Some(rc.lang.unwrap_or_else(|| "en-US".into()))
}
/// The languages the missing-pack bar looks at: the body text's ([`document_languages`]), then
/// the caret's typing language ([`caret_language`]).
pub fn wanted_languages(s: &Session) -> Vec<String> {
let mut out = document_languages(s);
if let Some(tag) = caret_language(s)
&& !out.contains(&tag)
{
out.push(tag);
}
out
}
/// The packs the document's text (or the caret's typing language) asks for that are available
/// and not installed: what the missing-pack bar offers. Never a language without a pack, nor one
/// proofing can't use.
pub fn missing(s: &Session) -> Vec<Pack> {
let packs = available();
if packs.is_empty() {
@@ -680,7 +745,7 @@ pub fn missing(s: &Session) -> Vec<Pack> {
}
let installed = installed();
let mut out: Vec<Pack> = Vec::new();
for tag in document_languages(s) {
for tag in wanted_languages(s) {
if proofable(&tag)
&& let Some(p) = pack_for_tag(&tag, &packs)
&& !installed.iter().any(|i| i.locale == p.locale)
@@ -692,6 +757,113 @@ pub fn missing(s: &Session) -> Vec<Pack> {
out
}
/// The index was fetched this session (since the backend was set): what it lists is then the
/// whole answer, even when it lists nothing.
static FETCHED: AtomicBool = AtomicBool::new(false);
/// The automatic fetch of a missing or stale list ([`refresh_if_stale`]) was tried.
static AUTO_REFRESHED: AtomicBool = AtomicBool::new(false);
/// Languages (document tags) whose pack the person asked for before the list was known
/// ([`start_get`]): installed once a fetch lists one.
static WANTED: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// Whether the index was fetched since the backend was set.
pub fn fetched() -> bool {
FETCHED.load(Ordering::Relaxed)
}
/// The languages the document (or the caret) asks for while there is no list of packs yet (a
/// fresh install, or a list cached for an older pin): proofable, not English, with no dictionary
/// and no pack installed, one per proofing language, as the document tags them (`tr-TR`). The
/// bar offers to get them: the list is fetched first, and the language then installed if it has
/// a pack. Nothing once a list is known or was fetched this session ([`missing`] then answers).
pub fn unlisted(s: &Session) -> Vec<String> {
if backend().is_none() || fetched() || available_len() > 0 {
return Vec::new();
}
let installed = installed();
let mut seen: Vec<wordcraft_proof::ProofLang> = Vec::new();
let mut out = Vec::new();
for tag in wanted_languages(s) {
let base = tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase();
let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag));
if base.is_empty()
|| base == "en"
|| !proofable(&tag)
|| wordcraft_proof::missing_dictionary(Some(&tag)).is_none()
|| seen.contains(&lang)
|| installed.iter().any(|i| i.locale == base || tag.eq_ignore_ascii_case(&i.locale))
{
continue;
}
seen.push(lang);
out.push(tag);
}
out
}
/// The person asked for `tag`'s pack before the list was known (the bar's Install, after their
/// consent turned downloading on): fetch the list ([`start_refresh`]), then install the pack it
/// lists for `tag`, if any; with none, the bar stops offering it. Downloading must be on.
/// Returns whether it is still running.
pub fn start_get(tag: &str) -> Result<bool, String> {
let tag = tag.trim();
if tag.is_empty() || tag.len() > MAX_LOCALE_LEN {
return Err(format!("`{tag}` isn't a language tag"));
}
if let Some(p) = pack_for_tag(tag, &available()) {
return start_install(&p.locale);
}
let b = backend().ok_or_else(no_store)?;
if !b.network_on() {
// Never queued: a later fetch mustn't install what was refused.
return Err(network_off());
}
let queued = {
let mut w = WANTED.lock().unwrap_or_else(PoisonError::into_inner);
let new = !w.iter().any(|t| t == tag) && w.len() < MAX_DOC_LANGUAGES;
if new {
w.push(tag.to_string());
}
new
};
let r = start_refresh();
if r.is_err() && queued {
WANTED.lock().unwrap_or_else(PoisonError::into_inner).retain(|t| t != tag);
}
r
}
/// After a fetch: install the packs asked for before the list was known ([`start_get`]).
fn install_wanted(index: &[Pack]) {
let wanted = std::mem::take(&mut *WANTED.lock().unwrap_or_else(PoisonError::into_inner));
for tag in wanted {
let Some(p) = pack_for_tag(&tag, index) else { continue };
if is_installed(&p.locale) {
continue;
}
if let Err(e) = start_install(&p.locale) {
set_error(&p.locale, Some(e));
}
}
}
/// Once a session (the first time it is asked once requests run on a thread, [`runs_in_background`]:
/// the window), with downloading already on and no list (none cached, or one cached for an older
/// pin): fetch it in the background, so the bar can offer packs. The person's consent is the
/// downloading setting itself; nothing is asked. Its failure is recorded for Options (""). The
/// window asks every frame: after the first time this reads nothing (the downloading setting is
/// a file). Returns whether a fetch was started.
pub fn refresh_if_stale() -> bool {
let Some(b) = backend() else { return false };
if !runs_in_background() || AUTO_REFRESHED.swap(true, Ordering::Relaxed) {
return false;
}
if !b.network_on() || fetched() || fetching() || available_len() > 0 {
return false;
}
start_refresh().is_ok()
}
/// A backend with a fixed list and installs that only record themselves: tests and `ui_shot`.
#[derive(Default)]
pub struct FixedBackend {
+48 -1
View File
@@ -206,6 +206,11 @@ pub(crate) struct Alphas {
/// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to
/// the bytes, as [`crate::LayoutCache`]'s outlines do.
seen: HashMap<(usize, usize), Option<Arc<Alpha>>>,
/// Pictures whose cost was charged for a lasting outline found in [`crate::LayoutCache`]
/// ([`Alphas::charge`]) without decoding them in this layout.
charged: HashSet<(usize, usize)>,
/// Pixels each picture took of the budget (also when it then failed to decode), by bytes.
costs: HashMap<(usize, usize), u64>,
/// Pictures refused for the budget (bytes' address and length), not looked at again.
refused: HashSet<(usize, usize)>,
/// Pixels decoded so far.
@@ -223,9 +228,16 @@ impl Alphas {
Some(alpha) => alpha.clone(),
None if self.refused.contains(&key) => return Err(OverBudget),
None => {
let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| {
// A picture already charged for (its outline was cached) isn't charged twice.
let mut free = 0u64;
let paid = self.charged.contains(&key);
let before = self.decoded;
let alpha = decode_alpha(bytes, if paid { &mut free } else { &mut self.decoded }).inspect_err(|_| {
self.refused.insert(key);
})?;
if !paid {
self.costs.insert(key, self.decoded.saturating_sub(before));
}
let alpha = alpha.map(Arc::new);
self.seen.insert(key, alpha.clone());
alpha
@@ -233,6 +245,41 @@ impl Alphas {
};
Ok(alpha.and_then(|a| alpha_in_crop(&a, crop)))
}
/// The pixels picture `bytes` took of the budget when it was decoded in this layout (0:
/// not decoded, or none to look at; a picture that failed to decode still cost its pixels).
pub(crate) fn cost(&self, bytes: &[u8]) -> u64 {
self.costs.get(&(bytes.as_ptr() as usize, bytes.len())).copied().unwrap_or(0)
}
/// Charges the budget for picture `bytes`, whose outline was found kept from an earlier
/// layout (`cost`: the pixels it took then), as decoding it now would: pictures get an outline
/// by their place in the document, not by what the cache holds. [`OverBudget`] when it
/// wouldn't have fitted: its rectangle is its outline in this layout.
pub(crate) fn charge(&mut self, bytes: &[u8], cost: u64) -> Result<(), OverBudget> {
let key = (bytes.as_ptr() as usize, bytes.len());
if self.seen.contains_key(&key) || self.charged.contains(&key) {
return Ok(());
}
if self.refused.contains(&key) {
return Err(OverBudget);
}
if cost == 0 {
return Ok(());
}
match self.decoded.checked_add(cost).filter(|t| *t <= MAX_DECODED_PIXELS) {
Some(total) => {
self.decoded = total;
self.charged.insert(key);
self.costs.insert(key, cost);
Ok(())
}
None => {
self.refused.insert(key);
Err(OverBudget)
}
}
}
}
/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures
+13 -3
View File
@@ -69,6 +69,9 @@ pub(crate) struct Ctx<'a> {
pub(crate) hf: Arc<HfLinks<'a>>,
/// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]).
pub(crate) alphas: contour::Alphas,
/// Items re-placed as repeated table header rows so far, in the whole layout (see
/// [`MAX_HEADER_ITEMS`]).
pub(crate) header_repeats: usize,
}
struct ParaEnvInput<'a> {
@@ -144,10 +147,14 @@ pub(crate) fn with_pin<T>(
/// an address names the same bytes only while their allocation lives: the weak reference keeps
/// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address
/// and length and be taken for it.
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>)>;
///
/// Each also holds the pixels its picture took of a layout's decoding budget, which a layout
/// finding the entry charges as if it had decoded the picture ([`contour::Alphas::charge`]).
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>, u64)>;
/// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this
/// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it.
/// One kept from before is charged to the budget as if decoded now.
pub(crate) fn picture_outline(
outlines: &mut Outlines,
alphas: &mut contour::Alphas,
@@ -155,14 +162,17 @@ pub(crate) fn picture_outline(
bytes: &Arc<Vec<u8>>,
crop: [f32; 4],
) -> Option<Arc<Vec<(f32, f32)>>> {
if let Some((_, o)) = outlines.get(&key) {
if let Some((_, o, cost)) = outlines.get(&key) {
// Kept from an earlier layout, but it still costs this one's budget: the same pictures
// get outlines whatever the cache holds.
alphas.charge(bytes, *cost).ok()?;
return o.clone();
}
let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new);
if outlines.len() >= 256 {
outlines.clear();
}
outlines.insert(key, (Arc::downgrade(bytes), outline.clone()));
outlines.insert(key, (Arc::downgrade(bytes), outline.clone(), alphas.cost(bytes)));
outline
}
+54 -1
View File
@@ -109,11 +109,64 @@ impl Linked {
/// Header rows are re-placed on every page a table spills onto, so a table is O(header items ×
/// pages) placed items — and an exact-height header row keeps all its overflowing text without
/// growing, so a few KB of document could otherwise fill memory. Once this many items (a row
/// counts at least one) have been repeated across a table, later pages carry no header rows;
/// counts at least one) have been repeated across the whole layout (every table together), later pages carry no header rows;
/// pagination still finishes. Far above any real table (three ordinary header rows over some
/// 5 000 pages).
pub(crate) const MAX_HEADER_ITEMS: usize = 500_000;
#[cfg(test)]
thread_local! {
/// A smaller [`MAX_HEADER_ITEMS`] for tests, so a short table can reach it.
pub(crate) static HEADER_ITEMS_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_HEADER_ITEMS) };
}
/// [`MAX_HEADER_ITEMS`], or the test cap ([`HEADER_ITEMS_CAP`]) in tests.
pub(crate) fn header_items_cap() -> usize {
#[cfg(test)]
return HEADER_ITEMS_CAP.with(|c| c.get());
#[cfg(not(test))]
MAX_HEADER_ITEMS
}
/// What re-placing `it` costs against [`MAX_HEADER_ITEMS`] (and laying it out against
/// [`textbox::MAX_FLOW_WORK`]): one, and for a clipped group (a floating Drawing Canvas) every
/// member below it, nested groups included, as placing clones them all. Counted with an explicit
/// stack, and counting stops once the total passes `limit` (the result is then just over it), so
/// neither deep nesting nor a huge group makes the count itself costly.
pub(crate) fn placed_cost(it: &Placed, limit: usize) -> usize {
placed_cost_by(it, limit, |_| 1)
}
/// [`placed_cost`] with each item (the group itself and every member) costing `cost` of it.
pub(crate) fn placed_cost_by(it: &Placed, limit: usize, cost: impl Fn(&Placed) -> usize) -> usize {
let mut count = 0usize;
let mut stack: Vec<std::slice::Iter<Placed>> = Vec::new();
let mut next = Some(it);
loop {
let cur = match next.take() {
Some(c) => c,
None => loop {
match stack.last_mut() {
None => return count,
Some(iter) => match iter.next() {
Some(c) => break c,
None => {
stack.pop();
}
},
}
},
};
count = count.saturating_add(cost(cur));
if count > limit {
return count;
}
if let Placed::Clip { items, .. } = cur {
stack.push(items.iter());
}
}
}
/// Header tables can displace a non-overlapping body table. Page-positioned drawings keep
/// their own wrapping rules, so only table areas are retained from the header layout.
pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
+3
View File
@@ -66,6 +66,9 @@ pub use floats::{is_floating, shape_figure_alt};
use floats::{left_out_objects, wrap_area};
#[cfg(test)]
use headers::MAX_HEADER_ITEMS;
#[cfg(test)]
use headers::placed_cost;
use headers::placed_cost_by;
pub use notes::DECOR_STORY;
pub(crate) use notes::NOTE_SEP_W;
use notes::{MAX_NOTE_PASSES, footnote_pages};
+4
View File
@@ -109,6 +109,7 @@ fn new_ctx<'a>(
floats_above: HashMap::new(),
hf: Arc::new(HfLinks::new(doc.sections())),
alphas: Default::default(),
header_repeats: 0,
}
}
@@ -560,6 +561,7 @@ struct Snap<'a> {
eq_count: u32,
page_field: u32,
boxes: usize,
header_repeats: usize,
}
impl<'a> Snap<'a> {
@@ -577,6 +579,7 @@ impl<'a> Snap<'a> {
eq_count: ctx.eq_count,
page_field: ctx.fields.page,
boxes: ctx.boxes.total(),
header_repeats: ctx.header_repeats,
}
}
@@ -599,6 +602,7 @@ impl<'a> Snap<'a> {
ctx.counters = self.counters.clone();
ctx.eq_count = self.eq_count;
ctx.fields.page = self.page_field;
ctx.header_repeats = self.header_repeats;
}
}
+13 -9
View File
@@ -2,7 +2,7 @@ use super::boxes::{BoxInput, clear_of_float_tables, layout_box};
use super::ctx::{Ctx, with_pct_base};
use super::floats::{WrapArea, align_in, body_wraps};
use super::flow::again;
use super::headers::{MAX_HEADER_ITEMS, header_table_areas};
use super::headers::{header_items_cap, header_table_areas, placed_cost};
use super::notes::table_notes;
use super::page::{PageBuilder, PageFrame, page_frame};
use super::placed::Placed;
@@ -127,10 +127,14 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
// A table in the text flow starts below the floating objects in its way.
below_floats(ctx, pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
let header_rows: Vec<usize> = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
// Items repeated so far, and what one more repeat of the header rows costs (see
// `MAX_HEADER_ITEMS`).
let mut header_repeats = 0usize;
let header_cost = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.items.len().max(1)).fold(0, usize::saturating_add);
// What one more repeat of the header rows costs (see `MAX_HEADER_ITEMS`; the items repeated
// so far are counted in the whole layout).
let header_cap = header_items_cap();
let header_cost = header_rows
.iter()
.filter_map(|i| tl.rows.get(*i))
.map(|r| r.items.iter().map(|it| placed_cost(it, header_cap)).fold(0, usize::saturating_add).max(1))
.fold(0, usize::saturating_add);
// Resolve kept chains once; nested paragraphs cannot keep their enclosing row.
let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect();
for i in (0..tl.rows.len().saturating_sub(1)).rev() {
@@ -180,7 +184,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
let table_at_top = pb.at_top();
for (ri, row) in tl.rows.iter().enumerate() {
// Word never splits a row of an exact height: it moves on whole.
let splittable = !header_rows.contains(&ri)
let splittable = ri >= header_rows.len()
&& !t
.rows
.get(ri)
@@ -237,9 +241,9 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
None => {}
}
pb.advance(block, body_top);
// Repeat header rows, up to this table's cap.
if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS {
header_repeats += header_cost;
// Repeat header rows, up to the layout's cap.
if ri >= header_rows.len() && ctx.header_repeats.saturating_add(header_cost) <= header_cap {
ctx.header_repeats = ctx.header_repeats.saturating_add(header_cost);
for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) {
place(pb, h);
}
+355 -8
View File
@@ -1,6 +1,7 @@
//! Hostile documents whose layout work or memory would otherwise grow far past their size: each
//! is bounded by a cap, and real documents stay well under it.
use crate::ctx::Outlines;
use crate::*;
use wordcraft_doc::props::HeightRule;
use wordcraft_doc::table::{MAX_COLS, Table};
@@ -36,10 +37,11 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 {
}
#[test]
fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() {
// Forty linked boxes, each a line deep and each a different width, over a long story: every
// width would lay the whole story out again. Past the cap, later boxes reuse the nearest
// width already laid out, so every box still shows its share of the story.
// width would lay the whole story out again. Past the work cap, later boxes reuse the
// nearest width already laid out, so every box still shows its share of the story.
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
let n = 40;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(200);
@@ -56,7 +58,85 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
lines.iter().filter_map(|it| if let Placed::Lines { para, .. } = it { Some(Arc::as_ptr(para) as usize) } else { None }).collect();
widths.sort_unstable();
widths.dedup();
assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len());
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
// The cap is spent after the first few layouts (each is far over 2000 / 8 work).
assert!(widths.len() <= 8, "{} widths laid the story out", widths.len());
}
#[test]
fn twenty_distinct_widths_of_an_ordinary_story_all_keep_their_width() {
// A real chain of 20 boxes, 100, 105, ... 195 pt wide: each is laid out at its own width.
let n = 20;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(200);
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 5.0 * i as f32, 28.0)).collect();
for w in ids.windows(2) {
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
}
let mut widths = story_layouts(&lay(&d), ids[0]);
assert_eq!(widths.len(), n, "every box shows the story");
widths.sort_unstable();
widths.dedup();
assert_eq!(widths.len(), n, "every box has a layout at its own width");
}
/// How many times story layouts ran while `doc` was laid out with the chain work cap at `cap`.
fn chain_layouts(d: &Document, cap: usize) -> usize {
textbox::FLOW_WORK_CAP.with(|c| c.set(cap));
textbox::FLOW_LAYOUTS.with(|c| c.set(0));
lay(d);
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
textbox::FLOW_LAYOUTS.with(|c| c.get())
}
/// `n` linked boxes `w0`, `w0 + 0.1`, ... pt wide (each its own layout) and a line deep, the
/// first holding `blocks`; the first box's id.
fn hairline_chain(d: &mut Document, n: usize, w0: f32, blocks: Vec<Arc<Block>>) -> u32 {
let ids: Vec<u32> = (0..n).map(|i| text_box(d, i, "", w0 + 0.1 * i as f32, 28.0)).collect();
for w in ids.windows(2) {
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
}
d.parts.get_mut(&ids[0]).unwrap().blocks = blocks;
ids[0]
}
#[test]
fn a_chain_whose_story_shows_little_still_pays_for_its_text() {
// All 256 boxes a hair apart in width, each showing one short visible line of a story that
// hides about 1 MB of text between them: each layout gives some 300 lines but works through
// the whole story. Charged by its lines alone, every width was laid out (256 times the story).
let n = wordcraft_doc::MAX_LINKED_BOXES;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let hidden = "Hidden words that never show. ".repeat(3500 / 30);
let hide = wordcraft_doc::props::CharProps { hidden: Some(true), ..Default::default() };
let story = (0..300)
.map(|k| {
let shown = format!("Line {k}.");
let mut p = Paragraph::with_text(&format!("{shown}{hidden}"), Default::default());
p.runs = vec![
wordcraft_doc::para::Run { len: shown.len(), props: Default::default() },
wordcraft_doc::para::Run { len: hidden.len(), props: hide.clone() },
];
p.touch();
para_block(p)
})
.collect();
hairline_chain(&mut d, n, 300.0, story);
// Ordinary stories of this many lines keep all 256 widths under this cap.
let layouts = chain_layouts(&d, 100_000);
assert!(layouts <= 8, "{layouts} layouts of a 1 MB story");
}
#[test]
fn a_chain_of_wide_boxes_pays_for_a_long_paragraph() {
// 256 wide boxes a hair apart over one long paragraph: few lines per layout, but each
// layout shapes all of it.
let n = wordcraft_doc::MAX_LINKED_BOXES;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(64 * 1024 / 58);
hairline_chain(&mut d, n, 1500.0, vec![para_block(Paragraph::with_text(&story, Default::default()))]);
let layouts = chain_layouts(&d, 2000);
assert!(layouts <= 4, "{layouts} layouts of a 64 KB story");
}
/// The paragraph layouts (by address) showing story `head` across the document.
@@ -95,7 +175,8 @@ fn stacked_boxes_of_any_width_share_a_layout() {
fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
// The cap is full of horizontal widths when a vertical box comes: it lays the story out
// its own way rather than showing a horizontal layout.
let n = textbox::MAX_FLOW_WIDTHS + 1;
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
let n = 17;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(200);
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect();
@@ -109,7 +190,12 @@ fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
assert_eq!(widths.len(), n, "every box shows the story");
widths.sort_unstable();
widths.dedup();
assert_eq!(widths.len(), n, "the vertical box has a layout of its own");
assert!(widths.len() > 1 && widths.len() < n, "{} layouts: the vertical box has its own, the rest are shared", widths.len());
// The vertical box's layout is none of the horizontal boxes', still past the cap.
let mut all = story_layouts(&lay(&d), ids[0]);
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
let vertical = all.pop();
assert!(vertical.is_some_and(|v| !all.contains(&v)), "the vertical box shares a horizontal layout");
}
#[test]
@@ -276,7 +362,7 @@ fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() {
assert!(outlines.is_empty(), "the refusal isn't kept");
let mut fresh = contour::Alphas::default();
assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some());
assert!(outlines.get(&key).is_some_and(|(_, o)| o.is_some()), "the outline is kept");
assert!(outlines.get(&key).is_some_and(|(_, o, _)| o.is_some()), "the outline is kept");
}
/// The outline cache outlives the documents it is used with and tells pictures apart by their
@@ -288,9 +374,270 @@ fn a_kept_outline_holds_on_to_its_pictures_allocation() {
let mut outlines = HashMap::new();
let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]);
assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some());
let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") };
let Some((weak, ..)) = outlines.get(&key) else { panic!("the outline is kept") };
assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes");
assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes");
drop(disc);
assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept");
}
/// A floating Drawing Canvas in a header cell is one `Clip` holding its members, and re-placing
/// the header clones every one of them: they all count against the repeat budget.
#[test]
fn a_clipped_group_costs_its_members_when_a_header_repeats() {
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
let big = usize::MAX;
assert_eq!(placed_cost(&rule(), big), 1);
assert_eq!(placed_cost(&clip((0..1000).map(|_| rule()).collect()), big), 1001);
// Nested groups count too, however deep: every descendant is charged.
assert_eq!(placed_cost(&clip(vec![clip(vec![rule(), rule()]), rule()]), big), 5);
let mut deep = clip((0..1000).map(|_| rule()).collect());
for _ in 0..20 {
deep = clip(vec![deep]);
}
assert_eq!(placed_cost(&deep, big), 1021, "members below the eighth level still cost one each");
// Counting stops once past the limit, so a huge or very deep group is cheap to count.
assert_eq!(placed_cost(&deep, 10), 11);
let mut deeper = rule();
for _ in 0..100_000 {
deeper = clip(vec![deeper]);
}
assert_eq!(placed_cost(&deeper, 50), 51);
assert_eq!(placed_cost(&deeper, big), 100_001);
std::mem::forget(deeper);
}
/// The linked-box work budget counts a clipped group's members, nested groups included.
#[test]
fn flow_work_counts_clip_members() {
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
let mut deep = clip((0..100).map(|_| rule()).collect());
for _ in 0..12 {
deep = clip(vec![deep, rule()]);
}
assert_eq!(textbox::flow_work(&[rule(), deep]), 1 + 100 + 1 + 12 * 2);
}
/// Lines inside a clipped group (a text box on a canvas) cost each line, as they do outside one.
#[test]
fn flow_work_counts_each_line_in_a_clip() {
let l = lay(&Document::from_text(&"Words that run on and on. ".repeat(200)));
let lines = l.pages.iter().flat_map(|p| &p.items).find(|it| matches!(it, Placed::Lines { .. })).cloned().unwrap();
let (l0, l1) = match &lines {
Placed::Lines { l0, l1, .. } => (*l0, *l1),
_ => (0, 0),
};
assert!(l1 - l0 > 1);
let clip = Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items: vec![lines] };
assert_eq!(textbox::flow_work(&[clip]), 1 + (l1 - l0));
}
#[test]
fn repeated_header_items_are_capped_across_all_the_tables_of_a_layout() {
// Each table's repeats used to have a budget of their own: a hundred tables, a hundred times
// the cap.
let table = || {
let mut t = Table::new(201, 1, 468.0);
t.rows[0].props.header = true;
t.rows[0].props.height = Some(20.0);
t.rows[0].props.height_rule = HeightRule::Exact;
t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect();
for row in t.rows.iter_mut().skip(1) {
row.props.height = Some(600.0);
row.props.height_rule = HeightRule::Exact;
}
t
};
let mut d = Document::from_text("");
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table())).unwrap();
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(table())).unwrap();
d.insert_block(StoryRef::Body, &Path::top(3), Block::Table(table())).unwrap();
let l = lay(&d);
let items: usize = l.pages.iter().map(|p| p.items.len()).sum();
assert!(items <= MAX_HEADER_ITEMS + 3 * 3000 + 50 * l.pages.len(), "{items} items placed");
}
/// Which pictures wrap by their outline must not depend on what the cache already holds: a
/// picture's outline kept from an earlier layout is charged to the budget like a decoded one.
#[test]
fn cold_and_warm_caches_give_the_same_outlines() {
let (a, b) = (Arc::new(disc_png(64)), Arc::new(disc_png(64)));
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
// Room for one of the two pictures.
let pass = |outlines: &mut Outlines| {
let mut alphas = contour::Alphas::default();
alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64;
let got = [(&a, "a"), (&b, "b")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
(got, alphas.decoded)
};
let mut roomy = contour::Alphas::default();
let mut warm = Outlines::new();
for (p, n) in [(&a, "a"), (&b, "b")] {
assert!(picture_outline(&mut warm, &mut roomy, key(p, n), p, [0.0; 4]).is_some());
}
assert_eq!(warm.len(), 2, "both outlines are kept");
let (cold, cold_spent) = pass(&mut Outlines::new());
assert_eq!(cold, [true, false], "the first picture fits, the second wraps as its rectangle");
let (warmed, warm_spent) = pass(&mut warm);
assert_eq!(warmed, cold, "the same pictures get outlines with the cache warm");
assert_eq!(warm_spent, cold_spent);
// And again: a layout after a layout.
assert_eq!(pass(&mut warm).0, cold);
}
/// A PNG whose header says `side` × `side` RGBA and whose body is missing: it has transparency
/// to look at, costs its pixels, and then fails to decode.
fn truncated_png(side: u32) -> Vec<u8> {
fn crc(data: &[u8]) -> u32 {
let mut c = !0u32;
for b in data {
c ^= u32::from(*b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
let mut ihdr = b"IHDR".to_vec();
ihdr.extend(side.to_be_bytes());
ihdr.extend(side.to_be_bytes());
ihdr.extend([8, 6, 0, 0, 0]);
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
png.extend(&ihdr);
png.extend(crc(&ihdr).to_be_bytes());
// An image data chunk of junk: the header is read, the pixels aren't there.
let mut idat = b"IDAT".to_vec();
idat.extend([0x78, 0x9c, 1, 2, 3, 4]);
png.extend(6u32.to_be_bytes());
png.extend(&idat);
png.extend(crc(&idat).to_be_bytes());
png
}
#[test]
fn a_truncated_picture_costs_the_same_cold_and_warm() {
// Its header promises transparency, so a layout spends its pixels before the body fails to
// decode; a layout finding its (empty) outline kept spends them too, or pictures after it
// would get outlines only with the cache warm.
let (big, disc) = (Arc::new(truncated_png(8000)), Arc::new(disc_png(64)));
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
let pass = |outlines: &mut Outlines| {
let mut alphas = contour::Alphas::default();
alphas.decoded = contour::MAX_DECODED_PIXELS - 8000 * 8000;
let got = [(&big, "big"), (&disc, "disc")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
(got, alphas.decoded)
};
let mut warm = Outlines::new();
let (cold, spent) = pass(&mut warm);
assert_eq!(spent, contour::MAX_DECODED_PIXELS, "the truncated picture spent its pixels");
assert_eq!(cold, [false, false], "no budget is left for the disc");
assert_eq!(warm.get(&key(&big, "big")).map(|e| e.2), Some(8000 * 8000), "the entry holds the cost");
// Also with a roomy first layout filling the cache, then a tight one.
let mut roomy_cache = Outlines::new();
let mut roomy = contour::Alphas::default();
for (p, n) in [(&big, "big"), (&disc, "disc")] {
let _ = picture_outline(&mut roomy_cache, &mut roomy, key(p, n), p, [0.0; 4]);
}
assert_eq!(pass(&mut roomy_cache), (cold, spent), "warm matches cold");
}
#[test]
fn a_header_row_holding_a_canvas_repeats_within_the_budget() {
use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap};
// A canvas of 800 members in an exact-height header row, over two thousand pages: each page
// would clone all 800 members.
let member = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 4.0,
h: 4.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let canvas = InlineObject::Group {
w: 40.0,
h: 40.0,
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 5.0, y: 5.0, ..Default::default() },
ch_w: 40.0,
ch_h: 40.0,
children: (0..800).map(|_| GroupChild { x: 1.0, y: 1.0, obj: member.clone() }).collect(),
canvas: Some(CanvasStyle { fill: None, stroke: None, stroke_width: 0.0 }),
};
let mut t = Table::new(2001, 1, 468.0);
t.rows[0].props.header = true;
t.rows[0].props.height = Some(60.0);
t.rows[0].props.height_rule = HeightRule::Exact;
let mut p = Paragraph::with_text("", Default::default());
p.insert_object(0, canvas, &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![para_block(p)];
for row in t.rows.iter_mut().skip(1) {
row.props.height = Some(600.0);
row.props.height_rule = HeightRule::Exact;
}
fn count(it: &Placed) -> usize {
match it {
Placed::Clip { items, .. } => items.iter().map(count).fold(1, usize::saturating_add),
_ => 1,
}
}
let mut d = Document::from_text("");
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
let l = lay(&d);
assert!(l.pages.len() >= 1000, "{} pages", l.pages.len());
let items: usize = l.pages.iter().flat_map(|p| &p.items).map(count).sum();
assert!(items <= MAX_HEADER_ITEMS + 2000 + 50 * l.pages.len(), "{items} items placed");
assert!(items > 100_000, "the header still repeats on early pages: {items}");
}
/// A table with a repeating header row in a two-column section ended by a continuous break, so
/// its columns are balanced: laid out once, then again from a snapshot for each balancing try.
fn balanced_header_table(rows: usize) -> Document {
use wordcraft_doc::section::{SectionProps, SectionStart};
let mut d = Document::from_text("Before\nEnd of the section\nAfter the break");
let mut s = SectionProps::default();
s.columns.count = 2;
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(s));
d.last_section.start = SectionStart::Continuous;
let mut t = Table::new(rows, 1, 200.0);
t.rows[0].props.header = true;
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
d
}
/// Header rows placed (each repeat of the one-column header row is one cell of row 0).
fn header_cells(l: &DocLayout) -> usize {
l.pages.iter().flat_map(|p| &p.items).filter(|i| matches!(i, Placed::Cell { row: 0, .. })).count()
}
fn with_header_cap<T>(cap: usize, f: impl FnOnce() -> T) -> T {
headers::HEADER_ITEMS_CAP.with(|c| c.set(cap));
let out = f();
headers::HEADER_ITEMS_CAP.with(|c| c.set(MAX_HEADER_ITEMS));
out
}
#[test]
fn balancing_columns_gives_back_the_header_repeats_it_throws_away() {
// The table runs over both columns of a page and onto a second one.
let d = balanced_header_table(120);
let full = lay(&d);
let shown = header_cells(&full);
assert!(shown >= 3, "the header repeats in each later column: {shown} header rows");
// What one repeat costs: the smallest cap at which a plain two-page table repeats its header.
let mut plain = Table::new(80, 1, 200.0);
plain.rows[0].props.header = true;
let mut p = Document::from_text("");
p.insert_block(StoryRef::Body, &Path::top(1), Block::Table(plain)).unwrap();
let cost = (1..=200).find(|cap| with_header_cap(*cap, || header_cells(&lay(&p))) >= 2).expect("a header repeat costs a few items");
// A budget holding exactly the repeats shown is enough: the balancing tries thrown away don't
// spend it.
let capped = with_header_cap((shown - 1) * cost, || lay(&d));
assert_eq!(header_cells(&capped), shown, "the final layout repeats the header as often");
}
+68 -9
View File
@@ -208,13 +208,66 @@ struct Cut {
byte: usize,
}
/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole
/// story out again and is kept while the chain is split, so a chain of boxes that each differ in
/// width would multiply its story's layout work and memory. Past 16, a box whose length isn't
/// among them takes the nearest narrower one laid out in its text direction, so its lines still
/// fit across it (they stop short of its far edge), else the narrowest, whose lines are longer
/// than the box. Far above a real chain's few box sizes.
pub(crate) const MAX_FLOW_WIDTHS: usize = 16;
/// Most work a chain's story is laid out for, summed over its layouts. Each distinct line length
/// (and text direction) lays the whole story out again and is kept while the chain is split, so
/// a chain of boxes that each differ in width multiplies its story's layout work and memory by
/// its widths. A layout costs the lines and other items it gives, or its story's text at
/// [`STORY_BYTES_PER_WORK`] bytes a unit, whichever is more: text that gives few lines (hidden,
/// deleted, very wide lines, or cut by a box inside) still costs what shaping it does. So the
/// layouts of a chain shape at most about 64 MB of text, plus the one that crosses the cap and
/// the first in each other direction. Within this every box of an ordinary story (even all
/// [`wordcraft_doc::MAX_LINKED_BOXES`]) keeps its exact width; past it, a box whose length isn't
/// among those laid out takes the nearest narrower one laid out in its text direction, so its
/// lines still fit across it (they stop short of its far edge), else the narrowest, whose lines
/// are longer than the box. Only a huge story gets there.
pub(crate) const MAX_FLOW_WORK: usize = 1_000_000;
/// Bytes of story text that cost one unit of [`MAX_FLOW_WORK`]: about a line's worth.
pub(crate) const STORY_BYTES_PER_WORK: usize = 64;
#[cfg(test)]
thread_local! {
/// A smaller cap for tests, so a short story can reach it.
pub(crate) static FLOW_WORK_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_FLOW_WORK) };
/// How many times chains' stories have been laid out, for tests.
pub(crate) static FLOW_LAYOUTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn flow_work_cap() -> usize {
#[cfg(test)]
return FLOW_WORK_CAP.with(|c| c.get());
#[cfg(not(test))]
MAX_FLOW_WORK
}
/// What laying `items` out cost: its lines and other items (a clipped group's members, nested
/// ones too, each count, its lines each).
pub(crate) fn flow_work(items: &[Placed]) -> usize {
let cap = flow_work_cap();
items.iter().map(|it| super::placed_cost_by(it, cap, line_count)).fold(0, usize::saturating_add)
}
/// One for an item, or its lines for a run of lines.
fn line_count(it: &Placed) -> usize {
match it {
Placed::Lines { l0, l1, .. } => l1.saturating_sub(*l0).max(1),
_ => 1,
}
}
/// The work laying `blocks` out costs whatever it gives (see [`MAX_FLOW_WORK`]): its text's
/// bytes, tables' cells too, over [`STORY_BYTES_PER_WORK`].
fn story_work(blocks: &Blocks) -> usize {
let mut bytes = 0usize;
let mut stack: Vec<&Arc<Block>> = blocks.iter().collect();
while let Some(b) = stack.pop() {
match &**b {
Block::Para(p) => bytes = bytes.saturating_add(p.text.len()),
Block::Table(t) => stack.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks)),
}
}
bytes / STORY_BYTES_PER_WORK
}
/// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart
/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size.
@@ -237,6 +290,9 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
let snap = (ctx.counters.clone(), ctx.eq_count);
let mut after = None;
let mut layouts: Vec<((i32, TextVert), Vec<Placed>)> = Vec::new();
// Work done by the layouts so far (see `MAX_FLOW_WORK`).
let mut work = 0usize;
let floor = story_work(&blocks);
let mut out = Vec::with_capacity(chain.len());
// Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown.
let mut start: Option<Option<Cut>> = Some(None);
@@ -249,17 +305,20 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
let (len, across) = frame(&body, w, h);
let key = flow_key(len, body.vert);
// Past the cap the box takes the nearest layout in its direction: the widest line length
// Past the work cap the box takes the nearest layout in its direction: the widest line length
// that fits it, else the narrowest. The first box in a direction is laid out (one more
// layout for each of the few directions at most).
let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0)));
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None });
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if work >= flow_work_cap() { near() } else { None });
let items = match kept {
Some((_, items)) => items.clone(),
None => {
(ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1);
let items = lay(ctx, head, &blocks, len, body.vert, depth);
after.get_or_insert_with(|| (ctx.counters.clone(), ctx.eq_count));
#[cfg(test)]
FLOW_LAYOUTS.with(|c| c.set(c.get().saturating_add(1)));
work = work.saturating_add(flow_work(&items).max(floor));
layouts.push((key, items.clone()));
items
}
+1
View File
@@ -25,6 +25,7 @@ wordcraft-render = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-zotero = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_extras = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+2
View File
@@ -16,6 +16,8 @@ use crate::theme::{Tokens, regular, semibold};
mod balloons;
mod context_menu;
#[cfg(test)]
pub(crate) use context_menu::issue_heading;
mod cursor;
mod geometry;
mod input;
+12 -2
View File
@@ -48,6 +48,17 @@ fn equation_menu(app: &mut WordApp, ui: &mut Ui) {
}
}
/// The heading of a proofing issue's context menu, in the interface language like the rest of
/// the menu: a spelling issue's own message is in the text's language (craft-proof's, "Faute
/// d'orthographe possible" for French text), so it says Possible spelling mistake instead.
/// English grammar rules' messages are catalog keys; other languages' rules explain in their own.
pub(crate) fn issue_heading(issue: &serde_json::Value) -> String {
if issue.get("kind").and_then(|k| k.as_str()) == Some("spelling") {
return tl!("Possible spelling mistake").to_string();
}
tl!(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).to_string()
}
pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
if app.session.math.is_some() {
return equation_menu(app, ui);
@@ -67,8 +78,7 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) {
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
.unwrap_or_default();
// English rules' messages are catalog keys; Polish rules already explain in Polish.
ui.label(egui::RichText::new(tl!(issue.get("message").and_then(|m| m.as_str()).unwrap_or(""))).small().weak());
ui.label(egui::RichText::new(issue_heading(&issue)).small().weak());
if sugg.is_empty() {
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
}
+210 -52
View File
@@ -250,12 +250,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.label(RichText::new(summary).small().weak());
ui.separator();
crate::rtl::island(ui, |ui| {
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
if list.is_empty() {
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
table(ui, &list, &mut v);
} else {
if list.is_empty() {
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
// once its columns are dragged wider than the dialog.
ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| {
ui.set_max_height(360.0);
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
});
} else {
egui::ScrollArea::vertical().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -264,40 +269,149 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
}
})
});
}
});
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
crate::rtl::grid(ui, egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()), |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", tl!(k.label().1))).strong()).on_hover_text(tl!(k.label().0)).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
/// How the contributor table's spare width is shared out: all of it to Name.
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
/// How the model table's spare width is shared out: to Company and Model, the text columns.
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
/// The narrowest a column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// The width a column needs for its header and its widest cell.
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
widest + 2.0 * ui.spacing().button_padding.x
}
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, down to [`MIN_COLUMN`].
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if spare >= 0.0 && weight > 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
(w + spare * w / flexible).max(MIN_COLUMN)
} else {
*w
}
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
})
.collect()
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
/// whatever width is left but never shrinks below its opening width, so widening a column grows
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
/// than the window scrolls sideways. Text too long for its column is clipped.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
let last = widths.len().saturating_sub(1);
for (i, w) in widths.into_iter().enumerate() {
let c = if i == last {
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
} else {
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
};
t = t.column(c.clip(true));
}
t
}
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
ui.with_layout(layout, add).inner
}
fn row_height(ui: &egui::Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Body) + 6.0
}
/// One contributor's table cells after the name, in `SortKey::ALL` order.
fn cells(c: &Contributor) -> [String; 9] {
[
group(c.prs),
group(c.commits),
group(c.lines_added),
group(c.lines_deleted),
signed(c.lines_delta()),
group(c.binary_added),
group(c.binary_deleted),
day(c.first_commit).to_string(),
day(c.last_commit).to_string(),
]
}
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
/// toolbar's sort-order button, so no arrow widens the column.
fn header_text(k: SortKey) -> RichText {
RichText::new(tl!(k.label().1)).strong()
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
let h = row_height(ui);
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
let widths: Vec<f32> = SortKey::ALL
.iter()
.enumerate()
.map(|(i, &k)| match i.checked_sub(1) {
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
})
.collect();
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
.header(h + 4.0, |mut header| {
for k in SortKey::ALL {
header.col(|ui| {
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(tl!(k.label().0));
if r.clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
}
})
.body(|mut body| {
for (c, cells) in list.iter().zip(rows) {
body.row(h, |mut row| {
row.col(|ui| {
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
.on_hover_text(c.summary());
});
for text in cells {
row.col(|ui| {
aligned(ui, true, |ui| ui.label(text));
});
}
});
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
@@ -307,24 +421,53 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
crate::rtl::island(ui, |ui| {
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
crate::rtl::grid(ui, egui::Grid::new("credits_models").striped(true).num_columns(6), |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(tl!(h)).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
let h = row_height(ui);
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
// Company, Model and Version read as text; the counts line up on the right.
let right = [false, false, false, true, true, true];
let rows: Vec<[String; 6]> = MODELS
.iter()
.map(|m| {
[
m.company.to_string(),
m.model.to_string(),
m.version.to_string(),
group(m.commits),
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
]
})
.collect();
let widths: Vec<f32> = titles
.iter()
.enumerate()
.map(|(i, title)| natural(ui, RichText::new(tl!(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
.collect();
crate::rtl::island(ui, |ui| {
ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| {
ui.set_max_height(360.0);
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
.header(h + 4.0, |mut header| {
for (title, r) in titles.into_iter().zip(right) {
header.col(|ui| {
aligned(ui, r, |ui| ui.label(RichText::new(tl!(title)).strong()));
});
}
})
.body(|mut body| {
for cells in rows {
body.row(h, |mut row| {
for (text, r) in cells.into_iter().zip(right) {
row.col(|ui| {
aligned(ui, r, |ui| ui.label(text));
});
}
});
}
});
});
});
});
}
@@ -404,4 +547,19 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
#[test]
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
}
#[test]
fn too_little_room_shrinks_only_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
}
@@ -322,21 +322,17 @@ impl LanguageForm {
/// A language's name in the interface language (the tag itself when we don't know it).
pub(super) fn language_name(tag: &str) -> String {
LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)).map(|(_, n)| tl!(n).to_string()).unwrap_or_else(|| tag.to_string())
crate::ribbon::display_name(Some(tag))
}
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
ui.label(tl!("Mark selected text as:"));
let mut list: Vec<(String, String)> = LANGUAGES.iter().map(|(t, _)| (t.to_string(), language_name(t))).collect();
if !LANGUAGES.iter().any(|(t, _)| t.eq_ignore_ascii_case(&f.lang)) {
list.push((f.lang.clone(), f.lang.clone()));
}
list.sort_by_key(|a| a.1.to_lowercase());
let list = crate::ribbon::language_list(&f.lang);
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, crate::theme::Tokens::get(ui.ctx()).border_strong)).inner_margin(4.0).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(220.0).min_scrolled_height(220.0).show(ui, |ui| {
ui.set_min_width(300.0);
for (tag, name) in &list {
let on = f.lang.eq_ignore_ascii_case(tag);
let on = crate::ribbon::same_language(&f.lang, tag);
let r = ui.selectable_label(on, name.as_str());
if r.clicked() {
f.lang = tag.clone();
+1 -1
View File
@@ -1112,7 +1112,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
let Dialog::About { tab } = d else {
return false;
};
ui.set_width(660.0);
ui.set_width(820.0);
let names = ["About", "Contributors", "Models", "Language data"].map(|n| tl!(n).to_string());
if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
*tab = i as u8;
+477 -56
View File
@@ -75,21 +75,39 @@ pub(crate) struct Runtime {
/// Failures the window had itself (turning downloading on, starting, removing), by locale
/// ("" for the list), shown until the next try. The engine keeps the installs' own.
errors: HashMap<String, String>,
/// (document, locale) pairs whose bar was closed.
/// (document, pack locale) pairs whose bar was closed.
dismissed: HashSet<(u64, String)>,
/// The missing packs of the shown document, what they were worked out from, and when.
missing: Option<(MissingKey, f64, Vec<Pack>)>,
/// (document, language tag) pairs whose bar was closed before the list was known: the pack
/// the list then has for the tag stays closed.
dismissed_tags: HashSet<(u64, String)>,
/// What the bar offers for the shown document, what it was worked out from, and when.
missing: Option<(MissingKey, f64, Offers)>,
/// [`packs::finished`] when last looked at.
finished: u64,
/// The person clicked the bar's Install before the list was known: the list's failure then
/// shows in the bar (an automatic fetch's stays in Options).
asked: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
/// What the bar can offer.
#[derive(Clone, Default)]
struct Offers {
/// Available packs the document asks for ([`packs::missing`]).
packs: Vec<Pack>,
/// Languages it asks for while there is no list yet ([`packs::unlisted`]).
unlisted: Vec<String>,
}
#[derive(Clone, PartialEq, Eq)]
struct MissingKey {
document: u64,
rev: u64,
generation: u64,
/// [`packs::index_generation`]: the list changed.
packs: u64,
/// The caret's typing language ([`packs::caret_language`]): choosing a language with nothing
/// selected changes no text.
caret: Option<String>,
}
/// Bytes as people read them: `1.9 MB`, `640 KB`.
@@ -103,56 +121,49 @@ pub(crate) fn size_text(bytes: u64) -> String {
}
}
/// English names of pack languages the Language dialog's list doesn't have.
const MORE_LANGUAGES: [(&str, &str); 14] = [
("bg", "Bulgarian"),
("bs", "Bosnian"),
("ca", "Catalan"),
("ga", "Irish"),
("hi", "Hindi"),
("lt", "Lithuanian"),
("lv", "Latvian"),
("ro", "Romanian"),
("sk", "Slovak"),
("sl", "Slovenian"),
("sq", "Albanian"),
("sw", "Swahili"),
("ta", "Tamil"),
("vi", "Vietnamese"),
/// English names of the Language pack languages the Language dialog's list doesn't have, by the
/// tag a document stores for them ([`crate::ribbon::tag_for_pack`]): every pack in the published
/// index has a name in the interface language (CP4.3: Belarusian showed as Беларуская).
pub(crate) const MORE_LANGUAGES: [(&str, &str); 28] = [
("af-ZA", "Afrikaans"),
("be-BY", "Belarusian"),
("bg-BG", "Bulgarian"),
("bs-BA", "Bosnian"),
("ca-ES", "Catalan"),
("ca-ES-valencia", "Catalan (Valencian)"),
("cy-GB", "Welsh"),
("eo", "Esperanto"),
("fa-IR", "Persian"),
("ga-IE", "Irish"),
("gl-ES", "Galician"),
("hi-IN", "Hindi"),
("hu-HU", "Hungarian"),
("hy-AM", "Armenian"),
("is-IS", "Icelandic"),
("ka-GE", "Georgian"),
("lt-LT", "Lithuanian"),
("lv-LV", "Latvian"),
("mk-MK", "Macedonian"),
("nn-NO", "Norwegian (Nynorsk)"),
("ro-RO", "Romanian"),
("sk-SK", "Slovak"),
("sl-SI", "Slovenian"),
("sq-AL", "Albanian"),
("sw-KE", "Swahili"),
("ta-IN", "Tamil"),
("th-TH", "Thai"),
("vi-VN", "Vietnamese"),
];
/// The language a pack is for, in the interface language (`Dutch`, `Serbian (Latin)`), else its
/// own name.
/// The language a pack is for, in the interface language (`Dutch`, `Serbian (Latin)`): the name
/// of the tag text in it is marked with ([`crate::ribbon::tag_for_pack`]), as the status bar and
/// the Language dialog name it; else the pack's own name; else its locale.
pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String {
let langs = crate::dialogs_layout::LANGUAGES;
let exact = langs.iter().find(|(t, _)| t.eq_ignore_ascii_case(pack_locale)).map(|(_, n)| *n);
// `sr-latn` is `sr-Latn-RS` (Serbian (Latin)); a bare `nl` is the language without the region.
let prefixed = || {
langs
.iter()
.find(|(t, _)| {
t.len() > pack_locale.len()
&& t.get(..pack_locale.len()).is_some_and(|p| p.eq_ignore_ascii_case(pack_locale))
&& t.as_bytes().get(pack_locale.len()) == Some(&b'-')
})
.map(|(_, n)| if pack_locale.contains('-') { n } else { n.split(" (").next().unwrap_or(n) })
};
let base = pack_locale.split('-').next().unwrap_or(pack_locale);
let more = || MORE_LANGUAGES.iter().find(|(t, _)| *t == base).map(|(_, n)| *n);
if let Some(n) = exact.or_else(prefixed) {
return tl!(n).to_string();
}
// A language with a region the list lacks (`de-ch`: German (Switzerland)), as the status bar
// names it.
let named = crate::ribbon::language_name(Some(pack_locale));
if !named.eq_ignore_ascii_case(pack_locale) {
return tl!(&named).to_string();
}
match more() {
Some(n) => tl!(n).to_string(),
None if !endonym.is_empty() => endonym.to_string(),
None => pack_locale.to_string(),
let tag = crate::ribbon::tag_for_pack(pack_locale);
if !crate::ribbon::language_name(Some(&tag)).eq_ignore_ascii_case(&tag) {
return crate::ribbon::display_name(Some(&tag));
}
if endonym.is_empty() { pack_locale.to_string() } else { endonym.to_string() }
}
/// Once per frame: installs and fetches run on the engine's thread, which repaints the window as
@@ -165,6 +176,8 @@ pub(crate) fn tick(app: &mut WordApp) {
{
packs::set_background(Some(Arc::new(move || ctx.request_repaint())));
}
// Downloading already on and no list (none cached, or one for an older pin): fetch it once.
packs::refresh_if_stale();
let finished = packs::finished();
if finished != app.packs_rt.finished {
app.packs_rt.finished = finished;
@@ -251,25 +264,45 @@ fn remove(app: &mut WordApp, locale: &str) {
}
}
/// The packs the shown document asks for. Worked out again when the document, the packs or the
/// list changed; while it is being typed in, at most once a second.
fn missing(app: &mut WordApp, now: f64) -> Vec<Pack> {
/// What the bar offers for the shown document. Worked out again when the document, the caret's
/// language, the packs or the list changed; while it is being typed in, at most once a second.
fn missing(app: &mut WordApp, now: f64) -> Offers {
let key = MissingKey {
document: app.session.document_id(),
rev: app.session.rev(),
generation: wordcraft_engine::proof::dictionary_generation(),
packs: packs::index_generation(),
caret: packs::caret_language(&app.session),
};
if let Some((k, at, m)) = &app.packs_rt.missing
&& (*k == key || (MissingKey { rev: key.rev, ..*k } == key && now - at < MISSING_REFRESH_SECS))
&& (*k == key || (MissingKey { rev: key.rev, ..k.clone() } == key && now - at < MISSING_REFRESH_SECS))
{
return m.clone();
}
let m = packs::missing(&app.session);
let packs = packs::missing(&app.session);
let unlisted = if packs.is_empty() { packs::unlisted(&app.session) } else { Vec::new() };
let m = Offers { packs, unlisted };
app.packs_rt.missing = Some((key, now, m.clone()));
m
}
/// Get `tag`'s pack before the list is known: the person clicked the bar's Install. Their click
/// turns on downloading ([`consent`]); the list is fetched, then the pack installed if it lists
/// one (on the engine's thread).
fn start_get(app: &mut WordApp, ctx: &egui::Context, tag: &str) {
app.packs_rt.errors.remove("");
if !consent(app, ctx, "") {
return;
}
app.packs_rt.asked = true;
if let Err(e) = packs::start_get(tag) {
app.packs_rt
.errors
.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &shown_error(&e))]));
}
app.packs_rt.missing = None;
}
/// The missing-pack bar under the ribbon: "<Language> proofing isn't installed. Install (<size>)",
/// for the first language of the document with an available pack that isn't installed. Closing it
/// hides it for that language in this document. Never shown for a language with no pack.
@@ -279,7 +312,13 @@ pub(crate) fn bar(app: &mut WordApp, ui: &mut Ui) {
}
let document = app.session.document_id();
let now = ui.input(|i| i.time);
let Some(pack) = missing(app, now).into_iter().find(|p| !app.packs_rt.dismissed.contains(&(document, p.locale.clone()))) else {
let offers = missing(app, now);
// The whole list, for a tag closed before it was known (only then: it is a copy).
let index = if app.packs_rt.dismissed_tags.is_empty() { Vec::new() } else { packs::available() };
let Some(pack) = offers.packs.into_iter().find(|p| !dismissed(app, document, p, &index)) else {
if let Some(tag) = offers.unlisted.into_iter().find(|t| !app.packs_rt.dismissed_tags.contains(&(document, t.clone()))) {
unlisted_bar(app, ui, &tag);
}
return;
};
let t = Tokens::get(ui.ctx());
@@ -318,6 +357,67 @@ pub(crate) fn bar(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Whether the bar for `pack` was closed in `document`: for the pack itself, or for a language
/// tag it serves, closed before the list was known (`kl-GL` for `kl`).
fn dismissed(app: &WordApp, document: u64, pack: &Pack, index: &[Pack]) -> bool {
// A closed pack is that pack alone (closing `de-ch` leaves `de`); a closed tag is the pack it
// finds in the whole list (`de-CH` is `de-ch`'s, not `de`'s, once the list has both).
app.packs_rt.dismissed.contains(&(document, pack.locale.clone()))
|| app
.packs_rt
.dismissed_tags
.iter()
.any(|(d, tag)| *d == document && packs::pack_for_tag(tag, index).is_some_and(|p| p.locale == pack.locale))
}
/// The bar for a language the document asks for while there is no list of packs yet (a fresh
/// install, or a list cached for an older pin): "<Language> proofing isn't installed. Install".
/// Install fetches the list, then installs the language's pack if it has one; if not, the bar
/// goes. Hidden while an automatic fetch of the list runs.
fn unlisted_bar(app: &mut WordApp, ui: &mut Ui, tag: &str) {
let fetching = packs::fetching();
if fetching && !app.packs_rt.asked {
return;
}
let document = app.session.document_id();
let t = Tokens::get(ui.ctx());
egui::Panel::top("language_pack_bar")
.exact_size(36.0)
.show_separator_line(false)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(12, 0)))
.show(ui, |ui| {
let bottom = ui.max_rect().bottom();
let full = ui.max_rect();
ui.painter().hline(full.x_range(), bottom - 0.5, egui::Stroke::new(1.0, t.border));
ui.horizontal_centered(|ui| {
let language = shown(&language_of(&tag.to_ascii_lowercase(), ""), MAX_BAR_NAME);
ui.label(RichText::new(crate::i18n::fmt(tl!("{language} proofing isn't installed."), &[("language", &language)])).color(t.text));
if fetching {
ui.spinner();
ui.label(RichText::new(tl!("Getting the list of languages…")).color(t.text_dim));
} else {
let mut hover = vec![tl!("Gets the list of Language packs, then this language's pack if there is one.").to_string()];
if packs::backend().is_some_and(|b| !b.network_on()) {
hover.push(tl!("Installing turns on downloading of fonts and Language packs from GitHub.").to_string());
}
if ui.add(crate::widgets::primary_button(ui, tl!("Install").to_string())).on_hover_text(hover.join("\n")).clicked() {
start_get(app, &ui.ctx().clone(), tag);
}
}
// The window's own failure (consent), else the list's when the person asked.
let e = app.packs_rt.errors.get("").cloned().or_else(|| if app.packs_rt.asked { error_of(app, "") } else { None });
if let Some(e) = e {
ui.add(egui::Label::new(RichText::new(e).color(t.red)).truncate());
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "close", tl!("Not now")).clicked() {
app.packs_rt.dismissed_tags.insert((document, tag.to_string()));
}
});
});
});
}
/// The bar's Install tooltip: the licence and source, that it turns on downloading when it is
/// off, and the test source when packs come from one.
fn licence_hover(pack: &Pack) -> String {
@@ -586,6 +686,209 @@ mod tests {
/// Tests that set the process-wide pack backend take turns.
static BACKEND_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Every locale of the published index (`language-packs-2/index.json`), with its own name.
const INDEX_LOCALES: [(&str, &str); 75] = [
("af", "Afrikaans"),
("ar", "العربية"),
("be", "Беларуская"),
("bg", "Български"),
("bs", "Bosanski"),
("ca", "Català"),
("ca-valencia", "Català (valencià)"),
("cs", "Čeština"),
("cy", "Cymraeg"),
("da", "Dansk"),
("de", "Deutsch (Deutschland)"),
("de-at", "Deutsch (Österreich)"),
("de-ch", "Deutsch (Schweiz)"),
("el", "Ελληνικά"),
("eo", "Esperanto"),
("es", "Español (España)"),
("es-ar", "Español (Argentina)"),
("es-bo", "Español (Bolivia)"),
("es-cl", "Español (Chile)"),
("es-co", "Español (Colombia)"),
("es-cr", "Español (Costa Rica)"),
("es-cu", "Español (Cuba)"),
("es-do", "Español (República Dominicana)"),
("es-ec", "Español (Ecuador)"),
("es-gq", "Español (Guinea Ecuatorial)"),
("es-gt", "Español (Guatemala)"),
("es-hn", "Español (Honduras)"),
("es-mx", "Español (México)"),
("es-ni", "Español (Nicaragua)"),
("es-pa", "Español (Panamá)"),
("es-pe", "Español (Perú)"),
("es-ph", "Español (Filipinas)"),
("es-pr", "Español (Puerto Rico)"),
("es-py", "Español (Paraguay)"),
("es-sv", "Español (El Salvador)"),
("es-us", "Español (Estados Unidos)"),
("es-uy", "Español (Uruguay)"),
("es-ve", "Español (Venezuela)"),
("et", "Eesti"),
("fa", "فارسی"),
("fr", "Français"),
("ga", "Gaeilge"),
("gl", "Galego"),
("he", "עברית"),
("hi", "\u{939}\u{93f}\u{928}\u{94d}\u{926}\u{940}"),
("hr", "Hrvatski"),
("hu", "Magyar"),
("hy", "Հայերեն"),
("id", "Bahasa Indonesia"),
("is", "Íslenska"),
("it", "Italiano"),
("ka", "ქართული"),
("ko", "한국어"),
("lt", "Lietuvių"),
("lv", "Latviešu"),
("mk", "Македонски"),
("nb", "Norsk bokmål"),
("nl", "Nederlands"),
("nn", "Norsk nynorsk"),
("pl", "Polski"),
("pt-br", "Português (Brasil)"),
("pt-pt", "Português (Portugal)"),
("ro", "Română"),
("ru", "Русский"),
("sk", "Slovenčina"),
("sl", "Slovenščina"),
("sq", "Shqip"),
("sr", "Српски"),
("sr-latn", "Srpski (latinica)"),
("sv", "Svenska"),
("sv-fi", "Svenska (Finland)"),
("sw", "Kiswahili"),
("ta", "\u{ba4}\u{bae}\u{bbf}\u{bb4}\u{bcd}"),
("tr", "Türkçe"),
("vi", "Tiếng Việt"),
];
fn index_packs() -> Vec<packs::Pack> {
let base = FixedBackend::sample().packs.remove(0);
INDEX_LOCALES.iter().map(|(l, n)| packs::Pack { locale: (*l).into(), name: (*n).into(), ..base.clone() }).collect()
}
/// CP4.3: every pack of the published index has an English name (Belarusian showed as
/// Беларуская, Arabic as its own name), no two packs share one, and a pack's language is
/// named as the status bar names the tag text in it is marked with.
#[test]
fn every_published_pack_has_an_english_name() {
let mut seen = std::collections::HashSet::new();
for (locale, endonym) in INDEX_LOCALES {
let name = super::language_of(locale, endonym);
// In Latin letters (Norwegian (Bokmål)), never the pack's own name in its script.
assert!(name.chars().all(|c| (c as u32) < 0x250) && name != *locale, "{locale}: {name}");
assert!(seen.insert(name.clone()), "{locale}: {name} twice");
assert_eq!(name, crate::ribbon::language_name(Some(&crate::ribbon::tag_for_pack(locale))), "{locale}");
}
assert_eq!(super::language_of("be", "Беларуская"), "Belarusian");
assert_eq!(super::language_of("ar", "العربية"), "Arabic");
assert_eq!(super::language_of("nn", "Norsk nynorsk"), "Norwegian (Nynorsk)");
assert_eq!(super::language_of("es-cu", ""), "Spanish (Cuba)");
// A locale WordCraft has no name for keeps the pack's own name.
assert_eq!(super::language_of("xq", "Xq name"), "Xq name");
}
/// CP4.3: the tag text is marked with when a pack's language is chosen finds that pack again,
/// for every published pack: Bulgarian chosen from its pack is proofed by it, Serbian (Latin)
/// by `sr-latn` rather than the Cyrillic `sr`.
#[test]
fn a_pack_language_tag_finds_its_pack() {
let index = index_packs();
for p in &index {
let tag = crate::ribbon::tag_for_pack(&p.locale);
assert_eq!(packs::pack_for_tag(&tag, &index).map(|f| f.locale.as_str()), Some(p.locale.as_str()), "{} → {tag}", p.locale);
assert!(packs::proofable(&tag), "{tag}");
}
// Valencian is stored as Word writes it, and Word's tag finds the Valencian pack, not the
// Catalan one; Catalan of Spain stays the Catalan pack's.
assert_eq!(crate::ribbon::language_name(Some("ca-ES-valencia")), "Catalan (Valencian)");
assert_eq!(super::language_of("ca-valencia", ""), "Catalan (Valencian)");
assert_eq!(packs::pack_for_tag("ca-ES-valencia", &index).map(|f| f.locale.as_str()), Some("ca-valencia"));
assert_eq!(packs::pack_for_tag("ca-ES", &index).map(|f| f.locale.as_str()), Some("ca"));
for (locale, tag) in [
("ca-valencia", "ca-ES-valencia"),
("ca", "ca-ES"),
("bg", "bg-BG"),
("sr-latn", "sr-Latn-RS"),
("sr", "sr-Cyrl-RS"),
("nl", "nl-NL"),
("de-at", "de-AT"),
("pt-br", "pt-BR"),
("eo", "eo"),
("es", "es-ES"),
] {
assert_eq!(crate::ribbon::tag_for_pack(locale), tag);
}
}
/// CP4.3: the Set Proofing Language dialog lists every installed and available pack's language
/// beside the built-in ones, each once and sorted by name; Review › Language and the status
/// bar's menu add the installed ones to the short list.
#[test]
fn pack_languages_are_in_the_language_lists() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
packs::set_background(None);
packs::set_backend(None);
}
}
let _reset = Reset;
let backend = std::sync::Arc::new(FixedBackend { packs: index_packs(), ..Default::default() });
backend.installed.lock().unwrap().push(packs::Installed {
locale: "bg".into(),
name: "Български".into(),
licence: String::new(),
path: std::path::PathBuf::new(),
});
packs::set_backend(Some(backend.clone() as std::sync::Arc<dyn Backend>));
let all = crate::ribbon::all_languages();
for (tag, name) in [
("bg-BG", "Bulgarian"),
("be-BY", "Belarusian"),
("sr-Latn-RS", "Serbian (Latin)"),
("es-CU", "Spanish (Cuba)"),
("ar-SA", "Arabic"),
("hr-HR", "Croatian"),
("ja-JP", "Japanese"),
] {
assert!(all.iter().any(|(t, n)| t == tag && n == name), "{tag} {name}: {all:?}");
}
let mut tags: Vec<String> = all.iter().map(|(t, _)| t.to_ascii_lowercase()).collect();
let n = tags.len();
tags.sort();
tags.dedup();
assert_eq!(tags.len(), n, "each language once");
assert!(all.windows(2).all(|w| crate::ribbon::sort_key(&w[0].1) <= crate::ribbon::sort_key(&w[1].1)), "sorted by name");
assert!(!all.iter().any(|(_, n)| n == "Беларуская" || n == "Български"), "every pack named in the interface language");
// One Catalan row and one Valencian, no stray Catalan (Spain).
assert_eq!(all.iter().filter(|(_, n)| n.starts_with("Catalan")).map(|(t, _)| t.as_str()).collect::<Vec<_>>(), ["ca-ES", "ca-ES-valencia"]);
// A bare tag is its regioned row, not a second row of the same name.
for (current, row) in [("nl", "nl-NL"), ("sv", "sv-SE"), ("ca-valencia", "ca-ES-valencia")] {
let list = crate::ribbon::language_list(current);
assert_eq!(list, all, "{current}");
assert_eq!(list.iter().filter(|(t, _)| crate::ribbon::same_language(current, t)).map(|(t, _)| t.as_str()).collect::<Vec<_>>(), [row]);
}
// The dialog shows the same list, and a tag none of them has besides.
assert_eq!(crate::ribbon::language_list("bg-BG"), all);
assert!(crate::ribbon::language_list("xx-YY").iter().any(|(t, _)| t == "xx-YY"));
// The short list: the built-in sixteen, then the installed Bulgarian.
let short = crate::ribbon::short_languages();
assert_eq!(short.len(), crate::ribbon::PROOFING_LANGUAGES.len() + 1);
assert_eq!(short.last().map(|(t, n)| (t.as_str(), n.as_str())), Some(("bg-BG", "Bulgarian")));
// Choosing it marks the text with the tag its pack proofs.
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("document.setText", json!({"text": "Добър ден"})).unwrap();
a.run("select.all", json!({})).unwrap();
a.run("review.language", json!({"lang": "bg-BG"})).unwrap();
assert_eq!(crate::ribbon::caret_language(&a), "Bulgarian");
assert_eq!(packs::pack_for_tag("bg-BG", &packs::available()).map(|p| p.locale.as_str()), Some("bg"));
}
/// Characters a pack index or a server's answer could use to hide, reorder or break text.
fn hostile(c: char) -> bool {
matches!(c, '\u{2028}' | '\u{2029}' | '\u{202E}' | '\u{E0000}'..='\u{E007F}') || (c.is_control() && c != '\n' && c != '\t')
@@ -724,4 +1027,122 @@ mod tests {
}
assert!(packs::is_installed("pl"));
}
/// A font store that never fetched the list (a fresh install): `index` has nothing.
struct Uncached(FixedBackend);
impl Backend for Uncached {
fn index(&self) -> Result<Vec<packs::Pack>, String> {
Ok(Vec::new())
}
fn fetch_index(&self, c: &packs::Cancel) -> Result<Vec<packs::Pack>, String> {
self.0.fetch_index(c)
}
fn network_on(&self) -> bool {
self.0.network_on()
}
fn enable_network(&self) -> Result<(), String> {
self.0.enable_network()
}
fn install(&self, pack: &packs::Pack, cancel: &packs::Cancel, progress: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
self.0.install(pack, cancel, progress)
}
fn remove(&self, locale: &str) -> Result<(), String> {
self.0.remove(locale)
}
fn packs_dir(&self) -> Option<std::path::PathBuf> {
None
}
fn installed(&self) -> Vec<packs::Installed> {
self.0.installed()
}
}
/// Martin's test of a fresh install: no list fetched, Greenlandic text. The bar still offers
/// it; a faked click leaves downloading off, and the person's click fetches the list and
/// installs the pack.
#[test]
fn with_no_list_yet_the_bar_offers_the_language_and_gets_it() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
ctx.enable_accesskit();
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
packs::set_background(None);
packs::set_backend(None);
}
}
let _reset = Reset;
let mut inner = FixedBackend::sample();
let kl = packs::Pack { locale: "kl".into(), name: "Kalaallisut".into(), ..inner.packs[0].clone() };
inner.packs.push(kl);
let backend = std::sync::Arc::new(Uncached(inner));
packs::set_backend(Some(backend.clone() as std::sync::Arc<dyn Backend>));
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("document.setText", json!({"text": "Aluu, qanoq ippit?"})).unwrap();
a.run("select.all", json!({})).unwrap();
a.run("review.language", json!({"lang": "kl-GL"})).unwrap();
let mut tree = frame(&mut a, &ctx, Vec::new());
for _ in 0..4 {
tree = frame(&mut a, &ctx, Vec::new());
}
assert!(packs::available().is_empty());
let install = spot(&tree, "Install").expect("the bar offers Greenlandic with no list");
as_agent(&mut a, &ctx, "ui.click", json!({"x": install.0, "y": install.1}));
assert!(!backend.0.network.load(Ordering::Relaxed), "a faked click leaves downloading off");
assert!(!packs::fetched() && !packs::is_installed("kl"));
assert!(a.packs_rt.errors.get("").is_some_and(|e| e.contains("Only you")), "{:?}", a.packs_rt.errors);
user_click(&mut a, &ctx, install);
assert!(backend.0.network.load(Ordering::Relaxed), "the person's click turns it on");
for _ in 0..500 {
if packs::is_installed("kl") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(packs::fetched(), "the list was fetched");
assert!(packs::is_installed("kl"), "then its pack installed");
}
/// Review: a language whose bar was closed before the list was known stays closed once the
/// list lists its pack (the dismissal is kept by document tag, the pack bar by pack locale).
#[test]
fn a_language_closed_before_the_list_stays_closed_after_it() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let doc = a.session.document_id();
let kl = packs::Pack { locale: "kl".into(), ..FixedBackend::sample().packs[0].clone() };
let pt_pt = packs::Pack { locale: "pt-pt".into(), ..kl.clone() };
let index = vec![kl.clone(), pt_pt.clone()];
assert!(!super::dismissed(&a, doc, &kl, &index));
a.packs_rt.dismissed_tags.insert((doc, "kl-GL".into()));
assert!(super::dismissed(&a, doc, &kl, &index), "closed as kl-GL: kl stays closed");
assert!(!super::dismissed(&a, doc, &pt_pt, &index), "other languages aren't");
assert!(!super::dismissed(&a, doc.wrapping_add(1), &kl, &index), "nor other documents");
// Closing a regional pack's bar leaves the base language's (Swiss German, then German).
let de = packs::Pack { locale: "de".into(), ..kl.clone() };
let de_ch = packs::Pack { locale: "de-ch".into(), ..kl.clone() };
let index = vec![de.clone(), de_ch.clone()];
a.packs_rt.dismissed.insert((doc, "de-ch".into()));
assert!(super::dismissed(&a, doc, &de_ch, &index));
assert!(!super::dismissed(&a, doc, &de, &index), "German's bar still shows");
// Closed as de-CH before the list was known: Swiss German's pack, not German's.
a.packs_rt.dismissed.clear();
a.packs_rt.dismissed_tags.insert((doc, "de-CH".into()));
assert!(super::dismissed(&a, doc, &de_ch, &index));
assert!(!super::dismissed(&a, doc, &de, &index), "a de-CH tag doesn't close German");
}
/// Martin's test: a spelling issue's context menu heading is in the interface language, not
/// the text's (craft-proof's message for French text is "Faute d'orthographe possible").
#[test]
fn the_spelling_heading_is_in_the_interface_language() {
let french = json!({"kind": "spelling", "message": "Faute d\u{2019}orthographe possible", "lang": "fr"});
assert_eq!(crate::canvas::issue_heading(&french), tl!("Possible spelling mistake"));
let grammar = json!({"kind": "grammar", "message": "Repeated word"});
assert_eq!(crate::canvas::issue_heading(&grammar), tl!("Repeated word"));
}
}
+7 -1
View File
@@ -47,11 +47,17 @@ use insert::insert;
#[cfg(test)]
pub(crate) use language::PROOFING_LANGUAGES;
#[cfg(test)]
pub(crate) use language::all_languages;
#[cfg(test)]
use language::language_checks;
pub(crate) use language::language_name;
#[cfg(test)]
pub(crate) use language::short_languages;
#[cfg(test)]
pub(crate) use language::sort_key;
pub(crate) use language::{
caret_language, caret_language_proofed, caret_missing_dictionary, caret_no_proof, caret_unreadable_dictionary, language_menu,
};
pub(crate) use language::{display_name, language_list, language_name, same_language, tag_for_pack};
use layout::layout;
#[cfg(test)]
use layout::{columns_items, margins_items, orientation_items};
+366 -41
View File
@@ -20,36 +20,221 @@ pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [
("es-UY", "Spanish (Uruguay)"),
];
/// Every language WordCraft has an English name for, by tag: the Language menu's short list, the
/// Language dialog's built-in list, then the Language pack languages neither has (Bulgarian…).
fn known() -> impl Iterator<Item = &'static (&'static str, &'static str)> {
PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).chain(crate::language_packs::MORE_LANGUAGES.iter())
}
/// The language tag a document stores for text in a Language pack's language: the tag WordCraft
/// already names for it (`nl` → `nl-NL`, `sr-latn` → `sr-Latn-RS`, `bg` → `bg-BG`), else the
/// locale in BCP 47 case (`de-at` → `de-AT`). [`wordcraft_engine::packs::pack_for_tag`] finds the
/// same pack again from it, so text marked in a pack's language is proofed by that pack.
pub(crate) fn tag_for_pack(locale: &str) -> String {
let wanted = locale.replace('_', "-");
let exact = known().find(|(t, _)| t.eq_ignore_ascii_case(&wanted));
// A variant WordCraft names with its region (`ca-valencia` is `ca-ES-valencia`).
let regioned = || known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(&wanted));
if let Some((t, _)) = exact.or_else(|| named_starting(&wanted)).or_else(regioned) {
return (*t).to_string();
}
let parts: Vec<String> = locale
.split(['-', '_'])
.enumerate()
.map(|(i, p)| {
let alpha = p.chars().all(|c| c.is_ascii_alphabetic());
if i > 0 && wordcraft_engine::packs::is_region(p) {
p.to_ascii_uppercase()
} else if i > 0 && alpha && p.len() == 4 {
let lower = p.to_ascii_lowercase();
let mut c = lower.chars();
c.next().map(|f| f.to_ascii_uppercase().to_string() + c.as_str()).unwrap_or_default()
} else {
p.to_ascii_lowercase()
}
})
.collect();
parts.join("-")
}
/// The first language WordCraft names whose tag starts with `prefix` and a `-` (`bg` is
/// `bg-BG`, `sr-Latn` is `sr-Latn-RS`).
fn named_starting(prefix: &str) -> Option<&'static (&'static str, &'static str)> {
known().find(|(t, _)| {
t.len() > prefix.len()
&& t.get(..prefix.len()).is_some_and(|p| p.eq_ignore_ascii_case(prefix))
&& t.as_bytes().get(prefix.len()) == Some(&b'-')
})
}
/// A pack language's name for the lists, in English: the name WordCraft has for its tag, else the
/// pack's own name (as shown, without format characters), else the tag.
fn pack_language_name(tag: &str, endonym: &str) -> String {
let named = language_name(Some(tag));
if !named.eq_ignore_ascii_case(tag) || endonym.trim().is_empty() {
return named;
}
crate::language_packs::shown(endonym, 40)
}
/// The installed and available Language packs, as (document tag, pack's own name), installed first.
fn pack_languages(available: bool) -> Vec<(String, String)> {
let installed = wordcraft_engine::packs::installed().into_iter().map(|i| (i.locale, i.name));
let index: Vec<(String, String)> =
if available { wordcraft_engine::packs::available().into_iter().map(|p| (p.locale, p.name)).collect() } else { Vec::new() };
installed.chain(index).map(|(locale, name)| (tag_for_pack(&locale), name)).collect()
}
/// Review › Language's and the status bar's short list, as (tag, English name): the languages
/// WordCraft proofs itself, then each installed Language pack's language not among them, by name.
pub(crate) fn short_languages() -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = PROOFING_LANGUAGES.iter().map(|(t, n)| ((*t).to_string(), (*n).to_string())).collect();
let mut more: Vec<(String, String)> = Vec::new();
for (tag, endonym) in pack_languages(false) {
if !out.iter().chain(&more).any(|(t, _)| t.eq_ignore_ascii_case(&tag)) {
let name = pack_language_name(&tag, &endonym);
more.push((tag, name));
}
}
more.sort_by_cached_key(|(t, n)| sort_key(&display_of(t, n)));
out.extend(more);
out
}
/// The Set Proofing Language dialog's list, as (tag, name in the interface language), sorted by
/// name: every language WordCraft names, every installed Language pack's and every available
/// one's, each once.
pub(crate) fn all_languages() -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
let builtin = PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).map(|(t, n)| ((*t).to_string(), (*n).to_string()));
let packs = pack_languages(true).into_iter().map(|(tag, endonym)| {
let name = pack_language_name(&tag, &endonym);
(tag, name)
});
for (tag, name) in builtin.chain(packs) {
if !out.iter().any(|(t, _)| t.eq_ignore_ascii_case(&tag)) {
out.push((tag.clone(), display_of(&tag, &name)));
}
}
out.sort_by_cached_key(|(_, n)| sort_key(n));
out
}
/// The Set Proofing Language dialog's list: [`all_languages`], with the `current` language when
/// it is none of them ([`same_language`]: a row is checked for it then).
pub(crate) fn language_list(current: &str) -> Vec<(String, String)> {
let mut list = all_languages();
// A tag the list has under another spelling (`nl` for `nl-NL`) is that row, not another.
if !current.is_empty() && !list.iter().any(|(t, _)| same_language(current, t)) {
list.push((current.to_string(), display_name(Some(current))));
list.sort_by_cached_key(|(_, n)| sort_key(n));
}
list
}
/// How a language name sorts: case-insensitively, a Latin letter with a mark beside the plain one
/// (Čeština with Czech, Ελληνικά and other scripts after Latin, by code point).
pub(crate) fn sort_key(name: &str) -> (String, String) {
const FOLD: [(&str, &str); 22] = [
("àáâãäåāăą", "a"),
("æ", "ae"),
("çćĉċč", "c"),
("ďđð", "d"),
("èéêëēĕėęě", "e"),
("ĝğġģ", "g"),
("ĥħ", "h"),
("ìíîïĩīĭįı", "i"),
("ĵ", "j"),
("ķ", "k"),
("ĺļľŀł", "l"),
("ñńņňʼn", "n"),
("òóôõöøōŏő", "o"),
("œ", "oe"),
("ŕŗř", "r"),
("śŝşšș", "s"),
("ß", "ss"),
("ţťŧțþ", "t"),
("ùúûüũūŭůűų", "u"),
("ŵ", "w"),
("ýÿŷ", "y"),
("źżž", "z"),
];
let lower = name.to_lowercase();
let mut folded = String::with_capacity(lower.len());
for c in lower.chars() {
match FOLD.iter().find(|(from, _)| from.contains(c)) {
Some((_, to)) => folded.push_str(to),
None => folded.push(c),
}
}
(folded, lower)
}
/// A language's name in English, as the lists build it: a whole name (`Polish`, `Serbian (Latin)`)
/// or a language with a region the lists have no entry for (`Swedish` and `Finland`).
enum NameParts {
Whole(String),
Regioned { language: String, region: String },
}
/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's
/// default, US English): a listed language by its name; another region of one by the language
/// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag.
pub(crate) fn language_name(tag: Option<&str>) -> String {
match name_parts(tag) {
NameParts::Whole(n) => n,
NameParts::Regioned { language, region } => format!("{language} ({region})"),
}
}
/// [`language_name`] in the interface language: a whole name is looked up as it is, a language
/// with a region is the translated `{language} ({region})` of the translated language and region
/// (a name built at run time has no catalog row of its own). English is unchanged.
pub(crate) fn display_name(tag: Option<&str>) -> String {
display_name_with(tag, &|s| tl!(s).to_string())
}
/// [`display_name`] with the lookup given (`translate`: English in, interface language out).
pub(super) fn display_name_with(tag: Option<&str>, translate: &dyn Fn(&str) -> String) -> String {
match name_parts(tag) {
NameParts::Whole(n) => translate(&n),
NameParts::Regioned { language, region } => {
crate::i18n::fmt(&translate("{language} ({region})"), &[("language", &translate(&language)), ("region", &translate(&region))])
}
}
}
/// The name a list shows for `tag` whose English name is `name`: [`display_name`] for the name
/// WordCraft has for the tag, else `name` as it is (a pack's own name for a language WordCraft
/// has none for).
pub(crate) fn display_of(tag: &str, name: &str) -> String {
if name == language_name(Some(tag)) { display_name(Some(tag)) } else { name.to_string() }
}
fn name_parts(tag: Option<&str>) -> NameParts {
let tag = tag.unwrap_or("en-US");
let named =
|t: &str| PROOFING_LANGUAGES.iter().chain(crate::dialogs_layout::LANGUAGES.iter()).find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n);
let named = |t: &str| known().find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n);
// Any language the Language dialog offers (French, German…) by its name, not its tag (#860).
if let Some(name) = named(tag) {
return name.to_string();
return NameParts::Whole(name.to_string());
}
let mut parts = tag.split(['-', '_']);
let primary = parts.next().unwrap_or("");
// The region: the first two-letter or three-digit subtag after the language (and script).
let region =
parts.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())));
// The region: the first region subtag after the language (and script).
let region = parts.find(|p| wordcraft_engine::packs::is_region(p));
match region {
// A region the list doesn't have: the language's name without its own region or variant.
Some(r) => {
let language = crate::dialogs_layout::LANGUAGES
.iter()
let language = known()
.find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary)))
.map(|(_, n)| n.split(" (").next().unwrap_or(n));
match language {
Some(language) => format!("{language} ({})", region_name(r)),
None => tag.to_string(),
Some(language) => NameParts::Regioned { language: language.to_string(), region: region_name(r) },
None => NameParts::Whole(tag.to_string()),
}
}
// A bare language subtag ("pt", "pl"): the variant WordCraft proofs.
None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
None => NameParts::Whole(match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
wordcraft_engine::proof::ProofLang::Pl => "Polish".into(),
wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(),
wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(),
@@ -61,14 +246,18 @@ pub(crate) fn language_name(tag: Option<&str>) -> String {
wordcraft_engine::proof::ProofLang::Es => "Spanish (Spain)".into(),
wordcraft_engine::proof::ProofLang::De => "German (Germany)".into(),
wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(),
_ => tag.to_string(),
},
// A language (and script) without its region: the named tag it starts (`bg` is
// Bulgarian, `sr-Latn` Serbian (Latin)).
_ => named_starting(tag)
.or_else(|| known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(tag)))
.map_or_else(|| tag.to_string(), |(_, n)| (*n).to_string()),
}),
}
}
/// The English name of a region subtag the language list has no entry for (else the subtag).
fn region_name(code: &str) -> String {
const REGIONS: [(&str, &str); 62] = [
const REGIONS: [(&str, &str); 69] = [
("AE", "U.A.E."),
("AO", "Angola"),
("AR", "Argentina"),
@@ -85,6 +274,7 @@ fn region_name(code: &str) -> String {
("CN", "China"),
("CO", "Colombia"),
("CR", "Costa Rica"),
("CU", "Cuba"),
("CV", "Cabo Verde"),
("CY", "Cyprus"),
("DE", "Germany"),
@@ -92,10 +282,13 @@ fn region_name(code: &str) -> String {
("EC", "Ecuador"),
("EG", "Egypt"),
("ES", "Spain"),
("FI", "Finland"),
("FR", "France"),
("GB", "United Kingdom"),
("GQ", "Equatorial Guinea"),
("GT", "Guatemala"),
("HK", "Hong Kong SAR"),
("HN", "Honduras"),
("IE", "Ireland"),
("IN", "India"),
("IQ", "Iraq"),
@@ -114,8 +307,10 @@ fn region_name(code: &str) -> String {
("MX", "Mexico"),
("MY", "Malaysia"),
("MZ", "Mozambique"),
("NI", "Nicaragua"),
("NL", "Netherlands"),
("NZ", "New Zealand"),
("PA", "Panama"),
("PE", "Peru"),
("PH", "Philippines"),
("PR", "Puerto Rico"),
@@ -124,6 +319,7 @@ fn region_name(code: &str) -> String {
("QA", "Qatar"),
("RU", "Russia"),
("SG", "Singapore"),
("SV", "El Salvador"),
("SY", "Syria"),
("TW", "Taiwan"),
("UA", "Ukraine"),
@@ -137,7 +333,7 @@ fn region_name(code: &str) -> String {
/// The language at the caret (resolved through the styles), translated, for the status bar.
pub(crate) fn caret_language(app: &WordApp) -> String {
tl!(&language_name(caret_lang_tag(app).as_deref())).to_string()
display_name(caret_lang_tag(app).as_deref())
}
/// The formatting at the caret, resolved through the styles and the document default.
@@ -189,24 +385,13 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
_ => String::new(),
};
let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false);
let listed = |tag: &str| {
current.eq_ignore_ascii_case(tag)
|| (tag == "pl-PL" && current.eq_ignore_ascii_case("pl"))
|| (tag == "pt-BR" && current.eq_ignore_ascii_case("pt"))
|| (tag == "it-IT" && current.eq_ignore_ascii_case("it"))
|| (tag == "hr-HR" && current.eq_ignore_ascii_case("hr"))
|| (tag == "tr-TR" && current.eq_ignore_ascii_case("tr"))
|| (tag == "cs-CZ" && wordcraft_engine::proof::ProofLang::from_tag(Some(&current)) == wordcraft_engine::proof::ProofLang::Cs)
|| (tag == "id-ID" && wordcraft_engine::proof::ProofLang::from_tag(Some(&current)) == wordcraft_engine::proof::ProofLang::Id)
|| (tag == "es-ES" && current.eq_ignore_ascii_case("es"))
|| (tag == "en-US" && current.eq_ignore_ascii_case("en"))
};
let checks = language_checks(&current, listed);
if let Some(name) = checks.extra {
mi_check(ui, app, &name, true, "review.language", json!({"lang": current}));
let entries = short_languages();
let checks = language_checks(&current, &entries);
if checks.extra.is_some() {
mi_check(ui, app, &display_name(Some(&current)), true, "review.language", json!({"lang": current}));
}
for ((tag, name), on) in PROOFING_LANGUAGES.iter().zip(checks.listed) {
mi_check(ui, app, name, on, "review.language", json!({"lang": tag}));
for ((tag, name), on) in entries.iter().zip(checks.listed) {
mi_check(ui, app, &display_of(tag, name), on, "review.language", json!({"lang": tag}));
}
ui.separator();
mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof}));
@@ -215,19 +400,159 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
mi_check(ui, app, "Language Preferences…", false, "file.options", json!({"pane": "language"}));
}
/// The Language menu's check marks for the `current` tag: which short-list entries are checked
/// (`listed` matches a tag), and the name of the current language when it is none of them,
/// shown checked above. A language named like a short-list entry checks that entry, so no name
/// shows twice.
/// The Language menu's check marks for the `current` tag: which `entries` (tag, English name) are
/// checked, and the name of the current language when it is none of them, shown checked above.
/// An entry is checked for its own tag (any case), or for a tag that is the same language
/// ([`same_language`]: `pl` for Polish, `ces` for Czech, `PT-br-x-test` for Portuguese (Brazil)),
/// so no language shows twice; English (New Zealand) doesn't check English (United Kingdom).
pub(super) struct LanguageChecks {
pub(super) extra: Option<String>,
pub(super) listed: [bool; PROOFING_LANGUAGES.len()],
pub(super) listed: Vec<bool>,
}
pub(super) fn language_checks(current: &str, listed: impl Fn(&str) -> bool) -> LanguageChecks {
let name = (!current.is_empty()).then(|| language_name(Some(current)));
let on = PROOFING_LANGUAGES.map(|(t, n)| listed(t) || name.as_deref() == Some(n));
LanguageChecks { extra: name.filter(|_| !on.contains(&true)), listed: on }
pub(super) fn language_checks(current: &str, entries: &[(String, String)]) -> LanguageChecks {
let on: Vec<bool> = entries.iter().map(|(t, _)| same_language(current, t)).collect();
let extra = (!current.is_empty() && !on.contains(&true)).then(|| language_name(Some(current)));
LanguageChecks { extra, listed: on }
}
/// A language tag's parts, lowercase: what [`same_language`] compares instead of names.
#[derive(Default)]
struct TagParts {
language: String,
/// Four letters (`latn`).
script: Option<String>,
/// [`wordcraft_engine::packs::is_region`].
region: Option<String>,
/// Five to eight letters or digits, or four starting with a digit (`valencia`).
variants: Vec<String>,
}
/// A tag's parts. An extension or private use (`-x-test`) ends the tag; a tag is a handful of short
/// subtags, so only the start of a longer one is read.
fn tag_parts(tag: &str) -> TagParts {
let lower = tag.trim().replace('_', "-").to_ascii_lowercase();
let mut subtags = lower.split('-').take(8);
let mut out = TagParts { language: subtags.next().unwrap_or("").to_string(), ..Default::default() };
for p in subtags {
let alnum = p.bytes().all(|b| b.is_ascii_alphanumeric());
if p.len() < 2 || !alnum {
break;
}
if out.script.is_none() && out.region.is_none() && p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphabetic()) {
out.script = Some(p.to_string());
} else if out.region.is_none() && wordcraft_engine::packs::is_region(p) {
out.region = Some(p.to_string());
} else if (5..=8).contains(&p.len()) || (p.len() == 4 && p.bytes().next().is_some_and(|b| b.is_ascii_digit())) {
out.variants.push(p.to_string());
}
}
out
}
/// A tag's parts, with the region (and script) the lists name it with when it has none: `nl` is
/// `nl-NL`, `sr-latn` is `sr-Latn-RS`, `pt` is `pt-BR`.
fn listed_parts(tag: &str) -> TagParts {
let mut parts = tag_parts(tag);
if parts.region.is_none() {
let named = tag_parts(&tag_for_pack(tag));
parts.region = named.region;
if parts.script.is_none() {
parts.script = named.script;
}
}
parts
}
/// Are `a` and `b` the same language as the lists show it: the same tag (any case), or tags that
/// agree in script, region and variant (one lacking a script or region takes the one the lists name
/// it with) and that WordCraft proofs alike ([`same_proofing`]): `nl` is the `nl-NL` row, `pl` the
/// `pl-PL` one, `ces` the `cs-CZ` one. Tags are compared, never the names the lists show for them.
pub(crate) fn same_language(a: &str, b: &str) -> bool {
if a.eq_ignore_ascii_case(b) {
return true;
}
if a.trim().is_empty() || b.trim().is_empty() {
return false;
}
let (pa, pb) = (listed_parts(a), listed_parts(b));
let agree = |x: &Option<String>, y: &Option<String>| x.is_none() || y.is_none() || x == y;
// A language the lists name no region for (`xx`, `kl`) is not its regioned tag (`xx-AA`): they
// stay separate rows. Rules WordCraft has built in (`ces` for `cs-CZ`) match without one.
use wordcraft_engine::proof::ProofLang;
if pa.region.is_none() != pb.region.is_none() && matches!(ProofLang::from_tag(Some(a)), ProofLang::Pack(_) | ProofLang::Other) {
return false;
}
agree(&pa.script, &pb.script) && agree(&pa.region, &pb.region) && variants_agree(&pa.variants, &pb.variants) && same_proofing(a, b)
}
/// Do two tags' variants (`valencia`, `1996`) agree: the same ones, or one tag has none and the
/// other's are no variant a pack or the lists name (`de-DE-1996` is the `de-DE` row, but
/// `ca-ES-valencia` is not the `ca` one).
fn variants_agree(a: &[String], b: &[String]) -> bool {
let (mut a, mut b) = (a.to_vec(), b.to_vec());
a.sort();
b.sort();
if a == b {
return true;
}
let named = |v: &String| {
let in_tag = |t: &str| t.split(['-', '_']).skip(1).any(|p| p.eq_ignore_ascii_case(v));
known().any(|(t, _)| in_tag(t))
|| wordcraft_engine::packs::available().iter().any(|p| in_tag(&p.locale))
|| wordcraft_engine::packs::installed().iter().any(|p| in_tag(&p.locale))
};
let extra = if a.is_empty() {
&b
} else if b.is_empty() {
&a
} else {
return false;
};
!extra.iter().any(named)
}
/// Does WordCraft proof `a` and `b` the same way: the same built-in language, or two Language
/// pack languages read from the same pack ([`wordcraft_engine::packs::pack_for_tag`]: `nl` and
/// `nl-NL` are both the `nl` pack's, though their pack locales differ); with neither pack known,
/// the same language and script whatever the region. Languages WordCraft has no rules or pack for
/// are the same only when their language subtags are.
fn same_proofing(a: &str, b: &str) -> bool {
use wordcraft_engine::packs;
use wordcraft_engine::proof::ProofLang;
let (la, lb) = (ProofLang::from_tag(Some(a)), ProofLang::from_tag(Some(b)));
if la == lb {
// Languages without rules or a pack are the same only as the same language, script and
// region (`xx` is not `xx-AA`).
if la == ProofLang::Other {
let (pa, pb) = (tag_parts(a), tag_parts(b));
return pa.language == pb.language && pa.script == pb.script && pa.region == pb.region;
}
return true;
}
let (Some(pa), Some(pb)) = (la.pack_locale(), lb.pack_locale()) else { return false };
let mut known = packs::available();
for i in packs::installed() {
if !known.iter().any(|p| p.locale == i.locale) {
known.push(packs::Pack {
locale: i.locale,
name: i.name,
version: String::new(),
url: String::new(),
source: None,
size: 0,
licence: i.licence,
class: String::new(),
spelling: false,
hyphenation: false,
});
}
}
match (packs::pack_for_tag(a, &known), packs::pack_for_tag(b, &known)) {
(Some(x), Some(y)) => x.locale == y.locale,
(None, None) => packs::without_region(pa.as_str()).eq_ignore_ascii_case(&packs::without_region(pb.as_str())),
_ => false,
}
}
/// A menu item with a check mark when `checked` (the current choice of several).
+113 -9
View File
@@ -229,6 +229,111 @@ fn page_setup_menus_mark_the_current_setting() {
assert_eq!(first_words, ["Narrow", "Landscape", "A4", "Right"], "{checked:?}");
}
/// The Language menu's built-in short list, as `language_checks` takes it.
fn short() -> Vec<(String, String)> {
PROOFING_LANGUAGES.iter().map(|(t, n)| ((*t).to_string(), (*n).to_string())).collect()
}
/// Check marks with only `tag` checked.
fn listed_only(tag: &str) -> Vec<bool> {
PROOFING_LANGUAGES.iter().map(|(t, _)| *t == tag).collect()
}
/// CP4.3: the menu's check marks are worked out one way (the tag, or the same proofing language
/// named alike), not by a case per language: a bare or ISO 639-2 tag checks its language, and a
/// region WordCraft proofs like another (New Zealand like the UK) doesn't check that one.
#[test]
fn language_menu_checks_by_tag_or_proofing_language() {
for (current, tag) in [
("pl", "pl-PL"),
("pt", "pt-BR"),
("it", "it-IT"),
("hr", "hr-HR"),
("tr", "tr-TR"),
("ces", "cs-CZ"),
("in", "id-ID"),
("es", "es-ES"),
("en", "en-US"),
("RU-ru", "ru-RU"),
] {
let c = language_checks(current, &short());
assert_eq!((c.extra, c.listed), (None, listed_only(tag)), "{current}");
}
let c = language_checks("en-NZ", &short());
assert_eq!(c.extra.as_deref(), Some("English (New Zealand)"));
assert!(!c.listed.contains(&true), "English (New Zealand) doesn't check English (United Kingdom)");
// A pack language in the list (once installed) is checked for its bare tag too.
let mut entries = short();
entries.push(("bg-BG".into(), "Bulgarian".into()));
let c = language_checks("bg", &entries);
assert_eq!((c.extra, c.listed.last().copied()), (None, Some(true)));
// CP4.3 review: a bare pack-language tag checks the regioned row of the same pack, however
// the packs are known (`nl` and `nl-NL` are both the `nl` pack's), and shows no second Dutch.
entries.push(("nl-NL".into(), "Dutch".into()));
for current in ["nl", "NL-nl"] {
let c = language_checks(current, &entries);
assert_eq!((c.extra, c.listed.last().copied()), (None, Some(true)), "{current}");
}
let c = language_checks("nl-BE", &entries);
assert_eq!((c.extra.as_deref(), c.listed.last().copied()), (Some("Dutch (Belgium)"), Some(false)));
assert!(super::language::same_language("sv", "sv-SE") && !super::language::same_language("sv-FI", "sv-SE"));
// Nothing set (mixed selection): nothing checked, nothing above.
let c = language_checks("", &entries);
assert_eq!((c.extra, c.listed.contains(&true)), (None, false));
}
/// CP4.3 review: rows are matched by tag, never by the name shown. Serbian in Latin and in
/// Cyrillic letters of Bosnia show under the same name but are two languages, so neither checks
/// the other's row; a name that is translated or different doesn't matter to the match.
#[test]
fn rows_are_matched_by_tag_not_by_display_name() {
use super::language::same_language;
assert_eq!(language_name(Some("sr-Latn-BA")), language_name(Some("sr-Cyrl-BA")), "same display name");
assert!(!same_language("sr-Latn-BA", "sr-Cyrl-BA"));
let entries = vec![("sr-Latn-BA".to_string(), "Serbian".to_string()), ("sr-Cyrl-BA".to_string(), "Serbian".to_string())];
assert_eq!(language_checks("sr-Cyrl-BA", &entries).listed, [false, true]);
// A variant on an otherwise equal tag is the row without it, unless a pack or the lists name
// the variant (Valencian is not Catalan); a language WordCraft has nothing for keeps its regions apart.
assert!(same_language("de-DE-1996", "de-DE") && same_language("de-DE", "de-DE-1996"));
assert!(!same_language("ca-ES-valencia", "ca-ES"));
assert!(!same_language("xx", "xx-AA"), "xx vs xx-AA");
assert!(same_language("xx-AA", "XX_aa"), "xx-AA vs XX_aa");
assert_eq!(language_checks("de-DE-1996", &[("de-DE".to_string(), "German (Germany)".to_string())]).listed, [true]);
// Two unknown languages never merge, though neither has a name but its tag.
assert!(!same_language("xx", "yy") && !same_language("xx-AA", "yy-AA"));
// The row's name can be anything (translated): the tag decides.
let renamed = vec![("pl-PL".to_string(), "Polski".to_string())];
assert_eq!(language_checks("pl", &renamed).listed, [true]);
assert_eq!(language_checks("PL_pl", &renamed).listed, [true]);
assert_eq!(language_checks("pt", &[("pt-PT".to_string(), "Portuguese (Brazil)".to_string())]).listed, [false]);
}
/// CP4.3 review: a name built from a language and a region (`Swedish (Finland)`) has no catalog row
/// of its own, so the language, the region and the `{language} ({region})` template are looked up
/// separately and joined. English stays as `language_name` builds it.
#[test]
fn a_language_and_region_name_is_translated_in_parts() {
use super::language::display_name_with;
let fake = |s: &str| -> String {
match s {
"Swedish" => "X".into(),
"Finland" => "Y".into(),
"{language} ({region})" => "{language} \u{2013} {region}".into(),
other => other.to_string(),
}
};
assert_eq!(display_name_with(Some("sv-FI"), &fake), "X \u{2013} Y");
// A whole name is looked up as it is; the template isn't used for it.
assert_eq!(display_name_with(Some("sv-SE"), &fake), "X");
// Without a catalogue (English) the name is byte-identical to the English name.
for tag in ["sv-FI", "es-CU", "fr-CA", "de-CH", "pt-AO", "en-SG", "sr-Latn-BA", "pl-PL", "nl", "xx-YY", "xx", "ca-valencia"] {
assert_eq!(super::language::display_name(Some(tag)), language_name(Some(tag)), "{tag}");
}
assert_eq!(language_name(Some("sv-FI")), "Swedish (Finland)");
// Matching never uses a translated name: Finnish Swedish and Swedish of Sweden stay two rows.
assert!(!super::language::same_language("sv-FI", "sv-SE"));
}
/// #860 review: a tag the language list doesn't have shows by language and region, not as
/// the tag; Portuguese (Angola) isn't Brazil's, and no name shows twice in the menu.
#[test]
@@ -240,17 +345,16 @@ fn other_regions_show_by_name_once() {
assert_eq!(language_name(Some("sr-Latn-BA")), "Serbian (Bosnia and Herzegovina)");
assert_eq!(language_name(Some("en-ZZ")), "English (ZZ)");
assert_eq!(language_name(Some("xx-YY")), "xx-YY");
let exact = |cur: &'static str| move |t: &str| t.eq_ignore_ascii_case(cur);
for tag in ["fr-CA", "pt-AO", "en-SG"] {
let c = language_checks(tag, exact(tag));
let c = language_checks(tag, &short());
let name = c.extra.expect("shown above the list");
assert!(!PROOFING_LANGUAGES.iter().any(|(_, n)| *n == name), "{name} is listed once");
assert!(!c.listed.contains(&true), "{tag}: nothing in the list checked");
}
// A tag named like a list entry checks that entry instead of showing it again.
let c = language_checks("PT-br-x-test", |_| false);
let c = language_checks("PT-br-x-test", &short());
assert_eq!(c.extra, None);
assert_eq!(c.listed, PROOFING_LANGUAGES.map(|(t, _)| t == "pt-BR"));
assert_eq!(c.listed, listed_only("pt-BR"));
}
/// The Englishes WordCraft proofs differently are in the menu, checked by their tags, and
@@ -262,8 +366,8 @@ fn english_dialects_are_in_the_language_menu() {
{
assert!(PROOFING_LANGUAGES.contains(&(tag, name)), "{tag}");
assert_eq!(language_name(Some(tag)), name);
let c = language_checks(&tag.to_ascii_lowercase(), |t| t.eq_ignore_ascii_case(tag));
assert_eq!((c.extra, c.listed), (None, PROOFING_LANGUAGES.map(|(t, _)| t == tag)), "{tag}");
let c = language_checks(&tag.to_ascii_lowercase(), &short());
assert_eq!((c.extra, c.listed), (None, listed_only(tag)), "{tag}");
}
for (tag, name) in
[("en-NZ", "English (New Zealand)"), ("en-IE", "English (Ireland)"), ("en-ZA", "English (South Africa)"), ("en-IN", "English (India)")]
@@ -292,12 +396,12 @@ fn spanish_varieties_are_in_the_language_menu() {
assert!(PROOFING_LANGUAGES.contains(&(tag, name)), "{tag}");
assert!(crate::dialogs_layout::LANGUAGES.contains(&(tag, name)), "{tag}");
assert_eq!(language_name(Some(tag)), name);
let c = language_checks(&tag.to_ascii_lowercase(), |t| t.eq_ignore_ascii_case(tag));
assert_eq!((c.extra, c.listed), (None, PROOFING_LANGUAGES.map(|(t, _)| t == tag)), "{tag}");
let c = language_checks(&tag.to_ascii_lowercase(), &short());
assert_eq!((c.extra, c.listed), (None, listed_only(tag)), "{tag}");
}
assert_eq!(language_name(Some("es")), "Spanish (Spain)");
assert_eq!(language_name(Some("es-AR")), "Spanish (Argentina)");
let c = language_checks("es-AR", |t| t.eq_ignore_ascii_case("es-AR"));
let c = language_checks("es-AR", &short());
assert_eq!(c.extra.as_deref(), Some("Spanish (Argentina)"));
assert!(!c.listed.contains(&true));
}
+6 -3
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+21 -5
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Interface languages, script support and proofing per language. How translation works (adding a
language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and
@@ -44,8 +44,7 @@ native-speaker review (human).
- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). Arabic UI landed (#1195).
- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech, Indonesian and Spanish**, chosen per run from its language (`w:lang`, resolved through
the styles; Review › Language and the status bar set it: a short list, plus Set Proofing
Language… for any of 38 languages and Set As Default for the document and future blank documents). Languages without proofing tools get
the styles; Review › Language and the status bar set it). Languages without proofing tools get
Language… for every built-in language and every installed or available Language pack language, and Set As Default for the document and future blank documents). Languages without proofing tools get
no spelling or grammar marks (as in Word without the language's tools) instead of being flagged
as English. Detail in [Proofing per language](#proofing-per-language).
- Document scripts: shaping via HarfRust (HarfBuzz port) for every script; bidi (UAX #9) and Arabic
@@ -64,7 +63,7 @@ shared items below.
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) |
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (spelling and suggestions from the French Language pack, elided forms first: `Jadore` → `J'adore`; no grammar marks) | 1–2 (grammar) |
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; ruby (Phonetic Guide) over its base text, kept by HTML export/import (#1681); word count counts each Han ideograph and kana as a word, not punctuation (#4547); no vertical text or document grid | no | partial | 3–5 |
@@ -175,9 +174,21 @@ pinned pack index and installed through the font store into `<font store>/langua
agents). A pack is read at once; the lookup order is the dictionaries folder, the installed pack,
then the system's Hunspell dictionaries.
- A document with text in a language whose pack is available but not installed shows a bar
offering it; it never shows for a language without a pack, and closes per document.
offering it, and closes per document. Once the list of packs is known it never shows for a
language without a pack; before then (a fresh install) it offers a language with no dictionary
provisionally: Install fetches the list, then installs the pack if there is one, and the bar goes
if there isn't. The caret's typing language counts, and the spelling context menu's heading is in
the interface language.
- Help › About › Language data shows the full licence texts of the embedded English data and the
installed packs.
- One language list: Set Proofing Language lists the built-in languages and every installed or
available pack's language (Bulgarian, Belarusian, Spanish (Cuba)…), each once and sorted by
name; Review › Language and the status bar's menu show the short list plus the installed packs'
languages. A pack's language is stored as the tag WordCraft names (`bg-BG`, `sr-Latn-RS`,
`de-AT`, Word's `ca-ES-valencia` for Valencian), which finds that pack again, so the text is
proofed by it; a bare tag (`nl`) is the same row as its regioned one (`nl-NL`), compared by tag
(language, script, region, variant), never by the name shown. Every pack in the published
index has an English name.
- language-packs-2 is published and pinned (75 packs, craft-i18n `20467b63`), adding the
compounding languages (Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian,
Persian, Esperanto); Mongolian and Thai stay hidden. Install reads it,
@@ -256,7 +267,12 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review |
| 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review |
| 2026-11-03 | trivial | craft-proof pin moved to craft-libs main with #589 and #590: French suggestions offer the elided form first (`Jadore` → `J'adore` before `J adore`; English keeps plural fixes first), and Valencian (`ca-ES-valencia`) text reads the `ca-valencia` pack |
| 2026-11-03 | minor | Arabic (`ar`) interface: ~2,730 catalog rows from craft-i18n, `ar-*` locales, mirrored shell (ribbon, panes, File page, dialogs, menus), Arabic UI font fallback, document canvas independent (#1195); 28 languages; Hebrew UI still open |
| 2026-11-03 | trivial | Language lists: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English (Belarusian); Valencian is `ca-ES-valencia`; rows are matched by tag, not by the name shown |
| 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language |
| 2026-11-03 | trivial | Language packs: language-packs-2 published and pinned, 75 packs, with Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto |
| 2026-11-02 | trivial | Language packs (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded |
| 2026-11-01 | minor | Language packs (CP4.3–CP4.5): install and remove a language's spelling and hyphenation from File › Options › Language or the missing-pack bar, About › Language data, the commands; the default build embeds English data only; the language requests point at their packs: Lithuanian and Romanian available once language-packs-1 is published, the others waiting on craft-proof engine support |
+8 -2
View File
@@ -1,6 +1,6 @@
# Installing languages and spelling dictionaries
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian,
Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that
@@ -32,7 +32,12 @@ built in; every other language is a pack.
again. The first time, click **Show available languages** to get the list.
- When a document has text in a language whose pack isn't installed, a bar under the ribbon says
*Dutch proofing isn't installed.* with an **Install** button showing the download size. ✕ hides
it for that document. It never shows for a language that has no pack.
it for that document. Once WordCraft has the list of packs, the bar shows only for a language
that has a pack. Before the list has been fetched (a fresh install, or the list of an older
WordCraft), it can't know, so it shows for a language that has no dictionary, with **Install**
alone: clicking it gets the list, then the pack if there is one (if not, the bar goes). With
downloading on, WordCraft fetches a missing or out-of-date list by itself once. Choosing a
language with nothing selected counts too.
- Installing needs **downloading** on: the same single setting as WordCraft's font downloads
(the font store). Clicking Install, Show available languages or Refresh turns it on; nothing is
downloaded before. Each pack is checked against the sha256 in WordCraft's pinned list before it
@@ -183,6 +188,7 @@ Never commit dictionary files to this repository: they stay on the user's system
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list is fetched and gets the list, then the pack, on Install; a missing or stale list is fetched once while downloading is on; the caret's typing language counts |
| 2026-11-03 | trivial | Language packs: language-packs-2 is published and pinned (75 languages, adding the compounding languages); Mongolian and Thai aren't packs yet, and why |
| 2026-11-02 | trivial | Language packs: language-packs-1 is published and pinned (61 languages); which languages aren't packs yet and why (CP4.4) |
| 2026-11-02 | trivial | The Flatpak's own dictionaries folder (`~/.var/app/ai.storyteller.wordcraft/data/wordcraft/dictionaries`); the host's folders are outside its sandbox |
@@ -126,6 +126,6 @@ modules:
# the two equal).
- type: git
url: https://github.com/storytold/craft-i18n
commit: 059cbf95c2a3eb95d637ca8584faca1129d17f48
commit: 9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
dest: craft-i18n
- cargo-sources.json