diff --git a/src/qml/BlockchainView.qml b/src/qml/BlockchainView.qml index ce2279e..5e4f924 100644 --- a/src/qml/BlockchainView.qml +++ b/src/qml/BlockchainView.qml @@ -183,19 +183,22 @@ Rectangle { ? root.backend.status === BlockchainBackend.Running : false - // Channel Deposit requires a running node. If the node stops while - // it's selected, fall back to Accounts so the user isn't stranded - // on a disabled nav item. + // Wallet operations require a running node. If the node stops while + // the Operations tab is open, fall back to the Node tab so the user + // isn't stranded on a disabled tab. onNodeRunningChanged: { - if (!nodeRunning && operationIndex === 3) - operationIndex = 0 + if (!nodeRunning) + operationTabBar.currentIndex = 0 } LogosTabBar { id: operationTabBar Layout.fillWidth: true LogosTabButton { text: qsTr("Node") } - LogosTabButton { text: qsTr("Operations") } + LogosTabButton { + text: qsTr("Operations") + enabled: opPage.nodeRunning + } } StackLayout { @@ -266,11 +269,7 @@ Rectangle { NavItem { label: qsTr("Accounts"); index: 0 } NavItem { label: qsTr("Transfer"); index: 1 } NavItem { label: qsTr("Leader Rewards"); index: 2 } - NavItem { - label: qsTr("Channel Deposit") - index: 3 - itemEnabled: opPage.nodeRunning - } + NavItem { label: qsTr("Channel Deposit"); index: 3 } Item { Layout.fillHeight: true } } @@ -406,7 +405,6 @@ Rectangle { component NavItem: Rectangle { property string label property int index - property bool itemEnabled: true Layout.fillWidth: true Layout.preferredHeight: 40 @@ -414,7 +412,6 @@ Rectangle { color: opPage.operationIndex === index ? Theme.palette.backgroundTertiary : (navMouse.containsMouse ? Theme.palette.backgroundSecondary : "transparent") - opacity: itemEnabled ? 1.0 : 0.4 LogosText { anchors.left: parent.left @@ -434,7 +431,6 @@ Rectangle { MouseArea { id: navMouse anchors.fill: parent - enabled: itemEnabled hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: opPage.operationIndex = index diff --git a/src/qml/controls/BlockDelegate.qml b/src/qml/controls/BlockDelegate.qml index 31e0580..5e62e65 100644 --- a/src/qml/controls/BlockDelegate.qml +++ b/src/qml/controls/BlockDelegate.qml @@ -19,6 +19,12 @@ Rectangle { // The transactions role is a QStringList; surface it for the Repeater. readonly property var transactionsList: model.transactions || [] + // Roles read as `undefined` during the brief QtRO replica sync at node + // startup. Treat the fallback as active ONLY when the model says so + // explicitly (parsed === false); undefined means "still loading", not + // "unparsed" — otherwise freshly-arrived blocks flash as Unparsed. + readonly property bool isUnparsed: model.parsed === false + width: ListView.view ? ListView.view.width : implicitWidth implicitHeight: col.implicitHeight + 2 * Theme.spacing.medium @@ -53,7 +59,7 @@ Rectangle { } LogosText { - visible: model.parsed + visible: !del.isUnparsed text: qsTr("slot %1").arg(model.slot || qsTr("?")) font.pixelSize: Theme.typography.secondaryText color: Theme.palette.textSecondary @@ -61,7 +67,7 @@ Rectangle { // Version badge Rectangle { - visible: model.parsed && (model.version || "").length > 0 + visible: !del.isUnparsed && (model.version || "").length > 0 radius: Theme.spacing.radiusSmall color: Theme.palette.backgroundSecondary border.color: Theme.palette.border @@ -78,7 +84,7 @@ Rectangle { } LogosText { - visible: !model.parsed + visible: del.isUnparsed text: qsTr("Unparsed block") font.pixelSize: Theme.typography.secondaryText color: Theme.palette.warning @@ -87,7 +93,7 @@ Rectangle { Item { Layout.fillWidth: true } LogosText { - visible: model.parsed + visible: !del.isUnparsed text: qsTr("%1 tx").arg(model.txCount || 0) font.pixelSize: Theme.typography.secondaryText color: Theme.palette.textSecondary @@ -109,14 +115,23 @@ Rectangle { } // Parsed: structured header - HashRow { label: qsTr("Parent block"); value: model.parentBlock || ""; visible: model.parsed } - HashRow { label: qsTr("Block root"); value: model.blockRoot || ""; visible: model.parsed } - HashRow { label: qsTr("Signature"); value: model.signature || ""; visible: model.parsed } + HashRow { + label: qsTr("Parent block"); value: model.parentBlock || ""; visible: !del.isUnparsed + onCopyRequested: (t) => del.copyToClipboard(t) + } + HashRow { + label: qsTr("Block root"); value: model.blockRoot || ""; visible: !del.isUnparsed + onCopyRequested: (t) => del.copyToClipboard(t) + } + HashRow { + label: qsTr("Signature"); value: model.signature || ""; visible: !del.isUnparsed + onCopyRequested: (t) => del.copyToClipboard(t) + } // Proof of leadership (collapsible sub-group) RowLayout { Layout.fillWidth: true - visible: model.parsed + visible: !del.isUnparsed spacing: Theme.spacing.small LogosText { text: (del.proofExpanded ? "▾ " : "▸ ") + qsTr("Proof of leadership") @@ -129,34 +144,35 @@ Rectangle { ColumnLayout { Layout.fillWidth: true Layout.leftMargin: Theme.spacing.medium - visible: model.parsed && del.proofExpanded + visible: !del.isUnparsed && del.proofExpanded spacing: Theme.spacing.small - HashRow { label: qsTr("Leader key"); value: model.leaderKey || "" } - HashRow { label: qsTr("Entropy"); value: model.entropy || "" } - HashRow { label: qsTr("Proof"); value: model.proof || "" } - HashRow { label: qsTr("Voucher cm"); value: model.voucherCm || "" } + HashRow { label: qsTr("Leader key"); value: model.leaderKey || ""; onCopyRequested: (t) => del.copyToClipboard(t) } + HashRow { label: qsTr("Entropy"); value: model.entropy || ""; onCopyRequested: (t) => del.copyToClipboard(t) } + HashRow { label: qsTr("Proof"); value: model.proof || ""; onCopyRequested: (t) => del.copyToClipboard(t) } + HashRow { label: qsTr("Voucher cm"); value: model.voucherCm || ""; onCopyRequested: (t) => del.copyToClipboard(t) } } // Transactions LogosText { - visible: model.parsed + visible: !del.isUnparsed text: qsTr("Transactions (%1)").arg(model.txCount || 0) font.pixelSize: Theme.typography.secondaryText font.bold: true } ColumnLayout { Layout.fillWidth: true - visible: model.parsed - spacing: Theme.spacing.tiny + visible: !del.isUnparsed + spacing: Theme.spacing.small Repeater { model: del.expanded ? del.transactionsList : [] - delegate: TxItem { + delegate: TransactionDelegate { required property int index required property string modelData Layout.fillWidth: true idx: index json: modelData + onCopyToClipboard: (t) => del.copyToClipboard(t) } } @@ -171,7 +187,7 @@ Rectangle { // Unparsed: raw fallback RowLayout { Layout.fillWidth: true - visible: !model.parsed + visible: del.isUnparsed spacing: Theme.spacing.small LogosText { text: qsTr("Raw payload") @@ -183,80 +199,9 @@ Rectangle { } JsonBlock { Layout.fillWidth: true - visible: !model.parsed - json: model.rawJson || "" + visible: del.isUnparsed + json: model.rawJson || qsTr("(no payload)") } } } - - // ---- Inline helpers ---- - - component HashRow: RowLayout { - property string label - property string value - Layout.fillWidth: true - spacing: Theme.spacing.small - LogosText { - text: label - Layout.preferredWidth: 110 - color: Theme.palette.textSecondary - font.pixelSize: Theme.typography.secondaryText - } - LogosText { - Layout.fillWidth: true - text: value && value.length > 0 ? value : "—" - elide: Text.ElideMiddle - font.pixelSize: Theme.typography.secondaryText - font.family: "monospace" - } - LogosCopyButton { - visible: value && value.length > 0 - onCopyText: del.copyToClipboard(value) - } - } - - component JsonBlock: Rectangle { - property string json - implicitHeight: jsonText.implicitHeight + 2 * Theme.spacing.small - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusSmall - border.color: Theme.palette.border - border.width: 1 - LogosText { - id: jsonText - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Theme.spacing.small - text: parent.json - font.pixelSize: Theme.typography.secondaryText - font.family: "monospace" - wrapMode: Text.WrapAnywhere - } - } - - component TxItem: ColumnLayout { - id: txRoot - property int idx - property string json - property bool open: false - spacing: Theme.spacing.tiny - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - LogosText { - text: (txRoot.open ? "▾ " : "▸ ") + qsTr("Transaction %1").arg(txRoot.idx + 1) - font.pixelSize: Theme.typography.secondaryText - TapHandler { onTapped: txRoot.open = !txRoot.open } - } - Item { Layout.fillWidth: true } - LogosCopyButton { onCopyText: del.copyToClipboard(txRoot.json) } - } - JsonBlock { - Layout.fillWidth: true - visible: txRoot.open - json: txRoot.json - } - } } diff --git a/src/qml/controls/HashRow.qml b/src/qml/controls/HashRow.qml new file mode 100644 index 0000000..0ff568d --- /dev/null +++ b/src/qml/controls/HashRow.qml @@ -0,0 +1,41 @@ +import QtQuick +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +// A labelled value row with an elided monospace value and a copy button. +// Used for hashes/keys and any short scalar field. +RowLayout { + id: root + + property string label: "" + property string value: "" + property int labelWidth: 110 + property bool copyable: true + + signal copyRequested(string text) + + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosText { + visible: root.label.length > 0 + text: root.label + Layout.preferredWidth: root.labelWidth + Layout.alignment: Qt.AlignTop + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + LogosText { + Layout.fillWidth: true + text: root.value && root.value.length > 0 ? root.value : "—" + elide: Text.ElideMiddle + font.pixelSize: Theme.typography.secondaryText + font.family: "monospace" + } + LogosCopyButton { + visible: root.copyable && root.value && root.value.length > 0 + onCopyText: root.copyRequested(root.value) + } +} diff --git a/src/qml/controls/JsonBlock.qml b/src/qml/controls/JsonBlock.qml new file mode 100644 index 0000000..4d67eb4 --- /dev/null +++ b/src/qml/controls/JsonBlock.qml @@ -0,0 +1,32 @@ +import QtQuick +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +// A boxed, monospace, wrapping JSON/text block. Used for prettified payloads +// and raw fallbacks. +Rectangle { + id: root + + property string json: "" + + Layout.fillWidth: true + implicitHeight: jsonText.implicitHeight + 2 * Theme.spacing.small + color: Theme.palette.backgroundSecondary + radius: Theme.spacing.radiusSmall + border.color: Theme.palette.border + border.width: 1 + + LogosText { + id: jsonText + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacing.small + text: root.json + font.pixelSize: Theme.typography.secondaryText + font.family: "monospace" + wrapMode: Text.WrapAnywhere + } +} diff --git a/src/qml/controls/TransactionDelegate.qml b/src/qml/controls/TransactionDelegate.qml new file mode 100644 index 0000000..13157b0 --- /dev/null +++ b/src/qml/controls/TransactionDelegate.qml @@ -0,0 +1,179 @@ +import QtQuick +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +// Structured view of a single block transaction (a SignedMantleTx JSON string). +// +// Shape (see nomos-node core/src/mantle): +// { "mantle_tx": { "ops": [ {opcode, payload}, ... ] }, +// "ops_proofs": [ , ... ] } // paired with ops by index +// +// Each op is labelled by its opcode name; the payload (and paired proof) are +// shown as prettified JSON. Unparsable transactions fall back to raw JSON. +ColumnLayout { + id: txRoot + + property string json: "" + property int idx: 0 + property bool open: false + + signal copyToClipboard(string text) + + readonly property var parsed: safeParse(json) + readonly property bool parseFailed: parsed === null + readonly property var ops: (parsed && parsed.mantle_tx && parsed.mantle_tx.ops) + ? parsed.mantle_tx.ops : [] + readonly property var proofs: (parsed && parsed.ops_proofs) ? parsed.ops_proofs : [] + + spacing: Theme.spacing.tiny + + function safeParse(s) { + try { return JSON.parse(s) } catch (e) { return null } + } + + function opName(code) { + switch (code) { + case 0: return qsTr("Transfer") + case 16: return qsTr("Channel Config") + case 17: return qsTr("Channel Inscribe") + case 18: return qsTr("Channel Deposit") + case 19: return qsTr("Channel Withdraw") + case 32: return qsTr("SDP Declare") + case 33: return qsTr("SDP Withdraw") + case 34: return qsTr("SDP Active") + case 48: return qsTr("Leader Claim") + default: return qsTr("Op 0x%1").arg(Number(code).toString(16)) + } + } + + function opSummary() { + if (parseFailed) return qsTr("unparsed") + if (!ops.length) return qsTr("no ops") + var names = [] + for (var i = 0; i < ops.length; i++) names.push(opName(ops[i].opcode)) + return names.join(", ") + } + + function pretty(v) { + try { return JSON.stringify(v, null, 2) } catch (e) { return String(v) } + } + + function proofVariant(p) { + if (!p || typeof p !== "object") return "" + var ks = Object.keys(p) + return ks.length ? ks[0] : "" + } + + // ---- Header (toggle) ---- + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosText { + text: (txRoot.open ? "▾ " : "▸ ") + qsTr("Transaction %1").arg(txRoot.idx + 1) + font.pixelSize: Theme.typography.secondaryText + font.bold: true + TapHandler { onTapped: txRoot.open = !txRoot.open } + } + LogosText { + Layout.fillWidth: true + text: txRoot.opSummary() + elide: Text.ElideRight + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + TapHandler { onTapped: txRoot.open = !txRoot.open } + } + LogosCopyButton { onCopyText: txRoot.copyToClipboard(txRoot.json) } + } + + // ---- Body ---- + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacing.medium + visible: txRoot.open + spacing: Theme.spacing.small + + JsonBlock { + Layout.fillWidth: true + visible: txRoot.parseFailed + json: txRoot.json + } + + Repeater { + model: txRoot.open && !txRoot.parseFailed ? txRoot.ops : [] + delegate: OpView { + required property int index + required property var modelData + Layout.fillWidth: true + op: modelData + proof: txRoot.proofs.length > index ? txRoot.proofs[index] : null + } + } + } + + // ---- Inline: one operation (opcode name + payload/proof JSON) ---- + component OpView: ColumnLayout { + id: opView + property var op + property var proof: null + spacing: Theme.spacing.tiny + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + Rectangle { + radius: Theme.spacing.radiusSmall + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.border + border.width: 1 + implicitWidth: opcodeText.implicitWidth + 2 * Theme.spacing.small + implicitHeight: opcodeText.implicitHeight + Theme.spacing.tiny + LogosText { + id: opcodeText + anchors.centerIn: parent + text: qsTr("op %1").arg(opView.op && opView.op.opcode !== undefined ? opView.op.opcode : "?") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + LogosText { + Layout.fillWidth: true + text: txRoot.opName(opView.op ? opView.op.opcode : -1) + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + LogosCopyButton { + onCopyText: txRoot.copyToClipboard(txRoot.pretty(opView.op ? opView.op.payload : null)) + } + } + + // payload as JSON + JsonBlock { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacing.medium + json: txRoot.pretty(opView.op ? opView.op.payload : null) + } + + // paired proof + RowLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacing.medium + visible: opView.proof !== null && opView.proof !== undefined + spacing: Theme.spacing.small + LogosText { + text: qsTr("Proof · %1").arg(txRoot.proofVariant(opView.proof) || qsTr("unknown")) + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + Item { Layout.fillWidth: true } + LogosCopyButton { onCopyText: txRoot.copyToClipboard(txRoot.pretty(opView.proof)) } + } + JsonBlock { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacing.medium + visible: opView.proof !== null && opView.proof !== undefined + json: (opView.proof !== null && opView.proof !== undefined) ? txRoot.pretty(opView.proof) : "" + } + } +}