mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
feat(ui): drop files on the Pages panel to insert pages; track the pointer during drags on Windows (#1547)
Files dragged from outside onto the Pages panel are inserted at the gap under the pointer (with a drop indicator) instead of opening as new documents. Files dropped beside the panel still open as before. On Windows, winit sends no pointer moves while files are dragged over the window, so the cursor is read with GetPhysicalCursorPos (winsafe "user" feature). This also makes the page grid show and use the gap under the pointer during a drag on Windows. Linux/BSD still have no pointer during a drag and keep the existing fallback. Tests: two new kittest cases in tests/editing.rs (drop on the panel inserts at the gap / at the end; drop beside the panel opens a document). The Windows cursor path was checked by hand.
This commit is contained in:
1 parent
e770c564a8
commit
5105eb732c
6 files changed
+130
-14
No files matched your search
@@ -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"] }
|
||||
|
||||
@@ -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<usize>,
|
||||
/// 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<Rect>,
|
||||
/// The Pages-panel gap under the pointer (where dropped files go), when it can take pages.
|
||||
pub panel_gap: Option<usize>,
|
||||
/// 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();
|
||||
|
||||
@@ -19,7 +19,22 @@ pub(crate) fn in_window(ctx: &egui::Context) -> Option<egui::Pos2> {
|
||||
(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<egui::Pos2> {
|
||||
// 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<egui::Pos2> {
|
||||
None
|
||||
}
|
||||
+38
-11
@@ -250,9 +250,11 @@ fn read_grid_file((name, source): GridFile) -> Result<(String, Vec<u8>), (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<GridFile>,
|
||||
/// 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<pdfcraft_engine::DocId> {
|
||||
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<egui::DroppedFileHandle>, ctx: &egui::Context) -> Vec<egui::DroppedFileHandle> {
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user