control: ui.drop drops files on the window

The Open dialog is a native picker the control channel cannot click, so
nothing could walk a person's open path end to end: ui.start file.open
opens the command's JSON form instead. ui.drop {paths} hands the paths
to the next frame with the window system's drops, which open them
through the same route a picked file takes (open_path: by type, in the
background, the unsaved-changes prompt, the recent list). At most 16
paths of at most 4096 bytes each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 08:46:50 -04:00
1 parent cf72503f76
commit 8a4209a20c
4 files changed
+50 -2

No files matched your search

+41
View File
@@ -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<Vec<String>> =
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<Value> = 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());
+4 -2
View File
@@ -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<String> = ctx
let mut dropped: Vec<String> = 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<String>, Vec<String>) = dropped.into_iter().partition(|p| Kind::of(p) == Some(Kind::Model));
for p in models.into_iter().take(1).chain(rest) {
+4
View File
@@ -289,6 +289,9 @@ pub struct ModelApp {
/// Input injected by the control channel, fed to egui one pointer event per frame.
pub synthetic: Vec<egui::Event>,
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<String>,
control_rx: Option<Receiver<ControlRequest>>,
shots: Vec<Shot>,
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,
+1
View File
@@ -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 |