Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion electrum/gui/qml/components/controls/ServerConfig.qml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ Item {
ElListView {
id: serversListView
anchors.fill: parent
exclusionZone: 0
model: Network.serverListModel
delegate: ServerDelegate {
onClicked: {
Expand All @@ -116,7 +117,7 @@ Item {
required property string section
Label {
text: section
? serversListView.model.chaintips > 1
? Network.chaintips > 1
? qsTr('Connected @%1').arg(section)
: qsTr('Connected')
: qsTr('Other known servers')
Expand Down
30 changes: 25 additions & 5 deletions electrum/gui/qml/components/controls/ServerDelegate.qml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ ItemDelegate {
left: parent.left
right: parent.right
leftMargin: constants.paddingXLarge
rightMargin: constants.paddingSmall
rightMargin: constants.paddingSmall + (root.ListView.view.rightExclusionZone || 0)
}

columns: 3
columns: 4

// topmargin
Rectangle {
Layout.columnSpan: 3
Layout.columnSpan: 4
Layout.preferredHeight: constants.paddingSmall
color: 'transparent'
}
Expand All @@ -42,7 +42,9 @@ ItemDelegate {
Layout.preferredWidth: constants.iconSizeMedium
Layout.preferredHeight: constants.iconSizeMedium
Image {
source: '../../../icons/status_connected.png'
source: model.is_lagging
? '../../../icons/status_lagging.png'
: '../../../icons/status_connected.png'
width: constants.iconSizeMedium
height: constants.iconSizeMedium
visible: model.is_connected
Expand All @@ -51,11 +53,29 @@ ItemDelegate {
Label {
Layout.fillWidth: true
text: model.address
wrapMode: Text.Wrap
}
Item {
Layout.preferredWidth: constants.iconSizeLarge
Layout.preferredHeight: constants.iconSizeLarge
Image {
anchors.centerIn: parent
width: constants.iconSizeMedium
height: constants.iconSizeMedium
source: model.is_bookmarked
? '../../../icons/bookmark.png'
: '../../../icons/bookmark_add.png'
opacity: model.is_bookmarked ? 1.0 : 0.4
}
MouseArea {
anchors.fill: parent
onClicked: Network.setServerBookmark(model.name, !model.is_bookmarked)
}
}

// bottommargin
Rectangle {
Layout.columnSpan: 3
Layout.columnSpan: 4
Layout.preferredHeight: constants.paddingSmall
color: 'transparent'
}
Expand Down
22 changes: 18 additions & 4 deletions electrum/gui/qml/qenetwork.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from electrum.gui.common_qt.util import QtEventListener

from .qeconfig import QEConfig
from .qeserverlistmodel import QEServerListModel
from .qeserverlistmodel import QEServerListModel, QEServerListSortModel

if TYPE_CHECKING:
from electrum.network import Network
Expand Down Expand Up @@ -56,6 +56,7 @@ def __init__(self, network: 'Network', parent=None):
assert network, "--offline is not yet implemented for this GUI" # TODO
self.network = network
self._serverListModel = None
self._serverListSortModel = None
self._height = network.get_local_height() # init here, update event can take a while
self._server_height = network.get_server_height() # init here, update event can take a while
self.register_callbacks()
Expand Down Expand Up @@ -212,6 +213,18 @@ def server(self):
def isValidServerAddress(self, server: str) -> bool:
return ServerAddr.from_str_with_inference(server) is not None

@pyqtSlot(str, bool)
def setServerBookmark(self, server_str: str, bookmarked: bool):
# server_str is the 'name' role of QEServerListModel, i.e. str(ServerAddr)
try:
server = ServerAddr.from_str(server_str)
except ValueError:
self._logger.warning(f'setServerBookmark: invalid server address {server_str!r}')
return
self.network.set_server_bookmark(server, add=bookmarked)
if self._serverListModel is not None:
self._serverListModel.updateModel()

@pyqtSlot(str, bool, bool)
def setServerParameters(self, server_str: str, auto_connect: bool, one_server: bool):
net_params = self.network.get_parameters()
Expand Down Expand Up @@ -303,11 +316,12 @@ def gossipInfo(self):
}

serverListModelChanged = pyqtSignal()
@pyqtProperty(QEServerListModel, notify=serverListModelChanged)
@pyqtProperty(QEServerListSortModel, notify=serverListModelChanged)
def serverListModel(self):
if self._serverListModel is None:
if self._serverListSortModel is None:
self._serverListModel = QEServerListModel(self.network)
return self._serverListModel
self._serverListSortModel = QEServerListSortModel(self._serverListModel)
return self._serverListSortModel

@pyqtSlot()
def probeTor(self):
Expand Down
160 changes: 116 additions & 44 deletions electrum/gui/qml/qeserverlistmodel.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, List, Dict, Any

from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex
from PyQt6.QtCore import pyqtSlot
from PyQt6.QtCore import Qt, QAbstractListModel, QModelIndex, QSortFilterProxyModel

from electrum.logging import get_logger
from electrum.util import Satoshis
Expand All @@ -13,19 +13,24 @@


class QEServerListModel(QAbstractListModel, QtEventListener):
"""List of known servers, connected and disconnected.

Rows are updated in place and keep their position in this model; the view should
use QEServerListSortModel to get connected servers grouped by chain first and
disconnected servers last.
"""
_logger = get_logger(__name__)

# define listmodel rolemap
_ROLE_NAMES=('name', 'address', 'is_connected', 'is_primary', 'is_tor', 'chain', 'height')
_ROLE_NAMES=('name', 'address', 'is_connected', 'is_primary', 'is_tor', 'is_bookmarked', 'is_lagging', 'chain', 'height')
_ROLE_KEYS = range(Qt.ItemDataRole.UserRole, Qt.ItemDataRole.UserRole + len(_ROLE_NAMES))
_ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES]))
_ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS))

def __init__(self, network: 'Network', parent=None):
super().__init__(parent)

self._chaintips = 0
self._servers = []
self._servers = [] # type: List[Dict[str, Any]]

self.network = network
self.initModel()
Expand All @@ -34,18 +39,13 @@ def __init__(self, network: 'Network', parent=None):

@qt_event_listener
def on_event_network_updated(self):
self._logger.info(f'network updated')
self.initModel()

@qt_event_listener
def on_event_blockchain_updated(self):
self._logger.info(f'blockchain updated')
self.initModel()
self._logger.debug(f'network updated')
self.updateModel()

@qt_event_listener
def on_event_default_server_changed(self):
self._logger.info(f'default server changed')
self.initModel()
self.updatePrimary()

def rowCount(self, index):
return len(self._servers)
Expand All @@ -69,46 +69,48 @@ def clear(self):
self._servers = []
self.endResetModel()

chaintipsChanged = pyqtSignal()
@pyqtProperty(int, notify=chaintipsChanged)
def chaintips(self):
return self._chaintips

def get_chains(self):
chains = self.network.get_blockchains()
n_chains = len(chains)
if n_chains != self._chaintips:
self._chaintips = n_chains
self.chaintipsChanged.emit()
return chains
def _is_primary(self, server: dict) -> bool:
interface = self.network.interface
if interface is None or not server['is_connected']:
return False
return server['name'] == str(interface.server)

@pyqtSlot()
def initModel(self):
self.clear()
def updatePrimary(self):
role = self._ROLE_RMAP['is_primary']
for row, server in enumerate(self._servers):
is_primary = self._is_primary(server)
if server['is_primary'] == is_primary:
continue
server['is_primary'] = is_primary
idx = self.index(row, 0)
self.dataChanged.emit(idx, idx, [role])

def _build_servers(self) -> List[Dict[str, Any]]:
"""Collect the current server state from the network as a list of row dicts."""
servers = []

chains = self.get_chains()

for chain_id, interfaces in chains.items():
self._logger.debug(f'chain {chain_id} has {len(interfaces)} interfaces')
b = self.network.bc_mgr.blockchains.get(chain_id)
if b is None:
continue
for chain_id, interfaces in self.network.get_blockchains().items():
b = interfaces[0].blockchain

name = b.get_name()

self._logger.debug(f'chain {chain_id} has name={name}, max_forkpoint=@{b.get_max_forkpoint()}, height={b.height()}')
self._logger.debug(f'chain {chain_id} has name={name}, max_forkpoint=@{b.get_max_forkpoint()}, '
f'height={b.height()}, interfaces={len(interfaces)}')

for i in interfaces:
server = {
'chain': name,
'chain_height': b.height(),
'is_primary': i == self.network.interface,
'is_connected': True,
# more than 1 block behind the chain it is on. Network._server_is_lagging
# applies the same threshold to the main interface, relative to the local height.
'is_lagging': (b.height() - i.tip) > 1,
'is_tor': i.server.host.endswith('.onion'),
'is_bookmarked': self.network.is_server_bookmarked(i.server),
'name': str(i.server),
'address': i.server.to_friendly_name(),
'height': i.tip
'height': i.tip,
}

servers.append(server)
Expand All @@ -117,16 +119,86 @@ def initModel(self):
for s in self.network.get_disconnected_server_addrs():
server = {
'chain': '',
'chain_height': 0,
'height': 0,
'is_primary': False,
'is_connected': False,
'name': s.to_friendly_name()
'is_lagging': False,
'is_tor': s.host.endswith('.onion'),
'is_bookmarked': self.network.is_server_bookmarked(s),
'name': str(s),
'address': s.to_friendly_name(),
}
server['address'] = server['name']

servers.append(server)

self.beginInsertRows(QModelIndex(), 0, len(servers) - 1)
self._servers = servers
self.endInsertRows()
return servers

@pyqtSlot()
def updateModel(self):
"""Bring the model in sync with the network, updating rows in place.

Existing rows keep their position: changed roles are signalled through
dataChanged, vanished servers are removed and new servers are appended.
"""
new_servers = self._build_servers()
new_by_name = {}
for server in new_servers:
new_by_name.setdefault(server['name'], server)

# remove rows for servers that are gone
for row in reversed(range(len(self._servers))):
if self._servers[row]['name'] in new_by_name:
continue
self.beginRemoveRows(QModelIndex(), row, row)
del self._servers[row]
self.endRemoveRows()

# update rows for servers that are still there
existing = {server['name']: row for row, server in enumerate(self._servers)}
for name, row in existing.items():
current = self._servers[row]
new = new_by_name[name]
changed_roles = [self._ROLE_RMAP[k] for k in self._ROLE_NAMES if current.get(k) != new.get(k)]
if not changed_roles:
continue
current.update(new)
idx = self.index(row, 0)
self.dataChanged.emit(idx, idx, changed_roles)

# append rows for new servers
to_add = [server for name, server in new_by_name.items() if name not in existing]
if to_add:
first = len(self._servers)
self.beginInsertRows(QModelIndex(), first, first + len(to_add) - 1)
self._servers.extend(to_add)
self.endInsertRows()

@pyqtSlot()
def initModel(self):
self.clear()
self.updateModel()


class QEServerListSortModel(QSortFilterProxyModel):
"""Presents QEServerListModel with connected servers first, grouped by chain,
and disconnected servers last, so the view can use ListView.section on 'chain'.
Within a group bookmarked servers come first, then alphabetically by name.

The name tiebreaker keeps the order deterministic: on dataChanged the proxy
reinserts a row before rows with an equal key, which would otherwise leave
e.g. an unbookmarked server at the top of its group.
"""
_logger = get_logger(__name__)

def __init__(self, source: QEServerListModel, parent=None):
super().__init__(parent)
self.setSourceModel(source)
self.setDynamicSortFilter(True)
self.sort(0)

def _sort_key(self, source_index: QModelIndex):
server = self.sourceModel()._servers[source_index.row()]
return not server['is_connected'], server['chain'], not server['is_bookmarked'], server['name']

def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool:
return self._sort_key(left) < self._sort_key(right)
Loading