feat: redesign StatusTab UI with bento-grid layout and add CSV metadata editing capabilities
This commit is contained in:
+153
-103
@@ -28,15 +28,28 @@ Rectangle {
|
|||||||
|
|
||||||
property bool memoryRead: false
|
property bool memoryRead: false
|
||||||
property bool waferDetected: false
|
property bool waferDetected: false
|
||||||
|
// TODO P2.2: add `property bool waferLicensed: false`; set it in
|
||||||
|
// onDetectResult below via deviceController.waferLicensed() (slot from
|
||||||
|
// P1.2 — the detectResult payload is opaque in QML, can't read serial
|
||||||
|
// from it). Then READ/ERASE buttons get
|
||||||
|
// `enabled: root.waferDetected && root.waferLicensed`.
|
||||||
|
// See docs/pending/license-gating-plan.md §2.2.
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: deviceController
|
target: deviceController
|
||||||
function onDetectResult(result){
|
function onDetectResult(result){
|
||||||
root.waferDetected = (result != null && result != undefined)
|
// detectResult payload is Signal(object): a dict may cross into QML
|
||||||
|
// as an opaque wrapper whose fields read as undefined, so only
|
||||||
|
// test truthiness (None on failure, dict on success).
|
||||||
|
root.waferDetected = !!result
|
||||||
}
|
}
|
||||||
function onParsedDataReady(result) {
|
function onParsedDataReady(result) {
|
||||||
if (result.success && result.csv_path) {
|
if (result.success && result.csv_path) {
|
||||||
streamController.loadFile(result.csv_path)
|
streamController.loadFile(result.csv_path)
|
||||||
|
// TODO P3.1: only auto-jump to Map when unlocked:
|
||||||
|
// `&& licenseModel.licenses.length > 0` — otherwise a
|
||||||
|
// successful read walks straight into the locked tab.
|
||||||
|
// See plan §3.1.
|
||||||
if (root.selectedTabIndex === 0) {
|
if (root.selectedTabIndex === 0) {
|
||||||
root.selectedTabIndex = 2
|
root.selectedTabIndex = 2
|
||||||
}
|
}
|
||||||
@@ -63,6 +76,7 @@ Rectangle {
|
|||||||
|
|
||||||
function _doDetect() {
|
function _doDetect() {
|
||||||
root.memoryRead = false
|
root.memoryRead = false
|
||||||
|
root.waferDetected = false
|
||||||
streamController.setMode("review")
|
streamController.setMode("review")
|
||||||
streamController.stopStream()
|
streamController.stopStream()
|
||||||
deviceController.detectWafer()
|
deviceController.detectWafer()
|
||||||
@@ -213,6 +227,15 @@ Rectangle {
|
|||||||
model: pillModel
|
model: pillModel
|
||||||
delegate: Button {
|
delegate: Button {
|
||||||
id: pillBtn
|
id: pillBtn
|
||||||
|
// TODO P3.1: lock MAP pill (index 2) when no license:
|
||||||
|
// `enabled: index !== 2 || licenseModel.licenses.length > 0`
|
||||||
|
// and dim icon/label when disabled. licensesChanged
|
||||||
|
// notify already fires on Load License → unlocks live,
|
||||||
|
// no restart. Rule is "any valid license" (not
|
||||||
|
// per-wafer): Map also replays CSVs with no wafer
|
||||||
|
// attached. Also gate the Map Loader (see TODO at the
|
||||||
|
// StackLayout below) and the auto-switch at
|
||||||
|
// onParsedDataReady. See plan §3.1.
|
||||||
checked: root.selectedTabIndex === index
|
checked: root.selectedTabIndex === index
|
||||||
flat: true
|
flat: true
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -523,10 +546,12 @@ Rectangle {
|
|||||||
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
|
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
|
||||||
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
|
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
|
||||||
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
|
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
|
||||||
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing
|
readonly property string gateError: !!dt ? dt.familyGateError : ""
|
||||||
ToolTip.visible: (sameFile || masterMissing) && hovered
|
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
|
||||||
|
ToolTip.visible: (sameFile || masterMissing || gateError !== "") && hovered
|
||||||
ToolTip.text: masterMissing
|
ToolTip.text: masterMissing
|
||||||
? "No master file registered for this wafer type (Settings → Master Files)"
|
? "No master file registered for this wafer type (Settings → Master Files)"
|
||||||
|
: gateError !== "" ? gateError
|
||||||
: "Choose two different runs to compare"
|
: "Choose two different runs to compare"
|
||||||
onClicked: {
|
onClicked: {
|
||||||
dt.comparing = true
|
dt.comparing = true
|
||||||
@@ -552,7 +577,9 @@ Rectangle {
|
|||||||
: compareRunBtn.enabled
|
: compareRunBtn.enabled
|
||||||
? "Run DTW Comparison"
|
? "Run DTW Comparison"
|
||||||
: (compareRunBtn.masterMissing ? "No master for this type"
|
: (compareRunBtn.masterMissing ? "No master for this type"
|
||||||
: compareRunBtn.sameFile ? "Choose different runs" : "Select both runs")
|
: compareRunBtn.sameFile ? "Choose different runs"
|
||||||
|
: compareRunBtn.gateError !== "" ? "Wafer family mismatch"
|
||||||
|
: "Select both runs")
|
||||||
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
color: compareRunBtn.enabled ? Theme.tone100 : Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
font.bold: true
|
font.bold: true
|
||||||
@@ -569,27 +596,28 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
|
||||||
Rectangle {
|
StackLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
Layout.minimumHeight: 120
|
Layout.minimumHeight: 120
|
||||||
radius: Theme.sidePanelRadius
|
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
color: Theme.sidePanelBackground
|
|
||||||
|
|
||||||
|
// ── Status tab: Hardware Actions + Log Actions ──────────
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
spacing: Theme.sideRailSpacing
|
||||||
anchors.margins: 14
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
StackLayout {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
|
||||||
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
// ── Status tab: Hardware Actions ────────────────
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
|
id: hwActionsCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
@@ -597,7 +625,6 @@ Rectangle {
|
|||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
topPadding: 6
|
|
||||||
font.letterSpacing: 1.5
|
font.letterSpacing: 1.5
|
||||||
font.bold: true
|
font.bold: true
|
||||||
}
|
}
|
||||||
@@ -627,16 +654,62 @@ Rectangle {
|
|||||||
onClicked: deviceController.eraseMemory(
|
onClicked: deviceController.eraseMemory(
|
||||||
deviceController.selectedPort)
|
deviceController.selectedPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
Item { Layout.fillHeight: true }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Map tab: Source file browser ──────────────
|
Rectangle {
|
||||||
SourcePanel {
|
Layout.fillWidth: true
|
||||||
Layout.fillWidth: true
|
Layout.preferredHeight: logActionsCol.implicitHeight + 28
|
||||||
Layout.fillHeight: true
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: logActionsCol
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "LOG ACTIONS"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "REFRESH SESSION"
|
||||||
|
iconSource: "../icons/refresh.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
onClicked: deviceController.clearSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
RailActionButton {
|
||||||
|
label: "CLEAR LOG"
|
||||||
|
iconSource: "../icons/x.svg"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
onClicked: deviceController.clearActivityLog()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map tab: Source file browser ──────────────
|
||||||
|
Rectangle {
|
||||||
|
radius: Theme.sidePanelRadius
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
color: Theme.sidePanelBackground
|
||||||
|
|
||||||
|
SourcePanel {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 14
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,90 +730,42 @@ Rectangle {
|
|||||||
anchors.margins: 14
|
anchors.margins: 14
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
// Row 1: Title + status badge
|
// Row 1: Title
|
||||||
|
Label {
|
||||||
|
text: "CONNECTION FLOW"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.bold: true
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2: Port / descriptive text
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: {
|
||||||
|
if (deviceController.connectionStatus === "Connected")
|
||||||
|
return deviceController.selectedPort || "Connected"
|
||||||
|
if (deviceController.selectedPort)
|
||||||
|
return deviceController.selectedPort
|
||||||
|
return "No port selected"
|
||||||
|
}
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
// Row 3: Mode chip left, status badge bottom-right
|
||||||
RowLayout {
|
RowLayout {
|
||||||
spacing: 8
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "CONNECTION FLOW"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.bold: true
|
|
||||||
Layout.fillWidth: true
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
height: 18
|
|
||||||
width: badgeLabel.implicitWidth + 14
|
|
||||||
radius: 9
|
|
||||||
Layout.alignment: Qt.AlignVCenter
|
|
||||||
color: {
|
|
||||||
var s = deviceController.connectionStatus
|
|
||||||
if (s === "Connected") return Theme.statusSuccessColor
|
|
||||||
if (s === "Disconnected") return Theme.statusErrorColor
|
|
||||||
return Theme.statusWarningColor
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
id: badgeLabel
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: deviceController.connectionStatus || "Disconnected"
|
|
||||||
color: Theme.statusBadgeText
|
|
||||||
font.pixelSize: Theme.font2xs
|
|
||||||
font.bold: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 2: Status dot + descriptive text (replaces dotted trail)
|
|
||||||
Row {
|
|
||||||
spacing: 8
|
|
||||||
Layout.fillWidth: true
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: statusDot
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 8
|
|
||||||
height: 8
|
|
||||||
radius: 4
|
|
||||||
color: deviceController.connectionStatus === "Connected"
|
|
||||||
? Theme.statusSuccessColor : Theme.statusErrorColor
|
|
||||||
|
|
||||||
SequentialAnimation on opacity {
|
|
||||||
running: deviceController.connectionStatus === "Connected"
|
|
||||||
loops: Animation.Infinite
|
|
||||||
NumberAnimation { to: 0.4; duration: 1200 }
|
|
||||||
NumberAnimation { to: 1.0; duration: 1200 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Label {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: {
|
|
||||||
if (deviceController.connectionStatus === "Connected")
|
|
||||||
return deviceController.selectedPort || "Connected"
|
|
||||||
if (deviceController.selectedPort)
|
|
||||||
return deviceController.selectedPort
|
|
||||||
return "No port selected"
|
|
||||||
}
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
elide: Text.ElideRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row 3: Mode chip — neutral gray tag
|
|
||||||
Row {
|
|
||||||
spacing: 6
|
spacing: 6
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: modeChipLabel.implicitWidth + 12
|
width: modeChipLabel.implicitWidth + 12
|
||||||
height: 18
|
height: 18
|
||||||
radius: 4
|
radius: 4
|
||||||
@@ -755,11 +780,34 @@ Rectangle {
|
|||||||
? streamController.mode.toUpperCase() : "REVIEW"
|
? streamController.mode.toUpperCase() : "REVIEW"
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.pixelSize: Theme.font2xs
|
font.pixelSize: Theme.fontXs
|
||||||
font.bold: true
|
font.bold: true
|
||||||
font.letterSpacing: 0.8
|
font.letterSpacing: 0.8
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
height: 18
|
||||||
|
width: badgeLabel.implicitWidth + 14
|
||||||
|
radius: 9
|
||||||
|
color: {
|
||||||
|
var s = deviceController.connectionStatus
|
||||||
|
if (s === "Connected") return Theme.statusSuccessColor
|
||||||
|
if (s === "Disconnected") return Theme.statusErrorColor
|
||||||
|
return Theme.statusWarningColor
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
id: badgeLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: deviceController.connectionStatus || "Disconnected"
|
||||||
|
color: Theme.statusBadgeText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -894,6 +942,8 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
Loader {
|
Loader {
|
||||||
source: "Tabs/WaferMapTab.qml"
|
source: "Tabs/WaferMapTab.qml"
|
||||||
|
// TODO P3.1: append `&& licenseModel.licenses.length > 0`
|
||||||
|
// so the tab never instantiates while locked. See plan §3.1.
|
||||||
active: StackLayout.index === root.selectedTabIndex
|
active: StackLayout.index === root.selectedTabIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,20 @@ Item {
|
|||||||
|
|
||||||
function waferTypeForFile(path) {
|
function waferTypeForFile(path) {
|
||||||
if (!path) return ""
|
if (!path) return ""
|
||||||
|
// A file registered as a master carries that slot's family — this is
|
||||||
|
// the only way a live recording gets a family (its filename/metadata
|
||||||
|
// don't encode one reliably).
|
||||||
|
for (var fam in settingsModel.masters)
|
||||||
|
if (settingsModel.masters[fam] === path) return fam.toUpperCase()
|
||||||
var rows = file_browser.files
|
var rows = file_browser.files
|
||||||
for (var i = 0; i < rows.length; i++) {
|
for (var i = 0; i < rows.length; i++) {
|
||||||
if (rows[i].fileName === path) return (rows[i].waferType || "").toUpperCase()
|
if (rows[i].fileName === path) {
|
||||||
|
if (rows[i].isRecording) return (rows[i].masterType || "").toUpperCase()
|
||||||
|
return (rows[i].waferType || "").toUpperCase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var base = path.split("/").pop()
|
var base = path.split("/").pop()
|
||||||
|
if (base.toLowerCase().indexOf("live_") === 0) return ""
|
||||||
return base ? base.charAt(0).toUpperCase() : ""
|
return base ? base.charAt(0).toUpperCase() : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +46,19 @@ Item {
|
|||||||
// simply won't exist under this key).
|
// simply won't exist under this key).
|
||||||
readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB)
|
readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB)
|
||||||
readonly property string masterFileForRunB: root.runBWaferType !== "" ? (settingsModel.masters[root.runBWaferType] || "") : ""
|
readonly property string masterFileForRunB: root.runBWaferType !== "" ? (settingsModel.masters[root.runBWaferType] || "") : ""
|
||||||
|
readonly property string runAWaferType: root.useMasterFile ? root.runBWaferType : root.waferTypeForFile(root.compareFileA)
|
||||||
|
// Family gate: both runs must resolve to the same wafer family. An empty
|
||||||
|
// family means an unassigned live recording — comparable only after it is
|
||||||
|
// registered as a master file (Settings → Master Files).
|
||||||
|
readonly property string familyGateError: {
|
||||||
|
var fileA = root.useMasterFile ? root.masterFileForRunB : root.compareFileA
|
||||||
|
if (fileA === "" || root.compareFileB === "") return ""
|
||||||
|
if (root.runAWaferType === "" || root.runBWaferType === "")
|
||||||
|
return "Recording has no wafer family — register it as a master file (Settings → Master Files)"
|
||||||
|
if (root.runAWaferType !== root.runBWaferType)
|
||||||
|
return "Wafer family mismatch: " + root.runAWaferType + " vs " + root.runBWaferType
|
||||||
|
return ""
|
||||||
|
}
|
||||||
property real warpingDistance: -1
|
property real warpingDistance: -1
|
||||||
property int frameOffset: 0
|
property int frameOffset: 0
|
||||||
property real frameOffsetSeconds: 0
|
property real frameOffsetSeconds: 0
|
||||||
@@ -609,7 +631,15 @@ Item {
|
|||||||
readonly property real padTop: 24
|
readonly property real padTop: 24
|
||||||
readonly property real padBottom: 36
|
readonly property real padBottom: 36
|
||||||
|
|
||||||
function drawGrid(ctx, minV, maxV, dataLen) {
|
// Nice tick step (1/2/5 × 10^k) — same scheme as TrendChartItem,
|
||||||
|
// so labels land on round numbers instead of raw data min/max.
|
||||||
|
function niceStep(rawStep) {
|
||||||
|
var mag = Math.pow(10, Math.floor(Math.log10(rawStep)))
|
||||||
|
return [1, 2, 5, 10].map(function (s) { return s * mag })
|
||||||
|
.find(function (s) { return s >= rawStep })
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawGrid(ctx, minV, maxV, yStep, dataLen) {
|
||||||
var plotW = width - padLeft - padRight
|
var plotW = width - padLeft - padRight
|
||||||
var plotH = height - padTop - padBottom
|
var plotH = height - padTop - padBottom
|
||||||
var range = (maxV - minV) || 1
|
var range = (maxV - minV) || 1
|
||||||
@@ -620,7 +650,8 @@ Item {
|
|||||||
ctx.font = "10px " + Theme.uiFontFamily
|
ctx.font = "10px " + Theme.uiFontFamily
|
||||||
ctx.textAlign = "right"
|
ctx.textAlign = "right"
|
||||||
|
|
||||||
var ySteps = 5
|
var ySteps = Math.max(1, Math.round(range / yStep))
|
||||||
|
var yDec = Math.max(0, -Math.floor(Math.log10(yStep)))
|
||||||
for (var yi = 0; yi <= ySteps; yi++) {
|
for (var yi = 0; yi <= ySteps; yi++) {
|
||||||
var yFrac = yi / ySteps
|
var yFrac = yi / ySteps
|
||||||
var yPx = padTop + plotH * yFrac
|
var yPx = padTop + plotH * yFrac
|
||||||
@@ -631,16 +662,17 @@ Item {
|
|||||||
ctx.lineTo(padLeft + plotW, yPx)
|
ctx.lineTo(padLeft + plotW, yPx)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
ctx.fillText(yVal.toFixed(1), padLeft - 6, yPx + 3)
|
ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.textAlign = "center"
|
ctx.textAlign = "center"
|
||||||
var xSteps = Math.min(5, dataLen - 1)
|
var xMax = dataLen - 1
|
||||||
for (var xi = 0; xi <= xSteps; xi++) {
|
if (xMax > 0) {
|
||||||
var xFrac = xi / xSteps
|
var xStep = niceStep(xMax / 5 || 1)
|
||||||
var xPx = padLeft + plotW * xFrac
|
for (var xIdx = 0; xIdx <= xMax; xIdx += xStep) {
|
||||||
var xIdx = Math.round(xFrac * (dataLen - 1))
|
var xPx = padLeft + (xIdx / xMax) * plotW
|
||||||
ctx.fillText(xIdx.toString(), xPx, height - 20)
|
ctx.fillText(xIdx.toString(), xPx, height - 20)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
@@ -700,9 +732,13 @@ Item {
|
|||||||
var padding = (maxV - minV) * 0.05
|
var padding = (maxV - minV) * 0.05
|
||||||
minV -= padding
|
minV -= padding
|
||||||
maxV += padding
|
maxV += padding
|
||||||
|
// Snap bounds to nice-step multiples -> stable round-number axis
|
||||||
|
var yStep = niceStep((maxV - minV) / 5 || 1)
|
||||||
|
minV = Math.floor(minV / yStep) * yStep
|
||||||
|
maxV = Math.ceil(maxV / yStep) * yStep
|
||||||
var range = (maxV - minV) || 1
|
var range = (maxV - minV) || 1
|
||||||
|
|
||||||
drawGrid(ctx, minV, maxV, root.seriesLen)
|
drawGrid(ctx, minV, maxV, yStep, root.seriesLen)
|
||||||
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen)
|
drawSeries(ctx, root.trendDataA, minV, range, Theme.primaryAccent, root.seriesLen)
|
||||||
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen)
|
drawSeries(ctx, root.trendDataB, minV, range, Theme.themeSkill, root.seriesLen)
|
||||||
drawScrubMarker(ctx)
|
drawScrubMarker(ctx)
|
||||||
|
|||||||
+295
-305
@@ -30,36 +30,45 @@ ColumnLayout {
|
|||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
Text {
|
||||||
|
id: labelText
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
}
|
||||||
Text {
|
Text {
|
||||||
id: valueText
|
id: valueText
|
||||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
font.pixelSize: valSize
|
font.pixelSize: valSize
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.alignment: Qt.AlignHCenter
|
Layout.fillHeight: true
|
||||||
}
|
Layout.fillWidth: true
|
||||||
Text {
|
horizontalAlignment: Text.AlignHCenter
|
||||||
id: labelText
|
verticalAlignment: Text.AlignVCenter
|
||||||
color: Theme.sideMutedText
|
fontSizeMode: Text.HorizontalFit
|
||||||
font.pixelSize: Theme.fontXs
|
minimumPixelSize: Theme.fontMd
|
||||||
font.weight: Font.Medium
|
|
||||||
font.letterSpacing: 1.2
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignHCenter
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
component GridCell : RowLayout {
|
// Stacked stat block: small label over big value, centered in its grid
|
||||||
|
// quadrant so the card's height is used instead of leaving dead space.
|
||||||
|
component GridCell : ColumnLayout {
|
||||||
property alias label: labelText.text
|
property alias label: labelText.text
|
||||||
property alias value: valueText.text
|
property alias value: valueText.text
|
||||||
property bool active: root.waferDetected
|
property bool active: root.waferDetected
|
||||||
|
|
||||||
spacing: 8
|
spacing: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
Text {
|
Text {
|
||||||
id: labelText
|
id: labelText
|
||||||
color: Theme.sideMutedText
|
color: Theme.sideMutedText
|
||||||
@@ -67,17 +76,15 @@ ColumnLayout {
|
|||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
id: valueText
|
id: valueText
|
||||||
color: active ? Theme.headingColor : Theme.sideMutedText
|
color: active ? Theme.headingColor : Theme.sideMutedText
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontXl
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
}
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Aliases for external wiring ──
|
// ── Aliases for external wiring ──
|
||||||
@@ -120,16 +127,64 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// Directory-only picker for the DIRECTORY card button — sets the save dir
|
||||||
// ROW 1: Connection Status (100%)
|
// without kicking off a parse/save of pending read data.
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
FolderDialog {
|
||||||
RowLayout {
|
id: dirOnlyDialog
|
||||||
Layout.fillWidth: true
|
title: "Choose a folder to save Data"
|
||||||
Layout.preferredHeight: 90
|
onAccepted: deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
|
||||||
Layout.maximumHeight: 90
|
}
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
|
// Post-read metadata editor (C# parity: EditCSVMetadataDialog after read).
|
||||||
|
// Cancel keeps the CSV, skips the sidecar — matching C# behavior loosely.
|
||||||
|
Dialog {
|
||||||
|
id: editCsvDialog
|
||||||
|
parent: Overlay.overlay
|
||||||
|
modal: true
|
||||||
|
title: "Edit CSV Metadata"
|
||||||
|
width: 460
|
||||||
|
anchors.centerIn: parent
|
||||||
|
standardButtons: Dialog.Save | Dialog.Cancel
|
||||||
|
onAccepted: file_browser.saveMetadata(root.csvPath, metaWafer.text, metaDate.text, metaChamber.text, metaNotes.text, false, "")
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.csvPath.split("/").pop()
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
Label { text: "Wafer" }
|
||||||
|
TextField { id: metaWafer; Layout.fillWidth: true }
|
||||||
|
Label { text: "Date" }
|
||||||
|
TextField { id: metaDate; Layout.fillWidth: true }
|
||||||
|
Label { text: "Chamber" }
|
||||||
|
TextField { id: metaChamber; Layout.fillWidth: true }
|
||||||
|
Label { text: "Notes" }
|
||||||
|
TextField { id: metaNotes; Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// ROWS 1-2 (3-column bento):
|
||||||
|
// [Connection Status] [Total Runtime ] [Wafer & Sensor]
|
||||||
|
// [Directory (span 2) ] [ (rowspan) ]
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: 188
|
||||||
|
Layout.maximumHeight: 188
|
||||||
|
columns: 3
|
||||||
|
rowSpacing: 8
|
||||||
|
columnSpacing: 8
|
||||||
|
|
||||||
|
// ── Connection Status: two cells wide, top-left ──
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
color: Theme.cardBackground
|
color: Theme.cardBackground
|
||||||
@@ -138,13 +193,9 @@ ColumnLayout {
|
|||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
id: connStatusCol
|
anchors.fill: parent
|
||||||
anchors.left: parent.left
|
anchors.margins: Theme.panelPadding
|
||||||
anchors.right: parent.right
|
spacing: 6
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
anchors.leftMargin: Theme.panelPadding
|
|
||||||
anchors.rightMargin: Theme.panelPadding
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "CONNECTION STATUS"
|
text: "CONNECTION STATUS"
|
||||||
@@ -155,26 +206,11 @@ ColumnLayout {
|
|||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: 0
|
spacing: 8
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: 1
|
|
||||||
Text {
|
|
||||||
text: "HOST PC"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: "localhost"
|
|
||||||
color: Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -239,221 +275,204 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
spacing: 1
|
|
||||||
Text {
|
|
||||||
text: "COM PORT"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.portName
|
|
||||||
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
|
||||||
font.pixelSize: Theme.fontMd
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
Layout.alignment: Qt.AlignRight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%)
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: 90
|
|
||||||
Layout.maximumHeight: 90
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.preferredWidth: 65
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Theme.panelPadding
|
|
||||||
spacing: 6
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 2
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "DIRECTORY"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.bold: true
|
|
||||||
font.letterSpacing: 1.5
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: deviceController.saveDataDir || "Not configured"
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.italic: true
|
|
||||||
opacity: 0.7
|
|
||||||
elide: Text.ElideMiddle
|
|
||||||
Layout.fillWidth: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RowLayout {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
spacing: 8
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
visible: root.csvPath !== ""
|
|
||||||
width: sessionSavedLabel.implicitWidth + 12
|
|
||||||
height: 18
|
|
||||||
radius: 4
|
|
||||||
color: Theme.fieldBackground
|
|
||||||
border.color: Theme.sideBorder
|
|
||||||
border.width: 1
|
|
||||||
|
|
||||||
Text {
|
|
||||||
id: sessionSavedLabel
|
|
||||||
anchors.centerIn: parent
|
|
||||||
text: "SESSION SAVED"
|
|
||||||
color: Theme.sideMutedText
|
|
||||||
font.pixelSize: Theme.fontXs
|
|
||||||
font.weight: Font.Bold
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
font.letterSpacing: 0.6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Item { Layout.fillWidth: true }
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: browseBtn
|
|
||||||
text: "Save As"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.weight: Font.Medium
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
implicitWidth: 70
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: browseBtn.text
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font: browseBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: saveDirDialog.open()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.fillHeight: true
|
|
||||||
color: Theme.cardBackground
|
|
||||||
border.color: Theme.cardBorder
|
|
||||||
border.width: 1
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
|
|
||||||
ColumnLayout {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: 10
|
|
||||||
spacing: 4
|
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: "WAFER & SENSOR DATA"
|
text: root.portName
|
||||||
color: Theme.sideMutedText
|
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
|
||||||
font.pixelSize: Theme.fontXs
|
font.pixelSize: Theme.fontMd
|
||||||
font.weight: Font.Bold
|
font.weight: Font.Bold
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
font.letterSpacing: 0.6
|
elide: Text.ElideMiddle
|
||||||
Layout.bottomMargin: 2
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wafer & Sensor Data: far right, spans both rows ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.rowSpan: 2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "WAFER & SENSOR DATA"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
Layout.bottomMargin: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
columns: 2
|
||||||
|
rowSpacing: 6
|
||||||
|
columnSpacing: 16
|
||||||
|
|
||||||
|
GridCell {
|
||||||
|
id: waferInfoFamilyCell
|
||||||
|
label: "Family Code"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GridCell {
|
||||||
|
id: waferSerialCell
|
||||||
|
label: "Serial"
|
||||||
|
value: {
|
||||||
|
var info = deviceController.lastWaferInfo;
|
||||||
|
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GridLayout {
|
Rectangle {
|
||||||
|
Layout.columnSpan: 2
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.preferredHeight: 1
|
||||||
columns: 2
|
color: Theme.cardBorder
|
||||||
rowSpacing: 4
|
opacity: 0.5
|
||||||
columnSpacing: 16
|
}
|
||||||
|
|
||||||
GridCell {
|
GridCell {
|
||||||
id: waferInfoFamilyCell
|
id: waferCyclesCell
|
||||||
label: "Family Code"
|
label: "Cycles Completed"
|
||||||
value: {
|
value: {
|
||||||
var info = deviceController.lastWaferInfo;
|
var info = deviceController.lastWaferInfo;
|
||||||
return (info && info.length > 0 && info[0]) ? info[0] : "—";
|
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
||||||
}
|
|
||||||
}
|
}
|
||||||
GridCell {
|
active: root.waferDetected
|
||||||
id: waferCyclesCell
|
}
|
||||||
label: "Cycles Completed"
|
GridCell {
|
||||||
value: {
|
id: waferSensorsCell
|
||||||
var info = deviceController.lastWaferInfo;
|
label: "Sensors"
|
||||||
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
|
value: {
|
||||||
}
|
var info = deviceController.lastWaferInfo;
|
||||||
active: root.waferDetected
|
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
Layout.columnSpan: 2
|
|
||||||
Layout.fillWidth: true
|
|
||||||
height: 1
|
|
||||||
color: Theme.cardBorder
|
|
||||||
opacity: 0.5
|
|
||||||
}
|
|
||||||
|
|
||||||
GridCell {
|
|
||||||
id: waferSerialCell
|
|
||||||
label: "Serial"
|
|
||||||
value: {
|
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
return (info && info.length > 1 && info[1]) ? info[1] : "—";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GridCell {
|
|
||||||
id: waferSensorsCell
|
|
||||||
label: "Sensors"
|
|
||||||
value: {
|
|
||||||
var info = deviceController.lastWaferInfo;
|
|
||||||
return (info && info.length > 2 && info[2]) ? String(info[2]) : "—";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Directory: bottom-left cell ──
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.panelPadding
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "DIRECTORY"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.bold: true
|
||||||
|
font.letterSpacing: 1.5
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.csvPath !== ""
|
||||||
|
implicitWidth: sessionSavedLabel.implicitWidth + 12
|
||||||
|
implicitHeight: 18
|
||||||
|
radius: 4
|
||||||
|
color: Theme.fieldBackground
|
||||||
|
border.color: Theme.sideBorder
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: sessionSavedLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "SESSION SAVED"
|
||||||
|
color: Theme.sideMutedText
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
font.weight: Font.Bold
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.letterSpacing: 0.6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
// Path pinned bottom-left
|
||||||
|
Text {
|
||||||
|
text: deviceController.saveDataDir || "Not configured"
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font.pixelSize: Theme.fontMd
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
font.italic: true
|
||||||
|
opacity: 0.7
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: browseBtn
|
||||||
|
text: "Select Folder"
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
font.weight: Font.Medium
|
||||||
|
font.family: Theme.uiFontFamily
|
||||||
|
implicitHeight: 24
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
|
||||||
|
border.color: Theme.cardBorder
|
||||||
|
border.width: 1
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: Text {
|
||||||
|
text: browseBtn.text
|
||||||
|
color: Theme.bodyColor
|
||||||
|
font: browseBtn.font
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
onClicked: dirOnlyDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Total Runtime: bottom center ──
|
||||||
StatCard {
|
StatCard {
|
||||||
id: runtimeCard
|
id: runtimeCard
|
||||||
Layout.preferredWidth: 35
|
label: "TOTAL RUNTIME"
|
||||||
value: {
|
value: {
|
||||||
var info = deviceController.lastWaferInfo;
|
var info = deviceController.lastWaferInfo;
|
||||||
return (info && info.length > 4 && info[4] !== undefined) ? info[4] + "s" : "—";
|
if (!(info && info.length > 4 && info[4] !== undefined)) return "—";
|
||||||
|
var secs = parseFloat(info[4]);
|
||||||
|
if (isNaN(secs)) return info[4] + "s";
|
||||||
|
var m = Math.floor(secs / 60);
|
||||||
|
var s = Math.round(secs % 60);
|
||||||
|
return secs + "s / " + m + ":" + (s < 10 ? "0" : "") + s + " min";
|
||||||
}
|
}
|
||||||
label: "TOTAL RUNTIME"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,10 +492,20 @@ ColumnLayout {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: 36
|
Layout.preferredHeight: 36
|
||||||
color: Theme.subtleSectionBackground
|
color: Theme.subtleSectionBackground
|
||||||
|
radius: Theme.radiusMd
|
||||||
|
|
||||||
|
// Cover bottom corners so only top-left and top-right are rounded
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: parent.radius
|
||||||
|
color: parent.color
|
||||||
|
}
|
||||||
|
|
||||||
RowLayout {
|
RowLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -489,64 +518,22 @@ ColumnLayout {
|
|||||||
width: 14; height: 14
|
width: 14; height: 14
|
||||||
sourceSize.width: 14
|
sourceSize.width: 14
|
||||||
sourceSize.height: 14
|
sourceSize.height: 14
|
||||||
color: Theme.bodyColor
|
color: Theme.sideMutedText
|
||||||
visible: root.csvPath !== ""
|
visible: root.csvPath !== ""
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG"
|
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
|
||||||
color: Theme.bodyColor
|
color: Theme.sideMutedText
|
||||||
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm
|
font.pixelSize: Theme.fontMd
|
||||||
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8
|
font.letterSpacing: 1.5
|
||||||
font.weight: Font.Medium
|
font.bold: true
|
||||||
font.family: Theme.uiFontFamily
|
font.family: Theme.uiFontFamily
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
|
||||||
id: refreshSessionBtn
|
|
||||||
text: "Refresh"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: refreshSessionBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: refreshSessionBtn.text
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font: refreshSessionBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: deviceController.clearSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
id: clearLogBtn
|
|
||||||
text: "Clear"
|
|
||||||
font.pixelSize: Theme.fontSm
|
|
||||||
font.family: Theme.uiFontFamily
|
|
||||||
implicitHeight: 24
|
|
||||||
hoverEnabled: true
|
|
||||||
background: Rectangle {
|
|
||||||
color: clearLogBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
|
||||||
radius: Theme.radiusSm
|
|
||||||
}
|
|
||||||
contentItem: Text {
|
|
||||||
text: clearLogBtn.text
|
|
||||||
color: Theme.bodyColor
|
|
||||||
font: clearLogBtn.font
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
verticalAlignment: Text.AlignVCenter
|
|
||||||
}
|
|
||||||
onClicked: deviceController.clearActivityLog()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
@@ -567,7 +554,9 @@ ColumnLayout {
|
|||||||
TextArea {
|
TextArea {
|
||||||
id: activityLog
|
id: activityLog
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
// Bound to backend so log survives tab switches (Loader
|
||||||
|
// destroys this tab); clears only via Clear Log / restart.
|
||||||
|
text: deviceController.activityLog || qsTr("Welcome — use the side rail to detect a wafer, or import data to review.")
|
||||||
readOnly: true
|
readOnly: true
|
||||||
font.family: "monospace"
|
font.family: "monospace"
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
@@ -578,12 +567,6 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: deviceController
|
|
||||||
function onActivityLogUpdated(newLog) {
|
|
||||||
activityLog.text = newLog ? newLog : qsTr("Welcome — use the side rail to detect a wafer, or import data to review.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -635,6 +618,13 @@ ColumnLayout {
|
|||||||
root.dataRows = result.rows;
|
root.dataRows = result.rows;
|
||||||
root.dataCols = result.cols;
|
root.dataCols = result.cols;
|
||||||
root.csvPath = result.csv_path || "";
|
root.csvPath = result.csv_path || "";
|
||||||
|
if (root.csvPath) {
|
||||||
|
metaWafer.text = result.serialNumber || "";
|
||||||
|
metaDate.text = Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss");
|
||||||
|
metaChamber.text = settingsModel.chamberId || "";
|
||||||
|
metaNotes.text = "";
|
||||||
|
editCsvDialog.open();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
root.dataParsed = false;
|
root.dataParsed = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ def main() -> int:
|
|||||||
engine.rootContext().setContextProperty("appVersion", app_version)
|
engine.rootContext().setContextProperty("appVersion", app_version)
|
||||||
|
|
||||||
# ===== Device Controller (serial comm) =====
|
# ===== Device Controller (serial comm) =====
|
||||||
|
# TODO P1.1: construct LicenseModel BEFORE DeviceController and pass
|
||||||
|
# license_lookup=license_model.levelForWafer into the ctor.
|
||||||
|
# THINKING: controller must answer "is this serial licensed?" at
|
||||||
|
# read/erase time (P2.1); a bound Callable[[str], str] avoids a
|
||||||
|
# LicenseModel import in the controller and keeps it testable with a
|
||||||
|
# lambda. See docs/pending/license-gating-plan.md §1.1.
|
||||||
data_dir = str(settings_model._data_dir)
|
data_dir = str(settings_model._data_dir)
|
||||||
raw_settings = LocalSettings.read_settings(data_dir)
|
raw_settings = LocalSettings.read_settings(data_dir)
|
||||||
device_controller = DeviceController(raw_settings, data_dir)
|
device_controller = DeviceController(raw_settings, data_dir)
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ class DeviceController(QObject):
|
|||||||
self._activity_log: list[str] = []
|
self._activity_log: list[str] = []
|
||||||
self._raw_bytes: Optional[bytes] = None
|
self._raw_bytes: Optional[bytes] = None
|
||||||
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
self._save_data_dir: str = getattr(settings, 'save_data_dir', "") or str(Path(self._data_dir) / "csv")
|
||||||
|
# TODO P1.2: accept ctor param license_lookup: Callable[[str], str]
|
||||||
|
# (default lambda s: "") and add:
|
||||||
|
# def _wafer_license_level(self) -> str:
|
||||||
|
# return self._license_lookup(self._last_wafer_info.get("serialNumber", ""))
|
||||||
|
# plus @Slot(result=bool) waferLicensed() for QML (used by P2.2).
|
||||||
|
# THINKING: single shared guard — per-call-site checks are the
|
||||||
|
# sibling-caller bug (fix read, forget erase). Empty serial → ""
|
||||||
|
# → denied = safe default. Called by P2.1.
|
||||||
|
# See docs/pending/license-gating-plan.md §1.2.
|
||||||
self._last_wafer_info: dict[str, Any] = {}
|
self._last_wafer_info: dict[str, Any] = {}
|
||||||
self._last_csv_path: str = ""
|
self._last_csv_path: str = ""
|
||||||
self._selected_port: str = ""
|
self._selected_port: str = ""
|
||||||
@@ -116,6 +125,11 @@ class DeviceController(QObject):
|
|||||||
"""Whether a hardware operation is currently running."""
|
"""Whether a hardware operation is currently running."""
|
||||||
return self._operation_in_progress
|
return self._operation_in_progress
|
||||||
|
|
||||||
|
@Property(str, notify=activityLogUpdated)
|
||||||
|
def activityLog(self) -> str:
|
||||||
|
"""Full activity log text; survives QML tab reloads."""
|
||||||
|
return "\n".join(self._activity_log)
|
||||||
|
|
||||||
@Property(str, notify=activityLogUpdated)
|
@Property(str, notify=activityLogUpdated)
|
||||||
def saveDataDir(self) -> str:
|
def saveDataDir(self) -> str:
|
||||||
"""Directory where parsed CSV data files are saved."""
|
"""Directory where parsed CSV data files are saved."""
|
||||||
@@ -324,6 +338,7 @@ class DeviceController(QObject):
|
|||||||
self._graph_view.resetChart()
|
self._graph_view.resetChart()
|
||||||
self._activity_log.clear()
|
self._activity_log.clear()
|
||||||
self.detectResult.emit(None)
|
self.detectResult.emit(None)
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
self.parsedDataReady.emit({"success": False})
|
self.parsedDataReady.emit({"success": False})
|
||||||
|
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
@@ -349,20 +364,27 @@ class DeviceController(QObject):
|
|||||||
self, port: Optional[str], info: Optional[WaferInfo]
|
self, port: Optional[str], info: Optional[WaferInfo]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Main-thread completion handler for detectWafer."""
|
"""Main-thread completion handler for detectWafer."""
|
||||||
if info is not None:
|
try:
|
||||||
self._selected_port = port or ""
|
if info is not None:
|
||||||
self._set_connection_status("Connected")
|
# Build the dict (can raise) before declaring success, so a
|
||||||
wafer_dict = self._wafer_info_to_dict(info)
|
# bad payload can't leave status="Connected" while
|
||||||
self._last_wafer_info = wafer_dict
|
# waferDetected stays false and Read/Erase stay greyed.
|
||||||
self.detectResult.emit(wafer_dict)
|
wafer_dict = self._wafer_info_to_dict(info)
|
||||||
self._save_status()
|
self._selected_port = port or ""
|
||||||
else:
|
self._last_wafer_info = wafer_dict
|
||||||
self._selected_port = ""
|
self._set_connection_status("Connected")
|
||||||
self._set_connection_status("Disconnected")
|
self.detectResult.emit(wafer_dict)
|
||||||
self._append_log("No wafer detected on any port")
|
self.selectedPortChanged.emit()
|
||||||
self._last_wafer_info = {}
|
self._save_status()
|
||||||
self.detectResult.emit(None)
|
else:
|
||||||
self._set_operation_progress(False)
|
self._selected_port = ""
|
||||||
|
self._set_connection_status("Disconnected")
|
||||||
|
self._append_log("No wafer detected on any port")
|
||||||
|
self._last_wafer_info = {}
|
||||||
|
self.detectResult.emit(None)
|
||||||
|
self.selectedPortChanged.emit()
|
||||||
|
finally:
|
||||||
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
@slot_error_boundary
|
@slot_error_boundary
|
||||||
@@ -377,6 +399,16 @@ class DeviceController(QObject):
|
|||||||
self._append_log("Already busy — ignoring read request")
|
self._append_log("Already busy — ignoring read request")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# TODO P2.1: license guard —
|
||||||
|
# if self._wafer_license_level() == "":
|
||||||
|
# self._append_log("Wafer not licensed — load its license .bin (About dialog)")
|
||||||
|
# self.readResult.emit({"error": "Wafer not licensed"})
|
||||||
|
# return
|
||||||
|
# THINKING: this slot is the trust boundary; the QML enabled
|
||||||
|
# binding (P2.2) is UX only. Reuses existing readResult error
|
||||||
|
# shape → zero new signals. Depends on P1.2. Same guard goes in
|
||||||
|
# eraseMemory (emit eraseResult there). See plan §2.1.
|
||||||
|
|
||||||
if not self._selected_port:
|
if not self._selected_port:
|
||||||
self._append_log("No wafer detected — run Detect Wafer first")
|
self._append_log("No wafer detected — run Detect Wafer first")
|
||||||
self.readResult.emit({"error": "No port — detect wafer first"})
|
self.readResult.emit({"error": "No port — detect wafer first"})
|
||||||
@@ -436,6 +468,9 @@ class DeviceController(QObject):
|
|||||||
if self._operation_in_progress:
|
if self._operation_in_progress:
|
||||||
self._append_log("Already busy — ignoring erase request")
|
self._append_log("Already busy — ignoring erase request")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# TODO P2.1: same license guard as readMemoryAsync, but emit
|
||||||
|
# self.eraseResult.emit({"success": False}) on denial. See plan §2.1.
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
self._set_connection_status("Erasing...")
|
self._set_connection_status("Erasing...")
|
||||||
self.portsUpdated.emit(self._service.enumerate_ports())
|
self.portsUpdated.emit(self._service.enumerate_ports())
|
||||||
|
|||||||
@@ -71,17 +71,16 @@ class FileBrowser(QObject):
|
|||||||
self._show_error(f"CSV file was not found:\n{file_path}")
|
self._show_error(f"CSV file was not found:\n{file_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# ponytail: row is absent when saving metadata for a CSV just read from
|
||||||
|
# the wafer (not in the browser listing) — still write the sidecar.
|
||||||
row = self._find_row(file_path)
|
row = self._find_row(file_path)
|
||||||
if row is None:
|
if row is not None:
|
||||||
self._show_error("The selected CSV row is no longer available.")
|
row["wafer"] = wafer
|
||||||
return
|
row["date"] = date
|
||||||
|
row["chamber"] = chamber
|
||||||
row["wafer"] = wafer
|
row["notes"] = notes
|
||||||
row["date"] = date
|
row["selected"] = selected
|
||||||
row["chamber"] = chamber
|
row["masterType"] = master_type
|
||||||
row["notes"] = notes
|
|
||||||
row["selected"] = selected
|
|
||||||
row["masterType"] = master_type
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"wafer": wafer,
|
"wafer": wafer,
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class SerialPort:
|
|||||||
)
|
)
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
kwargs["timeout"] = timeout
|
kwargs["timeout"] = timeout
|
||||||
|
# Write timeout must never be None (= block forever): on macOS,
|
||||||
|
# pseudo-ports like Bluetooth-Incoming-Port accept the open and then
|
||||||
|
# hang the write, which froze detect in "Detecting..." permanently.
|
||||||
|
kwargs["write_timeout"] = timeout if timeout is not None else 2.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
return pyserial.Serial(port_name, SerialPort.BAUDRATE, **kwargs)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# TODO P4.2: unit tests for the DeviceController license guard (plan §4.2,
|
||||||
|
# docs/pending/license-gating-plan.md). Two cases:
|
||||||
|
# 1. license_lookup=lambda s: "" + fake _last_wafer_info → readMemoryAsync()
|
||||||
|
# emits readResult {"error": ...} and spawns no thread.
|
||||||
|
# 2. lookup returning "02" → proceeds to _read_worker (mock DeviceService).
|
||||||
|
# THINKING: guard is a trust boundary (plan §2.1); these fail if someone
|
||||||
|
# reorders the busy/license checks or renames the "serialNumber" key.
|
||||||
|
# Depends on P1.2 + P2.1 being implemented.
|
||||||
Reference in New Issue
Block a user