From 644ed088b33d7de36f23d44dbb57e761f44bc3e9 Mon Sep 17 00:00:00 2001 From: Lewis Buckingham Date: Fri, 2 Oct 2026 14:50:00 +1000 Subject: [PATCH 1/6] Clean up character token integration --- src/CMakeLists.txt | 2 + src/configuration/configuration.cpp | 22 ++ src/configuration/configuration.h | 4 + src/display/Characters.cpp | 134 +++++++++++- src/display/Characters.h | 10 +- src/display/mapcanvas_gl.cpp | 2 + src/group/CGroupChar.h | 2 + src/group/GroupModel.cpp | 3 + src/group/GroupModel.h | 2 + src/group/groupwidget.cpp | 117 +++++++++- src/group/groupwidget.h | 7 + src/group/tokenmanager.cpp | 318 ++++++++++++++++++++++++++++ src/group/tokenmanager.h | 62 ++++++ src/mainwindow/mainwindow.cpp | 16 ++ src/preferences/grouppage.cpp | 32 +++ src/preferences/grouppage.ui | 104 +++++++-- 16 files changed, 806 insertions(+), 31 deletions(-) create mode 100644 src/group/tokenmanager.cpp create mode 100644 src/group/tokenmanager.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b7eb179e6..dc15956e7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -252,6 +252,8 @@ set(mmapper_SRCS group/ColorGenerator.h group/GroupManagerApi.cpp group/GroupManagerApi.h + group/tokenmanager.cpp + group/tokenmanager.h group/GroupModel.cpp group/GroupModel.h group/groupwidget.cpp diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 89dc485a4..7eb2ff5cb 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -802,6 +802,17 @@ 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()); + } } void Configuration::MumeClockSettings::read(const QSettings &conf) @@ -995,6 +1006,17 @@ 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); } void Configuration::MumeClockSettings::write(QSettings &conf) const diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index 64223a8b0..e21dda345 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -320,6 +320,10 @@ class NODISCARD Configuration final bool npcColorOverride = false; bool npcSortBottom = false; bool npcHide = false; + bool showTokens = false; + bool showMapTokens = false; + int tokenIconSize = 32; + QMap tokenOverrides; private: SUBGROUP(); diff --git a/src/display/Characters.cpp b/src/display/Characters.cpp index 5ec665e4b..ae0bb557d 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,10 @@ 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 Configuration::CanvasSettings &settings = getConfig().canvas; @@ -100,7 +105,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); } void CharacterBatch::CharFakeGL::drawPathSegment(const glm::vec3 p1, @@ -232,10 +237,8 @@ void CharacterBatch::CharFakeGL::drawQuadCommon(const glm::vec2 in_a, } } -void CharacterBatch::CharFakeGL::drawBox(const Coordinate coord, - bool fill, - bool beacon, - const bool isFar) +void CharacterBatch::CharFakeGL::drawBox( + const Coordinate coord, bool fill, bool beacon, const bool isFar, const QString &dispName) { const bool dontFillRotatedQuads = true; const bool shrinkRotatedQuads = false; // REVISIT: make this a user option? @@ -295,6 +298,47 @@ void CharacterBatch::CharFakeGL::drawBox(const Coordinate coord, addTransformed(c); addTransformed(d); + 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% + 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}); + + QString key = TokenManager::overrideFor(dispName); + if (key.isEmpty()) + key = canonicalTokenKey(dispName); + else + key = canonicalTokenKey(key); + + m_charTokenKeys.emplace_back(key); + } + if (beacon) { drawQuadCommon(a, b, c, d, QuadOptsEnum::BEACON); } @@ -324,6 +368,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); @@ -339,6 +384,48 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva textures.char_room_sel->getArrayPosition().array)); } + if (!m_charTokenQuads.empty() && !m_charTokenKeys.empty()) { + size_t base = 0; + + for (const QString &key : m_charTokenKeys) { + if (base + 3 >= m_charTokenQuads.size()) + break; + + MMTextureId id = tokenManager().textureIdFor(key); + + if (id == INVALID_MM_TEXTURE_ID) { + if (!QOpenGLContext::currentContext()) { + base += 4; + continue; + } + + const QPixmap px = tokenManager().getToken(key); + + if (!px.isNull()) { + id = tokenManager().uploadNow(key, 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_charTris.empty()) { gl.renderColoredTris(m_charTris, blended_noDepth); } @@ -357,6 +444,9 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva gl.renderFont3d(textures.char_arrows, m_screenSpaceArrows); m_screenSpaceArrows.clear(); } + + m_charTokenQuads.clear(); + m_charTokenKeys.clear(); } void CharacterBatch::CharFakeGL::reallyDrawPaths(OpenGL &gl) @@ -471,8 +561,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 +582,10 @@ 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()); // paint prespam const auto prespam = m_data.getPath(id, m_prespammedPath.getQueue()); characterBatch.drawPreSpammedPath(pos, prespam, color); @@ -510,6 +610,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 +649,10 @@ 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); + 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..d6a7bbaab 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,8 @@ 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_charLines; std::vector m_charRoomQuads; std::vector m_pathPoints; @@ -152,7 +155,7 @@ 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); 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 +197,10 @@ 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()); 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.h b/src/group/CGroupChar.h index efe01d462..3f5694cca 100644 --- a/src/group/CGroupChar.h +++ b/src/group/CGroupChar.h @@ -154,4 +154,6 @@ 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..fe5f6dc9a 100644 --- a/src/group/groupwidget.cpp +++ b/src/group/groupwidget.cpp @@ -12,6 +12,7 @@ #include "CGroupChar.h" #include "enums.h" #include "mmapper2group.h" +#include "tokenmanager.h" #include #include @@ -22,8 +23,13 @@ #include #include #include +#include +#include +#include #include +#include #include +#include #include #include #include @@ -143,6 +149,20 @@ 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()); + if (!token.isNull()) { + QIcon(token).paint(&painter, rect, Qt::AlignCenter); + } + return; + } + // Layer 0: Background painter.fillRect(rect, charColor); @@ -155,7 +175,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 +307,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 +412,45 @@ 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 QString tokensDir = QDir(getConfig().canvas.resourcesDirectory).filePath("tokens"); + if (!QDir(tokensDir).exists()) { + QMessageBox::information(this, + tr("No tokens folder"), + tr("The tokens folder does not exist:\n%1").arg(tokensDir)); + return; + } + const QString file + = QFileDialog::getOpenFileName(this, + tr("Choose icon for %1").arg(charName), + tokensDir, + tr("Images (*.png *.jpg *.jpeg *.bmp *.webp)")); + if (file.isEmpty()) { + return; + } + setConfig().groupManager.tokenOverrides[charName] = QFileInfo(file).completeBaseName(); + 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(); + setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + slot_updateLabels(); + emit sig_characterUpdated(selectedCharacter); + }); + connect(m_table, &QAbstractItemView::clicked, this, [this](const QModelIndex &proxyIndex) { if (!proxyIndex.isValid()) { return; @@ -403,6 +475,8 @@ 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); + contextMenu->addAction(m_useDefaultIcon); contextMenu->popup(QCursor::pos()); } }); @@ -420,6 +494,7 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget connect(m_model, &GroupModel::sig_anyManaChanged, this, &GroupWidget::updateColumnVisibility); updateColumnVisibility(); + updateTokenSizing(); updatePulseTimer(); } @@ -441,6 +516,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 +583,7 @@ void GroupWidget::slot_onCharacterAdded(SharedGroupChar character) { assert(character); deref(m_model).insertCharacter(character); + updateTokenSizing(); updatePulseTimer(); } @@ -501,5 +604,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/tokenmanager.cpp b/src/group/tokenmanager.cpp new file mode 100644 index 000000000..dea643e14 --- /dev/null +++ b/src/group/tokenmanager.cpp @@ -0,0 +1,318 @@ +#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 + +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; +} + +QString TokenManager::overrideFor(const QString &displayName) +{ + const auto &over = getConfig().groupManager.tokenOverrides; + auto it = over.constFind(displayName.trimmed()); + return (it != over.constEnd()) ? it.value() : QString(); +} + +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::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.baseName()); + 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::getToken(const QString &key) +{ + if (m_fallbackPixmap.isNull()) + m_fallbackPixmap.load(":/pixmaps/char-room-sel.png"); + + if (key == kForceFallback) { + return m_fallbackPixmap; + } + + const QString ov = overrideFor(key); + if (ov == kForceFallback) { + return m_fallbackPixmap; + } + + QString lookup = key; + if (!ov.isEmpty()) { + lookup = ov; // use the user-chosen icon basename + } + + 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 { + qWarning() << "TokenManager: No match found for key:" << resolvedKey; + } + + // Fallback: user-defined blank_character.png in tokens folder + const QString userFallback + = QDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)) + .filePath(QStringLiteral("tokens/blank_character.png")); + if (QFile::exists(userFallback)) { + m_tokenPathCache[resolvedKey] = userFallback; // ✅ Cache fallback + return QPixmap(userFallback); + } + + // Final fallback: built-in resource image + QString finalFallback = ":/pixmaps/char-room-sel.png"; + m_tokenPathCache[resolvedKey] = finalFallback; // ✅ Cache fallback + return QPixmap(finalFallback); +} + +TokenManager &tokenManager() +{ + static TokenManager instance; // created on first call (post-QGuiApp) + return instance; +} + +MMTextureId TokenManager::textureIdFor(const QString &key) +{ + if (m_textureCache.contains(key)) + return m_textureCache.value(key); + + return INVALID_MM_TEXTURE_ID; +} + +QString canonicalTokenKey(const QString &name) +{ + return normalizeKey(name); // reuse the existing static helper +} + +MMTextureId TokenManager::uploadNow(const QString &key, 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(key, 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::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..83e4d0f62 --- /dev/null +++ b/src/group/tokenmanager.h @@ -0,0 +1,62 @@ +#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; // … +QString canonicalTokenKey(const QString &name); + +class NODISCARD_QOBJECT TokenManager final : public QObject +{ + Q_OBJECT + +public: + explicit TokenManager(QObject *parent = nullptr); + ~TokenManager() final; + + QPixmap getToken(const QString &key); + static QString overrideFor(const QString &displayName); + MMTextureId textureIdFor(const QString &key); // ← per-token cache + MMTextureId uploadNow(const QString &key, const QPixmap &px); + SharedMMTexture textureById(MMTextureId id) const; + void processPendingTextureChanges(OpenGL &gl); + + static void cleanupOpenGLTexturesIfCreated(OpenGL &gl); + +signals: + void sig_tokensChanged(); + +private: + void scanDirectories(); + void scheduleDirectoryScan(); + void clearOpenGLTextures(OpenGL &gl); + + 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/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 2f8111d5b..3cbb6bc29 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,10 @@ void MainWindow::slot_onPreferences() &ConfigDialog::sig_groupSettingsChanged, m_groupManager, &Mmapper2Group::slot_groupSettingsChanged); + connect(configDialog, + &ConfigDialog::sig_groupSettingsChanged, + m_groupWidget, + &GroupWidget::slot_groupSettingsChanged); connect(configDialog, &QDialog::finished, this, [this](MAYBE_UNUSED int result) { m_configDialog.reset(); }); 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 + + From 2c1170f4b395d4887347648215811804a76d1966 Mon Sep 17 00:00:00 2001 From: Lewis Buckingham Date: Thu, 8 Oct 2026 09:35:02 +1100 Subject: [PATCH 2/6] Address token review feedback --- src/display/Characters.cpp | 81 ++++++++++++++------------------ src/group/CGroupChar.cpp | 5 ++ src/group/tokenmanager.cpp | 20 ++------ src/group/tokenmanager.h | 4 +- src/mainwindow/mainwindow.cpp | 4 ++ src/preferences/configdialog.cpp | 4 ++ src/preferences/configdialog.h | 1 + src/preferences/generalpage.cpp | 10 +++- src/preferences/generalpage.h | 1 + 9 files changed, 67 insertions(+), 63 deletions(-) diff --git a/src/display/Characters.cpp b/src/display/Characters.cpp index ae0bb557d..380ea4d6b 100644 --- a/src/display/Characters.cpp +++ b/src/display/Characters.cpp @@ -298,52 +298,42 @@ void CharacterBatch::CharFakeGL::drawBox( addTransformed(c); addTransformed(d); - 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% - 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}); - - QString key = TokenManager::overrideFor(dispName); - if (key.isEmpty()) - key = canonicalTokenKey(dispName); - else - key = canonicalTokenKey(key); - - m_charTokenKeys.emplace_back(key); - } - if (beacon) { drawQuadCommon(a, b, c, d, QuadOptsEnum::BEACON); } } + 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); + } + glPopMatrix(); } @@ -384,6 +374,10 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva textures.char_room_sel->getArrayPosition().array)); } + if (!m_charTris.empty()) { + gl.renderColoredTris(m_charTris, blended_noDepth); + } + if (!m_charTokenQuads.empty() && !m_charTokenKeys.empty()) { size_t base = 0; @@ -426,10 +420,6 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva } } - if (!m_charTris.empty()) { - gl.renderColoredTris(m_charTris, blended_noDepth); - } - if (!m_charLines.empty()) { gl.renderColoredLines(m_charLines, blended_noDepth.withLineParams(LineParams{CHAR_ARROW_LINE_WIDTH})); @@ -650,6 +640,7 @@ void MapCanvas::drawGroupCharacters(CharacterBatch &batch, ServerRoomId yourServ const bool fill = !drawnRoomIds.contains(id); 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); 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/tokenmanager.cpp b/src/group/tokenmanager.cpp index dea643e14..2074ece3a 100644 --- a/src/group/tokenmanager.cpp +++ b/src/group/tokenmanager.cpp @@ -14,7 +14,6 @@ #include #include #include -#include namespace { TokenManager *g_tokenManager = nullptr; @@ -97,6 +96,11 @@ void TokenManager::scheduleDirectoryScan() m_rescanTimer.start(100); } +void TokenManager::slot_resourcesDirectoryChanged() +{ + scheduleDirectoryScan(); +} + void TokenManager::scanDirectories() { for (const QString &path : m_availableFiles) { @@ -237,15 +241,6 @@ QPixmap TokenManager::getToken(const QString &key) qWarning() << "TokenManager: No match found for key:" << resolvedKey; } - // Fallback: user-defined blank_character.png in tokens folder - const QString userFallback - = QDir(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)) - .filePath(QStringLiteral("tokens/blank_character.png")); - if (QFile::exists(userFallback)) { - m_tokenPathCache[resolvedKey] = userFallback; // ✅ Cache fallback - return QPixmap(userFallback); - } - // Final fallback: built-in resource image QString finalFallback = ":/pixmaps/char-room-sel.png"; m_tokenPathCache[resolvedKey] = finalFallback; // ✅ Cache fallback @@ -266,11 +261,6 @@ MMTextureId TokenManager::textureIdFor(const QString &key) return INVALID_MM_TEXTURE_ID; } -QString canonicalTokenKey(const QString &name) -{ - return normalizeKey(name); // reuse the existing static helper -} - MMTextureId TokenManager::uploadNow(const QString &key, const QPixmap &px) { SharedMMTexture tex = makeTextureFromPixmap(px); diff --git a/src/group/tokenmanager.h b/src/group/tokenmanager.h index 83e4d0f62..dbe36f08e 100644 --- a/src/group/tokenmanager.h +++ b/src/group/tokenmanager.h @@ -17,7 +17,6 @@ class MMTexture; // forward class OpenGL; using SharedMMTexture = std::shared_ptr; // … -QString canonicalTokenKey(const QString &name); class NODISCARD_QOBJECT TokenManager final : public QObject { @@ -36,6 +35,9 @@ class NODISCARD_QOBJECT TokenManager final : public QObject static void cleanupOpenGLTexturesIfCreated(OpenGL &gl); +public slots: + void slot_resourcesDirectoryChanged(); + signals: void sig_tokensChanged(); diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 3cbb6bc29..e7962dba6 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -1712,6 +1712,10 @@ void MainWindow::slot_onPreferences() &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(); From 42597f79a4075781ed787f7ee68458cd594d0229 Mon Sep 17 00:00:00 2001 From: Lewis Buckingham Date: Thu, 8 Oct 2026 10:10:06 +1100 Subject: [PATCH 3/6] Format token manager code --- src/group/tokenmanager.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/group/tokenmanager.cpp b/src/group/tokenmanager.cpp index 2074ece3a..c24f7f2cd 100644 --- a/src/group/tokenmanager.cpp +++ b/src/group/tokenmanager.cpp @@ -50,8 +50,7 @@ static SharedMMTexture makeTextureFromPixmap(const QPixmap &px) 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.allocateStorage(QOpenGLTexture::PixelFormat::RGBA, QOpenGLTexture::PixelType::UInt8); tex.setData(0, 0, @@ -120,8 +119,8 @@ void TokenManager::scanDirectories() m_watcher.removePaths(watchedDirectories); } - const QString userTokensDir - = QDir(getConfig().canvas.resourcesDirectory).filePath(QStringLiteral("tokens")); + const QString userTokensDir = QDir(getConfig().canvas.resourcesDirectory) + .filePath(QStringLiteral("tokens")); const QString assetTokensDir = QDir(getAssetsPath()).filePath(QStringLiteral("tokens")); const QList supportedFormats = QImageReader::supportedImageFormats(); From f2077731af878741512ce9c3ca0225ee8b43a695 Mon Sep 17 00:00:00 2001 From: Lewis Buckingham Date: Wed, 14 Oct 2026 12:12:50 +1100 Subject: [PATCH 4/6] Add group token picker dialog --- src/CMakeLists.txt | 2 + src/group/groupwidget.cpp | 28 ++-- src/group/tokenmanager.cpp | 105 +++++++++++++- src/group/tokenmanager.h | 8 + src/group/tokenpickerdialog.cpp | 250 ++++++++++++++++++++++++++++++++ src/group/tokenpickerdialog.h | 36 +++++ 6 files changed, 413 insertions(+), 16 deletions(-) create mode 100644 src/group/tokenpickerdialog.cpp create mode 100644 src/group/tokenpickerdialog.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index dc15956e7..fa067de00 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -254,6 +254,8 @@ set(mmapper_SRCS 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/group/groupwidget.cpp b/src/group/groupwidget.cpp index fe5f6dc9a..d43f7f94e 100644 --- a/src/group/groupwidget.cpp +++ b/src/group/groupwidget.cpp @@ -12,6 +12,7 @@ #include "CGroupChar.h" #include "enums.h" #include "mmapper2group.h" +#include "tokenpickerdialog.h" #include "tokenmanager.h" #include @@ -23,9 +24,6 @@ #include #include #include -#include -#include -#include #include #include #include @@ -418,22 +416,20 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget return; } const QString charName = selectedCharacter->getDisplayName().trimmed(); - const QString tokensDir = QDir(getConfig().canvas.resourcesDirectory).filePath("tokens"); - if (!QDir(tokensDir).exists()) { - QMessageBox::information(this, - tr("No tokens folder"), - tr("The tokens folder does not exist:\n%1").arg(tokensDir)); + TokenPickerDialog dialog(tokenManager().availableFiles(), this); + dialog.setWindowTitle(tr("Choose token for %1").arg(charName)); + if (dialog.exec() != QDialog::Accepted) { return; } - const QString file - = QFileDialog::getOpenFileName(this, - tr("Choose icon for %1").arg(charName), - tokensDir, - tr("Images (*.png *.jpg *.jpeg *.bmp *.webp)")); - if (file.isEmpty()) { + if (dialog.useDefault()) { + setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + } else if (!dialog.selectedToken().isEmpty()) { + setConfig().groupManager.tokenOverrides[charName] = dialog.selectedToken(); + } else { return; } - setConfig().groupManager.tokenOverrides[charName] = QFileInfo(file).completeBaseName(); + getConfig().write(); + tokenManager().invalidateTextures(); slot_updateLabels(); emit sig_characterUpdated(selectedCharacter); }); @@ -447,6 +443,8 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget } const QString charName = selectedCharacter->getDisplayName().trimmed(); setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + getConfig().write(); + tokenManager().invalidateTextures(); slot_updateLabels(); emit sig_characterUpdated(selectedCharacter); }); diff --git a/src/group/tokenmanager.cpp b/src/group/tokenmanager.cpp index c24f7f2cd..efdc45da3 100644 --- a/src/group/tokenmanager.cpp +++ b/src/group/tokenmanager.cpp @@ -9,10 +9,12 @@ #include #include #include +#include #include #include #include #include +#include #include namespace { @@ -100,6 +102,102 @@ 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) { @@ -156,7 +254,7 @@ void TokenManager::scanDirectories() continue; } - const QString key = normalizeKey(info.baseName()); + const QString key = normalizeKey(info.completeBaseName()); if (!key.isEmpty() && !m_availableFiles.contains(key)) { m_availableFiles.insert(key, filePath); if (watch) { @@ -290,6 +388,11 @@ void TokenManager::processPendingTextureChanges(OpenGL &gl) } } +void TokenManager::invalidateTextures() +{ + m_pendingTextureChanges = true; +} + void TokenManager::cleanupOpenGLTexturesIfCreated(OpenGL &gl) { if (g_tokenManager != nullptr && QOpenGLContext::currentContext() != nullptr) { diff --git a/src/group/tokenmanager.h b/src/group/tokenmanager.h index dbe36f08e..7cc1998b1 100644 --- a/src/group/tokenmanager.h +++ b/src/group/tokenmanager.h @@ -18,6 +18,8 @@ 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 @@ -27,10 +29,16 @@ class NODISCARD_QOBJECT TokenManager final : public QObject ~TokenManager() final; QPixmap getToken(const QString &key); + const QMap &availableFiles() const { return m_availableFiles; } + TokenImportResultEnum importTokenImage(const QString &sourcePath, + bool replaceExisting, + QString &tokenName, + QString &errorMessage); static QString overrideFor(const QString &displayName); MMTextureId textureIdFor(const QString &key); // ← per-token cache MMTextureId uploadNow(const QString &key, const QPixmap &px); SharedMMTexture textureById(MMTextureId id) const; + void invalidateTextures(); void processPendingTextureChanges(OpenGL &gl); static void cleanupOpenGLTexturesIfCreated(OpenGL &gl); 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; } +}; From d20d73f0be6ec17a5bbe739ddcfd03d9f7545c3b Mon Sep 17 00:00:00 2001 From: Lewis Buckingham Date: Wed, 14 Oct 2026 12:17:53 +1100 Subject: [PATCH 5/6] Format token picker changes --- src/group/groupwidget.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/group/groupwidget.cpp b/src/group/groupwidget.cpp index d43f7f94e..e6aeb9e7e 100644 --- a/src/group/groupwidget.cpp +++ b/src/group/groupwidget.cpp @@ -12,8 +12,8 @@ #include "CGroupChar.h" #include "enums.h" #include "mmapper2group.h" -#include "tokenpickerdialog.h" #include "tokenmanager.h" +#include "tokenpickerdialog.h" #include #include From 6433756ab274e4192e3b288eb23563cce34b317c Mon Sep 17 00:00:00 2001 From: Lewis Buckingham Date: Fri, 16 Oct 2026 11:28:55 +1100 Subject: [PATCH 6/6] Add self bear-form token switching --- src/configuration/configuration.cpp | 14 ++++ src/configuration/configuration.h | 2 + src/display/Characters.cpp | 31 +++++--- src/display/Characters.h | 11 ++- src/group/CGroupChar.h | 10 +++ src/group/groupwidget.cpp | 38 ++++++++-- src/group/mmapper2group.cpp | 13 +++- src/group/tokenmanager.cpp | 111 ++++++++++++++++++++-------- src/group/tokenmanager.h | 8 +- 9 files changed, 186 insertions(+), 52 deletions(-) diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 7eb2ff5cb..13be7a67c 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -813,6 +813,13 @@ void Configuration::GroupManagerSettings::read(const QSettings &conf) 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) @@ -1017,6 +1024,13 @@ void Configuration::GroupManagerSettings::write(QSettings &conf) const 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 e21dda345..f7a5365de 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -324,6 +324,8 @@ class NODISCARD Configuration final 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 380ea4d6b..7d273ca39 100644 --- a/src/display/Characters.cpp +++ b/src/display/Characters.cpp @@ -51,10 +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, - const QString &dispName) +void CharacterBatch::drawCharacter( + const Coordinate c, const Color color, bool fill, const QString &dispName, const bool bearForm) { const Configuration::CanvasSettings &settings = getConfig().canvas; @@ -105,7 +103,7 @@ void CharacterBatch::drawCharacter(const Coordinate c, } const bool beacon = visible && !differentLayer && wantBeacons; - gl.drawBox(c, fill, beacon, isFar, dispName); + gl.drawBox(c, fill, beacon, isFar, dispName, bearForm); } void CharacterBatch::CharFakeGL::drawPathSegment(const glm::vec3 p1, @@ -237,8 +235,12 @@ void CharacterBatch::CharFakeGL::drawQuadCommon(const glm::vec2 in_a, } } -void CharacterBatch::CharFakeGL::drawBox( - const Coordinate coord, bool fill, bool beacon, const bool isFar, const QString &dispName) +void CharacterBatch::CharFakeGL::drawBox(const Coordinate coord, + bool fill, + bool beacon, + const bool isFar, + const QString &dispName, + const bool bearForm) { const bool dontFillRotatedQuads = true; const bool shrinkRotatedQuads = false; // REVISIT: make this a user option? @@ -332,6 +334,7 @@ void CharacterBatch::CharFakeGL::drawBox( // Resolve the override once in TokenManager, using the display name as the key. m_charTokenKeys.emplace_back(dispName); + m_charTokenBearForms.emplace_back(bearForm); } glPopMatrix(); @@ -381,11 +384,13 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva if (!m_charTokenQuads.empty() && !m_charTokenKeys.empty()) { size_t base = 0; - for (const QString &key : m_charTokenKeys) { + for (size_t tokenIndex = 0; tokenIndex < m_charTokenKeys.size(); ++tokenIndex) { if (base + 3 >= m_charTokenQuads.size()) break; - MMTextureId id = tokenManager().textureIdFor(key); + 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()) { @@ -393,10 +398,10 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva continue; } - const QPixmap px = tokenManager().getToken(key); + const QPixmap px = tokenManager().getToken(key, bearForm); if (!px.isNull()) { - id = tokenManager().uploadNow(key, px); + id = tokenManager().uploadNow(key, bearForm, px); } } @@ -437,6 +442,7 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva m_charTokenQuads.clear(); m_charTokenKeys.clear(); + m_charTokenBearForms.clear(); } void CharacterBatch::CharFakeGL::reallyDrawPaths(OpenGL &gl) @@ -575,7 +581,8 @@ void MapCanvas::paintCharacters() characterBatch.drawCharacter(pos, color, true, - playerChar ? playerChar->getDisplayName() : QString()); + playerChar ? playerChar->getDisplayName() : QString(), + playerChar && playerChar->isBearForm()); // paint prespam const auto prespam = m_data.getPath(id, m_prespammedPath.getQueue()); characterBatch.drawPreSpammedPath(pos, prespam, color); diff --git a/src/display/Characters.h b/src/display/Characters.h index d6a7bbaab..8db7fe579 100644 --- a/src/display/Characters.h +++ b/src/display/Characters.h @@ -105,6 +105,7 @@ class NODISCARD CharacterBatch final 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; @@ -155,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, const QString &dispName); + 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); @@ -200,7 +206,8 @@ class NODISCARD CharacterBatch final void drawCharacter(Coordinate coordinate, Color color, bool fill = true, - const QString &dispName = QString()); + const QString &dispName = QString(), + bool bearForm = false); void drawName(const Coordinate c, const std::string &name, const Color color) { diff --git a/src/group/CGroupChar.h b/src/group/CGroupChar.h index 3f5694cca..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_thisgetDisplayName()); + const QPixmap token = tokenManager().getToken(character->getDisplayName(), + character->isBearForm()); if (!token.isNull()) { QIcon(token).paint(&painter, rect, Qt::AlignCenter); } @@ -416,15 +417,35 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget return; } const QString charName = selectedCharacter->getDisplayName().trimmed(); + const bool selectingBearToken = selectedCharacter->isYou() + && selectedCharacter->isBearForm(); TokenPickerDialog dialog(tokenManager().availableFiles(), this); - dialog.setWindowTitle(tr("Choose token for %1").arg(charName)); + 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()) { - setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + if (selectingBearToken) { + setConfig().groupManager.bearTokenOverrides[charName] = kForceFallback; + } else { + setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + } } else if (!dialog.selectedToken().isEmpty()) { - setConfig().groupManager.tokenOverrides[charName] = dialog.selectedToken(); + 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; } @@ -442,7 +463,11 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget return; } const QString charName = selectedCharacter->getDisplayName().trimmed(); - setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + if (selectedCharacter->isYou() && selectedCharacter->isBearForm()) { + setConfig().groupManager.bearTokenOverrides[charName] = kForceFallback; + } else { + setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + } getConfig().write(); tokenManager().invalidateTextures(); slot_updateLabels(); @@ -474,6 +499,9 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget 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()); } 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 index efdc45da3..3e2c96c06 100644 --- a/src/group/tokenmanager.cpp +++ b/src/group/tokenmanager.cpp @@ -32,13 +32,39 @@ static QString normalizeKey(QString key) 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; - auto it = over.constFind(displayName.trimmed()); + 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); @@ -278,25 +304,8 @@ void TokenManager::scanDirectories() emit sig_tokensChanged(); } -QPixmap TokenManager::getToken(const QString &key) +QPixmap TokenManager::loadToken(const QString &lookup) { - if (m_fallbackPixmap.isNull()) - m_fallbackPixmap.load(":/pixmaps/char-room-sel.png"); - - if (key == kForceFallback) { - return m_fallbackPixmap; - } - - const QString ov = overrideFor(key); - if (ov == kForceFallback) { - return m_fallbackPixmap; - } - - QString lookup = key; - if (!ov.isEmpty()) { - lookup = ov; // use the user-chosen icon basename - } - QString resolvedKey = normalizeKey(lookup); if (resolvedKey.isEmpty()) { resolvedKey = QStringLiteral("blank_character"); @@ -335,13 +344,56 @@ QPixmap TokenManager::getToken(const QString &key) qWarning() << "TokenManager: Failed to load image from path:" << path; } } else { - qWarning() << "TokenManager: No match found for key:" << resolvedKey; + 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; } - // Final fallback: built-in resource image - QString finalFallback = ":/pixmaps/char-room-sel.png"; - m_tokenPathCache[resolvedKey] = finalFallback; // ✅ Cache fallback - return QPixmap(finalFallback); + qWarning() << "TokenManager: No match found for key:" << normalizeKey(lookup); + return m_fallbackPixmap; } TokenManager &tokenManager() @@ -350,15 +402,16 @@ TokenManager &tokenManager() return instance; } -MMTextureId TokenManager::textureIdFor(const QString &key) +MMTextureId TokenManager::textureIdFor(const QString &key, const bool bearForm) { - if (m_textureCache.contains(key)) - return m_textureCache.value(key); + 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 QPixmap &px) +MMTextureId TokenManager::uploadNow(const QString &key, const bool bearForm, const QPixmap &px) { SharedMMTexture tex = makeTextureFromPixmap(px); MMTextureId id = tex->getId(); @@ -367,7 +420,7 @@ MMTextureId TokenManager::uploadNow(const QString &key, const QPixmap &px) return id; m_ownedTextures.push_back(std::move(tex)); - m_textureCache.insert(key, id); + m_textureCache.insert(textureCacheKey(key, bearForm), id); return id; } diff --git a/src/group/tokenmanager.h b/src/group/tokenmanager.h index 7cc1998b1..d12cbeb5f 100644 --- a/src/group/tokenmanager.h +++ b/src/group/tokenmanager.h @@ -28,15 +28,16 @@ class NODISCARD_QOBJECT TokenManager final : public QObject explicit TokenManager(QObject *parent = nullptr); ~TokenManager() final; - QPixmap getToken(const QString &key); + 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); - MMTextureId textureIdFor(const QString &key); // ← per-token cache - MMTextureId uploadNow(const QString &key, const QPixmap &px); + 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); @@ -53,6 +54,7 @@ public slots: void scanDirectories(); void scheduleDirectoryScan(); void clearOpenGLTextures(OpenGL &gl); + QPixmap loadToken(const QString &lookup); QMap m_availableFiles; QFileSystemWatcher m_watcher;