From 341083ffedf864a72732c0de7829b877d304f26e Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sun, 29 Mar 2026 10:05:35 -0700 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20=E2=99=BB=EF=B8=8F=20Restructur?= =?UTF-8?q?e=20UI=20components=20(ContextMenu,=20PanelWindow,=20ScreenLayo?= =?UTF-8?q?utControl)=20and=20settings=20pages=20(Animations,=20Backend,?= =?UTF-8?q?=20Camera,=20General,=20Personality,=20Sounds,=20TTS)=20for=20i?= =?UTF-8?q?mproved=20organization=20and=20maintainability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- shared/godot/ui/context_menu.gd | 144 +------- shared/godot/ui/panel_window.gd | 113 +------ shared/godot/ui/screen_layout_control.gd | 95 +++--- shared/godot/ui/settings_page_animations.gd | 70 ++-- shared/godot/ui/settings_page_backend.gd | 84 +++-- shared/godot/ui/settings_page_camera.gd | 327 ++++++++++--------- shared/godot/ui/settings_page_general.gd | 129 +++----- shared/godot/ui/settings_page_personality.gd | 53 ++- shared/godot/ui/settings_page_sounds.gd | 119 +++---- shared/godot/ui/settings_page_tts.gd | 114 +++++++ shared/godot/ui/settings_page_tts.gd.uid | 1 + shared/godot/ui/settings_window.gd | 252 ++++++-------- 12 files changed, 631 insertions(+), 870 deletions(-) create mode 100644 shared/godot/ui/settings_page_tts.gd create mode 100644 shared/godot/ui/settings_page_tts.gd.uid diff --git a/shared/godot/ui/context_menu.gd b/shared/godot/ui/context_menu.gd index 2d07ccf..dd07ac8 100644 --- a/shared/godot/ui/context_menu.gd +++ b/shared/godot/ui/context_menu.gd @@ -1,134 +1,24 @@ -extends Window +extends "res://addons/godot-ui/menu/popup_menu.gd" ## Right-click context menu for the companion avatar. -## Native OS window (force_native) so it renders at monitor DPI -## regardless of character zoom / viewport stretch. ## Three items: Chat, Settings, Quit. - -const ITEM_H := 32 -const MENU_W := 160 -const PADDING := 6 -const FONT_SIZE := 14 - -var _items: Array[Dictionary] = [ - {"label": "Chat", "action": "_on_chat"}, - {"label": "Settings", "action": "_on_settings"}, - {"label": "Quit", "action": "_on_quit"}, -] - - -func _init() -> void: - visible = false - force_native = true - exclusive = false - borderless = true - always_on_top = true - transparent = true - unfocusable = false +## Delegates all window/UI/theming to the godot-ui popup_menu component. func _ready() -> void: - title = "" - size = Vector2i(MENU_W, ITEM_H * _items.size() + PADDING * 2) - min_size = size - max_size = size - focus_exited.connect(_on_focus_lost) - _build_ui() - UiTheme.theme_changed.connect(_rebuild_on_theme) + var items: Array[Dictionary] = [ + {"label": "Chat", "action": "chat"}, + {"label": "Settings", "action": "settings"}, + {"label": "Quit", "action": "quit"}, + ] + setup(items) + item_pressed.connect(_on_item_pressed) -func _rebuild_on_theme() -> void: - for child in get_children(): - remove_child(child) - child.queue_free() - _build_ui() - - -func show_at(pos: Vector2i) -> void: - var screen_idx := DisplayServer.get_primary_screen() - var screen_rect := DisplayServer.screen_get_usable_rect(screen_idx) - - # Clamp so the menu stays on-screen - var x := clampi( - pos.x, screen_rect.position.x, screen_rect.position.x + screen_rect.size.x - size.x - ) - var y := clampi( - pos.y, screen_rect.position.y, screen_rect.position.y + screen_rect.size.y - size.y - ) - position = Vector2i(x, y) - show() - grab_focus() - - -func _build_ui() -> void: - var bg := PanelContainer.new() - bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - var bg_style := StyleBoxFlat.new() - bg_style.bg_color = UiTheme.bg_dark - bg_style.set_border_width_all(1) - bg_style.border_color = UiTheme.border - bg_style.set_corner_radius_all(8) - bg_style.content_margin_left = PADDING - bg_style.content_margin_right = PADDING - bg_style.content_margin_top = PADDING - bg_style.content_margin_bottom = PADDING - bg.add_theme_stylebox_override("panel", bg_style) - add_child(bg) - - var vbox := VBoxContainer.new() - vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - vbox.add_theme_constant_override("separation", 0) - bg.add_child(vbox) - - for item: Dictionary in _items: - vbox.add_child(_build_item(item["label"], Callable(self, item["action"]))) - - -func _build_item(label_text: String, callback: Callable) -> Button: - var btn := Button.new() - btn.text = label_text - btn.flat = true - btn.custom_minimum_size.y = ITEM_H - btn.alignment = HORIZONTAL_ALIGNMENT_LEFT - btn.add_theme_color_override("font_color", UiTheme.text_primary) - btn.add_theme_color_override("font_hover_color", UiTheme.accent) - btn.add_theme_font_size_override("font_size", FONT_SIZE) - - var normal := StyleBoxFlat.new() - normal.bg_color = Color.TRANSPARENT - normal.set_corner_radius_all(5) - normal.content_margin_left = 10 - normal.content_margin_right = 10 - btn.add_theme_stylebox_override("normal", normal) - - var hover := StyleBoxFlat.new() - hover.bg_color = UiTheme.bg_panel - hover.set_corner_radius_all(5) - hover.content_margin_left = 10 - hover.content_margin_right = 10 - btn.add_theme_stylebox_override("hover", hover) - - var pressed := hover.duplicate() as StyleBoxFlat - btn.add_theme_stylebox_override("pressed", pressed) - - btn.pressed.connect( - func() -> void: - hide() - callback.call() - ) - return btn - - -func _on_chat() -> void: - EventBus.chat_opened.emit() - - -func _on_settings() -> void: - EventBus.settings_opened.emit() - - -func _on_quit() -> void: - get_tree().quit() - - -func _on_focus_lost() -> void: - hide() +func _on_item_pressed(action: String) -> void: + match action: + "chat": + EventBus.chat_opened.emit() + "settings": + EventBus.settings_opened.emit() + "quit": + get_tree().quit() diff --git a/shared/godot/ui/panel_window.gd b/shared/godot/ui/panel_window.gd index 5efcc78..8a29e32 100644 --- a/shared/godot/ui/panel_window.gd +++ b/shared/godot/ui/panel_window.gd @@ -1,42 +1,12 @@ -extends Window -## Base class for all floating panel windows in Chobit. -## Guarantees native OS windowing, borderless always-on-top, smart companion-relative positioning. -## Rebuilds the UI tree when UiTheme.theme_changed fires — colors cascade automatically. -## -## Subclass contract: -## - Override _build_ui() to populate content. -## - In _ready(), set size/min_size first, then call super._ready(). -## - Use _build_panel_title_bar(title, extras) to build the standard Miku title bar. - -var _drag_start: Vector2i -var _dragging: bool = false - - -func _init() -> void: - visible = false - force_native = true +extends "res://addons/godot-ui/core/window_base.gd" +## Chobit floating panel window. +## Adds always-on-top and companion-relative positioning on top of the generic base. func _ready() -> void: - title = "" - borderless = true always_on_top = true - if not close_requested.is_connected(hide): - close_requested.connect(hide) - _build_ui() + super._ready() _position_beside_companion() - UiTheme.theme_changed.connect(_on_theme_changed) - - -func _build_ui() -> void: - pass - - -func _on_theme_changed() -> void: - for child in get_children(): - remove_child(child) - child.queue_free() - _build_ui() func _position_beside_companion() -> void: @@ -66,78 +36,3 @@ func _position_beside_companion() -> void: var y := companion_pos.y + companion_size.y - my_h y = clampi(y, screen_rect.position.y, screen_rect.position.y + screen_rect.size.y - my_h) position = Vector2i(x, y) - - -func _build_panel_title_bar(title_text: String, extra_widgets: Array[Control] = []) -> Control: - ## Builds the standard Miku-themed title bar. - ## extra_widgets are inserted between the right-side spacer and the × close button. - ## The entire bar area (including the title label) is draggable. - var bar := PanelContainer.new() - var style := StyleBoxFlat.new() - style.bg_color = UiTheme.bg_panel - style.corner_radius_top_left = 10 - style.corner_radius_top_right = 10 - bar.add_theme_stylebox_override("panel", style) - bar.custom_minimum_size.y = 40 - bar.gui_input.connect(_on_titlebar_input) - - var margin := MarginContainer.new() - margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL - margin.mouse_filter = Control.MOUSE_FILTER_PASS - margin.add_theme_constant_override("margin_left", 14) - margin.add_theme_constant_override("margin_right", 8) - margin.add_theme_constant_override("margin_top", 0) - margin.add_theme_constant_override("margin_bottom", 0) - bar.add_child(margin) - - var hbox := HBoxContainer.new() - hbox.add_theme_constant_override("separation", 8) - hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hbox.mouse_filter = Control.MOUSE_FILTER_PASS - margin.add_child(hbox) - - var title_lbl := Label.new() - title_lbl.text = title_text - title_lbl.add_theme_color_override("font_color", UiTheme.accent) - title_lbl.add_theme_font_size_override("font_size", 13) - title_lbl.mouse_filter = Control.MOUSE_FILTER_PASS - hbox.add_child(title_lbl) - - var spacer := Control.new() - spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL - spacer.mouse_filter = Control.MOUSE_FILTER_PASS - hbox.add_child(spacer) - - for widget: Control in extra_widgets: - hbox.add_child(widget) - - var close_btn := Button.new() - close_btn.text = "×" - close_btn.flat = true - close_btn.custom_minimum_size = Vector2(32, 32) - close_btn.add_theme_color_override("font_color", UiTheme.text_muted) - close_btn.add_theme_color_override("font_hover_color", UiTheme.text_primary) - close_btn.add_theme_font_size_override("font_size", 18) - close_btn.pressed.connect(hide) - hbox.add_child(close_btn) - - return bar - - -func _build_divider() -> Control: - var line := ColorRect.new() - line.color = UiTheme.border - line.custom_minimum_size.y = 1 - line.size_flags_horizontal = Control.SIZE_EXPAND_FILL - return line - - -func _on_titlebar_input(event: InputEvent) -> void: - if event is InputEventMouseButton: - var mb := event as InputEventMouseButton - if mb.button_index == MOUSE_BUTTON_LEFT: - _dragging = mb.pressed - if _dragging: - _drag_start = DisplayServer.mouse_get_position() - position - elif event is InputEventMouseMotion and _dragging: - position = DisplayServer.mouse_get_position() - _drag_start diff --git a/shared/godot/ui/screen_layout_control.gd b/shared/godot/ui/screen_layout_control.gd index e5d0795..dfb8f54 100644 --- a/shared/godot/ui/screen_layout_control.gd +++ b/shared/godot/ui/screen_layout_control.gd @@ -3,20 +3,6 @@ extends Control ## Mirrors the Python screen_layout.py visualization, now native in Godot. ## Camera rect is draggable; position is persisted to AppState. -const COLOR_BG := Color("#0D1117") -const COLOR_MONITOR := Color("#1C2130") -const COLOR_MONITOR_BORDER := Color("#3A4060") -const COLOR_MONITOR_LABEL := Color("#5A6480") -const COLOR_MIKU := Color("#39C5BB") -const COLOR_MIKU_FILL := Color("#061818") -const COLOR_MIKU_LOOKING := Color("#2ECC71") -const COLOR_CAMERA := Color("#FFB300") -const COLOR_CAMERA_FILL := Color("#1A1200") -const COLOR_GAZE_LOOKING := Color("#2ECC71") -const COLOR_GAZE_SCREEN := Color("#00BCD4") -const COLOR_GAZE_AWAY := Color("#FF9800") -const COLOR_GAZE_ABSENT := Color("#607D8B") - const MARGIN := 14.0 const FONT_SIZE := 9 @@ -38,6 +24,9 @@ var _tf_offset: Vector2 = Vector2.ZERO var _tf_origin: Vector2 = Vector2.ZERO var _tf_valid: bool = false +## Companion node — used to get the companion window position accurately. +var _companion: Node = null + func _ready() -> void: mouse_filter = MOUSE_FILTER_STOP @@ -51,6 +40,10 @@ func _ready() -> void: ## Apply a named preset for the camera's physical position. ## Presets: "top-center", "top-left", "top-right", "left", "right", "center" +func setup(companion: Node) -> void: + _companion = companion + + func apply_preset(preset: String) -> void: var bounds := _monitor_bounds() if bounds.monitors.is_empty(): @@ -81,11 +74,11 @@ func apply_preset(preset: String) -> void: func _draw() -> void: - draw_rect(Rect2(Vector2.ZERO, size), COLOR_BG, true) + draw_rect(Rect2(Vector2.ZERO, size), UiTheme.bg_dark, true) var bounds := _monitor_bounds() if bounds.monitors.is_empty(): - _draw_centered_text("No monitors detected", size / 2.0, COLOR_MONITOR_LABEL) + _draw_centered_text("No monitors detected", size / 2.0, UiTheme.gaze_text) return _build_transform(bounds) @@ -95,30 +88,39 @@ func _draw() -> void: # Monitors for mon: Dictionary in bounds.monitors: var r := _to_panel_rect(mon.rect) - draw_rect(r, COLOR_MONITOR, true) - draw_rect(r, COLOR_MONITOR_BORDER, false, 1.0) - _draw_centered_text(mon.name, r.get_center() - Vector2(0.0, 6.0), COLOR_MONITOR_LABEL) + draw_rect(r, UiTheme.gaze_monitor, true) + draw_rect(r, UiTheme.gaze_monitor_border, false, 1.0) + _draw_centered_text(mon.name, r.get_center() - Vector2(0.0, 6.0), UiTheme.gaze_text) _draw_centered_text( "%dx%d" % [int(mon.rect.size.x), int(mon.rect.size.y)], r.get_center() + Vector2(0.0, 6.0), - COLOR_MONITOR_LABEL, + UiTheme.gaze_text, ) - # Miku window - var win_pos := Vector2(DisplayServer.window_get_position()) - var win_size := Vector2(DisplayServer.window_get_size()) + # Miku window — use companion's actual window to avoid returning the settings panel position + var win_pos: Vector2 + var win_size: Vector2 + if _companion != null and is_instance_valid(_companion): + var companion_win := _companion.get_window() + win_pos = Vector2(companion_win.position) + win_size = Vector2(companion_win.size) + else: + win_pos = Vector2(DisplayServer.window_get_position()) + win_size = Vector2(DisplayServer.window_get_size()) var miku_r := _to_panel_rect(Rect2(win_pos, win_size)) - var miku_color: Color = COLOR_MIKU_LOOKING if _attention == "looking" else COLOR_MIKU - draw_rect(miku_r, COLOR_MIKU_FILL, true) + var miku_color: Color = UiTheme.status_looking if _attention == "looking" else UiTheme.accent + draw_rect(miku_r, UiTheme.gaze_miku_fill, true) draw_rect(miku_r, miku_color, false, 2.0) _draw_centered_text("Miku", miku_r.get_center(), miku_color) # Camera rect if not _camera_rect.is_empty(): var cam_r := _to_panel_rect(_camera_rect_as_rect2()) - draw_rect(cam_r, COLOR_CAMERA_FILL, true) - draw_rect(cam_r, COLOR_CAMERA, false, 2.0) - _draw_text("CAM", cam_r.position + Vector2(2.0, float(FONT_SIZE) + 2.0), COLOR_CAMERA) + draw_rect(cam_r, UiTheme.gaze_camera_fill, true) + draw_rect(cam_r, UiTheme.gaze_camera, false, 2.0) + _draw_text( + "CAM", cam_r.position + Vector2(2.0, float(FONT_SIZE) + 2.0), UiTheme.gaze_camera + ) # Gaze point + line to Miku center if _gaze_screen_pos.x > -1.0e8: @@ -178,34 +180,35 @@ func _on_face_lost() -> void: func _attention_color() -> Color: match _attention: "looking": - return COLOR_GAZE_LOOKING + return UiTheme.status_looking "screen": - return COLOR_GAZE_SCREEN + return UiTheme.status_screen "away": - return COLOR_GAZE_AWAY - return COLOR_GAZE_ABSENT + return UiTheme.status_away + return UiTheme.status_absent func _load_camera_rect() -> void: - var cr: Variant = AppState.get_section("tray").get("camera_rect") - if cr is Dictionary and cr.has("x"): + var cr: Dictionary = AppState.get_camera_rect() + if cr.has("x"): _camera_rect = cr.duplicate() func _persist_camera_rect() -> void: - var tray: Dictionary = AppState.get_section("tray") - tray["camera_rect"] = _camera_rect.duplicate() - AppState.set_section("tray", tray) + AppState.set_camera_rect(_camera_rect.duplicate()) 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)), - }, + ( + 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)), + }, + ) ) diff --git a/shared/godot/ui/settings_page_animations.gd b/shared/godot/ui/settings_page_animations.gd index 60e1630..e8a669a 100644 --- a/shared/godot/ui/settings_page_animations.gd +++ b/shared/godot/ui/settings_page_animations.gd @@ -1,9 +1,14 @@ -extends "res://addons/godot-ui/settings_page_base.gd" +extends "res://addons/godot-ui/core/page_base.gd" ## Builds the Animations page for the settings window. ## Lets the user pick and preview the animation triggered on gaze. +const ScrollPageScript = preload("res://addons/godot-ui/layout/scroll_page.gd") +const SectionHeaderScript = preload("res://addons/godot-ui/layout/section_header.gd") +const OptionRowScript = preload("res://addons/godot-ui/form/option_row.gd") + var _companion: Node var _sound_config: Node +var _anim_row: HBoxContainer func setup(companion: Node, sound_config: Node) -> void: @@ -12,53 +17,34 @@ func setup(companion: Node, sound_config: Node) -> void: func build() -> Control: - var outer := _page_margin() - var vbox := _page_vbox() - outer.add_child(vbox) - vbox.add_child(_header("GAZE ANIMATION")) + var page: ScrollContainer = ScrollPageScript.new() + page.setup() + var vbox: VBoxContainer = page.content + + var header: Label = SectionHeaderScript.new() + header.setup("GAZE ANIMATION") + vbox.add_child(header) var gesture_names: Array = _get_gesture_names() - var row := HBoxContainer.new() - row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.add_theme_constant_override("separation", 8) - - var lbl := Label.new() - lbl.text = "Animation on gaze" - lbl.custom_minimum_size.x = 140 - lbl.add_theme_color_override("font_color", UiTheme.text_primary) - lbl.add_theme_font_size_override("font_size", 13) - row.add_child(lbl) - - var opt := OptionButton.new() - opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _style_option(opt) - opt.add_item("(none)", 0) - for i: int in range(gesture_names.size()): - opt.add_item(str(gesture_names[i]), i + 1) + var options: Array[String] = ["(none)"] + for gname: Variant in gesture_names: + options.append(str(gname)) var current_anim: String = "" if _sound_config != null: current_anim = _sound_config.get_sound("gaze_anim") - if current_anim.is_empty(): - opt.select(0) - else: + var selected: int = 0 + if not current_anim.is_empty(): var idx: int = gesture_names.find(current_anim) - opt.select(idx + 1 if idx >= 0 else 0) + selected = idx + 1 if idx >= 0 else 0 - opt.item_selected.connect(_on_anim_selected.bind(gesture_names)) - row.add_child(opt) + _anim_row = OptionRowScript.new() + _anim_row.setup("Animation on gaze", options, selected, true) + _anim_row.item_selected.connect(_on_anim_selected.bind(gesture_names)) + _anim_row.play_pressed.connect(_on_anim_play.bind(gesture_names)) + vbox.add_child(_anim_row) - var play_btn := Button.new() - play_btn.text = "\u25b6" - play_btn.flat = false - play_btn.custom_minimum_size = Vector2(36, 30) - play_btn.add_theme_color_override("font_color", UiTheme.bg_dark) - play_btn.add_theme_font_size_override("font_size", 13) - _style_play(play_btn) - play_btn.pressed.connect(_on_anim_play.bind(opt, gesture_names)) - row.add_child(play_btn) - vbox.add_child(row) - return outer + return page func _on_anim_selected(index: int, gesture_names: Array) -> void: @@ -69,8 +55,8 @@ func _on_anim_selected(index: int, gesture_names: Array) -> void: _sound_config.set_sound("gaze_anim", anim) -func _on_anim_play(opt: OptionButton, gesture_names: Array) -> void: - var sel: int = opt.selected +func _on_anim_play(gesture_names: Array) -> void: + var sel: int = _anim_row.get_selected() if sel > 0: var anim_name: String = str(gesture_names[sel - 1]) EventBus.gesture_requested.emit(anim_name) @@ -78,7 +64,7 @@ func _on_anim_play(opt: OptionButton, gesture_names: Array) -> void: func _get_gesture_names() -> Array: if _companion != null: - var idle := _companion.find_child("IdleAnimator", true, false) + var idle: Node = _companion.find_child("IdleAnimator", true, false) if idle != null and idle.gesture_reg != null: return idle.gesture_reg.get_names() return [] diff --git a/shared/godot/ui/settings_page_backend.gd b/shared/godot/ui/settings_page_backend.gd index 2e0839d..c21bdd7 100644 --- a/shared/godot/ui/settings_page_backend.gd +++ b/shared/godot/ui/settings_page_backend.gd @@ -1,45 +1,67 @@ -extends "res://addons/godot-ui/settings_page_base.gd" +extends "res://addons/godot-ui/core/page_base.gd" ## Builds the Backend page for the settings window. ## Endpoints, model parameters, conversation settings. -var speech_url_input: LineEdit -var llm_url_input: LineEdit -var llm_model_input: LineEdit -var temperature_spin: SpinBox -var max_tokens_spin: SpinBox +const ScrollPageScript = preload("res://addons/godot-ui/layout/scroll_page.gd") +const SectionHeaderScript = preload("res://addons/godot-ui/layout/section_header.gd") +const LabeledInputScript = preload("res://addons/godot-ui/form/labeled_input.gd") +const SpinRowScript = preload("res://addons/godot-ui/form/spin_row.gd") +const CheckToggleScript = preload("res://addons/godot-ui/form/check_toggle.gd") +const ActionButtonScript = preload("res://addons/godot-ui/button/action_button.gd") + +var speech_url_input: VBoxContainer +var llm_url_input: VBoxContainer +var llm_model_input: VBoxContainer +var temperature_spin: HBoxContainer +var max_tokens_spin: HBoxContainer var auto_resume_toggle: CheckButton +var retry_btn: Button func build() -> Control: - var scroll := ScrollContainer.new() - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - var outer := _page_margin() - scroll.add_child(outer) - var vbox := _page_vbox() - outer.add_child(vbox) + var page: ScrollContainer = ScrollPageScript.new() + page.setup() + var vbox: VBoxContainer = page.content - vbox.add_child(_header("ENDPOINTS")) - speech_url_input = _labeled_input("Speech Service URL", CompanionConfig.speech_url) - vbox.add_child(speech_url_input.get_parent()) - llm_url_input = _labeled_input("LLM Service URL", CompanionConfig.llm_url) - vbox.add_child(llm_url_input.get_parent()) + _add_header(vbox, "ENDPOINTS") + + speech_url_input = LabeledInputScript.new() + speech_url_input.setup("Speech Service URL", CompanionConfig.speech_url) + vbox.add_child(speech_url_input) + + llm_url_input = LabeledInputScript.new() + llm_url_input.setup("LLM Service URL", CompanionConfig.llm_url) + vbox.add_child(llm_url_input) + + retry_btn = ActionButtonScript.new() + retry_btn.setup("Test Connection") + vbox.add_child(retry_btn) vbox.add_child(_thin_sep()) - vbox.add_child(_header("MODEL")) - llm_model_input = _labeled_input("Model", CompanionConfig.llm_model) - vbox.add_child(llm_model_input.get_parent()) - temperature_spin = _add_spin( - vbox, "Temperature", CompanionConfig.llm_temperature, 0.0, 2.0, 0.1 - ) - max_tokens_spin = _add_spin( - vbox, "Max Tokens", float(CompanionConfig.llm_max_tokens), 50.0, 2000.0, 50.0 - ) + _add_header(vbox, "MODEL") + + llm_model_input = LabeledInputScript.new() + llm_model_input.setup("Model", CompanionConfig.llm_model) + vbox.add_child(llm_model_input) + + temperature_spin = SpinRowScript.new() + temperature_spin.setup("Temperature", CompanionConfig.llm_temperature, 0.0, 2.0, 0.1) + vbox.add_child(temperature_spin) + + max_tokens_spin = SpinRowScript.new() + max_tokens_spin.setup("Max Tokens", float(CompanionConfig.llm_max_tokens), 50.0, 2000.0, 50.0) + vbox.add_child(max_tokens_spin) vbox.add_child(_thin_sep()) - vbox.add_child(_header("CONVERSATION")) - auto_resume_toggle = _check_toggle( - "Resume Last Conversation", CompanionConfig.auto_resume_conversation - ) + _add_header(vbox, "CONVERSATION") + auto_resume_toggle = CheckToggleScript.new() + auto_resume_toggle.setup("Resume Last Conversation", CompanionConfig.auto_resume_conversation) vbox.add_child(auto_resume_toggle) - return scroll + return page + + +func _add_header(parent: VBoxContainer, text: String) -> void: + var header: Label = SectionHeaderScript.new() + header.setup(text) + parent.add_child(header) diff --git a/shared/godot/ui/settings_page_camera.gd b/shared/godot/ui/settings_page_camera.gd index 93ddda0..cbca983 100644 --- a/shared/godot/ui/settings_page_camera.gd +++ b/shared/godot/ui/settings_page_camera.gd @@ -1,19 +1,20 @@ -extends "res://addons/godot-ui/settings_page_base.gd" +extends "res://addons/godot-ui/core/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") - -const STATUS_LOOKING := Color("#2ECC71") -const STATUS_SCREEN := Color("#00BCD4") -const STATUS_AWAY := Color("#FF9800") -const STATUS_ABSENT := Color("#607D8B") +const StatusLabelScript = preload("res://addons/godot-ui/feedback/status_label.gd") +const ScrollPageScript = preload("res://addons/godot-ui/layout/scroll_page.gd") +const SectionHeaderScript = preload("res://addons/godot-ui/layout/section_header.gd") +const CheckToggleScript = preload("res://addons/godot-ui/form/check_toggle.gd") +const SpinRowScript = preload("res://addons/godot-ui/form/spin_row.gd") +const OptionRowScript = preload("res://addons/godot-ui/form/option_row.gd") const PREVIEW_WS_URL := "ws://127.0.0.1:19703" -class _WsPoller extends Node: - ## Thin Node shim so RefCounted settings page gets _process() ticks. +class _WsPoller: + extends Node var page: RefCounted func _process(_delta: float) -> void: @@ -23,10 +24,12 @@ class _WsPoller extends Node: var _companion: Node var _gaze_toggle: CheckButton +var _cooldown_spin: HBoxContainer +var _duration_spin: HBoxContainer +var _margin_spin: HBoxContainer var _camera_option: OptionButton var _camera_name_label: Label -var _attention_dot: ColorRect -var _attention_label: Label +var _status_row: Node var _pose_label: Label var _iris_label: Label var _layout_control: Control @@ -46,43 +49,51 @@ func setup(companion: Node) -> void: func build() -> Control: - var scroll := ScrollContainer.new() - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + var page: ScrollContainer = ScrollPageScript.new() + page.setup() + var vbox: VBoxContainer = page.content - var outer := _page_margin() - scroll.add_child(outer) - var vbox := _page_vbox() - outer.add_child(vbox) - - vbox.add_child(_header("FACE GAZE")) - var gaze_on: bool = _get_gaze_mode() == "face_to_face" - _gaze_toggle = _check_toggle("Enable Face Gaze", gaze_on) + _add_header(vbox, "FACE TRACKING") + var camera_on: bool = AppState.get_camera_enabled() + _gaze_toggle = CheckToggleScript.new() + _gaze_toggle.setup("Enable Camera", camera_on) _gaze_toggle.toggled.connect(_on_gaze_toggled) vbox.add_child(_gaze_toggle) - vbox.add_child(_thin_sep()) - - vbox.add_child(_header("CAMERA")) vbox.add_child(_build_camera_row()) vbox.add_child(_thin_sep()) - vbox.add_child(_header("STATUS")) + _add_header(vbox, "GAZE BEHAVIOR") + vbox.add_child(_build_behavior_section()) + vbox.add_child(_thin_sep()) + + _add_header(vbox, "STATUS") vbox.add_child(_build_status_section()) vbox.add_child(_thin_sep()) - vbox.add_child(_header("PREVIEW")) + _add_header(vbox, "PREVIEW") vbox.add_child(_build_preview_section()) vbox.add_child(_thin_sep()) - vbox.add_child(_header("SCREEN LAYOUT")) - vbox.add_child(_build_preset_row()) + _add_header(vbox, "SCREEN LAYOUT") + var presets: Array[String] = [ + "Top Center", + "Top Left", + "Top Right", + "Left Side", + "Right Side", + "Center (eye-level)", + ] + var preset_row: HBoxContainer = OptionRowScript.new() + preset_row.setup("Camera Position", presets, 0, false, 130.0) + preset_row.item_selected.connect(_on_preset_selected) + vbox.add_child(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) + page.add_child(_ws_poller) EventBus.attention_changed.connect(_on_attention_changed) EventBus.face_lost.connect(_on_face_lost) @@ -90,7 +101,10 @@ func build() -> Control: EventBus.camera_list_updated.connect(_on_camera_list_updated) _refresh_status() - return scroll + if AppState.get_camera_enabled(): + _ws_start() + _send_tray_msg({"cmd": "list_cameras"}) + return page func cleanup() -> void: @@ -105,20 +119,27 @@ func cleanup() -> void: EventBus.camera_list_updated.disconnect(_on_camera_list_updated) +func _add_header(parent: VBoxContainer, text: String) -> void: + var header: Label = SectionHeaderScript.new() + header.setup(text) + parent.add_child(header) + + func _build_camera_row() -> Control: - var col := VBoxContainer.new() + var col: VBoxContainer = VBoxContainer.new() col.size_flags_horizontal = Control.SIZE_EXPAND_FILL col.add_theme_constant_override("separation", 4) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) + var row: HBoxContainer = HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + col.add_child(row) - var lbl := Label.new() - 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) + var device_lbl: Label = Label.new() + device_lbl.text = "Input Device" + device_lbl.custom_minimum_size.x = 110 + device_lbl.add_theme_color_override("font_color", UiTheme.text_primary) + device_lbl.add_theme_font_size_override("font_size", 13) + row.add_child(device_lbl) _camera_option = OptionButton.new() _camera_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -127,8 +148,6 @@ func _build_camera_row() -> Control: _style_option(_camera_option) row.add_child(_camera_option) - col.add_child(row) - _camera_name_label = Label.new() _camera_name_label.text = "" _camera_name_label.add_theme_color_override("font_color", UiTheme.text_muted) @@ -138,25 +157,41 @@ func _build_camera_row() -> Control: return col +func _build_behavior_section() -> Control: + var vbox: VBoxContainer = VBoxContainer.new() + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.add_theme_constant_override("separation", 5) + var bs: Dictionary = _get_behavior_settings() + + _cooldown_spin = SpinRowScript.new() + _cooldown_spin.setup( + "Focus steal cooldown (s)", bs.get("focus_steal_cooldown", 15.0), 5.0, 120.0, 1.0 + ) + _cooldown_spin.value_changed.connect(_on_focus_cooldown) + vbox.add_child(_cooldown_spin) + + _duration_spin = SpinRowScript.new() + _duration_spin.setup("Gaze duration (s)", bs.get("gaze_duration_s", 3.0), 0.5, 10.0, 0.5) + _duration_spin.value_changed.connect(_on_gaze_duration) + vbox.add_child(_duration_spin) + + _margin_spin = SpinRowScript.new() + _margin_spin.setup("Gaze margin (px)", float(bs.get("gaze_margin", 50)), 0.0, 300.0, 10.0) + _margin_spin.value_changed.connect(_on_gaze_margin) + vbox.add_child(_margin_spin) + + return vbox + + func _build_status_section() -> Control: - var vbox := VBoxContainer.new() + var vbox: VBoxContainer = VBoxContainer.new() vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL vbox.add_theme_constant_override("separation", 5) - var att_row := HBoxContainer.new() - att_row.add_theme_constant_override("separation", 8) - - _attention_dot = ColorRect.new() - _attention_dot.custom_minimum_size = Vector2(10, 10) - _attention_dot.color = STATUS_ABSENT - att_row.add_child(_attention_dot) - - _attention_label = Label.new() - _attention_label.text = "No face detected" - _attention_label.add_theme_color_override("font_color", UiTheme.text_muted) - _attention_label.add_theme_font_size_override("font_size", 13) - att_row.add_child(_attention_label) - vbox.add_child(att_row) + _status_row = StatusLabelScript.new() + (_status_row as StatusLabelScript).setup() + (_status_row as StatusLabelScript).set_status("absent", "No face detected") + vbox.add_child(_status_row) _pose_label = Label.new() _pose_label.text = "Head pose: —" @@ -174,7 +209,7 @@ func _build_status_section() -> Control: func _build_preview_section() -> Control: - var vbox := VBoxContainer.new() + var vbox: VBoxContainer = VBoxContainer.new() vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL vbox.add_theme_constant_override("separation", 6) @@ -187,7 +222,7 @@ func _build_preview_section() -> Control: vbox.add_child(_preview_btn) _preview_texture_rect = TextureRect.new() - _preview_texture_rect.custom_minimum_size = Vector2(480, 270) + _preview_texture_rect.custom_minimum_size = Vector2(640, 360) _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 @@ -197,51 +232,27 @@ func _build_preview_section() -> Control: return vbox -func _build_preset_row() -> Control: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 8) - - var lbl := Label.new() - lbl.text = "Camera Position" - 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) - - var opt := OptionButton.new() - opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL - opt.add_theme_font_size_override("font_size", 13) - opt.add_item("Top Center", 0) - opt.add_item("Top Left", 1) - opt.add_item("Top Right", 2) - opt.add_item("Left Side", 3) - 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 - - func _refresh_status() -> void: - if _get_gaze_mode() != "face_to_face": - _attention_dot.color = STATUS_ABSENT - _attention_label.text = "Face gaze disabled" - _attention_label.add_theme_color_override("font_color", UiTheme.text_muted) + if not AppState.get_camera_enabled(): + (_status_row as StatusLabelScript).set_status("absent", "Face gaze disabled") # -- WebSocket preview -------------------------------------------------------- +func _ws_start() -> void: + _ws = WebSocketPeer.new() + _ws.connect_to_url(PREVIEW_WS_URL) + _ws_active = true + _preview_texture_rect.visible = true + _preview_btn.text = "Stop Preview" + _preview_btn.set_pressed_no_signal(true) + FlightRecorder.record("camera.preview_started", "Camera preview stream opened") + + 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") + _ws_start() else: _ws_stop() FlightRecorder.record("camera.preview_stopped", "Camera preview stream closed") @@ -251,11 +262,11 @@ func _ws_tick() -> void: if not _ws_active or _ws == null: return _ws.poll() - var state := _ws.get_ready_state() + var state: int = _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() + var data: PackedByteArray = _ws.get_packet() + var img: Image = Image.new() if img.load_jpg_from_buffer(data) == OK: if _preview_texture == null: _preview_texture = ImageTexture.create_from_image(img) @@ -282,51 +293,44 @@ func _ws_stop() -> void: func _on_gaze_toggled(on: bool) -> void: - var gaze := _get_gaze_controller() - if gaze == null: + if AppState.get_camera_enabled() == on: return - 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") + AppState.set_camera_enabled(on) + _send_tray_msg({"cmd": "set_camera_enabled", "enabled": on}) + ( + FlightRecorder + . record( + "camera.capture_toggled", + "Camera capture toggled via settings", + {"enabled": on}, + ) + ) 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) + AppState.set_active_camera(index) _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}, + ( + FlightRecorder + . record( + "camera.selected", + "Camera changed via settings", + {"index": index, "name": camera_name}, + ) ) func _on_attention_changed(state: String, confidence: float) -> void: - var color: Color = STATUS_ABSENT - match state: - "looking": - color = STATUS_LOOKING - "screen": - color = STATUS_SCREEN - "away": - color = STATUS_AWAY - _attention_dot.color = color - _attention_label.text = "Attention: %s (%.0f%%)" % [state, confidence * 100.0] - _attention_label.add_theme_color_override("font_color", UiTheme.text_primary) + (_status_row as StatusLabelScript).set_status( + state, "Attention: %s (%.0f%%)" % [state, confidence * 100.0] + ) func _on_face_lost() -> void: - _attention_dot.color = STATUS_ABSENT - _attention_label.text = "No face detected" - _attention_label.add_theme_color_override("font_color", UiTheme.text_muted) + (_status_row as StatusLabelScript).set_status("absent", "No face detected") _pose_label.text = "Head pose: —" _pose_label.add_theme_color_override("font_color", UiTheme.text_muted) _iris_label.text = "Iris gaze: —" @@ -343,13 +347,23 @@ func _on_pose_updated(yaw: float, pitch: float, iris_h: float, iris_v: float) -> func _on_preset_selected(index: int) -> void: if _layout_control == null: return - const PRESETS := ["top-center", "top-left", "top-right", "left", "right", "center"] + 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]}, + ( + FlightRecorder + . record( + "camera.preset_selected", + "Camera position preset changed", + {"preset": PRESETS[index]}, + ) ) @@ -362,37 +376,46 @@ func _on_camera_list_updated(cameras: Array) -> void: for cam: Variant in cameras: 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 + var cam_name: String = str(cam.get("name", "Camera %d" % idx)) + _camera_option.add_item("%d — %s" % [idx, cam_name], idx) for i: int in range(_camera_option.item_count): if _camera_option.get_item_id(i) == active: _camera_option.select(i) break +func _on_focus_cooldown(val: float) -> void: + AppState.set_focus_steal_cooldown(val) + + +func _on_gaze_duration(val: float) -> void: + AppState.set_gaze_duration(val) + + +func _on_gaze_margin(val: float) -> void: + AppState.set_gaze_margin(int(val)) + + # -- Helpers ------------------------------------------------------------------- + + + +func _get_behavior_settings() -> Dictionary: + return { + "focus_steal_cooldown": AppState.get_focus_steal_cooldown(), + "gaze_duration_s": AppState.get_gaze_duration(), + "gaze_margin": AppState.get_gaze_margin(), + } + + func _send_tray_msg(msg: Dictionary) -> void: - var udp := PacketPeerUDP.new() + var udp: PacketPeerUDP = PacketPeerUDP.new() udp.set_dest_address("127.0.0.1", 19701) udp.put_packet(JSON.stringify(msg).to_utf8_buffer()) udp.close() -func _get_gaze_controller() -> Node: - if _companion != null: - return _companion.find_child("GazeController", true, false) - return null - - -func _get_gaze_mode() -> String: - var gaze := _get_gaze_controller() - if gaze != null: - return gaze.get_mode_name() - return "desktop" - - func _get_active_camera() -> int: - return int(AppState.get_section("tray").get("active_camera", 0)) + return AppState.get_active_camera() diff --git a/shared/godot/ui/settings_page_general.gd b/shared/godot/ui/settings_page_general.gd index 62838e6..f28f671 100644 --- a/shared/godot/ui/settings_page_general.gd +++ b/shared/godot/ui/settings_page_general.gd @@ -1,12 +1,18 @@ -extends "res://addons/godot-ui/settings_page_base.gd" +extends "res://addons/godot-ui/core/page_base.gd" ## Builds the General page for the settings window. ## Voice, display, zoom, actions, gaze behavior sections. +const ScrollPageScript = preload("res://addons/godot-ui/layout/scroll_page.gd") +const SectionHeaderScript = preload("res://addons/godot-ui/layout/section_header.gd") +const CheckToggleScript = preload("res://addons/godot-ui/form/check_toggle.gd") +const SliderRowScript = preload("res://addons/godot-ui/form/slider_row.gd") +const ActionButtonScript = preload("res://addons/godot-ui/button/action_button.gd") + var stt_toggle: CheckButton var tts_toggle: CheckButton var snap_toggle: CheckButton -var gaze_toggle: CheckButton var halo_toggle: CheckButton +var gaze_toggle: CheckButton var zoom_slider: HSlider var zoom_label: Label var reset_btn: Button @@ -22,119 +28,62 @@ func setup(companion: Node) -> void: func build() -> Control: - var scroll := ScrollContainer.new() - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - var outer := _page_margin() - scroll.add_child(outer) - var vbox := _page_vbox() - outer.add_child(vbox) + var page: ScrollContainer = ScrollPageScript.new() + page.setup() + var vbox: VBoxContainer = page.content - vbox.add_child(_header("VOICE")) - stt_toggle = _check_toggle("Voice Input (STT)", CompanionConfig.stt_enabled) - vbox.add_child(stt_toggle) - tts_toggle = _check_toggle("Voice Output (TTS)", CompanionConfig.tts_enabled) - vbox.add_child(tts_toggle) + _add_header(vbox, "VOICE") + stt_toggle = _add_toggle(vbox, "Voice Input (STT)", CompanionConfig.stt_enabled) + tts_toggle = _add_toggle(vbox, "Voice Output (TTS)", CompanionConfig.tts_enabled) vbox.add_child(_thin_sep()) - vbox.add_child(_header("DISPLAY")) - halo_toggle = _check_toggle("Gaze Halo", AppState.get_companion("gaze_halo", false)) - vbox.add_child(halo_toggle) - snap_toggle = _check_toggle("Snap to Edges", _get_snap_enabled()) - vbox.add_child(snap_toggle) - var gaze_on: bool = _get_gaze_mode() == "face_to_face" - gaze_toggle = _check_toggle("Face Gaze", gaze_on) - vbox.add_child(gaze_toggle) + _add_header(vbox, "DISPLAY") + halo_toggle = _add_toggle(vbox, "Gaze Halo", AppState.get_companion("gaze_halo", false)) + snap_toggle = _add_toggle(vbox, "Snap to Edges", _get_snap_enabled()) vbox.add_child(_thin_sep()) - vbox.add_child(_header("ZOOM")) - vbox.add_child(_build_zoom_row()) + _add_header(vbox, "ZOOM") + var slider_row: HBoxContainer = SliderRowScript.new() + slider_row.setup("Zoom", 0.15, 1.0, 0.01, _get_zoom(), "%d%%", 100.0) + zoom_slider = slider_row.slider + zoom_label = slider_row.value_label + vbox.add_child(slider_row) vbox.add_child(_thin_sep()) - vbox.add_child(_header("ACTIONS")) - reset_btn = _make_reset_btn() + _add_header(vbox, "ACTIONS") + reset_btn = ActionButtonScript.new() + reset_btn.setup("Reset Position") + reset_btn.custom_minimum_size.y = 32 vbox.add_child(reset_btn) vbox.add_child(_thin_sep()) - vbox.add_child(_header("GAZE BEHAVIOR")) - var bs: Dictionary = _get_behavior_settings() - cooldown_spin = _add_spin( - vbox, "Focus steal cooldown (s)", bs.get("focus_steal_cooldown", 15.0), 5.0, 120.0, 1.0 - ) - duration_spin = _add_spin( - vbox, "Gaze duration (s)", bs.get("gaze_duration_s", 3.0), 0.5, 10.0, 0.5 - ) - margin_spin = _add_spin( - vbox, "Gaze margin (px)", float(bs.get("gaze_margin", 50)), 0.0, 300.0, 10.0 - ) - - return scroll + return page -func _build_zoom_row() -> Control: - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) - zoom_slider = HSlider.new() - zoom_slider.min_value = 0.15 - zoom_slider.max_value = 1.0 - zoom_slider.step = 0.01 - zoom_slider.value = _get_zoom() - zoom_slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL - zoom_slider.custom_minimum_size.x = 200 - row.add_child(zoom_slider) - zoom_label = Label.new() - zoom_label.text = "%d%%" % int(_get_zoom() * 100) - zoom_label.custom_minimum_size.x = 44 - zoom_label.add_theme_color_override("font_color", UiTheme.text_primary) - zoom_label.add_theme_font_size_override("font_size", 13) - row.add_child(zoom_label) - return row +func _add_header(parent: VBoxContainer, text: String) -> void: + var header: Label = SectionHeaderScript.new() + header.setup(text) + parent.add_child(header) -func _make_reset_btn() -> Button: - var btn := Button.new() - btn.text = "Reset Position" - btn.custom_minimum_size.y = 32 - _style_action(btn) - return btn - - -# -- Companion accessors ------------------------------------------------------ +func _add_toggle(parent: VBoxContainer, label_text: String, initial: bool) -> CheckButton: + var toggle: CheckButton = CheckToggleScript.new() + toggle.setup(label_text, initial) + parent.add_child(toggle) + return toggle func _get_snap_enabled() -> bool: if _companion != null: - var snap := _companion.get_node_or_null("EdgeSnap") + var snap: Node = _companion.get_node_or_null("EdgeSnap") if snap != null: return snap.enabled return false -func _get_gaze_mode() -> String: - if _companion != null: - var gaze := _companion.find_child("GazeController", true, false) - if gaze != null: - return gaze.get_mode_name() - return "desktop" - - func _get_zoom() -> float: if _companion != null: - var zoom := _companion.get_node_or_null("WindowZoom") + var zoom: Node = _companion.get_node_or_null("WindowZoom") if zoom != null: return zoom.get_zoom() return 0.5 - - -func _get_behavior_settings() -> Dictionary: - var tray_section: Dictionary = AppState.get_section("tray") - return ( - tray_section - . get( - "behavior_settings", - { - "focus_steal_cooldown": 15.0, - "gaze_duration_s": 3.0, - "gaze_margin": 50, - } - ) - ) diff --git a/shared/godot/ui/settings_page_personality.gd b/shared/godot/ui/settings_page_personality.gd index 484d385..8bf5134 100644 --- a/shared/godot/ui/settings_page_personality.gd +++ b/shared/godot/ui/settings_page_personality.gd @@ -1,47 +1,39 @@ -extends "res://addons/godot-ui/settings_page_base.gd" +extends "res://addons/godot-ui/core/page_base.gd" ## Builds the Personality page for the settings window. ## Scans res://config/personalities and lets the user pick the active personality. const PersonalityRegistryScript = preload("res://src/conversation/personality_registry.gd") +const ScrollPageScript = preload("res://addons/godot-ui/layout/scroll_page.gd") +const SectionHeaderScript = preload("res://addons/godot-ui/layout/section_header.gd") +const OptionRowScript = preload("res://addons/godot-ui/form/option_row.gd") func build() -> Control: - var outer := _page_margin() - var vbox := _page_vbox() - outer.add_child(vbox) - vbox.add_child(_header("ACTIVE PERSONALITY")) + var page: ScrollContainer = ScrollPageScript.new() + page.setup() + var vbox: VBoxContainer = page.content - var registry := PersonalityRegistryScript.new() + var header: Label = SectionHeaderScript.new() + header.setup("ACTIVE PERSONALITY") + vbox.add_child(header) + + var registry: RefCounted = PersonalityRegistryScript.new() var entries: Array[Dictionary] = registry.scan("res://config/personalities") - var row := HBoxContainer.new() - row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.add_theme_constant_override("separation", 8) - - var lbl := Label.new() - lbl.text = "Personality" - lbl.custom_minimum_size.x = 100 - lbl.add_theme_color_override("font_color", UiTheme.text_primary) - lbl.add_theme_font_size_override("font_size", 13) - row.add_child(lbl) - - var opt := OptionButton.new() - opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _style_option(opt) - + var names: Array[String] = [] var current_id: String = CompanionConfig.personality_id var selected_idx: int = 0 for i: int in range(entries.size()): - var entry: Dictionary = entries[i] - opt.add_item(entry["name"], i) - opt.set_item_metadata(i, entry) - if entry["id"] == current_id: + names.append(entries[i]["name"]) + if entries[i]["id"] == current_id: selected_idx = i - opt.select(selected_idx) - row.add_child(opt) + + var row: HBoxContainer = OptionRowScript.new() + row.setup("Personality", names, selected_idx, false, 100.0) + row.item_selected.connect(_on_item_selected.bind(entries)) vbox.add_child(row) - var desc_label := Label.new() + var desc_label: Label = Label.new() desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART desc_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL desc_label.add_theme_color_override("font_color", UiTheme.text_muted) @@ -50,14 +42,13 @@ func build() -> Control: desc_label.text = entries[selected_idx].get("description", "") vbox.add_child(desc_label) - opt.item_selected.connect(_on_item_selected.bind(entries)) - opt.item_selected.connect( + row.item_selected.connect( func(idx: int) -> void: if idx < entries.size(): desc_label.text = entries[idx].get("description", "") ) - return outer + return page func _on_item_selected(idx: int, entries: Array[Dictionary]) -> void: diff --git a/shared/godot/ui/settings_page_sounds.gd b/shared/godot/ui/settings_page_sounds.gd index af592b2..e890625 100644 --- a/shared/godot/ui/settings_page_sounds.gd +++ b/shared/godot/ui/settings_page_sounds.gd @@ -1,10 +1,14 @@ -extends "res://addons/godot-ui/settings_page_base.gd" +extends "res://addons/godot-ui/core/page_base.gd" ## Builds the Sounds page for the settings window. ## Event→sound mapping dropdowns with play buttons. +const ScrollPageScript = preload("res://addons/godot-ui/layout/scroll_page.gd") +const SectionHeaderScript = preload("res://addons/godot-ui/layout/section_header.gd") +const OptionRowScript = preload("res://addons/godot-ui/form/option_row.gd") + var _sound_config: Node var _sound_engine: Node -var _option_buttons: Dictionary = {} +var _option_rows: Dictionary = {} func setup(sound_config: Node, sound_engine: Node) -> void: @@ -13,109 +17,60 @@ func setup(sound_config: Node, sound_engine: Node) -> void: func build() -> Control: - var scroll := ScrollContainer.new() - scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + var page: ScrollContainer = ScrollPageScript.new() + page.setup(16, 14, 14, 8) + var vbox: VBoxContainer = page.content - var outer := _page_margin() - scroll.add_child(outer) - - var vbox := VBoxContainer.new() - vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL - vbox.add_theme_constant_override("separation", 8) - outer.add_child(vbox) - - vbox.add_child(_header("EVENT SOUNDS")) + var header: Label = SectionHeaderScript.new() + header.setup("EVENT SOUNDS") + vbox.add_child(header) if _sound_config == null: - var warn := Label.new() + var warn: Label = Label.new() warn.text = "SoundConfig not available." warn.add_theme_color_override("font_color", UiTheme.text_muted) vbox.add_child(warn) - return scroll + return page var slots: Dictionary = _sound_config.get_slots() var sound_names: Array[String] = [] if _sound_engine != null: sound_names = _sound_engine.get_sound_names() + var options: Array[String] = ["(none)"] + for snd: String in sound_names: + options.append(snd) + for slot_key: String in slots.keys(): if slot_key == "gaze_anim": continue - var row := _build_row(slot_key, slots[slot_key], sound_names) + var current: String = _sound_config.get_sound(slot_key) + var selected: int = 0 + if not current.is_empty(): + var idx: int = sound_names.find(current) + selected = idx + 1 if idx >= 0 else 0 + + var row: HBoxContainer = OptionRowScript.new() + row.setup(slots[slot_key], options, selected, true) + row.item_selected.connect(_on_option_changed.bind(slot_key, sound_names)) + row.play_pressed.connect(_on_play.bind(row, sound_names)) vbox.add_child(row) + _option_rows[slot_key] = row - return scroll + return page -func _build_row( - slot_key: String, - label_text: String, - sound_names: Array[String], -) -> Control: - var row := HBoxContainer.new() - row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.add_theme_constant_override("separation", 8) - - var lbl := Label.new() - lbl.text = label_text - lbl.custom_minimum_size.x = 140 - lbl.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - lbl.add_theme_color_override("font_color", UiTheme.text_primary) - lbl.add_theme_font_size_override("font_size", 13) - row.add_child(lbl) - - var opt := OptionButton.new() - opt.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _style_option(opt) - - opt.add_item("(none)", 0) - for i: int in range(sound_names.size()): - opt.add_item(sound_names[i], i + 1) - - var current: String = _sound_config.get_sound(slot_key) - if current.is_empty(): - opt.select(0) - else: - var idx: int = sound_names.find(current) - opt.select(idx + 1 if idx >= 0 else 0) - - opt.item_selected.connect(_on_option_changed.bind(slot_key, opt, sound_names)) - row.add_child(opt) - _option_buttons[slot_key] = opt - - var play_btn := Button.new() - play_btn.text = "\u25b6" - play_btn.flat = false - play_btn.custom_minimum_size = Vector2(36, 30) - play_btn.add_theme_color_override("font_color", UiTheme.bg_dark) - play_btn.add_theme_font_size_override("font_size", 13) - _style_play(play_btn) - play_btn.pressed.connect(_on_play.bind(opt, sound_names)) - row.add_child(play_btn) - - return row - - -func _on_option_changed( - _index: int, - slot_key: String, - opt: OptionButton, - sound_names: Array[String], -) -> void: - var selected_idx: int = opt.selected +func _on_option_changed(index: int, slot_key: String, sound_names: Array[String]) -> void: var sound: String = "" - if selected_idx > 0: - sound = sound_names[selected_idx - 1] + if index > 0: + sound = sound_names[index - 1] if _sound_config != null: _sound_config.set_sound(slot_key, sound) -func _on_play( - opt: OptionButton, - sound_names: Array[String], -) -> void: - var selected_idx: int = opt.selected - if selected_idx == 0 or _sound_engine == null: +func _on_play(row: HBoxContainer, sound_names: Array[String]) -> void: + var selected: int = row.get_selected() + if selected == 0 or _sound_engine == null: return - var sound: String = sound_names[selected_idx - 1] + var sound: String = sound_names[selected - 1] _sound_engine.play_sound(sound) diff --git a/shared/godot/ui/settings_page_tts.gd b/shared/godot/ui/settings_page_tts.gd new file mode 100644 index 0000000..3b4aa3f --- /dev/null +++ b/shared/godot/ui/settings_page_tts.gd @@ -0,0 +1,114 @@ +extends "res://addons/godot-ui/core/page_base.gd" +## Builds the Voice (TTS) page for the settings window. +## Exaggeration, CFG weight, and emotion parameter override. + +var exaggeration_slider: HSlider +var exaggeration_label: Label +var cfg_weight_slider: HSlider +var cfg_weight_label: Label +var emotion_params_toggle: CheckButton + + +func build() -> Control: + var scroll: ScrollContainer = ScrollContainer.new() + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + var outer: MarginContainer = _page_margin() + scroll.add_child(outer) + var vbox: VBoxContainer = _page_vbox() + outer.add_child(vbox) + + vbox.add_child(_header("VOICE PARAMETERS")) + _add_exaggeration_row(vbox) + _add_cfg_weight_row(vbox) + vbox.add_child(_thin_sep()) + + vbox.add_child(_header("EMOTION")) + emotion_params_toggle = _check_toggle( + "Emotion-Based Parameters", CompanionConfig.tts_use_emotion_params + ) + vbox.add_child(emotion_params_toggle) + + var hint: Label = Label.new() + hint.text = "When enabled, emotion tags in responses override the sliders above." + hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + hint.add_theme_color_override("font_color", UiTheme.text_muted) + hint.add_theme_font_size_override("font_size", 11) + vbox.add_child(hint) + + exaggeration_slider.value_changed.connect(_on_exaggeration_changed) + cfg_weight_slider.value_changed.connect(_on_cfg_weight_changed) + emotion_params_toggle.toggled.connect(_on_emotion_params_toggled) + + _apply_slider_state(CompanionConfig.tts_use_emotion_params) + + return scroll + + +func _add_exaggeration_row(vbox: VBoxContainer) -> void: + var row: HBoxContainer = _slider_row("Exaggeration", CompanionConfig.tts_exaggeration) + vbox.add_child(row) + + +func _add_cfg_weight_row(vbox: VBoxContainer) -> void: + var row: HBoxContainer = _slider_row("Clarity (CFG Weight)", CompanionConfig.tts_cfg_weight) + vbox.add_child(row) + + +func _slider_row(label_text: String, initial: float) -> HBoxContainer: + var row: HBoxContainer = HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + + var lbl: Label = Label.new() + lbl.text = label_text + lbl.custom_minimum_size.x = 160 + lbl.add_theme_color_override("font_color", UiTheme.text_primary) + lbl.add_theme_font_size_override("font_size", 13) + row.add_child(lbl) + + var slider: HSlider = HSlider.new() + slider.min_value = 0.0 + slider.max_value = 1.0 + slider.step = 0.01 + slider.value = initial + slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + slider.custom_minimum_size.x = 160 + row.add_child(slider) + + var val_lbl: Label = Label.new() + val_lbl.text = "%.2f" % initial + val_lbl.custom_minimum_size.x = 36 + val_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + val_lbl.add_theme_color_override("font_color", UiTheme.text_primary) + val_lbl.add_theme_font_size_override("font_size", 13) + row.add_child(val_lbl) + + if label_text == "Exaggeration": + exaggeration_slider = slider + exaggeration_label = val_lbl + else: + cfg_weight_slider = slider + cfg_weight_label = val_lbl + + return row + + +func _on_exaggeration_changed(value: float) -> void: + exaggeration_label.text = "%.2f" % value + + +func _on_cfg_weight_changed(value: float) -> void: + cfg_weight_label.text = "%.2f" % value + + +func _on_emotion_params_toggled(on: bool) -> void: + _apply_slider_state(on) + + +func _apply_slider_state(emotion_on: bool) -> void: + exaggeration_slider.editable = not emotion_on + cfg_weight_slider.editable = not emotion_on + var alpha: float = 0.4 if emotion_on else 1.0 + exaggeration_slider.modulate.a = alpha + cfg_weight_slider.modulate.a = alpha + exaggeration_label.modulate.a = alpha + cfg_weight_label.modulate.a = alpha diff --git a/shared/godot/ui/settings_page_tts.gd.uid b/shared/godot/ui/settings_page_tts.gd.uid new file mode 100644 index 0000000..55e7efe --- /dev/null +++ b/shared/godot/ui/settings_page_tts.gd.uid @@ -0,0 +1 @@ +uid://btq0i4p3vwx0l diff --git a/shared/godot/ui/settings_window.gd b/shared/godot/ui/settings_window.gd index 5c6be4b..0c5e594 100644 --- a/shared/godot/ui/settings_window.gd +++ b/shared/godot/ui/settings_window.gd @@ -1,24 +1,31 @@ extends "res://src/ui/panel_window.gd" ## Unified settings panel — replaces the GTK control board. -## Sidebar navigation: General, Sounds, Animations, Camera. +## Sidebar navigation: General, Backend, Personality, Sounds, Animations, Camera. +const SidebarNavScript = preload("res://addons/godot-ui/core/sidebar_nav.gd") const GeneralPageScript = preload("res://src/ui/settings_page_general.gd") const BackendPageScript = preload("res://src/ui/settings_page_backend.gd") +const TtsPageScript = preload("res://src/ui/settings_page_tts.gd") const SoundsPageScript = preload("res://src/ui/settings_page_sounds.gd") const CameraPageScript = preload("res://src/ui/settings_page_camera.gd") const PersonalityPageScript = preload("res://src/ui/settings_page_personality.gd") const AnimationsPageScript = preload("res://src/ui/settings_page_animations.gd") -const SIDEBAR_W := 140 + +const _DEFAULT_SIZE := Vector2i(660, 520) +const _DEFAULT_MIN := Vector2i(620, 480) +const _CAMERA_SIZE := Vector2i(900, 580) +const _CAMERA_MIN := Vector2i(860, 480) var _companion: Node var _sound_config: Node var _sound_engine: Node var _general_page: RefCounted var _backend_page: RefCounted +var _tts_page: RefCounted var _camera_page: RefCounted +var _sidebar_nav: PanelContainer var _pages: Dictionary = {} -var _tab_buttons: Dictionary = {} var _active_tab: String = "general" @@ -33,9 +40,10 @@ func setup( func _ready() -> void: - min_size = Vector2i(620, 480) - size = Vector2i(660, 520) + min_size = _DEFAULT_MIN + size = _DEFAULT_SIZE super._ready() + _update_panel_size() func _on_theme_changed() -> void: @@ -60,7 +68,7 @@ func _build_ui() -> void: root.add_theme_constant_override("separation", 0) bg.add_child(root) - root.add_child(_build_panel_title_bar("SETTINGS")) + root.add_child(_build_title_bar("SETTINGS")) root.add_child(_build_divider()) var body := HBoxContainer.new() @@ -68,7 +76,20 @@ func _build_ui() -> void: body.add_theme_constant_override("separation", 0) root.add_child(body) - body.add_child(_build_sidebar()) + _sidebar_nav = SidebarNavScript.new() + var tabs: Array[Dictionary] = [ + {"key": "general", "label": "General"}, + {"key": "backend", "label": "Backend"}, + {"key": "tts", "label": "Voice"}, + {"key": "personality", "label": "Personality"}, + {"key": "sounds", "label": "Sounds"}, + {"key": "animations", "label": "Animations"}, + {"key": "camera", "label": "Camera"}, + ] + _sidebar_nav.setup(tabs) + _sidebar_nav.tab_selected.connect(_switch_tab) + _sidebar_nav.set_active(_active_tab) + body.add_child(_sidebar_nav) body.add_child(_sidebar_line()) body.add_child(_build_page_area()) @@ -77,103 +98,6 @@ func show_tab(tab_key: String) -> void: _switch_tab(tab_key) -# -- Sidebar ------------------------------------------------------------------ - - -func _build_sidebar() -> Control: - var panel := PanelContainer.new() - panel.custom_minimum_size.x = SIDEBAR_W - panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - var style := StyleBoxFlat.new() - style.bg_color = UiTheme.sidebar_bg - style.corner_radius_bottom_left = 10 - panel.add_theme_stylebox_override("panel", style) - - var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_top", 8) - margin.add_theme_constant_override("margin_bottom", 8) - margin.add_theme_constant_override("margin_left", 6) - margin.add_theme_constant_override("margin_right", 6) - panel.add_child(margin) - - var vbox := VBoxContainer.new() - vbox.add_theme_constant_override("separation", 2) - margin.add_child(vbox) - - var tabs: Array[Array] = [ - ["general", "General"], - ["backend", "Backend"], - ["personality", "Personality"], - ["sounds", "Sounds"], - ["animations", "Animations"], - ["camera", "Camera"], - ] - for pair: Array in tabs: - var btn := _tab_btn(pair[1], pair[0]) - vbox.add_child(btn) - _tab_buttons[pair[0]] = btn - - _update_tab_highlight() - return panel - - -func _tab_btn(label_text: String, tab_key: String) -> Button: - var btn := Button.new() - btn.text = label_text - btn.flat = true - btn.alignment = HORIZONTAL_ALIGNMENT_LEFT - btn.custom_minimum_size.y = 34 - btn.add_theme_color_override("font_color", UiTheme.text_muted) - btn.add_theme_color_override("font_hover_color", UiTheme.text_primary) - btn.add_theme_font_size_override("font_size", 13) - _apply_tab_style(btn, false) - btn.pressed.connect(_switch_tab.bind(tab_key)) - return btn - - -func _apply_tab_style(btn: Button, active: bool) -> void: - var bg_color: Color = UiTheme.sidebar_active if active else Color.TRANSPARENT - var style := StyleBoxFlat.new() - style.bg_color = bg_color - style.set_corner_radius_all(6) - style.content_margin_left = 10 - style.content_margin_right = 10 - btn.add_theme_stylebox_override("normal", style) - var hover := StyleBoxFlat.new() - hover.bg_color = UiTheme.sidebar_hover - hover.set_corner_radius_all(6) - hover.content_margin_left = 10 - hover.content_margin_right = 10 - btn.add_theme_stylebox_override("hover", hover) - - -func _sidebar_line() -> Control: - var line := ColorRect.new() - line.color = UiTheme.border - line.custom_minimum_size.x = 1 - line.size_flags_vertical = Control.SIZE_EXPAND_FILL - return line - - -func _switch_tab(tab_key: String) -> void: - if tab_key == _active_tab: - return - _active_tab = tab_key - _update_tab_highlight() - for key: String in _pages: - _pages[key].visible = (key == tab_key) - - -func _update_tab_highlight() -> void: - for key: String in _tab_buttons: - var btn: Button = _tab_buttons[key] - var is_active: bool = key == _active_tab - btn.add_theme_color_override( - "font_color", UiTheme.accent if is_active else UiTheme.text_muted - ) - _apply_tab_style(btn, is_active) - - # -- Pages -------------------------------------------------------------------- @@ -184,6 +108,7 @@ func _build_page_area() -> Control: _pages["general"] = _build_general_page() _pages["backend"] = _build_backend_page() + _pages["tts"] = _build_tts_page() _pages["personality"] = _build_personality_page() _pages["sounds"] = _build_sounds_page() _pages["animations"] = _build_animations_page() @@ -203,17 +128,12 @@ func _build_general_page() -> Control: _general_page.setup(_companion) var ctrl: Control = _general_page.build() - # Wire callbacks via exposed widget refs _general_page.stt_toggle.toggled.connect(_on_stt) _general_page.tts_toggle.toggled.connect(_on_tts) _general_page.halo_toggle.toggled.connect(_on_halo_toggled) _general_page.snap_toggle.toggled.connect(_on_snap_toggled) - _general_page.gaze_toggle.toggled.connect(_on_gaze_toggled) _general_page.zoom_slider.value_changed.connect(_on_zoom_changed) _general_page.reset_btn.pressed.connect(_on_reset_pressed) - _general_page.cooldown_spin.value_changed.connect(_on_focus_cooldown) - _general_page.duration_spin.value_changed.connect(_on_gaze_duration) - _general_page.margin_spin.value_changed.connect(_on_gaze_margin) return ctrl @@ -222,20 +142,30 @@ func _build_backend_page() -> Control: var ctrl: Control = _backend_page.build() _backend_page.speech_url_input.text_submitted.connect(_on_speech_url) - _backend_page.speech_url_input.focus_exited.connect( - func() -> void: _on_speech_url(_backend_page.speech_url_input.text) + _backend_page.speech_url_input.input_focus_exited.connect( + func() -> void: _on_speech_url(_backend_page.speech_url_input.get_text()) ) _backend_page.llm_url_input.text_submitted.connect(_on_llm_url) - _backend_page.llm_url_input.focus_exited.connect( - func() -> void: _on_llm_url(_backend_page.llm_url_input.text) + _backend_page.llm_url_input.input_focus_exited.connect( + func() -> void: _on_llm_url(_backend_page.llm_url_input.get_text()) ) _backend_page.llm_model_input.text_submitted.connect(_on_llm_model) - _backend_page.llm_model_input.focus_exited.connect( - func() -> void: _on_llm_model(_backend_page.llm_model_input.text) + _backend_page.llm_model_input.input_focus_exited.connect( + func() -> void: _on_llm_model(_backend_page.llm_model_input.get_text()) ) _backend_page.temperature_spin.value_changed.connect(_on_temperature) _backend_page.max_tokens_spin.value_changed.connect(_on_max_tokens) _backend_page.auto_resume_toggle.toggled.connect(_on_auto_resume) + _backend_page.retry_btn.pressed.connect(_on_retry_services) + return ctrl + + +func _build_tts_page() -> Control: + _tts_page = TtsPageScript.new() + var ctrl: Control = _tts_page.build() + _tts_page.exaggeration_slider.value_changed.connect(_on_tts_exaggeration) + _tts_page.cfg_weight_slider.value_changed.connect(_on_tts_cfg_weight) + _tts_page.emotion_params_toggle.toggled.connect(_on_tts_emotion_params) return ctrl @@ -262,30 +192,38 @@ func _build_camera_page() -> Control: return _camera_page.build() +# -- Tab switching ------------------------------------------------------------ + + +func _switch_tab(tab_key: String) -> void: + if tab_key == _active_tab: + return + _active_tab = tab_key + _sidebar_nav.set_active(tab_key) + _update_panel_size() + for key: String in _pages: + _pages[key].visible = (key == tab_key) + + +func _update_panel_size() -> void: + if _active_tab == "camera": + min_size = _CAMERA_MIN + size = _CAMERA_SIZE + else: + min_size = _DEFAULT_MIN + size = _DEFAULT_SIZE + + +func _sidebar_line() -> Control: + var line := ColorRect.new() + line.color = UiTheme.border + line.custom_minimum_size.x = 1 + line.size_flags_vertical = Control.SIZE_EXPAND_FILL + return line + + # -- Accessors ---------------------------------------------------------------- - -func _get_gaze_controller() -> Node: - if _companion != null: - return _companion.find_child("GazeController", true, false) - return null - - -func _get_gaze_mode() -> String: - var gaze := _get_gaze_controller() - if gaze != null: - return gaze.get_mode_name() - return "desktop" - - -func _set_behavior(key: String, value: Variant) -> void: - var tray_data: Dictionary = AppState.get_section("tray") - var bs: Dictionary = tray_data.get("behavior_settings", {}) - bs[key] = value - tray_data["behavior_settings"] = bs - AppState.set_section("tray", tray_data) - - # -- Callbacks ---------------------------------------------------------------- @@ -309,17 +247,6 @@ func _on_snap_toggled(on: bool) -> void: snap.enabled = on -func _on_gaze_toggled(on: bool) -> void: - var gaze := _get_gaze_controller() - if gaze == null: - return - var current_mode: String = gaze.get_mode_name() - if on and current_mode == "desktop": - gaze.toggle_mode() - elif not on and current_mode == "face_to_face": - gaze.toggle_mode() - - func _on_zoom_changed(value: float) -> void: if _companion != null: var zoom := _companion.get_node_or_null("WindowZoom") @@ -336,18 +263,6 @@ func _on_reset_pressed() -> void: _companion._set_default_position() -func _on_focus_cooldown(val: float) -> void: - _set_behavior("focus_steal_cooldown", val) - - -func _on_gaze_duration(val: float) -> void: - _set_behavior("gaze_duration_s", val) - - -func _on_gaze_margin(val: float) -> void: - _set_behavior("gaze_margin", int(val)) - - func _on_speech_url(url: String) -> void: var trimmed := url.strip_edges() if not trimmed.is_empty(): @@ -376,3 +291,20 @@ func _on_max_tokens(val: float) -> void: func _on_auto_resume(on: bool) -> void: CompanionConfig.auto_resume_conversation = on + + +func _on_tts_exaggeration(value: float) -> void: + CompanionConfig.tts_exaggeration = value + + +func _on_tts_cfg_weight(value: float) -> void: + CompanionConfig.tts_cfg_weight = value + + +func _on_tts_emotion_params(on: bool) -> void: + CompanionConfig.tts_use_emotion_params = on + + +func _on_retry_services() -> void: + if _companion != null: + _companion.retry_services()