Skip to main content

corundum_framework_gui/settings/
launcher.rs

1//! [`LauncherRow`]: whether the app is in the OS's app menu, and the button that changes it.
2
3use corundum_framework_platform::launcher::{self, LauncherSpec, Status};
4
5use crate::tr;
6
7/// The settings row that says the app is in the OS's app menu, with a button that takes it
8/// out (**Remove**) or puts it back (**Add back**). Put it on one of the app's
9/// [`Settings`](super::Settings) pages, its general one, say; hold it there, and call
10/// [`ui`](Self::ui) every frame the page is shown.
11///
12/// The entry is the one [`WindowOptions::launcher`](crate::window::WindowOptions::launcher)
13/// registers as the window opens. With the `serde` feature and a [store](crate::store), the
14/// person's choice is kept there, so a removed entry is not written back on the next start;
15/// without a store, a removal lasts only for the session.
16/// Where there is no app menu to look in ([`Status::Unsupported`]: on iOS and Android, or
17/// without a home directory, or for a build [`launcher::register`] skips) the row shows only
18/// its label.
19pub struct LauncherRow {
20    spec: LauncherSpec,
21    /// How the entry's state is found: [`launcher::status`], or a stand-in in the tests.
22    status_of: fn(&LauncherSpec) -> Status,
23    /// The entry's state as last found; `None` until the first frame, and after a click.
24    status: Option<Status>,
25    /// Why the last click did nothing, shown under the row.
26    error: Option<String>,
27    /// The message id of the button last shown, for the tests.
28    #[cfg(test)]
29    shown: Option<&'static str>,
30}
31
32/// What a click on the row's button does.
33#[derive(Clone, Copy)]
34enum Act {
35    Remove,
36    Add,
37}
38
39impl LauncherRow {
40    /// The row for `spec`, the spec the app gives its window.
41    pub fn new(spec: LauncherSpec) -> LauncherRow {
42        LauncherRow::with_status(spec, launcher::status)
43    }
44
45    /// The row for `spec`, finding the entry's state with `status_of` instead of looking on
46    /// the host.
47    pub(crate) fn with_status(
48        spec: LauncherSpec,
49        status_of: fn(&LauncherSpec) -> Status,
50    ) -> LauncherRow {
51        LauncherRow {
52            spec,
53            status_of,
54            status: None,
55            error: None,
56            #[cfg(test)]
57            shown: None,
58        }
59    }
60
61    /// Render the row: its label, and the button its state calls for.
62    pub fn ui(&mut self, ui: &mut egui::Ui) {
63        let status = *self
64            .status
65            .get_or_insert_with(|| (self.status_of)(&self.spec));
66        let act = match status {
67            Status::Registered | Status::Stale => Some((Act::Remove, "corundum-launcher-remove")),
68            Status::Absent => Some((Act::Add, "corundum-launcher-add")),
69            Status::Unsupported => None,
70        };
71        #[cfg(test)]
72        {
73            self.shown = act.map(|(_, id)| id);
74        }
75        let mut clicked = None;
76        ui.horizontal(|ui| {
77            ui.label(tr!(ui, "corundum-launcher-in-menu"));
78            if let Some((act, id)) = act
79                && ui.button(tr!(ui, id)).clicked()
80            {
81                clicked = Some(act);
82            }
83        });
84        if let Some(act) = clicked {
85            self.act(ui.ctx(), act);
86        }
87        if let Some(error) = &self.error {
88            ui.colored_label(
89                ui.visuals().error_fg_color,
90                tr!(ui, "corundum-launcher-failed", error = error.as_str()),
91            );
92        }
93    }
94
95    /// Remove or add the entry, keep the choice, and look at the entry again next frame.
96    #[cfg_attr(not(feature = "serde"), allow(unused_variables))]
97    fn act(&mut self, ctx: &egui::Context, act: Act) {
98        let done = match act {
99            Act::Remove => launcher::unregister(&self.spec),
100            Act::Add => launcher::register(&self.spec).map(|_| ()),
101        };
102        match done {
103            Ok(()) => {
104                self.error = None;
105                #[cfg(feature = "serde")]
106                crate::store::update(ctx, |s| {
107                    s.launcher = Some(matches!(act, Act::Add));
108                });
109            }
110            Err(err) => self.error = Some(err.to_string()),
111        }
112        self.status = None;
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::test_support::render;
120    use std::sync::atomic::{AtomicUsize, Ordering};
121
122    const SPEC: LauncherSpec = LauncherSpec {
123        id: "net.example.Test",
124        name: "Test",
125        description: "A test app",
126        category: "Utility",
127        icon_svg: b"<svg/>",
128        bundle_prefix: "net.example",
129    };
130
131    fn shown(status_of: fn(&LauncherSpec) -> Status) -> Option<&'static str> {
132        let mut row = LauncherRow::with_status(SPEC, status_of);
133        render([600.0, 400.0], |ui| row.ui(ui));
134        row.shown
135    }
136
137    #[test]
138    fn an_absent_entry_offers_add_back() {
139        assert_eq!(shown(|_| Status::Absent), Some("corundum-launcher-add"));
140    }
141
142    #[test]
143    fn a_registered_or_stale_entry_offers_remove() {
144        assert_eq!(
145            shown(|_| Status::Registered),
146            Some("corundum-launcher-remove")
147        );
148        assert_eq!(shown(|_| Status::Stale), Some("corundum-launcher-remove"));
149    }
150
151    #[test]
152    fn without_an_app_menu_there_is_no_button() {
153        assert_eq!(shown(|_| Status::Unsupported), None);
154    }
155
156    #[test]
157    fn the_status_is_looked_up_once() {
158        static CALLS: AtomicUsize = AtomicUsize::new(0);
159        let mut row = LauncherRow::with_status(SPEC, |_| {
160            CALLS.fetch_add(1, Ordering::Relaxed);
161            Status::Absent
162        });
163        render([600.0, 400.0], |ui| row.ui(ui));
164        render([600.0, 400.0], |ui| row.ui(ui));
165        assert_eq!(CALLS.load(Ordering::Relaxed), 1);
166    }
167}