Place: a file dragged in from the Finder fills the frame it was dropped on

Split out of #2073. winit sends no pointer events during a drag from another
application on macOS, so a Finder drop had no pointer and never found the frame
under it. The host now gives the pointer's place on the screen
(`Services::pointer`, `PointerFn`: the system's points before the UI scale,
from `NSEvent.mouseLocation`), and `take_pending_drops` uses it for the files
that came without a pointer, divided by egui's zoom factor and made relative
to the window.
This commit is contained in:
texjer committed 2026-11-03 21:50:08 -05:00
1 parent e1910529a7
commit 8815ef7350
10 files changed
+102 -14

No files matched your search

Generated
+1
View File
@@ -1320,6 +1320,7 @@ dependencies = [
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd",
"serde",
"serde_json",
+1
View File
@@ -405,6 +405,7 @@ Newest first.
- **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change.
- **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change.
- **2026-10-30** · Without facing pages, a page of a multi-page spread shows the parent page in its place, as in InDesign: the second page of a two-page spread shows the parent's second page. Every single-sided page showed the parent's first page, so an InDesign book set up without facing pages but laid out in two-page spreads repeated the left parent page's items (a black heading bar) on its right pages and lost the right parent page's. No readiness percentage change.
- **2026-10-30** · macOS: a file dragged in from the Finder fills the frame it is dropped on. winit sends no pointer events during such a drag, so the drop asks the system where the pointer is (`Services::pointer`, from `NSEvent.mouseLocation`), at any UI scale. No readiness percentage change.
- **2026-10-30** · File > Package opens a dialog, as the reference app's: a Summary of fonts, links and images and inks, pages for each, a Printing Instructions step, and a PDF made with the options of a preset (#1514, #1515).
- **2026-10-30** · IDML import reads TOC separators and entry styles written with the `$ID/` prefix or as `Properties` children, and the TOC separator notation (`^t`, `^y`, `^m`, `^>`) is shared by the dialog and IDML, with a special-character menu on the separator field (#1249, #1255).
- **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change.
+3 -1
View File
@@ -48,7 +48,9 @@ muda = { version = "0.21", default-features = false }
# The input context, to drop the IME's marked text when the Type tool ends a composition. The
# versions muda uses, through safe methods.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSResponder", "NSTextInputContext"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSResponder", "NSScreen", "NSTextInputContext"] }
# Files dragged in from the Finder: the pointer's place on the screen (src/pointer.rs).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry"] }
# The window's `movable` flag follows the application bar's controls.
craft-window = { workspace = true }
# Finder double-clicks, Open With, Dock drops and `open -a DesignCraft brochure.idml` arrive as a
+4
View File
@@ -21,6 +21,8 @@ mod gpu_limits;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod pointer;
mod profile;
mod window_frame;
@@ -225,6 +227,8 @@ fn services() -> Services {
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())),
#[cfg(target_os = "macos")]
pointer: Some(Box::new(pointer::screen_position)),
..Default::default()
}
}
+16
View File
@@ -0,0 +1,16 @@
//! macOS: where the pointer is on the screen, for files dragged in from the Finder. winit sends no
//! pointer events during a drag, so the drop asks AppKit (`NSEvent.mouseLocation`) instead.
use objc2::MainThreadMarker;
use objc2_app_kit::{NSEvent, NSScreen};
/// The pointer, in points from the top left of the main display (where winit, and so egui's
/// `inner_rect`, put the origin). `None` off the main thread or with no display.
pub fn screen_position() -> Option<egui::Pos2> {
let mtm = MainThreadMarker::new()?;
// AppKit's origin is the bottom left of the first screen, the one with the menu bar.
let height = NSScreen::screens(mtm).firstObject()?.frame().size.height;
let at = NSEvent::mouseLocation();
let (x, y) = (at.x, height - at.y);
(x.is_finite() && y.is_finite()).then(|| egui::pos2(x as f32, y as f32))
}
+1 -12
View File
@@ -206,18 +206,7 @@ impl DesignApp {
// must not replay shortcuts or dropped files from stale input.
if ctx.current_pass_index() == 0 {
#[cfg(not(target_arch = "wasm32"))]
{
// Sample the generation once for the whole batch: placing the first valid file
// must not suppress later files that belong to the same captured batch.
let current = self.input_generation();
let mut frame_free = true;
for drop in std::mem::take(&mut self.pending_drops) {
if drop.generation != current {
continue;
}
let _ = self.place_dropped(&drop.path, drop.pointer, &mut frame_free);
}
}
self.take_pending_drops(&ctx);
menus::shortcuts(self, &ctx);
}
let t0 = now_ms();
+35
View File
@@ -142,6 +142,41 @@ impl DesignApp {
}
}
/// Open or place the files dropped since the last UI pass, at the pointer. Files dropped
/// without pointer events (dragged in from another application) are placed where the system
/// says the pointer is, so one dropped on a frame still fills it.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn take_pending_drops(&mut self, ctx: &egui::Context) {
// Sample the generation once for the whole batch: placing the first valid file must not
// suppress later files that belong to the same captured batch.
let current = self.input_generation();
let drops: Vec<_> = std::mem::take(&mut self.pending_drops).into_iter().filter(|d| d.generation == current).collect();
if drops.is_empty() {
return;
}
// Each file keeps the pointer it was dropped at; the system is asked once, for those
// that came without one.
let system = drops.iter().any(|d| d.pointer.is_none()).then(|| self.system_pointer(ctx)).flatten();
let mut frame_free = true;
for drop in drops {
let _ = self.place_dropped(&drop.path, drop.pointer.or(system), &mut frame_free);
}
}
/// Where the pointer is in the window, asked of the system ([`crate::Services::pointer`]),
/// for files dropped without pointer events: dragged in from another application, they bring
/// none. `None` when the system doesn't say or the window's place isn't known.
pub(crate) fn system_pointer(&self, ctx: &egui::Context) -> Option<egui::Pos2> {
let at = (self.services.pointer.as_ref()?)()?;
// The system's points are unscaled; egui's (the window's rect too) are divided by the
// UI scale.
let zoom = ctx.zoom_factor();
let at = if zoom.is_finite() && zoom > 0.0 { at / zoom } else { at };
let inner = ctx.input(|i| i.viewport().inner_rect)?;
let local = at - inner.min.to_vec2();
(local.x.is_finite() && local.y.is_finite()).then_some(local)
}
/// The current state an injected batch belongs to.
pub(crate) fn input_generation(&self) -> InputGeneration {
InputGeneration {
+1 -1
View File
@@ -71,7 +71,7 @@ use app::browser_import_tests;
pub use app::opens_as_document;
pub use clock::now_ms;
pub use key_names::{drop_key_name_text, is_key_name};
pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, ReadFn, Services, WindowFrameFn, WriteFn};
pub use services::{ClipboardFn, DownloadFn, Inbox, OpenAsyncFn, PointerFn, ReadFn, Services, WindowFrameFn, WriteFn};
pub(crate) use state::second_window_viewport;
pub use state::{CanvasCache, HiddenPanels, ImportRequest, ImportedFile, PdfExportSettings, Perf, SavedWorkspace, ScreenMode, Shown, UiState, View};
+10
View File
@@ -14,6 +14,11 @@ pub type ClipboardFn = Box<dyn FnMut() -> Option<String>>;
/// Keeps the window's frame as the app needs it; called once per frame, before it is laid out.
pub type WindowFrameFn = Box<dyn FnMut()>;
/// Where the pointer is on the screen now, in the system's points from the top left of the main
/// display, before the UI scale: the app divides them by egui's zoom factor to compare them with
/// the window's `inner_rect`. `None` when the system doesn't say.
pub type PointerFn = Box<dyn Fn() -> Option<egui::Pos2>>;
/// Files delivered asynchronously with their original operation and document identity.
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<ImportedFile>>>;
@@ -39,4 +44,9 @@ pub struct Services {
/// (Windows custom title bar, #1295). `None` when the window is decorated or the platform
/// needs no fixing.
pub window_frame: Option<WindowFrameFn>,
/// The pointer's place on the screen, asked when a file is dropped: a file dragged in from
/// another application brings no pointer events with it (winit reports none during a drag on
/// macOS), so without it a drop can't tell which frame it landed on. `None`: a drop without
/// pointer events loads the place cursor.
pub pointer: Option<PointerFn>,
}
+30
View File
@@ -899,6 +899,36 @@ fn a_graphic_dropped_on_a_frame_fills_it() {
assert!(matches!(d.item(designcraft_doc::ItemId(frame)).unwrap().content, designcraft_doc::Content::Graphic(_)));
}
/// A file dragged in from the Finder brings no pointer events (winit sends none during a drag on
/// macOS): the drop asks the system where the pointer is, so one dropped on a frame fills it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_file_dropped_without_pointer_events_fills_the_frame_under_the_system_pointer() {
// At any UI scale: the system's pointer is in unscaled points, egui's are divided by the scale.
for zoom in [1.0f32, 2.0] {
let (mut app, frame, inside, _) = app_with_drop_frame();
// The window's content starts at (300, 200) in egui's points.
let window = egui::vec2(300.0, 200.0);
app.services.pointer = Some(Box::new(move || Some(((inside + window).to_vec2() * zoom).to_pos2())));
let path = dropped_png("drop-system-pointer");
app.pending_drops.push(crate::PendingDrop { path: path.to_string_lossy().into_owned(), pointer: None, generation: app.input_generation() });
let ctx = egui::Context::default();
ctx.set_zoom_factor(zoom);
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO + window, egui::vec2(1400.0, 900.0));
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, rect.size())), ..Default::default() };
raw.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { inner_rect: Some(rect), ..Default::default() });
ctx.run_ui(raw, |ui| app.take_pending_drops(ui.ctx())).textures_delta.clear();
let _ = std::fs::remove_file(&path);
assert!(app.pending_drops.is_empty());
let d = &app.session.doc().unwrap().doc;
assert_eq!(d.spreads[0].items.len(), 1, "no new object at {zoom}");
let designcraft_doc::Content::Graphic(g) = &d.item(designcraft_doc::ItemId(frame)).unwrap().content else {
panic!("the frame under the pointer is filled at {zoom}")
};
assert!(d.assets[&g.asset].name.contains("drop-system-pointer"));
}
}
#[test]
fn a_locked_frame_is_not_a_drop_target() {
let (mut app, frame, inside, _) = app_with_drop_frame();