From f6a6e6c256bf7314ecacf853a922fcbaf85cea43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Romero?= Date: Mon, 9 Nov 2026 23:22:14 +0100 Subject: [PATCH] feat: notification words without the window --- Cargo.lock | 1 + Cargo.toml | 5 +- build.rs | 86 +++++++ platform/android/Cargo.lock | 1 + platform/ohos/Cargo.lock | 1 + src/notifications.rs | 5 + src/notifications/words.rs | 458 ++++++++++++++++++++++++++++++++++++ 7 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 src/notifications/words.rs diff --git a/Cargo.lock b/Cargo.lock index e0a22d3d..f05ab516 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2249,6 +2249,7 @@ dependencies = [ "pulldown-cmark", "reqwest", "rfd", + "rspolib", "seccompiler", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index dc092fa7..622e2565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -251,7 +251,7 @@ auto_link_exclude_list = [ # These do not appear as normal Rust paths on every host, so dependency # scanners need the explicit exceptions. [package.metadata.cargo-machete] -ignored = ["fontique_0_11", "fontique_0_12", "wayland-backend", "slint-build", "winresource", "cc", "cfg_aliases", "windows-core"] +ignored = ["fontique_0_11", "fontique_0_12", "wayland-backend", "slint-build", "winresource", "cc", "cfg_aliases", "rspolib", "windows-core"] # tests/text_fit.rs measures laid-out text through Slint's item tree, which # only the internal crate exposes. It must stay on the version `slint` uses. @@ -263,6 +263,9 @@ async-trait = "0.1" [build-dependencies] cc = "1" cfg_aliases = "0.2" +# Reads the translation catalogs as Slint does, for notification words +# without the window (build.rs). +rspolib = "0.1.2" slint-build = "1.18.1" winresource = "0.1" diff --git a/build.rs b/build.rs index de2a2dae..0a67eb8d 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,7 @@ use cfg_aliases::cfg_aliases; fn main() { + generate_notification_words(); // Shared code selects behaviour by capability, never by naming a mobile // operating system. A new platform joins these groups here (and in the // core's build.rs) instead of editing call sites. See platform/README.md. @@ -93,3 +94,88 @@ fn main() { slint_build::compile_with_config("ui/app.slint", config).expect("failed to compile Slint UI"); } + +/// The words of new-mail notifications from every bundled catalog, for a +/// process that shows them without the window (src/notifications/words.rs). +/// Slint bundles the same catalogs (`with_bundled_translations`); this reads +/// them the way it does, so the words come out the same. +fn generate_notification_words() { + use rspolib::TranslatedEntry; + use std::fmt::Write; + + // As app.slint's I18n asks for them: context, message, plural message. + // Keep in step with WORDS in src/notifications/words.rs. + const SINGLES: &[(&str, &str)] = &[ + ("", "New message"), + ("", "(No subject)"), + ("", "Open"), + ("", "Mark as read"), + ("", "Archive"), + ("", "Delete"), + ("", "New mail"), + ("", "New mail without sound"), + ]; + const PLURALS: &[(&str, &str)] = &[ + ("", "{n} new message"), + ( + "the first senders of new mail, then how many others", + "{0} and {n} more", + ), + ("", "{n} unread message"), + ]; + + let mut languages = std::fs::read_dir("lang") + .expect("reading lang/") + .map(|entry| entry.expect("reading lang/").path()) + .filter(|path| path.join("LC_MESSAGES/flectar-mail.po").exists()) + .collect::>(); + languages.sort(); + let mut out = String::from( + "// Generated by build.rs from lang/*/LC_MESSAGES/flectar-mail.po.\n\ + static CATALOGS: &[Catalog] = &[\n", + ); + for directory in &languages { + let path = directory.join("LC_MESSAGES/flectar-mail.po"); + println!("cargo:rerun-if-changed={}", path.display()); + let catalog = rspolib::pofile(path.as_path()) + .unwrap_or_else(|error| panic!("parsing {}: {error}", path.display())); + let rule = catalog + .metadata + .get("Plural-Forms") + .and_then(|header| { + header.split(';').find_map(|part| { + let (key, expression) = part.split_once('=')?; + (key.trim() == "plural").then(|| expression.trim().to_owned()) + }) + }) + .unwrap_or_else(|| "n != 1".to_owned()); + let language = directory.file_name().unwrap().to_string_lossy(); + let find = |context: &str, message: &str| { + catalog + .find_by_msgid_msgctxt(message, context) + .filter(|entry| entry.translated()) + }; + write!(out, " Catalog {{\n language: {language:?},\n plural: {rule:?},\n singles: &[").unwrap(); + for (context, message) in SINGLES { + match find(context, message).and_then(|entry| entry.msgstr) { + Some(text) => write!(out, "Some({text:?}), ").unwrap(), + None => out.push_str("None, "), + } + } + out.push_str("],\n plurals: &["); + for (context, message) in PLURALS { + match find(context, message) + .map(|entry| entry.msgstr_plural) + .filter(|forms| !forms.is_empty()) + { + Some(forms) => write!(out, "Some(&{forms:?}), ").unwrap(), + None => out.push_str("None, "), + } + } + out.push_str("],\n },\n"); + } + out.push_str("];\n"); + let path = std::path::Path::new(&std::env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")) + .join("notification_words.rs"); + std::fs::write(path, out).expect("writing the notification words"); +} diff --git a/platform/android/Cargo.lock b/platform/android/Cargo.lock index dd152c0d..9e5f5fc9 100644 --- a/platform/android/Cargo.lock +++ b/platform/android/Cargo.lock @@ -1812,6 +1812,7 @@ dependencies = [ "pulldown-cmark", "reqwest", "rfd", + "rspolib", "seccompiler", "serde", "serde_json", diff --git a/platform/ohos/Cargo.lock b/platform/ohos/Cargo.lock index 39d6b3ad..a32ff742 100644 --- a/platform/ohos/Cargo.lock +++ b/platform/ohos/Cargo.lock @@ -1863,6 +1863,7 @@ dependencies = [ "pulldown-cmark", "reqwest", "rfd", + "rspolib", "seccompiler", "serde", "serde_json", diff --git a/src/notifications.rs b/src/notifications.rs index cbddfe30..c88b61aa 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -1174,6 +1174,11 @@ pub struct FixedWords { #[path = "notifications/ui.rs"] pub(crate) mod ui; +/// The words without the window, for a process that runs without it. +#[cfg(any(test, background_engine))] +#[path = "notifications/words.rs"] +mod words; + #[cfg(test)] mod tests { use super::*; diff --git a/src/notifications/words.rs b/src/notifications/words.rs new file mode 100644 index 00000000..2385df0c --- /dev/null +++ b/src/notifications/words.rs @@ -0,0 +1,458 @@ +//! The words of new-mail notifications without the window, as a check in the +//! background on Android shows them. build.rs reads the catalogs Slint +//! bundles, the way Slint reads them; this chooses and fills them in as its +//! `@tr` does, so the words come out the same (see the tests). + +use super::FixedWords; + +struct Catalog { + /// The catalog's directory under lang/, as Slint selects it. + language: &'static str, + /// The gettext expression giving the plural form of `n`. + plural: &'static str, + /// SINGLES in the catalog's language, `None` where untranslated. + singles: &'static [Option<&'static str>], + /// PLURALS' forms in the catalog's language. + plurals: &'static [Option<&'static [&'static str]>], +} + +include!(concat!(env!("OUT_DIR"), "/notification_words.rs")); + +/// The phrases app.slint's I18n uses, in build.rs's order. +const SINGLES: [&str; 8] = [ + "New message", + "(No subject)", + "Open", + "Mark as read", + "Archive", + "Delete", + "New mail", + "New mail without sound", +]; +const PLURALS: [[&str; 2]; 3] = [ + ["{n} new message", "{n} new messages"], + ["{0} and {n} more", "{0} and {n} more"], + ["{n} unread message", "{n} unread messages"], +]; +const NEW_MESSAGES: usize = 0; +const MORE_SENDERS: usize = 1; +const UNREAD_COUNT: usize = 2; + +/// One language's notification words. +pub(crate) struct Speech { + catalog: Option<&'static Catalog>, + rule: Option, +} + +impl Speech { + /// The words in `language`, a name as Slint's `select_bundled_translation` + /// takes it; the source phrases for one without a catalog, such as "en". + pub(crate) fn new(language: &str) -> Self { + let catalog = CATALOGS.iter().find(|catalog| catalog.language == language); + Self { + catalog, + rule: catalog.and_then(|catalog| Rule::parse(catalog.plural)), + } + } + + fn single(&self, index: usize) -> String { + self.catalog + .and_then(|catalog| catalog.singles[index]) + .unwrap_or(SINGLES[index]) + .to_owned() + } + + /// Slint's `translate_from_bundle_with_plural`: the catalog's forms by + /// its rule, else the source forms by English's. + fn plural(&self, index: usize, n: i32, first: Option<&str>) -> String { + let english = usize::from(n != 1); + let (forms, form) = match self.catalog.and_then(|catalog| catalog.plurals[index]) { + Some(forms) => ( + forms, + self.rule.as_ref().map_or(english, |rule| rule.form(n)), + ), + None => (&PLURALS[index][..], english), + }; + let chosen = forms + .get(form) + .or(forms.first()) + .copied() + .unwrap_or_default(); + format(chosen, first, n) + } + + /// The words that don't depend on the mail. + pub(crate) fn fixed(&self) -> FixedWords { + FixedWords { + new_message: self.single(0), + no_subject: self.single(1), + open: self.single(2), + mark_read: self.single(3), + archive: self.single(4), + delete: self.single(5), + new_mail_channel: self.single(6), + quiet_mail_channel: self.single(7), + } + } + + /// "3 new messages", the title of a summary. + pub(crate) fn new_messages(&self, count: i32) -> String { + self.plural(NEW_MESSAGES, count, None) + } + + /// "Ana, Bo and 2 more", the senders of a summary. + pub(crate) fn more_senders(&self, senders: &str, count: i32) -> String { + self.plural(MORE_SENDERS, count, Some(senders)) + } + + /// "3 unread messages", what a screen reader says for the app icon. + pub(crate) fn unread_count(&self, count: i32) -> String { + self.plural(UNREAD_COUNT, count, None) + } +} + +/// Slint's `@tr` formatting: `{}` takes the next argument, `{0}` the first, +/// `{n}` the count, and doubled braces stand for one. There is at most one +/// argument here, `first`; a reference to a missing one stays as written. +fn format(text: &str, first: Option<&str>, n: i32) -> String { + let argument = |key: &str, next: &mut usize| -> Option { + let index = if key.is_empty() { + *next += 1; + Some(*next - 1) + } else { + key.parse::().ok() + }; + match index { + Some(0) => first.map(str::to_owned), + Some(_) => None, + None => (key == "n").then(|| n.to_string()), + } + }; + let mut out = String::with_capacity(text.len() + 8); + let (mut next, mut position) = (0, 0); + while let Some(found) = text[position..].find(['{', '}']) { + let brace = position + found; + if text.len() - position < found + 1 { + break; + } + if &text[brace..=brace] == "}" { + out.push_str(&text[position..=brace]); + position = if text.get(brace + 1..=brace + 1) == Some("}") { + brace + 2 + } else { + brace + 1 + }; + continue; + } + if text.get(brace + 1..=brace + 1) == Some("{") { + out.push_str(&text[position..=brace]); + position = brace + 2; + continue; + } + let Some(end) = text[brace..].find('}').map(|end| end + brace) else { + out.push_str(&text[position..=brace]); + position = brace + 1; + continue; + }; + out.push_str(&text[position..brace]); + match argument(text[brace + 1..end].trim(), &mut next) { + Some(value) => out.push_str(&value), + None => out.push_str(&text[brace..=end]), + } + position = end + 1; + } + out.push_str(&text[position..]); + out +} + +/// A gettext plural expression, such as +/// `n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 ? 1 : 2`. +#[derive(Debug)] +enum Rule { + N, + Number(i64), + Not(Box), + Binary(Box, Op, Box), + Choose(Box, Box, Box), +} + +#[derive(Clone, Copy, Debug)] +enum Op { + Or, + And, + Equal, + NotEqual, + Less, + LessOrEqual, + Greater, + GreaterOrEqual, + Remainder, +} + +impl Rule { + fn parse(text: &str) -> Option { + let mut parser = Parser(text.as_bytes(), 0); + let rule = parser.choose()?; + parser.blank(); + (parser.1 == parser.0.len()).then_some(rule) + } + + /// The plural form of `n`. + fn form(&self, n: i32) -> usize { + usize::try_from(self.value(i64::from(n))).unwrap_or(0) + } + + fn value(&self, n: i64) -> i64 { + match self { + Self::N => n, + Self::Number(number) => *number, + Self::Not(rule) => i64::from(rule.value(n) == 0), + Self::Choose(condition, yes, no) => { + if condition.value(n) != 0 { + yes.value(n) + } else { + no.value(n) + } + } + Self::Binary(left, op, right) => { + let (left, right) = (left.value(n), right.value(n)); + i64::from(match op { + Op::Or => left != 0 || right != 0, + Op::And => left != 0 && right != 0, + Op::Equal => left == right, + Op::NotEqual => left != right, + Op::Less => left < right, + Op::LessOrEqual => left <= right, + Op::Greater => left > right, + Op::GreaterOrEqual => left >= right, + Op::Remainder => return left.checked_rem(right).unwrap_or(0), + }) + } + } + } +} + +/// C's precedence: `?:`, `||`, `&&`, comparisons, `%`, `!`. +struct Parser<'a>(&'a [u8], usize); + +impl Parser<'_> { + fn blank(&mut self) { + while self.0.get(self.1).is_some_and(u8::is_ascii_whitespace) { + self.1 += 1; + } + } + + fn eat(&mut self, token: &str) -> bool { + self.blank(); + let found = self.0[self.1..].starts_with(token.as_bytes()); + if found { + self.1 += token.len(); + } + found + } + + fn choose(&mut self) -> Option { + let condition = self.or()?; + if !self.eat("?") { + return Some(condition); + } + let yes = self.choose()?; + self.eat(":").then_some(())?; + let no = self.choose()?; + Some(Rule::Choose(condition.into(), yes.into(), no.into())) + } + + fn or(&mut self) -> Option { + let mut rule = self.and()?; + while self.eat("||") { + rule = Rule::Binary(rule.into(), Op::Or, self.and()?.into()); + } + Some(rule) + } + + fn and(&mut self) -> Option { + let mut rule = self.compare()?; + while self.eat("&&") { + rule = Rule::Binary(rule.into(), Op::And, self.compare()?.into()); + } + Some(rule) + } + + fn compare(&mut self) -> Option { + let mut rule = self.remainder()?; + loop { + let op = if self.eat("==") { + Op::Equal + } else if self.eat("!=") { + Op::NotEqual + } else if self.eat("<=") { + Op::LessOrEqual + } else if self.eat(">=") { + Op::GreaterOrEqual + } else if self.eat("<") { + Op::Less + } else if self.eat(">") { + Op::Greater + } else { + return Some(rule); + }; + rule = Rule::Binary(rule.into(), op, self.remainder()?.into()); + } + } + + fn remainder(&mut self) -> Option { + let mut rule = self.unary()?; + while self.eat("%") { + rule = Rule::Binary(rule.into(), Op::Remainder, self.unary()?.into()); + } + Some(rule) + } + + fn unary(&mut self) -> Option { + if self.eat("!") && !self.0[self.1..].starts_with(b"=") { + return Some(Rule::Not(self.unary()?.into())); + } + if self.eat("(") { + let rule = self.choose()?; + return self.eat(")").then_some(rule); + } + if self.eat("n") { + return Some(Rule::N); + } + self.blank(); + let start = self.1; + while self.0.get(self.1).is_some_and(u8::is_ascii_digit) { + self.1 += 1; + } + std::str::from_utf8(&self.0[start..self.1]) + .ok()? + .parse() + .ok() + .map(Rule::Number) + } +} + +#[cfg(test)] +mod tests { + use super::{CATALOGS, Rule, Speech, format}; + use crate::{AppWindow, I18n}; + use slint::ComponentHandle; + use std::rc::Rc; + + fn headless_app() -> AppWindow { + use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType}; + struct Headless(Rc); + impl slint::platform::Platform for Headless { + fn create_window_adapter( + &self, + ) -> Result, slint::PlatformError> { + Ok(self.0.clone()) + } + } + let window = MinimalSoftwareWindow::new(RepaintBufferType::NewBuffer); + slint::platform::set_platform(Box::new(Headless(window))).unwrap(); + AppWindow::new().unwrap() + } + + /// Every bundled catalog, and the source phrases, say the same as the + /// window does, for every count a notification can show. + #[test] + fn the_words_match_the_window_in_every_language() { + let app = headless_app(); + let i18n = app.global::(); + let languages = + std::iter::once("en").chain(CATALOGS.iter().map(|catalog| catalog.language)); + for language in languages { + slint::select_bundled_translation(language).unwrap(); + let speech = Speech::new(language); + let fixed = speech.fixed(); + assert_eq!( + fixed.new_message, + i18n.invoke_notification_new_message().as_str(), + "{language}" + ); + assert_eq!( + fixed.no_subject, + i18n.invoke_notification_no_subject().as_str(), + "{language}" + ); + assert_eq!( + fixed.open, + i18n.invoke_notification_open().as_str(), + "{language}" + ); + assert_eq!( + fixed.mark_read, + i18n.invoke_notification_mark_read().as_str(), + "{language}" + ); + assert_eq!( + fixed.archive, + i18n.invoke_notification_archive().as_str(), + "{language}" + ); + assert_eq!( + fixed.delete, + i18n.invoke_notification_delete().as_str(), + "{language}" + ); + assert_eq!( + fixed.new_mail_channel, + i18n.invoke_notification_new_mail_channel().as_str(), + "{language}" + ); + assert_eq!( + fixed.quiet_mail_channel, + i18n.invoke_notification_quiet_mail_channel().as_str(), + "{language}" + ); + for count in (0..=250).chain([999, 1000, 1001, 12_345]) { + assert_eq!( + speech.new_messages(count), + i18n.invoke_notification_new_messages(count).as_str(), + "{language} {count}" + ); + assert_eq!( + speech.more_senders("Ana, Bo", count), + i18n.invoke_notification_more_senders("Ana, Bo".into(), count) + .as_str(), + "{language} {count}" + ); + assert_eq!( + speech.unread_count(count), + i18n.invoke_notification_unread_count(count).as_str(), + "{language} {count}" + ); + } + } + } + + #[test] + fn every_plural_rule_parses() { + for catalog in CATALOGS { + assert!( + Rule::parse(catalog.plural).is_some(), + "{}", + catalog.language + ); + } + let russian = Rule::parse( + "n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2", + ) + .unwrap(); + let forms = [1, 2, 5, 11, 12, 21, 22, 25, 111, 112].map(|n| russian.form(n)); + assert_eq!(forms, [0, 1, 2, 2, 2, 0, 1, 2, 2, 2]); + assert!(Rule::parse("n != 1 ?").is_none()); + assert!(Rule::parse("(n").is_none()); + } + + #[test] + fn formatting_follows_slint() { + assert_eq!(format("{n} new", None, 3), "3 new"); + assert_eq!(format("{0} and {n} more", Some("Ana"), 2), "Ana and 2 more"); + assert_eq!(format("{} and {n}", Some("Ana"), 2), "Ana and 2"); + assert_eq!(format("{{0}} {}", Some("Ana"), 1), "{0} Ana"); + assert_eq!(format("{1} {x}", Some("Ana"), 1), "{1} {x}"); + assert_eq!(format("{0} new", None, 1), "{0} new"); + } +}