From 8815ef7350819d4e7dca3dc4841bf9411de24f32 Mon Sep 17 00:00:00 2001 From: texjer Date: Tue, 3 Nov 2026 21:50:08 -0500 Subject: [PATCH] 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. --- Cargo.lock | 1 + ROADMAP.md | 1 + apps/designcraft/Cargo.toml | 4 +++- apps/designcraft/src/main.rs | 4 ++++ apps/designcraft/src/pointer.rs | 16 +++++++++++++++ crates/ui-egui/src/app.rs | 13 +----------- crates/ui-egui/src/app/input.rs | 35 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/services.rs | 10 ++++++++++ crates/ui-egui/src/tests.rs | 30 ++++++++++++++++++++++++++++ 10 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 apps/designcraft/src/pointer.rs diff --git a/Cargo.lock b/Cargo.lock index 549312de5..f928f149a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/ROADMAP.md b/ROADMAP.md index a13912f7c..003531247 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 518c89874..b17258acd 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -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 diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index ea0747a46..53f4a454e 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -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() } } diff --git a/apps/designcraft/src/pointer.rs b/apps/designcraft/src/pointer.rs new file mode 100644 index 000000000..14bf9599a --- /dev/null +++ b/apps/designcraft/src/pointer.rs @@ -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 { + 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)) +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c1b7a62e3..b2b433b71 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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(); diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index e889c3287..2682478c4 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -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 { + 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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ef8629896..c780d632a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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}; diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 47459cddb..107e9e775 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -14,6 +14,11 @@ pub type ClipboardFn = Box Option>; /// Keeps the window's frame as the app needs it; called once per frame, before it is laid out. pub type WindowFrameFn = Box; +/// 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 Option>; + /// Files delivered asynchronously with their original operation and document identity. pub type Inbox = std::sync::Arc>>; @@ -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, + /// 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, } diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index db52fbd72..e53e603a1 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -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();