diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 993df310e..47bb3f659 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -158,6 +158,8 @@ qt_add_qml_module(arstack_studio assets/material/file_upload.svg assets/material/stop.svg assets/material/play_arrow.svg + assets/material/run-bolt.svg + assets/material/stop-bolt.svg assets/material/dashboard.svg assets/material/folder_open.svg assets/material/search.svg diff --git a/apps/arstack_studio/assets/material/run-bolt.svg b/apps/arstack_studio/assets/material/run-bolt.svg new file mode 100644 index 000000000..6df727593 --- /dev/null +++ b/apps/arstack_studio/assets/material/run-bolt.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/assets/material/stop-bolt.svg b/apps/arstack_studio/assets/material/stop-bolt.svg new file mode 100644 index 000000000..71035e290 --- /dev/null +++ b/apps/arstack_studio/assets/material/stop-bolt.svg @@ -0,0 +1 @@ + diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index e0ee2e98c..126652474 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1065,8 +1065,12 @@ ApplicationWindow { objectName: "studioInjectionSurface" SplitView.fillWidth: true SplitView.fillHeight: true - SplitView.minimumWidth: root.compactLayout ? 500 : 550 - SplitView.preferredWidth: root.compactLayout ? 700 : 860 + // Form fields must not expand across the whole screen. + // Keep the injector compact; give spare width to plots. + // Users can still resize the split manually. + SplitView.minimumWidth: root.compactLayout ? 480 : 520 + SplitView.preferredWidth: root.compactLayout ? 505 : 610 + SplitView.maximumWidth: 660 theme: studioTheme ColumnLayout { @@ -1295,9 +1299,9 @@ ApplicationWindow { SplitView { visible: (root.phasorDockVisible && !root.phasorDetached) || (root.waveformDockVisible && !root.waveformDetached) - SplitView.preferredWidth: root.compactLayout ? 300 : 380 - SplitView.minimumWidth: root.compactLayout ? 270 : 300 - SplitView.maximumWidth: 680 + SplitView.preferredWidth: root.compactLayout ? 420 : 560 + SplitView.minimumWidth: root.compactLayout ? 330 : 380 + SplitView.maximumWidth: 1200 SplitView.fillHeight: true orientation: Qt.Vertical handle: Rectangle { diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 193af1705..1f913ab45 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -220,11 +220,13 @@ Rectangle { } Rectangle { - Layout.preferredWidth: bar.compact ? 84 : 108 - Layout.preferredHeight: 36 + Layout.preferredWidth: bar.compact ? 92 : 120 + Layout.preferredHeight: 39 Layout.alignment: Qt.AlignVCenter - radius: 15 - color: "#0a1016" + radius: 12 + // A noninteractive status, not another START/STOP authority. + // Green only after verified board RUNNING; muted red otherwise. + color: bar.device.running ? "#112a23" : "#271a20" border.width: 1 border.color: Qt.rgba(bar.workflow.smartStateColor.r, bar.workflow.smartStateColor.g, @@ -236,21 +238,25 @@ Rectangle { anchors.rightMargin: 9 spacing: 6 - Rectangle { - width: 6 - height: 6 - radius: 3 - color: bar.workflow.smartStateColor + Image { + source: bar.device.running + ? Qt.resolvedUrl("../assets/material/run-bolt.svg") + : Qt.resolvedUrl("../assets/material/stop-bolt.svg") + sourceSize.width: 21 + sourceSize.height: 21 + Layout.preferredWidth: 21 + Layout.preferredHeight: 21 + fillMode: Image.PreserveAspectFit + opacity: bar.device.running ? 1.0 : 0.72 } - Label { Layout.fillWidth: true - text: bar.conciseState() - color: bar.workflow.smartStateColor + text: bar.device.running ? "RUN" : "STOP" + color: bar.device.running ? bar.theme.green : "#b45d67" font.family: bar.uiFont - font.pixelSize: 9 - font.weight: Font.DemiBold - elide: Text.ElideRight + font.pixelSize: 12 + font.weight: Font.Bold + font.letterSpacing: 0.7 verticalAlignment: Text.AlignVCenter } } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 06f5d88c6..f28210e89 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -17,14 +17,64 @@ 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) + readonly property bool startQueued: Boolean( + (ribbon.device && ribbon.device.scenarioApplying) || ribbon.startIntentWaiting) + // One deliberate click while 4I+4V is preparing remains armed only for + // this verified board boot. No automatic START after USB reconnect/flash. + property bool startIntentWaiting: false + property string startIntentBootId: "" + function cancelStartIntent() { + startIntentWaiting = false + startIntentBootId = "" + startIntentDeadline.stop() + } + Timer { + id: startIntentDeadline + interval: 8000 + repeat: false + onTriggered: { + if (!ribbon.startIntentWaiting) return + ribbon.cancelStartIntent() + controller.showMessage( + "START belum dikirim: persiapan profil 4I+4V melebihi 8 detik. Periksa status board dan coba lagi.", true) + } + } + function deliverReadyStart() { + if (!startIntentWaiting || !ribbon.device || !ribbon.device.deviceVerified) return + if (ribbon.device.bootId !== startIntentBootId || + smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) { + cancelStartIntent() + return + } + if (ribbon.device.running || ribbon.device.scenarioApplying) { + cancelStartIntent() + return + } + if (!smartSession.startReady) return + cancelStartIntent() + // Snapshot is taken at the actual verified start boundary, not from + // stale startup defaults. No hidden retries after a rejected command. + const snapshot = controller.startScenarioSnapshot() + if (!snapshot) return + if (!smartSession.requestStartWithScenario(snapshot)) { + controller.pendingApply = true + controller.showMessage("START ditolak board. Periksa status koneksi dan diagnostics.", true) + } + } 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() + } + } + function onDeviceIdentityChanged() { + if (ribbon.startIntentWaiting && ribbon.device.bootId !== ribbon.startIntentBootId) + ribbon.cancelStartIntent() } } property bool updatePromptDeferred: false @@ -123,6 +173,7 @@ SurfacePanel { Connections { target: smartSession function onReadyForLiveApply() { + if (ribbon.startIntentWaiting) Qt.callLater(ribbon.deliverReadyStart) // 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. @@ -132,6 +183,7 @@ SurfacePanel { // No auto-transmit on device discovery/reconnection. } function onStateChanged() { + if (ribbon.startIntentWaiting) Qt.callLater(ribbon.deliverReadyStart) if (ribbon.firmwareGateActive) controller.profileDirty = true @@ -271,9 +323,17 @@ 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. - controller.showMessage("START waiting: " + ribbon.startReason(), true) + if (ribbon.device.deviceVerified && + smartSession.state === "PREPARING 4I+4V" && + ribbon.device.bootId.length > 0) { + ribbon.startIntentBootId = ribbon.device.bootId + ribbon.startIntentWaiting = true + startIntentDeadline.restart() + controller.showMessage( + "START diterima. Menunggu profil 4I+4V siap; injeksi dimulai setelah board terverifikasi.", false) + } else { + controller.showMessage("START belum siap: " + ribbon.startReason(), true) + } return } // A START always transmits the current 4I+4V table snapshot. @@ -291,6 +351,13 @@ SurfacePanel { } function requestStop() { + // An operator STOP cancels a queued one-click intent before any + // firmware command can be dispatched. + if (startIntentWaiting) { + cancelStartIntent() + controller.showMessage("START dibatalkan. Board tetap STOP.", 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..a80c31b31 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -191,7 +191,10 @@ 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 'ribbon.device.scenarioApplying) || ribbon.startIntentWaiting' in source("WorkflowBar.qml") + and 'if (startIntentWaiting)' in source("WorkflowBar.qml") + and 'cancelStartIntent()' 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 +540,29 @@ 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") + # Operator desktop layout: bounded injector column, space for plots, + # no extra QML START authority and no abandoned first-click intent. + layout = source("Main.qml") + ribbon = source("ModernRibbon.qml") + workflow = source("WorkflowBar.qml") + require("SplitView.maximumWidth: 660" in layout + and "SplitView.preferredWidth: root.compactLayout ? 505 : 610" in layout + and "SplitView.preferredWidth: root.compactLayout ? 420 : 560" in layout + and "run-bolt.svg" in ribbon and "stop-bolt.svg" in ribbon + and "assets/material/run-bolt.svg" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + and "assets/material/stop-bolt.svg" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + and 'text: bar.device.running ? "RUN" : "STOP"' in ribbon + and "property bool startIntentWaiting: false" in workflow + and "interval: 8000" in workflow + and "startIntentBootId" in workflow + and "function cancelStartIntent()" in workflow + and "function deliverReadyStart()" in workflow + and "smartSession.state === \"PREPARING 4I+4V\"" in workflow + and "if (startIntentWaiting)" in workflow + and "smartSession.requestStartWithScenario(snapshot)" in workflow, + "Studio must offer proportional engineering panels, verified bolt RUN/STOP " + "and first-click START through a bounded same-boot cancellable intent") + toast_layout = source("Main.qml") toast_view = source("StatusToast.qml") require("function explainFailure(message)" in toast_layout