diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 993df310e..79db977ea 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -170,6 +170,8 @@ qt_add_qml_module(arstack_studio assets/material/device_hub.svg assets/material/help_outline.svg assets/material/smv_injector.svg + assets/material/electric_bolt_run.svg + assets/material/electric_bolt_stop.svg ) qt_add_resources(arstack_studio "reference_templates" diff --git a/apps/arstack_studio/assets/material/electric_bolt.svg b/apps/arstack_studio/assets/material/electric_bolt.svg new file mode 100644 index 000000000..a1c3d2793 --- /dev/null +++ b/apps/arstack_studio/assets/material/electric_bolt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/electric_bolt_run.svg b/apps/arstack_studio/assets/material/electric_bolt_run.svg new file mode 100644 index 000000000..81cf2396b --- /dev/null +++ b/apps/arstack_studio/assets/material/electric_bolt_run.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material/electric_bolt_stop.svg b/apps/arstack_studio/assets/material/electric_bolt_stop.svg new file mode 100644 index 000000000..daa281f8a --- /dev/null +++ b/apps/arstack_studio/assets/material/electric_bolt_stop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index e0ee2e98c..c8a46b6e8 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -7,8 +7,8 @@ import ARStack.Studio 1.0 ApplicationWindow { id: root - width: 1240 - height: 760 + width: 1150 + height: 790 minimumWidth: 960 minimumHeight: 620 visible: true @@ -192,7 +192,7 @@ ApplicationWindow { source: Qt.resolvedUrl("../assets/InterVariable.ttf") } - readonly property bool compactLayout: width < 1120 + readonly property bool compactLayout: width < 1220 readonly property bool canDeploy: workflowBar.session ? workflowBar.session.canDeployProfile : false readonly property bool canStart: workflowBar.session ? workflowBar.session.startReady : false readonly property string toastMessage: transientMessage @@ -1065,8 +1065,9 @@ ApplicationWindow { objectName: "studioInjectionSurface" SplitView.fillWidth: true SplitView.fillHeight: true - SplitView.minimumWidth: root.compactLayout ? 500 : 550 - SplitView.preferredWidth: root.compactLayout ? 700 : 860 + SplitView.minimumWidth: root.compactLayout ? 500 : 540 + SplitView.preferredWidth: root.compactLayout ? 600 : 690 + SplitView.maximumWidth: 750 theme: studioTheme ColumnLayout { @@ -1295,8 +1296,8 @@ ApplicationWindow { SplitView { visible: (root.phasorDockVisible && !root.phasorDetached) || (root.waveformDockVisible && !root.waveformDetached) - SplitView.preferredWidth: root.compactLayout ? 300 : 380 - SplitView.minimumWidth: root.compactLayout ? 270 : 300 + SplitView.preferredWidth: root.compactLayout ? 450 : 470 + SplitView.minimumWidth: root.compactLayout ? 300 : 340 SplitView.maximumWidth: 680 SplitView.fillHeight: true orientation: Qt.Vertical diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 193af1705..bce90ffec 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -220,36 +220,42 @@ Rectangle { } Rectangle { - Layout.preferredWidth: bar.compact ? 84 : 108 - Layout.preferredHeight: 36 + Layout.preferredWidth: bar.compact ? 84 : 102 + Layout.preferredHeight: 40 Layout.alignment: Qt.AlignVCenter radius: 15 color: "#0a1016" border.width: 1 - border.color: Qt.rgba(bar.workflow.smartStateColor.r, - bar.workflow.smartStateColor.g, - bar.workflow.smartStateColor.b, 0.48) + border.color: bar.device.deviceVerified && bar.device.running + ? bar.theme.green : "#663942" + readonly property bool physicalRunning: bar.device.deviceVerified && + bar.device.running + readonly property color runColor: physicalRunning + ? bar.theme.green : "#b66069" RowLayout { anchors.fill: parent - anchors.leftMargin: 9 + anchors.leftMargin: 10 anchors.rightMargin: 9 spacing: 6 - - Rectangle { - width: 6 - height: 6 - radius: 3 - color: bar.workflow.smartStateColor - } - + Image { + Layout.preferredWidth: 19 + Layout.preferredHeight: 19 + source: Qt.resolvedUrl(parent.parent.physicalRunning + ? "../assets/material/electric_bolt_run.svg" + : "../assets/material/electric_bolt_stop.svg") + sourceSize.width: 24 + sourceSize.height: 24 + opacity: parent.parent.physicalRunning ? 1.0 : 0.48 + } Label { Layout.fillWidth: true - text: bar.conciseState() - color: bar.workflow.smartStateColor + text: bar.device.deviceVerified + ? (bar.device.running ? "RUN" : "STOP") : "OFF" + color: parent.parent.runColor font.family: bar.uiFont - font.pixelSize: 9 - font.weight: Font.DemiBold + font.pixelSize: 11 + font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 06f5d88c6..3295cbe9d 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -17,14 +17,78 @@ SurfacePanel { property bool compact: false // Firmware transaction is the single source of truth. UI timers and // unrelated PTP/serial errors must not bounce the injection START button. - readonly property bool startQueued: Boolean(ribbon.device && ribbon.device.scenarioApplying) + // Operator intent may arrive before first profile synchronization ends. + // One bounded, boot-bound pending click is consumed as soon as the SAME + // verified board enters READY. Never persist/replay across reconnect. + property bool startIntentPending: false + property string startIntentBoot: "" + property double startIntentDeadline: 0 + readonly property bool startQueued: Boolean( + startIntentPending || (ribbon.device && ribbon.device.scenarioApplying)) + Timer { + id: startIntentExpiry + interval: 6500 + repeat: false + onTriggered: { + if (!ribbon.startIntentPending) return + ribbon.cancelStartIntent() + controller.showMessage( + "START not sent: device preparation timed out. Check board/profile state and retry.", true) + } + } + function cancelStartIntent() { + startIntentPending = false + startIntentBoot = "" + startIntentDeadline = 0 + startIntentExpiry.stop() + } + function queueFirstStart() { + // Only a verified, physically connected board may receive deferred + // START. Never arm for offline, firmware update or reconnect. + if (!device.deviceVerified || !device.connected || + smartSession.updatingFirmware || smartSession.firmwareUpdateRequired || + smartSession.firmwareInstallRequired) return false + const boot = String(device.bootId) + if (!boot || boot === "undefined") return false + startIntentBoot = boot + startIntentPending = true + startIntentDeadline = Date.now() + 6500 + startIntentExpiry.restart() + controller.showMessage("START queued — waiting for 4I+4V profile readiness on this board.", false) + return true + } + function tryConsumeFirstStart() { + if (!startIntentPending || !smartSession.startReady) return + const boot = String(device.bootId) + if (!device.deviceVerified || !device.connected || boot !== startIntentBoot || + Date.now() > startIntentDeadline) { + cancelStartIntent() + return + } + const requestedBoot = startIntentBoot + cancelStartIntent() + Qt.callLater(function() { + // Prevent a reconnect between readiness notification and dispatch. + if (device.deviceVerified && device.connected && + String(device.bootId) === requestedBoot && smartSession.startReady && + !device.running && !device.scenarioApplying) + ribbon.requestStart() + }) + } + Connections { + target: smartSession + function onStateChanged() { ribbon.tryConsumeFirstStart() } + } Connections { target: ribbon.device function onRunningChanged() { if (ribbon.device.running) controller.pendingApply = false } function onDeviceVerifiedChanged() { - if (!ribbon.device.deviceVerified) controller.pendingApply = true + if (!ribbon.device.deviceVerified) { + controller.pendingApply = true + ribbon.cancelStartIntent() + } } } property bool updatePromptDeferred: false @@ -271,8 +335,13 @@ SurfacePanel { return } if (!smartSession.startReady) { - // One click must explain why a physical START is not queued. - // Never make the operator click twice to discover readiness. + // A first click during same-device profile preparation is retained + // briefly and dispatched once READY. Other errors are explained. + if (smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "RECOVERING PROFILE" || + smartSession.state === "DEVICE FOUND") { + if (ribbon.queueFirstStart()) return + } controller.showMessage("START waiting: " + ribbon.startReason(), true) return } @@ -291,6 +360,11 @@ SurfacePanel { } function requestStop() { + if (ribbon.startIntentPending) { + ribbon.cancelStartIntent() + controller.showMessage("Pending START cancelled.", false) + return + } // A pending CFG transaction can progress to START: Stop must remain // actionable before RUNNING telemetry, not only after it. if (!ribbon.injectionRunning && !ribbon.startQueued) return diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index fe344ee67..aa459e8ae 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -191,7 +191,8 @@ def main() -> None: require('bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning' in ribbon_qml and '!ribbon.injectionRunning && !ribbon.startQueued' in source("WorkflowBar.qml") - and 'readonly property bool startQueued: Boolean(ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml") + and 'readonly property bool startQueued: Boolean(' in source("WorkflowBar.qml") + and 'startIntentPending || (ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml") and 'startWatchdog' not in source("WorkflowBar.qml"), "SV STOP must remain actionable while the acknowledged scenario transaction is pending") require("function flushPendingEdits()" in matrix @@ -537,6 +538,33 @@ def main() -> None: "SV output must fail-closed on measured heartbeat loss, preserve board STOP cause, " "and never show misleading green READY after an unrequested stop") + # Default workbench should never stretch the 4I+4V setpoint matrix + # beyond a useful editing width while starving Phasor/Waveform charts. + responsive_main = source("Main.qml") + ribbon_layout = source("ModernRibbon.qml") + start_flow = source("WorkflowBar.qml") + require("width: 1150" in responsive_main + and "SplitView.maximumWidth: 750" in responsive_main + and "SplitView.preferredWidth: root.compactLayout ? 450 : 470" in responsive_main + and "electric_bolt_run.svg" in ribbon_layout + and "electric_bolt_stop.svg" in ribbon_layout + and "assets/material/electric_bolt_run.svg" in + (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + and "assets/material/electric_bolt_stop.svg" in + (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + and "bar.device.running ? \"RUN\" : \"STOP\"" in ribbon_layout, + "Studio default workbench must balance channels and charts; physical RUN/STOP uses supplied bolt") + require("property bool startIntentPending: false" in start_flow + and "String(device.bootId)" in start_flow + and "startIntentDeadline = Date.now() + 6500" in start_flow + and "onStateChanged() { ribbon.tryConsumeFirstStart() }" in start_flow + and "function onDeviceVerifiedChanged()" in start_flow + and "ribbon.cancelStartIntent()" in start_flow + and "if (ribbon.startIntentPending)" in start_flow + and "String(device.bootId) === requestedBoot" in start_flow + and "smartSession.requestStartWithScenario(scenario)" in start_flow, + "First START click must be bounded, single-device, cancelable, and use verified scenario START") + toast_layout = source("Main.qml") toast_view = source("StatusToast.qml") require("function explainFailure(message)" in toast_layout