From 444b15bc1997b6ceebcd351d62ef5a2719790f74 Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sun, 6 Sep 2026 16:26:58 +0800 Subject: [PATCH 1/3] feat(ui): swap panels within the same canvas --- crates/app/src/ui/canvas/mod.rs | 1 + .../app/src/ui/canvas/panel_interactions.rs | 11 +++ .../src/ui/canvas/panel_interactions/swap.rs | 81 +++++++++++++++ .../canvas/panel_interactions/swap/tests.rs | 98 +++++++++++++++++++ crates/core/src/actions/app_impl/mod.rs | 6 +- crates/core/src/actions/mod.rs | 1 + crates/core/src/actions/panel_swap.rs | 62 ++++++++++++ .../content/docs/guides/layout-and-export.md | 5 + .../docs/zh-cn/guides/layout-and-export.md | 4 + 9 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 crates/app/src/ui/canvas/panel_interactions/swap.rs create mode 100644 crates/app/src/ui/canvas/panel_interactions/swap/tests.rs create mode 100644 crates/core/src/actions/panel_swap.rs 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..bc586dc 100644 --- a/crates/app/src/ui/canvas/panel_interactions.rs +++ b/crates/app/src/ui/canvas/panel_interactions.rs @@ -72,6 +72,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 +94,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..fbb54ca --- /dev/null +++ b/crates/app/src/ui/canvas/panel_interactions/swap.rs @@ -0,0 +1,81 @@ +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(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..f21d387 --- /dev/null +++ b/crates/app/src/ui/canvas/panel_interactions/swap/tests.rs @@ -0,0 +1,98 @@ +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_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_resize_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 布局,内容会在移入后重新排列。隐藏对象 From 99b0d50f11e0bb99894711733882a129c5f9ffba Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sun, 6 Sep 2026 21:04:54 +0800 Subject: [PATCH 2/3] fix(ui): keep both panel slots readable during swap preview --- .../app/src/ui/canvas/panel_interactions.rs | 3 ++- .../src/ui/canvas/panel_interactions/swap.rs | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/app/src/ui/canvas/panel_interactions.rs b/crates/app/src/ui/canvas/panel_interactions.rs index bc586dc..b48b098 100644 --- a/crates/app/src/ui/canvas/panel_interactions.rs +++ b/crates/app/src/ui/canvas/panel_interactions.rs @@ -16,7 +16,7 @@ pub(crate) fn handle_panel_drag( 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) @@ -65,6 +65,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) { diff --git a/crates/app/src/ui/canvas/panel_interactions/swap.rs b/crates/app/src/ui/canvas/panel_interactions/swap.rs index fbb54ca..64521cc 100644 --- a/crates/app/src/ui/canvas/panel_interactions/swap.rs +++ b/crates/app/src/ui/canvas/panel_interactions/swap.rs @@ -48,6 +48,25 @@ pub(super) fn commit(app: &mut PlotxApp, drag: &PanelDrag, target: PanelId) { } } +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, From 9ccecb63bf483dfaf670b782de205d766c8afc85 Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sun, 6 Sep 2026 22:12:34 +0800 Subject: [PATCH 3/3] fix(ui): handle panel swap release in the first drag frame --- .../app/src/ui/canvas/panel_interactions.rs | 3 +- .../canvas/panel_interactions/swap/tests.rs | 63 ++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/app/src/ui/canvas/panel_interactions.rs b/crates/app/src/ui/canvas/panel_interactions.rs index b48b098..51d5ab4 100644 --- a/crates/app/src/ui/canvas/panel_interactions.rs +++ b/crates/app/src/ui/canvas/panel_interactions.rs @@ -9,7 +9,7 @@ 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 { @@ -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; } diff --git a/crates/app/src/ui/canvas/panel_interactions/swap/tests.rs b/crates/app/src/ui/canvas/panel_interactions/swap/tests.rs index f21d387..aa94dd6 100644 --- a/crates/app/src/ui/canvas/panel_interactions/swap/tests.rs +++ b/crates/app/src/ui/canvas/panel_interactions/swap/tests.rs @@ -44,6 +44,67 @@ fn fixture() -> (PlotxApp, PanelDrag, PanelId, EguiRect, Pos2) { (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(); @@ -74,7 +135,7 @@ fn panel_swap_release_scales_multiple_children_and_undo_restores_pre_drag() { } #[test] -fn panel_swap_excludes_locked_hidden_self_resize_and_multiple_selection() { +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);