This commit is contained in:
Javis committed 2026-10-10 13:20:35 +02:00
1 parent 4c16f87a3e
commit 2493c5c532
27 files changed
+8008 -568

No files matched your search

+4
View File
@@ -37,6 +37,10 @@ screen (GUI mode) and played back through the device's speakers.
If the master's turn fails it returns its spoken error phrase instead, so a
screenless satellite still gets audible feedback; connection problems play
a low falling tone.
5. **Conversation view** — The GUI lists the conversation with the newest
message on top; scroll down for older ones. The fullscreen button in the
conversation card (or `F11`) toggles window fullscreen; the orb and the
conversation stay visible. `Esc` leaves fullscreen.
## Setup
+78 -18
View File
@@ -132,6 +132,7 @@ 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}; }}
QPushButton[variant="icon"][small="true"] {{ min-width: 46px; min-height: 46px; }}
QScrollArea {{ background: transparent; border: 0; }}
QScrollBar:vertical {{ background: transparent; width: 8px; margin: 4px 0; }}
@@ -211,9 +212,28 @@ def _draw_tune(p: QtGui.QPainter, box: QtCore.QRectF, color: QtGui.QColor) -> No
p.drawEllipse(QtCore.QPointF(x + knob * s, y + row * s), 0.09 * s, 0.09 * s)
def _draw_expand(p: QtGui.QPainter, box: QtCore.QRectF, color: QtGui.QColor, collapse: bool) -> None:
"""Four corner brackets: pointing outwards to enter, inwards to leave fullscreen."""
s, x, y = box.width(), box.x(), box.y()
pen = QtGui.QPen(color, max(1.5, s * 0.075))
pen.setCapStyle(QtCore.Qt.RoundCap)
pen.setJoinStyle(QtCore.Qt.RoundJoin)
p.setPen(pen)
p.setBrush(QtCore.Qt.NoBrush)
leg = 0.26 * s
reach = 0.10 if collapse else 0.36 # corner distance from the centre
d = 1 if collapse else -1 # legs run away from / towards the centre
for sx in (-1, 1):
for sy in (-1, 1):
vx, vy = x + (0.5 + sx * reach) * s, y + (0.5 + sy * reach) * s
p.drawPolyline([QtCore.QPointF(vx + d * sx * leg, vy),
QtCore.QPointF(vx, vy),
QtCore.QPointF(vx, vy + d * sy * leg)])
@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'."""
"""Render a crisp (2x) icon: 'mic', 'mic_off', 'tune', 'expand' or 'collapse'."""
pm = QtGui.QPixmap(size * 2, size * 2)
pm.setDevicePixelRatio(2.0)
pm.fill(QtCore.Qt.transparent)
@@ -222,6 +242,8 @@ def _icon(kind: str, color: str, size: int = 28) -> QtGui.QIcon:
box = QtCore.QRectF(0, 0, size, size)
if kind == "tune":
_draw_tune(p, box, QtGui.QColor(color))
elif kind in ("expand", "collapse"):
_draw_expand(p, box, QtGui.QColor(color), collapse=(kind == "collapse"))
else:
_draw_mic(p, box, QtGui.QColor(color), slashed=(kind == "mic_off"))
p.end()
@@ -574,15 +596,18 @@ class MainScreen(QtWidgets.QWidget):
Alexa-style UX features:
- Mute toggle button on the orb
- Connection health monitoring with retry countdown
- Transcript history (scrollable conversation log)
- Transcript history (scrollable conversation log, newest message on top)
- Fullscreen button in the conversation card (orb and chat stay visible)
- Visual mute indicator overlay
"""
settingsRequested = QtCore.Signal()
muteToggled = QtCore.Signal(bool) # new signal for mute toggle
fullscreenToggleRequested = QtCore.Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._fullscreen = False
self.setFocusPolicy(QtCore.Qt.ClickFocus) # tapping the background clears focus
outer = QtWidgets.QVBoxLayout(self)
outer.setContentsMargins(28, 20, 28, 24)
@@ -654,10 +679,24 @@ class MainScreen(QtWidgets.QWidget):
cv.setContentsMargins(24, 18, 14, 18)
cv.setSpacing(10)
# Header label
# Header row: caption + fullscreen-chat toggle
header_row = QtWidgets.QHBoxLayout()
header_row.setContentsMargins(0, 0, 0, 0)
history_header = QtWidgets.QLabel("Konversation")
history_header.setProperty("role", "caption")
cv.addWidget(history_header)
header_row.addWidget(history_header)
header_row.addStretch(1)
self.expand_btn = QtWidgets.QPushButton()
self.expand_btn.setProperty("variant", "icon")
self.expand_btn.setProperty("small", True) # 48 px: the stylesheet's min-height would shrink it
self.expand_btn.setFixedSize(48, 48)
self.expand_btn.setIconSize(QtCore.QSize(26, 26))
self.expand_btn.setCursor(QtCore.Qt.PointingHandCursor)
self.expand_btn.setFocusPolicy(QtCore.Qt.TabFocus)
self.expand_btn.clicked.connect(self.fullscreenToggleRequested.emit)
self._sync_expand_button()
header_row.addWidget(self.expand_btn)
cv.addLayout(header_row)
# Transcript history (replaces single-turn display).
# NOTE: this layout must belong to *exactly one* parent — we set it on
@@ -708,6 +747,20 @@ class MainScreen(QtWidgets.QWidget):
self.mute_btn.setToolTip(tip)
self.mute_btn.setAccessibleName(tip)
def set_fullscreen(self, fullscreen: bool) -> None:
"""Mirror the window's fullscreen state in the toggle button."""
if fullscreen == self._fullscreen:
return
self._fullscreen = fullscreen
self._sync_expand_button()
def _sync_expand_button(self) -> None:
fs = self._fullscreen
self.expand_btn.setIcon(_icon("collapse" if fs else "expand", CL_TEXT, 26))
tip = "Vollbild beenden" if fs else "Vollbild"
self.expand_btn.setToolTip(tip)
self.expand_btn.setAccessibleName(tip)
def _bubble_max_width(self) -> int:
return max(240, int(self.transcript_scroll.viewport().width() * 0.8))
@@ -767,7 +820,8 @@ class MainScreen(QtWidgets.QWidget):
else:
h.addWidget(bubble)
h.addStretch(1)
self.transcript_layout.addWidget(row)
# Newest first: the latest bubble goes above the older ones.
self.transcript_layout.insertWidget(0, row)
# ── slots (called via Qt signals from the engine bridge) ──────────────
@@ -796,9 +850,8 @@ class MainScreen(QtWidgets.QWidget):
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).
It pushes short transcripts to the top; stray spacers (e.g. left from
the empty state) would open gaps between bubbles.
"""
for i in reversed(range(self.transcript_layout.count())):
item = self.transcript_layout.itemAt(i)
@@ -809,16 +862,16 @@ class MainScreen(QtWidgets.QWidget):
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)."""
"""Keep at most *max_bubbles* bubbles in the transcript layout (drops the oldest, at the bottom)."""
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)]:
for row in 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)."""
"""Add a new conversation turn to the transcript history (newest on top)."""
self._add_bubble(user_text, "user")
# Assistant message bubble (if any)
@@ -826,16 +879,16 @@ class MainScreen(QtWidgets.QWidget):
self._add_bubble(reply_text, "assistant")
self._prune_transcript()
# Spacer to push content to bottom (like Alexa's conversation history)
# Keeps short transcripts hugging the top edge
self._ensure_transcript_spacer()
# Auto-scroll to bottom
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
# Auto-scroll to top
QTimer.singleShot(50, self._scroll_transcript_to_top)
def _scroll_transcript_to_bottom(self) -> None:
"""Auto-scroll transcript to show latest messages."""
def _scroll_transcript_to_top(self) -> None:
"""Auto-scroll transcript to show the latest messages (newest is on top)."""
vbar = self.transcript_scroll.verticalScrollBar()
vbar.setValue(vbar.maximum())
vbar.setValue(vbar.minimum())
def set_connection(self, ok: bool, retry_count: int = 0, backoff: float = 0.0) -> None:
if ok:
@@ -865,7 +918,7 @@ class MainScreen(QtWidgets.QWidget):
self._add_bubble(f"⚠ {message}", "error")
self._prune_transcript()
self._ensure_transcript_spacer()
QTimer.singleShot(50, self._scroll_transcript_to_bottom)
QTimer.singleShot(50, self._scroll_transcript_to_top)
class SlaveWindow(QtWidgets.QMainWindow):
@@ -907,6 +960,7 @@ class SlaveWindow(QtWidgets.QMainWindow):
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))
self.main_screen.fullscreenToggleRequested.connect(self._toggle_fullscreen)
QtGui.QShortcut(QtGui.QKeySequence("F11"), self, activated=self._toggle_fullscreen)
# Kiosk: fullscreen with a hidden cursor (touch panels); Esc leaves fullscreen.
@@ -919,6 +973,12 @@ class SlaveWindow(QtWidgets.QMainWindow):
self.showFullScreen()
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.CursorShape.BlankCursor)
def changeEvent(self, event): # noqa: N802
super().changeEvent(event)
if event.type() == QtCore.QEvent.Type.WindowStateChange:
# Covers every path in (button, F11, kiosk, settings) and out (Esc).
self.main_screen.set_fullscreen(self.isFullScreen())
def _leave_fullscreen(self) -> None:
if self.isFullScreen():
self.showNormal()