Editor: native folder picker for New/Open Project

Stage-10 editor-UX follow-up ("typing the path by hand is very hard to
use"). Both project dialogs gain a Browse… button that opens the native
folder picker via rfd's xdg-portal backend — pure Rust, one build works
on Wayland and X11 through xdg-desktop-portal. The dialog runs on a
helper thread reporting over an mpsc channel (polled once per frame in
Shell::build), so the editor keeps rendering while the picker is up;
one pick at a time, and the typed path field remains as a fallback for
portal-less environments.

GUI piece — needs an eye-check (on both Wayland and Xorg) before
promotion to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Homer
2026-07-10 20:47:12 +02:00
parent 608898411a
commit 3c59faa506
3 changed files with 118 additions and 7 deletions
+4
View File
@@ -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"
+3
View File
@@ -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
+111 -7
View File
@@ -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<Option<PathBuf>>)>,
/// 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<PathBuf>) {
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) {