#[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;
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
mod marked_text;
#[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};
pub const IOS_SCENE_DELEGATE: &str = "CorundumSceneDelegate";
pub type InstallFonts = Box<dyn FnOnce(&egui::Context)>;
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>,
#[cfg(feature = "single-instance")]
on_activate: Option<OnActivate>,
}
#[cfg(feature = "single-instance")]
type OnActivate = Box<dyn Fn(Option<String>) + Send>;
#[cfg(feature = "single-instance")]
enum SingleInstance {
Acquire(&'static str),
#[cfg(test)]
AcquireIn(&'static str, std::path::PathBuf),
Held(instance::Instance),
}
#[cfg(feature = "single-instance")]
enum Instancing {
NotAsked,
Keep(instance::Instance),
Exit,
}
impl WindowOptions {
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,
#[cfg(feature = "single-instance")]
on_activate: None,
}
}
pub fn inner_size(mut self, size: [f32; 2]) -> WindowOptions {
self.inner_size = size;
self
}
pub fn min_inner_size(mut self, size: [f32; 2]) -> WindowOptions {
self.min_inner_size = size;
self
}
pub fn fonts(mut self, install: impl FnOnce(&egui::Context) + 'static) -> WindowOptions {
self.fonts = Some(Box::new(install));
self
}
pub fn launcher(mut self, spec: LauncherSpec) -> WindowOptions {
self.launcher = Some(spec);
self
}
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn launcher_spec(&self) -> Option<&LauncherSpec> {
self.launcher.as_ref()
}
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
}
#[cfg(feature = "serde")]
pub fn remember(mut self, store: crate::store::Store) -> WindowOptions {
self.store = Some(store);
self
}
#[cfg(feature = "single-instance")]
pub fn single_instance(mut self, app_name: &'static str) -> WindowOptions {
self.single_instance = Some(SingleInstance::Acquire(app_name));
self
}
#[cfg(feature = "single-instance")]
pub fn instance(mut self, instance: instance::Instance) -> WindowOptions {
self.single_instance = Some(SingleInstance::Held(instance));
self
}
#[cfg(feature = "single-instance")]
pub fn on_activate(mut self, f: impl Fn(Option<String>) + Send + 'static) -> WindowOptions {
self.on_activate = Some(Box::new(f));
self
}
#[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)
}
}
}
pub fn run(
options: WindowOptions,
create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
) -> eframe::Result<()> {
let mut options = options;
#[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;
let then = options.on_activate.take();
move |cc: &eframe::CreationContext<'_>| {
if let Some(instance) = instance {
come_to_front_on_activate(instance, &cc.egui_ctx, then);
}
create(cc)
}
};
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 {
#[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
}
}
}
}
#[cfg(feature = "single-instance")]
fn come_to_front_on_activate(
instance: &mut instance::Instance,
ctx: &egui::Context,
then: Option<OnActivate>,
) {
let ctx = ctx.clone();
instance.on_activate_with(move |message| {
come_to_front(&ctx);
if let Some(then) = &then {
then(message.map(str::to_owned));
}
});
}
#[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();
}
fn with_launcher_look(
viewport: egui::ViewportBuilder,
spec: &LauncherSpec,
) -> egui::ViewportBuilder {
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
}
pub fn open(
title: &str,
native: eframe::NativeOptions,
fonts: Option<InstallFonts>,
create: impl FnOnce(&eframe::CreationContext<'_>) -> Box<dyn eframe::App>,
) -> eframe::Result<()> {
#[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);
#[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 on_activate_is_kept_for_the_instance() {
let options = WindowOptions::new("x");
assert!(options.on_activate.is_none());
let options = options.on_activate(|_| {});
assert!(options.on_activate.is_some());
}
#[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"
);
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));
}
}