diff --git a/chessx.pro b/chessx.pro index a571311d..e09cead1 100644 --- a/chessx.pro +++ b/chessx.pro @@ -296,6 +296,7 @@ HEADERS += src/database/board.h \ src/gui/databaselistmodel.h \ src/gui/digitalclock.h \ src/gui/dockwidgetex.h \ + src/gui/duplicatepositionswidget.h \ src/gui/ecolistwidget.h \ src/gui/ecothread.h \ src/gui/engineoptiondialog.h \ @@ -466,6 +467,7 @@ SOURCES += \ src/gui/databaselistmodel.cpp \ src/gui/digitalclock.cpp \ src/gui/dockwidgetex.cpp \ + src/gui/duplicatepositionswidget.cpp \ src/gui/ecolistwidget.cpp \ src/gui/engineoptiondialog.cpp \ src/gui/engineoptionlist.cpp \ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 86b8a7d0..3e3bfbd2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -355,6 +355,8 @@ add_library(gui STATIC gui/digitalclock.h gui/dockwidgetex.cpp gui/dockwidgetex.h + gui/duplicatepositionswidget.cpp + gui/duplicatepositionswidget.h gui/ecolistwidget.cpp gui/ecolistwidget.h gui/ecothread.h diff --git a/src/database/gamex.cpp b/src/database/gamex.cpp index 2550e3fa..898171f5 100644 --- a/src/database/gamex.cpp +++ b/src/database/gamex.cpp @@ -1826,3 +1826,139 @@ int GameX::isBetterOrEqual(const GameX& game) const (m_annotations.count() >= game.m_annotations.count()) && (m_variationStartAnnotations.count() >= game.m_variationStartAnnotations.count())); } + +// Given a game at a certain position, return all the moves that led to that position +// starting with the first move. +static DuplicateMoveList getMoves(GameX const & game) noexcept +{ + DuplicateMoveList ret; + ret.moves.reserve(game.plyCount()); + MoveId currentSpot {game.currentMove()}; + // Save the tip of this move list so that it can be linked in the frontend + ret.lastMove = currentSpot; + // Walk back toward the root node recording the moves on the way + while (currentSpot != NO_MOVE && currentSpot != ROOT_NODE) + { + ret.moves.push_back(game.move(currentSpot)); + currentSpot = game.cursor().prevMove(currentSpot); + } + std::reverse(ret.moves.begin(), ret.moves.end()); + return ret; +} + +// Traverses all positions of all variations of the game recording the position (FEN) and the +// move list that resulted in that position. +// positions is a map from a position to the various sets of moves that resulted in that position +static void getPositions(GameX & game, std::unordered_map & positions) noexcept +{ + if (game.nextMove() == NO_MOVE) + { + return; + } + game.forward(); + DuplicateMoveList moves {getMoves(game)}; + // If two positions are transpositionally equivalent but one of them ended with + // a double-advance of a pawn, they will be considered different because there + // may be an ability to capture en passant (even if there is no pawn that could + // capture en passant). + QString fenForBoard {game.board().toFen(true)}; + // We don't care about the number of moves to reach this position. Id est, + // if the two positions were reachable in a different amount of moves, + // everything else being equal, it doesn't matter, they're still duplicates. + // The location of the space before the halfmove clock + qsizetype const ultimateSpace{fenForBoard.lastIndexOf(' ')}; + if (ultimateSpace < 1) + { + // Unable to find the halfmove clock + return; + } + // The location of the space before the fullmove number + qsizetype const penultimateSpace{fenForBoard.lastIndexOf(' ', ultimateSpace-1)}; + if (penultimateSpace < 1) + { + // Unable to find the fullmove number + return; + } + fenForBoard = fenForBoard.first(penultimateSpace); + // This will create an item in the positions map if it doesn't exist, or add to the + // set of move lists that resulted in that position. + positions[fenForBoard].moveLists.push_back(moves); + // We only get subsequent positions if this position isn't a duplicate. + // If this position is a duplicated position, then all the subsequent positions + // will also be duplicates. We short-circuit that duplicating of duplicates with this + // if statement. + if (positions[fenForBoard].moveLists.size() < 2) + { + getPositions(game, positions); + } + // Next we go back one step in the game to put it back where we found it + game.backward(); + if (positions[fenForBoard].moveLists.size() > 1) + { + // We don't want subsequent duplicated positions, even if they're in variations + return; + } + if (!game.variationCount()) + { + return; + } + for (MoveId const & variation_move : game.variations()) + { + game.enterVariation(variation_move); + getPositions(game, positions); + game.backward(); + } +} + +// Duplicate positions are divided into classes: +// 1. At most one move list that leads to a position continues +// 2. Both move lists that lead to a position continue past the duplicated position +static void addWarnings(GameX & game, std::vector & positions) noexcept +{ + for (DuplicatedPosition & position : positions) + { + unsigned continuations{0}; + for (DuplicateMoveList & moves : position.moveLists) + { + game.moveToStart(); + // Get the game to the end of this move list + for (Move const & move : moves.moves) + { + if (!game.findNextMove(move)) + { + // This is an (fatal?) internal error. + break; + } + } + // The game is at the end of the move list. Is there a continuation? + if (game.cursor().nextMove() != NO_MOVE) + { + ++continuations; + } + } + position.warning = (continuations > 1) ? DuplicatedPosition::BothMove : DuplicatedPosition::None; + } +} + +std::vector GameX::getDuplicatePositions() const noexcept +{ + std::unordered_map positions; + GameX copy {*this}; + copy.moveToStart(); + if (copy.nextMove() == NO_MOVE) + { + return {}; + } + getPositions(copy, positions); + std::vector duplicates; + for (auto it{positions.begin()}; it != positions.end(); ++it) + { + if (it->second.moveLists.size() > 1) + { + duplicates.emplace_back(std::move(it->second)); + duplicates.back().fen = it->first; + } + } + addWarnings(copy, duplicates); + return duplicates; +} diff --git a/src/database/gamex.h b/src/database/gamex.h index 051a4c50..05837ce7 100644 --- a/src/database/gamex.h +++ b/src/database/gamex.h @@ -48,6 +48,28 @@ class SaveRestoreMove; typedef QHash TagMap; typedef QHashIterator TagMapIterator; +class DuplicateMoveList +{ +public: + std::vector moves; + MoveId lastMove; +}; + +class DuplicatedPosition final +{ +public: + enum WarningLevel + { + // At most only one variation has moves after it + None, + // More than one variation has moves after + BothMove, + }; + std::vector moveLists; + WarningLevel warning; + QString fen; +}; + class GameX : public QObject { Q_OBJECT @@ -155,6 +177,9 @@ public : bool editAnnotation(QString annotation, MoveId moveId = CURRENT_MOVE, Position position = AfterMove); /** Append to existing annotations associated with move at node @p moveId */ bool appendAnnotation(QString annotation, MoveId moveId = CURRENT_MOVE, Position position = AfterMove); + /** Finds duplicate positions within a game. + * Returns a set of positions that have more than one move list leading to that position. */ + std::vector getDuplicatePositions() const noexcept; /** Append a square to the existing lists of square annotations, if there is none, create one */ bool appendSquareAnnotation(chessx::Square s, QChar colorCode); diff --git a/src/gui/duplicatepositionswidget.cpp b/src/gui/duplicatepositionswidget.cpp new file mode 100644 index 00000000..e16ffa06 --- /dev/null +++ b/src/gui/duplicatepositionswidget.cpp @@ -0,0 +1,418 @@ +#include "duplicatepositionswidget.h" +#include +#include +#include +#include "settings.h" + +QBrush const darkModeWarningBackground {Qt::darkRed}; +// A light red +QBrush const lightModeWarningBackground {QColor{0xff, 0xb6, 0xc1}}; + +DuplicatePositionItem::DuplicatePositionItem(QString const & datum, DuplicatedPosition::WarningLevel warningLevel, + DuplicatePositionItem * parent) + : warning{warningLevel}, m_itemDatum{datum}, m_parentItem{parent}, m_move{CURRENT_MOVE} +{ + if (m_parentItem) + { + m_parentItem->appendChild(this); + } +} + +DuplicatePositionItem::DuplicatePositionItem(QString const & datum, MoveId move, DuplicatePositionItem * parent) + : warning{DuplicatedPosition::WarningLevel::None}, m_itemDatum{datum}, m_parentItem{parent}, m_move{move} +{ + if (m_parentItem) + { + m_parentItem->appendChild(this); + } +} + +DuplicatePositionItem::~DuplicatePositionItem() +{ + deleteChildren(); +} + +MoveId DuplicatePositionItem::move() const +{ + return m_move; +} + +void DuplicatePositionItem::appendChild(DuplicatePositionItem * item) +{ + m_childItems.append(item); +} + +DuplicatePositionItem * DuplicatePositionItem::child(int row) +{ + if (row < 0 || row >= m_childItems.size()) + { + return nullptr; + } + return m_childItems.at(row); +} + +int DuplicatePositionItem::childCount() const +{ + return m_childItems.count(); +} + +int DuplicatePositionItem::row() const +{ + if (m_parentItem) + { + return m_parentItem->m_childItems.indexOf(const_cast(this)); + } + return 0; +} + +QString DuplicatePositionItem::datum() const +{ + return m_itemDatum; +} + +DuplicatePositionItem * DuplicatePositionItem::parentItem() +{ + return m_parentItem; +} + +void DuplicatePositionItem::deleteChildren() noexcept +{ + qDeleteAll(m_childItems); + m_childItems.clear(); +} + +DuplicatePositionModel::DuplicatePositionModel(QObject * parent) + : QAbstractItemModel(parent) +{ + rootItem = new DuplicatePositionItem("Duplicate Positions", ROOT_NODE); +} + +DuplicatePositionModel::~DuplicatePositionModel() +{ + delete rootItem; +} + +QModelIndex DuplicatePositionModel::index(int row, int column, const QModelIndex &parent) const +{ + if (!hasIndex(row, column, parent)) + { + return {}; + } + DuplicatePositionItem * parentItem {nullptr}; + if (!parent.isValid()) + { + parentItem = rootItem; + } + else + { + parentItem = static_cast(parent.internalPointer()); + } + DuplicatePositionItem * childItem {parentItem->child(row)}; + if (!childItem) + { + return {}; + } + return createIndex(row, column, childItem); +} + +QModelIndex DuplicatePositionModel::parent(const QModelIndex &index) const +{ + if (!index.isValid()) + { + return {}; + } + DuplicatePositionItem * childItem = static_cast(index.internalPointer()); + DuplicatePositionItem * parentItem = childItem->parentItem(); + if (parentItem == rootItem) + { + return {}; + } + return createIndex(parentItem->row(), 0, parentItem); +} + +int DuplicatePositionModel::rowCount(const QModelIndex &parentIndex) const +{ + DuplicatePositionItem * parentItem; + if (parentIndex.column() > 0) + { + return 0; + } + if (!parentIndex.isValid()) + { + parentItem = rootItem; + } + else + { + parentItem = static_cast(parentIndex.internalPointer()); + } + return parentItem->childCount(); +} + +int DuplicatePositionModel::columnCount(const QModelIndex &) const +{ + return 1; +} + +QVariant DuplicatePositionModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.column() != 0) + { + return {}; + } + if (role != Qt::DisplayRole && role != Qt::BackgroundRole) + { + return {}; + } + DuplicatePositionItem * item = static_cast(index.internalPointer()); + if (role == Qt::DisplayRole) + { + return item->datum(); + } + // Return a background setting + if (item->warning == DuplicatedPosition::WarningLevel::None) + { + return {}; + } + if (AppSettings->getValue("/MainWindow/DarkTheme").toBool()) + { + return darkModeWarningBackground; + } + else + { + return lightModeWarningBackground; + } +} + +std::optional DuplicatePositionModel::getLink(const QModelIndex &index) const +{ + if (!index.isValid() || index.column() != 0) + { + return {}; + } + DuplicatePositionItem * item {static_cast(index.internalPointer())}; + return item->move(); +} + +Qt::ItemFlags DuplicatePositionModel::flags(const QModelIndex &index) const +{ + if (!index.isValid()) + { + return Qt::NoItemFlags; + } + return QAbstractItemModel::flags(index); +} + +QVariant DuplicatePositionModel::headerData(int /*section*/, Qt::Orientation orientation, int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) + { + return {}; + } + return rootItem->datum(); +} + +void DuplicatePositionModel::setData(std::vector const & duplicates, GameX const & rawGame) noexcept +{ + beginResetModel(); + if (!rootItem) + { + // A terrible internal error + return; + } + rootItem->deleteChildren(); + GameX game {rawGame}; + QVector parents; + for (DuplicatedPosition const & dupe : duplicates) + { + // This item repsents one position that has duplicates + DuplicatePositionItem * duplicatePosition{new DuplicatePositionItem{dupe.fen, dupe.warning, rootItem}}; + for (DuplicateMoveList const & ml : dupe.moveLists) + { + game.moveToStart(); + QString list; + for (unsigned i{0}; i < ml.moves.size(); ++i) + { + if (!game.findNextMove(ml.moves[i])) + { + list.append("Could not find moves for line"); + break; + } + list.append(game.moveToSan(GameX::MoveStringFlags::MoveOnly, GameX::NextPreviousMove::PreviousMove)); + if (i+1 < ml.moves.size()) + { + list.append(", "); + } + } + new DuplicatePositionItem{list, ml.lastMove, duplicatePosition}; + } + } + endResetModel(); +} + +DuplicatePositionsWidget::DuplicatePositionsWidget(QWidget* parent) + : QWidget{parent} + , m_requestFindDuplicates{nullptr} + , m_expandAll{nullptr} + , m_treeView{nullptr} + , m_treeModel{nullptr} +{ + setObjectName("duplicate positions widget"); + m_requestFindDuplicates = new QPushButton{"Find Duplicate Positions"}; + m_requestFindDuplicates->setObjectName("request duplicates"); + connect(m_requestFindDuplicates, &QPushButton::clicked, this, &DuplicatePositionsWidget::findDuplicatesRequested); + m_expandAll = new QPushButton{"Expand All"}; + m_expandAll->setObjectName("duplicates expand all"); + connect(m_expandAll, &QPushButton::clicked, this, &DuplicatePositionsWidget::toggleExpandAll); + m_treeView = new QTreeView; + m_treeView->setObjectName("duplicate positions tree view"); + m_treeModel = new DuplicatePositionModel; + m_treeView->setHeaderHidden(true); + m_treeView->setModel(m_treeModel); + connect(m_treeView->selectionModel(), &QItemSelectionModel::selectionChanged, this, &DuplicatePositionsWidget::selectionChanged); + connect(m_treeView, &QTreeView::expanded, this, &DuplicatePositionsWidget::treeViewExpanded); + connect(m_treeView, &QTreeView::collapsed, this, &DuplicatePositionsWidget::treeViewExpanded); + QGridLayout * layout {new QGridLayout{this}}; + m_requestFindDuplicates->setDefault(true); + layout->addWidget(m_requestFindDuplicates, /*row*/0, 0); + layout->addWidget(m_expandAll, 0, 1); + layout->addWidget(m_treeView, 1, 0, /*rowSpan*/1, /*columnSpan*/2); +} + +DuplicatePositionsWidget::~DuplicatePositionsWidget() noexcept +{ + delete m_treeModel; +} + +void DuplicatePositionsWidget::findDuplicatesRequested() +{ + if (m_treeModel) + { + m_treeModel->setData(game.getDuplicatePositions(), game); + } +} + +void DuplicatePositionsWidget::toggleExpandAll() +{ + if (!m_expandAll || !m_treeView) + { + return; + } + if (m_expandAll->text().contains("Expand")) + { + m_treeView->expandAll(); + m_expandAll->setText("Collapse All"); + } + else + { + m_treeView->collapseAll(); + m_expandAll->setText("Expand All"); + } +} + +void DuplicatePositionsWidget::gameChanged(GameX const & game) +{ + // Save a copy of the game + this->game = game; + if (!m_treeModel) + { + return; + } + // Clear the duplicate positions because they haven't been calculated for this game yet + m_treeModel->setData({}, game); +} + +void DuplicatePositionsWidget::selectionChanged(QItemSelection const & selected, QItemSelection const & /*deselected*/) +{ + if (!m_treeModel) + { + return; + } + QModelIndexList indexes {selected.indexes()}; + for (QModelIndex const & index : indexes) + { + std::optional maybeMove {m_treeModel->getLink(index)}; + if (!maybeMove) + { + continue; + } + MoveId move {*maybeMove}; + if (move < 1) + { + // Next move, previous move, etc. don't have any meaning here + continue; + } + linkClicked("move:" + QString::number(move)); + break; + } +} + +// Returns the depth of the given index +// Pass index by copy so we can change it +static unsigned getDepth(QModelIndex index) noexcept +{ + unsigned depth{0}; + while (index.isValid()) + { + ++depth; + index = index.parent(); + } + return depth; +} + +// Returns the next index in the model that has the same depth as the given index +static QModelIndex getNextSibling(QModelIndex const & start, QAbstractItemModel const * itemModel) +{ + int currentRow = start.row(); + unsigned const startDepth = getDepth(start); + QModelIndex currentIndex {start}; + while (true) + { + ++currentRow; + currentIndex = itemModel->index(currentRow, 0); + if (!currentIndex.isValid()) + { + // We've gone through all the rows of the model, return an invalid index + return currentIndex; + } + unsigned const currentDepth{getDepth(currentIndex)}; + if (currentDepth != startDepth) + { + // This index is perhaps a child index, not a sibling + continue; + } + return currentIndex; + } +} + +// If the tree view has all its items expanded, change the button text to +// "Collapse All". +void DuplicatePositionsWidget::treeViewExpanded() +{ + if (!m_treeModel || !m_treeModel->rowCount() || !m_treeView) + { + return; + } + QModelIndex currentItem = m_treeModel->index(0,0); + bool allExpanded = true; + bool allCollapsed = true; + while (currentItem.isValid()) + { + if (m_treeView->isExpanded(currentItem)) + { + allCollapsed = false; + } + else + { + allExpanded = false; + } + currentItem = getNextSibling(currentItem, m_treeModel); + } + if (allExpanded) + { + m_expandAll->setText("Collapse All"); + } + else if(allCollapsed) + { + m_expandAll->setText("Expand All"); + } +} diff --git a/src/gui/duplicatepositionswidget.h b/src/gui/duplicatepositionswidget.h new file mode 100644 index 00000000..99930547 --- /dev/null +++ b/src/gui/duplicatepositionswidget.h @@ -0,0 +1,90 @@ +#ifndef DUPLICATEPOSITIONSWIDGET_H +#define DUPLICATEPOSITIONSWIDGET_H + +#include +#include "gamex.h" + +class QPushButton; +class QTreeView; + +// Represents either a duplicated position or its child node: a +// set of moves that led to a duplicated position +class DuplicatePositionItem +{ +public: + DuplicatePositionItem(QString const &, MoveId = CURRENT_MOVE, DuplicatePositionItem * parent = nullptr); + DuplicatePositionItem(QString const &, DuplicatedPosition::WarningLevel = DuplicatedPosition::WarningLevel::None, + DuplicatePositionItem * parent = nullptr); + ~DuplicatePositionItem(); + + void appendChild(DuplicatePositionItem *child); + DuplicatePositionItem *child(int row); + int childCount() const; + QString datum() const; + MoveId move() const; + int row() const; + DuplicatePositionItem *parentItem(); + DuplicatedPosition::WarningLevel const warning; + void deleteChildren() noexcept; + +private: + QVector m_childItems; + QString m_itemDatum; + DuplicatePositionItem *m_parentItem; + // Used to link to the list of moves that led to a duplicate position + MoveId m_move; +}; + +class DuplicatePositionModel : public QAbstractItemModel +{ + Q_OBJECT + +public: + explicit DuplicatePositionModel(QObject *parent = nullptr); + ~DuplicatePositionModel(); + + // A GameX is necessary to be able to convert to standard algebraic notation. + void setData(std::vector const &, GameX const &)noexcept; + + QVariant data(QModelIndex const &index, int role) const override; + Qt::ItemFlags flags(QModelIndex const &index) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + QModelIndex index(int row, int column, QModelIndex const &parent = {}) const override; + QModelIndex parent(QModelIndex const &index) const override; + int rowCount(QModelIndex const &parent = {}) const override; + int columnCount(QModelIndex const &parent = {}) const override; + std::optional getLink(QModelIndex const &) const; + +private: + DuplicatePositionItem *rootItem; +}; + +class DuplicatePositionsWidget final : public QWidget +{ + Q_OBJECT + +public: + DuplicatePositionsWidget(QWidget* parent = nullptr); + ~DuplicatePositionsWidget() noexcept; + + void gameChanged(GameX const &); + +signals: + void linkClicked(QString const &); + +private slots: + void findDuplicatesRequested(); + void toggleExpandAll(); + void treeViewExpanded(); + +private: + QPushButton * m_requestFindDuplicates; + QPushButton * m_expandAll; + QTreeView * m_treeView; + DuplicatePositionModel * m_treeModel; + GameX game; + + void selectionChanged(QItemSelection const &, QItemSelection const &); +}; + +#endif // DUPLICATEPOSITIONSWIDGET_H diff --git a/src/gui/mainwindow.cpp b/src/gui/mainwindow.cpp index 5416641e..b55c2fef 100644 --- a/src/gui/mainwindow.cpp +++ b/src/gui/mainwindow.cpp @@ -21,6 +21,7 @@ #include "databaselistmodel.h" #include "dockwidgetex.h" #include "downloadmanager.h" +#include "duplicatepositionswidget.h" #include "ecolistwidget.h" #include "ecothread.h" #include "eventlistwidget.h" @@ -93,6 +94,7 @@ MainWindow::MainWindow() : QMainWindow(), m_tabDragIndex(-1), m_pDragTabBar(nullptr), m_gameWindow(nullptr), + m_duplicatePositionsWidget(nullptr), m_gameToolBar(0), m_operationFlag(0), m_currentFrom(InvalidSquare), @@ -213,6 +215,12 @@ MainWindow::MainWindow() : QMainWindow(), addDockWidget(Qt::RightDockWidgetArea, gameTextDock); connect(m_gameWindow, SIGNAL(linkActivated(QString)), this, SLOT(slotGameViewLink(QString))); + DockWidgetEx* duplicatePositionsDock = new DockWidgetEx("Duplicate Game Positions", this); + duplicatePositionsDock->setObjectName("Duplicate Positions Dock"); + m_duplicatePositionsWidget = new DuplicatePositionsWidget{duplicatePositionsDock}; + duplicatePositionsDock->setWidget(m_duplicatePositionsWidget); + connect(m_duplicatePositionsWidget, &DuplicatePositionsWidget::linkClicked, this, &MainWindow::slotGameViewLink); + m_menuView->addAction(gameTextDock->toggleViewAction()); gameTextDock->toggleViewAction()->setShortcut(Qt::CTRL | Qt::Key_E); diff --git a/src/gui/mainwindow.h b/src/gui/mainwindow.h index cf809138..9c82c40b 100644 --- a/src/gui/mainwindow.h +++ b/src/gui/mainwindow.h @@ -49,6 +49,7 @@ class GameList; class GameNotationWidget; class GameToolBar; class GameWindow; +class DuplicatePositionsWidget; class HistoryLabel; class OpeningTreeWidget; class PlayerListWidget; @@ -703,6 +704,7 @@ private slots: QLabel* m_sliderText; QPointer m_comboEngine; GameWindow* m_gameWindow; + DuplicatePositionsWidget* m_duplicatePositionsWidget; GameToolBar* m_gameToolBar; QTabWidget* m_tabWidget; AnnotationWidget* annotationWidget; diff --git a/src/gui/mainwindowactions.cpp b/src/gui/mainwindowactions.cpp index 4da9e781..4f2355cc 100644 --- a/src/gui/mainwindowactions.cpp +++ b/src/gui/mainwindowactions.cpp @@ -26,6 +26,7 @@ #include "databasetagdialog.h" #include "dlgsavebook.h" #include "downloadmanager.h" +#include "duplicatepositionswidget.h" #include "duplicatesearch.h" #include "ecolistwidget.h" #include "editaction.h" @@ -1914,6 +1915,10 @@ void MainWindow::slotGameChanged(bool /*bModified*/) UpdateGameText(); UpdateGameTitle(); moveChanged(); + if (m_duplicatePositionsWidget) + { + m_duplicatePositionsWidget->gameChanged(game()); + } } void MainWindow::slotGameViewLinkUrl(const QUrl& url)