weather: now, the next 24 hours and the week
git clone https://git.lucas.co/cce-weather.git
src/main.rs (38.3K)
1 //! cce-weather — current conditions, the next 24 hours and the week ahead,
2 //! from Open-Meteo (no account, no key).
3 //!
4 //! The window is a root plate with a control row on it (place search, unit
5 //! toggle, refresh) and three pane plates below: now, the hourly chart (a
6 //! canvas well), and the daily rows. A search with several matches swaps
7 //! the panes for a list of them. Network work runs on worker threads and
8 //! comes back through the loop's `Sender`; a timer thread asks for a refresh
9 //! by wall clock, so a forecast is not left stale across a suspend.
10
11 mod api;
12 mod config;
13 mod glyph;
14
15 use std::sync::atomic::{AtomicU64, Ordering};
16 use std::sync::Arc;
17
18 use chrono::{Datelike, NaiveDate, NaiveDateTime, Timelike};
19
20 use cce_ui::widget::Handle;
21 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
22 use cce_ui::layout::{
23 align_text_y, bevel_width, control_gap, list_font_parsed, plate_corner_radius,
24 plate_padding,
25 };
26 use cce_ui::scene::arena::Arena;
27 use cce_ui::scene::layout::{compute_layout, LayoutBox, Length, Rect, Size as LSize, Style};
28 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
29 use cce_ui::scene::Material;
30 use cce_ui::widget::{
31 Adapted, Button, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey,
32 TextBox, WidgetHost, WidgetId,
33 };
34
35 use api::Forecast;
36 use config::{Location, State, Units};
37
38 /// How many geocoder matches the picker offers.
39 const MAX_RESULTS: usize = 5;
40 /// Fixed pane heights; the daily pane takes what is left.
41 const NOW_H: f32 = 156.0;
42 const HOURLY_H: f32 = 172.0;
43 const HOURS_SHOWN: usize = 24;
44 /// The hourly chart labels every this many hours.
45 const LABEL_EVERY: usize = 3;
46
47 const BIG_SIZE: f32 = 46.0;
48 const HEAD_SIZE: f32 = 15.0;
49
50 const PRECIP: [u8; 3] = [110, 170, 255];
51 // Prim colours below are sRGB and go through `lin` where drawn; text takes
52 // sRGB bytes directly.
53 const PRECIP_BAR: [f32; 4] = [0.36, 0.62, 1.0, 0.45];
54 const CURVE: [f32; 4] = [1.0, 0.68, 0.30, 1.0];
55 const COLD: [f32; 3] = [0.40, 0.66, 1.0];
56 const WARM: [f32; 3] = [1.0, 0.60, 0.26];
57 const TRACK: [f32; 4] = [1.0, 1.0, 1.0, 0.08];
58
59 #[derive(Debug, Clone)]
60 enum Message {
61 Forecast { generation: u64, result: Result<Forecast, String> },
62 Geocoded { query: String, result: Result<Vec<Location>, String> },
63 /// The timer thread: a refresh is due by wall clock.
64 RefreshDue,
65 Exit,
66 }
67
68 fn shaped_width(text: &str, size: f32, font: &str) -> f32 {
69 let mut fs = cce_ui::geometry_font_system().lock().unwrap_or_else(|e| e.into_inner());
70 cce_ui::backend::window_runner::shaped_cluster_offsets(&mut fs, text, size, Some(font))
71 .last()
72 .map_or(0.0, |&(_, x)| x)
73 }
74
75 fn to_u8(c: [f32; 4]) -> [u8; 3] {
76 [(c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8]
77 }
78
79 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
80 to_u8(cce_ui::colors::to_srgb(linear))
81 }
82
83 fn lin(c: [f32; 4]) -> [f32; 4] {
84 cce_ui::color::to_linear(c)
85 }
86
87 fn now_unix() -> i64 {
88 std::time::SystemTime::now()
89 .duration_since(std::time::UNIX_EPOCH)
90 .map_or(0, |d| d.as_secs() as i64)
91 }
92
93 fn parse_local(t: &str) -> Option<NaiveDateTime> {
94 NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M").ok()
95 }
96
97 fn compass(deg: f32) -> &'static str {
98 const POINTS: [&str; 8] = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
99 POINTS[(((deg.rem_euclid(360.0) + 22.5) / 45.0) as usize) % 8]
100 }
101
102 /// Hour label in the unit system's habit: 24-hour for metric, "3pm" else.
103 fn hour_label(t: &NaiveDateTime, units: Units) -> String {
104 match units {
105 Units::Metric => format!("{:02}", t.hour()),
106 Units::Imperial => {
107 let h = t.hour() % 12;
108 format!("{}{}", if h == 0 { 12 } else { h }, if t.hour() < 12 { "am" } else { "pm" })
109 }
110 }
111 }
112
113 fn clock(t: &NaiveDateTime, units: Units) -> String {
114 match units {
115 Units::Metric => t.format("%H:%M").to_string(),
116 Units::Imperial => t.format("%-I:%M %P").to_string(),
117 }
118 }
119
120 fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 4] {
121 let t = t.clamp(0.0, 1.0);
122 [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, 1.0]
123 }
124
125 /// The index of the hour containing `now` in an hourly series: the first
126 /// slot whose hour is not before it (ISO strings compare as times).
127 fn hour_index(times: &[String], now: &str) -> usize {
128 let key = &now[..now.len().min(13)];
129 times.iter().position(|t| &t[..t.len().min(13)] >= key).unwrap_or(0)
130 }
131
132 /// Spawn the timer: every minute it checks the wall clock against the last
133 /// fetch, and says so once a refresh is due. Wall clock, not a sleep for the
134 /// whole interval, because a monotonic sleep does not count a suspend.
135 fn spawn_timer(sender: calloop::channel::Sender<Message>, last_fetch: Arc<AtomicU64>, every_min: u64) {
136 std::thread::Builder::new()
137 .name("weather-timer".into())
138 .spawn(move || loop {
139 std::thread::sleep(std::time::Duration::from_secs(60));
140 let last = last_fetch.load(Ordering::Relaxed) as i64;
141 if now_unix() - last >= (every_min * 60) as i64 && sender.send(Message::RefreshDue).is_err() {
142 return;
143 }
144 })
145 .expect("spawning the refresh timer");
146 }
147
148 struct WeatherApp {
149 // Widgets: plain fields so their addresses are stable (the UiContext
150 // registry holds pointers to them).
151 search: Handle<Adapted<TextBox>>,
152 units_btn: Handle<Adapted<Button>>,
153 refresh_btn: Handle<Adapted<Button>>,
154 results_btns: [Handle<Adapted<Button>>; MAX_RESULTS],
155
156 // App state — the source of truth; widgets are re-asserted from it.
157 location: Option<Location>,
158 units: Units,
159 /// What the user picked in this app (a search, the toggle) — the only
160 /// parts of `location`/`units` that are saved; see `config::State`.
161 chosen_location: Option<Location>,
162 chosen_units: Option<Units>,
163 forecast: Option<Forecast>,
164 fetched_at: Option<i64>,
165 /// Bumped whenever the place or units change, so a reply for the old
166 /// request is dropped on arrival.
167 generation: u64,
168 loading: bool,
169 searching: bool,
170 results: Vec<Location>,
171 status: Option<String>,
172 last_fetch: Arc<AtomicU64>,
173 sender: calloop::channel::Sender<Message>,
174
175 ui_context: cce_ui::context::UiContext,
176 needs_rebuild: bool,
177 size: (f32, f32),
178 scale: f64,
179 // Solved geometry, kept between rebuilds for painting.
180 now_rect: Rect,
181 hourly_rect: Rect,
182 daily_rect: Rect,
183 results_rect: Rect,
184 }
185
186 impl WeatherApp {
187 fn root_ids(&self) -> Vec<WidgetId> {
188 let mut ids = vec![self.search.id(), self.units_btn.id(), self.refresh_btn.id()];
189 ids.extend(self.results_btns.iter().map(|b| b.id()));
190 ids
191 }
192
193 fn picking(&self) -> bool {
194 !self.results.is_empty()
195 }
196
197 fn save(&self) {
198 config::save_state(&State {
199 location: self.chosen_location.clone(),
200 units: self.chosen_units,
201 forecast: self.forecast.clone(),
202 forecast_location: self.forecast.as_ref().and(self.location.clone()),
203 fetched_at: self.fetched_at,
204 });
205 }
206
207 fn refresh(&mut self) {
208 let Some(loc) = self.location.clone() else { return };
209 self.loading = true;
210 self.needs_rebuild = true;
211 let (generation, units, sender) = (self.generation, self.units, self.sender.clone());
212 std::thread::spawn(move || {
213 let result = api::fetch_forecast(&loc, units);
214 let _ = sender.send(Message::Forecast { generation, result });
215 });
216 }
217
218 fn set_location(&mut self, loc: Location) {
219 self.chosen_location = Some(loc.clone());
220 self.location = Some(loc);
221 self.forecast = None;
222 self.fetched_at = None;
223 self.generation += 1;
224 self.results.clear();
225 self.status = None;
226 self.save();
227 self.refresh();
228 }
229
230 fn toggle_units(&mut self) {
231 self.units = self.units.toggled();
232 self.chosen_units = Some(self.units);
233 self.generation += 1;
234 self.save();
235 self.refresh();
236 }
237
238 fn search_value(&self) -> String {
239 let raw = if self.ui_context[self.search].editing { &self.ui_context[self.search].edit_buffer } else { &self.ui_context[self.search].text };
240 raw.trim().to_string()
241 }
242
243 fn clear_search(&mut self) {
244 self.ui_context[self.search].text.clear();
245 self.ui_context[self.search].edit_buffer.clear();
246 self.ui_context[self.search].cursor_idx = 0;
247 }
248
249 fn submit_search(&mut self) {
250 let query = self.search_value();
251 if query.is_empty() {
252 return;
253 }
254 self.searching = true;
255 self.status = None;
256 self.needs_rebuild = true;
257 let sender = self.sender.clone();
258 std::thread::spawn(move || {
259 let result = api::geocode(&query, MAX_RESULTS);
260 let _ = sender.send(Message::Geocoded { query, result });
261 });
262 }
263
264 fn drain_widget_changes(&mut self) {
265 if self.ui_context[self.units_btn].take_click() {
266 self.toggle_units();
267 }
268 if self.ui_context[self.refresh_btn].take_click() {
269 self.refresh();
270 }
271 for i in 0..MAX_RESULTS {
272 if self.ui_context[self.results_btns[i]].take_click() {
273 if let Some(loc) = self.results.get(i).cloned() {
274 self.clear_search();
275 self.ui_context.unfocus_id(self.search.id());
276 self.set_location(loc);
277 }
278 }
279 }
280 if self.ui_context[self.search].take_change() {
281 self.needs_rebuild = true;
282 }
283 }
284
285 fn route(&mut self, ev: &Event, short_circuit: bool) -> bool {
286 let mut changed = false;
287 for id in self.root_ids() {
288 if self.ui_context.propagate_event(ev, id) {
289 changed = true;
290 if short_circuit {
291 break;
292 }
293 }
294 }
295 self.drain_widget_changes();
296 changed
297 }
298
299 fn layout(&mut self) {
300 let (w, h) = self.size;
301 let mut arena: Arena<LayoutBox> = Arena::new();
302 let root = arena.insert(LayoutBox::container(Style::root_column()));
303 let box_h = cce_ui::layout::textbox_height();
304 let btn_h = cce_ui::layout::button_height();
305 let controls = arena.insert(LayoutBox::container(
306 Style::controls_row().height(Length::Fixed(box_h.max(btn_h))),
307 ));
308 let search = arena.insert(LayoutBox::leaf(Style::row().grow(1.0).shrink(1.0), LSize::new(0.0, box_h)));
309 let units = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(52.0, btn_h)));
310 let refresh = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(btn_h, btn_h)));
311 let now = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, NOW_H)));
312 let hourly = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, HOURLY_H)));
313 let daily = arena.insert(LayoutBox::container(Style::column().grow(1.0)));
314 arena.append_child(root, controls);
315 arena.append_child(controls, search);
316 arena.append_child(controls, units);
317 arena.append_child(controls, refresh);
318 arena.append_child(root, now);
319 arena.append_child(root, hourly);
320 arena.append_child(root, daily);
321 compute_layout(&mut arena, root, LSize::new(w, h));
322
323 let r = |id| arena.value(id).unwrap().rect;
324 let s = r(search);
325 self.ui_context[self.search].set_rect(s.x, s.y, s.width, s.height);
326 let u = r(units);
327 self.ui_context[self.units_btn].set_rect(u.x, u.y, u.width, u.height);
328 let f = r(refresh);
329 self.ui_context[self.refresh_btn].set_rect(f.x, f.y, f.width, f.height);
330 self.now_rect = r(now);
331 self.hourly_rect = r(hourly);
332 self.daily_rect = r(daily);
333
334 // The picker takes the whole area under the control row.
335 let top = self.now_rect.y;
336 let bottom = self.daily_rect.y + self.daily_rect.height;
337 self.results_rect = Rect { x: self.now_rect.x, y: top, width: self.now_rect.width, height: bottom - top };
338 let pad = plate_padding();
339 let row_h = btn_h;
340 let picking = self.picking();
341 for (i, &h) in self.results_btns.iter().enumerate() {
342 let b = &mut self.ui_context[h];
343 match self.results.get(i) {
344 Some(loc) if picking => {
345 let label = if loc.region.is_empty() {
346 loc.name.clone()
347 } else {
348 format!("{} — {}", loc.name, loc.region)
349 };
350 b.set_label(&label);
351 let y = top + pad + HEAD_SIZE * 1.6 + i as f32 * (row_h + control_gap());
352 b.set_rect(self.results_rect.x + pad, y, self.results_rect.width - 2.0 * pad, row_h);
353 }
354 // Off the window, so a hidden row takes no clicks.
355 _ => b.set_rect(-1000.0, -1000.0, 0.0, 0.0),
356 }
357 }
358 }
359
360 fn pane(&self, pc: &mut PaintCtx, rect: Rect) {
361 let r = plate_corner_radius();
362 pc.plate(rect, (r, r, r, r), &Material::pane(), bevel_width().min(rect.height * 0.2));
363 }
364
365 fn paint_now(&self, pc: &mut PaintCtx, font: &str, size: f32, fg: [u8; 3], dim: [u8; 3]) {
366 let rect = self.now_rect;
367 self.pane(pc, rect);
368 let pad = plate_padding();
369 let inner = Rect {
370 x: rect.x + pad,
371 y: rect.y + pad,
372 width: rect.width - 2.0 * pad,
373 height: rect.height - 2.0 * pad,
374 };
375 let text = |pc: &mut PaintCtx, s: String, x: f32, y: f32, sz: f32, c: [u8; 3]| {
376 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]));
377 };
378
379 let Some(loc) = &self.location else {
380 text(pc, "No place yet".into(), inner.x, inner.y, HEAD_SIZE, fg);
381 text(pc, "Type a city in the box above and press Enter.".into(), inner.x, inner.y + HEAD_SIZE * 1.6, size, dim);
382 return;
383 };
384
385 // The place, right-aligned along the top.
386 let place = loc.name.clone();
387 let pw = shaped_width(&place, HEAD_SIZE, font);
388 text(pc, place, inner.x + inner.width - pw, inner.y, HEAD_SIZE, fg);
389 if !loc.region.is_empty() {
390 let rw = shaped_width(&loc.region, size, font);
391 text(pc, loc.region.clone(), inner.x + inner.width - rw, inner.y + HEAD_SIZE * 1.4, size, dim);
392 }
393
394 let Some(f) = &self.forecast else {
395 let msg = if self.loading { "Loading…" } else { "No forecast" };
396 text(pc, msg.into(), inner.x, inner.y, HEAD_SIZE, dim);
397 return;
398 };
399 let c = &f.current;
400 let gs = inner.height.min(96.0);
401 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);
402
403 let x = inner.x + gs + pad;
404 let temp = format!("{:.0}°", c.temperature_2m);
405 text(pc, temp, x, inner.y, BIG_SIZE, fg);
406 let (_, words) = glyph::describe(c.weather_code);
407 let line = size * 1.45;
408 let mut y = inner.y + BIG_SIZE * 1.25;
409 text(pc, words.to_string(), x, y, HEAD_SIZE, fg);
410 y += HEAD_SIZE * 1.45;
411 text(pc, format!("Feels like {:.0}° · Humidity {:.0}%", c.apparent_temperature, c.relative_humidity_2m), x, y, size, dim);
412 y += line;
413 let mut detail = format!("Wind {:.0} {} {}", c.wind_speed_10m, f.units.wind_suffix(), compass(c.wind_direction_10m));
414 if let (Some(hi), Some(lo)) = (
415 f.daily.temperature_2m_max.first().copied().flatten(),
416 f.daily.temperature_2m_min.first().copied().flatten(),
417 ) {
418 detail.push_str(&format!(" · H {hi:.0}° L {lo:.0}°"));
419 }
420 text(pc, detail, x, y, size, dim);
421
422 // Freshness, bottom-right: when the numbers are from, or what went wrong.
423 let foot = if self.loading {
424 "Updating…".to_string()
425 } else if let Some(e) = &self.status {
426 e.clone()
427 } else if let Some(t) = parse_local(&c.time) {
428 let age = self.fetched_at.map_or(0, |at| now_unix() - at);
429 if age > 3 * 3600 {
430 format!("{} · {}h old", clock(&t, f.units), age / 3600)
431 } else {
432 format!("As of {}", clock(&t, f.units))
433 }
434 } else {
435 String::new()
436 };
437 let small = (size - 1.0).max(9.0);
438 let fw = shaped_width(&foot, small, font);
439 let fy = inner.y + HEAD_SIZE * 1.4 + if loc.region.is_empty() { 0.0 } else { size * 1.4 };
440 text(pc, foot, inner.x + inner.width - fw, fy, small, dim);
441 }
442
443 fn paint_hourly(&self, pc: &mut PaintCtx, font: &str, size: f32, fg: [u8; 3], dim: [u8; 3]) {
444 let rect = self.hourly_rect;
445 self.pane(pc, rect);
446 let pad = plate_padding();
447 pc.text_with("Next 24 hours", rect.x + pad, rect.y + pad, size, fg, Some(font.to_string()), None);
448 let well = Rect {
449 x: rect.x + pad,
450 y: rect.y + pad + size * 1.6,
451 width: rect.width - 2.0 * pad,
452 height: rect.height - 2.0 * pad - size * 1.6,
453 };
454 let r = cce_ui::layout::textbox_corner_radius();
455 pc.canvas_well(well, r, &Material::pane(), cce_ui::layout::control_relief(), false);
456
457 let Some(f) = &self.forecast else { return };
458 let start = hour_index(&f.hourly.time, &f.current.time);
459 let end = (start + HOURS_SHOWN + 1).min(f.hourly.time.len());
460 if end <= start + 1 {
461 return;
462 }
463 let temps: Vec<Option<f32>> = f.hourly.temperature_2m[start..end].to_vec();
464 let (lo, hi) = temps.iter().flatten().fold((f32::MAX, f32::MIN), |(a, b), &t| (a.min(t), b.max(t)));
465 if lo > hi {
466 return;
467 }
468 let span = (hi - lo).max(4.0);
469 let mid = (hi + lo) / 2.0;
470 let small = (size - 1.5).max(9.0);
471
472 // Bands inside the well, top to bottom: glyphs, the curve (with its
473 // labels above each point), the precipitation bars, the hour labels.
474 let inset = r.max(6.0);
475 let gl = 18.0f32;
476 let x0 = well.x + inset;
477 let x1 = well.x + well.width - inset;
478 let glyph_y = well.y + inset * 0.5;
479 let hours_y = well.y + well.height - inset - small * 1.2;
480 let curve_top = glyph_y + gl + small * 1.4;
481 let curve_bot = hours_y - small * 0.6;
482 let n = end - start;
483 let step = (x1 - x0) / (n - 1) as f32;
484 let px = |i: usize| x0 + step * i as f32;
485 let py = |t: f32| {
486 let k = (t - (mid - span / 2.0)) / span;
487 curve_bot - k * (curve_bot - curve_top)
488 };
489
490 // Precipitation chance: bars rising from the curve band's floor.
491 let bar_max = (curve_bot - curve_top) * 0.45;
492 let bar_w = (step * 0.6).max(2.0);
493 for i in 0..n {
494 if let Some(p) = f.hourly.precipitation_probability.get(start + i).copied().flatten() {
495 if p >= 5.0 {
496 let bh = bar_max * p / 100.0;
497 pc.rounded_rect(
498 Rect { x: px(i) - bar_w / 2.0, y: curve_bot - bh, width: bar_w, height: bh },
499 (bar_w / 2.0).min(2.0),
500 (true, true, false, false),
501 lin(PRECIP_BAR),
502 );
503 }
504 }
505 }
506
507 // The curve, skipping across gaps rather than drawing to zero.
508 let mut prev: Option<(f32, f32)> = None;
509 for (i, t) in temps.iter().enumerate() {
510 match t {
511 Some(t) => {
512 let p = (px(i), py(*t));
513 if let Some(q) = prev {
514 pc.vector(q.0, q.1, p.0, p.1, 2.0, lin(CURVE), Cap::Round);
515 }
516 prev = Some(p);
517 }
518 None => prev = None,
519 }
520 }
521
522 // Labels every few hours: glyph on top, temperature over its point,
523 // the hour under the well's floor band.
524 for i in (0..n).step_by(LABEL_EVERY) {
525 let x = px(i);
526 if let Some(t) = temps[i] {
527 pc.circle(x, py(t), 3.0, lin(CURVE));
528 let s = format!("{t:.0}°");
529 let w = shaped_width(&s, small, font);
530 let lx = (x - w / 2.0).clamp(well.x + inset * 0.5, well.x + well.width - inset * 0.5 - w);
531 pc.text_with(s, lx, py(t) - small * 1.5, small, fg, Some(font.to_string()), None);
532 }
533 if let Some(code) = f.hourly.weather_code.get(start + i).copied().flatten() {
534 let day = f.hourly.is_day.get(start + i).copied().flatten().unwrap_or(1) != 0;
535 let gx = (x - gl / 2.0).clamp(well.x + inset * 0.5, well.x + well.width - inset * 0.5 - gl);
536 glyph::draw(pc, Rect { x: gx, y: glyph_y, width: gl, height: gl }, code, day);
537 }
538 let label = if i == 0 {
539 "Now".to_string()
540 } else {
541 parse_local(&f.hourly.time[start + i]).map(|t| hour_label(&t, f.units)).unwrap_or_default()
542 };
543 let w = shaped_width(&label, small, font);
544 let lx = (x - w / 2.0).clamp(well.x + inset * 0.5, well.x + well.width - inset * 0.5 - w);
545 pc.text_with(label, lx, hours_y, small, dim, Some(font.to_string()), None);
546 }
547 }
548
549 fn paint_daily(&self, pc: &mut PaintCtx, font: &str, size: f32, fg: [u8; 3], dim: [u8; 3]) {
550 let rect = self.daily_rect;
551 if rect.height < 40.0 {
552 return;
553 }
554 self.pane(pc, rect);
555 let pad = plate_padding();
556 pc.text_with("7 days", rect.x + pad, rect.y + pad, size, fg, Some(font.to_string()), None);
557 let Some(f) = &self.forecast else { return };
558 let d = &f.daily;
559 let days = d.time.len().min(7);
560 if days == 0 {
561 return;
562 }
563 let week_lo = d.temperature_2m_min.iter().take(days).flatten().fold(f32::MAX, |a, &b| a.min(b));
564 let week_hi = d.temperature_2m_max.iter().take(days).flatten().fold(f32::MIN, |a, &b| a.max(b));
565 let week_span = (week_hi - week_lo).max(1.0);
566
567 let top = rect.y + pad + size * 1.8;
568 let bottom = rect.y + rect.height - pad;
569 let row_h = ((bottom - top) / days as f32).min(44.0);
570 let x0 = rect.x + pad;
571 let x1 = rect.x + rect.width - pad;
572 let today = d.time.first().and_then(|t| NaiveDate::parse_from_str(t, "%Y-%m-%d").ok());
573
574 // Columns: day | glyph | chance of rain | low ─ bar ─ high.
575 let day_w = shaped_width("Today", size, font).max(shaped_width("Wed", size, font)) + pad;
576 let gs = (row_h * 0.62).min(26.0);
577 let pct_w = shaped_width("100%", size, font) + pad;
578 let num_w = shaped_width("-00°", size, font);
579 let bar_x0 = x0 + day_w + gs + pad + pct_w + num_w + pad * 0.5;
580 let bar_x1 = x1 - num_w - pad * 0.5;
581
582 for i in 0..days {
583 let y = top + row_h * i as f32;
584 let ty = align_text_y(y, row_h, size, 0.0);
585 let date = NaiveDate::parse_from_str(&d.time[i], "%Y-%m-%d").ok();
586 let name = match (date, today) {
587 (Some(dt), Some(t0)) if dt == t0 => "Today".to_string(),
588 (Some(dt), _) => dt.weekday().to_string(),
589 _ => d.time[i].clone(),
590 };
591 pc.text_with(name, x0, ty, size, fg, Some(font.to_string()), None);
592 if let Some(code) = d.weather_code[i] {
593 glyph::draw(pc, Rect { x: x0 + day_w, y: y + (row_h - gs) / 2.0, width: gs, height: gs }, code, true);
594 }
595 if let Some(p) = d.precipitation_probability_max[i] {
596 if p >= 10.0 {
597 pc.text_with(format!("{p:.0}%"), x0 + day_w + gs + pad, ty, size, PRECIP, Some(font.to_string()), None);
598 }
599 }
600 let (Some(lo), Some(hi)) = (d.temperature_2m_min[i], d.temperature_2m_max[i]) else { continue };
601 let lo_s = format!("{lo:.0}°");
602 let lw = shaped_width(&lo_s, size, font);
603 pc.text_with(lo_s, bar_x0 - pad * 0.5 - lw, ty, size, dim, Some(font.to_string()), None);
604 pc.text_with(format!("{hi:.0}°"), bar_x1 + pad * 0.5, ty, size, fg, Some(font.to_string()), None);
605
606 // The day's range on the week's scale, coloured cold → warm.
607 if bar_x1 - bar_x0 > 12.0 {
608 let bh = 5.0f32;
609 let by = y + (row_h - bh) / 2.0;
610 let track = Rect { x: bar_x0, y: by, width: bar_x1 - bar_x0, height: bh };
611 pc.rounded_rect(track, bh / 2.0, (true, true, true, true), TRACK);
612 let a = (lo - week_lo) / week_span;
613 let b = (hi - week_lo) / week_span;
614 let seg = Rect {
615 x: bar_x0 + a * track.width,
616 y: by,
617 width: ((b - a) * track.width).max(bh),
618 height: bh,
619 };
620 pc.rounded_rect(seg, bh / 2.0, (true, true, true, true), lin(lerp3(COLD, WARM, (a + b) / 2.0)));
621 }
622 }
623 }
624
625 fn paint_results(&self, pc: &mut PaintCtx, font: &str, size: f32, fg: [u8; 3]) {
626 let rect = self.results_rect;
627 self.pane(pc, rect);
628 let pad = plate_padding();
629 pc.text_with("Choose a place (Esc to cancel)", rect.x + pad, rect.y + pad, size, fg, Some(font.to_string()), None);
630 for &b in &self.results_btns {
631 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[b], pc);
632 }
633 }
634 }
635
636 impl Application for WeatherApp {
637 type Message = Message;
638
639 fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
640 // The app keeps calloop's sender; `AppSender` converts into it.
641 let sender: calloop::channel::Sender<Self::Message> = sender.into();
642 let config = config::load_config();
643 let state = config::load_state();
644 let units = config::resolve_units(&state, &config);
645 let location = state.location.clone().or_else(|| config.location.clone());
646 // A cached forecast is shown only for the place and units it was for.
647 let cache_ok = location.is_some()
648 && state.forecast_location == location
649 && state.forecast.as_ref().is_some_and(|f| f.units == units);
650 let (forecast, fetched_at) = if cache_ok { (state.forecast, state.fetched_at) } else { (None, None) };
651 let last_fetch = Arc::new(AtomicU64::new(fetched_at.unwrap_or(0) as u64));
652 spawn_timer(sender.clone(), last_fetch.clone(), config.refresh_minutes);
653
654 // The context owns the widgets; the app keeps their handles.
655 let mut ui_context = cce_ui::context::UiContext::new();
656 let mut app = Self {
657 search: ui_context.insert(TextBox::new(String::new()).with_placeholder("Search for a city…")),
658 units_btn: ui_context.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label(units.toggled().temp_suffix())),
659 refresh_btn: ui_context.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_icon_name("refresh", "Refresh")),
660 results_btns: std::array::from_fn(|_| ui_context.insert(Button::new_list_row(0.0, 0.0, 0.0, 0.0).with_label(""))),
661 chosen_location: state.location.clone(),
662 chosen_units: state.units,
663 location,
664 units,
665 forecast,
666 fetched_at,
667 generation: 0,
668 loading: false,
669 searching: false,
670 results: Vec::new(),
671 status: None,
672 last_fetch,
673 sender,
674 ui_context,
675 needs_rebuild: true,
676 size: (520.0, 720.0),
677 scale: 1.0,
678 now_rect: Rect::ZERO,
679 hourly_rect: Rect::ZERO,
680 daily_rect: Rect::ZERO,
681 results_rect: Rect::ZERO,
682 };
683 // Always fetch at start: the cache is only for the first frame.
684 app.refresh();
685 app
686 }
687
688 fn settings(&self) -> WindowSettings {
689 WindowSettings {
690 title: "Weather".to_string(),
691 app_id: "cce-weather".to_string(),
692 width: 520,
693 height: 720,
694 fullscreen: false,
695 min_size: Some((400, 560)),
696 }
697 }
698
699 fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
700 match msg {
701 Message::Forecast { generation, result } => {
702 if generation != self.generation {
703 return;
704 }
705 self.loading = false;
706 match result {
707 Ok(f) => {
708 self.forecast = Some(f);
709 let now = now_unix();
710 self.fetched_at = Some(now);
711 self.last_fetch.store(now as u64, Ordering::Relaxed);
712 self.status = None;
713 self.save();
714 }
715 Err(e) => {
716 log::warn!("forecast: {e}");
717 // Back off a full interval rather than retrying every
718 // minute while offline; the button retries at once.
719 self.last_fetch.store(now_unix() as u64, Ordering::Relaxed);
720 self.status = Some("Couldn't update — offline?".into());
721 }
722 }
723 }
724 Message::Geocoded { query, result } => {
725 self.searching = false;
726 match result {
727 Ok(list) if list.is_empty() => self.status = Some(format!("No place called “{query}”")),
728 // One match needs no question.
729 Ok(mut list) if list.len() == 1 => {
730 self.clear_search();
731 self.ui_context.unfocus_id(self.search.id());
732 self.set_location(list.remove(0));
733 }
734 Ok(list) => self.results = list,
735 Err(e) => {
736 log::warn!("geocode: {e}");
737 self.status = Some("Search failed — offline?".into());
738 }
739 }
740 }
741 Message::RefreshDue => {
742 if !self.loading {
743 self.refresh();
744 }
745 }
746 Message::Exit => *exit = true,
747 }
748 self.needs_rebuild = true;
749 *needs_rebuild = true;
750 }
751
752 fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
753 if self.ui_context.tick(dt) {
754 *needs_rebuild = true;
755 }
756 }
757
758 fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
759 let size_changed = self.size != (size.width, size.height) || self.scale != scale;
760 if self.needs_rebuild || size_changed {
761 self.size = (size.width, size.height);
762 self.scale = scale;
763 cce_ui::scale::set_scale_factor(scale as f32);
764 // The button names the unit it switches TO.
765 self.ui_context[self.units_btn].set_label(self.units.toggled().temp_suffix());
766 self.ui_context[self.search].set_placeholder(if self.searching { "Searching…" } else { "Search for a city…" });
767 self.layout();
768 self.needs_rebuild = false;
769 self.ui_context.rebuild_spatial_grid();
770 }
771
772 // The family alone: `list_font()` may carry a size, which would
773 // override every size asked for here.
774 let (font, size_pt) = list_font_parsed();
775 let fg = to_u8(cce_ui::colors::list_font_color());
776 let dim = srgb_u8(cce_ui::colors::TEXT_DIM);
777
778 let mut pc = PaintCtx::new();
779 pc.root_plate(size.width, size.height);
780 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.search], &mut pc);
781 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.units_btn], &mut pc);
782 cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.refresh_btn], &mut pc);
783
784 if self.picking() {
785 self.paint_results(&mut pc, &font, size_pt, fg);
786 } else {
787 self.paint_now(&mut pc, &font, size_pt, fg, dim);
788 self.paint_hourly(&mut pc, &font, size_pt, fg, dim);
789 self.paint_daily(&mut pc, &font, size_pt, fg, dim);
790 }
791 // A search error has no forecast pane to sit in when there is no place yet.
792 if self.location.is_none() {
793 if let Some(s) = &self.status {
794 let y = self.daily_rect.y + self.daily_rect.height - size_pt * 1.4;
795 pc.text_with(s.clone(), self.daily_rect.x, y, size_pt, PRECIP, Some(font.clone()), None);
796 }
797 }
798 Some(pc.finish())
799 }
800
801 fn display_list_text(&self) -> bool {
802 true
803 }
804
805 fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
806 Some(&self.ui_context)
807 }
808
809 fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
810 Some(&mut self.ui_context)
811 }
812
813 fn is_movable_root_plate_at(&self, px: f32, py: f32) -> bool {
814 self.ui_context.drag_allowed_at(px, py)
815 }
816
817 fn clear_color(&self) -> [f32; 4] {
818 [0.0, 0.0, 0.0, 0.0]
819 }
820
821 fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
822 // The shared context menu (the search box's) gets the pointer to itself
823 // while open: its row highlight.
824 if cce_ui::widget::context_menu::is_visible() {
825 if cce_ui::widget::context_menu::cursor_moved(pos.x, pos.y) {
826 self.needs_rebuild = true;
827 *needs_rebuild = true;
828 }
829 return;
830 }
831 let ev = Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
832 if self.route(&ev, false) || self.needs_rebuild {
833 self.needs_rebuild = true;
834 *needs_rebuild = true;
835 }
836 }
837
838 fn handle_mouse_input(
839 &mut self,
840 button: MouseButton,
841 state: ElementState,
842 pos: LogicalPosition,
843 needs_rebuild: &mut bool,
844 ) -> Option<Self::Message> {
845 // The shared context menu a right-click on the search box opens takes
846 // every click while open: a row runs, a press anywhere else dismisses it.
847 // The toolkit leaves this routing to the app; without it the menu could
848 // not be closed by clicking outside it, and its rows did nothing. A row's
849 // edit (Paste, Cut) is drained like any other change to the box.
850 if cce_ui::widget::context_menu::is_visible() {
851 if cce_ui::widget::context_menu::mouse_input(button, state, pos.x, pos.y, Some(&mut self.ui_context)) {
852 self.drain_widget_changes();
853 self.needs_rebuild = true;
854 *needs_rebuild = true;
855 }
856 return None;
857 }
858 let ev = Event::MouseButton { button, state, x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
859 if self.route(&ev, false) || self.needs_rebuild {
860 self.needs_rebuild = true;
861 *needs_rebuild = true;
862 }
863 None
864 }
865
866 fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
867 let ev = Event::MouseWheel { delta: delta.clone(), x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
868 if self.route(&ev, false) || self.needs_rebuild {
869 self.needs_rebuild = true;
870 *needs_rebuild = true;
871 }
872 }
873
874 fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
875 if event.state == ElementState::Pressed && !event.repeat {
876 if event.ctrl {
877 if let Key::Character(ref c) = event.logical_key {
878 match c.as_str() {
879 "q" => return Some(Message::Exit),
880 "r" => {
881 self.refresh();
882 *needs_rebuild = true;
883 return None;
884 }
885 _ => {}
886 }
887 }
888 }
889 match event.logical_key {
890 Key::Named(NamedKey::F5) => {
891 self.refresh();
892 *needs_rebuild = true;
893 return None;
894 }
895 Key::Named(NamedKey::Escape) if self.picking() || self.ui_context[self.search].editing => {
896 self.results.clear();
897 self.ui_context.unfocus_id(self.search.id());
898 self.needs_rebuild = true;
899 *needs_rebuild = true;
900 return None;
901 }
902 // The box's own edit mode, not `focused(&ctx)`: a click
903 // focuses through the thread-local registry, which the
904 // UiContext's focused_widget never learns of.
905 Key::Named(NamedKey::Enter) if self.ui_context[self.search].editing => {
906 self.submit_search();
907 *needs_rebuild = true;
908 return None;
909 }
910 _ => {}
911 }
912 }
913 // KeyInput short-circuits: the router hands keys to the focused
914 // widget on every propagate call.
915 let ev = Event::KeyInput(event.clone());
916 if self.route(&ev, true) || self.needs_rebuild {
917 self.needs_rebuild = true;
918 *needs_rebuild = true;
919 }
920 None
921 }
922 }
923
924 fn main() {
925 env_logger::init();
926 cce_ui::engine::run::<WeatherApp>();
927 }
928
929 #[cfg(test)]
930 mod tests {
931 use super::*;
932
933 #[test]
934 fn hour_index_finds_the_current_hour() {
935 let times: Vec<String> = (0..24).map(|h| format!("2026-10-05T{h:02}:00")).collect();
936 assert_eq!(hour_index(×, "2026-10-05T14:15"), 14);
937 assert_eq!(hour_index(×, "2026-10-05T00:00"), 0);
938 // Past the series: fall back to the start rather than panic.
939 assert_eq!(hour_index(×, "2026-10-06T03:00"), 0);
940 }
941
942 #[test]
943 fn compass_points() {
944 assert_eq!(compass(0.0), "N");
945 assert_eq!(compass(350.0), "N");
946 assert_eq!(compass(225.0), "SW");
947 assert_eq!(compass(-90.0), "W");
948 }
949
950 #[test]
951 fn hour_labels_follow_units() {
952 let t = parse_local("2026-10-05T15:00").unwrap();
953 assert_eq!(hour_label(&t, Units::Metric), "15");
954 assert_eq!(hour_label(&t, Units::Imperial), "3pm");
955 let m = parse_local("2026-10-05T00:00").unwrap();
956 assert_eq!(hour_label(&m, Units::Imperial), "12am");
957 }
958 }