Skip to main content

zest_widget/widget/
weather_icons.rs

1//! Compact weather-condition glyphs drawn from renderer primitives.
2//!
3//! Each [`WeatherCondition`] maps to a small vector glyph that fills its
4//! arranged rect, centered on a shared square anchor so a row of mixed
5//! conditions lines up on one visual center.
6
7use embedded_graphics::{pixelcolor::Rgb565, prelude::*, primitives::Rectangle};
8use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, Widget};
9use zest_theme::Theme;
10
11mod cloudy;
12mod fog;
13mod rain;
14mod showers;
15mod snow;
16mod sunny;
17mod thunderstorm;
18mod unknown;
19mod windy;
20
21/// Centered square drawing anchor for a glyph within `rect`.
22///
23/// Returns `(cx, cy, size)`: `(cx, cy)` is the rect's true center — computed
24/// from its actual width/height, so a glyph stays centered in a
25/// wider-than-tall (or taller-than-wide) rect — and `size` is the largest
26/// square that fits. Every glyph anchors here and centers on `(cx, cy)`, so a
27/// row of mixed conditions shares one visual center.
28fn anchor(rect: Rectangle) -> (i32, i32, i32) {
29    let size = rect.size.width.min(rect.size.height) as i32;
30    let cx = rect.top_left.x + rect.size.width as i32 / 2;
31    let cy = rect.top_left.y + rect.size.height as i32 / 2;
32    (cx, cy, size)
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38    use embedded_graphics::{mono_font::MonoFont, text::Alignment};
39
40    #[derive(Default)]
41    struct Bbox {
42        minx: i32,
43        miny: i32,
44        maxx: i32,
45        maxy: i32,
46        any: bool,
47    }
48    impl Bbox {
49        fn add(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
50            if !self.any {
51                (self.minx, self.miny, self.maxx, self.maxy, self.any) = (x0, y0, x1, y1, true);
52            } else {
53                self.minx = self.minx.min(x0);
54                self.miny = self.miny.min(y0);
55                self.maxx = self.maxx.max(x1);
56                self.maxy = self.maxy.max(y1);
57            }
58        }
59        fn cx(&self) -> i32 {
60            (self.minx + self.maxx) / 2
61        }
62        fn cy(&self) -> i32 {
63            (self.miny + self.maxy) / 2
64        }
65    }
66    struct Rec(Bbox);
67    impl Renderer<Rgb565> for Rec {
68        fn fill_rect(&mut self, r: Rectangle, _: Rgb565) -> Result<(), RenderError> {
69            self.0.add(
70                r.top_left.x,
71                r.top_left.y,
72                r.top_left.x + r.size.width as i32,
73                r.top_left.y + r.size.height as i32,
74            );
75            Ok(())
76        }
77        fn stroke_rect(&mut self, r: Rectangle, c: Rgb565) -> Result<(), RenderError> {
78            self.fill_rect(r, c)
79        }
80        fn fill_circle(&mut self, ctr: Point, radius: u32, _: Rgb565) -> Result<(), RenderError> {
81            let r = radius as i32;
82            self.0.add(ctr.x - r, ctr.y - r, ctr.x + r, ctr.y + r);
83            Ok(())
84        }
85        fn stroke_line(
86            &mut self,
87            a: Point,
88            b: Point,
89            _: Rgb565,
90            w: u32,
91        ) -> Result<(), RenderError> {
92            let w = w as i32;
93            self.0.add(
94                a.x.min(b.x) - w,
95                a.y.min(b.y) - w,
96                a.x.max(b.x) + w,
97                a.y.max(b.y) + w,
98            );
99            Ok(())
100        }
101        fn draw_text(
102            &mut self,
103            text: &str,
104            pos: Point,
105            font: &MonoFont,
106            _: Rgb565,
107            _: Alignment,
108        ) -> Result<(), RenderError> {
109            let half_w = text.chars().count() as i32 * font.character_size.width as i32 / 2;
110            self.0.add(
111                pos.x - half_w,
112                pos.y - font.baseline as i32,
113                pos.x + half_w,
114                pos.y,
115            );
116            Ok(())
117        }
118    }
119
120    fn bb(f: impl FnOnce(&mut Rec)) -> Bbox {
121        let mut r = Rec(Bbox::default());
122        f(&mut r);
123        r.0
124    }
125
126    /// Every glyph must center on the shared anchor and stay within the
127    /// centered square, in both a square and a wide rect. Guards against the
128    /// left-shift (non-square rects) and inconsistent-baseline regressions.
129    #[test]
130    fn glyphs_centered() {
131        for rect in [
132            Rectangle::new(Point::new(0, 0), Size::new(200, 80)),
133            Rectangle::new(Point::new(0, 0), Size::new(80, 80)),
134        ] {
135            let (cx, cy, size) = anchor(rect);
136            let half = size / 2;
137            let check = |name: &str, b: Bbox, tol: i32| {
138                assert!((b.cx() - cx).abs() <= 2, "{name}: cx={} want {cx}", b.cx());
139                assert!(
140                    (b.cy() - cy).abs() <= tol,
141                    "{name}: cy={} want {cy}±{tol}",
142                    b.cy()
143                );
144                assert!(
145                    b.minx >= cx - half - 2
146                        && b.maxx <= cx + half + 2
147                        && b.miny >= cy - half - 2
148                        && b.maxy <= cy + half + 2,
149                    "{name}: bbox x[{}..{}] y[{}..{}] spills the centered square",
150                    b.minx,
151                    b.maxx,
152                    b.miny,
153                    b.maxy
154                );
155            };
156            // Single-element glyphs share the anchor tightly.
157            check("sunny", bb(|r| sunny::draw(r, rect).unwrap()), 4);
158            check("cloudy", bb(|r| cloudy::draw(r, rect).unwrap()), 4);
159            check("rain", bb(|r| rain::draw(r, rect).unwrap()), 4);
160            check("snow", bb(|r| snow::draw(r, rect).unwrap()), 4);
161            check("thunder", bb(|r| thunderstorm::draw(r, rect).unwrap()), 4);
162            check("fog", bb(|r| fog::draw(r, rect).unwrap()), 4);
163            check("windy", bb(|r| windy::draw(r, rect).unwrap()), 4);
164            check("unknown", bb(|r| unknown::draw(r, rect).unwrap()), 4);
165            // Sun+cloud compositions are intentionally a touch asymmetric, but
166            // must still center reasonably and not spill the square.
167            check(
168                "partly",
169                bb(|r| {
170                    sunny::draw_small(r, rect).unwrap();
171                    let (cx, cy, size) = anchor(rect);
172                    cloudy::draw_at(r, Point::new(cx, cy + size / 12), size).unwrap();
173                }),
174                8,
175            );
176            check("showers", bb(|r| showers::draw(r, rect).unwrap()), 8);
177        }
178    }
179}
180
181/// Weather condition selector.
182#[derive(Debug, Clone, Copy, PartialEq)]
183pub enum WeatherCondition {
184    /// Clear skies.
185    Sunny,
186    /// Mostly cloudy.
187    Cloudy,
188    /// Steady rain.
189    Rain,
190    /// Sun + cloud (variable).
191    PartlyCloudy,
192    /// Sun + cloud + light precipitation.
193    Showers,
194    /// Cloud + lightning.
195    Thunderstorm,
196    /// Cloud + flakes.
197    Snow,
198    /// Three horizontal lines.
199    Fog,
200    /// Three lines with hooked ends.
201    Windy,
202    /// Filled circle with `?`.
203    Unknown,
204}
205
206/// Compact weather glyph that fills its arranged rect.
207pub struct WeatherIcon {
208    rect: Rectangle,
209    condition: WeatherCondition,
210    width: Length,
211    height: Length,
212}
213
214impl WeatherIcon {
215    /// Create a new icon for `condition`. Position and size are
216    /// assigned by the parent via `arrange`; use `.width(N).height(N)`
217    /// to lock a fixed size.
218    pub fn new(condition: WeatherCondition) -> Self {
219        Self {
220            rect: Rectangle::zero(),
221            condition,
222            width: Length::Fill,
223            height: Length::Fill,
224        }
225    }
226
227    /// Width sizing intent.
228    #[must_use]
229    pub fn width(mut self, width: impl Into<Length>) -> Self {
230        self.width = width.into();
231        self
232    }
233
234    /// Height sizing intent.
235    #[must_use]
236    pub fn height(mut self, height: impl Into<Length>) -> Self {
237        self.height = height.into();
238        self
239    }
240}
241
242impl<M: Clone> Widget<Rgb565, M> for WeatherIcon {
243    fn draw<'t>(
244        &self,
245        renderer: &mut dyn Renderer<Rgb565>,
246        _theme: &Theme<'t, Rgb565>,
247    ) -> Result<(), RenderError> {
248        match self.condition {
249            WeatherCondition::Sunny => sunny::draw(renderer, self.rect),
250            WeatherCondition::Cloudy => cloudy::draw(renderer, self.rect),
251            WeatherCondition::Rain => rain::draw(renderer, self.rect),
252            WeatherCondition::PartlyCloudy => {
253                sunny::draw_small(renderer, self.rect)?;
254                // Drop the cloud slightly so it balances the up-left peek sun
255                // and the whole glyph centers on the shared anchor.
256                let (cx, cy, size) = anchor(self.rect);
257                cloudy::draw_at(renderer, Point::new(cx, cy + size / 12), size)
258            }
259            WeatherCondition::Showers => showers::draw(renderer, self.rect),
260            WeatherCondition::Thunderstorm => thunderstorm::draw(renderer, self.rect),
261            WeatherCondition::Snow => snow::draw(renderer, self.rect),
262            WeatherCondition::Fog => fog::draw(renderer, self.rect),
263            WeatherCondition::Windy => windy::draw(renderer, self.rect),
264            WeatherCondition::Unknown => unknown::draw(renderer, self.rect),
265        }
266    }
267
268    fn measure(&mut self, constraints: Constraints) -> Size {
269        let w = self
270            .width
271            .resolve(constraints.max.width, constraints.max.width);
272        let h = self
273            .height
274            .resolve(constraints.max.height, constraints.max.height);
275        constraints.clamp(Size::new(w, h))
276    }
277
278    fn preferred_size(&self) -> (Length, Length) {
279        (self.width, self.height)
280    }
281
282    fn arrange(&mut self, rect: Rectangle) {
283        self.rect = rect;
284    }
285
286    fn rect(&self) -> Rectangle {
287        self.rect
288    }
289
290    fn handle_touch(&mut self, _: Point, _: TouchPhase) -> Option<M> {
291        None
292    }
293}