From ecfe96e321d840395b488f20feaaed961ab055c8 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 5 Aug 2026 10:49:30 +0800 Subject: [PATCH] feat(app): add contextual multi-selection --- crates/app/src/ui/canvas/board.rs | 98 +++-- crates/app/src/ui/canvas/board_marquee.rs | 94 +++++ crates/app/src/ui/canvas/board_tests.rs | 8 +- crates/app/src/ui/canvas/interactions.rs | 5 + crates/app/src/ui/canvas/mod.rs | 40 +- crates/app/src/ui/canvas/snap.rs | 26 +- crates/app/src/ui/clipboard_figure.rs | 20 +- crates/app/src/ui/command_exec.rs | 63 +++- crates/app/src/ui/commands.rs | 7 +- crates/app/src/ui/commands/identity.rs | 15 +- crates/app/src/ui/menus.rs | 1 + crates/app/src/ui/mod.rs | 1 + crates/app/src/ui/primary_sidebar.rs | 84 +++-- .../src/ui/primary_sidebar/data_browser.rs | 49 +++ .../app/src/ui/primary_sidebar/selection.rs | 354 ++++++++++++++++++ crates/app/src/ui/shortcuts.rs | 15 +- crates/core/src/actions/tests/board.rs | 7 +- crates/core/src/actions/tests/interaction.rs | 26 +- crates/core/src/actions/tests/stack.rs | 3 +- .../src/automation/resources/selection.rs | 22 +- crates/core/src/automation/tests.rs | 36 +- .../src/state/app_impl_analysis_tables.rs | 7 +- crates/core/src/state/board.rs | 25 +- crates/core/src/state/interaction.rs | 13 +- crates/core/src/state/mod.rs | 2 + crates/core/src/state/selection.rs | 55 +++ crates/core/src/state/table_execution_job.rs | 12 +- crates/core/src/state/ui_state.rs | 57 +-- .../content/docs/guides/layout-and-export.md | 7 + .../content/docs/guides/organizing-data.md | 13 +- docs/src/content/docs/reference/shortcuts.md | 10 +- .../docs/zh-cn/guides/layout-and-export.md | 6 + .../docs/zh-cn/guides/organizing-data.md | 13 +- .../content/docs/zh-cn/reference/shortcuts.md | 10 +- 34 files changed, 1016 insertions(+), 188 deletions(-) create mode 100644 crates/app/src/ui/canvas/board_marquee.rs create mode 100644 crates/app/src/ui/primary_sidebar/selection.rs create mode 100644 crates/core/src/state/selection.rs diff --git a/crates/app/src/ui/canvas/board.rs b/crates/app/src/ui/canvas/board.rs index 65d6464..11e90e1 100644 --- a/crates/app/src/ui/canvas/board.rs +++ b/crates/app/src/ui/canvas/board.rs @@ -98,6 +98,7 @@ pub(crate) fn zoom_to_selection(app: &mut PlotxApp, ctx: &egui::Context) { .frame_selection .clone() .into_iter() + .filter_map(|id| board_frame_ref(app, id)) .filter_map(|f| frame_board_rect(app, f)); bbox_of_rects(rects) }; @@ -497,7 +498,11 @@ fn paint_sheet_body( } fn activate_frame(app: &mut PlotxApp, frame: FrameRef) { - app.session.ui.frame_selection = vec![frame]; + if let Some(id) = board_frame_id(app, frame) { + app.session.ui.frame_selection = vec![id]; + app.session.ui.selection_scope = plotx_core::state::SelectionScope::Board; + app.session.ui.selection_anchors.frame = Some(id); + } match frame { FrameRef::Page(ci) => { activate_page(app, ci); @@ -514,11 +519,7 @@ fn activate_frame(app: &mut PlotxApp, frame: FrameRef) { } pub(crate) fn frame_is_selected(app: &PlotxApp, frame: FrameRef) -> bool { - let is_active = match frame { - FrameRef::Page(ci) => app.session.active_canvas == Some(ci), - FrameRef::Sheet(di) => app.active_dataset() == Some(di), - }; - is_active || app.session.ui.frame_selection.contains(&frame) + board_frame_id(app, frame).is_some_and(|id| app.session.ui.frame_selection.contains(&id)) } fn activate_page(app: &mut PlotxApp, ci: usize) { @@ -533,6 +534,9 @@ 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(), @@ -547,6 +551,7 @@ pub(crate) fn dispatch_frame_gesture(app: &mut PlotxApp, rect: egui::Rect, ui: & && let Some(p) = hover && let Some(frame) = frame_at(app, rect, p).or_else(|| frame_header_at(app, rect, p)) { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::Board; toggle_frame_selection_synced(app, frame); return true; } @@ -569,6 +574,7 @@ pub(crate) fn dispatch_frame_gesture(app: &mut PlotxApp, rect: egui::Rect, ui: & if let (true, Some(p)) = (pressed, hover) && let Some(frame) = frame_at(app, rect, p) { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::Board; activate_frame(app, frame); } if let (true, Some(p)) = (double, hover) @@ -603,11 +609,30 @@ fn handle_frame_drag(app: &mut PlotxApp, rect: egui::Rect, ui: &Ui) -> bool { && let Some(p) = hover && let Some(frame) = frame_header_at(app, rect, p) { + let Some(frame_id) = board_frame_id(app, frame) else { + return false; + }; + let preserve = app.session.ui.frame_selection.contains(&frame_id); + let selection = app.session.ui.frame_selection.clone(); + let data_selection = app.session.ui.data_selection.clone(); activate_frame(app, frame); - if let Some(before) = frame_board_pos(app, frame) { + if preserve { + app.session.ui.frame_selection = selection; + app.focus_datasets(&data_selection, app.active_dataset()); + } + let before = app + .session + .ui + .frame_selection + .iter() + .filter_map(|id| { + board_frame_ref(app, *id).and_then(|f| frame_board_pos(app, f).map(|p| (*id, p))) + }) + .collect::>(); + if !before.is_empty() { let start = BoardTransform::from_board(app.session.board, rect).screen_to_world(p); app.begin_interaction(Interaction::Frame(FrameDrag { - frame, + frame: frame_id, before, start_world: [start.x, start.y], })); @@ -615,18 +640,33 @@ fn handle_frame_drag(app: &mut PlotxApp, rect: egui::Rect, ui: &Ui) -> bool { } let drag = match &app.session.ui.interaction { - Interaction::Frame(d) => *d, + Interaction::Frame(d) => d.clone(), _ => return false, }; if primary_down && let Some(p) = hover { let world = BoardTransform::from_board(app.session.board, rect).screen_to_world(p); + let Some((_, primary_before)) = drag.before.iter().find(|(id, _)| *id == drag.frame) else { + return true; + }; let candidate = [ - drag.before[0] + (world.x - drag.start_world[0]), - drag.before[1] + (world.y - drag.start_world[1]), + primary_before[0] + (world.x - drag.start_world[0]), + primary_before[1] + (world.y - drag.start_world[1]), + ]; + let Some(primary_ref) = board_frame_ref(app, drag.frame) else { + return true; + }; + let moving = drag.before.iter().map(|(id, _)| *id).collect::>(); + let snapped = snap_dragged_frame(app, primary_ref, &moving, candidate, alt); + let delta = [ + snapped[0] - primary_before[0], + snapped[1] - primary_before[1], ]; - let snapped = snap_dragged_frame(app, drag.frame, candidate, alt); - set_frame_board_pos(app, drag.frame, snapped); + for (id, before) in &drag.before { + if let Some(frame) = board_frame_ref(app, *id) { + set_frame_board_pos(app, frame, [before[0] + delta[0], before[1] + delta[1]]); + } + } app.session.board_fit = None; app.session.board.auto_fit = false; ui.ctx().request_repaint(); @@ -634,19 +674,25 @@ fn handle_frame_drag(app: &mut PlotxApp, rect: egui::Rect, ui: &Ui) -> bool { if primary_released || !primary_down { app.reset_interaction(); - if let Some(after) = frame_board_pos(app, drag.frame) { - let action = match drag.frame { - FrameRef::Page(ci) => Some(Action::move_canvas_on_board(ci, drag.before, after)), - FrameRef::Sheet(di) => app - .doc - .datasets - .get(di) - .map(plotx_core::state::Dataset::resource_id) - .map(|id| Action::move_sheet_on_board(id, drag.before, after)), - }; - if let Some(action) = action { - app.execute_action(action); - } + let actions = drag + .before + .into_iter() + .filter_map(|(id, before)| { + let frame = board_frame_ref(app, id)?; + let after = frame_board_pos(app, frame)?; + match frame { + FrameRef::Page(ci) => Some(Action::move_canvas_on_board(ci, before, after)), + FrameRef::Sheet(_) => match id { + plotx_core::state::BoardFrameId::Sheet(dataset) => { + Some(Action::move_sheet_on_board(dataset, before, after)) + } + _ => None, + }, + } + }) + .collect::>(); + if !actions.is_empty() { + app.execute_action(Action::Composite(actions)); } } diff --git a/crates/app/src/ui/canvas/board_marquee.rs b/crates/app/src/ui/canvas/board_marquee.rs new file mode 100644 index 0000000..1c3dc48 --- /dev/null +++ b/crates/app/src/ui/canvas/board_marquee.rs @@ -0,0 +1,94 @@ +use super::*; + +pub(super) fn handle(app: &mut PlotxApp, rect: egui::Rect, ui: &Ui) -> bool { + let (hover, pressed, down, released, shift, command) = ui.input(|input| { + ( + input.pointer.hover_pos(), + input.pointer.primary_pressed(), + input.pointer.primary_down(), + input.pointer.primary_released(), + input.modifiers.shift, + input.modifiers.command || input.modifiers.ctrl, + ) + }); + if !matches!(app.session.ui.interaction, Interaction::BoardMarquee(_)) + && pressed + && let Some(point) = hover + && rect.contains(point) + && frame_at(app, rect, point).is_none() + && frame_header_at(app, rect, point).is_none() + { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::Board; + if !shift && !command { + app.session.ui.frame_selection.clear(); + } + freeze_board_for_gesture(app); + app.begin_interaction(Interaction::BoardMarquee(BoardMarqueeDrag { + start: [point.x, point.y], + current: [point.x, point.y], + additive: shift, + toggle: command, + })); + } + let mut drag = match app.session.ui.interaction { + Interaction::BoardMarquee(drag) => drag, + _ => return false, + }; + if down && let Some(point) = hover { + drag.current = [point.x, point.y]; + app.session.ui.interaction = Interaction::BoardMarquee(drag); + } + let marquee = egui::Rect::from_two_pos( + Pos2::new(drag.start[0], drag.start[1]), + Pos2::new(drag.current[0], drag.current[1]), + ); + ui.painter().rect_filled( + marquee, + 0.0, + ui.visuals().selection.bg_fill.gamma_multiply(0.15), + ); + ui.painter().rect_stroke( + marquee, + 0.0, + ui.visuals().selection.stroke, + StrokeKind::Inside, + ); + if released || !down { + let transform = BoardTransform::from_board(app.session.board, rect); + let hits = board_frames(app) + .into_iter() + .filter(|frame| { + frame_screen_rect(&transform, app, *frame) + .is_some_and(|frame_rect| marquee.intersects(frame_rect)) + }) + .filter_map(|frame| board_frame_id(app, frame)) + .collect::>(); + if drag.toggle { + for id in hits { + if let Some(index) = app + .session + .ui + .frame_selection + .iter() + .position(|item| *item == id) + { + app.session.ui.frame_selection.remove(index); + } else { + app.session.ui.frame_selection.push(id); + } + } + } else { + if !drag.additive { + app.session.ui.frame_selection.clear(); + } + for id in hits { + if !app.session.ui.frame_selection.contains(&id) { + app.session.ui.frame_selection.push(id); + } + } + } + plotx_core::state::sync_frame_selection_to_data(app); + app.reset_interaction(); + } + true +} diff --git a/crates/app/src/ui/canvas/board_tests.rs b/crates/app/src/ui/canvas/board_tests.rs index ec83e35..db7433c 100644 --- a/crates/app/src/ui/canvas/board_tests.rs +++ b/crates/app/src/ui/canvas/board_tests.rs @@ -72,7 +72,10 @@ fn frame_header_at_hits_strip_above_page() { fn toggle_frame_selection_adds_and_removes() { let mut app = app_with_pages(&[[0.0, 0.0]]); plotx_core::state::toggle_frame_selection(&mut app, FrameRef::Page(0)); - assert_eq!(app.session.ui.frame_selection, vec![FrameRef::Page(0)]); + assert_eq!( + app.session.ui.frame_selection, + vec![plotx_core::state::board_frame_id(&app, FrameRef::Page(0)).unwrap()] + ); plotx_core::state::toggle_frame_selection(&mut app, FrameRef::Page(0)); assert!(app.session.ui.frame_selection.is_empty()); } @@ -82,7 +85,8 @@ fn zoom_to_selection_targets_selected_then_all_frames() { let mut app = app_with_pages(&[[0.0, 0.0], [1000.0, 0.0]]); let ctx = egui::Context::default(); - app.session.ui.frame_selection = vec![FrameRef::Page(1)]; + app.session.ui.frame_selection = + vec![plotx_core::state::board_frame_id(&app, FrameRef::Page(1)).unwrap()]; zoom_to_selection(&mut app, &ctx); let r = app.doc.canvases[1].board_rect_pt(); match app.session.board_fit { diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index a84b115..2607c61 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -344,11 +344,14 @@ pub(crate) fn handle_object_interactions( if let Some(hit) = hit { let id = hit.object; if shift { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::CanvasObjects; app.toggle_object_selection(ci, id); } else { let keep_group = app.session.ui.selection.objects().len() > 1 && app.session.ui.selection.contains(id); if !keep_group { + app.session.ui.selection_scope = + plotx_core::state::SelectionScope::CanvasObjects; app.select_object(ci, id); } if matches!(app.interaction(), Interaction::PanelLabel(_)) { @@ -393,6 +396,7 @@ pub(crate) fn handle_object_interactions( && !page_screen_rect(app.session.board, &app.doc.canvases[ci], rect) .contains(screen_pos) { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::Board; // An empty press on the board outside any page body clears the // selection; a press over the side bars/toolbar (global pointer, no // object hit) must not. @@ -401,6 +405,7 @@ pub(crate) fn handle_object_interactions( } else if let Some(p) = page_pos.filter(|_| { page_screen_rect(app.session.board, &app.doc.canvases[ci], rect).contains(screen_pos) }) { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::CanvasObjects; // Marquee is scoped to the frame it begins in: only start when the // press lands inside this page's body, never on empty board. freeze_board_for_gesture(app); diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 2f44c39..ddaff8d 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -3,15 +3,16 @@ use plotx_core::actions::{Action, PendingViewportEdit, PendingWheelPropertyEdit} use plotx_core::layout::{self, MovableEdges, SnapGuide, SnapTargets}; use plotx_core::state::region_color; use plotx_core::state::{ - AnalysisSelection, AuthorDrag, AxisRange, BOARD_GUTTER_PT, BoardFitTarget, BoardViewport, - CanvasDocument, CanvasObject, CanvasObjectKind, Dataset, FrameDrag, FrameRef, FurnitureDrag, - FurnitureTarget, Integral2DDrag, Integral2DDragKind, IntegralDrag, Interaction, MarqueeDrag, - ObjectDrag, ObjectDragKind, ObjectFrame, ObjectId, PanDrag, PanelLabelDrag, PanelNoteEditState, - PhaseDrag, PhaseDragKind, PhaseOrient, PlotxApp, Region, RegionDrag, RegionDragKind, RegionId, - RegionSelection, ResizeHandle, SHEET_COL_W_PT, SHEET_HEADER_H_PT, SHEET_MAX_ROWS, - SHEET_ROW_H_PT, Selection, SelectionDrag, TableDataset, TextEditState, TileDropCacheKey, - TileDropPreview, Tool, ZoomAxis, ZoomDrag, board_frame_id, board_frame_ref, board_frames, - frame_board_pos, frame_board_rect, set_frame_board_pos, toggle_frame_selection_synced, + AnalysisSelection, AuthorDrag, AxisRange, BOARD_GUTTER_PT, BoardFitTarget, BoardMarqueeDrag, + BoardViewport, CanvasDocument, CanvasObject, CanvasObjectKind, Dataset, FrameDrag, FrameRef, + FurnitureDrag, FurnitureTarget, Integral2DDrag, Integral2DDragKind, IntegralDrag, Interaction, + MarqueeDrag, ObjectDrag, ObjectDragKind, ObjectFrame, ObjectId, PanDrag, PanelLabelDrag, + PanelNoteEditState, PhaseDrag, PhaseDragKind, PhaseOrient, PlotxApp, Region, RegionDrag, + RegionDragKind, RegionId, RegionSelection, ResizeHandle, SHEET_COL_W_PT, SHEET_HEADER_H_PT, + SHEET_MAX_ROWS, SHEET_ROW_H_PT, Selection, SelectionDrag, TableDataset, TextEditState, + TileDropCacheKey, TileDropPreview, Tool, ZoomAxis, ZoomDrag, board_frame_id, board_frame_ref, + board_frames, frame_board_pos, frame_board_rect, set_frame_board_pos, + toggle_frame_selection_synced, }; use plotx_core::{Integral2D, IntegralResult}; use plotx_render::Rect as PlotRect; @@ -29,6 +30,7 @@ const SNAP_PX: f32 = 6.0; mod authoring; mod board; +mod board_marquee; mod board_notes; mod chrome; mod cursors; @@ -123,6 +125,9 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { let avail = ui.available_rect_before_wrap(); let (resp, painter) = ui.allocate_painter(avail.size(), Sense::click_and_drag()); let rect = resp.rect; + ui.ctx().data_mut(|data| { + data.insert_temp(egui::Id::new("plotx.canvas.navigation_rect"), rect); + }); let chrome = ChromeStyle::from_visuals(ui.visuals(), app.settings.appearance.canvas_accent); ensure_board_view(app, rect); consume_board_reveal(app, ui.ctx()); @@ -144,16 +149,12 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { let view_consumed = pointer_owned && handle_navigation(app, ci, rect, ui); // A live non-frame gesture cannot be interrupted by a frame switch. - let frame_consumed = if pointer_owned - && !view_consumed - && matches!( - app.session.ui.interaction, - Interaction::Idle | Interaction::Frame(_) - ) { - dispatch_frame_gesture(app, rect, ui) - } else { - false - }; + let frame_consumed = + if pointer_owned && !view_consumed && app.session.ui.interaction.allows_frame_dispatch() { + dispatch_frame_gesture(app, rect, ui) + } else { + false + }; // `dispatch_frame_gesture` may have switched the active frame. let ci = app.session.active_canvas.unwrap_or(ci); @@ -595,6 +596,7 @@ fn resize_cursor(handle: ResizeHandle) -> egui::CursorIcon { #[cfg(test)] mod tests { use super::*; + use plotx_core::state::{ CanvasObject, CanvasObjectKind, CanvasViewport, PanelMeta, PlotObject, TextBox, }; diff --git a/crates/app/src/ui/canvas/snap.rs b/crates/app/src/ui/canvas/snap.rs index a1960d9..5bafb86 100644 --- a/crates/app/src/ui/canvas/snap.rs +++ b/crates/app/src/ui/canvas/snap.rs @@ -11,6 +11,7 @@ const FRAME_SNAP_TOL_PX: f32 = 8.0; pub(crate) fn snap_dragged_frame( app: &PlotxApp, frame: FrameRef, + moving: &[plotx_core::state::BoardFrameId], candidate: [f32; 2], bypass: bool, ) -> [f32; 2] { @@ -23,7 +24,7 @@ pub(crate) fn snap_dragged_frame( let size = [r.right() - r.left, r.bottom() - r.top]; let others: Vec = board_frames(app) .into_iter() - .filter(|&f| f != frame) + .filter(|&f| f != frame && board_frame_id(app, f).is_none_or(|id| !moving.contains(&id))) .filter_map(|f| frame_board_rect(app, f)) .collect(); let tol = FRAME_SNAP_TOL_PX / app.session.board.zoom.max(0.01); @@ -178,7 +179,7 @@ mod tests { let candidate = [width + BOARD_GUTTER_PT + 2.0, 3.0]; app.settings.general.snap_enabled = false; assert_eq!( - snap_dragged_frame(&app, FrameRef::Page(1), candidate, false), + snap_dragged_frame(&app, FrameRef::Page(1), &[], candidate, false), candidate ); } @@ -189,7 +190,26 @@ mod tests { let width = app.doc.canvases[0].board_rect_pt().width; let candidate = [width + BOARD_GUTTER_PT + 2.0, 3.0]; assert_eq!( - snap_dragged_frame(&app, FrameRef::Page(1), candidate, true), + snap_dragged_frame(&app, FrameRef::Page(1), &[], candidate, true), + candidate + ); + } + + #[test] + fn group_drag_does_not_snap_to_another_moving_page() { + let mut app = app_with_two_pages(); + let width = app.doc.canvases[0].board_rect_pt().width; + app.doc.canvases[1].board_pos = [width, 0.0]; + let moving = app + .doc + .canvases + .iter() + .map(|canvas| plotx_core::state::BoardFrameId::Page(canvas.resource_id)) + .collect::>(); + let candidate = [3.0, 3.0]; + + assert_eq!( + snap_dragged_frame(&app, FrameRef::Page(0), &moving, candidate, false), candidate ); } diff --git a/crates/app/src/ui/clipboard_figure.rs b/crates/app/src/ui/clipboard_figure.rs index 050facd..0cf752b 100644 --- a/crates/app/src/ui/clipboard_figure.rs +++ b/crates/app/src/ui/clipboard_figure.rs @@ -6,7 +6,7 @@ use std::fmt; use egui::Context; use plotx_core::export::{RasterError, RasterImage, RasterOptions, rasterize_canvas}; use plotx_core::operation::{Diagnostic, DiagnosticCode, OperationKind, OperationReport, Severity}; -use plotx_core::state::{FrameRef, PlotxApp}; +use plotx_core::state::{BoardFrameId, PlotxApp}; pub(super) fn copy_figure_to_clipboard(app: &mut PlotxApp, ctx: &Context) { match resolve_copy_target(app) { @@ -32,16 +32,14 @@ pub(super) fn copy_figure_to_clipboard(app: &mut PlotxApp, ctx: &Context) { /// Selected page frame wins over the active canvas, matching what the user /// perceives as "the selected figure". pub(super) fn resolve_copy_target(app: &PlotxApp) -> Option { - app.session - .ui - .frame_selection - .iter() - .find_map(|frame| match frame { - FrameRef::Page(ci) => Some(*ci), - FrameRef::Sheet(_) => None, - }) - .or(app.session.active_canvas) - .filter(|ci| *ci < app.doc.canvases.len()) + match app.session.ui.frame_selection.as_slice() { + [BoardFrameId::Page(id)] => app.doc.canvas_index(*id), + [BoardFrameId::Sheet(_)] | [_, _, ..] => None, + [] => app + .session + .active_canvas + .filter(|ci| *ci < app.doc.canvases.len()), + } } pub(super) fn copy_canvas_figure(app: &mut PlotxApp, ctx: &Context, canvas_index: usize) { diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index 06b19f1..3bf5614 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -86,7 +86,8 @@ fn execute_inner( CommandId::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), CommandId::Undo => app.undo(), CommandId::Redo => app.redo(), - CommandId::SelectAll => app.select_all_objects(), + CommandId::SelectAll => select_all_in_scope(app), + CommandId::DeselectAll => deselect_all_in_scope(app), CommandId::Group => app.group_selected(), CommandId::Ungroup => app.ungroup_selected(), CommandId::TogglePrimarySidebar => { @@ -200,6 +201,66 @@ fn execute_inner( } } +fn select_all_in_scope(app: &mut PlotxApp) { + use plotx_core::state::{Selection, SelectionScope, board_frame_id, board_frames}; + match app.session.ui.selection_scope { + SelectionScope::Board => { + app.session.ui.frame_selection = board_frames(app) + .into_iter() + .filter_map(|frame| board_frame_id(app, frame)) + .collect(); + plotx_core::state::sync_frame_selection_to_data(app); + } + SelectionScope::CanvasList => { + app.session.ui.frame_selection = app + .doc + .canvases + .iter() + .map(|canvas| plotx_core::state::BoardFrameId::Page(canvas.resource_id)) + .collect(); + plotx_core::state::sync_frame_selection_to_data(app); + } + SelectionScope::DataList => { + app.session.ui.frame_selection.clear(); + let indices = (0..app.doc.datasets.len()).collect::>(); + app.focus_datasets(&indices, app.active_dataset()); + } + SelectionScope::CanvasObjects => app.select_all_objects(), + SelectionScope::Layers => { + if let Some(ci) = app.session.active_canvas { + let ids = app.doc.canvases[ci] + .objects + .iter() + .map(|object| object.id) + .collect::>(); + app.set_selection(if ids.is_empty() { + Selection::None + } else { + Selection::Objects(ids) + }); + } + } + } + app.session.status = "Selected all items in the current context.".to_owned(); +} + +fn deselect_all_in_scope(app: &mut PlotxApp) { + use plotx_core::state::{Selection, SelectionScope}; + match app.session.ui.selection_scope { + SelectionScope::Board | SelectionScope::CanvasList => { + app.session.ui.frame_selection.clear(); + } + SelectionScope::DataList => { + app.session.ui.frame_selection.clear(); + app.focus_datasets(&[], None); + } + SelectionScope::CanvasObjects | SelectionScope::Layers => { + app.set_selection(Selection::None) + } + } + app.session.status = "Cleared the current selection.".to_owned(); +} + fn cycle_cursor(app: &mut PlotxApp) { let Some(dataset) = app.active_dataset() else { return; diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index fffc68c..7963d05 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -65,6 +65,7 @@ pub enum CommandId { Undo, Redo, SelectAll, + DeselectAll, Group, Ungroup, TogglePrimarySidebar, @@ -205,6 +206,7 @@ pub fn catalog(app: &PlotxApp) -> Vec { CommandId::Undo, CommandId::Redo, CommandId::SelectAll, + CommandId::DeselectAll, CommandId::Group, CommandId::Ungroup, CommandId::TogglePrimarySidebar, @@ -429,7 +431,10 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { ), CommandId::Undo => requires(app.can_undo(), "Nothing to undo yet."), CommandId::Redo => requires(app.can_redo(), "Nothing to redo yet."), - CommandId::SelectAll => requires(has_canvas, "Open a canvas before selecting objects."), + CommandId::SelectAll | CommandId::DeselectAll => requires( + has_canvas || !app.doc.datasets.is_empty(), + "Open a canvas or dataset before changing the selection.", + ), CommandId::Group => requires( selected >= 2, "Select at least two objects before grouping them.", diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 068506e..5e15c59 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -76,7 +76,8 @@ pub(super) fn command_identity( CommandId::Quit => plain("Quit PlotX", None), CommandId::Undo => ("Undo".into(), Some(icon::ARROW_ARC_LEFT), None), CommandId::Redo => ("Redo".into(), Some(icon::ARROW_ARC_RIGHT), None), - CommandId::SelectAll => ("Select All Objects".into(), None, None), + CommandId::SelectAll => (selection_label(app, "Select All"), None, None), + CommandId::DeselectAll => (selection_label(app, "Deselect All"), None, None), CommandId::Group => ("Group Selection".into(), None, None), CommandId::Ungroup => ("Ungroup Selection".into(), None, None), CommandId::TogglePrimarySidebar => ( @@ -353,6 +354,7 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::Undo => "edit.undo", CommandId::Redo => "edit.redo", CommandId::SelectAll => "edit.select_all", + CommandId::DeselectAll => "edit.deselect_all", CommandId::Group => "edit.group", CommandId::Ungroup => "edit.ungroup", CommandId::TogglePrimarySidebar => "view.primary_sidebar", @@ -401,6 +403,17 @@ fn simple_stable_id(id: CommandId) -> &'static str { } } +fn selection_label(app: &PlotxApp, verb: &str) -> String { + use plotx_core::state::SelectionScope; + let noun = match app.session.ui.selection_scope { + SelectionScope::CanvasObjects | SelectionScope::Layers => "Objects", + SelectionScope::Board => "Frames", + SelectionScope::CanvasList => "Canvases", + SelectionScope::DataList => "Datasets", + }; + format!("{verb} {noun}") +} + fn spacing_slug(mode: SpacingMode) -> &'static str { match mode { SpacingMode::Frame => "frame", diff --git a/crates/app/src/ui/menus.rs b/crates/app/src/ui/menus.rs index 54f25b4..28982eb 100644 --- a/crates/app/src/ui/menus.rs +++ b/crates/app/src/ui/menus.rs @@ -81,6 +81,7 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { Command(CommandId::Redo), Separator, Command(CommandId::SelectAll), + Command(CommandId::DeselectAll), Command(CommandId::Group), Command(CommandId::Ungroup), Separator, diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index e1aec7a..1e62410 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -99,6 +99,7 @@ pub fn render( || app.session.ui.settings_dialog.is_some() || batch_workflow.is_open(); if !modal_open { + primary_sidebar::selection::handle_keyboard_selection(app, &ctx); handle_command_shortcuts(app, clipboard_table_paste, &ctx); handle_escape_shortcut(app, &ctx); handle_rename_shortcut(app, &ctx); diff --git a/crates/app/src/ui/primary_sidebar.rs b/crates/app/src/ui/primary_sidebar.rs index 17dd0bd..b787da8 100644 --- a/crates/app/src/ui/primary_sidebar.rs +++ b/crates/app/src/ui/primary_sidebar.rs @@ -9,8 +9,10 @@ use plotx_core::state::{ mod board_views; mod data_browser; +pub(crate) mod selection; use board_views::board_views_section; use data_browser::{AnalysisItem, AnalysisKind, DataTree, DatasetNode}; +use selection::*; pub fn render(app: &mut PlotxApp, ui: &mut Ui) { ui.add_space(6.0); @@ -94,7 +96,7 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { return; } - let mut select: Option<(usize, bool)> = None; + let mut select: Option<(usize, SelectModifiers)> = None; let mut open_settings: Option = None; let mut delete: Option = None; let mut start_rename: Option = None; @@ -120,8 +122,11 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { let selected = crate::ui::canvas::frame_is_selected(app, FrameRef::Page(ci)); let resp = ui.selectable_label(selected, name); if resp.clicked() { - let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command || i.modifiers.ctrl); - select = Some((ci, extend)); + claim_list_keyboard_focus(ui, &resp); + select = Some((ci, select_modifiers(ui))); + } else if resp.secondary_clicked() && !selected { + claim_list_keyboard_focus(ui, &resp); + select = Some((ci, SelectModifiers::default())); } if resp.double_clicked() { start_rename = Some(ci); @@ -165,14 +170,24 @@ fn canvas_list(app: &mut PlotxApp, ui: &mut Ui) { if cancel { app.session.ui.rename = None; } - if let Some((ci, extend)) = select { - if extend { + if let Some((ci, modifiers)) = select { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::CanvasList; + let id = app.doc.canvases[ci].resource_id; + if modifiers.shift { + select_canvas_range(app, ci, modifiers.command); + } else if modifiers.command { plotx_core::state::toggle_frame_selection_synced(app, FrameRef::Page(ci)); + app.session.ui.selection_anchors.canvas = Some(id); + app.session.ui.selection_anchors.canvas_lead = Some(id); } else { app.activate_canvas(ci); app.session.ui.panel_note_inline_edit = None; app.session.ui.panel_note_edit = None; - app.session.ui.frame_selection = vec![FrameRef::Page(ci)]; + if let Some(id) = plotx_core::state::board_frame_id(app, FrameRef::Page(ci)) { + app.session.ui.frame_selection = vec![id]; + } + app.session.ui.selection_anchors.canvas = Some(id); + app.session.ui.selection_anchors.canvas_lead = Some(id); crate::ui::canvas::request_board_fit(app, ui.ctx(), FrameRef::Page(ci)); } } @@ -242,8 +257,9 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { let selected = app.session.ui.selection.contains(object_id) || app.session.ui.selection.object() == Some(object_id); let resp = ui.selectable_label(selected, app.doc.canvases[ci].objects[oi].name.clone()); - if resp.clicked() { - select = Some(object_id); + if resp.clicked() || (resp.secondary_clicked() && !selected) { + claim_list_keyboard_focus(ui, &resp); + select = Some((object_id, select_modifiers(ui))); } resp.context_menu(|ui| { object_transfer_menu(ui, object_id, &others, &mut transfer); @@ -286,8 +302,9 @@ fn object_list(app: &mut PlotxApp, ci: usize, ui: &mut Ui) { }); }); } - if let Some(object_id) = select { - app.select_object(ci, object_id); + if let Some((object_id, modifiers)) = select { + app.session.ui.selection_scope = plotx_core::state::SelectionScope::Layers; + select_layer_range(app, ci, object_id, modifiers); let active = app.doc.canvases[ci] .object(object_id) .and_then(|object| object.dataset()) @@ -357,6 +374,7 @@ fn data_list(app: &mut PlotxApp, ui: &mut Ui) { return; } + let visible = tree.visible_datasets(app, filtering); let mut event = None; let mut rename_rendered = false; for node in &tree.roots { @@ -370,12 +388,12 @@ fn data_list(app: &mut PlotxApp, ui: &mut Ui) { &mut event, ); } - apply_browser_event(app, ui, event); + apply_browser_event(app, ui, &visible, event); } #[derive(Clone)] enum BrowserEvent { - SelectDataset(usize, bool), + SelectDataset(usize, SelectModifiers), OpenSheet(usize), StartRename(usize), RenameCommit(usize, String), @@ -455,8 +473,11 @@ fn render_dataset_node( )); } if resp.clicked() { - let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command || i.modifiers.ctrl); - *event = Some(BrowserEvent::SelectDataset(di, extend)); + claim_list_keyboard_focus(ui, &resp); + *event = Some(BrowserEvent::SelectDataset(di, select_modifiers(ui))); + } else if resp.secondary_clicked() && !selected { + claim_list_keyboard_focus(ui, &resp); + *event = Some(BrowserEvent::SelectDataset(di, SelectModifiers::default())); } if resp.double_clicked() { *event = Some(BrowserEvent::OpenSheet(di)); @@ -603,7 +624,12 @@ fn render_derived_group( } } -fn apply_browser_event(app: &mut PlotxApp, ui: &Ui, event: Option) { +fn apply_browser_event( + app: &mut PlotxApp, + ui: &Ui, + visible: &[usize], + event: Option, +) { match event { Some(BrowserEvent::RenameCommit(di, name)) => { let trimmed = name.trim(); @@ -617,7 +643,9 @@ fn apply_browser_event(app: &mut PlotxApp, ui: &Ui, event: Option) app.session.ui.rename = None; } Some(BrowserEvent::RenameCancel) => app.session.ui.rename = None, - Some(BrowserEvent::SelectDataset(di, extend)) => select_dataset(app, ui, di, extend), + Some(BrowserEvent::SelectDataset(di, extend)) => { + selection::select_dataset(app, ui, visible, di, extend) + } Some(BrowserEvent::OpenSheet(di)) => { app.focus_single(di); app.session.ui.data_browser_selected_node = Some(format!("dataset:{di}")); @@ -646,26 +674,6 @@ fn apply_browser_event(app: &mut PlotxApp, ui: &Ui, event: Option) } } -fn select_dataset(app: &mut PlotxApp, ui: &Ui, di: usize, extend: bool) { - let table_frame = app.doc.datasets[di].as_table().and_then(|table| { - if table.board_sheet_visible() { - Some(FrameRef::Sheet(di)) - } else { - plotx_core::state::page_frame_showing_dataset(app, di) - } - }); - app.toggle_selection(di, extend); - app.session.ui.data_browser_selected_node = Some(format!("dataset:{di}")); - if let Some(frame) = table_frame { - if extend { - plotx_core::state::toggle_frame_selection(app, frame); - } else { - app.session.ui.frame_selection = vec![frame]; - crate::ui::canvas::request_board_fit(app, ui.ctx(), frame); - } - } -} - fn select_analysis(app: &mut PlotxApp, ui: &Ui, di: usize, item: &AnalysisItem, open: bool) { app.focus_single(di); app.session.ui.data_browser_selected_node = Some(item.kind.key(di)); @@ -746,7 +754,9 @@ fn jump_to_frame(app: &mut PlotxApp, ui: &Ui, frame: FrameRef) { FrameRef::Page(ci) => app.session.active_canvas = Some(ci), FrameRef::Sheet(sdi) => app.focus_single(sdi), } - app.session.ui.frame_selection = vec![frame]; + if let Some(id) = plotx_core::state::board_frame_id(app, frame) { + app.session.ui.frame_selection = vec![id]; + } crate::ui::canvas::request_board_fit(app, ui.ctx(), frame); app.session.status = "Jumped to linked frame.".to_owned(); } diff --git a/crates/app/src/ui/primary_sidebar/data_browser.rs b/crates/app/src/ui/primary_sidebar/data_browser.rs index cd7a2f8..7fe74a6 100644 --- a/crates/app/src/ui/primary_sidebar/data_browser.rs +++ b/crates/app/src/ui/primary_sidebar/data_browser.rs @@ -134,6 +134,39 @@ impl DataTree { .collect(), } } + + /// Dataset rows in the exact order the current tree renders them. Linked + /// references are one logical selectable item and therefore appear once. + pub(super) fn visible_datasets(&self, app: &PlotxApp, filtering: bool) -> Vec { + fn visit(node: &DatasetNode, app: &PlotxApp, filtering: bool, out: &mut Vec) { + if !out.contains(&node.dataset) { + out.push(node.dataset); + } + let dataset_open = filtering + || !app + .session + .ui + .data_browser_collapsed_datasets + .contains(&node.dataset); + let derived_open = filtering + || !app + .session + .ui + .data_browser_collapsed_derived + .contains(&node.dataset); + if dataset_open && derived_open && !node.cycle_cut { + for child in &node.derived { + visit(child, app, filtering, out); + } + } + } + + let mut visible = Vec::new(); + for root in &self.roots { + visit(root, app, filtering, &mut visible); + } + visible + } } fn mark_reachable(di: usize, children: &[Vec], seen: &mut HashSet) { @@ -440,6 +473,22 @@ mod tests { assert_eq!(filtered.roots[0].derived[0].dataset, 1); } + #[test] + fn visible_dataset_order_follows_the_rendered_lineage_tree() { + let mut app = PlotxApp::new(); + app.doc.datasets = vec![root("A"), root("B")]; + let source = app.doc.datasets[0].resource_id(); + app.doc + .datasets + .push(derived("A child", DerivationKind::Projection, &[source])); + + let tree = DataTree::build(&app); + assert_eq!(tree.visible_datasets(&app, false), vec![0, 2, 1]); + + app.session.ui.data_browser_collapsed_derived.insert(0); + assert_eq!(tree.visible_datasets(&app, false), vec![0, 1]); + } + #[test] fn cycles_are_cut_and_remain_accessible() { let mut app = PlotxApp::new(); diff --git a/crates/app/src/ui/primary_sidebar/selection.rs b/crates/app/src/ui/primary_sidebar/selection.rs new file mode 100644 index 0000000..d24be47 --- /dev/null +++ b/crates/app/src/ui/primary_sidebar/selection.rs @@ -0,0 +1,354 @@ +use egui::{Id, Response, Ui}; +use plotx_core::state::{BoardFrameId, ObjectId, PlotxApp}; + +#[derive(Clone, Copy, Default)] +pub(super) struct SelectModifiers { + pub shift: bool, + pub command: bool, +} + +pub(super) fn select_modifiers(ui: &Ui) -> SelectModifiers { + ui.input(|input| SelectModifiers { + shift: input.modifiers.shift, + command: input.modifiers.command || input.modifiers.ctrl, + }) +} + +const LIST_FOCUS_KEY: &str = "plotx.primary_sidebar.list_focus"; + +pub(super) fn claim_list_keyboard_focus(ui: &Ui, response: &Response) { + response.request_focus(); + ui.ctx().data_mut(|data| { + data.insert_temp(Id::new(LIST_FOCUS_KEY), response.id); + }); +} + +pub(super) fn select_canvas_range(app: &mut PlotxApp, clicked: usize, additive: bool) { + let anchor = app + .session + .ui + .selection_anchors + .canvas + .and_then(|id| app.doc.canvas_index(id)) + .unwrap_or(clicked); + let (start, end) = if anchor <= clicked { + (anchor, clicked) + } else { + (clicked, anchor) + }; + let range = app.doc.canvases[start..=end] + .iter() + .map(|canvas| BoardFrameId::Page(canvas.resource_id)); + if !additive { + app.session.ui.frame_selection.clear(); + } + for id in range { + if !app.session.ui.frame_selection.contains(&id) { + app.session.ui.frame_selection.push(id); + } + } + app.activate_canvas(clicked); + app.session.ui.selection_anchors.canvas_lead = Some(app.doc.canvases[clicked].resource_id); + plotx_core::state::sync_frame_selection_to_data(app); +} + +pub(super) fn select_dataset_range( + app: &mut PlotxApp, + visible: &[usize], + clicked: usize, + modifiers: SelectModifiers, +) { + let clicked_id = app.doc.datasets[clicked].resource_id(); + if modifiers.shift { + let anchor_id = app + .session + .ui + .selection_anchors + .dataset + .unwrap_or(clicked_id); + let anchor = visible + .iter() + .position(|index| app.doc.datasets[*index].resource_id() == anchor_id) + .unwrap_or_else(|| { + visible + .iter() + .position(|index| *index == clicked) + .unwrap_or(0) + }); + let lead = visible + .iter() + .position(|index| *index == clicked) + .unwrap_or(anchor); + let (start, end) = if anchor <= lead { + (anchor, lead) + } else { + (lead, anchor) + }; + let mut selected = if modifiers.command { + app.session.ui.data_selection.clone() + } else { + Vec::new() + }; + for &index in &visible[start..=end] { + if !selected.contains(&index) { + selected.push(index); + } + } + app.focus_datasets(&selected, Some(clicked)); + } else { + app.toggle_selection(clicked, modifiers.command); + app.session.ui.selection_anchors.dataset = Some(clicked_id); + } + app.session.ui.selection_anchors.dataset_lead = Some(clicked_id); +} + +pub(super) fn select_dataset( + app: &mut PlotxApp, + ui: &Ui, + visible: &[usize], + dataset: usize, + modifiers: SelectModifiers, +) { + let table_frame = app.doc.datasets[dataset].as_table().and_then(|table| { + if table.board_sheet_visible() { + Some(plotx_core::state::FrameRef::Sheet(dataset)) + } else { + plotx_core::state::page_frame_showing_dataset(app, dataset) + } + }); + app.session.ui.selection_scope = plotx_core::state::SelectionScope::DataList; + select_dataset_range(app, visible, dataset, modifiers); + app.session.ui.data_browser_selected_node = Some(format!("dataset:{dataset}")); + if let Some(frame) = table_frame { + if modifiers.command && !modifiers.shift { + plotx_core::state::toggle_frame_selection(app, frame); + } else if !modifiers.shift { + if let Some(id) = plotx_core::state::board_frame_id(app, frame) { + app.session.ui.frame_selection = vec![id]; + } + crate::ui::canvas::request_board_fit(app, ui.ctx(), frame); + } + } +} + +pub(super) fn select_layer_range( + app: &mut PlotxApp, + canvas: usize, + clicked: ObjectId, + modifiers: SelectModifiers, +) { + let order = app.doc.canvases[canvas] + .objects + .iter() + .rev() + .map(|object| object.id) + .collect::>(); + let clicked_index = order.iter().position(|id| *id == clicked).unwrap_or(0); + if modifiers.shift { + let anchor = app + .session + .ui + .selection_anchors + .layer + .and_then(|id| order.iter().position(|candidate| *candidate == id)) + .unwrap_or(clicked_index); + let (start, end) = if anchor <= clicked_index { + (anchor, clicked_index) + } else { + (clicked_index, anchor) + }; + app.set_page_selection(canvas, &order[start..=end], modifiers.command); + } else if modifiers.command { + app.toggle_object_selection(canvas, clicked); + app.session.ui.selection_anchors.layer = Some(clicked); + } else { + app.select_object(canvas, clicked); + app.session.ui.selection_anchors.layer = Some(clicked); + } + app.session.ui.selection_anchors.layer_lead = Some(clicked); + app.focus_object_datasets(canvas, clicked); +} + +pub(crate) fn handle_keyboard_selection(app: &mut PlotxApp, ctx: &egui::Context) { + use plotx_core::state::SelectionScope; + if ctx.egui_wants_keyboard_input() { + return; + } + let focused = ctx.memory(|memory| memory.focused()); + let list_focus = ctx.data(|data| data.get_temp::(Id::new(LIST_FOCUS_KEY))); + if focused.is_none() || focused != list_focus { + return; + } + let mut input = ctx.input(|input| { + let edge = if input.key_pressed(egui::Key::Home) { + Some(false) + } else if input.key_pressed(egui::Key::End) { + Some(true) + } else { + None + }; + let delta = if input.key_pressed(egui::Key::ArrowUp) { + -1 + } else if input.key_pressed(egui::Key::ArrowDown) { + 1 + } else { + 0 + }; + ( + edge, + delta, + input.modifiers.shift, + input.key_pressed(egui::Key::Space), + ) + }); + if input.3 { + let canvas_rect = + ctx.data(|data| data.get_temp::(Id::new("plotx.canvas.navigation_rect"))); + let pointer_over_canvas = ctx + .pointer_hover_pos() + .zip(canvas_rect) + .is_some_and(|(pointer, rect)| rect.contains(pointer)); + if pointer_over_canvas { + input.3 = false; + } + } + if input.0.is_none() && input.1 == 0 && !input.3 { + return; + } + match app.session.ui.selection_scope { + SelectionScope::CanvasList if !app.doc.canvases.is_empty() => { + let current = app + .session + .ui + .selection_anchors + .canvas_lead + .and_then(|id| app.doc.canvas_index(id)) + .or(app.session.active_canvas) + .unwrap_or(0); + let target = keyboard_target(current, app.doc.canvases.len(), input.0, input.1); + if input.3 { + plotx_core::state::toggle_frame_selection_synced( + app, + plotx_core::state::FrameRef::Page(current), + ); + } else if input.2 { + select_canvas_range(app, target, false); + } else { + let id = app.doc.canvases[target].resource_id; + app.activate_canvas(target); + app.session.ui.frame_selection = vec![BoardFrameId::Page(id)]; + app.session.ui.selection_anchors.canvas = Some(id); + app.session.ui.selection_anchors.canvas_lead = Some(id); + } + } + SelectionScope::DataList if !app.doc.datasets.is_empty() => { + let query = app.session.ui.data_browser_filter.clone(); + let filtering = !query.trim().is_empty(); + let visible = super::data_browser::DataTree::build(app) + .filtered(app, &query) + .visible_datasets(app, filtering); + if visible.is_empty() { + return; + } + let current = app + .session + .ui + .selection_anchors + .dataset_lead + .and_then(|id| { + visible + .iter() + .position(|index| app.doc.datasets[*index].resource_id() == id) + }) + .or_else(|| { + app.active_dataset() + .and_then(|active| visible.iter().position(|index| *index == active)) + }) + .unwrap_or(0); + let target = keyboard_target(current, visible.len(), input.0, input.1); + let modifiers = SelectModifiers { + shift: input.2, + command: input.3, + }; + select_dataset_range(app, &visible, visible[target], modifiers); + } + SelectionScope::Layers => { + let Some(canvas) = app.session.active_canvas else { + return; + }; + let order = app.doc.canvases[canvas] + .objects + .iter() + .rev() + .map(|o| o.id) + .collect::>(); + if order.is_empty() { + return; + } + let current = app + .session + .ui + .selection_anchors + .layer_lead + .and_then(|id| order.iter().position(|candidate| *candidate == id)) + .unwrap_or(0); + let target = keyboard_target(current, order.len(), input.0, input.1); + select_layer_range( + app, + canvas, + order[target], + SelectModifiers { + shift: input.2, + command: input.3, + }, + ); + } + _ => return, + } + ctx.request_repaint(); +} + +fn keyboard_target(current: usize, len: usize, edge: Option, delta: isize) -> usize { + if let Some(end) = edge { + if end { len - 1 } else { 0 } + } else { + current.saturating_add_signed(delta).min(len - 1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use plotx_core::state::CanvasDocument; + + #[test] + fn additive_canvas_range_keeps_outside_frames_and_updates_the_lead() { + let mut app = PlotxApp::new(); + for index in 0..4 { + app.doc + .canvases + .push(CanvasDocument::new(format!("page {index}"), [100.0, 80.0])); + } + let ids = app + .doc + .canvases + .iter() + .map(|canvas| canvas.resource_id) + .collect::>(); + app.session.ui.selection_anchors.canvas = Some(ids[1]); + app.session.ui.frame_selection = + vec![BoardFrameId::Page(ids[0]), BoardFrameId::Page(ids[3])]; + + select_canvas_range(&mut app, 2, true); + + assert_eq!(app.session.ui.frame_selection.len(), 4); + assert_eq!(app.session.ui.selection_anchors.canvas_lead, Some(ids[2])); + } + + #[test] + fn keyboard_navigation_advances_from_the_moving_lead() { + let first = keyboard_target(0, 4, None, 1); + let second = keyboard_target(first, 4, None, 1); + assert_eq!((first, second), (1, 2)); + } +} diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index ffebb05..de6914b 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -106,6 +106,7 @@ static BINDINGS: &[CommandBinding] = &[ menu_accelerator: false, }, bound(commands::CommandId::SelectAll, cmd(egui::Key::A)), + bound(commands::CommandId::DeselectAll, cmd_shift(egui::Key::A)), bound(commands::CommandId::Group, cmd(egui::Key::G)), bound(commands::CommandId::Ungroup, cmd_shift(egui::Key::G)), bound(commands::CommandId::Preferences, cmd(egui::Key::Comma)), @@ -335,6 +336,18 @@ fn handle_escape(app: &mut PlotxApp, now: f64) { return; } + if !app.session.ui.frame_selection.is_empty() { + app.session.ui.frame_selection.clear(); + app.session.status = "Frame selection cleared.".to_owned(); + return; + } + + if !app.session.ui.data_selection.is_empty() { + app.focus_datasets(&[], None); + app.session.status = "Dataset selection cleared.".to_owned(); + return; + } + if !matches!(app.session.ui.selection, Selection::None) { exit_to_page(app, "Selection cleared."); return; @@ -429,7 +442,7 @@ pub(super) fn handle_focus_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { return; } let frame = match app.session.ui.frame_selection.as_slice() { - [only] => Some(*only), + [only] => plotx_core::state::board_frame_ref(app, *only), _ => app .session .active_canvas diff --git a/crates/core/src/actions/tests/board.rs b/crates/core/src/actions/tests/board.rs index 9857335..c12c403 100644 --- a/crates/core/src/actions/tests/board.rs +++ b/crates/core/src/actions/tests/board.rs @@ -1,7 +1,7 @@ use super::{dataset_id, sample_app, table_app}; use crate::actions::Action; -use crate::state::FrameRef; use crate::state::page_frame_showing_dataset; +use crate::state::{BoardFrameId, FrameRef}; #[test] fn new_table_dataset_adds_placed_starter_table() { @@ -16,7 +16,10 @@ fn new_table_dataset_adds_placed_starter_table() { assert_eq!(t.series_bindings.len(), 1); assert_eq!(app.active_dataset(), Some(di)); assert_eq!(app.session.ui.sheet_open, Some(di)); - assert_eq!(app.session.ui.frame_selection, vec![FrameRef::Sheet(di)]); + assert_eq!( + app.session.ui.frame_selection, + vec![BoardFrameId::Sheet(app.doc.datasets[di].resource_id())] + ); // Placed off the origin so it does not land on a page at [0, 0]. assert_ne!(t.board_pos, [0.0, 0.0]); } diff --git a/crates/core/src/actions/tests/interaction.rs b/crates/core/src/actions/tests/interaction.rs index 41cc38e..439b076 100644 --- a/crates/core/src/actions/tests/interaction.rs +++ b/crates/core/src/actions/tests/interaction.rs @@ -1,5 +1,6 @@ use super::{first_plot, push_canvas, sample_app}; use crate::actions::Action; +use crate::state::BoardFrameId; #[test] fn delete_canvas_resets_in_flight_interaction() { @@ -24,9 +25,9 @@ fn delete_canvas_resets_in_flight_interaction() { #[test] fn gesture_active_covers_only_the_board_freezing_drags() { use crate::state::{ - AuthorDrag, FrameDrag, FrameRef, Interaction, MarqueeDrag, ObjectDrag, ObjectDragKind, - ObjectFrame, PanDrag, PanelLabelDrag, PhaseAxis, PhaseDrag, PhaseDragKind, RegionDrag, - RegionDragKind, SelectionDrag, ZoomAxis, ZoomDrag, + AuthorDrag, FrameDrag, Interaction, MarqueeDrag, ObjectDrag, ObjectDragKind, ObjectFrame, + PanDrag, PanelLabelDrag, PhaseAxis, PhaseDrag, PhaseDragKind, RegionDrag, RegionDragKind, + SelectionDrag, ZoomAxis, ZoomDrag, }; let mut app = sample_app(); let object = app.doc.canvases[0].objects[0].id; @@ -124,8 +125,11 @@ fn gesture_active_covers_only_the_board_freezing_drags() { ), ( Interaction::Frame(FrameDrag { - frame: FrameRef::Page(0), - before: [0.0, 0.0], + frame: BoardFrameId::Page(app.doc.canvases[0].resource_id), + before: vec![( + BoardFrameId::Page(app.doc.canvases[0].resource_id), + [0.0, 0.0], + )], start_world: [0.0, 0.0], }), false, @@ -146,6 +150,18 @@ fn gesture_active_covers_only_the_board_freezing_drags() { } } +#[test] +fn active_board_marquee_remains_frame_dispatchable() { + use crate::state::{BoardMarqueeDrag, Interaction}; + let marquee = Interaction::BoardMarquee(BoardMarqueeDrag { + start: [0.0, 0.0], + current: [1.0, 1.0], + additive: false, + toggle: false, + }); + assert!(marquee.allows_frame_dispatch()); +} + #[test] fn switching_to_data_tool_resets_in_flight_interaction() { use crate::state::{Interaction, MarqueeDrag, Tool}; diff --git a/crates/core/src/actions/tests/stack.rs b/crates/core/src/actions/tests/stack.rs index e398001..276397f 100644 --- a/crates/core/src/actions/tests/stack.rs +++ b/crates/core/src/actions/tests/stack.rs @@ -291,7 +291,8 @@ fn multi_selecting_pages_in_the_workspace_populates_data_for_stacking() { .push(Dataset::Nmr(Box::new(NmrDataset::load(synthetic_1d())))); push_canvas(&mut app, 1, "second canvas", [120.0, 80.0]); - app.session.ui.frame_selection = vec![FrameRef::Page(0)]; + app.session.ui.frame_selection = + vec![crate::state::board_frame_id(&app, FrameRef::Page(0)).unwrap()]; crate::state::toggle_frame_selection_synced(&mut app, FrameRef::Page(1)); // Both pages' datasets become the Data-list selection, so the stack command diff --git a/crates/core/src/automation/resources/selection.rs b/crates/core/src/automation/resources/selection.rs index 2186094..faf9a9f 100644 --- a/crates/core/src/automation/resources/selection.rs +++ b/crates/core/src/automation/resources/selection.rs @@ -1,21 +1,17 @@ use super::{KIND_CANVAS, KIND_DATASET, ResourceRef, top_ref}; -use crate::state::{FrameRef, PlotxApp}; +use crate::state::{BoardFrameId, PlotxApp}; pub(super) fn current(app: &PlotxApp) -> Vec { let mut selected = Vec::new(); if !app.session.ui.frame_selection.is_empty() { for frame in &app.session.ui.frame_selection { let target = match *frame { - FrameRef::Page(index) => app - .doc - .canvases - .get(index) - .map(|canvas| top_ref(canvas.resource_id, KIND_CANVAS)), - FrameRef::Sheet(index) => app - .doc - .datasets - .get(index) - .map(|dataset| top_ref(dataset.resource_id(), KIND_DATASET)), + BoardFrameId::Page(id) => { + app.doc.canvas_index(id).map(|_| top_ref(id, KIND_CANVAS)) + } + BoardFrameId::Sheet(id) => { + app.doc.dataset_index(id).map(|_| top_ref(id, KIND_DATASET)) + } }; if let Some(target) = target && !selected.contains(&target) @@ -23,7 +19,9 @@ pub(super) fn current(app: &PlotxApp) -> Vec { selected.push(target); } } - return selected; + if !selected.is_empty() { + return selected; + } } if let Some(dataset) = app .active_dataset() diff --git a/crates/core/src/automation/tests.rs b/crates/core/src/automation/tests.rs index 279a33e..85c56c2 100644 --- a/crates/core/src/automation/tests.rs +++ b/crates/core/src/automation/tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::actions::Action; -use crate::state::{CanvasDocument, Dataset, FrameRef, PlotxApp, TableDataset, TableSeriesBinding}; +use crate::state::{ + BoardFrameId, CanvasDocument, Dataset, PlotxApp, TableDataset, TableSeriesBinding, +}; use std::collections::{BTreeMap, BTreeSet}; fn app_with_table_and_canvas() -> PlotxApp { @@ -71,7 +73,12 @@ fn current_selection_preserves_multi_selected_canvases() { app.doc .canvases .push(CanvasDocument::new("Figure 2".to_owned(), [120.0, 90.0])); - app.session.ui.frame_selection = vec![FrameRef::Page(0), FrameRef::Page(1)]; + app.session.ui.frame_selection = app + .doc + .canvases + .iter() + .map(|canvas| BoardFrameId::Page(canvas.resource_id)) + .collect(); let selected = ProjectResourceProvider::new(&app).current_selection(); @@ -80,6 +87,31 @@ fn current_selection_preserves_multi_selected_canvases() { assert_eq!(selected[1].id, app.doc.canvases[1].resource_id.to_string()); } +#[test] +fn current_selection_ignores_removed_frame_ids_and_falls_back_to_active() { + let mut app = app_with_table_and_canvas(); + app.doc + .canvases + .push(CanvasDocument::new("removed".to_owned(), [120.0, 90.0])); + let removed = app.doc.canvases[1].resource_id; + app.session.ui.frame_selection = vec![BoardFrameId::Page(removed)]; + app.doc.canvases.pop(); + app.session.active_canvas = Some(0); + + let selected = ProjectResourceProvider::new(&app).current_selection(); + + assert!( + selected + .iter() + .all(|target| target.id != removed.to_string()) + ); + assert!( + selected + .iter() + .any(|target| target.id == app.doc.canvases[0].resource_id.to_string()) + ); +} + #[test] fn query_reports_reasons_pagination_and_stale_frozen_sets_are_rejected() { let mut app = app_with_table_and_canvas(); diff --git a/crates/core/src/state/app_impl_analysis_tables.rs b/crates/core/src/state/app_impl_analysis_tables.rs index c4c7a97..0d9687d 100644 --- a/crates/core/src/state/app_impl_analysis_tables.rs +++ b/crates/core/src/state/app_impl_analysis_tables.rs @@ -93,7 +93,8 @@ impl PlotxApp { let di = self.doc.datasets.len() - 1; self.focus_single(di); self.session.view = PrimaryView::Data; - self.session.ui.frame_selection = vec![FrameRef::Sheet(di)]; + self.session.ui.frame_selection = + vec![BoardFrameId::Sheet(self.doc.datasets[di].resource_id())]; self.session.ui.sheet_open = Some(di); self.mark_document_dirty(); self.session.status = "Created a data table.".to_owned(); @@ -131,7 +132,9 @@ impl PlotxApp { self.execute_action(action); self.focus_single(dataset_index); self.session.view = PrimaryView::Data; - self.session.ui.frame_selection = vec![FrameRef::Sheet(dataset_index)]; + self.session.ui.frame_selection = vec![BoardFrameId::Sheet( + self.doc.datasets[dataset_index].resource_id(), + )]; self.session.ui.sheet_open = Some(dataset_index); dataset_index } diff --git a/crates/core/src/state/board.rs b/crates/core/src/state/board.rs index ca50a1f..41341a6 100644 --- a/crates/core/src/state/board.rs +++ b/crates/core/src/state/board.rs @@ -1,4 +1,6 @@ -use crate::state::{BoardFrameId, CanvasDocument, Dataset, FrameRef, PlotxApp, TableDataset}; +use crate::state::{ + BoardFrameId, CanvasDocument, Dataset, FrameRef, PlotxApp, SelectionScope, TableDataset, +}; use plotx_render::Rect as PlotRect; /// World-pt gap kept between board frames by auto-placement and Tidy Up — the @@ -251,13 +253,18 @@ impl PlotxApp { FrameRef::Sheet(di) => self.focus_single(di), } self.session.view = crate::state::PrimaryView::Canvas; - self.session.ui.frame_selection = vec![frame]; + self.session.ui.frame_selection = vec![frame_id]; + self.session.ui.selection_scope = SelectionScope::Board; + self.session.ui.selection_anchors.frame = Some(frame_id); self.session.board_reveal = Some(frame_id); } } /// Add or remove a frame from the multi-select set (Shift/Ctrl-click). pub fn toggle_frame_selection(app: &mut PlotxApp, frame: FrameRef) { + let Some(frame) = board_frame_id(app, frame) else { + return; + }; if let Some(pos) = app .session .ui @@ -277,16 +284,19 @@ pub fn toggle_frame_selection(app: &mut PlotxApp, frame: FrameRef) { /// the Data list drives its own selection, so it toggles directly. pub fn toggle_frame_selection_synced(app: &mut PlotxApp, frame: FrameRef) { toggle_frame_selection(app, frame); - sync_data_selection_from_frames(app); + sync_frame_selection_to_data(app); } /// Rebuild the Data-list selection from the multi-selected frames (union of each /// page's datasets plus any sheets). The active dataset is the set's lead, so it /// can no longer point outside the multi-select the Stack command counts. -fn sync_data_selection_from_frames(app: &mut PlotxApp) { +pub fn sync_frame_selection_to_data(app: &mut PlotxApp) { let frames = app.session.ui.frame_selection.clone(); let mut datasets: Vec = Vec::new(); - for frame in frames { + for frame_id in frames { + let Some(frame) = board_frame_ref(app, frame_id) else { + continue; + }; let indices = match frame { FrameRef::Page(ci) => app.doc.page_dataset_indices(ci), FrameRef::Sheet(di) => vec![di], @@ -427,7 +437,10 @@ mod tests { assert_eq!(app.session.active_canvas, Some(1)); assert_eq!(app.session.ui.selection, crate::state::Selection::None); - assert_eq!(app.session.ui.frame_selection, vec![FrameRef::Page(1)]); + assert_eq!( + app.session.ui.frame_selection, + vec![BoardFrameId::Page(app.doc.canvases[1].resource_id)] + ); assert_eq!( app.session.board_reveal, Some(BoardFrameId::Page(result_id)) diff --git a/crates/core/src/state/interaction.rs b/crates/core/src/state/interaction.rs index dfdd390..d1aab0f 100644 --- a/crates/core/src/state/interaction.rs +++ b/crates/core/src/state/interaction.rs @@ -30,6 +30,7 @@ pub enum Interaction { Marquee(MarqueeDrag), PanelLabel(PanelLabelDrag), Frame(FrameDrag), + BoardMarquee(BoardMarqueeDrag), Author(AuthorDrag), Zoom(ZoomDrag), Selection(SelectionDrag), @@ -54,6 +55,10 @@ pub enum GestureFamily { } impl Interaction { + pub fn allows_frame_dispatch(&self) -> bool { + matches!(self, Self::Idle | Self::Frame(_) | Self::BoardMarquee(_)) + } + pub fn is_active(&self) -> bool { !matches!(self, Interaction::Idle) } @@ -65,6 +70,7 @@ impl Interaction { | Interaction::Marquee(_) | Interaction::PanelLabel(_) | Interaction::Frame(_) + | Interaction::BoardMarquee(_) | Interaction::Author(_) => GestureFamily::Layout, Interaction::Zoom(_) | Interaction::Selection(_) @@ -96,7 +102,10 @@ impl Interaction { Interaction::Integral2D(d) => Some(d.canvas), Interaction::PeakThreshold(d) => Some(d.canvas), Interaction::PeakBand(d) => Some(d.canvas), - Interaction::Idle | Interaction::Frame(_) | Interaction::Phase(_) => None, + Interaction::Idle + | Interaction::Frame(_) + | Interaction::BoardMarquee(_) + | Interaction::Phase(_) => None, } } @@ -119,7 +128,7 @@ impl Interaction { // A frame drag rides the board under any tool; other layout gestures // belong to the Select tool or an authoring create-tool. GestureFamily::Layout => { - matches!(self, Interaction::Frame(_)) + matches!(self, Interaction::Frame(_) | Interaction::BoardMarquee(_)) || tool.is_layout_tool() || tool.creates_object() } diff --git a/crates/core/src/state/mod.rs b/crates/core/src/state/mod.rs index 36a472b..f4ffbb2 100644 --- a/crates/core/src/state/mod.rs +++ b/crates/core/src/state/mod.rs @@ -87,6 +87,7 @@ mod peaks2d; mod plot_interaction; mod plot_object; mod region; +mod selection; mod series_binding; mod size_presets; mod stack; @@ -163,6 +164,7 @@ pub use peaks2d::*; pub use plot_interaction::*; pub use plot_object::*; pub use region::*; +pub use selection::*; pub use series_binding::*; pub use size_presets::*; pub use statistics::*; diff --git a/crates/core/src/state/selection.rs b/crates/core/src/state/selection.rs new file mode 100644 index 0000000..d1ff9d1 --- /dev/null +++ b/crates/core/src/state/selection.rs @@ -0,0 +1,55 @@ +use super::{BoardFrameId, CanvasId, DatasetId, ObjectId}; + +/// The current desktop selection context. Global selection commands dispatch +/// through this scope instead of guessing from whichever collection is active. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SelectionScope { + #[default] + CanvasObjects, + Board, + CanvasList, + DataList, + Layers, +} + +/// The whole-object selection on the active canvas. The first object is the +/// lead item used by inspectors and data-tool resolution. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub enum Selection { + #[default] + None, + Objects(Vec), +} + +impl Selection { + pub fn single(id: ObjectId) -> Self { + Self::Objects(vec![id]) + } + + pub fn object(&self) -> Option { + self.objects().first().copied() + } + + pub fn objects(&self) -> &[ObjectId] { + match self { + Self::None => &[], + Self::Objects(ids) => ids, + } + } + + pub fn contains(&self, id: ObjectId) -> bool { + self.objects().contains(&id) + } +} + +/// Stable lead/anchor identities for desktop extended selection. +#[derive(Clone, Copy, Debug, Default)] +pub struct SelectionAnchors { + pub frame: Option, + pub canvas: Option, + pub dataset: Option, + pub layer: Option, + pub canvas_lead: Option, + pub dataset_lead: Option, + pub layer_lead: Option, +} diff --git a/crates/core/src/state/table_execution_job.rs b/crates/core/src/state/table_execution_job.rs index b3b8f2e..68b0411 100644 --- a/crates/core/src/state/table_execution_job.rs +++ b/crates/core/src/state/table_execution_job.rs @@ -257,7 +257,9 @@ impl crate::state::PlotxApp { if reveal { self.focus_single(index); self.session.view = crate::state::PrimaryView::Data; - self.session.ui.frame_selection = vec![crate::state::FrameRef::Sheet(index)]; + self.session.ui.frame_selection = vec![crate::state::BoardFrameId::Sheet( + self.doc.datasets[index].resource_id(), + )]; self.session.ui.sheet_open = Some(index); } self.session.status = "Table transform completed.".into(); @@ -385,7 +387,9 @@ mod tests { app.doc.datasets.push(Dataset::Table(Box::new(source))); app.session.view = crate::state::PrimaryView::Canvas; app.session.ui.sheet_open = Some(0); - app.session.ui.frame_selection = vec![crate::state::FrameRef::Sheet(0)]; + app.session.ui.frame_selection = vec![crate::state::BoardFrameId::Sheet( + app.doc.datasets[0].resource_id(), + )]; app.start_table_transform(plan, vec![0], "Projected".into(), 16 * 1024 * 1024) .unwrap(); let deadline = Instant::now() + Duration::from_secs(2); @@ -399,7 +403,9 @@ mod tests { assert_eq!(app.session.ui.sheet_open, Some(0)); assert_eq!( app.session.ui.frame_selection, - vec![crate::state::FrameRef::Sheet(0)] + vec![crate::state::BoardFrameId::Sheet( + app.doc.datasets[0].resource_id() + )] ); assert_eq!( app.doc.datasets[1] diff --git a/crates/core/src/state/ui_state.rs b/crates/core/src/state/ui_state.rs index 351d67c..9fba86f 100644 --- a/crates/core/src/state/ui_state.rs +++ b/crates/core/src/state/ui_state.rs @@ -50,43 +50,6 @@ pub struct AuthorDrag { pub current: [f32; 2], } -/// The single source of truth for what is selected in the active canvas: one or -/// more whole objects. `Objects` holds an ordered set whose first entry is the -/// primary (drives active-plot resolution and serialization). A data tool acts -/// on the primary plot directly. Sub-selections that are not whole objects (a -/// title, an analysis region) live in their own `UiState` fields. -#[derive(Clone, Debug, PartialEq, Eq, Default)] -pub enum Selection { - #[default] - None, - Objects(Vec), -} - -impl Selection { - pub fn single(id: ObjectId) -> Self { - Selection::Objects(vec![id]) - } - - pub fn object(&self) -> Option { - match self { - Selection::None => None, - Selection::Objects(ids) => ids.first().copied(), - } - } - - /// The page-space multi-selection, empty when nothing is selected. - pub fn objects(&self) -> &[ObjectId] { - match self { - Selection::Objects(ids) => ids, - _ => &[], - } - } - - pub fn contains(&self, id: ObjectId) -> bool { - self.objects().contains(&id) - } -} - /// A rail row in the Preferences panel, mapping 1:1 to a `Settings` sub-struct. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum SettingsCategory { @@ -326,6 +289,8 @@ pub struct UiState { pub spectrum_arithmetic_dialog: Option, pub align_spectra_dialog: Option, pub selection: Selection, + pub selection_scope: SelectionScope, + pub selection_anchors: SelectionAnchors, /// A panel-letter sub-selection (canvas index, object id): its own page-space /// selection scope, distinct from the whole-object `selection`. pub panel_label_selection: Option<(usize, ObjectId)>, @@ -336,7 +301,7 @@ pub struct UiState { pub tile_drop: Option, /// Multi-frame board selection (pages and/or sheets) built with Shift/Ctrl /// click, used by zoom-to-selection. Transient; a plain click resets it. - pub frame_selection: Vec, + pub frame_selection: Vec, /// 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, @@ -528,6 +493,8 @@ impl Default for UiState { spectrum_arithmetic_dialog: None, align_spectra_dialog: None, selection: Selection::None, + selection_scope: SelectionScope::default(), + selection_anchors: SelectionAnchors::default(), panel_label_selection: None, tile_drop: None, frame_selection: Vec::new(), @@ -763,13 +730,21 @@ pub struct ObjectDrag { /// header strip. `before` is the frame's `board_pos` (pt) at grab time and /// `start_world` the board-world (pt) pointer position then, so the live position /// is recomputed absolutely each frame (grid snapping never accumulates drift). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct FrameDrag { - pub frame: FrameRef, - pub before: [f32; 2], + pub frame: BoardFrameId, + pub before: Vec<(BoardFrameId, [f32; 2])>, pub start_world: [f32; 2], } +#[derive(Clone, Copy, Debug)] +pub struct BoardMarqueeDrag { + pub start: [f32; 2], + pub current: [f32; 2], + pub additive: bool, + pub toggle: bool, +} + /// In-progress rubber-band selecting objects on empty page area. `start` and /// `current` are page-space (pt) pointer positions; `additive` keeps the prior /// selection (Shift+marquee) instead of replacing it. diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 946cd45..af4f639 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -11,6 +11,13 @@ can be toggled off from the toolbar. The arrange menu in the toolbar offers alignment (with two or more frames selected), horizontal / vertical distribution (three or more), z-ordering, and a *Tidy up frames* command. +Drag over empty board space to marquee-select page and sheet frames. Hold +`Shift` to add the enclosed frames or `Ctrl` (`Cmd` on macOS) to toggle them. +Dragging the header of any selected frame moves the whole selection; the move +undoes as one step. In the Canvas list, `Shift`-click selects a continuous range +and `Ctrl`-click adds or removes one canvas. `Ctrl` + `A` selects every frame or +canvas according to the area you last used. + ## Canvas size The active page shows a size chip above its top-left corner — the current diff --git a/docs/src/content/docs/guides/organizing-data.md b/docs/src/content/docs/guides/organizing-data.md index 060aa7d..5181dbd 100644 --- a/docs/src/content/docs/guides/organizing-data.md +++ b/docs/src/content/docs/guides/organizing-data.md @@ -23,9 +23,16 @@ clearing it restores your previous expanded and collapsed branches. ## Selecting and opening -Click a dataset or reference to focus it. Hold `Shift`, `Ctrl`, or `Cmd` while -clicking to extend the selection — a multi-selection is how you stack several -spectra in one plot or apply a processing template to many datasets at once. +Click a dataset or reference to select it. `Shift`-click selects the continuous +range from the previous lead item; `Ctrl`-click (`Cmd` on macOS) adds or removes +one item, and combining both modifiers adds a range without clearing the current +selection. The same extended-selection model applies to the Canvas and Layers +lists. A multi-selection is how you stack several spectra in one plot or apply a +processing template to many datasets at once. + +When one of these lists is active, use `↑` / `↓` or `Home` / `End` to move the +selection, hold `Shift` to extend it, and press `Space` to add or remove the lead +item. `Ctrl` + `A` selects all datasets; `Ctrl` + `Shift` + `A` clears them. Double-click a dataset to open its data sheet. Click an analysis result to focus its dataset; double-click it to jump to the plot and the corresponding analysis tool. diff --git a/docs/src/content/docs/reference/shortcuts.md b/docs/src/content/docs/reference/shortcuts.md index 3dee150..6a1b652 100644 --- a/docs/src/content/docs/reference/shortcuts.md +++ b/docs/src/content/docs/reference/shortcuts.md @@ -74,14 +74,20 @@ layer you want from the Object inspector. | `Ctrl` + `S` | Open project save options | | `Ctrl` + `Z` | Undo | | `Ctrl` + `Shift` + `Z` or `Ctrl` + `Y` | Redo | -| `Ctrl` + `A` | Select all objects on the page | +| `Ctrl` + `A` | Select every item in the current context: page objects, board frames, canvases, or datasets | +| `Ctrl` + `Shift` + `A` | Clear the selection in the current context | +| `Shift` + click | Select a continuous range in the Canvas, Layers, or Data list | +| `Ctrl` + click | Add or remove one item without clearing the rest | +| `Ctrl` + `Shift` + click | Add a continuous range to the existing list selection | +| `↑` / `↓`, `Home` / `End` | Move selection through the active Canvas, Layers, or Data list; hold `Shift` to extend | +| `Space` | Add or remove the lead item in the active list | | `Ctrl` + `G` | Group the selected objects | | `Ctrl` + `Shift` + `G` | Ungroup | | `Delete` or `Backspace` | Delete the selected annotation objects; in Peaks or Integrate, delete the selected peak or region; in Symmetry review, delete the selected cross-peak mark | | `+` (or `=`) / `-` | Raise / lower the lowest contour level of the selected plot | | `F2` | Rename the selected dataset or canvas | | `Esc` | Cancel the active drag; further presses clear the Analysis Range and selections one at a time, then leave the active tool | -| `Ctrl` + `C` | Copy the selected frame (or the active canvas) to the clipboard as bitmap + vector | +| `Ctrl` + `C` | Copy the single selected page frame (or the active canvas) to the clipboard as bitmap + vector | | `Ctrl` + `Shift` + `V` | Paste a delimited table (comma, tab, or semicolon) from the clipboard as a new data table | | `Ctrl` + `,` | Open Preferences | | `Ctrl` + `K` or `Ctrl` + `Shift` + `P` | Open the [command palette](/reference/command-palette/) | diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index 9c60173..4047d08 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -10,6 +10,12 @@ description: 在无限画板上排布图形,并按期刊规范设定页面尺 对齐(选中两个及以上图框)、水平 / 垂直分布(三个及以上)、层叠顺序, 以及 *Tidy up frames*(一键整理)命令。 +在画板空白处拖动可框选页面和工作表图框。按住 `Shift` 可追加框内图框,按住 +`Ctrl`(macOS 上为 `Cmd`)可切换框内图框。拖动任一所选图框的标题栏会移动 +整个选择,并可作为一步撤销。在 Canvas 列表中,`Shift` + 单击选择连续区间, +`Ctrl` + 单击添加或移除单个画布。`Ctrl` + `A` 会根据最近使用的区域全选图框 +或画布。 + ## 画布尺寸 活动页面左上角上方会显示一个尺寸标签,内容为当前尺寸和匹配到的预设 diff --git a/docs/src/content/docs/zh-cn/guides/organizing-data.md b/docs/src/content/docs/zh-cn/guides/organizing-data.md index 594199b..e4b785f 100644 --- a/docs/src/content/docs/zh-cn/guides/organizing-data.md +++ b/docs/src/content/docs/zh-cn/guides/organizing-data.md @@ -19,9 +19,16 @@ description: 浏览数据集与派生结果,并把工作保存为项目。 ## 选择与打开 -单击数据集或引用可聚焦。按住 `Shift`、`Ctrl` 或 `Cmd` 单击可扩展多 -选——多选正是在一个图中堆叠多条谱、或把处理模板一次应用到多个数据集 -的方式。双击数据集打开其数据表。单击分析项会聚焦其所属数据集,双击则 +单击数据集或引用可将其选中。`Shift` + 单击选择从上一个主项目到当前项目的 +连续区间;`Ctrl` + 单击(macOS 上为 `Cmd`)添加或移除单个项目;同时按住 +两者则在不清除现有选择的情况下追加一个区间。Canvas 与 Layers 列表使用 +相同的扩展选择方式。多选正是在一个图中堆叠多条谱、或把处理模板一次应用 +到多个数据集的方式。 + +列表处于活动状态时,可用 `↑` / `↓` 或 `Home` / `End` 移动选择,按住 +`Shift` 扩展选择,按 `Space` 添加或移除主项目。`Ctrl` + `A` 全选数据集, +`Ctrl` + `Shift` + `A` 清空选择。双击数据集打开其数据表。单击分析项会聚焦 +其所属数据集,双击则 跳转到相应图和分析工具。 用很旧版本的 PlotX 保存的项目可能把部分派生结果显示为顶层数据集;它们 diff --git a/docs/src/content/docs/zh-cn/reference/shortcuts.md b/docs/src/content/docs/zh-cn/reference/shortcuts.md index 437d676..2f224b9 100644 --- a/docs/src/content/docs/zh-cn/reference/shortcuts.md +++ b/docs/src/content/docs/zh-cn/reference/shortcuts.md @@ -69,14 +69,20 @@ description: 键盘与鼠标快捷操作。 | `Ctrl` + `S` | 打开项目保存选项 | | `Ctrl` + `Z` | 撤销 | | `Ctrl` + `Shift` + `Z` 或 `Ctrl` + `Y` | 重做 | -| `Ctrl` + `A` | 全选页面上的对象 | +| `Ctrl` + `A` | 全选当前上下文中的项目:页面对象、画板图框、画布或数据集 | +| `Ctrl` + `Shift` + `A` | 清除当前上下文中的选择 | +| `Shift` + 单击 | 在 Canvas、Layers 或 Data 列表中选择连续区间 | +| `Ctrl` + 单击 | 保留其余选择,并添加或移除单个项目 | +| `Ctrl` + `Shift` + 单击 | 把一个连续区间追加到现有列表选择中 | +| `↑` / `↓`、`Home` / `End` | 在当前 Canvas、Layers 或 Data 列表中移动选择;按住 `Shift` 可扩展 | +| `Space` | 添加或移除当前列表的主项目 | | `Ctrl` + `G` | 编组所选对象 | | `Ctrl` + `Shift` + `G` | 取消编组 | | `Delete` 或 `Backspace` | 删除所选标注对象;在峰或积分工具中删除所选的峰或区域;在 **Symmetry review** 中删除所选交叉峰标记 | | `+`(或 `=`)/ `-` | 提高 / 降低所选图的等高线最低层 | | `F2` | 重命名所选数据集或画布 | | `Esc` | 取消进行中的拖动;继续按则依次清除分析范围和各级选择,最后退出当前工具 | -| `Ctrl` + `C` | 把选中的图框(未选中时为活动画布)以位图 + 矢量格式复制到剪贴板 | +| `Ctrl` + `C` | 把唯一选中的页面图框(未选中时为活动画布)以位图 + 矢量格式复制到剪贴板 | | `Ctrl` + `Shift` + `V` | 把剪贴板中的分隔文本(逗号、制表符或分号)粘贴为新数据表 | | `Ctrl` + `,` | 打开首选项 | | `Ctrl` + `K` 或 `Ctrl` + `Shift` + `P` | 打开[命令面板](/zh-cn/reference/command-palette/) |