corundum_framework_gui/window/
mod.rs1#[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#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
53mod marked_text;
54#[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
61pub const IOS_SCENE_DELEGATE: &str = "CorundumSceneDelegate";
64
65pub type InstallFonts = Box<dyn FnOnce(&egui::Context)>;
67
68pub 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#[cfg(feature = "single-instance")]
85type OnActivate = Box<dyn Fn(Option<String>) + Send>;
86
87#[cfg(feature = "single-instance")]
89enum SingleInstance {
90 Acquire(&'static str),
91 #[cfg(test)]
93 AcquireIn(&'static str, std::path::PathBuf),
94 Held(instance::Instance),
95}
96
97#[cfg(feature = "single-instance")]
99enum Instancing {
100 NotAsked,
102 Keep(instance::Instance),
104 Exit,
106}
107
108impl WindowOptions {
109 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 pub fn inner_size(mut self, size: [f32; 2]) -> WindowOptions {
128 self.inner_size = size;
129 self
130 }
131
132 pub fn min_inner_size(mut self, size: [f32; 2]) -> WindowOptions {
134 self.min_inner_size = size;
135 self
136 }
137
138 pub fn fonts(mut self, install: impl FnOnce(&egui::Context) + 'static) -> WindowOptions {
141 self.fonts = Some(Box::new(install));
142 self
143 }
144
145 pub fn launcher(mut self, spec: LauncherSpec) -> WindowOptions {
151 self.launcher = Some(spec);
152 self
153 }
154
155 #[cfg_attr(not(test), allow(dead_code))]
157 pub(crate) fn launcher_spec(&self) -> Option<&LauncherSpec> {
158 self.launcher.as_ref()
159 }
160
161 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 #[cfg(feature = "serde")]
175 pub fn remember(mut self, store: crate::store::Store) -> WindowOptions {
176 self.store = Some(store);
177 self
178 }
179
180 #[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 #[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 #[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 #[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
237pub fn run(
244 options: WindowOptions,
245 create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
246) -> eframe::Result<()> {
247 let mut options = options;
248 #[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 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 #[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#[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#[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
349fn with_launcher_look(
352 viewport: egui::ViewportBuilder,
353 spec: &LauncherSpec,
354) -> egui::ViewportBuilder {
355 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
377pub 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 #[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 #[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 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}