From e2d05d2c3359c49b11bcaecd4abe8f296cbe2146 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 7 Jul 2026 10:38:42 -0700 Subject: [PATCH] refactor: relocate live stream controls to ReadoutPanel and update chart axis rendering --- .../ISC/Tabs/components/ReadoutPanel.qml | 109 ++++++++++++++++ src/pygui/ISC/Tabs/components/SourcePanel.qml | 9 +- .../ISC/Tabs/components/TransportBar.qml | 117 +----------------- .../backend/visualization/trend_chart_item.py | 35 +++++- 4 files changed, 149 insertions(+), 121 deletions(-) diff --git a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml index 39d139b..617d1ad 100644 --- a/src/pygui/ISC/Tabs/components/ReadoutPanel.qml +++ b/src/pygui/ISC/Tabs/components/ReadoutPanel.qml @@ -75,6 +75,115 @@ ColumnLayout { } } + // Live-mode stream stats + stop — moved here from the footer transport bar + // so that bar collapses to zero height in live mode, freeing vertical + // space for the trend chart. + Rectangle { + Layout.fillWidth: true + visible: streamController.mode === "live" + implicitHeight: visible ? liveStatsCard.implicitHeight + 24 : 0 + color: Theme.sidePanelBackground + radius: Theme.sidePanelRadius + border.color: Theme.sideBorder + border.width: 1 + + ColumnLayout { + id: liveStatsCard + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: 12 + } + spacing: 8 + + Label { + text: "LIVE STREAM" + color: Theme.sideMutedText + font.family: Theme.uiFontFamily + font.pixelSize: Theme.fontXs + font.letterSpacing: 1.5 + font.bold: true + Layout.bottomMargin: 4 + } + + RowLayout { + Layout.fillWidth: true + Label { + text: "Received" + color: Theme.sideMutedText + font.family: Theme.uiFontFamily + font.pixelSize: Theme.fontXs + font.bold: true + } + Item { Layout.fillWidth: true } + Label { + text: streamController.receivedCount + color: Theme.headingColor + font.family: Theme.codeFontFamily + font.pixelSize: Theme.fontMd + font.bold: true + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Theme.cardBorder + } + + RowLayout { + Layout.fillWidth: true + Label { + text: "Errors" + color: Theme.sideMutedText + font.family: Theme.uiFontFamily + font.pixelSize: Theme.fontXs + font.bold: true + } + Item { Layout.fillWidth: true } + Label { + text: streamController.errorCount + color: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor + font.family: Theme.codeFontFamily + font.pixelSize: Theme.fontMd + font.bold: true + } + } + + Button { + id: stopStreamBtn + Layout.fillWidth: true + Layout.topMargin: 4 + hoverEnabled: true + text: "STOP" + font.family: Theme.uiFontFamily + font.pixelSize: Theme.fontSm + font.weight: Font.Medium + font.letterSpacing: 1.0 + + background: Rectangle { + radius: 8 + color: stopStreamBtn.down ? Theme.transportButtonHover + : (stopStreamBtn.hovered ? Theme.transportButtonBg : "transparent") + border.width: Theme.borderThin + border.color: Theme.sideBorder + Behavior on color { + ColorAnimation { duration: Theme.durationFast } + } + } + contentItem: Text { + text: stopStreamBtn.text + color: Theme.headingColor + font: stopStreamBtn.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + onClicked: streamController.stopStream() + } + } + } + Rectangle { Layout.fillWidth: true implicitHeight: readoutCard.implicitHeight + 24 diff --git a/src/pygui/ISC/Tabs/components/SourcePanel.qml b/src/pygui/ISC/Tabs/components/SourcePanel.qml index e29fb6a..3075e5a 100644 --- a/src/pygui/ISC/Tabs/components/SourcePanel.qml +++ b/src/pygui/ISC/Tabs/components/SourcePanel.qml @@ -200,16 +200,13 @@ ColumnLayout { return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile; } - // "live__.csv" is SessionController's recording - // naming convention (see FileBrowser._refresh_files) — grey these out - // unconditionally rather than only while actively being written, since - // a stale directory listing can't tell "mid-write" from "just finished". readonly property bool isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0 + readonly property bool liveFileBlocked: root.selectedTabIndex === 1 && isLiveFile visible: matchesFilter height: matchesFilter ? implicitHeight : 0 - enabled: !isLiveFile - opacity: isLiveFile ? 0.45 : 1.0 + enabled: !liveFileBlocked + opacity: liveFileBlocked ? 0.45 : 1.0 Behavior on opacity { NumberAnimation { duration: Theme.durationFast } } diff --git a/src/pygui/ISC/Tabs/components/TransportBar.qml b/src/pygui/ISC/Tabs/components/TransportBar.qml index 1f03d9d..102fa7e 100644 --- a/src/pygui/ISC/Tabs/components/TransportBar.qml +++ b/src/pygui/ISC/Tabs/components/TransportBar.qml @@ -5,8 +5,10 @@ import QtQuick.Layouts import ISC // ===== Control Bar ===== -// Mode-aware footer: cross-fades between Live and Review layouts. -// Both variants render as a single centered pill — no full-width bar. +// Review-mode transport footer (frame counter, play/pause/step, speed). +// Live-mode's Received/Errors/Stop moved to ReadoutPanel's "LIVE STREAM" +// card so this bar can collapse to zero height and free vertical space for +// the trend chart while streaming. Item { id: bar Layout.fillWidth: true @@ -16,120 +18,13 @@ Item { } clip: true - // Hide entirely when in review mode with no file loaded - readonly property bool hasContent: { - if (streamController.mode === "live") return true; - return streamController.loadedFile !== ""; - } - readonly property bool isLive: streamController.mode === "live" - - // ── LIVE MODE ───────────────────────────────────────────────── - Item { - id: liveContent - anchors.fill: parent - opacity: bar.isLive ? 1.0 : 0.0 - visible: opacity > 0 - Behavior on opacity { - NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard } - } - - // Single centered pill for Received + Errors + Stop - Rectangle { - anchors.centerIn: parent - implicitWidth: liveRow.implicitWidth + 48 - implicitHeight: 68 - radius: Theme.radiusMd - color: Theme.sidePanelBackground - border.color: Theme.sideBorder - border.width: 1 - - Row { - id: liveRow - anchors.centerIn: parent - spacing: 24 - - // Received counter - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Received: " + streamController.receivedCount - color: Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontMd - font.weight: Font.Medium - } - - // Separator - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: 1 - height: 36 - color: Theme.sideBorder - } - - // Errors counter - Text { - anchors.verticalCenter: parent.verticalCenter - text: "Errors: " + streamController.errorCount - color: streamController.errorCount > 0 - ? Theme.statusWarningColor : Theme.sideMutedText - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontMd - font.weight: Font.Medium - } - - // Separator - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: 1 - height: 36 - color: Theme.sideBorder - } - - // STOP button - Button { - id: stopBtn - anchors.verticalCenter: parent.verticalCenter - implicitWidth: 112 - implicitHeight: 48 - hoverEnabled: true - text: "STOP" - font.family: Theme.uiFontFamily - font.pixelSize: Theme.fontSm - font.weight: Font.Medium - font.letterSpacing: 1.0 - - background: Rectangle { - radius: 8 - color: stopBtn.pressed ? Theme.transportButtonHover - : Theme.transportButtonBg - border.color: Theme.sideBorder - border.width: 1 - Behavior on color { - ColorAnimation { duration: Theme.durationFast } - } - } - contentItem: Text { - text: parent.text - color: Theme.headingColor - font: parent.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - onClicked: streamController.stopStream() - } - } - } - } + // Only shown in review mode, and only once a file is loaded + readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== "" // ── REVIEW MODE ────────────────────────────────────────────── Item { id: reviewContent anchors.fill: parent - opacity: !bar.isLive ? 1.0 : 0.0 - visible: opacity > 0 - Behavior on opacity { - NumberAnimation { duration: Theme.durationBase; easing.type: Theme.easeStandard } - } // Single centered pill for frame counter + transport + speed Rectangle { diff --git a/src/pygui/backend/visualization/trend_chart_item.py b/src/pygui/backend/visualization/trend_chart_item.py index 8ce94bb..7b5c063 100644 --- a/src/pygui/backend/visualization/trend_chart_item.py +++ b/src/pygui/backend/visualization/trend_chart_item.py @@ -44,6 +44,11 @@ class TrendChartItem(QQuickPaintedItem): self._data: list[float] = [] self._padding: int = 8 + # Separate from `_padding` (breathing room around the plot area): these + # reserve space for the axis labels themselves, which are wider than + # `_padding` alone — that mismatch is what clipped y-axis text before. + self._margin_left: int = 34 + self._margin_bottom: int = 16 self._line_color = QColor("#5B9DF5") self._grid_color = QColor("#2A3441") self._text_color = QColor("#CBD5E1") @@ -153,9 +158,11 @@ class TrendChartItem(QQuickPaintedItem): if w <= 0 or h <= 0: return - plot_rect = QRectF(self._padding, self._padding, - max(1, w - 2 * self._padding), - max(1, h - 2 * self._padding)) + left = self._padding + self._margin_left + bottom_inset = self._padding + self._margin_bottom + plot_rect = QRectF(left, self._padding, + max(1, w - left - self._padding), + max(1, h - self._padding - bottom_inset)) self._draw_grid(painter, plot_rect) @@ -220,16 +227,36 @@ class TrendChartItem(QQuickPaintedItem): font = QFont(painter.font()) font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85)) painter.setFont(font) + rows = 4 + label_w = self._margin_left - 4 # 4px gutter before the plot area for i in range(rows + 1): t = i / rows y_val = y_max - t * (y_max - y_min) y_px = plot_rect.top() + t * plot_rect.height() label = f"{y_val:.1f}" - painter.drawText(QRectF(0, y_px - 7, self._padding, 14), + painter.drawText(QRectF(0, y_px - 7, label_w, 14), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, label) + # X-axis: sample index. `trendData` carries plain averages with no + # timestamps attached, so index is the only x-coordinate available — + # it reads correctly for review mode (index == frame number) and as a + # relative recency order for the live rolling window. paint() already + # guarantees len(self._data) >= 2 before calling this. + n = len(self._data) + cols = min(4, n - 1) + y_px = plot_rect.bottom() + 4 + for i in range(cols + 1): + idx = round((i / cols) * (n - 1)) + x_px = self._x_to_px(idx, n, plot_rect) + align = (Qt.AlignmentFlag.AlignLeft if i == 0 + else Qt.AlignmentFlag.AlignRight if i == cols + else Qt.AlignmentFlag.AlignHCenter) + painter.drawText(QRectF(x_px - 20, y_px, 40, 14), + align | Qt.AlignmentFlag.AlignTop, + str(idx)) + def _draw_line( self, painter: QPainter,