"""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()