Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/arstack_studio/qml/CalmButton.qml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Button {
Behavior on scale { NumberAnimation { duration: 70; easing.type: Easing.OutCubic } }

readonly property color activeFill: !enabled ? "#10161d" :
hovered ? "#edf5ff" :
tone === "success" ? (hovered ? "#1e5c43" : "#194d38") :
tone === "danger" ? (hovered ? "#523039" : theme.redSoft) :
tone === "accent" ? (hovered ? "#1d3c5e" : theme.accentSoft) :
Expand All @@ -32,11 +33,14 @@ Button {
tone === "accent" ? "#315f8d" :
(activeFocus ? theme.accent : hovered ? "#3a4a5b" : theme.line)
readonly property color activeText: !enabled ? theme.muted2 :
hovered ? "#142334" :
tone === "success" ? "#d6f4e6" :
tone === "danger" ? "#ffdce0" :
tone === "accent" ? "#dcebff" : theme.textSoft

contentItem: Row {
// The same item owns both the icon and text; no native Button
// hover glyph can appear independently.
spacing: control.iconSource.toString().length > 0 ? 7 : 0
anchors.centerIn: parent
Image {
Expand Down
4 changes: 2 additions & 2 deletions apps/arstack_studio/qml/ConfigurationHub.qml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,13 @@ Item {
font.weight: checked ? Font.DemiBold : Font.Medium
contentItem: Label {
text: parent.text
color: parent.checked ? hub.theme.text : hub.theme.muted
color: parent.hovered ? "#142334" : parent.checked ? hub.theme.text : hub.theme.muted
font: parent.font
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.checked ? hub.theme.raised : "transparent"
color: parent.hovered ? "#edf5ff" : parent.checked ? hub.theme.raised : "transparent"
radius: 6
border.width: parent.checked ? 1 : 0
border.color: hub.theme.line
Expand Down
51 changes: 37 additions & 14 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,9 @@ ApplicationWindow {
function markScenarioDirty() {
if (restoringScenario) return
saveScenarioTimer.restart()
// While STOPPED, a new edit belongs to the next supervised Start batch.
// Only a genuinely RUNNING board receives incremental live SETs.
if (!workflowBar.injectionRunning)
pendingApply = true
// All edits are a draft until the matching command is queued.
// Never label a visible draft as proven transmitted waveform data.
pendingApply = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve live-update eligibility after marking edits dirty

When injection is RUNNING, every non-cell editor still calls markScenarioDirty() before checking !pendingApply; this unconditional assignment therefore makes those checks permanently false. Frequency/waveform mode, CT shaping, channel enabled/quality, balanced/zero, and the group context-menu actions now change only the QML model while the physical SV stream continues using the old values, and Start cannot apply them until the operator stops injection. These handlers need the same RUNNING-aware queue behavior added to editSignal, rather than using the draft flag to suppress transmission.

AGENTS.md reference: AGENTS.md:L74-L76

Useful? React with 👍 / 👎.

}
function restoreLocalScenario() {
const saved = scenarioSettings.lastScenario
Expand Down Expand Up @@ -122,6 +121,7 @@ ApplicationWindow {
property string ptpDesiredRole: "SOURCE"
property bool ptpRestoreAfterFirmware: false
property int ptpRestoreChecks: 0
property bool ptpDefaultStartAttempted: false
property bool restoringScenario: false
property bool offlinePreviewRunning: false
property real currentScale: 1000.0
Expand Down Expand Up @@ -215,6 +215,23 @@ ApplicationWindow {
}
}

// Operator-friendly default: start SOURCE with the verified profile
// once the connected board is READY, never in a blind reconnect loop.
// A deliberate OFF cancels this default for the rest of the session.
Timer {
interval: 500
repeat: true
running: !root.ptpDefaultStartAttempted
onTriggered: {
const session = workflowBar.session
if (!session || !device.deviceVerified || !session.startReady ||
session.updatingFirmware || device.ptpStarting) return
root.ptpDefaultStartAttempted = true
if (!device.ptpRunning && !root.startPtpRole("SOURCE"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route Advanced PTP OFF through intent cancellation

The automatic start calls root.startPtpRole(), which records ptpDesiredForSession = true, but the Advanced PTP switch still stops directly through panel.session.requestStopPtp() rather than root.requestStopPtp(). If an operator turns PTP off there and subsequently performs a successful firmware update, onFirmwareUpdateFinished observes the stale desired flag and silently restarts SOURCE despite the explicit OFF action. All PTP OFF surfaces must clear the shared session intent before stopping.

AGENTS.md reference: AGENTS.md:L74-L76

Useful? React with 👍 / 👎.

root.showMessage("PTP could not start automatically; use PTP On to retry.", true)
}
}

Timer {
interval: 300
repeat: true
Expand Down Expand Up @@ -323,6 +340,7 @@ ApplicationWindow {
}

function requestStopPtp() {
root.ptpDefaultStartAttempted = true
root.ptpDesiredForSession = false
root.ptpRestoreAfterFirmware = false
return workflowBar.session && workflowBar.session.requestStopPtp()
Expand Down Expand Up @@ -467,9 +485,12 @@ ApplicationWindow {

selectSignal(group, row)
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) {
if (phaseLink && row < 3) sendLinkedGroup(group)
else sendSignal(group, row)
if (workflowBar.injectionRunning && workflowBar.session &&
workflowBar.session.liveControlReady) {
const queued = phaseLink && row < 3
? sendLinkedGroup(group) : sendSignal(group, row)
if (!queued) showMessage("Signal update not queued; retry or Stop/Start.", true)
else pendingApply = false
Comment on lines +492 to +493

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep older failed drafts pending after a later successful SET

A failed live update leaves pendingApply true, but any later successful signal update clears the global flag even if the earlier unsent edit was for another channel or for frequency/CT state. After Stop, WorkflowBar.requestStart() then sees no pending draft and calls requestStart() instead of requestStartWithScenario(), so the missed setpoint can remain unapplied while the footer claims synchronization. Clear this state only when all outstanding model changes are known to have queued successfully, or resend the complete scenario.

AGENTS.md reference: AGENTS.md:L74-L76

Useful? React with 👍 / 👎.

}
return true
}
Expand All @@ -487,6 +508,8 @@ ApplicationWindow {
return ok
}
function startScenarioSnapshot() {
// START always serializes the same ListModels that draw the table,
// including all eight current phase angles and enabled flags.
var draft = scenarioSnapshot()
draft.currentScale = currentScale
draft.voltageScale = voltageScale
Expand Down Expand Up @@ -698,10 +721,10 @@ ApplicationWindow {
}
Rectangle { width: 1; height: 12; color: studioTheme.lineSoft }
Label {
text: root.pendingApply && !workflowBar.injectionRunning
? "4I + 4V · Changes sent on Start"
text: root.pendingApply
? "4I + 4V · Setpoints pending · Start to apply"
: "4I + 4V · 4000 samples/s"
color: root.pendingApply && !workflowBar.injectionRunning
color: root.pendingApply
? studioTheme.amber : studioTheme.muted
font.family: root.uiFont
font.pixelSize: 9
Expand Down Expand Up @@ -1049,11 +1072,11 @@ ApplicationWindow {
}
}

RowLayout {
ColumnLayout {
Layout.fillWidth: true
Layout.preferredHeight: root.compactLayout ? 214 : 224
Layout.maximumHeight: root.compactLayout ? 214 : 224
spacing: 9
Layout.preferredHeight: root.compactLayout ? 352 : 384
Layout.maximumHeight: root.compactLayout ? 352 : 384
spacing: 7
SignalMatrix {
id: voltageMatrix
enabled: !workflowBar.startQueued
Expand Down
16 changes: 8 additions & 8 deletions apps/arstack_studio/qml/ModernRibbon.qml
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,21 @@ Rectangle {
spacing: 6

RowLayout {
Layout.preferredWidth: bar.compact ? 105 : 124
Layout.preferredWidth: bar.compact ? 152 : 182
Layout.alignment: Qt.AlignVCenter
spacing: 8

Rectangle {
width: 28
height: 28
radius: 7
width: 38
height: 38
radius: 9
color: "#0d151d"
border.width: 1
border.color: bar.theme.lineSoft
Image {
anchors.centerIn: parent
width: 15
height: 15
width: 25
height: 25
source: Qt.resolvedUrl("../assets/material/smv_injector.svg")
sourceSize.width: 30
sourceSize.height: 30
Expand All @@ -73,8 +73,8 @@ Rectangle {
text: "SMV Injector"
color: bar.theme.text
font.family: bar.uiFont
font.pixelSize: 11
font.weight: Font.DemiBold
font.pixelSize: 15
font.weight: Font.Bold
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
Expand Down
4 changes: 3 additions & 1 deletion apps/arstack_studio/qml/RibbonAction.qml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Button {
: tone === "accent" || checked ? "#e1f1ff" : theme.textSoft
contentItem: ColumnLayout {
spacing: 3
anchors.centerIn: parent
Image {
visible: control.iconSource.toString().length > 0
source: control.hovered && control.enabled ? control.hoverPath : control.iconSource
Expand All @@ -56,7 +57,8 @@ Button {
text: control.text
color: control.textColor
font: control.font
Layout.alignment: Qt.AlignHCenter
Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
Expand Down
4 changes: 3 additions & 1 deletion apps/arstack_studio/qml/StudioDarkCheckBox.qml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ CheckBox {
property string uiFont: "Inter"
implicitHeight: 30
spacing: 9
hoverEnabled: true
font.family: uiFont
font.pixelSize: 11
indicator: Rectangle {
Expand All @@ -19,7 +20,7 @@ CheckBox {
radius: 5
color: control.checked ? control.theme.accent : control.theme.surface2
border.width: 1
border.color: control.checked ? control.theme.accent
border.color: control.checked || control.hovered ? control.theme.accent
: control.activeFocus ? control.theme.textSoft : control.theme.line
Text {
anchors.centerIn: parent
Expand All @@ -31,6 +32,7 @@ CheckBox {
}
}
contentItem: Text {
horizontalAlignment: Text.AlignLeft
leftPadding: control.indicator.width + control.spacing
text: control.text
font: control.font
Expand Down
8 changes: 5 additions & 3 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,11 @@ SurfacePanel {
controller.showMessage(ribbon.startReason(), false)
return
}
const accepted = controller.pendingApply
? smartSession.requestStartWithScenario(controller.startScenarioSnapshot())
: smartSession.requestStart()
// 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())
if (!accepted) {
controller.pendingApply = true
controller.showMessage("Start rejected. Check device status.", true)
Expand Down
6 changes: 4 additions & 2 deletions apps/arstack_studio/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,10 @@ struct UiBounds {
return false;
}
}
if (!(voltage.area.right() <= current.area.left() + tolerance
&& tray.area.top() >= voltage.area.bottom() - tolerance
// Operator layout: voltage above current. Validate the new actual
// vertical hierarchy, not the obsolete side-by-side geometry.
if (!(voltage.area.bottom() <= current.area.top() + tolerance
&& tray.area.top() >= current.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
Expand Down
27 changes: 22 additions & 5 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,20 @@ def main() -> None:
require("Lab simulation controls" not in config
and "Signals, firmware, PTP and device settings" in config,
"Advanced must not imply this is only a simulated lab tool")
require("Changes sent on Start" in main_qml
and "root.pendingApply && !workflowBar.injectionRunning" in main_qml,
"STOPPED waveform edits must distinguish draft from sent setpoints")
require("Setpoints pending · Start to apply" in main_qml
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
and "ColumnLayout {" in main_qml,
"voltage and current must stack vertically to use the workspace")
require('property bool ptpDefaultStartAttempted: false' in main_qml
and 'root.ptpDefaultStartAttempted = true' in main_qml
and '!root.startPtpRole("SOURCE")' in main_qml,
"default PTP start must be one-shot and deliberate OFF must cancel it")
require("phaseLink && row < 3" in main_qml
and "sendLinkedGroup(group) : sendSignal(group, row)" in main_qml,
"edited 3-phase angles must be transmitted from the same table model")
controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8")
require("logUpdateTimer_.setInterval(100)" in controller
and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller,
Expand Down Expand Up @@ -81,6 +92,10 @@ def main() -> None:
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")
require('const accepted = smartSession.requestStartWithScenario(' in workflow
and 'controller.startScenarioSnapshot()' in workflow
and 'smartSession.requestStart()' not in workflow,
"every Start must send the visible 4I+4V table, never reuse stale device angles")
require("Qt.callLater(installDialog.open)" not in workflow
and "Qt.callLater(updateDialog.open)" not in workflow
and "if (!success) Qt.callLater(resultDialog.open)" in workflow
Expand Down Expand Up @@ -116,7 +131,8 @@ def main() -> None:
and 'enabled: !workflowBar.startQueued; onActivated: root.zeroAll()' in main_qml,
"scenario editors and shortcuts must be frozen during a pending START snapshot")
require("controller.pendingApply = true" in workflow
and "requestStartWithScenario(controller.startScenarioSnapshot())" in workflow
and "smartSession.requestStartWithScenario(" in workflow
and "controller.startScenarioSnapshot())" 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")
Expand Down Expand Up @@ -145,7 +161,8 @@ def main() -> None:
and 'toolTipText: "Advanced"' not in ribbon
and 'ToolTip.text: "smpSynch mode"' not in timing_ui,
"captioned operator controls must not duplicate labels as tooltips")
require('requestStartWithScenario(controller.startScenarioSnapshot())' in workflow
require('smartSession.requestStartWithScenario(' in workflow
and 'controller.startScenarioSnapshot())' 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
Expand Down
Loading