mod assets_panel;
mod diagnostics;
mod dock;
mod file_dialogs;
pub mod gui_elements;
mod hierarchy;
mod icons;
mod inspector;
mod overlay;
mod picking;
pub mod profiler;
mod project;
mod shortcuts;
pub mod view;
use dock::EditorLayoutFile;
pub use dock::{EditorDockNode, EditorPanel, EditorSplitAxis};
use file_dialogs::{DialogPurpose, FileDialogs};
use hierarchy::{collect_entities, draw_hierarchy_area};
pub use icons::EditorIcon;
pub use inspector::InspectorRegistry;
use inspector::{draw_inspector_area, ComponentEdit};
pub use view::draw_editor_view;
use crate::project::export_built_game;
#[cfg(test)]
use crate::project::package_game_files;
pub use project::{
create_project, open_project, EditorPreferences, OpenProject, ProjectError,
ProjectManagerState, ProjectManifest, RecentProject,
PROJECT_FORMAT_VERSION,
};
pub use shortcuts::{
add_mouse_delta, editor_navigation_active, handle_keyboard_input,
handle_mouse_button_input, handle_mouse_wheel, release_editor_navigation,
ui_receives_during_navigation, update_fly_camera, EditorAction,
EditorCommandQueue, EditorFlyCamera, EditorShortcuts, EditorTransformMode,
KeyBinding, SceneViewAction, ShortcutAction, ShortcutContext,
TransformModes,
};
use bevy_ecs::entity::Entity;
use bevy_ecs::prelude::{Resource, World};
use egui::{CentralPanel, Context, DragValue, TopBottomPanel};
use std::path::PathBuf;
use crate::rendering::debug_overlay::RenderDebugOverlay;
use crate::rendering::scene_renderer::SceneDebugView;
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, CollisionLayers,
DirectionalLight, GlobalTransform, MeshRenderer, Name, ObjectClasses,
Parent, PhysicsBackendStatus, PhysicsBody, Plugin, PointLight,
RenderBounds, RenderCameraOverride, RenderSettings, RenderWorld, RigidBody,
SceneDocument, SceneId, SceneLoadMode, SpotLight, Visibility,
};
use crate::Transform;
use crate::{AssetServer, Handle, MaterialAsset, PrimitiveShape};
pub fn editor_needs_continuous_redraw(world: &World) -> bool {
world
.get_resource::<EditorState>()
.is_some_and(|state| state.mode == EditorMode::Play)
|| world
.get_resource::<EditorFlyCamera>()
.is_some_and(|fly| fly.active || fly.drag.is_some())
}
#[derive(serde::Serialize, serde::Deserialize, Default, Debug, PartialEq)]
struct EditorSceneMetadata {
camera_position: Option<[f32; 3]>,
camera_rotation: Option<[f32; 3]>,
orbit_distance: Option<f32>,
selection: Vec<uuid::Uuid>,
}
fn editor_metadata_path(scene: &std::path::Path) -> PathBuf {
let mut path = scene.as_os_str().to_owned();
path.push(".editor.json");
PathBuf::from(path)
}
#[derive(Resource, Clone, Debug, Default)]
pub struct SceneFileRevision {
path: Option<PathBuf>,
revision: String,
modified: Option<std::time::SystemTime>,
}
impl SceneFileRevision {
fn read(path: &std::path::Path) -> Option<Self> {
let bytes = std::fs::read(path).ok()?;
Some(Self {
path: path.canonicalize().ok(),
revision: crate::runtime::scene_revision(&bytes),
modified: std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok(),
})
}
fn record(world: &mut World, path: &std::path::Path) {
world.insert_resource(Self::read(path).unwrap_or_default());
}
}
fn reload_external_scene_change(
world: &mut World,
state: &mut EditorState,
history: &mut EditorHistory,
) {
let Some(known) = world.get_resource::<SceneFileRevision>() else {
return;
};
let Some(path) = known.path.clone() else {
return;
};
let modified = std::fs::metadata(&path)
.and_then(|metadata| metadata.modified())
.ok();
if modified == known.modified || state.mode != EditorMode::Edit {
return;
}
let known = known.revision.clone();
let Some(disk) = SceneFileRevision::read(&path) else {
return;
};
if disk.revision == known {
world.resource_mut::<SceneFileRevision>().modified = disk.modified;
return;
}
if state.scene_dirty {
world.resource_mut::<SceneFileRevision>().modified = disk.modified;
state.scene_message = Some(format!(
"{} changed on disk while this scene has unsaved edits; Load \
takes the outside change, Save overwrites it",
path.display()
));
return;
}
if let Err(error) = remember_scene_before_edit(world, history) {
state.scene_message = Some(error);
return;
}
let result = view::reload_keeping_selection(world, state, |world| {
load_scene(world, &path, SceneLoadMode::Replace)
});
state.scene_message = Some(match result {
Ok(()) => {
history.redo.clear();
world.insert_resource(disk);
format!(
"Reloaded outside change to {}; Undo reverts it",
path.display()
)
}
Err(error) => {
history.undo.pop_back();
world.resource_mut::<SceneFileRevision>().modified = disk.modified;
format!("Could not reload outside change: {error}")
}
});
}
pub fn save_editor_scene(
world: &mut World,
state: &EditorState,
path: &std::path::Path,
) -> Result<(), crate::runtime::SceneIoError> {
if let Some(mut known) = world.get_resource_mut::<SceneFileRevision>() {
if known.path.is_some() && known.path == path.canonicalize().ok() {
if let Some(disk) = SceneFileRevision::read(path) {
if disk.revision != known.revision {
*known = disk;
return Err(crate::runtime::SceneIoError::Conflict(
path.to_owned(),
));
}
}
}
}
save_scene(world, path, "Main Scene")?;
SceneFileRevision::record(world, path);
let camera = state
.editor_camera
.and_then(|camera| world.get::<Transform>(camera));
let selection = state
.selected
.into_iter()
.chain(state.selection.iter().copied())
.filter_map(|entity| world.get::<SceneId>(entity).map(|id| id.0));
let mut metadata = EditorSceneMetadata {
camera_position: camera.map(|transform| transform.position),
camera_rotation: camera.map(|transform| transform.rotation),
orbit_distance: world
.get_resource::<EditorFlyCamera>()
.map(|fly| fly.orbit_distance),
selection: Vec::new(),
};
for id in selection {
if !metadata.selection.contains(&id) {
metadata.selection.push(id);
}
}
if let Ok(bytes) = serde_json::to_vec_pretty(&metadata) {
let _ =
crate::runtime::write_atomic(editor_metadata_path(path), &bytes);
}
Ok(())
}
pub fn load_editor_scene(
world: &mut World,
state: &mut EditorState,
path: &std::path::Path,
) -> Result<usize, crate::runtime::SceneIoError> {
let count = load_scene(world, path, SceneLoadMode::Replace)?;
SceneFileRevision::record(world, path);
state.selected = None;
state.selection.clear();
let Some(metadata) = std::fs::read(editor_metadata_path(path))
.ok()
.and_then(|bytes| {
serde_json::from_slice::<EditorSceneMetadata>(&bytes).ok()
})
else {
return Ok(count);
};
if let Some(mut transform) = state
.editor_camera
.and_then(|camera| world.get_mut::<Transform>(camera))
{
if let Some(position) = metadata.camera_position {
transform.position = position;
}
if let Some(rotation) = metadata.camera_rotation {
transform.rotation = rotation;
}
}
if let (Some(distance), Some(mut fly)) = (
metadata.orbit_distance,
world.get_resource_mut::<EditorFlyCamera>(),
) {
fly.orbit_distance = distance;
}
let mut query = world.query::<(Entity, &SceneId)>();
let by_id: std::collections::HashMap<_, _> = query
.iter(world)
.map(|(entity, id)| (id.0, entity))
.collect();
state.selection = metadata
.selection
.iter()
.filter_map(|id| by_id.get(id).copied())
.collect();
state.selected = state.selection.first().copied();
Ok(count)
}
pub fn configure_editor_style(context: &Context) {
let preferences = EditorPreferences::load();
gui_elements::EditorTheme::apply(context);
context.set_zoom_factor(preferences.ui_scale);
apply_font_scale(context, preferences.font_scale);
}
pub fn apply_font_scale(context: &Context, scale: f32) {
let defaults = egui::Style::default().text_styles;
context.style_mut(|style| {
for (text_style, font) in &mut style.text_styles {
if let Some(default) = defaults.get(text_style) {
font.size = default.size * scale;
}
}
});
}
#[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 selection: Vec<Entity>,
pub hierarchy_filter: String,
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 add_object_modal_open: bool,
pub add_object_parent: Option<Entity>,
pub class_draft: String,
pub workspace: EditorWorkspace,
pub editor_camera: Option<Entity>,
pub play_snapshot: Option<SceneDocument>,
pub preview_entities: Option<std::collections::HashSet<Entity>>,
pub preview_scene_dirty: Option<bool>,
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,
}
pub const SCENE_VIEW_TEXTURE: egui::TextureId = egui::TextureId::User(0);
#[derive(Resource, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EditorViewport {
pub offset: [u32; 2],
pub extent: [u32; 2],
pub valid: bool,
pub hovered: bool,
}
#[derive(Resource, Clone, Copy, Debug, PartialEq)]
pub struct EditorGizmoSettings {
pub show_grid: bool,
pub show_selected_axes: bool,
pub show_selected_bounds: bool,
pub shading: SceneDebugView,
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
serde::Serialize,
serde::Deserialize,
)]
pub enum GizmoAxis {
X,
Y,
Z,
}
#[derive(Resource, Clone, Debug, Default)]
pub struct EditorGizmoDrag {
pub mode: Option<TransformModes>,
pub snap_enabled: bool,
pub global_axes: bool,
pub global_scale_weights: [[f32; 3]; 3],
pub modal: bool,
pub axis_mask: [bool; 3],
pub active_axis: Option<GizmoAxis>,
pub entity: Option<Entity>,
pub start_pointer: Option<egui::Pos2>,
pub original_transform: Option<Transform>,
pub world_axis: [f32; 3],
pub local_delta_axis: [f32; 3],
pub screen_axis: [f32; 2],
pub pixels_per_world_unit: f32,
pub gizmo_axis_length: f32,
pub origin_screen: Option<egui::Pos2>,
pub world_axes: [[f32; 3]; 3],
pub local_delta_axes: [[f32; 3]; 3],
pub screen_vectors: [[f32; 2]; 3],
pub rotation_screen_signs: [f32; 3],
pub view_rotation_axis: [f32; 3],
pub move_axis_origin: [f32; 3],
pub move_axis_direction: [f32; 3],
pub move_drag_plane_normal: [f32; 3],
pub move_start_axis_parameter: Option<f32>,
undo_snapshot: Option<SceneDocument>,
}
impl EditorGizmoDrag {
#[must_use]
pub fn is_active(&self) -> bool {
self.mode.is_some()
}
}
pub fn editor_debug_view(world: &World) -> SceneDebugView {
if world.resource::<EditorState>().workspace == EditorWorkspace::Scene {
world.resource::<EditorGizmoSettings>().shading
} else {
SceneDebugView::Lit
}
}
impl Default for EditorGizmoSettings {
fn default() -> Self {
Self {
show_grid: true,
show_selected_axes: true,
show_selected_bounds: true,
shading: SceneDebugView::Lit,
}
}
}
#[derive(Resource, Clone, Debug, Default, PartialEq)]
pub struct EditorDebugOverlay(pub RenderDebugOverlay);
#[derive(Resource, Clone, Debug, Default)]
struct EditorHistory {
undo: std::collections::VecDeque<SceneDocument>,
redo: Vec<SceneDocument>,
pending_inspector: Option<SceneDocument>,
preview_before: Option<Box<EditorHistory>>,
}
impl EditorHistory {
const MAX_SNAPSHOTS: usize = 100;
fn push_undo(&mut self, document: SceneDocument) {
if self.undo.back() != Some(&document) {
self.undo.push_back(document);
if self.undo.len() > Self::MAX_SNAPSHOTS {
self.undo.pop_front();
}
}
self.redo.clear();
}
}
impl Default for EditorState {
fn default() -> Self {
Self {
selected: None,
selection: Vec::new(),
hierarchy_filter: String::new(),
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,
add_object_modal_open: false,
add_object_parent: None,
class_draft: String::new(),
workspace: EditorWorkspace::Scene,
editor_camera: None,
play_snapshot: None,
preview_entities: None,
preview_scene_dirty: 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 = editor_relative_path(&project.manifest.main_scene);
let code_path = project
.code_path
.strip_prefix(&project.root)
.unwrap_or(std::path::Path::new("src/main.rs"));
state.code_path = editor_relative_path(code_path);
state.code_source.clear();
state.code_dirty = false;
state.rename_target = None;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConsoleEntry {
pub level: ConsoleLevel,
pub source: &'static str,
pub message: String,
pub count: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConsoleLevel {
Info,
Warning,
Error,
}
#[derive(Resource, Clone, Debug, Default)]
pub struct EditorConsole {
entries: Vec<ConsoleEntry>,
filter: String,
hidden: [bool; 3],
status_seen: [Option<String>; 2],
}
impl EditorConsole {
pub const CAPACITY: usize = 2000;
pub fn push(&mut self, level: ConsoleLevel, message: impl Into<String>) {
self.push_from(level, "Editor", message);
}
pub fn push_from(
&mut self,
level: ConsoleLevel,
source: &'static str,
message: impl Into<String>,
) {
let message = message.into();
if let Some(last) = self.entries.last_mut() {
if last.level == level
&& last.source == source
&& last.message == message
{
last.count += 1;
return;
}
}
if self.entries.len() == Self::CAPACITY {
self.entries.remove(0);
}
self.entries.push(ConsoleEntry {
level,
source,
message,
count: 1,
});
}
#[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(EditorGizmoSettings::default())
.insert_resource(EditorGizmoDrag::default())
.insert_resource(EditorDebugOverlay::default())
.insert_resource(EditorShortcuts::default())
.insert_resource(EditorCommandQueue::default())
.insert_resource(InspectorRegistry::default())
.insert_resource(EditorTransformMode::default())
.insert_resource(EditorFlyCamera::default())
.insert_resource(EditorConsole::default())
.insert_resource(profiler::EditorProfiler::default())
.insert_resource(EditorHistory::default())
.insert_resource(PendingDestructiveAction::default())
.insert_resource(EditorAssetState::default())
.insert_resource(EditorBuildState::default())
.insert_resource(ProjectManagerState::default())
.insert_resource(FileDialogs::default());
app.world_mut()
.resource_mut::<crate::runtime::SceneComponentRegistry>()
.keep_unregistered();
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,
selected: Option<PathBuf>,
collapsed: std::collections::HashSet<PathBuf>,
message: Option<String>,
files: Vec<PathBuf>,
files_scanned: Option<(String, std::time::Instant)>,
thumbnails: std::collections::HashMap<PathBuf, Option<egui::TextureHandle>>,
replace_target: Option<PathBuf>,
replace_preview: Option<ReplacePreview>,
}
#[derive(Clone)]
struct ReplacePreview {
target: PathBuf,
source: PathBuf,
report: Result<crate::asset_import::ImportReport, String>,
}
impl EditorAssetState {
fn refresh_files(&mut self, project_root: &str) {
let fresh = self.files_scanned.as_ref().is_some_and(|(root, time)| {
root == project_root
&& time.elapsed() < std::time::Duration::from_secs(1)
});
if !fresh {
self.files = project_asset_files(project_root);
self.files_scanned =
Some((project_root.to_owned(), std::time::Instant::now()));
}
}
}
#[derive(Clone)]
enum BuildRequest {
Check,
BuildAndRun {
profile: GameBuildProfile,
},
Export {
parent: PathBuf,
project_name: String,
binary_name: String,
cooked_scene: PathBuf,
target: Option<&'static str>,
},
}
pub(super) const WINDOWS_TARGET: &str = "x86_64-pc-windows-gnu";
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>>>,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
restart_requested: bool,
restart_ready: bool,
play_started: Option<std::time::Instant>,
built_after: Option<std::time::Duration>,
notices: Vec<(ConsoleLevel, &'static str, String)>,
}
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 { target: None, .. } => {
"Building game for export...".into()
}
BuildRequest::Export {
target: Some(target),
..
} => format!(
"Building game for {target} export. This needs \
`rustup target add {target}` and a matching linker \
(mingw-w64 for Windows)...\n"
),
};
self.console_cursor = 0;
self.play_started = matches!(request, BuildRequest::BuildAndRun { .. })
.then(std::time::Instant::now);
self.built_after = None;
self.stop = std::sync::Arc::default();
let stop = std::sync::Arc::clone(&self.stop);
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);
}
cargo
.args(["--message-format", "short", "--manifest-path"])
.arg(&manifest)
.current_dir(&project_root);
if let BuildRequest::Export {
target: Some(target),
..
} = &request
{
cargo.args(["--target", target]);
if target.contains("windows-gnu") {
cargo.env(
format!(
"CARGO_TARGET_{}_RUSTFLAGS",
target.to_uppercase().replace('-', "_")
),
"-Clink-arg=-mwindows",
);
}
}
let finished = match run_streamed(&mut cargo, &sender, &stop) {
Ok(None) => BuildFinished {
success: false,
output: "\nCargo task stopped.\n".into(),
},
Ok(Some(status)) => {
let mut success = status.success();
if success {
if let BuildRequest::BuildAndRun { profile } = &request
{
return run_native_game(
&project_root,
&manifest,
*profile,
sender,
&stop,
);
} else if let BuildRequest::Export {
parent,
project_name,
binary_name,
cooked_scene,
target,
} = &request
{
match export_built_game(
&project_root,
&manifest,
parent,
project_name,
binary_name,
cooked_scene,
*target,
) {
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 request_stop(&mut self) {
self.restart_requested = false;
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
}
fn request_restart(&mut self) {
if self.running {
self.restart_requested = true;
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
fn take_restart_ready(&mut self) -> bool {
std::mem::take(&mut self.restart_ready)
}
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.note_output(&text);
self.append_output(&text);
}
BuildWorkerMessage::Finished(finished) => {
self.append_output(&finished.output);
self.running = false;
if !finished.success && self.built_after.is_none() {
self.note_build_errors();
}
self.play_started = None;
finished_status = Some(finished.success);
}
}
}
if finished_status.is_some() {
self.restart_ready = std::mem::take(&mut self.restart_requested);
*self.receiver.get_mut().ok()? = None;
finished_status
} else if disconnected {
if self.running {
self.running = false;
self.restart_ready =
std::mem::take(&mut self.restart_requested);
self.append_output("\nCargo worker stopped unexpectedly.\n");
*self.receiver.get_mut().ok()? = None;
Some(false)
} else {
None
}
} else {
None
}
}
fn note_output(&mut self, text: &str) {
let Some(started) = self.play_started else {
return;
};
if text.contains(BUILD_PASSED) {
self.built_after = Some(started.elapsed());
}
if let Some(game_ms) = crate::project::first_frame_ms(text) {
self.notices.push((
ConsoleLevel::Info,
"Play",
format!(
"First playable frame {:.2} s after save (build {:.2} s, \
game startup {game_ms} ms)",
started.elapsed().as_secs_f64(),
self.built_after.unwrap_or_default().as_secs_f64(),
),
));
}
for failure in crate::project::reload_diagnostics(text) {
if failure.kind == "asset" {
self.notices.push((
ConsoleLevel::Error,
"Assets",
failure.message,
));
}
}
}
fn note_build_errors(&mut self) {
for error in crate::project::reload_diagnostics(&self.output) {
let (source, location) = match error.kind {
"shader" => ("Shader", error.file),
"rust" => ("Rust", error.file),
_ => continue,
};
let location = match (location, error.line) {
(Some(file), Some(line)) => {
format!("{}:{line}: ", file.display())
}
(Some(file), None) => format!("{}: ", file.display()),
_ => String::new(),
};
self.notices.push((
ConsoleLevel::Error,
source,
format!("{location}{}", error.message),
));
}
}
fn take_notices(&mut self) -> Vec<(ConsoleLevel, &'static str, String)> {
std::mem::take(&mut self.notices)
}
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;
}
const MARKER: &str = "[older output truncated]\n";
self.output.drain(..remove);
self.output.insert_str(0, MARKER);
self.console_cursor = match self.console_cursor.checked_sub(remove)
{
Some(cursor) => cursor + MARKER.len(),
None => 0,
};
}
}
fn take_console_output(&mut self) -> String {
let text = self.output[self.console_cursor..].to_owned();
self.console_cursor = self.output.len();
text
}
}
const BUILD_PASSED: &str = "Build passed.";
fn run_native_game(
project_root: &std::path::Path,
manifest: &std::path::Path,
profile: GameBuildProfile,
sender: std::sync::mpsc::Sender<BuildWorkerMessage>,
stop: &std::sync::atomic::AtomicBool,
) {
let _ = sender.send(BuildWorkerMessage::Output(format!(
"\n{BUILD_PASSED} Starting native game...\n"
)));
let mut cargo = std::process::Command::new("cargo");
cargo.arg("run");
if let Some(flag) = profile.cargo_flag() {
cargo.arg(flag);
}
cargo
.arg("--manifest-path")
.arg(manifest)
.current_dir(project_root);
let finished = match run_streamed(&mut cargo, &sender, stop) {
Ok(Some(status)) if status.success() => BuildFinished {
success: true,
output: "\nNative game exited successfully.\n".into(),
},
Ok(Some(status)) => BuildFinished {
success: false,
output: format!("\nNative game exited with status {status}.\n"),
},
Ok(None) => BuildFinished {
success: false,
output: "\nNative game stopped.\n".into(),
},
Err(error) => BuildFinished {
success: false,
output: format!("\nCould not run native game: {error}\n"),
},
};
let _ = sender.send(BuildWorkerMessage::Finished(finished));
}
fn run_streamed(
command: &mut std::process::Command,
sender: &std::sync::mpsc::Sender<BuildWorkerMessage>,
stop: &std::sync::atomic::AtomicBool,
) -> std::io::Result<Option<std::process::ExitStatus>> {
use std::io::BufRead;
use std::process::Stdio;
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let pipes: [Option<Box<dyn std::io::Read + Send>>; 2] = [
child.stdout.take().map(|pipe| Box::new(pipe) as _),
child.stderr.take().map(|pipe| Box::new(pipe) as _),
];
let readers: Vec<_> = pipes
.into_iter()
.flatten()
.map(|pipe| {
let sender = sender.clone();
std::thread::spawn(move || {
for line in std::io::BufReader::new(pipe).lines() {
let text = match line {
Ok(line) => format!("{line}\n"),
Err(error) => {
format!("Could not read output: {error}\n")
}
};
let _ = sender.send(BuildWorkerMessage::Output(text));
}
})
})
.collect();
let status = loop {
if let Some(status) = child.try_wait()? {
break Some(status);
}
if stop.load(std::sync::atomic::Ordering::Relaxed) {
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
for reader in readers {
let _ = reader.join();
}
Ok(status)
}
enum AssetRequest {
ImportFiles(Vec<PathBuf>),
PreviewReplacement(PathBuf),
ConfirmReplacement,
LoadTexture(PathBuf),
AddModel(PathBuf),
AssignTexture(PathBuf),
NewMaterial,
AssignMesh {
entity: Entity,
handle: Handle<crate::assets::MeshAsset>,
},
AssignMaterial {
entity: Entity,
handle: Handle<MaterialAsset>,
},
LoadMaterialTexture(usize),
SetMaterialTexture {
entity: Entity,
slot: usize,
path: PathBuf,
},
}
enum EntityRequest {
CreateEmpty(Option<Entity>),
CreatePrimitive(PrimitiveShape, Option<Entity>),
CreateCamera(Option<Entity>),
CreateLight(EditorLightType, Option<Entity>),
Duplicate(Vec<Entity>),
Delete(Vec<Entity>),
SetVisible(Entity, bool),
Rename(Entity, String),
Reparent(Vec<Entity>, Option<Entity>),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EditorLightType {
Directional,
Point,
Spot,
}
impl EditorLightType {
const ALL: [Self; 3] = [Self::Directional, Self::Point, Self::Spot];
const fn label(self) -> &'static str {
match self {
Self::Directional => "Directional Light",
Self::Point => "Point Light",
Self::Spot => "Spot Light",
}
}
}
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<_>>();
free_name(&names, base)
}
fn free_name(names: &std::collections::HashSet<String>, base: &str) -> String {
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 add_model_to_scene(
world: &mut World,
path: &std::path::Path,
) -> Result<Entity, String> {
let nodes = world
.resource_mut::<AssetServer>()
.import_gltf_scene(path)
.map_err(|error| error.to_string())?;
let before = world
.query::<Entity>()
.iter(world)
.collect::<std::collections::HashSet<_>>();
let base = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("Model");
let root_name = unique_object_name(world, base);
let root = world
.spawn((SceneId::new(), Name(root_name), Transform::default()))
.id();
let entities = crate::spawn_gltf_nodes_in_world(world, &nodes, None)
.map_err(|error| error.to_string())?;
for (node, &entity) in nodes.iter().zip(&entities) {
if node.parent.is_none() {
crate::runtime::hierarchy::set_parent(world, entity, root)
.map_err(|error| error.to_string())?;
}
}
let mut query = world.query::<(Entity, &Name)>();
let mut names = std::collections::HashSet::new();
let mut added = Vec::new();
for (entity, name) in query.iter(world) {
if before.contains(&entity) || entity == root {
names.insert(name.0.clone());
} else {
added.push(entity);
}
}
for entity in added {
let base = world.get::<Name>(entity).map_or("", |name| &name.0);
let base = if base.is_empty() { "Node" } else { base };
let name = free_name(&names, base);
names.insert(name.clone());
world.entity_mut(entity).insert(Name(name));
}
Ok(root)
}
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 replace_asset(
project_root: &str,
target: &std::path::Path,
source: &std::path::Path,
dry_run: bool,
) -> Result<crate::asset_import::ImportReport, String> {
crate::asset_import::reimport_asset(
std::path::Path::new(project_root),
&target.to_string_lossy(),
Some(source),
&crate::asset_import::AssetProvenance::default(),
None,
dry_run,
)
.map_err(|error| error.to_string())
}
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,
dialogs: &mut FileDialogs,
) -> 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() {
dialogs.pick_folder(
DialogPurpose::OpenProject,
rfd::AsyncFileDialog::new()
.set_title("Open RustingEngine Project"),
);
}
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::AsyncFileDialog::new()
.set_title("Choose Project Parent Folder");
if !manager.parent_directory.as_os_str().is_empty() {
dialog =
dialog.set_directory(&manager.parent_directory);
}
dialogs.pick_folder(
DialogPurpose::ProjectParentFolder,
dialog,
);
}
});
if ui.button("Create Project").clicked() {
if manager.parent_directory.as_os_str().is_empty() {
dialogs.pick_folder(
DialogPurpose::CreateProjectIn,
rfd::AsyncFileDialog::new()
.set_title("Choose Where to Create the Project"),
);
} 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 dock_header_button(
ui: &mut egui::Ui,
icon: EditorIcon,
hover_text: &str,
) -> egui::Response {
gui_elements::icon_button_sized(
ui,
icon,
false,
true,
egui::vec2(22.0, 20.0),
)
.on_hover_text(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 = 4.0;
match node {
EditorDockNode::Area { id, panel } => {
let id = *id;
let panel_rect = rect.shrink(1.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())
&& ui.rect_contains_pointer(panel_rect);
if pressed_inside {
*active_area = id;
}
let active = *active_area == id;
if !matches!(panel, EditorPanel::Scene | EditorPanel::Game)
{
ui.painter().rect_filled(
panel_rect,
4.0,
gui_elements::EditorTheme::PANEL,
);
}
let stroke = if active {
egui::Stroke::new(
1.0_f32,
gui_elements::EditorTheme::ACCENT,
)
} else {
egui::Stroke::new(
1.0_f32,
gui_elements::EditorTheme::BORDER,
)
};
egui::Frame::NONE
.fill(gui_elements::EditorTheme::PANEL_RAISED)
.corner_radius(egui::CornerRadius {
nw: 4,
ne: 4,
sw: 0,
se: 0,
})
.inner_margin(egui::Margin::symmetric(4, 2))
.show(ui, |ui| {
ui.horizontal(|ui| {
let (icon_rect, _) = ui.allocate_exact_size(
egui::vec2(18.0, 18.0),
egui::Sense::hover(),
);
icons::paint_editor_icon(
ui.painter(),
panel.icon(),
icon_rect,
if active {
gui_elements::EditorTheme::ACCENT_HOVER
} else {
gui_elements::EditorTheme::TEXT_MUTED
},
);
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,
EditorIcon::SplitColumns,
"Split into left/right areas",
)
.clicked()
{
actions.push(DockAction::Split(
id,
EditorSplitAxis::Columns,
));
}
if dock_header_button(
ui,
EditorIcon::SplitRows,
"Split into top/bottom areas",
)
.clicked()
{
actions.push(DockAction::Split(
id,
EditorSplitAxis::Rows,
));
}
if dock_header_button(
ui,
EditorIcon::Close,
"Close this area",
)
.clicked()
{
actions.push(DockAction::Close(id));
}
});
});
egui::Frame::NONE
.inner_margin(egui::Margin::same(6))
.show(ui, |ui| show_panel(ui, *panel));
ui.painter().rect_stroke(
panel_rect,
4.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() {
gui_elements::EditorTheme::ACCENT_HOVER
} else {
gui_elements::EditorTheme::BORDER_SOFT
},
);
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();
}
}
}
}
}
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(editor_relative_path)
}
fn editor_relative_path(path: &std::path::Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
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())
})?;
}
crate::runtime::write_atomic(&path, source.as_bytes())
.map_err(|error| format!("Could not save {}: {error}", path.display()))
}
#[cfg(test)]
mod tests;