clock
自分で再描画を要求するストップウォッチと、後片付けをするエフェクト。
ここでは再描画が明示的です。分かっておく価値があるのはそこです。egui は誰かが頼んだときにしか描かないので、止まっている時計は 1 秒に 1 フレームを頼み、動いているストップウォッチは次のフレームをすぐ頼みます。アプリのほかの部分は何も知らなくてよく、どちらも動いていなければ、アプリはコストゼロです。
show ticker のチェックボックスは子をマウントし、アンマウントします。このサンプルの本題はそこです。その子の use_effect はクロージャを返し、それがクリーンアップです。子が消えたときに走ります。消えたことを見つけるのはパス終わりの掃除です。クリーンアップは保存されるので'static で、ログを借りられません。だから代わりに Dispatch で報告します。アンマウントされるコンポーネントがアプリのほかの部分に何かを伝えるときは、だいたいこの形になります。
時刻は ui.input(|i| i.time) から取っています。egui が数えている、アプリ開始からの秒数です。std::time::Instant はどこにも出てきません。wasm32-unknown-unknown で panic するからです。壁時計の時刻を web_time 経由にしているのも同じ理由です。ブラウザでも動かすアプリなら覚えておく価値のある、移植性の落とし穴です。
自分で動かす
sh
cargo run -p clock
trunk serve --config examples/clock/Trunk.tomlソースは examples/clock/src/lib.rs です。
rust
use std::time::Duration;
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
/// The stopwatch. `started_at` is a reading of `i.time`, not a wall-clock
/// instant, so it survives being suspended and works the same in a browser.
#[derive(Default)]
pub struct Stopwatch {
started_at: Option<f64>,
accumulated: f64,
laps: Vec<f64>,
}
impl Stopwatch {
pub fn running(&self) -> bool {
self.started_at.is_some()
}
pub fn elapsed(&self, now: f64) -> f64 {
self.accumulated + self.started_at.map_or(0.0, |start| now - start)
}
pub fn toggle(&mut self, now: f64) {
match self.started_at.take() {
Some(start) => self.accumulated += now - start,
None => self.started_at = Some(now),
}
}
pub fn lap(&mut self, now: f64) {
self.laps.push(self.elapsed(now));
}
}
/// Seconds since midnight UTC, from the system clock.
///
/// `web_time::SystemTime` rather than `std::time::SystemTime`: the standard one
/// panics on `wasm32-unknown-unknown`. There is no time zone here because that
/// would mean a time zone database; UTC is the honest label.
pub fn utc_now() -> u64 {
web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.map_or(0, |since_epoch| since_epoch.as_secs() % 86_400)
}
/// `HH:MM:SS`, by hand. A date library would be a big dependency for one line.
pub fn clock_text(seconds: u64) -> String {
format!(
"{:02}:{:02}:{:02}",
seconds / 3600,
(seconds / 60) % 60,
seconds % 60
)
}
/// `MM:SS.CC`.
pub fn stopwatch_text(elapsed: f64) -> String {
let cs = (elapsed.max(0.0) * 100.0) as u64;
format!("{:02}:{:02}.{:02}", cs / 6000, (cs / 100) % 60, cs % 100)
}
/// `now` is the wall clock to show, in seconds since midnight UTC. `None`, the
/// default, reads the system clock; a test passes a fixed value so its picture
/// does not change every second.
#[component]
pub fn App(cx: &mut Cx, now: Option<u64>) {
let mut watch = use_state(cx, Stopwatch::default);
let mut ticker = use_state(cx, || true);
// The log lives in a reducer because the effect's cleanup writes to it, and
// a cleanup is `'static`: it can hold a `Dispatch` but not a borrow.
let (log, dispatch) = use_reducer(
cx,
|lines: &mut Vec<String>, line: String| lines.push(line),
Vec::new,
);
let time = cx.ui().input(|i| i.time);
let running = watch.running();
let elapsed = watch.elapsed(time);
// Ask for exactly the frames that are needed: every one while the
// stopwatch runs, one a second otherwise so the clock stays right.
let ctx = cx.ctx().clone();
if running {
ctx.request_repaint();
} else {
ctx.request_repaint_after(Duration::from_secs(1));
}
// Formatting every lap on every frame would be wasted work at 60fps, and
// the list only changes when a lap is added or the watch is reset.
let laps = use_memo(cx, watch.laps.len(), || {
let total = watch.laps.len();
watch
.laps
.iter()
.enumerate()
.rev()
.map(|(i, lap)| format!("lap {} {}", i + 1, stopwatch_text(*lap)))
.take(total)
.collect::<Vec<String>>()
});
let lines: Vec<(usize, String)> = log.iter().cloned().enumerate().rev().take(4).collect();
rsx! {
<View direction="column" gap={8} p={12} grow={1.0}>
<Text size={22.0} strong>"clock"</Text>
<Text size={32.0} strong>{clock_text(now.unwrap_or_else(utc_now))}</Text>
<Text>"UTC"</Text>
<Separator/>
<Text size={28.0}>{stopwatch_text(elapsed)}</Text>
<View direction="row" gap={8}>
<Button on_click={|| watch.toggle(time)}>
{if running { "stop" } else { "start" }}
</Button>
<Button enabled={running} on_click={|| watch.lap(time)}>"lap"</Button>
<Button on_click={|| *watch = Stopwatch::default()}>"reset"</Button>
</View>
for (i, lap) in laps.iter().enumerate() {
<Text key={i}>{lap.as_str()}</Text>
}
<Separator/>
<Checkbox bind={ticker.bind()} label="show ticker"/>
if *ticker {
<Ticker log={dispatch.clone()}/>
}
for (i, line) in lines.iter() {
<Text key={i}>{line.as_str()}</Text>
}
</View>
}
}
/// Mounted and unmounted by the checkbox above, and says so both times.
///
/// The effect's deps are `()`, so the body runs once when this component first
/// appears. What it returns is the cleanup, which runs when the component stops
/// being drawn and the pass-end sweep notices. The message lands on the next
/// visit to the reducer, so "unmounted" appears one frame later.
#[component]
fn Ticker(cx: &mut Cx, log: Dispatch<String>) {
let mounted = log.clone();
let unmounted = log.clone();
use_effect(cx, (), move || {
mounted.send(String::from("ticker mounted"));
move || unmounted.send(String::from("ticker unmounted"))
});
rsx! { <Text>"ticker: mounted"</Text> }
}