From bd1d26dbdfa62a6d18d3b0b20483ae48a9eb5e2d Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Sat, 25 Oct 2025 22:57:33 -0400 Subject: [PATCH 1/3] Formattable datetime strings with strftime Closes: #1096, #779, pop-os/cosmic-epoch#2255 The format follows standard strftime specifiers. Chrono's docs has a page listing them with examples: https://docs.rs/chrono/latest/chrono/format/strftime/index.html The strftime formatter overrides ICU if enabled because ICU formats time in a locale appropriate manner. Strftime, by its nature, is an override. --- cosmic-applet-time/src/window.rs | 139 ++++++++++++++++++------------ cosmic-applets-config/src/time.rs | 17 ++++ 2 files changed, 99 insertions(+), 57 deletions(-) diff --git a/cosmic-applet-time/src/window.rs b/cosmic-applet-time/src/window.rs index 2353db26..d45fe76f 100644 --- a/cosmic-applet-time/src/window.rs +++ b/cosmic-applet-time/src/window.rs @@ -159,45 +159,61 @@ impl Window { } fn vertical_layout(&self) -> Element<'_, Message> { - let mut elements = Vec::new(); - let date = self.now.naive_local(); - let datetime = self.create_datetime(&date); - let mut prefs = DateTimeFormatterPreferences::from(self.locale.clone()); - prefs.hour_cycle = Some(if self.config.military_time { - HourCycle::H23 + let elements: Vec> = if let Some(formatted) = self + .config + .format_strftime + .as_deref() + .map(|format| self.now.format(format).to_string()) + { + // strftime formatter may override locale specific elements so it stands alone rather + // than using ICU to determine a format. + formatted + .split_whitespace() + .map(|piece| self.core.applet.text(piece.to_owned()).into()) + .collect() } else { - HourCycle::H12 - }); + let mut elements = Vec::new(); + let date = self.now.naive_local(); + let datetime = self.create_datetime(&date); + let mut prefs = DateTimeFormatterPreferences::from(self.locale.clone()); + prefs.hour_cycle = Some(if self.config.military_time { + HourCycle::H23 + } else { + HourCycle::H12 + }); + + if self.config.show_date_in_top_panel { + let formatted_date = DateTimeFormatter::try_new(prefs, fieldsets::MD::medium()) + .unwrap() + .format(&datetime) + .to_string(); - if self.config.show_date_in_top_panel { - let formatted_date = DateTimeFormatter::try_new(prefs, fieldsets::MD::medium()) + for p in formatted_date.split_whitespace() { + elements.push(self.core.applet.text(p.to_owned()).into()); + } + elements.push( + horizontal_rule(2) + .width(self.core.applet.suggested_size(true).0) + .into(), + ); + } + let mut fs = fieldsets::T::medium(); + if !self.config.show_seconds { + fs = fs.with_time_precision(TimePrecision::Minute); + } + let formatted_time = DateTimeFormatter::try_new(prefs, fs) .unwrap() .format(&datetime) .to_string(); - for p in formatted_date.split_whitespace() { + // todo: split using formatToParts when it is implemented + // https://github.com/unicode-org/icu4x/issues/4936#issuecomment-2128812667 + for p in formatted_time.split_whitespace().flat_map(|s| s.split(':')) { elements.push(self.core.applet.text(p.to_owned()).into()); } - elements.push( - horizontal_rule(2) - .width(self.core.applet.suggested_size(true).0) - .into(), - ); - } - let mut fs = fieldsets::T::medium(); - if !self.config.show_seconds { - fs = fs.with_time_precision(TimePrecision::Minute); - } - let formatted_time = DateTimeFormatter::try_new(prefs, fs) - .unwrap() - .format(&datetime) - .to_string(); - - // todo: split using formatToParts when it is implemented - // https://github.com/unicode-org/icu4x/issues/4936#issuecomment-2128812667 - for p in formatted_time.split_whitespace().flat_map(|s| s.split(':')) { - elements.push(self.core.applet.text(p.to_owned()).into()); - } + + elements + }; let date_time_col = Column::with_children(elements) .align_x(Alignment::Center) @@ -216,26 +232,44 @@ impl Window { } fn horizontal_layout(&self) -> Element<'_, Message> { - let datetime = self.create_datetime(&self.now); - let mut prefs = DateTimeFormatterPreferences::from(self.locale.clone()); - prefs.hour_cycle = Some(if self.config.military_time { - HourCycle::H23 + let formatted_date = if let Some(formatted) = self + .config + .format_strftime + .as_deref() + .map(|format| self.now.format(format).to_string()) + { + formatted } else { - HourCycle::H12 - }); - - let formatted_date = if self.config.show_date_in_top_panel { - if self.config.show_weekday { - let mut fs = fieldsets::MDET::long(); - if !self.config.show_seconds { - fs = fs.with_time_precision(TimePrecision::Minute); + let datetime = self.create_datetime(&self.now); + let mut prefs = DateTimeFormatterPreferences::from(self.locale.clone()); + prefs.hour_cycle = Some(if self.config.military_time { + HourCycle::H23 + } else { + HourCycle::H12 + }); + + if self.config.show_date_in_top_panel { + if self.config.show_weekday { + let mut fs = fieldsets::MDET::long(); + if !self.config.show_seconds { + fs = fs.with_time_precision(TimePrecision::Minute); + } + DateTimeFormatter::try_new(prefs, fs) + .unwrap() + .format(&datetime) + .to_string() + } else { + let mut fs = fieldsets::MDT::long(); + if !self.config.show_seconds { + fs = fs.with_time_precision(TimePrecision::Minute); + } + DateTimeFormatter::try_new(prefs, fs) + .unwrap() + .format(&datetime) + .to_string() } - DateTimeFormatter::try_new(prefs, fs) - .unwrap() - .format(&datetime) - .to_string() } else { - let mut fs = fieldsets::MDT::long(); + let mut fs = fieldsets::T::medium(); if !self.config.show_seconds { fs = fs.with_time_precision(TimePrecision::Minute); } @@ -244,15 +278,6 @@ impl Window { .format(&datetime) .to_string() } - } else { - let mut fs = fieldsets::T::medium(); - if !self.config.show_seconds { - fs = fs.with_time_precision(TimePrecision::Minute); - } - DateTimeFormatter::try_new(prefs, fs) - .unwrap() - .format(&datetime) - .to_string() }; Element::from( diff --git a/cosmic-applets-config/src/time.rs b/cosmic-applets-config/src/time.rs index c79c9468..b1aaa645 100644 --- a/cosmic-applets-config/src/time.rs +++ b/cosmic-applets-config/src/time.rs @@ -11,6 +11,8 @@ pub struct TimeAppletConfig { pub first_day_of_week: u8, pub show_date_in_top_panel: bool, pub show_weekday: bool, + #[serde(default, deserialize_with = "strftime_opt_de")] + pub format_strftime: Option, } impl Default for TimeAppletConfig { @@ -21,6 +23,21 @@ impl Default for TimeAppletConfig { first_day_of_week: 6, show_date_in_top_panel: true, show_weekday: false, + format_strftime: None, } } } + +/// Deserialize optional String but only if it is non-empty. +fn strftime_opt_de<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + serde::Deserialize::deserialize(deserializer).map(|strftime: Option| { + if strftime.as_deref().is_none_or(str::is_empty) { + None + } else { + strftime + } + }) +} From 59a9bb1e4a1ab70fa2c534adb2eec6918a1a6750 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Fri, 31 Oct 2025 13:38:22 -0400 Subject: [PATCH 2/3] time: Simplify strftime config --- cosmic-applet-time/src/window.rs | 20 ++++++-------------- cosmic-applets-config/src/time.rs | 20 +++----------------- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/cosmic-applet-time/src/window.rs b/cosmic-applet-time/src/window.rs index d45fe76f..8417178b 100644 --- a/cosmic-applet-time/src/window.rs +++ b/cosmic-applet-time/src/window.rs @@ -159,15 +159,12 @@ impl Window { } fn vertical_layout(&self) -> Element<'_, Message> { - let elements: Vec> = if let Some(formatted) = self - .config - .format_strftime - .as_deref() - .map(|format| self.now.format(format).to_string()) - { + let elements: Vec> = if !self.config.format_strftime.is_empty() { // strftime formatter may override locale specific elements so it stands alone rather // than using ICU to determine a format. - formatted + self.now + .format(&self.config.format_strftime) + .to_string() .split_whitespace() .map(|piece| self.core.applet.text(piece.to_owned()).into()) .collect() @@ -232,13 +229,8 @@ impl Window { } fn horizontal_layout(&self) -> Element<'_, Message> { - let formatted_date = if let Some(formatted) = self - .config - .format_strftime - .as_deref() - .map(|format| self.now.format(format).to_string()) - { - formatted + let formatted_date = if !self.config.format_strftime.is_empty() { + self.now.format(&self.config.format_strftime).to_string() } else { let datetime = self.create_datetime(&self.now); let mut prefs = DateTimeFormatterPreferences::from(self.locale.clone()); diff --git a/cosmic-applets-config/src/time.rs b/cosmic-applets-config/src/time.rs index b1aaa645..8af39027 100644 --- a/cosmic-applets-config/src/time.rs +++ b/cosmic-applets-config/src/time.rs @@ -11,8 +11,8 @@ pub struct TimeAppletConfig { pub first_day_of_week: u8, pub show_date_in_top_panel: bool, pub show_weekday: bool, - #[serde(default, deserialize_with = "strftime_opt_de")] - pub format_strftime: Option, + #[serde(default, skip_serializing_if = "str::is_empty")] + pub format_strftime: String, } impl Default for TimeAppletConfig { @@ -23,21 +23,7 @@ impl Default for TimeAppletConfig { first_day_of_week: 6, show_date_in_top_panel: true, show_weekday: false, - format_strftime: None, + format_strftime: Default::default(), } } } - -/// Deserialize optional String but only if it is non-empty. -fn strftime_opt_de<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - serde::Deserialize::deserialize(deserializer).map(|strftime: Option| { - if strftime.as_deref().is_none_or(str::is_empty) { - None - } else { - strftime - } - }) -} From da485e853e739beabffaa4765de15411dfa73a70 Mon Sep 17 00:00:00 2001 From: Josh Megnauth Date: Tue, 4 Nov 2025 22:07:01 -0500 Subject: [PATCH 3/3] time: Tick per sec if strftime has seconds spec It's possible to have a discrepancy between strftime and the show_seconds setting. If show_seconds is disabled but a user's strftime formatter has a seconds specifier, then the time applet won't update seconds because it's internally ticking per minute. The fix is to ensure that the time applet updates per second regardless of the user's setting if strftime has a seconds specifier. This is an internal flag and shouldn't affect the user's show_seconds setting. --- cosmic-applet-time/src/window.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/cosmic-applet-time/src/window.rs b/cosmic-applet-time/src/window.rs index 8417178b..aa47b1ae 100644 --- a/cosmic-applet-time/src/window.rs +++ b/cosmic-applet-time/src/window.rs @@ -43,6 +43,11 @@ use icu::{ static AUTOSIZE_MAIN_ID: LazyLock = LazyLock::new(|| Id::new("autosize-main")); +// Specifiers for strftime that indicate seconds. Subsecond precision isn't supported by the applet +// so those specifiers aren't listed here. This list is non-exhaustive, and it's possible that %X +// and other specifiers have to be added depending on locales. +const STRFTIME_SECONDS: &[char] = &['S', 'T', '+', 's']; + fn get_system_locale() -> Locale { for var in ["LC_TIME", "LC_ALL", "LANG"] { if let Ok(locale_str) = std::env::var(var) { @@ -604,7 +609,20 @@ impl cosmic::Application for Window { Message::ConfigChanged(c) => { // Don't interrupt the tick subscription unless necessary self.show_seconds_tx.send_if_modified(|show_seconds| { - if *show_seconds == c.show_seconds { + if !c.format_strftime.is_empty() { + if c.format_strftime.split('%').any(|s| { + STRFTIME_SECONDS.contains(&s.chars().next().unwrap_or_default()) + }) && !*show_seconds + { + // The strftime formatter contains a seconds specifier. Force enable + // ticking per seconds internally regardless of the user setting. + // This does not change the user's setting. It's invisible to the user. + *show_seconds = true; + true + } else { + false + } + } else if *show_seconds == c.show_seconds { false } else { *show_seconds = c.show_seconds;