Skip to main content

editor/
ui.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use libdqg::ecs::Entity;
5use libdqg::glam;
6use libdqg::scripting::{ScriptAttachment, ScriptList};
7use libdqg::world::{CameraComponent, Renderable, Transform, World};
8
9use crate::editor_settings::EditorSettings;
10use crate::project::{Project, RenderableAsset, RenderableKind};
11
12/// Side length, in points, of an asset tile's preview image in the Assets panel.
13const ASSET_PREVIEW_SIZE: f32 = 56.0;
14
15/// Requests raised by the UI that need something `draw` doesn't have access to (a live
16/// `&mut Renderer` for loading assets, or `EditorScene`'s ability to replace its whole `World`
17/// when a project is opened) — consumed by `EditorScene::update` right after `draw` runs, while
18/// the renderer is still in scope for that frame.
19#[derive(Default)]
20pub struct UiRequests {
21    pub new_project: Option<PathBuf>,
22    pub open_project: Option<PathBuf>,
23    pub attach_renderable: Option<(Entity, RenderableKind, PathBuf)>,
24    /// Play/Stop was clicked — starting/stopping the [`libdqg::scripting::ScriptRuntime`] needs
25    /// `EditorScene`'s help (it owns the runtime and the pre-Play transform snapshot), unlike a
26    /// script attachment/removal/reorder, which `ui.rs` can just apply to `world.scripts`
27    /// directly since it needs no GPU load step.
28    pub toggle_play: bool,
29    /// "Export..." was clicked and a destination folder chosen — building the standalone game
30    /// needs `EditorScene`'s open [`Project`], which `ui.rs` doesn't own.
31    pub export_project: Option<PathBuf>,
32    /// A scene tile in the Assets panel's Scenes section was double-clicked, or a tab in the
33    /// scene tab strip was clicked — `EditorScene`'s own `switch_or_open_scene` method needs to
34    /// run (it owns every open tab's `World`/`project`), same reason `toggle_play`/
35    /// `export_project` are requests instead of handled here directly.
36    pub switch_scene: Option<PathBuf>,
37    /// A tab's close button was clicked — `EditorScene`'s own `close_scene_tab` needs to run (it
38    /// owns the tab list and has to autosave before dropping one).
39    pub close_scene_tab: Option<PathBuf>,
40    /// A scene tile's inline rename was committed (`(old_path, new_stem)`) — needs `EditorScene`'s
41    /// help since renaming may also need to update `ProjectManifest.start_scene` and any open
42    /// tab's identity, neither of which `ui.rs` owns.
43    pub rename_scene: Option<(PathBuf, String)>,
44    /// A script tile's inline rename was committed (`(old_path, new_stem)`) — deferred to
45    /// `EditorScene::rename_script`, which fixes up every open tab's `World` and every scene file
46    /// on disk that references the old path, not just whichever `World` happens to be active —
47    /// `ui.rs` only ever sees one `World` at a time.
48    pub rename_script: Option<(PathBuf, String)>,
49    /// A scene tile's "Set as Start Scene" context-menu item was clicked — needs `&mut Project` to
50    /// persist `ProjectManifest.start_scene`, which `ui.rs` only ever sees as `&Project`.
51    pub set_start_scene: Option<PathBuf>,
52    /// Something in the active tab's `World`/entity-asset map was mutated this frame — set by
53    /// every editing widget below (transform drag, hierarchy add/rename/duplicate/delete, camera/
54    /// renderable/script editing) so `EditorScene::update` can mark that tab dirty. Not raised for
55    /// `attach_renderable`, which is itself a request `EditorScene` marks dirty when it applies it.
56    pub edited: bool,
57    /// "Save" was clicked — deferred to `EditorScene` (rather than saved directly here, unlike
58    /// before tabs existed) because `ui.rs` doesn't own the dirty flag it needs to clear on
59    /// success.
60    pub save_scene: bool,
61}
62
63/// Top menu bar (project New/Open/Save) + bottom assets panel (collapsible; import/preview
64/// project assets) + left hierarchy panel (add/remove/rename/select entities) + right inspector
65/// panel for the selected entity's `Transform` and `Renderable` (attach/replace/remove),
66/// editable live.
67pub fn draw(
68    ui: &mut egui::Ui,
69    world: &mut World,
70    selected: &mut Option<Entity>,
71    renaming: &mut Option<Entity>,
72    rename_buffer: &mut String,
73    project: Option<&Project>,
74    entity_assets: &mut HashMap<Entity, RenderableAsset>,
75    assets_expanded: &mut bool,
76    texture_previews: &mut HashMap<PathBuf, egui::TextureHandle>,
77    is_playing: bool,
78    script_errors: &[String],
79    settings: &mut EditorSettings,
80    renaming_script: &mut Option<PathBuf>,
81    script_rename_buffer: &mut String,
82    renaming_scene: &mut Option<PathBuf>,
83    scene_rename_buffer: &mut String,
84    export_status: Option<&str>,
85    current_scene: &Path,
86    current_scene_dirty: bool,
87    open_scenes: &[(PathBuf, bool)],
88    active_scene: usize,
89    requests: &mut UiRequests,
90) {
91    draw_menu_bar(ui, project, is_playing, current_scene_dirty, requests);
92    draw_scene_tabs(ui, open_scenes, active_scene, requests);
93    draw_assets_panel(
94        ui,
95        project,
96        assets_expanded,
97        texture_previews,
98        settings,
99        renaming_script,
100        script_rename_buffer,
101        renaming_scene,
102        scene_rename_buffer,
103        current_scene,
104        requests,
105    );
106    draw_hierarchy(ui, world, selected, renaming, rename_buffer, entity_assets, requests);
107    draw_inspector(ui, world, selected, project, entity_assets, is_playing, requests);
108    draw_script_error_overlay(ui, script_errors);
109    draw_export_status_overlay(ui, export_status);
110}
111
112/// Tab strip for the project's open scenes (`EditorScene::open_scenes`), one tab per scene kept
113/// loaded in memory — clicking a tab switches to it instantly (no reload: unlike the old
114/// single-scene behavior, an opened scene stays fully loaded until its tab is closed). Each tab's
115/// `bool` marks it dirty (unsaved edits — see `EditorScene::OpenScene::dirty`), shown as a
116/// trailing `*` on its label. The close button ("x") removes a tab (autosaving it first, handled
117/// by `EditorScene::close_scene_tab`), except when it's the only one open — closing while Playing
118/// (if it's the active tab) stops Play first, same as switching does, so no separate disabled
119/// state is needed here.
120fn draw_scene_tabs(ui: &mut egui::Ui, open_scenes: &[(PathBuf, bool)], active_scene: usize, requests: &mut UiRequests) {
121    egui::Panel::top("scene_tabs_panel").show(ui, |ui| {
122        ui.horizontal(|ui| {
123            for (index, (path, dirty)) in open_scenes.iter().enumerate() {
124                let is_active = index == active_scene;
125                let label = if *dirty { format!("{} *", asset_file_name(path)) } else { asset_file_name(path) };
126                if ui.selectable_label(is_active, label).clicked() && !is_active {
127                    requests.switch_scene = Some(path.clone());
128                }
129                if open_scenes.len() > 1 && ui.small_button("x").clicked() {
130                    requests.close_scene_tab = Some(path.clone());
131                }
132                ui.separator();
133            }
134        });
135    });
136}
137
138fn draw_menu_bar(ui: &mut egui::Ui, project: Option<&Project>, is_playing: bool, current_scene_dirty: bool, requests: &mut UiRequests) {
139    egui::Panel::top("menu_bar_panel").show(ui, |ui| {
140        ui.horizontal(|ui| {
141            ui.menu_button("File", |ui| {
142                if ui.button("New Project...").clicked() {
143                    if let Some(root) = rfd::FileDialog::new().pick_folder() {
144                        requests.new_project = Some(root);
145                    }
146                    ui.close();
147                }
148                if ui.button("Open Project...").clicked() {
149                    if let Some(root) = rfd::FileDialog::new().pick_folder() {
150                        requests.open_project = Some(root);
151                    }
152                    ui.close();
153                }
154                // Also disabled during Play: scripts can now spawn/despawn/rename entities and
155                // attach scripts, all of which are meant to be ephemeral and revert on Stop (see
156                // EditorScene::stop_play) — saving mid-Play would bake that Play-time state into
157                // the scene file instead.
158                ui.add_enabled_ui(project.is_some() && !is_playing, |ui| {
159                    if ui.button("Save").clicked() {
160                        requests.save_scene = true;
161                        ui.close();
162                    }
163                    if ui.button("Export...").clicked() {
164                        if let Some(output_dir) = rfd::FileDialog::new().pick_folder() {
165                            requests.export_project = Some(output_dir);
166                        }
167                        ui.close();
168                    }
169                });
170            });
171
172            // Plain text rather than a play/stop glyph — egui's default font doesn't cover
173            // those and renders a tofu box instead.
174            ui.add_enabled_ui(project.is_some(), |ui| {
175                if ui.button(if is_playing { "Stop" } else { "Play" }).clicked() {
176                    requests.toggle_play = true;
177                }
178            });
179
180            if let Some(project) = project {
181                let dirty_marker = if current_scene_dirty { " *" } else { "" };
182                ui.label(format!("Project: {}{dirty_marker}", project.manifest.name));
183            }
184        });
185    });
186}
187
188fn draw_hierarchy(
189    ui: &mut egui::Ui,
190    world: &mut World,
191    selected: &mut Option<Entity>,
192    renaming: &mut Option<Entity>,
193    rename_buffer: &mut String,
194    entity_assets: &mut HashMap<Entity, RenderableAsset>,
195    requests: &mut UiRequests
196) {
197    egui::Panel::left("hierarchy_panel").show(ui, |ui| {
198        ui.heading("Hierarchy");
199        ui.separator();
200
201        ui.menu_button("+ Add Entity", |ui| {
202            if ui.button("Empty").clicked() {
203                let name = format!("Entity {}", world.iter_entities().count() + 1);
204                let entity = world.spawn_empty(name, Transform::default());
205                *selected = Some(entity);
206                *renaming = None;
207                requests.edited = true;
208                ui.close();
209            }
210            if ui.button("Camera").clicked() {
211                let name = format!("Camera {}", world.iter_entities().count() + 1);
212                let entity = world.spawn_empty(name, Transform::default());
213                world.set_camera(entity, CameraComponent::default());
214                *selected = Some(entity);
215                *renaming = None;
216                requests.edited = true;
217                ui.close();
218            }
219        });
220        ui.separator();
221
222        let entities: Vec<Entity> = world.iter_entities().collect();
223        let mut despawn_requested: Option<Entity> = None;
224        for entity in entities {
225            let label = world
226                .names
227                .get(entity)
228                .map(|name| name.0.clone())
229                .unwrap_or_else(|| "<unnamed>".to_string());
230
231            if *renaming == Some(entity) {
232                let response = ui.text_edit_singleline(rename_buffer);
233                if response.lost_focus() {
234                    if let Some(name) = world.names.get_mut(entity) {
235                        name.0 = rename_buffer.clone();
236                    }
237                    *renaming = None;
238                    requests.edited = true;
239                } else {
240                    response.request_focus();
241                }
242                continue;
243            }
244
245            let is_selected = *selected == Some(entity);
246            let response = ui.selectable_label(is_selected, label.clone());
247            if response.clicked() {
248                *selected = Some(entity);
249            }
250            if response.double_clicked() {
251                *renaming = Some(entity);
252                *rename_buffer = label.clone();
253            }
254            // Right-click menu is the only way to rename/delete now that the row has no
255            // dedicated buttons of its own.
256            response.context_menu(|ui| {
257                if ui.button("Rename").clicked() {
258                    *selected = Some(entity);
259                    *renaming = Some(entity);
260                    *rename_buffer = label.clone();
261                    ui.close();
262                }
263                if ui.button("Duplicate").clicked() {
264                    if let Some(name) = world.names.get(entity) {
265                        let new_name = format!("{} (copy)", name.0);
266                        let new_entity = world.spawn_empty(new_name, *world.transforms.get(entity).unwrap_or(&Transform::default()));
267                        if let Some(asset) = entity_assets.get(&entity) {
268                            requests.attach_renderable = Some((new_entity, asset.kind(), asset.path().to_path_buf()));
269                        }
270                        if let Some(component) = world.cameras.get(entity).copied() {
271                            world.set_camera(new_entity, component);
272                        }
273                        requests.edited = true;
274                    }
275                    ui.close();
276                }
277                if ui.button("Delete").clicked() {
278                    despawn_requested = Some(entity);
279                    ui.close();
280                }
281            });
282        }
283
284        if let Some(entity) = despawn_requested {
285            world.despawn(entity);
286            entity_assets.remove(&entity);
287            requests.edited = true;
288            if *selected == Some(entity) {
289                *selected = None;
290            }
291            if *renaming == Some(entity) {
292                *renaming = None;
293            }
294        }
295    });
296}
297
298fn draw_assets_panel(
299    ui: &mut egui::Ui,
300    project: Option<&Project>,
301    assets_expanded: &mut bool,
302    texture_previews: &mut HashMap<PathBuf, egui::TextureHandle>,
303    settings: &mut EditorSettings,
304    renaming_script: &mut Option<PathBuf>,
305    script_rename_buffer: &mut String,
306    renaming_scene: &mut Option<PathBuf>,
307    scene_rename_buffer: &mut String,
308    current_scene: &Path,
309    requests: &mut UiRequests,
310) {
311    egui::Panel::bottom("assets_panel").show(ui, |ui| {
312        ui.horizontal(|ui| {
313            // Plain ASCII rather than a triangle glyph (`\u{25BC}`/`\u{25B6}`) — egui's default
314            // font doesn't cover those and renders a tofu box instead.
315            let toggle_icon = if *assets_expanded { "v" } else { ">" };
316            if ui.small_button(toggle_icon).clicked() {
317                *assets_expanded = !*assets_expanded;
318            }
319            ui.heading("Assets");
320        });
321
322        if !*assets_expanded {
323            return;
324        }
325        ui.separator();
326
327        let Some(project) = project else {
328            ui.label("Open or create a project to import assets.");
329            return;
330        };
331
332        egui::ScrollArea::horizontal().show(ui, |ui| {
333            ui.horizontal(|ui| {
334                draw_texture_group(ui, project, texture_previews);
335                ui.separator();
336                draw_model_group(ui, project);
337                ui.separator();
338                draw_script_group(ui, project, settings, renaming_script, script_rename_buffer, requests);
339                ui.separator();
340                draw_scene_group(ui, project, current_scene, renaming_scene, scene_rename_buffer, requests);
341            });
342        });
343    });
344}
345
346fn draw_texture_group(ui: &mut egui::Ui, project: &Project, previews: &mut HashMap<PathBuf, egui::TextureHandle>) {
347    ui.vertical(|ui| {
348        draw_group_header(ui, "Textures", &["png", "jpg", "jpeg"], project);
349        ui.horizontal_wrapped(|ui| {
350            for path in project.list_assets(RenderableKind::Sprite) {
351                draw_texture_tile(ui, project, &path, previews);
352            }
353        });
354    });
355}
356
357fn draw_model_group(ui: &mut egui::Ui, project: &Project) {
358    ui.vertical(|ui| {
359        draw_group_header(ui, "Models", &["obj"], project);
360        ui.horizontal_wrapped(|ui| {
361            for path in project.list_assets(RenderableKind::Model) {
362                draw_model_tile(ui, &path);
363            }
364        });
365    });
366}
367
368fn draw_script_group(
369    ui: &mut egui::Ui,
370    project: &Project,
371    settings: &mut EditorSettings,
372    renaming_script: &mut Option<PathBuf>,
373    script_rename_buffer: &mut String,
374    requests: &mut UiRequests,
375) {
376    ui.vertical(|ui| {
377        ui.horizontal(|ui| {
378            draw_group_header(ui, "Scripts", &["rhai"], project);
379            // Unlike Textures/Models, a script has nothing to import from outside the project —
380            // it's authored here, so this creates one from scratch instead.
381            if ui.small_button("New Script").clicked() {
382                if let Err(e) = project.create_script() {
383                    eprintln!("Failed to create script: {e}");
384                }
385            }
386        });
387        ui.horizontal_wrapped(|ui| {
388            for path in project.list_scripts() {
389                draw_script_tile(ui, project, &path, settings, renaming_script, script_rename_buffer, requests);
390            }
391        });
392    });
393}
394
395/// Mirrors [`draw_script_group`]'s shape: a project can hold several scene `.ron` files (see
396/// `Project::list_scenes`), listed here with a way to create new ones from scratch (a scene, like
397/// a script, has nothing to import from outside the project) and switch which one is currently
398/// open for editing.
399fn draw_scene_group(
400    ui: &mut egui::Ui,
401    project: &Project,
402    current_scene: &Path,
403    renaming_scene: &mut Option<PathBuf>,
404    scene_rename_buffer: &mut String,
405    requests: &mut UiRequests,
406) {
407    ui.vertical(|ui| {
408        ui.horizontal(|ui| {
409            ui.label("Scenes");
410            if ui.small_button("New Scene").clicked() {
411                if let Err(e) = project.create_scene() {
412                    eprintln!("Failed to create scene: {e}");
413                }
414            }
415        });
416        ui.horizontal_wrapped(|ui| {
417            for path in project.list_scenes() {
418                draw_scene_tile(ui, &path, current_scene, &project.manifest.start_scene, renaming_scene, scene_rename_buffer, requests);
419            }
420        });
421    });
422}
423
424/// One tile: a placeholder icon (see `draw_model_tile`'s doc comment — no cheap thumbnail for a
425/// scene either), highlighted if it's the scene currently open for editing and starred if it's
426/// the project's start scene. Double-click switches to it (opening a new tab if it isn't already
427/// open — see `EditorScene::switch_or_open_scene` — or just activating its existing tab, no
428/// reload, if it is); the context menu offers Rename (inline, like the Scripts section's) and
429/// "Set as Start Scene". Renaming only touches the file on disk, `ProjectManifest.start_scene`,
430/// and any open tab's identity — unlike a `ScriptAttachment` path (structured ECS data
431/// `rename_script` can fix up everywhere it's referenced), a `scene.change("path")` call is a
432/// free-text string literal inside `.rhai` source that can't be auto-fixed, so a renamed scene's
433/// incoming `scene.change(...)` calls (if any) are left dangling and need updating by hand.
434fn draw_scene_tile(
435    ui: &mut egui::Ui,
436    path: &Path,
437    current_scene: &Path,
438    start_scene: &Path,
439    renaming_scene: &mut Option<PathBuf>,
440    scene_rename_buffer: &mut String,
441    requests: &mut UiRequests,
442) {
443    ui.vertical(|ui| {
444        ui.set_width(ASSET_PREVIEW_SIZE);
445
446        if renaming_scene.as_deref() == Some(path) {
447            let response = ui.text_edit_singleline(scene_rename_buffer);
448            if response.lost_focus() {
449                requests.rename_scene = Some((path.to_path_buf(), scene_rename_buffer.clone()));
450                *renaming_scene = None;
451            } else {
452                response.request_focus();
453            }
454            return;
455        }
456
457        let (rect, response) =
458            ui.allocate_exact_size(egui::vec2(ASSET_PREVIEW_SIZE, ASSET_PREVIEW_SIZE), egui::Sense::click());
459        let is_open = path == current_scene;
460        let fill = if is_open { egui::Color32::from_gray(80) } else { egui::Color32::from_gray(55) };
461        ui.painter().rect_filled(rect, 4.0, fill);
462        ui.painter().text(
463            rect.center(),
464            egui::Align2::CENTER_CENTER,
465            "RON",
466            egui::FontId::proportional(12.0),
467            egui::Color32::from_gray(200),
468        );
469        if path == start_scene {
470            ui.painter().text(
471                rect.right_top(),
472                egui::Align2::RIGHT_TOP,
473                "*",
474                egui::FontId::proportional(14.0),
475                egui::Color32::YELLOW,
476            );
477        }
478
479        if response.double_clicked() {
480            requests.switch_scene = Some(path.to_path_buf());
481        }
482        response.context_menu(|ui| {
483            if ui.button("Rename").clicked() {
484                *renaming_scene = Some(path.to_path_buf());
485                *scene_rename_buffer = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
486                ui.close();
487            }
488            if ui.button("Set as Start Scene").clicked() {
489                requests.set_start_scene = Some(path.to_path_buf());
490                ui.close();
491            }
492        });
493
494        ui.add(egui::Label::new(asset_file_name(path)).wrap());
495    });
496}
497
498fn draw_group_header(ui: &mut egui::Ui, heading: &str, import_filter: &[&str], project: &Project) {
499    ui.horizontal(|ui| {
500        ui.label(heading);
501        if ui.small_button("Import...").clicked() {
502            if let Some(path) = rfd::FileDialog::new().add_filter(heading, import_filter).pick_file() {
503                if let Err(e) = project.import_asset(&path) {
504                    eprintln!("Failed to import asset: {e}");
505                }
506            }
507        }
508    });
509}
510
511/// One tile: the texture's own thumbnail (loaded through `egui`'s own texture manager, and
512/// cached in `previews` so it's decoded from disk once rather than every frame) with the file
513/// name below it.
514fn draw_texture_tile(ui: &mut egui::Ui, project: &Project, path: &Path, previews: &mut HashMap<PathBuf, egui::TextureHandle>) {
515    let handle = previews
516        .entry(path.to_path_buf())
517        .or_insert_with(|| load_texture_preview(ui.ctx(), &project.root.join(path)));
518
519    // Reserve a fixed square footprint so tiles still line up in a grid, but paint the
520    // thumbnail at its own aspect ratio (scaled to fit) inside it rather than stretching a
521    // non-square image to fill the square.
522    let fitted_size = fit_within_square(handle.size_vec2(), ASSET_PREVIEW_SIZE);
523
524    ui.vertical(|ui| {
525        ui.set_width(ASSET_PREVIEW_SIZE);
526        let (slot_rect, _) = ui.allocate_exact_size(egui::vec2(ASSET_PREVIEW_SIZE, ASSET_PREVIEW_SIZE), egui::Sense::hover());
527        let image_rect = egui::Rect::from_center_size(slot_rect.center(), fitted_size);
528        egui::Image::new((handle.id(), fitted_size)).paint_at(ui, image_rect);
529        ui.add(egui::Label::new(asset_file_name(path)).wrap());
530    });
531}
532
533/// Scales `size` down (never up) to fit within a `max_side`-by-`max_side` square, preserving
534/// its aspect ratio.
535fn fit_within_square(size: egui::Vec2, max_side: f32) -> egui::Vec2 {
536    if size.x <= 0.0 || size.y <= 0.0 {
537        return egui::Vec2::splat(max_side);
538    }
539    let scale = (max_side / size.x).min(max_side / size.y).min(1.0);
540    size * scale
541}
542
543fn load_texture_preview(ctx: &egui::Context, full_path: &Path) -> egui::TextureHandle {
544    let color_image = image::open(full_path)
545        .map(|image| {
546            let thumbnail = image.thumbnail(64, 64).to_rgba8();
547            let size = [thumbnail.width() as usize, thumbnail.height() as usize];
548            egui::ColorImage::from_rgba_unmultiplied(size, thumbnail.as_raw())
549        })
550        .unwrap_or_else(|e| {
551            eprintln!("Failed to load texture preview for {}: {e}", full_path.display());
552            egui::ColorImage::filled([1, 1], egui::Color32::from_gray(80))
553        });
554
555    ctx.load_texture(full_path.display().to_string(), color_image, egui::TextureOptions::LINEAR)
556}
557
558/// A model has no cheap thumbnail to render (that would need an offscreen 3D render pass per
559/// asset), so its tile is a plain placeholder icon instead, sized to match a texture tile.
560fn draw_model_tile(ui: &mut egui::Ui, path: &Path) {
561    ui.vertical(|ui| {
562        ui.set_width(ASSET_PREVIEW_SIZE);
563        let (rect, _) = ui.allocate_exact_size(egui::vec2(ASSET_PREVIEW_SIZE, ASSET_PREVIEW_SIZE), egui::Sense::hover());
564        ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(55));
565        ui.painter().text(
566            rect.center(),
567            egui::Align2::CENTER_CENTER,
568            "OBJ",
569            egui::FontId::proportional(14.0),
570            egui::Color32::from_gray(200),
571        );
572        ui.add(egui::Label::new(asset_file_name(path)).wrap());
573    });
574}
575
576/// A script has no thumbnail either (see `draw_model_tile`'s doc comment for the same reasoning),
577/// so its tile is a plain placeholder icon, sized to match a texture/model tile. Unlike the
578/// other asset tiles, this one is interactive: click opens `path` in an external text editor
579/// (see [`open_script_in_editor`]) and the right-click context menu offers Rename (inline, like
580/// the Hierarchy panel's entity rename) and a way to change the remembered editor.
581fn draw_script_tile(
582    ui: &mut egui::Ui,
583    project: &Project,
584    path: &Path,
585    settings: &mut EditorSettings,
586    renaming_script: &mut Option<PathBuf>,
587    script_rename_buffer: &mut String,
588    requests: &mut UiRequests,
589) {
590    ui.vertical(|ui| {
591        ui.set_width(ASSET_PREVIEW_SIZE);
592
593        if renaming_script.as_deref() == Some(path) {
594            let response = ui.text_edit_singleline(script_rename_buffer);
595            if response.lost_focus() {
596                // Deferred to `EditorScene::rename_script`, not handled here like the old
597                // single-scene version of this rename was — a `ScriptAttachment` can be
598                // referenced by entities in *any* scene file, not just whichever one is the
599                // active tab's `World`, so fixing it up needs `Project`/every open tab together,
600                // neither of which `ui.rs` owns.
601                requests.rename_script = Some((path.to_path_buf(), script_rename_buffer.clone()));
602                *renaming_script = None;
603            } else {
604                response.request_focus();
605            }
606            return;
607        }
608
609        let (rect, response) =
610            ui.allocate_exact_size(egui::vec2(ASSET_PREVIEW_SIZE, ASSET_PREVIEW_SIZE), egui::Sense::click());
611        ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(55));
612        ui.painter().text(
613            rect.center(),
614            egui::Align2::CENTER_CENTER,
615            "RHAI",
616            egui::FontId::proportional(12.0),
617            egui::Color32::from_gray(200),
618        );
619
620        // A single click opens the script — no `double_clicked()` handling here, since a
621        // double-click's first press already fires `clicked()` too, and rename lives in the
622        // context menu instead (see the doc comment above) precisely to avoid that collision:
623        // double-clicking to rename would otherwise also launch the editor once first.
624        if response.clicked() {
625            open_script_in_editor(project, path, settings);
626        }
627
628        response.context_menu(|ui| {
629            if ui.button("Rename").clicked() {
630                *renaming_script = Some(path.to_path_buf());
631                *script_rename_buffer = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
632                ui.close();
633            }
634            if ui.button("Change Editor...").clicked() {
635                pick_and_save_editor(settings);
636                ui.close();
637            }
638        });
639
640        ui.add(egui::Label::new(asset_file_name(path)).wrap());
641    });
642}
643
644/// Opens `path` (project-relative) in [`EditorSettings::preferred_editor`], prompting for one
645/// first (and remembering the choice — see [`pick_and_save_editor`]) if none is set yet.
646fn open_script_in_editor(project: &Project, path: &Path, settings: &mut EditorSettings) {
647    if settings.preferred_editor.is_none() {
648        pick_and_save_editor(settings);
649    }
650    let Some(editor) = settings.preferred_editor.as_ref() else { return };
651
652    if let Err(e) = std::process::Command::new(editor).arg(project.root.join(path)).spawn() {
653        eprintln!("Failed to launch {}: {e}", editor.display());
654    }
655}
656
657/// Prompts for an executable via a native file picker and, if one is chosen, saves it as the
658/// remembered editor (persisted next to the editor executable — see [`EditorSettings::save`]) so
659/// future script clicks don't need to ask again.
660fn pick_and_save_editor(settings: &mut EditorSettings) {
661    let Some(editor) = rfd::FileDialog::new().set_title("Choose a text editor").pick_file() else { return };
662    settings.preferred_editor = Some(editor);
663    if let Err(e) = settings.save() {
664        eprintln!("Failed to save editor preference: {e}");
665    }
666}
667
668fn asset_file_name(path: &Path) -> String {
669    path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default()
670}
671
672fn draw_inspector(
673    ui: &mut egui::Ui,
674    world: &mut World,
675    selected: &mut Option<Entity>,
676    project: Option<&Project>,
677    entity_assets: &mut HashMap<Entity, RenderableAsset>,
678    is_playing: bool,
679    requests: &mut UiRequests,
680) {
681    if let Some(entity) = *selected
682        && !world.is_alive(entity)
683    {
684        *selected = None;
685    }
686
687    egui::Panel::right("inspector_panel").show(ui, |ui| {
688        ui.heading("Inspector");
689        ui.separator();
690
691        let Some(entity) = *selected else {
692            ui.label("No entity selected.");
693            return;
694        };
695
696        // Editing a Transform in the Inspector while a script is writing to the same entity
697        // every frame would visibly fight with it, so disable the numeric fields during Play.
698        ui.add_enabled_ui(!is_playing, |ui| {
699            draw_transform_editor(ui, world, entity, requests);
700        });
701        ui.separator();
702        draw_camera_editor(ui, world, entity, requests);
703        ui.separator();
704        draw_renderable_editor(ui, world, entity, project, entity_assets, requests);
705        ui.separator();
706        draw_script_editor(ui, world, entity, project, requests);
707    });
708}
709
710fn draw_transform_editor(ui: &mut egui::Ui, world: &mut World, entity: Entity, requests: &mut UiRequests) {
711    let Some(transform) = world.transforms.get_mut(entity) else { return };
712    let mut edited = false;
713
714    ui.label("Position");
715    ui.horizontal(|ui| {
716        edited |= ui.add(egui::DragValue::new(&mut transform.position.x).speed(0.05).prefix("x: ")).changed();
717        edited |= ui.add(egui::DragValue::new(&mut transform.position.y).speed(0.05).prefix("y: ")).changed();
718        edited |= ui.add(egui::DragValue::new(&mut transform.position.z).speed(0.05).prefix("z: ")).changed();
719    });
720
721    let (mut yaw, mut pitch, mut roll) = transform.rotation.to_euler(glam::EulerRot::YXZ);
722    yaw = yaw.to_degrees();
723    pitch = pitch.to_degrees();
724    roll = roll.to_degrees();
725    ui.label("Rotation (deg)");
726    let mut changed = false;
727    ui.horizontal(|ui| {
728        changed |= ui.add(egui::DragValue::new(&mut pitch).speed(0.5).prefix("x: ")).changed();
729        changed |= ui.add(egui::DragValue::new(&mut yaw).speed(0.5).prefix("y: ")).changed();
730        changed |= ui.add(egui::DragValue::new(&mut roll).speed(0.5).prefix("z: ")).changed();
731    });
732    if changed {
733        transform.rotation = glam::Quat::from_euler(
734            glam::EulerRot::YXZ,
735            yaw.to_radians(),
736            pitch.to_radians(),
737            roll.to_radians(),
738        );
739    }
740    edited |= changed;
741
742    ui.label("Scale");
743    ui.horizontal(|ui| {
744        edited |= ui.add(egui::DragValue::new(&mut transform.scale.x).speed(0.05).prefix("x: ")).changed();
745        edited |= ui.add(egui::DragValue::new(&mut transform.scale.y).speed(0.05).prefix("y: ")).changed();
746        edited |= ui.add(egui::DragValue::new(&mut transform.scale.z).speed(0.05).prefix("z: ")).changed();
747    });
748
749    if edited {
750        requests.edited = true;
751    }
752}
753
754/// The selected entity's camera lens, if it has one: a checkbox to attach/detach a
755/// [`CameraComponent`] (mirrors `draw_renderable_editor`'s Remove button), then FOV/near/far
756/// fields and an "Active Camera" checkbox once attached. No GPU-load `UiRequests` indirection is
757/// needed here like `draw_renderable_editor`'s attach flow — `requests` is only used to flag
758/// `edited`.
759fn draw_camera_editor(ui: &mut egui::Ui, world: &mut World, entity: Entity, requests: &mut UiRequests) {
760    let mut enabled = world.cameras.get(entity).is_some();
761    if ui.checkbox(&mut enabled, "Camera").changed() {
762        if enabled {
763            world.set_camera(entity, CameraComponent::default());
764        } else {
765            world.clear_camera(entity);
766        }
767        requests.edited = true;
768    }
769    if !enabled {
770        return;
771    }
772
773    let mut activate: Option<bool> = None;
774    if let Some(component) = world.cameras.get_mut(entity) {
775        let mut edited = false;
776        ui.horizontal(|ui| {
777            edited |= ui.add(egui::DragValue::new(&mut component.fov).speed(0.5).prefix("FOV: ").range(1.0..=179.0)).changed();
778        });
779        ui.horizontal(|ui| {
780            edited |= ui.add(egui::DragValue::new(&mut component.znear).speed(0.01).prefix("Near: ")).changed();
781            edited |= ui.add(egui::DragValue::new(&mut component.zfar).speed(0.5).prefix("Far: ")).changed();
782        });
783
784        let mut active = component.active;
785        if ui.checkbox(&mut active, "Active Camera (used in Play)").changed() {
786            activate = Some(active);
787            edited = true;
788        }
789        if edited {
790            requests.edited = true;
791        }
792    }
793
794    // Radio-button semantics: clear every other camera's flag when this one turns on, so
795    // World::active_camera never has to arbitrate between two "active" entities in practice.
796    match activate {
797        Some(true) => {
798            for (other, component) in world.cameras.iter_mut() {
799                component.active = other == entity;
800            }
801        }
802        Some(false) => {
803            if let Some(component) = world.cameras.get_mut(entity) {
804                component.active = false;
805            }
806        }
807        None => {}
808    }
809}
810
811fn draw_renderable_editor(
812    ui: &mut egui::Ui,
813    world: &mut World,
814    entity: Entity,
815    project: Option<&Project>,
816    entity_assets: &mut HashMap<Entity, RenderableAsset>,
817    requests: &mut UiRequests,
818) {
819    let kind = world.renderables.get(entity).map(|renderable| match renderable {
820        Renderable::Sprite(_) => "Sprite",
821        Renderable::Model(_) => "Model",
822    });
823    ui.label(format!("Renderable: {}", kind.unwrap_or("<none>")));
824
825    if let Some(asset) = entity_assets.get(&entity) {
826        let path = match asset {
827            RenderableAsset::Sprite { texture_path, .. } => texture_path,
828            RenderableAsset::Model { model_path } => model_path,
829        };
830        ui.label(format!("Asset: {}", path.display()));
831        if ui.button("Remove").clicked() {
832            world.clear_renderable(entity);
833            entity_assets.remove(&entity);
834            requests.edited = true;
835            return;
836        }
837        ui.label("Change to:");
838    }
839
840    let Some(project) = project else {
841        ui.label("Open or create a project to attach a renderable.");
842        return;
843    };
844
845    ui.collapsing("Attach Sprite", |ui| {
846        for path in project.list_assets(RenderableKind::Sprite) {
847            if ui.button(path.display().to_string()).clicked() {
848                requests.attach_renderable = Some((entity, RenderableKind::Sprite, path));
849            }
850        }
851    });
852    ui.collapsing("Attach Model", |ui| {
853        for path in project.list_assets(RenderableKind::Model) {
854            if ui.button(path.display().to_string()).clicked() {
855                requests.attach_renderable = Some((entity, RenderableKind::Model, path));
856            }
857        }
858    });
859}
860
861/// The selected entity's stacked `.rhai` scripts: a reorderable/removable list of what's
862/// attached (execution order top-to-bottom), plus a picker to attach more from the project's
863/// `scripts/` folder. Unlike [`draw_renderable_editor`], attaching/detaching/reordering needs no
864/// GPU load step, so this mutates `world.scripts` directly rather than going through
865/// [`UiRequests`] (beyond flagging `edited`).
866fn draw_script_editor(ui: &mut egui::Ui, world: &mut World, entity: Entity, project: Option<&Project>, requests: &mut UiRequests) {
867    ui.label("Scripts");
868
869    let mut move_up: Option<usize> = None;
870    let mut move_down: Option<usize> = None;
871    let mut remove: Option<usize> = None;
872
873    if let Some(list) = world.scripts.get_mut(entity) {
874        let last = list.0.len().saturating_sub(1);
875        for (index, attachment) in list.0.iter_mut().enumerate() {
876            ui.horizontal(|ui| {
877                if ui.checkbox(&mut attachment.enabled, "").changed() {
878                    requests.edited = true;
879                }
880                ui.label(asset_file_name(&attachment.path));
881                // Plain text rather than up/down arrow glyphs — egui's default font doesn't
882                // cover those and renders a tofu box instead.
883                ui.add_enabled_ui(index > 0, |ui| {
884                    if ui.small_button("Up").clicked() {
885                        move_up = Some(index);
886                    }
887                });
888                ui.add_enabled_ui(index < last, |ui| {
889                    if ui.small_button("Down").clicked() {
890                        move_down = Some(index);
891                    }
892                });
893                if ui.small_button("Remove").clicked() {
894                    remove = Some(index);
895                }
896            });
897        }
898    }
899
900    if let Some(list) = world.scripts.get_mut(entity) {
901        if let Some(index) = move_up.filter(|&i| i > 0) {
902            list.0.swap(index, index - 1);
903            requests.edited = true;
904        }
905        if let Some(index) = move_down.filter(|&i| i + 1 < list.0.len()) {
906            list.0.swap(index, index + 1);
907            requests.edited = true;
908        }
909        if let Some(index) = remove {
910            list.0.remove(index);
911            requests.edited = true;
912        }
913    }
914
915    let Some(project) = project else {
916        ui.label("Open or create a project to attach a script.");
917        return;
918    };
919
920    ui.collapsing("Attach Script", |ui| {
921        let scripts = project.list_scripts();
922        if scripts.is_empty() {
923            ui.label("No .rhai files in this project's scripts/ folder.");
924        }
925        for path in scripts {
926            if ui.button(path.display().to_string()).clicked() {
927                let attachment = ScriptAttachment { path, enabled: true };
928                match world.scripts.get_mut(entity) {
929                    Some(list) => list.0.push(attachment),
930                    None => world.scripts.insert(entity, ScriptList(vec![attachment])),
931                }
932                requests.edited = true;
933            }
934        }
935    });
936}
937
938/// A small transient toast reporting the outcome of the last Export (see `EditorScene`'s
939/// `export_status`), success or failure — mirrors [`draw_script_error_overlay`]'s shape, just
940/// for one message instead of a list.
941fn draw_export_status_overlay(ui: &mut egui::Ui, export_status: Option<&str>) {
942    let Some(message) = export_status else { return };
943
944    egui::Area::new(egui::Id::new("export_status_overlay"))
945        .anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-8.0, -8.0))
946        .show(ui.ctx(), |ui| {
947            egui::Frame::popup(ui.style()).show(ui, |ui| {
948                ui.set_max_width(420.0);
949                ui.label(message);
950            });
951        });
952}
953
954/// A small transient toast listing recent script compile/runtime errors (see
955/// `EditorScene`'s `script_errors`), so a broken script fails loudly without a full log panel.
956fn draw_script_error_overlay(ui: &mut egui::Ui, script_errors: &[String]) {
957    if script_errors.is_empty() {
958        return;
959    }
960
961    egui::Area::new(egui::Id::new("script_error_overlay"))
962        .anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0))
963        .show(ui.ctx(), |ui| {
964            egui::Frame::popup(ui.style()).show(ui, |ui| {
965                ui.set_max_width(420.0);
966                for message in script_errors {
967                    ui.colored_label(egui::Color32::from_rgb(220, 90, 90), message);
968                }
969            });
970        });
971}