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
29 changes: 19 additions & 10 deletions crates/app/src/ui/canvas/interactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,24 @@ pub(crate) fn handle_object_interactions(
ui: &Ui,
_resp: &egui::Response,
) {
let (hover, primary_down, primary_pressed, primary_released, shift) = ui.input(|i| {
(
i.pointer.hover_pos(),
i.pointer.primary_down(),
i.pointer.primary_pressed(),
i.pointer.primary_released(),
i.modifiers.shift,
)
});
let (hover, primary_down, primary_pressed, primary_released, shift, alt, esc, focused) = ui
.input(|i| {
(
i.pointer.hover_pos(),
i.pointer.primary_down(),
i.pointer.primary_pressed(),
i.pointer.primary_released(),
i.modifiers.shift,
i.modifiers.alt,
i.key_pressed(egui::Key::Escape),
i.focused,
)
});

if (esc || !focused) && matches!(app.interaction(), Interaction::Object(_)) {
app.cancel_interaction();
return;
}

if primary_pressed {
let Some(screen_pos) = hover else {
Expand Down Expand Up @@ -410,7 +419,7 @@ pub(crate) fn handle_object_interactions(
app.session.ui.snap_guides.clear();
if let Interaction::Object(drag) = app.take_interaction() {
if let Some(preview) = app.session.ui.tile_drop.take() {
commit_tile_drop(app, ci, drag, preview);
commit_tile_drop(app, drag, preview, alt);
} else if active {
finish_object_drag(app, ci, drag);
}
Expand Down
71 changes: 61 additions & 10 deletions crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,42 @@ pub(crate) use slices::*;
pub(crate) use snap::*;
pub(crate) use tiling::*;

fn finite_rect_intersects(a: egui::Rect, b: egui::Rect) -> bool {
let finite = |r: egui::Rect| {
[r.min.x, r.min.y, r.max.x, r.max.y]
.iter()
.all(|value| value.is_finite())
};
finite(a)
&& finite(b)
&& a.max.x >= b.min.x
&& b.max.x >= a.min.x
&& a.max.y >= b.min.y
&& b.max.y >= a.min.y
}

#[cfg(test)]
mod culling_tests {
use super::finite_rect_intersects;

#[test]
fn edge_contact_is_visible_and_non_finite_is_not() {
let clip = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(10.0, 10.0));
assert!(finite_rect_intersects(
egui::Rect::from_min_max(egui::pos2(10.0, 2.0), egui::pos2(20.0, 8.0)),
clip,
));
assert!(!finite_rect_intersects(
egui::Rect::from_min_max(egui::pos2(11.0, 2.0), egui::pos2(20.0, 8.0)),
clip,
));
assert!(!finite_rect_intersects(
egui::Rect::from_min_max(egui::pos2(f32::NAN, 0.0), egui::pos2(1.0, 1.0)),
clip,
));
}
}

#[derive(Clone, Copy)]
pub(crate) enum CanvasInteractionClearScope {
Transient,
Expand Down Expand Up @@ -112,7 +148,6 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) {
};
// `dispatch_frame_gesture` may have switched the active frame.
let ci = app.session.active_canvas.unwrap_or(ci);
let page = page_screen_rect(app.session.board, &app.doc.canvases[ci], rect);

// Processing direct manipulation must update the document before its plots
// are painted, so the changed spectrum is visible in this same frame.
Expand Down Expand Up @@ -149,6 +184,15 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) {
}
}

// A tile drop may remove the now-empty source canvas and switch focus to the
// target in the same UI pass. Refresh both values before any post-interaction
// painting; the old index can otherwise refer to a different canvas or be out
// of bounds when the source was the last page.
let Some(ci) = app.session.active_canvas else {
return;
};
let page = page_screen_rect(app.session.board, &app.doc.canvases[ci], rect);

let frame_stroke = Stroke::new(1.0_f32, ui.visuals().widgets.noninteractive.bg_stroke.color);
// Pages float on the board the way chrome cards float on the workspace: a
// soft shadow keeps a white page legible on the light workspace fill.
Expand All @@ -158,17 +202,23 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) {
spread: 0,
color: Color32::from_black_alpha(if ui.visuals().dark_mode { 110 } else { 36 }),
};
for other in 0..app.doc.canvases.len() {
if other == ci {
continue;
}
let clip = painter.clip_rect();
for other in (0..app.doc.canvases.len())
.filter(|&other| other != ci)
.chain(std::iter::once(ci))
{
let other_page = page_screen_rect(app.session.board, &app.doc.canvases[other], rect);
painter.add(page_shadow.as_shape(frame_card_rect(other_page), header_corner_radius()));
paint_document(app, other, rect, &painter);
painter.rect_stroke(other_page, 0.0, frame_stroke, StrokeKind::Inside);
let card = frame_card_rect(other_page);
if finite_rect_intersects(card.expand(12.0), clip) {
painter.add(page_shadow.as_shape(card, header_corner_radius()));
}
if finite_rect_intersects(other_page, clip) {
paint_document(app, other, rect, &painter);
if other != ci {
painter.rect_stroke(other_page, 0.0, frame_stroke, StrokeKind::Inside);
}
}
}
painter.add(page_shadow.as_shape(frame_card_rect(page), header_corner_radius()));
paint_document(app, ci, rect, &painter);
paint_frame_headers(app, rect, ui, &painter);
paint_frame_captions(app, rect, ui, &painter);
render_inline_panel_note_editor(app, rect, ui);
Expand All @@ -179,6 +229,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) {
paint_marquee(app, ci, rect, &painter, chrome);
paint_panel_label_selection(app, ci, rect, &painter, chrome);
paint_object_selection(app, ci, rect, page, &painter, chrome);
paint_tile_ghost(app, &painter, chrome);
paint_tile_preview(app, rect, &painter, chrome);
super::canvas_size::page_size_chrome(app, ci, page, rect, ui);
if pointer_owned {
Expand Down
126 changes: 121 additions & 5 deletions crates/app/src/ui/canvas/tiling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,34 @@ pub(crate) fn update_tile_drop(
existing_ids.len(),
[pointer_page.x, pointer_page.y],
);
let cache_key = tile_cache_key(drag, target, page_pt, layout, &existing_ids, region);
let pointer_cell = plotx_core::layout::tiling_drop_cell(
page_pt,
&layout,
existing_ids.len() + 1,
[pointer_page.x, pointer_page.y],
);
let cache_key = tile_cache_key(
drag,
target,
page_pt,
layout,
&existing_ids,
region,
pointer_cell,
);
if app
.session
.ui
.tile_drop
.as_ref()
.is_some_and(|preview| preview.cache_key == cache_key)
{
if let Some(preview) = app.session.ui.tile_drop.as_mut() {
preview.pointer_screen = [p.x, p.y];
}
if let Some(object) = app.doc.canvases[drag.canvas].object_mut(drag.object) {
object.frame = drag.before;
}
return true;
}
let existing_items: Vec<_> = existing_ids
Expand All @@ -77,7 +97,22 @@ pub(crate) fn update_tile_drop(
target,
newcomer: plan.newcomer,
existing: plan.existing,
pointer_screen: [p.x, p.y],
anchor: [
((drag.start_pointer[0] - drag.before.x) / drag.before.width.max(f32::EPSILON))
.clamp(0.0, 1.0),
((drag.start_pointer[1] - drag.before.y) / drag.before.height.max(f32::EPSILON))
.clamp(0.0, 1.0),
],
});
app.session.status = if app.keep_empty_source_canvas {
"Hold Alt to remove the empty source canvas.".into()
} else {
"Hold Alt to keep the empty source canvas.".into()
};
if let Some(object) = app.doc.canvases[drag.canvas].object_mut(drag.object) {
object.frame = drag.before;
}
true
}

Expand All @@ -88,6 +123,7 @@ fn tile_cache_key(
target_layout: plotx_core::layout::PageLayout,
target_existing_ids: &[ObjectId],
region: plotx_core::layout::TilingDropRegion,
pointer_cell: Option<usize>,
) -> TileDropCacheKey {
TileDropCacheKey {
source_canvas: drag.canvas,
Expand All @@ -97,6 +133,7 @@ fn tile_cache_key(
target_layout,
target_existing_ids: target_existing_ids.to_vec(),
region,
pointer_cell,
}
}

Expand All @@ -113,26 +150,78 @@ fn layout_item(canvas: &CanvasDocument, id: ObjectId) -> Option<plotx_core::layo
/// Falls back to a plain move if the atomic action cannot be built.
pub(crate) fn commit_tile_drop(
app: &mut PlotxApp,
ci: usize,
drag: ObjectDrag,
preview: TileDropPreview,
alt: bool,
) {
let remove_empty_source = app.keep_empty_source_canvas == alt;
let source_becomes_empty =
app.doc.canvases.get(drag.canvas).is_some_and(|canvas| {
canvas.objects.len() == 1 && canvas.object(drag.object).is_some()
});
let Some(action) = Action::tile_drop(
app,
ci,
drag.canvas,
drag.object,
preview.target,
preview.newcomer,
preview.existing,
remove_empty_source,
) else {
if drag.active {
finish_object_drag(app, ci, drag);
finish_object_drag(app, drag.canvas, drag);
}
return;
};
let target = app.doc.canvases[preview.target].name.clone();
app.execute_action(action);
app.session.status = format!("Tiled plot into “{target}”.");
app.session.status = if remove_empty_source && source_becomes_empty {
format!("Tiled plot into “{target}” and removed the empty source canvas.")
} else {
format!("Tiled plot into “{target}”; kept the source canvas.")
};
}

pub(crate) fn paint_tile_ghost(app: &PlotxApp, painter: &egui::Painter, chrome: ChromeStyle) {
let (Some(preview), Interaction::Object(drag)) =
(&app.session.ui.tile_drop, &app.session.ui.interaction)
else {
return;
};
if preview.cache_key.source_canvas != drag.canvas
|| preview.cache_key.source_object != drag.object
{
return;
}
let Some(plot) = app
.doc
.canvases
.get(drag.canvas)
.and_then(|canvas| canvas.object(drag.object))
.and_then(|object| object.plot())
else {
return;
};
let ghost = preview.ghost_frame(drag.before, app.session.board.zoom);
if ![ghost.x, ghost.y, ghost.width, ghost.height]
.iter()
.all(|v| v.is_finite())
{
return;
}
let screen = PlotRect::new(ghost.x, ghost.y, ghost.width, ghost.height);
plotx_render::screen::paint(painter, screen, &plot.figure, app.session.board.zoom);
let r = EguiRect::from_min_size(
Pos2::new(ghost.x, ghost.y),
Vec2::new(ghost.width, ghost.height),
);
painter.rect_filled(r, 0.0, Color32::from_white_alpha(36));
painter.rect_stroke(
r,
0.0,
Stroke::new(2.0_f32, chrome.tile_target_stroke),
StrokeKind::Inside,
);
}

pub(crate) fn paint_tile_preview(
Expand Down Expand Up @@ -203,6 +292,7 @@ mod tests {
layout,
&[20, 21],
plotx_core::layout::TilingDropRegion::Left,
None,
);
assert_eq!(
base,
Expand All @@ -213,6 +303,7 @@ mod tests {
layout,
&[20, 21],
plotx_core::layout::TilingDropRegion::Left,
None,
)
);
assert_ne!(
Expand All @@ -224,6 +315,7 @@ mod tests {
layout,
&[20, 21],
plotx_core::layout::TilingDropRegion::Left,
None,
)
);
assert_ne!(
Expand All @@ -235,6 +327,7 @@ mod tests {
layout,
&[20, 21],
plotx_core::layout::TilingDropRegion::Left,
None,
)
);
assert_ne!(
Expand All @@ -246,6 +339,7 @@ mod tests {
layout,
&[20, 21],
plotx_core::layout::TilingDropRegion::Left,
None,
)
);
assert_ne!(
Expand All @@ -257,6 +351,7 @@ mod tests {
plotx_core::layout::PageLayout { cols: 2, ..layout },
&[20, 21],
plotx_core::layout::TilingDropRegion::Left,
None,
)
);
assert_ne!(
Expand All @@ -268,6 +363,7 @@ mod tests {
layout,
&[20, 21],
plotx_core::layout::TilingDropRegion::Right,
None,
)
);
assert_ne!(
Expand All @@ -279,7 +375,27 @@ mod tests {
layout,
&[21, 20],
plotx_core::layout::TilingDropRegion::Left,
None,
)
);
let retile_top_left = tile_cache_key(
&drag(0, 10),
2,
page,
layout,
&[20, 21],
plotx_core::layout::TilingDropRegion::Retile,
Some(0),
);
let retile_bottom_right = tile_cache_key(
&drag(0, 10),
2,
page,
layout,
&[20, 21],
plotx_core::layout::TilingDropRegion::Retile,
Some(3),
);
assert_ne!(retile_top_left, retile_bottom_right);
}
}
Loading
Loading