weather: now, the next 24 hours and the week
git clone https://git.lucas.co/cce-weather.git
cce-weather: current conditions, the next 24 hours and the week
Open-Meteo's forecast and geocoder (keyless HTTPS JSON) over blocking
reqwest on worker threads. A root plate with a place search, a unit
toggle and refresh, then three pane plates: now, an hourly chart (curve,
labels, precipitation-chance bars) and the week's rows with range bars on
the week's scale. A search with several matches offers up to five; one
match is taken as is. Weather glyphs are drawn from prims.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.gitignore | 2 +
CLAUDE.md | 69 ++++
Cargo.toml | 17 +
Makefile | 15 +
cce-weather.desktop | 10 +
src/api.rs | 192 +++++++++++
src/config.rs | 210 ++++++++++++
src/glyph.rs | 179 ++++++++++
src/main.rs | 949 ++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 1643 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..6c9bb5b
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,69 @@
+# cce-weather
+
+Current conditions, the next 24 hours and the week ahead. Read the workspace
+guide (`../cce-compositor/WORKSPACE.md`) first; this file covers only what is
+particular to this crate.
+
+## Shape
+
+- `src/main.rs` — the `Application`: root plate, a control row (place
+ search `TextBox`, unit toggle, refresh), then three pane plates — now, the
+ hourly chart (a canvas well: glyphs, curve with labels, precipitation-chance
+ bars, hour labels) and the daily rows (range bar on the week's scale,
+ coloured cold → warm). A search with several matches swaps the panes for
+ up to five list-row buttons; one match is taken without asking.
+- `src/api.rs` — Open-Meteo forecast + geocoder over blocking reqwest, on
+ worker threads; results come back through the loop's `Sender`. The
+ requested field lists and the serde structs move together.
+- `src/config.rs` — config, state, units.
+- `src/glyph.rs` — WMO code → words, and a glyph drawn from prims (no
+ artwork). Colours are sRGB constants sent through `to_linear`: prim colours
+ are linear, and raw sRGB values draw washed out.
+
+## Data
+
+Open-Meteo needs no key or account. `timezone=auto` makes every time string
+local wall-clock time *at the place*, without an offset — compare them as
+strings (`hour_index`), never against the machine's clock.
+
+## Config vs state
+
+- `~/.config/cce/cce-weather/config.kdl`:
+ `units "imperial"|"metric"`, `location "Name" lat=… lon=… region="…"`,
+ `refresh_minutes 15` (clamped 5..1440). No units anywhere → the
+ measurement locale (US → imperial).
+- `~/.local/state/cce/weather/state.json`: ONLY choices made in the app (a
+ searched place, the toggle) plus the last forecast and the place/units it
+ was for. A choice made in the app wins over the config; a setting never
+ touched in the app keeps following the config. Delete the state file to
+ go back to the config entirely.
+
+## Refresh
+
+A timer thread wakes every minute and compares the *wall clock* with the
+last fetch, so a laptop waking from suspend refreshes within a minute — a
+monotonic sleep for the whole interval does not count suspended time. A
+failed fetch also resets the clock (one retry per interval, not per minute);
+Refresh / Ctrl+R / F5 retry at once. The idle frame loop is untouched: the
+thread only sends a message when a refresh is due.
+
+## Text
+
+Pass the family from `list_font_parsed().0`, never `list_font()`: the
+latter may carry a size, which overrides every size asked for (the big
+temperature rendered at body size until this).
+
+## Verifying
+
+`cargo test -p cce-weather` (offline); `cargo test -p cce-weather -- --ignored`
+hits the real service. Visually, shadow only, with the fonts exported:
+
+```sh
+cce-shadow start --new # add --scale 2 for the live display's scale
+cce-shadow spawn env CCE_FONTS_DIR=/home/lsgalante/Dropbox/Fonts \
+ CCE_ICONS_DIR=/home/lsgalante/projects/cce/cce-icons/svg \
+ /home/lsgalante/projects/cce/target/release/cce-weather
+cce-shadow shot-window 0
+```
+
+The shadow has its own HOME, so its state file is not the real one.
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..bf93215
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "cce-weather"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "9afd4d8c634296572feaddc96ae7c7edd0582ce4" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+# Open-Meteo (forecast + geocoding): keyless HTTPS JSON.
+reqwest = { version = "0.12", features = ["blocking", "json"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+chrono = "0.4"
+kdl = "4.6"
+log = "0.4"
+env_logger = "0.11"
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..6ce8014
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,15 @@
+.PHONY: build install run clean
+
+build:
+ cargo build --release
+
+# Binaries are enumerated by ccebuild from cargo metadata — never name one here.
+install: build
+ @command -v ccebuild >/dev/null || { echo "ccebuild not installed — run: make -C ../cce-compositor install"; exit 1; }
+ ccebuild install --no-build cce-weather
+
+run:
+ cargo run
+
+clean:
+ cargo clean
diff --git a/cce-weather.desktop b/cce-weather.desktop
new file mode 100644
index 0000000..ba031c8
--- /dev/null
+++ b/cce-weather.desktop
@@ -0,0 +1,10 @@
+[Desktop Entry]
+Type=Application
+Name=Weather
+Comment=Current conditions and forecast (Open-Meteo)
+Exec=cce-weather
+Icon=cce-weather
+Categories=Utility;
+Terminal=false
+StartupNotify=true
+StartupWMClass=cce-weather
diff --git a/src/api.rs b/src/api.rs
new file mode 100644
index 0000000..fbba04f
--- /dev/null
+++ b/src/api.rs
@@ -0,0 +1,192 @@
+//! Open-Meteo: the forecast and the geocoder. Both are keyless HTTPS JSON
+//! endpoints, called from worker threads (blocking reqwest) — never from the
+//! frame loop.
+
+use serde::{Deserialize, Serialize};
+
+use crate::config::{Location, Units};
+
+const FORECAST_URL: &str = "https://api.open-meteo.com/v1/forecast";
+const GEOCODE_URL: &str = "https://geocoding-api.open-meteo.com/v1/search";
+const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
+
+/// The fields asked for, per block. Each name is also a key of the response
+/// struct below, so the two lists move together.
+const CURRENT: &str = "temperature_2m,apparent_temperature,relative_humidity_2m,is_day,\
+ weather_code,wind_speed_10m,wind_direction_10m,precipitation";
+const HOURLY: &str = "temperature_2m,precipitation_probability,weather_code,is_day";
+const DAILY: &str = "weather_code,temperature_2m_max,temperature_2m_min,\
+ precipitation_probability_max,sunrise,sunset";
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Forecast {
+ /// Local times are in this zone (`timezone=auto`); every time string in
+ /// the response is local wall-clock time without an offset.
+ pub timezone: String,
+ pub utc_offset_seconds: i32,
+ pub current: Current,
+ pub hourly: Hourly,
+ pub daily: Daily,
+ /// Units the request asked for, so a cached forecast is never shown
+ /// under the other unit's suffix.
+ #[serde(default)]
+ pub units: Units,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Current {
+ pub time: String,
+ pub temperature_2m: f32,
+ pub apparent_temperature: f32,
+ pub relative_humidity_2m: f32,
+ pub is_day: u8,
+ pub weather_code: u8,
+ pub wind_speed_10m: f32,
+ pub wind_direction_10m: f32,
+ pub precipitation: f32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Hourly {
+ pub time: Vec<String>,
+ pub temperature_2m: Vec<Option<f32>>,
+ pub precipitation_probability: Vec<Option<f32>>,
+ pub weather_code: Vec<Option<u8>>,
+ pub is_day: Vec<Option<u8>>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Daily {
+ pub time: Vec<String>,
+ pub weather_code: Vec<Option<u8>>,
+ pub temperature_2m_max: Vec<Option<f32>>,
+ pub temperature_2m_min: Vec<Option<f32>>,
+ pub precipitation_probability_max: Vec<Option<f32>>,
+ pub sunrise: Vec<String>,
+ pub sunset: Vec<String>,
+}
+
+fn client() -> Result<reqwest::blocking::Client, String> {
+ reqwest::blocking::Client::builder()
+ .timeout(TIMEOUT)
+ .user_agent(concat!("cce-weather/", env!("CARGO_PKG_VERSION")))
+ .build()
+ .map_err(|e| e.to_string())
+}
+
+pub fn fetch_forecast(loc: &Location, units: Units) -> Result<Forecast, String> {
+ let lat = format!("{:.4}", loc.latitude);
+ let lon = format!("{:.4}", loc.longitude);
+ let mut query: Vec<(&str, &str)> = vec![
+ ("latitude", &lat),
+ ("longitude", &lon),
+ ("current", CURRENT),
+ ("hourly", HOURLY),
+ ("daily", DAILY),
+ ("timezone", "auto"),
+ ("forecast_days", "7"),
+ ];
+ if units == Units::Imperial {
+ query.extend([
+ ("temperature_unit", "fahrenheit"),
+ ("wind_speed_unit", "mph"),
+ ("precipitation_unit", "inch"),
+ ]);
+ }
+ let resp = client()?
+ .get(FORECAST_URL)
+ .query(&query)
+ .send()
+ .map_err(|e| format!("network: {e}"))?;
+ if !resp.status().is_success() {
+ return Err(format!("forecast: HTTP {}", resp.status()));
+ }
+ let mut f: Forecast = resp.json().map_err(|e| format!("forecast: {e}"))?;
+ f.units = units;
+ Ok(f)
+}
+
+#[derive(Deserialize)]
+struct GeoResponse {
+ #[serde(default)]
+ results: Vec<GeoResult>,
+}
+
+#[derive(Deserialize)]
+struct GeoResult {
+ name: String,
+ latitude: f64,
+ longitude: f64,
+ #[serde(default)]
+ admin1: Option<String>,
+ #[serde(default)]
+ country: Option<String>,
+}
+
+/// Up to `count` places matching a free-text name, best match first.
+pub fn geocode(query: &str, count: usize) -> Result<Vec<Location>, String> {
+ let count = count.to_string();
+ let resp = client()?
+ .get(GEOCODE_URL)
+ .query(&[("name", query), ("count", &count), ("format", "json")])
+ .send()
+ .map_err(|e| format!("network: {e}"))?;
+ if !resp.status().is_success() {
+ return Err(format!("search: HTTP {}", resp.status()));
+ }
+ let geo: GeoResponse = resp.json().map_err(|e| format!("search: {e}"))?;
+ Ok(geo
+ .results
+ .into_iter()
+ .map(|r| Location {
+ name: r.name,
+ region: [r.admin1, r.country].into_iter().flatten().collect::<Vec<_>>().join(", "),
+ latitude: r.latitude,
+ longitude: r.longitude,
+ })
+ .collect())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A trimmed real response: the shape serde must accept, including the
+ /// nulls Open-Meteo sends past the end of a model's horizon.
+ const SAMPLE: &str = r#"{
+ "latitude": 40.71, "longitude": -74.0, "timezone": "America/New_York",
+ "utc_offset_seconds": -14400,
+ "current": {"time": "2026-10-05T14:00", "interval": 900, "temperature_2m": 18.4,
+ "apparent_temperature": 17.1, "relative_humidity_2m": 61, "is_day": 1,
+ "weather_code": 2, "wind_speed_10m": 11.2, "wind_direction_10m": 225,
+ "precipitation": 0.0},
+ "hourly": {"time": ["2026-10-05T00:00", "2026-10-05T01:00"],
+ "temperature_2m": [15.0, null], "precipitation_probability": [10, null],
+ "weather_code": [3, null], "is_day": [0, 0]},
+ "daily": {"time": ["2026-10-05"], "weather_code": [61],
+ "temperature_2m_max": [19.2], "temperature_2m_min": [12.0],
+ "precipitation_probability_max": [70],
+ "sunrise": ["2026-10-05T07:01"], "sunset": ["2026-10-05T18:37"]}
+ }"#;
+
+ #[test]
+ fn parses_forecast_with_nulls() {
+ let f: Forecast = serde_json::from_str(SAMPLE).unwrap();
+ assert_eq!(f.current.weather_code, 2);
+ assert_eq!(f.hourly.temperature_2m[1], None);
+ assert_eq!(f.daily.precipitation_probability_max[0], Some(70.0));
+ assert_eq!(f.units, Units::Metric);
+ }
+
+ /// Against the real service: `cargo test -p cce-weather -- --ignored`.
+ #[test]
+ #[ignore]
+ fn live_geocode_and_forecast() {
+ let places = geocode("Brooklyn", 3).unwrap();
+ assert!(!places.is_empty());
+ let f = fetch_forecast(&places[0], Units::Imperial).unwrap();
+ assert!(f.hourly.time.len() >= 24 * 7);
+ assert_eq!(f.daily.time.len(), 7);
+ assert_eq!(f.units, Units::Imperial);
+ }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..8e7e868
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,210 @@
+//! Where the app's settings come from, and what it remembers.
+//!
+//! - `~/.config/cce/cce-weather/config.kdl` is the user's declared default:
+//! `units "imperial"` / `units "metric"`, `location "Name" lat=… lon=…`
+//! (optional `region="…"`), `refresh_minutes 15`.
+//! - `<state>/cce/weather/state.json` is what the app itself changed — a
+//! searched-for place, the unit toggle — plus the last forecast, so a cold
+//! start shows something before the network answers. Its choices win over
+//! the config's, because they are the more recent decision.
+
+use serde::{Deserialize, Serialize};
+
+use crate::api::Forecast;
+
+const APP: &str = "cce-weather";
+pub const DEFAULT_REFRESH_MINUTES: u64 = 15;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Units {
+ #[default]
+ Metric,
+ Imperial,
+}
+
+impl Units {
+ pub fn temp_suffix(self) -> &'static str {
+ match self {
+ Units::Metric => "°C",
+ Units::Imperial => "°F",
+ }
+ }
+
+ pub fn wind_suffix(self) -> &'static str {
+ match self {
+ Units::Metric => "km/h",
+ Units::Imperial => "mph",
+ }
+ }
+
+ pub fn toggled(self) -> Self {
+ match self {
+ Units::Metric => Units::Imperial,
+ Units::Imperial => Units::Metric,
+ }
+ }
+
+ fn parse(s: &str) -> Option<Self> {
+ match s.to_ascii_lowercase().as_str() {
+ "metric" | "c" | "celsius" => Some(Units::Metric),
+ "imperial" | "f" | "fahrenheit" => Some(Units::Imperial),
+ _ => None,
+ }
+ }
+
+ /// No config, no state: follow the measurement locale. Only the US (and
+ /// a couple of others) read Fahrenheit.
+ fn from_locale() -> Self {
+ let loc = ["LC_ALL", "LC_MEASUREMENT", "LANG"]
+ .iter()
+ .filter_map(|k| std::env::var(k).ok())
+ .find(|v| !v.is_empty())
+ .unwrap_or_default();
+ let region = loc.split(['.', '@']).next().unwrap_or("").rsplit('_').next().unwrap_or("");
+ match region {
+ "US" | "LR" | "MM" => Units::Imperial,
+ _ => Units::Metric,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Location {
+ pub name: String,
+ /// "State, Country" as the geocoder gives it; may be empty.
+ #[serde(default)]
+ pub region: String,
+ pub latitude: f64,
+ pub longitude: f64,
+}
+
+/// Only choices made IN the app are recorded here (a search, the toggle), so
+/// an untouched setting keeps following the config when that is edited.
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct State {
+ #[serde(default)]
+ pub location: Option<Location>,
+ #[serde(default)]
+ pub units: Option<Units>,
+ #[serde(default)]
+ pub forecast: Option<Forecast>,
+ /// The place `forecast` is for.
+ #[serde(default)]
+ pub forecast_location: Option<Location>,
+ /// Unix seconds when `forecast` was fetched.
+ #[serde(default)]
+ pub fetched_at: Option<i64>,
+}
+
+pub struct Config {
+ pub location: Option<Location>,
+ pub units: Option<Units>,
+ pub refresh_minutes: u64,
+}
+
+pub fn load_config() -> Config {
+ let path = cce_ui::config::get_app_config_path(APP);
+ let text = std::fs::read_to_string(&path).unwrap_or_default();
+ parse_config(&text).unwrap_or_else(|e| {
+ log::warn!("{}: {e}", path.display());
+ parse_config("").unwrap()
+ })
+}
+
+fn parse_config(text: &str) -> Result<Config, String> {
+ let doc: kdl::KdlDocument = text.parse().map_err(|e: kdl::KdlError| e.to_string())?;
+ let units = doc
+ .get("units")
+ .and_then(|n| n.entries().first())
+ .and_then(|e| e.value().as_string())
+ .and_then(Units::parse);
+ let location = doc.get("location").and_then(|n| {
+ let num = |k: &str| {
+ n.get(k).and_then(|e| e.value().as_f64().or_else(|| e.value().as_i64().map(|i| i as f64)))
+ };
+ let name = n.entries().iter().find(|e| e.name().is_none())?.value().as_string()?;
+ Some(Location {
+ name: name.to_string(),
+ region: n.get("region").and_then(|e| e.value().as_string()).unwrap_or("").to_string(),
+ latitude: num("lat")?,
+ longitude: num("lon")?,
+ })
+ });
+ let refresh_minutes = doc
+ .get("refresh_minutes")
+ .and_then(|n| n.entries().first())
+ .and_then(|e| e.value().as_i64())
+ .map(|m| m.clamp(5, 24 * 60) as u64)
+ .unwrap_or(DEFAULT_REFRESH_MINUTES);
+ Ok(Config { location, units, refresh_minutes })
+}
+
+fn state_path() -> std::path::PathBuf {
+ cce_ui::config::cce_state_dir().join("weather").join("state.json")
+}
+
+pub fn load_state() -> State {
+ std::fs::read_to_string(state_path())
+ .ok()
+ .and_then(|t| serde_json::from_str(&t).ok())
+ .unwrap_or_default()
+}
+
+/// Written whole through a temp file and a rename, so a crash mid-write
+/// leaves the previous state rather than half a JSON document.
+pub fn save_state(state: &State) {
+ let path = state_path();
+ let result = (|| -> std::io::Result<()> {
+ std::fs::create_dir_all(path.parent().unwrap())?;
+ let tmp = path.with_extension("json.tmp");
+ std::fs::write(&tmp, serde_json::to_vec(state)?)?;
+ std::fs::rename(&tmp, &path)
+ })();
+ if let Err(e) = result {
+ log::warn!("saving {}: {e}", path.display());
+ }
+}
+
+/// The units in force: the app's own toggle, else the config, else the locale.
+pub fn resolve_units(state: &State, config: &Config) -> Units {
+ state.units.or(config.units).unwrap_or_else(Units::from_locale)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_full_config() {
+ let c = parse_config(
+ "units \"imperial\"\nlocation \"Brooklyn\" lat=40.65 lon=-73.95 region=\"New York, United States\"\nrefresh_minutes 30\n",
+ )
+ .unwrap();
+ assert_eq!(c.units, Some(Units::Imperial));
+ let l = c.location.unwrap();
+ assert_eq!(l.name, "Brooklyn");
+ assert_eq!(l.region, "New York, United States");
+ assert!((l.longitude + 73.95).abs() < 1e-9);
+ assert_eq!(c.refresh_minutes, 30);
+ }
+
+ #[test]
+ fn empty_config_is_all_defaults() {
+ let c = parse_config("").unwrap();
+ assert!(c.location.is_none() && c.units.is_none());
+ assert_eq!(c.refresh_minutes, DEFAULT_REFRESH_MINUTES);
+ }
+
+ #[test]
+ fn location_needs_both_coordinates() {
+ let c = parse_config("location \"Nowhere\" lat=1.0\n").unwrap();
+ assert!(c.location.is_none());
+ }
+
+ #[test]
+ fn integer_coordinates_are_accepted() {
+ let c = parse_config("location \"Null Island\" lat=0 lon=0\n").unwrap();
+ assert_eq!(c.location.unwrap().latitude, 0.0);
+ }
+}
diff --git a/src/glyph.rs b/src/glyph.rs
new file mode 100644
index 0000000..1210ed5
--- /dev/null
+++ b/src/glyph.rs
@@ -0,0 +1,179 @@
+//! WMO weather codes: their wording, and a glyph for each drawn from prims
+//! (circles, arcs, strokes) so the app ships no artwork and every glyph
+//! scales with the size it is asked for.
+
+use std::f32::consts::{PI, TAU};
+
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{Cap, PaintCtx};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Sky {
+ Clear,
+ PartlyCloudy,
+ Cloudy,
+ Fog,
+ Drizzle,
+ Rain,
+ Snow,
+ Thunder,
+}
+
+/// WMO 4677 code as Open-Meteo reports it → (sky, words).
+pub fn describe(code: u8) -> (Sky, &'static str) {
+ match code {
+ 0 => (Sky::Clear, "Clear"),
+ 1 => (Sky::Clear, "Mainly clear"),
+ 2 => (Sky::PartlyCloudy, "Partly cloudy"),
+ 3 => (Sky::Cloudy, "Overcast"),
+ 45 => (Sky::Fog, "Fog"),
+ 48 => (Sky::Fog, "Freezing fog"),
+ 51 => (Sky::Drizzle, "Light drizzle"),
+ 53 => (Sky::Drizzle, "Drizzle"),
+ 55 => (Sky::Drizzle, "Heavy drizzle"),
+ 56 | 57 => (Sky::Drizzle, "Freezing drizzle"),
+ 61 => (Sky::Rain, "Light rain"),
+ 63 => (Sky::Rain, "Rain"),
+ 65 => (Sky::Rain, "Heavy rain"),
+ 66 | 67 => (Sky::Rain, "Freezing rain"),
+ 71 => (Sky::Snow, "Light snow"),
+ 73 => (Sky::Snow, "Snow"),
+ 75 => (Sky::Snow, "Heavy snow"),
+ 77 => (Sky::Snow, "Snow grains"),
+ 80 => (Sky::Rain, "Light showers"),
+ 81 => (Sky::Rain, "Showers"),
+ 82 => (Sky::Rain, "Violent showers"),
+ 85 | 86 => (Sky::Snow, "Snow showers"),
+ 95 => (Sky::Thunder, "Thunderstorm"),
+ 96 | 99 => (Sky::Thunder, "Thunderstorm, hail"),
+ _ => (Sky::Cloudy, "Unknown"),
+ }
+}
+
+// sRGB, as a designer reads them; prim colours are linear, so every use goes
+// through `lin`.
+const SUN: [f32; 4] = [1.0, 0.72, 0.18, 1.0];
+const MOON: [f32; 4] = [0.86, 0.88, 0.95, 1.0];
+const CLOUD: [f32; 4] = [0.80, 0.83, 0.88, 1.0];
+const CLOUD_DARK: [f32; 4] = [0.52, 0.55, 0.62, 1.0];
+const RAIN: [f32; 4] = [0.36, 0.62, 1.0, 1.0];
+const SNOW: [f32; 4] = [0.94, 0.96, 1.0, 1.0];
+const BOLT: [f32; 4] = [1.0, 0.84, 0.25, 1.0];
+
+fn lin(c: [f32; 4]) -> [f32; 4] {
+ cce_ui::color::to_linear(c)
+}
+
+/// Draw the glyph for `code` filling the square `rect`. `day` picks sun or
+/// moon for the clear and partly-cloudy skies.
+pub fn draw(pc: &mut PaintCtx, rect: Rect, code: u8, day: bool) {
+ let s = rect.width.min(rect.height);
+ let (cx, cy) = (rect.x + rect.width / 2.0, rect.y + rect.height / 2.0);
+ let (sky, _) = describe(code);
+ match sky {
+ Sky::Clear => orb(pc, cx, cy, s * 0.5, day),
+ Sky::PartlyCloudy => {
+ orb(pc, cx + s * 0.14, cy - s * 0.14, s * 0.36, day);
+ cloud(pc, cx - s * 0.06, cy + s * 0.10, s * 0.62, CLOUD);
+ }
+ Sky::Cloudy => {
+ cloud(pc, cx + s * 0.12, cy - s * 0.10, s * 0.5, CLOUD_DARK);
+ cloud(pc, cx - s * 0.05, cy + s * 0.06, s * 0.72, CLOUD);
+ }
+ Sky::Fog => {
+ cloud(pc, cx, cy - s * 0.12, s * 0.62, CLOUD_DARK);
+ let t = (s * 0.06).max(1.5);
+ for (i, w) in [0.62f32, 0.48, 0.56].iter().enumerate() {
+ let y = cy + s * (0.14 + 0.12 * i as f32);
+ let off = if i % 2 == 0 { -s * 0.04 } else { s * 0.05 };
+ pc.vector(cx - s * w / 2.0 + off, y, cx + s * w / 2.0 + off, y, t, lin(CLOUD), Cap::Round);
+ }
+ }
+ Sky::Drizzle | Sky::Rain => {
+ cloud(pc, cx, cy - s * 0.12, s * 0.72, CLOUD);
+ let heavy = sky == Sky::Rain;
+ let t = (s * if heavy { 0.065 } else { 0.05 }).max(1.5);
+ let len = s * if heavy { 0.18 } else { 0.1 };
+ for i in 0..3 {
+ let x = cx + s * (-0.18 + 0.18 * i as f32);
+ let y = cy + s * 0.18 + if i == 1 { s * 0.06 } else { 0.0 };
+ pc.vector(x, y, x - len * 0.4, y + len, t, lin(RAIN), Cap::Round);
+ }
+ }
+ Sky::Snow => {
+ cloud(pc, cx, cy - s * 0.12, s * 0.72, CLOUD);
+ for i in 0..3 {
+ let x = cx + s * (-0.18 + 0.18 * i as f32);
+ let y = cy + s * 0.26 + if i == 1 { s * 0.07 } else { 0.0 };
+ flake(pc, x, y, s * 0.075);
+ }
+ }
+ Sky::Thunder => {
+ cloud(pc, cx, cy - s * 0.14, s * 0.72, CLOUD_DARK);
+ let t = (s * 0.06).max(1.5);
+ let pts = [
+ (cx + s * 0.04, cy + s * 0.06),
+ (cx - s * 0.08, cy + s * 0.24),
+ (cx + s * 0.04, cy + s * 0.24),
+ (cx - s * 0.06, cy + s * 0.44),
+ ];
+ for w in pts.windows(2) {
+ pc.vector(w[0].0, w[0].1, w[1].0, w[1].1, t, lin(BOLT), Cap::Round);
+ }
+ }
+ }
+}
+
+/// The sun (disc and rays) or the moon (a crescent) inside a box `d` wide.
+fn orb(pc: &mut PaintCtx, cx: f32, cy: f32, d: f32, day: bool) {
+ if day {
+ let r = d * 0.26;
+ pc.circle(cx, cy, r, lin(SUN));
+ let t = (d * 0.06).max(1.2);
+ for i in 0..8 {
+ let a = i as f32 * TAU / 8.0;
+ let (sn, cs) = a.sin_cos();
+ let (r0, r1) = (r + d * 0.08, d * 0.48);
+ pc.vector(cx + cs * r0, cy + sn * r0, cx + cs * r1, cy + sn * r1, t, lin(SUN), Cap::Round);
+ }
+ } else {
+ // A thick arc reads as a crescent and needs no background colour to
+ // cut the inner disc away with.
+ let r = d * 0.36;
+ pc.arc(cx, cy, r, r * 0.55, PI * 0.35, PI * 1.55, lin(MOON));
+ }
+}
+
+/// A cloud `w` wide centred on (cx, cy): three puffs on a rounded base.
+fn cloud(pc: &mut PaintCtx, cx: f32, cy: f32, w: f32, color: [f32; 4]) {
+ let color = lin(color);
+ let h = w * 0.3;
+ let base = Rect { x: cx - w / 2.0, y: cy - h / 2.0 + w * 0.06, width: w, height: h };
+ pc.rounded_rect(base, h / 2.0, (true, true, true, true), color);
+ pc.circle(cx - w * 0.18, cy, w * 0.2, color);
+ pc.circle(cx + w * 0.08, cy - w * 0.08, w * 0.26, color);
+ pc.circle(cx + w * 0.3, cy + w * 0.04, w * 0.15, color);
+}
+
+/// A six-armed snowflake of radius `r`.
+fn flake(pc: &mut PaintCtx, cx: f32, cy: f32, r: f32) {
+ let t = (r * 0.35).max(1.2);
+ for i in 0..3 {
+ let a = i as f32 * PI / 3.0 + PI / 2.0;
+ let (sn, cs) = a.sin_cos();
+ pc.vector(cx - cs * r, cy - sn * r, cx + cs * r, cy + sn * r, t, lin(SNOW), Cap::Round);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_documented_code_has_words() {
+ for code in [0, 1, 2, 3, 45, 48, 51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 71, 73, 75, 77, 80, 81, 82, 85, 86, 95, 96, 99] {
+ assert_ne!(describe(code).1, "Unknown", "code {code}");
+ }
+ assert_eq!(describe(42).1, "Unknown");
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..4b26ea9
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,949 @@
+//! cce-weather — current conditions, the next 24 hours and the week ahead,
+//! from Open-Meteo (no account, no key).
+//!
+//! The window is a root plate with a control row on it (place search, unit
+//! toggle, refresh) and three pane plates below: now, the hourly chart (a
+//! canvas well), and the daily rows. A search with several matches swaps
+//! the panes for a list of them. Network work runs on worker threads and
+//! comes back through the loop's `Sender`; a timer thread asks for a refresh
+//! by wall clock, so a forecast is not left stale across a suspend.
+
+mod api;
+mod config;
+mod glyph;
+
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+
+use chrono::{Datelike, NaiveDate, NaiveDateTime, Timelike};
+use wayland_client::QueueHandle;
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::layout::{
+ align_text_y, bevel_width, control_gap, list_font_parsed, plate_corner_radius,
+ plate_padding,
+};
+use cce_ui::scene::arena::Arena;
+use cce_ui::scene::layout::{compute_layout, LayoutBox, Length, Rect, Size as LSize, Style};
+use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
+use cce_ui::scene::Material;
+use cce_ui::widget::{
+ Adapted, Button, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey,
+ TextBox, WidgetHost, WidgetId,
+};
+
+use api::Forecast;
+use config::{Location, State, Units};
+
+/// How many geocoder matches the picker offers.
+const MAX_RESULTS: usize = 5;
+const CONTROL_H: f32 = 30.0;
+/// Fixed pane heights; the daily pane takes what is left.
+const NOW_H: f32 = 156.0;
+const HOURLY_H: f32 = 172.0;
+const HOURS_SHOWN: usize = 24;
+/// The hourly chart labels every this many hours.
+const LABEL_EVERY: usize = 3;
+
+const BIG_SIZE: f32 = 46.0;
+const HEAD_SIZE: f32 = 15.0;
+
+const PRECIP: [u8; 3] = [110, 170, 255];
+// Prim colours below are sRGB and go through `lin` where drawn; text takes
+// sRGB bytes directly.
+const PRECIP_BAR: [f32; 4] = [0.36, 0.62, 1.0, 0.45];
+const CURVE: [f32; 4] = [1.0, 0.68, 0.30, 1.0];
+const COLD: [f32; 3] = [0.40, 0.66, 1.0];
+const WARM: [f32; 3] = [1.0, 0.60, 0.26];
+const TRACK: [f32; 4] = [1.0, 1.0, 1.0, 0.08];
+
+#[derive(Debug, Clone)]
+enum Message {
+ Forecast { generation: u64, result: Result<Forecast, String> },
+ Geocoded { query: String, result: Result<Vec<Location>, String> },
+ /// The timer thread: a refresh is due by wall clock.
+ RefreshDue,
+ Exit,
+}
+
+fn shaped_width(text: &str, size: f32, font: &str) -> f32 {
+ let mut fs = cce_ui::geometry_font_system().lock().unwrap_or_else(|e| e.into_inner());
+ cce_ui::backend::window_runner::shaped_cluster_offsets(&mut fs, text, size, Some(font))
+ .last()
+ .map_or(0.0, |&(_, x)| x)
+}
+
+fn to_u8(c: [f32; 4]) -> [u8; 3] {
+ [(c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8]
+}
+
+fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
+ to_u8(cce_ui::colors::to_srgb(linear))
+}
+
+fn lin(c: [f32; 4]) -> [f32; 4] {
+ cce_ui::color::to_linear(c)
+}
+
+fn now_unix() -> i64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |d| d.as_secs() as i64)
+}
+
+fn parse_local(t: &str) -> Option<NaiveDateTime> {
+ NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M").ok()
+}
+
+fn compass(deg: f32) -> &'static str {
+ const POINTS: [&str; 8] = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
+ POINTS[(((deg.rem_euclid(360.0) + 22.5) / 45.0) as usize) % 8]
+}
+
+/// Hour label in the unit system's habit: 24-hour for metric, "3pm" else.
+fn hour_label(t: &NaiveDateTime, units: Units) -> String {
+ match units {
+ Units::Metric => format!("{:02}", t.hour()),
+ Units::Imperial => {
+ let h = t.hour() % 12;
+ format!("{}{}", if h == 0 { 12 } else { h }, if t.hour() < 12 { "am" } else { "pm" })
+ }
+ }
+}
+
+fn clock(t: &NaiveDateTime, units: Units) -> String {
+ match units {
+ Units::Metric => t.format("%H:%M").to_string(),
+ Units::Imperial => t.format("%-I:%M %P").to_string(),
+ }
+}
+
+fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 4] {
+ let t = t.clamp(0.0, 1.0);
+ [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, 1.0]
+}
+
+/// The index of the hour containing `now` in an hourly series: the first
+/// slot whose hour is not before it (ISO strings compare as times).
+fn hour_index(times: &[String], now: &str) -> usize {
+ let key = &now[..now.len().min(13)];
+ times.iter().position(|t| &t[..t.len().min(13)] >= key).unwrap_or(0)
+}
+
+/// Spawn the timer: every minute it checks the wall clock against the last
+/// fetch, and says so once a refresh is due. Wall clock, not a sleep for the
+/// whole interval, because a monotonic sleep does not count a suspend.
+fn spawn_timer(sender: calloop::channel::Sender<Message>, last_fetch: Arc<AtomicU64>, every_min: u64) {
+ std::thread::Builder::new()
+ .name("weather-timer".into())
+ .spawn(move || loop {
+ std::thread::sleep(std::time::Duration::from_secs(60));
+ let last = last_fetch.load(Ordering::Relaxed) as i64;
+ if now_unix() - last >= (every_min * 60) as i64 && sender.send(Message::RefreshDue).is_err() {
+ return;
+ }
+ })
+ .expect("spawning the refresh timer");
+}
+
+struct WeatherApp {
+ // Widgets: plain fields so their addresses are stable (the UiContext
+ // registry holds pointers to them).
+ search: Adapted<TextBox>,
+ units_btn: Adapted<Button>,
+ refresh_btn: Adapted<Button>,
+ results_btns: [Adapted<Button>; MAX_RESULTS],
+
+ // App state — the source of truth; widgets are re-asserted from it.
+ location: Option<Location>,
+ units: Units,
+ /// What the user picked in this app (a search, the toggle) — the only
+ /// parts of `location`/`units` that are saved; see `config::State`.
+ chosen_location: Option<Location>,
+ chosen_units: Option<Units>,
+ forecast: Option<Forecast>,
+ fetched_at: Option<i64>,
+ /// Bumped whenever the place or units change, so a reply for the old
+ /// request is dropped on arrival.
+ generation: u64,
+ loading: bool,
+ searching: bool,
+ results: Vec<Location>,
+ status: Option<String>,
+ last_fetch: Arc<AtomicU64>,
+ sender: calloop::channel::Sender<Message>,
+
+ ui_context: cce_ui::context::UiContext,
+ widgets_registered: bool,
+ needs_rebuild: bool,
+ size: (f32, f32),
+ scale: f64,
+ // Solved geometry, kept between rebuilds for painting.
+ now_rect: Rect,
+ hourly_rect: Rect,
+ daily_rect: Rect,
+ results_rect: Rect,
+}
+
+impl WeatherApp {
+ fn root_ids(&self) -> Vec<WidgetId> {
+ let mut ids = vec![self.search.id(), self.units_btn.id(), self.refresh_btn.id()];
+ ids.extend(self.results_btns.iter().map(|b| b.id()));
+ ids
+ }
+
+ fn roots(&mut self) -> Vec<*mut (dyn WidgetHost + 'static)> {
+ let mut v: Vec<*mut (dyn WidgetHost + 'static)> =
+ vec![self.search.as_ptr_mut(), self.units_btn.as_ptr_mut(), self.refresh_btn.as_ptr_mut()];
+ v.extend(self.results_btns.iter_mut().map(|b| b.as_ptr_mut()));
+ v
+ }
+
+ fn picking(&self) -> bool {
+ !self.results.is_empty()
+ }
+
+ fn save(&self) {
+ config::save_state(&State {
+ location: self.chosen_location.clone(),
+ units: self.chosen_units,
+ forecast: self.forecast.clone(),
+ forecast_location: self.forecast.as_ref().and(self.location.clone()),
+ fetched_at: self.fetched_at,
+ });
+ }
+
+ fn refresh(&mut self) {
+ let Some(loc) = self.location.clone() else { return };
+ self.loading = true;
+ self.needs_rebuild = true;
+ let (generation, units, sender) = (self.generation, self.units, self.sender.clone());
+ std::thread::spawn(move || {
+ let result = api::fetch_forecast(&loc, units);
+ let _ = sender.send(Message::Forecast { generation, result });
+ });
+ }
+
+ fn set_location(&mut self, loc: Location) {
+ self.chosen_location = Some(loc.clone());
+ self.location = Some(loc);
+ self.forecast = None;
+ self.fetched_at = None;
+ self.generation += 1;
+ self.results.clear();
+ self.status = None;
+ self.save();
+ self.refresh();
+ }
+
+ fn toggle_units(&mut self) {
+ self.units = self.units.toggled();
+ self.chosen_units = Some(self.units);
+ self.generation += 1;
+ self.save();
+ self.refresh();
+ }
+
+ fn search_value(&self) -> String {
+ let raw = if self.search.editing { &self.search.edit_buffer } else { &self.search.text };
+ raw.trim().to_string()
+ }
+
+ fn clear_search(&mut self) {
+ self.search.text.clear();
+ self.search.edit_buffer.clear();
+ self.search.cursor_idx = 0;
+ }
+
+ fn submit_search(&mut self) {
+ let query = self.search_value();
+ if query.is_empty() {
+ return;
+ }
+ self.searching = true;
+ self.status = None;
+ self.needs_rebuild = true;
+ let sender = self.sender.clone();
+ std::thread::spawn(move || {
+ let result = api::geocode(&query, MAX_RESULTS);
+ let _ = sender.send(Message::Geocoded { query, result });
+ });
+ }
+
+ fn drain_widget_changes(&mut self) {
+ if self.units_btn.take_click() {
+ self.toggle_units();
+ }
+ if self.refresh_btn.take_click() {
+ self.refresh();
+ }
+ for i in 0..MAX_RESULTS {
+ if self.results_btns[i].take_click() {
+ if let Some(loc) = self.results.get(i).cloned() {
+ self.clear_search();
+ self.search.unfocus();
+ self.set_location(loc);
+ }
+ }
+ }
+ if self.search.take_change() {
+ self.needs_rebuild = true;
+ }
+ }
+
+ fn route(&mut self, ev: &Event, short_circuit: bool) -> bool {
+ let mut changed = false;
+ for id in self.root_ids() {
+ if self.ui_context.propagate_event(ev, id) {
+ changed = true;
+ if short_circuit {
+ break;
+ }
+ }
+ }
+ self.drain_widget_changes();
+ changed
+ }
+
+ fn layout(&mut self) {
+ let (w, h) = self.size;
+ let mut arena: Arena<LayoutBox> = Arena::new();
+ let root = arena.insert(LayoutBox::container(Style::root_column()));
+ let controls = arena.insert(LayoutBox::container(
+ Style::controls_row().height(Length::Fixed(CONTROL_H)),
+ ));
+ let search = arena.insert(LayoutBox::leaf(Style::row().grow(1.0).shrink(1.0), LSize::new(0.0, CONTROL_H)));
+ let units = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(52.0, CONTROL_H)));
+ let refresh = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(CONTROL_H, CONTROL_H)));
+ let now = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, NOW_H)));
+ let hourly = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, HOURLY_H)));
+ let daily = arena.insert(LayoutBox::container(Style::column().grow(1.0)));
+ arena.append_child(root, controls);
+ arena.append_child(controls, search);
+ arena.append_child(controls, units);
+ arena.append_child(controls, refresh);
+ arena.append_child(root, now);
+ arena.append_child(root, hourly);
+ arena.append_child(root, daily);
+ compute_layout(&mut arena, root, LSize::new(w, h));
+
+ let r = |id| arena.value(id).unwrap().rect;
+ let s = r(search);
+ self.search.set_rect(s.x, s.y, s.width, s.height);
+ let u = r(units);
+ self.units_btn.set_rect(u.x, u.y, u.width, u.height);
+ let f = r(refresh);
+ self.refresh_btn.set_rect(f.x, f.y, f.width, f.height);
+ self.now_rect = r(now);
+ self.hourly_rect = r(hourly);
+ self.daily_rect = r(daily);
+
+ // The picker takes the whole area under the control row.
+ let top = self.now_rect.y;
+ let bottom = self.daily_rect.y + self.daily_rect.height;
+ self.results_rect = Rect { x: self.now_rect.x, y: top, width: self.now_rect.width, height: bottom - top };
+ let pad = plate_padding();
+ let row_h = cce_ui::layout::button_height().max(CONTROL_H);
+ let picking = self.picking();
+ for (i, b) in self.results_btns.iter_mut().enumerate() {
+ match self.results.get(i) {
+ Some(loc) if picking => {
+ let label = if loc.region.is_empty() {
+ loc.name.clone()
+ } else {
+ format!("{} — {}", loc.name, loc.region)
+ };
+ b.set_label(&label);
+ let y = top + pad + HEAD_SIZE * 1.6 + i as f32 * (row_h + control_gap());
+ b.set_rect(self.results_rect.x + pad, y, self.results_rect.width - 2.0 * pad, row_h);
+ }
+ // Off the window, so a hidden row takes no clicks.
+ _ => b.set_rect(-1000.0, -1000.0, 0.0, 0.0),
+ }
+ }
+ }
+
+ fn pane(&self, pc: &mut PaintCtx, rect: Rect) {
+ let r = plate_corner_radius();
+ pc.plate(rect, (r, r, r, r), &Material::pane(), bevel_width().min(rect.height * 0.2));
+ }
+
+ fn paint_now(&self, pc: &mut PaintCtx, font: &str, size: f32, fg: [u8; 3], dim: [u8; 3]) {
+ let rect = self.now_rect;
+ self.pane(pc, rect);
+ let pad = plate_padding();
+ let inner = Rect {
+ x: rect.x + pad,
+ y: rect.y + pad,
+ width: rect.width - 2.0 * pad,
+ height: rect.height - 2.0 * pad,
+ };
+ let text = |pc: &mut PaintCtx, s: String, x: f32, y: f32, sz: f32, c: [u8; 3]| {
+ pc.text_with(s, x, y, sz, c, Some(font.to_string()), Some([inner.x, inner.y, inner.x + inner.width, inner.y + inner.height]));
+ };
+
+ let Some(loc) = &self.location else {
+ text(pc, "No place yet".into(), inner.x, inner.y, HEAD_SIZE, fg);
+ text(pc, "Type a city in the box above and press Enter.".into(), inner.x, inner.y + HEAD_SIZE * 1.6, size, dim);
+ return;
+ };
+
+ // The place, right-aligned along the top.
+ let place = loc.name.clone();
+ let pw = shaped_width(&place, HEAD_SIZE, font);
+ text(pc, place, inner.x + inner.width - pw, inner.y, HEAD_SIZE, fg);
+ if !loc.region.is_empty() {
+ let rw = shaped_width(&loc.region, size, font);
+ text(pc, loc.region.clone(), inner.x + inner.width - rw, inner.y + HEAD_SIZE * 1.4, size, dim);
+ }
+
+ let Some(f) = &self.forecast else {
+ let msg = if self.loading { "Loading…" } else { "No forecast" };
+ text(pc, msg.into(), inner.x, inner.y, HEAD_SIZE, dim);
+ return;
+ };
+ let c = &f.current;
+ let gs = inner.height.min(96.0);
+ glyph::draw(pc, Rect { x: inner.x, y: inner.y + (inner.height - gs) / 2.0, width: gs, height: gs }, c.weather_code, c.is_day != 0);
+
+ let x = inner.x + gs + pad;
+ let temp = format!("{:.0}°", c.temperature_2m);
+ text(pc, temp, x, inner.y, BIG_SIZE, fg);
+ let (_, words) = glyph::describe(c.weather_code);
+ let line = size * 1.45;
+ let mut y = inner.y + BIG_SIZE * 1.25;
+ text(pc, words.to_string(), x, y, HEAD_SIZE, fg);
+ y += HEAD_SIZE * 1.45;
+ text(pc, format!("Feels like {:.0}° · Humidity {:.0}%", c.apparent_temperature, c.relative_humidity_2m), x, y, size, dim);
+ y += line;
+ let mut detail = format!("Wind {:.0} {} {}", c.wind_speed_10m, f.units.wind_suffix(), compass(c.wind_direction_10m));
+ if let (Some(hi), Some(lo)) = (
+ f.daily.temperature_2m_max.first().copied().flatten(),
+ f.daily.temperature_2m_min.first().copied().flatten(),
+ ) {
+ detail.push_str(&format!(" · H {hi:.0}° L {lo:.0}°"));
+ }
+ text(pc, detail, x, y, size, dim);
+
+ // Freshness, bottom-right: when the numbers are from, or what went wrong.
+ let foot = if self.loading {
+ "Updating…".to_string()
+ } else if let Some(e) = &self.status {
+ e.clone()
+ } else if let Some(t) = parse_local(&c.time) {
+ let age = self.fetched_at.map_or(0, |at| now_unix() - at);
+ if age > 3 * 3600 {
+ format!("{} · {}h old", clock(&t, f.units), age / 3600)
+ } else {
+ format!("As of {}", clock(&t, f.units))
+ }
+ } else {
+ String::new()
+ };
+ let small = (size - 1.0).max(9.0);
+ let fw = shaped_width(&foot, small, font);
+ let fy = inner.y + HEAD_SIZE * 1.4 + if loc.region.is_empty() { 0.0 } else { size * 1.4 };
+ text(pc, foot, inner.x + inner.width - fw, fy, small, dim);
+ }
+
+ fn paint_hourly(&self, pc: &mut PaintCtx, font: &str, size: f32, fg: [u8; 3], dim: [u8; 3]) {
+ let rect = self.hourly_rect;
+ self.pane(pc, rect);
+ let pad = plate_padding();
+ pc.text_with("Next 24 hours", rect.x + pad, rect.y + pad, size, fg, Some(font.to_string()), None);
+ let well = Rect {
+ x: rect.x + pad,
+ y: rect.y + pad + size * 1.6,
+ width: rect.width - 2.0 * pad,
+ height: rect.height - 2.0 * pad - size * 1.6,
+ };
+ let r = cce_ui::layout::textbox_corner_radius();
+ pc.canvas_well(well, r, &Material::pane(), cce_ui::layout::control_relief(), false);
+
+ let Some(f) = &self.forecast else { return };
+ let start = hour_index(&f.hourly.time, &f.current.time);
+ let end = (start + HOURS_SHOWN + 1).min(f.hourly.time.len());
+ if end <= start + 1 {
+ return;
+ }
+ let temps: Vec<Option<f32>> = f.hourly.temperature_2m[start..end].to_vec();
+ let (lo, hi) = temps.iter().flatten().fold((f32::MAX, f32::MIN), |(a, b), &t| (a.min(t), b.max(t)));
+ if lo > hi {
+ return;
+ }
+ let span = (hi - lo).max(4.0);
+ let mid = (hi + lo) / 2.0;
+ let small = (size - 1.5).max(9.0);
+
+ // Bands inside the well, top to bottom: glyphs, the curve (with its
+ // labels above each point), the precipitation bars, the hour labels.
+ let inset = r.max(6.0);
+ let gl = 18.0f32;
+ let x0 = well.x + inset;
+ let x1 = well.x + well.width - inset;
+ let glyph_y = well.y + inset * 0.5;
+ let hours_y = well.y + well.height - inset - small * 1.2;
+ let curve_top = glyph_y + gl + small * 1.4;
+ let curve_bot = hours_y - small * 0.6;
+ let n = end - start;
+ let step = (x1 - x0) / (n - 1) as f32;
+ let px = |i: usize| x0 + step * i as f32;
+ let py = |t: f32| {
+ let k = (t - (mid - span / 2.0)) / span;
+ curve_bot - k * (curve_bot - curve_top)
+ };
+
+ // Precipitation chance: bars rising from the curve band's floor.
+ let bar_max = (curve_bot - curve_top) * 0.45;
+ let bar_w = (step * 0.6).max(2.0);
+ for i in 0..n {
+ if let Some(p) = f.hourly.precipitation_probability.get(start + i).copied().flatten() {
+ if p >= 5.0 {
+ let bh = bar_max * p / 100.0;
+ pc.rounded_rect(
+ Rect { x: px(i) - bar_w / 2.0, y: curve_bot - bh, width: bar_w, height: bh },
+ (bar_w / 2.0).min(2.0),
+ (true, true, false, false),
+ lin(PRECIP_BAR),
+ );
+ }
+ }
+ }
+
+ // The curve, skipping across gaps rather than drawing to zero.
+ let mut prev: Option<(f32, f32)> = None;
+ for (i, t) in temps.iter().enumerate() {
+ match t {
+ Some(t) => {
+ let p = (px(i), py(*t));
+ if let Some(q) = prev {
+ pc.vector(q.0, q.1, p.0, p.1, 2.0, lin(CURVE), Cap::Round);
+ }
+ prev = Some(p);
+ }
+ None => prev = None,
+ }
+ }
+
+ // Labels every few hours: glyph on top, temperature over its point,
+ // the hour under the well's floor band.
+ for i in (0..n).step_by(LABEL_EVERY) {
+ let x = px(i);
+ if let Some(t) = temps[i] {
+ pc.circle(x, py(t), 3.0, lin(CURVE));
+ let s = format!("{t:.0}°");
+ let w = shaped_width(&s, small, font);
+ let lx = (x - w / 2.0).clamp(well.x + inset * 0.5, well.x + well.width - inset * 0.5 - w);
+ pc.text_with(s, lx, py(t) - small * 1.5, small, fg, Some(font.to_string()), None);
+ }
+ if let Some(code) = f.hourly.weather_code.get(start + i).copied().flatten() {
+ let day = f.hourly.is_day.get(start + i).copied().flatten().unwrap_or(1) != 0;
+ let gx = (x - gl / 2.0).clamp(well.x + inset * 0.5, well.x + well.width - inset * 0.5 - gl);
+ glyph::draw(pc, Rect { x: gx, y: glyph_y, width: gl, height: gl }, code, day);
+ }
+ let label = if i == 0 {
+ "Now".to_string()
+ } else {
+ parse_local(&f.hourly.time[start + i]).map(|t| hour_label(&t, f.units)).unwrap_or_default()
+ };
+ let w = shaped_width(&label, small, font);
+ let lx = (x - w / 2.0).clamp(well.x + inset * 0.5, well.x + well.width - inset * 0.5 - w);
+ pc.text_with(label, lx, hours_y, small, dim, Some(font.to_string()), None);
+ }
+ }
+
+ fn paint_daily(&self, pc: &mut PaintCtx, font: &str, size: f32, fg: [u8; 3], dim: [u8; 3]) {
+ let rect = self.daily_rect;
+ if rect.height < 40.0 {
+ return;
+ }
+ self.pane(pc, rect);
+ let pad = plate_padding();
+ pc.text_with("7 days", rect.x + pad, rect.y + pad, size, fg, Some(font.to_string()), None);
+ let Some(f) = &self.forecast else { return };
+ let d = &f.daily;
+ let days = d.time.len().min(7);
+ if days == 0 {
+ return;
+ }
+ let week_lo = d.temperature_2m_min.iter().take(days).flatten().fold(f32::MAX, |a, &b| a.min(b));
+ let week_hi = d.temperature_2m_max.iter().take(days).flatten().fold(f32::MIN, |a, &b| a.max(b));
+ let week_span = (week_hi - week_lo).max(1.0);
+
+ let top = rect.y + pad + size * 1.8;
+ let bottom = rect.y + rect.height - pad;
+ let row_h = ((bottom - top) / days as f32).min(44.0);
+ let x0 = rect.x + pad;
+ let x1 = rect.x + rect.width - pad;
+ let today = d.time.first().and_then(|t| NaiveDate::parse_from_str(t, "%Y-%m-%d").ok());
+
+ // Columns: day | glyph | chance of rain | low ─ bar ─ high.
+ let day_w = shaped_width("Today", size, font).max(shaped_width("Wed", size, font)) + pad;
+ let gs = (row_h * 0.62).min(26.0);
+ let pct_w = shaped_width("100%", size, font) + pad;
+ let num_w = shaped_width("-00°", size, font);
+ let bar_x0 = x0 + day_w + gs + pad + pct_w + num_w + pad * 0.5;
+ let bar_x1 = x1 - num_w - pad * 0.5;
+
+ for i in 0..days {
+ let y = top + row_h * i as f32;
+ let ty = align_text_y(y, row_h, size, 0.0);
+ let date = NaiveDate::parse_from_str(&d.time[i], "%Y-%m-%d").ok();
+ let name = match (date, today) {
+ (Some(dt), Some(t0)) if dt == t0 => "Today".to_string(),
+ (Some(dt), _) => dt.weekday().to_string(),
+ _ => d.time[i].clone(),
+ };
+ pc.text_with(name, x0, ty, size, fg, Some(font.to_string()), None);
+ if let Some(code) = d.weather_code[i] {
+ glyph::draw(pc, Rect { x: x0 + day_w, y: y + (row_h - gs) / 2.0, width: gs, height: gs }, code, true);
+ }
+ if let Some(p) = d.precipitation_probability_max[i] {
+ if p >= 10.0 {
+ pc.text_with(format!("{p:.0}%"), x0 + day_w + gs + pad, ty, size, PRECIP, Some(font.to_string()), None);
+ }
+ }
+ let (Some(lo), Some(hi)) = (d.temperature_2m_min[i], d.temperature_2m_max[i]) else { continue };
+ let lo_s = format!("{lo:.0}°");
+ let lw = shaped_width(&lo_s, size, font);
+ pc.text_with(lo_s, bar_x0 - pad * 0.5 - lw, ty, size, dim, Some(font.to_string()), None);
+ pc.text_with(format!("{hi:.0}°"), bar_x1 + pad * 0.5, ty, size, fg, Some(font.to_string()), None);
+
+ // The day's range on the week's scale, coloured cold → warm.
+ if bar_x1 - bar_x0 > 12.0 {
+ let bh = 5.0f32;
+ let by = y + (row_h - bh) / 2.0;
+ let track = Rect { x: bar_x0, y: by, width: bar_x1 - bar_x0, height: bh };
+ pc.rounded_rect(track, bh / 2.0, (true, true, true, true), TRACK);
+ let a = (lo - week_lo) / week_span;
+ let b = (hi - week_lo) / week_span;
+ let seg = Rect {
+ x: bar_x0 + a * track.width,
+ y: by,
+ width: ((b - a) * track.width).max(bh),
+ height: bh,
+ };
+ pc.rounded_rect(seg, bh / 2.0, (true, true, true, true), lin(lerp3(COLD, WARM, (a + b) / 2.0)));
+ }
+ }
+ }
+
+ fn paint_results(&self, pc: &mut PaintCtx, font: &str, size: f32, fg: [u8; 3]) {
+ let rect = self.results_rect;
+ self.pane(pc, rect);
+ let pad = plate_padding();
+ pc.text_with("Choose a place (Esc to cancel)", rect.x + pad, rect.y + pad, size, fg, Some(font.to_string()), None);
+ for b in &self.results_btns {
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, b, pc);
+ }
+ }
+}
+
+impl Application for WeatherApp {
+ type Message = Message;
+
+ fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
+ let config = config::load_config();
+ let state = config::load_state();
+ let units = config::resolve_units(&state, &config);
+ let location = state.location.clone().or_else(|| config.location.clone());
+ // A cached forecast is shown only for the place and units it was for.
+ let cache_ok = location.is_some()
+ && state.forecast_location == location
+ && state.forecast.as_ref().is_some_and(|f| f.units == units);
+ let (forecast, fetched_at) = if cache_ok { (state.forecast, state.fetched_at) } else { (None, None) };
+ let last_fetch = Arc::new(AtomicU64::new(fetched_at.unwrap_or(0) as u64));
+ spawn_timer(sender.clone(), last_fetch.clone(), config.refresh_minutes);
+
+ let mut app = Self {
+ search: TextBox::new(String::new()).with_placeholder("Search for a city…"),
+ units_btn: Button::new(0.0, 0.0, 0.0, 0.0).with_label(units.toggled().temp_suffix()),
+ refresh_btn: Button::new(0.0, 0.0, 0.0, 0.0).with_icon_name("refresh", "↻"),
+ results_btns: std::array::from_fn(|_| Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label("")),
+ chosen_location: state.location.clone(),
+ chosen_units: state.units,
+ location,
+ units,
+ forecast,
+ fetched_at,
+ generation: 0,
+ loading: false,
+ searching: false,
+ results: Vec::new(),
+ status: None,
+ last_fetch,
+ sender,
+ ui_context: cce_ui::context::UiContext::new(),
+ widgets_registered: false,
+ needs_rebuild: true,
+ size: (520.0, 720.0),
+ scale: 1.0,
+ now_rect: Rect::ZERO,
+ hourly_rect: Rect::ZERO,
+ daily_rect: Rect::ZERO,
+ results_rect: Rect::ZERO,
+ };
+ // Always fetch at start: the cache is only for the first frame.
+ app.refresh();
+ app
+ }
+
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: "Weather".to_string(),
+ app_id: "cce-weather".to_string(),
+ width: 520,
+ height: 720,
+ fullscreen: false,
+ min_size: Some((400, 560)),
+ }
+ }
+
+ fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
+ match msg {
+ Message::Forecast { generation, result } => {
+ if generation != self.generation {
+ return;
+ }
+ self.loading = false;
+ match result {
+ Ok(f) => {
+ self.forecast = Some(f);
+ let now = now_unix();
+ self.fetched_at = Some(now);
+ self.last_fetch.store(now as u64, Ordering::Relaxed);
+ self.status = None;
+ self.save();
+ }
+ Err(e) => {
+ log::warn!("forecast: {e}");
+ // Back off a full interval rather than retrying every
+ // minute while offline; the button retries at once.
+ self.last_fetch.store(now_unix() as u64, Ordering::Relaxed);
+ self.status = Some("Couldn't update — offline?".into());
+ }
+ }
+ }
+ Message::Geocoded { query, result } => {
+ self.searching = false;
+ match result {
+ Ok(list) if list.is_empty() => self.status = Some(format!("No place called “{query}”")),
+ // One match needs no question.
+ Ok(mut list) if list.len() == 1 => {
+ self.clear_search();
+ self.search.unfocus();
+ self.set_location(list.remove(0));
+ }
+ Ok(list) => self.results = list,
+ Err(e) => {
+ log::warn!("geocode: {e}");
+ self.status = Some("Search failed — offline?".into());
+ }
+ }
+ }
+ Message::RefreshDue => {
+ if !self.loading {
+ self.refresh();
+ }
+ }
+ Message::Exit => *exit = true,
+ }
+ self.needs_rebuild = true;
+ *needs_rebuild = true;
+ }
+
+ fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+ if self.ui_context.tick(dt) {
+ *needs_rebuild = true;
+ }
+ }
+
+ fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
+ if !self.widgets_registered {
+ self.widgets_registered = true;
+ let self_ptr = self as *mut Self;
+ unsafe {
+ for w in (*self_ptr).roots() {
+ let id = (*w).base().id();
+ self.ui_context.register_widget(id, w);
+ }
+ }
+ }
+ let size_changed = self.size != (size.width, size.height) || self.scale != scale;
+ if self.needs_rebuild || size_changed {
+ self.size = (size.width, size.height);
+ self.scale = scale;
+ cce_ui::scale::set_scale_factor(scale as f32);
+ // The button names the unit it switches TO.
+ self.units_btn.set_label(self.units.toggled().temp_suffix());
+ self.search.set_placeholder(if self.searching { "Searching…" } else { "Search for a city…" });
+ self.layout();
+ self.needs_rebuild = false;
+ self.ui_context.rebuild_spatial_grid();
+ }
+
+ // The family alone: `list_font()` may carry a size, which would
+ // override every size asked for here.
+ let (font, size_pt) = list_font_parsed();
+ let fg = to_u8(cce_ui::colors::list_font_color());
+ let dim = srgb_u8(cce_ui::colors::TEXT_DIM);
+
+ let mut pc = PaintCtx::new();
+ pc.root_plate(size.width, size.height);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.search, &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.units_btn, &mut pc);
+ cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.refresh_btn, &mut pc);
+
+ if self.picking() {
+ self.paint_results(&mut pc, &font, size_pt, fg);
+ } else {
+ self.paint_now(&mut pc, &font, size_pt, fg, dim);
+ self.paint_hourly(&mut pc, &font, size_pt, fg, dim);
+ self.paint_daily(&mut pc, &font, size_pt, fg, dim);
+ }
+ // A search error has no forecast pane to sit in when there is no place yet.
+ if self.location.is_none() {
+ if let Some(s) = &self.status {
+ let y = self.daily_rect.y + self.daily_rect.height - size_pt * 1.4;
+ pc.text_with(s.clone(), self.daily_rect.x, y, size_pt, PRECIP, Some(font.clone()), None);
+ }
+ }
+ Some(pc.finish())
+ }
+
+ fn display_list_text(&self) -> bool {
+ true
+ }
+
+ fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
+ Some(&self.ui_context)
+ }
+
+ fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
+ Some(&mut self.ui_context)
+ }
+
+ fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
+ self.ui_context.drag_allowed_at(px, py)
+ }
+
+ fn clear_color(&self) -> [f32; 4] {
+ [0.0, 0.0, 0.0, 0.0]
+ }
+
+ fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
+ if self.route(&ev, false) || self.needs_rebuild {
+ self.needs_rebuild = true;
+ *needs_rebuild = true;
+ }
+ }
+
+ fn handle_mouse_input(
+ &mut self,
+ button: MouseButton,
+ state: ElementState,
+ pos: LogicalPosition,
+ needs_rebuild: &mut bool,
+ ) -> Option<Self::Message> {
+ let ev = Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
+ if self.route(&ev, false) || self.needs_rebuild {
+ self.needs_rebuild = true;
+ *needs_rebuild = true;
+ }
+ None
+ }
+
+ fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+ let ev = Event::MouseWheel { delta: delta.clone(), x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
+ if self.route(&ev, false) || self.needs_rebuild {
+ self.needs_rebuild = true;
+ *needs_rebuild = true;
+ }
+ }
+
+ fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
+ if event.state == ElementState::Pressed && !event.repeat {
+ if event.ctrl {
+ if let Key::Character(ref c) = event.logical_key {
+ match c.as_str() {
+ "q" => return Some(Message::Exit),
+ "r" => {
+ self.refresh();
+ *needs_rebuild = true;
+ return None;
+ }
+ _ => {}
+ }
+ }
+ }
+ match event.logical_key {
+ Key::Named(NamedKey::F5) => {
+ self.refresh();
+ *needs_rebuild = true;
+ return None;
+ }
+ Key::Named(NamedKey::Escape) if self.picking() || self.search.editing => {
+ self.results.clear();
+ self.search.unfocus();
+ self.needs_rebuild = true;
+ *needs_rebuild = true;
+ return None;
+ }
+ // The box's own edit mode, not `focused(&ctx)`: a click
+ // focuses through the thread-local registry, which the
+ // UiContext's focused_widget never learns of.
+ Key::Named(NamedKey::Enter) if self.search.editing => {
+ self.submit_search();
+ *needs_rebuild = true;
+ return None;
+ }
+ _ => {}
+ }
+ }
+ // KeyInput short-circuits: the router hands keys to the focused
+ // widget on every propagate call.
+ let ev = Event::KeyInput(event.clone());
+ if self.route(&ev, true) || self.needs_rebuild {
+ self.needs_rebuild = true;
+ *needs_rebuild = true;
+ }
+ None
+ }
+}
+
+fn main() {
+ env_logger::init();
+ cce_ui::engine::run::<WeatherApp>();
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn hour_index_finds_the_current_hour() {
+ let times: Vec<String> = (0..24).map(|h| format!("2026-10-05T{h:02}:00")).collect();
+ assert_eq!(hour_index(×, "2026-10-05T14:15"), 14);
+ assert_eq!(hour_index(×, "2026-10-05T00:00"), 0);
+ // Past the series: fall back to the start rather than panic.
+ assert_eq!(hour_index(×, "2026-10-06T03:00"), 0);
+ }
+
+ #[test]
+ fn compass_points() {
+ assert_eq!(compass(0.0), "N");
+ assert_eq!(compass(350.0), "N");
+ assert_eq!(compass(225.0), "SW");
+ assert_eq!(compass(-90.0), "W");
+ }
+
+ #[test]
+ fn hour_labels_follow_units() {
+ let t = parse_local("2026-10-05T15:00").unwrap();
+ assert_eq!(hour_label(&t, Units::Metric), "15");
+ assert_eq!(hour_label(&t, Units::Imperial), "3pm");
+ let m = parse_local("2026-10-05T00:00").unwrap();
+ assert_eq!(hour_label(&m, Units::Imperial), "12am");
+ }
+}