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

View File

@@ -1,34 +1,8 @@
use super::CanvasState;
use egui::{Response, Sense, Ui}; use egui::{Response, Sense, Ui};
pub struct CanvasInteraction; pub struct CanvasInteraction;
impl 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 { pub fn allocate_canvas(ui: &mut Ui) -> Response {
let available_size = ui.available_size(); let available_size = ui.available_size();
let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag()); 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 boolean::BooleanOpType;
pub use element::{DrawingElement, Shape, ShapeStyle}; pub use element::{DrawingElement, Shape, ShapeStyle};
pub use render::{ 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, screen_to_canvas,
}; };
pub use tool::{DragState, Tool}; 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( fn draw_path_fill(
painter: &Painter, painter: &Painter,
polygon: &PathPolygon, polygon: &PathPolygon,

View File

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

View File

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