diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5400829cde..e45c596881 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -55,8 +55,9 @@ rustix = { version = "1", features = ["fs"] } [target.'cfg(windows)'.dependencies] # Auto interface language: the Windows display language, without starting a process. sys-locale = "0.3" -# Region ▸ Measurement system for the default Units, read from the registry without starting reg.exe. -winsafe = { version = "0.0.29", default-features = false, features = ["advapi"] } +# Region ▸ Measurement system for the default Units, read from the registry without starting reg.exe; +# where the pointer is while files are dragged over the window (winit doesn't say). +winsafe = { version = "0.0.29", default-features = false, features = ["advapi", "user"] } [target.'cfg(target_arch = "wasm32")'.dependencies] rfd = { workspace = true, features = ["file-handle-inner"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9dc2c98dfa..3ae27c40ce 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -449,6 +449,11 @@ pub struct DocView { /// The page-grid gap under the pointer this frame (where dropped files go), when it can /// take pages. pub grid_gap: Option, + /// The visible part of the Pages panel as last drawn, when it can take dropped files + /// (cleared each frame before the panels draw). + pub pages_panel: Option, + /// The Pages-panel gap under the pointer (where dropped files go), when it can take pages. + pub panel_gap: Option, /// How large the page grid draws its pages (1.0 = the usual thumbnails). grid_zoom: f32, /// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid @@ -649,6 +654,8 @@ impl DocView { pending_action: None, insert_at: None, grid_gap: None, + pages_panel: None, + panel_gap: None, grid_zoom: 1.0, grid_zoom_request: None, grid_anchor: None, @@ -1315,6 +1322,8 @@ impl DocView { /// Start collecting demand from every consumer before scheduling or uploading results. pub(crate) fn begin_render_frame(&mut self) { + self.pages_panel = None; + self.panel_gap = None; self.thumb_demand.clear(); self.frame_queue.clear(); self.frame_visible.clear(); diff --git a/crates/ui-egui/src/drag_pointer.rs b/crates/ui-egui/src/drag_pointer.rs index 898d319fe6..05ef05c426 100644 --- a/crates/ui-egui/src/drag_pointer.rs +++ b/crates/ui-egui/src/drag_pointer.rs @@ -19,7 +19,22 @@ pub(crate) fn in_window(ctx: &egui::Context) -> Option { (pos.x.is_finite() && pos.y.is_finite()).then_some(pos) } -#[cfg(not(target_os = "macos"))] +/// The pointer in this window's coordinates (egui points), when the system can tell. +#[cfg(windows)] +pub(crate) fn in_window(ctx: &egui::Context) -> Option { + // Physical screen pixels, whatever this thread's DPI awareness. + let pointer = winsafe::GetPhysicalCursorPos().ok()?; + let inner = ctx.input(|i| i.viewport().inner_rect)?; + // egui places the window in points: physical pixels over the pixels per point (zoom included). + let ppp = ctx.pixels_per_point(); + if !(ppp.is_finite() && ppp > 0.0) { + return None; + } + let pos = egui::pos2(pointer.x as f32 / ppp - inner.min.x, pointer.y as f32 / ppp - inner.min.y); + (pos.x.is_finite() && pos.y.is_finite()).then_some(pos) +} + +#[cfg(not(any(target_os = "macos", windows)))] pub(crate) fn in_window(_ctx: &egui::Context) -> Option { None } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f800ad6a00..b01a1e9bf6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -250,9 +250,11 @@ fn read_grid_file((name, source): GridFile) -> Result<(String, Vec), (String Ok((name, bytes)) } -/// Files dropped on a document's page grid. +/// Files dropped on a document's page grid or its Pages panel. struct GridDrop { doc: pdfcraft_engine::DocId, + /// Dropped on the Pages panel (its gap decides where they go), not the page grid. + panel: bool, files: Vec, /// When to stop waiting for the pointer (egui time, seconds). deadline: f64, @@ -1368,11 +1370,29 @@ impl PdfCraftApp { (self.dialog.is_none() && self.session.get(view.id)?.allows_assembly()).then_some(view.id) } - /// Files dropped while the page grid shows are inserted into it rather than opened: they - /// are kept until [`Self::finish_grid_drop`] knows the gap. Returns the files not taken. + /// The document whose Pages panel is under the pointer and may take pages, if any. + #[cfg(not(target_arch = "wasm32"))] + fn panel_target(&self, ctx: &egui::Context) -> Option { + let view = self.active.and_then(|i| self.views.get(i))?; + let pointer = ctx.input(|i| i.pointer.hover_pos())?; + let over = view.pages_panel.is_some_and(|panel| panel.contains(pointer)); + (over && self.dialog.is_none() && self.session.get(view.id)?.allows_assembly()).then_some(view.id) + } + + /// Files dropped on the Pages panel, or while the page grid shows, are inserted into the + /// document rather than opened: they are kept until [`Self::finish_grid_drop`] knows the + /// gap. Returns the files not taken. #[cfg(not(target_arch = "wasm32"))] fn drop_on_grid(&mut self, dropped: Vec, ctx: &egui::Context) -> Vec { - let Some(id) = self.grid_target().filter(|_| !dropped.is_empty()) else { return dropped }; + if dropped.is_empty() { + return dropped; + } + // The panel first: the grid takes files dropped anywhere in the window. + let (id, panel) = match (self.panel_target(ctx), self.grid_target()) { + (Some(id), _) => (id, true), + (None, Some(id)) => (id, false), + (None, None) => return dropped, + }; let mut files = Vec::new(); for f in &dropped { let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "dropped.pdf".into()); @@ -1389,22 +1409,29 @@ impl PdfCraftApp { // Where there is no telling where the pointer is during the drag, its place arrives // with the first move after the drop. ctx.request_repaint(); - self.grid_drop = Some(GridDrop { doc: id, files, deadline: ctx.input(|i| i.time) + 1.0 }); + self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: ctx.input(|i| i.time) + 1.0 }); } Vec::new() } - /// Insert files dropped on the page grid at the gap under the pointer, or at the end when - /// the pointer hasn't shown up in time. + /// Insert files dropped on the page grid or the Pages panel at the gap under the pointer, + /// or at the end when the pointer hasn't shown up in time. fn finish_grid_drop(&mut self, ctx: &egui::Context) { - let Some(GridDrop { doc: id, deadline, .. }) = &self.grid_drop else { return }; - let (id, deadline) = (*id, *deadline); - if self.grid_target() != Some(id) { + let Some(GridDrop { doc: id, panel, deadline, .. }) = &self.grid_drop else { return }; + let (id, panel, deadline) = (*id, *panel, *deadline); + let still = if panel { + self.dialog.is_none() + && self.active_ids().map(|(_, active)| active) == Some(id) + && self.session.get(id).is_some_and(|d| d.allows_assembly()) + } else { + self.grid_target() == Some(id) + }; + if !still { self.grid_drop = None; return self.notify_tr("The document changed while you were choosing a file, so nothing was changed."); } let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; - let gap = match view.grid_gap { + let gap = match if panel { view.panel_gap } else { view.grid_gap } { Some(gap) => gap, None if ctx.input(|i| i.time) >= deadline => usize::MAX, None => return ctx.request_repaint(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bdd625804d..cf71f99a68 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1114,6 +1114,28 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie ui.add_space(4.0); } page_drag(ui, t, view, &rows, dropped); + file_drop(ui, t, view, &rows, editable && !modal); +} + +/// Files dragged in from outside go to the gap under the pointer: remember where the panel is +/// and that gap, for the app to take the drop, and show where the pages would go. +fn file_drop(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, rows: &[Rect], editable: bool) { + if !editable { + return; + } + let clip = ui.clip_rect(); + view.pages_panel = Some(clip); + let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| clip.contains(*p)); + view.panel_gap = pointer.filter(|_| view.panel_drag.is_none()).and_then(|p| panel_gap(rows, p.y)); + if pointer.is_none() || ui.input(|i| i.raw.hovered_files.is_empty()) { + return; + } + let painter = ui.painter().with_clip_rect(clip); + painter.rect_stroke(clip.shrink(2.0), CornerRadius::same(6), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + if let (Some(gap), Some(first), Some(last)) = (view.panel_gap, rows.first(), rows.last()) { + let y = rows.get(gap).map_or(last.bottom() + 2.0, |r| r.top() - 3.0); + painter.line_segment([pos2(first.left(), y), pos2(first.right(), y)], Stroke::new(3.0, t.accent)); + } } /// Queue deleting the selection (or the current page) of a document of `n` pages, unless that diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index c470837775..2708b6c3db 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1686,6 +1686,48 @@ fn files_dropped_outside_the_page_grid_still_open_as_documents() { assert_eq!(doc.bytes.as_slice(), fixture(1).as_slice()); } +/// The Pages panel's thumbnail of page `label` (the panel is right of the page view). +fn panel_thumb_rect(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.left().total_cmp(&b.left())).unwrap() +} + +#[test] +fn files_dropped_on_the_pages_panel_are_inserted_there() { + let mut h = harness(3, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // The top half of page 2's thumbnail: between pages 1 and 2. + let thumb = panel_thumb_rect(&h, "Page 2"); + h.hover_at(thumb.center() - egui::vec2(0.0, thumb.height() * 0.25)); + h.run_steps(2); + assert_eq!(h.state().views[0].panel_gap, Some(1)); + drop_files(&mut h, vec![Dropped("extra.pdf", fixture(2)), Dropped("note.txt", b"a note".to_vec())]); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1, "dropped files are not opened as documents"); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 1", "Page 2", "a note", "Page 2", "Page 3"]); + assert_eq!(picked(&h), [1, 2, 3], "the inserted pages are selected"); + // The bottom half of the last thumbnail: the end. + let mut h = harness(2, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + let last = panel_thumb_rect(&h, "Page 2"); + h.hover_at(last.center() + egui::vec2(0.0, last.height() * 0.25)); + h.run_steps(2); + drop_files(&mut h, vec![Dropped("note.txt", b"a note".to_vec())]); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2", "a note"]); +} + +#[test] +fn files_dropped_beside_the_pages_panel_still_open_as_documents() { + let mut h = harness(2, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // Over the page view, not the panel. + let thumb = panel_thumb_rect(&h, "Page 1"); + h.hover_at(egui::pos2(thumb.left() - 300.0, thumb.center().y)); + h.run_steps(2); + drop_files(&mut h, vec![Dropped("other.pdf", fixture(1))]); + h.run_steps(3); + assert_eq!(h.state().views.len(), 2); + assert_eq!(texts_of(h.state(), 0), ["Page 1", "Page 2"]); +} + fn grid_zoom(h: &Harness<'static, PdfCraftApp>) -> f32 { h.state().views[0].grid_zoom() }