all_is_cubes_ui/ui_content/
vui_manager.rs

1use alloc::boxed::Box;
2use alloc::string::{String, ToString as _};
3use alloc::sync::Arc;
4use core::fmt;
5use std::sync::Mutex;
6
7use flume::TryRecvError;
8
9use all_is_cubes::arcstr::ArcStr;
10use all_is_cubes::character::{Character, Cursor};
11use all_is_cubes::inv::{EphemeralOpaque, Tool, ToolError, ToolInput};
12use all_is_cubes::listen::{self, Notifier};
13use all_is_cubes::space::Space;
14use all_is_cubes::time;
15use all_is_cubes::transaction::{self, Transaction};
16use all_is_cubes::universe::{
17    Handle, ReadTicket, StrongHandle, Universe, UniverseStepInfo, UniverseTransaction,
18};
19use all_is_cubes_render::camera::{FogOption, GraphicsOptions, UiViewState, Viewport};
20
21use crate::apps::{
22    ControlMessage, FullscreenSetter, FullscreenState, QuitCancelled, QuitFn, QuitResult,
23};
24use crate::ui_content::hud::{HudBlocks, HudInputs};
25use crate::ui_content::{notification, pages};
26use crate::vui::widgets::TooltipState;
27use crate::vui::{self, PageInst, UiSize};
28
29// -------------------------------------------------------------------------------------------------
30
31/// All the things exposed by [`crate::apps::Session`] to [`Vui`],
32/// and that are required to create a [`Vui`].
33///
34/// TODO: Better name, better data flow...?
35#[derive(Clone)]
36pub(crate) struct UiTargets {
37    pub(crate) mouselook_mode: listen::DynSource<bool>,
38
39    /// Reports the `Character` whose inventory should be displayed.
40    pub(crate) character_source: listen::DynSource<Option<StrongHandle<Character>>>,
41
42    pub(crate) paused: listen::DynSource<bool>,
43
44    pub(crate) graphics_options: listen::DynSource<Arc<GraphicsOptions>>,
45
46    pub(crate) app_control_channel: flume::Sender<ControlMessage>,
47
48    pub(crate) viewport_source: listen::DynSource<Viewport>,
49
50    pub(crate) fullscreen_mode: listen::DynSource<FullscreenState>,
51    pub(crate) set_fullscreen: FullscreenSetter,
52
53    pub(crate) quit: Option<QuitFn>,
54
55    pub(crate) custom_commands: listen::DynSource<Arc<[Command]>>,
56}
57
58// TODO: define a builder
59#[allow(missing_docs)]
60#[derive(Clone)]
61#[allow(clippy::exhaustive_structs)]
62pub struct Command {
63    pub label: ArcStr,
64    // TODO: icon_name: Option<Name>,
65
66    // TODO: replace uses of arbitrary functions with some kind of channel to the session
67    pub command:
68        Arc<dyn Fn() -> Result<(), Box<dyn core::error::Error + Send + Sync>> + Send + Sync>,
69}
70
71// -------------------------------------------------------------------------------------------------
72
73/// `Vui` builds user interfaces out of voxels. It owns a `Universe` dedicated to the
74/// purpose and draws into spaces to form the HUD and menus.
75///
76/// TODO: This needs a renaming given the revised focus of the `vui` module as being about
77/// "widget framework" and not about application UI
78#[derive(Debug)] // TODO: probably not very informative Debug as derived
79pub(crate) struct Vui {
80    /// Universe used for storing VUI elements.
81    universe: Universe,
82
83    /// The space that should be displayed to the user, drawn on top of the world.
84    /// The value of this cell is derived from `self.state`.
85    current_view: listen::Cell<Arc<UiViewState>>,
86
87    /// The `focus_on_ui` value from the current [`vui::Page`].
88    ///
89    /// TODO: this is a kludge which should be part of a more general mechanism
90    /// analogous to `UiViewState.`
91    current_focus_on_ui: bool,
92
93    /// Identifies which [`Page`] the UI should be showing — what
94    /// should be in `current_space`, taken from one of the [`PageInst`]s.
95    state: listen::Cell<Arc<VuiPageState>>,
96
97    /// Listens to the provided user graphics options.
98    changed_graphics_options: listen::Flag,
99    /// Modified version of the user graphics options for rendering UI.
100    ui_graphics_options: listen::Cell<Arc<GraphicsOptions>>,
101
102    changed_viewport: listen::Flag,
103    /// Size computed from `viewport_source` and compared with `PageInst`.
104    last_ui_size: UiSize,
105    hud_inputs: HudInputs,
106
107    hud_page: PageInst,
108    paused_page: PageInst,
109    about_page: PageInst,
110    progress_page: PageInst,
111    options_page: PageInst,
112    /// Whatever [`VuiPageState::Dump`] contained.
113    dump_page: PageInst,
114
115    /// Receiving internal messages from widgets for controlling the UI itself
116    /// (changing `state`, etc).
117    ///
118    /// Design note: Using `flume` not because we want MPMC, but because its receiver is
119    /// `Send + Sync`, unlike the `std` one.
120    /// Our choice of `flume` in particular is just because our other crates use it.
121    control_channel: flume::Receiver<VuiMessage>,
122    changed_character: listen::Flag,
123    changed_custom_commands: listen::Flag,
124    tooltip_state: Arc<Mutex<TooltipState>>,
125    /// Messages from session to UI that don't fit as [`listen::DynSource`] changes.
126    cue_channel: CueNotifier,
127
128    notif_hub: notification::Hub,
129}
130
131impl Vui {
132    /// TODO: Reduce coupling, perhaps by passing in a separate struct with just the listenable
133    /// elements.
134    ///
135    /// This is an async function for the sake of cancellation and optional cooperative
136    /// multitasking. It may safely be blocked on from a synchronous context.
137    pub(crate) async fn new(params: UiTargets) -> Self {
138        let UiTargets {
139            viewport_source, ..
140        } = &params;
141
142        let mut universe = Universe::new();
143
144        let mut content_txn = UniverseTransaction::default();
145        // TODO: take YieldProgress as a parameter
146        let hud_blocks = Arc::new(
147            HudBlocks::new(
148                universe.read_ticket(),
149                &mut content_txn,
150                all_is_cubes::util::YieldProgressBuilder::new().build(),
151            )
152            .await,
153        );
154        content_txn.execute(&mut universe, (), &mut transaction::no_outputs).unwrap();
155
156        let (control_send, control_recv) = flume::bounded(100);
157        let state = listen::Cell::new(Arc::new(VuiPageState::Hud));
158
159        let tooltip_state = Arc::<Mutex<TooltipState>>::default();
160        let cue_channel: CueNotifier = Arc::new(Notifier::new());
161        let notif_hub = notification::Hub::new();
162
163        let changed_graphics_options = listen::Flag::listening(false, &params.graphics_options);
164        let ui_graphics_options = listen::Cell::new(Arc::new(Self::graphics_options(
165            (*params.graphics_options.get()).clone(),
166        )));
167
168        let changed_custom_commands = listen::Flag::listening(false, &params.custom_commands);
169        let changed_viewport = listen::Flag::listening(false, &viewport_source);
170        let ui_size = UiSize::new(viewport_source.get());
171        let hud_inputs = HudInputs {
172            base: params.clone(),
173            hud_blocks,
174            cue_channel: cue_channel.clone(),
175            vui_control_channel: control_send,
176            page_state: state.as_source(),
177        };
178        let hud_page =
179            super::hud::new_hud_page(universe.read_ticket(), &hud_inputs, tooltip_state.clone());
180
181        let paused_page = pages::new_paused_page(&mut universe, &hud_inputs).unwrap();
182        let options_page = pages::new_options_widget_tree(universe.read_ticket(), &hud_inputs);
183        let about_page = pages::new_about_page(&mut universe, &hud_inputs).unwrap();
184        let progress_page =
185            pages::new_progress_page(&hud_inputs.hud_blocks.widget_theme, &notif_hub);
186
187        let mut new_self = Self {
188            universe: *universe,
189
190            current_view: listen::Cell::new(Arc::new(UiViewState::default())),
191            current_focus_on_ui: false,
192            state: listen::Cell::new(Arc::new(VuiPageState::Hud)),
193
194            changed_graphics_options,
195            ui_graphics_options,
196
197            changed_viewport,
198            last_ui_size: ui_size,
199            hud_inputs,
200
201            hud_page: PageInst::new(hud_page),
202            paused_page: PageInst::new(paused_page),
203            options_page: PageInst::new(options_page),
204            about_page: PageInst::new(about_page),
205            dump_page: PageInst::new(vui::Page::empty()),
206            progress_page: PageInst::new(progress_page),
207
208            control_channel: control_recv,
209            changed_character: listen::Flag::listening(false, &params.character_source),
210            changed_custom_commands,
211            tooltip_state,
212            cue_channel,
213            notif_hub,
214        };
215        new_self.compute_view_state();
216        new_self
217    }
218
219    /// The space that should be displayed to the user, drawn on top of the world.
220    // TODO: It'd be more encapsulating if we could provide a _read-only_ Handle...
221    pub fn view(&self) -> listen::DynSource<Arc<UiViewState>> {
222        self.current_view.as_source()
223    }
224
225    /// Returns a [`ReadTicket`] for use with the space handle in [`Self::view()`].
226    pub fn read_ticket(&self) -> ReadTicket<'_> {
227        self.universe.read_ticket()
228    }
229
230    /// Returns whether the input/output mechanisms for this UI should direct input to the UI
231    /// rather than gameplay controls. That is: disable mouselook and direct typing to text input.
232    //---
233    // TODO: This should be public and be a listen::Cell, but we don't really know what the name
234    // and data type should be
235    pub(crate) fn should_focus_on_ui(&self) -> bool {
236        self.current_focus_on_ui
237    }
238
239    pub(crate) fn set_state(&mut self, state: impl Into<Arc<VuiPageState>>) {
240        self.state.set(state.into());
241
242        // Special case: the dump state has to replace the widget tree, and
243        // unconditionally because we can't just check if it is equal (WidgetTree: !Eq)
244        if let VuiPageState::Dump {
245            previous: _,
246            content,
247        } = &*self.state.get()
248        {
249            let content: vui::Page = match content.try_ref() {
250                Some(page) => (*page).clone(),
251                None => vui::Page::empty(),
252            };
253            self.dump_page = PageInst::new(content);
254        }
255
256        self.compute_view_state();
257    }
258
259    /// Update `self.current_space` from `self.state` and the source of the selected space.
260    fn compute_view_state(&mut self) {
261        let size = self.last_ui_size;
262        let universe = &mut self.universe;
263
264        let next_page: &mut PageInst = match &*self.state.get() {
265            VuiPageState::Hud => &mut self.hud_page,
266            VuiPageState::Paused => {
267                if self.changed_custom_commands.get_and_clear() {
268                    // TODO: make this dependency solely the page's responsibility
269                    // instead of hardcoding it here
270                    self.paused_page =
271                        PageInst::new(pages::new_paused_page(universe, &self.hud_inputs).unwrap());
272                }
273                &mut self.paused_page
274            }
275            VuiPageState::Options => &mut self.options_page,
276            VuiPageState::AboutText => &mut self.about_page,
277            VuiPageState::Progress => &mut self.progress_page,
278
279            // Note: checking the `content` is handled in `set_state()`.
280            VuiPageState::Dump {
281                previous: _,
282                content: _,
283            } => &mut self.dump_page,
284        };
285        let next_space: Handle<Space> = next_page.get_or_create_space(size, universe);
286        let page_layout = next_page.page().layout;
287        let graphics_options = self.ui_graphics_options.get();
288
289        let new_view_state = UiViewState {
290            view_transform: page_layout.view_transform(
291                &next_space.read(universe.read_ticket()).unwrap(), // TODO: eliminate this unwrap
292                graphics_options.fov_y.into_inner(),
293            ),
294            space: Some(next_space),
295            graphics_options,
296        };
297
298        if new_view_state != *self.current_view.get() {
299            self.current_view.set(Arc::new(new_view_state));
300            self.current_focus_on_ui = next_page.page().focus_on_ui;
301            log::trace!(
302                "UI switched to {:?} ({:?})",
303                self.current_view.get().space,
304                self.state.get()
305            );
306        }
307    }
308
309    /// Compute graphics options to render the VUI space given the user's regular options.
310    fn graphics_options(mut options: GraphicsOptions) -> GraphicsOptions {
311        // Set FOV to give a predictable, not-too-wide-angle perspective.
312        options.fov_y = 30u8.into();
313
314        // Disable fog for maximum clarity and because we shouldn't have any far clipping to hide.
315        options.fog = FogOption::None;
316
317        // Fixed view distance for our layout.
318        // TODO: Derive this from HudLayout and also FOV (since FOV determines eye-to-space distance).
319        options.view_distance = 100u8.into();
320
321        // clutter
322        options.debug_chunk_boxes = false;
323        options.debug_pixel_cost = false;
324
325        options
326    }
327
328    // TODO: This should stop taking a `Tick` and instead expose whatever is necessary for
329    // it to be stepped on a schedule independent of the in-game time.
330    pub fn step(
331        &mut self,
332        tick: time::Tick,
333        deadline: time::Deadline,
334        world_read_ticket: ReadTicket<'_>,
335    ) -> UniverseStepInfo {
336        self.step_pre_sync(world_read_ticket);
337        self.universe.step(tick.paused(), deadline)
338    }
339
340    /// Update the UI from its data sources
341    #[inline(never)]
342    fn step_pre_sync(&mut self, world_read_ticket: ReadTicket<'_>) {
343        let mut anything_changed = false;
344
345        if self.changed_graphics_options.get_and_clear() {
346            anything_changed = true;
347            self.ui_graphics_options.set_if_unequal(Arc::new(Self::graphics_options(
348                (*self.hud_inputs.graphics_options.get()).clone(),
349            )));
350        }
351
352        // TODO: This should possibly be the responsibility of the TooltipState itself?
353        if self.changed_character.get_and_clear()
354            && let Some(character_handle) = self.hud_inputs.character_source.get()
355        {
356            TooltipState::bind_to_character(
357                world_read_ticket,
358                &self.tooltip_state,
359                character_handle,
360            );
361        }
362
363        // Drain the control channel.
364        loop {
365            match self.control_channel.try_recv() {
366                Ok(msg) => match msg {
367                    VuiMessage::Back => {
368                        self.back();
369                    }
370                    VuiMessage::Open(page) => {
371                        // TODO: States should be stackable somehow, and this should not totally overwrite the previous state.
372                        self.set_state(page);
373                    }
374                },
375                Err(TryRecvError::Empty) => break,
376                Err(TryRecvError::Disconnected) => {
377                    // Lack of whatever control sources is non-fatal.
378                }
379            }
380        }
381
382        if self.changed_viewport.get_and_clear() {
383            let new_viewport = self.hud_inputs.viewport_source.get();
384            let new_size = UiSize::new(new_viewport);
385            if new_size != self.last_ui_size {
386                anything_changed = true;
387                self.last_ui_size = new_size;
388                self.current_view.set(Arc::new(UiViewState::default())); // force reconstruction
389            }
390        }
391
392        // Gather latest notification data.
393        self.notif_hub.update();
394
395        // Decide what state we should be in (based on all the stuff we just checked).
396        if let Some(new_state) = self.choose_new_page_state(&self.state.get()) {
397            self.set_state(new_state);
398        } else if anything_changed {
399            // We need to update the view state even though it isn't changing what Space it looks
400            // at.
401            self.compute_view_state();
402        }
403
404        if let Some(space_handle) = self.view().get().space.as_ref() {
405            if let Ok(space) = space_handle.read(self.universe.read_ticket()) {
406                vui::synchronize_widgets(world_read_ticket, self.universe.read_ticket(), &space);
407            } else {
408                log::error!("failed to synchronize widgets");
409            }
410        }
411    }
412
413    pub fn show_modal_message(&mut self, message: ArcStr) {
414        let content =
415            EphemeralOpaque::from(Arc::new(pages::new_message_page(message, &self.hud_inputs)));
416        self.set_state(VuiPageState::Dump {
417            previous: self.state.get(),
418            content,
419        });
420    }
421
422    pub fn show_notification(
423        &mut self,
424        content: impl Into<notification::NotificationContent>,
425    ) -> notification::Notification {
426        self.notif_hub.insert(content.into())
427    }
428
429    /// Enter some kind of debug view. Not yet defined for the long run exactly what that is.
430    pub(crate) fn enter_debug(&mut self, read_ticket: ReadTicket<'_>, cursor: &Cursor) {
431        // TODO(read_ticket): kludge; perhaps instead the cursor information provided should
432        // include which layer's universe it came from.
433        // (Note that the caller can't supply this read ticket selection because it would be a
434        // borrow conflict.)
435        // This is probably something that will be easier to fix in the shiny ECS future that
436        // read_ticket is supposed to be an interim migration mechanism for.
437        let read_ticket = if cursor.space().universe_id() == Some(self.universe.universe_id()) {
438            self.universe.read_ticket()
439        } else {
440            read_ticket
441        };
442        let content = EphemeralOpaque::new(Arc::new(crate::editor::inspect_block_at_cursor(
443            read_ticket,
444            &self.hud_inputs,
445            cursor,
446        )));
447        self.set_state(VuiPageState::Dump {
448            previous: self.state.get(),
449            content,
450        });
451    }
452
453    /// Present the UI visual response to a click (that has already been handled),
454    /// either a small indication that a button was pressed or an error message.
455    pub fn show_click_result(&self, button: usize, result: Result<(), ToolError>) {
456        self.cue_channel.notify(&CueMessage::Clicked(button));
457        match result {
458            Ok(()) => {}
459            Err(error) => self.show_tool_error(&error),
460        }
461    }
462
463    fn show_tool_error(&self, error: &ToolError) {
464        // TODO: review text formatting
465        if let Ok(mut state) = self.tooltip_state.lock() {
466            state.set_message(error.to_string().into());
467        }
468    }
469
470    /// Handle clicks that hit the UI itself
471    pub fn click(&mut self, _button: usize, cursor: Option<Cursor>) -> Result<(), ToolError> {
472        if cursor.as_ref().map(Cursor::space) != Option::as_ref(&self.current_view.get().space) {
473            return Err(ToolError::Internal(String::from(
474                "Vui::click: space didn't match",
475            )));
476        }
477        // TODO: We'll probably want to distinguish buttons eventually.
478        // TODO: It should be easier to use a tool
479        let transaction = Tool::Activate.use_immutable_tool(&ToolInput {
480            read_ticket: self.universe.read_ticket(),
481            cursor,
482            character: None,
483        })?;
484        transaction
485            .execute(&mut self.universe, (), &mut transaction::no_outputs)
486            .map_err(|e| ToolError::Internal(e.to_string()))?;
487        Ok(())
488    }
489
490    /// Perform the back/escape key action.
491    ///
492    /// This may cancel out of a menu/dialog, or pause or unpause the game.
493    pub fn back(&mut self) {
494        match *self.state.get() {
495            VuiPageState::Hud => {
496                if !self.hud_inputs.paused.get() {
497                    // Pause
498                    // TODO: instead of unwrapping, log and visually report the error
499                    // (there should be some simple way to do that).
500                    // TODO: We should have a "set paused state" message instead of toggle.
501                    self.hud_inputs.app_control_channel.send(ControlMessage::TogglePause).unwrap();
502                }
503            }
504            VuiPageState::Paused => {
505                if self.hud_inputs.paused.get() {
506                    // Unpause
507                    self.hud_inputs.app_control_channel.send(ControlMessage::TogglePause).unwrap();
508                }
509            }
510            VuiPageState::AboutText | VuiPageState::Options => {
511                // The next step will decide whether we should be paused or unpaused.
512                // TODO: Instead check right now, but in a reusable fashion.
513                self.set_state(VuiPageState::Hud);
514            }
515            VuiPageState::Progress => {
516                log::error!("TODO: need UI state for dismissing notifications");
517            }
518            VuiPageState::Dump { ref previous, .. } => {
519                self.set_state(Arc::clone(previous));
520            }
521        }
522    }
523
524    /// Perform the quit action.
525    ///
526    /// This may be used in response to a window's close button, for example.
527    ///
528    /// The UI state *may* decline to react, such as if there are unsaved changes, but it should
529    /// be expected to prompt the user in that case.
530    ///
531    /// The returned future will produce a [`QuitCancelled`] value if quitting was unsuccessful for
532    /// any reason. If it is successful, the future never resolves. It is not necessary to poll
533    /// the future if the result value is not wanted.
534    pub fn quit(&self) -> impl Future<Output = QuitResult> + Send + 'static + use<> {
535        if let Some(quit_fn) = self.hud_inputs.quit.as_ref() {
536            std::future::ready(quit_fn())
537        } else {
538            std::future::ready(Err(QuitCancelled::Unsupported))
539        }
540    }
541
542    /// Compute the wanted page state based on the previous state and external inputs.
543    ///
544    /// This is how, for example, the pause menu appears when the game is paused by whatever means.
545    ///
546    /// Returns [`None`] if no change in state should be made.
547    fn choose_new_page_state(&self, current_state: &VuiPageState) -> Option<VuiPageState> {
548        // Decide whether to display VuiPageState::Progress
549        if self.notif_hub.has_interrupt()
550            && current_state.freely_replaceable()
551            && !matches!(current_state, VuiPageState::Progress)
552        {
553            return Some(VuiPageState::Progress);
554        }
555
556        // Decide whether to stop displaying notifications
557        if !self.notif_hub.has_interrupt() && matches!(current_state, VuiPageState::Progress) {
558            // TODO: actually we should delegate to the paused-or-not logic...
559            return Some(VuiPageState::Hud);
560        }
561
562        let paused = self.hud_inputs.paused.get();
563        if paused && matches!(current_state, VuiPageState::Hud) {
564            // TODO: also do this for lost focus
565            Some(VuiPageState::Paused)
566        } else if !paused && matches!(current_state, VuiPageState::Paused) {
567            Some(VuiPageState::Hud)
568        } else {
569            None
570        }
571    }
572}
573
574/// Identifies which “page” the UI should be showing — what should be in
575/// [`Vui::current_space()`].
576#[derive(Clone, Debug, PartialEq)]
577pub(crate) enum VuiPageState {
578    /// Normal gameplay, with UI elements around the perimeter.
579    Hud,
580
581    /// Report the paused (or lost-focus) state and offer a button to unpause
582    /// and reactivate mouselook.
583    Paused,
584
585    /// Options/settings/preferences menu.
586    Options,
587
588    /// “About All is Cubes” info.
589    AboutText,
590
591    /// Displays a task progress bar taken from the [notification] list.
592    /// Reverts to [`VuiPageState::Hud`] when there is no notification.
593    Progress,
594
595    /// Arbitrary widgets that have already been computed, and which don't demand
596    /// any navigation behavior more complex than “cancellable”. This is to be used for
597    /// viewing various reports/dialogs until we have a better idea.
598    Dump {
599        previous: Arc<VuiPageState>,
600        content: EphemeralOpaque<vui::Page>,
601    },
602}
603
604impl VuiPageState {
605    /// Whether replacing this state _won't_ lose any important state.
606    pub fn freely_replaceable(&self) -> bool {
607        match self {
608            VuiPageState::Hud => true,
609            VuiPageState::Paused => true,
610            VuiPageState::AboutText => true,
611            VuiPageState::Progress => true,
612
613            VuiPageState::Options => false,
614            VuiPageState::Dump { .. } => false,
615        }
616    }
617}
618
619/// Message indicating a UI action that affects the UI itself
620#[derive(Clone, Debug)]
621pub(crate) enum VuiMessage {
622    /// Perform the VUI-internal “back” action. This is not necessarily the same as an
623    /// external button press.
624    ///
625    /// TODO: This is a kludge being used for 'close the current page state' but ideally
626    /// it would specify *what* it's closing in case of message race conditions etc.
627    Back,
628    /// Transition to the specified [`VuiPageState`].
629    Open(VuiPageState),
630}
631
632/// Channel for broadcasting, from session to widgets, various user interface responses
633/// to events (that don't fit into the [`listen::DynSource`] model).
634///
635/// TODO: This `Arc` is a kludge; probably Notifier should have some kind of clonable
636/// add-a-listener handle to itself, and that would help out other situations too.
637pub(crate) type CueNotifier = Arc<Notifier<CueMessage>>;
638
639/// Message from session to widget.
640#[derive(Clone, Copy, Debug)]
641pub(crate) enum CueMessage {
642    /// User clicked the specified button and it was handled as a tool usage.
643    ///
644    /// TODO: This needs to communicate "which space" or be explicitly restricted to the
645    /// world space.
646    Clicked(usize),
647}
648
649// -------------------------------------------------------------------------------------------------
650
651impl fmt::Debug for Command {
652    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
653        f.debug_struct("Command").field("label", &self.label).finish_non_exhaustive()
654    }
655}
656
657// -------------------------------------------------------------------------------------------------
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    async fn new_vui_for_test(paused: bool) -> (Vui, flume::Receiver<ControlMessage>) {
664        let (cctx, ccrx) = flume::bounded(1);
665        let vui = Vui::new(UiTargets {
666            mouselook_mode: listen::constant(false),
667            character_source: listen::constant(None),
668            paused: listen::constant(paused),
669            graphics_options: listen::constant(Arc::new(GraphicsOptions::default())),
670            app_control_channel: cctx,
671            viewport_source: listen::constant(Viewport::ARBITRARY),
672            fullscreen_mode: listen::constant(None),
673            set_fullscreen: None,
674            quit: None,
675            custom_commands: listen::constant(Default::default()),
676        })
677        .await;
678        (vui, ccrx)
679    }
680
681    #[macro_rules_attribute::apply(smol_macros::test)]
682    async fn back_pause() {
683        let (mut vui, control_channel) = new_vui_for_test(false).await;
684        vui.back();
685        let msg = control_channel.try_recv().unwrap();
686        assert!(matches!(msg, ControlMessage::TogglePause), "{msg:?}");
687        assert!(control_channel.try_recv().is_err());
688    }
689
690    #[macro_rules_attribute::apply(smol_macros::test)]
691    async fn back_unpause() {
692        let (mut vui, control_channel) = new_vui_for_test(true).await;
693        vui.set_state(VuiPageState::Paused);
694        vui.back();
695        let msg = control_channel.try_recv().unwrap();
696        assert!(matches!(msg, ControlMessage::TogglePause), "{msg:?}");
697        assert!(control_channel.try_recv().is_err());
698    }
699}