From e155018474e3a578da27d343c1ffb31b559adef7 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 20 Apr 2026 04:58:50 -0700 Subject: [PATCH] control units from LC_ALL with config fallback --- README.md | 37 +++++++++++++ src/app.rs | 51 +++++++++++++----- src/config.rs | 146 +++++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 13 ++++- 4 files changed, 231 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 513fabe..d3f6e59 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,43 @@ killall cosmic-panel The panel will relaunch automatically and load the updated applet. +## Configuration + +### Temperature and wind units + +By default, the applet lets [wttr.in](https://wttr.in) pick units based on +your IP-based location (so users in the US get Fahrenheit, everyone else +gets Celsius). + +At startup the applet additionally inspects your measurement locale +(`LC_MEASUREMENT`, then `LC_ALL`, then `LANG`). Locales in the US, +Liberia, the Bahamas, Belize, the Cayman Islands, and Palau are requested +as Fahrenheit / USCS; all other locales fall through to the wttr.in +default. + +To override the choice explicitly, set `COSMIC_WEATHER_UNITS`: + +| Value | Meaning | +| ------ | ----------------------------------------- | +| `u` | Fahrenheit, mph (USCS) | +| `m` | Celsius, km/h (metric) | +| `M` | Celsius, m/s | +| `auto` | (or unset) Use locale / geolocation | + +Because the applet is launched by `cosmic-panel`, a persistent override +is easiest to set on that service, e.g.: + +```sh +systemctl --user edit cosmic-panel +# In the override file: +# [Service] +# Environment=COSMIC_WEATHER_UNITS=u + +systemctl --user restart cosmic-panel +``` + +Changes take effect after the applet is restarted. + ## Localization [Fluent](https://projectfluent.org/) is used for localization. Translation files are in the [i18n directory](./i18n). To add a new language, copy the [English (en) localization](./i18n/en), rename the directory to the target [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes), and translate the message values. diff --git a/src/app.rs b/src/app.rs index 19ab3d4..4f80a50 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,6 +12,8 @@ use cosmic::iced_core::Shadow; use futures_util::{SinkExt, StreamExt}; use std::time::Duration; +use crate::config::Units; + const WTTR_URL: &str = "https://wttr.lerch.org"; const WEATHER_UPDATE_INTERVAL_MINUTES: u64 = 15; const REQUEST_TIMEOUT_SECS: u64 = 15; @@ -59,6 +61,8 @@ pub struct AppModel { rectangle_tracker: Option>, /// Number of consecutive fetch retries (for captive portal / network issues) retry_count: u32, + /// Unit system to request from wttr.in (None = let the service geolocate). + units: Option, } /// Messages emitted by the applet. @@ -76,7 +80,7 @@ pub enum Message { impl cosmic::Application for AppModel { type Executor = cosmic::executor::Default; - type Flags = (); + type Flags = Option; type Message = Message; const APP_ID: &'static str = "org.lerch.weather"; @@ -90,7 +94,7 @@ impl cosmic::Application for AppModel { fn init( core: cosmic::Core, - _flags: Self::Flags, + flags: Self::Flags, ) -> (Self, Task>) { let app = AppModel { core, @@ -103,9 +107,11 @@ impl cosmic::Application for AppModel { rectangle: Rectangle::default(), rectangle_tracker: None, retry_count: 0, + units: flags, }; - let command = Task::perform(fetch_weather(), |result| { + let units = app.units; + let command = Task::perform(fetch_weather(units), |result| { cosmic::Action::App(Message::WeatherUpdate(result)) }); @@ -328,8 +334,9 @@ impl cosmic::Application for AppModel { "Scheduling retry {}/{MAX_RETRIES} in {delay}s", self.retry_count ); + let units = self.units; return Task::perform( - fetch_weather_delayed(delay), + fetch_weather_delayed(delay, units), |result| cosmic::Action::App(Message::WeatherUpdate(result)), ); } @@ -354,7 +361,8 @@ impl cosmic::Application for AppModel { } Message::RefreshWeather => { self.retry_count = 0; - Task::perform(fetch_weather(), |result| { + let units = self.units; + Task::perform(fetch_weather(units), |result| { cosmic::Action::App(Message::WeatherUpdate(result)) }) } @@ -363,7 +371,8 @@ impl cosmic::Application for AppModel { self.retry_count = 0; // Clear cached full weather so the popup re-fetches on next open self.full_weather.clear(); - Task::perform(fetch_weather(), |result| { + let units = self.units; + Task::perform(fetch_weather(units), |result| { cosmic::Action::App(Message::WeatherUpdate(result)) }) } @@ -445,7 +454,8 @@ impl AppModel { .max_height(1080.0); if self.full_weather.is_empty() { - let fetch_task = Task::perform(fetch_full_weather(), |result| { + let units = self.units; + let fetch_task = Task::perform(fetch_full_weather(units), |result| { cosmic::Action::App(Message::FullWeatherUpdate(result)) }); Task::batch([get_popup(popup_settings), fetch_task]) @@ -455,14 +465,17 @@ impl AppModel { } } -async fn fetch_weather() -> Result<(String, String), String> { +async fn fetch_weather(units: Option) -> Result<(String, String), String> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) .build() .map_err(|e| e.to_string())?; let response = client - .get(format!("{WTTR_URL}/?format=%l|%c|%t")) + .get(format!( + "{WTTR_URL}/?format=%l|%c|%t{suffix}", + suffix = units_suffix(units) + )) .send() .await .map_err(|e| e.to_string())?; @@ -501,14 +514,14 @@ fn format_current_time() -> String { .unwrap_or_default() } -async fn fetch_full_weather() -> Result { +async fn fetch_full_weather(units: Option) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) .build() .map_err(|e| e.to_string())?; let response = client - .get(format!("{WTTR_URL}?A&T")) + .get(format!("{WTTR_URL}?A&T{suffix}", suffix = units_suffix(units))) .send() .await .map_err(|e| e.to_string())?; @@ -538,7 +551,19 @@ fn retry_delay(retry_count: u32) -> u64 { } /// Fetch weather after waiting `delay_secs`, used for retry backoff. -async fn fetch_weather_delayed(delay_secs: u64) -> Result<(String, String), String> { +async fn fetch_weather_delayed( + delay_secs: u64, + units: Option, +) -> Result<(String, String), String> { tokio::time::sleep(Duration::from_secs(delay_secs)).await; - fetch_weather().await + fetch_weather(units).await +} + +/// Build the URL suffix that selects a unit system on wttr.in. +/// Returns an empty string when no explicit choice has been made. +fn units_suffix(units: Option) -> String { + match units { + Some(u) => format!("&{}", u.query_param()), + None => String::new(), + } } diff --git a/src/config.rs b/src/config.rs index 49286f2..c9bd90b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,147 @@ // SPDX-License-Identifier: MIT -// Config module placeholder - currently unused +//! Runtime configuration for the applet. +//! +//! Currently only handles temperature/wind unit selection for wttr.in. +//! Units are resolved once at startup from (in order): +//! 1. The `COSMIC_WEATHER_UNITS` environment variable +//! (values: `u`, `m`, `M`, or `auto`/empty to fall through). +//! 2. The user's measurement locale (`LC_MEASUREMENT`, then `LC_ALL`, +//! then `LANG`). Fahrenheit-using locales map to `Uscs`; everything +//! else falls through. +//! 3. `None`, which leaves the choice to wttr.in's IP-based geolocation +//! (the previous behavior). + +/// Unit system to request from wttr.in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Units { + /// Celsius, km/h. Corresponds to wttr.in's `m` flag. + Metric, + /// Celsius, m/s. Corresponds to wttr.in's `M` flag. + MetricMs, + /// Fahrenheit, mph. Corresponds to wttr.in's `u` flag. + Uscs, +} + +impl Units { + /// Single-letter flag understood by wttr.in (e.g. appended as `&u`). + pub const fn query_param(self) -> &'static str { + match self { + Self::Metric => "m", + Self::MetricMs => "M", + Self::Uscs => "u", + } + } +} + +/// Locales whose measurement convention is Fahrenheit / USCS. +/// Source: CLDR measurement-system data (US, Liberia, Bahamas, Belize, +/// Cayman Islands, Palau). +const USCS_LOCALES: &[&str] = &["en_US", "en_LR", "en_BS", "en_BZ", "en_KY", "en_PW"]; + +/// Resolve the units to request at applet startup. +/// +/// Returns `None` when neither the env var nor the locale gives a +/// definitive answer, in which case wttr.in decides based on the +/// client's geolocation. +#[must_use] +pub fn resolve_units() -> Option { + if let Some(u) = units_from_env() { + return u; + } + units_from_locale() +} + +/// Parse `COSMIC_WEATHER_UNITS`. +/// +/// Returns: +/// - `Some(Some(units))` — explicit override. +/// - `Some(None)` — explicit `auto` or empty; caller should still return +/// `None` overall (skip locale detection? No — we want `auto` to mean +/// "fall through to locale", so this returns `None` to signal fall-through). +/// - `None` — env var unset; fall through. +/// +/// To keep the control flow simple we collapse all fall-through cases to +/// a single `None` return and only return `Some(Some(_))` for an explicit +/// unit choice. +fn units_from_env() -> Option> { + let raw = std::env::var("COSMIC_WEATHER_UNITS").ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("auto") { + return None; + } + let parsed = match trimmed { + "u" | "U" => Some(Units::Uscs), + "m" => Some(Units::Metric), + "M" => Some(Units::MetricMs), + other => { + eprintln!( + "COSMIC_WEATHER_UNITS={other:?} is not recognized; \ + expected one of: u, m, M, auto" + ); + return None; + } + }; + Some(parsed) +} + +/// Inspect measurement-related locale env vars and map to `Units`. +fn units_from_locale() -> Option { + for var in ["LC_MEASUREMENT", "LC_ALL", "LANG"] { + if let Ok(val) = std::env::var(var) { + if let Some(units) = classify_locale(&val) { + return Some(units); + } + } + } + None +} + +/// Strip codeset (`.UTF-8`) and modifier (`@euro`) and check the +/// language_TERRITORY prefix against the USCS list. +fn classify_locale(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "C" || trimmed == "POSIX" { + return None; + } + let without_codeset = trimmed.split('.').next().unwrap_or(trimmed); + let without_modifier = without_codeset.split('@').next().unwrap_or(without_codeset); + if USCS_LOCALES + .iter() + .any(|l| l.eq_ignore_ascii_case(without_modifier)) + { + Some(Units::Uscs) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_params() { + assert_eq!(Units::Metric.query_param(), "m"); + assert_eq!(Units::MetricMs.query_param(), "M"); + assert_eq!(Units::Uscs.query_param(), "u"); + } + + #[test] + fn classify_known_uscs_locales() { + assert_eq!(classify_locale("en_US.UTF-8"), Some(Units::Uscs)); + assert_eq!(classify_locale("en_US"), Some(Units::Uscs)); + assert_eq!(classify_locale("en_LR.UTF-8"), Some(Units::Uscs)); + assert_eq!(classify_locale("en_BZ@something"), Some(Units::Uscs)); + } + + #[test] + fn classify_non_uscs_locales() { + assert_eq!(classify_locale("fi_FI.UTF-8"), None); + assert_eq!(classify_locale("en_GB.UTF-8"), None); + assert_eq!(classify_locale("de_DE"), None); + assert_eq!(classify_locale("C"), None); + assert_eq!(classify_locale("POSIX"), None); + assert_eq!(classify_locale(""), None); + } +} diff --git a/src/main.rs b/src/main.rs index 91e576a..a068f3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,15 @@ fn main() -> cosmic::iced::Result { // Enable localizations to be applied. i18n::init(&requested_languages); - // Starts the applet's event loop with `()` as the application's flags. - cosmic::applet::run::(()) + // Resolve unit preference once at startup. This checks the + // COSMIC_WEATHER_UNITS env var first, then measurement locale vars, + // falling back to `None` (let wttr.in decide by IP). + let units = config::resolve_units(); + match units { + Some(u) => eprintln!("Weather units resolved to {u:?} (wttr.in flag: {})", u.query_param()), + None => eprintln!("Weather units not explicitly set; deferring to wttr.in geolocation"), + } + + // Starts the applet's event loop, passing the resolved units as flags. + cosmic::applet::run::(units) }