From 9b8acd40025a3380a87cd4825b27b4e1fe63a9a2 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Tue, 10 Feb 2026 01:08:26 +0100 Subject: [PATCH] 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 to Vec - New DragState::MarqueeSelecting variant with dashed rect rendering - 29 tests passing, clippy clean --- crates/agcanvas/src/app.rs | 199 ++++++++++++++-------- crates/agcanvas/src/canvas/interaction.rs | 26 --- crates/agcanvas/src/drawing/mod.rs | 2 +- crates/agcanvas/src/drawing/render.rs | 74 ++++++++ crates/agcanvas/src/drawing/tool.rs | 6 +- crates/agcanvas/src/session.rs | 35 ++-- 6 files changed, 235 insertions(+), 107 deletions(-) diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs index f898757..c04232b 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -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, ) { - 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 = 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, ) { - 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, diff --git a/crates/agcanvas/src/canvas/interaction.rs b/crates/agcanvas/src/canvas/interaction.rs index 8607b09..b887bd4 100644 --- a/crates/agcanvas/src/canvas/interaction.rs +++ b/crates/agcanvas/src/canvas/interaction.rs @@ -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()); diff --git a/crates/agcanvas/src/drawing/mod.rs b/crates/agcanvas/src/drawing/mod.rs index 8966917..db2ac9c 100644 --- a/crates/agcanvas/src/drawing/mod.rs +++ b/crates/agcanvas/src/drawing/mod.rs @@ -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}; diff --git a/crates/agcanvas/src/drawing/render.rs b/crates/agcanvas/src/drawing/render.rs index 902c429..79fbcdc 100644 --- a/crates/agcanvas/src/drawing/render.rs +++ b/crates/agcanvas/src/drawing/render.rs @@ -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, diff --git a/crates/agcanvas/src/drawing/tool.rs b/crates/agcanvas/src/drawing/tool.rs index 0586997..a8d7f55 100644 --- a/crates/agcanvas/src/drawing/tool.rs +++ b/crates/agcanvas/src/drawing/tool.rs @@ -45,7 +45,11 @@ pub enum DragState { current: Pos2, }, Moving { - element_id: String, + element_ids: Vec, + }, + MarqueeSelecting { + start: Pos2, + current: Pos2, }, Resizing { handle: ResizeHandle, diff --git a/crates/agcanvas/src/session.rs b/crates/agcanvas/src/session.rs index 65a602e..16d36a1 100644 --- a/crates/agcanvas/src/session.rs +++ b/crates/agcanvas/src/session.rs @@ -88,7 +88,7 @@ pub struct Session { pub description_text: String, pub drawing_elements: Vec, - pub selected_element_id: Option, + pub selected_element_ids: Vec, 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 {