forked from 1b-agent/fieldcraft-upstream
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:
1 parent
cf72503f76
commit
8a4209a20c
4 files changed
+50
-2
No files matched your search
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user