refactor: relocate live stream controls to ReadoutPanel and update chart axis rendering
This commit is contained in:
@@ -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 {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
implicitHeight: readoutCard.implicitHeight + 24
|
implicitHeight: readoutCard.implicitHeight + 24
|
||||||
|
|||||||
@@ -200,16 +200,13 @@ ColumnLayout {
|
|||||||
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
return streamController.mode === "review" && streamController.loadedFile !== "" && modelData.fileName === streamController.loadedFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// "live_<serial>_<timestamp>.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 isLiveFile: (modelData.baseName || "").toLowerCase().indexOf("live") >= 0
|
||||||
|
readonly property bool liveFileBlocked: root.selectedTabIndex === 1 && isLiveFile
|
||||||
|
|
||||||
visible: matchesFilter
|
visible: matchesFilter
|
||||||
height: matchesFilter ? implicitHeight : 0
|
height: matchesFilter ? implicitHeight : 0
|
||||||
enabled: !isLiveFile
|
enabled: !liveFileBlocked
|
||||||
opacity: isLiveFile ? 0.45 : 1.0
|
opacity: liveFileBlocked ? 0.45 : 1.0
|
||||||
Behavior on opacity {
|
Behavior on opacity {
|
||||||
NumberAnimation { duration: Theme.durationFast }
|
NumberAnimation { duration: Theme.durationFast }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import QtQuick.Layouts
|
|||||||
import ISC
|
import ISC
|
||||||
|
|
||||||
// ===== Control Bar =====
|
// ===== Control Bar =====
|
||||||
// Mode-aware footer: cross-fades between Live and Review layouts.
|
// Review-mode transport footer (frame counter, play/pause/step, speed).
|
||||||
// Both variants render as a single centered pill — no full-width bar.
|
// 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 {
|
Item {
|
||||||
id: bar
|
id: bar
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -16,120 +18,13 @@ Item {
|
|||||||
}
|
}
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
// Hide entirely when in review mode with no file loaded
|
// Only shown in review mode, and only once a file is loaded
|
||||||
readonly property bool hasContent: {
|
readonly property bool hasContent: streamController.mode !== "live" && streamController.loadedFile !== ""
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── REVIEW MODE ──────────────────────────────────────────────
|
// ── REVIEW MODE ──────────────────────────────────────────────
|
||||||
Item {
|
Item {
|
||||||
id: reviewContent
|
id: reviewContent
|
||||||
anchors.fill: parent
|
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
|
// Single centered pill for frame counter + transport + speed
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
|
|
||||||
self._data: list[float] = []
|
self._data: list[float] = []
|
||||||
self._padding: int = 8
|
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._line_color = QColor("#5B9DF5")
|
||||||
self._grid_color = QColor("#2A3441")
|
self._grid_color = QColor("#2A3441")
|
||||||
self._text_color = QColor("#CBD5E1")
|
self._text_color = QColor("#CBD5E1")
|
||||||
@@ -153,9 +158,11 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
if w <= 0 or h <= 0:
|
if w <= 0 or h <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
plot_rect = QRectF(self._padding, self._padding,
|
left = self._padding + self._margin_left
|
||||||
max(1, w - 2 * self._padding),
|
bottom_inset = self._padding + self._margin_bottom
|
||||||
max(1, h - 2 * self._padding))
|
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)
|
self._draw_grid(painter, plot_rect)
|
||||||
|
|
||||||
@@ -220,16 +227,36 @@ class TrendChartItem(QQuickPaintedItem):
|
|||||||
font = QFont(painter.font())
|
font = QFont(painter.font())
|
||||||
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
font.setPointSizeF(max(7.0, font.pointSizeF() * 0.85))
|
||||||
painter.setFont(font)
|
painter.setFont(font)
|
||||||
|
|
||||||
rows = 4
|
rows = 4
|
||||||
|
label_w = self._margin_left - 4 # 4px gutter before the plot area
|
||||||
for i in range(rows + 1):
|
for i in range(rows + 1):
|
||||||
t = i / rows
|
t = i / rows
|
||||||
y_val = y_max - t * (y_max - y_min)
|
y_val = y_max - t * (y_max - y_min)
|
||||||
y_px = plot_rect.top() + t * plot_rect.height()
|
y_px = plot_rect.top() + t * plot_rect.height()
|
||||||
label = f"{y_val:.1f}"
|
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,
|
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||||
label)
|
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(
|
def _draw_line(
|
||||||
self,
|
self,
|
||||||
painter: QPainter,
|
painter: QPainter,
|
||||||
|
|||||||
Reference in New Issue
Block a user