From 680aff2493ff45c87de0dab50316da512c081459 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Tue, 11 Aug 2026 17:54:27 +0800 Subject: [PATCH] feat(figure): complete panel editing and image workflows --- Cargo.lock | 9 + Cargo.toml | 8 +- crates/app/Cargo.toml | 2 + crates/app/src/main.rs | 17 +- crates/app/src/observability.rs | 11 +- crates/app/src/ui/canvas/board.rs | 4 +- crates/app/src/ui/canvas/breadcrumb.rs | 51 ++ crates/app/src/ui/canvas/geometry.rs | 95 ++- crates/app/src/ui/canvas/geometry_aspect.rs | 30 + crates/app/src/ui/canvas/geometry_panels.rs | 144 ++++ .../app/src/ui/canvas/geometry_pr2_tests.rs | 55 ++ crates/app/src/ui/canvas/image_drop.rs | 86 ++ crates/app/src/ui/canvas/image_painting.rs | 125 +++ crates/app/src/ui/canvas/interactions.rs | 266 ++++-- .../app/src/ui/canvas/marquee_interactions.rs | 73 ++ crates/app/src/ui/canvas/mod.rs | 85 +- crates/app/src/ui/canvas/painting.rs | 43 +- .../app/src/ui/canvas/panel_interactions.rs | 265 ++++++ crates/app/src/ui/canvas/panel_notes.rs | 3 +- crates/app/src/ui/canvas/panel_selection.rs | 110 +++ crates/app/src/ui/canvas/snap.rs | 57 ++ crates/app/src/ui/canvas/tiling.rs | 224 ++++-- crates/app/src/ui/clipboard_native.rs | 393 ++++++++- crates/app/src/ui/command_exec.rs | 184 ++++- crates/app/src/ui/commands.rs | 266 +++--- crates/app/src/ui/commands/helpers.rs | 98 +++ crates/app/src/ui/commands/identity.rs | 67 +- crates/app/src/ui/commands/ribbon.rs | 1 + crates/app/src/ui/commands_alignment_tests.rs | 15 + crates/app/src/ui/file_dialogs.rs | 37 +- .../app/src/ui/file_dialogs/image_import.rs | 754 ++++++++++++++++++ .../ui/file_dialogs/image_import/clipboard.rs | 207 +++++ .../ui/file_dialogs/image_import/consent.rs | 48 ++ .../ui/file_dialogs/image_import/geometry.rs | 29 + .../src/ui/file_dialogs/image_import/jobs.rs | 59 ++ .../ui/file_dialogs/image_import/new_pages.rs | 125 +++ .../ui/file_dialogs/image_import/picker.rs | 130 +++ .../src/ui/file_dialogs/image_import/proxy.rs | 164 ++++ .../src/ui/file_dialogs/image_import_tests.rs | 442 ++++++++++ crates/app/src/ui/file_dialogs/recent.rs | 12 +- crates/app/src/ui/file_drop.rs | 474 +++++++++++ crates/app/src/ui/menus.rs | 34 + crates/app/src/ui/mod.rs | 8 +- crates/app/src/ui/object_inspector.rs | 206 +++++ crates/app/src/ui/object_inspector/edits.rs | 44 + .../ui/object_inspector/panel_inspector.rs | 213 +++++ crates/app/src/ui/primary_sidebar.rs | 66 +- .../app/src/ui/primary_sidebar/layers_tree.rs | 325 ++++++++ crates/app/src/ui/ribbon.rs | 2 + crates/app/src/ui/shortcuts.rs | 218 +++-- crates/core/src/actions/app_impl/apply.rs | 20 + crates/core/src/actions/app_impl/revert.rs | 20 + crates/core/src/actions/app_impl/validate.rs | 54 ++ crates/core/src/actions/arrange.rs | 160 ++++ crates/core/src/actions/mod.rs | 25 +- crates/core/src/actions/panel.rs | 206 ++++- crates/core/src/actions/tests/interaction.rs | 24 +- crates/core/src/actions/tests/mod.rs | 1 + crates/core/src/actions/tests/panel.rs | 199 +++++ crates/core/src/actions/tests/pr2.rs | 63 ++ crates/core/src/actions/tests/tiling.rs | 84 +- crates/core/src/actions/transfer.rs | 44 +- crates/core/src/actions/zorder.rs | 10 +- crates/core/src/operation.rs | 8 + crates/core/src/project/convert_views.rs | 2 + .../core/src/project/convert_views_panel.rs | 23 + crates/core/src/project/dto_panel.rs | 4 + crates/core/src/project/mod.rs | 23 + crates/core/src/state/app_impl.rs | 68 +- crates/core/src/state/app_impl_interaction.rs | 41 +- crates/core/src/state/app_impl_io.rs | 80 ++ crates/core/src/state/app_impl_selection.rs | 157 ++++ crates/core/src/state/content.rs | 4 +- crates/core/src/state/interaction.rs | 3 + crates/core/src/state/mod.rs | 1 + crates/core/src/state/panel.rs | 64 +- crates/core/src/state/panel_tests.rs | 27 +- crates/core/src/state/selection.rs | 165 +++- crates/core/src/state/tile_drop.rs | 13 +- crates/core/src/state/ui_layout_drag.rs | 53 ++ crates/core/src/state/ui_state.rs | 48 +- crates/core/src/workflow_tests.rs | 2 +- crates/io/Cargo.toml | 5 + crates/io/src/image.rs | 681 ++++++++++++++++ crates/io/src/image/proxy.rs | 149 ++++ crates/io/src/lib.rs | 1 + crates/render/Cargo.toml | 3 +- crates/render/src/emf.rs | 1 + crates/render/src/lib.rs | 28 + crates/render/src/screen.rs | 19 +- crates/render/src/screen_raster.rs | 359 +++++++++ crates/render/src/svg/document.rs | 1 + deny.toml | 1 + .../content/docs/guides/layout-and-export.md | 65 ++ .../content/docs/reference/file-formats.md | 16 + .../content/docs/reference/troubleshooting.md | 11 + .../src/content/docs/reference/ui-overview.md | 4 + .../docs/zh-cn/guides/layout-and-export.md | 50 ++ .../docs/zh-cn/reference/file-formats.md | 14 + .../docs/zh-cn/reference/troubleshooting.md | 8 + .../docs/zh-cn/reference/ui-overview.md | 4 + 101 files changed, 8925 insertions(+), 575 deletions(-) create mode 100644 crates/app/src/ui/canvas/breadcrumb.rs create mode 100644 crates/app/src/ui/canvas/geometry_aspect.rs create mode 100644 crates/app/src/ui/canvas/geometry_panels.rs create mode 100644 crates/app/src/ui/canvas/geometry_pr2_tests.rs create mode 100644 crates/app/src/ui/canvas/image_drop.rs create mode 100644 crates/app/src/ui/canvas/image_painting.rs create mode 100644 crates/app/src/ui/canvas/marquee_interactions.rs create mode 100644 crates/app/src/ui/canvas/panel_interactions.rs create mode 100644 crates/app/src/ui/canvas/panel_selection.rs create mode 100644 crates/app/src/ui/commands/helpers.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import/clipboard.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import/consent.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import/geometry.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import/jobs.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import/new_pages.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import/picker.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import/proxy.rs create mode 100644 crates/app/src/ui/file_dialogs/image_import_tests.rs create mode 100644 crates/app/src/ui/file_drop.rs create mode 100644 crates/app/src/ui/object_inspector/panel_inspector.rs create mode 100644 crates/app/src/ui/primary_sidebar/layers_tree.rs create mode 100644 crates/core/src/actions/tests/pr2.rs create mode 100644 crates/core/src/state/app_impl_selection.rs create mode 100644 crates/core/src/state/ui_layout_drag.rs create mode 100644 crates/io/src/image.rs create mode 100644 crates/io/src/image/proxy.rs create mode 100644 crates/render/src/screen_raster.rs diff --git a/Cargo.lock b/Cargo.lock index fc4b648..0d5f6d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2903,6 +2903,7 @@ dependencies = [ "byteorder-lite", "color_quant", "gif 0.14.2", + "image-webp", "moxcms", "num-traits", "png 0.18.1", @@ -4054,6 +4055,7 @@ name = "plotx" version = "0.1.0" dependencies = [ "ab_glyph", + "arboard", "eframe", "egui", "egui-phosphor", @@ -4074,6 +4076,7 @@ dependencies = [ "rfd", "rhai", "serde_json", + "tiff", "uuid", "windows-sys 0.61.2", "winresource", @@ -4168,12 +4171,17 @@ dependencies = [ "base64", "calamine", "flate2", + "image", + "memmap2", "num-complex", "quick-xml 0.41.0", "rust_xlsxwriter", "serde", "serde_json", + "sha2", + "tempfile", "thiserror 2.0.18", + "tiff", "zip 8.6.0", ] @@ -4194,6 +4202,7 @@ name = "plotx-render" version = "0.1.0" dependencies = [ "egui", + "image", "plotx-figure", "unicode-width", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 81fd35a..52b3ec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,7 +78,11 @@ zip = { version = "8.6", default-features = false, features = ["deflate"] } quick-xml = { version = "0.41", default-features = false } base64 = "0.22" flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "tiff"] } +image = { version = "0.25", default-features = false, features = ["bmp", "jpeg", "png", "tiff", "webp"] } +tiff = "0.11" +memmap2 = "0.9" +tempfile = "3" +arboard = "3.6" libc = "0.2" pdf-writer = "0.12" resvg = "0.47" @@ -95,6 +99,8 @@ windows-sys = { version = "0.61", features = [ "Win32_System_ProcessStatus", "Win32_System_Threading", "Win32_UI_Controls", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index fdac999..39d41bd 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -28,6 +28,7 @@ egui_extras.workspace = true egui-phosphor.workspace = true fontdb.workspace = true image.workspace = true +arboard.workspace = true log.workspace = true num-complex.workspace = true rfd.workspace = true @@ -56,3 +57,4 @@ winresource = "0.1" [dev-dependencies] ab_glyph = "0.2" +tiff.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 9fbda2f..6789c29 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -126,6 +126,10 @@ struct Shell { } impl eframe::App for Shell { + #[cfg(windows)] + fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + ui::clipboard_native::restore_missing_paste_shortcut(raw_input); + } fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); observability::show_pending_crash_dialog(); @@ -586,26 +590,19 @@ impl Shell { } } -/// Undecorated windows lose the DWM frame, so Windows 11 rounded corners and -/// the drop shadow must be requested explicitly. Both calls are cosmetic: -/// failures (e.g. Windows 10 rejecting the corner attribute) are deliberately -/// ignored and the window simply stays square/shadowless. +/// Register native drag hit testing, then restore the DWM frame effects lost +/// by undecorated windows. Cosmetic DWM failures are deliberately ignored. #[cfg(windows)] fn apply_windows_frame_polish(cc: &eframe::CreationContext<'_>) { - use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use windows_sys::Win32::Graphics::Dwm::{ DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, DwmExtendFrameIntoClientArea, DwmSetWindowAttribute, }; use windows_sys::Win32::UI::Controls::MARGINS; - let Ok(handle) = cc.window_handle() else { - return; - }; - let RawWindowHandle::Win32(win32) = handle.as_raw() else { + let Some(hwnd) = ui::file_drop::register_native_window(cc) else { return; }; - let hwnd = win32.hwnd.get() as *mut core::ffi::c_void; let corner = DWMWCP_ROUND; // SAFETY: hwnd comes from the live winit window; both DWM calls only read // the passed attribute structs. diff --git a/crates/app/src/observability.rs b/crates/app/src/observability.rs index f584f61..7a7a477 100644 --- a/crates/app/src/observability.rs +++ b/crates/app/src/observability.rs @@ -281,10 +281,13 @@ fn rotate_files(dir: &Path, prefix: &str, keep: usize) -> io::Result<()> { }) .collect::>(); files.sort_by_key(|entry| { - entry - .metadata() - .and_then(|metadata| metadata.modified()) - .unwrap_or(UNIX_EPOCH) + ( + entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(UNIX_EPOCH), + entry.file_name(), + ) }); let remove_count = files.len().saturating_sub(keep); for entry in files.into_iter().take(remove_count) { diff --git a/crates/app/src/ui/canvas/board.rs b/crates/app/src/ui/canvas/board.rs index 9ad087c..1dc5c55 100644 --- a/crates/app/src/ui/canvas/board.rs +++ b/crates/app/src/ui/canvas/board.rs @@ -545,7 +545,9 @@ pub(crate) fn dispatch_frame_gesture(app: &mut PlotxApp, rect: egui::Rect, ui: & }); if pressed && let Some(point) = hover - && let Some((canvas, _)) = object_at_screen(app, rect, point) + && let Some(canvas) = object_at_screen(app, rect, point) + .map(|(canvas, _)| canvas) + .or_else(|| panel_at_screen(app, rect, point).map(|(canvas, _)| canvas)) { if app.session.active_canvas != Some(canvas) { activate_frame(app, FrameRef::Page(canvas)); diff --git a/crates/app/src/ui/canvas/breadcrumb.rs b/crates/app/src/ui/canvas/breadcrumb.rs new file mode 100644 index 0000000..23adcb2 --- /dev/null +++ b/crates/app/src/ui/canvas/breadcrumb.rs @@ -0,0 +1,51 @@ +use super::*; + +pub(crate) fn canvas_breadcrumb(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { + let canvas_name = app.doc.canvases[ci].name.clone(); + let lead = app.session.ui.hierarchical_selection.lead(); + let panel = lead.and_then(|path| path.panel).and_then(|id| { + app.doc.canvases[ci] + .panel(id) + .map(|panel| (id, panel.name.clone())) + }); + let content = lead.and_then(|path| path.content).and_then(|id| { + app.doc.canvases[ci] + .object(id) + .map(|object| (id, object.name.clone())) + }); + let mut clicked = None; + ui.horizontal(|ui| { + ui.add_space(2.0); + if ui.small_button(canvas_name).clicked() { + clicked = Some(BreadcrumbTarget::Canvas); + } + if let Some((id, name)) = panel { + ui.weak("›"); + if ui.small_button(name).clicked() { + clicked = Some(BreadcrumbTarget::Panel(id)); + } + } + if let Some((id, name)) = content { + ui.weak("›"); + if ui.small_button(name).clicked() { + clicked = Some(BreadcrumbTarget::Content(id)); + } + } else if lead.is_none() { + ui.weak("›"); + ui.small(app.session.tool.label()); + } + }); + ui.add_space(2.0); + match clicked { + Some(BreadcrumbTarget::Canvas) => app.exit_panel_scope(), + Some(BreadcrumbTarget::Panel(panel)) => app.select_panel(ci, panel), + Some(BreadcrumbTarget::Content(content)) => app.select_content(ci, content), + None => {} + } +} + +enum BreadcrumbTarget { + Canvas, + Panel(PanelId), + Content(ObjectId), +} diff --git a/crates/app/src/ui/canvas/geometry.rs b/crates/app/src/ui/canvas/geometry.rs index 9c464d6..6840724 100644 --- a/crates/app/src/ui/canvas/geometry.rs +++ b/crates/app/src/ui/canvas/geometry.rs @@ -1,42 +1,13 @@ use super::*; - -#[derive(Clone, Copy)] -pub(crate) struct ObjectHit { - pub(crate) object: ObjectId, - pub(crate) kind: ObjectDragKind, -} - -pub(crate) fn hit_object(canvas: &CanvasDocument, p: Pos2, zoom: f32) -> Option { - let handle_radius = (HANDLE_SIZE_PX / zoom.max(0.01)).max(3.0); - canvas.objects.iter().rev().find_map(|object| { - if !object.visible { - return None; - } - let frame = canvas.layout_frame(object.id)?; - let r = egui::Rect::from_min_size( - Pos2::new(frame.x, frame.y), - egui::vec2(frame.width, frame.height), - ); - let handles = [ - (r.left_top(), ResizeHandle::TopLeft), - (r.right_top(), ResizeHandle::TopRight), - (r.left_bottom(), ResizeHandle::BottomLeft), - (r.right_bottom(), ResizeHandle::BottomRight), - ]; - for (pos, handle) in handles { - if pos.distance(p) <= handle_radius { - return Some(ObjectHit { - object: object.id, - kind: ObjectDragKind::Resize(handle), - }); - } - } - r.contains(p).then_some(ObjectHit { - object: object.id, - kind: ObjectDragKind::Move, - }) - }) -} +#[path = "geometry_aspect.rs"] +mod aspect; +pub(crate) use aspect::preserve_aspect_frame; +#[path = "geometry_panels.rs"] +mod panels; +pub(crate) use panels::{ + ObjectHit, PanelHit, content_screen_rect, hit_content_object, hit_content_objects, hit_object, + hit_objects, hit_panel, +}; /// Topmost canvas object under a screen point, including objects outside their /// owning page. The active page wins ties, matching page/frame hit ordering. @@ -59,6 +30,28 @@ pub(crate) fn object_at_screen( }) } +/// Topmost Panel under a screen point, including an empty Panel or one placed +/// beyond its page boundary. Board-level gestures use this before considering +/// a marquee so direct manipulation always owns a semantic Panel hit. +pub(crate) fn panel_at_screen( + app: &PlotxApp, + screen: egui::Rect, + point: Pos2, +) -> Option<(usize, PanelHit)> { + let mut canvases: Vec<_> = (0..app.doc.canvases.len()).rev().collect(); + if let Some(active) = app.session.active_canvas + && canvases.contains(&active) + { + canvases.retain(|candidate| *candidate != active); + canvases.insert(0, active); + } + let transform = BoardTransform::from_board(app.session.board, screen); + canvases.into_iter().find_map(|canvas| { + let page = transform.screen_to_page(&app.doc.canvases[canvas], point); + hit_panel(&app.doc.canvases[canvas], page, app.session.board.zoom).map(|hit| (canvas, hit)) + }) +} + pub(crate) fn drag_frame( frame: ObjectFrame, kind: ObjectDragKind, @@ -199,6 +192,21 @@ impl BoardTransform { }; bounds = bounds.union(plot_rect(frame)); } + // Empty Panels have no content frame to contribute above, but remain + // directly manipulable page objects and must not disappear from the + // editor's input/culling bounds when placed beyond the paper edge. + let page = self.page_screen_rect(canvas); + for panel in &canvas.panels { + let frame = panel.frame; + let panel_rect = egui::Rect::from_min_size( + Pos2::new( + page.left() + frame.x * self.zoom, + page.top() + frame.y * self.zoom, + ), + egui::vec2(frame.width * self.zoom, frame.height * self.zoom), + ); + bounds = bounds.union(panel_rect); + } bounds } @@ -435,6 +443,10 @@ pub(crate) fn pos(p: [f32; 2]) -> Pos2 { Pos2::new(p[0], p[1]) } +#[cfg(test)] +#[path = "geometry_pr2_tests.rs"] +mod pr2_tests; + #[cfg(test)] mod tests { use super::*; @@ -683,6 +695,13 @@ mod tests { .push(text_object(2, ObjectFrame::new(20.0, 20.0, 50.0, 50.0))); let hit = hit_object(&canvas, Pos2::new(35.0, 35.0), 1.0).unwrap(); assert_eq!(hit.object, ObjectId::new(2)); + assert_eq!( + hit_objects(&canvas, Pos2::new(35.0, 35.0), 1.0) + .iter() + .map(|hit| hit.object) + .collect::>(), + [ObjectId::new(2), ObjectId::new(1)] + ); } #[test] diff --git a/crates/app/src/ui/canvas/geometry_aspect.rs b/crates/app/src/ui/canvas/geometry_aspect.rs new file mode 100644 index 0000000..80391f7 --- /dev/null +++ b/crates/app/src/ui/canvas/geometry_aspect.rs @@ -0,0 +1,30 @@ +use super::*; + +pub(crate) fn preserve_aspect_frame( + before: ObjectFrame, + candidate: ObjectFrame, + kind: ObjectDragKind, +) -> ObjectFrame { + let ObjectDragKind::Resize(handle) = kind else { + return candidate; + }; + let width_scale = candidate.width / before.width; + let height_scale = candidate.height / before.height; + let scale = if (width_scale - 1.0).abs() >= (height_scale - 1.0).abs() { + width_scale + } else { + height_scale + } + .max(MIN_OBJECT_SIZE_PT / before.width) + .max(MIN_OBJECT_SIZE_PT / before.height); + let width = before.width * scale; + let height = before.height * scale; + let right = before.x + before.width; + let bottom = before.y + before.height; + match handle { + ResizeHandle::TopLeft => ObjectFrame::new(right - width, bottom - height, width, height), + ResizeHandle::TopRight => ObjectFrame::new(before.x, bottom - height, width, height), + ResizeHandle::BottomLeft => ObjectFrame::new(right - width, before.y, width, height), + ResizeHandle::BottomRight => ObjectFrame::new(before.x, before.y, width, height), + } +} diff --git a/crates/app/src/ui/canvas/geometry_panels.rs b/crates/app/src/ui/canvas/geometry_panels.rs new file mode 100644 index 0000000..5260838 --- /dev/null +++ b/crates/app/src/ui/canvas/geometry_panels.rs @@ -0,0 +1,144 @@ +use super::*; + +#[derive(Clone, Copy)] +pub(crate) struct ObjectHit { + pub(crate) object: ObjectId, + pub(crate) kind: ObjectDragKind, +} + +#[derive(Clone, Copy)] +pub(crate) struct PanelHit { + pub(crate) panel: PanelId, + pub(crate) kind: ObjectDragKind, +} + +pub(crate) fn hit_object(canvas: &CanvasDocument, p: Pos2, zoom: f32) -> Option { + hit_objects(canvas, p, zoom).into_iter().next() +} + +pub(crate) fn hit_objects(canvas: &CanvasDocument, p: Pos2, zoom: f32) -> Vec { + hit_frames(canvas, p, zoom, |canvas, id| canvas.layout_frame(id)) +} + +/// Child hit-testing uses page-space content frames. In page scope callers hit +/// the Panel first; after entering a Panel this is the only content target. +pub(crate) fn hit_content_objects( + canvas: &CanvasDocument, + p: Pos2, + zoom: f32, + panel: Option, +) -> Vec { + hit_frames(canvas, p, zoom, |canvas, id| { + (panel.is_none_or(|panel_id| canvas.parent_panel(id) == Some(panel_id))) + .then(|| canvas.content_page_frame(id)) + .flatten() + }) +} + +pub(crate) fn hit_content_object( + canvas: &CanvasDocument, + p: Pos2, + zoom: f32, + panel: Option, +) -> Option { + hit_content_objects(canvas, p, zoom, panel) + .into_iter() + .next() +} + +fn hit_frames( + canvas: &CanvasDocument, + p: Pos2, + zoom: f32, + frame: impl Fn(&CanvasDocument, ObjectId) -> Option, +) -> Vec { + let handle_radius = (HANDLE_SIZE_PX / zoom.max(0.01)).max(3.0); + canvas + .objects + .iter() + .rev() + .filter_map(|object| { + if !object.visible { + return None; + } + let frame = frame(canvas, object.id)?; + let r = egui::Rect::from_min_size( + Pos2::new(frame.x, frame.y), + egui::vec2(frame.width, frame.height), + ); + let handles = [ + (r.left_top(), ResizeHandle::TopLeft), + (r.right_top(), ResizeHandle::TopRight), + (r.left_bottom(), ResizeHandle::BottomLeft), + (r.right_bottom(), ResizeHandle::BottomRight), + ]; + handles + .into_iter() + .find_map(|(pos, handle)| { + (pos.distance(p) <= handle_radius).then_some(ObjectHit { + object: object.id, + kind: ObjectDragKind::Resize(handle), + }) + }) + .or_else(|| { + r.contains(p).then_some(ObjectHit { + object: object.id, + kind: ObjectDragKind::Move, + }) + }) + }) + .collect() +} + +/// Hit-test the semantic Panel frame, including empty Panels and resize +/// handles. The topmost visible Panel wins on overlap. +pub(crate) fn hit_panel(canvas: &CanvasDocument, p: Pos2, zoom: f32) -> Option { + let handle_radius = (HANDLE_SIZE_PX / zoom.max(0.01)).max(3.0); + canvas + .panels + .iter() + .rev() + .filter(|panel| panel.visible) + .find_map(|panel| { + let r = egui::Rect::from_min_size( + Pos2::new(panel.frame.x, panel.frame.y), + egui::vec2(panel.frame.width, panel.frame.height), + ); + let handles = [ + (r.left_top(), ResizeHandle::TopLeft), + (r.right_top(), ResizeHandle::TopRight), + (r.left_bottom(), ResizeHandle::BottomLeft), + (r.right_bottom(), ResizeHandle::BottomRight), + ]; + handles + .into_iter() + .find_map(|(pos, handle)| { + (pos.distance(p) <= handle_radius).then_some(PanelHit { + panel: panel.id, + kind: ObjectDragKind::Resize(handle), + }) + }) + .or_else(|| { + r.contains(p).then_some(PanelHit { + panel: panel.id, + kind: ObjectDragKind::Move, + }) + }) + }) +} + +pub(crate) fn content_screen_rect( + board: BoardViewport, + canvas: &CanvasDocument, + object_id: ObjectId, + screen: egui::Rect, +) -> Option { + let page = BoardTransform::from_board(board, screen).page_screen_rect(canvas); + let frame = canvas.content_page_frame(object_id)?; + Some(PlotRect::new( + page.left() + frame.x * board.zoom, + page.top() + frame.y * board.zoom, + frame.width * board.zoom, + frame.height * board.zoom, + )) +} diff --git a/crates/app/src/ui/canvas/geometry_pr2_tests.rs b/crates/app/src/ui/canvas/geometry_pr2_tests.rs new file mode 100644 index 0000000..1192df2 --- /dev/null +++ b/crates/app/src/ui/canvas/geometry_pr2_tests.rs @@ -0,0 +1,55 @@ +use super::*; +use plotx_core::state::TextBox; + +#[test] +fn aspect_resize_preserves_ratio_and_opposite_corner() { + let before = ObjectFrame::new(10.0, 20.0, 40.0, 20.0); + let candidate = ObjectFrame::new(0.0, 5.0, 50.0, 35.0); + let after = preserve_aspect_frame( + before, + candidate, + ObjectDragKind::Resize(ResizeHandle::TopLeft), + ); + assert_eq!(after, ObjectFrame::new(-20.0, 5.0, 70.0, 35.0)); +} + +#[test] +fn panel_hit_includes_empty_panels_and_resize_handles() { + let mut canvas = CanvasDocument::new("page".to_owned(), [100.0, 80.0]); + let panel = canvas.create_panel("Panel".to_owned(), ObjectFrame::new(10.0, 12.0, 40.0, 30.0)); + + assert_eq!( + hit_panel(&canvas, Pos2::new(20.0, 20.0), 1.0) + .unwrap() + .panel, + panel + ); + assert!(matches!( + hit_panel(&canvas, Pos2::new(10.0, 12.0), 1.0).unwrap().kind, + ObjectDragKind::Resize(ResizeHandle::TopLeft) + )); +} + +#[test] +fn content_hit_uses_page_position_inside_an_editing_panel() { + let mut canvas = CanvasDocument::new("page".to_owned(), [100.0, 80.0]); + let content = ObjectId::new(1); + canvas.objects.push(CanvasObject { + id: content, + name: "content".to_owned(), + frame: ObjectFrame::new(4.0, 5.0, 16.0, 12.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Text(TextBox::label("text".to_owned())), + }); + let panel = canvas.create_panel("Panel".to_owned(), ObjectFrame::new(30.0, 20.0, 40.0, 30.0)); + canvas.panel_mut(panel).unwrap().item_order.push(content); + + assert_eq!( + hit_content_object(&canvas, Pos2::new(36.0, 26.0), 1.0, Some(panel)) + .unwrap() + .object, + content + ); + assert!(hit_content_object(&canvas, Pos2::new(6.0, 11.0), 1.0, Some(panel)).is_none()); +} diff --git a/crates/app/src/ui/canvas/image_drop.rs b/crates/app/src/ui/canvas/image_drop.rs new file mode 100644 index 0000000..3ab2429 --- /dev/null +++ b/crates/app/src/ui/canvas/image_drop.rs @@ -0,0 +1,86 @@ +use super::*; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ImagePanelDropTarget { + pub canvas: plotx_core::state::CanvasId, + pub panel: plotx_core::state::PanelId, + pub position: [f32; 2], + pub screen_rect: egui::Rect, +} + +pub(crate) fn image_drop_target( + app: &PlotxApp, + screen: egui::Rect, + pointer: egui::Pos2, +) -> Option { + let selected = app.session.ui.hierarchical_selection.lead()?; + let panel_id = selected.panel?; + let ci = app.doc.canvas_index(selected.canvas)?; + if app.session.active_canvas != Some(ci) { + return None; + } + let canvas = &app.doc.canvases[ci]; + let panel = canvas.panel(panel_id)?; + if !panel.visible || panel.locked { + return None; + } + let transform = BoardTransform::from_board(app.session.board, screen); + let page_rect = transform.page_screen_rect(canvas); + let screen_rect = egui::Rect::from_min_size( + page_rect.min + + egui::vec2( + panel.frame.x * transform.zoom, + panel.frame.y * transform.zoom, + ), + egui::vec2( + panel.frame.width * transform.zoom, + panel.frame.height * transform.zoom, + ), + ); + if !screen_rect.contains(pointer) { + return None; + } + let page = transform.screen_to_page(canvas, pointer); + Some(ImagePanelDropTarget { + canvas: canvas.resource_id, + panel: panel_id, + position: [page.x, page.y], + screen_rect, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use plotx_core::state::{CanvasDocument, ObjectFrame}; + + fn app_with_panel() -> (PlotxApp, plotx_core::state::PanelId) { + let mut app = PlotxApp::default(); + let mut canvas = CanvasDocument::new("Page".to_owned(), [100.0, 100.0]); + let panel = + canvas.create_panel("Panel".to_owned(), ObjectFrame::new(10.0, 10.0, 80.0, 60.0)); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + (app, panel) + } + + #[test] + fn page_hit_without_an_explicit_panel_selection_creates_no_panel_target() { + let (app, _) = app_with_panel(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + assert!(image_drop_target(&app, screen, egui::pos2(20.0, 20.0)).is_none()); + } + + #[test] + fn selected_unlocked_panel_is_a_stable_direct_drop_target() { + let (mut app, panel) = app_with_panel(); + app.select_panel(0, panel); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 400.0)); + let target = image_drop_target(&app, screen, egui::pos2(20.0, 20.0)).unwrap(); + assert_eq!(target.canvas, app.doc.canvases[0].resource_id); + assert_eq!(target.panel, panel); + + app.doc.canvases[0].panel_mut(panel).unwrap().locked = true; + assert!(image_drop_target(&app, screen, egui::pos2(20.0, 20.0)).is_none()); + } +} diff --git a/crates/app/src/ui/canvas/image_painting.rs b/crates/app/src/ui/canvas/image_painting.rs new file mode 100644 index 0000000..b090bd9 --- /dev/null +++ b/crates/app/src/ui/canvas/image_painting.rs @@ -0,0 +1,125 @@ +use super::*; +pub(super) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painter: &egui::Painter) { + let canvas = &app.doc.canvases[ci]; + let [width, height] = canvas.size_pt(); + let mut items = plotx_core::state::document_items(canvas); + for (object_index, object) in canvas.objects.iter().enumerate() { + let plotx_core::state::CanvasObjectKind::RasterImage(image) = &object.kind else { + continue; + }; + let frame = canvas + .content_page_frame(object.id) + .unwrap_or(object.frame) + .rect(); + let parent_panel = canvas + .parent_panel(object.id) + .and_then(|id| canvas.panel(id)); + let panel_visible = parent_panel.is_none_or(|panel| panel.visible); + let clip = parent_panel + .filter(|panel| panel.clip_children) + .map(|panel| panel.frame.rect()); + let Some(asset) = app.doc.assets.get(&image.asset) else { + items[object_index] = missing_image_placeholder(frame, panel_visible && object.visible); + continue; + }; + let Some(preview) = + app.session.ui.raster_proxies.iter().find(|preview| { + preview.hash == asset.sha256 && preview.page_index == image.page_index + }) + else { + items[object_index] = loading_image_placeholder(frame, panel_visible && object.visible); + continue; + }; + items[object_index] = plotx_render::DocumentItem::Raster(plotx_render::DocumentRaster { + source_hash: raster_cache_hash(asset.sha256, image.page_index), + frame, + pixels: preview.rgba8.as_slice(), + pixel_size: preview.pixel_size, + source_pixel_size: if image.page_index == 0 { + asset.pixel_size + } else { + preview.pixel_size + }, + crop: image.crop, + fit: match image.fit { + plotx_core::state::ImageFit::Contain => plotx_render::RasterFit::Contain, + plotx_core::state::ImageFit::Cover => plotx_render::RasterFit::Cover, + plotx_core::state::ImageFit::Stretch => plotx_render::RasterFit::Stretch, + }, + quarter_turns: match image.rotation { + plotx_core::state::QuarterTurn::Zero => 0, + plotx_core::state::QuarterTurn::Clockwise90 => 1, + plotx_core::state::QuarterTurn::Clockwise180 => 2, + plotx_core::state::QuarterTurn::Clockwise270 => 3, + }, + opacity: image.opacity, + nearest: matches!( + image.interpolation, + plotx_core::state::ImageInterpolation::Nearest + ), + clip, + visible: panel_visible && object.visible, + }); + } + let document = plotx_render::Document { + width, + height, + background: canvas.background, + items, + }; + let zoom = app.session.board.zoom; + let bp = canvas.board_pos; + plotx_render::screen::paint_document_for_editor( + painter, + PlotRect::new(rect.left(), rect.top(), rect.width(), rect.height()), + &document, + plotx_render::DocumentViewport { + zoom, + pan: [ + app.session.board.pan[0] + bp[0] * zoom, + app.session.board.pan[1] + bp[1] * zoom, + ], + }, + ); +} + +fn loading_image_placeholder( + frame: plotx_render::Rect, + visible: bool, +) -> plotx_render::DocumentItem<'static> { + plotx_render::DocumentItem::Overlay(plotx_render::DocumentOverlay { + frame, + visible, + kind: plotx_render::OverlayKind::Text(plotx_render::OverlayText { + text: "Preparing image preview…", + font_size: 9.0, + color: plotx_figure::Color::rgb(90, 90, 90), + align: plotx_render::OverlayAlign::Center, + bold: false, + }), + }) +} + +fn missing_image_placeholder( + frame: plotx_render::Rect, + visible: bool, +) -> plotx_render::DocumentItem<'static> { + plotx_render::DocumentItem::Overlay(plotx_render::DocumentOverlay { + frame, + visible, + kind: plotx_render::OverlayKind::Text(plotx_render::OverlayText { + text: "Missing image — replace it in the inspector", + font_size: 9.0, + color: plotx_figure::Color::rgb(180, 45, 45), + align: plotx_render::OverlayAlign::Center, + bold: true, + }), + }) +} + +fn raster_cache_hash(mut hash: [u8; 32], page_index: u32) -> [u8; 32] { + for (slot, value) in hash[..4].iter_mut().zip(page_index.to_le_bytes()) { + *slot ^= value; + } + hash +} diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 0ade4d1..3ca7265 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -310,23 +310,29 @@ pub(crate) fn handle_object_interactions( ci: usize, rect: egui::Rect, ui: &Ui, - _resp: &egui::Response, + resp: &egui::Response, ) { - let (hover, primary_down, primary_pressed, primary_released, shift, alt, esc, focused) = ui - .input(|i| { + let (hover, primary_down, primary_pressed, primary_released, shift, command, alt, esc, focused) = + ui.input(|i| { ( i.pointer.hover_pos(), i.pointer.primary_down(), i.pointer.primary_pressed(), i.pointer.primary_released(), i.modifiers.shift, + i.modifiers.command, i.modifiers.alt, i.key_pressed(egui::Key::Escape), i.focused, ) }); - if (esc || !focused) && matches!(app.interaction(), Interaction::Object(_)) { + if (esc || !focused) + && matches!( + app.interaction(), + Interaction::Object(_) | Interaction::Panel(_) + ) + { app.cancel_interaction(); return; } @@ -337,32 +343,153 @@ pub(crate) fn handle_object_interactions( }; let page_pos = screen_to_page_unbounded(app.session.board, &app.doc.canvases[ci], rect, screen_pos); + let editing_panel = app + .session + .ui + .hierarchical_selection + .editing_panel() + .filter(|(canvas, _)| *canvas == app.doc.canvases[ci].resource_id) + .map(|(_, panel)| panel); + + // Page scope owns the Panel frame. This is deliberately checked before + // content hit-testing so a child can never steal the parent drag. + if editing_panel.is_none() + && !alt + && let Some(panel_hit) = page_pos + .and_then(|point| hit_panel(&app.doc.canvases[ci], point, app.session.board.zoom)) + { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::CanvasObjects; + if shift || command { + if let Err(reason) = app.toggle_panel_sibling(ci, panel_hit.panel) { + app.session.status = reason.to_owned(); + } + } else { + app.select_panel(ci, panel_hit.panel); + if resp.double_clicked() { + app.enter_panel(ci, panel_hit.panel); + } else if app.doc.canvases[ci] + .panel(panel_hit.panel) + .is_some_and(|panel| !panel.locked) + { + begin_panel_drag( + app, + ci, + panel_hit.panel, + panel_hit.kind, + page_pos, + screen_pos, + false, + ); + } + } + return; + } let hit = page_pos.and_then(|page_pos| { - hit_object(&app.doc.canvases[ci], page_pos, app.session.board.zoom) + if editing_panel.is_some() || alt || command || resp.double_clicked() { + let hits = hit_content_objects( + &app.doc.canvases[ci], + page_pos, + app.session.board.zoom, + editing_panel, + ); + if !alt { + return hits.into_iter().next(); + } + let current = app + .session + .ui + .hierarchical_selection + .lead() + .and_then(|path| path.content); + let next = current + .and_then(|id| hits.iter().position(|hit| hit.object == id)) + .map_or(0, |index| (index + 1) % hits.len().max(1)); + return hits.get(next).copied(); + } + if !alt { + return hit_object(&app.doc.canvases[ci], page_pos, app.session.board.zoom); + } + let hits = hit_objects(&app.doc.canvases[ci], page_pos, app.session.board.zoom); + let current = app + .session + .ui + .hierarchical_selection + .lead() + .and_then(|path| path.content); + let next = current + .and_then(|id| hits.iter().position(|hit| hit.object == id)) + .map_or(0, |index| (index + 1) % hits.len().max(1)); + hits.get(next).copied() }); if let Some(hit) = hit { let id = hit.object; - if shift { + let parent = app.doc.canvases[ci].parent_panel(id); + if alt || command || (resp.double_clicked() && parent.is_some()) { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::CanvasObjects; + app.select_content(ci, id); + } else if shift { app.session.ui.selection_scope = plotx_core::state::SelectionScope::CanvasObjects; - app.toggle_object_selection(ci, id); + let editing_parent = app.session.ui.hierarchical_selection.editing_panel() + == parent.map(|panel| (app.doc.canvases[ci].resource_id, panel)); + let result = if let Some(panel) = parent.filter(|_| !editing_parent) { + app.toggle_panel_sibling(ci, panel) + } else { + app.toggle_content_sibling(ci, id) + }; + if let Err(reason) = result { + app.session.status = reason.to_owned(); + } } else { let keep_group = app.session.ui.selection.objects().len() > 1 && app.session.ui.selection.contains(id); if !keep_group { app.session.ui.selection_scope = plotx_core::state::SelectionScope::CanvasObjects; - app.select_object(ci, id); + let editing_parent = app.session.ui.hierarchical_selection.editing_panel() + == parent.map(|panel| (app.doc.canvases[ci].resource_id, panel)); + if let Some(panel) = parent.filter(|_| !editing_parent) { + app.select_panel(ci, panel); + } else { + app.select_object(ci, id); + } } if matches!(app.interaction(), Interaction::PanelLabel(_)) { app.reset_interaction(); } app.focus_object_datasets(ci, id); - if app.doc.canvases[ci] - .object(id) - .is_some_and(|object| !object.locked) - { - let Some(before) = app.doc.canvases[ci].layout_frame(id) else { + let editable = parent + .and_then(|panel| app.doc.canvases[ci].panel(panel)) + .map_or_else( + || { + app.doc.canvases[ci] + .object(id) + .is_some_and(|object| !object.locked) + }, + |panel| { + !panel.locked + && app.doc.canvases[ci] + .object(id) + .is_some_and(|object| !object.locked) + }, + ); + if editable { + let space = parent + .filter(|panel| editing_panel == Some(*panel)) + .map(ObjectDragSpace::Panel) + .unwrap_or(ObjectDragSpace::Page); + if parent.is_some() && matches!(space, ObjectDragSpace::Page) { + // A page-scope child is represented by its Panel. The + // Panel hit branch normally returned above; this guard + // keeps an out-of-bounds child from falling through to a + // misleading content drag. + return; + } + let Some(before) = (if matches!(space, ObjectDragSpace::Panel(_)) { + app.doc.canvases[ci].object(id).map(|object| object.frame) + } else { + app.doc.canvases[ci].layout_frame(id) + }) else { return; }; let start = page_pos.map(|p| [p.x, p.y]).unwrap_or([before.x, before.y]); @@ -379,9 +506,14 @@ pub(crate) fn handle_object_interactions( .object(oid) .filter(|o| !o.locked) .and_then(|_| { - app.doc.canvases[ci] - .layout_frame(oid) - .map(|frame| (oid, frame)) + let frame = if matches!(space, ObjectDragSpace::Panel(_)) { + app.doc.canvases[ci] + .object(oid) + .map(|object| object.frame) + } else { + app.doc.canvases[ci].layout_frame(oid) + }?; + Some((oid, frame)) }) }) .collect() @@ -398,6 +530,7 @@ pub(crate) fn handle_object_interactions( start_pointer_screen: [screen_pos.x, screen_pos.y], others, active: matches!(hit.kind, ObjectDragKind::Resize(_)), + space, })); } } @@ -426,7 +559,7 @@ pub(crate) fn handle_object_interactions( })); } } - + handle_panel_drag(app, ci, rect, hover, primary_down, primary_released, alt); let object_drag = match &app.session.ui.interaction { Interaction::Object(d) if d.canvas == ci => Some(d.clone()), _ => None, @@ -451,24 +584,52 @@ pub(crate) fn handle_object_interactions( d.active = active; } if active { - if update_tile_drop(app, ci, rect, &drag, hover) { + if let Some(source) = tile_source_for_object(app, &drag) + && update_tile_drop(app, ci, rect, source, hover) + { app.session.ui.snap_guides.clear(); } else { let candidate = drag_frame(drag.before, drag.kind, dpx, dpy); - let (snapped, guides) = snap_object_frame(app, ci, &drag, candidate, ui); + let (mut snapped, guides) = if let ObjectDragSpace::Panel(panel) = drag.space { + snap_panel_content_frame(app, ci, panel, &drag, candidate, ui) + } else { + snap_object_frame(app, ci, &drag, candidate, ui) + }; + let preserve_aspect = app.doc.canvases[ci].parent_panel(drag.object).is_some() + || app.doc.canvases[ci] + .object(drag.object) + .and_then(|item| match &item.kind { + CanvasObjectKind::RasterImage(image) => Some(image.preserve_aspect), + _ => None, + }) + .unwrap_or(false); + if preserve_aspect { + snapped = preserve_aspect_frame(drag.before, snapped, drag.kind); + } let applied = [snapped.x - drag.before.x, snapped.y - drag.before.y]; - app.doc.canvases[ci].set_layout_frame(drag.object, snapped); + if matches!(drag.space, ObjectDragSpace::Panel(_)) { + if let Some(object) = app.doc.canvases[ci].object_mut(drag.object) { + object.frame = snapped; + } + } else { + app.doc.canvases[ci].set_layout_frame(drag.object, snapped); + } for &(oid, before) in &drag.others { - app.doc.canvases[ci].set_layout_frame( - oid, - ObjectFrame::new( - before.x + applied[0], - before.y + applied[1], - before.width, - before.height, - ), + let frame = ObjectFrame::new( + before.x + applied[0], + before.y + applied[1], + before.width, + before.height, ); + if matches!(drag.space, ObjectDragSpace::Panel(_)) { + if let Some(object) = app.doc.canvases[ci].object_mut(oid) { + object.frame = frame; + } + } else { + app.doc.canvases[ci].set_layout_frame(oid, frame); + } } + update_content_drop_target(app, ci, &drag, pointer_page); app.session.ui.snap_guides = guides; } } @@ -477,14 +638,22 @@ pub(crate) fn handle_object_interactions( app.session.ui.snap_guides.clear(); if let Interaction::Object(drag) = app.take_interaction() { if let Some(preview) = app.session.ui.tile_drop.take() { - commit_tile_drop(app, drag, preview, alt); + if let Some(source) = tile_source_for_object(app, &drag) { + commit_tile_drop(app, source, preview, alt); + } else { + finish_object_drag(app, ci, drag); + } } else if active { - finish_object_drag(app, ci, drag); + let target = app.session.ui.panel_drop_target.take(); + if target.is_some() || matches!(drag.space, ObjectDragSpace::Panel(_)) { + finish_content_drag(app, ci, drag, target); + } else { + finish_object_drag(app, ci, drag); + } } } } } - if matches!(&app.session.ui.interaction, Interaction::Marquee(m) if m.canvas == ci) { if primary_down && let Some(p) = hover.and_then(|p| { @@ -502,41 +671,6 @@ pub(crate) fn handle_object_interactions( } } -const MARQUEE_CLICK_PT: f32 = 3.0; - -fn finish_marquee(app: &mut PlotxApp, ci: usize, marq: MarqueeDrag) { - let dx = (marq.current[0] - marq.start[0]).abs(); - let dy = (marq.current[1] - marq.start[1]).abs(); - if dx < MARQUEE_CLICK_PT && dy < MARQUEE_CLICK_PT { - if !marq.additive { - clear_canvas_interaction_state(app, ci, CanvasInteractionClearScope::Selection); - app.session.status = "Selection cleared.".to_owned(); - } - return; - } - let min_x = marq.start[0].min(marq.current[0]); - let max_x = marq.start[0].max(marq.current[0]); - let min_y = marq.start[1].min(marq.current[1]); - let max_y = marq.start[1].max(marq.current[1]); - let hits: Vec = app.doc.canvases[ci] - .objects - .iter() - .filter(|o| o.visible) - .filter(|o| { - let Some(f) = app.doc.canvases[ci].layout_frame(o.id) else { - return false; - }; - max_x >= f.x && min_x <= f.x + f.width && max_y >= f.y && min_y <= f.y + f.height - }) - .map(|o| o.id) - .collect(); - app.set_page_selection(ci, &hits, marq.additive); - app.session.status = format!( - "Selected {} object(s).", - app.session.ui.selection.objects().len() - ); -} - pub(crate) fn arrange_context_menu(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { if ui.button("Copy figure").clicked() { let ctx = ui.ctx().clone(); diff --git a/crates/app/src/ui/canvas/marquee_interactions.rs b/crates/app/src/ui/canvas/marquee_interactions.rs new file mode 100644 index 0000000..3d0a91b --- /dev/null +++ b/crates/app/src/ui/canvas/marquee_interactions.rs @@ -0,0 +1,73 @@ +use super::*; + +const MARQUEE_CLICK_PT: f32 = 3.0; + +pub(crate) fn finish_marquee(app: &mut PlotxApp, ci: usize, marq: MarqueeDrag) { + let dx = (marq.current[0] - marq.start[0]).abs(); + let dy = (marq.current[1] - marq.start[1]).abs(); + if dx < MARQUEE_CLICK_PT && dy < MARQUEE_CLICK_PT { + if !marq.additive { + clear_canvas_interaction_state(app, ci, CanvasInteractionClearScope::Selection); + app.session.status = "Selection cleared.".to_owned(); + } + return; + } + let min_x = marq.start[0].min(marq.current[0]); + let max_x = marq.start[0].max(marq.current[0]); + let min_y = marq.start[1].min(marq.current[1]); + let max_y = marq.start[1].max(marq.current[1]); + let canvas = &app.doc.canvases[ci]; + let canvas_id = canvas.resource_id; + let intersects = |frame: ObjectFrame| { + max_x >= frame.x + && min_x <= frame.x + frame.width + && max_y >= frame.y + && min_y <= frame.y + frame.height + }; + let editing_panel = app + .session + .ui + .hierarchical_selection + .editing_panel() + .filter(|(id, _)| *id == canvas_id) + .map(|(_, panel)| panel); + let paths: Vec<_> = if let Some(panel_id) = editing_panel { + canvas + .panel(panel_id) + .into_iter() + .flat_map(|panel| panel.item_order.iter()) + .filter_map(|id| { + canvas + .object(*id) + .filter(|item| item.visible) + .and_then(|_| canvas.content_page_frame(*id)) + .filter(|frame| intersects(*frame)) + .map(|_| { + plotx_core::state::SelectionPath::content(canvas_id, Some(panel_id), *id) + }) + }) + .collect() + } else { + let panels = canvas + .panels + .iter() + .filter(|panel| panel.visible && intersects(panel.frame)) + .map(|panel| plotx_core::state::SelectionPath::panel(canvas_id, panel.id)); + let loose = canvas + .objects + .iter() + .filter(|item| { + item.visible && canvas.parent_panel(item.id).is_none() && intersects(item.frame) + }) + .map(|item| plotx_core::state::SelectionPath::content(canvas_id, None, item.id)); + panels.chain(loose).collect() + }; + if let Err(reason) = app.set_hierarchical_paths(ci, &paths, marq.additive) { + app.session.status = reason.to_owned(); + return; + } + app.session.status = format!( + "Selected {} item(s).", + app.session.ui.hierarchical_selection.paths().len() + ); +} diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index bbbd8f5..644e242 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -1,18 +1,18 @@ use egui::{Color32, Pos2, Rect as EguiRect, Sense, Stroke, StrokeKind, Ui, Vec2}; -use plotx_core::actions::{Action, PendingViewportEdit, PendingWheelPropertyEdit}; +use plotx_core::actions::{Action, PanelState, PendingViewportEdit, PendingWheelPropertyEdit}; use plotx_core::layout::{self, MovableEdges, SnapGuide, SnapTargets}; use plotx_core::state::region_color; use plotx_core::state::{ AnalysisSelection, AuthorDrag, AxisRange, BOARD_GUTTER_PT, BoardFitTarget, BoardMarqueeDrag, BoardViewport, CanvasDocument, CanvasObject, CanvasObjectKind, Dataset, FrameDrag, FrameRef, FurnitureDrag, FurnitureTarget, Integral2DDrag, Integral2DDragKind, IntegralDrag, Interaction, - MarqueeDrag, ObjectDrag, ObjectDragKind, ObjectFrame, ObjectId, PanDrag, PanelLabelDrag, - PanelNoteEditState, PhaseDrag, PhaseDragKind, PhaseOrient, PlotxApp, Region, RegionDrag, - RegionDragKind, RegionId, RegionSelection, ResizeHandle, SHEET_COL_W_PT, SHEET_HEADER_H_PT, - SHEET_MAX_ROWS, SHEET_ROW_H_PT, Selection, SelectionDrag, TableDataset, TextEditState, - TileDropCacheKey, TileDropPreview, Tool, ZoomAxis, ZoomDrag, board_frame_id, board_frame_ref, - board_frames, frame_board_pos, frame_board_rect, set_frame_board_pos, - toggle_frame_selection_synced, + MarqueeDrag, ObjectDrag, ObjectDragKind, ObjectDragSpace, ObjectFrame, ObjectId, PanDrag, + PanelDrag, PanelId, PanelLabelDrag, PanelNoteEditState, PhaseDrag, PhaseDragKind, PhaseOrient, + PlotxApp, Region, RegionDrag, RegionDragKind, RegionId, RegionSelection, ResizeHandle, + SHEET_COL_W_PT, SHEET_HEADER_H_PT, SHEET_MAX_ROWS, SHEET_ROW_H_PT, Selection, SelectionDrag, + TableDataset, TextEditState, TileDropCacheKey, TileDropPreview, Tool, ZoomAxis, ZoomDrag, + board_frame_id, board_frame_ref, board_frames, frame_board_pos, frame_board_rect, + set_frame_board_pos, toggle_frame_selection_synced, }; use plotx_core::{Integral2D, IntegralResult}; use plotx_render::Rect as PlotRect; @@ -32,16 +32,23 @@ mod authoring; mod board; mod board_marquee; mod board_notes; +mod breadcrumb; mod chrome; mod cursors; mod furniture; mod geometry; +mod image_drop; +mod image_painting; +pub(crate) use image_drop::{ImagePanelDropTarget, image_drop_target}; mod integrals; mod integrals2d; mod interactions; +mod marquee_interactions; mod navigation; mod painting; +mod panel_interactions; mod panel_notes; +mod panel_selection; mod peaks; mod phase; mod readout; @@ -54,6 +61,7 @@ mod tiling; pub(crate) use authoring::*; pub(crate) use board::*; pub(crate) use board_notes::*; +pub(crate) use breadcrumb::*; pub(crate) use chrome::*; pub(crate) use cursors::*; pub(crate) use furniture::*; @@ -61,8 +69,10 @@ pub(crate) use geometry::*; pub(crate) use integrals::*; pub(crate) use integrals2d::*; pub(crate) use interactions::*; +pub(crate) use marquee_interactions::*; pub(crate) use navigation::*; pub(crate) use painting::*; +pub(crate) use panel_interactions::*; pub(crate) use panel_notes::*; pub(crate) use peaks::*; pub(crate) use phase::*; @@ -148,7 +158,6 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { sync_macos_trackpad_gesture(ui.ctx(), &ui.input(|i| i.events.clone()), pointer_owned); let view_consumed = pointer_owned && handle_navigation(app, ci, rect, ui); - // A live non-frame gesture cannot be interrupted by a frame switch. let frame_consumed = if pointer_owned && !view_consumed && app.session.ui.interaction.allows_frame_dispatch() { dispatch_frame_gesture(app, rect, ui) @@ -514,31 +523,6 @@ fn welcome_action(ui: &mut Ui, glyph: &str, label: &str) -> bool { .clicked() } -fn canvas_breadcrumb(app: &PlotxApp, ci: usize, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.add_space(2.0); - ui.small(app.doc.canvases[ci].name.clone()); - ui.weak("›"); - ui.small(app.session.tool.label()); - if let Some(id) = data_edit_target(app, ci).or_else(|| app.session.ui.selection.object()) { - let title = app.doc.canvases[ci] - .object(id) - .map(|object| { - app.doc.canvases[ci] - .panel_meta_for_content(object.id) - .and_then(|panel| panel.user_note.lines().next().map(str::to_owned)) - .filter(|line| !line.trim().is_empty()) - .unwrap_or_else(|| object.name.clone()) - }) - .unwrap_or_default(); - ui.weak("›"); - ui.small(format!("\"{title}\"")); - } - }); - ui.add_space(2.0); -} - -/// A specific handler that sets its own cursor later in the frame wins. fn canvas_cursor(app: &PlotxApp, ci: usize, rect: egui::Rect, ui: &Ui) { let Some(p) = ui .input(|i| i.pointer.hover_pos()) @@ -546,8 +530,6 @@ fn canvas_cursor(app: &PlotxApp, ci: usize, rect: egui::Rect, ui: &Ui) { else { return; }; - // Ambient pan reads on top of the tool cursor: an active data-pan grabs, and - // holding Space arms the hand anywhere on the board. if matches!(app.session.ui.interaction, Interaction::Furniture(_)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); return; @@ -565,10 +547,33 @@ fn canvas_cursor(app: &PlotxApp, ci: usize, rect: egui::Rect, ui: &Ui) { return; } let icon = if app.session.tool.is_layout_tool() { - match screen_to_page_unbounded(app.session.board, &app.doc.canvases[ci], rect, p) - .and_then(|page| hit_object(&app.doc.canvases[ci], page, app.session.board.zoom)) - { - Some(hit) => match hit.kind { + let editing_panel = app + .session + .ui + .hierarchical_selection + .editing_panel() + .filter(|(canvas, _)| *canvas == app.doc.canvases[ci].resource_id) + .map(|(_, panel)| panel); + match screen_to_page_unbounded(app.session.board, &app.doc.canvases[ci], rect, p).and_then( + |page| { + if let Some(panel) = editing_panel { + return hit_content_object( + &app.doc.canvases[ci], + page, + app.session.board.zoom, + Some(panel), + ) + .map(|hit| hit.kind); + } + hit_panel(&app.doc.canvases[ci], page, app.session.board.zoom) + .map(|hit| hit.kind) + .or_else(|| { + hit_object(&app.doc.canvases[ci], page, app.session.board.zoom) + .map(|hit| hit.kind) + }) + }, + ) { + Some(kind) => match kind { ObjectDragKind::Move => egui::CursorIcon::Grab, ObjectDragKind::Resize(handle) => resize_cursor(handle), }, diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index 567cb51..5276899 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -522,28 +522,7 @@ pub(crate) fn paint_selection_drag( } pub(crate) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painter: &egui::Painter) { - let canvas = &app.doc.canvases[ci]; - let [width, height] = canvas.size_pt(); - let document = plotx_render::Document { - width, - height, - background: canvas.background, - items: plotx_core::state::document_items(canvas), - }; - let zoom = app.session.board.zoom; - let bp = canvas.board_pos; - plotx_render::screen::paint_document_for_editor( - painter, - PlotRect::new(rect.left(), rect.top(), rect.width(), rect.height()), - &document, - plotx_render::DocumentViewport { - zoom, - pan: [ - app.session.board.pan[0] + bp[0] * zoom, - app.session.board.pan[1] + bp[1] * zoom, - ], - }, - ); + super::image_painting::paint_document(app, ci, rect, painter); } pub(crate) fn paint_layout_overlay( @@ -677,17 +656,29 @@ pub(crate) fn paint_object_selection( painter: &egui::Painter, chrome: ChromeStyle, ) { + super::panel_selection::paint(app, ci, rect, painter, chrome); let selection = &app.session.ui.selection; let mut ids = selection.objects().to_vec(); if let Some(primary) = selection.object().filter(|id| !ids.contains(id)) { ids.push(primary); } let handles = ids.len() == 1 && app.session.tool.is_layout_tool(); + let editing_panel = app + .session + .ui + .hierarchical_selection + .editing_panel() + .filter(|(canvas, _)| *canvas == app.doc.canvases[ci].resource_id); for id in ids { - let Some(frame) = object_screen_rect(app.session.board, &app.doc.canvases[ci], id, rect) - else { - continue; - }; + let frame = editing_panel + .and_then(|(_, panel)| { + (app.doc.canvases[ci].parent_panel(id) == Some(panel)).then(|| { + content_screen_rect(app.session.board, &app.doc.canvases[ci], id, rect) + }) + }) + .flatten() + .or_else(|| object_screen_rect(app.session.board, &app.doc.canvases[ci], id, rect)); + let Some(frame) = frame else { continue }; let r = plot_rect(frame); let stroke = if data_edit_target(app, ci) == Some(id) { Stroke::new(2.0_f32, chrome.selection_active) diff --git a/crates/app/src/ui/canvas/panel_interactions.rs b/crates/app/src/ui/canvas/panel_interactions.rs new file mode 100644 index 0000000..6da1b72 --- /dev/null +++ b/crates/app/src/ui/canvas/panel_interactions.rs @@ -0,0 +1,265 @@ +use super::*; + +pub(crate) fn handle_panel_drag( + app: &mut PlotxApp, + ci: usize, + rect: egui::Rect, + hover: Option, + primary_down: bool, + primary_released: bool, + alt: bool, +) { + let Some(drag) = (match &app.session.ui.interaction { + Interaction::Panel(d) if d.canvas == ci => Some(d.clone()), + _ => None, + }) else { + return; + }; + let mut active = drag.active; + if primary_down + && let Some(screen_now) = hover + && let Some(pointer_page) = + screen_to_page_unbounded(app.session.board, &app.doc.canvases[ci], rect, screen_now) + { + let dpx = pointer_page.x - drag.start_pointer[0]; + let dpy = pointer_page.y - drag.start_pointer[1]; + let dsx = screen_now.x - drag.start_pointer_screen[0]; + let dsy = screen_now.y - drag.start_pointer_screen[1]; + active |= dsx.hypot(dsy) > DRAG_START_PX; + if let Interaction::Panel(current) = &mut app.session.ui.interaction { + current.active = active; + } + if active { + let frame = drag_frame(drag.before, drag.kind, dpx, dpy); + if let Some(page) = app.doc.canvases.get_mut(ci) { + let scale = [ + frame.width / drag.before.width, + frame.height / drag.before.height, + ]; + if matches!(drag.kind, ObjectDragKind::Resize(_)) { + for &(id, before) in &drag.children { + if let Some(object) = page.object_mut(id) { + object.frame = ObjectFrame::new( + before.x * scale[0], + before.y * scale[1], + before.width * scale[0], + before.height * scale[1], + ); + } + } + } + if let Some(panel) = page.panel_mut(drag.panel) { + panel.frame = frame; + } + if matches!(drag.kind, ObjectDragKind::Move) { + let delta = [frame.x - drag.before.x, frame.y - drag.before.y]; + for &(id, before) in &drag.others { + if let Some(panel) = page.panel_mut(id) { + panel.frame = ObjectFrame::new( + before.x + delta[0], + before.y + delta[1], + before.width, + before.height, + ); + } + } + } + } + if let Some(source) = tile_source_for_panel(app, &drag) + && update_tile_drop(app, ci, rect, source, hover) + { + app.session.ui.snap_guides.clear(); + } + } + } + if (primary_released || !primary_down) + && let Interaction::Panel(drag) = app.take_interaction() + && active + { + if let Some(preview) = app.session.ui.tile_drop.take() + && let Some(source) = tile_source_for_panel(app, &drag) + { + commit_tile_drop(app, source, preview, alt); + } else { + finish_panel_drag(app, ci, drag); + } + } +} + +pub(crate) fn begin_panel_drag( + app: &mut PlotxApp, + ci: usize, + panel_id: PanelId, + kind: ObjectDragKind, + page_pos: Option, + screen_pos: Pos2, + preserve_selection: bool, +) { + let Some(panel) = app.doc.canvases[ci].panel(panel_id).cloned() else { + return; + }; + let children = panel + .item_order + .iter() + .filter_map(|id| { + app.doc.canvases[ci] + .object(*id) + .map(|object| (*id, object.frame)) + }) + .collect(); + let others = if preserve_selection && matches!(kind, ObjectDragKind::Move) { + app.session + .ui + .hierarchical_selection + .paths() + .iter() + .filter_map(|path| { + let id = path.panel.filter(|id| *id != panel_id)?; + app.doc.canvases[ci] + .panel(id) + .filter(|panel| !panel.locked) + .map(|panel| (id, panel.frame)) + }) + .collect() + } else { + Vec::new() + }; + let start = page_pos + .map(|p| [p.x, p.y]) + .unwrap_or([panel.frame.x, panel.frame.y]); + freeze_board_for_gesture(app); + app.begin_interaction(Interaction::Panel(PanelDrag { + canvas: ci, + panel: panel_id, + kind, + before: panel.frame, + others, + children, + start_pointer: start, + start_pointer_screen: [screen_pos.x, screen_pos.y], + active: matches!(kind, ObjectDragKind::Resize(_)), + })); +} + +fn update_panel_drop_target(app: &mut PlotxApp, ci: usize, drag: &ObjectDrag, point: Pos2) { + let source = app.doc.canvases[ci].parent_panel(drag.object); + let target = app.doc.canvases[ci] + .panels + .iter() + .rev() + .find(|panel| { + Some(panel.id) != source + && panel.visible + && !panel.locked + && point.x >= panel.frame.x + && point.x <= panel.frame.x + panel.frame.width + && point.y >= panel.frame.y + && point.y <= panel.frame.y + panel.frame.height + }) + .map(|panel| panel.id); + app.session.ui.panel_drop_target = target; +} + +pub(crate) fn update_content_drop_target( + app: &mut PlotxApp, + ci: usize, + drag: &ObjectDrag, + point: Pos2, +) { + update_panel_drop_target(app, ci, drag, point); +} + +fn finish_panel_drag(app: &mut PlotxApp, ci: usize, drag: PanelDrag) { + let Some(page) = app.doc.canvases.get(ci) else { + return; + }; + let layout = page.panel(drag.panel).map(|panel| { + ( + panel.layout, + panel.layout_gap, + panel.layout_padding, + panel.layout_alignment, + ) + }); + let after = match layout + .filter(|(layout, ..)| *layout != plotx_core::state::PanelLayout::Free) + .map(|(layout, gap, padding, alignment)| { + app.set_panel_layout_action(ci, drag.panel, layout, gap, padding, alignment) + }) { + Some(Ok(Action::ReplacePanelState { after, .. })) => after, + Some(Ok(_)) => PanelState::of(&app.doc.canvases[ci]), + Some(Err(error)) => { + app.session.status = format!("Could not apply Panel layout: {error}"); + PanelState::of(&app.doc.canvases[ci]) + } + None => PanelState::of(&app.doc.canvases[ci]), + }; + let mut before = after.clone(); + if let Some(panel) = before + .panels + .iter_mut() + .find(|panel| panel.id == drag.panel) + { + panel.frame = drag.before; + } + for (id, frame) in drag.others { + if let Some(panel) = before.panels.iter_mut().find(|panel| panel.id == id) { + panel.frame = frame; + } + } + for (id, frame) in drag.children { + if let Some(object) = before.objects.iter_mut().find(|object| object.id == id) { + object.frame = frame; + } + } + app.execute_action(Action::ReplacePanelState { + canvas: ci, + before, + after, + }); + app.rebuild_canvas(ci); +} + +pub(crate) fn finish_content_drag( + app: &mut PlotxApp, + ci: usize, + drag: ObjectDrag, + target: Option, +) { + let mut contents = vec![drag.object]; + contents.extend(drag.others.iter().map(|(id, _)| *id)); + contents.sort_unstable(); + contents.dedup(); + + let Some(page) = app.doc.canvases.get(ci) else { + return; + }; + let mut before = PanelState::of(page); + for (id, frame) in + std::iter::once((drag.object, drag.before)).chain(drag.others.iter().copied()) + { + if let Some(object) = before.objects.iter_mut().find(|object| object.id == id) { + object.frame = frame; + } + } + + let after = match target { + Some(panel) => { + match app.move_contents_to_panel_action(ci, &contents, Some(panel), usize::MAX) { + Ok(Action::ReplacePanelState { after, .. }) => after, + Ok(_) => PanelState::of(&app.doc.canvases[ci]), + Err(error) => { + app.session.status = format!("Could not move content to Panel: {error}"); + PanelState::of(&app.doc.canvases[ci]) + } + } + } + None => PanelState::of(&app.doc.canvases[ci]), + }; + app.execute_action(Action::ReplacePanelState { + canvas: ci, + before, + after, + }); + app.rebuild_canvas(ci); +} diff --git a/crates/app/src/ui/canvas/panel_notes.rs b/crates/app/src/ui/canvas/panel_notes.rs index d44baa6..54e1634 100644 --- a/crates/app/src/ui/canvas/panel_notes.rs +++ b/crates/app/src/ui/canvas/panel_notes.rs @@ -170,7 +170,8 @@ pub(crate) fn panel_label_screen_rect( ) -> Option { let frame = object_screen_rect(board, canvas, object_id, screen)?; let panel = canvas.panel_meta_for_content(object_id)?; - if !panel.visible { + let panel_id = canvas.parent_panel(object_id)?; + if !canvas.panel_label_is_displayed(panel_id) { return None; } let letter = canvas.panel_letter(object_id)?; diff --git a/crates/app/src/ui/canvas/panel_selection.rs b/crates/app/src/ui/canvas/panel_selection.rs new file mode 100644 index 0000000..a753bd0 --- /dev/null +++ b/crates/app/src/ui/canvas/panel_selection.rs @@ -0,0 +1,110 @@ +use super::*; + +pub(super) fn paint( + app: &PlotxApp, + ci: usize, + rect: egui::Rect, + painter: &egui::Painter, + chrome: ChromeStyle, +) { + let canvas = &app.doc.canvases[ci]; + let bt = BoardTransform::from_board(app.session.board, rect); + let page = bt.page_screen_rect(canvas); + if let Some(target) = app.session.ui.panel_drop_target + && let Some(panel) = canvas.panel(target) + && panel.visible + { + let target_rect = screen_rect(page, bt.zoom, panel.frame); + painter.rect_filled(target_rect, 0.0, chrome.selection_fill.gamma_multiply(0.35)); + painter.rect_stroke( + target_rect, + 0.0, + Stroke::new(2.0_f32, chrome.selection_active), + StrokeKind::Inside, + ); + } + // Editing a child switches the selection affordance to local coordinates, + // but the Panel remains the spatial container. Keep its boundary visible as + // a quiet orientation cue without presenting it as the active selection. + if let Some((canvas_id, panel_id)) = app.session.ui.hierarchical_selection.editing_panel() + && canvas_id == canvas.resource_id + && let Some(panel) = canvas.panel(panel_id).filter(|panel| panel.visible) + { + let panel_rect = screen_rect(page, bt.zoom, panel.frame); + painter.rect_stroke( + panel_rect, + 0.0, + Stroke::new(1.0_f32, chrome.selection_stroke.gamma_multiply(0.38)), + StrokeKind::Inside, + ); + } + for panel in canvas + .panels + .iter() + .filter(|panel| panel.visible && panel.item_order.is_empty()) + { + let panel_rect = screen_rect(page, bt.zoom, panel.frame); + painter.rect_stroke( + panel_rect, + 0.0, + Stroke::new(1.0_f32, chrome.selection_stroke.gamma_multiply(0.45)), + StrokeKind::Inside, + ); + painter.text( + panel_rect.center(), + egui::Align2::CENTER_CENTER, + "Empty panel", + egui::FontId::proportional(11.0), + chrome.selection_stroke.gamma_multiply(0.65), + ); + } + let selected_panels: Vec<_> = app + .session + .ui + .hierarchical_selection + .paths() + .iter() + .filter_map(|path| { + (path.canvas == canvas.resource_id && path.content.is_none()) + .then_some(path.panel) + .flatten() + }) + .filter_map(|id| canvas.panel(id)) + .collect(); + let primary = app + .session + .ui + .hierarchical_selection + .lead() + .and_then(|path| (path.content.is_none()).then_some(path.panel).flatten()); + for panel in selected_panels { + let panel_rect = screen_rect(page, bt.zoom, panel.frame); + painter.rect_stroke( + panel_rect, + 0.0, + Stroke::new(1.5_f32, chrome.selection_stroke), + StrokeKind::Inside, + ); + if primary == Some(panel.id) && app.session.tool.is_layout_tool() && !panel.locked { + for point in [ + panel_rect.left_top(), + panel_rect.right_top(), + panel_rect.left_bottom(), + panel_rect.right_bottom(), + ] { + painter.rect_filled( + egui::Rect::from_center_size(point, egui::vec2(HANDLE_SIZE_PX, HANDLE_SIZE_PX)), + 0.0, + chrome.selection_stroke, + ); + } + } + } +} + +fn screen_rect(page: EguiRect, zoom: f32, frame: ObjectFrame) -> EguiRect { + EguiRect::from_min_size( + Pos2::new(page.left() + frame.x * zoom, page.top() + frame.y * zoom), + egui::vec2(frame.width * zoom, frame.height * zoom), + ) +} diff --git a/crates/app/src/ui/canvas/snap.rs b/crates/app/src/ui/canvas/snap.rs index 4ae0b99..5a68698 100644 --- a/crates/app/src/ui/canvas/snap.rs +++ b/crates/app/src/ui/canvas/snap.rs @@ -121,6 +121,63 @@ pub(crate) fn snap_object_frame( } } +/// Snap a Panel child in Panel-local coordinates. Guides are translated back to +/// page coordinates because the overlay is deliberately rendered in page space. +pub(crate) fn snap_panel_content_frame( + app: &PlotxApp, + ci: usize, + panel_id: PanelId, + drag: &ObjectDrag, + candidate: ObjectFrame, + ui: &Ui, +) -> (ObjectFrame, Vec) { + let alt = ui.input(|i| i.modifiers.alt); + if !app.settings.general.snap_enabled || alt { + return (candidate, Vec::new()); + } + let canvas = &app.doc.canvases[ci]; + let Some(panel) = canvas.panel(panel_id) else { + return (candidate, Vec::new()); + }; + let zoom = app.session.board.zoom.max(0.01); + let threshold = SNAP_PX / zoom; + let mut targets = SnapTargets::from_page( + [panel.frame.width, panel.frame.height], + &plotx_core::layout::PageLayout::default(), + ); + for &content in &panel.item_order { + let moving = content == drag.object || drag.others.iter().any(|(id, _)| *id == content); + if !moving + && canvas.object(content).is_some_and(|item| item.visible) + && let Some(frame) = canvas.object(content).map(|item| item.frame) + { + targets.push_object(frame); + } + } + let (frame, guides) = match drag.kind { + ObjectDragKind::Move => layout::snap_move(candidate, &targets, threshold), + ObjectDragKind::Resize(handle) => layout::snap_resize( + candidate, + movable_edges(handle), + &targets, + threshold, + MIN_OBJECT_SIZE_PT, + ), + }; + let guides = guides + .into_iter() + .map(|mut guide| { + guide.pos += if guide.vertical { + panel.frame.x + } else { + panel.frame.y + }; + guide + }) + .collect(); + (frame, guides) +} + pub(crate) fn movable_edges(handle: ResizeHandle) -> MovableEdges { let (left, right) = ( matches!(handle, ResizeHandle::TopLeft | ResizeHandle::BottomLeft), diff --git a/crates/app/src/ui/canvas/tiling.rs b/crates/app/src/ui/canvas/tiling.rs index 755a6f6..acb5359 100644 --- a/crates/app/src/ui/canvas/tiling.rs +++ b/crates/app/src/ui/canvas/tiling.rs @@ -1,30 +1,94 @@ use super::*; -fn drag_is_tileable(app: &PlotxApp, drag: &ObjectDrag) -> bool { - if drag.kind != ObjectDragKind::Move || !drag.others.is_empty() { - return false; - } - if app.session.ui.selection.objects().len() > 1 { - return false; - } - app.doc.canvases.get(drag.canvas).is_some_and(|canvas| { - canvas - .object(drag.object) - .is_some_and(|o| canvas.content_group(o.id).is_none() && o.plot().is_some()) +/// The document-level source of an automatic canvas tiling gesture. A one-item +/// Panel and a loose object use the same source so changing page-scope selection +/// can never change the resulting transfer semantics. +#[derive(Clone, Copy, Debug)] +pub(crate) struct TileTransferSource { + pub(crate) canvas: usize, + pub(crate) object: ObjectId, + pub(crate) frame: ObjectFrame, + pub(crate) start_pointer: [f32; 2], + pub(crate) panel: Option, +} + +pub(crate) fn tile_source_for_object( + app: &PlotxApp, + drag: &ObjectDrag, +) -> Option { + (drag.kind == ObjectDragKind::Move && drag.others.is_empty()).then_some(())?; + tile_source( + app, + drag.canvas, + drag.object, + drag.before, + drag.start_pointer, + None, + ) +} + +pub(crate) fn tile_source_for_panel( + app: &PlotxApp, + drag: &PanelDrag, +) -> Option { + (drag.kind == ObjectDragKind::Move && drag.others.is_empty()).then_some(())?; + let canvas = app.doc.canvases.get(drag.canvas)?; + let panel = canvas.panel(drag.panel)?; + (panel.item_order.len() == 1).then_some(())?; + tile_source( + app, + drag.canvas, + panel.item_order[0], + drag.before, + drag.start_pointer, + Some(drag.panel), + ) +} + +fn tile_source( + app: &PlotxApp, + canvas_index: usize, + object: ObjectId, + frame: ObjectFrame, + start_pointer: [f32; 2], + panel: Option, +) -> Option { + let canvas = app.doc.canvases.get(canvas_index)?; + let item = canvas.object(object)?; + (canvas.content_group(object).is_none() + && matches!( + item.kind, + CanvasObjectKind::Plot(_) | CanvasObjectKind::RasterImage(_) + )) + .then_some(TileTransferSource { + canvas: canvas_index, + object, + frame, + start_pointer, + panel, }) } +pub(crate) fn restore_tile_source(app: &mut PlotxApp, source: TileTransferSource) { + let Some(canvas) = app.doc.canvases.get_mut(source.canvas) else { + return; + }; + if let Some(panel) = source.panel { + if let Some(panel) = canvas.panel_mut(panel) { + panel.frame = source.frame; + } + } else { + canvas.set_layout_frame(source.object, source.frame); + } +} + pub(crate) fn update_tile_drop( app: &mut PlotxApp, _ci: usize, rect: egui::Rect, - drag: &ObjectDrag, + source: TileTransferSource, pointer_screen: Option, ) -> bool { - if !drag_is_tileable(app, drag) { - app.session.ui.tile_drop = None; - return false; - } let Some(p) = pointer_screen else { app.session.ui.tile_drop = None; return false; @@ -33,7 +97,7 @@ pub(crate) fn update_tile_drop( app.session.ui.tile_drop = None; return false; }; - if target == drag.canvas { + if target == source.canvas { app.session.ui.tile_drop = None; return false; } @@ -41,7 +105,7 @@ pub(crate) fn update_tile_drop( let pointer_page = bt.screen_to_page(&app.doc.canvases[target], p); let page_pt = app.doc.canvases[target].size_pt(); let layout = app.doc.canvases[target].layout; - let existing_ids = app.doc.canvases[target].plot_object_ids(); + let existing_ids = tileable_object_ids(&app.doc.canvases[target]); let region = plotx_core::layout::tiling_drop_region( page_pt, existing_ids.len(), @@ -54,7 +118,7 @@ pub(crate) fn update_tile_drop( [pointer_page.x, pointer_page.y], ); let cache_key = tile_cache_key( - drag, + source, target, page_pt, layout, @@ -72,14 +136,14 @@ pub(crate) fn update_tile_drop( if let Some(preview) = app.session.ui.tile_drop.as_mut() { preview.pointer_screen = [p.x, p.y]; } - app.doc.canvases[drag.canvas].set_layout_frame(drag.object, drag.before); + restore_tile_source(app, source); return true; } let existing_items: Vec<_> = existing_ids .iter() .filter_map(|&id| layout_item(&app.doc.canvases[target], id)) .collect(); - let Some(newcomer_item) = layout_item(&app.doc.canvases[drag.canvas], drag.object) else { + let Some(newcomer_item) = layout_item(&app.doc.canvases[source.canvas], source.object) else { app.session.ui.tile_drop = None; return false; }; @@ -95,11 +159,12 @@ pub(crate) fn update_tile_drop( target, newcomer: plan.newcomer, existing: plan.existing, + source_frame: source.frame, pointer_screen: [p.x, p.y], anchor: [ - ((drag.start_pointer[0] - drag.before.x) / drag.before.width.max(f32::EPSILON)) + ((source.start_pointer[0] - source.frame.x) / source.frame.width.max(f32::EPSILON)) .clamp(0.0, 1.0), - ((drag.start_pointer[1] - drag.before.y) / drag.before.height.max(f32::EPSILON)) + ((source.start_pointer[1] - source.frame.y) / source.frame.height.max(f32::EPSILON)) .clamp(0.0, 1.0), ], }); @@ -108,12 +173,12 @@ pub(crate) fn update_tile_drop( } else { "Hold Alt to keep the empty source canvas.".into() }; - app.doc.canvases[drag.canvas].set_layout_frame(drag.object, drag.before); + restore_tile_source(app, source); true } fn tile_cache_key( - drag: &ObjectDrag, + source: TileTransferSource, target_canvas: usize, target_page_pt: [f32; 2], target_layout: plotx_core::layout::PageLayout, @@ -122,8 +187,8 @@ fn tile_cache_key( pointer_cell: Option, ) -> TileDropCacheKey { TileDropCacheKey { - source_canvas: drag.canvas, - source_object: drag.object, + source_canvas: source.canvas, + source_object: source.object, target_canvas, target_page_pt, target_layout, @@ -135,70 +200,80 @@ fn tile_cache_key( fn layout_item(canvas: &CanvasDocument, id: ObjectId) -> Option { let object = canvas.object(id)?; - let plot = object.plot()?; - Some(plotx_core::layout::layout_item( - id, - plot.figure(), - canvas.layout_frame(id)?, - )) + let frame = canvas.layout_frame(id)?; + match &object.kind { + CanvasObjectKind::Plot(plot) => { + Some(plotx_core::layout::layout_item(id, plot.figure(), frame)) + } + CanvasObjectKind::RasterImage(_) => Some(plotx_core::layout::LayoutItem { + id, + insets: [0.0; 4], + }), + CanvasObjectKind::Text(_) | CanvasObjectKind::Shape(_) => None, + } +} + +fn tileable_object_ids(canvas: &CanvasDocument) -> Vec { + canvas + .objects + .iter() + .filter(|object| { + matches!( + object.kind, + CanvasObjectKind::Plot(_) | CanvasObjectKind::RasterImage(_) + ) + }) + .map(|object| object.id) + .collect() } /// Falls back to a plain move if the atomic action cannot be built. pub(crate) fn commit_tile_drop( app: &mut PlotxApp, - drag: ObjectDrag, + source: TileTransferSource, preview: TileDropPreview, alt: bool, ) { let remove_empty_source = app.settings.general.keep_empty_source_canvas == alt; let source_becomes_empty = - app.doc.canvases.get(drag.canvas).is_some_and(|canvas| { - canvas.objects.len() == 1 && canvas.object(drag.object).is_some() + app.doc.canvases.get(source.canvas).is_some_and(|canvas| { + canvas.objects.len() == 1 && canvas.object(source.object).is_some() }); let Some(action) = Action::tile_drop( app, - drag.canvas, - drag.object, + source.canvas, + source.object, preview.target, preview.newcomer, preview.existing, remove_empty_source, ) else { - if drag.active { - finish_object_drag(app, drag.canvas, drag); - } + restore_tile_source(app, source); + app.session.status = "Could not tile this content into the destination canvas.".to_owned(); return; }; let target = app.doc.canvases[preview.target].name.clone(); app.execute_action(action); app.session.status = if remove_empty_source && source_becomes_empty { - format!("Tiled plot into “{target}” and removed the empty source canvas.") + format!("Tiled content into “{target}” and removed the empty source canvas.") } else { - format!("Tiled plot into “{target}”; kept the source canvas.") + format!("Tiled content into “{target}”; kept the source canvas.") }; } pub(crate) fn paint_tile_ghost(app: &PlotxApp, painter: &egui::Painter, chrome: ChromeStyle) { - let (Some(preview), Interaction::Object(drag)) = - (&app.session.ui.tile_drop, &app.session.ui.interaction) - else { + let Some(preview) = &app.session.ui.tile_drop else { return; }; - if preview.cache_key.source_canvas != drag.canvas - || preview.cache_key.source_object != drag.object - { - return; - } - let Some(plot) = app + let object = app .doc .canvases - .get(drag.canvas) - .and_then(|canvas| canvas.object(drag.object)) - .and_then(|object| object.plot()) - else { + .get(preview.cache_key.source_canvas) + .and_then(|canvas| canvas.object(preview.cache_key.source_object)); + let Some(object) = object else { return; }; - let ghost = preview.ghost_frame(drag.before, app.session.board.zoom); + let ghost = preview.ghost_frame(app.session.board.zoom); if ![ghost.x, ghost.y, ghost.width, ghost.height] .iter() .all(|v| v.is_finite()) @@ -206,7 +281,9 @@ pub(crate) fn paint_tile_ghost(app: &PlotxApp, painter: &egui::Painter, chrome: return; } let screen = PlotRect::new(ghost.x, ghost.y, ghost.width, ghost.height); - plotx_render::screen::paint(painter, screen, plot.figure(), app.session.board.zoom); + if let Some(plot) = object.plot() { + plotx_render::screen::paint(painter, screen, plot.figure(), app.session.board.zoom); + } let r = EguiRect::from_min_size( Pos2::new(ghost.x, ghost.y), Vec2::new(ghost.width, ghost.height), @@ -264,16 +341,13 @@ pub(crate) fn paint_tile_preview( mod tests { use super::*; - fn drag(canvas: usize, object: u64) -> ObjectDrag { - ObjectDrag { + fn tile_source_for_test(canvas: usize, object: u64) -> TileTransferSource { + TileTransferSource { canvas, object: ObjectId::new(object), - kind: ObjectDragKind::Move, - before: ObjectFrame::new(0.0, 0.0, 10.0, 10.0), + frame: ObjectFrame::new(0.0, 0.0, 10.0, 10.0), start_pointer: [0.0; 2], - start_pointer_screen: [0.0; 2], - others: Vec::new(), - active: true, + panel: None, } } @@ -284,7 +358,7 @@ mod tests { let ids = [ObjectId::new(20), ObjectId::new(21)]; let reversed_ids = [ObjectId::new(21), ObjectId::new(20)]; let base = tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 2, page, layout, @@ -295,7 +369,7 @@ mod tests { assert_eq!( base, tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 2, page, layout, @@ -307,7 +381,7 @@ mod tests { assert_ne!( base, tile_cache_key( - &drag(1, 11), + tile_source_for_test(1, 11), 2, page, layout, @@ -319,7 +393,7 @@ mod tests { assert_ne!( base, tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 3, page, layout, @@ -331,7 +405,7 @@ mod tests { assert_ne!( base, tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 2, [401.0, 300.0], layout, @@ -343,7 +417,7 @@ mod tests { assert_ne!( base, tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 2, page, plotx_core::layout::PageLayout { cols: 2, ..layout }, @@ -355,7 +429,7 @@ mod tests { assert_ne!( base, tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 2, page, layout, @@ -367,7 +441,7 @@ mod tests { assert_ne!( base, tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 2, page, layout, @@ -377,7 +451,7 @@ mod tests { ) ); let retile_top_left = tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 2, page, layout, @@ -386,7 +460,7 @@ mod tests { Some(0), ); let retile_bottom_right = tile_cache_key( - &drag(0, 10), + tile_source_for_test(0, 10), 2, page, layout, diff --git a/crates/app/src/ui/clipboard_native.rs b/crates/app/src/ui/clipboard_native.rs index 3253a68..4b30f8c 100644 --- a/crates/app/src/ui/clipboard_native.rs +++ b/crates/app/src/ui/clipboard_native.rs @@ -2,6 +2,7 @@ //! "PNG") and vector ("image/svg+xml", CF_ENHMETAFILE) formats at once, so //! each paste target picks the richest one it understands. +use std::cell::Cell; use std::fmt; use windows_sys::Win32::Foundation::{GetLastError, GlobalFree, HANDLE, HGLOBAL}; use windows_sys::Win32::Graphics::Gdi::{DeleteEnhMetaFile, SetEnhMetaFileBits}; @@ -13,6 +14,17 @@ use windows_sys::Win32::System::Memory::{ GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, }; use windows_sys::Win32::System::Ole::{CF_DIBV5, CF_ENHMETAFILE, CF_UNICODETEXT}; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + GetAsyncKeyState, VK_CONTROL, VK_SHIFT, VK_V, +}; +use windows_sys::Win32::UI::Shell::DragQueryFileW; + +const CF_DIB_FORMAT: u32 = 8; +const CF_HDROP_FORMAT: u32 = 15; + +thread_local! { + static PASTE_CHORD_DOWN: Cell = const { Cell::new(false) }; +} pub(super) const PLOTX_TABLE_SCHEMA_MIME: &str = "application/vnd.plotx.table-schema+json;version=1"; @@ -25,7 +37,14 @@ pub(super) struct FormatOutcome { #[derive(Debug)] pub(super) enum NativeClipboardError { - OpenFailed { last_error: u32 }, + OpenFailed { + last_error: u32, + }, + ReadFailed { + operation: &'static str, + last_error: u32, + }, + BitmapDecode(String), } impl fmt::Display for NativeClipboardError { @@ -34,6 +53,11 @@ impl fmt::Display for NativeClipboardError { Self::OpenFailed { last_error } => { write!(f, "the clipboard could not be opened (error {last_error})") } + Self::ReadFailed { + operation, + last_error, + } => write!(f, "{operation} failed (error {last_error})"), + Self::BitmapDecode(error) => write!(f, "could not decode clipboard bitmap: {error}"), } } } @@ -125,6 +149,304 @@ pub(super) fn get_table_schema() -> Result, NativeClipboardError> Ok(String::from_utf8(bytes).ok()) } +pub(super) fn get_file_list() -> Result, NativeClipboardError> { + if unsafe { IsClipboardFormatAvailable(CF_HDROP_FORMAT) } == 0 { + return Ok(Vec::new()); + } + let _guard = ClipboardGuard::open()?; + let handle = unsafe { GetClipboardData(CF_HDROP_FORMAT) }; + if handle.is_null() { + return Err(read_failed("GetClipboardData(CF_HDROP)")); + } + let count = unsafe { DragQueryFileW(handle, u32::MAX, std::ptr::null_mut(), 0) }; + let mut paths = Vec::with_capacity(count as usize); + for index in 0..count { + let len = unsafe { DragQueryFileW(handle, index, std::ptr::null_mut(), 0) }; + let mut buffer = vec![0_u16; len as usize + 1]; + let written = + unsafe { DragQueryFileW(handle, index, buffer.as_mut_ptr(), buffer.len() as u32) }; + buffer.truncate(written as usize); + paths.push(std::path::PathBuf::from(String::from_utf16_lossy(&buffer))); + } + Ok(paths) +} + +pub(super) fn get_dib_image() -> Result, NativeClipboardError> { + let format = if unsafe { IsClipboardFormatAvailable(CF_DIBV5 as u32) } != 0 { + CF_DIBV5 as u32 + } else if unsafe { IsClipboardFormatAvailable(CF_DIB_FORMAT) } != 0 { + CF_DIB_FORMAT + } else { + return Ok(None); + }; + let bytes = { + let _guard = ClipboardGuard::open()?; + let handle = unsafe { GetClipboardData(format) }; + if handle.is_null() { + return Err(read_failed("GetClipboardData(CF_DIB)")); + } + let memory = handle as HGLOBAL; + let size = unsafe { GlobalSize(memory) }; + if size == 0 { + return Err(read_failed("GlobalSize(CF_DIB)")); + } + let pointer = unsafe { GlobalLock(memory) }; + if pointer.is_null() { + return Err(read_failed("GlobalLock(CF_DIB)")); + } + let bytes = unsafe { std::slice::from_raw_parts(pointer.cast::(), size) }.to_vec(); + unsafe { + GlobalUnlock(memory); + } + bytes + }; + decode_dib(&bytes).map(Some) +} + +fn decode_dib(bytes: &[u8]) -> Result { + let invalid = |reason: &str| NativeClipboardError::BitmapDecode(reason.to_owned()); + let header_size = read_u32(bytes, 0).ok_or_else(|| invalid("missing DIB header"))? as usize; + if header_size < 40 || bytes.len() < header_size { + return Err(invalid("unsupported or truncated DIB header")); + } + let width = read_i32(bytes, 4).ok_or_else(|| invalid("missing DIB width"))?; + let signed_height = read_i32(bytes, 8).ok_or_else(|| invalid("missing DIB height"))?; + if width <= 0 || signed_height == 0 { + return Err(invalid("DIB dimensions are invalid")); + } + let width = width as u32; + let height = signed_height.unsigned_abs(); + let top_down = signed_height < 0; + let planes = read_u16(bytes, 12).ok_or_else(|| invalid("missing DIB plane count"))?; + let bits = read_u16(bytes, 14).ok_or_else(|| invalid("missing DIB bit depth"))?; + let compression = read_u32(bytes, 16).ok_or_else(|| invalid("missing DIB compression"))?; + if planes != 1 || !matches!(bits, 16 | 24 | 32) || !matches!(compression, 0 | 3 | 6) { + return Err(invalid("DIB pixel layout is not a supported RGB format")); + } + + let external_masks = usize::from(header_size == 40 && matches!(compression, 3 | 6)) + * if compression == 6 { 16 } else { 12 }; + let colors_used = read_u32(bytes, 32).unwrap_or(0) as usize; + let palette_entries = if bits <= 8 { + if colors_used == 0 { + 1usize << bits + } else { + colors_used + } + } else { + 0 + }; + let pixel_offset = header_size + .checked_add(external_masks) + .and_then(|offset| offset.checked_add(palette_entries * 4)) + .ok_or_else(|| invalid("DIB pixel offset overflowed"))?; + let row_stride = (u64::from(width) * u64::from(bits)).div_ceil(32) * 4; + let pixel_bytes = row_stride + .checked_mul(u64::from(height)) + .and_then(|size| usize::try_from(size).ok()) + .ok_or_else(|| invalid("DIB pixel size overflowed"))?; + if pixel_offset + .checked_add(pixel_bytes) + .is_none_or(|end| end > bytes.len()) + { + return Err(invalid("DIB pixel buffer is truncated")); + } + + let masks = dib_color_masks(bytes, header_size, bits, compression)?; + let rgba_len = usize::try_from(u64::from(width) * u64::from(height) * 4) + .map_err(|_| invalid("decoded DIB size overflowed"))?; + let mut rgba = vec![0; rgba_len]; + for output_y in 0..height as usize { + let source_y = if top_down { + output_y + } else { + height as usize - output_y - 1 + }; + let row = &bytes[pixel_offset + source_y * row_stride as usize..]; + for x in 0..width as usize { + let output = (output_y * width as usize + x) * 4; + match bits { + 24 => { + let input = x * 3; + rgba[output..output + 4].copy_from_slice(&[ + row[input + 2], + row[input + 1], + row[input], + 255, + ]); + } + 16 => { + let input = x * 2; + let pixel = u16::from_le_bytes([row[input], row[input + 1]]) as u32; + write_masked_pixel(&mut rgba[output..output + 4], pixel, masks); + } + 32 if compression == 0 => { + let input = x * 4; + rgba[output..output + 4].copy_from_slice(&[ + row[input + 2], + row[input + 1], + row[input], + 255, + ]); + } + 32 => { + let input = x * 4; + let pixel = u32::from_le_bytes( + row[input..input + 4] + .try_into() + .map_err(|_| invalid("DIB pixel row ended unexpectedly"))?, + ); + write_masked_pixel(&mut rgba[output..output + 4], pixel, masks); + } + _ => unreachable!("validated DIB bit depth"), + } + } + } + image::RgbaImage::from_raw(width, height, rgba) + .ok_or_else(|| invalid("decoded DIB dimensions did not match its pixels")) +} + +fn dib_color_masks( + bytes: &[u8], + header_size: usize, + bits: u16, + compression: u32, +) -> Result<[u32; 4], NativeClipboardError> { + if compression == 0 { + return Ok(if bits == 16 { + [0x7C00, 0x03E0, 0x001F, 0] + } else { + [0x00FF_0000, 0x0000_FF00, 0x0000_00FF, 0] + }); + } + let offset = 40; + let red = read_u32(bytes, offset) + .ok_or_else(|| NativeClipboardError::BitmapDecode("missing red mask".to_owned()))?; + let green = read_u32(bytes, offset + 4) + .ok_or_else(|| NativeClipboardError::BitmapDecode("missing green mask".to_owned()))?; + let blue = read_u32(bytes, offset + 8) + .ok_or_else(|| NativeClipboardError::BitmapDecode("missing blue mask".to_owned()))?; + let alpha = if header_size >= 56 || compression == 6 { + read_u32(bytes, offset + 12).unwrap_or(0) + } else { + 0 + }; + Ok([red, green, blue, alpha]) +} + +fn write_masked_pixel(output: &mut [u8], pixel: u32, masks: [u32; 4]) { + output[0] = masked_component(pixel, masks[0]); + output[1] = masked_component(pixel, masks[1]); + output[2] = masked_component(pixel, masks[2]); + output[3] = if masks[3] == 0 { + 255 + } else { + masked_component(pixel, masks[3]) + }; +} + +fn masked_component(pixel: u32, mask: u32) -> u8 { + if mask == 0 { + return 0; + } + let shift = mask.trailing_zeros(); + let maximum = mask >> shift; + let value = (pixel & mask) >> shift; + (u64::from(value) * 255 / u64::from(maximum)) as u8 +} + +fn read_u16(bytes: &[u8], offset: usize) -> Option { + Some(u16::from_le_bytes( + bytes.get(offset..offset + 2)?.try_into().ok()?, + )) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option { + Some(u32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_i32(bytes: &[u8], offset: usize) -> Option { + Some(i32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +/// egui-winit consumes Ctrl+V before emitting a key event and emits +/// `Event::Paste` only when it can read non-empty text. Restore the swallowed +/// press for native file-list and bitmap clipboards. +pub(crate) fn restore_missing_paste_shortcut(raw_input: &mut egui::RawInput) { + let v = unsafe { GetAsyncKeyState(VK_V as i32) } as u16; + let control = unsafe { GetAsyncKeyState(VK_CONTROL as i32) } as u16; + let shift = unsafe { GetAsyncKeyState(VK_SHIFT as i32) } as u16; + let chord_down = v & 0x8000 != 0 && control & 0x8000 != 0; + let pressed_since_poll = v & 1 != 0 && control & (0x8000 | 1) != 0; + let shift_active = shift & 0x8000 != 0; + PASTE_CHORD_DOWN.with(|was_down| { + restore_paste_from_snapshot( + raw_input, + was_down, + chord_down, + pressed_since_poll, + shift_active, + ); + }); +} + +fn restore_paste_from_snapshot( + raw_input: &mut egui::RawInput, + was_down: &Cell, + chord_down: bool, + pressed_since_poll: bool, + shift_active: bool, +) { + let shift_active = shift_active + || raw_input.modifiers.shift + || raw_input.events.iter().any(|event| { + matches!( + event, + egui::Event::Key { + key: egui::Key::V, + modifiers, + .. + } if modifiers.shift + ) + }); + let already_reported = raw_input.events.iter().any(|event| { + matches!(event, egui::Event::Paste(_)) + || matches!( + event, + egui::Event::Key { + key: egui::Key::V, + pressed: true, + .. + } + ) + }); + if !was_down.get() && (chord_down || pressed_since_poll) && !shift_active && !already_reported { + let mut modifiers = raw_input.modifiers; + modifiers.ctrl = true; + modifiers.command = true; + modifiers.shift = false; + raw_input.events.push(egui::Event::Key { + key: egui::Key::V, + physical_key: Some(egui::Key::V), + pressed: true, + repeat: false, + modifiers, + }); + } + was_down.set(chord_down); +} + +fn read_failed(operation: &'static str) -> NativeClipboardError { + NativeClipboardError::ReadFailed { + operation, + last_error: unsafe { GetLastError() }, + } +} + struct ClipboardGuard; impl ClipboardGuard { @@ -279,6 +601,75 @@ mod tests { assert_eq!(&dib[128..132], &[70, 60, 50, 80]); } + #[test] + fn native_dib_decoder_accepts_standard_dibv5_pixels() { + let rgba = [10u8, 20, 30, 255, 50, 60, 70, 128]; + let decoded = decode_dib(&build_dibv5(2, 1, &rgba, 96)).unwrap(); + assert_eq!(decoded.dimensions(), (2, 1)); + assert_eq!(decoded.into_raw(), rgba); + } + + #[test] + fn native_dib_decoder_handles_bottom_up_24_bit_rows_and_padding() { + let mut dib = vec![0_u8; 40]; + dib[0..4].copy_from_slice(&40_u32.to_le_bytes()); + dib[4..8].copy_from_slice(&2_i32.to_le_bytes()); + dib[8..12].copy_from_slice(&2_i32.to_le_bytes()); + dib[12..14].copy_from_slice(&1_u16.to_le_bytes()); + dib[14..16].copy_from_slice(&24_u16.to_le_bytes()); + dib[20..24].copy_from_slice(&16_u32.to_le_bytes()); + dib.extend_from_slice(&[ + 255, 0, 0, 255, 255, 255, 0, 0, // bottom: blue, white + 0, 0, 255, 0, 255, 0, 0, 0, // top: red, green + ]); + + let decoded = decode_dib(&dib).unwrap(); + assert_eq!( + decoded.into_raw(), + [ + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, + ] + ); + } + + #[test] + fn missing_paste_press_is_restored_once_per_chord() { + let state = Cell::new(false); + let mut raw = egui::RawInput::default(); + restore_paste_from_snapshot(&mut raw, &state, true, true, false); + assert!(matches!( + raw.events.as_slice(), + [egui::Event::Key { + key: egui::Key::V, + pressed: true, + .. + }] + )); + + raw.events.clear(); + restore_paste_from_snapshot(&mut raw, &state, true, false, false); + assert!(raw.events.is_empty()); + restore_paste_from_snapshot(&mut raw, &state, false, false, false); + restore_paste_from_snapshot(&mut raw, &state, true, true, false); + assert_eq!(raw.events.len(), 1); + } + + #[test] + fn existing_paste_event_and_shift_paste_are_not_rewritten() { + let state = Cell::new(false); + let mut raw = egui::RawInput { + events: vec![egui::Event::Paste("text".to_owned())], + ..Default::default() + }; + restore_paste_from_snapshot(&mut raw, &state, true, true, false); + assert_eq!(raw.events.len(), 1); + + raw.events.clear(); + restore_paste_from_snapshot(&mut raw, &state, false, false, false); + restore_paste_from_snapshot(&mut raw, &state, true, true, true); + assert!(raw.events.is_empty()); + } + /// Puts a test pattern on the real clipboard for manual inspection. #[test] #[ignore] diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index b605d05..ba9774f 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -3,7 +3,8 @@ //! within the repository size limit. use plotx_core::state::{ - CommandPaletteState, LineShapeKind, PlotxApp, ProjectTransition, Tool, ToolGroup, + CanvasDocument, CommandPaletteState, LineShapeKind, ObjectFrame, PlotxApp, ProjectTransition, + Tool, ToolGroup, }; use super::clipboard_table::ClipboardTablePaste; @@ -64,6 +65,17 @@ fn execute_inner( ctx.open_url(egui::OpenUrl::new_tab(commands::MANUAL_URL)); } CommandId::ImportTable => super::file_dialogs::import_delimited_table(app), + CommandId::ImportImage => super::file_dialogs::import_images(app), + CommandId::ImportImageFirstFrame => { + super::file_dialogs::image_import::import_images_first_frame(app) + } + CommandId::ImportImageWithoutMetadata => { + super::file_dialogs::image_import::import_images_without_metadata(app) + } + CommandId::ImportTiffPages => super::file_dialogs::image_import::import_tiff_pages(app), + CommandId::PasteImage => super::file_dialogs::image_import::paste_clipboard_image(app), + CommandId::CancelImageImport => super::file_dialogs::image_import::cancel_all(app), + CommandId::ReplaceImage => super::file_dialogs::image_import::replace_selected_image(app), CommandId::PasteTable => { if let Some(clipboard) = clipboard { clipboard.request(app, ctx); @@ -90,6 +102,16 @@ fn execute_inner( CommandId::DeselectAll => deselect_all_in_scope(app), CommandId::Group => app.group_selected(), CommandId::Ungroup => app.ungroup_selected(), + CommandId::CreatePanel + | CommandId::ComposePanel + | CommandId::DissolvePanel + | CommandId::DeletePanel + | CommandId::DuplicatePanel + | CommandId::MergePanels + | CommandId::SplitPanel + | CommandId::ReorderPanelLabels + | CommandId::SetPanelLayout(_) + | CommandId::MoveContentToPanel(_) => execute_panel_command(id, app), CommandId::TogglePrimarySidebar => { app.session.primary_sidebar_visible = !app.session.primary_sidebar_visible; } @@ -202,6 +224,151 @@ fn execute_inner( } } +fn execute_panel_command(id: CommandId, app: &mut PlotxApp) { + use plotx_core::actions::Action; + + let Some(ci) = app.session.active_canvas else { + return; + }; + let paths = app.session.ui.hierarchical_selection.paths().to_vec(); + let lead = paths.first().copied(); + let selected_panel = lead.and_then(|path| path.panel); + let selected_contents: Vec<_> = paths.iter().filter_map(|path| path.content).collect(); + let result: Result<(Option, Action), String> = match id { + CommandId::CreatePanel => { + let page = &app.doc.canvases[ci]; + let frame = next_panel_frame(page); + app.create_panel_action(ci, "Panel".to_owned(), frame) + .map(|(panel, action)| (Some(panel), action)) + .map_err(|error| error.to_string()) + } + CommandId::ComposePanel => app + .compose_panel_action(ci, "Panel".to_owned(), &selected_contents, 6.0) + .map(|(panel, action)| (Some(panel), action)) + .map_err(|error| error.to_string()), + CommandId::DissolvePanel => selected_panel + .ok_or_else(|| "Select a panel before dissolving it.".to_owned()) + .and_then(|panel| { + app.dissolve_panel_action(ci, panel) + .map(|action| (None, action)) + .map_err(|error| error.to_string()) + }), + CommandId::DeletePanel => selected_panel + .ok_or_else(|| "Select a panel before deleting it.".to_owned()) + .and_then(|panel| { + app.delete_panel_action(ci, panel) + .map(|action| (None, action)) + .map_err(|error| error.to_string()) + }), + CommandId::DuplicatePanel => selected_panel + .ok_or_else(|| "Select a panel before duplicating it.".to_owned()) + .and_then(|panel| { + app.duplicate_panel_action(ci, panel, [8.0, 8.0]) + .map(|(new_panel, action)| (Some(new_panel), action)) + .map_err(|error| error.to_string()) + }), + CommandId::MergePanels => { + let panels: Vec<_> = paths + .iter() + .filter_map(|path| (path.content.is_none()).then_some(path.panel).flatten()) + .collect(); + let primary = panels + .first() + .copied() + .ok_or_else(|| "Select at least two panels to merge.".to_owned()); + primary.and_then(|primary| { + app.merge_panels_action(ci, primary, &panels[1..]) + .map(|action| (Some(primary), action)) + .map_err(|error| error.to_string()) + }) + } + CommandId::SplitPanel => selected_panel + .ok_or_else(|| "Select content inside a panel before splitting it.".to_owned()) + .and_then(|panel| { + app.split_panel_action(ci, panel, &selected_contents, "Panel".to_owned()) + .map(|(new_panel, action)| (Some(new_panel), action)) + .map_err(|error| error.to_string()) + }), + CommandId::ReorderPanelLabels => app + .reorder_panel_labels_action(ci) + .map(|action| (None, action)) + .map_err(|error| error.to_string()), + CommandId::SetPanelLayout(layout) => selected_panel + .ok_or_else(|| "Select a panel before changing its layout.".to_owned()) + .and_then(|panel| { + let current = app.doc.canvases[ci] + .panel(panel) + .ok_or_else(|| "The selected panel no longer exists.".to_owned())?; + app.set_panel_layout_action( + ci, + panel, + layout, + current.layout_gap, + current.layout_padding, + current.layout_alignment, + ) + .map(|action| (Some(panel), action)) + .map_err(|error| error.to_string()) + }), + CommandId::MoveContentToPanel(target) => app + .move_contents_to_panel_action(ci, &selected_contents, target, 0) + .map(|action| (target, action)) + .map_err(|error| error.to_string()), + _ => return, + }; + match result { + Ok((panel, action)) => { + app.execute_action(action); + if let Some(panel) = panel { + app.select_panel(ci, panel); + } else if matches!(id, CommandId::DeletePanel | CommandId::DissolvePanel) { + app.session.ui.hierarchical_selection.clear(); + } + } + Err(error) => app.session.status = error, + } +} + +fn next_panel_frame(page: &CanvasDocument) -> ObjectFrame { + let [page_width, page_height] = page.size_pt(); + let margin = 12.0; + let width = (page_width * 0.42) + .clamp(48.0, 220.0) + .min(page_width - margin * 2.0); + let height = (page_height * 0.42) + .clamp(48.0, 160.0) + .min(page_height - margin * 2.0); + let overlaps = |candidate: ObjectFrame| { + page.panels.iter().any(|panel| { + candidate.x < panel.frame.x + panel.frame.width + && candidate.x + candidate.width > panel.frame.x + && candidate.y < panel.frame.y + panel.frame.height + && candidate.y + candidate.height > panel.frame.y + }) + }; + for row in 0..8 { + for col in 0..8 { + let x = margin + col as f32 * (width + margin); + let y = margin + row as f32 * (height + margin); + if x + width <= page_width - margin + && y + height <= page_height - margin + && !overlaps(ObjectFrame::new(x, y, width, height)) + { + return ObjectFrame::new(x, y, width, height); + } + } + } + // A fully occupied page still gets a deterministic, visible cascade. The + // fallback is bounded to the page and can be moved immediately. + let offset = (page.panels.len() % 8) as f32 * 8.0; + ObjectFrame::new( + (margin + offset).min((page_width - margin - width).max(margin)), + (margin + offset).min((page_height - margin - height).max(margin)), + width, + height, + ) +} + fn select_all_in_scope(app: &mut PlotxApp) { use plotx_core::state::{Selection, SelectionScope, board_frame_id, board_frames}; match app.session.ui.selection_scope { @@ -413,6 +580,7 @@ mod tests { use plotx_core::actions::Action; use plotx_core::properties::{AggregateValue, PropertyAddress, PropertyValue, app_preferences}; use plotx_core::settings::Settings; + use plotx_core::state::CanvasDocument; use plotx_core::state::DEFAULT_CANVAS_SIZE_MM; fn catalog_snap(app: &mut PlotxApp, enabled: bool) { @@ -426,6 +594,20 @@ mod tests { app.commit_property(commit); } + #[test] + fn new_panel_frame_avoids_existing_panel_overlap_when_space_exists() { + let mut page = CanvasDocument::new("page".to_owned(), [120.0, 90.0]); + let first = next_panel_frame(&page); + page.create_panel("Panel".to_owned(), first); + let second = next_panel_frame(&page); + assert!( + second.x >= first.x + first.width + || second.x + second.width <= first.x + || second.y >= first.y + first.height + || second.y + second.height <= first.y + ); + } + fn resolved_snap(app: &PlotxApp) -> AggregateValue { app.resolve_property(&PropertyAddress::new( app.app_target(), diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index f50bbfd..89e8260 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -1,26 +1,21 @@ -//! Shared command descriptions and dispatch. Menus, the Ribbon, shortcuts and -//! the command palette all ask this module for the same live state and execute -//! the same stable command IDs. - use plotx_core::actions::ZOrder; use plotx_core::export::ExportFormat; use plotx_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; use plotx_core::properties::PropertyStep; -use plotx_core::state::{Dataset, ObjectId, PlotxApp, Tool, ToolGroup, WorkflowTab}; +use plotx_core::state::{Dataset, PlotxApp, Tool, ToolGroup, WorkflowTab}; pub use super::command_exec::{execute, execute_without_clipboard}; - mod identity; use identity::command_identity; pub(crate) use identity::recent_entry_label; +mod helpers; +pub(super) use helpers::chart_plot_target; +use helpers::{requires, selected_paths_unlocked, tool_commands}; mod ribbon; use ribbon::ribbon_placement; - -/// The published user manual; opened by `HelpManual` and linked from About. pub(crate) const MANUAL_URL: &str = "https://docs.plotx.nmrtist.space/"; /// The public source repository, linked from About. pub(crate) const REPOSITORY_URL: &str = "https://github.com/nmrtist/plotx"; - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RibbonPlacement { pub tab: WorkflowTab, @@ -29,7 +24,6 @@ pub struct RibbonPlacement { pub priority: u8, pub applicability: Applicability, } - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Applicability { Always, @@ -39,7 +33,6 @@ pub enum Applicability { Homonuclear2dOnly, ToolGroup(ToolGroup), } - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CommandId { NewProject, @@ -49,12 +42,17 @@ pub enum CommandId { OpenFolder, RunBatchWorkflow, RunScientificScript, - /// Reopen the recent-list entry at this index (newest first). Registered - /// per live entry, so the index always resolves against the current list. OpenRecent(usize), ClearRecentFiles, HelpManual, ImportTable, + ImportImage, + ImportImageFirstFrame, + ImportImageWithoutMetadata, + ImportTiffPages, + PasteImage, + CancelImageImport, + ReplaceImage, PasteTable, SaveProject, NewTable, @@ -69,6 +67,16 @@ pub enum CommandId { DeselectAll, Group, Ungroup, + CreatePanel, + ComposePanel, + DissolvePanel, + DeletePanel, + DuplicatePanel, + MergePanels, + SplitPanel, + ReorderPanelLabels, + SetPanelLayout(plotx_core::state::PanelLayout), + MoveContentToPanel(Option), TogglePrimarySidebar, ToggleSecondarySidebar, ZoomToFit, @@ -108,9 +116,6 @@ pub enum CommandId { Multiplets, TidyBoard, CanvasSettings, - /// Apply the size preset with this catalog id (`SizePreset::id`) to the - /// active canvas. Registered per catalog entry so every preset is - /// palette-searchable. SetCanvasSizePreset(&'static str), ArrangeGrid(u32, u32), SimplifyInnerAxes, @@ -128,8 +133,6 @@ pub enum CommandId { /// Move the canvas-steppable property one rung (§8.5 channel 3). The /// property is derived from the catalog, so the binding does not name one. StepProperty(PropertyStep), - /// Advance through the cursor family. Unlike ordinary tool bindings, repeated - /// presses select the next applicable cursor instead of leaving the tool. CycleCursor, Tool(Tool), } @@ -144,34 +147,6 @@ pub enum CommandExecutionClass { ToolBacked, } -impl CommandId { - fn tool_target(self) -> Option { - match self { - Self::SelectRange => Some(Tool::SelectRegion), - Self::Regions => Some(Tool::Regions), - Self::PeakList => Some(Tool::Peaks), - Self::LineFit => Some(Tool::LineFit), - Self::Integrate => Some(Tool::Integrate), - Self::Tool(tool) => Some(tool), - _ => None, - } - } - - pub fn execution_class(self) -> CommandExecutionClass { - match self { - Self::RunBatchWorkflow | Self::RunScientificScript => CommandExecutionClass::ToolEditor, - Self::OperationHistory | Self::CommandPalette | Self::About => { - CommandExecutionClass::UiOnly - } - Self::ExportData - | Self::Export(_) - | Self::ApplyProcessingTemplate - | Self::ApplyTheme(_) => CommandExecutionClass::ToolBacked, - _ => CommandExecutionClass::UiOnly, - } - } -} - pub struct CommandDescriptor { pub id: CommandId, pub execution_class: CommandExecutionClass, @@ -199,6 +174,13 @@ pub fn catalog(app: &PlotxApp) -> Vec { CommandId::ClearRecentFiles, CommandId::HelpManual, CommandId::ImportTable, + CommandId::ImportImage, + CommandId::ImportImageFirstFrame, + CommandId::ImportImageWithoutMetadata, + CommandId::ImportTiffPages, + CommandId::PasteImage, + CommandId::CancelImageImport, + CommandId::ReplaceImage, CommandId::PasteTable, CommandId::SaveProject, CommandId::NewTable, @@ -211,6 +193,18 @@ pub fn catalog(app: &PlotxApp) -> Vec { CommandId::DeselectAll, CommandId::Group, CommandId::Ungroup, + CommandId::CreatePanel, + CommandId::ComposePanel, + CommandId::DissolvePanel, + CommandId::DeletePanel, + CommandId::DuplicatePanel, + CommandId::MergePanels, + CommandId::SplitPanel, + CommandId::ReorderPanelLabels, + CommandId::SetPanelLayout(plotx_core::state::PanelLayout::Free), + CommandId::SetPanelLayout(plotx_core::state::PanelLayout::VerticalStack), + CommandId::SetPanelLayout(plotx_core::state::PanelLayout::HorizontalStack), + CommandId::SetPanelLayout(plotx_core::state::PanelLayout::Grid { rows: 2, cols: 2 }), CommandId::TogglePrimarySidebar, CommandId::ToggleSecondarySidebar, CommandId::ZoomToFit, @@ -321,36 +315,6 @@ pub fn catalog(app: &PlotxApp) -> Vec { .collect() } -/// `Ok` when a requirement holds, otherwise the sentence a surface shows in the -/// disabled tooltip. Chain with `and_then` to report the first unmet one. -fn requires(ok: bool, reason: &'static str) -> Result<(), &'static str> { - if ok { Ok(()) } else { Err(reason) } -} - -/// The canvas and plot object displaying `dataset`, preferring the active -/// canvas so Chart Type lands on the plot the user is looking at. -pub(super) fn chart_plot_target(app: &PlotxApp, dataset: usize) -> Option<(usize, ObjectId)> { - let candidates = app - .session - .active_canvas - .into_iter() - .chain(0..app.doc.canvases.len()); - for ci in candidates { - let Some(canvas) = app.doc.canvases.get(ci) else { - continue; - }; - let hit = canvas.objects.iter().find(|object| { - object.plot().is_some_and(|plot| { - plot.binding.primary_dataset() == Some(app.doc.datasets[dataset].resource_id()) - }) - }); - if let Some(object) = hit { - return Some((ci, object.id)); - } - } - None -} - pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { let has_canvas = app.session.active_canvas.is_some(); let selected = app.session.ui.selection.objects().len(); @@ -421,6 +385,32 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { app.session.ui.table_import_preview.is_none(), "Finish or cancel the current table import preview before importing another table.", ), + CommandId::ImportImage + | CommandId::ImportImageFirstFrame + | CommandId::ImportImageWithoutMetadata + | CommandId::ImportTiffPages + | CommandId::PasteImage => Ok(()), + CommandId::CancelImageImport => requires( + super::file_dialogs::image_import::has_active_jobs(), + "Start an image import before cancelling it.", + ), + CommandId::ReplaceImage => requires( + app.session.active_canvas.is_some_and(|ci| { + app.session + .ui + .hierarchical_selection + .lead() + .and_then(|path| path.content) + .and_then(|id| app.doc.canvases[ci].object(id)) + .is_some_and(|item| { + matches!( + item.kind, + plotx_core::state::CanvasObjectKind::RasterImage(_) + ) && !item.locked + }) + }), + "Select an unlocked image before replacing it.", + ), CommandId::ExportData => requires( dataset().is_some_and(|dataset| { !plotx_core::data_export::DataExportAvailability::for_dataset(dataset).is_empty() @@ -429,8 +419,15 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { ), CommandId::Export(_) => requires(has_canvas, "Open a canvas before exporting a figure."), CommandId::CopyFigure => requires( - super::clipboard_figure::resolve_copy_target(app).is_some(), - "Open a canvas or select a page frame before copying a figure.", + super::clipboard_figure::resolve_copy_target(app).is_some_and(|canvas| { + !app.doc.canvases[canvas].objects.iter().any(|item| { + matches!( + item.kind, + plotx_core::state::CanvasObjectKind::RasterImage(_) + ) + }) + }), + "Open a figure without external images; Copy Figure cannot include them yet.", ), CommandId::Undo => requires(app.can_undo(), "Nothing to undo yet."), CommandId::Redo => requires(app.can_redo(), "Nothing to redo yet."), @@ -446,6 +443,73 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { selected >= 1, "Select at least one object before ungrouping it.", ), + CommandId::CreatePanel => { + requires(has_canvas, "Open a figure page before creating a panel.") + } + CommandId::ComposePanel => requires( + app.session + .ui + .hierarchical_selection + .paths() + .iter() + .any(|path| path.content.is_some() && path.panel.is_none()) + && selected_paths_unlocked(app), + "Select one or more unlocked loose content items before composing a panel.", + ), + CommandId::DissolvePanel + | CommandId::DeletePanel + | CommandId::DuplicatePanel + | CommandId::SetPanelLayout(_) => requires( + app.session + .ui + .hierarchical_selection + .lead() + .is_some_and(|path| path.panel.is_some()) + && selected_paths_unlocked(app), + "Select an unlocked panel before using this command.", + ), + CommandId::MoveContentToPanel(target) => requires( + selected >= 1 + && selected_paths_unlocked(app) + && target.is_none_or(|panel| { + app.session.active_canvas.is_some_and(|ci| { + app.doc.canvases[ci] + .panel(panel) + .is_some_and(|panel| !panel.locked) + }) + }), + "Select unlocked sibling content and an unlocked destination panel.", + ), + CommandId::MergePanels => requires( + app.session + .ui + .hierarchical_selection + .paths() + .iter() + .filter(|path| path.panel.is_some() && path.content.is_none()) + .count() + >= 2 + && selected_paths_unlocked(app), + "Select at least two unlocked sibling panels before merging them.", + ), + CommandId::SplitPanel => requires( + app.session + .ui + .hierarchical_selection + .paths() + .iter() + .any(|path| path.panel.is_some() && path.content.is_some()) + && selected_paths_unlocked(app), + "Select unlocked content inside an unlocked panel before splitting it.", + ), + CommandId::ReorderPanelLabels => requires( + has_canvas + && app + .session + .active_canvas + .is_some_and(|ci| !app.doc.canvases[ci].panels.is_empty()), + "Create a panel before renumbering panel labels.", + ), CommandId::ZoomToFit => requires(has_canvas, "Open a canvas before zooming to fit."), CommandId::ZoomToSelection => { requires(has_canvas, "Open a canvas before zooming to the selection.") @@ -628,8 +692,15 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { "Select at least three objects before distributing them.", ), CommandId::ZOrder(_) => requires( - selected >= 1, - "Select an object before changing its stacking order.", + selected >= 1 + || app + .session + .ui + .hierarchical_selection + .paths() + .iter() + .any(|path| path.panel.is_some() && path.content.is_none()), + "Select an object or panel before changing its stacking order.", ), CommandId::PropertyGroup(section) => requires( super::properties::discovery::group_applies(app, section), @@ -713,40 +784,15 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { } } -fn tool_commands() -> [Tool; 17] { - [ - Tool::Select, - Tool::BrowseZoom, - Tool::ManualPhase, - Tool::Integrate, - Tool::Peaks, - Tool::InspectCursor, - Tool::DeltaCursor, - Tool::Symmetry, - Tool::Slice, - Tool::LineFit, - Tool::Annotate, - Tool::Text, - Tool::PanelLabel, - Tool::Rect, - Tool::Ellipse, - Tool::Line, - Tool::Arrow, - ] -} - #[cfg(test)] -#[path = "commands_tests.rs"] -mod tests; - +#[path = "commands_alignment_tests.rs"] +mod alignment_tests; #[cfg(test)] #[path = "commands_mass_spec_tests.rs"] mod mass_spec_tests; - +#[cfg(test)] +#[path = "commands_tests.rs"] +mod tests; #[cfg(test)] #[path = "commands_xps_tests.rs"] mod xps_tests; - -#[cfg(test)] -#[path = "commands_alignment_tests.rs"] -mod alignment_tests; diff --git a/crates/app/src/ui/commands/helpers.rs b/crates/app/src/ui/commands/helpers.rs new file mode 100644 index 0000000..af36f7a --- /dev/null +++ b/crates/app/src/ui/commands/helpers.rs @@ -0,0 +1,98 @@ +use plotx_core::state::{ObjectId, PlotxApp, Tool}; + +use super::{CommandExecutionClass, CommandId}; + +impl CommandId { + pub(super) fn tool_target(self) -> Option { + match self { + Self::SelectRange => Some(Tool::SelectRegion), + Self::Regions => Some(Tool::Regions), + Self::PeakList => Some(Tool::Peaks), + Self::LineFit => Some(Tool::LineFit), + Self::Integrate => Some(Tool::Integrate), + Self::Tool(tool) => Some(tool), + _ => None, + } + } + + pub fn execution_class(self) -> CommandExecutionClass { + match self { + Self::RunBatchWorkflow | Self::RunScientificScript => CommandExecutionClass::ToolEditor, + Self::OperationHistory | Self::CommandPalette | Self::About => { + CommandExecutionClass::UiOnly + } + Self::ExportData + | Self::Export(_) + | Self::ApplyProcessingTemplate + | Self::ApplyTheme(_) => CommandExecutionClass::ToolBacked, + _ => CommandExecutionClass::UiOnly, + } + } +} + +pub(super) fn requires(ok: bool, reason: &'static str) -> Result<(), &'static str> { + if ok { Ok(()) } else { Err(reason) } +} + +pub(super) fn selected_paths_unlocked(app: &PlotxApp) -> bool { + app.session.active_canvas.is_some_and(|ci| { + app.session + .ui + .hierarchical_selection + .paths() + .iter() + .all(|path| { + path.panel + .and_then(|id| app.doc.canvases[ci].panel(id)) + .is_none_or(|panel| !panel.locked) + && path + .content + .and_then(|id| app.doc.canvases[ci].object(id)) + .is_none_or(|item| !item.locked) + }) + }) +} + +pub(crate) fn chart_plot_target(app: &PlotxApp, dataset: usize) -> Option<(usize, ObjectId)> { + let candidates = app + .session + .active_canvas + .into_iter() + .chain(0..app.doc.canvases.len()); + for ci in candidates { + let Some(canvas) = app.doc.canvases.get(ci) else { + continue; + }; + let hit = canvas.objects.iter().find(|object| { + object.plot().is_some_and(|plot| { + plot.binding.primary_dataset() == Some(app.doc.datasets[dataset].resource_id()) + }) + }); + if let Some(object) = hit { + return Some((ci, object.id)); + } + } + None +} + +pub(super) fn tool_commands() -> [Tool; 17] { + [ + Tool::Select, + Tool::BrowseZoom, + Tool::ManualPhase, + Tool::Integrate, + Tool::Peaks, + Tool::InspectCursor, + Tool::DeltaCursor, + Tool::Symmetry, + Tool::Slice, + Tool::LineFit, + Tool::Annotate, + Tool::Text, + Tool::PanelLabel, + Tool::Rect, + Tool::Ellipse, + Tool::Line, + Tool::Arrow, + ] +} diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 88fe7f4..8ba44d6 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -6,7 +6,7 @@ use egui_phosphor::regular as icon; use plotx_core::actions::ZOrder; use plotx_core::layout::{Align, Distribute, GutterPreset, SpacingMode}; use plotx_core::properties::PropertyStep; -use plotx_core::state::{PlotxApp, Tool}; +use plotx_core::state::{PanelLayout, PlotxApp, Tool}; use super::CommandId; @@ -28,6 +28,11 @@ impl CommandId { Self::PropertyGroup(section) => format!("properties.group.{section}"), Self::StepProperty(step) => format!("properties.step.{}", step.as_str()), Self::Tool(tool) => format!("tool.{}", tool_slug(tool)), + Self::SetPanelLayout(layout) => { + format!("panel.layout.{}", panel_layout_slug(layout)) + } + Self::MoveContentToPanel(Some(panel)) => format!("panel.move_content.{panel}"), + Self::MoveContentToPanel(None) => "panel.move_content.loose".to_owned(), _ => simple_stable_id(self).to_owned(), } } @@ -54,6 +59,17 @@ pub(super) fn command_identity( CommandId::ClearRecentFiles => plain("Clear Recent Files", None), CommandId::HelpManual => plain("User Manual", Some(icon::BOOK_OPEN)), CommandId::ImportTable => plain("Import Table…", Some(icon::TABLE)), + CommandId::ImportImage => plain("Add Images…", Some(icon::FILE)), + CommandId::ImportImageFirstFrame => { + plain("Add Animated Image First Frame…", Some(icon::FILE)) + } + CommandId::ImportImageWithoutMetadata => { + plain("Add Images Without Metadata…", Some(icon::FILE)) + } + CommandId::ImportTiffPages => plain("Add All TIFF Pages…", Some(icon::FILE)), + CommandId::PasteImage => plain("Paste Image", Some(icon::CLIPBOARD_TEXT)), + CommandId::CancelImageImport => plain("Cancel Image Import", Some(icon::X)), + CommandId::ReplaceImage => plain("Replace Image…", Some(icon::ARROWS_CLOCKWISE)), CommandId::PasteTable => plain("Paste Table from Clipboard", Some(icon::CLIPBOARD_TEXT)), CommandId::SaveProject => plain("Save Project", Some(icon::FLOPPY_DISK)), CommandId::NewTable => plain("New Empty Data Table", Some(icon::TABLE)), @@ -80,6 +96,30 @@ pub(super) fn command_identity( CommandId::DeselectAll => (selection_label(app, "Deselect All"), None, None), CommandId::Group => ("Group Selection".into(), None, None), CommandId::Ungroup => ("Ungroup Selection".into(), None, None), + CommandId::CreatePanel => plain("Create Empty Panel", Some(icon::RECTANGLE)), + CommandId::ComposePanel => plain("Compose Panel from Selection", Some(icon::SQUARES_FOUR)), + CommandId::DissolvePanel => { + plain("Dissolve Panel and Keep Contents", Some(icon::ARROWS_OUT)) + } + CommandId::DeletePanel => plain("Delete Panel and Contents", Some(icon::TRASH)), + CommandId::DuplicatePanel => plain("Duplicate Panel", Some(icon::COPY)), + CommandId::MergePanels => plain("Merge Selected Panels", Some(icon::UNION)), + CommandId::SplitPanel => plain("Split Selected Contents into Panel", Some(icon::SCISSORS)), + CommandId::ReorderPanelLabels => plain( + "Renumber Panels by Reading Order", + Some(icon::SORT_ASCENDING), + ), + CommandId::SetPanelLayout(layout) => plain( + match layout { + PanelLayout::Free => "Panel Layout: Free", + PanelLayout::VerticalStack => "Panel Layout: Vertical Stack", + PanelLayout::HorizontalStack => "Panel Layout: Horizontal Stack", + PanelLayout::Grid { .. } => "Panel Layout: Grid", + }, + Some(icon::SQUARES_FOUR), + ), + CommandId::MoveContentToPanel(Some(_)) => plain("Move into Panel", Some(icon::ARROW_RIGHT)), + CommandId::MoveContentToPanel(None) => plain("Move out of Panel", Some(icon::ARROW_LEFT)), CommandId::TogglePrimarySidebar => ( "Left Sidebar".into(), Some(icon::SIDEBAR), @@ -346,6 +386,13 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::RunBatchWorkflow => "tools.automation", CommandId::RunScientificScript => "tools.run_scientific_script", CommandId::ImportTable => "file.import_table", + CommandId::ImportImage => "figure.import_image", + CommandId::ImportImageFirstFrame => "figure.import_image_first_frame", + CommandId::ImportImageWithoutMetadata => "figure.import_image_without_metadata", + CommandId::ImportTiffPages => "figure.import_tiff_pages", + CommandId::PasteImage => "figure.paste_image", + CommandId::CancelImageImport => "figure.cancel_image_import", + CommandId::ReplaceImage => "figure.replace_image", CommandId::PasteTable => "file.paste_table", CommandId::SaveProject => "file.save", CommandId::NewTable => "file.new_table", @@ -358,6 +405,15 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::DeselectAll => "edit.deselect_all", CommandId::Group => "edit.group", CommandId::Ungroup => "edit.ungroup", + CommandId::CreatePanel => "panel.create", + CommandId::ComposePanel => "panel.compose", + CommandId::DissolvePanel => "panel.dissolve", + CommandId::DeletePanel => "panel.delete", + CommandId::DuplicatePanel => "panel.duplicate", + CommandId::MergePanels => "panel.merge", + CommandId::SplitPanel => "panel.split", + CommandId::ReorderPanelLabels => "panel.renumber", + CommandId::MoveContentToPanel(_) => "panel.move_content.context", CommandId::TogglePrimarySidebar => "view.primary_sidebar", CommandId::ToggleSecondarySidebar => "view.secondary_sidebar", CommandId::ZoomToFit => "view.zoom_fit", @@ -405,6 +461,15 @@ fn simple_stable_id(id: CommandId) -> &'static str { } } +fn panel_layout_slug(layout: PanelLayout) -> &'static str { + match layout { + PanelLayout::Free => "free", + PanelLayout::VerticalStack => "vertical_stack", + PanelLayout::HorizontalStack => "horizontal_stack", + PanelLayout::Grid { .. } => "grid_2x2", + } +} + fn selection_label(app: &PlotxApp, verb: &str) -> String { use plotx_core::state::SelectionScope; let noun = match app.session.ui.selection_scope { diff --git a/crates/app/src/ui/commands/ribbon.rs b/crates/app/src/ui/commands/ribbon.rs index 77c6746..d6cf072 100644 --- a/crates/app/src/ui/commands/ribbon.rs +++ b/crates/app/src/ui/commands/ribbon.rs @@ -17,6 +17,7 @@ pub(super) fn ribbon_placement(id: CommandId) -> Option { | CommandId::Preferences => (View, "Display", 1, Always), CommandId::OpenFile | CommandId::ImportTable + | CommandId::ImportImage | CommandId::OpenFolder | CommandId::PasteTable => (Data, "Import", 0, Always), CommandId::NewTable | CommandId::StackData => (Data, "Build", 1, Always), diff --git a/crates/app/src/ui/commands_alignment_tests.rs b/crates/app/src/ui/commands_alignment_tests.rs index 7170152..eaf6443 100644 --- a/crates/app/src/ui/commands_alignment_tests.rs +++ b/crates/app/src/ui/commands_alignment_tests.rs @@ -1,4 +1,19 @@ use super::*; + +#[test] +fn image_entry_commands_are_available_before_a_page_exists() { + let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + assert!(app.doc.canvases.is_empty()); + for command in [ + CommandId::ImportImage, + CommandId::ImportImageFirstFrame, + CommandId::ImportImageWithoutMetadata, + CommandId::ImportTiffPages, + CommandId::PasteImage, + ] { + assert!(describe(&app, command).enabled); + } +} use plotx_core::actions::Action; use plotx_core::state::{ CanvasObject, CanvasObjectKind, DEFAULT_CANVAS_SIZE_MM, Dataset, ElectrophysiologyDataset, diff --git a/crates/app/src/ui/file_dialogs.rs b/crates/app/src/ui/file_dialogs.rs index 1448096..3af3d02 100644 --- a/crates/app/src/ui/file_dialogs.rs +++ b/crates/app/src/ui/file_dialogs.rs @@ -8,17 +8,48 @@ use plotx_core::state::ProcessingSchemeDialogState; mod delimited; mod discovery; +pub(crate) mod image_import; mod origin; mod path; mod preview; mod recent; mod xlsx; pub(crate) use delimited::DelimitedTableSource; +pub(crate) use image_import::{import_image_paths, import_images}; use path::{ensure_extension, ensure_plotx_extension, io_error_category}; pub(crate) use preview::table_import_preview_window; pub(crate) use recent::{RecentOpenKind, open_recent_path, recent_open_kind}; use xlsx::import_xlsx_table_path; +pub(crate) struct ClassifiedDropPath(recent::ClassifiedOpenPath); + +impl ClassifiedDropPath { + pub(crate) fn is_image(&self) -> bool { + self.0.kind() == RecentOpenKind::Image + } +} + +pub(crate) fn classify_dropped_path( + app: &mut PlotxApp, + path: &std::path::Path, +) -> Option { + match recent::classify_open_path(path) { + Ok(classified) => Some(ClassifiedDropPath(classified)), + Err(error) => { + recent::record_open_path_failure(app, path, error); + None + } + } +} + +pub(crate) fn dispatch_dropped_path( + app: &mut PlotxApp, + path: &std::path::Path, + classified: ClassifiedDropPath, +) { + recent::dispatch_classified_path(app, path, classified.0); +} + pub(crate) fn import_delimited_table(app: &mut PlotxApp) { let Some(path) = rfd::FileDialog::new() .add_filter( @@ -343,6 +374,10 @@ pub(crate) fn load_and_note(app: &mut PlotxApp, path: &std::path::Path) { pub(crate) fn open_file(app: &mut PlotxApp) { if let Some(paths) = rfd::FileDialog::new() + .add_filter( + "Images (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.webp, *.bmp)", + &["png", "jpg", "jpeg", "tif", "tiff", "webp", "bmp"], + ) .add_filter( "All supported data (*.mzML, *.rasx, *.raw, *.vms, *.txt, *.spm, *.pfc, *.abf, *.jdf, fid, ser, *.zip, *.opj)", origin::OPEN_FILE_FILTER_EXTENSIONS, @@ -360,7 +395,7 @@ pub(crate) fn open_file(app: &mut PlotxApp) { .add_filter("Bruker TopSpin (fid, ser)", &["fid", "ser"]) .add_filter("Archive (*.zip)", &["zip"]) .add_filter("All files", &["*"]) - .set_title("Open data files — format is detected automatically") + .set_title("Open data or add images — format is detected automatically") .pick_files() { for path in paths { diff --git a/crates/app/src/ui/file_dialogs/image_import.rs b/crates/app/src/ui/file_dialogs/image_import.rs new file mode 100644 index 0000000..773151c --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import.rs @@ -0,0 +1,754 @@ +use plotx_core::actions::{Action, PanelState}; +use plotx_core::operation::{ + Diagnostic, DiagnosticCode, OperationId, OperationKind, OperationReport, Severity, +}; +use plotx_core::state::{ + AssetId, AssetRecord, CanvasDocument, CanvasId, CanvasObject, CanvasObjectKind, ContentId, + ObjectFrame, PanelId, PlotxApp, RasterImageContent, +}; +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, mpsc}; + +mod consent; +pub(crate) use consent::large_image_consent_window; +mod clipboard; +pub(crate) use clipboard::paste_clipboard_image; +mod geometry; +use geometry::{initial_image_size, physical_image_size_mm}; +mod jobs; +pub(crate) use jobs::poll; +mod new_pages; +mod picker; +pub(crate) use picker::{ + import_image_paths, import_images, import_images_first_frame, import_images_without_metadata, + import_tiff_pages, replace_selected_image, +}; +mod proxy; +use proxy::{insert_proxy, rebuild_preview}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ImportImageState { + Queued, + Probing, + AwaitingLargeImageConsent, + DecodingProxy, + ReadyToCommit, + Committed, + Failed, + Cancelled, +} + +#[derive(Clone, Debug)] +pub(crate) struct ImportImageRequest { + pub paths: Vec, + pub payloads: Vec<(String, Vec)>, + pub target: ImportImageTarget, + pub allow_first_frame: bool, + pub strip_metadata: bool, + pub import_all_tiff_pages: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum ImportImageTarget { + NewPages, + Canvas { + canvas: CanvasId, + position: Option<[f32; 2]>, + }, + Panel { + canvas: CanvasId, + panel: PanelId, + position: Option<[f32; 2]>, + }, + Replace { + canvas: CanvasId, + content: ContentId, + }, +} + +struct Candidate { + basename: String, + bytes: Arc>, + sha256: [u8; 32], + format: String, + pixel_size: [u32; 2], + auto_page_size_mm: [f32; 2], + page_index: u32, + warning: Option, + preview: plotx_io::image::ProxyImage, +} + +#[derive(Clone, Copy)] +struct ImportOptions { + allow_first_frame: bool, + strip_metadata: bool, + import_all_tiff_pages: bool, +} + +#[derive(Debug)] +struct Failure { + basename: String, + detected_format: String, + stage: &'static str, + reason: String, + remedy: &'static str, +} + +enum WorkerEvent { + State(ImportImageState), + Finished(Vec>), +} + +struct ImportJob { + request: ImportImageRequest, + operation: OperationId, + state: ImportImageState, + receiver: mpsc::Receiver, + cancelled: Arc, + consent: mpsc::Sender, +} + +#[derive(Default)] +struct ImageImportManager { + jobs: Vec, +} + +thread_local! { + static MANAGER: RefCell = RefCell::new(ImageImportManager::default()); +} + +pub(crate) fn enqueue(app: &mut PlotxApp, request: ImportImageRequest) { + let operation = app.session.begin_operation(); + let (sender, receiver) = mpsc::channel(); + let (consent, worker_consent) = mpsc::channel(); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let paths = request.paths.clone(); + let payloads = request.payloads.clone(); + let options = ImportOptions { + allow_first_frame: request.allow_first_frame, + strip_metadata: request.strip_metadata, + import_all_tiff_pages: request.import_all_tiff_pages, + }; + std::thread::spawn(move || { + worker( + paths, + payloads, + worker_cancelled, + sender, + worker_consent, + options, + ) + }); + MANAGER.with(|manager| { + manager.borrow_mut().jobs.push(ImportJob { + request, + operation, + state: ImportImageState::Queued, + receiver, + cancelled, + consent, + }); + }); + app.session.status = "Image import queued.".to_owned(); +} + +pub(crate) fn has_active_jobs() -> bool { + MANAGER.with(|manager| !manager.borrow().jobs.is_empty()) +} + +pub(crate) fn cancel_all(app: &mut PlotxApp) { + MANAGER.with(|manager| { + for job in &mut manager.borrow_mut().jobs { + job.cancelled.store(true, Ordering::Relaxed); + let _ = job.consent.send(false); + job.state = ImportImageState::Cancelled; + } + }); + app.session.status = "Cancelling image import…".to_owned(); +} + +fn worker( + paths: Vec, + payloads: Vec<(String, Vec)>, + cancelled: Arc, + sender: mpsc::Sender, + consent: mpsc::Receiver, + options: ImportOptions, +) { + let _ = sender.send(WorkerEvent::State(ImportImageState::Probing)); + let mut results = Vec::with_capacity(paths.len()); + for path in paths { + if cancelled.load(Ordering::Relaxed) { + let _ = sender.send(WorkerEvent::State(ImportImageState::Cancelled)); + return; + } + match prepare_path(&path, &sender, &consent, &cancelled, options) { + Ok(candidates) => results.extend(candidates.into_iter().map(Ok)), + Err(error) => results.push(Err(error)), + } + if cancelled.load(Ordering::Relaxed) { + let _ = sender.send(WorkerEvent::State(ImportImageState::Cancelled)); + return; + } + } + for (name, bytes) in payloads { + if cancelled.load(Ordering::Relaxed) { + let _ = sender.send(WorkerEvent::State(ImportImageState::Cancelled)); + return; + } + match prepare_bytes(name, bytes, &sender, &consent, &cancelled, options) { + Ok(candidates) => results.extend(candidates.into_iter().map(Ok)), + Err(error) => results.push(Err(error)), + } + if cancelled.load(Ordering::Relaxed) { + let _ = sender.send(WorkerEvent::State(ImportImageState::Cancelled)); + return; + } + } + let _ = sender.send(WorkerEvent::State(ImportImageState::ReadyToCommit)); + let _ = sender.send(WorkerEvent::Finished(results)); +} + +fn prepare_path( + path: &Path, + sender: &mpsc::Sender, + consent: &mpsc::Receiver, + cancelled: &AtomicBool, + options: ImportOptions, +) -> Result, Failure> { + let basename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + .to_owned(); + let bytes = std::fs::read(path).map_err(|error| { + failure( + &basename, + "unknown", + "read", + error.to_string(), + "Check file permissions and retry.", + ) + })?; + prepare_bytes(basename, bytes, sender, consent, cancelled, options) +} + +fn prepare_bytes( + basename: String, + bytes: Vec, + sender: &mpsc::Sender, + consent: &mpsc::Receiver, + cancelled: &AtomicBool, + options: ImportOptions, +) -> Result, Failure> { + let detected = plotx_io::image::sniff(&bytes).name().to_owned(); + let source_dpi = plotx_io::image::metadata_dpi(&bytes); + let probe = plotx_io::image::probe(&bytes).map_err(|error| { + failure( + &basename, + &detected, + "probe", + error.to_string(), + "Choose a supported PNG, JPEG, TIFF, WebP, or BMP file.", + ) + })?; + if options.import_all_tiff_pages && probe.format != plotx_io::image::RasterFormat::Tiff { + return Err(failure( + &basename, + &detected, + "probe_pages", + "the all-pages command accepts TIFF sources only".to_owned(), + "Choose Add Images… for PNG, JPEG, WebP, or BMP sources.", + )); + } + if probe.animated && !options.allow_first_frame { + return Err(failure( + &basename, + &detected, + "probe", + "animated images are not supported by this import path".to_owned(), + "Export the intended frame as a static PNG, JPEG, TIFF, WebP, or BMP and retry.", + )); + } + if probe.class == plotx_io::image::ResourceClass::Rejected { + return Err(failure( + &basename, + &detected, + "limits", + format!( + "{} × {} pixels and {} decoded bytes exceed the hard limit", + probe.width, probe.height, probe.decoded_bytes + ), + "Reduce the image dimensions before importing.", + )); + } + let bytes = if options.strip_metadata { + plotx_io::image::strip_metadata_to_png(&bytes, options.allow_first_frame).map_err( + |error| { + failure( + &basename, + &detected, + "strip_metadata", + error.to_string(), + "Use the normal import path for a large image, or reduce its dimensions first.", + ) + }, + )? + } else { + bytes + }; + let mut notices = Vec::new(); + if options.strip_metadata { + notices.push( + "Metadata was removed explicitly and the embedded pixels were re-encoded as PNG.", + ); + } + if probe.animated { + notices.push( + "Only the first animation frame is displayed; original animated bytes remain embedded.", + ); + } + if probe.high_precision { + notices.push( + "High-precision source is displayed as RGBA8 sRGB; original bytes remain embedded.", + ); + } + if probe.has_icc || probe.has_exif { + notices.push("ICC/EXIF metadata is preserved in the embedded original bytes."); + } + if probe.pages > 1 && !options.import_all_tiff_pages { + notices.push("Multi-page TIFF detected; only page one is imported by this command. Use Add All TIFF Pages… to create one image per page."); + } + let preview = if probe.class == plotx_io::image::ResourceClass::ProxyRequired { + let _ = sender.send(WorkerEvent::State( + ImportImageState::AwaitingLargeImageConsent, + )); + match consent.recv() { + Ok(true) => {} + Ok(false) | Err(_) => { + cancelled.store(true, Ordering::Relaxed); + return Err(failure( + &basename, + &detected, + "consent", + "large-image import was cancelled".to_owned(), + "Choose the image again if you want to import it.", + )); + } + } + let _ = sender.send(WorkerEvent::State(ImportImageState::DecodingProxy)); + let proxy = plotx_io::image::decode_proxy_rgba8(&bytes, 2048).map_err(|error| { + failure( + &basename, + &detected, + "proxy_decode", + error.to_string(), + "Reduce the image dimensions below the 500 megapixel hard limit and retry.", + ) + })?; + notices.push("Large image embedded; a bounded proxy is used for interactive preview."); + proxy + } else { + rebuild_preview(&bytes, 0).map_err(|error| { + failure( + &basename, + &detected, + "decode", + error.to_string(), + "Retry or re-encode the image in a supported format.", + ) + })? + }; + let warning = (!notices.is_empty()).then(|| notices.join(" ")); + let sha256 = plotx_io::image::sha256(&bytes); + let bytes = Arc::new(bytes); + let format = if options.strip_metadata { + "png".to_owned() + } else { + probe.format.name().to_ascii_lowercase() + }; + let mut candidates = vec![Candidate { + basename, + bytes, + sha256, + format, + pixel_size: [probe.width, probe.height], + auto_page_size_mm: physical_image_size_mm([probe.width, probe.height], source_dpi), + page_index: 0, + warning, + preview, + }]; + if options.import_all_tiff_pages && probe.format == plotx_io::image::RasterFormat::Tiff { + for page_index in 1..probe.pages { + let decoded = plotx_io::image::decode_rgba8_page( + candidates[0].bytes.as_slice(), + page_index, + false, + ) + .map_err(|error| { + failure( + &candidates[0].basename, + "TIFF", + "decode_page", + error.to_string(), + "Re-encode the failing TIFF page as a standalone PNG and retry.", + ) + })?; + candidates.push(Candidate { + basename: format!( + "{} — page {}", + Path::new(&candidates[0].basename) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("Image"), + page_index + 1 + ), + bytes: Arc::clone(&candidates[0].bytes), + sha256, + format: "tiff".to_owned(), + pixel_size: [decoded.probe.width, decoded.probe.height], + auto_page_size_mm: physical_image_size_mm( + [decoded.probe.width, decoded.probe.height], + source_dpi, + ), + page_index, + warning: decoded.probe.high_precision.then(|| { + "High-precision TIFF page is displayed as RGBA8 sRGB; original bytes remain embedded." + .to_owned() + }), + preview: rebuild_preview(candidates[0].bytes.as_slice(), page_index).map_err( + |error| { + failure( + &candidates[0].basename, + "TIFF", + "preview_page", + error.to_string(), + "Re-encode the failing TIFF page as a standalone PNG and retry.", + ) + }, + )?, + }); + } + } + Ok(candidates) +} + +fn commit(app: &mut PlotxApp, job: &mut ImportJob, results: Vec>) { + if job.cancelled.load(Ordering::Relaxed) { + job.state = ImportImageState::Cancelled; + return; + } + if job.request.target == ImportImageTarget::NewPages { + new_pages::commit(app, job, results); + return; + } + + let target_canvas = match job.request.target { + ImportImageTarget::Canvas { canvas, .. } + | ImportImageTarget::Panel { canvas, .. } + | ImportImageTarget::Replace { canvas, .. } => canvas, + ImportImageTarget::NewPages => unreachable!("new-page imports returned above"), + }; + let Some(ci) = app.doc.canvas_index(target_canvas) else { + job.state = ImportImageState::Cancelled; + record_failures( + app, + job.operation, + 0, + vec![failure( + "", + "unknown", + "commit", + "target page was deleted".to_owned(), + "Choose a page and import the images again.", + )], + ); + return; + }; + let (target_panel, drop_position, replace_content) = match job.request.target { + ImportImageTarget::Canvas { position, .. } => (None, position, None), + ImportImageTarget::Panel { + panel, position, .. + } => { + let Some(target) = app.doc.canvases[ci].panel(panel) else { + fail_stale_panel(app, job, "target panel was deleted"); + return; + }; + if target.locked { + fail_stale_panel(app, job, "target panel became locked"); + return; + } + (Some(panel), position, None) + } + ImportImageTarget::Replace { content, .. } => (None, None, Some(content)), + ImportImageTarget::NewPages => unreachable!("new-page imports returned above"), + }; + let page_before = app.doc.canvases[ci].clone(); + let before = PanelState::of(&page_before); + let mut page = page_before.clone(); + let mut failures = Vec::new(); + let mut imported = 0usize; + let mut warnings = Vec::new(); + let mut staged_assets: Vec = Vec::new(); + for result in results { + let candidate = match result { + Ok(candidate) => candidate, + Err(error) => { + failures.push(error); + continue; + } + }; + let asset = app + .doc + .assets + .iter() + .find_map(|(id, asset)| (asset.sha256 == candidate.sha256).then_some(*id)) + .or_else(|| { + staged_assets + .iter() + .find_map(|asset| (asset.sha256 == candidate.sha256).then_some(asset.id)) + }) + .unwrap_or_else(AssetId::new); + if let Some(content) = replace_content { + if let Some(item) = page.object_mut(content) + && let CanvasObjectKind::RasterImage(image) = &mut item.kind + { + image.asset = asset; + image.page_index = candidate.page_index; + if !app.doc.assets.contains_key(&asset) + && !staged_assets.iter().any(|record| record.id == asset) + { + staged_assets.push(AssetRecord { + id: asset, + sha256: candidate.sha256, + format: candidate.format.clone(), + pixel_size: candidate.pixel_size, + bytes: candidate.bytes.as_ref().clone(), + }); + } + insert_proxy( + app, + candidate.sha256, + candidate.page_index, + candidate.preview, + ); + imported += 1; + if let Some(warning) = candidate.warning { + warnings.push((candidate.basename, candidate.format, warning)); + } + continue; + } + failures.push(failure( + &candidate.basename, + &candidate.format, + "commit", + "the image being replaced no longer exists".to_owned(), + "Select an image and choose Replace Image again.", + )); + continue; + } + let id = page.allocate_object_id(); + let aspect = candidate.pixel_size[0] as f32 / candidate.pixel_size[1].max(1) as f32; + let [width, height] = initial_image_size(page.size_pt(), aspect); + let base = drop_position.unwrap_or([12.0, 12.0]); + let offset = imported as f32 * 6.0; + let mut frame = ObjectFrame::new(base[0] + offset, base[1] + offset, width, height); + if let Some(panel) = target_panel.and_then(|panel| page.panel(panel).cloned()) { + frame.x -= panel.frame.x; + frame.y -= panel.frame.y; + } + page.objects.push(CanvasObject { + id, + name: Path::new(&candidate.basename) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("Image") + .to_owned(), + frame, + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage({ + let mut image = RasterImageContent::new(asset); + image.page_index = candidate.page_index; + image + }), + }); + if let Some(panel) = target_panel.and_then(|panel| page.panel_mut(panel)) { + panel.item_order.push(id); + } + if !app.doc.assets.contains_key(&asset) + && !staged_assets.iter().any(|record| record.id == asset) + { + staged_assets.push(AssetRecord { + id: asset, + sha256: candidate.sha256, + format: candidate.format.clone(), + pixel_size: candidate.pixel_size, + bytes: candidate.bytes.as_ref().clone(), + }); + } + insert_proxy( + app, + candidate.sha256, + candidate.page_index, + candidate.preview, + ); + if let Some(warning) = candidate.warning { + warnings.push((candidate.basename, candidate.format, warning)); + } + imported += 1; + } + if imported > 0 { + let mut actions = Vec::new(); + actions.extend(staged_assets.into_iter().map(|asset| Action::SetAsset { + id: asset.id, + before: None, + after: Some(asset), + })); + actions.push(Action::ReplacePanelState { + canvas: ci, + before, + after: PanelState::of(&page), + }); + match app.try_execute_action(Action::Composite(actions)) { + Ok(()) => job.state = ImportImageState::Committed, + Err(error) => { + imported = 0; + job.state = ImportImageState::Failed; + failures.push(failure( + "", + "unknown", + "commit", + error.to_string(), + "Retry the import; if it still fails, review the diagnostic history.", + )); + } + } + } else { + job.state = ImportImageState::Failed; + } + record_result(app, job.operation, imported, failures, warnings); +} + +fn fail_stale_panel(app: &mut PlotxApp, job: &mut ImportJob, reason: &str) { + job.state = ImportImageState::Cancelled; + record_failures( + app, + job.operation, + 0, + vec![failure( + "", + "unknown", + "commit", + reason.to_owned(), + "Select an unlocked Panel and import the images again.", + )], + ); +} + +fn failure( + basename: &str, + detected: &str, + stage: &'static str, + reason: String, + remedy: &'static str, +) -> Failure { + Failure { + basename: basename.to_owned(), + detected_format: detected.to_owned(), + stage, + reason, + remedy, + } +} + +fn record_result( + app: &mut PlotxApp, + operation: OperationId, + imported: usize, + failures: Vec, + warnings: Vec<(String, String, String)>, +) { + let failed = failures.len(); + let summary = format!("Imported {imported} image(s); {failed} failed."); + if imported == 0 { + record_failures(app, operation, imported, failures); + return; + } + let mut report = if failed == 0 && warnings.is_empty() { + OperationReport::success(operation, OperationKind::ImageImport, summary, ()) + } else { + OperationReport::warning(operation, OperationKind::ImageImport, summary, ()) + }; + for failure in failures { + report = report.with_diagnostic(diagnostic(failure)); + } + for (file, detected_format, warning) in warnings { + report = report.with_diagnostic( + Diagnostic::new( + Severity::Warning, + DiagnosticCode::ImageImportWarning, + warning, + ) + .with_source("app.image_import") + .with_context("file", file) + .with_context("detected_format", detected_format) + .with_context("stage", "proxy_or_commit") + .with_context("reason", "safe fallback used") + .with_context( + "next_step", + "Review the placed image and retry with a smaller source if needed.", + ), + ); + } + app.session.record_operation(report); +} + +fn record_failures( + app: &mut PlotxApp, + operation: OperationId, + imported: usize, + failures: Vec, +) { + let mut failures = failures.into_iter(); + let first = failures.next().unwrap_or_else(|| { + failure( + "", + "unknown", + "commit", + "no image was ready to commit".to_owned(), + "Choose another image and retry.", + ) + }); + let mut report = OperationReport::<()>::failure( + operation, + OperationKind::ImageImport, + format!("Imported {imported} image(s); import failed."), + diagnostic(first), + ); + for failure in failures { + report = report.with_diagnostic(diagnostic(failure)); + } + app.session.record_operation(report); +} + +fn diagnostic(error: Failure) -> Diagnostic { + Diagnostic::new( + Severity::Error, + DiagnosticCode::ImageImportFailed, + "Image import failed; the document and undo history were not changed for this file.", + ) + .with_source("app.image_import") + .with_context("file", error.basename) + .with_context("detected_format", error.detected_format) + .with_context("stage", error.stage) + .with_context("reason", error.reason) + .with_context("next_step", error.remedy) +} + +#[cfg(test)] +#[path = "image_import_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/file_dialogs/image_import/clipboard.rs b/crates/app/src/ui/file_dialogs/image_import/clipboard.rs new file mode 100644 index 0000000..30adcaa --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import/clipboard.rs @@ -0,0 +1,207 @@ +use super::*; + +pub(crate) fn paste_clipboard_image(app: &mut PlotxApp) { + #[cfg(windows)] + let file_list_error = { + match crate::ui::clipboard_native::get_file_list() { + Ok(paths) if !paths.is_empty() => { + enqueue( + app, + ImportImageRequest { + paths, + payloads: Vec::new(), + target: ImportImageTarget::NewPages, + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + ); + return; + } + Ok(_) => None, + Err(error) => Some(error.to_string()), + } + }; + #[cfg(not(windows))] + let file_list_error: Option = None; + let image = match read_clipboard_pixels() { + Ok(Some(image)) => image, + Ok(None) if file_list_error.is_none() => return, + Ok(None) => { + let Some(file_list_error) = file_list_error else { + return; + }; + let operation = app.session.begin_operation(); + record_failures( + app, + operation, + 0, + vec![failure( + "Clipboard image", + "unknown", + "clipboard", + file_list_error, + "Copy image files or bitmap pixels, then choose Paste Image again.", + )], + ); + return; + } + Err(image_error) => { + let reason = match file_list_error { + Some(file_error) => format!( + "file-list read failed: {file_error}; pixel-image read failed: {image_error}" + ), + None => image_error, + }; + let operation = app.session.begin_operation(); + record_failures( + app, + operation, + 0, + vec![failure( + "Clipboard image", + "unknown", + "clipboard", + reason, + "Copy image files or bitmap pixels, then choose Paste Image again.", + )], + ); + return; + } + }; + let Some(buffer) = image::RgbaImage::from_raw(image.width, image.height, image.rgba) else { + let operation = app.session.begin_operation(); + record_failures( + app, + operation, + 0, + vec![failure( + "Clipboard image", + "RGBA", + "clipboard_decode", + "clipboard pixel dimensions did not match the returned byte count".to_owned(), + "Copy the image again and retry.", + )], + ); + return; + }; + let mut cursor = std::io::Cursor::new(Vec::new()); + if let Err(error) = + image::DynamicImage::ImageRgba8(buffer).write_to(&mut cursor, image::ImageFormat::Png) + { + let operation = app.session.begin_operation(); + record_failures( + app, + operation, + 0, + vec![failure( + "Clipboard image", + "RGBA", + "encode", + error.to_string(), + "Copy the image again and retry.", + )], + ); + return; + } + let target = app + .session + .active_canvas + .filter(|index| *index < app.doc.canvases.len()) + .map_or(ImportImageTarget::NewPages, |ci| { + let canvas = app.doc.canvases[ci].resource_id; + app.session + .ui + .hierarchical_selection + .lead() + .and_then(|path| (path.canvas == canvas).then_some(path.panel).flatten()) + .map_or( + ImportImageTarget::Canvas { + canvas, + position: None, + }, + |panel| ImportImageTarget::Panel { + canvas, + panel, + position: None, + }, + ) + }); + enqueue( + app, + ImportImageRequest { + paths: Vec::new(), + payloads: vec![("Pasted image.png".to_owned(), cursor.into_inner())], + target, + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + ); +} + +struct ClipboardPixels { + width: u32, + height: u32, + rgba: Vec, +} + +fn read_clipboard_pixels() -> Result, String> { + let arboard_error = + match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_image()) { + Ok(image) => { + let width = u32::try_from(image.width) + .map_err(|_| "clipboard image width exceeds the supported range".to_owned())?; + let height = u32::try_from(image.height) + .map_err(|_| "clipboard image height exceeds the supported range".to_owned())?; + return Ok(Some(ClipboardPixels { + width, + height, + rgba: image.bytes.into_owned(), + })); + } + Err(error) => arboard_image_error(error), + }; + + #[cfg(windows)] + { + match crate::ui::clipboard_native::get_dib_image() { + Ok(Some(image)) => Ok(Some(ClipboardPixels { + width: image.width(), + height: image.height(), + rgba: image.into_raw(), + })), + Ok(None) => arboard_error.map_or(Ok(None), |error| { + Err(format!( + "arboard returned {error}; no native DIB format was available" + )) + }), + Err(error) => Err(format!( + "arboard returned {}; native DIB read failed: {error}", + arboard_error.as_deref().unwrap_or("no image content") + )), + } + } + #[cfg(not(windows))] + { + arboard_error.map_or(Ok(None), Err) + } +} + +fn arboard_image_error(error: arboard::Error) -> Option { + match error { + arboard::Error::ContentNotAvailable => None, + error => Some(error.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::arboard_image_error; + + #[test] + fn unavailable_clipboard_image_content_is_not_a_read_failure() { + assert!(arboard_image_error(arboard::Error::ContentNotAvailable).is_none()); + assert!(arboard_image_error(arboard::Error::ClipboardOccupied).is_some()); + } +} diff --git a/crates/app/src/ui/file_dialogs/image_import/consent.rs b/crates/app/src/ui/file_dialogs/image_import/consent.rs new file mode 100644 index 0000000..95c5a24 --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import/consent.rs @@ -0,0 +1,48 @@ +use super::*; + +pub(crate) fn large_image_consent_window(app: &mut PlotxApp, ctx: &egui::Context) { + let awaiting = MANAGER.with(|manager| { + manager + .borrow() + .jobs + .iter() + .position(|job| job.state == ImportImageState::AwaitingLargeImageConsent) + }); + let Some(index) = awaiting else { return }; + let mut decision = None; + egui::Window::new("Large image") + .id(egui::Id::new("large-image-import-consent")) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label("This image exceeds 100 MP or an estimated 512 MiB when decoded."); + ui.label("PlotX will embed the original and use a bounded proxy for editing."); + ui.horizontal(|ui| { + if ui.button("Continue with proxy").clicked() { + decision = Some(true); + } + if ui.button("Cancel import").clicked() { + decision = Some(false); + } + }); + }); + if let Some(allow) = decision { + MANAGER.with(|manager| { + if let Some(job) = manager.borrow_mut().jobs.get_mut(index) + && job.consent.send(allow).is_ok() + { + job.state = if allow { + ImportImageState::DecodingProxy + } else { + job.cancelled.store(true, Ordering::Relaxed); + ImportImageState::Cancelled + }; + } + }); + app.session.status = if allow { + "Generating a bounded image proxy…".to_owned() + } else { + "Image import cancelled.".to_owned() + }; + } +} diff --git a/crates/app/src/ui/file_dialogs/image_import/geometry.rs b/crates/app/src/ui/file_dialogs/image_import/geometry.rs new file mode 100644 index 0000000..794791a --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import/geometry.rs @@ -0,0 +1,29 @@ +pub(super) fn initial_image_size(page_size: [f32; 2], aspect: f32) -> [f32; 2] { + let aspect = if aspect.is_finite() && aspect > 0.0 { + aspect + } else { + 1.0 + }; + let bounds = [ + (page_size[0] * 0.65).max(12.0), + (page_size[1] * 0.65).max(12.0), + ]; + if bounds[0] / bounds[1] > aspect { + [bounds[1] * aspect, bounds[1]] + } else { + [bounds[0], bounds[0] / aspect] + } +} + +pub(super) fn physical_image_size_mm(pixel_size: [u32; 2], dpi: Option<[f32; 2]>) -> [f32; 2] { + const DEFAULT_DPI: f32 = 96.0; + const MM_PER_INCH: f32 = 25.4; + let source_dpi = dpi + .map(|value| value[0]) + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(DEFAULT_DPI); + let aspect = pixel_size[0].max(1) as f32 / pixel_size[1].max(1) as f32; + let natural_width = pixel_size[0].max(1) as f32 / source_dpi * MM_PER_INCH; + let width = natural_width.min(plotx_core::state::NATURE_SINGLE_COLUMN.width_mm); + [width, width / aspect] +} diff --git a/crates/app/src/ui/file_dialogs/image_import/jobs.rs b/crates/app/src/ui/file_dialogs/image_import/jobs.rs new file mode 100644 index 0000000..e1c42f8 --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import/jobs.rs @@ -0,0 +1,59 @@ +use super::*; + +pub(crate) fn poll(app: &mut PlotxApp, ctx: &egui::Context) { + proxy::poll_rebuilds(app, ctx); + MANAGER.with(|manager| { + let mut manager = manager.borrow_mut(); + let mut completed = Vec::new(); + for (index, job) in manager.jobs.iter_mut().enumerate() { + loop { + let event = match job.receiver.try_recv() { + Ok(event) => event, + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + job.state = ImportImageState::Failed; + record_failures( + app, + job.operation, + 0, + vec![failure( + "", + "unknown", + "worker", + "the image import worker stopped before returning a result" + .to_owned(), + "Retry the import; if it still fails, review the diagnostic history.", + )], + ); + completed.push(index); + break; + } + }; + match event { + WorkerEvent::State(state) => { + job.state = state; + app.session.status = format!("Image import: {state:?}."); + if state == ImportImageState::Cancelled { + completed.push(index); + break; + } + } + WorkerEvent::Finished(results) => { + job.state = ImportImageState::ReadyToCommit; + commit(app, job, results); + completed.push(index); + break; + } + } + } + } + completed.sort_unstable(); + completed.dedup(); + for index in completed.into_iter().rev() { + manager.jobs.remove(index); + } + if !manager.jobs.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } + }); +} diff --git a/crates/app/src/ui/file_dialogs/image_import/new_pages.rs b/crates/app/src/ui/file_dialogs/image_import/new_pages.rs new file mode 100644 index 0000000..7445d04 --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import/new_pages.rs @@ -0,0 +1,125 @@ +use super::*; + +pub(super) fn commit( + app: &mut PlotxApp, + job: &mut ImportJob, + results: Vec>, +) { + let first_canvas = app.doc.canvases.len(); + let active_before = app.session.active_canvas; + let mut failures = Vec::new(); + let mut warnings = Vec::new(); + let mut staged_assets: Vec = Vec::new(); + let mut page_actions = Vec::new(); + let mut imported = 0usize; + + for result in results { + let candidate = match result { + Ok(candidate) => candidate, + Err(error) => { + failures.push(error); + continue; + } + }; + let asset = app + .doc + .assets + .iter() + .find_map(|(id, asset)| (asset.sha256 == candidate.sha256).then_some(*id)) + .or_else(|| { + staged_assets + .iter() + .find_map(|asset| (asset.sha256 == candidate.sha256).then_some(asset.id)) + }) + .unwrap_or_else(AssetId::new); + if !app.doc.assets.contains_key(&asset) + && !staged_assets.iter().any(|record| record.id == asset) + { + staged_assets.push(AssetRecord { + id: asset, + sha256: candidate.sha256, + format: candidate.format.clone(), + pixel_size: candidate.pixel_size, + bytes: candidate.bytes.as_ref().clone(), + }); + } + + let index = first_canvas + imported; + let mut page = + CanvasDocument::new(format!("Figure {}", index + 1), candidate.auto_page_size_mm); + let id = page.allocate_object_id(); + let [width, height] = page.size_pt(); + page.objects.push(CanvasObject { + id, + name: Path::new(&candidate.basename) + .file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("Image") + .to_owned(), + frame: ObjectFrame::new(0.0, 0.0, width, height), + locked: false, + visible: true, + kind: CanvasObjectKind::RasterImage({ + let mut image = RasterImageContent::new(asset); + image.page_index = candidate.page_index; + image + }), + }); + page.create_panel_for_content(id) + .expect("a raster image supports a one-item panel"); + let empty = CanvasDocument::new(page.name.clone(), page.size_mm); + page_actions.push(Action::insert_canvas( + index, + empty.clone(), + if imported == 0 { + active_before + } else { + Some(index - 1) + }, + )); + page_actions.push(Action::ReplacePanelState { + canvas: index, + before: PanelState::of(&empty), + after: PanelState::of(&page), + }); + insert_proxy( + app, + candidate.sha256, + candidate.page_index, + candidate.preview, + ); + if let Some(warning) = candidate.warning { + warnings.push((candidate.basename, candidate.format, warning)); + } + imported += 1; + } + + if imported > 0 { + let mut actions: Vec<_> = staged_assets + .into_iter() + .map(|asset| Action::SetAsset { + id: asset.id, + before: None, + after: Some(asset), + }) + .collect(); + actions.extend(page_actions); + match app.try_execute_action(Action::Composite(actions)) { + Ok(()) => job.state = ImportImageState::Committed, + Err(error) => { + imported = 0; + job.state = ImportImageState::Failed; + failures.push(failure( + "", + "unknown", + "commit", + error.to_string(), + "Retry the import; if it still fails, review the diagnostic history.", + )); + } + } + } else { + job.state = ImportImageState::Failed; + } + record_result(app, job.operation, imported, failures, warnings); +} diff --git a/crates/app/src/ui/file_dialogs/image_import/picker.rs b/crates/app/src/ui/file_dialogs/image_import/picker.rs new file mode 100644 index 0000000..f889fa0 --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import/picker.rs @@ -0,0 +1,130 @@ +use super::*; + +pub(crate) fn import_images(app: &mut PlotxApp) { + pick_images(app, false, false, false); +} + +pub(crate) fn import_images_first_frame(app: &mut PlotxApp) { + pick_images(app, true, false, false); +} + +pub(crate) fn import_images_without_metadata(app: &mut PlotxApp) { + pick_images(app, false, true, false); +} + +pub(crate) fn import_tiff_pages(app: &mut PlotxApp) { + pick_images(app, false, false, true); +} + +pub(crate) fn replace_selected_image(app: &mut PlotxApp) { + let Some(ci) = app.session.active_canvas else { + return; + }; + let Some(content) = app + .session + .ui + .hierarchical_selection + .lead() + .and_then(|path| path.content) + else { + return; + }; + let Some(path) = rfd::FileDialog::new() + .add_filter( + "Images", + &["png", "jpg", "jpeg", "tif", "tiff", "webp", "bmp"], + ) + .set_title("Replace image") + .pick_file() + else { + return; + }; + enqueue( + app, + ImportImageRequest { + paths: vec![path], + payloads: Vec::new(), + target: ImportImageTarget::Replace { + canvas: app.doc.canvases[ci].resource_id, + content, + }, + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + ); +} + +fn pick_images( + app: &mut PlotxApp, + allow_first_frame: bool, + strip_metadata: bool, + import_all_tiff_pages: bool, +) { + let Some(paths) = rfd::FileDialog::new() + .add_filter( + "Images (*.png, *.jpg, *.jpeg, *.tif, *.tiff, *.webp, *.bmp)", + &["png", "jpg", "jpeg", "tif", "tiff", "webp", "bmp"], + ) + .add_filter("All files", &["*"]) + .set_title("Add images to the figure") + .pick_files() + else { + return; + }; + import_image_paths_with_options( + app, + &paths, + allow_first_frame, + strip_metadata, + import_all_tiff_pages, + ); +} + +pub(crate) fn import_image_paths(app: &mut PlotxApp, paths: &[PathBuf]) { + import_image_paths_with_options(app, paths, false, false, false); +} + +fn import_image_paths_with_options( + app: &mut PlotxApp, + paths: &[PathBuf], + allow_first_frame: bool, + strip_metadata: bool, + import_all_tiff_pages: bool, +) { + let ci = app + .session + .active_canvas + .filter(|index| *index < app.doc.canvases.len()); + let target = ci.map_or(ImportImageTarget::NewPages, |ci| { + let canvas = app.doc.canvases[ci].resource_id; + match app + .session + .ui + .hierarchical_selection + .lead() + .and_then(|path| (path.canvas == canvas).then_some(path.panel).flatten()) + { + Some(panel) => ImportImageTarget::Panel { + canvas, + panel, + position: None, + }, + None => ImportImageTarget::Canvas { + canvas, + position: None, + }, + } + }); + enqueue( + app, + ImportImageRequest { + paths: paths.to_vec(), + payloads: Vec::new(), + target, + allow_first_frame, + strip_metadata, + import_all_tiff_pages, + }, + ); +} diff --git a/crates/app/src/ui/file_dialogs/image_import/proxy.rs b/crates/app/src/ui/file_dialogs/image_import/proxy.rs new file mode 100644 index 0000000..1353af4 --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import/proxy.rs @@ -0,0 +1,164 @@ +use super::*; + +struct ProxyRebuild { + hash: [u8; 32], + page_index: u32, + format: String, + receiver: mpsc::Receiver>, +} + +#[derive(Default)] +struct ProxyRebuilds { + active: Option, + failed: std::collections::BTreeSet<([u8; 32], u32)>, +} + +thread_local! { + static REBUILDS: RefCell = RefCell::new(ProxyRebuilds::default()); +} + +pub(super) fn poll_rebuilds(app: &mut PlotxApp, ctx: &egui::Context) { + REBUILDS.with(|rebuilds| { + let mut rebuilds = rebuilds.borrow_mut(); + if let Some(active) = &rebuilds.active { + match active.receiver.try_recv() { + Ok(Ok(proxy)) => { + let hash = active.hash; + let page_index = active.page_index; + insert_proxy(app, hash, page_index, proxy); + rebuilds.active = None; + } + Ok(Err(reason)) => { + let hash = active.hash; + let page_index = active.page_index; + let format = active.format.clone(); + rebuilds.failed.insert((hash, page_index)); + rebuilds.active = None; + let operation = app.session.begin_operation(); + record_failures( + app, + operation, + 0, + vec![failure( + "Embedded image", + &format, + "proxy_rebuild", + reason, + "Replace the image with a source below the 500 megapixel hard limit.", + )], + ); + } + Err(mpsc::TryRecvError::Empty) => { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + return; + } + Err(mpsc::TryRecvError::Disconnected) => { + let hash = active.hash; + let page_index = active.page_index; + rebuilds.failed.insert((hash, page_index)); + rebuilds.active = None; + } + } + } + let existing: std::collections::BTreeSet<_> = app + .session + .ui + .raster_proxies + .iter() + .map(|proxy| (proxy.hash, proxy.page_index)) + .collect(); + let candidate = app + .doc + .canvases + .iter() + .flat_map(|canvas| &canvas.objects) + .find_map(|item| { + let CanvasObjectKind::RasterImage(image) = &item.kind else { + return None; + }; + let asset = app.doc.assets.get(&image.asset)?; + let key = (asset.sha256, image.page_index); + (!existing.contains(&key) && !rebuilds.failed.contains(&key)) + .then_some((asset, image.page_index)) + }); + let Some((asset, page_index)) = candidate else { + return; + }; + let hash = asset.sha256; + let format = asset.format.clone(); + let bytes = asset.bytes.clone(); + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + let result = rebuild_preview(&bytes, page_index).map_err(|error| error.to_string()); + let _ = sender.send(result); + }); + rebuilds.active = Some(ProxyRebuild { + hash, + page_index, + format, + receiver, + }); + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + }); +} + +pub(super) fn insert_proxy( + app: &mut PlotxApp, + hash: [u8; 32], + page_index: u32, + proxy: plotx_io::image::ProxyImage, +) { + const PROXY_BUDGET: usize = 128 * 1024 * 1024; + app.session + .ui + .raster_proxies + .retain(|entry| entry.hash != hash || entry.page_index != page_index); + app.session + .ui + .raster_proxies + .push(plotx_core::state::RasterProxy { + hash, + page_index, + pixel_size: proxy.pixel_size, + rgba8: Arc::new(proxy.rgba8), + }); + while app + .session + .ui + .raster_proxies + .iter() + .map(|entry| entry.rgba8.len()) + .sum::() + > PROXY_BUDGET + && app.session.ui.raster_proxies.len() > 1 + { + app.session.ui.raster_proxies.remove(0); + } +} + +pub(super) fn rebuild_preview( + bytes: &[u8], + page_index: u32, +) -> Result { + if page_index == 0 { + let edge = if plotx_io::image::probe(bytes)?.class + == plotx_io::image::ResourceClass::ProxyRequired + { + 2048 + } else { + 4096 + }; + return plotx_io::image::decode_proxy_rgba8(bytes, edge); + } + let decoded = plotx_io::image::decode_rgba8_page(bytes, page_index, false)?; + let source = + image::RgbaImage::from_raw(decoded.probe.width, decoded.probe.height, decoded.rgba8) + .ok_or(plotx_io::image::ImageError::InvalidDecodedSize)?; + let preview = image::DynamicImage::ImageRgba8(source) + .thumbnail(4096, 4096) + .into_rgba8(); + Ok(plotx_io::image::ProxyImage { + pixel_size: [preview.width(), preview.height()], + rgba8: preview.into_raw(), + }) +} diff --git a/crates/app/src/ui/file_dialogs/image_import_tests.rs b/crates/app/src/ui/file_dialogs/image_import_tests.rs new file mode 100644 index 0000000..1a9e72b --- /dev/null +++ b/crates/app/src/ui/file_dialogs/image_import_tests.rs @@ -0,0 +1,442 @@ +use super::*; +use plotx_core::state::DEFAULT_CANVAS_SIZE_MM; + +fn png_payload() -> Vec { + let source = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 4])); + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut cursor, image::ImageFormat::Png) + .unwrap(); + cursor.into_inner() +} + +fn multipage_tiff_payload() -> Vec { + use tiff::encoder::{TiffEncoder, colortype}; + let mut cursor = std::io::Cursor::new(Vec::new()); + { + let mut encoder = TiffEncoder::new(&mut cursor).unwrap(); + encoder + .write_image::(2, 1, &[10, 20]) + .unwrap(); + encoder + .write_image::(1, 2, &[1, 2, 3, 4, 5, 6]) + .unwrap(); + } + cursor.into_inner() +} + +fn candidate(name: &str, pixel_size: [u32; 2]) -> Candidate { + let bytes = png_payload(); + Candidate { + basename: name.to_owned(), + sha256: plotx_io::image::sha256(&bytes), + preview: rebuild_preview(&bytes, 0).unwrap(), + bytes: Arc::new(bytes), + format: "png".to_owned(), + pixel_size, + auto_page_size_mm: physical_image_size_mm(pixel_size, None), + page_index: 0, + warning: None, + } +} + +fn ready_job(app: &mut PlotxApp, target: ImportImageTarget) -> ImportJob { + let (_events, receiver) = mpsc::channel(); + let (consent, _worker_consent) = mpsc::channel(); + ImportJob { + request: ImportImageRequest { + paths: Vec::new(), + payloads: Vec::new(), + target, + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + operation: app.session.begin_operation(), + state: ImportImageState::ReadyToCommit, + receiver, + cancelled: Arc::new(AtomicBool::new(false)), + consent, + } +} + +#[test] +fn worker_reports_ordered_states_and_a_ready_candidate() { + let (sender, receiver) = mpsc::channel(); + let (_consent_sender, consent) = mpsc::channel(); + worker( + Vec::new(), + vec![("sample.dat".to_owned(), png_payload())], + Arc::new(AtomicBool::new(false)), + sender, + consent, + ImportOptions { + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + ); + assert!(matches!( + receiver.recv().unwrap(), + WorkerEvent::State(ImportImageState::Probing) + )); + assert!(matches!( + receiver.recv().unwrap(), + WorkerEvent::State(ImportImageState::ReadyToCommit) + )); + let WorkerEvent::Finished(results) = receiver.recv().unwrap() else { + panic!("expected finished event") + }; + let candidate = results.into_iter().next().unwrap().ok().unwrap(); + assert_eq!(candidate.pixel_size, [2, 2]); + assert_eq!(candidate.format, "png"); +} + +#[test] +fn cancelled_worker_releases_payload_without_a_commit_event() { + let (sender, receiver) = mpsc::channel(); + let (_consent_sender, consent) = mpsc::channel(); + worker( + Vec::new(), + vec![("sample.png".to_owned(), png_payload())], + Arc::new(AtomicBool::new(true)), + sender, + consent, + ImportOptions { + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + ); + assert!(matches!( + receiver.recv().unwrap(), + WorkerEvent::State(ImportImageState::Probing) + )); + assert!(matches!( + receiver.recv().unwrap(), + WorkerEvent::State(ImportImageState::Cancelled) + )); + assert!(receiver.try_recv().is_err()); +} + +#[test] +fn disconnected_worker_is_removed_and_reported() { + let mut app = PlotxApp::default(); + let job = ready_job(&mut app, ImportImageTarget::NewPages); + MANAGER.with(|manager| manager.borrow_mut().jobs.push(job)); + + poll(&mut app, &egui::Context::default()); + + assert!(!has_active_jobs()); + let report = app + .session + .operation_history + .operations() + .next_back() + .expect("the disconnected worker must produce an operation report"); + assert_eq!(report.kind, OperationKind::ImageImport); + assert!( + report + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == DiagnosticCode::ImageImportFailed) + ); +} + +#[test] +fn all_tiff_pages_share_original_bytes_and_keep_distinct_page_indices() { + let (_sender_guard, sender) = mpsc::channel::(); + let (events, _receiver) = mpsc::channel(); + let candidates = prepare_bytes( + "stack.tiff".to_owned(), + multipage_tiff_payload(), + &events, + &sender, + &AtomicBool::new(false), + ImportOptions { + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: true, + }, + ) + .unwrap(); + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].page_index, 0); + assert_eq!(candidates[1].page_index, 1); + assert_eq!(candidates[1].pixel_size, [1, 2]); + assert!(Arc::ptr_eq(&candidates[0].bytes, &candidates[1].bytes)); +} + +#[test] +fn successful_import_into_empty_project_creates_one_undoable_page() { + let mut app = PlotxApp::default(); + assert!(app.doc.canvases.is_empty()); + let bytes = png_payload(); + let preview = rebuild_preview(&bytes, 0).unwrap(); + let candidate = Candidate { + basename: "dropped.png".to_owned(), + sha256: plotx_io::image::sha256(&bytes), + bytes: Arc::new(bytes), + format: "png".to_owned(), + pixel_size: [2, 2], + auto_page_size_mm: physical_image_size_mm([2, 2], None), + page_index: 0, + warning: None, + preview, + }; + let (_events, receiver) = mpsc::channel(); + let (consent, _worker_consent) = mpsc::channel(); + let mut job = ImportJob { + request: ImportImageRequest { + paths: Vec::new(), + payloads: Vec::new(), + target: ImportImageTarget::NewPages, + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + operation: app.session.begin_operation(), + state: ImportImageState::ReadyToCommit, + receiver, + cancelled: Arc::new(AtomicBool::new(false)), + consent, + }; + + commit(&mut app, &mut job, vec![Ok(candidate)]); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].objects.len(), 1); + assert_eq!(app.doc.assets.len(), 1); + assert_eq!(app.session.active_canvas, Some(0)); + assert_eq!( + app.doc.canvases[0].objects[0].frame, + ObjectFrame::new( + 0.0, + 0.0, + app.doc.canvases[0].size_pt()[0], + app.doc.canvases[0].size_pt()[1], + ) + ); + + let path = std::env::temp_dir().join(format!( + "plotx-empty-project-image-import-{}.plotx", + uuid::Uuid::new_v4() + )); + plotx_core::project::save_project(&app, &path, false).unwrap(); + let loaded = plotx_core::project::load_project(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(loaded.doc.canvases.len(), 1); + assert_eq!(loaded.doc.canvases[0].objects.len(), 1); + assert_eq!(loaded.doc.assets.len(), 1); + + app.undo(); + assert!(app.doc.canvases.is_empty()); + assert!(app.doc.assets.is_empty()); + assert_eq!(app.session.active_canvas, None); + + app.redo(); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].objects.len(), 1); + assert_eq!(app.doc.assets.len(), 1); +} + +#[test] +fn multiple_images_into_empty_project_create_distinct_pages_in_source_order() { + let mut app = PlotxApp::default(); + let make_candidate = |name: &str, pixel_size: [u32; 2]| { + let bytes = png_payload(); + Candidate { + basename: name.to_owned(), + sha256: plotx_io::image::sha256(&bytes), + preview: rebuild_preview(&bytes, 0).unwrap(), + bytes: Arc::new(bytes), + format: "png".to_owned(), + pixel_size, + auto_page_size_mm: physical_image_size_mm(pixel_size, None), + page_index: 0, + warning: None, + } + }; + let (_events, receiver) = mpsc::channel(); + let (consent, _worker_consent) = mpsc::channel(); + let mut job = ImportJob { + request: ImportImageRequest { + paths: Vec::new(), + payloads: Vec::new(), + target: ImportImageTarget::NewPages, + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + operation: app.session.begin_operation(), + state: ImportImageState::ReadyToCommit, + receiver, + cancelled: Arc::new(AtomicBool::new(false)), + consent, + }; + commit( + &mut app, + &mut job, + vec![ + Ok(make_candidate("first.png", [400, 200])), + Ok(make_candidate("second.png", [200, 400])), + ], + ); + assert_eq!(app.doc.canvases.len(), 2); + assert_eq!(app.doc.canvases[0].objects[0].name, "first"); + assert_eq!(app.doc.canvases[1].objects[0].name, "second"); + assert_ne!(app.doc.canvases[0].size_mm, app.doc.canvases[1].size_mm); + assert_eq!(app.session.undo_stack.len(), 1); + assert_eq!( + app.doc.canvases[0] + .panel_letter(app.doc.canvases[0].objects[0].id) + .as_deref(), + Some("a") + ); + app.undo(); + assert!(app.doc.canvases.is_empty()); + app.redo(); + assert_eq!(app.doc.canvases.len(), 2); +} + +#[test] +fn sequential_new_page_batches_never_retarget_the_active_imported_page() { + let mut app = PlotxApp::default(); + app.doc.canvases.push(CanvasDocument::new( + "Existing".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + )); + app.session.active_canvas = Some(0); + + let mut first = ready_job(&mut app, ImportImageTarget::NewPages); + commit( + &mut app, + &mut first, + vec![ + Ok(candidate("first.png", [400, 200])), + Ok(candidate("second.png", [200, 400])), + ], + ); + assert_eq!(app.doc.canvases.len(), 3); + assert_eq!(app.session.active_canvas, Some(2)); + assert!(app.doc.canvases[0].objects.is_empty()); + assert_eq!(app.session.undo_stack.len(), 1); + + let mut second = ready_job(&mut app, ImportImageTarget::NewPages); + commit( + &mut app, + &mut second, + vec![Ok(candidate("third.png", [300, 300]))], + ); + assert_eq!(app.doc.canvases.len(), 4); + assert_eq!(app.session.active_canvas, Some(3)); + assert_eq!(app.doc.canvases[2].objects.len(), 1); + assert_eq!(app.doc.canvases[3].objects[0].name, "third"); + assert_eq!(app.session.undo_stack.len(), 2); + + app.undo(); + assert_eq!(app.doc.canvases.len(), 3); + assert_eq!(app.session.active_canvas, Some(2)); + app.undo(); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.session.active_canvas, Some(0)); + app.redo(); + app.redo(); + assert_eq!(app.doc.canvases.len(), 4); + assert_eq!(app.session.active_canvas, Some(3)); +} + +#[test] +fn panel_target_that_locks_before_commit_fails_without_loose_content() { + let mut app = PlotxApp::default(); + let mut page = CanvasDocument::new("Existing".to_owned(), DEFAULT_CANVAS_SIZE_MM); + let panel = page.create_panel( + "Panel".to_owned(), + ObjectFrame::new(10.0, 10.0, 100.0, 80.0), + ); + let canvas = page.resource_id; + app.doc.canvases.push(page); + app.session.active_canvas = Some(0); + let mut job = ready_job( + &mut app, + ImportImageTarget::Panel { + canvas, + panel, + position: Some([20.0, 20.0]), + }, + ); + app.doc.canvases[0].panel_mut(panel).unwrap().locked = true; + + commit( + &mut app, + &mut job, + vec![Ok(candidate("late.png", [200, 100]))], + ); + + assert_eq!(job.state, ImportImageState::Cancelled); + assert!(app.doc.canvases[0].objects.is_empty()); + assert!(app.doc.assets.is_empty()); + assert!(app.session.undo_stack.is_empty()); + assert!(app.session.status.contains("failed")); +} + +#[test] +fn failed_import_into_empty_project_does_not_create_a_page_or_undo_step() { + let mut app = PlotxApp::default(); + let (_events, receiver) = mpsc::channel(); + let (consent, _worker_consent) = mpsc::channel(); + let mut job = ImportJob { + request: ImportImageRequest { + paths: Vec::new(), + payloads: Vec::new(), + target: ImportImageTarget::NewPages, + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + operation: app.session.begin_operation(), + state: ImportImageState::ReadyToCommit, + receiver, + cancelled: Arc::new(AtomicBool::new(false)), + consent, + }; + commit( + &mut app, + &mut job, + vec![Err(failure( + "broken.png", + "PNG", + "decode", + "truncated payload".to_owned(), + "Choose another image.", + ))], + ); + assert!(app.doc.canvases.is_empty()); + assert!(app.doc.assets.is_empty()); + assert_eq!(job.state, ImportImageState::Failed); + app.undo(); + assert!(app.doc.canvases.is_empty()); +} + +#[test] +fn initial_image_size_uses_page_points_and_preserves_aspect() { + let size = initial_image_size([510.0, 340.0], 2.0); + assert_eq!(size, [331.5, 165.75]); + let portrait = initial_image_size([510.0, 340.0], 0.5); + assert!((portrait[0] - 110.5).abs() < 1e-4); + assert!((portrait[1] - 221.0).abs() < 1e-4); +} + +#[test] +fn automatic_page_uses_physical_size_and_caps_width_at_nature_single_column() { + let sized = physical_image_size_mm([600, 300], Some([300.0, 300.0])); + assert!((sized[0] - 50.8).abs() < 1e-4); + assert!((sized[1] - 25.4).abs() < 1e-4); + + let capped = physical_image_size_mm([2400, 1200], None); + assert!((capped[0] - 89.0).abs() < 1e-4); + assert!((capped[1] - 44.5).abs() < 1e-4); + + let portrait = physical_image_size_mm([1200, 2400], None); + assert!((portrait[0] - 89.0).abs() < 1e-4); + assert!((portrait[1] - 178.0).abs() < 1e-4); +} diff --git a/crates/app/src/ui/file_dialogs/recent.rs b/crates/app/src/ui/file_dialogs/recent.rs index 217e183..38155ab 100644 --- a/crates/app/src/ui/file_dialogs/recent.rs +++ b/crates/app/src/ui/file_dialogs/recent.rs @@ -20,6 +20,7 @@ pub(crate) enum RecentOpenKind { OriginProject, Folder, DataFile, + Image, } impl RecentOpenKind { @@ -39,6 +40,7 @@ pub(crate) enum ClassifiedOpenPath { OriginProject(origin::OpenOriginSource), Folder, DataFile, + Image, } impl ClassifiedOpenPath { @@ -50,6 +52,7 @@ impl ClassifiedOpenPath { Self::OriginProject(_) => RecentOpenKind::OriginProject, Self::Folder => RecentOpenKind::Folder, Self::DataFile => RecentOpenKind::DataFile, + Self::Image => RecentOpenKind::Image, } } @@ -197,6 +200,7 @@ pub(crate) fn classify_open_handle( } RecentOpenKind::Folder => ClassifiedOpenPath::Folder, RecentOpenKind::DataFile => ClassifiedOpenPath::DataFile, + RecentOpenKind::Image => ClassifiedOpenPath::Image, }) } @@ -220,6 +224,9 @@ where } fn classify_open_header(path: &Path, header: &[u8]) -> Result { + if plotx_io::image::sniff(header).supported() { + return Ok(RecentOpenKind::Image); + } if header.starts_with(b"CPYA") || header.starts_with(b"CPYUA") { let probe = plotx_io::origin::probe_origin(header).map_err(OpenPathError::OriginProbe)?; reject_origin_family_mismatch(path, probe.format)?; @@ -331,10 +338,13 @@ pub(crate) fn dispatch_classified_path( } ClassifiedOpenPath::Folder => open_folder_path(app, path), ClassifiedOpenPath::DataFile => load_and_note(app, path), + ClassifiedOpenPath::Image => { + super::import_image_paths(app, std::slice::from_ref(&path.to_owned())) + } } } -fn record_open_path_failure(app: &mut PlotxApp, path: &Path, error: OpenPathError) { +pub(super) fn record_open_path_failure(app: &mut PlotxApp, path: &Path, error: OpenPathError) { let operation_id = app.session.begin_operation(); let message = error.to_string(); let (operation_kind, diagnostic_code) = open_path_failure_classification(path, &error); diff --git a/crates/app/src/ui/file_drop.rs b/crates/app/src/ui/file_drop.rs new file mode 100644 index 0000000..aac56c4 --- /dev/null +++ b/crates/app/src/ui/file_drop.rs @@ -0,0 +1,474 @@ +use std::cell::RefCell; +use std::path::PathBuf; +#[cfg(windows)] +use std::sync::atomic::{AtomicPtr, Ordering}; +use std::time::Duration; + +use egui::{Color32, Stroke, StrokeKind}; +use plotx_core::state::PlotxApp; + +use super::canvas; +use super::file_dialogs; +use super::file_dialogs::image_import::{ImportImageRequest, ImportImageTarget, enqueue}; + +const DROP_FALLBACK_SETTLE_SECONDS: f64 = 0.5; +const DROP_INCOMPLETE_TIMEOUT_SECONDS: f64 = 2.0; + +#[cfg(windows)] +static NATIVE_WINDOW: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + +#[cfg(windows)] +pub(crate) fn register_native_window( + cc: &eframe::CreationContext<'_>, +) -> Option { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + let Ok(handle) = cc.window_handle() else { + log::warn!("could not register the native window for external file-drop hit testing"); + return None; + }; + let RawWindowHandle::Win32(win32) = handle.as_raw() else { + log::warn!("the native window is not a Win32 window; file-drop hit testing is unavailable"); + return None; + }; + let window = win32.hwnd.get() as *mut core::ffi::c_void; + NATIVE_WINDOW.store(window, Ordering::Relaxed); + Some(window) +} + +#[derive(Default)] +struct FileDropBatch { + paths: Vec, + target: Option, + expected_path_count: Option, + ready_at: Option, + hover_paths: Vec, + hover_target: Option, +} + +impl FileDropBatch { + fn observe_hover( + &mut self, + hovered_paths: &[PathBuf], + dropping: bool, + target: Option, + ) { + if !hovered_paths.is_empty() && self.paths.is_empty() { + self.hover_paths.clear(); + self.hover_paths.extend_from_slice(hovered_paths); + self.hover_target = target; + } else if !dropping && self.paths.is_empty() { + self.hover_paths.clear(); + self.hover_target = None; + } + } + + fn push(&mut self, paths: Vec, now: f64) { + if self.paths.is_empty() { + self.target = Some(self.hover_target.unwrap_or(ImportImageTarget::NewPages)); + // Windows reports one DroppedFile event per path. The hover list + // gives the physical gesture's batch size before winit clears it. + self.expected_path_count = + (!self.hover_paths.is_empty()).then_some(self.hover_paths.len().max(paths.len())); + } + self.paths.extend(paths); + let settle_seconds = if self.expected_path_count.is_some() { + DROP_INCOMPLETE_TIMEOUT_SECONDS + } else { + DROP_FALLBACK_SETTLE_SECONDS + }; + self.ready_at = Some(now + settle_seconds); + } + + fn take_ready(&mut self, now: f64) -> Option<(Vec, ImportImageTarget)> { + let received_expected_paths = self + .expected_path_count + .is_some_and(|expected| self.paths.len() >= expected); + if !received_expected_paths && self.ready_at.is_none_or(|ready_at| now < ready_at) { + return None; + } + self.ready_at = None; + self.expected_path_count = None; + self.hover_paths.clear(); + self.hover_target = None; + let paths = std::mem::take(&mut self.paths); + let target = self.target.take().unwrap_or(ImportImageTarget::NewPages); + Some((paths, target)) + } +} + +thread_local! { + static FILE_DROP_BATCH: RefCell = RefCell::new(FileDropBatch::default()); +} + +pub(super) fn handle(app: &mut PlotxApp, ctx: &egui::Context) { + let board_rect = + ctx.data(|data| data.get_temp::(egui::Id::new("plotx.canvas.navigation_rect"))); + let (hovered_paths, dropped, pointer, now) = ctx.input(|input| { + ( + input + .raw + .hovered_files + .iter() + .filter_map(|file| file.path.clone()) + .collect::>(), + input + .raw + .dropped_files + .iter() + .filter_map(|file| file.path.clone()) + .collect::>(), + input.pointer.hover_pos(), + input.time, + ) + }); + let hovered = !hovered_paths.is_empty(); + let pointer = external_file_pointer(ctx, hovered, pointer); + let hover_target = pointer.and_then(|pointer| { + board_rect.and_then(|rect| canvas::image_drop_target(app, rect, pointer)) + }); + if hovered { + paint_overlay(ctx, hover_target); + } + + let panel_target = hover_target.map(|target| ImportImageTarget::Panel { + canvas: target.canvas, + panel: target.panel, + position: Some(target.position), + }); + FILE_DROP_BATCH.with(|batch| { + let mut batch = batch.borrow_mut(); + batch.observe_hover(&hovered_paths, !dropped.is_empty(), panel_target); + }); + if !dropped.is_empty() { + FILE_DROP_BATCH.with(|batch| batch.borrow_mut().push(dropped, now)); + } + + let pending = FILE_DROP_BATCH.with(|batch| batch.borrow().ready_at); + if let Some(ready_at) = pending { + ctx.request_repaint_after(Duration::from_secs_f64((ready_at - now).max(0.0))); + } + let ready = FILE_DROP_BATCH.with(|batch| batch.borrow_mut().take_ready(now)); + if let Some((paths, target)) = ready { + dispatch(app, paths, target); + } +} + +fn external_file_pointer( + _ctx: &egui::Context, + hovered: bool, + egui_pointer: Option, +) -> Option { + if !hovered { + return egui_pointer; + } + #[cfg(windows)] + { + native_windows_pointer(_ctx) + } + #[cfg(not(windows))] + { + egui_pointer + } +} + +#[cfg(windows)] +fn native_windows_pointer(ctx: &egui::Context) -> Option { + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::Graphics::Gdi::ScreenToClient; + use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos; + + let window = NATIVE_WINDOW.load(Ordering::Relaxed); + if window.is_null() { + return None; + } + let mut point = POINT { x: 0, y: 0 }; + // SAFETY: `window` is the live eframe HWND registered during app creation; + // both APIs only read it and write to `point`. + unsafe { + if GetCursorPos(&mut point) == 0 || ScreenToClient(window, &mut point) == 0 { + return None; + } + } + client_pixels_to_egui(point.x, point.y, ctx.pixels_per_point()) +} + +#[cfg(any(windows, test))] +fn client_pixels_to_egui(x: i32, y: i32, pixels_per_point: f32) -> Option { + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + Some(egui::pos2( + x as f32 / pixels_per_point, + y as f32 / pixels_per_point, + )) +} + +fn dispatch(app: &mut PlotxApp, paths: Vec, target: ImportImageTarget) { + if let Some(project) = paths + .iter() + .find(|path| file_dialogs::recent_open_kind(path) == file_dialogs::RecentOpenKind::Project) + { + let ignored = paths.len().saturating_sub(1); + file_dialogs::open_recent_path(app, project); + if ignored > 0 { + app.session.status = format!( + "Opening {}. Ignored {ignored} other dropped item(s) because a project open replaces the current document.", + project.display() + ); + } + return; + } + let mut images = Vec::new(); + let mut others = Vec::new(); + for path in paths { + let Some(classified) = file_dialogs::classify_dropped_path(app, &path) else { + continue; + }; + if classified.is_image() { + images.push(path); + } else { + others.push((path, classified)); + } + } + if !images.is_empty() { + enqueue( + app, + ImportImageRequest { + paths: images, + payloads: Vec::new(), + target, + allow_first_frame: false, + strip_metadata: false, + import_all_tiff_pages: false, + }, + ); + app.session.status = match target { + ImportImageTarget::Panel { .. } => { + "Dropping images into the selected Panel.".to_owned() + } + _ => "Each dropped image will create a new page.".to_owned(), + }; + } + for (path, classified) in others { + file_dialogs::dispatch_dropped_path(app, &path, classified); + } +} + +fn paint_overlay(ctx: &egui::Context, target: Option) { + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("file_drop_overlay"), + )); + let rect = ctx.content_rect(); + painter.rect_filled(rect, 0.0, Color32::from_black_alpha(140)); + let label = if let Some(target) = target { + painter.rect_stroke( + target.screen_rect, + 0.0, + Stroke::new(3.0_f32, Color32::from_rgb(82, 196, 120)), + StrokeKind::Inside, + ); + "Add images to selected Panel" + } else { + "Drop files here; each image creates a new page" + }; + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::new( + crate::typography::TITLE_1_PT, + egui::FontFamily::Proportional, + ), + Color32::WHITE, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use plotx_core::state::{ + CanvasDocument, CanvasId, DEFAULT_CANVAS_SIZE_MM, ObjectFrame, PanelId, + }; + + fn write_test_image(name: &str) -> PathBuf { + let source = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 255])); + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut cursor, image::ImageFormat::Bmp) + .unwrap(); + let path = std::env::temp_dir().join(format!( + "plotx-file-drop-{}-{name}.bmp", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, cursor.into_inner()).unwrap(); + path + } + + fn finish_image_imports(app: &mut PlotxApp) { + let ctx = egui::Context::default(); + for _ in 0..2_000 { + file_dialogs::image_import::poll(app, &ctx); + if !file_dialogs::image_import::has_active_jobs() { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!( + "image import did not finish; status: {}", + app.session.status + ); + } + + #[test] + fn split_windows_events_form_one_batch_and_the_next_drop_gets_a_fresh_target() { + let canvas = CanvasId::new(); + let panel = PanelId::new(); + let panel_target = ImportImageTarget::Panel { + canvas, + panel, + position: Some([4.0, 5.0]), + }; + let mut batch = FileDropBatch::default(); + batch.observe_hover( + &[ + PathBuf::from("one.png"), + PathBuf::from("two.png"), + PathBuf::from("three.png"), + ], + false, + Some(panel_target), + ); + batch.push(vec![PathBuf::from("one.png")], 1.0); + assert!(batch.take_ready(1.2).is_none()); + batch.push(vec![PathBuf::from("two.png")], 1.25); + assert!(batch.take_ready(1.5).is_none()); + batch.push(vec![PathBuf::from("three.png")], 1.55); + let (paths, target) = batch.take_ready(1.55).unwrap(); + assert_eq!( + paths, + [ + PathBuf::from("one.png"), + PathBuf::from("two.png"), + PathBuf::from("three.png") + ] + ); + assert_eq!(target, panel_target); + + batch.observe_hover(&[PathBuf::from("four.png")], false, None); + batch.push(vec![PathBuf::from("four.png")], 2.0); + let (_, target) = batch.take_ready(2.0).unwrap(); + assert_eq!(target, ImportImageTarget::NewPages); + } + + #[test] + fn drop_without_a_hover_snapshot_uses_a_bounded_fallback() { + let mut batch = FileDropBatch::default(); + batch.push(vec![PathBuf::from("one.png")], 1.0); + assert!(batch.take_ready(1.49).is_none()); + assert!(batch.take_ready(1.5).is_some()); + } + + #[test] + fn native_client_pixels_use_the_same_scale_as_egui_pointer_events() { + assert_eq!( + client_pixels_to_egui(180, 90, 1.5), + Some(egui::pos2(120.0, 60.0)) + ); + assert!(client_pixels_to_egui(10, 10, 0.0).is_none()); + assert!(client_pixels_to_egui(10, 10, f32::NAN).is_none()); + } + + #[test] + fn drop_frame_keeps_the_green_panel_target_when_current_ui_state_disappears() { + let mut batch = FileDropBatch::default(); + let target = ImportImageTarget::Panel { + canvas: CanvasId::new(), + panel: PanelId::new(), + position: Some([40.0, 50.0]), + }; + batch.observe_hover(&[PathBuf::from("one.png")], false, Some(target)); + batch.observe_hover(&[], true, None); + batch.push(vec![PathBuf::from("one.png")], 1.0); + let (_, committed_target) = batch.take_ready(1.0).unwrap(); + assert_eq!(committed_target, target); + } + + #[test] + fn green_panel_target_commits_after_drop_frame_loses_selection() { + let path = write_test_image("panel"); + let mut app = PlotxApp::default(); + let mut page = CanvasDocument::new("Existing".to_owned(), DEFAULT_CANVAS_SIZE_MM); + let panel = page.create_panel( + "Target".to_owned(), + ObjectFrame::new(10.0, 10.0, 120.0, 80.0), + ); + let canvas = page.resource_id; + app.doc.canvases.push(page); + app.session.active_canvas = Some(0); + app.select_panel(0, panel); + + let target = ImportImageTarget::Panel { + canvas, + panel, + position: Some([20.0, 20.0]), + }; + let mut batch = FileDropBatch::default(); + batch.observe_hover(std::slice::from_ref(&path), false, Some(target)); + app.session.ui.hierarchical_selection.clear(); + batch.observe_hover(&[], true, None); + batch.push(vec![path.clone()], 1.0); + let (paths, committed_target) = batch.take_ready(1.0).unwrap(); + assert_eq!(committed_target, target); + + dispatch(&mut app, paths, committed_target); + finish_image_imports(&mut app); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].objects.len(), 1); + assert_eq!( + app.doc.canvases[0].parent_panel(app.doc.canvases[0].objects[0].id), + Some(panel) + ); + assert_eq!(app.session.undo_stack.len(), 1); + + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn image_drop_batches_are_atomic_and_a_followup_drop_still_creates_a_page() { + let first = write_test_image("first"); + let second = write_test_image("second"); + let third = write_test_image("third"); + let mut app = PlotxApp::default(); + app.doc.canvases.push(CanvasDocument::new( + "Existing".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + )); + app.session.active_canvas = Some(0); + + dispatch( + &mut app, + vec![first.clone(), second.clone()], + ImportImageTarget::NewPages, + ); + finish_image_imports(&mut app); + assert_eq!(app.doc.canvases.len(), 3); + assert!(app.doc.canvases[0].objects.is_empty()); + assert_eq!(app.session.undo_stack.len(), 1); + app.undo(); + assert_eq!(app.doc.canvases.len(), 1); + app.redo(); + assert_eq!(app.doc.canvases.len(), 3); + + dispatch(&mut app, vec![third.clone()], ImportImageTarget::NewPages); + finish_image_imports(&mut app); + assert_eq!(app.doc.canvases.len(), 4); + assert_eq!(app.doc.canvases[2].objects.len(), 1); + assert!(app.doc.canvases[3].objects[0].name.ends_with("-third")); + + for path in [first, second, third] { + std::fs::remove_file(path).unwrap(); + } + } +} diff --git a/crates/app/src/ui/menus.rs b/crates/app/src/ui/menus.rs index 28982eb..10f78a9 100644 --- a/crates/app/src/ui/menus.rs +++ b/crates/app/src/ui/menus.rs @@ -65,6 +65,12 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { ), Separator, Command(CommandId::ImportTable), + Command(CommandId::ImportImage), + Command(CommandId::ImportImageFirstFrame), + Command(CommandId::ImportImageWithoutMetadata), + Command(CommandId::ImportTiffPages), + Command(CommandId::PasteImage), + Command(CommandId::CancelImageImport), Command(CommandId::PasteTable), Separator, Command(CommandId::SaveProject), @@ -84,6 +90,33 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { Command(CommandId::DeselectAll), Command(CommandId::Group), Command(CommandId::Ungroup), + Submenu( + "Panel", + vec![ + Command(CommandId::ComposePanel), + Command(CommandId::DissolvePanel), + Command(CommandId::DeletePanel), + Command(CommandId::DuplicatePanel), + Command(CommandId::MergePanels), + Command(CommandId::SplitPanel), + Separator, + Command(CommandId::SetPanelLayout( + plotx_core::state::PanelLayout::Free, + )), + Command(CommandId::SetPanelLayout( + plotx_core::state::PanelLayout::VerticalStack, + )), + Command(CommandId::SetPanelLayout( + plotx_core::state::PanelLayout::HorizontalStack, + )), + Command(CommandId::SetPanelLayout( + plotx_core::state::PanelLayout::Grid { rows: 2, cols: 2 }, + )), + Separator, + Command(CommandId::ReorderPanelLabels), + ], + ), + Command(CommandId::ReplaceImage), Separator, Command(CommandId::Preferences), ], @@ -111,6 +144,7 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { vec![ Submenu("New Canvas from Template", templates), Command(CommandId::NewTable), + Command(CommandId::CreatePanel), Separator, Command(CommandId::Tool(Tool::Text)), Command(CommandId::Tool(Tool::PanelLabel)), diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 6f5a8d2..ba9aba5 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -5,7 +5,7 @@ pub(crate) mod canvas; pub(crate) mod canvas_size; mod clipboard_figure; #[cfg(windows)] -mod clipboard_native; +pub(crate) mod clipboard_native; pub(crate) mod clipboard_table; mod command_exec; mod command_palette; @@ -16,6 +16,7 @@ mod diagnostics; mod export_dialog; mod figure_typography; pub(crate) mod file_dialogs; +pub(crate) mod file_drop; mod menus; #[cfg(target_os = "macos")] pub(crate) mod native_menu; @@ -66,6 +67,8 @@ pub fn render( ctx.send_viewport_cmd(egui::ViewportCommand::Title(project_window_title(app))); sync_chrome_theme(&ctx, app.settings.appearance.theme); clipboard_table_paste.begin_frame(app, &ctx); + file_dialogs::image_import::poll(app, &ctx); + file_dialogs::image_import::large_image_consent_window(app, &ctx); if let Some(payload) = app.poll_data_export() { copy_table_export(&ctx, payload); } @@ -109,6 +112,7 @@ pub fn render( handle_rename_shortcut(app, &ctx); handle_fit_shortcut(app, &ctx); handle_focus_shortcut(app, &ctx); + handle_hierarchy_traversal(app, &ctx); handle_delete_shortcut(app, &ctx); } @@ -178,7 +182,7 @@ pub fn render( trace_composer::trace_composer_window(app, &ctx); batch_workflow.show(app, &ctx); - handle_file_drop(app, &ctx); + file_drop::handle(app, &ctx); handle_close_request(app, &ctx); #[cfg(not(target_os = "macos"))] title_bar::resize_zones(&ctx); diff --git a/crates/app/src/ui/object_inspector.rs b/crates/app/src/ui/object_inspector.rs index 5e2eb4f..05db6af 100644 --- a/crates/app/src/ui/object_inspector.rs +++ b/crates/app/src/ui/object_inspector.rs @@ -6,6 +6,7 @@ mod chart_gallery; mod data; mod edits; mod geometry; +mod panel_inspector; use axes::{axes_section, commit_if_target_changed}; use chart_gallery::chart_gallery; @@ -47,6 +48,13 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { return; } inspector_header(app, Some(ci), &ids, ui); + if let Some(path) = app.session.ui.hierarchical_selection.lead() + && let Some(panel) = path.panel + && path.content.is_none() + { + panel_inspector::render(app, ci, panel, ui); + return; + } if ids.is_empty() { if app.session.ui.requested_inspector_section.as_deref() == Some("inspector.layout") { app.session.ui.requested_inspector_section = None; @@ -60,6 +68,9 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { section_navigation(app, ci, &ids, ui); custom_section_heading(app, "inspector.layout", "Layout", ui); geometry_section(app, ci, &ids, ui); + if ids.len() == 1 { + raster_image_section(app, ci, ids[0], ui); + } let property_objects: Vec<_> = ids .iter() .copied() @@ -119,6 +130,201 @@ pub(crate) fn render(app: &mut PlotxApp, ui: &mut Ui) { ui.add_space(2.0); } +fn raster_image_section(app: &mut PlotxApp, ci: usize, id: ObjectId, ui: &mut Ui) { + let Some(before) = app.doc.canvases[ci] + .object(id) + .and_then(|item| match &item.kind { + plotx_core::state::CanvasObjectKind::RasterImage(image) => Some(image.clone()), + _ => None, + }) + else { + return; + }; + let mut after = before.clone(); + let mut continuous = Vec::new(); + ui.separator(); + ui.label(crate::typography::headline("Image")); + if let Some(asset) = app.doc.assets.get(&before.asset) { + let source_pixels = if before.page_index == 0 { + asset.pixel_size + } else { + plotx_io::image::tiff_page_dimensions(&asset.bytes, before.page_index) + .unwrap_or(asset.pixel_size) + }; + ui.label(format!( + "Original: {} × {} px · {} · page {}", + source_pixels[0], + source_pixels[1], + asset.format, + before.page_index + 1 + )); + if let Some(dpi) = plotx_io::image::metadata_dpi(&asset.bytes) { + ui.label(format!("Metadata DPI: {:.0} × {:.0}", dpi[0], dpi[1])); + } else { + ui.weak("Metadata DPI: not present or not reported by this format"); + } + if let Some(frame) = app.doc.canvases[ci].content_page_frame(id) { + let crop_width = before.crop[2] - before.crop[0]; + let crop_height = before.crop[3] - before.crop[1]; + let mut pixels = [ + source_pixels[0] as f32 * crop_width, + source_pixels[1] as f32 * crop_height, + ]; + if matches!( + before.rotation, + plotx_core::state::QuarterTurn::Clockwise90 + | plotx_core::state::QuarterTurn::Clockwise270 + ) { + pixels.swap(0, 1); + } + let ppi = [ + pixels[0] / (frame.width / 72.0), + pixels[1] / (frame.height / 72.0), + ]; + ui.label(format!("Effective PPI: {:.0} × {:.0}", ppi[0], ppi[1])); + if ppi[0].min(ppi[1]) < 300.0 { + ui.colored_label( + ui.visuals().warn_fg_color, + "Below 300 PPI at the current frame size; reduce the frame or use a larger source.", + ); + } + } + } else { + ui.colored_label( + ui.visuals().error_fg_color, + "Embedded image asset is missing. Replace the image before continuing.", + ); + } + egui::ComboBox::from_label("Fit") + .selected_text(format!("{:?}", after.fit)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut after.fit, + plotx_core::state::ImageFit::Contain, + "Contain", + ); + ui.selectable_value(&mut after.fit, plotx_core::state::ImageFit::Cover, "Cover"); + ui.selectable_value( + &mut after.fit, + plotx_core::state::ImageFit::Stretch, + "Stretch", + ); + }); + egui::ComboBox::from_label("Interpolation") + .selected_text(format!("{:?}", after.interpolation)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut after.interpolation, + plotx_core::state::ImageInterpolation::Auto, + "Auto", + ); + ui.selectable_value( + &mut after.interpolation, + plotx_core::state::ImageInterpolation::Nearest, + "Nearest", + ); + ui.selectable_value( + &mut after.interpolation, + plotx_core::state::ImageInterpolation::Linear, + "Linear", + ); + }); + continuous.push(ui.add(egui::Slider::new(&mut after.opacity, 0.0..=1.0).text("Opacity"))); + ui.checkbox(&mut after.preserve_aspect, "Preserve aspect ratio"); + ui.horizontal(|ui| { + if ui.button("Rotate 90°").clicked() { + after.rotation = match after.rotation { + plotx_core::state::QuarterTurn::Zero => plotx_core::state::QuarterTurn::Clockwise90, + plotx_core::state::QuarterTurn::Clockwise90 => { + plotx_core::state::QuarterTurn::Clockwise180 + } + plotx_core::state::QuarterTurn::Clockwise180 => { + plotx_core::state::QuarterTurn::Clockwise270 + } + plotx_core::state::QuarterTurn::Clockwise270 => { + plotx_core::state::QuarterTurn::Zero + } + }; + } + if ui.button("Reset crop").clicked() { + after.crop = [0.0, 0.0, 1.0, 1.0]; + } + let replace = + crate::ui::commands::describe(app, crate::ui::commands::CommandId::ReplaceImage); + if ui + .add_enabled(replace.enabled, egui::Button::new(replace.label)) + .on_disabled_hover_text( + replace + .disabled_reason + .unwrap_or("Select one image to replace it."), + ) + .clicked() + { + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::ReplaceImage, + app, + ui.ctx(), + ); + } + }); + ui.collapsing("Crop mode", |ui| { + let [left, top, right, bottom] = after.crop; + continuous + .push(ui.add(egui::Slider::new(&mut after.crop[0], 0.0..=right - 0.001).text("Left"))); + continuous + .push(ui.add(egui::Slider::new(&mut after.crop[1], 0.0..=bottom - 0.001).text("Top"))); + continuous + .push(ui.add(egui::Slider::new(&mut after.crop[2], left + 0.001..=1.0).text("Right"))); + continuous + .push(ui.add(egui::Slider::new(&mut after.crop[3], top + 0.001..=1.0).text("Bottom"))); + }); + if after != before && after.validate().is_ok() { + let gesture_id = egui::Id::new(("raster-inspector-gesture", ci, id)); + let started = continuous.iter().any(egui::Response::drag_started); + let dragging = continuous.iter().any(egui::Response::dragged); + let stopped = continuous.iter().any(egui::Response::drag_stopped); + if started { + ui.ctx() + .data_mut(|data| data.insert_temp(gesture_id, before.clone())); + } + if dragging || stopped { + if let Some(item) = app.doc.canvases[ci].object_mut(id) + && let plotx_core::state::CanvasObjectKind::RasterImage(image) = &mut item.kind + { + *image = after.clone(); + } + if stopped { + let gesture_before = ui + .ctx() + .data_mut(|data| { + let value = data.get_temp(gesture_id); + data.remove::(gesture_id); + value + }) + .unwrap_or_else(|| before.clone()); + if let Some(item) = app.doc.canvases[ci].object_mut(id) + && let plotx_core::state::CanvasObjectKind::RasterImage(image) = &mut item.kind + { + *image = gesture_before.clone(); + } + app.execute_action(Action::SetRasterImage { + canvas: ci, + object: id, + before: gesture_before, + after, + }); + } + return; + } + app.execute_action(Action::SetRasterImage { + canvas: ci, + object: id, + before, + after, + }); + } +} + pub(crate) fn finish_series_edit_if_inactive(app: &mut PlotxApp, inspector_visible: bool) { let target_changed = app .session diff --git a/crates/app/src/ui/object_inspector/edits.rs b/crates/app/src/ui/object_inspector/edits.rs index aac5620..39e0ae7 100644 --- a/crates/app/src/ui/object_inspector/edits.rs +++ b/crates/app/src/ui/object_inspector/edits.rs @@ -51,6 +51,31 @@ pub(super) fn selection_context_label(app: &PlotxApp, ci: usize, ids: &[ObjectId return "No canvas".to_owned(); }; if ids.is_empty() { + if let Some(panel) = app + .session + .ui + .hierarchical_selection + .lead() + .and_then(|path| path.panel) + .and_then(|id| canvas.panel(id)) + { + let label = match &panel.label.mode { + plotx_core::state::PanelLabelMode::Auto { slot } => { + canvas.panel_label_style.format(*slot as usize) + } + plotx_core::state::PanelLabelMode::LockedAuto { value } + | plotx_core::state::PanelLabelMode::Manual { value } => value.clone(), + }; + return format!( + "{} / {}", + canvas.name, + if label.is_empty() { + panel.name.clone() + } else { + format!("Panel {label}") + } + ); + } return format!("Canvas · {}", canvas.name); } let datasets: std::collections::HashSet<_> = ids @@ -84,6 +109,25 @@ pub(super) fn selection_context_label(app: &PlotxApp, ci: usize, ids: &[ObjectId .object(ids[0]) .map(|object| object.name.clone()) .unwrap_or_else(|| "Object".to_owned()); + if let Some(panel) = canvas.parent_panel(ids[0]).and_then(|id| canvas.panel(id)) { + let label = match &panel.label.mode { + plotx_core::state::PanelLabelMode::Auto { slot } => { + canvas.panel_label_style.format(*slot as usize) + } + plotx_core::state::PanelLabelMode::LockedAuto { value } + | plotx_core::state::PanelLabelMode::Manual { value } => value.clone(), + }; + return format!( + "{} / {} / {}", + canvas.name, + if label.is_empty() { + panel.name.clone() + } else { + format!("Panel {label}") + }, + object + ); + } let dataset = datasets .iter() .next() diff --git a/crates/app/src/ui/object_inspector/panel_inspector.rs b/crates/app/src/ui/object_inspector/panel_inspector.rs new file mode 100644 index 0000000..874fefe --- /dev/null +++ b/crates/app/src/ui/object_inspector/panel_inspector.rs @@ -0,0 +1,213 @@ +use egui::Ui; +use plotx_core::actions::{Action, PanelState}; +use plotx_core::state::{PanelAlignment, PanelId, PanelLabelMode, PanelLayout, PlotxApp}; + +pub(super) fn render(app: &mut PlotxApp, ci: usize, id: PanelId, ui: &mut Ui) { + let Some(before) = app.doc.canvases[ci].panel(id).cloned() else { + ui.weak("The selected panel no longer exists. Select another layer."); + return; + }; + let mut after = before.clone(); + ui.label(crate::typography::headline("Panel")); + let name = ui.add(egui::TextEdit::singleline(&mut after.name).hint_text("Panel name")); + ui.checkbox(&mut after.visible, "Visible"); + ui.checkbox(&mut after.locked, "Locked"); + ui.checkbox(&mut after.clip_children, "Clip contents to panel"); + ui.add_enabled_ui(!before.locked, |ui| { + ui.label(crate::typography::headline("Frame")); + ui.horizontal(|ui| { + ui.label("X"); + ui.add(egui::DragValue::new(&mut after.frame.x).speed(0.25)); + ui.label("Y"); + ui.add(egui::DragValue::new(&mut after.frame.y).speed(0.25)); + }); + ui.horizontal(|ui| { + ui.label("Width"); + let width = ui.add( + egui::DragValue::new(&mut after.frame.width) + .range(1.0..=10_000.0) + .speed(0.25), + ); + if width.changed() { + after.frame.height = after.frame.width * before.frame.height / before.frame.width; + } + ui.label(format!( + "Height {:.1} pt (proportional)", + after.frame.height + )); + }); + }); + ui.separator(); + ui.label(crate::typography::headline("Internal layout")); + egui::ComboBox::from_label("Layout") + .selected_text(layout_label(after.layout)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut after.layout, PanelLayout::Free, "Free"); + ui.selectable_value( + &mut after.layout, + PanelLayout::VerticalStack, + "Vertical Stack", + ); + ui.selectable_value( + &mut after.layout, + PanelLayout::HorizontalStack, + "Horizontal Stack", + ); + ui.selectable_value( + &mut after.layout, + PanelLayout::Grid { rows: 2, cols: 2 }, + "Grid 2 × 2", + ); + }); + if let PanelLayout::Grid { rows, cols } = &mut after.layout { + ui.horizontal(|ui| { + ui.label("Rows"); + ui.add(egui::DragValue::new(rows).range(1..=64)); + ui.label("Columns"); + ui.add(egui::DragValue::new(cols).range(1..=64)); + }); + } + ui.horizontal(|ui| { + ui.label("Gap"); + ui.add( + egui::DragValue::new(&mut after.layout_gap) + .range(0.0..=144.0) + .suffix(" pt"), + ); + ui.label("Padding"); + ui.add( + egui::DragValue::new(&mut after.layout_padding) + .range(0.0..=144.0) + .suffix(" pt"), + ); + }); + egui::ComboBox::from_label("Alignment") + .selected_text(alignment_label(after.layout_alignment)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut after.layout_alignment, + PanelAlignment::Stretch, + "Stretch", + ); + ui.selectable_value(&mut after.layout_alignment, PanelAlignment::Start, "Start"); + ui.selectable_value( + &mut after.layout_alignment, + PanelAlignment::Center, + "Center", + ); + ui.selectable_value(&mut after.layout_alignment, PanelAlignment::End, "End"); + }); + ui.separator(); + ui.label(crate::typography::headline("Panel label")); + ui.checkbox(&mut after.label.visible, "Show label"); + ui.checkbox( + &mut after.label.participates_in_sequence, + "Participates in sequence", + ); + let mut mode = match after.label.mode { + PanelLabelMode::Auto { .. } => 0, + PanelLabelMode::LockedAuto { .. } => 1, + PanelLabelMode::Manual { .. } => 2, + }; + egui::ComboBox::from_label("Mode") + .selected_text(["Auto", "Locked auto", "Manual"][mode]) + .show_ui(ui, |ui| { + ui.selectable_value(&mut mode, 0, "Auto"); + ui.selectable_value(&mut mode, 1, "Locked auto"); + ui.selectable_value(&mut mode, 2, "Manual"); + }); + let displayed = panel_label(&app.doc.canvases[ci], &before); + after.label.mode = match mode { + 0 => match before.label.mode { + PanelLabelMode::Auto { slot } => PanelLabelMode::Auto { slot }, + _ => PanelLabelMode::Auto { + slot: app.doc.canvases[ci].next_panel_label_slot, + }, + }, + 1 => PanelLabelMode::LockedAuto { + value: displayed.clone(), + }, + _ => { + let mut value = match &before.label.mode { + PanelLabelMode::Manual { value } => value.clone(), + _ => displayed, + }; + ui.add(egui::TextEdit::singleline(&mut value).hint_text("Label text")); + PanelLabelMode::Manual { value } + } + }; + ui.add( + egui::DragValue::new(&mut after.label.font_size) + .range(1.0..=72.0) + .suffix(" pt"), + ); + ui.horizontal(|ui| { + ui.label("Position"); + ui.add(egui::DragValue::new(&mut after.label.position[0]).speed(0.25)); + ui.add(egui::DragValue::new(&mut after.label.position[1]).speed(0.25)); + }); + ui.label("Note"); + ui.add(egui::TextEdit::multiline(&mut after.note).desired_rows(3)); + let layout_changed = after.layout != before.layout + || after.layout_gap != before.layout_gap + || after.layout_padding != before.layout_padding + || after.layout_alignment != before.layout_alignment; + if layout_changed { + match app.set_panel_layout_action( + ci, + id, + after.layout, + after.layout_gap, + after.layout_padding, + after.layout_alignment, + ) { + Ok(action) => app.execute_action(action), + Err(error) => app.session.status = error.to_string(), + } + return; + } + if after != before + && after.label.validate().is_ok() + && (!name.has_focus() || name.lost_focus()) + && !after.name.trim().is_empty() + { + let state_before = PanelState::of(&app.doc.canvases[ci]); + let mut page = app.doc.canvases[ci].clone(); + if let Some(panel) = page.panel_mut(id) { + *panel = after; + app.execute_action(Action::ReplacePanelState { + canvas: ci, + before: state_before, + after: PanelState::of(&page), + }); + } + } +} + +fn alignment_label(alignment: PanelAlignment) -> &'static str { + match alignment { + PanelAlignment::Stretch => "Stretch", + PanelAlignment::Start => "Start", + PanelAlignment::Center => "Center", + PanelAlignment::End => "End", + } +} + +fn layout_label(layout: PanelLayout) -> &'static str { + match layout { + PanelLayout::Free => "Free", + PanelLayout::VerticalStack => "Vertical Stack", + PanelLayout::HorizontalStack => "Horizontal Stack", + PanelLayout::Grid { .. } => "Grid", + } +} + +fn panel_label( + page: &plotx_core::state::CanvasDocument, + panel: &plotx_core::state::Panel, +) -> String { + match &panel.label.mode { + PanelLabelMode::Auto { slot } => page.panel_label_style.format(*slot as usize), + PanelLabelMode::LockedAuto { value } | PanelLabelMode::Manual { value } => value.clone(), + } +} diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index febb2b0..5305d0e 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -6,21 +6,19 @@ use plotx_core::state::{ CanvasObjectKind, FrameRef, ObjectId, PlotxApp, PrimaryView, RegionSelection, RenameState, RenameTarget, }; - mod board_views; mod data_browser; +mod layers_tree; pub(crate) mod selection; use board_views::board_views_section; use data_browser::{AnalysisItem, AnalysisKind, DataTree, DatasetNode}; use selection::*; - pub fn render(app: &mut PlotxApp, ui: &mut Ui) { ui.add_space(6.0); switcher::segmented(ui, &mut app.session.view); ui.add_space(8.0); ui.separator(); ui.add_space(4.0); - if app.session.view == PrimaryView::Data { ui.add( egui::TextEdit::singleline(&mut app.session.ui.data_browser_filter) @@ -32,14 +30,11 @@ pub fn render(app: &mut PlotxApp, ui: &mut Ui) { ); ui.add_space(4.0); } - - // Board bookmarks pin to the bottom; the list scrolls in the space above. if app.session.view == PrimaryView::Canvas && !app.doc.canvases.is_empty() { egui::Panel::bottom("board_views") .resizable(false) .show_inside(ui, |ui| board_views_section(app, ui)); } - egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| match app.session.view { @@ -47,13 +42,11 @@ pub fn render(app: &mut PlotxApp, ui: &mut Ui) { PrimaryView::Data => data_list(app, ui), }); } - enum RenameOutcome { Commit(String), Cancel, Editing, } - /// The shared inline text box for an active rename. Clicking elsewhere keeps /// the draft; only Enter commits and Escape cancels it. fn rename_edit(ui: &mut Ui, rs: &mut RenameState, id: egui::Id) -> RenameOutcome { @@ -89,20 +82,17 @@ fn rename_edit(ui: &mut Ui, rs: &mut RenameState, id: egui::Id) -> RenameOutcome RenameOutcome::Editing } } - fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { if app.doc.canvases.is_empty() { ui.weak("No canvases yet. Opening data creates one automatically."); return; } - let mut select: Option<(usize, SelectModifiers)> = None; let mut open_settings: Option = None; let mut delete: Option = None; let mut start_rename: Option = None; let mut commit: Option<(usize, String)> = None; let mut cancel = false; - for ci in 0..app.doc.canvases.len() { let renaming = matches!( &app.session.ui.rename, @@ -117,7 +107,6 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { } continue; } - let name = app.doc.canvases[ci].name.clone(); let selected = crate::ui::canvas::frame_is_selected(app, FrameRef::Page(ci)); let resp = ui.selectable_label(selected, name); @@ -147,7 +136,6 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { }); resp.on_hover_text("Double-click to rename"); } - if let Some(ci) = app.session.active_canvas && ci < app.doc.canvases.len() && !app.doc.canvases[ci].objects.is_empty() @@ -158,7 +146,6 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { ui.label(crate::typography::headline("Layers")); object_list(app, ci, ui); } - if let Some((ci, name)) = commit { let trimmed = name.trim(); if !trimmed.is_empty() { @@ -216,20 +203,25 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { app.execute_action(action); } } - -/// The z-order (front-to-back, top-to-bottom) layers list. Rows mirror the -/// canvas front: the top row is the frontmost object (last in `objects`). fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { + layers_tree::render_panels(app, ci, ui); let mut select = None; let mut reorder: Option<(ObjectId, ZOrder)> = None; - // (object, destination canvas, is_move). Deferred so the row's context menu - // doesn't hold an app borrow while mutating the canvases. let mut transfer: Option<(ObjectId, usize, bool)> = None; let others = crate::ui::menus::other_canvas_destinations(app, ci); + let panel_destinations: Vec<_> = app.doc.canvases[ci] + .panels + .iter() + .filter(|panel| !panel.locked) + .map(|panel| (panel.id, layers_tree::panel_tree_name(app, ci, panel))) + .collect(); let count = app.doc.canvases[ci].objects.len(); for row in 0..count { let oi = count - 1 - row; let object_id = app.doc.canvases[ci].objects[oi].id; + if app.doc.canvases[ci].parent_panel(object_id).is_some() { + continue; + } ui.horizontal(|ui| { let mut visible = app.doc.canvases[ci].objects[oi].visible; if ui @@ -259,13 +251,36 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { } let selected = app.session.ui.selection.contains(object_id) || app.session.ui.selection.object() == Some(object_id); - let resp = ui.selectable_label(selected, app.doc.canvases[ci].objects[oi].name.clone()); + let resp = ui.add( + egui::Button::selectable(selected, app.doc.canvases[ci].objects[oi].name.clone()) + .sense(egui::Sense::click_and_drag()), + ); + if resp.drag_started() { + app.session.ui.layers_drag_content = Some(object_id); + } if resp.clicked() || (resp.secondary_clicked() && !selected) { claim_list_keyboard_focus(ui, &resp); select = Some((object_id, select_modifiers(ui))); } resp.context_menu(|ui| { object_transfer_menu(ui, object_id, &others, &mut transfer); + if !panel_destinations.is_empty() { + ui.menu_button("Move into panel", |ui| { + for (panel, name) in &panel_destinations { + if ui.button(name).clicked() { + app.select_content(ci, object_id); + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::MoveContentToPanel(Some( + *panel, + )), + app, + ui.ctx(), + ); + ui.close(); + } + } + }); + } }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let mut locked = app.doc.canvases[ci].objects[oi].locked; @@ -324,7 +339,6 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { app.transfer_objects_to_canvas(ci, &[object_id], to, is_move); } } - fn object_transfer_menu( ui: &mut Ui, object_id: ObjectId, @@ -343,7 +357,6 @@ fn object_transfer_menu( *transfer = Some((object_id, to, is_move)); } } - fn kind_glyph(kind: &CanvasObjectKind) -> &'static str { match kind { CanvasObjectKind::Plot(_) => icon::CHART_LINE, @@ -352,7 +365,6 @@ fn kind_glyph(kind: &CanvasObjectKind) -> &'static str { CanvasObjectKind::RasterImage(_) => icon::FILE, } } - fn kind_label(kind: &CanvasObjectKind) -> &'static str { match kind { CanvasObjectKind::Plot(_) => "Plot", @@ -361,13 +373,11 @@ fn kind_label(kind: &CanvasObjectKind) -> &'static str { CanvasObjectKind::RasterImage(_) => "Image", } } - fn data_list(app: &mut PlotxApp, ui: &mut Ui) { if app.doc.datasets.is_empty() { ui.weak("No data yet. Open an acquisition from the toolbar."); return; } - data_browser::reveal_active_path(app); let query = app.session.ui.data_browser_filter.clone(); let filtering = !query.trim().is_empty(); @@ -376,7 +386,6 @@ fn data_list(app: &mut PlotxApp, ui: &mut Ui) { ui.weak("No matching data or analysis results."); return; } - let visible = tree.visible_datasets(app, filtering); let mut event = None; let mut rename_rendered = false; @@ -393,7 +402,6 @@ fn data_list(app: &mut PlotxApp, ui: &mut Ui) { } apply_browser_event(app, ui, &visible, event); } - #[derive(Clone)] enum BrowserEvent { SelectDataset(usize, SelectModifiers), @@ -406,7 +414,6 @@ enum BrowserEvent { Jump(FrameRef), SelectAnalysis(usize, AnalysisItem, bool), } - fn render_dataset_node( app: &mut PlotxApp, ui: &mut Ui, @@ -424,7 +431,6 @@ fn render_dataset_node( &app.session.ui.rename, Some(RenameState { target: RenameTarget::Data(i), .. }) if *i == di ); - ui.horizontal(|ui| { ui.add_space(depth as f32 * 12.0); if has_children { @@ -533,7 +539,6 @@ fn render_dataset_node( } }); }); - if !open || node.cycle_cut { return; } @@ -544,7 +549,6 @@ fn render_dataset_node( render_derived_group(app, ui, node, depth + 1, filtering, rename_rendered, event); } } - fn render_analysis_group( app: &mut PlotxApp, ui: &mut Ui, diff --git a/crates/app/src/ui/primary_sidebar/layers_tree.rs b/crates/app/src/ui/primary_sidebar/layers_tree.rs new file mode 100644 index 0000000..76aba03 --- /dev/null +++ b/crates/app/src/ui/primary_sidebar/layers_tree.rs @@ -0,0 +1,325 @@ +use egui::Ui; +use egui_phosphor::regular as icon; +use plotx_core::actions::{Action, PanelState}; +use plotx_core::state::{ + CanvasObjectKind, ContentId, Panel, PanelId, PanelLabelMode, PlotxApp, SelectionPath, +}; + +pub(super) fn render_panels(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { + let panels: Vec<_> = app.doc.canvases[ci] + .panels + .iter() + .map(|panel| panel.id) + .rev() + .collect(); + for panel in panels { + render_panel(app, ci, panel, ui); + } + if app.session.ui.layers_drag_content.is_some() + && ui.input(|input| input.pointer.primary_released()) + { + app.session.ui.layers_drag_content = None; + app.session.ui.panel_drop_target = None; + } +} + +fn render_panel(app: &mut PlotxApp, ci: usize, panel_id: PanelId, ui: &mut Ui) { + let Some(panel) = app.doc.canvases[ci].panel(panel_id).cloned() else { + return; + }; + let collapse_id = egui::Id::new(("figure-layer-panel-open", panel_id)); + let mut open = ui.data(|data| data.get_temp::(collapse_id).unwrap_or(true)); + let selected_path = SelectionPath::panel(app.doc.canvases[ci].resource_id, panel_id); + let selected = app + .session + .ui + .hierarchical_selection + .contains(selected_path); + let mut select_panel = false; + let mut enter_panel = false; + let mut flags = None; + let mut drop_content = None; + let display_name = panel_tree_name(app, ci, &panel); + ui.horizontal(|ui| { + if ui + .small_button(if open { + icon::CARET_DOWN + } else { + icon::CARET_RIGHT + }) + .clicked() + { + open = !open; + ui.data_mut(|data| data.insert_temp(collapse_id, open)); + } + let mut visible = panel.visible; + if ui + .checkbox(&mut visible, "") + .on_hover_text("Visible") + .changed() + { + flags = Some((visible, panel.locked)); + } + ui.weak(icon::RECTANGLE).on_hover_text("Panel"); + let response = ui.add( + egui::Button::selectable(selected, display_name).sense(egui::Sense::click_and_drag()), + ); + if response.drag_started() { + app.session.ui.panel_drop_target = None; + } + if let Some(content) = app.session.ui.layers_drag_content + && response.hovered() + && !panel.locked + && app.doc.canvases[ci].parent_panel(content) != Some(panel_id) + { + app.session.ui.panel_drop_target = Some(panel_id); + if ui.input(|input| input.pointer.primary_released()) { + drop_content = Some(content); + } + } + if app.session.ui.panel_drop_target == Some(panel_id) { + ui.painter().rect_stroke( + response.rect, + 2.0, + egui::Stroke::new(1.5_f32, ui.visuals().selection.stroke.color), + egui::StrokeKind::Inside, + ); + } + if response.clicked() || (response.secondary_clicked() && !selected) { + select_panel = true; + } + if response.double_clicked() { + enter_panel = true; + } + response.context_menu(|ui| panel_context_menu(app, ui)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut locked = panel.locked; + if ui + .checkbox(&mut locked, "") + .on_hover_text("Locked") + .changed() + { + flags = Some((panel.visible, locked)); + } + }); + }); + if let Some(content) = drop_content { + app.select_content(ci, content); + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::MoveContentToPanel(Some(panel_id)), + app, + ui.ctx(), + ); + app.session.ui.layers_drag_content = None; + app.session.ui.panel_drop_target = None; + } + if let Some((visible, locked)) = flags { + replace_panel(app, ci, panel_id, |panel| { + panel.visible = visible; + panel.locked = locked; + }); + } + if select_panel { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::Layers; + if ui.input(|input| input.modifiers.command || input.modifiers.shift) { + if let Err(reason) = app.toggle_panel_sibling(ci, panel_id) { + app.session.status = reason.to_owned(); + } + } else { + app.select_panel(ci, panel_id); + } + } + if enter_panel { + app.enter_panel(ci, panel_id); + } + if open { + for content in panel.item_order.iter().rev().copied() { + render_content(app, ci, panel_id, content, ui); + } + if panel.item_order.is_empty() { + ui.horizontal(|ui| { + ui.add_space(36.0); + ui.weak("Empty panel — add or move content here."); + }); + } + } +} + +pub(super) fn panel_tree_name(app: &PlotxApp, ci: usize, panel: &Panel) -> String { + let label = match &panel.label.mode { + PanelLabelMode::Auto { slot } => app.doc.canvases[ci] + .panel_label_style + .format(*slot as usize), + PanelLabelMode::LockedAuto { value } | PanelLabelMode::Manual { value } => value.clone(), + }; + if panel.label.visible && !label.is_empty() { + format!("{label} {}", panel.name) + } else { + panel.name.clone() + } +} + +fn render_content(app: &mut PlotxApp, ci: usize, panel: PanelId, content: ContentId, ui: &mut Ui) { + let Some(item) = app.doc.canvases[ci].object(content).cloned() else { + return; + }; + let path = SelectionPath::content(app.doc.canvases[ci].resource_id, Some(panel), content); + let selected = app.session.ui.hierarchical_selection.contains(path); + let mut select = false; + let mut flags = None; + let destinations: Vec<_> = app.doc.canvases[ci] + .panels + .iter() + .filter(|candidate| candidate.id != panel && !candidate.locked) + .map(|candidate| (candidate.id, candidate.name.clone())) + .collect(); + ui.horizontal(|ui| { + ui.add_space(24.0); + let mut visible = item.visible; + if ui + .checkbox(&mut visible, "") + .on_hover_text("Visible") + .changed() + { + flags = Some((visible, item.locked)); + } + ui.weak(kind_glyph(&item.kind)) + .on_hover_text(kind_label(&item.kind)); + let response = ui.add( + egui::Button::selectable(selected, item.name.clone()) + .sense(egui::Sense::click_and_drag()), + ); + if response.drag_started() { + app.session.ui.layers_drag_content = Some(content); + } + if response.clicked() || (response.secondary_clicked() && !selected) { + select = true; + } + response.context_menu(|ui| { + if ui.button("Move out of panel").clicked() { + app.select_content(ci, content); + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::MoveContentToPanel(None), + app, + ui.ctx(), + ); + ui.close(); + } + if !destinations.is_empty() { + ui.menu_button("Move to panel", |ui| { + for (target, name) in &destinations { + if ui.button(name).clicked() { + app.select_content(ci, content); + crate::ui::commands::execute_without_clipboard( + crate::ui::commands::CommandId::MoveContentToPanel(Some(*target)), + app, + ui.ctx(), + ); + ui.close(); + } + } + }); + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut locked = item.locked; + if ui + .checkbox(&mut locked, "") + .on_hover_text("Locked") + .changed() + { + flags = Some((item.visible, locked)); + } + }); + }); + if let Some((visible, locked)) = flags { + app.execute_action(Action::set_object_flags( + ci, + content, + (item.visible, item.locked), + (visible, locked), + )); + } + if select { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::Layers; + let additive = ui.input(|input| input.modifiers.command || input.modifiers.shift); + if additive { + if let Err(reason) = app.toggle_content_sibling(ci, content) { + app.session.status = reason.to_owned(); + } + } else { + app.select_content(ci, content); + } + } +} + +fn panel_context_menu(app: &mut PlotxApp, ui: &mut Ui) { + ui.menu_button("Order", |ui| { + for (label, order) in [ + ("Bring to Front", plotx_core::actions::ZOrder::Front), + ("Bring Forward", plotx_core::actions::ZOrder::Forward), + ("Send Backward", plotx_core::actions::ZOrder::Backward), + ("Send to Back", plotx_core::actions::ZOrder::Back), + ] { + let command = crate::ui::commands::CommandId::ZOrder(order); + let descriptor = crate::ui::commands::describe(app, command); + if ui + .add_enabled(descriptor.enabled, egui::Button::new(label)) + .clicked() + { + crate::ui::commands::execute_without_clipboard(command, app, ui.ctx()); + ui.close(); + } + } + }); + for command in [ + crate::ui::commands::CommandId::DuplicatePanel, + crate::ui::commands::CommandId::DissolvePanel, + crate::ui::commands::CommandId::DeletePanel, + ] { + let descriptor = crate::ui::commands::describe(app, command); + if ui + .add_enabled(descriptor.enabled, egui::Button::new(descriptor.label)) + .clicked() + { + crate::ui::commands::execute_without_clipboard(command, app, ui.ctx()); + ui.close(); + } + } +} + +fn replace_panel( + app: &mut PlotxApp, + ci: usize, + id: PanelId, + edit: impl FnOnce(&mut plotx_core::state::Panel), +) { + let before = PanelState::of(&app.doc.canvases[ci]); + let mut page = app.doc.canvases[ci].clone(); + if let Some(panel) = page.panel_mut(id) { + edit(panel); + app.execute_action(Action::ReplacePanelState { + canvas: ci, + before, + after: PanelState::of(&page), + }); + } +} + +fn kind_glyph(kind: &CanvasObjectKind) -> &'static str { + match kind { + CanvasObjectKind::Plot(_) => icon::CHART_LINE, + CanvasObjectKind::Text(_) => "T", + CanvasObjectKind::Shape(_) => icon::SHAPES, + CanvasObjectKind::RasterImage(_) => icon::FILE, + } +} + +fn kind_label(kind: &CanvasObjectKind) -> &'static str { + match kind { + CanvasObjectKind::Plot(_) => "Plot", + CanvasObjectKind::Text(_) => "Text", + CanvasObjectKind::Shape(_) => "Shape", + CanvasObjectKind::RasterImage(_) => "Image", + } +} diff --git a/crates/app/src/ui/ribbon.rs b/crates/app/src/ui/ribbon.rs index 85095ef..ae7636d 100644 --- a/crates/app/src/ui/ribbon.rs +++ b/crates/app/src/ui/ribbon.rs @@ -412,6 +412,8 @@ fn short_label(command: &CommandDescriptor) -> String { } .to_owned(), CommandId::ImportTable => "Import Table".to_owned(), + CommandId::ImportImage => "Add Images".to_owned(), + CommandId::ImportImageFirstFrame => "First Frame".to_owned(), CommandId::PasteTable => "Paste Table".to_owned(), CommandId::NewTable => "New Table".to_owned(), CommandId::StackData => "Stack Data".to_owned(), diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index f8cb21c..d56c09c 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -105,6 +105,7 @@ static BINDINGS: &[CommandBinding] = &[ // routes the platform Copy event to this command. menu_accelerator: false, }, + bound(commands::CommandId::PasteImage, cmd(egui::Key::V)), bound(commands::CommandId::SelectAll, cmd(egui::Key::A)), bound(commands::CommandId::DeselectAll, cmd_shift(egui::Key::A)), bound(commands::CommandId::Group, cmd(egui::Key::G)), @@ -259,10 +260,22 @@ pub(super) fn handle_command_shortcuts( clipboard: &mut clipboard_table::ClipboardTablePaste, ctx: &egui::Context, ) { + if let Some(command) = shortcut_command(ctx) { + commands::execute(command, app, clipboard, ctx); + } +} + +fn shortcut_command(ctx: &egui::Context) -> Option { + let system_paste = ctx.input(|input| { + input + .events + .iter() + .any(|event| matches!(event, egui::Event::Paste(_))) + }); if ctx.egui_wants_keyboard_input() { - return; + return None; } - let command = ctx.input(|i| { + ctx.input(|i| { // The platform Copy event has no chord of its own; route it like Cmd+C. if i.events .iter() @@ -270,6 +283,9 @@ pub(super) fn handle_command_shortcuts( { return Some(commands::CommandId::CopyFigure); } + if system_paste { + return Some(commands::CommandId::PasteImage); + } BINDINGS .iter() .filter(|binding| binding.dispatch) @@ -279,10 +295,7 @@ pub(super) fn handle_command_shortcuts( .any(|chord| chord_pressed(i, chord)) .then_some(binding.id) }) - }); - if let Some(command) = command { - commands::execute(command, app, clipboard, ctx); - } + }) } pub(super) fn handle_escape_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { @@ -336,6 +349,18 @@ fn handle_escape(app: &mut PlotxApp, now: f64) { return; } + if app + .session + .ui + .hierarchical_selection + .lead() + .is_some_and(|path| path.panel.is_some() && path.content.is_some()) + { + app.exit_panel_scope(); + app.session.status = "Selected parent panel.".to_owned(); + return; + } + if !app.session.ui.frame_selection.is_empty() { app.session.ui.frame_selection.clear(); app.session.status = "Frame selection cleared.".to_owned(); @@ -441,6 +466,15 @@ pub(super) fn handle_focus_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { if !ctx.input(|i| i.key_pressed(egui::Key::Enter)) { return; } + if let Some(path) = app.session.ui.hierarchical_selection.lead() + && let Some(panel) = path.panel + && path.content.is_none() + && let Some(ci) = app.doc.canvas_index(path.canvas) + { + app.enter_panel(ci, panel); + app.session.status = "Entered panel.".to_owned(); + return; + } let frame = match app.session.ui.frame_selection.as_slice() { [only] => plotx_core::state::board_frame_ref(app, *only), _ => app @@ -455,6 +489,75 @@ pub(super) fn handle_focus_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { app.session.status = "Focused frame.".to_owned(); } +pub(super) fn handle_hierarchy_traversal(app: &mut PlotxApp, ctx: &egui::Context) { + if ctx.egui_wants_keyboard_input() { + return; + } + let (pressed, reverse) = + ctx.input(|input| (input.key_pressed(egui::Key::Tab), input.modifiers.shift)); + if !pressed { + return; + } + let Some(ci) = app.session.active_canvas else { + return; + }; + let canvas = &app.doc.canvases[ci]; + let current = app.session.ui.hierarchical_selection.lead(); + let mut paths = Vec::new(); + if let Some(panel_id) = current.and_then(|path| { + (path.canvas == canvas.resource_id && path.content.is_some()) + .then_some(path.panel) + .flatten() + }) { + if let Some(panel) = canvas.panel(panel_id) { + paths.extend(panel.item_order.iter().filter_map(|id| { + canvas.object(*id).filter(|item| item.visible).map(|_| { + plotx_core::state::SelectionPath::content( + canvas.resource_id, + Some(panel_id), + *id, + ) + }) + })); + } + } else { + paths.extend( + canvas + .panels + .iter() + .filter(|panel| panel.visible) + .map(|panel| plotx_core::state::SelectionPath::panel(canvas.resource_id, panel.id)), + ); + paths.extend( + canvas + .objects + .iter() + .filter(|item| item.visible && canvas.parent_panel(item.id).is_none()) + .map(|item| { + plotx_core::state::SelectionPath::content(canvas.resource_id, None, item.id) + }), + ); + } + if paths.is_empty() { + return; + } + let at = current + .and_then(|path| paths.iter().position(|candidate| *candidate == path)) + .unwrap_or(if reverse { 0 } else { paths.len() - 1 }); + let next = if reverse { + (at + paths.len() - 1) % paths.len() + } else { + (at + 1) % paths.len() + }; + let path = paths[next]; + if let Some(content) = path.content { + app.select_content(ci, content); + } else if let Some(panel) = path.panel { + app.select_panel(ci, panel); + } + app.session.status = format!("Selected item {} of {}.", next + 1, paths.len()); +} + pub(super) fn handle_delete_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { if ctx.egui_wants_keyboard_input() { return; @@ -519,55 +622,6 @@ pub(super) fn handle_delete_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { app.session.status = "Panel letter hidden.".to_owned(); } -pub(super) fn handle_file_drop(app: &mut PlotxApp, ctx: &egui::Context) { - let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty()); - if hovering { - let painter = ctx.layer_painter(egui::LayerId::new( - egui::Order::Foreground, - egui::Id::new("file_drop_overlay"), - )); - let rect = ctx.content_rect(); - painter.rect_filled(rect, 0.0, Color32::from_black_alpha(160)); - painter.text( - rect.center(), - egui::Align2::CENTER_CENTER, - "Drop a PlotX project, table, Origin project, data file, data folder, or archive", - egui::FontId::new( - crate::typography::TITLE_1_PT, - egui::FontFamily::Proportional, - ), - Color32::WHITE, - ); - } - - let dropped: Vec<_> = ctx.input(|i| { - i.raw - .dropped_files - .iter() - .filter_map(|f| f.path.clone()) - .collect() - }); - if let Some(project) = dropped.iter().find(|path| { - super::file_dialogs::recent_open_kind(path) == super::file_dialogs::RecentOpenKind::Project - }) { - let ignored = dropped.len().saturating_sub(1); - super::file_dialogs::open_recent_path(app, project); - if ignored > 0 { - app.session.status = format!( - "Opening {}. Ignored {ignored} other dropped item(s) because a project open replaces the current document.", - project.display() - ); - } - return; - } - for path in dropped { - // Route by shape through the same dispatcher as the recent list and the - // welcome page, so a dropped CSV reaches the delimited importer instead - // of failing through the acquisition loader. Notes each open as recent. - super::file_dialogs::open_recent_path(app, &path); - } -} - #[cfg(test)] mod tests { use super::*; @@ -591,6 +645,10 @@ mod tests { fn labels_derive_from_the_binding_table() { let label = shortcut_label(commands::CommandId::SaveProject).unwrap(); assert!(label.ends_with("+S")); + assert!( + shortcut_label(commands::CommandId::PasteImage) + .is_some_and(|label| label.ends_with("+V")) + ); assert_eq!( shortcut_label(commands::CommandId::Tool(Tool::Select)).as_deref(), Some("V") @@ -603,6 +661,56 @@ mod tests { assert!(shortcut_label(commands::CommandId::About).is_none()); } + fn paste_key_event() -> egui::Event { + egui::Event::Key { + key: egui::Key::V, + physical_key: Some(egui::Key::V), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::CTRL, + } + } + + #[test] + fn restored_ctrl_v_and_platform_paste_events_route_to_paste_image() { + for event in [ + paste_key_event(), + egui::Event::Paste("clipboard".to_owned()), + ] { + let ctx = egui::Context::default(); + let input = egui::RawInput { + events: vec![event], + modifiers: egui::Modifiers::CTRL, + ..Default::default() + }; + let mut command = None; + let _ = ctx.run_ui(input, |ui| command = shortcut_command(ui.ctx())); + assert_eq!(command, Some(commands::CommandId::PasteImage)); + } + } + + #[test] + fn focused_text_edit_keeps_ctrl_v_for_text_paste() { + let ctx = egui::Context::default(); + let mut text = String::new(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.add(egui::TextEdit::singleline(&mut text)) + .request_focus(); + }); + let input = egui::RawInput { + events: vec![egui::Event::Paste("text".to_owned())], + modifiers: egui::Modifiers::CTRL, + ..Default::default() + }; + let mut command = None; + let _ = ctx.run_ui(input, |ui| { + command = shortcut_command(ui.ctx()); + ui.add(egui::TextEdit::singleline(&mut text)); + }); + assert_eq!(command, None); + assert_eq!(text, "text"); + } + #[test] fn escape_exits_an_active_tool_after_other_fallbacks() { let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs index 8b65e19..ed7f565 100644 --- a/crates/core/src/actions/app_impl/apply.rs +++ b/crates/core/src/actions/app_impl/apply.rs @@ -16,6 +16,14 @@ impl PlotxApp { }; } match action { + Action::SetAsset { id, after, .. } => match after { + Some(asset) => { + self.doc.assets.insert(*id, asset.clone()); + } + None => { + self.doc.assets.remove(id); + } + }, Action::ReplacePanelState { canvas, after, .. } => { self.set_panel_state(*canvas, after); } @@ -263,6 +271,18 @@ impl PlotxApp { } => { self.set_object_text_value(*canvas, *object, after.clone()); } + Action::SetRasterImage { + canvas, + object, + after, + .. + } => { + if let Some(item) = self.doc.canvases[*canvas].object_mut(*object) + && let crate::state::CanvasObjectKind::RasterImage(image) = &mut item.kind + { + *image = after.clone(); + } + } Action::SetObjectStyle { canvas, after, .. } => { self.set_object_styles(*canvas, after); } diff --git a/crates/core/src/actions/app_impl/revert.rs b/crates/core/src/actions/app_impl/revert.rs index bb22af5..cc3aef5 100644 --- a/crates/core/src/actions/app_impl/revert.rs +++ b/crates/core/src/actions/app_impl/revert.rs @@ -12,6 +12,14 @@ impl PlotxApp { }; } match action { + Action::SetAsset { id, before, .. } => match before { + Some(asset) => { + self.doc.assets.insert(*id, asset.clone()); + } + None => { + self.doc.assets.remove(id); + } + }, Action::ReplacePanelState { canvas, before, .. } => { self.set_panel_state(*canvas, before); } @@ -305,6 +313,18 @@ impl PlotxApp { } => { self.set_object_text_value(*canvas, *object, before.clone()); } + Action::SetRasterImage { + canvas, + object, + before, + .. + } => { + if let Some(item) = self.doc.canvases[*canvas].object_mut(*object) + && let crate::state::CanvasObjectKind::RasterImage(image) = &mut item.kind + { + *image = before.clone(); + } + } Action::SetObjectStyle { canvas, before, .. } => { self.set_object_styles(*canvas, before); } diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index 135798f..fd128c1 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -39,6 +39,21 @@ pub(super) fn validate_action( shape: &mut ValidationShape, ) -> Result<(), ActionApplyError> { match action { + Action::SetAsset { id, before, after } => { + for asset in before.iter().chain(after) { + if asset.id != *id || asset.pixel_size.contains(&0) { + return Err(ActionApplyError::InvalidValue(format!( + "asset {id} has invalid identity or dimensions" + ))); + } + let digest = plotx_io::image::sha256(&asset.bytes); + if digest != asset.sha256 { + return Err(ActionApplyError::InvalidValue(format!( + "asset {id} hash does not match its bytes" + ))); + } + } + } Action::ReplacePanelState { canvas, before, @@ -229,6 +244,45 @@ pub(super) fn validate_action( } shape.canvases += 1; } + Action::SetRasterImage { + canvas, + object, + before, + after, + } => { + let Some(item) = app + .doc + .canvases + .get(*canvas) + .and_then(|canvas| canvas.object(*object)) + else { + return Err(ActionApplyError::StaleTarget(format!("image {object}"))); + }; + if !matches!(item.kind, crate::state::CanvasObjectKind::RasterImage(_)) { + return Err(ActionApplyError::InvalidValue(format!( + "content {object} is not a raster image" + ))); + } + for image in [before, after] { + image.validate().map_err(ActionApplyError::InvalidValue)?; + let Some(asset) = app.doc.assets.get(&image.asset) else { + return Err(ActionApplyError::InvalidValue(format!( + "image {object} references missing asset {}", + image.asset + ))); + }; + if image.page_index > 0 + && (asset.format != "tiff" + || plotx_io::image::tiff_page_count(&asset.bytes) + .is_none_or(|pages| image.page_index >= pages)) + { + return Err(ActionApplyError::InvalidValue(format!( + "image {object} references missing page {}", + image.page_index + ))); + } + } + } Action::SetObjectViewport { canvas, object, .. } | Action::SetAxisOverrides { canvas, object, .. } | Action::MoveResizeObject { canvas, object, .. } diff --git a/crates/core/src/actions/arrange.rs b/crates/core/src/actions/arrange.rs index 28e7ec5..48f0fbb 100644 --- a/crates/core/src/actions/arrange.rs +++ b/crates/core/src/actions/arrange.rs @@ -1,4 +1,5 @@ use super::*; +use crate::state::{ContentId, ObjectId, PanelId}; impl PlotxApp { /// Reposition the active canvas's plot objects into a `rows × cols` grid @@ -24,6 +25,20 @@ impl PlotxApp { let mut after_layout = before_layout; after_layout.rows = rows.max(1); after_layout.cols = cols.max(1); + let page_siblings = self.page_sibling_frames(ci); + if page_siblings.len() >= 2 { + let ids: Vec<_> = page_siblings.iter().map(|(_, id, _)| *id).collect(); + let after = crate::layout::assign_grid(canvas.size_pt(), &after_layout, &ids); + self.execute_action(Action::Composite(vec![ + Action::set_page_layout(ci, before_layout, after_layout), + self.page_sibling_frame_action(ci, &page_siblings, &after), + ])); + self.session.status = format!( + "Arranged {} page item(s) into a {rows}×{cols} grid.", + after.len() + ); + return; + } let page = canvas.size_pt(); let ids = canvas.plot_object_ids(); let axis_changes = if simplify_inner_axes { @@ -159,11 +174,69 @@ impl PlotxApp { .collect() } + fn page_sibling_frames(&self, ci: usize) -> Vec<(PageSibling, ObjectId, ObjectFrame)> { + let Some(page) = self.doc.canvases.get(ci) else { + return Vec::new(); + }; + self.session + .ui + .hierarchical_selection + .paths() + .iter() + .filter_map(|path| match (path.panel, path.content) { + (Some(panel), None) => page + .panel(panel) + .filter(|panel| !panel.locked) + .map(|panel| (PageSibling::Panel(panel.id), panel.frame)), + (None, Some(content)) => page + .object(content) + .filter(|item| !item.locked && page.parent_panel(content).is_none()) + .map(|item| (PageSibling::Content(content), item.frame)), + _ => None, + }) + .enumerate() + .map(|(index, (target, frame))| (target, ObjectId::new(index as u64 + 1), frame)) + .collect() + } + + fn page_sibling_frame_action( + &self, + ci: usize, + before: &[(PageSibling, ObjectId, ObjectFrame)], + after: &[(ObjectId, ObjectFrame)], + ) -> Action { + let state_before = PanelState::of(&self.doc.canvases[ci]); + let mut page = self.doc.canvases[ci].clone(); + for ((target, _, _), (_, frame)) in before.iter().zip(after) { + match target { + PageSibling::Panel(id) => page.panel_mut(*id).unwrap().frame = *frame, + PageSibling::Content(id) => page.object_mut(*id).unwrap().frame = *frame, + } + } + Action::ReplacePanelState { + canvas: ci, + before: state_before, + after: PanelState::of(&page), + } + } + /// Align the current multi-selection to a shared edge/centre (≥2 objects). pub fn align_selected(&mut self, mode: crate::layout::Align) { let Some(ci) = self.session.active_canvas else { return; }; + let page_siblings = self.page_sibling_frames(ci); + if page_siblings.len() >= 2 { + let frames: Vec<_> = page_siblings + .iter() + .map(|(_, id, frame)| (*id, *frame)) + .collect(); + let after = crate::layout::align(&frames, mode); + let action = self.page_sibling_frame_action(ci, &page_siblings, &after); + self.execute_action(action); + self.session.status = "Aligned page items.".to_owned(); + return; + } let before = self.selected_movable_frames(ci); if before.len() < 2 { return; @@ -178,6 +251,18 @@ impl PlotxApp { let Some(ci) = self.session.active_canvas else { return; }; + let page_siblings = self.page_sibling_frames(ci); + if page_siblings.len() >= 3 { + let frames: Vec<_> = page_siblings + .iter() + .map(|(_, id, frame)| (*id, *frame)) + .collect(); + let after = crate::layout::distribute(&frames, axis); + let action = self.page_sibling_frame_action(ci, &page_siblings, &after); + self.execute_action(action); + self.session.status = "Distributed page items.".to_owned(); + return; + } let before = self.selected_movable_frames(ci); if before.len() < 3 { return; @@ -246,6 +331,31 @@ impl PlotxApp { let Some(ci) = self.session.active_canvas else { return; }; + let panel_targets: Vec<_> = self + .session + .ui + .hierarchical_selection + .paths() + .iter() + .filter(|path| path.content.is_none()) + .filter_map(|path| path.panel) + .collect(); + if !panel_targets.is_empty() { + let before = PanelState::of(&self.doc.canvases[ci]); + let mut page = self.doc.canvases[ci].clone(); + let order: Vec<_> = page.panels.iter().map(|panel| panel.id).collect(); + let reordered = crate::actions::reorder_z(&order, &panel_targets, op); + page.panels + .sort_by_key(|panel| reordered.iter().position(|id| *id == panel.id)); + reorder_panel_content_blocks(&mut page, &panel_targets, op); + self.execute_action(Action::ReplacePanelState { + canvas: ci, + before, + after: PanelState::of(&page), + }); + self.session.status = "Reordered panels.".to_owned(); + return; + } let targets: Vec = self.session.ui.selection.objects().to_vec(); self.apply_z_order(ci, &targets, op); } @@ -311,6 +421,56 @@ impl PlotxApp { } } +/// A Panel is one stacking unit even though its children are stored in the +/// canvas-wide content collection. Reordering only `page.panels` changes tree +/// order but not paint order, so derive page-level blocks and move every child +/// in the selected Panel together. Loose content retains its own layer slot. +fn reorder_panel_content_blocks( + page: &mut crate::state::CanvasDocument, + panels: &[PanelId], + op: crate::actions::ZOrder, +) { + let mut blocks: Vec> = Vec::new(); + for object in &page.objects { + let parent = page.parent_panel(object.id); + if let Some(panel) = parent + && let Some(block) = blocks.iter_mut().find(|block| { + block + .first() + .is_some_and(|first| page.parent_panel(*first) == Some(panel)) + }) + { + block.push(object.id); + } else { + blocks.push(vec![object.id]); + } + } + let order: Vec<_> = (0..blocks.len()).collect(); + let targets: Vec<_> = order + .iter() + .copied() + .filter(|index| { + blocks[*index] + .first() + .and_then(|id| page.parent_panel(*id)) + .is_some_and(|panel| panels.contains(&panel)) + }) + .collect(); + let reordered = crate::actions::reorder_z(&order, &targets, op); + let object_order: Vec<_> = reordered + .into_iter() + .flat_map(|index| blocks[index].iter().copied()) + .collect(); + page.objects + .sort_by_key(|object| object_order.iter().position(|id| *id == object.id)); +} + +#[derive(Clone, Copy)] +enum PageSibling { + Panel(PanelId), + Content(ContentId), +} + fn layout_items( canvas: &crate::state::CanvasDocument, ids: &[ObjectId], diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index a811f75..cb7df6e 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -1,11 +1,11 @@ use crate::layout::PageLayout; use crate::state::{ - AxisOverrides, AxisProjections, CanvasDocument, CanvasObject, CanvasViewport, ChartSpec, - CurveFitReference, DataBinding, Dataset, DatasetId, ExtractedIonChromatogram, - ExtractedMassSpectrum, ExtractionId, IonChromatogramId, LayoutGroup, NamedView, ObjectFrame, - ObjectId, ObjectStyle, Panel, PanelLabelStyle, PanelMeta, PlotxApp, PrimaryView, Region, - Selection, StackSpec, StatAnalysis, StoredCurveFitAnalysis, StoredLineFit, StoredMultiplet, - TableEditDelta, TextBox, TypedTableState, + AssetId, AssetRecord, AxisOverrides, AxisProjections, CanvasDocument, CanvasObject, + CanvasViewport, ChartSpec, CurveFitReference, DataBinding, Dataset, DatasetId, + ExtractedIonChromatogram, ExtractedMassSpectrum, ExtractionId, IonChromatogramId, LayoutGroup, + NamedView, ObjectFrame, ObjectId, ObjectStyle, Panel, PanelLabelStyle, PanelMeta, PlotxApp, + PrimaryView, Region, Selection, StackSpec, StatAnalysis, StoredCurveFitAnalysis, StoredLineFit, + StoredMultiplet, TableEditDelta, TextBox, TypedTableState, }; use crate::theme::ThemeSnapshot; use crate::{Integral2D, IntegralResult}; @@ -196,6 +196,11 @@ impl PanelState { #[derive(Clone)] pub enum Action { Composite(Vec), + SetAsset { + id: AssetId, + before: Option, + after: Option, + }, ReplacePanelState { canvas: usize, before: PanelState, @@ -490,6 +495,12 @@ pub enum Action { before: TextBox, after: TextBox, }, + SetRasterImage { + canvas: usize, + object: ObjectId, + before: crate::state::RasterImageContent, + after: crate::state::RasterImageContent, + }, /// Set the visual style of one or more objects at once (inspector edits, /// format-once). One entry per object; kinds that don't match are ignored. SetObjectStyle { @@ -597,6 +608,7 @@ impl Action { fn is_noop(&self) -> bool { match self { Self::Composite(actions) => actions.iter().all(Self::is_noop), + Self::SetAsset { .. } => false, Self::ReplacePanelState { .. } => false, Self::UpdateDatasetProcessing { before, after, .. } => before == after, Self::SetObjectViewport { before, after, .. } => before == after, @@ -648,6 +660,7 @@ impl Action { Self::SetMultiplets { before, after, .. } => before == after, Self::SetTableStatistics { before, after, .. } => before == after, Self::SetObjectText { before, after, .. } => before == after, + Self::SetRasterImage { before, after, .. } => before == after, Self::SetObjectStyle { before, after, .. } => before == after, Self::ApplyTheme { before, after, .. } => before == after, Self::SetFigureTypography { before, after } => before == after, diff --git a/crates/core/src/actions/panel.rs b/crates/core/src/actions/panel.rs index 92e1017..13da389 100644 --- a/crates/core/src/actions/panel.rs +++ b/crates/core/src/actions/panel.rs @@ -1,6 +1,7 @@ use super::{Action, PanelState}; use crate::state::{ - ContentId, GroupMember, LayoutGroup, ObjectFrame, Panel, PanelId, PanelLabelMode, PlotxApp, + ContentId, GroupMember, LayoutGroup, ObjectFrame, Panel, PanelAlignment, PanelId, + PanelLabelMode, PanelLayout, PlotxApp, }; #[derive(Debug, thiserror::Error, PartialEq, Eq)] @@ -197,6 +198,68 @@ impl PlotxApp { .ok_or(PanelActionError::MissingPanel(target))? .item_order; order.insert(target_index.min(order.len()), content); + apply_panel_layout(&mut page, target)?; + } + page.reconcile_content_group_scopes(); + page.validate_structure() + .map_err(PanelActionError::Invalid)?; + Ok(Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }) + } + + pub fn move_contents_to_panel_action( + &self, + canvas: usize, + contents: &[ContentId], + target: Option, + target_index: usize, + ) -> Result { + if contents.is_empty() { + return Err(PanelActionError::EmptySelection); + } + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + let target_frame = target + .map(|id| { + page.panel(id) + .map(|panel| panel.frame) + .ok_or(PanelActionError::MissingPanel(id)) + }) + .transpose()?; + let placements: Vec<_> = contents + .iter() + .map(|content| { + page.content_page_frame(*content) + .map(|frame| (*content, frame)) + .ok_or(PanelActionError::MissingContent(*content)) + }) + .collect::>()?; + for panel in &mut page.panels { + panel.item_order.retain(|id| !contents.contains(id)); + } + for &(content, page_frame) in &placements { + let item = page + .object_mut(content) + .ok_or(PanelActionError::MissingContent(content))?; + item.frame = target_frame.map_or(page_frame, |panel| ObjectFrame { + x: page_frame.x - panel.x, + y: page_frame.y - panel.y, + ..page_frame + }); + } + if let Some(target) = target { + let order = &mut page + .panel_mut(target) + .ok_or(PanelActionError::MissingPanel(target))? + .item_order; + let index = target_index.min(order.len()); + for (offset, content) in contents.iter().enumerate() { + order.insert(index + offset, *content); + } + apply_panel_layout(&mut page, target)?; } page.reconcile_content_group_scopes(); page.validate_structure() @@ -455,6 +518,41 @@ impl PlotxApp { }) } + pub fn set_panel_layout_action( + &self, + canvas: usize, + panel_id: PanelId, + layout: PanelLayout, + gap: f32, + padding: f32, + alignment: PanelAlignment, + ) -> Result { + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + page.panel(panel_id) + .ok_or(PanelActionError::MissingPanel(panel_id))?; + if !gap.is_finite() || gap < 0.0 || !padding.is_finite() || padding < 0.0 { + return Err(PanelActionError::Invalid( + "layout gap and padding must be finite and non-negative".to_owned(), + )); + } + let target = page + .panel_mut(panel_id) + .ok_or(PanelActionError::MissingPanel(panel_id))?; + target.layout = layout; + target.layout_gap = gap; + target.layout_padding = padding; + target.layout_alignment = alignment; + apply_panel_layout(&mut page, panel_id)?; + page.validate_structure() + .map_err(PanelActionError::Invalid)?; + Ok(Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }) + } + fn panel_state(&self, canvas: usize) -> Result { self.doc .canvases @@ -464,6 +562,112 @@ impl PlotxApp { } } +/// Reflow one Panel from its persisted layout settings. Structural changes such +/// as reparenting call this as well as the property editor, so Stack and Grid +/// have one deterministic layout rule irrespective of how content arrived. +fn apply_panel_layout( + page: &mut crate::state::CanvasDocument, + panel_id: PanelId, +) -> Result<(), PanelActionError> { + let panel = page + .panel(panel_id) + .cloned() + .ok_or(PanelActionError::MissingPanel(panel_id))?; + let frames = layout_frames( + page, + &panel, + panel.layout, + panel.layout_gap, + panel.layout_padding, + panel.layout_alignment, + )?; + for (content, frame) in panel.item_order.iter().copied().zip(frames) { + page.object_mut(content) + .ok_or(PanelActionError::MissingContent(content))? + .frame = frame; + } + Ok(()) +} + +fn layout_frames( + page: &crate::state::CanvasDocument, + panel: &Panel, + layout: PanelLayout, + gap: f32, + padding: f32, + alignment: PanelAlignment, +) -> Result, PanelActionError> { + if layout == PanelLayout::Free || panel.item_order.is_empty() { + return panel + .item_order + .iter() + .map(|id| { + page.object(*id) + .map(|item| item.frame) + .ok_or(PanelActionError::MissingContent(*id)) + }) + .collect(); + } + let count = panel.item_order.len(); + let (rows, cols) = match layout { + PanelLayout::VerticalStack => (count, 1), + PanelLayout::HorizontalStack => (1, count), + PanelLayout::Grid { rows, cols } => (rows as usize, cols as usize), + PanelLayout::Free => unreachable!(), + }; + if rows == 0 || cols == 0 || rows.saturating_mul(cols) < count { + return Err(PanelActionError::Invalid( + "panel grid must contain every content item".to_owned(), + )); + } + let inner_width = panel.frame.width - padding * 2.0 - gap * (cols.saturating_sub(1) as f32); + let inner_height = panel.frame.height - padding * 2.0 - gap * (rows.saturating_sub(1) as f32); + if inner_width <= 0.0 || inner_height <= 0.0 { + return Err(PanelActionError::Invalid( + "panel is too small for its layout padding and gap".to_owned(), + )); + } + let cell = [inner_width / cols as f32, inner_height / rows as f32]; + panel + .item_order + .iter() + .enumerate() + .map(|(index, id)| { + let item = page + .object(*id) + .ok_or(PanelActionError::MissingContent(*id))?; + let row = index / cols; + let col = index % cols; + let origin = [ + padding + col as f32 * (cell[0] + gap), + padding + row as f32 * (cell[1] + gap), + ]; + let (x, width) = aligned_axis(origin[0], cell[0], item.frame.width, alignment); + let (y, height) = aligned_axis(origin[1], cell[1], item.frame.height, alignment); + Ok(ObjectFrame::new(x, y, width, height)) + }) + .collect() +} + +fn aligned_axis( + origin: f32, + available: f32, + preferred: f32, + alignment: PanelAlignment, +) -> (f32, f32) { + if alignment == PanelAlignment::Stretch { + return (origin, available); + } + let size = preferred.min(available); + let offset = match alignment { + PanelAlignment::Start => 0.0, + PanelAlignment::Center => (available - size) * 0.5, + PanelAlignment::End => available - size, + PanelAlignment::Stretch => unreachable!(), + }; + (origin + offset, size) +} + fn bounds(frames: impl IntoIterator) -> Option { let mut frames = frames.into_iter(); let first = frames.next()?; diff --git a/crates/core/src/actions/tests/interaction.rs b/crates/core/src/actions/tests/interaction.rs index 87752d6..6241f07 100644 --- a/crates/core/src/actions/tests/interaction.rs +++ b/crates/core/src/actions/tests/interaction.rs @@ -25,17 +25,18 @@ fn delete_canvas_resets_in_flight_interaction() { #[test] fn gesture_active_covers_only_the_board_freezing_drags() { use crate::state::{ - AuthorDrag, FrameDrag, Interaction, MarqueeDrag, ObjectDrag, ObjectDragKind, ObjectFrame, - PanDrag, PanelLabelDrag, PhaseAxis, PhaseDrag, PhaseDragKind, RegionDrag, RegionDragKind, - SelectionDrag, ZoomAxis, ZoomDrag, + AuthorDrag, FrameDrag, Interaction, MarqueeDrag, ObjectDrag, ObjectDragKind, + ObjectDragSpace, ObjectFrame, PanDrag, PanelDrag, PanelLabelDrag, PhaseAxis, PhaseDrag, + PhaseDragKind, RegionDrag, RegionDragKind, SelectionDrag, ZoomAxis, ZoomDrag, }; let mut app = sample_app(); let object = app.doc.canvases[0].objects[0].id; let frame = ObjectFrame::new(0.0, 0.0, 10.0, 10.0); let viewport = first_plot(&app).viewport.clone(); let title = crate::state::PanelMeta::new("title".to_owned(), frame.width); + let panel = crate::state::PanelId::new(); - let cases: [(Interaction, bool); 11] = [ + let cases: [(Interaction, bool); 12] = [ (Interaction::Idle, false), ( Interaction::Object(ObjectDrag { @@ -47,6 +48,21 @@ fn gesture_active_covers_only_the_board_freezing_drags() { start_pointer_screen: [0.0, 0.0], others: Vec::new(), active: true, + space: ObjectDragSpace::Page, + }), + true, + ), + ( + Interaction::Panel(PanelDrag { + canvas: 0, + panel, + kind: ObjectDragKind::Move, + before: frame, + others: Vec::new(), + children: Vec::new(), + start_pointer: [0.0, 0.0], + start_pointer_screen: [0.0, 0.0], + active: true, }), true, ), diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index c060a08..e121859 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -13,6 +13,7 @@ mod interaction; mod linefit; mod more; mod multiplet; +mod pr2; mod scheme_apply; mod stable_identity; mod stack; diff --git a/crates/core/src/actions/tests/panel.rs b/crates/core/src/actions/tests/panel.rs index 9c81334..18f94af 100644 --- a/crates/core/src/actions/tests/panel.rs +++ b/crates/core/src/actions/tests/panel.rs @@ -196,3 +196,202 @@ fn merging_grouped_panels_rewrites_and_dissolves_groups() { assert!(members.contains(&GroupMember::Panel(ids[0]))); assert!(members.contains(&GroupMember::Panel(ids[2]))); } + +#[test] +fn raster_edit_round_trips_through_undo_and_redo() { + let (mut app, ids) = app(); + let asset = crate::state::AssetId::new(); + let bytes = b"raster-edit-fixture".to_vec(); + app.doc.assets.insert( + asset, + crate::state::AssetRecord { + id: asset, + sha256: plotx_io::image::sha256(&bytes), + format: "png".to_owned(), + pixel_size: [2, 2], + bytes, + }, + ); + let before = crate::state::RasterImageContent::new(asset); + app.doc.canvases[0].object_mut(ids[0]).unwrap().kind = ContentKind::RasterImage(before.clone()); + let mut after = before.clone(); + after.crop = [0.1, 0.2, 0.9, 0.8]; + after.rotation = crate::state::QuarterTurn::Clockwise90; + after.opacity = 0.4; + after.interpolation = crate::state::ImageInterpolation::Nearest; + app.try_execute_action(crate::actions::Action::SetRasterImage { + canvas: 0, + object: ids[0], + before: before.clone(), + after: after.clone(), + }) + .unwrap(); + let image = |app: &PlotxApp| match &app.doc.canvases[0].object(ids[0]).unwrap().kind { + ContentKind::RasterImage(image) => image.clone(), + _ => panic!("expected raster image"), + }; + assert_eq!(image(&app), after); + app.undo(); + assert_eq!(image(&app), before); + app.redo(); + assert_eq!(image(&app), after); +} + +#[test] +fn raster_edit_rejects_invalid_values_and_wrong_content_kind() { + let (mut app, ids) = app(); + let asset = crate::state::AssetId::new(); + let bytes = b"raster-validation-fixture".to_vec(); + app.doc.assets.insert( + asset, + crate::state::AssetRecord { + id: asset, + sha256: plotx_io::image::sha256(&bytes), + format: "png".to_owned(), + pixel_size: [2, 2], + bytes, + }, + ); + let before = crate::state::RasterImageContent::new(asset); + let mut invalid = before.clone(); + invalid.opacity = f32::NAN; + assert!( + app.try_execute_action(crate::actions::Action::SetRasterImage { + canvas: 0, + object: ids[0], + before: before.clone(), + after: invalid, + }) + .is_err() + ); + + app.doc.canvases[0].object_mut(ids[0]).unwrap().kind = ContentKind::RasterImage(before.clone()); + let mut invalid_crop = before.clone(); + invalid_crop.crop = [0.8, 0.0, 0.2, 1.0]; + assert!( + app.try_execute_action(crate::actions::Action::SetRasterImage { + canvas: 0, + object: ids[0], + before, + after: invalid_crop, + }) + .is_err() + ); +} + +#[test] +fn deterministic_panel_layout_round_trips_frames_and_parameters() { + let (mut app, ids) = app(); + let (panel, action) = app + .compose_panel_action(0, "stack".to_owned(), &ids[..2], 2.0) + .unwrap(); + app.try_execute_action(action).unwrap(); + let action = app + .set_panel_layout_action( + 0, + panel, + crate::state::PanelLayout::VerticalStack, + 3.0, + 2.0, + crate::state::PanelAlignment::Stretch, + ) + .unwrap(); + cycle(&mut app, action); + let page = &app.doc.canvases[0]; + let panel = page.panel(panel).unwrap(); + assert_eq!(panel.layout_gap, 3.0); + assert_eq!(panel.layout_padding, 2.0); + let first = page.object(ids[0]).unwrap().frame; + let second = page.object(ids[1]).unwrap().frame; + assert_eq!(first.x, 2.0); + assert_eq!(second.x, 2.0); + assert!(second.y > first.y + first.height); +} + +#[test] +fn moving_one_content_into_a_stacked_panel_reflows_and_round_trips() { + let (mut app, ids) = app(); + let (panel, action) = app + .compose_panel_action(0, "stack".to_owned(), &ids[..2], 2.0) + .unwrap(); + app.try_execute_action(action).unwrap(); + let action = app + .set_panel_layout_action( + 0, + panel, + crate::state::PanelLayout::VerticalStack, + 3.0, + 2.0, + crate::state::PanelAlignment::Stretch, + ) + .unwrap(); + app.try_execute_action(action).unwrap(); + + let before = signature(&app); + let action = app + .move_content_to_panel_action(0, ids[2], Some(panel), usize::MAX) + .unwrap(); + app.try_execute_action(action).unwrap(); + + let page = &app.doc.canvases[0]; + let panel_state = page.panel(panel).unwrap(); + assert_eq!(panel_state.item_order, ids); + let frames: Vec<_> = ids + .iter() + .map(|id| page.object(*id).unwrap().frame) + .collect(); + assert!( + frames + .windows(2) + .all(|pair| pair[1].y > pair[0].y + pair[0].height) + ); + + app.undo(); + assert_eq!(signature(&app), before); + app.redo(); + assert_eq!(app.doc.canvases[0].panel(panel).unwrap().item_order, ids); +} + +#[test] +fn panel_z_order_moves_its_content_block_and_round_trips() { + let (mut app, ids) = app(); + app.session.active_canvas = Some(0); + let (first, action) = app + .compose_panel_action(0, "first".to_owned(), &ids[..1], 2.0) + .unwrap(); + app.try_execute_action(action).unwrap(); + let (_second, action) = app + .compose_panel_action(0, "second".to_owned(), &ids[1..2], 2.0) + .unwrap(); + app.try_execute_action(action).unwrap(); + let before = signature(&app); + + app.select_panel(0, first); + app.z_order_selected(crate::actions::ZOrder::Front); + let object_order: Vec<_> = app.doc.canvases[0] + .objects + .iter() + .map(|object| object.id) + .collect(); + assert!( + object_order.iter().position(|id| *id == ids[0]).unwrap() + > object_order.iter().position(|id| *id == ids[1]).unwrap() + ); + assert_eq!(app.doc.canvases[0].panels.last().unwrap().id, first); + + app.undo(); + assert_eq!(signature(&app), before); + app.redo(); + assert!( + app.doc.canvases[0] + .objects + .iter() + .position(|object| object.id == ids[0]) + .unwrap() + > app.doc.canvases[0] + .objects + .iter() + .position(|object| object.id == ids[1]) + .unwrap() + ); +} diff --git a/crates/core/src/actions/tests/pr2.rs b/crates/core/src/actions/tests/pr2.rs new file mode 100644 index 0000000..83aa373 --- /dev/null +++ b/crates/core/src/actions/tests/pr2.rs @@ -0,0 +1,63 @@ +use super::*; + +#[test] +fn page_scope_aligns_panel_and_loose_content_as_siblings_with_undo_redo() { + let mut app = sample_app(); + let loose = app.doc.canvases[0].objects[0].id; + app.doc.canvases[0].objects[0].frame = ObjectFrame::new(35.0, 12.0, 20.0, 10.0); + let panel = app.doc.canvases[0].create_panel( + "Panel a".to_owned(), + ObjectFrame::new(5.0, 30.0, 40.0, 25.0), + ); + let canvas = app.doc.canvases[0].resource_id; + app.set_hierarchical_paths( + 0, + &[ + crate::state::SelectionPath::panel(canvas, panel), + crate::state::SelectionPath::content(canvas, None, loose), + ], + false, + ) + .unwrap(); + let original = ( + app.doc.canvases[0].panel(panel).unwrap().frame, + app.doc.canvases[0].object(loose).unwrap().frame, + ); + app.align_selected(crate::layout::Align::Left); + assert_eq!(app.doc.canvases[0].panel(panel).unwrap().frame.x, 5.0); + assert_eq!(app.doc.canvases[0].object(loose).unwrap().frame.x, 5.0); + let aligned = ( + app.doc.canvases[0].panel(panel).unwrap().frame, + app.doc.canvases[0].object(loose).unwrap().frame, + ); + app.undo(); + assert_eq!(app.doc.canvases[0].panel(panel).unwrap().frame, original.0); + assert_eq!(app.doc.canvases[0].object(loose).unwrap().frame, original.1); + app.redo(); + assert_eq!(app.doc.canvases[0].panel(panel).unwrap().frame, aligned.0); + assert_eq!(app.doc.canvases[0].object(loose).unwrap().frame, aligned.1); +} + +#[test] +fn imported_asset_action_applies_undoes_and_redoes() { + let mut app = sample_app(); + let id = crate::state::AssetId::new(); + let bytes = b"embedded image bytes".to_vec(); + let asset = crate::state::AssetRecord { + id, + sha256: plotx_io::image::sha256(&bytes), + format: "png".to_owned(), + pixel_size: [2, 3], + bytes, + }; + app.execute_action(Action::SetAsset { + id, + before: None, + after: Some(asset), + }); + assert!(app.doc.assets.contains_key(&id)); + app.undo(); + assert!(!app.doc.assets.contains_key(&id)); + app.redo(); + assert!(app.doc.assets.contains_key(&id)); +} diff --git a/crates/core/src/actions/tests/tiling.rs b/crates/core/src/actions/tests/tiling.rs index 7872aac..511a5a6 100644 --- a/crates/core/src/actions/tests/tiling.rs +++ b/crates/core/src/actions/tests/tiling.rs @@ -2,9 +2,55 @@ use crate::actions::Action; use crate::actions::tests::{push_canvas, sample_app}; use crate::layout::compute_tiling_plan; use crate::state::{ - AxisOverrides, AxisRange, ObjectFrame, ObjectId, TileDropCacheKey, TileDropPreview, + AssetId, AxisOverrides, AxisRange, ContentKind, ObjectFrame, ObjectId, RasterImageContent, + TileDropCacheKey, TileDropPreview, }; +#[test] +fn tile_drop_wraps_loose_raster_images_and_round_trips() { + let mut app = sample_app(); + push_canvas(&mut app, 0, "target", [120.0, 80.0]); + let newcomer = app.doc.canvases[0].objects[0].id; + let existing = app.doc.canvases[1].objects[0].id; + app.doc.canvases[0].objects[0].kind = + ContentKind::RasterImage(RasterImageContent::new(AssetId::new())); + app.doc.canvases[1].objects[0].kind = + ContentKind::RasterImage(RasterImageContent::new(AssetId::new())); + let page = app.doc.canvases[1].size_pt(); + let layout = app.doc.canvases[1].layout; + let plan = compute_tiling_plan(page, &layout, &[existing], [page[0] * 0.9, page[1] * 0.5]); + let action = + Action::tile_drop(&app, 0, newcomer, 1, plan.newcomer, plan.existing, false).unwrap(); + app.execute_action(action); + assert!(app.doc.canvases[0].objects.is_empty()); + assert_eq!(app.doc.canvases[1].objects.len(), 2); + let moved = app.doc.canvases[1].objects.last().unwrap().id; + assert!(app.doc.canvases[1].parent_panel(moved).is_some()); + assert_eq!( + app.doc.canvases[1].panel_letter(existing).as_deref(), + Some("a") + ); + assert_eq!( + app.doc.canvases[1].panel_letter(moved).as_deref(), + Some("b") + ); + assert!(app.doc.canvases[1].objects[0].frame.width < page[0]); + let existing_frame = app.doc.canvases[1].object(existing).unwrap().frame; + let moved_frame = app.doc.canvases[1].object(moved).unwrap().frame; + assert_eq!(existing_frame.x, 0.0); + assert_eq!(moved_frame.x, 0.0); + let existing_page_frame = app.doc.canvases[1].content_page_frame(existing).unwrap(); + let moved_page_frame = app.doc.canvases[1].content_page_frame(moved).unwrap(); + assert!(moved_page_frame.x > existing_page_frame.x); + assert!(moved_page_frame.width < page[0]); + app.undo(); + assert_eq!(app.doc.canvases[0].objects.len(), 1); + assert_eq!(app.doc.canvases[1].objects.len(), 1); + app.redo(); + assert!(app.doc.canvases[0].objects.is_empty()); + assert_eq!(app.doc.canvases[1].objects.len(), 2); +} + /// A drop of canvas 0's plot onto canvas 1 (which already has one plot) transfers /// ownership and reframes both into a two-way split, undoably. #[test] @@ -107,6 +153,41 @@ fn tile_drop_transfers_reframes_and_round_trips() { assert_eq!(app.doc.canvases[1].next_panel_label_slot, 2); } +#[test] +fn tile_drop_renumbers_auto_labels_in_final_reading_order() { + let mut app = sample_app(); + push_canvas(&mut app, 0, "target", [120.0, 80.0]); + let newcomer = app.doc.canvases[0].objects[0].id; + let existing = app.doc.canvases[1].objects[0].id; + app.doc.canvases[0].create_panel_for_plot(newcomer).unwrap(); + app.doc.canvases[1].create_panel_for_plot(existing).unwrap(); + let page = app.doc.canvases[1].size_pt(); + let plan = compute_tiling_plan( + page, + &app.doc.canvases[1].layout, + &[existing], + [page[0] * 0.1, page[1] * 0.5], + ); + let action = Action::tile_drop(&app, 0, newcomer, 1, plan.newcomer, plan.existing, false) + .expect("left tile action"); + app.execute_action(action); + let moved = app.doc.canvases[1].objects.last().unwrap().id; + assert_eq!( + app.doc.canvases[1].panel_letter(moved).as_deref(), + Some("a") + ); + assert_eq!( + app.doc.canvases[1].panel_letter(existing).as_deref(), + Some("b") + ); + app.undo(); + app.redo(); + assert_eq!( + app.doc.canvases[1].panel_letter(moved).as_deref(), + Some("a") + ); +} + fn assert_empty_source_removal_round_trip(from: usize, to: usize) { let mut app = sample_app(); push_canvas(&mut app, 0, "second", [120.0, 80.0]); @@ -179,6 +260,7 @@ fn cancelling_interaction_clears_tile_preview_cache() { target: 1, newcomer: ObjectFrame::new(0.0, 0.0, 50.0, 80.0), existing: Vec::new(), + source_frame: ObjectFrame::new(0.0, 0.0, 50.0, 80.0), pointer_screen: [0.0; 2], anchor: [0.5; 2], }); diff --git a/crates/core/src/actions/transfer.rs b/crates/core/src/actions/transfer.rs index e2acf4a..2f0757b 100644 --- a/crates/core/src/actions/transfer.rs +++ b/crates/core/src/actions/transfer.rs @@ -139,9 +139,21 @@ impl Action { } target_page.panels.push(panel); } - for object in &inserted { - if object.plot().is_some() && target_page.parent_panel(object.id).is_none() { - target_page.create_panel_for_plot(object.id)?; + let panel_capable: Vec<_> = target_page + .objects + .iter() + .filter(|object| { + matches!( + object.kind, + crate::state::CanvasObjectKind::Plot(_) + | crate::state::CanvasObjectKind::RasterImage(_) + ) + }) + .map(|object| object.id) + .collect(); + for id in panel_capable { + if target_page.parent_panel(id).is_none() { + target_page.create_panel_for_content(id)?; } } for object in &mut inserted { @@ -202,7 +214,7 @@ impl Action { source_label_slot_before, source_label_slot_after, target_label_slot_before, - target_label_slot_after, + mut target_label_slot_after, .. } = Action::transfer_objects(app, from, &[object], to, true)? else { @@ -211,10 +223,12 @@ impl Action { inserted.first_mut()?.frame = crate::state::ObjectFrame::new(0.0, 0.0, newcomer_frame.width, newcomer_frame.height); let newcomer_id = inserted.first()?.id; - let newcomer_panel = target_panels_after + if let Some(newcomer_panel) = target_panels_after .iter_mut() - .find(|panel| panel.item_order.contains(&newcomer_id))?; - newcomer_panel.frame = newcomer_frame; + .find(|panel| panel.item_order.contains(&newcomer_id)) + { + newcomer_panel.frame = newcomer_frame; + } for &(content, frame) in &existing_after { if let Some(panel) = target_panels_after .iter_mut() @@ -223,6 +237,22 @@ impl Action { panel.frame = frame; } } + let mut ordered_page = app.doc.canvases.get(to)?.clone(); + ordered_page.panels.clone_from(&target_panels_after); + let order = ordered_page.panel_reading_order(); + let mut slot = 0_u64; + for id in order { + let panel = target_panels_after + .iter_mut() + .find(|panel| panel.id == id)?; + if panel.label.participates_in_sequence { + if matches!(panel.label.mode, PanelLabelMode::Auto { .. }) { + panel.label.mode = PanelLabelMode::Auto { slot }; + } + slot = slot.saturating_add(1); + } + } + target_label_slot_after = target_label_slot_after.max(slot); let src = app.doc.canvases.get(from)?; let dst = app.doc.canvases.get(to)?; let existing_before = existing_after diff --git a/crates/core/src/actions/zorder.rs b/crates/core/src/actions/zorder.rs index 24b9e65..2f73033 100644 --- a/crates/core/src/actions/zorder.rs +++ b/crates/core/src/actions/zorder.rs @@ -1,5 +1,3 @@ -use crate::state::ObjectId; - /// The active-canvas index after deleting `deleted` from `len` canvases: `None` /// when the last page goes, else the same slot clamped to the new final index. pub fn active_canvas_after_delete(len: usize, deleted: usize) -> Option { @@ -23,16 +21,16 @@ pub enum ZOrder { /// Compute a new full id order after applying `op` to the `targets` within /// `order`. Targets keep their relative order; `Forward`/`Backward` step one /// slot past the nearest non-target neighbour. -pub fn reorder_z(order: &[ObjectId], targets: &[ObjectId], op: ZOrder) -> Vec { - let is_target = |id: &ObjectId| targets.contains(id); +pub fn reorder_z(order: &[T], targets: &[T], op: ZOrder) -> Vec { + let is_target = |id: &T| targets.contains(id); match op { ZOrder::Front => { - let mut v: Vec = order.iter().copied().filter(|id| !is_target(id)).collect(); + let mut v: Vec = order.iter().copied().filter(|id| !is_target(id)).collect(); v.extend(order.iter().copied().filter(|id| is_target(id))); v } ZOrder::Back => { - let mut v: Vec = order.iter().copied().filter(is_target).collect(); + let mut v: Vec = order.iter().copied().filter(is_target).collect(); v.extend(order.iter().copied().filter(|id| !is_target(id))); v } diff --git a/crates/core/src/operation.rs b/crates/core/src/operation.rs index 6419a0a..7f4e448 100644 --- a/crates/core/src/operation.rs +++ b/crates/core/src/operation.rs @@ -53,6 +53,9 @@ pub enum DiagnosticCode { ClipboardImageSucceeded, ClipboardImageFailed, ClipboardFigurePartial, + ImageImportSucceeded, + ImageImportWarning, + ImageImportFailed, ProcessingSchemeLoadFailed, ProcessingSchemeApplySucceeded, ProcessingSchemeApplyFailed, @@ -87,6 +90,9 @@ impl DiagnosticCode { Self::ClipboardImageSucceeded => "clipboard.image.succeeded", Self::ClipboardImageFailed => "clipboard.image.failed", Self::ClipboardFigurePartial => "clipboard.figure.partial", + Self::ImageImportSucceeded => "image.import.succeeded", + Self::ImageImportWarning => "image.import.warning", + Self::ImageImportFailed => "image.import.failed", Self::ProcessingSchemeLoadFailed => "processing.scheme.load.failed", Self::ProcessingSchemeApplySucceeded => "processing.scheme.apply.succeeded", Self::ProcessingSchemeApplyFailed => "processing.scheme.apply.failed", @@ -181,6 +187,7 @@ pub enum OperationKind { DataExport, ClipboardImageCopy, ClipboardFigureCopy, + ImageImport, ProcessingSchemeLoadAndApply, ProcessingSchemeSave, } @@ -196,6 +203,7 @@ impl OperationKind { Self::DataExport => "data.export", Self::ClipboardImageCopy => "clipboard.image.copy", Self::ClipboardFigureCopy => "clipboard.figure.copy", + Self::ImageImport => "image.import", Self::ProcessingSchemeLoadAndApply => "processing.scheme.load_and_apply", Self::ProcessingSchemeSave => "processing.scheme.save", } diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index f9af956..c9de537 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -241,6 +241,7 @@ pub fn canvas_to_view( CanvasObjectKind::RasterImage(image) => Ok(ViewCanvasObject { image: Some(RasterImageDto { asset: image.asset.to_string(), + page_index: image.page_index, crop: image.crop, fit: match image.fit { ImageFit::Contain => "contain", @@ -373,6 +374,7 @@ pub fn view_to_canvas( asset: image.asset.parse::().map_err(|_| { ProjectError::Invalid(format!("invalid asset id {}", image.asset)) })?, + page_index: image.page_index, crop: image.crop, fit: match image.fit.as_str() { "contain" => ImageFit::Contain, diff --git a/crates/core/src/project/convert_views_panel.rs b/crates/core/src/project/convert_views_panel.rs index 303e309..cb6cb43 100644 --- a/crates/core/src/project/convert_views_panel.rs +++ b/crates/core/src/project/convert_views_panel.rs @@ -1,4 +1,5 @@ use super::*; +use crate::state::PanelAlignment; pub(super) fn panel_to_dto(panel: &Panel) -> ViewPanel { let (participates, position, font_size) = ( @@ -45,6 +46,15 @@ pub(super) fn panel_to_dto(panel: &Panel) -> ViewPanel { PanelLayout::HorizontalStack => PanelLayoutDto::HorizontalStack, PanelLayout::Grid { rows, cols } => PanelLayoutDto::Grid { rows, cols }, }, + layout_gap: panel.layout_gap, + layout_padding: panel.layout_padding, + layout_alignment: match panel.layout_alignment { + PanelAlignment::Stretch => "stretch", + PanelAlignment::Start => "start", + PanelAlignment::Center => "center", + PanelAlignment::End => "end", + } + .to_owned(), } } @@ -140,6 +150,19 @@ pub(super) fn panel_from_dto(dto: &ViewPanel) -> Result { PanelLayoutDto::HorizontalStack => PanelLayout::HorizontalStack, PanelLayoutDto::Grid { rows, cols } => PanelLayout::Grid { rows, cols }, }, + layout_gap: dto.layout_gap, + layout_padding: dto.layout_padding, + layout_alignment: match dto.layout_alignment.as_str() { + "stretch" => PanelAlignment::Stretch, + "start" => PanelAlignment::Start, + "center" => PanelAlignment::Center, + "end" => PanelAlignment::End, + value => { + return Err(ProjectError::Invalid(format!( + "unknown panel layout alignment {value}" + ))); + } + }, }) } diff --git a/crates/core/src/project/dto_panel.rs b/crates/core/src/project/dto_panel.rs index 8e04165..8ad941d 100644 --- a/crates/core/src/project/dto_panel.rs +++ b/crates/core/src/project/dto_panel.rs @@ -22,6 +22,7 @@ pub enum ParentPanelDto { #[serde(deny_unknown_fields)] pub struct RasterImageDto { pub asset: String, + pub page_index: u32, pub crop: [f32; 4], pub fit: String, pub rotation: u16, @@ -43,6 +44,9 @@ pub struct ViewPanel { pub locked: bool, pub clip_children: bool, pub layout: PanelLayoutDto, + pub layout_gap: f32, + pub layout_padding: f32, + pub layout_alignment: String, } #[derive(Serialize, Deserialize, Clone)] diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 7416182..c10b972 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -617,6 +617,29 @@ fn validate_resource_ids(doc: &crate::state::Document) -> Result<()> { } for canvas in &doc.canvases { canvas.validate_structure().map_err(ProjectError::Invalid)?; + for item in &canvas.objects { + let crate::state::CanvasObjectKind::RasterImage(image) = &item.kind else { + continue; + }; + let asset = doc.assets.get(&image.asset).ok_or_else(|| { + ProjectError::Invalid(format!("missing referenced asset {}", image.asset)) + })?; + if image.page_index > 0 && asset.format != "tiff" { + return Err(ProjectError::Invalid(format!( + "image {} references page {} of non-TIFF asset {}", + item.id, image.page_index, asset.id + ))); + } + if image.page_index > 0 + && plotx_io::image::tiff_page_count(&asset.bytes) + .is_none_or(|pages| image.page_index >= pages) + { + return Err(ProjectError::Invalid(format!( + "image {} references missing page {} of asset {}", + item.id, image.page_index, asset.id + ))); + } + } } Ok(()) } diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 753b32c..ab7dc3b 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -12,8 +12,6 @@ type PlotRebuild = ( use crate::operation::OperationHistory; use plotx_processing::{ProjectionMode, SliceKind}; -/// Qualitative, colourblind-safe overlay palette (Okabe–Ito derived). Index 0 is -/// the default single-trace blue so a promoted primary keeps its colour. pub const OVERLAY_PALETTE: [Color; 8] = [ Color::rgb(0x1f, 0x6f, 0xeb), Color::rgb(0xd5, 0x5e, 0x00), @@ -72,8 +70,6 @@ impl PlotxApp { files }, ui: UiState { - // `ilt_params` deliberately starts empty rather than mirroring - // the preference here: a copy taken at construction would not // follow later preference edits, and resolving at build time // is what keeps the default reachable as a lifecycle stage. ..Default::default() @@ -322,23 +318,22 @@ impl PlotxApp { /// Select a whole object in page space. Clicking a grouped member selects the /// whole group, with the clicked object primary. pub fn select_object(&mut self, ci: usize, id: ObjectId) { + let Some(canvas) = self.doc.canvases.get(ci) else { + return; + }; + let canvas_id = canvas.resource_id; + let parent = canvas.parent_panel(id); self.session.ui.panel_label_selection = None; self.session.ui.selection = Selection::Objects(self.group_click_members(ci, id)); + self.session + .ui + .hierarchical_selection + .replace(SelectionPath::content(canvas_id, parent, id)); if let Some(c) = self.doc.canvases.get_mut(ci) { c.selected_object = Some(id); } } - /// Navigate to a page. - /// - /// Switching pages is never only a change of what is drawn. `ObjectId`s are - /// allocated per page and start again at one on each, so a selection left - /// over from the page being left resolves, on the page being entered, to an - /// unrelated object — and every inspector row, property write and tool that - /// follows would act on it. Activation therefore re-derives the selection - /// and the data focus from the page it enters, and every entry point that - /// switches pages comes through here rather than assigning `active_canvas` - /// and leaving the rest of the session pointing at the page it left. pub fn activate_canvas(&mut self, ci: usize) { let Some(canvas) = self.doc.canvases.get(ci) else { return; @@ -352,13 +347,6 @@ impl PlotxApp { self.reset_interaction(); } - /// Navigate to one object: activate its page, select it, and follow the - /// datasets it draws. - /// - /// This is [`Self::activate_canvas`]'s counterpart for a destination that - /// names an object, and it exists for the same reason: selecting an object - /// without pointing the data focus at what it draws leaves the two halves of - /// the session describing different things. pub fn reveal_object(&mut self, ci: usize, id: ObjectId) { if self .doc @@ -375,8 +363,6 @@ impl PlotxApp { self.focus_object_datasets(ci, id); } - /// Point the data focus at the datasets one object draws, with the object's - /// own active dataset leading. pub fn focus_object_datasets(&mut self, ci: usize, id: ObjectId) { let Some(object) = self .doc @@ -400,8 +386,7 @@ impl PlotxApp { } } - /// The group members of `id` with `id` moved to the front (the primary). - fn group_click_members(&self, ci: usize, id: ObjectId) -> Vec { + pub(super) fn group_click_members(&self, ci: usize, id: ObjectId) -> Vec { let Some(c) = self.doc.canvases.get(ci) else { return vec![id]; }; @@ -412,8 +397,6 @@ impl PlotxApp { members } - /// Shift+click: toggle an object (and its group) in or out of the current - /// page-space multi-selection. pub fn toggle_object_selection(&mut self, ci: usize, id: ObjectId) { let group = self.group_click_members(ci, id); let mut current: Vec = self.session.ui.selection.objects().to_vec(); @@ -433,8 +416,6 @@ impl PlotxApp { } } - /// Replace (or extend, when `additive`) the page-space selection with a set of - /// objects, expanding each to its full group. Used by the marquee. pub fn set_page_selection(&mut self, ci: usize, ids: &[ObjectId], additive: bool) { let mut out: Vec = if additive { self.session.ui.selection.objects().to_vec() @@ -457,7 +438,6 @@ impl PlotxApp { } } - /// Ctrl+A: select every visible object on the active canvas. pub fn select_all_objects(&mut self) { let Some(ci) = self.session.active_canvas else { return; @@ -481,7 +461,6 @@ impl PlotxApp { self.set_selection(Selection::Objects(ids)); } - /// Sub-select a plot's panel letter (its own page-space selection scope). pub fn select_panel_label(&mut self, ci: usize, id: ObjectId) { self.session.ui.selection = Selection::None; self.session.ui.panel_label_selection = Some((ci, id)); @@ -490,13 +469,10 @@ impl PlotxApp { } } - /// The active-canvas panel-letter sub-selection, as `(canvas, object)`. pub fn panel_label_selection(&self) -> Option<(usize, ObjectId)> { self.session.ui.panel_label_selection } - /// Set the unified selection and mirror the active canvas's page-space object - /// identity, which drives active-plot resolution and serialization. pub fn set_selection(&mut self, selection: Selection) { let primary = selection.object(); self.session.ui.panel_label_selection = None; @@ -505,13 +481,15 @@ impl PlotxApp { && let Some(c) = self.doc.canvases.get_mut(ci) { c.selected_object = primary; + if let Some(id) = primary { + let path = SelectionPath::content(c.resource_id, c.parent_panel(id), id); + self.session.ui.hierarchical_selection.replace(path); + } else { + self.session.ui.hierarchical_selection.clear(); + } } } - /// Re-derive the selection from a newly active canvas's persisted object - /// identity (the panel-letter sub-selection is transient and resets on a canvas - /// switch). If the canvas has a plot but none is selected, arm its first plot - /// so the Browse-default viewing tools have a target out of the box. pub fn sync_selection_to_active_canvas(&mut self) { self.session.ui.panel_label_selection = None; self.session.ui.selection = self @@ -521,6 +499,20 @@ impl PlotxApp { .and_then(|c| c.selected_object) .map(Selection::single) .unwrap_or(Selection::None); + self.session.ui.hierarchical_selection.clear(); + if let Some(ci) = self.session.active_canvas + && let Some(canvas) = self.doc.canvases.get(ci) + && let Some(id) = canvas.selected_object + { + self.session + .ui + .hierarchical_selection + .replace(SelectionPath::content( + canvas.resource_id, + canvas.parent_panel(id), + id, + )); + } if let Some(ci) = self.session.active_canvas && let Some(c) = self.doc.canvases.get(ci) && c.selected_plot_object_id().is_none() diff --git a/crates/core/src/state/app_impl_interaction.rs b/crates/core/src/state/app_impl_interaction.rs index f3f123c..bd0434d 100644 --- a/crates/core/src/state/app_impl_interaction.rs +++ b/crates/core/src/state/app_impl_interaction.rs @@ -17,6 +17,8 @@ impl PlotxApp { pub fn reset_interaction(&mut self) { self.session.ui.interaction = Interaction::Idle; self.session.ui.tile_drop = None; + self.session.ui.panel_drop_target = None; + self.session.ui.layers_drag_content = None; self.session.ui.snap_guides.clear(); } @@ -90,14 +92,49 @@ impl PlotxApp { } } Interaction::Object(drag) => { - self.set_object_frame(drag.canvas, drag.object, drag.before); + if matches!(drag.space, ObjectDragSpace::Panel(_)) { + if let Some(page) = self.doc.canvases.get_mut(drag.canvas) + && let Some(object) = page.object_mut(drag.object) + { + object.frame = drag.before; + } + } else { + self.set_object_frame(drag.canvas, drag.object, drag.before); + } for (id, frame) in drag.others { - self.set_object_frame(drag.canvas, id, frame); + if matches!(drag.space, ObjectDragSpace::Panel(_)) { + if let Some(page) = self.doc.canvases.get_mut(drag.canvas) + && let Some(object) = page.object_mut(id) + { + object.frame = frame; + } + } else { + self.set_object_frame(drag.canvas, id, frame); + } + } + } + Interaction::Panel(drag) => { + if let Some(page) = self.doc.canvases.get_mut(drag.canvas) { + if let Some(panel) = page.panel_mut(drag.panel) { + panel.frame = drag.before; + } + for (id, frame) in drag.others { + if let Some(panel) = page.panel_mut(id) { + panel.frame = frame; + } + } + for (id, frame) in drag.children { + if let Some(object) = page.object_mut(id) { + object.frame = frame; + } + } } } _ => {} } self.session.ui.tile_drop = None; + self.session.ui.panel_drop_target = None; + self.session.ui.layers_drag_content = None; self.session.ui.snap_guides.clear(); } } diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index 2b786d6..5cca0bc 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -372,6 +372,27 @@ impl PlotxApp { self.record_export_unavailable(format); return; } + if self.doc.canvases[ci] + .objects + .iter() + .any(|item| matches!(item.kind, crate::state::CanvasObjectKind::RasterImage(_))) + { + let operation_id = self.session.begin_operation(); + self.session.record_operation(OperationReport::<()>::failure( + operation_id, + OperationKind::Export, + "Export stopped because this figure contains external images.", + Diagnostic::new( + Severity::Error, + DiagnosticCode::ExportUnavailable, + "This export path does not yet support external images in the figure. Remove the images or keep the project as a .plotx file until image export is available.", + ) + .with_source("core.export.precheck") + .with_context("format", format.label()) + .with_context("reason", "external_images_unsupported"), + )); + return; + } let mut state = ExportDialogState::from_defaults(format, &self.settings.export); let canvas = &self.doc.canvases[ci]; if let Some(preset) = crate::export::ExportPreset::matching_canvas( @@ -391,6 +412,35 @@ impl PlotxApp { .record_operation(export_unavailable_report(operation_id, settings.format)); return; } + let export_pages = crate::export::resolve_page_scope( + settings.scope, + self.session.active_canvas, + self.doc.canvases.len(), + ); + if export_pages.as_ref().is_ok_and(|pages| { + pages.iter().any(|&page| { + self.doc.canvases[page] + .objects + .iter() + .any(|item| matches!(item.kind, crate::state::CanvasObjectKind::RasterImage(_))) + }) + }) { + self.session + .record_operation(OperationReport::<()>::failure( + operation_id, + OperationKind::Export, + "Export stopped because the selected pages contain external images.", + Diagnostic::new( + Severity::Error, + DiagnosticCode::ExportUnavailable, + "This export path does not yet support external images in the figure.", + ) + .with_source("core.export.precheck") + .with_context("format", settings.format.label()) + .with_context("reason", "external_images_unsupported"), + )); + return; + } match crate::export::export_canvases( &self.doc.canvases, self.session.active_canvas, @@ -599,6 +649,36 @@ mod export_operation_tests { Some("invalid_page_range") ); } + + #[test] + fn raster_precheck_only_scans_pages_in_the_requested_scope() { + let mut app = PlotxApp::new_with_settings(crate::settings::Settings::default()); + app.doc.canvases.push(CanvasDocument::new( + "clean".to_owned(), + DEFAULT_CANVAS_SIZE_MM, + )); + let mut raster_page = CanvasDocument::new("raster".to_owned(), DEFAULT_CANVAS_SIZE_MM); + let id = raster_page.allocate_object_id(); + raster_page.objects.push(crate::state::CanvasObject { + id, + name: "image".to_owned(), + frame: crate::state::ObjectFrame::new(0.0, 0.0, 10.0, 10.0), + locked: false, + visible: true, + kind: crate::state::CanvasObjectKind::RasterImage( + crate::state::RasterImageContent::new(crate::state::AssetId::new()), + ), + }); + app.doc.canvases.push(raster_page); + app.session.active_canvas = Some(0); + + app.request_export(ExportFormat::Svg); + assert!(app.session.ui.export_options.is_some()); + app.session.ui.export_options = None; + app.session.active_canvas = Some(1); + app.request_export(ExportFormat::Svg); + assert!(app.session.ui.export_options.is_none()); + } } #[cfg(test)] diff --git a/crates/core/src/state/app_impl_selection.rs b/crates/core/src/state/app_impl_selection.rs new file mode 100644 index 0000000..63d06c6 --- /dev/null +++ b/crates/core/src/state/app_impl_selection.rs @@ -0,0 +1,157 @@ +use super::*; + +impl PlotxApp { + pub fn set_hierarchical_paths( + &mut self, + ci: usize, + paths: &[SelectionPath], + additive: bool, + ) -> Result<(), &'static str> { + let mut selection = if additive { + self.session.ui.hierarchical_selection.clone() + } else { + HierarchicalSelection::default() + }; + for path in paths { + selection.extend_sibling(*path)?; + } + self.session.ui.hierarchical_selection = selection; + let ids: Vec<_> = self + .session + .ui + .hierarchical_selection + .paths() + .iter() + .filter_map(|path| path.content) + .collect(); + self.session.ui.selection = if ids.is_empty() { + Selection::None + } else { + Selection::Objects(ids.clone()) + }; + if let Some(canvas) = self.doc.canvases.get_mut(ci) { + canvas.selected_object = ids.first().copied(); + } + Ok(()) + } + + pub fn select_panel(&mut self, ci: usize, panel: PanelId) { + let Some(canvas) = self.doc.canvases.get(ci) else { + return; + }; + if canvas.panel(panel).is_none() { + return; + } + self.session.ui.panel_label_selection = None; + self.session.ui.selection = Selection::None; + self.session + .ui + .hierarchical_selection + .replace(SelectionPath::panel(canvas.resource_id, panel)); + if let Some(canvas) = self.doc.canvases.get_mut(ci) { + canvas.selected_object = None; + } + } + + pub fn toggle_panel_sibling(&mut self, ci: usize, panel: PanelId) -> Result<(), &'static str> { + let Some(canvas) = self.doc.canvases.get(ci) else { + return Err("The target page no longer exists."); + }; + if canvas.panel(panel).is_none() { + return Err("The target panel no longer exists."); + } + self.session + .ui + .hierarchical_selection + .toggle_sibling(SelectionPath::panel(canvas.resource_id, panel))?; + self.session.ui.selection = Selection::None; + if let Some(canvas) = self.doc.canvases.get_mut(ci) { + canvas.selected_object = None; + } + Ok(()) + } + + pub fn select_content(&mut self, ci: usize, id: ContentId) { + let Some(canvas) = self.doc.canvases.get(ci) else { + return; + }; + if canvas.object(id).is_none() { + return; + } + let canvas_id = canvas.resource_id; + let parent = canvas.parent_panel(id); + self.session.ui.panel_label_selection = None; + self.session.ui.selection = Selection::Objects(self.group_click_members(ci, id)); + self.session + .ui + .hierarchical_selection + .replace(SelectionPath::content(canvas_id, parent, id)); + if let Some(canvas) = self.doc.canvases.get_mut(ci) { + canvas.selected_object = Some(id); + } + } + + pub fn toggle_content_sibling(&mut self, ci: usize, id: ContentId) -> Result<(), &'static str> { + let Some(canvas) = self.doc.canvases.get(ci) else { + return Err("The target page no longer exists."); + }; + let path = SelectionPath::content(canvas.resource_id, canvas.parent_panel(id), id); + self.session + .ui + .hierarchical_selection + .toggle_sibling(path)?; + let ids: Vec<_> = self + .session + .ui + .hierarchical_selection + .paths() + .iter() + .filter_map(|path| path.content) + .collect(); + self.session.ui.selection = if ids.is_empty() { + Selection::None + } else { + Selection::Objects(ids.clone()) + }; + if let Some(canvas) = self.doc.canvases.get_mut(ci) { + canvas.selected_object = ids.first().copied(); + } + Ok(()) + } + + pub fn enter_panel(&mut self, ci: usize, panel: PanelId) { + let Some(canvas) = self.doc.canvases.get(ci) else { + return; + }; + let Some(first) = canvas + .panel(panel) + .and_then(|panel| panel.item_order.first()) + .copied() + else { + self.select_panel(ci, panel); + return; + }; + self.session + .ui + .hierarchical_selection + .replace(SelectionPath::content( + canvas.resource_id, + Some(panel), + first, + )); + self.session.ui.selection = Selection::single(first); + if let Some(canvas) = self.doc.canvases.get_mut(ci) { + canvas.selected_object = Some(first); + } + } + + pub fn exit_panel_scope(&mut self) { + self.session.ui.hierarchical_selection.exit_scope(); + self.session.ui.selection = Selection::None; + if let Some(ci) = self.session.active_canvas + && let Some(canvas) = self.doc.canvases.get_mut(ci) + { + canvas.selected_object = None; + } + } +} diff --git a/crates/core/src/state/content.rs b/crates/core/src/state/content.rs index 3de5c93..31602d5 100644 --- a/crates/core/src/state/content.rs +++ b/crates/core/src/state/content.rs @@ -25,6 +25,7 @@ pub enum QuarterTurn { #[derive(Clone, Debug, PartialEq)] pub struct RasterImageContent { pub asset: AssetId, + pub page_index: u32, /// Normalized `[left, top, right, bottom]` source rectangle. pub crop: [f32; 4], pub fit: ImageFit, @@ -38,6 +39,7 @@ impl RasterImageContent { pub fn new(asset: AssetId) -> Self { Self { asset, + page_index: 0, crop: [0.0, 0.0, 1.0, 1.0], fit: ImageFit::Contain, rotation: QuarterTurn::Zero, @@ -243,7 +245,7 @@ pub fn document_items(canvas: &CanvasDocument) -> Vec GestureFamily::Idle, Interaction::Object(_) + | Interaction::Panel(_) | Interaction::Marquee(_) | Interaction::PanelLabel(_) | Interaction::Frame(_) @@ -90,6 +92,7 @@ impl Interaction { pub fn canvas(&self) -> Option { match self { Interaction::Object(d) => Some(d.canvas), + Interaction::Panel(d) => Some(d.canvas), Interaction::Marquee(d) => Some(d.canvas), Interaction::PanelLabel(d) => Some(d.canvas), Interaction::Author(d) => Some(d.canvas), diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 2ba7186..810d6e9 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -39,6 +39,7 @@ mod app_impl_linefit; mod app_impl_multiplet; mod app_impl_peaks; mod app_impl_project; +mod app_impl_selection; mod app_impl_slice; mod app_impl_statistics; #[cfg(test)] diff --git a/crates/core/src/state/panel.rs b/crates/core/src/state/panel.rs index c4baa79..3f9376d 100644 --- a/crates/core/src/state/panel.rs +++ b/crates/core/src/state/panel.rs @@ -58,6 +58,15 @@ pub enum PanelLayout { }, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PanelAlignment { + #[default] + Stretch, + Start, + Center, + End, +} + #[derive(Clone, Debug, PartialEq)] pub struct Panel { pub id: PanelId, @@ -70,6 +79,9 @@ pub struct Panel { pub locked: bool, pub clip_children: bool, pub layout: PanelLayout, + pub layout_gap: f32, + pub layout_padding: f32, + pub layout_alignment: PanelAlignment, } impl Panel { @@ -85,6 +97,9 @@ impl Panel { locked: false, clip_children: false, layout: PanelLayout::Free, + layout_gap: 6.0, + layout_padding: 6.0, + layout_alignment: PanelAlignment::Stretch, } } } @@ -138,6 +153,13 @@ pub fn validate_panel_structure( { return Err(format!("panel {} has an empty grid dimension", panel.id)); } + if !panel.layout_gap.is_finite() + || panel.layout_gap < 0.0 + || !panel.layout_padding.is_finite() + || panel.layout_padding < 0.0 + { + return Err(format!("panel {} has invalid layout spacing", panel.id)); + } } let mut group_ids = BTreeSet::new(); let mut grouped_members = BTreeMap::new(); @@ -233,6 +255,30 @@ pub fn validate_frame(frame: ObjectFrame, what: &str) -> Result<(), String> { } impl CanvasDocument { + /// Automatic labels distinguish multi-panel pages; explicit labels remain + /// visible even when the page has only one panel. + pub fn panel_label_is_displayed(&self, panel: PanelId) -> bool { + let Some(panel) = self.panel(panel) else { + return false; + }; + if !panel.visible || !panel.label.visible { + return false; + } + if !matches!(panel.label.mode, PanelLabelMode::Auto { .. }) { + return true; + } + self.panels + .iter() + .filter(|candidate| { + candidate.visible + && candidate.label.visible + && candidate.label.participates_in_sequence + }) + .take(2) + .count() + >= 2 + } + /// Page-space frame manipulated by the canvas authoring UI. Parented /// content is represented by its semantic panel; loose content uses its /// own frame. This is the single coordinate contract for selection, @@ -282,15 +328,20 @@ impl CanvasDocument { } } - /// Give a loose plot its default one-item semantic panel while preserving - /// its page position and existing display name as the panel note. - pub fn create_panel_for_plot(&mut self, content: ContentId) -> Option { + /// Give loose panel-capable content its default one-item semantic panel while + /// preserving its page position and existing display name as the panel note. + pub fn create_panel_for_content(&mut self, content: ContentId) -> Option { if let Some(panel) = self.parent_panel(content) { return Some(panel); } let (name, frame) = { let item = self.object(content)?; - item.plot()?; + if !matches!( + item.kind, + super::CanvasObjectKind::Plot(_) | super::CanvasObjectKind::RasterImage(_) + ) { + return None; + } (item.name.clone(), item.frame) }; let panel_id = self.create_panel(name.clone(), frame); @@ -303,6 +354,11 @@ impl CanvasDocument { panel.note = name; Some(panel_id) } + + pub fn create_panel_for_plot(&mut self, content: ContentId) -> Option { + self.object(content)?.plot()?; + self.create_panel_for_content(content) + } } #[cfg(test)] diff --git a/crates/core/src/state/panel_tests.rs b/crates/core/src/state/panel_tests.rs index a3db763..e10685b 100644 --- a/crates/core/src/state/panel_tests.rs +++ b/crates/core/src/state/panel_tests.rs @@ -109,8 +109,16 @@ fn panel_visibility_and_labels_are_independent_of_content_kind() { let items = crate::state::document_items(&page); assert!(matches!( items.last(), - Some(plotx_render::DocumentItem::PanelLabel { visible: true, .. }) + Some(plotx_render::DocumentItem::PanelLabel { visible: false, .. }) )); + + let second = page.create_panel("b".to_owned(), ObjectFrame::new(70.0, 30.0, 40.0, 40.0)); + page.panel_mut(second).unwrap().item_order.push(ids[1]); + let items = crate::state::document_items(&page); + assert!(items.iter().rev().take(2).all(|item| matches!( + item, + plotx_render::DocumentItem::PanelLabel { visible: true, .. } + ))); } #[test] @@ -136,3 +144,20 @@ fn layout_frame_keeps_panel_page_geometry_separate_from_local_content() { ObjectFrame::new(80.0, 60.0, 20.0, 15.0) ); } + +#[test] +fn panel_resize_scales_children_to_the_requested_frame() { + let (mut page, ids) = page(); + let panel = page.create_panel("a".to_owned(), ObjectFrame::new(10.0, 20.0, 40.0, 20.0)); + page.panel_mut(panel).unwrap().item_order.push(ids[0]); + page.object_mut(ids[0]).unwrap().frame = ObjectFrame::new(0.0, 0.0, 40.0, 20.0); + page.set_layout_frame(ids[0], ObjectFrame::new(0.0, 10.0, 50.0, 30.0)); + assert_eq!( + page.panel(panel).unwrap().frame, + ObjectFrame::new(0.0, 10.0, 50.0, 30.0) + ); + assert_eq!( + page.object(ids[0]).unwrap().frame, + ObjectFrame::new(0.0, 0.0, 50.0, 30.0) + ); +} diff --git a/crates/core/src/state/selection.rs b/crates/core/src/state/selection.rs index d1ff9d1..60c26a1 100644 --- a/crates/core/src/state/selection.rs +++ b/crates/core/src/state/selection.rs @@ -1,4 +1,167 @@ -use super::{BoardFrameId, CanvasId, DatasetId, ObjectId}; +use super::{BoardFrameId, CanvasId, ContentId, DatasetId, ObjectId, PanelId}; + +/// A stable hierarchical selection address. `content` is only valid in the +/// loose page scope when `panel` is `None`, or as a direct child of `panel`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SelectionPath { + pub canvas: CanvasId, + pub panel: Option, + pub content: Option, +} + +impl SelectionPath { + pub fn panel(canvas: CanvasId, panel: PanelId) -> Self { + Self { + canvas, + panel: Some(panel), + content: None, + } + } + + pub fn content(canvas: CanvasId, panel: Option, content: ContentId) -> Self { + Self { + canvas, + panel, + content: Some(content), + } + } + + pub fn sibling_scope(self) -> (CanvasId, Option) { + (self.canvas, self.content.and(self.panel)) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HierarchicalSelection { + paths: Vec, +} + +impl HierarchicalSelection { + pub fn paths(&self) -> &[SelectionPath] { + &self.paths + } + + pub fn replace(&mut self, path: SelectionPath) { + self.paths = vec![path]; + } + + pub fn clear(&mut self) { + self.paths.clear(); + } + + pub fn lead(&self) -> Option { + self.paths.first().copied() + } + + pub fn contains(&self, path: SelectionPath) -> bool { + self.paths.contains(&path) + } + + pub fn editing_panel(&self) -> Option<(CanvasId, PanelId)> { + self.lead().and_then(|path| { + path.content + .and(path.panel) + .map(|panel| (path.canvas, panel)) + }) + } + + pub fn extend_sibling(&mut self, path: SelectionPath) -> Result<(), &'static str> { + if self + .paths + .first() + .is_some_and(|lead| lead.sibling_scope() != path.sibling_scope()) + { + return Err("Select sibling items in the same page or panel scope."); + } + if !self.paths.contains(&path) { + self.paths.push(path); + } + Ok(()) + } + + pub fn exit_scope(&mut self) { + let Some(lead) = self.paths.first().copied() else { + return; + }; + self.paths = match (lead.panel, lead.content) { + (Some(panel), Some(_)) => vec![SelectionPath::panel(lead.canvas, panel)], + _ => Vec::new(), + }; + } + + pub fn toggle_sibling(&mut self, path: SelectionPath) -> Result<(), &'static str> { + if self + .paths + .first() + .is_some_and(|lead| lead.sibling_scope() != path.sibling_scope()) + { + return Err("Select sibling items in the same page or panel scope."); + } + if let Some(index) = self.paths.iter().position(|selected| *selected == path) { + self.paths.remove(index); + } else { + self.paths.push(path); + } + Ok(()) + } +} + +#[cfg(test)] +mod hierarchy_tests { + use super::*; + + #[test] + fn rejects_cross_scope_multi_selection_and_exits_to_panel() { + let canvas = CanvasId::new(); + let panel = PanelId::new(); + let mut selection = HierarchicalSelection::default(); + selection.replace(SelectionPath::content( + canvas, + Some(panel), + ContentId::new(1), + )); + assert!( + selection + .extend_sibling(SelectionPath::content(canvas, None, ContentId::new(2))) + .is_err() + ); + selection.exit_scope(); + assert_eq!(selection.paths(), &[SelectionPath::panel(canvas, panel)]); + } + + #[test] + fn panels_share_page_scope_but_contents_require_the_same_parent() { + let canvas = CanvasId::new(); + let first = PanelId::new(); + let second = PanelId::new(); + let mut selection = HierarchicalSelection::default(); + selection.replace(SelectionPath::panel(canvas, first)); + assert!( + selection + .extend_sibling(SelectionPath::panel(canvas, second)) + .is_ok() + ); + assert!( + selection + .extend_sibling(SelectionPath::content(canvas, None, ContentId::new(3))) + .is_ok() + ); + selection.replace(SelectionPath::content( + canvas, + Some(first), + ContentId::new(1), + )); + assert!( + selection + .extend_sibling(SelectionPath::content( + canvas, + Some(second), + ContentId::new(2), + )) + .is_err() + ); + } +} /// The current desktop selection context. Global selection commands dispatch /// through this scope instead of guessing from whichever collection is active. diff --git a/crates/core/src/state/tile_drop.rs b/crates/core/src/state/tile_drop.rs index bac9a2d..bc14370 100644 --- a/crates/core/src/state/tile_drop.rs +++ b/crates/core/src/state/tile_drop.rs @@ -7,6 +7,10 @@ pub struct TileDropPreview { pub target: usize, pub newcomer: ObjectFrame, pub existing: Vec<(ObjectId, ObjectFrame)>, + /// Screen-space ghost sizing is based on the page-space frame that was + /// dragged. This may be a Panel frame when its sole transferable content + /// is being tiled into another canvas. + pub source_frame: ObjectFrame, /// Current cursor in screen pixels and its clamped relative grab point in /// the source's pre-drag frame. These are independent of the target layout. pub pointer_screen: [f32; 2], @@ -14,10 +18,10 @@ pub struct TileDropPreview { } impl TileDropPreview { - pub fn ghost_frame(&self, before: ObjectFrame, zoom: f32) -> ObjectFrame { + pub fn ghost_frame(&self, zoom: f32) -> ObjectFrame { let zoom = if zoom.is_finite() { zoom.max(0.0) } else { 0.0 }; - let width = before.width.max(0.0) * zoom; - let height = before.height.max(0.0) * zoom; + let width = self.source_frame.width.max(0.0) * zoom; + let height = self.source_frame.height.max(0.0) * zoom; ObjectFrame::new( self.pointer_screen[0] - self.anchor[0].clamp(0.0, 1.0) * width, self.pointer_screen[1] - self.anchor[1].clamp(0.0, 1.0) * height, @@ -61,10 +65,11 @@ mod tests { target: 1, newcomer: ObjectFrame::new(0.0, 0.0, 5.0, 5.0), existing: vec![], + source_frame: ObjectFrame::new(2.0, 3.0, 40.0, 20.0), pointer_screen: [90.0, 70.0], anchor: [0.25, 2.0], }; - let ghost = preview.ghost_frame(ObjectFrame::new(2.0, 3.0, 40.0, 20.0), 2.0); + let ghost = preview.ghost_frame(2.0); assert_eq!(ghost, ObjectFrame::new(70.0, 30.0, 80.0, 40.0)); } } diff --git a/crates/core/src/state/ui_layout_drag.rs b/crates/core/src/state/ui_layout_drag.rs new file mode 100644 index 0000000..019ae62 --- /dev/null +++ b/crates/core/src/state/ui_layout_drag.rs @@ -0,0 +1,53 @@ +use super::*; + +#[derive(Clone, Debug)] +pub struct ObjectDrag { + pub canvas: usize, + pub object: ObjectId, + pub kind: ObjectDragKind, + pub before: ObjectFrame, + /// Pointer position in page space (pt) when the drag began, so the live + /// frame is recomputed absolutely each frame — a snap correction on one + /// frame is not re-perturbed by the next frame's incremental delta. + pub start_pointer: [f32; 2], + /// Pointer position in screen px when the drag began. The move dead-zone is + /// measured against screen-space pointer travel: intent to drag is about how + /// far the cursor moved, not how far the page moved under it, so a view + /// change can never trip a drag the user never made. + pub start_pointer_screen: [f32; 2], + /// Start frames of the other selected objects moving with the primary (group + /// move). Empty for a single-object drag; populated only for `Move`. + pub others: Vec<(ObjectId, ObjectFrame)>, + /// Whether the gesture has cleared the move dead-zone. A `Move` starts `false` + /// and only moves/commits once the pointer travels past the threshold, so a + /// click with a few px of jitter selects without nudging the frame. Resize + /// grabs are deliberate and start `true`. + pub active: bool, + /// Coordinate space in which the live gesture edits the content frame. + pub space: ObjectDragSpace, +} + +/// A content frame is either expressed in page coordinates or in the local +/// coordinate system of its owning Panel. Keeping this explicit prevents a +/// loose item from being mistaken for Panel-local content merely because both +/// cases used to be represented by `false`/`true` conditionals. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectDragSpace { + Page, + Panel(PanelId), +} + +/// A live drag of a whole Panel frame. Child frames are local snapshots so a +/// resize scales them with the Panel and cancellation restores the gesture. +#[derive(Clone, Debug)] +pub struct PanelDrag { + pub canvas: usize, + pub panel: PanelId, + pub kind: ObjectDragKind, + pub before: ObjectFrame, + pub others: Vec<(PanelId, ObjectFrame)>, + pub children: Vec<(ContentId, ObjectFrame)>, + pub start_pointer: [f32; 2], + pub start_pointer_screen: [f32; 2], + pub active: bool, +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 6880396..a4eeea9 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -12,6 +12,10 @@ pub use xps::{PropertyTextEditState, XpsWorkbenchTab}; mod task_dock; pub use task_dock::TaskDockTab; +#[path = "ui_layout_drag.rs"] +mod layout_drag; +pub use layout_drag::*; + mod trace_composer; pub use trace_composer::{TraceComposerItem, TraceComposerState}; mod trace_alignment; @@ -238,6 +242,14 @@ impl PropertyFocus { } } +#[derive(Clone, Debug)] +pub struct RasterProxy { + pub hash: [u8; 32], + pub page_index: u32, + pub pixel_size: [u32; 2], + pub rgba8: std::sync::Arc>, +} + pub struct UiState { /// The single in-flight direct-manipulation gesture; see [`Interaction`]. pub interaction: Interaction, @@ -297,11 +309,17 @@ pub struct UiState { pub trace_alignment_dialog: Option, pub trace_composer: Option, pub selection: Selection, + pub hierarchical_selection: HierarchicalSelection, + pub raster_proxies: Vec, pub selection_scope: SelectionScope, pub selection_anchors: SelectionAnchors, /// A panel-letter sub-selection (canvas index, object id): its own page-space /// selection scope, distinct from the whole-object `selection`. pub panel_label_selection: Option<(usize, ObjectId)>, + /// Panel currently highlighted as a same-page drop destination. + pub panel_drop_target: Option, + /// Content row being dragged in the Layers tree. + pub layers_drag_content: Option, /// Live auto-tiling preview while a single-plot move drag hovers a different /// canvas: the target's resulting layout, painted as ghost rects and committed /// on release. `None` when the drag is not over a tiling target. Derived from @@ -449,6 +467,7 @@ impl UiState { matches!( self.interaction, Interaction::Object(_) + | Interaction::Panel(_) | Interaction::Marquee(_) | Interaction::Selection(_) | Interaction::Zoom(_) @@ -504,9 +523,13 @@ impl Default for UiState { trace_alignment_dialog: None, trace_composer: None, selection: Selection::None, + hierarchical_selection: HierarchicalSelection::default(), + raster_proxies: Vec::new(), selection_scope: SelectionScope::default(), selection_anchors: SelectionAnchors::default(), panel_label_selection: None, + panel_drop_target: None, + layers_drag_content: None, tile_drop: None, frame_selection: Vec::new(), data_selection: Vec::new(), @@ -713,31 +736,6 @@ impl Session { } } -#[derive(Clone, Debug)] -pub struct ObjectDrag { - pub canvas: usize, - pub object: ObjectId, - pub kind: ObjectDragKind, - pub before: ObjectFrame, - /// Pointer position in page space (pt) when the drag began, so the live - /// frame is recomputed absolutely each frame — a snap correction on one - /// frame is not re-perturbed by the next frame's incremental delta. - pub start_pointer: [f32; 2], - /// Pointer position in screen px when the drag began. The move dead-zone is - /// measured against screen-space pointer travel: intent to drag is about how - /// far the cursor moved, not how far the page moved under it, so a view - /// change can never trip a drag the user never made. - pub start_pointer_screen: [f32; 2], - /// Start frames of the other selected objects moving with the primary (group - /// move). Empty for a single-object drag; populated only for `Move`. - pub others: Vec<(ObjectId, ObjectFrame)>, - /// Whether the gesture has cleared the move dead-zone. A `Move` starts `false` - /// and only moves/commits once the pointer travels past the threshold, so a - /// click with a few px of jitter selects without nudging the frame. Resize - /// grabs are deliberate and start `true`. - pub active: bool, -} - /// In-progress drag of a whole frame (page or sheet) across the board by its /// header strip. `before` is the frame's `board_pos` (pt) at grab time and /// `start_world` the board-world (pt) pointer position then, so the live position diff --git a/crates/core/src/workflow_tests.rs b/crates/core/src/workflow_tests.rs index 6d99fdd..d8686c9 100644 --- a/crates/core/src/workflow_tests.rs +++ b/crates/core/src/workflow_tests.rs @@ -58,7 +58,7 @@ fn canonical_conversion_and_default_canvas_share_dataset_identity() { assert!(crate::state::document_items(&canvas).iter().any(|item| { matches!( item, - plotx_render::DocumentItem::PanelLabel { visible: true, .. } + plotx_render::DocumentItem::PanelLabel { visible: false, .. } ) })); } diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index c7ccbc4..413cc45 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -21,3 +21,8 @@ rust_xlsxwriter.workspace = true quick-xml.workspace = true base64.workspace = true flate2.workspace = true +image.workspace = true +tiff.workspace = true +sha2.workspace = true +memmap2.workspace = true +tempfile.workspace = true diff --git a/crates/io/src/image.rs b/crates/io/src/image.rs new file mode 100644 index 0000000..1d30ada --- /dev/null +++ b/crates/io/src/image.rs @@ -0,0 +1,681 @@ +//! Header-first probing and bounded decoding for embedded raster assets. + +use image::{GenericImageView, ImageDecoder}; +use sha2::{Digest, Sha256}; +use std::io::Cursor; + +mod proxy; +pub use proxy::decode_proxy_rgba8; + +pub const SOFT_PIXEL_LIMIT: u64 = 100_000_000; +pub const HARD_PIXEL_LIMIT: u64 = 500_000_000; +pub const SOFT_DECODED_BYTES: u64 = 512 * 1024 * 1024; +pub const HARD_DECODED_BYTES: u64 = 2 * 1024 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RasterFormat { + Png, + Jpeg, + Tiff, + WebP, + Bmp, + Gif, + Svg, + Unknown, +} + +impl RasterFormat { + pub fn name(self) -> &'static str { + match self { + Self::Png => "PNG", + Self::Jpeg => "JPEG", + Self::Tiff => "TIFF", + Self::WebP => "WebP", + Self::Bmp => "BMP", + Self::Gif => "GIF", + Self::Svg => "SVG", + Self::Unknown => "unknown", + } + } + + pub fn supported(self) -> bool { + matches!( + self, + Self::Png | Self::Jpeg | Self::Tiff | Self::WebP | Self::Bmp + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResourceClass { + Normal, + ProxyRequired, + Rejected, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImageProbe { + pub format: RasterFormat, + pub width: u32, + pub height: u32, + pub decoded_bytes: u64, + pub class: ResourceClass, + pub animated: bool, + pub has_icc: bool, + pub has_exif: bool, + pub high_precision: bool, + pub pages: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DecodedImage { + pub probe: ImageProbe, + pub rgba8: Vec, + pub sha256: [u8; 32], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProxyImage { + pub pixel_size: [u32; 2], + pub rgba8: Vec, +} + +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// Reads physical pixel density without decoding pixels. Returns `None` when +/// the source carries no density, uses non-physical units, or is malformed. +pub fn metadata_dpi(bytes: &[u8]) -> Option<[f32; 2]> { + match sniff(bytes) { + RasterFormat::Png => png_dpi(bytes), + RasterFormat::Jpeg => jpeg_dpi(bytes), + RasterFormat::Bmp => bmp_dpi(bytes), + _ => None, + } +} + +fn png_dpi(bytes: &[u8]) -> Option<[f32; 2]> { + let mut offset = 8usize; + while offset.checked_add(12)? <= bytes.len() { + let length = u32::from_be_bytes(bytes.get(offset..offset + 4)?.try_into().ok()?) as usize; + let data = offset.checked_add(8)?; + let end = data.checked_add(length)?; + if end.checked_add(4)? > bytes.len() { + return None; + } + if bytes.get(offset + 4..offset + 8)? == b"pHYs" && length == 9 { + if bytes[end - 1] != 1 { + return None; + } + let x = u32::from_be_bytes(bytes.get(data..data + 4)?.try_into().ok()?); + let y = u32::from_be_bytes(bytes.get(data + 4..data + 8)?.try_into().ok()?); + return (x > 0 && y > 0).then_some([x as f32 * 0.0254, y as f32 * 0.0254]); + } + offset = end.checked_add(4)?; + } + None +} + +fn jpeg_dpi(bytes: &[u8]) -> Option<[f32; 2]> { + let marker = bytes.windows(5).position(|window| window == b"JFIF\0")?; + let units = *bytes.get(marker + 7)?; + let x = u16::from_be_bytes(bytes.get(marker + 8..marker + 10)?.try_into().ok()?) as f32; + let y = u16::from_be_bytes(bytes.get(marker + 10..marker + 12)?.try_into().ok()?) as f32; + match units { + 1 if x > 0.0 && y > 0.0 => Some([x, y]), + 2 if x > 0.0 && y > 0.0 => Some([x * 2.54, y * 2.54]), + _ => None, + } +} + +fn bmp_dpi(bytes: &[u8]) -> Option<[f32; 2]> { + let x = i32::from_le_bytes(bytes.get(38..42)?.try_into().ok()?); + let y = i32::from_le_bytes(bytes.get(42..46)?.try_into().ok()?); + (x > 0 && y > 0).then_some([x as f32 * 0.0254, y as f32 * 0.0254]) +} + +#[derive(Debug, thiserror::Error)] +pub enum ImageError { + #[error("detected {format}; supported image formats are PNG, JPEG, TIFF, WebP, and BMP")] + Unsupported { format: &'static str }, + #[error("animated {format} is not supported by this import path")] + Animated { format: &'static str }, + #[error("image dimensions could not be read: {0}")] + Probe(String), + #[error("image is too large ({width} x {height}, estimated {decoded_bytes} decoded bytes)")] + TooLarge { + width: u32, + height: u32, + decoded_bytes: u64, + }, + #[error("image decode failed: {0}")] + Decode(String), + #[error("decoded image size is inconsistent with its dimensions")] + InvalidDecodedSize, + #[error("image page {page} is unavailable or unsupported: {reason}")] + Page { page: u32, reason: String }, +} + +pub fn sniff(bytes: &[u8]) -> RasterFormat { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + RasterFormat::Png + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + RasterFormat::Jpeg + } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { + RasterFormat::Tiff + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + RasterFormat::WebP + } else if bytes.starts_with(b"BM") { + RasterFormat::Bmp + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + RasterFormat::Gif + } else if looks_like_svg(bytes) { + RasterFormat::Svg + } else { + RasterFormat::Unknown + } +} + +pub fn probe(bytes: &[u8]) -> Result { + let format = sniff(bytes); + if !format.supported() { + return Err(ImageError::Unsupported { + format: format.name(), + }); + } + let animated = animation_marker(format, bytes); + let reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| ImageError::Probe(error.to_string()))?; + let mut decoder = reader + .into_decoder() + .map_err(|error| ImageError::Probe(error.to_string()))?; + let (width, height) = decoder.dimensions(); + let color_type = decoder.color_type(); + let has_icc = decoder + .icc_profile() + .map_err(|error| ImageError::Probe(error.to_string()))? + .is_some(); + let has_exif = decoder + .exif_metadata() + .map_err(|error| ImageError::Probe(error.to_string()))? + .is_some(); + let pixels = u64::from(width) + .checked_mul(u64::from(height)) + .ok_or(ImageError::TooLarge { + width, + height, + decoded_bytes: u64::MAX, + })?; + let decoded_bytes = pixels.checked_mul(4).ok_or(ImageError::TooLarge { + width, + height, + decoded_bytes: u64::MAX, + })?; + let class = classify(pixels, decoded_bytes); + Ok(ImageProbe { + format, + width, + height, + decoded_bytes, + class, + animated, + has_icc, + has_exif, + high_precision: matches!( + color_type, + image::ColorType::L16 + | image::ColorType::La16 + | image::ColorType::Rgb16 + | image::ColorType::Rgba16 + | image::ColorType::Rgb32F + | image::ColorType::Rgba32F + ), + pages: if format == RasterFormat::Tiff { + tiff_page_count(bytes).unwrap_or(1) + } else { + 1 + }, + }) +} + +pub fn tiff_page_count(bytes: &[u8]) -> Option { + if sniff(bytes) != RasterFormat::Tiff { + return None; + } + let mut decoder = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?; + let mut pages = 1_u32; + while decoder.more_images() { + decoder.next_image().ok()?; + pages = pages.checked_add(1)?; + } + Some(pages) +} + +pub fn tiff_page_dimensions(bytes: &[u8], page_index: u32) -> Option<[u32; 2]> { + if sniff(bytes) != RasterFormat::Tiff { + return None; + } + let mut decoder = tiff::decoder::Decoder::new(Cursor::new(bytes)).ok()?; + for _ in 0..page_index { + decoder.next_image().ok()?; + } + let (width, height) = decoder.dimensions().ok()?; + Some([width, height]) +} + +fn classify(pixels: u64, decoded_bytes: u64) -> ResourceClass { + if pixels > HARD_PIXEL_LIMIT || decoded_bytes > HARD_DECODED_BYTES { + ResourceClass::Rejected + } else if pixels > SOFT_PIXEL_LIMIT || decoded_bytes > SOFT_DECODED_BYTES { + ResourceClass::ProxyRequired + } else { + ResourceClass::Normal + } +} + +pub fn decode_rgba8(bytes: &[u8], allow_first_frame: bool) -> Result { + decode_rgba8_page(bytes, 0, allow_first_frame) +} + +pub fn decode_rgba8_page( + bytes: &[u8], + page_index: u32, + allow_first_frame: bool, +) -> Result { + let probe = probe(bytes)?; + if page_index >= probe.pages || (probe.format != RasterFormat::Tiff && page_index != 0) { + return Err(ImageError::Page { + page: page_index, + reason: "page index is outside the source".to_owned(), + }); + } + if probe.animated && !allow_first_frame { + return Err(ImageError::Animated { + format: probe.format.name(), + }); + } + if probe.class == ResourceClass::Rejected { + return Err(ImageError::TooLarge { + width: probe.width, + height: probe.height, + decoded_bytes: probe.decoded_bytes, + }); + } + // Proxy-required inputs need a format-specific reduced decoder. Refuse a + // full allocation here so callers cannot accidentally defeat the limit. + if probe.class == ResourceClass::ProxyRequired { + return Err(ImageError::TooLarge { + width: probe.width, + height: probe.height, + decoded_bytes: probe.decoded_bytes, + }); + } + if probe.format == RasterFormat::Tiff && page_index != 0 { + return decode_tiff_page(bytes, page_index, probe); + } + let decoded = + image::load_from_memory(bytes).map_err(|error| ImageError::Decode(error.to_string()))?; + let (width, height) = decoded.dimensions(); + if width != probe.width || height != probe.height { + return Err(ImageError::InvalidDecodedSize); + } + let rgba8 = decoded.into_rgba8().into_raw(); + let expected = + usize::try_from(probe.decoded_bytes).map_err(|_| ImageError::InvalidDecodedSize)?; + if rgba8.len() != expected { + return Err(ImageError::InvalidDecodedSize); + } + let sha256 = sha256(bytes); + Ok(DecodedImage { + probe, + rgba8, + sha256, + }) +} + +fn decode_tiff_page( + bytes: &[u8], + page_index: u32, + mut probe: ImageProbe, +) -> Result { + use tiff::ColorType; + use tiff::decoder::DecodingResult; + let mut decoder = + tiff::decoder::Decoder::new(Cursor::new(bytes)).map_err(|error| ImageError::Page { + page: page_index, + reason: error.to_string(), + })?; + for _ in 0..page_index { + decoder.next_image().map_err(|error| ImageError::Page { + page: page_index, + reason: error.to_string(), + })?; + } + let (width, height) = decoder.dimensions().map_err(|error| ImageError::Page { + page: page_index, + reason: error.to_string(), + })?; + let pixels = u64::from(width) + .checked_mul(u64::from(height)) + .ok_or(ImageError::InvalidDecodedSize)?; + let decoded_bytes = pixels + .checked_mul(4) + .ok_or(ImageError::InvalidDecodedSize)?; + let class = classify(pixels, decoded_bytes); + if class != ResourceClass::Normal { + return Err(ImageError::TooLarge { + width, + height, + decoded_bytes, + }); + } + let color = decoder.colortype().map_err(|error| ImageError::Page { + page: page_index, + reason: error.to_string(), + })?; + let data = decoder.read_image().map_err(|error| ImageError::Page { + page: page_index, + reason: error.to_string(), + })?; + let rgba8 = match data { + DecodingResult::U8(values) => samples_to_rgba8(&values, color, |value| value)?, + DecodingResult::U16(values) => { + samples_to_rgba8(&values, color, |value| (value >> 8) as u8)? + } + _ => { + return Err(ImageError::Page { + page: page_index, + reason: "only unsigned 8-bit and 16-bit TIFF samples are supported".to_owned(), + }); + } + }; + probe.width = width; + probe.height = height; + probe.decoded_bytes = decoded_bytes; + probe.class = class; + probe.high_precision = matches!( + color, + ColorType::Gray(16) | ColorType::GrayA(16) | ColorType::RGB(16) | ColorType::RGBA(16) + ); + Ok(DecodedImage { + probe, + rgba8, + sha256: sha256(bytes), + }) +} + +fn samples_to_rgba8( + values: &[T], + color: tiff::ColorType, + convert: impl Fn(T) -> u8, +) -> Result, ImageError> { + let channels = match color { + tiff::ColorType::Gray(_) => 1, + tiff::ColorType::GrayA(_) => 2, + tiff::ColorType::RGB(_) => 3, + tiff::ColorType::RGBA(_) | tiff::ColorType::CMYK(_) => 4, + _ => { + return Err(ImageError::Decode(format!( + "unsupported TIFF color type {color:?}" + ))); + } + }; + if !values.len().is_multiple_of(channels) { + return Err(ImageError::InvalidDecodedSize); + } + let mut out = Vec::with_capacity(values.len() / channels * 4); + for sample in values.chunks_exact(channels) { + match color { + tiff::ColorType::Gray(_) => { + let value = convert(sample[0]); + out.extend_from_slice(&[value, value, value, 255]); + } + tiff::ColorType::GrayA(_) => { + let value = convert(sample[0]); + out.extend_from_slice(&[value, value, value, convert(sample[1])]); + } + tiff::ColorType::RGB(_) => out.extend_from_slice(&[ + convert(sample[0]), + convert(sample[1]), + convert(sample[2]), + 255, + ]), + tiff::ColorType::RGBA(_) => out.extend(sample.iter().map(|value| convert(*value))), + tiff::ColorType::CMYK(_) => { + let c = u16::from(convert(sample[0])); + let m = u16::from(convert(sample[1])); + let y = u16::from(convert(sample[2])); + let k = u16::from(convert(sample[3])); + out.extend_from_slice(&[ + 255_u16.saturating_sub((c + k).min(255)) as u8, + 255_u16.saturating_sub((m + k).min(255)) as u8, + 255_u16.saturating_sub((y + k).min(255)) as u8, + 255, + ]); + } + _ => unreachable!(), + } + } + Ok(out) +} + +pub fn strip_metadata_to_png(bytes: &[u8], allow_first_frame: bool) -> Result, ImageError> { + let decoded = decode_rgba8(bytes, allow_first_frame)?; + let image = + image::RgbaImage::from_raw(decoded.probe.width, decoded.probe.height, decoded.rgba8) + .ok_or(ImageError::InvalidDecodedSize)?; + let mut output = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut output, image::ImageFormat::Png) + .map_err(|error| ImageError::Decode(error.to_string()))?; + Ok(output.into_inner()) +} + +fn animation_marker(format: RasterFormat, bytes: &[u8]) -> bool { + match format { + RasterFormat::Png => bytes.windows(4).any(|window| window == b"acTL"), + RasterFormat::WebP => bytes + .windows(4) + .any(|window| window == b"ANIM" || window == b"ANMF"), + _ => false, + } +} + +fn looks_like_svg(bytes: &[u8]) -> bool { + let prefix = &bytes[..bytes.len().min(1024)]; + std::str::from_utf8(prefix) + .ok() + .map(str::trim_start) + .is_some_and(|text| { + text.starts_with(""), + RasterFormat::Svg + ); + } + + #[test] + fn detects_animation_markers() { + assert!(animation_marker(RasterFormat::Png, b"png acTL chunk")); + assert!(animation_marker(RasterFormat::WebP, b"webp ANMF frame")); + } + + #[test] + fn decodes_every_supported_first_release_format() { + let cases = [ + (image::ImageFormat::Png, RasterFormat::Png), + (image::ImageFormat::Jpeg, RasterFormat::Jpeg), + (image::ImageFormat::Tiff, RasterFormat::Tiff), + (image::ImageFormat::WebP, RasterFormat::WebP), + (image::ImageFormat::Bmp, RasterFormat::Bmp), + ]; + for (encoded, expected) in cases { + let source = + image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 128, 0, 255, 0, 255]).unwrap(); + let mut cursor = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut cursor, encoded) + .unwrap(); + let decoded = decode_rgba8(cursor.get_ref(), false).unwrap(); + assert_eq!(decoded.probe.format, expected); + assert_eq!([decoded.probe.width, decoded.probe.height], [2, 1]); + assert_eq!(decoded.rgba8.len(), 8); + } + } + + #[test] + fn preserves_alpha_and_expands_grayscale_to_rgba8() { + let alpha = image::RgbaImage::from_raw(1, 1, vec![10, 20, 30, 40]).unwrap(); + let gray = image::GrayImage::from_raw(1, 1, vec![73]).unwrap(); + let mut alpha_png = Cursor::new(Vec::new()); + let mut gray_png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(alpha) + .write_to(&mut alpha_png, image::ImageFormat::Png) + .unwrap(); + image::DynamicImage::ImageLuma8(gray) + .write_to(&mut gray_png, image::ImageFormat::Png) + .unwrap(); + assert_eq!( + decode_rgba8(alpha_png.get_ref(), false).unwrap().rgba8, + [10, 20, 30, 40] + ); + assert_eq!( + decode_rgba8(gray_png.get_ref(), false).unwrap().rgba8, + [73, 73, 73, 255] + ); + } + + #[test] + fn rejects_corrupt_and_truncated_supported_payloads() { + for bytes in [ + b"\x89PNG\r\n\x1a\ntruncated".as_slice(), + b"\xff\xd8\xfftruncated".as_slice(), + b"BMtruncated".as_slice(), + ] { + assert!(probe(bytes).is_err()); + } + } + + #[test] + fn extension_cannot_override_magic_bytes() { + let source = image::RgbaImage::from_pixel(1, 1, image::Rgba([1, 2, 3, 4])); + let mut bytes_named_jpeg = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut bytes_named_jpeg, image::ImageFormat::Png) + .unwrap(); + assert_eq!( + probe(bytes_named_jpeg.get_ref()).unwrap().format, + RasterFormat::Png + ); + } + + #[test] + fn soft_and_hard_limits_are_strict_at_the_documented_boundaries() { + assert_eq!(classify(SOFT_PIXEL_LIMIT, 4), ResourceClass::Normal); + assert_eq!( + classify(SOFT_PIXEL_LIMIT + 1, 4), + ResourceClass::ProxyRequired + ); + assert_eq!( + classify(1, SOFT_DECODED_BYTES + 1), + ResourceClass::ProxyRequired + ); + assert_eq!(classify(HARD_PIXEL_LIMIT + 1, 4), ResourceClass::Rejected); + assert_eq!(classify(1, HARD_DECODED_BYTES + 1), ResourceClass::Rejected); + } + + #[test] + fn proxy_decode_is_bounded_and_preserves_aspect() { + let source = image::RgbaImage::from_pixel(10, 5, image::Rgba([1, 2, 3, 4])); + let mut encoded = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, image::ImageFormat::Png) + .unwrap(); + let proxy = decode_proxy_rgba8(encoded.get_ref(), 4).unwrap(); + assert_eq!(proxy.pixel_size, [4, 2]); + assert_eq!(proxy.rgba8.len(), 32); + } + + #[test] + fn file_backed_proxy_handles_every_supported_format() { + for format in [ + image::ImageFormat::Png, + image::ImageFormat::Jpeg, + image::ImageFormat::Tiff, + image::ImageFormat::WebP, + image::ImageFormat::Bmp, + ] { + let source = + image::RgbaImage::from_fn(8, 4, |x, y| image::Rgba([x as u8, y as u8, 17, 200])); + let mut encoded = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, format) + .unwrap(); + let probe = probe(encoded.get_ref()).unwrap(); + let proxy = proxy::decode_file_backed_proxy(encoded.get_ref(), 4, probe).unwrap(); + assert_eq!(proxy.pixel_size, [4, 2], "{format:?}"); + assert_eq!(proxy.rgba8.len(), 32, "{format:?}"); + } + } + + #[test] + fn explicit_metadata_strip_reencodes_pixels_as_png() { + let source = image::RgbaImage::from_pixel(3, 2, image::Rgba([7, 8, 9, 10])); + let mut encoded = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, image::ImageFormat::Bmp) + .unwrap(); + let stripped = strip_metadata_to_png(encoded.get_ref(), false).unwrap(); + let result = decode_rgba8(&stripped, false).unwrap(); + assert_eq!(result.probe.format, RasterFormat::Png); + assert_eq!([result.probe.width, result.probe.height], [3, 2]); + assert!(!result.probe.has_icc); + assert!(!result.probe.has_exif); + assert_eq!(result.rgba8, [7, 8, 9, 10].repeat(6)); + } + + #[test] + fn reports_bmp_physical_density_without_decoding_pixels() { + let mut bytes = vec![0_u8; 54]; + bytes[..2].copy_from_slice(b"BM"); + bytes[38..42].copy_from_slice(&11_811_i32.to_le_bytes()); + bytes[42..46].copy_from_slice(&5_906_i32.to_le_bytes()); + let dpi = metadata_dpi(&bytes).unwrap(); + assert!((dpi[0] - 300.0).abs() < 0.1); + assert!((dpi[1] - 150.0).abs() < 0.1); + } + + #[test] + fn counts_and_decodes_each_multipage_tiff_page() { + use tiff::encoder::{TiffEncoder, colortype}; + let mut encoded = Cursor::new(Vec::new()); + { + let mut encoder = TiffEncoder::new(&mut encoded).unwrap(); + encoder + .write_image::(2, 1, &[10, 20]) + .unwrap(); + encoder + .write_image::(1, 2, &[1, 2, 3, 4, 5, 6]) + .unwrap(); + } + assert_eq!(tiff_page_count(encoded.get_ref()), Some(2)); + assert_eq!(probe(encoded.get_ref()).unwrap().pages, 2); + let second = decode_rgba8_page(encoded.get_ref(), 1, false).unwrap(); + assert_eq!([second.probe.width, second.probe.height], [1, 2]); + assert_eq!(second.rgba8, [1, 2, 3, 255, 4, 5, 6, 255]); + assert!(decode_rgba8_page(encoded.get_ref(), 2, false).is_err()); + } +} diff --git a/crates/io/src/image/proxy.rs b/crates/io/src/image/proxy.rs new file mode 100644 index 0000000..b52ca12 --- /dev/null +++ b/crates/io/src/image/proxy.rs @@ -0,0 +1,149 @@ +use super::{HARD_DECODED_BYTES, ImageError, ImageProbe, ProxyImage, ResourceClass, probe}; +use image::ImageDecoder; +use std::io::Cursor; + +pub fn decode_proxy_rgba8(bytes: &[u8], max_edge: u32) -> Result { + let probe = probe(bytes)?; + if probe.class == ResourceClass::Rejected { + return Err(ImageError::TooLarge { + width: probe.width, + height: probe.height, + decoded_bytes: probe.decoded_bytes, + }); + } + if probe.class == ResourceClass::ProxyRequired { + return decode_file_backed_proxy(bytes, max_edge, probe); + } + let decoded = + image::load_from_memory(bytes).map_err(|error| ImageError::Decode(error.to_string()))?; + let edge = max_edge.max(1); + let proxy = decoded.thumbnail(edge, edge).into_rgba8(); + let pixel_size = [proxy.width(), proxy.height()]; + let rgba8 = proxy.into_raw(); + let expected = usize::try_from(u64::from(pixel_size[0]) * u64::from(pixel_size[1]) * 4) + .map_err(|_| ImageError::InvalidDecodedSize)?; + if rgba8.len() != expected { + return Err(ImageError::InvalidDecodedSize); + } + Ok(ProxyImage { pixel_size, rgba8 }) +} + +pub(super) fn decode_file_backed_proxy( + bytes: &[u8], + max_edge: u32, + probe: ImageProbe, +) -> Result { + let reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| ImageError::Decode(error.to_string()))?; + let decoder = reader + .into_decoder() + .map_err(|error| ImageError::Decode(error.to_string()))?; + let color = decoder.color_type(); + let source_bytes = decoder.total_bytes(); + if source_bytes > HARD_DECODED_BYTES { + return Err(ImageError::TooLarge { + width: probe.width, + height: probe.height, + decoded_bytes: source_bytes, + }); + } + let mapped_len = usize::try_from(source_bytes).map_err(|_| ImageError::TooLarge { + width: probe.width, + height: probe.height, + decoded_bytes: source_bytes, + })?; + let file = tempfile::tempfile().map_err(|error| ImageError::Decode(error.to_string()))?; + file.set_len(source_bytes) + .map_err(|error| ImageError::Decode(error.to_string()))?; + // The mapping cannot outlive `file`, and its exact decoder-reported length + // has passed the project hard limit above. + let mut mapped = unsafe { + memmap2::MmapOptions::new() + .len(mapped_len) + .map_mut(&file) + .map_err(|error| ImageError::Decode(error.to_string()))? + }; + decoder + .read_image(&mut mapped) + .map_err(|error| ImageError::Decode(error.to_string()))?; + proxy_from_pixels(&mapped, [probe.width, probe.height], color, max_edge) +} + +fn proxy_from_pixels( + pixels: &[u8], + source: [u32; 2], + color: image::ColorType, + max_edge: u32, +) -> Result { + let edge = max_edge.max(1); + let scale = (edge as f64 / f64::from(source[0].max(source[1]))).min(1.0); + let width = (f64::from(source[0]) * scale).round().max(1.0) as u32; + let height = (f64::from(source[1]) * scale).round().max(1.0) as u32; + let bytes_per_pixel = usize::from(color.bytes_per_pixel()); + let expected = usize::try_from(u64::from(source[0]) * u64::from(source[1])) + .ok() + .and_then(|count| count.checked_mul(bytes_per_pixel)) + .ok_or(ImageError::InvalidDecodedSize)?; + if pixels.len() != expected { + return Err(ImageError::InvalidDecodedSize); + } + let output_len = usize::try_from(u64::from(width) * u64::from(height) * 4) + .map_err(|_| ImageError::InvalidDecodedSize)?; + let mut rgba8 = Vec::with_capacity(output_len); + for y in 0..height { + let source_y = (u64::from(y) * u64::from(source[1]) / u64::from(height)) as u32; + for x in 0..width { + let source_x = (u64::from(x) * u64::from(source[0]) / u64::from(width)) as u32; + let pixel_index = u64::from(source_y) * u64::from(source[0]) + u64::from(source_x); + let offset = usize::try_from(pixel_index * bytes_per_pixel as u64) + .map_err(|_| ImageError::InvalidDecodedSize)?; + rgba8.extend_from_slice(&pixel_to_rgba8( + &pixels[offset..offset + bytes_per_pixel], + color, + )?); + } + } + Ok(ProxyImage { + pixel_size: [width, height], + rgba8, + }) +} + +fn pixel_to_rgba8(pixel: &[u8], color: image::ColorType) -> Result<[u8; 4], ImageError> { + let u16_sample = + |offset: usize| u16::from_ne_bytes([pixel[offset], pixel[offset + 1]]).to_be_bytes()[0]; + let f32_sample = |offset: usize| { + let bytes = [ + pixel[offset], + pixel[offset + 1], + pixel[offset + 2], + pixel[offset + 3], + ]; + (f32::from_ne_bytes(bytes).clamp(0.0, 1.0) * 255.0).round() as u8 + }; + let rgba = match color { + image::ColorType::L8 => [pixel[0], pixel[0], pixel[0], 255], + image::ColorType::La8 => [pixel[0], pixel[0], pixel[0], pixel[1]], + image::ColorType::Rgb8 => [pixel[0], pixel[1], pixel[2], 255], + image::ColorType::Rgba8 => [pixel[0], pixel[1], pixel[2], pixel[3]], + image::ColorType::L16 => { + let value = u16_sample(0); + [value, value, value, 255] + } + image::ColorType::La16 => { + let value = u16_sample(0); + [value, value, value, u16_sample(2)] + } + image::ColorType::Rgb16 => [u16_sample(0), u16_sample(2), u16_sample(4), 255], + image::ColorType::Rgba16 => [u16_sample(0), u16_sample(2), u16_sample(4), u16_sample(6)], + image::ColorType::Rgb32F => [f32_sample(0), f32_sample(4), f32_sample(8), 255], + image::ColorType::Rgba32F => [f32_sample(0), f32_sample(4), f32_sample(8), f32_sample(12)], + _ => { + return Err(ImageError::Decode(format!( + "unsupported proxy color type {color:?}" + ))); + } + }; + Ok(rgba) +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a5e27e4..10d1ae7 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -699,3 +699,4 @@ pub fn load_path(path: impl AsRef) -> Result { DataFormat::CasaXpsText => xps::load_casaxps(path), } } +pub mod image; diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 618b54f..a2d3096 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -14,13 +14,14 @@ path = "src/lib.rs" default = ["screen"] # SVG export is always available. Gate egui screen rendering so headless and # export-only builds do not depend on egui; EMF export is available on Windows. -screen = ["dep:egui"] +screen = ["dep:egui", "dep:image"] emf = ["dep:windows-sys"] [dependencies] plotx-figure.workspace = true unicode-width.workspace = true egui = { workspace = true, optional = true } +image = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, optional = true } diff --git a/crates/render/src/emf.rs b/crates/render/src/emf.rs index ca12453..b8a14e0 100644 --- a/crates/render/src/emf.rs +++ b/crates/render/src/emf.rs @@ -98,6 +98,7 @@ pub fn export_document_emf(doc: &Document<'_>) -> Result, EmfError> { write_overlay(&mut dc, overlay); } } + DocumentItem::Raster(_) => {} DocumentItem::PanelLabel { frame, text, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d28e04a..f1af807 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -15,6 +15,8 @@ pub use ticks::{AxisLayout, AxisTicks, axis_layout, axis_ticks, axis_ticks_for, pub mod screen; #[cfg(feature = "screen")] mod screen_annotations; +#[cfg(feature = "screen")] +mod screen_raster; #[cfg(all(windows, feature = "emf"))] pub mod emf; @@ -60,6 +62,7 @@ pub struct Document<'a> { pub enum DocumentItem<'a> { Plot(DocumentObject<'a>), Overlay(DocumentOverlay<'a>), + Raster(DocumentRaster<'a>), PanelLabel { frame: Rect, text: DocumentText, @@ -67,6 +70,31 @@ pub enum DocumentItem<'a> { }, } +pub struct DocumentRaster<'a> { + pub source_hash: [u8; 32], + pub frame: Rect, + pub pixels: &'a [u8], + /// Pixel dimensions of `pixels`, which may describe an editor proxy. + pub pixel_size: [u32; 2], + /// Original asset dimensions used for fit, crop, and effective resolution. + pub source_pixel_size: [u32; 2], + pub crop: [f32; 4], + pub fit: RasterFit, + pub quarter_turns: u8, + pub opacity: f32, + pub nearest: bool, + /// Optional page-space bounds imposed by a containing layout panel. + pub clip: Option, + pub visible: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RasterFit { + Contain, + Cover, + Stretch, +} + pub struct DocumentObject<'a> { pub id: String, pub frame: Rect, diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index 0fc6318..0e54f7b 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -604,6 +604,9 @@ fn paint_document_impl( DocumentItem::Overlay(overlay) => { paint_document_overlay(&item_painter, page, overlay, viewport) } + DocumentItem::Raster(raster) => { + paint_document_raster(&item_painter, page, raster, viewport) + } DocumentItem::PanelLabel { frame, text, @@ -614,19 +617,19 @@ fn paint_document_impl( page.top + (frame.top + text.position[1]) * viewport.zoom, ); let font = FontId::proportional((text.font_size * viewport.zoom).max(6.0)); - item_painter.text( - pos, - Align2::LEFT_TOP, - &text.text, - font.clone(), - col(Color::BLACK), + let color = crate::screen_raster::contrasting_label_color( + &document.items, + frame, + text, + document.background, ); + item_painter.text(pos, Align2::LEFT_TOP, &text.text, font.clone(), col(color)); item_painter.text( pos + Vec2::new(0.6, 0.0), Align2::LEFT_TOP, &text.text, font, - col(Color::BLACK), + col(color), ); } DocumentItem::PanelLabel { .. } => {} @@ -634,6 +637,8 @@ fn paint_document_impl( } } +use crate::screen_raster::paint_document_raster; + fn paint_document_object( painter: &egui::Painter, page: Rect, diff --git a/crates/render/src/screen_raster.rs b/crates/render/src/screen_raster.rs new file mode 100644 index 0000000..6a9d936 --- /dev/null +++ b/crates/render/src/screen_raster.rs @@ -0,0 +1,359 @@ +use crate::{DocumentItem, DocumentRaster, DocumentText, DocumentViewport, RasterFit, Rect}; +use plotx_figure::Color; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct TextureKey { + hash: [u8; 32], + pixel_size: [u32; 2], + nearest: bool, +} + +#[derive(Clone)] +struct CachedTexture { + key: TextureKey, + texture: egui::TextureHandle, + bytes: usize, + last_used: u64, +} + +#[derive(Clone, Default)] +struct RasterTextureCache { + entries: Vec, + tick: u64, + bytes: usize, +} + +const TEXTURE_BUDGET: usize = 256 * 1024 * 1024; + +pub(crate) fn contrasting_label_color( + items: &[DocumentItem<'_>], + frame: &Rect, + text: &DocumentText, + background: Color, +) -> Color { + let point = [frame.left + text.position[0], frame.top + text.position[1]]; + let Some(raster) = items.iter().rev().find_map(|item| match item { + DocumentItem::Raster(raster) if raster.visible && point_in_rect(point, raster.frame) => { + Some(raster) + } + _ => None, + }) else { + return Color::BLACK; + }; + let page_frame = egui::Rect::from_min_size( + egui::pos2(raster.frame.left, raster.frame.top), + egui::vec2(raster.frame.width, raster.frame.height), + ); + let (display, crop) = fitted_geometry( + page_frame, + raster.source_pixel_size, + raster.crop, + raster.quarter_turns, + raster.fit, + ); + let point = egui::pos2(point[0], point[1]); + if !display.contains(point) { + return Color::BLACK; + } + let x = (point.x - display.left()) / display.width().max(f32::EPSILON); + let y = (point.y - display.top()) / display.height().max(f32::EPSILON); + let [u, v] = rotated_uv(x, y, crop, raster.quarter_turns); + let px = (u * raster.pixel_size[0].saturating_sub(1) as f32).round() as u32; + let py = (v * raster.pixel_size[1].saturating_sub(1) as f32).round() as u32; + let offset = (py as usize * raster.pixel_size[0] as usize + px as usize) * 4; + let Some(pixel) = raster.pixels.get(offset..offset + 4) else { + return Color::BLACK; + }; + let alpha = f32::from(pixel[3]) / 255.0; + let blend = |channel: u8, bg: u8| f32::from(channel) * alpha + f32::from(bg) * (1.0 - alpha); + let luminance = 0.2126 * blend(pixel[0], background.r) + + 0.7152 * blend(pixel[1], background.g) + + 0.0722 * blend(pixel[2], background.b); + if luminance < 140.0 { + Color::rgb(255, 255, 255) + } else { + Color::BLACK + } +} + +fn point_in_rect(point: [f32; 2], rect: Rect) -> bool { + point[0] >= rect.left + && point[0] <= rect.left + rect.width + && point[1] >= rect.top + && point[1] <= rect.top + rect.height +} + +fn rotated_uv(x: f32, y: f32, crop: [f32; 4], turns: u8) -> [f32; 2] { + let [left, top, right, bottom] = crop; + let (u, v) = match turns % 4 { + 1 => (y, 1.0 - x), + 2 => (1.0 - x, 1.0 - y), + 3 => (1.0 - y, x), + _ => (x, y), + }; + [left + u * (right - left), top + v * (bottom - top)] +} + +pub(crate) fn paint_document_raster( + painter: &egui::Painter, + page: Rect, + raster: &DocumentRaster<'_>, + viewport: DocumentViewport, +) { + if !raster.visible || raster.pixel_size.contains(&0) { + return; + } + let Ok(width) = usize::try_from(raster.pixel_size[0]) else { + return; + }; + let Ok(height) = usize::try_from(raster.pixel_size[1]) else { + return; + }; + let Some(expected) = width + .checked_mul(height) + .and_then(|value| value.checked_mul(4)) + else { + return; + }; + if raster.pixels.len() != expected { + return; + } + let frame = egui::Rect::from_min_size( + egui::pos2( + page.left + raster.frame.left * viewport.zoom, + page.top + raster.frame.top * viewport.zoom, + ), + egui::vec2( + raster.frame.width * viewport.zoom, + raster.frame.height * viewport.zoom, + ), + ); + let (rect, crop) = fitted_geometry( + frame, + raster.source_pixel_size, + raster.crop, + raster.quarter_turns, + raster.fit, + ); + let key = TextureKey { + hash: raster.source_hash, + pixel_size: raster.pixel_size, + nearest: raster.nearest, + }; + let options = if raster.nearest { + egui::TextureOptions::NEAREST + } else { + egui::TextureOptions::LINEAR + }; + let texture = cached_texture(painter.ctx(), key, [width, height], raster.pixels, options); + let [left, top, right, bottom] = crop; + let uv = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + let tint = + egui::Color32::from_white_alpha((raster.opacity.clamp(0.0, 1.0) * 255.0).round() as u8); + let corners = [ + rect.left_top(), + rect.right_top(), + rect.right_bottom(), + rect.left_bottom(), + ]; + let uv_corners = [ + uv.left_top(), + uv.right_top(), + uv.right_bottom(), + uv.left_bottom(), + ]; + let turns = usize::from(raster.quarter_turns % 4); + let mut mesh = egui::Mesh::with_texture(texture.id()); + for index in 0..4 { + mesh.vertices.push(egui::epaint::Vertex { + pos: corners[index], + uv: uv_corners[(index + 4 - turns) % 4], + color: tint, + }); + } + mesh.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]); + let mut clip = painter.clip_rect().intersect(frame); + if let Some(bounds) = raster.clip { + let bounds = egui::Rect::from_min_size( + egui::pos2( + page.left + bounds.left * viewport.zoom, + page.top + bounds.top * viewport.zoom, + ), + egui::vec2(bounds.width * viewport.zoom, bounds.height * viewport.zoom), + ); + clip = clip.intersect(bounds); + } + painter.with_clip_rect(clip).add(egui::Shape::mesh(mesh)); +} + +fn cached_texture( + ctx: &egui::Context, + key: TextureKey, + size: [usize; 2], + pixels: &[u8], + options: egui::TextureOptions, +) -> egui::TextureHandle { + let cache_id = egui::Id::new("plotx-raster-texture-cache-v1"); + if let Some(hit) = ctx.data_mut(|data| { + let mut cache = data + .get_temp::(cache_id) + .unwrap_or_default(); + cache.tick = cache.tick.wrapping_add(1); + let tick = cache.tick; + let hit = cache + .entries + .iter_mut() + .find(|entry| entry.key == key) + .map(|entry| { + entry.last_used = tick; + entry.texture.clone() + }); + data.insert_temp(cache_id, cache); + hit + }) { + return hit; + } + let image = egui::ColorImage::from_rgba_unmultiplied(size, pixels); + let texture = ctx.load_texture( + format!("plotx-raster-{:02x?}", &key.hash[..4]), + image, + options, + ); + ctx.data_mut(|data| { + let mut cache = data + .get_temp::(cache_id) + .unwrap_or_default(); + cache.tick = cache.tick.wrapping_add(1); + let bytes = pixels.len(); + cache.bytes = cache.bytes.saturating_add(bytes); + cache.entries.push(CachedTexture { + key, + texture: texture.clone(), + bytes, + last_used: cache.tick, + }); + while cache.bytes > TEXTURE_BUDGET && cache.entries.len() > 1 { + let index = cache + .entries + .iter() + .enumerate() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(index, _)| index) + .unwrap_or(0); + let removed = cache.entries.swap_remove(index); + cache.bytes = cache.bytes.saturating_sub(removed.bytes); + } + data.insert_temp(cache_id, cache); + }); + texture +} + +fn fitted_geometry( + frame: egui::Rect, + pixel_size: [u32; 2], + crop: [f32; 4], + turns: u8, + fit: RasterFit, +) -> (egui::Rect, [f32; 4]) { + if fit == RasterFit::Stretch { + return (frame, crop); + } + let mut source = [ + pixel_size[0] as f32 * (crop[2] - crop[0]), + pixel_size[1] as f32 * (crop[3] - crop[1]), + ]; + if turns % 2 == 1 { + source.swap(0, 1); + } + let source_aspect = source[0] / source[1]; + let frame_aspect = frame.width() / frame.height(); + if fit == RasterFit::Contain { + let size = if source_aspect > frame_aspect { + egui::vec2(frame.width(), frame.width() / source_aspect) + } else { + egui::vec2(frame.height() * source_aspect, frame.height()) + }; + return (egui::Rect::from_center_size(frame.center(), size), crop); + } + let mut covered = crop; + if source_aspect > frame_aspect { + let fraction = frame_aspect / source_aspect; + shrink_crop_axis(&mut covered, turns.is_multiple_of(2), fraction); + } else { + let fraction = source_aspect / frame_aspect; + shrink_crop_axis(&mut covered, turns % 2 == 1, fraction); + } + (frame, covered) +} + +fn shrink_crop_axis(crop: &mut [f32; 4], horizontal: bool, fraction: f32) { + let (start, end) = if horizontal { (0, 2) } else { (1, 3) }; + let center = (crop[start] + crop[end]) * 0.5; + let half = (crop[end] - crop[start]) * fraction * 0.5; + crop[start] = center - half; + crop[end] = center + half; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raster(pixels: &[u8]) -> DocumentRaster<'_> { + DocumentRaster { + source_hash: [0; 32], + frame: Rect::new(0.0, 0.0, 100.0, 100.0), + pixels, + pixel_size: [1, 1], + source_pixel_size: [1, 1], + crop: [0.0, 0.0, 1.0, 1.0], + fit: RasterFit::Stretch, + quarter_turns: 0, + opacity: 1.0, + nearest: false, + clip: None, + visible: true, + } + } + + #[test] + fn panel_label_contrast_uses_white_on_dark_and_black_on_light_rasters() { + let text = DocumentText { + text: "a".into(), + position: [5.0, 5.0], + font_size: 8.0, + }; + let frame = Rect::new(0.0, 0.0, 100.0, 100.0); + let dark = [DocumentItem::Raster(raster(&[0, 0, 0, 255]))]; + assert_eq!( + contrasting_label_color(&dark, &frame, &text, Color::rgb(255, 255, 255)), + Color::rgb(255, 255, 255) + ); + let light = [DocumentItem::Raster(raster(&[255, 255, 255, 255]))]; + assert_eq!( + contrasting_label_color(&light, &frame, &text, Color::BLACK), + Color::BLACK + ); + } + + #[test] + fn contain_letterboxes_without_changing_crop() { + let frame = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(100.0, 100.0)); + let (rect, crop) = fitted_geometry( + frame, + [200, 100], + [0.0, 0.0, 1.0, 1.0], + 0, + RasterFit::Contain, + ); + assert_eq!(rect.size(), egui::vec2(100.0, 50.0)); + assert_eq!(crop, [0.0, 0.0, 1.0, 1.0]); + } + + #[test] + fn cover_crops_the_displayed_horizontal_axis_after_rotation() { + let frame = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(100.0, 100.0)); + let (_, crop) = + fitted_geometry(frame, [100, 200], [0.0, 0.0, 1.0, 1.0], 1, RasterFit::Cover); + assert_eq!(crop, [0.0, 0.25, 1.0, 0.75]); + } +} diff --git a/crates/render/src/svg/document.rs b/crates/render/src/svg/document.rs index ee37ee6..0084c8c 100644 --- a/crates/render/src/svg/document.rs +++ b/crates/render/src/svg/document.rs @@ -77,6 +77,7 @@ fn export_document_with_page( write_overlay(&mut s, overlay); } } + DocumentItem::Raster(_) => {} DocumentItem::PanelLabel { frame, text, diff --git a/deny.toml b/deny.toml index dc13768..5083c51 100644 --- a/deny.toml +++ b/deny.toml @@ -24,6 +24,7 @@ ignore = [ # wayland-scanner moves to quick-xml >= 0.41. { id = "RUSTSEC-2026-0194", reason = "quick-xml is build-time only via wayland-scanner; no safe upgrade" }, { id = "RUSTSEC-2026-0195", reason = "quick-xml is build-time only via wayland-scanner; no safe upgrade" }, + { id = "RUSTSEC-2026-0249", reason = "smartstring is transitive through rhai; no safe upgrade is available" }, ] [licenses] diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 6edb8ea..7b74c2e 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -18,6 +18,71 @@ undoes as one step. In the Canvas list, `Shift`-click selects a continuous range and `Ctrl`-click adds or removes one canvas. `Ctrl` + `A` selects every frame or canvas according to the area you last used. +## Add external images + +To place each image on its own fitted page, choose **Add Images…** from the File +menu, Ribbon, or command palette. You can select multiple PNG, JPEG, TIFF, WebP, +or BMP files. You can also drop files onto the board; each image gets a new page +even if you drop it over an existing one. + +To add an image inside an existing Panel, select or enter an unlocked Panel, +then drop the file inside the green outline. To paste an image or a copied list +of image files, use **Paste Image** or press `Ctrl`/`Cmd` + `V`. On Windows, you +can copy files from File Explorer or copy image pixels from an application such +as Paint. Pasting in a text field continues to paste text normally. + +When PlotX creates a page for an image, it uses the image's physical size when +DPI information is available and assumes 96 DPI otherwise. The page is at most +89 mm wide, and its height follows the image's aspect ratio. The image fills the +page without distortion. Importing several images is one undoable action; a +failed or cancelled import leaves the project unchanged. + +For formats that need special handling, use these commands: + +- **Add Animated Image First Frame…** imports the first frame of an animated + PNG or WebP. +- **Add Images Without Metadata…** removes metadata by converting the image to + PNG before adding it. +- **Add All TIFF Pages…** adds every readable page of a multi-page TIFF. The + standard **Add Images…** command adds only its first page. + +PlotX asks for confirmation before importing a very large image. Select an +image and use **Image** in the Object inspector to crop or rotate it, adjust its +opacity, or choose how it is interpolated. These changes do not alter the +embedded source image. + +Pages that contain external images cannot yet be exported or copied with +**Copy Figure**. PlotX stops the operation and reports the reason instead of +creating a figure with the image missing. + +## Work with figure panels + +Use the Panel commands in the Arrange menu or command palette to build a +multi-panel figure. You can create an empty Panel, turn selected objects into a +Panel, or duplicate, merge, split, dissolve, and delete Panels. Expand a Panel +in Layers to see the objects it contains. + +Click anywhere inside a Panel to select it, then drag to move it or use a corner +handle to resize it together with its contents. `Shift`-click selects multiple +Panels. To work with an object inside a Panel, double-click the Panel or press +`Enter`. Use the breadcrumb above the page or press `Esc` to return to the page. +You can also `Ctrl`/`Cmd`-click an object to select it without entering first. + +Inside a Panel, objects snap to the Panel edge and to one another. To move an +object into another unlocked Panel, drag it over the Panel and release when the +target is highlighted. You can make the same move in Layers by dragging the +object's row onto the Panel's row. A Stack or Grid Panel rearranges its contents +after the move. Hidden objects remain selectable in Layers; locked objects can +be selected but not edited. + +Use the Panel inspector to control visibility, locking, clipping, labels, and +layout. **Vertical Stack**, **Horizontal Stack**, and **Grid** arrange the +contents using the selected gap, padding, and alignment. Automatic panel letters +appear only when a page has more than one visible Panel and follow reading order: +left to right, then top to bottom. A letter placed over an image automatically +switches between black and white for contrast. Manual and locked labels remain +visible on a page with one Panel. + ## Canvas size The active page shows a size chip above its top-left corner — the current diff --git a/docs/src/content/docs/reference/file-formats.md b/docs/src/content/docs/reference/file-formats.md index 727cb52..da11aed 100644 --- a/docs/src/content/docs/reference/file-formats.md +++ b/docs/src/content/docs/reference/file-formats.md @@ -33,6 +33,22 @@ you on reopening if the map no longer matches the data the project reconstructs. The contour drawing is not stored — PlotX redraws it from the saved map when the project opens. +## Embedded images + +PlotX accepts PNG, JPEG, TIFF, WebP, and BMP images and stores them in the +`.plotx` project. Duplicating an image does not duplicate its stored source, but +each copy can have its own crop, rotation, opacity, fit, and interpolation. + +SVG is not supported. For an animated PNG or WebP, **Add Images…** reports that +the image is animated; use **Add Animated Image First Frame…** to display its +first frame. **Add Images Without Metadata…** converts the pixels to PNG before +storing them. + +PlotX rejects images above 500 megapixels or an estimated 2 GiB when decoded. +It asks for confirmation above 100 megapixels or 512 MiB. For a multi-page TIFF, +**Add Images…** imports the first page and **Add All TIFF Pages…** imports every +page that PlotX can read. Each imported page can be edited independently. + ## `.plotxproc` processing recipes A `.plotxproc` file stores one processing pipeline, without any data — save a diff --git a/docs/src/content/docs/reference/troubleshooting.md b/docs/src/content/docs/reference/troubleshooting.md index 40c1700..b95a0c0 100644 --- a/docs/src/content/docs/reference/troubleshooting.md +++ b/docs/src/content/docs/reference/troubleshooting.md @@ -5,6 +5,13 @@ description: What to check when an import, analysis, export, or update doesn't b ## Importing +**An image will not import.** Confirm that its actual content is PNG, JPEG, +TIFF, WebP, or BMP. PlotX checks the signature, not just the extension. SVG is +not supported. For an animated PNG or WebP, use **Add Animated Image First +Frame…**. Open **Operation and Diagnostic History** to see the file name, +detected format, point of failure, and reason. A failed import does not change +the project. + **My ABF file won't open.** PlotX reads ABF 2.x with int16 or float32 samples. ABF1 files and compressed ABF2 data are not currently supported — re-export the recording as uncompressed ABF2 from your acquisition software. @@ -38,6 +45,10 @@ window or confirm the exclusion it proposes. ## Exporting +**Export stops because the page contains an external image.** Export and +**Copy Figure** do not support external images. Remove the images from the page +before exporting. PlotX stops instead of creating a figure with images missing. + **The export precheck flags my figure.** Font sizes or line widths violate the selected journal preset. Fix them on the board — **Figure Typography…** sets all axis text sizes at once — or pick a preset that matches your diff --git a/docs/src/content/docs/reference/ui-overview.md b/docs/src/content/docs/reference/ui-overview.md index b8b1f76..fe0c5ab 100644 --- a/docs/src/content/docs/reference/ui-overview.md +++ b/docs/src/content/docs/reference/ui-overview.md @@ -37,6 +37,10 @@ introduces the same regions in walkthrough form. ## Recurring elements +- **Figure panel (Panel)** — a labelled section of a figure that can contain + plots, images, text, and shapes. Panels cannot be placed inside other Panels. + A **group** moves and arranges objects together but does not add a panel label. + - **Task page** — one task's page in the task dock. Drag its lower edge to change its height. Switching tabs keeps a page's settings; closing it with its ✕ is what discards them, and selecting a tab makes that page's dataset diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index ae49379..005ae99 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -16,6 +16,56 @@ description: 在无限画板上排布图形,并按期刊规范设定页面尺 `Ctrl` + 单击添加或移除单个画布。`Ctrl` + `A` 会根据最近使用的区域全选图框 或画布。 +## 添加外部图片 + +若要让每张图片各占一个适配大小的页面,请从 File 菜单、Ribbon 或命令面板选择 +**Add Images…**。一次可以选择多张 PNG、JPEG、TIFF、WebP 或 BMP 图片。也可以 +直接把文件拖到画板上;即使落点位于已有页面内,每张图片仍会新建一个页面。 + +若要把图片放进已有 Panel,请先选中或进入未锁定的 Panel,再把文件拖入绿色轮廓。 +若要粘贴图片或复制的一组图片文件,请使用 **Paste Image**,或按 `Ctrl`/`Cmd` + +`V`。在 Windows 上,可以从文件资源管理器复制文件,也可以从 Paint 等应用复制 +图片像素。光标位于文本框中时,该快捷键仍会正常粘贴文本。 + +PlotX 创建图片页面时,会优先采用图片的 DPI 信息确定实际尺寸;没有 DPI 信息时按 +96 DPI 计算。页面宽度不超过 89 mm,高度保持图片的原始宽高比,图片会无变形地 +铺满页面。一次导入多张图片只产生一个撤销步骤;导入失败或取消不会改动项目。 + +以下格式需要使用专门的命令: + +- **Add Animated Image First Frame…** 导入动画 PNG 或 WebP 的第一帧。 +- **Add Images Without Metadata…** 先把图片转换为 PNG,以去除元数据,再添加到项目。 +- **Add All TIFF Pages…** 添加多页 TIFF 中所有可读取的页面;常规的 **Add Images…** + 只添加第一页。 + +导入尺寸很大的图片前,PlotX 会要求确认。选中图片后,可在对象检查器的 **Image** +区域裁剪或旋转图片、调整透明度,或选择插值方式。这些调整不会改变嵌入的源图片。 + +包含外部图片的页面暂时无法导出,也无法使用 **Copy Figure** 复制。PlotX 会停止 +操作并说明原因,不会生成缺少图片的图版。 + +## 使用图版面板 + +使用 Arrange 菜单或命令面板中的 Panel 命令,可以制作多 Panel 图版。你可以创建 +空 Panel、把所选对象组成 Panel,也可以复制、合并、拆分、解散或删除 Panel。 +在 Layers 中展开 Panel,即可查看其中的对象。 + +单击 Panel 内任意位置可将其选中,随后可以拖动整个 Panel,或用四角控制点连同 +其中内容一起缩放。按住 `Shift` 单击可选择多个 Panel。若要编辑 Panel 内的对象, +请双击 Panel 或按 `Enter` 进入。点击页面上方的面包屑或按 `Esc` 可返回页面。也可 +按住 `Ctrl`/`Cmd` 单击对象,无需先进入 Panel 即可直接选择它。 + +在 Panel 内,对象会吸附到 Panel 边缘和其他对象。若要把对象移入另一个未锁定的 +Panel,请将它拖到目标 Panel 上,待目标高亮后松开。也可以在 Layers 中把对象行 +拖到 Panel 行。若目标采用 Stack 或 Grid 布局,内容会在移入后重新排列。隐藏对象 +仍可在 Layers 中选择;锁定对象可以选择,但不能编辑。 + +对象检查器中的 Panel 设置可控制可见性、锁定、裁剪、标签和布局。**Vertical +Stack**、**Horizontal Stack** 与 **Grid** 会按选定的间距、内边距和对齐方式排列 +内容。页面有多个可见 Panel 时,自动字母标签才会出现,并按从左到右、从上到下的 +阅读顺序排列。标签位于图片上时会自动切换黑白颜色以保持清晰;手工标签和锁定 +标签在单 Panel 页面中仍会显示。 + ## 画布尺寸 活动页面左上角上方会显示一个尺寸标签,内容为当前尺寸和匹配到的预设 diff --git a/docs/src/content/docs/zh-cn/reference/file-formats.md b/docs/src/content/docs/zh-cn/reference/file-formats.md index d2b6026..d152eeb 100644 --- a/docs/src/content/docs/zh-cn/reference/file-formats.md +++ b/docs/src/content/docs/zh-cn/reference/file-formats.md @@ -27,6 +27,20 @@ PlotX(或相反)时,文件会被拒绝并给出明确的"不支持的版 重新打开项目时,若某张图与项目重建出的数据不再一致,PlotX 就据此发出警告。 等高线绘图本身不保存;项目打开时,PlotX 会根据保存的图重新绘制。 +## 嵌入图片 + +PlotX 支持 PNG、JPEG、TIFF、WebP 与 BMP,并将图片保存在 `.plotx` 项目中。 +复制图片不会重复保存源图片,但每个副本可以分别设置裁剪、旋转、透明度、适配与 +插值方式。 + +PlotX 不支持 SVG。用 **Add Images…** 添加动画 PNG 或 WebP 时,PlotX 会提示该 +图片包含动画;使用 **Add Animated Image First Frame…** 可显示其第一帧。 +**Add Images Without Metadata…** 会先把图片像素转换为 PNG,再保存到项目中。 + +图片超过 5 亿像素或预计解码后超过 2 GiB 时,PlotX 会拒绝导入;超过 1 亿像素或 +512 MiB 时,会先要求确认。对于多页 TIFF,**Add Images…** 导入第一页,**Add All +TIFF Pages…** 导入 PlotX 能够读取的所有页面。导入后的各页可以分别编辑。 + ## `.plotxproc` 处理配方 `.plotxproc` 文件保存一条处理管线,不含任何数据——配方保存一次即可在 diff --git a/docs/src/content/docs/zh-cn/reference/troubleshooting.md b/docs/src/content/docs/zh-cn/reference/troubleshooting.md index ebda15d..37e3996 100644 --- a/docs/src/content/docs/zh-cn/reference/troubleshooting.md +++ b/docs/src/content/docs/zh-cn/reference/troubleshooting.md @@ -5,6 +5,11 @@ description: 导入、分析、导出或更新不符合预期时的检查清单 ## 导入 +**图片无法导入。** 请确认文件实际内容为 PNG、JPEG、TIFF、WebP 或 BMP; +PlotX 检查文件签名,而不只看扩展名。PlotX 不支持 SVG;若是动画 PNG 或 WebP, +请使用 **Add Animated Image First Frame…**。打开 **Operation and Diagnostic +History** 可查看文件名、检测到的格式、失败环节和原因。导入失败不会改动项目。 + **ABF 文件打不开。** PlotX 读取 int16 或 float32 采样的 ABF 2.x。ABF1 文件和压缩的 ABF2 数据目前不支持——请在采集软件中重新导出为未压缩的 ABF2。 @@ -33,6 +38,9 @@ ABF2。 ## 导出 +**页面含外部图片时导出停止。** 导出与 **Copy Figure** 不支持外部图片。请先从 +页面中移除图片再导出。PlotX 会停止操作,不会生成缺少图片的图版。 + **导出预检标记了我的图。** 字号或线宽违反所选期刊预设。在画板上修正 ——**Figure Typography…** 可一次设定全部坐标轴文字尺寸——或改选符合 预期尺寸的预设。 diff --git a/docs/src/content/docs/zh-cn/reference/ui-overview.md b/docs/src/content/docs/zh-cn/reference/ui-overview.md index d624df9..48f632e 100644 --- a/docs/src/content/docs/zh-cn/reference/ui-overview.md +++ b/docs/src/content/docs/zh-cn/reference/ui-overview.md @@ -32,6 +32,10 @@ PlotX 的界面为英文;手册中加粗的英文词即界面上的原文标 ## 常见元素 +- **图版面板(Panel)**——图版中带标签的分区,可以包含图表、图片、文本与形状。 + Panel 不能嵌套在其他 Panel 中。**编组(Group)**可让多个对象一起移动和排列, + 但不会添加 Panel 标签。 + - **任务页(task page)**——任务 dock 中某一个任务的内容页,拖动其下边缘可 调整高度。切换标签会保留该页的设置,只有用 ✕ 关闭才会丢弃;选中标签还会把 该页对应的数据集设为当前数据集。