From 1dd57a7bd521eee9447643ecca976c5212e3b437 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Wed, 6 Nov 2024 17:15:04 +0100 Subject: [PATCH] feat: add Region & Language page --- Cargo.lock | 104 ++- cosmic-settings/Cargo.toml | 35 +- cosmic-settings/src/app.rs | 8 + cosmic-settings/src/pages/mod.rs | 2 + cosmic-settings/src/pages/time/mod.rs | 7 +- cosmic-settings/src/pages/time/region.rs | 828 ++++++++++++++++++++++- i18n/en/cosmic_settings.ftl | 16 + 7 files changed, 981 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7640bacd..3046429d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,6 +1599,7 @@ dependencies = [ "dirs", "downcast-rs", "eyre", + "fixed_decimal", "fontdb 0.16.2", "freedesktop-desktop-entry", "futures", @@ -1612,6 +1613,7 @@ dependencies = [ "itertools 0.13.0", "itoa", "libcosmic", + "locale1", "notify", "once_cell", "regex", @@ -1623,6 +1625,7 @@ dependencies = [ "slotmap", "static_init", "sunrise", + "system", "tachyonix", "timedate-zbus", "tokio", @@ -1775,6 +1778,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" @@ -2589,6 +2607,16 @@ dependencies = [ "xdg", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2846,6 +2874,18 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gpt" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8283e7331b8c93b9756e0cfdbcfb90312852f953c6faf9bf741e684cc3b6ad69" +dependencies = [ + "bitflags 2.6.0", + "crc", + "log", + "uuid", +] + [[package]] name = "gpu-alloc" version = "0.6.0" @@ -4333,7 +4373,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.48.5", ] [[package]] @@ -4448,6 +4488,14 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +[[package]] +name = "locale1" +version = "0.1.0" +source = "git+https://github.com/pop-os/dbus-settings-bindings#931f5db558bf3fcb572ff4e18f7f1618a7430046" +dependencies = [ + "zbus 4.4.0", +] + [[package]] name = "locale_config" version = "0.3.0" @@ -5010,7 +5058,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" dependencies = [ - "proc-macro-crate 3.2.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.85", @@ -6748,6 +6796,16 @@ dependencies = [ "chrono", ] +[[package]] +name = "superblock" +version = "0.1.0" +source = "git+https://github.com/serpent-os/blsforme.git#77c30adbfbc09cb54cd9f781fb7e5e5a563de3bf" +dependencies = [ + "log", + "thiserror", + "uuid", +] + [[package]] name = "svg_fmt" version = "0.4.3" @@ -6843,6 +6901,22 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "system" +version = "0.1.0" +source = "git+https://github.com/serpent-os/lichen#d09e89773d5051a3c6603cd5ef7444183dd2451c" +dependencies = [ + "fs-err", + "futures", + "gpt", + "serde", + "serde_json", + "superblock", + "thiserror", + "tokio", + "tokio-stream", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -7089,6 +7163,7 @@ dependencies = [ "bytes", "libc", "mio 1.0.2", + "parking_lot 0.12.3", "pin-project-lite", "signal-hook-registry", "socket2 0.5.7", @@ -7117,6 +7192,20 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] @@ -7491,6 +7580,9 @@ name = "uuid" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", +] [[package]] name = "v_frame" @@ -7788,7 +7880,7 @@ dependencies = [ "js-sys", "log", "naga", - "parking_lot 0.12.3", + "parking_lot 0.11.2", "profiling", "raw-window-handle", "smallvec", @@ -7816,7 +7908,7 @@ dependencies = [ "log", "naga", "once_cell", - "parking_lot 0.12.3", + "parking_lot 0.11.2", "profiling", "raw-window-handle", "rustc-hash 1.1.0", @@ -7857,7 +7949,7 @@ dependencies = [ "ndk-sys 0.5.0+25.2.9519653", "objc", "once_cell", - "parking_lot 0.12.3", + "parking_lot 0.11.2", "profiling", "range-alloc", "raw-window-handle", @@ -7910,7 +8002,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index 4d62a87c..cedad750 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -7,7 +7,9 @@ license = "GPL-3.0-only" [dependencies] anyhow = "1.0" as-result = "0.2.1" -ashpd = { version = "0.9", default-features = false, features = ["tokio"], optional = true } +ashpd = { version = "0.9", default-features = false, features = [ + "tokio", +], optional = true } async-channel = "2.3.1" chrono = "0.4.38" clap = { version = "4.5.17", features = ["derive"] } @@ -38,6 +40,7 @@ indexmap = "2.5.0" itertools = "0.13.0" itoa = "1.0.11" libcosmic.workspace = true +locale1 = { git = "https://github.com/pop-os/dbus-settings-bindings", optional = true } notify = "6.1.1" once_cell = "1.19.0" regex = "1.10.6" @@ -62,6 +65,7 @@ xkb-data = "0.2.1" zbus = { version = "4.4.0", features = ["tokio"], optional = true } ustr = "1.0.0" fontdb = "0.16.2" +fixed_decimal = "0.5.6" [dependencies.cosmic-settings-subscriptions] git = "https://github.com/pop-os/cosmic-settings-subscriptions" @@ -77,14 +81,14 @@ features = ["experimental", "compiled_data", "icu_datetime_experimental"] version = "0.15.0" features = ["fluent-system", "desktop-requester"] +# Contains region-handling logic for Linux +[dependencies.lichen-system] +git = "https://github.com/serpent-os/lichen" +package = "system" +optional = true + [features] -default = [ - "a11y", - "dbus-config", - "linux", - "single-instance", - "wgpu", -] +default = ["a11y", "dbus-config", "linux", "single-instance", "wgpu"] # Default features for Linux linux = [ @@ -94,6 +98,7 @@ linux = [ "page-input", "page-networking", "page-power", + "page-region", "page-sound", "page-window-management", "page-workspaces", @@ -105,9 +110,19 @@ linux = [ page-about = ["dep:cosmic-settings-system", "dep:hostname1-zbus", "dep:zbus"] page-bluetooth = ["dep:bluez-zbus", "dep:zbus"] page-date = ["dep:timedate-zbus", "dep:zbus"] -page-input = ["dep:cosmic-comp-config", "dep:cosmic-settings-config", "dep:udev"] -page-networking = ["ashpd", "dep:cosmic-dbus-networkmanager", "dep:cosmic-settings-subscriptions", "dep:zbus"] +page-input = [ + "dep:cosmic-comp-config", + "dep:cosmic-settings-config", + "dep:udev", +] +page-networking = [ + "ashpd", + "dep:cosmic-dbus-networkmanager", + "dep:cosmic-settings-subscriptions", + "dep:zbus", +] page-power = ["dep:upower_dbus", "dep:zbus"] +page-region = ["dep:lichen-system", "dep:locale1"] page-sound = ["dep:cosmic-settings-subscriptions"] page-window-management = ["dep:cosmic-settings-config"] page-workspaces = ["dep:cosmic-comp-config"] diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index f1996fb8..d1451636 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -96,6 +96,7 @@ impl SettingsApp { PageCommands::Panel => self.pages.page_id::(), #[cfg(feature = "page-power")] PageCommands::Power => self.pages.page_id::(), + #[cfg(feature = "page-region")] PageCommands::RegionLanguage => self.pages.page_id::(), #[cfg(feature = "page-sound")] PageCommands::Sound => self.pages.page_id::(), @@ -480,6 +481,13 @@ impl cosmic::Application for SettingsApp { } } + #[cfg(feature = "page-region")] + crate::pages::Message::Region(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + #[cfg(feature = "page-sound")] crate::pages::Message::Sound(message) => { if let Some(page) = self.pages.page_mut::() { diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index 86c04a53..1d619909 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -63,6 +63,8 @@ pub enum Message { PanelApplet(desktop::panel::applets_inner::Message), #[cfg(feature = "page-power")] Power(power::Message), + #[cfg(feature = "page-region")] + Region(time::region::Message), #[cfg(feature = "page-sound")] Sound(sound::Message), #[cfg(feature = "page-input")] diff --git a/cosmic-settings/src/pages/time/mod.rs b/cosmic-settings/src/pages/time/mod.rs index f2275b5a..426cc9b9 100644 --- a/cosmic-settings/src/pages/time/mod.rs +++ b/cosmic-settings/src/pages/time/mod.rs @@ -32,7 +32,12 @@ impl page::AutoBind for Page { { page = page.sub_page::(); } - page = page.sub_page::(); + + #[cfg(feature = "page-region")] + { + page = page.sub_page::(); + } + page } } diff --git a/cosmic-settings/src/pages/time/region.rs b/cosmic-settings/src/pages/time/region.rs index 37a18b92..f7068dcd 100644 --- a/cosmic-settings/src/pages/time/region.rs +++ b/cosmic-settings/src/pages/time/region.rs @@ -1,13 +1,101 @@ // Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only +use std::collections::BTreeMap; +use std::str::FromStr; +use std::sync::Arc; + +use cosmic::iced::{Border, Color, Length}; +use cosmic::widget::{self, button, container}; +use cosmic::{theme, Apply, Element}; +use cosmic_config::{ConfigGet, ConfigSet}; use cosmic_settings_page::Section; use cosmic_settings_page::{self as page, section}; -use slotmap::SlotMap; +use eyre::Context; +use fixed_decimal::FixedDecimal; +use icu::calendar::DateTime; +use icu::datetime::options::components::{self, Bag}; +use icu::datetime::options::preferences; +use icu::datetime::DateTimeFormatter; +use icu::decimal::options::FixedDecimalFormatterOptions; +use icu::decimal::FixedDecimalFormatter; +use lichen_system::locale; +use slotmap::{DefaultKey, SlotMap}; +use tokio::sync::mpsc; + +#[derive(Clone, Debug)] +pub enum Message { + AddLanguage(DefaultKey), + AddLanguageContext, + AddLanguageSearch(String), + AvailableLanguages(SlotMap), + ExpandLanguagePopover(Option), + InstallAdditionalLanguages, + SelectRegion(DefaultKey), + SourceContext(SourceContext), + Refresh(Arc>), + RegionContext, +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Region(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Region(message) + } +} + +enum ContextView { + AddLanguage, + Region, +} + +#[derive(Clone, Debug)] +pub enum SourceContext { + MoveDown(usize), + MoveUp(usize), + Remove(usize), +} + +#[derive(Clone, Debug)] +pub struct AvailableLanguage { + code: String, + description: String, + is_installed: bool, +} + +#[derive(Clone, Debug)] +struct SystemLocale { + lang_code: String, + display_name: String, +} + +#[derive(Debug)] +pub struct PageRefresh { + config: Option<(cosmic_config::Config, Vec)>, + registry: Registry, + language: Option, + region: Option, + available_languages: SlotMap, + system_locales: BTreeMap, +} #[derive(Default)] pub struct Page { entity: page::Entity, + config: Option<(cosmic_config::Config, Vec)>, + context: Option, + language: Option, + region: Option, + available_languages: SlotMap, + system_locales: BTreeMap, + registry: Option, + expanded_source_popover: Option, + add_language_search: String, } impl page::Page for Page { @@ -19,7 +107,10 @@ impl page::Page for Page { &self, sections: &mut SlotMap>, ) -> Option { - Some(vec![sections.insert(Section::default())]) + Some(vec![ + sections.insert(preferred_languages::section()), + sections.insert(formatting::section()), + ]) } fn info(&self) -> page::Info { @@ -27,6 +118,739 @@ impl page::Page for Page { .title(fl!("time-region")) .description(fl!("time-region", "desc")) } + + fn on_enter( + &mut self, + sender: mpsc::Sender, + ) -> cosmic::Task { + cosmic::command::future(async move { Message::Refresh(Arc::new(page_reload().await)) }) + } + + fn context_drawer(&self) -> Option> { + Some(match self.context.as_ref()? { + ContextView::AddLanguage => self.add_language_view(), + ContextView::Region => self.region_view(), + }) + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::Task { + match message { + Message::AddLanguage(id) => { + if let Some(language) = self.available_languages.get(id) { + if let Some((config, locales)) = self.config.as_mut() { + if !locales.contains(&language.code) { + locales.push(language.code.clone()); + _ = config.set("system_locales", &locales); + } + } + } + + return cosmic::command::message(crate::app::Message::CloseContextDrawer); + } + + Message::SelectRegion(id) => { + if let Some((region, language)) = + self.available_languages.get(id).zip(self.language.as_ref()) + { + self.region = Some(SystemLocale { + lang_code: region.code.clone(), + display_name: region.description.clone(), + }); + + let lang = language.lang_code.clone(); + let region = region.code.clone(); + + tokio::spawn(async move { + _ = set_locale(lang, region).await; + }); + } + + return cosmic::command::message(crate::app::Message::CloseContextDrawer); + } + + Message::AddLanguageContext => { + self.context = Some(ContextView::AddLanguage); + return cosmic::Task::done(crate::app::Message::OpenContextDrawer( + self.entity, + fl!("add-language", "context").into(), + )); + } + + Message::AddLanguageSearch(search) => { + self.add_language_search = search; + } + + Message::AvailableLanguages(languages) => { + self.available_languages = languages; + } + + Message::ExpandLanguagePopover(id) => { + self.expanded_source_popover = id; + } + + Message::InstallAdditionalLanguages => { + return cosmic::command::future(async move { + _ = tokio::process::Command::new("gnome-language-selector") + .status() + .await; + + Message::Refresh(Arc::new(page_reload().await)) + }) + } + + Message::Refresh(result) => match Arc::into_inner(result).unwrap() { + Ok(page_refresh) => { + self.config = page_refresh.config; + self.available_languages = page_refresh.available_languages; + self.system_locales = page_refresh.system_locales; + self.language = page_refresh.language; + self.region = page_refresh.region; + self.registry = Some(page_refresh.registry.0); + } + + Err(why) => { + tracing::error!(?why, "failed to get locales from the system"); + } + }, + + Message::RegionContext => { + self.context = Some(ContextView::Region); + return cosmic::Task::done(crate::app::Message::OpenContextDrawer( + self.entity, + fl!("region").into(), + )); + } + + Message::SourceContext(context_message) => { + self.expanded_source_popover = None; + + if let Some((config, locales)) = self.config.as_mut() { + match context_message { + SourceContext::MoveDown(id) => { + if id + 1 < locales.len() { + locales.swap(id, id + 1); + } + } + + SourceContext::MoveUp(id) => { + if id > 0 { + locales.swap(id, id - 1); + } + } + + SourceContext::Remove(id) => { + let _removed = locales.remove(id); + } + } + + _ = config.set("system_locales", &locales); + + if let Some(language_code) = locales.get(0) { + if let Some(language) = self + .available_languages + .values() + .find(|lang| &lang.code == language_code) + { + let language = SystemLocale { + lang_code: language_code.clone(), + display_name: language.description.clone(), + }; + + self.language = Some(language.clone()); + let region = self.region.clone(); + + tokio::spawn(async move { + _ = set_locale( + language.lang_code.clone(), + region.unwrap_or(language).lang_code.clone(), + ) + .await; + }); + } + } + } + } + } + + cosmic::Task::none() + } + + fn add_language_view(&self) -> cosmic::Element<'_, crate::pages::Message> { + let space_l = theme::active().cosmic().spacing.space_l; + + let search = widget::search_input(fl!("type-to-search"), &self.add_language_search) + .on_input(Message::AddLanguageSearch) + .on_clear(Message::AddLanguageSearch(String::new())); + + let mut list = widget::list_column(); + + let search_input = &self.add_language_search.trim().to_lowercase(); + + for (id, available_language) in &self.available_languages { + if search_input.is_empty() + || available_language + .description + .to_lowercase() + .contains(search_input) + { + let button = widget::button::text(&available_language.description) + .on_press_maybe(if available_language.is_installed { + None + } else { + Some(Message::AddLanguage(id)) + }) + .apply(widget::container) + .padding([0, 0, 0, 16]); + + list = list.add(button) + } + } + + let install_additional_button = + widget::button::standard(fl!("install-additional-languages")) + .on_press(Message::InstallAdditionalLanguages); + + widget::column() + .padding([2, 0]) + .spacing(space_l) + .push(search) + .push(list) + .push(install_additional_button) + .apply(Element::from) + .map(crate::pages::Message::Region) + } + + fn formatted_date(&self) -> String { + let time_locale = self + .system_locales + .get("LC_TIME") + .or_else(|| self.system_locales.get("LANG")) + .map_or("en_US", |locale| &locale.lang_code) + .split('.') + .next() + .unwrap_or("en_US"); + + let Ok(locale) = icu::locid::Locale::from_str(time_locale) else { + return String::new(); + }; + + let mut bag = Bag::empty(); + bag.day = Some(components::Day::TwoDigitDayOfMonth); + bag.month = Some(components::Month::TwoDigit); + bag.year = Some(components::Year::Numeric); + + let options = icu::datetime::DateTimeFormatterOptions::Components(bag); + + let dtf = DateTimeFormatter::try_new_experimental(&locale.into(), options).unwrap(); + + let datetime = DateTime::try_new_gregorian_datetime(2024, 1, 1, 12, 0, 0) + .unwrap() + .to_iso() + .to_any(); + + dtf.format(&datetime) + .expect("can't format value") + .to_string() + } + + fn formatted_dates_and_times(&self) -> String { + let time_locale = self + .system_locales + .get("LC_TIME") + .or_else(|| self.system_locales.get("LANG")) + .map_or("en_US", |locale| &locale.lang_code) + .split('.') + .next() + .unwrap_or("en_US"); + + let Ok(locale) = icu::locid::Locale::from_str(time_locale) else { + return String::new(); + }; + + let mut bag = Bag::empty(); + bag.hour = Some(components::Numeric::Numeric); + bag.minute = Some(components::Numeric::Numeric); + bag.second = Some(components::Numeric::Numeric); + bag.preferences = Some(preferences::Bag::from_hour_cycle( + preferences::HourCycle::H12, + )); + // bag.time_zone_name = Some(components::TimeZoneName::ShortSpecific); + bag.day = Some(components::Day::TwoDigitDayOfMonth); + bag.month = Some(components::Month::Short); + bag.year = Some(components::Year::Numeric); + + let options = icu::datetime::DateTimeFormatterOptions::Components(bag); + + let dtf = DateTimeFormatter::try_new_experimental(&locale.into(), options).unwrap(); + + let datetime = DateTime::try_new_gregorian_datetime(2024, 1, 1, 12, 0, 0) + .unwrap() + .to_iso() + .to_any(); + + dtf.format(&datetime) + .expect("can't format value") + .to_string() + } + + fn formatted_time(&self) -> String { + let time_locale = self + .system_locales + .get("LC_TIME") + .or_else(|| self.system_locales.get("LANG")) + .map_or("en_US", |locale| &locale.lang_code) + .split('.') + .next() + .unwrap_or("en_US"); + + let Ok(locale) = icu::locid::Locale::from_str(time_locale) else { + return String::new(); + }; + + let mut bag = Bag::empty(); + bag.hour = Some(components::Numeric::Numeric); + bag.minute = Some(components::Numeric::Numeric); + bag.second = Some(components::Numeric::Numeric); + bag.preferences = Some(preferences::Bag::from_hour_cycle( + preferences::HourCycle::H12, + )); + + let options = icu::datetime::DateTimeFormatterOptions::Components(bag); + + let dtf = DateTimeFormatter::try_new_experimental(&locale.into(), options).unwrap(); + + let datetime = DateTime::try_new_gregorian_datetime(2024, 1, 1, 12, 0, 0) + .unwrap() + .to_iso() + .to_any(); + + dtf.format(&datetime) + .expect("can't format value") + .to_string() + } + + fn formatted_numbers(&self) -> String { + let numerical_locale = self + .system_locales + .get("LC_NUMERIC") + .or_else(|| self.system_locales.get("LANG")) + .map_or("en_US", |locale| &locale.lang_code) + .split('.') + .next() + .unwrap_or("en_US"); + + let Ok(locale) = icu::locid::Locale::from_str(numerical_locale) else { + return String::new(); + }; + + let options = FixedDecimalFormatterOptions::default(); + let formatter = FixedDecimalFormatter::try_new(&locale.into(), options).unwrap(); + let mut value = FixedDecimal::from(123456789); + value.multiply_pow10(-2); + + formatter.format(&value).to_string() + } + + fn region_view(&self) -> cosmic::Element<'_, crate::pages::Message> { + let space_l = theme::active().cosmic().spacing.space_l; + + let search = widget::search_input(fl!("type-to-search"), &self.add_language_search) + .on_input(Message::AddLanguageSearch) + .on_clear(Message::AddLanguageSearch(String::new())); + + let mut list = widget::list_column(); + + let search_input = &self.add_language_search.trim().to_lowercase(); + + for (id, available_language) in &self.available_languages { + if search_input.is_empty() + || available_language + .description + .to_lowercase() + .contains(search_input) + { + let button = widget::button::text(&available_language.description) + .on_press_maybe(if available_language.is_installed { + None + } else { + Some(Message::SelectRegion(id)) + }) + .apply(widget::container) + .padding([0, 0, 0, 16]); + + list = list.add(button) + } + } + + widget::column() + .padding([2, 0]) + .spacing(space_l) + .push(search) + .push(list) + .apply(Element::from) + .map(crate::pages::Message::Region) + } } impl page::AutoBind for Page {} + +mod preferred_languages { + use super::Message; + use cosmic::{ + iced::{Alignment, Length}, + widget, Apply, + }; + use cosmic_settings_page::Section; + + pub fn section() -> Section { + crate::slab!(descriptions { + pref_lang_desc = fl!("preferred-languages", "desc"); + add_lang_txt = fl!("add-language"); + }); + + Section::default() + .title(fl!("preferred-languages")) + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let title = widget::text::body(§ion.title).font(cosmic::font::bold()); + + let description = widget::text::body(§ion.descriptions[pref_lang_desc]); + + let mut content = widget::settings::section(); + + if let Some(((_config, locales), registry)) = + page.config.as_ref().zip(page.registry.as_ref()) + { + for (id, locale) in locales.iter().enumerate() { + if let Some(locale) = registry.locale(locale) { + content = content.add(super::language_element( + id, + locale.display_name.clone(), + page.expanded_source_popover, + )); + } + } + } + + let add_language_button = + widget::button::standard(§ion.descriptions[add_lang_txt]) + .on_press(Message::AddLanguageContext) + .apply(widget::container) + .width(Length::Fill) + .align_x(Alignment::End); + + widget::column::with_capacity(5) + .push(title) + .push(description) + .push(content) + .push( + widget::vertical_space() + .height(cosmic::theme::active().cosmic().spacing.space_xxs), + ) + .push(add_language_button) + .spacing(cosmic::theme::active().cosmic().spacing.space_xxs) + .apply(cosmic::Element::from) + .map(Into::into) + }) + } +} + +mod formatting { + use super::Message; + use cosmic::{widget, Apply}; + use cosmic_settings_page::Section; + + pub fn section() -> Section { + crate::slab!(descriptions { + formatting_txt = fl!("formatting"); + dates_txt = fl!("formatting", "dates"); + time_txt = fl!("formatting", "time"); + date_and_time_txt = fl!("formatting", "date-and-time"); + numbers_txt = fl!("formatting", "numbers"); + measurement_txt = fl!("formatting", "measurement"); + paper_txt = fl!("formatting", "paper"); + region_txt = fl!("region"); + }); + + let dates_label = [&descriptions[dates_txt], ":"].concat(); + let time_label = [&descriptions[time_txt], ":"].concat(); + let date_and_time_label = [&descriptions[date_and_time_txt], ":"].concat(); + let numbers_label = [&descriptions[numbers_txt], ":"].concat(); + let measurement_label = [&descriptions[measurement_txt], ":"].concat(); + let paper_label = [&descriptions[paper_txt], ":"].concat(); + + Section::default() + .title(fl!("formatting")) + .descriptions(descriptions) + .view::(move |_binder, page, section| { + let desc = §ion.descriptions; + + let dates = widget::row::with_capacity(2) + .push(widget::text::body(dates_label.clone())) + .push(widget::text::body(page.formatted_date()).font(cosmic::font::bold())) + .spacing(4); + + let time = widget::row::with_capacity(2) + .push(widget::text::body(time_label.clone())) + .push(widget::text::body(page.formatted_time()).font(cosmic::font::bold())) + .spacing(4); + + let dates_and_times = widget::row::with_capacity(2) + .push(widget::text::body(date_and_time_label.clone())) + .push( + widget::text::body(page.formatted_dates_and_times()) + .font(cosmic::font::bold()), + ) + .spacing(4); + + let numbers = widget::row::with_capacity(2) + .push(widget::text::body(numbers_label.clone())) + .push(widget::text::body(page.formatted_numbers()).font(cosmic::font::bold())) + .spacing(4); + + let measurement = widget::row::with_capacity(2) + .push(widget::text::body(measurement_label.clone())) + .push(widget::text::body("").font(cosmic::font::bold())) + .spacing(4); + + let paper = widget::row::with_capacity(2) + .push(widget::text::body(paper_label.clone())) + .push(widget::text::body("").font(cosmic::font::bold())) + .spacing(4); + + let formatted_demo = widget::column::with_capacity(6) + .push(dates) + .push(time) + .push(dates_and_times) + .push(numbers) + .push(measurement) + .push(paper) + .spacing(4) + .padding(5.0) + .apply(|column| widget::settings::item_row(vec![column.into()])); + + let region = page + .region + .as_ref() + .map(|locale| locale.display_name.as_str()) + .unwrap_or(""); + + let select_region = crate::widget::go_next_with_item( + &desc[region_txt], + widget::text::body(region), + Message::RegionContext, + ); + + widget::settings::section() + .title(&desc[formatting_txt]) + .add(formatted_demo) + .add(select_region) + .apply(cosmic::Element::from) + .map(Into::into) + }) + } +} + +struct Registry(locale::Registry); + +impl std::fmt::Debug for Registry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Registry").finish() + } +} + +pub async fn page_reload() -> eyre::Result { + let conn = zbus::Connection::system() + .await + .wrap_err("zbus system connection error")?; + + let registry = locale::Registry::new().wrap_err("failed to get locale registry")?; + + let system_locales: BTreeMap = locale1::locale1Proxy::new(&conn) + .await + .wrap_err("locale1 proxy connect error")? + .locale() + .await + .wrap_err("could not get locale from locale1")? + .into_iter() + .filter_map(|expression| { + let mut fields = expression.split('='); + let var = fields.next()?; + let lang_code = fields.next()?; + Some(( + var.to_owned(), + SystemLocale { + lang_code: lang_code.to_owned(), + display_name: registry + .locale(lang_code) + .map_or(String::from(""), |locale| locale.display_name.clone()), + }, + )) + }) + .collect(); + + let config = cosmic_config::Config::new("com.system76.CosmicSettings", 1) + .ok() + .map(|context| { + let locales = context + .get::>("system_locales") + .ok() + .unwrap_or_else(|| { + let current = system_locales + .get("LANG") + .map_or("en_US.UTF-8", |l| l.lang_code.as_str()) + .to_owned(); + + vec![current] + }); + + (context, locales) + }); + + let language = system_locales + .get("LC_ALL") + .or_else(|| system_locales.get("LANG")) + .cloned(); + + let region = system_locales + .get("LC_TIME") + .or_else(|| system_locales.get("LANG")) + .cloned(); + + let mut available_languages = SlotMap::new(); + + let output = tokio::process::Command::new("localectl") + .arg("list-locales") + .output() + .await + .expect("Failed to run localectl"); + + let output = String::from_utf8(output.stdout).unwrap_or_default(); + for line in output.lines() { + if line == "C.UTF-8" { + continue; + } + + if let Some(locale) = registry.locale(line) { + available_languages.insert(AvailableLanguage { + code: line.to_owned(), + description: locale.display_name, + is_installed: false, + }); + } + } + + Ok(PageRefresh { + config, + registry: Registry(registry), + language, + region, + available_languages, + system_locales, + }) +} + +fn language_element( + id: usize, + description: String, + expanded_source_popover: Option, +) -> cosmic::Element<'static, Message> { + let expanded = expanded_source_popover.is_some_and(|expanded_id| expanded_id == id); + + widget::settings::flex_item(description, popover_button(id, expanded)).into() +} + +fn popover_button(id: usize, expanded: bool) -> Element<'static, Message> { + let on_press = Message::ExpandLanguagePopover(if expanded { None } else { Some(id) }); + + let button = button::icon(widget::icon::from_name("view-more-symbolic")) + .extra_small() + .on_press(on_press); + + if expanded { + widget::popover(button) + .popup(popover_menu(id)) + .on_close(Message::ExpandLanguagePopover(None)) + .into() + } else { + button.into() + } +} + +fn popover_menu(id: usize) -> Element<'static, Message> { + widget::column::with_children(vec![ + popover_menu_row( + id, + fl!("keyboard-sources", "move-up"), + SourceContext::MoveUp, + ), + cosmic::widget::divider::horizontal::default().into(), + popover_menu_row( + id, + fl!("keyboard-sources", "move-down"), + SourceContext::MoveDown, + ), + cosmic::widget::divider::horizontal::default().into(), + popover_menu_row(id, fl!("keyboard-sources", "remove"), SourceContext::Remove), + ]) + .padding(8) + .width(Length::Shrink) + .height(Length::Shrink) + .apply(cosmic::widget::container) + .class(cosmic::theme::Container::custom(|theme| { + let cosmic = theme.cosmic(); + container::Style { + icon_color: Some(theme.cosmic().background.on.into()), + text_color: Some(theme.cosmic().background.on.into()), + background: Some(Color::from(theme.cosmic().background.base).into()), + border: Border { + radius: cosmic.corner_radii.radius_m.into(), + ..Default::default() + }, + shadow: Default::default(), + } + })) + .into() +} + +fn popover_menu_row( + id: usize, + label: String, + message: impl Fn(usize) -> SourceContext + 'static, +) -> cosmic::Element<'static, Message> { + widget::text::body(label) + .apply(widget::container) + .class(cosmic::theme::Container::custom(|theme| { + widget::container::Style { + background: None, + ..widget::container::Catalog::style(theme, &cosmic::theme::Container::List) + } + })) + .apply(button::custom) + .on_press(()) + .class(theme::Button::Transparent) + .apply(Element::from) + .map(move |()| Message::SourceContext(message(id))) +} + +pub async fn set_locale(lang: String, region: String) { + eprintln!("setting locale lang={lang}, region={region}"); + _ = tokio::process::Command::new("localectl") + .arg("set-locale") + .args(&[ + ["LANG=", &lang].concat(), + ["LC_ADDRESS=", ®ion].concat(), + ["LC_IDENTIFICATION=", ®ion].concat(), + ["LC_MEASUREMENT=", ®ion].concat(), + ["LC_MONETARY=", ®ion].concat(), + ["LC_NAME=", ®ion].concat(), + ["LC_NUMERIC=", ®ion].concat(), + ["LC_PAPER=", ®ion].concat(), + ["LC_TELEPHONE=", ®ion].concat(), + ["LC_TIME=", ®ion].concat(), + ]) + .status() + .await; +} diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 135d7876..5830b6a2 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -707,6 +707,22 @@ time-format = Date & Time Format time-region = Region & Language .desc = Format dates, times, and numbers based on your region +formatting = Formatting + .dates = Dates + .time = Time + .date-and-time = Date & Time + .numbers = Numbers + .measurement = Measurement + .paper = Paper + +preferred-languages = Preferred Languages + .desc = The order of languages determines which language is used for the translation of the desktop. Changes take effect on next login. + +add-language = Add language + .context = Add Language +install-additional-languages = Install additional languages +region = Region + ## System system = System & Accounts