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

+35
View File
@@ -0,0 +1,35 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://scripts/camera_layout_frame.gd" id="1"]
[node name="CameraSlot" type="VBoxContainer"]
theme_override_constants/separation = 8
[node name="Header" type="HBoxContainer" parent="."]
layout_mode = 2
[node name="Title" type="Label" parent="Header"]
layout_mode = 2
size_flags_horizontal = 3
text = "CAMERA / PRIMARY"
theme_override_font_sizes/font_size = 20
[node name="State" type="Label" parent="Header"]
layout_mode = 2
text = "NO STREAM"
[node name="Frame" type="AspectRatioContainer" parent="."]
unique_name_in_owner = true
layout_mode = 2
ratio = 1.7777778
script = ExtResource("1")
[node name="Placeholder" type="PanelContainer" parent="Frame"]
layout_mode = 2
[node name="Label" type="Label" parent="Frame/Placeholder"]
layout_mode = 2
text = "16:9 CAMERA\nReserved video surface"
horizontal_alignment = 1
vertical_alignment = 1
theme_override_font_sizes/font_size = 24
+52
View File
@@ -0,0 +1,52 @@
[gd_scene format=3 uid="uid://dscdhba74otou"]
[ext_resource type="Script" uid="uid://bqrapdh6yddl8" path="res://scripts/layout_canvas.gd" id="1"]
[ext_resource type="Script" uid="uid://cwbk5g4yhdfd6" path="res://scripts/layout_edit_overlay.gd" id="2"]
[node name="LayoutCanvas" type="Control" unique_id=325333537]
clip_contents = true
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1")
legacy_config_path = null
[node name="EditOverlay" type="Control" parent="." unique_id=162710218]
visible = false
z_index = 120
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
mouse_filter = 2
script = ExtResource("2")
[node name="Actions" type="HBoxContainer" parent="EditOverlay" unique_id=846269020]
visible = false
layout_mode = 0
offset_right = 112.0
offset_bottom = 36.0
theme_override_constants/separation = 2
[node name="Settings" type="Button" parent="EditOverlay/Actions" unique_id=1387368158]
custom_minimum_size = Vector2(36, 36)
layout_mode = 2
tooltip_text = "Settings (not available yet)"
disabled = true
text = "⚙"
[node name="Resize" type="Button" parent="EditOverlay/Actions" unique_id=1301115821]
custom_minimum_size = Vector2(36, 36)
layout_mode = 2
tooltip_text = "Drag to resize"
text = "↘"
[node name="Delete" type="Button" parent="EditOverlay/Actions" unique_id=1936216385]
custom_minimum_size = Vector2(36, 36)
layout_mode = 2
tooltip_text = "Remove panel from layout"
text = "×"
+12
View File
@@ -0,0 +1,12 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://scripts/layout_item.gd" id="1"]
[node name="LayoutItem" type="Control"]
layout_mode = 0
script = ExtResource("1")
[node name="Background" type="Panel" parent="."]
layout_mode = 0
mouse_filter = 2
theme_type_variation = &"LayoutItemBackground"
+66
View File
@@ -0,0 +1,66 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://scripts/layout_library.gd" id="1"]
[node name="LayoutLibrary" type="PanelContainer"]
visible = false
z_index = 300
layout_mode = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -424.0
offset_top = 80.0
offset_right = -24.0
offset_bottom = 720.0
theme_type_variation = &"VehicleBackground"
script = ExtResource("1")
[node name="Margin" type="MarginContainer" parent="."]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_right = 16
theme_override_constants/margin_top = 12
theme_override_constants/margin_bottom = 12
[node name="Body" type="VBoxContainer" parent="Margin"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="Header" type="HBoxContainer" parent="Margin/Body"]
layout_mode = 2
[node name="Title" type="Label" parent="Margin/Body/Header"]
layout_mode = 2
size_flags_horizontal = 3
text = "ELEMENT LIBRARY"
theme_override_font_sizes/font_size = 20
[node name="Close" type="Button" parent="Margin/Body/Header"]
layout_mode = 2
text = "×"
tooltip_text = "Close library"
[node name="List" type="ItemList" parent="Margin/Body"]
custom_minimum_size = Vector2(0, 180)
layout_mode = 2
size_flags_vertical = 3
theme_type_variation = &"LayoutLibraryList"
same_column_width = true
fixed_icon_size = Vector2i(24, 24)
[node name="Description" type="Label" parent="Margin/Body"]
custom_minimum_size = Vector2(0, 78)
layout_mode = 2
autowrap_mode = 3
[node name="Size" type="Label" parent="Margin/Body"]
layout_mode = 2
[node name="Status" type="Label" parent="Margin/Body"]
layout_mode = 2
autowrap_mode = 3
[node name="Add" type="Button" parent="Margin/Body"]
layout_mode = 2
text = "Add element"
tooltip_text = "Place in the first available space, using the minimum size if needed"
+81
View File
@@ -0,0 +1,81 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://scripts/layout_overview.gd" id="1"]
[ext_resource type="Script" path="res://scripts/layout_grid.gd" id="2"]
[ext_resource type="Shader" path="res://shaders/layout_grid.gdshader" id="3"]
[ext_resource type="PackedScene" path="res://components/layout_library.tscn" id="library"]
[sub_resource type="ShaderMaterial" id="GridMaterial"]
resource_local_to_scene = true
shader = ExtResource("3")
[node name="LayoutOverview" type="Control"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")
[node name="Dashboard" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Grid" type="ColorRect" parent="Dashboard"]
visible = false
material = SubResource("GridMaterial")
z_index = 100
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
script = ExtResource("2")
[node name="Toolbar" type="HBoxContainer" parent="."]
visible = false
z_index = 200
layout_mode = 1
anchor_right = 1.0
offset_left = 24.0
offset_top = 16.0
offset_right = -24.0
offset_bottom = 60.0
mouse_filter = 2
theme_override_constants/separation = 20
[node name="Title" type="Label" parent="Toolbar"]
layout_mode = 2
size_flags_horizontal = 3
mouse_filter = 2
theme_override_font_sizes/font_size = 20
text = "LAYOUT OVERVIEW"
[node name="Hint" type="Label" parent="Toolbar"]
layout_mode = 2
mouse_filter = 2
text = "Esc to return"
[node name="Add" type="Button" parent="Toolbar"]
layout_mode = 2
text = "Add element"
[node name="Reset" type="Button" parent="Toolbar"]
layout_mode = 2
text = "Reset layout"
[node name="Return" type="Button" parent="Toolbar"]
layout_mode = 2
text = "Return to dashboard"
[node name="Library" parent="." instance=ExtResource("library")]
layout_mode = 1
+30
View File
@@ -0,0 +1,30 @@
[gd_scene format=3 uid="uid://u014ihs685mq"]
[ext_resource type="Script" uid="uid://btaf1ke22k5ij" path="res://scripts/layout_slot.gd" id="1"]
[node name="LayoutSlot" type="PanelContainer" unique_id=241401501]
custom_minimum_size = Vector2(0, 100)
theme_type_variation = &"VehicleBackground"
script = ExtResource("1")
[node name="Margin" type="MarginContainer" parent="." unique_id=1948608899]
layout_mode = 2
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 12
[node name="Text" type="VBoxContainer" parent="Margin" unique_id=764454736]
layout_mode = 2
theme_override_constants/separation = 12
[node name="Title" type="Label" parent="Margin/Text" unique_id=806114057]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 20
text = "RESERVED"
[node name="Description" type="Label" parent="Margin/Text" unique_id=592619421]
unique_name_in_owner = true
layout_mode = 2
autowrap_mode = 3
+48
View File
@@ -0,0 +1,48 @@
# Display layouts
Run either scene with F6:
- `scenes/layout_landscape.tscn`: 1920×1080, 32×18 grid.
- `scenes/layout_dual_portrait.tscn`: two independent 1080×1920 windows, each 18×32. With two portrait monitors detected, each window is maximized on its own screen; otherwise, smaller windows open side by side. Disable `use_portrait_monitors` to force the windowed preview.
The reference cell size is 60px. Window scaling is uniform and independent of the project's stretch scale. Physical monitor placement still needs checking on the target hardware.
## Editing
Hover briefly in the top-left corner to enter overview. The trigger uses a 20-pixel window area, including letterboxing, independently of UI scale. In dual portrait, only the left window owns the hot corner; both windows enter overview together.
**Add element** opens a scrollable library. Select an entry and click Add element (or double-click it). Panels use the first free space, falling back to their minimum size if needed. Multiple instances are supported; a full grid leaves the library open with a no-space message.
Select and drag a panel to move it. Selected panels show three buttons:
- **↘**: drag to resize from the bottom-right edge.
- **×**: remove the panel from this layout.
- **⚙**: settings, currently disabled.
Moves and sizes snap to cells. Occupied space, minimum sizes, and window boundaries block invalid placements; invalid drops return to their starting position. Escape or leaving overview cancels an unfinished drag. Panels stay within their own window.
Changes save automatically as JSON under `user://layouts/`: `landscape.json`, `portrait_left.json`, and `portrait_right.json`. Godot resolves `user://` to the application's user-data directory (the XDG data location on Linux), available from `OS.get_user_data_dir()`. To store files beside a writable application instead, set each canvas's `config_path` to an absolute path in that folder. Existing `.cfg` layouts migrate on first load when no JSON file exists; the originals are retained. **Reset layout** restores default positions, sizes, and deleted panels. Exit with Escape, Return to dashboard, or a click outside the preview.
## JSON format
```json
{
"version": 1,
"grid": [32, 18],
"elements": [
{"id": "camera", "type": "camera", "rect": [0, 0, 16, 10]}
]
}
```
The elements array is the complete active layout; omitted default panels stay deleted. IDs distinguish repeated instances. Writes use a temporary file followed by rename. Invalid types, duplicate IDs, overlaps, and out-of-bounds rectangles are rejected before changing the displayed layout.
## Scene structure
`layout_overview.tscn` owns the shader grid and toolbar. Each `layout_canvas.tscn` places its panel children using `layout_item.gd`; set their IDs, cell rectangles, and minimum sizes in the Inspector. Existing UI scenes remain under each item's `Content` node. Each allocation has a full-size panel background with a shared `panel_gap` (16 logical pixels by default). Instruments scale uniformly inside that frame; reserved slots fill it. Embedded component backgrounds are transparent so their scaling does not change the visible gutters. The camera retains its 16:9 picture inside the camera/header allocation.
The initial layouts deliberately leave free cells for rearranging. Environment, VR/video, and operator tools are placeholders. The Link panel has a separate [telemetry demo](link_panel.md). Camera and telemetry feeds are not connected.
The library registry is `scripts/layout_catalog.gd`. Each entry supplies a scene, display text, preferred/minimum cells and sizing behavior. JSON stores registered type IDs rather than resource paths. `components/layout_library.tscn` owns the drawer; selection button order is unchanged.
`LayoutItemBackground` supplies the shared outer frame. Components with a separate background node can specify `background_path` on their item (or `background` in the catalog entry); root PanelContainers are embedded automatically. Standalone component scenes retain their own styling.
+49
View File
@@ -0,0 +1,49 @@
[gd_scene format=3]
[ext_resource type="PackedScene" path="res://scenes/display_left.tscn" id="left"]
[ext_resource type="PackedScene" path="res://scenes/display_right.tscn" id="right"]
[ext_resource type="Script" path="res://scripts/layout_preview.gd" id="preview"]
[ext_resource type="PackedScene" path="res://components/layout_overview.tscn" id="overview"]
[node name="DualPortraitLayout" type="Control"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("preview")
configuration = 1
[node name="Overview" parent="." instance=ExtResource("overview")]
layout_mode = 1
grid_size = Vector2i(18, 32)
[node name="Canvas" parent="Overview/Dashboard" instance=ExtResource("left")]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="RightWindow" type="Window" parent="."]
title = "ROV / right portrait"
visible = false
size = Vector2i(540, 960)
content_scale_size = Vector2i(1080, 1920)
content_scale_mode = 1
content_scale_aspect = 1
[node name="RightOverview" parent="RightWindow" instance=ExtResource("overview")]
layout_mode = 1
hot_corner_enabled = false
grid_size = Vector2i(18, 32)
[node name="Canvas" parent="RightWindow/RightOverview/Dashboard" instance=ExtResource("right")]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
+107
View File
@@ -0,0 +1,107 @@
[gd_scene format=3]
[ext_resource type="PackedScene" path="res://components/layout_canvas.tscn" id="canvas"]
[ext_resource type="PackedScene" path="res://components/layout_item.tscn" id="item"]
[ext_resource type="PackedScene" path="res://components/camera_layout_slot.tscn" id="camera"]
[ext_resource type="PackedScene" path="res://components/vehicle_status_panel.tscn" id="vehicle"]
[ext_resource type="PackedScene" path="res://components/motion_panel.tscn" id="motion"]
[ext_resource type="PackedScene" path="res://components/layout_slot.tscn" id="slot"]
[ext_resource type="Script" path="res://scripts/layout_preview.gd" id="preview"]
[ext_resource type="PackedScene" path="res://components/layout_overview.tscn" id="overview"]
[ext_resource type="PackedScene" path="res://components/link_panel.tscn" id="link"]
[node name="LandscapeLayout" type="Control"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("preview")
[node name="Overview" parent="." instance=ExtResource("overview")]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Canvas" parent="Overview/Dashboard" instance=ExtResource("canvas")]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
config_path = "user://layouts/landscape.json"
legacy_config_path = "user://layout_landscape.cfg"
[node name="Camera" parent="Overview/Dashboard/Canvas" instance=ExtResource("item")]
layout_mode = 0
panel_id = "camera"
cell_rect = Rect2i(0, 0, 16, 10)
minimum_cells = Vector2i(8, 5)
reference_size = Vector2(960, 600)
[node name="Content" parent="Overview/Dashboard/Canvas/Camera" instance=ExtResource("camera")]
layout_mode = 0
[node name="Vehicle" parent="Overview/Dashboard/Canvas" instance=ExtResource("item")]
layout_mode = 0
panel_id = "vehicle"
cell_rect = Rect2i(16, 0, 10, 8)
minimum_cells = Vector2i(6, 4)
reference_size = Vector2(600, 440)
[node name="Content" parent="Overview/Dashboard/Canvas/Vehicle" instance=ExtResource("vehicle")]
layout_mode = 0
[node name="Motion" parent="Overview/Dashboard/Canvas" instance=ExtResource("item")]
layout_mode = 0
panel_id = "motion"
cell_rect = Rect2i(16, 8, 10, 7)
minimum_cells = Vector2i(6, 4)
reference_size = Vector2(600, 410)
background_path = NodePath("Content/Background")
[node name="Content" parent="Overview/Dashboard/Canvas/Motion" instance=ExtResource("motion")]
layout_mode = 0
[node name="Environment" parent="Overview/Dashboard/Canvas" instance=ExtResource("item")]
layout_mode = 0
panel_id = "environment"
cell_rect = Rect2i(26, 0, 6, 8)
minimum_cells = Vector2i(4, 3)
reference_size = Vector2(360, 240)
fill_allocation = true
[node name="Content" parent="Overview/Dashboard/Canvas/Environment" instance=ExtResource("slot")]
layout_mode = 0
title = "ENVIRONMENT"
description = "Depth · pressure · water temperature"
[node name="VideoVR" parent="Overview/Dashboard/Canvas" instance=ExtResource("item")]
layout_mode = 0
panel_id = "videovr"
cell_rect = Rect2i(0, 10, 16, 8)
minimum_cells = Vector2i(6, 4)
reference_size = Vector2(600, 400)
fill_allocation = true
[node name="Content" parent="Overview/Dashboard/Canvas/VideoVR" instance=ExtResource("slot")]
layout_mode = 0
title = "VIDEO / VR"
description = "Latency breakdown · headset · resolution"
[node name="Link" parent="Overview/Dashboard/Canvas" instance=ExtResource("item")]
layout_mode = 0
panel_id = "link"
cell_rect = Rect2i(26, 8, 6, 4)
minimum_cells = Vector2i(4, 3)
reference_size = Vector2(360, 240)
[node name="Content" parent="Overview/Dashboard/Canvas/Link" instance=ExtResource("link")]
layout_mode = 0
+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
+30
View File
@@ -0,0 +1,30 @@
shader_type canvas_item;
render_mode unshaded;
uniform vec2 grid_size = vec2(32.0, 18.0);
uniform vec4 line_color : source_color;
uniform vec4 boundary_color : source_color;
uniform float monitor_boundary = 0.0;
float grid_coverage(vec2 coordinate, float width) {
vec2 distance_to_line = abs(fract(coordinate + 0.5) - 0.5);
vec2 pixels = distance_to_line / max(fwidth(coordinate), vec2(0.00001));
vec2 coverage = 1.0 - smoothstep(vec2(width - 0.5), vec2(width + 0.5), pixels);
return max(coverage.x, coverage.y);
}
void fragment() {
vec2 cells = UV * grid_size;
float minor = grid_coverage(cells, 0.6) * 0.4;
float major = grid_coverage(cells / 4.0, 0.8) * 0.85;
vec2 edge_pixels = min(UV, 1.0 - UV) / max(fwidth(UV), vec2(0.00001));
float border = 1.0 - smoothstep(0.5, 1.5, min(edge_pixels.x, edge_pixels.y));
float alpha = max(max(minor, major), border);
vec4 result = vec4(line_color.rgb, line_color.a * alpha);
if (monitor_boundary > 0.0 && monitor_boundary < grid_size.x) {
float distance_pixels = abs(cells.x - monitor_boundary) / max(fwidth(cells.x), 0.00001);
float boundary = 1.0 - smoothstep(1.0, 2.0, distance_pixels);
result = mix(result, boundary_color, boundary);
}
COLOR = result;
}
+1
View File
@@ -0,0 +1 @@
uid://beaucnaf4ol72
+7
View File
@@ -0,0 +1,7 @@
[gd_resource type="StyleBoxEmpty" format=3]
[resource]
content_margin_left = 0.0
content_margin_top = 0.0
content_margin_right = 0.0
content_margin_bottom = 0.0