@tool extends Control class_name DashboardGrid signal module_placed(module: Control, col: int, row: int) signal module_removed(module: Control) signal module_resized(module: Control, col: int, row: int, w: int, h: int) @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 # Visual cells — shown as drag guides var _cells: Array = [] # _cells[row][col] -> PanelContainer # Occupancy: _grid[row][col] -> Control or null var _grid: Array = [] # Ordered module list (row-major order for layout) var _module_list: Array[Control] = [] # Cell styles cached for show/hide var _cell_styles: Dictionary = {} # 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 # Resize state var _is_resizing: bool = false var _resize_module: Control = null var _resize_edge: int = 0 # bitfield: 1=left, 2=right, 4=top, 8=bottom var _resize_origin_col: int = -1 var _resize_origin_row: int = -1 var _resize_origin_w: int = 1 var _resize_origin_h: int = 1 var _resize_preview: ColorRect = null # ghost showing new size # Size of a single cell in pixels (computed during _layout_cells) var _cell_w: float = 1.0 var _cell_h: float = 1.0 # Resize edge detection threshold (pixels) const RESIZE_THRESHOLD: float = 10.0 const EDGE_LEFT: int = 1 const EDGE_RIGHT: int = 2 const EDGE_TOP: int = 4 const EDGE_BOTTOM: int = 8 # Long-press gesture — used as a touch-friendly alternative to double-click const LONG_PRESS_TIME: float = 0.5 const LONG_PRESS_DRAG_THRESHOLD: float = 10.0 var _long_press_timer: Timer = null var _long_press_pos: Vector2 = Vector2.ZERO var _long_press_active: bool = false func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_STOP resized.connect(_rebuild_grid) gui_input.connect(_on_gui_input) mouse_exited.connect(_on_mouse_exited) _rebuild_grid() func _on_mouse_exited() -> void: if _long_press_active: _cancel_long_press() if _is_dragging: if _drag_module != null: _drag_module.modulate = Color(1, 1, 1, 1) _drop_to_source() _layout_cells() _finish_drag() _show_cells(false) elif _is_resizing: _end_resize(Vector2.ZERO) func _on_gui_input(event: InputEvent) -> void: if event is InputEventMouseButton: var mb: InputEventMouseButton = event if mb.button_index == MOUSE_BUTTON_LEFT and mb.double_click and mb.pressed: # Double-click still works for mouse users _show_tile_menu(mb.position) elif mb.button_index == MOUSE_BUTTON_LEFT and not mb.double_click: if mb.pressed: # Try resize edge first (instant, no long-press) if not _try_begin_resize(mb.position): # Not on an edge — start long-press gesture _start_long_press(mb.position) else: _cancel_long_press() if _is_resizing: _end_resize(mb.position) elif _is_dragging: _end_drag(mb.position) elif event is InputEventMouseMotion: var mm: InputEventMouseMotion = event if _is_resizing: _update_resize(mm.position) elif _is_dragging: _update_drag(mm.position) elif _long_press_active: if (mm.position - _long_press_pos).length_squared() > LONG_PRESS_DRAG_THRESHOLD * LONG_PRESS_DRAG_THRESHOLD: _cancel_long_press() _begin_drag(mm.position) else: _update_hover_cursor(mm.position) const MENU_BUTTON_MIN_SIZE: Vector2 = Vector2(48, 48) const MENU_BUTTON_FONT_SIZE: int = 24 # -------------------------------------------------------------------------- # Tile action menu (replaces old preset-only popup) # -------------------------------------------------------------------------- func _show_tile_menu(mouse_pos: Vector2) -> void: if _is_dragging or _is_resizing: return var cell_pos := _cell_at_position(mouse_pos) if cell_pos.x < 0 or cell_pos.y < 0: return var module := _get_module_at(cell_pos.x, cell_pos.y) var panel := PopupPanel.new() panel.name = "TileMenu" panel.position = Vector2i(get_screen_position()) + Vector2i(mouse_pos) add_child(panel) var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 8) margin.add_theme_constant_override("margin_right", 8) margin.add_theme_constant_override("margin_top", 8) margin.add_theme_constant_override("margin_bottom", 8) panel.add_child(margin) var vbox := VBoxContainer.new() vbox.add_theme_constant_override("separation", 6) margin.add_child(vbox) # -- toolbar row -- var toolbar := HBoxContainer.new() toolbar.add_theme_constant_override("separation", 6) toolbar.alignment = BoxContainer.ALIGNMENT_CENTER vbox.add_child(toolbar) var settings_btn := _make_menu_button("⚙", "Settings") toolbar.add_child(settings_btn) var info_btn := _make_menu_button("ℹ", "Info") toolbar.add_child(info_btn) if module != null: # On a tile: show remove button (red X) var remove_btn := _make_menu_button("✕", "Remove Tile") remove_btn.modulate = Color(1.0, 0.25, 0.25, 1.0) remove_btn.pressed.connect(_remove_tile.bind(module, panel)) toolbar.add_child(remove_btn) else: # On empty space: show add button (green +) var add_btn := _make_menu_button("+", "Add Tile") add_btn.modulate = Color(0.2, 1.0, 0.2, 1.0) add_btn.pressed.connect(_show_add_tile_submenu.bind(add_btn, panel)) toolbar.add_child(add_btn) # -- Themes button (only on a tile) -- if module != null: var themes_btn := Button.new() themes_btn.text = "Themes ▸" themes_btn.flat = true themes_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER themes_btn.pressed.connect(_show_themes_submenu.bind(themes_btn, module, panel)) vbox.add_child(themes_btn) panel.popup() func _make_menu_button(text: String, tooltip: String) -> Button: var btn := Button.new() btn.text = text btn.tooltip_text = tooltip btn.flat = true btn.custom_min_size = MENU_BUTTON_MIN_SIZE btn.add_theme_font_size_override("font_size", MENU_BUTTON_FONT_SIZE) return btn func _show_themes_submenu(parent_btn: Button, module: Control, panel: PopupPanel) -> void: var submenu := PopupMenu.new() submenu.name = "ThemeSubmenu" var names := ShaderPresets.get_preset_names() for i in names.size(): submenu.add_item(names[i], i) # Position the submenu to the right of the parent button var btn_global: Vector2 = parent_btn.global_position + Vector2(parent_btn.size.x, 0.0) submenu.position = Vector2i(btn_global) add_child(submenu) submenu.popup() submenu.id_pressed.connect(func(id: int): if id >= 0 and id < names.size(): ShaderPresets.apply_preset(module, names[id]) if is_instance_valid(submenu): submenu.queue_free() if is_instance_valid(panel): panel.queue_free() ) func _show_add_tile_submenu(parent_btn: Button, panel: PopupPanel) -> void: if not PluginManager.has_method("get_all_tile_defs"): return var defs: Array[Dictionary] = PluginManager.get_all_tile_defs() if defs.is_empty(): return var submenu := PopupMenu.new() submenu.name = "AddTileSubmenu" for i in defs.size(): var label: String = defs[i].get("name", defs[i].get("id", "Tile %d" % i)) submenu.add_item(label, i) var btn_global: Vector2 = parent_btn.global_position + Vector2(parent_btn.size.x, 0.0) submenu.position = Vector2i(btn_global) add_child(submenu) submenu.popup() submenu.id_pressed.connect(func(id: int): if id >= 0 and id < defs.size(): _add_tile_from_def(defs[id]) if is_instance_valid(submenu): submenu.queue_free() if is_instance_valid(panel): panel.queue_free() ) func _remove_tile(module: Control, panel: PopupPanel) -> void: remove_module(module) if is_instance_valid(panel): panel.queue_free() func _add_tile_from_def(def: Dictionary) -> void: if not PluginManager.has_method("instantiate_tile"): return var tid: String = def.get("id", "") if tid.is_empty(): return var instance := PluginManager.instantiate_tile(tid) if instance == null: return # Find a free position at the end of the grid var gs := get_grid_size() var target_col := 0 var target_row := gs.y # Try current row first var dw: int = def.get("default_w", 1) var dh: int = def.get("default_h", 1) for c in range(gs.x): if _span_fits(c, target_row, dw, dh, null): target_col = c break place_module(instance, target_col, target_row, dw, dh) # -------------------------------------------------------------------------- # Long-press gesture (touch-friendly popup trigger) # -------------------------------------------------------------------------- func _start_long_press(pos: Vector2) -> void: _cancel_long_press() _long_press_pos = pos _long_press_active = true _long_press_timer = Timer.new() _long_press_timer.name = "LongPressTimer" _long_press_timer.one_shot = true _long_press_timer.wait_time = LONG_PRESS_TIME _long_press_timer.timeout.connect(_on_long_press_timeout) add_child(_long_press_timer) _long_press_timer.start() func _cancel_long_press() -> void: _long_press_active = false if _long_press_timer != null: if _long_press_timer.is_inside_tree(): _long_press_timer.queue_free() _long_press_timer = null func _on_long_press_timeout() -> void: _long_press_active = false _long_press_timer = null _show_tile_menu(_long_press_pos) # -------------------------------------------------------------------------- # Public API # -------------------------------------------------------------------------- func place_module(module: Control, col: int, row: int, span_w: int = 1, span_h: int = 1) -> void: _ensure_occupancy_for_span(col, row, span_w, span_h) _set_module_grid_data(module, col, row, span_w, span_h) _occupy_cells(module, col, row, span_w, span_h) # Add as direct child of the grid add_child(module) if module not in _module_list: _module_list.append(module) module_placed.emit(module, col, row) _layout_cells() func remove_module(module: Control) -> void: if not is_instance_valid(module): return var col: int = module.get_meta("grid_col", -1) var row: int = module.get_meta("grid_row", -1) var w: int = module.get_meta("grid_w", 1) var h: int = module.get_meta("grid_h", 1) _clear_occupancy(col, row, w, h) _module_list.erase(module) if module.get_parent() == self: remove_child(module) module_removed.emit(module) _layout_cells() func get_module_at(col: int, row: int) -> Control: if not _is_valid_cell(col, row): return null return _grid[row][col] as Control 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) # -------------------------------------------------------------------------- # Module grid-data helpers # -------------------------------------------------------------------------- func _set_module_grid_data(module: Control, col: int, row: int, w: int, h: int) -> void: module.set_meta("grid_col", col) module.set_meta("grid_row", row) module.set_meta("grid_w", w) module.set_meta("grid_h", h) func _get_module_grid_data(module: Control) -> Dictionary: return { col = module.get_meta("grid_col", 0), row = module.get_meta("grid_row", 0), w = module.get_meta("grid_w", 1), h = module.get_meta("grid_h", 1), } func _occupy_cells(module: Control, col: int, row: int, w: int, h: int) -> void: for r in range(row, row + h): for c in range(col, col + w): if r < _grid.size() and c < _grid[r].size(): _grid[r][c] = module func _clear_occupancy(col: int, row: int, w: int, h: int) -> void: for r in range(row, row + h): for c in range(col, col + w): if r < _grid.size() and c < _grid[r].size(): _grid[r][c] = null func _ensure_occupancy_for_span(col: int, row: int, w: int, h: int) -> void: var needed_rows := row + h var needed_cols := col + w while _grid.size() < needed_rows: _grid.append([]) for r in range(_grid.size()): while _grid[r].size() < needed_cols: _grid[r].append(null) if columns < needed_cols: columns = needed_cols if rows < needed_rows: rows = needed_rows func _is_valid_cell(col: int, row: int) -> bool: return row >= 0 and row < _grid.size() and col >= 0 and col < _grid[row].size() # -------------------------------------------------------------------------- # Occupancy checks # -------------------------------------------------------------------------- func _span_fits(col: int, row: int, w: int, h: int, exclude: Control = null) -> bool: for r in range(row, row + h): for c in range(col, col + w): if r < 0 or c < 0: return false if r >= _grid.size() or c >= _grid[r].size(): continue # beyond grid is OK — will expand var occupant: Control = _grid[r][c] if occupant != null and occupant != exclude: return false return true # -------------------------------------------------------------------------- # Cell / position helpers # -------------------------------------------------------------------------- func _cell_at_position(pos: Vector2) -> Vector2i: if columns == 0 or rows == 0 or _cell_w <= 0 or _cell_h <= 0: return Vector2i(-1, -1) var inner_x := margin + cell_spacing var inner_y := margin + cell_spacing 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_module_at(col: int, row: int) -> Control: if not _is_valid_cell(col, row): return null return _grid[row][col] as Control func _get_module_in_grid() -> Array[Control]: var result: Array[Control] = [] for r in range(rows): for c in range(columns): var mod := _get_module_at(c, r) as Control if mod != null and mod not in result: result.append(mod) return result # -------------------------------------------------------------------------- # Hover cursor # -------------------------------------------------------------------------- func _update_hover_cursor(mouse_pos: Vector2) -> void: var edge := _detect_resize_edge(mouse_pos) mouse_default_cursor_shape = Control.CURSOR_ARROW if edge != 0: if edge & (EDGE_TOP | EDGE_BOTTOM): mouse_default_cursor_shape = Control.CURSOR_VSIZE elif edge & (EDGE_LEFT | EDGE_RIGHT): mouse_default_cursor_shape = Control.CURSOR_HSIZE func _detect_resize_edge(mouse_pos: Vector2) -> int: for module in _module_list: if not is_instance_valid(module): continue if not module.visible: continue var d := _get_module_grid_data(module) var rect := _get_module_rect(d.col, d.row, d.w, d.h) if not rect.has_point(mouse_pos): continue var edge := 0 var dist_left: float = abs(mouse_pos.x - rect.position.x) var dist_right: float = abs(mouse_pos.x - (rect.position.x + rect.size.x)) var dist_top: float = abs(mouse_pos.y - rect.position.y) var dist_bottom: float = abs(mouse_pos.y - (rect.position.y + rect.size.y)) if dist_left < RESIZE_THRESHOLD: edge |= EDGE_LEFT if dist_right < RESIZE_THRESHOLD: edge |= EDGE_RIGHT if dist_top < RESIZE_THRESHOLD: edge |= EDGE_TOP if dist_bottom < RESIZE_THRESHOLD: edge |= EDGE_BOTTOM return edge return 0 # -------------------------------------------------------------------------- # Resize # -------------------------------------------------------------------------- func _try_begin_resize(mouse_pos: Vector2) -> bool: var edge := _detect_resize_edge(mouse_pos) if edge == 0: return false # Find which module for module in _module_list: if not is_instance_valid(module): continue var d := _get_module_grid_data(module) var rect := _get_module_rect(d.col, d.row, d.w, d.h) if rect.has_point(mouse_pos): _is_resizing = true _resize_module = module _resize_edge = edge _resize_origin_col = d.col _resize_origin_row = d.row _resize_origin_w = d.w _resize_origin_h = d.h # Create preview ghost _resize_preview = ColorRect.new() _resize_preview.color = Color(0.3, 0.5, 1.0, 0.2) _resize_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_resize_preview) _update_resize_preview(mouse_pos) # Show cells _show_cells(true) return true return false func _update_resize(mouse_pos: Vector2) -> void: if _resize_preview == null: return _update_resize_preview(mouse_pos) func _update_resize_preview(mouse_pos: Vector2) -> void: var d := _get_module_grid_data(_resize_module) var cell_pos := _cell_at_position(mouse_pos) if cell_pos.x < 0 or cell_pos.y < 0: _resize_preview.visible = false return var new_col: int = d.col var new_row: int = d.row var new_w: int = d.w var new_h: int = d.h if _resize_edge & EDGE_LEFT: new_col = min(cell_pos.x, d.col + d.w - 1) new_w = d.col + d.w - new_col if _resize_edge & EDGE_RIGHT: new_w = max(1, cell_pos.x - d.col + 1) if _resize_edge & EDGE_TOP: new_row = min(cell_pos.y, d.row + d.h - 1) new_h = d.row + d.h - new_row if _resize_edge & EDGE_BOTTOM: new_h = max(1, cell_pos.y - d.row + 1) # Clamp to grid new_w = clampi(new_w, 1, columns - new_col) new_h = clampi(new_h, 1, rows - new_row) # Check for overlap (excluding ourselves) var fits := _span_fits(new_col, new_row, new_w, new_h, _resize_module) _resize_preview.visible = fits if fits: var prect := _get_module_rect(new_col, new_row, new_w, new_h) _resize_preview.set_position(prect.position) _resize_preview.set_size(prect.size) # Store proposed span for end_resize _resize_origin_col = new_col _resize_origin_row = new_row _resize_origin_w = new_w _resize_origin_h = new_h func _end_resize(_mouse_pos: Vector2) -> void: if _resize_preview != null: _resize_preview.queue_free() _resize_preview = null _show_cells(false) if _resize_module != null and is_instance_valid(_resize_module): # Clear old occupancy var d := _get_module_grid_data(_resize_module) _clear_occupancy(d.col, d.row, d.w, d.h) # Apply new span _set_module_grid_data(_resize_module, _resize_origin_col, _resize_origin_row, _resize_origin_w, _resize_origin_h) _occupy_cells(_resize_module, _resize_origin_col, _resize_origin_row, _resize_origin_w, _resize_origin_h) _layout_cells() module_resized.emit(_resize_module, _resize_origin_col, _resize_origin_row, _resize_origin_w, _resize_origin_h) _is_resizing = false _resize_module = null _resize_edge = 0 # -------------------------------------------------------------------------- # 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_module_at(col, row) if module == null: return var d := _get_module_grid_data(module) _drag_source_col = d.col _drag_source_row = d.row _show_cells(true) # Module position in grid space var module_rect := _get_module_rect(d.col, d.row, d.w, d.h) _drag_mouse_offset = mouse_pos - module_rect.position # Reparent module to top of tree (already a child if direct child of grid) _drag_module = module move_child(_drag_module, get_child_count()) _is_dragging = true _drag_module.position = mouse_pos - _drag_mouse_offset _drag_module.modulate = Color(1, 1, 1, 0.7) _clear_hover() # Remove from occupancy temporarily _clear_occupancy(d.col, d.row, d.w, d.h) func _update_drag(mouse_pos: Vector2) -> void: if _drag_module == null: return _drag_module.position = mouse_pos - _drag_mouse_offset 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 d := _get_module_grid_data(_drag_module) # Calculate target cell from the module's visual position (which already # accounts for the grab offset), not from the raw mouse position. # This ensures multi-cell tiles snap to the grid based on where the # module's top-left would logically land, not where the pointer is. var snap_pos := _drag_module.position + Vector2(_cell_w * 0.5, _cell_h * 0.5) var cell_pos := _cell_at_position(snap_pos) var target_col := cell_pos.x var target_row := cell_pos.y # Check if the module's center is actually inside the cell bounds 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(snap_pos - cell_ref.position) if not on_cell or not _is_valid_cell(target_col, target_row): _drop_to_source() _layout_cells() _finish_drag() return # Check if target is occupied var existing := _get_module_at(target_col, target_row) if existing != null: var ed := _get_module_grid_data(existing) _swap_modules(_drag_module, d.col, d.row, d.w, d.h, existing, ed) var ad := _get_module_grid_data(_drag_module) module_placed.emit(_drag_module, ad.col, ad.row) module_placed.emit(existing, d.col, d.row) else: # Place in new position _clear_occupancy(d.col, d.row, d.w, d.h) _set_module_grid_data(_drag_module, target_col, target_row, d.w, d.h) if _span_fits(target_col, target_row, d.w, d.h, _drag_module): _occupy_cells(_drag_module, target_col, target_row, d.w, d.h) else: _occupy_cells(_drag_module, d.col, d.row, d.w, d.h) _set_module_grid_data(_drag_module, d.col, d.row, d.w, d.h) module_placed.emit(_drag_module, target_col, target_row) _layout_cells() _finish_drag() func _swap_modules(a: Control, a_col: int, a_row: int, a_w: int, a_h: int, b: Control, bd: Dictionary) -> void: _clear_occupancy(a_col, a_row, a_w, a_h) _clear_occupancy(bd.col, bd.row, bd.w, bd.h) _set_module_grid_data(a, bd.col, bd.row, bd.w, bd.h) _set_module_grid_data(b, a_col, a_row, a_w, a_h) _occupy_cells(a, bd.col, bd.row, bd.w, bd.h) _occupy_cells(b, a_col, a_row, a_w, a_h) func _drop_to_source() -> void: if _drag_module == null: return var d := _get_module_grid_data(_drag_module) _occupy_cells(_drag_module, d.col, d.row, d.w, d.h) func _finish_drag() -> void: _drag_module = null _is_dragging = false _drag_source_col = -1 _drag_source_row = -1 # -------------------------------------------------------------------------- # Module rect in grid coordinates # -------------------------------------------------------------------------- func _get_module_rect(col: int, row: int, w: int, h: int) -> Rect2: var x := margin + cell_spacing + col * (_cell_w + cell_spacing) var y := margin + cell_spacing + row * (_cell_h + cell_spacing) var mw := w * _cell_w + (w - 1) * cell_spacing var mh := h * _cell_h + (h - 1) * cell_spacing return Rect2(x, y, mw, mh) # -------------------------------------------------------------------------- # 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) # -------------------------------------------------------------------------- # Grid rebuild # -------------------------------------------------------------------------- func _rebuild_grid() -> void: if _long_press_active: _cancel_long_press() if _is_dragging: _cancel_drag() if _is_resizing: _end_resize(Vector2.ZERO) # Clean up orphaned popups from tile menu for child in get_children(): if child is PopupMenu or child is PopupPanel: child.queue_free() var avail := size - Vector2(margin * 2.0, margin * 2.0) 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))) var module_count := _module_list.size() 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) var d := _get_module_grid_data(_drag_module) _occupy_cells(_drag_module, d.col, d.row, d.w, d.h) _show_cells(false) _clear_hover() _drag_module = null _is_dragging = false _drag_source_col = -1 _drag_source_row = -1 # -------------------------------------------------------------------------- # Cell building / teardown / styling # -------------------------------------------------------------------------- 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: 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 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(): # Keep modules — only remove cells if child is PanelContainer and child.name.begins_with("Cell_"): remove_child(child) if is_instance_valid(child): child.queue_free() _cells.clear() # -------------------------------------------------------------------------- # Layout # -------------------------------------------------------------------------- func _ensure_cells_match_grid() -> void: while _cells.size() < rows: _cells.append([]) var new_row := _cells.size() - 1 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) func _layout_cells() -> void: if rows == 0 or columns == 0: return _ensure_cells_match_grid() 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) _cell_w = maxf(1.0, (inner_w - gap_x) / columns) _cell_h = maxf(1.0, (inner_h - gap_y) / rows) # Position cells for row in range(rows): for col in range(columns): if row >= _cells.size() or col >= _cells[row].size(): continue 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)) # Position modules for module in _module_list: if not is_instance_valid(module): continue var d := _get_module_grid_data(module) var rect := _get_module_rect(d.col, d.row, d.w, d.h) module.set_position(rect.position) module.set_size(rect.size) # -------------------------------------------------------------------------- # Save / restore (used during grid rebuild) # -------------------------------------------------------------------------- func _save_modules() -> void: # Modules already have their grid data in metadata pass func _restore_modules() -> void: # Rebuild grid _grid.clear() for r in range(rows): _grid.append([]) for c in range(columns): _grid[r].append(null) if _module_list.is_empty(): return # Ensure enough rows while _grid.size() < rows: _grid.append([]) for c in range(columns): _grid[_grid.size() - 1].append(null) # Re-place modules row-major var reordered: Array[Control] = [] for r in range(rows): for c in range(columns): for module in _module_list: if module in reordered: continue var d := _get_module_grid_data(module) if d.col == c and d.row == r: if _span_fits(c, r, d.w, d.h, null): # Ensure grid is big enough _ensure_occupancy_for_span(c, r, d.w, d.h) _occupy_cells(module, c, r, d.w, d.h) reordered.append(module) break # Add any modules that didn't find their spot for module in _module_list: if module not in reordered: reordered.append(module) _module_list = reordered # Place orphaned modules for module in _module_list: if module.get_parent() != self: add_child(module) # Check if it's in the grid var d := _get_module_grid_data(module) if _get_module_at(d.col, d.row) != module: # Find a spot _find_and_place(module) # Rebuild columns/rows from grid occupancy _ensure_occupancy_from_grid() _layout_cells() func _ensure_occupancy_from_grid() -> void: var max_c := 0 var max_r := 0 for r in range(_grid.size()): for c in range(_grid[r].size()): if _grid[r][c] != null: max_c = maxi(max_c, c + 1) max_r = maxi(max_r, r + 1) columns = maxi(columns, max_c) rows = maxi(rows, max_r) func _find_and_place(module: Control) -> void: var d := _get_module_grid_data(module) # Try the desired position first if _span_fits(d.col, d.row, d.w, d.h, null): _ensure_occupancy_for_span(d.col, d.row, d.w, d.h) _occupy_cells(module, d.col, d.row, d.w, d.h) return # Find first available position for r in range(rows): for c in range(columns): if _span_fits(c, r, d.w, d.h, null): _set_module_grid_data(module, c, r, d.w, d.h) _ensure_occupancy_for_span(c, r, d.w, d.h) _occupy_cells(module, c, r, d.w, d.h) return # No room — expand grid var new_col := columns var new_row := 0 _ensure_occupancy_for_span(new_col, new_row, d.w, d.h) columns = new_col + d.w _set_module_grid_data(module, new_col, new_row, d.w, d.h) _occupy_cells(module, new_col, new_row, d.w, d.h)