7d98759b43
- BatchExport worker + BatchExportController (threaded, busy flag) - SourcePanel: Export Maps button → FolderDialog → batch export with progress/summary banner (dismissible) - StreamControlPanel: name single export after loaded CSV + timestamp
383 lines
14 KiB
QML
383 lines
14 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Controls.impl
|
|
import QtQuick.Dialogs
|
|
import QtQuick.Layouts
|
|
import ISC
|
|
|
|
Rectangle {
|
|
id: root
|
|
Layout.fillWidth: true
|
|
implicitHeight: contentCol.implicitHeight + 24
|
|
|
|
color: Theme.sidePanelBackground
|
|
radius: Theme.sidePanelRadius
|
|
border.color: Theme.sideBorder
|
|
border.width: 1
|
|
|
|
signal exportRequested(string filePath, string extra)
|
|
|
|
property int _liveSecs: 0
|
|
Connections {
|
|
target: streamController
|
|
function onModeChanged() {
|
|
if (streamController.mode === "review" && modeBar.currentIndex !== 0) {
|
|
modeBar.currentIndex = 0;
|
|
}
|
|
}
|
|
}
|
|
Timer {
|
|
id: liveTimer
|
|
interval: 1000
|
|
repeat: true
|
|
running: streamController.mode === "live" && streamController.state !== "idle"
|
|
onTriggered: root._liveSecs++
|
|
onRunningChanged: if (!running) root._liveSecs = 0
|
|
}
|
|
|
|
function fmtTime(s) {
|
|
var m = Math.floor(s / 60);
|
|
var ss = s % 60;
|
|
return (m < 10 ? "0" : "") + m + ":" + (ss < 10 ? "0" : "") + ss;
|
|
}
|
|
|
|
function switchToReview() {
|
|
modeBar.currentIndex = 0;
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: contentCol
|
|
anchors {
|
|
fill: parent
|
|
topMargin: 10
|
|
leftMargin: 12
|
|
rightMargin: 12
|
|
bottomMargin: 12
|
|
}
|
|
spacing: 8
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
|
|
TabBar {
|
|
id: modeBar
|
|
currentIndex: 0
|
|
spacing: 2
|
|
padding: 2
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 28
|
|
background: Rectangle {
|
|
color: Theme.subtleSectionBackground
|
|
radius: Theme.radiusXs
|
|
border.color: Theme.cardBorder
|
|
border.width: 1
|
|
}
|
|
|
|
TabButton {
|
|
text: "Review"
|
|
Layout.fillWidth: true
|
|
implicitHeight: 24
|
|
font.pixelSize: Theme.fontMd
|
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
|
background: Rectangle {
|
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
|
radius: Theme.radiusXs - 1
|
|
}
|
|
contentItem: Text {
|
|
text: parent.text
|
|
color: parent.checked ? Theme.headingColor : Theme.bodyColor
|
|
font: parent.font
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
|
|
TabButton {
|
|
id: liveTab
|
|
text: "Live"
|
|
enabled: deviceController.connectionStatus === "Connected"
|
|
opacity: enabled ? 1.0 : 0.4
|
|
Layout.fillWidth: true
|
|
implicitHeight: 24
|
|
font.pixelSize: Theme.fontMd
|
|
font.weight: parent.checked ? Font.Medium : Font.Normal
|
|
|
|
AppToolTip {
|
|
visible: disabledHover.containsMouse && !liveTab.enabled
|
|
text: "Connect a wafer to enable Live mode"
|
|
delay: 300
|
|
}
|
|
|
|
MouseArea {
|
|
id: disabledHover
|
|
anchors.fill: parent
|
|
enabled: !liveTab.enabled
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.NoButton
|
|
}
|
|
|
|
background: Rectangle {
|
|
color: parent.checked ? Theme.tabActiveBackground : "transparent"
|
|
radius: Theme.radiusXs - 1
|
|
}
|
|
|
|
contentItem: Item {
|
|
Row {
|
|
spacing: 4
|
|
anchors.centerIn: parent
|
|
Rectangle {
|
|
id: liveTabDot
|
|
width: 5; height: 5; radius: 2.5
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: liveTab.enabled ? Theme.metricGood : Theme.sideMutedText
|
|
|
|
SequentialAnimation on opacity {
|
|
running: streamController.mode === "live" && streamController.state !== "idle"
|
|
loops: Animation.Infinite
|
|
NumberAnimation { to: 0.2; duration: 600; easing.type: Easing.InOutQuad }
|
|
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutQuad }
|
|
onRunningChanged: if (!running) liveTabDot.opacity = 1.0
|
|
}
|
|
}
|
|
Text {
|
|
text: liveTab.text
|
|
color: liveTab.checked ? Theme.headingColor : Theme.bodyColor
|
|
font: liveTab.font
|
|
verticalAlignment: Text.AlignVCenter
|
|
height: parent.height
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onCurrentIndexChanged: {
|
|
if (currentIndex === 1) {
|
|
if (deviceController.connectionStatus !== "Connected") {
|
|
currentIndex = 0;
|
|
return;
|
|
}
|
|
streamController.setMode("live");
|
|
var fc = "";
|
|
if (deviceController.lastWaferInfo && deviceController.lastWaferInfo.length > 0) {
|
|
fc = deviceController.lastWaferInfo[0];
|
|
}
|
|
streamController.startStream(deviceController.selectedPort, fc);
|
|
} else {
|
|
streamController.setMode("review");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: Theme.cardBorder
|
|
}
|
|
|
|
Label {
|
|
text: "METRICS"
|
|
color: Theme.sideMutedText
|
|
font.family: Theme.uiFontFamily
|
|
font.pixelSize: Theme.fontMd
|
|
font.letterSpacing: 1.5
|
|
font.bold: true
|
|
Layout.bottomMargin: 2
|
|
}
|
|
|
|
MetricRow {
|
|
label: "Received Frames"
|
|
value: modeBar.currentIndex === 1 ? streamController.receivedCount : "—"
|
|
}
|
|
|
|
MetricRow {
|
|
label: "Errors"
|
|
value: modeBar.currentIndex === 1 ? streamController.errorCount : "—"
|
|
valueColor: streamController.errorCount > 0 ? Theme.statusWarningColor : Theme.headingColor
|
|
}
|
|
|
|
MetricRow {
|
|
label: "Resyncs"
|
|
value: modeBar.currentIndex === 1 ? streamController.resyncCount : "—"
|
|
}
|
|
|
|
MetricRow {
|
|
label: "Elapsed"
|
|
value: modeBar.currentIndex === 1 ? root.fmtTime(root._liveSecs) : "—"
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: Theme.cardBorder
|
|
}
|
|
|
|
Label {
|
|
text: "ACTIONS"
|
|
color: Theme.sideMutedText
|
|
font.family: Theme.uiFontFamily
|
|
font.pixelSize: Theme.fontMd
|
|
font.letterSpacing: 1.5
|
|
font.bold: true
|
|
Layout.bottomMargin: 2
|
|
}
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 6
|
|
|
|
Button {
|
|
id: recordBtn
|
|
Layout.fillWidth: true
|
|
implicitHeight: 28
|
|
enabled: streamController.mode === "live"
|
|
opacity: enabled ? 1.0 : 0.4
|
|
text: streamController.recording ? "Stop REC" : "Record"
|
|
|
|
AppToolTip {
|
|
visible: recordBtn.hovered && !recordBtn.enabled
|
|
text: "Connect to Live to start recording"
|
|
delay: 300
|
|
}
|
|
|
|
onClicked: {
|
|
if (streamController.recording) {
|
|
streamController.stopRecording();
|
|
} else {
|
|
var info = deviceController.lastWaferInfo;
|
|
var serial = (info && info.length > 1) ? info[1] : "";
|
|
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss");
|
|
var name = "live_" + (serial ? serial + "_" : "") + ts + ".csv";
|
|
streamController.startRecording(deviceController.saveDataDir + "/" + name, serial);
|
|
}
|
|
}
|
|
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: recordBtn.down ? Theme.transportButtonHover
|
|
: (recordBtn.hovered ? Theme.transportButtonBg : "transparent")
|
|
border.width: Theme.borderThin
|
|
border.color: Theme.sideBorder
|
|
}
|
|
contentItem: Text {
|
|
text: recordBtn.text
|
|
color: Theme.headingColor
|
|
font.pixelSize: Theme.fontMd
|
|
font.weight: Font.Medium
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
|
|
Button {
|
|
id: exportBtn
|
|
Layout.fillWidth: true
|
|
implicitHeight: 28
|
|
text: "Export"
|
|
// Nothing to export until a file is loaded or a stream has played
|
|
enabled: streamController.sensorValues.length > 0
|
|
opacity: enabled ? 1 : 0.4
|
|
|
|
onClicked: {
|
|
// Default into <saveDataDir>/Export (created on demand),
|
|
// named after the loaded CSV so exports are traceable
|
|
// back to their source (falls back to a generic name for
|
|
// a live stream with no review file loaded).
|
|
var loaded = streamController.loadedFile
|
|
var baseName = loaded
|
|
? loaded.split("/").pop().replace(/\.[^.]+$/, "")
|
|
: "wafer_map"
|
|
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss")
|
|
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/" + baseName + "_" + ts + ".png"
|
|
exportDialog.open()
|
|
}
|
|
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: exportBtn.down ? Theme.transportButtonHover
|
|
: (exportBtn.hovered ? Theme.transportButtonBg : "transparent")
|
|
border.width: Theme.borderThin
|
|
border.color: Theme.sideBorder
|
|
}
|
|
contentItem: Text {
|
|
text: exportBtn.text
|
|
color: Theme.headingColor
|
|
font.pixelSize: Theme.fontMd
|
|
font.weight: Font.Medium
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
|
|
FileDialog {
|
|
id: exportDialog
|
|
title: "Export Wafer Map"
|
|
fileMode: FileDialog.SaveFile
|
|
defaultSuffix: "png"
|
|
nameFilters: ["PNG files (*.png)"]
|
|
onAccepted: {
|
|
var path = String(selectedFile).replace(/^file:\/\//, "");
|
|
var extra = "";
|
|
if (streamController.mode === "live") {
|
|
extra = "Frames: " + streamController.receivedCount
|
|
+ " Errors: " + streamController.errorCount
|
|
+ " Resyncs: " + streamController.resyncCount
|
|
+ " Elapsed: " + root.fmtTime(root._liveSecs);
|
|
}
|
|
root.exportRequested(path, extra);
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: Theme.cardBorder
|
|
}
|
|
|
|
Button {
|
|
id: primaryBtn
|
|
Layout.fillWidth: true
|
|
implicitHeight: 32
|
|
text: streamController.mode === "live" ? "STOP" : "START"
|
|
enabled: streamController.mode === "live"
|
|
? true
|
|
: deviceController.connectionStatus === "Connected"
|
|
opacity: enabled ? 1.0 : 0.4
|
|
|
|
AppToolTip {
|
|
visible: primaryBtn.hovered && !primaryBtn.enabled
|
|
text: "Connect a wafer to enable Live mode"
|
|
delay: 300
|
|
}
|
|
|
|
onClicked: {
|
|
if (streamController.mode === "live") {
|
|
streamController.stopStream();
|
|
modeBar.currentIndex = 0;
|
|
} else {
|
|
modeBar.currentIndex = 1;
|
|
}
|
|
}
|
|
|
|
background: Rectangle {
|
|
radius: Theme.radiusSm
|
|
color: primaryBtn.down ? Theme.transportButtonHover
|
|
: (primaryBtn.hovered ? Theme.transportButtonBg : "transparent")
|
|
border.width: Theme.borderThin
|
|
border.color: Theme.sideBorder
|
|
}
|
|
contentItem: Text {
|
|
text: primaryBtn.text
|
|
color: Theme.headingColor
|
|
font.pixelSize: Theme.fontMd
|
|
font.weight: Font.Medium
|
|
font.letterSpacing: 1.0
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
}
|
|
}
|
|
}
|
|
}
|