346 lines
11 KiB
GDScript
346 lines
11 KiB
GDScript
@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:
|
|
_set_item_interaction(item)
|
|
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()
|
|
_set_item_interaction(item)
|
|
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()
|
|
_set_item_interaction(item)
|
|
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()
|
|
|
|
func _set_item_interaction(item: Control) -> void:
|
|
item.mouse_behavior_recursive = Control.MOUSE_BEHAVIOR_DISABLED if edit_mode else Control.MOUSE_BEHAVIOR_INHERITED
|
|
var content := item.get_node_or_null("Content")
|
|
if content != null and content.has_method("set_layout_editing"):
|
|
content.set_layout_editing(edit_mode)
|