@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 = 6 hidden.corner_radius_top_right = 6 hidden.corner_radius_bottom_right = 6 hidden.corner_radius_bottom_left = 6 # 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 = 6 visible.corner_radius_top_right = 6 visible.corner_radius_bottom_right = 6 visible.corner_radius_bottom_left = 6 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)