diff --git a/Cargo.toml b/Cargo.toml index 6a3d646..61b39a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,10 @@ gltf = { version = "1.4", features = ["utils"] } ab_glyph = "0.2" # Editor UI (egui — integrated into oxide-editor only) +# Native file/folder dialogs (New/Open Project). The default `xdg-portal` +# backend is pure Rust and talks to xdg-desktop-portal over D-Bus, so one +# build serves both Wayland and X11 with no GTK link-time dependency. +rfd = "0.15" egui = "0.34" egui-wgpu = "0.34" egui-winit = "0.34" diff --git a/PLAN.md b/PLAN.md index 182cf80..2eca892 100644 --- a/PLAN.md +++ b/PLAN.md @@ -680,6 +680,9 @@ console)**. Each is GUI → `dev` + eye-check. path, which the maintainer confirms "is very hard to use". Wanted: a native file/folder picker (a `rfd`-style dialog, working on Wayland + X11) and/or a better-designed in-editor chooser, instead of a free-typed path field. + **🚧 On `dev` (2026-07-10), awaiting eye-check**: Browse… buttons on both + dialogs → native folder picker (`rfd`/xdg-portal, Wayland + X11) on a helper + thread; the typed field remains as fallback. - **"New Script" button on the `Script` component inspector** (Stage 10 follow-up). Create a new `.rhai` from a template into `assets/scripts/` and auto-assign it to the component, without leaving the editor — today scripts diff --git a/docs/projects.md b/docs/projects.md index 7c7321e..14fa85d 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -58,6 +58,17 @@ The typed settings framework serializes its sections to and from these strings, so a section round-trips through the project file without this module knowing the section's shape. +## The editor's New / Open dialogs + +The editor's **File ▸ New Project… / Open Project…** dialogs wrap +`Project::create`/`Project::open`. Each path field has a **Browse…** button +opening the **native folder picker** (`rfd` with the `xdg-portal` backend — one +pure-Rust build serves both Wayland and X11 through `xdg-desktop-portal`). The +dialog runs on a helper thread so the editor keeps rendering while it is up; +the picked folder lands back in the field, which stays hand-editable — if no +portal service is running the picker simply doesn't appear and the typed path +still works. + ## Recent projects `RecentProjects` is a small most-recently-used list, persisted globally as an diff --git a/editor/Cargo.toml b/editor/Cargo.toml index a91123f..fdae58e 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -22,6 +22,9 @@ egui.workspace = true egui-wgpu.workspace = true egui-winit.workspace = true egui_dock.workspace = true +# Native folder picker for New/Open Project, run on a helper thread so the +# UI keeps redrawing while the dialog is up (see Shell::poll_folder_pick). +rfd.workspace = true # Editor preferences file I/O reads/writes the same RON shape `Settings` # exports; the engine already pulls `ron` in, the editor now does too. ron.workspace = true diff --git a/editor/src/shell.rs b/editor/src/shell.rs index 010cdb1..872c7b0 100644 --- a/editor/src/shell.rs +++ b/editor/src/shell.rs @@ -104,6 +104,15 @@ impl PanelKind { /// despawn / reparent). Tracked here only because the hierarchy panel builds /// these while the UI closure runs and applies them after, the same idiom /// the Stage-5 main.rs used. +/// Which path field a finished native folder pick fills in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FolderPickTarget { + /// The "New Project" dialog's project-folder field. + NewProject, + /// The "Open Project" dialog's path field. + OpenProject, +} + enum PendingAction { /// Spawn a named prefab as a root entity (data-driven add-menu). The /// `Empty` prefab is a bare node; the rest carry components. @@ -523,6 +532,11 @@ pub struct Shell { new_project_name: String, /// Text entered in the "Open Project" modal. open_project_path: String, + /// An in-flight native folder pick (Browse… in New/Open Project): which + /// field the result lands in + the channel the dialog thread reports on. + /// The dialog runs on its own thread so the UI keeps redrawing; `None` + /// means no pick is up (and gates the Browse buttons to one at a time). + folder_pick: Option<(FolderPickTarget, std::sync::mpsc::Receiver>)>, /// Text entered in the "Groups" editor's "add group" field. new_group_name: String, /// Text entered in the Script inspector's "New Script" name field. @@ -625,6 +639,7 @@ impl Shell { new_project_path: String::new(), new_project_name: String::new(), open_project_path: String::new(), + folder_pick: None, new_group_name: String::new(), new_script_name: String::new(), terminal_input: String::new(), @@ -1103,6 +1118,9 @@ impl Shell { /// closure more than once during layout. pub fn build(&mut self, ui: &mut egui::Ui) { self.pending.clear(); + // Collect a native folder pick before the project dialogs render, so + // a chosen path appears in its field the same frame. + self.poll_folder_pick(); self.menu_bar(ui); self.play_toolbar(ui); @@ -1890,22 +1908,80 @@ impl Shell { self.show_group_editor = open; } - /// In-app New Project dialog: a path field, a name field, and Create / Cancel. - /// A native OS file dialog (`rfd` or similar) is a piece-6 polish item — for - /// now the path is typed, which is enough to exercise the flow end-to-end and - /// keeps the editor dependency-light. + /// Launches the native folder picker on a helper thread, reporting into + /// [`Shell::folder_pick`]. One pick at a time; the Browse buttons are + /// disabled while one is up. `rfd`'s portal backend serves both Wayland + /// and X11; if no portal service is running the thread reports `None` + /// (same as cancel) and the typed path field still works. + fn launch_folder_pick(&mut self, target: FolderPickTarget, start_dir: Option) { + if self.folder_pick.is_some() { + return; + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut dialog = rfd::FileDialog::new().set_title("Choose a project folder"); + if let Some(dir) = start_dir.filter(|d| d.is_dir()) { + dialog = dialog.set_directory(dir); + } + // A dropped receiver (shell already gone) is fine to ignore. + let _ = tx.send(dialog.pick_folder()); + }); + self.folder_pick = Some((target, rx)); + } + + /// Collects a finished native folder pick into its target path field. + /// Called once per frame from [`Shell::build`]; does nothing while the + /// dialog is still up (the thread hasn't reported). + fn poll_folder_pick(&mut self) { + use std::sync::mpsc::TryRecvError; + let Some((target, rx)) = self.folder_pick.take() else { + return; + }; + match rx.try_recv() { + // Picked: fill the field the dialog was opened for. + Ok(Some(path)) => { + let text = path.display().to_string(); + match target { + FolderPickTarget::NewProject => self.new_project_path = text, + FolderPickTarget::OpenProject => self.open_project_path = text, + } + } + // Cancelled (or the portal is unavailable): keep the typed text. + Ok(None) | Err(TryRecvError::Disconnected) => {} + // Still up: put it back and check again next frame. + Err(TryRecvError::Empty) => self.folder_pick = Some((target, rx)), + } + } + + /// In-app New Project dialog: a folder field with a native Browse… picker, + /// a name field, and Create / Cancel. The path can still be typed by hand + /// (the picker needs a running xdg-desktop-portal to appear). fn new_project_window(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let mut open = self.show_new_project; let mut create_now = false; let mut cancel_now = false; + let mut browse_now = false; + let picking = self.folder_pick.is_some(); egui::Window::new("New Project") .open(&mut open) .default_size([520.0, 160.0]) .resizable(true) .show(&ctx, |ui| { ui.label("Project folder"); - ui.text_edit_singleline(&mut self.new_project_path); + ui.horizontal(|ui| { + ui.text_edit_singleline(&mut self.new_project_path); + if ui + .add_enabled(!picking, egui::Button::new("Browse…")) + .on_hover_text( + "Pick the project folder with the system dialog \ + (it can create a new folder too)", + ) + .clicked() + { + browse_now = true; + } + }); ui.label("Display name"); ui.text_edit_singleline(&mut self.new_project_name); ui.add_space(6.0); @@ -1924,6 +2000,12 @@ impl Shell { project.oxide.", ); }); + if browse_now { + let start = Some(PathBuf::from(self.new_project_path.trim())) + .filter(|p| p.is_dir()) + .or_else(|| std::env::var_os("HOME").map(PathBuf::from)); + self.launch_folder_pick(FolderPickTarget::NewProject, start); + } if create_now { let path = PathBuf::from(self.new_project_path.trim()); let name = self.new_project_name.trim().to_owned(); @@ -1943,19 +2025,31 @@ impl Shell { } } - /// In-app Open Project dialog: one path field, Open / Cancel. + /// In-app Open Project dialog: a path field with a native Browse… picker, + /// Open / Cancel. fn open_project_window(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); let mut open = self.show_open_project; let mut open_now = false; let mut cancel_now = false; + let mut browse_now = false; + let picking = self.folder_pick.is_some(); egui::Window::new("Open Project") .open(&mut open) .default_size([520.0, 140.0]) .resizable(true) .show(&ctx, |ui| { ui.label("Project folder (or path to project.oxide)"); - ui.text_edit_singleline(&mut self.open_project_path); + ui.horizontal(|ui| { + ui.text_edit_singleline(&mut self.open_project_path); + if ui + .add_enabled(!picking, egui::Button::new("Browse…")) + .on_hover_text("Pick the project folder with the system dialog") + .clicked() + { + browse_now = true; + } + }); ui.add_space(6.0); ui.horizontal(|ui| { let valid = !self.open_project_path.trim().is_empty(); @@ -1967,6 +2061,16 @@ impl Shell { } }); }); + if browse_now { + let start = Some(PathBuf::from(self.open_project_path.trim())) + .filter(|p| p.is_dir()) + .or_else(|| { + let first = self.state.recent.entries().first()?; + Some(first.parent()?.to_path_buf()) + }) + .or_else(|| std::env::var_os("HOME").map(PathBuf::from)); + self.launch_folder_pick(FolderPickTarget::OpenProject, start); + } if open_now { let path = PathBuf::from(self.open_project_path.trim()); match self.open_project(path) {