diff --git a/components/selectable_container.tscn b/components/selectable_container.tscn new file mode 100644 index 0000000..74b621f --- /dev/null +++ b/components/selectable_container.tscn @@ -0,0 +1,54 @@ +[gd_scene format=3 uid="uid://s7f003dyd60i"] + +[ext_resource type="Script" uid="uid://cxerh1gbkrop2" path="res://scripts/selectable_container.gd" id="1_focus"] + +[sub_resource type="LabelSettings" id="LabelSettings_0fpm5"] +font_size = 24 + +[node name="SelectableContainer" type="MarginContainer" unique_id=903061831] +anchors_preset = -1 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 4.0 +offset_top = 4.0 +offset_right = -4.0 +offset_bottom = -4.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_focus") + +[node name="PanelContainer" type="PanelContainer" parent="." unique_id=555833581] +layout_mode = 2 + +[node name="MarginContainer" type="MarginContainer" parent="." unique_id=701103853] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 16 + +[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer" unique_id=1200250605] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=157590431] +layout_mode = 2 +size_flags_vertical = 0 + +[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/HBoxContainer" unique_id=1765103253] +layout_mode = 2 +size_flags_horizontal = 0 +size_flags_vertical = 1 +text = "Panel" +label_settings = SubResource("LabelSettings_0fpm5") + +[node name="Button" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer" unique_id=519793654] +layout_mode = 2 +size_flags_horizontal = 10 +text = "F1" + +[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer" unique_id=1046926303] +layout_mode = 2 + +[node name="Content" type="VBoxContainer" parent="MarginContainer/VBoxContainer" unique_id=1706387279] +layout_mode = 2 +size_flags_vertical = 3 diff --git a/components/terminal_panel.tscn b/components/terminal_panel.tscn new file mode 100644 index 0000000..0c380e0 --- /dev/null +++ b/components/terminal_panel.tscn @@ -0,0 +1,26 @@ +[gd_scene format=3 uid="uid://chflnxm5486kc"] + +[ext_resource type="PackedScene" uid="uid://s7f003dyd60i" path="res://components/selectable_container.tscn" id="1_panel"] +[ext_resource type="Script" uid="uid://cjghwf3b858xu" path="res://scripts/terminal_panel.gd" id="2_terminal"] + +[node name="TerminalPanel" unique_id=690518475 instance=ExtResource("1_panel")] +script = ExtResource("2_terminal") +title = "ROV terminal" +initial_focus = NodePath("Command") + +[node name="Label" parent="MarginContainer/VBoxContainer/HBoxContainer" parent_id_path=PackedInt32Array(157590431) index="0" unique_id=1765103253] +text = "ROV terminal" + +[node name="Output" type="RichTextLabel" parent="." index="2" unique_id=1341181181] +custom_minimum_size = Vector2(260, 100) +layout_mode = 2 +size_flags_vertical = 3 +focus_mode = 2 +text = "SSH transport not connected. +Press the panel shortcut, then type a command. +" +scroll_following = true + +[node name="Command" type="LineEdit" parent="." index="3" unique_id=701067863] +layout_mode = 2 +placeholder_text = "Enter command…" diff --git a/project.godot b/project.godot index 555a0de..65e9a95 100644 --- a/project.godot +++ b/project.godot @@ -11,8 +11,7 @@ config_version=5 [application] config/name="SurfaceSide-UI" -run/main_scene="uid://63m7uewe6luk" -config/features=PackedStringArray("4.6", "Forward Plus") +config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" [display] @@ -21,6 +20,10 @@ window/size/mode=2 window/subwindows/embed_subwindows=false window/vsync/vsync_mode=3 +[gui] + +theme/custom="res://themes/theme.tres" + [physics] 3d/physics_engine="Jolt Physics" @@ -28,3 +31,4 @@ window/vsync/vsync_mode=3 [rendering] rendering_device/driver.windows="d3d12" +environment/defaults/default_clear_color=Color(0.067, 0.055, 0.067, 1) diff --git a/scripts/selectable_container.gd b/scripts/selectable_container.gd new file mode 100644 index 0000000..4aaaeec --- /dev/null +++ b/scripts/selectable_container.gd @@ -0,0 +1,152 @@ +@tool +class_name SelectableContainer +extends MarginContainer + +signal activated +signal deactivated + +@export var title: String = "Panel": + set(value): + title = value + _refresh_header() +@export var focus_key: Key = KEY_F1: + set(value): + focus_key = value + _refresh_header() +@export_node_path("Control") var initial_focus: NodePath + +var is_active: bool = false +var _last_focus: Control +var _shortcut_available: bool = true +var _initial_focus_control: Control +@onready var content: VBoxContainer = $MarginContainer/VBoxContainer/Content +@onready var _panel: PanelContainer = $PanelContainer +@onready var _shortcut: Button = $MarginContainer/VBoxContainer/HBoxContainer/Button + +func _ready() -> void: + _refresh_header() + if Engine.is_editor_hint(): + return + # Resolve authored paths before reparenting changes the runtime hierarchy. + _initial_focus_control = get_node_or_null(initial_focus) as Control + for child in get_children(): + _route_content_child(child) + child_entered_tree.connect(_on_child_entered_tree) + add_to_group("keyboard_focus_containers") + focus_mode = Control.FOCUS_NONE + _shortcut.focus_mode = Control.FOCUS_NONE + _shortcut.pressed.connect(activate) + for other in get_tree().get_nodes_in_group("keyboard_focus_containers"): + if other != self and other.get_viewport() == get_viewport() and other.focus_key == focus_key and other._shortcut_available: + _shortcut_available = false + _shortcut.disabled = true + _shortcut.tooltip_text = "Shortcut already assigned to " + other.title + push_warning("Duplicate panel shortcut: " + OS.get_keycode_string(focus_key)) + break + get_viewport().gui_focus_changed.connect(_on_focus_changed) + visibility_changed.connect(_on_visibility_changed) + _update_style() + content.focus_behavior_recursive = Control.FOCUS_BEHAVIOR_DISABLED + +func _refresh_header() -> void: + var label := get_node_or_null("MarginContainer/VBoxContainer/HBoxContainer/Label") as Label + var badge := get_node_or_null("MarginContainer/VBoxContainer/HBoxContainer/Button") as Button + if label != null: + label.text = title + if badge != null: + badge.text = OS.get_keycode_string(focus_key) + (" · ACTIVE" if is_active else "") + +func _on_child_entered_tree(child: Node) -> void: + # SceneTree may still be adding the node when this signal fires. + _route_content_child.call_deferred(child) + +func _route_content_child(child: Node) -> void: + if not is_instance_valid(child) or child.get_parent() != self: + return + if child == _panel or child == $MarginContainer: + return + child.reparent(content, false) + +func activate() -> void: + if Engine.is_editor_hint() or not is_visible_in_tree() or not _shortcut_available: + return + for other in get_tree().get_nodes_in_group("keyboard_focus_containers"): + if other != self and other.get_viewport() == get_viewport(): + other.deactivate() + var was_active := is_active + is_active = true + focus_mode = Control.FOCUS_ALL + content.focus_behavior_recursive = Control.FOCUS_BEHAVIOR_ENABLED + _update_style() + var target: Control = _initial_focus_control if is_instance_valid(_initial_focus_control) else null + if is_instance_valid(_last_focus) and _last_focus.is_visible_in_tree(): + target = _last_focus + if target != null and target.is_visible_in_tree() and target.get_focus_mode_with_override() != Control.FOCUS_NONE: + target.grab_focus() + else: + var controls := _focusable_controls(content) + if controls.is_empty(): + grab_focus() + else: + controls[0].grab_focus() + if not was_active: + activated.emit() + +func deactivate() -> void: + if not is_active: + return + is_active = false + focus_mode = Control.FOCUS_NONE + var focus_owner := get_viewport().gui_get_focus_owner() + if focus_owner == self or (focus_owner != null and is_ancestor_of(focus_owner)): + focus_owner.release_focus() + content.focus_behavior_recursive = Control.FOCUS_BEHAVIOR_DISABLED + _update_style() + deactivated.emit() + +func _input(event: InputEvent) -> void: + if Engine.is_editor_hint() or not event is InputEventKey or not is_visible_in_tree(): + return + var key := event as InputEventKey + if key.keycode == focus_key and not key.alt_pressed and not key.ctrl_pressed and not key.meta_pressed and not key.shift_pressed and _shortcut_available: + # Consume releases and repeats as well, so shortcuts never become input. + get_viewport().set_input_as_handled() + if key.pressed and not key.echo: + activate() + return + if not is_active or not key.pressed: + return + if key.keycode == KEY_ESCAPE: + deactivate() + get_viewport().set_input_as_handled() + elif key.keycode == KEY_TAB and not key.ctrl_pressed and not key.alt_pressed and not key.meta_pressed: + var controls := _focusable_controls(content) + if not controls.is_empty(): + var index := controls.find(get_viewport().gui_get_focus_owner()) + var step := -1 if key.shift_pressed else 1 + controls[posmod(index + step, controls.size())].grab_focus() + get_viewport().set_input_as_handled() + +func _focusable_controls(node: Node) -> Array[Control]: + var result: Array[Control] = [] + for child in node.get_children(): + if child is Control and child.is_visible_in_tree() and child.get_focus_mode_with_override() == Control.FOCUS_ALL: + if not (child is BaseButton and child.disabled): + result.append(child) + result.append_array(_focusable_controls(child)) + return result + +func _on_focus_changed(control: Control) -> void: + if is_active and control != null and is_ancestor_of(control): + _last_focus = control + elif is_active and control != self: + deactivate() + +func _on_visibility_changed() -> void: + if not is_visible_in_tree(): + deactivate() + +func _update_style() -> void: + var style_name := "panel_active" if is_active else "panel_inactive" + _panel.add_theme_stylebox_override("panel", get_theme_stylebox(style_name, "SurfaceSide")) + _refresh_header() diff --git a/scripts/selectable_container.gd.uid b/scripts/selectable_container.gd.uid new file mode 100644 index 0000000..136a7f5 --- /dev/null +++ b/scripts/selectable_container.gd.uid @@ -0,0 +1 @@ +uid://cxerh1gbkrop2 diff --git a/scripts/terminal_panel.gd b/scripts/terminal_panel.gd new file mode 100644 index 0000000..6f9de52 --- /dev/null +++ b/scripts/terminal_panel.gd @@ -0,0 +1,25 @@ +@tool +extends SelectableContainer +signal command_requested(command: String) + +@onready var output: RichTextLabel = $Output +@onready var command_input: LineEdit = $Command + +func _ready() -> void: + super._ready() + if Engine.is_editor_hint(): + return + command_input.text_submitted.connect(_submit) + +func _submit(command: String) -> void: + if command.strip_edges().is_empty(): + return + append_output("$ " + command + "\n") + command_input.clear() + if command_requested.get_connections().is_empty(): + append_output("No SSH transport connected. Command was not sent.\n") + else: + command_requested.emit(command) + +func append_output(text: String) -> void: + output.add_text(text) diff --git a/scripts/terminal_panel.gd.uid b/scripts/terminal_panel.gd.uid new file mode 100644 index 0000000..9bc9fbe --- /dev/null +++ b/scripts/terminal_panel.gd.uid @@ -0,0 +1 @@ +uid://cjghwf3b858xu