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:
@@ -45,6 +45,10 @@ gltf = { version = "1.4", features = ["utils"] }
|
|||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
# Editor UI (egui — integrated into oxide-editor only)
|
# 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 = "0.34"
|
||||||
egui-wgpu = "0.34"
|
egui-wgpu = "0.34"
|
||||||
egui-winit = "0.34"
|
egui-winit = "0.34"
|
||||||
|
|||||||
@@ -680,6 +680,9 @@ console)**. Each is GUI → `dev` + eye-check.
|
|||||||
path, which the maintainer confirms "is very hard to use". Wanted: a native
|
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
|
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.
|
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
|
- **"New Script" button on the `Script` component inspector** (Stage 10
|
||||||
follow-up). Create a new `.rhai` from a template into `assets/scripts/` and
|
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
|
auto-assign it to the component, without leaving the editor — today scripts
|
||||||
|
|||||||
@@ -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
|
so a section round-trips through the project file without this module knowing the
|
||||||
section's shape.
|
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
|
## Recent projects
|
||||||
|
|
||||||
`RecentProjects` is a small most-recently-used list, persisted globally as an
|
`RecentProjects` is a small most-recently-used list, persisted globally as an
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ egui.workspace = true
|
|||||||
egui-wgpu.workspace = true
|
egui-wgpu.workspace = true
|
||||||
egui-winit.workspace = true
|
egui-winit.workspace = true
|
||||||
egui_dock.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`
|
# Editor preferences file I/O reads/writes the same RON shape `Settings`
|
||||||
# exports; the engine already pulls `ron` in, the editor now does too.
|
# exports; the engine already pulls `ron` in, the editor now does too.
|
||||||
ron.workspace = true
|
ron.workspace = true
|
||||||
|
|||||||
+109
-5
@@ -104,6 +104,15 @@ impl PanelKind {
|
|||||||
/// despawn / reparent). Tracked here only because the hierarchy panel builds
|
/// despawn / reparent). Tracked here only because the hierarchy panel builds
|
||||||
/// these while the UI closure runs and applies them after, the same idiom
|
/// these while the UI closure runs and applies them after, the same idiom
|
||||||
/// the Stage-5 main.rs used.
|
/// 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 {
|
enum PendingAction {
|
||||||
/// Spawn a named prefab as a root entity (data-driven add-menu). The
|
/// Spawn a named prefab as a root entity (data-driven add-menu). The
|
||||||
/// `Empty` prefab is a bare node; the rest carry components.
|
/// `Empty` prefab is a bare node; the rest carry components.
|
||||||
@@ -523,6 +532,11 @@ pub struct Shell {
|
|||||||
new_project_name: String,
|
new_project_name: String,
|
||||||
/// Text entered in the "Open Project" modal.
|
/// Text entered in the "Open Project" modal.
|
||||||
open_project_path: String,
|
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.
|
/// Text entered in the "Groups" editor's "add group" field.
|
||||||
new_group_name: String,
|
new_group_name: String,
|
||||||
/// Text entered in the Script inspector's "New Script" name field.
|
/// Text entered in the Script inspector's "New Script" name field.
|
||||||
@@ -625,6 +639,7 @@ impl Shell {
|
|||||||
new_project_path: String::new(),
|
new_project_path: String::new(),
|
||||||
new_project_name: String::new(),
|
new_project_name: String::new(),
|
||||||
open_project_path: String::new(),
|
open_project_path: String::new(),
|
||||||
|
folder_pick: None,
|
||||||
new_group_name: String::new(),
|
new_group_name: String::new(),
|
||||||
new_script_name: String::new(),
|
new_script_name: String::new(),
|
||||||
terminal_input: String::new(),
|
terminal_input: String::new(),
|
||||||
@@ -1103,6 +1118,9 @@ impl Shell {
|
|||||||
/// closure more than once during layout.
|
/// closure more than once during layout.
|
||||||
pub fn build(&mut self, ui: &mut egui::Ui) {
|
pub fn build(&mut self, ui: &mut egui::Ui) {
|
||||||
self.pending.clear();
|
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.menu_bar(ui);
|
||||||
self.play_toolbar(ui);
|
self.play_toolbar(ui);
|
||||||
@@ -1890,22 +1908,80 @@ impl Shell {
|
|||||||
self.show_group_editor = open;
|
self.show_group_editor = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-app New Project dialog: a path field, a name field, and Create / Cancel.
|
/// Launches the native folder picker on a helper thread, reporting into
|
||||||
/// A native OS file dialog (`rfd` or similar) is a piece-6 polish item — for
|
/// [`Shell::folder_pick`]. One pick at a time; the Browse buttons are
|
||||||
/// now the path is typed, which is enough to exercise the flow end-to-end and
|
/// disabled while one is up. `rfd`'s portal backend serves both Wayland
|
||||||
/// keeps the editor dependency-light.
|
/// 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) {
|
fn new_project_window(&mut self, ui: &mut egui::Ui) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let mut open = self.show_new_project;
|
let mut open = self.show_new_project;
|
||||||
let mut create_now = false;
|
let mut create_now = false;
|
||||||
let mut cancel_now = false;
|
let mut cancel_now = false;
|
||||||
|
let mut browse_now = false;
|
||||||
|
let picking = self.folder_pick.is_some();
|
||||||
egui::Window::new("New Project")
|
egui::Window::new("New Project")
|
||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.default_size([520.0, 160.0])
|
.default_size([520.0, 160.0])
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.show(&ctx, |ui| {
|
.show(&ctx, |ui| {
|
||||||
ui.label("Project folder");
|
ui.label("Project folder");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
ui.text_edit_singleline(&mut self.new_project_path);
|
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.label("Display name");
|
||||||
ui.text_edit_singleline(&mut self.new_project_name);
|
ui.text_edit_singleline(&mut self.new_project_name);
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
@@ -1924,6 +2000,12 @@ impl Shell {
|
|||||||
project.oxide.",
|
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 {
|
if create_now {
|
||||||
let path = PathBuf::from(self.new_project_path.trim());
|
let path = PathBuf::from(self.new_project_path.trim());
|
||||||
let name = self.new_project_name.trim().to_owned();
|
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) {
|
fn open_project_window(&mut self, ui: &mut egui::Ui) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let mut open = self.show_open_project;
|
let mut open = self.show_open_project;
|
||||||
let mut open_now = false;
|
let mut open_now = false;
|
||||||
let mut cancel_now = false;
|
let mut cancel_now = false;
|
||||||
|
let mut browse_now = false;
|
||||||
|
let picking = self.folder_pick.is_some();
|
||||||
egui::Window::new("Open Project")
|
egui::Window::new("Open Project")
|
||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.default_size([520.0, 140.0])
|
.default_size([520.0, 140.0])
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.show(&ctx, |ui| {
|
.show(&ctx, |ui| {
|
||||||
ui.label("Project folder (or path to project.oxide)");
|
ui.label("Project folder (or path to project.oxide)");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
ui.text_edit_singleline(&mut self.open_project_path);
|
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.add_space(6.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let valid = !self.open_project_path.trim().is_empty();
|
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 {
|
if open_now {
|
||||||
let path = PathBuf::from(self.open_project_path.trim());
|
let path = PathBuf::from(self.open_project_path.trim());
|
||||||
match self.open_project(path) {
|
match self.open_project(path) {
|
||||||
|
|||||||
Reference in New Issue
Block a user