From 822e88a8ce494f9a2e290d5ef7c52c57136330ac Mon Sep 17 00:00:00 2001 From: Savanni D'Gerinel Date: Fri, 1 Mar 2024 10:07:44 -0500 Subject: [PATCH] Speculative work on adding Message support to localization --- Cargo.lock | 3 +- kifu/gtk/Cargo.toml | 1 + kifu/gtk/src/lib.rs | 2 + kifu/gtk/src/messages.rs | 42 ++++++++++++ l10n/Cargo.toml | 2 +- l10n/src/lib.rs | 137 +++++++++++++++++++++++++++++++-------- 6 files changed, 158 insertions(+), 29 deletions(-) create mode 100644 kifu/gtk/src/messages.rs diff --git a/Cargo.lock b/Cargo.lock index 3def0dc..cd0cc8c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2694,6 +2694,7 @@ dependencies = [ "gtk4", "image 0.24.7", "kifu-core", + "l10n", "libadwaita", "pango", "sgf", @@ -2716,7 +2717,7 @@ dependencies = [ "chrono", "chrono-tz", "fixed_decimal", - "fluent-ergonomics", + "fluent", "icu", "icu_locid", "icu_provider", diff --git a/kifu/gtk/Cargo.toml b/kifu/gtk/Cargo.toml index 4167479..30bcf0a 100644 --- a/kifu/gtk/Cargo.toml +++ b/kifu/gtk/Cargo.toml @@ -18,6 +18,7 @@ glib = { version = "0.18" } gtk = { version = "0.7", package = "gtk4", features = [ "v4_8" ] } image = { version = "0.24" } kifu-core = { path = "../core" } +l10n = { path = "../../l10n" } pango = { version = "*" } sgf = { path = "../../sgf" } tokio = { version = "1.26", features = [ "full" ] } diff --git a/kifu/gtk/src/lib.rs b/kifu/gtk/src/lib.rs index ef7508c..d062dc2 100644 --- a/kifu/gtk/src/lib.rs +++ b/kifu/gtk/src/lib.rs @@ -1,3 +1,5 @@ +mod messages; + pub mod ui; mod view_models; diff --git a/kifu/gtk/src/messages.rs b/kifu/gtk/src/messages.rs new file mode 100644 index 0000000..3893f06 --- /dev/null +++ b/kifu/gtk/src/messages.rs @@ -0,0 +1,42 @@ +use l10n::{FluentArgs, FluentValue, Message}; + +const ENGLISH_MESSAGES: &'static str = " +welcome = Welcome +games-in-database = {count -> + [one] There is one game in the database + [other] There are {count} games in the database +} +"; + +const ESPERANTO_MESSAGES: &'static str = " +welcome = Bonvenon +games-in-database = {count -> + [one] Estas unu ludon en la datumbazo. + [other] Estas {count} ludojn en la datumbazo. +} +"; + +enum Phrase { + Welcome, + GamesInDatabase(i32), +} + +impl Message for Phrase { + fn msgid(&self) -> &str { + match self { + Phrase::Welcome => "welcome", + Phrase::GamesInDatabase(_) => "games-in-database", + } + } + + fn args(&self) -> Option { + match self { + Phrase::Welcome => None, + Phrase::GamesInDatabase(val) => { + let mut args = FluentArgs::new(); + args.set("count", FluentValue::from(val)); + Some(args) + } + } + } +} diff --git a/l10n/Cargo.toml b/l10n/Cargo.toml index a671860..f559834 100644 --- a/l10n/Cargo.toml +++ b/l10n/Cargo.toml @@ -9,7 +9,7 @@ edition = "2021" chrono = { version = "0.4" } chrono-tz = { version = "0.8" } fixed_decimal = { version = "0.5.5", features = [ "ryu" ] } -fluent-ergonomics = { path = "../fluent-ergonomics" } +fluent = { version = "0.16" } icu = { version = "1" } icu_locid = { version = "1" } icu_provider = { version = "1" } diff --git a/l10n/src/lib.rs b/l10n/src/lib.rs index e95ce9c..ad98416 100644 --- a/l10n/src/lib.rs +++ b/l10n/src/lib.rs @@ -1,40 +1,118 @@ -use std::ops::Deref; - +use std::{ops::Deref, path::Path}; use chrono::{Datelike, NaiveDate, Timelike}; use chrono_tz::{Tz}; use fixed_decimal::{FixedDecimal}; use icu::{ datetime::options::length, decimal::{FixedDecimalFormatter}, + locid::Locale, }; -use icu_locid::{Locale}; use icu_provider::DataLocale; use sys_locale::get_locale; +// Re-exports. I'm doing these so that clients of this library don't have to go tracking down +// additional structures +pub use fluent::{FluentValue, FluentArgs}; pub use fixed_decimal::FloatPrecision; -#[derive(Clone, Debug)] +#[derive(Debug)] +pub enum NonEmptyListError { + BuildFromEmptyContainer, +} + +pub struct NonEmptyList(Vec); + +impl NonEmptyList { + fn new(elem: A) -> Self { + Self(vec![elem]) + } + + fn from_iter(iter: impl IntoIterator) -> Result ,NonEmptyListError> { + let lst = iter.into_iter().collect::>(); + if lst.len() > 0 { + Ok(NonEmptyList(lst)) + } else { + Err(NonEmptyListError::BuildFromEmptyContainer) + } + } + + fn first(&self) -> &A { + &self.0[0] + } + +} + +impl Deref for NonEmptyList { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub enum L10NError { + UnparsableLocale +} + +impl From for L10NError { + fn from(_: icu::locid::Error) -> L10NError { + L10NError::UnparsableLocale + } +} + +// Potential Message structure. +// +// Let's assume the application has an enumeration that implements Message. For each element of the +// enumeration, there should be some boilerplate code that returns the message ID and the arguments +// as a FluentArgs. +// +// Nobody wants to generate all of that code, though I have done so in the past, and manually +// generating that code could be useful for illustration. I think I'm going to want to do code +// generation from the source strings file, and then compile the enumeration into the code. +pub trait Message { + fn msgid(&self) -> &str; + fn args(&self) -> Option; +} + pub struct L10N { - locale: Locale, + locales: NonEmptyList, zone: chrono_tz::Tz, } impl Default for L10N { fn default() -> Self { - let lc = get_locale().unwrap(); - let locale = Locale::try_from_bytes(lc.as_bytes()).unwrap(); + let english = "en-US".parse::().unwrap(); + let sys_locale = get_locale().and_then(|locale_str| locale_str.parse::().ok()).unwrap_or(english); + let locales = NonEmptyList::new(sys_locale.clone()); let zone = chrono_tz::UTC; - Self { locale, zone } + Self { locales, zone } } } impl L10N { - fn set_locale(&mut self, locale: String) { - let locale = Locale::try_from_bytes(locale.as_bytes()).unwrap(); - self.locale = locale; + pub fn load_messages_from_file(&mut self, locale: String, path: &Path) -> Result<(), L10NError>{ + unimplemented!() } - fn set_timezone(&mut self, zone: Tz) { + // Now, whenever the user changes the locales, the list of messages has to change. How do we + // automatically set up the messages? Theoretically they all need to be reloaded, and I've + // already split how the messages get loaded from how the locales are specified. + // + // But, FluentErgo does that, too. It already has the concept of being constructed with a list + // of languages and then having each language bundle manually loaded afterwards. + // + // Problem: be able to change the preferred list of locales and automatically have a new + // FluentBundle which has all relevant translations loaded. + // + // One solution is that all bundles get loaded at startup time, and the bundle list gets + // changed any time the list of locales gets changed. Also, the system can just run through the + // entire list of fallbacks. + pub fn set_locales(&mut self, locales: NonEmptyList<&str>) -> Result<(), L10NError> { + let locales = locales.iter().map(|locale| Locale::try_from_bytes(locale.as_bytes())).collect::, icu::locid::Error>>()?; + self.locales = NonEmptyList(locales); + Ok(()) + } + + pub fn set_timezone(&mut self, zone: Tz) { self.zone = zone; } @@ -42,11 +120,16 @@ impl L10N { // know yet what form the message should take. Forming an adapter around fluent_ergonomics or // even around fluent itself. I would want for the message to be statically typed, but then I // don't know what can be the data type that gets passed in here. - fn format_message(&self) -> String { + // + // Formatting a message requires identifying the message and passing it any relevant + // parameters. In an ideal world, neither of these can be incorrect. Messages are all checked + // at compile time, as are their parameters. That implies an enumeration, with one element per + // message, and with each element knowing its parameters. + pub fn format_message(&self) -> String { unimplemented!() } - fn format_date_time_utc( + pub fn format_date_time_utc( &self, time: DateTime, date_style: length::Date, @@ -55,7 +138,7 @@ impl L10N { let time: DateTime = time.with_timezone(&chrono_tz::UTC).into(); let options = length::Bag::from_date_time_style(date_style, time_style); let formatter = icu::datetime::DateTimeFormatter::try_new( - &DataLocale::from(&self.locale), + &DataLocale::from(self.locales.first()), options.into(), ) .unwrap(); @@ -63,7 +146,7 @@ impl L10N { formatter.format_to_string(&icu_time.to_any()).unwrap() } - fn format_date_time_local( + pub fn format_date_time_local( &self, time: DateTime, date_style: length::Date, @@ -72,7 +155,7 @@ impl L10N { let time: DateTime = time.with_timezone(&self.zone).into(); let options = length::Bag::from_date_time_style(date_style, time_style); let formatter = icu::datetime::DateTimeFormatter::try_new( - &DataLocale::from(&self.locale), + &DataLocale::from(self.locales.first()), options.into(), ) .unwrap(); @@ -123,9 +206,9 @@ impl L10N { } */ - fn format_date(&self, date: NaiveDate, date_style: length::Date) -> String { + pub fn format_date(&self, date: NaiveDate, date_style: length::Date) -> String { let formatter = icu::datetime::DateFormatter::try_new_with_length( - &DataLocale::from(&self.locale), + &DataLocale::from(self.locales.first()), date_style, ) .unwrap(); @@ -139,9 +222,9 @@ impl L10N { formatter.format_to_string(&icu_date.to_any()).unwrap() } - fn format_f64(&self, value: f64, precision: FloatPrecision) -> String { + pub fn format_f64(&self, value: f64, precision: FloatPrecision) -> String { let fdf = FixedDecimalFormatter::try_new( - &self.locale.clone().into(), + &self.locales.first().clone().into(), Default::default() ).expect("locale should be present"); @@ -152,7 +235,7 @@ impl L10N { } #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct DateTime(chrono::DateTime); +pub struct DateTime(chrono::DateTime); impl Deref for DateTime { type Target = chrono::DateTime; @@ -192,7 +275,7 @@ mod tests { let mut l10n = L10N::default(); // Make sure we know the locale before the test begins. Some systems, such as my own, are // not actually in English. - l10n.set_locale("en-US".to_owned()); + l10n.set_locales(NonEmptyList::from_iter(vec!["en-US"]).unwrap()); l10n.set_timezone(chrono_tz::US::Eastern); l10n } @@ -223,7 +306,7 @@ mod tests { "January 2, 2006, 10:04:05\u{202f}AM" ); - l10n.set_locale("eo-EO".to_owned()); + l10n.set_locales(NonEmptyList::from_iter(vec!["eo-EO", "en-US"]).unwrap()); assert_eq!( l10n.format_date_time_utc(now.clone(), length::Date::Long, length::Time::Medium), "2006-Januaro-02 10:04:05" @@ -242,7 +325,7 @@ mod tests { "January 2, 2006, 5:04:05\u{202f}AM" ); - l10n.set_locale("eo-EO".to_owned()); + l10n.set_locales(NonEmptyList::from_iter(vec!["eo-EO", "en-US"]).unwrap()); assert_eq!( l10n.format_date_time_local(now.clone(), length::Date::Long, length::Time::Medium), "2006-Januaro-02 05:04:05" @@ -259,7 +342,7 @@ mod tests { "January 2, 2006" ); - l10n.set_locale("eo-EO".to_owned()); + l10n.set_locales(NonEmptyList::from_iter(vec!["eo-EO", "en-US"]).unwrap()); assert_eq!( l10n.format_date(today.clone(), length::Date::Long), "2006-Januaro-02" @@ -279,7 +362,7 @@ mod tests { "15,000.4", ); - l10n.set_locale("de-DE".to_owned()); + l10n.set_locales(NonEmptyList::from_iter(vec!["de-DE", "en-US"]).unwrap()); assert_eq!( l10n.format_f64(100.4, FloatPrecision::Floating), "100,4",