From c94e2c895635fb4606a487876fac3bb7c33390f8 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 12 Oct 2026 20:36:24 +0000 Subject: [PATCH 1/5] Fix explorer dragging, inline rename and context-menu actions --- Engine/README.md | 2 + Engine/apps/editor/src/main.rs | 1187 +++++++++++++++---- Engine/crates/engine-editor/src/document.rs | 148 +++ Engine/docs/EXPLORER.md | 44 + 4 files changed, 1172 insertions(+), 209 deletions(-) create mode 100644 Engine/docs/EXPLORER.md diff --git a/Engine/README.md b/Engine/README.md index 62b87eb8..7e54af9a 100644 --- a/Engine/README.md +++ b/Engine/README.md @@ -81,4 +81,6 @@ After a successful run, download the **RusticGameEngine-Windows-Installer** arti from the run page and extract the ZIP to obtain the setup `.exe`. Artifacts require GitHub access to this repository and are retained for 30 days. +Explorer rename, clipboard, drag and script-scope controls are described in [`docs/EXPLORER.md`](docs/EXPLORER.md). + Gameplay authors should start with [`docs/GAMEPLAY_PROGRAMMING.md`](docs/GAMEPLAY_PROGRAMMING.md). diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index 0f603001..f4c90fc1 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -1334,8 +1334,15 @@ impl eframe::App for EditorApp { viewer.flush_play_input(); let request_preview = viewer.request_preview; drop(viewer); - if let Some(ProjectFileAction::Attach(path, scope)) = project_file_action { - self.attach_project_script(&path, scope); + match project_file_action { + Some(ProjectFileAction::Attach(path, scope)) => { + self.attach_project_script(&path, scope); + } + Some(ProjectFileAction::Refresh) => { + self.project_scan = Some(spawn_project_scan(project_root)); + self.last_project_scan = Instant::now(); + } + _ => {} } if request_preview { self.refresh_preview(); @@ -2099,6 +2106,10 @@ impl EditorViewer<'_> { } } + #[allow( + clippy::too_many_lines, + reason = "scene tree toolbar and recursive hierarchy share the current selection" + )] fn scene_tree(&mut self, ui: &mut egui::Ui) { let snapshots = self.document.entity_snapshots(); let entity_count = snapshots.as_ref().map_or(0, Vec::len); @@ -2174,19 +2185,46 @@ impl EditorViewer<'_> { .auto_shrink([false, false]) .show(ui, |ui| match snapshots { Ok(snapshots) => { - let (root, dropped) = ui - .dnd_drop_zone::(egui::Frame::NONE, |ui| { - render_scene_hierarchy(ui, &snapshots, None, self.document.selected()) + let (root, dropped) = + ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { + render_scene_hierarchy( + ui, + &snapshots, + None, + self.document.selected(), + !self.document.is_read_only() && !self.playing, + self.gizmo.space, + ) }); + root.response.context_menu(|ui| { + let enabled = !self.playing + && !self.document.is_read_only() + && ui.ctx().data_mut(|data| { + data.get_temp::(egui::Id::new("scene-clipboard")) + .is_some() + }); + if ui + .add_enabled(enabled, egui::Button::new("Paste at Scene Root")) + .clicked() + { + self.apply_scene_tree_action(ui, SceneTreeAction::Paste(None)); + ui.close(); + } + }); if let Some(id) = root.inner.clicked { self.document.select(Some(id)); self.request_preview = true; } + if let Some(action) = root.inner.action { + self.apply_scene_tree_action(ui, action); + } let reparent = root .inner .reparent .or_else(|| dropped.as_deref().map(|payload| (payload.entity, None))); if let Some((entity, parent)) = reparent + && !self.playing + && !self.document.is_read_only() && let Err(error) = self.document.reparent(entity, parent) { self.console.push(simple_console( @@ -2202,6 +2240,64 @@ impl EditorViewer<'_> { }); } + fn apply_scene_tree_action(&mut self, ui: &egui::Ui, action: SceneTreeAction) { + if let SceneTreeAction::Space(entity, space) = action { + self.document.select(Some(entity)); + self.gizmo.space = space; + self.request_preview = true; + return; + } + if self.playing || self.document.is_read_only() { + return; + } + let clipboard_id = egui::Id::new("scene-clipboard"); + let result = match action { + SceneTreeAction::Rename(entity, name) => self.document.set_name(entity, name), + SceneTreeAction::Delete(entity) => self.document.delete_tree(entity), + action @ (SceneTreeAction::Copy(_) | SceneTreeAction::Cut(_)) => { + let cut = matches!(action, SceneTreeAction::Cut(_)); + let (SceneTreeAction::Copy(entity) | SceneTreeAction::Cut(entity)) = action else { + unreachable!() + }; + self.document.snapshot_tree(entity).map(|snapshots| { + ui.ctx().data_mut(|data| { + data.insert_temp(clipboard_id, SceneClipboard { snapshots, cut }) + }); + }) + } + SceneTreeAction::Paste(parent) => { + if let Some(clipboard) = ui + .ctx() + .data_mut(|data| data.get_temp::(clipboard_id)) + { + if clipboard.cut { + let result = self.document.reparent(clipboard.snapshots[0].id, parent); + if result.is_ok() { + ui.ctx() + .data_mut(|data| data.remove::(clipboard_id)); + } + result + } else { + self.document + .paste_tree(&clipboard.snapshots, parent) + .map(|_| ()) + } + } else { + Ok(()) + } + } + SceneTreeAction::Space(_, _) => Ok(()), + }; + match result { + Ok(()) => self.request_preview = true, + Err(error) => self.console.push(simple_console( + Severity::Error, + "explorer", + error.to_string(), + )), + } + } + fn file_explorer(&mut self, ui: &mut egui::Ui) { ui.horizontal(|ui| { ui.label(egui::RichText::new("Filter").color(ui_theme::MUTED)); @@ -2244,11 +2340,26 @@ impl EditorViewer<'_> { ui, tree, &filter, - !self.document.is_read_only(), + !self.document.is_read_only() && !self.playing, self.document.selected().is_some(), ) }); action = root.inner; + root.response.context_menu(|ui| { + let enabled = !self.playing + && !self.document.is_read_only() + && ui.ctx().data_mut(|data| { + data.get_temp::(project_clipboard_id()) + .is_some() + }); + if ui + .add_enabled(enabled, egui::Button::new("Paste at Project Root")) + .clicked() + { + action = Some(ProjectFileAction::Paste(PathBuf::new())); + ui.close(); + } + }); if action.is_none() { action = dropped.as_deref().map(|payload| match payload { ViewportDrop::ProjectFile(path) @@ -2272,27 +2383,79 @@ impl EditorViewer<'_> { ui.weak("Scanning project files…"); }); } + self.apply_project_file_action(ui, action); + } + + fn apply_project_file_action(&mut self, ui: &egui::Ui, action: Option) { + let can_edit = !self.document.is_read_only() && !self.playing; match action { Some(ProjectFileAction::Open(path)) => self.open_project_script(&path), - Some(action @ ProjectFileAction::Attach(_, _)) => { + Some(action @ ProjectFileAction::Attach(_, _)) if can_edit => { *self.project_file_action = Some(action); } - Some(ProjectFileAction::Move { - source, - destination, - }) => { - if let Err(error) = move_project_entry(self.project_root, &source, &destination) { + Some(entry_action @ (ProjectFileAction::Copy(_) | ProjectFileAction::Cut(_))) + if can_edit => + { + let cut = matches!(entry_action, ProjectFileAction::Cut(_)); + let (ProjectFileAction::Copy(source) | ProjectFileAction::Cut(source)) = + entry_action + else { + unreachable!() + }; + ui.ctx().data_mut(|data| { + data.insert_temp(project_clipboard_id(), ProjectClipboard { source, cut }) + }); + } + Some(action) if can_edit => { + let result = match action { + ProjectFileAction::Move { + source, + destination, + } => move_project_entry(self.project_root, &source, &destination), + ProjectFileAction::Rename(source, name) => { + rename_project_entry(self.project_root, &source, &name) + } + ProjectFileAction::Delete(source) => { + delete_project_entry(self.project_root, &source) + } + ProjectFileAction::Paste(destination) => { + let clipboard = ui.ctx().data_mut(|data| { + data.get_temp::(project_clipboard_id()) + }); + if let Some(clipboard) = clipboard { + let result = if clipboard.cut { + move_project_entry( + self.project_root, + &clipboard.source, + &destination, + ) + } else { + copy_project_entry( + self.project_root, + &clipboard.source, + &destination, + ) + }; + if result.is_ok() && clipboard.cut { + ui.ctx().data_mut(|data| { + data.remove::(project_clipboard_id()); + }); + } + result + } else { + Ok(()) + } + } + _ => Ok(()), + }; + if let Err(error) = result { self.console .push(simple_console(Severity::Error, "explorer", error)); } else { - self.console.push(simple_console( - Severity::Info, - "explorer", - "Moved project entry", - )); + *self.project_file_action = Some(ProjectFileAction::Refresh); } } - None => {} + _ => {} } } @@ -3560,7 +3723,9 @@ fn load_project_model_assets(root: &Path) -> BTreeMap { continue; } if file_type.is_dir() { - if !is_hidden_project_directory(&entry.file_name()) { + if !is_hidden_project_directory(&entry.file_name()) + && path != root.join(".rustic/trash") + { visit(root, &path, output); } continue; @@ -3810,93 +3975,184 @@ struct SceneTreeDrag { entity: engine_core::EntityId, } +#[derive(Debug)] +enum SceneTreeAction { + Rename(EntityId, String), + Copy(EntityId), + Cut(EntityId), + Paste(Option), + Delete(EntityId), + Space(EntityId, GizmoSpace), +} + +#[derive(Clone)] +struct SceneClipboard { + snapshots: Vec, + cut: bool, +} + #[derive(Default)] struct SceneTreeResponse { - clicked: Option, - reparent: Option<(engine_core::EntityId, Option)>, + clicked: Option, + reparent: Option<(EntityId, Option)>, + action: Option, } fn render_scene_hierarchy( ui: &mut egui::Ui, snapshots: &[engine_world::EntitySnapshot], - parent: Option, - selected: Option, + parent: Option, + selected: Option, + can_edit: bool, + space: GizmoSpace, ) -> SceneTreeResponse { let mut result = SceneTreeResponse::default(); for snapshot in snapshots.iter().filter(|item| item.parent == parent) { let label = snapshot .name - .as_deref() - .map_or_else(|| snapshot.id.to_string(), str::to_owned); + .clone() + .unwrap_or_else(|| snapshot.id.to_string()); let has_children = snapshots .iter() .any(|item| item.parent == Some(snapshot.id)); - if snapshot.folder || has_children { - let title = if snapshot.folder { - format!("πŸ“ {label}") + let (drop, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { + if snapshot.folder || has_children { + egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + ui.make_persistent_id(("scene-entity", snapshot.id)), + snapshot.folder, + ) + .show_header(ui, |ui| { + if snapshot.folder { + ui.label("πŸ“"); + } + scene_entry_row( + ui, + snapshot.id, + &label, + selected, + can_edit, + space, + &mut result, + ); + }) + .body(|ui| { + let child = render_scene_hierarchy( + ui, + snapshots, + Some(snapshot.id), + selected, + can_edit, + space, + ); + if child.clicked.is_some() { + result.clicked = child.clicked; + } + if child.reparent.is_some() { + result.reparent = child.reparent; + } + if child.action.is_some() { + result.action = child.action; + } + }); } else { - label - }; - let (drop, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { - let response = - egui::CollapsingHeader::new(egui::RichText::new(title).color(ui_theme::TEXT)) - .id_salt(("scene-entity", snapshot.id)) - .default_open(snapshot.folder) - .show(ui, |ui| { - render_scene_hierarchy(ui, snapshots, Some(snapshot.id), selected) - }); - response - .header_response - .interact(egui::Sense::click_and_drag()) - .dnd_set_drag_payload(SceneTreeDrag { - entity: snapshot.id, - }); - response - }); - let response = drop.inner; - if response.header_response.clicked() { - result.clicked = Some(snapshot.id); - } - if let Some(child) = response.body_returned { - if child.clicked.is_some() { - result.clicked = child.clicked; - } - if child.reparent.is_some() { - result.reparent = child.reparent; - } - } - if selected == Some(snapshot.id) { - response.header_response.highlight(); + scene_entry_row( + ui, + snapshot.id, + &label, + selected, + can_edit, + space, + &mut result, + ); } - if let Some(payload) = dropped.as_deref() - && payload.entity != snapshot.id - { - result.reparent = Some((payload.entity, Some(snapshot.id))); + }); + let _ = drop; + if can_edit + && let Some(payload) = dropped.as_deref() + && payload.entity != snapshot.id + { + result.reparent = Some((payload.entity, Some(snapshot.id))); + } + } + result +} + +fn scene_entry_row( + ui: &mut egui::Ui, + entity: EntityId, + label: &str, + selected: Option, + can_edit: bool, + space: GizmoSpace, + result: &mut SceneTreeResponse, +) { + let rename_id = ui.make_persistent_id(("scene-rename", entity)); + let editing = ui.ctx().data_mut(|data| data.get_temp::(rename_id)); + if let Some(mut text) = editing { + let response = ui.add(egui::TextEdit::singleline(&mut text).id(rename_id)); + let escape = ui.input(|input| input.key_pressed(egui::Key::Escape)); + if escape || response.lost_focus() || ui.input(|input| input.key_pressed(egui::Key::Enter)) + { + ui.ctx().data_mut(|data| data.remove::(rename_id)); + if can_edit && !escape && !text.trim().is_empty() && text != label { + result.action = Some(SceneTreeAction::Rename(entity, text)); } } else { - let (drop, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { - let response = ui.selectable_label( - selected == Some(snapshot.id), - egui::RichText::new(label).color(ui_theme::TEXT), - ); - response - .interact(egui::Sense::click_and_drag()) - .dnd_set_drag_payload(SceneTreeDrag { - entity: snapshot.id, - }); - response - }); - if drop.inner.clicked() { - result.clicked = Some(snapshot.id); + ui.ctx().data_mut(|data| data.insert_temp(rename_id, text)); + } + return; + } + let response = ui + .selectable_label(selected == Some(entity), label) + .interact(egui::Sense::click_and_drag()); + if can_edit && response.drag_started_by(egui::PointerButton::Primary) { + response.dnd_set_drag_payload(SceneTreeDrag { entity }); + } + if response.clicked() { + result.clicked = Some(entity); + if can_edit { + ui.ctx() + .data_mut(|data| data.insert_temp(rename_id, label.to_owned())); + ui.memory_mut(|memory| memory.request_focus(rename_id)); + } + } + if response.secondary_clicked() { + result.clicked = Some(entity); + } + response.context_menu(|ui| { + for (label, action) in [ + ("Copy", SceneTreeAction::Copy(entity)), + ("Cut", SceneTreeAction::Cut(entity)), + ("Delete", SceneTreeAction::Delete(entity)), + ] { + if ui.add_enabled(can_edit, egui::Button::new(label)).clicked() { + result.action = Some(action); + ui.close(); } - if let Some(payload) = dropped.as_deref() - && payload.entity != snapshot.id - { - result.reparent = Some((payload.entity, Some(snapshot.id))); + } + let has_clipboard = ui.ctx().data_mut(|data| { + data.get_temp::(egui::Id::new("scene-clipboard")) + .is_some() + }); + if ui + .add_enabled(can_edit && has_clipboard, egui::Button::new("Paste")) + .clicked() + { + result.action = Some(SceneTreeAction::Paste(Some(entity))); + ui.close(); + } + ui.separator(); + for (label, value) in [ + ("Use World Space", GizmoSpace::World), + ("Use Local Space", GizmoSpace::Local), + ] { + if ui.selectable_label(space == value, label).clicked() { + result.action = Some(SceneTreeAction::Space(entity, value)); + ui.close(); } } - } - result + }); } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] @@ -3914,8 +4170,14 @@ struct ProjectFile { #[derive(Debug)] enum ProjectFileAction { + Refresh, Open(PathBuf), Attach(PathBuf, ScriptScope), + Rename(PathBuf, String), + Copy(PathBuf), + Cut(PathBuf), + Paste(PathBuf), + Delete(PathBuf), Move { source: PathBuf, destination: PathBuf, @@ -3969,58 +4231,58 @@ fn render_project_directory( let mut action = None; let id = ui.make_persistent_id(("project-directory", &directory.relative_path)); let (drop, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { - ui.dnd_drag_source( - egui::Id::new(("project-directory-drag", &directory.relative_path)), - ViewportDrop::ProjectDirectory(directory.relative_path.clone()), - |ui| { - let header = egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - id, - default_open && !is_empty, - ) - .show_header(ui, |ui| { - ui.add( - egui::Image::new(egui::include_image!( - "../../../assets/icons/material-design/folder.svg" - )) - .fit_to_exact_size(egui::vec2(18.0, 18.0)) - .tint(text_color), - ); - ui.label(egui::RichText::new(&directory.name).color(text_color)); - if is_empty { - ui.label(egui::RichText::new("empty").small().color(EMPTY_TEXT)); - } - }); - header.body(|ui| { - if !is_empty { - for child in &directory.directories { - if project_directory_matches(child, filter) - && let Some(file_action) = render_project_directory( - ui, - child, - filter, - !filter.is_empty(), - can_edit, - has_selected_object, - ) - { - action = Some(file_action); - } - } - for file in &directory.files { - if project_file_matches(file, filter) - && let Some(file_action) = - render_project_file(ui, file, can_edit, has_selected_object) - { - action = Some(file_action); - } - } - } - }) - }, + let header = egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + id, + default_open && !is_empty, ) + .show_header(ui, |ui| { + ui.add( + egui::Image::new(egui::include_image!( + "../../../assets/icons/material-design/folder.svg" + )) + .fit_to_exact_size(egui::vec2(18.0, 18.0)) + .tint(text_color), + ); + action = project_entry_row( + ui, + &directory.name, + &directory.relative_path, + text_color, + true, + can_edit, + has_selected_object, + ); + if is_empty { + ui.label(egui::RichText::new("empty").small().color(EMPTY_TEXT)); + } + }); + header.body(|ui| { + for child in &directory.directories { + if project_directory_matches(child, filter) + && let Some(file_action) = render_project_directory( + ui, + child, + filter, + !filter.is_empty(), + can_edit, + has_selected_object, + ) + { + action = Some(file_action); + } + } + for file in &directory.files { + if project_file_matches(file, filter) + && let Some(file_action) = + render_project_file(ui, file, can_edit, has_selected_object) + { + action = Some(file_action); + } + } + }) }); - let (_, header_response, _) = drop.inner.inner; + let (_, header_response, _) = drop.inner; if let Some(payload) = dropped.as_deref() { let source = match payload { ViewportDrop::ProjectFile(path) | ViewportDrop::ProjectDirectory(path) => Some(path), @@ -4050,51 +4312,158 @@ fn render_project_file( ) -> Option { const FILE_TEXT: egui::Color32 = egui::Color32::from_rgb(190, 199, 211); let icon = project_file_icon(&file.relative_path); - let response = ui.dnd_drag_source( - egui::Id::new(("project-file", &file.relative_path)), - ViewportDrop::ProjectFile(file.relative_path.clone()), - |ui| { - ui.horizontal(|ui| { - ui.add( - egui::Image::new(icon) - .fit_to_exact_size(egui::vec2(18.0, 18.0)) - .tint(FILE_TEXT), - ); - ui.label(egui::RichText::new(&file.name).color(FILE_TEXT)) - .on_hover_text(file.relative_path.display().to_string()); - }); - }, + ui.horizontal(|ui| { + ui.add( + egui::Image::new(icon) + .fit_to_exact_size(egui::vec2(18.0, 18.0)) + .tint(FILE_TEXT), + ); + project_entry_row( + ui, + &file.name, + &file.relative_path, + FILE_TEXT, + false, + can_edit, + has_selected_object, + ) + }) + .inner +} + +#[derive(Clone)] +struct ProjectClipboard { + source: PathBuf, + cut: bool, +} + +fn project_clipboard_id() -> egui::Id { + egui::Id::new("project-entry-clipboard") +} + +// Only the name owns drag interaction; an expanded folder never owns its children. +fn project_entry_row( + ui: &mut egui::Ui, + name: &str, + path: &Path, + color: egui::Color32, + directory: bool, + can_edit: bool, + has_selected_object: bool, +) -> Option { + let rename_id = ui.make_persistent_id(("project-rename", path)); + let editing = ui.ctx().data_mut(|data| data.get_temp::(rename_id)); + if let Some(mut text) = editing { + let response = ui.add(egui::TextEdit::singleline(&mut text).id(rename_id)); + let escape = ui.input(|input| input.key_pressed(egui::Key::Escape)); + let enter = ui.input(|input| input.key_pressed(egui::Key::Enter)); + if escape || enter || response.lost_focus() { + ui.ctx().data_mut(|data| data.remove::(rename_id)); + return (can_edit && !escape && text != name) + .then(|| ProjectFileAction::Rename(path.to_path_buf(), text)); + } + ui.ctx().data_mut(|data| data.insert_temp(rename_id, text)); + return None; + } + let response = ui.add( + egui::Label::new(egui::RichText::new(name).color(color)) + .sense(egui::Sense::click_and_drag()), ); - let mut action = response - .response - .double_clicked() - .then(|| ProjectFileAction::Open(file.relative_path.clone())); - if ScriptLanguage::from_path(&file.relative_path).is_some() { - response.response.context_menu(|ui| { - ui.label(egui::RichText::new("Use Script As").strong()); - for (label, scope, enabled) in [ - ("Global Startup Script", ScriptScope::Global, can_edit), - ("Scene Startup Script", ScriptScope::Scene, can_edit), - ( - "Object Script", - ScriptScope::Component, - can_edit && has_selected_object, - ), + if can_edit && response.drag_started_by(egui::PointerButton::Primary) { + response.dnd_set_drag_payload(if directory { + ViewportDrop::ProjectDirectory(path.to_path_buf()) + } else { + ViewportDrop::ProjectFile(path.to_path_buf()) + }); + } + let pending_id = rename_id.with("pending-click"); + let now = ui.input(|input| input.time); + let pending = ui.ctx().data_mut(|data| data.get_temp::(pending_id)); + let mut action = None; + if response.double_clicked() && !directory { + ui.ctx().data_mut(|data| data.remove::(pending_id)); + action = Some(ProjectFileAction::Open(path.to_path_buf())); + } else if response.clicked() && can_edit { + ui.ctx().data_mut(|data| data.insert_temp(pending_id, now)); + ui.ctx().request_repaint_after(Duration::from_millis(310)); + } else if can_edit + && pending.is_some_and(|time| now - time > 0.3) + && !ui.input(|input| input.pointer.any_down()) + { + ui.ctx().data_mut(|data| { + data.remove::(pending_id); + data.insert_temp(rename_id, name.to_owned()); + }); + ui.memory_mut(|memory| memory.request_focus(rename_id)); + ui.ctx().request_repaint(); + } + if response.secondary_clicked() || response.drag_started() { + ui.ctx().data_mut(|data| data.remove::(pending_id)); + } + action = + project_entry_menu(&response, path, directory, can_edit, has_selected_object).or(action); + response.on_hover_text(path.display().to_string()); + action +} + +fn project_entry_menu( + response: &egui::Response, + path: &Path, + directory: bool, + can_edit: bool, + has_selected_object: bool, +) -> Option { + let mut action = None; + response.context_menu(|ui| { + for (label, entry_action) in [ + ("Copy", ProjectFileAction::Copy(path.to_path_buf())), + ("Cut", ProjectFileAction::Cut(path.to_path_buf())), + ("Delete", ProjectFileAction::Delete(path.to_path_buf())), + ] { + if ui.add_enabled(can_edit, egui::Button::new(label)).clicked() { + action = Some(entry_action); + ui.close(); + } + } + let has_clipboard = ui.ctx().data_mut(|data| { + data.get_temp::(project_clipboard_id()) + .is_some() + }); + if ui + .add_enabled(can_edit && has_clipboard, egui::Button::new("Paste")) + .clicked() + { + action = Some(ProjectFileAction::Paste(if directory { + path.to_path_buf() + } else { + path.parent().unwrap_or(Path::new("")).to_path_buf() + })); + ui.close(); + } + if !directory && ScriptLanguage::from_path(path).is_some() { + ui.separator(); + ui.label(egui::RichText::new("Set As").strong()); + for (label, scope) in [ + ("Global Startup Script", ScriptScope::Global), + ("Scene Startup Script", ScriptScope::Scene), + ("Object Component Script", ScriptScope::Component), ] { - if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { - action = Some(ProjectFileAction::Attach(file.relative_path.clone(), scope)); + if ui + .add_enabled( + can_edit && (scope != ScriptScope::Component || has_selected_object), + egui::Button::new(label), + ) + .clicked() + { + action = Some(ProjectFileAction::Attach(path.to_path_buf(), scope)); ui.close(); } } if !has_selected_object { - ui.label( - egui::RichText::new("Select an object to attach an object script") - .small() - .color(ui_theme::MUTED), - ); + ui.weak("Select an object to attach an object component script"); } - }); - } + } + }); action } @@ -4129,49 +4498,185 @@ fn project_directory_matches(directory: &ProjectDirectory, filter: &str) -> bool .any(|child| project_directory_matches(child, filter)) } -fn move_project_entry( - project_root: &Path, - source: &Path, - destination_directory: &Path, -) -> Result<(), String> { - let is_safe_relative = |path: &Path| { - !path.is_absolute() - && path.components().all(|part| { - matches!( - part, - std::path::Component::Normal(_) | std::path::Component::CurDir - ) - }) - }; - if source.as_os_str().is_empty() - || !is_safe_relative(source) - || !is_safe_relative(destination_directory) +fn move_project_entry(root: &Path, source: &Path, destination: &Path) -> Result<(), String> { + let source = checked_project_path(root, source, false)?; + let directory = checked_project_path(root, destination, true)?; + if !directory.is_dir() { + return Err("Move destination must be a folder".into()); + } + if source.is_dir() && directory.starts_with(&source) { + return Err("A folder cannot be moved inside itself".into()); + } + let target = directory.join(source.file_name().ok_or("Entry has no name")?); + if target == source { + return Ok(()); + } + relocate_project_entry(&source, &target) +} + +// Resolve existing entries and reject symlinks, including symlinked ancestors. +fn checked_project_path(root: &Path, relative: &Path, allow_root: bool) -> Result { + if (!allow_root && relative.as_os_str().is_empty()) + || relative.is_absolute() + || relative + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) { - return Err("The dragged project path is not safe to move".to_owned()); + return Err("The project path is not safe to edit".into()); } - let source_path = project_root.join(source); - let destination_directory_path = project_root.join(destination_directory); - if !source_path.exists() || !destination_directory_path.is_dir() { - return Err("The source or destination no longer exists".to_owned()); + let root = root.canonicalize().map_err(|error| error.to_string())?; + let mut path = root.clone(); + for part in relative.components() { + path.push(part); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() { + return Err("Explorer operations do not follow symbolic links".into()); + } } - if source_path.is_dir() && destination_directory_path.starts_with(&source_path) { - return Err("A folder cannot be moved inside itself".to_owned()); + let resolved = path.canonicalize().map_err(|error| error.to_string())?; + if !resolved.starts_with(&root) { + return Err("The project path leaves the project".into()); } - let name = source - .file_name() - .ok_or_else(|| "The dragged item has no file name".to_owned())?; - let destination = destination_directory_path.join(name); - if destination == source_path { + Ok(resolved) +} + +fn rename_project_entry(root: &Path, source: &Path, name: &str) -> Result<(), String> { + if name.trim().is_empty() + || name != name.trim() + || name.contains(['/', '\\', ':']) + || name == "." + || name == ".." + || name.ends_with('.') + || name.contains(['<', '>', '"', '|', '?', '*']) + || name.chars().any(char::is_control) + { + return Err("Enter a single valid file or folder name".into()); + } + let source_path = checked_project_path(root, source, false)?; + let destination = source_path.with_file_name(name); + if source_path == destination { return Ok(()); } - if destination.exists() { - return Err(format!( - "{} already exists in the destination folder", - name.to_string_lossy() - )); + relocate_project_entry(&source_path, &destination) +} + +fn project_path_occupied(path: &Path) -> Result { + match std::fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.to_string()), } - std::fs::rename(&source_path, &destination) - .map_err(|error| format!("Could not move {}: {error}", source.to_string_lossy())) +} + +fn relocate_project_entry(source: &Path, destination: &Path) -> Result<(), String> { + if destination + .try_exists() + .map_err(|error| error.to_string())? + { + return Err("An entry with that name already exists".into()); + } + let metadata = source.is_file().then(|| sidecar_path(source)); + if let Some(metadata) = &metadata + && metadata.exists() + && sidecar_path(destination).exists() + { + return Err("Asset metadata already exists at the destination".into()); + } + std::fs::rename(source, destination).map_err(|error| error.to_string())?; + if let Some(metadata) = metadata + && metadata.exists() + && let Err(error) = std::fs::rename(&metadata, sidecar_path(destination)) + { + let _ = std::fs::rename(destination, source); + return Err(format!("Could not move asset metadata: {error}")); + } + Ok(()) +} + +fn delete_project_entry(root: &Path, source: &Path) -> Result<(), String> { + let source_path = checked_project_path(root, source, false)?; + // Keep deleted entries recoverable without showing them in the project tree. + if root.join(".rustic").exists() { + checked_project_path(root, Path::new(".rustic"), false)?; + } + if root.join(".rustic/trash").exists() { + checked_project_path(root, Path::new(".rustic/trash"), false)?; + } + let trash = root.join(".rustic").join("trash"); + std::fs::create_dir_all(&trash).map_err(|error| error.to_string())?; + let trash_relative = Path::new(".rustic/trash"); + let trash = checked_project_path(root, trash_relative, false)?; + if trash.starts_with(&source_path) { + return Err("Cannot delete the project trash directory".into()); + } + let entry = trash.join(AssetId::new().to_string()); + std::fs::create_dir(&entry).map_err(|error| error.to_string())?; + let destination = entry.join(source_path.file_name().ok_or("Entry has no name")?); + relocate_project_entry(&source_path, &destination) +} + +fn copy_tree(source: &Path, target: &Path) -> Result<(), String> { + let metadata = std::fs::symlink_metadata(source).map_err(|error| error.to_string())?; + if metadata.file_type().is_symlink() { + return Err("Cannot copy symbolic links".into()); + } + if metadata.is_dir() { + std::fs::create_dir(target).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())?; + // Copies acquire new asset identities on import/registration. + if entry + .path() + .extension() + .is_some_and(|extension| extension == "rmeta") + { + continue; + } + copy_tree(&entry.path(), &target.join(entry.file_name()))?; + } + } else if metadata.is_file() { + std::fs::copy(source, target).map_err(|error| error.to_string())?; + } else { + return Err("Only regular files and folders can be copied".into()); + } + Ok(()) +} +fn copy_project_entry(root: &Path, source: &Path, destination: &Path) -> Result<(), String> { + let source = checked_project_path(root, source, false)?; + let directory = checked_project_path(root, destination, true)?; + if !directory.is_dir() { + return Err("Paste destination must be a folder".into()); + } + if source.is_dir() && directory.starts_with(&source) { + return Err("A folder cannot be copied inside itself".into()); + } + let name = source.file_name().ok_or("Entry has no name")?; + let mut target = directory.join(name); + let mut index = 1; + while project_path_occupied(&target)? + || (source.is_file() && project_path_occupied(&sidecar_path(&target))?) + { + let stem = source.file_stem().unwrap_or(name).to_string_lossy(); + let extension = if source.is_file() { + source + .extension() + .map(|value| format!(".{}", value.to_string_lossy())) + .unwrap_or_default() + } else { + String::new() + }; + target = directory.join(format!("{stem} copy {index}{extension}")); + index += 1; + } + let result = copy_tree(&source, &target); + if result.is_err() { + if target.is_dir() { + let _ = std::fs::remove_dir_all(&target); + } else { + let _ = std::fs::remove_file(&target); + } + } + result } fn project_file_icon(path: &Path) -> egui::ImageSource<'static> { @@ -4275,7 +4780,9 @@ fn scan_project_directory(root: &Path, directory: &Path) -> ProjectDirectory { } let path = entry.path(); if file_type.is_dir() { - if !is_hidden_project_directory(&entry.file_name()) { + if !is_hidden_project_directory(&entry.file_name()) + && path != root.join(".rustic/trash") + { node.directories.push(scan_project_directory(root, &path)); } } else if file_type.is_file() @@ -4646,3 +5153,265 @@ fn edit_light(ui: &mut egui::Ui, light: &mut engine_world::Light) -> bool { ui.label("Shadow casting is not supported yet."); before != *light } + +#[cfg(test)] +mod explorer_tests { + use super::*; + + struct TestProject(PathBuf); + impl TestProject { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("rustic-explorer-{}", AssetId::new())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + } + impl Drop for TestProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn rename_move_copy_and_delete_preserve_files_and_metadata() { + let project = TestProject::new(); + let root = &project.0; + std::fs::write(root.join("script.lua"), "original").unwrap(); + std::fs::write(root.join("script.lua.rmeta"), "asset identity").unwrap(); + rename_project_entry(root, Path::new("script.lua"), "renamed.lua").unwrap(); + assert_eq!( + std::fs::read_to_string(root.join("renamed.lua.rmeta")).unwrap(), + "asset identity" + ); + copy_project_entry(root, Path::new("renamed.lua"), Path::new("")).unwrap(); + assert_eq!( + std::fs::read_to_string(root.join("renamed copy 1.lua")).unwrap(), + "original" + ); + assert!(!root.join("renamed copy 1.lua.rmeta").exists()); + std::fs::create_dir(root.join("scripts")).unwrap(); + move_project_entry(root, Path::new("renamed.lua"), Path::new("scripts")).unwrap(); + assert!(root.join("scripts/renamed.lua.rmeta").exists()); + delete_project_entry(root, Path::new("scripts/renamed.lua")).unwrap(); + assert!(!root.join("scripts/renamed.lua").exists()); + let trash = std::fs::read_dir(root.join(".rustic/trash")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!( + std::fs::read_to_string(trash.join("renamed.lua")).unwrap(), + "original" + ); + assert!(trash.join("renamed.lua.rmeta").exists()); + } + + #[test] + fn filesystem_operations_reject_collisions_traversal_and_self_moves() { + let project = TestProject::new(); + let root = &project.0; + std::fs::write(root.join("a.lua"), "a").unwrap(); + std::fs::write(root.join("b.lua"), "b").unwrap(); + assert!(rename_project_entry(root, Path::new("a.lua"), "b.lua").is_err()); + assert!(rename_project_entry(root, Path::new("a.lua"), "../escape.lua").is_err()); + assert!(delete_project_entry(root, Path::new("")).is_err()); + assert!(delete_project_entry(root, Path::new("../escape")).is_err()); + std::fs::create_dir_all(root.join("folder/child")).unwrap(); + assert!(move_project_entry(root, Path::new("folder"), Path::new("folder/child")).is_err()); + assert!(copy_project_entry(root, Path::new("folder"), Path::new("folder/child")).is_err()); + assert_eq!(std::fs::read_to_string(root.join("a.lua")).unwrap(), "a"); + assert_eq!(std::fs::read_to_string(root.join("b.lua")).unwrap(), "b"); + } + + #[cfg(unix)] + #[test] + fn filesystem_operations_reject_symlinks_and_clean_partial_copies() { + let project = TestProject::new(); + let outside = TestProject::new(); + let root = &project.0; + std::fs::write(outside.0.join("secret"), "untouched").unwrap(); + std::os::unix::fs::symlink(&outside.0, root.join("linked")).unwrap(); + assert!(delete_project_entry(root, Path::new("linked/secret")).is_err()); + assert!(copy_project_entry(root, Path::new("linked"), Path::new("")).is_err()); + std::fs::create_dir(root.join("folder")).unwrap(); + std::os::unix::fs::symlink(outside.0.join("secret"), root.join("folder/link")).unwrap(); + assert!(copy_project_entry(root, Path::new("folder"), Path::new("")).is_err()); + assert!(!root.join("folder copy 1").exists()); + assert_eq!( + std::fs::read_to_string(outside.0.join("secret")).unwrap(), + "untouched" + ); + } + + fn row_frame( + context: &egui::Context, + time: f64, + events: Vec, + ) -> (egui::FullOutput, egui::Rect, Option) { + let mut rect = egui::Rect::NOTHING; + let mut action = None; + let mut output = context.run_ui( + egui::RawInput { + time: Some(time), + events, + ..Default::default() + }, + |ui| { + action = project_entry_row( + ui, + "script.lua", + Path::new("script.lua"), + egui::Color32::WHITE, + false, + true, + true, + ); + rect = ui.min_rect(); + }, + ); + output.textures_delta.clear(); + (output, rect, action) + } + + fn pointer(position: egui::Pos2, button: egui::PointerButton, pressed: bool) -> egui::Event { + egui::Event::PointerButton { + pos: position, + button, + pressed, + modifiers: egui::Modifiers::NONE, + } + } + + #[test] + fn explorer_hover_does_not_drag_but_primary_press_and_move_does() { + let context = egui::Context::default(); + let (_, rect, _) = row_frame(&context, 0.0, vec![]); + let position = rect.center(); + let (output, _, _) = row_frame(&context, 0.1, vec![egui::Event::PointerMoved(position)]); + assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Move); + assert!(!egui::DragAndDrop::has_any_payload(&context)); + row_frame( + &context, + 0.2, + vec![pointer(position, egui::PointerButton::Primary, true)], + ); + row_frame( + &context, + 0.3, + vec![egui::Event::PointerMoved(position + egui::vec2(20.0, 0.0))], + ); + assert!(egui::DragAndDrop::has_payload_of_type::( + &context + )); + } + + #[test] + fn explorer_click_renames_and_double_click_still_opens() { + let context = egui::Context::default(); + let (_, rect, _) = row_frame(&context, 0.0, vec![]); + let position = rect.center(); + row_frame( + &context, + 0.1, + vec![ + egui::Event::PointerMoved(position), + pointer(position, egui::PointerButton::Primary, true), + ], + ); + row_frame( + &context, + 0.15, + vec![pointer(position, egui::PointerButton::Primary, false)], + ); + row_frame( + &context, + 0.2, + vec![pointer(position, egui::PointerButton::Primary, true)], + ); + let (_, _, action) = row_frame( + &context, + 0.25, + vec![pointer(position, egui::PointerButton::Primary, false)], + ); + assert!(matches!(action, Some(ProjectFileAction::Open(_)))); + row_frame( + &context, + 1.0, + vec![pointer(position, egui::PointerButton::Primary, true)], + ); + row_frame( + &context, + 1.05, + vec![pointer(position, egui::PointerButton::Primary, false)], + ); + row_frame(&context, 1.4, vec![]); + let mut editing = false; + let mut output = context.run_ui( + egui::RawInput { + time: Some(1.5), + ..Default::default() + }, + |ui| { + editing = ui.ctx().data_mut(|data| { + data.get_temp::( + ui.make_persistent_id(("project-rename", Path::new("script.lua"))), + ) + .is_some() + }); + }, + ); + output.textures_delta.clear(); + assert!(editing); + } + fn collect_text(shape: &egui::epaint::Shape, text: &mut String) { + match shape { + egui::epaint::Shape::Text(shape) => { + text.push_str(&shape.galley.job.text); + text.push('\n'); + } + egui::epaint::Shape::Vec(shapes) => { + for shape in shapes { + collect_text(shape, text); + } + } + _ => {} + } + } + #[test] + fn secondary_click_opens_file_actions_and_script_scopes() { + let context = egui::Context::default(); + let (_, rect, _) = row_frame(&context, 0.0, vec![]); + let position = rect.center(); + row_frame( + &context, + 0.1, + vec![ + egui::Event::PointerMoved(position), + pointer(position, egui::PointerButton::Secondary, true), + ], + ); + row_frame( + &context, + 0.2, + vec![pointer(position, egui::PointerButton::Secondary, false)], + ); + let (output, _, _) = row_frame(&context, 0.3, vec![]); + let mut text = String::new(); + for shape in output.shapes { + collect_text(&shape.shape, &mut text); + } + for expected in [ + "Copy", + "Cut", + "Delete", + "Paste", + "Global Startup Script", + "Scene Startup Script", + "Object Component Script", + ] { + assert!(text.contains(expected), "Missing {expected}: {text}"); + } + assert!(!egui::DragAndDrop::has_any_payload(&context)); + } +} diff --git a/Engine/crates/engine-editor/src/document.rs b/Engine/crates/engine-editor/src/document.rs index 75c3dc85..4ed0ec7d 100644 --- a/Engine/crates/engine-editor/src/document.rs +++ b/Engine/crates/engine-editor/src/document.rs @@ -486,6 +486,90 @@ impl AuthoringDocument { Ok(()) } + /// Captures an object and its descendants in parent-first order. + /// # Errors + /// Returns an error if the entity is absent. + pub fn snapshot_tree(&self, entity: EntityId) -> Result, AuthoringError> { + let snapshots = self.entity_snapshots()?; + let mut result = vec![self.world.snapshot(entity)?]; + let mut index = 0; + while index < result.len() { + let parent = result[index].id; + result.extend( + snapshots + .iter() + .filter(|snapshot| snapshot.parent == Some(parent)) + .cloned(), + ); + index += 1; + } + Ok(result) + } + + /// Deletes an object and its descendants as one undoable edit. + /// # Errors + /// Returns an error for read-only access or an absent entity. + pub fn delete_tree(&mut self, entity: EntityId) -> Result<(), AuthoringError> { + self.ensure_writable()?; + let snapshots = self.snapshot_tree(entity)?; + let edits = snapshots + .iter() + .rev() + .map(|snapshot| SceneEdit::capture_delete(&self.world, snapshot.id)) + .collect::, _>>()?; + self.undo.execute( + &mut self.world, + SceneEdit::Transaction { + label: "Delete object tree".into(), + edits, + }, + )?; + if self + .selected + .is_some_and(|selected| snapshots.iter().any(|snapshot| snapshot.id == selected)) + { + self.selected = None; + } + Ok(()) + } + + /// Pastes copied objects with fresh IDs and preserved internal parent links. + /// # Errors + /// Returns an error for read-only access or invalid hierarchy/component data. + pub fn paste_tree( + &mut self, + snapshots: &[EntitySnapshot], + parent: Option, + ) -> Result, AuthoringError> { + self.ensure_writable()?; + let ids: BTreeMap<_, _> = snapshots + .iter() + .map(|snapshot| (snapshot.id, EntityId::new())) + .collect(); + let edits = snapshots + .iter() + .map(|snapshot| { + let mut copy = snapshot.clone(); + copy.id = ids[&snapshot.id]; + copy.parent = snapshot + .parent + .and_then(|id| ids.get(&id).copied()) + .or(parent); + SceneEdit::Create { snapshot: copy } + }) + .collect(); + let root = snapshots.first().map(|snapshot| ids[&snapshot.id]); + self.undo.execute( + &mut self.world, + SceneEdit::Transaction { + label: "Paste object tree".into(), + edits, + }, + )?; + self.selected = root; + Ok(root) + } + /// Inserts a `.rscene` from the project's `scenes` folder as an additive instance. /// # Errors /// Returns an error for read-only state, an unsafe path, or invalid scene data. @@ -764,6 +848,70 @@ mod tests { use glam::Vec3; use tempfile::tempdir; + #[test] + fn explorer_tree_copy_delete_are_atomic_and_undoable() { + let temporary = tempdir().unwrap(); + let project = Project::create( + temporary.path().join("trees"), + "Trees", + ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project, false).unwrap(); + let folder = document.add_folder(None).unwrap(); + let child = document + .add_primitive("Child", Primitive::Cube { size: 1.0 }, Some(folder)) + .unwrap(); + let snapshots = document.snapshot_tree(folder).unwrap(); + assert_eq!(snapshots.len(), 2); + let copied = document.paste_tree(&snapshots, None).unwrap().unwrap(); + assert_ne!(copied, folder); + let copied_tree = document.snapshot_tree(copied).unwrap(); + assert_ne!(copied_tree[1].id, child); + assert_eq!(copied_tree[1].parent, Some(copied)); + document.undo().unwrap(); + assert!(document.snapshot_tree(copied).is_err()); + document.redo().unwrap(); + assert_eq!(document.snapshot_tree(copied).unwrap(), copied_tree); + document.delete_tree(folder).unwrap(); + assert!(document.snapshot_tree(folder).is_err()); + assert!(document.world().snapshot(child).is_err()); + document.undo().unwrap(); + assert_eq!(document.snapshot_tree(folder).unwrap(), snapshots); + document.redo().unwrap(); + assert!(document.snapshot_tree(folder).is_err()); + } + + #[test] + fn explorer_paste_rejects_missing_parent_without_partial_objects() { + let temporary = tempdir().unwrap(); + let project = Project::create( + temporary.path().join("paste"), + "Paste", + ProjectTemplate::Blank, + ) + .unwrap(); + let mut document = AuthoringDocument::open(project, false).unwrap(); + let folder = document.add_folder(None).unwrap(); + let snapshots = document.snapshot_tree(folder).unwrap(); + let before = document.entity_snapshots().unwrap(); + assert!( + document + .paste_tree(&snapshots, Some(EntityId::new())) + .is_err() + ); + assert_eq!(document.entity_snapshots().unwrap(), before); + document.read_only = true; + assert!(matches!( + document.delete_tree(folder), + Err(AuthoringError::ReadOnly) + )); + assert!(matches!( + document.paste_tree(&snapshots, None), + Err(AuthoringError::ReadOnly) + )); + } + #[test] fn gizmo_preview_drag_commits_exactly_one_undo_entry_and_read_only_rejects() { let temporary = tempdir().unwrap(); diff --git a/Engine/docs/EXPLORER.md b/Engine/docs/EXPLORER.md new file mode 100644 index 00000000..f76f775e --- /dev/null +++ b/Engine/docs/EXPLORER.md @@ -0,0 +1,44 @@ +# Explorer and asset management + +Prerequisites: a writable project and an existing asset file. Stop Play before reorganising project files. Switch **Project β†’ Explorer** to browse directories; **Filter** matches names and project-relative paths. + +## Import a model + +1. Copy the model and its companion files into the project with your operating system's file manager. Keep glTF buffers/textures and OBJ dependencies in their required relative locations. +2. Find the model in Explorer. The viewport accepts `.obj`, `.gltf`, `.glb` and `.fbx` model drops. +3. Drag the file into the authoring viewport. The editor imports it, creates/reads its `.rmeta` sidecar and uses its Asset ID for scene instances. +4. Inspect the created object, save the scene and check Console for import errors. + +Expected result: an imported model appears as a scene object. A recognised extension alone does not guarantee that every file variant is supported. Keep the original source and metadata together; regenerating metadata may change identity. + +## Files and scripts + +The project separates assets, scenes, scripts, `ui/` Web assets, and configuration. **Programming β†’ Object Component Script** creates and registers a script for the selected object; global and scene startup scopes also exist. Existing registered scripts can be attached through **Programming β†’ Attach Existing Script** or Inspector. Double-clicking a script opens the external editor. Right-click (or two-finger click on a trackpad) offers **Set As β†’ Global Startup Script**, **Scene Startup Script**, or **Object Component Script**. Select the target scene object first to enable Object Component Script. See [the scripting guides](/docs/scripting/lua) for each language's setup, including Luau registration and optional external runtimes. + +## Rename, move, copy, cut and delete + +Hovering over a row does not start a move. Press and hold the primary mouse button on the item name, then drag to another project folder or the root. A folder's arrow expands or collapses it; dragging its name moves only that folder, and dragging a child moves the child. + +Click a file or folder name once to edit it inline. The field appears after the short double-click window so double-click can still open a file. Press **Enter** or click elsewhere to save; **Escape** cancels. Use a single filename, including its extension. Empty names, path separators and existing names are rejected; Console explains the failure. + +Right-click an item, or use a two-finger trackpad click, for **Copy**, **Cut**, **Paste** and **Delete**. Copy/Cut stores an item in the editor clipboard; Paste on a folder uses that folder, and Paste on a file uses its containing folder. Right-click empty tree space for **Paste at Project Root**. Copies get a numbered name when needed. Cut leaves the source in place until a successful Paste. Delete moves the entry into `.rustic/trash//`; recover it with your operating system's file manager. Scene Undo does not undo filesystem actions. + +These controls require a writable project and stopped Play. Moves and renames keep a file's `.rmeta` sidecar alongside it. Copies omit `.rmeta` so a new asset identity can be assigned on import or script registration. **Manifest paths, scene references and companion-file links are not rewritten.** Update affected references before Play; copied scripts must be registered using the script scope commands. A folder copy does not rewrite references within its files. Operations reject traversal, symbolic links, missing destinations, move collisions and moving/copying a folder into itself. + +World/Local space applies to scene transforms: switch to **Project β†’ Scene**, right-click the object and choose **Use World Space** or **Use Local Space**. This changes the active transform gizmo axes without changing the object's transform. + +## Known limitations and diagnosis + +- `.rmeta` entries and generated/build/cache directories are filtered from the tree; a hidden entry is not necessarily missing from disk. +- For missing files, check the project-relative path and filename case, then dependent buffers/textures. +- For importer failures, inspect Console and the [asset pipeline](/docs/open-source/engine/architecture) limitations. +- For attachment failures, check `config/scripts.ron`, scope, language and toolchain; copying a script into a folder alone does not register it. + +Related: [Inspector](/docs/guides/editor/inspector), [Playtesting](/docs/guides/editor/playtesting). + +## Scene objects + +Right-click an object (or two-finger click on a trackpad) for **Copy**, **Cut**, **Paste**, **Delete**, **Use World Space** and **Use Local Space**. Copy captures the object and its descendants; Paste gives them fresh entity IDs and preserves their internal parent links. Paste on an object makes the pasted root its child; right-click empty tree space for **Paste at Scene Root**. Cut moves the original tree only after a successful Paste. Delete removes the object and its descendants. Rename, Paste, Cut/Paste and Delete support **Edit β†’ Undo/Redo** and are disabled during Play or read-only access. + +World/Local space changes the active gizmo's axes without modifying the stored transform. Copied scripts and other components keep their existing asset references; embedded entity IDs in arbitrary component data are not rewritten. There is no Delete-key shortcut. Creating a folder does not create a filesystem directory, and moving a scene object does not move its source asset. + From 09db0ea9604eedf1da4b5127ea0170c2ac50a9cb Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 12 Oct 2026 20:37:16 +0000 Subject: [PATCH 2/5] Cover expanded folder child drag regression --- Engine/apps/editor/src/main.rs | 59 ++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index f4c90fc1..d8a7df65 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -5414,4 +5414,63 @@ mod explorer_tests { } assert!(!egui::DragAndDrop::has_any_payload(&context)); } + #[test] + fn expanded_folder_child_owns_its_drag_payload() { + let context = egui::Context::default(); + egui_extras::install_image_loaders(&context); + let directory = ProjectDirectory { + name: "scripts".into(), + relative_path: "scripts".into(), + directories: vec![], + files: vec![ProjectFile { + name: "child.lua".into(), + relative_path: "scripts/child.lua".into(), + }], + }; + let frame = |time, events| { + let mut output = context.run_ui( + egui::RawInput { + time: Some(time), + events, + ..Default::default() + }, + |ui| { + render_project_directory(ui, &directory, "", true, true, true); + }, + ); + output.textures_delta.clear(); + output + }; + let output = frame(0.0, vec![]); + let position = output + .shapes + .iter() + .find_map(|shape| { + if let egui::epaint::Shape::Text(text) = &shape.shape + && text.galley.job.text == "child.lua" + { + Some(text.pos + text.galley.size() * 0.5) + } else { + None + } + }) + .expect("expanded folder must show its child"); + let output = frame(0.1, vec![egui::Event::PointerMoved(position)]); + assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Move); + assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + assert!(!egui::DragAndDrop::has_any_payload(&context)); + frame( + 0.2, + vec![pointer(position, egui::PointerButton::Primary, true)], + ); + frame( + 0.3, + vec![egui::Event::PointerMoved(position + egui::vec2(25.0, 0.0))], + ); + let payload = + egui::DragAndDrop::payload::(&context).expect("child starts dragging"); + assert!( + matches!(payload.as_ref(), ViewportDrop::ProjectFile(path) if path == Path::new("scripts/child.lua")) + ); + } } From 9435604f4f57f8e244a3f951f3ff52be83a49785 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 12 Oct 2026 20:43:17 +0000 Subject: [PATCH 3/5] Handle root paste and drops in empty explorer space --- Engine/apps/editor/src/main.rs | 89 +++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index d8a7df65..447aa93c 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -2185,6 +2185,7 @@ impl EditorViewer<'_> { .auto_shrink([false, false]) .show(ui, |ui| match snapshots { Ok(snapshots) => { + let background = explorer_tree_background(ui, "scene-tree-background"); let (root, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { render_scene_hierarchy( @@ -2196,7 +2197,9 @@ impl EditorViewer<'_> { self.gizmo.space, ) }); - root.response.context_menu(|ui| { + let dropped = + dropped.or_else(|| background.dnd_release_payload::()); + background.context_menu(|ui| { let enabled = !self.playing && !self.document.is_read_only() && ui.ctx().data_mut(|data| { @@ -2334,6 +2337,7 @@ impl EditorViewer<'_> { .id_salt("game-project-files") .auto_shrink([false, false]) .show(ui, |ui| { + let background = explorer_tree_background(ui, "project-tree-background"); let (root, dropped) = ui.dnd_drop_zone::(egui::Frame::NONE, |ui| { render_project_root( @@ -2345,7 +2349,9 @@ impl EditorViewer<'_> { ) }); action = root.inner; - root.response.context_menu(|ui| { + let dropped = + dropped.or_else(|| background.dnd_release_payload::()); + background.context_menu(|ui| { let enabled = !self.playing && !self.document.is_read_only() && ui.ctx().data_mut(|data| { @@ -3962,6 +3968,15 @@ fn edit_primitive(ui: &mut egui::Ui, primitive: &mut Primitive) -> bool { changed } +// Register the background before rows so item controls retain pointer priority. +fn explorer_tree_background(ui: &egui::Ui, id: &str) -> egui::Response { + ui.interact( + ui.clip_rect(), + ui.make_persistent_id(id), + egui::Sense::click(), + ) +} + #[derive(Debug)] struct ProjectDirectory { name: String, @@ -5473,4 +5488,74 @@ mod explorer_tests { matches!(payload.as_ref(), ViewportDrop::ProjectFile(path) if path == Path::new("scripts/child.lua")) ); } + #[test] + fn explorer_background_menu_opens_in_empty_space_without_stealing_row_clicks() { + let context = egui::Context::default(); + let frame = |time, events| { + let mut clicked = false; + let mut output = context.run_ui( + egui::RawInput { + time: Some(time), + events, + ..Default::default() + }, + |ui| { + let background = explorer_tree_background(ui, "background"); + clicked = ui.button("Object").clicked(); + background.context_menu(|ui| { + let _ = ui.button("Paste at Project Root"); + }); + }, + ); + output.textures_delta.clear(); + (output, clicked) + }; + let (output, _) = frame(0.0, vec![]); + let position = output + .shapes + .iter() + .find_map(|shape| { + if let egui::epaint::Shape::Text(text) = &shape.shape + && text.galley.job.text == "Object" + { + Some(text.pos + text.galley.size() * 0.5) + } else { + None + } + }) + .unwrap(); + frame( + 0.1, + vec![ + egui::Event::PointerMoved(position), + pointer(position, egui::PointerButton::Primary, true), + ], + ); + let (_, clicked) = frame( + 0.2, + vec![pointer(position, egui::PointerButton::Primary, false)], + ); + assert!(clicked, "the item must keep its click interaction"); + let empty_space = position + egui::vec2(100.0, 100.0); + frame( + 0.3, + vec![ + egui::Event::PointerMoved(empty_space), + pointer(empty_space, egui::PointerButton::Secondary, true), + ], + ); + frame( + 0.4, + vec![pointer(empty_space, egui::PointerButton::Secondary, false)], + ); + let (output, _) = frame(0.5, vec![]); + let mut text = String::new(); + for shape in output.shapes { + collect_text(&shape.shape, &mut text); + } + assert!( + text.contains("Paste at Project Root"), + "Missing root paste menu: {text}" + ); + } } From 9b7ab7675e3eca4471717e7bf36366129dc46c3a Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 12 Oct 2026 21:02:11 +0000 Subject: [PATCH 4/5] Fix Explorer model refresh and filesystem destination collisions --- Engine/apps/editor/src/main.rs | 113 ++++++++++++++++++++++++++++----- Engine/docs/EXPLORER.md | 6 +- 2 files changed, 102 insertions(+), 17 deletions(-) diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index 447aa93c..bf8f9bf5 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -1339,8 +1339,10 @@ impl eframe::App for EditorApp { self.attach_project_script(&path, scope); } Some(ProjectFileAction::Refresh) => { + self.model_assets = load_project_model_assets(&project_root); self.project_scan = Some(spawn_project_scan(project_root)); self.last_project_scan = Instant::now(); + self.refresh_preview(); } _ => {} } @@ -3729,9 +3731,7 @@ fn load_project_model_assets(root: &Path) -> BTreeMap { continue; } if file_type.is_dir() { - if !is_hidden_project_directory(&entry.file_name()) - && path != root.join(".rustic/trash") - { + if !is_hidden_project_directory(&entry.file_name()) { visit(root, &path, output); } continue; @@ -4584,17 +4584,11 @@ fn project_path_occupied(path: &Path) -> Result { } fn relocate_project_entry(source: &Path, destination: &Path) -> Result<(), String> { - if destination - .try_exists() - .map_err(|error| error.to_string())? - { + if project_path_occupied(destination)? { return Err("An entry with that name already exists".into()); } let metadata = source.is_file().then(|| sidecar_path(source)); - if let Some(metadata) = &metadata - && metadata.exists() - && sidecar_path(destination).exists() - { + if metadata.is_some() && project_path_occupied(&sidecar_path(destination))? { return Err("Asset metadata already exists at the destination".into()); } std::fs::rename(source, destination).map_err(|error| error.to_string())?; @@ -4795,9 +4789,7 @@ fn scan_project_directory(root: &Path, directory: &Path) -> ProjectDirectory { } let path = entry.path(); if file_type.is_dir() { - if !is_hidden_project_directory(&entry.file_name()) - && path != root.join(".rustic/trash") - { + if !is_hidden_project_directory(&entry.file_name()) { node.directories.push(scan_project_directory(root, &path)); } } else if file_type.is_file() @@ -4821,7 +4813,7 @@ fn scan_project_directory(root: &Path, directory: &Path) -> ProjectDirectory { fn is_hidden_project_directory(name: &std::ffi::OsStr) -> bool { matches!( name.to_string_lossy().to_ascii_lowercase().as_str(), - ".git" | ".tools" | "target" | "cache" | "temp" | "builds" | "logs" + ".git" | ".tools" | ".rustic" | "target" | "cache" | "temp" | "builds" | "logs" ) } @@ -5222,6 +5214,97 @@ mod explorer_tests { assert!(trash.join("renamed.lua.rmeta").exists()); } + #[test] + fn file_moves_reject_orphaned_destination_metadata() { + let project = TestProject::new(); + let root = &project.0; + std::fs::write(root.join("a.obj"), "source").unwrap(); + std::fs::create_dir(root.join("models")).unwrap(); + for target in [root.join("b.obj"), root.join("models/a.obj")] { + std::fs::write(sidecar_path(&target), "orphan identity").unwrap(); + } + assert!(rename_project_entry(root, Path::new("a.obj"), "b.obj").is_err()); + assert!(move_project_entry(root, Path::new("a.obj"), Path::new("models")).is_err()); + assert_eq!( + std::fs::read_to_string(root.join("a.obj")).unwrap(), + "source" + ); + for target in [root.join("b.obj"), root.join("models/a.obj")] { + assert!(!target.exists()); + assert_eq!( + std::fs::read_to_string(sidecar_path(&target)).unwrap(), + "orphan identity" + ); + } + } + + #[cfg(unix)] + #[test] + fn file_moves_reject_dangling_destination_links_and_sidecars() { + let project = TestProject::new(); + let root = &project.0; + std::fs::write(root.join("a.obj"), "source").unwrap(); + std::fs::create_dir(root.join("models")).unwrap(); + let missing = root.join("missing"); + for target in [root.join("b.obj"), root.join("models/a.obj")] { + std::os::unix::fs::symlink(&missing, &target).unwrap(); + } + assert!(rename_project_entry(root, Path::new("a.obj"), "b.obj").is_err()); + assert!(move_project_entry(root, Path::new("a.obj"), Path::new("models")).is_err()); + for target in [root.join("b.obj"), root.join("models/a.obj")] { + assert_eq!(std::fs::read_link(&target).unwrap(), missing); + std::fs::remove_file(&target).unwrap(); + std::os::unix::fs::symlink(&missing, sidecar_path(&target)).unwrap(); + } + assert!(rename_project_entry(root, Path::new("a.obj"), "b.obj").is_err()); + assert!(move_project_entry(root, Path::new("a.obj"), Path::new("models")).is_err()); + assert_eq!( + std::fs::read_to_string(root.join("a.obj")).unwrap(), + "source" + ); + for target in [root.join("b.obj"), root.join("models/a.obj")] { + assert!(!target.exists()); + assert_eq!(std::fs::read_link(sidecar_path(&target)).unwrap(), missing); + } + } + + #[test] + fn model_reload_reconciles_moves_unsupported_renames_and_deletion() { + let project = TestProject::new(); + let root = &project.0; + std::fs::write( + root.join("triangle.obj"), + "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n", + ) + .unwrap(); + let (id, _) = import_model_asset(root, Path::new("triangle.obj")).unwrap(); + assert!(load_project_model_assets(root).contains_key(&id)); + std::fs::create_dir(root.join("models")).unwrap(); + move_project_entry(root, Path::new("triangle.obj"), Path::new("models")).unwrap(); + assert!(load_project_model_assets(root).contains_key(&id)); + rename_project_entry(root, Path::new("models/triangle.obj"), "triangle.txt").unwrap(); + assert!(load_project_model_assets(root).is_empty()); + rename_project_entry(root, Path::new("models/triangle.txt"), "triangle.obj").unwrap(); + assert!(load_project_model_assets(root).contains_key(&id)); + delete_project_entry(root, Path::new("models/triangle.obj")).unwrap(); + assert!(load_project_model_assets(root).is_empty()); + assert!( + scan_project_directory(root, root) + .directories + .iter() + .all(|entry| entry.name != ".rustic") + ); + // Generated assets must be excluded too, not only the trash child. + std::fs::create_dir_all(root.join(".rustic/generated")).unwrap(); + std::fs::write( + root.join(".rustic/generated/triangle.obj"), + "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n", + ) + .unwrap(); + import_model_asset(root, Path::new(".rustic/generated/triangle.obj")).unwrap(); + assert!(load_project_model_assets(root).is_empty()); + } + #[test] fn filesystem_operations_reject_collisions_traversal_and_self_moves() { let project = TestProject::new(); diff --git a/Engine/docs/EXPLORER.md b/Engine/docs/EXPLORER.md index f76f775e..d87b4a68 100644 --- a/Engine/docs/EXPLORER.md +++ b/Engine/docs/EXPLORER.md @@ -21,9 +21,11 @@ Hovering over a row does not start a move. Press and hold the primary mouse butt Click a file or folder name once to edit it inline. The field appears after the short double-click window so double-click can still open a file. Press **Enter** or click elsewhere to save; **Escape** cancels. Use a single filename, including its extension. Empty names, path separators and existing names are rejected; Console explains the failure. -Right-click an item, or use a two-finger trackpad click, for **Copy**, **Cut**, **Paste** and **Delete**. Copy/Cut stores an item in the editor clipboard; Paste on a folder uses that folder, and Paste on a file uses its containing folder. Right-click empty tree space for **Paste at Project Root**. Copies get a numbered name when needed. Cut leaves the source in place until a successful Paste. Delete moves the entry into `.rustic/trash//`; recover it with your operating system's file manager. Scene Undo does not undo filesystem actions. +Right-click an item, or use a two-finger trackpad click, for **Copy**, **Cut**, **Paste** and **Delete**. Copy/Cut stores an item in the editor clipboard; Paste on a folder uses that folder, and Paste on a file uses its containing folder. Right-click empty tree space for **Paste at Project Root**. Copies get a numbered name when needed. Cut leaves the source in place until a successful Paste. Delete moves the entry into `.rustic/trash//`; recover it with your operating system's file manager. The engine-owned `.rustic` directory is hidden from Explorer, including generated files and recovery trash. Scene Undo does not undo filesystem actions. -These controls require a writable project and stopped Play. Moves and renames keep a file's `.rmeta` sidecar alongside it. Copies omit `.rmeta` so a new asset identity can be assigned on import or script registration. **Manifest paths, scene references and companion-file links are not rewritten.** Update affected references before Play; copied scripts must be registered using the script scope commands. A folder copy does not rewrite references within its files. Operations reject traversal, symbolic links, missing destinations, move collisions and moving/copying a folder into itself. +These controls require a writable project and stopped Play. Moves and renames keep a file's `.rmeta` sidecar alongside it. Copies omit `.rmeta` so a new asset identity can be assigned on import or script registration. **Manifest paths, scene references and companion-file links are not rewritten.** Update affected references before Play; copied scripts must be registered using the script scope commands. A folder copy does not rewrite references within its files. Operations reject traversal, symbolic links, missing destinations, move collisions and moving/copying a folder into itself. A dangling destination symlink or an existing destination `.rmeta` also blocks a move or rename, even when the source has no metadata; inspect the destination with your file manager and resolve the conflict before retrying. Do not reuse orphaned metadata for an unrelated asset. + +Successful Explorer file operations reload imported models and refresh the viewport. Moving or renaming a model to another supported model extension preserves its Asset ID through the sidecar. Deleting a model or changing its extension to an unsupported one removes its cached geometry from rendering and picking; the scene object remains with its unresolved asset reference. Restore the source and sidecar to a supported path, then restart the editor or perform another Explorer file operation to reload it. World/Local space applies to scene transforms: switch to **Project β†’ Scene**, right-click the object and choose **Use World Space** or **Use Local Space**. This changes the active transform gizmo axes without changing the object's transform. From 7b36f526673a2ba34c77a41fe1527c81d8ef5484 Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 12 Oct 2026 21:09:02 +0000 Subject: [PATCH 5/5] Add Explorer selection gestures and multi-folder clipboard paste --- Engine/apps/editor/src/explorer_selection.rs | 168 ++++ Engine/apps/editor/src/main.rs | 948 ++++++++++++++++--- Engine/docs/EXPLORER.md | 16 +- 3 files changed, 1000 insertions(+), 132 deletions(-) create mode 100644 Engine/apps/editor/src/explorer_selection.rs diff --git a/Engine/apps/editor/src/explorer_selection.rs b/Engine/apps/editor/src/explorer_selection.rs new file mode 100644 index 00000000..c20b22c2 --- /dev/null +++ b/Engine/apps/editor/src/explorer_selection.rs @@ -0,0 +1,168 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Default)] +pub(super) struct ExplorerSelection { + pub highlighted: BTreeSet, + pub anchor: Option, + pub visible: Vec, + pub rendered: Vec, + pub press: Option<(PathBuf, bool)>, + pub focused: bool, + pub renaming: bool, + pub brushing: bool, +} + +impl ExplorerSelection { + pub fn begin_frame(&mut self) { + self.visible = std::mem::take(&mut self.rendered); + self.renaming = false; + } + + pub fn select(&mut self, path: &Path, shift: bool, ctrl: bool) { + if ctrl { + let start = self + .anchor + .as_ref() + .and_then(|anchor| self.visible.iter().position(|item| item == anchor)); + let end = self.visible.iter().position(|item| item == path); + if !shift { + self.highlighted.clear(); + } + if let (Some(start), Some(end)) = (start, end) { + self.highlighted.extend( + self.visible[start.min(end)..=start.max(end)] + .iter() + .cloned(), + ); + } else { + self.highlighted.insert(path.to_path_buf()); + } + } else { + if !shift { + self.highlighted.clear(); + } + self.highlighted.insert(path.to_path_buf()); + } + self.anchor = Some(path.to_path_buf()); + self.focused = true; + } + + // Eligibility is captured before highlighting. An unselected press can never + // become a drag merely because that same press just selected the row. + pub fn press(&mut self, path: &Path, shift: bool, ctrl: bool) { + let drag = self.highlighted.contains(path) && !shift && !ctrl; + self.brushing = shift && !ctrl; + if !drag { + self.select(path, shift, ctrl); + } + self.press = Some((path.to_path_buf(), drag)); + self.focused = true; + } + + pub fn relocate(&mut self, source: &Path, destination: &Path) { + let remap = |path: PathBuf| { + path.strip_prefix(source).map_or_else( + |_| path.clone(), + |suffix| { + if suffix.as_os_str().is_empty() { + destination.to_path_buf() + } else { + destination.join(suffix) + } + }, + ) + }; + self.highlighted = std::mem::take(&mut self.highlighted) + .into_iter() + .map(remap) + .collect(); + self.anchor = self.anchor.take().map(remap); + } + + pub fn can_drag(&self, path: &Path) -> bool { + self.highlighted.contains(path) + && self + .press + .as_ref() + .is_some_and(|(origin, armed)| *armed && origin == path) + } +} + +// Selecting a folder and its descendants copies/moves that subtree once. +pub(super) fn selection_roots(paths: impl IntoIterator) -> Vec { + let paths: BTreeSet<_> = paths.into_iter().collect(); + paths + .iter() + .filter(|path| { + !paths + .iter() + .any(|parent| *path != parent && path.starts_with(parent)) + }) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shift_adds_and_ctrl_selects_visible_range_from_last_highlight() { + let mut selection = ExplorerSelection { + visible: ["a", "b", "c", "d"].map(PathBuf::from).to_vec(), + ..Default::default() + }; + selection.select(Path::new("a"), false, false); + selection.select(Path::new("c"), true, false); + assert_eq!(selection.highlighted, ["a", "c"].map(PathBuf::from).into()); + selection.select(Path::new("a"), false, true); + assert_eq!( + selection.highlighted, + ["a", "b", "c"].map(PathBuf::from).into() + ); + selection.select(Path::new("d"), true, true); + assert_eq!(selection.highlighted.len(), 4); + } + + #[test] + fn first_press_only_highlights_and_second_press_arms_that_item() { + let mut selection = ExplorerSelection::default(); + selection.press(Path::new("a"), false, false); + assert!(selection.highlighted.contains(Path::new("a"))); + assert!(!selection.can_drag(Path::new("a"))); + selection.press(Path::new("a"), false, false); + assert!(selection.can_drag(Path::new("a"))); + assert!(!selection.can_drag(Path::new("b"))); + selection.press(Path::new("b"), true, false); + assert_eq!(selection.highlighted.len(), 2); + assert!(!selection.can_drag(Path::new("b"))); + assert!(!selection.can_drag(Path::new("a"))); + } + + #[test] + fn selected_parent_suppresses_duplicate_descendant_operations() { + assert_eq!( + selection_roots(["folder/a", "folder", "other", "folderish"].map(PathBuf::from)), + ["folder", "folderish", "other"].map(PathBuf::from) + ); + } + #[test] + fn renamed_folder_retains_selection_and_anchor_at_new_paths() { + let mut selection = ExplorerSelection { + highlighted: ["folder", "folder/child", "other"] + .map(PathBuf::from) + .into(), + anchor: Some("folder/child".into()), + ..Default::default() + }; + selection.relocate(Path::new("folder"), Path::new("renamed")); + assert_eq!( + selection.highlighted, + ["renamed", "renamed/child", "other"] + .map(PathBuf::from) + .into() + ); + assert_eq!(selection.anchor, Some("renamed/child".into())); + } +} diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index bf8f9bf5..e1ed2888 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -1,3 +1,4 @@ +mod explorer_selection; mod play_session; mod ui_theme; @@ -28,6 +29,7 @@ use engine_scripting::{ }; use engine_world::ScriptComponent; use engine_world::{EntityId, LocalTransform, Primitive, RenderWorldBuffer}; +use explorer_selection::{ExplorerSelection, selection_roots}; use glam::{EulerRot, Mat4, Quat, Vec2, Vec3}; use num_traits::ToPrimitive as _; use play_session::EditorPlaySession; @@ -1380,8 +1382,18 @@ struct EditorViewer<'a> { #[derive(Clone, Debug)] enum ViewportDrop { - ProjectFile(PathBuf), - ProjectDirectory(PathBuf), + File(PathBuf), + Directory(PathBuf), + Entries(Vec), +} + +impl ViewportDrop { + fn project_paths(&self) -> Vec { + match self { + Self::File(path) | Self::Directory(path) => vec![path.clone()], + Self::Entries(paths) => paths.clone(), + } + } } impl Behavior for EditorViewer<'_> { @@ -1511,7 +1523,7 @@ impl EditorViewer<'_> { self.viewport_input(ui, &response); self.gizmo_overlay(ui, response.rect); } - if let Some(ViewportDrop::ProjectFile(path)) = dropped.as_deref() { + if let Some(ViewportDrop::File(path)) = dropped.as_deref() { if path.extension().is_some_and(|extension| { extension.eq_ignore_ascii_case("scene") || extension.eq_ignore_ascii_case("rscene") }) { @@ -1613,13 +1625,20 @@ impl EditorViewer<'_> { } } // Directories organize project assets and have no viewport representation. - if matches!(dropped.as_deref(), Some(ViewportDrop::ProjectDirectory(_))) { + if matches!(dropped.as_deref(), Some(ViewportDrop::Directory(_))) { self.console.push(simple_console( Severity::Info, "explorer", "Folders can only be moved inside the Explorer", )); } + if matches!(dropped.as_deref(), Some(ViewportDrop::Entries(_))) { + self.console.push(simple_console( + Severity::Info, + "explorer", + "Drop one model or script at a time into the viewport", + )); + } ui.small(self.preview_status); } @@ -2304,6 +2323,7 @@ impl EditorViewer<'_> { } fn file_explorer(&mut self, ui: &mut egui::Ui) { + begin_explorer_selection(ui); ui.horizontal(|ui| { ui.label(egui::RichText::new("Filter").color(ui_theme::MUTED)); ui.add( @@ -2351,6 +2371,9 @@ impl EditorViewer<'_> { ) }); action = root.inner; + if background.clicked() { + clear_explorer_selection(ui); + } let dropped = dropped.or_else(|| background.dnd_release_payload::()); background.context_menu(|ui| { @@ -2364,17 +2387,14 @@ impl EditorViewer<'_> { .add_enabled(enabled, egui::Button::new("Paste at Project Root")) .clicked() { - action = Some(ProjectFileAction::Paste(PathBuf::new())); + action = Some(ProjectFileAction::Paste(vec![PathBuf::new()])); ui.close(); } }); if action.is_none() { - action = dropped.as_deref().map(|payload| match payload { - ViewportDrop::ProjectFile(path) - | ViewportDrop::ProjectDirectory(path) => ProjectFileAction::Move { - source: path.clone(), - destination: PathBuf::new(), - }, + action = dropped.as_deref().map(|payload| ProjectFileAction::Move { + sources: payload.project_paths(), + destination: PathBuf::new(), }); } if !filter.is_empty() && !project_directory_matches(tree, &filter) { @@ -2391,6 +2411,13 @@ impl EditorViewer<'_> { ui.weak("Scanning project files…"); }); } + if let Some(shortcut) = explorer_keyboard_action( + ui, + self.project_root, + !self.document.is_read_only() && !self.playing, + ) { + action = Some(shortcut); + } self.apply_project_file_action(ui, action); } @@ -2405,63 +2432,76 @@ impl EditorViewer<'_> { if can_edit => { let cut = matches!(entry_action, ProjectFileAction::Cut(_)); - let (ProjectFileAction::Copy(source) | ProjectFileAction::Cut(source)) = + let (ProjectFileAction::Copy(sources) | ProjectFileAction::Cut(sources)) = entry_action else { unreachable!() }; - ui.ctx().data_mut(|data| { - data.insert_temp(project_clipboard_id(), ProjectClipboard { source, cut }) - }); + if !sources.is_empty() { + ui.ctx().data_mut(|data| { + data.insert_temp( + project_clipboard_id(), + ProjectClipboard { + sources: selection_roots(sources), + cut, + }, + ) + }); + } } Some(action) if can_edit => { - let result = match action { + let renamed = match &action { + ProjectFileAction::Rename(source, name) => { + Some((source.clone(), source.with_file_name(name))) + } + _ => None, + }; + let results = match action { ProjectFileAction::Move { - source, + sources, destination, - } => move_project_entry(self.project_root, &source, &destination), + } => selection_roots(sources) + .iter() + .map(|source| move_project_entry(self.project_root, source, &destination)) + .collect::>(), ProjectFileAction::Rename(source, name) => { - rename_project_entry(self.project_root, &source, &name) - } - ProjectFileAction::Delete(source) => { - delete_project_entry(self.project_root, &source) + vec![rename_project_entry(self.project_root, &source, &name)] } - ProjectFileAction::Paste(destination) => { - let clipboard = ui.ctx().data_mut(|data| { + ProjectFileAction::Delete(sources) => selection_roots(sources) + .iter() + .map(|source| delete_project_entry(self.project_root, source)) + .collect(), + ProjectFileAction::Paste(destinations) => { + if let Some(mut clipboard) = ui.ctx().data_mut(|data| { data.get_temp::(project_clipboard_id()) - }); - if let Some(clipboard) = clipboard { - let result = if clipboard.cut { - move_project_entry( - self.project_root, - &clipboard.source, - &destination, - ) - } else { - copy_project_entry( - self.project_root, - &clipboard.source, - &destination, - ) - }; - if result.is_ok() && clipboard.cut { - ui.ctx().data_mut(|data| { + }) { + let results = paste_project_entries( + self.project_root, + &mut clipboard, + &destinations, + ); + ui.ctx().data_mut(|data| { + if clipboard.sources.is_empty() { data.remove::(project_clipboard_id()); - }); - } - result + } else { + data.insert_temp(project_clipboard_id(), clipboard); + } + }); + results } else { - Ok(()) + vec![] } } - _ => Ok(()), + _ => vec![], }; - if let Err(error) = result { + let succeeded = results.iter().all(Result::is_ok); + for error in results.into_iter().filter_map(Result::err) { self.console .push(simple_console(Severity::Error, "explorer", error)); - } else { - *self.project_file_action = Some(ProjectFileAction::Refresh); } + update_explorer_selection(ui, self.project_root, renamed.filter(|_| succeeded)); + // Rescan after partial batches too, so completed operations are visible. + *self.project_file_action = Some(ProjectFileAction::Refresh); } _ => {} } @@ -4189,12 +4229,12 @@ enum ProjectFileAction { Open(PathBuf), Attach(PathBuf, ScriptScope), Rename(PathBuf, String), - Copy(PathBuf), - Cut(PathBuf), - Paste(PathBuf), - Delete(PathBuf), + Copy(Vec), + Cut(Vec), + Paste(Vec), + Delete(Vec), Move { - source: PathBuf, + sources: Vec, destination: PathBuf, }, } @@ -4299,14 +4339,14 @@ fn render_project_directory( }); let (_, header_response, _) = drop.inner; if let Some(payload) = dropped.as_deref() { - let source = match payload { - ViewportDrop::ProjectFile(path) | ViewportDrop::ProjectDirectory(path) => Some(path), - }; - if let Some(source) = source - && source != &directory.relative_path - { + let sources: Vec<_> = payload + .project_paths() + .into_iter() + .filter(|source| source != &directory.relative_path) + .collect(); + if !sources.is_empty() { action = Some(ProjectFileAction::Move { - source: source.clone(), + sources, destination: directory.relative_path.clone(), }); } @@ -4348,7 +4388,7 @@ fn render_project_file( #[derive(Clone)] struct ProjectClipboard { - source: PathBuf, + sources: Vec, cut: bool, } @@ -4369,6 +4409,14 @@ fn project_entry_row( let rename_id = ui.make_persistent_id(("project-rename", path)); let editing = ui.ctx().data_mut(|data| data.get_temp::(rename_id)); if let Some(mut text) = editing { + ui.ctx().data_mut(|data| { + let mut selection = data + .get_temp::(explorer_selection_id()) + .unwrap_or_default(); + selection.rendered.push(path.to_path_buf()); + selection.renaming = true; + data.insert_temp(explorer_selection_id(), selection); + }); let response = ui.add(egui::TextEdit::singleline(&mut text).id(rename_id)); let escape = ui.input(|input| input.key_pressed(egui::Key::Escape)); let enter = ui.input(|input| input.key_pressed(egui::Key::Enter)); @@ -4380,47 +4428,245 @@ fn project_entry_row( ui.ctx().data_mut(|data| data.insert_temp(rename_id, text)); return None; } - let response = ui.add( - egui::Label::new(egui::RichText::new(name).color(color)) - .sense(egui::Sense::click_and_drag()), - ); - if can_edit && response.drag_started_by(egui::PointerButton::Primary) { - response.dnd_set_drag_payload(if directory { - ViewportDrop::ProjectDirectory(path.to_path_buf()) - } else { - ViewportDrop::ProjectFile(path.to_path_buf()) - }); + let state_id = explorer_selection_id(); + let mut selection = ui + .ctx() + .data_mut(|data| data.get_temp::(state_id)) + .unwrap_or_default(); + selection.rendered.push(path.to_path_buf()); + let highlighted = selection.highlighted.contains(path); + let response = ui + .selectable_label(highlighted, egui::RichText::new(name).color(color)) + .interact(egui::Sense::click_and_drag()); + let (pressed, modifiers) = ui.input(|input| { + ( + input.pointer.button_pressed(egui::PointerButton::Primary), + input.modifiers, + ) + }); + if pressed && response.is_pointer_button_down_on() { + selection.press(path, modifiers.shift, modifiers.ctrl || modifiers.command); + } + if selection.brushing + && modifiers.shift + && !modifiers.ctrl + && !modifiers.command + && ui.input(|input| input.pointer.primary_down()) + && response.contains_pointer() + { + selection.select(path, true, false); + selection.press = None; + } + if can_edit + && response.drag_started_by(egui::PointerButton::Primary) + && selection.can_drag(path) + && !modifiers.shift + && !modifiers.ctrl + && !modifiers.command + { + let sources = selection_roots(selection.highlighted.iter().cloned()); + response.dnd_set_drag_payload( + if sources.len() > 1 || sources.first().is_some_and(|source| source != path) { + ViewportDrop::Entries(sources) + } else if directory { + ViewportDrop::Directory(path.to_path_buf()) + } else { + ViewportDrop::File(path.to_path_buf()) + }, + ); } - let pending_id = rename_id.with("pending-click"); - let now = ui.input(|input| input.time); - let pending = ui.ctx().data_mut(|data| data.get_temp::(pending_id)); - let mut action = None; - if response.double_clicked() && !directory { - ui.ctx().data_mut(|data| data.remove::(pending_id)); - action = Some(ProjectFileAction::Open(path.to_path_buf())); - } else if response.clicked() && can_edit { - ui.ctx().data_mut(|data| data.insert_temp(pending_id, now)); - ui.ctx().request_repaint_after(Duration::from_millis(310)); - } else if can_edit - && pending.is_some_and(|time| now - time > 0.3) - && !ui.input(|input| input.pointer.any_down()) + if response.clicked() && !modifiers.shift && !modifiers.ctrl && !modifiers.command { + selection.select(path, false, false); + } + if response.secondary_clicked() { + if !selection.highlighted.contains(path) { + selection.select(path, false, false); + } + selection.focused = true; + } + if response.double_clicked() + && can_edit + && !modifiers.shift + && !modifiers.ctrl + && !modifiers.command { - ui.ctx().data_mut(|data| { - data.remove::(pending_id); - data.insert_temp(rename_id, name.to_owned()); - }); + ui.ctx() + .data_mut(|data| data.insert_temp(rename_id, name.to_owned())); ui.memory_mut(|memory| memory.request_focus(rename_id)); + selection.renaming = true; ui.ctx().request_repaint(); } - if response.secondary_clicked() || response.drag_started() { - ui.ctx().data_mut(|data| data.remove::(pending_id)); - } - action = - project_entry_menu(&response, path, directory, can_edit, has_selected_object).or(action); + ui.ctx() + .data_mut(|data| data.insert_temp(state_id, selection)); + let action = project_entry_menu(&response, path, directory, can_edit, has_selected_object); response.on_hover_text(path.display().to_string()); action } +fn begin_explorer_selection(ui: &egui::Ui) { + let outside_press = ui.input(|input| { + input.pointer.any_pressed() + && !input + .pointer + .latest_pos() + .is_some_and(|pos| ui.max_rect().contains(pos)) + }); + let released = ui.input(|input| !input.pointer.primary_down()); + ui.ctx().data_mut(|data| { + let mut selection = data + .get_temp::(explorer_selection_id()) + .unwrap_or_default(); + selection.begin_frame(); + if released || outside_press { + selection.press = None; + selection.brushing = false; + } + if outside_press { + selection.focused = false; + } + data.insert_temp(explorer_selection_id(), selection); + }); +} + +fn clear_explorer_selection(ui: &egui::Ui) { + ui.ctx().data_mut(|data| { + let mut selection = data + .get_temp::(explorer_selection_id()) + .unwrap_or_default(); + selection.highlighted.clear(); + selection.anchor = None; + selection.focused = true; + data.insert_temp(explorer_selection_id(), selection); + }); +} + +fn update_explorer_selection(ui: &egui::Ui, root: &Path, renamed: Option<(PathBuf, PathBuf)>) { + ui.ctx().data_mut(|data| { + let mut selection = data + .get_temp::(explorer_selection_id()) + .unwrap_or_default(); + if let Some((source, destination)) = renamed { + selection.relocate(&source, &destination); + } + selection + .highlighted + .retain(|path| root.join(path).exists()); + if selection + .anchor + .as_ref() + .is_some_and(|path| !selection.highlighted.contains(path)) + { + selection.anchor = None; + } + selection.press = None; + data.insert_temp(explorer_selection_id(), selection); + }); +} + +fn explorer_selection_id() -> egui::Id { + egui::Id::new("project-explorer-selection") +} + +fn selected_project_entries(context: &egui::Context, fallback: &Path) -> Vec { + let selection = context + .data_mut(|data| data.get_temp::(explorer_selection_id())) + .unwrap_or_default(); + if selection.highlighted.contains(fallback) { + selection_roots(selection.highlighted) + } else { + vec![fallback.to_path_buf()] + } +} + +fn explorer_keyboard_action( + ui: &egui::Ui, + root: &Path, + can_edit: bool, +) -> Option { + let selection = ui + .ctx() + .data_mut(|data| data.get_temp::(explorer_selection_id())) + .unwrap_or_default(); + if !can_edit || !selection.focused || selection.renaming || ui.ctx().text_edit_focused() { + return None; + } + let (copy, paste) = ui.input_mut(|input| { + let copy = input.consume_key(egui::Modifiers::CTRL, egui::Key::C) + || input.consume_key(egui::Modifiers::COMMAND, egui::Key::C) + || input + .events + .iter() + .any(|event| matches!(event, egui::Event::Copy)); + let paste = input.consume_key(egui::Modifiers::CTRL, egui::Key::V) + || input.consume_key(egui::Modifiers::COMMAND, egui::Key::V) + || input + .events + .iter() + .any(|event| matches!(event, egui::Event::Paste(_))); + if copy || paste { + input + .events + .retain(|event| !matches!(event, egui::Event::Copy | egui::Event::Paste(_))); + } + (copy, paste) + }); + if copy { + return Some(ProjectFileAction::Copy(selection_roots( + selection.highlighted, + ))); + } + if paste { + let mut destinations: Vec<_> = selection + .highlighted + .iter() + .filter(|path| root.join(path).is_dir()) + .cloned() + .collect(); + if destinations.is_empty() { + destinations.push( + selection + .anchor + .as_deref() + .and_then(Path::parent) + .unwrap_or(Path::new("")) + .to_path_buf(), + ); + } + return Some(ProjectFileAction::Paste(destinations)); + } + None +} + +fn paste_project_entries( + root: &Path, + clipboard: &mut ProjectClipboard, + destinations: &[PathBuf], +) -> Vec> { + let destinations: BTreeSet<_> = destinations.iter().collect(); + if clipboard.cut && destinations.len() > 1 { + return vec![Err("Cut entries can only be pasted into one folder; use Copy to paste into multiple folders".into())]; + } + let mut results = Vec::new(); + let mut moved = BTreeSet::new(); + for destination in destinations { + for source in &clipboard.sources { + let result = if clipboard.cut { + move_project_entry(root, source, destination) + } else { + copy_project_entry(root, source, destination) + }; + if result.is_ok() && clipboard.cut { + moved.insert(source.clone()); + } + results.push(result.map_err(|error| { + format!("{} β†’ {}: {error}", source.display(), destination.display()) + })); + } + } + clipboard.sources.retain(|source| !moved.contains(source)); + results +} + fn project_entry_menu( response: &egui::Response, path: &Path, @@ -4431,9 +4677,18 @@ fn project_entry_menu( let mut action = None; response.context_menu(|ui| { for (label, entry_action) in [ - ("Copy", ProjectFileAction::Copy(path.to_path_buf())), - ("Cut", ProjectFileAction::Cut(path.to_path_buf())), - ("Delete", ProjectFileAction::Delete(path.to_path_buf())), + ( + "Copy", + ProjectFileAction::Copy(selected_project_entries(&response.ctx, path)), + ), + ( + "Cut", + ProjectFileAction::Cut(selected_project_entries(&response.ctx, path)), + ), + ( + "Delete", + ProjectFileAction::Delete(selected_project_entries(&response.ctx, path)), + ), ] { if ui.add_enabled(can_edit, egui::Button::new(label)).clicked() { action = Some(entry_action); @@ -4448,11 +4703,15 @@ fn project_entry_menu( .add_enabled(can_edit && has_clipboard, egui::Button::new("Paste")) .clicked() { - action = Some(ProjectFileAction::Paste(if directory { + action = Some(ProjectFileAction::Paste(vec![if directory { path.to_path_buf() } else { path.parent().unwrap_or(Path::new("")).to_path_buf() - })); + }])); + ui.close(); + } + if !directory && ui.button("Open").clicked() { + action = Some(ProjectFileAction::Open(path.to_path_buf())); ui.close(); } if !directory && ScriptLanguage::from_path(path).is_some() { @@ -5399,13 +5658,36 @@ mod explorer_tests { 0.3, vec![egui::Event::PointerMoved(position + egui::vec2(20.0, 0.0))], ); + assert!(!egui::DragAndDrop::has_any_payload(&context)); + row_frame( + &context, + 0.4, + vec![pointer( + position + egui::vec2(20.0, 0.0), + egui::PointerButton::Primary, + false, + )], + ); + row_frame( + &context, + 0.8, + vec![ + egui::Event::PointerMoved(position), + pointer(position, egui::PointerButton::Primary, true), + ], + ); + row_frame( + &context, + 0.9, + vec![egui::Event::PointerMoved(position + egui::vec2(20.0, 0.0))], + ); assert!(egui::DragAndDrop::has_payload_of_type::( &context )); } #[test] - fn explorer_click_renames_and_double_click_still_opens() { + fn explorer_single_click_highlights_and_double_click_renames() { let context = egui::Context::default(); let (_, rect, _) = row_frame(&context, 0.0, vec![]); let position = rect.center(); @@ -5422,6 +5704,11 @@ mod explorer_tests { 0.15, vec![pointer(position, egui::PointerButton::Primary, false)], ); + let selection = context + .data_mut(|data| data.get_temp::(explorer_selection_id())) + .unwrap(); + assert!(selection.highlighted.contains(Path::new("script.lua"))); + assert!(!selection.renaming); row_frame( &context, 0.2, @@ -5432,35 +5719,25 @@ mod explorer_tests { 0.25, vec![pointer(position, egui::PointerButton::Primary, false)], ); - assert!(matches!(action, Some(ProjectFileAction::Open(_)))); - row_frame( - &context, - 1.0, - vec![pointer(position, egui::PointerButton::Primary, true)], + assert!(action.is_none(), "double click must not open the file"); + assert!( + context + .data_mut(|data| data.get_temp::(explorer_selection_id())) + .unwrap() + .renaming ); - row_frame( + let (_, _, action) = row_frame( &context, - 1.05, - vec![pointer(position, egui::PointerButton::Primary, false)], - ); - row_frame(&context, 1.4, vec![]); - let mut editing = false; - let mut output = context.run_ui( - egui::RawInput { - time: Some(1.5), - ..Default::default() - }, - |ui| { - editing = ui.ctx().data_mut(|data| { - data.get_temp::( - ui.make_persistent_id(("project-rename", Path::new("script.lua"))), - ) - .is_some() - }); - }, + 0.3, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], ); - output.textures_delta.clear(); - assert!(editing); + assert!(action.is_none(), "Escape cancels rename"); } fn collect_text(shape: &egui::epaint::Shape, text: &mut String) { match shape { @@ -5557,6 +5834,13 @@ mod explorer_tests { assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Move); assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); assert!(!egui::DragAndDrop::has_any_payload(&context)); + context.data_mut(|data| { + let mut selection = data + .get_temp::(explorer_selection_id()) + .unwrap_or_default(); + selection.select(Path::new("scripts/child.lua"), false, false); + data.insert_temp(explorer_selection_id(), selection); + }); frame( 0.2, vec![pointer(position, egui::PointerButton::Primary, true)], @@ -5568,7 +5852,7 @@ mod explorer_tests { let payload = egui::DragAndDrop::payload::(&context).expect("child starts dragging"); assert!( - matches!(payload.as_ref(), ViewportDrop::ProjectFile(path) if path == Path::new("scripts/child.lua")) + matches!(payload.as_ref(), ViewportDrop::File(path) if path == Path::new("scripts/child.lua")) ); } #[test] @@ -5641,4 +5925,414 @@ mod explorer_tests { "Missing root paste menu: {text}" ); } + fn selection_frame( + context: &egui::Context, + root: &Path, + time: f64, + mut events: Vec, + ) -> (egui::FullOutput, Option) { + let modifiers = events + .iter() + .rev() + .find_map(|event| match event { + egui::Event::PointerButton { modifiers, .. } + | egui::Event::Key { modifiers, .. } => Some(*modifiers), + _ => None, + }) + .unwrap_or_else(|| context.input(|input| input.modifiers)); + events.insert(0, egui::Event::ModifiersChanged(modifiers)); + let mut action = None; + let mut output = context.run_ui( + egui::RawInput { + time: Some(time), + events, + ..Default::default() + }, + |ui| { + begin_explorer_selection(ui); + for (name, directory) in [ + ("a.lua", false), + ("b.lua", false), + ("c.lua", false), + ("one", true), + ("two", true), + ] { + if let Some(row_action) = project_entry_row( + ui, + name, + Path::new(name), + egui::Color32::WHITE, + directory, + true, + true, + ) { + action = Some(row_action); + } + } + if let Some(shortcut) = explorer_keyboard_action(ui, root, true) { + action = Some(shortcut); + } + }, + ); + output.textures_delta.clear(); + (output, action) + } + + fn text_position(output: &egui::FullOutput, label: &str) -> egui::Pos2 { + output + .shapes + .iter() + .find_map(|shape| { + if let egui::epaint::Shape::Text(text) = &shape.shape + && text.galley.job.text == label + { + Some(text.pos + text.galley.size() * 0.5) + } else { + None + } + }) + .unwrap() + } + + fn modifier_pointer( + position: egui::Pos2, + pressed: bool, + modifiers: egui::Modifiers, + ) -> egui::Event { + egui::Event::PointerButton { + pos: position, + button: egui::PointerButton::Primary, + pressed, + modifiers, + } + } + + fn ctrl_key(key: egui::Key) -> egui::Event { + egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::CTRL | egui::Modifiers::COMMAND, + } + } + + #[test] + #[allow( + clippy::too_many_lines, + reason = "verify keyboard selection, copy and fan-out paste as a single user flow" + )] + fn explorer_ctrl_range_copy_and_multi_folder_paste_work_end_to_end() { + let project = TestProject::new(); + for name in ["a.lua", "b.lua", "c.lua"] { + std::fs::write(project.0.join(name), name).unwrap(); + } + for name in ["one", "two"] { + std::fs::create_dir(project.0.join(name)).unwrap(); + } + let context = egui::Context::default(); + let (output, _) = selection_frame(&context, &project.0, 0.0, vec![]); + let a = text_position(&output, "a.lua"); + let c = text_position(&output, "c.lua"); + let one = text_position(&output, "one"); + let two = text_position(&output, "two"); + selection_frame( + &context, + &project.0, + 0.1, + vec![ + egui::Event::PointerMoved(a), + modifier_pointer(a, true, egui::Modifiers::NONE), + ], + ); + selection_frame( + &context, + &project.0, + 0.2, + vec![modifier_pointer(a, false, egui::Modifiers::NONE)], + ); + selection_frame( + &context, + &project.0, + 0.4, + vec![ + egui::Event::PointerMoved(c), + modifier_pointer(c, true, egui::Modifiers::CTRL), + ], + ); + selection_frame( + &context, + &project.0, + 0.5, + vec![modifier_pointer(c, false, egui::Modifiers::CTRL)], + ); + let (_, copy) = selection_frame(&context, &project.0, 0.6, vec![ctrl_key(egui::Key::C)]); + let Some(ProjectFileAction::Copy(sources)) = copy else { + panic!("Ctrl+C must copy selected rows"); + }; + assert_eq!(sources, ["a.lua", "b.lua", "c.lua"].map(PathBuf::from)); + let mut clipboard = ProjectClipboard { + sources, + cut: false, + }; + selection_frame( + &context, + &project.0, + 0.8, + vec![ + egui::Event::PointerMoved(one), + modifier_pointer(one, true, egui::Modifiers::NONE), + ], + ); + selection_frame( + &context, + &project.0, + 0.9, + vec![modifier_pointer(one, false, egui::Modifiers::NONE)], + ); + selection_frame( + &context, + &project.0, + 1.1, + vec![ + egui::Event::PointerMoved(two), + modifier_pointer(two, true, egui::Modifiers::SHIFT), + ], + ); + selection_frame( + &context, + &project.0, + 1.2, + vec![modifier_pointer(two, false, egui::Modifiers::SHIFT)], + ); + let (_, paste) = selection_frame( + &context, + &project.0, + 1.3, + vec![ctrl_key(egui::Key::V), egui::Event::Paste(String::new())], + ); + let Some(ProjectFileAction::Paste(destinations)) = paste else { + panic!("Ctrl+V must paste into selected folders"); + }; + assert_eq!(destinations, ["one", "two"].map(PathBuf::from)); + assert!( + paste_project_entries(&project.0, &mut clipboard, &destinations) + .into_iter() + .all(|result| result.is_ok()) + ); + for folder in ["one", "two"] { + for name in ["a.lua", "b.lua", "c.lua"] { + assert_eq!( + std::fs::read_to_string(project.0.join(folder).join(name)).unwrap(), + name + ); + } + } + assert_eq!( + clipboard.sources.len(), + 3, + "copy remains available for repeated paste" + ); + assert!( + paste_project_entries(&project.0, &mut clipboard, &destinations) + .into_iter() + .all(|result| result.is_ok()) + ); + assert!(project.0.join("one/a copy 1.lua").exists()); + assert!(project.0.join("two/a copy 1.lua").exists()); + } + + #[test] + fn explorer_dragging_selected_row_moves_the_whole_highlighted_group() { + let context = egui::Context::default(); + let (output, _) = selection_frame(&context, Path::new(""), 0.0, vec![]); + let a = text_position(&output, "a.lua"); + let b = text_position(&output, "b.lua"); + selection_frame( + &context, + Path::new(""), + 0.1, + vec![ + egui::Event::PointerMoved(a), + modifier_pointer(a, true, egui::Modifiers::NONE), + ], + ); + selection_frame( + &context, + Path::new(""), + 0.2, + vec![modifier_pointer(a, false, egui::Modifiers::NONE)], + ); + selection_frame( + &context, + Path::new(""), + 0.4, + vec![ + egui::Event::PointerMoved(b), + modifier_pointer(b, true, egui::Modifiers::SHIFT), + ], + ); + selection_frame( + &context, + Path::new(""), + 0.5, + vec![modifier_pointer(b, false, egui::Modifiers::SHIFT)], + ); + selection_frame( + &context, + Path::new(""), + 0.8, + vec![ + egui::Event::PointerMoved(a), + modifier_pointer(a, true, egui::Modifiers::NONE), + ], + ); + selection_frame( + &context, + Path::new(""), + 0.9, + vec![egui::Event::PointerMoved(a + egui::vec2(40.0, 0.0))], + ); + let payload = egui::DragAndDrop::payload::(&context).unwrap(); + assert_eq!( + payload.project_paths(), + ["a.lua", "b.lua"].map(PathBuf::from) + ); + } + + #[test] + fn paste_failures_preserve_cut_clipboard_and_process_other_copy_destinations() { + let project = TestProject::new(); + std::fs::write(project.0.join("a.lua"), "a").unwrap(); + std::fs::create_dir(project.0.join("one")).unwrap(); + let mut clipboard = ProjectClipboard { + sources: vec!["a.lua".into()], + cut: true, + }; + assert!( + paste_project_entries(&project.0, &mut clipboard, &["one".into(), "two".into()])[0] + .is_err() + ); + assert!(project.0.join("a.lua").exists()); + assert_eq!(clipboard.sources.len(), 1); + clipboard.cut = false; + let results = paste_project_entries( + &project.0, + &mut clipboard, + &["missing".into(), "one".into()], + ); + assert!(results[0].is_err()); + assert!(results[1].is_ok()); + assert!(project.0.join("one/a.lua").exists()); + } + #[test] + fn shift_hold_can_highlight_rows_without_starting_a_drag() { + let context = egui::Context::default(); + let (output, _) = selection_frame(&context, Path::new(""), 0.0, vec![]); + let a = text_position(&output, "a.lua"); + let c = text_position(&output, "c.lua"); + selection_frame( + &context, + Path::new(""), + 0.1, + vec![ + egui::Event::PointerMoved(a), + modifier_pointer(a, true, egui::Modifiers::SHIFT), + ], + ); + selection_frame( + &context, + Path::new(""), + 0.2, + vec![egui::Event::PointerMoved(c)], + ); + let selection = context + .data_mut(|data| data.get_temp::(explorer_selection_id())) + .unwrap(); + assert!(selection.highlighted.contains(Path::new("a.lua"))); + assert!(selection.highlighted.contains(Path::new("c.lua"))); + assert!(!egui::DragAndDrop::has_any_payload(&context)); + } + + #[test] + fn explorer_shortcuts_are_scoped_to_selection_and_leave_text_editing_alone() { + let context = egui::Context::default(); + let frame = |focused, renaming, can_edit| { + context.data_mut(|data| { + data.insert_temp( + explorer_selection_id(), + ExplorerSelection { + highlighted: [PathBuf::from("a.lua")].into(), + focused, + renaming, + ..Default::default() + }, + ) + }); + let mut action = None; + let mut output = context.run_ui( + egui::RawInput { + events: vec![egui::Event::Copy], + ..Default::default() + }, + |ui| { + action = explorer_keyboard_action(ui, Path::new(""), can_edit); + }, + ); + output.textures_delta.clear(); + action + }; + assert!(frame(false, false, true).is_none()); + assert!(frame(true, true, true).is_none()); + assert!(frame(true, false, false).is_none()); + assert!(matches!( + frame(true, false, true), + Some(ProjectFileAction::Copy(_)) + )); + } + + #[test] + fn double_click_rename_commits_enter_text() { + let context = egui::Context::default(); + let (_, rect, _) = row_frame(&context, 0.0, vec![]); + let position = rect.center(); + for (time, pressed) in [(0.1, true), (0.15, false), (0.2, true), (0.25, false)] { + row_frame( + &context, + time, + vec![ + egui::Event::PointerMoved(position), + pointer(position, egui::PointerButton::Primary, pressed), + ], + ); + } + row_frame(&context, 0.3, vec![]); + row_frame( + &context, + 0.4, + vec![ + egui::Event::ModifiersChanged(egui::Modifiers::CTRL | egui::Modifiers::COMMAND), + ctrl_key(egui::Key::A), + egui::Event::Text("renamed.lua".into()), + ], + ); + let (_, _, action) = row_frame( + &context, + 0.5, + vec![ + egui::Event::ModifiersChanged(egui::Modifiers::NONE), + egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }, + ], + ); + assert!( + matches!(action.as_ref(), Some(ProjectFileAction::Rename(path, name)) if path == Path::new("script.lua") && name == "renamed.lua"), + "Rename action: {action:?}" + ); + } } diff --git a/Engine/docs/EXPLORER.md b/Engine/docs/EXPLORER.md index d87b4a68..ae375341 100644 --- a/Engine/docs/EXPLORER.md +++ b/Engine/docs/EXPLORER.md @@ -6,22 +6,28 @@ Prerequisites: a writable project and an existing asset file. Stop Play before r 1. Copy the model and its companion files into the project with your operating system's file manager. Keep glTF buffers/textures and OBJ dependencies in their required relative locations. 2. Find the model in Explorer. The viewport accepts `.obj`, `.gltf`, `.glb` and `.fbx` model drops. -3. Drag the file into the authoring viewport. The editor imports it, creates/reads its `.rmeta` sidecar and uses its Asset ID for scene instances. +3. Click the file once to highlight it, then press and hold on its highlighted name and drag it into the authoring viewport. The editor imports it, creates/reads its `.rmeta` sidecar and uses its Asset ID for scene instances. 4. Inspect the created object, save the scene and check Console for import errors. Expected result: an imported model appears as a scene object. A recognised extension alone does not guarantee that every file variant is supported. Keep the original source and metadata together; regenerating metadata may change identity. ## Files and scripts -The project separates assets, scenes, scripts, `ui/` Web assets, and configuration. **Programming β†’ Object Component Script** creates and registers a script for the selected object; global and scene startup scopes also exist. Existing registered scripts can be attached through **Programming β†’ Attach Existing Script** or Inspector. Double-clicking a script opens the external editor. Right-click (or two-finger click on a trackpad) offers **Set As β†’ Global Startup Script**, **Scene Startup Script**, or **Object Component Script**. Select the target scene object first to enable Object Component Script. See [the scripting guides](/docs/scripting/lua) for each language's setup, including Luau registration and optional external runtimes. +The project separates assets, scenes, scripts, `ui/` Web assets, and configuration. **Programming β†’ Object Component Script** creates and registers a script for the selected object; global and scene startup scopes also exist. Existing registered scripts can be attached through **Programming β†’ Attach Existing Script** or Inspector. Right-click a script and choose **Open** to launch the external editor. Right-click (or two-finger click on a trackpad) offers **Set As β†’ Global Startup Script**, **Scene Startup Script**, or **Object Component Script**. Select the target scene object first to enable Object Component Script. See [the scripting guides](/docs/scripting/lua) for each language's setup, including Luau registration and optional external runtimes. ## Rename, move, copy, cut and delete -Hovering over a row does not start a move. Press and hold the primary mouse button on the item name, then drag to another project folder or the root. A folder's arrow expands or collapses it; dragging its name moves only that folder, and dragging a child moves the child. +Click an Explorer file or folder name once to highlight it. Double-click the name to edit it inline. Press **Enter** or click elsewhere to save; **Escape** cancels. Use a single filename, including its extension. Empty names, path separators and existing names are rejected; Console explains the failure. Scene-tab selection and rename controls keep their existing behavior. -Click a file or folder name once to edit it inline. The field appears after the short double-click window so double-click can still open a file. Press **Enter** or click elsewhere to save; **Escape** cancels. Use a single filename, including its extension. Empty names, path separators and existing names are rejected; Console explains the failure. +**Shift-click** adds files or folders to the selection. Holding Shift and the primary mouse button while moving over names also adds those items. **Ctrl-click** highlights the visible range between the last highlighted item and the new item, including both ends. The range follows the tree's displayed order and skips collapsed/filtered entries. Ctrl+Shift adds that range to the existing selection. -Right-click an item, or use a two-finger trackpad click, for **Copy**, **Cut**, **Paste** and **Delete**. Copy/Cut stores an item in the editor clipboard; Paste on a folder uses that folder, and Paste on a file uses its containing folder. Right-click empty tree space for **Paste at Project Root**. Copies get a numbered name when needed. Cut leaves the source in place until a successful Paste. Delete moves the entry into `.rustic/trash//`; recover it with your operating system's file manager. The engine-owned `.rustic` directory is hidden from Explorer, including generated files and recovery trash. Scene Undo does not undo filesystem actions. +Hovering does not start a move. To drag, the item must already be highlighted before you press and hold on its name. Pressing and holding on an unhighlighted item highlights it; that press cannot start a drag. Release, then press and drag the highlighted name. Dragging a highlighted item moves the highlighted group to the destination folder or project root. Shift/Ctrl selection gestures do not start drags. A folder's arrow expands or collapses it. A selected folder and its selected descendants are moved/copied as one subtree. + +Press **Ctrl+C** to copy highlighted items. Highlight a destination folder and press **Ctrl+V** to paste. If several folders are highlighted, each receives a copy of every copied item. This includes nested selected destinations. Without a selected folder, Paste uses the last selected file's containing folder, or the project root when the selection is empty. The editor also accepts Command+C/V on macOS. Shortcuts apply while Explorer has focus; a focused rename/filter text field uses normal text copy/paste. Copies remain in the clipboard for repeated Paste and get numbered names on collisions. + +Right-click an item, or use a two-finger trackpad click, for **Copy**, **Cut**, **Paste**, **Delete**, and file **Open**. Copy/Cut/Delete uses the highlighted group when the clicked item is highlighted; otherwise the clicked item becomes the selection. Menu Paste on a folder uses that folder, and Paste on a file uses its containing folder. Right-click empty tree space for **Paste at Project Root**. Cut leaves sources in place until a successful Paste and accepts a single destination folder; use Copy for multiple destinations. A batch reports individual failures in Console and refreshes completed operations. Delete moves entries into `.rustic/trash//`; recover them with your operating system's file manager. The engine-owned `.rustic` directory is hidden from Explorer, including generated files and recovery trash. Scene Undo does not undo filesystem actions. + +Select one model or script at a time for viewport drops; grouped drags organize files within Explorer. These controls require a writable project and stopped Play. Moves and renames keep a file's `.rmeta` sidecar alongside it. Copies omit `.rmeta` so a new asset identity can be assigned on import or script registration. **Manifest paths, scene references and companion-file links are not rewritten.** Update affected references before Play; copied scripts must be registered using the script scope commands. A folder copy does not rewrite references within its files. Operations reject traversal, symbolic links, missing destinations, move collisions and moving/copying a folder into itself. A dangling destination symlink or an existing destination `.rmeta` also blocks a move or rename, even when the source has no metadata; inspect the destination with your file manager and resolve the conflict before retrying. Do not reuse orphaned metadata for an unrelated asset.