//! Loaders: eleven ways to say "wait", over one job the app owns. The job
//! is a number from 0 to 1 that a clock moves while it runs; three loaders
//! are *determinate* and draw that number — a pie, a ring, a bar — and eight
//! are *indeterminate* and draw only the time — an arc, a chasing pie,
//! spokes, dots, a sweeping bar, a bar going back and forth, a skeleton,
//! a rainbow.
//!
//! What it shows of composing an app:
//!
//! - **The clock is the model's.** `view` is a function of `phase` and
//! `progress`; `step` is the only thing that moves them. A window steps
//! by the wall clock and a headless drive by hand, so the drive is exact.
//! - **A frame is asked for only while something moves** (`request_frame`).
//! Idle, paused or done, the window draws nothing and costs nothing.
//! - **The round loaders are `path`s** (`docs/adr/0040-a-path-is-a-mask-in-the-atlas.md`):
//! a pie is `Path::sector` with its sweep the progress, a ring the same
//! with an inner radius, the arc an open stroked `arc_to`. The fills'
//! shape changes every frame, which is the case the ADR's decision 8 is
//! for — each leaves the atlas for a texture of its own and is
//! rasterized per frame, where the track under it, which never changes,
//! is one mask in the atlas for good. The arc only *turns*, and that is
//! `rotate` (`docs/adr/0041-a-mask-turns-about-its-centre.md`): one
//! mask, in the atlas, turned by the quad that draws it. The spokes are `line`s and the rest are boxes:
//! a loader that is only rectangles needs no path.
//! - **The rainbow is a `gradient`** (`docs/adr/0042-a-gradient-is-an-image-the-core-paints.md`):
//! two turns of the spectrum on a box twice its track's length, slid
//! under the track's clip. A gradient does not tween, and this one does
//! not have to: what moves is the box, so the colours are rasterized
//! once, into one strip in the atlas, and every frame after is the same
//! image quad somewhere else.
//! - **Reduced motion is honoured** (`env.system.motion`): the
//! indeterminate loaders stand still and the determinate ones still
//! report, since progress is information and spinning is not.
//!
//! Run: cargo run -p kui-native --example loaders [-- --headless]
use std::f32::consts::TAU;
use std::time::Instant;
use kui_devtools::{Drive, Example};
use kui_native::widgets;
use kui_native::{
Align, App, Color, Core, FloatConfig, Gradient, Message, NodeSpec, Path, QuadKind, Role, Side,
Stroke, TextStyle, Ui, UiEvent, Vec2,
};
/// How long the job takes at speed 1, in seconds.
const JOB_SECS: f32 = 4.0;
/// A loader's stage: the square its shapes are drawn in, and its centre
/// and radius.
const STAGE: f32 = 96.0;
const C: f32 = STAGE / 2.0;
const R: f32 = 34.0;
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
/// Start, pause, resume or run again: whichever the state offers.
Toggle,
Reset,
/// The speed slider's tag; the value rides on the `change` event.
Speed,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum State {
Idle,
Running,
Paused,
Done,
}
struct Loaders {
state: State,
/// The job, 0 to 1.
progress: f32,
/// Seconds the loaders have been moving: what the indeterminate ones
/// draw. It stops when the job does, so a paused spinner is paused.
phase: f32,
speed: f32,
/// The wall clock's last reading, for a window; `None` under a drive,
/// which calls `step` itself.
last: Option<Instant>,
manual: bool,
/// Whether the first frame has given the job's button the keyboard.
focused: bool,
}
impl Loaders {
fn new() -> Self {
Self {
state: State::Idle,
progress: 0.0,
phase: 0.0,
speed: 1.0,
last: None,
manual: false,
focused: false,
}
}
/// Moves the job and the loaders `dt` seconds on. The one place the
/// model's time changes.
fn step(&mut self, dt: f32) {
if self.state != State::Running {
return;
}
self.phase += dt;
self.progress = (self.progress + dt * self.speed / JOB_SECS).min(1.0);
if self.progress >= 1.0 {
self.state = State::Done;
}
}
/// What the one button does next, which is also its label.
fn action(&self) -> &'static str {
match self.state {
State::Idle => "start",
State::Running => "pause",
State::Paused => "resume",
State::Done => "again",
}
}
}
impl App for Loaders {
fn view(&mut self, ui: &mut Ui<'_>) {
// A window's clock: the time since the last frame, while running.
if !self.manual {
let now = Instant::now();
let dt = self
.last
.map_or(0.0, |l| now.duration_since(l).as_secs_f32());
self.last = (self.state == State::Running).then_some(now);
// A frame after a long sleep is not a long step.
self.step(dt.min(0.1));
}
// Nothing else wakes the window for the next step.
if self.state == State::Running {
ui.request_frame();
}
let t = ui.theme();
let still = ui.env().system.motion.is_reduced();
// The time the indeterminate loaders draw: none, when the user
// asked for less motion.
let phase = if still { 0.0 } else { self.phase };
let p = self.progress;
let done = self.state == State::Done;
let tint = if done { t.success } else { t.accent };
let muted = TextStyle::new(12.0).color(t.muted);
// What a loader runs in: a tint of the text colour, so it shows
// on the card in either appearance.
let track = track_color(&t);
ui.with(
NodeSpec::column().fill().bg(t.bg).pad(24.0).gap(16.0),
|ui| {
// The job: its one button, a reset, the speed, what it says.
ui.with(NodeSpec::row().gap(12.0).cross_align(Align::Center), |ui| {
// The job's button is one node whatever it says — a label
// that changes would re-key it and drop the keyboard — and
// it has the focus from the first frame, so Space starts
// the job and pauses it.
let (theme, m) = (ui.theme(), ui.metrics());
widgets::button_with(
ui,
"job",
self.action(),
widgets::button_spec(&theme, &m).on_click(Msg::Toggle),
None,
);
if !self.focused {
self.focused = true;
let job = ui.child_key("job");
ui.focus(job);
}
widgets::button(ui, "reset", Msg::Reset);
ui.leaf(NodeSpec::row().width(12.0));
ui.text("speed", muted);
widgets::slider(ui, "speed", self.speed, 0.25, 3.0, 0.25, Msg::Speed);
ui.text(
&format!("{:.2}×", self.speed),
TextStyle::new(13.0).color(t.fg),
);
ui.leaf(NodeSpec::row().width(12.0));
let status = match self.state {
State::Idle => "idle".to_string(),
State::Running => format!("running · {:.0}%", p * 100.0),
State::Paused => format!("paused at {:.0}%", p * 100.0),
State::Done => "done".to_string(),
};
ui.with_keyed("status", NodeSpec::row(), |ui| {
ui.text(&status, TextStyle::new(13.0).color(t.fg));
});
});
ui.text("determinate: the job's progress", muted);
ui.with(NodeSpec::row().gap(12.0), |ui| {
// The pie: a disc for the track, and over it a sector from
// twelve o'clock whose sweep is the progress. A full turn
// is `sector`'s two half arcs, so 100% is a whole disc.
card(ui, "pie", &format!("pie, {:.0}%", p * 100.0), |ui| {
ui.path(
&Path::sector(C, C, R, 0.0, 0.0, 1.0),
NodeSpec::column().bg(track),
);
if p > 0.0 {
ui.path_keyed(
"fill",
&Path::sector(C, C, R, 0.0, -0.25, p),
NodeSpec::column().bg(tint).transition(200.0),
);
}
});
// The ring: the same sector with an inner radius, and the
// number in its hole.
card(ui, "ring", &format!("ring, {:.0}%", p * 100.0), |ui| {
ui.path(
&Path::sector(C, C, R, R - 9.0, 0.0, 1.0),
NodeSpec::column().bg(track),
);
if p > 0.0 {
ui.path_keyed(
"fill",
&Path::sector(C, C, R, R - 9.0, -0.25, p),
NodeSpec::column().bg(tint).transition(200.0),
);
}
ui.with(
NodeSpec::column()
.float(FloatConfig::parent().inside(Align::Center, Align::Center)),
|ui| {
ui.text(
&format!("{:.0}", p * 100.0),
TextStyle::new(16.0).color(t.fg),
);
},
);
});
// The bar: two boxes. No path in it.
card(ui, "bar", &format!("bar, {:.0}%", p * 100.0), |ui| {
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(
NodeSpec::row()
.size(STAGE - 12.0, 8.0)
.bg(track)
.radius(4.0)
.clip(),
|ui| {
ui.leaf(
NodeSpec::row()
.size((STAGE - 12.0) * p, 8.0)
.bg(tint)
.radius(4.0),
);
},
);
});
});
});
ui.text(
if still {
"indeterminate: only the time — standing still, as the system asked"
} else {
"indeterminate: only the time"
},
muted,
);
ui.with(NodeSpec::row().gap(12.0), |ui| {
// The arc: an open path, stroked and not filled, turning
// once a second — by `rotate`, about the circle's centre,
// so it is one shape and one mask at every angle and the
// quad that draws it carries the turn (ADR 0041).
card(ui, "arc", "arc spinner", |ui| {
let end = 0.3 * TAU;
let arc = Path::new()
.move_to(C + R, C)
.arc_to(R, R, 0.0, false, true, C + R * end.cos(), C + R * end.sin())
.stroked(Stroke::new(5.0, t.accent))
.pivot(C, C)
.rotated(phase);
ui.path_keyed("arc", &arc, NodeSpec::column());
});
// The chase: a pie whose head runs ahead of its tail and
// is caught, the sweep breathing between a sliver and
// three quarters while the whole turns.
card(ui, "chase", "chasing pie", |ui| {
let sweep = 0.08 + 0.67 * (0.5 - 0.5 * (phase * 0.5 * TAU).cos());
let from = phase * 0.75 - sweep * 0.5;
ui.path_keyed(
"chase",
&Path::sector(C, C, R, R - 12.0, from, sweep),
NodeSpec::column().bg(t.accent),
);
});
// The spokes: twelve lines, the brightest stepping round
// twelve times a second and the rest fading behind it.
card(ui, "spokes", "spokes spinner", |ui| {
let head = (phase * 12.0).floor() as usize % 12;
for i in 0..12 {
let a = i as f32 / 12.0 * TAU;
let behind = (head + 12 - i) % 12;
let alpha = 1.0 - behind as f32 / 12.0 * 0.85;
ui.line_indexed(
i as u64,
Vec2::new(C + 16.0 * a.cos(), C + 16.0 * a.sin()),
Vec2::new(C + 30.0 * a.cos(), C + 30.0 * a.sin()),
Stroke::new(4.0, t.fg.with_alpha(alpha)),
NodeSpec::column(),
);
}
});
// The dots: three round boxes, each a third of a beat
// behind the last, lifted by a spacer above it.
card(ui, "dots", "bouncing dots", |ui| {
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(NodeSpec::row().gap(8.0).height(36.0), |ui| {
for i in 0..3 {
let beat = (phase * 1.4 - i as f32 * 0.15).rem_euclid(1.0);
let lift = (beat * TAU * 0.5).sin().max(0.0) * 20.0;
ui.with(NodeSpec::column().size(12.0, 36.0), |ui| {
ui.leaf(NodeSpec::row().size(12.0, 20.0 - lift));
ui.leaf(
NodeSpec::row()
.size(12.0, 12.0)
.radius(6.0)
.bg(t.accent.with_alpha(0.55 + 0.45 * lift / 20.0)),
);
});
}
});
});
});
// The sweep: a bar that crosses its track and comes back
// in from the left, cut by the track's clip at both ends.
card(ui, "sweep", "sweeping bar", |ui| {
let len = STAGE - 12.0;
let run = len * 0.4;
let x = (phase * 0.8).rem_euclid(1.0) * (len + run) - run;
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(
NodeSpec::row().size(len, 8.0).bg(track).radius(4.0).clip(),
|ui| {
ui.leaf(NodeSpec::row().size(x.max(0.0), 8.0));
ui.leaf(
NodeSpec::row()
.size(run + x.min(0.0), 8.0)
.bg(t.accent)
.radius(4.0),
);
},
);
});
});
// The loop: the same bar going back and forth, slowing
// into each end and out of it, and never leaving its track.
card(ui, "loop", "bar going back and forth", |ui| {
let len = STAGE - 12.0;
let run = len * 0.4;
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(
NodeSpec::row().size(len, 8.0).bg(track).radius(4.0).clip(),
|ui| {
ui.leaf(
NodeSpec::row().size(loop_offset(phase, len, run), 8.0),
);
ui.leaf(
NodeSpec::row().size(run, 8.0).bg(t.accent).radius(4.0),
);
},
);
});
});
// The skeleton: the shape of what is coming, each line
// dimming and brightening a little after the one above.
card(ui, "skeleton", "skeleton", |ui| {
ui.with(NodeSpec::column().fill().center().gap(8.0), |ui| {
for (i, w) in [72.0, 84.0, 56.0].into_iter().enumerate() {
let wave =
0.5 + 0.5 * ((phase * 0.9 - i as f32 * 0.12) * TAU).sin();
ui.leaf(
NodeSpec::row()
.size(w, 10.0)
.radius(5.0)
.bg(t.fg.with_alpha(0.10 + 0.14 * wave)),
);
}
});
});
// The rainbow: the spectrum flowing along a track. One
// gradient, on a box two tracks long, slid left under
// the track's clip by up to a track — where the second
// turn of the colours sits exactly where the first
// began, so the loop has no seam. Only the box moves:
// the gradient is the same every frame, and so is its
// one strip in the atlas.
card(ui, "rainbow", "rainbow bar", |ui| {
let len = STAGE - 12.0;
let x = (phase * 0.6).rem_euclid(1.0) * len;
ui.with(NodeSpec::column().fill().center(), |ui| {
ui.with(NodeSpec::row().size(len, 10.0).radius(5.0).clip(), |ui| {
ui.leaf_keyed(
"spectrum",
NodeSpec::row()
.float(FloatConfig::parent().offset(-x, 0.0).clipped())
.size(len * 2.0, 10.0)
.gradient(rainbow()),
);
});
});
});
});
},
);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Toggle) => {
self.state = match self.state {
State::Idle | State::Paused => State::Running,
State::Running => State::Paused,
State::Done => {
self.progress = 0.0;
State::Running
}
}
}
Some(Msg::Reset) => {
self.state = State::Idle;
self.progress = 0.0;
self.phase = 0.0;
}
// A slider proposes; the app stores, and the next frame draws
// the slider there.
Some(Msg::Speed) => {
if let Some(v) = ev.payload.get_float("value") {
self.speed = v as f32;
}
}
None => {}
}
}
}
/// Where the looping bar's left edge is at `phase`, on a track `len`
/// long: from 0 to `len - run` and back every two and a half seconds, on
/// a cosine, so it eases at both ends.
fn loop_offset(phase: f32, len: f32, run: f32) -> f32 {
(len - run) * (0.5 - 0.5 * (phase * 0.4 * TAU).cos())
}
/// The spectrum twice over, left to right: red round to red, and round
/// again, so a box painted with it and slid by half its length looks as
/// it did before it moved.
fn rainbow() -> Gradient {
const TURN: [u32; 6] = [
0xff5f6dff, 0xffa64dff, 0xffe066ff, 0x5fd38dff, 0x4da3ffff, 0xb580ffff,
];
let stops = TURN
.iter()
.chain(&TURN)
.chain(&TURN[..1])
.map(|&c| Color::hex(c));
Gradient::to(Side::Right, stops)
}
/// The colour of a loader's track.
fn track_color(t: &kui_native::Theme) -> Color {
t.fg.with_alpha(0.10)
}
/// One loader's card: the stage its shapes are drawn in — a path or a
/// line is a float in its parent's box space, so the stage is the origin
/// of every coordinate above — and its name under it. The stage is one
/// image to assistive technology, named for what it shows.
fn card(ui: &mut Ui<'_>, name: &str, says: &str, stage: impl FnOnce(&mut Ui<'_>)) {
let t = ui.theme();
ui.with_keyed(
name,
NodeSpec::column()
.pad(12.0)
.gap(8.0)
.bg(t.surface)
.border(1.0, t.border)
.radius(12.0)
.cross_align(Align::Center),
|ui| {
ui.with_keyed(
"stage",
NodeSpec::column()
.size(STAGE, STAGE)
.role(Role::Image)
.label(says),
stage,
);
ui.text(name, TextStyle::new(12.0).color(t.muted));
},
);
}
impl Example for Loaders {
const KEYS: &'static [(&'static str, &'static str)] = &[
(
"Space",
"start the job; the same button pauses and resumes it",
),
(
"speed",
"how fast the job goes, not how fast the loaders turn",
),
];
fn window(&self) -> kui_devtools::Window {
kui_devtools::Window::default().size(1160.0, 470.0)
}
/// The job by hand: idle asks for no frame; running, the progress is
/// the time stepped and the moving paths leave the atlas; paused, it
/// holds and the window sleeps; done, the pie is a whole disc in the
/// success colour.
fn headless(&mut self, core: &mut Core) -> Result<(), String> {
self.manual = true;
let mut d = Drive::new(core, 1160.0, 470.0);
let step = |app: &mut Self, d: &mut Drive<'_>, secs: f32, frames: u32| {
for _ in 0..frames {
app.step(secs / frames as f32);
d.advance(f64::from(secs / frames as f32));
d.frame(app);
}
};
let count = |core: &mut Core, kind: QuadKind, color: Option<Color>| {
core.output()
.0
.quads
.iter()
.filter(|q| q.kind == kind && color.is_none_or(|c| q.color == c))
.count()
};
d.frame(self);
d.check(!d.core.animating(), "idle, no frame is asked for")?;
// The two tracks, and the still arc and chase: four masks, all in
// the atlas (told from the text's glyphs by their colours), and
// the twelve spokes.
let (track, accent) = (track_color(d.core.theme()), d.core.theme().accent);
let masks = count(d.core, QuadKind::GlyphMask, Some(track))
+ count(d.core, QuadKind::GlyphMask, Some(accent));
d.check(masks == 4, "idle, four paths are four atlas masks")?;
let spokes = count(d.core, QuadKind::Segment, None);
d.check(spokes == 12, "and the spokes are twelve segments")?;
d.check(d.core.path_texture_count() == 0, "with no texture")?;
// The rainbow is one image quad from the atlas, twice its track.
let spectrum = |core: &mut Core| -> Vec<(f32, [u32; 4])> {
let quads = &core.output().0.quads;
let images = quads.iter().filter(|q| q.kind == QuadKind::Image);
images.map(|q| (q.rect.x, q.uv)).collect()
};
let idle = spectrum(d.core);
d.check(idle.len() == 1, "the rainbow is one image quad")?;
d.check(idle[0].1[2..] == [256, 1], "from a strip in the atlas")?;
// A second of a four-second job, in ten frames.
let job = d.key_of("job").ok_or("no job button")?;
d.check(
d.core.focus() == Some(job),
"the job's button has the keyboard",
)?;
d.key(self, "space", Default::default());
d.check(self.state == State::Running, "and Space starts the job")?;
step(self, &mut d, 1.0, 10);
d.check(d.core.animating(), "running, the next frame is asked for")?;
d.check((self.progress - 0.25).abs() < 1e-3, "a second is a quarter")?;
let status = d.texts_under("status");
d.check(
status.iter().any(|s| s.contains("25%")),
"and the status says so",
)?;
// The pie's fill, the ring's and the chase change shape every
// frame: each draws from a texture of its own, and the two tracks
// stay the atlas's. The arc only turns: it is still its one atlas
// mask, and its quad carries the angle.
let textures = count(d.core, QuadKind::Texture, None);
d.check(textures == 3, "the three changing paths are textures")?;
let turned: Vec<f32> = d
.core
.output()
.0
.quads
.iter()
.filter(|q| q.kind == QuadKind::GlyphMask && q.color == accent)
.map(|q| q.blur)
.collect();
d.check(
turned.len() == 1 && (turned[0] - self.phase * TAU).abs() < 1e-3,
"the arc is one atlas mask, turned by its quad",
)?;
let masks = count(d.core, QuadKind::GlyphMask, Some(track));
d.check(masks == 2, "and the two tracks are still masks")?;
// The rainbow moved and was not drawn again: the same texels,
// further left.
let flowing = spectrum(d.core);
d.check(
flowing.len() == 1 && flowing[0].1 == idle[0].1 && flowing[0].0 < idle[0].0,
"the rainbow slid, and is the strip it was",
)?;
let warned = d.warnings();
d.check(warned.is_empty(), "nothing warned")?;
// The looping bar goes out and comes back: at rest on the left,
// at the far end half a period on, home again a period on.
let (len, run) = (84.0, 33.6);
let at = |phase: f32| loop_offset(phase, len, run);
d.check(
at(0.0).abs() < 1e-3 && (at(1.25) - (len - run)).abs() < 1e-3 && at(2.5).abs() < 1e-3,
"the looping bar goes to the far end and back",
)?;
// Paused: time passes and nothing moves.
d.check(d.core.focus() == Some(job), "the button kept the keyboard")?;
d.key(self, "space", Default::default());
step(self, &mut d, 1.0, 2);
d.check(
self.state == State::Paused && (self.progress - 0.25).abs() < 1e-3,
"paused, the job holds",
)?;
d.check(!d.core.animating(), "and no frame is asked for")?;
// Twice as fast, the rest takes a second and a half.
self.speed = 2.0;
d.click_key(self, job);
step(self, &mut d, 1.6, 16);
d.check(
self.state == State::Done && self.progress == 1.0,
"the job ends at 1",
)?;
// The fill's colour eases to the success role's.
step(self, &mut d, 0.5, 2);
d.check(!d.core.animating(), "done, the window sleeps")?;
let success = d.core.theme().success;
let green = count(d.core, QuadKind::Texture, Some(success))
+ count(d.core, QuadKind::GlyphMask, Some(success));
d.check(green == 2, "the pie and the ring are whole and green")?;
// Reset: back to nothing drawn over the tracks.
let reset = d.key_of("reset").ok_or("no reset button")?;
d.click_key(self, reset);
d.frame(self);
d.check(
self.state == State::Idle && self.progress == 0.0 && self.phase == 0.0,
"reset is idle at zero",
)?;
let says = d.texts_under("job");
d.check(
says.iter().any(|s| s == "start"),
"and the button offers start again",
)
}
}
kui_devtools::main!(Loaders::new());