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:
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user