mod dock;
pub mod gui_elements;
mod hierarchy;
mod project;
mod view;
use dock::EditorLayoutFile;
pub use dock::{EditorDockNode, EditorPanel, EditorSplitAxis};
use hierarchy::{collect_entities, draw_hierarchy_area};
pub use view::draw_editor_view;
pub use project::{
create_project, open_project, OpenProject, ProjectError,
ProjectManagerState, ProjectManifest, RecentProject,
PROJECT_FORMAT_VERSION,
};
use bevy_ecs::entity::Entity;
use bevy_ecs::prelude::{Resource, World};
use egui::{CentralPanel, ComboBox, Context, DragValue, TopBottomPanel};
use std::path::PathBuf;
use crate::runtime::{
add_registered_component, cook_scene, load_scene,
registered_component_names, registered_component_values,
remove_registered_component, save_scene, scene_document,
set_registered_component, App, AppError, Camera, Collider, ColliderShape,
CollisionLayers, FrameTime, MeshRenderer, Name, ObjectClasses,
PhysicsBackendStatus, PhysicsBody, PhysicsSolver, Plugin, Projection,
RenderCameraOverride, RenderSettings, RenderWorld, RigidBody,
RigidBodyKind, SceneDocument, SceneId, SceneLoadMode, SimulationClass,
Visibility,
};
use crate::Transform;
use crate::{
AssetServer, Handle, ImportedGltfPrimitive, MaterialAsset, MeshAsset,
TextureAsset,
};
pub fn configure_editor_style(context: &Context) {
gui_elements::EditorTheme::apply(context);
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EditorMode {
#[default]
Edit,
Play,
Paused,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum GameBuildProfile {
#[default]
Debug,
Release,
}
impl GameBuildProfile {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Debug => "Debug",
Self::Release => "Release",
}
}
const fn cargo_flag(self) -> Option<&'static str> {
match self {
Self::Debug => None,
Self::Release => Some("--release"),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EditorWorkspace {
#[default]
Scene,
Game,
Code,
}
#[derive(Resource, Clone, Debug)]
pub struct EditorState {
pub selected: Option<Entity>,
pub mode: EditorMode,
pub game_build_profile: GameBuildProfile,
pub scene_path: String,
pub project_root: String,
pub scene_message: Option<String>,
pub scene_dirty: bool,
pub rename_draft: String,
pub rename_target: Option<Entity>,
pub class_draft: String,
pub component_drafts: std::collections::HashMap<(Entity, String), String>,
pub workspace: EditorWorkspace,
pub editor_camera: Option<Entity>,
pub play_snapshot: Option<SceneDocument>,
pub code_path: String,
pub code_source: String,
pub code_message: Option<String>,
pub code_dirty: bool,
pub dock_layout: EditorDockNode,
pub active_area: u64,
pub next_area_id: u64,
}
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EditorViewport {
pub offset: [u32; 2],
pub extent: [u32; 2],
pub valid: bool,
}
#[derive(Resource, Clone, Debug, Default)]
struct EditorHistory {
undo: Vec<SceneDocument>,
redo: Vec<SceneDocument>,
pending_inspector: Option<SceneDocument>,
}
impl EditorHistory {
const MAX_SNAPSHOTS: usize = 100;
fn push_undo(&mut self, document: SceneDocument) {
if self.undo.last() != Some(&document) {
self.undo.push(document);
if self.undo.len() > Self::MAX_SNAPSHOTS {
self.undo.remove(0);
}
}
self.redo.clear();
}
}
impl Default for EditorState {
fn default() -> Self {
Self {
selected: None,
mode: EditorMode::Edit,
game_build_profile: GameBuildProfile::Debug,
scene_path: String::new(),
project_root: String::new(),
scene_message: None,
scene_dirty: false,
rename_draft: String::new(),
rename_target: None,
class_draft: String::new(),
component_drafts: std::collections::HashMap::new(),
workspace: EditorWorkspace::Scene,
editor_camera: None,
play_snapshot: None,
code_path: "src/main.rs".into(),
code_source: String::new(),
code_message: None,
code_dirty: false,
dock_layout: EditorDockNode::default_layout(),
active_area: 2,
next_area_id: 5,
}
}
}
fn set_open_project_paths(state: &mut EditorState, project: &OpenProject) {
state.project_root = project.root.display().to_string();
state.scene_path = project.manifest.main_scene.display().to_string();
state.code_path = project
.code_path
.strip_prefix(&project.root)
.unwrap_or(std::path::Path::new("src/main.rs"))
.display()
.to_string();
state.code_source.clear();
state.code_dirty = false;
state.rename_target = None;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsoleEntry {
pub level: ConsoleLevel,
pub message: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConsoleLevel {
Info,
Warning,
Error,
}
#[derive(Resource, Clone, Debug, Default)]
pub struct EditorConsole {
entries: Vec<ConsoleEntry>,
}
impl EditorConsole {
pub fn push(&mut self, level: ConsoleLevel, message: impl Into<String>) {
self.entries.push(ConsoleEntry {
level,
message: message.into(),
});
}
#[must_use]
pub fn entries(&self) -> &[ConsoleEntry] {
&self.entries
}
pub fn clear(&mut self) {
self.entries.clear();
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct EditorPlugin;
impl Plugin for EditorPlugin {
fn build(&self, app: &mut App) -> Result<(), AppError> {
app.insert_resource(EditorState::default())
.insert_resource(EditorViewport::default())
.insert_resource(EditorConsole::default())
.insert_resource(EditorHistory::default())
.insert_resource(PendingDestructiveAction::default())
.insert_resource(EditorAssetState::default())
.insert_resource(EditorBuildState::default())
.insert_resource(ProjectManagerState::default());
Ok(())
}
}
#[derive(Clone)]
enum ProjectRequest {
Create { parent: PathBuf, name: String },
Open(PathBuf),
}
#[derive(Clone)]
enum DestructiveRequest {
NewScene,
ReloadScene,
OpenScene(PathBuf),
OpenProject(ProjectRequest),
}
#[derive(Resource, Clone, Default)]
struct PendingDestructiveAction {
request: Option<DestructiveRequest>,
}
#[derive(Resource, Clone, Default)]
struct EditorAssetState {
filter: String,
gltf_primitives: Vec<ImportedGltfPrimitive>,
message: Option<String>,
}
#[derive(Clone)]
enum BuildRequest {
Check,
BuildAndRun {
profile: GameBuildProfile,
},
Export {
parent: PathBuf,
project_name: String,
binary_name: String,
cooked_scene: PathBuf,
},
}
struct BuildFinished {
success: bool,
output: String,
}
enum BuildWorkerMessage {
Output(String),
Finished(BuildFinished),
}
#[derive(Resource, Default)]
struct EditorBuildState {
running: bool,
output: String,
console_cursor: usize,
receiver:
std::sync::Mutex<Option<std::sync::mpsc::Receiver<BuildWorkerMessage>>>,
}
impl EditorBuildState {
fn start(
&mut self,
project_root: PathBuf,
request: BuildRequest,
) -> Result<(), String> {
if self.running {
return Err("A Cargo task is already running".into());
}
let manifest = project_root.join("Cargo.toml");
if !manifest.is_file() {
return Err(format!("{} is missing", manifest.display()));
}
let (sender, receiver) = std::sync::mpsc::channel();
*self.receiver.get_mut().map_err(|error| error.to_string())? =
Some(receiver);
self.running = true;
self.output = match &request {
BuildRequest::Check => "Running cargo check...".into(),
BuildRequest::BuildAndRun { profile } => {
format!("Building {} game...", profile.label())
}
BuildRequest::Export { .. } => "Building game for export...".into(),
};
self.console_cursor = 0;
std::thread::spawn(move || {
let command = match &request {
BuildRequest::Check => "check",
BuildRequest::BuildAndRun { .. }
| BuildRequest::Export { .. } => "build",
};
let profile = match &request {
BuildRequest::BuildAndRun { profile } => Some(*profile),
BuildRequest::Export { .. } => Some(GameBuildProfile::Release),
BuildRequest::Check => None,
};
let mut cargo = std::process::Command::new("cargo");
cargo.arg(command);
if let Some(flag) = profile.and_then(GameBuildProfile::cargo_flag) {
cargo.arg(flag);
}
let result = cargo
.args(["--message-format", "short", "--manifest-path"])
.arg(&manifest)
.current_dir(&project_root)
.output();
let finished = match result {
Ok(output) => {
let mut success = output.status.success();
let mut text =
String::from_utf8_lossy(&output.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&output.stderr));
if !text.is_empty() {
let _ = sender.send(BuildWorkerMessage::Output(text));
}
if output.status.success() {
if let BuildRequest::BuildAndRun { profile } = &request
{
return run_native_game(
&project_root,
&manifest,
*profile,
sender,
);
} else if let BuildRequest::Export {
parent,
project_name,
binary_name,
cooked_scene,
} = &request
{
match export_built_game(
&project_root,
&manifest,
parent,
project_name,
binary_name,
cooked_scene,
) {
Ok(path) => {
let _ = sender.send(
BuildWorkerMessage::Output(format!(
"\nExported game to {}\n",
path.display()
)),
);
}
Err(error) => {
success = false;
let _ = sender.send(
BuildWorkerMessage::Output(format!(
"\nBuild passed, but export failed: {error}\n"
)),
);
}
}
}
}
BuildFinished {
success,
output: if success {
"\nCargo task finished successfully.\n".into()
} else {
"\nCargo task failed.\n".into()
},
}
}
Err(error) => BuildFinished {
success: false,
output: format!("Could not start Cargo: {error}"),
},
};
let _ = sender.send(BuildWorkerMessage::Finished(finished));
});
Ok(())
}
fn poll(&mut self) -> Option<bool> {
let mut messages = Vec::new();
let mut disconnected = false;
{
let receiver = self.receiver.get_mut().ok()?.as_ref()?;
loop {
match receiver.try_recv() {
Ok(message) => messages.push(message),
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
disconnected = true;
break;
}
}
}
}
let mut finished_status = None;
for message in messages {
match message {
BuildWorkerMessage::Output(text) => {
self.append_output(&text);
}
BuildWorkerMessage::Finished(finished) => {
self.append_output(&finished.output);
self.running = false;
finished_status = Some(finished.success);
}
}
}
if finished_status.is_some() {
*self.receiver.get_mut().ok()? = None;
finished_status
} else if disconnected {
if self.running {
self.running = false;
self.append_output("\nCargo worker stopped unexpectedly.\n");
*self.receiver.get_mut().ok()? = None;
Some(false)
} else {
None
}
} else {
None
}
}
fn append_output(&mut self, text: &str) {
const MAX_OUTPUT_BYTES: usize = 500_000;
self.output.push_str(text);
if self.output.len() > MAX_OUTPUT_BYTES {
let mut remove = self.output.len() - MAX_OUTPUT_BYTES;
while !self.output.is_char_boundary(remove) {
remove += 1;
}
self.output.drain(..remove);
self.output.insert_str(0, "[older output truncated]\n");
}
}
fn take_console_output(&mut self) -> String {
if self.console_cursor > self.output.len() {
self.console_cursor = 0;
}
let text = self.output[self.console_cursor..].to_owned();
self.console_cursor = self.output.len();
text
}
}
fn run_native_game(
project_root: &std::path::Path,
manifest: &std::path::Path,
profile: GameBuildProfile,
sender: std::sync::mpsc::Sender<BuildWorkerMessage>,
) {
use std::io::BufRead;
use std::process::Stdio;
let _ = sender.send(BuildWorkerMessage::Output(
"\nBuild passed. Starting native game...\n".into(),
));
let mut cargo = std::process::Command::new("cargo");
cargo.arg("run");
if let Some(flag) = profile.cargo_flag() {
cargo.arg(flag);
}
let child = cargo
.arg("--manifest-path")
.arg(manifest)
.current_dir(project_root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match child {
Ok(child) => child,
Err(error) => {
let _ = sender.send(BuildWorkerMessage::Finished(BuildFinished {
success: false,
output: format!("Could not start native game: {error}\n"),
}));
return;
}
};
let mut readers = Vec::new();
if let Some(stdout) = child.stdout.take() {
let sender = sender.clone();
readers.push(std::thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines() {
match line {
Ok(line) => {
let _ = sender.send(BuildWorkerMessage::Output(
format!("{line}\n"),
));
}
Err(error) => {
let _ = sender.send(BuildWorkerMessage::Output(
format!("Could not read game output: {error}\n"),
));
break;
}
}
}
}));
}
if let Some(stderr) = child.stderr.take() {
let sender = sender.clone();
readers.push(std::thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines() {
match line {
Ok(line) => {
let _ = sender.send(BuildWorkerMessage::Output(
format!("{line}\n"),
));
}
Err(error) => {
let _ = sender.send(BuildWorkerMessage::Output(
format!("Could not read game errors: {error}\n"),
));
break;
}
}
}
}));
}
let status = child.wait();
for reader in readers {
let _ = reader.join();
}
let finished = match status {
Ok(status) if status.success() => BuildFinished {
success: true,
output: "\nNative game exited successfully.\n".into(),
},
Ok(status) => BuildFinished {
success: false,
output: format!("\nNative game exited with status {status}.\n"),
},
Err(error) => BuildFinished {
success: false,
output: format!("\nCould not wait for native game: {error}\n"),
},
};
let _ = sender.send(BuildWorkerMessage::Finished(finished));
}
fn export_built_game(
project_root: &std::path::Path,
manifest: &std::path::Path,
parent: &std::path::Path,
project_name: &str,
binary_name: &str,
cooked_scene: &std::path::Path,
) -> Result<PathBuf, String> {
if !parent.is_dir() {
return Err(format!("{} is not a folder", parent.display()));
}
let metadata = std::process::Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.args(["--manifest-path"])
.arg(manifest)
.current_dir(project_root)
.output()
.map_err(|error| format!("Could not read Cargo metadata: {error}"))?;
if !metadata.status.success() {
return Err(String::from_utf8_lossy(&metadata.stderr).into_owned());
}
let metadata: serde_json::Value = serde_json::from_slice(&metadata.stdout)
.map_err(|error| format!("Invalid Cargo metadata: {error}"))?;
let target = metadata
.get("target_directory")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "Cargo metadata has no target directory".to_owned())?;
let executable_name = if cfg!(target_os = "windows") {
format!("{binary_name}.exe")
} else {
binary_name.to_owned()
};
let executable =
PathBuf::from(target).join("release").join(&executable_name);
if !executable.is_file() {
return Err(format!(
"Release executable {} was not produced",
executable.display()
));
}
package_game_files(
project_root,
&executable,
parent,
project_name,
binary_name,
cooked_scene,
)
}
fn package_game_files(
project_root: &std::path::Path,
executable: &std::path::Path,
parent: &std::path::Path,
project_name: &str,
binary_name: &str,
cooked_scene: &std::path::Path,
) -> Result<PathBuf, String> {
let executable_name = if cfg!(target_os = "windows") {
format!("{binary_name}.exe")
} else {
binary_name.to_owned()
};
let cooked_source = project_root.join(cooked_scene);
if !cooked_source.is_file() {
return Err(format!(
"Cooked scene {} is missing",
cooked_source.display()
));
}
let safe_name = binary_name.replace('-', "_");
let mut destination = parent.join(format!("{safe_name}_export"));
for number in 2.. {
if !destination.exists() {
break;
}
destination = parent.join(format!("{safe_name}_export_{number}"));
}
let temporary =
parent.join(format!(".rusting-export-{}", uuid::Uuid::new_v4()));
let result = (|| -> Result<(), String> {
std::fs::create_dir_all(&temporary)
.map_err(|error| error.to_string())?;
std::fs::copy(executable, temporary.join(&executable_name))
.map_err(|error| error.to_string())?;
let cooked_destination = temporary.join(cooked_scene);
if let Some(folder) = cooked_destination.parent() {
std::fs::create_dir_all(folder)
.map_err(|error| error.to_string())?;
}
std::fs::copy(&cooked_source, cooked_destination)
.map_err(|error| error.to_string())?;
let assets = project_root.join("assets");
if assets.is_dir() {
copy_directory(&assets, &temporary.join("assets"))?;
}
let engine_license =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("LICENSE.md");
if engine_license.is_file() {
std::fs::copy(
engine_license,
temporary.join("RUSTING_ENGINE_LICENSE.md"),
)
.map_err(|error| error.to_string())?;
}
std::fs::write(
temporary.join("README.txt"),
format!(
"{project_name}\n\nRun {executable_name} to start the game.\nThe system needs a Vulkan-capable graphics driver.\n"
),
)
.map_err(|error| error.to_string())?;
std::fs::rename(&temporary, &destination)
.map_err(|error| error.to_string())?;
Ok(())
})();
if result.is_err() && temporary.exists() {
let _ = std::fs::remove_dir_all(&temporary);
}
result.map(|()| destination)
}
fn copy_directory(
source: &std::path::Path,
destination: &std::path::Path,
) -> Result<(), String> {
std::fs::create_dir_all(destination).map_err(|error| error.to_string())?;
for entry in std::fs::read_dir(source).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
let file_type = entry.file_type().map_err(|error| error.to_string())?;
if file_type.is_symlink() {
continue;
}
let target = destination.join(entry.file_name());
if file_type.is_dir() {
copy_directory(&entry.path(), &target)?;
} else if file_type.is_file() {
std::fs::copy(entry.path(), target)
.map_err(|error| error.to_string())?;
}
}
Ok(())
}
enum AssetRequest {
ImportFiles(Vec<PathBuf>),
LoadTexture(PathBuf),
ImportGltf(PathBuf),
AssignTexture(Handle<TextureAsset>),
AssignPrimitive(Handle<MeshAsset>, Handle<MaterialAsset>),
}
enum EntityRequest {
CreateEmpty,
CreateCube,
CreateSphere,
CreateCamera,
Duplicate(Entity),
Delete(Entity),
Rename(Entity, String),
Reparent(Entity, Option<Entity>),
}
fn remember_scene_before_edit(
world: &mut World,
history: &mut EditorHistory,
) -> Result<(), String> {
if let Some(document) = history.pending_inspector.take() {
history.push_undo(document);
}
scene_document(world, "Undo Snapshot")
.map(|document| history.push_undo(document))
.map_err(|error| format!("Could not create undo snapshot: {error}"))
}
fn unique_object_name(world: &mut World, base: &str) -> String {
let mut query = world.query::<&Name>();
let names = query
.iter(world)
.map(|name| name.0.clone())
.collect::<std::collections::HashSet<_>>();
if !names.contains(base) {
return base.into();
}
for number in 2.. {
let candidate = format!("{base} {number}");
if !names.contains(&candidate) {
return candidate;
}
}
unreachable!("a free object name always exists")
}
fn project_asset_files(project_root: &str) -> Vec<PathBuf> {
let root = PathBuf::from(project_root).join("assets");
let mut pending = vec![root];
let mut files = Vec::new();
while let Some(folder) = pending.pop() {
let Ok(entries) = std::fs::read_dir(folder) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
pending.push(entry.path());
} else if file_type.is_file() {
files.push(entry.path());
}
if files.len() >= 10_000 {
break;
}
}
}
files.sort();
files
}
fn copy_into_project_assets(
project_root: &str,
source: &std::path::Path,
) -> Result<PathBuf, String> {
if !source.is_file() {
return Err(format!("{} is not a file", source.display()));
}
let asset_folder = PathBuf::from(project_root).join("assets");
std::fs::create_dir_all(&asset_folder)
.map_err(|error| format!("Could not create assets folder: {error}"))?;
let source = source
.canonicalize()
.map_err(|error| format!("Could not open asset: {error}"))?;
let asset_folder = asset_folder
.canonicalize()
.map_err(|error| format!("Could not open assets folder: {error}"))?;
if source.starts_with(&asset_folder) {
return Ok(source);
}
let file_name = source
.file_name()
.ok_or_else(|| "Asset path has no file name".to_owned())?;
let mut destination = asset_folder.join(file_name);
let stem = source
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("asset");
let extension = source.extension().and_then(|value| value.to_str());
for number in 2.. {
if !destination.exists() {
break;
}
let name = extension.map_or_else(
|| format!("{stem}_{number}"),
|extension| format!("{stem}_{number}.{extension}"),
);
destination = asset_folder.join(name);
}
std::fs::copy(&source, &destination).map_err(|error| {
format!(
"Could not copy {} to {}: {error}",
source.display(),
destination.display()
)
})?;
Ok(destination)
}
fn draw_project_manager(
context: &Context,
manager: &mut ProjectManagerState,
) -> Option<ProjectRequest> {
if !manager.open {
return None;
}
let mut request = None;
egui::Window::new("RustingEngine Project Manager")
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
.collapsible(false)
.resizable(true)
.default_width(620.0)
.show(context, |ui| {
ui.heading("Open a project");
if manager.recent_projects.is_empty() {
ui.label("No recent projects yet.");
} else {
egui::ScrollArea::vertical()
.max_height(160.0)
.show(ui, |ui| {
for project in &manager.recent_projects {
let text = format!(
"{}\n{}",
project.name,
project.path.display()
);
if ui.button(text).clicked() {
request = Some(ProjectRequest::Open(
project.path.clone(),
));
}
}
});
}
if ui.button("Browse for existing project...").clicked() {
if let Some(path) = rfd::FileDialog::new()
.set_title("Open RustingEngine Project")
.pick_folder()
{
request = Some(ProjectRequest::Open(path));
}
}
ui.separator();
ui.heading("Create a project");
ui.horizontal(|ui| {
ui.label("Name");
ui.text_edit_singleline(&mut manager.project_name);
});
ui.horizontal(|ui| {
ui.label("Parent folder");
if manager.parent_directory.as_os_str().is_empty() {
ui.colored_label(
gui_elements::EditorTheme::TEXT_MUTED,
"Not selected",
);
} else {
ui.monospace(
manager.parent_directory.display().to_string(),
);
}
if ui.button("Browse...").clicked() {
let mut dialog = rfd::FileDialog::new()
.set_title("Choose Project Parent Folder");
if !manager.parent_directory.as_os_str().is_empty() {
dialog =
dialog.set_directory(&manager.parent_directory);
}
if let Some(path) = dialog.pick_folder() {
manager.parent_directory = path;
}
}
});
if ui.button("Create Project").clicked() {
if manager.parent_directory.as_os_str().is_empty() {
if let Some(path) = rfd::FileDialog::new()
.set_title("Choose Where to Create the Project")
.pick_folder()
{
manager.parent_directory = path.clone();
request = Some(ProjectRequest::Create {
parent: path,
name: manager.project_name.clone(),
});
}
} else {
request = Some(ProjectRequest::Create {
parent: manager.parent_directory.clone(),
name: manager.project_name.clone(),
});
}
}
if let Some(message) = &manager.message {
ui.separator();
ui.label(message);
}
});
request
}
fn edit_vector(
ui: &mut egui::Ui,
label: &str,
values: &mut [f32; 3],
speed: f64,
) {
ui.horizontal(|ui| {
ui.label(label);
for (axis, value) in ["X", "Y", "Z"].into_iter().zip(values) {
ui.label(axis);
ui.add(DragValue::new(value).speed(speed));
}
});
}
fn dock_header_button(
ui: &mut egui::Ui,
symbol: &str,
hover_text: &str,
) -> egui::Response {
gui_elements::EditorTheme::dock_button(ui, symbol, hover_text)
}
#[derive(Clone, Copy)]
enum DockAction {
Split(u64, EditorSplitAxis),
Close(u64),
}
fn show_dock_node(
ui: &mut egui::Ui,
node: &mut EditorDockNode,
rect: egui::Rect,
active_area: &mut u64,
actions: &mut Vec<DockAction>,
show_panel: &mut impl FnMut(&mut egui::Ui, EditorPanel),
) {
const DIVIDER: f32 = 6.0;
match node {
EditorDockNode::Area { id, panel } => {
let id = *id;
let panel_rect = rect.shrink(2.0);
ui.scope_builder(
egui::UiBuilder::new()
.id_salt(("dock_area", id))
.max_rect(panel_rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
|ui| {
ui.set_clip_rect(ui.clip_rect().intersect(panel_rect));
let pressed_inside = ui.input(|input| {
input.pointer.primary_pressed()
&& input.pointer.interact_pos().is_some_and(
|position| panel_rect.contains(position),
)
});
if pressed_inside {
*active_area = id;
}
let active = *active_area == id;
if !matches!(panel, EditorPanel::Scene | EditorPanel::Game)
{
ui.painter().rect_filled(
panel_rect,
6.0,
gui_elements::EditorTheme::PANEL,
);
}
let stroke = if active {
egui::Stroke::new(
2.0,
gui_elements::EditorTheme::ACCENT,
)
} else {
egui::Stroke::new(
1.0,
gui_elements::EditorTheme::BORDER,
)
};
egui::Frame::NONE
.fill(gui_elements::EditorTheme::PANEL_RAISED)
.inner_margin(egui::Margin::symmetric(8, 5))
.show(ui, |ui| {
ui.horizontal(|ui| {
let buttons_width = 22.0 * 3.0
+ ui.spacing().item_spacing.x * 2.0;
let selector_width = (ui.available_width()
- buttons_width
- 12.0)
.clamp(60.0, 140.0);
gui_elements::EditorTheme::toolbar_combo_box_with_popup(
ui,
("dock_panel_selector", id),
panel.title(),
selector_width,
220.0,
|ui| {
for choice in EditorPanel::ALL {
if gui_elements::EditorTheme::menu_choice(
ui,
choice.title(),
*panel == choice,
true,
)
.clicked()
{
*panel = choice;
}
}
},
);
ui.add_space(
(ui.available_width() - buttons_width)
.max(0.0),
);
if dock_header_button(
ui,
"<>",
"Split into left/right areas",
)
.clicked()
{
actions.push(DockAction::Split(
id,
EditorSplitAxis::Columns,
));
}
if dock_header_button(
ui,
"||",
"Split into top/bottom areas",
)
.clicked()
{
actions.push(DockAction::Split(
id,
EditorSplitAxis::Rows,
));
}
if dock_header_button(
ui,
"x",
"Close this area",
)
.clicked()
{
actions.push(DockAction::Close(id));
}
});
});
egui::Frame::NONE
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| show_panel(ui, *panel));
ui.painter().rect_stroke(
panel_rect,
6.0,
stroke,
egui::StrokeKind::Inside,
);
},
);
}
EditorDockNode::Split {
axis,
ratio,
first,
second,
} => {
let ratio_clamped = ratio.clamp(0.1, 0.9);
*ratio = ratio_clamped;
let (first_rect, divider_rect, second_rect) = match axis {
EditorSplitAxis::Columns => {
let split =
rect.left() + (rect.width() - DIVIDER) * ratio_clamped;
(
egui::Rect::from_min_max(
rect.min,
egui::pos2(split, rect.bottom()),
),
egui::Rect::from_min_max(
egui::pos2(split, rect.top()),
egui::pos2(split + DIVIDER, rect.bottom()),
),
egui::Rect::from_min_max(
egui::pos2(split + DIVIDER, rect.top()),
rect.max,
),
)
}
EditorSplitAxis::Rows => {
let split =
rect.top() + (rect.height() - DIVIDER) * ratio_clamped;
(
egui::Rect::from_min_max(
rect.min,
egui::pos2(rect.right(), split),
),
egui::Rect::from_min_max(
egui::pos2(rect.left(), split),
egui::pos2(rect.right(), split + DIVIDER),
),
egui::Rect::from_min_max(
egui::pos2(rect.left(), split + DIVIDER),
rect.max,
),
)
}
};
let divider_id = ui.id().with((
"dock_divider",
first_rect.min.x.to_bits(),
first_rect.min.y.to_bits(),
second_rect.max.x.to_bits(),
second_rect.max.y.to_bits(),
));
let response = ui.interact(
divider_rect,
divider_id,
egui::Sense::click_and_drag(),
);
if response.dragged() {
if let Some(pointer) = response.interact_pointer_pos() {
*ratio = match axis {
EditorSplitAxis::Columns => {
(pointer.x - rect.left()) / rect.width()
}
EditorSplitAxis::Rows => {
(pointer.y - rect.top()) / rect.height()
}
}
.clamp(0.1, 0.9);
}
}
if response.hovered() || response.dragged() {
ui.ctx().set_cursor_icon(match axis {
EditorSplitAxis::Columns => {
egui::CursorIcon::ResizeHorizontal
}
EditorSplitAxis::Rows => egui::CursorIcon::ResizeVertical,
});
}
ui.painter().rect_filled(
divider_rect,
0.0,
if response.hovered() || response.dragged() {
egui::Color32::from_rgb(55, 120, 220)
} else {
egui::Color32::from_rgb(38, 41, 48)
},
);
show_dock_node(
ui,
first,
first_rect,
active_area,
actions,
show_panel,
);
show_dock_node(
ui,
second,
second_rect,
active_area,
actions,
show_panel,
);
}
}
}
fn apply_dock_actions(state: &mut EditorState, actions: Vec<DockAction>) {
for action in actions {
match action {
DockAction::Split(id, axis) => {
let new_id = state.next_area_id;
state.next_area_id = state.next_area_id.saturating_add(1);
if state.dock_layout.split(id, axis, new_id) {
state.active_area = new_id;
}
}
DockAction::Close(id) => {
if state.dock_layout.close(id) && state.active_area == id {
state.active_area = state.dock_layout.first_area_id();
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_inspector_area(
ui: &mut egui::Ui,
world: &World,
state: &mut EditorState,
physics_backends: PhysicsBackendStatus,
edited_transform: &mut Option<Transform>,
edited_camera: &mut Option<Camera>,
edited_classes: &mut Option<ObjectClasses>,
edited_physics: &mut Option<PhysicsBody>,
edited_rigid_body: &mut Option<RigidBody>,
edited_collider: &mut Option<Collider>,
registered_names: &[String],
custom_values: &[(String, String)],
component_edits: &mut Vec<ComponentEdit>,
add_physics: &mut bool,
remove_physics: &mut bool,
edit_custom_shader: &mut bool,
) {
let Some(entity) = state.selected else {
ui.label("Select an entity in the Hierarchy area.");
return;
};
egui::ScrollArea::both()
.id_salt("inspector_panel_scroll")
.auto_shrink([false, false])
.scroll_bar_visibility(
egui::scroll_area::ScrollBarVisibility::AlwaysHidden,
)
.show(ui, |ui| {
ui.monospace(format!("{entity:?}"));
if let Some(classes) = edited_classes {
ui.collapsing("Classes", |ui| {
let mut remove = None;
for class in &classes.names {
ui.horizontal(|ui| {
ui.label(class);
if ui.small_button("Remove").clicked() {
remove = Some(class.clone());
}
});
}
if let Some(class) = remove {
classes.remove(&class);
}
ui.horizontal(|ui| {
ui.text_edit_singleline(&mut state.class_draft);
if ui.button("Add Class").clicked()
&& classes.add(&state.class_draft)
{
state.class_draft.clear();
}
});
ui.small("An object can belong to several classes.");
});
}
if let Some(transform) = edited_transform {
ui.collapsing("Transform", |ui| {
edit_vector(ui, "Position", &mut transform.position, 0.1);
edit_vector(ui, "Rotation", &mut transform.rotation, 0.01);
edit_vector(ui, "Scale", &mut transform.scale, 0.01);
});
}
if let Some(renderer) = world.get::<MeshRenderer>(entity) {
ui.collapsing("Mesh Renderer", |ui| {
ui.monospace(format!("Mesh: {}", renderer.mesh.key()));
ui.monospace(format!("Material: {}", renderer.material.key()));
});
}
if let Some(camera) = edited_camera {
ui.collapsing("Camera", |ui| {
ui.checkbox(&mut camera.active, "Active");
ui.add(
DragValue::new(&mut camera.priority)
.prefix("Priority ")
.speed(1.0),
);
match &mut camera.projection {
Projection::Perspective {
vertical_fov_radians,
near,
far,
} => {
let mut fov = vertical_fov_radians.to_degrees();
ui.add(
egui::Slider::new(&mut fov, 10.0..=120.0)
.text("Vertical FOV")
.suffix(" deg"),
);
*vertical_fov_radians = fov.to_radians();
projection_planes(ui, near, far);
}
Projection::Orthographic {
vertical_size,
near,
far,
} => {
ui.add(
DragValue::new(vertical_size)
.prefix("Vertical size ")
.range(0.01..=100_000.0)
.speed(0.1),
);
projection_planes(ui, near, far);
}
}
});
}
ui.separator();
ui.horizontal(|ui| {
ui.label("Physics");
if edited_physics.is_some() {
if ui.small_button("Remove").clicked() {
*remove_physics = true;
}
} else if ui.small_button("Add Physics").clicked() {
*add_physics = true;
*edited_physics = Some(PhysicsBody::default());
*edited_rigid_body = Some(RigidBody::default());
*edited_collider = Some(Collider::default());
}
});
if let Some(physics) = edited_physics {
ComboBox::from_label("Simulation")
.selected_text(simulation_class_name(physics.simulation))
.show_ui(ui, |ui| {
for class in [
SimulationClass::None,
SimulationClass::Static,
SimulationClass::Gameplay,
SimulationClass::GpuDynamic,
] {
ui.selectable_value(
&mut physics.simulation,
class,
simulation_class_name(class),
);
}
});
match physics.simulation {
SimulationClass::None => {
ui.small("No physics simulation.");
}
SimulationClass::Static => {
if let Some(body) = edited_rigid_body {
body.kind = RigidBodyKind::Fixed;
}
ui.small("Static collider; never dispatched per frame.");
}
SimulationClass::Gameplay => {
ui.colored_label(
if physics_backends.gameplay_available {
ui.visuals().text_color()
} else {
egui::Color32::LIGHT_RED
},
if physics_backends.gameplay_available {
"CPU-authoritative gameplay physics."
} else {
"Gameplay physics backend is not connected yet."
},
);
}
SimulationClass::GpuDynamic => {
if !physics_backends.gpu_dynamic_available {
ui.colored_label(
egui::Color32::LIGHT_YELLOW,
"GPU gravity and condition events run in native Play; editor preview simulation is not active yet.",
);
}
ComboBox::from_label("GPU solver")
.selected_text(physics_solver_name(physics.solver))
.show_ui(ui, |ui| {
for solver in [
PhysicsSolver::Full,
PhysicsSolver::Simplified,
PhysicsSolver::NoCollision,
PhysicsSolver::Custom,
] {
ui.selectable_value(
&mut physics.solver,
solver,
physics_solver_name(solver),
);
}
});
if physics.solver == PhysicsSolver::Custom {
let path =
physics.custom_shader.get_or_insert_with(|| {
format!(
"{}/shaders/custom.comp",
state.project_root
)
});
ui.text_edit_singleline(path);
if ui.button("Open in Code Editor").clicked() {
*edit_custom_shader = true;
}
}
}
};
if physics.simulation != SimulationClass::None {
let body =
edited_rigid_body.get_or_insert_with(RigidBody::default);
if physics.simulation != SimulationClass::Static {
ComboBox::from_label("Body")
.selected_text(rigid_body_kind_name(body.kind))
.show_ui(ui, |ui| {
for kind in [
RigidBodyKind::Dynamic,
RigidBodyKind::Kinematic,
RigidBodyKind::Fixed,
] {
ui.selectable_value(
&mut body.kind,
kind,
rigid_body_kind_name(kind),
);
}
});
ui.add(
DragValue::new(&mut body.mass)
.prefix("Mass ")
.suffix(" kg")
.range(0.001..=1_000_000.0),
);
ui.add(
DragValue::new(&mut body.gravity_scale)
.prefix("Gravity ")
.range(-100.0..=100.0),
);
edit_vector(ui, "Velocity", &mut body.linear_velocity, 0.1);
}
edit_collider(
ui,
edited_collider.get_or_insert_with(Collider::default),
);
}
}
ui.separator();
ui.label("Compiled Components");
for (name, _) in custom_values {
let key = (entity, name.clone());
ui.group(|ui| {
ui.horizontal(|ui| {
ui.monospace(name);
if ui.button("Apply").clicked() {
if let Some(value) = state.component_drafts.get(&key) {
component_edits.push(ComponentEdit::Set {
entity,
name: name.clone(),
value: value.clone(),
});
}
}
if ui.button("Remove").clicked() {
component_edits.push(ComponentEdit::Remove {
entity,
name: name.clone(),
});
}
});
if let Some(value) = state.component_drafts.get_mut(&key) {
ui.text_edit_multiline(value);
}
});
}
for name in registered_names.iter().filter(|name| {
!custom_values.iter().any(|(present, _)| present == *name)
}) {
if ui.button(format!("+ {name}")).clicked() {
component_edits.push(ComponentEdit::Add {
entity,
name: name.clone(),
});
}
}
});
}
enum ComponentEdit {
Set {
entity: Entity,
name: String,
value: String,
},
Add {
entity: Entity,
name: String,
},
Remove {
entity: Entity,
name: String,
},
}
fn simulation_class_name(class: SimulationClass) -> &'static str {
match class {
SimulationClass::None => "No Physics",
SimulationClass::Static => "Static",
SimulationClass::Gameplay => "Gameplay (CPU)",
SimulationClass::GpuDynamic => "GPU Dynamic",
}
}
fn physics_solver_name(solver: PhysicsSolver) -> &'static str {
match solver {
PhysicsSolver::Full => "Full Physics",
PhysicsSolver::Simplified => "Simplified",
PhysicsSolver::NoCollision => "Gravity / No Collision",
PhysicsSolver::Space => "Space",
PhysicsSolver::Custom => "Custom Compute Shader",
}
}
fn rigid_body_kind_name(kind: RigidBodyKind) -> &'static str {
match kind {
RigidBodyKind::Fixed => "Fixed",
RigidBodyKind::Dynamic => "Dynamic",
RigidBodyKind::Kinematic => "Kinematic",
}
}
fn edit_collider(ui: &mut egui::Ui, collider: &mut Collider) {
let shape_name = match collider.shape {
ColliderShape::Box { .. } => "Box",
ColliderShape::Sphere { .. } => "Sphere",
ColliderShape::Capsule { .. } => "Capsule",
};
ComboBox::from_label("Collider")
.selected_text(shape_name)
.show_ui(ui, |ui| {
if ui.selectable_label(shape_name == "Box", "Box").clicked() {
collider.shape = ColliderShape::Box {
half_extents: [0.5; 3],
};
}
if ui
.selectable_label(shape_name == "Sphere", "Sphere")
.clicked()
{
collider.shape = ColliderShape::Sphere { radius: 0.5 };
}
if ui
.selectable_label(shape_name == "Capsule", "Capsule")
.clicked()
{
collider.shape = ColliderShape::Capsule {
half_height: 0.5,
radius: 0.5,
};
}
});
match &mut collider.shape {
ColliderShape::Box { half_extents } => {
edit_vector(ui, "Half size", half_extents, 0.05);
for extent in half_extents {
*extent = extent.max(0.001);
}
}
ColliderShape::Sphere { radius } => {
ui.add(
DragValue::new(radius)
.prefix("Radius ")
.range(0.001..=1_000_000.0)
.speed(0.05),
);
}
ColliderShape::Capsule {
half_height,
radius,
} => {
ui.add(
DragValue::new(half_height)
.prefix("Half height ")
.range(0.001..=1_000_000.0)
.speed(0.05),
);
ui.add(
DragValue::new(radius)
.prefix("Radius ")
.range(0.001..=1_000_000.0)
.speed(0.05),
);
}
}
ui.add(
egui::Slider::new(&mut collider.friction, 0.0..=1.0).text("Friction"),
);
ui.add(
egui::Slider::new(&mut collider.restitution, 0.0..=1.0)
.text("Bounciness"),
);
ui.checkbox(&mut collider.sensor, "Trigger / sensor");
}
fn project_source_path(
project_root: &str,
path: &str,
) -> Result<PathBuf, String> {
let path = project_content_path(project_root, path)?;
let supported = path.extension().and_then(|extension| extension.to_str());
if !matches!(supported, Some("rs" | "glsl" | "comp" | "vert" | "frag")) {
return Err(
"Supported source types: .rs, .glsl, .comp, .vert, .frag".into()
);
}
Ok(path)
}
fn project_content_path(
project_root: &str,
path: &str,
) -> Result<PathBuf, String> {
use std::path::Component;
let root = std::path::Path::new(project_root);
if root.as_os_str().is_empty() || !root.join("project.json").is_file() {
return Err(
"Selected project must contain a project.json manifest".into()
);
}
let requested = std::path::Path::new(path);
if requested
.components()
.any(|part| matches!(part, Component::ParentDir))
{
return Err(
"Project paths must stay inside the selected game project".into()
);
}
let absolute_root = root
.canonicalize()
.map_err(|error| format!("Could not open project folder: {error}"))?;
let path = if requested.is_absolute() {
requested.to_path_buf()
} else {
absolute_root.join(requested)
};
let mut existing_ancestor = path.as_path();
while !existing_ancestor.exists() {
existing_ancestor = existing_ancestor.parent().ok_or_else(|| {
"Project path must stay inside the project".to_owned()
})?;
}
let checked_ancestor = existing_ancestor
.canonicalize()
.map_err(|error| format!("Could not check source path: {error}"))?;
if !checked_ancestor.starts_with(&absolute_root) {
return Err(
"Project paths must stay inside the selected game project".into()
);
}
Ok(path)
}
fn project_relative_path(
project_root: &str,
path: &std::path::Path,
) -> Result<String, String> {
let absolute = project_content_path(project_root, &path.to_string_lossy())?;
let root = std::path::Path::new(project_root)
.canonicalize()
.map_err(|error| format!("Could not open project folder: {error}"))?;
absolute
.strip_prefix(root)
.map_err(|_| "File must stay inside the selected project".to_owned())
.map(|relative| relative.to_string_lossy().into_owned())
}
fn validate_project_source(
project_root: &str,
path: &str,
source: &str,
) -> Result<(), String> {
let path = project_source_path(project_root, path)?;
if source.trim().is_empty() {
return Err("Source file is empty".into());
}
match path.extension().and_then(|extension| extension.to_str()) {
Some("comp" | "vert" | "frag") => {
if !source.contains("#version") {
return Err(
"GLSL shader is missing a #version directive".into()
);
}
if !source.contains("void main") {
return Err("GLSL shader is missing void main()".into());
}
if path
.extension()
.is_some_and(|extension| extension == "comp")
&& !source.contains("local_size_")
{
return Err(
"Compute shader is missing a local workgroup size".into()
);
}
}
Some("rs") if !source.contains('{') => {
return Err("Rust source does not contain a code block".into());
}
_ => {}
}
Ok(())
}
fn save_project_source(
project_root: &str,
path: &str,
source: &str,
) -> Result<(), String> {
validate_project_source(project_root, path, source)?;
let path = project_source_path(project_root, path)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
format!("Could not create {}: {error}", parent.display())
})?;
}
std::fs::write(&path, source)
.map_err(|error| format!("Could not save {}: {error}", path.display()))
}
fn projection_planes(ui: &mut egui::Ui, near: &mut f32, far: &mut f32) {
ui.add(
DragValue::new(near)
.prefix("Near ")
.range(0.001..=1_000.0)
.speed(0.01),
);
*far = (*far).max(*near + 0.001);
ui.add(
DragValue::new(far)
.prefix("Far ")
.range((*near + 0.001)..=1_000_000.0)
.speed(1.0),
);
}
#[cfg(test)]
mod tests;