control units from LC_ALL with config fallback
This commit is contained in:
parent
07cce8ec0d
commit
e155018474
4 changed files with 231 additions and 16 deletions
37
README.md
37
README.md
|
|
@ -39,6 +39,43 @@ killall cosmic-panel
|
||||||
|
|
||||||
The panel will relaunch automatically and load the updated applet.
|
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
|
## 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.
|
[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.
|
||||||
|
|
|
||||||
51
src/app.rs
51
src/app.rs
|
|
@ -12,6 +12,8 @@ use cosmic::iced_core::Shadow;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::Units;
|
||||||
|
|
||||||
const WTTR_URL: &str = "https://wttr.lerch.org";
|
const WTTR_URL: &str = "https://wttr.lerch.org";
|
||||||
const WEATHER_UPDATE_INTERVAL_MINUTES: u64 = 15;
|
const WEATHER_UPDATE_INTERVAL_MINUTES: u64 = 15;
|
||||||
const REQUEST_TIMEOUT_SECS: u64 = 15;
|
const REQUEST_TIMEOUT_SECS: u64 = 15;
|
||||||
|
|
@ -59,6 +61,8 @@ pub struct AppModel {
|
||||||
rectangle_tracker: Option<RectangleTracker<u32>>,
|
rectangle_tracker: Option<RectangleTracker<u32>>,
|
||||||
/// Number of consecutive fetch retries (for captive portal / network issues)
|
/// Number of consecutive fetch retries (for captive portal / network issues)
|
||||||
retry_count: u32,
|
retry_count: u32,
|
||||||
|
/// Unit system to request from wttr.in (None = let the service geolocate).
|
||||||
|
units: Option<Units>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages emitted by the applet.
|
/// Messages emitted by the applet.
|
||||||
|
|
@ -76,7 +80,7 @@ pub enum Message {
|
||||||
|
|
||||||
impl cosmic::Application for AppModel {
|
impl cosmic::Application for AppModel {
|
||||||
type Executor = cosmic::executor::Default;
|
type Executor = cosmic::executor::Default;
|
||||||
type Flags = ();
|
type Flags = Option<Units>;
|
||||||
type Message = Message;
|
type Message = Message;
|
||||||
const APP_ID: &'static str = "org.lerch.weather";
|
const APP_ID: &'static str = "org.lerch.weather";
|
||||||
|
|
||||||
|
|
@ -90,7 +94,7 @@ impl cosmic::Application for AppModel {
|
||||||
|
|
||||||
fn init(
|
fn init(
|
||||||
core: cosmic::Core,
|
core: cosmic::Core,
|
||||||
_flags: Self::Flags,
|
flags: Self::Flags,
|
||||||
) -> (Self, Task<cosmic::Action<Self::Message>>) {
|
) -> (Self, Task<cosmic::Action<Self::Message>>) {
|
||||||
let app = AppModel {
|
let app = AppModel {
|
||||||
core,
|
core,
|
||||||
|
|
@ -103,9 +107,11 @@ impl cosmic::Application for AppModel {
|
||||||
rectangle: Rectangle::default(),
|
rectangle: Rectangle::default(),
|
||||||
rectangle_tracker: None,
|
rectangle_tracker: None,
|
||||||
retry_count: 0,
|
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))
|
cosmic::Action::App(Message::WeatherUpdate(result))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -328,8 +334,9 @@ impl cosmic::Application for AppModel {
|
||||||
"Scheduling retry {}/{MAX_RETRIES} in {delay}s",
|
"Scheduling retry {}/{MAX_RETRIES} in {delay}s",
|
||||||
self.retry_count
|
self.retry_count
|
||||||
);
|
);
|
||||||
|
let units = self.units;
|
||||||
return Task::perform(
|
return Task::perform(
|
||||||
fetch_weather_delayed(delay),
|
fetch_weather_delayed(delay, units),
|
||||||
|result| cosmic::Action::App(Message::WeatherUpdate(result)),
|
|result| cosmic::Action::App(Message::WeatherUpdate(result)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +361,8 @@ impl cosmic::Application for AppModel {
|
||||||
}
|
}
|
||||||
Message::RefreshWeather => {
|
Message::RefreshWeather => {
|
||||||
self.retry_count = 0;
|
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))
|
cosmic::Action::App(Message::WeatherUpdate(result))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -363,7 +371,8 @@ impl cosmic::Application for AppModel {
|
||||||
self.retry_count = 0;
|
self.retry_count = 0;
|
||||||
// Clear cached full weather so the popup re-fetches on next open
|
// Clear cached full weather so the popup re-fetches on next open
|
||||||
self.full_weather.clear();
|
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))
|
cosmic::Action::App(Message::WeatherUpdate(result))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -445,7 +454,8 @@ impl AppModel {
|
||||||
.max_height(1080.0);
|
.max_height(1080.0);
|
||||||
|
|
||||||
if self.full_weather.is_empty() {
|
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))
|
cosmic::Action::App(Message::FullWeatherUpdate(result))
|
||||||
});
|
});
|
||||||
Task::batch([get_popup(popup_settings), fetch_task])
|
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<Units>) -> Result<(String, String), String> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let response = client
|
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()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
@ -501,14 +514,14 @@ fn format_current_time() -> String {
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_full_weather() -> Result<String, String> {
|
async fn fetch_full_weather(units: Option<Units>) -> Result<String, String> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.get(format!("{WTTR_URL}?A&T"))
|
.get(format!("{WTTR_URL}?A&T{suffix}", suffix = units_suffix(units)))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.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.
|
/// 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<Units>,
|
||||||
|
) -> Result<(String, String), String> {
|
||||||
tokio::time::sleep(Duration::from_secs(delay_secs)).await;
|
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<Units>) -> String {
|
||||||
|
match units {
|
||||||
|
Some(u) => format!("&{}", u.query_param()),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
src/config.rs
146
src/config.rs
|
|
@ -1,3 +1,147 @@
|
||||||
// SPDX-License-Identifier: MIT
|
// 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<Units> {
|
||||||
|
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<Option<Units>> {
|
||||||
|
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<Units> {
|
||||||
|
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<Units> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
13
src/main.rs
13
src/main.rs
|
|
@ -11,6 +11,15 @@ fn main() -> cosmic::iced::Result {
|
||||||
// Enable localizations to be applied.
|
// Enable localizations to be applied.
|
||||||
i18n::init(&requested_languages);
|
i18n::init(&requested_languages);
|
||||||
|
|
||||||
// Starts the applet's event loop with `()` as the application's flags.
|
// Resolve unit preference once at startup. This checks the
|
||||||
cosmic::applet::run::<app::AppModel>(())
|
// 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::<app::AppModel>(units)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue