use crate::{
AppSettings, Container, EngineBackend, EnginePaths, GodoruError, GodoruResult, LibGodotDesktop,
NoAction, Renderer, Ui, WindowOptions,
};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
pub struct GodoruApp<S = (), A = NoAction> {
engineBackend: EngineBackend,
enginePaths: EnginePaths,
settings: AppSettings,
windows: Vec<WindowOptions<A>>,
assetsRoot: PathBuf,
state: S,
view: Option<Box<dyn Fn(&mut Ui<A>, &S)>>,
actionHandler: Option<Box<dyn FnMut(&mut S, A)>>,
}
impl GodoruApp<(), NoAction> {
pub fn new() -> Self {
Self {
engineBackend: EngineBackend::Auto,
enginePaths: EnginePaths::default(),
settings: AppSettings::default(),
windows: Vec::new(),
assetsRoot: PathBuf::from("assets"),
state: (),
view: None,
actionHandler: None,
}
}
}
impl<S: 'static, A: Clone + 'static> GodoruApp<S, A> {
pub fn withState(state: S) -> Self {
Self {
engineBackend: EngineBackend::Auto,
enginePaths: EnginePaths::default(),
settings: AppSettings::default(),
windows: Vec::new(),
assetsRoot: PathBuf::from("assets"),
state,
view: None,
actionHandler: None,
}
}
pub fn engineBackend(mut self, engineBackend: EngineBackend) -> Self {
self.engineBackend = engineBackend;
self
}
pub fn enginePaths(mut self, enginePaths: EnginePaths) -> Self {
self.enginePaths = enginePaths;
self
}
pub fn settings(mut self, configure: impl FnOnce(&mut AppSettings)) -> Self {
configure(&mut self.settings);
self
}
pub fn assetsRoot(mut self, path: impl Into<PathBuf>) -> Self {
self.assetsRoot = path.into();
self
}
pub fn view(mut self, view: impl Fn(&mut Ui<A>, &S) + 'static) -> Self {
self.view = Some(Box::new(view));
self
}
pub fn onAction(mut self, handler: impl FnMut(&mut S, A) + 'static) -> Self {
self.actionHandler = Some(Box::new(handler));
self
}
pub fn createWindow(mut self, options: WindowOptions<A>) -> GodoruResult<Self> {
if self.windows.len() >= 1 {
return Err(GodoruError::TooManyWindows);
}
self.windows.push(options);
Ok(self)
}
pub fn loadEngine(self) -> GodoruResult<LibGodotDesktop> {
self.loadDesktopEngine()
}
pub fn run(mut self) -> GodoruResult<()> {
let mut windowOptions = self
.windows
.first()
.ok_or(GodoruError::MissingWindow)?
.clone();
if self.view.is_some() {
windowOptions.root = self.renderRoot(&windowOptions.root);
}
let projectDir = prepareRuntimeProject(&windowOptions, &self.assetsRoot)?;
let previousDir = std::env::current_dir().map_err(|err| GodoruError::ProjectCreate {
path: PathBuf::from("current_dir"),
message: err.to_string(),
})?;
std::env::set_current_dir(&projectDir).map_err(|err| GodoruError::ProjectCreate {
path: projectDir.clone(),
message: err.to_string(),
})?;
let result = (|| {
let engine = self.loadDesktopEngine()?;
let instance = engine.createGodotInstanceWithArgs(godotArgs(&windowOptions))?;
instance.start()?;
let mut uiTree = match instance.mountUi(&windowOptions.root, &windowOptions.theme) {
Ok(uiTree) => uiTree,
Err(_) => {
let _ = instance.iteration()?;
instance.mountUi(&windowOptions.root, &windowOptions.theme)?
}
};
let mut pendingRender = false;
loop {
if instance.iteration()? {
uiTree.queueFreeForShutdown();
for _ in 0..3 {
let _ = instance.iteration();
}
let _ = instance.stop();
break;
}
if pendingRender {
windowOptions.root = self.renderRoot(&windowOptions.root);
uiTree.replaceUi(&windowOptions.root, &windowOptions.theme)?;
pendingRender = false;
}
if let Some(actionHandler) = self.actionHandler.as_mut() {
let mut actions = Vec::new();
uiTree.pollActions(&mut |action| actions.push(action))?;
if actions.is_empty() {
if self.settings.lowProcessorMode {
thread::sleep(Duration::from_millis(8));
}
continue;
}
for action in actions {
actionHandler(&mut self.state, action);
}
if self.view.is_some() {
pendingRender = true;
}
}
if self.settings.lowProcessorMode {
thread::sleep(Duration::from_millis(8));
}
}
Ok(())
})();
let restoreResult =
std::env::set_current_dir(&previousDir).map_err(|err| GodoruError::ProjectCreate {
path: previousDir,
message: err.to_string(),
});
restoreResult?;
result?;
Ok(())
}
fn loadDesktopEngine(&self) -> GodoruResult<LibGodotDesktop> {
match self.engineBackend {
EngineBackend::Auto | EngineBackend::LibGodotDesktop => {
LibGodotDesktop::load(self.enginePaths.desktopLibraryForCurrentTarget())
}
EngineBackend::GodotAndroidLibrary => Err(GodoruError::UnsupportedBackend(
"Godoru Android Library backend is not implemented in phase 2".to_string(),
)),
EngineBackend::Custom => Err(GodoruError::UnsupportedBackend(
"custom Godoru engine backend is not implemented in phase 2".to_string(),
)),
}
}
fn renderRoot(&self, base: &Container<A>) -> Container<A> {
let mut root = base.clone();
let mut ui = Ui::new();
if let Some(view) = self.view.as_ref() {
view(&mut ui, &self.state);
}
root.children = ui.nodes;
root
}
}
impl Default for GodoruApp<(), NoAction> {
fn default() -> Self {
Self::new()
}
}
impl<S, A> fmt::Debug for GodoruApp<S, A> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GodoruApp")
.field("engineBackend", &self.engineBackend)
.field("enginePaths", &self.enginePaths)
.field("settings", &self.settings)
.field("windows", &self.windows.len())
.field("assetsRoot", &self.assetsRoot)
.finish()
}
}
fn prepareRuntimeProject<A>(
windowOptions: &WindowOptions<A>,
assetsRoot: &Path,
) -> GodoruResult<PathBuf> {
let executableDir = std::env::current_exe()
.map_err(|err| GodoruError::ProjectCreate {
path: PathBuf::from("current_exe"),
message: err.to_string(),
})?
.parent()
.ok_or_else(|| GodoruError::ProjectCreate {
path: PathBuf::from("current_exe"),
message: "executable has no parent directory".to_string(),
})?
.to_path_buf();
fs::create_dir_all(&executableDir).map_err(|err| GodoruError::ProjectCreate {
path: executableDir.clone(),
message: err.to_string(),
})?;
fs::write(
executableDir.join("project.godot"),
renderProjectSettings(windowOptions),
)
.map_err(|err| GodoruError::ProjectCreate {
path: executableDir.join("project.godot"),
message: err.to_string(),
})?;
fs::write(
executableDir.join("godoru_bootstrap.tscn"),
"[gd_scene format=3]\n\n[node name=\"GodoruBootstrap\" type=\"Node\"]\n",
)
.map_err(|err| GodoruError::ProjectCreate {
path: executableDir.join("godoru_bootstrap.tscn"),
message: err.to_string(),
})?;
let godotDataDir = executableDir.join(".godot");
fs::create_dir_all(&godotDataDir).map_err(|err| GodoruError::ProjectCreate {
path: godotDataDir.clone(),
message: err.to_string(),
})?;
fs::write(
godotDataDir.join("global_script_class_cache.cfg"),
"\nlist=[]\n",
)
.map_err(|err| GodoruError::ProjectCreate {
path: godotDataDir.join("global_script_class_cache.cfg"),
message: err.to_string(),
})?;
let runtimeAssets = executableDir.join("assets");
if assetsRoot.exists() {
copyAssets(assetsRoot, &runtimeAssets)?;
} else {
fs::create_dir_all(&runtimeAssets).map_err(|err| GodoruError::ProjectCreate {
path: runtimeAssets.clone(),
message: err.to_string(),
})?;
}
let userDir = std::env::current_dir()
.map_err(|err| GodoruError::ProjectCreate {
path: PathBuf::from("current_dir"),
message: err.to_string(),
})?
.join("target")
.join("godoru-runtime-user");
fs::create_dir_all(&userDir).map_err(|err| GodoruError::ProjectCreate {
path: userDir.clone(),
message: err.to_string(),
})?;
unsafe {
std::env::set_var("XDG_DATA_HOME", userDir.join("data"));
std::env::set_var("XDG_CONFIG_HOME", userDir.join("config"));
std::env::set_var("XDG_CACHE_HOME", userDir.join("cache"));
}
Ok(executableDir)
}
fn copyAssets(from: &Path, to: &Path) -> GodoruResult<()> {
fs::create_dir_all(to).map_err(|err| GodoruError::ProjectCreate {
path: to.to_path_buf(),
message: err.to_string(),
})?;
for entry in fs::read_dir(from).map_err(|err| GodoruError::ProjectCreate {
path: from.to_path_buf(),
message: err.to_string(),
})? {
let entry = entry.map_err(|err| GodoruError::ProjectCreate {
path: from.to_path_buf(),
message: err.to_string(),
})?;
let source = entry.path();
let target = to.join(entry.file_name());
if source.is_dir() {
copyAssets(&source, &target)?;
} else {
fs::copy(&source, &target).map_err(|err| GodoruError::ProjectCreate {
path: target,
message: err.to_string(),
})?;
}
}
Ok(())
}
fn godotArgs<A>(windowOptions: &WindowOptions<A>) -> Vec<String> {
let mut args = vec!["godoru".to_string(), "--quiet".to_string()];
match windowOptions.renderer {
Renderer::Compatibility => {
args.push("--rendering-method".to_string());
args.push("gl_compatibility".to_string());
}
Renderer::Mobile => {
args.push("--rendering-method".to_string());
args.push("mobile".to_string());
}
Renderer::ForwardPlus => {
args.push("--rendering-method".to_string());
args.push("forward_plus".to_string());
}
Renderer::Default => {}
}
args
}
fn renderProjectSettings<A>(windowOptions: &WindowOptions<A>) -> String {
format!(
"config_version=5\n\n[application]\nconfig/name=\"{}\"\nrun/main_scene=\"res://godoru_bootstrap.tscn\"\nboot_splash/show_image=false\nboot_splash/minimum_display_time=0\nboot_splash/bg_color={}\n\n[display]\nwindow/size/viewport_width={}\nwindow/size/viewport_height={}\nwindow/size/window_width_override={}\nwindow/size/window_height_override={}\nwindow/size/resizable=true\nwindow/size/borderless={}\nwindow/size/transparent={}\nwindow/size/initial_position_type=1\nwindow/per_pixel_transparency/allowed={}\nwindow/per_pixel_transparency/enabled={}\nwindow/stretch/mode=\"disabled\"\nwindow/stretch/aspect=\"ignore\"\nwindow/stretch/scale=1.0\nwindow/stretch/scale_mode=\"fractional\"\nwindow/subwindows/embed_subwindows=true\n",
escapeGodotString(&windowOptions.title),
godotColor(&windowOptions.theme.backgroundColor),
windowOptions.size.width,
windowOptions.size.height,
windowOptions.size.width,
windowOptions.size.height,
!windowOptions.decorations,
windowOptions.transparent,
windowOptions.transparent,
windowOptions.transparent
)
}
fn godotColor(color: &crate::Color) -> String {
format!(
"Color({:.3}, {:.3}, {:.3}, {:.3})",
color.red, color.green, color.blue, color.alpha
)
}
fn escapeGodotString(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appDefaultsToAutoBackend() {
let app = GodoruApp::new();
assert_eq!(app.engineBackend, EngineBackend::Auto);
assert!(app.settings.lowProcessorMode);
}
#[test]
fn createWindowStoresSingleWindow() {
let app = GodoruApp::new()
.createWindow(WindowOptions::new("main").title("Godoru"))
.unwrap();
assert_eq!(app.windows.len(), 1);
assert_eq!(app.windows[0].id, "main");
assert_eq!(app.windows[0].title, "Godoru");
}
#[test]
fn phase2RejectsMultipleWindows() {
let err = GodoruApp::new()
.createWindow(WindowOptions::new("main"))
.unwrap()
.createWindow(WindowOptions::new("second"))
.unwrap_err();
assert!(matches!(err, GodoruError::TooManyWindows));
}
#[test]
fn withStateStoresReactiveState() {
let app = GodoruApp::<u32, String>::withState(7).view(|ui, state| {
ui.text(format!("Count {state}"));
});
assert_eq!(app.state, 7);
assert!(app.view.is_some());
}
#[test]
fn godotArgsUseQuietMode() {
let args = godotArgs(&WindowOptions::<NoAction>::new("main"));
assert!(args.iter().any(|arg| arg == "--quiet"));
}
}