Skip to main content

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}