~/
rust-munich
·
crux + gpui
# Step 3 — add weather The counter works. Now: every time it crosses a multiple of 10, fetch Munich weather and show it. This step touches both sides. We grow the **core** types first, then walk over to the **shell** to perform the new I/O. --- ## Growing the core types Three small additions to the data we already have: ```rust // NEW — what a weather sample looks like #[derive(Debug, Clone, PartialEq)] pub struct WeatherSnapshot { pub temp_c: f32, pub condition: WeatherCondition, /// Wall-clock time when the shell received the response. The shell /// stamps it; the core only compares it for the refresh throttle. pub fetched_at: std::time::SystemTime, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WeatherCondition { Clear, Cloudy, Fog, Rain, Snow, Thunder, Unknown } #[derive(Debug, Clone, PartialEq)] pub enum WeatherError { Network(String), Parse(String) } ``` Then we extend the three types from Step 1: ```rust pub enum Event { Increment, Decrement, WeatherLoaded(Result
), // NEW } pub struct Model { pub count: i32, pub last_weather: Option
, // NEW pub weather_in_flight: bool, // NEW } pub struct ViewModel { pub count: i32, pub weather: Option
, // NEW pub weather_in_flight: bool, // NEW } ``` Note the asymmetry: `Model.last_weather` and `ViewModel.weather` are *not* the same name. The view chooses what the UI is allowed to call it. --- ## Growing the effect We add a custom **operation** — a request the shell will fulfil: ```rust #[derive(Debug, Clone, PartialEq)] pub struct FetchMunichWeather; impl Operation for FetchMunichWeather { type Output = Result
; } #[effect] pub enum Effect { Render(RenderOperation), FetchMunichWeather(FetchMunichWeather), // NEW } ``` `type Output` tells crux exactly what the shell must send back when it's done. Strongly typed end-to-end. --- ## The multiple-of-10 rule A small helper, used by both `Increment` and `Decrement`: ```rust const WEATHER_REFRESH_AFTER: Duration = Duration::from_secs(10); fn after_count_change(model: &mut Model) -> Command
{ use crux_core::render::render; let render_cmd = render(); let should_fetch = model.count != 0 && model.count % 10 == 0 && !model.weather_in_flight && weather_is_stale(model.last_weather.as_ref()); if should_fetch { model.weather_in_flight = true; let fetch_cmd = Command::request_from_shell(FetchMunichWeather) .then_send(Event::WeatherLoaded); Command::all([render_cmd, fetch_cmd]) } else { render_cmd } } fn weather_is_stale(last: Option<&WeatherSnapshot>) -> bool { match last { None => true, Some(snap) => SystemTime::now() .duration_since(snap.fetched_at) .map(|elapsed| elapsed >= WEATHER_REFRESH_AFTER) .unwrap_or(true), } } ``` - `request_from_shell(...)` — ask the shell to perform an effect. - `.then_send(Event::WeatherLoaded)` — when the result comes back, wrap it in this event and feed it through `update` again. - `Command::all(...)` — emit several commands at once. - `weather_in_flight` is the in-flight guard that stops rapid clicks from firing several requests concurrently. - `weather_is_stale(...)` is the time-based throttle. With both guards in place we never hammer the API: at most one request in flight, and never more than once every 10 seconds. --- ## The new `update` ```rust fn update(&self, event: Event, model: &mut Model) -> Command
{ use crux_core::render::render; match event { Event::Increment => { model.count += 1; after_count_change(model) } Event::Decrement => { model.count -= 1; after_count_change(model) } Event::WeatherLoaded(result) => { model.weather_in_flight = false; if let Ok(snap) = result { model.last_weather = Some(snap); } render() } } } ``` The async result re-enters the system as an ordinary event. No special path. --- ## Two more boring tests ```rust #[test] fn crossing_into_multiple_of_ten_requests_weather_fetch() { let app = CounterApp; let mut model = Model { count: 9, ..Default::default() }; let mut cmd = app.update(Event::Increment, &mut model); let effects: Vec
= cmd.effects().collect(); assert!(effects.iter().any(|e| matches!(e, Effect::FetchMunichWeather(_)))); assert!(model.weather_in_flight); assert_eq!(model.count, 10); } #[test] fn fetch_is_suppressed_when_already_in_flight() { let app = CounterApp; let mut model = Model { count: 9, weather_in_flight: true, ..Default::default() }; let mut cmd = app.update(Event::Increment, &mut model); let effects: Vec
= cmd.effects().collect(); assert!(!effects.iter().any(|e| matches!(e, Effect::FetchMunichWeather(_)))); } ``` We never opened a socket. The effect is data; we just inspected it. --- ## The shell — a new `handle_effects` arm Back in `shell-gpui/src/main.rs`, we add the second arm to the match we wrote in Step 2: ```rust Effect::FetchMunichWeather(mut req) => { let core_for_async = self.core.clone(); cx.spawn(async move |weak, acx| { let result = acx .background_executor() .spawn(async move { weather::fetch_munich_weather_blocking() }) .await; let _ = weak.update(acx, |shell, cx| { match core_for_async.resolve(&mut req, result) { Ok(further) => shell.handle_effects(further, cx), Err(e) => eprintln!("resolve failed: {e:?}"), } }); }).detach(); } ``` What's happening: 1. `cx.spawn` schedules a task tied to this entity. 2. `background_executor().spawn(...)` runs the blocking `ureq` call off the main thread. 3. `core.resolve(&mut req, result)` feeds the result back into the core as `Event::WeatherLoaded(...)` and returns the *next* effects (typically `Render`). 4. We recurse to drain those, too. No `tokio`. No global runtime. gpui's own scheduler. --- ## The HTTP performer A new file: `shell-gpui/src/weather.rs`. Boring blocking HTTP: ```rust const ENDPOINT: &str = "https://api.open-meteo.com/v1/forecast?\ latitude=48.1374&longitude=11.5755\ ¤t=temperature_2m,weather_code\ &timezone=Europe%2FBerlin"; pub fn fetch_munich_weather_blocking() -> Result
{ let body = ureq::get(ENDPOINT) .timeout(std::time::Duration::from_secs(8)) .call()? .into_string()?; let parsed: OpenMeteoResponse = serde_json::from_str(&body)?; Ok(WeatherSnapshot { /* … */ }) } ``` The core never sees `ureq`. The shell never invents business rules. That's the whole point of the split. --- ## A weather card in the render tree Finally, extend the render to show the new state: ```rust let weather_block = match (&self.view_model.weather, self.view_model.weather_in_flight) { (_, true) => div().child("fetching Munich weather…"), (Some(w), false) => /* emoji + Tag chip + timestamp */, (None, false) => div().child("(reach a multiple of 10 to fetch weather)"), }; ``` Three states, three little subtrees. gpui handles the diff. --- ## Run it ```bash cargo run -p shell-gpui ``` Click `+` until you hit 10. The weather card fades in. Done.