git.lucas.co / cce-weather
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(&times, "2026-10-05T14:15"), 14);
937         assert_eq!(hour_index(&times, "2026-10-05T00:00"), 0);
938         // Past the series: fall back to the start rather than panic.
939         assert_eq!(hour_index(&times, "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 }