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) and in a Windows
18/// or macOS package, whose installer owns the entry, the row draws nothing.
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, id) = match status {
67            Status::Registered | Status::Stale => (Act::Remove, "corundum-launcher-remove"),
68            Status::Absent => (Act::Add, "corundum-launcher-add"),
69            Status::Unsupported => {
70                #[cfg(test)]
71                {
72                    self.shown = None;
73                }
74                return;
75            }
76        };
77        #[cfg(test)]
78        {
79            self.shown = Some(id);
80        }
81        let mut clicked = None;
82        ui.horizontal(|ui| {
83            ui.label(tr!(ui, "corundum-launcher-in-menu"));
84            if ui.button(tr!(ui, id)).clicked() {
85                clicked = Some(act);
86            }
87        });
88        if let Some(act) = clicked {
89            self.act(ui.ctx(), act);
90        }
91        if let Some(error) = &self.error {
92            ui.colored_label(
93                ui.visuals().error_fg_color,
94                tr!(ui, "corundum-launcher-failed", error = error.as_str()),
95            );
96        }
97    }
98
99    /// Remove or add the entry, keep the choice, and look at the entry again next frame.
100    #[cfg_attr(not(feature = "serde"), allow(unused_variables))]
101    fn act(&mut self, ctx: &egui::Context, act: Act) {
102        let done = match act {
103            Act::Remove => launcher::unregister(&self.spec),
104            Act::Add => launcher::register(&self.spec).map(|_| ()),
105        };
106        match done {
107            Ok(()) => {
108                self.error = None;
109                #[cfg(feature = "serde")]
110                crate::store::update(ctx, |s| {
111                    s.launcher = Some(matches!(act, Act::Add));
112                });
113            }
114            Err(err) => self.error = Some(err.to_string()),
115        }
116        self.status = None;
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::test_support::render;
124    use std::sync::atomic::{AtomicUsize, Ordering};
125
126    const SPEC: LauncherSpec = LauncherSpec {
127        id: "net.example.Test",
128        name: "Test",
129        description: "A test app",
130        category: "Utility",
131        icon_svg: b"<svg/>",
132        bundle_prefix: "net.example",
133    };
134
135    fn shown(status_of: fn(&LauncherSpec) -> Status) -> Option<&'static str> {
136        let mut row = LauncherRow::with_status(SPEC, status_of);
137        render([600.0, 400.0], |ui| row.ui(ui));
138        row.shown
139    }
140
141    #[test]
142    fn an_absent_entry_offers_add_back() {
143        assert_eq!(shown(|_| Status::Absent), Some("corundum-launcher-add"));
144    }
145
146    #[test]
147    fn a_registered_or_stale_entry_offers_remove() {
148        assert_eq!(
149            shown(|_| Status::Registered),
150            Some("corundum-launcher-remove")
151        );
152        assert_eq!(shown(|_| Status::Stale), Some("corundum-launcher-remove"));
153    }
154
155    #[test]
156    fn without_an_app_menu_there_is_no_button() {
157        assert_eq!(shown(|_| Status::Unsupported), None);
158    }
159
160    #[test]
161    fn without_an_app_menu_the_row_is_not_drawn() {
162        let drawn = |status_of: fn(&LauncherSpec) -> Status| {
163            let mut row = LauncherRow::with_status(SPEC, status_of);
164            let mut moved = false;
165            render([600.0, 400.0], |ui| {
166                let before = ui.cursor().min;
167                row.ui(ui);
168                moved = ui.cursor().min != before;
169            });
170            moved
171        };
172        assert!(drawn(|_| Status::Absent));
173        assert!(!drawn(|_| Status::Unsupported));
174    }
175
176    #[test]
177    fn the_status_is_looked_up_once() {
178        static CALLS: AtomicUsize = AtomicUsize::new(0);
179        let mut row = LauncherRow::with_status(SPEC, |_| {
180            CALLS.fetch_add(1, Ordering::Relaxed);
181            Status::Absent
182        });
183        render([600.0, 400.0], |ui| row.ui(ui));
184        render([600.0, 400.0], |ui| row.ui(ui));
185        assert_eq!(CALLS.load(Ordering::Relaxed), 1);
186    }
187}