Added console panel with device and host console, logs and domain console.

This commit is contained in:
RedNicStone committed 2026-11-09 21:15:57 +01:00
1 parent 6038b816b7
commit b98ed82619
17 files changed
+551 -22

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
extends VBoxContainer
@onready var console = $ConsolePanel
var _log_index := 0
func _ready() -> void:
console.command_requested.connect(_demo_command)
for id in ["rov", "host", "logs", "server"]:
console.set_connection(id, true, "SIMULATED · " + id)
console.append_output("rov", "[demo] ROV shell ready. Try help, pwd or status.\n")
console.append_output("host", "[demo] Host shell ready. Try help or pwd.\n")
console.append_output("server", "[demo] Server shell ready. Try help or status.\n")
$Controls/Contrast.toggled.connect(func(high: bool) -> void:
theme = load("res://themes/theme_highcontrast.tres" if high else "res://themes/theme.tres")
)
$Controls/Feed.toggled.connect(func(enabled: bool) -> void: $LogTimer.paused = not enabled)
$LogTimer.timeout.connect(_append_log)
_append_log()
func _demo_command(id: String, command: String) -> void:
match command.strip_edges():
"help": console.append_output(id, "Demo commands: help, status, pwd, clear\n")
"status": console.append_output(id, "[demo] Vehicle ready · stationkeep · no live transport\n")
"pwd": console.append_output(id, "/" + id + " (simulated)\n")
"clear": console.session(id).clear_output()
_: console.append_output(id, "[demo] Received: " + command + "\n")
func _append_log() -> void:
_log_index += 1
var level := "WARN" if _log_index % 5 == 0 else "INFO"
var message := "sample telemetry delayed" if level == "WARN" else "sample telemetry received"
console.append_output("logs", "%04d %s rov-server: %s\n" % [_log_index, level, message])
+1
View File
@@ -0,0 +1 @@
uid://blo11s7dqe2b
+104
View File
@@ -0,0 +1,104 @@
@tool
extends SelectableContainer
signal command_requested(session_id: String, command: String)
@onready var tabs: TabContainer = %Tabs
func _ready() -> void:
super._ready()
_refresh_header()
if Engine.is_editor_hint():
return
tabs.get_tab_bar().focus_mode = Control.FOCUS_NONE
tabs.get_tab_bar().gui_input.connect(func(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed: activate()
)
tabs.tab_changed.connect(func(_index: int) -> void:
_last_focus = null
activate()
)
for page in _pages():
page.command_submitted.connect(_route_command.bind(page.session_id))
page.focus_requested.connect(activate)
func _pages() -> Array[Control]:
var result: Array[Control] = []
var node := get_node_or_null("%Tabs")
if node != null:
for child in node.get_children():
if child.has_method("submit_command"): result.append(child)
return result
func get_focus_shortcuts() -> Array[Key]:
var keys: Array[Key] = []
for page in _pages():
if page.shortcut != KEY_NONE: keys.append(page.shortcut)
return keys
func _refresh_header() -> void:
super._refresh_header()
var node := get_node_or_null("%Tabs") as TabContainer
if node == null:
return
var seen: Dictionary = {}
for index in range(node.get_tab_count()):
var page := node.get_tab_control(index)
if not page.has_method("submit_command"): continue
var key: Key = page.shortcut
var available := is_shortcut_available(key) and not seen.has(key)
var caption: String = page.tab_label
if key != KEY_NONE:
caption += " · " + OS.get_keycode_string(key) + (" (in use)" if not available else "")
seen[key] = true
node.set_tab_title(index, caption)
node.set_tab_tooltip(index, "Focus " + page.tab_label if available else "Shortcut already used by another panel or tab")
func activate() -> void:
super.activate()
if is_active:
var page := tabs.get_current_tab_control()
if page != null:
var target: Control = page.preferred_input()
if target.is_visible_in_tree(): target.grab_focus()
func activate_shortcut(key: Key) -> void:
for index in range(tabs.get_tab_count()):
if tabs.get_tab_control(index).shortcut == key:
tabs.current_tab = index
_last_focus = null
activate()
return
func _input(event: InputEvent) -> void:
if not Engine.is_editor_hint() and is_active and event is InputEventKey:
if event.ctrl_pressed and not event.alt_pressed and not event.meta_pressed and event.keycode in [KEY_TAB, KEY_L]:
get_viewport().set_input_as_handled()
if event.pressed and not event.echo:
if event.keycode == KEY_TAB:
tabs.current_tab = wrapi(tabs.current_tab + (-1 if event.shift_pressed else 1), 0, tabs.get_tab_count())
else:
tabs.get_current_tab_control().clear_output()
return
super._input(event)
func session(id: String) -> Control:
for page in _pages():
if page.session_id == id: return page
return null
func append_output(id: String, text: String) -> void:
var page := session(id)
if page != null: page.append_output(text)
func set_connection(id: String, connected: bool, message: String = "") -> void:
var page := session(id)
if page != null: page.set_connection(connected, message)
func _route_command(command: String, id: String) -> void:
var page := session(id)
if page == null: return
if not page.connected or command_requested.get_connections().is_empty():
page.append_output("Not connected; command was not sent.\n")
else:
command_requested.emit(id, command)
+1
View File
@@ -0,0 +1 @@
uid://dcxkla5mcrxmx
+106
View File
@@ -0,0 +1,106 @@
@tool
extends VBoxContainer
signal command_submitted(command: String)
signal focus_requested
@export var session_id := "rov"
@export var tab_label := "ROV"
@export var shortcut: Key = KEY_F5
@export var prompt := "rov $"
@export var read_only := false
@export var introduction := "ROV shell disconnected."
@export var max_scrollback_characters := 128000
var connected := false
var _buffer := ""
var _history: Array[String] = []
var _history_index := 0
var _draft := ""
@onready var output: RichTextLabel = $Output
@onready var command_input: LineEdit = $Input/Command
@onready var filter_input: LineEdit = $Filter/Text
func _ready() -> void:
$Input.visible = not read_only
$Filter.visible = read_only
$Input/Prompt.text = prompt
_buffer = introduction + "\n"
output.text = _buffer
if Engine.is_editor_hint():
return
command_input.text_submitted.connect(submit_command)
command_input.gui_input.connect(_history_input)
filter_input.text_changed.connect(func(_text: String) -> void: _render_output())
$Status/Clear.pressed.connect(clear_output)
$Status/Follow.toggled.connect(func(enabled: bool) -> void:
output.scroll_following = enabled
if enabled: output.scroll_to_line(maxi(output.get_line_count() - 1, 0))
)
for control in [output, command_input, filter_input, $Status/Clear, $Status/Follow]:
control.gui_input.connect(func(event: InputEvent) -> void:
if event is InputEventMouseButton and event.pressed:
focus_requested.emit()
)
func preferred_input() -> Control:
return filter_input if read_only else command_input
func set_connection(value: bool, message: String = "") -> void:
connected = value
$Status/Connection.text = message if not message.is_empty() else ("Connected" if value else "Not connected")
func submit_command(command: String) -> bool:
if read_only or command.strip_edges().is_empty():
return false
if _history.is_empty() or _history.back() != command:
_history.append(command)
if _history.size() > 200: _history.pop_front()
_history_index = _history.size()
_draft = ""
append_output(prompt + " " + command + "\n")
command_input.clear()
command_submitted.emit(command)
return true
func append_output(text: String) -> void:
_buffer += text
var truncated := _buffer.length() > max_scrollback_characters
if truncated:
_buffer = _buffer.right(max_scrollback_characters)
var newline := _buffer.find("\n")
if newline >= 0 and newline < _buffer.length() - 1:
_buffer = _buffer.substr(newline + 1)
if truncated or (read_only and not filter_input.text.is_empty()):
_render_output()
else:
output.add_text(text)
func clear_output() -> void:
_buffer = ""
output.clear()
func _render_output() -> void:
var scroll := output.get_v_scroll_bar().value
if read_only and not filter_input.text.is_empty():
var matches := PackedStringArray()
for line in _buffer.split("\n"):
if line.to_lower().contains(filter_input.text.to_lower()):
matches.append(line)
output.text = "\n".join(matches)
else:
output.text = _buffer
if not $Status/Follow.button_pressed:
output.get_v_scroll_bar().set_deferred("value", scroll)
func _history_input(event: InputEvent) -> void:
if not event is InputEventKey or not event.pressed or event.ctrl_pressed or event.alt_pressed or event.meta_pressed:
return
if event.keycode not in [KEY_UP, KEY_DOWN] or _history.is_empty():
return
if _history_index == _history.size():
_draft = command_input.text
_history_index = clampi(_history_index + (-1 if event.keycode == KEY_UP else 1), 0, _history.size())
command_input.text = _draft if _history_index == _history.size() else _history[_history_index]
command_input.caret_column = command_input.text.length()
command_input.accept_event()
+1
View File
@@ -0,0 +1 @@
uid://bvo8xa0w47jy6
+9 -3
View File
@@ -55,7 +55,7 @@ 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
_set_item_interaction(item)
if not active:
selected = null
overlay.visible = active
@@ -182,7 +182,7 @@ func add_element(element_type: String) -> Control:
_items.append(item)
item.cell_rect = rect
item.show()
item.mouse_behavior_recursive = Control.MOUSE_BEHAVIOR_DISABLED
_set_item_interaction(item)
selected = item
arrange_items()
save_layout()
@@ -297,7 +297,7 @@ func _apply_records(records: Array) -> void:
_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
_set_item_interaction(item)
arrange_items()
func _migrate_legacy() -> void:
@@ -337,3 +337,9 @@ func _update_overlay() -> void:
actions.position = selected.position + Vector2(selected.size.x - 116, 4)
overlay.show_candidate = not _operation.is_empty()
overlay.queue_redraw()
func _set_item_interaction(item: Control) -> void:
item.mouse_behavior_recursive = Control.MOUSE_BEHAVIOR_DISABLED if edit_mode else Control.MOUSE_BEHAVIOR_INHERITED
var content := item.get_node_or_null("Content")
if content != null and content.has_method("set_layout_editing"):
content.set_layout_editing(edit_mode)
+1
View File
@@ -8,6 +8,7 @@ const ENTRIES = {
"environment": {"title": "Environment", "description": "Depth with a shallow-expanded scale, outside pressure and water temperature.", "scene": preload("res://components/environment_panel.tscn"), "size": Vector2i(6, 5), "minimum": Vector2i(4, 3), "reference": Vector2(360, 240)},
"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},
"console": {"title": "Console", "description": "Tabbed ROV shell, host shell, journal logs and server shell. F5–F8 select tabs.", "scene": preload("res://components/console_panel.tscn"), "size": Vector2i(16, 9), "minimum": Vector2i(10, 6), "reference": Vector2(900, 520)},
"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},
}
+2
View File
@@ -21,6 +21,8 @@ func _ready() -> void:
var background := get_node_or_null(background_path) as Control
if background != null:
background.add_theme_stylebox_override("panel", EMBEDDED_PANEL)
if has_node("Content") and $Content.has_method("set_frame_host"):
$Content.set_frame_host($Background)
arrange_content.call_deferred()
func arrange_content() -> void:
+5
View File
@@ -2,6 +2,8 @@ extends Control
signal overview_changed(active: bool)
const FOCUS_SCOPES = preload("res://scripts/selectable_container.gd")
@export var grid_size := Vector2i(32, 18):
set(value):
grid_size = Vector2i(maxi(1, value.x), maxi(1, value.y))
@@ -43,6 +45,9 @@ func _ready() -> void:
_resize()
func _input(event: InputEvent) -> void:
if FOCUS_SCOPES.dispatch_focus_shortcut(event, get_tree()):
get_viewport().set_input_as_handled()
return
if event is InputEventMouseMotion and hot_corner_enabled:
_pointer_in_window = true
_update_corner(get_window().get_final_transform() * event.position)
+91 -18
View File
@@ -16,8 +16,12 @@ signal deactivated
@export_node_path("Control") var initial_focus: NodePath
var is_active: bool = false
var _layout_editing := false
var _registered := false
var _blocked_shortcuts: Dictionary = {}
var _frame_host: Control
const EMBEDDED_PANEL = preload("res://themes/layout_embedded_panel.tres")
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
@@ -33,16 +37,13 @@ func _ready() -> void:
_route_content_child(child)
child_entered_tree.connect(_on_child_entered_tree)
add_to_group("keyboard_focus_containers")
_registered = true
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
refresh_shortcuts(get_tree())
get_window().focus_exited.connect(deactivate)
theme_changed.connect(_update_style)
get_viewport().gui_focus_changed.connect(_on_focus_changed)
visibility_changed.connect(_on_visibility_changed)
_update_style()
@@ -54,7 +55,10 @@ func _refresh_header() -> void:
if label != null:
label.text = title
if badge != null:
badge.visible = focus_key != KEY_NONE
badge.disabled = not is_shortcut_available(focus_key)
badge.text = OS.get_keycode_string(focus_key) + (" · ACTIVE" if is_active else "")
badge.tooltip_text = "Shortcut already used by " + str(_blocked_shortcuts.get(focus_key, "")) if _blocked_shortcuts.has(focus_key) else "Focus this panel"
func _on_child_entered_tree(child: Node) -> void:
# SceneTree may still be adding the node when this signal fires.
@@ -68,11 +72,12 @@ func _route_content_child(child: Node) -> void:
child.reparent(content, false)
func activate() -> void:
if Engine.is_editor_hint() or not is_visible_in_tree() or not _shortcut_available:
if Engine.is_editor_hint() or not focus_available():
return
for other in get_tree().get_nodes_in_group("keyboard_focus_containers"):
if other != self and other.get_viewport() == get_viewport():
if other != self:
other.deactivate()
get_window().grab_focus()
var was_active := is_active
is_active = true
focus_mode = Control.FOCUS_ALL
@@ -105,15 +110,14 @@ func deactivate() -> void:
deactivated.emit()
func _input(event: InputEvent) -> void:
if Engine.is_editor_hint() or not event is InputEventKey or not is_visible_in_tree():
if Engine.is_editor_hint() or not is_visible_in_tree() or _layout_editing:
return
if dispatch_focus_shortcut(event, get_tree()):
get_viewport().set_input_as_handled()
return
if not event is InputEventKey:
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:
@@ -145,8 +149,77 @@ func _on_focus_changed(control: Control) -> void:
func _on_visibility_changed() -> void:
if not is_visible_in_tree():
deactivate()
if is_inside_tree():
refresh_shortcuts(get_tree())
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"))
if is_instance_valid(_frame_host):
_panel.add_theme_stylebox_override("panel", EMBEDDED_PANEL)
_frame_host.add_theme_stylebox_override("panel", get_theme_stylebox(style_name, "SurfaceSide"))
else:
_panel.add_theme_stylebox_override("panel", get_theme_stylebox(style_name, "SurfaceSide"))
_refresh_header()
func get_focus_shortcuts() -> Array[Key]:
return [focus_key] if focus_key != KEY_NONE else []
func is_shortcut_available(key: Key) -> bool:
return key != KEY_NONE and not _blocked_shortcuts.has(key)
func focus_available() -> bool:
return _registered and is_inside_tree() and is_visible_in_tree() and not _layout_editing
func activate_shortcut(_key: Key) -> void:
activate()
func set_layout_editing(editing: bool) -> void:
_layout_editing = editing
if editing:
deactivate()
if is_inside_tree():
refresh_shortcuts(get_tree())
func set_frame_host(host: Control) -> void:
_frame_host = host
if is_node_ready():
_update_style()
func _exit_tree() -> void:
_registered = false
if not Engine.is_editor_hint() and get_tree() != null:
refresh_shortcuts(get_tree())
static func refresh_shortcuts(tree: SceneTree) -> void:
var scopes := tree.get_nodes_in_group("keyboard_focus_containers")
scopes.sort_custom(func(a: Node, b: Node) -> bool: return a.get_instance_id() < b.get_instance_id())
var claimed: Dictionary = {}
for scope in scopes:
scope._blocked_shortcuts.clear()
if scope.focus_available():
for key in scope.get_focus_shortcuts():
if key == KEY_NONE:
continue
if claimed.has(key) and claimed[key] != scope:
scope._blocked_shortcuts[key] = claimed[key].title
else:
claimed[key] = scope
scope._refresh_header()
static func dispatch_focus_shortcut(event: InputEvent, tree: SceneTree) -> bool:
if not event is InputEventKey or event.alt_pressed or event.ctrl_pressed or event.meta_pressed or event.shift_pressed:
return false
var matches := false
for scope in tree.get_nodes_in_group("keyboard_focus_containers"):
if scope.focus_available() and event.keycode in scope.get_focus_shortcuts():
matches = true
break
if not matches:
return false
refresh_shortcuts(tree)
for scope in tree.get_nodes_in_group("keyboard_focus_containers"):
if scope.focus_available() and event.keycode in scope.get_focus_shortcuts() and scope.is_shortcut_available(event.keycode):
if event.pressed and not event.echo:
scope.activate_shortcut(event.keycode)
return true
return false