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
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 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 Ball 9819939d98 Merge branch 'fix/one-language-list' into fix/missing-pack-bar 2026-11-04 00:12:20 +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
9 changed files with 564 additions and 22 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+154
View File
@@ -529,5 +529,159 @@ mod tests {
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;
}
+140 -3
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)
}
@@ -706,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() {
@@ -715,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)
@@ -727,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 {
+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());
}
+235 -10
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`.
@@ -158,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;
@@ -244,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.
@@ -272,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());
@@ -311,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 {
@@ -920,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"));
}
}
+3 -2
View File
File diff suppressed because one or more lines are too long
+7 -2
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** 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
> **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
@@ -174,7 +174,11 @@ 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
@@ -268,6 +272,7 @@ WordCraft keeps 2,54 cm).
| 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 |