diff --git a/CMakeLists.txt b/CMakeLists.txt index 60e6b42..badd769 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,8 +18,8 @@ logos_module( src/BlockchainBackend.cpp src/AccountsModel.h src/AccountsModel.cpp - src/LogModel.h - src/LogModel.cpp + src/BlockModel.h + src/BlockModel.cpp FIND_PACKAGES Qt6Gui LINK_LIBRARIES diff --git a/flake.lock b/flake.lock index 3f54632..3dd2fe1 100644 --- a/flake.lock +++ b/flake.lock @@ -21,17 +21,17 @@ "logos-module-builder": "logos-module-builder" }, "locked": { - "lastModified": 1781796694, - "narHash": "sha256-cW/sytFz22oMABjmTH6UHj3FS7Mo9kv62rbTir6s7rk=", + "lastModified": 1782217354, + "narHash": "sha256-pUt/GLJRhqj4Ap7WzGJtOVqlil1FF5s2NoCxPuAgt18=", "owner": "logos-blockchain", "repo": "logos-blockchain-module", - "rev": "ab733aa7074cf1992f605c203c3d6a7923602705", + "rev": "4c8df124929c6e86d21e2f0db50a99dedee901b3", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-blockchain-module", - "rev": "ab733aa7074cf1992f605c203c3d6a7923602705", + "rev": "4c8df124929c6e86d21e2f0db50a99dedee901b3", "type": "github" } }, @@ -44,16 +44,16 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1781698521, - "narHash": "sha256-cnk7CmRskPL5CjH3Q0vZEoLbYVkwUaQrq86xdJiLje8=", + "lastModified": 1782212034, + "narHash": "sha256-IcU+c3YokqSmv4huTHZsBUhsen8lLs3wGvtaMWfQths=", "owner": "logos-blockchain", "repo": "logos-blockchain", - "rev": "0222706b14010fbdfbe9a94d3617ebf46a77fdd1", + "rev": "113faae6fd5f68aa70e3cece4fa54f8522c95eec", "type": "github" }, "original": { "owner": "logos-blockchain", - "ref": "0222706b14010fbdfbe9a94d3617ebf46a77fdd1", + "ref": "113faae6fd5f68aa70e3cece4fa54f8522c95eec", "repo": "logos-blockchain", "type": "github" } diff --git a/flake.nix b/flake.nix index 6f99cbc..2329f61 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,7 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder/38ddf92c1f240f4e420d300a1fbabb1609d5db01"; nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx"; - liblogos_blockchain_module.url = "github:logos-blockchain/logos-blockchain-module/ab733aa7074cf1992f605c203c3d6a7923602705"; + liblogos_blockchain_module.url = "github:logos-blockchain/logos-blockchain-module/4c8df124929c6e86d21e2f0db50a99dedee901b3"; }; outputs = inputs@{ logos-module-builder, ... }: diff --git a/src/BlockModel.cpp b/src/BlockModel.cpp new file mode 100644 index 0000000..2f7719b --- /dev/null +++ b/src/BlockModel.cpp @@ -0,0 +1,167 @@ +#include "BlockModel.h" + +#include +#include +#include +#include +#include + +namespace { + +QString prettify(const QJsonValue& value) +{ + if (value.isObject()) + return QString::fromUtf8(QJsonDocument(value.toObject()).toJson(QJsonDocument::Indented)); + if (value.isArray()) + return QString::fromUtf8(QJsonDocument(value.toArray()).toJson(QJsonDocument::Indented)); + return value.toVariant().toString(); +} + +} // namespace + +int BlockModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + return m_entries.size(); +} + +QVariant BlockModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) + return QVariant(); + + const Entry& e = m_entries.at(index.row()); + switch (role) { + case TimestampRole: return e.timestamp; + case SlotRole: return e.slot; + case VersionRole: return e.version; + case ParentBlockRole: return e.parentBlock; + case BlockRootRole: return e.blockRoot; + case LeaderKeyRole: return e.leaderKey; + case EntropyRole: return e.entropy; + case ProofRole: return e.proof; + case VoucherCmRole: return e.voucherCm; + case SignatureRole: return e.signature; + case TxCountRole: return e.txCount; + case TransactionsRole: return e.transactions; + case RawJsonRole: return e.rawJson; + case ParsedRole: return e.parsed; + default: return QVariant(); + } +} + +QHash BlockModel::roleNames() const +{ + QHash names; + names[TimestampRole] = "timestamp"; + names[SlotRole] = "slot"; + names[VersionRole] = "version"; + names[ParentBlockRole] = "parentBlock"; + names[BlockRootRole] = "blockRoot"; + names[LeaderKeyRole] = "leaderKey"; + names[EntropyRole] = "entropy"; + names[ProofRole] = "proof"; + names[VoucherCmRole] = "voucherCm"; + names[SignatureRole] = "signature"; + names[TxCountRole] = "txCount"; + names[TransactionsRole] = "transactions"; + names[RawJsonRole] = "rawJson"; + names[ParsedRole] = "parsed"; + return names; +} + +void BlockModel::appendRaw(const QString& timestamp, const QString& rawJson) +{ + Entry e; + e.timestamp = timestamp; + + // The payload is double-encoded JSON: an outer {"block":""} + // whose value is itself a JSON-encoded block. Tolerate a few shapes: + // { "block": "" } (observed) + // { "block": { ... } } (already an object) + // { "header": ..., "transactions": ... }(block sent directly) + QJsonObject block; + bool ok = false; + + QJsonParseError err{}; + const QJsonDocument outer = QJsonDocument::fromJson(rawJson.toUtf8(), &err); + if (err.error == QJsonParseError::NoError && outer.isObject()) { + const QJsonObject o = outer.object(); + if (o.contains(QStringLiteral("block"))) { + const QJsonValue bv = o.value(QStringLiteral("block")); + if (bv.isString()) { + const QJsonDocument inner = + QJsonDocument::fromJson(bv.toString().toUtf8(), &err); + if (err.error == QJsonParseError::NoError && inner.isObject()) { + block = inner.object(); + ok = true; + } + } else if (bv.isObject()) { + block = bv.toObject(); + ok = true; + } + } else if (o.contains(QStringLiteral("header"))) { + block = o; + ok = true; + } + } + + if (ok) { + e.parsed = true; + + const QJsonObject header = block.value(QStringLiteral("header")).toObject(); + e.version = header.value(QStringLiteral("version")).toString(); + e.parentBlock = header.value(QStringLiteral("parent_block")).toString(); + + const QJsonValue slotV = header.value(QStringLiteral("slot")); + e.slot = slotV.isDouble() + ? QString::number(static_cast(slotV.toDouble())) + : slotV.toString(); + + e.blockRoot = header.value(QStringLiteral("block_root")).toString(); + + const QJsonObject pol = + header.value(QStringLiteral("proof_of_leadership")).toObject(); + e.proof = pol.value(QStringLiteral("proof")).toString(); + e.entropy = pol.value(QStringLiteral("entropy_contribution")).toString(); + e.leaderKey = pol.value(QStringLiteral("leader_key")).toString(); + e.voucherCm = pol.value(QStringLiteral("voucher_cm")).toString(); + + e.signature = block.value(QStringLiteral("signature")).toString(); + + const QJsonArray txs = block.value(QStringLiteral("transactions")).toArray(); + e.txCount = txs.size(); + for (const QJsonValue tx : txs) + e.transactions << prettify(tx); + + e.rawJson = QString::fromUtf8(QJsonDocument(block).toJson(QJsonDocument::Indented)); + } else { + // Keep the raw text so an unexpected format is still inspectable. + e.parsed = false; + e.rawJson = rawJson; + } + + beginInsertRows(QModelIndex(), 0, 0); + m_entries.prepend(e); + endInsertRows(); + + if (m_entries.size() > kMaxBlocks) { + const int last = m_entries.size() - 1; + beginRemoveRows(QModelIndex(), last, last); + m_entries.remove(last); + endRemoveRows(); + } + + emit countChanged(); +} + +void BlockModel::clear() +{ + if (m_entries.isEmpty()) + return; + beginResetModel(); + m_entries.clear(); + endResetModel(); + emit countChanged(); +} diff --git a/src/BlockModel.h b/src/BlockModel.h new file mode 100644 index 0000000..c955f81 --- /dev/null +++ b/src/BlockModel.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include + +// Structured model of recent blockchain blocks, fed by the backend's `newBlock` +// event. Each incoming payload is parsed once (in appendRaw) into header fields +// plus a list of prettified per-transaction JSON; unparsable payloads are kept +// as raw text (ParsedRole == false) so nothing is ever silently dropped. +// +// Newest block is row 0. Only the latest kMaxBlocks are retained. +class BlockModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) +public: + enum Roles { + TimestampRole = Qt::UserRole + 1, + SlotRole, + VersionRole, + ParentBlockRole, + BlockRootRole, + LeaderKeyRole, + EntropyRole, + ProofRole, + VoucherCmRole, + SignatureRole, + TxCountRole, + TransactionsRole, // QStringList: prettified JSON per transaction + RawJsonRole, // prettified full block (or the raw payload on parse failure) + ParsedRole, // bool: false when the payload could not be parsed + }; + + explicit BlockModel(QObject* parent = nullptr) : QAbstractListModel(parent) {} + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + // Parse a raw `newBlock` payload and insert it as the newest block (row 0), + // evicting the oldest once kMaxBlocks is exceeded. + Q_INVOKABLE void appendRaw(const QString& timestamp, const QString& rawJson); + Q_INVOKABLE void clear(); + +signals: + void countChanged(); + +private: + struct Entry { + QString timestamp; + QString slot; + QString version; + QString parentBlock; + QString blockRoot; + QString leaderKey; + QString entropy; + QString proof; + QString voucherCm; + QString signature; + int txCount = 0; + QStringList transactions; + QString rawJson; + bool parsed = false; + }; + + static constexpr int kMaxBlocks = 100; + QVector m_entries; +}; diff --git a/src/BlockchainBackend.cpp b/src/BlockchainBackend.cpp index 199d095..96c3f93 100644 --- a/src/BlockchainBackend.cpp +++ b/src/BlockchainBackend.cpp @@ -119,7 +119,7 @@ BlockchainBackend::BlockchainBackend(LogosAPI* logosAPI, QObject* parent) : BlockchainBackendSimpleSource(parent) , m_logosAPI(logosAPI) , m_accountsModel(new AccountsModel(this)) - , m_logModel(new LogModel(this)) + , m_blockModel(new BlockModel(this)) { setStatus(NotStarted); setUseGeneratedConfig(false); @@ -185,13 +185,8 @@ BlockchainBackend::BlockchainBackend(LogosAPI* logosAPI, QObject* parent) [this](const QString&, const QVariantList& data) { const QString timestamp = QDateTime::currentDateTime().toString("HH:mm:ss"); - QString line; - if (!data.isEmpty()) - line = QString("[%1] New block: %2") - .arg(timestamp, data.first().toString()); - else - line = QString("[%1] New block (no data)").arg(timestamp); - m_logModel->append(line); + const QString raw = data.isEmpty() ? QString() : data.first().toString(); + m_blockModel->appendRaw(timestamp, raw); }); } else { setError(QStringLiteral("Failed to subscribe to events")); @@ -215,6 +210,35 @@ QVariantMap BlockchainBackend::claimLeaderRewards() BLOCKCHAIN_MODULE_NAME, "leader_claim"))); } +QVariantMap BlockchainBackend::getCryptarchiaInfo() +{ + if (!m_blockchainClient) + return result::toVariantMap(result::err(QStringLiteral("Module not initialized."))); + + return result::toVariantMap(result::toLogosResult(m_blockchainClient->invokeRemoteMethod( + BLOCKCHAIN_MODULE_NAME, QStringLiteral("get_cryptarchia_info")))); +} + +QVariantMap BlockchainBackend::getPeerId() +{ + if (!m_blockchainClient) + return result::toVariantMap(result::err(QStringLiteral("Module not initialized."))); + + // Derived from the node key in the user config; available without the node + // running. + return result::toVariantMap(result::toLogosResult(m_blockchainClient->invokeRemoteMethod( + BLOCKCHAIN_MODULE_NAME, QStringLiteral("get_peer_id"), userConfig()))); +} + +QVariantMap BlockchainBackend::getClaimableVouchers() +{ + if (!m_blockchainClient) + return result::toVariantMap(result::err(QStringLiteral("Module not initialized."))); + + return result::toVariantMap(result::toLogosResult(m_blockchainClient->invokeRemoteMethod( + BLOCKCHAIN_MODULE_NAME, QStringLiteral("wallet_get_claimable_vouchers")))); +} + void BlockchainBackend::startBlockchain() { if (!m_blockchainClient) { @@ -423,9 +447,9 @@ QVariantMap BlockchainBackend::channelDepositWithNotes( args))); } -void BlockchainBackend::clearLogs() +void BlockchainBackend::clearBlocks() { - m_logModel->clear(); + m_blockModel->clear(); } void BlockchainBackend::copyToClipboard(QString text) diff --git a/src/BlockchainBackend.h b/src/BlockchainBackend.h index 4c5a342..bf3ccc0 100644 --- a/src/BlockchainBackend.h +++ b/src/BlockchainBackend.h @@ -10,7 +10,7 @@ #include "rep_BlockchainBackend_source.h" #include "AccountsModel.h" -#include "LogModel.h" +#include "BlockModel.h" class LogosAPI; class LogosAPIClient; @@ -20,22 +20,22 @@ class LogosAPIClient; // Inheriting from BlockchainBackendSimpleSource gives us the generated PROPs, // SLOTs and SIGNALs from BlockchainBackend.rep. // -// AccountsModel* / LogModel* are subclass-only Q_PROPERTYs — QAbstractItemModel* +// AccountsModel* / BlockModel* are subclass-only Q_PROPERTYs — QAbstractItemModel* // can't flow through a .rep, so ui-host auto-remotes each such property as // "/" (see logos-view-module-runtime/ui-host/main.cpp). -// QML acquires them via logos.model("blockchain_ui", "accounts"|"logs"). +// QML acquires them via logos.model("blockchain_ui", "accounts"|"blocks"). class BlockchainBackend : public BlockchainBackendSimpleSource { Q_OBJECT Q_PROPERTY(AccountsModel* accounts READ accounts CONSTANT) - Q_PROPERTY(LogModel* logs READ logs CONSTANT) + Q_PROPERTY(BlockModel* blocks READ blocks CONSTANT) public: explicit BlockchainBackend(LogosAPI* logosAPI, QObject* parent = nullptr); ~BlockchainBackend() override; AccountsModel* accounts() const { return m_accountsModel; } - LogModel* logs() const { return m_logModel; } + BlockModel* blocks() const { return m_blockModel; } public slots: // Overrides of the pure-virtual slots generated from the .rep. @@ -45,6 +45,9 @@ public slots: QVariantMap getBalance(QString addressHex) override; QVariantMap transferFunds(QString fromKeyHex, QString toKeyHex, QString amountStr) override; QVariantMap claimLeaderRewards() override; + QVariantMap getCryptarchiaInfo() override; + QVariantMap getPeerId() override; + QVariantMap getClaimableVouchers() override; QVariantMap generateConfig(QString outputPath, QStringList initialPeers, int netPort, int blendPort, QString httpAddr, QString externalAddress, bool noPublicIpCheck, int deploymentMode, @@ -57,7 +60,7 @@ public slots: QStringList fundingPublicKeyHexes, QString maxTxFee, QString optionalTipHex) override; - void clearLogs() override; + void clearBlocks() override; void copyToClipboard(QString text) override; private: @@ -67,7 +70,7 @@ private: LogosAPI* m_logosAPI = nullptr; LogosAPIClient* m_blockchainClient = nullptr; AccountsModel* m_accountsModel = nullptr; - LogModel* m_logModel = nullptr; + BlockModel* m_blockModel = nullptr; static const QString BLOCKCHAIN_MODULE_NAME; }; diff --git a/src/BlockchainBackend.rep b/src/BlockchainBackend.rep index ca27996..10190dd 100644 --- a/src/BlockchainBackend.rep +++ b/src/BlockchainBackend.rep @@ -15,9 +15,12 @@ class BlockchainBackend SLOT(QVariantMap getBalance(QString addressHex)) SLOT(QVariantMap transferFunds(QString fromKeyHex, QString toKeyHex, QString amountStr)) SLOT(QVariantMap claimLeaderRewards()) + SLOT(QVariantMap getCryptarchiaInfo()) + SLOT(QVariantMap getPeerId()) + SLOT(QVariantMap getClaimableVouchers()) SLOT(QVariantMap generateConfig(QString outputPath, QStringList initialPeers, int netPort, int blendPort, QString httpAddr, QString externalAddress, bool noPublicIpCheck, int deploymentMode, QString deploymentConfigPath, QString statePath)) SLOT(QVariantMap getNotes(QString walletAddressHex, QString optionalTipHex)) SLOT(QVariantMap channelDepositWithNotes(QString channelIdHex, QStringList inputNoteIdHexes, QString metadataBase58, QString changePublicKeyHex, QStringList fundingPublicKeyHexes, QString maxTxFee, QString optionalTipHex)) - SLOT(void clearLogs()) + SLOT(void clearBlocks()) SLOT(void copyToClipboard(QString text)) } diff --git a/src/LogModel.cpp b/src/LogModel.cpp deleted file mode 100644 index 35d9281..0000000 --- a/src/LogModel.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include "LogModel.h" - -int LogModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - return m_lines.size(); -} - -QVariant LogModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_lines.size()) - return QVariant(); - if (role == TextRole || role == Qt::DisplayRole) - return m_lines.at(index.row()); - return QVariant(); -} - -QHash LogModel::roleNames() const -{ - QHash names; - names[TextRole] = "text"; - return names; -} - -void LogModel::append(const QString& line) -{ - const int row = m_lines.size(); - beginInsertRows(QModelIndex(), row, row); - m_lines.append(line); - endInsertRows(); - emit countChanged(); -} - -void LogModel::clear() -{ - if (m_lines.isEmpty()) - return; - beginResetModel(); - m_lines.clear(); - endResetModel(); - emit countChanged(); -} diff --git a/src/LogModel.h b/src/LogModel.h deleted file mode 100644 index 215de63..0000000 --- a/src/LogModel.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include - -class LogModel : public QAbstractListModel { - Q_OBJECT - Q_PROPERTY(int count READ rowCount NOTIFY countChanged) -public: - enum Roles { TextRole = Qt::UserRole + 1 }; - - explicit LogModel(QObject* parent = nullptr) : QAbstractListModel(parent) {} - - int rowCount(const QModelIndex& parent = QModelIndex()) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - - Q_INVOKABLE void append(const QString& line); - Q_INVOKABLE void clear(); - -signals: - void countChanged(); - -private: - QStringList m_lines; -}; diff --git a/src/qml/BlockchainView.qml b/src/qml/BlockchainView.qml index 21a2efa..6656a5d 100644 --- a/src/qml/BlockchainView.qml +++ b/src/qml/BlockchainView.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Window import Logos.Theme import Logos.Controls @@ -24,8 +25,10 @@ Rectangle { Connections { target: logos function onViewModuleReadyChanged(moduleName, isReady) { - if (moduleName === "blockchain_ui") + if (moduleName === "blockchain_ui") { root.ready = isReady && root.backend !== null + if (root.ready) root.refreshPeerId() + } } } @@ -33,12 +36,48 @@ Rectangle { // Cover the case where the replica is already Valid by the time // we attach the Connections handler. root.ready = root.backend !== null && logos.isViewModuleReady("blockchain_ui") + if (root.ready) root.refreshPeerId() + } + + // Graceful shutdown: if the window is closed while the node is running, + // veto the close, stop the node, then close once it has stopped. + property bool quitting: false + + function _nodeBusy() { + return root.backend + && (root.backend.status === BlockchainBackend.Running + || root.backend.status === BlockchainBackend.Starting + || root.backend.status === BlockchainBackend.Stopping) + } + + Connections { + target: root.Window.window + enabled: root.Window.window !== null + ignoreUnknownSignals: true + function onClosing(close) { + if (!root.quitting && root._nodeBusy()) { + root.quitting = true + close.accepted = false + root.backend.stopBlockchain() + } + } + } + + // Once the stop initiated above completes, finish closing the window. + Connections { + target: root.backend + enabled: root.quitting && root.backend !== null + ignoreUnknownSignals: true + function onStatusChanged() { + if (root.quitting && !root._nodeBusy() && root.Window.window) + root.Window.window.close() + } } // Models live on the C++ backend and are auto-remoted by ui-host as // "/". QML acquires them via logos.model(...). readonly property var accountsModel: logos.model("blockchain_ui", "accounts") - readonly property var logModel: logos.model("blockchain_ui", "logs") + readonly property var blockModel: logos.model("blockchain_ui", "blocks") // Clipboard must be handled here in the UI-host (GUI) process. The backend // .rep source runs in a separate, non-GUI ViewModuleHost subprocess where @@ -57,6 +96,90 @@ Rectangle { visible: false } + // Self libp2p peer id, derived from the selected user config (no running + // node required). Refreshed when ready and whenever the config changes. + property string peerId: "" + + function refreshPeerId() { + if (!root.backend || !root.backend.userConfig) { + root.peerId = "" + return + } + logos.watch( + root.backend.getPeerId(), + function(result) { root.peerId = result.success ? result.value : "" }, + function(error) { root.peerId = "" } + ) + } + + Connections { + target: root.backend + enabled: root.backend !== null + ignoreUnknownSignals: true + function onUserConfigChanged() { root.refreshPeerId() } + } + + // Live Cryptarchia consensus state, polled while the node runs. + property string cryptarchiaInfoJson: "" + property string cryptarchiaInfoError: "" + + Timer { + id: cryptarchiaTimer + interval: 2000 + repeat: true + triggeredOnStart: true + running: root.ready && root.backend + && root.backend.status === BlockchainBackend.Running + onTriggered: { + if (!root.backend) return + logos.watch( + root.backend.getCryptarchiaInfo(), + function(result) { + if (result.success) { + root.cryptarchiaInfoJson = result.value + root.cryptarchiaInfoError = "" + } else { + root.cryptarchiaInfoError = _d.errorText(result.error) + } + }, + function(error) { root.cryptarchiaInfoError = _d.errorText(error) } + ) + } + } + + // Wallet's claimable ("pending") vouchers. Auto-refreshed on every incoming + // block, and once when the node starts running. + property string claimableVouchersJson: "" + + function refreshClaimableVouchers() { + if (!root.backend || root.backend.status !== BlockchainBackend.Running) + return + logos.watch( + root.backend.getClaimableVouchers(), + function(result) { if (result.success) root.claimableVouchersJson = result.value }, + function(error) { /* keep last known list on transient errors */ } + ) + } + + // Incoming blocks arrive as row insertions on the remoted block model. + Connections { + target: root.blockModel + enabled: root.blockModel !== null + ignoreUnknownSignals: true + function onRowsInserted() { root.refreshClaimableVouchers() } + } + + // Initial load when the node reaches Running (before the next block). + Connections { + target: root.backend + enabled: root.backend !== null + ignoreUnknownSignals: true + function onStatusChanged() { + if (root.backend.status === BlockchainBackend.Running) + root.refreshClaimableVouchers() + } + } + QtObject { id: _d function errorText(message) { @@ -170,18 +293,38 @@ Rectangle { } } - // Page 2: Node control, wallet, logs + Channel Deposit (tabbed) + // Page 2: Node information + Wallet operations (tabbed) ColumnLayout { + id: opPage spacing: Theme.spacing.medium + // Selected operation inside the Operations tab's sidebar nav. + // 0 Accounts · 1 Transfer · 2 Leader Rewards · 3 Channel Deposit + property int operationIndex: 0 + + // When pinned, Accounts stays visible (stacked on top) even while a + // different operation is selected. + property bool accountsPinned: false + + readonly property bool nodeRunning: root.backend + ? root.backend.status === BlockchainBackend.Running + : false + + // 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) + operationTabBar.currentIndex = 0 + } + LogosTabBar { id: operationTabBar Layout.fillWidth: true - LogosTabButton { text: qsTr("Node & Wallet") } + LogosTabButton { text: qsTr("Node") } LogosTabButton { - text: qsTr("Channel Deposit") - enabled: root.backend - && root.backend.status === BlockchainBackend.Running + text: qsTr("Operations") + enabled: opPage.nodeRunning } } @@ -191,149 +334,313 @@ Rectangle { Layout.fillHeight: true currentIndex: operationTabBar.currentIndex - SplitView { - orientation: Qt.Vertical + // ---- Tab 0: Node information (status + logs) ---- + SplitView { + orientation: Qt.Vertical - ColumnLayout { - SplitView.fillWidth: true - SplitView.minimumHeight: 200 - spacing: Theme.spacing.large + ColumnLayout { + SplitView.fillWidth: true + SplitView.minimumHeight: 120 + spacing: Theme.spacing.large - StatusConfigView { - Layout.fillWidth: true - statusText: root.backend - ? _d.getStatusString(root.backend.status) - : qsTr("Not Connected") - statusColor: root.backend - ? _d.getStatusColor(root.backend.status) - : Theme.palette.error - userConfig: root.backend ? root.backend.userConfig : "" - deploymentConfig: root.backend ? root.backend.deploymentConfig : "" - useGeneratedConfig: root.backend ? root.backend.useGeneratedConfig : false - canStart: root.backend - && !!root.backend.userConfig - && root.backend.status !== BlockchainBackend.Starting - && root.backend.status !== BlockchainBackend.Stopping - isRunning: root.backend - ? root.backend.status === BlockchainBackend.Running - : false + StatusConfigView { + Layout.fillWidth: true + statusText: root.backend + ? _d.getStatusString(root.backend.status) + : qsTr("Not Connected") + statusColor: root.backend + ? _d.getStatusColor(root.backend.status) + : Theme.palette.error + userConfig: root.backend ? root.backend.userConfig : "" + deploymentConfig: root.backend ? root.backend.deploymentConfig : "" + useGeneratedConfig: root.backend ? root.backend.useGeneratedConfig : false + canStart: root.backend + && !!root.backend.userConfig + && root.backend.status !== BlockchainBackend.Starting + && root.backend.status !== BlockchainBackend.Stopping + isRunning: opPage.nodeRunning - onStartRequested: if (root.backend) root.backend.startBlockchain() - onStopRequested: if (root.backend) root.backend.stopBlockchain() - onChangeConfigRequested: _d.currentPage = 0 - } - - WalletView { - id: walletView - accountsModel: root.accountsModel - - onGetBalanceRequested: function(addressHex) { - if (!root.backend) return - logos.watch( - root.backend.getBalance(addressHex), - function(result) { - if (result.success) { - walletView.lastBalanceErrorAddress = "" - walletView.lastBalanceError = "" - } else { - walletView.lastBalanceErrorAddress = addressHex - walletView.lastBalanceError = _d.errorText(result.error) - } - }, - function(error) { - walletView.lastBalanceErrorAddress = addressHex - walletView.lastBalanceError = _d.errorText(error) - } - ) - } - onCopyToClipboard: (text) => { - root.copyText(text) - } - onTransferRequested: function(fromKeyHex, toKeyHex, amount) { - if (!root.backend) return - logos.watch( - root.backend.transferFunds(fromKeyHex, toKeyHex, amount), - function(result) { - if (result.success) { - walletView.setTransferResult(result.value) - } else { - walletView.setTransferResult(_d.errorText(result.error)) - } - }, - function(error) { walletView.setTransferResult(_d.errorText(error)) } - ) - } - onClaimLeaderRewardsRequested: function() { - if (!root.backend) return - logos.watch( - root.backend.claimLeaderRewards(), - function(result) { - if (result.success) { - walletView.setLeaderClaimResult(result.value) - } else { - walletView.setLeaderClaimResult(_d.errorText(result.error)) - } - }, - function(error) { walletView.setLeaderClaimResult(_d.errorText(error)) } - ) - } - onRefreshAccountsRequested: if (root.backend) root.backend.refreshAccounts() + onStartRequested: if (root.backend) root.backend.startBlockchain() + onStopRequested: if (root.backend) root.backend.stopBlockchain() + onChangeConfigRequested: _d.currentPage = 0 + } + + NodeInfoView { + Layout.fillWidth: true + peerId: root.peerId + onCopyToClipboard: (text) => root.copyText(text) + } + + CryptarchiaInfoView { + Layout.fillWidth: true + visible: opPage.nodeRunning + infoJson: root.cryptarchiaInfoJson + errorText: root.cryptarchiaInfoError + onCopyToClipboard: (text) => root.copyText(text) + } + + Item { + Layout.preferredHeight: Theme.spacing.small + } + } + + BlocksView { + SplitView.fillWidth: true + SplitView.minimumHeight: 150 + + blockModel: root.blockModel + onClearRequested: if (root.backend) root.backend.clearBlocks() + onCopyToClipboard: (text) => { + root.copyText(text) + } + } } + // ---- Tab 1: Wallet operations (sidebar nav + panels) ---- + // Anchor-based (not a Layout): StackLayout force-fills this Item, + // and anchors give the SplitView explicit geometry. The panels + // have ~zero implicit height, so a plain Layout would collapse + // them — anchors + SplitView.fillHeight avoid that. Item { - Layout.preferredHeight: Theme.spacing.small + // Sidebar navigation + ColumnLayout { + id: opSidebar + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 180 + spacing: Theme.spacing.small + + NavItem { label: qsTr("Accounts"); index: 0; pinnable: true } + NavItem { label: qsTr("Transfer"); index: 1 } + NavItem { label: qsTr("Leader Rewards"); index: 2 } + NavItem { label: qsTr("Channel Deposit"); index: 3 } + + Item { Layout.fillHeight: true } + } + + Rectangle { + id: opDivider + anchors.left: opSidebar.right + anchors.leftMargin: Theme.spacing.large + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 1 + color: Theme.palette.borderSecondary + } + + // Operation panels. Accounts lives outside the stack so it + // can stay pinned on top while another operation is shown; + // a vertical SplitView keeps both visible and resizable. + SplitView { + anchors.left: opDivider.right + anchors.leftMargin: Theme.spacing.large + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + orientation: Qt.Vertical + + AccountsView { + id: accountsView + visible: opPage.operationIndex === 0 || opPage.accountsPinned + // Fills when it's the sole panel; when pinned beside + // an operation it's a resizable 260px strip on top + // (the operation below is the SplitView filler). + SplitView.fillHeight: opPage.operationIndex === 0 + SplitView.preferredHeight: 260 + SplitView.minimumHeight: 120 + + accountsModel: root.accountsModel + + onGetBalanceRequested: function(addressHex) { + if (!root.backend) return + logos.watch( + root.backend.getBalance(addressHex), + function(result) { + if (result.success) { + accountsView.lastBalanceErrorAddress = "" + accountsView.lastBalanceError = "" + } else { + accountsView.lastBalanceErrorAddress = addressHex + accountsView.lastBalanceError = _d.errorText(result.error) + } + }, + function(error) { + accountsView.lastBalanceErrorAddress = addressHex + accountsView.lastBalanceError = _d.errorText(error) + } + ) + } + onRefreshAccountsRequested: if (root.backend) root.backend.refreshAccounts() + onCopyToClipboard: (text) => { + root.copyText(text) + } + } + + // Transfer / Leader Rewards / Channel Deposit. + // operationIndex 1,2,3 maps to stack index 0,1,2. + StackLayout { + id: otherOpsStack + SplitView.fillHeight: true + SplitView.minimumHeight: 150 + visible: opPage.operationIndex !== 0 + currentIndex: Math.max(0, opPage.operationIndex - 1) + + TransferView { + id: transferView + accountsModel: root.accountsModel + + onTransferRequested: function(fromKeyHex, toKeyHex, amount) { + if (!root.backend) return + logos.watch( + root.backend.transferFunds(fromKeyHex, toKeyHex, amount), + function(result) { + if (result.success) { + transferView.setTransferResult(result.value) + } else { + transferView.setTransferResult(_d.errorText(result.error)) + } + }, + function(error) { transferView.setTransferResult(_d.errorText(error)) } + ) + } + onCopyToClipboard: (text) => { + root.copyText(text) + } + } + + LeaderRewardsView { + id: leaderRewardsView + vouchersJson: root.claimableVouchersJson + + onClaimLeaderRewardsRequested: function() { + if (!root.backend) return + logos.watch( + root.backend.claimLeaderRewards(), + function(result) { + if (result.success) { + leaderRewardsView.setLeaderClaimResult(result.value) + } else { + leaderRewardsView.setLeaderClaimResult(_d.errorText(result.error)) + } + // Reflect the claim in the pending list. + root.refreshClaimableVouchers() + }, + function(error) { leaderRewardsView.setLeaderClaimResult(_d.errorText(error)) } + ) + } + onCopyToClipboard: (text) => { + root.copyText(text) + } + } + + ChannelDepositView { + id: channelDepositView + accountsModel: root.accountsModel + nodeRunning: opPage.nodeRunning + + onGetNotesRequested: function(addressHex, optionalTipHex) { + if (!root.backend) return + logos.watch( + root.backend.getNotes(addressHex, optionalTipHex), + function(result) { + if (result.success) + channelDepositView.setNotes(result.value) + else + channelDepositView.setNotesError(_d.errorText(result.error)) + }, + function(error) { channelDepositView.setNotesError(_d.errorText(error)) } + ) + } + onSubmitRequested: function(channelIdHex, inputNoteIdHexes, metadataBase58, changePublicKeyHex, fundingPublicKeyHexes, maxTxFee, optionalTipHex) { + if (!root.backend) return + logos.watch( + root.backend.channelDepositWithNotes( + channelIdHex, inputNoteIdHexes, metadataBase58, + changePublicKeyHex, fundingPublicKeyHexes, maxTxFee, optionalTipHex), + function(result) { + if (result.success) + channelDepositView.setSubmitResult(true, result.value) + else + channelDepositView.setSubmitResult(false, _d.errorText(result.error)) + }, + function(error) { channelDepositView.setSubmitResult(false, _d.errorText(error)) } + ) + } + onCopyToClipboard: (text) => { + root.copyText(text) + } + } + } + } } } - LogsView { - SplitView.fillWidth: true - SplitView.minimumHeight: 150 + // Sidebar nav entry used by the Operations tab. `pinnable` adds a + // pin toggle on the right (used by Accounts) that keeps the panel + // visible alongside other operations. + component NavItem: Rectangle { + property string label + property int index + property bool pinnable: false - logModel: root.logModel - onClearRequested: if (root.backend) root.backend.clearLogs() - onCopyToClipboard: (text) => { - root.copyText(text) + Layout.fillWidth: true + Layout.preferredHeight: 40 + radius: Theme.spacing.radiusSmall + color: opPage.operationIndex === index + ? Theme.palette.backgroundTertiary + : (navMouse.containsMouse ? Theme.palette.backgroundSecondary : "transparent") + + // Background click selects the operation. Sits below the row so + // the pin button on top captures its own clicks. + MouseArea { + id: navMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: opPage.operationIndex = index } - } - } - ChannelDepositView { - id: channelDepositView - accountsModel: root.accountsModel - nodeRunning: root.backend - ? root.backend.status === BlockchainBackend.Running - : false + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacing.medium + anchors.rightMargin: Theme.spacing.small + spacing: Theme.spacing.small - onGetNotesRequested: function(addressHex, optionalTipHex) { - if (!root.backend) return - logos.watch( - root.backend.getNotes(addressHex, optionalTipHex), - function(result) { - if (result.success) - channelDepositView.setNotes(result.value) - else - channelDepositView.setNotesError(_d.errorText(result.error)) - }, - function(error) { channelDepositView.setNotesError(_d.errorText(error)) } - ) + LogosText { + Layout.fillWidth: true + text: label + elide: Text.ElideRight + font.pixelSize: Theme.typography.secondaryText + font.bold: opPage.operationIndex === index + color: opPage.operationIndex === index + ? Theme.palette.primary + : Theme.palette.text } - onSubmitRequested: function(channelIdHex, inputNoteIdHexes, metadataBase58, changePublicKeyHex, fundingPublicKeyHexes, maxTxFee, optionalTipHex) { - if (!root.backend) return - logos.watch( - root.backend.channelDepositWithNotes( - channelIdHex, inputNoteIdHexes, metadataBase58, - changePublicKeyHex, fundingPublicKeyHexes, maxTxFee, optionalTipHex), - function(result) { - if (result.success) - channelDepositView.setSubmitResult(true, result.value) - else - channelDepositView.setSubmitResult(false, _d.errorText(result.error)) - }, - function(error) { channelDepositView.setSubmitResult(false, _d.errorText(error)) } - ) - } - onCopyToClipboard: (text) => { - root.copyText(text) + + // Pin toggle (Accounts only). A flat icon button matching + // the other SVG icons; the pin colours up when pinned. Its + // own click handling stops the nav-background MouseArea + // below from also selecting the item. + Button { + visible: pinnable + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 28 + Layout.preferredHeight: 28 + display: AbstractButton.IconOnly + flat: true + padding: 4 + icon.source: Qt.resolvedUrl("icons/pin.svg") + icon.width: 18 + icon.height: 18 + icon.color: opPage.accountsPinned + ? Theme.palette.primary + : Theme.palette.textTertiary + onClicked: opPage.accountsPinned = !opPage.accountsPinned + + ToolTip.visible: hovered + ToolTip.text: opPage.accountsPinned + ? qsTr("Unpin accounts") : qsTr("Pin accounts") } } } diff --git a/src/qml/controls/BlockDelegate.qml b/src/qml/controls/BlockDelegate.qml new file mode 100644 index 0000000..5e62e65 --- /dev/null +++ b/src/qml/controls/BlockDelegate.qml @@ -0,0 +1,207 @@ +import QtQuick +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +// Collapsible card for a single block (BlockModel row). +// Collapsed: timestamp · slot · version · tx count +// Expanded: header hashes, proof-of-leadership group, transactions list +// Unparsed payloads fall back to showing their raw text. +Rectangle { + id: del + + signal copyToClipboard(string text) + + property bool expanded: false + property bool proofExpanded: false + + // 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 + + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ColumnLayout { + id: col + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacing.medium + spacing: Theme.spacing.small + + // ---- Summary row (always visible, toggles expansion) ---- + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + LogosText { + text: del.expanded ? "▾" : "▸" + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + + LogosText { + text: model.timestamp || "" + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + + LogosText { + visible: !del.isUnparsed + text: qsTr("slot %1").arg(model.slot || qsTr("?")) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + + // Version badge + Rectangle { + visible: !del.isUnparsed && (model.version || "").length > 0 + radius: Theme.spacing.radiusSmall + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.border + border.width: 1 + implicitWidth: versionText.implicitWidth + 2 * Theme.spacing.small + implicitHeight: versionText.implicitHeight + Theme.spacing.tiny + LogosText { + id: versionText + anchors.centerIn: parent + text: model.version || "" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + + LogosText { + visible: del.isUnparsed + text: qsTr("Unparsed block") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.warning + } + + Item { Layout.fillWidth: true } + + LogosText { + visible: !del.isUnparsed + text: qsTr("%1 tx").arg(model.txCount || 0) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + + TapHandler { onTapped: del.expanded = !del.expanded } + } + + // ---- Expanded details ---- + ColumnLayout { + Layout.fillWidth: true + visible: del.expanded + spacing: Theme.spacing.small + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.palette.borderSecondary + } + + // Parsed: structured header + 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: !del.isUnparsed + spacing: Theme.spacing.small + LogosText { + text: (del.proofExpanded ? "▾ " : "▸ ") + qsTr("Proof of leadership") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + Item { Layout.fillWidth: true } + TapHandler { onTapped: del.proofExpanded = !del.proofExpanded } + } + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: Theme.spacing.medium + visible: !del.isUnparsed && del.proofExpanded + spacing: Theme.spacing.small + 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: !del.isUnparsed + text: qsTr("Transactions (%1)").arg(model.txCount || 0) + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + ColumnLayout { + Layout.fillWidth: true + visible: !del.isUnparsed + spacing: Theme.spacing.small + + Repeater { + model: del.expanded ? del.transactionsList : [] + delegate: TransactionDelegate { + required property int index + required property string modelData + Layout.fillWidth: true + idx: index + json: modelData + onCopyToClipboard: (t) => del.copyToClipboard(t) + } + } + + LogosText { + visible: (model.txCount || 0) === 0 + text: qsTr("No transactions in this block.") + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + } + + // Unparsed: raw fallback + RowLayout { + Layout.fillWidth: true + visible: del.isUnparsed + spacing: Theme.spacing.small + LogosText { + text: qsTr("Raw payload") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + Item { Layout.fillWidth: true } + LogosCopyButton { onCopyText: del.copyToClipboard(model.rawJson || "") } + } + JsonBlock { + Layout.fillWidth: true + visible: del.isUnparsed + json: model.rawJson || qsTr("(no payload)") + } + } + } +} 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/InfoButton.qml b/src/qml/controls/InfoButton.qml new file mode 100644 index 0000000..2b5b211 --- /dev/null +++ b/src/qml/controls/InfoButton.qml @@ -0,0 +1,59 @@ +import QtQuick +import QtQuick.Controls + +import Logos.Theme +import Logos.Controls + +// Small circled-"i" help button. Click to toggle a popup with `text`, a short +// description of the operation. Styled to match the other SVG icon buttons. +Item { + id: root + + property string text: "" + + implicitWidth: 28 + implicitHeight: 28 + + Button { + id: btn + anchors.fill: parent + display: AbstractButton.IconOnly + flat: true + padding: 4 + icon.source: Qt.resolvedUrl("../icons/info.svg") + icon.width: 18 + icon.height: 18 + icon.color: (btn.hovered || popup.visible) + ? Theme.palette.primary + : Theme.palette.textTertiary + onClicked: popup.visible ? popup.close() : popup.open() + + ToolTip.visible: btn.hovered && !popup.visible + ToolTip.text: qsTr("What is this?") + } + + Popup { + id: popup + // Right-aligned to the button, opening downward. + x: root.width - width + y: root.height + Theme.spacing.tiny + width: 300 + padding: Theme.spacing.medium + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.border + border.width: 1 + radius: Theme.spacing.radiusLarge + } + contentItem: LogosText { + text: root.text + wrapMode: Text.WordWrap + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.text + } + } +} 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..c3b838d --- /dev/null +++ b/src/qml/controls/TransactionDelegate.qml @@ -0,0 +1,180 @@ +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 : [] + + // Transaction id, if the block payload includes one. Falls back to the + // positional "Transaction N" label when absent. + readonly property string txId: (parsed && parsed.id) ? String(parsed.id) : "" + + 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 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 ? "▾" : "▸" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + TapHandler { onTapped: txRoot.open = !txRoot.open } + } + LogosText { + // Show the tx id when present, else the positional label. + Layout.fillWidth: true + text: txRoot.txId !== "" ? txRoot.txId : qsTr("Transaction %1").arg(txRoot.idx + 1) + elide: Text.ElideMiddle + font.pixelSize: Theme.typography.secondaryText + font.bold: true + font.family: txRoot.txId !== "" ? "monospace" : Theme.typography.publicSans + TapHandler { onTapped: txRoot.open = !txRoot.open } + } + LogosCopyButton { + // Copy the tx id when available, otherwise the full tx JSON. + onCopyText: txRoot.copyToClipboard(txRoot.txId !== "" ? txRoot.txId : 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) : "" + } + } +} diff --git a/src/qml/icons/info.svg b/src/qml/icons/info.svg new file mode 100644 index 0000000..e969ce3 --- /dev/null +++ b/src/qml/icons/info.svg @@ -0,0 +1 @@ + diff --git a/src/qml/icons/pin.svg b/src/qml/icons/pin.svg new file mode 100644 index 0000000..f2e181e --- /dev/null +++ b/src/qml/icons/pin.svg @@ -0,0 +1 @@ + diff --git a/src/qml/views/AccountsView.qml b/src/qml/views/AccountsView.qml new file mode 100644 index 0000000..167ee27 --- /dev/null +++ b/src/qml/views/AccountsView.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +import "../controls" + +// Accounts panel: the list of known wallet addresses with per-account +// balance refresh and copy. Extracted from the former WalletView. +ColumnLayout { + id: root + + required property var accountsModel + + property string lastBalanceError: "" + property string lastBalanceErrorAddress: "" + + signal getBalanceRequested(string addressHex) + signal refreshAccountsRequested() + signal copyToClipboard(string text) + + spacing: Theme.spacing.large + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacing.large + spacing: Theme.spacing.large + + RowLayout { + Layout.fillWidth: true + LogosText { + text: qsTr("Accounts") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + Item { Layout.fillWidth: true } + LogosButton { + text: qsTr("Refresh") + padding: Theme.spacing.small + onClicked: root.refreshAccountsRequested() + } + InfoButton { + Layout.alignment: Qt.AlignVCenter + text: qsTr("Your wallet addresses and balances. Press Refresh to fetch the latest known addresses and their balances from the running node.") + } + } + + LogosText { + text: qsTr("Start node to see accounts here.") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + wrapMode: Text.WordWrap + visible: balanceListView.count === 0 + } + + ListView { + id: balanceListView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.accountsModel + spacing: Theme.spacing.small + + delegate: AccountDelegate { + balanceError: root.lastBalanceErrorAddress === model.address ? + root.lastBalanceError : "" + onGetBalanceRequested: (addr) => root.getBalanceRequested(addr) + onCopyRequested: (text) => root.copyToClipboard(text) + } + } + } + } +} diff --git a/src/qml/views/BlocksView.qml b/src/qml/views/BlocksView.qml new file mode 100644 index 0000000..a1795c6 --- /dev/null +++ b/src/qml/views/BlocksView.qml @@ -0,0 +1,85 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +import "../controls" + +// Structured view of recent blocks (BlockModel). Newest block is at the top; +// only the latest 100 are retained by the model. +Control { + id: root + + // --- Public API --- + required property var blockModel + + signal clearRequested() + signal copyToClipboard(string text) + + background: Rectangle { + color: Theme.palette.background + } + + ColumnLayout { + anchors.fill: parent + anchors.topMargin: Theme.spacing.large + spacing: Theme.spacing.medium + + // Header + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + spacing: Theme.spacing.medium + + LogosText { + text: qsTr("Blocks") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + + Item { Layout.fillWidth: true } + + LogosButton { + text: qsTr("Clear") + Layout.preferredWidth: 80 + Layout.preferredHeight: 32 + onClicked: root.clearRequested() + } + } + + // Block list + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Theme.palette.backgroundSecondary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ListView { + id: blocksListView + anchors.fill: parent + anchors.margins: Theme.spacing.small + clip: true + model: root.blockModel + spacing: Theme.spacing.small + + delegate: BlockDelegate { + onCopyToClipboard: (text) => root.copyToClipboard(text) + } + + LogosText { + // ListView's `count` has a NOTIFY signal, unlike the remoted + // model's own count property — use it for the empty state. + visible: blocksListView.count === 0 + anchors.centerIn: parent + text: qsTr("No blocks yet...") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + } + } +} diff --git a/src/qml/views/ChannelDepositView.qml b/src/qml/views/ChannelDepositView.qml index 3e91281..b9ab11d 100644 --- a/src/qml/views/ChannelDepositView.qml +++ b/src/qml/views/ChannelDepositView.qml @@ -66,11 +66,32 @@ ColumnLayout { readonly property int stepCount: 4 property string notesTip: "" + // Address whose notes are currently loaded. Selecting/entering a + // different address clears the old notes and loads the new ones. + property string loadedAddress: "" + // result state property bool resultPending: false property bool resultSuccess: false property string resultText: "" + // Clear any loaded notes and (re)load for `addr` — but only when it + // actually differs from what's already loaded. + function loadNotesFor(addr) { + var a = (addr || "").trim() + if (a === "" || a === loadedAddress) + return + loadedAddress = a + noteSelector.clearSelection() + noteSelector.notes = [] + noteSelector.errorText = "" + notesTip = "" + if (!root.nodeRunning) + return + root.setNotesLoading() + root.getNotesRequested(a, "") + } + function fundingKeyList() { return fundingKeysArea.text.split("\n") .map(function(s) { return s.trim() }) @@ -143,6 +164,8 @@ ColumnLayout { noteSelector.clearSelection() noteSelector.notes = [] noteSelector.errorText = "" + walletField.text = "" + loadedAddress = "" channelIdField.text = "" changeKeyField.text = "" fundingKeysArea.text = "" @@ -186,6 +209,10 @@ ColumnLayout { font.pixelSize: Theme.typography.secondaryText color: Theme.palette.textSecondary } + InfoButton { + Layout.alignment: Qt.AlignVCenter + text: qsTr("Deposit wallet notes (UTXOs) into a channel. Pick an address to load its notes, select the notes to consume, fill in the channel id, change/funding keys and max fee, then confirm to submit.") + } } LogosText { @@ -224,19 +251,23 @@ ColumnLayout { model: root.accountsModel textRole: "address" currentIndex: -1 - onActivated: function(index) { walletField.text = currentText } + // Selecting a (different) known address loads its notes. + onActivated: function(index) { + walletField.text = currentText + d.loadNotesFor(currentText) + } } LogosTextField { id: walletField Layout.fillWidth: true placeholderText: qsTr("Wallet address hex") - } - LogosButton { - text: qsTr("Load notes") - enabled: root.nodeRunning && walletField.text.trim().length > 0 - onClicked: { - root.setNotesLoading() - root.getNotesRequested(walletField.text.trim(), "") + + // LogosTextField has no editingFinished; reach the inner + // TextInput. Manually entered address loads on commit + // (Enter / focus out). + Connections { + target: walletField.textInput + function onEditingFinished() { d.loadNotesFor(walletField.text) } } } } diff --git a/src/qml/views/CryptarchiaInfoView.qml b/src/qml/views/CryptarchiaInfoView.qml new file mode 100644 index 0000000..c8df818 --- /dev/null +++ b/src/qml/views/CryptarchiaInfoView.qml @@ -0,0 +1,143 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +import "../controls" + +// Live Cryptarchia consensus state, polled from get_cryptarchia_info. +// The result `value` is a JSON string; parsed defensively (nested under +// "cryptarchia_info" or flat, mode as string or numeric enum). +Rectangle { + id: root + + property string infoJson: "" + property string errorText: "" + + signal copyToClipboard(string text) + + readonly property var info: parse(infoJson) + + function parse(s) { + try { return s && s.length > 0 ? JSON.parse(s) : null } catch (e) { return null } + } + + function field(key) { + if (!info) return undefined + if (info.cryptarchia_info && info.cryptarchia_info[key] !== undefined) + return info.cryptarchia_info[key] + return info[key] + } + + function num(key) { + var v = field(key) + return (v === undefined || v === null) ? qsTr("—") : String(v) + } + + function hash(key) { + var v = field(key) + return (v === undefined || v === null) ? "" : String(v) + } + + // mode lives at the top level (ChainServiceMode); accept string or the + // c-binding numeric enum (0 Bootstrapping / 1 Online / 2 NotStarted). + function modeText() { + var m = info ? info.mode : undefined + if (m === undefined || m === null) return qsTr("—") + if (typeof m === "number") { + switch (m) { + case 0: return qsTr("Bootstrapping") + case 1: return qsTr("Online") + case 2: return qsTr("Not Started") + default: return String(m) + } + } + return String(m) + } + + function modeColor() { + var t = modeText() + if (t === qsTr("Online")) return Theme.palette.success + if (t === qsTr("Bootstrapping")) return Theme.palette.warning + return Theme.palette.textSecondary + } + + implicitHeight: contentCol.implicitHeight + 2 * Theme.spacing.large + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ColumnLayout { + id: contentCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacing.large + spacing: Theme.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosText { + text: qsTr("Consensus") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + Item { Layout.fillWidth: true } + LogosText { + text: root.modeText() + color: root.modeColor() + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + } + + LogosText { + Layout.fillWidth: true + visible: root.errorText.length > 0 + text: root.errorText + color: Theme.palette.error + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + visible: root.errorText.length === 0 + spacing: Theme.spacing.large + LogosText { + text: qsTr("Slot: %1").arg(root.num("slot")) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + LogosText { + text: qsTr("Height: %1").arg(root.num("height")) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + LogosText { + visible: root.field("lib_slot") !== undefined + text: qsTr("LIB slot: %1").arg(root.num("lib_slot")) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + Item { Layout.fillWidth: true } + } + + HashRow { + visible: root.errorText.length === 0 + label: qsTr("Tip") + value: root.hash("tip") + onCopyRequested: (t) => root.copyToClipboard(t) + } + HashRow { + visible: root.errorText.length === 0 + label: qsTr("LIB") + value: root.hash("lib") + onCopyRequested: (t) => root.copyToClipboard(t) + } + } +} diff --git a/src/qml/views/LeaderRewardsView.qml b/src/qml/views/LeaderRewardsView.qml new file mode 100644 index 0000000..2caa29a --- /dev/null +++ b/src/qml/views/LeaderRewardsView.qml @@ -0,0 +1,228 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +import "../controls" + +// Leader rewards panel: claim action plus a read-only, horizontally-sliding +// list of the wallet's claimable ("pending") vouchers with a count. The +// protocol picks which voucher a claim consumes — the list is informational. +ColumnLayout { + id: root + + // JSON from wallet_get_claimable_vouchers: + // { "tip": "", "vouchers": [ {commitment, nullifier}, ... ] } + property string vouchersJson: "" + + signal claimLeaderRewardsRequested() + signal copyToClipboard(string text) + + function setLeaderClaimResult(text) { + leaderClaimResultText.text = text + } + + readonly property var _parsed: safeParse(vouchersJson) + readonly property var vouchers: (_parsed && _parsed.vouchers) + ? _parsed.vouchers + : (Array.isArray(_parsed) ? _parsed : []) + readonly property string tip: (_parsed && _parsed.tip) ? String(_parsed.tip) : "" + + function safeParse(s) { + try { return s && s.length > 0 ? JSON.parse(s) : null } catch (e) { return null } + } + + spacing: Theme.spacing.large + + // ---- Claimable vouchers card ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: vouchersCol.implicitHeight + 2 * Theme.spacing.large + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ColumnLayout { + id: vouchersCol + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacing.large + spacing: Theme.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosText { + text: qsTr("Claimable vouchers") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + // Pending counter badge + Rectangle { + radius: Theme.spacing.radiusSmall + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.border + border.width: 1 + implicitWidth: pendingText.implicitWidth + 2 * Theme.spacing.small + implicitHeight: pendingText.implicitHeight + Theme.spacing.tiny + LogosText { + id: pendingText + anchors.centerIn: parent + text: qsTr("%1 pending").arg(root.vouchers.length) + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + } + Item { Layout.fillWidth: true } + LogosText { + visible: root.tip.length > 0 + text: qsTr("tip %1").arg(root.tip) + elide: Text.ElideMiddle + Layout.maximumWidth: 140 + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + InfoButton { + Layout.alignment: Qt.AlignVCenter + text: qsTr("Claim block-leader rewards. The list shows your pending claimable vouchers (refreshed each block). Claim submits a claim transaction; the protocol selects which voucher it consumes.") + } + } + + // Horizontally-sliding list of voucher cards. + ListView { + id: vouchersList + Layout.fillWidth: true + Layout.preferredHeight: 78 + visible: root.vouchers.length > 0 + orientation: ListView.Horizontal + clip: true + spacing: Theme.spacing.small + model: root.vouchers + snapMode: ListView.SnapToItem + ScrollBar.horizontal: ScrollBar { policy: ScrollBar.AsNeeded } + + delegate: Rectangle { + width: 260 + height: ListView.view.height + radius: Theme.spacing.radiusSmall + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.border + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacing.small + spacing: Theme.spacing.tiny + + LogosText { + text: qsTr("Voucher %1").arg(index + 1) + font.pixelSize: Theme.typography.secondaryText + font.bold: true + color: Theme.palette.textSecondary + } + VoucherField { + label: qsTr("cm") + value: modelData && modelData.commitment ? String(modelData.commitment) : "" + } + VoucherField { + label: qsTr("nf") + value: modelData && modelData.nullifier ? String(modelData.nullifier) : "" + } + } + } + } + + LogosText { + visible: root.vouchers.length === 0 + text: qsTr("No claimable vouchers.") + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + } + } + + // ---- Claim action ---- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: claimRow.height + 2 * Theme.spacing.large + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + RowLayout { + id: claimRow + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacing.large + + LogosButton { + id: leaderClaimButton + Layout.preferredWidth: 140 + text: qsTr("Claim") + onClicked: root.claimLeaderRewardsRequested() + } + + LogosButton { + Layout.fillWidth: true + enabled: true + padding: Theme.spacing.small + contentItem: RowLayout { + width: parent.width + anchors.centerIn: parent + LogosText { + id: leaderClaimResultText + Layout.fillWidth: true + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightMedium + wrapMode: Text.WordWrap + elide: Text.ElideRight + } + LogosCopyButton { + Layout.alignment: Qt.AlignRight + Layout.preferredHeight: 40 + Layout.preferredWidth: 40 + onCopyText: root.copyToClipboard(leaderClaimResultText.text) + visible: leaderClaimResultText.text + } + } + } + } + } + + Item { Layout.fillHeight: true } + + // A labelled, elided, copyable hash inside a voucher card. + component VoucherField: RowLayout { + id: vf + property string label: "" + property string value: "" + Layout.fillWidth: true + spacing: Theme.spacing.tiny + LogosText { + text: vf.label + Layout.preferredWidth: 20 + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + LogosText { + Layout.fillWidth: true + text: vf.value || "—" + elide: Text.ElideMiddle + font.pixelSize: Theme.typography.secondaryText + font.family: "monospace" + } + LogosCopyButton { + Layout.preferredHeight: 24 + Layout.preferredWidth: 24 + visible: vf.value.length > 0 + onCopyText: root.copyToClipboard(vf.value) + } + } +} diff --git a/src/qml/views/LogsView.qml b/src/qml/views/LogsView.qml deleted file mode 100644 index f0a4372..0000000 --- a/src/qml/views/LogsView.qml +++ /dev/null @@ -1,99 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -Control { - id: root - - // --- Public API --- - required property var logModel // ListModel with "text" role - - signal clearRequested() - signal copyToClipboard(string text) - - background: Rectangle { - color: Theme.palette.background - } - - ColumnLayout { - anchors.fill: parent - anchors.topMargin: Theme.spacing.large - spacing: Theme.spacing.medium - - // Header - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - spacing: Theme.spacing.medium - - LogosText { - text: qsTr("Logs") - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - - Item { Layout.fillWidth: true } - - LogosButton { - text: qsTr("Clear") - Layout.preferredWidth: 80 - Layout.preferredHeight: 32 - onClicked: root.clearRequested() - } - } - - // Log list - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusLarge - border.color: Theme.palette.border - border.width: 1 - - ListView { - id: logsListView - anchors.fill: parent - clip: true - model: root.logModel - spacing: 2 - - // Auto-scroll to the latest log on insert. Use the ListView's - // own `count` (it's always available and emits countChanged) — - // the model replica is a QAbstractItemModelReplica and does - // not carry the source-side `count` Q_PROPERTY through QtRO. - onCountChanged: if (count > 0) positionViewAtEnd() - - delegate: ItemDelegate{ - width: ListView.view.width - contentItem: LogosText { - // The remoted log model can briefly report an undefined - // "text" role while the replica syncs — coerce to "". - text: model.text || "" - font.pixelSize: Theme.typography.secondaryText - wrapMode: Text.Wrap - } - background: Rectangle { - color: hovered ? Theme.palette.background: "transparent" - radius: 2 - } - onClicked: root.copyToClipboard(model.text || "") - } - - LogosText { - // ListView's `count` reflects the model row count and has - // a NOTIFY signal — using it here gives the binding - // automatic refresh, unlike `root.logModel.count`. - visible: logsListView.count === 0 - anchors.centerIn: parent - text: qsTr("No logs yet...") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - } - } -} diff --git a/src/qml/views/NodeInfoView.qml b/src/qml/views/NodeInfoView.qml new file mode 100644 index 0000000..4ddfbaa --- /dev/null +++ b/src/qml/views/NodeInfoView.qml @@ -0,0 +1,62 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +import "../controls" + +// "Node info" card — node identity that doesn't depend on the consensus +// runtime. Currently the self libp2p peer id (derived from the user config). +Rectangle { + id: root + + property string peerId: "" + + signal copyToClipboard(string text) + + implicitHeight: contentCol.implicitHeight + 2 * Theme.spacing.large + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ColumnLayout { + id: contentCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacing.large + spacing: Theme.spacing.small + + LogosText { + text: qsTr("Node info") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + LogosText { + text: qsTr("Peer ID") + Layout.preferredWidth: 70 + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + LogosText { + Layout.fillWidth: true + text: root.peerId || qsTr("—") + elide: Text.ElideMiddle + font.pixelSize: Theme.typography.secondaryText + } + LogosCopyButton { + Layout.preferredHeight: 24 + Layout.preferredWidth: 24 + visible: root.peerId.length > 0 + onCopyText: root.copyToClipboard(root.peerId) + } + } + } +} diff --git a/src/qml/views/StatusConfigView.qml b/src/qml/views/StatusConfigView.qml index b8584fa..4c5b153 100644 --- a/src/qml/views/StatusConfigView.qml +++ b/src/qml/views/StatusConfigView.qml @@ -117,6 +117,9 @@ Rectangle { } LogosButton { + // Config can't be changed while the node is running — hide the + // button entirely (not just disable it) in that state. + visible: !root.isRunning Layout.alignment: Qt.AlignHCenter Layout.preferredWidth: 100 Layout.preferredHeight: 40 diff --git a/src/qml/views/TransferView.qml b/src/qml/views/TransferView.qml new file mode 100644 index 0000000..ed37456 --- /dev/null +++ b/src/qml/views/TransferView.qml @@ -0,0 +1,241 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Theme +import Logos.Controls + +import "../controls" + +// Transfer funds panel. Extracted from the former WalletView. +ColumnLayout { + id: root + + required property var accountsModel + + signal transferRequested(string fromKeyHex, string toKeyHex, string amount) + signal copyToClipboard(string text) + + function setTransferResult(text) { + transferResultText.text = text + } + + spacing: Theme.spacing.large + + Rectangle { + id: transferRect + + Layout.fillWidth: true + Layout.preferredHeight: transferCol.height + 2 * Theme.spacing.large + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + + ColumnLayout { + id: transferCol + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacing.large + spacing: Theme.spacing.small + + RowLayout { + Layout.fillWidth: true + LogosText { + text: qsTr("Transfer funds") + font.pixelSize: Theme.typography.secondaryText + font.bold: true + } + Item { Layout.fillWidth: true } + InfoButton { + Layout.alignment: Qt.AlignVCenter + text: qsTr("Send funds between addresses. Choose a source address (its balance is shown), enter the recipient key and amount, then press Send.") + } + } + + StyledAddressComboBox { + id: transferFromCombo + model: root.accountsModel + textRole: "address" + } + + LogosTextField { + id: transferToField + Layout.fillWidth: true + Layout.preferredHeight: 30 + placeholderText: qsTr("To key (64 hex chars)") + } + + LogosTextField { + id: transferAmountField + Layout.fillWidth: true + Layout.preferredHeight: 30 + placeholderText: qsTr("Amount") + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: transferButton.implicitHeight + + LogosButton { + id: transferButton + Layout.preferredWidth: 60 + Layout.alignment: Qt.AlignRight + text: qsTr("Send") + onClicked: root.transferRequested(transferFromCombo.currentText.trim(), transferToField.text.trim(), transferAmountField.text) + } + + LogosButton { + Layout.fillWidth: true + enabled: true + padding: Theme.spacing.small + contentItem: RowLayout { + width: parent.width + anchors.centerIn: parent + LogosText { + id: transferResultText + Layout.fillWidth: true + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightMedium + wrapMode: Text.WordWrap + elide: Text.ElideRight + } + LogosCopyButton { + Layout.alignment: Qt.AlignRight + Layout.preferredHeight: 40 + Layout.preferredWidth: 40 + onCopyText: root.copyToClipboard(transferResultText.text) + visible: transferResultText.text + } + } + } + } + } + } + + Item { Layout.fillHeight: true } + + component StyledAddressComboBox: ComboBox { + id: comboControl + + Layout.fillWidth: true + padding: Theme.spacing.large + editable: true + // Balance of the selected row, shown read-only in the closed box (the + // editable text holds only the address — it's used as the transfer key). + valueRole: "balance" + font.pixelSize: Theme.typography.secondaryText + + background: Rectangle { + color: Theme.palette.backgroundTertiary + radius: Theme.spacing.radiusLarge + border.color: Theme.palette.border + border.width: 1 + } + indicator: LogosText { + id: comboIndicator + text: "▼" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + x: comboControl.width - width - Theme.spacing.small + y: (comboControl.height - height) / 2 + visible: comboControl.count > 0 + } + contentItem: Item { + implicitWidth: 200 + implicitHeight: 30 + + TextField { + id: comboTextField + anchors.fill: parent + leftPadding: 0 + rightPadding: (comboControl.count > 0 ? comboIndicator.width + Theme.spacing.small : Theme.spacing.small) + + (balanceLabel.visible ? balanceLabel.width + Theme.spacing.small : 0) + topPadding: 0 + bottomPadding: 0 + verticalAlignment: Text.AlignVCenter + font.pixelSize: Theme.typography.secondaryText + text: comboControl.editText + onTextChanged: if (text !== comboControl.editText) comboControl.editText = text + selectByMouse: true + color: Theme.palette.text + background: Item { } + } + LogosText { + id: balanceLabel + anchors.right: parent.right + anchors.rightMargin: (comboControl.count > 0 ? comboIndicator.width + Theme.spacing.small : 0) + + Theme.spacing.small + anchors.verticalCenter: parent.verticalCenter + visible: comboControl.currentIndex >= 0 && text.length > 0 + text: comboControl.currentValue || "" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + } + MouseArea { + anchors.fill: parent + visible: comboControl.count > 0 + z: 1 + onPressed: { + comboControl.popup.visible ? comboControl.popup.close() : comboControl.popup.open() + } + } + } + delegate: ItemDelegate { + id: comboDelegate + width: comboControl.width + contentItem: RowLayout { + spacing: Theme.spacing.small + LogosText { + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + Theme.spacing.large + font.pixelSize: Theme.typography.secondaryText + font.bold: true + text: (typeof model.address !== "undefined" ? model.address : modelData) || "" + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + } + LogosText { + visible: (typeof model.balance !== "undefined") && (model.balance || "").length > 0 + text: model.balance || "" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + } + } + background: Rectangle { + color: comboDelegate.highlighted ? + Theme.palette.backgroundTertiary : + Theme.palette.backgroundSecondary + } + highlighted: comboControl.highlightedIndex === index + } + popup: Popup { + y: comboControl.height - 1 + width: comboControl.width + height: contentItem.implicitHeight + padding: 1 + + onOpened: if (comboControl.count === 0) close() + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: comboControl.popup.visible ? comboControl.delegateModel : null + ScrollIndicator.vertical: ScrollIndicator { } + highlightFollowsCurrentItem: false + } + + background: Rectangle { + color: Theme.palette.backgroundSecondary + border.color: Theme.palette.border + border.width: 1 + radius: Theme.spacing.radiusLarge + } + } + } +} diff --git a/src/qml/views/WalletView.qml b/src/qml/views/WalletView.qml deleted file mode 100644 index 345ea9e..0000000 --- a/src/qml/views/WalletView.qml +++ /dev/null @@ -1,345 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -import "../controls" - -RowLayout { - id: root - - required property var accountsModel - - property string lastBalanceError: "" - property string lastBalanceErrorAddress: "" - - signal getBalanceRequested(string addressHex) - signal refreshAccountsRequested() - signal transferRequested(string fromKeyHex, string toKeyHex, string amount) - signal claimLeaderRewardsRequested() - signal copyToClipboard(string text) - - function setTransferResult(text) { - transferResultText.text = text - } - - function setLeaderClaimResult(text) { - leaderClaimResultText.text = text - } - - spacing: Theme.spacing.medium - - // Get balance card - Rectangle { - Layout.fillWidth: true - implicitHeight: actionsCol.height - Layout.preferredHeight: Math.min(implicitHeight, 400) - color: Theme.palette.backgroundTertiary - radius: Theme.spacing.radiusLarge - border.color: Theme.palette.border - border.width: 1 - - ColumnLayout { - id: balanceCol - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Theme.spacing.large - spacing: Theme.spacing.large - - RowLayout { - Layout.fillWidth: true - LogosText { - text: qsTr("Accounts") - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - Item { Layout.fillWidth: true } - LogosButton { - text: qsTr("Refresh") - padding: Theme.spacing.small - onClicked: root.refreshAccountsRequested() - } - } - - LogosText { - text: qsTr("Start node to see accounts here.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - visible: balanceListView.count === 0 - } - - ListView { - id: balanceListView - Layout.fillWidth: true - Layout.preferredHeight: Math.min(contentHeight, 320) - clip: true - model: root.accountsModel - spacing: Theme.spacing.small - - delegate: AccountDelegate { - balanceError: root.lastBalanceErrorAddress === model.address ? - root.lastBalanceError: "" - onGetBalanceRequested: (addr) => root.getBalanceRequested(addr) - onCopyRequested: (text) => root.copyToClipboard(text) - } - } - } - } - - ColumnLayout { - id: actionsCol - - Layout.fillWidth: true - spacing: Theme.spacing.large - - // Transfer funds card - Rectangle { - id: transferRect - - Layout.fillWidth: true - Layout.preferredHeight: transferCol.height + 2 * Theme.spacing.large - color: Theme.palette.backgroundTertiary - radius: Theme.spacing.radiusLarge - border.color: Theme.palette.border - border.width: 1 - - ColumnLayout { - id: transferCol - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: Theme.spacing.large - spacing: Theme.spacing.small - - LogosText { - text: qsTr("Transfer funds") - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - - StyledAddressComboBox { - id: transferFromCombo - model: root.accountsModel - textRole: "address" - } - - LogosTextField { - id: transferToField - Layout.fillWidth: true - Layout.preferredHeight: 30 - placeholderText: qsTr("To key (64 hex chars)") - } - - LogosTextField { - id: transferAmountField - Layout.fillWidth: true - Layout.preferredHeight: 30 - placeholderText: qsTr("Amount") - } - - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: transferButton.implicitHeight - - LogosButton { - id: transferButton - Layout.preferredWidth: 60 - Layout.alignment: Qt.AlignRight - text: qsTr("Send") - onClicked: root.transferRequested(transferFromCombo.currentText.trim(), transferToField.text.trim(), transferAmountField.text) - } - - LogosButton { - Layout.fillWidth: true - enabled: true - padding: Theme.spacing.small - contentItem: RowLayout { - width: parent.width - anchors.centerIn: parent - LogosText { - id: transferResultText - Layout.fillWidth: true - color: Theme.palette.textSecondary - font.pixelSize: Theme.typography.secondaryText - font.weight: Theme.typography.weightMedium - wrapMode: Text.WordWrap - elide: Text.ElideRight - } - LogosCopyButton { - Layout.alignment: Qt.AlignRight - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - onCopyText: root.copyToClipboard(transferResultText.text) - visible: transferResultText.text - } - } - } - } - } - } - - // Leader rewards card - Rectangle { - id: leaderRewardsRect - - Layout.fillWidth: true - Layout.preferredHeight: leaderRewardsCol.height + 2 * Theme.spacing.large - color: Theme.palette.backgroundTertiary - radius: Theme.spacing.radiusLarge - border.color: Theme.palette.border - border.width: 1 - - ColumnLayout { - id: leaderRewardsCol - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: Theme.spacing.large - spacing: Theme.spacing.small - - LogosText { - text: qsTr("Leader rewards") - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - - RowLayout { - Layout.fillWidth: true - - LogosButton { - id: leaderClaimButton - Layout.preferredWidth: 140 - text: qsTr("Claim") - onClicked: root.claimLeaderRewardsRequested() - } - - LogosButton { - Layout.fillWidth: true - enabled: true - padding: Theme.spacing.small - contentItem: RowLayout { - width: parent.width - anchors.centerIn: parent - LogosText { - id: leaderClaimResultText - Layout.fillWidth: true - color: Theme.palette.textSecondary - font.pixelSize: Theme.typography.secondaryText - font.weight: Theme.typography.weightMedium - wrapMode: Text.WordWrap - elide: Text.ElideRight - } - LogosCopyButton { - Layout.alignment: Qt.AlignRight - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - onCopyText: root.copyToClipboard(leaderClaimResultText.text) - visible: leaderClaimResultText.text - } - } - } - } - } - } - } - - component StyledAddressComboBox: ComboBox { - id: comboControl - - Layout.fillWidth: true - padding: Theme.spacing.large - editable: true - font.pixelSize: Theme.typography.secondaryText - - background: Rectangle { - color: Theme.palette.backgroundTertiary - radius: Theme.spacing.radiusLarge - border.color: Theme.palette.border - border.width: 1 - } - indicator: LogosText { - id: comboIndicator - text: "▼" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - x: comboControl.width - width - Theme.spacing.small - y: (comboControl.height - height) / 2 - visible: comboControl.count > 0 - } - contentItem: Item { - implicitWidth: 200 - implicitHeight: 30 - - TextField { - id: comboTextField - anchors.fill: parent - leftPadding: 0 - rightPadding: comboControl.count > 0 ? comboIndicator.width + Theme.spacing.small : Theme.spacing.small - topPadding: 0 - bottomPadding: 0 - verticalAlignment: Text.AlignVCenter - font.pixelSize: Theme.typography.secondaryText - text: comboControl.editText - onTextChanged: if (text !== comboControl.editText) comboControl.editText = text - selectByMouse: true - color: Theme.palette.text - background: Item { } - } - MouseArea { - anchors.fill: parent - visible: comboControl.count > 0 - z: 1 - onPressed: { - comboControl.popup.visible ? comboControl.popup.close() : comboControl.popup.open() - } - } - } - delegate: ItemDelegate { - id: comboDelegate - width: comboControl.width - contentItem: LogosText { - width: parent.width - height: contentHeight + Theme.spacing.large - font.pixelSize: Theme.typography.secondaryText - font.bold: true - text: (typeof model.address !== "undefined" ? model.address : modelData) || "" - elide: Text.ElideMiddle - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - color: comboDelegate.highlighted ? - Theme.palette.backgroundTertiary : - Theme.palette.backgroundSecondary - } - highlighted: comboControl.highlightedIndex === index - } - popup: Popup { - y: comboControl.height - 1 - width: comboControl.width - height: contentItem.implicitHeight - padding: 1 - - onOpened: if (comboControl.count === 0) close() - - contentItem: ListView { - clip: true - implicitHeight: contentHeight - model: comboControl.popup.visible ? comboControl.delegateModel : null - ScrollIndicator.vertical: ScrollIndicator { } - highlightFollowsCurrentItem: false - } - - background: Rectangle { - color: Theme.palette.backgroundSecondary - border.color: Theme.palette.border - border.width: 1 - radius: Theme.spacing.radiusLarge - } - } - } -} diff --git a/src/qml/views/qmldir b/src/qml/views/qmldir index fbca773..3696ca1 100644 --- a/src/qml/views/qmldir +++ b/src/qml/views/qmldir @@ -1,7 +1,11 @@ module views StatusConfigView 1.0 StatusConfigView.qml -LogsView 1.0 LogsView.qml -WalletView 1.0 WalletView.qml +BlocksView 1.0 BlocksView.qml +CryptarchiaInfoView 1.0 CryptarchiaInfoView.qml +NodeInfoView 1.0 NodeInfoView.qml +AccountsView 1.0 AccountsView.qml +TransferView 1.0 TransferView.qml +LeaderRewardsView 1.0 LeaderRewardsView.qml GenerateConfigView 1.0 GenerateConfigView.qml ConfigChoiceView 1.0 ConfigChoiceView.qml SetConfigPathView 1.0 SetConfigPathView.qml