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
+153 -103
View File
@@ -28,15 +28,28 @@ Rectangle {
property bool memoryRead: 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 {
target: deviceController
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) {
if (result.success && 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) {
root.selectedTabIndex = 2
}
@@ -63,6 +76,7 @@ Rectangle {
function _doDetect() {
root.memoryRead = false
root.waferDetected = false
streamController.setMode("review")
streamController.stopStream()
deviceController.detectWafer()
@@ -213,6 +227,15 @@ Rectangle {
model: pillModel
delegate: Button {
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
flat: true
Layout.fillWidth: true
@@ -523,10 +546,12 @@ Rectangle {
readonly property string fileA: !!dt ? (dt.useMasterFile ? dt.masterFileForRunB : dt.compareFileA) : ""
readonly property bool masterMissing: !!dt && dt.useMasterFile && dt.masterFileForRunB === ""
readonly property bool sameFile: !!dt && fileA !== "" && fileA === dt.compareFileB
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing
ToolTip.visible: (sameFile || masterMissing) && hovered
readonly property string gateError: !!dt ? dt.familyGateError : ""
enabled: !!dt && fileA !== "" && dt.compareFileB !== "" && !sameFile && !dt.comparing && !masterMissing && gateError === ""
ToolTip.visible: (sameFile || masterMissing || gateError !== "") && hovered
ToolTip.text: masterMissing
? "No master file registered for this wafer type (Settings → Master Files)"
: gateError !== "" ? gateError
: "Choose two different runs to compare"
onClicked: {
dt.comparing = true
@@ -552,7 +577,9 @@ Rectangle {
: compareRunBtn.enabled
? "Run DTW Comparison"
: (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
font.pixelSize: Theme.fontSm
font.bold: true
@@ -569,27 +596,28 @@ Rectangle {
}
// ── BOX 2: Context-Sensitive Rail Panel ────────────────────
Rectangle {
StackLayout {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: 120
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
// ── Status tab: Hardware Actions + Log Actions ──────────
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 8
spacing: Theme.sideRailSpacing
StackLayout {
Rectangle {
Layout.fillWidth: true
Layout.fillHeight: true
currentIndex: root.selectedTabIndex === 0 ? 0 : 1
Layout.preferredHeight: hwActionsCol.implicitHeight + 28
radius: Theme.sidePanelRadius
border.color: Theme.sideBorder
border.width: 1
color: Theme.sidePanelBackground
// ── Status tab: Hardware Actions ────────────────
ColumnLayout {
id: hwActionsCol
anchors.fill: parent
anchors.margins: 14
spacing: 6
Label {
@@ -597,7 +625,6 @@ Rectangle {
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.fontSm
topPadding: 6
font.letterSpacing: 1.5
font.bold: true
}
@@ -627,16 +654,62 @@ Rectangle {
onClicked: deviceController.eraseMemory(
deviceController.selectedPort)
}
Item { Layout.fillHeight: true }
}
}
// ── Map tab: Source file browser ──────────────
SourcePanel {
Layout.fillWidth: true
Layout.fillHeight: true
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 }
}
// ── 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
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 {
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
Layout.fillWidth: true
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: modeChipLabel.implicitWidth + 12
height: 18
radius: 4
@@ -755,11 +780,34 @@ Rectangle {
? streamController.mode.toUpperCase() : "REVIEW"
color: Theme.sideMutedText
font.family: Theme.uiFontFamily
font.pixelSize: Theme.font2xs
font.pixelSize: Theme.fontXs
font.bold: true
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 {
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
}
}
+47 -11
View File
@@ -24,11 +24,20 @@ Item {
function waferTypeForFile(path) {
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
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()
if (base.toLowerCase().indexOf("live_") === 0) return ""
return base ? base.charAt(0).toUpperCase() : ""
}
@@ -37,6 +46,19 @@ Item {
// simply won't exist under this key).
readonly property string runBWaferType: root.waferTypeForFile(root.compareFileB)
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 int frameOffset: 0
property real frameOffsetSeconds: 0
@@ -609,7 +631,15 @@ Item {
readonly property real padTop: 24
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 plotH = height - padTop - padBottom
var range = (maxV - minV) || 1
@@ -620,7 +650,8 @@ Item {
ctx.font = "10px " + Theme.uiFontFamily
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++) {
var yFrac = yi / ySteps
var yPx = padTop + plotH * yFrac
@@ -631,16 +662,17 @@ Item {
ctx.lineTo(padLeft + plotW, yPx)
ctx.stroke()
ctx.fillText(yVal.toFixed(1), padLeft - 6, yPx + 3)
ctx.fillText(yVal.toFixed(yDec), padLeft - 6, yPx + 3)
}
ctx.textAlign = "center"
var xSteps = Math.min(5, dataLen - 1)
for (var xi = 0; xi <= xSteps; xi++) {
var xFrac = xi / xSteps
var xPx = padLeft + plotW * xFrac
var xIdx = Math.round(xFrac * (dataLen - 1))
ctx.fillText(xIdx.toString(), xPx, height - 20)
var xMax = dataLen - 1
if (xMax > 0) {
var xStep = niceStep(xMax / 5 || 1)
for (var xIdx = 0; xIdx <= xMax; xIdx += xStep) {
var xPx = padLeft + (xIdx / xMax) * plotW
ctx.fillText(xIdx.toString(), xPx, height - 20)
}
}
ctx.save()
@@ -700,9 +732,13 @@ Item {
var padding = (maxV - minV) * 0.05
minV -= 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
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.trendDataB, minV, range, Theme.themeSkill, root.seriesLen)
drawScrubMarker(ctx)
+295 -305
View File
@@ -30,36 +30,45 @@ ColumnLayout {
radius: Theme.radiusSm
ColumnLayout {
anchors.centerIn: parent
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 4
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.bold: true
font.letterSpacing: 1.5
font.family: Theme.uiFontFamily
}
Text {
id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: valSize
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignHCenter
}
Text {
id: labelText
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
font.weight: Font.Medium
font.letterSpacing: 1.2
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignHCenter
Layout.fillHeight: true
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
fontSizeMode: Text.HorizontalFit
minimumPixelSize: Theme.fontMd
}
}
}
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 value: valueText.text
property bool active: root.waferDetected
spacing: 8
spacing: 2
Layout.fillWidth: true
Layout.fillHeight: true
Item { Layout.fillHeight: true }
Text {
id: labelText
color: Theme.sideMutedText
@@ -67,17 +76,15 @@ ColumnLayout {
font.family: Theme.uiFontFamily
Layout.fillWidth: true
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
Text {
id: valueText
color: active ? Theme.headingColor : Theme.sideMutedText
font.pixelSize: Theme.fontSm
font.pixelSize: Theme.fontXl
font.weight: Font.Bold
font.family: Theme.uiFontFamily
Layout.alignment: Qt.AlignRight
verticalAlignment: Text.AlignVCenter
}
Item { Layout.fillHeight: true }
}
// ── Aliases for external wiring ──
@@ -120,16 +127,64 @@ ColumnLayout {
}
}
// ═══════════════════════════════════════════════════════════════════════
// ROW 1: Connection Status (100%)
// ═══════════════════════════════════════════════════════════════════════
RowLayout {
Layout.fillWidth: true
Layout.preferredHeight: 90
Layout.maximumHeight: 90
spacing: 8
// Directory-only picker for the DIRECTORY card button — sets the save dir
// without kicking off a parse/save of pending read data.
FolderDialog {
id: dirOnlyDialog
title: "Choose a folder to save Data"
onAccepted: deviceController.setSaveDataDir(root.cleanFolderUrl(selectedFolder))
}
// 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 {
Layout.columnSpan: 2
Layout.fillWidth: true
Layout.fillHeight: true
color: Theme.cardBackground
@@ -138,13 +193,9 @@ ColumnLayout {
radius: Theme.radiusSm
ColumnLayout {
id: connStatusCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Theme.panelPadding
anchors.rightMargin: Theme.panelPadding
spacing: 8
anchors.fill: parent
anchors.margins: Theme.panelPadding
spacing: 6
Text {
text: "CONNECTION STATUS"
@@ -155,26 +206,11 @@ ColumnLayout {
font.family: Theme.uiFontFamily
}
Item { Layout.fillHeight: true }
RowLayout {
Layout.fillWidth: true
spacing: 0
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
}
}
spacing: 8
Item {
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: "WAFER & SENSOR DATA"
color: Theme.sideMutedText
font.pixelSize: Theme.fontXs
text: root.portName
color: root.isConnected ? Theme.statusSuccessColor : Theme.headingColor
font.pixelSize: Theme.fontMd
font.weight: Font.Bold
font.family: Theme.uiFontFamily
font.letterSpacing: 0.6
Layout.bottomMargin: 2
elide: Text.ElideMiddle
}
}
}
}
// ── 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.fillHeight: true
columns: 2
rowSpacing: 4
columnSpacing: 16
Layout.preferredHeight: 1
color: Theme.cardBorder
opacity: 0.5
}
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]) : "—";
}
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]) : "—";
}
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 {
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 {
id: runtimeCard
Layout.preferredWidth: 35
label: "TOTAL RUNTIME"
value: {
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
spacing: 0
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
color: Theme.subtleSectionBackground
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 36
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 {
anchors.fill: parent
@@ -489,64 +518,22 @@ ColumnLayout {
width: 14; height: 14
sourceSize.width: 14
sourceSize.height: 14
color: Theme.bodyColor
color: Theme.sideMutedText
visible: root.csvPath !== ""
Layout.alignment: Qt.AlignVCenter
}
Label {
text: root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG"
color: Theme.bodyColor
font.pixelSize: root.csvPath !== "" ? Theme.fontMd : Theme.fontSm
font.letterSpacing: root.csvPath !== "" ? 0 : 1.8
font.weight: Font.Medium
text: (root.csvPath !== "" ? root.csvPath.split("/").pop() : "ACTIVITY LOG").toUpperCase()
color: Theme.sideMutedText
font.pixelSize: Theme.fontMd
font.letterSpacing: 1.5
font.bold: true
font.family: Theme.uiFontFamily
elide: Text.ElideMiddle
Layout.fillWidth: true
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 {
@@ -567,7 +554,9 @@ ColumnLayout {
TextArea {
id: activityLog
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
font.family: "monospace"
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.dataCols = result.cols;
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 {
root.dataParsed = false;
}
+6
View File
@@ -48,6 +48,12 @@ def main() -> int:
engine.rootContext().setContextProperty("appVersion", app_version)
# ===== 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)
raw_settings = LocalSettings.read_settings(data_dir)
device_controller = DeviceController(raw_settings, data_dir)
@@ -73,6 +73,15 @@ class DeviceController(QObject):
self._activity_log: list[str] = []
self._raw_bytes: Optional[bytes] = None
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_csv_path: str = ""
self._selected_port: str = ""
@@ -116,6 +125,11 @@ class DeviceController(QObject):
"""Whether a hardware operation is currently running."""
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)
def saveDataDir(self) -> str:
"""Directory where parsed CSV data files are saved."""
@@ -324,6 +338,7 @@ class DeviceController(QObject):
self._graph_view.resetChart()
self._activity_log.clear()
self.detectResult.emit(None)
self.selectedPortChanged.emit()
self.parsedDataReady.emit({"success": False})
self._set_operation_progress(True)
@@ -349,20 +364,27 @@ class DeviceController(QObject):
self, port: Optional[str], info: Optional[WaferInfo]
) -> None:
"""Main-thread completion handler for detectWafer."""
if info is not None:
self._selected_port = port or ""
self._set_connection_status("Connected")
wafer_dict = self._wafer_info_to_dict(info)
self._last_wafer_info = wafer_dict
self.detectResult.emit(wafer_dict)
self._save_status()
else:
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._set_operation_progress(False)
try:
if info is not None:
# Build the dict (can raise) before declaring success, so a
# bad payload can't leave status="Connected" while
# waferDetected stays false and Read/Erase stay greyed.
wafer_dict = self._wafer_info_to_dict(info)
self._selected_port = port or ""
self._last_wafer_info = wafer_dict
self._set_connection_status("Connected")
self.detectResult.emit(wafer_dict)
self.selectedPortChanged.emit()
self._save_status()
else:
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_error_boundary
@@ -377,6 +399,16 @@ class DeviceController(QObject):
self._append_log("Already busy — ignoring read request")
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:
self._append_log("No wafer detected — run Detect Wafer first")
self.readResult.emit({"error": "No port — detect wafer first"})
@@ -436,6 +468,9 @@ class DeviceController(QObject):
if self._operation_in_progress:
self._append_log("Already busy — ignoring erase request")
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_connection_status("Erasing...")
self.portsUpdated.emit(self._service.enumerate_ports())
+9 -10
View File
@@ -71,17 +71,16 @@ class FileBrowser(QObject):
self._show_error(f"CSV file was not found:\n{file_path}")
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)
if row is None:
self._show_error("The selected CSV row is no longer available.")
return
row["wafer"] = wafer
row["date"] = date
row["chamber"] = chamber
row["notes"] = notes
row["selected"] = selected
row["masterType"] = master_type
if row is not None:
row["wafer"] = wafer
row["date"] = date
row["chamber"] = chamber
row["notes"] = notes
row["selected"] = selected
row["masterType"] = master_type
payload = {
"wafer": wafer,
+4
View File
@@ -65,6 +65,10 @@ class SerialPort:
)
if timeout is not None:
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:
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.