Added a dynamic layout system where UI elements can be moved about on a tiled grid.

This commit is contained in:
RedNicStone committed 2026-11-09 18:14:52 +01:00
1 parent ea6ba54666
commit 26c29df4ad
34 files changed
+1269

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
@tool
extends AspectRatioContainer
func _ready() -> void:
resized.connect(_update_height)
_update_height()
func _update_height() -> void:
custom_minimum_size.y = ceilf(size.x / ratio)
+1
View File
@@ -0,0 +1 @@
uid://k6buaitag78o
+339
View File
@@ -0,0 +1,339 @@
@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()
+1
View File
@@ -0,0 +1 @@
uid://bqrapdh6yddl8
+31
View File
@@ -0,0 +1,31 @@
extends RefCounted
const ITEM = preload("res://components/layout_item.tscn")
const ENTRIES = {
"camera": {"title": "Camera", "description": "16:9 video surface with a stream header.", "scene": preload("res://components/camera_layout_slot.tscn"), "size": Vector2i(16, 10), "minimum": Vector2i(8, 5), "reference": Vector2(960, 600)},
"vehicle": {"title": "Vehicle status", "description": "Mode, faults, electrical readings and internal temperature.", "scene": preload("res://components/vehicle_status_panel.tscn"), "size": Vector2i(10, 8), "minimum": Vector2i(6, 4), "reference": Vector2(600, 440)},
"motion": {"title": "Motion", "description": "Attitude, heading, acceleration and angular momentum.", "scene": preload("res://components/motion_panel.tscn"), "size": Vector2i(10, 7), "minimum": Vector2i(6, 4), "reference": Vector2(600, 410), "background": NodePath("Content/Background")},
"environment": {"title": "Environment", "description": "Reserved for depth, pressure and water temperature.", "scene": preload("res://components/layout_slot.tscn"), "size": Vector2i(6, 5), "minimum": Vector2i(4, 3), "reference": Vector2(360, 240), "fill": true},
"link": {"title": "Link", "description": "Connection state, traffic utilization and packet loss.", "scene": preload("res://components/link_panel.tscn"), "size": Vector2i(6, 4), "minimum": Vector2i(4, 3), "reference": Vector2(360, 240)},
"videovr": {"title": "Video / VR", "description": "Reserved for latency, headset connection and resolution.", "scene": preload("res://components/layout_slot.tscn"), "size": Vector2i(12, 8), "minimum": Vector2i(6, 4), "reference": Vector2(600, 400), "fill": true},
"operatortools": {"title": "Operator tools", "description": "Reserved for recording, manipulator controls and terminal.", "scene": preload("res://components/layout_slot.tscn"), "size": Vector2i(10, 6), "minimum": Vector2i(6, 4), "reference": Vector2(600, 400), "fill": true},
}
static func create_item(element_type: String, id: String, rect: Rect2i) -> Control:
var entry: Dictionary = ENTRIES[element_type]
var item = ITEM.instantiate()
item.name = id
item.panel_id = id
item.element_type = element_type
item.cell_rect = rect
item.minimum_cells = entry.minimum
item.reference_size = entry.reference
item.fill_allocation = entry.get("fill", false)
item.background_path = entry.get("background", NodePath(""))
var content = entry.scene.instantiate()
content.name = "Content"
if item.fill_allocation:
content.title = entry.title.to_upper()
content.description = entry.description
item.add_child(content)
return item
+1
View File
@@ -0,0 +1 @@
uid://0hiij8bwyn7w
+19
View File
@@ -0,0 +1,19 @@
@tool
extends Control
var selected_rect := Rect2()
var candidate_rect := Rect2()
var show_candidate := false
var candidate_valid := true
func _draw() -> void:
if not $Actions.visible:
return
var accent := get_theme_color("accent", "SurfaceSide")
draw_rect(selected_rect.grow(-1), accent, false, 2)
if show_candidate:
var ink := accent if candidate_valid else get_theme_color("fault", "SurfaceSide")
var fill := ink
fill.a = 0.12
draw_rect(candidate_rect.abs(), fill)
draw_rect(candidate_rect.abs(), ink, false, 3)
+1
View File
@@ -0,0 +1 @@
uid://cwbk5g4yhdfd6
+27
View File
@@ -0,0 +1,27 @@
@tool
extends ColorRect
@export var grid_size := Vector2i(32, 18):
set(value):
grid_size = value
if is_node_ready():
_update_material()
@export var monitor_boundary := 0:
set(value):
monitor_boundary = value
if is_node_ready():
_update_material()
func _ready() -> void:
theme_changed.connect(_update_material)
_update_material()
func _gui_input(_event: InputEvent) -> void:
accept_event()
func _update_material() -> void:
var shader_material := material as ShaderMaterial
shader_material.set_shader_parameter("grid_size", Vector2(grid_size))
shader_material.set_shader_parameter("monitor_boundary", float(monitor_boundary))
shader_material.set_shader_parameter("line_color", get_theme_color("instrument_grid", "SurfaceSide"))
shader_material.set_shader_parameter("boundary_color", get_theme_color("accent", "SurfaceSide"))
+1
View File
@@ -0,0 +1 @@
uid://n4eb1a6j5j88
+42
View File
@@ -0,0 +1,42 @@
@tool
extends Control
const EMBEDDED_PANEL = preload("res://themes/layout_embedded_panel.tres")
@export var panel_id := "panel"
@export var element_type := ""
@export var cell_rect := Rect2i(0, 0, 6, 4)
@export var minimum_cells := Vector2i(4, 3)
@export var reference_size := Vector2(600, 440)
@export var fill_allocation := false
@export var padding := 8.0
@export_node_path("Control") var background_path := NodePath("")
func _ready() -> void:
clip_contents = true
resized.connect(arrange_content)
if has_node("Content") and $Content is PanelContainer:
$Content.add_theme_stylebox_override("panel", EMBEDDED_PANEL)
if not background_path.is_empty():
var background := get_node_or_null(background_path) as Control
if background != null:
background.add_theme_stylebox_override("panel", EMBEDDED_PANEL)
arrange_content.call_deferred()
func arrange_content() -> void:
if not has_node("Content"):
return
var content: Control = $Content
var available := (size - Vector2.ONE * padding * 2).max(Vector2.ZERO)
$Background.position = Vector2.ONE * padding
$Background.size = available
content.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
if fill_allocation:
content.scale = Vector2.ONE
content.position = Vector2.ONE * padding
content.size = available
else:
content.size = reference_size
var factor := maxf(0.01, minf(available.x / reference_size.x, available.y / reference_size.y))
content.scale = Vector2.ONE * factor
content.position = (size - reference_size * factor) / 2
+1
View File
@@ -0,0 +1 @@
uid://byjnf0ae0jr2y
+30
View File
@@ -0,0 +1,30 @@
extends PanelContainer
signal element_requested(element_type: String)
const CATALOG = preload("res://scripts/layout_catalog.gd")
var _types: Array = []
func _ready() -> void:
_types = CATALOG.ENTRIES.keys()
for element_type in _types:
$Margin/Body/List.add_item(CATALOG.ENTRIES[element_type].title)
$Margin/Body/List.item_selected.connect(_select)
$Margin/Body/List.item_activated.connect(func(_index: int) -> void: _request())
$Margin/Body/Add.pressed.connect(_request)
$Margin/Body/Header/Close.pressed.connect(hide)
$Margin/Body/List.select(0)
_select(0)
func _select(index: int) -> void:
var entry: Dictionary = CATALOG.ENTRIES[_types[index]]
$Margin/Body/Description.text = entry.description
$Margin/Body/Size.text = "%d × %d cells · minimum %d × %d" % [entry.size.x, entry.size.y, entry.minimum.x, entry.minimum.y]
$Margin/Body/Status.text = ""
func _request() -> void:
var indices: PackedInt32Array = $Margin/Body/List.get_selected_items()
if not indices.is_empty():
element_requested.emit(_types[indices[0]])
func show_no_space() -> void:
$Margin/Body/Status.text = "No space available. Move, resize or remove a panel first."
+1
View File
@@ -0,0 +1 @@
uid://cwdwpabj62mhl
+135
View File
@@ -0,0 +1,135 @@
extends Control
signal overview_changed(active: bool)
@export var grid_size := Vector2i(32, 18):
set(value):
grid_size = Vector2i(maxi(1, value.x), maxi(1, value.y))
if is_node_ready():
_update_grid()
@export var monitor_boundary := 0
@export_range(0.5, 0.95) var overview_scale := 0.82
@export var transition_seconds := 0.22
@export var hot_corner_enabled := true
@export var corner_size := 20.0
@export var corner_dwell := 0.18
var overview_active := false
var _pointer_in_window := false
var _corner_hover := false
var _corner_armed := true
var _dwell := 0.0
var _tween: Tween
@onready var dashboard: Control = $Dashboard
@onready var grid: Control = $Dashboard/Grid
@onready var canvas: Control = $Dashboard/Canvas
func _ready() -> void:
grid.gui_input.connect(canvas.handle_grid_input)
$Toolbar/Add.pressed.connect(func() -> void: $Library.visible = not $Library.visible)
$Library.element_requested.connect(_add_element)
canvas.layout_saved.connect(func() -> void: $Toolbar/Hint.text = "Esc to return")
canvas.load_failed.connect(func() -> void: $Toolbar/Hint.text = "Could not load saved layout")
$Toolbar/Reset.pressed.connect(canvas.reset_layout)
canvas.save_failed.connect(func() -> void: $Toolbar/Hint.text = "Could not save layout")
$Toolbar/Return.pressed.connect(func() -> void: set_overview(false))
resized.connect(_resize)
get_window().mouse_entered.connect(func() -> void: _pointer_in_window = true)
get_window().mouse_exited.connect(_leave_corner)
theme_changed.connect(queue_redraw)
_update_grid()
canvas.load_layout()
_resize()
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion and hot_corner_enabled:
_pointer_in_window = true
_update_corner(get_window().get_final_transform() * event.position)
elif overview_active and event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
if $Library.visible:
$Library.hide()
else:
set_overview(false)
get_viewport().set_input_as_handled()
elif overview_active and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
var pointer: Vector2 = get_global_transform_with_canvas().affine_inverse() * event.position
var in_library: bool = $Library.visible and $Library.get_global_rect().has_point(event.position)
if not in_library and not preview_rect().has_point(pointer) and not $Toolbar.get_global_rect().has_point(event.position):
set_overview(false)
get_viewport().set_input_as_handled()
func _process(delta: float) -> void:
var window := get_window()
# Letterbox regions may not send canvas mouse-motion events.
if hot_corner_enabled and window.visible and (_pointer_in_window or window.has_focus()):
_update_corner(window.get_final_transform() * window.get_mouse_position())
else:
_leave_corner()
if hot_corner_enabled and _corner_hover and _corner_armed and not overview_active:
_dwell += delta
if _dwell >= corner_dwell:
_corner_armed = false
set_overview(true)
func _update_corner(window_position: Vector2) -> void:
_corner_hover = Rect2(Vector2.ZERO, Vector2.ONE * corner_size).has_point(window_position)
if not _corner_hover:
_corner_armed = true
_dwell = 0
func _leave_corner() -> void:
_pointer_in_window = false
_corner_hover = false
_dwell = 0
func set_overview(active: bool) -> void:
if overview_active == active:
return
if not active:
$Library.hide()
canvas.set_edit_mode(active)
overview_active = active
_corner_armed = false
_dwell = 0
if is_instance_valid(_tween):
_tween.kill()
grid.visible = active
$Toolbar.visible = active
queue_redraw()
var target := Vector2.ONE * overview_scale if active else Vector2.ONE
if transition_seconds <= 0:
dashboard.scale = target
else:
_tween = create_tween().set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
_tween.tween_property(dashboard, "scale", target, transition_seconds)
overview_changed.emit(active)
func _add_element(element_type: String) -> void:
if canvas.add_element(element_type) == null:
$Library.show_no_space()
else:
$Library.hide()
func preview_rect() -> Rect2:
return Rect2((size - size * dashboard.scale) / 2, size * dashboard.scale)
func _resize() -> void:
_dwell = 0
if is_instance_valid(_tween):
_tween.kill()
dashboard.pivot_offset = size / 2
dashboard.scale = Vector2.ONE * overview_scale if overview_active else Vector2.ONE
queue_redraw()
func _update_grid() -> void:
canvas.grid_size = grid_size
canvas.arrange_items()
grid.grid_size = grid_size
grid.monitor_boundary = monitor_boundary
grid.queue_redraw()
$Toolbar/Title.text = "LAYOUT OVERVIEW %d × %d" % [grid_size.x, grid_size.y]
func _draw() -> void:
if overview_active:
draw_rect(Rect2(Vector2.ZERO, size), get_theme_color("instrument_empty", "SurfaceSide"))
+1
View File
@@ -0,0 +1 @@
uid://b4g15anvgpot8
+56
View File
@@ -0,0 +1,56 @@
extends Control
@export_enum("Landscape", "Dual portrait") var configuration := 0
@export var use_portrait_monitors := true
func _ready() -> void:
if configuration == 1:
$Overview.overview_changed.connect($RightWindow/RightOverview.set_overview)
$RightWindow/RightOverview.overview_changed.connect($Overview.set_overview)
$RightWindow.close_requested.connect(get_tree().quit)
if get_parent() == get_tree().root:
_configure_windows.call_deferred()
func _configure_windows() -> void:
var window := get_window()
var reference := Vector2i(1920, 1080) if configuration == 0 else Vector2i(1080, 1920)
_prepare_window(window, reference)
if configuration == 0:
var usable := DisplayServer.screen_get_usable_rect()
window.size = _preview_size(reference, usable.size, 1)
window.title = "Layout / landscape"
return
var right: Window = $RightWindow
_prepare_window(right, reference)
window.title = "ROV / left portrait"
var portrait_screens: Array[int] = []
for screen in range(DisplayServer.get_screen_count()):
var dimensions := DisplayServer.screen_get_size(screen)
if dimensions.y > dimensions.x:
portrait_screens.append(screen)
if use_portrait_monitors and portrait_screens.size() >= 2:
window.current_screen = portrait_screens[0]
window.mode = Window.MODE_MAXIMIZED
right.current_screen = portrait_screens[1]
right.show()
right.mode = Window.MODE_MAXIMIZED
else:
var usable := DisplayServer.screen_get_usable_rect()
var preview := _preview_size(reference, usable.size, 2)
window.size = preview
right.size = preview
var origin := usable.position + (usable.size - Vector2i(preview.x * 2 + 16, preview.y)) / 2
window.position = origin
right.position = origin + Vector2i(preview.x + 16, 0)
right.show()
func _prepare_window(window: Window, reference: Vector2i) -> void:
window.content_scale_factor = 1.0
window.content_scale_size = reference
window.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
window.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_KEEP
window.mode = Window.MODE_WINDOWED
func _preview_size(reference: Vector2i, usable: Vector2i, count: int) -> Vector2i:
var factor := minf(float(usable.x) * 0.85 / (reference.x * count + 16 * (count - 1)), float(usable.y) * 0.85 / reference.y)
return Vector2i(Vector2(reference) * clampf(factor, 0.1, 1.0))
+1
View File
@@ -0,0 +1 @@
uid://bxj3puinklg62
+17
View File
@@ -0,0 +1,17 @@
@tool
extends PanelContainer
@export var title := "RESERVED":
set(value):
title = value
if is_node_ready():
%Title.text = value
@export_multiline var description := "":
set(value):
description = value
if is_node_ready():
%Description.text = value
func _ready() -> void:
%Title.text = title
%Description.text = description
+1
View File
@@ -0,0 +1 @@
uid://btaf1ke22k5ij
+35
View File
@@ -0,0 +1,35 @@
extends RefCounted
static func read_json(path: String) -> Variant:
var file := FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > 1048576:
return null
var parser := JSON.new()
if parser.parse(file.get_as_text()) != OK:
return null
return parser.data
static func write_json(path: String, document: Dictionary) -> Error:
var absolute := ProjectSettings.globalize_path(path)
var error := DirAccess.make_dir_recursive_absolute(absolute.get_base_dir())
if error != OK:
return error
var temporary := absolute + ".tmp"
var file := FileAccess.open(temporary, FileAccess.WRITE)
if file == null:
return FileAccess.get_open_error()
file.store_string(JSON.stringify(document, "\t") + "\n")
file.flush()
error = file.get_error()
file.close()
if error != OK:
return error
return DirAccess.rename_absolute(temporary, absolute)
static func integer_array(value: Variant, length: int) -> bool:
if not value is Array or value.size() != length:
return false
for number in value:
if not (number is int or number is float) or not is_finite(float(number)) or float(number) != floorf(float(number)):
return false
return true
+1
View File
@@ -0,0 +1 @@
uid://ceg5mij8na3qr