PyQt4 заставляет представление fetchMore из QAbstractItemModel

У меня есть QTableView, который динамически загружает данные из пользовательской модели, наследующей QAbstractItemModel. Модель реализует как fetchMore, так и canFetchMore.

Проблема в том, что я хотел бы иметь возможность выбирать все строки для небольших наборов данных, но если я нажму Ctrl-A в представлении, будут выбраны только те строки, которые загружены в данный момент.

Есть ли какой-то механизм, заставляющий QTableView извлекать больше строк? В идеале я хотел бы показать индикатор выполнения, показывающий долю данных, загруженных из модели. Каждые несколько секунд я хотел бы заставить модель загружать немного больше данных, но я все еще хочу, чтобы пользователь мог взаимодействовать с уже загруженными данными. Таким образом, когда индикатор выполнения заполнен, пользователь может нажать ctrl-a и быть уверенным, что все данные выбраны.


Редактировать: у меня есть еще один мотивирующий вариант использования. Я хочу перейти к определенной строке, но если эта строка не загружена, мой интерфейс ничего не делает.

Как я могу заставить QAbstractItemModel получить больше (или до определенной строки), а затем заставить QTableView показать это?

Если я не реализую fetchMore и canFetchMore, предыдущая функциональность работает, но загрузка таблиц происходит очень медленно. Когда я реализую эти методы, происходит обратное. Отсутствие ответа на эту проблему вызывает проблемы с удобством использования моего интерфейса qt, поэтому я открываю награду за этот вопрос.

Вот метод, который я использую для выбора определенной строки.

def select_row_from_id(view, _id, scroll=False, collapse=True):
    """
        _id is from the iders function (i.e. an ibeis rowid)
        selects the row in that view if it exists
    """
    with ut.Timer('[api_item_view] select_row_from_id(id=%r, scroll=%r, collapse=%r)' %
                  (_id, scroll, collapse)):
        qtindex, row = view.get_row_and_qtindex_from_id(_id)
        if row is not None:
            if isinstance(view, QtWidgets.QTreeView):
                if collapse:
                    view.collapseAll()
                select_model = view.selectionModel()
                select_flag = QtCore.QItemSelectionModel.ClearAndSelect
                #select_flag = QtCore.QItemSelectionModel.Select
                #select_flag = QtCore.QItemSelectionModel.NoUpdate
                with ut.Timer('[api_item_view] selecting name. qtindex=%r' % (qtindex,)):
                    select_model.select(qtindex, select_flag)
                with ut.Timer('[api_item_view] expanding'):
                    view.setExpanded(qtindex, True)
            else:
                # For Table Views
                view.selectRow(row)
            # Scroll to selection
            if scroll:
                with ut.Timer('scrolling'):
                    view.scrollTo(qtindex)
            return row
    return None

Если пользователь вручную прокрутил нужную строку, эта функция работает. Однако, если пользователь не видел определенную строку, эта функция просто прокручивается назад к началу представления.


person Erotemic    schedule 21.07.2016    source источник
comment
У меня нет времени для полного ответа, но таймер одиночного выстрела, связанный со слотом, который обновляет индикатор выполнения и проверяет, доступны ли дополнительные данные, сделает это. Каждый раз, когда фрагмент извлекается, цикл обработки событий снова входит, обрабатывает пользовательские события, а затем повторно вызывает слот. Продолжайте стрелять одиночными выстрелами, 0 интервальных таймеров, пока не будут получены все данные.   -  person anonymous    schedule 19.08.2016
comment
Что должен сделать слот, чтобы проверить, есть ли еще данные? Это та часть, которую я не понимаю. Я не очень беспокоюсь о индикаторе выполнения, но я не смог определить, что вызывать, чтобы заставить его проверить наличие дополнительных данных и обновить их соответствующим образом.   -  person Erotemic    schedule 23.08.2016


Ответы (2)


Вероятно, уже слишком поздно для ответа здесь, но, возможно, это все равно принесет пользу кому-то в будущем.

Ниже можно найти рабочий пример модели списка с методами canFetchMore и fetchMore + представление с парой пользовательских методов:

  1. Метод, пытающийся загрузить больше элементов из модели, если в модели еще что-то не загружено
  2. Метод, способный извлекать определенные строки из модели, если они еще не загружены.

Подкласс QMainWindow в этом примере имеет таймер, который используется для многократного вызова первого из вышеупомянутых методов, каждый раз вызывая загрузку очередной партии элементов из модели в представление. Загрузка элементов пакетами через небольшие промежутки времени позволяет полностью избежать блокировки потока пользовательского интерфейса и иметь возможность редактировать загруженные до сих пор элементы практически без задержки. Пример содержит индикатор выполнения, показывающий часть загруженных элементов.

Подкласс QMainWindow также имеет счетчик, который позволяет выбрать конкретную строку для отображения в представлении. Если соответствующий элемент уже был выбран из модели, представление просто прокручивается до него. В противном случае он сначала извлекает элемент этой строки из модели синхронно, т.е. с блокировкой пользовательского интерфейса.

Вот полный код решения, протестированный с помощью python 3.5.2 и PyQt5:

import sys
from PyQt5 import QtWidgets, QtCore

class DelayedFetchingListModel(QtCore.QAbstractListModel):
    def __init__(self, batch_size=100, max_num_nodes=1000):
        QtCore.QAbstractListModel.__init__(self)
        self.batch_size = batch_size
        self.nodes = []
        for i in range(0, self.batch_size):
            self.nodes.append('node ' + str(i))
        self.max_num_nodes = max(self.batch_size, max_num_nodes)

    def flags(self, index):
        if not index.isValid():
            return QtCore.Qt.ItemIsEnabled
        return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable;

    def rowCount(self, index):
        if index.isValid():
            return 0
        return len(self.nodes)

    def data(self, index, role):
        if not index.isValid():
            return None
        if role != QtCore.Qt.DisplayRole:
            return None
        row = index.row()
        if row < 0 or row >= len(self.nodes):
            return None
        else:
            return self.nodes[row]

    def setData(self, index, value, role):
        if not index.isValid():
            return False
        if role != QtCore.Qt.EditRole:
            return False
        row = index.row()
        if row < 0 or row >= len(self.nodes):
            return False
        self.nodes[row] = value
        self.dataChanged.emit(index, index)
        return True

    def headerData(self, section, orientation, role):
        if section != QtCore.Qt.Horizontal:
            return None
        if section != 0:
            return None
        if role != QtCore.Qt.DisplayRole:
            return None
        return 'node'

    def canFetchMore(self, index):
        if index.isValid():
            return False
        return (len(self.nodes) < self.max_num_nodes)

    def fetchMore(self, index):
        if index.isValid():
            return
        current_len = len(self.nodes)
        target_len = min(current_len + self.batch_size, self.max_num_nodes)
        self.beginInsertRows(index, current_len, target_len - 1)
        for i in range(current_len, target_len):
            self.nodes.append('node ' + str(i))
        self.endInsertRows()

class ListView(QtWidgets.QListView):
    def __init__(self, parent=None):
        QtWidgets.QListView.__init__(self, parent)

    def jumpToRow(self, row):
        model = self.model()
        if model == None:
            return False
        num_rows = model.rowCount()
        while(row >= num_rows):
            res = fetchMoreRows(QtCore.QModelIndex())
            if res == False:
                return False
            num_rows = model.rowCount()
        index = model.index(row, 0, QtCore.QModelIndex())
        self.scrollTo(index, QtCore.QAbstractItemView.PositionAtCenter)
        return True

    def fetchMoreRows(self, index):
        model = self.model()
        if model == None:
            return False
        if not model.canFetchMore(index):
            return False
        model.fetchMore(index)
        return True

class MainForm(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        QtWidgets.QMainWindow.__init__(self, parent)
        # Setup the model
        self.max_num_nodes = 10000
        self.batch_size = 100
        self.model = DelayedFetchingListModel(batch_size=self.batch_size, max_num_nodes=self.max_num_nodes)
        # Setup the view
        self.view = ListView()
        self.view.setModel(self.model)
        # Update the currently selected row in the spinbox
        self.view.selectionModel().currentChanged.connect(self.onCurrentItemChanged)
        # Select the first row in the model
        index = self.model.index(0, 0, QtCore.QModelIndex())
        self.view.selectionModel().clearSelection()
        self.view.selectionModel().select(index, QtCore.QItemSelectionModel.Select)
        # Setup the spinbox
        self.spinBox = QtWidgets.QSpinBox()
        self.spinBox.setMinimum(0)
        self.spinBox.setMaximum(self.max_num_nodes-1)
        self.spinBox.setSingleStep(1)
        self.spinBox.valueChanged.connect(self.onSpinBoxNewValue)
        # Setup the progress bar showing the status of model data loading
        self.progressBar = QtWidgets.QProgressBar()
        self.progressBar.setRange(0, self.max_num_nodes)
        self.progressBar.setValue(0)
        self.progressBar.valueChanged.connect(self.onProgressBarValueChanged)
        # Add status bar but initially hidden, will only show it if there's something to say
        self.statusBar = QtWidgets.QStatusBar()
        self.statusBar.hide()
        # Collect all this stuff into a vertical layout
        self.layout = QtWidgets.QVBoxLayout()
        self.layout.addWidget(self.view)
        self.layout.addWidget(self.spinBox)
        self.layout.addWidget(self.progressBar)
        self.layout.addWidget(self.statusBar)
        self.window = QtWidgets.QWidget()
        self.window.setLayout(self.layout)
        self.setCentralWidget(self.window)
        # Setup timer to fetch more data from the model over small time intervals
        self.timer = QtCore.QBasicTimer()
        self.timerPeriod = 1000
        self.timer.start(self.timerPeriod, self)

    def onCurrentItemChanged(self, current, previous):
        if not current.isValid():
            return
        row = current.row()
        self.spinBox.setValue(row)

    def onSpinBoxNewValue(self, value):
        try:
            value_int = int(value)
        except ValueError:
            return
        num_rows = self.model.rowCount(QtCore.QModelIndex())
        if value_int >= num_rows:
            # There is no such row within the model yet, trying to fetch more
            while(True):
                res = self.view.fetchMoreRows(QtCore.QModelIndex())
                if res == False:
                    # We shouldn't really get here in this example since out
                    # spinbox's range is limited by exactly the number of items
                    # possible to fetch but generally it's a good idea to handle
                    # cases like this, when someone requests more rows than 
                    # the model has
                    self.statusBar.show()
                    self.statusBar.showMessage("Can't jump to row %d, the model has only %d rows" % (value_int, self.model.rowCount(QtCore.QModelIndex())))
                    return
                num_rows = self.model.rowCount(QtCore.QModelIndex())
                if value_int < num_rows:
                    break;
        if num_rows < self.max_num_nodes:
            # If there are still items to fetch more, check if we need to update the progress bar
            if self.progressBar.value() < value_int:
                self.progressBar.setValue(value_int)
        elif num_rows == self.max_num_nodes:
            # All items are loaded, nothing to fetch more -> no need for the progress bar
            self.progressBar.hide()
        # Update the selection accordingly with the new row and scroll to it
        index = self.model.index(value_int, 0, QtCore.QModelIndex())
        selectionModel = self.view.selectionModel()
        selectionModel.clearSelection()
        selectionModel.select(index, QtCore.QItemSelectionModel.Select)
        self.view.scrollTo(index, QtWidgets.QAbstractItemView.PositionAtCenter)
        # Ensure the status bar is hidden now
        self.statusBar.hide()

    def timerEvent(self, event):
        res = self.view.fetchMoreRows(QtCore.QModelIndex())
        if res == False:
            self.timer.stop()
        else:
            self.progressBar.setValue(self.model.rowCount(QtCore.QModelIndex()))
            if not self.timer.isActive():
                self.timer.start(self.timerPeriod, self)

    def onProgressBarValueChanged(self, value):
        if value >= self.max_num_nodes:
            self.progressBar.hide()

def main():
    app = QtWidgets.QApplication(sys.argv)
    form = MainForm()
    form.show()
    app.exec_()

if __name__ == '__main__':
    main()

Еще одна вещь, которую я хотел бы отметить, заключается в том, что в этом примере предполагается, что метод fetchMore будет выполнять свою работу синхронно. Но в более сложных подходах fetchMore на самом деле не обязательно так себя вести. Если ваша модель загружает свои элементы, скажем, из базы данных, то синхронное общение с базой данных в потоке пользовательского интерфейса было бы плохой идеей. Вместо этого реализация fetchMore могла бы запускать асинхронную последовательность связи сигнал/слот с некоторым объектом, обрабатывающим связь с базой данных, происходящую в каком-то фоновом потоке.

person Dmitry    schedule 15.11.2016
comment
Не слишком поздно! Как ни странно, я только что снова начал работать над этим фрагментом кода. Хорошее время. - person Erotemic; 15.11.2016
comment
Вопрос: Почему у вас if index.isValid(): return вместо fetchMore вместо if not index.isValid(): return? То же самое с rowCount и canFetchMore. - person Erotemic; 15.11.2016
comment
Индекс во всех трех методах является родительским. В этом примере представлена ​​модель списка, элементы которой считаются находящимися под невидимым родительским элементом с недопустимым индексом. Представьте модель списка как модель дерева с единственным корнем и одним уровнем глубины. Таким образом, все три метода соглашаются выполнять действия только для элементов с недопустимым индексом невидимого родительского элемента, они не соглашаются извлекать больше элементов или предоставлять количество строк для элементов с действительными индексами, являющимися фактическими элементами списка. - person Dmitry; 15.11.2016
comment
Если у вас есть древовидная модель с произвольным вложением элементов друг в друга, все будет сложнее, и вам придется рассматривать дочерние элементы элементов с допустимыми индексами, а также дочерние элементы корневого элемента с недопустимым индексом. - person Dmitry; 15.11.2016
comment
А, имеет смысл. Итак, в случае, когда представление было TreeView с глубиной › 1, было бы разумно извлекать элементы, когда имеет смысл извлекать элемент, когда родитель действителен. В этом случае вам нужно будет определить некоторые критерии для определения того, является ли index конечным узлом, и выбирать только в том случае, если это не так. Я прав? - person Erotemic; 15.11.2016
comment
Да, вам нужно рассмотреть нелистовые узлы, которым есть что еще извлечь. - person Dmitry; 15.11.2016

класс модели с самостоятельным использованием, основанный на ответе Дмитрия.

class EzQListModel(QAbstractListModel):
    items_changed = Signal()
    an_item_changed = Signal(int)

    def __init__(self, batch_size=100, items_header='Items', parent=None):
        super().__init__(parent)
        self._batch_size = batch_size
        self._current_size = 0
        self._items = []
        self.items_header = items_header
        self.data_getter_mapping = {Qt.DisplayRole: self.get_display_data, Qt.BackgroundRole: self.get_background_data}

    @property
    def items_size(self):
        return len(self._items)

    def update_fetch_more(self):
        if self.canFetchMore():
            self.fetchMore()
        return self

    @contextlib.contextmanager
    def ctx_change_items(self):
        yield
        self.items_changed.emit()

    @contextlib.contextmanager
    def ctx_change_an_item(self, index):
        yield
        self.an_item_changed.emit(index)

    def clear_items(self):
        with self.ctx_change_items():
            self._items.clear()
            self._current_size = 0
        return self

    def append_item(self, x):
        with self.ctx_change_items():
            self._items.append(x)
        return self

    def insert_item(self, index, x):
        with self.ctx_change_items():
            self._items.insert(index, x)
        return self

    def extend_items(self, items):
        with self.ctx_change_items():
            self._items.extend(items)
        return self

    def get_item(self, index):
        return self._items[index]

    def set_item(self, index, value):
        with self.ctx_change_items():
            with self.ctx_change_an_item(index):
                self._items[index] = value
        return self

    def flags(self, index):
        if not index.isValid():
            return Qt.ItemIsEnabled
        return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable

    def rowCount(self, parent=QModelIndex()):
        if parent.isValid():
            return 0
        n = self._current_size
        if n <= self.items_size:
            return n
        else:
            self._current_size = self.items_size
            return self.items_size

    @staticmethod
    def get_none_data(index):
        return None

    def get_display_data(self, index: QModelIndex):
        return self._items[index.row()]

    @staticmethod
    def get_background_data(index: QModelIndex):
        palette = QApplication.palette()
        return palette.alternateBase() if index.row() % 2 else palette.base()

    def data(self, index, role=Qt.DisplayRole):
        if not index.isValid():
            return None
        if self.items_size <= index.row() < 0:
            return None
        return self.data_getter_mapping.get(role, self.get_none_data)(index)

    def setData(self, index, value, role=Qt.EditRole):
        if not index.isValid():
            return False
        if role != Qt.EditRole:
            return False
        row = index.row()
        if self.items_size <= row < 0:
            return False
        self._items[row] = value
        self.dataChanged.emit(index, index)
        # print(self.setData.__name__, row, self._items[row], self.data(index))
        return True

    def headerData(self, section, orientation, role=None):
        if orientation != Qt.Horizontal:
            return None
        if section != 0:
            return None
        if role != Qt.DisplayRole:
            return None
        return self.items_header

    def canFetchMore(self, parent: QModelIndex = QModelIndex()):
        if parent.isValid():
            return False
        return self._current_size < self.items_size

    def fetchMore(self, parent: QModelIndex = QModelIndex()):
        if parent.isValid():
            return
        fcls = FirstCountLastStop().set_first_and_total(self._current_size,
                                                        min(self.items_size - self._current_size, self._batch_size))
        self.beginInsertRows(parent, fcls.first, fcls.last)
        self.endInsertRows()
        self._current_size += fcls.total


class FirstCountLastStop:
    def __init__(self):
        self.first = 0
        self.total = 0
        self.last = 0
        self.stop = 1

    def set_first_and_total(self, first, count):
        self.first = first
        self.total = count
        self.stop = first + count
        self.last = self.stop - 1
        return self
person mo-han    schedule 28.06.2021