added multiselection
This commit is contained in:
+26
-3
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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
@@ -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))>,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user