From c34b7857fcc03d2ede0d40063b49a36e1ad7d9dd Mon Sep 17 00:00:00 2001 From: Berthold Stoeger Date: Fri, 6 Mar 2020 12:48:47 +0100 Subject: [PATCH] mobile/divelist: implement model that shows all dives in trip-order MobileSwipeModel is probably not an ideal name, but all I could come up with. One of the main complications here is that our list is reversed with respect to the source model. We should change that one day. Probably by moving the sorting down to the core-model. Since looking up the source row is somewhat expensive, the lookup is cached for a single entry because accesses come in bursts for a single dive. This should be a good compromise and avoids keeping track of a full row-to-dive array. Signed-off-by: Berthold Stoeger Signed-off-by: Dirk Hohndel --- mobile-widgets/qml/DiveDetails.qml | 6 +- qt-models/mobilelistmodel.cpp | 390 ++++++++++++++++++++++++++++- qt-models/mobilelistmodel.h | 57 ++++- subsurface-helper.cpp | 1 + 4 files changed, 449 insertions(+), 5 deletions(-) diff --git a/mobile-widgets/qml/DiveDetails.qml b/mobile-widgets/qml/DiveDetails.qml index 541d1d0c0..86ca5ea63 100644 --- a/mobile-widgets/qml/DiveDetails.qml +++ b/mobile-widgets/qml/DiveDetails.qml @@ -230,7 +230,7 @@ Kirigami.Page { } function showDiveIndex(id) { - currentIndex = diveModel.getIdxForId(id); + currentIndex = swipeModel.getIdxForId(id); diveDetailsListView.positionViewAtIndex(currentIndex, ListView.End); } @@ -307,7 +307,7 @@ Kirigami.Page { ListView { id: diveDetailsListView anchors.fill: parent - model: diveModel + model: swipeModel currentIndex: -1 boundsBehavior: Flickable.StopAtBounds maximumFlickVelocity: parent.width * 5 @@ -335,7 +335,7 @@ Kirigami.Page { } ScrollIndicator.horizontal: ScrollIndicator { } Connections { - target: diveModel + target: swipeModel onCurrentDiveChanged: { currentIndex = index.row diveDetailsListView.positionViewAtIndex(currentIndex, ListView.End) diff --git a/qt-models/mobilelistmodel.cpp b/qt-models/mobilelistmodel.cpp index 3312a2d97..744cda195 100644 --- a/qt-models/mobilelistmodel.cpp +++ b/qt-models/mobilelistmodel.cpp @@ -527,6 +527,388 @@ void MobileListModel::toggle(int row) else expand(row); } + +MobileSwipeModel::MobileSwipeModel(DiveTripModelBase *source) : MobileListModelBase(source) +{ + connect(source, &DiveTripModelBase::modelAboutToBeReset, this, &MobileSwipeModel::beginResetModel); + connect(source, &DiveTripModelBase::modelReset, this, &MobileSwipeModel::doneReset); + connect(source, &DiveTripModelBase::rowsAboutToBeRemoved, this, &MobileSwipeModel::prepareRemove); + connect(source, &DiveTripModelBase::rowsRemoved, this, &MobileSwipeModel::doneRemove); + connect(source, &DiveTripModelBase::rowsAboutToBeInserted, this, &MobileSwipeModel::prepareInsert); + connect(source, &DiveTripModelBase::rowsInserted, this, &MobileSwipeModel::doneInsert); + connect(source, &DiveTripModelBase::rowsAboutToBeMoved, this, &MobileSwipeModel::prepareMove); + connect(source, &DiveTripModelBase::rowsMoved, this, &MobileSwipeModel::doneMove); + connect(source, &DiveTripModelBase::dataChanged, this, &MobileSwipeModel::changed); + + initData(); +} + +// Return the size of a top level item in the source model. Whereby size +// is the number of items it represents in the swipe model: +// A dive has size one, a trip has the size of the number of its items. +// Attention: the given row is expressed in source-coordinates! +int MobileSwipeModel::topLevelRowCountInSource(int sourceRow) const +{ + QModelIndex index = source->index(sourceRow, 0, QModelIndex()); + return source->data(index, DiveTripModelBase::IS_TRIP_ROLE).value() ? + source->rowCount(index) : 1; +} + +void MobileSwipeModel::initData() +{ + rows = 0; + int act = 0; + int topLevelRows = source->rowCount(); + firstElement.resize(topLevelRows); + for (int i = 0; i < topLevelRows; ++i) { + firstElement[i] = act; + // Note: we populate the model in reverse order, because we show the newest dives first. + act += topLevelRowCountInSource(topLevelRows - i - 1); + } + rows = act; + invalidateSourceRowCache(); +} + +void MobileSwipeModel::doneReset() +{ + initData(); + endResetModel(); +} + +void MobileSwipeModel::invalidateSourceRowCache() const +{ + cachedRow = -1; + cacheSourceParent = QModelIndex(); + cacheSourceRow = -1; +} + +void MobileSwipeModel::updateSourceRowCache(int localRow) const +{ + if (firstElement.empty()) + return invalidateSourceRowCache(); + + cachedRow = localRow; + + // Do a binary search for the first top-level item that starts after the given row + auto idx = std::upper_bound(firstElement.begin(), firstElement.end(), localRow); + if (idx == firstElement.begin()) + return invalidateSourceRowCache(); // Huh? localRow was negative? Then index->isValid() should have returned true. + + --idx; + int topLevelRow = idx - firstElement.begin(); + int topLevelRowSource = firstElement.end() - idx - 1; // Reverse direction. + int indexInRow = localRow - *idx; + if (indexInRow == 0) { + // This might be a top-level dive or a one-dive trip. Perhaps we should save which one it is. + if (!source->data(source->index(topLevelRowSource, 0), DiveTripModelBase::IS_TRIP_ROLE).value()) { + cacheSourceParent = QModelIndex(); + cacheSourceRow = topLevelRowSource; + return; + } + } + cacheSourceParent = source->index(topLevelRowSource, 0); + int numElements = elementCountInTopLevel(topLevelRow); + cacheSourceRow = numElements - indexInRow - 1; +} + +QModelIndex MobileSwipeModel::mapToSource(const QModelIndex &index) const +{ + if (!index.isValid()) + return QModelIndex(); + if (index.row() != cachedRow) + updateSourceRowCache(index.row()); + + return cacheSourceRow >= 0 ? source->index(cacheSourceRow, index.column(), cacheSourceParent) : QModelIndex(); +} + +int MobileSwipeModel::mapTopLevelFromSource(int row) const +{ + return firstElement.size() - row - 1; +} + +int MobileSwipeModel::mapTopLevelFromSourceForInsert(int row) const +{ + return firstElement.size() - row; +} + +int MobileSwipeModel::elementCountInTopLevel(int row) const +{ + if (row < 0 || row >= (int)firstElement.size()) + return 0; + if (row + 1 < (int)firstElement.size()) + return firstElement[row + 1] - firstElement[row]; + else + return rows - firstElement[row]; +} + +int MobileSwipeModel::mapRowFromSource(const QModelIndex &parent, int row) const +{ + if (parent.isValid()) { + int topLevelRow = mapTopLevelFromSource(parent.row()); + int count = elementCountInTopLevel(topLevelRow); + return firstElement[topLevelRow] + count - row - 1; // Note: we invert the direction! + } else { + int topLevelRow = mapTopLevelFromSource(row); + return firstElement[topLevelRow]; + } +} + +int MobileSwipeModel::mapRowFromSource(const QModelIndex &idx) const +{ + return mapRowFromSource(idx.parent(), idx.row()); +} + +int MobileSwipeModel::mapRowFromSourceForInsert(const QModelIndex &parent, int row) const +{ + if (parent.isValid()) { + int topLevelRow = mapTopLevelFromSource(parent.row()); + int count = elementCountInTopLevel(topLevelRow); + return firstElement[topLevelRow] + count - row; // Note: we invert the direction! + } else { + if (row == 0) + return rows; // Insert at the end + int topLevelRow = mapTopLevelFromSource(row - 1); + return firstElement[topLevelRow]; // Note: we invert the direction! + } +} + +MobileSwipeModel::IndexRange MobileSwipeModel::mapRangeFromSource(const QModelIndex &parent, int first, int last) const +{ + // Since we invert the direction, the last will be the first. + if (!parent.isValid()) { + int localFirst = mapRowFromSource(QModelIndex(), last); + // Point to the *last* item in the topLevelRange. Yay for Qt's bizzare [first,last] range-semantics. + int localLast = mapRowFromSource(QModelIndex(), first); + int topLevelLast = mapTopLevelFromSource(first); + localLast += elementCountInTopLevel(topLevelLast) - 1; + return { localFirst, localLast }; + } else { + // For items inside trips we can simply translate them, as they cannot contain subitems. + // Remember to reverse the direction, though. + return { mapRowFromSource(parent, last), mapRowFromSource(parent, first) }; + } +} + +// Remove top-level items. Parameters with standard range semantics (pointer to first and past last element). +int MobileSwipeModel::removeTopLevel(int begin, int end) +{ + auto it1 = firstElement.begin() + begin; + auto it2 = firstElement.begin() + end; + int count = 0; // Number of items we have to subtract from rest + for (int row = begin; row < end; ++row) + count += elementCountInTopLevel(row); + firstElement.erase(it1, it2); // Remove items + for (auto act = firstElement.begin() + begin; act != firstElement.end(); ++act) + *act -= count; // Subtract removed items + rows -= count; + return count; +} + +// Add or remove subitems from top-level items +void MobileSwipeModel::updateTopLevel(int row, int delta) +{ + for (int i = row + 1; i < (int)firstElement.size(); ++i) + firstElement[i] += delta; + rows += delta; +} + +// Add items at top-level. The number of subelements of each items is given in the second parameter. +void MobileSwipeModel::addTopLevel(int row, std::vector items) +{ + // We get an array with the number of items per inserted row. + // Transform that to the first element in each row. + int nextEl = row < (int)firstElement.size() ? firstElement[row] : rows; + int count = 0; + for (int &item: items) { + int num = item; + item = nextEl; + nextEl += num; + count += num; + } + + // Now, increase the first element of the items after the inserted range + // by the number of inserted items. + auto it = firstElement.begin() + row; + for (auto act = it; act != firstElement.end(); ++act) + *act += count; + rows += count; + + // Insert the range + firstElement.insert(it, items.begin(), items.end()); +} + +void MobileSwipeModel::prepareRemove(const QModelIndex &parent, int first, int last) +{ + IndexRange range = mapRangeFromSource(parent, first, last); + rangeStack.push_back(range); + if (range.last >= range.first) + beginRemoveRows(QModelIndex(), range.first, range.last); +} + +void MobileSwipeModel::doneRemove(const QModelIndex &parent, int first, int last) +{ + IndexRange range = pop(rangeStack); + if (range.last < range.first) + return; + if (!parent.isValid()) { + // This is a top-level range. This means that we have to remove top-level items. + // Remember to invert the direction. + removeTopLevel(mapTopLevelFromSource(last), mapTopLevelFromSource(first) + 1); + } else { + // This is part of a trip. Only the number of items has to be changed. + updateTopLevel(mapTopLevelFromSource(parent.row()), -(last - first + 1)); + } + invalidateSourceRowCache(); + endRemoveRows(); +} + +void MobileSwipeModel::prepareInsert(const QModelIndex &parent, int first, int last) +{ + // We can not call beginInsertRows here, because before the source model + // has inserted its rows we don't know how many subitems there are! +} + +void MobileSwipeModel::doneInsert(const QModelIndex &parent, int first, int last) +{ + if (!parent.isValid()) { + // This is a top-level range. This means that we have to add top-level items. + + // Create vector of new top-level items + std::vector items; + items.reserve(last - first + 1); + int count = 0; + for (int row = last; row >= first; --row) { + items.push_back(topLevelRowCountInSource(row)); + count += items.back(); + } + + int firstLocal = mapTopLevelFromSourceForInsert(first); + if (firstLocal >= 0) { + beginInsertRows(QModelIndex(), firstLocal, firstLocal + count - 1); + addTopLevel(firstLocal, std::move(items)); + endInsertRows(); + } else { + qWarning("MobileSwipeModel::doneInsert(): invalid source index!\n"); + } + } else { + // This is part of a trip. Only the number of items has to be changed. + int row = mapRowFromSourceForInsert(parent, first); + int count = last - first + 1; + beginInsertRows(QModelIndex(), row, row + count - 1); + updateTopLevel(mapTopLevelFromSource(parent.row()), count); + endInsertRows(); + } + invalidateSourceRowCache(); +} + +void MobileSwipeModel::prepareMove(const QModelIndex &parent, int first, int last, const QModelIndex &dest, int destRow) +{ + IndexRange range = mapRangeFromSource(parent, first, last); + rangeStack.push_back(range); + if (range.last >= range.first) + beginMoveRows(QModelIndex(), range.first, range.last, QModelIndex(), mapRowFromSourceForInsert(dest, destRow)); +} + +void MobileSwipeModel::doneMove(const QModelIndex &parent, int first, int last, const QModelIndex &dest, int destRow) +{ + IndexRange range = pop(rangeStack); + if (range.last < range.first) + return; + + // Moving is annoying. There are four cases to consider, depending whether + // we move in / out of a top-level item! + if (!parent.isValid() && !dest.isValid()) { + // From top-level to top-level + if (destRow < first || destRow > last + 1) { + int beginLocal = mapTopLevelFromSource(last); + int endLocal = mapTopLevelFromSource(first) + 1; + int destLocal = mapTopLevelFromSourceForInsert(destRow); + int count = endLocal - beginLocal; + std::vector items; + items.reserve(count); + for (int row = beginLocal; row < endLocal; ++row) { + items.push_back(row < (int)firstElement.size() - 1 ? firstElement[row + 1] - firstElement[row] + : rows - firstElement[row]); + } + removeTopLevel(mapTopLevelFromSource(last), mapTopLevelFromSource(first) + 1); + + if (destLocal >= beginLocal) + destLocal -= count; + addTopLevel(destLocal, std::move(items)); + } + } else if (!parent.isValid() && dest.isValid()) { + // From top-level to trip + int beginLocal = mapTopLevelFromSource(last); + int endLocal = mapTopLevelFromSource(first) + 1; + int destLocal = mapTopLevelFromSourceForInsert(dest.row()); + int count = endLocal - beginLocal; + int numMoved = removeTopLevel(beginLocal, endLocal); + if (destLocal >= beginLocal) + destLocal -= count; + updateTopLevel(destLocal, numMoved); + } else if (parent.isValid() && !dest.isValid()) { + // From trip to top-level + int fromLocal = mapTopLevelFromSource(parent.row()); + int toLocal = mapTopLevelFromSourceForInsert(dest.row()); + int numMoved = last - first + 1; + std::vector items(numMoved, 1); // This can only be dives -> item count is 1 + updateTopLevel(fromLocal, -numMoved); + addTopLevel(toLocal, std::move(items)); + } else { + // From trip to other trip + int fromLocal = mapTopLevelFromSource(parent.row()); + int toLocal = mapTopLevelFromSourceForInsert(dest.row()); + int numMoved = last - first + 1; + if (fromLocal != toLocal) { + updateTopLevel(fromLocal, -numMoved); + updateTopLevel(toLocal, numMoved); + } + } + invalidateSourceRowCache(); + endMoveRows(); +} + +void MobileSwipeModel::changed(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) +{ + if (!topLeft.isValid() || !bottomRight.isValid()) + return; + + // We don't display trips in the swipe model. If we get changed signals for that - ignore it. + // Subtle: we only check that for single-row changes, because the source model sends changes + // to trips one-by-one. The way we query the source model is ... not nice to read. + if (topLeft.row() == bottomRight.row() && + source->data(topLeft, DiveTripModelBase::IS_TRIP_ROLE).value()) + return; + + int fromSource = mapRowFromSource(bottomRight); + int toSource = mapRowFromSource(topLeft); + QModelIndex fromIdx = createIndex(fromSource, topLeft.column()); + QModelIndex toIdx = createIndex(toSource, bottomRight.column()); + + dataChanged(fromIdx, toIdx, roles); + + // Special case CURRENT_ROLE: if a dive becomes current, we send a signal so that the + // dive-details page can update the current dive. It would be nicer if the frontend could + // hook into the changed-signal, but currently I don't know how this works in QML. + // Note: changes to current must not be combined with other changes, therefore we can + // assume that roles.size() == 1. + if (roles.size() == 1 && roles[0] == DiveTripModelBase::CURRENT_ROLE && + source->data(topLeft, DiveTripModelBase::CURRENT_ROLE).value()) + emit currentDiveChanged(fromIdx); +} + +QVariant MobileSwipeModel::data(const QModelIndex &index, int role) const +{ + return source->data(mapToSource(index), role); +} + +int MobileSwipeModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; // There is no parent + return rows; +} + MobileModels *MobileModels::instance() { static MobileModels self; @@ -534,7 +916,8 @@ MobileModels *MobileModels::instance() } MobileModels::MobileModels() : - lm(&source) + lm(&source), + sm(&source) { reset(); } @@ -544,6 +927,11 @@ MobileListModel *MobileModels::listModel() return &lm; } +MobileSwipeModel *MobileModels::swipeModel() +{ + return &sm; +} + void MobileModels::clear() { source.clear(); diff --git a/qt-models/mobilelistmodel.h b/qt-models/mobilelistmodel.h index f5214d9bd..2ec8cbd8c 100644 --- a/qt-models/mobilelistmodel.h +++ b/qt-models/mobilelistmodel.h @@ -4,7 +4,7 @@ // MobileListModel presents a list of trips and optionally the dives of // one expanded trip. It is used for quick navigation through trips. // -// MobileDiveListModel gives a linearized view of all dives, sorted by +// MobileSwipeModel gives a linearized view of all dives, sorted by // trip. Even if there is temporal overlap of trips, all dives of // a trip are listed in a contiguous block. This model is used for // swiping through dives. @@ -109,18 +109,73 @@ private slots: void changed(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles); }; +class MobileSwipeModel : public MobileListModelBase { + Q_OBJECT +public: + MobileSwipeModel(DiveTripModelBase *source); + static MobileSwipeModel *instance(); + void resetModel(DiveTripModelBase::Layout layout); // Switch between tree and list view +private: + struct IndexRange { + int first, last; + }; + std::vector rangeStack; + std::vector firstElement; // First element of top level item. + int rows; + QVariant data(const QModelIndex &index, int role) const override; + int rowCount(const QModelIndex &parent) const override; + + // Since accesses to data come in bursts, we cache map-to-source lookup. + // Note that this is not thread safe. We suppose that the model is only ever accessed from the UI thread. + mutable int cachedRow = -1; + mutable QModelIndex cacheSourceParent; + mutable int cacheSourceRow = -1; + + // Translate indexes from/to source + int topLevelRowCountInSource(int sourceRow) const; + QModelIndex mapToSource(const QModelIndex &index) const; + int mapTopLevelFromSource(int row) const; + int mapTopLevelFromSourceForInsert(int row) const; + int elementCountInTopLevel(int row) const; + int mapRowFromSource(const QModelIndex &parent, int row) const; + int mapRowFromSource(const QModelIndex &parent) const; + int mapRowFromSourceForInsert(const QModelIndex &parent, int row) const; + IndexRange mapRangeFromSource(const QModelIndex &parent, int first, int last) const; + void invalidateSourceRowCache() const; + void updateSourceRowCache(int row) const; + + // Update elements + void initData(); + int removeTopLevel(int begin, int end); + void addTopLevel(int row, std::vector items); + void updateTopLevel(int row, int delta); +signals: + void currentDiveChanged(QModelIndex index); +private slots: + void doneReset(); + void prepareRemove(const QModelIndex &parent, int first, int last); + void doneRemove(const QModelIndex &parent, int first, int last); + void prepareInsert(const QModelIndex &parent, int first, int last); + void doneInsert(const QModelIndex &parent, int first, int last); + void prepareMove(const QModelIndex &parent, int first, int last, const QModelIndex &dest, int destRow); + void doneMove(const QModelIndex &parent, int first, int last, const QModelIndex &dest, int destRow); + void changed(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles); +}; + // This convenience class provides access to the two mobile models. // Moreover, it provides an interface to the source trip-model. class MobileModels { public: static MobileModels *instance(); MobileListModel *listModel(); + MobileSwipeModel *swipeModel(); void clear(); // Clear all dive data void reset(); // Reset model after having reloaded the core data private: MobileModels(); DiveTripModelTree source; MobileListModel lm; + MobileSwipeModel sm; }; // Helper functions - these are actually defined in DiveObjectHelper.cpp. Why declare them here? diff --git a/subsurface-helper.cpp b/subsurface-helper.cpp index 44676eb74..d76e4b52c 100644 --- a/subsurface-helper.cpp +++ b/subsurface-helper.cpp @@ -105,6 +105,7 @@ void run_ui() QQmlContext *ctxt = engine.rootContext(); ctxt->setContextProperty("gpsModel", gpsSortModel); ctxt->setContextProperty("vendorList", vendorList); + ctxt->setContextProperty("swipeModel", MobileModels::instance()->swipeModel()); ctxt->setContextProperty("diveModel", MobileModels::instance()->listModel()); set_non_bt_addresses();