@tool extends Control signal layout_saved signal save_failed signal load_failed const CATALOG = preload("res://scripts/layout_catalog.gd") const STORE = preload("res://scripts/layout_store.gd") @export var grid_size := Vector2i(32, 18) @export var panel_gap := 16.0: set(value): panel_gap = maxf(value, 0.0) if is_node_ready(): arrange_items() @export var config_path := "" @export var legacy_config_path := "" var edit_mode := false var selected: Control var _items: Array[Control] = [] var _defaults: Dictionary = {} var _operation := "" var _origin := Rect2i() var _candidate := Rect2i() var _offset := Vector2.ZERO @onready var overlay: Control = $EditOverlay func _ready() -> void: for child in get_children(): if child.has_method("arrange_content"): if child.element_type.is_empty(): child.element_type = child.panel_id _items.append(child) _defaults[child.panel_id] = child.cell_rect resized.connect(arrange_items) get_window().focus_exited.connect(cancel_interaction) $EditOverlay/Actions/Resize.gui_input.connect(_resize_input) $EditOverlay/Actions/Delete.pressed.connect(delete_selected) arrange_items() func cell_size() -> Vector2: return size / Vector2(grid_size) func arrange_items() -> void: for item in _items: item.padding = panel_gap / 2 item.position = Vector2(item.cell_rect.position) * cell_size() item.size = Vector2(item.cell_rect.size) * cell_size() item.arrange_content() _update_overlay() func set_edit_mode(active: bool) -> void: cancel_interaction() edit_mode = active for item in _items: item.mouse_behavior_recursive = Control.MOUSE_BEHAVIOR_DISABLED if active else Control.MOUSE_BEHAVIOR_INHERITED if not active: selected = null overlay.visible = active _update_overlay() func handle_grid_input(event: InputEvent) -> void: if not edit_mode: return if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: var point: Vector2 = event.position selected = null for item in _items: if item.visible and Rect2(item.position, item.size).has_point(point): selected = item break if selected != null: _begin_interaction("move", point) _update_overlay() func _input(event: InputEvent) -> void: if not edit_mode or _operation.is_empty(): return if event is InputEventMouseMotion: update_interaction(get_global_transform_with_canvas().affine_inverse() * event.position) get_viewport().set_input_as_handled() elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed: finish_interaction() func _resize_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed and selected != null: var button: Control = $EditOverlay/Actions/Resize var point: Vector2 = get_global_transform_with_canvas().affine_inverse() * button.get_global_transform_with_canvas() * event.position _begin_interaction("resize", point) $EditOverlay/Actions/Resize.accept_event() func _begin_interaction(operation: String, point: Vector2) -> void: _operation = operation _origin = selected.cell_rect _candidate = _origin _offset = point / cell_size() _update_overlay() func update_interaction(point: Vector2) -> void: if selected == null or _operation.is_empty(): return var delta := Vector2i((point / cell_size() - _offset).round()) _candidate = Rect2i(_origin.position + delta, _origin.size) if _operation == "move" else Rect2i(_origin.position, _origin.size + delta) if can_place(selected, _candidate): selected.cell_rect = _candidate arrange_items() _update_overlay() func can_place(item: Control, rect: Rect2i) -> bool: if rect.position.x < 0 or rect.position.y < 0 or rect.end.x > grid_size.x or rect.end.y > grid_size.y: return false if rect.size.x < item.minimum_cells.x or rect.size.y < item.minimum_cells.y: return false for other in _items: if other != item and other.visible and rect.intersects(other.cell_rect): return false return true func finish_interaction() -> void: if selected == null or _operation.is_empty(): return selected.cell_rect = _candidate if can_place(selected, _candidate) else _origin var changed: bool = selected.cell_rect != _origin _operation = "" arrange_items() if changed: save_layout() func cancel_interaction() -> void: if selected != null and not _operation.is_empty(): selected.cell_rect = _origin _operation = "" if is_node_ready(): arrange_items() func delete_selected() -> void: if selected == null: return cancel_interaction() selected.hide() selected = null _update_overlay() save_layout() func reset_layout() -> void: cancel_interaction() selected = null _remove_dynamic_items() for item in _items: item.cell_rect = _defaults[item.panel_id] item.show() arrange_items() save_layout() func add_element(element_type: String) -> Control: if not edit_mode or not CATALOG.ENTRIES.has(element_type): return null cancel_interaction() var entry: Dictionary = CATALOG.ENTRIES[element_type] var item: Control for candidate in _items: if not candidate.visible and candidate.element_type == element_type: item = candidate break var minimum: Vector2i = item.minimum_cells if item != null else entry.minimum var preferred: Vector2i = Vector2i(entry.size).max(minimum) var rect := _first_free(preferred) if rect.size == Vector2i.ZERO: rect = _first_free(minimum) if rect.size == Vector2i.ZERO: return null if item == null: var id := element_type var suffix := 2 while _find_item(id) != null: id = "%s_%d" % [element_type, suffix] suffix += 1 item = CATALOG.create_item(element_type, id, rect) add_child(item) _items.append(item) item.cell_rect = rect item.show() item.mouse_behavior_recursive = Control.MOUSE_BEHAVIOR_DISABLED selected = item arrange_items() save_layout() return item func _first_free(dimensions: Vector2i) -> Rect2i: for y in range(grid_size.y - dimensions.y + 1): for x in range(grid_size.x - dimensions.x + 1): var rect := Rect2i(Vector2i(x, y), dimensions) var free := true for item in _items: if item.visible and rect.intersects(item.cell_rect): free = false break if free: return rect return Rect2i() func _find_item(id: String) -> Control: for item in _items: if item.panel_id == id: return item return null func _remove_dynamic_items() -> void: for item in _items.duplicate(): if not _defaults.has(item.panel_id): _items.erase(item) remove_child(item) item.queue_free() func _record(item: Control, rect: Rect2i) -> Dictionary: return {"id": item.panel_id, "type": item.element_type, "rect": [rect.position.x, rect.position.y, rect.size.x, rect.size.y]} func save_layout() -> void: if config_path.is_empty(): return var elements: Array = [] for item in _items: if item.visible: elements.append(_record(item, item.cell_rect)) var document := {"version": 1, "grid": [grid_size.x, grid_size.y], "elements": elements} if STORE.write_json(config_path, document) != OK: save_failed.emit() else: layout_saved.emit() func load_layout() -> void: if config_path.is_empty(): return if not FileAccess.file_exists(config_path): _migrate_legacy() return var decoded := _decode_document(STORE.read_json(config_path)) if decoded.is_empty(): load_failed.emit() return _apply_records(decoded.records) func _decode_document(document: Variant) -> Dictionary: if not document is Dictionary or document.get("version") != 1: return {} var dimensions = document.get("grid") if not STORE.integer_array(dimensions, 2) or (dimensions[0] != grid_size.x or dimensions[1] != grid_size.y): return {} var elements = document.get("elements") if not elements is Array or elements.size() > grid_size.x * grid_size.y: return {} var ids: Dictionary = {} var occupied: Array[Rect2i] = [] var records: Array = [] for record in elements: if not record is Dictionary: return {} var id = record.get("id") var element_type = record.get("type") var coordinates = record.get("rect") if not id is String or id.length() > 64 or not id.is_valid_identifier() or ids.has(id): return {} if not element_type is String or not CATALOG.ENTRIES.has(element_type) or not STORE.integer_array(coordinates, 4): return {} for coordinate in coordinates: if absf(float(coordinate)) > maxi(grid_size.x, grid_size.y): return {} var rect := Rect2i(coordinates[0], coordinates[1], coordinates[2], coordinates[3]) var existing := _find_item(id) if existing != null and existing.element_type != element_type: return {} var minimum: Vector2i = existing.minimum_cells if existing != null else CATALOG.ENTRIES[element_type].minimum if rect.position.x < 0 or rect.position.y < 0 or rect.end.x > grid_size.x or rect.end.y > grid_size.y or rect.size.x < minimum.x or rect.size.y < minimum.y: return {} for other in occupied: if rect.intersects(other): return {} occupied.append(rect) ids[id] = true records.append({"id": id, "type": element_type, "rect": rect}) return {"records": records} func _apply_records(records: Array) -> void: cancel_interaction() selected = null _remove_dynamic_items() for item in _items: item.hide() for record in records: var item := _find_item(record.id) if item == null: item = CATALOG.create_item(record.type, record.id, record.rect) add_child(item) _items.append(item) item.cell_rect = record.rect item.show() item.mouse_behavior_recursive = Control.MOUSE_BEHAVIOR_DISABLED if edit_mode else Control.MOUSE_BEHAVIOR_INHERITED arrange_items() func _migrate_legacy() -> void: if legacy_config_path.is_empty() or not FileAccess.file_exists(legacy_config_path): return var config := ConfigFile.new() if config.load(legacy_config_path) != OK or config.get_value("layout", "grid") != grid_size: load_failed.emit() return var elements: Array = [] for item in _items: if not _defaults.has(item.panel_id): continue var rect = config.get_value(item.panel_id, "rect", _defaults[item.panel_id]) var enabled = config.get_value(item.panel_id, "visible", true) if not rect is Rect2i or not enabled is bool: load_failed.emit() return if enabled: elements.append(_record(item, rect)) var decoded := _decode_document({"version": 1, "grid": [grid_size.x, grid_size.y], "elements": elements}) if decoded.is_empty(): load_failed.emit() return _apply_records(decoded.records) save_layout() func _update_overlay() -> void: if not is_instance_valid(overlay): return var actions: Control = $EditOverlay/Actions actions.visible = edit_mode and selected != null if selected != null: overlay.selected_rect = Rect2(selected.position, selected.size) overlay.candidate_rect = Rect2(Vector2(_candidate.position) * cell_size(), Vector2(_candidate.size) * cell_size()) overlay.candidate_valid = can_place(selected, _candidate) actions.position = selected.position + Vector2(selected.size.x - 116, 4) overlay.show_candidate = not _operation.is_empty() overlay.queue_redraw()