added multiselection

This commit is contained in:
grimsace
2026-05-21 13:18:25 -05:00
parent bbe524c39a
commit d53e5297c7
5 changed files with 776 additions and 4 deletions
+26 -3
View File
@@ -9,7 +9,7 @@ use std::sync::mpsc;
use std::thread; use std::thread;
use crate::exporter; use crate::exporter;
use crate::interact::{ use crate::interact::{MultiDragState, MultiResizeState, SelectedItem, SelectionBoxDrag,
AddCorridorDrag, AddDoorDrag, DragState, HoverMarker, ResizeState, draw_grid, AddCorridorDrag, AddDoorDrag, DragState, HoverMarker, ResizeState, draw_grid,
}; };
use crate::layout::{ use crate::layout::{
@@ -57,6 +57,10 @@ pub struct DungeonApp {
pub hover_level_idx: Option<usize>, pub hover_level_idx: Option<usize>,
pub hover_stair_idx: Option<usize>, pub hover_stair_idx: Option<usize>,
pub rename_level_state: Option<(usize, String)>, pub rename_level_state: Option<(usize, String)>,
pub selection: Vec<SelectedItem>,
pub selection_box_drag: Option<SelectionBoxDrag>,
pub multi_drag_state: Option<MultiDragState>,
pub multi_resize_state: Option<MultiResizeState>,
} }
impl Default for DungeonApp { impl Default for DungeonApp {
@@ -84,6 +88,10 @@ impl Default for DungeonApp {
hover_level_idx: None, hover_level_idx: None,
hover_stair_idx: None, hover_stair_idx: None,
rename_level_state: None, rename_level_state: None,
selection: Vec::new(),
selection_box_drag: None,
multi_drag_state: None,
multi_resize_state: None,
}; };
if app.settings.composition_mode { if app.settings.composition_mode {
app.enter_composition_layout(); app.enter_composition_layout();
@@ -308,8 +316,13 @@ impl eframe::App for DungeonApp {
self.delete_level(level_idx); self.delete_level(level_idx);
} }
let resizing = crate::interact::handle_resize(self, ctx, &response, &geometry); let multi_consumed = crate::interact::handle_multi_select(self, ctx, &response, &geometry);
if !resizing { let resizing = if !multi_consumed {
crate::interact::handle_resize(self, ctx, &response, &geometry)
} else {
false
};
if !resizing && !multi_consumed {
crate::interact::handle_drag(self, ctx, &response, &geometry); crate::interact::handle_drag(self, ctx, &response, &geometry);
} }
crate::interact::handle_add_tool(self, &response, &geometry); crate::interact::handle_add_tool(self, &response, &geometry);
@@ -318,6 +331,10 @@ impl eframe::App for DungeonApp {
crate::interact::delete_at_pointer(self, pointer_pos, &geometry); crate::interact::delete_at_pointer(self, pointer_pos, &geometry);
} }
self.pending_delete = false; self.pending_delete = false;
self.selection.clear();
self.selection_box_drag = None;
self.multi_drag_state = None;
self.multi_resize_state = None;
} }
if self.settings.active_level_index < self.levels.len() { if self.settings.active_level_index < self.levels.len() {
@@ -373,6 +390,8 @@ impl eframe::App for DungeonApp {
crate::interact::draw_resize_overlay(self, &painter, &geometry); crate::interact::draw_resize_overlay(self, &painter, &geometry);
crate::interact::draw_corridor_hover_overlay(self, &painter, &geometry); crate::interact::draw_corridor_hover_overlay(self, &painter, &geometry);
crate::interact::draw_door_hover_overlay(self, &painter, &geometry); crate::interact::draw_door_hover_overlay(self, &painter, &geometry);
crate::interact::draw_selection_highlights(self, &painter, &geometry);
crate::interact::draw_selection_box(self, &painter, &geometry);
}); });
} }
} }
@@ -478,6 +497,10 @@ impl DungeonApp {
self.resize_state = None; self.resize_state = None;
self.clear_hover_targets(); self.clear_hover_targets();
self.pending_delete = false; self.pending_delete = false;
self.selection.clear();
self.selection_box_drag = None;
self.multi_drag_state = None;
self.multi_resize_state = None;
} }
// Captures the current app state for undo history. // Captures the current app state for undo history.
+3
View File
@@ -15,6 +15,9 @@ pub fn update_hover_targets(app: &mut DungeonApp, ctx: &egui::Context, geometry:
|| app.resize_state.is_some() || app.resize_state.is_some()
|| app.add_corridor_drag.is_some() || app.add_corridor_drag.is_some()
|| app.add_door_drag.is_some() || app.add_door_drag.is_some()
|| app.selection_box_drag.is_some()
|| app.multi_drag_state.is_some()
|| app.multi_resize_state.is_some()
{ {
return; return;
} }
+2
View File
@@ -11,6 +11,7 @@ pub mod add_delete;
pub mod drag; pub mod drag;
pub mod geometry; pub mod geometry;
pub mod hit_test; pub mod hit_test;
pub mod multi_select;
pub mod pathing; pub mod pathing;
pub mod resize; pub mod resize;
pub mod types; pub mod types;
@@ -19,6 +20,7 @@ pub use add_delete::*;
pub use drag::*; pub use drag::*;
pub use geometry::*; pub use geometry::*;
pub use hit_test::*; pub use hit_test::*;
pub use multi_select::*;
pub use pathing::*; pub use pathing::*;
pub use resize::*; pub use resize::*;
pub use types::*; pub use types::*;
+704
View File
@@ -0,0 +1,704 @@
/*
* Multi-selection interaction logic.
*
* Rubber-band selection: left-click drag on empty space draws a selection box.
* Multi-move: left-click drag when items are already selected (and pointer is not
* on an empty space) moves all selected items together.
* Multi-resize: right-click drag when items are selected resizes all of them together.
*/
use super::{
GridGeometry, MarkerKind, MultiDragState, MultiResizeState, SelectedItem, SelectionBoxDrag,
corridor_drag_at_pointer, marker_at_pointer, pointer_to_grid, room_at_pointer,
stair_at_pointer, text_at_pointer,
};
use crate::app::DungeonApp;
use crate::interact::reroute_corridors_for_room;
use crate::ui::AddTool;
use eframe::egui;
use egui::{Color32, Pos2, Rect, Stroke, pos2};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Returns true when the pointer is over any interactive element.
pub fn pointer_on_item(app: &DungeonApp, pos: Pos2, geometry: &GridGeometry) -> bool {
text_at_pointer(app, pos, geometry).is_some()
|| marker_at_pointer(app, pos, geometry).is_some()
|| stair_at_pointer(app, pos, geometry).is_some()
|| room_at_pointer(app, pos, geometry).is_some()
|| corridor_drag_at_pointer(app, pos, geometry).is_some()
}
/// Returns true when the pointer is over one of the currently selected items.
pub fn pointer_on_selected_item(app: &DungeonApp, pos: Pos2, geometry: &GridGeometry) -> bool {
if app.selection.is_empty() {
return false;
}
if app.settings.active_level_index >= app.levels.len() {
return false;
}
let Some((grid_x, grid_y)) = pointer_to_grid(pos, geometry) else {
return false;
};
let cell = (grid_x.floor() as usize, grid_y.floor() as usize);
let layout = &app.levels[app.settings.active_level_index];
for item in &app.selection {
match item {
SelectedItem::Room(idx) => {
if let Some(r) = layout.rooms.get(*idx) {
if cell.0 >= r.x
&& cell.0 < r.x + r.width
&& cell.1 >= r.y
&& cell.1 < r.y + r.height
{
return true;
}
}
}
SelectedItem::Text(idx) => {
if let Some(l) = layout.text_labels.get(*idx) {
if l.cell == cell {
return true;
}
}
}
SelectedItem::Staircase(idx) => {
if let Some(s) = layout.stairs.get(*idx) {
if cell.0 >= s.cell.0
&& cell.0 < s.cell.0 + s.width
&& cell.1 >= s.cell.1
&& cell.1 < s.cell.1 + s.height
{
return true;
}
}
}
SelectedItem::Marker(kind, idx) => {
let markers = match kind {
MarkerKind::Start => &layout.start_markers,
MarkerKind::End => &layout.end_markers,
MarkerKind::Trap => &layout.trap_markers,
MarkerKind::Monster => &layout.monster_markers,
};
if let Some(m) = markers.get(*idx) {
if cell.0 >= m.cell.0
&& cell.0 < m.cell.0 + m.size
&& cell.1 >= m.cell.1
&& cell.1 < m.cell.1 + m.size
{
return true;
}
}
}
}
}
false
}
/// Returns the top-left grid cell of a selected item.
fn item_origin(app: &DungeonApp, item: &SelectedItem) -> Option<(isize, isize)> {
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let layout = &app.levels[app.settings.active_level_index];
match item {
SelectedItem::Room(idx) => layout
.rooms
.get(*idx)
.map(|r| (r.x as isize, r.y as isize)),
SelectedItem::Text(idx) => layout
.text_labels
.get(*idx)
.map(|l| (l.cell.0 as isize, l.cell.1 as isize)),
SelectedItem::Staircase(idx) => layout
.stairs
.get(*idx)
.map(|s| (s.cell.0 as isize, s.cell.1 as isize)),
SelectedItem::Marker(kind, idx) => {
let markers = match kind {
MarkerKind::Start => &layout.start_markers,
MarkerKind::End => &layout.end_markers,
MarkerKind::Trap => &layout.trap_markers,
MarkerKind::Monster => &layout.monster_markers,
};
markers.get(*idx).map(|m| (m.cell.0 as isize, m.cell.1 as isize))
}
}
}
/// Returns (x, y, width, height) for resize origins. For text, width = font_size as isize, height = 0.
fn item_size_origin(app: &DungeonApp, item: &SelectedItem) -> Option<(isize, isize, isize, isize)> {
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let layout = &app.levels[app.settings.active_level_index];
match item {
SelectedItem::Room(idx) => layout.rooms.get(*idx).map(|r| {
(r.x as isize, r.y as isize, r.width as isize, r.height as isize)
}),
SelectedItem::Staircase(idx) => layout.stairs.get(*idx).map(|s| {
(s.cell.0 as isize, s.cell.1 as isize, s.width as isize, s.height as isize)
}),
SelectedItem::Text(idx) => layout.text_labels.get(*idx).map(|l| {
(l.cell.0 as isize, l.cell.1 as isize, l.font_size as isize, 0)
}),
SelectedItem::Marker(kind, idx) => {
let markers = match kind {
MarkerKind::Start => &layout.start_markers,
MarkerKind::End => &layout.end_markers,
MarkerKind::Trap => &layout.trap_markers,
MarkerKind::Monster => &layout.monster_markers,
};
markers.get(*idx).map(|m| {
(m.cell.0 as isize, m.cell.1 as isize, m.size as isize, m.size as isize)
})
}
}
}
// ---------------------------------------------------------------------------
// Selection box
// ---------------------------------------------------------------------------
/// Start a rubber-band selection drag.
pub fn start_selection_box(app: &mut DungeonApp, pos: Pos2, geometry: &GridGeometry) {
let Some((gx, gy)) = pointer_to_grid(pos, geometry) else { return };
app.selection.clear();
app.selection_box_drag = Some(SelectionBoxDrag {
start_cell: (gx, gy),
current_cell: (gx, gy),
});
}
/// Update the rubber-band selection rectangle and compute the new selection set.
pub fn update_selection_box(app: &mut DungeonApp, pos: Pos2, geometry: &GridGeometry) {
let Some((gx, gy)) = pointer_to_grid(pos, geometry) else { return };
if let Some(ref mut drag) = app.selection_box_drag {
drag.current_cell = (gx, gy);
} else {
return;
}
if app.settings.active_level_index >= app.levels.len() {
return;
}
let (sx, sy, cx, cy) = {
let drag = app.selection_box_drag.as_ref().unwrap();
(drag.start_cell.0, drag.start_cell.1, drag.current_cell.0, drag.current_cell.1)
};
let min_x = sx.min(cx).floor() as usize;
let min_y = sy.min(cy).floor() as usize;
let max_x = sx.max(cx).ceil() as usize;
let max_y = sy.max(cy).ceil() as usize;
let layout = &app.levels[app.settings.active_level_index];
let mut selection = Vec::new();
for (idx, room) in layout.rooms.iter().enumerate() {
// Room overlaps selection box if they intersect.
if room.x < max_x
&& room.x + room.width > min_x
&& room.y < max_y
&& room.y + room.height > min_y
{
selection.push(SelectedItem::Room(idx));
}
}
for (idx, label) in layout.text_labels.iter().enumerate() {
if label.cell.0 >= min_x
&& label.cell.0 < max_x
&& label.cell.1 >= min_y
&& label.cell.1 < max_y
{
selection.push(SelectedItem::Text(idx));
}
}
for (idx, stair) in layout.stairs.iter().enumerate() {
if stair.cell.0 < max_x
&& stair.cell.0 + stair.width > min_x
&& stair.cell.1 < max_y
&& stair.cell.1 + stair.height > min_y
{
selection.push(SelectedItem::Staircase(idx));
}
}
let add_markers = |markers: &Vec<crate::layout::AreaMarker>,
kind: MarkerKind,
selection: &mut Vec<SelectedItem>| {
for (idx, m) in markers.iter().enumerate() {
if m.cell.0 < max_x
&& m.cell.0 + m.size > min_x
&& m.cell.1 < max_y
&& m.cell.1 + m.size > min_y
{
selection.push(SelectedItem::Marker(kind, idx));
}
}
};
add_markers(&layout.start_markers.clone(), MarkerKind::Start, &mut selection);
add_markers(&layout.end_markers.clone(), MarkerKind::End, &mut selection);
add_markers(&layout.trap_markers.clone(), MarkerKind::Trap, &mut selection);
add_markers(&layout.monster_markers.clone(), MarkerKind::Monster, &mut selection);
app.selection = selection;
}
/// Finish the rubber-band drag (keep current selection).
pub fn finish_selection_box(app: &mut DungeonApp) {
app.selection_box_drag = None;
// If nothing ended up selected, clear to stay clean.
// (keep the selection so the user can immediately use it)
}
// ---------------------------------------------------------------------------
// Multi-drag (left button move)
// ---------------------------------------------------------------------------
/// Start a multi-item drag.
pub fn start_multi_drag(app: &mut DungeonApp, pos: Pos2, geometry: &GridGeometry) {
let Some((gx, gy)) = pointer_to_grid(pos, geometry) else { return };
let origins: Vec<(SelectedItem, (isize, isize))> = app
.selection
.iter()
.filter_map(|item| {
let origin = item_origin(app, item)?;
Some((item.clone(), origin))
})
.collect();
if origins.is_empty() {
return;
}
app.push_undo_snapshot();
app.multi_drag_state = Some(MultiDragState {
start_grid: (gx, gy),
origins,
});
}
/// Apply multi-item movement based on current pointer position.
pub fn update_multi_drag(app: &mut DungeonApp, pos: Pos2, geometry: &GridGeometry) {
let Some((gx, gy)) = pointer_to_grid(pos, geometry) else { return };
let Some(state) = app.multi_drag_state.clone() else { return };
let dx = (gx - state.start_grid.0).round() as isize;
let dy = (gy - state.start_grid.1).round() as isize;
if app.settings.active_level_index >= app.levels.len() {
return;
}
let cols = app.settings.cols as isize;
let rows = app.settings.rows as isize;
for (item, (ox, oy)) in &state.origins {
let new_x = (ox + dx).max(0);
let new_y = (oy + dy).max(0);
match item {
SelectedItem::Room(idx) => {
let layout = &mut app.levels[app.settings.active_level_index];
if let Some(room) = layout.rooms.get_mut(*idx) {
let max_x = (cols - room.width as isize).max(0);
let max_y = (rows - room.height as isize).max(0);
room.x = new_x.min(max_x) as usize;
room.y = new_y.min(max_y) as usize;
}
}
SelectedItem::Text(idx) => {
let layout = &mut app.levels[app.settings.active_level_index];
if let Some(label) = layout.text_labels.get_mut(*idx) {
label.cell = (
new_x.min(cols - 1).max(0) as usize,
new_y.min(rows - 1).max(0) as usize,
);
}
}
SelectedItem::Staircase(idx) => {
let layout = &mut app.levels[app.settings.active_level_index];
if let Some(stair) = layout.stairs.get_mut(*idx) {
let max_x = (cols - stair.width as isize).max(0);
let max_y = (rows - stair.height as isize).max(0);
stair.cell = (new_x.min(max_x) as usize, new_y.min(max_y) as usize);
}
}
SelectedItem::Marker(kind, idx) => {
let layout = &mut app.levels[app.settings.active_level_index];
let markers = match kind {
MarkerKind::Start => &mut layout.start_markers,
MarkerKind::End => &mut layout.end_markers,
MarkerKind::Trap => &mut layout.trap_markers,
MarkerKind::Monster => &mut layout.monster_markers,
};
if let Some(m) = markers.get_mut(*idx) {
let max_x = (cols - m.size as isize).max(0);
let max_y = (rows - m.size as isize).max(0);
m.cell = (new_x.min(max_x) as usize, new_y.min(max_y) as usize);
}
}
}
}
// Reroute corridors for all moved rooms.
let moved_rooms: Vec<usize> = state
.origins
.iter()
.filter_map(|(item, _)| {
if let SelectedItem::Room(idx) = item { Some(*idx) } else { None }
})
.collect();
for room_idx in moved_rooms {
reroute_corridors_for_room(app, room_idx);
}
app.refresh_doors();
}
/// Finish multi-drag.
pub fn finish_multi_drag(app: &mut DungeonApp) {
app.multi_drag_state = None;
}
// ---------------------------------------------------------------------------
// Multi-resize (right button)
// ---------------------------------------------------------------------------
/// Start a multi-item resize.
pub fn start_multi_resize(app: &mut DungeonApp, pos: Pos2, geometry: &GridGeometry) {
let Some((gx, gy)) = pointer_to_grid(pos, geometry) else { return };
let origins: Vec<(SelectedItem, (isize, isize, isize, isize))> = app
.selection
.iter()
.filter_map(|item| {
let origin = item_size_origin(app, item)?;
Some((item.clone(), origin))
})
.collect();
if origins.is_empty() {
return;
}
app.push_undo_snapshot();
app.multi_resize_state = Some(MultiResizeState {
start_grid: (gx, gy),
origins,
});
}
/// Apply multi-item resize based on current pointer position.
/// Scale factor is driven by the mouse delta: moving right/down grows items,
/// moving left/up shrinks them. The delta is applied as an integer cell offset
/// to width and height (rooms/stairs grow from bottom-right corner; text scales font).
pub fn update_multi_resize(app: &mut DungeonApp, pos: Pos2, geometry: &GridGeometry) {
let Some((gx, gy)) = pointer_to_grid(pos, geometry) else { return };
let Some(state) = app.multi_resize_state.clone() else { return };
let dx = (gx - state.start_grid.0).round() as isize;
let dy = (gy - state.start_grid.1).round() as isize;
// Use the larger of dx/dy as a single resize delta so all items scale uniformly.
let delta = if dx.abs() >= dy.abs() { dx } else { dy };
if app.settings.active_level_index >= app.levels.len() {
return;
}
let cols = app.settings.cols as isize;
let rows = app.settings.rows as isize;
for (item, (ox, oy, ow, oh)) in &state.origins {
match item {
SelectedItem::Room(idx) => {
let new_w = (*ow + delta).max(1);
let new_h = (*oh + delta).max(1);
let layout = &mut app.levels[app.settings.active_level_index];
if let Some(room) = layout.rooms.get_mut(*idx) {
if ox + new_w <= cols && oy + new_h <= rows {
room.width = new_w as usize;
room.height = new_h as usize;
}
}
}
SelectedItem::Staircase(idx) => {
let new_w = (*ow + delta).max(1);
let new_h = (*oh + delta).max(1);
let layout = &mut app.levels[app.settings.active_level_index];
if let Some(stair) = layout.stairs.get_mut(*idx) {
if ox + new_w <= cols && oy + new_h <= rows {
stair.width = new_w as usize;
stair.height = new_h as usize;
}
}
}
SelectedItem::Text(idx) => {
// ow stores the original font_size for text labels.
let new_size = (*ow + delta * 4).clamp(8, 96) as u16;
let layout = &mut app.levels[app.settings.active_level_index];
if let Some(label) = layout.text_labels.get_mut(*idx) {
label.font_size = new_size;
}
}
SelectedItem::Marker(kind, idx) => {
let new_size = (*ow + delta).max(1) as usize;
let layout = &mut app.levels[app.settings.active_level_index];
let markers = match kind {
MarkerKind::Start => &mut layout.start_markers,
MarkerKind::End => &mut layout.end_markers,
MarkerKind::Trap => &mut layout.trap_markers,
MarkerKind::Monster => &mut layout.monster_markers,
};
if let Some(m) = markers.get_mut(*idx) {
let max_x = (cols - new_size as isize).max(0);
let max_y = (rows - new_size as isize).max(0);
if *ox <= max_x && *oy <= max_y {
m.size = new_size;
}
}
}
}
}
// Reroute corridors for resized rooms.
let resized_rooms: Vec<usize> = state
.origins
.iter()
.filter_map(|(item, _)| {
if let SelectedItem::Room(idx) = item { Some(*idx) } else { None }
})
.collect();
for room_idx in resized_rooms {
reroute_corridors_for_room(app, room_idx);
}
app.refresh_doors();
}
/// Finish multi-resize.
pub fn finish_multi_resize(app: &mut DungeonApp) {
app.multi_resize_state = None;
}
// ---------------------------------------------------------------------------
// Master handler (called from app.rs update loop)
// ---------------------------------------------------------------------------
/// Central handler for all multi-selection interactions.
/// Returns true if it consumed the input so the single-item handlers are skipped.
pub fn handle_multi_select(
app: &mut DungeonApp,
ctx: &egui::Context,
response: &egui::Response,
geometry: &GridGeometry,
) -> bool {
if app.settings.add_tool != AddTool::None {
return false;
}
// --- Right click: multi-resize ---
if ctx.input(|i| i.pointer.secondary_pressed())
&& !app.selection.is_empty()
&& app.multi_resize_state.is_none()
{
if let Some(pos) = response.interact_pointer_pos() {
start_multi_resize(app, pos, geometry);
return true;
}
}
if app.multi_resize_state.is_some() {
if response.dragged_by(egui::PointerButton::Secondary)
&& ctx.input(|i| i.pointer.secondary_down())
{
if let Some(pos) = response.interact_pointer_pos() {
update_multi_resize(app, pos, geometry);
}
}
if ctx.input(|i| i.pointer.button_released(egui::PointerButton::Secondary)) {
finish_multi_resize(app);
}
return true;
}
// --- Left click on empty space: rubber-band select ---
if response.drag_started()
&& ctx.input(|i| i.pointer.primary_down())
&& app.multi_drag_state.is_none()
&& app.selection_box_drag.is_none()
{
if let Some(pos) = response.interact_pointer_pos() {
let on_item = pointer_on_item(app, pos, geometry);
let on_selected = pointer_on_selected_item(app, pos, geometry);
if on_selected && !app.selection.is_empty() {
// Start moving the selection.
start_multi_drag(app, pos, geometry);
return true;
} else if !on_item {
// Start rubber-band selection.
start_selection_box(app, pos, geometry);
return true;
}
// on an unselected item — fall through to single-item handlers.
}
}
if app.selection_box_drag.is_some() {
if response.dragged() {
if let Some(pos) = response.interact_pointer_pos() {
update_selection_box(app, pos, geometry);
}
}
if response.drag_stopped() || !ctx.input(|i| i.pointer.primary_down()) {
finish_selection_box(app);
}
return true;
}
if app.multi_drag_state.is_some() {
if response.dragged() && ctx.input(|i| i.pointer.primary_down()) {
if let Some(pos) = response.interact_pointer_pos() {
update_multi_drag(app, pos, geometry);
}
}
if response.drag_stopped() || !ctx.input(|i| i.pointer.primary_down()) {
finish_multi_drag(app);
}
return true;
}
// Clear selection on plain click on empty space.
if response.clicked() {
if let Some(pos) = response.interact_pointer_pos() {
if !pointer_on_item(app, pos, geometry) {
app.selection.clear();
}
}
}
false
}
// ---------------------------------------------------------------------------
// Drawing
// ---------------------------------------------------------------------------
/// Draws the rubber-band selection box while dragging.
pub fn draw_selection_box(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) {
let Some(drag) = &app.selection_box_drag else { return };
let to_screen = |gx: f32, gy: f32| -> Pos2 {
pos2(
geometry.rect.left() + gx * geometry.cell_size,
geometry.rect.top() + gy * geometry.cell_size,
)
};
let tl = to_screen(
drag.start_cell.0.min(drag.current_cell.0),
drag.start_cell.1.min(drag.current_cell.1),
);
let br = to_screen(
drag.start_cell.0.max(drag.current_cell.0),
drag.start_cell.1.max(drag.current_cell.1),
);
let rect = Rect::from_min_max(tl, br);
painter.rect_filled(rect, 0.0, Color32::from_rgba_unmultiplied(100, 160, 255, 30));
painter.rect_stroke(
rect,
0.0,
Stroke::new(1.5, Color32::from_rgba_unmultiplied(100, 160, 255, 200)),
egui::StrokeKind::Middle,
);
}
/// Draws highlight outlines around all selected items.
pub fn draw_selection_highlights(
app: &DungeonApp,
painter: &egui::Painter,
geometry: &GridGeometry,
) {
if app.selection.is_empty() {
return;
}
if app.settings.active_level_index >= app.levels.len() {
return;
}
let layout = &app.levels[app.settings.active_level_index];
let color = Color32::from_rgba_unmultiplied(100, 180, 255, 220);
let stroke = Stroke::new(2.0, color);
for item in &app.selection {
match item {
SelectedItem::Room(idx) => {
if let Some(r) = layout.rooms.get(*idx) {
let left = geometry.rect.left() + r.x as f32 * geometry.cell_size;
let top = geometry.rect.top() + r.y as f32 * geometry.cell_size;
let rect = Rect::from_min_size(
pos2(left, top),
egui::vec2(
r.width as f32 * geometry.cell_size,
r.height as f32 * geometry.cell_size,
),
);
painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Middle);
}
}
SelectedItem::Staircase(idx) => {
if let Some(s) = layout.stairs.get(*idx) {
let left = geometry.rect.left() + s.cell.0 as f32 * geometry.cell_size;
let top = geometry.rect.top() + s.cell.1 as f32 * geometry.cell_size;
let rect = Rect::from_min_size(
pos2(left, top),
egui::vec2(
s.width as f32 * geometry.cell_size,
s.height as f32 * geometry.cell_size,
),
);
painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Middle);
}
}
SelectedItem::Text(idx) => {
if let Some(l) = layout.text_labels.get(*idx) {
let center = pos2(
geometry.rect.left() + (l.cell.0 as f32 + 0.5) * geometry.cell_size,
geometry.rect.top() + (l.cell.1 as f32 + 0.5) * geometry.cell_size,
);
let half = geometry.cell_size * 0.5;
let rect = Rect::from_center_size(center, egui::vec2(half * 2.0, half * 2.0));
painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Middle);
}
}
SelectedItem::Marker(kind, idx) => {
let markers = match kind {
MarkerKind::Start => &layout.start_markers,
MarkerKind::End => &layout.end_markers,
MarkerKind::Trap => &layout.trap_markers,
MarkerKind::Monster => &layout.monster_markers,
};
if let Some(m) = markers.get(*idx) {
let left = geometry.rect.left() + m.cell.0 as f32 * geometry.cell_size;
let top = geometry.rect.top() + m.cell.1 as f32 * geometry.cell_size;
let rect = Rect::from_min_size(
pos2(left, top),
egui::vec2(
m.size as f32 * geometry.cell_size,
m.size as f32 * geometry.cell_size,
),
);
painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Middle);
}
}
}
}
}
+41 -1
View File
@@ -53,7 +53,7 @@ pub enum ResizeCorner {
BottomRight, BottomRight,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MarkerKind { pub enum MarkerKind {
Start, Start,
End, End,
@@ -116,3 +116,43 @@ pub enum ResizeState {
Text(TextResizeState), Text(TextResizeState),
Staircase(StaircaseResizeState), Staircase(StaircaseResizeState),
} }
// ---- Multi-selection types ----
/// Identifies a single selectable item in the dungeon layout.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SelectedItem {
Room(usize),
Text(usize),
Staircase(usize),
Marker(MarkerKind, usize),
}
/// State while the user is rubber-band selecting (left-click drag on empty space).
#[derive(Debug, Clone)]
pub struct SelectionBoxDrag {
/// Grid-space start corner of the selection rectangle.
pub start_cell: (f32, f32),
/// Grid-space current corner of the selection rectangle (tracks the pointer).
pub current_cell: (f32, f32),
}
/// State while dragging a multi-selection with the left mouse button.
#[derive(Debug, Clone)]
pub struct MultiDragState {
/// Grid-space position when the drag started (used to compute delta).
pub start_grid: (f32, f32),
/// Snapshot of each selected item's original position at drag start.
pub origins: Vec<(SelectedItem, (isize, isize))>,
}
/// State while resizing a multi-selection with the right mouse button.
#[derive(Debug, Clone)]
pub struct MultiResizeState {
/// Grid-space position when the resize started.
pub start_grid: (f32, f32),
/// Snapshot of each selected item's original size at resize start.
/// For rooms/staircases: (x, y, width, height).
/// For text: (cell_x, cell_y, font_size as width, 0).
pub origins: Vec<(SelectedItem, (isize, isize, isize, isize))>,
}