diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 3c0cd66..3fb8461 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -265,6 +265,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { paint_wheel_target_hint(app, ci, rect, ui, &painter, chrome, ui.visuals().dark_mode); paint_tile_ghost(app, &painter, chrome); paint_tile_preview(app, rect, &painter, chrome); + paint_panel_swap(app, rect, &painter, chrome); super::canvas_size::page_size_chrome(app, ci, page, rect, ui); if pointer_owned { canvas_cursor(app, ci, rect, ui); diff --git a/crates/app/src/ui/canvas/panel_interactions.rs b/crates/app/src/ui/canvas/panel_interactions.rs index 6da1b72..51d5ab4 100644 --- a/crates/app/src/ui/canvas/panel_interactions.rs +++ b/crates/app/src/ui/canvas/panel_interactions.rs @@ -9,14 +9,14 @@ pub(crate) fn handle_panel_drag( primary_released: bool, alt: bool, ) { - let Some(drag) = (match &app.session.ui.interaction { + let Some(mut drag) = (match &app.session.ui.interaction { Interaction::Panel(d) if d.canvas == ci => Some(d.clone()), _ => None, }) else { return; }; let mut active = drag.active; - if primary_down + if (primary_down || primary_released) && let Some(screen_now) = hover && let Some(pointer_page) = screen_to_page_unbounded(app.session.board, &app.doc.canvases[ci], rect, screen_now) @@ -26,6 +26,7 @@ pub(crate) fn handle_panel_drag( let dsx = screen_now.x - drag.start_pointer_screen[0]; let dsy = screen_now.y - drag.start_pointer_screen[1]; active |= dsx.hypot(dsy) > DRAG_START_PX; + drag.active = active; if let Interaction::Panel(current) = &mut app.session.ui.interaction { current.active = active; } @@ -65,6 +66,7 @@ pub(crate) fn handle_panel_drag( } } } + swap::restore_preview(app, rect, hover); if let Some(source) = tile_source_for_panel(app, &drag) && update_tile_drop(app, ci, rect, source, hover) { @@ -72,6 +74,14 @@ pub(crate) fn handle_panel_drag( } } } + if (primary_released || !primary_down) + && active + && let Some(target) = swap::target(app, &drag, rect, hover) + { + app.take_interaction(); + swap::commit(app, &drag, target); + return; + } if (primary_released || !primary_down) && let Interaction::Panel(drag) = app.take_interaction() && active @@ -86,6 +96,9 @@ pub(crate) fn handle_panel_drag( } } +mod swap; +pub(crate) use swap::paint_panel_swap; + pub(crate) fn begin_panel_drag( app: &mut PlotxApp, ci: usize, diff --git a/crates/app/src/ui/canvas/panel_interactions/swap.rs b/crates/app/src/ui/canvas/panel_interactions/swap.rs new file mode 100644 index 0000000..64521cc --- /dev/null +++ b/crates/app/src/ui/canvas/panel_interactions/swap.rs @@ -0,0 +1,100 @@ +use super::*; + +pub(super) fn target( + app: &PlotxApp, + drag: &PanelDrag, + rect: EguiRect, + pointer: Option, +) -> Option { + if !drag.active || drag.kind != ObjectDragKind::Move || !drag.others.is_empty() { + return None; + } + let pointer = pointer?; + if !rect.contains(pointer) || frame_at(app, rect, pointer) != Some(FrameRef::Page(drag.canvas)) + { + return None; + } + let page = app.doc.canvases.get(drag.canvas)?; + let point = BoardTransform::from_board(app.session.board, rect).screen_to_page(page, pointer); + page.panels + .iter() + .rev() + .find(|panel| { + panel.id != drag.panel + && panel.visible + && !panel.locked + && EguiRect::from_min_size( + Pos2::new(panel.frame.x, panel.frame.y), + Vec2::new(panel.frame.width, panel.frame.height), + ) + .contains(point) + }) + .map(|panel| panel.id) +} + +pub(super) fn commit(app: &mut PlotxApp, drag: &PanelDrag, target: PanelId) { + let Some(page) = app.doc.canvases.get_mut(drag.canvas) else { + return; + }; + let canvas = page.resource_id; + // The history baseline must precede the entire live move. + if let Some(panel) = page.panel_mut(drag.panel) { + panel.frame = drag.before; + } + app.session.ui.tile_drop = None; + match app.swap_panels_action(canvas, drag.panel, target) { + Ok(action) => app.execute_action(action), + Err(error) => app.session.status = format!("Could not swap Panels: {error}"), + } +} + +pub(super) fn restore_preview(app: &mut PlotxApp, rect: EguiRect, pointer: Option) { + let Interaction::Panel(drag) = &app.session.ui.interaction else { + return; + }; + if target(app, drag, rect, pointer).is_none() { + return; + } + let (canvas, panel, before) = (drag.canvas, drag.panel, drag.before); + // Both highlighted slots remain readable while the pending exchange is shown. + if let Some(panel) = app + .doc + .canvases + .get_mut(canvas) + .and_then(|page| page.panel_mut(panel)) + { + panel.frame = before; + } +} + +pub(crate) fn paint_panel_swap( + app: &PlotxApp, + rect: EguiRect, + painter: &egui::Painter, + chrome: ChromeStyle, +) { + let Interaction::Panel(drag) = &app.session.ui.interaction else { + return; + }; + let pointer = painter.ctx().input(|input| input.pointer.hover_pos()); + let Some(target) = target(app, drag, rect, pointer) else { + return; + }; + let page = &app.doc.canvases[drag.canvas]; + let Some(panel) = page.panel(target) else { + return; + }; + let bt = BoardTransform::from_board(app.session.board, rect); + let page_rect = bt.page_screen_rect(page); + for frame in [drag.before, panel.frame] { + let r = EguiRect::from_min_size( + page_rect.min + Vec2::new(frame.x, frame.y) * bt.zoom, + Vec2::new(frame.width, frame.height) * bt.zoom, + ); + painter.rect_filled(r, 0.0, chrome.tile_target_fill); + painter.rect_stroke(r, 0.0, chrome.tile_target_stroke(), StrokeKind::Inside); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app/src/ui/canvas/panel_interactions/swap/tests.rs b/crates/app/src/ui/canvas/panel_interactions/swap/tests.rs new file mode 100644 index 0000000..aa94dd6 --- /dev/null +++ b/crates/app/src/ui/canvas/panel_interactions/swap/tests.rs @@ -0,0 +1,159 @@ +use super::*; +use plotx_core::state::{CanvasObject, CanvasObjectKind, TextBox}; + +fn fixture() -> (PlotxApp, PanelDrag, PanelId, EguiRect, Pos2) { + let mut app = PlotxApp::default(); + let mut page = CanvasDocument::new("Swap".into(), [200.0, 100.0]); + let a = page.create_panel("A".into(), ObjectFrame::new(10.0, 10.0, 100.0, 80.0)); + let b = page.create_panel("B".into(), ObjectFrame::new(200.0, 10.0, 200.0, 160.0)); + for (panel, width) in [(a, 100.0), (b, 200.0)] { + for x in [0.0, 20.0] { + let id = page.allocate_object_id(); + page.objects.push(CanvasObject { + id, + name: "Text".into(), + frame: ObjectFrame::new(x, 5.0, width / 2.0, 20.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Text(TextBox::label("Example".into())), + }); + page.panel_mut(panel).unwrap().item_order.push(id); + } + } + app.doc.canvases.push(page); + app.session.active_canvas = Some(0); + app.session.board.zoom = 1.0; + let rect = EguiRect::from_min_size(Pos2::ZERO, Vec2::new(1000.0, 800.0)); + let page_rect = + BoardTransform::from_board(app.session.board, rect).page_screen_rect(&app.doc.canvases[0]); + let pointer = page_rect.min + Vec2::new(250.0, 50.0); + app.session.tool = Tool::Select; + begin_panel_drag( + &mut app, + 0, + a, + ObjectDragKind::Move, + Some(Pos2::new(50.0, 50.0)), + page_rect.min + Vec2::new(50.0, 50.0), + false, + ); + let Interaction::Panel(mut drag) = app.take_interaction() else { + panic!("panel gesture expected"); + }; + drag.active = true; + (app, drag, b, rect, pointer) +} + +#[test] +fn panel_swap_handles_first_movement_and_release_in_one_frame() { + assert_swap_through_drag_handler(true); +} + +#[test] +fn panel_swap_handles_movement_before_release() { + assert_swap_through_drag_handler(false); +} + +fn assert_swap_through_drag_handler(release_on_first_movement: bool) { + let (mut app, drag, b, rect, pointer) = fixture(); + let source = drag.panel; + let before = PanelState::of(&app.doc.canvases[0]); + let ctx = egui::Context::default(); + let start = Pos2::new(drag.start_pointer_screen[0], drag.start_pointer_screen[1]); + let button = |pos, pressed| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + let mut frame = |events| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(rect), + events, + ..Default::default() + }, + |ui| { + let response = ui.interact(rect, egui::Id::new("swap"), egui::Sense::drag()); + handle_object_interactions(&mut app, 0, rect, ui, &response); + }, + ); + }; + frame(vec![egui::Event::PointerMoved(start), button(start, true)]); + if !release_on_first_movement { + frame(vec![egui::Event::PointerMoved(pointer)]); + } + frame(vec![ + egui::Event::PointerMoved(pointer), + button(pointer, false), + ]); + assert_eq!( + app.doc.canvases[0].panel(source).unwrap().frame, + before.panels[1].frame + ); + assert_eq!( + app.doc.canvases[0].panel(b).unwrap().frame, + before.panels[0].frame + ); + app.undo(); + assert_eq!(app.doc.canvases[0].panels, before.panels); + assert!(!app.can_undo()); + app.redo(); + assert_eq!( + app.doc.canvases[0].panel(source).unwrap().frame, + before.panels[1].frame + ); +} + +#[test] +fn panel_swap_release_scales_multiple_children_and_undo_restores_pre_drag() { + let (mut app, drag, b, rect, pointer) = fixture(); + let before = PanelState::of(&app.doc.canvases[0]); + app.doc.canvases[0].panel_mut(drag.panel).unwrap().frame.x = 210.0; + assert_eq!(target(&app, &drag, rect, Some(pointer)), Some(b)); + commit(&mut app, &drag, b); + let page = &app.doc.canvases[0]; + assert_eq!( + page.panel(drag.panel).unwrap().frame, + before.panels[1].frame + ); + assert_eq!(page.panel(b).unwrap().frame, drag.before); + assert_eq!(page.objects[0].frame.width, 100.0); + assert_eq!(page.objects[2].frame.width, 50.0); + page.validate_structure().unwrap(); + let after = PanelState::of(page); + app.undo(); + assert_eq!(app.doc.canvases[0].panels, before.panels); + assert_eq!( + app.doc.canvases[0].objects[0].frame, + before.objects[0].frame + ); + assert!(!app.can_undo()); + app.redo(); + assert_eq!(app.doc.canvases[0].panels, after.panels); + assert_eq!(app.doc.canvases.len(), 1); +} + +#[test] +fn panel_swap_excludes_locked_hidden_self_inactive_and_multiple_selection() { + let (mut app, mut drag, b, rect, pointer) = fixture(); + app.doc.canvases[0].panel_mut(b).unwrap().locked = true; + assert_eq!(target(&app, &drag, rect, Some(pointer)), None); + assert!( + app.swap_panels_action(app.doc.canvases[0].resource_id, drag.panel, b) + .is_err() + ); + app.doc.canvases[0].panel_mut(b).unwrap().locked = false; + app.doc.canvases[0].panel_mut(b).unwrap().visible = false; + assert_eq!(target(&app, &drag, rect, Some(pointer)), None); + app.doc.canvases[0].panel_mut(b).unwrap().visible = true; + assert_eq!(target(&app, &drag, rect, None), None); + let own = pointer - Vec2::new(200.0, 0.0); + assert_eq!(target(&app, &drag, rect, Some(own)), None); + drag.others + .push((b, app.doc.canvases[0].panel(b).unwrap().frame)); + assert_eq!(target(&app, &drag, rect, Some(pointer)), None); + drag.others.clear(); + drag.active = false; + assert_eq!(target(&app, &drag, rect, Some(pointer)), None); +} diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 7a71550..fe8d7d7 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -10,14 +10,16 @@ pub use validate::ActionApplyError; use validate::{ValidationShape, validate_action}; impl PlotxApp { - fn set_panel_state(&mut self, canvas: usize, state: &PanelState) { - if let Some(canvas) = self.doc.canvases.get_mut(canvas) { + fn set_panel_state(&mut self, canvas_index: usize, state: &PanelState) { + if let Some(canvas) = self.doc.canvases.get_mut(canvas_index) { canvas.objects.clone_from(&state.objects); canvas.panels.clone_from(&state.panels); canvas.groups.clone_from(&state.groups); canvas.next_object_id = state.next_object_id; canvas.next_group_id = state.next_group_id; canvas.next_panel_label_slot = state.next_panel_label_slot; + // Apply, undo and redo must render plots at their restored content sizes. + self.rebuild_canvas(canvas_index); } } diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 8863c6c..ae11021 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -16,6 +16,7 @@ use plotx_processing::{AxisPipeline, Params2D, Preset2D}; mod app_impl; mod arrange; mod panel; +mod panel_swap; mod processing_state; mod transfer; mod zorder; diff --git a/crates/core/src/actions/panel_swap.rs b/crates/core/src/actions/panel_swap.rs new file mode 100644 index 0000000..8f74003 --- /dev/null +++ b/crates/core/src/actions/panel_swap.rs @@ -0,0 +1,62 @@ +use super::{Action, PanelActionError, PanelState}; +use crate::state::{CanvasId, ObjectFrame, PanelId, PlotxApp}; + +impl PlotxApp { + /// Exchange occupied panel slots, preserving panel identities and local layout. + pub fn swap_panels_action( + &self, + canvas: CanvasId, + source: PanelId, + target: PanelId, + ) -> Result { + let canvas = self + .doc + .canvas_index(canvas) + .ok_or_else(|| PanelActionError::Invalid("the canvas no longer exists".to_owned()))?; + if source == target { + return Err(PanelActionError::Invalid( + "choose two different panels".to_owned(), + )); + } + let mut page = self.doc.canvases[canvas].clone(); + let before = PanelState::of(&page); + let frames = [source, target].map(|id| { + let panel = page.panel(id).ok_or(PanelActionError::MissingPanel(id))?; + if panel.locked || !panel.visible { + return Err(PanelActionError::Invalid( + "both panels must be visible and unlocked".to_owned(), + )); + } + Ok(panel.frame) + }); + let [source_frame, target_frame] = frames; + let (source_frame, target_frame) = (source_frame?, target_frame?); + for (id, frame) in [(source, target_frame), (target, source_frame)] { + let panel = page.panel(id).expect("panels checked above"); + let scale = [ + frame.width / panel.frame.width, + frame.height / panel.frame.height, + ]; + let children = panel.item_order.clone(); + for child in children { + let item = page + .object_mut(child) + .ok_or(PanelActionError::MissingContent(child))?; + item.frame = ObjectFrame::new( + item.frame.x * scale[0], + item.frame.y * scale[1], + item.frame.width * scale[0], + item.frame.height * scale[1], + ); + } + page.panel_mut(id).expect("panels checked above").frame = frame; + } + page.validate_structure() + .map_err(PanelActionError::Invalid)?; + Ok(Action::ReplacePanelState { + canvas, + before, + after: PanelState::of(&page), + }) + } +} diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 21bcff5..3cc34d0 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -86,6 +86,11 @@ Panels. To work with an object inside a Panel, double-click the Panel or press `Enter`. Use the breadcrumb above the page or press `Esc` to return to the page. You can also `Ctrl`/`Cmd`-click an object to select it without entering first. +To exchange two Panels on the same canvas, drag one over the other and release +when both slots are highlighted. The Panels exchange positions and sizes; their +contents scale with the destination slot. This is one undoable step. Hidden or +locked Panels cannot be swap targets, and multi-Panel drags remain group moves. + Inside a Panel, objects snap to the Panel edge and to one another. To move an object into another unlocked Panel, drag it over the Panel and release when the target is highlighted. You can make the same move in Layers by dragging the 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 e1e056b..d263ee2 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 @@ -69,6 +69,10 @@ PlotX 创建图片页面时,会优先采用图片的 DPI 信息确定实际尺 请双击 Panel 或按 `Enter` 进入。点击页面上方的面包屑或按 `Esc` 可返回页面。也可 按住 `Ctrl`/`Cmd` 单击对象,无需先进入 Panel 即可直接选择它。 +若要交换同一 canvas 中的两个 Panel,将其中一个拖到另一个上面,待两个位置高亮后 +松开。两个 Panel 会交换位置和尺寸,内部内容随目标框缩放;整个交换可一步撤销。 +隐藏或锁定的 Panel 不能作为交换目标,多 Panel 拖动仍保持整体移动。 + 在 Panel 内,对象会吸附到 Panel 边缘和其他对象。若要把对象移入另一个未锁定的 Panel,请将它拖到目标 Panel 上,待目标高亮后松开。也可以在 Layers 中把对象行 拖到 Panel 行。若目标采用 Stack 或 Grid 布局,内容会在移入后重新排列。隐藏对象