From 7705ecff72b0be8cdc83524cdc617f071a2f1d75 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:50:25 +0700 Subject: [PATCH 01/16] feat(studio): premium injection workspace with main PTP tray, voltage-first plots and shared menus --- apps/arstack_studio/qml/ConfigurationHub.qml | 5 + apps/arstack_studio/qml/Main.qml | 40 +++-- apps/arstack_studio/qml/PtpExpertPanel.qml | 53 ++----- apps/arstack_studio/qml/SignalMatrix.qml | 54 +++++-- apps/arstack_studio/qml/SignalPreview.qml | 47 +++--- apps/arstack_studio/qml/StudioActionMenu.qml | 47 ++++++ apps/arstack_studio/qml/StudioPtpSwitch.qml | 41 +++++ apps/arstack_studio/qml/StudioTheme.qml | 9 ++ apps/arstack_studio/qml/StudioTimingTray.qml | 158 +++++++++++++++++++ 9 files changed, 363 insertions(+), 91 deletions(-) create mode 100644 apps/arstack_studio/qml/StudioActionMenu.qml create mode 100644 apps/arstack_studio/qml/StudioPtpSwitch.qml create mode 100644 apps/arstack_studio/qml/StudioTimingTray.qml diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 6f56597a3..0b22527d1 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -16,6 +16,10 @@ Item { function openEngineeringFile() { profileInspector.openEngineeringFile() } function showRecovery() { expertTabs.currentIndex = 1 } + function showTiming() { expertTabs.currentIndex = 3 } + // The Main tray always shares the Advanced PTP profile and verified + // guided START state machine; never create an independent START sequence. + function startPtpRole(role) { return timingPanel.startRole(role) } Component.onCompleted: { // Recovery is the only advanced page an unconfigured board should need. @@ -159,6 +163,7 @@ Item { monoFont: hub.monoFont } PtpExpertPanel { + id: timingPanel theme: hub.theme controller: hub.controller device: hub.device diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index fdaef5634..79d112fc6 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -808,41 +808,53 @@ ApplicationWindow { Layout.fillHeight: true spacing: 9 SignalMatrix { - id: currentMatrix + id: voltageMatrix Layout.fillWidth: true Layout.fillHeight: true theme: studioTheme controller: root device: device session: workflowBar.session - sourceModel: currentModel + sourceModel: voltageModel uiFont: root.uiFont monoFont: root.monoFont - groupIndex: 0 - titleText: "Current" - symbolText: "I" - unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" + groupIndex: 1 + titleText: "Voltage" + symbolText: "U" + unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" compact: root.compactLayout - } - SignalMatrix { - id: voltageMatrix + } SignalMatrix { + id: currentMatrix Layout.fillWidth: true Layout.fillHeight: true theme: studioTheme controller: root device: device session: workflowBar.session - sourceModel: voltageModel + sourceModel: currentModel uiFont: root.uiFont monoFont: root.monoFont - groupIndex: 1 - titleText: "Voltage" - symbolText: "U" - unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" + groupIndex: 0 + titleText: "Current" + symbolText: "I" + unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" compact: root.compactLayout } + } + StudioTimingTray { + Layout.fillWidth: true + Layout.preferredHeight: root.height < 700 ? 112 : 132 + Layout.minimumHeight: root.height < 700 ? 112 : 132 + theme: studioTheme + controller: root + device: device + session: workflowBar.session + uiFont: root.uiFont + monoFont: root.monoFont + compact: root.compactLayout + } Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } RowLayout { Layout.fillWidth: true diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index ebbc7ef52..bef8d9b70 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -44,12 +44,18 @@ SurfacePanel { return panel.session && panel.session.requestConfigurePtp(panel.ptpProfile()) } + function startRole(role) { + if (!panel.session || !panel.device.ptpAvailable || !panel.device.deviceVerified + || panel.device.ptpRunning || panel.startPending || panel.session.updatingFirmware) + return false + const accepted = panel.session.requestStartPtpConfigured(role, panel.ptpProfile()) + if (!accepted && panel.controller) + panel.controller.showMessage("PTP start blocked. Check timing diagnostics.", true) + return Boolean(accepted) + } + function startSelectedMode() { - if (!panel.session || !panel.device.ptpAvailable) return - var role = panel.roleValues[modeCombo.currentIndex] - if (!panel.session.requestStartPtpConfigured(role, panel.ptpProfile())) { - if (panel.controller) panel.controller.showMessage("PTP start blocked. See the Timing status for details.", true) - } + return panel.startRole(panel.roleValues[modeCombo.currentIndex]) } readonly property string activeRole: panel.device.ptpRole @@ -114,52 +120,23 @@ SurfacePanel { } } Item { Layout.fillWidth: true } - Switch { + StudioPtpSwitch { id: ptpPower + theme: panel.theme + uiFont: panel.uiFont text: "PTP" checked: Boolean(panel.device.ptpRunning) enabled: panel.session && panel.device.ptpAvailable && !panel.startPending && !panel.session.updatingFirmware - font.family: panel.uiFont - font.pixelSize: 11 ToolTip.visible: hovered - ToolTip.text: "Start the selected lab PTP role, or stop PTP. State follows verified board telemetry." + ToolTip.text: "Start the selected lab PTP role or stop it. State follows verified board telemetry." onClicked: { - // A Switch flips locally before asynchronous firmware ACK. - // Restore its binding to the sole device-owned state first; - // rejected START/STOP must never advertise the wrong status. const wantsRunning = checked checked = Qt.binding(function() { return Boolean(panel.device.ptpRunning) }) if (wantsRunning) panel.startSelectedMode() else if (panel.session && !panel.session.requestStopPtp() && panel.controller) panel.controller.showMessage("PTP stop was not accepted. Copy diagnostics.", true) } - indicator: Rectangle { - implicitWidth: 46 - implicitHeight: 24 - x: ptpPower.leftPadding - y: (ptpPower.height - height) / 2 - radius: 12 - color: ptpPower.checked ? "#195b44" : panel.theme.surface2 - border.width: 1 - border.color: ptpPower.checked ? panel.theme.green : panel.theme.line - Rectangle { - width: 16 - height: 16 - y: 4 - x: ptpPower.checked ? parent.width - width - 4 : 4 - radius: 8 - color: ptpPower.checked ? panel.theme.green : panel.theme.textSoft - Behavior on x { NumberAnimation { duration: 120 } } - } - } - contentItem: Text { - text: ptpPower.text - color: ptpPower.enabled ? panel.theme.text : panel.theme.muted - font: ptpPower.font - leftPadding: ptpPower.indicator.width + ptpPower.spacing - verticalAlignment: Text.AlignVCenter - } } StateBadge { theme: panel.theme diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 1b789b0d0..dd7d1635a 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -124,33 +124,55 @@ Rectangle { property real pendingMagnitude: mag property real pendingPhase: angle - Menu { + // Action-backed menu uses a single globally styled delegate. + // Rows/callbacks stay local so keyboard navigation and model + // ownership remain unchanged. + StudioActionMenu { id: magnitudeMenu - background: Rectangle { color: matrix.theme.raised; radius: 8; border.color: matrix.theme.line } - MenuItem { - text: "Equal Value · A/B/C = " + signalRow.sid + theme: matrix.theme + uiFont: matrix.uiFont + Action { text: "MAGNITUDE · " + signalRow.sid; enabled: false } + Action { + text: "Zero magnitude" + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", 0) + } + Action { + text: "Demo level · 50%" + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, + "magnitude", matrix.groupIndex === 0 ? 0.5 : 28.87) + } + Action { + text: "Demo level · 100%" + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, + "magnitude", matrix.groupIndex === 0 ? 1.0 : 57.74) + } + MenuSeparator {} + Action { + text: "Equal ABC magnitudes" enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.equalGroupMagnitudes(matrix.groupIndex, signalRow.rowIndex) - contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } - background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } } } - Menu { + StudioActionMenu { id: phaseMenu - background: Rectangle { color: matrix.theme.raised; radius: 8; border.color: matrix.theme.line } - MenuItem { - text: "Equal Angle · A/B/C = " + signalRow.sid + theme: matrix.theme + uiFont: matrix.uiFont + Action { text: "ANGLE · " + signalRow.sid; enabled: false } + Action { + text: "Zero angle · 0°" + enabled: matrix.controller.signalFrequency > 0 + onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", 0) + } + MenuSeparator {} + Action { + text: "Equal ABC angles" enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.equalGroupAngles(matrix.groupIndex, signalRow.rowIndex) - contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } - background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } } - MenuItem { - text: "Balance Angle · 120° from " + signalRow.sid + Action { + text: "Balance ABC · 120°" enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.balanceGroupAngles(matrix.groupIndex, signalRow.rowIndex) - contentItem: Label { text: parent.text; color: matrix.theme.text; font.family: matrix.uiFont; font.pixelSize: 12 } - background: Rectangle { color: parent.highlighted ? matrix.theme.accentSoft : "transparent" } } } diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml index 8e8a33910..41e217bd1 100644 --- a/apps/arstack_studio/qml/SignalPreview.qml +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -176,8 +176,9 @@ SurfacePanel { var plotHeight = canvas.height - top - bottom var gap = preview.compact ? 15 : 18 var laneHeight = (plotHeight - gap) / 2 - var currentY = top + laneHeight / 2 - var voltageY = top + laneHeight + gap + laneHeight / 2 + // One physical order across table, phasor and waveform: U then I. + var voltageY = top + laneHeight / 2 + var currentY = top + laneHeight + gap + laneHeight / 2 var gridColor = preview.theme ? preview.theme.plotGrid : "#22303d" var strongGridColor = preview.theme ? preview.theme.plotGridStrong : "#334353" @@ -199,7 +200,7 @@ SurfacePanel { ctx.strokeStyle = strongGridColor for (var lane = 0; lane < 2; ++lane) { - var baseline = lane === 0 ? currentY : voltageY + var baseline = lane === 0 ? voltageY : currentY ctx.beginPath() ctx.moveTo(left, baseline) ctx.lineTo(left + plotWidth, baseline) @@ -210,10 +211,10 @@ SurfacePanel { ctx.font = "600 8px '" + preview.uiFont + "'" ctx.textAlign = "right" ctx.textBaseline = "middle" - ctx.fillText("CURRENT", left - 7, top + 11) - ctx.fillText("A", left - 7, top + 23) - ctx.fillText("VOLTAGE", left - 7, top + laneHeight + gap + 11) - ctx.fillText("V", left - 7, top + laneHeight + gap + 23) + ctx.fillText("VOLTAGE", left - 7, top + 11) + ctx.fillText("V", left - 7, top + 23) + ctx.fillText("CURRENT", left - 7, top + laneHeight + gap + 11) + ctx.fillText("A", left - 7, top + laneHeight + gap + 23) ctx.font = "500 8px '" + preview.uiFont + "'" ctx.textAlign = "center" @@ -281,8 +282,8 @@ SurfacePanel { } } - drawWaveGroup(preview.currentModel, currentY, true) drawWaveGroup(preview.voltageModel, voltageY, false) + drawWaveGroup(preview.currentModel, currentY, true) } ColumnLayout { @@ -336,10 +337,10 @@ SurfacePanel { radius: 7 color: preview.theme.plotSurface border.width: 1 - border.color: currentPhasorHover.hovered ? "#35506b" : preview.theme.lineSoft + border.color: voltagePhasorHover.hovered ? "#35506b" : preview.theme.lineSoft Behavior on border.color { ColorAnimation { duration: 100 } } - HoverHandler { id: currentPhasorHover } + HoverHandler { id: voltagePhasorHover } ColumnLayout { anchors.fill: parent @@ -351,7 +352,7 @@ SurfacePanel { spacing: 6 Rectangle { width: 3; height: 13; radius: 2; color: preview.theme.accent } Label { - text: "CURRENT PHASOR" + text: "VOLTAGE PHASOR" color: preview.theme.textSoft font.family: preview.uiFont font.pixelSize: 9 @@ -360,7 +361,7 @@ SurfacePanel { } Item { Layout.fillWidth: true } Label { - text: preview.signalFrequency === 0 ? "A DC" : "A RMS" + text: preview.signalFrequency === 0 ? "V DC" : "V RMS" color: preview.theme.muted font.family: preview.uiFont font.pixelSize: 8 @@ -368,28 +369,26 @@ SurfacePanel { } Canvas { - id: currentPhasorCanvas + id: voltagePhasorCanvas Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 100 onWidthChanged: requestPaint() onHeightChanged: requestPaint() - onPaint: preview.paintPhasor(currentPhasorCanvas, preview.currentModel) + onPaint: preview.paintPhasor(voltagePhasorCanvas, preview.voltageModel) } } - } - - Rectangle { + } Rectangle { Layout.fillWidth: true Layout.fillHeight: true Layout.minimumWidth: 150 radius: 7 color: preview.theme.plotSurface border.width: 1 - border.color: voltagePhasorHover.hovered ? "#35506b" : preview.theme.lineSoft + border.color: currentPhasorHover.hovered ? "#35506b" : preview.theme.lineSoft Behavior on border.color { ColorAnimation { duration: 100 } } - HoverHandler { id: voltagePhasorHover } + HoverHandler { id: currentPhasorHover } ColumnLayout { anchors.fill: parent @@ -401,7 +400,7 @@ SurfacePanel { spacing: 6 Rectangle { width: 3; height: 13; radius: 2; color: preview.theme.accent } Label { - text: "VOLTAGE PHASOR" + text: "CURRENT PHASOR" color: preview.theme.textSoft font.family: preview.uiFont font.pixelSize: 9 @@ -410,7 +409,7 @@ SurfacePanel { } Item { Layout.fillWidth: true } Label { - text: preview.signalFrequency === 0 ? "V DC" : "V RMS" + text: preview.signalFrequency === 0 ? "A DC" : "A RMS" color: preview.theme.muted font.family: preview.uiFont font.pixelSize: 8 @@ -418,16 +417,18 @@ SurfacePanel { } Canvas { - id: voltagePhasorCanvas + id: currentPhasorCanvas Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 100 onWidthChanged: requestPaint() onHeightChanged: requestPaint() - onPaint: preview.paintPhasor(voltagePhasorCanvas, preview.voltageModel) + onPaint: preview.paintPhasor(currentPhasorCanvas, preview.currentModel) } } } + + } Rectangle { diff --git a/apps/arstack_studio/qml/StudioActionMenu.qml b/apps/arstack_studio/qml/StudioActionMenu.qml new file mode 100644 index 000000000..2ef0aec90 --- /dev/null +++ b/apps/arstack_studio/qml/StudioActionMenu.qml @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Shared action-based Qt Quick desktop menu; no duplicated native popup styling. +import QtQuick +import QtQuick.Controls + +Menu { + id: menu + property var theme + property string uiFont: "Inter" + implicitWidth: 250 + topPadding: 7 + bottomPadding: 7 + leftPadding: 6 + rightPadding: 6 + spacing: 2 + font.family: uiFont + font.pixelSize: 11 + // Use model Actions so menu handles activation, Escape and keyboard focus. + delegate: MenuItem { + id: actionItem + width: menu.width - menu.leftPadding - menu.rightPadding + implicitHeight: menu.theme.menuItemHeight + leftPadding: 12 + rightPadding: 12 + font.family: menu.uiFont + font.pixelSize: 11 + contentItem: Text { + text: actionItem.text + font: actionItem.font + color: actionItem.enabled ? menu.theme.text : menu.theme.muted + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + background: Rectangle { + radius: menu.theme.controlRadius + color: actionItem.highlighted ? menu.theme.menuHover : "transparent" + border.width: actionItem.activeFocus ? 1 : 0 + border.color: menu.theme.accent + } + } + background: Rectangle { + color: menu.theme.menuSurface + radius: menu.theme.menuRadius + border.width: 1 + border.color: menu.theme.line + } +} diff --git a/apps/arstack_studio/qml/StudioPtpSwitch.qml b/apps/arstack_studio/qml/StudioPtpSwitch.qml new file mode 100644 index 000000000..9b476072e --- /dev/null +++ b/apps/arstack_studio/qml/StudioPtpSwitch.qml @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Presentation-only PTP switch; checked is owned by device telemetry at call site. +import QtQuick +import QtQuick.Controls + +Switch { + id: control + property var theme + property string uiFont: "Inter" + text: "PTP" + hoverEnabled: true + font.family: uiFont + font.pixelSize: 11 + spacing: 9 + indicator: Rectangle { + implicitWidth: 46 + implicitHeight: 24 + x: control.leftPadding + y: (control.height - height) / 2 + radius: 12 + color: control.checked ? control.theme.greenSoft : control.theme.surface2 + border.width: 1 + border.color: control.checked ? control.theme.green : control.theme.line + Rectangle { + width: 16 + height: 16 + radius: 8 + y: 4 + x: control.checked ? parent.width - width - 4 : 4 + color: control.checked ? control.theme.green : control.theme.textSoft + Behavior on x { NumberAnimation { duration: 120 } } + } + } + contentItem: Text { + text: control.text + font: control.font + color: control.enabled ? control.theme.text : control.theme.muted + leftPadding: control.indicator.width + control.spacing + verticalAlignment: Text.AlignVCenter + } +} diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml index d9921adfe..8ac1140e0 100644 --- a/apps/arstack_studio/qml/StudioTheme.qml +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -45,4 +45,13 @@ QtObject { readonly property int controlHeight: 34 readonly property int panelRadius: 8 readonly property int controlRadius: 7 + // Shared Material-inspired desktop surface/interaction tokens. + readonly property color menuSurface: "#18222e" + readonly property color menuHover: "#253b51" + readonly property int menuRadius: 11 + readonly property int menuItemHeight: 38 + readonly property int spaceXs: 4 + readonly property int spaceSm: 8 + readonly property int spaceMd: 12 + readonly property int spaceLg: 16 } diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml new file mode 100644 index 000000000..97f8b338b --- /dev/null +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Main-window timing workflow. Shares the existing Advanced PTP profile and +// SmartSessionController/DeviceController authority; never invents lock state. +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: tray + property var theme + property var controller + property var device + property var session + property string uiFont: "Inter" + property string monoFont: "Inter" + property bool compact: false + property int selectedRoleIndex: 0 + readonly property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] + readonly property bool busy: Boolean(device && device.ptpStarting) + readonly property bool ptpReady: Boolean(session && device && device.deviceVerified + && device.ptpAvailable && !session.updatingFirmware) + readonly property string stateText: !device || !device.deviceVerified ? "NO DEVICE" + : !device.ptpAvailable ? "PTP UNAVAILABLE" + : busy ? (device.ptpStatus.indexOf("unverified") !== -1 ? "UNVERIFIED" : "PENDING") + : !device.ptpRunning ? "PTP OFF" + : device.ptpRole + " RUNNING" + readonly property color stateColor: busy ? theme.amber + : device && device.ptpRunning ? theme.green : theme.muted + + function syncIndex(mode) { + if (mode === "FORCE_0") return 1 + if (mode === "FORCE_1_LOCAL") return 2 + if (mode === "FORCE_2_GLOBAL") return 3 + return 0 + } + + color: theme.panelAlt + radius: theme.panelRadius + border.width: 1 + border.color: theme.line + + ColumnLayout { + anchors.fill: parent + anchors.margins: tray.compact ? 9 : 11 + spacing: tray.compact ? 5 : 7 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Rectangle { width: 3; height: 14; radius: 2; color: tray.theme.accent } + Label { + text: "TIME SYNC / SV SIMULATION" + color: tray.theme.textSoft + font.family: tray.uiFont + font.pixelSize: 10 + font.weight: Font.DemiBold + font.letterSpacing: 0.45 + } + Item { Layout.fillWidth: true } + StateBadge { + theme: tray.theme + monoFont: tray.monoFont + state: tray.stateText + stateColor: tray.stateColor + } + } + + RowLayout { + Layout.fillWidth: true + spacing: tray.compact ? 7 : 10 + StudioPtpSwitch { + id: powerSwitch + theme: tray.theme + uiFont: tray.uiFont + checked: Boolean(tray.device && tray.device.ptpRunning) + enabled: tray.ptpReady && !tray.busy + ToolTip.visible: hovered + ToolTip.text: "Start/stop PTP with verified board feedback. Does not establish SV sample-phase lock." + onClicked: { + // Switch flips locally before asynchronous firmware evidence. + // Restore binding immediately, including on rejected requests. + const wantsRunning = checked + checked = Qt.binding(function() { return Boolean(tray.device && tray.device.ptpRunning) }) + if (wantsRunning) { + if (tray.controller) tray.controller.startPtpRole(tray.roleValues[tray.selectedRoleIndex]) + } else if (tray.session && !tray.session.requestStopPtp() && tray.controller) { + tray.controller.showMessage("PTP stop was not accepted; inspect diagnostics.", true) + } + } + } + StudioDarkComboBox { + id: roleCombo + theme: tray.theme + uiFont: tray.uiFont + Layout.fillWidth: true + Layout.minimumWidth: 118 + model: ["Source", "Follower", "Monitor"] + currentIndex: tray.selectedRoleIndex + enabled: tray.ptpReady && !tray.busy && !tray.device.ptpRunning + onActivated: tray.selectedRoleIndex = currentIndex + ToolTip.visible: hovered + ToolTip.text: "Source transmits PTP; Follower discovers an external GM; Monitor receives without disciplining." + } + Label { + text: "smpSynch" + color: tray.theme.muted + font.family: tray.uiFont + font.pixelSize: 9 + } + StudioDarkComboBox { + id: syncCombo + theme: tray.theme + uiFont: tray.uiFont + Layout.fillWidth: true + Layout.minimumWidth: 100 + model: ["AUTO", "0 · No sync", "1 · Local (sim)", "2 · Global (sim)"] + currentIndex: tray.syncIndex(tray.device ? tray.device.smpSynchMode : "AUTO") + enabled: tray.session && tray.device && tray.device.deviceVerified && !tray.session.updatingFirmware + ToolTip.visible: hovered + ToolTip.text: "1/2 change SV metadata only; they do not synchronize the sample clock." + onActivated: { + const choice = ["AUTO", "0", "1", "2"][currentIndex] + const accepted = tray.session && tray.session.requestSmpSynch(choice) + currentIndex = Qt.binding(function() { + return tray.syncIndex(tray.device ? tray.device.smpSynchMode : "AUTO") + }) + if (!accepted && tray.controller) + tray.controller.showMessage("SV smpSynch override rejected; inspect diagnostics.", true) + } + } + CalmButton { + theme: tray.theme + uiFont: tray.uiFont + text: "Details" + implicitWidth: 66 + toolTipText: "PTP settings, timing evidence and diagnostics" + onClicked: if (tray.controller) tray.controller.openTimingSettings() + } + } + Label { + Layout.fillWidth: true + text: !tray.device || !tray.device.deviceVerified + ? "Connect an ESP32-P4 to enable PTP. Generated waveform is a setpoint preview." + : tray.device.smpSynchSimulated + ? "SV smpSynch=" + tray.device.smpSynchValue + " · SIMULATED metadata — sample clock not proven locked." + : tray.device.smpSynchMeasured + ? "SV smpSynch=" + tray.device.smpSynchValue + " · board-reported evidence; sample phase needs independent verification." + : "SV smpSynch=" + tray.device.smpSynchValue + " · unqualified. PTP traffic alone does not prove SV synchronization." + color: tray.device && tray.device.smpSynchSimulated ? tray.theme.amber : tray.theme.muted + font.family: tray.uiFont + font.pixelSize: tray.compact ? 9 : 10 + elide: Text.ElideRight + ToolTip.visible: evidenceHover.hovered + ToolTip.text: text + HoverHandler { id: evidenceHover } + } + } +} From d78d696c34c4b03894bf303e37c51be2d54c91c6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:52:58 +0700 Subject: [PATCH 02/16] ux(studio): complete frequency right-click presets and anchor wide action menus --- apps/arstack_studio/qml/Main.qml | 25 ++++++++++++++++++++ apps/arstack_studio/qml/SignalMatrix.qml | 14 +++++++++-- apps/arstack_studio/qml/StudioActionMenu.qml | 2 ++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 79d112fc6..ced95745c 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -740,6 +740,23 @@ ApplicationWindow { color: "#0b1219" border.width: 0 + StudioActionMenu { + id: frequencyMenu + theme: studioTheme + uiFont: root.uiFont + x: 80 + y: 45 + Action { text: "FREQUENCY · SHARED"; enabled: false } + Action { text: "DC mode · 0 Hz"; onTriggered: root.setWaveformMode("DC") } + Action { text: "50 Hz · nominal lab preset"; onTriggered: root.setFrequencyValue(50) } + Action { text: "60 Hz · lab preset"; onTriggered: root.setFrequencyValue(60) } + MenuSeparator {} + Action { + text: "Return to last AC frequency" + enabled: root.previousAcFrequency > 0 + onTriggered: root.setWaveformMode("AC") + } + } RowLayout { anchors.fill: parent anchors.leftMargin: 11 @@ -756,6 +773,14 @@ ApplicationWindow { } NumericField { id: frequencyField + ToolTip.visible: hovered && !activeFocus + ToolTip.delay: 650 + ToolTip.text: "Right-click for DC / 50 Hz / 60 Hz and AC restoration" + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + onClicked: frequencyMenu.popup() + } theme: studioTheme monoFont: root.monoFont compact: root.compactLayout diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index dd7d1635a..1892ee3d6 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -258,7 +258,12 @@ Rectangle { anchors.fill: parent z: 5 acceptedButtons: Qt.RightButton - onClicked: magnitudeMenu.popup() + onClicked: { + var at = signalRow.mapFromItem(magnitudeField, 0, magnitudeField.height) + magnitudeMenu.x = Math.max(6, Math.min(at.x, signalRow.width - magnitudeMenu.width)) + magnitudeMenu.y = at.y + magnitudeMenu.popup() + } } Layout.fillWidth: true theme: matrix.theme @@ -316,7 +321,12 @@ Rectangle { anchors.fill: parent z: 5 acceptedButtons: Qt.RightButton - onClicked: phaseMenu.popup() + onClicked: { + var at = signalRow.mapFromItem(phaseField, 0, phaseField.height) + phaseMenu.x = Math.max(6, Math.min(at.x, signalRow.width - phaseMenu.width)) + phaseMenu.y = at.y + phaseMenu.popup() + } } enabled: matrix.controller.signalFrequency > 0 Layout.preferredWidth: matrix.compact ? 84 : 100 diff --git a/apps/arstack_studio/qml/StudioActionMenu.qml b/apps/arstack_studio/qml/StudioActionMenu.qml index 2ef0aec90..1172d8754 100644 --- a/apps/arstack_studio/qml/StudioActionMenu.qml +++ b/apps/arstack_studio/qml/StudioActionMenu.qml @@ -38,6 +38,8 @@ Menu { border.color: menu.theme.accent } } + // Render the same separators on Windows rather than native light lines. + // All commands are Qt Actions so disabled/keyboard behavior remains native. background: Rectangle { color: menu.theme.menuSurface radius: menu.theme.menuRadius From e801a11ad54b8a6c0e2c191b86ad9f05e527d78f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:54:15 +0700 Subject: [PATCH 03/16] style(studio): unify menu separators and keep active PTP mode truthful --- apps/arstack_studio/qml/Main.qml | 2 +- apps/arstack_studio/qml/SignalMatrix.qml | 4 ++-- apps/arstack_studio/qml/StudioMenuDivider.qml | 16 ++++++++++++++++ apps/arstack_studio/qml/StudioTimingTray.qml | 9 ++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 apps/arstack_studio/qml/StudioMenuDivider.qml diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index ced95745c..b3e79a291 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -750,7 +750,7 @@ ApplicationWindow { Action { text: "DC mode · 0 Hz"; onTriggered: root.setWaveformMode("DC") } Action { text: "50 Hz · nominal lab preset"; onTriggered: root.setFrequencyValue(50) } Action { text: "60 Hz · lab preset"; onTriggered: root.setFrequencyValue(60) } - MenuSeparator {} + StudioMenuDivider { theme: studioTheme } Action { text: "Return to last AC frequency" enabled: root.previousAcFrequency > 0 diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 1892ee3d6..05ca6f6fe 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -146,7 +146,7 @@ Rectangle { onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", matrix.groupIndex === 0 ? 1.0 : 57.74) } - MenuSeparator {} + StudioMenuDivider { theme: matrix.theme } Action { text: "Equal ABC magnitudes" enabled: signalRow.rowIndex < 3 @@ -163,7 +163,7 @@ Rectangle { enabled: matrix.controller.signalFrequency > 0 onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", 0) } - MenuSeparator {} + StudioMenuDivider { theme: matrix.theme } Action { text: "Equal ABC angles" enabled: signalRow.rowIndex < 3 diff --git a/apps/arstack_studio/qml/StudioMenuDivider.qml b/apps/arstack_studio/qml/StudioMenuDivider.qml new file mode 100644 index 000000000..05cabb37a --- /dev/null +++ b/apps/arstack_studio/qml/StudioMenuDivider.qml @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +MenuSeparator { + id: separator + property var theme + topPadding: 5 + bottomPadding: 5 + leftPadding: 10 + rightPadding: 10 + contentItem: Rectangle { + implicitHeight: 1 + color: separator.theme.line + } +} diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index 97f8b338b..c35aaffbb 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -27,6 +27,12 @@ Rectangle { readonly property color stateColor: busy ? theme.amber : device && device.ptpRunning ? theme.green : theme.muted + function roleIndex(role) { + if (role === "RECEIVER") return 1 + if (role === "MONITOR") return 2 + return 0 + } + function syncIndex(mode) { if (mode === "FORCE_0") return 1 if (mode === "FORCE_1_LOCAL") return 2 @@ -95,7 +101,8 @@ Rectangle { Layout.fillWidth: true Layout.minimumWidth: 118 model: ["Source", "Follower", "Monitor"] - currentIndex: tray.selectedRoleIndex + currentIndex: tray.device && tray.device.ptpRunning + ? tray.roleIndex(tray.device.ptpRole) : tray.selectedRoleIndex enabled: tray.ptpReady && !tray.busy && !tray.device.ptpRunning onActivated: tray.selectedRoleIndex = currentIndex ToolTip.visible: hovered From 60d78052de29ee923a91e395850abf02f660e1ad Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:54:53 +0700 Subject: [PATCH 04/16] build(studio): register reusable premium QML components in Qt module --- apps/arstack_studio/CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 4a9fa7112..52308f19e 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -99,6 +99,10 @@ qt_add_qml_module(arstack_studio qml/PtpExpertPanel.qml qml/StudioDarkComboBox.qml qml/StudioDarkCheckBox.qml + qml/StudioActionMenu.qml + qml/StudioMenuDivider.qml + qml/StudioPtpSwitch.qml + qml/StudioTimingTray.qml qml/DeviceExpertPanel.qml qml/FirmwarePanel.qml qml/WaveformExpertPanel.qml From de32cb70ee426a9f3f6cbcc56f5eac9deeb1cde0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:55:47 +0700 Subject: [PATCH 05/16] fix(studio): enforce custom Qt 6 context menu rendering and field-aligned popups --- apps/arstack_studio/qml/SignalMatrix.qml | 4 ++-- apps/arstack_studio/qml/StudioActionMenu.qml | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 05ca6f6fe..2c8f18e85 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -262,7 +262,7 @@ Rectangle { var at = signalRow.mapFromItem(magnitudeField, 0, magnitudeField.height) magnitudeMenu.x = Math.max(6, Math.min(at.x, signalRow.width - magnitudeMenu.width)) magnitudeMenu.y = at.y - magnitudeMenu.popup() + magnitudeMenu.popup(magnitudeMenu.x, magnitudeMenu.y) } } Layout.fillWidth: true @@ -325,7 +325,7 @@ Rectangle { var at = signalRow.mapFromItem(phaseField, 0, phaseField.height) phaseMenu.x = Math.max(6, Math.min(at.x, signalRow.width - phaseMenu.width)) phaseMenu.y = at.y - phaseMenu.popup() + phaseMenu.popup(phaseMenu.x, phaseMenu.y) } } enabled: matrix.controller.signalFrequency > 0 diff --git a/apps/arstack_studio/qml/StudioActionMenu.qml b/apps/arstack_studio/qml/StudioActionMenu.qml index 1172d8754..16188edf7 100644 --- a/apps/arstack_studio/qml/StudioActionMenu.qml +++ b/apps/arstack_studio/qml/StudioActionMenu.qml @@ -7,6 +7,9 @@ Menu { id: menu property var theme property string uiFont: "Inter" + // Qt 6.8+ can prefer native Windows menus, which ignore custom QML + // delegates and render in OS colors. Force our portable styled overlay. + popupType: Popup.Item implicitWidth: 250 topPadding: 7 bottomPadding: 7 From 89726b3b54693d9550a99ace7640dad6ea488de2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:57:34 +0700 Subject: [PATCH 06/16] test(studio): guard voltage-first visual order and single-owner PTP workflow --- .github/workflows/arstack-studio-qt.yml | 4 ++ .../tests/check_operator_workspace.py | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 apps/arstack_studio/tests/check_operator_workspace.py diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index b9cd57e6d..32827b2df 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -170,6 +170,10 @@ jobs: fi echo 'S7 QML authority boundary: PASS · presentation submits intents through SmartSessionController and device mutators are not QML-invokable' + - name: Studio operator workspace layout and PTP authority contract + shell: bash + run: python3 apps/arstack_studio/tests/check_operator_workspace.py + - name: Native binary profile authority regression shell: bash run: | diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py new file mode 100644 index 000000000..6e4b9a604 --- /dev/null +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Fast structural gates for the native operator GUI (before the Qt build). + +These assert UI and state-ownership intent; headless QML launch/Windows +interactive review remain separate acceptance steps. +""" +from pathlib import Path + +STUDIO = Path(__file__).resolve().parents[1] +QML = STUDIO / "qml" + + +def source(name: str) -> str: + return (QML / name).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise SystemExit(f"Studio workspace contract failed: {message}") + + +def ordered(data: str, first: str, second: str, reason: str) -> None: + require(first in data and second in data and data.index(first) < data.index(second), reason) + + +def main() -> None: + main_qml = source("Main.qml") + preview = source("SignalPreview.qml") + tray = source("StudioTimingTray.qml") + expert = source("PtpExpertPanel.qml") + config = source("ConfigurationHub.qml") + matrix = source("SignalMatrix.qml") + menu = source("StudioActionMenu.qml") + cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + + ordered(main_qml, "id: voltageMatrix", "id: currentMatrix", + "voltage must precede current in the injection table") + ordered(preview, "id: voltagePhasorCanvas", "id: currentPhasorCanvas", + "voltage must precede current in the phasor view") + ordered(preview, "drawWaveGroup(preview.voltageModel, voltageY, false)", + "drawWaveGroup(preview.currentModel, currentY, true)", + "voltage waveform must be above current") + require("lane === 0 ? voltageY : currentY" in preview, + "waveform baselines must follow voltage/current lane order") + require("StudioTimingTray {" in main_qml and "id: frequencyMenu" in main_qml, + "main window must expose timing and frequency context controls") + require("function startPtpRole(role) { return timingPanel.startRole(role) }" in config + and "return profilePanel.startPtpRole(role)" in main_qml + and "requestStartPtpConfigured(role, panel.ptpProfile())" in expert, + "main PTP must reuse the existing verified Advanced profile") + require("checked = Qt.binding(function()" in tray + and "tray.session.requestStopPtp()" in tray + and "tray.session.requestSmpSynch(choice)" in tray, + "main PTP/smpSynch must restore device-owned state after user intent") + require("SIMULATED metadata" in tray and "sample phase needs independent verification" in tray, + "SV simulation must never masquerade as independently proven lock") + require(matrix.count("StudioActionMenu {") == 2 + and "id: magnitudeMenu" in matrix and "id: phaseMenu" in matrix + and "popupType: Popup.Item" in menu, + "QML-styled wide context menus must replace platform-native renderers") + for component in ("StudioActionMenu", "StudioMenuDivider", "StudioPtpSwitch", "StudioTimingTray"): + require(f"qml/{component}.qml" in cmake, + f"{component} must be registered in the Qt QML module") + print("Studio operator workspace contract: PASS") + + +if __name__ == "__main__": + main() From 3ec3e098fd3ce623bbf4c5c06c30356371c6a613 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 19:01:01 +0700 Subject: [PATCH 07/16] fix(studio): wire main timing tray through verified Advanced PTP controller --- apps/arstack_studio/qml/Main.qml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index b3e79a291..205a4249e 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -164,6 +164,15 @@ ApplicationWindow { configurationWindow.requestActivate() } + function openTimingSettings() { + profilePanel.showTiming() + openConfiguration() + } + + function startPtpRole(role) { + return profilePanel.startPtpRole(role) + } + function openDiagnostics() { diagnosticsDialog.open() } From da9cc493ec9aa5bee937f8904307a1fa3a60e7c4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 19:06:08 +0700 Subject: [PATCH 08/16] fix(studio): remove MenuItem.menu scope shadow and fail smoke on QML runtime errors --- .github/workflows/arstack-studio-qt.yml | 14 +++++++++++++ apps/arstack_studio/qml/StudioActionMenu.qml | 22 ++++++++++---------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 32827b2df..2c2ec74b5 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -312,6 +312,13 @@ jobs: status=$? set -e cat /tmp/arstack-studio-launch.log + # A process that stays alive can still have broken QML bindings. + # Headless Linux lacks Qt SVG decode support (non-fatal presentation + # warnings), but JavaScript/QML binding failures are release blockers. + if grep -Eq 'TypeError:|ReferenceError:|Cannot assign to non-existent property|is not a type' /tmp/arstack-studio-launch.log; then + echo 'ARStack Studio has QML runtime binding/type failures.' + exit 1 + fi if [ "$status" -ne 124 ]; then echo "ARStack Studio exited before smoke timeout (status=$status)." exit "$status" @@ -326,6 +333,13 @@ jobs: status=$? set -e cat /tmp/arstack-studio-hidpi.log + # A process that stays alive can still have broken QML bindings. + # Headless Linux lacks Qt SVG decode support (non-fatal presentation + # warnings), but JavaScript/QML binding failures are release blockers. + if grep -Eq 'TypeError:|ReferenceError:|Cannot assign to non-existent property|is not a type' /tmp/arstack-studio-hidpi.log; then + echo 'ARStack Studio has QML runtime binding/type failures.' + exit 1 + fi if [ "$status" -ne 124 ]; then echo "ARStack Studio high-DPI launch exited early (status=$status)." exit "$status" diff --git a/apps/arstack_studio/qml/StudioActionMenu.qml b/apps/arstack_studio/qml/StudioActionMenu.qml index 16188edf7..4e8983371 100644 --- a/apps/arstack_studio/qml/StudioActionMenu.qml +++ b/apps/arstack_studio/qml/StudioActionMenu.qml @@ -4,7 +4,7 @@ import QtQuick import QtQuick.Controls Menu { - id: menu + id: actionMenu property var theme property string uiFont: "Inter" // Qt 6.8+ can prefer native Windows menus, which ignore custom QML @@ -21,32 +21,32 @@ Menu { // Use model Actions so menu handles activation, Escape and keyboard focus. delegate: MenuItem { id: actionItem - width: menu.width - menu.leftPadding - menu.rightPadding - implicitHeight: menu.theme.menuItemHeight + width: actionMenu.width - actionMenu.leftPadding - actionMenu.rightPadding + implicitHeight: actionMenu.theme.menuItemHeight leftPadding: 12 rightPadding: 12 - font.family: menu.uiFont + font.family: actionMenu.uiFont font.pixelSize: 11 contentItem: Text { text: actionItem.text font: actionItem.font - color: actionItem.enabled ? menu.theme.text : menu.theme.muted + color: actionItem.enabled ? actionMenu.theme.text : actionMenu.theme.muted verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } background: Rectangle { - radius: menu.theme.controlRadius - color: actionItem.highlighted ? menu.theme.menuHover : "transparent" + radius: actionMenu.theme.controlRadius + color: actionItem.highlighted ? actionMenu.theme.menuHover : "transparent" border.width: actionItem.activeFocus ? 1 : 0 - border.color: menu.theme.accent + border.color: actionMenu.theme.accent } } // Render the same separators on Windows rather than native light lines. // All commands are Qt Actions so disabled/keyboard behavior remains native. background: Rectangle { - color: menu.theme.menuSurface - radius: menu.theme.menuRadius + color: actionMenu.theme.menuSurface + radius: actionMenu.theme.menuRadius border.width: 1 - border.color: menu.theme.line + border.color: actionMenu.theme.line } } From 0194d41b6837bcb13cfd5cfc42c9cbd881360a64 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:10:50 +0700 Subject: [PATCH 09/16] test(studio): quantify Windows viewport safety and capture desktop GUI evidence --- .../arstack-studio-windows-lifecycle.yml | 32 +++++ apps/arstack_studio/qml/Main.qml | 10 +- apps/arstack_studio/qml/StudioTimingTray.qml | 4 + apps/arstack_studio/src/main.cpp | 134 ++++++++++++++++++ .../tests/check_operator_workspace.py | 4 + 5 files changed, 183 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 6f080900e..d7debfe95 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -85,3 +85,35 @@ jobs: Start-Sleep -Milliseconds 250 Invoke-LifecycleCycle 2 Write-Host 'Windows lifecycle gate: PASS · normal close -> process exit -> lock release -> immediate reopen -> clean exit' + + - name: Studio UI geometry acceptance and Windows screenshots + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + # Exercise real Windows Qt platform rendering, not offscreen lifecycle. + Remove-Item Env:QT_QPA_PLATFORM -ErrorAction SilentlyContinue + Remove-Item Env:QT_QUICK_BACKEND -ErrorAction SilentlyContinue + $candidate = Get-ChildItem -Path (Join-Path $PWD 'build-arstack-studio') -Recurse -File | + Where-Object { $_.Name -ieq 'arstack_studio.exe' -or $_.Name -ieq 'ARStackStudio.exe' } | + Select-Object -First 1 + if ($null -eq $candidate) { throw 'Studio GUI executable not found' } + $env:ARSTACK_STUDIO_QA_DIR = Join-Path $PWD 'studio-ui-evidence' + New-Item -ItemType Directory -Path $env:ARSTACK_STUDIO_QA_DIR -Force | Out-Null + $exe = $candidate.FullName + $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru + if ($probe.ExitCode -ne 0) { throw "Studio UI geometry and screenshot check failed: $($probe.ExitCode)" } + foreach ($dimensions in @('1240x760', '960x620', '1440x900')) { + $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR "studio-$dimensions.png" + if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { + throw "Missing/empty Windows screenshot at $dimensions" + } + } + Write-Host 'Native Windows GUI screenshot evidence: PASS' + - name: Upload desktop layout evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-windows-layout-evidence + path: studio-ui-evidence/ + if-no-files-found: ignore + retention-days: 7 diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 205a4249e..62d4399d0 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -672,6 +672,7 @@ ApplicationWindow { WorkflowBar { id: workflowBar + objectName: "studioWorkflowBar" Layout.fillWidth: true Layout.preferredHeight: 54 Layout.minimumHeight: 54 @@ -722,6 +723,7 @@ ApplicationWindow { } SurfacePanel { + objectName: "studioInjectionSurface" SplitView.fillWidth: true SplitView.fillHeight: true SplitView.minimumWidth: root.compactLayout ? 500 : 550 @@ -751,6 +753,7 @@ ApplicationWindow { StudioActionMenu { id: frequencyMenu + objectName: "studioFrequencyMenu" theme: studioTheme uiFont: root.uiFont x: 80 @@ -843,6 +846,7 @@ ApplicationWindow { spacing: 9 SignalMatrix { id: voltageMatrix + objectName: "studioVoltageMatrix" Layout.fillWidth: true Layout.fillHeight: true theme: studioTheme @@ -857,8 +861,10 @@ ApplicationWindow { symbolText: "U" unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" compact: root.compactLayout - } SignalMatrix { + } + SignalMatrix { id: currentMatrix + objectName: "studioCurrentMatrix" Layout.fillWidth: true Layout.fillHeight: true theme: studioTheme @@ -878,6 +884,7 @@ ApplicationWindow { } StudioTimingTray { + objectName: "studioTimingTray" Layout.fillWidth: true Layout.preferredHeight: root.height < 700 ? 112 : 132 Layout.minimumHeight: root.height < 700 ? 112 : 132 @@ -891,6 +898,7 @@ ApplicationWindow { } Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } RowLayout { + objectName: "studioQualityRow" Layout.fillWidth: true spacing: 8 Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index c35aaffbb..51bd2e0ea 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -76,6 +76,7 @@ Rectangle { spacing: tray.compact ? 7 : 10 StudioPtpSwitch { id: powerSwitch + objectName: "studioPtpSwitch" theme: tray.theme uiFont: tray.uiFont checked: Boolean(tray.device && tray.device.ptpRunning) @@ -96,6 +97,7 @@ Rectangle { } StudioDarkComboBox { id: roleCombo + objectName: "studioPtpRole" theme: tray.theme uiFont: tray.uiFont Layout.fillWidth: true @@ -116,6 +118,7 @@ Rectangle { } StudioDarkComboBox { id: syncCombo + objectName: "studioSmpSynchMode" theme: tray.theme uiFont: tray.uiFont Layout.fillWidth: true @@ -139,6 +142,7 @@ Rectangle { theme: tray.theme uiFont: tray.uiFont text: "Details" + objectName: "studioTimingDetails" implicitWidth: 66 toolTipText: "PTP settings, timing evidence and diagnostics" onClicked: if (tray.controller) tray.controller.openTimingSettings() diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index b74c177f0..8fe4e32cf 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -20,12 +20,17 @@ #include #include #include +#include #include +#include +#include #include #include #include #include +#include +#include #include #ifndef ARSTACK_STUDIO_VERSION @@ -40,6 +45,73 @@ bool hasArgument(const int argc, char* argv[], const std::string_view wanted) { return false; } + +struct UiBounds { + QRectF area; + bool visible{false}; +}; + +[[nodiscard]] UiBounds findUiBounds(QQuickWindow* window, const char* name) { + auto* item = window->findChild(QString::fromLatin1(name)); + if (item == nullptr || item->width() < 1 || item->height() < 1) return {}; + return {QRectF{item->mapToScene(QPointF{0, 0}), QSizeF{item->width(), item->height()}}, + item->isVisible()}; +} + +[[nodiscard]] bool checkOperatorViewport(QQuickWindow* window, const QSize size) { + constexpr qreal tolerance = 2.0; + const QRectF viewport{QPointF{0, 0}, QSizeF{size}}; + const auto bar = findUiBounds(window, "studioWorkflowBar"); + const auto surface = findUiBounds(window, "studioInjectionSurface"); + const auto voltage = findUiBounds(window, "studioVoltageMatrix"); + const auto current = findUiBounds(window, "studioCurrentMatrix"); + const auto tray = findUiBounds(window, "studioTimingTray"); + const auto quality = findUiBounds(window, "studioQualityRow"); + const std::array, 6> areas{{ + {"toolbar", bar}, {"injection workspace", surface}, + {"voltage matrix", voltage}, {"current matrix", current}, + {"PTP timing tray", tray}, {"quality editor", quality} + }}; + for (const auto& [label, item] : areas) { + if (!item.visible || item.area.width() < 25 || item.area.height() < 15 || + !viewport.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(item.area.adjusted(tolerance, tolerance, + -tolerance, -tolerance))) { + qCritical().noquote() << "Studio UI viewport violation" << size << label + << "bounds=" << item.area << "viewport=" << viewport; + return false; + } + } + if (!(voltage.area.right() <= current.area.left() + tolerance + && tray.area.top() >= voltage.area.bottom() - tolerance + && quality.area.top() >= tray.area.bottom() - tolerance + && quality.area.bottom() <= surface.area.bottom() + tolerance)) { + qCritical().noquote() << "Studio UI panel order/overlap violation" << size + << voltage.area << current.area << tray.area << quality.area; + return false; + } + const std::array controls{{ + "studioPtpSwitch", "studioPtpRole", "studioSmpSynchMode", "studioTimingDetails" + }}; + qreal previousRight = tray.area.left(); + for (const char* name : controls) { + const auto control = findUiBounds(window, name); + if (!control.visible || control.area.width() < 35 || + control.area.left() + tolerance < previousRight || + !tray.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(control.area.adjusted(tolerance, tolerance, + -tolerance, -tolerance))) { + qCritical().noquote() << "Studio timing controls clipped or overlapped" + << size << name << control.area << tray.area; + return false; + } + previousRight = control.area.right(); + } + qInfo().noquote() << "Studio viewport geometry: PASS" << size + << "toolbar=" << bar.area << "PTP=" << tray.area; + return true; +} + class PrimaryWindowCloseFilter final : public QObject { public: explicit PrimaryWindowCloseFilter(QCoreApplication* app) @@ -436,6 +508,7 @@ int main(int argc, char* argv[]) { } const bool lifecycleCheck = hasArgument(argc, argv, "--check-app-lifecycle"); + const bool operatorUiCheck = hasArgument(argc, argv, "--check-operator-ui"); QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(true); @@ -521,6 +594,67 @@ int main(int argc, char* argv[]) { }, Qt::DirectConnection); + + if (operatorUiCheck) { + if (mainWindow == nullptr) { + qCritical().noquote() << "Studio UI geometry test: no main window"; + return 19; + } + // Exercise desktop, supported minimum, and extended desktop layout on + // the real rendering engine. This branch never touches a serial port. + constexpr std::array sizes{{QSize{1240, 760}, QSize{960, 620}, + QSize{1440, 900}}}; + const QString screenshots = qEnvironmentVariable("ARSTACK_STUDIO_QA_DIR"); + if (!screenshots.isEmpty() && !QDir{}.mkpath(screenshots)) { + qCritical().noquote() << "Cannot create GUI evidence directory" << screenshots; + return 20; + } + auto step = std::make_shared(0); + auto* uiTimer = new QTimer(&app); + uiTimer->setInterval(400); + mainWindow->resize(sizes.front()); + QObject::connect(uiTimer, &QTimer::timeout, &app, + [&, step, uiTimer] { + const QSize target = sizes.at(static_cast(*step)); + if (mainWindow->size() != target) { + // Window managers may apply a deferred resize. + mainWindow->resize(target); + return; + } + if (!checkOperatorViewport(mainWindow, target)) { + uiTimer->stop(); + QCoreApplication::exit(21); + return; + } + if (!screenshots.isEmpty()) { + const QString path = QDir{screenshots}.filePath( + QStringLiteral("studio-%1x%2.png").arg(target.width()).arg(target.height())); + const QImage frame = mainWindow->grabWindow(); + if (frame.isNull() || !frame.save(path)) { + qCritical().noquote() << "GUI evidence capture failed:" << path; + uiTimer->stop(); + QCoreApplication::exit(22); + return; + } + } + ++*step; + if (*step == static_cast(sizes.size())) { + uiTimer->stop(); + qInfo().noquote() << "Studio operator layout acceptance: PASS"; + QCoreApplication::exit(0); + return; + } + mainWindow->resize(sizes.at(static_cast(*step))); + }); + uiTimer->start(); + QTimer::singleShot(15000, &app, [&app, uiTimer] { + if (uiTimer->isActive()) { + qCritical().noquote() << "Studio UI geometry test timed out"; + QCoreApplication::exit(23); + } + }); + } + if (lifecycleCheck) { if (mainWindow == nullptr) { qCritical().noquote() << "Application lifecycle regression: main window was not created."; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 6e4b9a604..0546ca6be 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -62,6 +62,10 @@ def main() -> None: for component in ("StudioActionMenu", "StudioMenuDivider", "StudioPtpSwitch", "StudioTimingTray"): require(f"qml/{component}.qml" in cmake, f"{component} must be registered in the Qt QML module") + + require("--check-operator-ui" in (STUDIO / "src/main.cpp").read_text(encoding="utf-8") + and "studio-ui-evidence" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), + "Windows geometry/evidence gate must run from existing lifecycle build") print("Studio operator workspace contract: PASS") From 114c01add398ae7b0db584b8b96511f0ba85860e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:19:17 +0700 Subject: [PATCH 10/16] test(studio): run deterministic Windows-built Qt viewport QA at 100% and 150% DPI --- .../arstack-studio-windows-lifecycle.yml | 38 +++++++++++++------ apps/arstack_studio/src/main.cpp | 15 +++++++- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index d7debfe95..c0e6f09cb 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -90,25 +90,39 @@ jobs: shell: powershell run: | $ErrorActionPreference = 'Stop' - # Exercise real Windows Qt platform rendering, not offscreen lifecycle. - Remove-Item Env:QT_QPA_PLATFORM -ErrorAction SilentlyContinue - Remove-Item Env:QT_QUICK_BACKEND -ErrorAction SilentlyContinue + # The GitHub runner's virtual desktop clamps physical window sizes. + # Exercise *exact* logical viewports using Windows-built Qt Quick + # offscreen software rendering. These PNGs are layout evidence, + # NOT an assertion of real on-monitor Windows color/hover fidelity. + $env:QT_QPA_PLATFORM = 'offscreen' + $env:QT_QUICK_BACKEND = 'software' $candidate = Get-ChildItem -Path (Join-Path $PWD 'build-arstack-studio') -Recurse -File | Where-Object { $_.Name -ieq 'arstack_studio.exe' -or $_.Name -ieq 'ARStackStudio.exe' } | Select-Object -First 1 if ($null -eq $candidate) { throw 'Studio GUI executable not found' } - $env:ARSTACK_STUDIO_QA_DIR = Join-Path $PWD 'studio-ui-evidence' - New-Item -ItemType Directory -Path $env:ARSTACK_STUDIO_QA_DIR -Force | Out-Null $exe = $candidate.FullName - $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru - if ($probe.ExitCode -ne 0) { throw "Studio UI geometry and screenshot check failed: $($probe.ExitCode)" } - foreach ($dimensions in @('1240x760', '960x620', '1440x900')) { - $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR "studio-$dimensions.png" - if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { - throw "Missing/empty Windows screenshot at $dimensions" + foreach ($scale in @('1', '1.5')) { + $env:QT_SCALE_FACTOR = $scale + $suffix = if ($scale -eq '1') { 'dpi-100' } else { 'dpi-150' } + $env:ARSTACK_STUDIO_QA_DIR = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix + New-Item -ItemType Directory -Path $env:ARSTACK_STUDIO_QA_DIR -Force | Out-Null + $stdout = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'ui-test-stdout.txt' + $stderr = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'ui-test-stderr.txt' + $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + if ($probe.ExitCode -ne 0) { + throw "Qt Quick viewport geometry or screenshot failed at DPI=$scale, exit=$($probe.ExitCode); see logs in evidence." + } + foreach ($dimensions in @('1240x760', '960x620', '1440x900')) { + $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR "studio-$dimensions.png" + if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { + throw "Missing/empty Windows-built Qt screenshot DPI=$scale at $dimensions" + } } } - Write-Host 'Native Windows GUI screenshot evidence: PASS' + Write-Host 'Windows-built Qt Quick deterministic viewport and High-DPI evidence: PASS' - name: Upload desktop layout evidence if: always() uses: actions/upload-artifact@v4 diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 8fe4e32cf..8e755509c 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -610,17 +610,28 @@ int main(int argc, char* argv[]) { return 20; } auto step = std::make_shared(0); + auto resizeRetries = std::make_shared(0); auto* uiTimer = new QTimer(&app); uiTimer->setInterval(400); mainWindow->resize(sizes.front()); QObject::connect(uiTimer, &QTimer::timeout, &app, - [&, step, uiTimer] { + [&, step, resizeRetries, uiTimer] { const QSize target = sizes.at(static_cast(*step)); if (mainWindow->size() != target) { - // Window managers may apply a deferred resize. + // CI's virtual Windows desktop can clamp large native + // windows. Diagnose that instead of spinning 15 seconds. + if (++*resizeRetries > 5) { + qCritical().noquote() << "Studio UI cannot reach target viewport:" + << "requested=" << target << "actual=" << mainWindow->size() + << "platform=" << QGuiApplication::platformName(); + uiTimer->stop(); + QCoreApplication::exit(24); + return; + } mainWindow->resize(target); return; } + *resizeRetries = 0; if (!checkOperatorViewport(mainWindow, target)) { uiTimer->stop(); QCoreApplication::exit(21); From 901edc448463baa76865bae642b6bd2e8350b997 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:26:58 +0700 Subject: [PATCH 11/16] test(studio): separate exact offscreen viewport metrics from exposed native Windows screenshot --- .../arstack-studio-windows-lifecycle.yml | 42 +++++++++++++------ apps/arstack_studio/src/main.cpp | 17 ++++++-- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index c0e6f09cb..a886f1ed2 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -101,28 +101,46 @@ jobs: Select-Object -First 1 if ($null -eq $candidate) { throw 'Studio GUI executable not found' } $exe = $candidate.FullName + New-Item -ItemType Directory -Path 'studio-ui-evidence' -Force | Out-Null + Remove-Item Env:ARSTACK_STUDIO_QA_DIR -ErrorAction SilentlyContinue + Remove-Item Env:ARSTACK_STUDIO_QA_NATIVE_ONLY -ErrorAction SilentlyContinue foreach ($scale in @('1', '1.5')) { $env:QT_SCALE_FACTOR = $scale $suffix = if ($scale -eq '1') { 'dpi-100' } else { 'dpi-150' } - $env:ARSTACK_STUDIO_QA_DIR = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix - New-Item -ItemType Directory -Path $env:ARSTACK_STUDIO_QA_DIR -Force | Out-Null - $stdout = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'ui-test-stdout.txt' - $stderr = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'ui-test-stderr.txt' + $logdir = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix + New-Item -ItemType Directory -Path $logdir -Force | Out-Null + $stdout = Join-Path $logdir 'geometry-stdout.txt' + $stderr = Join-Path $logdir 'geometry-stderr.txt' $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru ` -RedirectStandardOutput $stdout -RedirectStandardError $stderr Get-Content $stdout -ErrorAction SilentlyContinue Get-Content $stderr -ErrorAction SilentlyContinue if ($probe.ExitCode -ne 0) { - throw "Qt Quick viewport geometry or screenshot failed at DPI=$scale, exit=$($probe.ExitCode); see logs in evidence." - } - foreach ($dimensions in @('1240x760', '960x620', '1440x900')) { - $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR "studio-$dimensions.png" - if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { - throw "Missing/empty Windows-built Qt screenshot DPI=$scale at $dimensions" - } + throw "Deterministic Qt Quick viewport geometry failed at DPI=$scale, exit=$($probe.ExitCode)" } } - Write-Host 'Windows-built Qt Quick deterministic viewport and High-DPI evidence: PASS' + # Offscreen grabWindow() is not a reliable image source. Capture + # one supported native Windows viewport separately at 100% DPI. + Remove-Item Env:QT_QPA_PLATFORM -ErrorAction SilentlyContinue + Remove-Item Env:QT_QUICK_BACKEND -ErrorAction SilentlyContinue + $env:QT_SCALE_FACTOR = '1' + $env:ARSTACK_STUDIO_QA_NATIVE_ONLY = '1' + $env:ARSTACK_STUDIO_QA_DIR = Join-Path $PWD 'studio-ui-evidence\native-windows' + New-Item -ItemType Directory -Path $env:ARSTACK_STUDIO_QA_DIR -Force | Out-Null + $stdout = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'native-stdout.txt' + $stderr = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'native-stderr.txt' + $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue + if ($probe.ExitCode -ne 0) { + throw "Native Windows screenshot acceptance failed, exit=$($probe.ExitCode)" + } + $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'studio-960x620.png' + if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { + throw 'Native Windows 960x620 screenshot was missing or empty' + } + Write-Host 'Windows Qt deterministic 3-viewports x 2-DPI geometry + native 960x620 screenshot: PASS' - name: Upload desktop layout evidence if: always() uses: actions/upload-artifact@v4 diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 8e755509c..e2040a7d9 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -604,6 +604,15 @@ int main(int argc, char* argv[]) { // the real rendering engine. This branch never touches a serial port. constexpr std::array sizes{{QSize{1240, 760}, QSize{960, 620}, QSize{1440, 900}}}; + // Virtual Qt platforms can resize to arbitrary logical viewports, + // but cannot support grabWindow. One native 960x620 pass captures + // actual Windows pixels separately without requesting an oversized + // desktop window. + const bool nativeCapture = !qEnvironmentVariableIsEmpty("ARSTACK_STUDIO_QA_NATIVE_ONLY"); + const int caseCount = nativeCapture ? 1 : static_cast(sizes.size()); + const auto caseSize = [&](const int step) { + return nativeCapture ? sizes[1] : sizes.at(static_cast(step)); + }; const QString screenshots = qEnvironmentVariable("ARSTACK_STUDIO_QA_DIR"); if (!screenshots.isEmpty() && !QDir{}.mkpath(screenshots)) { qCritical().noquote() << "Cannot create GUI evidence directory" << screenshots; @@ -613,10 +622,10 @@ int main(int argc, char* argv[]) { auto resizeRetries = std::make_shared(0); auto* uiTimer = new QTimer(&app); uiTimer->setInterval(400); - mainWindow->resize(sizes.front()); + mainWindow->resize(caseSize(0)); QObject::connect(uiTimer, &QTimer::timeout, &app, [&, step, resizeRetries, uiTimer] { - const QSize target = sizes.at(static_cast(*step)); + const QSize target = caseSize(*step); if (mainWindow->size() != target) { // CI's virtual Windows desktop can clamp large native // windows. Diagnose that instead of spinning 15 seconds. @@ -649,13 +658,13 @@ int main(int argc, char* argv[]) { } } ++*step; - if (*step == static_cast(sizes.size())) { + if (*step == caseCount) { uiTimer->stop(); qInfo().noquote() << "Studio operator layout acceptance: PASS"; QCoreApplication::exit(0); return; } - mainWindow->resize(sizes.at(static_cast(*step))); + mainWindow->resize(caseSize(*step)); }); uiTimer->start(); QTimer::singleShot(15000, &app, [&app, uiTimer] { From 41f4439206d830f4492fa2c1adf3f535bbfcdc2b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:27:28 +0700 Subject: [PATCH 12/16] fix(studio): keep deferred viewport QA lambda inputs alive across event loop --- apps/arstack_studio/src/main.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index e2040a7d9..13518af84 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -610,7 +610,7 @@ int main(int argc, char* argv[]) { // desktop window. const bool nativeCapture = !qEnvironmentVariableIsEmpty("ARSTACK_STUDIO_QA_NATIVE_ONLY"); const int caseCount = nativeCapture ? 1 : static_cast(sizes.size()); - const auto caseSize = [&](const int step) { + const auto caseSize = [sizes, nativeCapture](const int step) { return nativeCapture ? sizes[1] : sizes.at(static_cast(step)); }; const QString screenshots = qEnvironmentVariable("ARSTACK_STUDIO_QA_DIR"); @@ -624,7 +624,7 @@ int main(int argc, char* argv[]) { uiTimer->setInterval(400); mainWindow->resize(caseSize(0)); QObject::connect(uiTimer, &QTimer::timeout, &app, - [&, step, resizeRetries, uiTimer] { + [&, step, resizeRetries, uiTimer, caseSize, caseCount, screenshots] { const QSize target = caseSize(*step); if (mainWindow->size() != target) { // CI's virtual Windows desktop can clamp large native From 50e4f0488170a01e4a1439ffcaf137378d9c2121 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 05:45:02 +0700 Subject: [PATCH 13/16] test(studio): keyboard-accessible context menus and 175% DPI viewport gate --- .../arstack-studio-windows-lifecycle.yml | 6 +-- apps/arstack_studio/qml/SignalMatrix.qml | 38 +++++++++++-------- .../tests/check_operator_workspace.py | 5 +++ 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index a886f1ed2..7a6e86daa 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -104,9 +104,9 @@ jobs: New-Item -ItemType Directory -Path 'studio-ui-evidence' -Force | Out-Null Remove-Item Env:ARSTACK_STUDIO_QA_DIR -ErrorAction SilentlyContinue Remove-Item Env:ARSTACK_STUDIO_QA_NATIVE_ONLY -ErrorAction SilentlyContinue - foreach ($scale in @('1', '1.5')) { + foreach ($scale in @('1', '1.5', '1.75')) { $env:QT_SCALE_FACTOR = $scale - $suffix = if ($scale -eq '1') { 'dpi-100' } else { 'dpi-150' } + $suffix = 'dpi-' + [string][int]([double]::Parse($scale, [System.Globalization.CultureInfo]::InvariantCulture) * 100) $logdir = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix New-Item -ItemType Directory -Path $logdir -Force | Out-Null $stdout = Join-Path $logdir 'geometry-stdout.txt' @@ -140,7 +140,7 @@ jobs: if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { throw 'Native Windows 960x620 screenshot was missing or empty' } - Write-Host 'Windows Qt deterministic 3-viewports x 2-DPI geometry + native 960x620 screenshot: PASS' + Write-Host 'Windows Qt deterministic 3-viewports x 3-DPI geometry + native 960x620 screenshot: PASS' - name: Upload desktop layout evidence if: always() uses: actions/upload-artifact@v4 diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 2c8f18e85..c63a11018 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -124,6 +124,14 @@ Rectangle { property real pendingMagnitude: mag property real pendingPhase: angle + // The same placement/visibility semantics for mouse and keyboard. + // Clamp horizontally to the row rather than allowing a 250px + // popup to extend underneath the adjacent matrix. + function openFieldMenu(field, actionMenu) { + const at = signalRow.mapFromItem(field, 0, field.height) + actionMenu.popup(Math.max(6, Math.min(at.x, signalRow.width - actionMenu.width)), at.y) + } + // Action-backed menu uses a single globally styled delegate. // Rows/callbacks stay local so keyboard navigation and model // ownership remain unchanged. @@ -253,17 +261,12 @@ Rectangle { id: magnitudeField ToolTip.visible: hovered && !activeFocus ToolTip.delay: 650 - ToolTip.text: "Right-click: Equal Value (set phases A/B/C to this magnitude)" + ToolTip.text: "Right-click or Shift+F10: magnitude presets and Equal ABC" MouseArea { anchors.fill: parent z: 5 acceptedButtons: Qt.RightButton - onClicked: { - var at = signalRow.mapFromItem(magnitudeField, 0, magnitudeField.height) - magnitudeMenu.x = Math.max(6, Math.min(at.x, signalRow.width - magnitudeMenu.width)) - magnitudeMenu.y = at.y - magnitudeMenu.popup(magnitudeMenu.x, magnitudeMenu.y) - } + onClicked: signalRow.openFieldMenu(magnitudeField, magnitudeMenu) } Layout.fillWidth: true theme: matrix.theme @@ -302,7 +305,11 @@ Rectangle { } } Keys.onPressed: function(event) { - if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + if (event.key === Qt.Key_Menu || + (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { + signalRow.openFieldMenu(magnitudeField, magnitudeMenu) + event.accepted = true + } else if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 0, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { @@ -316,17 +323,12 @@ Rectangle { id: phaseField ToolTip.visible: hovered && !activeFocus ToolTip.delay: 650 - ToolTip.text: "Right-click: Equal Angle or Balance phases A/B/C by 120°" + ToolTip.text: "Right-click or Shift+F10: Equal/Balance ABC angles" MouseArea { anchors.fill: parent z: 5 acceptedButtons: Qt.RightButton - onClicked: { - var at = signalRow.mapFromItem(phaseField, 0, phaseField.height) - phaseMenu.x = Math.max(6, Math.min(at.x, signalRow.width - phaseMenu.width)) - phaseMenu.y = at.y - phaseMenu.popup(phaseMenu.x, phaseMenu.y) - } + onClicked: signalRow.openFieldMenu(phaseField, phaseMenu) } enabled: matrix.controller.signalFrequency > 0 Layout.preferredWidth: matrix.compact ? 84 : 100 @@ -366,7 +368,11 @@ Rectangle { } } Keys.onPressed: function(event) { - if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { + if (event.key === Qt.Key_Menu || + (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { + signalRow.openFieldMenu(phaseField, phaseMenu) + event.accepted = true + } else if ([Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right].indexOf(event.key) >= 0) { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 1, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 0546ca6be..e99b966ac 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -55,6 +55,11 @@ def main() -> None: "main PTP/smpSynch must restore device-owned state after user intent") require("SIMULATED metadata" in tray and "sample phase needs independent verification" in tray, "SV simulation must never masquerade as independently proven lock") + require(matrix.count("Qt.Key_Menu") == 2 and matrix.count("Qt.Key_F10") == 2 + and matrix.count("signalRow.openFieldMenu(") == 4, + "both magnitude and angle menus must support mouse and keyboard") + require("1.75" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), + "reuse Windows executable for 100%, 150% and 175% DPI acceptance") require(matrix.count("StudioActionMenu {") == 2 and "id: magnitudeMenu" in matrix and "id: phaseMenu" in matrix and "popupType: Popup.Item" in menu, From f1513fe402297cbe9aabea1250904fb4656ed991 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 06:13:42 +0700 Subject: [PATCH 14/16] feat(studio): offline-first SMV lab drafts, Material icon system and compact action menus --- apps/arstack_studio/CMakeLists.txt | 43 ++-- .../assets/MATERIAL_ICONS_LICENSE.txt | 206 ++++++++++++++++++ .../assets/material/balance.svg | 1 + apps/arstack_studio/assets/material/block.svg | 1 + .../assets/material/check_circle.svg | 1 + apps/arstack_studio/assets/material/close.svg | 1 + .../assets/material/content_copy.svg | 1 + .../assets/material/dashboard.svg | 1 + .../assets/material/device_hub.svg | 1 + .../assets/material/done_all.svg | 1 + .../assets/material/file_upload.svg | 1 + .../assets/material/folder_open.svg | 1 + .../assets/material/help_outline.svg | 1 + .../assets/material/keyboard_arrow_down.svg | 1 + .../assets/material/keyboard_arrow_up.svg | 1 + .../assets/material/monitor_heart.svg | 1 + .../assets/material/open_in_new.svg | 1 + .../assets/material/play_arrow.svg | 1 + .../assets/material/refresh.svg | 1 + .../assets/material/schedule.svg | 1 + .../arstack_studio/assets/material/search.svg | 1 + .../assets/material/settings.svg | 1 + .../assets/material/smv_injector.svg | 1 + apps/arstack_studio/assets/material/stop.svg | 1 + apps/arstack_studio/assets/material/sync.svg | 1 + apps/arstack_studio/assets/material/tune.svg | 1 + apps/arstack_studio/qml/DockFrame.qml | 4 +- apps/arstack_studio/qml/Main.qml | 176 +++++++++++++-- apps/arstack_studio/qml/ModernRibbon.qml | 44 ++-- apps/arstack_studio/qml/SignalMatrix.qml | 28 ++- apps/arstack_studio/qml/SignalPreview.qml | 24 ++ apps/arstack_studio/qml/StudioActionMenu.qml | 50 +++-- apps/arstack_studio/qml/StudioMenuDivider.qml | 4 +- apps/arstack_studio/qml/StudioTheme.qml | 2 +- apps/arstack_studio/qml/StudioTimingTray.qml | 64 ++++-- apps/arstack_studio/qml/TelemetryDock.qml | 4 +- apps/arstack_studio/qml/WorkflowBar.qml | 10 +- .../tests/check_operator_workspace.py | 20 ++ 38 files changed, 593 insertions(+), 110 deletions(-) create mode 100644 apps/arstack_studio/assets/MATERIAL_ICONS_LICENSE.txt create mode 100644 apps/arstack_studio/assets/material/balance.svg create mode 100644 apps/arstack_studio/assets/material/block.svg create mode 100644 apps/arstack_studio/assets/material/check_circle.svg create mode 100644 apps/arstack_studio/assets/material/close.svg create mode 100644 apps/arstack_studio/assets/material/content_copy.svg create mode 100644 apps/arstack_studio/assets/material/dashboard.svg create mode 100644 apps/arstack_studio/assets/material/device_hub.svg create mode 100644 apps/arstack_studio/assets/material/done_all.svg create mode 100644 apps/arstack_studio/assets/material/file_upload.svg create mode 100644 apps/arstack_studio/assets/material/folder_open.svg create mode 100644 apps/arstack_studio/assets/material/help_outline.svg create mode 100644 apps/arstack_studio/assets/material/keyboard_arrow_down.svg create mode 100644 apps/arstack_studio/assets/material/keyboard_arrow_up.svg create mode 100644 apps/arstack_studio/assets/material/monitor_heart.svg create mode 100644 apps/arstack_studio/assets/material/open_in_new.svg create mode 100644 apps/arstack_studio/assets/material/play_arrow.svg create mode 100644 apps/arstack_studio/assets/material/refresh.svg create mode 100644 apps/arstack_studio/assets/material/schedule.svg create mode 100644 apps/arstack_studio/assets/material/search.svg create mode 100644 apps/arstack_studio/assets/material/settings.svg create mode 100644 apps/arstack_studio/assets/material/smv_injector.svg create mode 100644 apps/arstack_studio/assets/material/stop.svg create mode 100644 apps/arstack_studio/assets/material/sync.svg create mode 100644 apps/arstack_studio/assets/material/tune.svg diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 52308f19e..83ca7bb7e 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -113,24 +113,31 @@ qt_add_qml_module(arstack_studio RESOURCES assets/InterVariable.ttf assets/INTER_LICENSE.txt - assets/LUCIDE_LICENSE.txt - assets/lucide/x.svg - assets/lucide/external-link.svg - assets/lucide/chevron-up.svg - assets/lucide/chevron-down.svg - assets/lucide/radio-tower.svg - assets/lucide/scale.svg - assets/lucide/circle-off.svg - assets/lucide/activity.svg - assets/lucide/circle-check.svg - assets/lucide/settings-2.svg - assets/lucide/upload.svg - assets/lucide/square.svg - assets/lucide/play.svg - assets/lucide/panels-top-left.svg - assets/lucide/folder-open.svg - assets/lucide/scan-search.svg - assets/lucide/clock-3.svg + assets/MATERIAL_ICONS_LICENSE.txt + assets/material/close.svg + assets/material/open_in_new.svg + assets/material/keyboard_arrow_up.svg + assets/material/keyboard_arrow_down.svg + assets/material/tune.svg + assets/material/block.svg + assets/material/monitor_heart.svg + assets/material/check_circle.svg + assets/material/settings.svg + assets/material/file_upload.svg + assets/material/stop.svg + assets/material/play_arrow.svg + assets/material/dashboard.svg + assets/material/folder_open.svg + assets/material/search.svg + assets/material/schedule.svg + assets/material/content_copy.svg + assets/material/sync.svg + assets/material/done_all.svg + assets/material/balance.svg + assets/material/refresh.svg + assets/material/device_hub.svg + assets/material/help_outline.svg + assets/material/smv_injector.svg ) qt_add_resources(arstack_studio "reference_templates" diff --git a/apps/arstack_studio/assets/MATERIAL_ICONS_LICENSE.txt b/apps/arstack_studio/assets/MATERIAL_ICONS_LICENSE.txt new file mode 100644 index 000000000..3a3cdacf3 --- /dev/null +++ b/apps/arstack_studio/assets/MATERIAL_ICONS_LICENSE.txt @@ -0,0 +1,206 @@ +Google Material Icons +Upstream: https://github.com/google/material-design-icons +License: Apache License 2.0. Paths are embedded as project SVG resources. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/balance.svg b/apps/arstack_studio/assets/material/balance.svg new file mode 100644 index 000000000..4c75e57d5 --- /dev/null +++ b/apps/arstack_studio/assets/material/balance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/block.svg b/apps/arstack_studio/assets/material/block.svg new file mode 100644 index 000000000..67398f975 --- /dev/null +++ b/apps/arstack_studio/assets/material/block.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/check_circle.svg b/apps/arstack_studio/assets/material/check_circle.svg new file mode 100644 index 000000000..00d29c79f --- /dev/null +++ b/apps/arstack_studio/assets/material/check_circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/close.svg b/apps/arstack_studio/assets/material/close.svg new file mode 100644 index 000000000..ddedbfb23 --- /dev/null +++ b/apps/arstack_studio/assets/material/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/content_copy.svg b/apps/arstack_studio/assets/material/content_copy.svg new file mode 100644 index 000000000..d63d6294e --- /dev/null +++ b/apps/arstack_studio/assets/material/content_copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/dashboard.svg b/apps/arstack_studio/assets/material/dashboard.svg new file mode 100644 index 000000000..da8e31e2b --- /dev/null +++ b/apps/arstack_studio/assets/material/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/device_hub.svg b/apps/arstack_studio/assets/material/device_hub.svg new file mode 100644 index 000000000..2be3ff75e --- /dev/null +++ b/apps/arstack_studio/assets/material/device_hub.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/done_all.svg b/apps/arstack_studio/assets/material/done_all.svg new file mode 100644 index 000000000..3cc0d238a --- /dev/null +++ b/apps/arstack_studio/assets/material/done_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/file_upload.svg b/apps/arstack_studio/assets/material/file_upload.svg new file mode 100644 index 000000000..f5074356c --- /dev/null +++ b/apps/arstack_studio/assets/material/file_upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/folder_open.svg b/apps/arstack_studio/assets/material/folder_open.svg new file mode 100644 index 000000000..0fcd40589 --- /dev/null +++ b/apps/arstack_studio/assets/material/folder_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/help_outline.svg b/apps/arstack_studio/assets/material/help_outline.svg new file mode 100644 index 000000000..763bbc3dd --- /dev/null +++ b/apps/arstack_studio/assets/material/help_outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/keyboard_arrow_down.svg b/apps/arstack_studio/assets/material/keyboard_arrow_down.svg new file mode 100644 index 000000000..435865d74 --- /dev/null +++ b/apps/arstack_studio/assets/material/keyboard_arrow_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/keyboard_arrow_up.svg b/apps/arstack_studio/assets/material/keyboard_arrow_up.svg new file mode 100644 index 000000000..cb874fc07 --- /dev/null +++ b/apps/arstack_studio/assets/material/keyboard_arrow_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/monitor_heart.svg b/apps/arstack_studio/assets/material/monitor_heart.svg new file mode 100644 index 000000000..ef138cd9c --- /dev/null +++ b/apps/arstack_studio/assets/material/monitor_heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/open_in_new.svg b/apps/arstack_studio/assets/material/open_in_new.svg new file mode 100644 index 000000000..fccef9cde --- /dev/null +++ b/apps/arstack_studio/assets/material/open_in_new.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/play_arrow.svg b/apps/arstack_studio/assets/material/play_arrow.svg new file mode 100644 index 000000000..49aa93429 --- /dev/null +++ b/apps/arstack_studio/assets/material/play_arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/refresh.svg b/apps/arstack_studio/assets/material/refresh.svg new file mode 100644 index 000000000..646978692 --- /dev/null +++ b/apps/arstack_studio/assets/material/refresh.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/schedule.svg b/apps/arstack_studio/assets/material/schedule.svg new file mode 100644 index 000000000..0980a7b48 --- /dev/null +++ b/apps/arstack_studio/assets/material/schedule.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/search.svg b/apps/arstack_studio/assets/material/search.svg new file mode 100644 index 000000000..f75f071ab --- /dev/null +++ b/apps/arstack_studio/assets/material/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/settings.svg b/apps/arstack_studio/assets/material/settings.svg new file mode 100644 index 000000000..4ae879efd --- /dev/null +++ b/apps/arstack_studio/assets/material/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/smv_injector.svg b/apps/arstack_studio/assets/material/smv_injector.svg new file mode 100644 index 000000000..4ae58d19d --- /dev/null +++ b/apps/arstack_studio/assets/material/smv_injector.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/stop.svg b/apps/arstack_studio/assets/material/stop.svg new file mode 100644 index 000000000..104f9a3fd --- /dev/null +++ b/apps/arstack_studio/assets/material/stop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/sync.svg b/apps/arstack_studio/assets/material/sync.svg new file mode 100644 index 000000000..67cd25010 --- /dev/null +++ b/apps/arstack_studio/assets/material/sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/tune.svg b/apps/arstack_studio/assets/material/tune.svg new file mode 100644 index 000000000..4598f18d9 --- /dev/null +++ b/apps/arstack_studio/assets/material/tune.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/qml/DockFrame.qml b/apps/arstack_studio/qml/DockFrame.qml index bc492d874..9fad0df28 100644 --- a/apps/arstack_studio/qml/DockFrame.qml +++ b/apps/arstack_studio/qml/DockFrame.qml @@ -70,7 +70,7 @@ Rectangle { DarkToolButton { visible: frame.detachable - iconSource: Qt.resolvedUrl("../assets/lucide/external-link.svg") + iconSource: Qt.resolvedUrl("../assets/material/open_in_new.svg") iconSize: 15 theme: frame.theme uiFont: frame.uiFont @@ -80,7 +80,7 @@ Rectangle { } DarkToolButton { visible: frame.closable - iconSource: Qt.resolvedUrl("../assets/lucide/x.svg") + iconSource: Qt.resolvedUrl("../assets/material/close.svg") iconSize: 16 theme: frame.theme uiFont: frame.uiFont diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 62d4399d0..01cddf3e4 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtCore import ARStack.Studio 1.0 ApplicationWindow { @@ -26,11 +27,89 @@ ApplicationWindow { palette.highlightedText: studioTheme.text StudioTheme { id: studioTheme } + Settings { + id: scenarioSettings + category: "offline-injection-lab" + property string lastScenario: "" + } + Timer { + id: saveScenarioTimer + interval: 400 + onTriggered: scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) + } + function scenarioSnapshot() { + function values(model) { + var rows = [] + for (var i = 0; i < 4; ++i) { + const r = model.get(i) + rows.push([r.magnitude, r.phase, r.enabled, Number(r.quality)]) + } + return rows + } + return { version: 1, hz: signalFrequency, current: values(currentModel), + voltage: values(voltageModel), linked: phaseLink } + } + function markScenarioDirty() { + if (restoringScenario) return + saveScenarioTimer.restart() + if (!workflowBar.session || !workflowBar.session.liveControlReady) + pendingApply = true + } + function restoreLocalScenario() { + const saved = scenarioSettings.lastScenario + if (!saved || saved.length > 8192) return + try { + const data = JSON.parse(saved) + if (data.version !== 1 || !validFrequency(data.hz) + || !Array.isArray(data.current) || !Array.isArray(data.voltage) + || data.current.length !== 4 || data.voltage.length !== 4) + throw new Error("Invalid scenario format") + function validate(rows, group) { + for (var i = 0; i < 4; ++i) { + const row = rows[i] + if (!Array.isArray(row) || row.length !== 4 + || !validMagnitude(group, row[0]) || !validPhase(row[1]) + || typeof row[2] !== "boolean" || !Number.isInteger(row[3]) + || row[3] < 0 || row[3] > 4294967295) + throw new Error("Invalid channel value") + } + } + validate(data.current, 0) + validate(data.voltage, 1) + restoringScenario = true + function assign(model, rows) { + for (var i = 0; i < 4; ++i) { + model.setProperty(i, "magnitude", rows[i][0]) + model.setProperty(i, "phase", rows[i][1]) + model.setProperty(i, "enabled", rows[i][2]) + model.setProperty(i, "quality", rows[i][3]) + } + } + assign(currentModel, data.current) + assign(voltageModel, data.voltage) + signalFrequency = data.hz + previousAcFrequency = data.hz > 0 ? data.hz : 50 + frequencyField.text = data.hz.toFixed(3) + phaseLink = data.linked === true + selectSignal(activeGroup, activeRow) + refreshPreview() + restoringScenario = false + pendingApply = true + } catch (e) { + restoringScenario = false + scenarioSettings.lastScenario = "" + showMessage("Invalid local experiment; safe 4I+4V defaults restored.", true) + } + } + Component.onCompleted: restoreLocalScenario() property string uiFont: interFont.status === FontLoader.Ready ? interFont.name : "Inter" property string monoFont: uiFont property bool phaseLink: false property bool profileDirty: false + property bool pendingApply: true + property bool restoringScenario: false + property bool offlinePreviewRunning: false property real currentScale: 1000.0 property real voltageScale: 100.0 property real signalFrequency: 50.0 @@ -95,6 +174,10 @@ ApplicationWindow { function onDeviceMessage(message) { root.showMessage(message, false) } + function onDeviceVerifiedChanged() { + root.pendingApply = true + root.offlinePreviewRunning = false + } } Timer { @@ -311,7 +394,8 @@ ApplicationWindow { } selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady) { + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) { if (phaseLink && row < 3) sendLinkedGroup(group) else sendSignal(group, row) } @@ -319,11 +403,27 @@ ApplicationWindow { } function applyAllSignals() { - if (!workflowBar.session || !workflowBar.session.liveControlReady) return - workflowBar.session.requestSetFrequency(signalFrequency) - for (var group = 0; group < 2; ++group) applyGroupSignals(group) - workflowBar.session.requestSetCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, - ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + if (!workflowBar.session || !workflowBar.session.liveControlReady) return false + var ok = workflowBar.session.requestSetFrequency(signalFrequency) + for (var group = 0; group < 2; ++group) + for (var row = 0; row < 4; ++row) ok = sendSignal(group, row) && ok + ok = workflowBar.session.requestSetCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, + ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) && ok + pendingApply = !ok + showMessage(ok ? "Local draft commands queued. Verify physical SV on wire; SETs are not atomic." + : "One or more draft commands could not be queued; retry after checking the board.", !ok) + return ok + } + function requestApplyDraft() { + if (!workflowBar.session || !workflowBar.session.liveControlReady) { + showMessage("Local draft saved; connect a verified ESP32-P4 to apply it.", false) + return false + } + if (workflowBar.injectionRunning) { + showMessage("Stop the publisher before applying an offline draft.", true) + return false + } + return applyAllSignals() } function setFrequencyValue(value) { @@ -333,7 +433,8 @@ ApplicationWindow { frequencyField.text = value.toFixed(3) frequencyField.invalidInput = false refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetFrequency(value) return true } @@ -357,7 +458,8 @@ ApplicationWindow { } ctSaturationEnabled = enabled refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent, ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) showMessage(enabled @@ -374,11 +476,12 @@ ApplicationWindow { if (!validMagnitude(group, value)) return for (var i = 0; i < 3; ++i) model.setProperty(i, "magnitude", value) selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) { showMessage("ABC update was not accepted; inspect device diagnostics.", true) return } - showMessage("ABC magnitude queued from " + model.get(row).signalId, false) + showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "ABC magnitude queued from " + model.get(row).signalId, false) } function equalGroupAngles(group, row) { @@ -388,11 +491,12 @@ ApplicationWindow { if (!validPhase(value)) return for (var i = 0; i < 3; ++i) model.setProperty(i, "phase", value) selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) { showMessage("ABC update was not accepted; inspect device diagnostics.", true) return } - showMessage("ABC angles queued from " + model.get(row).signalId, false) + showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "ABC angles queued from " + model.get(row).signalId, false) } function balanceGroupAngles(group, row) { @@ -405,11 +509,12 @@ ApplicationWindow { model.setProperty(1, "phase", normalizedAngle(base - 120)) model.setProperty(2, "phase", normalizedAngle(base + 120)) selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply && !sendLinkedGroup(group)) { showMessage("ABC update was not accepted; inspect device diagnostics.", true) return } - showMessage("Balanced ABC angles queued from " + model.get(row).signalId, false) + showMessage((pendingApply ? "Local experiment: " : "Device queue: ") + "Balanced ABC angles queued from " + model.get(row).signalId, false) } function balanced() { @@ -424,7 +529,8 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) applyAllSignals() + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) applyAllSignals() } function zeroAll() { @@ -434,7 +540,8 @@ ApplicationWindow { } selectSignal(activeGroup, activeRow) refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestZero() } @@ -442,7 +549,8 @@ ApplicationWindow { var unsignedValue = Number(value) >>> 0 groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue - if (workflowBar.session && workflowBar.session.liveControlReady) + markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetQuality(activeSignal, unsignedValue) } @@ -614,6 +722,7 @@ ApplicationWindow { activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + previewRunning: root.offlinePreviewRunning && !device.deviceVerified ctSaturationEnabled: root.ctSaturationEnabled ctDcOffsetPercent: root.ctDcOffsetPercent ctHarmonicPercent: root.ctHarmonicPercent @@ -656,6 +765,7 @@ ApplicationWindow { activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + previewRunning: root.offlinePreviewRunning && !device.deviceVerified ctSaturationEnabled: root.ctSaturationEnabled ctDcOffsetPercent: root.ctDcOffsetPercent ctHarmonicPercent: root.ctHarmonicPercent @@ -742,6 +852,17 @@ ApplicationWindow { Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } + CalmButton { + theme: studioTheme + uiFont: root.uiFont + iconSource: Qt.resolvedUrl("../assets/material/file_upload.svg") + text: !device.deviceVerified ? "Local draft" + : root.pendingApply ? "Apply to board" : "SET queued" + implicitWidth: 106 + tone: device.deviceVerified && root.pendingApply ? "accent" : "normal" + toolTipText: "Local setpoints are separate from physical output. Board last values are not read back." + onClicked: root.requestApplyDraft() + } } Rectangle { @@ -758,13 +879,14 @@ ApplicationWindow { uiFont: root.uiFont x: 80 y: 45 - Action { text: "FREQUENCY · SHARED"; enabled: false } - Action { text: "DC mode · 0 Hz"; onTriggered: root.setWaveformMode("DC") } - Action { text: "50 Hz · nominal lab preset"; onTriggered: root.setFrequencyValue(50) } - Action { text: "60 Hz · lab preset"; onTriggered: root.setFrequencyValue(60) } + title: "Frequency · shared across all channels" + Action { text: "DC · 0 Hz"; icon.source: Qt.resolvedUrl("../assets/material/block.svg"); onTriggered: root.setWaveformMode("DC") } + Action { text: "50 Hz · nominal"; icon.source: Qt.resolvedUrl("../assets/material/schedule.svg"); onTriggered: root.setFrequencyValue(50) } + Action { text: "60 Hz · nominal"; icon.source: Qt.resolvedUrl("../assets/material/schedule.svg"); onTriggered: root.setFrequencyValue(60) } StudioMenuDivider { theme: studioTheme } Action { - text: "Return to last AC frequency" + text: "Restore previous AC" + icon.source: Qt.resolvedUrl("../assets/material/refresh.svg") enabled: root.previousAcFrequency > 0 onTriggered: root.setWaveformMode("AC") } @@ -808,7 +930,8 @@ ApplicationWindow { root.signalFrequency = value if (value > 0) root.previousAcFrequency = value root.refreshPreview() - if (workflowBar.session && workflowBar.session.liveControlReady) + root.markScenarioDirty() + if (workflowBar.session && workflowBar.session.liveControlReady && !root.pendingApply) workflowBar.session.requestSetFrequency(value) } else invalidInput = true } @@ -832,7 +955,10 @@ ApplicationWindow { enabled: root.signalFrequency > 0 checked: root.phaseLink text: "3-phase link" - onToggled: root.phaseLink = checked + onToggled: { + root.phaseLink = checked + root.markScenarioDirty() + } font.family: root.uiFont font.pixelSize: 9 } @@ -977,6 +1103,7 @@ ApplicationWindow { activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + previewRunning: root.offlinePreviewRunning && !device.deviceVerified ctSaturationEnabled: root.ctSaturationEnabled ctDcOffsetPercent: root.ctDcOffsetPercent ctHarmonicPercent: root.ctHarmonicPercent @@ -1015,6 +1142,7 @@ ApplicationWindow { activeMagnitude: root.activeMagnitude activePhase: root.activePhase signalFrequency: root.signalFrequency + previewRunning: root.offlinePreviewRunning && !device.deviceVerified ctSaturationEnabled: root.ctSaturationEnabled ctDcOffsetPercent: root.ctDcOffsetPercent ctHarmonicPercent: root.ctHarmonicPercent diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 31f809305..0ed98d7f5 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -27,6 +27,7 @@ Rectangle { } function conciseState() { + if (!device.deviceVerified && controller.offlinePreviewRunning) return "Offline preview" if (session.state === "RUNNING") return "Running" if (session.state === "READY") return "Ready" if (session.state === "WAITING FOR DEVICE") return "Offline" @@ -61,7 +62,7 @@ Rectangle { anchors.centerIn: parent width: 15 height: 15 - source: Qt.resolvedUrl("../assets/lucide/radio-tower.svg") + source: Qt.resolvedUrl("../assets/material/smv_injector.svg") sourceSize.width: 30 sourceSize.height: 30 } @@ -84,12 +85,21 @@ Rectangle { RibbonAction { theme: bar.theme uiFont: bar.uiFont - text: "Start" - tone: bar.session.startReady ? "success" : "neutral" - iconSource: Qt.resolvedUrl("../assets/lucide/play.svg") - enabled: !bar.workflow.injectionRunning && !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp - toolTipText: bar.workflow.startReason() - onClicked: bar.workflow.requestStart() + text: !bar.device.deviceVerified ? "Preview" : "Start" + tone: !bar.device.deviceVerified ? "accent" + : bar.session.startReady ? "success" : "neutral" + iconSource: Qt.resolvedUrl("../assets/material/play_arrow.svg") + enabled: !bar.workflow.injectionRunning && !bar.controller.offlinePreviewRunning + && !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp + toolTipText: !bar.device.deviceVerified + ? "Animate local setpoint preview only; no SV frames are transmitted." + : bar.workflow.startReason() + onClicked: { + if (!bar.device.deviceVerified) { + bar.controller.offlinePreviewRunning = true + bar.controller.showMessage("Offline waveform preview · no network SV output.", false) + } else bar.workflow.requestStart() + } } RibbonAction { @@ -97,10 +107,14 @@ Rectangle { uiFont: bar.uiFont text: "Stop" tone: bar.workflow.injectionRunning ? "danger" : "neutral" - iconSource: Qt.resolvedUrl("../assets/lucide/square.svg") - enabled: bar.workflow.injectionRunning && !bar.session.updatingFirmware - toolTipText: "Stop Sampled Values output" - onClicked: bar.workflow.requestStop() + iconSource: Qt.resolvedUrl("../assets/material/stop.svg") + enabled: (bar.workflow.injectionRunning || bar.controller.offlinePreviewRunning) + && !bar.session.updatingFirmware + toolTipText: bar.device.deviceVerified ? "Stop Sampled Values output" : "Stop local preview only" + onClicked: { + if (!bar.device.deviceVerified) bar.controller.offlinePreviewRunning = false + else bar.workflow.requestStop() + } } Rectangle { width: 1; height: 24; color: bar.theme.lineSoft } @@ -109,7 +123,7 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: bar.compact ? "SCL" : "Open SCL" - iconSource: Qt.resolvedUrl("../assets/lucide/folder-open.svg") + iconSource: Qt.resolvedUrl("../assets/material/folder_open.svg") enabled: bar.session.engineeringEditable toolTipText: "Open IEC 61850 engineering configuration" onClicked: bar.workflow.openEngineeringDialog() @@ -120,7 +134,7 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: "4I+4V" - iconSource: Qt.resolvedUrl("../assets/lucide/panels-top-left.svg") + iconSource: Qt.resolvedUrl("../assets/material/dashboard.svg") enabled: bar.session.engineeringEditable toolTipText: "Use the built-in 4I + 4V / 4000 samples/s profile" onClicked: bar.workflow.useBuiltInProfile() @@ -150,6 +164,7 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: "Phasor" + iconSource: Qt.resolvedUrl("../assets/material/tune.svg") checkable: true checked: bar.controller.phasorDockVisible || bar.controller.phasorDetached tone: checked ? "accent" : "neutral" @@ -164,6 +179,7 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: "Wave" + iconSource: Qt.resolvedUrl("../assets/material/monitor_heart.svg") visible: !bar.compact checkable: true checked: bar.controller.waveformDockVisible || bar.controller.waveformDetached @@ -180,7 +196,7 @@ Rectangle { uiFont: bar.uiFont text: bar.compact ? "" : "Advanced" iconOnly: bar.compact - iconSource: Qt.resolvedUrl("../assets/lucide/settings-2.svg") + iconSource: Qt.resolvedUrl("../assets/material/settings.svg") tone: "neutral" toolTipText: bar.session.firmwareUpdateAvailable ? "Advanced controls · optional firmware update available" diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index c63a11018..8b49bd621 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -139,24 +139,28 @@ Rectangle { id: magnitudeMenu theme: matrix.theme uiFont: matrix.uiFont - Action { text: "MAGNITUDE · " + signalRow.sid; enabled: false } + title: "Magnitude · " + signalRow.sid Action { - text: "Zero magnitude" + text: "Zero · 0" + icon.source: Qt.resolvedUrl("../assets/material/block.svg") onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", 0) } Action { - text: "Demo level · 50%" + text: "50% nominal (demo)" + icon.source: Qt.resolvedUrl("../assets/material/tune.svg") onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", matrix.groupIndex === 0 ? 0.5 : 28.87) } Action { - text: "Demo level · 100%" + text: "100% nominal (demo)" + icon.source: Qt.resolvedUrl("../assets/material/check_circle.svg") onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", matrix.groupIndex === 0 ? 1.0 : 57.74) } StudioMenuDivider { theme: matrix.theme } Action { - text: "Equal ABC magnitudes" + text: "Equal magnitudes A/B/C" + icon.source: Qt.resolvedUrl("../assets/material/done_all.svg") enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.equalGroupMagnitudes(matrix.groupIndex, signalRow.rowIndex) } @@ -165,20 +169,23 @@ Rectangle { id: phaseMenu theme: matrix.theme uiFont: matrix.uiFont - Action { text: "ANGLE · " + signalRow.sid; enabled: false } + title: "Angle · " + signalRow.sid Action { - text: "Zero angle · 0°" + text: "Zero angle · 0°" + icon.source: Qt.resolvedUrl("../assets/material/block.svg") enabled: matrix.controller.signalFrequency > 0 onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", 0) } StudioMenuDivider { theme: matrix.theme } Action { - text: "Equal ABC angles" + text: "Equal angles A/B/C" + icon.source: Qt.resolvedUrl("../assets/material/done_all.svg") enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.equalGroupAngles(matrix.groupIndex, signalRow.rowIndex) } Action { - text: "Balance ABC · 120°" + text: "Balance angles · 120°" + icon.source: Qt.resolvedUrl("../assets/material/balance.svg") enabled: signalRow.rowIndex < 3 onTriggered: matrix.controller.balanceGroupAngles(matrix.groupIndex, signalRow.rowIndex) } @@ -237,7 +244,8 @@ Rectangle { matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) matrix.controller.refreshPreview() - if (matrix.session && matrix.session.liveControlReady) + matrix.controller.markScenarioDirty() + if (matrix.session && matrix.session.liveControlReady && !matrix.controller.pendingApply) matrix.session.requestSetEnabled(signalRow.sid, checked) } } diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml index 41e217bd1..342998400 100644 --- a/apps/arstack_studio/qml/SignalPreview.qml +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -22,6 +22,8 @@ SurfacePanel { property real ctClipPercent: 60 property bool showHeader: true property string viewMode: "both" // both, phasor, waveform + property bool previewRunning: false + property real previewCursorMs: 0 color: preview.showHeader ? preview.theme.surface : "transparent" border.width: preview.showHeader ? 1 : 0 @@ -164,6 +166,17 @@ SurfacePanel { } } + // Only an animated local cursor: never mistaken for live SV capture. + Timer { + interval: 80 + repeat: true + running: preview.previewRunning && preview.viewMode !== "phasor" + onTriggered: { + preview.previewCursorMs = (preview.previewCursorMs + 2) % 40 + waveformCanvas.requestPaint() + } + } + function paintWaveform(canvas) { var ctx = canvas.getContext("2d") ctx.reset() @@ -284,6 +297,17 @@ SurfacePanel { drawWaveGroup(preview.voltageModel, voltageY, false) drawWaveGroup(preview.currentModel, currentY, true) + if (preview.previewRunning) { + const cursorX = left + (preview.previewCursorMs / 40) * plotWidth + ctx.strokeStyle = preview.theme ? preview.theme.accent : "#67a5ee" + ctx.lineWidth = 1.1 + ctx.globalAlpha = 0.78 + ctx.beginPath() + ctx.moveTo(cursorX, top) + ctx.lineTo(cursorX, top + plotHeight) + ctx.stroke() + ctx.globalAlpha = 1 + } } ColumnLayout { diff --git a/apps/arstack_studio/qml/StudioActionMenu.qml b/apps/arstack_studio/qml/StudioActionMenu.qml index 4e8983371..62c4dc0df 100644 --- a/apps/arstack_studio/qml/StudioActionMenu.qml +++ b/apps/arstack_studio/qml/StudioActionMenu.qml @@ -1,38 +1,50 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// Shared action-based Qt Quick desktop menu; no duplicated native popup styling. +// Compact Material-action popup shared by magnitude, angle and frequency. import QtQuick import QtQuick.Controls +import QtQuick.Layouts Menu { id: actionMenu property var theme property string uiFont: "Inter" - // Qt 6.8+ can prefer native Windows menus, which ignore custom QML - // delegates and render in OS colors. Force our portable styled overlay. popupType: Popup.Item - implicitWidth: 250 - topPadding: 7 - bottomPadding: 7 - leftPadding: 6 - rightPadding: 6 - spacing: 2 + implicitWidth: 232 + topPadding: 5 + bottomPadding: 5 + leftPadding: 5 + rightPadding: 5 + spacing: 0 font.family: uiFont font.pixelSize: 11 - // Use model Actions so menu handles activation, Escape and keyboard focus. delegate: MenuItem { id: actionItem width: actionMenu.width - actionMenu.leftPadding - actionMenu.rightPadding implicitHeight: actionMenu.theme.menuItemHeight - leftPadding: 12 - rightPadding: 12 + leftPadding: 9 + rightPadding: 10 font.family: actionMenu.uiFont font.pixelSize: 11 - contentItem: Text { - text: actionItem.text - font: actionItem.font - color: actionItem.enabled ? actionMenu.theme.text : actionMenu.theme.muted - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight + contentItem: RowLayout { + spacing: 9 + Image { + visible: actionItem.icon.source.toString().length > 0 + source: actionItem.icon.source + sourceSize.width: 28 + sourceSize.height: 28 + Layout.preferredWidth: visible ? 17 : 0 + Layout.preferredHeight: 17 + opacity: actionItem.enabled ? 0.95 : 0.35 + } + Text { + text: actionItem.text + font: actionItem.font + color: !actionItem.enabled ? actionMenu.theme.muted2 + : actionItem.highlighted ? actionMenu.theme.text : actionMenu.theme.textSoft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + Layout.fillWidth: true + } } background: Rectangle { radius: actionMenu.theme.controlRadius @@ -41,8 +53,6 @@ Menu { border.color: actionMenu.theme.accent } } - // Render the same separators on Windows rather than native light lines. - // All commands are Qt Actions so disabled/keyboard behavior remains native. background: Rectangle { color: actionMenu.theme.menuSurface radius: actionMenu.theme.menuRadius diff --git a/apps/arstack_studio/qml/StudioMenuDivider.qml b/apps/arstack_studio/qml/StudioMenuDivider.qml index 05cabb37a..c673ae18c 100644 --- a/apps/arstack_studio/qml/StudioMenuDivider.qml +++ b/apps/arstack_studio/qml/StudioMenuDivider.qml @@ -5,8 +5,8 @@ import QtQuick.Controls MenuSeparator { id: separator property var theme - topPadding: 5 - bottomPadding: 5 + topPadding: 3 + bottomPadding: 3 leftPadding: 10 rightPadding: 10 contentItem: Rectangle { diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml index 8ac1140e0..1cb937802 100644 --- a/apps/arstack_studio/qml/StudioTheme.qml +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -49,7 +49,7 @@ QtObject { readonly property color menuSurface: "#18222e" readonly property color menuHover: "#253b51" readonly property int menuRadius: 11 - readonly property int menuItemHeight: 38 + readonly property int menuItemHeight: 33 readonly property int spaceXs: 4 readonly property int spaceSm: 8 readonly property int spaceMd: 12 diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index 51bd2e0ea..a85413743 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -4,6 +4,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtCore Rectangle { id: tray @@ -14,17 +15,30 @@ Rectangle { property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false - property int selectedRoleIndex: 0 + property int selectedRoleIndex: offlineSettings.lastRole + // Offline controls edit only a lab plan, never the real PTP subsystem. + Settings { + id: offlineSettings + category: "offline-ptp-lab" + property int lastRole: 0 + property int lastSynch: 0 + property bool previewPtp: false + } + readonly property bool boardOnline: Boolean(device && device.deviceVerified) + readonly property bool offlinePtpPreview: !boardOnline && offlineSettings.previewPtp + readonly property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] readonly property bool busy: Boolean(device && device.ptpStarting) readonly property bool ptpReady: Boolean(session && device && device.deviceVerified && device.ptpAvailable && !session.updatingFirmware) - readonly property string stateText: !device || !device.deviceVerified ? "NO DEVICE" + readonly property string stateText: !boardOnline + ? (offlinePtpPreview ? "PTP PLAN · OFFLINE" : "OFFLINE LAB") : !device.ptpAvailable ? "PTP UNAVAILABLE" : busy ? (device.ptpStatus.indexOf("unverified") !== -1 ? "UNVERIFIED" : "PENDING") : !device.ptpRunning ? "PTP OFF" : device.ptpRole + " RUNNING" - readonly property color stateColor: busy ? theme.amber + readonly property color stateColor: !boardOnline ? theme.amber + : busy ? theme.amber : device && device.ptpRunning ? theme.green : theme.muted function roleIndex(role) { @@ -79,16 +93,22 @@ Rectangle { objectName: "studioPtpSwitch" theme: tray.theme uiFont: tray.uiFont - checked: Boolean(tray.device && tray.device.ptpRunning) - enabled: tray.ptpReady && !tray.busy + checked: tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp + enabled: !tray.busy && (!tray.boardOnline || tray.ptpReady) ToolTip.visible: hovered ToolTip.text: "Start/stop PTP with verified board feedback. Does not establish SV sample-phase lock." onClicked: { // Switch flips locally before asynchronous firmware evidence. // Restore binding immediately, including on rejected requests. const wantsRunning = checked - checked = Qt.binding(function() { return Boolean(tray.device && tray.device.ptpRunning) }) - if (wantsRunning) { + checked = Qt.binding(function() { + return tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp + }) + if (!tray.boardOnline) { + offlineSettings.previewPtp = wantsRunning + if (tray.controller) + tray.controller.showMessage("Offline PTP plan only — no network packets or clock discipline.", false) + } else if (wantsRunning) { if (tray.controller) tray.controller.startPtpRole(tray.roleValues[tray.selectedRoleIndex]) } else if (tray.session && !tray.session.requestStopPtp() && tray.controller) { tray.controller.showMessage("PTP stop was not accepted; inspect diagnostics.", true) @@ -105,8 +125,12 @@ Rectangle { model: ["Source", "Follower", "Monitor"] currentIndex: tray.device && tray.device.ptpRunning ? tray.roleIndex(tray.device.ptpRole) : tray.selectedRoleIndex - enabled: tray.ptpReady && !tray.busy && !tray.device.ptpRunning - onActivated: tray.selectedRoleIndex = currentIndex + enabled: !tray.busy && (!tray.boardOnline + || (tray.ptpReady && !tray.device.ptpRunning)) + onActivated: { + tray.selectedRoleIndex = currentIndex + offlineSettings.lastRole = currentIndex + } ToolTip.visible: hovered ToolTip.text: "Source transmits PTP; Follower discovers an external GM; Monitor receives without disciplining." } @@ -124,15 +148,22 @@ Rectangle { Layout.fillWidth: true Layout.minimumWidth: 100 model: ["AUTO", "0 · No sync", "1 · Local (sim)", "2 · Global (sim)"] - currentIndex: tray.syncIndex(tray.device ? tray.device.smpSynchMode : "AUTO") - enabled: tray.session && tray.device && tray.device.deviceVerified && !tray.session.updatingFirmware + currentIndex: tray.boardOnline + ? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch + enabled: !tray.boardOnline || (tray.session && !tray.session.updatingFirmware) ToolTip.visible: hovered ToolTip.text: "1/2 change SV metadata only; they do not synchronize the sample clock." onActivated: { - const choice = ["AUTO", "0", "1", "2"][currentIndex] - const accepted = tray.session && tray.session.requestSmpSynch(choice) + const requested = currentIndex + offlineSettings.lastSynch = requested + if (!tray.boardOnline) { + if (tray.controller) + tray.controller.showMessage("Offline smpSynch plan saved; no SV flag or clock changed.", false) + return + } + const accepted = tray.session && tray.session.requestSmpSynch(["AUTO", "0", "1", "2"][requested]) currentIndex = Qt.binding(function() { - return tray.syncIndex(tray.device ? tray.device.smpSynchMode : "AUTO") + return tray.boardOnline ? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch }) if (!accepted && tray.controller) tray.controller.showMessage("SV smpSynch override rejected; inspect diagnostics.", true) @@ -142,6 +173,7 @@ Rectangle { theme: tray.theme uiFont: tray.uiFont text: "Details" + iconSource: Qt.resolvedUrl("../assets/material/settings.svg") objectName: "studioTimingDetails" implicitWidth: 66 toolTipText: "PTP settings, timing evidence and diagnostics" @@ -150,8 +182,8 @@ Rectangle { } Label { Layout.fillWidth: true - text: !tray.device || !tray.device.deviceVerified - ? "Connect an ESP32-P4 to enable PTP. Generated waveform is a setpoint preview." + text: !tray.boardOnline + ? "OFFLINE PLAN ONLY · PTP and smpSynch selections are local; no PTP/SV packets sent. Board state wins at reconnect." : tray.device.smpSynchSimulated ? "SV smpSynch=" + tray.device.smpSynchValue + " · SIMULATED metadata — sample clock not proven locked." : tray.device.smpSynchMeasured diff --git a/apps/arstack_studio/qml/TelemetryDock.qml b/apps/arstack_studio/qml/TelemetryDock.qml index 61cd0331e..2b874382c 100644 --- a/apps/arstack_studio/qml/TelemetryDock.qml +++ b/apps/arstack_studio/qml/TelemetryDock.qml @@ -84,8 +84,8 @@ Rectangle { theme: telemetry.theme uiFont: telemetry.uiFont iconSource: telemetry.expanded - ? Qt.resolvedUrl("../assets/lucide/chevron-down.svg") - : Qt.resolvedUrl("../assets/lucide/chevron-up.svg") + ? Qt.resolvedUrl("../assets/material/keyboard_arrow_down.svg") + : Qt.resolvedUrl("../assets/material/keyboard_arrow_up.svg") onClicked: telemetry.expanded = !telemetry.expanded ToolTip.visible: hovered ToolTip.text: telemetry.expanded ? "Hide monitor details" : "Show monitor details" diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 032056bd6..9865562ad 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -112,8 +112,12 @@ SurfacePanel { Connections { target: smartSession function onReadyForLiveApply() { + // The current board SHOW response does not contain per-channel + // amplitude/phase/quality. Never silently claim readback or + // overwrite a board with a restored/offline local draft. controller.profileDirty = false - controller.applyAllSignals() + controller.pendingApply = true + controller.showMessage("Board ready. Review your local values, then Apply to board.", false) } function onStateChanged() { if (ribbon.firmwareGateActive) @@ -244,6 +248,10 @@ SurfacePanel { controller.showMessage(ribbon.startReason(), false) return } + if (controller.pendingApply) { + controller.showMessage("Apply the local scenario before starting physical SMV output.", true) + return + } if (!smartSession.requestStart()) controller.showMessage(smartSession.statusText, true) } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index e99b966ac..deaf3e14f 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -60,6 +60,26 @@ def main() -> None: "both magnitude and angle menus must support mouse and keyboard") require("1.75" in (STUDIO.parent.parent / ".github/workflows/arstack-studio-windows-lifecycle.yml").read_text(encoding="utf-8"), "reuse Windows executable for 100%, 150% and 175% DPI acceptance") + workflow = source("WorkflowBar.qml") + ribbon = source("ModernRibbon.qml") + cmake_icons = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + require("scenarioSnapshot()" in main_qml and "restoreLocalScenario()" in main_qml + and "requestApplyDraft()" in main_qml and "offlinePreviewRunning" in main_qml, + "offline-first scenario needs persistence, explicit Apply and non-wire preview") + require("controller.pendingApply = true" in workflow + and "Apply the local scenario before starting" in workflow + and "controller.applyAllSignals()" not in workflow, + "reconnect must not silently transmit an offline draft") + require("offline-ptp-lab" in tray and "no PTP/SV packets sent" in tray, + "PTP/smpSynch offline controls must be plans only") + require("assets/material/smv_injector.svg" in cmake_icons + and "assets/MATERIAL_ICONS_LICENSE.txt" in cmake_icons + and "../assets/material/smv_injector.svg" in ribbon, + "user-provided injector mark and licensed Material icon resources required") + require("MAGNITUDE ·" not in matrix and "ANGLE ·" not in matrix + and "Equal magnitudes A/B/C" in matrix + and "Balance angles · 120°" in matrix, + "context actions must stay compact, descriptive and grouped") require(matrix.count("StudioActionMenu {") == 2 and "id: magnitudeMenu" in matrix and "id: phaseMenu" in matrix and "popupType: Popup.Item" in menu, From 21f6458c133cdd3a0bb635549ebec73b36692383 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 06:16:16 +0700 Subject: [PATCH 15/16] fix(studio): keep offline PTP combo bindings and validate signed DC scenario restore --- apps/arstack_studio/qml/Main.qml | 4 +++- apps/arstack_studio/qml/StudioTimingTray.qml | 8 ++++++++ apps/arstack_studio/tests/check_operator_workspace.py | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 01cddf3e4..457b6d5bb 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -68,7 +68,9 @@ ApplicationWindow { for (var i = 0; i < 4; ++i) { const row = rows[i] if (!Array.isArray(row) || row.length !== 4 - || !validMagnitude(group, row[0]) || !validPhase(row[1]) + || !isFinite(row[0]) + || (data.hz === 0 ? Math.abs(row[0]) : row[0]) > maximumMagnitude(group) + || (data.hz !== 0 && row[0] < 0) || !validPhase(row[1]) || typeof row[2] !== "boolean" || !Number.isInteger(row[3]) || row[3] < 0 || row[3] > 4294967295) throw new Error("Invalid channel value") diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index a85413743..0da9537dc 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -130,6 +130,10 @@ Rectangle { onActivated: { tray.selectedRoleIndex = currentIndex offlineSettings.lastRole = currentIndex + currentIndex = Qt.binding(function() { + return tray.device && tray.device.ptpRunning + ? tray.roleIndex(tray.device.ptpRole) : tray.selectedRoleIndex + }) } ToolTip.visible: hovered ToolTip.text: "Source transmits PTP; Follower discovers an external GM; Monitor receives without disciplining." @@ -157,6 +161,10 @@ Rectangle { const requested = currentIndex offlineSettings.lastSynch = requested if (!tray.boardOnline) { + currentIndex = Qt.binding(function() { + return tray.boardOnline ? tray.syncIndex(tray.device.smpSynchMode) + : offlineSettings.lastSynch + }) if (tray.controller) tray.controller.showMessage("Offline smpSynch plan saved; no SV flag or clock changed.", false) return diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index deaf3e14f..dd9624f78 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -51,7 +51,8 @@ def main() -> None: "main PTP must reuse the existing verified Advanced profile") require("checked = Qt.binding(function()" in tray and "tray.session.requestStopPtp()" in tray - and "tray.session.requestSmpSynch(choice)" in tray, + and "tray.session.requestSmpSynch([\"AUTO\", \"0\", \"1\", \"2\"][requested])" in tray + and "if (!tray.boardOnline)" in tray, "main PTP/smpSynch must restore device-owned state after user intent") require("SIMULATED metadata" in tray and "sample phase needs independent verification" in tray, "SV simulation must never masquerade as independently proven lock") From a2fc04f6d0e224d695e01ca91b23ce79cd14d68f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 17 Oct 2026 07:20:29 +0700 Subject: [PATCH 16/16] fix(studio): fail-closed offline draft restore and synchronous close persistence --- apps/arstack_studio/qml/Main.qml | 17 +++++++++++++---- .../tests/check_operator_workspace.py | 8 ++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 457b6d5bb..9c4e3b919 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -60,7 +60,8 @@ ApplicationWindow { if (!saved || saved.length > 8192) return try { const data = JSON.parse(saved) - if (data.version !== 1 || !validFrequency(data.hz) + if (data.version !== 1 || typeof data.hz !== "number" + || !Number.isFinite(data.hz) || !validFrequency(data.hz) || !Array.isArray(data.current) || !Array.isArray(data.voltage) || data.current.length !== 4 || data.voltage.length !== 4) throw new Error("Invalid scenario format") @@ -68,10 +69,12 @@ ApplicationWindow { for (var i = 0; i < 4; ++i) { const row = rows[i] if (!Array.isArray(row) || row.length !== 4 - || !isFinite(row[0]) + || typeof row[0] !== "number" || !Number.isFinite(row[0]) || (data.hz === 0 ? Math.abs(row[0]) : row[0]) > maximumMagnitude(group) - || (data.hz !== 0 && row[0] < 0) || !validPhase(row[1]) - || typeof row[2] !== "boolean" || !Number.isInteger(row[3]) + || (data.hz !== 0 && row[0] < 0) + || typeof row[1] !== "number" || !Number.isFinite(row[1]) + || !validPhase(row[1]) || typeof row[2] !== "boolean" + || typeof row[3] !== "number" || !Number.isInteger(row[3]) || row[3] < 0 || row[3] > 4294967295) throw new Error("Invalid channel value") } @@ -149,6 +152,12 @@ ApplicationWindow { configurationWindow.hide() detachedPhasorWindow.hide() detachedWaveformWindow.hide() + // The debounced persistence timer can still be pending when the user + // closes immediately after editing. Flush the local draft once, while + // ListModels and Settings remain alive; no device command is sent. + saveScenarioTimer.stop() + if (!root.restoringScenario) + scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) // C++ owns process termination and bounded worker retirement. Do not // race it with a second QML quit path. } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index dd9624f78..68fe8e9b6 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -67,6 +67,14 @@ def main() -> None: require("scenarioSnapshot()" in main_qml and "restoreLocalScenario()" in main_qml and "requestApplyDraft()" in main_qml and "offlinePreviewRunning" in main_qml, "offline-first scenario needs persistence, explicit Apply and non-wire preview") + require('saveScenarioTimer.stop()' in main_qml + and 'scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot())' in main_qml, + "closing immediately after an edit must flush the local draft before teardown") + require('typeof data.hz !== "number"' in main_qml + and 'typeof row[0] !== "number"' in main_qml + and 'typeof row[1] !== "number"' in main_qml + and 'typeof row[3] !== "number"' in main_qml, + "saved scenario restoration must reject JSON null/string coercion and nonfinite values") require("controller.pendingApply = true" in workflow and "Apply the local scenario before starting" in workflow and "controller.applyAllSignals()" not in workflow,