Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 15 additions & 2 deletions crates/app/src/ui/canvas/panel_interactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
}
Expand Down Expand Up @@ -65,13 +66,22 @@ 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)
{
app.session.ui.snap_guides.clear();
}
}
}
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
Expand All @@ -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,
Expand Down
100 changes: 100 additions & 0 deletions crates/app/src/ui/canvas/panel_interactions/swap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
use super::*;

pub(super) fn target(
app: &PlotxApp,
drag: &PanelDrag,
rect: EguiRect,
pointer: Option<Pos2>,
) -> Option<PanelId> {
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<Pos2>) {
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;
159 changes: 159 additions & 0 deletions crates/app/src/ui/canvas/panel_interactions/swap/tests.rs
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 4 additions & 2 deletions crates/core/src/actions/app_impl/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/core/src/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading