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
98 changes: 72 additions & 26 deletions crates/app/src/ui/canvas/board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
};
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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(),
Expand All @@ -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;
}
Expand All @@ -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)
Expand Down Expand Up @@ -603,50 +609,90 @@ 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::<Vec<_>>();
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],
}));
}
}

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::<Vec<_>>();
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();
}

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::<Vec<_>>();
if !actions.is_empty() {
app.execute_action(Action::Composite(actions));
}
}

Expand Down
94 changes: 94 additions & 0 deletions crates/app/src/ui/canvas/board_marquee.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>();
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
}
8 changes: 6 additions & 2 deletions crates/app/src/ui/canvas/board_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions crates/app/src/ui/canvas/interactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(_)) {
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down
40 changes: 21 additions & 19 deletions crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,6 +30,7 @@ const SNAP_PX: f32 = 6.0;

mod authoring;
mod board;
mod board_marquee;
mod board_notes;
mod chrome;
mod cursors;
Expand Down Expand Up @@ -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());
Expand All @@ -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);

Expand Down Expand Up @@ -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,
};
Expand Down
Loading
Loading