Skip to main content

corundum_framework_gui/window/
mod.rs

1//! A desktop window: eframe on wgpu, with the renderer set up by [`gpu`](crate::gpu) and a
2//! CJK font behind egui's own.
3//!
4//! [`run`] is the whole of opening an app's desktop window; [`open`] is its lower half, for
5//! an entry that builds its own [`eframe::NativeOptions`]. With the `android` feature,
6//! `run_android` opens the window on an Android phone. The sapphire apps
7//! and asteria open their windows through here, so a renderer fix reaches all of them.
8//!
9//! On iOS, an app built with the iOS 27 SDK must declare a scene in its Info.plist, with
10//! [`IOS_SCENE_DELEGATE`] as the scene's delegate: iOS 27 stops at launch an app that does
11//! not, and winit 0.30 does not put its window in a scene by itself (#37).
12//!
13//! ```yaml
14//! UIApplicationSceneManifest:
15//!   UIApplicationSupportsMultipleScenes: false
16//!   UISceneConfigurations:
17//!     UIWindowSceneSessionRoleApplication:
18//!       - UISceneConfigurationName: Default
19//!         UISceneDelegateClassName: CorundumSceneDelegate
20//! ```
21//!
22//! ```no_run
23//! use corundum_framework_gui::{eframe, egui, window};
24//!
25//! struct Example;
26//!
27//! impl eframe::App for Example {
28//!     fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
29//!         ui.label("Hello");
30//!     }
31//! }
32//!
33//! fn main() -> eframe::Result<()> {
34//!     window::run(
35//!         window::WindowOptions::new("Example").inner_size([800.0, 500.0]),
36//!         |_cc| Box::new(Example),
37//!     )
38//! }
39//! ```
40
41#[cfg(feature = "single-instance")]
42use corundum_framework_platform::instance;
43use corundum_framework_platform::launcher::{self, LauncherSpec};
44
45use crate::gpu;
46
47#[cfg(all(target_os = "android", feature = "android"))]
48mod android;
49#[cfg(target_os = "ios")]
50mod ios;
51// Used by the iOS window; kept platform-free so its tests run on a desktop.
52#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
53mod marked_text;
54// Used by the Android window; kept platform-free so its tests run on a desktop.
55#[cfg_attr(not(all(target_os = "android", feature = "android")), allow(dead_code))]
56mod text_input;
57
58#[cfg(all(target_os = "android", feature = "android"))]
59pub use android::{AndroidApp, run_android};
60
61/// The class of the scene delegate an iOS app names in its Info.plist's scene manifest
62/// (`UISceneDelegateClassName`). It shows the window once the app's scene connects.
63pub const IOS_SCENE_DELEGATE: &str = "CorundumSceneDelegate";
64
65/// Sets the egui context's fonts, once, before the app is built.
66pub type InstallFonts = Box<dyn FnOnce(&egui::Context)>;
67
68/// A desktop window: its title, its size, its fonts and the app's entry in the OS's app menu.
69pub struct WindowOptions {
70    title: String,
71    inner_size: [f32; 2],
72    min_inner_size: [f32; 2],
73    fonts: Option<InstallFonts>,
74    launcher: Option<LauncherSpec>,
75    #[cfg(feature = "serde")]
76    store: Option<crate::store::Store>,
77    #[cfg(feature = "single-instance")]
78    single_instance: Option<SingleInstance>,
79    #[cfg(feature = "single-instance")]
80    on_activate: Option<OnActivate>,
81}
82
83/// What [`WindowOptions::on_activate`] calls.
84#[cfg(feature = "single-instance")]
85type OnActivate = Box<dyn Fn(Option<String>) + Send>;
86
87/// How [`run`] keeps the app to one window: the lock it takes, or the one the app took.
88#[cfg(feature = "single-instance")]
89enum SingleInstance {
90    Acquire(&'static str),
91    /// [`Acquire`](Self::Acquire) with the lock in a directory of the tests' own.
92    #[cfg(test)]
93    AcquireIn(&'static str, std::path::PathBuf),
94    Held(instance::Instance),
95}
96
97/// What [`run`] does about other starts of the app.
98#[cfg(feature = "single-instance")]
99enum Instancing {
100    /// The app is not kept to one window.
101    NotAsked,
102    /// This start is the app's instance, kept while the window is open.
103    Keep(instance::Instance),
104    /// Another start runs and has been asked to come to the front: open nothing.
105    Exit,
106}
107
108impl WindowOptions {
109    /// A window titled `title`, 900 × 600 and no smaller than 600 × 400.
110    pub fn new(title: impl Into<String>) -> WindowOptions {
111        WindowOptions {
112            title: title.into(),
113            inner_size: [900.0, 600.0],
114            min_inner_size: [600.0, 400.0],
115            fonts: None,
116            launcher: None,
117            #[cfg(feature = "serde")]
118            store: None,
119            #[cfg(feature = "single-instance")]
120            single_instance: None,
121            #[cfg(feature = "single-instance")]
122            on_activate: None,
123        }
124    }
125
126    /// The window's size when it opens.
127    pub fn inner_size(mut self, size: [f32; 2]) -> WindowOptions {
128        self.inner_size = size;
129        self
130    }
131
132    /// The smallest the window can be made.
133    pub fn min_inner_size(mut self, size: [f32; 2]) -> WindowOptions {
134        self.min_inner_size = size;
135        self
136    }
137
138    /// Set the fonts with `install` instead of the default: egui's own, with a CJK font
139    /// the OS has behind them ([`fonts::install_system_cjk_fallback`](crate::fonts::install_system_cjk_fallback)).
140    pub fn fonts(mut self, install: impl FnOnce(&egui::Context) + 'static) -> WindowOptions {
141        self.fonts = Some(Box::new(install));
142        self
143    }
144
145    /// Put the app in the OS's app menu as `spec` as the window opens
146    /// ([`launcher::refresh`]: in an AppImage only an entry the person asked for is kept up
147    /// to date), and give the window `spec`'s id and icon, so the OS matches
148    /// the running window to the entry. With [`remember`](Self::remember), an entry the
149    /// person took out ([`LauncherRow`](crate::settings::LauncherRow)) is not written back.
150    pub fn launcher(mut self, spec: LauncherSpec) -> WindowOptions {
151        self.launcher = Some(spec);
152        self
153    }
154
155    /// The spec the app's entry in the app menu is made from, if one is set.
156    #[cfg_attr(not(test), allow(dead_code))]
157    pub(crate) fn launcher_spec(&self) -> Option<&LauncherSpec> {
158        self.launcher.as_ref()
159    }
160
161    /// Whether `run` registers the app's entry: unless the store says the person took it
162    /// out. Without a store it always does.
163    fn wants_launcher(&self) -> bool {
164        #[cfg(feature = "serde")]
165        if let Some(store) = &self.store {
166            return crate::store::wants_launcher(&store.load().unwrap_or_default());
167        }
168        true
169    }
170
171    /// Keep corundum's file in `store`: the look saved there is installed before the app's first
172    /// frame, and the [`Shell`](crate::Shell) takes the rail's order saved there; from then
173    /// on [`look::apply`](crate::look::apply) and the shell's menu save their changes there.
174    #[cfg(feature = "serde")]
175    pub fn remember(mut self, store: crate::store::Store) -> WindowOptions {
176        self.store = Some(store);
177        self
178    }
179
180    /// Run the app once: [`run`] takes `app_name`'s lock
181    /// ([`instance::acquire_or_activate`]) before the window opens. A start that finds the
182    /// app already running asks it to come to the front and returns without a window; the
183    /// app's `main` then ends. One that finds it closing waits a moment and opens instead. The running window, asked, is unminimized and focused.
184    ///
185    /// Set only where the app opens its window: a server or a command line started from
186    /// the same binary runs beside it. On iOS and Android this does nothing.
187    #[cfg(feature = "single-instance")]
188    pub fn single_instance(mut self, app_name: &'static str) -> WindowOptions {
189        self.single_instance = Some(SingleInstance::Acquire(app_name));
190        self
191    }
192
193    /// [`single_instance`](Self::single_instance) with a lock the app already took, for an
194    /// app that must know it is the instance before the window opens: one that applies a
195    /// downloaded update at start, which a second start must not do under the first.
196    #[cfg(feature = "single-instance")]
197    pub fn instance(mut self, instance: instance::Instance) -> WindowOptions {
198        self.single_instance = Some(SingleInstance::Held(instance));
199        self
200    }
201
202    /// Call `f` when a second start asks this window to come to the front, after it has,
203    /// with the message the start sent ([`instance::AlreadyRunning::activate_with`]), or
204    /// `None`: which page to show, say. Only with [`single_instance`](Self::single_instance)
205    /// or [`instance`](Self::instance).
206    ///
207    /// `f` runs on the listener's thread, not the UI's: hand the message to the app (a
208    /// channel it reads each frame) and return. A start that asks more than once, as one
209    /// does while this window is still opening, calls it more than once.
210    /// [`single_instance`](Self::single_instance) sends no message: a start that has one
211    /// takes the lock itself with [`instance::acquire_or_activate_with`] and passes it to
212    /// [`instance`](Self::instance).
213    #[cfg(feature = "single-instance")]
214    pub fn on_activate(mut self, f: impl Fn(Option<String>) + Send + 'static) -> WindowOptions {
215        self.on_activate = Some(Box::new(f));
216        self
217    }
218
219    /// `create`, after attaching corundum's store to the context when there is one.
220    #[cfg_attr(not(feature = "serde"), allow(unused_mut))]
221    pub(crate) fn wrap_create<'f>(
222        &mut self,
223        create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'f,
224    ) -> impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'f {
225        #[cfg(feature = "serde")]
226        let store = self.store.take();
227        move |cc| {
228            #[cfg(feature = "serde")]
229            if let Some(store) = store {
230                crate::store::attach(&cc.egui_ctx, store);
231            }
232            create(cc)
233        }
234    }
235}
236
237/// Open a desktop window and run it until it closes.
238///
239/// Installs [`gpu::install_panic_hook`], opens the window on wgpu with
240/// [`gpu::wgpu_options`], and builds the app with `create` once the window exists. With
241/// [`WindowOptions::single_instance`], a start that finds the app running brings its window
242/// to the front instead, and returns at once.
243pub fn run(
244    options: WindowOptions,
245    create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
246) -> eframe::Result<()> {
247    let mut options = options;
248    // First of all: a second start opens nothing and writes nothing.
249    #[cfg(feature = "single-instance")]
250    let mut instance = match options.take_instance() {
251        Instancing::NotAsked => None,
252        Instancing::Keep(instance) => Some(instance),
253        Instancing::Exit => return Ok(()),
254    };
255    #[cfg(feature = "single-instance")]
256    let create = {
257        let instance = &mut instance;
258        let then = options.on_activate.take();
259        move |cc: &eframe::CreationContext<'_>| {
260            if let Some(instance) = instance {
261                come_to_front_on_activate(instance, &cc.egui_ctx, then);
262            }
263            create(cc)
264        }
265    };
266    // Before the store is handed to the context: it is read here for the launcher.
267    let wants_launcher = options.wants_launcher();
268    let create = options.wrap_create(create);
269    let WindowOptions {
270        title,
271        inner_size,
272        min_inner_size,
273        fonts,
274        launcher,
275        ..
276    } = options;
277    gpu::install_panic_hook();
278    let mut viewport = egui::ViewportBuilder::default()
279        .with_inner_size(inner_size)
280        .with_min_inner_size(min_inner_size)
281        .with_title(&title);
282    if let Some(spec) = launcher {
283        if wants_launcher && let Err(err) = launcher::refresh(&spec) {
284            tracing::warn!("could not put {} in the app menu: {err}", spec.id);
285        }
286        viewport = with_launcher_look(viewport, &spec);
287    }
288    let native = eframe::NativeOptions {
289        viewport,
290        wgpu_options: gpu::wgpu_options(),
291        ..Default::default()
292    };
293    open(&title, native, fonts, create)
294}
295
296impl WindowOptions {
297    /// Whether this start is the app's instance, taking the lock where it is asked to.
298    #[cfg(feature = "single-instance")]
299    fn take_instance(&mut self) -> Instancing {
300        let (app_name, instance) = match self.single_instance.take() {
301            None => return Instancing::NotAsked,
302            Some(SingleInstance::Held(instance)) => return Instancing::Keep(instance),
303            Some(SingleInstance::Acquire(app_name)) => {
304                (app_name, instance::acquire_or_activate(app_name))
305            }
306            #[cfg(test)]
307            Some(SingleInstance::AcquireIn(app_name, dir)) => {
308                (app_name, instance::acquire_or_activate_in(app_name, &dir))
309            }
310        };
311        match instance {
312            Some(instance) => Instancing::Keep(instance),
313            None => {
314                tracing::info!("{app_name} is running already: brought it to the front");
315                Instancing::Exit
316            }
317        }
318    }
319}
320
321/// Bring the app's main window to the front whenever a second start asks.
322#[cfg(feature = "single-instance")]
323fn come_to_front_on_activate(
324    instance: &mut instance::Instance,
325    ctx: &egui::Context,
326    then: Option<OnActivate>,
327) {
328    let ctx = ctx.clone();
329    instance.on_activate_with(move |message| {
330        come_to_front(&ctx);
331        if let Some(then) = &then {
332            then(message.map(str::to_owned));
333        }
334    });
335}
336
337/// Unminimize and focus the app's main window, from any thread: the commands wait for the
338/// frame the repaint asks for.
339#[cfg(feature = "single-instance")]
340fn come_to_front(ctx: &egui::Context) {
341    ctx.send_viewport_cmd_to(
342        egui::ViewportId::ROOT,
343        egui::ViewportCommand::Minimized(false),
344    );
345    ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
346    ctx.request_repaint();
347}
348
349/// `viewport` with `spec`'s id, and its icon where it can be rendered, so the OS matches the
350/// window to the app's entry in the app menu.
351fn with_launcher_look(
352    viewport: egui::ViewportBuilder,
353    spec: &LauncherSpec,
354) -> egui::ViewportBuilder {
355    // Before the window exists, so the taskbar groups it with the app's entry.
356    if let Err(err) = launcher::set_process_app_id(spec) {
357        tracing::warn!("could not set the app id of {}: {err}", spec.id);
358    }
359    let viewport = viewport.with_app_id(spec.id);
360    #[cfg(not(any(target_os = "ios", target_os = "android")))]
361    {
362        const SIZE: u32 = 256;
363        match corundum_framework_platform::icon::render_rgba(spec.icon_svg, SIZE) {
364            Ok(rgba) => {
365                return viewport.with_icon(std::sync::Arc::new(egui::IconData {
366                    rgba,
367                    width: SIZE,
368                    height: SIZE,
369                }));
370            }
371            Err(err) => tracing::warn!("could not render the icon of {}: {err}", spec.id),
372        }
373    }
374    viewport
375}
376
377/// Run eframe with `native` as the caller built it, building the app with `create` once the
378/// window exists: reports the renderer ([`gpu::on_render_state`]) and installs `fonts`, or
379/// the default ones when `None`. [`run`] is this for a desktop window.
380pub fn open(
381    title: &str,
382    native: eframe::NativeOptions,
383    fonts: Option<InstallFonts>,
384    create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
385) -> eframe::Result<()> {
386    // Before the event loop starts: UIKit makes the app's scene as it launches (#37).
387    #[cfg(target_os = "ios")]
388    ios::scene::adopt();
389    eframe::run_native(
390        title,
391        native,
392        Box::new(move |cc| {
393            gpu::on_render_state(cc.wgpu_render_state.as_ref());
394            match fonts {
395                Some(install) => install(&cc.egui_ctx),
396                None => {
397                    crate::fonts::install_system_cjk_fallback(&cc.egui_ctx);
398                }
399            }
400            let app = create(cc);
401            // eframe stretches its surface over an iPhone's screen (#20): see `ios`.
402            #[cfg(target_os = "ios")]
403            let app = ios::full_screen(cc, app);
404            Ok(app)
405        }),
406    )
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn options_default_and_override_the_window_size() {
415        let options = WindowOptions::new("Example");
416        assert_eq!(options.title, "Example");
417        assert_eq!(options.inner_size, [900.0, 600.0]);
418        assert_eq!(options.min_inner_size, [600.0, 400.0]);
419        assert!(options.fonts.is_none());
420        let options = options
421            .inner_size([1000.0, 640.0])
422            .min_inner_size([720.0, 440.0])
423            .fonts(|_| {});
424        assert_eq!(options.inner_size, [1000.0, 640.0]);
425        assert_eq!(options.min_inner_size, [720.0, 440.0]);
426        assert!(options.fonts.is_some());
427    }
428
429    #[test]
430    fn options_keep_the_launcher_spec() {
431        const SPEC: LauncherSpec = LauncherSpec {
432            id: "net.example.Test",
433            name: "Test",
434            description: "A test app",
435            category: "Utility",
436            icon_svg: b"<svg/>",
437            bundle_prefix: "net.example",
438        };
439        assert!(WindowOptions::new("x").launcher_spec().is_none());
440        let options = WindowOptions::new("x").launcher(SPEC);
441        assert_eq!(
442            options.launcher_spec().map(|s| s.id),
443            Some("net.example.Test")
444        );
445    }
446
447    #[cfg(feature = "single-instance")]
448    #[test]
449    fn on_activate_is_kept_for_the_instance() {
450        let options = WindowOptions::new("x");
451        assert!(options.on_activate.is_none());
452        let options = options.on_activate(|_| {});
453        assert!(options.on_activate.is_some());
454    }
455
456    #[cfg(feature = "single-instance")]
457    #[test]
458    fn options_run_any_number_of_times_unless_asked_not_to() {
459        let mut options = WindowOptions::new("x");
460        assert!(matches!(options.take_instance(), Instancing::NotAsked));
461        let options = WindowOptions::new("x").single_instance("example-app");
462        assert!(matches!(
463            options.single_instance,
464            Some(SingleInstance::Acquire("example-app"))
465        ));
466    }
467
468    #[cfg(feature = "single-instance")]
469    #[test]
470    fn a_second_start_through_the_window_options_exits() {
471        let dir = tempfile::tempdir().unwrap();
472        let options = || WindowOptions {
473            single_instance: Some(SingleInstance::AcquireIn(
474                "example-app",
475                dir.path().to_owned(),
476            )),
477            ..WindowOptions::new("x")
478        };
479        let Instancing::Keep(mut first) = options().take_instance() else {
480            panic!("the first start is the instance");
481        };
482        first.on_activate(|| {});
483        assert!(
484            matches!(options().take_instance(), Instancing::Exit),
485            "the second start opens no window"
486        );
487        // Once the first is gone, a start is the instance again.
488        drop(first);
489        assert!(matches!(options().take_instance(), Instancing::Keep(_)));
490    }
491
492    #[cfg(feature = "single-instance")]
493    #[test]
494    fn an_activation_unminimizes_and_focuses_the_main_window() {
495        let ctx = egui::Context::default();
496        come_to_front(&ctx);
497        let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
498        output.textures_delta.clear();
499        let commands = &output.viewport_output[&egui::ViewportId::ROOT].commands;
500        assert!(commands.contains(&egui::ViewportCommand::Minimized(false)));
501        assert!(commands.contains(&egui::ViewportCommand::Focus));
502    }
503}