feat: redesign StatusTab UI with bento-grid layout and add CSV metadata editing capabilities

This commit is contained in:
jack
2026-07-07 16:41:40 -07:00
parent 92f130b3bd
commit b6903af625
8 changed files with 571 additions and 443 deletions
+124 -74
View File
@@ -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
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions + Log Actions ──────────
ColumnLayout {
spacing: Theme.sideRailSpacing
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
radius: Theme.sidePanelRadius radius: Theme.sidePanelRadius
border.color: Theme.sideBorder border.color: Theme.sideBorder
border.width: 1 border.width: 1
color: Theme.sidePanelBackground color: Theme.sidePanelBackground
ColumnLayout { ColumnLayout {
id: hwActionsCol
anchors.fill: parent anchors.fill: parent
anchors.margins: 14 anchors.margins: 14
spacing: 8
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions ────────────────
ColumnLayout {
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,15 +654,61 @@ Rectangle {
onClicked: deviceController.eraseMemory( onClicked: deviceController.eraseMemory(
deviceController.selectedPort) deviceController.selectedPort)
} }
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: logActionsCol.implicitHeight + 28
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 } Item { Layout.fillHeight: true }
} }
// ── Map tab: Source file browser ────────────── // ── Map tab: Source file browser ──────────────
Rectangle {
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
SourcePanel { SourcePanel {
Layout.fillWidth: true anchors.fill: parent
Layout.fillHeight: true anchors.margins: 14
}
} }
} }
} }
@@ -657,11 +730,7 @@ Rectangle {
anchors.margins: 14 anchors.margins: 14
spacing: 6 spacing: 6
// Row 1: Title + status badge // Row 1: Title
RowLayout {
spacing: 8
Layout.fillWidth: true
Label { Label {
text: "CONNECTION FLOW" text: "CONNECTION FLOW"
color: Theme.sideMutedText color: Theme.sideMutedText
@@ -673,53 +742,9 @@ Rectangle {
elide: Text.ElideRight elide: Text.ElideRight
} }
Rectangle { // Row 2: Port / descriptive text
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 { 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 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: { text: {
if (deviceController.connectionStatus === "Connected") if (deviceController.connectionStatus === "Connected")
return deviceController.selectedPort || "Connected" return deviceController.selectedPort || "Connected"
@@ -729,18 +754,18 @@ Rectangle {
} }
color: Theme.bodyColor color: Theme.bodyColor
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontXs font.pixelSize: Theme.fontSm
elide: Text.ElideRight elide: Text.ElideRight
} }
}
// Row 3: Mode chip — neutral gray tag Item { Layout.fillHeight: true }
Row {
// Row 3: Mode chip left, status badge bottom-right
RowLayout {
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
} }
} }
+46 -10
View File
@@ -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,17 +662,18 @@ 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()
ctx.translate(14, padTop + plotH / 2) ctx.translate(14, padTop + plotH / 2)
@@ -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)
+221 -231
View File
@@ -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,44 +275,97 @@ 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 {
text: root.portName text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd font.pixelSize: Theme.fontMd
font.weight: Font.Bold font.weight: Font.Bold
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight elide: Text.ElideMiddle
}
}
} }
} }
} }
} }
// ═══════════════════════════════════════════════════════════════════════ // ── Wafer & Sensor Data: far right, spans both rows ──
// ROW 2: Wafer & Sensor + Directory (65%) | Total Runtime (35%) Rectangle {
// ═══════════════════════════════════════════════════════════════════════ Layout.rowSpan: 2
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 90
Layout.maximumHeight: 90
spacing: 8
RowLayout {
Layout.preferredWidth: 65
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
spacing: 8 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] : "—";
}
}
Rectangle {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Theme.cardBorder
opacity: 0.5
}
GridCell {
id: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
active: root.waferDetected
}
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 { Rectangle {
Layout.fillWidth: true Layout.fillWidth: true
Layout.fillHeight: true Layout.fillHeight: true
@@ -290,9 +379,9 @@ ColumnLayout {
anchors.margins: Theme.panelPadding anchors.margins: Theme.panelPadding
spacing: 6 spacing: 6
ColumnLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
spacing: 2 spacing: 8
Text { Text {
text: "DIRECTORY" text: "DIRECTORY"
@@ -301,27 +390,13 @@ ColumnLayout {
font.bold: true font.bold: true
font.letterSpacing: 1.5 font.letterSpacing: 1.5
font.family: Theme.uiFontFamily 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 Layout.fillWidth: true
} }
}
RowLayout {
Layout.fillWidth: true
spacing: 8
Rectangle { Rectangle {
visible: root.csvPath !== "" visible: root.csvPath !== ""
width: sessionSavedLabel.implicitWidth + 12 implicitWidth: sessionSavedLabel.implicitWidth + 12
height: 18 implicitHeight: 18
radius: 4 radius: 4
color: Theme.fieldBackground color: Theme.fieldBackground
border.color: Theme.sideBorder border.color: Theme.sideBorder
@@ -338,17 +413,33 @@ ColumnLayout {
font.letterSpacing: 0.6 font.letterSpacing: 0.6
} }
} }
}
Item { Layout.fillWidth: true } 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 { Button {
id: browseBtn id: browseBtn
text: "Save As" text: "Select Folder"
font.pixelSize: Theme.fontSm font.pixelSize: Theme.fontSm
font.weight: Font.Medium font.weight: Font.Medium
font.family: Theme.uiFontFamily font.family: Theme.uiFontFamily
implicitHeight: 24 implicitHeight: 24
implicitWidth: 70
hoverEnabled: true hoverEnabled: true
background: Rectangle { background: Rectangle {
color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground color: browseBtn.hovered ? Theme.buttonNeutralHover : Theme.buttonNeutralBackground
@@ -363,97 +454,25 @@ ColumnLayout {
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
onClicked: saveDirDialog.open() onClicked: dirOnlyDialog.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: "WAFER & SENSOR DATA"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
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: 4
columnSpacing: 16
GridCell {
id: waferInfoFamilyCell
label: "Family Code"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 0 && info[0]) ? info[0] : "—";
}
}
GridCell {
id: waferCyclesCell
label: "Cycles Completed"
value: {
var info = deviceController.lastWaferInfo;
return (info && info.length > 5 && info[5] !== undefined) ? String(info[5]) : "—";
}
active: root.waferDetected
}
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]) : "—";
}
}
} }
} }
} }
} }
// ── 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"
} }
} }
@@ -477,6 +496,16 @@ ColumnLayout {
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;
} }
+6
View File
@@ -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,12 +364,17 @@ 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."""
try:
if info is not None: if info is not None:
self._selected_port = port or "" # Build the dict (can raise) before declaring success, so a
self._set_connection_status("Connected") # bad payload can't leave status="Connected" while
# waferDetected stays false and Read/Erase stay greyed.
wafer_dict = self._wafer_info_to_dict(info) wafer_dict = self._wafer_info_to_dict(info)
self._selected_port = port or ""
self._last_wafer_info = wafer_dict self._last_wafer_info = wafer_dict
self._set_connection_status("Connected")
self.detectResult.emit(wafer_dict) self.detectResult.emit(wafer_dict)
self.selectedPortChanged.emit()
self._save_status() self._save_status()
else: else:
self._selected_port = "" self._selected_port = ""
@@ -362,6 +382,8 @@ class DeviceController(QObject):
self._append_log("No wafer detected on any port") self._append_log("No wafer detected on any port")
self._last_wafer_info = {} self._last_wafer_info = {}
self.detectResult.emit(None) self.detectResult.emit(None)
self.selectedPortChanged.emit()
finally:
self._set_operation_progress(False) self._set_operation_progress(False)
@Slot() @Slot()
@@ -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())
+3 -4
View File
@@ -71,11 +71,10 @@ 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.")
return
row["wafer"] = wafer row["wafer"] = wafer
row["date"] = date row["date"] = date
row["chamber"] = chamber row["chamber"] = chamber
+4
View File
@@ -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)
+8
View File
@@ -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.