Compare commits
4 Commits
915720085b
...
d63332d619
| Author | SHA1 | Date | |
|---|---|---|---|
| d63332d619 | |||
| 88b0214582 | |||
| 0014ccc184 | |||
| 1989ba8e5a |
@@ -404,7 +404,7 @@ Rectangle {
|
||||
id: badgeLabel
|
||||
anchors.centerIn: parent
|
||||
text: deviceController.connectionStatus || "Disconnected"
|
||||
color: "#111111"
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.font2xs
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
import ISC.Wafer
|
||||
@@ -124,7 +125,7 @@ Item {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 38
|
||||
radius: 4
|
||||
color: Qt.rgba(1, 1, 1, 0.02)
|
||||
color: Theme.cardWash
|
||||
border.color: Theme.cardBorder
|
||||
border.width: 1
|
||||
|
||||
@@ -232,7 +233,7 @@ Item {
|
||||
Label {
|
||||
text: slotBox.filePath !== ""
|
||||
? root.shortName(slotBox.filePath)
|
||||
: (root.activeBox === slotBox.boxIndex ? "← Select file from left panel…" : "Click to select")
|
||||
: (root.activeBox === slotBox.boxIndex ? "<- Select file from left panel..." : "Click to select")
|
||||
font.pixelSize: Theme.fontXs
|
||||
color: slotBox.filePath !== "" ? Theme.headingColor : Theme.bodyColor
|
||||
opacity: slotBox.filePath !== "" ? 1.0 : 0.6
|
||||
@@ -248,14 +249,15 @@ Item {
|
||||
onClicked: slotBox.cleared()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
|
||||
color: parent.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: Label {
|
||||
text: "✕"
|
||||
contentItem: IconImage {
|
||||
source: "icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,44 +293,54 @@ Item {
|
||||
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: resetBtn.hovered ? Qt.rgba(1, 1, 1, 0.08) : "transparent"
|
||||
color: resetBtn.hovered ? Theme.flatButtonHover : "transparent"
|
||||
}
|
||||
contentItem: Label {
|
||||
text: "↺"
|
||||
font.pixelSize: Theme.fontLg
|
||||
contentItem: IconImage {
|
||||
source: "icons/rotate-ccw.svg"
|
||||
width: 18; height: 18
|
||||
sourceSize.width: 18
|
||||
sourceSize.height: 18
|
||||
color: Theme.bodyColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
ToolTip.visible: resetBtn.hovered
|
||||
ToolTip.text: "Reset comparison"
|
||||
}
|
||||
|
||||
Label {
|
||||
text: "Dynamic Time Warping Profile Matching"
|
||||
font.pixelSize: Theme.fontSm
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
|
||||
// ── Error Banner ───────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: errorLabel.implicitHeight + 16
|
||||
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||
visible: root.errorMessage !== ""
|
||||
color: Qt.rgba(0.93, 0.27, 0.27, 0.15)
|
||||
border.color: "#ef4444"
|
||||
color: Theme.errorSurface
|
||||
border.color: Theme.statusErrorColor
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
Label {
|
||||
id: errorLabel
|
||||
RowLayout {
|
||||
id: errorRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
text: "⚠ " + root.errorMessage
|
||||
color: "#fca5a5"
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
source: "icons/triangle-alert.svg"
|
||||
width: 16; height: 16
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.errorTextSoft
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
Label {
|
||||
id: errorLabel
|
||||
text: root.errorMessage
|
||||
color: Theme.errorTextSoft
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,9 +414,9 @@ Item {
|
||||
var plotH = height - padTop - padBottom
|
||||
var range = (maxV - minV) || 1
|
||||
|
||||
ctx.strokeStyle = Qt.rgba(1, 1, 1, 0.08)
|
||||
ctx.strokeStyle = Theme.chartGridLine
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = Qt.rgba(1, 1, 1, 0.35)
|
||||
ctx.fillStyle = Theme.chartAxisText
|
||||
ctx.font = "10px sans-serif"
|
||||
ctx.textAlign = "right"
|
||||
|
||||
@@ -507,10 +519,14 @@ Item {
|
||||
visible: !root.hasSeries
|
||||
spacing: 8
|
||||
|
||||
Label {
|
||||
IconImage {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: root.comparing ? "" : "📊"
|
||||
font.pixelSize: 32
|
||||
visible: !root.comparing
|
||||
source: "icons/bar-chart.svg"
|
||||
width: 32; height: 32
|
||||
sourceSize.width: 32
|
||||
sourceSize.height: 32
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
@@ -655,10 +671,13 @@ Item {
|
||||
anchors.centerIn: parent
|
||||
visible: root.compareSensorLayout.length === 0 || !enableOverlapCheck.checked
|
||||
spacing: 4
|
||||
Label {
|
||||
IconImage {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "🗺"
|
||||
font.pixelSize: 24
|
||||
source: "icons/map.svg"
|
||||
width: 24; height: 24
|
||||
sourceSize.width: 24
|
||||
sourceSize.height: 24
|
||||
color: Theme.sideMutedText
|
||||
}
|
||||
Label {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
@@ -864,7 +883,7 @@ Item {
|
||||
label: "DTW ALIGNMENT DISTANCE"
|
||||
value: root.warpingDistance >= 0 ? root.warpingDistance.toFixed(2) : "--"
|
||||
valueColor: root.warpingDistance >= 0
|
||||
? (root.warpingDistance < 50 ? "#6ee7b7" : root.warpingDistance < 100 ? "#fde047" : "#ef4444")
|
||||
? (root.warpingDistance < 50 ? Theme.metricGood : root.warpingDistance < 100 ? Theme.metricWarn : Theme.metricBad)
|
||||
: Theme.sideMutedText
|
||||
}
|
||||
ReadoutCard {
|
||||
@@ -878,7 +897,7 @@ Item {
|
||||
label: "MAX SENSOR DEVIATION"
|
||||
value: root.maxSensorDeviation >= 0 ? root.maxSensorDeviation.toFixed(2) + "°C" : "--"
|
||||
valueColor: root.maxSensorDeviation >= 0
|
||||
? (root.maxSensorDeviation < 1.0 ? "#6ee7b7" : root.maxSensorDeviation < 3.0 ? "#fde047" : "#ef4444")
|
||||
? (root.maxSensorDeviation < 1.0 ? Theme.metricGood : root.maxSensorDeviation < 3.0 ? Theme.metricWarn : Theme.metricBad)
|
||||
: Theme.sideMutedText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.qmlmodels
|
||||
import ISC
|
||||
@@ -181,10 +182,17 @@ Dialog {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
text: "✕"
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
onClicked: root.close()
|
||||
contentItem: IconImage {
|
||||
source: "icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.buttonNeutralText
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
@@ -123,19 +124,17 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
|
||||
Text {
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
text: "✓"
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
source: "icons/check.svg"
|
||||
width: 12; height: 12
|
||||
sourceSize.width: 12
|
||||
sourceSize.height: 12
|
||||
color: Theme.panelBackground
|
||||
opacity: toggle.checked ? 1.0 : 0.0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
// Small nudge to center the checkmark perfectly
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +365,7 @@ Item {
|
||||
width: 22
|
||||
height: 22
|
||||
radius: height / 2
|
||||
color: "white"
|
||||
color: Theme.toggleThumb
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Theme.isDarkMode ? parent.width - width - 3 : 3
|
||||
|
||||
@@ -452,12 +451,9 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Label {
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
text: "↓ Scroll for more"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
color: Theme.bodyColor
|
||||
spacing: 4
|
||||
opacity: (settingsScroll.ScrollBar.vertical.position <= 0 && settingsScroll.contentHeight > settingsScroll.height) ? (scrollHintTimer.running ? 1 : 0) : 0
|
||||
|
||||
Behavior on opacity {
|
||||
@@ -465,6 +461,22 @@ Item {
|
||||
duration: 400
|
||||
}
|
||||
}
|
||||
|
||||
IconImage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
source: "icons/chevron-down.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
Label {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Scroll for more"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
color: Theme.bodyColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Dialogs
|
||||
import ISC
|
||||
|
||||
@@ -211,7 +212,7 @@ ColumnLayout {
|
||||
id: statusPillText
|
||||
anchors.centerIn: parent
|
||||
text: root.isConnected ? "ACTIVE" : (root.isBusy ? deviceController.connectionStatus.replace("...", "").toUpperCase() : "INACTIVE")
|
||||
color: "#111111"
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.weight: Font.Bold
|
||||
font.family: Theme.uiFontFamily
|
||||
@@ -483,9 +484,12 @@ ColumnLayout {
|
||||
anchors.rightMargin: Theme.panelPadding
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: "📄"
|
||||
font.pixelSize: Theme.fontSm
|
||||
IconImage {
|
||||
source: "icons/file-text.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
visible: root.csvPath !== ""
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Dialogs
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
@@ -30,11 +31,23 @@ Item {
|
||||
// into the trend chart's data property. The slot parseJsonToData() is defined
|
||||
// on the Python TrendChartItem; we use it so malformed payloads are logged
|
||||
// there instead of crashing the QML binding.
|
||||
property string loadErrorMessage: ""
|
||||
Connections {
|
||||
target: streamController
|
||||
function onTrendData(avgsJson) {
|
||||
trendChart.setDataFromJson(avgsJson);
|
||||
}
|
||||
// A file was picked from the rail (any tab) — loadFile() switches the
|
||||
// backend to review mode; mirror that in the toolbar toggle so a
|
||||
// successful load is never hidden behind a stale "Live" selection.
|
||||
function onLoadedFileChanged() {
|
||||
root.loadErrorMessage = "";
|
||||
if (streamController.mode === "review")
|
||||
modeBar.currentIndex = 0;
|
||||
}
|
||||
function onLoadFileError(message) {
|
||||
root.loadErrorMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
@@ -118,12 +131,13 @@ Item {
|
||||
RowLayout {
|
||||
visible: streamController.mode === "live"
|
||||
spacing: 5
|
||||
// WiFi icon (Unicode approximation; swap for SVG if available)
|
||||
Label {
|
||||
// Live status dot (matches the connection-flow indicator dot elsewhere)
|
||||
Rectangle {
|
||||
id: liveIndicator
|
||||
text: "◉"
|
||||
width: 8; height: 8
|
||||
radius: 4
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle"
|
||||
@@ -250,6 +264,41 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load error banner ───────────────────────────────────────────
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: errorRow.implicitHeight + 16
|
||||
visible: root.loadErrorMessage !== ""
|
||||
color: Theme.errorSurface
|
||||
border.color: Theme.statusErrorColor
|
||||
border.width: 1
|
||||
radius: Theme.radiusSm
|
||||
|
||||
RowLayout {
|
||||
id: errorRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 8
|
||||
|
||||
IconImage {
|
||||
source: "icons/triangle-alert.svg"
|
||||
width: 16; height: 16
|
||||
sourceSize.width: 16
|
||||
sourceSize.height: 16
|
||||
color: Theme.errorTextSoft
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
|
||||
Label {
|
||||
text: root.loadErrorMessage
|
||||
color: Theme.errorTextSoft
|
||||
font.pixelSize: Theme.fontSm
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Body: 2 columns (wafer + trend + transport | readout) ──────────
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
@@ -32,18 +33,17 @@ ColumnLayout {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
|
||||
Text {
|
||||
IconImage {
|
||||
anchors.centerIn: parent
|
||||
text: "✓"
|
||||
font.pixelSize: Theme.fontSm
|
||||
font.bold: true
|
||||
source: "../icons/check.svg"
|
||||
width: 12; height: 12
|
||||
sourceSize.width: 12
|
||||
sourceSize.height: 12
|
||||
color: Theme.panelBackground
|
||||
opacity: toggle.checked ? 1.0 : 0.0
|
||||
Behavior on opacity {
|
||||
NumberAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ ColumnLayout {
|
||||
Item { Layout.fillWidth: true }
|
||||
Label {
|
||||
text: s.diff !== undefined ? s.diff : "—"
|
||||
color: Theme.isDarkMode ? "#A78BFA" : "#8B5CF6"
|
||||
color: Theme.diffAccent
|
||||
font.family: Theme.codeFontFamily
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.bold: true
|
||||
|
||||
@@ -10,6 +10,10 @@ Dialog {
|
||||
standardButtons: Dialog.NoButton
|
||||
width: 300
|
||||
|
||||
// Anchor to the top-left of the window instead of the default centered position.
|
||||
x: 24
|
||||
y: 24
|
||||
|
||||
// called by WaferMapView's TapHandler
|
||||
property int sensorIndex: -1
|
||||
property string sensorLabel: ""
|
||||
@@ -60,7 +64,7 @@ Dialog {
|
||||
id: overrideTag
|
||||
anchors.centerIn: parent
|
||||
text: "OVERRIDE"
|
||||
color: "white"
|
||||
color: Theme.statusBadgeText
|
||||
font.pixelSize: Theme.fontXs
|
||||
font.bold: true
|
||||
font.letterSpacing: 1
|
||||
|
||||
@@ -122,10 +122,12 @@ ColumnLayout {
|
||||
}
|
||||
contentItem: RowLayout {
|
||||
spacing: 6
|
||||
Label {
|
||||
text: "▤"
|
||||
IconImage {
|
||||
source: "../icons/folder.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
font.pixelSize: Theme.fontSm
|
||||
}
|
||||
Label {
|
||||
text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory
|
||||
@@ -204,7 +206,7 @@ ColumnLayout {
|
||||
onClicked: streamController.loadFile(modelData.fileName)
|
||||
|
||||
background: Rectangle {
|
||||
color: fileItem.isActive ? Qt.rgba(1, 1, 1, 0.06) : fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent"
|
||||
color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent"
|
||||
radius: Theme.radiusSm
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
@@ -225,11 +227,11 @@ ColumnLayout {
|
||||
color: {
|
||||
var t = modelData.waferType;
|
||||
if (t === "A" || t === "E" || t === "P")
|
||||
return "#3B82F6";
|
||||
return Theme.familyBlueAccent;
|
||||
if (t === "B" || t === "C" || t === "D")
|
||||
return "#10B981";
|
||||
return Theme.familyGreenAccent;
|
||||
if (t === "Z")
|
||||
return "#8B5CF6";
|
||||
return Theme.familyVioletAccent;
|
||||
return Theme.headingColor;
|
||||
}
|
||||
}
|
||||
@@ -249,19 +251,36 @@ ColumnLayout {
|
||||
color: {
|
||||
var t = modelData.waferType;
|
||||
if (t === "A" || t === "E" || t === "P")
|
||||
return "#1D4ED8";
|
||||
return Theme.familyBlueFill;
|
||||
if (t === "B" || t === "C" || t === "D")
|
||||
return "#065F46";
|
||||
return Theme.familyGreenFill;
|
||||
if (t === "Z")
|
||||
return "#7C3AED";
|
||||
return "#374151";
|
||||
return Theme.familyVioletFill;
|
||||
return Theme.familyNeutralFill;
|
||||
}
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: modelData.waferType || "?"
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
color: "#FFFFFF"
|
||||
color: Theme.textOnColor
|
||||
}
|
||||
|
||||
// Recording indicator dot — distinguishes a live-recorded CSV
|
||||
// (SessionController.startRecording) from a read-memory dump
|
||||
// (DeviceController.parseAndSaveData).
|
||||
Rectangle {
|
||||
visible: modelData.isRecording === true
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
color: Theme.recordColor
|
||||
border.color: Theme.subtleSectionBackground
|
||||
border.width: 1.5
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: -2
|
||||
anchors.rightMargin: -2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,14 +289,38 @@ ColumnLayout {
|
||||
spacing: 1
|
||||
Layout.fillWidth: true
|
||||
|
||||
// Primary: wafer type + serial number
|
||||
Label {
|
||||
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
elide: Text.ElideRight
|
||||
// Primary: wafer type + serial number, plus a REC badge for recordings
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName
|
||||
color: Theme.headingColor
|
||||
font.pixelSize: Theme.fontMd
|
||||
font.weight: Font.Bold
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: modelData.isRecording === true
|
||||
radius: 4
|
||||
color: Qt.alpha(Theme.recordColor, 0.18)
|
||||
implicitWidth: recLabel.implicitWidth + 10
|
||||
implicitHeight: recLabel.implicitHeight + 4
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
|
||||
Label {
|
||||
id: recLabel
|
||||
anchors.centerIn: parent
|
||||
text: "REC"
|
||||
color: Theme.recordColor
|
||||
font.pixelSize: Theme.font2xs
|
||||
font.bold: true
|
||||
font.letterSpacing: 0.6
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary: date · time
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import ISC
|
||||
|
||||
// ===== Stream Stats Dialog =====
|
||||
@@ -72,7 +73,6 @@ Popup {
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "✕"
|
||||
flat: true
|
||||
Layout.preferredWidth: 32
|
||||
Layout.preferredHeight: 32
|
||||
@@ -82,11 +82,13 @@ Popup {
|
||||
radius: Theme.radiusXs
|
||||
color: parent.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||
}
|
||||
contentItem: Label {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: "../icons/x.svg"
|
||||
width: 14; height: 14
|
||||
sourceSize.width: 14
|
||||
sourceSize.height: 14
|
||||
color: Theme.bodyColor
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Controls.impl
|
||||
import QtQuick.Layouts
|
||||
import ISC
|
||||
|
||||
@@ -177,8 +178,6 @@ Item {
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
text: "⏮"
|
||||
font.pixelSize: 24
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: skipStartBtn.pressed ? Theme.transportButtonHover
|
||||
@@ -188,12 +187,13 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: "../icons/prev.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: streamController.step(-1)
|
||||
}
|
||||
@@ -205,8 +205,6 @@ Item {
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
text: "■"
|
||||
font.pixelSize: 22
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: reviewStopBtn.pressed ? Theme.transportButtonHover
|
||||
@@ -216,12 +214,13 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: "../icons/stop.svg"
|
||||
width: 20; height: 20
|
||||
sourceSize.width: 20
|
||||
sourceSize.height: 20
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: streamController.stop()
|
||||
}
|
||||
@@ -233,8 +232,6 @@ Item {
|
||||
implicitWidth: 64
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
text: streamController.playing ? "⏸" : "▶"
|
||||
font.pixelSize: 24
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: playPauseBtn.pressed ? Theme.transportButtonHover
|
||||
@@ -244,13 +241,13 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: streamController.playing ? "../icons/pause.svg" : "../icons/play.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
leftPadding: parent.text === "▶" ? 4 : 0
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: {
|
||||
if (streamController.playing) {
|
||||
@@ -268,8 +265,6 @@ Item {
|
||||
implicitWidth: 56
|
||||
implicitHeight: 56
|
||||
hoverEnabled: true
|
||||
text: "⏭"
|
||||
font.pixelSize: 24
|
||||
background: Rectangle {
|
||||
radius: 8
|
||||
color: skipEndBtn.pressed ? Theme.transportButtonHover
|
||||
@@ -279,12 +274,13 @@ Item {
|
||||
ColorAnimation { duration: Theme.durationFast }
|
||||
}
|
||||
}
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
contentItem: IconImage {
|
||||
source: "../icons/next.svg"
|
||||
width: 22; height: 22
|
||||
sourceSize.width: 22
|
||||
sourceSize.height: 22
|
||||
color: Theme.headingColor
|
||||
font: parent.font
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
anchors.centerIn: parent
|
||||
}
|
||||
onClicked: streamController.step(1)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,22 @@ Item {
|
||||
highColor: Theme.sensorHigh
|
||||
textColor: Theme.headingColor
|
||||
|
||||
// Grows the hovered marker smoothly; hoverScale setter triggers a repaint.
|
||||
hoverScale: 1.0 + 0.35 * hoverPulse
|
||||
property real hoverPulse: map.hoveredIndex >= 0 ? 1.0 : 0.0
|
||||
Behavior on hoverPulse {
|
||||
NumberAnimation { duration: 150; easing.type: Easing.OutQuad }
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: hoverHandler
|
||||
onPointChanged: {
|
||||
map.hoveredIndex = map.which_marker(hoverHandler.point.position.x, hoverHandler.point.position.y);
|
||||
}
|
||||
onHoveredChanged: if (!hoverHandler.hovered) map.hoveredIndex = -1
|
||||
cursorShape: map.hoveredIndex >= 0 ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: ev => {
|
||||
var idx = map.which_marker(ev.position.x, ev.position.y);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m12 19-7-7 7-7" />
|
||||
<path d="M19 12H5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 262 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" x2="18" y1="20" y2="10" />
|
||||
<line x1="12" x2="12" y1="20" y2="4" />
|
||||
<line x1="6" x2="6" y1="20" y2="14" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M10 9H8" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 392 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 342 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="14" y="3" width="5" height="18" rx="1" />
|
||||
<rect x="6" y="3" width="5" height="18" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="6 3 20 12 6 21 6 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 250 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 297 B |
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -7,18 +7,23 @@ import QtQuick
|
||||
// =============================================================================
|
||||
// Sections:
|
||||
// 1. Mode flag
|
||||
// 2. Typography
|
||||
// 2. Typography (families, pixel sizes, weights)
|
||||
// 3. Motion
|
||||
// 4. Tone palette (raw tones; everything else derives from these)
|
||||
// 5. Surfaces (page / card / panel / workspace backgrounds)
|
||||
// 4. Tone palette (raw tones; everything else derives from these)
|
||||
// 5. Surfaces (page / card / panel / workspace backgrounds)
|
||||
// 6. Borders
|
||||
// 7. Text (headings, body, panel titles)
|
||||
// 8. Controls (fields, buttons, checkbox)
|
||||
// 9. Tracks (pill toggle + scrollbar)
|
||||
// 10. Tabs (footer tab bar)
|
||||
// 11. Side rail
|
||||
// 12. Status (success / warning / error)
|
||||
// 13. Geometry (radius / border width / spacing / sizing)
|
||||
// 7. Text (headings, body, panel titles)
|
||||
// 8. Controls (fields, buttons, checkbox, toggle)
|
||||
// 9. Interaction overlays (hover / active washes — mode-aware)
|
||||
// 10. Tracks (pill toggle + scrollbar)
|
||||
// 11. Tabs (mode toggle pills)
|
||||
// 12. Side rail
|
||||
// 13. Transport / toolbar
|
||||
// 14. Status & badges (success / warning / error, badge text)
|
||||
// 15. Sensor bands (wafer map dots)
|
||||
// 16. Wafer family badges (source list avatars + accent bars)
|
||||
// 17. Comparison & charts (metric grading, diff accent, canvas grid)
|
||||
// 18. Geometry (radius / border width / spacing / sizing)
|
||||
// =============================================================================
|
||||
// Based on Codex themes:
|
||||
// Dark (oscurange): surface #0B0B0F, ink #E6E6E6
|
||||
@@ -29,7 +34,10 @@ QtObject {
|
||||
// ── 1. Mode ──────────────────────────────────────────────────────────────
|
||||
property bool isDarkMode: true
|
||||
|
||||
// ── 2. Typography ──────────────────────────────────────────────────────
|
||||
// ── 2. Typography ────────────────────────────────────────────────────────
|
||||
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
|
||||
|
||||
// pixelSize scale — map to closest token, tune globally
|
||||
readonly property int font2xs: 9 // tiny metadata
|
||||
readonly property int fontXs: 11 // captions, micro labels
|
||||
@@ -44,22 +52,20 @@ QtObject {
|
||||
readonly property int fontWeightMedium: 500
|
||||
readonly property int fontWeightBold: 700
|
||||
|
||||
// ── 3. Motion ──────────────────────────────────────────────────────────
|
||||
// ── 3. Motion ────────────────────────────────────────────────────────────
|
||||
readonly property int durationFast: 120 // micro-interactions (focus ring, hover flash)
|
||||
readonly property int durationBase: 180 // standard transitions (pill switch, row select)
|
||||
readonly property int durationSlow: 260 // larger transitions (panel open/close)
|
||||
readonly property var easeStandard: Easing.OutCubic
|
||||
|
||||
// ── 4. Tone palette (Codex theme v1 inspired) ───────────────────────────
|
||||
readonly property string uiFontFamily: "Geist, Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif"
|
||||
readonly property string codeFontFamily: "\"Geist Mono\", ui-monospace, \"SFMono-Regular\", monospace"
|
||||
|
||||
// ── 4. Tone palette (Codex theme v1 inspired) ────────────────────────────
|
||||
readonly property color themeSurface: isDarkMode ? "#0B0B0F" : "#F9F9F7"
|
||||
readonly property color themeInk: isDarkMode ? "#E6E6E6" : "#2D2D2B"
|
||||
readonly property color themeAccent: isDarkMode ? "#F9B98C" : "#B87333"
|
||||
readonly property color themeSkill: isDarkMode ? "#479FFA" : "#3B82F6" // blue family both modes
|
||||
readonly property color themeAdded: isDarkMode ? "#40C977" : "#00C853"
|
||||
readonly property color themeRemoved: isDarkMode ? "#FA423E" : "#FF5F38"
|
||||
readonly property color themeViolet: isDarkMode ? "#A78BFA" : "#8B5CF6"
|
||||
|
||||
readonly property color tone100: themeSurface
|
||||
readonly property color tone150: isDarkMode ? "#101014" : "#F3F3F1"
|
||||
@@ -71,7 +77,7 @@ QtObject {
|
||||
readonly property color toneMute: isDarkMode ? "#A8A8A1" : "#85857E"
|
||||
readonly property color toneBorder: isDarkMode ? "#2D2D34" : "#D2D2CE"
|
||||
|
||||
// ── 3. Surfaces ──────────────────────────────────────────────────────────
|
||||
// ── 5. Surfaces ──────────────────────────────────────────────────────────
|
||||
readonly property color pageBackground: tone100
|
||||
readonly property color cardBackground: tone150
|
||||
readonly property color panelBackground: tone150
|
||||
@@ -79,7 +85,7 @@ QtObject {
|
||||
readonly property color responseBackground: tone150
|
||||
readonly property color subtleSectionBackground: tone250
|
||||
|
||||
// ── 4. Borders ───────────────────────────────────────────────────────────
|
||||
// ── 6. Borders ───────────────────────────────────────────────────────────
|
||||
readonly property color cardBorder: toneBorder
|
||||
readonly property color cardSurfaceBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||
readonly property color responseBorder: toneBorder
|
||||
@@ -88,14 +94,14 @@ QtObject {
|
||||
readonly property color outerFrameBorder: isDarkMode ? "#3A3A42" : "#C7C7C2"
|
||||
readonly property color softBorder: isDarkMode ? "#24242A" : "#E4E4E1"
|
||||
|
||||
// ── 5. Text ──────────────────────────────────────────────────────────────
|
||||
// ── 7. Text ──────────────────────────────────────────────────────────────
|
||||
readonly property color headingColor: toneText
|
||||
readonly property color bodyColor: toneMute
|
||||
readonly property color subheadingColor: toneMute
|
||||
readonly property color panelTitleText: toneMute
|
||||
readonly property color disabledText: toneMute
|
||||
|
||||
// ── 6. Controls ──────────────────────────────────────────────────────────
|
||||
// ── 8. Controls ──────────────────────────────────────────────────────────
|
||||
// Fields
|
||||
readonly property color fieldBackground: isDarkMode ? "#101014" : "#FFFFFF"
|
||||
readonly property color fieldText: toneText
|
||||
@@ -111,32 +117,48 @@ QtObject {
|
||||
readonly property color primaryAccent: themeAccent
|
||||
// Checkbox
|
||||
readonly property color checkboxText: toneText
|
||||
// Toggle switch thumb (white knob reads on both track colors)
|
||||
readonly property color toggleThumb: "#FFFFFF"
|
||||
|
||||
// ── 7. Tracks (pill toggle, scrollbar thumb) ─────────────────────────────
|
||||
// ── 9. Interaction overlays ──────────────────────────────────────────────
|
||||
// Mode-aware washes. Never use raw Qt.rgba(1,1,1,x) in views: a white
|
||||
// overlay is invisible on light-mode surfaces.
|
||||
readonly property color listHoverBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.04) : Qt.rgba(0, 0, 0, 0.03)
|
||||
readonly property color listActiveBackground: isDarkMode ? Qt.rgba(1, 1, 1, 0.06) : Qt.rgba(0, 0, 0, 0.05)
|
||||
readonly property color flatButtonHover: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.06)
|
||||
readonly property color cardWash: isDarkMode ? Qt.rgba(1, 1, 1, 0.02) : Qt.rgba(0, 0, 0, 0.02)
|
||||
|
||||
// ── 10. Tracks (pill toggle, scrollbar thumb) ────────────────────────────
|
||||
readonly property color trackBackground: isDarkMode ? "#25252B" : "#E2E2DE"
|
||||
|
||||
// ── 8. Tabs (mode toggle pills) ──────────────────────────────────────────
|
||||
// ── 11. Tabs (mode toggle pills) ─────────────────────────────────────────
|
||||
readonly property color tabActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||
|
||||
// ── 9. Side rail ─────────────────────────────────────────────────────────
|
||||
// ── 12. Side rail ────────────────────────────────────────────────────────
|
||||
readonly property color sideRailBackground: tone150
|
||||
readonly property color sidePanelBackground: isDarkMode ? "#151518" : "#F3F3F1"
|
||||
readonly property color sideActiveBackground: isDarkMode ? "#25252B" : "#FFFFFF"
|
||||
readonly property color sideBorder: toneBorder
|
||||
readonly property color sideMutedText: toneMute
|
||||
|
||||
// ── 10a. Transport / toolbar surfaces ────────────────────────────────────
|
||||
// ── 13. Transport / toolbar surfaces ─────────────────────────────────────
|
||||
readonly property color transportButtonBg: tone250
|
||||
readonly property color transportButtonHover: tone300
|
||||
readonly property color liveColor: themeAdded
|
||||
readonly property color recordColor: themeRemoved
|
||||
|
||||
// ── 10. Status ───────────────────────────────────────────────────────────
|
||||
// ── 14. Status & badges ──────────────────────────────────────────────────
|
||||
readonly property color statusSuccessColor: themeAdded
|
||||
readonly property color statusWarningColor: isDarkMode ? "#E8A817" : "#B8860B" // amber/yellow, separate from accent
|
||||
readonly property color statusErrorColor: themeRemoved
|
||||
// Text placed ON a filled status pill / saturated chip:
|
||||
readonly property color statusBadgeText: "#111111" // dark ink on bright status fills, both modes
|
||||
readonly property color textOnColor: "#FFFFFF" // white text on deep saturated fills (avatars etc.)
|
||||
// Error banner (soft red surface + readable red text)
|
||||
readonly property color errorSurface: Qt.alpha(statusErrorColor, 0.15)
|
||||
readonly property color errorTextSoft: isDarkMode ? "#FCA5A5" : "#B91C1C"
|
||||
|
||||
// -- 10b. Sensor bands (wafer map dots)
|
||||
// ── 15. Sensor bands (wafer map dots) ────────────────────────────────────
|
||||
readonly property color sensorInRange: statusSuccessColor
|
||||
// ponytail: high-temp shares error red — semantically correct (dangerous value),
|
||||
// re-hue if colorblind testing demands a different channel.
|
||||
@@ -145,7 +167,31 @@ QtObject {
|
||||
readonly property color waferRingColor: toneBorder
|
||||
readonly property color waferAxisColor: softBorder
|
||||
|
||||
// ── 11. Geometry ─────────────────────────────────────────────────────────
|
||||
// ── 16. Wafer family badges (SourcePanel list) ───────────────────────────
|
||||
// Accent = left bar / outline shade; Fill = avatar circle behind textOnColor.
|
||||
// Family grouping mirrors FileBrowser's waferType first-letter convention.
|
||||
readonly property color familyBlueAccent: "#3B82F6" // A / E / P
|
||||
readonly property color familyBlueFill: "#1D4ED8"
|
||||
readonly property color familyGreenAccent: "#10B981" // B / C / D
|
||||
readonly property color familyGreenFill: "#065F46"
|
||||
readonly property color familyVioletAccent: "#8B5CF6" // Z
|
||||
readonly property color familyVioletFill: "#7C3AED"
|
||||
readonly property color familyNeutralFill: "#374151" // unknown family
|
||||
|
||||
// ── 17. Comparison & charts ──────────────────────────────────────────────
|
||||
// Metric grading for DTW readout cards (good → caution → bad).
|
||||
// Dark values are pastel (readable on tone150); light values are deepened
|
||||
// so they still pass on near-white cards.
|
||||
readonly property color metricGood: isDarkMode ? "#6EE7B7" : "#059669"
|
||||
readonly property color metricWarn: isDarkMode ? "#FDE047" : "#B45309"
|
||||
readonly property color metricBad: isDarkMode ? "#EF4444" : "#DC2626"
|
||||
// DIFF column accent in ReadoutPanel
|
||||
readonly property color diffAccent: themeViolet
|
||||
// Canvas chart primitives (DTW plot grid + axis labels)
|
||||
readonly property color chartGridLine: isDarkMode ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0, 0, 0, 0.10)
|
||||
readonly property color chartAxisText: isDarkMode ? Qt.rgba(1, 1, 1, 0.35) : Qt.rgba(0, 0, 0, 0.45)
|
||||
|
||||
// ── 18. Geometry ─────────────────────────────────────────────────────────
|
||||
// Radius
|
||||
readonly property int radiusXs: 6 // fields, tight elements
|
||||
readonly property int radiusSm: 8 // buttons
|
||||
|
||||
@@ -35,6 +35,7 @@ class SessionController(QObject):
|
||||
recordingChanged = Signal()
|
||||
sensorsChanged = Signal()
|
||||
loadedFileChanged = Signal()
|
||||
loadFileError = Signal(str) # emitted when loadFile() fails, so QML can show why
|
||||
clusterAveragingEnabledChanged = Signal()
|
||||
settingsChanged = Signal() # emitted when session state changes that should be persisted
|
||||
liveStatsChanged = Signal() # emitted when receivedCount or errorCount change
|
||||
@@ -275,17 +276,30 @@ class SessionController(QObject):
|
||||
try:
|
||||
data, _ = ZWaferParser().parse(file_path)
|
||||
except (ValueError, KeyError) as exc:
|
||||
log.warning("Could not parse %s: %s", file_path, exc)
|
||||
msg = f"Could not parse {Path(file_path).name}: {exc}"
|
||||
log.warning("%s", msg)
|
||||
self.loadFileError.emit(msg)
|
||||
return
|
||||
if data is None or not data.sensors:
|
||||
log.warning("Could not parse %s", file_path)
|
||||
msg = f"Could not parse {Path(file_path).name}"
|
||||
log.warning("%s", msg)
|
||||
self.loadFileError.emit(msg)
|
||||
return
|
||||
records = read_data_records(file_path)
|
||||
sensors = data.sensors
|
||||
frames = frames_from_wafer_data(data, records)
|
||||
|
||||
if not sensors or not frames:
|
||||
log.warning("No sensors or data in %s", file_path)
|
||||
if not sensors:
|
||||
msg = f"No sensor layout found in {Path(file_path).name}"
|
||||
log.warning("%s", msg)
|
||||
self.loadFileError.emit(msg)
|
||||
return
|
||||
if not frames:
|
||||
# A well-formed header with zero data rows below it — most commonly
|
||||
# a live recording that was stopped before any frames arrived.
|
||||
msg = f"{Path(file_path).name} has no recorded frames"
|
||||
log.warning("%s", msg)
|
||||
self.loadFileError.emit(msg)
|
||||
return
|
||||
|
||||
wafer_id = ""
|
||||
@@ -304,6 +318,10 @@ class SessionController(QObject):
|
||||
self._model.reset()
|
||||
self._stats_tracker.reset()
|
||||
self._loaded_file = file_path
|
||||
# A file was explicitly picked for review — show it regardless of
|
||||
# whichever mode (e.g. Live) was previously active, otherwise a
|
||||
# successful load can be invisible to the user.
|
||||
self.setMode(MODE_REVIEW)
|
||||
self.loadedFileChanged.emit()
|
||||
self.sensorsChanged.emit()
|
||||
self._emit_current()
|
||||
@@ -330,10 +348,48 @@ class SessionController(QObject):
|
||||
@slot_error_boundary
|
||||
def compareFiles(self, file_a: str, file_b: str) -> None:
|
||||
"""Run DTW comparison between two CSV files and emit result."""
|
||||
from pathlib import Path
|
||||
|
||||
from pygui.backend.comparison import compare_runs
|
||||
from pygui.backend.data.data_records import is_official_csv, read_data_records, read_official_csv
|
||||
|
||||
try:
|
||||
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
|
||||
# startRecording (see FileBrowser.isRecording), distinct from the
|
||||
# "<serial>-<timestamp>.csv" convention used for read-memory dumps.
|
||||
# Comparing across those categories doesn't make sense (a live
|
||||
# capture vs. a full memory readout aren't the same kind of run),
|
||||
# so gate on that before even looking at wafer type.
|
||||
stem_a, stem_b = Path(file_a).stem, Path(file_b).stem
|
||||
is_recording_a = stem_a.lower().startswith("live_")
|
||||
is_recording_b = stem_b.lower().startswith("live_")
|
||||
if is_recording_a != is_recording_b:
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": "Cannot compare a recording file with a read-memory file"
|
||||
})
|
||||
return
|
||||
|
||||
# Gate on wafer type (family): comparing a P-wafer against an X-wafer
|
||||
# (different sensor counts/layouts) would silently truncate the
|
||||
# smaller sensor set and pair up unrelated physical sensors in the
|
||||
# overlap view. Same convention as FileBrowser's waferType badge —
|
||||
# first character of the filename stem, minus the "live_" prefix
|
||||
# so the wafer letter is read from the serial, not the "L".
|
||||
def _wafer_type(stem: str, is_recording: bool) -> str:
|
||||
if is_recording:
|
||||
stem = stem[len("live_"):]
|
||||
return stem[0].upper() if stem else ""
|
||||
|
||||
type_a = _wafer_type(stem_a, is_recording_a)
|
||||
type_b = _wafer_type(stem_b, is_recording_b)
|
||||
if type_a and type_b and type_a != type_b:
|
||||
self.comparisonResult.emit({
|
||||
"success": False,
|
||||
"error": f"Cannot compare different wafer types: {type_a} vs {type_b}"
|
||||
})
|
||||
return
|
||||
|
||||
if is_official_csv(file_a):
|
||||
_, recs_a = read_official_csv(file_a)
|
||||
else:
|
||||
@@ -350,8 +406,10 @@ class SessionController(QObject):
|
||||
})
|
||||
return
|
||||
|
||||
# Use first 100 frames of each for comparison (avoid O(n²) blowup)
|
||||
max_frames = min(100, len(recs_a), len(recs_b))
|
||||
# Compare the full overlapping length of both runs. DTW's O(n*m) cost
|
||||
# matrix is cheap even at a few thousand frames (~26MB at 1800x1800),
|
||||
# so only cap against pathologically long files, not typical runs.
|
||||
max_frames = min(5000, len(recs_a), len(recs_b))
|
||||
series_a = [recs_a[i].values[0] if recs_a[i].values else 0.0
|
||||
for i in range(max_frames)]
|
||||
series_b = [recs_b[i].values[0] if recs_b[i].values else 0.0
|
||||
@@ -380,10 +438,8 @@ class SessionController(QObject):
|
||||
max_sensor_deviation = diff
|
||||
|
||||
# Sensor layout + per-sensor diff for the wafer overlap view. Reuses the
|
||||
# same sensor-parsing branch as loadFile() (file_a is assumed representative
|
||||
# of both files' wafer type).
|
||||
from pathlib import Path
|
||||
|
||||
# same sensor-parsing branch as loadFile(); file_a and file_b are now
|
||||
# guaranteed to share a wafer type by the gate above.
|
||||
from pygui.backend.data.data_records import is_official_csv, read_official_csv
|
||||
from pygui.backend.wafer.wafer_layouts import resolve_shape_and_size
|
||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||
|
||||
@@ -119,6 +119,11 @@ class FileBrowser(QObject):
|
||||
return
|
||||
|
||||
for csv_path in sorted(self._current_directory.glob("*.csv")):
|
||||
# Recorded-live CSVs are named "live_<serial>_<timestamp>.csv" by
|
||||
# SessionController.startRecording (see WaferMapTab's Record button),
|
||||
# distinct from the "<serial>-<timestamp>.csv" convention used by
|
||||
# DeviceController.parseAndSaveData for read-memory dumps.
|
||||
is_recording = csv_path.stem.lower().startswith("live_")
|
||||
try:
|
||||
metadata = self._load_metadata(csv_path)
|
||||
wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "")
|
||||
@@ -146,6 +151,7 @@ class FileBrowser(QObject):
|
||||
"masterType": master_state.get(str(csv_path), "") or metadata.master_type,
|
||||
"fileName": str(csv_path),
|
||||
"highlight": wafer_type in {"A", "B", "C"},
|
||||
"isRecording": is_recording,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
@@ -169,6 +175,7 @@ class FileBrowser(QObject):
|
||||
"masterType": master_state.get(str(csv_path), ""),
|
||||
"fileName": str(csv_path),
|
||||
"highlight": False,
|
||||
"isRecording": is_recording,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
sizeChanged = Signal()
|
||||
thicknessChanged = Signal()
|
||||
showThicknessChanged = Signal()
|
||||
hoveredIndexChanged = Signal()
|
||||
hoverScaleChanged = Signal()
|
||||
|
||||
|
||||
def __init__(self, parent=None):
|
||||
@@ -68,6 +70,10 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self._thickness_data: list[float] = []
|
||||
self._show_thickness: bool = False
|
||||
self._thickness_heatmap:QImage | None = None
|
||||
# Hover highlight: index of the marker under the pointer (-1 = none)
|
||||
# and a 1.0->~1.5 grow factor driven by a QML Behavior for a smooth animation.
|
||||
self._hovered_index: int = -1
|
||||
self._hover_scale: float = 1.0
|
||||
|
||||
# Dark-theme color defaults (match Theme.qml tokens)
|
||||
self._ring_color = QColor("#2A3441") # waferRingColor (toneBorder)
|
||||
@@ -81,6 +87,12 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self._markers: dict[int, tuple[int, int]] = {} # sensor index → (px, py)
|
||||
self._marker_r: int = 4
|
||||
self._heatmap: QImage | None = None
|
||||
# Size the markers/heatmap were last computed against. On first activation
|
||||
# (e.g. a freshly-created Loader item) width()/height() can still be 0/stale
|
||||
# when `sensors` is first set, computing degenerate marker positions that
|
||||
# never get retried. paint() re-checks this every frame so geometry that
|
||||
# settles after property assignment still gets picked up.
|
||||
self._last_draw_size: int = -1
|
||||
|
||||
self.widthChanged.connect(self._on_resize)
|
||||
self.heightChanged.connect(self._on_resize)
|
||||
@@ -213,6 +225,29 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self.showThicknessChanged.emit()
|
||||
self.update()
|
||||
|
||||
@Property(int, notify=hoveredIndexChanged)
|
||||
def hoveredIndex(self) -> int:
|
||||
return self._hovered_index
|
||||
|
||||
@hoveredIndex.setter
|
||||
def hoveredIndex(self, val: int) -> None:
|
||||
val = int(val)
|
||||
if val == self._hovered_index:
|
||||
return
|
||||
self._hovered_index = val
|
||||
self.hoveredIndexChanged.emit()
|
||||
self.update()
|
||||
|
||||
@Property(float, notify=hoverScaleChanged)
|
||||
def hoverScale(self) -> float:
|
||||
return self._hover_scale
|
||||
|
||||
@hoverScale.setter
|
||||
def hoverScale(self, val: float) -> None:
|
||||
self._hover_scale = float(val)
|
||||
self.hoverScaleChanged.emit()
|
||||
self.update()
|
||||
|
||||
# Colour properties — QML can bind these to Theme tokens
|
||||
@Property(QColor, notify=colorsChanged)
|
||||
def ringColor(self) -> QColor: return self._ring_color
|
||||
@@ -440,7 +475,15 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
# ── paint ─────────────────────────────────────────────────────────────
|
||||
|
||||
def paint(self, painter: QPainter) -> None:
|
||||
ds = self._draw_size()
|
||||
ds = self._draw_size()
|
||||
if ds != self._last_draw_size:
|
||||
# Geometry settled (or changed) since markers/heatmap were last built
|
||||
# against a stale size — recompute now, at actual paint time.
|
||||
self._last_draw_size = ds
|
||||
self._compute_markers()
|
||||
self._rebuild_heatmap()
|
||||
self._rebuild_thickness()
|
||||
|
||||
r_px = int(ds / 2 - 4)
|
||||
cx, cy = self._center()
|
||||
|
||||
@@ -544,10 +587,22 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
self._bands[i] if i < len(self._bands) else "in_range",
|
||||
self._in_range_color,
|
||||
)
|
||||
is_hovered = i == self._hovered_index
|
||||
marker_r = int(round(r * self._hover_scale)) if is_hovered else r
|
||||
|
||||
if is_hovered:
|
||||
# Soft glow ring behind the marker, growing with hoverScale.
|
||||
glow_r = int(round(marker_r * 1.9))
|
||||
glow_color = QColor(color)
|
||||
glow_color.setAlpha(70)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QBrush(glow_color))
|
||||
painter.drawEllipse(px - glow_r, py - glow_r, 2 * glow_r, 2 * glow_r)
|
||||
|
||||
# Filled circle with thin dark outline for contrast over heatmap
|
||||
painter.setPen(QPen(QColor(0, 0, 0, 100), 1))
|
||||
painter.setBrush(QBrush(color))
|
||||
painter.drawEllipse(px - r, py - r, 2 * r, 2 * r)
|
||||
painter.drawEllipse(px - marker_r, py - marker_r, 2 * marker_r, 2 * marker_r)
|
||||
|
||||
if self._show_labels:
|
||||
has_temp = i < len(self._values)
|
||||
@@ -557,7 +612,6 @@ class WaferMapItem(QQuickPaintedItem):
|
||||
ox = getattr(s, "offset_x", 0.0) * r
|
||||
oy = getattr(s, "offset_y", 0.0) * r
|
||||
|
||||
# Pre-compute metrics using current scaled fonts
|
||||
painter.setFont(id_font)
|
||||
id_fm = painter.fontMetrics()
|
||||
id_line_h = id_fm.height()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pygui.backend.data.file_browser import FileBrowser
|
||||
|
||||
|
||||
def _write_csv(path, wafer_id="P00001"):
|
||||
path.write_text(
|
||||
f"Wafer ID={wafer_id}\n"
|
||||
"Acquisition Date=03/26/2025\n"
|
||||
"Label,1,2,3\n"
|
||||
"X (mm),0,10,-10\n"
|
||||
"Y (mm),10,-10,-10\n"
|
||||
"data\n"
|
||||
"0.0,149.0,148.5,150.6\n"
|
||||
)
|
||||
|
||||
|
||||
def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
|
||||
"""Files named live_<serial>_<timestamp>.csv (SessionController.startRecording)
|
||||
must be distinguishable in the browser from read-memory dumps."""
|
||||
_write_csv(tmp_path / "live_P00001_20260706_143022.csv")
|
||||
_write_csv(tmp_path / "P00002-20260706_143500.csv")
|
||||
|
||||
browser = FileBrowser()
|
||||
browser._set_current_directory(tmp_path)
|
||||
browser.refreshFiles()
|
||||
|
||||
files_by_name = {Path(row["fileName"]).name: row for row in browser.files}
|
||||
assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True
|
||||
assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False
|
||||
@@ -47,6 +47,36 @@ def test_pause_stop_timer(controller):
|
||||
controller.stop()
|
||||
assert controller.frameIndex == 0
|
||||
|
||||
def test_load_file_emits_error_for_empty_recording(controller, tmp_path):
|
||||
"""A recording with a valid header but zero data rows (e.g. Record was
|
||||
started/stopped without any live frames arriving) must surface a clear
|
||||
loadFileError instead of silently doing nothing."""
|
||||
empty_recording = tmp_path / "live_A00001_20260706_120427.csv"
|
||||
empty_recording.write_text(
|
||||
"Wafer ID=A00001\n"
|
||||
"Acquisition Date=07/06/2026\n"
|
||||
"Label,1,2\n"
|
||||
"X (mm),0.0,10.0\n"
|
||||
"Y (mm),10.0,-10.0\n"
|
||||
"data\n"
|
||||
)
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.loadFileError.connect(mock_slot)
|
||||
controller.loadFile(str(empty_recording))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
assert "no recorded frames" in mock_slot.call_args[0][0]
|
||||
assert controller.loadedFile == ""
|
||||
|
||||
def test_load_file_switches_to_review_mode(controller):
|
||||
"""Loading a file for playback must always land in review mode, even if
|
||||
the controller was left in live mode from a previous session — otherwise
|
||||
a successful load can be invisible behind a stale Live toggle."""
|
||||
controller._mode = "live"
|
||||
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||
assert controller.mode == "review"
|
||||
|
||||
def test_compare_files_integration(controller):
|
||||
# Mock the comparisonResult signal
|
||||
mock_slot = MagicMock()
|
||||
@@ -65,6 +95,96 @@ def test_compare_files_integration(controller):
|
||||
assert "series_b" in args
|
||||
assert args["frame_offset"] == 0
|
||||
|
||||
def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path):
|
||||
"""compareFiles must score the whole run, not just an initial 100-frame window.
|
||||
|
||||
Regression test: max_frames used to be hard-capped at 100, so a >100-frame
|
||||
run's soak/cool phases were silently excluded from the DTW comparison.
|
||||
"""
|
||||
num_frames = 150
|
||||
csv_path = tmp_path / "long_run.csv"
|
||||
lines = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data"]
|
||||
for i in range(num_frames):
|
||||
t = i * 0.5
|
||||
lines.append(f"{t},{149.0 + i * 0.01},{148.5 + i * 0.01},{150.6 + i * 0.01}")
|
||||
csv_path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(csv_path), str(csv_path))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is True
|
||||
assert len(result["series_a"]) == num_frames
|
||||
assert len(result["series_b"]) == num_frames
|
||||
|
||||
def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path):
|
||||
"""compareFiles must refuse to compare two different wafer families.
|
||||
|
||||
Otherwise the overlap view silently truncates to min(sensor_count) and
|
||||
pairs up unrelated physical sensors between the two wafer types.
|
||||
"""
|
||||
header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
file_a = tmp_path / "A00055-20250326.csv"
|
||||
file_b = tmp_path / "X00108-20250326.csv"
|
||||
file_a.write_text("\n".join(header) + "\n")
|
||||
file_b.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(file_a), str(file_b))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is False
|
||||
assert "A" in result["error"] and "X" in result["error"]
|
||||
|
||||
def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path):
|
||||
"""A live_-prefixed recording must not be compared against a read-memory
|
||||
dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be
|
||||
misreported as a wafer-type mismatch ("L vs P") instead of this clearer
|
||||
recording-vs-read-mem error.
|
||||
"""
|
||||
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
recording = tmp_path / "live_P0001_20260706_143022.csv"
|
||||
read_mem = tmp_path / "P0001-20260706_143500.csv"
|
||||
recording.write_text("\n".join(header) + "\n")
|
||||
read_mem.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(recording), str(read_mem))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "Cannot compare a recording file with a read-memory file"
|
||||
|
||||
def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path):
|
||||
"""Two live recordings of the same wafer family should compare fine —
|
||||
the "live_" prefix must not be mistaken for the wafer type letter."""
|
||||
header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3",
|
||||
"X (mm),0,10,-10", "Y (mm),10,-10,-10", "data",
|
||||
"0.0,149.0,148.5,150.6"]
|
||||
file_a = tmp_path / "live_P0001_20260706_143022.csv"
|
||||
file_b = tmp_path / "live_P0002_20260706_150000.csv"
|
||||
file_a.write_text("\n".join(header) + "\n")
|
||||
file_b.write_text("\n".join(header) + "\n")
|
||||
|
||||
mock_slot = MagicMock()
|
||||
controller.comparisonResult.connect(mock_slot)
|
||||
controller.compareFiles(str(file_a), str(file_b))
|
||||
|
||||
assert mock_slot.call_count == 1
|
||||
result = mock_slot.call_args[0][0]
|
||||
assert result["success"] is True
|
||||
|
||||
def test_comparison_result_reaches_qml(qapp, controller):
|
||||
"""The result dict must arrive in QML as a real JS object with fields.
|
||||
|
||||
|
||||