diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b7eb179e6..fa067de00 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -252,6 +252,10 @@ set(mmapper_SRCS group/ColorGenerator.h group/GroupManagerApi.cpp group/GroupManagerApi.h + group/tokenmanager.cpp + group/tokenmanager.h + group/tokenpickerdialog.cpp + group/tokenpickerdialog.h group/GroupModel.cpp group/GroupModel.h group/groupwidget.cpp diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 89dc485a4..13be7a67c 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -802,6 +802,24 @@ void Configuration::GroupManagerSettings::read(const QSettings &conf) npcColorOverride = conf.value(KEY_GROUP_NPC_COLOR_OVERRIDE, false).toBool(); npcHide = conf.value(KEY_GROUP_NPC_HIDE, false).toBool(); npcSortBottom = conf.value(KEY_GROUP_NPC_SORT_BOTTOM, false).toBool(); + + // TODO: Replace string keys with KEY_GROUP_* constants for consistency + showTokens = conf.value("group/showTokens", false).toBool(); + showMapTokens = conf.value("group/showMapTokens", false).toBool(); + tokenIconSize = conf.value("group/tokenIconSize", 32).toInt(); + + tokenOverrides.clear(); + const QVariantMap overridesMap = conf.value("group/tokenOverrides").toMap(); + for (auto it = overridesMap.constBegin(); it != overridesMap.constEnd(); ++it) { + tokenOverrides.insert(it.key(), it.value().toString()); + } + + bearTokenOverrides.clear(); + const QVariantMap bearOverridesMap = conf.value("group/bearTokenOverrides").toMap(); + for (auto it = bearOverridesMap.constBegin(); it != bearOverridesMap.constEnd(); ++it) { + bearTokenOverrides.insert(it.key(), it.value().toString()); + } + defaultBearToken = conf.value("group/defaultBearToken").toString(); } void Configuration::MumeClockSettings::read(const QSettings &conf) @@ -995,6 +1013,24 @@ void Configuration::GroupManagerSettings::write(QSettings &conf) const conf.setValue(KEY_GROUP_NPC_COLOR_OVERRIDE, npcColorOverride); conf.setValue(KEY_GROUP_NPC_HIDE, npcHide); conf.setValue(KEY_GROUP_NPC_SORT_BOTTOM, npcSortBottom); + + // TODO: Replace string keys with KEY_GROUP_* constants for consistency + conf.setValue("group/showTokens", showTokens); + conf.setValue("group/showMapTokens", showMapTokens); + conf.setValue("group/tokenIconSize", tokenIconSize); + + QVariantMap overridesMap; + for (auto it = tokenOverrides.constBegin(); it != tokenOverrides.constEnd(); ++it) { + overridesMap.insert(it.key(), it.value()); + } + conf.setValue("group/tokenOverrides", overridesMap); + + QVariantMap bearOverridesMap; + for (auto it = bearTokenOverrides.constBegin(); it != bearTokenOverrides.constEnd(); ++it) { + bearOverridesMap.insert(it.key(), it.value()); + } + conf.setValue("group/bearTokenOverrides", bearOverridesMap); + conf.setValue("group/defaultBearToken", defaultBearToken); } void Configuration::MumeClockSettings::write(QSettings &conf) const diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index 64223a8b0..f7a5365de 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -320,6 +320,12 @@ class NODISCARD Configuration final bool npcColorOverride = false; bool npcSortBottom = false; bool npcHide = false; + bool showTokens = false; + bool showMapTokens = false; + int tokenIconSize = 32; + QMap tokenOverrides; + QMap bearTokenOverrides; + QString defaultBearToken; private: SUBGROUP(); diff --git a/src/display/Characters.cpp b/src/display/Characters.cpp index 5ec665e4b..7d273ca39 100644 --- a/src/display/Characters.cpp +++ b/src/display/Characters.cpp @@ -6,6 +6,7 @@ #include "../configuration/configuration.h" #include "../group/CGroupChar.h" #include "../group/mmapper2group.h" +#include "../group/tokenmanager.h" #include "../map/roomid.h" #include "../mapdata/mapdata.h" #include "../mapdata/roomselection.h" @@ -25,6 +26,7 @@ #include #include +#include #include static constexpr float CHAR_ARROW_LINE_WIDTH = 2.f; @@ -49,7 +51,8 @@ bool CharacterBatch::isVisible(const Coordinate c, float margin) const return m_mapScreen.isRoomVisible(c, margin); } -void CharacterBatch::drawCharacter(const Coordinate c, const Color color, bool fill) +void CharacterBatch::drawCharacter( + const Coordinate c, const Color color, bool fill, const QString &dispName, const bool bearForm) { const Configuration::CanvasSettings &settings = getConfig().canvas; @@ -100,7 +103,7 @@ void CharacterBatch::drawCharacter(const Coordinate c, const Color color, bool f } const bool beacon = visible && !differentLayer && wantBeacons; - gl.drawBox(c, fill, beacon, isFar); + gl.drawBox(c, fill, beacon, isFar, dispName, bearForm); } void CharacterBatch::CharFakeGL::drawPathSegment(const glm::vec3 p1, @@ -235,7 +238,9 @@ void CharacterBatch::CharFakeGL::drawQuadCommon(const glm::vec2 in_a, void CharacterBatch::CharFakeGL::drawBox(const Coordinate coord, bool fill, bool beacon, - const bool isFar) + const bool isFar, + const QString &dispName, + const bool bearForm) { const bool dontFillRotatedQuads = true; const bool shrinkRotatedQuads = false; // REVISIT: make this a user option? @@ -300,6 +305,38 @@ void CharacterBatch::CharFakeGL::drawBox(const Coordinate coord, } } + if (!dispName.isEmpty() && getConfig().groupManager.showMapTokens && numAlreadyInRoom == 0) { + const Color tokenColor{1.f, 1.f, 1.f, 1.f}; + const auto &mtx = m_stack.top().modelView; + + auto pushVert = [this, &tokenColor, &mtx](const glm::vec2 &roomPos, const glm::vec2 &uv) { + const auto tmp = mtx * glm::vec4(roomPos, 0.f, 1.f); + m_charTokenQuads.emplace_back(tokenColor, glm::vec3{uv, 0.f}, glm::vec3{tmp / tmp.w}); + }; + + // Scale the room quad around its center to 80% so the character square remains visible. + static constexpr float kTokenScale = 0.80f; + const glm::vec2 center = 0.5f * (a + c); + const auto scaleAround = [&](const glm::vec2 &p) { + return center + (p - center) * kTokenScale; + }; + + const glm::vec2 sa = scaleAround(a); + const glm::vec2 sb = scaleAround(b); + const glm::vec2 sc = scaleAround(c); + const glm::vec2 sd = scaleAround(d); + + // Keep full UVs so the whole texture shows on the smaller quad. + pushVert(sa, {0.f, 0.f}); + pushVert(sb, {1.f, 0.f}); + pushVert(sc, {1.f, 1.f}); + pushVert(sd, {0.f, 1.f}); + + // Resolve the override once in TokenManager, using the display name as the key. + m_charTokenKeys.emplace_back(dispName); + m_charTokenBearForms.emplace_back(bearForm); + } + glPopMatrix(); } @@ -324,6 +361,7 @@ void CharacterBatch::CharFakeGL::drawArrow(const bool fill, const bool beacon) void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanvasTextures &textures) { + tokenManager().processPendingTextureChanges(gl); const auto blended_noDepth = GLRenderState().withDepthFunction(std::nullopt).withBlend(BlendModeEnum::TRANSPARENCY); @@ -343,6 +381,50 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva gl.renderColoredTris(m_charTris, blended_noDepth); } + if (!m_charTokenQuads.empty() && !m_charTokenKeys.empty()) { + size_t base = 0; + + for (size_t tokenIndex = 0; tokenIndex < m_charTokenKeys.size(); ++tokenIndex) { + if (base + 3 >= m_charTokenQuads.size()) + break; + + const QString &key = m_charTokenKeys[tokenIndex]; + const bool bearForm = m_charTokenBearForms[tokenIndex]; + MMTextureId id = tokenManager().textureIdFor(key, bearForm); + + if (id == INVALID_MM_TEXTURE_ID) { + if (!QOpenGLContext::currentContext()) { + base += 4; + continue; + } + + const QPixmap px = tokenManager().getToken(key, bearForm); + + if (!px.isNull()) { + id = tokenManager().uploadNow(key, bearForm, px); + } + } + + if (id == INVALID_MM_TEXTURE_ID) { + base += 4; + continue; + } + + SharedMMTexture tex = tokenManager().textureById(id); + if (!tex) { + base += 4; + continue; + } + + gl.setTextureLookup(id, tex); + + gl.renderColoredTexturedQuads(View{m_charTokenQuads.data() + base, 4}, + blended_noDepth.withTexture0(id)); + + base += 4; + } + } + if (!m_charLines.empty()) { gl.renderColoredLines(m_charLines, blended_noDepth.withLineParams(LineParams{CHAR_ARROW_LINE_WIDTH})); @@ -357,6 +439,10 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva gl.renderFont3d(textures.char_arrows, m_screenSpaceArrows); m_screenSpaceArrows.clear(); } + + m_charTokenQuads.clear(); + m_charTokenKeys.clear(); + m_charTokenBearForms.clear(); } void CharacterBatch::CharFakeGL::reallyDrawPaths(OpenGL &gl) @@ -471,8 +557,16 @@ void MapCanvas::paintCharacters() CharacterBatch characterBatch{m_mapScreen, getCurrentLayer(), getTotalScaleFactor()}; + const CGroupChar *playerChar = nullptr; + for (const auto &pCharacter : m_groupManager.selectAll()) { + if (pCharacter->isYou()) { + playerChar = pCharacter.get(); + break; + } + } + // IIFE to abuse return to avoid duplicate else branches - std::invoke([this, &characterBatch]() -> void { + std::invoke([this, &characterBatch, playerChar]() -> void { if (const std::optional opt_pos = m_data.getCurrentRoomId()) { const auto &id = opt_pos.value(); if (const auto room = m_data.findRoomHandle(id)) { @@ -484,8 +578,11 @@ void MapCanvas::paintCharacters() // paint char current position const Color color{getConfig().groupManager.color}; - characterBatch.drawCharacter(pos, color); - + characterBatch.drawCharacter(pos, + color, + true, + playerChar ? playerChar->getDisplayName() : QString(), + playerChar && playerChar->isBearForm()); // paint prespam const auto prespam = m_data.getPath(id, m_prespammedPath.getQueue()); characterBatch.drawPreSpammedPath(pos, prespam, color); @@ -510,6 +607,17 @@ void MapCanvas::drawGroupCharacters(CharacterBatch &batch, ServerRoomId yourServ RoomIdSet drawnRoomIds; const Map &map = m_data.getCurrentMap(); + + RoomId playerRoomId = INVALID_ROOMID; + for (const auto &p : m_groupManager.selectAll()) { + if (p->isYou()) { + if (const auto r = map.findRoomHandle(p->getServerId())) { + playerRoomId = r.getId(); + } + break; + } + } + for (const auto &pCharacter : m_groupManager.selectAll()) { // Omit player so that they know group members are below them if (pCharacter->isYou()) { @@ -538,7 +646,11 @@ void MapCanvas::drawGroupCharacters(CharacterBatch &batch, ServerRoomId yourServ const auto color = Color{character.getColor()}; const bool fill = !drawnRoomIds.contains(id); - batch.drawCharacter(pos, color, fill); + const bool showToken = (id != playerRoomId); + // Use the player's token for their room; don't stack group-member tokens there. + const QString tokenKey = showToken ? character.getDisplayName() : QString(); + + batch.drawCharacter(pos, color, fill, tokenKey); if (srvId != INVALID_SERVER_ROOMID && srvId != yourServerId) { QString name = character.getLabel().isEmpty() ? character.getName().toQString() diff --git a/src/display/Characters.h b/src/display/Characters.h index e8c1749f8..8db7fe579 100644 --- a/src/display/Characters.h +++ b/src/display/Characters.h @@ -19,6 +19,7 @@ #include #include +#include class MapScreen; class OpenGL; @@ -102,6 +103,9 @@ class NODISCARD CharacterBatch final MatrixStack m_stack; std::vector m_charTris; std::vector m_charBeaconQuads; + std::vector m_charTokenQuads; + std::vector m_charTokenKeys; + std::vector m_charTokenBearForms; std::vector m_charLines; std::vector m_charRoomQuads; std::vector m_pathPoints; @@ -152,7 +156,12 @@ class NODISCARD CharacterBatch final m = glm::translate(m, v); } void drawArrow(bool fill, bool beacon); - void drawBox(Coordinate coord, bool fill, bool beacon, bool isFar); + void drawBox(Coordinate coord, + bool fill, + bool beacon, + bool isFar, + const QString &dispName, + bool bearForm); void addScreenSpaceArrow(glm::vec3 pos, float degrees, Color color, bool fill); void addName(Coordinate c, const std::string &name, Color color, const MapScreen &mapScreen); void drawPathSegment(glm::vec3 p1, glm::vec3 p2, Color color); @@ -194,7 +203,11 @@ class NODISCARD CharacterBatch final NODISCARD bool isVisible(Coordinate c, float margin) const; public: - void drawCharacter(Coordinate coordinate, Color color, bool fill = true); + void drawCharacter(Coordinate coordinate, + Color color, + bool fill = true, + const QString &dispName = QString(), + bool bearForm = false); void drawName(const Coordinate c, const std::string &name, const Color color) { diff --git a/src/display/mapcanvas_gl.cpp b/src/display/mapcanvas_gl.cpp index c352dac6a..8c86ad4e7 100644 --- a/src/display/mapcanvas_gl.cpp +++ b/src/display/mapcanvas_gl.cpp @@ -11,6 +11,7 @@ #include "../global/logging.h" #include "../global/progresscounter.h" #include "../global/utils.h" +#include "../group/tokenmanager.h" #include "../map/coordinate.h" #include "../mapdata/mapdata.h" #include "../opengl/Font.h" @@ -131,6 +132,7 @@ void MapCanvas::hostCleanupGL() // and it also owns the lifetime of some OpenGL objects (e.g. VBOs). m_batches.resetExistingMeshesAndIgnorePendingRemesh(); m_weather.cleanup(); + TokenManager::cleanupOpenGLTexturesIfCreated(getOpenGL()); m_textures.destroyAll(); getGLFont().cleanup(); getOpenGL().cleanup(); diff --git a/src/group/CGroupChar.cpp b/src/group/CGroupChar.cpp index d8f7f239f..7af6df4a7 100644 --- a/src/group/CGroupChar.cpp +++ b/src/group/CGroupChar.cpp @@ -326,3 +326,8 @@ bool CGroupChar::setScore(const QString &textHP, const QString &textMana, const #undef X_SCORE return updated; } + +QString CGroupChar::getDisplayName() const +{ + return getName().toQString(); +} diff --git a/src/group/CGroupChar.h b/src/group/CGroupChar.h index efe01d462..22f12762e 100644 --- a/src/group/CGroupChar.h +++ b/src/group/CGroupChar.h @@ -83,6 +83,7 @@ class NODISCARD CGroupChar final : public std::enable_shared_from_this(m_count) * m_height; } + NODISCARD int getWidthForHeight(int height) const { return static_cast(m_count) * height; } }; Q_DECLARE_METATYPE(GroupStateData) #define XFOREACH_COLUMNTYPE(X) \ + X(CHARACTER_TOKEN, character_token, CharacterToken, "Token") \ X(NAME, name, Name, "Name") \ X(HP, hp, Hp, "HP") \ X(MANA, mana, Mana, "Mana") \ diff --git a/src/group/groupwidget.cpp b/src/group/groupwidget.cpp index b502f4d59..ae27ec7af 100644 --- a/src/group/groupwidget.cpp +++ b/src/group/groupwidget.cpp @@ -12,6 +12,8 @@ #include "CGroupChar.h" #include "enums.h" #include "mmapper2group.h" +#include "tokenmanager.h" +#include "tokenpickerdialog.h" #include #include @@ -23,7 +25,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -143,6 +147,21 @@ void GroupDelegate::paint(QPainter *const pPainter, const QRect rect = option.rect; const QColor charColor = character->getColor(); + if (column == ColumnTypeEnum::CHARACTER_TOKEN) { + painter.fillRect(rect, charColor); + if (option.state & QStyle::State_Selected) { + QColor selectColor = option.palette.color(QPalette::Highlight); + selectColor.setAlpha(60); + painter.fillRect(rect, selectColor); + } + const QPixmap token = tokenManager().getToken(character->getDisplayName(), + character->isBearForm()); + if (!token.isNull()) { + QIcon(token).paint(&painter, rect, Qt::AlignCenter); + } + return; + } + // Layer 0: Background painter.fillRect(rect, charColor); @@ -155,7 +174,12 @@ void GroupDelegate::paint(QPainter *const pPainter, if (index.data().canConvert()) { GroupStateData stateData = qvariant_cast(index.data()); - stateData.paint(pPainter, option.rect); + const int contentWidth = stateData.getWidthForHeight(rect.height()); + const QRect centeredRect(rect.x() + (rect.width() - contentWidth) / 2, + rect.y(), + contentWidth, + rect.height()); + stateData.paint(pPainter, centeredRect); return; } @@ -282,13 +306,21 @@ QSize GroupDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIn if (index.data().canConvert()) { GroupStateData stateData = qvariant_cast(index.data()); QSize size = QStyledItemDelegate::sizeHint(option, index); - const int padding = size.width() / 2; - const int content = stateData.getWidth(); - size.setWidth(padding + content); + const int rowHeight = std::max(getConfig().groupManager.tokenIconSize, + option.fontMetrics.height() + 4); + size.setWidth(size.width() / 2 + stateData.getWidthForHeight(rowHeight)); return size; } const auto column = static_cast(index.column()); + if (column == ColumnTypeEnum::CHARACTER_TOKEN) { + const int tokenSize = getConfig().groupManager.tokenIconSize; + QSize size = QStyledItemDelegate::sizeHint(option, index); + size.setWidth(std::max(size.width(), tokenSize)); + size.setHeight(std::max(size.height(), tokenSize)); + return size; + } + if (column == ColumnTypeEnum::HP || column == ColumnTypeEnum::MANA || column == ColumnTypeEnum::MOVES) { QFont monoFont = option.font; @@ -379,6 +411,69 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget } }); + m_setIcon = new QAction(QIcon(":/icons/group-set-icon.png"), tr("Set &Icon…"), this); + connect(m_setIcon, &QAction::triggered, this, [this]() { + if (!selectedCharacter) { + return; + } + const QString charName = selectedCharacter->getDisplayName().trimmed(); + const bool selectingBearToken = selectedCharacter->isYou() + && selectedCharacter->isBearForm(); + TokenPickerDialog dialog(tokenManager().availableFiles(), this); + dialog.setWindowTitle(selectingBearToken ? tr("Choose bear-form token for %1").arg(charName) + : tr("Choose token for %1").arg(charName)); + if (dialog.exec() != QDialog::Accepted) { + return; + } + if (dialog.useDefault()) { + if (selectingBearToken) { + setConfig().groupManager.bearTokenOverrides[charName] = kForceFallback; + } else { + setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + } + } else if (!dialog.selectedToken().isEmpty()) { + if (selectingBearToken) { + setConfig().groupManager.bearTokenOverrides[charName] = dialog.selectedToken(); + const auto answer = QMessageBox::question( + this, + tr("Default bear image"), + tr("Use this as the default bear image for characters without their own bear image?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + if (answer == QMessageBox::Yes) { + setConfig().groupManager.defaultBearToken = dialog.selectedToken(); + } + } else { + setConfig().groupManager.tokenOverrides[charName] = dialog.selectedToken(); + } + } else { + return; + } + getConfig().write(); + tokenManager().invalidateTextures(); + slot_updateLabels(); + emit sig_characterUpdated(selectedCharacter); + }); + + m_useDefaultIcon = new QAction(QIcon(":/icons/group-clear-icon.png"), + tr("&Use default icon"), + this); + connect(m_useDefaultIcon, &QAction::triggered, this, [this]() { + if (!selectedCharacter) { + return; + } + const QString charName = selectedCharacter->getDisplayName().trimmed(); + if (selectedCharacter->isYou() && selectedCharacter->isBearForm()) { + setConfig().groupManager.bearTokenOverrides[charName] = kForceFallback; + } else { + setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + } + getConfig().write(); + tokenManager().invalidateTextures(); + slot_updateLabels(); + emit sig_characterUpdated(selectedCharacter); + }); + connect(m_table, &QAbstractItemView::clicked, this, [this](const QModelIndex &proxyIndex) { if (!proxyIndex.isValid()) { return; @@ -403,6 +498,11 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget contextMenu->setAttribute(Qt::WA_DeleteOnClose); contextMenu->addAction(m_center); contextMenu->addAction(m_recolor); + contextMenu->addAction(m_setIcon); + m_useDefaultIcon->setText(selectedCharacter->isYou() && selectedCharacter->isBearForm() + ? tr("Use default bear icon") + : tr("Use default icon")); + contextMenu->addAction(m_useDefaultIcon); contextMenu->popup(QCursor::pos()); } }); @@ -420,6 +520,7 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget connect(m_model, &GroupModel::sig_anyManaChanged, this, &GroupWidget::updateColumnVisibility); updateColumnVisibility(); + updateTokenSizing(); updatePulseTimer(); } @@ -441,6 +542,33 @@ void GroupWidget::updateColumnVisibility() { // Hide unnecessary columns like mana if everyone is a zorc/troll m_table->setColumnHidden(static_cast(ColumnTypeEnum::MANA), !deref(m_model).getAnyMana()); + + const bool hide_tokens = !getConfig().groupManager.showTokens; + m_table->setColumnHidden(static_cast(ColumnTypeEnum::CHARACTER_TOKEN), hide_tokens); + m_table->viewport()->update(); +} + +void GroupWidget::updateTokenSizing() +{ + const int iconSize = getConfig().groupManager.tokenIconSize; + m_table->setIconSize(QSize(iconSize, iconSize)); + m_table->verticalHeader()->setDefaultSectionSize( + std::max(iconSize, m_table->fontMetrics().height() + 4)); + m_table->resizeRowsToContents(); + m_table->resizeColumnToContents(static_cast(ColumnTypeEnum::CHARACTER_TOKEN)); + m_table->resizeColumnToContents(static_cast(ColumnTypeEnum::STATE)); + m_table->viewport()->update(); +} + +void GroupWidget::slot_groupSettingsChanged() +{ + updateColumnVisibility(); + updateTokenSizing(); +} + +void GroupWidget::slot_tokensChanged() +{ + m_table->viewport()->update(); } void GroupWidget::updatePulseTimer() @@ -481,6 +609,7 @@ void GroupWidget::slot_onCharacterAdded(SharedGroupChar character) { assert(character); deref(m_model).insertCharacter(character); + updateTokenSizing(); updatePulseTimer(); } @@ -501,5 +630,11 @@ void GroupWidget::slot_onCharacterUpdated(SharedGroupChar character) void GroupWidget::slot_onGroupReset(const GroupVector &newCharacterList) { deref(m_model).setCharacters(newCharacterList); + updateTokenSizing(); updatePulseTimer(); } + +void GroupWidget::slot_updateLabels() +{ + deref(m_model).resetModel(); +} diff --git a/src/group/groupwidget.h b/src/group/groupwidget.h index fe416e5bb..78b96e762 100644 --- a/src/group/groupwidget.h +++ b/src/group/groupwidget.h @@ -48,11 +48,14 @@ class NODISCARD_QOBJECT GroupWidget final : public QWidget QTimer *m_pulseTimer = nullptr; void updateColumnVisibility(); + void updateTokenSizing(); void updatePulseTimer(); private: QAction *m_center = nullptr; QAction *m_recolor = nullptr; + QAction *m_setIcon = nullptr; + QAction *m_useDefaultIcon = nullptr; SharedGroupChar selectedCharacter; public: @@ -65,14 +68,18 @@ class NODISCARD_QOBJECT GroupWidget final : public QWidget signals: void sig_kickCharacter(const QString &); void sig_center(glm::vec2); + void sig_characterUpdated(SharedGroupChar character); public slots: void slot_mapUnloaded() { deref(m_model).setMapLoaded(false); } void slot_mapLoaded() { deref(m_model).setMapLoaded(true); } + void slot_groupSettingsChanged(); + void slot_tokensChanged(); private slots: void slot_onCharacterAdded(SharedGroupChar character); void slot_onCharacterRemoved(GroupId characterId); void slot_onCharacterUpdated(SharedGroupChar character); void slot_onGroupReset(const GroupVector &newCharacterList); + void slot_updateLabels(); }; diff --git a/src/group/mmapper2group.cpp b/src/group/mmapper2group.cpp index 1a0dd0714..6c68de7d0 100644 --- a/src/group/mmapper2group.cpp +++ b/src/group/mmapper2group.cpp @@ -72,6 +72,14 @@ void Mmapper2Group::parseGmcpCharName(const JsonObj &obj) void Mmapper2Group::parseGmcpCharStatusVars(const JsonObj &obj) { parseGmcpCharName(obj); + + // Char.StatusVars updates may be partial. Only an explicit race field changes form. + if (const auto race = obj.getString("race")) { + SharedGroupChar self = getSelf(); + if (self->setBearForm(race->compare(QStringLiteral("Bear"), Qt::CaseInsensitive) == 0)) { + characterChanged(); + } + } } void Mmapper2Group::parseGmcpCharVitals(const JsonObj &obj) @@ -195,9 +203,12 @@ void Mmapper2Group::slot_parseGmcpInput(const GmcpMessage &msg) if (msg.isCharVitals()) { debug(); parseGmcpCharVitals(obj); - } else if (msg.isCharName() || msg.isCharStatusVars()) { + } else if (msg.isCharName()) { debug(); parseGmcpCharName(obj); + } else if (msg.isCharStatusVars()) { + debug(); + parseGmcpCharStatusVars(obj); } else if (msg.isGroupAdd()) { debug(); parseGmcpGroupAdd(obj); diff --git a/src/group/tokenmanager.cpp b/src/group/tokenmanager.cpp new file mode 100644 index 000000000..3e2c96c06 --- /dev/null +++ b/src/group/tokenmanager.cpp @@ -0,0 +1,463 @@ +#include "tokenmanager.h" + +#include "../configuration/configuration.h" +#include "../display/Filenames.h" +#include "../display/Textures.h" +#include "../opengl/OpenGL.h" +#include "../opengl/OpenGLTypes.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +TokenManager *g_tokenManager = nullptr; +} + +const QString kForceFallback(QStringLiteral("__force_fallback__")); + +static QString normalizeKey(QString key) +{ + static const QRegularExpression nonWordReg(QStringLiteral("[^a-z0-9_]+")); + + key = key.toLower(); + key.replace(nonWordReg, QStringLiteral("_")); + return key; +} + +static QString findOverride(const QMap &overrides, const QString &displayName) +{ + const QString name = displayName.trimmed(); + const auto exactMatch = overrides.constFind(name); + if (exactMatch != overrides.constEnd()) { + return exactMatch.value(); + } + for (auto it = overrides.cbegin(); it != overrides.cend(); ++it) { + if (it.key().trimmed().compare(name, Qt::CaseInsensitive) == 0) { + return it.value(); + } + } + return {}; +} + +QString TokenManager::overrideFor(const QString &displayName) +{ + const auto &over = getConfig().groupManager.tokenOverrides; + const auto it = over.constFind(displayName.trimmed()); + return (it != over.constEnd()) ? it.value() : QString(); +} + +QString TokenManager::bearOverrideFor(const QString &displayName) +{ + return findOverride(getConfig().groupManager.bearTokenOverrides, displayName); +} + +static QString textureCacheKey(const QString &key, const bool bearForm) +{ + return QStringLiteral("%1:%2").arg(bearForm ? QStringLiteral("bear") : QStringLiteral("normal"), + key); +} + +static SharedMMTexture makeTextureFromPixmap(const QPixmap &px) +{ + const QImage img = px.toImage().mirrored().convertToFormat(QImage::Format_RGBA8888); + + auto mmtex = MMTexture::alloc( + QOpenGLTexture::Target2DArray, + [&img](QOpenGLTexture &tex) { + tex.setFormat(QOpenGLTexture::TextureFormat::RGBA8_UNorm); + tex.setSize(img.width(), img.height()); + tex.setLayers(1); + tex.setMipLevels(1); + tex.setWrapMode(QOpenGLTexture::WrapMode::MirroredRepeat); + tex.setMinMagFilters(QOpenGLTexture::Filter::Linear, QOpenGLTexture::Filter::Linear); + tex.allocateStorage(QOpenGLTexture::PixelFormat::RGBA, QOpenGLTexture::PixelType::UInt8); + + tex.setData(0, + 0, + QOpenGLTexture::PixelFormat::RGBA, + QOpenGLTexture::PixelType::UInt8, + img.constBits()); + }, + /*forbidUpdates = */ true); + + const MMTextureId id = allocateTextureId(); + mmtex->setId(id); + mmtex->setArrayPosition(MMTexArrayPosition{id, 0}); + + return mmtex; +} + +TokenManager::TokenManager(QObject *parent) + : QObject(parent) +{ + g_tokenManager = this; + scanDirectories(); + + m_rescanTimer.setSingleShot(true); + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, [this]() { + scheduleDirectoryScan(); + }); + connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, [this]() { + scheduleDirectoryScan(); + }); + connect(&m_rescanTimer, &QTimer::timeout, this, &TokenManager::scanDirectories); +} + +TokenManager::~TokenManager() +{ + if (g_tokenManager == this) { + g_tokenManager = nullptr; + } +} + +void TokenManager::scheduleDirectoryScan() +{ + m_rescanTimer.start(100); +} + +void TokenManager::slot_resourcesDirectoryChanged() +{ + scheduleDirectoryScan(); +} + +TokenImportResultEnum TokenManager::importTokenImage(const QString &sourcePath, + const bool replaceExisting, + QString &tokenName, + QString &errorMessage) +{ + const QFileInfo sourceInfo(sourcePath); + if (!sourceInfo.isFile()) { + errorMessage = tr("The selected image file could not be found."); + return TokenImportResultEnum::FAILED; + } + + const QString resourcesDirectory = getConfig().canvas.resourcesDirectory; + if (resourcesDirectory.isEmpty()) { + errorMessage = tr("No resource directory has been configured."); + return TokenImportResultEnum::FAILED; + } + + const QString tokensDirectory = QDir(resourcesDirectory).filePath(QStringLiteral("tokens")); + if (!QDir().mkpath(tokensDirectory)) { + errorMessage = tr("The tokens folder could not be created:\n%1").arg(tokensDirectory); + return TokenImportResultEnum::FAILED; + } + + const QString destinationPath = QDir(tokensDirectory).filePath(sourceInfo.fileName()); + const QFileInfo destinationInfo(destinationPath); + tokenName = sourceInfo.completeBaseName(); + + if (sourceInfo.canonicalFilePath() == destinationInfo.canonicalFilePath()) { + scheduleDirectoryScan(); + return TokenImportResultEnum::IMPORTED; + } + + if (destinationInfo.exists() && !replaceExisting) { + return TokenImportResultEnum::ALREADY_EXISTS; + } + + const auto existingToken = m_availableFiles.constFind(normalizeKey(tokenName)); + if (existingToken != m_availableFiles.cend()) { + const QFileInfo existingInfo(existingToken.value()); + const QString existingPath = existingInfo.absoluteFilePath(); + const QString relativePath = QDir(tokensDirectory).relativeFilePath(existingPath); + const bool existingIsUserToken = relativePath != QStringLiteral("..") + && !relativePath.startsWith(QStringLiteral("../")) + && !QDir::isAbsolutePath(relativePath); + if (existingIsUserToken + && existingInfo.fileName().compare(sourceInfo.fileName(), Qt::CaseInsensitive) != 0) { + errorMessage + = tr("A token with the same selectable name already exists as %1. Rename the new " + "image or remove the existing file before importing it.") + .arg(existingInfo.fileName()); + return TokenImportResultEnum::FAILED; + } + } + + QFile sourceFile(sourcePath); + if (!sourceFile.open(QIODevice::ReadOnly)) { + errorMessage = tr("The selected image could not be read:\n%1").arg(sourceFile.errorString()); + return TokenImportResultEnum::FAILED; + } + + QSaveFile destinationFile(destinationPath); + if (!destinationFile.open(QIODevice::WriteOnly)) { + errorMessage = tr("The image could not be saved to:\n%1\n%2") + .arg(destinationPath, destinationFile.errorString()); + return TokenImportResultEnum::FAILED; + } + + while (!sourceFile.atEnd()) { + const QByteArray chunk = sourceFile.read(64 * 1024); + if (chunk.isEmpty()) { + if (sourceFile.error() != QFileDevice::NoError) { + errorMessage = tr("The selected image could not be read:\n%1") + .arg(sourceFile.errorString()); + destinationFile.cancelWriting(); + return TokenImportResultEnum::FAILED; + } + break; + } + if (destinationFile.write(chunk) != chunk.size()) { + errorMessage = tr("The image could not be saved to:\n%1\n%2") + .arg(destinationPath, destinationFile.errorString()); + destinationFile.cancelWriting(); + return TokenImportResultEnum::FAILED; + } + } + + if (!destinationFile.commit()) { + errorMessage = tr("The image could not be saved to:\n%1\n%2") + .arg(destinationPath, destinationFile.errorString()); + return TokenImportResultEnum::FAILED; + } + + scheduleDirectoryScan(); + return TokenImportResultEnum::IMPORTED; +} + +void TokenManager::scanDirectories() +{ + for (const QString &path : m_availableFiles) { + QPixmapCache::remove(path); + } + for (const QString &path : m_tokenPathCache) { + QPixmapCache::remove(path); + } + m_tokenPathCache.clear(); + m_availableFiles.clear(); + const QStringList watchedFiles = m_watcher.files(); + const QStringList watchedDirectories = m_watcher.directories(); + if (!watchedFiles.isEmpty()) { + m_watcher.removePaths(watchedFiles); + } + if (!watchedDirectories.isEmpty()) { + m_watcher.removePaths(watchedDirectories); + } + + const QString userTokensDir = QDir(getConfig().canvas.resourcesDirectory) + .filePath(QStringLiteral("tokens")); + const QString assetTokensDir = QDir(getAssetsPath()).filePath(QStringLiteral("tokens")); + + const QList supportedFormats = QImageReader::supportedImageFormats(); + const QSet formats(supportedFormats.begin(), supportedFormats.end()); + + // Scan from highest to lowest priority. The user's resource directory overrides + // sideloaded assets, which override built-in Qt resources. + const auto scanDirectory = [this, &formats](const QString &path, const bool watch) { + if (path.isEmpty()) { + return; + } + + const QDir dir(path); + if (!dir.exists()) { + return; + } + + if (watch) { + m_watcher.addPath(path); + QDirIterator directories(path, + QDir::Dirs | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories); + while (directories.hasNext()) { + m_watcher.addPath(directories.next()); + } + } + + QDirIterator it(path, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString filePath = it.next(); + const QFileInfo info(filePath); + if (!formats.contains(info.suffix().toLower().toUtf8())) { + continue; + } + + const QString key = normalizeKey(info.completeBaseName()); + if (!key.isEmpty() && !m_availableFiles.contains(key)) { + m_availableFiles.insert(key, filePath); + if (watch) { + m_watcher.addPath(filePath); + } + } + } + }; + + // Preserve the existing mod-first lookup, then fall back to packaged assets + // and finally to any token images compiled into the Qt resource system. + scanDirectory(userTokensDir, true); + scanDirectory(assetTokensDir, false); + scanDirectory(QStringLiteral(":/tokens"), false); + + if (!QDir(userTokensDir).exists()) { + m_watcher.addPath(QFileInfo(userTokensDir).absolutePath()); + } + + m_pendingTextureChanges = true; + emit sig_tokensChanged(); +} + +QPixmap TokenManager::loadToken(const QString &lookup) +{ + QString resolvedKey = normalizeKey(lookup); + if (resolvedKey.isEmpty()) { + resolvedKey = QStringLiteral("blank_character"); + } + + if (m_tokenPathCache.contains(resolvedKey)) { + QString path = m_tokenPathCache[resolvedKey]; + QPixmap cached; + if (QPixmapCache::find(path, &cached)) { + return cached; + } + QPixmap pix; + if (pix.load(path)) { + QPixmapCache::insert(path, pix); + return pix; + } + qWarning() << "TokenManager: Cached path was invalid:" << path; + } + + const auto availableIt = m_availableFiles.constFind(resolvedKey); + if (availableIt != m_availableFiles.cend()) { + const QString &path = availableIt.value(); + + m_tokenPathCache[resolvedKey] = path; + + QPixmap cached; + if (QPixmapCache::find(path, &cached)) { + return cached; + } + + QPixmap pix; + if (pix.load(path)) { + QPixmapCache::insert(path, pix); + return pix; + } else { + qWarning() << "TokenManager: Failed to load image from path:" << path; + } + } else { + return {}; + } + + return {}; +} + +QPixmap TokenManager::getToken(const QString &key, const bool bearForm) +{ + if (m_fallbackPixmap.isNull()) { + m_fallbackPixmap.load(":/pixmaps/char-room-sel.png"); + } + + if (key == kForceFallback) { + return m_fallbackPixmap; + } + + if (bearForm) { + const QString bearOverride = bearOverrideFor(key); + if (!bearOverride.isEmpty() && bearOverride != kForceFallback) { + if (QPixmap selected = loadToken(bearOverride); !selected.isNull()) { + return selected; + } + } + + const QString defaultBearName = getConfig().groupManager.defaultBearToken; + if (!defaultBearName.isEmpty()) { + if (QPixmap defaultBear = loadToken(defaultBearName); !defaultBear.isNull()) { + return defaultBear; + } + } + if (QPixmap builtInBear = loadToken(QStringLiteral("Bear-M")); !builtInBear.isNull()) { + return builtInBear; + } + + // Keep self visible even if neither the selected image nor Bear-M.png is available. + return getToken(key, false); + } + + const QString tokenOverride = overrideFor(key); + if (tokenOverride == kForceFallback) { + return m_fallbackPixmap; + } + + const QString lookup = tokenOverride.isEmpty() ? key : tokenOverride; + if (QPixmap token = loadToken(lookup); !token.isNull()) { + return token; + } + + qWarning() << "TokenManager: No match found for key:" << normalizeKey(lookup); + return m_fallbackPixmap; +} + +TokenManager &tokenManager() +{ + static TokenManager instance; // created on first call (post-QGuiApp) + return instance; +} + +MMTextureId TokenManager::textureIdFor(const QString &key, const bool bearForm) +{ + const QString cacheKey = textureCacheKey(key, bearForm); + if (m_textureCache.contains(cacheKey)) + return m_textureCache.value(cacheKey); + + return INVALID_MM_TEXTURE_ID; +} + +MMTextureId TokenManager::uploadNow(const QString &key, const bool bearForm, const QPixmap &px) +{ + SharedMMTexture tex = makeTextureFromPixmap(px); + MMTextureId id = tex->getId(); + + if (id == INVALID_MM_TEXTURE_ID) + return id; + + m_ownedTextures.push_back(std::move(tex)); + m_textureCache.insert(textureCacheKey(key, bearForm), id); + return id; +} + +void TokenManager::clearOpenGLTextures(OpenGL &gl) +{ + for (const SharedMMTexture &texture : m_ownedTextures) { + gl.setTextureLookup(texture->getId(), {}); + } + m_textureCache.clear(); + m_ownedTextures.clear(); + m_pendingTextureChanges = false; +} + +void TokenManager::processPendingTextureChanges(OpenGL &gl) +{ + if (m_pendingTextureChanges && QOpenGLContext::currentContext()) { + clearOpenGLTextures(gl); + } +} + +void TokenManager::invalidateTextures() +{ + m_pendingTextureChanges = true; +} + +void TokenManager::cleanupOpenGLTexturesIfCreated(OpenGL &gl) +{ + if (g_tokenManager != nullptr && QOpenGLContext::currentContext() != nullptr) { + g_tokenManager->clearOpenGLTextures(gl); + } +} + +// retrieve pointer later +SharedMMTexture TokenManager::textureById(MMTextureId id) const +{ + for (const auto &ptr : m_ownedTextures) + if (ptr->getId() == id) + return ptr; + return {}; +} diff --git a/src/group/tokenmanager.h b/src/group/tokenmanager.h new file mode 100644 index 000000000..d12cbeb5f --- /dev/null +++ b/src/group/tokenmanager.h @@ -0,0 +1,74 @@ +#ifndef TOKENMANAGER_H +#define TOKENMANAGER_H + +#include "../opengl/OpenGLTypes.h" // MMTextureId forward-declared here + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +class MMTexture; // forward +class OpenGL; +using SharedMMTexture = std::shared_ptr; // … + +enum class NODISCARD TokenImportResultEnum { IMPORTED, ALREADY_EXISTS, FAILED }; + +class NODISCARD_QOBJECT TokenManager final : public QObject +{ + Q_OBJECT + +public: + explicit TokenManager(QObject *parent = nullptr); + ~TokenManager() final; + + QPixmap getToken(const QString &key, bool bearForm = false); + const QMap &availableFiles() const { return m_availableFiles; } + TokenImportResultEnum importTokenImage(const QString &sourcePath, + bool replaceExisting, + QString &tokenName, + QString &errorMessage); + static QString overrideFor(const QString &displayName); + static QString bearOverrideFor(const QString &displayName); + MMTextureId textureIdFor(const QString &key, bool bearForm = false); // ← per-token cache + MMTextureId uploadNow(const QString &key, bool bearForm, const QPixmap &px); + SharedMMTexture textureById(MMTextureId id) const; + void invalidateTextures(); + void processPendingTextureChanges(OpenGL &gl); + + static void cleanupOpenGLTexturesIfCreated(OpenGL &gl); + +public slots: + void slot_resourcesDirectoryChanged(); + +signals: + void sig_tokensChanged(); + +private: + void scanDirectories(); + void scheduleDirectoryScan(); + void clearOpenGLTextures(OpenGL &gl); + QPixmap loadToken(const QString &lookup); + + QMap m_availableFiles; + QFileSystemWatcher m_watcher; + QTimer m_rescanTimer; + mutable QMap m_tokenPathCache; + QPixmap m_fallbackPixmap; + + QHash m_textureCache; // key → GL id + QVector m_ownedTextures; // keep textures alive + bool m_pendingTextureChanges = false; +}; + +// sentinel +extern const QString kForceFallback; +TokenManager &tokenManager(); + +#endif diff --git a/src/group/tokenpickerdialog.cpp b/src/group/tokenpickerdialog.cpp new file mode 100644 index 000000000..8a767db3a --- /dev/null +++ b/src/group/tokenpickerdialog.cpp @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "tokenpickerdialog.h" + +#include "tokenmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TokenPickerDialog::~TokenPickerDialog() = default; + +TokenPickerDialog::TokenPickerDialog(const QMap &availableFiles, QWidget *parent) + : QDialog(parent) +{ + setWindowTitle(tr("Choose Character Token")); + resize(1040, 700); + + auto *layout = new QVBoxLayout(this); + m_filter = new QLineEdit(this); + m_filter->setPlaceholderText(tr("Filter tokens…")); + m_filter->setClearButtonEnabled(true); + layout->addWidget(m_filter); + + auto *contentLayout = new QHBoxLayout; + m_tokenList = new QListWidget(this); + m_tokenList->setViewMode(QListView::IconMode); + m_tokenList->setFlow(QListView::LeftToRight); + m_tokenList->setWrapping(true); + m_tokenList->setResizeMode(QListView::Adjust); + m_tokenList->setMovement(QListView::Static); + m_tokenList->setSelectionMode(QAbstractItemView::SingleSelection); + m_tokenList->setIconSize(QSize(96, 96)); + m_tokenList->setGridSize(QSize(128, 140)); + m_tokenList->setWordWrap(true); + contentLayout->addWidget(m_tokenList, 1); + + auto *previewLayout = new QVBoxLayout; + m_preview = new QLabel(this); + m_preview->setMinimumSize(220, 220); + m_preview->setMaximumSize(220, 220); + m_preview->setAlignment(Qt::AlignCenter); + previewLayout->addWidget(m_preview, 0, Qt::AlignLeft); + + m_selectedName = new QLabel(this); + m_selectedName->setWordWrap(true); + previewLayout->addWidget(m_selectedName); + + auto *importButton = new QPushButton(tr("Import New Image"), this); + auto *cancelButton = new QPushButton(tr("Cancel"), this); + auto *selectButton = new QPushButton(tr("Select"), this); + importButton->setMinimumHeight(44); + cancelButton->setMinimumHeight(44); + selectButton->setMinimumHeight(44); + m_selectButton = selectButton; + m_selectButton->setEnabled(false); + previewLayout->addWidget(importButton); + previewLayout->addWidget(cancelButton); + previewLayout->addWidget(selectButton); + previewLayout->addStretch(1); + contentLayout->addLayout(previewLayout); + layout->addLayout(contentLayout, 1); + + auto *bottomLayout = new QHBoxLayout; + auto *defaultButton = new QPushButton(tr("Use Default"), this); + bottomLayout->addWidget(defaultButton); + bottomLayout->addStretch(1); + layout->addLayout(bottomLayout); + + connect(m_filter, &QLineEdit::textChanged, this, [this](const QString &text) { + for (int i = 0; i < m_tokenList->count(); ++i) { + auto *item = m_tokenList->item(i); + const bool hidden = !item->text().contains(text, Qt::CaseInsensitive); + item->setHidden(hidden); + if (hidden && item->isSelected()) { + m_tokenList->clearSelection(); + } + } + }); + connect(m_tokenList, + &QListWidget::itemSelectionChanged, + this, + &TokenPickerDialog::updatePreview); + connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); + connect(selectButton, &QPushButton::clicked, this, [this]() { + const auto selectedItems = m_tokenList->selectedItems(); + if (!selectedItems.isEmpty()) { + m_selectedToken = selectedItems.first()->data(Qt::UserRole).toString(); + accept(); + } + }); + connect(defaultButton, &QPushButton::clicked, this, [this]() { + m_useDefault = true; + accept(); + }); + + connect(importButton, &QPushButton::clicked, this, [this]() { + QSet uniquePatterns; + for (const QByteArray &format : QImageReader::supportedImageFormats()) { + uniquePatterns.insert(QStringLiteral("*.%1").arg(QString::fromLatin1(format))); + } + QStringList patterns = uniquePatterns.values(); + patterns.sort(Qt::CaseInsensitive); + const QString fileFilter = patterns.isEmpty() + ? tr("Image files (*)") + : tr("Image files (%1)").arg(patterns.join(QLatin1Char(' '))); + const QString sourcePath = QFileDialog::getOpenFileName(this, + tr("Import New Token Image"), + QString(), + fileFilter); + if (sourcePath.isEmpty()) { + return; + } + + QImageReader reader(sourcePath); + if (!reader.canRead()) { + QMessageBox::warning(this, + tr("Cannot Read Image"), + tr("MMapper cannot read this image format, or the file is damaged.")); + return; + } + + QSize imageSize = reader.size(); + if (!imageSize.isValid()) { + const QImage image = reader.read(); + if (image.isNull()) { + QMessageBox::warning( + this, + tr("Cannot Read Image"), + tr("MMapper cannot read this image format, or the file is damaged.")); + return; + } + imageSize = image.size(); + } + + if (imageSize.width() != imageSize.height()) { + const QString message + = tr("This image is %1 × %2 pixels. Map tokens are displayed in a square, so " + "the image may appear stretched. Import it anyway?") + .arg(imageSize.width()) + .arg(imageSize.height()); + const auto answer = QMessageBox::warning(this, + tr("Image Is Not Square"), + message, + QMessageBox::Yes | QMessageBox::Cancel, + QMessageBox::Cancel); + if (answer != QMessageBox::Yes) { + return; + } + } + + QString tokenName; + QString errorMessage; + auto &manager = tokenManager(); + auto result = manager.importTokenImage(sourcePath, false, tokenName, errorMessage); + if (result == TokenImportResultEnum::ALREADY_EXISTS) { + const QString message + = tr("An image named %1 already exists in your tokens folder. Replace it?\n\n" + "Characters using this token will immediately display the updated image.") + .arg(QFileInfo(sourcePath).fileName()); + const auto answer = QMessageBox::question(this, + tr("Replace Token Image?"), + message, + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + if (answer != QMessageBox::Yes) { + return; + } + result = manager.importTokenImage(sourcePath, true, tokenName, errorMessage); + } + if (result == TokenImportResultEnum::FAILED) { + QMessageBox::warning(this, tr("Image Import Failed"), errorMessage); + return; + } + + m_filter->clear(); + m_tokenToSelectAfterImport = tokenName; + }); + + connect(&tokenManager(), &TokenManager::sig_tokensChanged, this, [this]() { + populateTokens(tokenManager().availableFiles(), m_tokenToSelectAfterImport); + m_tokenToSelectAfterImport.clear(); + }); + + populateTokens(availableFiles); +} + +void TokenPickerDialog::populateTokens(const QMap &availableFiles, + const QString &selectToken) +{ + m_tokenList->clear(); + QListWidgetItem *itemToSelect = nullptr; + for (auto it = availableFiles.cbegin(); it != availableFiles.cend(); ++it) { + const QFileInfo info(it.value()); + auto *item = new QListWidgetItem(QIcon(it.value()), info.fileName()); + item->setData(Qt::UserRole, info.completeBaseName()); + item->setTextAlignment(Qt::AlignHCenter); + m_tokenList->addItem(item); + if (info.completeBaseName() == selectToken) { + itemToSelect = item; + } + } + + if (itemToSelect != nullptr) { + m_tokenList->setCurrentItem(itemToSelect); + itemToSelect->setSelected(true); + m_tokenList->scrollToItem(itemToSelect); + } else if (m_tokenList->count() == 0) { + m_selectedName->setText(tr("No token images found.")); + } else { + updatePreview(); + } +} + +void TokenPickerDialog::updatePreview() +{ + const auto selectedItems = m_tokenList->selectedItems(); + const bool hasSelection = !selectedItems.isEmpty(); + m_selectButton->setEnabled(hasSelection); + if (!hasSelection) { + m_preview->clear(); + m_selectedName->clear(); + m_selectedToken.clear(); + return; + } + + const auto *item = selectedItems.first(); + const QString fileName = item->text(); + const QString tokenName = item->data(Qt::UserRole).toString(); + const QPixmap pixmap = item->icon().pixmap(m_preview->size()); + m_preview->setPixmap( + pixmap.scaled(m_preview->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + m_selectedName->setText(fileName); + m_selectedToken = tokenName; +} diff --git a/src/group/tokenpickerdialog.h b/src/group/tokenpickerdialog.h new file mode 100644 index 000000000..bad0e7f50 --- /dev/null +++ b/src/group/tokenpickerdialog.h @@ -0,0 +1,36 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +class QLabel; +class QLineEdit; +class QListWidget; +class QPushButton; + +class TokenPickerDialog final : public QDialog +{ +private: + QListWidget *m_tokenList = nullptr; + QLineEdit *m_filter = nullptr; + QLabel *m_preview = nullptr; + QLabel *m_selectedName = nullptr; + QPushButton *m_selectButton = nullptr; + QString m_selectedToken; + QString m_tokenToSelectAfterImport; + bool m_useDefault = false; + + void updatePreview(); + void populateTokens(const QMap &availableFiles, + const QString &selectToken = QString()); + +public: + ~TokenPickerDialog() override; + explicit TokenPickerDialog(const QMap &availableFiles, + QWidget *parent = nullptr); + + const QString &selectedToken() const { return m_selectedToken; } + bool useDefault() const { return m_useDefault; } +}; diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 2f8111d5b..e7962dba6 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -24,6 +24,7 @@ #include "../global/Version.h" #include "../global/window_utils.h" #include "../group/groupwidget.h" +#include "../group/tokenmanager.h" #include "../logger/autologger.h" #include "../media/AudioManager.h" #include "../media/DescriptionWidget.h" @@ -195,6 +196,17 @@ MainWindow::MainWindow() addDockWidget(Qt::TopDockWidgetArea, dock); dock->setWidget(w); connect(w, &GroupWidget::sig_center, m_mapWindow, &MapWindow::slot_centerOnWorldPos); + auto *const canvas = getCanvas(); + connect(w, &GroupWidget::sig_characterUpdated, canvas, [canvas](SharedGroupChar) { + canvas->slot_requestUpdate(); + }); + connect(&tokenManager(), + &TokenManager::sig_tokensChanged, + w, + &GroupWidget::slot_tokensChanged); + connect(&tokenManager(), &TokenManager::sig_tokensChanged, canvas, [canvas]() { + canvas->slot_requestUpdate(); + }); m_groupWidget = w; m_dockDialogGroup = dock; @@ -1696,6 +1708,14 @@ void MainWindow::slot_onPreferences() &ConfigDialog::sig_groupSettingsChanged, m_groupManager, &Mmapper2Group::slot_groupSettingsChanged); + connect(configDialog, + &ConfigDialog::sig_groupSettingsChanged, + m_groupWidget, + &GroupWidget::slot_groupSettingsChanged); + connect(configDialog, + &ConfigDialog::sig_resourcesDirectoryChanged, + &tokenManager(), + &TokenManager::slot_resourcesDirectoryChanged); connect(configDialog, &QDialog::finished, this, [this](MAYBE_UNUSED int result) { m_configDialog.reset(); }); diff --git a/src/preferences/configdialog.cpp b/src/preferences/configdialog.cpp index d467e02a0..2f657b5c5 100644 --- a/src/preferences/configdialog.cpp +++ b/src/preferences/configdialog.cpp @@ -143,6 +143,10 @@ ConfigDialog::ConfigDialog(QWidget *const parent) &ConfigDialog::slot_onResultSelected); connect(generalPage, &GeneralPage::sig_reloadConfig, this, [this]() { emit sig_loadConfig(); }); + connect(generalPage, + &GeneralPage::sig_resourcesDirectoryChanged, + this, + &ConfigDialog::sig_resourcesDirectoryChanged); connect(this, &ConfigDialog::sig_loadConfig, generalPage, &GeneralPage::slot_loadConfig); connect(this, &ConfigDialog::sig_loadConfig, graphicsPage, &GraphicsPage::slot_loadConfig); diff --git a/src/preferences/configdialog.h b/src/preferences/configdialog.h index 754f64c0d..ef4ff11c7 100644 --- a/src/preferences/configdialog.h +++ b/src/preferences/configdialog.h @@ -64,5 +64,6 @@ private slots: signals: void sig_graphicsSettingsChanged(); void sig_groupSettingsChanged(); + void sig_resourcesDirectoryChanged(); void sig_loadConfig(); }; diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index 21fb2ba92..ee2c7c060 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -252,8 +252,14 @@ GeneralPage::GeneralPage(QWidget *parent) connect(ui->remoteName, &QLineEdit::textChanged, this, [this]() { updateAutoLoginEnabled(); }); - connect(ui->resourceLineEdit, &QLineEdit::textChanged, this, [](const QString &text) { - setConfig().canvas.resourcesDirectory = text; + connect(ui->resourceLineEdit, &QLineEdit::textChanged, this, [this](const QString &text) { + auto &resourcesDirectory = setConfig().canvas.resourcesDirectory; + if (resourcesDirectory == text) { + return; + } + + resourcesDirectory = text; + emit sig_resourcesDirectoryChanged(); }); connect(ui->resourcePushButton, &QAbstractButton::clicked, this, [this](bool /*unused*/) { const auto &resourceDir = getConfig().canvas.resourcesDirectory; diff --git a/src/preferences/generalpage.h b/src/preferences/generalpage.h index 7e0014b0d..4f36b4992 100644 --- a/src/preferences/generalpage.h +++ b/src/preferences/generalpage.h @@ -31,6 +31,7 @@ class NODISCARD_QOBJECT GeneralPage final : public QWidget signals: void sig_reloadConfig(); + void sig_resourcesDirectoryChanged(); public slots: void slot_loadConfig(); diff --git a/src/preferences/grouppage.cpp b/src/preferences/grouppage.cpp index f9f3a988a..c4b02d9d2 100644 --- a/src/preferences/grouppage.cpp +++ b/src/preferences/grouppage.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -37,6 +38,28 @@ GroupPage::GroupPage(QWidget *const parent) emit sig_groupSettingsChanged(); }); + connect(ui->showTokensCheckBox, &QCheckBox::stateChanged, this, [this](int checked) { + setConfig().groupManager.showTokens = checked; + emit sig_groupSettingsChanged(); + }); + + connect(ui->showMapTokensCheckBox, &QCheckBox::stateChanged, this, [this](int checked) { + setConfig().groupManager.showMapTokens = checked; + emit sig_groupSettingsChanged(); + }); + + const QString tokenSizeText = QString::number(getConfig().groupManager.tokenIconSize) + " px"; + const int tokenSizeIndex = ui->tokenSizeComboBox->findText(tokenSizeText); + if (tokenSizeIndex >= 0) { + ui->tokenSizeComboBox->setCurrentIndex(tokenSizeIndex); + } + + connect(ui->tokenSizeComboBox, &QComboBox::currentTextChanged, this, [this](const QString &txt) { + const int value = txt.section(' ', 0, 0).toInt(); + setConfig().groupManager.tokenIconSize = value; + emit sig_groupSettingsChanged(); + }); + slot_loadConfig(); } @@ -54,12 +77,21 @@ void GroupPage::slot_loadConfig() ui->yourColorPushButton->setIcon(QIcon(yourPix)); ui->npcOverrideColorCheckBox->setChecked(settings.npcColorOverride); + QPixmap npcOverridePix(16, 16); npcOverridePix.fill(settings.npcColor); ui->npcOverrideColorPushButton->setIcon(QIcon(npcOverridePix)); ui->npcSortBottomCheckbox->setChecked(settings.npcSortBottom); ui->npcHideCheckbox->setChecked(settings.npcHide); + + ui->showTokensCheckBox->setChecked(settings.showTokens); + ui->showMapTokensCheckBox->setChecked(settings.showMapTokens); + const QString tokenSizeText = QString::number(settings.tokenIconSize) + " px"; + const int tokenSizeIndex = ui->tokenSizeComboBox->findText(tokenSizeText); + if (tokenSizeIndex >= 0) { + ui->tokenSizeComboBox->setCurrentIndex(tokenSizeIndex); + } } void GroupPage::slot_chooseColor() diff --git a/src/preferences/grouppage.ui b/src/preferences/grouppage.ui index 59da212e3..b1ba51f62 100644 --- a/src/preferences/grouppage.ui +++ b/src/preferences/grouppage.ui @@ -12,6 +12,34 @@ Appearance + + + + Show tokens on map + + + + + + + Show tokens in group manager + + + + + + + + + + + + + + + + + @@ -25,6 +53,23 @@ + + + + Select + + + + + + + Your color: + + + yourColorPushButton + + + @@ -38,35 +83,64 @@ - - + + - - + + - Your color: - - - yourColorPushButton + Select - - + + - Select + Token size - - - - Select + + + + 64 + + + QComboBox::NoInsert + + + 16 + + + + 32 + 32 + + + + 16 px + + + + + 32 px + + + + + 48 px + + + + + 64 px + +