diff --git a/shared/godot/ui/screen_layout_control.gd b/shared/godot/ui/screen_layout_control.gd index 9a9cc01..e5d0795 100644 --- a/shared/godot/ui/screen_layout_control.gd +++ b/shared/godot/ui/screen_layout_control.gd @@ -197,6 +197,16 @@ func _persist_camera_rect() -> void: tray["camera_rect"] = _camera_rect.duplicate() AppState.set_section("tray", tray) EventBus.camera_rect_changed.emit(_camera_rect.duplicate()) + FlightRecorder.record( + "camera.rect_saved", + "Camera rect position saved", + { + "x": int(_camera_rect.get("x", 0)), + "y": int(_camera_rect.get("y", 0)), + "w": int(_camera_rect.get("w", 0)), + "h": int(_camera_rect.get("h", 0)), + }, + ) func _camera_rect_as_rect2() -> Rect2: diff --git a/shared/godot/ui/settings_page_animations.gd b/shared/godot/ui/settings_page_animations.gd index 828ea92..60e1630 100644 --- a/shared/godot/ui/settings_page_animations.gd +++ b/shared/godot/ui/settings_page_animations.gd @@ -1,4 +1,4 @@ -extends "res://src/ui/settings_page_base.gd" +extends "res://addons/godot-ui/settings_page_base.gd" ## Builds the Animations page for the settings window. ## Lets the user pick and preview the animation triggered on gaze. diff --git a/shared/godot/ui/settings_page_backend.gd b/shared/godot/ui/settings_page_backend.gd index 9748e89..2e0839d 100644 --- a/shared/godot/ui/settings_page_backend.gd +++ b/shared/godot/ui/settings_page_backend.gd @@ -1,4 +1,4 @@ -extends "res://src/ui/settings_page_base.gd" +extends "res://addons/godot-ui/settings_page_base.gd" ## Builds the Backend page for the settings window. ## Endpoints, model parameters, conversation settings. diff --git a/shared/godot/ui/settings_page_base.gd b/shared/godot/ui/settings_page_base.gd deleted file mode 100644 index ec15785..0000000 --- a/shared/godot/ui/settings_page_base.gd +++ /dev/null @@ -1,160 +0,0 @@ -extends RefCounted -## Shared scaffold helpers for all settings pages. -## All styling reads live from UiTheme so a theme change cascades -## automatically on the next rebuild. - - -func _page_margin() -> MarginContainer: - var m := MarginContainer.new() - m.size_flags_horizontal = Control.SIZE_EXPAND_FILL - m.add_theme_constant_override("margin_left", 16) - m.add_theme_constant_override("margin_right", 16) - m.add_theme_constant_override("margin_top", 14) - m.add_theme_constant_override("margin_bottom", 14) - return m - - -func _page_vbox() -> VBoxContainer: - var vbox := VBoxContainer.new() - vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL - vbox.add_theme_constant_override("separation", 6) - return vbox - - -func _header(text: String) -> Label: - var lbl := Label.new() - lbl.text = text - lbl.add_theme_color_override("font_color", UiTheme.accent) - lbl.add_theme_font_size_override("font_size", 11) - return lbl - - -func _check_toggle(label_text: String, initial: bool) -> CheckButton: - var btn := CheckButton.new() - btn.text = label_text - btn.button_pressed = initial - btn.add_theme_color_override("font_color", UiTheme.text_primary) - btn.add_theme_font_size_override("font_size", 13) - return btn - - -func _thin_sep() -> HSeparator: - var sep := HSeparator.new() - sep.add_theme_constant_override("separation", 4) - var style := StyleBoxFlat.new() - style.bg_color = UiTheme.border - style.content_margin_top = 0 - style.content_margin_bottom = 0 - sep.add_theme_stylebox_override("separator", style) - return sep - - -func _style_action(btn: Button) -> void: - var normal := StyleBoxFlat.new() - normal.bg_color = UiTheme.bg_panel - normal.set_border_width_all(1) - normal.border_color = UiTheme.border - normal.set_corner_radius_all(6) - normal.content_margin_left = 12 - normal.content_margin_right = 12 - btn.add_theme_stylebox_override("normal", normal) - btn.add_theme_color_override("font_color", UiTheme.text_primary) - btn.add_theme_font_size_override("font_size", 13) - var hover := normal.duplicate() as StyleBoxFlat - hover.border_color = UiTheme.accent - btn.add_theme_stylebox_override("hover", hover) - - -func _style_option(opt: OptionButton) -> void: - var style := StyleBoxFlat.new() - style.bg_color = UiTheme.input_bg - style.set_border_width_all(1) - style.border_color = UiTheme.border - style.set_corner_radius_all(5) - style.content_margin_left = 8 - style.content_margin_right = 8 - style.content_margin_top = 4 - style.content_margin_bottom = 4 - opt.add_theme_stylebox_override("normal", style) - opt.add_theme_color_override("font_color", UiTheme.text_primary) - var hover := style.duplicate() as StyleBoxFlat - hover.border_color = UiTheme.accent - opt.add_theme_stylebox_override("hover", hover) - opt.add_theme_stylebox_override("focus", hover) - - -func _style_play(btn: Button) -> void: - var normal := StyleBoxFlat.new() - normal.bg_color = UiTheme.accent - normal.set_corner_radius_all(5) - btn.add_theme_stylebox_override("normal", normal) - var hover := normal.duplicate() as StyleBoxFlat - hover.bg_color = UiTheme.accent_hover() - btn.add_theme_stylebox_override("hover", hover) - var pressed := normal.duplicate() as StyleBoxFlat - pressed.bg_color = UiTheme.accent_press() - btn.add_theme_stylebox_override("pressed", pressed) - - -func _add_spin( - parent: VBoxContainer, - label_text: String, - value: float, - min_val: float, - max_val: float, - step_val: float, -) -> SpinBox: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) - var lbl := Label.new() - lbl.text = label_text - lbl.custom_minimum_size.x = 200 - lbl.add_theme_color_override("font_color", UiTheme.text_primary) - lbl.add_theme_font_size_override("font_size", 13) - row.add_child(lbl) - var spin := SpinBox.new() - spin.min_value = min_val - spin.max_value = max_val - spin.step = step_val - spin.value = value - spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL - spin.add_theme_font_size_override("font_size", 13) - row.add_child(spin) - parent.add_child(row) - return spin - - -func _labeled_input(label_text: String, initial: String) -> LineEdit: - var row := VBoxContainer.new() - row.add_theme_constant_override("separation", 4) - - var lbl := Label.new() - lbl.text = label_text - lbl.add_theme_color_override("font_color", UiTheme.text_primary) - lbl.add_theme_font_size_override("font_size", 13) - row.add_child(lbl) - - var input := LineEdit.new() - input.text = initial - input.size_flags_horizontal = Control.SIZE_EXPAND_FILL - input.add_theme_color_override("font_color", UiTheme.text_primary) - input.add_theme_color_override("caret_color", UiTheme.accent) - input.add_theme_font_size_override("font_size", 13) - - var style := StyleBoxFlat.new() - style.bg_color = UiTheme.input_bg - style.set_border_width_all(1) - style.border_color = UiTheme.border - style.set_corner_radius_all(5) - style.content_margin_left = 8 - style.content_margin_right = 8 - style.content_margin_top = 5 - style.content_margin_bottom = 5 - input.add_theme_stylebox_override("normal", style) - - var focus := style.duplicate() as StyleBoxFlat - focus.border_color = UiTheme.accent - input.add_theme_stylebox_override("focus", focus) - - row.add_child(input) - return input diff --git a/shared/godot/ui/settings_page_camera.gd b/shared/godot/ui/settings_page_camera.gd index b3294c1..93ddda0 100644 --- a/shared/godot/ui/settings_page_camera.gd +++ b/shared/godot/ui/settings_page_camera.gd @@ -1,5 +1,6 @@ -extends "res://src/ui/settings_page_base.gd" -## Camera settings page — face tracking controls, live status, and screen layout diagram. +extends "res://addons/godot-ui/settings_page_base.gd" +## Camera settings page — face tracking controls, live status, screen layout diagram, +## and inline WebSocket camera preview streamed from the vision sidecar. const ScreenLayoutControlScript = preload("res://src/ui/screen_layout_control.gd") @@ -8,19 +9,37 @@ const STATUS_SCREEN := Color("#00BCD4") const STATUS_AWAY := Color("#FF9800") const STATUS_ABSENT := Color("#607D8B") -const TRAY_PORT: int = 19701 +const PREVIEW_WS_URL := "ws://127.0.0.1:19703" + + +class _WsPoller extends Node: + ## Thin Node shim so RefCounted settings page gets _process() ticks. + var page: RefCounted + + func _process(_delta: float) -> void: + page._ws_tick() + var _companion: Node var _gaze_toggle: CheckButton -var _camera_spin: SpinBox +var _camera_option: OptionButton +var _camera_name_label: Label var _attention_dot: ColorRect var _attention_label: Label var _pose_label: Label var _iris_label: Label -var _camera_name_label: Label var _layout_control: Control +var _preview_btn: Button +var _preview_texture_rect: TextureRect +var _preview_texture: ImageTexture +var _ws: WebSocketPeer +var _ws_active: bool = false +var _ws_poller: Node + +var _cameras_cache: Array = [] + func setup(companion: Node) -> void: _companion = companion @@ -51,11 +70,20 @@ func build() -> Control: vbox.add_child(_build_status_section()) vbox.add_child(_thin_sep()) + vbox.add_child(_header("PREVIEW")) + vbox.add_child(_build_preview_section()) + vbox.add_child(_thin_sep()) + vbox.add_child(_header("SCREEN LAYOUT")) vbox.add_child(_build_preset_row()) _layout_control = ScreenLayoutControlScript.new() vbox.add_child(_layout_control) + # Attach poller node to scroll so it enters the scene tree and gets _process() + _ws_poller = _WsPoller.new() + (_ws_poller as _WsPoller).page = self + scroll.add_child(_ws_poller) + EventBus.attention_changed.connect(_on_attention_changed) EventBus.face_lost.connect(_on_face_lost) EventBus.face_pose_updated.connect(_on_pose_updated) @@ -66,6 +94,7 @@ func build() -> Control: func cleanup() -> void: + _ws_stop() if EventBus.attention_changed.is_connected(_on_attention_changed): EventBus.attention_changed.disconnect(_on_attention_changed) if EventBus.face_lost.is_connected(_on_face_lost): @@ -85,21 +114,18 @@ func _build_camera_row() -> Control: row.add_theme_constant_override("separation", 10) var lbl := Label.new() - lbl.text = "Camera Index" + lbl.text = "Camera" lbl.custom_minimum_size.x = 130 lbl.add_theme_color_override("font_color", UiTheme.text_primary) lbl.add_theme_font_size_override("font_size", 13) row.add_child(lbl) - _camera_spin = SpinBox.new() - _camera_spin.min_value = 0 - _camera_spin.max_value = 9 - _camera_spin.step = 1 - _camera_spin.value = float(_get_active_camera()) - _camera_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _camera_spin.add_theme_font_size_override("font_size", 13) - _camera_spin.value_changed.connect(_on_camera_changed) - row.add_child(_camera_spin) + _camera_option = OptionButton.new() + _camera_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _camera_option.add_theme_font_size_override("font_size", 13) + _camera_option.item_selected.connect(_on_camera_option_selected) + _style_option(_camera_option) + row.add_child(_camera_option) col.add_child(row) @@ -147,6 +173,30 @@ func _build_status_section() -> Control: return vbox +func _build_preview_section() -> Control: + var vbox := VBoxContainer.new() + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.add_theme_constant_override("separation", 6) + + _preview_btn = Button.new() + _preview_btn.text = "Preview Camera" + _preview_btn.toggle_mode = true + _preview_btn.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _preview_btn.add_theme_font_size_override("font_size", 13) + _preview_btn.toggled.connect(_on_preview_toggled) + vbox.add_child(_preview_btn) + + _preview_texture_rect = TextureRect.new() + _preview_texture_rect.custom_minimum_size = Vector2(480, 270) + _preview_texture_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _preview_texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL + _preview_texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _preview_texture_rect.visible = false + vbox.add_child(_preview_texture_rect) + + return vbox + + func _build_preset_row() -> Control: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) @@ -168,6 +218,7 @@ func _build_preset_row() -> Control: opt.add_item("Right Side", 4) opt.add_item("Center (eye-level)", 5) opt.item_selected.connect(_on_preset_selected) + _style_option(opt) row.add_child(opt) return row @@ -180,6 +231,53 @@ func _refresh_status() -> void: _attention_label.add_theme_color_override("font_color", UiTheme.text_muted) +# -- WebSocket preview -------------------------------------------------------- + + +func _on_preview_toggled(on: bool) -> void: + if on: + _ws = WebSocketPeer.new() + _ws.connect_to_url(PREVIEW_WS_URL) + _ws_active = true + _preview_texture_rect.visible = true + _preview_btn.text = "Stop Preview" + FlightRecorder.record("camera.preview_started", "Camera preview stream opened") + else: + _ws_stop() + FlightRecorder.record("camera.preview_stopped", "Camera preview stream closed") + + +func _ws_tick() -> void: + if not _ws_active or _ws == null: + return + _ws.poll() + var state := _ws.get_ready_state() + if state == WebSocketPeer.STATE_OPEN: + while _ws.get_available_packet_count() > 0: + var data := _ws.get_packet() + var img := Image.new() + if img.load_jpg_from_buffer(data) == OK: + if _preview_texture == null: + _preview_texture = ImageTexture.create_from_image(img) + _preview_texture_rect.texture = _preview_texture + else: + _preview_texture.update(img) + elif state == WebSocketPeer.STATE_CLOSED: + _ws_stop() + + +func _ws_stop() -> void: + _ws_active = false + if _ws != null: + _ws.close() + _ws = null + if _preview_texture_rect != null: + _preview_texture_rect.visible = false + if _preview_btn != null: + _preview_btn.text = "Preview Camera" + _preview_btn.set_pressed_no_signal(false) + + # -- Callbacks ----------------------------------------------------------------- @@ -190,17 +288,25 @@ func _on_gaze_toggled(on: bool) -> void: var current: String = gaze.get_mode_name() if on and current == "desktop": gaze.toggle_mode() + FlightRecorder.record("camera.gaze_enabled", "Face gaze enabled via settings") elif not on and current == "face_to_face": gaze.toggle_mode() + FlightRecorder.record("camera.gaze_disabled", "Face gaze disabled via settings") -func _on_camera_changed(value: float) -> void: - var index: int = int(value) +func _on_camera_option_selected(idx: int) -> void: + var index: int = _camera_option.get_item_id(idx) + var camera_name: String = _camera_option.get_item_text(idx) var tray_data: Dictionary = AppState.get_section("tray") tray_data["active_camera"] = index AppState.set_section("tray", tray_data) _send_tray_msg({"cmd": "select_camera", "index": index}) _camera_name_label.text = "" + FlightRecorder.record( + "camera.selected", + "Camera changed via settings", + {"index": index, "name": camera_name}, + ) func _on_attention_changed(state: String, confidence: float) -> void: @@ -240,15 +346,29 @@ func _on_preset_selected(index: int) -> void: const PRESETS := ["top-center", "top-left", "top-right", "left", "right", "center"] if index < PRESETS.size(): _layout_control.apply_preset(PRESETS[index]) + FlightRecorder.record( + "camera.preset_selected", + "Camera position preset changed", + {"preset": PRESETS[index]}, + ) func _on_camera_list_updated(cameras: Array) -> void: + _cameras_cache = cameras + if _camera_option == null: + return var active: int = _get_active_camera() + _camera_option.clear() for cam: Variant in cameras: - if cam is Dictionary and int(cam.get("index", -1)) == active: - _camera_name_label.text = str(cam.get("name", "")) - return - _camera_name_label.text = "" + if cam is Dictionary: + var idx: int = int(cam.get("index", 0)) + var name: String = str(cam.get("name", "Camera %d" % idx)) + _camera_option.add_item("%d — %s" % [idx, name], idx) + # Select the active camera + for i: int in range(_camera_option.item_count): + if _camera_option.get_item_id(i) == active: + _camera_option.select(i) + break # -- Helpers ------------------------------------------------------------------- @@ -256,7 +376,7 @@ func _on_camera_list_updated(cameras: Array) -> void: func _send_tray_msg(msg: Dictionary) -> void: var udp := PacketPeerUDP.new() - udp.set_dest_address("127.0.0.1", TRAY_PORT) + udp.set_dest_address("127.0.0.1", 19701) udp.put_packet(JSON.stringify(msg).to_utf8_buffer()) udp.close() diff --git a/shared/godot/ui/settings_page_general.gd b/shared/godot/ui/settings_page_general.gd index 4e2edbd..62838e6 100644 --- a/shared/godot/ui/settings_page_general.gd +++ b/shared/godot/ui/settings_page_general.gd @@ -1,4 +1,4 @@ -extends "res://src/ui/settings_page_base.gd" +extends "res://addons/godot-ui/settings_page_base.gd" ## Builds the General page for the settings window. ## Voice, display, zoom, actions, gaze behavior sections. diff --git a/shared/godot/ui/settings_page_personality.gd b/shared/godot/ui/settings_page_personality.gd index 6b73b86..484d385 100644 --- a/shared/godot/ui/settings_page_personality.gd +++ b/shared/godot/ui/settings_page_personality.gd @@ -1,4 +1,4 @@ -extends "res://src/ui/settings_page_base.gd" +extends "res://addons/godot-ui/settings_page_base.gd" ## Builds the Personality page for the settings window. ## Scans res://config/personalities and lets the user pick the active personality. diff --git a/shared/godot/ui/settings_page_sounds.gd b/shared/godot/ui/settings_page_sounds.gd index 2086cec..af592b2 100644 --- a/shared/godot/ui/settings_page_sounds.gd +++ b/shared/godot/ui/settings_page_sounds.gd @@ -1,4 +1,4 @@ -extends "res://src/ui/settings_page_base.gd" +extends "res://addons/godot-ui/settings_page_base.gd" ## Builds the Sounds page for the settings window. ## Event→sound mapping dropdowns with play buttons. diff --git a/shared/godot/ui/ui_theme.gd b/shared/godot/ui/ui_theme.gd deleted file mode 100644 index a1e4f66..0000000 --- a/shared/godot/ui/ui_theme.gd +++ /dev/null @@ -1,61 +0,0 @@ -extends Node -## Central UI theme for Chobit — single source of truth for all colors. -## Modify color vars then call apply_preset() to emit theme_changed, -## which cascades a full rebuild through all panel_window subclasses. - -signal theme_changed - -## Backgrounds -var bg_dark := Color("#0D1117") -var bg_panel := Color("#111822") - -## Accent (Miku teal) -var accent := Color("#39C5BB") - -## Text -var text_primary := Color("#E8F4F3") -var text_muted := Color("#6B8E8B") - -## Borders / separators -var border := Color("#1A3330") - -## Input field background -var input_bg := Color("#0A1628") - -## Sidebar -var sidebar_bg := Color("#090E14") -var sidebar_hover := Color("#111822") -var sidebar_active := Color("#1A3330") - -## Chat text selection -var selection_bg := Color("#1A5C56") -var selection_text := Color("#FFFFFF") - - -func accent_hover() -> Color: - return Color("#4ECDC4") - - -func accent_press() -> Color: - return Color("#2BA8A0") - - -func apply_preset(preset: String) -> void: - match preset: - "miku_dark": - bg_dark = Color("#0D1117") - bg_panel = Color("#111822") - accent = Color("#39C5BB") - text_primary = Color("#E8F4F3") - text_muted = Color("#6B8E8B") - border = Color("#1A3330") - input_bg = Color("#0A1628") - sidebar_bg = Color("#090E14") - sidebar_hover = Color("#111822") - sidebar_active = Color("#1A3330") - selection_bg = Color("#1A5C56") - selection_text = Color("#FFFFFF") - _: - push_warning("UiTheme.apply_preset: unknown preset '%s'" % preset) - return - theme_changed.emit()