diff --git a/components/console_panel.tscn b/components/console_panel.tscn new file mode 100644 index 0000000..44aaa72 --- /dev/null +++ b/components/console_panel.tscn @@ -0,0 +1,51 @@ +[gd_scene format=3] + +[ext_resource type="PackedScene" path="res://components/selectable_container.tscn" id="1"] +[ext_resource type="Script" path="res://scripts/console_panel.gd" id="2"] +[ext_resource type="PackedScene" path="res://components/console_session.tscn" id="3"] + +[node name="ConsolePanel" instance=ExtResource("1")] +custom_minimum_size = Vector2(720, 420) +script = ExtResource("2") +title = "CONSOLE" +focus_key = 0 +initial_focus = NodePath("Tabs/ROV/Input/Command") + +[node name="Label" parent="MarginContainer/VBoxContainer/HBoxContainer"] +label_settings = null +theme_override_font_sizes/font_size = 18 +text = "CONSOLE" + +[node name="Tabs" type="TabContainer" parent="."] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +current_tab = 0 + +[node name="ROV" parent="Tabs" instance=ExtResource("3")] +layout_mode = 2 + +[node name="Host" parent="Tabs" instance=ExtResource("3")] +layout_mode = 2 +session_id = "host" +tab_label = "Host" +shortcut = 4194337 +prompt = "host $" +introduction = "Host shell disconnected." + +[node name="Logs" parent="Tabs" instance=ExtResource("3")] +layout_mode = 2 +session_id = "logs" +tab_label = "Logs" +shortcut = 4194338 +read_only = true +introduction = "ROV journal stream disconnected." + +[node name="Server" parent="Tabs" instance=ExtResource("3")] +layout_mode = 2 +session_id = "server" +tab_label = "Server" +shortcut = 4194339 +prompt = "server >" +introduction = "Server shell disconnected." diff --git a/components/console_session.tscn b/components/console_session.tscn new file mode 100644 index 0000000..c61f3ee --- /dev/null +++ b/components/console_session.tscn @@ -0,0 +1,73 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scripts/console_session.gd" id="1"] + +[ext_resource type="Font" path="res://themes/console_mono.tres" id="2_mono"] + +[node name="ConsoleSession" type="VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 6 +script = ExtResource("1") + +[node name="Status" type="HBoxContainer" parent="."] +layout_mode = 2 + +[node name="Connection" type="Label" parent="Status"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 14 +text = "Not connected" + +[node name="Follow" type="CheckButton" parent="Status"] +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "Follow" +button_pressed = true + +[node name="Clear" type="Button" parent="Status"] +layout_mode = 2 +theme_override_font_sizes/font_size = 14 +text = "Clear" + +[node name="Output" type="RichTextLabel" parent="."] +custom_minimum_size = Vector2(0, 160) +layout_mode = 2 +size_flags_vertical = 3 +focus_mode = 2 +theme_override_fonts/normal_font = ExtResource("2_mono") +theme_override_font_sizes/normal_font_size = 18 +bbcode_enabled = false +scroll_following = true +selection_enabled = true +context_menu_enabled = true + +[node name="Filter" type="HBoxContainer" parent="."] +visible = false +layout_mode = 2 + +[node name="Label" type="Label" parent="Filter"] +layout_mode = 2 +text = "Filter" + +[node name="Text" type="LineEdit" parent="Filter"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_fonts/font = ExtResource("2_mono") +theme_override_font_sizes/font_size = 18 +placeholder_text = "Filter log text" + +[node name="Input" type="HBoxContainer" parent="."] +layout_mode = 2 + +[node name="Prompt" type="Label" parent="Input"] +layout_mode = 2 +theme_override_fonts/font = ExtResource("2_mono") +theme_override_font_sizes/font_size = 18 +text = "rov $" + +[node name="Command" type="LineEdit" parent="Input"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_fonts/font = ExtResource("2_mono") +theme_override_font_sizes/font_size = 18 +placeholder_text = "Enter command…" diff --git a/docs/console_panel.md b/docs/console_panel.md new file mode 100644 index 0000000..8963a7a --- /dev/null +++ b/docs/console_panel.md @@ -0,0 +1,27 @@ +# Console + +Run `scenes/console_demo.tscn` for simulated commands and a journal feed, or add **Console** from the layout library. + +| Tab | Shortcut | Input | +| --- | --- | --- | +| ROV | F5 | ROV shell command | +| Host | F6 | Host shell command | +| Logs | F7 | Case-insensitive log filter | +| Server | F8 | Server command | + +A shortcut selects its tab and focuses its input across either dashboard window. Escape releases focus. Ctrl+Tab / Ctrl+Shift+Tab cycle tabs, Ctrl+L clears the current output, and Up/Down recall commands. Each tab retains its draft, history and output. Follow controls automatic scrolling; Clear keeps command history. Output supports selection and copying. + +Shortcuts are exported on the session nodes in `components/console_panel.tscn`. Conflicting shortcuts show “in use”; the first visible panel owns them. Layout editing suspends console input and shortcuts. Placement persists with the layout; console contents and command history do not. + +## Transport hooks + +`components/console_panel.tscn` owns four instances of `console_session.tscn`. Controls and tabs live in the scene tree and inherit the shared theme. Output, prompts and inputs share `themes/console_mono.tres`, with full hinting, 2× oversampling and slightly strengthened strokes for legibility under scaling. + +- Connect `command_requested(session_id, command)` to a transport. +- Call `set_connection(session_id, connected, message)` to update availability and status. +- Call `append_output(session_id, text)` to deliver output, including trailing newlines where appropriate. +- Session IDs are `rov`, `host`, `logs`, and `server`. + +The component starts disconnected and rejects command dispatch until connected with a handler attached. Logs are read-only. Scrollback is bounded by each session's `max_scrollback_characters`. + +This is a line-oriented frontend. SSH, local processes, journal streaming, ANSI/VT terminal emulation and interactive full-screen programs are not connected. The demo only simulates responses and never executes system commands. diff --git a/docs/display_layouts.md b/docs/display_layouts.md index 6d7f70a..a4f4720 100644 --- a/docs/display_layouts.md +++ b/docs/display_layouts.md @@ -41,7 +41,7 @@ The elements array is the complete active layout; omitted default panels stay de `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. VR/video and operator tools are placeholders. The Link and Environment panels have separate [Link](link_panel.md) and [Environment](environment_panel.md) telemetry demos. Camera and telemetry feeds are not connected. +The initial layouts deliberately leave free cells for rearranging. VR/video and operator tools are placeholders. The Link and Environment panels have separate [Link](link_panel.md) and [Environment](environment_panel.md) telemetry demos. The [Console](console_panel.md) is available in the library with four tabs and direct focus shortcuts. 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. diff --git a/scenes/console_demo.tscn b/scenes/console_demo.tscn new file mode 100644 index 0000000..1cbd32c --- /dev/null +++ b/scenes/console_demo.tscn @@ -0,0 +1,36 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scripts/console_demo.gd" id="1"] +[ext_resource type="PackedScene" path="res://components/console_panel.tscn" id="2"] + +[node name="ConsoleDemo" type="VBoxContainer"] +offset_left = 24.0 +offset_top = 24.0 +offset_right = 924.0 +offset_bottom = 700.0 +theme_override_constants/separation = 12 +script = ExtResource("1") + +[node name="Notice" type="Label" parent="."] +layout_mode = 2 +text = "SIMULATED / CONSOLE · F5–F8 choose a tab · Esc releases focus" + +[node name="ConsolePanel" parent="." instance=ExtResource("2")] +layout_mode = 2 +custom_minimum_size = Vector2(900, 520) + +[node name="Controls" type="HBoxContainer" parent="."] +layout_mode = 2 + +[node name="Contrast" type="CheckButton" parent="Controls"] +layout_mode = 2 +text = "High contrast" + +[node name="Feed" type="CheckButton" parent="Controls"] +layout_mode = 2 +text = "Simulated journal feed" +button_pressed = true + +[node name="LogTimer" type="Timer" parent="."] +wait_time = 1.0 +autostart = true diff --git a/scripts/console_demo.gd b/scripts/console_demo.gd new file mode 100644 index 0000000..aa148f0 --- /dev/null +++ b/scripts/console_demo.gd @@ -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]) diff --git a/scripts/console_demo.gd.uid b/scripts/console_demo.gd.uid new file mode 100644 index 0000000..181b0bd --- /dev/null +++ b/scripts/console_demo.gd.uid @@ -0,0 +1 @@ +uid://blo11s7dqe2b diff --git a/scripts/console_panel.gd b/scripts/console_panel.gd new file mode 100644 index 0000000..9da16a0 --- /dev/null +++ b/scripts/console_panel.gd @@ -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) diff --git a/scripts/console_panel.gd.uid b/scripts/console_panel.gd.uid new file mode 100644 index 0000000..8f25683 --- /dev/null +++ b/scripts/console_panel.gd.uid @@ -0,0 +1 @@ +uid://dcxkla5mcrxmx diff --git a/scripts/console_session.gd b/scripts/console_session.gd new file mode 100644 index 0000000..e8b2566 --- /dev/null +++ b/scripts/console_session.gd @@ -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() diff --git a/scripts/console_session.gd.uid b/scripts/console_session.gd.uid new file mode 100644 index 0000000..1aa83c9 --- /dev/null +++ b/scripts/console_session.gd.uid @@ -0,0 +1 @@ +uid://bvo8xa0w47jy6 diff --git a/scripts/layout_canvas.gd b/scripts/layout_canvas.gd index 42119dd..88dc1fc 100644 --- a/scripts/layout_canvas.gd +++ b/scripts/layout_canvas.gd @@ -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) diff --git a/scripts/layout_catalog.gd b/scripts/layout_catalog.gd index c1e83c5..a097652 100644 --- a/scripts/layout_catalog.gd +++ b/scripts/layout_catalog.gd @@ -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}, } diff --git a/scripts/layout_item.gd b/scripts/layout_item.gd index 25f65ed..7d8ac28 100644 --- a/scripts/layout_item.gd +++ b/scripts/layout_item.gd @@ -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: diff --git a/scripts/layout_overview.gd b/scripts/layout_overview.gd index dc56fdb..ed31b63 100644 --- a/scripts/layout_overview.gd +++ b/scripts/layout_overview.gd @@ -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) diff --git a/scripts/selectable_container.gd b/scripts/selectable_container.gd index 4aaaeec..b32fc66 100644 --- a/scripts/selectable_container.gd +++ b/scripts/selectable_container.gd @@ -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 diff --git a/themes/console_mono.tres b/themes/console_mono.tres new file mode 100644 index 0000000..f260101 --- /dev/null +++ b/themes/console_mono.tres @@ -0,0 +1,10 @@ +[gd_resource type="FontVariation" format=3] + +[sub_resource type="SystemFont" id="SystemFont_mono"] +font_names = PackedStringArray("DejaVu Sans Mono", "Liberation Mono", "monospace") +hinting = 2 +oversampling = 2.0 + +[resource] +base_font = SubResource("SystemFont_mono") +variation_embolden = 0.35