chobit/shared/godot/ui/screen_layout_control.gd
Claude Code d2bbd09624 fix(ui): 🐛 Remove stale PID files and refactor UI layout logic for conflict-free runtime behavior
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-30 00:45:57 -07:00

313 lines
9.3 KiB
GDScript

extends Control
## Live screen layout diagram — monitors, Miku window, camera position, gaze point.
## Mirrors the Python screen_layout.py visualization, now native in Godot.
## Camera rect is draggable; position is persisted to AppState.
const MARGIN := 14.0
const FONT_SIZE := 9
## Invalid sentinel: gaze not yet received.
const GAZE_NONE := Vector2(-1.0e9, -1.0e9)
var _gaze_screen_pos: Vector2 = GAZE_NONE
var _attention: String = "absent"
## {x, y, w, h} in screen coords; empty dict = not yet set.
var _camera_rect: Dictionary = {}
## Drag state
var _dragging: bool = false
var _drag_screen_offset: Vector2 = Vector2.ZERO
## Cached transform (rebuilt each draw)
var _tf_scale: float = 1.0
var _tf_offset: Vector2 = Vector2.ZERO
var _tf_origin: Vector2 = Vector2.ZERO
var _tf_valid: bool = false
## 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
custom_minimum_size = Vector2(360, 220)
size_flags_horizontal = SIZE_EXPAND_FILL
_load_camera_rect()
EventBus.gaze_screen_updated.connect(_on_gaze_updated)
EventBus.attention_changed.connect(_on_attention_changed)
EventBus.face_lost.connect(_on_face_lost)
func _process(_delta: float) -> void:
var pos: Vector2 = Vector2(DisplayServer.window_get_position(0))
var sz: Vector2 = Vector2(DisplayServer.window_get_size(0))
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:
pass
func apply_preset(preset: String) -> void:
var bounds := _monitor_bounds()
if bounds.monitors.is_empty():
return
var origin: Vector2 = bounds.origin
var total: Vector2 = bounds.total_size
var cx := origin.x + total.x / 2.0
var cy := origin.y + total.y / 2.0
var right := origin.x + total.x
var bottom := origin.y + total.y
const W := 200.0
const H := 150.0
match preset:
"top-center":
_camera_rect = {"x": cx - W / 2.0, "y": origin.y - 300.0, "w": W, "h": H}
"top-left":
_camera_rect = {"x": origin.x, "y": origin.y - 300.0, "w": W, "h": H}
"top-right":
_camera_rect = {"x": right - W, "y": origin.y - 300.0, "w": W, "h": H}
"left":
_camera_rect = {"x": origin.x - W - 50.0, "y": cy - H / 2.0, "w": W, "h": H}
"right":
_camera_rect = {"x": right + 50.0, "y": cy - H / 2.0, "w": W, "h": H}
"center":
_camera_rect = {"x": cx - W / 2.0, "y": cy - H / 2.0, "w": W, "h": H}
_persist_camera_rect()
queue_redraw()
func _draw() -> void:
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, UiTheme.gaze_text)
return
_build_transform(bounds)
if not _tf_valid:
return
# Monitors
for mon: Dictionary in bounds.monitors:
var r := _to_panel_rect(mon.rect)
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),
UiTheme.gaze_text,
)
# Miku window — always use window ID 0 (the companion OS window).
# Window.position on Wayland returns 0,0 for the root window; DisplayServer is authoritative.
var win_pos: Vector2 = Vector2(DisplayServer.window_get_position(0))
var win_size: Vector2 = Vector2(DisplayServer.window_get_size(0))
var miku_r := _to_panel_rect(Rect2(win_pos, win_size))
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, 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:
var gp := _to_panel(_gaze_screen_pos)
var gc := _attention_color()
draw_line(gp, miku_r.get_center(), gc, 1.0, true)
draw_circle(gp, 6.0, gc)
draw_arc(gp, 6.0, 0.0, TAU, 16, Color.WHITE, 1.0)
func _gui_input(event: InputEvent) -> void:
if _camera_rect.is_empty() or not _tf_valid:
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
var sp := _to_screen(mb.position)
if _camera_rect_as_rect2().has_point(sp):
_dragging = true
_drag_screen_offset = sp - Vector2(_camera_rect.x, _camera_rect.y)
accept_event()
elif _dragging:
_dragging = false
_persist_camera_rect()
accept_event()
elif event is InputEventMouseMotion and _dragging:
var sp := _to_screen((event as InputEventMouseMotion).position)
_camera_rect["x"] = sp.x - _drag_screen_offset.x
_camera_rect["y"] = sp.y - _drag_screen_offset.y
queue_redraw()
accept_event()
# -- Event handlers ------------------------------------------------------------
func _on_gaze_updated(screen_position: Vector2) -> void:
_gaze_screen_pos = screen_position
queue_redraw()
func _on_attention_changed(state: String, _confidence: float) -> void:
_attention = state
queue_redraw()
func _on_face_lost() -> void:
_attention = "absent"
_gaze_screen_pos = GAZE_NONE
queue_redraw()
# -- Helpers -------------------------------------------------------------------
func _attention_color() -> Color:
match _attention:
"looking":
return UiTheme.status_looking
"screen":
return UiTheme.status_screen
"away":
return UiTheme.status_away
return UiTheme.status_absent
func _load_camera_rect() -> void:
var cr: Dictionary = AppState.get_camera_rect()
if cr.has("x"):
_camera_rect = cr.duplicate()
func _persist_camera_rect() -> void:
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)),
},
)
)
func _camera_rect_as_rect2() -> Rect2:
return Rect2(
float(_camera_rect.get("x", 0.0)),
float(_camera_rect.get("y", 0.0)),
float(_camera_rect.get("w", 200.0)),
float(_camera_rect.get("h", 150.0)),
)
## Returns {monitors: [{name, rect: Rect2}], origin: Vector2, total_size: Vector2}
func _monitor_bounds() -> Dictionary:
var count := DisplayServer.get_screen_count()
if count == 0:
return {"monitors": [], "origin": Vector2.ZERO, "total_size": Vector2.ZERO}
var monitors: Array = []
var min_x := INF
var min_y := INF
var max_x := -INF
var max_y := -INF
for i: int in range(count):
var pos := Vector2(DisplayServer.screen_get_position(i))
var sz := Vector2(DisplayServer.screen_get_size(i))
monitors.append({"name": "Monitor %d" % (i + 1), "rect": Rect2(pos, sz)})
min_x = minf(min_x, pos.x)
min_y = minf(min_y, pos.y)
max_x = maxf(max_x, pos.x + sz.x)
max_y = maxf(max_y, pos.y + sz.y)
return {
"monitors": monitors,
"origin": Vector2(min_x, min_y),
"total_size": Vector2(max_x - min_x, max_y - min_y),
}
## Rebuild the screen→panel transform, expanding bounds to include camera rect.
func _build_transform(bounds: Dictionary) -> void:
var origin: Vector2 = bounds.origin
var total: Vector2 = bounds.total_size
# Expand bounding box to include camera rect so it's always visible
if not _camera_rect.is_empty():
var cr := _camera_rect_as_rect2()
var new_origin := Vector2(minf(origin.x, cr.position.x), minf(origin.y, cr.position.y))
var new_end := Vector2(
maxf(origin.x + total.x, cr.end.x),
maxf(origin.y + total.y, cr.end.y),
)
origin = new_origin
total = new_end - new_origin
if total.x <= 0.0 or total.y <= 0.0:
_tf_valid = false
return
var avail := size - Vector2(MARGIN * 2.0, MARGIN * 2.0)
var scale := minf(avail.x / total.x, avail.y / total.y)
var drawn := total * scale
var offset := Vector2(MARGIN, MARGIN) + (avail - drawn) / 2.0
_tf_scale = scale
_tf_offset = offset
_tf_origin = origin
_tf_valid = true
func _to_panel(screen_pos: Vector2) -> Vector2:
return _tf_offset + (screen_pos - _tf_origin) * _tf_scale
func _to_panel_rect(screen_rect: Rect2) -> Rect2:
return Rect2(_to_panel(screen_rect.position), screen_rect.size * _tf_scale)
func _to_screen(panel_pos: Vector2) -> Vector2:
if _tf_scale == 0.0:
return Vector2.ZERO
return (panel_pos - _tf_offset) / _tf_scale + _tf_origin
func _draw_text(text: String, pos: Vector2, color: Color) -> void:
draw_string(ThemeDB.fallback_font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
func _draw_centered_text(text: String, center: Vector2, color: Color) -> void:
var font := ThemeDB.fallback_font
var sz := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
draw_string(
font,
center + Vector2(-sz.x / 2.0, sz.y / 2.0),
text,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
FONT_SIZE,
color
)