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
41use corundum_framework_platform::launcher::{self, LauncherSpec};
42
43use crate::gpu;
44
45#[cfg(all(target_os = "android", feature = "android"))]
46mod android;
47#[cfg(target_os = "ios")]
48mod ios;
49// Used by the iOS window; kept platform-free so its tests run on a desktop.
50#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
51mod marked_text;
52// Used by the Android window; kept platform-free so its tests run on a desktop.
53#[cfg_attr(not(all(target_os = "android", feature = "android")), allow(dead_code))]
54mod text_input;
55
56#[cfg(all(target_os = "android", feature = "android"))]
57pub use android::{AndroidApp, run_android};
58
59/// The class of the scene delegate an iOS app names in its Info.plist's scene manifest
60/// (`UISceneDelegateClassName`). It shows the window once the app's scene connects.
61pub const IOS_SCENE_DELEGATE: &str = "CorundumSceneDelegate";
62
63/// Sets the egui context's fonts, once, before the app is built.
64pub type InstallFonts = Box<dyn FnOnce(&egui::Context)>;
65
66/// A desktop window: its title, its size, its fonts and the app's entry in the OS's app menu.
67pub struct WindowOptions {
68    title: String,
69    inner_size: [f32; 2],
70    min_inner_size: [f32; 2],
71    fonts: Option<InstallFonts>,
72    launcher: Option<LauncherSpec>,
73    #[cfg(feature = "serde")]
74    store: Option<crate::store::Store>,
75}
76
77impl WindowOptions {
78    /// A window titled `title`, 900 × 600 and no smaller than 600 × 400.
79    pub fn new(title: impl Into<String>) -> WindowOptions {
80        WindowOptions {
81            title: title.into(),
82            inner_size: [900.0, 600.0],
83            min_inner_size: [600.0, 400.0],
84            fonts: None,
85            launcher: None,
86            #[cfg(feature = "serde")]
87            store: None,
88        }
89    }
90
91    /// The window's size when it opens.
92    pub fn inner_size(mut self, size: [f32; 2]) -> WindowOptions {
93        self.inner_size = size;
94        self
95    }
96
97    /// The smallest the window can be made.
98    pub fn min_inner_size(mut self, size: [f32; 2]) -> WindowOptions {
99        self.min_inner_size = size;
100        self
101    }
102
103    /// Set the fonts with `install` instead of the default: egui's own, with a CJK font
104    /// the OS has behind them ([`fonts::install_system_cjk_fallback`](crate::fonts::install_system_cjk_fallback)).
105    pub fn fonts(mut self, install: impl FnOnce(&egui::Context) + 'static) -> WindowOptions {
106        self.fonts = Some(Box::new(install));
107        self
108    }
109
110    /// Put the app in the OS's app menu as `spec` as the window opens
111    /// ([`launcher::register`]), and give the window `spec`'s id and icon, so the OS matches
112    /// the running window to the entry. With [`remember`](Self::remember), an entry the
113    /// person took out ([`LauncherRow`](crate::settings::LauncherRow)) is not written back.
114    pub fn launcher(mut self, spec: LauncherSpec) -> WindowOptions {
115        self.launcher = Some(spec);
116        self
117    }
118
119    /// The spec the app's entry in the app menu is made from, if one is set.
120    #[cfg_attr(not(test), allow(dead_code))]
121    pub(crate) fn launcher_spec(&self) -> Option<&LauncherSpec> {
122        self.launcher.as_ref()
123    }
124
125    /// Whether `run` registers the app's entry: unless the store says the person took it
126    /// out. Without a store it always does.
127    fn wants_launcher(&self) -> bool {
128        #[cfg(feature = "serde")]
129        if let Some(store) = &self.store {
130            return crate::store::wants_launcher(&store.load().unwrap_or_default());
131        }
132        true
133    }
134
135    /// Keep corundum's file in `store`: the look saved there is installed before the app's first
136    /// frame, and the [`Shell`](crate::Shell) takes the rail's order saved there; from then
137    /// on [`look::apply`](crate::look::apply) and the shell's menu save their changes there.
138    #[cfg(feature = "serde")]
139    pub fn remember(mut self, store: crate::store::Store) -> WindowOptions {
140        self.store = Some(store);
141        self
142    }
143
144    /// `create`, after attaching corundum's store to the context when there is one.
145    #[cfg_attr(not(feature = "serde"), allow(unused_mut))]
146    pub(crate) fn wrap_create<'f>(
147        &mut self,
148        create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'f,
149    ) -> impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'f {
150        #[cfg(feature = "serde")]
151        let store = self.store.take();
152        move |cc| {
153            #[cfg(feature = "serde")]
154            if let Some(store) = store {
155                crate::store::attach(&cc.egui_ctx, store);
156            }
157            create(cc)
158        }
159    }
160}
161
162/// Open a desktop window and run it until it closes.
163///
164/// Installs [`gpu::install_panic_hook`], opens the window on wgpu with
165/// [`gpu::wgpu_options`], and builds the app with `create` once the window exists.
166pub fn run(
167    options: WindowOptions,
168    create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
169) -> eframe::Result<()> {
170    let mut options = options;
171    // Before the store is handed to the context: it is read here for the launcher.
172    let wants_launcher = options.wants_launcher();
173    let create = options.wrap_create(create);
174    let WindowOptions {
175        title,
176        inner_size,
177        min_inner_size,
178        fonts,
179        launcher,
180        ..
181    } = options;
182    gpu::install_panic_hook();
183    let mut viewport = egui::ViewportBuilder::default()
184        .with_inner_size(inner_size)
185        .with_min_inner_size(min_inner_size)
186        .with_title(&title);
187    if let Some(spec) = launcher {
188        if wants_launcher && let Err(err) = launcher::register(&spec) {
189            tracing::warn!("could not put {} in the app menu: {err}", spec.id);
190        }
191        viewport = with_launcher_look(viewport, &spec);
192    }
193    let native = eframe::NativeOptions {
194        viewport,
195        wgpu_options: gpu::wgpu_options(),
196        ..Default::default()
197    };
198    open(&title, native, fonts, create)
199}
200
201/// `viewport` with `spec`'s id, and its icon where it can be rendered, so the OS matches the
202/// window to the app's entry in the app menu.
203fn with_launcher_look(
204    viewport: egui::ViewportBuilder,
205    spec: &LauncherSpec,
206) -> egui::ViewportBuilder {
207    // Before the window exists, so the taskbar groups it with the app's entry.
208    if let Err(err) = launcher::set_process_app_id(spec) {
209        tracing::warn!("could not set the app id of {}: {err}", spec.id);
210    }
211    let viewport = viewport.with_app_id(spec.id);
212    #[cfg(not(any(target_os = "ios", target_os = "android")))]
213    {
214        const SIZE: u32 = 256;
215        match corundum_framework_platform::icon::render_rgba(spec.icon_svg, SIZE) {
216            Ok(rgba) => {
217                return viewport.with_icon(std::sync::Arc::new(egui::IconData {
218                    rgba,
219                    width: SIZE,
220                    height: SIZE,
221                }));
222            }
223            Err(err) => tracing::warn!("could not render the icon of {}: {err}", spec.id),
224        }
225    }
226    viewport
227}
228
229/// Run eframe with `native` as the caller built it, building the app with `create` once the
230/// window exists: reports the renderer ([`gpu::on_render_state`]) and installs `fonts`, or
231/// the default ones when `None`. [`run`] is this for a desktop window.
232pub fn open(
233    title: &str,
234    native: eframe::NativeOptions,
235    fonts: Option<InstallFonts>,
236    create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
237) -> eframe::Result<()> {
238    // Before the event loop starts: UIKit makes the app's scene as it launches (#37).
239    #[cfg(target_os = "ios")]
240    ios::scene::adopt();
241    eframe::run_native(
242        title,
243        native,
244        Box::new(move |cc| {
245            gpu::on_render_state(cc.wgpu_render_state.as_ref());
246            match fonts {
247                Some(install) => install(&cc.egui_ctx),
248                None => {
249                    crate::fonts::install_system_cjk_fallback(&cc.egui_ctx);
250                }
251            }
252            let app = create(cc);
253            // eframe stretches its surface over an iPhone's screen (#20): see `ios`.
254            #[cfg(target_os = "ios")]
255            let app = ios::full_screen(cc, app);
256            Ok(app)
257        }),
258    )
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn options_default_and_override_the_window_size() {
267        let options = WindowOptions::new("Example");
268        assert_eq!(options.title, "Example");
269        assert_eq!(options.inner_size, [900.0, 600.0]);
270        assert_eq!(options.min_inner_size, [600.0, 400.0]);
271        assert!(options.fonts.is_none());
272        let options = options
273            .inner_size([1000.0, 640.0])
274            .min_inner_size([720.0, 440.0])
275            .fonts(|_| {});
276        assert_eq!(options.inner_size, [1000.0, 640.0]);
277        assert_eq!(options.min_inner_size, [720.0, 440.0]);
278        assert!(options.fonts.is_some());
279    }
280
281    #[test]
282    fn options_keep_the_launcher_spec() {
283        const SPEC: LauncherSpec = LauncherSpec {
284            id: "net.example.Test",
285            name: "Test",
286            description: "A test app",
287            category: "Utility",
288            icon_svg: b"<svg/>",
289            bundle_prefix: "net.example",
290        };
291        assert!(WindowOptions::new("x").launcher_spec().is_none());
292        let options = WindowOptions::new("x").launcher(SPEC);
293        assert_eq!(
294            options.launcher_spec().map(|s| s.id),
295            Some("net.example.Test")
296        );
297    }
298}