Files
javis/javis-slave/src/javis_slave/main_window.py
T
2026-10-04 15:31:44 +02:00

1010 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()