diff --git a/apps/arstack_studio/qml/CalmButton.qml b/apps/arstack_studio/qml/CalmButton.qml index eb418851a..882788bfe 100644 --- a/apps/arstack_studio/qml/CalmButton.qml +++ b/apps/arstack_studio/qml/CalmButton.qml @@ -39,8 +39,7 @@ Button { tone === "accent" ? "#dcebff" : theme.textSoft contentItem: Row { - // The same item owns both the icon and text; no native Button - // hover glyph can appear independently. + // Both caption and icon belong to the same centered Control item. spacing: control.iconSource.toString().length > 0 ? 7 : 0 anchors.centerIn: parent Image { diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 72ac77a79..4ac8d2779 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -508,6 +508,12 @@ ApplicationWindow { return ok } function startScenarioSnapshot() { + // The table editors debounce keyboard edits for live preview. Flush + // them synchronously before creating the ordered board START payload. + if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) { + showMessage("Invalid signal input: correct highlighted field before Start.", true) + return null + } // START always serializes the same ListModels that draw the table, // including all eight current phase angles and enabled flags. var draft = scenarioSnapshot() @@ -1074,9 +1080,15 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true - Layout.preferredHeight: root.compactLayout ? 352 : 384 - Layout.maximumHeight: root.compactLayout ? 352 : 384 - spacing: 7 + // Use all available vertical workspace rather than + // a fixed 384px island with empty space below. + Layout.fillHeight: true + Layout.preferredHeight: root.compactLayout ? 350 : 380 + // Allow 960x620 minimum viewport to shrink without + // clipping timing and quality rows; on larger + // viewports this column expands into spare space. + Layout.minimumHeight: root.compactLayout ? 180 : 210 + spacing: 8 SignalMatrix { id: voltageMatrix enabled: !workflowBar.startQueued diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 6776d73df..27fdd8139 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -47,24 +47,26 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 152 : 182 + Layout.preferredWidth: bar.compact ? 190 : 224 Layout.alignment: Qt.AlignVCenter spacing: 8 Rectangle { - width: 38 - height: 38 - radius: 9 + Layout.preferredWidth: 48 + Layout.preferredHeight: 48 + width: 48 + height: 48 + radius: 10 color: "#0d151d" border.width: 1 border.color: bar.theme.lineSoft Image { anchors.centerIn: parent - width: 25 - height: 25 + width: 34 + height: 34 source: Qt.resolvedUrl("../assets/material/smv_injector.svg") - sourceSize.width: 30 - sourceSize.height: 30 + sourceSize.width: 68 + sourceSize.height: 68 } } @@ -73,7 +75,7 @@ Rectangle { text: "SMV Injector" color: bar.theme.text font.family: bar.uiFont - font.pixelSize: 15 + font.pixelSize: bar.compact ? 17 : 19 font.weight: Font.Bold elide: Text.ElideRight verticalAlignment: Text.AlignVCenter diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index ae84160c4..8e5bf1ff3 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -39,8 +39,10 @@ Button { : tone === "danger" ? "#ffe3e6" : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft contentItem: ColumnLayout { - spacing: 3 - anchors.centerIn: parent + spacing: 4 + // Controls own the content rectangle; centering is declarative + // on the layout itself, rather than anchoring Qt Controls content. + Item { Layout.fillHeight: true } Image { visible: control.iconSource.toString().length > 0 source: control.hovered && control.enabled ? control.hoverPath : control.iconSource @@ -59,10 +61,12 @@ Button { font: control.font Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter Layout.fillWidth: true + Layout.preferredHeight: 18 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } + Item { Layout.fillHeight: true } } background: Rectangle { radius: 9 diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 1c84e2b43..101e9c2d3 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -27,6 +27,17 @@ Rectangle { return [theme.phaseA, theme.phaseB, theme.phaseC, theme.phaseN][index] } + // Commit any last keystroke synchronously before constructing an ordered + // START scenario. The 90ms edit debounce must never make the board + // receive old angles while the table already displays new ones. + function flushPendingEdits() { + for (var i = 0; i < rowRepeater.count; ++i) { + const row = rowRepeater.itemAt(i) + if (row && !row.flushPendingEdits()) return false + } + return true + } + function focusCell(row, column) { var rowItem = rowRepeater.itemAt(row) if (!rowItem) @@ -124,6 +135,26 @@ Rectangle { property real pendingMagnitude: mag property real pendingPhase: angle + function flushPendingEdits() { + if (magnitudeApplyTimer.running || magnitudeField.activeFocus) { + const magValue = matrix.controller.parseOperatorNumber(magnitudeField.text) + if (!matrix.controller.validMagnitude(matrix.groupIndex, magValue)) + return false + magnitudeApplyTimer.stop() + if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "magnitude", magValue)) + return false + } + if (phaseApplyTimer.running || phaseField.activeFocus) { + const phaseValue = matrix.controller.parseOperatorNumber(phaseField.text) + if (!matrix.controller.validPhase(phaseValue)) + return false + phaseApplyTimer.stop() + if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "phase", phaseValue)) + return false + } + return true + } + // Align with the field edge, not the left side of the table. // Clamp inside the owning matrix and open below the editor; // keyboard Menu/Shift+F10 uses exactly the same placement. diff --git a/apps/arstack_studio/qml/StudioDarkCheckBox.qml b/apps/arstack_studio/qml/StudioDarkCheckBox.qml index fd2794a78..a50529053 100644 --- a/apps/arstack_studio/qml/StudioDarkCheckBox.qml +++ b/apps/arstack_studio/qml/StudioDarkCheckBox.qml @@ -8,13 +8,18 @@ CheckBox { property var theme property string uiFont: "Inter" implicitHeight: 30 + implicitWidth: Math.max(28, contentItem.implicitWidth + 30) + leftPadding: 1 + rightPadding: 2 spacing: 9 hoverEnabled: true font.family: uiFont font.pixelSize: 11 indicator: Rectangle { - implicitWidth: 18 - implicitHeight: 18 + implicitWidth: 20 + implicitHeight: 20 + width: 20 + height: 20 x: control.leftPadding y: (control.height - height) / 2 radius: 5 @@ -27,7 +32,7 @@ CheckBox { visible: control.checked text: "\u2713" color: control.theme.bg - font.pixelSize: 13 + font.pixelSize: 14 font.weight: Font.Bold } } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index bbc858ac4..943258148 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -284,8 +284,11 @@ SurfacePanel { // A START always transmits the current 4I+4V table snapshot. // A previous run may have cleared pendingApply; a bare START could // then repeat old board angles while Studio displays edited values. - const accepted = smartSession.requestStartWithScenario( - controller.startScenarioSnapshot()) + // Validate/flush the visible 4I+4V table before entering the + // exclusive device START transaction; no stale debounced angle. + const scenario = controller.startScenarioSnapshot() + if (!scenario) return + const accepted = smartSession.requestStartWithScenario(scenario) if (!accepted) { controller.pendingApply = true controller.showMessage("Start rejected. Check device status.", true) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bd5fe2233..da467e2b9 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -644,6 +644,13 @@ class DeterministicSessionHarness final { QStringLiteral("Ib"), 1.0, -120.0, 0U, 1000.0, 100.0, 50.0); const QString ic = DeviceController::formatSignalCommand( QStringLiteral("Ic"), 1.0, 120.0, 0U, 1000.0, 100.0, 50.0); + // Operator table −30/−150/+90 must NEVER be serialized as 0/−120/+120. + const QString iaOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ia"), 1.0, -30.0, 0U, 1000.0, 100.0, 50.0); + const QString ibOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ib"), 1.0, -150.0, 0U, 1000.0, 100.0, 50.0); + const QString icOperator = DeviceController::formatSignalCommand( + QStringLiteral("Ic"), 1.0, 90.0, 0U, 1000.0, 100.0, 50.0); const QString badId = DeviceController::formatSignalCommand( QStringLiteral("IX"), 1.0, 0.0, 0U, 1000.0, 100.0, 50.0); const QString overflow = DeviceController::formatSignalCommand( @@ -653,6 +660,9 @@ class DeterministicSessionHarness final { return ia == QStringLiteral("SET IA 1000 0 0") && ib == QStringLiteral("SET IB 1000 -120000 0") && ic == QStringLiteral("SET IC 1000 120000 0") && + iaOperator == QStringLiteral("SET IA 1000 -30000 0") && + ibOperator == QStringLiteral("SET IB 1000 -150000 0") && + icOperator == QStringLiteral("SET IC 1000 90000 0") && badId.isEmpty() && overflow.isEmpty() && dcNegative == QStringLiteral("SET IA -1000 0 0"); } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index afea41801..184b54d46 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -37,9 +37,10 @@ def main() -> None: and "root.pendingApply" in main_qml and "pendingApply = true" in main_qml, "table edits must expose unsent setpoints without claiming wire readback") - require("Layout.preferredHeight: root.compactLayout ? 352 : 384" in main_qml + require("Layout.minimumHeight: root.compactLayout ? 180 : 210" in main_qml + and "Layout.fillHeight: true" in main_qml and "ColumnLayout {" in main_qml, - "voltage and current must stack vertically to use the workspace") + "voltage and current must stack vertically and fill the workspace") require('property bool ptpDefaultStartAttempted: false' in main_qml and 'root.ptpDefaultStartAttempted = true' in main_qml and '!root.startPtpRole("SOURCE")' in main_qml, @@ -60,6 +61,28 @@ def main() -> None: and "root.ptpDesiredForSession = false" in main_qml, "PTP user intent restoration must be bounded, explicit and cancellable") matrix = source("SignalMatrix.qml") + require("function flushPendingEdits()" in matrix + and "magnitudeApplyTimer.stop()" in matrix + and "phaseApplyTimer.stop()" in matrix + and "currentMatrix.flushPendingEdits()" in main_qml + and "voltageMatrix.flushPendingEdits()" in main_qml + and "if (!scenario) return" in source("WorkflowBar.qml"), + "START must commit last visible I/V angle before ordered scenario serialization") + require("Layout.fillHeight: true" in main_qml + and "Layout.maximumHeight: root.compactLayout ? 352 : 384" not in main_qml + and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), + "Voltage and Current need a vertical, flexible-height editor workspace") + ribbon_qml = source("ModernRibbon.qml") + require("Layout.preferredWidth: bar.compact ? 190 : 224" in ribbon_qml + and "font.pixelSize: bar.compact ? 17 : 19" in ribbon_qml, + "SMV Injector logo/caption must be instrument-sized, not micro branding") + checkbox_qml = source("StudioDarkCheckBox.qml") + require("width: 20" in checkbox_qml and "height: 20" in checkbox_qml, + "checkbox geometry must remain unchanged by hover") + require("hovered ? \"#edf5ff\"" in source("CalmButton.qml") + and "hovered ? \"#142334\"" in source("CalmButton.qml"), + "light hover background must always pair with dark caption") + menu = source("StudioActionMenu.qml") cmake = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") @@ -132,7 +155,8 @@ def main() -> None: "scenario editors and shortcuts must be frozen during a pending START snapshot") require("controller.pendingApply = true" in workflow and "smartSession.requestStartWithScenario(" in workflow - and "controller.startScenarioSnapshot())" in workflow + and "const scenario = controller.startScenarioSnapshot()" in workflow + and "if (!scenario) return" in workflow and "controller.applyAllSignals()" not in workflow and "startWatchdog" in workflow and "onRunningChanged()" in workflow, "reconnect must not auto-transmit, but one-click Start must stage a bounded scenario") @@ -162,7 +186,8 @@ def main() -> None: and 'ToolTip.text: "smpSynch mode"' not in timing_ui, "captioned operator controls must not duplicate labels as tooltips") require('smartSession.requestStartWithScenario(' in workflow - and 'controller.startScenarioSnapshot())' in workflow + and 'const scenario = controller.startScenarioSnapshot()' in workflow + and 'if (!scenario) return' in workflow and "Apply to board" not in main_qml, "Edit-to-Start must not regress to a manual Apply-to-board action") require("assets/material/smv_injector.svg" in cmake_icons