diff --git a/main.qml b/main.qml index 16fcff2106..1677145331 100644 --- a/main.qml +++ b/main.qml @@ -1521,6 +1521,8 @@ ApplicationWindow { property bool autosave: true property int autosaveMinutes: 10 property bool pruneBlockchain: false + property bool enableSubaddressPagination: false + property int subaddressPageSize: 100 property bool fiatPriceEnabled: false property bool fiatPriceToggle: false diff --git a/pages/Receive.qml b/pages/Receive.qml index 9a36a279dd..eb0eea8237 100644 --- a/pages/Receive.qml +++ b/pages/Receive.qml @@ -551,13 +551,61 @@ Rectangle { } } + Rectangle { + id: primaryAddressContainer + Layout.fillWidth: true + Layout.preferredHeight: 50 + Layout.topMargin: 6 + color: MoneroComponents.Style.titleBarButtonHoverColor + radius: 3 + visible: persistentSettings.enableSubaddressPagination && (!subaddressListView.model || subaddressListView.count === 0) + + Rectangle { + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 2 + color: MoneroComponents.Style.accountColors[0] + } + + MoneroComponents.Label { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "#0 " + qsTr("Primary address") + translationManager.emptyString + fontSize: 16 + color: MoneroComponents.Style.defaultFontColor + } + + MoneroComponents.Label { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: appWindow.currentWallet ? TxUtils.addressTruncatePretty(appWindow.currentWallet.address(0, 0), 3) : "" + fontSize: 16 + fontFamily: MoneroComponents.Style.fontMonoRegular.name + color: MoneroComponents.Style.defaultFontColor + } + + MoneroComponents.TextPlain { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.bottom + anchors.topMargin: 20 + text: qsTr("Loading addresses...") + translationManager.emptyString + color: MoneroComponents.Style.dimmedFontColor + font.pixelSize: 12 + visible: true + } + } + ColumnLayout { id: subaddressListRow property int subaddressListItemHeight: 50 Layout.topMargin: 6 Layout.fillWidth: true Layout.minimumWidth: 240 - Layout.preferredHeight: subaddressListItemHeight * subaddressListView.count + Layout.preferredHeight: persistentSettings.enableSubaddressPagination ? + Math.min(subaddressListItemHeight * 20, subaddressListItemHeight * subaddressListView.count) : + subaddressListItemHeight * subaddressListView.count visible: subaddressListView.count >= 1 ListView { @@ -566,7 +614,32 @@ Rectangle { Layout.fillHeight: true clip: true boundsBehavior: ListView.StopAtBounds - interactive: false + interactive: persistentSettings.enableSubaddressPagination + + cacheBuffer: persistentSettings.enableSubaddressPagination ? 200 : 0 + + property bool paginationEnabled: persistentSettings.enableSubaddressPagination && count > persistentSettings.subaddressPageSize + + function loadAllRemaining() { + var model = subaddressListView.model; + if (!model) return; + + while (model.loadedCount < model.totalCount && model.canFetchMore(model.index(0,0))) { + model.fetchMore(model.index(model.rowCount() - 1, 0)); + } + subaddressListView.positionViewAtEnd(); + } + + onContentYChanged: { + if (paginationEnabled && model && model.shouldPreload) { + var firstVisible = Math.floor(contentY / subaddressListRow.subaddressListItemHeight); + var lastVisible = Math.ceil((contentY + height) / subaddressListRow.subaddressListItemHeight); + + if (model.shouldPreload(firstVisible, lastVisible)) { + model.fetchMore(model.index(model.rowCount() - 1, 0)); + } + } + } delegate: Rectangle { id: tableItem2 @@ -574,6 +647,8 @@ Rectangle { width: parent ? parent.width : undefined Layout.fillWidth: true color: itemMouseArea.containsMouse || index === appWindow.current_subaddress_table_index ? MoneroComponents.Style.titleBarButtonHoverColor : "transparent" + + property bool isPlaceholder: false Rectangle { visible: index === appWindow.current_subaddress_table_index @@ -619,7 +694,8 @@ Rectangle { MoneroComponents.Label { id: nameLabel - color: index === appWindow.current_subaddress_table_index ? MoneroComponents.Style.defaultFontColor : MoneroComponents.Style.dimmedFontColor + color: tableItem2.isPlaceholder ? MoneroComponents.Style.dimmedFontColor : + (index === appWindow.current_subaddress_table_index ? MoneroComponents.Style.defaultFontColor : MoneroComponents.Style.dimmedFontColor) anchors.verticalCenter: parent.verticalCenter anchors.left: idLabel.right anchors.leftMargin: 6 @@ -628,26 +704,33 @@ Rectangle { elide: Text.ElideRight textWidth: addressLabel.x - nameLabel.x - 1 themeTransition: false + opacity: tableItem2.isPlaceholder ? 0.6 : 1.0 } MoneroComponents.Label { id: addressLabel - color: MoneroComponents.Style.defaultFontColor + color: tableItem2.isPlaceholder ? MoneroComponents.Style.dimmedFontColor : MoneroComponents.Style.defaultFontColor anchors.verticalCenter: parent.verticalCenter anchors.left: parent.right anchors.leftMargin: -addressLabel.width - 5 fontSize: 16 fontFamily: MoneroComponents.Style.fontMonoRegular.name; - text: TxUtils.addressTruncatePretty(address, mainLayout.width < 520 ? 1 : (mainLayout.width < 650 ? 2 : 3)) + text: tableItem2.isPlaceholder ? "..." : TxUtils.addressTruncatePretty(address, mainLayout.width < 520 ? 1 : (mainLayout.width < 650 ? 2 : 3)) themeTransition: false + opacity: tableItem2.isPlaceholder ? 0.6 : 1.0 } MouseArea { id: itemMouseArea - cursorShape: Qt.PointingHandCursor + cursorShape: tableItem2.isPlaceholder ? Qt.ArrowCursor : Qt.PointingHandCursor anchors.fill: parent - hoverEnabled: true - onClicked: subaddressListView.currentIndex = index; + hoverEnabled: !tableItem2.isPlaceholder + enabled: !tableItem2.isPlaceholder + onClicked: { + if (!tableItem2.isPlaceholder) { + subaddressListView.currentIndex = index; + } + } } } @@ -657,6 +740,7 @@ Rectangle { anchors.rightMargin: 6 height: 21 spacing: 10 + visible: !tableItem2.isPlaceholder MoneroComponents.IconButton { fontAwesomeFallbackIcon: FontAwesome.searchPlus @@ -726,6 +810,75 @@ Rectangle { } } } + + footer: persistentSettings.enableSubaddressPagination ? loadingFooter : null + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: visible ? 50 : 0 + color: "transparent" + visible: persistentSettings.enableSubaddressPagination && + subaddressListView.model && + subaddressListView.model.totalCount > subaddressListView.model.loadedCount + + MoneroComponents.StandardButton { + anchors.centerIn: parent + small: true + text: qsTr("Scroll to bottom") + translationManager.emptyString + fontSize: 13 + onClicked: { + var model = subaddressListView.model; + if (model && model.totalCount > 0) { + subaddressListView.loadAllRemaining(); + } + } + } + } + + + Component { + id: loadingFooter + Rectangle { + width: subaddressListView.width + height: visible ? 50 : 0 + color: "transparent" + visible: subaddressListView.model && + subaddressListView.model.loadedCount < subaddressListView.model.totalCount + + RowLayout { + anchors.centerIn: parent + spacing: 10 + + MoneroComponents.TextPlain { + id: loadingSpinner + text: "⟳" + color: MoneroComponents.Style.dimmedFontColor + font.pixelSize: 16 + + RotationAnimator { + target: loadingSpinner + from: 0 + to: 360 + duration: 1000 + running: parent.parent.parent.visible + loops: Animation.Infinite + } + } + + MoneroComponents.TextPlain { + text: { + var model = subaddressListView.model; + if (model && typeof model.loadedCount === 'number' && typeof model.totalCount === 'number') { + return qsTr("Loading ") + model.loadedCount + qsTr(" of ") + model.totalCount + qsTr(" addresses...") + translationManager.emptyString; + } + return qsTr("Loading more addresses...") + translationManager.emptyString; + } + color: MoneroComponents.Style.dimmedFontColor + font.pixelSize: 14 + } + } + } } } @@ -774,12 +927,29 @@ Rectangle { if (appWindow.currentWallet) { appWindow.current_address = appWindow.currentWallet.address(appWindow.currentWallet.currentSubaddressAccount, 0) - appWindow.currentWallet.subaddress.refresh(appWindow.currentWallet.currentSubaddressAccount) + + if (persistentSettings.enableSubaddressPagination) { + delayedRefreshTimer.start(); + } else { + appWindow.currentWallet.subaddress.refresh(appWindow.currentWallet.currentSubaddressAccount) + } + if (subaddressListView.currentIndex == -1) { subaddressListView.currentIndex = 0; } } } + + Timer { + id: delayedRefreshTimer + interval: 400 // 400ms delay to allow page transition animation to complete (300ms + buffer). + repeat: false + onTriggered: { + if (appWindow.currentWallet) { + appWindow.currentWallet.subaddress.refresh(appWindow.currentWallet.currentSubaddressAccount); + } + } + } function clearFields() { amountToReceiveFiat.text = ""; diff --git a/pages/settings/SettingsLayout.qml b/pages/settings/SettingsLayout.qml index e73355ae88..3e03dfe44f 100644 --- a/pages/settings/SettingsLayout.qml +++ b/pages/settings/SettingsLayout.qml @@ -121,6 +121,12 @@ Rectangle { text: qsTr("Autosave") + translationManager.emptyString } + MoneroComponents.CheckBox { + checked: persistentSettings.enableSubaddressPagination + onClicked: persistentSettings.enableSubaddressPagination = !persistentSettings.enableSubaddressPagination + text: qsTr("Enable subaddress pagination") + translationManager.emptyString + } + MoneroComponents.Slider { Layout.fillWidth: true Layout.leftMargin: 35 diff --git a/src/libwalletqt/Subaddress.cpp b/src/libwalletqt/Subaddress.cpp index 0108215b67..c281239389 100644 --- a/src/libwalletqt/Subaddress.cpp +++ b/src/libwalletqt/Subaddress.cpp @@ -30,8 +30,9 @@ #include Subaddress::Subaddress(Monero::Subaddress *subaddressImpl, QObject *parent) - : QObject(parent), m_subaddressImpl(subaddressImpl) + : QObject(parent), m_subaddressImpl(subaddressImpl), m_totalCount(0), m_totalCountCached(false) { + m_pageCache.setMaxCost(50); getAll(); } @@ -42,10 +43,20 @@ void Subaddress::getAll() const { QWriteLocker locker(&m_lock); + if (!m_rows.isEmpty() && m_totalCountCached) { + emit refreshFinished(); + return; + } + + auto allRows = m_subaddressImpl->getAll(); m_rows.clear(); - for (auto &row: m_subaddressImpl->getAll()) { + + for (auto &row: allRows) { m_rows.append(row); } + + m_totalCount = allRows.size(); + m_totalCountCached = true; } emit refreshFinished(); @@ -67,12 +78,14 @@ bool Subaddress::getRow(int index, std::functionaddRow(accountIndex, label.toStdString()); + clearCache(); getAll(); } void Subaddress::setLabel(quint32 accountIndex, quint32 addressIndex, const QString &label) const { m_subaddressImpl->setLabel(accountIndex, addressIndex, label.toStdString()); + clearCache(); getAll(); } @@ -86,6 +99,7 @@ void Subaddress::refresh(quint32 accountIndex) const { qCritical() << "Failed to refresh account" << accountIndex << "subaddresses:" << e.what(); } + clearCache(); getAll(); } @@ -95,3 +109,86 @@ quint64 Subaddress::count() const return m_rows.size(); } + +void Subaddress::getPage(int offset, int limit) const +{ + emit refreshStarted(); + + { + QWriteLocker locker(&m_lock); + + int pageKey = offset / limit; + + if (m_pageCache.contains(pageKey)) { + emit refreshFinished(); + return; + } + + if (m_rows.isEmpty()) { + for (auto &row: m_subaddressImpl->getAll()) { + m_rows.append(row); + } + } + + QList page; + int endIndex = qMin(offset + limit, static_cast(m_rows.size())); + + for (int i = offset; i < endIndex; ++i) { + page.append(m_rows[i]); + } + + m_pageCache.insert(pageKey, new QList(page), 1); + } + + emit refreshFinished(); +} + +void Subaddress::getPageSilent(int offset, int limit) const +{ + QWriteLocker locker(&m_lock); + + int pageKey = offset / limit; + + if (m_pageCache.contains(pageKey)) { + return; + } + + if (m_rows.isEmpty()) { + for (auto &row: m_subaddressImpl->getAll()) { + m_rows.append(row); + } + } + + QList page; + int endIndex = qMin(offset + limit, static_cast(m_rows.size())); + + for (int i = offset; i < endIndex; ++i) { + page.append(m_rows[i]); + } + + m_pageCache.insert(pageKey, new QList(page), 1); +} + +quint64 Subaddress::getTotalCount() const +{ + QReadLocker locker(&m_lock); + + if (!m_totalCountCached) { + m_totalCount = m_rows.size(); + if (m_totalCount == 0) { + const_cast(this)->getAll(); + m_totalCount = m_rows.size(); + } + m_totalCountCached = true; + } + + return m_totalCount; +} + +void Subaddress::clearCache() const +{ + QWriteLocker locker(&m_lock); + m_pageCache.clear(); + m_totalCountCached = false; + m_totalCount = 0; +} diff --git a/src/libwalletqt/Subaddress.h b/src/libwalletqt/Subaddress.h index e1001c6bed..7e5c43b4aa 100644 --- a/src/libwalletqt/Subaddress.h +++ b/src/libwalletqt/Subaddress.h @@ -36,12 +36,17 @@ #include #include #include +#include +#include class Subaddress : public QObject { Q_OBJECT public: Q_INVOKABLE void getAll() const; + Q_INVOKABLE void getPage(int offset, int limit) const; + void getPageSilent(int offset, int limit) const; + Q_INVOKABLE quint64 getTotalCount() const; Q_INVOKABLE bool getRow(int index, std::function callback) const; Q_INVOKABLE void addRow(quint32 accountIndex, const QString &label) const; Q_INVOKABLE void setLabel(quint32 accountIndex, quint32 addressIndex, const QString &label) const; @@ -60,6 +65,11 @@ public slots: mutable QReadWriteLock m_lock; Monero::Subaddress * m_subaddressImpl; mutable QList m_rows; + + mutable QCache> m_pageCache; + mutable quint64 m_totalCount; + mutable bool m_totalCountCached; + void clearCache() const; }; #endif // SUBADDRESS_H diff --git a/src/model/SubaddressModel.cpp b/src/model/SubaddressModel.cpp index 56586b317d..cc0f9c77ad 100644 --- a/src/model/SubaddressModel.cpp +++ b/src/model/SubaddressModel.cpp @@ -33,28 +33,39 @@ #include SubaddressModel::SubaddressModel(QObject *parent, Subaddress *subaddress) - : QAbstractListModel(parent), m_subaddress(subaddress) + : QAbstractListModel(parent), m_subaddress(subaddress), m_loadedCount(0), m_pageSize(100), m_fetchingMore(false) { connect(m_subaddress,SIGNAL(refreshStarted()),this,SLOT(startReset())); connect(m_subaddress,SIGNAL(refreshFinished()),this,SLOT(endReset())); - } void SubaddressModel::startReset(){ beginResetModel(); + m_fetchingMore = false; } void SubaddressModel::endReset(){ + int actualRows = static_cast(m_subaddress->count()); + int totalCount = static_cast(m_subaddress->getTotalCount()); + + if (m_loadedCount == 0) { + m_loadedCount = qMin(m_pageSize, actualRows); + } else { + m_loadedCount = qMin(m_loadedCount, actualRows); + } + endResetModel(); + emit loadedCountChanged(); + emit totalCountChanged(); } int SubaddressModel::rowCount(const QModelIndex &) const { - return m_subaddress->count(); + return m_loadedCount; } QVariant SubaddressModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() < 0 || static_cast(index.row()) >= m_subaddress->count()) + if (!index.isValid() || index.row() < 0 || index.row() >= m_loadedCount) return {}; QVariant result; @@ -89,3 +100,57 @@ QHash SubaddressModel::roleNames() const } return roleNames; } + +bool SubaddressModel::canFetchMore(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + if (m_fetchingMore) { + return false; + } + + return m_loadedCount < m_subaddress->getTotalCount(); +} + +void SubaddressModel::fetchMore(const QModelIndex &parent) +{ + Q_UNUSED(parent) + + if (m_fetchingMore) { + return; + } + + m_fetchingMore = true; + + quint64 totalCount = m_subaddress->getTotalCount(); + int itemsToFetch = qMin(m_pageSize, static_cast(totalCount - m_loadedCount)); + + if (itemsToFetch <= 0) { + m_fetchingMore = false; + return; + } + + m_subaddress->getPageSilent(m_loadedCount, itemsToFetch); + + int oldLoadedCount = m_loadedCount; + m_loadedCount += itemsToFetch; + emit loadedCountChanged(); + + beginInsertRows(QModelIndex(), oldLoadedCount, m_loadedCount - 1); + endInsertRows(); + + m_fetchingMore = false; +} + +bool SubaddressModel::shouldPreload(int firstVisible, int lastVisible) const +{ + int preloadThreshold = static_cast(m_loadedCount * 0.8); + bool shouldPreload = lastVisible >= preloadThreshold && canFetchMore(QModelIndex()); + + return shouldPreload; +} + +int SubaddressModel::getTotalCount() const +{ + return static_cast(m_subaddress->getTotalCount()); +} diff --git a/src/model/SubaddressModel.h b/src/model/SubaddressModel.h index 70183adb64..537e7b73ee 100644 --- a/src/model/SubaddressModel.h +++ b/src/model/SubaddressModel.h @@ -36,6 +36,8 @@ class Subaddress; class SubaddressModel : public QAbstractListModel { Q_OBJECT + Q_PROPERTY(int loadedCount READ getLoadedCount NOTIFY loadedCountChanged) + Q_PROPERTY(int totalCount READ getTotalCount NOTIFY totalCountChanged) public: enum SubaddressRowRole { @@ -50,6 +52,18 @@ class SubaddressModel : public QAbstractListModel int rowCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QHash roleNames() const override; + + bool canFetchMore(const QModelIndex &parent) const override; + void fetchMore(const QModelIndex &parent) override; + + Q_INVOKABLE bool shouldPreload(int firstVisible, int lastVisible) const; + + int getLoadedCount() const { return m_loadedCount; } + int getTotalCount() const; + +signals: + void loadedCountChanged(); + void totalCountChanged(); public slots: void startReset(); @@ -57,6 +71,10 @@ public slots: private: Subaddress *m_subaddress; + + mutable int m_loadedCount; + mutable int m_pageSize; + mutable bool m_fetchingMore; }; #endif // SUBADDRESSMODEL_H