feat: Revamp interface (#24)

* Modify views

* Use structure view for blocks events

* Stability fixes

* Add cryptarchia info block

* Hide settings button on run

* Pinnable accounts operation

* Use stylish pin button

* Show balance on transfer

* Stop node on leave

* Add info box with peer id

* Add vouchers view to leader claim with pending counter

* Add help buttons

* Remove tx useless label

* Update module
This commit is contained in:
Daniel Sanchez 2026-06-23 14:49:35 +02:00 committed by GitHub
parent 40dd478ed4
commit 85b0b153f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2149 additions and 688 deletions

View File

@ -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

16
flake.lock generated
View File

@ -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"
}

View File

@ -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, ... }:

167
src/BlockModel.cpp Normal file
View File

@ -0,0 +1,167 @@
#include "BlockModel.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
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<int, QByteArray> BlockModel::roleNames() const
{
QHash<int, QByteArray> 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":"<json string>"}
// whose value is itself a JSON-encoded block. Tolerate a few shapes:
// { "block": "<stringified 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<qlonglong>(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();
}

69
src/BlockModel.h Normal file
View File

@ -0,0 +1,69 @@
#pragma once
#include <QAbstractListModel>
#include <QString>
#include <QStringList>
#include <QVector>
// 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<int, QByteArray> 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<Entry> m_entries;
};

View File

@ -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)

View File

@ -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
// "<module>/<propertyName>" (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;
};

View File

@ -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))
}

View File

@ -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<int, QByteArray> LogModel::roleNames() const
{
QHash<int, QByteArray> 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();
}

View File

@ -1,26 +0,0 @@
#pragma once
#include <QAbstractListModel>
#include <QStringList>
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<int, QByteArray> roleNames() const override;
Q_INVOKABLE void append(const QString& line);
Q_INVOKABLE void clear();
signals:
void countChanged();
private:
QStringList m_lines;
};

View File

@ -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
// "<module>/<propertyName>". 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")
}
}
}

View File

@ -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)")
}
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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": [ <proof>, ... ] } // 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) : ""
}
}
}

1
src/qml/icons/info.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m11 7h2v2h-2zm0 4h2v6h-2zm1-9c-5.52 0-10 4.48-10 10s4.48 10 10 10 10-4.48 10-10-4.48-10-10-10zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 278 B

1
src/qml/icons/pin.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m16 12v-8h1v-2h-10v2h1v8l-2 2v2h5.2v6h1.6v-6h5.2v-2z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 178 B

View File

@ -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)
}
}
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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) }
}
}
}

View File

@ -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)
}
}
}

View File

@ -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": "<hex>", "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)
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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

View File

@ -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
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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