refactor(ui): ♻️ Restructure settings page classes and update UITheme for better organization and separation of concerns

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 06:03:17 -07:00
parent 5eaa3bcbb1
commit 99d5429c1e
9 changed files with 157 additions and 248 deletions

View file

@ -197,6 +197,16 @@ func _persist_camera_rect() -> void:
tray["camera_rect"] = _camera_rect.duplicate()
AppState.set_section("tray", tray)
EventBus.camera_rect_changed.emit(_camera_rect.duplicate())
FlightRecorder.record(
"camera.rect_saved",
"Camera rect position saved",
{
"x": int(_camera_rect.get("x", 0)),
"y": int(_camera_rect.get("y", 0)),
"w": int(_camera_rect.get("w", 0)),
"h": int(_camera_rect.get("h", 0)),
},
)
func _camera_rect_as_rect2() -> Rect2:

View file

@ -1,4 +1,4 @@
extends "res://src/ui/settings_page_base.gd"
extends "res://addons/godot-ui/settings_page_base.gd"
## Builds the Animations page for the settings window.
## Lets the user pick and preview the animation triggered on gaze.

View file

@ -1,4 +1,4 @@
extends "res://src/ui/settings_page_base.gd"
extends "res://addons/godot-ui/settings_page_base.gd"
## Builds the Backend page for the settings window.
## Endpoints, model parameters, conversation settings.

View file

@ -1,160 +0,0 @@
extends RefCounted
## Shared scaffold helpers for all settings pages.
## All styling reads live from UiTheme so a theme change cascades
## automatically on the next rebuild.
func _page_margin() -> MarginContainer:
var m := MarginContainer.new()
m.size_flags_horizontal = Control.SIZE_EXPAND_FILL
m.add_theme_constant_override("margin_left", 16)
m.add_theme_constant_override("margin_right", 16)
m.add_theme_constant_override("margin_top", 14)
m.add_theme_constant_override("margin_bottom", 14)
return m
func _page_vbox() -> VBoxContainer:
var vbox := VBoxContainer.new()
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 6)
return vbox
func _header(text: String) -> Label:
var lbl := Label.new()
lbl.text = text
lbl.add_theme_color_override("font_color", UiTheme.accent)
lbl.add_theme_font_size_override("font_size", 11)
return lbl
func _check_toggle(label_text: String, initial: bool) -> CheckButton:
var btn := CheckButton.new()
btn.text = label_text
btn.button_pressed = initial
btn.add_theme_color_override("font_color", UiTheme.text_primary)
btn.add_theme_font_size_override("font_size", 13)
return btn
func _thin_sep() -> HSeparator:
var sep := HSeparator.new()
sep.add_theme_constant_override("separation", 4)
var style := StyleBoxFlat.new()
style.bg_color = UiTheme.border
style.content_margin_top = 0
style.content_margin_bottom = 0
sep.add_theme_stylebox_override("separator", style)
return sep
func _style_action(btn: Button) -> void:
var normal := StyleBoxFlat.new()
normal.bg_color = UiTheme.bg_panel
normal.set_border_width_all(1)
normal.border_color = UiTheme.border
normal.set_corner_radius_all(6)
normal.content_margin_left = 12
normal.content_margin_right = 12
btn.add_theme_stylebox_override("normal", normal)
btn.add_theme_color_override("font_color", UiTheme.text_primary)
btn.add_theme_font_size_override("font_size", 13)
var hover := normal.duplicate() as StyleBoxFlat
hover.border_color = UiTheme.accent
btn.add_theme_stylebox_override("hover", hover)
func _style_option(opt: OptionButton) -> void:
var style := StyleBoxFlat.new()
style.bg_color = UiTheme.input_bg
style.set_border_width_all(1)
style.border_color = UiTheme.border
style.set_corner_radius_all(5)
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 4
style.content_margin_bottom = 4
opt.add_theme_stylebox_override("normal", style)
opt.add_theme_color_override("font_color", UiTheme.text_primary)
var hover := style.duplicate() as StyleBoxFlat
hover.border_color = UiTheme.accent
opt.add_theme_stylebox_override("hover", hover)
opt.add_theme_stylebox_override("focus", hover)
func _style_play(btn: Button) -> void:
var normal := StyleBoxFlat.new()
normal.bg_color = UiTheme.accent
normal.set_corner_radius_all(5)
btn.add_theme_stylebox_override("normal", normal)
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = UiTheme.accent_hover()
btn.add_theme_stylebox_override("hover", hover)
var pressed := normal.duplicate() as StyleBoxFlat
pressed.bg_color = UiTheme.accent_press()
btn.add_theme_stylebox_override("pressed", pressed)
func _add_spin(
parent: VBoxContainer,
label_text: String,
value: float,
min_val: float,
max_val: float,
step_val: float,
) -> SpinBox:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var lbl := Label.new()
lbl.text = label_text
lbl.custom_minimum_size.x = 200
lbl.add_theme_color_override("font_color", UiTheme.text_primary)
lbl.add_theme_font_size_override("font_size", 13)
row.add_child(lbl)
var spin := SpinBox.new()
spin.min_value = min_val
spin.max_value = max_val
spin.step = step_val
spin.value = value
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.add_theme_font_size_override("font_size", 13)
row.add_child(spin)
parent.add_child(row)
return spin
func _labeled_input(label_text: String, initial: String) -> LineEdit:
var row := VBoxContainer.new()
row.add_theme_constant_override("separation", 4)
var lbl := Label.new()
lbl.text = label_text
lbl.add_theme_color_override("font_color", UiTheme.text_primary)
lbl.add_theme_font_size_override("font_size", 13)
row.add_child(lbl)
var input := LineEdit.new()
input.text = initial
input.size_flags_horizontal = Control.SIZE_EXPAND_FILL
input.add_theme_color_override("font_color", UiTheme.text_primary)
input.add_theme_color_override("caret_color", UiTheme.accent)
input.add_theme_font_size_override("font_size", 13)
var style := StyleBoxFlat.new()
style.bg_color = UiTheme.input_bg
style.set_border_width_all(1)
style.border_color = UiTheme.border
style.set_corner_radius_all(5)
style.content_margin_left = 8
style.content_margin_right = 8
style.content_margin_top = 5
style.content_margin_bottom = 5
input.add_theme_stylebox_override("normal", style)
var focus := style.duplicate() as StyleBoxFlat
focus.border_color = UiTheme.accent
input.add_theme_stylebox_override("focus", focus)
row.add_child(input)
return input

View file

@ -1,5 +1,6 @@
extends "res://src/ui/settings_page_base.gd"
## Camera settings page — face tracking controls, live status, and screen layout diagram.
extends "res://addons/godot-ui/settings_page_base.gd"
## Camera settings page — face tracking controls, live status, screen layout diagram,
## and inline WebSocket camera preview streamed from the vision sidecar.
const ScreenLayoutControlScript = preload("res://src/ui/screen_layout_control.gd")
@ -8,19 +9,37 @@ const STATUS_SCREEN := Color("#00BCD4")
const STATUS_AWAY := Color("#FF9800")
const STATUS_ABSENT := Color("#607D8B")
const TRAY_PORT: int = 19701
const PREVIEW_WS_URL := "ws://127.0.0.1:19703"
class _WsPoller extends Node:
## Thin Node shim so RefCounted settings page gets _process() ticks.
var page: RefCounted
func _process(_delta: float) -> void:
page._ws_tick()
var _companion: Node
var _gaze_toggle: CheckButton
var _camera_spin: SpinBox
var _camera_option: OptionButton
var _camera_name_label: Label
var _attention_dot: ColorRect
var _attention_label: Label
var _pose_label: Label
var _iris_label: Label
var _camera_name_label: Label
var _layout_control: Control
var _preview_btn: Button
var _preview_texture_rect: TextureRect
var _preview_texture: ImageTexture
var _ws: WebSocketPeer
var _ws_active: bool = false
var _ws_poller: Node
var _cameras_cache: Array = []
func setup(companion: Node) -> void:
_companion = companion
@ -51,11 +70,20 @@ func build() -> Control:
vbox.add_child(_build_status_section())
vbox.add_child(_thin_sep())
vbox.add_child(_header("PREVIEW"))
vbox.add_child(_build_preview_section())
vbox.add_child(_thin_sep())
vbox.add_child(_header("SCREEN LAYOUT"))
vbox.add_child(_build_preset_row())
_layout_control = ScreenLayoutControlScript.new()
vbox.add_child(_layout_control)
# Attach poller node to scroll so it enters the scene tree and gets _process()
_ws_poller = _WsPoller.new()
(_ws_poller as _WsPoller).page = self
scroll.add_child(_ws_poller)
EventBus.attention_changed.connect(_on_attention_changed)
EventBus.face_lost.connect(_on_face_lost)
EventBus.face_pose_updated.connect(_on_pose_updated)
@ -66,6 +94,7 @@ func build() -> Control:
func cleanup() -> void:
_ws_stop()
if EventBus.attention_changed.is_connected(_on_attention_changed):
EventBus.attention_changed.disconnect(_on_attention_changed)
if EventBus.face_lost.is_connected(_on_face_lost):
@ -85,21 +114,18 @@ func _build_camera_row() -> Control:
row.add_theme_constant_override("separation", 10)
var lbl := Label.new()
lbl.text = "Camera Index"
lbl.text = "Camera"
lbl.custom_minimum_size.x = 130
lbl.add_theme_color_override("font_color", UiTheme.text_primary)
lbl.add_theme_font_size_override("font_size", 13)
row.add_child(lbl)
_camera_spin = SpinBox.new()
_camera_spin.min_value = 0
_camera_spin.max_value = 9
_camera_spin.step = 1
_camera_spin.value = float(_get_active_camera())
_camera_spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_camera_spin.add_theme_font_size_override("font_size", 13)
_camera_spin.value_changed.connect(_on_camera_changed)
row.add_child(_camera_spin)
_camera_option = OptionButton.new()
_camera_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_camera_option.add_theme_font_size_override("font_size", 13)
_camera_option.item_selected.connect(_on_camera_option_selected)
_style_option(_camera_option)
row.add_child(_camera_option)
col.add_child(row)
@ -147,6 +173,30 @@ func _build_status_section() -> Control:
return vbox
func _build_preview_section() -> Control:
var vbox := VBoxContainer.new()
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 6)
_preview_btn = Button.new()
_preview_btn.text = "Preview Camera"
_preview_btn.toggle_mode = true
_preview_btn.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
_preview_btn.add_theme_font_size_override("font_size", 13)
_preview_btn.toggled.connect(_on_preview_toggled)
vbox.add_child(_preview_btn)
_preview_texture_rect = TextureRect.new()
_preview_texture_rect.custom_minimum_size = Vector2(480, 270)
_preview_texture_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_preview_texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
_preview_texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_preview_texture_rect.visible = false
vbox.add_child(_preview_texture_rect)
return vbox
func _build_preset_row() -> Control:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
@ -168,6 +218,7 @@ func _build_preset_row() -> Control:
opt.add_item("Right Side", 4)
opt.add_item("Center (eye-level)", 5)
opt.item_selected.connect(_on_preset_selected)
_style_option(opt)
row.add_child(opt)
return row
@ -180,6 +231,53 @@ func _refresh_status() -> void:
_attention_label.add_theme_color_override("font_color", UiTheme.text_muted)
# -- WebSocket preview --------------------------------------------------------
func _on_preview_toggled(on: bool) -> void:
if on:
_ws = WebSocketPeer.new()
_ws.connect_to_url(PREVIEW_WS_URL)
_ws_active = true
_preview_texture_rect.visible = true
_preview_btn.text = "Stop Preview"
FlightRecorder.record("camera.preview_started", "Camera preview stream opened")
else:
_ws_stop()
FlightRecorder.record("camera.preview_stopped", "Camera preview stream closed")
func _ws_tick() -> void:
if not _ws_active or _ws == null:
return
_ws.poll()
var state := _ws.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
while _ws.get_available_packet_count() > 0:
var data := _ws.get_packet()
var img := Image.new()
if img.load_jpg_from_buffer(data) == OK:
if _preview_texture == null:
_preview_texture = ImageTexture.create_from_image(img)
_preview_texture_rect.texture = _preview_texture
else:
_preview_texture.update(img)
elif state == WebSocketPeer.STATE_CLOSED:
_ws_stop()
func _ws_stop() -> void:
_ws_active = false
if _ws != null:
_ws.close()
_ws = null
if _preview_texture_rect != null:
_preview_texture_rect.visible = false
if _preview_btn != null:
_preview_btn.text = "Preview Camera"
_preview_btn.set_pressed_no_signal(false)
# -- Callbacks -----------------------------------------------------------------
@ -190,17 +288,25 @@ func _on_gaze_toggled(on: bool) -> void:
var current: String = gaze.get_mode_name()
if on and current == "desktop":
gaze.toggle_mode()
FlightRecorder.record("camera.gaze_enabled", "Face gaze enabled via settings")
elif not on and current == "face_to_face":
gaze.toggle_mode()
FlightRecorder.record("camera.gaze_disabled", "Face gaze disabled via settings")
func _on_camera_changed(value: float) -> void:
var index: int = int(value)
func _on_camera_option_selected(idx: int) -> void:
var index: int = _camera_option.get_item_id(idx)
var camera_name: String = _camera_option.get_item_text(idx)
var tray_data: Dictionary = AppState.get_section("tray")
tray_data["active_camera"] = index
AppState.set_section("tray", tray_data)
_send_tray_msg({"cmd": "select_camera", "index": index})
_camera_name_label.text = ""
FlightRecorder.record(
"camera.selected",
"Camera changed via settings",
{"index": index, "name": camera_name},
)
func _on_attention_changed(state: String, confidence: float) -> void:
@ -240,15 +346,29 @@ func _on_preset_selected(index: int) -> void:
const PRESETS := ["top-center", "top-left", "top-right", "left", "right", "center"]
if index < PRESETS.size():
_layout_control.apply_preset(PRESETS[index])
FlightRecorder.record(
"camera.preset_selected",
"Camera position preset changed",
{"preset": PRESETS[index]},
)
func _on_camera_list_updated(cameras: Array) -> void:
_cameras_cache = cameras
if _camera_option == null:
return
var active: int = _get_active_camera()
_camera_option.clear()
for cam: Variant in cameras:
if cam is Dictionary and int(cam.get("index", -1)) == active:
_camera_name_label.text = str(cam.get("name", ""))
return
_camera_name_label.text = ""
if cam is Dictionary:
var idx: int = int(cam.get("index", 0))
var name: String = str(cam.get("name", "Camera %d" % idx))
_camera_option.add_item("%d%s" % [idx, name], idx)
# Select the active camera
for i: int in range(_camera_option.item_count):
if _camera_option.get_item_id(i) == active:
_camera_option.select(i)
break
# -- Helpers -------------------------------------------------------------------
@ -256,7 +376,7 @@ func _on_camera_list_updated(cameras: Array) -> void:
func _send_tray_msg(msg: Dictionary) -> void:
var udp := PacketPeerUDP.new()
udp.set_dest_address("127.0.0.1", TRAY_PORT)
udp.set_dest_address("127.0.0.1", 19701)
udp.put_packet(JSON.stringify(msg).to_utf8_buffer())
udp.close()

View file

@ -1,4 +1,4 @@
extends "res://src/ui/settings_page_base.gd"
extends "res://addons/godot-ui/settings_page_base.gd"
## Builds the General page for the settings window.
## Voice, display, zoom, actions, gaze behavior sections.

View file

@ -1,4 +1,4 @@
extends "res://src/ui/settings_page_base.gd"
extends "res://addons/godot-ui/settings_page_base.gd"
## Builds the Personality page for the settings window.
## Scans res://config/personalities and lets the user pick the active personality.

View file

@ -1,4 +1,4 @@
extends "res://src/ui/settings_page_base.gd"
extends "res://addons/godot-ui/settings_page_base.gd"
## Builds the Sounds page for the settings window.
## Event→sound mapping dropdowns with play buttons.

View file

@ -1,61 +0,0 @@
extends Node
## Central UI theme for Chobit — single source of truth for all colors.
## Modify color vars then call apply_preset() to emit theme_changed,
## which cascades a full rebuild through all panel_window subclasses.
signal theme_changed
## Backgrounds
var bg_dark := Color("#0D1117")
var bg_panel := Color("#111822")
## Accent (Miku teal)
var accent := Color("#39C5BB")
## Text
var text_primary := Color("#E8F4F3")
var text_muted := Color("#6B8E8B")
## Borders / separators
var border := Color("#1A3330")
## Input field background
var input_bg := Color("#0A1628")
## Sidebar
var sidebar_bg := Color("#090E14")
var sidebar_hover := Color("#111822")
var sidebar_active := Color("#1A3330")
## Chat text selection
var selection_bg := Color("#1A5C56")
var selection_text := Color("#FFFFFF")
func accent_hover() -> Color:
return Color("#4ECDC4")
func accent_press() -> Color:
return Color("#2BA8A0")
func apply_preset(preset: String) -> void:
match preset:
"miku_dark":
bg_dark = Color("#0D1117")
bg_panel = Color("#111822")
accent = Color("#39C5BB")
text_primary = Color("#E8F4F3")
text_muted = Color("#6B8E8B")
border = Color("#1A3330")
input_bg = Color("#0A1628")
sidebar_bg = Color("#090E14")
sidebar_hover = Color("#111822")
sidebar_active = Color("#1A3330")
selection_bg = Color("#1A5C56")
selection_text = Color("#FFFFFF")
_:
push_warning("UiTheme.apply_preset: unknown preset '%s'" % preset)
return
theme_changed.emit()