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}
80
81#[cfg(feature = "single-instance")]
83enum SingleInstance {
84 Acquire(&'static str),
85 #[cfg(test)]
87 AcquireIn(&'static str, std::path::PathBuf),
88 Held(instance::Instance),
89}
90
91#[cfg(feature = "single-instance")]
93enum Instancing {
94 NotAsked,
96 Keep(instance::Instance),
98 Exit,
100}
101
102impl WindowOptions {
103 pub fn new(title: impl Into<String>) -> WindowOptions {
105 WindowOptions {
106 title: title.into(),
107 inner_size: [900.0, 600.0],
108 min_inner_size: [600.0, 400.0],
109 fonts: None,
110 launcher: None,
111 #[cfg(feature = "serde")]
112 store: None,
113 #[cfg(feature = "single-instance")]
114 single_instance: None,
115 }
116 }
117
118 pub fn inner_size(mut self, size: [f32; 2]) -> WindowOptions {
120 self.inner_size = size;
121 self
122 }
123
124 pub fn min_inner_size(mut self, size: [f32; 2]) -> WindowOptions {
126 self.min_inner_size = size;
127 self
128 }
129
130 pub fn fonts(mut self, install: impl FnOnce(&egui::Context) + 'static) -> WindowOptions {
133 self.fonts = Some(Box::new(install));
134 self
135 }
136
137 pub fn launcher(mut self, spec: LauncherSpec) -> WindowOptions {
143 self.launcher = Some(spec);
144 self
145 }
146
147 #[cfg_attr(not(test), allow(dead_code))]
149 pub(crate) fn launcher_spec(&self) -> Option<&LauncherSpec> {
150 self.launcher.as_ref()
151 }
152
153 fn wants_launcher(&self) -> bool {
156 #[cfg(feature = "serde")]
157 if let Some(store) = &self.store {
158 return crate::store::wants_launcher(&store.load().unwrap_or_default());
159 }
160 true
161 }
162
163 #[cfg(feature = "serde")]
167 pub fn remember(mut self, store: crate::store::Store) -> WindowOptions {
168 self.store = Some(store);
169 self
170 }
171
172 #[cfg(feature = "single-instance")]
180 pub fn single_instance(mut self, app_name: &'static str) -> WindowOptions {
181 self.single_instance = Some(SingleInstance::Acquire(app_name));
182 self
183 }
184
185 #[cfg(feature = "single-instance")]
189 pub fn instance(mut self, instance: instance::Instance) -> WindowOptions {
190 self.single_instance = Some(SingleInstance::Held(instance));
191 self
192 }
193
194 #[cfg_attr(not(feature = "serde"), allow(unused_mut))]
196 pub(crate) fn wrap_create<'f>(
197 &mut self,
198 create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'f,
199 ) -> impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App> + 'f {
200 #[cfg(feature = "serde")]
201 let store = self.store.take();
202 move |cc| {
203 #[cfg(feature = "serde")]
204 if let Some(store) = store {
205 crate::store::attach(&cc.egui_ctx, store);
206 }
207 create(cc)
208 }
209 }
210}
211
212pub fn run(
219 options: WindowOptions,
220 create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
221) -> eframe::Result<()> {
222 let mut options = options;
223 #[cfg(feature = "single-instance")]
225 let mut instance = match options.take_instance() {
226 Instancing::NotAsked => None,
227 Instancing::Keep(instance) => Some(instance),
228 Instancing::Exit => return Ok(()),
229 };
230 #[cfg(feature = "single-instance")]
231 let create = {
232 let instance = &mut instance;
233 move |cc: &eframe::CreationContext<'_>| {
234 if let Some(instance) = instance {
235 come_to_front_on_activate(instance, &cc.egui_ctx);
236 }
237 create(cc)
238 }
239 };
240 let wants_launcher = options.wants_launcher();
242 let create = options.wrap_create(create);
243 let WindowOptions {
244 title,
245 inner_size,
246 min_inner_size,
247 fonts,
248 launcher,
249 ..
250 } = options;
251 gpu::install_panic_hook();
252 let mut viewport = egui::ViewportBuilder::default()
253 .with_inner_size(inner_size)
254 .with_min_inner_size(min_inner_size)
255 .with_title(&title);
256 if let Some(spec) = launcher {
257 if wants_launcher && let Err(err) = launcher::refresh(&spec) {
258 tracing::warn!("could not put {} in the app menu: {err}", spec.id);
259 }
260 viewport = with_launcher_look(viewport, &spec);
261 }
262 let native = eframe::NativeOptions {
263 viewport,
264 wgpu_options: gpu::wgpu_options(),
265 ..Default::default()
266 };
267 open(&title, native, fonts, create)
268}
269
270impl WindowOptions {
271 #[cfg(feature = "single-instance")]
273 fn take_instance(&mut self) -> Instancing {
274 let (app_name, instance) = match self.single_instance.take() {
275 None => return Instancing::NotAsked,
276 Some(SingleInstance::Held(instance)) => return Instancing::Keep(instance),
277 Some(SingleInstance::Acquire(app_name)) => {
278 (app_name, instance::acquire_or_activate(app_name))
279 }
280 #[cfg(test)]
281 Some(SingleInstance::AcquireIn(app_name, dir)) => {
282 (app_name, instance::acquire_or_activate_in(app_name, &dir))
283 }
284 };
285 match instance {
286 Some(instance) => Instancing::Keep(instance),
287 None => {
288 tracing::info!("{app_name} is running already: brought it to the front");
289 Instancing::Exit
290 }
291 }
292 }
293}
294
295#[cfg(feature = "single-instance")]
297fn come_to_front_on_activate(instance: &mut instance::Instance, ctx: &egui::Context) {
298 let ctx = ctx.clone();
299 instance.on_activate(move || come_to_front(&ctx));
300}
301
302#[cfg(feature = "single-instance")]
305fn come_to_front(ctx: &egui::Context) {
306 ctx.send_viewport_cmd_to(
307 egui::ViewportId::ROOT,
308 egui::ViewportCommand::Minimized(false),
309 );
310 ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
311 ctx.request_repaint();
312}
313
314fn with_launcher_look(
317 viewport: egui::ViewportBuilder,
318 spec: &LauncherSpec,
319) -> egui::ViewportBuilder {
320 if let Err(err) = launcher::set_process_app_id(spec) {
322 tracing::warn!("could not set the app id of {}: {err}", spec.id);
323 }
324 let viewport = viewport.with_app_id(spec.id);
325 #[cfg(not(any(target_os = "ios", target_os = "android")))]
326 {
327 const SIZE: u32 = 256;
328 match corundum_framework_platform::icon::render_rgba(spec.icon_svg, SIZE) {
329 Ok(rgba) => {
330 return viewport.with_icon(std::sync::Arc::new(egui::IconData {
331 rgba,
332 width: SIZE,
333 height: SIZE,
334 }));
335 }
336 Err(err) => tracing::warn!("could not render the icon of {}: {err}", spec.id),
337 }
338 }
339 viewport
340}
341
342pub fn open(
346 title: &str,
347 native: eframe::NativeOptions,
348 fonts: Option<InstallFonts>,
349 create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
350) -> eframe::Result<()> {
351 #[cfg(target_os = "ios")]
353 ios::scene::adopt();
354 eframe::run_native(
355 title,
356 native,
357 Box::new(move |cc| {
358 gpu::on_render_state(cc.wgpu_render_state.as_ref());
359 match fonts {
360 Some(install) => install(&cc.egui_ctx),
361 None => {
362 crate::fonts::install_system_cjk_fallback(&cc.egui_ctx);
363 }
364 }
365 let app = create(cc);
366 #[cfg(target_os = "ios")]
368 let app = ios::full_screen(cc, app);
369 Ok(app)
370 }),
371 )
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn options_default_and_override_the_window_size() {
380 let options = WindowOptions::new("Example");
381 assert_eq!(options.title, "Example");
382 assert_eq!(options.inner_size, [900.0, 600.0]);
383 assert_eq!(options.min_inner_size, [600.0, 400.0]);
384 assert!(options.fonts.is_none());
385 let options = options
386 .inner_size([1000.0, 640.0])
387 .min_inner_size([720.0, 440.0])
388 .fonts(|_| {});
389 assert_eq!(options.inner_size, [1000.0, 640.0]);
390 assert_eq!(options.min_inner_size, [720.0, 440.0]);
391 assert!(options.fonts.is_some());
392 }
393
394 #[test]
395 fn options_keep_the_launcher_spec() {
396 const SPEC: LauncherSpec = LauncherSpec {
397 id: "net.example.Test",
398 name: "Test",
399 description: "A test app",
400 category: "Utility",
401 icon_svg: b"<svg/>",
402 bundle_prefix: "net.example",
403 };
404 assert!(WindowOptions::new("x").launcher_spec().is_none());
405 let options = WindowOptions::new("x").launcher(SPEC);
406 assert_eq!(
407 options.launcher_spec().map(|s| s.id),
408 Some("net.example.Test")
409 );
410 }
411
412 #[cfg(feature = "single-instance")]
413 #[test]
414 fn options_run_any_number_of_times_unless_asked_not_to() {
415 let mut options = WindowOptions::new("x");
416 assert!(matches!(options.take_instance(), Instancing::NotAsked));
417 let options = WindowOptions::new("x").single_instance("example-app");
418 assert!(matches!(
419 options.single_instance,
420 Some(SingleInstance::Acquire("example-app"))
421 ));
422 }
423
424 #[cfg(feature = "single-instance")]
425 #[test]
426 fn a_second_start_through_the_window_options_exits() {
427 let dir = tempfile::tempdir().unwrap();
428 let options = || WindowOptions {
429 single_instance: Some(SingleInstance::AcquireIn(
430 "example-app",
431 dir.path().to_owned(),
432 )),
433 ..WindowOptions::new("x")
434 };
435 let Instancing::Keep(mut first) = options().take_instance() else {
436 panic!("the first start is the instance");
437 };
438 first.on_activate(|| {});
439 assert!(
440 matches!(options().take_instance(), Instancing::Exit),
441 "the second start opens no window"
442 );
443 drop(first);
445 assert!(matches!(options().take_instance(), Instancing::Keep(_)));
446 }
447
448 #[cfg(feature = "single-instance")]
449 #[test]
450 fn an_activation_unminimizes_and_focuses_the_main_window() {
451 let ctx = egui::Context::default();
452 come_to_front(&ctx);
453 let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
454 output.textures_delta.clear();
455 let commands = &output.viewport_output[&egui::ViewportId::ROOT].commands;
456 assert!(commands.contains(&egui::ViewportCommand::Minimized(false)));
457 assert!(commands.contains(&egui::ViewportCommand::Focus));
458 }
459}