ji
This commit is contained in:
1 parent
4c16f87a3e
commit
2493c5c532
27 files changed
+8008
-568
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user