feat: add marquee multi-select and always-on middle-button pan

- Drag on empty space in Select tool draws a marquee rectangle;
  elements intersecting it on release become selected
- Shift+click toggles elements in/out of the selection set
- Moving and deleting operate on all selected elements at once
- Middle mouse button drag now pans unconditionally in every tool mode
- Session selection state changed from Option<String> to Vec<String>
- New DragState::MarqueeSelecting variant with dashed rect rendering
- 29 tests passing, clippy clean
This commit is contained in:
David Ibia
2026-02-10 01:08:26 +01:00
parent 1929023409
commit 9b8acd4002
6 changed files with 235 additions and 107 deletions

View File

@@ -3,7 +3,7 @@ use crate::canvas::{CanvasInteraction, CanvasState};
use crate::clipboard::ClipboardManager;
use crate::command_palette::{CommandId, CommandPalette};
use crate::drawing::{
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
};
use crate::history::{ChangeSource, HistoryTree, NodeId};
@@ -347,9 +347,7 @@ impl AgCanvasApp {
DrawingCommand::Delete { session_id, id } => {
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
session.drawing_elements.retain(|e| e.id != id);
if session.selected_element_id.as_deref() == Some(&id) {
session.selected_element_id = None;
}
session.selected_element_ids.retain(|selected_id| selected_id != &id);
session.record_edit(
"Agent: Delete Element",
ChangeSource::Agent { name: None },
@@ -359,7 +357,7 @@ impl AgCanvasApp {
DrawingCommand::Clear { session_id } => {
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
session.drawing_elements.clear();
session.selected_element_id = None;
session.selected_element_ids.clear();
session
.record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None });
}
@@ -606,21 +604,28 @@ fn handle_select_tool(
zoom: f32,
pointer_pos: Option<egui::Pos2>,
) {
if response.drag_started() {
if response.drag_started_by(egui::PointerButton::Primary) {
if let Some(screen_pos) = pointer_pos {
if let Some(selected_el) = session.selected_element() {
if let Some(handle) =
find_handle_at_screen_pos(selected_el, screen_pos, canvas_center, offset, zoom)
{
let original_rect = selected_el.bounding_rect();
let eid = selected_el.id.clone();
session.drag_state = DragState::Resizing {
handle,
element_id: eid,
original_rect,
};
return;
}
let shift_held = response.ctx.input(|i| i.modifiers.shift);
if let Some((handle, selected_el)) = session
.drawing_elements
.iter()
.rev()
.filter(|el| session.selected_element_ids.contains(&el.id))
.find_map(|el| {
find_handle_at_screen_pos(el, screen_pos, canvas_center, offset, zoom)
.map(|handle| (handle, el))
})
{
let original_rect = selected_el.bounding_rect();
let eid = selected_el.id.clone();
session.drag_state = DragState::Resizing {
handle,
element_id: eid,
original_rect,
};
return;
}
let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom);
@@ -632,24 +637,48 @@ fn handle_select_tool(
if let Some(el) = hit {
let eid = el.id.clone();
session.selected_element_id = Some(eid.clone());
session.drag_state = DragState::Moving { element_id: eid };
if shift_held {
if session.selected_element_ids.contains(&eid) {
session.selected_element_ids.retain(|id| id != &eid);
} else {
session.selected_element_ids.push(eid);
}
session.drag_state = DragState::None;
} else {
if !session.selected_element_ids.contains(&eid) {
session.selected_element_ids = vec![eid.clone()];
}
session.drag_state = DragState::Moving {
element_ids: session.selected_element_ids.clone(),
};
}
} else {
session.selected_element_id = None;
session.drag_state = DragState::None;
if !shift_held {
session.selected_element_ids.clear();
}
session.drag_state = DragState::MarqueeSelecting {
start: canvas_pos,
current: canvas_pos,
};
}
}
}
if response.dragged() {
if response.dragged_by(egui::PointerButton::Primary) {
let delta_screen = response.drag_delta();
let delta_canvas = delta_screen / zoom;
match &session.drag_state {
DragState::Moving { element_id, .. } => {
let eid = element_id.clone();
if let Some(el) = session.drawing_elements.iter_mut().find(|e| e.id == eid) {
el.translate(delta_canvas);
match &mut session.drag_state {
DragState::Moving { element_ids } => {
for eid in element_ids.iter() {
if let Some(el) = session.drawing_elements.iter_mut().find(|e| e.id == *eid) {
el.translate(delta_canvas);
}
}
}
DragState::MarqueeSelecting { current, .. } => {
if let Some(screen_pos) = pointer_pos {
*current = screen_to_canvas(screen_pos, canvas_center, offset, zoom);
}
}
DragState::Resizing {
@@ -672,25 +701,46 @@ fn handle_select_tool(
}
}
}
_ => {
DragState::None => {
session.canvas_state.pan(delta_screen);
}
_ => {}
}
}
if response.drag_stopped() {
if response.drag_stopped_by(egui::PointerButton::Primary) {
match &session.drag_state {
DragState::Moving { .. } => session.record_edit("Move Element", ChangeSource::Human),
DragState::Resizing { .. } => {
session.record_edit("Resize Element", ChangeSource::Human)
}
DragState::MarqueeSelecting { start, current } => {
let marquee = egui::Rect::from_two_pos(*start, *current);
let marquee_hits: Vec<String> = session
.drawing_elements
.iter()
.filter(|e| e.bounding_rect().intersects(marquee))
.map(|e| e.id.clone())
.collect();
if response.ctx.input(|i| i.modifiers.shift) {
for id in marquee_hits {
if !session.selected_element_ids.contains(&id) {
session.selected_element_ids.push(id);
}
}
} else {
session.selected_element_ids = marquee_hits;
}
}
_ => {}
}
session.drag_state = DragState::None;
}
if response.clicked() && !response.dragged() {
if response.clicked_by(egui::PointerButton::Primary) && !response.dragged() {
if let Some(screen_pos) = pointer_pos {
let shift_held = response.ctx.input(|i| i.modifiers.shift);
let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom);
let hit = session
.drawing_elements
@@ -698,7 +748,19 @@ fn handle_select_tool(
.rev()
.find(|e| e.contains_point(canvas_pos));
session.selected_element_id = hit.map(|e| e.id.clone());
if shift_held {
if let Some(el) = hit {
if session.selected_element_ids.contains(&el.id) {
session.selected_element_ids.retain(|id| id != &el.id);
} else {
session.selected_element_ids.push(el.id.clone());
}
}
} else if let Some(el) = hit {
session.selected_element_ids = vec![el.id.clone()];
} else {
session.selected_element_ids.clear();
}
}
}
}
@@ -711,7 +773,7 @@ fn handle_shape_tool(
zoom: f32,
pointer_pos: Option<egui::Pos2>,
) {
if response.drag_started() {
if response.drag_started_by(egui::PointerButton::Primary) {
if let Some(screen_pos) = pointer_pos {
let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom);
session.drag_state = DragState::Creating {
@@ -721,7 +783,7 @@ fn handle_shape_tool(
}
}
if response.dragged() {
if response.dragged_by(egui::PointerButton::Primary) {
if let DragState::Creating { current, .. } = &mut session.drag_state {
if let Some(screen_pos) = pointer_pos {
*current = screen_to_canvas(screen_pos, canvas_center, offset, zoom);
@@ -729,7 +791,7 @@ fn handle_shape_tool(
}
}
if response.drag_stopped() {
if response.drag_stopped_by(egui::PointerButton::Primary) {
if let DragState::Creating { start, current } = &session.drag_state {
let start = *start;
let current = *current;
@@ -768,7 +830,7 @@ fn handle_shape_tool(
};
let element = DrawingElement::new(shape, ShapeStyle::default());
session.selected_element_id = Some(element.id.clone());
session.selected_element_ids = vec![element.id.clone()];
session.drawing_elements.push(element);
session.record_edit(&format!("Draw {}", tool_label), ChangeSource::Human);
}
@@ -868,7 +930,11 @@ impl eframe::App for AgCanvasApp {
if toggle_history {
self.show_history_panel = !self.show_history_panel;
}
if delete_selected && !self.show_text_input && !self.show_mermaid_dialog {
if delete_selected
&& !self.show_text_input
&& !self.show_mermaid_dialog
&& !self.active_session().selected_element_ids.is_empty()
{
self.active_session_mut().delete_selected();
self.active_session_mut()
.record_edit("Delete Element", ChangeSource::Human);
@@ -1335,7 +1401,7 @@ impl eframe::App for AgCanvasApp {
self.active_session_mut().drawing_elements.push(element);
self.active_session_mut()
.record_edit("Draw Text", ChangeSource::Human);
self.active_session_mut().selected_element_id = Some(eid);
self.active_session_mut().selected_element_ids = vec![eid];
}
self.show_text_input = false;
self.text_input_buffer.clear();
@@ -1353,35 +1419,29 @@ impl eframe::App for AgCanvasApp {
let response = CanvasInteraction::allocate_canvas(ui);
let canvas_center = response.rect.center();
let active_tool = self.active_session().active_tool;
let is_select_idle = active_tool == Tool::Select
&& matches!(self.active_session().drag_state, DragState::None);
if is_select_idle {
CanvasInteraction::handle(
ui,
&response,
&mut self.active_session_mut().canvas_state,
);
} else if active_tool != Tool::Select {
if response.hovered() {
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
if scroll_delta != 0.0 {
let zoom_factor = 1.0 + scroll_delta * 0.001;
if let Some(pointer_pos) = response.hover_pos() {
self.active_session_mut().canvas_state.zoom_at(
pointer_pos,
canvas_center,
zoom_factor,
);
}
if response.dragged_by(egui::PointerButton::Middle) {
self.active_session_mut().canvas_state.pan(response.drag_delta());
}
if response.hovered() {
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
if scroll_delta != 0.0 {
let zoom_factor = 1.0 + scroll_delta * 0.001;
if let Some(pointer_pos) = response.hover_pos() {
self.active_session_mut().canvas_state.zoom_at(
pointer_pos,
canvas_center,
zoom_factor,
);
}
}
if response.dragged_by(egui::PointerButton::Middle) {
self.active_session_mut()
.canvas_state
.pan(response.drag_delta());
}
}
let is_idle = matches!(self.active_session().drag_state, DragState::None);
if is_idle
&& response.dragged_by(egui::PointerButton::Primary)
&& ui.input(|i| i.modifiers.command)
{
self.active_session_mut().canvas_state.pan(response.drag_delta());
}
if !self.show_mermaid_dialog && !self.show_text_input {
@@ -1424,10 +1484,15 @@ impl eframe::App for AgCanvasApp {
zoom,
);
if let Some(selected_el) = self.active_session().selected_element() {
for selected_el in self.active_session().selected_elements() {
draw_selection(&painter, selected_el, canvas_center, offset, zoom);
}
if let DragState::MarqueeSelecting { start, current } = &self.active_session().drag_state
{
draw_marquee(&painter, *start, *current, canvas_center, offset, zoom);
}
let drag_state = self.active_session().drag_state.clone();
draw_creation_preview(
&painter,

View File

@@ -1,34 +1,8 @@
use super::CanvasState;
use egui::{Response, Sense, Ui};
pub struct CanvasInteraction;
impl CanvasInteraction {
pub fn handle(ui: &Ui, response: &Response, state: &mut CanvasState) {
if response.dragged_by(egui::PointerButton::Middle)
|| (response.dragged_by(egui::PointerButton::Primary)
&& ui.input(|i| i.modifiers.command))
{
state.pan(response.drag_delta());
}
if response.hovered() {
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
if scroll_delta != 0.0 {
let zoom_factor = 1.0 + scroll_delta * 0.001;
if let Some(pointer_pos) = response.hover_pos() {
state.zoom_at(pointer_pos, response.rect.center(), zoom_factor);
}
}
ui.input(|i| {
if i.key_pressed(egui::Key::Num0) && i.modifiers.command {
state.reset();
}
});
}
}
pub fn allocate_canvas(ui: &mut Ui) -> Response {
let available_size = ui.available_size();
let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());

View File

@@ -6,7 +6,7 @@ mod tool;
pub use boolean::BooleanOpType;
pub use element::{DrawingElement, Shape, ShapeStyle};
pub use render::{
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos,
screen_to_canvas,
};
pub use tool::{DragState, Tool};

View File

@@ -134,6 +134,80 @@ pub fn draw_selection(
}
}
pub fn draw_marquee(
painter: &Painter,
start: Pos2,
current: Pos2,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
let screen_start = canvas_to_screen(start, canvas_center, offset, zoom);
let screen_current = canvas_to_screen(current, canvas_center, offset, zoom);
let rect = egui::Rect::from_two_pos(screen_start, screen_current);
let fill = Color32::from_rgba_premultiplied(
SELECTION_COLOR.r(),
SELECTION_COLOR.g(),
SELECTION_COLOR.b(),
36,
);
painter.rect_filled(rect, 0.0, fill);
draw_dashed_rect(painter, rect, Stroke::new(1.5, SELECTION_COLOR), 8.0, 4.0);
}
fn draw_dashed_rect(painter: &Painter, rect: egui::Rect, stroke: Stroke, dash: f32, gap: f32) {
draw_dashed_line(
painter,
rect.left_top(),
rect.right_top(),
stroke,
dash,
gap,
);
draw_dashed_line(
painter,
rect.right_top(),
rect.right_bottom(),
stroke,
dash,
gap,
);
draw_dashed_line(
painter,
rect.right_bottom(),
rect.left_bottom(),
stroke,
dash,
gap,
);
draw_dashed_line(
painter,
rect.left_bottom(),
rect.left_top(),
stroke,
dash,
gap,
);
}
fn draw_dashed_line(painter: &Painter, from: Pos2, to: Pos2, stroke: Stroke, dash: f32, gap: f32) {
let dir = to - from;
let len = dir.length();
if len <= 0.0 {
return;
}
let dir_norm = dir / len;
let mut covered = 0.0;
while covered < len {
let seg_start = from + dir_norm * covered;
let seg_end = from + dir_norm * (covered + dash).min(len);
painter.line_segment([seg_start, seg_end], stroke);
covered += dash + gap;
}
}
fn draw_path_fill(
painter: &Painter,
polygon: &PathPolygon,

View File

@@ -45,7 +45,11 @@ pub enum DragState {
current: Pos2,
},
Moving {
element_id: String,
element_ids: Vec<String>,
},
MarqueeSelecting {
start: Pos2,
current: Pos2,
},
Resizing {
handle: ResizeHandle,

View File

@@ -88,7 +88,7 @@ pub struct Session {
pub description_text: String,
pub drawing_elements: Vec<DrawingElement>,
pub selected_element_id: Option<String>,
pub selected_element_ids: Vec<String>,
pub active_tool: Tool,
pub drag_state: DragState,
pub history: HistoryTree,
@@ -110,7 +110,7 @@ impl Session {
svg_source: None,
description_text: String::new(),
drawing_elements: Vec::new(),
selected_element_id: None,
selected_element_ids: Vec::new(),
active_tool: Tool::default(),
drag_state: DragState::default(),
history: HistoryTree::new(DocumentSnapshot::new_empty()),
@@ -145,26 +145,37 @@ impl Session {
self.svg_source = None;
self.description_text.clear();
self.drawing_elements.clear();
self.selected_element_id = None;
self.selected_element_ids.clear();
self.drag_state = DragState::default();
self.canvas_state.reset();
}
#[allow(dead_code)]
pub fn selected_element(&self) -> Option<&DrawingElement> {
self.selected_element_id
.as_ref()
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
self.selected_element_ids
.iter()
.find_map(|id| self.drawing_elements.iter().find(|e| e.id == *id))
}
pub fn selected_elements(&self) -> Vec<&DrawingElement> {
self.selected_element_ids
.iter()
.filter_map(|id| self.drawing_elements.iter().find(|e| e.id == *id))
.collect()
}
#[allow(dead_code)]
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
let id = self.selected_element_id.clone();
let id = self.selected_element_ids.first().cloned();
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
}
pub fn delete_selected(&mut self) {
if let Some(id) = self.selected_element_id.take() {
self.drawing_elements.retain(|e| e.id != id);
if !self.selected_element_ids.is_empty() {
let selected_ids = self.selected_element_ids.clone();
self.drawing_elements
.retain(|e| !selected_ids.contains(&e.id));
self.selected_element_ids.clear();
}
}
@@ -182,7 +193,7 @@ impl Session {
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.selected_element_id = None;
self.selected_element_ids.clear();
self.drag_state = DragState::default();
}
@@ -194,7 +205,7 @@ impl Session {
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.selected_element_id = None;
self.selected_element_ids.clear();
self.drag_state = DragState::default();
true
} else {
@@ -210,7 +221,7 @@ impl Session {
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.selected_element_id = None;
self.selected_element_ids.clear();
self.drag_state = DragState::default();
true
} else {