unlit3dx_examples/scene/ui.rs
1//! The control panel: the scene's UI, drawn over the frame.
2//!
3//! The panel lives in the gameplay world with the rest of the scene's objects,
4//! and the render world draws it. A [`UiPanel`] can only be built where its
5//! egui context runs, so the gameplay world lays the panel out on its own tick
6//! and sends the frame to the render world, whose [`UiSource`] holds and draws
7//! it.
8//!
9//! Everything the panel decides is decided here, beside the objects it acts
10//! on: the drop and clear buttons call the scene's own helpers, and the
11//! transport buttons write the [`MidiSequencer`] of the track they belong to.
12//! The sequences live in this world, so a button is a field write rather than a
13//! message to another world, and a row reads the transport state it shows
14//! instead of remembering it.
15
16use unlit_ecs::World;
17use unlit_ecs::bundle;
18use unlit3d::prelude::*;
19use unlit3d::ui::{UiPanel, UiSource, egui};
20use unlit3dx::prelude::*;
21
22use super::{PanelLayout, PanelSlots, Scene, SceneTracks, Track, clear_balls_in, drop_ball_in};
23use crate::render::RenderEntity;
24
25/// What one of the panel's two track rows shows.
26///
27/// The row derives it from the sequence rather than keeping it: the sequence is
28/// a component of this world, so the transport state a row shows is the
29/// transport state that drives the music.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31enum TrackStatus {
32 /// Nothing is playing and the sequence stands at its start, so its row
33 /// offers a start.
34 Idle,
35 /// The sequence is playing.
36 Playing,
37 /// The sequence was paused part-way through.
38 Paused,
39}
40
41impl TrackStatus {
42 /// The state `sequencer` shows.
43 fn of(sequencer: &MidiSequencer) -> Self {
44 if sequencer.finished {
45 return Self::Idle;
46 }
47 if !sequencer.paused {
48 return Self::Playing;
49 }
50 if sequencer.time == 0.0 && sequencer.index == 0 {
51 Self::Idle
52 } else {
53 Self::Paused
54 }
55 }
56
57 /// The buttons a row in this state shows, in the order it shows them.
58 fn buttons(self) -> &'static [TrackButton] {
59 match self {
60 Self::Idle => &[TrackButton::Play],
61 Self::Playing => &[TrackButton::Pause, TrackButton::Rewind],
62 Self::Paused => &[TrackButton::Resume, TrackButton::Rewind],
63 }
64 }
65}
66
67/// One button a track row can be drawn with.
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69enum TrackButton {
70 /// Starts the track from its beginning, once: an idle row's only
71 /// button, and the only way the one-shot sequence ever gets going.
72 Play,
73 /// Pauses a playing track.
74 Pause,
75 /// Resumes a paused track where it stopped.
76 Resume,
77 /// Returns the track to its start, keeping its pause.
78 Rewind,
79}
80
81impl TrackButton {
82 /// The label the button is painted with, naming what pressing it does
83 /// next.
84 fn label(self) -> &'static str {
85 match self {
86 Self::Play => "Play",
87 Self::Pause => "Pause",
88 Self::Resume => "Resume",
89 Self::Rewind => "Rewind",
90 }
91 }
92}
93
94/// One track's transport: its name, then the buttons its state calls for.
95///
96/// The row names the track so the buttons need no suffix of their own. A
97/// sequence that played to its end is idle again, so its row offers the same
98/// Play, which returns it to its start before starting it.
99fn track_controls(
100 ui: &mut egui::Ui,
101 label: &str,
102 sequencer: &mut MidiSequencer,
103 layout: &mut PanelLayout,
104 audio: &JobSender,
105) {
106 ui.horizontal(|ui| {
107 ui.label(label);
108 for button in TrackStatus::of(sequencer).buttons() {
109 let response = ui.button(button.label());
110 if label == "MIDI sequence" {
111 layout.buttons.insert(button.label(), response.rect);
112 }
113 if !response.clicked() {
114 continue;
115 }
116 match button {
117 // A sequence that finished is started by rewinding it first:
118 // a finished sequencer stays put however far its clock moves.
119 // Starting a track also unlocks the audio output: a browser
120 // grants a Web Audio context only inside the handling of a
121 // user gesture, and this click is that gesture, so the world
122 // that owns the output learns of the request on this frame's
123 // turn and opens the device here.
124 TrackButton::Play => {
125 let _ = request_audio_output(audio);
126 sequencer.rewind();
127 sequencer.paused = false;
128 }
129 TrackButton::Pause => sequencer.paused = true,
130 TrackButton::Resume => sequencer.paused = false,
131 TrackButton::Rewind => sequencer.rewind(),
132 }
133 }
134 });
135}
136
137/// Mount the panel: one entity carrying the source that runs it.
138///
139/// The panel reads its input from the gameplay world's own buffer, which
140/// the world subscribes when it is set up and dispatches on its tick, so
141/// nothing input-related is spawned here: the events the panel reacts to
142/// are the ones the rest of this world saw on the same tick.
143///
144/// The background loop starts once the audio world's output is up — with the
145/// scene, or on the user's first click where the output waits for one; the
146/// one-shot sequence waits for its row's Play button and plays once per
147/// press, and pressing it is also what unlocks a locked output. Each track then pauses, resumes and rewinds on its own, and the
148/// balls drop or clear without touching the music. A scene with no
149/// synthesizer has no [`SceneTracks`] and nothing to transport, so its panel
150/// shows the balls alone.
151///
152/// The panel owns no state: a row reads the sequence it draws, and pressing a
153/// button writes it. egui may run a panel more than once in a frame, and every
154/// write here is one a second run repeats harmlessly.
155pub fn mount_controls(world: &mut World) {
156 // Whether the audio world has opened an output, watched from here so the
157 // panel can say when the tracks are still locked. A world the host pulls
158 // the fills of is open by definition, and a self-driven one opens on its
159 // first tick, so only a page whose output waits for the user's first
160 // click ever shows the hint below.
161 let unlocked = (|| {
162 let scene = world.root_scope().query::<&Scene>().iter().next()?.clone();
163 scene
164 .audio
165 .watch(|audio| {
166 audio
167 .root_scope()
168 .query::<&AudioOutputOpen>()
169 .iter()
170 .next()
171 .map(|_| ())
172 })
173 .ok()
174 })();
175 world
176 .root_scope_mut()
177 .spawn(bundle![PanelLayout::default()]);
178 world
179 .root_scope_mut()
180 .spawn(bundle![UiPanel::new(move |world, _entity, ui| {
181 let mut layouts = world.query::<&mut PanelLayout>();
182 let Some(layout) = layouts.iter().next() else {
183 return;
184 };
185 layout.buttons.clear();
186 egui::Window::new("Controls").show(ui.ctx(), |ui| {
187 // The tracks are spawned once the audio world has answered
188 // with its voice, so a scene whose soundfont failed to load
189 // shows the ball buttons alone.
190 if let Some(tracks) = world.query::<&SceneTracks>().iter().next().copied() {
191 // The output waits for the user's first click: say so, so
192 // a silent panel reads as a nudge to press Play rather
193 // than as something broken.
194 if unlocked.as_ref().and_then(|open| open.latest()).is_none() {
195 ui.label("audio is locked until you press Play");
196 }
197 // One row at a time: the borrow each one takes is given
198 // back before the next asks for its own. The sender goes
199 // along so Play can unlock the output the same click.
200 if let (Some(scene), Ok(mut track)) = (
201 world.query::<&Scene>().iter().next(),
202 world.query_one::<&mut Track>(tracks.background),
203 ) {
204 track_controls(
205 ui,
206 "Background music",
207 &mut track.get().midi,
208 layout,
209 &scene.audio,
210 );
211 }
212 if let (Some(scene), Ok(mut track)) = (
213 world.query::<&Scene>().iter().next(),
214 world.query_one::<&mut Track>(tracks.sequence),
215 ) {
216 track_controls(
217 ui,
218 "MIDI sequence",
219 &mut track.get().midi,
220 layout,
221 &scene.audio,
222 );
223 }
224 ui.separator();
225 }
226 ui.horizontal(|ui| {
227 let drop = ui.button("Drop the next ball");
228 layout.buttons.insert("Drop the next ball", drop.rect);
229 if drop.clicked() {
230 drop_ball_in(&world);
231 }
232 let clear = ui.button("Clear the balls");
233 layout.buttons.insert("Clear the balls", clear.rect);
234 if clear.clicked() {
235 clear_balls_in(&world);
236 }
237 });
238 });
239 }),]);
240}
241
242/// A job that gives the render world the source drawing this scene's panel.
243///
244/// The panel itself is not here: it lives in the gameplay world, which lays it
245/// out and sends the frame over. This world owns the slots those frames land
246/// in, and measures the target the publisher has to match.
247///
248/// `pass` is the pass the source draws into: a pass records the sources below
249/// it, so a source spawned anywhere else would never reach the frame.
250pub fn control_panel_job(panels: PanelSlots, pass: RenderEntity) -> Job {
251 Box::new(move |world: &mut World| {
252 let mut source = UiSource::new();
253 source.publish_target(panels.target);
254 world
255 .scope_mut(pass.0)
256 .expect("the scene's pass is alive")
257 .spawn(bundle![Source::new(source)]);
258 })
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 /// A sequence nothing has been played into: it stands at its start.
266 fn sequence() -> MidiSequencer {
267 MidiSequencer::new(midi_file(std::iter::empty()).expect("an empty file is well formed"))
268 }
269
270 #[test]
271 fn a_fresh_sequence_offers_a_start() {
272 let mut sequencer = sequence();
273 sequencer.paused = true;
274 assert_eq!(TrackStatus::of(&sequencer), TrackStatus::Idle);
275 assert_eq!(TrackStatus::of(&sequencer).buttons(), &[TrackButton::Play]);
276 }
277
278 #[test]
279 fn a_running_sequence_offers_a_pause() {
280 let sequencer = sequence();
281 assert_eq!(TrackStatus::of(&sequencer), TrackStatus::Playing);
282 assert_eq!(
283 TrackStatus::of(&sequencer).buttons(),
284 &[TrackButton::Pause, TrackButton::Rewind]
285 );
286 }
287
288 #[test]
289 fn a_paused_sequence_offers_a_resume() {
290 let mut sequencer = sequence();
291 sequencer.time = 1.0;
292 sequencer.paused = true;
293 assert_eq!(TrackStatus::of(&sequencer), TrackStatus::Paused);
294 assert_eq!(
295 TrackStatus::of(&sequencer).buttons(),
296 &[TrackButton::Resume, TrackButton::Rewind]
297 );
298 }
299
300 #[test]
301 fn a_played_out_sequence_offers_a_start_again() {
302 let mut sequencer = sequence();
303 sequencer.finished = true;
304 assert_eq!(TrackStatus::of(&sequencer), TrackStatus::Idle);
305 assert_eq!(TrackStatus::of(&sequencer).buttons(), &[TrackButton::Play]);
306 }
307
308 #[test]
309 fn a_rewind_puts_the_row_back_at_its_start() {
310 let mut sequencer = sequence();
311 sequencer.time = 1.0;
312 sequencer.paused = true;
313 sequencer.rewind();
314 assert_eq!(TrackStatus::of(&sequencer), TrackStatus::Idle);
315 }
316}