Use structure view for blocks events

This commit is contained in:
Daniel 2026-06-20 15:59:14 +02:00
parent c34ba1552f
commit 901a7fa7d4
13 changed files with 603 additions and 193 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

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"));
@ -423,9 +418,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.
@ -57,7 +57,7 @@ public slots:
QStringList fundingPublicKeyHexes,
QString maxTxFee,
QString optionalTipHex) override;
void clearLogs() override;
void clearBlocks() override;
void copyToClipboard(QString text) override;
private:
@ -67,7 +67,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

@ -18,6 +18,6 @@ class BlockchainBackend
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

@ -38,7 +38,7 @@ Rectangle {
// 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
@ -240,12 +240,12 @@ Rectangle {
}
}
LogsView {
BlocksView {
SplitView.fillWidth: true
SplitView.minimumHeight: 150
logModel: root.logModel
onClearRequested: if (root.backend) root.backend.clearLogs()
blockModel: root.blockModel
onClearRequested: if (root.backend) root.backend.clearBlocks()
onCopyToClipboard: (text) => {
root.copyText(text)
}

View File

@ -0,0 +1,262 @@
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 || []
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: model.parsed
text: qsTr("slot %1").arg(model.slot || qsTr("?"))
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
// Version badge
Rectangle {
visible: model.parsed && (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: !model.parsed
text: qsTr("Unparsed block")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.warning
}
Item { Layout.fillWidth: true }
LogosText {
visible: model.parsed
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: model.parsed }
HashRow { label: qsTr("Block root"); value: model.blockRoot || ""; visible: model.parsed }
HashRow { label: qsTr("Signature"); value: model.signature || ""; visible: model.parsed }
// Proof of leadership (collapsible sub-group)
RowLayout {
Layout.fillWidth: true
visible: model.parsed
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: model.parsed && del.proofExpanded
spacing: Theme.spacing.small
HashRow { label: qsTr("Leader key"); value: model.leaderKey || "" }
HashRow { label: qsTr("Entropy"); value: model.entropy || "" }
HashRow { label: qsTr("Proof"); value: model.proof || "" }
HashRow { label: qsTr("Voucher cm"); value: model.voucherCm || "" }
}
// Transactions
LogosText {
visible: model.parsed
text: qsTr("Transactions (%1)").arg(model.txCount || 0)
font.pixelSize: Theme.typography.secondaryText
font.bold: true
}
ColumnLayout {
Layout.fillWidth: true
visible: model.parsed
spacing: Theme.spacing.tiny
Repeater {
model: del.expanded ? del.transactionsList : []
delegate: TxItem {
required property int index
required property string modelData
Layout.fillWidth: true
idx: index
json: modelData
}
}
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: !model.parsed
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: !model.parsed
json: model.rawJson || ""
}
}
}
// ---- Inline helpers ----
component HashRow: RowLayout {
property string label
property string value
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: label
Layout.preferredWidth: 110
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
}
LogosText {
Layout.fillWidth: true
text: value && value.length > 0 ? value : "—"
elide: Text.ElideMiddle
font.pixelSize: Theme.typography.secondaryText
font.family: "monospace"
}
LogosCopyButton {
visible: value && value.length > 0
onCopyText: del.copyToClipboard(value)
}
}
component JsonBlock: Rectangle {
property string json
implicitHeight: jsonText.implicitHeight + 2 * Theme.spacing.small
color: Theme.palette.backgroundSecondary
radius: Theme.spacing.radiusSmall
border.color: Theme.palette.border
border.width: 1
LogosText {
id: jsonText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacing.small
text: parent.json
font.pixelSize: Theme.typography.secondaryText
font.family: "monospace"
wrapMode: Text.WrapAnywhere
}
}
component TxItem: ColumnLayout {
id: txRoot
property int idx
property string json
property bool open: false
spacing: Theme.spacing.tiny
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: (txRoot.open ? "▾ " : "▸ ") + qsTr("Transaction %1").arg(txRoot.idx + 1)
font.pixelSize: Theme.typography.secondaryText
TapHandler { onTapped: txRoot.open = !txRoot.open }
}
Item { Layout.fillWidth: true }
LogosCopyButton { onCopyText: del.copyToClipboard(txRoot.json) }
}
JsonBlock {
Layout.fillWidth: true
visible: txRoot.open
json: txRoot.json
}
}
}

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

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

@ -1,6 +1,6 @@
module views
StatusConfigView 1.0 StatusConfigView.qml
LogsView 1.0 LogsView.qml
BlocksView 1.0 BlocksView.qml
AccountsView 1.0 AccountsView.qml
TransferView 1.0 TransferView.qml
LeaderRewardsView 1.0 LeaderRewardsView.qml