From 5448128ce054d5df44a456c516412f88386310c3 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Sun, 9 Aug 2026 22:14:54 +0800 Subject: [PATCH] feat(figure): introduce panel and content hierarchy - model semantic panels and typed content/assets in the final v1 schema - preserve labels, groups, assets, and identity through actions and persistence - unify panel-aware layout, rendering, captions, transfers, and editor interactions --- crates/app/src/ui/canvas/authoring.rs | 3 +- crates/app/src/ui/canvas/board.rs | 15 +- crates/app/src/ui/canvas/board_marquee.rs | 1 + crates/app/src/ui/canvas/board_notes.rs | 44 +- crates/app/src/ui/canvas/geometry.rs | 120 ++++- crates/app/src/ui/canvas/interactions.rs | 42 +- crates/app/src/ui/canvas/mod.rs | 20 +- crates/app/src/ui/canvas/navigation_tests.rs | 1 - crates/app/src/ui/canvas/painting.rs | 2 +- crates/app/src/ui/canvas/panel_notes.rs | 47 +- crates/app/src/ui/canvas/snap.rs | 7 +- crates/app/src/ui/canvas/tiling.rs | 20 +- crates/app/src/ui/canvas_size.rs | 10 +- crates/app/src/ui/commands_alignment_tests.rs | 1 - crates/app/src/ui/object_inspector/edits.rs | 8 +- .../app/src/ui/object_inspector/geometry.rs | 4 +- crates/app/src/ui/object_inspector_tests.rs | 1 - crates/app/src/ui/primary_sidebar.rs | 9 +- crates/app/src/ui/properties/control_tests.rs | 1 - crates/app/src/ui/properties/mod.rs | 2 +- crates/app/src/ui/shortcuts.rs | 4 +- crates/app/src/ui/tools/task_card.rs | 6 +- crates/app/src/ui/windows/canvas_settings.rs | 10 +- crates/core/src/actions/app_impl/apply.rs | 8 +- .../core/src/actions/app_impl/meta_edits.rs | 19 +- crates/core/src/actions/app_impl/mod.rs | 50 +- crates/core/src/actions/app_impl/revert.rs | 12 + crates/core/src/actions/app_impl/validate.rs | 35 ++ crates/core/src/actions/arrange.rs | 24 +- crates/core/src/actions/mod.rs | 67 ++- crates/core/src/actions/panel.rs | 481 ++++++++++++++++++ crates/core/src/actions/tests/interaction.rs | 2 +- crates/core/src/actions/tests/mod.rs | 32 +- crates/core/src/actions/tests/panel.rs | 198 +++++++ crates/core/src/actions/tests/stack.rs | 9 +- crates/core/src/actions/tests/tiling.rs | 60 ++- crates/core/src/actions/transfer.rs | 271 ++++++++-- .../properties_tests_inbound_value.rs | 1 - .../automation/properties_tests_outbound.rs | 1 - .../automation/properties_tests_rejections.rs | 1 - crates/core/src/export/mod.rs | 1 - crates/core/src/export/precheck.rs | 15 +- crates/core/src/project/asset_codec.rs | 154 ++++++ crates/core/src/project/convert_views.rs | 220 +++++++- .../core/src/project/convert_views_panel.rs | 164 ++++++ crates/core/src/project/dto.rs | 64 +-- crates/core/src/project/dto_panel.rs | 95 ++++ crates/core/src/project/mod.rs | 33 +- crates/core/src/project/panel_schema_tests.rs | 201 ++++++++ crates/core/src/project/reference_tests.rs | 12 + crates/core/src/project/schema_tests.rs | 1 + crates/core/src/project/tests.rs | 12 - crates/core/src/properties/object.rs | 28 +- .../core/src/properties/object_definitions.rs | 2 +- crates/core/src/properties/object_tests.rs | 9 +- crates/core/src/properties/tests_catalog.rs | 1 - .../core/src/properties/transaction_object.rs | 13 +- crates/core/src/state/app_impl.rs | 4 +- crates/core/src/state/content.rs | 250 +++++++++ crates/core/src/state/document.rs | 376 +++++++------- crates/core/src/state/identity.rs | 9 +- crates/core/src/state/mass_spec_app.rs | 6 +- crates/core/src/state/mass_spec_tests.rs | 40 +- crates/core/src/state/mod.rs | 4 + crates/core/src/state/page_fit.rs | 15 +- crates/core/src/state/panel.rs | 310 +++++++++++ crates/core/src/state/panel_label.rs | 71 ++- crates/core/src/state/panel_tests.rs | 138 +++++ crates/core/src/state/plot_object.rs | 7 +- crates/core/src/state/stack.rs | 8 +- crates/core/src/state/ui_state.rs | 1 + crates/core/src/workflow.rs | 27 +- crates/core/src/workflow_tests.rs | 15 + crates/render/src/emf.rs | 14 + crates/render/src/emf/tests.rs | 41 ++ crates/render/src/lib.rs | 5 + crates/render/src/screen.rs | 65 ++- crates/render/src/screen_tests.rs | 54 ++ crates/render/src/svg.rs | 2 +- crates/render/src/svg/document.rs | 57 ++- 80 files changed, 3580 insertions(+), 613 deletions(-) create mode 100644 crates/core/src/actions/panel.rs create mode 100644 crates/core/src/actions/tests/panel.rs create mode 100644 crates/core/src/project/asset_codec.rs create mode 100644 crates/core/src/project/convert_views_panel.rs create mode 100644 crates/core/src/project/dto_panel.rs create mode 100644 crates/core/src/project/panel_schema_tests.rs create mode 100644 crates/core/src/state/content.rs create mode 100644 crates/core/src/state/panel.rs create mode 100644 crates/core/src/state/panel_tests.rs diff --git a/crates/app/src/ui/canvas/authoring.rs b/crates/app/src/ui/canvas/authoring.rs index c4e81a2..2f83921 100644 --- a/crates/app/src/ui/canvas/authoring.rs +++ b/crates/app/src/ui/canvas/authoring.rs @@ -97,7 +97,7 @@ fn create_object(app: &mut PlotxApp, ci: usize, tool: Tool, frame: ObjectFrame) Tool::PanelLabel => { let mut t = app.doc.style_library.panel_label.clone(); t.text = "a".to_owned(); - ("Panel label".to_owned(), CanvasObjectKind::PanelLabel(t)) + ("Text".to_owned(), CanvasObjectKind::Text(t)) } Tool::Text => { let mut t = app.doc.style_library.text.clone(); @@ -116,7 +116,6 @@ fn create_object(app: &mut PlotxApp, ci: usize, tool: Tool, frame: ObjectFrame) frame, locked: false, visible: true, - group: None, kind, }; let selection_before = app.session.ui.selection.clone(); diff --git a/crates/app/src/ui/canvas/board.rs b/crates/app/src/ui/canvas/board.rs index 11e90e1..9ad087c 100644 --- a/crates/app/src/ui/canvas/board.rs +++ b/crates/app/src/ui/canvas/board.rs @@ -534,9 +534,6 @@ fn activate_page(app: &mut PlotxApp, ci: usize) { } pub(crate) fn dispatch_frame_gesture(app: &mut PlotxApp, rect: egui::Rect, ui: &Ui) -> bool { - if board_marquee::handle(app, rect, ui) { - return true; - } let (pressed, double, hover, extend) = ui.input(|i| { ( i.pointer.primary_pressed(), @@ -546,6 +543,18 @@ pub(crate) fn dispatch_frame_gesture(app: &mut PlotxApp, rect: egui::Rect, ui: & i.modifiers.shift || i.modifiers.command || i.modifiers.ctrl, ) }); + if pressed + && let Some(point) = hover + && let Some((canvas, _)) = object_at_screen(app, rect, point) + { + if app.session.active_canvas != Some(canvas) { + activate_frame(app, FrameRef::Page(canvas)); + } + return false; + } + if board_marquee::handle(app, rect, ui) { + return true; + } if extend && pressed && let Some(p) = hover diff --git a/crates/app/src/ui/canvas/board_marquee.rs b/crates/app/src/ui/canvas/board_marquee.rs index 1c3dc48..60a7a7e 100644 --- a/crates/app/src/ui/canvas/board_marquee.rs +++ b/crates/app/src/ui/canvas/board_marquee.rs @@ -15,6 +15,7 @@ pub(super) fn handle(app: &mut PlotxApp, rect: egui::Rect, ui: &Ui) -> bool { && pressed && let Some(point) = hover && rect.contains(point) + && object_at_screen(app, rect, point).is_none() && frame_at(app, rect, point).is_none() && frame_header_at(app, rect, point).is_none() { diff --git a/crates/app/src/ui/canvas/board_notes.rs b/crates/app/src/ui/canvas/board_notes.rs index a7df0ef..c94ff36 100644 --- a/crates/app/src/ui/canvas/board_notes.rs +++ b/crates/app/src/ui/canvas/board_notes.rs @@ -43,31 +43,25 @@ pub(crate) fn handle_frame_caption_interactions( ui.id().with(("panel_note_row", ci, object_id)), Sense::click(), ) - .on_hover_text("Click to select. Double-click to edit this panel note."); + .on_hover_text("Click to edit this panel description."); if resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); consumed = true; } - if resp.double_clicked() { + if resp.clicked() { app.session.active_canvas = Some(ci); app.select_object(ci, object_id); open_inline_panel_note_editor(app, ci, object_id); consumed = true; - } else if resp.clicked() { - app.session.active_canvas = Some(ci); - app.select_object(ci, object_id); - app.session.status = - "Panel selected. Double-click its note to edit in place.".to_owned(); - consumed = true; } resp.context_menu(|ui| { app.session.active_canvas = Some(ci); app.select_object(ci, object_id); - if ui.button("Edit note in place").clicked() { + if ui.button("Edit description in place").clicked() { open_inline_panel_note_editor(app, ci, object_id); ui.close(); } - if ui.button("Edit note in dialog").clicked() { + if ui.button("Edit description in dialog").clicked() { app.session.ui.panel_note_inline_edit = None; open_panel_note_editor(app, ci, object_id); ui.close(); @@ -83,9 +77,7 @@ pub(crate) fn open_inline_panel_note_editor(app: &mut PlotxApp, ci: usize, objec .doc .canvases .get(ci) - .and_then(|canvas| canvas.object(object_id)) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()) + .and_then(|canvas| canvas.panel_meta_for_content(object_id)) else { return; }; @@ -131,11 +123,9 @@ pub(crate) fn render_inline_panel_note_editor(app: &mut PlotxApp, screen: egui:: if let Some(edit) = app.session.ui.panel_note_inline_edit.as_mut() { edit.buffer.clone_from(&buffer); } - if let Some(plot) = app.doc.canvases[ci] - .object_mut(object_id) - .and_then(|object| object.plot_mut()) - { - plot.panel.user_note = buffer.clone(); + if let Some(mut panel) = app.doc.canvases[ci].panel_meta_for_content(object_id) { + panel.user_note = buffer.clone(); + app.doc.canvases[ci].set_panel_meta_for_content(object_id, panel); app.mark_document_dirty(); } } @@ -202,14 +192,12 @@ fn commit_inline_panel_note_edit(app: &mut PlotxApp) { .doc .canvases .get(ci) - .and_then(|canvas| canvas.object(id)) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()) + .and_then(|canvas| canvas.panel_meta_for_content(id)) else { return; }; app.execute_action(Action::set_panel_meta(ci, id, before, after)); - app.session.status = "Panel note updated.".to_owned(); + app.session.status = "Panel description updated.".to_owned(); } fn cancel_inline_panel_note_edit(app: &mut PlotxApp) { @@ -217,14 +205,8 @@ fn cancel_inline_panel_note_edit(app: &mut PlotxApp) { let Some((ci, id, before)) = app.session.ui.note_edit_before.take() else { return; }; - if let Some(plot) = app - .doc - .canvases - .get_mut(ci) - .and_then(|canvas| canvas.object_mut(id)) - .and_then(|object| object.plot_mut()) - { - plot.panel = before; + if let Some(canvas) = app.doc.canvases.get_mut(ci) { + canvas.set_panel_meta_for_content(id, before); } - app.session.status = "Panel note edit cancelled.".to_owned(); + app.session.status = "Panel description edit cancelled.".to_owned(); } diff --git a/crates/app/src/ui/canvas/geometry.rs b/crates/app/src/ui/canvas/geometry.rs index 0ea8589..9c464d6 100644 --- a/crates/app/src/ui/canvas/geometry.rs +++ b/crates/app/src/ui/canvas/geometry.rs @@ -12,9 +12,10 @@ pub(crate) fn hit_object(canvas: &CanvasDocument, p: Pos2, zoom: f32) -> Option< if !object.visible { return None; } + let frame = canvas.layout_frame(object.id)?; let r = egui::Rect::from_min_size( - Pos2::new(object.frame.x, object.frame.y), - egui::vec2(object.frame.width, object.frame.height), + Pos2::new(frame.x, frame.y), + egui::vec2(frame.width, frame.height), ); let handles = [ (r.left_top(), ResizeHandle::TopLeft), @@ -37,6 +38,27 @@ pub(crate) fn hit_object(canvas: &CanvasDocument, p: Pos2, zoom: f32) -> Option< }) } +/// Topmost canvas object under a screen point, including objects outside their +/// owning page. The active page wins ties, matching page/frame hit ordering. +pub(crate) fn object_at_screen( + app: &PlotxApp, + screen: egui::Rect, + point: Pos2, +) -> Option<(usize, ObjectHit)> { + 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_object(&app.doc.canvases[canvas], page, app.session.board.zoom).map(|hit| (canvas, hit)) + }) +} + pub(crate) fn drag_frame( frame: ObjectFrame, kind: ObjectDragKind, @@ -160,15 +182,26 @@ impl BoardTransform { object_id: ObjectId, ) -> Option { let page = self.page_screen_rect(canvas); - let object = canvas.object(object_id)?; + let frame = canvas.layout_frame(object_id)?; Some(PlotRect::new( - page.left() + object.frame.x * self.zoom, - page.top() + object.frame.y * self.zoom, - object.frame.width * self.zoom, - object.frame.height * self.zoom, + page.left() + frame.x * self.zoom, + page.top() + frame.y * self.zoom, + frame.width * self.zoom, + frame.height * self.zoom, )) } + pub fn canvas_editor_screen_rect(&self, canvas: &CanvasDocument) -> egui::Rect { + let mut bounds = self.page_screen_rect(canvas); + for object in &canvas.objects { + let Some(frame) = self.object_screen_rect(canvas, object.id) else { + continue; + }; + bounds = bounds.union(plot_rect(frame)); + } + bounds + } + /// Screen px → board world (pt), before any per-page `board_pos` offset. pub fn screen_to_world(&self, p: Pos2) -> Pos2 { Pos2::new( @@ -415,7 +448,6 @@ mod tests { frame, locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label("x".to_owned())), } } @@ -652,4 +684,76 @@ mod tests { let hit = hit_object(&canvas, Pos2::new(35.0, 35.0), 1.0).unwrap(); assert_eq!(hit.object, ObjectId::new(2)); } + + #[test] + fn panel_local_content_hits_and_moves_by_distinct_page_frames() { + let mut canvas = CanvasDocument::new("t".to_owned(), [100.0, 100.0]); + let a = ObjectId::new(1); + let b = ObjectId::new(2); + canvas + .objects + .push(text_object(1, ObjectFrame::new(0.0, 0.0, 40.0, 30.0))); + canvas + .objects + .push(text_object(2, ObjectFrame::new(0.0, 0.0, 40.0, 30.0))); + let panel_a = canvas.create_panel("a".into(), ObjectFrame::new(5.0, 10.0, 40.0, 30.0)); + let panel_b = canvas.create_panel("b".into(), ObjectFrame::new(55.0, 10.0, 40.0, 30.0)); + canvas.panel_mut(panel_a).unwrap().item_order.push(a); + canvas.panel_mut(panel_b).unwrap().item_order.push(b); + + assert_eq!( + hit_object(&canvas, Pos2::new(20.0, 20.0), 1.0) + .unwrap() + .object, + a + ); + assert_eq!( + hit_object(&canvas, Pos2::new(70.0, 20.0), 1.0) + .unwrap() + .object, + b + ); + + canvas.set_layout_frame(b, ObjectFrame::new(55.0, 50.0, 40.0, 30.0)); + assert_eq!(canvas.layout_frame(a).unwrap().y, 10.0); + assert_eq!(canvas.layout_frame(b).unwrap().y, 50.0); + assert_eq!( + hit_object(&canvas, Pos2::new(70.0, 60.0), 1.0) + .unwrap() + .object, + b + ); + } + + #[test] + fn outside_page_object_remains_in_editor_bounds_and_hit_testing() { + let mut app = PlotxApp::new(); + let mut canvas = CanvasDocument::new("t".to_owned(), [20.0, 20.0]); + canvas.board_pos = [30.0, 40.0]; + let [page_width, _] = canvas.size_pt(); + let id = ObjectId::new(1); + canvas.objects.push(text_object( + 1, + ObjectFrame::new(page_width + 15.0, 10.0, 40.0, 30.0), + )); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app.session.board = BoardViewport { + zoom: 1.0, + pan: [0.0, 0.0], + auto_fit: false, + }; + let screen = egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(500.0, 400.0)); + let transform = BoardTransform::from_board(app.session.board, screen); + let page = transform.page_screen_rect(&app.doc.canvases[0]); + let point = Pos2::new(page.left() + page_width + 25.0, page.top() + 20.0); + + assert!(!page.contains(point)); + assert!( + transform + .canvas_editor_screen_rect(&app.doc.canvases[0]) + .contains(point) + ); + assert_eq!(object_at_screen(&app, screen, point).unwrap().1.object, id); + } } diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 2607c61..0ade4d1 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -358,8 +358,13 @@ pub(crate) fn handle_object_interactions( app.reset_interaction(); } app.focus_object_datasets(ci, id); - if let Some(object) = app.doc.canvases[ci].object(id).filter(|o| !o.locked) { - let before = object.frame; + if app.doc.canvases[ci] + .object(id) + .is_some_and(|object| !object.locked) + { + let Some(before) = 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]); let others = if matches!(hit.kind, ObjectDragKind::Move) { app.session @@ -373,7 +378,11 @@ pub(crate) fn handle_object_interactions( app.doc.canvases[ci] .object(oid) .filter(|o| !o.locked) - .map(|o| (oid, o.frame)) + .and_then(|_| { + app.doc.canvases[ci] + .layout_frame(oid) + .map(|frame| (oid, frame)) + }) }) .collect() } else { @@ -448,18 +457,17 @@ pub(crate) fn handle_object_interactions( let candidate = drag_frame(drag.before, drag.kind, dpx, dpy); let (snapped, guides) = snap_object_frame(app, ci, &drag, candidate, ui); let applied = [snapped.x - drag.before.x, snapped.y - drag.before.y]; - if let Some(object) = app.doc.canvases[ci].object_mut(drag.object) { - object.frame = snapped; - } + app.doc.canvases[ci].set_layout_frame(drag.object, snapped); for &(oid, before) in &drag.others { - if let Some(o) = app.doc.canvases[ci].object_mut(oid) { - o.frame = ObjectFrame::new( + app.doc.canvases[ci].set_layout_frame( + oid, + ObjectFrame::new( before.x + applied[0], before.y + applied[1], before.width, before.height, - ); - } + ), + ); } app.session.ui.snap_guides = guides; } @@ -515,7 +523,9 @@ fn finish_marquee(app: &mut PlotxApp, ci: usize, marq: MarqueeDrag) { .iter() .filter(|o| o.visible) .filter(|o| { - let f = o.frame; + 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) @@ -624,12 +634,12 @@ pub(crate) fn arrange_context_menu(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { pub(crate) fn finish_object_drag(app: &mut PlotxApp, ci: usize, drag: ObjectDrag) { if drag.others.is_empty() { - if let Some(object) = app.doc.canvases[ci].object(drag.object) { + if let Some(after) = app.doc.canvases[ci].layout_frame(drag.object) { app.execute_action(Action::move_resize_object( ci, drag.object, drag.before, - object.frame, + after, )); } return; @@ -638,7 +648,11 @@ pub(crate) fn finish_object_drag(app: &mut PlotxApp, ci: usize, drag: ObjectDrag before.extend(drag.others.iter().copied()); let after: Vec<(ObjectId, ObjectFrame)> = before .iter() - .filter_map(|&(id, _)| app.doc.canvases[ci].object(id).map(|o| (id, o.frame))) + .filter_map(|&(id, _)| { + app.doc.canvases[ci] + .layout_frame(id) + .map(|frame| (id, frame)) + }) .collect(); app.execute_action(Action::set_object_frames(ci, before, after)); } diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 408da4e..bbbd8f5 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -224,6 +224,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { color: Color32::from_black_alpha(if ui.visuals().dark_mode { 110 } else { 36 }), }; let clip = painter.clip_rect(); + let board_transform = BoardTransform::from_board(app.session.board, rect); for other in (0..app.doc.canvases.len()) .filter(|&other| other != ci) .chain(std::iter::once(ci)) @@ -233,11 +234,12 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { if finite_rect_intersects(card.expand(12.0), clip) { painter.add(page_shadow.as_shape(card, header_corner_radius())); } - if finite_rect_intersects(other_page, clip) { + let editor_bounds = board_transform.canvas_editor_screen_rect(&app.doc.canvases[other]); + if finite_rect_intersects(editor_bounds, clip) { paint_document(app, other, rect, &painter); - if other != ci { - painter.rect_stroke(other_page, 0.0, frame_stroke, StrokeKind::Inside); - } + } + if finite_rect_intersects(other_page, clip) && other != ci { + painter.rect_stroke(other_page, 0.0, frame_stroke, StrokeKind::Inside); } } paint_frame_headers(app, rect, ui, &painter); @@ -522,11 +524,10 @@ fn canvas_breadcrumb(app: &PlotxApp, ci: usize, ui: &mut Ui) { let title = app.doc.canvases[ci] .object(id) .map(|object| { - object - .plot() - .and_then(|plot| plot.panel.user_note.lines().next()) + 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()) - .map(str::to_owned) .unwrap_or_else(|| object.name.clone()) }) .unwrap_or_default(); @@ -611,7 +612,6 @@ mod tests { frame: ObjectFrame::new(20.0, 20.0, 100.0, 30.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label("hi".to_owned())), }); @@ -629,7 +629,6 @@ mod tests { frame: ObjectFrame::new(-30.0, 20.0, 50.0, 40.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Plot(Box::new({ let figure = Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); @@ -664,7 +663,6 @@ mod tests { frame: ObjectFrame::new(10.0, 10.0, 80.0, 60.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Plot(Box::new({ let figure = Figure::new("plot", Axis::new("x", 0.0, 1.0), Axis::new("y", 0.0, 1.0)); diff --git a/crates/app/src/ui/canvas/navigation_tests.rs b/crates/app/src/ui/canvas/navigation_tests.rs index c2e07c6..b79cc02 100644 --- a/crates/app/src/ui/canvas/navigation_tests.rs +++ b/crates/app/src/ui/canvas/navigation_tests.rs @@ -22,7 +22,6 @@ fn zoomed_plot_fixture() -> (PlotxApp, ObjectId, PlotRect) { frame: ObjectFrame::new(10.0, 10.0, 180.0, 100.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( None, plotx_core::state::SeriesId::new(1), diff --git a/crates/app/src/ui/canvas/painting.rs b/crates/app/src/ui/canvas/painting.rs index 67d89d6..567cb51 100644 --- a/crates/app/src/ui/canvas/painting.rs +++ b/crates/app/src/ui/canvas/painting.rs @@ -532,7 +532,7 @@ pub(crate) fn paint_document(app: &PlotxApp, ci: usize, rect: egui::Rect, painte }; let zoom = app.session.board.zoom; let bp = canvas.board_pos; - plotx_render::screen::paint_document( + plotx_render::screen::paint_document_for_editor( painter, PlotRect::new(rect.left(), rect.top(), rect.width(), rect.height()), &document, diff --git a/crates/app/src/ui/canvas/panel_notes.rs b/crates/app/src/ui/canvas/panel_notes.rs index 9486076..d44baa6 100644 --- a/crates/app/src/ui/canvas/panel_notes.rs +++ b/crates/app/src/ui/canvas/panel_notes.rs @@ -32,7 +32,7 @@ pub(crate) fn handle_panel_label_interactions( let id = ui.id().with(("panel_label", ci, object_id)); let resp = ui .interact(label_rect, id, Sense::click_and_drag()) - .on_hover_text("Double-click to edit this panel note"); + .on_hover_text("Double-click to edit this panel description"); let mut consumed = label_hovered || resp.hovered() || matches!(app.interaction(), Interaction::PanelLabel(_)); @@ -41,13 +41,11 @@ pub(crate) fn handle_panel_label_interactions( if matches!(app.interaction(), Interaction::Object(_)) { app.reset_interaction(); } - app.session.status = "Panel letter selected. Double-click to edit its note.".to_owned(); + app.session.status = + "Panel letter selected. Double-click to edit its description.".to_owned(); if let (Some(pointer), Some(panel)) = ( hover, - app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()), + app.doc.canvases[ci].panel_meta_for_content(object_id), ) { freeze_board_for_gesture(app); app.begin_interaction(Interaction::PanelLabel(PanelLabelDrag { @@ -67,7 +65,7 @@ pub(crate) fn handle_panel_label_interactions( resp.context_menu(|ui| { app.select_panel_label(ci, object_id); - if ui.button("Edit panel note").clicked() { + if ui.button("Edit panel description").clicked() { open_panel_note_editor(app, ci, object_id); ui.close(); } @@ -86,10 +84,7 @@ pub(crate) fn handle_panel_label_interactions( let zoom = app.session.board.zoom.max(0.01); let max_x = panel_label_max_x(app, ci, object_id); let max_y = panel_label_max_y(app, ci, object_id); - if let Some(panel) = app.doc.canvases[ci] - .object_mut(object_id) - .and_then(|object| object.plot_mut()) - .map(|plot| &mut plot.panel) + if let Some(mut panel) = app.doc.canvases[ci].panel_meta_for_content(object_id) && let Some(pointer) = hover { let delta = @@ -98,6 +93,7 @@ pub(crate) fn handle_panel_label_interactions( (drag.before.position[0] + delta.x).clamp(0.0, max_x), (drag.before.position[1] + delta.y).clamp(0.0, max_y), ]; + app.doc.canvases[ci].set_panel_meta_for_content(object_id, panel); app.mark_document_dirty(); } } @@ -112,24 +108,20 @@ pub(crate) fn handle_panel_label_interactions( pub(crate) fn panel_label_max_x(app: &PlotxApp, ci: usize, object_id: ObjectId) -> f32 { app.doc.canvases[ci] - .object(object_id) - .map(|object| object.frame.width) + .layout_frame(object_id) + .map(|frame| frame.width) .unwrap_or(1.0) } pub(crate) fn panel_label_max_y(app: &PlotxApp, ci: usize, object_id: ObjectId) -> f32 { app.doc.canvases[ci] - .object(object_id) - .map(|object| object.frame.height) + .layout_frame(object_id) + .map(|frame| frame.height) .unwrap_or(1.0) } pub(crate) fn open_panel_note_editor(app: &mut PlotxApp, ci: usize, object_id: ObjectId) { - let Some(panel) = app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()) - else { + let Some(panel) = app.doc.canvases[ci].panel_meta_for_content(object_id) else { return; }; app.select_panel_label(ci, object_id); @@ -143,11 +135,7 @@ pub(crate) fn open_panel_note_editor(app: &mut PlotxApp, ci: usize, object_id: O } pub(crate) fn hide_panel_label(app: &mut PlotxApp, ci: usize, object_id: ObjectId) { - let Some(before) = app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()) - else { + let Some(before) = app.doc.canvases[ci].panel_meta_for_content(object_id) else { return; }; let mut after = before.clone(); @@ -168,11 +156,7 @@ pub(crate) fn finish_panel_label_drag(app: &mut PlotxApp, ci: usize, object_id: if drag.canvas != ci || drag.object != object_id { return; } - let Some(after) = app.doc.canvases[ci] - .object(object_id) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()) - else { + let Some(after) = app.doc.canvases[ci].panel_meta_for_content(object_id) else { return; }; app.execute_action(Action::set_panel_meta(ci, object_id, drag.before, after)); @@ -185,8 +169,7 @@ pub(crate) fn panel_label_screen_rect( screen: egui::Rect, ) -> Option { let frame = object_screen_rect(board, canvas, object_id, screen)?; - let object = canvas.object(object_id)?; - let panel = &object.plot()?.panel; + let panel = canvas.panel_meta_for_content(object_id)?; if !panel.visible { return None; } diff --git a/crates/app/src/ui/canvas/snap.rs b/crates/app/src/ui/canvas/snap.rs index 5bafb86..4ae0b99 100644 --- a/crates/app/src/ui/canvas/snap.rs +++ b/crates/app/src/ui/canvas/snap.rs @@ -102,8 +102,11 @@ pub(crate) fn snap_object_frame( for object in &canvas.objects { let moving = object.id == drag.object || drag.others.iter().any(|(oid, _)| *oid == object.id); - if !moving && object.visible { - targets.push_object(object.frame); + if !moving + && object.visible + && let Some(frame) = canvas.layout_frame(object.id) + { + targets.push_object(frame); } } match drag.kind { diff --git a/crates/app/src/ui/canvas/tiling.rs b/crates/app/src/ui/canvas/tiling.rs index 8eff204..755a6f6 100644 --- a/crates/app/src/ui/canvas/tiling.rs +++ b/crates/app/src/ui/canvas/tiling.rs @@ -7,11 +7,11 @@ fn drag_is_tileable(app: &PlotxApp, drag: &ObjectDrag) -> bool { if app.session.ui.selection.objects().len() > 1 { return false; } - app.doc - .canvases - .get(drag.canvas) - .and_then(|c| c.object(drag.object)) - .is_some_and(|o| o.group.is_none() && o.plot().is_some()) + 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()) + }) } pub(crate) fn update_tile_drop( @@ -72,9 +72,7 @@ pub(crate) fn update_tile_drop( if let Some(preview) = app.session.ui.tile_drop.as_mut() { preview.pointer_screen = [p.x, p.y]; } - if let Some(object) = app.doc.canvases[drag.canvas].object_mut(drag.object) { - object.frame = drag.before; - } + app.doc.canvases[drag.canvas].set_layout_frame(drag.object, drag.before); return true; } let existing_items: Vec<_> = existing_ids @@ -110,9 +108,7 @@ pub(crate) fn update_tile_drop( } else { "Hold Alt to keep the empty source canvas.".into() }; - if let Some(object) = app.doc.canvases[drag.canvas].object_mut(drag.object) { - object.frame = drag.before; - } + app.doc.canvases[drag.canvas].set_layout_frame(drag.object, drag.before); true } @@ -143,7 +139,7 @@ fn layout_item(canvas: &CanvasDocument, id: ObjectId) -> Option = canvas.objects.iter().map(|o| (o.id, o.frame)).collect(); + let before: Vec<_> = canvas + .objects + .iter() + .filter_map(|object| { + canvas + .layout_frame(object.id) + .map(|frame| (object.id, frame)) + }) + .collect(); let after: Vec<_> = before .iter() .map(|&(id, f)| { diff --git a/crates/app/src/ui/commands_alignment_tests.rs b/crates/app/src/ui/commands_alignment_tests.rs index 2a30260..7170152 100644 --- a/crates/app/src/ui/commands_alignment_tests.rs +++ b/crates/app/src/ui/commands_alignment_tests.rs @@ -104,7 +104,6 @@ fn selected_non_plot_never_falls_back_to_an_unrelated_plot() { frame: ObjectFrame::new(0.0, 0.0, 120.0, 40.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label("Note".to_owned())), }); app.doc.canvases[0].selected_object = Some(text_id); diff --git a/crates/app/src/ui/object_inspector/edits.rs b/crates/app/src/ui/object_inspector/edits.rs index 6b17804..aac5620 100644 --- a/crates/app/src/ui/object_inspector/edits.rs +++ b/crates/app/src/ui/object_inspector/edits.rs @@ -104,7 +104,7 @@ pub(super) fn note_inspector_edit(app: &mut PlotxApp, ci: usize, ids: &[ObjectId }; let frames = ids .iter() - .filter_map(|&id| c.object(id).map(|o| (id, o.frame))) + .filter_map(|&id| c.layout_frame(id).map(|frame| (id, frame))) .collect(); app.session.ui.inspector_edit = Some(PendingInspectorEdit { canvas: ci, frames }); } @@ -130,11 +130,11 @@ pub(super) fn flush_inspector_edit(app: &mut PlotxApp, ui: &Ui, text_focused: bo let mut fb = Vec::new(); let mut fa = Vec::new(); for &(id, before) in &edit.frames { - if let Some(o) = c.object(id) - && o.frame != before + if let Some(after) = c.layout_frame(id) + && after != before { fb.push((id, before)); - fa.push((id, o.frame)); + fa.push((id, after)); } } (fb, fa) diff --git a/crates/app/src/ui/object_inspector/geometry.rs b/crates/app/src/ui/object_inspector/geometry.rs index f806522..0e84d1b 100644 --- a/crates/app/src/ui/object_inspector/geometry.rs +++ b/crates/app/src/ui/object_inspector/geometry.rs @@ -9,7 +9,9 @@ pub(super) fn geometry_section(app: &mut PlotxApp, ci: usize, ids: &[ObjectId], return; }; let enabled = !o.locked; - let frame = o.frame; + let Some(frame) = app.doc.canvases[ci].layout_frame(primary) else { + return; + }; let mut x = frame.x / MM_TO_PT; let mut y = frame.y / MM_TO_PT; let mut w = frame.width / MM_TO_PT; diff --git a/crates/app/src/ui/object_inspector_tests.rs b/crates/app/src/ui/object_inspector_tests.rs index 55afc11..a448eca 100644 --- a/crates/app/src/ui/object_inspector_tests.rs +++ b/crates/app/src/ui/object_inspector_tests.rs @@ -85,7 +85,6 @@ fn frame_drag_with_mid_gesture_catalog_style_write_keeps_two_independent_undo_re frame: ObjectFrame::new(1.0, 2.0, 30.0, 20.0), locked: false, visible: true, - group: None, kind: plotx_core::state::CanvasObjectKind::Shape(plotx_core::state::ShapeObject::new( plotx_core::state::ShapeKind::Rect, )), diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index cf9f04b..febb2b0 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -250,7 +250,10 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { } ui.weak(kind_glyph(&app.doc.canvases[ci].objects[oi].kind)) .on_hover_text(kind_label(&app.doc.canvases[ci].objects[oi].kind)); - if app.doc.canvases[ci].objects[oi].group.is_some() { + if app.doc.canvases[ci] + .content_group(app.doc.canvases[ci].objects[oi].id) + .is_some() + { ui.weak(egui::RichText::new("⛓").small()) .on_hover_text("Grouped"); } @@ -346,7 +349,7 @@ fn kind_glyph(kind: &CanvasObjectKind) -> &'static str { CanvasObjectKind::Plot(_) => icon::CHART_LINE, CanvasObjectKind::Text(_) => "T", CanvasObjectKind::Shape(_) => icon::SHAPES, - CanvasObjectKind::PanelLabel(_) => icon::TAG, + CanvasObjectKind::RasterImage(_) => icon::FILE, } } @@ -355,7 +358,7 @@ fn kind_label(kind: &CanvasObjectKind) -> &'static str { CanvasObjectKind::Plot(_) => "Plot", CanvasObjectKind::Text(_) => "Text", CanvasObjectKind::Shape(_) => "Shape", - CanvasObjectKind::PanelLabel(_) => "Panel label", + CanvasObjectKind::RasterImage(_) => "Image", } } diff --git a/crates/app/src/ui/properties/control_tests.rs b/crates/app/src/ui/properties/control_tests.rs index a645c45..dd5f8cb 100644 --- a/crates/app/src/ui/properties/control_tests.rs +++ b/crates/app/src/ui/properties/control_tests.rs @@ -72,7 +72,6 @@ fn continuous_text_box_input_commits_one_undo_record() { frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label(String::new())), }); app.doc.canvases.push(canvas); diff --git a/crates/app/src/ui/properties/mod.rs b/crates/app/src/ui/properties/mod.rs index cb46b36..9ea15e3 100644 --- a/crates/app/src/ui/properties/mod.rs +++ b/crates/app/src/ui/properties/mod.rs @@ -48,7 +48,7 @@ pub const PRESENTATIONS: &[PropertyPresentation] = &[ object_entry(object::CHART_COLORMAP, "Colormap", CHART_HOME), object_entry(object::CHART_VIEW_AZIMUTH, "Azimuth", CHART_HOME), object_entry(object::CHART_VIEW_ELEVATION, "Elevation", CHART_HOME), - object_entry(object::PANEL_USER_NOTE, "Note", PANEL_HOME), + object_entry(object::PANEL_USER_NOTE, "Description", PANEL_HOME), object_entry(object::PANEL_VISIBLE, "Show letter", PANEL_HOME), object_entry(object::TEXT, "Text", TEXT_HOME), object_entry(object::TEXT_FONT_SIZE, "Size", TEXT_HOME), diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index de6914b..f8cb21c 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -505,9 +505,7 @@ pub(super) fn handle_delete_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { .doc .canvases .get(ci) - .and_then(|canvas| canvas.object(object_id)) - .and_then(|object| object.plot()) - .map(|plot| plot.panel.clone()) + .and_then(|canvas| canvas.panel_meta_for_content(object_id)) else { app.session.ui.panel_label_selection = None; return; diff --git a/crates/app/src/ui/tools/task_card.rs b/crates/app/src/ui/tools/task_card.rs index 48a767a..5a0e795 100644 --- a/crates/app/src/ui/tools/task_card.rs +++ b/crates/app/src/ui/tools/task_card.rs @@ -125,7 +125,11 @@ pub(super) fn area(host: &Ui, id: Id, pos: Pos2) -> Area { if let Some(stored) = stored { area.current_pos(stored) } else { - area.default_pos(pos) + // `default_pos` only applies when egui first creates the Area. The + // central workspace can move when sidebars or workflow chrome change, + // so an untouched task card must be re-anchored to the current host on + // every frame. A user drag stores an explicit position above. + area.current_pos(pos) } } diff --git a/crates/app/src/ui/windows/canvas_settings.rs b/crates/app/src/ui/windows/canvas_settings.rs index d590f69..c7cdd38 100644 --- a/crates/app/src/ui/windows/canvas_settings.rs +++ b/crates/app/src/ui/windows/canvas_settings.rs @@ -112,11 +112,7 @@ fn panels_section(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { } for (i, id) in order.into_iter().enumerate() { let letter = app.doc.canvases[ci].panel_label_style.format(i); - let Some(_panel) = app.doc.canvases[ci] - .object(id) - .and_then(|o| o.plot()) - .map(|p| p.panel.clone()) - else { + let Some(_panel) = app.doc.canvases[ci].panel_meta_for_content(id) else { continue; }; ui.horizontal(|ui| { @@ -136,9 +132,7 @@ fn commit_note_edit(app: &mut PlotxApp) { .doc .canvases .get(ci) - .and_then(|c| c.object(id)) - .and_then(|o| o.plot()) - .map(|p| p.panel.clone()) + .and_then(|c| c.panel_meta_for_content(id)) else { return; }; diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs index 765b3cb..8b65e19 100644 --- a/crates/core/src/actions/app_impl/apply.rs +++ b/crates/core/src/actions/app_impl/apply.rs @@ -16,6 +16,9 @@ impl PlotxApp { }; } match action { + Action::ReplacePanelState { canvas, after, .. } => { + self.set_panel_state(*canvas, after); + } Action::Composite(actions) => { for (index, action) in actions.iter().enumerate() { if let Err(error) = self.apply_action(action) { @@ -321,7 +324,7 @@ impl PlotxApp { }; let page = canvas.size_pt(); let offset = 18.0 * canvas.objects.len() as f32; - let object_name = format!("Plot {}", canvas.objects.len() + 1); + let object_name = crate::workflow::dataset_title(dataset.as_ref()); let frame = ObjectFrame::new( 24.0 + offset, 24.0 + offset, @@ -333,6 +336,9 @@ impl PlotxApp { let canvas = self.doc.canvases.get_mut(*ci).unwrap(); canvas.next_object_id = canvas.next_object_id.max(id.checked_advance(1)); canvas.objects.push(object); + canvas + .create_panel_for_plot(id) + .expect("the inserted dataset object is a plot"); self.session.active_canvas = Some(*ci); } else { if *canvas_index != self.doc.canvases.len() { diff --git a/crates/core/src/actions/app_impl/meta_edits.rs b/crates/core/src/actions/app_impl/meta_edits.rs index 1751339..28c4fc0 100644 --- a/crates/core/src/actions/app_impl/meta_edits.rs +++ b/crates/core/src/actions/app_impl/meta_edits.rs @@ -47,15 +47,18 @@ impl PlotxApp { } pub(super) fn set_panel_meta(&mut self, canvas: usize, object: ObjectId, panel: PanelMeta) { - let Some(plot) = self - .doc - .canvases - .get_mut(canvas) - .and_then(|canvas| canvas.object_mut(object)) - .and_then(|object| object.plot_mut()) - else { + let Some(page) = self.doc.canvases.get_mut(canvas) else { + return; + }; + let Some(panel_id) = page.parent_panel(object) else { + return; + }; + let Some(target) = page.panel_mut(panel_id) else { return; }; - plot.panel = panel; + target.note = panel.user_note; + target.label.position = panel.position; + target.label.font_size = panel.font_size; + target.label.visible = panel.visible; } } diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index e1d91ee..7a71550 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -10,6 +10,17 @@ pub use validate::ActionApplyError; use validate::{ValidationShape, validate_action}; impl PlotxApp { + fn set_panel_state(&mut self, canvas: usize, state: &PanelState) { + if let Some(canvas) = self.doc.canvases.get_mut(canvas) { + canvas.objects.clone_from(&state.objects); + canvas.panels.clone_from(&state.panels); + canvas.groups.clone_from(&state.groups); + canvas.next_object_id = state.next_object_id; + canvas.next_group_id = state.next_group_id; + canvas.next_panel_label_slot = state.next_panel_label_slot; + } + } + pub fn execute_action(&mut self, action: Action) { self.finish_series_presentation_edit(); self.finish_axis_overrides_edit(); @@ -147,15 +158,13 @@ impl PlotxApp { } } pub fn set_object_frame(&mut self, canvas: usize, object: ObjectId, frame: ObjectFrame) { - let Some(o) = self - .doc - .canvases - .get_mut(canvas) - .and_then(|canvas| canvas.object_mut(object)) - else { + let Some(page) = self.doc.canvases.get_mut(canvas) else { return; }; - o.frame = frame; + if !page.set_layout_frame(object, frame) { + return; + } + let Some(o) = page.object(object) else { return }; if let Some(plot) = o.plot() { let owner = plot.display_owner; let binding = plot.binding.clone(); @@ -179,11 +188,7 @@ impl PlotxApp { let Some(c) = self.doc.canvases.get_mut(canvas) else { return; }; - for &(id, group) in groups { - if let Some(object) = c.object_mut(id) { - object.group = group; - } - } + c.apply_content_group_assignments(groups); } fn reorder_objects_value(&mut self, canvas: usize, order: &[ObjectId]) { @@ -288,13 +293,34 @@ impl PlotxApp { let id = object.id; if let Some(c) = self.doc.canvases.get_mut(canvas) { c.next_object_id = c.next_object_id.max(id.checked_advance(1)); + let is_plot = object.plot().is_some(); c.objects.push(object); + if is_plot { + c.create_panel_for_plot(id) + .expect("the inserted object was checked as a plot"); + } } self.select_object(canvas, id); } pub(super) fn remove_object_value(&mut self, canvas: usize, id: ObjectId) { if let Some(c) = self.doc.canvases.get_mut(canvas) { + if let Some(panel) = c.parent_panel(id) { + let remove_panel = c + .panel(panel) + .is_some_and(|candidate| candidate.item_order.len() == 1); + if remove_panel { + c.panels.retain(|candidate| candidate.id != panel); + c.groups.retain_mut(|group| { + group.members.retain(|member| { + !matches!(member, crate::state::GroupMember::Panel(candidate) if *candidate == panel) + }); + group.members.len() >= 2 + }); + } else if let Some(panel) = c.panel_mut(panel) { + panel.item_order.retain(|candidate| *candidate != id); + } + } c.objects.retain(|o| o.id != id); if c.selected_object == Some(id) { c.selected_object = None; diff --git a/crates/core/src/actions/app_impl/revert.rs b/crates/core/src/actions/app_impl/revert.rs index 4509732..bb22af5 100644 --- a/crates/core/src/actions/app_impl/revert.rs +++ b/crates/core/src/actions/app_impl/revert.rs @@ -12,6 +12,9 @@ impl PlotxApp { }; } match action { + Action::ReplacePanelState { canvas, before, .. } => { + self.set_panel_state(*canvas, before); + } Action::Composite(actions) => { for action in actions.iter().rev() { self.revert_action(action); @@ -348,6 +351,15 @@ impl PlotxApp { if let Some(canvas) = self.doc.canvases.get_mut(*ci) && let Some(id) = inserted_object_id { + if let Some(panel) = canvas.parent_panel(*id) { + canvas.panels.retain(|candidate| candidate.id != panel); + canvas.groups.retain_mut(|group| { + group.members.retain(|member| { + !matches!(member, crate::state::GroupMember::Panel(candidate) if *candidate == panel) + }); + group.members.len() >= 2 + }); + } canvas.objects.retain(|object| object.id != *id); if canvas.selected_object == Some(*id) { canvas.selected_object = None; diff --git a/crates/core/src/actions/app_impl/validate.rs b/crates/core/src/actions/app_impl/validate.rs index d65759e..135798f 100644 --- a/crates/core/src/actions/app_impl/validate.rs +++ b/crates/core/src/actions/app_impl/validate.rs @@ -39,6 +39,41 @@ pub(super) fn validate_action( shape: &mut ValidationShape, ) -> Result<(), ActionApplyError> { match action { + Action::ReplacePanelState { + canvas, + before, + after, + } => { + if *canvas >= shape.canvases { + return Err(ActionApplyError::StaleTarget(format!("canvas {canvas}"))); + } + for (label, state) in [("before", before), ("after", after)] { + crate::state::validate_panel_structure( + &state.panels, + state.objects.iter().map(|item| item.id), + &state.groups, + ) + .map_err(|error| { + ActionApplyError::InvalidValue(format!("{label} panel state: {error}")) + })?; + for item in &state.objects { + crate::state::validate_frame(item.frame, "content") + .map_err(ActionApplyError::InvalidValue)?; + } + } + } + Action::SetObjectGroups { canvas, after, .. } => { + let Some(mut projected) = app.doc.canvases.get(*canvas).cloned() else { + return Err(ActionApplyError::StaleTarget(format!("canvas {canvas}"))); + }; + if after.iter().any(|(id, _)| projected.object(*id).is_none()) { + return Err(ActionApplyError::StaleTarget("group content".to_owned())); + } + projected.apply_content_group_assignments(after); + projected + .validate_structure() + .map_err(ActionApplyError::InvalidValue)?; + } Action::Composite(actions) => { for child in actions { validate_action(app, child, shape)?; diff --git a/crates/core/src/actions/arrange.rs b/crates/core/src/actions/arrange.rs index a8e8743..28e7ec5 100644 --- a/crates/core/src/actions/arrange.rs +++ b/crates/core/src/actions/arrange.rs @@ -40,7 +40,7 @@ impl PlotxApp { let after = crate::layout::arrange_grid(page, &after_layout, &refined_items); let before: Vec<(ObjectId, ObjectFrame)> = after .iter() - .filter_map(|(id, _)| canvas.object(*id).map(|o| (*id, o.frame))) + .filter_map(|(id, _)| canvas.layout_frame(*id).map(|frame| (*id, frame))) .collect(); let placed = after.len(); let total = ids.len(); @@ -80,7 +80,11 @@ impl PlotxApp { .objects .iter() .filter(|object| object.plot().is_some()) - .map(|object| (object.id, object.frame)) + .filter_map(|object| { + canvas + .layout_frame(object.id) + .map(|frame| (object.id, frame)) + }) .collect(); if frames.len() < 2 { self.session.status = @@ -151,7 +155,7 @@ impl PlotxApp { .iter() .filter_map(|&id| c.object(id)) .filter(|o| !o.locked) - .map(|o| (o.id, o.frame)) + .filter_map(|o| c.layout_frame(o.id).map(|frame| (o.id, frame))) .collect() } @@ -195,7 +199,11 @@ impl PlotxApp { let group = self.doc.canvases[ci].allocate_group_id(); let before: Vec<(ObjectId, Option)> = ids .iter() - .filter_map(|&id| self.doc.canvases[ci].object(id).map(|o| (id, o.group))) + .filter_map(|&id| { + self.doc.canvases[ci] + .object(id) + .map(|_| (id, self.doc.canvases[ci].content_group(id))) + }) .collect(); let after: Vec<(ObjectId, Option)> = ids.iter().map(|&id| (id, Some(group))).collect(); @@ -218,7 +226,11 @@ impl PlotxApp { } let before: Vec<(ObjectId, Option)> = ids .iter() - .filter_map(|&id| self.doc.canvases[ci].object(id).map(|o| (id, o.group))) + .filter_map(|&id| { + self.doc.canvases[ci] + .object(id) + .map(|_| (id, self.doc.canvases[ci].content_group(id))) + }) .filter(|(_, g)| g.is_some()) .collect(); if before.is_empty() { @@ -312,7 +324,7 @@ fn layout_items( let frame = frames .iter() .find_map(|(candidate, frame)| (*candidate == id).then_some(*frame)) - .unwrap_or(object.frame); + .or_else(|| canvas.layout_frame(id))?; if let Some(change) = axis_changes.iter().find(|change| change.id == id) { let mut figure = plot.figure().clone(); change.after.apply_to(&mut figure); diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 634c635..a811f75 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -2,10 +2,10 @@ use crate::layout::PageLayout; use crate::state::{ AxisOverrides, AxisProjections, CanvasDocument, CanvasObject, CanvasViewport, ChartSpec, CurveFitReference, DataBinding, Dataset, DatasetId, ExtractedIonChromatogram, - ExtractedMassSpectrum, ExtractionId, IonChromatogramId, NamedView, ObjectFrame, ObjectId, - ObjectStyle, PanelLabelStyle, PanelMeta, PlotxApp, PrimaryView, Region, Selection, StackSpec, - StatAnalysis, StoredCurveFitAnalysis, StoredLineFit, StoredMultiplet, TableEditDelta, TextBox, - TypedTableState, + 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}; @@ -15,13 +15,18 @@ use plotx_processing::{AxisPipeline, Params2D, Preset2D}; mod app_impl; mod arrange; +mod panel; mod processing_state; mod transfer; mod zorder; +pub use panel::PanelActionError; pub use processing_state::{ProcessingRebuild, ProcessingStateError}; pub use zorder::*; +#[cfg(test)] +#[path = "tests/panel.rs"] +mod panel_tests; #[cfg(test)] mod tests; @@ -165,9 +170,37 @@ pub struct PendingSeriesPresentationEdit { pub before: DataBinding, } +#[derive(Clone)] +pub struct PanelState { + pub objects: Vec, + pub panels: Vec, + pub groups: Vec, + pub next_object_id: ObjectId, + pub next_group_id: crate::state::GroupId, + pub next_panel_label_slot: u64, +} + +impl PanelState { + pub fn of(canvas: &CanvasDocument) -> Self { + Self { + objects: canvas.objects.clone(), + panels: canvas.panels.clone(), + groups: canvas.groups.clone(), + next_object_id: canvas.next_object_id, + next_group_id: canvas.next_group_id, + next_panel_label_slot: canvas.next_panel_label_slot, + } + } +} + #[derive(Clone)] pub enum Action { Composite(Vec), + ReplacePanelState { + canvas: usize, + before: PanelState, + after: PanelState, + }, UpdateDatasetProcessing { dataset: DatasetId, before: DatasetProcessingState, @@ -486,6 +519,18 @@ pub enum Action { to: usize, removed: Vec<(usize, CanvasObject)>, inserted: Vec, + source_groups_before: Vec, + source_groups_after: Vec, + target_groups_before: Vec, + target_groups_after: Vec, + source_panels_before: Vec, + source_panels_after: Vec, + target_panels_before: Vec, + target_panels_after: Vec, + source_label_slot_before: u64, + source_label_slot_after: u64, + target_label_slot_before: u64, + target_label_slot_after: u64, active_before: Option, selection_before: Selection, }, @@ -501,6 +546,18 @@ pub enum Action { source_canvas_before: Option>, removed: Vec<(usize, CanvasObject)>, inserted: Vec, + source_groups_before: Vec, + source_groups_after: Vec, + target_groups_before: Vec, + target_groups_after: Vec, + source_panels_before: Vec, + source_panels_after: Vec, + target_panels_before: Vec, + target_panels_after: Vec, + source_label_slot_before: u64, + source_label_slot_after: u64, + target_label_slot_before: u64, + target_label_slot_after: u64, existing_before: Vec<(ObjectId, ObjectFrame)>, existing_after: Vec<(ObjectId, ObjectFrame)>, active_before: Option, @@ -513,6 +570,7 @@ mod build; impl Action { pub fn undo_label(&self) -> &'static str { match self { + Self::ReplacePanelState { .. } => "edit panel structure", Self::Composite(actions) => actions .iter() .find(|action| !action.is_noop()) @@ -539,6 +597,7 @@ impl Action { fn is_noop(&self) -> bool { match self { Self::Composite(actions) => actions.iter().all(Self::is_noop), + Self::ReplacePanelState { .. } => false, Self::UpdateDatasetProcessing { before, after, .. } => before == after, Self::SetObjectViewport { before, after, .. } => before == after, Self::SetMassSpecStream { before, after, .. } => before == after, diff --git a/crates/core/src/actions/panel.rs b/crates/core/src/actions/panel.rs new file mode 100644 index 0000000..92e1017 --- /dev/null +++ b/crates/core/src/actions/panel.rs @@ -0,0 +1,481 @@ +use super::{Action, PanelState}; +use crate::state::{ + ContentId, GroupMember, LayoutGroup, ObjectFrame, Panel, PanelId, PanelLabelMode, PlotxApp, +}; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum PanelActionError { + #[error("canvas {0} does not exist")] + MissingCanvas(usize), + #[error("panel {0} does not exist")] + MissingPanel(PanelId), + #[error("content {0} does not exist")] + MissingContent(ContentId), + #[error("content {0} is already assigned to a panel")] + AlreadyAssigned(ContentId), + #[error("at least one content item is required")] + EmptySelection, + #[error("panel operation is invalid: {0}")] + Invalid(String), +} + +impl PlotxApp { + pub fn create_panel_action( + &self, + canvas: usize, + name: String, + frame: ObjectFrame, + ) -> Result<(PanelId, Action), PanelActionError> { + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + let id = page.create_panel(name, frame); + page.validate_structure() + .map_err(PanelActionError::Invalid)?; + Ok(( + id, + Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }, + )) + } + + pub fn compose_panel_action( + &self, + canvas: usize, + name: String, + contents: &[ContentId], + padding: f32, + ) -> Result<(PanelId, Action), PanelActionError> { + if contents.is_empty() { + return Err(PanelActionError::EmptySelection); + } + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + for &content in contents { + if page.object(content).is_none() { + return Err(PanelActionError::MissingContent(content)); + } + if page.parent_panel(content).is_some() { + return Err(PanelActionError::AlreadyAssigned(content)); + } + } + let frame = bounds( + contents + .iter() + .filter_map(|id| page.object(*id).map(|v| v.frame)), + ) + .ok_or(PanelActionError::EmptySelection)?; + let frame = ObjectFrame::new( + frame.x - padding, + frame.y - padding, + frame.width + padding * 2.0, + frame.height + padding * 2.0, + ); + let id = page.create_panel(name, frame); + for &content in contents { + let item = page + .object_mut(content) + .ok_or(PanelActionError::MissingContent(content))?; + item.frame.x -= frame.x; + item.frame.y -= frame.y; + } + page.panel_mut(id).expect("new panel exists").item_order = contents.to_vec(); + page.reconcile_content_group_scopes(); + page.validate_structure() + .map_err(PanelActionError::Invalid)?; + Ok(( + id, + Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }, + )) + } + + pub fn dissolve_panel_action( + &self, + canvas: usize, + panel: PanelId, + ) -> Result { + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + let index = page + .panels + .iter() + .position(|value| value.id == panel) + .ok_or(PanelActionError::MissingPanel(panel))?; + let removed = page.panels.remove(index); + for content in removed.item_order { + let item = page + .object_mut(content) + .ok_or(PanelActionError::MissingContent(content))?; + item.frame.x += removed.frame.x; + item.frame.y += removed.frame.y; + } + page.groups.retain_mut(|group| { + group + .members + .retain(|member| *member != crate::state::GroupMember::Panel(panel)); + group.members.len() > 1 + }); + page.reconcile_content_group_scopes(); + Ok(Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }) + } + + pub fn delete_panel_action( + &self, + canvas: usize, + panel: PanelId, + ) -> Result { + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + let index = page + .panels + .iter() + .position(|value| value.id == panel) + .ok_or(PanelActionError::MissingPanel(panel))?; + let removed = page.panels.remove(index); + page.objects + .retain(|item| !removed.item_order.contains(&item.id)); + page.groups.retain_mut(|group| { + group.members.retain(|member| match member { + crate::state::GroupMember::Panel(id) => *id != panel, + crate::state::GroupMember::Content(id) => !removed.item_order.contains(id), + }); + group.members.len() > 1 + }); + Ok(Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }) + } + + pub fn move_content_to_panel_action( + &self, + canvas: usize, + content: ContentId, + target: Option, + target_index: usize, + ) -> Result { + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + let page_frame = page + .content_page_frame(content) + .ok_or(PanelActionError::MissingContent(content))?; + for panel in &mut page.panels { + panel.item_order.retain(|id| *id != content); + } + let target_frame = target + .map(|id| { + page.panel(id) + .map(|v| v.frame) + .ok_or(PanelActionError::MissingPanel(id)) + }) + .transpose()?; + let item = page + .object_mut(content) + .ok_or(PanelActionError::MissingContent(content))?; + item.frame = match target_frame { + Some(panel) => ObjectFrame { + x: page_frame.x - panel.x, + y: page_frame.y - panel.y, + ..page_frame + }, + None => page_frame, + }; + if let Some(target) = target { + let order = &mut page + .panel_mut(target) + .ok_or(PanelActionError::MissingPanel(target))? + .item_order; + order.insert(target_index.min(order.len()), content); + } + page.reconcile_content_group_scopes(); + page.validate_structure() + .map_err(PanelActionError::Invalid)?; + Ok(Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }) + } + + pub fn split_panel_action( + &self, + canvas: usize, + source: PanelId, + contents: &[ContentId], + name: String, + ) -> Result<(PanelId, Action), PanelActionError> { + if contents.is_empty() { + return Err(PanelActionError::EmptySelection); + } + let mut page = self + .doc + .canvases + .get(canvas) + .cloned() + .ok_or(PanelActionError::MissingCanvas(canvas))?; + let source_panel = page + .panel(source) + .cloned() + .ok_or(PanelActionError::MissingPanel(source))?; + if contents + .iter() + .any(|id| !source_panel.item_order.contains(id)) + { + return Err(PanelActionError::Invalid( + "split content must belong to the source panel".to_owned(), + )); + } + let page_frames: Vec<_> = contents + .iter() + .map(|id| { + page.content_page_frame(*id) + .ok_or(PanelActionError::MissingContent(*id)) + }) + .collect::>()?; + let frame = bounds(page_frames).ok_or(PanelActionError::EmptySelection)?; + let before = PanelState::of(&page); + page.panel_mut(source) + .expect("source checked") + .item_order + .retain(|id| !contents.contains(id)); + let new_id = page.create_panel(name, frame); + page.panel_mut(new_id).expect("new panel exists").item_order = contents.to_vec(); + for &id in contents { + let absolute = ObjectFrame { + x: source_panel.frame.x + page.object(id).expect("checked").frame.x, + y: source_panel.frame.y + page.object(id).expect("checked").frame.y, + ..page.object(id).expect("checked").frame + }; + let item = page.object_mut(id).expect("checked"); + item.frame = ObjectFrame { + x: absolute.x - frame.x, + y: absolute.y - frame.y, + ..absolute + }; + } + page.reconcile_content_group_scopes(); + page.validate_structure() + .map_err(PanelActionError::Invalid)?; + Ok(( + new_id, + Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }, + )) + } + + pub fn merge_panels_action( + &self, + canvas: usize, + primary: PanelId, + others: &[PanelId], + ) -> Result { + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + let mut ids = vec![primary]; + ids.extend_from_slice(others); + ids.sort(); + ids.dedup(); + let selected: Vec = ids + .iter() + .map(|id| { + page.panel(*id) + .cloned() + .ok_or(PanelActionError::MissingPanel(*id)) + }) + .collect::>()?; + if selected.len() < 2 { + return Err(PanelActionError::Invalid( + "merge requires two panels".to_owned(), + )); + } + let frame = bounds(selected.iter().map(|panel| panel.frame)).expect("non-empty"); + let mut ordered: Vec<_> = selected + .iter() + .flat_map(|panel| { + panel + .item_order + .iter() + .copied() + .map(move |content| (content, panel.frame)) + }) + .collect(); + ordered.sort_by_key(|(content, _)| { + page.objects + .iter() + .position(|item| item.id == *content) + .unwrap_or(usize::MAX) + }); + for (content, old_panel) in &ordered { + let item = page + .object_mut(*content) + .ok_or(PanelActionError::MissingContent(*content))?; + item.frame.x += old_panel.x - frame.x; + item.frame.y += old_panel.y - frame.y; + } + let primary_index = page + .panels + .iter() + .position(|panel| panel.id == primary) + .ok_or(PanelActionError::MissingPanel(primary))?; + page.panels[primary_index].frame = frame; + page.panels[primary_index].item_order = ordered.iter().map(|(id, _)| *id).collect(); + let notes: Vec<_> = selected + .iter() + .filter(|panel| panel.id != primary) + .map(|panel| panel.note.trim()) + .filter(|note| !note.is_empty()) + .collect(); + if !notes.is_empty() { + page.panels[primary_index] + .note + .push_str(&format!("\n\n{}", notes.join("\n\n"))); + } + page.panels + .retain(|panel| panel.id == primary || !ids.contains(&panel.id)); + let affected_group_ids: Vec<_> = page + .groups + .iter() + .filter(|group| { + group + .members + .iter() + .any(|member| matches!(member, GroupMember::Panel(id) if ids.contains(id))) + }) + .map(|group| group.id) + .collect(); + if let Some(&group_id) = affected_group_ids.first() { + let mut members = std::collections::BTreeSet::new(); + for group in page + .groups + .iter() + .filter(|group| affected_group_ids.contains(&group.id)) + { + for member in &group.members { + members.insert(match member { + GroupMember::Panel(id) if ids.contains(id) => GroupMember::Panel(primary), + member => *member, + }); + } + } + page.groups + .retain(|group| !affected_group_ids.contains(&group.id)); + if members.len() >= 2 { + page.groups.push(LayoutGroup { + id: group_id, + members: members.into_iter().collect(), + }); + } + } + page.reconcile_content_group_scopes(); + page.validate_structure() + .map_err(PanelActionError::Invalid)?; + Ok(Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }) + } + + pub fn duplicate_panel_action( + &self, + canvas: usize, + panel: PanelId, + offset: [f32; 2], + ) -> Result<(PanelId, Action), PanelActionError> { + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + let source = page + .panel(panel) + .cloned() + .ok_or(PanelActionError::MissingPanel(panel))?; + let slot = page.next_panel_label_slot; + page.next_panel_label_slot = slot.saturating_add(1); + let mut copy = source.clone(); + copy.id = PanelId::new(); + copy.frame.x += offset[0]; + copy.frame.y += offset[1]; + copy.label.mode = PanelLabelMode::Auto { slot }; + copy.item_order.clear(); + for id in source.item_order { + let mut item = page + .object(id) + .cloned() + .ok_or(PanelActionError::MissingContent(id))?; + item.id = page.allocate_object_id(); + copy.item_order.push(item.id); + page.objects.push(item); + } + let id = copy.id; + page.panels.push(copy); + Ok(( + id, + Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }, + )) + } + + pub fn reorder_panel_labels_action(&self, canvas: usize) -> Result { + let before = self.panel_state(canvas)?; + let mut page = self.doc.canvases[canvas].clone(); + let order = page.panel_reading_order(); + let mut slot = 0_u64; + for id in order { + let panel = page + .panel_mut(id) + .ok_or(PanelActionError::MissingPanel(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); + } + } + page.next_panel_label_slot = page.next_panel_label_slot.max(slot); + Ok(Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }) + } + + fn panel_state(&self, canvas: usize) -> Result { + self.doc + .canvases + .get(canvas) + .map(PanelState::of) + .ok_or(PanelActionError::MissingCanvas(canvas)) + } +} + +fn bounds(frames: impl IntoIterator) -> Option { + let mut frames = frames.into_iter(); + let first = frames.next()?; + let mut x0 = first.x; + let mut y0 = first.y; + let mut x1 = first.x + first.width; + let mut y1 = first.y + first.height; + for frame in frames { + x0 = x0.min(frame.x); + y0 = y0.min(frame.y); + x1 = x1.max(frame.x + frame.width); + y1 = y1.max(frame.y + frame.height); + } + Some(ObjectFrame::new(x0, y0, x1 - x0, y1 - y0)) +} diff --git a/crates/core/src/actions/tests/interaction.rs b/crates/core/src/actions/tests/interaction.rs index 439b076..87752d6 100644 --- a/crates/core/src/actions/tests/interaction.rs +++ b/crates/core/src/actions/tests/interaction.rs @@ -33,7 +33,7 @@ fn gesture_active_covers_only_the_board_freezing_drags() { 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 = first_plot(&app).panel.clone(); + let title = crate::state::PanelMeta::new("title".to_owned(), frame.width); let cases: [(Interaction, bool); 11] = [ (Interaction::Idle, false), diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index 6c31c3f..c060a08 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -140,6 +140,7 @@ fn insert_dataset_existing_canvas_does_not_select_inserted_object() { let inserted_id = app.doc.canvases[0].next_object_id; let dataset_index = app.doc.datasets.len(); let dataset = Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d()))); + let expected_note = crate::workflow::dataset_title(&dataset); app.execute_action(Action::InsertDatasetWithCanvas { dataset_index, @@ -156,6 +157,11 @@ fn insert_dataset_existing_canvas_does_not_select_inserted_object() { assert_eq!(app.doc.canvases[0].objects.len(), 2); assert_eq!(app.doc.canvases[0].selected_object, None); + let panel = app.doc.canvases[0].parent_panel(inserted_id).unwrap(); + assert_eq!( + app.doc.canvases[0].panel(panel).unwrap().note, + expected_note + ); app.doc.canvases[0].selected_object = Some(inserted_id); app.undo(); @@ -412,31 +418,6 @@ fn preset_only_change_at_equal_size_is_undoable() { ); } -#[test] -fn plot_title_undo_redo_restores_text_position_and_visibility() { - let mut app = sample_app(); - let object_id = app.doc.canvases[0].objects[0].id; - let before = first_plot(&app).panel.clone(); - let mut after = before.clone(); - after.user_note = "edited title".to_owned(); - after.position = [42.0, 12.0]; - after.visible = false; - - app.execute_action(Action::set_panel_meta( - 0, - object_id, - before.clone(), - after.clone(), - )); - assert_eq!(first_plot(&app).panel, after); - - app.undo(); - assert_eq!(first_plot(&app).panel, before); - - app.redo(); - assert_eq!(first_plot(&app).panel, after); -} - #[test] fn page_view_zoom_pan_does_not_change_svg_or_object_geometry() { let mut app = sample_app(); @@ -610,7 +591,6 @@ pub(super) fn push_text_object( frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label(text.to_owned())), }); id diff --git a/crates/core/src/actions/tests/panel.rs b/crates/core/src/actions/tests/panel.rs new file mode 100644 index 0000000..9c81334 --- /dev/null +++ b/crates/core/src/actions/tests/panel.rs @@ -0,0 +1,198 @@ +use crate::state::{ + CanvasDocument, ContentId, ContentItem, ContentKind, GroupMember, LayoutGroup, ObjectFrame, + PanelLabelMode, PlotxApp, TextBox, +}; + +fn app() -> (PlotxApp, [ContentId; 3]) { + let mut app = PlotxApp::default(); + let mut page = CanvasDocument::new("Figure".to_owned(), [100.0, 100.0]); + let ids = [ + page.allocate_object_id(), + page.allocate_object_id(), + page.allocate_object_id(), + ]; + for (index, id) in ids.into_iter().enumerate() { + page.objects.push(ContentItem { + id, + name: format!("item {index}"), + frame: ObjectFrame::new(index as f32 * 20.0, 10.0, 10.0, 10.0), + locked: false, + visible: true, + kind: ContentKind::Text(TextBox::label("x".to_owned())), + }); + } + app.doc.canvases.push(page); + (app, ids) +} + +fn signature(app: &PlotxApp) -> String { + let page = &app.doc.canvases[0]; + format!( + "{:?}|{:?}|{:?}", + page.panels + .iter() + .map(|p| (p.id, p.frame, p.item_order.clone(), p.label.clone())) + .collect::>(), + page.objects + .iter() + .map(|i| (i.id, i.frame)) + .collect::>(), + page.groups + ) +} + +fn cycle(app: &mut PlotxApp, action: crate::actions::Action) { + let before = signature(app); + app.try_execute_action(action).unwrap(); + let after = signature(app); + assert_ne!(before, after); + app.undo(); + assert_eq!(signature(app), before); + app.redo(); + assert_eq!(signature(app), after); +} + +#[test] +fn create_compose_dissolve_delete_round_trip() { + let (mut app, ids) = app(); + let (_, action) = app + .create_panel_action( + 0, + "empty".to_owned(), + ObjectFrame::new(0.0, 0.0, 20.0, 20.0), + ) + .unwrap(); + cycle(&mut app, action); + let (_, action) = app + .compose_panel_action(0, "a".to_owned(), &ids[..2], 2.0) + .unwrap(); + cycle(&mut app, action); + let panel = app.doc.canvases[0].parent_panel(ids[0]).unwrap(); + let action = app.dissolve_panel_action(0, panel).unwrap(); + cycle(&mut app, action); + let (_, action) = app + .compose_panel_action(0, "b".to_owned(), &ids[..2], 0.0) + .unwrap(); + app.try_execute_action(action).unwrap(); + let panel = app.doc.canvases[0].parent_panel(ids[0]).unwrap(); + let action = app.delete_panel_action(0, panel).unwrap(); + cycle(&mut app, action); +} + +#[test] +fn move_split_merge_and_duplicate_round_trip() { + let (mut app, ids) = app(); + let (a, action) = app + .compose_panel_action(0, "a".to_owned(), &ids[..2], 0.0) + .unwrap(); + app.try_execute_action(action).unwrap(); + let (b, action) = app + .create_panel_action(0, "b".to_owned(), ObjectFrame::new(60.0, 0.0, 30.0, 30.0)) + .unwrap(); + app.try_execute_action(action).unwrap(); + let action = app + .move_content_to_panel_action(0, ids[1], Some(b), 0) + .unwrap(); + cycle(&mut app, action); + let (split, action) = app + .split_panel_action(0, a, &[ids[0]], "split".to_owned()) + .unwrap(); + cycle(&mut app, action); + let action = app.merge_panels_action(0, a, &[split]).unwrap(); + cycle(&mut app, action); + let (_, action) = app.duplicate_panel_action(0, a, [5.0, 5.0]).unwrap(); + cycle(&mut app, action); + let action = app.reorder_panel_labels_action(0).unwrap(); + cycle(&mut app, action); +} + +#[test] +fn label_edit_round_trips_without_plot_owned_state() { + let (mut app, ids) = app(); + let (panel, action) = app + .compose_panel_action(0, "a".to_owned(), &ids[..1], 0.0) + .unwrap(); + app.try_execute_action(action).unwrap(); + let before = app.doc.canvases[0].panel_meta_for_content(ids[0]).unwrap(); + let mut after = before.clone(); + after.user_note = "edited".to_owned(); + after.position = [4.0, 5.0]; + after.visible = false; + app.execute_action(crate::actions::Action::set_panel_meta( + 0, + ids[0], + before.clone(), + after.clone(), + )); + assert_eq!( + app.doc.canvases[0].panel_meta_for_content(ids[0]).unwrap(), + after + ); + app.undo(); + assert_eq!( + app.doc.canvases[0].panel_meta_for_content(ids[0]).unwrap(), + before + ); + app.redo(); + assert_eq!( + app.doc.canvases[0].panel_meta_for_content(ids[0]).unwrap(), + after + ); + let _ = panel; +} + +#[test] +fn renumber_reserves_positions_for_locked_and_manual_labels() { + let (mut app, _) = app(); + let mut panel_ids = Vec::new(); + for x in [0.0, 20.0, 40.0] { + let (id, action) = app + .create_panel_action(0, "p".to_owned(), ObjectFrame::new(x, 0.0, 10.0, 10.0)) + .unwrap(); + app.try_execute_action(action).unwrap(); + panel_ids.push(id); + } + app.doc.canvases[0] + .panel_mut(panel_ids[0]) + .unwrap() + .label + .mode = PanelLabelMode::LockedAuto { + value: "a".to_owned(), + }; + app.doc.canvases[0] + .panel_mut(panel_ids[1]) + .unwrap() + .label + .mode = PanelLabelMode::Manual { + value: "custom".to_owned(), + }; + let action = app.reorder_panel_labels_action(0).unwrap(); + app.try_execute_action(action).unwrap(); + assert!(matches!( + app.doc.canvases[0].panel(panel_ids[2]).unwrap().label.mode, + PanelLabelMode::Auto { slot: 2 } + )); +} + +#[test] +fn merging_grouped_panels_rewrites_and_dissolves_groups() { + let (mut app, _) = app(); + let mut ids = Vec::new(); + for x in [0.0, 20.0, 40.0] { + let (id, action) = app + .create_panel_action(0, "p".to_owned(), ObjectFrame::new(x, 0.0, 10.0, 10.0)) + .unwrap(); + app.try_execute_action(action).unwrap(); + ids.push(id); + } + app.doc.canvases[0].groups.push(LayoutGroup { + id: 1, + members: vec![GroupMember::Panel(ids[1]), GroupMember::Panel(ids[2])], + }); + let action = app.merge_panels_action(0, ids[0], &[ids[1]]).unwrap(); + cycle(&mut app, action); + let members = &app.doc.canvases[0].groups[0].members; + assert_eq!(members.len(), 2); + assert!(members.contains(&GroupMember::Panel(ids[0]))); + assert!(members.contains(&GroupMember::Panel(ids[2]))); +} diff --git a/crates/core/src/actions/tests/stack.rs b/crates/core/src/actions/tests/stack.rs index d4a0bbc..1d6cf29 100644 --- a/crates/core/src/actions/tests/stack.rs +++ b/crates/core/src/actions/tests/stack.rs @@ -160,7 +160,14 @@ fn plain_then_ctrl_click_selects_two_datasets_for_stacking() { assert_eq!(app.active_dataset(), Some(1)); app.stack_selected_data(); - let plot = app.doc.canvases.last().unwrap().objects[0].plot().unwrap(); + let canvas = app.doc.canvases.last().unwrap(); + assert_eq!(canvas.panels.len(), 1); + assert_eq!( + canvas.panel_letter(canvas.objects[0].id).as_deref(), + Some("a") + ); + assert_eq!(canvas.panel_notes().len(), 1); + let plot = canvas.objects[0].plot().unwrap(); assert_ne!( plot.binding.series[0].primary_color(), plot.binding.series[1].primary_color(), diff --git a/crates/core/src/actions/tests/tiling.rs b/crates/core/src/actions/tests/tiling.rs index 3d5f1f7..7872aac 100644 --- a/crates/core/src/actions/tests/tiling.rs +++ b/crates/core/src/actions/tests/tiling.rs @@ -14,6 +14,8 @@ fn tile_drop_transfers_reframes_and_round_trips() { app.session.active_canvas = Some(0); let newcomer = app.doc.canvases[0].objects[0].id; let existing = app.doc.canvases[1].objects[0].id; + let newcomer_panel = app.doc.canvases[0].create_panel_for_plot(newcomer).unwrap(); + app.doc.canvases[1].create_panel_for_plot(existing).unwrap(); let src_before = app.doc.canvases[0].objects.len(); let dst_before = app.doc.canvases[1].objects.len(); @@ -31,9 +33,21 @@ fn tile_drop_transfers_reframes_and_round_trips() { assert_eq!(app.doc.canvases[1].objects.len(), dst_before + 1); assert_eq!(app.session.active_canvas, Some(1)); let moved_id = app.doc.canvases[1].objects.last().unwrap().id; + assert_eq!( + app.doc.canvases[1].parent_panel(moved_id), + Some(newcomer_panel) + ); + assert_eq!( + app.doc.canvases[1].panel_letter(existing).as_deref(), + Some("a") + ); + assert_eq!( + app.doc.canvases[1].panel_letter(moved_id).as_deref(), + Some("b") + ); assert_eq!(app.session.ui.selection.object(), Some(moved_id)); - let ex = app.doc.canvases[1].object(existing).unwrap().frame; - let nc = app.doc.canvases[1].objects.last().unwrap().frame; + let ex = app.doc.canvases[1].content_page_frame(existing).unwrap(); + let nc = app.doc.canvases[1].content_page_frame(moved_id).unwrap(); assert_eq!(ex, existing_after_frame); assert!(nc.x > ex.x); assert!(nc.x + nc.width <= page[0] + 0.5); @@ -42,9 +56,13 @@ fn tile_drop_transfers_reframes_and_round_trips() { assert_eq!(app.doc.canvases[0].objects.len(), src_before); assert_eq!(app.doc.canvases[1].objects.len(), dst_before); assert_eq!(app.doc.canvases[0].objects[0].id, newcomer); + assert_eq!( + app.doc.canvases[0].panel_letter(newcomer).as_deref(), + Some("a") + ); assert_eq!(app.session.active_canvas, Some(0)); assert_eq!( - app.doc.canvases[1].object(existing).unwrap().frame, + app.doc.canvases[1].content_page_frame(existing).unwrap(), ObjectFrame::new(0.0, 0.0, page[0], page[1]) ); @@ -52,7 +70,41 @@ fn tile_drop_transfers_reframes_and_round_trips() { assert_eq!(app.doc.canvases[0].objects.len(), src_before - 1); assert_eq!(app.doc.canvases[1].objects.len(), dst_before + 1); assert_eq!(app.session.active_canvas, Some(1)); - assert_eq!(app.doc.canvases[1].object(existing).unwrap().frame, ex); + assert_eq!( + app.doc.canvases[1].content_page_frame(existing).unwrap(), + ex + ); + assert_eq!( + app.doc.canvases[1].panel_letter(moved_id).as_deref(), + Some("b") + ); + + app.transfer_objects_to_canvas(1, &[moved_id], 0, true); + let on_blank = app.doc.canvases[0].objects.last().unwrap().id; + assert_eq!( + app.doc.canvases[0].parent_panel(on_blank), + Some(newcomer_panel) + ); + assert_eq!( + app.doc.canvases[0].panel_letter(on_blank).as_deref(), + Some("a") + ); + + app.transfer_objects_to_canvas(0, &[on_blank], 1, true); + let returned = app.doc.canvases[1].objects.last().unwrap().id; + assert_eq!( + app.doc.canvases[1].parent_panel(returned), + Some(newcomer_panel) + ); + assert_eq!( + app.doc.canvases[1].panel_letter(existing).as_deref(), + Some("a") + ); + assert_eq!( + app.doc.canvases[1].panel_letter(returned).as_deref(), + Some("b") + ); + assert_eq!(app.doc.canvases[1].next_panel_label_slot, 2); } fn assert_empty_source_removal_round_trip(from: usize, to: usize) { diff --git a/crates/core/src/actions/transfer.rs b/crates/core/src/actions/transfer.rs index 427b5ed..e2acf4a 100644 --- a/crates/core/src/actions/transfer.rs +++ b/crates/core/src/actions/transfer.rs @@ -1,4 +1,5 @@ use super::*; +use crate::state::{GroupMember, LayoutGroup, PanelId, PanelLabelMode}; impl Action { /// Build a move/copy of `ids` (each expanded to its whole group) from canvas @@ -38,36 +39,134 @@ impl Action { return None; } - let mut next_group = dst.next_group_id; - let mut group_map: Vec<(crate::state::GroupId, crate::state::GroupId)> = Vec::new(); let mut inserted = Vec::with_capacity(picked.len()); let mut removed = Vec::with_capacity(picked.len()); for (offset, &(slot, object)) in picked.iter().enumerate() { let mut clone = object.clone(); clone.id = dst.next_object_id.checked_advance(offset as u64); - if let Some(g) = clone.group { - let mapped = match group_map.iter().find(|(old, _)| *old == g) { - Some(&(_, new)) => new, - None => { - let new = next_group; - next_group += 1; - group_map.push((g, new)); - new - } - }; - clone.group = Some(mapped); - } + clone.frame = src.content_page_frame(object.id)?; inserted.push(clone); if is_move { removed.push((slot, object.clone())); } } + let id_map: std::collections::BTreeMap<_, _> = picked + .iter() + .zip(&inserted) + .map(|((_, source), target)| (source.id, target.id)) + .collect(); + let source_groups_before = src.groups.clone(); + let mut source_groups_after = source_groups_before.clone(); + if is_move { + source_groups_after.iter_mut().for_each(|group| { + group.members.retain( + |member| !matches!(member, GroupMember::Content(id) if id_map.contains_key(id)), + ); + }); + source_groups_after.retain(|group| group.members.len() >= 2); + } + let target_groups_before = dst.groups.clone(); + let mut target_groups_after = target_groups_before.clone(); + let mut next_group = dst.next_group_id; + for group in &src.groups { + let members: Vec<_> = group + .members + .iter() + .filter_map(|member| match member { + GroupMember::Content(id) => id_map.get(id).copied().map(GroupMember::Content), + GroupMember::Panel(_) => None, + }) + .collect(); + if members.len() >= 2 { + target_groups_after.push(LayoutGroup { + id: next_group, + members, + }); + next_group += 1; + } + } + + let source_panels_before = src.panels.clone(); + let mut source_panels_after = source_panels_before.clone(); + if is_move { + source_panels_after.iter_mut().for_each(|panel| { + panel.item_order.retain(|id| !id_map.contains_key(id)); + }); + source_panels_after.retain(|panel| !panel.item_order.is_empty()); + source_groups_after.iter_mut().for_each(|group| { + group.members.retain(|member| match member { + GroupMember::Panel(id) => { + source_panels_after.iter().any(|panel| panel.id == *id) + } + GroupMember::Content(_) => true, + }); + }); + source_groups_after.retain(|group| group.members.len() >= 2); + } + let target_panels_before = dst.panels.clone(); + let mut target_page = dst.clone(); + target_page.objects.extend(inserted.iter().cloned()); + target_page.next_object_id = target_page + .next_object_id + .max(dst.next_object_id.checked_advance(inserted.len() as u64)); + for source_panel in src.panels.iter().filter(|panel| { + !panel.item_order.is_empty() + && panel.item_order.iter().all(|id| id_map.contains_key(id)) + }) { + let mut panel = source_panel.clone(); + panel.id = if is_move { panel.id } else { PanelId::new() }; + panel.item_order = source_panel + .item_order + .iter() + .filter_map(|id| id_map.get(id).copied()) + .collect(); + if matches!(panel.label.mode, PanelLabelMode::Auto { .. }) { + let slot = if is_move { + first_free_auto_slot(&target_page.panels) + } else { + let slot = target_page.next_panel_label_slot; + target_page.next_panel_label_slot = slot.saturating_add(1); + slot + }; + panel.label.mode = PanelLabelMode::Auto { slot }; + target_page.next_panel_label_slot = target_page + .next_panel_label_slot + .max(slot.saturating_add(1)); + } + for &source_id in &source_panel.item_order { + let target_id = *id_map.get(&source_id)?; + target_page.object_mut(target_id)?.frame = src.object(source_id)?.frame; + } + 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)?; + } + } + for object in &mut inserted { + *object = target_page.object(object.id)?.clone(); + } + let target_label_slot_after = target_page.next_panel_label_slot; + let target_panels_after = target_page.panels; Some(Self::TransferObjects { from, to, removed, inserted, + source_groups_before, + source_groups_after, + target_groups_before, + target_groups_after, + source_panels_before, + source_panels_after, + target_panels_before, + target_panels_after, + source_label_slot_before: src.next_panel_label_slot, + source_label_slot_after: src.next_panel_label_slot, + target_label_slot_before: dst.next_panel_label_slot, + target_label_slot_after, active_before: app.session.active_canvas, selection_before: app.session.ui.selection.clone(), }) @@ -92,17 +191,43 @@ impl Action { mut inserted, active_before, selection_before, + source_groups_before, + source_groups_after, + target_groups_before, + target_groups_after, + source_panels_before, + source_panels_after, + target_panels_before, + mut target_panels_after, + source_label_slot_before, + source_label_slot_after, + target_label_slot_before, + target_label_slot_after, .. } = Action::transfer_objects(app, from, &[object], to, true)? else { return None; }; - inserted.first_mut()?.frame = newcomer_frame; + 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 + .iter_mut() + .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() + .find(|panel| panel.item_order.contains(&content)) + { + panel.frame = frame; + } + } let src = app.doc.canvases.get(from)?; let dst = app.doc.canvases.get(to)?; let existing_before = existing_after .iter() - .filter_map(|&(id, _)| dst.object(id).map(|o| (id, o.frame))) + .filter_map(|&(id, _)| dst.layout_frame(id).map(|frame| (id, frame))) .collect(); let source_will_be_empty = src.objects.len() == removed.len(); let source_canvas_before = @@ -119,6 +244,18 @@ impl Action { source_canvas_before, removed, inserted, + source_groups_before, + source_groups_after, + target_groups_before, + target_groups_after, + source_panels_before, + source_panels_after, + target_panels_before, + target_panels_after, + source_label_slot_before, + source_label_slot_after, + target_label_slot_before, + target_label_slot_after, existing_before, existing_after, active_before, @@ -127,6 +264,17 @@ impl Action { } } +fn first_free_auto_slot(panels: &[crate::state::Panel]) -> u64 { + let used: std::collections::BTreeSet<_> = panels + .iter() + .filter_map(|panel| match panel.label.mode { + PanelLabelMode::Auto { slot } => Some(slot), + PanelLabelMode::LockedAuto { .. } | PanelLabelMode::Manual { .. } => None, + }) + .collect(); + (0..).find(|slot| !used.contains(slot)).unwrap_or(u64::MAX) +} + impl PlotxApp { /// Move or copy `ids` (each expanded to its whole group) from canvas `from` /// to canvas `to` as one undoable step, switching focus to the destination @@ -161,6 +309,12 @@ impl PlotxApp { to, removed, inserted, + source_groups_after, + target_groups_after, + source_panels_after, + target_panels_after, + source_label_slot_after, + target_label_slot_after, .. } = action else { @@ -174,13 +328,19 @@ impl PlotxApp { if let Some(dst) = self.doc.canvases.get_mut(to) { for object in inserted { dst.next_object_id = dst.next_object_id.max(object.id.checked_advance(1)); - if let Some(group) = object.group { - dst.next_group_id = dst.next_group_id.max(group + 1); - } dst.objects.push(object.clone()); } + dst.groups = target_groups_after.clone(); + dst.panels = target_panels_after.clone(); + dst.next_panel_label_slot = *target_label_slot_after; + dst.next_group_id = dst.groups.iter().map(|group| group.id).max().unwrap_or(0) + 1; dst.selected_object = ids.first().copied(); } + if let Some(src) = self.doc.canvases.get_mut(from) { + src.groups = source_groups_after.clone(); + src.panels = source_panels_after.clone(); + src.next_panel_label_slot = *source_label_slot_after; + } self.session.active_canvas = Some(to); self.session.ui.selection = Selection::Objects(ids); let active = self @@ -204,8 +364,15 @@ impl PlotxApp { to, removed, inserted, + source_groups_before, + target_groups_before, + source_panels_before, + target_panels_before, + source_label_slot_before, + target_label_slot_before, active_before, selection_before, + .. } = action else { return; @@ -218,6 +385,9 @@ impl PlotxApp { dst.selected_object = None; } } + dst.groups = target_groups_before.clone(); + dst.panels = target_panels_before.clone(); + dst.next_panel_label_slot = *target_label_slot_before; } if let Some(src) = self.doc.canvases.get_mut(from) { // Ascending slot order keeps each re-inserted object at its original @@ -227,6 +397,9 @@ impl PlotxApp { src.next_object_id = src.next_object_id.max(object.id.checked_advance(1)); src.objects.insert(at, object.clone()); } + src.groups = source_groups_before.clone(); + src.panels = source_panels_before.clone(); + src.next_panel_label_slot = *source_label_slot_before; } self.session.active_canvas = active_before; let active = active_before @@ -250,6 +423,12 @@ impl PlotxApp { source_canvas_before, removed, inserted, + source_groups_after, + target_groups_after, + source_panels_after, + target_panels_after, + source_label_slot_after, + target_label_slot_after, existing_after, .. } = action @@ -273,23 +452,23 @@ impl PlotxApp { if let Some(dst) = self.doc.canvases.get_mut(to) { for object in inserted { dst.next_object_id = dst.next_object_id.max(object.id.checked_advance(1)); - if let Some(group) = object.group { - dst.next_group_id = dst.next_group_id.max(group + 1); - } dst.objects.push(object.clone()); } + dst.groups = target_groups_after.clone(); + dst.panels = target_panels_after.clone(); + dst.next_panel_label_slot = *target_label_slot_after; + dst.next_group_id = dst.groups.iter().map(|group| group.id).max().unwrap_or(0) + 1; dst.selected_object = ids.first().copied(); } + if let Some(src) = self.doc.canvases.get_mut(from) { + src.groups = source_groups_after.clone(); + src.panels = source_panels_after.clone(); + src.next_panel_label_slot = *source_label_slot_after; + } // The clones' figures were built for the source frame; rebuild at the // landing size now that they sit in the target's layout. for &id in &ids { - if let Some(frame) = self - .doc - .canvases - .get(to) - .and_then(|c| c.object(id)) - .map(|o| o.frame) - { + if let Some(frame) = self.doc.canvases.get(to).and_then(|c| c.layout_frame(id)) { self.set_object_frame(to, id, frame); } } @@ -321,6 +500,12 @@ impl PlotxApp { source_canvas_before, removed, inserted, + source_groups_before, + target_groups_before, + source_panels_before, + target_panels_before, + source_label_slot_before, + target_label_slot_before, existing_before, active_before, selection_before, @@ -350,10 +535,15 @@ impl PlotxApp { dst.selected_object = None; } } + dst.groups = target_groups_before.clone(); } for &(id, frame) in existing_before { self.set_object_frame(current_target, id, frame); } + if let Some(dst) = self.doc.canvases.get_mut(current_target) { + dst.panels = target_panels_before.clone(); + dst.next_panel_label_slot = *target_label_slot_before; + } if source_canvas_before.is_none() && let Some(src) = self.doc.canvases.get_mut(from) { @@ -362,6 +552,9 @@ impl PlotxApp { src.next_object_id = src.next_object_id.max(object.id.checked_advance(1)); src.objects.insert(at, object.clone()); } + src.groups = source_groups_before.clone(); + src.panels = source_panels_before.clone(); + src.next_panel_label_slot = *source_label_slot_before; } self.session.active_canvas = active_before; let active = active_before @@ -452,9 +645,13 @@ mod tests { let a = app.doc.canvases[0].objects[0].id; let _b = push_text_object(&mut app, 0, "b"); let group = app.doc.canvases[0].allocate_group_id(); - for id in [a, _b] { - app.doc.canvases[0].object_mut(id).unwrap().group = Some(group); - } + app.doc.canvases[0].groups.push(crate::state::LayoutGroup { + id: group, + members: vec![ + crate::state::GroupMember::Content(a), + crate::state::GroupMember::Content(_b), + ], + }); // Give canvas 1 its own group id space so a collision would be visible. app.doc.canvases[1].next_group_id = 1; @@ -462,16 +659,12 @@ mod tests { assert!(app.doc.canvases[0].objects.is_empty()); assert_eq!(app.doc.canvases[1].objects.len(), 3); - let moved: Vec<_> = app.doc.canvases[1].objects[1..] - .iter() - .map(|o| o.group) - .collect(); - assert!(moved[0].is_some()); - assert_eq!(moved[0], moved[1]); + let moved = &app.doc.canvases[1].groups[0].members; + assert_eq!(moved.len(), 2); app.undo(); assert_eq!(app.doc.canvases[0].objects.len(), 2); - assert_eq!(app.doc.canvases[0].object(a).unwrap().group, Some(group)); + assert_eq!(app.doc.canvases[0].content_group(a), Some(group)); assert_eq!(app.doc.canvases[1].objects.len(), 1); } diff --git a/crates/core/src/automation/properties_tests_inbound_value.rs b/crates/core/src/automation/properties_tests_inbound_value.rs index e7480cf..3d89101 100644 --- a/crates/core/src/automation/properties_tests_inbound_value.rs +++ b/crates/core/src/automation/properties_tests_inbound_value.rs @@ -38,7 +38,6 @@ fn automation_writes_new_object_text_color_and_enum_properties() { frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label(String::new())), }); let resource = format!("{}/{id}", canvas.resource_id); diff --git a/crates/core/src/automation/properties_tests_outbound.rs b/crates/core/src/automation/properties_tests_outbound.rs index d93486b..0bc46af 100644 --- a/crates/core/src/automation/properties_tests_outbound.rs +++ b/crates/core/src/automation/properties_tests_outbound.rs @@ -35,7 +35,6 @@ fn automation_reports_new_object_color_and_enum_values_and_schemas() { frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(text), }); let resource = format!("{}/{id}", canvas.resource_id); diff --git a/crates/core/src/automation/properties_tests_rejections.rs b/crates/core/src/automation/properties_tests_rejections.rs index c9fb597..87aae9a 100644 --- a/crates/core/src/automation/properties_tests_rejections.rs +++ b/crates/core/src/automation/properties_tests_rejections.rs @@ -73,7 +73,6 @@ fn an_object_without_components_is_skipped_by_the_shared_gate() { frame: ObjectFrame::new(0.0, 0.0, 20.0, 10.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label("hello".to_owned())), }); let text_id = format!("{}/{id}", app.doc.canvases[0].resource_id); diff --git a/crates/core/src/export/mod.rs b/crates/core/src/export/mod.rs index a52e8b4..0a5a43f 100644 --- a/crates/core/src/export/mod.rs +++ b/crates/core/src/export/mod.rs @@ -526,7 +526,6 @@ mod tests { frame, locked: false, visible: true, - group: None, kind: CanvasObjectKind::Shape(ShapeObject::new(ShapeKind::Rect)), }); canvas diff --git a/crates/core/src/export/precheck.rs b/crates/core/src/export/precheck.rs index ea173eb..1f05a70 100644 --- a/crates/core/src/export/precheck.rs +++ b/crates/core/src/export/precheck.rs @@ -69,15 +69,21 @@ pub fn page_metrics(canvas: &CanvasDocument) -> PageMetrics { continue; } match &object.kind { - CanvasObjectKind::Text(t) | CanvasObjectKind::PanelLabel(t) => { + CanvasObjectKind::RasterImage(_) => {} + CanvasObjectKind::Text(t) => { if !t.text.trim().is_empty() { fonts.push(t.font_size); } } CanvasObjectKind::Shape(s) => lines.push(s.stroke_width), CanvasObjectKind::Plot(plot) => { - if plot.panel.visible { - fonts.push(plot.panel.font_size); + if let Some(panel) = canvas + .parent_panel(object.id) + .and_then(|id| canvas.panel(id)) + && panel.visible + && panel.label.visible + { + fonts.push(panel.label.font_size); } let typography = plot.figure().typography; if plot.figure().axis_frame != AxisFrame::Hidden { @@ -278,7 +284,6 @@ mod tests { frame: ObjectFrame::new(0.0, 0.0, 100.0, 100.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( None, crate::state::SeriesId::new(1), @@ -321,7 +326,6 @@ mod tests { frame: ObjectFrame::new(0.0, 0.0, 100.0, 100.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( None, crate::state::SeriesId::new(1), @@ -364,7 +368,6 @@ mod tests { frame: ObjectFrame::new(0.0, 0.0, 100.0, 100.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( None, crate::state::SeriesId::new(1), diff --git a/crates/core/src/project/asset_codec.rs b/crates/core/src/project/asset_codec.rs new file mode 100644 index 0000000..d3d6b6f --- /dev/null +++ b/crates/core/src/project/asset_codec.rs @@ -0,0 +1,154 @@ +use super::*; + +pub(super) fn write_reachable_assets( + doc: &Document, + zip: &mut zip::ZipWriter, + options: SimpleFileOptions, + manifest: &mut Manifest, +) -> Result<()> { + let reachable: std::collections::BTreeSet<_> = doc + .canvases + .iter() + .flat_map(|canvas| canvas.objects.iter()) + .filter_map(|item| match &item.kind { + CanvasObjectKind::RasterImage(image) => Some(image.asset), + _ => None, + }) + .collect(); + let mut path_metadata = std::collections::BTreeMap::new(); + for &asset_id in &reachable { + let asset = doc + .assets + .get(&asset_id) + .ok_or_else(|| ProjectError::Invalid(format!("missing referenced asset {asset_id}")))?; + if asset.id != asset_id { + return Err(ProjectError::Invalid(format!( + "asset map key {asset_id} does not match record id {}", + asset.id + ))); + } + let digest: [u8; 32] = Sha256::digest(&asset.bytes).into(); + if digest != asset.sha256 { + return Err(ProjectError::Invalid(format!( + "asset {asset_id} sha256 does not match its bytes" + ))); + } + if asset.pixel_size.contains(&0) || !valid_format(&asset.format) { + return Err(ProjectError::Invalid(format!( + "asset {asset_id} has invalid metadata" + ))); + } + let hash = format!("{:x}", Sha256::digest(&asset.bytes)); + let path = format!("assets/{hash}.{}", asset.format); + let metadata = ( + hash, + asset.format.clone(), + asset.bytes.len(), + asset.pixel_size, + ); + if let Some(previous) = path_metadata.insert(path.clone(), metadata.clone()) + && previous != metadata + { + return Err(ProjectError::Invalid(format!( + "asset path {path:?} has conflicting metadata" + ))); + } + } + let mut written_paths = std::collections::BTreeSet::new(); + for asset_id in reachable { + let asset = doc + .assets + .get(&asset_id) + .ok_or_else(|| ProjectError::Invalid(format!("missing referenced asset {asset_id}")))?; + let hash = format!("{:x}", Sha256::digest(&asset.bytes)); + let path = format!("assets/{hash}.{}", asset.format); + if written_paths.insert(path.clone()) { + write_bytes(zip, options, &path, &asset.bytes)?; + } + manifest.assets.push(AssetEntry { + id: asset_id.to_string(), + sha256: hash, + path, + format: asset.format.clone(), + byte_len: asset.bytes.len() as u64, + pixel_size: asset.pixel_size, + }); + } + Ok(()) +} + +pub(super) fn load_assets( + zip: &mut ZipArchive, + manifest: &Manifest, + app: &mut PlotxApp, +) -> Result<()> { + let mut ids = std::collections::BTreeSet::new(); + let mut path_metadata = std::collections::BTreeMap::new(); + let mut path_bytes: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for entry in &manifest.assets { + let id = entry + .id + .parse::() + .map_err(|_| ProjectError::Invalid(format!("invalid asset id {}", entry.id)))?; + if !ids.insert(id) { + return Err(ProjectError::Invalid("duplicate asset id".to_owned())); + } + if entry.byte_len > usize::MAX as u64 + || entry.pixel_size.contains(&0) + || !valid_format(&entry.format) + { + return Err(ProjectError::Invalid(format!( + "asset {id} has invalid metadata" + ))); + } + let metadata = ( + entry.sha256.clone(), + entry.format.clone(), + entry.byte_len, + entry.pixel_size, + ); + if let Some(previous) = path_metadata.insert(entry.path.clone(), metadata.clone()) + && previous != metadata + { + return Err(ProjectError::Invalid(format!( + "asset path {:?} has conflicting metadata", + entry.path + ))); + } + let bytes = if let Some(bytes) = path_bytes.get(&entry.path) { + bytes.clone() + } else { + let bytes = read_bytes(zip, &entry.path)?; + path_bytes.insert(entry.path.clone(), bytes.clone()); + bytes + }; + if bytes.len() as u64 != entry.byte_len { + return Err(ProjectError::Invalid(format!( + "asset {id} byte length mismatch" + ))); + } + let digest = Sha256::digest(&bytes); + let hash = format!("{digest:x}"); + if hash != entry.sha256 || entry.path != format!("assets/{hash}.{}", entry.format) { + return Err(ProjectError::Invalid(format!( + "asset {id} hash or path mismatch" + ))); + } + app.doc.assets.insert( + id, + AssetRecord { + id, + sha256: digest.into(), + format: entry.format.clone(), + pixel_size: entry.pixel_size, + bytes, + }, + ); + } + Ok(()) +} + +fn valid_format(format: &str) -> bool { + matches!(format, "png" | "jpeg" | "tiff" | "webp" | "bmp") +} diff --git a/crates/core/src/project/convert_views.rs b/crates/core/src/project/convert_views.rs index de8e8f4..f9af956 100644 --- a/crates/core/src/project/convert_views.rs +++ b/crates/core/src/project/convert_views.rs @@ -2,7 +2,15 @@ use super::axis_overrides::AxisOverridesDto; use super::field_catalog::validate_series_source; use super::*; use crate::state::SeriesId; +use crate::state::{AssetId, ImageFit, ImageInterpolation, QuarterTurn, RasterImageContent}; +#[path = "convert_views_panel.rs"] +mod panel_convert; use crate::state::{AxisProjection, AxisProjections, ProjectionSource}; +use crate::state::{ + GroupMember, LayoutGroup, Panel, PanelId, PanelLabelMode, PanelLabelSpec, PanelLayout, +}; +use panel_convert::*; + fn projections_to_dto(p: &AxisProjections, datasets: &[Dataset]) -> Result> { if p.is_empty() { return Ok(None); @@ -107,15 +115,17 @@ pub fn canvas_to_view( stack: None, projections: None, frame: FrameDto::from_frame(object.frame), + parent_panel: canvas + .parent_panel(object.id) + .map(|id| ParentPanelDto::Panel { id: id.to_string() }) + .unwrap_or(ParentPanelDto::Loose), viewport: None, axis_overrides: None, - panel: None, - title: None, text: None, shape: None, + image: None, locked: object.locked, visible: object.visible, - group: object.group, snapshot: None, }; match &object.kind { @@ -217,7 +227,6 @@ pub fn canvas_to_view( projections: projections_to_dto(&plot.projections, datasets)?, viewport: Some(ViewportDto::from_viewport(&plot.viewport)), axis_overrides: AxisOverridesDto::from_overrides(&plot.axis_overrides), - panel: Some(PanelDto::from_panel(&plot.panel)), ..base(kind) }) } @@ -225,14 +234,37 @@ pub fn canvas_to_view( text: Some(TextBoxDto::from_text_box(t)), ..base("text") }), - CanvasObjectKind::PanelLabel(t) => Ok(ViewCanvasObject { - text: Some(TextBoxDto::from_text_box(t)), - ..base("panel_label") - }), CanvasObjectKind::Shape(s) => Ok(ViewCanvasObject { shape: Some(ShapeDto::from_shape(s)), ..base("shape") }), + CanvasObjectKind::RasterImage(image) => Ok(ViewCanvasObject { + image: Some(RasterImageDto { + asset: image.asset.to_string(), + crop: image.crop, + fit: match image.fit { + ImageFit::Contain => "contain", + ImageFit::Cover => "cover", + ImageFit::Stretch => "stretch", + } + .to_owned(), + rotation: match image.rotation { + QuarterTurn::Zero => 0, + QuarterTurn::Clockwise90 => 90, + QuarterTurn::Clockwise180 => 180, + QuarterTurn::Clockwise270 => 270, + }, + opacity: image.opacity, + interpolation: match image.interpolation { + ImageInterpolation::Auto => "auto", + ImageInterpolation::Nearest => "nearest", + ImageInterpolation::Linear => "linear", + } + .to_owned(), + preserve_aspect: image.preserve_aspect, + }), + ..base("raster_image") + }), } }) .collect::>>()?; @@ -251,9 +283,18 @@ pub fn canvas_to_view( .collect(), name: canvas.name.clone(), next_object_id: canvas.next_object_id.get(), + next_panel_label_slot: canvas.next_panel_label_slot, caption: canvas.caption.clone(), caption_visible: canvas.caption_visible, - panel_label_style: Some(canvas.panel_label_style.as_key().to_owned()), + panel_label_style: canvas.panel_label_style.as_key().to_owned(), + panels: canvas.panels.iter().map(panel_to_dto).collect(), + loose_item_order: canvas + .objects + .iter() + .filter(|item| canvas.parent_panel(item.id).is_none()) + .map(|item| item.id.to_string()) + .collect(), + groups: canvas.groups.iter().map(group_to_dto).collect(), layout: ViewLayout { size_mm: canvas.size_mm, size_preset: canvas.size_preset_id.clone(), @@ -271,6 +312,7 @@ pub fn canvas_to_view( snapshot: None, }) } + pub fn view_to_canvas( app: &mut PlotxApp, zip: &mut zip::ZipArchive, @@ -288,11 +330,13 @@ pub fn view_to_canvas( .unwrap_or_else(|| crate::state::default_board_layout(index)); canvas.caption = view.caption.clone(); canvas.caption_visible = view.caption_visible; - canvas.panel_label_style = view - .panel_label_style - .as_deref() - .map(crate::state::PanelLabelStyle::from_key) - .unwrap_or_default(); + canvas.panel_label_style = crate::state::PanelLabelStyle::try_from_key(&view.panel_label_style) + .ok_or_else(|| { + ProjectError::Invalid(format!( + "unknown panel label style {}", + view.panel_label_style + )) + })?; canvas.layout = view .layout .grid @@ -302,7 +346,7 @@ pub fn view_to_canvas( canvas.background = plotx_figure::Color::rgb(r, g, b); } let mut max_id = 0; - let mut max_group = 0; + let mut declared_parents = std::collections::BTreeMap::new(); for view_object in &view.objects { let object_id = view_object .id @@ -311,7 +355,6 @@ pub fn view_to_canvas( let frame = view_object.frame.into_frame(); let mut kind = match view_object.kind.as_str() { "text" => CanvasObjectKind::Text(text_box_from(view_object, false)), - "panel_label" => CanvasObjectKind::PanelLabel(text_box_from(view_object, true)), "shape" => CanvasObjectKind::Shape( view_object .shape @@ -319,6 +362,53 @@ pub fn view_to_canvas( .map(ShapeDto::into_shape) .unwrap_or_else(|| ShapeObject::new(ShapeKind::Rect)), ), + "raster_image" => { + let image = view_object.image.as_ref().ok_or_else(|| { + ProjectError::Invalid(format!( + "view {view_id} raster content {} has no image parameters", + view_object.id + )) + })?; + CanvasObjectKind::RasterImage(RasterImageContent { + asset: image.asset.parse::().map_err(|_| { + ProjectError::Invalid(format!("invalid asset id {}", image.asset)) + })?, + crop: image.crop, + fit: match image.fit.as_str() { + "contain" => ImageFit::Contain, + "cover" => ImageFit::Cover, + "stretch" => ImageFit::Stretch, + other => { + return Err(ProjectError::Invalid(format!( + "unknown image fit {other}" + ))); + } + }, + rotation: match image.rotation { + 0 => QuarterTurn::Zero, + 90 => QuarterTurn::Clockwise90, + 180 => QuarterTurn::Clockwise180, + 270 => QuarterTurn::Clockwise270, + other => { + return Err(ProjectError::Invalid(format!( + "invalid image rotation {other}" + ))); + } + }, + opacity: image.opacity, + interpolation: match image.interpolation.as_str() { + "auto" => ImageInterpolation::Auto, + "nearest" => ImageInterpolation::Nearest, + "linear" => ImageInterpolation::Linear, + other => { + return Err(ProjectError::Invalid(format!( + "unknown image interpolation {other}" + ))); + } + }, + preserve_aspect: image.preserve_aspect, + }) + } "line_plot" | "contour_plot" | "stack_plot" | "plot" => { let resolve = |input: &str| { recipe_to_dataset.get(input).copied().ok_or_else(|| { @@ -497,12 +587,6 @@ pub fn view_to_canvas( viewport.apply_to(&mut figure); } figure.title.clear(); - let panel = view_object - .panel - .clone() - .or_else(|| view_object.title.clone()) - .map(PanelDto::into_panel) - .unwrap_or_else(|| PanelMeta::new(app.default_plot_title(di), frame.width)); CanvasObjectKind::Plot(Box::new(PlotObject::from_materialized_figure( display_owner, SeriesId::new(view_object.next_series_id), @@ -514,10 +598,14 @@ pub fn view_to_canvas( derived_axes, figure, viewport, - panel, + PanelMeta::new(app.default_plot_title(di), frame.width), ))) } - _ => continue, + other => { + return Err(ProjectError::Invalid(format!( + "view {view_id} has unknown content kind {other}" + ))); + } }; if let CanvasObjectKind::Plot(plot) = &mut kind { plot.repair_series_allocator().ok_or_else(|| { @@ -533,17 +621,95 @@ pub fn view_to_canvas( frame, locked: view_object.locked, visible: view_object.visible, - group: view_object.group, kind, }); + let parent = match &view_object.parent_panel { + ParentPanelDto::Loose => None, + ParentPanelDto::Panel { id } => Some(id.parse::().map_err(|_| { + ProjectError::Invalid(format!( + "invalid parent panel id for content {}", + view_object.id + )) + })?), + }; + if declared_parents.insert(object_id, parent).is_some() { + return Err(ProjectError::Invalid(format!( + "duplicate content id {}", + view_object.id + ))); + } max_id = max_id.max(object_id.get()); - max_group = max_group.max(view_object.group.unwrap_or(0)); } let repaired_next = ObjectId::new(max_id) .try_advance(1) .ok_or_else(|| ProjectError::Invalid("object id space exhausted".to_owned()))?; canvas.next_object_id = ObjectId::new(view.next_object_id).max(repaired_next); - canvas.next_group_id = max_group + 1; + canvas.panels = view + .panels + .iter() + .map(panel_from_dto) + .collect::>>()?; + canvas.groups = view + .groups + .iter() + .map(group_from_dto) + .collect::>>()?; + canvas.next_group_id = canvas + .groups + .iter() + .map(|group| group.id) + .max() + .unwrap_or(0) + .saturating_add(1); + let loose: Vec = view + .loose_item_order + .iter() + .map(|id| { + id.parse::() + .map_err(|_| ProjectError::Invalid(format!("invalid loose content id {id}"))) + }) + .collect::>()?; + let mut seen_loose = std::collections::BTreeSet::new(); + for id in &loose { + if !seen_loose.insert(*id) || canvas.object(*id).is_none() { + return Err(ProjectError::Invalid(format!( + "invalid or duplicate loose content id {id}" + ))); + } + } + for item in &canvas.objects { + let actual = canvas.parent_panel(item.id); + let declared = declared_parents.get(&item.id).copied().flatten(); + if actual != declared { + return Err(ProjectError::Invalid(format!( + "content {} parent does not match panel order", + item.id + ))); + } + if actual.is_none() != seen_loose.contains(&item.id) { + return Err(ProjectError::Invalid(format!( + "content {} loose order is inconsistent", + item.id + ))); + } + } + let minimum_label_slot = canvas + .panels + .iter() + .filter_map(|panel| match panel.label.mode { + PanelLabelMode::Auto { slot } => slot.checked_add(1), + _ => None, + }) + .max() + .unwrap_or(0); + if view.next_panel_label_slot < minimum_label_slot { + return Err(ProjectError::Invalid(format!( + "next panel label slot {} does not exceed allocated auto slots", + view.next_panel_label_slot + ))); + } + canvas.next_panel_label_slot = view.next_panel_label_slot; + canvas.validate_structure().map_err(ProjectError::Invalid)?; Ok(canvas) } fn text_box_from(view_object: &ViewCanvasObject, panel: bool) -> TextBox { diff --git a/crates/core/src/project/convert_views_panel.rs b/crates/core/src/project/convert_views_panel.rs new file mode 100644 index 0000000..303e309 --- /dev/null +++ b/crates/core/src/project/convert_views_panel.rs @@ -0,0 +1,164 @@ +use super::*; + +pub(super) fn panel_to_dto(panel: &Panel) -> ViewPanel { + let (participates, position, font_size) = ( + panel.label.participates_in_sequence, + panel.label.position, + panel.label.font_size, + ); + let label = match &panel.label.mode { + PanelLabelMode::Auto { slot } => PanelLabelDto::Auto { + slot: *slot, + visible: panel.label.visible, + participates, + position, + font_size, + }, + PanelLabelMode::LockedAuto { value } => PanelLabelDto::LockedAuto { + value: value.clone(), + visible: panel.label.visible, + participates, + position, + font_size, + }, + PanelLabelMode::Manual { value } => PanelLabelDto::Manual { + value: value.clone(), + visible: panel.label.visible, + participates, + position, + font_size, + }, + }; + ViewPanel { + id: panel.id.to_string(), + name: panel.name.clone(), + frame: FrameDto::from_frame(panel.frame), + item_order: panel.item_order.iter().map(ToString::to_string).collect(), + label, + note: panel.note.clone(), + visible: panel.visible, + locked: panel.locked, + clip_children: panel.clip_children, + layout: match panel.layout { + PanelLayout::Free => PanelLayoutDto::Free, + PanelLayout::VerticalStack => PanelLayoutDto::VerticalStack, + PanelLayout::HorizontalStack => PanelLayoutDto::HorizontalStack, + PanelLayout::Grid { rows, cols } => PanelLayoutDto::Grid { rows, cols }, + }, + } +} + +pub(super) fn group_to_dto(group: &LayoutGroup) -> ViewGroup { + ViewGroup { + id: group.id, + members: group + .members + .iter() + .map(|member| match member { + GroupMember::Panel(id) => ViewGroupMember::Panel { id: id.to_string() }, + GroupMember::Content(id) => ViewGroupMember::Content { id: id.to_string() }, + }) + .collect(), + } +} + +pub(super) fn panel_from_dto(dto: &ViewPanel) -> Result { + let (mode, visible, participates, position, font_size) = match &dto.label { + PanelLabelDto::Auto { + slot, + visible, + participates, + position, + font_size, + } => ( + PanelLabelMode::Auto { slot: *slot }, + *visible, + *participates, + *position, + *font_size, + ), + PanelLabelDto::LockedAuto { + value, + visible, + participates, + position, + font_size, + } => ( + PanelLabelMode::LockedAuto { + value: value.clone(), + }, + *visible, + *participates, + *position, + *font_size, + ), + PanelLabelDto::Manual { + value, + visible, + participates, + position, + font_size, + } => ( + PanelLabelMode::Manual { + value: value.clone(), + }, + *visible, + *participates, + *position, + *font_size, + ), + }; + Ok(Panel { + id: dto + .id + .parse::() + .map_err(|_| ProjectError::Invalid(format!("invalid panel id {}", dto.id)))?, + name: dto.name.clone(), + frame: dto.frame.into_frame(), + item_order: dto + .item_order + .iter() + .map(|id| { + id.parse::() + .map_err(|_| ProjectError::Invalid(format!("invalid panel content id {id}"))) + }) + .collect::>()?, + label: PanelLabelSpec { + mode, + visible, + participates_in_sequence: participates, + position, + font_size, + }, + note: dto.note.clone(), + visible: dto.visible, + locked: dto.locked, + clip_children: dto.clip_children, + layout: match dto.layout { + PanelLayoutDto::Free => PanelLayout::Free, + PanelLayoutDto::VerticalStack => PanelLayout::VerticalStack, + PanelLayoutDto::HorizontalStack => PanelLayout::HorizontalStack, + PanelLayoutDto::Grid { rows, cols } => PanelLayout::Grid { rows, cols }, + }, + }) +} + +pub(super) fn group_from_dto(dto: &ViewGroup) -> Result { + Ok(LayoutGroup { + id: dto.id, + members: dto + .members + .iter() + .map(|member| match member { + ViewGroupMember::Panel { id } => id + .parse::() + .map(GroupMember::Panel) + .map_err(|_| ProjectError::Invalid(format!("invalid group panel id {id}"))), + ViewGroupMember::Content { id } => id + .parse::() + .map(GroupMember::Content) + .map_err(|_| ProjectError::Invalid(format!("invalid group content id {id}"))), + }) + .collect::>()?, + }) +} diff --git a/crates/core/src/project/dto.rs b/crates/core/src/project/dto.rs index d3a80c3..d9f492e 100644 --- a/crates/core/src/project/dto.rs +++ b/crates/core/src/project/dto.rs @@ -3,6 +3,9 @@ use super::*; #[path = "dto_series_source.rs"] mod dto_series_source; pub use dto_series_source::SeriesSourceDto; +#[path = "dto_panel.rs"] +mod dto_panel; +pub use dto_panel::*; #[derive(Serialize, Deserialize)] pub struct Manifest { @@ -16,6 +19,7 @@ pub struct Manifest { pub save_profile: SaveProfile, pub objects: Vec, pub views: Vec, + pub assets: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub runs: Vec, pub workspace: String, @@ -266,6 +270,7 @@ pub struct DiffusionMetaDto { } #[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ViewObject { pub id: String, pub role: String, @@ -274,15 +279,17 @@ pub struct ViewObject { pub inputs: Vec, pub name: String, pub next_object_id: u64, + pub next_panel_label_slot: u64, #[serde(default, skip_serializing_if = "String::is_empty")] pub caption: String, #[serde(default = "caption_visible_default")] pub caption_visible: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub panel_label_style: Option, + pub panel_label_style: String, pub layout: ViewLayout, - #[serde(default)] pub objects: Vec, + pub panels: Vec, + pub loose_item_order: Vec, + pub groups: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub viewport: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -345,6 +352,7 @@ impl PageLayoutDto { mod tests; #[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ViewCanvasObject { pub id: String, pub name: String, @@ -380,22 +388,20 @@ pub struct ViewCanvasObject { #[serde(default, skip_serializing_if = "Option::is_none")] pub projections: Option, pub frame: FrameDto, + /// Required in final schema v1. `null` means page-level loose content. + pub parent_panel: ParentPanelDto, #[serde(default, skip_serializing_if = "Option::is_none")] pub viewport: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub axis_overrides: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub panel: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] pub text: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub shape: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, pub locked: bool, pub visible: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub group: Option, #[serde(skip_serializing_if = "Option::is_none")] pub snapshot: Option, } @@ -585,46 +591,6 @@ impl ShapeDto { } } -#[derive(Serialize, Deserialize, Clone)] -pub struct PanelDto { - #[serde(default, alias = "text")] - pub note: String, - #[serde(default = "default_panel_label_position", alias = "position")] - pub label_position: [f32; 2], - #[serde(default = "default_panel_label_font_size", alias = "font_size")] - pub label_font_size: f32, - #[serde(default = "bool_true", alias = "visible")] - pub label_visible: bool, -} - -impl PanelDto { - pub fn from_panel(panel: &PanelMeta) -> Self { - Self { - note: panel.user_note.clone(), - label_position: panel.position, - label_font_size: panel.font_size, - label_visible: panel.visible, - } - } - - pub fn into_panel(self) -> PanelMeta { - PanelMeta { - user_note: self.note, - position: self.label_position, - font_size: self.label_font_size, - visible: self.label_visible, - } - } -} - -fn default_panel_label_position() -> [f32; 2] { - [6.0, 5.0] -} - -fn default_panel_label_font_size() -> f32 { - 8.0 -} - #[derive(Serialize, Deserialize, Clone, Copy)] pub struct FrameDto { pub x: f32, diff --git a/crates/core/src/project/dto_panel.rs b/crates/core/src/project/dto_panel.rs new file mode 100644 index 0000000..8e04165 --- /dev/null +++ b/crates/core/src/project/dto_panel.rs @@ -0,0 +1,95 @@ +use super::*; + +#[derive(Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct AssetEntry { + pub id: String, + pub sha256: String, + pub path: String, + pub format: String, + pub byte_len: u64, + pub pixel_size: [u32; 2], +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ParentPanelDto { + Loose, + Panel { id: String }, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct RasterImageDto { + pub asset: String, + pub crop: [f32; 4], + pub fit: String, + pub rotation: u16, + pub opacity: f32, + pub interpolation: String, + pub preserve_aspect: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct ViewPanel { + pub id: String, + pub name: String, + pub frame: FrameDto, + pub item_order: Vec, + pub label: PanelLabelDto, + pub note: String, + pub visible: bool, + pub locked: bool, + pub clip_children: bool, + pub layout: PanelLayoutDto, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] +pub enum PanelLabelDto { + Auto { + slot: u64, + visible: bool, + participates: bool, + position: [f32; 2], + font_size: f32, + }, + LockedAuto { + value: String, + visible: bool, + participates: bool, + position: [f32; 2], + font_size: f32, + }, + Manual { + value: String, + visible: bool, + participates: bool, + position: [f32; 2], + font_size: f32, + }, +} + +#[derive(Serialize, Deserialize, Clone, Copy)] +#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] +pub enum PanelLayoutDto { + Free, + VerticalStack, + HorizontalStack, + Grid { rows: u32, cols: u32 }, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(deny_unknown_fields)] +pub struct ViewGroup { + pub id: u64, + pub members: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ViewGroupMember { + Panel { id: String }, + Content { id: String }, +} diff --git a/crates/core/src/project/mod.rs b/crates/core/src/project/mod.rs index 7b57dc1..7416182 100644 --- a/crates/core/src/project/mod.rs +++ b/crates/core/src/project/mod.rs @@ -1,9 +1,10 @@ use crate::layout::PageLayout; use crate::state::{ - AnalysisSelection, AxisRange, CanvasDocument, CanvasObject, CanvasObjectKind, CanvasViewport, - DataBinding, Dataset, DatasetLineage, DerivationKind, Nmr2DDataset, NmrDataset, ObjectFrame, - ObjectId, PanelMeta, PlotObject, PlotxApp, PrimaryView, SeriesBinding, ShapeKind, ShapeObject, - StackMode, StackSpec, TextAlign, TextBox, Tool, + AnalysisSelection, AssetId, AssetRecord, AxisRange, CanvasDocument, CanvasObject, + CanvasObjectKind, CanvasViewport, DataBinding, Dataset, DatasetLineage, DerivationKind, + Document, Nmr2DDataset, NmrDataset, ObjectFrame, ObjectId, PanelMeta, PlotObject, PlotxApp, + PrimaryView, SeriesBinding, ShapeKind, ShapeObject, StackMode, StackSpec, TextAlign, TextBox, + Tool, }; use num_complex::Complex64; use plotx_figure::Color; @@ -16,13 +17,16 @@ use plotx_processing::{ SmoothMethod, StepId, StepKind, StepSource, ZeroFill, }; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs::File; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use zip::ZipArchive; use zip::write::SimpleFileOptions; mod afm_convert; +mod asset_codec; mod axis_overrides; mod codec; mod convert; @@ -275,11 +279,13 @@ fn save_project_impl( }, objects: Vec::new(), views: Vec::new(), + assets: Vec::new(), runs: Vec::new(), workspace: "workspace.json".to_owned(), }; let mut bindings = Vec::with_capacity(doc.datasets.len()); + asset_codec::write_reachable_assets(doc, &mut zip, options, &mut manifest)?; let mut written_table_blocks = std::collections::BTreeSet::new(); for dataset in &doc.datasets { let data_id = dataset.resource_id().to_string(); @@ -461,6 +467,8 @@ pub fn load_project(path: &Path) -> Result { let mut app = PlotxApp::new(); app.doc.datasets.clear(); app.doc.canvases.clear(); + app.doc.assets.clear(); + asset_codec::load_assets(&mut zip, &manifest, &mut app)?; app.doc.project_path = Some(path.to_owned()); // Restore before the canvases below are built: figures stamp the document // typography at build time. @@ -527,6 +535,18 @@ pub fn load_project(path: &Path) -> Result { .collect::>>()?; app.doc.automation_revision = workspace.automation_revision; validate_resource_ids(&app.doc)?; + for canvas in &app.doc.canvases { + for item in &canvas.objects { + if let CanvasObjectKind::RasterImage(image) = &item.kind + && !app.doc.assets.contains_key(&image.asset) + { + return Err(ProjectError::Invalid(format!( + "content {} references missing asset {}", + item.id, image.asset + ))); + } + } + } let active_dataset = workspace .active_data @@ -595,6 +615,9 @@ fn validate_resource_ids(doc: &crate::state::Document) -> Result<()> { ))); } } + for canvas in &doc.canvases { + canvas.validate_structure().map_err(ProjectError::Invalid)?; + } Ok(()) } @@ -730,6 +753,8 @@ mod linefit_tests; #[cfg(test)] mod multiplet_tests; #[cfg(test)] +mod panel_schema_tests; +#[cfg(test)] mod pipeline_domain_tests; #[cfg(test)] mod pseudo_tests; diff --git a/crates/core/src/project/panel_schema_tests.rs b/crates/core/src/project/panel_schema_tests.rs new file mode 100644 index 0000000..7361b26 --- /dev/null +++ b/crates/core/src/project/panel_schema_tests.rs @@ -0,0 +1,201 @@ +use super::*; +use crate::state::{ + AssetId, AssetRecord, ContentItem, ContentKind, ObjectFrame, RasterImageContent, +}; +use sha2::{Digest, Sha256}; + +#[test] +fn final_v1_panel_and_shared_asset_round_trip_deterministically() { + let mut app = super::tests::sample_app(); + let page = &mut app.doc.canvases[0]; + let plot = page.objects[0].id; + let panel = page.create_panel("Panel a".to_owned(), page.objects[0].frame); + page.objects[0].frame.x = 0.0; + page.objects[0].frame.y = 0.0; + page.panel_mut(panel).unwrap().item_order.push(plot); + page.next_panel_label_slot = 9; + + let bytes = b"not-decoded-in-foundation-stage".to_vec(); + let asset = AssetId::new(); + app.doc.assets.insert( + asset, + AssetRecord { + id: asset, + sha256: Sha256::digest(&bytes).into(), + format: "png".to_owned(), + pixel_size: [12, 8], + bytes: bytes.clone(), + }, + ); + let image_id = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(ContentItem { + id: image_id, + name: "microscopy".to_owned(), + frame: ObjectFrame::new(4.0, 5.0, 20.0, 10.0), + locked: false, + visible: true, + kind: ContentKind::RasterImage(RasterImageContent::new(asset)), + }); + app.doc.canvases[0] + .panel_mut(panel) + .unwrap() + .item_order + .push(image_id); + + let view_a = canvas_to_view(&app.doc.datasets, &app.doc.canvases[0], "view").unwrap(); + let view_b = canvas_to_view(&app.doc.datasets, &app.doc.canvases[0], "view").unwrap(); + assert_eq!( + serde_json::to_vec(&view_a).unwrap(), + serde_json::to_vec(&view_b).unwrap() + ); + + let path = super::tests::temp_project("final-v1-panel-asset"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + let loaded_page = &loaded.doc.canvases[0]; + assert_eq!(loaded_page.panels[0].item_order, vec![plot, image_id]); + assert_eq!(loaded_page.next_panel_label_slot, 9); + assert_eq!( + loaded_page.content_page_frame(image_id), + app.doc.canvases[0].content_page_frame(image_id) + ); + assert_eq!(loaded.doc.assets[&asset].bytes, bytes); +} + +#[test] +fn distinct_asset_ids_can_share_one_content_addressed_blob() { + let mut app = super::tests::sample_app(); + let bytes = b"identical-image-bytes".to_vec(); + let digest: [u8; 32] = Sha256::digest(&bytes).into(); + let first = AssetId::new(); + let second = AssetId::new(); + for id in [first, second] { + app.doc.assets.insert( + id, + AssetRecord { + id, + sha256: digest, + format: "png".to_owned(), + pixel_size: [4, 3], + bytes: bytes.clone(), + }, + ); + let object = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(ContentItem { + id: object, + name: format!("image {id}"), + frame: ObjectFrame::new(0.0, 0.0, 10.0, 10.0), + locked: false, + visible: true, + kind: ContentKind::RasterImage(RasterImageContent::new(id)), + }); + } + + let path = super::tests::temp_project("shared-content-addressed-blob"); + let _ = std::fs::remove_file(&path); + save_project(&app, &path, false).unwrap(); + let file = std::fs::File::open(&path).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + let asset_entries = (0..archive.len()) + .filter(|&index| { + archive + .by_index(index) + .unwrap() + .name() + .starts_with("assets/") + }) + .count(); + assert_eq!(asset_entries, 1); + let loaded = load_project(&path).unwrap(); + let _ = std::fs::remove_file(&path); + + assert_eq!(loaded.doc.assets[&first].bytes, bytes); + assert_eq!(loaded.doc.assets[&second].bytes, bytes); +} + +#[test] +fn shared_blob_metadata_conflicts_are_rejected_before_save() { + let mut app = super::tests::sample_app(); + let bytes = b"same-blob-conflicting-metadata".to_vec(); + let digest: [u8; 32] = Sha256::digest(&bytes).into(); + for (pixel_size, x) in [([4, 3], 0.0), ([8, 6], 12.0)] { + let id = AssetId::new(); + app.doc.assets.insert( + id, + AssetRecord { + id, + sha256: digest, + format: "png".to_owned(), + pixel_size, + bytes: bytes.clone(), + }, + ); + let object = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(ContentItem { + id: object, + name: format!("image {id}"), + frame: ObjectFrame::new(x, 0.0, 10.0, 10.0), + locked: false, + visible: true, + kind: ContentKind::RasterImage(RasterImageContent::new(id)), + }); + } + + let path = super::tests::temp_project("conflicting-shared-blob-metadata"); + let _ = std::fs::remove_file(&path); + let error = save_project(&app, &path, false).unwrap_err(); + assert!(error.to_string().contains("conflicting metadata")); + assert!(!path.exists()); +} + +#[test] +fn asset_record_id_must_match_its_map_key() { + let mut app = super::tests::sample_app(); + let key = AssetId::new(); + let bytes = b"mismatched-asset-id".to_vec(); + app.doc.assets.insert( + key, + AssetRecord { + id: AssetId::new(), + sha256: Sha256::digest(&bytes).into(), + format: "png".to_owned(), + pixel_size: [2, 2], + bytes, + }, + ); + let object = app.doc.canvases[0].allocate_object_id(); + app.doc.canvases[0].objects.push(ContentItem { + id: object, + name: "mismatched asset".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 10.0, 10.0), + locked: false, + visible: true, + kind: ContentKind::RasterImage(RasterImageContent::new(key)), + }); + + let path = super::tests::temp_project("mismatched-asset-id"); + let _ = std::fs::remove_file(&path); + let error = save_project(&app, &path, false).unwrap_err(); + assert!(error.to_string().contains("does not match record id")); + assert!(!path.exists()); +} + +#[test] +fn schema_v1_requires_explicit_hierarchy_and_parent_fields() { + let missing_hierarchy = serde_json::json!({ + "id":"view", "role":"view", "classification":{"domain":"visualization","object":"page"}, + "name":"page", "next_object_id":1, "panel_label_style":"lower_alpha", + "layout":{"size_mm":[100.0,100.0]}, "objects":[] + }); + assert!(serde_json::from_value::(missing_hierarchy).is_err()); + + let missing_parent = serde_json::json!({ + "id":"view", "role":"view", "classification":{"domain":"visualization","object":"page"}, + "name":"page", "next_object_id":2, "next_panel_label_slot":0, "panel_label_style":"lower_alpha", + "layout":{"size_mm":[100.0,100.0]}, "panels":[], "loose_item_order":["1"], "groups":[], + "objects":[{"id":"1","name":"text","kind":"text","series":[],"frame":{"x":0.0,"y":0.0,"width":1.0,"height":1.0},"locked":false,"visible":true}] + }); + assert!(serde_json::from_value::(missing_parent).is_err()); +} diff --git a/crates/core/src/project/reference_tests.rs b/crates/core/src/project/reference_tests.rs index 164b9f4..2d10184 100644 --- a/crates/core/src/project/reference_tests.rs +++ b/crates/core/src/project/reference_tests.rs @@ -153,7 +153,12 @@ fn loading_a_maximum_object_id_reports_exhaustion() { }, "name": "Maximum object id", "next_object_id": 1, + "next_panel_label_slot": 0, + "panel_label_style": "lower_alpha", "layout": { "size_mm": [120.0, 80.0] }, + "panels": [], + "loose_item_order": [u64::MAX.to_string()], + "groups": [], "objects": [{ "id": u64::MAX.to_string(), "name": "Label", @@ -161,6 +166,7 @@ fn loading_a_maximum_object_id_reports_exhaustion() { "next_series_id": 0, "series": [], "frame": { "x": 0.0, "y": 0.0, "width": 10.0, "height": 10.0 }, + "parent_panel": {"kind":"loose"}, "locked": false, "visible": true }] @@ -208,7 +214,12 @@ fn loading_a_maximum_series_id_reports_exhaustion() { "classification": { "domain": "visualization", "object": "page" }, "name": "Maximum series id", "next_object_id": 2, + "next_panel_label_slot": 0, + "panel_label_style": "lower_alpha", "layout": { "size_mm": [120.0, 80.0] }, + "panels": [], + "loose_item_order": ["1"], + "groups": [], "objects": [{ "id": "1", "name": "Plot", @@ -220,6 +231,7 @@ fn loading_a_maximum_series_id_reports_exhaustion() { "encoding": {"kind":"line","spec":{"color":{"explicit":{"r":15,"g":77,"b":146}},"scale":1.0,"width":1.0,"x_shift":0.0}} }], "frame": { "x": 0.0, "y": 0.0, "width": 100.0, "height": 80.0 }, + "parent_panel": {"kind":"loose"}, "locked": false, "visible": true }] diff --git a/crates/core/src/project/schema_tests.rs b/crates/core/src/project/schema_tests.rs index 4042f2b..923ae0f 100644 --- a/crates/core/src/project/schema_tests.rs +++ b/crates/core/src/project/schema_tests.rs @@ -69,6 +69,7 @@ fn pre_release_loader_rejects_non_v1_schema_without_a_migration_chain() { }, objects: Vec::new(), views: Vec::new(), + assets: Vec::new(), runs: Vec::new(), workspace: "workspace.json".to_owned(), }; diff --git a/crates/core/src/project/tests.rs b/crates/core/src/project/tests.rs index 0ea655b..0636f1e 100644 --- a/crates/core/src/project/tests.rs +++ b/crates/core/src/project/tests.rs @@ -257,9 +257,6 @@ fn project_roundtrip_preserves_data_recipe_and_view() { pan: [-5.0, -8.0], }, ]; - first_plot_mut(&mut app).panel.user_note = "custom title\nHSQC summary".to_owned(); - first_plot_mut(&mut app).panel.position = [33.0, 14.0]; - first_plot_mut(&mut app).panel.visible = false; let axis_overrides = AxisOverrides { x_label: Some("Chemical shift".to_owned()), y_label: Some("Response".to_owned()), @@ -343,11 +340,6 @@ fn project_roundtrip_preserves_data_recipe_and_view() { first_plot(&loaded).viewport.view_x, first_plot(&app).viewport.view_x ); - assert_eq!( - first_plot(&loaded).panel.user_note, - "custom title\nHSQC summary" - ); - assert_eq!(first_plot(&loaded).panel.position, [33.0, 14.0]); assert_eq!(first_plot(&loaded).axis_overrides, axis_overrides); assert_eq!(first_plot(&loaded).figure().x.label, "Chemical shift"); assert_eq!(first_plot(&loaded).figure().y.label, "Response"); @@ -360,7 +352,6 @@ fn project_roundtrip_preserves_data_recipe_and_view() { AxisRange::new(-2.0, 12.0) ); assert!(!first_plot(&loaded).viewport.auto_y); - assert!(!first_plot(&loaded).panel.visible); let Dataset::Nmr(n) = &loaded.doc.datasets[0] else { panic!("expected 1D NMR dataset"); @@ -531,7 +522,6 @@ fn project_roundtrip_preserves_authoring_objects() { frame: ObjectFrame::new(10.0, 12.0, 160.0, 36.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label("Caption".to_owned())), }); let id_shape = app.doc.canvases[0].allocate_object_id(); @@ -544,7 +534,6 @@ fn project_roundtrip_preserves_authoring_objects() { frame: ObjectFrame::new(40.0, 50.0, 80.0, 20.0), locked: false, visible: false, - group: None, kind: CanvasObjectKind::Shape(shape), }); @@ -582,7 +571,6 @@ fn project_roundtrip_preserves_zorder() { frame: ObjectFrame::new(10.0, 10.0, 40.0, 20.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label(name.to_owned())), }); } diff --git a/crates/core/src/properties/object.rs b/crates/core/src/properties/object.rs index fc16030..60ca36c 100644 --- a/crates/core/src/properties/object.rs +++ b/crates/core/src/properties/object.rs @@ -3,6 +3,7 @@ use super::provider::PropertyProvider; use super::target::{require_object_target, resolved_schema, series_context_unchecked}; use super::*; +use crate::state::ObjectId; use crate::state::{ DataDomain, FieldCapabilities, ObjectStyle, PlotObject, PlotxApp, ShapeKind, StackKind, StackMode, TextAlign, default_chart_type, @@ -209,13 +210,23 @@ fn object_value( } _ => { let (plot, _, domain, capabilities) = plot_context(app, canvas, object)?; - plot_value(app, definition, plot, domain, capabilities) + plot_value( + app, + canvas, + object.id, + definition, + plot, + domain, + capabilities, + ) } } } fn plot_value( app: &PlotxApp, + canvas: usize, + object: ObjectId, definition: &'static PropertyDefinition, plot: &PlotObject, domain: DataDomain, @@ -306,9 +317,20 @@ fn plot_value( } PANEL_USER_NOTE | PANEL_VISIBLE => Ok(( if definition.id == PANEL_USER_NOTE { - PropertyValue::Text(plot.panel.user_note.clone()) + PropertyValue::Text( + app.doc.canvases[canvas] + .parent_panel(object) + .and_then(|id| app.doc.canvases[canvas].panel(id)) + .map(|panel| panel.note.clone()) + .unwrap_or_default(), + ) } else { - PropertyValue::Bool(plot.panel.visible) + PropertyValue::Bool( + app.doc.canvases[canvas] + .parent_panel(object) + .and_then(|id| app.doc.canvases[canvas].panel(id)) + .is_some_and(|panel| panel.label.visible), + ) }, fixed, None, diff --git a/crates/core/src/properties/object_definitions.rs b/crates/core/src/properties/object_definitions.rs index f278f55..1cea80f 100644 --- a/crates/core/src/properties/object_definitions.rs +++ b/crates/core/src/properties/object_definitions.rs @@ -143,7 +143,7 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ definition(CHART_COLORMAP, ValueSchema::Enum { variants: COLORMAPS }, DefaultPolicy::Fixed(PropertyValue::Enum("viridis")), "Chart colormap", &["colour map"]), definition(CHART_VIEW_AZIMUTH, ValueSchema::Float { bounds: FloatBounds::inclusive(-180.0_f64.to_radians(), 180.0_f64.to_radians()), display: FloatDisplay::Degrees, drag_step: Some(1.0) }, DefaultPolicy::Fixed(PropertyValue::Float(-50.0_f64.to_radians())), "Surface azimuth", &["view azimuth"]), definition(CHART_VIEW_ELEVATION, ValueSchema::Float { bounds: FloatBounds::inclusive(5.0_f64.to_radians(), 90.0_f64.to_radians()), display: FloatDisplay::Degrees, drag_step: Some(1.0) }, DefaultPolicy::Fixed(PropertyValue::Float(30.0_f64.to_radians())), "Surface elevation", &["view elevation"]), - definition(PANEL_USER_NOTE, ValueSchema::Text, DefaultPolicy::Fixed(PropertyValue::Text(String::new())), "Panel note", &["figure note"]), + definition(PANEL_USER_NOTE, ValueSchema::Text, DefaultPolicy::Fixed(PropertyValue::Text(String::new())), "Panel description", &["panel note", "figure note", "caption entry"]), definition(PANEL_VISIBLE, ValueSchema::Bool, DefaultPolicy::Fixed(PropertyValue::Bool(true)), "Show panel letter", &["panel label visible"]), PropertyDefinition { id: SERIES_VISIBLE, scope_kind: ScopeKind::Object, value_schema: ValueSchema::Bool, access: PropertyAccess::ReadWrite, applicability: SERIES, default_policy: DefaultPolicy::Fixed(PropertyValue::Bool(true)), tier: Tier::Essential, copies: ValueCopies::PerTarget, canonical_label: "Series visibility", canonical_aliases: &["show series"] }, definition(TEXT, ValueSchema::Text, DefaultPolicy::Derived, "Text", &["label text"]), diff --git a/crates/core/src/properties/object_tests.rs b/crates/core/src/properties/object_tests.rs index 9186da8..f79561d 100644 --- a/crates/core/src/properties/object_tests.rs +++ b/crates/core/src/properties/object_tests.rs @@ -17,7 +17,6 @@ fn object_app(kind: CanvasObjectKind) -> (PlotxApp, TargetRef, ObjectId) { frame: ObjectFrame::new(1.0, 2.0, 30.0, 20.0), locked: false, visible: true, - group: None, kind, }); app.doc.canvases.push(canvas); @@ -54,6 +53,10 @@ fn table_app() -> (PlotxApp, TargetRef, ObjectId) { id, "Plot".to_owned(), )); + let panel = canvas.create_panel("Panel a".to_owned(), canvas.objects[0].frame); + canvas.objects[0].frame.x = 0.0; + canvas.objects[0].frame.y = 0.0; + canvas.panel_mut(panel).unwrap().item_order.push(id); app.doc.canvases.push(canvas); app.session.active_canvas = Some(0); let target = app.object_target(0, id).expect("plot target"); @@ -87,6 +90,10 @@ fn stack_app() -> (PlotxApp, ObjectId) { id, "Plot".to_owned(), )); + let panel = canvas.create_panel("Panel a".to_owned(), canvas.objects[0].frame); + canvas.objects[0].frame.x = 0.0; + canvas.objects[0].frame.y = 0.0; + canvas.panel_mut(panel).unwrap().item_order.push(id); let plot = canvas.object_mut(id).unwrap().plot_mut().unwrap(); let mut second = SeriesBinding::from_dataset(&app.doc.datasets[1]).expect("series"); second.id = plot.allocate_series_id(); diff --git a/crates/core/src/properties/tests_catalog.rs b/crates/core/src/properties/tests_catalog.rs index 8a7c67e..c1b95db 100644 --- a/crates/core/src/properties/tests_catalog.rs +++ b/crates/core/src/properties/tests_catalog.rs @@ -96,7 +96,6 @@ fn every_derived_default_read_reports_a_reset_target() { frame: ObjectFrame::new(0.0, 0.0, 20.0, 10.0), locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label("derived default".to_owned())), }); let object = crate::automation::TargetRef::resource(series.resource.clone()); diff --git a/crates/core/src/properties/transaction_object.rs b/crates/core/src/properties/transaction_object.rs index 44e8b95..cbe5126 100644 --- a/crates/core/src/properties/transaction_object.rs +++ b/crates/core/src/properties/transaction_object.rs @@ -232,7 +232,18 @@ impl PropertyTransaction { { index } else { - let current = plot(app, canvas, object)?.panel.clone(); + let page = app.doc.canvases.get(canvas).ok_or_else(|| { + crate::properties::PropertyError::UnknownTarget(format!("canvas {canvas}")) + })?; + let panel = page + .parent_panel(object) + .and_then(|id| page.panel(id)) + .ok_or_else(|| { + crate::properties::PropertyError::NotApplicable( + "The plot is not inside a panel.".to_owned(), + ) + })?; + let current = PanelMeta::from_panel(panel); self.objects .panels .push((canvas, object, current.clone(), current)); diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 3a6e617..753b32c 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -41,6 +41,7 @@ impl PlotxApp { doc: SharedDocument::new(Document { datasets: Vec::new(), canvases: Vec::new(), + assets: std::collections::BTreeMap::new(), style_library: StyleLibrary::default(), project_path: None, project_revision: None, @@ -583,10 +584,9 @@ impl PlotxApp { return; }; match &o.kind { - CanvasObjectKind::PanelLabel(t) => self.doc.style_library.panel_label = t.clone(), CanvasObjectKind::Text(t) => self.doc.style_library.text = t.clone(), CanvasObjectKind::Shape(s) => self.doc.style_library.shape = s.clone(), - CanvasObjectKind::Plot(_) => return, + CanvasObjectKind::Plot(_) | CanvasObjectKind::RasterImage(_) => return, } self.session.status = "Saved as default for new objects.".to_owned(); } diff --git a/crates/core/src/state/content.rs b/crates/core/src/state/content.rs new file mode 100644 index 0000000..3de5c93 --- /dev/null +++ b/crates/core/src/state/content.rs @@ -0,0 +1,250 @@ +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageFit { + Contain, + Cover, + Stretch, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageInterpolation { + Auto, + Nearest, + Linear, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QuarterTurn { + Zero, + Clockwise90, + Clockwise180, + Clockwise270, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct RasterImageContent { + pub asset: AssetId, + /// Normalized `[left, top, right, bottom]` source rectangle. + pub crop: [f32; 4], + pub fit: ImageFit, + pub rotation: QuarterTurn, + pub opacity: f32, + pub interpolation: ImageInterpolation, + pub preserve_aspect: bool, +} + +impl RasterImageContent { + pub fn new(asset: AssetId) -> Self { + Self { + asset, + crop: [0.0, 0.0, 1.0, 1.0], + fit: ImageFit::Contain, + rotation: QuarterTurn::Zero, + opacity: 1.0, + interpolation: ImageInterpolation::Auto, + preserve_aspect: true, + } + } + + pub fn validate(&self) -> Result<(), String> { + let [left, top, right, bottom] = self.crop; + if !self.crop.into_iter().all(f32::is_finite) + || left < 0.0 + || top < 0.0 + || right > 1.0 + || bottom > 1.0 + || left >= right + || top >= bottom + { + return Err("image crop must be a non-empty normalized rectangle".to_owned()); + } + if !self.opacity.is_finite() || !(0.0..=1.0).contains(&self.opacity) { + return Err("image opacity must be between 0 and 1".to_owned()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetRecord { + pub id: AssetId, + pub sha256: [u8; 32], + pub format: String, + pub pixel_size: [u32; 2], + pub bytes: Vec, +} + +#[derive(Clone)] +pub enum ContentKind { + Plot(Box), + Text(TextBox), + Shape(ShapeObject), + RasterImage(RasterImageContent), +} + +#[derive(Clone)] +pub struct ContentItem { + pub id: ContentId, + pub name: String, + pub frame: ObjectFrame, + pub locked: bool, + pub visible: bool, + pub kind: ContentKind, +} + +pub type CanvasObject = ContentItem; +pub type CanvasObjectKind = ContentKind; + +impl ContentItem { + pub fn plot(&self) -> Option<&PlotObject> { + match &self.kind { + ContentKind::Plot(v) => Some(v), + _ => None, + } + } + pub fn plot_mut(&mut self) -> Option<&mut PlotObject> { + match &mut self.kind { + ContentKind::Plot(v) => Some(v), + _ => None, + } + } + pub fn text(&self) -> Option<&TextBox> { + match &self.kind { + ContentKind::Text(v) => Some(v), + _ => None, + } + } + pub fn text_mut(&mut self) -> Option<&mut TextBox> { + match &mut self.kind { + ContentKind::Text(v) => Some(v), + _ => None, + } + } + pub fn shape(&self) -> Option<&ShapeObject> { + match &self.kind { + ContentKind::Shape(v) => Some(v), + _ => None, + } + } + pub fn shape_mut(&mut self) -> Option<&mut ShapeObject> { + match &mut self.kind { + ContentKind::Shape(v) => Some(v), + _ => None, + } + } + pub fn is_panel_label(&self) -> bool { + false + } + pub fn style(&self) -> Option { + match &self.kind { + ContentKind::Text(v) => Some(ObjectStyle::Text(v.clone())), + ContentKind::Shape(v) => Some(ObjectStyle::Shape(v.clone())), + _ => None, + } + } + pub fn set_style(&mut self, style: &ObjectStyle) { + match (&mut self.kind, style) { + (ContentKind::Text(v), ObjectStyle::Text(value)) => *v = value.clone(), + (ContentKind::Shape(v), ObjectStyle::Shape(value)) => *v = value.clone(), + _ => {} + } + } + pub fn dataset(&self) -> Option { + self.plot().and_then(PlotObject::primary_dataset) + } + pub fn dataset_ids(&self) -> Vec { + self.plot() + .map(|plot| plot.binding.dataset_ids()) + .unwrap_or_default() + } +} + +pub fn document_item( + object: &ContentItem, + frame: ObjectFrame, + visible: bool, +) -> plotx_render::DocumentItem<'_> { + match &object.kind { + ContentKind::Plot(plot) => plotx_render::DocumentItem::Plot(plotx_render::DocumentObject { + id: format!("object_{}", object.id), + frame: frame.rect(), + figure: plot.figure(), + visible, + title: None, + }), + ContentKind::Text(text) => { + plotx_render::DocumentItem::Overlay(plotx_render::DocumentOverlay { + frame: frame.rect(), + visible, + kind: plotx_render::OverlayKind::Text(plotx_render::OverlayText { + text: &text.text, + font_size: text.font_size, + color: text.color, + align: text.align.to_render(), + bold: text.bold, + }), + }) + } + ContentKind::Shape(shape) => { + plotx_render::DocumentItem::Overlay(plotx_render::DocumentOverlay { + frame: frame.rect(), + visible, + kind: plotx_render::OverlayKind::Shape(plotx_render::OverlayShape { + shape: shape.shape.to_render(), + stroke: shape.stroke, + stroke_width: shape.stroke_width, + fill: shape.fill, + }), + }) + } + ContentKind::RasterImage(_) => { + plotx_render::DocumentItem::Overlay(plotx_render::DocumentOverlay { + frame: frame.rect(), + visible: false, + kind: plotx_render::OverlayKind::Shape(plotx_render::OverlayShape { + shape: plotx_render::OverlayShapeKind::Rect, + stroke: Color::BLACK, + stroke_width: 0.0, + fill: None, + }), + }) + } + } +} + +pub fn document_items(canvas: &CanvasDocument) -> Vec> { + let mut items: Vec<_> = canvas + .objects + .iter() + .map(|object| { + let panel_visible = canvas + .parent_panel(object.id) + .and_then(|id| canvas.panel(id)) + .is_none_or(|panel| panel.visible); + document_item( + object, + canvas.content_page_frame(object.id).unwrap_or(object.frame), + panel_visible && object.visible, + ) + }) + .collect(); + items.extend(canvas.panels.iter().filter_map(|panel| { + let text = match &panel.label.mode { + PanelLabelMode::Auto { slot } => canvas.panel_label_style.format(*slot as usize), + PanelLabelMode::LockedAuto { value } | PanelLabelMode::Manual { value } => { + value.clone() + } + }; + (!text.is_empty()).then(|| plotx_render::DocumentItem::PanelLabel { + frame: panel.frame.rect(), + text: plotx_render::DocumentText { + text, + position: panel.label.position, + font_size: panel.label.font_size, + }, + visible: panel.visible && panel.label.visible, + }) + })); + items +} diff --git a/crates/core/src/state/document.rs b/crates/core/src/state/document.rs index 4dc2572..993e131 100644 --- a/crates/core/src/state/document.rs +++ b/crates/core/src/state/document.rs @@ -76,6 +76,15 @@ impl PanelMeta { visible: true, } } + + pub fn from_panel(panel: &Panel) -> Self { + Self { + user_note: panel.note.clone(), + position: panel.label.position, + font_size: panel.label.font_size, + visible: panel.label.visible, + } + } } #[derive(Clone, Debug)] @@ -456,178 +465,6 @@ impl Default for StyleLibrary { } } -#[derive(Clone)] -pub enum CanvasObjectKind { - /// Boxed so a page of light authoring objects doesn't pay the plot's size. - Plot(Box), - Text(TextBox), - Shape(ShapeObject), - PanelLabel(TextBox), -} - -#[derive(Clone)] -pub struct CanvasObject { - pub id: ObjectId, - pub name: String, - pub frame: ObjectFrame, - pub locked: bool, - pub visible: bool, - /// Flat, non-nested grouping tag: members of one group select and move - /// together. `None` is ungrouped. - pub group: Option, - pub kind: CanvasObjectKind, -} - -impl CanvasObject { - pub fn plot(&self) -> Option<&PlotObject> { - match &self.kind { - CanvasObjectKind::Plot(plot) => Some(plot.as_ref()), - _ => None, - } - } - - pub fn plot_mut(&mut self) -> Option<&mut PlotObject> { - match &mut self.kind { - CanvasObjectKind::Plot(plot) => Some(plot.as_mut()), - _ => None, - } - } - - /// The editable text of a `Text` or `PanelLabel` object. - pub fn text(&self) -> Option<&TextBox> { - match &self.kind { - CanvasObjectKind::Text(t) | CanvasObjectKind::PanelLabel(t) => Some(t), - _ => None, - } - } - - pub fn text_mut(&mut self) -> Option<&mut TextBox> { - match &mut self.kind { - CanvasObjectKind::Text(t) | CanvasObjectKind::PanelLabel(t) => Some(t), - _ => None, - } - } - - pub fn shape(&self) -> Option<&ShapeObject> { - match &self.kind { - CanvasObjectKind::Shape(s) => Some(s), - _ => None, - } - } - - pub fn shape_mut(&mut self) -> Option<&mut ShapeObject> { - match &mut self.kind { - CanvasObjectKind::Shape(s) => Some(s), - _ => None, - } - } - - pub fn is_panel_label(&self) -> bool { - matches!(self.kind, CanvasObjectKind::PanelLabel(_)) - } - - /// A snapshot of this object's editable style, or `None` for a plot object. - pub fn style(&self) -> Option { - match &self.kind { - CanvasObjectKind::Text(t) | CanvasObjectKind::PanelLabel(t) => { - Some(ObjectStyle::Text(t.clone())) - } - CanvasObjectKind::Shape(s) => Some(ObjectStyle::Shape(s.clone())), - CanvasObjectKind::Plot(_) => None, - } - } - - /// Restore a style snapshot, preserving the object's kind (a `Text` style - /// applies to both `Text` and `PanelLabel`; mismatched kinds are ignored). - pub fn set_style(&mut self, style: &ObjectStyle) { - match (&mut self.kind, style) { - (CanvasObjectKind::Text(t) | CanvasObjectKind::PanelLabel(t), ObjectStyle::Text(v)) => { - *t = v.clone() - } - (CanvasObjectKind::Shape(s), ObjectStyle::Shape(v)) => *s = v.clone(), - _ => {} - } - } - - pub fn dataset(&self) -> Option { - self.plot().and_then(|plot| plot.primary_dataset()) - } - - /// Every dataset this object binds (all series of a plot; empty for non-plots). - /// Drives mirroring a board selection into the Data list. - pub fn dataset_ids(&self) -> Vec { - self.plot() - .map(|plot| plot.binding.dataset_ids()) - .unwrap_or_default() - } -} - -/// Map a canvas object into one render item. The item list order == z-order -/// (index 0 back, last front), so both back-ends paint in a single ordered pass. -pub fn document_item( - object: &CanvasObject, - letter: Option, -) -> plotx_render::DocumentItem<'_> { - match &object.kind { - CanvasObjectKind::Plot(plot) => { - plotx_render::DocumentItem::Plot(plotx_render::DocumentObject { - id: format!("object_{}", object.id), - frame: object.frame.rect(), - figure: plot.figure(), - visible: object.visible, - title: plot.panel.visible.then_some(letter).flatten().map(|text| { - plotx_render::DocumentText { - text, - position: plot.panel.position, - font_size: plot.panel.font_size, - } - }), - }) - } - CanvasObjectKind::Text(t) | CanvasObjectKind::PanelLabel(t) => { - plotx_render::DocumentItem::Overlay(plotx_render::DocumentOverlay { - frame: object.frame.rect(), - visible: object.visible, - kind: plotx_render::OverlayKind::Text(plotx_render::OverlayText { - text: &t.text, - font_size: t.font_size, - color: t.color, - align: t.align.to_render(), - bold: t.bold, - }), - }) - } - CanvasObjectKind::Shape(s) => { - plotx_render::DocumentItem::Overlay(plotx_render::DocumentOverlay { - frame: object.frame.rect(), - visible: object.visible, - kind: plotx_render::OverlayKind::Shape(plotx_render::OverlayShape { - shape: s.shape.to_render(), - stroke: s.stroke, - stroke_width: s.stroke_width, - fill: s.fill, - }), - }) - } - } -} - -/// The render items for a whole page in `objects` (z) order. -pub fn document_items(canvas: &CanvasDocument) -> Vec> { - let order = canvas.plot_reading_order(); - canvas - .objects - .iter() - .map(|object| { - let letter = order - .iter() - .position(|&id| id == object.id) - .map(|i| canvas.panel_label_style.format(i)); - document_item(object, letter) - }) - .collect() -} - #[derive(Clone)] pub struct CanvasDocument { /// Stable identity used by project bindings, automation and run manifests. @@ -643,6 +480,12 @@ pub struct CanvasDocument { pub auto_height: bool, pub background: Color, pub objects: Vec, + /// Semantic panels in stable page order. Content not referenced by any + /// `item_order` is page-level loose content. + pub panels: Vec, + /// Ordinary, non-semantic groups. A group contains either same-scope + /// content or panels, never a mixture. + pub groups: Vec, pub selected_object: Option, /// Top-left of this page on the board, in world (pt) space. pub board_pos: [f32; 2], @@ -657,6 +500,8 @@ pub struct CanvasDocument { pub layout: crate::layout::PageLayout, pub next_object_id: ObjectId, pub next_group_id: GroupId, + /// Stable automatic label slots are never implicitly reclaimed. + pub next_panel_label_slot: u64, } impl CanvasDocument { @@ -669,6 +514,8 @@ impl CanvasDocument { auto_height: false, background: Color::rgb(255, 255, 255), objects: Vec::new(), + panels: Vec::new(), + groups: Vec::new(), selected_object: None, board_pos: [0.0, 0.0], caption: String::new(), @@ -677,6 +524,7 @@ impl CanvasDocument { layout: crate::layout::PageLayout::default(), next_object_id: ObjectId::new(1), next_group_id: 1, + next_panel_label_slot: 0, } } @@ -725,18 +573,188 @@ impl CanvasDocument { id } + pub fn create_panel(&mut self, name: String, frame: ObjectFrame) -> PanelId { + let slot = self.next_panel_label_slot; + self.next_panel_label_slot = self.next_panel_label_slot.saturating_add(1); + let panel = Panel::new(name, frame, slot); + let id = panel.id; + self.panels.push(panel); + id + } + + pub fn panel(&self, id: PanelId) -> Option<&Panel> { + self.panels.iter().find(|panel| panel.id == id) + } + + pub fn panel_mut(&mut self, id: PanelId) -> Option<&mut Panel> { + self.panels.iter_mut().find(|panel| panel.id == id) + } + + pub fn parent_panel(&self, content: ContentId) -> Option { + self.panels + .iter() + .find(|panel| panel.item_order.contains(&content)) + .map(|panel| panel.id) + } + + pub fn panel_meta_for_content(&self, content: ContentId) -> Option { + self.parent_panel(content) + .and_then(|id| self.panel(id)) + .map(PanelMeta::from_panel) + } + + pub fn set_panel_meta_for_content(&mut self, content: ContentId, meta: PanelMeta) -> bool { + let Some(id) = self.parent_panel(content) else { + return false; + }; + let Some(panel) = self.panel_mut(id) else { + return false; + }; + panel.note = meta.user_note; + panel.label.position = meta.position; + panel.label.font_size = meta.font_size; + panel.label.visible = meta.visible; + true + } + + pub fn content_page_frame(&self, content: ContentId) -> Option { + let item = self.object(content)?; + let Some(panel) = self + .parent_panel(content) + .and_then(|panel| self.panel(panel)) + else { + return Some(item.frame); + }; + Some(ObjectFrame { + x: panel.frame.x + item.frame.x, + y: panel.frame.y + item.frame.y, + ..item.frame + }) + } + + pub fn validate_structure(&self) -> Result<(), String> { + let mut ids = std::collections::BTreeSet::new(); + for item in &self.objects { + if !ids.insert(item.id) { + return Err(format!("duplicate content id {}", item.id)); + } + validate_frame(item.frame, "content")?; + if let ContentKind::RasterImage(image) = &item.kind { + image.validate()?; + } + } + if self.panels.iter().any(|panel| { + matches!(panel.label.mode, PanelLabelMode::Auto { slot } if slot >= self.next_panel_label_slot) + }) { + return Err("next panel label slot must exceed every allocated auto slot".to_owned()); + } + validate_panel_structure(&self.panels, ids, &self.groups) + } + + pub fn structure_warnings(&self) -> Vec { + let mut labels = std::collections::BTreeMap::<&str, Vec>::new(); + for panel in &self.panels { + if let PanelLabelMode::Manual { value } = &panel.label.mode { + labels.entry(value.as_str()).or_default().push(panel.id); + } + } + labels + .into_iter() + .filter(|(_, panels)| panels.len() > 1) + .map(|(label, panels)| { + format!( + "manual panel label {label:?} is used by {} panels", + panels.len() + ) + }) + .collect() + } + /// The ids of `id`'s group in list order, or just `[id]` when ungrouped. /// Clicking any member selects the whole group. pub fn group_members(&self, id: ObjectId) -> Vec { - match self.object(id).and_then(|object| object.group) { - Some(group) => self - .objects + self.groups + .iter() + .find(|group| group.members.contains(&GroupMember::Content(id))) + .map(|group| { + group + .members + .iter() + .filter_map(|member| match member { + GroupMember::Content(id) => Some(*id), + GroupMember::Panel(_) => None, + }) + .collect() + }) + .unwrap_or_else(|| vec![id]) + } + + pub fn content_group(&self, id: ContentId) -> Option { + self.groups + .iter() + .find(|group| group.members.contains(&GroupMember::Content(id))) + .map(|group| group.id) + } + + pub(crate) fn reconcile_content_group_scopes(&mut self) { + let groups = std::mem::take(&mut self.groups); + for group in groups { + if group + .members .iter() - .filter(|object| object.group == Some(group)) - .map(|object| object.id) - .collect(), - None => vec![id], + .all(|member| matches!(member, GroupMember::Content(_))) + { + let mut scopes = + std::collections::BTreeMap::, Vec>::new(); + for member in group.members { + let GroupMember::Content(id) = member else { + unreachable!() + }; + scopes + .entry(self.parent_panel(id)) + .or_default() + .push(GroupMember::Content(id)); + } + let mut first = true; + for members in scopes.into_values().filter(|members| members.len() >= 2) { + let id = if first { + group.id + } else { + self.allocate_group_id() + }; + first = false; + self.groups.push(LayoutGroup { id, members }); + } + } else { + self.groups.push(group); + } + } + } + + pub(crate) fn apply_content_group_assignments( + &mut self, + assignments: &[(ContentId, Option)], + ) { + let changed: std::collections::BTreeSet<_> = + assignments.iter().map(|(id, _)| *id).collect(); + for group in &mut self.groups { + group.members.retain( + |member| !matches!(member, GroupMember::Content(id) if changed.contains(id)), + ); + } + self.groups.retain(|group| group.members.len() >= 2); + for &(id, group_id) in assignments { + let Some(group_id) = group_id else { continue }; + if let Some(group) = self.groups.iter_mut().find(|group| group.id == group_id) { + group.members.push(GroupMember::Content(id)); + } else { + self.groups.push(LayoutGroup { + id: group_id, + members: vec![GroupMember::Content(id)], + }); + } } + self.groups.retain(|group| group.members.len() >= 2); } pub fn object(&self, id: ObjectId) -> Option<&CanvasObject> { diff --git a/crates/core/src/state/identity.rs b/crates/core/src/state/identity.rs index b7ac6c8..03a6221 100644 --- a/crates/core/src/state/identity.rs +++ b/crates/core/src/state/identity.rs @@ -49,6 +49,8 @@ macro_rules! uuid_id { uuid_id!(DatasetId); uuid_id!(CanvasId); +uuid_id!(PanelId); +uuid_id!(AssetId); macro_rules! local_id { ($name:ident) => { @@ -119,7 +121,12 @@ macro_rules! local_id { local_id!(SeriesId); local_id!(FieldId); -local_id!(ObjectId); +local_id!(ContentId); + +/// Transitional source-level spelling used by the existing chart editing UI. +/// Persisted page content is identified by `ContentId`; this alias does not +/// create a second identity domain. +pub type ObjectId = ContentId; #[cfg(test)] #[path = "identity_tests.rs"] diff --git a/crates/core/src/state/mass_spec_app.rs b/crates/core/src/state/mass_spec_app.rs index a15bf50..c150f34 100644 --- a/crates/core/src/state/mass_spec_app.rs +++ b/crates/core/src/state/mass_spec_app.rs @@ -328,7 +328,7 @@ impl PlotxApp { series.source.field = field; series.encoding = plotx_figure::SeriesEncoding::default(); } - plot.panel.user_note = title; + object.name = title; } let selection_before = self.session.ui.selection.clone(); let before_frames = self.doc.canvases[canvas_index] @@ -428,7 +428,7 @@ impl PlotxApp { series.source.field = field; series.encoding = plotx_figure::SeriesEncoding::default(); } - plot.panel.user_note = title; + object.name = title; } let selection_before = self.session.ui.selection.clone(); let before_frames = self.doc.canvases[canvas_index] @@ -669,7 +669,7 @@ impl PlotxApp { && (Some(series.source.field) == new_tic || Some(series.source.field) == new_bpi) }) { - plot.panel.user_note = tic_note.clone(); + object.name.clone_from(&tic_note); } } } diff --git a/crates/core/src/state/mass_spec_tests.rs b/crates/core/src/state/mass_spec_tests.rs index 090585e..1f4decb 100644 --- a/crates/core/src/state/mass_spec_tests.rs +++ b/crates/core/src/state/mass_spec_tests.rs @@ -36,9 +36,9 @@ fn default_lcms_canvas_shows_uv_and_tic_with_distinct_semantic_notes() { assert_eq!(top.chart.type_id, "mass_chromatogram"); assert_eq!(top.binding.series.len(), 2); assert_eq!(bottom.chart.type_id, "mass_chromatogram"); - assert!(top.panel.user_note.starts_with("UV chromatograms")); - assert!(bottom.panel.user_note.starts_with("Total ion chromatogram")); - assert_ne!(top.panel.user_note, bottom.panel.user_note); + assert!(canvas.objects[0].name.starts_with("UV chromatograms")); + assert!(canvas.objects[1].name.starts_with("Total ion chromatogram")); + assert_ne!(canvas.objects[0].name, canvas.objects[1].name); let mut app = PlotxApp::new(); app.doc.canvases.push(canvas); @@ -67,7 +67,7 @@ fn default_lcms_canvas_without_optical_data_contains_only_tic() { assert_eq!(canvas.objects.len(), 1); let plot = canvas.objects[0].plot().unwrap(); assert_eq!(plot.chart.type_id, "mass_chromatogram"); - assert!(plot.panel.user_note.starts_with("Total ion chromatogram")); + assert!(canvas.objects[0].name.starts_with("Total ion chromatogram")); } #[test] @@ -108,8 +108,12 @@ fn stream_and_retention_time_selection_retarget_all_linked_plots() { .id_for_key(&stream_tic_key(AcquisitionStreamId::new(7))) .unwrap() ); - assert!(bottom.panel.user_note.contains("Function 7")); - assert!(bottom.panel.user_note.contains("negative polarity")); + assert!(app.doc.canvases[0].objects[1].name.contains("Function 7")); + assert!( + app.doc.canvases[0].objects[1] + .name + .contains("negative polarity") + ); app.undo(); let dataset = app.doc.datasets[0].as_mass_spec().unwrap(); @@ -317,28 +321,14 @@ fn stream_switch_uses_the_shared_undo_history() { app.doc.datasets[0].as_mass_spec().unwrap().active_stream, AcquisitionStreamId::new(7) ); - assert!( - app.doc.canvases[0].objects[1] - .plot() - .unwrap() - .panel - .user_note - .contains("Function 7") - ); + assert!(app.doc.canvases[0].objects[1].name.contains("Function 7")); app.undo(); assert_eq!( app.doc.datasets[0].as_mass_spec().unwrap().active_stream, AcquisitionStreamId::new(3) ); - assert!( - app.doc.canvases[0].objects[1] - .plot() - .unwrap() - .panel - .user_note - .contains("Function 3") - ); + assert!(app.doc.canvases[0].objects[1].name.contains("Function 3")); app.redo(); assert_eq!( @@ -401,7 +391,11 @@ fn extracted_spectrum_is_pinned_and_does_not_follow_preview_cursor() { let spectrum = app.doc.canvases[0].objects[2].plot().unwrap(); assert_eq!(spectrum.chart.type_id, "mass_spectrum"); assert_eq!(spectrum.figure().series[0].kind, SeriesKind::Stick); - assert!(spectrum.panel.user_note.contains("0.400–1.000 min")); + assert!( + app.doc.canvases[0].objects[2] + .name + .contains("0.400–1.000 min") + ); let before = spectrum.figure().series[0].points.clone(); assert!(app.select_mass_spec_spectrum_near(dataset_id, AcquisitionStreamId::new(3), 0.5)); diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index af48c10..2ba7186 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -50,6 +50,7 @@ mod axis_overrides; mod board; mod charts; mod compute; +mod content; mod cursors; mod dataset_identity; mod dataset_trace; @@ -82,6 +83,7 @@ mod nmr_integrals; mod nmr_integrals_2d; mod nus; mod page_fit; +mod panel; mod panel_label; mod peaks; mod peaks2d; @@ -130,6 +132,7 @@ pub use axis_overrides::*; pub use board::*; pub use charts::*; pub use compute::*; +pub use content::*; pub use cursors::*; pub use datasets::*; pub(crate) use datasets_2d_figure::build_processed_figure; @@ -167,6 +170,7 @@ pub(crate) use mass_spec_ranges::point_ranges; pub use mass_spec_xic::*; pub use multiplet::*; pub use page_fit::*; +pub use panel::*; pub use panel_label::*; pub use peaks::*; pub use peaks2d::*; diff --git a/crates/core/src/state/page_fit.rs b/crates/core/src/state/page_fit.rs index db73a93..e58798f 100644 --- a/crates/core/src/state/page_fit.rs +++ b/crates/core/src/state/page_fit.rs @@ -22,7 +22,9 @@ const OVERFLOW_SLACK_PT: f32 = 0.5; pub fn content_bounds_pt(canvas: &CanvasDocument) -> Option<[f32; 4]> { let mut bounds: Option<[f32; 4]> = None; for object in canvas.objects.iter().filter(|o| o.visible) { - let f = object.frame; + let Some(f) = canvas.layout_frame(object.id) else { + continue; + }; let b = bounds.get_or_insert([f.x, f.y, f.x + f.width, f.y + f.height]); b[0] = b[0].min(f.x); b[1] = b[1].min(f.y); @@ -58,7 +60,15 @@ pub fn scaled_frames( after_mm: [f32; 2], ) -> Option { let scale = content_scale_factor(before_mm, after_mm)?; - let before: Vec<_> = canvas.objects.iter().map(|o| (o.id, o.frame)).collect(); + let before: Vec<_> = canvas + .objects + .iter() + .filter_map(|object| { + canvas + .layout_frame(object.id) + .map(|frame| (object.id, frame)) + }) + .collect(); if before.is_empty() { return None; } @@ -158,7 +168,6 @@ mod tests { frame, locked: false, visible: true, - group: None, kind: CanvasObjectKind::Text(TextBox::label("x".to_owned())), }); canvas.next_object_id = ObjectId::new(2); diff --git a/crates/core/src/state/panel.rs b/crates/core/src/state/panel.rs new file mode 100644 index 0000000..c4baa79 --- /dev/null +++ b/crates/core/src/state/panel.rs @@ -0,0 +1,310 @@ +use super::{CanvasDocument, ContentId, ObjectFrame, PanelId}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PanelLabelMode { + Auto { slot: u64 }, + LockedAuto { value: String }, + Manual { value: String }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PanelLabelSpec { + pub mode: PanelLabelMode, + pub visible: bool, + pub participates_in_sequence: bool, + pub position: [f32; 2], + pub font_size: f32, +} + +impl PanelLabelSpec { + pub fn auto(slot: u64) -> Self { + Self { + mode: PanelLabelMode::Auto { slot }, + visible: true, + participates_in_sequence: true, + position: [6.0, 5.0], + font_size: 8.0, + } + } + + pub fn validate(&self) -> Result<(), String> { + if !self.position.into_iter().all(f32::is_finite) + || !self.font_size.is_finite() + || self.font_size <= 0.0 + { + return Err("panel label geometry must be finite and positive".to_owned()); + } + match &self.mode { + PanelLabelMode::Manual { value } | PanelLabelMode::LockedAuto { value } + if value.trim().is_empty() => + { + Err("manual and locked panel labels must not be empty".to_owned()) + } + _ => Ok(()), + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PanelLayout { + #[default] + Free, + VerticalStack, + HorizontalStack, + Grid { + rows: u32, + cols: u32, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Panel { + pub id: PanelId, + pub name: String, + pub frame: ObjectFrame, + pub item_order: Vec, + pub label: PanelLabelSpec, + pub note: String, + pub visible: bool, + pub locked: bool, + pub clip_children: bool, + pub layout: PanelLayout, +} + +impl Panel { + pub fn new(name: String, frame: ObjectFrame, slot: u64) -> Self { + Self { + id: PanelId::new(), + name, + frame, + item_order: Vec::new(), + label: PanelLabelSpec::auto(slot), + note: String::new(), + visible: true, + locked: false, + clip_children: false, + layout: PanelLayout::Free, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum GroupMember { + Panel(PanelId), + Content(ContentId), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LayoutGroup { + pub id: u64, + pub members: Vec, +} + +pub fn validate_panel_structure( + panels: &[Panel], + content_ids: impl IntoIterator, + groups: &[LayoutGroup], +) -> Result<(), String> { + let content_ids: BTreeSet<_> = content_ids.into_iter().collect(); + let mut panel_ids = BTreeSet::new(); + let mut parents = BTreeMap::new(); + for panel in panels { + if !panel_ids.insert(panel.id) { + return Err(format!("duplicate panel id {}", panel.id)); + } + validate_frame(panel.frame, "panel")?; + panel.label.validate()?; + let mut order = BTreeSet::new(); + for &content in &panel.item_order { + if !content_ids.contains(&content) { + return Err(format!( + "panel {} references missing content {content}", + panel.id + )); + } + if !order.insert(content) { + return Err(format!("panel {} repeats content {content}", panel.id)); + } + if let Some(first) = parents.insert(content, panel.id) { + return Err(format!( + "content {content} belongs to both panel {first} and {}", + panel.id + )); + } + } + if let PanelLayout::Grid { rows, cols } = panel.layout + && (rows == 0 || cols == 0) + { + return Err(format!("panel {} has an empty grid dimension", panel.id)); + } + } + let mut group_ids = BTreeSet::new(); + let mut grouped_members = BTreeMap::new(); + for group in groups { + if !group_ids.insert(group.id) { + return Err(format!("duplicate group id {}", group.id)); + } + let mut members = BTreeSet::new(); + let mut kind = None; + let mut content_scope = None; + if group.members.len() < 2 { + return Err(format!( + "group {} must contain at least two members", + group.id + )); + } + for member in &group.members { + if !members.insert(*member) { + return Err(format!("group {} repeats a member", group.id)); + } + if let Some(first_group) = grouped_members.insert(*member, group.id) { + return Err(format!( + "member belongs to both group {first_group} and {}", + group.id + )); + } + let member_kind = match member { + GroupMember::Panel(id) => { + if !panel_ids.contains(id) { + return Err(format!("group {} references missing panel {id}", group.id)); + } + 0 + } + GroupMember::Content(id) => { + if !content_ids.contains(id) { + return Err(format!( + "group {} references missing content {id}", + group.id + )); + } + let scope = parents.get(id).copied(); + if content_scope + .replace(scope) + .is_some_and(|prior| prior != scope) + { + return Err(format!( + "group {} contains content from different scopes", + group.id + )); + } + 1 + } + }; + if kind + .replace(member_kind) + .is_some_and(|prior| prior != member_kind) + { + return Err(format!("group {} mixes panels and content", group.id)); + } + } + if group.members.iter().any(|member| match member { + GroupMember::Panel(panel) => panels + .iter() + .find(|candidate| candidate.id == *panel) + .is_some_and(|panel| { + panel + .item_order + .iter() + .any(|content| group.members.contains(&GroupMember::Content(*content))) + }), + GroupMember::Content(_) => false, + }) { + return Err(format!( + "group {} contains a panel and its content", + group.id + )); + } + } + Ok(()) +} + +pub fn validate_frame(frame: ObjectFrame, what: &str) -> Result<(), String> { + if [frame.x, frame.y, frame.width, frame.height] + .into_iter() + .all(f32::is_finite) + && frame.width > 0.0 + && frame.height > 0.0 + { + Ok(()) + } else { + Err(format!("{what} frame must be finite and positive")) + } +} + +impl CanvasDocument { + /// 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, + /// snapping, arranging and drag actions. + pub fn layout_frame(&self, content: ContentId) -> Option { + self.parent_panel(content) + .and_then(|id| self.panel(id)) + .map(|panel| panel.frame) + .or_else(|| self.object(content).map(|item| item.frame)) + } + + /// Apply a page-space authoring frame without mixing it with a content + /// item's panel-local coordinates. Resizing a multi-content panel scales + /// its children proportionally; moving it preserves their local layout. + pub fn set_layout_frame(&mut self, content: ContentId, frame: ObjectFrame) -> bool { + let Some(panel_id) = self.parent_panel(content) else { + let Some(item) = self.object_mut(content) else { + return false; + }; + item.frame = frame; + return true; + }; + let Some(before) = self.panel(panel_id).map(|panel| panel.frame) else { + return false; + }; + let scale_x = frame.width / before.width; + let scale_y = frame.height / before.height; + let children = self + .panel(panel_id) + .map(|panel| panel.item_order.clone()) + .unwrap_or_default(); + if scale_x != 1.0 || scale_y != 1.0 { + for id in children { + if let Some(item) = self.object_mut(id) { + item.frame.x *= scale_x; + item.frame.y *= scale_y; + item.frame.width *= scale_x; + item.frame.height *= scale_y; + } + } + } + if let Some(panel) = self.panel_mut(panel_id) { + panel.frame = frame; + true + } else { + false + } + } + + /// 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 { + if let Some(panel) = self.parent_panel(content) { + return Some(panel); + } + let (name, frame) = { + let item = self.object(content)?; + item.plot()?; + (item.name.clone(), item.frame) + }; + let panel_id = self.create_panel(name.clone(), frame); + if let Some(item) = self.object_mut(content) { + item.frame.x = 0.0; + item.frame.y = 0.0; + } + let panel = self.panel_mut(panel_id)?; + panel.item_order.push(content); + panel.note = name; + Some(panel_id) + } +} + +#[cfg(test)] +#[path = "panel_tests.rs"] +mod tests; diff --git a/crates/core/src/state/panel_label.rs b/crates/core/src/state/panel_label.rs index d13a3a4..7ec924d 100644 --- a/crates/core/src/state/panel_label.rs +++ b/crates/core/src/state/panel_label.rs @@ -37,11 +37,16 @@ impl PanelLabelStyle { } pub fn from_key(key: &str) -> Self { + Self::try_from_key(key).unwrap_or_default() + } + + pub fn try_from_key(key: &str) -> Option { match key { - "upper_alpha" => PanelLabelStyle::UpperAlpha, - "lower_roman" => PanelLabelStyle::LowerRoman, - "arabic" => PanelLabelStyle::Arabic, - _ => PanelLabelStyle::LowerAlpha, + "lower_alpha" => Some(PanelLabelStyle::LowerAlpha), + "upper_alpha" => Some(PanelLabelStyle::UpperAlpha), + "lower_roman" => Some(PanelLabelStyle::LowerRoman), + "arabic" => Some(PanelLabelStyle::Arabic), + _ => None, } } @@ -98,17 +103,13 @@ fn roman_label(mut n: usize) -> String { } impl CanvasDocument { - /// Plot object ids in publication reading order: row-major by frame top + /// Panel ids in publication reading order: row-major by frame top /// (bucketed so a near-aligned row reads left-to-right), then by left edge, /// with the object id as a stable final tie-break. Drives panel lettering. - pub fn plot_reading_order(&self) -> Vec { + pub fn panel_reading_order(&self) -> Vec { const ROW_BUCKET_PT: f32 = 8.0; - let mut plots: Vec<&CanvasObject> = self - .objects - .iter() - .filter(|object| object.plot().is_some()) - .collect(); - plots.sort_by(|a, b| { + let mut panels: Vec<&Panel> = self.panels.iter().collect(); + panels.sort_by(|a, b| { let ra = (a.frame.y / ROW_BUCKET_PT).round() as i32; let rb = (b.frame.y / ROW_BUCKET_PT).round() as i32; ra.cmp(&rb) @@ -120,25 +121,49 @@ impl CanvasDocument { ) .then(a.id.cmp(&b.id)) }); - plots.iter().map(|object| object.id).collect() + panels.iter().map(|panel| panel.id).collect() + } + + pub fn plot_reading_order(&self) -> Vec { + self.panel_reading_order() + .into_iter() + .filter_map(|id| self.panel(id)) + .flat_map(|panel| panel.item_order.iter().copied()) + .filter(|id| self.object(*id).is_some_and(|item| item.plot().is_some())) + .collect() } /// `None` if `object_id` is not a plot on this page. pub fn panel_letter(&self, object_id: ObjectId) -> Option { - self.plot_reading_order() - .iter() - .position(|&id| id == object_id) - .map(|i| self.panel_label_style.format(i)) + let panel = self.parent_panel(object_id).and_then(|id| self.panel(id))?; + if !panel.label.visible { + return None; + } + match &panel.label.mode { + PanelLabelMode::Auto { slot } => Some(self.panel_label_style.format(*slot as usize)), + PanelLabelMode::LockedAuto { value } | PanelLabelMode::Manual { value } => { + Some(value.clone()) + } + } } /// Skips empty notes. pub fn panel_note_entries(&self) -> Vec<(ObjectId, String, String)> { - self.plot_reading_order() - .into_iter() - .enumerate() - .filter_map(|(i, id)| { - let note = self.object(id)?.plot()?.panel.user_note.trim(); - (!note.is_empty()).then(|| (id, self.panel_label_style.format(i), note.to_owned())) + self.panels + .iter() + .filter_map(|panel| { + let id = *panel.item_order.first()?; + let note = panel.note.trim(); + if !panel.label.visible { + return None; + } + let letter = match &panel.label.mode { + PanelLabelMode::Auto { slot } => self.panel_label_style.format(*slot as usize), + PanelLabelMode::LockedAuto { value } | PanelLabelMode::Manual { value } => { + value.clone() + } + }; + (!note.is_empty()).then(|| (id, letter, note.to_owned())) }) .collect() } diff --git a/crates/core/src/state/panel_tests.rs b/crates/core/src/state/panel_tests.rs new file mode 100644 index 0000000..a3db763 --- /dev/null +++ b/crates/core/src/state/panel_tests.rs @@ -0,0 +1,138 @@ +use super::*; +use crate::state::{CanvasDocument, ContentItem, ContentKind, TextBox}; + +fn page() -> (CanvasDocument, [ContentId; 2]) { + let mut page = CanvasDocument::new("Figure".to_owned(), [100.0, 100.0]); + let ids = [page.allocate_object_id(), page.allocate_object_id()]; + for (index, id) in ids.into_iter().enumerate() { + page.objects.push(ContentItem { + id, + name: format!("item {index}"), + frame: ObjectFrame::new(index as f32 * 20.0, 10.0, 10.0, 10.0), + locked: false, + visible: true, + kind: ContentKind::Text(TextBox::label("x".to_owned())), + }); + } + (page, ids) +} + +#[test] +fn rejects_multi_parent_duplicate_order_and_invalid_geometry() { + let (mut page, ids) = page(); + let first = page.create_panel("a".to_owned(), ObjectFrame::new(0.0, 0.0, 50.0, 50.0)); + page.panel_mut(first).unwrap().item_order = vec![ids[0], ids[0]]; + assert!(page.validate_structure().unwrap_err().contains("repeats")); + page.panel_mut(first).unwrap().item_order = vec![ids[0]]; + let second = page.create_panel("b".to_owned(), ObjectFrame::new(0.0, 0.0, 50.0, 50.0)); + page.panel_mut(second).unwrap().item_order = vec![ids[0]]; + assert!( + page.validate_structure() + .unwrap_err() + .contains("both panel") + ); + page.panel_mut(second).unwrap().item_order.clear(); + page.panels[0].frame.width = f32::NAN; + assert!(page.validate_structure().unwrap_err().contains("finite")); +} + +#[test] +fn manual_unicode_duplicates_warn_but_validate() { + let (mut page, _) = page(); + for _ in 0..2 { + let id = page.create_panel( + "supplement".to_owned(), + ObjectFrame::new(0.0, 0.0, 10.0, 10.0), + ); + page.panel_mut(id).unwrap().label.mode = PanelLabelMode::Manual { + value: "图 α".to_owned(), + }; + } + page.validate_structure().unwrap(); + assert_eq!(page.structure_warnings().len(), 1); +} + +#[test] +fn groups_reject_cross_layer_mixing() { + let (mut page, ids) = page(); + let panel = page.create_panel("a".to_owned(), ObjectFrame::new(0.0, 0.0, 10.0, 10.0)); + page.groups.push(LayoutGroup { + id: 1, + members: vec![GroupMember::Panel(panel), GroupMember::Content(ids[1])], + }); + assert!(page.validate_structure().unwrap_err().contains("mixes")); +} + +#[test] +fn groups_reject_multiple_membership_and_cross_scope_content() { + let (mut page, ids) = page(); + page.groups = vec![ + LayoutGroup { + id: 1, + members: vec![GroupMember::Content(ids[0]), GroupMember::Content(ids[1])], + }, + LayoutGroup { + id: 2, + members: vec![GroupMember::Content(ids[0]), GroupMember::Content(ids[1])], + }, + ]; + assert!( + page.validate_structure() + .unwrap_err() + .contains("both group") + ); + + page.groups.truncate(1); + let panel = page.create_panel("a".to_owned(), ObjectFrame::new(0.0, 0.0, 20.0, 20.0)); + page.panel_mut(panel).unwrap().item_order.push(ids[0]); + assert!( + page.validate_structure() + .unwrap_err() + .contains("different scopes") + ); +} + +#[test] +fn panel_visibility_and_labels_are_independent_of_content_kind() { + let (mut page, ids) = page(); + let panel = page.create_panel("a".to_owned(), ObjectFrame::new(20.0, 30.0, 40.0, 40.0)); + page.panel_mut(panel).unwrap().item_order.push(ids[0]); + page.panel_mut(panel).unwrap().visible = false; + let items = crate::state::document_items(&page); + assert!(matches!(&items[0], plotx_render::DocumentItem::Overlay(overlay) if !overlay.visible)); + assert!( + matches!(&items[2], plotx_render::DocumentItem::PanelLabel { frame, visible: false, .. } if frame.left == 20.0 && frame.top == 30.0) + ); + + page.panel_mut(panel).unwrap().visible = true; + page.panel_mut(panel).unwrap().item_order.clear(); + let items = crate::state::document_items(&page); + assert!(matches!( + items.last(), + Some(plotx_render::DocumentItem::PanelLabel { visible: true, .. }) + )); +} + +#[test] +fn layout_frame_keeps_panel_page_geometry_separate_from_local_content() { + let (mut page, ids) = page(); + let panel = page.create_panel("a".to_owned(), ObjectFrame::new(50.0, 20.0, 40.0, 30.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, 30.0); + + assert_eq!(page.layout_frame(ids[0]).unwrap().x, 50.0); + page.set_layout_frame(ids[0], ObjectFrame::new(80.0, 60.0, 20.0, 15.0)); + + assert_eq!( + page.panel(panel).unwrap().frame, + ObjectFrame::new(80.0, 60.0, 20.0, 15.0) + ); + assert_eq!( + page.object(ids[0]).unwrap().frame, + ObjectFrame::new(0.0, 0.0, 20.0, 15.0) + ); + assert_eq!( + page.content_page_frame(ids[0]).unwrap(), + ObjectFrame::new(80.0, 60.0, 20.0, 15.0) + ); +} diff --git a/crates/core/src/state/plot_object.rs b/crates/core/src/state/plot_object.rs index 11b44e3..ff1eb85 100644 --- a/crates/core/src/state/plot_object.rs +++ b/crates/core/src/state/plot_object.rs @@ -26,7 +26,6 @@ pub struct PlotObject { derived_axes: DerivedAxes, figure: Figure, pub viewport: CanvasViewport, - pub panel: PanelMeta, } impl PlotObject { @@ -41,7 +40,7 @@ impl PlotObject { axis_overrides: AxisOverrides, figure: Figure, viewport: CanvasViewport, - panel: PanelMeta, + _panel: PanelMeta, ) -> Self { let derived_axes = DerivedAxes::from_figure(&figure); Self { @@ -55,7 +54,6 @@ impl PlotObject { derived_axes, figure, viewport, - panel, } } @@ -73,7 +71,7 @@ impl PlotObject { derived_axes: DerivedAxes, figure: Figure, viewport: CanvasViewport, - panel: PanelMeta, + _panel: PanelMeta, ) -> Self { Self { display_owner, @@ -86,7 +84,6 @@ impl PlotObject { derived_axes, figure, viewport, - panel, } } diff --git a/crates/core/src/state/stack.rs b/crates/core/src/state/stack.rs index 1f8c6fd..95fafe0 100644 --- a/crates/core/src/state/stack.rs +++ b/crates/core/src/state/stack.rs @@ -597,13 +597,17 @@ impl PlotxApp { plot.mint_series_ids(); canvas.objects.push(CanvasObject { id, - name: "Plot 1".to_owned(), + name: self.default_plot_title(primary), frame, locked: false, visible: true, - group: None, kind: CanvasObjectKind::Plot(Box::new(plot)), }); + let panel_id = canvas + .create_panel_for_plot(id) + .expect("a newly materialized stack object is a plot"); + canvas.panel_mut(panel_id).expect("new panel exists").note = + self.default_plot_title(primary); let index = self.doc.canvases.len(); let canvas_count = self.doc.canvases.len(); self.execute_action(Action::insert_canvas( diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 983eeef..6880396 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -576,6 +576,7 @@ impl Default for UiState { pub struct Document { pub datasets: Vec, pub canvases: Vec, + pub assets: std::collections::BTreeMap, /// Per-kind default styles fed to the authoring create-tools. pub style_library: StyleLibrary, pub project_path: Option, diff --git a/crates/core/src/workflow.rs b/crates/core/src/workflow.rs index 5aa45c4..8d3576e 100644 --- a/crates/core/src/workflow.rs +++ b/crates/core/src/workflow.rs @@ -410,7 +410,6 @@ pub fn build_plot_object( frame, locked: false, visible: true, - group: None, kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( Some(dataset.resource_id()), crate::state::SeriesId::new(binding.series.len() as u64), @@ -436,9 +435,8 @@ pub fn build_default_canvas(dataset: &Dataset, source: &str) -> CanvasDocument { ) } -/// Build the canonical initial layout for one dataset. GUI insertion, CLI, -/// automation, and export use this same layout policy; callers supply only the -/// document-local dataset index and canvas identity. +/// Build the canonical initial layout used by GUI insertion, CLI, automation, +/// and export; callers supply only the document-local dataset and canvas identity. pub fn build_default_canvas_for_dataset( dataset: &Dataset, dataset_index: usize, @@ -477,7 +475,7 @@ pub fn build_default_canvas_for_dataset( } else if matches!(dataset, Dataset::MassSpec(_)) { "Total Ion Chromatogram".to_owned() } else { - "Plot 1".to_owned() + dataset_title(dataset) }, ); if let Dataset::MassSpec(mass_spec) = dataset { @@ -488,8 +486,8 @@ pub fn build_default_canvas_for_dataset( &optical_fields, "mass_chromatogram", ); - if let Some(plot) = first.plot_mut() { - plot.panel.user_note = format!( + if first.plot().is_some() { + first.name = format!( "UV chromatogram{} — {}", if optical_fields.len() == 1 { "" } else { "s" }, optical_fields @@ -499,11 +497,14 @@ pub fn build_default_canvas_for_dataset( .join(", ") ); } - } else if let Some(plot) = first.plot_mut() { - plot.panel.user_note = mass_spec.tic_panel_note(); + } else if first.plot().is_some() { + first.name = mass_spec.tic_panel_note(); } } canvas.objects.push(first); + canvas + .create_panel_for_plot(id) + .expect("the default object is a plot"); if has_map_and_force { let second_id = canvas.allocate_object_id(); let mut second = build_plot_object( @@ -532,6 +533,9 @@ pub fn build_default_canvas_for_dataset( plot.adopt_rebuilt_figure(figure); } canvas.objects.push(second); + canvas + .create_panel_for_plot(second_id) + .expect("the AFM companion object is a plot"); } if let Dataset::MassSpec(mass_spec) = dataset && has_uv @@ -554,7 +558,7 @@ pub fn build_default_canvas_for_dataset( series.source.field = field; series.encoding = plotx_figure::SeriesEncoding::default(); } - plot.panel.user_note = mass_spec.tic_panel_note(); + second.name = mass_spec.tic_panel_note(); let figure = build_dataset_figure( dataset, &plot.chart, @@ -563,6 +567,9 @@ pub fn build_default_canvas_for_dataset( plot.adopt_rebuilt_figure(figure); } canvas.objects.push(second); + canvas + .create_panel_for_plot(second_id) + .expect("the mass spectrum companion object is a plot"); } canvas } diff --git a/crates/core/src/workflow_tests.rs b/crates/core/src/workflow_tests.rs index f8ae452..6d99fdd 100644 --- a/crates/core/src/workflow_tests.rs +++ b/crates/core/src/workflow_tests.rs @@ -46,6 +46,21 @@ fn canonical_conversion_and_default_canvas_share_dataset_identity() { let canvas = build_default_canvas(&dataset, &source); assert_eq!(canvas.dataset_ids(), vec![dataset.resource_id()]); assert_eq!(canvas.objects.len(), 1); + assert_eq!(canvas.panels.len(), 1); + assert_eq!(canvas.panels[0].item_order, vec![canvas.objects[0].id]); + assert_eq!( + canvas.panel_letter(canvas.objects[0].id).as_deref(), + Some("a") + ); + assert_eq!(canvas.panels[0].note, dataset_title(&dataset)); + assert_ne!(canvas.panels[0].note, "Plot 1"); + assert_eq!(canvas.panel_notes().len(), 1); + assert!(crate::state::document_items(&canvas).iter().any(|item| { + matches!( + item, + plotx_render::DocumentItem::PanelLabel { visible: true, .. } + ) + })); } #[test] diff --git a/crates/render/src/emf.rs b/crates/render/src/emf.rs index 2fedc05..ca12453 100644 --- a/crates/render/src/emf.rs +++ b/crates/render/src/emf.rs @@ -98,6 +98,20 @@ pub fn export_document_emf(doc: &Document<'_>) -> Result, EmfError> { write_overlay(&mut dc, overlay); } } + DocumentItem::PanelLabel { + frame, + text, + visible, + } => { + if *visible { + write_panel_letter( + &mut dc, + &text.text, + [frame.left + text.position[0], frame.top + text.position[1]], + text.font_size, + ); + } + } } } } diff --git a/crates/render/src/emf/tests.rs b/crates/render/src/emf/tests.rs index e766106..ebcd6b3 100644 --- a/crates/render/src/emf/tests.rs +++ b/crates/render/src/emf/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::DocumentText; use plotx_figure::{Axis, ErrorBar, Figure, Series}; fn demo_document(fig: &Figure) -> Document<'_> { @@ -82,3 +83,43 @@ fn hidden_axis_text_is_absent_from_emf_while_drawing_records_remain() { "EMF still contains axis and tick drawing records" ); } + +#[test] +fn panel_label_visibility_controls_emf_text() { + let contains_utf16 = |bytes: &[u8], needle: &str| { + let encoded: Vec = needle.encode_utf16().flat_map(u16::to_le_bytes).collect(); + bytes.windows(encoded.len()).any(|window| window == encoded) + }; + let label = "PANEL_LABEL_Ω"; + let document = Document { + width: 100.0, + height: 80.0, + background: Color::rgb(255, 255, 255), + items: vec![DocumentItem::PanelLabel { + frame: Rect::new(10.0, 20.0, 50.0, 40.0), + text: DocumentText { + text: label.to_owned(), + position: [3.0, 4.0], + font_size: 9.0, + }, + visible: true, + }], + }; + let visible = export_document_emf(&document).expect("visible label export"); + assert!(contains_utf16(&visible, label)); + + let hidden = Document { + items: vec![DocumentItem::PanelLabel { + frame: Rect::new(10.0, 20.0, 50.0, 40.0), + text: DocumentText { + text: label.to_owned(), + position: [3.0, 4.0], + font_size: 9.0, + }, + visible: false, + }], + ..document + }; + let hidden = export_document_emf(&hidden).expect("hidden label export"); + assert!(!contains_utf16(&hidden, label)); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e514540..d28e04a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -60,6 +60,11 @@ pub struct Document<'a> { pub enum DocumentItem<'a> { Plot(DocumentObject<'a>), Overlay(DocumentOverlay<'a>), + PanelLabel { + frame: Rect, + text: DocumentText, + visible: bool, + }, } pub struct DocumentObject<'a> { diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index 9b2b33f..0fc6318 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -542,7 +542,19 @@ pub fn paint_document( document: &Document<'_>, viewport: DocumentViewport, ) { - paint_document_with_stats(painter, screen, document, viewport, None); + paint_document_impl(painter, screen, document, viewport, true, None); +} + +/// Paint the editable board representation. The page background stays bounded, +/// while document items may remain visible outside the page so users can +/// recover and reposition temporarily overflowing content. +pub fn paint_document_for_editor( + painter: &egui::Painter, + screen: Rect, + document: &Document<'_>, + viewport: DocumentViewport, +) { + paint_document_impl(painter, screen, document, viewport, false, None); } pub fn paint_document_with_stats( @@ -550,6 +562,17 @@ pub fn paint_document_with_stats( screen: Rect, document: &Document<'_>, viewport: DocumentViewport, + stats: Option<&mut RenderStats>, +) { + paint_document_impl(painter, screen, document, viewport, true, stats); +} + +fn paint_document_impl( + painter: &egui::Painter, + screen: Rect, + document: &Document<'_>, + viewport: DocumentViewport, + clip_items_to_page: bool, mut stats: Option<&mut RenderStats>, ) { if let Some(stats) = stats.as_deref_mut() { @@ -565,18 +588,48 @@ pub fn paint_document_with_stats( Pos2::new(page.left, page.top), Vec2::new(page.width, page.height), ); - // Page clipping also supplies the board's complete culling bound. - let painter = painter.with_clip_rect(page_rect); - painter.rect_filled(page_rect, 0.0, col(document.background)); + let page_painter = painter.with_clip_rect(page_rect); + page_painter.rect_filled(page_rect, 0.0, col(document.background)); + let item_painter = if clip_items_to_page { + page_painter + } else { + painter.clone() + }; for item in &document.items { match item { DocumentItem::Plot(object) => { - paint_document_object(&painter, page, object, viewport, stats.as_deref_mut()) + paint_document_object(&item_painter, page, object, viewport, stats.as_deref_mut()) } DocumentItem::Overlay(overlay) => { - paint_document_overlay(&painter, page, overlay, viewport) + paint_document_overlay(&item_painter, page, overlay, viewport) + } + DocumentItem::PanelLabel { + frame, + text, + visible, + } if *visible => { + let pos = Pos2::new( + page.left + (frame.left + text.position[0]) * viewport.zoom, + 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), + ); + item_painter.text( + pos + Vec2::new(0.6, 0.0), + Align2::LEFT_TOP, + &text.text, + font, + col(Color::BLACK), + ); } + DocumentItem::PanelLabel { .. } => {} } } } diff --git a/crates/render/src/screen_tests.rs b/crates/render/src/screen_tests.rs index bfb7aa7..a65c638 100644 --- a/crates/render/src/screen_tests.rs +++ b/crates/render/src/screen_tests.rs @@ -1,5 +1,59 @@ use super::{MAX_LINE_COLUMNS, MIN_LINE_COLUMNS, line_columns, screen_line_points}; +#[test] +fn editor_keeps_outside_page_items_visible_while_document_rendering_clips() { + use plotx_figure::Color; + let document = crate::Document { + width: 100.0, + height: 80.0, + background: Color::rgb(255, 255, 255), + items: vec![crate::DocumentItem::Overlay(crate::DocumentOverlay { + frame: crate::Rect::new(120.0, 10.0, 30.0, 20.0), + visible: true, + kind: crate::OverlayKind::Shape(crate::OverlayShape { + shape: crate::OverlayShapeKind::Rect, + stroke: Color::BLACK, + stroke_width: 1.0, + fill: Some(Color::BLACK), + }), + })], + }; + let render = |editor| { + let ctx = egui::Context::default(); + let output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(300.0, 200.0), + )), + ..Default::default() + }, + |ui| { + let screen = crate::Rect::new(0.0, 0.0, 300.0, 200.0); + let viewport = crate::DocumentViewport { + zoom: 1.0, + pan: [0.0, 0.0], + }; + if editor { + super::paint_document_for_editor(ui.painter(), screen, &document, viewport); + } else { + super::paint_document(ui.painter(), screen, &document, viewport); + } + }, + ); + output + .shapes + .iter() + .filter(|shape| matches!(shape.shape, egui::Shape::Rect(_))) + .map(|shape| shape.clip_rect.max.x) + .next_back() + .unwrap() + }; + + assert_eq!(render(false), 100.0); + assert!(render(true) >= 150.0); +} + #[test] fn hidden_axis_text_keeps_screen_axis_and_tick_shapes() { use plotx_figure::{Axis, Figure}; diff --git a/crates/render/src/svg.rs b/crates/render/src/svg.rs index 79e4c5f..6920fee 100644 --- a/crates/render/src/svg.rs +++ b/crates/render/src/svg.rs @@ -585,7 +585,7 @@ fn write_projection( ); } -fn write_panel_letter(s: &mut String, text: &str, position: [f32; 2], font_size: f32) { +pub(crate) fn write_panel_letter(s: &mut String, text: &str, position: [f32; 2], font_size: f32) { if text.trim().is_empty() { return; } diff --git a/crates/render/src/svg/document.rs b/crates/render/src/svg/document.rs index 2a027d5..ee37ee6 100644 --- a/crates/render/src/svg/document.rs +++ b/crates/render/src/svg/document.rs @@ -1,4 +1,6 @@ -use super::{Document, DocumentItem, Rect, write_document_object, write_overlay}; +use super::{ + Document, DocumentItem, Rect, write_document_object, write_overlay, write_panel_letter, +}; use std::fmt::Write as _; /// Render a page document to SVG using page points as the geometry space. @@ -75,6 +77,20 @@ fn export_document_with_page( write_overlay(&mut s, overlay); } } + DocumentItem::PanelLabel { + frame, + text, + visible, + } => { + if *visible { + write_panel_letter( + &mut s, + &text.text, + [frame.left + text.position[0], frame.top + text.position[1]], + text.font_size, + ); + } + } } } let _ = write!(s, ""); @@ -84,7 +100,7 @@ fn export_document_with_page( #[cfg(test)] mod tests { use super::*; - use crate::DocumentObject; + use crate::{DocumentObject, DocumentText}; use plotx_figure::{Axis, AxisFrame, Color, Figure}; #[test] @@ -122,4 +138,41 @@ mod tests { }; assert!(export_document_for_bounds(&contrasting_doc).contains("fill=\"#010203\"")); } + + #[test] + fn panel_label_uses_panel_frame_and_escapes_text() { + let document = Document { + width: 100.0, + height: 80.0, + background: Color::rgb(255, 255, 255), + items: vec![DocumentItem::PanelLabel { + frame: Rect::new(10.0, 20.0, 50.0, 40.0), + text: DocumentText { + text: "a<&".to_owned(), + position: [3.0, 4.0], + font_size: 9.0, + }, + visible: true, + }], + }; + + let svg = export_document(&document); + assert!(svg.contains(r#"x="13.00" y="33.00""#)); + assert!(svg.contains("a<&")); + assert!(!svg.contains("a<&")); + + let hidden = Document { + items: vec![DocumentItem::PanelLabel { + frame: Rect::new(10.0, 20.0, 50.0, 40.0), + text: DocumentText { + text: "HIDDEN_PANEL_LABEL".to_owned(), + position: [3.0, 4.0], + font_size: 9.0, + }, + visible: false, + }], + ..document + }; + assert!(!export_document(&hidden).contains("HIDDEN_PANEL_LABEL")); + } }