From f21cb901fc9b2af8bc6ba6e907434545a654c35a Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Sat, 14 Nov 2026 15:40:08 +0800 Subject: [PATCH] fix(import): place batches together and preserve editing state --- crates/app/src/ui/canvas/mod.rs | 4 + .../ui/file_dialogs/image_import/new_pages.rs | 57 +++-- .../src/ui/file_dialogs/image_import_tests.rs | 55 ++++- crates/core/src/actions/app_impl/apply.rs | 20 +- crates/core/src/actions/app_impl/mod.rs | 39 +++- .../core/src/actions/app_impl/native_plot.rs | 7 +- crates/core/src/actions/app_impl/revert.rs | 27 ++- crates/core/src/actions/build.rs | 3 + crates/core/src/actions/mod.rs | 3 + crates/core/src/actions/panel_detach.rs | 1 + crates/core/src/actions/tests/mod.rs | 1 + crates/core/src/automation/native_import.rs | 1 + crates/core/src/state/app_impl_io.rs | 26 ++- crates/core/src/state/board.rs | 44 ++++ crates/core/src/state/data_import.rs | 82 ++++++- crates/core/src/state/data_import_tests.rs | 216 +++++++++++++++++- crates/core/src/state/ui_state.rs | 2 + crates/core/src/state/xas_figure.rs | 1 + docs/src/content/docs/guides/images.md | 5 +- .../src/content/docs/guides/importing-data.md | 6 +- docs/src/content/docs/zh-cn/guides/images.md | 3 +- .../docs/zh-cn/guides/importing-data.md | 4 +- 22 files changed, 553 insertions(+), 54 deletions(-) diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index f54115c8..14684d01 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -137,6 +137,10 @@ pub(crate) enum CanvasInteractionClearScope { } pub fn render_central(app: &mut PlotiumApp, ui: &mut Ui) { + let viewport = ui.available_size(); + if viewport.x > 0.0 && viewport.y > 0.0 { + app.session.ui.board_viewport_aspect = viewport.x / viewport.y; + } let Some(ci) = app.session.active_canvas else { if app.doc.canvases.is_empty() && !board_frames(app).is_empty() { board::render_sheets_only(app, ui); diff --git a/crates/app/src/ui/file_dialogs/image_import/new_pages.rs b/crates/app/src/ui/file_dialogs/image_import/new_pages.rs index 4d789649..9e94b029 100644 --- a/crates/app/src/ui/file_dialogs/image_import/new_pages.rs +++ b/crates/app/src/ui/file_dialogs/image_import/new_pages.rs @@ -5,8 +5,24 @@ pub(super) fn commit( job: &mut ImportJob, results: Vec>, ) { + let sizes: Vec<_> = results + .iter() + .filter_map(|result| result.as_ref().ok()) + .map(|candidate| CanvasDocument::new(String::new(), candidate.auto_page_size_mm).size_pt()) + .collect(); + let positions = plotium_core::state::imported_board_positions( + app, + &sizes, + app.session.ui.board_viewport_aspect, + ); let first_canvas = app.doc.canvases.len(); let active_before = app.session.active_canvas; + let selection = app.session.ui.selection.clone(); + let hierarchical = app.session.ui.hierarchical_selection.clone(); + let panel_label = app.session.ui.panel_label_selection; + let plot_part = app.session.ui.plot_part_selection; + let data_selection = app.session.ui.data_selection.clone(); + let view = app.session.view; let mut failures = Vec::new(); let mut warnings = Vec::new(); let mut staged_assets: Vec = Vec::new(); @@ -45,17 +61,14 @@ pub(super) fn commit( } let index = first_canvas + imported; - let mut page = - CanvasDocument::new(format!("Figure {}", index + 1), candidate.auto_page_size_mm); + let name = candidate.display_name().to_owned(); + let mut page = CanvasDocument::new(name, candidate.auto_page_size_mm); + page.board_pos = positions[imported]; let id = page.allocate_object_id(); let [width, height] = page.size_pt(); page.objects.push(CanvasObject { id, - name: Path::new(&candidate.basename) - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or("Image") - .to_owned(), + name: candidate.display_name().to_owned(), frame: ObjectFrame::new(0.0, 0.0, width, height), locked: false, visible: true, @@ -67,16 +80,19 @@ pub(super) fn commit( }); page.create_panel_for_content(id) .expect("a raster image supports a one-item panel"); - let empty = CanvasDocument::new(page.name.clone(), page.size_mm); - page_actions.push(Action::insert_canvas( - index, - empty.clone(), - if imported == 0 { - active_before - } else { - Some(index - 1) - }, - )); + let mut empty = CanvasDocument::new(page.name.clone(), page.size_mm); + empty.board_pos = page.board_pos; + let mut insert = Action::insert_canvas(index, empty.clone(), active_before); + if let Action::InsertCanvas { + auto_place, + activate, + .. + } = &mut insert + { + *auto_place = false; + *activate = false; + } + page_actions.push(insert); page_actions.push(Action::ReplacePanelState { canvas: index, before: PanelState::of(&empty), @@ -121,5 +137,12 @@ pub(super) fn commit( } else { job.state = ImportImageState::Failed; } + app.session.active_canvas = active_before; + app.session.ui.selection = selection; + app.session.ui.hierarchical_selection = hierarchical; + app.session.ui.panel_label_selection = panel_label; + app.session.ui.plot_part_selection = plot_part; + app.session.ui.data_selection = data_selection; + app.session.view = view; record_result(app, job.operation, imported, failures, warnings); } diff --git a/crates/app/src/ui/file_dialogs/image_import_tests.rs b/crates/app/src/ui/file_dialogs/image_import_tests.rs index 1da7b6c3..7a11472b 100644 --- a/crates/app/src/ui/file_dialogs/image_import_tests.rs +++ b/crates/app/src/ui/file_dialogs/image_import_tests.rs @@ -219,7 +219,7 @@ fn successful_import_into_empty_project_creates_one_undoable_page() { assert_eq!(app.doc.canvases.len(), 1); assert_eq!(app.doc.canvases[0].objects.len(), 1); assert_eq!(app.doc.assets.len(), 1); - assert_eq!(app.session.active_canvas, Some(0)); + assert_eq!(app.session.active_canvas, None); assert_eq!( app.doc.canvases[0].objects[0].frame, ObjectFrame::new( @@ -297,6 +297,8 @@ fn multiple_images_into_empty_project_create_distinct_pages_in_source_order() { ], ); assert_eq!(app.doc.canvases.len(), 2); + assert_eq!(app.doc.canvases[0].name, "first"); + assert_eq!(app.doc.canvases[1].name, "second"); assert_eq!(app.doc.canvases[0].objects[0].name, "first"); assert_eq!(app.doc.canvases[1].objects[0].name, "second"); assert_ne!(app.doc.canvases[0].size_mm, app.doc.canvases[1].size_mm); @@ -314,7 +316,7 @@ fn multiple_images_into_empty_project_create_distinct_pages_in_source_order() { } #[test] -fn sequential_new_page_batches_never_retarget_the_active_imported_page() { +fn sequential_new_page_batches_preserve_the_existing_active_page() { let mut app = PlotiumApp::default(); app.doc.canvases.push(CanvasDocument::new( "Existing".to_owned(), @@ -332,7 +334,7 @@ fn sequential_new_page_batches_never_retarget_the_active_imported_page() { ], ); assert_eq!(app.doc.canvases.len(), 3); - assert_eq!(app.session.active_canvas, Some(2)); + assert_eq!(app.session.active_canvas, Some(0)); assert!(app.doc.canvases[0].objects.is_empty()); assert_eq!(app.session.undo_stack.len(), 1); @@ -343,21 +345,21 @@ fn sequential_new_page_batches_never_retarget_the_active_imported_page() { vec![Ok(candidate("third.png", [300, 300]))], ); assert_eq!(app.doc.canvases.len(), 4); - assert_eq!(app.session.active_canvas, Some(3)); + assert_eq!(app.session.active_canvas, Some(0)); assert_eq!(app.doc.canvases[2].objects.len(), 1); assert_eq!(app.doc.canvases[3].objects[0].name, "third"); assert_eq!(app.session.undo_stack.len(), 2); app.undo(); assert_eq!(app.doc.canvases.len(), 3); - assert_eq!(app.session.active_canvas, Some(2)); + assert_eq!(app.session.active_canvas, Some(0)); app.undo(); assert_eq!(app.doc.canvases.len(), 1); assert_eq!(app.session.active_canvas, Some(0)); app.redo(); app.redo(); assert_eq!(app.doc.canvases.len(), 4); - assert_eq!(app.session.active_canvas, Some(3)); + assert_eq!(app.session.active_canvas, Some(0)); } #[test] @@ -631,3 +633,44 @@ fn disconnected_worker_reports_and_keeps_prepared_images_once() { app.undo(); assert!(app.doc.canvases.is_empty()); } + +#[test] +fn image_batch_history_keeps_the_existing_editing_target() { + let mut app = PlotiumApp::default(); + let mut existing = ready_job(&mut app, ImportImageTarget::NewPages); + commit( + &mut app, + &mut existing, + vec![Ok(candidate("existing.png", [2, 2]))], + ); + app.session.active_canvas = Some(0); + let object = app.doc.canvases[0].objects[0].id; + app.select_object(0, object); + let selection = app.session.ui.hierarchical_selection.clone(); + app.doc.canvases[0].board_pos = [-100.0, 140.0]; + let existing = app.doc.canvases[0].board_rect_pt(); + app.session.tool = plotium_core::state::Tool::ManualPhase; + let mut job = ready_job(&mut app, ImportImageTarget::NewPages); + commit( + &mut app, + &mut job, + vec![ + Ok(candidate("wide.png", [400, 200])), + Ok(candidate("tall.png", [200, 400])), + ], + ); + assert_eq!(app.session.active_canvas, Some(0)); + assert_eq!(app.session.tool, plotium_core::state::Tool::ManualPhase); + assert_eq!(app.doc.canvases[0].board_pos, [-100.0, 140.0]); + let wide = app.doc.canvases[1].board_rect_pt(); + let tall = app.doc.canvases[2].board_rect_pt(); + assert!(wide.top > existing.bottom() && tall.top > existing.bottom()); + assert!(wide.right() < tall.left || wide.bottom() < tall.top); + app.undo(); + assert_eq!(app.session.ui.selection.object(), Some(object)); + assert_eq!(app.session.ui.hierarchical_selection, selection); + app.redo(); + assert_eq!(app.session.active_canvas, Some(0)); + assert_eq!(app.session.ui.selection.object(), Some(object)); + assert_eq!(app.session.tool, plotium_core::state::Tool::ManualPhase); +} diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs index 47aeb4d7..4d355f85 100644 --- a/crates/core/src/actions/app_impl/apply.rs +++ b/crates/core/src/actions/app_impl/apply.rs @@ -393,9 +393,10 @@ impl PlotiumApp { index, canvas, auto_place, + activate, .. } => { - self.insert_canvas_value(*index, canvas.as_ref().clone(), *auto_place); + self.insert_canvas_value(*index, canvas.as_ref().clone(), *auto_place, *activate); } Action::ApplyTheme { canvas, after, .. } => { self.apply_theme_snapshot(*canvas, after); @@ -429,6 +430,7 @@ impl PlotiumApp { canvas_index, canvas_resource_id, prepared_canvas, + board_position, dataset, canvas_name, size_mm, @@ -478,7 +480,9 @@ impl PlotiumApp { ) }); canvas.resource_id.clone_from(canvas_resource_id); - canvas.board_pos = crate::state::next_board_frame_pos(self, canvas.size_pt()); + canvas.board_pos = board_position.unwrap_or_else(|| { + crate::state::next_board_frame_pos(self, canvas.size_pt()) + }); for object in &mut canvas.objects { if let Some(plot) = object.plot_mut() { plot.set_figure_typography(self.doc.style_library.figure_typography); @@ -486,10 +490,16 @@ impl PlotiumApp { } self.doc.canvases.push(canvas); self.rebuild_canvases_for(*dataset_index); - self.session.active_canvas = Some(*canvas_index); + if board_position.is_none() { + self.session.active_canvas = Some(*canvas_index); + } + } + // Background batch insertion preserves the active editing tool + // and draft; focus_single can cancel a live manual phase edit. + if board_position.is_none() { + self.focus_single(*dataset_index); + self.session.view = PrimaryView::Canvas; } - self.focus_single(*dataset_index); - self.session.view = PrimaryView::Canvas; } Action::TransferObjects { .. } => self.apply_transfer(action), Action::TileDrop { .. } => self.apply_tile_drop(action), diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 59f85b2b..e0cd7d36 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -230,7 +230,13 @@ impl PlotiumApp { } } - fn insert_canvas_value(&mut self, index: usize, mut canvas: CanvasDocument, auto_place: bool) { + fn insert_canvas_value( + &mut self, + index: usize, + mut canvas: CanvasDocument, + auto_place: bool, + activate: bool, + ) { if index > self.doc.canvases.len() { return; } @@ -238,6 +244,14 @@ impl PlotiumApp { canvas.board_pos = crate::state::next_board_frame_pos(self, canvas.size_pt()); } self.doc.canvases.insert(index, canvas); + if !activate { + if let Some(active) = &mut self.session.active_canvas + && *active >= index + { + *active += 1; + } + return; + } self.session.active_canvas = Some(index); let active = self.doc.canvases[index] .active_dataset() @@ -262,6 +276,29 @@ impl PlotiumApp { self.set_selection(Selection::None); } + fn remove_background_import_canvas(&mut self, index: usize, fallback: Option) { + if index >= self.doc.canvases.len() { + return; + } + let removed = self.doc.canvases.remove(index).resource_id; + self.session + .ui + .frame_selection + .retain(|frame| *frame != crate::state::BoardFrameId::Page(removed)); + self.session.ui.hierarchical_selection.clear_canvas(removed); + match self.session.active_canvas { + Some(active) if active == index => { + self.session.active_canvas = fallback.filter(|&i| i < self.doc.canvases.len()); + self.sync_selection_to_active_canvas(); + if self.session.tool.is_data_tool() { + self.set_tool(crate::state::Tool::BrowseZoom); + } + } + Some(active) if active > index => self.session.active_canvas = Some(active - 1), + _ => {} + } + } + fn set_canvas_size(&mut self, canvas: usize, state: &PageSizeState) { let Some(c) = self.doc.canvases.get_mut(canvas) else { return; diff --git a/crates/core/src/actions/app_impl/native_plot.rs b/crates/core/src/actions/app_impl/native_plot.rs index 7a7ce2d3..e7f840fc 100644 --- a/crates/core/src/actions/app_impl/native_plot.rs +++ b/crates/core/src/actions/app_impl/native_plot.rs @@ -89,7 +89,12 @@ impl PlotiumApp { self.validate_plot_insertion(insertion)?; match insertion { NativePlotInsertion::NewCanvas { page, .. } => { - self.insert_canvas_value(self.doc.canvases.len(), page.as_ref().clone(), false); + self.insert_canvas_value( + self.doc.canvases.len(), + page.as_ref().clone(), + false, + true, + ); } NativePlotInsertion::ExistingCanvas { canvas, diff --git a/crates/core/src/actions/app_impl/revert.rs b/crates/core/src/actions/app_impl/revert.rs index 7753280a..5243b495 100644 --- a/crates/core/src/actions/app_impl/revert.rs +++ b/crates/core/src/actions/app_impl/revert.rs @@ -396,9 +396,14 @@ impl PlotiumApp { Action::InsertCanvas { index, active_before, + activate, .. } => { - self.remove_canvas_at(*index, *active_before); + if *activate { + self.remove_canvas_at(*index, *active_before); + } else { + self.remove_background_import_canvas(*index, *active_before); + } } Action::ApplyTheme { canvas, before, .. } => { self.apply_theme_snapshot(*canvas, before); @@ -429,8 +434,11 @@ impl PlotiumApp { inserted_object_id, active_canvas_before, active_dataset_before, + board_position, .. } => { + let background = board_position.is_some(); + let removed_active_dataset = self.active_dataset() == Some(*dataset_index); if let Some(ci) = inserted_into_existing_canvas { if let Some(canvas) = self.doc.canvases.get_mut(*ci) && let Some(id) = inserted_object_id @@ -466,6 +474,8 @@ impl PlotiumApp { self.session.ui.panel_note_inline_edit = None; } } + } else if background { + self.remove_background_import_canvas(*canvas_index, *active_canvas_before); } else if *canvas_index < self.doc.canvases.len() { self.doc.canvases.remove(*canvas_index); } @@ -473,8 +483,19 @@ impl PlotiumApp { let dataset = self.doc.datasets.remove(*dataset_index); self.forget_removed_dataset(*dataset_index, dataset.resource_id()); } - self.session.active_canvas = *active_canvas_before; - self.set_active_dataset(*active_dataset_before); + if !background { + self.session.active_canvas = *active_canvas_before; + self.set_active_dataset(*active_dataset_before); + } else if removed_active_dataset { + if self.active_dataset().is_none() { + self.set_active_dataset( + active_dataset_before.filter(|&i| i < self.doc.datasets.len()), + ); + } + if self.session.tool == crate::state::Tool::ManualPhase { + self.set_tool(crate::state::Tool::BrowseZoom); + } + } } Action::TransferObjects { .. } => self.revert_transfer(action), Action::TileDrop { .. } => self.revert_tile_drop(action), diff --git a/crates/core/src/actions/build.rs b/crates/core/src/actions/build.rs index 6074bd88..3b53dd7f 100644 --- a/crates/core/src/actions/build.rs +++ b/crates/core/src/actions/build.rs @@ -473,6 +473,7 @@ impl Action { ) -> Self { Self::InsertCanvas { index, + activate: true, auto_place: canvas.board_pos == [0.0, 0.0], canvas: Box::new(canvas), active_before, @@ -528,6 +529,7 @@ impl Action { canvas_index: app.doc.canvases.len(), canvas_resource_id: crate::state::CanvasId::new(), prepared_canvas: None, + board_position: None, dataset: Box::new(dataset), canvas_name, size_mm, @@ -556,6 +558,7 @@ impl Action { canvas_index: app.doc.canvases.len(), canvas_resource_id: crate::state::CanvasId::new(), prepared_canvas: None, + board_position: None, dataset: Box::new(dataset), canvas_name: String::new(), size_mm: crate::state::DEFAULT_CANVAS_SIZE_MM, diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 8a170702..3b3a68a6 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -517,6 +517,8 @@ pub enum Action { index: usize, canvas: Box, active_before: Option, + /// Background batches add pages without changing the editing target. + activate: bool, /// Resolve a default position on insertion; false preserves an explicit origin. auto_place: bool, }, @@ -593,6 +595,7 @@ pub enum Action { canvas_index: usize, canvas_resource_id: crate::state::CanvasId, prepared_canvas: Option>, + board_position: Option<[f32; 2]>, dataset: Box, canvas_name: String, size_mm: [f32; 2], diff --git a/crates/core/src/actions/panel_detach.rs b/crates/core/src/actions/panel_detach.rs index 1d4b1c3e..26666bb4 100644 --- a/crates/core/src/actions/panel_detach.rs +++ b/crates/core/src/actions/panel_detach.rs @@ -97,6 +97,7 @@ impl PlotiumApp { index: self.doc.canvases.len(), canvas: Box::new(destination), active_before: self.session.active_canvas, + activate: true, auto_place: false, }, Action::ReplacePanelState { diff --git a/crates/core/src/actions/tests/mod.rs b/crates/core/src/actions/tests/mod.rs index 2712fef8..af37a8f0 100644 --- a/crates/core/src/actions/tests/mod.rs +++ b/crates/core/src/actions/tests/mod.rs @@ -150,6 +150,7 @@ fn insert_dataset_existing_canvas_does_not_select_inserted_object() { canvas_index: app.doc.canvases.len(), canvas_resource_id: crate::state::CanvasId::new(), prepared_canvas: None, + board_position: None, dataset: Box::new(dataset), canvas_name: "unused".to_owned(), size_mm: DEFAULT_CANVAS_SIZE_MM, diff --git a/crates/core/src/automation/native_import.rs b/crates/core/src/automation/native_import.rs index 844347b9..8414911b 100644 --- a/crates/core/src/automation/native_import.rs +++ b/crates/core/src/automation/native_import.rs @@ -291,6 +291,7 @@ impl PreparedNativeImport { canvas_index: app.doc.canvases.len() + offset, canvas_resource_id: canvas.resource_id, prepared_canvas: Some(Box::new(canvas)), + board_position: None, dataset: Box::new(loaded.dataset), canvas_name: loaded.source, size_mm: crate::state::DEFAULT_CANVAS_SIZE_MM, diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index 3e692f5c..acb87d36 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -269,6 +269,7 @@ impl PlotiumApp { Ok(prepared) => { let super::data_import::PreparedImport { dataset, + board_position, source, format, warnings, @@ -277,7 +278,9 @@ impl PlotiumApp { .as_nmr2d() .and_then(|data| data.reconstruction_warning.clone()); let before = self.doc.datasets.len(); - if let Err(error) = self.insert_prepared_dataset(dataset, &source) { + if let Err(error) = + self.insert_prepared_dataset_at(dataset, &source, board_position) + { return self.install_prepared_import(path, Err(error)); } if self.doc.datasets.len() == before { @@ -475,14 +478,27 @@ impl PlotiumApp { } fn insert_prepared_dataset(&mut self, dataset: Dataset, source: &str) -> Result<(), String> { + self.insert_prepared_dataset_at(dataset, source, None) + } + + fn insert_prepared_dataset_at( + &mut self, + dataset: Dataset, + source: &str, + position: Option<[f32; 2]>, + ) -> Result<(), String> { let name = Self::short_name(source); - self.try_execute_action(Action::insert_dataset_with_default_canvas( + let mut action = Action::insert_dataset_with_default_canvas( self, dataset, - format!("Canvas {} — {}", self.doc.canvases.len() + 1, name), + name.to_owned(), DEFAULT_CANVAS_SIZE_MM, - )) - .map_err(|error| error.to_string()) + ); + if let Action::InsertDatasetWithCanvas { board_position, .. } = &mut action { + *board_position = position; + } + self.try_execute_action(action) + .map_err(|error| error.to_string()) } pub fn request_export(&mut self, format: ExportFormat) { diff --git a/crates/core/src/state/board.rs b/crates/core/src/state/board.rs index 17931ac3..558ff428 100644 --- a/crates/core/src/state/board.rs +++ b/crates/core/src/state/board.rs @@ -155,6 +155,50 @@ pub fn next_board_frame_pos_with_extra( [0.0, top] } +/// Place one import gesture beyond the existing workspace bounds. The caller +/// supplies the viewport aspect ratio; invalid or unavailable ratios use 1.6. +pub fn imported_board_positions( + app: &PlotiumApp, + sizes: &[[f32; 2]], + viewport_aspect: f32, +) -> Vec<[f32; 2]> { + if sizes.is_empty() { + return Vec::new(); + } + let aspect = if viewport_aspect.is_finite() && viewport_aspect > 0.0 { + viewport_aspect + } else { + 1.6 + }; + let mean_width = sizes.iter().map(|s| s[0]).sum::() / sizes.len() as f32; + let mean_height = sizes.iter().map(|s| s[1]).sum::() / sizes.len() as f32; + let columns = ((sizes.len() as f32 * aspect * (mean_height + BOARD_GUTTER_PT) + / (mean_width + BOARD_GUTTER_PT)) + .sqrt() + .round() as usize) + .clamp(1, sizes.len()); + let existing = rects_with_extra(app, &[]); + let origin = if existing.is_empty() { + [0.0, 0.0] + } else { + [ + existing + .iter() + .map(|r| r.left) + .fold(f32::INFINITY, f32::min), + existing + .iter() + .map(|r| r.bottom()) + .fold(f32::NEG_INFINITY, f32::max) + + BOARD_GUTTER_PT, + ] + }; + grid_positions(sizes, columns, BOARD_GUTTER_PT) + .into_iter() + .map(|p| [p[0] + origin[0], p[1] + origin[1]]) + .collect() +} + /// Row-major top-lefts (pt) for frames of the given `sizes` packed into `cols` /// columns with `gutter` spacing. Each column's x aligns to its widest frame and /// each row's y to its tallest, so the result is a cleanly aligned matrix. diff --git a/crates/core/src/state/data_import.rs b/crates/core/src/state/data_import.rs index aa6d7a7d..20266507 100644 --- a/crates/core/src/state/data_import.rs +++ b/crates/core/src/state/data_import.rs @@ -5,6 +5,7 @@ use std::{collections::VecDeque, path::PathBuf, sync::mpsc}; pub(super) struct PreparedImport { pub dataset: Dataset, + pub board_position: Option<[f32; 2]>, pub source: String, pub format: plotium_io::DataFormat, pub warnings: Vec, @@ -40,6 +41,7 @@ impl PreparedImport { } Ok(Self { dataset, + board_position: None, source, format: loaded.format, warnings, @@ -132,6 +134,7 @@ struct Job { receiver: mpsc::Receiver, id: ImportBatchId, cancellation: nmr::CancellationToken, + prepared: Vec<(PathBuf, PreparedImport)>, } impl Drop for Job { @@ -247,7 +250,8 @@ impl PlotiumApp { .drain(..) .map(|request| request.id) .collect(); - if let Some(job) = imports.active.take() { + if let Some(mut job) = imports.active.take() { + self.commit_import_batch(&mut imports, job.id, std::mem::take(&mut job.prepared)); ids.push(job.id); } for id in ids { @@ -394,7 +398,7 @@ impl PlotiumApp { }), )) } - /// Process at most one item per frame, bounding both preparation and UI work. + /// Receive at most one prepared item per frame; commit the batch when complete. pub fn poll_data_import(&mut self) -> bool { let mut imports = std::mem::take(&mut self.session.data_imports); if imports.active.is_none() @@ -463,6 +467,7 @@ impl PlotiumApp { receiver, id, cancellation, + prepared: Vec::new(), }) } Err(error) => { @@ -493,14 +498,31 @@ impl PlotiumApp { batch.failed() ); } - Ok(Event::Item(path, result)) => { - self.record_import_item(&mut imports, id, path, result) - } + Ok(Event::Item(path, result)) => match result { + Ok(prepared) => { + let job = imports + .active + .as_mut() + .expect("active import received an item"); + job.prepared.push((path, prepared)); + self.session.status = + format!("Preparing import: {} ready to commit.", job.prepared.len()); + } + Err(error) => self.record_import_item(&mut imports, id, path, Err(error)), + }, Ok(Event::DiscoveryFailed(path, error)) => { imports.batch_mut(id).retryable = false; self.record_import_item(&mut imports, id, path, Err(error)); } Ok(Event::Finished) => { + let prepared = std::mem::take( + &mut imports + .active + .as_mut() + .expect("active import finished") + .prepared, + ); + self.commit_import_batch(&mut imports, id, prepared); let batch = imports.batch_mut(id); self.session.status = format!( "Import complete: {} loaded, {} failed.", @@ -515,6 +537,14 @@ impl PlotiumApp { } Err(mpsc::TryRecvError::Empty) => {} Err(mpsc::TryRecvError::Disconnected) => { + let prepared = std::mem::take( + &mut imports + .active + .as_mut() + .expect("active import disconnected") + .prepared, + ); + self.commit_import_batch(&mut imports, id, prepared); let path = imports.batch_mut(id).recent.clone(); self.record_import_item( &mut imports, @@ -531,6 +561,41 @@ impl PlotiumApp { self.session.data_imports = imports; busy } + // Prepared objects are not exposed until this synchronous commit, so edits + // made while the worker runs remain separate and cannot depend on half a batch. + fn commit_import_batch( + &mut self, + imports: &mut DataImports, + id: ImportBatchId, + prepared: Vec<(PathBuf, PreparedImport)>, + ) { + let mut history = std::mem::take(&mut self.session.undo_stack); + let redo = std::mem::take(&mut self.session.redo_stack); + let mut actions = Vec::new(); + let size = + super::CanvasDocument::new(String::new(), super::DEFAULT_CANVAS_SIZE_MM).size_pt(); + let positions = super::imported_board_positions( + self, + &vec![size; prepared.len()], + self.session.ui.board_viewport_aspect, + ); + for ((path, mut item), position) in prepared.into_iter().zip(positions) { + item.board_position = Some(position); + self.record_import_item(imports, id, path, Ok(item)); + // Drain after each insertion, before the history limit can evict any + // earlier import action. No event loop runs inside this scope. + actions.append(&mut self.session.undo_stack); + } + if actions.is_empty() { + self.session.redo_stack = redo; + } else { + history.push(crate::actions::Action::Composite(actions)); + if history.len() > self.session.history_limit { + history.remove(0); + } + } + self.session.undo_stack = history; + } fn record_import_item( &mut self, imports: &mut DataImports, @@ -544,6 +609,7 @@ impl PlotiumApp { let selection = self.session.ui.data_selection.clone(); let view = self.session.view; let before = self.doc.datasets.len(); + let canvas_before = self.doc.canvases.len(); let previous_order = self .session .operation_history @@ -563,9 +629,9 @@ impl PlotiumApp { Ok(ImportTarget { dataset, canvas: self - .session - .active_canvas - .and_then(|index| self.doc.canvases.get(index)) + .doc + .canvases + .get(canvas_before) .filter(|canvas| { canvas.objects.iter().any(|object| { object diff --git a/crates/core/src/state/data_import_tests.rs b/crates/core/src/state/data_import_tests.rs index 1119ebac..eddd0157 100644 --- a/crates/core/src/state/data_import_tests.rs +++ b/crates/core/src/state/data_import_tests.rs @@ -32,6 +32,7 @@ use crate::state::XrdDataset; fn prepared() -> PreparedImport { PreparedImport { + board_position: None, dataset: Dataset::Xrd(Box::new(XrdDataset::load(plotium_io::XrdData { two_theta_deg: vec![1.0, 2.0, 3.0], intensity: vec![2.0, 4.0, 2.0], @@ -62,12 +63,13 @@ fn app_with_channel() -> (PlotiumApp, mpsc::Sender) { receiver, id, cancellation: nmr::CancellationToken::new(), + prepared: Vec::new(), }); (app, sender) } #[test] -fn commits_only_one_item_per_poll_and_preserves_selection_and_undo() { +fn commits_batch_atomically_and_preserves_selection_and_undo() { let (mut app, sender) = app_with_channel(); assert!(app.install_prepared_import(std::path::Path::new("existing"), Ok(prepared()))); let selected = app.session.ui.data_selection.clone(); @@ -78,13 +80,16 @@ fn commits_only_one_item_per_poll_and_preserves_selection_and_undo() { .unwrap(); } assert!(app.poll_data_import()); - assert_eq!(app.doc.datasets.len(), 2); + assert_eq!(app.doc.datasets.len(), 1); assert_eq!(app.session.active_canvas, active); assert_eq!(app.session.ui.data_selection, selected); assert!(app.poll_data_import()); + assert_eq!(app.doc.datasets.len(), 1); + sender.send(Event::Finished).unwrap(); + assert!(!app.poll_data_import()); assert_eq!(app.doc.datasets.len(), 3); app.undo(); - assert_eq!(app.doc.datasets.len(), 2); + assert_eq!(app.doc.datasets.len(), 1); app.redo(); assert_eq!(app.doc.datasets.len(), 3); } @@ -110,6 +115,8 @@ fn failed_item_is_reported_and_does_not_prevent_later_success() { crate::operation::OperationOutcome::Failure ); app.poll_data_import(); + sender.send(Event::Finished).unwrap(); + app.poll_data_import(); assert_eq!(app.doc.datasets.len(), 1); assert!(app.session.status.contains("1 loaded, 1 failed")); } @@ -416,6 +423,7 @@ fn cancelling_import_signals_worker_and_marks_batch_cancelled() { receiver, id, cancellation: cancellation.clone(), + prepared: Vec::new(), }); app.cancel_data_imports(); assert!(cancellation.is_cancelled()); @@ -428,3 +436,205 @@ fn cancelling_import_signals_worker_and_marks_batch_cancelled() { assert!(!app.poll_data_import()); assert!(app.doc.datasets.is_empty()); } + +#[test] +fn large_import_keeps_prior_and_interleaved_edits_as_separate_history() { + let (mut app, sender) = app_with_channel(); + assert!(app.install_prepared_import(std::path::Path::new("prior"), Ok(prepared()))); + for _ in 0..205 { + sender + .send(Event::Item("next".into(), Ok(prepared()))) + .unwrap(); + assert!(app.poll_data_import()); + } + assert_eq!(app.doc.datasets.len(), 1); + assert!(app.install_prepared_import(std::path::Path::new("interleaved"), Ok(prepared()))); + sender.send(Event::Finished).unwrap(); + assert!(!app.poll_data_import()); + assert_eq!(app.session.undo_stack.len(), 3); + assert_eq!(app.doc.datasets.len(), 207); + app.undo(); + assert_eq!(app.doc.datasets.len(), 2); + app.redo(); + assert_eq!(app.doc.datasets.len(), 207); + app.undo(); + app.undo(); + assert_eq!(app.doc.datasets.len(), 1); +} + +#[test] +fn cancel_commits_prepared_successes_as_one_step() { + let (mut app, sender) = app_with_channel(); + for _ in 0..2 { + sender + .send(Event::Item("ready".into(), Ok(prepared()))) + .unwrap(); + app.poll_data_import(); + } + app.cancel_data_imports(); + assert_eq!(app.doc.datasets.len(), 2); + assert_eq!(app.session.undo_stack.len(), 1); + app.undo(); + assert!(app.doc.datasets.is_empty()); +} + +#[test] +fn imported_batch_stays_outside_hand_arranged_content_and_undo_restores_it() { + let (mut app, sender) = app_with_channel(); + assert!(app.install_prepared_import(std::path::Path::new("prior"), Ok(prepared()))); + app.doc.canvases[0].board_pos = [-50.0, 250.0]; + let original = app.doc.canvases[0].board_rect_pt(); + for _ in 0..8 { + sender + .send(Event::Item("next".into(), Ok(prepared()))) + .unwrap(); + app.poll_data_import(); + } + sender.send(Event::Finished).unwrap(); + app.poll_data_import(); + assert_eq!(app.doc.canvases[0].board_pos, [-50.0, 250.0]); + for canvas in &app.doc.canvases[1..] { + assert!(canvas.board_pos[1] >= original.bottom() + super::super::BOARD_GUTTER_PT); + } + let positions: Vec<_> = app.doc.canvases.iter().map(|c| c.board_pos).collect(); + app.undo(); + assert_eq!(app.doc.canvases.len(), 1); + app.redo(); + assert_eq!( + positions, + app.doc + .canvases + .iter() + .map(|c| c.board_pos) + .collect::>() + ); +} + +#[test] +#[ignore = "manual import commit CPU benchmark"] +fn benchmark_atomic_import_commit() { + for count in [200, 500] { + let (mut app, sender) = app_with_channel(); + for _ in 0..count { + sender + .send(Event::Item("sample".into(), Ok(prepared()))) + .unwrap(); + app.poll_data_import(); + } + sender.send(Event::Finished).unwrap(); + let started = std::time::Instant::now(); + assert!(!app.poll_data_import()); + eprintln!( + "atomic import commit: {count} tiny XRD datasets: {:?}", + started.elapsed() + ); + assert_eq!(app.session.undo_stack.len(), 1); + assert_eq!(app.doc.datasets.len(), count); + } +} + +#[test] +fn disconnected_batch_commits_once_and_records_one_failure() { + let (mut app, sender) = app_with_channel(); + sender + .send(Event::Item("ready".into(), Ok(prepared()))) + .unwrap(); + app.poll_data_import(); + drop(sender); + assert!(!app.poll_data_import()); + assert!(!app.poll_data_import()); + let batch = app.session.data_imports.batches().back().unwrap(); + assert_eq!((batch.loaded(), batch.failed()), (1, 1)); + assert_eq!(app.session.undo_stack.len(), 1); + app.cancel_data_imports(); + assert_eq!(app.session.undo_stack.len(), 1); + app.undo(); + assert!(app.doc.datasets.is_empty()); +} + +#[test] +fn background_commit_preserves_manual_phase_tool() { + let (mut app, sender) = app_with_channel(); + assert!(app.install_prepared_import(std::path::Path::new("existing"), Ok(prepared()))); + app.session.tool = super::super::Tool::ManualPhase; + sender + .send(Event::Item("new".into(), Ok(prepared()))) + .unwrap(); + app.poll_data_import(); + sender.send(Event::Finished).unwrap(); + app.poll_data_import(); + assert_eq!(app.session.tool, super::super::Tool::ManualPhase); +} + +#[test] +fn batch_history_preserves_navigation_and_returns_import_target() { + let (mut app, sender) = app_with_channel(); + assert!(app.install_prepared_import(std::path::Path::new("existing"), Ok(prepared()))); + assert!(app.install_prepared_import(std::path::Path::new("other"), Ok(prepared()))); + app.focus_datasets(&[0, 1], Some(1)); + app.session.tool = super::super::Tool::ManualPhase; + let active = app.session.active_canvas; + let selection = app.session.ui.data_selection.clone(); + let view = app.session.view; + sender + .send(Event::Item("new".into(), Ok(prepared()))) + .unwrap(); + app.poll_data_import(); + sender.send(Event::Finished).unwrap(); + app.poll_data_import(); + let target = app.session.data_imports.batches().back().unwrap().items[0] + .result + .as_ref() + .unwrap(); + assert_eq!(target.canvas, Some(app.doc.canvases[2].resource_id)); + app.undo(); + assert_eq!(app.session.active_canvas, active); + assert_eq!(app.session.ui.data_selection, selection); + assert_eq!(app.session.tool, super::super::Tool::ManualPhase); + app.redo(); + assert_eq!(app.session.active_canvas, active); + assert_eq!(app.session.ui.data_selection, selection); + assert!(app.session.view == view); + assert_eq!(app.session.tool, super::super::Tool::ManualPhase); +} + +#[test] +fn undo_falls_back_from_the_active_imported_page() { + use crate::state::{BoardFrameId, Tool}; + + let (mut app, sender) = app_with_channel(); + assert!(app.install_prepared_import(std::path::Path::new("existing"), Ok(prepared()))); + sender + .send(Event::Item("new".into(), Ok(prepared()))) + .unwrap(); + sender.send(Event::Finished).unwrap(); + assert!(app.poll_data_import()); + assert!(!app.poll_data_import()); + + let page = app.doc.canvases[1].resource_id; + let object = app.doc.canvases[1].objects[0].id; + app.session.active_canvas = Some(1); + app.focus_single(1); + app.select_object(1, object); + app.session.ui.frame_selection = vec![BoardFrameId::Page(page)]; + app.set_tool(Tool::ManualPhase); + + app.undo(); + assert_eq!(app.session.active_canvas, Some(0)); + assert_eq!(app.session.ui.data_selection, vec![0]); + let restored = &app.doc.canvases[0]; + assert_eq!(app.session.ui.selection.object(), restored.selected_object); + for path in app.session.ui.hierarchical_selection.paths() { + assert_eq!(path.canvas, restored.resource_id); + if let Some(content) = path.content { + assert!(restored.object(content).is_some()); + } + } + assert!(app.session.ui.frame_selection.is_empty()); + assert_eq!(app.session.tool, Tool::BrowseZoom); + assert!(app.doc.canvas_index(page).is_none()); + + app.redo(); + assert_eq!(app.doc.canvas_index(page), Some(1)); + assert_eq!(app.session.active_canvas, Some(0)); +} diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 1fb48b8c..ab259bbc 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -272,6 +272,7 @@ pub struct UiState { /// Multi-selection of datasets in the Data list (Shift/Ctrl click), the input /// to the "Stack selected data" command. Transient; a plain click resets it. pub data_selection: Vec, + pub board_viewport_aspect: f32, // Used only to place new import blocks. /// Transient Data-browser state. Dataset and Derived-data branches default /// open, so only explicit collapses are recorded; Analysis defaults closed. pub data_browser_filter: String, @@ -510,6 +511,7 @@ impl Default for UiState { tile_drop: None, frame_selection: Vec::new(), data_selection: Vec::new(), + board_viewport_aspect: 1.6, data_browser_filter: String::new(), data_browser_tag: None, data_browser_batch: None, diff --git a/crates/core/src/state/xas_figure.rs b/crates/core/src/state/xas_figure.rs index cfe2072e..b20d2293 100644 --- a/crates/core/src/state/xas_figure.rs +++ b/crates/core/src/state/xas_figure.rs @@ -76,6 +76,7 @@ impl PlotiumApp { index: self.doc.canvases.len(), canvas: Box::new(page), active_before: self.session.active_canvas, + activate: true, auto_place: true, }); Ok(()) diff --git a/docs/src/content/docs/guides/images.md b/docs/src/content/docs/guides/images.md index 442eb42b..31a9f170 100644 --- a/docs/src/content/docs/guides/images.md +++ b/docs/src/content/docs/guides/images.md @@ -24,7 +24,10 @@ as Paint. Pasting in a text field continues to paste text normally. When Plotium creates a page for an image, it uses the image's physical size when DPI information is available and assumes 96 DPI otherwise. The page is at most 89 mm wide, and its height follows the image's aspect ratio. The image fills the -page without distortion. Importing several images is one undoable action. If preparation is cancelled or some files fail, the images +page without distortion. New pages are named after their files and placed as one +block in empty space, without moving existing pages or changing your current +page or selection. Importing several images is one undoable action. If preparation +is cancelled or some files fail, the images already prepared successfully are added together and can be undone in one step. If none succeeded, the project is unchanged. diff --git a/docs/src/content/docs/guides/importing-data.md b/docs/src/content/docs/guides/importing-data.md index ac023b43..cf2225e0 100644 --- a/docs/src/content/docs/guides/importing-data.md +++ b/docs/src/content/docs/guides/importing-data.md @@ -29,8 +29,10 @@ so, and otherwise goes to [table import](#import-a-table). ## Follow import progress Scientific data files and acquisition folders load in the background, so you can -continue working. When you import multiple datasets, they appear on the board in -their original discovery order without changing your current page or selection. +continue working. Prepared datasets are added together when the batch finishes +or is cancelled. Successful items form one Undo step, in discovery order, without +changing your current page or selection. New canvases form a block in empty space +rather than filling gaps in your existing layout; their names put the source first. The status bar shows the current file and success/failure counts; review failures in the diagnostic history. Use **View this import** in the import banner or **Import Results** to show diff --git a/docs/src/content/docs/zh-cn/guides/images.md b/docs/src/content/docs/zh-cn/guides/images.md index f5ea7715..045f0ca5 100644 --- a/docs/src/content/docs/zh-cn/guides/images.md +++ b/docs/src/content/docs/zh-cn/guides/images.md @@ -20,7 +20,8 @@ description: 把显微图、照片等图片添加到图形页面和 Panel 中。 Plotium 创建图片页面时,会优先采用图片的 DPI 信息确定实际尺寸;没有 DPI 信息时按 96 DPI 计算。页面宽度不超过 89 mm,高度保持图片的原始宽高比,图片会无变形地 -铺满页面。一次导入多张图片只产生一个撤销步骤。准备阶段取消或部分文件失败时,已经准备成功的 +铺满页面。新页面以文件名命名,整批放在空白区域,不移动已有页面,也不切换当前页面或选择。 +一次导入多张图片只产生一个撤销步骤。准备阶段取消或部分文件失败时,已经准备成功的 图片会一起加入项目,并可一步撤销;没有成功项时,项目保持不变。 以下格式需要使用专门的命令: diff --git a/docs/src/content/docs/zh-cn/guides/importing-data.md b/docs/src/content/docs/zh-cn/guides/importing-data.md index 10403588..c0a93167 100644 --- a/docs/src/content/docs/zh-cn/guides/importing-data.md +++ b/docs/src/content/docs/zh-cn/guides/importing-data.md @@ -24,7 +24,9 @@ Rigaku XRD 时才按这些格式打开,否则进入[表格导入](#导入表 ## 查看导入进度 科学数据文件与采集目录会在后台加载,期间可以继续操作。一次导入多个数据集时, -它们会按原始发现顺序出现在画板上,不会切换当前页面或选择。状态栏显示当前文件及 +准备成功的数据会在批次完成或取消时统一加入项目,按原始发现顺序排列,整批只占一个 +撤销步骤,不会切换当前页面或选择。新画布整块放在空白处,不填入已有布局的空隙, +名称优先显示来源。状态栏显示当前文件及 成功、失败数量;错误可在诊断历史中查看。追加导入会排在当前批次之后。打开、关闭 或新建项目会取消旧项目尚未完成的导入。