Cockatrice/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp
BruebachL 5fbc636f93
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
[Models] Use QCollator for natural, cached sorting (#7633)
* [Models] Use QCollator for natural, cached sorting

QString::localeAwareCompare rebuilds its collation data for every
comparison, and it compares numbers as text. Keep one QCollator per model,
enable numeric mode, and use it for the card name, set and user name sorts.

Add a test that sorts "Forest 1", "Forest 2", "Forest 10" through
DeckListSortFilterProxyModel and checks the natural order.

* [Models] Sort naturally under the C locale too

Numeric mode only takes effect through a real locale: under the C locale,
which headless CI processes run in, QCollator silently falls back to string
order and sorts "Forest 10" before "Forest 2". That made collator_sort_test
fail on every Linux leg while passing locally.

Add makeNaturalSortCollator(), which enables numeric mode and pins English
only when the process locale is the degenerate one, and build the models'
collators through it. Users with a real locale keep their own collation.

* [Models] Fix compile of natural sorting (QStringView mismatch)

* nit: initialize collators in member initializer lists

* nit: namespace makeNaturalSortCollator under NaturalSort

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-25 01:01:04 +02:00

304 lines
9.6 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "card_database_display_model.h"
#include "../natural_sort_collator.h"
#include "card_database_model.h"
#include "libcockatrice/card/card_localization.h"
#include "libcockatrice/filters/filter_string.h"
#include "libcockatrice/filters/filter_tree.h"
#include <QAbstractItemModel>
#include <QChar>
#include <QList>
#include <QMap>
#include <QSharedPointer>
#include <QStringList>
#include <QVariant>
#include <QtGlobal>
#include <qnamespace.h>
#include <string>
#include <utility>
class QObject;
CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent)
: QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr),
collator(NaturalSort::makeNaturalSortCollator())
{
filterTree = nullptr;
setFilterCaseSensitivity(Qt::CaseInsensitive);
setSortCaseSensitivity(Qt::CaseInsensitive);
dirtyTimer.setSingleShot(true);
connect(&dirtyTimer, &QTimer::timeout, this, [this]() {
invalidate();
emit modelDirty();
});
loadedRowCount = 0;
}
void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model)
{
QSortFilterProxyModel::setSourceModel(model);
connect(model, &QAbstractItemModel::rowsInserted, this, [this]() { dirty(); });
connect(model, &QAbstractItemModel::rowsRemoved, this, [this]() { dirty(); });
connect(model, &QAbstractItemModel::modelReset, this, [this]() {
loadedRowCount = 0;
dirty();
});
}
QMap<wchar_t, wchar_t> CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'},
{L'”', L'\"'},
{L'‘', L'\''},
{L'’', L'\''}};
bool CardDatabaseDisplayModel::canFetchMore(const QModelIndex &index) const
{
return loadedRowCount < sourceModel()->rowCount(index);
}
void CardDatabaseDisplayModel::fetchMore(const QModelIndex &index)
{
int remainder = sourceModel()->rowCount(index) - loadedRowCount;
int itemsToFetch = qMin(100, remainder);
if (itemsToFetch == 0) {
return;
}
const auto startIndex = qMin(rowCount(QModelIndex()), loadedRowCount);
beginInsertRows(QModelIndex(), startIndex, startIndex + itemsToFetch - 1);
loadedRowCount += itemsToFetch;
endInsertRows();
}
int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const
{
return QSortFilterProxyModel::rowCount(parent);
}
bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
const QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString();
const QString rightString = sourceModel()->data(right, CardDatabaseModel::SortRole).toString();
if (!cardName.isEmpty() && left.column() == CardDatabaseModel::NameColumn) {
const bool isLeftType = leftString.startsWith(cardName, Qt::CaseInsensitive);
const bool isRightType = rightString.startsWith(cardName, Qt::CaseInsensitive);
// test for an exact match: isLeftType && leftString.size() == cardName.size()
// or an exclusive start match: isLeftType && !isRightType
if (isLeftType && (!isRightType || leftString.size() == cardName.size())) {
return true;
}
// same checks for the right string
if (isRightType && (!isLeftType || rightString.size() == cardName.size())) {
return false;
}
} else if (right.column() == CardDatabaseModel::PTColumn && left.column() == CardDatabaseModel::PTColumn) {
// The comparator runs O(n log n) times while the view is sorted, so
// splitting every power/toughness string into a QStringList allocates
// a lot for data that has exactly one separator. Work on views and
// only split the two halves out when both strings are well formed.
const QStringView leftPT = leftString;
const QStringView rightPT = rightString;
const qsizetype leftSlash = leftPT.indexOf(u'/');
const qsizetype rightSlash = rightPT.indexOf(u'/');
const bool leftIsWellFormed = leftSlash >= 0 && leftPT.indexOf(u'/', leftSlash + 1) < 0;
const bool rightIsWellFormed = rightSlash >= 0 && rightPT.indexOf(u'/', rightSlash + 1) < 0;
if (leftIsWellFormed && rightIsWellFormed) {
const int powerComparison = lessThanNumerically(leftPT.left(leftSlash), rightPT.left(rightSlash));
if (powerComparison != 0) {
return powerComparison < 0;
}
// power equal, check toughness
return lessThanNumerically(leftPT.mid(leftSlash + 1), rightPT.mid(rightSlash + 1)) < 0;
}
}
return collator.compare(leftString, rightString) < 0;
}
int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView right) const
{
if (left == right) {
return 0;
}
bool okLeft = false;
bool okRight = false;
float leftNum = left.toFloat(&okLeft);
float rightNum = right.toFloat(&okRight);
if (okLeft && okRight) {
if (leftNum < rightNum) {
return -1;
} else if (leftNum > rightNum) {
return 1;
} else {
return 0;
}
}
// try and parsing again, for weird ones like "1+*"
QStringView leftAfterNum;
QStringView rightAfterNum;
if (!okLeft) {
int leftNumIndex = 0;
for (; leftNumIndex < left.size(); leftNumIndex++) {
if (!left.at(leftNumIndex).isDigit()) {
break;
}
}
if (leftNumIndex != 0) {
leftNum = left.left(leftNumIndex).toFloat(&okLeft);
leftAfterNum = left.right(leftNumIndex);
}
}
if (!okRight) {
int rightNumIndex = 0;
for (; rightNumIndex < right.size(); rightNumIndex++) {
if (!right.at(rightNumIndex).isDigit()) {
break;
}
}
if (rightNumIndex != 0) {
rightNum = right.left(rightNumIndex).toFloat(&okRight);
rightAfterNum = right.right(rightNumIndex);
}
}
if (okLeft && okRight) {
if (leftNum != rightNum) {
// both parsed as numbers, but different number
if (leftNum < rightNum) {
return -1;
} else {
return 1;
}
} else {
// both parsed, same number, but at least one has something else
// so compare the part after the number - prefer nothing
return collator.compare(leftAfterNum, rightAfterNum);
}
} else if (okLeft) {
return -1;
} else if (okRight) {
return 1;
}
// couldn't parse it, just return String comparison
return collator.compare(left, right);
}
bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex & /*sourceParent*/) const
{
CardInfoPtr info = static_cast<CardDatabaseModel *>(sourceModel())->getCard(sourceRow);
if (((isToken == ShowTrue) && !info->getIsToken()) || ((isToken == ShowFalse) && info->getIsToken())) {
return false;
}
if (filterString != nullptr) {
if (filterTree != nullptr && !filterTree->acceptsCard(info, searchLanguage)) {
return false;
}
return filterString->check(info);
}
return rowMatchesCardName(info);
}
bool CardDatabaseDisplayModel::rowMatchesCardName(CardInfoPtr info) const
{
if (!cardName.isEmpty()) {
const bool matchesEnglish = info->getName().contains(cardName, Qt::CaseInsensitive);
const bool matchesLocalized =
!searchLanguage.isEnglishOnly() &&
info->getLocalizedName(searchLanguage.language).contains(cardName, Qt::CaseInsensitive);
if (!matchesEnglish && !matchesLocalized) {
return false;
}
}
if (!cardNameSet.isEmpty() && !cardNameSet.contains(info->getName())) {
return false;
}
if (filterTree != nullptr) {
return filterTree->acceptsCard(info, searchLanguage);
}
return true;
}
void CardDatabaseDisplayModel::clearFilterAll()
{
#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
beginFilterChange();
#endif
cardName.clear();
cardText.clear();
cardTypes.clear();
cardColors.clear();
if (filterTree != nullptr) {
filterTree->clear();
}
#if (QT_VERSION >= QT_VERSION_CHECK(6, 10, 0))
endFilterChange(QSortFilterProxyModel::Direction::Rows);
#else
invalidateFilter();
#endif
}
void CardDatabaseDisplayModel::setFilterTree(FilterTree *_filterTree)
{
if (this->filterTree != nullptr) {
disconnect(this->filterTree, nullptr, this, nullptr);
}
this->filterTree = _filterTree;
connect(this->filterTree, &FilterTree::changed, this, &CardDatabaseDisplayModel::filterTreeChanged);
invalidate();
}
void CardDatabaseDisplayModel::setStringFilter(const QString &_src)
{
searchText = _src;
delete filterString;
filterString = new FilterString(_src, searchLanguage);
dirty();
}
void CardDatabaseDisplayModel::setSearchLanguage(const CardSearchLanguage &searchLang)
{
if (searchLanguage == searchLang) {
return;
}
searchLanguage = searchLang;
if (filterString != nullptr) {
setStringFilter(searchText);
}
dirty();
}
void CardDatabaseDisplayModel::filterTreeChanged()
{
invalidate();
}
const QString CardDatabaseDisplayModel::sanitizeCardName(const QString &dirtyName, const QMap<wchar_t, wchar_t> &table)
{
std::wstring toReturn = dirtyName.toStdWString();
for (wchar_t &ch : toReturn) {
if (table.contains(ch)) {
ch = table.value(ch);
}
}
return QString::fromStdWString(toReturn);
}