corundum-framework-gui 0.4.0

The look shared by sapphire and asteria apps: egui shell, system fonts, Lucide icons, translations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! A desktop window: eframe on wgpu, with the renderer set up by [`gpu`](crate::gpu) and a
//! CJK font behind egui's own.
//!
//! [`run`] is the whole of opening an app's desktop window; [`open`] is its lower half, for
//! an entry that builds its own [`eframe::NativeOptions`]. With the `android` feature,
//! `run_android` opens the window on an Android phone. The sapphire apps
//! and asteria open their windows through here, so a renderer fix reaches all of them.
//!
//! On iOS, an app built with the iOS 27 SDK must declare a scene in its Info.plist, with
//! [`IOS_SCENE_DELEGATE`] as the scene's delegate: iOS 27 stops at launch an app that does
//! not, and winit 0.30 does not put its window in a scene by itself (#37).
//!
//! ```yaml
//! UIApplicationSceneManifest:
//!   UIApplicationSupportsMultipleScenes: false
//!   UISceneConfigurations:
//!     UIWindowSceneSessionRoleApplication:
//!       - UISceneConfigurationName: Default
//!         UISceneDelegateClassName: CorundumSceneDelegate
//! ```
//!
//! ```no_run
//! use corundum_framework_gui::{eframe, egui, window};
//!
//! struct Example;
//!
//! impl eframe::App for Example {
//!     fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
//!         ui.label("Hello");
//!     }
//! }
//!
//! fn main() -> eframe::Result<()> {
//!     window::run(
//!         window::WindowOptions::new("Example").inner_size([800.0, 500.0]),
//!         |_cc| Box::new(Example),
//!     )
//! }
//! ```

#[cfg(feature = "single-instance")]
use corundum_framework_platform::instance;
use corundum_framework_platform::launcher::{self, LauncherSpec};

use crate::gpu;

#[cfg(all(target_os = "android", feature = "android"))]
mod android;
#[cfg(target_os = "ios")]
mod ios;
// Used by the iOS window; kept platform-free so its tests run on a desktop.
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
mod marked_text;
// Used by the Android window; kept platform-free so its tests run on a desktop.
#[cfg_attr(not(all(target_os = "android", feature = "android")), allow(dead_code))]
mod text_input;

#[cfg(all(target_os = "android", feature = "android"))]
pub use android::{AndroidApp, run_android};

/// The class of the scene delegate an iOS app names in its Info.plist's scene manifest
/// (`UISceneDelegateClassName`). It shows the window once the app's scene connects.
pub const IOS_SCENE_DELEGATE: &str = "CorundumSceneDelegate";

/// Sets the egui context's fonts, once, before the app is built.
pub type InstallFonts = Box<dyn FnOnce(&egui::Context)>;

/// A desktop window: its title, its size, its fonts and the app's entry in the OS's app menu.
pub struct WindowOptions {
    title: String,
    inner_size: [f32; 2],
    min_inner_size: [f32; 2],
    fonts: Option<InstallFonts>,
    launcher: Option<LauncherSpec>,
    #[cfg(feature = "serde")]
    store: Option<crate::store::Store>,
    #[cfg(feature = "single-instance")]
    single_instance: Option<SingleInstance>,
}

/// How [`run`] keeps the app to one window: the lock it takes, or the one the app took.
#[cfg(feature = "single-instance")]
enum SingleInstance {
    Acquire(&'static str),
    /// [`Acquire`](Self::Acquire) with the lock in a directory of the tests' own.
    #[cfg(test)]
    AcquireIn(&'static str, std::path::PathBuf),
    Held(instance::Instance),
}

/// What [`run`] does about other starts of the app.
#[cfg(feature = "single-instance")]
enum Instancing {
    /// The app is not kept to one window.
    NotAsked,
    /// This start is the app's instance, kept while the window is open.
    Keep(instance::Instance),
    /// Another start runs and has been asked to come to the front: open nothing.
    Exit,
}

impl WindowOptions {
    /// A window titled `title`, 900 × 600 and no smaller than 600 × 400.
    pub fn new(title: impl Into<String>) -> WindowOptions {
        WindowOptions {
            title: title.into(),
            inner_size: [900.0, 600.0],
            min_inner_size: [600.0, 400.0],
            fonts: None,
            launcher: None,
            #[cfg(feature = "serde")]
            store: None,
            #[cfg(feature = "single-instance")]
            single_instance: None,
        }
    }

    /// The window's size when it opens.
    pub fn inner_size(mut self, size: [f32; 2]) -> WindowOptions {
        self.inner_size = size;
        self
    }

    /// The smallest the window can be made.
    pub fn min_inner_size(mut self, size: [f32; 2]) -> WindowOptions {
        self.min_inner_size = size;
        self
    }

    /// Set the fonts with `install` instead of the default: egui's own, with a CJK font
    /// the OS has behind them ([`fonts::install_system_cjk_fallback`](crate::fonts::install_system_cjk_fallback)).
    pub fn fonts(mut self, install: impl FnOnce(&egui::Context) + 'static) -> WindowOptions {
        self.fonts = Some(Box::new(install));
        self
    }

    /// Put the app in the OS's app menu as `spec` as the window opens
    /// ([`launcher::refresh`]: in an AppImage only an entry the person asked for is kept up
    /// to date), and give the window `spec`'s id and icon, so the OS matches
    /// the running window to the entry. With [`remember`](Self::remember), an entry the
    /// person took out ([`LauncherRow`](crate::settings::LauncherRow)) is not written back.
    pub fn launcher(mut self, spec: LauncherSpec) -> WindowOptions {
        self.launcher = Some(spec);
        self
    }

    /// The spec the app's entry in the app menu is made from, if one is set.
    #[cfg_attr(not(test), allow(dead_code))]
    pub(crate) fn launcher_spec(&self) -> Option<&LauncherSpec> {
        self.launcher.as_ref()
    }

    /// Whether `run` registers the app's entry: unless the store says the person took it
    /// out. Without a store it always does.
    fn wants_launcher(&self) -> bool {
        #[cfg(feature = "serde")]
        if let Some(store) = &self.store {
            return crate::store::wants_launcher(&store.load().unwrap_or_default());
        }
        true
    }

    /// Keep corundum's file in `store`: the look saved there is installed before the app's first
    /// frame, and the [`Shell`](crate::Shell) takes the rail's order saved there; from then
    /// on [`look::apply`](crate::look::apply) and the shell's menu save their changes there.
    #[cfg(feature = "serde")]
    pub fn remember(mut self, store: crate::store::Store) -> WindowOptions {
        self.store = Some(store);
        self
    }

    /// Run the app once: [`run`] takes `app_name`'s lock
    /// ([`instance::acquire_or_activate`]) before the window opens. A start that finds the
    /// app already running asks it to come to the front and returns without a window; the
    /// app's `main` then ends. One that finds it closing waits a moment and opens instead. The running window, asked, is unminimized and focused.
    ///
    /// Set only where the app opens its window: a server or a command line started from
    /// the same binary runs beside it. On iOS and Android this does nothing.
    #[cfg(feature = "single-instance")]
    pub fn single_instance(mut self, app_name: &'static str) -> WindowOptions {
        self.single_instance = Some(SingleInstance::Acquire(app_name));
        self
    }

    /// [`single_instance`](Self::single_instance) with a lock the app already took, for an
    /// app that must know it is the instance before the window opens: one that applies a
    /// downloaded update at start, which a second start must not do under the first.
    #[cfg(feature = "single-instance")]
    pub fn instance(mut self, instance: instance::Instance) -> WindowOptions {
        self.single_instance = Some(SingleInstance::Held(instance));
        self
    }

    /// `create`, after attaching corundum's store to the context when there is one.
    #[cfg_attr(not(feature = "serde"), allow(unused_mut))]
    pub(crate) fn wrap_create<'f>(
        &mut self,
        create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'f,
    ) -> impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'f {
        #[cfg(feature = "serde")]
        let store = self.store.take();
        move |cc| {
            #[cfg(feature = "serde")]
            if let Some(store) = store {
                crate::store::attach(&cc.egui_ctx, store);
            }
            create(cc)
        }
    }
}

/// Open a desktop window and run it until it closes.
///
/// Installs [`gpu::install_panic_hook`], opens the window on wgpu with
/// [`gpu::wgpu_options`], and builds the app with `create` once the window exists. With
/// [`WindowOptions::single_instance`], a start that finds the app running brings its window
/// to the front instead, and returns at once.
pub fn run(
    options: WindowOptions,
    create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
) -> eframe::Result<()> {
    let mut options = options;
    // First of all: a second start opens nothing and writes nothing.
    #[cfg(feature = "single-instance")]
    let mut instance = match options.take_instance() {
        Instancing::NotAsked => None,
        Instancing::Keep(instance) => Some(instance),
        Instancing::Exit => return Ok(()),
    };
    #[cfg(feature = "single-instance")]
    let create = {
        let instance = &mut instance;
        move |cc: &eframe::CreationContext<'_>| {
            if let Some(instance) = instance {
                come_to_front_on_activate(instance, &cc.egui_ctx);
            }
            create(cc)
        }
    };
    // Before the store is handed to the context: it is read here for the launcher.
    let wants_launcher = options.wants_launcher();
    let create = options.wrap_create(create);
    let WindowOptions {
        title,
        inner_size,
        min_inner_size,
        fonts,
        launcher,
        ..
    } = options;
    gpu::install_panic_hook();
    let mut viewport = egui::ViewportBuilder::default()
        .with_inner_size(inner_size)
        .with_min_inner_size(min_inner_size)
        .with_title(&title);
    if let Some(spec) = launcher {
        if wants_launcher && let Err(err) = launcher::refresh(&spec) {
            tracing::warn!("could not put {} in the app menu: {err}", spec.id);
        }
        viewport = with_launcher_look(viewport, &spec);
    }
    let native = eframe::NativeOptions {
        viewport,
        wgpu_options: gpu::wgpu_options(),
        ..Default::default()
    };
    open(&title, native, fonts, create)
}

impl WindowOptions {
    /// Whether this start is the app's instance, taking the lock where it is asked to.
    #[cfg(feature = "single-instance")]
    fn take_instance(&mut self) -> Instancing {
        let (app_name, instance) = match self.single_instance.take() {
            None => return Instancing::NotAsked,
            Some(SingleInstance::Held(instance)) => return Instancing::Keep(instance),
            Some(SingleInstance::Acquire(app_name)) => {
                (app_name, instance::acquire_or_activate(app_name))
            }
            #[cfg(test)]
            Some(SingleInstance::AcquireIn(app_name, dir)) => {
                (app_name, instance::acquire_or_activate_in(app_name, &dir))
            }
        };
        match instance {
            Some(instance) => Instancing::Keep(instance),
            None => {
                tracing::info!("{app_name} is running already: brought it to the front");
                Instancing::Exit
            }
        }
    }
}

/// Bring the app's main window to the front whenever a second start asks.
#[cfg(feature = "single-instance")]
fn come_to_front_on_activate(instance: &mut instance::Instance, ctx: &egui::Context) {
    let ctx = ctx.clone();
    instance.on_activate(move || come_to_front(&ctx));
}

/// Unminimize and focus the app's main window, from any thread: the commands wait for the
/// frame the repaint asks for.
#[cfg(feature = "single-instance")]
fn come_to_front(ctx: &egui::Context) {
    ctx.send_viewport_cmd_to(
        egui::ViewportId::ROOT,
        egui::ViewportCommand::Minimized(false),
    );
    ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
    ctx.request_repaint();
}

/// `viewport` with `spec`'s id, and its icon where it can be rendered, so the OS matches the
/// window to the app's entry in the app menu.
fn with_launcher_look(
    viewport: egui::ViewportBuilder,
    spec: &LauncherSpec,
) -> egui::ViewportBuilder {
    // Before the window exists, so the taskbar groups it with the app's entry.
    if let Err(err) = launcher::set_process_app_id(spec) {
        tracing::warn!("could not set the app id of {}: {err}", spec.id);
    }
    let viewport = viewport.with_app_id(spec.id);
    #[cfg(not(any(target_os = "ios", target_os = "android")))]
    {
        const SIZE: u32 = 256;
        match corundum_framework_platform::icon::render_rgba(spec.icon_svg, SIZE) {
            Ok(rgba) => {
                return viewport.with_icon(std::sync::Arc::new(egui::IconData {
                    rgba,
                    width: SIZE,
                    height: SIZE,
                }));
            }
            Err(err) => tracing::warn!("could not render the icon of {}: {err}", spec.id),
        }
    }
    viewport
}

/// Run eframe with `native` as the caller built it, building the app with `create` once the
/// window exists: reports the renderer ([`gpu::on_render_state`]) and installs `fonts`, or
/// the default ones when `None`. [`run`] is this for a desktop window.
pub fn open(
    title: &str,
    native: eframe::NativeOptions,
    fonts: Option<InstallFonts>,
    create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
) -> eframe::Result<()> {
    // Before the event loop starts: UIKit makes the app's scene as it launches (#37).
    #[cfg(target_os = "ios")]
    ios::scene::adopt();
    eframe::run_native(
        title,
        native,
        Box::new(move |cc| {
            gpu::on_render_state(cc.wgpu_render_state.as_ref());
            match fonts {
                Some(install) => install(&cc.egui_ctx),
                None => {
                    crate::fonts::install_system_cjk_fallback(&cc.egui_ctx);
                }
            }
            let app = create(cc);
            // eframe stretches its surface over an iPhone's screen (#20): see `ios`.
            #[cfg(target_os = "ios")]
            let app = ios::full_screen(cc, app);
            Ok(app)
        }),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn options_default_and_override_the_window_size() {
        let options = WindowOptions::new("Example");
        assert_eq!(options.title, "Example");
        assert_eq!(options.inner_size, [900.0, 600.0]);
        assert_eq!(options.min_inner_size, [600.0, 400.0]);
        assert!(options.fonts.is_none());
        let options = options
            .inner_size([1000.0, 640.0])
            .min_inner_size([720.0, 440.0])
            .fonts(|_| {});
        assert_eq!(options.inner_size, [1000.0, 640.0]);
        assert_eq!(options.min_inner_size, [720.0, 440.0]);
        assert!(options.fonts.is_some());
    }

    #[test]
    fn options_keep_the_launcher_spec() {
        const SPEC: LauncherSpec = LauncherSpec {
            id: "net.example.Test",
            name: "Test",
            description: "A test app",
            category: "Utility",
            icon_svg: b"<svg/>",
            bundle_prefix: "net.example",
        };
        assert!(WindowOptions::new("x").launcher_spec().is_none());
        let options = WindowOptions::new("x").launcher(SPEC);
        assert_eq!(
            options.launcher_spec().map(|s| s.id),
            Some("net.example.Test")
        );
    }

    #[cfg(feature = "single-instance")]
    #[test]
    fn options_run_any_number_of_times_unless_asked_not_to() {
        let mut options = WindowOptions::new("x");
        assert!(matches!(options.take_instance(), Instancing::NotAsked));
        let options = WindowOptions::new("x").single_instance("example-app");
        assert!(matches!(
            options.single_instance,
            Some(SingleInstance::Acquire("example-app"))
        ));
    }

    #[cfg(feature = "single-instance")]
    #[test]
    fn a_second_start_through_the_window_options_exits() {
        let dir = tempfile::tempdir().unwrap();
        let options = || WindowOptions {
            single_instance: Some(SingleInstance::AcquireIn(
                "example-app",
                dir.path().to_owned(),
            )),
            ..WindowOptions::new("x")
        };
        let Instancing::Keep(mut first) = options().take_instance() else {
            panic!("the first start is the instance");
        };
        first.on_activate(|| {});
        assert!(
            matches!(options().take_instance(), Instancing::Exit),
            "the second start opens no window"
        );
        // Once the first is gone, a start is the instance again.
        drop(first);
        assert!(matches!(options().take_instance(), Instancing::Keep(_)));
    }

    #[cfg(feature = "single-instance")]
    #[test]
    fn an_activation_unminimizes_and_focuses_the_main_window() {
        let ctx = egui::Context::default();
        come_to_front(&ctx);
        let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
        output.textures_delta.clear();
        let commands = &output.viewport_output[&egui::ViewportId::ROOT].commands;
        assert!(commands.contains(&egui::ViewportCommand::Minimized(false)));
        assert!(commands.contains(&egui::ViewportCommand::Focus));
    }
}