1010 lines
44 KiB
Python
1010 lines
44 KiB
Python
"""Touch-optimized PySide6 UI for javis-slave (Surface Pro 7 / Raspberry Pi kiosk).
|
||
|
||
Same VS Code Dark Modern palette (orange accent) as the Javis master GUI.
|
||
Design rules (Fluent 2 guidance, WCAG 2.2):
|
||
|
||
- one app-level stylesheet; widgets opt into looks via dynamic properties
|
||
(``variant``, ``role``, ``tone``, ``bubble``, ``chip``, ``card``) instead of
|
||
per-widget sheets, so hover / pressed / focus states stay consistent;
|
||
- touch targets >= 48 px, visible keyboard focus, text contrast >= 4.5:1 and
|
||
control boundaries >= 3:1;
|
||
- icons are painted as vectors (kiosk images often lack a colour-emoji font).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from functools import lru_cache
|
||
from typing import Callable, Optional
|
||
|
||
from PySide6 import QtCore, QtGui, QtWidgets
|
||
from PySide6.QtCore import QTimer
|
||
|
||
# ── palette (mirrors javis/gui/main_window.py) ────────────────────────────────
|
||
CL_BG = "#1e1e1e"
|
||
CL_SURFACE = "#252526"
|
||
CL_SURFACE2 = "#2d2d2d"
|
||
CL_INPUT = "#3c3c3c"
|
||
CL_ACCENT = "#DA7756"
|
||
CL_ACCENT2 = "#e0886a"
|
||
CL_ACCENT_PRESSED = "#c96a4b"
|
||
CL_ON_ACCENT = "#1e1e1e" # dark label on orange: 5.4:1 (white would be 3.1:1)
|
||
CL_TEXT = "#cccccc"
|
||
CL_MUTED = "#9d9d9d" # >= 4.5:1 on bg and surface (#858585 was ~4:1)
|
||
CL_BORDER = "#3c3c3c"
|
||
CL_BORDER2 = "#454545"
|
||
CL_STROKE = "#858585" # input boundaries: >= 3:1 against the surfaces
|
||
CL_HOVER = "#353537"
|
||
CL_PRESSED = "#3f3f46"
|
||
CL_DISABLED = "#8a8a8a"
|
||
CL_SEL = "#3d2b1f"
|
||
CL_SUCCESS = "#4ec994"
|
||
CL_WARN = "#f59e0b"
|
||
CL_ERROR = "#f14c4c" # fills (orb, mute button)
|
||
CL_ERROR_FG = "#f26d6d" # error text: >= 4.5:1 on surface and error_bg
|
||
CL_ERROR_BG = "#321a1a"
|
||
CL_ERROR_BORDER = "#5a2a2a"
|
||
|
||
_UI_FONTS = ("Segoe UI Variable Text", "Segoe UI", "Inter", "Noto Sans", "Cantarell", "DejaVu Sans")
|
||
_MONO_FONTS = ("Cascadia Mono", "Consolas", "JetBrains Mono", "Noto Sans Mono", "DejaVu Sans Mono")
|
||
|
||
# Gain applied to raw mic RMS before driving the orb pulse (A-12: was magic 8.0).
|
||
# Typical speech RMS is ~0.01–0.1, so ×8 maps it into the visible 0..1 range.
|
||
_RMS_GAIN = 8.0
|
||
|
||
STATE_COLORS = {
|
||
"idle": "#3c3c3c",
|
||
"listening": "#4ec994",
|
||
"thinking": CL_ACCENT,
|
||
"speaking": "#4ec994",
|
||
"muted": CL_ERROR,
|
||
"error": CL_ERROR,
|
||
}
|
||
|
||
STATE_LABELS = {
|
||
"idle": "Sag das Wake-Word …",
|
||
"listening": "Ich höre zu …",
|
||
"thinking": "Einen Moment …",
|
||
"speaking": "Antwort wird abgespielt …",
|
||
"muted": "Stumm geschaltet",
|
||
"error": "Fehler",
|
||
}
|
||
|
||
|
||
@lru_cache(maxsize=None)
|
||
def _font_family(candidates: tuple[str, ...]) -> str:
|
||
available = set(QtGui.QFontDatabase.families())
|
||
return next((f for f in candidates if f in available), candidates[-1])
|
||
|
||
|
||
def build_qss() -> str:
|
||
"""App-wide stylesheet. Tone rules come after role rules so they win."""
|
||
ui = _font_family(_UI_FONTS)
|
||
mono = _font_family(_MONO_FONTS)
|
||
return f"""
|
||
QWidget {{ color: {CL_TEXT}; font-family: '{ui}', sans-serif; font-size: 13pt; }}
|
||
QMainWindow {{ background: {CL_BG}; }}
|
||
QDialog, QMessageBox {{ background: {CL_SURFACE}; }}
|
||
QLabel {{ background: transparent; }}
|
||
QLabel[role="title"] {{ font-size: 24pt; font-weight: 700; }}
|
||
QLabel[role="heading"] {{ font-size: 18pt; font-weight: 600; }}
|
||
QLabel[role="hint"] {{ color: {CL_MUTED}; font-size: 12pt; }}
|
||
QLabel[role="caption"] {{ color: {CL_MUTED}; font-size: 11pt; font-weight: 600; }}
|
||
QLabel[role="field"] {{ font-size: 11pt; font-weight: 600; }}
|
||
QLabel[role="state"] {{ color: {CL_MUTED}; font-size: 18pt; }}
|
||
QLabel[role="state"][active="true"] {{ color: {CL_TEXT}; }}
|
||
QLabel[chip="true"] {{ background: {CL_SURFACE2}; border: 1px solid {CL_BORDER}; border-radius: 22px;
|
||
padding: 0 20px; min-height: 44px; font-size: 13pt; font-weight: 600; }}
|
||
QLabel[tone="muted"] {{ color: {CL_MUTED}; }}
|
||
QLabel[tone="success"] {{ color: {CL_SUCCESS}; }}
|
||
QLabel[tone="warn"] {{ color: {CL_WARN}; }}
|
||
QLabel[tone="error"] {{ color: {CL_ERROR_FG}; }}
|
||
QFrame[card="true"] {{ background: {CL_SURFACE}; border: 1px solid {CL_BORDER}; border-radius: 20px; }}
|
||
|
||
QLabel[bubble="user"] {{ background: {CL_SURFACE2}; border: 1px solid {CL_BORDER2}; border-radius: 18px;
|
||
border-bottom-right-radius: 6px; padding: 12px 18px; font-size: 14pt; }}
|
||
QLabel[bubble="assistant"] {{ background: {CL_ACCENT}; color: {CL_ON_ACCENT}; border-radius: 18px;
|
||
border-bottom-left-radius: 6px; padding: 12px 18px; font-size: 14pt;
|
||
font-weight: 500; }}
|
||
QLabel[bubble="error"] {{ background: {CL_ERROR_BG}; color: {CL_ERROR_FG}; border: 1px solid {CL_ERROR_BORDER};
|
||
border-radius: 14px; padding: 10px 16px; font-size: 13pt; }}
|
||
|
||
QLineEdit {{ background: {CL_SURFACE2}; border: 1px solid {CL_BORDER2}; border-bottom: 2px solid {CL_STROKE};
|
||
border-radius: 12px; padding: 11px 16px 10px 16px; font-size: 15pt;
|
||
selection-background-color: {CL_ACCENT}; selection-color: {CL_ON_ACCENT}; }}
|
||
QLineEdit:focus {{ border: 2px solid {CL_ACCENT}; padding: 10px 15px 10px 15px; }}
|
||
QLineEdit:read-only {{ background: {CL_SURFACE}; }}
|
||
QLineEdit[code="true"] {{ font-family: '{mono}', monospace; font-size: 20pt; }}
|
||
|
||
QPushButton {{ background: {CL_SURFACE2}; border: 1px solid {CL_BORDER2}; border-radius: 14px;
|
||
padding: 0 24px; min-height: 52px; font-size: 13pt; font-weight: 600; }}
|
||
QPushButton:hover {{ background: {CL_HOVER}; border-color: {CL_STROKE}; }}
|
||
QPushButton:pressed {{ background: {CL_PRESSED}; }}
|
||
QPushButton:focus {{ border-color: {CL_ACCENT}; }}
|
||
QPushButton:checked {{ background: {CL_SEL}; border-color: {CL_ACCENT}; }}
|
||
QPushButton:disabled {{ background: {CL_SURFACE}; color: {CL_DISABLED}; border-color: {CL_BORDER}; }}
|
||
QPushButton[variant="primary"] {{ background: {CL_ACCENT}; color: {CL_ON_ACCENT}; border-color: {CL_ACCENT};
|
||
border-radius: 16px; min-height: 56px; font-size: 15pt; font-weight: 700; }}
|
||
QPushButton[variant="primary"]:hover {{ background: {CL_ACCENT2}; border-color: {CL_ACCENT2}; }}
|
||
QPushButton[variant="primary"]:pressed {{ background: {CL_ACCENT_PRESSED}; border-color: {CL_ACCENT_PRESSED}; }}
|
||
QPushButton[variant="primary"]:focus {{ border-color: {CL_TEXT}; }}
|
||
QPushButton[variant="primary"]:disabled {{ background: {CL_INPUT}; color: {CL_DISABLED}; border-color: {CL_INPUT}; }}
|
||
QPushButton[variant="danger"] {{ color: {CL_ERROR_FG}; }}
|
||
QPushButton[variant="danger"]:hover {{ background: {CL_ERROR_BG}; border-color: {CL_ERROR_BORDER}; }}
|
||
QPushButton[variant="icon"] {{ padding: 0; min-height: 0; border-radius: 16px; }}
|
||
QPushButton[variant="icon"]:checked {{ background: {CL_ERROR}; border-color: {CL_ERROR}; }}
|
||
|
||
QScrollArea {{ background: transparent; border: 0; }}
|
||
QScrollBar:vertical {{ background: transparent; width: 8px; margin: 4px 0; }}
|
||
QScrollBar::handle:vertical {{ background: {CL_BORDER2}; border-radius: 4px; min-height: 40px; }}
|
||
QScrollBar::handle:vertical:hover {{ background: {CL_STROKE}; }}
|
||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }}
|
||
QToolTip {{ background: {CL_SURFACE2}; color: {CL_TEXT}; border: 1px solid {CL_BORDER2}; padding: 6px 10px; }}
|
||
"""
|
||
|
||
|
||
def _build_palette() -> QtGui.QPalette:
|
||
role, group = QtGui.QPalette.ColorRole, QtGui.QPalette.ColorGroup
|
||
pal = QtGui.QPalette()
|
||
for r, color in {
|
||
role.Window: CL_BG, role.WindowText: CL_TEXT, role.Base: CL_SURFACE2,
|
||
role.AlternateBase: CL_SURFACE, role.Text: CL_TEXT, role.Button: CL_SURFACE2,
|
||
role.ButtonText: CL_TEXT, role.BrightText: "#ffffff", role.Highlight: CL_ACCENT,
|
||
role.HighlightedText: CL_ON_ACCENT, role.ToolTipBase: CL_SURFACE2,
|
||
role.ToolTipText: CL_TEXT, role.PlaceholderText: CL_MUTED, role.Link: CL_ACCENT,
|
||
}.items():
|
||
pal.setColor(r, QtGui.QColor(color))
|
||
for r in (role.WindowText, role.Text, role.ButtonText):
|
||
pal.setColor(group.Disabled, r, QtGui.QColor(CL_DISABLED))
|
||
return pal
|
||
|
||
|
||
def apply_theme(app: QtWidgets.QApplication) -> None:
|
||
"""Dark colour scheme (native title bar), palette and app stylesheet. Idempotent."""
|
||
if app is None or app.property("javisSlaveThemed"):
|
||
return
|
||
try:
|
||
app.styleHints().setColorScheme(QtCore.Qt.ColorScheme.Dark)
|
||
except AttributeError: # Qt < 6.8
|
||
pass
|
||
app.setPalette(_build_palette())
|
||
app.setStyleSheet(build_qss())
|
||
app.setProperty("javisSlaveThemed", True)
|
||
|
||
|
||
def _set_prop(widget: QtWidgets.QWidget, name: str, value) -> None:
|
||
"""Set a style property and re-polish (property selectors aren't live)."""
|
||
if widget.property(name) == value:
|
||
return
|
||
widget.setProperty(name, value)
|
||
widget.style().unpolish(widget)
|
||
widget.style().polish(widget)
|
||
widget.update()
|
||
|
||
|
||
# Vector icons — painted, so they render identically on every platform.
|
||
|
||
def _draw_mic(p: QtGui.QPainter, box: QtCore.QRectF, color: QtGui.QColor, slashed: bool) -> None:
|
||
s, x, y = box.width(), box.x(), box.y()
|
||
pen = QtGui.QPen(color, max(1.5, s * 0.075))
|
||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||
p.setPen(pen)
|
||
p.setBrush(QtCore.Qt.NoBrush)
|
||
p.drawRoundedRect(QtCore.QRectF(x + 0.35 * s, y + 0.08 * s, 0.30 * s, 0.50 * s), 0.15 * s, 0.15 * s)
|
||
p.drawArc(QtCore.QRectF(x + 0.22 * s, y + 0.22 * s, 0.56 * s, 0.52 * s), 180 * 16, 180 * 16)
|
||
p.drawLine(QtCore.QPointF(x + 0.5 * s, y + 0.74 * s), QtCore.QPointF(x + 0.5 * s, y + 0.88 * s))
|
||
p.drawLine(QtCore.QPointF(x + 0.36 * s, y + 0.88 * s), QtCore.QPointF(x + 0.64 * s, y + 0.88 * s))
|
||
if slashed:
|
||
p.drawLine(QtCore.QPointF(x + 0.16 * s, y + 0.10 * s), QtCore.QPointF(x + 0.84 * s, y + 0.90 * s))
|
||
|
||
|
||
def _draw_tune(p: QtGui.QPainter, box: QtCore.QRectF, color: QtGui.QColor) -> None:
|
||
s, x, y = box.width(), box.x(), box.y()
|
||
pen = QtGui.QPen(color, max(1.5, s * 0.07))
|
||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||
p.setPen(pen)
|
||
rows = ((0.26, 0.64), (0.50, 0.34), (0.74, 0.58))
|
||
for row, _knob in rows:
|
||
p.drawLine(QtCore.QPointF(x + 0.14 * s, y + row * s), QtCore.QPointF(x + 0.86 * s, y + row * s))
|
||
p.setBrush(QtGui.QColor(CL_SURFACE2))
|
||
for row, knob in rows:
|
||
p.drawEllipse(QtCore.QPointF(x + knob * s, y + row * s), 0.09 * s, 0.09 * s)
|
||
|
||
|
||
@lru_cache(maxsize=None)
|
||
def _icon(kind: str, color: str, size: int = 28) -> QtGui.QIcon:
|
||
"""Render a crisp (2x) icon: 'mic', 'mic_off' or 'tune'."""
|
||
pm = QtGui.QPixmap(size * 2, size * 2)
|
||
pm.setDevicePixelRatio(2.0)
|
||
pm.fill(QtCore.Qt.transparent)
|
||
p = QtGui.QPainter(pm)
|
||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||
box = QtCore.QRectF(0, 0, size, size)
|
||
if kind == "tune":
|
||
_draw_tune(p, box, QtGui.QColor(color))
|
||
else:
|
||
_draw_mic(p, box, QtGui.QColor(color), slashed=(kind == "mic_off"))
|
||
p.end()
|
||
return QtGui.QIcon(pm)
|
||
|
||
|
||
class StateOrb(QtWidgets.QWidget):
|
||
"""Large animated circle reflecting the agent state.
|
||
|
||
Alexa-style visual feedback:
|
||
- Color indicates device state (idle/listening/thinking/speaking/error)
|
||
- Pulse ring shows mic activity level in real-time
|
||
- Mute overlay (red X) when microphone is disabled
|
||
- Retry indicator (spinning dots) during reconnection attempts
|
||
"""
|
||
|
||
_FPS = 30 # animation tick rate; the ticker only runs while something moves
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._state = "idle"
|
||
self._level = 0.0 # smoothed mic RMS 0..1 (pulse while idle/listening)
|
||
self._muted = False # explicit mute state overlay
|
||
self._reconnecting = False # showing retry indicator
|
||
self._color = QtGui.QColor(STATE_COLORS["idle"])
|
||
self._phase = 0.0 # seconds of animation time
|
||
self.setMinimumSize(260, 260)
|
||
self.setAccessibleName("Status")
|
||
self.setAccessibleDescription(STATE_LABELS["idle"])
|
||
|
||
# Cross-fade the core colour between states instead of snapping.
|
||
self._color_anim = QtCore.QVariantAnimation(self)
|
||
self._color_anim.setDuration(280)
|
||
self._color_anim.setEasingCurve(QtCore.QEasingCurve.OutCubic)
|
||
self._color_anim.valueChanged.connect(self._on_color_step)
|
||
|
||
self._ticker = QTimer(self)
|
||
self._ticker.setInterval(1000 // self._FPS)
|
||
self._ticker.timeout.connect(self._on_tick)
|
||
|
||
def set_state(self, state: str) -> None:
|
||
if state == self._state:
|
||
return
|
||
self._state = state
|
||
self._color_anim.stop()
|
||
self._color_anim.setStartValue(QtGui.QColor(self._color))
|
||
self._color_anim.setEndValue(QtGui.QColor(STATE_COLORS.get(state, CL_INPUT)))
|
||
self._color_anim.start()
|
||
self.setAccessibleDescription(STATE_LABELS.get(state, state))
|
||
self._sync_ticker()
|
||
self.update()
|
||
|
||
def set_level(self, level: float) -> None:
|
||
level = max(0.0, min(1.0, level))
|
||
# Fast attack, slower release: reacts to speech without flickering.
|
||
k = 0.6 if level > self._level else 0.25
|
||
self._level += (level - self._level) * k
|
||
self.update()
|
||
|
||
def set_muted(self, muted: bool) -> None:
|
||
"""Set the mute overlay state (Alexa-style mute indicator)."""
|
||
self._muted = muted
|
||
self.update()
|
||
|
||
def set_reconnecting(self, reconnecting: bool) -> None:
|
||
"""Show/hide reconnection retry indicator."""
|
||
self._reconnecting = reconnecting
|
||
self._sync_ticker()
|
||
self.update()
|
||
|
||
def is_muted(self) -> bool:
|
||
return self._muted
|
||
|
||
# ── animation plumbing ──
|
||
|
||
def _animating(self) -> bool:
|
||
return self.isVisible() and (
|
||
self._state in ("listening", "thinking", "speaking") or self._reconnecting)
|
||
|
||
def _sync_ticker(self) -> None:
|
||
if self._animating():
|
||
if not self._ticker.isActive():
|
||
self._ticker.start()
|
||
else:
|
||
self._ticker.stop()
|
||
|
||
def _on_tick(self) -> None:
|
||
self._phase = (self._phase + 1.0 / self._FPS) % 3600.0
|
||
self.update()
|
||
|
||
def _on_color_step(self, value) -> None:
|
||
self._color = QtGui.QColor(value)
|
||
self.update()
|
||
|
||
def showEvent(self, event): # noqa: N802
|
||
super().showEvent(event)
|
||
self._sync_ticker()
|
||
|
||
def hideEvent(self, event): # noqa: N802
|
||
super().hideEvent(event)
|
||
self._ticker.stop()
|
||
|
||
# ── painting ──
|
||
|
||
def paintEvent(self, event): # noqa: N802
|
||
p = QtGui.QPainter(self)
|
||
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
||
rect = QtCore.QRectF(self.rect())
|
||
size = min(rect.width(), rect.height())
|
||
center = rect.center()
|
||
base_r = size * 0.30
|
||
color = QtGui.QColor(self._color)
|
||
t = self._phase
|
||
tau = 2 * math.pi
|
||
|
||
# Soft halo: grows with the mic level, breathes while listening/speaking.
|
||
swell = 0.10 + 0.40 * self._level
|
||
if self._state == "listening":
|
||
swell += 0.05 + 0.03 * math.sin(t * tau * 1.2)
|
||
elif self._state == "speaking":
|
||
swell += 0.12 + 0.05 * math.sin(t * tau * 2.0)
|
||
halo_r = base_r * (1.15 + swell)
|
||
grad = QtGui.QRadialGradient(center, halo_r)
|
||
for stop, alpha in ((0.0, 110), (base_r / halo_r, 80), (1.0, 0)):
|
||
c = QtGui.QColor(color)
|
||
c.setAlpha(alpha)
|
||
grad.setColorAt(stop, c)
|
||
p.setPen(QtCore.Qt.NoPen)
|
||
p.setBrush(grad)
|
||
p.drawEllipse(center, halo_r, halo_r)
|
||
|
||
# Speaking: ripples travelling outwards.
|
||
if self._state == "speaking":
|
||
for k in (0.0, 0.5):
|
||
f = (t * 0.8 + k) % 1.0
|
||
ring = QtGui.QColor(color)
|
||
ring.setAlpha(int(130 * (1.0 - f)))
|
||
p.setPen(QtGui.QPen(ring, max(2.0, size * 0.010)))
|
||
p.setBrush(QtCore.Qt.NoBrush)
|
||
r = base_r * (1.05 + 0.5 * f)
|
||
p.drawEllipse(center, r, r)
|
||
|
||
# Core with a gentle top-light gradient.
|
||
core = QtGui.QLinearGradient(center.x(), center.y() - base_r, center.x(), center.y() + base_r)
|
||
core.setColorAt(0.0, color.lighter(112))
|
||
core.setColorAt(1.0, color)
|
||
p.setPen(QtCore.Qt.NoPen)
|
||
p.setBrush(core)
|
||
p.drawEllipse(center, base_r, base_r)
|
||
|
||
# Thinking: an accent arc orbiting the core.
|
||
if self._state == "thinking":
|
||
track = QtGui.QColor(CL_TEXT)
|
||
track.setAlpha(40)
|
||
arc_r = base_r * 1.2
|
||
arc_rect = QtCore.QRectF(center.x() - arc_r, center.y() - arc_r, 2 * arc_r, 2 * arc_r)
|
||
pen = QtGui.QPen(track, max(3.0, size * 0.018))
|
||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||
p.setPen(pen)
|
||
p.setBrush(QtCore.Qt.NoBrush)
|
||
p.drawEllipse(arc_rect)
|
||
pen.setColor(QtGui.QColor(CL_ACCENT2))
|
||
p.setPen(pen)
|
||
p.drawArc(arc_rect, int(-(t * 300) % 360 * 16), 100 * 16)
|
||
|
||
# Glyph: dark on the bright cores, light on the dark idle core.
|
||
glyph = QtGui.QColor(CL_ON_ACCENT if self._state != "idle" else CL_TEXT)
|
||
gs = base_r * 0.95
|
||
box = QtCore.QRectF(center.x() - gs / 2, center.y() - gs / 2, gs, gs)
|
||
if self._state in ("idle", "listening", "muted"):
|
||
_draw_mic(p, box, glyph, slashed=self._state == "muted")
|
||
elif self._state == "thinking":
|
||
p.setPen(QtCore.Qt.NoPen)
|
||
p.setBrush(glyph)
|
||
for i in range(3):
|
||
pulse = 0.5 + 0.5 * math.sin(tau * (t * 1.4 - i * 0.18))
|
||
r = gs * (0.055 + 0.035 * pulse)
|
||
p.drawEllipse(QtCore.QPointF(center.x() + (i - 1) * gs * 0.27, center.y()), r, r)
|
||
elif self._state == "speaking":
|
||
p.setPen(QtCore.Qt.NoPen)
|
||
p.setBrush(glyph)
|
||
bar_w = gs * 0.11
|
||
for i in range(4):
|
||
h = gs * (0.22 + 0.42 * (0.5 + 0.5 * math.sin(tau * (t * 1.8 + i * 0.27))))
|
||
x = center.x() + (i - 1.5) * gs * 0.2 - bar_w / 2
|
||
p.drawRoundedRect(QtCore.QRectF(x, center.y() - h / 2, bar_w, h), bar_w / 2, bar_w / 2)
|
||
elif self._state == "error":
|
||
pen = QtGui.QPen(glyph, gs * 0.11)
|
||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||
p.setPen(pen)
|
||
p.drawLine(QtCore.QPointF(center.x(), center.y() - gs * 0.30),
|
||
QtCore.QPointF(center.x(), center.y() + gs * 0.08))
|
||
p.setPen(QtCore.Qt.NoPen)
|
||
p.setBrush(glyph)
|
||
p.drawEllipse(QtCore.QPointF(center.x(), center.y() + gs * 0.30), gs * 0.065, gs * 0.065)
|
||
|
||
# Mute badge when muted but the core shows another state.
|
||
if self._muted and self._state != "muted":
|
||
br = base_r * 0.28
|
||
bc = QtCore.QPointF(center.x() + base_r * 0.75, center.y() - base_r * 0.75)
|
||
p.setPen(QtCore.Qt.NoPen)
|
||
p.setBrush(QtGui.QColor(CL_ERROR))
|
||
p.drawEllipse(bc, br, br)
|
||
_draw_mic(p, QtCore.QRectF(bc.x() - br * 0.7, bc.y() - br * 0.7, br * 1.4, br * 1.4),
|
||
QtGui.QColor("#ffffff"), slashed=True)
|
||
|
||
# Reconnecting: a dashed amber ring that slowly rotates.
|
||
if self._reconnecting:
|
||
pen = QtGui.QPen(QtGui.QColor(CL_WARN), max(2.0, size * 0.012), QtCore.Qt.DashLine)
|
||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||
pen.setDashOffset(-t * 6)
|
||
p.setPen(pen)
|
||
p.setBrush(QtCore.Qt.NoBrush)
|
||
ring_r = base_r * 1.42
|
||
p.drawEllipse(center, ring_r, ring_r)
|
||
|
||
p.end()
|
||
|
||
|
||
class SetupScreen(QtWidgets.QWidget):
|
||
"""First-run pairing: master address + pairing code."""
|
||
|
||
pairRequested = QtCore.Signal(str, int, str, str) # host, port, code, name
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
root = QtWidgets.QVBoxLayout(self)
|
||
root.setContentsMargins(0, 0, 0, 0)
|
||
# Scrollable so the form stays usable on small (800x480) panels and
|
||
# when an on-screen keyboard takes half the screen.
|
||
scroll = QtWidgets.QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||
scroll.viewport().setAutoFillBackground(False)
|
||
QtWidgets.QScroller.grabGesture(scroll.viewport(), QtWidgets.QScroller.TouchGesture)
|
||
root.addWidget(scroll)
|
||
page = QtWidgets.QWidget()
|
||
scroll.setWidget(page)
|
||
page.setAutoFillBackground(False) # setWidget() turns it on
|
||
outer = QtWidgets.QVBoxLayout(page)
|
||
outer.setContentsMargins(24, 24, 24, 24)
|
||
outer.setAlignment(QtCore.Qt.AlignCenter)
|
||
|
||
card = QtWidgets.QFrame()
|
||
card.setProperty("card", True)
|
||
card.setMaximumWidth(640)
|
||
v = QtWidgets.QVBoxLayout(card)
|
||
v.setContentsMargins(40, 36, 40, 36)
|
||
v.setSpacing(8)
|
||
|
||
title = QtWidgets.QLabel("Javis Slave koppeln")
|
||
title.setProperty("role", "title")
|
||
title.setAlignment(QtCore.Qt.AlignCenter)
|
||
v.addWidget(title)
|
||
|
||
hint = QtWidgets.QLabel(
|
||
"Erzeuge auf dem Javis-Master im Tab „Devices“ einen Pairing-Code "
|
||
"und trage hier die angezeigte Adresse und den Code ein.")
|
||
hint.setProperty("role", "hint")
|
||
hint.setAlignment(QtCore.Qt.AlignCenter)
|
||
hint.setWordWrap(True)
|
||
v.addWidget(hint)
|
||
v.addSpacing(16)
|
||
|
||
self.host_edit = QtWidgets.QLineEdit()
|
||
self.host_edit.setPlaceholderText("z. B. 192.168.1.20")
|
||
self.port_edit = QtWidgets.QLineEdit("8765")
|
||
self.port_edit.setPlaceholderText("8765")
|
||
self.port_edit.setValidator(QtGui.QIntValidator(1, 65535, self))
|
||
addr_row = QtWidgets.QHBoxLayout()
|
||
addr_row.setSpacing(12)
|
||
addr_row.addLayout(self._field("Master-Adresse", self.host_edit), 3)
|
||
addr_row.addLayout(self._field("Port", self.port_edit), 1)
|
||
v.addLayout(addr_row)
|
||
v.addSpacing(8)
|
||
|
||
self.code_edit = QtWidgets.QLineEdit()
|
||
self.code_edit.setPlaceholderText("Code")
|
||
self.code_edit.setMaxLength(6)
|
||
self.code_edit.setProperty("code", True)
|
||
self.code_edit.setAlignment(QtCore.Qt.AlignCenter)
|
||
code_font = self.code_edit.font()
|
||
code_font.setLetterSpacing(QtGui.QFont.AbsoluteSpacing, 6)
|
||
self.code_edit.setFont(code_font)
|
||
v.addLayout(self._field("Pairing-Code", self.code_edit))
|
||
v.addSpacing(8)
|
||
|
||
self.name_edit = QtWidgets.QLineEdit()
|
||
self.name_edit.setPlaceholderText("z. B. Tablet Küche")
|
||
v.addLayout(self._field("Gerätename (optional)", self.name_edit))
|
||
|
||
self.status_lbl = QtWidgets.QLabel("")
|
||
self.status_lbl.setProperty("role", "hint")
|
||
self.status_lbl.setProperty("tone", "error")
|
||
self.status_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||
self.status_lbl.setWordWrap(True)
|
||
v.addSpacing(4)
|
||
v.addWidget(self.status_lbl)
|
||
|
||
self.pair_btn = QtWidgets.QPushButton("Koppeln")
|
||
self.pair_btn.setProperty("variant", "primary")
|
||
self.pair_btn.setCursor(QtCore.Qt.PointingHandCursor)
|
||
self.pair_btn.clicked.connect(self._submit)
|
||
v.addSpacing(8)
|
||
v.addWidget(self.pair_btn)
|
||
|
||
# Enter walks through the form; on the last field it submits.
|
||
self.host_edit.returnPressed.connect(self.port_edit.setFocus)
|
||
self.port_edit.returnPressed.connect(self.code_edit.setFocus)
|
||
self.code_edit.returnPressed.connect(self.name_edit.setFocus)
|
||
self.name_edit.returnPressed.connect(self._submit)
|
||
|
||
outer.addWidget(card)
|
||
|
||
@staticmethod
|
||
def _field(label_text: str, edit: QtWidgets.QLineEdit) -> QtWidgets.QVBoxLayout:
|
||
"""Visible label above the input (placeholders alone vanish while typing)."""
|
||
lbl = QtWidgets.QLabel(label_text)
|
||
lbl.setProperty("role", "field")
|
||
lbl.setBuddy(edit)
|
||
edit.setAccessibleName(label_text)
|
||
box = QtWidgets.QVBoxLayout()
|
||
box.setSpacing(6)
|
||
box.addWidget(lbl)
|
||
box.addWidget(edit)
|
||
return box
|
||
|
||
def _submit(self) -> None:
|
||
host = self.host_edit.text().strip()
|
||
port = int(self.port_edit.text() or "8765")
|
||
code = self.code_edit.text().strip()
|
||
name = self.name_edit.text().strip()
|
||
if not host or not code:
|
||
self.show_error("Bitte Master-Adresse und Pairing-Code eingeben.")
|
||
return
|
||
_set_prop(self.status_lbl, "tone", "muted")
|
||
self.status_lbl.setText("Verbinde …")
|
||
self.pair_btn.setEnabled(False)
|
||
self.pairRequested.emit(host, port, code, name)
|
||
|
||
def show_error(self, message: str) -> None:
|
||
_set_prop(self.status_lbl, "tone", "error")
|
||
self.status_lbl.setText(message)
|
||
self.pair_btn.setEnabled(True)
|
||
|
||
|
||
class MainScreen(QtWidgets.QWidget):
|
||
"""Paired mode: state orb, transcript card, status badges.
|
||
|
||
Alexa-style UX features:
|
||
- Mute toggle button on the orb
|
||
- Connection health monitoring with retry countdown
|
||
- Transcript history (scrollable conversation log)
|
||
- Visual mute indicator overlay
|
||
"""
|
||
|
||
settingsRequested = QtCore.Signal()
|
||
muteToggled = QtCore.Signal(bool) # new signal for mute toggle
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setFocusPolicy(QtCore.Qt.ClickFocus) # tapping the background clears focus
|
||
outer = QtWidgets.QVBoxLayout(self)
|
||
outer.setContentsMargins(28, 20, 28, 24)
|
||
outer.setSpacing(16)
|
||
|
||
# ── top bar: room badge · connection health · mute · settings ──────
|
||
top = QtWidgets.QHBoxLayout()
|
||
top.setSpacing(10)
|
||
self.room_lbl = QtWidgets.QLabel("Raum: —")
|
||
self.room_lbl.setProperty("chip", True)
|
||
self.room_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||
top.addWidget(self.room_lbl)
|
||
top.addStretch(1)
|
||
|
||
# Connection status with retry countdown
|
||
self.conn_lbl = QtWidgets.QLabel("● Verbunden")
|
||
self.conn_lbl.setProperty("chip", True)
|
||
self.conn_lbl.setProperty("tone", "success")
|
||
self.conn_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||
top.addWidget(self.conn_lbl)
|
||
|
||
# Mute toggle button (Alexa-style red ring when muted)
|
||
self.mute_btn = QtWidgets.QPushButton()
|
||
self.mute_btn.setProperty("variant", "icon")
|
||
self.mute_btn.setFixedSize(56, 56)
|
||
self.mute_btn.setIconSize(QtCore.QSize(28, 28))
|
||
self.mute_btn.setCursor(QtCore.Qt.PointingHandCursor)
|
||
self.mute_btn.setCheckable(True) # Alexa-style toggle state
|
||
# Tab focus only: a tap must not leave a focus ring on touch panels.
|
||
self.mute_btn.setFocusPolicy(QtCore.Qt.TabFocus)
|
||
self.mute_btn.clicked.connect(self._on_mute_toggled)
|
||
self.mute_btn.toggled.connect(lambda _on: self._sync_mute_icon())
|
||
self._sync_mute_icon()
|
||
top.addWidget(self.mute_btn)
|
||
|
||
gear = QtWidgets.QPushButton()
|
||
gear.setProperty("variant", "icon")
|
||
gear.setFixedSize(56, 56)
|
||
gear.setIcon(_icon("tune", CL_TEXT))
|
||
gear.setIconSize(QtCore.QSize(28, 28))
|
||
gear.setToolTip("Einstellungen")
|
||
gear.setAccessibleName("Einstellungen")
|
||
gear.setCursor(QtCore.Qt.PointingHandCursor)
|
||
gear.setFocusPolicy(QtCore.Qt.TabFocus)
|
||
gear.clicked.connect(self.settingsRequested.emit)
|
||
top.addWidget(gear)
|
||
outer.addLayout(top)
|
||
|
||
# Retry countdown label (hidden by default, shown during reconnection)
|
||
self.retry_lbl = QtWidgets.QLabel("")
|
||
self.retry_lbl.setProperty("role", "hint")
|
||
self.retry_lbl.setProperty("tone", "warn")
|
||
self.retry_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||
self.retry_lbl.setVisible(False)
|
||
outer.addWidget(self.retry_lbl)
|
||
|
||
# ── state orb + label ─────────────────────────────────────────────
|
||
self.orb = StateOrb()
|
||
outer.addWidget(self.orb, 2)
|
||
self.state_lbl = QtWidgets.QLabel(STATE_LABELS["idle"])
|
||
self.state_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||
self.state_lbl.setProperty("role", "state")
|
||
outer.addWidget(self.state_lbl)
|
||
|
||
# ── transcript card (scrollable conversation history) ──────────────
|
||
card = QtWidgets.QFrame()
|
||
card.setProperty("card", True)
|
||
cv = QtWidgets.QVBoxLayout(card)
|
||
cv.setContentsMargins(24, 18, 14, 18)
|
||
cv.setSpacing(10)
|
||
|
||
# Header label
|
||
history_header = QtWidgets.QLabel("Konversation")
|
||
history_header.setProperty("role", "caption")
|
||
cv.addWidget(history_header)
|
||
|
||
# Transcript history (replaces single-turn display).
|
||
# NOTE: this layout must belong to *exactly one* parent — we set it on
|
||
# ``transcript_container`` (inside the scroll area) below, so it must
|
||
# NOT also be added to ``cv`` (Qt raises RuntimeError: "Attempting to
|
||
# set QLayout ... when the QLayout already has a parent").
|
||
self.transcript_layout = QtWidgets.QVBoxLayout()
|
||
self.transcript_layout.setSpacing(10)
|
||
self.transcript_layout.setContentsMargins(0, 0, 10, 0)
|
||
|
||
# Scroll area for transcript
|
||
self.transcript_scroll = QtWidgets.QScrollArea()
|
||
self.transcript_scroll.setWidgetResizable(True)
|
||
self.transcript_scroll.setFrameShape(QtWidgets.QFrame.NoFrame)
|
||
self.transcript_scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
|
||
self.transcript_scroll.viewport().setAutoFillBackground(False)
|
||
self.transcript_container = QtWidgets.QWidget()
|
||
self.transcript_container.setLayout(self.transcript_layout)
|
||
self.transcript_scroll.setWidget(self.transcript_container)
|
||
self.transcript_container.setAutoFillBackground(False) # setWidget() turns it on
|
||
QtWidgets.QScroller.grabGesture(
|
||
self.transcript_scroll.viewport(), QtWidgets.QScroller.TouchGesture)
|
||
cv.addWidget(self.transcript_scroll, 1)
|
||
|
||
# Empty state until the first turn arrives.
|
||
self._empty_lbl: Optional[QtWidgets.QLabel] = QtWidgets.QLabel(
|
||
"Noch keine Unterhaltung – sag das Wake-Word, um zu starten.")
|
||
self._empty_lbl.setProperty("role", "hint")
|
||
self._empty_lbl.setAlignment(QtCore.Qt.AlignCenter)
|
||
self._empty_lbl.setWordWrap(True)
|
||
self.transcript_layout.addWidget(self._empty_lbl)
|
||
self._ensure_transcript_spacer()
|
||
|
||
outer.addWidget(card, 3)
|
||
|
||
# Track last state for change detection
|
||
self._last_state = "idle"
|
||
|
||
def _on_mute_toggled(self) -> None:
|
||
"""Handle mute button toggle."""
|
||
is_muted = self.mute_btn.isChecked()
|
||
self.muteToggled.emit(is_muted)
|
||
|
||
def _sync_mute_icon(self) -> None:
|
||
muted = self.mute_btn.isChecked()
|
||
self.mute_btn.setIcon(_icon("mic_off", "#ffffff") if muted else _icon("mic", CL_TEXT))
|
||
tip = "Mikrofon einschalten" if muted else "Mikrofon stummschalten"
|
||
self.mute_btn.setToolTip(tip)
|
||
self.mute_btn.setAccessibleName(tip)
|
||
|
||
def _bubble_max_width(self) -> int:
|
||
return max(240, int(self.transcript_scroll.viewport().width() * 0.8))
|
||
|
||
def resizeEvent(self, event): # noqa: N802
|
||
super().resizeEvent(event)
|
||
width = self._bubble_max_width()
|
||
for lbl in self.transcript_container.findChildren(QtWidgets.QLabel):
|
||
if lbl.property("bubble"):
|
||
self._fit_bubble(lbl, width)
|
||
|
||
@staticmethod
|
||
def _fit_bubble(bubble: QtWidgets.QLabel, max_width: int) -> None:
|
||
"""Hug short texts, wrap long ones at *max_width*.
|
||
|
||
Word-wrapped QLabels otherwise guess a narrow width and wrap early.
|
||
"""
|
||
natural = bubble.property("naturalWidth")
|
||
if natural is None:
|
||
bubble.ensurePolished() # apply the stylesheet font before measuring
|
||
fm = bubble.fontMetrics()
|
||
text_w = max((fm.horizontalAdvance(line) for line in bubble.text().splitlines()), default=0)
|
||
m = bubble.contentsMargins()
|
||
base = text_w + m.left() + m.right()
|
||
# QLabel's height-for-width is a little conservative (it also
|
||
# subtracts the stylesheet frame): take the narrowest width that
|
||
# still lays the text out on as few lines as an unbounded width.
|
||
unwrapped = bubble.heightForWidth(100_000)
|
||
natural = base + 2 * bubble.frameWidth() + 8
|
||
for width in range(base, natural, 2):
|
||
if bubble.heightForWidth(width) <= unwrapped:
|
||
natural = width
|
||
break
|
||
bubble.setProperty("naturalWidth", natural)
|
||
# A fixed width keeps height-for-width deterministic (no stray gaps).
|
||
bubble.setFixedWidth(min(natural, max_width))
|
||
|
||
def _add_bubble(self, text: str, kind: str) -> None:
|
||
"""Append a chat bubble: user right-aligned, Javis / errors left-aligned."""
|
||
if self._empty_lbl is not None:
|
||
self.transcript_layout.removeWidget(self._empty_lbl)
|
||
self._empty_lbl.hide() # deleteLater() only runs on the next loop pass
|
||
self._empty_lbl.deleteLater()
|
||
self._empty_lbl = None
|
||
bubble = QtWidgets.QLabel(text)
|
||
# Transcribed speech / LLM output: never interpret it as rich text.
|
||
bubble.setTextFormat(QtCore.Qt.PlainText)
|
||
bubble.setWordWrap(True)
|
||
bubble.setProperty("bubble", kind)
|
||
bubble.setSizePolicy(QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred)
|
||
self._fit_bubble(bubble, self._bubble_max_width())
|
||
row = QtWidgets.QWidget()
|
||
h = QtWidgets.QHBoxLayout(row)
|
||
h.setContentsMargins(0, 0, 0, 0)
|
||
if kind == "user":
|
||
h.addStretch(1)
|
||
h.addWidget(bubble)
|
||
else:
|
||
h.addWidget(bubble)
|
||
h.addStretch(1)
|
||
self.transcript_layout.addWidget(row)
|
||
|
||
# ── slots (called via Qt signals from the engine bridge) ──────────────
|
||
|
||
def set_state(self, state: str) -> None:
|
||
self.orb.set_state(state)
|
||
self.orb.set_muted(state == "muted") # sync mute overlay with state
|
||
label = STATE_LABELS.get(state, state)
|
||
if label:
|
||
self.state_lbl.setText(label)
|
||
_set_prop(self.state_lbl, "active", state != "idle")
|
||
# Mute toggle stays in place (enabled/disabled) so the top bar never
|
||
# jumps while the state cycles; it is only inactive mid-turn.
|
||
interactive_states = ("idle", "listening", "speaking", "muted")
|
||
self.mute_btn.setEnabled(state in interactive_states)
|
||
# Sync the button's checked state to match the engine's mute state.
|
||
want_checked = (state == "muted")
|
||
if self.mute_btn.isChecked() != want_checked:
|
||
self.mute_btn.blockSignals(True)
|
||
self.mute_btn.setChecked(want_checked)
|
||
self.mute_btn.blockSignals(False)
|
||
self._sync_mute_icon()
|
||
|
||
def set_mic_level(self, rms: float, score: float) -> None:
|
||
self.orb.set_level(min(1.0, rms * _RMS_GAIN))
|
||
|
||
def _ensure_transcript_spacer(self) -> None:
|
||
"""Keep exactly one expanding spacer, at the end of the transcript.
|
||
|
||
Bubbles are appended after the previous spacer, so stale spacers must
|
||
be removed from anywhere in the layout (they used to pile up between
|
||
turns and open large gaps).
|
||
"""
|
||
for i in reversed(range(self.transcript_layout.count())):
|
||
item = self.transcript_layout.itemAt(i)
|
||
if item is not None and item.spacerItem() is not None:
|
||
self.transcript_layout.removeItem(item)
|
||
spacer = QtWidgets.QSpacerItem(0, 0, QtWidgets.QSizePolicy.Minimum,
|
||
QtWidgets.QSizePolicy.Expanding)
|
||
self.transcript_layout.addSpacerItem(spacer)
|
||
|
||
def _prune_transcript(self, max_bubbles: int = 40) -> None:
|
||
"""Keep at most *max_bubbles* bubbles in the transcript layout (oldest first)."""
|
||
rows = [self.transcript_layout.itemAt(i).widget()
|
||
for i in range(self.transcript_layout.count())]
|
||
rows = [row for row in rows if row is not None]
|
||
for row in rows[:max(0, len(rows) - max_bubbles)]:
|
||
self.transcript_layout.removeWidget(row)
|
||
row.deleteLater()
|
||
|
||
def set_turn(self, user_text: str, reply_text: str) -> None:
|
||
"""Add a new conversation turn to the transcript history (Alexa-style)."""
|
||
self._add_bubble(user_text, "user")
|
||
|
||
# Assistant message bubble (if any)
|
||
if reply_text:
|
||
self._add_bubble(reply_text, "assistant")
|
||
|
||
self._prune_transcript()
|
||
# Spacer to push content to bottom (like Alexa's conversation history)
|
||
self._ensure_transcript_spacer()
|
||
|
||
# Auto-scroll to bottom
|
||
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
|
||
|
||
def _scroll_transcript_to_bottom(self) -> None:
|
||
"""Auto-scroll transcript to show latest messages."""
|
||
vbar = self.transcript_scroll.verticalScrollBar()
|
||
vbar.setValue(vbar.maximum())
|
||
|
||
def set_connection(self, ok: bool, retry_count: int = 0, backoff: float = 0.0) -> None:
|
||
if ok:
|
||
self.conn_lbl.setText("● Verbunden")
|
||
_set_prop(self.conn_lbl, "tone", "success")
|
||
self.retry_lbl.setVisible(False)
|
||
self.orb.set_reconnecting(False)
|
||
else:
|
||
if retry_count > 0 and backoff > 0:
|
||
# Show retry countdown (Alexa-style progressive feedback)
|
||
self.conn_lbl.setText(f"○ Getrennt · Versuch {retry_count} in {backoff:.0f} s")
|
||
_set_prop(self.conn_lbl, "tone", "warn")
|
||
self.retry_lbl.setVisible(True)
|
||
self.retry_lbl.setText(f"Wiederverbindungsversuch in {backoff:.0f}s …")
|
||
self.orb.set_reconnecting(True)
|
||
else:
|
||
self.conn_lbl.setText("● Getrennt")
|
||
_set_prop(self.conn_lbl, "tone", "error")
|
||
self.retry_lbl.setVisible(False)
|
||
self.orb.set_reconnecting(False)
|
||
|
||
def set_room(self, room: Optional[str]) -> None:
|
||
self.room_lbl.setText(f"Raum: {room}" if room else "Raum: —")
|
||
|
||
def show_error(self, message: str) -> None:
|
||
"""Show a recoverable error to the user (Alexa-style progressive disclosure)."""
|
||
self._add_bubble(f"⚠ {message}", "error")
|
||
self._prune_transcript()
|
||
self._ensure_transcript_spacer()
|
||
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
|
||
|
||
|
||
class SlaveWindow(QtWidgets.QMainWindow):
|
||
"""Top-level window switching between setup and main screens.
|
||
|
||
Wired to engine callbacks that bridge asyncio events to Qt signals:
|
||
- engineState: device state changes (idle/listening/thinking/speaking/error)
|
||
- engineMicLevel: real-time audio level for orb pulse animation
|
||
- engineTurn: conversation turn results (user text + assistant reply)
|
||
- engineError: error messages from the engine
|
||
- engineConnection: connection health updates with retry countdown
|
||
- engineRoom: room assignment changes
|
||
"""
|
||
|
||
# Bridged engine events (emitted from asyncio, consumed on the Qt side).
|
||
engineState = QtCore.Signal(str)
|
||
engineMicLevel = QtCore.Signal(float, float)
|
||
engineTurn = QtCore.Signal(str, str)
|
||
engineError = QtCore.Signal(str)
|
||
engineConnection = QtCore.Signal(bool, int, float) # (ok, retry_count, backoff)
|
||
engineRoom = QtCore.Signal(object)
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("Javis Slave")
|
||
self.resize(1368, 912) # Surface Pro 7 logical resolution @150 %
|
||
apply_theme(QtWidgets.QApplication.instance())
|
||
|
||
self.stack = QtWidgets.QStackedWidget()
|
||
self.setup_screen = SetupScreen()
|
||
self.main_screen = MainScreen()
|
||
self.stack.addWidget(self.setup_screen)
|
||
self.stack.addWidget(self.main_screen)
|
||
self.setCentralWidget(self.stack)
|
||
|
||
self.engineState.connect(self.main_screen.set_state)
|
||
self.engineMicLevel.connect(self.main_screen.set_mic_level)
|
||
self.engineTurn.connect(self.main_screen.set_turn)
|
||
self.engineError.connect(self.main_screen.show_error)
|
||
self.engineConnection.connect(self.main_screen.set_connection)
|
||
self.engineRoom.connect(lambda r: self.main_screen.set_room(r))
|
||
|
||
QtGui.QShortcut(QtGui.QKeySequence("F11"), self, activated=self._toggle_fullscreen)
|
||
# Kiosk: fullscreen with a hidden cursor (touch panels); Esc leaves fullscreen.
|
||
self._kiosk = False
|
||
QtGui.QShortcut(QtGui.QKeySequence("Esc"), self, activated=self._leave_fullscreen)
|
||
|
||
def set_kiosk(self, enabled: bool) -> None:
|
||
self._kiosk = enabled
|
||
if enabled:
|
||
self.showFullScreen()
|
||
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.BlankCursor)
|
||
|
||
def _leave_fullscreen(self) -> None:
|
||
if self.isFullScreen():
|
||
self.showNormal()
|
||
if self._kiosk:
|
||
QtWidgets.QApplication.restoreOverrideCursor()
|
||
|
||
def show_setup(self) -> None:
|
||
self.stack.setCurrentWidget(self.setup_screen)
|
||
|
||
def show_main(self) -> None:
|
||
self.stack.setCurrentWidget(self.main_screen)
|
||
# Park focus on the screen itself so no button shows a focus ring
|
||
# until the user actually navigates with a keyboard.
|
||
self.main_screen.setFocus(QtCore.Qt.OtherFocusReason)
|
||
|
||
def _toggle_fullscreen(self) -> None:
|
||
if self.isFullScreen():
|
||
self.showNormal()
|
||
else:
|
||
self.showFullScreen()
|
||
|
||
def show_settings_dialog(self, device_id: str, device_name: str,
|
||
on_repair: Callable[[], None], *,
|
||
control_token: str = "", api_port: int = 8766) -> None:
|
||
dlg = QtWidgets.QDialog(self)
|
||
dlg.setWindowTitle("Einstellungen")
|
||
dlg.setMinimumWidth(520)
|
||
v = QtWidgets.QVBoxLayout(dlg)
|
||
v.setContentsMargins(32, 28, 32, 28)
|
||
v.setSpacing(10)
|
||
title = QtWidgets.QLabel("Einstellungen")
|
||
title.setProperty("role", "heading")
|
||
v.addWidget(title)
|
||
info = QtWidgets.QLabel(
|
||
f"Gerät: {device_name or '—'}\nGeräte-ID: {device_id}")
|
||
info.setTextFormat(QtCore.Qt.PlainText)
|
||
info.setProperty("role", "hint")
|
||
info.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
|
||
v.addWidget(info)
|
||
if control_token:
|
||
# Needed by javis-slave-mcp (JAVIS_SLAVE_TOKEN) on other machines.
|
||
tok_lbl = QtWidgets.QLabel(f"Steuer-Token (Port {api_port})")
|
||
tok_lbl.setProperty("role", "field")
|
||
v.addSpacing(6)
|
||
v.addWidget(tok_lbl)
|
||
tok_row = QtWidgets.QHBoxLayout()
|
||
tok_row.setSpacing(8)
|
||
tok_edit = QtWidgets.QLineEdit(control_token)
|
||
tok_edit.setReadOnly(True)
|
||
tok_edit.setEchoMode(QtWidgets.QLineEdit.Password)
|
||
tok_edit.setAccessibleName("Steuer-Token")
|
||
tok_lbl.setBuddy(tok_edit)
|
||
tok_row.addWidget(tok_edit, 1)
|
||
tok_btn = QtWidgets.QPushButton("Anzeigen")
|
||
tok_btn.setCheckable(True)
|
||
|
||
def _toggle_token(on: bool) -> None:
|
||
tok_edit.setEchoMode(
|
||
QtWidgets.QLineEdit.Normal if on else QtWidgets.QLineEdit.Password)
|
||
tok_btn.setText("Verbergen" if on else "Anzeigen")
|
||
|
||
tok_btn.toggled.connect(_toggle_token)
|
||
tok_row.addWidget(tok_btn)
|
||
v.addLayout(tok_row)
|
||
v.addSpacing(10)
|
||
btn_fs = QtWidgets.QPushButton("Vollbild umschalten (F11)")
|
||
btn_fs.clicked.connect(lambda: (self._toggle_fullscreen(), dlg.accept()))
|
||
v.addWidget(btn_fs)
|
||
btn_repair = QtWidgets.QPushButton("Neu koppeln …")
|
||
btn_repair.setProperty("variant", "danger")
|
||
|
||
def _do_repair():
|
||
ret = QtWidgets.QMessageBox.question(
|
||
dlg, "Neu koppeln",
|
||
"Verbindung zum Master trennen und neu koppeln?")
|
||
if ret == QtWidgets.QMessageBox.Yes:
|
||
dlg.accept()
|
||
on_repair()
|
||
|
||
btn_repair.clicked.connect(_do_repair)
|
||
v.addWidget(btn_repair)
|
||
btn_close = QtWidgets.QPushButton("Schließen")
|
||
btn_close.setProperty("variant", "primary")
|
||
btn_close.setDefault(True)
|
||
btn_close.clicked.connect(dlg.accept)
|
||
v.addWidget(btn_close)
|
||
btn_close.setFocus() # not the read-only token field
|
||
dlg.exec()
|