fetch
use_future がリクエストを走らせ、一番近い <Suspense> がスピナーを描く。
コンポーネントは 2 つです。App が、選ばれている都市と試行回数のカウンタを持ちます。Weather はその両方を deps にしてuse_future を呼びます。だから別の都市を選べば新しいリクエストになり、refresh はカウンタを進めて同じものをもう一度頼みます。リクエストは、座標がまだ借りられるその場で組み立てます。返る future はそれを所有し、'static です。返ってくる答えは本物です。Open-Meteo の 3 日分の天気で、鍵は要りません。serde は本文から、画面に出すフィールドだけを読みます。
Weather は保留中の表示を一切描きません。let Poll::Ready(response) = response else { return; }; で終わり、待つ仕事は上の <Suspense> に任せます。そちらは下にある保留中のfuture を数え、代わりに fallback を描きます。React の throw に対する Rust の答えがこれで、値が必要な葉は単にやめられる、ということです。
リクエストが失敗する 3 つの道 ― そもそも届かなかった、サーバが拒んだ、本文が期待した JSON でなかった ― は、画面上の 1 行に落ち着きます。parse はそのためのものです。同じコードが両方で走ります。ehttp はネイティブでは専用スレッドのureq を、Web ではブラウザの fetch API を使いますが、その違いは書く型には届きません。上の埋め込みは wasm ビルドなので、取得先はブラウザの通常のクロスオリジンの規則に従います。古い結果の扱いも含めた長い話は非同期 にあります。
自分で動かす
cargo run -p fetch
trunk serve --config examples/fetch/Trunk.tomlソースは examples/fetch/src/lib.rs です。
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
use serde::Deserialize;
/// The cities the buttons offer, with the coordinates the API wants.
pub const CITIES: [(&str, f32, f32); 4] = [
("Tokyo", 35.68, 139.69),
("Paris", 48.85, 2.35),
("Reykjavík", 64.15, -21.94),
("Sydney", -33.87, 151.21),
];
/// What the app asks Open-Meteo for: the weather now, and three days of it.
pub fn url(latitude: f32, longitude: f32) -> String {
format!(
"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}\
¤t=temperature_2m,wind_speed_10m,weather_code\
&daily=weather_code,temperature_2m_max,temperature_2m_min&forecast_days=3\
&timezone=auto"
)
}
/// The answer, as much of it as the screen shows.
///
/// `serde` reads the fields it is asked for and ignores the rest, so this is
/// the shape of the screen rather than the shape of the API.
#[derive(Clone, Debug, Deserialize)]
pub struct Forecast {
pub timezone: String,
pub current: Current,
pub daily: Daily,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Current {
pub temperature_2m: f32,
pub wind_speed_10m: f32,
pub weather_code: u8,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Daily {
pub time: Vec<String>,
pub weather_code: Vec<u8>,
pub temperature_2m_max: Vec<f32>,
pub temperature_2m_min: Vec<f32>,
}
/// WMO weather code 0..99, in words. The codes the API can answer with are
/// grouped the way a forecast reads them out.
pub fn sky(code: u8) -> &'static str {
match code {
0 => "clear",
1..=2 => "mostly clear",
3 => "overcast",
45..=48 => "fog",
51..=57 => "drizzle",
61..=67 => "rain",
71..=77 => "snow",
80..=82 => "showers",
85..=86 => "snow showers",
95..=99 => "thunderstorm",
_ => "weather",
}
}
#[component]
pub fn App(cx: &mut Cx) {
let mut city = use_state(cx, || 0usize);
let mut attempt = use_state(cx, || 0u32);
let picked = *city;
let (_, latitude, longitude) = CITIES[picked];
rsx! {
<View direction="column" gap={8} p={12} grow={1.0} w="100%">
// `wrap` on the row, so a narrow window puts the buttons on two
// lines instead of running them off the right edge.
<View direction="row" wrap gap={8} align="center" w="100%">
for (i, (name, _, _)) in CITIES.iter().enumerate() {
<City key={name} name={name} picked={i == picked} on_click={|| *city = i}/>
}
// `attempt` is part of the deps below, so bumping it asks for
// the same city again.
<Button on_click={|| *attempt += 1}>"refresh"</Button>
</View>
// What is being asked for. `wrap` needs a width to wrap inside,
// which is what `w="100%"` gives it.
<Text w="100%" wrap size={11.0}>{url(latitude, longitude)}</Text>
<Suspense fallback={view(|cx| { cx.ui().spinner(); })}>
<Weather city={picked} attempt={*attempt}/>
</Suspense>
</View>
}
}
/// One city button: on when it is the one being shown.
///
/// `egui-reactor-elements` has no toggle, so this is the escape hatch one leaf
/// deep — the same shape every component in these examples has: take a
/// `style`, draw one thing, report the click.
#[component]
fn City(
cx: &mut Cx,
#[prop(default)] style: ItemStyle,
name: &str,
picked: bool,
#[event] on_click: (),
) {
let clicked = cx.leaf(&style.shrink(0.0), |ui| {
// A hand-written leaf sets the wrap mode itself: measured in a
// zero-width `Ui` on its first draw, a wrapping widget would report
// one character wide and stay that way.
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
ui.selectable_label(picked, name).clicked()
});
if clicked {
on_click.emit(());
}
}
/// Waits for one request. It never draws a pending state of its own: the
/// `let`-`else` hands that job to the `<Suspense>` above it.
#[component]
fn Weather(cx: &mut Cx, city: usize, attempt: u32) {
let (name, latitude, longitude) = CITIES[city.min(CITIES.len() - 1)];
let response = use_future(cx, (city, attempt), || {
// Built here, where the coordinates are still borrowable; the future
// itself owns the request and is `'static`.
let request = ehttp::Request::get(url(latitude, longitude));
async move { ehttp::fetch_async(request).await }
});
let Poll::Ready(response) = response else {
return;
};
rsx! {
<View direction="column" gap={8} w="100%" grow={1.0} min_h={0.0}>
match parse(response) {
Ok(forecast) => {
<Text size={22.0} strong>
{format!("{name} {:.0}°C", forecast.current.temperature_2m)}
</Text>
<Text>
{format!(
"{}, wind {:.0} km/h, local time {}",
sky(forecast.current.weather_code),
forecast.current.wind_speed_10m,
forecast.timezone,
)}
</Text>
<Separator/>
<ScrollArea grow={1.0} h={0.0}>
<View direction="column" gap={4} w="100%">
for (i, day) in forecast.daily.time.iter().enumerate() {
<View key={day} direction="row" gap={8} w="100%">
<Text w={100.0}>{day.as_str()}</Text>
<Text w={110.0}>{sky(forecast.daily.weather_code[i])}</Text>
<Text>
{format!(
"{:.0}° / {:.0}°",
forecast.daily.temperature_2m_max[i],
forecast.daily.temperature_2m_min[i],
)}
</Text>
</View>
}
</View>
</ScrollArea>
}
Err(err) => { <Text w="100%" wrap>{format!("{name}: {err}")}</Text> }
}
</View>
}
}
/// The response as a [`Forecast`], or why it is not one.
///
/// Three ways to fail, and they are all the same to the screen: the request
/// never arrived, the server said no, or the body was not the JSON expected.
pub fn parse(response: &Result<ehttp::Response, String>) -> Result<Forecast, String> {
let response = response.as_ref().map_err(String::clone)?;
if !response.ok {
return Err(format!("{} {}", response.status, response.status_text));
}
let body = response.text().ok_or("the body is not text")?;
serde_json::from_str(body).map_err(|err| err.to_string())
}