Added a dynamic layout system where UI elements can be moved about on a tiled grid.
This commit is contained in:
1 parent
ea6ba54666
commit
26c29df4ad
34 files changed
+1269
No files matched your search
@@ -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
|
||||
@@ -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 = "×"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://k6buaitag78o
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqrapdh6yddl8
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://0hiij8bwyn7w
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwbk5g4yhdfd6
|
||||
@@ -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"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://n4eb1a6j5j88
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://byjnf0ae0jr2y
|
||||
@@ -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."
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwdwpabj62mhl
|
||||
@@ -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"))
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4g15anvgpot8
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxj3puinklg62
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://btaf1ke22k5ij
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ceg5mij8na3qr
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://beaucnaf4ol72
|
||||
@@ -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
|
||||
Reference in new issue
Block a user