558 lines
16 KiB
GDScript
558 lines
16 KiB
GDScript
@tool
|
|
extends Control
|
|
class_name DashboardGrid
|
|
|
|
|
|
signal module_placed(module: Control, col: int, row: int)
|
|
signal module_removed(module: Control)
|
|
|
|
@export var cell_min_size: Vector2 = Vector2(300, 240)
|
|
@export var cell_spacing: float = 8.0
|
|
@export var margin: float = 16.0
|
|
|
|
var columns: int = 0
|
|
var rows: int = 0
|
|
|
|
var _cells: Array = [] # 2D: _cells[row][col] -> PanelContainer
|
|
var _module_positions: Dictionary = {} # "col,row" -> Control
|
|
|
|
# Drag state
|
|
var _is_dragging: bool = false
|
|
var _drag_source_col: int = -1
|
|
var _drag_source_row: int = -1
|
|
var _drag_module: Control = null
|
|
var _drag_mouse_offset: Vector2 = Vector2.ZERO
|
|
var _hover_col: int = -1
|
|
var _hover_row: int = -1
|
|
|
|
# Visible cell styles cached per-cell so we can modify them during drag
|
|
var _cell_styles: Dictionary = {} # "col,row" -> StyleBoxFlat
|
|
|
|
|
|
func _ready() -> void:
|
|
mouse_filter = Control.MOUSE_FILTER_STOP
|
|
resized.connect(_rebuild_grid)
|
|
gui_input.connect(_on_gui_input)
|
|
_rebuild_grid()
|
|
|
|
|
|
func _on_gui_input(event: InputEvent) -> void:
|
|
if event is InputEventMouseButton:
|
|
var mb: InputEventMouseButton = event
|
|
if mb.button_index == MOUSE_BUTTON_LEFT and not mb.double_click:
|
|
if mb.pressed:
|
|
_begin_drag(mb.position)
|
|
elif _is_dragging:
|
|
_end_drag(mb.position)
|
|
elif event is InputEventMouseMotion and _is_dragging:
|
|
_update_drag(event.position)
|
|
|
|
|
|
func place_module(module: Control, col: int, row: int) -> void:
|
|
if not _is_valid_cell(col, row):
|
|
return
|
|
_place_module_in_cell(module, col, row)
|
|
module_placed.emit(module, col, row)
|
|
|
|
|
|
func remove_module(col: int, row: int) -> void:
|
|
if not _is_valid_cell(col, row):
|
|
return
|
|
var module := _take_module_from_cell(col, row)
|
|
if module != null:
|
|
module_removed.emit(module)
|
|
module.queue_free()
|
|
|
|
|
|
func get_grid_size() -> Vector2i:
|
|
return Vector2i(columns, rows)
|
|
|
|
|
|
func get_cell_rect(col: int, row: int) -> Rect2:
|
|
if not _is_valid_cell(col, row):
|
|
return Rect2()
|
|
var cell: PanelContainer = _cells[row][col]
|
|
return Rect2(cell.position, cell.size)
|
|
|
|
|
|
func _is_valid_cell(col: int, row: int) -> bool:
|
|
return row >= 0 and row < _cells.size() and col >= 0 and col < _cells[row].size()
|
|
|
|
|
|
func _cell_at_position(pos: Vector2) -> Vector2i:
|
|
if columns == 0 or rows == 0:
|
|
return Vector2i(-1, -1)
|
|
|
|
var inner_x := margin + cell_spacing
|
|
var inner_y := margin + cell_spacing
|
|
var inner_w := size.x - margin * 2.0 - cell_spacing
|
|
var inner_h := size.y - margin * 2.0 - cell_spacing
|
|
var gap_x := cell_spacing * (columns - 1)
|
|
var gap_y := cell_spacing * (rows - 1)
|
|
var cell_w := (inner_w - gap_x) / columns
|
|
var cell_h := (inner_h - gap_y) / rows
|
|
|
|
var col := int((pos.x - inner_x) / (cell_w + cell_spacing))
|
|
var row := int((pos.y - inner_y) / (cell_h + cell_spacing))
|
|
col = clampi(col, 0, columns - 1)
|
|
row = clampi(row, 0, rows - 1)
|
|
|
|
return Vector2i(col, row)
|
|
|
|
|
|
func _get_cell_module(col: int, row: int) -> Control:
|
|
if not _is_valid_cell(col, row):
|
|
return null
|
|
var cell: PanelContainer = _cells[row][col]
|
|
if cell.get_child_count() > 0:
|
|
return cell.get_child(0)
|
|
return null
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Drag & Drop
|
|
# --------------------------------------------------------------------------
|
|
|
|
func _begin_drag(mouse_pos: Vector2) -> void:
|
|
var cell_pos := _cell_at_position(mouse_pos)
|
|
if cell_pos.x < 0 or cell_pos.y < 0:
|
|
return
|
|
|
|
var col := cell_pos.x
|
|
var row := cell_pos.y
|
|
var module := _get_cell_module(col, row)
|
|
if module == null:
|
|
return
|
|
|
|
_drag_source_col = col
|
|
_drag_source_row = row
|
|
|
|
# Show grid cells so the user can see drop zones
|
|
_show_cells(true)
|
|
|
|
# Calculate where the module sits in grid coordinates before reparenting.
|
|
# module.position is relative to the cell; cell.position is relative to us (the grid).
|
|
var cell_ref: PanelContainer = _cells[row][col]
|
|
var module_origin_in_grid := cell_ref.position + module.position
|
|
_drag_mouse_offset = mouse_pos - module_origin_in_grid
|
|
|
|
_drag_module = _take_module_from_cell(col, row)
|
|
_is_dragging = true
|
|
|
|
# Reparent to grid so it renders above all cells
|
|
add_child(_drag_module)
|
|
_drag_module.position = mouse_pos - _drag_mouse_offset
|
|
|
|
# Make the dragged module semi-transparent
|
|
_drag_module.modulate = Color(1, 1, 1, 0.7)
|
|
|
|
# Clear hover state
|
|
_clear_hover()
|
|
|
|
|
|
func _update_drag(mouse_pos: Vector2) -> void:
|
|
if _drag_module == null:
|
|
return
|
|
|
|
_drag_module.position = mouse_pos - _drag_mouse_offset
|
|
|
|
# Update hover highlight
|
|
var cell_pos := _cell_at_position(mouse_pos)
|
|
if cell_pos.x != _hover_col or cell_pos.y != _hover_row:
|
|
_clear_hover()
|
|
if cell_pos.x >= 0 and cell_pos.y >= 0:
|
|
_hover_col = cell_pos.x
|
|
_hover_row = cell_pos.y
|
|
_highlight_cell(_hover_col, _hover_row, true)
|
|
|
|
|
|
func _end_drag(mouse_pos: Vector2) -> void:
|
|
if _drag_module == null:
|
|
return
|
|
|
|
_clear_hover()
|
|
_show_cells(false)
|
|
_drag_module.modulate = Color(1, 1, 1, 1)
|
|
|
|
var cell_pos := _cell_at_position(mouse_pos)
|
|
var target_col := cell_pos.x
|
|
var target_row := cell_pos.y
|
|
|
|
# Check whether the mouse is actually inside the cell bounds (not in the gap)
|
|
var on_cell := false
|
|
if _is_valid_cell(target_col, target_row):
|
|
var cell_ref: PanelContainer = _cells[target_row][target_col]
|
|
on_cell = Rect2(Vector2.ZERO, cell_ref.size).has_point(mouse_pos - cell_ref.position)
|
|
|
|
if not on_cell or not _is_valid_cell(target_col, target_row):
|
|
_drop_to_source()
|
|
_finish_drag()
|
|
return
|
|
|
|
# Remove from its temporary parent (the grid)
|
|
_drag_module.get_parent().remove_child(_drag_module)
|
|
|
|
var existing := _get_cell_module(target_col, target_row)
|
|
if existing != null:
|
|
# SWAP: put existing in source, dragged module in target
|
|
existing.get_parent().remove_child(existing)
|
|
var was_source_valid := _is_valid_cell(_drag_source_col, _drag_source_row)
|
|
if was_source_valid:
|
|
_place_module_in_cell(existing, _drag_source_col, _drag_source_row)
|
|
module_placed.emit(existing, _drag_source_col, _drag_source_row)
|
|
else:
|
|
existing.queue_free()
|
|
|
|
_place_module_in_cell(_drag_module, target_col, target_row)
|
|
module_placed.emit(_drag_module, target_col, target_row)
|
|
else:
|
|
# Place in the empty cell
|
|
_place_module_in_cell(_drag_module, target_col, target_row)
|
|
module_placed.emit(_drag_module, target_col, target_row)
|
|
|
|
_finish_drag()
|
|
|
|
|
|
func _drop_to_source() -> void:
|
|
if _drag_module == null:
|
|
return
|
|
if _is_valid_cell(_drag_source_col, _drag_source_row):
|
|
_drag_module.get_parent().remove_child(_drag_module)
|
|
_place_module_in_cell(_drag_module, _drag_source_col, _drag_source_row)
|
|
module_placed.emit(_drag_module, _drag_source_col, _drag_source_row)
|
|
else:
|
|
_drag_module.queue_free()
|
|
|
|
|
|
func _finish_drag() -> void:
|
|
_drag_module = null
|
|
_is_dragging = false
|
|
_drag_source_col = -1
|
|
_drag_source_row = -1
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Cell highlights
|
|
# --------------------------------------------------------------------------
|
|
|
|
func _clear_hover() -> void:
|
|
if _hover_col >= 0 and _hover_row >= 0 and _is_valid_cell(_hover_col, _hover_row):
|
|
_highlight_cell(_hover_col, _hover_row, false)
|
|
_hover_col = -1
|
|
_hover_row = -1
|
|
|
|
|
|
func _highlight_cell(col: int, row: int, highlighted: bool) -> void:
|
|
if not _is_valid_cell(col, row):
|
|
return
|
|
var cell: PanelContainer = _cells[row][col]
|
|
var style: StyleBoxFlat = cell.get_meta("visible_style")
|
|
if style == null:
|
|
return
|
|
if highlighted:
|
|
style.border_color = Color(0.5, 0.6, 1.0, 1.0)
|
|
else:
|
|
style.border_color = Color(0.25, 0.25, 0.35, 1.0)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Module placement helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
func _take_module_from_cell(col: int, row: int) -> Control:
|
|
if not _is_valid_cell(col, row):
|
|
return null
|
|
var cell: PanelContainer = _cells[row][col]
|
|
for child in cell.get_children():
|
|
cell.remove_child(child)
|
|
_module_positions.erase("%d,%d" % [col, row])
|
|
return child
|
|
return null
|
|
|
|
|
|
func _place_module_in_cell(module: Control, col: int, row: int) -> void:
|
|
if not _is_valid_cell(col, row):
|
|
return
|
|
var cell: PanelContainer = _cells[row][col]
|
|
# Clear existing children
|
|
for child in cell.get_children():
|
|
cell.remove_child(child)
|
|
if is_instance_valid(child):
|
|
child.queue_free()
|
|
cell.add_child(module)
|
|
_module_positions["%d,%d" % [col, row]] = module
|
|
|
|
|
|
func _set_cell_child(cell: PanelContainer, child: Control) -> void:
|
|
for existing in cell.get_children():
|
|
cell.remove_child(existing)
|
|
if is_instance_valid(existing):
|
|
existing.queue_free()
|
|
cell.add_child(child)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# List-reflow helpers
|
|
# --------------------------------------------------------------------------
|
|
|
|
# Returns all modules in the grid, in row-major order (left-to-right, top-to-bottom).
|
|
func _collect_all_modules() -> Array[Control]:
|
|
var result: Array[Control] = []
|
|
for r in range(rows):
|
|
for c in range(columns):
|
|
var mod := _get_cell_module(c, r)
|
|
if mod != null:
|
|
result.append(mod)
|
|
return result
|
|
|
|
|
|
# Clears every cell, then places the given modules in row-major order,
|
|
# adding extra rows as needed to fit them all.
|
|
func _reflow_modules(modules: Array[Control]) -> void:
|
|
# Remove all modules from cells
|
|
for r in range(rows):
|
|
for c in range(columns):
|
|
_take_module_from_cell(c, r)
|
|
|
|
if columns == 0:
|
|
return
|
|
|
|
# Ensure enough rows exist
|
|
var needed := maxi(rows, ceili(float(modules.size()) / float(columns)))
|
|
while _cells.size() < needed:
|
|
_add_row()
|
|
rows = _cells.size()
|
|
|
|
# Place modules sequentially
|
|
for i in range(modules.size()):
|
|
var c := i % columns
|
|
var r := floori(i / columns)
|
|
_place_module_in_cell(modules[i], c, r)
|
|
module_placed.emit(modules[i], c, r)
|
|
|
|
|
|
func _add_row() -> void:
|
|
var new_row := _cells.size()
|
|
_cells.append([])
|
|
for col in range(columns):
|
|
var cell := PanelContainer.new()
|
|
cell.name = "Cell_%d_%d" % [col, new_row]
|
|
cell.mouse_filter = Control.MOUSE_FILTER_PASS
|
|
_style_cell(cell, col, new_row)
|
|
add_child(cell)
|
|
_cells[new_row].append(cell)
|
|
rows = _cells.size()
|
|
_layout_cells()
|
|
|
|
|
|
func _count_modules_in_grid() -> int:
|
|
var count := 0
|
|
for r in range(rows):
|
|
for c in range(columns):
|
|
if _get_cell_module(c, r) != null:
|
|
count += 1
|
|
return count
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Grid rebuild
|
|
# --------------------------------------------------------------------------
|
|
|
|
func _rebuild_grid() -> void:
|
|
# Cancel any active drag
|
|
if _is_dragging:
|
|
_cancel_drag()
|
|
|
|
var avail := size - Vector2(margin * 2.0, margin * 2.0)
|
|
|
|
# If layout hasn't resolved yet (size too small), build a minimum 1x1 grid
|
|
# so modules can be placed. The resized signal will recalculate later.
|
|
if avail.x < cell_min_size.x or avail.y < cell_min_size.y:
|
|
if _cells.size() > 0:
|
|
_layout_cells()
|
|
return
|
|
var new_cols := 1
|
|
var new_rows := 1
|
|
if new_cols == columns and new_rows == rows and _cells.size() > 0:
|
|
_layout_cells()
|
|
return
|
|
columns = new_cols
|
|
rows = new_rows
|
|
_save_modules()
|
|
_teardown_cells()
|
|
_build_cells()
|
|
_restore_modules()
|
|
_layout_cells()
|
|
return
|
|
|
|
var new_cols := maxi(1, int(avail.x / (cell_min_size.x + cell_spacing)))
|
|
|
|
# Ensure enough rows for all current modules after column change
|
|
var module_count := _count_modules_in_grid()
|
|
var min_rows := maxi(1, ceili(float(module_count) / float(new_cols)))
|
|
var avail_rows := maxi(1, int(avail.y / (cell_min_size.y + cell_spacing)))
|
|
var new_rows := maxi(avail_rows, min_rows)
|
|
|
|
if new_cols == columns and new_rows == rows and _cells.size() > 0:
|
|
_layout_cells()
|
|
return
|
|
|
|
columns = new_cols
|
|
rows = new_rows
|
|
|
|
_save_modules()
|
|
_teardown_cells()
|
|
_build_cells()
|
|
_restore_modules()
|
|
_layout_cells()
|
|
|
|
|
|
func _cancel_drag() -> void:
|
|
if _drag_module != null:
|
|
_drag_module.modulate = Color(1, 1, 1, 1)
|
|
if _is_valid_cell(_drag_source_col, _drag_source_row):
|
|
var parent := _drag_module.get_parent()
|
|
if parent != null:
|
|
parent.remove_child(_drag_module)
|
|
_place_module_in_cell(_drag_module, _drag_source_col, _drag_source_row)
|
|
else:
|
|
_drag_module.queue_free()
|
|
_show_cells(false)
|
|
_clear_hover()
|
|
_drag_module = null
|
|
_is_dragging = false
|
|
_drag_source_col = -1
|
|
_drag_source_row = -1
|
|
|
|
|
|
func _build_cells() -> void:
|
|
_cells.clear()
|
|
for row in range(rows):
|
|
_cells.append([])
|
|
for col in range(columns):
|
|
var cell := PanelContainer.new()
|
|
cell.name = "Cell_%d_%d" % [col, row]
|
|
cell.mouse_filter = Control.MOUSE_FILTER_PASS
|
|
_style_cell(cell, col, row)
|
|
add_child(cell)
|
|
_cells[row].append(cell)
|
|
|
|
|
|
func _style_cell(cell: PanelContainer, col: int, row: int) -> void:
|
|
# Hidden style — used when not dragging
|
|
var hidden := StyleBoxFlat.new()
|
|
hidden.bg_color = Color.TRANSPARENT
|
|
hidden.border_width_left = 1
|
|
hidden.border_width_top = 1
|
|
hidden.border_width_right = 1
|
|
hidden.border_width_bottom = 1
|
|
hidden.border_color = Color.TRANSPARENT
|
|
hidden.corner_radius_top_left = 12
|
|
hidden.corner_radius_top_right = 12
|
|
hidden.corner_radius_bottom_right = 12
|
|
hidden.corner_radius_bottom_left = 12
|
|
|
|
# Visible style — used during drag, may be modified by highlights
|
|
var visible := StyleBoxFlat.new()
|
|
visible.bg_color = Color(0.14, 0.14, 0.18, 1.0)
|
|
visible.border_width_left = 1
|
|
visible.border_width_top = 1
|
|
visible.border_width_right = 1
|
|
visible.border_width_bottom = 1
|
|
visible.border_color = Color(0.25, 0.25, 0.35, 1.0)
|
|
visible.corner_radius_top_left = 12
|
|
visible.corner_radius_top_right = 12
|
|
visible.corner_radius_bottom_right = 12
|
|
visible.corner_radius_bottom_left = 12
|
|
|
|
cell.add_theme_stylebox_override("panel", hidden)
|
|
cell.add_theme_stylebox_override("panel_focused", hidden)
|
|
cell.set_meta("hidden_style", hidden)
|
|
cell.set_meta("visible_style", visible)
|
|
_cell_styles["%d,%d" % [col, row]] = visible
|
|
|
|
|
|
func _show_cells(show: bool) -> void:
|
|
for row_idx in range(_cells.size()):
|
|
for col_idx in range(_cells[row_idx].size()):
|
|
var cell: PanelContainer = _cells[row_idx][col_idx]
|
|
if not is_instance_valid(cell):
|
|
continue
|
|
var style: StyleBoxFlat = cell.get_meta("visible_style") if show else cell.get_meta("hidden_style")
|
|
cell.add_theme_stylebox_override("panel", style)
|
|
cell.add_theme_stylebox_override("panel_focused", style)
|
|
|
|
|
|
func _teardown_cells() -> void:
|
|
_cell_styles.clear()
|
|
for child in get_children():
|
|
remove_child(child)
|
|
if is_instance_valid(child):
|
|
child.queue_free()
|
|
_cells.clear()
|
|
|
|
|
|
func _layout_cells() -> void:
|
|
if rows == 0 or columns == 0:
|
|
return
|
|
|
|
var inner_w := maxf(0.0, size.x - margin * 2.0 - cell_spacing)
|
|
var inner_h := maxf(0.0, size.y - margin * 2.0 - cell_spacing)
|
|
var gap_x := cell_spacing * (columns - 1)
|
|
var gap_y := cell_spacing * (rows - 1)
|
|
var cell_w := maxf(1.0, (inner_w - gap_x) / columns)
|
|
var cell_h := maxf(1.0, (inner_h - gap_y) / rows)
|
|
|
|
for row in range(rows):
|
|
for col in range(columns):
|
|
var cell: PanelContainer = _cells[row][col]
|
|
if not is_instance_valid(cell):
|
|
continue
|
|
var x := margin + cell_spacing + col * (cell_w + cell_spacing)
|
|
var y := margin + cell_spacing + row * (cell_h + cell_spacing)
|
|
cell.set_position(Vector2(x, y))
|
|
cell.set_size(Vector2(cell_w, cell_h))
|
|
|
|
|
|
func _save_modules() -> void:
|
|
_module_positions.clear()
|
|
for row in range(_cells.size()):
|
|
for col in range(_cells[row].size()):
|
|
var cell: PanelContainer = _cells[row][col]
|
|
if cell.get_child_count() > 0:
|
|
var mod := cell.get_child(0)
|
|
cell.remove_child(mod)
|
|
_module_positions["%d,%d" % [col, row]] = mod
|
|
|
|
|
|
func _restore_modules() -> void:
|
|
# Collect modules in insertion order (row-major from _save_modules)
|
|
var module_list: Array[Control] = []
|
|
for key in _module_positions:
|
|
module_list.append(_module_positions[key])
|
|
|
|
_module_positions.clear()
|
|
|
|
if module_list.is_empty():
|
|
return
|
|
|
|
# Reflow onto the new grid dimensions
|
|
var module_count := module_list.size()
|
|
var needed := maxi(rows, ceili(float(module_count) / float(columns)))
|
|
while _cells.size() < needed:
|
|
var new_row := _cells.size()
|
|
_cells.append([])
|
|
for col in range(columns):
|
|
var cell := PanelContainer.new()
|
|
cell.name = "Cell_%d_%d" % [col, new_row]
|
|
cell.mouse_filter = Control.MOUSE_FILTER_PASS
|
|
_style_cell(cell, col, new_row)
|
|
add_child(cell)
|
|
_cells[new_row].append(cell)
|
|
rows = _cells.size()
|
|
|
|
for i in range(module_list.size()):
|
|
var c := i % columns
|
|
var r := floori(i / columns)
|
|
if _is_valid_cell(c, r):
|
|
_place_module_in_cell(module_list[i], c, r)
|