313 lines
9.3 KiB
GDScript
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
|
|
)
|