refactor(ui): ♻️ Restructure UI components (ContextMenu, PanelWindow, ScreenLayoutControl) and settings pages (Animations, Backend, Camera, General, Personality, Sounds, TTS) for improved organization and maintainability

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 10:05:35 -07:00
parent 8b4097778c
commit 341083ffed
12 changed files with 631 additions and 870 deletions

View file

@ -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()

View file

@ -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

View file

@ -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)),
},
)
)

View file

@ -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 []

View file

@ -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)

View file

@ -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()

View file

@ -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,
}
)
)

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -0,0 +1 @@
uid://btq0i4p3vwx0l

View file

@ -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()