From 09f3df34d595da4ad598c616e4e32b3d836e233c Mon Sep 17 00:00:00 2001 From: Durika Date: Wed, 17 Dec 2025 15:26:24 +0100 Subject: [PATCH] Add Communications Manager with GMCP and text-based parsing Communications System: - New CommsManager to handle all in-game communications - CommsWidget displaying communications in categorized tabs (Direct, Local, Global) - Real-time parsing from GMCP Comm.Channel messages - Fallback text-based parsing when GMCP unavailable - Tab-based organization (Tell/Whisper, Say/Emote, Narrate/Yell/etc) Features: - Color-coded communication types (customizable) - Talker identification (You, Player, NPC, Ally, Neutral, Enemy) - Font styling options (CAPS for yells, italic for whispers/emotes) - Optional timestamps - Communication log saving - Tab muting/filtering - Dockable communications panel Configuration: - Comprehensive color settings for each comm type - Talker-based coloring system - Font styling preferences - Log directory configuration - Per-tab muting controls GMCP Integration: - Requires PR-B (GMCP Broadcast) for sig_rawGameText signal - Parses Comm.Channel GMCP messages - Falls back to text parsing for compatibility UI: - Added Comms preferences page - Integrated with main window dock system - Tabbed interface for easy navigation This provides a centralized, organized view of all game communications with extensive customization options. --- src/CMakeLists.txt | 10 + src/comms/CommsManager.cpp | 272 +++++++++++++ src/comms/CommsManager.h | 83 ++++ src/comms/CommsWidget.cpp | 599 ++++++++++++++++++++++++++++ src/comms/CommsWidget.h | 81 ++++ src/configuration/configuration.cpp | 479 +++++++++++++++++++++- src/configuration/configuration.h | 162 +++++++- src/mainwindow/mainwindow.cpp | 350 ++++++++++++++-- src/mainwindow/mainwindow.h | 14 + src/preferences/commspage.cpp | 402 +++++++++++++++++++ src/preferences/commspage.h | 71 ++++ src/preferences/configdialog.cpp | 19 + 12 files changed, 2506 insertions(+), 36 deletions(-) create mode 100644 src/comms/CommsManager.cpp create mode 100644 src/comms/CommsManager.h create mode 100644 src/comms/CommsWidget.cpp create mode 100644 src/comms/CommsWidget.h create mode 100644 src/preferences/commspage.cpp create mode 100644 src/preferences/commspage.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 585e85b69..7c4d80959 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -32,6 +32,10 @@ set(mmapper_SRCS clock/mumeclockwidget.h clock/mumemoment.cpp clock/mumemoment.h + comms/CommsManager.cpp + comms/CommsManager.h + comms/CommsWidget.cpp + comms/CommsWidget.h configuration/NamedConfig.h configuration/PasswordConfig.cpp configuration/PasswordConfig.h @@ -226,6 +230,8 @@ set(mmapper_SRCS mainwindow/MapZoomSlider.h mainwindow/UpdateDialog.cpp mainwindow/UpdateDialog.h + mainwindow/VisibilityFilterWidget.cpp + mainwindow/VisibilityFilterWidget.h mainwindow/WinDarkMode.cpp mainwindow/WinDarkMode.h mainwindow/aboutdialog.cpp @@ -466,12 +472,16 @@ set(mmapper_SRCS preferences/autologpage.h preferences/clientpage.cpp preferences/clientpage.h + preferences/commspage.cpp + preferences/commspage.h preferences/configdialog.cpp preferences/configdialog.h preferences/generalpage.cpp preferences/generalpage.h preferences/graphicspage.cpp preferences/graphicspage.h + preferences/hotkeyspage.cpp + preferences/hotkeyspage.h preferences/grouppage.cpp preferences/grouppage.h preferences/mumeprotocolpage.cpp diff --git a/src/comms/CommsManager.cpp b/src/comms/CommsManager.cpp new file mode 100644 index 000000000..1f95519fd --- /dev/null +++ b/src/comms/CommsManager.cpp @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors +// Author: originally MuMeM for Mudlet Client (adapted for mMapper: Shimrod with Claude) + +#include "CommsManager.h" + +#include "../configuration/configuration.h" +#include "../global/AnsiTextUtils.h" +#include "../proxy/GmcpMessage.h" + +#include +#include + +CommsManager::CommsManager(QObject *parent) + : QObject(parent) +{} + +CommsManager::~CommsManager() = default; + +void CommsManager::slot_parseGmcpInput(const GmcpMessage &msg) +{ + if (msg.isCommChannelText()) { + parseCommChannelText(msg); + } +} + +void CommsManager::parseCommChannelText(const GmcpMessage &msg) +{ + const auto optDoc = msg.getJsonDocument(); + if (!optDoc.has_value()) { + return; + } + + const auto optObj = optDoc->getObject(); + if (!optObj.has_value()) { + return; + } + + const auto &obj = optObj.value(); + + // Extract fields from GMCP message + // Structure: { "channel": "tells", "talker": "Name", "talker-type": "npc", "text": "..." } + const auto channelOpt = obj.getString("channel"); + const auto talkerOpt = obj.getString("talker"); + const auto textOpt = obj.getString("text"); + const auto talkerTypeOpt = obj.getString("talker-type"); + + if (!channelOpt.has_value() || !talkerOpt.has_value() || !textOpt.has_value()) { + return; + } + + const QString channel = channelOpt.value(); + const QString talker = talkerOpt.value(); + const QString text = textOpt.value(); + + // Determine talker type based on talker name and talker-type field + TalkerType talkerType = TalkerType::PLAYER; // Default + + if (talker == "you") { + talkerType = TalkerType::YOU; + } else if (talkerTypeOpt.has_value()) { + const QString talkerTypeStr = talkerTypeOpt.value(); + if (talkerTypeStr == "npc") { + talkerType = TalkerType::NPC; + } else if (talkerTypeStr == "ally") { + talkerType = TalkerType::ALLY; + } else if (talkerTypeStr == "neutral") { + talkerType = TalkerType::NEUTRAL; + } else if (talkerTypeStr == "enemy") { + talkerType = TalkerType::ENEMY; + } + } + + // Note: Text may contain ANSI codes, but we'll display it as-is for now + // ANSI stripping can be added later if needed + + // Map channel name to CommType + CommType type = getCommTypeFromChannel(channel); + CommCategory category = getCategoryFromType(type); + + // Track yells from GMCP to avoid fallback duplicates + if (type == CommType::YELL) { + trackYellMessage(talker, text); + } + + // Create and emit the message + CommMessage commMsg; + commMsg.type = type; + commMsg.category = category; + commMsg.sender = talker; + commMsg.message = text; + commMsg.timestamp = QDateTime::currentDateTime().toString("HH:mm:ss"); + commMsg.talkerType = talkerType; + + emit sig_newMessage(commMsg); +} + +CommType CommsManager::getCommTypeFromChannel(const QString &channel) +{ + // Map GMCP channel names to CommType (support both singular and plural forms) + if (channel == "tells" || channel == "tell") { + return CommType::TELL; + } else if (channel == "whispers" || channel == "whisper") { + return CommType::WHISPER; + } else if (channel == "groups" || channel == "group") { + return CommType::GROUP; + } else if (channel == "says" || channel == "say") { + return CommType::SAY; + } else if (channel == "emotes" || channel == "emote") { + return CommType::EMOTE; + } else if (channel == "tales" || channel == "narrates" || channel == "narrate") { + return CommType::NARRATE; + } else if (channel == "yells" || channel == "yell") { + return CommType::YELL; + } else if (channel == "prayers" || channel == "prayer" || channel == "pray") { + return CommType::PRAY; + } else if (channel == "shouts" || channel == "shout") { + return CommType::SHOUT; + } else if (channel == "songs" || channel == "song" || channel == "sing") { + return CommType::SING; + } else if (channel == "questions" || channel == "question" || channel == "ask") { + return CommType::ASK; + } else if (channel == "socials" || channel == "social") { + return CommType::SOCIAL; + } + + // Default to SAY for unknown channels + return CommType::SAY; +} + +CommCategory CommsManager::getCategoryFromType(CommType type) +{ + switch (type) { + case CommType::TELL: + case CommType::WHISPER: + case CommType::GROUP: + return CommCategory::DIRECT; + + case CommType::SAY: + case CommType::EMOTE: + case CommType::SOCIAL: + return CommCategory::LOCAL; + + case CommType::NARRATE: + case CommType::YELL: + case CommType::PRAY: + case CommType::SHOUT: + case CommType::SING: + case CommType::ASK: + return CommCategory::GLOBAL; + + default: + return CommCategory::LOCAL; + } +} + +void CommsManager::slot_parseRawGameText(const QString &rawText) +{ + // Check if fallback parsing is enabled + if (!getConfig().parser.enableYellFallbackParsing) { + return; + } + + parseFallbackYell(rawText); +} + +void CommsManager::parseFallbackYell(const QString &rawText) +{ + // Pattern to match yell messages: + // "Name yells [from direction] 'message'" + // Examples: + // Círdan the Shipwright yells from below 'Come here if you want to speak with me!' + // A thief yells 'HELP! *Shimrod the Elf* is trying to kill me in the Robbers Haven!' + // You yell 'Hello!' + + // Strip ANSI codes from the text before pattern matching + QString cleanText = rawText.trimmed(); + + // Remove ANSI codes using regex + static const QRegularExpression ansiPattern(R"(\x1B\[[0-9;]*[a-zA-Z])"); + cleanText.remove(ansiPattern); + + // Pattern: Name yells [anything] 'message' [optional text after quote] + // Captures everything between "yells" and the opening quote as the qualifier + // Examples: "Name yells 'msg'", "Name yells loudly 'msg'", + // "Name yells faintly from below 'msg'", "Name yells loudly from far to the east 'msg'" + static const QRegularExpression yellPattern(R"(^(.+?) yells?(?: (.+?))? '(.+?)')", + QRegularExpression::CaseInsensitiveOption); + + auto match = yellPattern.match(cleanText); + if (!match.hasMatch()) { + return; + } + + QString sender = match.captured(1).trimmed(); + QString qualifier = match.captured(2).trimmed(); // Everything between "yells" and "'" (optional) + QString message = match.captured(3); + + // Check if this is a duplicate from GMCP (within last 2 seconds) + if (isRecentYellDuplicate(sender, message)) { + return; // Skip this fallback yell, already got it from GMCP + } + + // Determine talker type based on sender name + TalkerType talkerType = TalkerType::NPC; // Default to NPC + + if (sender.startsWith("You", Qt::CaseInsensitive)) { + talkerType = TalkerType::YOU; + } else if (!sender.startsWith("A ", Qt::CaseInsensitive) + && !sender.startsWith("An ", Qt::CaseInsensitive) + && !sender.startsWith("The ", Qt::CaseInsensitive) && !sender.contains(" the ")) { + // If name doesn't start with article, likely a player + talkerType = TalkerType::PLAYER; + } + + // Add qualifier (direction/volume info) to message if present + QString fullMessage = message; + if (!qualifier.isEmpty()) { + fullMessage = QString("[%1] %2").arg(qualifier, message); + } + + // Create and emit the comm message + CommMessage commMsg; + commMsg.type = CommType::YELL; + commMsg.category = CommCategory::GLOBAL; + commMsg.sender = sender; + commMsg.message = fullMessage; + commMsg.timestamp = QDateTime::currentDateTime().toString("HH:mm:ss"); + commMsg.talkerType = talkerType; + + // Track this yell to avoid future duplicates + trackYellMessage(sender, message); + + emit sig_newMessage(commMsg); +} + +void CommsManager::trackYellMessage(const QString &sender, const QString &message) +{ + // Create a unique key for this yell + const QString key = QString("%1|%2").arg(sender, message); + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + + // Store the timestamp + m_recentYells[key] = now; + + // Clean up old entries (older than 5 seconds) + const qint64 cutoff = now - 5000; + auto it = m_recentYells.begin(); + while (it != m_recentYells.end()) { + if (it.value() < cutoff) { + it = m_recentYells.erase(it); + } else { + ++it; + } + } +} + +bool CommsManager::isRecentYellDuplicate(const QString &sender, const QString &message) const +{ + const QString key = QString("%1|%2").arg(sender, message); + + if (!m_recentYells.contains(key)) { + return false; + } + + // Check if it's recent (within last 2 seconds) + const qint64 timestamp = m_recentYells[key]; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + const qint64 age = now - timestamp; + + return age < 2000; // 2 second window +} diff --git a/src/comms/CommsManager.h b/src/comms/CommsManager.h new file mode 100644 index 000000000..80a89e771 --- /dev/null +++ b/src/comms/CommsManager.h @@ -0,0 +1,83 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include +#include +#include +#include + +#include "../global/utils.h" + +class GmcpMessage; + +enum class NODISCARD CommType { + TELL, + WHISPER, + GROUP, + SAY, + EMOTE, + NARRATE, + YELL, + PRAY, + SHOUT, + SING, + ASK, + SOCIAL +}; + +enum class NODISCARD CommCategory { + DIRECT, // tells, whispers + LOCAL, // say, emote, social + GLOBAL // narrate, yell, pray, shout, sing, ask (questions) +}; + +enum class NODISCARD TalkerType { + YOU, // Messages sent by the player (talker: "you") + PLAYER, // Regular player (no talker-type specified) + NPC, // NPC (talker-type: "npc") + ALLY, // Ally (talker-type: "ally") + NEUTRAL, // Neutral (talker-type: "neutral") + ENEMY // Enemy (talker-type: "enemy") +}; + +struct NODISCARD CommMessage final +{ + CommType type = CommType::SAY; + CommCategory category = CommCategory::LOCAL; + QString sender; + QString message; + QString timestamp; + TalkerType talkerType = TalkerType::PLAYER; +}; + +class CommsManager final : public QObject +{ + Q_OBJECT + +public: + explicit CommsManager(QObject *parent); + ~CommsManager() override; + + DELETE_CTORS_AND_ASSIGN_OPS(CommsManager); + +public slots: + void slot_parseGmcpInput(const GmcpMessage &msg); + void slot_parseRawGameText(const QString &rawText); + +signals: + void sig_newMessage(const CommMessage &msg); + void sig_log(const QString &module, const QString &message); + +private: + void parseCommChannelText(const GmcpMessage &msg); + void parseFallbackYell(const QString &rawText); + CommType getCommTypeFromChannel(const QString &channel); + CommCategory getCategoryFromType(CommType type); + void trackYellMessage(const QString &sender, const QString &message); + bool isRecentYellDuplicate(const QString &sender, const QString &message) const; + + // Track recent GMCP yells to avoid duplicates from fallback parsing + // Format: "sender|message" -> timestamp (in msecs since epoch) + QHash m_recentYells; +}; diff --git a/src/comms/CommsWidget.cpp b/src/comms/CommsWidget.cpp new file mode 100644 index 000000000..f2e8da9eb --- /dev/null +++ b/src/comms/CommsWidget.cpp @@ -0,0 +1,599 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "CommsWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../configuration/configuration.h" +#include "../logger/autologger.h" +#include "../global/TextUtils.h" + +CommsWidget::CommsWidget(CommsManager &commsManager, AutoLogger *autoLogger, QWidget *parent) + : QWidget(parent) + , m_commsManager{commsManager} + , m_autoLogger{autoLogger} +{ + // Initialize all filter states to enabled (not muted) + m_filterStates[CommType::TELL] = true; + m_filterStates[CommType::WHISPER] = true; + m_filterStates[CommType::GROUP] = true; + m_filterStates[CommType::ASK] = true; + m_filterStates[CommType::EMOTE] = true; + m_filterStates[CommType::SOCIAL] = true; + m_filterStates[CommType::SAY] = true; + m_filterStates[CommType::YELL] = true; + m_filterStates[CommType::NARRATE] = true; + m_filterStates[CommType::SING] = true; + m_filterStates[CommType::PRAY] = true; + + setupUI(); + connectSignals(); + slot_loadSettings(); +} + +CommsWidget::~CommsWidget() = default; + +void CommsWidget::setupUI() +{ + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(2, 2, 2, 2); + mainLayout->setSpacing(4); + + // Top row: Filter buttons and C&M toggle + auto *topLayout = new QHBoxLayout(); + topLayout->setSpacing(4); + + // Helper to create a filter button + auto createFilterButton = [this](const QString &fullLabel, const QString &shortLabel, CommType type) { + auto *btn = new QPushButton(fullLabel); + btn->setToolTip(fullLabel); + btn->setProperty("shortLabel", shortLabel); + btn->setProperty("fullLabel", fullLabel); + btn->setCheckable(true); + btn->setChecked(true); // Initially not muted + btn->setMaximumHeight(24); + btn->setMinimumWidth(40); + m_filterButtons[type] = btn; + + connect(btn, &QPushButton::toggled, this, [this, type](bool checked) { + slot_onFilterToggled(type, checked); + }); + + return btn; + }; + + // Direct group + auto *directGroup = new QWidget(); + auto *directLayout = new QVBoxLayout(directGroup); + directLayout->setContentsMargins(0, 0, 0, 0); + directLayout->setSpacing(2); + + auto *directLabel = new QLabel("Direct"); + directLabel->setAlignment(Qt::AlignCenter); + directLayout->addWidget(directLabel); + + auto *directButtonsLayout = new QHBoxLayout(); + directButtonsLayout->setSpacing(2); + directButtonsLayout->addWidget(createFilterButton("Tells", "Te", CommType::TELL)); + directButtonsLayout->addWidget(createFilterButton("Qtions", "Qt", CommType::ASK)); + directButtonsLayout->addWidget(createFilterButton("Whispers", "Wh", CommType::WHISPER)); + directButtonsLayout->addWidget(createFilterButton("Group", "Gr", CommType::GROUP)); + directLayout->addLayout(directButtonsLayout); + + // Local group + auto *localGroup = new QWidget(); + auto *localLayout = new QVBoxLayout(localGroup); + localLayout->setContentsMargins(0, 0, 0, 0); + localLayout->setSpacing(2); + + auto *localLabel = new QLabel("Local"); + localLabel->setAlignment(Qt::AlignCenter); + localLayout->addWidget(localLabel); + + auto *localButtonsLayout = new QHBoxLayout(); + localButtonsLayout->setSpacing(2); + localButtonsLayout->addWidget(createFilterButton("Emotes", "Em", CommType::EMOTE)); + localButtonsLayout->addWidget(createFilterButton("Socials", "So", CommType::SOCIAL)); + localButtonsLayout->addWidget(createFilterButton("Says", "Sa", CommType::SAY)); + localButtonsLayout->addWidget(createFilterButton("Yells", "Ye", CommType::YELL)); + localLayout->addLayout(localButtonsLayout); + + // Global group + auto *globalGroup = new QWidget(); + auto *globalLayout = new QVBoxLayout(globalGroup); + globalLayout->setContentsMargins(0, 0, 0, 0); + globalLayout->setSpacing(2); + + auto *globalLabel = new QLabel("Global"); + globalLabel->setAlignment(Qt::AlignCenter); + globalLayout->addWidget(globalLabel); + + auto *globalButtonsLayout = new QHBoxLayout(); + globalButtonsLayout->setSpacing(2); + globalButtonsLayout->addWidget(createFilterButton("Tales", "Ta", CommType::NARRATE)); + globalButtonsLayout->addWidget(createFilterButton("Songs", "Sn", CommType::SING)); + globalButtonsLayout->addWidget(createFilterButton("Prayers", "Pr", CommType::PRAY)); + globalLayout->addLayout(globalButtonsLayout); + + // Add groups to top layout + topLayout->addWidget(directGroup); + topLayout->addWidget(localGroup); + topLayout->addWidget(globalGroup); + topLayout->addStretch(); + + // C&M toggle button (Characters and Mobs) + m_charMobToggle = new QToolButton(); + m_charMobToggle->setText("C&M"); + m_charMobToggle->setToolTip("Toggle between Characters, Mobs, or All"); + m_charMobToggle->setMaximumSize(32, 24); + connect(m_charMobToggle, &QToolButton::clicked, this, &CommsWidget::slot_onCharMobToggle); + updateCharMobButtonAppearance(); + topLayout->addWidget(m_charMobToggle); + + mainLayout->addLayout(topLayout); + + // Main text display + m_textDisplay = new QTextEdit(); + m_textDisplay->setReadOnly(true); + m_textDisplay->setLineWrapMode(QTextEdit::WidgetWidth); + m_textDisplay->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); + mainLayout->addWidget(m_textDisplay); + + setLayout(mainLayout); +} + +void CommsWidget::connectSignals() +{ + // Connect to CommsManager signals + connect(&m_commsManager, + &CommsManager::sig_newMessage, + this, + &CommsWidget::slot_onNewMessage); +} + +void CommsWidget::slot_loadSettings() +{ + const auto &comms = getConfig().comms; + + // Apply background color + QPalette palette = m_textDisplay->palette(); + palette.setColor(QPalette::Base, comms.backgroundColor.get()); + m_textDisplay->setPalette(palette); + + // Refresh display to apply any color/style changes + rebuildDisplay(); +} + +void CommsWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateButtonLabels(); +} + +void CommsWidget::updateButtonLabels() +{ + const int availableWidth = width(); + const bool useShortLabels = availableWidth < 600; // Threshold for switching to short labels + + for (auto it = m_filterButtons.constBegin(); it != m_filterButtons.constEnd(); ++it) { + auto *btn = it.value(); + const QString fullLabel = btn->property("fullLabel").toString(); + const QString shortLabel = btn->property("shortLabel").toString(); + btn->setText(useShortLabels ? shortLabel : fullLabel); + } +} + +void CommsWidget::slot_onFilterToggled(CommType type, bool enabled) +{ + m_filterStates[type] = enabled; + updateFilterButtonAppearance(m_filterButtons[type], enabled); + rebuildDisplay(); +} + +void CommsWidget::slot_onCharMobToggle() +{ + // Cycle through: C&M -> C -> M -> C&M + switch (m_charMobFilter) { + case CharMobFilterEnum::BOTH: + m_charMobFilter = CharMobFilterEnum::CHAR_ONLY; + break; + case CharMobFilterEnum::CHAR_ONLY: + m_charMobFilter = CharMobFilterEnum::MOB_ONLY; + break; + case CharMobFilterEnum::MOB_ONLY: + m_charMobFilter = CharMobFilterEnum::BOTH; + break; + } + + updateCharMobButtonAppearance(); + rebuildDisplay(); +} + +void CommsWidget::rebuildDisplay() +{ + m_textDisplay->clear(); + for (const auto &cached : m_messageCache) { + if (!isMessageFiltered(cached.msg)) { + appendFormattedMessage(cached.msg); + } + } +} + +void CommsWidget::slot_onNewMessage(const CommMessage &msg) +{ + // Cache the message (always with timestamp) + m_messageCache.append({msg}); + + // Limit cache size + while (m_messageCache.size() > MAX_MESSAGES) { + m_messageCache.removeFirst(); + } + + // Only display if not filtered + if (!isMessageFiltered(msg)) { + appendFormattedMessage(msg); + } +} + +void CommsWidget::appendFormattedMessage(const CommMessage &msg) +{ + QTextCursor cursor(m_textDisplay->document()); + cursor.movePosition(QTextCursor::End); + + // Add timestamp if enabled + if (getConfig().comms.showTimestamps.get()) { + QTextCharFormat timestampFormat; + timestampFormat.setForeground(QColor(128, 128, 128)); // Gray + cursor.setCharFormat(timestampFormat); + cursor.insertText(QString("[%1] ").arg(msg.timestamp)); + } + + // Strip ANSI codes first + QString originalSender = stripAnsiCodes(msg.sender); + QString message = stripAnsiCodes(msg.message); + + // Clean sender name (remove articles, capitalize) + QString cleanedSender = cleanSenderName(originalSender); + + // Check if message already contains formatting (double caption issue) + // Check against ORIGINAL sender name before cleaning + if (message.startsWith(originalSender, Qt::CaseInsensitive)) { + // Message already contains the full formatted text + // We need to replace the original sender with cleaned sender and use that + QTextCharFormat nameFormat; + nameFormat.setForeground(getColorForTalker(msg.talkerType)); // Use talker color for names + nameFormat.setFontWeight(QFont::Bold); + + QTextCharFormat textFormat; + textFormat.setForeground(getColorForType(msg.type)); + textFormat.setFontWeight(QFont::Normal); + + // Apply italic for whispers/emotes/socials if configured + if ((msg.type == CommType::WHISPER && getConfig().comms.whisperItalic.get()) + || ((msg.type == CommType::EMOTE || msg.type == CommType::SOCIAL) && getConfig().comms.emoteItalic.get())) { + textFormat.setFontItalic(true); + nameFormat.setFontItalic(true); // Name also italic for emotes/socials + } + + // Insert cleaned sender name in bold + cursor.setCharFormat(nameFormat); + cursor.insertText(cleanedSender); + + // Insert rest of message (skip original sender part) + cursor.setCharFormat(textFormat); + cursor.insertText(message.mid(originalSender.length()) + "\n"); + } else { + // Format the message ourselves + formatAndInsertMessage(cursor, msg, cleanedSender, message); + } + + // Auto-scroll to bottom + m_textDisplay->verticalScrollBar()->setValue(m_textDisplay->verticalScrollBar()->maximum()); +} + +void CommsWidget::formatAndInsertMessage(QTextCursor &cursor, const CommMessage &msg, const QString &sender, const QString &message) +{ + // Apply transformations + QString finalMessage = message; + if (msg.type == CommType::YELL && getConfig().comms.yellAllCaps.get()) { + // Only uppercase the message text, not qualifier prefix like "[faintly from below]" + static const QRegularExpression qualifierPrefix(R"(^(\[.+?\] )(.*)$)"); + auto match = qualifierPrefix.match(finalMessage); + if (match.hasMatch()) { + // Keep prefix lowercase, uppercase the message + finalMessage = match.captured(1) + match.captured(2).toUpper(); + } else { + // No prefix, uppercase everything + finalMessage = finalMessage.toUpper(); + } + } + + // Format: [Bold Name] verb: 'message' + QTextCharFormat nameFormat; + nameFormat.setForeground(getColorForTalker(msg.talkerType)); // Use talker color for name + nameFormat.setFontWeight(QFont::Bold); // Only name is bold + + QTextCharFormat textFormat; + textFormat.setForeground(getColorForType(msg.type)); + textFormat.setFontWeight(QFont::Normal); // Rest is normal + + // Apply italic for whispers/emotes if configured + if ((msg.type == CommType::WHISPER && getConfig().comms.whisperItalic.get()) + || ((msg.type == CommType::EMOTE || msg.type == CommType::SOCIAL) && getConfig().comms.emoteItalic.get())) { + textFormat.setFontItalic(true); + } + + // Simplified format: "Name: 'message'" for all types + if (msg.type == CommType::PRAY) { + // Special case for prayer (no sender from others) + cursor.setCharFormat(nameFormat); + cursor.insertText("You"); + cursor.setCharFormat(textFormat); + cursor.insertText(QString(": %1\n").arg(finalMessage)); + } else { + // Standard format + cursor.setCharFormat(nameFormat); + cursor.insertText(sender); + cursor.setCharFormat(textFormat); + + // For emotes and socials, no colon (just "Name message") + if (msg.type == CommType::EMOTE || msg.type == CommType::SOCIAL) { + cursor.insertText(QString(" %1\n").arg(finalMessage)); + } else { + // All other types: "Name: 'message'" + cursor.insertText(QString(": %1\n").arg(finalMessage)); + } + } +} + +QString CommsWidget::stripAnsiCodes(const QString &text) +{ + // ANSI escape sequence pattern: \x1b[...m or similar + static const QRegularExpression ansiPattern(R"(\x1b\[[0-9;]*m)"); + QString cleaned = text; + cleaned.remove(ansiPattern); + + // Also handle the ∂[ format seen in the screenshot + static const QRegularExpression altAnsiPattern(R"(∂\[[0-9;]*m?)"); + cleaned.remove(altAnsiPattern); + + return cleaned; +} + +QString CommsWidget::cleanSenderName(const QString &sender) +{ + QString cleaned = sender; + + // Remove leading articles (case insensitive) + static const QRegularExpression articlePattern(R"(^(an?)\s+)", QRegularExpression::CaseInsensitiveOption); + cleaned.remove(articlePattern); + + // Capitalize first letter + if (!cleaned.isEmpty() && cleaned[0].isLetter()) { + cleaned[0] = cleaned[0].toUpper(); + } + + return cleaned; +} + +QColor CommsWidget::getColorForType(CommType type) +{ + const auto &comms = getConfig().comms; + + switch (type) { + case CommType::TELL: + return comms.tellColor.get(); + case CommType::WHISPER: + return comms.whisperColor.get(); + case CommType::GROUP: + return comms.groupColor.get(); + case CommType::ASK: + return comms.askColor.get(); + case CommType::SAY: + return comms.sayColor.get(); + case CommType::EMOTE: + return comms.emoteColor.get(); + case CommType::SOCIAL: + return comms.socialColor.get(); + case CommType::YELL: + return comms.yellColor.get(); + case CommType::NARRATE: + return comms.narrateColor.get(); + case CommType::PRAY: + return comms.prayColor.get(); + case CommType::SHOUT: + return comms.shoutColor.get(); + case CommType::SING: + return comms.singColor.get(); + default: + return Qt::white; + } +} + +QColor CommsWidget::getColorForTalker(TalkerType talkerType) +{ + const auto &comms = getConfig().comms; + + switch (talkerType) { + case TalkerType::YOU: + return comms.talkerYouColor.get(); + case TalkerType::PLAYER: + return comms.talkerPlayerColor.get(); + case TalkerType::NPC: + return comms.talkerNpcColor.get(); + case TalkerType::ALLY: + return comms.talkerAllyColor.get(); + case TalkerType::NEUTRAL: + return comms.talkerNeutralColor.get(); + case TalkerType::ENEMY: + return comms.talkerEnemyColor.get(); + default: + return Qt::white; + } +} + +bool CommsWidget::isMessageFiltered(const CommMessage &msg) const +{ + // Check type filter + if (!m_filterStates.value(msg.type, true)) { + return true; // Filtered out (muted) + } + + // Check character/mob filter + switch (m_charMobFilter) { + case CharMobFilterEnum::CHAR_ONLY: + if (msg.talkerType == TalkerType::NPC) { + return true; // Filter out NPCs + } + break; + case CharMobFilterEnum::MOB_ONLY: + if (msg.talkerType != TalkerType::NPC) { + return true; // Filter out characters + } + break; + case CharMobFilterEnum::BOTH: + // Show all + break; + } + + return false; +} + +void CommsWidget::updateFilterButtonAppearance(QPushButton *button, bool enabled) +{ + if (enabled) { + // Normal appearance (not muted) + button->setStyleSheet(""); + } else { + // Red appearance (muted) + button->setStyleSheet("QPushButton { background-color: #8B0000; color: white; }"); + } +} + +void CommsWidget::updateCharMobButtonAppearance() +{ + switch (m_charMobFilter) { + case CharMobFilterEnum::BOTH: + m_charMobToggle->setText("C&M"); + m_charMobToggle->setToolTip("Showing both Characters and Mobs"); + break; + case CharMobFilterEnum::CHAR_ONLY: + m_charMobToggle->setText("C"); + m_charMobToggle->setToolTip("Showing Characters only"); + break; + case CharMobFilterEnum::MOB_ONLY: + m_charMobToggle->setText("M"); + m_charMobToggle->setToolTip("Showing Mobs only"); + break; + } +} + +void CommsWidget::slot_saveLog() +{ + struct NODISCARD Result + { + QStringList filenames; + bool isHtml = false; + }; + const auto getFileNames = [this]() -> Result { + auto save = std::make_unique(this, "Choose communications log file name ..."); + save->setFileMode(QFileDialog::AnyFile); + save->setDirectory(QDir::current()); + save->setNameFilters(QStringList() << "Text log (*.log *.txt)" + << "HTML log (*.htm *.html)"); + save->setDefaultSuffix("txt"); + save->setAcceptMode(QFileDialog::AcceptSave); + + if (save->exec() == QDialog::Accepted) { + const QString nameFilter = save->selectedNameFilter().toLower(); + const bool isHtml = nameFilter.endsWith(".htm") || nameFilter.endsWith(".html"); + return Result{save->selectedFiles(), isHtml}; + } + + return Result{}; + }; + + const auto result = getFileNames(); + const auto &fileNames = result.filenames; + + if (fileNames.isEmpty()) { + return; + } + + QFile document(fileNames[0]); + if (!document.open(QFile::WriteOnly | QFile::Text)) { + QMessageBox::warning(this, + tr("Save Error"), + tr("Error occurred while opening %1").arg(document.fileName())); + return; + } + + const auto getDocStringUtf8 = [](const QTextDocument *const pDoc, + const bool isHtml) -> QByteArray { + const QString string = isHtml ? pDoc->toHtml() : pDoc->toPlainText(); + return string.toUtf8(); + }; + document.write(getDocStringUtf8(m_textDisplay->document(), result.isHtml)); + document.close(); +} + +void CommsWidget::slot_saveLogOnExit() +{ + const auto &comms = getConfig().comms; + if (!comms.saveLogOnExit.get()) { + return; + } + + // Use the same directory as AutoLogger + const auto &autoLogConfig = getConfig().autoLog; + QString logDir = autoLogConfig.autoLogDirectory; + if (logDir.isEmpty()) { + logDir = QDir::current().path(); + } + + // Create directory if it doesn't exist + QDir dir(logDir); + if (!dir.exists()) { + dir.mkpath("."); + } + + // Generate filename matching AutoLogger format: Comms_Log_{date}_{filenum}_{runId}.txt + QString fileName; + if (m_autoLogger) { + // Note: getCurrentFileNumber() returns the NEXT file number, so we subtract 1 + // to get the current file number that matches the active MMapper log + const int currentFileNum = std::max(0, m_autoLogger->getCurrentFileNumber() - 1); + fileName = QString("Comms_Log_%1_%2_%3.txt") + .arg(QDate::currentDate().toString("yyyy_MM_dd")) + .arg(QString::number(currentFileNum)) + .arg(mmqt::toQStringUtf8(m_autoLogger->getRunId())); + } else { + // Fallback if AutoLogger is not available + const QString timestamp = QDateTime::currentDateTime().toString("yyyy_MM_dd_HH_mm_ss"); + fileName = QString("Comms_Log_%1.txt").arg(timestamp); + } + + const QString fullPath = dir.filePath(fileName); + QFile document(fullPath); + if (!document.open(QFile::WriteOnly | QFile::Text)) { + qWarning() << "Failed to save communications log to" << fullPath; + return; + } + + const QString plainText = m_textDisplay->document()->toPlainText(); + document.write(plainText.toUtf8()); + document.close(); + qInfo() << "Communications log saved to" << fullPath; +} diff --git a/src/comms/CommsWidget.h b/src/comms/CommsWidget.h new file mode 100644 index 000000000..c0014e6fd --- /dev/null +++ b/src/comms/CommsWidget.h @@ -0,0 +1,81 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../global/utils.h" +#include "CommsManager.h" + +class AutoLogger; + +enum class NODISCARD CharMobFilterEnum : uint8_t { BOTH, CHAR_ONLY, MOB_ONLY }; + +class CommsWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit CommsWidget(CommsManager &commsManager, AutoLogger *autoLogger, QWidget *parent); + ~CommsWidget() override; + + DELETE_CTORS_AND_ASSIGN_OPS(CommsWidget); + +public slots: + void slot_onNewMessage(const CommMessage &msg); + void slot_loadSettings(); + void slot_saveLog(); + void slot_saveLogOnExit(); + +protected: + void resizeEvent(QResizeEvent *event) override; + +private slots: + void slot_onFilterToggled(CommType type, bool enabled); + void slot_onCharMobToggle(); + +private: + void setupUI(); + void connectSignals(); + void appendFormattedMessage(const CommMessage &msg); + void formatAndInsertMessage(QTextCursor &cursor, const CommMessage &msg, const QString &sender, const QString &message); + QString stripAnsiCodes(const QString &text); + QString cleanSenderName(const QString &sender); + QColor getColorForType(CommType type); + QColor getColorForTalker(TalkerType talkerType); + bool isMessageFiltered(const CommMessage &msg) const; + void updateFilterButtonAppearance(QPushButton *button, bool enabled); + void updateCharMobButtonAppearance(); + void rebuildDisplay(); + void updateButtonLabels(); + + // Reference to data source + CommsManager &m_commsManager; + AutoLogger *m_autoLogger = nullptr; + + // UI components + QTextEdit *m_textDisplay = nullptr; + QToolButton *m_charMobToggle = nullptr; + + // Filter buttons by type + QMap m_filterButtons; + QMap m_filterStates; // true = show, false = muted/filtered + + CharMobFilterEnum m_charMobFilter = CharMobFilterEnum::BOTH; + + // Message cache for re-filtering + struct CachedMessage { + CommMessage msg; + }; + QList m_messageCache; + + // Maximum messages to keep in cache + static constexpr int MAX_MESSAGES = 1024; +}; diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 1541e3de1..3000e80d3 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -7,6 +7,7 @@ #include "configuration.h" #include "../global/utils.h" +#include "../map/infomark.h" #include #include @@ -52,6 +53,25 @@ NODISCARD const char *getPlatformEditor() } } +NODISCARD TextureSetEnum intToTextureSet(int value) +{ + switch (value) { + case 0: + return TextureSetEnum::CLASSIC; + case 1: + return TextureSetEnum::MODERN; + case 2: + return TextureSetEnum::CUSTOM; + default: + return TextureSetEnum::MODERN; // Default to Modern + } +} + +NODISCARD int textureSetToInt(TextureSetEnum value) +{ + return static_cast(value); +} + } // namespace Configuration::Configuration() @@ -194,10 +214,12 @@ ConstString GRP_ACCOUNT = "Account"; ConstString GRP_AUTO_LOAD_WORLD = "Auto load world"; ConstString GRP_AUTO_LOG = "Auto log"; ConstString GRP_CANVAS = "Canvas"; +ConstString GRP_COMMS = "Communications"; ConstString GRP_CONNECTION = "Connection"; ConstString GRP_FINDROOMS_DIALOG = "FindRooms Dialog"; ConstString GRP_GENERAL = "General"; ConstString GRP_GROUP_MANAGER = "Group Manager"; +ConstString GRP_HOTKEYS = "Hotkeys"; ConstString GRP_INFOMARKS_DIALOG = "InfoMarks Dialog"; ConstString GRP_INTEGRATED_MUD_CLIENT = "Integrated Mud Client"; ConstString GRP_MUME_CLIENT_PROTOCOL = "Mume client protocol"; @@ -227,9 +249,12 @@ ConstString KEY_CONNECTION_NORMAL_COLOR = "Connection normal color"; ConstString KEY_CORRECT_POSITION_BONUS = "correct position bonus"; ConstString KEY_DISPLAY_XP_STATUS = "Display XP status bar widget"; ConstString KEY_DISPLAY_CLOCK = "Display clock"; +ConstString KEY_GMCP_BROADCAST_CLOCK = "GMCP broadcast clock"; +ConstString KEY_GMCP_BROADCAST_INTERVAL = "GMCP broadcast interval"; ConstString KEY_DRAW_DOOR_NAMES = "Draw door names"; ConstString KEY_DRAW_NOT_MAPPED_EXITS = "Draw not mapped exits"; ConstString KEY_DRAW_UPPER_LAYERS_TEXTURED = "Draw upper layers textured"; +ConstString KEY_LAYER_TRANSPARENCY = "Layer transparency"; ConstString KEY_EMOJI_ENCODE = "encode emoji"; ConstString KEY_EMOJI_DECODE = "decode emoji"; ConstString KEY_EMULATED_EXITS = "Emulated Exits"; @@ -256,6 +281,73 @@ ConstString KEY_3D_FOV = "canvas.advanced.fov"; ConstString KEY_3D_VERTICAL_ANGLE = "canvas.advanced.verticalAngle"; ConstString KEY_3D_HORIZONTAL_ANGLE = "canvas.advanced.horizontalAngle"; ConstString KEY_3D_LAYER_HEIGHT = "canvas.advanced.layerHeight"; +ConstString KEY_BACKGROUND_IMAGE_ENABLED = "canvas.advanced.backgroundImageEnabled"; +ConstString KEY_BACKGROUND_IMAGE_PATH = "canvas.advanced.backgroundImagePath"; +ConstString KEY_BACKGROUND_IMAGE_FIT_MODE = "canvas.advanced.backgroundFitMode"; +ConstString KEY_BACKGROUND_IMAGE_OPACITY = "canvas.advanced.backgroundOpacity"; +ConstString KEY_BACKGROUND_IMAGE_FOCUSED_SCALE = "canvas.advanced.backgroundFocusedScale"; +ConstString KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_X = "canvas.advanced.backgroundFocusedOffsetX"; +ConstString KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_Y = "canvas.advanced.backgroundFocusedOffsetY"; +ConstString KEY_VISIBLE_MARKER_GENERIC = "canvas.visibleMarkers.generic"; +ConstString KEY_VISIBLE_MARKER_HERB = "canvas.visibleMarkers.herb"; +ConstString KEY_VISIBLE_MARKER_RIVER = "canvas.visibleMarkers.river"; +ConstString KEY_VISIBLE_MARKER_PLACE = "canvas.visibleMarkers.place"; +ConstString KEY_VISIBLE_MARKER_MOB = "canvas.visibleMarkers.mob"; +ConstString KEY_VISIBLE_MARKER_COMMENT = "canvas.visibleMarkers.comment"; +ConstString KEY_VISIBLE_MARKER_ROAD = "canvas.visibleMarkers.road"; +ConstString KEY_VISIBLE_MARKER_OBJECT = "canvas.visibleMarkers.object"; +ConstString KEY_VISIBLE_MARKER_ACTION = "canvas.visibleMarkers.action"; +ConstString KEY_VISIBLE_MARKER_LOCALITY = "canvas.visibleMarkers.locality"; +ConstString KEY_VISIBLE_CONNECTIONS = "canvas.visibilityFilter.connections"; + +// Hotkey configuration keys +ConstString KEY_HOTKEY_FILE_OPEN = "hotkeys.fileOpen"; +ConstString KEY_HOTKEY_FILE_SAVE = "hotkeys.fileSave"; +ConstString KEY_HOTKEY_FILE_RELOAD = "hotkeys.fileReload"; +ConstString KEY_HOTKEY_FILE_QUIT = "hotkeys.fileQuit"; +ConstString KEY_HOTKEY_EDIT_UNDO = "hotkeys.editUndo"; +ConstString KEY_HOTKEY_EDIT_REDO = "hotkeys.editRedo"; +ConstString KEY_HOTKEY_EDIT_PREFERENCES = "hotkeys.editPreferences"; +ConstString KEY_HOTKEY_EDIT_PREFERENCES_ALT = "hotkeys.editPreferencesAlt"; +ConstString KEY_HOTKEY_EDIT_FIND_ROOMS = "hotkeys.editFindRooms"; +ConstString KEY_HOTKEY_EDIT_ROOM = "hotkeys.editRoom"; +ConstString KEY_HOTKEY_VIEW_ZOOM_IN = "hotkeys.viewZoomIn"; +ConstString KEY_HOTKEY_VIEW_ZOOM_OUT = "hotkeys.viewZoomOut"; +ConstString KEY_HOTKEY_VIEW_ZOOM_RESET = "hotkeys.viewZoomReset"; +ConstString KEY_HOTKEY_VIEW_LAYER_UP = "hotkeys.viewLayerUp"; +ConstString KEY_HOTKEY_VIEW_LAYER_DOWN = "hotkeys.viewLayerDown"; +ConstString KEY_HOTKEY_VIEW_LAYER_RESET = "hotkeys.viewLayerReset"; +ConstString KEY_HOTKEY_VIEW_RADIAL_TRANSPARENCY = "hotkeys.viewRadialTransparency"; +ConstString KEY_HOTKEY_VIEW_STATUS_BAR = "hotkeys.viewStatusBar"; +ConstString KEY_HOTKEY_VIEW_SCROLL_BARS = "hotkeys.viewScrollBars"; +ConstString KEY_HOTKEY_VIEW_MENU_BAR = "hotkeys.viewMenuBar"; +ConstString KEY_HOTKEY_VIEW_ALWAYS_ON_TOP = "hotkeys.viewAlwaysOnTop"; +ConstString KEY_HOTKEY_PANEL_LOG = "hotkeys.panelLog"; +ConstString KEY_HOTKEY_PANEL_CLIENT = "hotkeys.panelClient"; +ConstString KEY_HOTKEY_PANEL_GROUP = "hotkeys.panelGroup"; +ConstString KEY_HOTKEY_PANEL_ROOM = "hotkeys.panelRoom"; +ConstString KEY_HOTKEY_PANEL_ADVENTURE = "hotkeys.panelAdventure"; +ConstString KEY_HOTKEY_PANEL_COMMS = "hotkeys.panelComms"; +ConstString KEY_HOTKEY_PANEL_DESCRIPTION = "hotkeys.panelDescription"; +ConstString KEY_HOTKEY_MODE_MOVE_MAP = "hotkeys.modeMoveMap"; +ConstString KEY_HOTKEY_MODE_RAYPICK = "hotkeys.modeRaypick"; +ConstString KEY_HOTKEY_MODE_SELECT_ROOMS = "hotkeys.modeSelectRooms"; +ConstString KEY_HOTKEY_MODE_SELECT_MARKERS = "hotkeys.modeSelectMarkers"; +ConstString KEY_HOTKEY_MODE_SELECT_CONNECTION = "hotkeys.modeSelectConnection"; +ConstString KEY_HOTKEY_MODE_CREATE_MARKER = "hotkeys.modeCreateMarker"; +ConstString KEY_HOTKEY_MODE_CREATE_ROOM = "hotkeys.modeCreateRoom"; +ConstString KEY_HOTKEY_MODE_CREATE_CONNECTION = "hotkeys.modeCreateConnection"; +ConstString KEY_HOTKEY_MODE_CREATE_ONEWAY_CONNECTION = "hotkeys.modeCreateOnewayConnection"; +ConstString KEY_HOTKEY_ROOM_CREATE = "hotkeys.roomCreate"; +ConstString KEY_HOTKEY_ROOM_MOVE_UP = "hotkeys.roomMoveUp"; +ConstString KEY_HOTKEY_ROOM_MOVE_DOWN = "hotkeys.roomMoveDown"; +ConstString KEY_HOTKEY_ROOM_MERGE_UP = "hotkeys.roomMergeUp"; +ConstString KEY_HOTKEY_ROOM_MERGE_DOWN = "hotkeys.roomMergeDown"; +ConstString KEY_HOTKEY_ROOM_DELETE = "hotkeys.roomDelete"; +ConstString KEY_HOTKEY_ROOM_CONNECT_NEIGHBORS = "hotkeys.roomConnectNeighbors"; +ConstString KEY_HOTKEY_ROOM_MOVE_TO_SELECTED = "hotkeys.roomMoveToSelected"; +ConstString KEY_HOTKEY_ROOM_UPDATE_SELECTED = "hotkeys.roomUpdateSelected"; + ConstString KEY_LAST_MAP_LOAD_DIRECTORY = "Last map load directory"; ConstString KEY_LINES_OF_INPUT_HISTORY = "Lines of input history"; ConstString KEY_LINES_OF_PEEK_PREVIEW = "Lines of peek preview"; @@ -270,6 +362,8 @@ ConstString KEY_PROXY_CONNECTION_STATUS = "Proxy connection status"; ConstString KEY_PROXY_LISTENS_ON_ANY_INTERFACE = "Proxy listens on any interface"; ConstString KEY_RELATIVE_PATH_ACCEPTANCE = "relative path acceptance"; ConstString KEY_RESOURCES_DIRECTORY = "canvas.resourcesDir"; +ConstString KEY_TEXTURE_SET = "canvas.textureSet"; +ConstString KEY_ENABLE_SEASONAL_TEXTURES = "canvas.enableSeasonalTextures"; ConstString KEY_MUME_REMOTE_PORT = "Remote port number"; ConstString KEY_REMEMBER_LOGIN = "remember login"; ConstString KEY_ROOM_CREATION_PENALTY = "room creation penalty"; @@ -441,6 +535,8 @@ NODISCARD static uint16_t sanitizeUint16(const int input, const uint16_t default GROUP_CALLBACK(callback, GRP_GENERAL, general); \ GROUP_CALLBACK(callback, GRP_CONNECTION, connection); \ GROUP_CALLBACK(callback, GRP_CANVAS, canvas); \ + GROUP_CALLBACK(callback, GRP_HOTKEYS, hotkeys); \ + GROUP_CALLBACK(callback, GRP_COMMS, comms); \ GROUP_CALLBACK(callback, GRP_ACCOUNT, account); \ GROUP_CALLBACK(callback, GRP_AUTO_LOAD_WORLD, autoLoad); \ GROUP_CALLBACK(callback, GRP_AUTO_LOG, autoLog); \ @@ -565,7 +661,7 @@ void Configuration::ConnectionSettings::read(const QSettings &conf) } // closest well-known color is "Outer Space" -static constexpr const std::string_view DEFAULT_BGCOLOR = "#2E3436"; +static constexpr const std::string_view DEFAULT_BGCOLOR = "#161f21"; // closest well-known color is "Dusty Gray" static constexpr const std::string_view DEFAULT_DARK_COLOR = "#A19494"; // closest well-known color is "Cold Turkey" @@ -587,11 +683,14 @@ void Configuration::CanvasSettings::read(const QSettings &conf) .append(DEFAULT_MMAPPER_SUBDIR) .append(DEFAULT_RESOURCES_SUBDIR)) .toString(); + textureSet = intToTextureSet(conf.value(KEY_TEXTURE_SET, 1).toInt()); // Default: MODERN + enableSeasonalTextures = conf.value(KEY_ENABLE_SEASONAL_TEXTURES, true).toBool(); showMissingMapId.set(conf.value(KEY_SHOW_MISSING_MAP_ID, true).toBool()); showUnsavedChanges.set(conf.value(KEY_SHOW_UNSAVED_CHANGES, true).toBool()); showUnmappedExits.set(conf.value(KEY_DRAW_NOT_MAPPED_EXITS, true).toBool()); drawUpperLayersTextured = conf.value(KEY_DRAW_UPPER_LAYERS_TEXTURED, false).toBool(); drawDoorNames = conf.value(KEY_DRAW_DOOR_NAMES, true).toBool(); + layerTransparency = conf.value(KEY_LAYER_TRANSPARENCY, 1.0).toFloat(); backgroundColor = lookupColor(KEY_BACKGROUND_COLOR, DEFAULT_BGCOLOR); connectionNormalColor = lookupColor(KEY_CONNECTION_NORMAL_COLOR, Colors::white.toHex()); roomDarkColor = lookupColor(KEY_ROOM_DARK_COLOR, DEFAULT_DARK_COLOR); @@ -605,6 +704,30 @@ void Configuration::CanvasSettings::read(const QSettings &conf) advanced.verticalAngle.set(conf.value(KEY_3D_VERTICAL_ANGLE, 450).toInt()); advanced.horizontalAngle.set(conf.value(KEY_3D_HORIZONTAL_ANGLE, 0).toInt()); advanced.layerHeight.set(conf.value(KEY_3D_LAYER_HEIGHT, 15).toInt()); + + // Load background image settings + advanced.useBackgroundImage = conf.value(KEY_BACKGROUND_IMAGE_ENABLED, false).toBool(); + advanced.backgroundImagePath = conf.value(KEY_BACKGROUND_IMAGE_PATH, "").toString(); + advanced.backgroundFitMode = conf.value(KEY_BACKGROUND_IMAGE_FIT_MODE, 0).toInt(); + advanced.backgroundOpacity = conf.value(KEY_BACKGROUND_IMAGE_OPACITY, 1.0f).toFloat(); + advanced.backgroundFocusedScale = conf.value(KEY_BACKGROUND_IMAGE_FOCUSED_SCALE, 1.0f).toFloat(); + advanced.backgroundFocusedOffsetX = conf.value(KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_X, 0.0f) + .toFloat(); + advanced.backgroundFocusedOffsetY = conf.value(KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_Y, 0.0f) + .toFloat(); + + // Load visible markers settings + visibilityFilter.generic.set(conf.value(KEY_VISIBLE_MARKER_GENERIC, true).toBool()); + visibilityFilter.herb.set(conf.value(KEY_VISIBLE_MARKER_HERB, true).toBool()); + visibilityFilter.river.set(conf.value(KEY_VISIBLE_MARKER_RIVER, true).toBool()); + visibilityFilter.place.set(conf.value(KEY_VISIBLE_MARKER_PLACE, true).toBool()); + visibilityFilter.mob.set(conf.value(KEY_VISIBLE_MARKER_MOB, true).toBool()); + visibilityFilter.comment.set(conf.value(KEY_VISIBLE_MARKER_COMMENT, true).toBool()); + visibilityFilter.road.set(conf.value(KEY_VISIBLE_MARKER_ROAD, true).toBool()); + visibilityFilter.object.set(conf.value(KEY_VISIBLE_MARKER_OBJECT, true).toBool()); + visibilityFilter.action.set(conf.value(KEY_VISIBLE_MARKER_ACTION, true).toBool()); + visibilityFilter.locality.set(conf.value(KEY_VISIBLE_MARKER_LOCALITY, true).toBool()); + visibilityFilter.connections.set(conf.value(KEY_VISIBLE_CONNECTIONS, true).toBool()); } void Configuration::AccountSettings::read(const QSettings &conf) @@ -691,11 +814,15 @@ void Configuration::GroupManagerSettings::read(const QSettings &conf) npcSortBottom = conf.value(KEY_GROUP_NPC_SORT_BOTTOM, false).toBool(); } +Configuration::MumeClockSettings::MumeClockSettings() = default; + void Configuration::MumeClockSettings::read(const QSettings &conf) { // NOTE: old values might be stored as int32 startEpoch = conf.value(KEY_MUME_START_EPOCH, 1517443173).toLongLong(); display = conf.value(KEY_DISPLAY_CLOCK, true).toBool(); + gmcpBroadcast.set(conf.value(KEY_GMCP_BROADCAST_CLOCK, true).toBool()); + gmcpBroadcastInterval.set(conf.value(KEY_GMCP_BROADCAST_INTERVAL, 2500).toInt()); } void Configuration::AdventurePanelSettings::read(const QSettings &conf) @@ -770,11 +897,14 @@ NODISCARD static auto getQColorName(const XNamedColor &color) void Configuration::CanvasSettings::write(QSettings &conf) const { conf.setValue(KEY_RESOURCES_DIRECTORY, resourcesDirectory); + conf.setValue(KEY_TEXTURE_SET, textureSetToInt(textureSet)); + conf.setValue(KEY_ENABLE_SEASONAL_TEXTURES, enableSeasonalTextures); conf.setValue(KEY_SHOW_MISSING_MAP_ID, showMissingMapId.get()); conf.setValue(KEY_SHOW_UNSAVED_CHANGES, showUnsavedChanges.get()); conf.setValue(KEY_DRAW_NOT_MAPPED_EXITS, showUnmappedExits.get()); conf.setValue(KEY_DRAW_UPPER_LAYERS_TEXTURED, drawUpperLayersTextured); conf.setValue(KEY_DRAW_DOOR_NAMES, drawDoorNames); + conf.setValue(KEY_LAYER_TRANSPARENCY, layerTransparency); conf.setValue(KEY_BACKGROUND_COLOR, getQColorName(backgroundColor)); conf.setValue(KEY_ROOM_DARK_COLOR, getQColorName(roomDarkColor)); conf.setValue(KEY_ROOM_DARK_LIT_COLOR, getQColorName(roomDarkLitColor)); @@ -788,6 +918,239 @@ void Configuration::CanvasSettings::write(QSettings &conf) const conf.setValue(KEY_3D_VERTICAL_ANGLE, advanced.verticalAngle.get()); conf.setValue(KEY_3D_HORIZONTAL_ANGLE, advanced.horizontalAngle.get()); conf.setValue(KEY_3D_LAYER_HEIGHT, advanced.layerHeight.get()); + + // Save background image settings + conf.setValue(KEY_BACKGROUND_IMAGE_ENABLED, advanced.useBackgroundImage); + conf.setValue(KEY_BACKGROUND_IMAGE_PATH, advanced.backgroundImagePath); + conf.setValue(KEY_BACKGROUND_IMAGE_FIT_MODE, advanced.backgroundFitMode); + conf.setValue(KEY_BACKGROUND_IMAGE_OPACITY, advanced.backgroundOpacity); + conf.setValue(KEY_BACKGROUND_IMAGE_FOCUSED_SCALE, advanced.backgroundFocusedScale); + conf.setValue(KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_X, advanced.backgroundFocusedOffsetX); + conf.setValue(KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_Y, advanced.backgroundFocusedOffsetY); + + // Save visible markers settings + conf.setValue(KEY_VISIBLE_MARKER_GENERIC, visibilityFilter.generic.get()); + conf.setValue(KEY_VISIBLE_MARKER_HERB, visibilityFilter.herb.get()); + conf.setValue(KEY_VISIBLE_MARKER_RIVER, visibilityFilter.river.get()); + conf.setValue(KEY_VISIBLE_MARKER_PLACE, visibilityFilter.place.get()); + conf.setValue(KEY_VISIBLE_MARKER_MOB, visibilityFilter.mob.get()); + conf.setValue(KEY_VISIBLE_MARKER_COMMENT, visibilityFilter.comment.get()); + conf.setValue(KEY_VISIBLE_MARKER_ROAD, visibilityFilter.road.get()); + conf.setValue(KEY_VISIBLE_MARKER_OBJECT, visibilityFilter.object.get()); + conf.setValue(KEY_VISIBLE_MARKER_ACTION, visibilityFilter.action.get()); + conf.setValue(KEY_VISIBLE_MARKER_LOCALITY, visibilityFilter.locality.get()); + conf.setValue(KEY_VISIBLE_CONNECTIONS, visibilityFilter.connections.get()); +} + +void Configuration::Hotkeys::read(const QSettings &conf) +{ + // File operations + fileOpen.set(conf.value(KEY_HOTKEY_FILE_OPEN, "Ctrl+O").toString()); + fileSave.set(conf.value(KEY_HOTKEY_FILE_SAVE, "Ctrl+S").toString()); + fileReload.set(conf.value(KEY_HOTKEY_FILE_RELOAD, "Ctrl+R").toString()); + fileQuit.set(conf.value(KEY_HOTKEY_FILE_QUIT, "Ctrl+Q").toString()); + + // Edit operations + editUndo.set(conf.value(KEY_HOTKEY_EDIT_UNDO, "Ctrl+Z").toString()); + editRedo.set(conf.value(KEY_HOTKEY_EDIT_REDO, "Ctrl+Y").toString()); + editPreferences.set(conf.value(KEY_HOTKEY_EDIT_PREFERENCES, "Ctrl+P").toString()); + editPreferencesAlt.set(conf.value(KEY_HOTKEY_EDIT_PREFERENCES_ALT, "Esc").toString()); + editFindRooms.set(conf.value(KEY_HOTKEY_EDIT_FIND_ROOMS, "Ctrl+F").toString()); + editRoom.set(conf.value(KEY_HOTKEY_EDIT_ROOM, "Ctrl+E").toString()); + + // View operations + viewZoomIn.set(conf.value(KEY_HOTKEY_VIEW_ZOOM_IN, "").toString()); + viewZoomOut.set(conf.value(KEY_HOTKEY_VIEW_ZOOM_OUT, "").toString()); + viewZoomReset.set(conf.value(KEY_HOTKEY_VIEW_ZOOM_RESET, "Ctrl+0").toString()); + viewLayerUp.set(conf.value(KEY_HOTKEY_VIEW_LAYER_UP, "").toString()); + viewLayerDown.set(conf.value(KEY_HOTKEY_VIEW_LAYER_DOWN, "").toString()); + viewLayerReset.set(conf.value(KEY_HOTKEY_VIEW_LAYER_RESET, "").toString()); + + // View toggles + viewRadialTransparency.set(conf.value(KEY_HOTKEY_VIEW_RADIAL_TRANSPARENCY, "").toString()); + viewStatusBar.set(conf.value(KEY_HOTKEY_VIEW_STATUS_BAR, "").toString()); + viewScrollBars.set(conf.value(KEY_HOTKEY_VIEW_SCROLL_BARS, "").toString()); + viewMenuBar.set(conf.value(KEY_HOTKEY_VIEW_MENU_BAR, "").toString()); + viewAlwaysOnTop.set(conf.value(KEY_HOTKEY_VIEW_ALWAYS_ON_TOP, "").toString()); + + // Side panels + panelLog.set(conf.value(KEY_HOTKEY_PANEL_LOG, "Ctrl+L").toString()); + panelClient.set(conf.value(KEY_HOTKEY_PANEL_CLIENT, "").toString()); + panelGroup.set(conf.value(KEY_HOTKEY_PANEL_GROUP, "").toString()); + panelRoom.set(conf.value(KEY_HOTKEY_PANEL_ROOM, "").toString()); + panelAdventure.set(conf.value(KEY_HOTKEY_PANEL_ADVENTURE, "").toString()); + panelComms.set(conf.value(KEY_HOTKEY_PANEL_COMMS, "").toString()); + panelDescription.set(conf.value(KEY_HOTKEY_PANEL_DESCRIPTION, "").toString()); + + // Mouse modes + modeMoveMap.set(conf.value(KEY_HOTKEY_MODE_MOVE_MAP, "").toString()); + modeRaypick.set(conf.value(KEY_HOTKEY_MODE_RAYPICK, "").toString()); + modeSelectRooms.set(conf.value(KEY_HOTKEY_MODE_SELECT_ROOMS, "").toString()); + modeSelectMarkers.set(conf.value(KEY_HOTKEY_MODE_SELECT_MARKERS, "").toString()); + modeSelectConnection.set(conf.value(KEY_HOTKEY_MODE_SELECT_CONNECTION, "").toString()); + modeCreateMarker.set(conf.value(KEY_HOTKEY_MODE_CREATE_MARKER, "").toString()); + modeCreateRoom.set(conf.value(KEY_HOTKEY_MODE_CREATE_ROOM, "").toString()); + modeCreateConnection.set(conf.value(KEY_HOTKEY_MODE_CREATE_CONNECTION, "").toString()); + modeCreateOnewayConnection.set( + conf.value(KEY_HOTKEY_MODE_CREATE_ONEWAY_CONNECTION, "").toString()); + + // Room operations + roomCreate.set(conf.value(KEY_HOTKEY_ROOM_CREATE, "").toString()); + roomMoveUp.set(conf.value(KEY_HOTKEY_ROOM_MOVE_UP, "").toString()); + roomMoveDown.set(conf.value(KEY_HOTKEY_ROOM_MOVE_DOWN, "").toString()); + roomMergeUp.set(conf.value(KEY_HOTKEY_ROOM_MERGE_UP, "").toString()); + roomMergeDown.set(conf.value(KEY_HOTKEY_ROOM_MERGE_DOWN, "").toString()); + roomDelete.set(conf.value(KEY_HOTKEY_ROOM_DELETE, "Del").toString()); + roomConnectNeighbors.set(conf.value(KEY_HOTKEY_ROOM_CONNECT_NEIGHBORS, "").toString()); + roomMoveToSelected.set(conf.value(KEY_HOTKEY_ROOM_MOVE_TO_SELECTED, "").toString()); + roomUpdateSelected.set(conf.value(KEY_HOTKEY_ROOM_UPDATE_SELECTED, "").toString()); +} + +void Configuration::Hotkeys::write(QSettings &conf) const +{ + // File operations + conf.setValue(KEY_HOTKEY_FILE_OPEN, fileOpen.get()); + conf.setValue(KEY_HOTKEY_FILE_SAVE, fileSave.get()); + conf.setValue(KEY_HOTKEY_FILE_RELOAD, fileReload.get()); + conf.setValue(KEY_HOTKEY_FILE_QUIT, fileQuit.get()); + + // Edit operations + conf.setValue(KEY_HOTKEY_EDIT_UNDO, editUndo.get()); + conf.setValue(KEY_HOTKEY_EDIT_REDO, editRedo.get()); + conf.setValue(KEY_HOTKEY_EDIT_PREFERENCES, editPreferences.get()); + conf.setValue(KEY_HOTKEY_EDIT_PREFERENCES_ALT, editPreferencesAlt.get()); + conf.setValue(KEY_HOTKEY_EDIT_FIND_ROOMS, editFindRooms.get()); + conf.setValue(KEY_HOTKEY_EDIT_ROOM, editRoom.get()); + + // View operations + conf.setValue(KEY_HOTKEY_VIEW_ZOOM_IN, viewZoomIn.get()); + conf.setValue(KEY_HOTKEY_VIEW_ZOOM_OUT, viewZoomOut.get()); + conf.setValue(KEY_HOTKEY_VIEW_ZOOM_RESET, viewZoomReset.get()); + conf.setValue(KEY_HOTKEY_VIEW_LAYER_UP, viewLayerUp.get()); + conf.setValue(KEY_HOTKEY_VIEW_LAYER_DOWN, viewLayerDown.get()); + conf.setValue(KEY_HOTKEY_VIEW_LAYER_RESET, viewLayerReset.get()); + + // View toggles + conf.setValue(KEY_HOTKEY_VIEW_RADIAL_TRANSPARENCY, viewRadialTransparency.get()); + conf.setValue(KEY_HOTKEY_VIEW_STATUS_BAR, viewStatusBar.get()); + conf.setValue(KEY_HOTKEY_VIEW_SCROLL_BARS, viewScrollBars.get()); + conf.setValue(KEY_HOTKEY_VIEW_MENU_BAR, viewMenuBar.get()); + conf.setValue(KEY_HOTKEY_VIEW_ALWAYS_ON_TOP, viewAlwaysOnTop.get()); + + // Side panels + conf.setValue(KEY_HOTKEY_PANEL_LOG, panelLog.get()); + conf.setValue(KEY_HOTKEY_PANEL_CLIENT, panelClient.get()); + conf.setValue(KEY_HOTKEY_PANEL_GROUP, panelGroup.get()); + conf.setValue(KEY_HOTKEY_PANEL_ROOM, panelRoom.get()); + conf.setValue(KEY_HOTKEY_PANEL_ADVENTURE, panelAdventure.get()); + conf.setValue(KEY_HOTKEY_PANEL_COMMS, panelComms.get()); + conf.setValue(KEY_HOTKEY_PANEL_DESCRIPTION, panelDescription.get()); + + // Mouse modes + conf.setValue(KEY_HOTKEY_MODE_MOVE_MAP, modeMoveMap.get()); + conf.setValue(KEY_HOTKEY_MODE_RAYPICK, modeRaypick.get()); + conf.setValue(KEY_HOTKEY_MODE_SELECT_ROOMS, modeSelectRooms.get()); + conf.setValue(KEY_HOTKEY_MODE_SELECT_MARKERS, modeSelectMarkers.get()); + conf.setValue(KEY_HOTKEY_MODE_SELECT_CONNECTION, modeSelectConnection.get()); + conf.setValue(KEY_HOTKEY_MODE_CREATE_MARKER, modeCreateMarker.get()); + conf.setValue(KEY_HOTKEY_MODE_CREATE_ROOM, modeCreateRoom.get()); + conf.setValue(KEY_HOTKEY_MODE_CREATE_CONNECTION, modeCreateConnection.get()); + conf.setValue(KEY_HOTKEY_MODE_CREATE_ONEWAY_CONNECTION, modeCreateOnewayConnection.get()); + + // Room operations + conf.setValue(KEY_HOTKEY_ROOM_CREATE, roomCreate.get()); + conf.setValue(KEY_HOTKEY_ROOM_MOVE_UP, roomMoveUp.get()); + conf.setValue(KEY_HOTKEY_ROOM_MOVE_DOWN, roomMoveDown.get()); + conf.setValue(KEY_HOTKEY_ROOM_MERGE_UP, roomMergeUp.get()); + conf.setValue(KEY_HOTKEY_ROOM_MERGE_DOWN, roomMergeDown.get()); + conf.setValue(KEY_HOTKEY_ROOM_DELETE, roomDelete.get()); + conf.setValue(KEY_HOTKEY_ROOM_CONNECT_NEIGHBORS, roomConnectNeighbors.get()); + conf.setValue(KEY_HOTKEY_ROOM_MOVE_TO_SELECTED, roomMoveToSelected.get()); + conf.setValue(KEY_HOTKEY_ROOM_UPDATE_SELECTED, roomUpdateSelected.get()); +} + +void Configuration::CommsSettings::read(const QSettings &conf) +{ + // Communication colors + tellColor.set(conf.value(tellColor.getName(), QColor(32, 108, 9)).value()); + whisperColor.set(conf.value(whisperColor.getName(), QColor(103, 135, 149)).value()); + groupColor.set(conf.value(groupColor.getName(), QColor(15, 123, 255)).value()); + askColor.set(conf.value(askColor.getName(), QColor(Qt::yellow)).value()); + sayColor.set(conf.value(sayColor.getName(), QColor(80, 173, 199)).value()); + emoteColor.set(conf.value(emoteColor.getName(), QColor(203, 37, 111)).value()); + socialColor.set(conf.value(socialColor.getName(), QColor(217, 140, 151)).value()); + yellColor.set(conf.value(yellColor.getName(), QColor(176, 80, 189)).value()); + narrateColor.set(conf.value(narrateColor.getName(), QColor(119, 197, 203)).value()); + prayColor.set(conf.value(prayColor.getName(), QColor(173, 216, 230)).value()); + shoutColor.set(conf.value(shoutColor.getName(), QColor(160, 9, 198)).value()); + singColor.set(conf.value(singColor.getName(), QColor(144, 238, 144)).value()); + backgroundColor.set(conf.value(backgroundColor.getName(), QColor(22, 31, 33)).value()); + + // Font styling options + yellAllCaps.set(conf.value(yellAllCaps.getName(), true).toBool()); + whisperItalic.set(conf.value(whisperItalic.getName(), true).toBool()); + emoteItalic.set(conf.value(emoteItalic.getName(), true).toBool()); + + // Display options + showTimestamps.set(conf.value(showTimestamps.getName(), false).toBool()); + saveLogOnExit.set(conf.value(saveLogOnExit.getName(), false).toBool()); + logDirectory.set(conf.value(logDirectory.getName(), QString("")).toString()); + + // Talker colors + talkerYouColor.set(conf.value(talkerYouColor.getName(), QColor(228, 250, 255)).value()); + talkerPlayerColor.set( + conf.value(talkerPlayerColor.getName(), QColor(255, 187, 16)).value()); + talkerNpcColor.set(conf.value(talkerNpcColor.getName(), QColor(25, 138, 23)).value()); + talkerAllyColor.set(conf.value(talkerAllyColor.getName(), QColor(33, 166, 255)).value()); + talkerNeutralColor.set( + conf.value(talkerNeutralColor.getName(), QColor(166, 168, 168)).value()); + talkerEnemyColor.set(conf.value(talkerEnemyColor.getName(), QColor(173, 7, 37)).value()); + + // Tab muting (filters) + muteDirectTab.set(conf.value(muteDirectTab.getName(), false).toBool()); + muteLocalTab.set(conf.value(muteLocalTab.getName(), false).toBool()); + muteGlobalTab.set(conf.value(muteGlobalTab.getName(), false).toBool()); +} + +void Configuration::CommsSettings::write(QSettings &conf) const +{ + // Communication colors + conf.setValue(tellColor.getName(), tellColor.get()); + conf.setValue(whisperColor.getName(), whisperColor.get()); + conf.setValue(groupColor.getName(), groupColor.get()); + conf.setValue(askColor.getName(), askColor.get()); + conf.setValue(sayColor.getName(), sayColor.get()); + conf.setValue(emoteColor.getName(), emoteColor.get()); + conf.setValue(socialColor.getName(), socialColor.get()); + conf.setValue(yellColor.getName(), yellColor.get()); + conf.setValue(narrateColor.getName(), narrateColor.get()); + conf.setValue(prayColor.getName(), prayColor.get()); + conf.setValue(shoutColor.getName(), shoutColor.get()); + conf.setValue(singColor.getName(), singColor.get()); + conf.setValue(backgroundColor.getName(), backgroundColor.get()); + + // Font styling options + conf.setValue(yellAllCaps.getName(), yellAllCaps.get()); + conf.setValue(whisperItalic.getName(), whisperItalic.get()); + conf.setValue(emoteItalic.getName(), emoteItalic.get()); + + // Display options + conf.setValue(showTimestamps.getName(), showTimestamps.get()); + conf.setValue(saveLogOnExit.getName(), saveLogOnExit.get()); + conf.setValue(logDirectory.getName(), logDirectory.get()); + + // Talker colors + conf.setValue(talkerYouColor.getName(), talkerYouColor.get()); + conf.setValue(talkerPlayerColor.getName(), talkerPlayerColor.get()); + conf.setValue(talkerNpcColor.getName(), talkerNpcColor.get()); + conf.setValue(talkerAllyColor.getName(), talkerAllyColor.get()); + conf.setValue(talkerNeutralColor.getName(), talkerNeutralColor.get()); + conf.setValue(talkerEnemyColor.getName(), talkerEnemyColor.get()); + + // Tab muting (filters) + conf.setValue(muteDirectTab.getName(), muteDirectTab.get()); + conf.setValue(muteLocalTab.getName(), muteLocalTab.get()); + conf.setValue(muteGlobalTab.getName(), muteGlobalTab.get()); } void Configuration::AccountSettings::write(QSettings &conf) const @@ -862,6 +1225,8 @@ void Configuration::MumeClockSettings::write(QSettings &conf) const // Note: There's no QVariant(int64_t) constructor. conf.setValue(KEY_MUME_START_EPOCH, static_cast(startEpoch)); conf.setValue(KEY_DISPLAY_CLOCK, display); + conf.setValue(KEY_GMCP_BROADCAST_CLOCK, gmcpBroadcast.get()); + conf.setValue(KEY_GMCP_BROADCAST_INTERVAL, gmcpBroadcastInterval.get()); } void Configuration::AdventurePanelSettings::write(QSettings &conf) const @@ -994,6 +1359,118 @@ void Configuration::CanvasSettings::Advanced::registerChangeCallback( layerHeight.registerChangeCallback(lifetime, callback); } +Configuration::CanvasSettings::VisibilityFilter::VisibilityFilter() = default; + +bool Configuration::CanvasSettings::VisibilityFilter::isVisible(InfomarkClassEnum markerClass) const +{ + switch (markerClass) { + case InfomarkClassEnum::GENERIC: + return generic.get(); + case InfomarkClassEnum::HERB: + return herb.get(); + case InfomarkClassEnum::RIVER: + return river.get(); + case InfomarkClassEnum::PLACE: + return place.get(); + case InfomarkClassEnum::MOB: + return mob.get(); + case InfomarkClassEnum::COMMENT: + return comment.get(); + case InfomarkClassEnum::ROAD: + return road.get(); + case InfomarkClassEnum::OBJECT: + return object.get(); + case InfomarkClassEnum::ACTION: + return action.get(); + case InfomarkClassEnum::LOCALITY: + return locality.get(); + } + return true; // Default to visible for unknown types +} + +void Configuration::CanvasSettings::VisibilityFilter::setVisible(InfomarkClassEnum markerClass, + bool visible) +{ + switch (markerClass) { + case InfomarkClassEnum::GENERIC: + generic.set(visible); + break; + case InfomarkClassEnum::HERB: + herb.set(visible); + break; + case InfomarkClassEnum::RIVER: + river.set(visible); + break; + case InfomarkClassEnum::PLACE: + place.set(visible); + break; + case InfomarkClassEnum::MOB: + mob.set(visible); + break; + case InfomarkClassEnum::COMMENT: + comment.set(visible); + break; + case InfomarkClassEnum::ROAD: + road.set(visible); + break; + case InfomarkClassEnum::OBJECT: + object.set(visible); + break; + case InfomarkClassEnum::ACTION: + action.set(visible); + break; + case InfomarkClassEnum::LOCALITY: + locality.set(visible); + break; + } +} + +void Configuration::CanvasSettings::VisibilityFilter::showAll() +{ + generic.set(true); + herb.set(true); + river.set(true); + place.set(true); + mob.set(true); + comment.set(true); + road.set(true); + object.set(true); + action.set(true); + locality.set(true); + connections.set(true); +} + +void Configuration::CanvasSettings::VisibilityFilter::hideAll() +{ + generic.set(false); + herb.set(false); + river.set(false); + place.set(false); + mob.set(false); + comment.set(false); + road.set(false); + object.set(false); + action.set(false); + locality.set(false); + connections.set(false); +} + +void Configuration::CanvasSettings::VisibilityFilter::registerChangeCallback( + const ChangeMonitor::Lifetime &lifetime, const ChangeMonitor::Function &callback) +{ + generic.registerChangeCallback(lifetime, callback); + herb.registerChangeCallback(lifetime, callback); + river.registerChangeCallback(lifetime, callback); + place.registerChangeCallback(lifetime, callback); + mob.registerChangeCallback(lifetime, callback); + comment.registerChangeCallback(lifetime, callback); + road.registerChangeCallback(lifetime, callback); + object.registerChangeCallback(lifetime, callback); + action.registerChangeCallback(lifetime, callback); + locality.registerChangeCallback(lifetime, callback); + connections.registerChangeCallback(lifetime, callback); +} + void setEnteredMain() { g_thread = std::this_thread::get_id(); diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index 58d1d1e9f..d83f60ce5 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -29,6 +29,9 @@ #undef TRANSPARENT // Bad dog, Microsoft; bad dog!!! +// Forward declaration for InfomarkClassEnum +enum class InfomarkClassEnum : uint8_t; + #define SUBGROUP() \ friend class Configuration; \ void read(const QSettings &conf); \ @@ -79,6 +82,7 @@ class NODISCARD Configuration final char prefixChar = char_consts::C_UNDERSCORE; bool encodeEmoji = true; bool decodeEmoji = true; + bool enableYellFallbackParsing = true; // Parse yells from game text when GMCP unavailable private: SUBGROUP(); @@ -143,6 +147,10 @@ class NODISCARD Configuration final bool trilinearFiltering = false; bool softwareOpenGL = false; QString resourcesDirectory; + TextureSetEnum textureSet = TextureSetEnum::MODERN; + bool enableSeasonalTextures = true; + float layerTransparency = 1.0f; // 0.0 = only focused layer, 1.0 = maximum transparency + bool enableRadialTransparency = true; // Enable radial transparency zones on upper layers // not saved yet: bool drawCharBeacons = true; @@ -159,12 +167,21 @@ class NODISCARD Configuration final NamedConfig autoTilt{"MMAPPER_AUTO_TILT", true}; NamedConfig printPerfStats{"MMAPPER_GL_PERFSTATS", IS_DEBUG_BUILD}; + // Background image settings + bool useBackgroundImage = false; + QString backgroundImagePath; + int backgroundFitMode = 0; // BackgroundFitModeEnum::FIT + float backgroundOpacity = 1.0f; + float backgroundFocusedScale = 1.0f; // Scale factor for FOCUSED mode (0.1 to 10.0) + float backgroundFocusedOffsetX = 0.0f; // X offset for FOCUSED mode (-1000 to 1000) + float backgroundFocusedOffsetY = 0.0f; // Y offset for FOCUSED mode (-1000 to 1000) + // 5..90 degrees FixedPoint<1> fov{50, 900, 765}; // 0..90 degrees FixedPoint<1> verticalAngle{0, 900, 450}; - // -45..45 degrees - FixedPoint<1> horizontalAngle{-450, 450, 0}; + // -180..180 degrees (full rotation) + FixedPoint<1> horizontalAngle{-1800, 1800, 0}; // 1..10 rooms FixedPoint<1> layerHeight{10, 100, 15}; @@ -175,10 +192,147 @@ class NODISCARD Configuration final Advanced(); } advanced; + struct NODISCARD VisibilityFilter final + { + NamedConfig generic{"VISIBLE_MARKER_GENERIC", true}; + NamedConfig herb{"VISIBLE_MARKER_HERB", true}; + NamedConfig river{"VISIBLE_MARKER_RIVER", true}; + NamedConfig place{"VISIBLE_MARKER_PLACE", true}; + NamedConfig mob{"VISIBLE_MARKER_MOB", true}; + NamedConfig comment{"VISIBLE_MARKER_COMMENT", true}; + NamedConfig road{"VISIBLE_MARKER_ROAD", true}; + NamedConfig object{"VISIBLE_MARKER_OBJECT", true}; + NamedConfig action{"VISIBLE_MARKER_ACTION", true}; + NamedConfig locality{"VISIBLE_MARKER_LOCALITY", true}; + NamedConfig connections{"VISIBLE_CONNECTIONS", true}; + + public: + NODISCARD bool isVisible(InfomarkClassEnum markerClass) const; + void setVisible(InfomarkClassEnum markerClass, bool visible); + NODISCARD bool isConnectionsVisible() const { return connections.get(); } + void setConnectionsVisible(bool visible) { connections.set(visible); } + void showAll(); + void hideAll(); + void registerChangeCallback(const ChangeMonitor::Lifetime &lifetime, + const ChangeMonitor::Function &callback); + + VisibilityFilter(); + } visibilityFilter; + private: SUBGROUP(); } canvas; + struct NODISCARD Hotkeys final + { + // File operations + NamedConfig fileOpen{"HOTKEY_FILE_OPEN", "Ctrl+O"}; + NamedConfig fileSave{"HOTKEY_FILE_SAVE", "Ctrl+S"}; + NamedConfig fileReload{"HOTKEY_FILE_RELOAD", "Ctrl+R"}; + NamedConfig fileQuit{"HOTKEY_FILE_QUIT", "Ctrl+Q"}; + + // Edit operations + NamedConfig editUndo{"HOTKEY_EDIT_UNDO", "Ctrl+Z"}; + NamedConfig editRedo{"HOTKEY_EDIT_REDO", "Ctrl+Y"}; + NamedConfig editPreferences{"HOTKEY_EDIT_PREFERENCES", "Ctrl+P"}; + NamedConfig editPreferencesAlt{"HOTKEY_EDIT_PREFERENCES_ALT", "Esc"}; + NamedConfig editFindRooms{"HOTKEY_EDIT_FIND_ROOMS", "Ctrl+F"}; + NamedConfig editRoom{"HOTKEY_EDIT_ROOM", "Ctrl+E"}; + + // View operations + NamedConfig viewZoomIn{"HOTKEY_VIEW_ZOOM_IN", ""}; + NamedConfig viewZoomOut{"HOTKEY_VIEW_ZOOM_OUT", ""}; + NamedConfig viewZoomReset{"HOTKEY_VIEW_ZOOM_RESET", "Ctrl+0"}; + NamedConfig viewLayerUp{"HOTKEY_VIEW_LAYER_UP", ""}; + NamedConfig viewLayerDown{"HOTKEY_VIEW_LAYER_DOWN", ""}; + NamedConfig viewLayerReset{"HOTKEY_VIEW_LAYER_RESET", ""}; + + // View toggles + NamedConfig viewRadialTransparency{"HOTKEY_VIEW_RADIAL_TRANSPARENCY", ""}; + NamedConfig viewStatusBar{"HOTKEY_VIEW_STATUS_BAR", ""}; + NamedConfig viewScrollBars{"HOTKEY_VIEW_SCROLL_BARS", ""}; + NamedConfig viewMenuBar{"HOTKEY_VIEW_MENU_BAR", ""}; + NamedConfig viewAlwaysOnTop{"HOTKEY_VIEW_ALWAYS_ON_TOP", ""}; + + // Side panels + NamedConfig panelLog{"HOTKEY_PANEL_LOG", "Ctrl+L"}; + NamedConfig panelClient{"HOTKEY_PANEL_CLIENT", ""}; + NamedConfig panelGroup{"HOTKEY_PANEL_GROUP", ""}; + NamedConfig panelRoom{"HOTKEY_PANEL_ROOM", ""}; + NamedConfig panelAdventure{"HOTKEY_PANEL_ADVENTURE", ""}; + NamedConfig panelDescription{"HOTKEY_PANEL_DESCRIPTION", ""}; + NamedConfig panelComms{"HOTKEY_PANEL_COMMS", ""}; + + // Mouse modes + NamedConfig modeMoveMap{"HOTKEY_MODE_MOVE_MAP", ""}; + NamedConfig modeRaypick{"HOTKEY_MODE_RAYPICK", ""}; + NamedConfig modeSelectRooms{"HOTKEY_MODE_SELECT_ROOMS", ""}; + NamedConfig modeSelectMarkers{"HOTKEY_MODE_SELECT_MARKERS", ""}; + NamedConfig modeSelectConnection{"HOTKEY_MODE_SELECT_CONNECTION", ""}; + NamedConfig modeCreateMarker{"HOTKEY_MODE_CREATE_MARKER", ""}; + NamedConfig modeCreateRoom{"HOTKEY_MODE_CREATE_ROOM", ""}; + NamedConfig modeCreateConnection{"HOTKEY_MODE_CREATE_CONNECTION", ""}; + NamedConfig modeCreateOnewayConnection{"HOTKEY_MODE_CREATE_ONEWAY_CONNECTION", ""}; + + // Room operations + NamedConfig roomCreate{"HOTKEY_ROOM_CREATE", ""}; + NamedConfig roomMoveUp{"HOTKEY_ROOM_MOVE_UP", ""}; + NamedConfig roomMoveDown{"HOTKEY_ROOM_MOVE_DOWN", ""}; + NamedConfig roomMergeUp{"HOTKEY_ROOM_MERGE_UP", ""}; + NamedConfig roomMergeDown{"HOTKEY_ROOM_MERGE_DOWN", ""}; + NamedConfig roomDelete{"HOTKEY_ROOM_DELETE", "Del"}; + NamedConfig roomConnectNeighbors{"HOTKEY_ROOM_CONNECT_NEIGHBORS", ""}; + NamedConfig roomMoveToSelected{"HOTKEY_ROOM_MOVE_TO_SELECTED", ""}; + NamedConfig roomUpdateSelected{"HOTKEY_ROOM_UPDATE_SELECTED", ""}; + + private: + SUBGROUP(); + } hotkeys; + + struct NODISCARD CommsSettings final + { + // Colors for each communication type + NamedConfig tellColor{"COMMS_TELL_COLOR", QColor(Qt::cyan)}; + NamedConfig whisperColor{"COMMS_WHISPER_COLOR", QColor(135, 206, 250)}; // Light sky blue + NamedConfig groupColor{"COMMS_GROUP_COLOR", QColor(Qt::green)}; + NamedConfig askColor{"COMMS_ASK_COLOR", QColor(Qt::yellow)}; + NamedConfig sayColor{"COMMS_SAY_COLOR", QColor(Qt::white)}; + NamedConfig emoteColor{"COMMS_EMOTE_COLOR", QColor(Qt::magenta)}; + NamedConfig socialColor{"COMMS_SOCIAL_COLOR", QColor(255, 182, 193)}; // Light pink + NamedConfig yellColor{"COMMS_YELL_COLOR", QColor(Qt::red)}; + NamedConfig narrateColor{"COMMS_NARRATE_COLOR", QColor(255, 165, 0)}; // Orange + NamedConfig prayColor{"COMMS_PRAY_COLOR", QColor(173, 216, 230)}; // Light blue + NamedConfig shoutColor{"COMMS_SHOUT_COLOR", QColor(139, 0, 0)}; // Dark red + NamedConfig singColor{"COMMS_SING_COLOR", QColor(144, 238, 144)}; // Light green + NamedConfig backgroundColor{"COMMS_BG_COLOR", QColor(Qt::black)}; + + // Talker colors (based on GMCP Comm.Channel talker-type) + NamedConfig talkerYouColor{"COMMS_TALKER_YOU_COLOR", QColor(255, 215, 0)}; // Gold + NamedConfig talkerPlayerColor{"COMMS_TALKER_PLAYER_COLOR", QColor(Qt::white)}; + NamedConfig talkerNpcColor{"COMMS_TALKER_NPC_COLOR", QColor(192, 192, 192)}; // Silver/Gray + NamedConfig talkerAllyColor{"COMMS_TALKER_ALLY_COLOR", QColor(0, 255, 0)}; // Bright green + NamedConfig talkerNeutralColor{"COMMS_TALKER_NEUTRAL_COLOR", QColor(255, 255, 0)}; // Yellow + NamedConfig talkerEnemyColor{"COMMS_TALKER_ENEMY_COLOR", QColor(255, 0, 0)}; // Red + + // Font styling options + NamedConfig yellAllCaps{"COMMS_YELL_ALL_CAPS", true}; + NamedConfig whisperItalic{"COMMS_WHISPER_ITALIC", true}; + NamedConfig emoteItalic{"COMMS_EMOTE_ITALIC", true}; + + // Display options + NamedConfig showTimestamps{"COMMS_SHOW_TIMESTAMPS", false}; + NamedConfig saveLogOnExit{"COMMS_SAVE_LOG_ON_EXIT", false}; + NamedConfig logDirectory{"COMMS_LOG_DIR", ""}; + + // Tab muting (acts as a filter) + NamedConfig muteDirectTab{"COMMS_MUTE_DIRECT", false}; + NamedConfig muteLocalTab{"COMMS_MUTE_LOCAL", false}; + NamedConfig muteGlobalTab{"COMMS_MUTE_GLOBAL", false}; + + private: + SUBGROUP(); + } comms; + #define XFOREACH_NAMED_COLOR_OPTIONS(X) \ X(BACKGROUND, BACKGROUND_NAME) \ X(CONNECTION_NORMAL, CONNECTION_NORMAL_NAME) \ @@ -300,6 +454,10 @@ class NODISCARD Configuration final { int64_t startEpoch = 0; bool display = false; + NamedConfig gmcpBroadcast{"GMCP_BROADCAST_CLOCK", true}; // Enable GMCP clock broadcasting + NamedConfig gmcpBroadcastInterval{"GMCP_BROADCAST_INTERVAL", 2500}; // Update interval in milliseconds (default: 2.5 seconds = 1 MUME minute) + + MumeClockSettings(); private: SUBGROUP(); diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 939d3660f..1767f37b5 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -10,8 +10,11 @@ #include "../adventure/adventurewidget.h" #include "../adventure/xpstatuswidget.h" #include "../client/ClientWidget.h" +#include "../comms/CommsManager.h" +#include "../comms/CommsWidget.h" #include "../clock/mumeclock.h" #include "../clock/mumeclockwidget.h" +#include "../display/Filenames.h" #include "../display/InfomarkSelection.h" #include "../display/MapCanvasData.h" #include "../display/mapcanvas.h" @@ -32,6 +35,7 @@ #include "DescriptionWidget.h" #include "MapZoomSlider.h" #include "UpdateDialog.h" +#include "VisibilityFilterWidget.h" #include "aboutdialog.h" #include "findroomsdlg.h" #include "infomarkseditdlg.h" @@ -128,7 +132,11 @@ MainWindow::MainWindow() addApplicationFont(); registerMetatypes(); - m_mapData = new MapData(this); + // Create game observer and clock first, as MapData depends on them + m_gameObserver = std::make_unique(); + m_mumeClock = new MumeClock(getConfig().mumeClock.startEpoch, deref(m_gameObserver), this); + + m_mapData = new MapData(deref(m_mumeClock), this); MapData &mapData = deref(m_mapData); m_mapData->setObjectName("MapData"); @@ -145,9 +153,21 @@ MainWindow::MainWindow() m_pathMachine = new Mmapper2PathMachine(mapData, this); m_pathMachine->setObjectName("Mmapper2PathMachine"); - m_gameObserver = std::make_unique(); m_adventureTracker = new AdventureTracker(deref(m_gameObserver), this); + // Create AutoLogger early (needed by CommsWidget) + m_logger = new AutoLogger(this); + + // Communications Manager + m_commsManager = new CommsManager(this); + deref(m_gameObserver).sig2_sentToUserGmcp.connect(m_lifetime, [this](const GmcpMessage &gmcp) { + deref(m_commsManager).slot_parseGmcpInput(gmcp); + }); + deref(m_gameObserver).sig2_rawGameText.connect(m_lifetime, [this](const QString &text) { + deref(m_commsManager).slot_parseRawGameText(text); + }); + connect(m_commsManager, &CommsManager::sig_log, this, &MainWindow::slot_log); + // View -> Side Panels -> Client Panel m_clientWidget = new ClientWidget(this); m_clientWidget->setObjectName("InternalMudClientWidget"); @@ -166,7 +186,6 @@ MainWindow::MainWindow() m_dockDialogLog->setAllowedAreas(Qt::TopDockWidgetArea | Qt::BottomDockWidgetArea); m_dockDialogLog->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - m_dockDialogLog->toggleViewAction()->setShortcut(tr("Ctrl+L")); addDockWidget(Qt::BottomDockWidgetArea, m_dockDialogLog); logWindow = new QTextBrowser(m_dockDialogLog); @@ -218,6 +237,18 @@ MainWindow::MainWindow() m_dockDialogAdventure->setWidget(m_adventureWidget); m_dockDialogAdventure->hide(); + // View -> Side Panels -> Communications Panel + m_dockDialogComms = new QDockWidget(tr("Communications"), this); + m_dockDialogComms->setObjectName("DockWidgetComms"); + m_dockDialogComms->setAllowedAreas(Qt::BottomDockWidgetArea | Qt::TopDockWidgetArea); + m_dockDialogComms->setFeatures(QDockWidget::DockWidgetClosable + | QDockWidget::DockWidgetFloatable + | QDockWidget::DockWidgetMovable); + addDockWidget(Qt::BottomDockWidgetArea, m_dockDialogComms); + m_commsWidget = new CommsWidget(deref(m_commsManager), m_logger, this); + m_dockDialogComms->setWidget(m_commsWidget); + m_dockDialogComms->hide(); + // View -> Side Panels -> Description / Area Panel m_descriptionWidget = new DescriptionWidget(this); m_dockDialogDescription = new QDockWidget(tr("Description Panel"), this); @@ -229,12 +260,40 @@ MainWindow::MainWindow() addDockWidget(Qt::RightDockWidgetArea, m_dockDialogDescription); m_dockDialogDescription->setWidget(m_descriptionWidget); - m_mumeClock = new MumeClock(getConfig().mumeClock.startEpoch, deref(m_gameObserver), this); + // View -> Toolbars -> Visibility Filter + m_visibilityFilterWidget = new VisibilityFilterWidget(this); + m_dockDialogVisibleMarkers = new QDockWidget(tr("Visibility Filter"), this); + m_dockDialogVisibleMarkers->setObjectName("DockWidgetVisibilityFilter"); + m_dockDialogVisibleMarkers->setAllowedAreas(Qt::AllDockWidgetAreas); + m_dockDialogVisibleMarkers->setFeatures(QDockWidget::DockWidgetMovable + | QDockWidget::DockWidgetFloatable + | QDockWidget::DockWidgetClosable); + addDockWidget(Qt::RightDockWidgetArea, m_dockDialogVisibleMarkers); + m_dockDialogVisibleMarkers->setWidget(m_visibilityFilterWidget); + m_dockDialogVisibleMarkers->hide(); + + // Connect visibility filter changes to map update + // Separate signals ensure we only rebuild what's necessary: + // - Infomarks visibility -> only rebuild infomark meshes + // - Connections visibility -> only rebuild map/connection batches + connect(m_visibilityFilterWidget, &VisibilityFilterWidget::sig_visibilityChanged, + this, [this]() { + m_mapWindow->getCanvas()->infomarksChanged(); + }); + + connect(m_visibilityFilterWidget, &VisibilityFilterWidget::sig_connectionsVisibilityChanged, + this, [this]() { + // Just trigger a repaint - connections use alpha transparency so no batch rebuild needed + m_mapWindow->getCanvas()->update(); + }); + if constexpr (!NO_UPDATER) { m_updateDialog = new UpdateDialog(this); } createActions(); + applyHotkeys(); + registerGlobalShortcuts(); setupToolBars(); setupMenuBar(); setupStatusBar(); @@ -244,8 +303,6 @@ MainWindow::MainWindow() setCorner(Qt::TopRightCorner, Qt::TopDockWidgetArea); setCorner(Qt::BottomRightCorner, Qt::BottomDockWidgetArea); - m_logger = new AutoLogger(this); - // TODO move this connect() wiring into AutoLogger::ctor ? GameObserver &observer = deref(m_gameObserver); observer.sig2_connected.connect(m_lifetime, [this]() { @@ -294,6 +351,8 @@ MainWindow::MainWindow() setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint); } + radialTransparencyAct->setChecked(getConfig().canvas.enableRadialTransparency); + showStatusBarAct->setChecked(getConfig().general.showStatusBar); slot_setShowStatusBar(); @@ -483,6 +542,13 @@ void MainWindow::wireConnections() connect(m_mapData, &MapFrontend::sig_clearingMap, m_groupWidget, &GroupWidget::slot_mapUnloaded); connect(m_mumeClock, &MumeClock::sig_log, this, &MainWindow::slot_log); + connect(m_mumeClock, &MumeClock::sig_seasonChanged, m_mapWindow->getCanvas(), &MapCanvas::slot_onSeasonChanged); + + // Initialize the current season for seasonal textures + // This sets the global season variable so textures load correctly on startup + // Note: We only call setCurrentSeason() here, NOT slot_onSeasonChanged() + // because the OpenGL context isn't ready yet during construction + setCurrentSeason(m_mumeClock->getMumeMoment().toSeason()); connect(m_listener, &ConnectionListener::sig_log, this, &MainWindow::slot_log); connect(m_dockDialogClient, @@ -534,21 +600,18 @@ void MainWindow::createActions() openAct = new QAction(QIcon::fromTheme("document-open", QIcon(":/icons/open.png")), tr("&Open..."), this); - openAct->setShortcut(tr("Ctrl+O")); openAct->setStatusTip(tr("Open an existing file")); connect(openAct, &QAction::triggered, this, &MainWindow::slot_open); reloadAct = new QAction(QIcon::fromTheme("document-open-recent", QIcon(":/icons/reload.png")), tr("&Reload"), this); - reloadAct->setShortcut(tr("Ctrl+R")); reloadAct->setStatusTip(tr("Reload the current map")); connect(reloadAct, &QAction::triggered, this, &MainWindow::slot_reload); saveAct = new QAction(QIcon::fromTheme("document-save", QIcon(":/icons/save.png")), tr("&Save"), this); - saveAct->setShortcut(tr("Ctrl+S")); saveAct->setStatusTip(tr("Save the document to disk")); saveAct->setEnabled(false); connect(saveAct, &QAction::triggered, this, &MainWindow::slot_save); @@ -578,19 +641,16 @@ void MainWindow::createActions() connect(mergeAct, &QAction::triggered, this, &MainWindow::slot_merge); exitAct = new QAction(QIcon::fromTheme("application-exit"), tr("E&xit"), this); - exitAct->setShortcut(tr("Ctrl+Q")); exitAct->setStatusTip(tr("Exit the application")); connect(exitAct, &QAction::triggered, this, &QWidget::close); m_undoAction = new QAction(QIcon::fromTheme("edit-undo"), tr("&Undo"), this); - m_undoAction->setShortcut(QKeySequence::Undo); m_undoAction->setStatusTip(tr("Undo the last action")); connect(m_undoAction, &QAction::triggered, m_mapData, &MapData::slot_undo); connect(m_mapData, &MapData::sig_undoAvailable, m_undoAction, &QAction::setEnabled); m_undoAction->setEnabled(false); m_redoAction = new QAction(QIcon::fromTheme("edit-redo"), tr("&Redo"), this); - m_redoAction->setShortcut(QKeySequence::Redo); m_redoAction->setStatusTip(tr("Redo the last undone action")); connect(m_redoAction, &QAction::triggered, m_mapData, &MapData::slot_redo); connect(m_mapData, &MapData::sig_redoAvailable, m_redoAction, &QAction::setEnabled); @@ -600,7 +660,6 @@ void MainWindow::createActions() QIcon(":/icons/preferences.png")), tr("&Preferences"), this); - preferencesAct->setShortcut(tr("Ctrl+P")); preferencesAct->setStatusTip(tr("MMapper preferences")); connect(preferencesAct, &QAction::triggered, this, &MainWindow::slot_onPreferences); @@ -647,22 +706,23 @@ void MainWindow::createActions() tr("Zoom In"), this); zoomInAct->setStatusTip(tr("Zooms In current map")); - zoomInAct->setShortcut(tr("Ctrl++")); zoomOutAct = new QAction(QIcon::fromTheme("zoom-out", QIcon(":/icons/viewmag-.png")), tr("Zoom Out"), this); - zoomOutAct->setShortcut(tr("Ctrl+-")); zoomOutAct->setStatusTip(tr("Zooms Out current map")); zoomResetAct = new QAction(QIcon::fromTheme("zoom-original", QIcon(":/icons/viewmagfit.png")), tr("Zoom Reset"), this); - zoomResetAct->setShortcut(tr("Ctrl+0")); zoomResetAct->setStatusTip(tr("Zoom to original size")); alwaysOnTopAct = new QAction(tr("Always On Top"), this); alwaysOnTopAct->setCheckable(true); connect(alwaysOnTopAct, &QAction::triggered, this, &MainWindow::slot_alwaysOnTop); + radialTransparencyAct = new QAction(tr("Radial Transparency"), this); + radialTransparencyAct->setCheckable(true); + connect(radialTransparencyAct, &QAction::triggered, this, &MainWindow::slot_setRadialTransparency); + showStatusBarAct = new QAction(tr("Always Show Status Bar"), this); showStatusBarAct->setCheckable(true); connect(showStatusBarAct, &QAction::triggered, this, &MainWindow::slot_setShowStatusBar); @@ -680,26 +740,11 @@ void MainWindow::createActions() layerUpAct = new QAction(QIcon::fromTheme("go-up", QIcon(":/icons/layerup.png")), tr("Layer Up"), this); - layerUpAct->setShortcut(tr([]() -> const char * { - // Technically tr() could convert Ctrl to Meta, right? - if constexpr (CURRENT_PLATFORM == PlatformEnum::Mac) { - return "Meta+Tab"; - } - return "Ctrl+Tab"; - }())); layerUpAct->setStatusTip(tr("Layer Up")); connect(layerUpAct, &QAction::triggered, this, &MainWindow::slot_onLayerUp); layerDownAct = new QAction(QIcon::fromTheme("go-down", QIcon(":/icons/layerdown.png")), tr("Layer Down"), this); - - layerDownAct->setShortcut(tr([]() -> const char * { - // Technically tr() could convert Ctrl to Meta, right? - if constexpr (CURRENT_PLATFORM == PlatformEnum::Mac) { - return "Meta+Shift+Tab"; - } - return "Ctrl+Shift+Tab"; - }())); layerDownAct->setStatusTip(tr("Layer Down")); connect(layerDownAct, &QAction::triggered, this, &MainWindow::slot_onLayerDown); @@ -816,7 +861,6 @@ void MainWindow::createActions() tr("Edit Selected Rooms"), this); editRoomSelectionAct->setStatusTip(tr("Edit Selected Rooms")); - editRoomSelectionAct->setShortcut(tr("Ctrl+E")); connect(editRoomSelectionAct, &QAction::triggered, this, &MainWindow::slot_onEditRoomSelection); deleteRoomSelectionAct = new QAction(QIcon(":/icons/roomdelete.png"), @@ -871,7 +915,6 @@ void MainWindow::createActions() findRoomsAct = new QAction(QIcon(":/icons/roomfind.png"), tr("&Find Rooms"), this); findRoomsAct->setStatusTip(tr("Find matching rooms")); - findRoomsAct->setShortcut(tr("Ctrl+F")); connect(findRoomsAct, &QAction::triggered, this, &MainWindow::slot_onFindRoom); clientAct = new QAction(QIcon(":/icons/online.png"), tr("&Launch mud client"), this); @@ -884,6 +927,12 @@ void MainWindow::createActions() connect(saveLogAct, &QAction::triggered, m_clientWidget, &ClientWidget::slot_saveLog); saveLogAct->setStatusTip(tr("Save log as file")); + saveCommsLogAct = new QAction(QIcon::fromTheme("document-save", QIcon(":/icons/save.png")), + tr("Save &communications log as..."), + this); + connect(saveCommsLogAct, &QAction::triggered, m_commsWidget, &CommsWidget::slot_saveLog); + saveCommsLogAct->setStatusTip(tr("Save communications log as file")); + releaseAllPathsAct = new QAction(QIcon(":/icons/cancel.png"), tr("Release All Paths"), this); releaseAllPathsAct->setStatusTip(tr("Release all paths")); releaseAllPathsAct->setCheckable(false); @@ -1005,6 +1054,104 @@ void MainWindow::createActions() connect(rebuildMeshesAct, &QAction::triggered, getCanvas(), &MapCanvas::slot_rebuildMeshes); } +void MainWindow::registerGlobalShortcuts() +{ + // Register all actions with the main window so their shortcuts work globally + // This is required for keyboard shortcuts to work anywhere in the application + + // File operations + addAction(newAct); + addAction(openAct); + addAction(mergeAct); + addAction(reloadAct); + addAction(saveAct); + addAction(saveAsAct); + addAction(exportBaseMapAct); + addAction(exportMm2xmlMapAct); + addAction(exportWebMapAct); + addAction(exportMmpMapAct); + addAction(exitAct); + + // Edit operations + addAction(m_undoAction); + addAction(m_redoAction); + addAction(preferencesAct); + addAction(findRoomsAct); + addAction(editRoomSelectionAct); + + // View operations + addAction(zoomInAct); + addAction(zoomOutAct); + addAction(zoomResetAct); + addAction(layerUpAct); + addAction(layerDownAct); + addAction(layerResetAct); + + // View toggles + addAction(radialTransparencyAct); + addAction(showStatusBarAct); + addAction(showScrollBarsAct); + addAction(showMenuBarAct); + addAction(alwaysOnTopAct); + + // Side panels + if (m_dockDialogLog && m_dockDialogLog->toggleViewAction()) { + addAction(m_dockDialogLog->toggleViewAction()); + } + if (m_dockDialogClient && m_dockDialogClient->toggleViewAction()) { + addAction(m_dockDialogClient->toggleViewAction()); + } + if (m_dockDialogGroup && m_dockDialogGroup->toggleViewAction()) { + addAction(m_dockDialogGroup->toggleViewAction()); + } + if (m_dockDialogRoom && m_dockDialogRoom->toggleViewAction()) { + addAction(m_dockDialogRoom->toggleViewAction()); + } + if (m_dockDialogAdventure && m_dockDialogAdventure->toggleViewAction()) { + addAction(m_dockDialogAdventure->toggleViewAction()); + } + if (m_dockDialogComms && m_dockDialogComms->toggleViewAction()) { + addAction(m_dockDialogComms->toggleViewAction()); + } + if (m_dockDialogDescription && m_dockDialogDescription->toggleViewAction()) { + addAction(m_dockDialogDescription->toggleViewAction()); + } + + // Mouse modes + addAction(mouseMode.modeMoveSelectAct); + addAction(mouseMode.modeRoomRaypickAct); + addAction(mouseMode.modeRoomSelectAct); + addAction(mouseMode.modeConnectionSelectAct); + addAction(mouseMode.modeInfomarkSelectAct); + addAction(mouseMode.modeCreateInfomarkAct); + addAction(mouseMode.modeCreateRoomAct); + addAction(mouseMode.modeCreateConnectionAct); + addAction(mouseMode.modeCreateOnewayConnectionAct); + + // Room operations + addAction(createRoomAct); + addAction(moveUpRoomSelectionAct); + addAction(moveDownRoomSelectionAct); + addAction(mergeUpRoomSelectionAct); + addAction(mergeDownRoomSelectionAct); + addAction(deleteRoomSelectionAct); + addAction(connectToNeighboursRoomSelectionAct); + addAction(gotoRoomAct); + addAction(forceRoomAct); + + // Connection operations + addAction(deleteConnectionSelectionAct); + + // Infomark operations + addAction(infomarkActions.deleteInfomarkAct); + addAction(infomarkActions.editInfomarkAct); + + // Other + addAction(rebuildMeshesAct); + + qDebug() << "Registered all actions with main window for global shortcuts"; +} + static void setConfigMapMode(const MapModeEnum mode) { setConfig().general.mapMode = mode; @@ -1156,12 +1303,14 @@ void MainWindow::setupMenuBar() toolbars->addAction(roomToolBar->toggleViewAction()); toolbars->addAction(connectionToolBar->toggleViewAction()); toolbars->addAction(settingsToolBar->toggleViewAction()); + toolbars->addAction(m_dockDialogVisibleMarkers->toggleViewAction()); QMenu *sidepanels = viewMenu->addMenu(tr("&Side Panels")); sidepanels->addAction(m_dockDialogLog->toggleViewAction()); sidepanels->addAction(m_dockDialogClient->toggleViewAction()); sidepanels->addAction(m_dockDialogGroup->toggleViewAction()); sidepanels->addAction(m_dockDialogRoom->toggleViewAction()); sidepanels->addAction(m_dockDialogAdventure->toggleViewAction()); + sidepanels->addAction(m_dockDialogComms->toggleViewAction()); sidepanels->addAction(m_dockDialogDescription->toggleViewAction()); viewMenu->addSeparator(); viewMenu->addAction(zoomInAct); @@ -1174,6 +1323,8 @@ void MainWindow::setupMenuBar() viewMenu->addSeparator(); viewMenu->addAction(rebuildMeshesAct); viewMenu->addSeparator(); + viewMenu->addAction(radialTransparencyAct); + viewMenu->addAction(showStatusBarAct); viewMenu->addAction(showScrollBarsAct); if constexpr (CURRENT_PLATFORM != PlatformEnum::Mac) { @@ -1186,6 +1337,8 @@ void MainWindow::setupMenuBar() tr("&Integrated Mud Client")); clientMenu->addAction(clientAct); clientMenu->addAction(saveLogAct); + clientMenu->addSeparator(); + clientMenu->addAction(saveCommsLogAct); QMenu *pathMachineMenu = settingsMenu->addMenu(QIcon(":/icons/goto.png"), tr("&Path Machine")); pathMachineMenu->addAction(mouseMode.modeRoomSelectAct); pathMachineMenu->addSeparator(); @@ -1269,6 +1422,15 @@ void MainWindow::slot_alwaysOnTop() show(); } +void MainWindow::slot_setRadialTransparency() +{ + const bool enableRadialTransparency = this->radialTransparencyAct->isChecked(); + setConfig().canvas.enableRadialTransparency = enableRadialTransparency; + if (m_mapWindow) { + m_mapWindow->update(); + } +} + void MainWindow::slot_setShowStatusBar() { const bool showStatusBar = this->showStatusBarAct->isChecked(); @@ -1414,10 +1576,22 @@ void MainWindow::slot_onPreferences() &ConfigDialog::sig_graphicsSettingsChanged, m_mapWindow, &MapWindow::slot_graphicsSettingsChanged); + connect(m_configDialog.get(), + &ConfigDialog::sig_textureSettingsChanged, + m_mapWindow->getCanvas(), + &MapCanvas::slot_reloadTextures); connect(m_configDialog.get(), &ConfigDialog::sig_groupSettingsChanged, m_groupManager, &Mmapper2Group::slot_groupSettingsChanged); + connect(m_configDialog.get(), + &ConfigDialog::sig_commsSettingsChanged, + m_commsWidget, + &CommsWidget::slot_loadSettings); + connect(m_configDialog.get(), + &ConfigDialog::sig_hotkeysChanged, + this, + &MainWindow::applyHotkeys); m_configDialog->show(); } @@ -1478,6 +1652,12 @@ bool MainWindow::eventFilter(QObject *const obj, QEvent *const event) void MainWindow::closeEvent(QCloseEvent *const event) { // REVISIT: wait and see if we're actually exiting first? + + // Save communications log if enabled + if (m_commsWidget) { + m_commsWidget->slot_saveLogOnExit(); + } + writeSettings(); if (!maybeSave()) { @@ -2131,3 +2311,107 @@ void MainWindow::onSuccessfulSave(const SaveModeEnum mode, } } } + +void MainWindow::applyHotkeys() +{ + const auto &hotkeys = getConfig().hotkeys; + + qDebug() << "=== Applying hotkeys ==="; + + // Helper lambda to apply shortcut only if not empty + auto applyShortcut = [](QAction *action, const QString &shortcut) { + if (action && !shortcut.isEmpty()) { + action->setShortcut(QKeySequence(shortcut)); + qDebug() << " Setting shortcut:" << action->text() << "=" << shortcut; + } else if (action) { + action->setShortcut(QKeySequence()); + qDebug() << " Clearing shortcut:" << action->text(); + } + }; + + // File operations + applyShortcut(openAct, hotkeys.fileOpen.get()); + applyShortcut(saveAct, hotkeys.fileSave.get()); + applyShortcut(reloadAct, hotkeys.fileReload.get()); + applyShortcut(exitAct, hotkeys.fileQuit.get()); + + // Edit operations + applyShortcut(m_undoAction, hotkeys.editUndo.get()); + applyShortcut(m_redoAction, hotkeys.editRedo.get()); + applyShortcut(preferencesAct, hotkeys.editPreferences.get()); + applyShortcut(findRoomsAct, hotkeys.editFindRooms.get()); + applyShortcut(editRoomSelectionAct, hotkeys.editRoom.get()); + + // View operations + applyShortcut(zoomInAct, hotkeys.viewZoomIn.get()); + applyShortcut(zoomOutAct, hotkeys.viewZoomOut.get()); + applyShortcut(zoomResetAct, hotkeys.viewZoomReset.get()); + applyShortcut(layerUpAct, hotkeys.viewLayerUp.get()); + applyShortcut(layerDownAct, hotkeys.viewLayerDown.get()); + applyShortcut(layerResetAct, hotkeys.viewLayerReset.get()); + + // View toggles + applyShortcut(radialTransparencyAct, hotkeys.viewRadialTransparency.get()); + applyShortcut(showStatusBarAct, hotkeys.viewStatusBar.get()); + applyShortcut(showScrollBarsAct, hotkeys.viewScrollBars.get()); + applyShortcut(showMenuBarAct, hotkeys.viewMenuBar.get()); + applyShortcut(alwaysOnTopAct, hotkeys.viewAlwaysOnTop.get()); + + // Side panels + if (m_dockDialogLog && m_dockDialogLog->toggleViewAction()) { + applyShortcut(m_dockDialogLog->toggleViewAction(), hotkeys.panelLog.get()); + } + if (m_dockDialogClient && m_dockDialogClient->toggleViewAction()) { + applyShortcut(m_dockDialogClient->toggleViewAction(), hotkeys.panelClient.get()); + } + if (m_dockDialogGroup && m_dockDialogGroup->toggleViewAction()) { + applyShortcut(m_dockDialogGroup->toggleViewAction(), hotkeys.panelGroup.get()); + } + if (m_dockDialogRoom && m_dockDialogRoom->toggleViewAction()) { + applyShortcut(m_dockDialogRoom->toggleViewAction(), hotkeys.panelRoom.get()); + } + if (m_dockDialogAdventure && m_dockDialogAdventure->toggleViewAction()) { + applyShortcut(m_dockDialogAdventure->toggleViewAction(), hotkeys.panelAdventure.get()); + } + if (m_dockDialogComms && m_dockDialogComms->toggleViewAction()) { + applyShortcut(m_dockDialogComms->toggleViewAction(), hotkeys.panelComms.get()); + } + if (m_dockDialogDescription && m_dockDialogDescription->toggleViewAction()) { + applyShortcut(m_dockDialogDescription->toggleViewAction(), hotkeys.panelDescription.get()); + } + + // Mouse modes + applyShortcut(mouseMode.modeMoveSelectAct, hotkeys.modeMoveMap.get()); + applyShortcut(mouseMode.modeRoomRaypickAct, hotkeys.modeRaypick.get()); + applyShortcut(mouseMode.modeRoomSelectAct, hotkeys.modeSelectRooms.get()); + applyShortcut(mouseMode.modeInfomarkSelectAct, hotkeys.modeSelectMarkers.get()); + applyShortcut(mouseMode.modeConnectionSelectAct, hotkeys.modeSelectConnection.get()); + applyShortcut(mouseMode.modeCreateInfomarkAct, hotkeys.modeCreateMarker.get()); + applyShortcut(mouseMode.modeCreateRoomAct, hotkeys.modeCreateRoom.get()); + applyShortcut(mouseMode.modeCreateConnectionAct, hotkeys.modeCreateConnection.get()); + applyShortcut(mouseMode.modeCreateOnewayConnectionAct, hotkeys.modeCreateOnewayConnection.get()); + + // Room operations + applyShortcut(createRoomAct, hotkeys.roomCreate.get()); + applyShortcut(moveUpRoomSelectionAct, hotkeys.roomMoveUp.get()); + applyShortcut(moveDownRoomSelectionAct, hotkeys.roomMoveDown.get()); + applyShortcut(mergeUpRoomSelectionAct, hotkeys.roomMergeUp.get()); + applyShortcut(mergeDownRoomSelectionAct, hotkeys.roomMergeDown.get()); + applyShortcut(deleteRoomSelectionAct, hotkeys.roomDelete.get()); + applyShortcut(connectToNeighboursRoomSelectionAct, hotkeys.roomConnectNeighbors.get()); + applyShortcut(gotoRoomAct, hotkeys.roomMoveToSelected.get()); + applyShortcut(forceRoomAct, hotkeys.roomUpdateSelected.get()); + + // Apply alternative preferences shortcut (Esc) + if (preferencesAct && !hotkeys.editPreferencesAlt.get().isEmpty()) { + QList shortcuts; + if (!hotkeys.editPreferences.get().isEmpty()) { + shortcuts << QKeySequence(hotkeys.editPreferences.get()); + } + shortcuts << QKeySequence(hotkeys.editPreferencesAlt.get()); + preferencesAct->setShortcuts(shortcuts); + qDebug() << " Setting dual shortcuts for Preferences:" << shortcuts; + } + + qDebug() << "=== Hotkeys applied ==="; +} diff --git a/src/mainwindow/mainwindow.h b/src/mainwindow/mainwindow.h index 674860557..42603a50d 100644 --- a/src/mainwindow/mainwindow.h +++ b/src/mainwindow/mainwindow.h @@ -34,6 +34,8 @@ class AdventureTracker; class AdventureWidget; class AutoLogger; class ClientWidget; +class CommsManager; +class CommsWidget; class ConfigDialog; class ConnectionListener; class ConnectionSelection; @@ -66,6 +68,7 @@ class RoomSelection; class RoomWidget; class UpdateDialog; class DescriptionWidget; +class VisibilityFilterWidget; struct MapLoadData; @@ -85,6 +88,8 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow QDockWidget *m_dockDialogGroup = nullptr; QDockWidget *m_dockDialogAdventure = nullptr; QDockWidget *m_dockDialogDescription = nullptr; + QDockWidget *m_dockDialogComms = nullptr; + QDockWidget *m_dockDialogVisibleMarkers = nullptr; std::unique_ptr m_gameObserver; AutoLogger *m_logger = nullptr; @@ -108,7 +113,11 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow AdventureTracker *m_adventureTracker = nullptr; AdventureWidget *m_adventureWidget = nullptr; + CommsManager *m_commsManager = nullptr; + CommsWidget *m_commsWidget = nullptr; + DescriptionWidget *m_descriptionWidget = nullptr; + VisibilityFilterWidget *m_visibilityFilterWidget = nullptr; SharedRoomSelection m_roomSelection; std::shared_ptr m_connectionSelection; @@ -166,6 +175,7 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow QAction *zoomOutAct = nullptr; QAction *zoomResetAct = nullptr; QAction *alwaysOnTopAct = nullptr; + QAction *radialTransparencyAct = nullptr; QAction *showStatusBarAct = nullptr; QAction *showScrollBarsAct = nullptr; QAction *showMenuBarAct = nullptr; @@ -222,6 +232,7 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow QAction *clientAct = nullptr; QAction *saveLogAct = nullptr; + QAction *saveCommsLogAct = nullptr; QAction *gotoRoomAct = nullptr; QAction *forceRoomAct = nullptr; @@ -304,6 +315,8 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow void setupMenuBar(); void setupToolBars(); void setupStatusBar(); + void applyHotkeys(); + void registerGlobalShortcuts(); void readSettings(); void writeSettings(); @@ -432,6 +445,7 @@ public slots: void slot_onOfflineMode(); void slot_setMode(MapModeEnum mode); void slot_alwaysOnTop(); + void slot_setRadialTransparency(); void slot_setShowStatusBar(); void slot_setShowScrollBars(); void slot_setShowMenuBar(); diff --git a/src/preferences/commspage.cpp b/src/preferences/commspage.cpp new file mode 100644 index 000000000..d803443fd --- /dev/null +++ b/src/preferences/commspage.cpp @@ -0,0 +1,402 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "commspage.h" + +#include +#include +#include +#include + +#include "../configuration/configuration.h" + +CommsPage::CommsPage(QWidget *parent) + : QWidget(parent) +{ + setupUI(); + connectSignals(); + slot_loadConfig(); +} + +CommsPage::~CommsPage() = default; + +void CommsPage::setupUI() +{ + auto *mainLayout = new QVBoxLayout(this); + + // Talker Colors Group + auto *talkerColorsGroup = new QGroupBox(tr("Talker Colors"), this); + auto *talkerColorsLayout = new QFormLayout(talkerColorsGroup); + + m_talkerYouColorButton = new QPushButton(tr("Choose Color..."), this); + m_talkerYouColorButton->setMinimumWidth(120); + m_talkerYouColorButton->setProperty("colorType", "talker_you"); + talkerColorsLayout->addRow(tr("You (sent messages):"), m_talkerYouColorButton); + + m_talkerPlayerColorButton = new QPushButton(tr("Choose Color..."), this); + m_talkerPlayerColorButton->setMinimumWidth(120); + m_talkerPlayerColorButton->setProperty("colorType", "talker_player"); + talkerColorsLayout->addRow(tr("Player:"), m_talkerPlayerColorButton); + + m_talkerNpcColorButton = new QPushButton(tr("Choose Color..."), this); + m_talkerNpcColorButton->setMinimumWidth(120); + m_talkerNpcColorButton->setProperty("colorType", "talker_npc"); + talkerColorsLayout->addRow(tr("NPC:"), m_talkerNpcColorButton); + + m_talkerAllyColorButton = new QPushButton(tr("Choose Color..."), this); + m_talkerAllyColorButton->setMinimumWidth(120); + m_talkerAllyColorButton->setProperty("colorType", "talker_ally"); + talkerColorsLayout->addRow(tr("Ally:"), m_talkerAllyColorButton); + + m_talkerNeutralColorButton = new QPushButton(tr("Choose Color..."), this); + m_talkerNeutralColorButton->setMinimumWidth(120); + m_talkerNeutralColorButton->setProperty("colorType", "talker_neutral"); + talkerColorsLayout->addRow(tr("Neutral:"), m_talkerNeutralColorButton); + + m_talkerEnemyColorButton = new QPushButton(tr("Choose Color..."), this); + m_talkerEnemyColorButton->setMinimumWidth(120); + m_talkerEnemyColorButton->setProperty("colorType", "talker_enemy"); + talkerColorsLayout->addRow(tr("Enemy:"), m_talkerEnemyColorButton); + + mainLayout->addWidget(talkerColorsGroup); + + // Communication Colors Group + auto *colorsGroup = new QGroupBox(tr("Communication Colors"), this); + auto *colorsLayout = new QFormLayout(colorsGroup); + + // Direct communications + m_tellColorButton = new QPushButton(tr("Choose Color..."), this); + m_tellColorButton->setMinimumWidth(120); + m_tellColorButton->setProperty("colorType", "tell"); + colorsLayout->addRow(tr("Tell:"), m_tellColorButton); + + m_whisperColorButton = new QPushButton(tr("Choose Color..."), this); + m_whisperColorButton->setMinimumWidth(120); + m_whisperColorButton->setProperty("colorType", "whisper"); + colorsLayout->addRow(tr("Whisper:"), m_whisperColorButton); + + m_groupColorButton = new QPushButton(tr("Choose Color..."), this); + m_groupColorButton->setMinimumWidth(120); + m_groupColorButton->setProperty("colorType", "group"); + colorsLayout->addRow(tr("Group:"), m_groupColorButton); + + m_askColorButton = new QPushButton(tr("Choose Color..."), this); + m_askColorButton->setMinimumWidth(120); + m_askColorButton->setProperty("colorType", "ask"); + colorsLayout->addRow(tr("Question:"), m_askColorButton); + + // Local communications + m_sayColorButton = new QPushButton(tr("Choose Color..."), this); + m_sayColorButton->setMinimumWidth(120); + m_sayColorButton->setProperty("colorType", "say"); + colorsLayout->addRow(tr("Say:"), m_sayColorButton); + + m_emoteColorButton = new QPushButton(tr("Choose Color..."), this); + m_emoteColorButton->setMinimumWidth(120); + m_emoteColorButton->setProperty("colorType", "emote"); + colorsLayout->addRow(tr("Emote:"), m_emoteColorButton); + + m_socialColorButton = new QPushButton(tr("Choose Color..."), this); + m_socialColorButton->setMinimumWidth(120); + m_socialColorButton->setProperty("colorType", "social"); + colorsLayout->addRow(tr("Social:"), m_socialColorButton); + + m_yellColorButton = new QPushButton(tr("Choose Color..."), this); + m_yellColorButton->setMinimumWidth(120); + m_yellColorButton->setProperty("colorType", "yell"); + colorsLayout->addRow(tr("Yell:"), m_yellColorButton); + + // Global communications + m_narrateColorButton = new QPushButton(tr("Choose Color..."), this); + m_narrateColorButton->setMinimumWidth(120); + m_narrateColorButton->setProperty("colorType", "narrate"); + colorsLayout->addRow(tr("Tale:"), m_narrateColorButton); + + m_singColorButton = new QPushButton(tr("Choose Color..."), this); + m_singColorButton->setMinimumWidth(120); + m_singColorButton->setProperty("colorType", "sing"); + colorsLayout->addRow(tr("Song:"), m_singColorButton); + + m_prayColorButton = new QPushButton(tr("Choose Color..."), this); + m_prayColorButton->setMinimumWidth(120); + m_prayColorButton->setProperty("colorType", "pray"); + colorsLayout->addRow(tr("Prayer:"), m_prayColorButton); + + m_shoutColorButton = new QPushButton(tr("Choose Color..."), this); + m_shoutColorButton->setMinimumWidth(120); + m_shoutColorButton->setProperty("colorType", "shout"); + colorsLayout->addRow(tr("Shout:"), m_shoutColorButton); + + // Background color + m_bgColorButton = new QPushButton(tr("Choose Color..."), this); + m_bgColorButton->setMinimumWidth(120); + colorsLayout->addRow(tr("Background:"), m_bgColorButton); + + mainLayout->addWidget(colorsGroup); + + // Font Styling Group + auto *fontGroup = new QGroupBox(tr("Font Styling"), this); + auto *fontLayout = new QVBoxLayout(fontGroup); + + m_yellAllCapsCheck = new QCheckBox(tr("Display yells in ALL CAPS"), this); + fontLayout->addWidget(m_yellAllCapsCheck); + + m_whisperItalicCheck = new QCheckBox(tr("Display whispers in italic"), this); + fontLayout->addWidget(m_whisperItalicCheck); + + m_emoteItalicCheck = new QCheckBox(tr("Display emotes in italic"), this); + fontLayout->addWidget(m_emoteItalicCheck); + + mainLayout->addWidget(fontGroup); + + // Display Options Group + auto *displayGroup = new QGroupBox(tr("Display Options"), this); + auto *displayLayout = new QVBoxLayout(displayGroup); + + m_showTimestampsCheck = new QCheckBox(tr("Show timestamps"), this); + displayLayout->addWidget(m_showTimestampsCheck); + + mainLayout->addWidget(displayGroup); + + // Add stretch at the bottom to push everything up + mainLayout->addStretch(); + + setLayout(mainLayout); +} + +void CommsPage::connectSignals() +{ + // Color buttons - all connect to the same slot + connect(m_tellColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_whisperColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_groupColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_askColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_sayColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_emoteColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_socialColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_yellColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_narrateColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_singColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_prayColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_shoutColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_bgColorButton, &QPushButton::clicked, this, &CommsPage::slot_onBgColorClicked); + + // Talker color buttons + connect(m_talkerYouColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_talkerPlayerColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_talkerNpcColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_talkerAllyColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_talkerNeutralColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + connect(m_talkerEnemyColorButton, &QPushButton::clicked, this, &CommsPage::slot_onColorClicked); + + // Font styling + connect(m_yellAllCapsCheck, &QCheckBox::checkStateChanged, this, &CommsPage::slot_onYellAllCapsChanged); + connect(m_whisperItalicCheck, &QCheckBox::checkStateChanged, this, &CommsPage::slot_onWhisperItalicChanged); + connect(m_emoteItalicCheck, &QCheckBox::checkStateChanged, this, &CommsPage::slot_onEmoteItalicChanged); + + // Display options + connect(m_showTimestampsCheck, &QCheckBox::checkStateChanged, this, &CommsPage::slot_onShowTimestampsChanged); +} + +void CommsPage::slot_loadConfig() +{ + const auto &comms = getConfig().comms; + + // Load per-type colors and update button backgrounds + updateColorButton(m_tellColorButton, comms.tellColor.get()); + updateColorButton(m_whisperColorButton, comms.whisperColor.get()); + updateColorButton(m_groupColorButton, comms.groupColor.get()); + updateColorButton(m_askColorButton, comms.askColor.get()); + updateColorButton(m_sayColorButton, comms.sayColor.get()); + updateColorButton(m_emoteColorButton, comms.emoteColor.get()); + updateColorButton(m_socialColorButton, comms.socialColor.get()); + updateColorButton(m_yellColorButton, comms.yellColor.get()); + updateColorButton(m_narrateColorButton, comms.narrateColor.get()); + updateColorButton(m_singColorButton, comms.singColor.get()); + updateColorButton(m_prayColorButton, comms.prayColor.get()); + updateColorButton(m_shoutColorButton, comms.shoutColor.get()); + updateColorButton(m_bgColorButton, comms.backgroundColor.get()); + + // Load talker colors + updateColorButton(m_talkerYouColorButton, comms.talkerYouColor.get()); + updateColorButton(m_talkerPlayerColorButton, comms.talkerPlayerColor.get()); + updateColorButton(m_talkerNpcColorButton, comms.talkerNpcColor.get()); + updateColorButton(m_talkerAllyColorButton, comms.talkerAllyColor.get()); + updateColorButton(m_talkerNeutralColorButton, comms.talkerNeutralColor.get()); + updateColorButton(m_talkerEnemyColorButton, comms.talkerEnemyColor.get()); + + // Load font styling options + m_yellAllCapsCheck->setChecked(comms.yellAllCaps.get()); + m_whisperItalicCheck->setChecked(comms.whisperItalic.get()); + m_emoteItalicCheck->setChecked(comms.emoteItalic.get()); + + // Load display options + m_showTimestampsCheck->setChecked(comms.showTimestamps.get()); +} + +void CommsPage::updateColorButton(QPushButton *button, const QColor &color) +{ + if (!button) { + return; + } + + // Set button background to the color + button->setStyleSheet(QString("background-color: %1; color: %2;") + .arg(color.name()) + .arg(color.lightnessF() > 0.5 ? "black" : "white")); +} + +void CommsPage::slot_onColorClicked() +{ + auto *button = qobject_cast(sender()); + if (!button) { + return; + } + + const QString colorType = button->property("colorType").toString(); + auto &comms = setConfig().comms; + + QColor currentColor; + QString dialogTitle; + + // Get current color and dialog title based on type + if (colorType == "tell") { + currentColor = comms.tellColor.get(); + dialogTitle = tr("Choose Tell Color"); + } else if (colorType == "whisper") { + currentColor = comms.whisperColor.get(); + dialogTitle = tr("Choose Whisper Color"); + } else if (colorType == "group") { + currentColor = comms.groupColor.get(); + dialogTitle = tr("Choose Group Color"); + } else if (colorType == "ask") { + currentColor = comms.askColor.get(); + dialogTitle = tr("Choose Question Color"); + } else if (colorType == "say") { + currentColor = comms.sayColor.get(); + dialogTitle = tr("Choose Say Color"); + } else if (colorType == "emote") { + currentColor = comms.emoteColor.get(); + dialogTitle = tr("Choose Emote Color"); + } else if (colorType == "social") { + currentColor = comms.socialColor.get(); + dialogTitle = tr("Choose Social Color"); + } else if (colorType == "yell") { + currentColor = comms.yellColor.get(); + dialogTitle = tr("Choose Yell Color"); + } else if (colorType == "narrate") { + currentColor = comms.narrateColor.get(); + dialogTitle = tr("Choose Tale Color"); + } else if (colorType == "sing") { + currentColor = comms.singColor.get(); + dialogTitle = tr("Choose Song Color"); + } else if (colorType == "pray") { + currentColor = comms.prayColor.get(); + dialogTitle = tr("Choose Prayer Color"); + } else if (colorType == "shout") { + currentColor = comms.shoutColor.get(); + dialogTitle = tr("Choose Shout Color"); + } else if (colorType == "talker_you") { + currentColor = comms.talkerYouColor.get(); + dialogTitle = tr("Choose You Color"); + } else if (colorType == "talker_player") { + currentColor = comms.talkerPlayerColor.get(); + dialogTitle = tr("Choose Player Color"); + } else if (colorType == "talker_npc") { + currentColor = comms.talkerNpcColor.get(); + dialogTitle = tr("Choose NPC Color"); + } else if (colorType == "talker_ally") { + currentColor = comms.talkerAllyColor.get(); + dialogTitle = tr("Choose Ally Color"); + } else if (colorType == "talker_neutral") { + currentColor = comms.talkerNeutralColor.get(); + dialogTitle = tr("Choose Neutral Color"); + } else if (colorType == "talker_enemy") { + currentColor = comms.talkerEnemyColor.get(); + dialogTitle = tr("Choose Enemy Color"); + } else { + return; + } + + QColor newColor = QColorDialog::getColor(currentColor, this, dialogTitle); + + if (newColor.isValid() && newColor != currentColor) { + // Set the new color based on type + if (colorType == "tell") { + comms.tellColor.set(newColor); + } else if (colorType == "whisper") { + comms.whisperColor.set(newColor); + } else if (colorType == "group") { + comms.groupColor.set(newColor); + } else if (colorType == "ask") { + comms.askColor.set(newColor); + } else if (colorType == "say") { + comms.sayColor.set(newColor); + } else if (colorType == "emote") { + comms.emoteColor.set(newColor); + } else if (colorType == "social") { + comms.socialColor.set(newColor); + } else if (colorType == "yell") { + comms.yellColor.set(newColor); + } else if (colorType == "narrate") { + comms.narrateColor.set(newColor); + } else if (colorType == "sing") { + comms.singColor.set(newColor); + } else if (colorType == "pray") { + comms.prayColor.set(newColor); + } else if (colorType == "shout") { + comms.shoutColor.set(newColor); + } else if (colorType == "talker_you") { + comms.talkerYouColor.set(newColor); + } else if (colorType == "talker_player") { + comms.talkerPlayerColor.set(newColor); + } else if (colorType == "talker_npc") { + comms.talkerNpcColor.set(newColor); + } else if (colorType == "talker_ally") { + comms.talkerAllyColor.set(newColor); + } else if (colorType == "talker_neutral") { + comms.talkerNeutralColor.set(newColor); + } else if (colorType == "talker_enemy") { + comms.talkerEnemyColor.set(newColor); + } + + updateColorButton(button, newColor); + emit sig_commsSettingsChanged(); + } +} + +void CommsPage::slot_onBgColorClicked() +{ + const QColor currentColor = getConfig().comms.backgroundColor.get(); + QColor newColor = QColorDialog::getColor(currentColor, this, tr("Choose Background Color")); + + if (newColor.isValid() && newColor != currentColor) { + setConfig().comms.backgroundColor.set(newColor); + updateColorButton(m_bgColorButton, newColor); + emit sig_commsSettingsChanged(); + } +} + +void CommsPage::slot_onYellAllCapsChanged(Qt::CheckState state) +{ + setConfig().comms.yellAllCaps.set(state == Qt::Checked); + emit sig_commsSettingsChanged(); +} + +void CommsPage::slot_onWhisperItalicChanged(Qt::CheckState state) +{ + setConfig().comms.whisperItalic.set(state == Qt::Checked); + emit sig_commsSettingsChanged(); +} + +void CommsPage::slot_onEmoteItalicChanged(Qt::CheckState state) +{ + setConfig().comms.emoteItalic.set(state == Qt::Checked); + emit sig_commsSettingsChanged(); +} + +void CommsPage::slot_onShowTimestampsChanged(Qt::CheckState state) +{ + setConfig().comms.showTimestamps.set(state == Qt::Checked); + emit sig_commsSettingsChanged(); +} diff --git a/src/preferences/commspage.h b/src/preferences/commspage.h new file mode 100644 index 000000000..eacb33866 --- /dev/null +++ b/src/preferences/commspage.h @@ -0,0 +1,71 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2025 The MMapper Authors + +#include "../global/macros.h" + +#include +#include +#include +#include + +class NODISCARD_QOBJECT CommsPage final : public QWidget +{ + Q_OBJECT + +public: + explicit CommsPage(QWidget *parent); + ~CommsPage() final; + + DELETE_CTORS_AND_ASSIGN_OPS(CommsPage); + +signals: + void sig_commsSettingsChanged(); + +public slots: + void slot_loadConfig(); + +private slots: + void slot_onColorClicked(); + void slot_onBgColorClicked(); + void slot_onYellAllCapsChanged(Qt::CheckState state); + void slot_onWhisperItalicChanged(Qt::CheckState state); + void slot_onEmoteItalicChanged(Qt::CheckState state); + void slot_onShowTimestampsChanged(Qt::CheckState state); + +private: + void setupUI(); + void connectSignals(); + void updateColorButton(QPushButton *button, const QColor &color); + + // Color buttons (one per communication type) + QPushButton *m_tellColorButton = nullptr; + QPushButton *m_whisperColorButton = nullptr; + QPushButton *m_groupColorButton = nullptr; + QPushButton *m_askColorButton = nullptr; + QPushButton *m_sayColorButton = nullptr; + QPushButton *m_emoteColorButton = nullptr; + QPushButton *m_socialColorButton = nullptr; + QPushButton *m_yellColorButton = nullptr; + QPushButton *m_narrateColorButton = nullptr; + QPushButton *m_prayColorButton = nullptr; + QPushButton *m_shoutColorButton = nullptr; + QPushButton *m_singColorButton = nullptr; + QPushButton *m_bgColorButton = nullptr; + + // Talker color buttons (based on GMCP talker-type) + QPushButton *m_talkerYouColorButton = nullptr; + QPushButton *m_talkerPlayerColorButton = nullptr; + QPushButton *m_talkerNpcColorButton = nullptr; + QPushButton *m_talkerAllyColorButton = nullptr; + QPushButton *m_talkerNeutralColorButton = nullptr; + QPushButton *m_talkerEnemyColorButton = nullptr; + + // Font styling checkboxes + QCheckBox *m_yellAllCapsCheck = nullptr; + QCheckBox *m_whisperItalicCheck = nullptr; + QCheckBox *m_emoteItalicCheck = nullptr; + + // Display options + QCheckBox *m_showTimestampsCheck = nullptr; +}; diff --git a/src/preferences/configdialog.cpp b/src/preferences/configdialog.cpp index cac8ffb69..870091db2 100644 --- a/src/preferences/configdialog.cpp +++ b/src/preferences/configdialog.cpp @@ -8,9 +8,11 @@ #include "autologpage.h" #include "clientpage.h" +#include "commspage.h" #include "generalpage.h" #include "graphicspage.h" #include "grouppage.h" +#include "hotkeyspage.h" #include "mumeprotocolpage.h" #include "parserpage.h" #include "pathmachinepage.h" @@ -38,12 +40,16 @@ ConfigDialog::ConfigDialog(QWidget *const parent) auto autoLogPage = new AutoLogPage(this); auto mumeProtocolPage = new MumeProtocolPage(this); auto pathmachinePage = new PathmachinePage(this); + auto hotkeysPage = new HotkeysPage(this); + auto commsPage = new CommsPage(this); m_pagesWidget = new QStackedWidget(this); auto *const pagesWidget = m_pagesWidget; pagesWidget->addWidget(generalPage); pagesWidget->addWidget(graphicsPage); + pagesWidget->addWidget(hotkeysPage); + pagesWidget->addWidget(commsPage); pagesWidget->addWidget(parserPage); pagesWidget->addWidget(clientPage); pagesWidget->addWidget(groupPage); @@ -80,10 +86,21 @@ ConfigDialog::ConfigDialog(QWidget *const parent) mumeProtocolPage, &MumeProtocolPage::slot_loadConfig); connect(this, &ConfigDialog::sig_loadConfig, pathmachinePage, &PathmachinePage::slot_loadConfig); + connect(this, &ConfigDialog::sig_loadConfig, hotkeysPage, &HotkeysPage::slot_loadConfig); + connect(hotkeysPage, &HotkeysPage::sig_hotkeysChanged, this, &ConfigDialog::sig_hotkeysChanged); + connect(this, &ConfigDialog::sig_loadConfig, commsPage, &CommsPage::slot_loadConfig); + connect(commsPage, + &CommsPage::sig_commsSettingsChanged, + this, + &ConfigDialog::sig_commsSettingsChanged); connect(graphicsPage, &GraphicsPage::sig_graphicsSettingsChanged, this, &ConfigDialog::sig_graphicsSettingsChanged); + connect(graphicsPage, + &GraphicsPage::sig_textureSettingsChanged, + this, + &ConfigDialog::sig_textureSettingsChanged); } ConfigDialog::~ConfigDialog() @@ -122,6 +139,8 @@ void ConfigDialog::createIcons() addItem(":/icons/generalcfg.png", tr("General")); addItem(":/icons/graphicscfg.png", tr("Graphics")); + addItem(":/icons/hotkeys.png", tr("Hotkeys")); + addItem(":/icons/comms.png", tr("Comms")); addItem(":/icons/parsercfg.png", tr("Parser")); addItem(":/icons/terminal.png", tr("Integrated\nMud Client")); addItem(":/icons/group-recolor.png", tr("Group Panel"));