diff --git a/editor/Cargo.toml b/editor/Cargo.toml index 857a96a..a91123f 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -25,6 +25,9 @@ egui_dock.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 +# Editor-owned settings sections (e.g. the External Editor preference) derive +# their own Serialize/Deserialize for the Settings store. +serde.workspace = true # PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells, # REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty` diff --git a/editor/src/shell.rs b/editor/src/shell.rs index 6528bae..010cdb1 100644 --- a/editor/src/shell.rs +++ b/editor/src/shell.rs @@ -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::(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 ` ` (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::>(&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 { + /// 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) -> Option { 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 + /// ` `; + /// 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::(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 = 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 { + 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, diff --git a/editor/src/state.rs b/editor/src/state.rs index 51a4d38..1f13500 100644 --- a/editor/src/state.rs +++ b/editor/src/state.rs @@ -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 ` ` (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::(bindings::SETTINGS_SECTION); + settings.register::(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::(restored) .is_some()); } + + #[test] + fn external_editor_section_is_registered_and_defaults_to_auto() { + let state = EditorState::new(); + let prefs = state + .settings + .get::(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)" + ); + } }