Editor: open scripts in an external editor
Stage-10 editor-UX follow-up, second half of in-editor script authoring: - "✏ Edit" button on the Script inspector section (enabled once a source is assigned) and double-click on any script in the Project panel open the .rhai in the user's editor. Non-script assets double-click through xdg-open. - Resolution order: the new External Editor preference command (spawned detached as `<command> <file>`, flags allowed), else $VISUAL/$EDITOR in a new Terminal-panel tab (TUI editors work in-editor via the existing PTY widget), else xdg-open. - New `editor.external_editor` settings section (ExternalEditorPrefs) registered by EditorState, persisted with the preferences file, with a rich row in the Preferences window; registration covered by a unit test. Editor crate now depends on serde directly for its own sections. Saved edits flow back through the file watcher's live reload, including into a playing scene. GUI piece — needs an eye-check before promotion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+169
-13
@@ -52,7 +52,7 @@ use crate::command::{Command, CommandStack};
|
||||
use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd};
|
||||
use crate::extension::EditorExtensions;
|
||||
use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis};
|
||||
use crate::state::{EditorState, PlayState};
|
||||
use crate::state::{EditorState, ExternalEditorPrefs, PlayState, EXTERNAL_EDITOR_SECTION};
|
||||
use oxide_engine::math::{Mat4, Vec4};
|
||||
use oxide_engine::reflect::FieldInfo;
|
||||
|
||||
@@ -1463,6 +1463,8 @@ impl Shell {
|
||||
// section editor lands.
|
||||
if name == crate::bindings::SETTINGS_SECTION {
|
||||
ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui));
|
||||
} else if name == EXTERNAL_EDITOR_SECTION {
|
||||
ui.collapsing("External Editor", |ui| self.external_editor_page(ui));
|
||||
} else {
|
||||
let ron = self.state.settings.section_ron(name);
|
||||
ui.collapsing(name, |ui| match ron {
|
||||
@@ -1486,6 +1488,37 @@ impl Shell {
|
||||
self.show_preferences = open;
|
||||
}
|
||||
|
||||
/// The External Editor preferences page: the command used when opening a
|
||||
/// script from the editor. Edits mark the preferences dirty so the host
|
||||
/// persists them like a binding remap.
|
||||
fn external_editor_page(&mut self, ui: &mut egui::Ui) {
|
||||
let Some(prefs) = self
|
||||
.state
|
||||
.settings
|
||||
.get_mut::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||
else {
|
||||
ui.weak("(section unavailable)");
|
||||
return;
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Open scripts with");
|
||||
if ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut prefs.command)
|
||||
.hint_text("auto — $VISUAL/$EDITOR, else xdg-open"),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
self.bindings_dirty = true;
|
||||
}
|
||||
});
|
||||
ui.weak(
|
||||
"Launched as `<command> <file>` (the command may carry flags, e.g. `code -g`). \
|
||||
Leave empty for auto: $VISUAL/$EDITOR runs in a built-in Terminal tab, \
|
||||
otherwise the file opens via xdg-open.",
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the input-bindings preferences page: every registered editor
|
||||
/// action with its current bindings, a Change/Add/Clear control per
|
||||
/// slot, a per-action "Restore defaults" button, and a global
|
||||
@@ -3371,11 +3404,18 @@ impl<'a> ShellTabViewer<'a> {
|
||||
edits.push((comp.name, row.info.name, new_ron));
|
||||
}
|
||||
}
|
||||
// Stage-10 UX: author a script without leaving the editor. The
|
||||
// assignment goes through `edits` → SetFieldCmd like any field
|
||||
// change, so it is undoable (the file itself stays — harmless).
|
||||
// Stage-10 UX: author and open scripts without leaving the
|
||||
// editor. The assignment goes through `edits` → SetFieldCmd
|
||||
// like any field change, so it is undoable (the created file
|
||||
// itself stays — harmless).
|
||||
if comp.name == "Script" {
|
||||
if let Some(ron) = self.new_script_row(ui) {
|
||||
let source = comp
|
||||
.fields
|
||||
.iter()
|
||||
.find(|r| r.info.name == "source")
|
||||
.and_then(|r| ron::from_str::<Option<AssetUid>>(&r.value).ok())
|
||||
.flatten();
|
||||
if let Some(ron) = self.new_script_row(ui, source) {
|
||||
edits.push((comp.name, "source", ron));
|
||||
}
|
||||
}
|
||||
@@ -3424,14 +3464,16 @@ impl<'a> ShellTabViewer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "New Script" row at the bottom of a `Script` component's section:
|
||||
/// a name field + create button that writes a `.rhai` template into
|
||||
/// The script-tools row at the bottom of a `Script` component's section:
|
||||
/// a name field + "New Script" button that writes a `.rhai` template into
|
||||
/// `assets/scripts/`, registers it in the asset database, and returns the
|
||||
/// RON for the component's `source` field — the caller routes it through
|
||||
/// the normal edit path so the assignment is undoable (undo detaches the
|
||||
/// script; the created file stays, which is harmless). Disabled until a
|
||||
/// project is open. Errors go to the log, i.e. the Console panel.
|
||||
fn new_script_row(&mut self, ui: &mut egui::Ui) -> Option<String> {
|
||||
/// script; the created file stays, which is harmless) — plus an "Edit"
|
||||
/// button opening the currently assigned script (`source`) in the user's
|
||||
/// editor. Disabled until a project is open. Errors go to the log, i.e.
|
||||
/// the Console panel.
|
||||
fn new_script_row(&mut self, ui: &mut egui::Ui, source: Option<AssetUid>) -> Option<String> {
|
||||
let mut created = None;
|
||||
ui.horizontal(|ui| {
|
||||
let open = self.state.asset_db.is_some();
|
||||
@@ -3469,6 +3511,18 @@ impl<'a> ShellTabViewer<'a> {
|
||||
Err(err) => log::warn!("could not create script: {err}"),
|
||||
}
|
||||
}
|
||||
let edit = ui
|
||||
.add_enabled(source.is_some(), egui::Button::new("✏ Edit").small())
|
||||
.on_hover_text(
|
||||
"Open the assigned script in your editor (External Editor \
|
||||
preference, else $VISUAL/$EDITOR in a Terminal tab, else \
|
||||
xdg-open). Saved edits live-reload.",
|
||||
);
|
||||
if edit.clicked() {
|
||||
if let Some(uid) = source {
|
||||
self.open_script_in_editor(uid);
|
||||
}
|
||||
}
|
||||
});
|
||||
if !open {
|
||||
ui.label(egui::RichText::new("(open a project to create scripts)").weak());
|
||||
@@ -3477,6 +3531,59 @@ impl<'a> ShellTabViewer<'a> {
|
||||
created
|
||||
}
|
||||
|
||||
/// Opens the script asset `uid` in the user's editor. Resolution order:
|
||||
///
|
||||
/// 1. the **External Editor** preference command, spawned detached as
|
||||
/// `<command> <file>`;
|
||||
/// 2. `$VISUAL` / `$EDITOR`, run in a new **Terminal-panel tab** (so TUI
|
||||
/// editors like vim/nano work in-editor);
|
||||
/// 3. `xdg-open` (the desktop's default handler).
|
||||
///
|
||||
/// Whichever way, saved edits flow back through the file watcher's live
|
||||
/// reload — including into a playing scene.
|
||||
fn open_script_in_editor(&mut self, uid: AssetUid) {
|
||||
let Some(abs) = self
|
||||
.state
|
||||
.asset_db
|
||||
.as_ref()
|
||||
.and_then(|db| db.absolute_path(uid))
|
||||
else {
|
||||
log::warn!("script asset {uid:?} has no file to open");
|
||||
return;
|
||||
};
|
||||
let configured = self
|
||||
.state
|
||||
.settings
|
||||
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||
.map(|p| p.command.trim().to_owned())
|
||||
.unwrap_or_default();
|
||||
if !configured.is_empty() {
|
||||
spawn_detached(&configured, &abs);
|
||||
return;
|
||||
}
|
||||
let not_blank = |v: String| (!v.trim().is_empty()).then_some(v);
|
||||
let terminal_editor = std::env::var("VISUAL")
|
||||
.ok()
|
||||
.and_then(not_blank)
|
||||
.or_else(|| std::env::var("EDITOR").ok().and_then(not_blank));
|
||||
match terminal_editor {
|
||||
Some(editor) => {
|
||||
let leaf = abs
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "script".to_owned());
|
||||
// $EDITOR may carry flags — whitespace-split it like the
|
||||
// configured command, then append the file path.
|
||||
let mut parts = editor.split_whitespace().map(str::to_owned);
|
||||
let program = parts.next().unwrap_or_else(|| "vi".to_owned());
|
||||
let owned: Vec<String> = parts.chain([abs.display().to_string()]).collect();
|
||||
let args: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
self.launch_terminal(&format!("edit {leaf}"), &program, &args);
|
||||
}
|
||||
None => spawn_detached("xdg-open", &abs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one reflected field as a typed widget chosen from its
|
||||
/// `type_name`, returning the field's new RON if the user changed it.
|
||||
/// Unknown types fall back to an editable RON text box, so the inspector is
|
||||
@@ -3790,10 +3897,29 @@ impl<'a> ShellTabViewer<'a> {
|
||||
}
|
||||
}
|
||||
});
|
||||
match &self.state.asset_db {
|
||||
let open_asset = match &self.state.asset_db {
|
||||
Some(db) => asset_browser(ui, db),
|
||||
None => {
|
||||
ui.weak("(asset database unavailable)");
|
||||
None
|
||||
}
|
||||
};
|
||||
// Double-click opens the asset: scripts through the external-editor
|
||||
// flow (preference / $VISUAL/$EDITOR terminal tab), everything else
|
||||
// via the desktop's default handler.
|
||||
if let Some(uid) = open_asset {
|
||||
let (kind, abs) = {
|
||||
let db = self
|
||||
.state
|
||||
.asset_db
|
||||
.as_ref()
|
||||
.expect("open_asset came from it");
|
||||
(db.entry(uid).map(|e| e.kind), db.absolute_path(uid))
|
||||
};
|
||||
if kind == Some(AssetKind::Script) {
|
||||
self.open_script_in_editor(uid);
|
||||
} else if let Some(abs) = abs {
|
||||
spawn_detached("xdg-open", &abs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4383,6 +4509,29 @@ impl<'a> ShellTabViewer<'a> {
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
|
||||
/// Spawns `command` (whitespace-split, so it may carry flags) with `file`
|
||||
/// appended as the final argument, detached from the editor; a reaper thread
|
||||
/// waits on the child so it never lingers as a zombie. Failures are logged
|
||||
/// (→ the Console), never fatal.
|
||||
fn spawn_detached(command: &str, file: &std::path::Path) {
|
||||
let mut parts = command.split_whitespace();
|
||||
let Some(program) = parts.next() else {
|
||||
return;
|
||||
};
|
||||
match std::process::Command::new(program)
|
||||
.args(parts)
|
||||
.arg(file)
|
||||
.spawn()
|
||||
{
|
||||
Ok(mut child) => {
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
Err(err) => log::warn!("could not launch {command}: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the rendered control changed any axis this frame.
|
||||
/// Projects a world-space point through `view_proj` to screen pixels
|
||||
/// inside `tab_rect` (egui logical points). Returns `None` when the point
|
||||
@@ -4861,7 +5010,8 @@ fn build_terminal_job(
|
||||
job
|
||||
}
|
||||
|
||||
fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) {
|
||||
fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) -> Option<AssetUid> {
|
||||
let mut open = None;
|
||||
for kind in AssetKind::TYPED {
|
||||
let mut entries: Vec<&AssetEntry> = db.entries_of_kind(kind).collect();
|
||||
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
@@ -4875,10 +5025,16 @@ fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) {
|
||||
for entry in entries {
|
||||
// Show the leaf name; the full relative path on hover.
|
||||
let leaf = entry.path.rsplit('/').next().unwrap_or(&entry.path);
|
||||
ui.label(leaf).on_hover_text(&entry.path);
|
||||
let resp = ui
|
||||
.add(egui::Label::new(leaf).sense(egui::Sense::click()))
|
||||
.on_hover_text(format!("{}\n(double-click to open)", entry.path));
|
||||
if resp.double_clicked() {
|
||||
open = Some(entry.uid);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// A short label for a widget in the tree / property header: its id if set,
|
||||
|
||||
@@ -209,6 +209,20 @@ impl Default for UiDoc {
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings section holding [`ExternalEditorPrefs`].
|
||||
pub const EXTERNAL_EDITOR_SECTION: &str = "editor.external_editor";
|
||||
|
||||
/// Preferences for opening a script (or other text asset) in an editor —
|
||||
/// registered as the [`EXTERNAL_EDITOR_SECTION`] settings section, editable in
|
||||
/// Preferences, persisted to `~/.config/oxide/editor.ron` like the bindings.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExternalEditorPrefs {
|
||||
/// Command to launch, invoked as `<command> <file>` (whitespace-split; may
|
||||
/// carry its own flags, e.g. `"code -g"`). **Empty (the default) = auto**:
|
||||
/// run `$VISUAL`/`$EDITOR` in an editor Terminal tab, else `xdg-open`.
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// A blank state with an empty scene, no open project, and the editor's
|
||||
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
|
||||
@@ -224,6 +238,7 @@ impl EditorState {
|
||||
bindings::register_defaults(&mut actions);
|
||||
let mut settings = Settings::new();
|
||||
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
|
||||
settings.register::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION);
|
||||
let mut registry = TypeRegistry::new();
|
||||
register_builtin_types(&mut registry);
|
||||
// Seed a small, generally-useful set of named layers (besides the
|
||||
@@ -590,4 +605,17 @@ mod tests {
|
||||
.get::<oxide_physics::RigidBody>(restored)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_section_is_registered_and_defaults_to_auto() {
|
||||
let state = EditorState::new();
|
||||
let prefs = state
|
||||
.settings
|
||||
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||
.expect("external-editor settings section must be registered");
|
||||
assert!(
|
||||
prefs.command.is_empty(),
|
||||
"default is empty = auto ($VISUAL/$EDITOR terminal tab, else xdg-open)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user