diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c3e8834..7d1d024 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -16,6 +16,9 @@ use crate::{ModelApp, UiState}; /// `ui.render`'s image size when none is given. const RENDER_DEFAULT: [usize; 2] = [1280, 800]; +/// Most files one `ui.drop` drops, and the longest path it takes. +const MAX_DROP: usize = 16; +const MAX_DROP_PATH: usize = 4096; pub struct ControlRequest { pub method: String, @@ -379,6 +382,20 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> app.synthetic.push(egui::Event::Text(s("text").unwrap_or("").to_string())); ok(Value::Null) } + "ui.drop" => { + // Files dropped on the window: the next frame takes them with the window system's + // drops, and they open as a picked file opens (the native picker cannot be driven). + let paths: Option> = + p.get("paths").and_then(Value::as_array).and_then(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect()); + match paths { + Some(paths) if !paths.is_empty() && paths.len() <= MAX_DROP && paths.iter().all(|p| !p.is_empty() && p.len() <= MAX_DROP_PATH) => { + let n = paths.len(); + app.synthetic_drops.extend(paths); + ok(json!({"dropped": n})) + } + _ => err(format!("`paths` must be a list of 1 to {MAX_DROP} file paths")), + } + } "ui.worldToScreen" => { let pts: Vec = p .get("points") @@ -658,6 +675,29 @@ mod tests { assert!(app.dialog.is_none() && app.session.model.node(3).is_some()); } + /// `ui.drop` stands in for the window system: the next frame opens the files as a drop does. + #[test] + fn dropped_files_open_as_a_drop_does() { + let dir = std::env::temp_dir().join(format!("fieldcraft-drop-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let design = dir.join("plate.solidcraft"); + std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap(); + let mut app = ModelApp::new(Session::default()); + let r = call(&mut app, "ui.drop", json!({"paths": [design.to_string_lossy()]})); + assert_eq!(r["result"]["dropped"], 1, "{r}"); + assert!(app.session.design.is_none(), "nothing opens before the next frame"); + app.logic(&egui::Context::default()); + assert_eq!(app.busy(), Some("Opening plate.solidcraft")); + app.wait_loaded(); + assert_eq!(app.session.design.as_ref().map(|d| d.bodies.len()), Some(1)); + assert_eq!(app.status.as_ref().map(|s| s.text.as_str()), Some("Opened plate.solidcraft")); + for bad in [json!({}), json!({"paths": []}), json!({"paths": [""]}), json!({"paths": [3]}), json!({"paths": vec!["a.solidcraft"; 17]})] { + assert_eq!(call(&mut app, "ui.drop", bad.clone())["ok"], false, "{bad}"); + } + assert!(app.synthetic_drops.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn ui_render_writes_a_png_of_the_model() { let mut app = ModelApp::new(Session::default()); @@ -729,6 +769,7 @@ mod tests { "ui.select", "ui.selection", "ui.fullscreen", + "ui.drop", ] { for p in &hostile { let _ = call(&mut app, method, p.clone()); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index bd019d9..3746525 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -540,10 +540,12 @@ impl ModelApp { true } - /// Files dropped on the window open in turn; a hovered drop shows where it goes. + /// Files dropped on the window (and by the control channel's `ui.drop`) open in turn; a + /// hovered drop shows where it goes. pub(crate) fn dropped_files(&mut self, ctx: &egui::Context) { - let dropped: Vec = ctx + let mut dropped: Vec = ctx .input(|i| i.raw.dropped_files.iter().filter_map(|f| Some(f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty())).collect()); + dropped.append(&mut self.synthetic_drops); // A model first: it replaces the session, then designs and meshes go into it. let (models, rest): (Vec, Vec) = dropped.into_iter().partition(|p| Kind::of(p) == Some(Kind::Model)); for p in models.into_iter().take(1).chain(rest) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f3b3a9e..929ba53 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -289,6 +289,9 @@ pub struct ModelApp { /// Input injected by the control channel, fed to egui one pointer event per frame. pub synthetic: Vec, synthetic_mods: egui::Modifiers, + /// Files dropped on the window by the control channel (`ui.drop`): the next frame takes them + /// with the window system's drops. + pub synthetic_drops: Vec, control_rx: Option>, shots: Vec, shot_token: u64, @@ -363,6 +366,7 @@ impl ModelApp { styled: None, synthetic: Vec::new(), synthetic_mods: egui::Modifiers::default(), + synthetic_drops: Vec::new(), control_rx: None, shots: Vec::new(), shot_token: 0, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 792a480..0057c9b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -58,6 +58,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | `ui.scroll` | `x`, `y`, `delta?` (wheel lines, −100–100; positive zooms in) | zooms at the point | | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | `F` over the viewport frames the model; `Escape` clears the selection (and then cancels an open form) | | `ui.text` | `text` | typed text (into the focused field) | +| `ui.drop` | `paths`: 1–16 file paths | drops the files on the window: the next frame opens them as a drop from the file manager does, and as a file picked in the Open dialog does (a `.fieldcraft` model, a `.solidcraft` design, a `.3mf` tagged mesh; `ui.inspect`'s `busy` while they open). The native picker cannot be driven over the channel; this is the same route | | `ui.worldToScreen` | `points`: `[[x, y, z]…]` (at most 1000) | screen points in the viewport drawn last frame (null when not on screen) | | `ui.resize` | `width`, `height` | window inner size in points | | `ui.fullscreen` | `on?` (default true) | into or out of full screen; on macOS the title bar's left group moves into the traffic lights' room while there are none |