V-Panel/scripts/dashboard_grid.gd
Eric Smith ebf97a6fc8 redesign tile action menu with toolbar and submenus
- replace old PopupMenu (flat preset list) with PopupPanel-based menu
- add 3-icon toolbar: ⚙ Settings, ℹ Info, ✕ Remove (+) + Add
- red ✕ on tiles removes the tile; green + on empty space opens add-tile submenu
- 'Themes ▸' button opens a submenu with all ShaderPresets
- add-tile submenu populates from PluginManager.get_all_tile_defs()
- all toolbar buttons are 48×48 flat buttons with 24px font for touch
- orphan PopupPanel children cleaned up during grid rebuild
2026-05-21 09:19:29 -04:00

1084 lines
32 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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)