ui(ui): 💄 Refactor and update UI system in Godot engine to improve layout consistency across ScreenLayoutControl and SettingsWindow classes

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-29 23:25:08 -07:00
parent 47eeb4cbe7
commit 2ddedcb070
6 changed files with 322 additions and 234 deletions

View file

@ -27,6 +27,10 @@ var _tf_valid: bool = false
## Companion node — used to get the companion window position accurately.
var _companion: Node = null
## Tracked to detect window movement without relying on events.
var _last_win_pos: Vector2 = Vector2.ZERO
var _last_win_size: Vector2 = Vector2.ZERO
func _ready() -> void:
mouse_filter = MOUSE_FILTER_STOP
@ -38,6 +42,18 @@ func _ready() -> void:
EventBus.face_lost.connect(_on_face_lost)
func _process(_delta: float) -> void:
if _companion == null or not is_instance_valid(_companion):
return
var win: Window = _companion.get_window()
var pos: Vector2 = Vector2(win.position)
var sz: Vector2 = Vector2(win.size)
if pos != _last_win_pos or sz != _last_win_size:
_last_win_pos = pos
_last_win_size = sz
queue_redraw()
## 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:

View file

@ -1,26 +1,13 @@
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.
## Camera settings page — face tracking enable/device, and gaze behavior tuning.
## Visualization (preview, status, screen layout) lives in the Gaze Demo page.
const ScreenLayoutControlScript = preload("res://src/ui/screen_layout_control.gd")
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
var page: RefCounted
func _process(_delta: float) -> void:
page._ws_tick()
var _companion: Node
var _gaze_toggle: CheckButton
@ -29,17 +16,6 @@ var _duration_spin: HBoxContainer
var _margin_spin: HBoxContainer
var _camera_option: OptionButton
var _camera_name_label: Label
var _status_row: Node
var _pose_label: Label
var _iris_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 = []
@ -64,60 +40,14 @@ func build() -> Control:
_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())
_add_header(vbox, "PREVIEW")
vbox.add_child(_build_preview_section())
vbox.add_child(_thin_sep())
_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)
_layout_control.setup(_companion)
if AppState.get_camera_rect().is_empty():
_layout_control.apply_preset("top-center")
_ws_poller = _WsPoller.new()
(_ws_poller as _WsPoller).page = self
page.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)
EventBus.camera_list_updated.connect(_on_camera_list_updated)
_refresh_status()
if AppState.get_camera_enabled():
_ws_start()
_send_tray_msg({"cmd": "list_cameras"})
return page
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):
EventBus.face_lost.disconnect(_on_face_lost)
if EventBus.face_pose_updated.is_connected(_on_pose_updated):
EventBus.face_pose_updated.disconnect(_on_pose_updated)
if EventBus.camera_list_updated.is_connected(_on_camera_list_updated):
EventBus.camera_list_updated.disconnect(_on_camera_list_updated)
@ -186,112 +116,6 @@ func _build_behavior_section() -> Control:
return vbox
func _build_status_section() -> Control:
var vbox: VBoxContainer = VBoxContainer.new()
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 5)
_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: —"
_pose_label.add_theme_color_override("font_color", UiTheme.text_muted)
_pose_label.add_theme_font_size_override("font_size", 13)
vbox.add_child(_pose_label)
_iris_label = Label.new()
_iris_label.text = "Iris gaze: —"
_iris_label.add_theme_color_override("font_color", UiTheme.text_muted)
_iris_label.add_theme_font_size_override("font_size", 13)
vbox.add_child(_iris_label)
return vbox
func _build_preview_section() -> Control:
var vbox: VBoxContainer = 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(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
_preview_texture_rect.visible = false
vbox.add_child(_preview_texture_rect)
return vbox
func _refresh_status() -> void:
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_start()
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: int = _ws.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
while _ws.get_available_packet_count() > 0:
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)
_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 -----------------------------------------------------------------
@ -300,10 +124,6 @@ func _on_gaze_toggled(on: bool) -> void:
return
AppState.set_camera_enabled(on)
_send_tray_msg({"cmd": "set_camera_enabled", "enabled": on})
if on:
_ws_start()
else:
_ws_stop()
(
FlightRecorder
. record(
@ -330,50 +150,6 @@ func _on_camera_option_selected(idx: int) -> void:
)
func _on_attention_changed(state: String, confidence: float) -> void:
(_status_row as StatusLabelScript).set_status(
state, "Attention: %s (%.0f%%)" % [state, confidence * 100.0]
)
func _on_face_lost() -> void:
(_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: —"
_iris_label.add_theme_color_override("font_color", UiTheme.text_muted)
func _on_pose_updated(yaw: float, pitch: float, iris_h: float, iris_v: float) -> void:
_pose_label.text = "Head pose: Yaw %+.1f° Pitch %+.1f°" % [yaw, pitch]
_pose_label.add_theme_color_override("font_color", UiTheme.text_primary)
_iris_label.text = "Iris gaze: H %.2f V %.2f" % [iris_h, iris_v]
_iris_label.add_theme_color_override("font_color", UiTheme.text_primary)
func _on_preset_selected(index: int) -> void:
if _layout_control == null:
return
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:

View file

@ -0,0 +1,280 @@
extends "res://addons/godot-ui/core/page_base.gd"
## Gaze Demo page — live camera preview, face tracking status, screen layout diagram,
## and desktop gaze halo toggle. All the live visualization for the vision pipeline.
const ScreenLayoutControlScript = preload("res://src/ui/screen_layout_control.gd")
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 PREVIEW_WS_URL := "ws://127.0.0.1:19703"
const WS_RETRY_INTERVAL: float = 3.0
class _WsPoller:
extends Node
var page: RefCounted
func _process(delta: float) -> void:
page._ws_tick(delta)
var _companion: Node
var _preview_texture_rect: TextureRect
var _preview_placeholder: Label
var _preview_texture: ImageTexture
var _ws: WebSocketPeer
var _ws_active: bool = false
var _ws_retry_t: float = 0.0
var _ws_poller: Node
var _status_row: Node
var _pose_label: Label
var _iris_label: Label
var _layout_control: Control
var _halo_toggle: CheckButton
var _page_control: Control
func setup(companion: Node) -> void:
_companion = companion
func build() -> Control:
var page: ScrollContainer = ScrollPageScript.new()
page.setup()
var vbox: VBoxContainer = page.content
_page_control = page
_add_header(vbox, "CAMERA PREVIEW")
vbox.add_child(_build_preview_container())
vbox.add_child(_thin_sep())
_add_header(vbox, "STATUS")
vbox.add_child(_build_status_section())
vbox.add_child(_thin_sep())
_add_header(vbox, "SCREEN LAYOUT")
_layout_control = ScreenLayoutControlScript.new()
vbox.add_child(_layout_control)
_layout_control.setup(_companion)
vbox.add_child(_thin_sep())
_add_header(vbox, "GAZE HALO")
_halo_toggle = CheckToggleScript.new()
_halo_toggle.setup("Show gaze halo on screen", AppState.get_companion("gaze_halo", true))
_halo_toggle.toggled.connect(_on_halo_toggled)
vbox.add_child(_halo_toggle)
_ws_poller = _WsPoller.new()
(_ws_poller as _WsPoller).page = self
page.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)
page.visibility_changed.connect(_on_visibility_changed)
return page
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):
EventBus.face_lost.disconnect(_on_face_lost)
if EventBus.face_pose_updated.is_connected(_on_pose_updated):
EventBus.face_pose_updated.disconnect(_on_pose_updated)
# -- Helpers -------------------------------------------------------------------
func _add_header(parent: VBoxContainer, text: String) -> void:
var header: Label = SectionHeaderScript.new()
header.setup(text)
parent.add_child(header)
func _build_preview_container() -> Control:
# Fixed-height container: dark background + placeholder label + video texture.
var container: Control = Control.new()
container.custom_minimum_size = Vector2(0, 360)
container.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var bg: ColorRect = ColorRect.new()
bg.color = Color(0.08, 0.08, 0.08, 1.0)
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
container.add_child(bg)
_preview_placeholder = Label.new()
_preview_placeholder.text = "Connecting to camera preview…"
_preview_placeholder.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_preview_placeholder.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_preview_placeholder.add_theme_color_override("font_color", UiTheme.text_muted)
_preview_placeholder.add_theme_font_size_override("font_size", 13)
_preview_placeholder.set_anchors_preset(Control.PRESET_FULL_RECT)
container.add_child(_preview_placeholder)
_preview_texture_rect = TextureRect.new()
_preview_texture_rect.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
_preview_texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_preview_texture_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
_preview_texture_rect.visible = false
container.add_child(_preview_texture_rect)
return container
func _build_status_section() -> Control:
var vbox: VBoxContainer = VBoxContainer.new()
vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vbox.add_theme_constant_override("separation", 5)
_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: —"
_pose_label.add_theme_color_override("font_color", UiTheme.text_muted)
_pose_label.add_theme_font_size_override("font_size", 13)
vbox.add_child(_pose_label)
_iris_label = Label.new()
_iris_label.text = "Iris gaze: —"
_iris_label.add_theme_color_override("font_color", UiTheme.text_muted)
_iris_label.add_theme_font_size_override("font_size", 13)
vbox.add_child(_iris_label)
return vbox
# -- WebSocket preview ---------------------------------------------------------
func _ws_start() -> void:
if _ws_active:
return
_ws_retry_t = 0.0
_ws = WebSocketPeer.new()
_ws.connect_to_url(PREVIEW_WS_URL)
_ws_active = true
_set_placeholder("Connecting to camera preview…")
FlightRecorder.record("gaze_demo.preview_started", "Gaze demo preview stream opened")
func _ws_tick(delta: float) -> void:
if not _ws_active:
if _ws_retry_t > 0.0:
_ws_retry_t -= delta
if _ws_retry_t <= 0.0 and _page_control != null and _page_control.visible:
_ws_start()
return
_ws.poll()
var state: int = _ws.get_ready_state()
if state == WebSocketPeer.STATE_OPEN:
while _ws.get_available_packet_count() > 0:
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)
_preview_texture_rect.texture = _preview_texture
else:
_preview_texture.update(img)
# Show video, hide placeholder on first successful frame.
if not _preview_texture_rect.visible:
_preview_texture_rect.visible = true
_preview_placeholder.visible = false
elif state == WebSocketPeer.STATE_CLOSED:
_ws_closed()
func _ws_closed() -> void:
_ws_active = false
if _ws != null:
_ws.close()
_ws = null
_preview_texture_rect.visible = false
_preview_texture = null
if _preview_texture_rect != null:
_preview_texture_rect.texture = null
_ws_retry_t = WS_RETRY_INTERVAL
_set_placeholder("No camera signal — retrying…")
func _ws_stop() -> void:
_ws_active = false
_ws_retry_t = 0.0
if _ws != null:
_ws.close()
_ws = null
if _preview_texture_rect != null:
_preview_texture_rect.visible = false
_preview_texture_rect.texture = null
_preview_texture = null
if _preview_placeholder != null:
_set_placeholder("Camera preview stopped.")
FlightRecorder.record("gaze_demo.preview_stopped", "Gaze demo preview stream closed")
func _set_placeholder(text: String) -> void:
if _preview_placeholder == null:
return
_preview_placeholder.text = text
_preview_placeholder.visible = true
# -- Callbacks -----------------------------------------------------------------
func _on_visibility_changed() -> void:
if _page_control == null:
return
if _page_control.visible:
_ws_start()
_sync_face_state()
else:
_ws_stop()
func _sync_face_state() -> void:
if _companion == null:
return
var tray: Node = _companion.get_node_or_null("TrayListener")
if tray != null and tray.has_method("sync_face_state"):
tray.sync_face_state()
func _on_attention_changed(state: String, confidence: float) -> void:
(_status_row as StatusLabelScript).set_status(
state, "Attention: %s (%.0f%%)" % [state, confidence * 100.0]
)
func _on_face_lost() -> void:
(_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: —"
_iris_label.add_theme_color_override("font_color", UiTheme.text_muted)
func _on_pose_updated(yaw: float, pitch: float, iris_h: float, iris_v: float) -> void:
_pose_label.text = "Head pose: Yaw %+.1f° Pitch %+.1f°" % [yaw, pitch]
_pose_label.add_theme_color_override("font_color", UiTheme.text_primary)
_iris_label.text = "Iris gaze: H %.2f V %.2f" % [iris_h, iris_v]
_iris_label.add_theme_color_override("font_color", UiTheme.text_primary)
func _on_halo_toggled(on: bool) -> void:
AppState.set_companion("gaze_halo", on)
EventBus.gaze_halo_toggled.emit(on)

View file

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

View file

@ -74,11 +74,7 @@ func _add_toggle(parent: VBoxContainer, label_text: String, initial: bool) -> Ch
func _get_snap_enabled() -> bool:
if _companion != null:
var snap: Node = _companion.get_node_or_null("EdgeSnap")
if snap != null:
return snap.enabled
return false
return AppState.get_snap_enabled()
func _get_zoom() -> float:

View file

@ -8,6 +8,7 @@ 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 GazeDemoPageScript = preload("res://src/ui/settings_page_gaze_demo.gd")
const PersonalityPageScript = preload("res://src/ui/settings_page_personality.gd")
const AnimationsPageScript = preload("res://src/ui/settings_page_animations.gd")
@ -15,6 +16,9 @@ const _DEFAULT_SIZE := Vector2i(660, 520)
const _DEFAULT_MIN := Vector2i(620, 480)
const _CAMERA_SIZE := Vector2i(900, 580)
const _CAMERA_MIN := Vector2i(860, 480)
## Gaze Demo needs room for the 640×360 preview + status + diagram + halo toggle.
const _GAZE_DEMO_SIZE := Vector2i(900, 940)
const _GAZE_DEMO_MIN := Vector2i(860, 860)
var _companion: Node
var _sound_config: Node
@ -23,6 +27,7 @@ var _general_page: RefCounted
var _backend_page: RefCounted
var _tts_page: RefCounted
var _camera_page: RefCounted
var _gaze_demo_page: RefCounted
var _sidebar_nav: PanelContainer
var _pages: Dictionary = {}
@ -49,6 +54,8 @@ func _ready() -> void:
func _on_theme_changed() -> void:
if _camera_page != null:
_camera_page.cleanup()
if _gaze_demo_page != null:
_gaze_demo_page.cleanup()
super._on_theme_changed()
@ -85,6 +92,7 @@ func _build_ui() -> void:
{"key": "sounds", "label": "Sounds"},
{"key": "animations", "label": "Animations"},
{"key": "camera", "label": "Camera"},
{"key": "gaze_demo", "label": "Gaze Demo"},
]
_sidebar_nav.setup(tabs)
_sidebar_nav.tab_selected.connect(_switch_tab)
@ -113,6 +121,7 @@ func _build_page_area() -> Control:
_pages["sounds"] = _build_sounds_page()
_pages["animations"] = _build_animations_page()
_pages["camera"] = _build_camera_page()
_pages["gaze_demo"] = _build_gaze_demo_page()
for key: String in _pages:
var page: Control = _pages[key]
@ -192,6 +201,12 @@ func _build_camera_page() -> Control:
return _camera_page.build()
func _build_gaze_demo_page() -> Control:
_gaze_demo_page = GazeDemoPageScript.new()
_gaze_demo_page.setup(_companion)
return _gaze_demo_page.build()
# -- Tab switching ------------------------------------------------------------
@ -206,7 +221,10 @@ func _switch_tab(tab_key: String) -> void:
func _update_panel_size() -> void:
if _active_tab == "camera":
if _active_tab == "gaze_demo":
min_size = _GAZE_DEMO_MIN
size = _GAZE_DEMO_SIZE
elif _active_tab == "camera":
min_size = _CAMERA_MIN
size = _CAMERA_SIZE
else:
@ -241,10 +259,11 @@ func _on_halo_toggled(on: bool) -> void:
func _on_snap_toggled(on: bool) -> void:
AppState.set_snap_enabled(on)
if _companion != null:
var snap := _companion.get_node_or_null("EdgeSnap")
var snap: Node = _companion.get_node_or_null("EdgeSnap")
if snap != null:
snap.enabled = on
snap.set("enabled", on)
func _on_zoom_changed(value: float) -> void: