diff --git a/.gitignore b/.gitignore index c62b8a3f2..5d8027ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ CMakeUserPresets.json CMakeSettings.json cmake-build-debug/ +# Qt installation from aqtinstall +6.5.3/ + # snapcraft parts/ diff --git a/AGENTS.md b/AGENTS.md index 2da7b4d9b..3121f0a9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,3 +22,32 @@ To enable fast, cached builds, the following steps are **required**: -DCMAKE_C_COMPILER_LAUNCHER=ccache \ ... [Other flags from BUILD.md] ``` + +--- + +## Code Formatting (clang-format) + +CI uses Ubuntu clang-format 18.1.8 via Docker. To run the exact same check locally: + +```bash +# Check a specific file +docker run --platform linux/amd64 --rm -v "$(pwd)":/src --entrypoint /bin/sh \ + ghcr.io/jidicula/clang-format:18 -c \ + "clang-format --dry-run -Werror -style=file /src/tests/TestHotkeyManager.cpp && echo '✓ Formatting OK'" \ + || echo '✗ Formatting errors found' + +# Auto-fix a file +docker run --platform linux/amd64 --rm -v "$(pwd)":/src --entrypoint /bin/sh \ + ghcr.io/jidicula/clang-format:18 -c \ + "clang-format -i -style=file /src/tests/TestHotkeyManager.cpp && echo '✓ File formatted'" + +# Check all src and tests files +docker run --platform linux/amd64 --rm -v "$(pwd)":/src --entrypoint /bin/sh \ + ghcr.io/jidicula/clang-format:18 -c \ + "find /src/src /src/tests -name '*.cpp' -o -name '*.h' | xargs clang-format --dry-run -Werror -style=file && echo '✓ All files OK'" \ + || echo '✗ Formatting errors found' +``` + +Note: `--platform linux/amd64` is required on ARM Macs for x86 emulation. + +Alternative (faster but may differ slightly): `brew install llvm@18` then use `/opt/homebrew/opt/llvm@18/bin/clang-format` diff --git a/docs/WEBASSEMBLY_BUILD.md b/docs/WEBASSEMBLY_BUILD.md new file mode 100644 index 000000000..7be217311 --- /dev/null +++ b/docs/WEBASSEMBLY_BUILD.md @@ -0,0 +1,121 @@ +--- +layout: default +title: WebAssembly Build Guide +--- + +## WebAssembly Build Guide + +### First-Time Setup: 1. Install Emscripten SDK + +```bash +cd ~/dev # or any directory you prefer +git clone https://github.com/emscripten-core/emsdk.git +cd emsdk +./emsdk install 3.1.25 +./emsdk activate 3.1.25 +``` + +### 2. Install Qt WebAssembly + +```bash +brew install aqtinstall +cd # your MMapper source directory +aqt install-qt mac desktop 6.5.3 wasm_multithread -m qtwebsockets -O . +``` + +### 3. Build script + +The build script is located at `scripts/build-wasm.sh`: + +```bash +#!/bin/bash +set -e + +# Source Emscripten environment +# Adjust path if you installed emsdk elsewhere +source "$HOME/dev/emsdk/emsdk_env.sh" + +# Paths - adjust these to match your setup +MMAPPER_SRC="" # e.g., /Users/yourname/dev/MMapper +QT_WASM="$MMAPPER_SRC/6.5.3/wasm_multithread" +QT_HOST="$MMAPPER_SRC/6.5.3/macos" + +"$QT_WASM/bin/qt-cmake" \ + -S "$MMAPPER_SRC" \ + -B "$MMAPPER_SRC/build-wasm" \ + -DQT_HOST_PATH="$QT_HOST" \ + -DWITH_OPENSSL=OFF \ + -DWITH_TESTS=OFF \ + -DWITH_WEBSOCKET=ON \ + -DWITH_UPDATER=OFF \ + -DCMAKE_BUILD_TYPE=Release + +# Build with limited parallelism to avoid system slowdown +# --parallel N uses N CPU cores. Omit the number to use all cores. +# Reduce N if your system becomes unresponsive during build. +cmake --build "$MMAPPER_SRC/build-wasm" --parallel 4 +``` + +### 4. Server script + +The server script is located at `scripts/server.py`: + +```python +import http.server +import socketserver + +PORT = 9742 + +class MyHandler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + # Required headers for SharedArrayBuffer (WASM multithreading) + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + http.server.SimpleHTTPRequestHandler.end_headers(self) + +with socketserver.TCPServer(("", PORT), MyHandler) as httpd: + print(f"Serving at http://localhost:{PORT}/mmapper.html") + httpd.serve_forever() +``` + +--- + +## Daily Use (Everything Installed) + +### Build + +```bash +cd +./scripts/build-wasm.sh +``` + +### Run + +```bash +cd build-wasm/src +python3 ../../scripts/server.py +``` + +### Open + +```text +http://localhost:9742/mmapper.html +``` + +### Clean rebuild + +```bash +rm -rf build-wasm && ./scripts/build-wasm.sh +``` + +--- + +## Path Reference + +- **``**: MMapper source directory + (e.g., `/Users/yourname/dev/MMapper`) +- **`$HOME/dev/emsdk`**: Emscripten SDK location (`~/dev/emsdk`) +- **`6.5.3/wasm_multithread`**: Qt WASM installed by aqt + (inside ``) +- **`6.5.3/macos`**: Qt native macOS host tools + (inside ``) diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 000000000..91007ff9c --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -e + +# Source Emscripten environment +# IMPORTANT: Change this path to match your emsdk installation location +source "$HOME/dev/emsdk/emsdk_env.sh" + +# Paths - automatically detect project root (parent of scripts directory) +MMAPPER_SRC="$(cd "$(dirname "$0")/.." && pwd)" +QT_WASM="$MMAPPER_SRC/6.5.3/wasm_multithread" +QT_HOST="$MMAPPER_SRC/6.5.3/macos" + +# Configure with qt-cmake +"$QT_WASM/bin/qt-cmake" \ + -S "$MMAPPER_SRC" \ + -B "$MMAPPER_SRC/build-wasm" \ + -DQT_HOST_PATH="$QT_HOST" \ + -DWITH_OPENSSL=OFF \ + -DWITH_TESTS=OFF \ + -DWITH_WEBSOCKET=ON \ + -DWITH_UPDATER=OFF \ + -DCMAKE_BUILD_TYPE=Release + +# Build (limited to 4 cores to avoid system slowdown) +cmake --build "$MMAPPER_SRC/build-wasm" --parallel 4 + +echo "" +echo "Build complete!" +echo "To run:" +echo " cd $MMAPPER_SRC/build-wasm/src && python3 $MMAPPER_SRC/scripts/server.py" +echo "Then open: http://localhost:9742/mmapper.html" diff --git a/scripts/server.py b/scripts/server.py new file mode 100755 index 000000000..1e76b202e --- /dev/null +++ b/scripts/server.py @@ -0,0 +1,15 @@ +import http.server +import socketserver + +PORT = 9742 + +class MyHandler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cross-Origin-Opener-Policy", "same-origin") + self.send_header("Cross-Origin-Embedder-Policy", "require-corp") + http.server.SimpleHTTPRequestHandler.end_headers(self) + +with socketserver.TCPServer(("", PORT), MyHandler) as httpd: + print(f"Serving MMapper WASM at http://localhost:{PORT}/mmapper.html") + print("Press Ctrl+C to stop") + httpd.serve_forever() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 22aa29011..1453459ea 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -37,6 +37,8 @@ set(mmapper_SRCS configuration/PasswordConfig.h configuration/configuration.cpp configuration/configuration.h + configuration/HotkeyManager.cpp + configuration/HotkeyManager.h display/CanvasMouseModeEnum.h display/Characters.cpp display/Characters.h @@ -432,6 +434,7 @@ set(mmapper_SRCS parser/AbstractParser-Commands.h parser/AbstractParser-Config.cpp parser/AbstractParser-Group.cpp + parser/AbstractParser-Hotkey.cpp parser/AbstractParser-Mark.cpp parser/AbstractParser-Room.cpp parser/AbstractParser-Timer.cpp @@ -867,6 +870,13 @@ set(CPACK_PACKAGE_VERSION_MAJOR ${CMAKE_MATCH_1}) set(CPACK_PACKAGE_VERSION_MINOR ${CMAKE_MATCH_2}) set(CPACK_PACKAGE_VERSION_PATCH ${CMAKE_MATCH_3}) set(CPACK_PACKAGE_VERSION_TWEAK ${CMAKE_MATCH_4}) +# If parsing failed (e.g., version is just a commit hash), fall back to MMAPPER_VERSION +if(NOT CPACK_PACKAGE_VERSION_MAJOR) + string(REGEX MATCH "^([0-9]+)\\.([0-9]+)\\.([0-9]+).*$" _ "${MMAPPER_VERSION}") + set(CPACK_PACKAGE_VERSION_MAJOR ${CMAKE_MATCH_1}) + set(CPACK_PACKAGE_VERSION_MINOR ${CMAKE_MATCH_2}) + set(CPACK_PACKAGE_VERSION_PATCH ${CMAKE_MATCH_3}) +endif() if(NOT CPACK_PACKAGE_VERSION_TWEAK) # Set to 0 if the commit count is missing set(CPACK_PACKAGE_VERSION_TWEAK 0) diff --git a/src/client/ClientWidget.cpp b/src/client/ClientWidget.cpp index 120d78572..7b2c0ea26 100644 --- a/src/client/ClientWidget.cpp +++ b/src/client/ClientWidget.cpp @@ -50,6 +50,13 @@ ClientWidget::ClientWidget(ConnectionListener &listener, QWidget *const parent) ClientWidget::~ClientWidget() = default; +void ClientWidget::playMume() +{ + qDebug() << "[ClientWidget::playMume] Auto-starting client and connecting to MUME"; + getUi().parent->setCurrentIndex(1); + getTelnet().connectToHost(m_listener); +} + ClientWidget::Pipeline::~Pipeline() { objs.clientTelnet.reset(); @@ -112,6 +119,15 @@ void ClientWidget::initStackedInputWidget() getSelf().slot_onShowMessage(msg); } void virt_requestPassword() final { getSelf().getInput().requestPassword(); } + void virt_scrollDisplay(bool pageUp) final + { + auto *scrollBar = getDisplay().verticalScrollBar(); + if (scrollBar) { + int pageStep = scrollBar->pageStep(); + int delta = pageUp ? -pageStep : pageStep; + scrollBar->setValue(scrollBar->value() + delta); + } + } }; auto &out = m_pipeline.outputs.stackedInputWidgetOutputs; out = std::make_unique(*this); diff --git a/src/client/ClientWidget.h b/src/client/ClientWidget.h index ef829460f..66320c65c 100644 --- a/src/client/ClientWidget.h +++ b/src/client/ClientWidget.h @@ -81,6 +81,7 @@ class NODISCARD_QOBJECT ClientWidget final : public QWidget public: NODISCARD bool isUsingClient() const; void displayReconnectHint(); + void playMume(); private: void relayMessage(const QString &msg) { emit sig_relayMessage(msg); } diff --git a/src/client/inputwidget.cpp b/src/client/inputwidget.cpp index b79a5d75e..8e0822beb 100644 --- a/src/client/inputwidget.cpp +++ b/src/client/inputwidget.cpp @@ -4,6 +4,7 @@ #include "inputwidget.h" +#include "../configuration/HotkeyManager.h" #include "../configuration/configuration.h" #include "../global/Color.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include @@ -19,6 +21,164 @@ static constexpr const int MIN_WORD_LENGTH = 3; static const QRegularExpression g_whitespaceRx(R"(\s+)"); +// Lookup tables for key name mapping (reduces cyclomatic complexity) +static const QHash &getNumpadKeyMap() +{ + static const QHash map{{Qt::Key_0, "NUMPAD0"}, + {Qt::Key_1, "NUMPAD1"}, + {Qt::Key_2, "NUMPAD2"}, + {Qt::Key_3, "NUMPAD3"}, + {Qt::Key_4, "NUMPAD4"}, + {Qt::Key_5, "NUMPAD5"}, + {Qt::Key_6, "NUMPAD6"}, + {Qt::Key_7, "NUMPAD7"}, + {Qt::Key_8, "NUMPAD8"}, + {Qt::Key_9, "NUMPAD9"}, + {Qt::Key_Slash, "NUMPAD_SLASH"}, + {Qt::Key_Asterisk, "NUMPAD_ASTERISK"}, + {Qt::Key_Minus, "NUMPAD_MINUS"}, + {Qt::Key_Plus, "NUMPAD_PLUS"}, + {Qt::Key_Period, "NUMPAD_PERIOD"}}; + return map; +} + +static QString getNumpadKeyName(int key) +{ + return getNumpadKeyMap().value(key); +} + +static const QHash &getNavigationKeyMap() +{ + static const QHash map{{Qt::Key_Home, "HOME"}, + {Qt::Key_End, "END"}, + {Qt::Key_Insert, "INSERT"}, + {Qt::Key_Help, "INSERT"}}; // macOS maps Insert to Help + return map; +} + +static QString getNavigationKeyName(int key) +{ + return getNavigationKeyMap().value(key); +} + +static const QHash &getMiscKeyMap() +{ + static const QHash map{{Qt::Key_QuoteLeft, "ACCENT"}, + {Qt::Key_1, "1"}, + {Qt::Key_2, "2"}, + {Qt::Key_3, "3"}, + {Qt::Key_4, "4"}, + {Qt::Key_5, "5"}, + {Qt::Key_6, "6"}, + {Qt::Key_7, "7"}, + {Qt::Key_8, "8"}, + {Qt::Key_9, "9"}, + {Qt::Key_0, "0"}, + {Qt::Key_Minus, "HYPHEN"}, + {Qt::Key_Equal, "EQUAL"}}; + return map; +} + +static QString getMiscKeyName(int key) +{ + return getMiscKeyMap().value(key); +} + +static KeyClassification classifyKey(int key, Qt::KeyboardModifiers mods) +{ + KeyClassification result; + result.realModifiers = mods & ~Qt::KeypadModifier; + + // Function keys F1-F12 (always handled) + if (key >= Qt::Key_F1 && key <= Qt::Key_F12) { + result.type = KeyType::FunctionKey; + result.keyName = QString("F%1").arg(key - Qt::Key_F1 + 1); + result.shouldHandle = true; + return result; + } + + // Numpad keys (only with KeypadModifier) + if (mods & Qt::KeypadModifier) { + QString name = getNumpadKeyName(key); + if (!name.isEmpty()) { + result.type = KeyType::NumpadKey; + result.keyName = name; + result.shouldHandle = true; + return result; + } + } + + // Navigation keys (HOME, END, INSERT - from any source) + { + QString name = getNavigationKeyName(key); + if (!name.isEmpty()) { + result.type = KeyType::NavigationKey; + result.keyName = name; + result.shouldHandle = true; + return result; + } + } + + // Arrow keys (UP, DOWN, LEFT, RIGHT) + if (key == Qt::Key_Up || key == Qt::Key_Down || key == Qt::Key_Left || key == Qt::Key_Right) { + result.type = KeyType::ArrowKey; + switch (key) { + case Qt::Key_Up: + result.keyName = "UP"; + break; + case Qt::Key_Down: + result.keyName = "DOWN"; + break; + case Qt::Key_Left: + result.keyName = "LEFT"; + break; + case Qt::Key_Right: + result.keyName = "RIGHT"; + break; + } + result.shouldHandle = true; + return result; + } + + // Misc keys (only when NOT from numpad) + if (!(mods & Qt::KeypadModifier)) { + QString name = getMiscKeyName(key); + if (!name.isEmpty()) { + result.type = KeyType::MiscKey; + result.keyName = name; + result.shouldHandle = true; + return result; + } + } + + // Terminal shortcuts (Ctrl+U, Ctrl+W, Ctrl+H or Cmd+U, Cmd+W, Cmd+H) + if ((key == Qt::Key_U || key == Qt::Key_W || key == Qt::Key_H) + && (result.realModifiers == Qt::ControlModifier + || result.realModifiers == Qt::MetaModifier)) { + result.type = KeyType::TerminalShortcut; + result.shouldHandle = true; + return result; + } + + // Basic keys (Tab, Enter - only without modifiers) + if ((key == Qt::Key_Tab || key == Qt::Key_Return || key == Qt::Key_Enter) + && result.realModifiers == Qt::NoModifier) { + result.type = KeyType::BasicKey; + result.shouldHandle = true; + return result; + } + + // Page keys (PageUp, PageDown - for scrolling display) + if (key == Qt::Key_PageUp || key == Qt::Key_PageDown) { + result.type = KeyType::PageKey; + result.keyName = (key == Qt::Key_PageUp) ? "PAGEUP" : "PAGEDOWN"; + result.shouldHandle = true; + return result; + } + + return result; +} + InputWidgetOutputs::~InputWidgetOutputs() = default; InputWidget::InputWidget(QWidget *const parent, InputWidgetOutputs &outputs) @@ -58,24 +218,32 @@ InputWidget::~InputWidget() = default; void InputWidget::keyPressEvent(QKeyEvent *const event) { - const auto currentKey = event->key(); - const auto currentModifiers = event->modifiers(); + // Check if this key was already handled in ShortcutOverride + if (m_handledInShortcutOverride) { + m_handledInShortcutOverride = false; // Reset for next key + event->accept(); + return; + } + + const auto key = event->key(); + const auto mods = event->modifiers(); + // Handle tabbing state (unchanged) if (m_tabbing) { - if (currentKey != Qt::Key_Tab) { + if (key != Qt::Key_Tab) { m_tabbing = false; } // If Backspace or Escape is pressed, reject the completion QTextCursor current = textCursor(); - if (currentKey == Qt::Key_Backspace || currentKey == Qt::Key_Escape) { + if (key == Qt::Key_Backspace || key == Qt::Key_Escape) { current.removeSelectedText(); event->accept(); return; } // For any other key press, accept the completion - if (currentKey != Qt::Key_Tab) { + if (key != Qt::Key_Tab) { current.movePosition(QTextCursor::Right, QTextCursor::MoveAnchor, static_cast(current.selectedText().length())); @@ -83,90 +251,67 @@ void InputWidget::keyPressEvent(QKeyEvent *const event) } } - // REVISIT: if (useConsoleEscapeKeys) ... - if (currentModifiers == Qt::ControlModifier) { - switch (currentKey) { - case Qt::Key_H: // ^H = backspace - // REVISIT: can this be translated to backspace key? - break; - case Qt::Key_U: // ^U = delete line (clear the input) - base::clear(); - return; - case Qt::Key_W: // ^W = delete word - // REVISIT: can this be translated to ctrl+shift+leftarrow + backspace? - break; - } + // Classify the key ONCE + auto classification = classifyKey(key, mods); - } else if (currentModifiers == Qt::NoModifier) { - switch (currentKey) { - /** Submit the current text */ - case Qt::Key_Return: - case Qt::Key_Enter: - gotInput(); + if (classification.shouldHandle) { + switch (classification.type) { + case KeyType::FunctionKey: + functionKeyPressed(key, classification.realModifiers); event->accept(); return; -#define X_CASE(_Name) \ - case Qt::Key_##_Name: { \ - event->accept(); \ - functionKeyPressed(#_Name); \ - break; \ - } - X_CASE(F1); - X_CASE(F2); - X_CASE(F3); - X_CASE(F4); - X_CASE(F5); - X_CASE(F6); - X_CASE(F7); - X_CASE(F8); - X_CASE(F9); - X_CASE(F10); - X_CASE(F11); - X_CASE(F12); - -#undef X_CASE - - /** Key bindings for word history and tab completion */ - case Qt::Key_Up: - case Qt::Key_Down: - case Qt::Key_Tab: - if (tryHistory(currentKey)) { + case KeyType::NumpadKey: + if (numpadKeyPressed(key, classification.realModifiers)) { event->accept(); return; } break; - } - } else if (currentModifiers == Qt::KeypadModifier) { - if constexpr (CURRENT_PLATFORM == PlatformEnum::Mac) { - // NOTE: MacOS does not differentiate between arrow keys and the keypad keys - // and as such we disable keypad movement functionality in favor of history - switch (currentKey) { - case Qt::Key_Up: - case Qt::Key_Down: - case Qt::Key_Tab: - if (tryHistory(currentKey)) { - event->accept(); - return; - } - break; + case KeyType::NavigationKey: + if (navigationKeyPressed(key, classification.realModifiers)) { + event->accept(); + return; + } + break; + + case KeyType::ArrowKey: + if (arrowKeyPressed(key, classification.realModifiers)) { + event->accept(); + return; } - } else { - switch (currentKey) { - case Qt::Key_Up: - case Qt::Key_Down: - case Qt::Key_Left: - case Qt::Key_Right: - case Qt::Key_PageUp: - case Qt::Key_PageDown: - case Qt::Key_Clear: // Numpad 5 - case Qt::Key_Home: - case Qt::Key_End: - keypadMovement(currentKey); + break; + + case KeyType::MiscKey: + if (miscKeyPressed(key, classification.realModifiers)) { event->accept(); return; } + break; + + case KeyType::TerminalShortcut: + if (handleTerminalShortcut(key)) { + event->accept(); + return; + } + break; + + case KeyType::BasicKey: + if (handleBasicKey(key)) { + event->accept(); + return; + } + break; + + case KeyType::PageKey: + if (handlePageKey(key, classification.realModifiers)) { + event->accept(); + return; + } + break; + + case KeyType::Other: + break; } } @@ -174,52 +319,170 @@ void InputWidget::keyPressEvent(QKeyEvent *const event) base::keyPressEvent(event); } -void InputWidget::functionKeyPressed(const QString &keyName) +void InputWidget::functionKeyPressed(int key, Qt::KeyboardModifiers modifiers) +{ + // Check if there's a configured hotkey for this key combination + // Function keys are never numpad keys + const QString command = getConfig().hotkeyManager.getCommandQString(key, modifiers, false); + + if (!command.isEmpty()) { + sendCommandWithSeparator(command); + } else { + // No hotkey configured, send the key name as-is (e.g., "CTRL+F1") + QString keyName = QString("F%1").arg(key - Qt::Key_F1 + 1); + QString fullKeyString = buildHotkeyString(keyName, modifiers); + sendCommandWithSeparator(fullKeyString); + } +} + +bool InputWidget::numpadKeyPressed(int key, Qt::KeyboardModifiers modifiers) +{ + // Check if there's a configured hotkey for this numpad key (isNumpad=true) + const QString command = getConfig().hotkeyManager.getCommandQString(key, modifiers, true); + + if (!command.isEmpty()) { + sendCommandWithSeparator(command); + return true; + } + return false; +} + +bool InputWidget::navigationKeyPressed(int key, Qt::KeyboardModifiers modifiers) +{ + // Check if there's a configured hotkey for this navigation key (isNumpad=false) + const QString command = getConfig().hotkeyManager.getCommandQString(key, modifiers, false); + + if (!command.isEmpty()) { + sendCommandWithSeparator(command); + return true; + } else { + return false; + } +} + +QString InputWidget::buildHotkeyString(const QString &keyName, Qt::KeyboardModifiers modifiers) +{ + QStringList parts; + + if (modifiers & Qt::ControlModifier) { + parts << "CTRL"; + } + if (modifiers & Qt::ShiftModifier) { + parts << "SHIFT"; + } + if (modifiers & Qt::AltModifier) { + parts << "ALT"; + } + if (modifiers & Qt::MetaModifier) { + parts << "META"; + } + + parts << keyName; + return parts.join("+"); +} + +bool InputWidget::arrowKeyPressed(const int key, Qt::KeyboardModifiers modifiers) { - sendUserInput(keyName); + // UP/DOWN with no modifiers cycle through command history + if (modifiers == Qt::NoModifier) { + if (key == Qt::Key_Up) { + backwardHistory(); + return true; + } else if (key == Qt::Key_Down) { + forwardHistory(); + return true; + } + } + + // Arrow keys with modifiers check for hotkeys (isNumpad=false) + const QString command = getConfig().hotkeyManager.getCommandQString(key, modifiers, false); + + if (!command.isEmpty()) { + sendCommandWithSeparator(command); + return true; + } + + // Let default behavior handle bare arrow keys (cursor movement) + return false; } -void InputWidget::keypadMovement(const int key) +bool InputWidget::miscKeyPressed(int key, Qt::KeyboardModifiers modifiers) +{ + // Check if there's a configured hotkey for this misc key (isNumpad=false) + const QString command = getConfig().hotkeyManager.getCommandQString(key, modifiers, false); + + if (!command.isEmpty()) { + sendCommandWithSeparator(command); + return true; + } + return false; +} + +bool InputWidget::handleTerminalShortcut(int key) { switch (key) { - case Qt::Key_Up: - sendUserInput("north"); - break; - case Qt::Key_Down: - sendUserInput("south"); - break; - case Qt::Key_Left: - sendUserInput("west"); - break; - case Qt::Key_Right: - sendUserInput("east"); - break; - case Qt::Key_PageUp: - sendUserInput("up"); - break; - case Qt::Key_PageDown: - sendUserInput("down"); - break; - case Qt::Key_Clear: // Numpad 5 - sendUserInput("exits"); - break; - case Qt::Key_Home: - sendUserInput("open exit"); - break; - case Qt::Key_End: - sendUserInput("close exit"); - break; - case Qt::Key_Insert: - sendUserInput("flee"); - break; - case Qt::Key_Delete: - case Qt::Key_Plus: - case Qt::Key_Minus: - case Qt::Key_Slash: - case Qt::Key_Asterisk: - default: - qDebug() << "! Unknown keypad movement" << key; + case Qt::Key_H: // ^H = backspace + textCursor().deletePreviousChar(); + return true; + + case Qt::Key_U: // ^U = delete line (clear the input) + base::clear(); + return true; + + case Qt::Key_W: // ^W = delete word (whitespace-delimited) + { + QTextCursor cursor = textCursor(); + // If at start, nothing to delete + if (cursor.atStart()) { + return true; + } + // First, skip any trailing whitespace before the word + while (!cursor.atStart() && document()->characterAt(cursor.position() - 1).isSpace()) { + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + } + // Then, select the word (non-whitespace characters) + while (!cursor.atStart() && !document()->characterAt(cursor.position() - 1).isSpace()) { + cursor.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor); + } + cursor.removeSelectedText(); + setTextCursor(cursor); + return true; + } + } + return false; +} + +bool InputWidget::handleBasicKey(int key) +{ + switch (key) { + case Qt::Key_Return: + case Qt::Key_Enter: + gotInput(); + return true; + + case Qt::Key_Tab: + return tryHistory(key); + } + return false; +} + +bool InputWidget::handlePageKey(int key, Qt::KeyboardModifiers modifiers) +{ + // PageUp/PageDown without modifiers scroll the display widget + if (modifiers == Qt::NoModifier) { + bool isPageUp = (key == Qt::Key_PageUp); + m_outputs.scrollDisplay(isPageUp); + return true; } + + // With modifiers, check for hotkeys (isNumpad=false for page keys) + const QString command = getConfig().hotkeyManager.getCommandQString(key, modifiers, false); + if (!command.isEmpty()) { + sendCommandWithSeparator(command); + return true; + } + + return false; } bool InputWidget::tryHistory(const int key) @@ -247,6 +510,25 @@ bool InputWidget::tryHistory(const int key) return false; } +void InputWidget::sendCommandWithSeparator(const QString &command) +{ + const auto &settings = getConfig().integratedClient; + + // Handle command separator (e.g., "l;;look" sends "l" then "look") + if (settings.useCommandSeparator && !settings.commandSeparator.isEmpty()) { + const QString &sep = settings.commandSeparator; + const QString escaped = QRegularExpression::escape(sep); + const QRegularExpression regex(QString("(?type() == QEvent::ShortcutOverride) { + QKeyEvent *keyEvent = static_cast(event); + auto classification = classifyKey(keyEvent->key(), keyEvent->modifiers()); + + if (classification.shouldHandle) { + // Handle directly if there are real modifiers (some don't generate KeyPress) + if (classification.realModifiers != Qt::NoModifier) { + bool handled = false; + + switch (classification.type) { + case KeyType::FunctionKey: + functionKeyPressed(keyEvent->key(), classification.realModifiers); + handled = true; + break; + case KeyType::NumpadKey: + handled = numpadKeyPressed(keyEvent->key(), classification.realModifiers); + break; + case KeyType::NavigationKey: + handled = navigationKeyPressed(keyEvent->key(), classification.realModifiers); + break; + case KeyType::ArrowKey: + handled = arrowKeyPressed(keyEvent->key(), classification.realModifiers); + break; + case KeyType::MiscKey: + handled = miscKeyPressed(keyEvent->key(), classification.realModifiers); + break; + case KeyType::PageKey: + handled = handlePageKey(keyEvent->key(), classification.realModifiers); + break; + case KeyType::TerminalShortcut: + case KeyType::BasicKey: + case KeyType::Other: + break; + } + + if (handled) { + m_handledInShortcutOverride = true; + event->accept(); + return true; + } + } + + // Accept so KeyPress comes through + event->accept(); + return true; + } + } + m_paletteManager.tryUpdateFromFocusEvent(*this, deref(event).type()); return QPlainTextEdit::event(event); } diff --git a/src/client/inputwidget.h b/src/client/inputwidget.h index a6bdf9795..d6575b42a 100644 --- a/src/client/inputwidget.h +++ b/src/client/inputwidget.h @@ -22,6 +22,27 @@ class QKeyEvent; class QObject; class QWidget; +// Key classification system for unified key handling +enum class KeyType { + FunctionKey, // F1-F12 + NumpadKey, // NUMPAD0-9, NUMPAD_SLASH, etc. + NavigationKey, // HOME, END, INSERT + ArrowKey, // UP, DOWN (for history), LEFT, RIGHT (for hotkeys) + MiscKey, // ACCENT, number row, HYPHEN, EQUAL + TerminalShortcut, // Ctrl+U, Ctrl+W, Ctrl+H + BasicKey, // Enter, Tab (no modifiers) + PageKey, // PageUp, PageDown (for scrolling display) + Other // Not handled by us +}; + +struct NODISCARD KeyClassification +{ + KeyType type = KeyType::Other; + QString keyName; + Qt::KeyboardModifiers realModifiers = Qt::NoModifier; + bool shouldHandle = false; +}; + class NODISCARD InputHistory final : private std::list { private: @@ -82,12 +103,14 @@ struct NODISCARD InputWidgetOutputs void displayMessage(const QString &msg) { virt_displayMessage(msg); } void showMessage(const QString &msg, const int timeout) { virt_showMessage(msg, timeout); } void gotPasswordInput(const QString &password) { virt_gotPasswordInput(password); } + void scrollDisplay(bool pageUp) { virt_scrollDisplay(pageUp); } private: virtual void virt_sendUserInput(const QString &msg) = 0; virtual void virt_displayMessage(const QString &msg) = 0; virtual void virt_showMessage(const QString &msg, int timeout) = 0; virtual void virt_gotPasswordInput(const QString &password) = 0; + virtual void virt_scrollDisplay(bool pageUp) = 0; }; class NODISCARD_QOBJECT InputWidget final : public QPlainTextEdit @@ -104,6 +127,7 @@ class NODISCARD_QOBJECT InputWidget final : public QPlainTextEdit InputHistory m_inputHistory; PaletteManager m_paletteManager; bool m_tabbing = false; + bool m_handledInShortcutOverride = false; // Track if key was already handled in ShortcutOverride public: explicit InputWidget(QWidget *parent, InputWidgetOutputs &); @@ -118,8 +142,15 @@ class NODISCARD_QOBJECT InputWidget final : public QPlainTextEdit private: void gotInput(); NODISCARD bool tryHistory(int); - void keypadMovement(int); - void functionKeyPressed(const QString &keyName); + NODISCARD bool numpadKeyPressed(int key, Qt::KeyboardModifiers modifiers); + NODISCARD bool navigationKeyPressed(int key, Qt::KeyboardModifiers modifiers); + NODISCARD bool arrowKeyPressed(int key, Qt::KeyboardModifiers modifiers); + NODISCARD bool miscKeyPressed(int key, Qt::KeyboardModifiers modifiers); + void functionKeyPressed(int key, Qt::KeyboardModifiers modifiers); + NODISCARD QString buildHotkeyString(const QString &keyName, Qt::KeyboardModifiers modifiers); + NODISCARD bool handleTerminalShortcut(int key); + NODISCARD bool handleBasicKey(int key); + NODISCARD bool handlePageKey(int key, Qt::KeyboardModifiers modifiers); private: void tabComplete(); @@ -130,4 +161,5 @@ class NODISCARD_QOBJECT InputWidget final : public QPlainTextEdit private: void sendUserInput(const QString &msg) { m_outputs.sendUserInput(msg); } + void sendCommandWithSeparator(const QString &command); }; diff --git a/src/client/stackedinputwidget.cpp b/src/client/stackedinputwidget.cpp index 220ce971d..71865e760 100644 --- a/src/client/stackedinputwidget.cpp +++ b/src/client/stackedinputwidget.cpp @@ -77,6 +77,8 @@ void StackedInputWidget::initInput() { getSelf().gotPasswordInput(password); } + + void virt_scrollDisplay(bool pageUp) final { getOutput().scrollDisplay(pageUp); } }; auto &out = m_pipeline.outputs.inputOutputs; diff --git a/src/client/stackedinputwidget.h b/src/client/stackedinputwidget.h index ae4684f32..1ca0ee655 100644 --- a/src/client/stackedinputwidget.h +++ b/src/client/stackedinputwidget.h @@ -39,6 +39,8 @@ struct NODISCARD StackedInputWidgetOutputs void showMessage(const QString &msg, const int timeout) { virt_showMessage(msg, timeout); } // request password void requestPassword() { virt_requestPassword(); } + // scroll display (pageUp=true for PageUp, false for PageDown) + void scrollDisplay(bool pageUp) { virt_scrollDisplay(pageUp); } private: // sent to the mud @@ -49,6 +51,8 @@ struct NODISCARD StackedInputWidgetOutputs virtual void virt_showMessage(const QString &msg, int timeout) = 0; // request password virtual void virt_requestPassword() = 0; + // scroll display + virtual void virt_scrollDisplay(bool pageUp) = 0; }; class NODISCARD_QOBJECT StackedInputWidget final : public QStackedWidget diff --git a/src/configuration/HotkeyManager.cpp b/src/configuration/HotkeyManager.cpp new file mode 100644 index 000000000..e33750825 --- /dev/null +++ b/src/configuration/HotkeyManager.cpp @@ -0,0 +1,738 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include "HotkeyManager.h" + +#include "../global/TextUtils.h" + +#include +#include + +#include +#include +#include +#include + +namespace { +constexpr const char *SETTINGS_GROUP = "IntegratedClient/Hotkeys"; +constexpr const char *SETTINGS_RAW_CONTENT_KEY = "IntegratedClient/HotkeysRawContent"; + +// Default hotkeys content preserving order and formatting +const QString DEFAULT_HOTKEYS_CONTENT = R"(# Hotkey Configuration +# Format: _hotkey KEY command +# Lines starting with # are comments. + +# Basic movement (numpad) +_hotkey NUMPAD8 n +_hotkey NUMPAD4 w +_hotkey NUMPAD6 e +_hotkey NUMPAD5 s +_hotkey NUMPAD_MINUS u +_hotkey NUMPAD_PLUS d + +# Open exit (CTRL+numpad) +_hotkey CTRL+NUMPAD8 open exit n +_hotkey CTRL+NUMPAD4 open exit w +_hotkey CTRL+NUMPAD6 open exit e +_hotkey CTRL+NUMPAD5 open exit s +_hotkey CTRL+NUMPAD_MINUS open exit u +_hotkey CTRL+NUMPAD_PLUS open exit d + +# Close exit (ALT+numpad) +_hotkey ALT+NUMPAD8 close exit n +_hotkey ALT+NUMPAD4 close exit w +_hotkey ALT+NUMPAD6 close exit e +_hotkey ALT+NUMPAD5 close exit s +_hotkey ALT+NUMPAD_MINUS close exit u +_hotkey ALT+NUMPAD_PLUS close exit d + +# Pick exit (SHIFT+numpad) +_hotkey SHIFT+NUMPAD8 pick exit n +_hotkey SHIFT+NUMPAD4 pick exit w +_hotkey SHIFT+NUMPAD6 pick exit e +_hotkey SHIFT+NUMPAD5 pick exit s +_hotkey SHIFT+NUMPAD_MINUS pick exit u +_hotkey SHIFT+NUMPAD_PLUS pick exit d + +# Other actions +_hotkey NUMPAD7 look +_hotkey NUMPAD9 flee +_hotkey NUMPAD2 lead +_hotkey NUMPAD0 bash +_hotkey NUMPAD1 ride +_hotkey NUMPAD3 stand +)"; + +// Key name to Qt::Key mapping +const QHash &getKeyNameToQtKeyMap() +{ + static const QHash map{// Function keys + {"F1", Qt::Key_F1}, + {"F2", Qt::Key_F2}, + {"F3", Qt::Key_F3}, + {"F4", Qt::Key_F4}, + {"F5", Qt::Key_F5}, + {"F6", Qt::Key_F6}, + {"F7", Qt::Key_F7}, + {"F8", Qt::Key_F8}, + {"F9", Qt::Key_F9}, + {"F10", Qt::Key_F10}, + {"F11", Qt::Key_F11}, + {"F12", Qt::Key_F12}, + // Numpad + {"NUMPAD0", Qt::Key_0}, + {"NUMPAD1", Qt::Key_1}, + {"NUMPAD2", Qt::Key_2}, + {"NUMPAD3", Qt::Key_3}, + {"NUMPAD4", Qt::Key_4}, + {"NUMPAD5", Qt::Key_5}, + {"NUMPAD6", Qt::Key_6}, + {"NUMPAD7", Qt::Key_7}, + {"NUMPAD8", Qt::Key_8}, + {"NUMPAD9", Qt::Key_9}, + {"NUMPAD_SLASH", Qt::Key_Slash}, + {"NUMPAD_ASTERISK", Qt::Key_Asterisk}, + {"NUMPAD_MINUS", Qt::Key_Minus}, + {"NUMPAD_PLUS", Qt::Key_Plus}, + {"NUMPAD_PERIOD", Qt::Key_Period}, + // Navigation + {"HOME", Qt::Key_Home}, + {"END", Qt::Key_End}, + {"INSERT", Qt::Key_Insert}, + {"PAGEUP", Qt::Key_PageUp}, + {"PAGEDOWN", Qt::Key_PageDown}, + // Arrow keys + {"UP", Qt::Key_Up}, + {"DOWN", Qt::Key_Down}, + {"LEFT", Qt::Key_Left}, + {"RIGHT", Qt::Key_Right}, + // Misc + {"ACCENT", Qt::Key_QuoteLeft}, + {"0", Qt::Key_0}, + {"1", Qt::Key_1}, + {"2", Qt::Key_2}, + {"3", Qt::Key_3}, + {"4", Qt::Key_4}, + {"5", Qt::Key_5}, + {"6", Qt::Key_6}, + {"7", Qt::Key_7}, + {"8", Qt::Key_8}, + {"9", Qt::Key_9}, + {"HYPHEN", Qt::Key_Minus}, + {"EQUAL", Qt::Key_Equal}}; + return map; +} + +// Qt::Key to key name mapping (for non-numpad keys) +const QHash &getQtKeyToKeyNameMap() +{ + static const QHash map{// Function keys + {Qt::Key_F1, "F1"}, + {Qt::Key_F2, "F2"}, + {Qt::Key_F3, "F3"}, + {Qt::Key_F4, "F4"}, + {Qt::Key_F5, "F5"}, + {Qt::Key_F6, "F6"}, + {Qt::Key_F7, "F7"}, + {Qt::Key_F8, "F8"}, + {Qt::Key_F9, "F9"}, + {Qt::Key_F10, "F10"}, + {Qt::Key_F11, "F11"}, + {Qt::Key_F12, "F12"}, + // Navigation + {Qt::Key_Home, "HOME"}, + {Qt::Key_End, "END"}, + {Qt::Key_Insert, "INSERT"}, + {Qt::Key_PageUp, "PAGEUP"}, + {Qt::Key_PageDown, "PAGEDOWN"}, + // Arrow keys + {Qt::Key_Up, "UP"}, + {Qt::Key_Down, "DOWN"}, + {Qt::Key_Left, "LEFT"}, + {Qt::Key_Right, "RIGHT"}, + // Misc + {Qt::Key_QuoteLeft, "ACCENT"}, + {Qt::Key_Equal, "EQUAL"}}; + return map; +} + +// Numpad Qt::Key to key name mapping (requires KeypadModifier to be set) +const QHash &getNumpadQtKeyToKeyNameMap() +{ + static const QHash map{{Qt::Key_0, "NUMPAD0"}, + {Qt::Key_1, "NUMPAD1"}, + {Qt::Key_2, "NUMPAD2"}, + {Qt::Key_3, "NUMPAD3"}, + {Qt::Key_4, "NUMPAD4"}, + {Qt::Key_5, "NUMPAD5"}, + {Qt::Key_6, "NUMPAD6"}, + {Qt::Key_7, "NUMPAD7"}, + {Qt::Key_8, "NUMPAD8"}, + {Qt::Key_9, "NUMPAD9"}, + {Qt::Key_Slash, "NUMPAD_SLASH"}, + {Qt::Key_Asterisk, "NUMPAD_ASTERISK"}, + {Qt::Key_Minus, "NUMPAD_MINUS"}, + {Qt::Key_Plus, "NUMPAD_PLUS"}, + {Qt::Key_Period, "NUMPAD_PERIOD"}}; + return map; +} + +// Non-numpad digit/symbol key names +const QHash &getNonNumpadDigitKeyNameMap() +{ + static const QHash map{{Qt::Key_0, "0"}, + {Qt::Key_1, "1"}, + {Qt::Key_2, "2"}, + {Qt::Key_3, "3"}, + {Qt::Key_4, "4"}, + {Qt::Key_5, "5"}, + {Qt::Key_6, "6"}, + {Qt::Key_7, "7"}, + {Qt::Key_8, "8"}, + {Qt::Key_9, "9"}, + {Qt::Key_Minus, "HYPHEN"}}; + return map; +} + +// Static set of valid base key names for validation +// Derived from HotkeyManager::getAvailableKeyNames() to avoid duplication and drift +const QSet &getValidBaseKeys() +{ + static const QSet validKeys = []() { + QSet keys; + for (const QString &key : HotkeyManager::getAvailableKeyNames()) { + keys.insert(key); + } + return keys; + }(); + return validKeys; +} + +// Check if key name is a numpad key +bool isNumpadKeyName(const QString &keyName) +{ + return keyName.startsWith("NUMPAD"); +} + +} // namespace + +HotkeyManager::HotkeyManager() +{ + loadFromSettings(); +} + +void HotkeyManager::loadFromSettings() +{ + m_hotkeys.clear(); + m_orderedHotkeys.clear(); + + QSettings settings; + + // Try to load raw content first (preserves comments and order) + m_rawContent = settings.value(SETTINGS_RAW_CONTENT_KEY).toString(); + + if (m_rawContent.isEmpty()) { + // Check if there are legacy hotkeys in the old format + settings.beginGroup(SETTINGS_GROUP); + const QStringList keys = settings.childKeys(); + settings.endGroup(); + + if (keys.isEmpty()) { + // First run - use default hotkeys + m_rawContent = DEFAULT_HOTKEYS_CONTENT; + } else { + // Migrate from legacy format: build raw content from existing keys + QString migrated; + QTextStream stream(&migrated); + stream << "# Hotkey Configuration\n"; + stream << "# Format: _hotkey KEY command\n\n"; + + settings.beginGroup(SETTINGS_GROUP); + for (const QString &key : keys) { + QString command = settings.value(key).toString(); + if (!command.isEmpty()) { + stream << "_hotkey " << key << " " << command << "\n"; + } + } + settings.endGroup(); + m_rawContent = migrated; + } + // Save in new format + saveToSettings(); + } + + // Parse the raw content to populate lookup structures + parseRawContent(); +} + +void HotkeyManager::parseRawContent() +{ + // Regex for parsing _hotkey commands: _hotkey KEY command + static const QRegularExpression hotkeyRegex(R"(^\s*_hotkey\s+(\S+)\s+(.+)$)"); + + m_hotkeys.clear(); + m_orderedHotkeys.clear(); + + const QStringList lines = m_rawContent.split('\n'); + + for (const QString &line : lines) { + QString trimmedLine = line.trimmed(); + + // Skip empty lines and comments + if (trimmedLine.isEmpty() || trimmedLine.startsWith('#')) { + continue; + } + + // Parse hotkey command + QRegularExpressionMatch match = hotkeyRegex.match(trimmedLine); + if (match.hasMatch()) { + QString keyStr = normalizeKeyString(match.captured(1)); + QString commandQStr = match.captured(2).trimmed(); + if (!keyStr.isEmpty() && !commandQStr.isEmpty()) { + // Convert string to HotkeyKey for fast lookup + HotkeyKey hk = stringToHotkeyKey(keyStr); + if (hk.key != 0) { + // Convert command to std::string for storage (cold path - OK) + std::string command = mmqt::toStdStringUtf8(commandQStr); + m_hotkeys[hk] = command; + m_orderedHotkeys.emplace_back(keyStr, command); + } + } + } + } +} + +void HotkeyManager::saveToSettings() const +{ + QSettings settings; + + // Remove legacy format if it exists + settings.remove(SETTINGS_GROUP); + + // Save the raw content (preserves comments, order, and formatting) + settings.setValue(SETTINGS_RAW_CONTENT_KEY, m_rawContent); +} + +bool HotkeyManager::setHotkey(const QString &keyName, const QString &command) +{ + QString normalizedKey = normalizeKeyString(keyName); + if (normalizedKey.isEmpty()) { + return false; // Invalid key name + } + + // Update or add in raw content + static const QRegularExpression hotkeyLineRegex(R"(^(\s*_hotkey\s+)(\S+)(\s+)(.+)$)", + QRegularExpression::MultilineOption); + + QString newLine = "_hotkey " + normalizedKey + " " + command; + bool found = false; + + // Try to find and replace existing hotkey line + QStringList lines = m_rawContent.split('\n'); + for (int i = 0; i < lines.size(); ++i) { + QRegularExpressionMatch match = hotkeyLineRegex.match(lines[i]); + if (match.hasMatch()) { + QString existingKey = normalizeKeyString(match.captured(2)); + if (existingKey == normalizedKey) { + lines[i] = newLine; + found = true; + break; + } + } + } + + if (!found) { + // Append new hotkey at the end + if (!m_rawContent.endsWith('\n')) { + m_rawContent += '\n'; + } + m_rawContent += newLine + '\n'; + } else { + m_rawContent = lines.join('\n'); + } + + // Re-parse and save + parseRawContent(); + saveToSettings(); + return true; +} + +void HotkeyManager::removeHotkey(const QString &keyName) +{ + QString normalizedKey = normalizeKeyString(keyName); + if (normalizedKey.isEmpty()) { + return; + } + + HotkeyKey hk = stringToHotkeyKey(normalizedKey); + if (m_hotkeys.count(hk) == 0) { + return; + } + + // Remove from raw content + static const QRegularExpression hotkeyLineRegex(R"(^\s*_hotkey\s+(\S+)\s+.+$)"); + + QStringList lines = m_rawContent.split('\n'); + QStringList newLines; + + for (const QString &line : lines) { + QRegularExpressionMatch match = hotkeyLineRegex.match(line); + if (match.hasMatch()) { + QString existingKey = normalizeKeyString(match.captured(1)); + if (existingKey == normalizedKey) { + // Skip this line (remove it) + continue; + } + } + newLines.append(line); + } + + m_rawContent = newLines.join('\n'); + + // Re-parse and save + parseRawContent(); + saveToSettings(); +} + +std::string HotkeyManager::getCommand(int key, Qt::KeyboardModifiers modifiers, bool isNumpad) const +{ + // Strip KeypadModifier from modifiers - numpad distinction is tracked via isNumpad flag + HotkeyKey hk(key, modifiers & ~Qt::KeypadModifier, isNumpad); + auto it = m_hotkeys.find(hk); + if (it != m_hotkeys.end()) { + return it->second; + } + return std::string(); +} + +std::string HotkeyManager::getCommand(const QString &keyName) const +{ + QString normalizedKey = normalizeKeyString(keyName); + if (normalizedKey.isEmpty()) { + return std::string(); + } + + HotkeyKey hk = stringToHotkeyKey(normalizedKey); + if (hk.key == 0) { + return std::string(); + } + + auto it = m_hotkeys.find(hk); + if (it != m_hotkeys.end()) { + return it->second; + } + return std::string(); +} + +QString HotkeyManager::getCommandQString(int key, + Qt::KeyboardModifiers modifiers, + bool isNumpad) const +{ + const std::string cmd = getCommand(key, modifiers, isNumpad); + if (cmd.empty()) { + return QString(); + } + return mmqt::toQStringUtf8(cmd); +} + +QString HotkeyManager::getCommandQString(const QString &keyName) const +{ + const std::string cmd = getCommand(keyName); + if (cmd.empty()) { + return QString(); + } + return mmqt::toQStringUtf8(cmd); +} + +bool HotkeyManager::hasHotkey(const QString &keyName) const +{ + QString normalizedKey = normalizeKeyString(keyName); + if (normalizedKey.isEmpty()) { + return false; + } + + HotkeyKey hk = stringToHotkeyKey(normalizedKey); + return hk.key != 0 && m_hotkeys.count(hk) > 0; +} + +QString HotkeyManager::normalizeKeyString(const QString &keyString) +{ + // Split by '+' to get individual parts + QStringList parts = keyString.split('+', Qt::SkipEmptyParts); + + if (parts.isEmpty()) { + qWarning() << "HotkeyManager: empty or invalid key string:" << keyString; + return QString(); + } + + // The last part is always the base key (e.g., F1, F2) + QString baseKey = parts.last(); + parts.removeLast(); + + // Build canonical order: CTRL, SHIFT, ALT, META + QStringList normalizedParts; + + bool hasCtrl = false; + bool hasShift = false; + bool hasAlt = false; + bool hasMeta = false; + + // Check which modifiers are present + for (const QString &part : parts) { + QString upperPart = part.toUpper().trimmed(); + if (upperPart == "CTRL" || upperPart == "CONTROL") { + hasCtrl = true; + } else if (upperPart == "SHIFT") { + hasShift = true; + } else if (upperPart == "ALT") { + hasAlt = true; + } else if (upperPart == "META" || upperPart == "CMD" || upperPart == "COMMAND") { + hasMeta = true; + } else { + qWarning() << "HotkeyManager: unrecognized modifier:" << part << "in:" << keyString; + } + } + + // Validate the base key + QString upperBaseKey = baseKey.toUpper(); + if (!isValidBaseKey(upperBaseKey)) { + qWarning() << "HotkeyManager: invalid base key:" << baseKey << "in:" << keyString; + return QString(); + } + + // Add modifiers in canonical order + if (hasCtrl) { + normalizedParts << "CTRL"; + } + if (hasShift) { + normalizedParts << "SHIFT"; + } + if (hasAlt) { + normalizedParts << "ALT"; + } + if (hasMeta) { + normalizedParts << "META"; + } + + // Add the base key + normalizedParts << upperBaseKey; + + return normalizedParts.join("+"); +} + +HotkeyKey HotkeyManager::stringToHotkeyKey(const QString &keyString) +{ + QString normalized = normalizeKeyString(keyString); + if (normalized.isEmpty()) { + return HotkeyKey(); + } + + QStringList parts = normalized.split('+', Qt::SkipEmptyParts); + if (parts.isEmpty()) { + return HotkeyKey(); + } + + QString baseKey = parts.last(); + parts.removeLast(); + + // Build modifiers + Qt::KeyboardModifiers mods = Qt::NoModifier; + for (const QString &part : parts) { + if (part == "CTRL") { + mods |= Qt::ControlModifier; + } else if (part == "SHIFT") { + mods |= Qt::ShiftModifier; + } else if (part == "ALT") { + mods |= Qt::AltModifier; + } else if (part == "META") { + mods |= Qt::MetaModifier; + } + } + + // Check if this is a numpad key + bool isNumpad = isNumpadKeyName(baseKey); + + // Convert base key name to Qt::Key + int qtKey = baseKeyNameToQtKey(baseKey); + if (qtKey == 0) { + return HotkeyKey(); + } + + return HotkeyKey(qtKey, mods, isNumpad); +} + +QString HotkeyManager::hotkeyKeyToString(const HotkeyKey &hk) +{ + if (hk.key == 0) { + return QString(); + } + + QStringList parts; + + // Add modifiers in canonical order + if (hk.modifiers & Qt::ControlModifier) { + parts << "CTRL"; + } + if (hk.modifiers & Qt::ShiftModifier) { + parts << "SHIFT"; + } + if (hk.modifiers & Qt::AltModifier) { + parts << "ALT"; + } + if (hk.modifiers & Qt::MetaModifier) { + parts << "META"; + } + + // Add the base key name - use numpad map if isNumpad is set + QString keyName; + if (hk.isNumpad) { + keyName = getNumpadQtKeyToKeyNameMap().value(hk.key); + } + if (keyName.isEmpty()) { + keyName = qtKeyToBaseKeyName(hk.key); + } + if (keyName.isEmpty()) { + return QString(); + } + parts << keyName; + + return parts.join("+"); +} + +int HotkeyManager::baseKeyNameToQtKey(const QString &keyName) +{ + auto it = getKeyNameToQtKeyMap().find(keyName.toUpper()); + if (it != getKeyNameToQtKeyMap().end()) { + return it.value(); + } + return 0; +} + +QString HotkeyManager::qtKeyToBaseKeyName(int qtKey) +{ + // First check regular keys + auto it = getQtKeyToKeyNameMap().find(qtKey); + if (it != getQtKeyToKeyNameMap().end()) { + return it.value(); + } + + // Check non-numpad digit keys + auto it2 = getNonNumpadDigitKeyNameMap().find(qtKey); + if (it2 != getNonNumpadDigitKeyNameMap().end()) { + return it2.value(); + } + + return QString(); +} + +void HotkeyManager::resetToDefaults() +{ + m_rawContent = DEFAULT_HOTKEYS_CONTENT; + parseRawContent(); + saveToSettings(); +} + +void HotkeyManager::clear() +{ + m_hotkeys.clear(); + m_orderedHotkeys.clear(); + m_rawContent.clear(); +} + +std::vector HotkeyManager::getAllKeyNames() const +{ + std::vector result; + result.reserve(m_orderedHotkeys.size()); + for (const auto &pair : m_orderedHotkeys) { + result.push_back(pair.first); + } + return result; +} + +QString HotkeyManager::exportToCliFormat() const +{ + // Return the raw content exactly as saved (preserves order, comments, and formatting) + return m_rawContent; +} + +int HotkeyManager::importFromCliFormat(const QString &content) +{ + // Store the raw content exactly as provided (preserves order, comments, and formatting) + m_rawContent = content; + + // Parse to populate lookup structures + parseRawContent(); + + // Save to settings + saveToSettings(); + + return static_cast(m_orderedHotkeys.size()); +} + +bool HotkeyManager::isValidBaseKey(const QString &baseKey) +{ + return getValidBaseKeys().contains(baseKey.toUpper()); +} + +std::vector HotkeyManager::getAvailableKeyNames() +{ + return std::vector{// Function keys + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + // Numpad + "NUMPAD0", + "NUMPAD1", + "NUMPAD2", + "NUMPAD3", + "NUMPAD4", + "NUMPAD5", + "NUMPAD6", + "NUMPAD7", + "NUMPAD8", + "NUMPAD9", + "NUMPAD_SLASH", + "NUMPAD_ASTERISK", + "NUMPAD_MINUS", + "NUMPAD_PLUS", + "NUMPAD_PERIOD", + // Navigation + "HOME", + "END", + "INSERT", + "PAGEUP", + "PAGEDOWN", + // Arrow keys + "UP", + "DOWN", + "LEFT", + "RIGHT", + // Misc + "ACCENT", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "HYPHEN", + "EQUAL"}; +} + +std::vector HotkeyManager::getAvailableModifiers() +{ + return std::vector{"CTRL", "SHIFT", "ALT", "META"}; +} diff --git a/src/configuration/HotkeyManager.h b/src/configuration/HotkeyManager.h new file mode 100644 index 000000000..cbd861624 --- /dev/null +++ b/src/configuration/HotkeyManager.h @@ -0,0 +1,156 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include "../global/RuleOf5.h" +#include "../global/macros.h" + +#include +#include +#include +#include + +#include +#include + +/// Represents a hotkey as (key, modifiers, isNumpad) for efficient lookup +struct NODISCARD HotkeyKey final +{ + int key = 0; + Qt::KeyboardModifiers modifiers = Qt::NoModifier; + bool isNumpad = false; // true if this is a numpad key (NUMPAD0-9, NUMPAD_MINUS, etc.) + + HotkeyKey() = default; + HotkeyKey(int k, Qt::KeyboardModifiers m, bool numpad = false) + : key(k) + , modifiers(m) + , isNumpad(numpad) + {} + + NODISCARD bool operator==(const HotkeyKey &other) const + { + return key == other.key && modifiers == other.modifiers && isNumpad == other.isNumpad; + } +}; + +/// Hash function for HotkeyKey to use in std::unordered_map +struct HotkeyKeyHash final +{ + NODISCARD std::size_t operator()(const HotkeyKey &k) const noexcept + { + // Combine hash values using XOR and bit shifting + std::size_t h1 = std::hash{}(k.key); + std::size_t h2 = std::hash{}(static_cast(k.modifiers)); + std::size_t h3 = std::hash{}(k.isNumpad); + return h1 ^ (h2 << 1) ^ (h3 << 2); + } +}; + +class NODISCARD HotkeyManager final +{ +private: + // Fast lookup map for runtime hotkey resolution: (key, modifiers) -> command (std::string) + std::unordered_map m_hotkeys; + + // Ordered list of hotkey entries (key string, command) to preserve user's order for display + std::vector> m_orderedHotkeys; + + // Raw content preserving comments and formatting (used for export) + QString m_rawContent; + + /// Normalize a key string to canonical modifier order: CTRL+SHIFT+ALT+META+Key + /// Example: "ALT+CTRL+F1" -> "CTRL+ALT+F1" + /// Returns empty string if the base key is invalid + NODISCARD static QString normalizeKeyString(const QString &keyString); + + /// Check if a base key name (without modifiers) is valid + NODISCARD static bool isValidBaseKey(const QString &baseKey); + + /// Parse raw content to populate m_hotkeys and m_orderedHotkeys + void parseRawContent(); + + /// Convert a key string (e.g., "CTRL+F1") to a HotkeyKey + /// Returns HotkeyKey with key=0 if parsing fails + NODISCARD static HotkeyKey stringToHotkeyKey(const QString &keyString); + + /// Convert a HotkeyKey to a normalized key string (e.g., "CTRL+F1") + NODISCARD static QString hotkeyKeyToString(const HotkeyKey &hk); + + /// Convert a base key name (e.g., "F1", "NUMPAD8") to Qt::Key + /// Returns 0 if the key name is not recognized + NODISCARD static int baseKeyNameToQtKey(const QString &keyName); + + /// Convert a Qt::Key to a base key name (e.g., Qt::Key_F1 -> "F1") + /// Returns empty string if the key is not recognized + NODISCARD static QString qtKeyToBaseKeyName(int qtKey); + +public: + HotkeyManager(); + ~HotkeyManager() = default; + + DELETE_CTORS_AND_ASSIGN_OPS(HotkeyManager); + + /// Load hotkeys from QSettings (called on startup) + void loadFromSettings(); + + /// Save hotkeys to QSettings + void saveToSettings() const; + + /// Set a hotkey using string key name (saves to QSettings immediately) + /// This is used by the _hotkey command for user convenience + /// Returns true if the hotkey was set successfully, false if the key name is invalid + NODISCARD bool setHotkey(const QString &keyName, const QString &command); + + /// Remove a hotkey using string key name (saves to QSettings immediately) + void removeHotkey(const QString &keyName); + + /// Get the command for a given key and modifiers (optimized for runtime lookup) + /// isNumpad should be true if the key was pressed on the numpad (KeypadModifier was set) + /// Returns empty string if no hotkey is configured + NODISCARD std::string getCommand(int key, Qt::KeyboardModifiers modifiers, bool isNumpad) const; + + /// Get the command for a given key name string (for _hotkey command) + /// Returns empty string if no hotkey is configured + NODISCARD std::string getCommand(const QString &keyName) const; + + /// Convenience: Get command as QString (for Qt UI layer) + /// Returns empty QString if no hotkey is configured + NODISCARD QString getCommandQString(int key, + Qt::KeyboardModifiers modifiers, + bool isNumpad) const; + + /// Convenience: Get command as QString by key name (for Qt UI layer) + /// Returns empty QString if no hotkey is configured + NODISCARD QString getCommandQString(const QString &keyName) const; + + /// Check if a hotkey is configured for the given key name + NODISCARD bool hasHotkey(const QString &keyName) const; + + /// Get all configured hotkeys in their original order (key string, command as std::string) + NODISCARD const std::vector> &getAllHotkeys() const + { + return m_orderedHotkeys; + } + + /// Reset hotkeys to defaults (clears all and loads defaults) + void resetToDefaults(); + + /// Clear all hotkeys (does not save to settings) + void clear(); + + /// Get all key names that have hotkeys configured + NODISCARD std::vector getAllKeyNames() const; + + /// Export hotkeys to CLI command format (for _config edit and export) + NODISCARD QString exportToCliFormat() const; + + /// Import hotkeys from CLI command format (clears existing hotkeys first) + /// Returns the number of hotkeys imported + int importFromCliFormat(const QString &content); + + /// Get list of available key names for _hotkey keys command + NODISCARD static std::vector getAvailableKeyNames(); + + /// Get list of available modifiers for _hotkey keys command + NODISCARD static std::vector getAvailableModifiers(); +}; diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 2cdb1888a..76210635d 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -218,6 +218,7 @@ ConstString KEY_SHOW_SCROLL_BARS = "Show Scroll Bars"; ConstString KEY_SHOW_MENU_BAR = "Show Menu Bar"; ConstString KEY_AUTO_LOAD = "Auto load"; ConstString KEY_AUTO_RESIZE_TERMINAL = "Auto resize terminal"; +ConstString KEY_AUTO_START_CLIENT = "Auto start client"; ConstString KEY_BACKGROUND_COLOR = "Background color"; ConstString KEY_CHARACTER_ENCODING = "Character encoding"; ConstString KEY_CHECK_FOR_UPDATE = "Check for update"; @@ -753,9 +754,9 @@ void Configuration::IntegratedMudClientSettings::read(const QSettings &conf) linesOfPeekPreview = conf.value(KEY_LINES_OF_PEEK_PREVIEW, 7).toInt(); audibleBell = conf.value(KEY_BELL_AUDIBLE, true).toBool(); visualBell = conf.value(KEY_BELL_VISUAL, (CURRENT_PLATFORM == PlatformEnum::Wasm)).toBool(); + autoStartClient = conf.value(KEY_AUTO_START_CLIENT, false).toBool(); useCommandSeparator = conf.value(KEY_USE_COMMAND_SEPARATOR, false).toBool(); - commandSeparator = conf.value(KEY_COMMAND_SEPARATOR, QString(char_consts::C_SEMICOLON)) - .toString(); + commandSeparator = conf.value(KEY_COMMAND_SEPARATOR, QString(";;")).toString(); } void Configuration::RoomPanelSettings::read(const QSettings &conf) @@ -925,6 +926,7 @@ void Configuration::IntegratedMudClientSettings::write(QSettings &conf) const conf.setValue(KEY_LINES_OF_PEEK_PREVIEW, linesOfPeekPreview); conf.setValue(KEY_BELL_AUDIBLE, audibleBell); conf.setValue(KEY_BELL_VISUAL, visualBell); + conf.setValue(KEY_AUTO_START_CLIENT, autoStartClient); conf.setValue(KEY_USE_COMMAND_SEPARATOR, useCommandSeparator); conf.setValue(KEY_COMMAND_SEPARATOR, commandSeparator); } diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index 018949f41..4f1b495f2 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -14,6 +14,7 @@ #include "../global/NamedColors.h" #include "../global/RuleOf5.h" #include "../global/Signal2.h" +#include "HotkeyManager.h" #include "NamedConfig.h" #include @@ -364,7 +365,6 @@ class NODISCARD Configuration final QString font; QColor foregroundColor; QColor backgroundColor; - QString commandSeparator; int columns = 0; int rows = 0; int linesOfScrollback = 0; @@ -375,7 +375,9 @@ class NODISCARD Configuration final int linesOfPeekPreview = 0; bool audibleBell = false; bool visualBell = false; + bool autoStartClient = false; bool useCommandSeparator = false; + QString commandSeparator; private: SUBGROUP(); @@ -413,6 +415,9 @@ class NODISCARD Configuration final SUBGROUP(); } findRoomsDialog; + // Hotkey manager for integrated MUD client + HotkeyManager hotkeyManager; + public: DELETE_CTORS_AND_ASSIGN_OPS(Configuration); diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 65ac3ae2f..61902040c 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -377,6 +377,14 @@ void MainWindow::readSettings() // Check if the window was moved to a screen with a different DPI getCanvas()->screenChanged(); } + + // Auto-start mud client if enabled + const auto &clientSettings = getConfig().integratedClient; + if (clientSettings.autoStartClient) { + qDebug() << "[MainWindow::MainWindow] Auto-starting mud client"; + m_dockDialogClient->show(); + m_clientWidget->playMume(); + } } void MainWindow::writeSettings() diff --git a/src/parser/AbstractParser-Commands.cpp b/src/parser/AbstractParser-Commands.cpp index d1b724cb3..9c4d256d5 100644 --- a/src/parser/AbstractParser-Commands.cpp +++ b/src/parser/AbstractParser-Commands.cpp @@ -49,6 +49,7 @@ const Abbrev cmdDoorHelp{"doorhelp", 5}; const Abbrev cmdGenerateBaseMap{"generate-base-map"}; const Abbrev cmdGroup{"group", 2}; const Abbrev cmdHelp{"help", 2}; +const Abbrev cmdHotkey{"hotkey", 3}; const Abbrev cmdMap{"map"}; const Abbrev cmdMark{"mark", 3}; const Abbrev cmdRemoveDoorNames{"remove-secret-door-names"}; @@ -613,7 +614,24 @@ syntax::MatchResult ArgHelpCommand::virt_match(const syntax::ParserInput &input, const auto &next = input.front(); const auto it = map.find(next); if (it != map.end()) { - return syntax::MatchResult::success(1, input, Value(next)); + // Collect remaining tokens as subcommand string + std::string subcommand; + auto iter = input.begin(); + ++iter; // skip command name + for (; iter != input.end(); ++iter) { + if (!subcommand.empty()) { + subcommand += " "; + } + subcommand += *iter; + } + // Create vector with command name and subcommand + std::vector result; + result.emplace_back(next); // command name + result.emplace_back(subcommand); + // Consume all tokens + return syntax::MatchResult::success(input.size(), + input, + Value(Vector(std::move(result)))); } } @@ -641,18 +659,24 @@ void AbstractParser::parseHelp(StringView words) Accept( [this](User &user, const Pair *const matched) -> void { auto &os = user.getOstream(); - if (!matched || !matched->car.isString()) { + if (!matched || !matched->car.isVector()) { + os << "Internal error.\n"; + return; + } + const auto &vec = matched->car.getVector(); + if (vec.size() < 2 || !vec[0].isString() || !vec[1].isString()) { os << "Internal error.\n"; return; } const auto &map = m_specialCommandMap; - const auto &name = matched->car.getString(); + const auto &name = vec[0].getString(); + const auto &subcommand = vec[1].getString(); const auto it = map.find(name); if (it == map.end()) { os << "Internal error.\n"; return; } - it->second.help(name); + it->second.help(name, subcommand); }, "detailed help pages")), @@ -898,7 +922,7 @@ void AbstractParser::initSpecialCommandMap() }; const auto makeSimpleHelp = [this](const std::string &help) { - return [this, help](const std::string &name) { + return [this, help](const std::string &name, const std::string & /*subcommand*/) { sendToUser(SendToUserSourceEnum::FromMMapper, QString("Help for %1%2:\n" " %3\n" @@ -962,7 +986,84 @@ void AbstractParser::initSpecialCommandMap() this->doConfig(rest); return true; }, - makeSimpleHelp("Configuration commands.")); + [this](const std::string &name, const std::string &subcommand) { + std::string help; + std::string cmdDisplay = name; + + if (subcommand.empty()) { + help = "Client configuration commands.\n" + "\n" + "Subcommands:\n" + "\tedit # Open editor for hotkeys and client config\n" + "\tmode play # Switch to play mode\n" + "\tmode mapping # Switch to mapping mode\n" + "\tmode emulation # Switch to offline emulation mode\n" + "\tfile save # Save config file\n" + "\tfile load # Load saved config file\n" + "\tfile factory reset \"Yes, I'm sure!\" # Factory reset\n" + "\tmap colors list # List customizable colors\n" + "\tmap colors set # Set a named color\n" + "\tmap zoom set # Set map zoom level\n" + "\tmap 3d-camera set ... # Configure 3D camera settings\n" + "\n" + "Use \"help config \" for detailed help on each subcommand."; + } else if (subcommand == "edit") { + cmdDisplay = name + " " + subcommand; + help = "Open an interactive editor for client configuration.\n" + "\n" + "Usage: config edit\n" + "\n" + "Opens a text editor where you can view and modify all hotkeys\n" + "using _hotkey command syntax. Each line should be in the format:\n" + "\n" + "\t_hotkey \n" + "\n" + "Examples:\n" + "\t_hotkey F1 kill orc\n" + "\t_hotkey CTRL+NUMPAD8 unlock north\n" + "\n" + "Changes are applied when you save and close the editor."; + } else if (subcommand == "mode" || subcommand.rfind("mode ", 0) == 0) { + cmdDisplay = name + " mode"; + help = "Switch MMapper operating mode.\n" + "\n" + "Usage:\n" + "\tconfig mode play # Normal play mode\n" + "\tconfig mode mapping # Mapping mode for creating/editing map\n" + "\tconfig mode emulation # Offline emulation mode\n"; + } else if (subcommand == "file" || subcommand.rfind("file ", 0) == 0) { + cmdDisplay = name + " file"; + help = "Configuration file operations.\n" + "\n" + "Usage:\n" + "\tconfig file save # Save current configuration to file\n" + "\tconfig file load # Load configuration from saved file\n" + "\tconfig file factory reset \"Yes, I'm sure!\" # Reset to defaults\n"; + } else if (subcommand == "map" || subcommand.rfind("map ", 0) == 0) { + cmdDisplay = name + " map"; + help = "Map display configuration.\n" + "\n" + "Usage:\n" + "\tconfig map colors list # List customizable colors\n" + "\tconfig map colors set # Set a named color\n" + "\tconfig map zoom set # Set map zoom level\n" + "\tconfig map 3d-camera set ... # Configure 3D camera\n"; + } else { + cmdDisplay = name + " " + subcommand; + help = "Unknown subcommand: " + subcommand + + "\n" + "\n" + "Available subcommands: edit, mode, file, map"; + } + + sendToUser(SendToUserSourceEnum::FromMMapper, + QString("Help for %1%2:\n" + "%3\n" + "\n") + .arg(getPrefixChar()) + .arg(mmqt::toQStringUtf8(cmdDisplay)) + .arg(mmqt::toQStringUtf8(help))); + }); add( cmdConnect, [this](const std::vector & /*s*/, StringView /*rest*/) { @@ -1024,7 +1125,7 @@ void AbstractParser::initSpecialCommandMap() this->parseSetCommand(rest); return true; }, - [this](const std::string &name) { + [this](const std::string &name, const std::string & /*subcommand*/) { const char help[] = "Subcommands:\n" "\tprefix # Displays the current prefix.\n" @@ -1102,6 +1203,97 @@ void AbstractParser::initSpecialCommandMap() }, makeSimpleHelp("Perform actions on the group manager.")); + /* hotkey commands */ + add( + cmdHotkey, + [this](const std::vector & /*s*/, StringView rest) { + parseHotkey(rest); + return true; + }, + [this](const std::string &name, const std::string &subcommand) { + std::string help; + std::string cmdDisplay = name; + + if (subcommand.empty()) { + help = "Define keyboard hotkeys for quick commands.\n" + "\n" + "Subcommands:\n" + "\tset # Assign a command to a key\n" + "\tremove # Remove a hotkey binding\n" + "\tconfig # List all configured hotkeys\n" + "\tkeys # Show available key names\n" + "\treset # Reset hotkeys to defaults\n" + "\n" + "Use \"help hotkey \" for detailed help on each subcommand.\n" + "To edit all hotkeys interactively, use: config edit"; + } else if (subcommand == "set" || subcommand.rfind("set ", 0) == 0) { + cmdDisplay = name + " set"; + help = "Assign a command to a hotkey.\n" + "\n" + "Usage: hotkey set \n" + "\n" + "Available key names:\n" + "\tFunction keys: F1-F12\n" + "\tNumpad: NUMPAD0-9, NUMPAD_SLASH, NUMPAD_ASTERISK,\n" + "\t NUMPAD_MINUS, NUMPAD_PLUS, NUMPAD_PERIOD\n" + "\tNavigation: HOME, END, INSERT, PAGEUP, PAGEDOWN\n" + "\tArrow keys: UP, DOWN, LEFT, RIGHT\n" + "\tMisc: ACCENT, 0-9, HYPHEN, EQUAL\n" + "\n" + "Modifiers: CTRL, SHIFT, ALT, META\n" + "\n" + "Examples:\n" + "\thotkey set F1 kill orc\n" + "\thotkey set CTRL+NUMPAD8 unlock north\n" + "\thotkey set SHIFT+F5 cast 'cure light'"; + } else if (subcommand == "remove" || subcommand.rfind("remove ", 0) == 0) { + cmdDisplay = name + " remove"; + help = "Remove a hotkey binding.\n" + "\n" + "Usage: hotkey remove \n" + "\n" + "Examples:\n" + "\thotkey remove F1\n" + "\thotkey remove CTRL+NUMPAD8"; + } else if (subcommand == "config") { + cmdDisplay = name + " config"; + help = "List all configured hotkeys.\n" + "\n" + "Usage: hotkey config\n" + "\n" + "Shows all currently defined hotkey bindings."; + } else if (subcommand == "keys") { + cmdDisplay = name + " keys"; + help = "Show available key names for hotkey bindings.\n" + "\n" + "Usage: hotkey keys\n" + "\n" + "Lists all key names that can be used with hotkey set."; + } else if (subcommand == "reset") { + cmdDisplay = name + " reset"; + help = "Reset all hotkeys to default values.\n" + "\n" + "Usage: hotkey reset\n" + "\n" + "Warning: This will remove all custom hotkey bindings\n" + "and restore the default configuration."; + } else { + cmdDisplay = name + " " + subcommand; + help = "Unknown subcommand: " + subcommand + + "\n" + "\n" + "Available subcommands: set, remove, config, keys, reset"; + } + + sendToUser(SendToUserSourceEnum::FromMMapper, + QString("Help for %1%2:\n" + "%3\n" + "\n") + .arg(getPrefixChar()) + .arg(mmqt::toQStringUtf8(cmdDisplay)) + .arg(mmqt::toQStringUtf8(help))); + }); + /* timers command */ add( cmdTimer, diff --git a/src/parser/AbstractParser-Config.cpp b/src/parser/AbstractParser-Config.cpp index 137d1f73d..f5e1dcd51 100644 --- a/src/parser/AbstractParser-Config.cpp +++ b/src/parser/AbstractParser-Config.cpp @@ -363,7 +363,15 @@ void AbstractParser::doConfig(const StringView cmd) makeFixedPointArg(advanced.fov, "fov"), makeFixedPointArg(advanced.verticalAngle, "pitch"), makeFixedPointArg(advanced.horizontalAngle, "yaw"), - makeFixedPointArg(advanced.layerHeight, "layer-height"))))); + makeFixedPointArg(advanced.layerHeight, "layer-height")))), + syn("edit", + Accept( + [this](User &user, auto) { + auto &os = user.getOstream(); + os << "Opening client configuration editor...\n"; + openClientConfigEditor(); + }, + "edit client configuration"))); eval("config", configSyntax, cmd); } diff --git a/src/parser/AbstractParser-Hotkey.cpp b/src/parser/AbstractParser-Hotkey.cpp new file mode 100644 index 000000000..fdb78ec9a --- /dev/null +++ b/src/parser/AbstractParser-Hotkey.cpp @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include "../configuration/configuration.h" +#include "../global/TextUtils.h" +#include "../syntax/SyntaxArgs.h" +#include "../syntax/TreeParser.h" +#include "AbstractParser-Utils.h" +#include "abstractparser.h" + +#include +#include +#include + +class NODISCARD ArgHotkeyName final : public syntax::IArgument +{ +private: + NODISCARD syntax::MatchResult virt_match(const syntax::ParserInput &input, + syntax::IMatchErrorLogger *) const final; + + std::ostream &virt_to_stream(std::ostream &os) const final; +}; + +syntax::MatchResult ArgHotkeyName::virt_match(const syntax::ParserInput &input, + syntax::IMatchErrorLogger * /*logger */) const +{ + if (input.empty()) { + return syntax::MatchResult::failure(input); + } + + return syntax::MatchResult::success(1, input, Value{input.front()}); +} + +std::ostream &ArgHotkeyName::virt_to_stream(std::ostream &os) const +{ + return os << ""; +} + +void AbstractParser::parseHotkey(StringView input) +{ + using namespace ::syntax; + static const auto abb = syntax::abbrevToken; + + // _hotkey set KEY command + auto setHotkey = Accept( + [](User &user, const Pair *const args) { + auto &os = user.getOstream(); + const auto v = getAnyVectorReversed(args); + + const auto keyName = mmqt::toQStringUtf8(v[1].getString()); + const std::string cmdStr = concatenate_unquoted(v[2].getVector()); + const auto command = mmqt::toQStringUtf8(cmdStr); + + if (setConfig().hotkeyManager.setHotkey(keyName, command)) { + os << "Hotkey set: " << mmqt::toStdStringUtf8(keyName.toUpper()) << " = " << cmdStr + << "\n"; + send_ok(os); + } else { + os << "Invalid key name: " << mmqt::toStdStringUtf8(keyName.toUpper()) << "\n"; + os << "Use '_hotkey keys' to see available key names.\n"; + } + }, + "set hotkey"); + + // _hotkey remove KEY + auto removeHotkey = Accept( + [](User &user, const Pair *const args) { + auto &os = user.getOstream(); + const auto v = getAnyVectorReversed(args); + + const auto keyName = mmqt::toQStringUtf8(v[1].getString()); + + if (getConfig().hotkeyManager.hasHotkey(keyName)) { + setConfig().hotkeyManager.removeHotkey(keyName); + os << "Hotkey removed: " << mmqt::toStdStringUtf8(keyName.toUpper()) << "\n"; + } else { + os << "No hotkey configured for: " << mmqt::toStdStringUtf8(keyName.toUpper()) + << "\n"; + } + send_ok(os); + }, + "remove hotkey"); + + // _hotkey config (list all) + auto listHotkeys = Accept( + [](User &user, const Pair *) { + auto &os = user.getOstream(); + const auto &hotkeys = getConfig().hotkeyManager.getAllHotkeys(); + + if (hotkeys.empty()) { + os << "No hotkeys configured.\n"; + } else { + os << "Configured hotkeys:\n"; + for (const auto &[key, cmd] : hotkeys) { + // key is QString (needs conversion), cmd is already std::string + os << " " << mmqt::toStdStringUtf8(key) << " = " << cmd << "\n"; + } + } + send_ok(os); + }, + "list hotkeys"); + + // _hotkey keys (show available keys) + auto listKeys = Accept( + [](User &user, const Pair *) { + auto &os = user.getOstream(); + os << "Available key names:\n" + << " Function keys: F1-F12\n" + << " Numpad: NUMPAD0-9, NUMPAD_SLASH, NUMPAD_ASTERISK,\n" + << " NUMPAD_MINUS, NUMPAD_PLUS, NUMPAD_PERIOD\n" + << " Navigation: HOME, END, INSERT, PAGEUP, PAGEDOWN\n" + << " Arrow keys: UP, DOWN, LEFT, RIGHT\n" + << " Misc: ACCENT, 0-9, HYPHEN, EQUAL\n" + << "\n" + << "Available modifiers: CTRL, SHIFT, ALT, META\n" + << "\n" + << "Examples: CTRL+F1, SHIFT+NUMPAD8, ALT+F5\n"; + send_ok(os); + }, + "list available keys"); + + // _hotkey reset + auto resetHotkeys = Accept( + [](User &user, const Pair *) { + auto &os = user.getOstream(); + setConfig().hotkeyManager.resetToDefaults(); + os << "Hotkeys reset to defaults.\n"; + send_ok(os); + }, + "reset to defaults"); + + // Build syntax tree + auto setSyntax = buildSyntax(abb("set"), + TokenMatcher::alloc(), + TokenMatcher::alloc(), + setHotkey); + + auto removeSyntax = buildSyntax(abb("remove"), + TokenMatcher::alloc(), + removeHotkey); + + auto configSyntax = buildSyntax(abb("config"), listHotkeys); + + auto keysSyntax = buildSyntax(abb("keys"), listKeys); + + auto resetSyntax = buildSyntax(abb("reset"), resetHotkeys); + + auto hotkeyUserSyntax = buildSyntax(setSyntax, + removeSyntax, + configSyntax, + keysSyntax, + resetSyntax); + + eval("hotkey", hotkeyUserSyntax, input); +} diff --git a/src/parser/abstractparser.h b/src/parser/abstractparser.h index 60c61167e..9872373df 100644 --- a/src/parser/abstractparser.h +++ b/src/parser/abstractparser.h @@ -95,6 +95,8 @@ struct NODISCARD AbstractParserOutputs // for commands that set the mode (emulation, play, map) // these are connected to MainWindow void onSetMode(const MapModeEnum mode) { virt_onSetMode(mode); } + // opens the client configuration editor (hotkeys, etc.) + void onOpenClientConfigEditor() { virt_onOpenClientConfigEditor(); } private: // sent to MudTelnet @@ -122,6 +124,8 @@ struct NODISCARD AbstractParserOutputs // for commands that set the mode (emulation, play, map) // these are connected to MainWindow virtual void virt_onSetMode(MapModeEnum) = 0; + // opens the client configuration editor (hotkeys, etc.) + virtual void virt_onOpenClientConfigEditor() = 0; }; struct NODISCARD ParserCommonData final @@ -311,7 +315,7 @@ class NODISCARD_QOBJECT AbstractParser final : public ParserCommon std::shared_ptr m_parseRoomHelper; public: - using HelpCallback = std::function; + using HelpCallback = std::function; using ParserCallback = std::function &matched, StringView args)>; struct NODISCARD ParserRecord final @@ -405,6 +409,7 @@ class NODISCARD_QOBJECT AbstractParser final : public ParserCommon NODISCARD bool evalSpecialCommandMap(StringView args); void parseHelp(StringView words); + void parseHotkey(StringView input); void parseMark(StringView input); void parseRoom(StringView input); void parseGroup(StringView input); @@ -425,6 +430,7 @@ class NODISCARD_QOBJECT AbstractParser final : public ParserCommon private: void graphicsSettingsChanged() { m_outputs.onGraphicsSettingsChanged(); } + void openClientConfigEditor() { m_outputs.onOpenClientConfigEditor(); } void sendToMud(const QByteArray &msg) = delete; void sendToMud(const QString &msg) { m_outputs.onSendToMud(msg); } diff --git a/src/preferences/clientpage.cpp b/src/preferences/clientpage.cpp index 427f64a19..a420a0680 100644 --- a/src/preferences/clientpage.cpp +++ b/src/preferences/clientpage.cpp @@ -5,6 +5,7 @@ #include "clientpage.h" #include "../configuration/configuration.h" +#include "../global/macros.h" #include "ui_clientpage.h" #include @@ -19,30 +20,42 @@ class NODISCARD CustomSeparatorValidator final : public QValidator { public: - explicit CustomSeparatorValidator(QObject *parent); + explicit CustomSeparatorValidator(QObject *parent) + : QValidator(parent) + {} ~CustomSeparatorValidator() final; void fixup(QString &input) const override { - mmqt::toLatin1InPlace(input); // transliterates non-latin1 codepoints + // Remove any non-printable, whitespace, or backslash characters + QString cleaned; + for (const QChar &c : input) { + if (c == '\\') { + continue; + } + if (c.isPrint() && !c.isSpace()) { + cleaned.append(c); + } + } + input = cleaned; } QValidator::State validate(QString &input, int & /* pos */) const override { - if (input.length() != 1) { + if (input.isEmpty()) { return QValidator::State::Intermediate; } - const auto c = input.at(0); - const bool valid = c != char_consts::C_BACKSLASH && c.isPrint() && !c.isSpace(); - return valid ? QValidator::State::Acceptable : QValidator::State::Invalid; + // Check that all characters are printable and not whitespace or backslash + for (const QChar &c : input) { + if (c == '\\' || !c.isPrint() || c.isSpace()) { + return QValidator::State::Invalid; + } + } + return QValidator::State::Acceptable; } }; -CustomSeparatorValidator::CustomSeparatorValidator(QObject *const parent) - : QValidator(parent) -{} - CustomSeparatorValidator::~CustomSeparatorValidator() = default; ClientPage::ClientPage(QWidget *parent) @@ -105,18 +118,28 @@ ClientPage::ClientPage(QWidget *parent) setConfig().integratedClient.visualBell = isChecked; }); + connect(ui->autoStartClientCheck, &QCheckBox::toggled, [](bool isChecked) { + setConfig().integratedClient.autoStartClient = isChecked; + }); + connect(ui->commandSeparatorCheckBox, &QCheckBox::toggled, this, [this](bool isChecked) { setConfig().integratedClient.useCommandSeparator = isChecked; ui->commandSeparatorLineEdit->setEnabled(isChecked); }); connect(ui->commandSeparatorLineEdit, &QLineEdit::textChanged, this, [](const QString &text) { - if (text.length() == 1) { - setConfig().integratedClient.commandSeparator = text; - } + // Keep config in sync with the UI, including when the separator is cleared + setConfig().integratedClient.commandSeparator = text; }); ui->commandSeparatorLineEdit->setValidator(new CustomSeparatorValidator(this)); + + // Disable auto-start option on WASM (client always starts automatically there) + if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { + ui->autoStartClientCheck->setDisabled(true); + ui->autoStartClientCheck->setToolTip("This option is not available in the web version.\n" + "The client always starts automatically."); + } } ClientPage::~ClientPage() @@ -139,6 +162,7 @@ void ClientPage::slot_loadConfig() ui->autoResizeTerminalCheckBox->setChecked(settings.autoResizeTerminal); ui->audibleBellCheckBox->setChecked(settings.audibleBell); ui->visualBellCheckBox->setChecked(settings.visualBell); + ui->autoStartClientCheck->setChecked(settings.autoStartClient); ui->commandSeparatorCheckBox->setChecked(settings.useCommandSeparator); ui->commandSeparatorLineEdit->setText(settings.commandSeparator); ui->commandSeparatorLineEdit->setEnabled(settings.useCommandSeparator); diff --git a/src/preferences/clientpage.ui b/src/preferences/clientpage.ui index 72ed0bca5..785d5b284 100644 --- a/src/preferences/clientpage.ui +++ b/src/preferences/clientpage.ui @@ -6,8 +6,8 @@ 0 0 - 331 - 687 + 303 + 534 @@ -236,6 +236,32 @@ Input + + + + Tab word completion dictionary size: + + + true + + + tabDictionarySpinBox + + + + + + + Lines of input history: + + + true + + + inputHistorySpinBox + + + @@ -268,27 +294,14 @@ - + Clear input on send - - - - Tab word completion dictionary size: - - - true - - - tabDictionarySpinBox - - - - + Qt::Horizontal @@ -301,21 +314,8 @@ - - - - Lines of input history: - - - true - - - inputHistorySpinBox - - - - - + + @@ -329,10 +329,10 @@ false - 1 + 2 - ; + ;; @@ -341,6 +341,22 @@ + + + + Startup + + + + + + Automatically start client on startup + + + + + + @@ -360,18 +376,17 @@ fontPushButton fgColorPushButton bgColorPushButton - rowsSpinBox columnsSpinBox + rowsSpinBox scrollbackSpinBox previewSpinBox autoResizeTerminalCheckBox - audibleBellCheckBox - visualBellCheckBox inputHistorySpinBox tabDictionarySpinBox + clearInputCheckBox commandSeparatorCheckBox commandSeparatorLineEdit - clearInputCheckBox + autoStartClientCheck diff --git a/src/proxy/proxy.cpp b/src/proxy/proxy.cpp index 883666589..cf0b711cb 100644 --- a/src/proxy/proxy.cpp +++ b/src/proxy/proxy.cpp @@ -21,6 +21,7 @@ #include "../map/parseevent.h" #include "../mpi/mpifilter.h" #include "../mpi/remoteedit.h" +#include "../mpi/remoteeditwidget.h" #include "../parser/abstractparser.h" #include "../parser/mumexmlparser.h" #include "../pathmachine/mmapper2pathmachine.h" @@ -699,6 +700,33 @@ void Proxy::allocParser() // (via user command) void virt_onSetMode(const MapModeEnum mode) final { getMainWindow().slot_setMode(mode); } + + void virt_onOpenClientConfigEditor() final + { + // Get content in CLI format (preserves comments and order) + const QString content = getConfig().hotkeyManager.exportToCliFormat(); + + // Create the editor widget + auto *editor = new RemoteEditWidget(true, // editSession = true (editable) + "MMapper Client Configuration", + content, + nullptr); + + // Connect save signal to import the edited content + QObject::connect(editor, &RemoteEditWidget::sig_save, [this](const QString &edited) { + // Import using HotkeyManager (handles parsing, clears existing, saves to QSettings) + int hotkeyCount = setConfig().hotkeyManager.importFromCliFormat(edited); + + // Send feedback to user + QString msg = QString("\n%1 hotkeys imported.\n").arg(hotkeyCount); + getUserTelnet().onSendToUser(msg, false); + }); + + // Show the editor + editor->setAttribute(Qt::WA_DeleteOnClose); + editor->show(); + editor->activateWindow(); + } }; auto &pipe = getPipeline(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a8c528313..23fb035c8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -56,6 +56,8 @@ add_test(NAME TestClock COMMAND TestClock) set(expandoracommon_SRCS ../src/configuration/configuration.cpp ../src/configuration/configuration.h + ../src/configuration/HotkeyManager.cpp + ../src/configuration/HotkeyManager.h ../src/parser/Abbrev.cpp ) set(TestExpandoraCommon_SRCS testexpandoracommon.cpp) @@ -82,6 +84,8 @@ add_test(NAME TestExpandoraCommon COMMAND TestExpandoraCommon) set(parser_SRCS ../src/configuration/configuration.cpp ../src/configuration/configuration.h + ../src/configuration/HotkeyManager.cpp + ../src/configuration/HotkeyManager.h ) set(TestParser_SRCS testparser.cpp) add_executable(TestParser ${TestParser_SRCS} ${parser_SRCS}) @@ -174,6 +178,8 @@ add_test(NAME TestGlobal COMMAND TestGlobal) set(TestMap_SRCS ../src/configuration/configuration.cpp ../src/configuration/configuration.h + ../src/configuration/HotkeyManager.cpp + ../src/configuration/HotkeyManager.h TestMap.cpp ) add_executable(TestMap ${TestMap_SRCS}) @@ -205,6 +211,8 @@ set(adventure_SRCS ../src/adventure/lineparsers.h ../src/configuration/configuration.cpp ../src/configuration/configuration.h + ../src/configuration/HotkeyManager.cpp + ../src/configuration/HotkeyManager.h ../src/observer/gameobserver.cpp ../src/observer/gameobserver.h ) @@ -300,3 +308,21 @@ set_target_properties( COMPILE_FLAGS "${WARNING_FLAGS}" ) add_test(NAME TestRoomManager COMMAND TestRoomManager) + +# HotkeyManager +set(hotkey_manager_SRCS + ../src/configuration/HotkeyManager.cpp + ../src/configuration/HotkeyManager.h +) +set(TestHotkeyManager_SRCS TestHotkeyManager.cpp TestHotkeyManager.h) +add_executable(TestHotkeyManager ${TestHotkeyManager_SRCS} ${hotkey_manager_SRCS}) +add_dependencies(TestHotkeyManager mm_global) +target_link_libraries(TestHotkeyManager mm_global Qt6::Test coverage_config) +set_target_properties( + TestHotkeyManager PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + COMPILE_FLAGS "${WARNING_FLAGS}" +) +add_test(NAME TestHotkeyManager COMMAND TestHotkeyManager) diff --git a/tests/TestHotkeyManager.cpp b/tests/TestHotkeyManager.cpp new file mode 100644 index 000000000..8fc697d5e --- /dev/null +++ b/tests/TestHotkeyManager.cpp @@ -0,0 +1,524 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include "TestHotkeyManager.h" + +#include "../src/configuration/HotkeyManager.h" + +#include +#include +#include +#include + +TestHotkeyManager::TestHotkeyManager() = default; +TestHotkeyManager::~TestHotkeyManager() = default; + +void TestHotkeyManager::initTestCase() +{ + // Save original QSettings namespace to avoid polluting real user settings + m_originalOrganization = QCoreApplication::organizationName(); + m_originalApplication = QCoreApplication::applicationName(); + + // Use test-specific namespace for isolation + QCoreApplication::setOrganizationName(QStringLiteral("MMapperTest")); + QCoreApplication::setApplicationName(QStringLiteral("HotkeyManagerTest")); +} + +void TestHotkeyManager::cleanupTestCase() +{ + // Restore original QSettings namespace + QCoreApplication::setOrganizationName(m_originalOrganization); + QCoreApplication::setApplicationName(m_originalApplication); +} + +void TestHotkeyManager::keyNormalizationTest() +{ + HotkeyManager manager; + + // Test that modifiers are normalized to canonical order: CTRL+SHIFT+ALT+META + // Set a hotkey with non-canonical modifier order + QVERIFY(manager.setHotkey("ALT+CTRL+F1", "test1")); + + // Should be retrievable with canonical order + QCOMPARE(manager.getCommandQString("CTRL+ALT+F1"), QString("test1")); + + // Should also be retrievable with the original order (due to normalization) + QCOMPARE(manager.getCommandQString("ALT+CTRL+F1"), QString("test1")); + + // Test all modifier combinations normalize correctly + QVERIFY(manager.setHotkey("META+ALT+SHIFT+CTRL+F2", "test2")); + QCOMPARE(manager.getCommandQString("CTRL+SHIFT+ALT+META+F2"), QString("test2")); + + // Test that case is normalized to uppercase + QVERIFY(manager.setHotkey("ctrl+f3", "test3")); + QCOMPARE(manager.getCommandQString("CTRL+F3"), QString("test3")); + + // Test CONTROL alias normalizes to CTRL + QVERIFY(manager.setHotkey("CONTROL+F4", "test4")); + QCOMPARE(manager.getCommandQString("CTRL+F4"), QString("test4")); + + // Test CMD/COMMAND aliases normalize to META + QVERIFY(manager.setHotkey("CMD+F5", "test5")); + QCOMPARE(manager.getCommandQString("META+F5"), QString("test5")); + + QVERIFY(manager.setHotkey("COMMAND+F6", "test6")); + QCOMPARE(manager.getCommandQString("META+F6"), QString("test6")); + + // Test simple key without modifiers + QVERIFY(manager.setHotkey("f7", "test7")); + QCOMPARE(manager.getCommandQString("F7"), QString("test7")); + + // Test numpad keys + QVERIFY(manager.setHotkey("numpad8", "north")); + QCOMPARE(manager.getCommandQString("NUMPAD8"), QString("north")); +} + +void TestHotkeyManager::importExportRoundTripTest() +{ + HotkeyManager manager; + + // Test import with a known string (this clears existing hotkeys) + QString testConfig = "_hotkey F1 look\n" + "_hotkey CTRL+F2 open exit n\n" + "_hotkey SHIFT+ALT+F3 pick exit s\n" + "_hotkey NUMPAD8 n\n" + "_hotkey CTRL+SHIFT+NUMPAD_PLUS test command\n"; + + int importedCount = manager.importFromCliFormat(testConfig); + + // Verify the import count + QCOMPARE(importedCount, 5); + + // Verify all hotkeys were imported correctly + QCOMPARE(manager.getCommandQString("F1"), QString("look")); + QCOMPARE(manager.getCommandQString("CTRL+F2"), QString("open exit n")); + QCOMPARE(manager.getCommandQString("SHIFT+ALT+F3"), QString("pick exit s")); + QCOMPARE(manager.getCommandQString("NUMPAD8"), QString("n")); + QCOMPARE(manager.getCommandQString("CTRL+SHIFT+NUMPAD_PLUS"), QString("test command")); + + // Verify total count + QCOMPARE(manager.getAllHotkeys().size(), 5); + + // Export and verify content + QString exported = manager.exportToCliFormat(); + QVERIFY(exported.contains("_hotkey F1 look")); + QVERIFY(exported.contains("_hotkey CTRL+F2 open exit n")); + QVERIFY(exported.contains("_hotkey NUMPAD8 n")); + + // Test that comments and empty lines are ignored during import + QString contentWithComments = "# This is a comment\n" + "\n" + "_hotkey F10 flee\n" + "# Another comment\n" + "_hotkey F11 rest\n"; + + int count = manager.importFromCliFormat(contentWithComments); + QCOMPARE(count, 2); + QCOMPARE(manager.getCommandQString("F10"), QString("flee")); + QCOMPARE(manager.getCommandQString("F11"), QString("rest")); + + // Verify import cleared existing hotkeys + QCOMPARE(manager.getAllHotkeys().size(), 2); + QCOMPARE(manager.getCommandQString("F1"), QString()); // Should be cleared + + // Test another import clears and replaces + manager.importFromCliFormat("_hotkey F12 stand\n"); + QCOMPARE(manager.getAllHotkeys().size(), 1); + QCOMPARE(manager.getCommandQString("F10"), QString()); // Should be cleared + QCOMPARE(manager.getCommandQString("F12"), QString("stand")); +} + +void TestHotkeyManager::importEdgeCasesTest() +{ + HotkeyManager manager; + + // Test command with multiple spaces (should preserve spaces in command) + manager.importFromCliFormat("_hotkey F1 cast 'cure light'"); + QCOMPARE(manager.getCommandQString("F1"), QString("cast 'cure light'")); + + // Test malformed lines are skipped + // "_hotkey" alone - no key + // "_hotkey F2" - no command + // "_hotkey F3 valid" - valid + manager.importFromCliFormat("_hotkey\n_hotkey F2\n_hotkey F3 valid"); + QCOMPARE(manager.getAllHotkeys().size(), 1); + QCOMPARE(manager.getCommandQString("F3"), QString("valid")); + + // Test leading/trailing whitespace handling + manager.importFromCliFormat(" _hotkey F4 command with spaces "); + QCOMPARE(manager.getCommandQString("F4"), QString("command with spaces")); + + // Test empty input + manager.importFromCliFormat(""); + QCOMPARE(manager.getAllHotkeys().size(), 0); + + // Test only comments and whitespace + manager.importFromCliFormat("# comment\n\n# another comment\n \n"); + QCOMPARE(manager.getAllHotkeys().size(), 0); +} + +void TestHotkeyManager::resetToDefaultsTest() +{ + HotkeyManager manager; + + // Import custom hotkeys + manager.importFromCliFormat("_hotkey F1 custom\n_hotkey F2 another"); + QCOMPARE(manager.getCommandQString("F1"), QString("custom")); + QCOMPARE(manager.getAllHotkeys().size(), 2); + + // Reset to defaults + manager.resetToDefaults(); + + // Verify defaults are restored + QCOMPARE(manager.getCommandQString("NUMPAD8"), QString("n")); + QCOMPARE(manager.getCommandQString("NUMPAD4"), QString("w")); + QCOMPARE(manager.getCommandQString("CTRL+NUMPAD8"), QString("open exit n")); + QCOMPARE(manager.getCommandQString("ALT+NUMPAD8"), QString("close exit n")); + QCOMPARE(manager.getCommandQString("SHIFT+NUMPAD8"), QString("pick exit n")); + + // F1 is not in defaults, should be empty + QCOMPARE(manager.getCommandQString("F1"), QString()); + + // Verify defaults are non-empty (don't assert exact count to avoid brittleness) + QVERIFY(!manager.getAllHotkeys().empty()); +} + +void TestHotkeyManager::exportSortOrderTest() +{ + HotkeyManager manager; + + // Import hotkeys in a specific order - order should be preserved (no auto-sorting) + QString testConfig = "_hotkey CTRL+SHIFT+F1 two_mods\n" + "_hotkey F2 no_mods\n" + "_hotkey ALT+F3 one_mod\n" + "_hotkey F4 no_mods_2\n" + "_hotkey CTRL+F5 one_mod_2\n"; + + manager.importFromCliFormat(testConfig); + + QString exported = manager.exportToCliFormat(); + + // Find positions of each hotkey in the exported string + const auto posF2 = exported.indexOf("_hotkey F2"); + const auto posF4 = exported.indexOf("_hotkey F4"); + const auto posAltF3 = exported.indexOf("_hotkey ALT+F3"); + const auto posCtrlF5 = exported.indexOf("_hotkey CTRL+F5"); + const auto posCtrlShiftF1 = exported.indexOf("_hotkey CTRL+SHIFT+F1"); + + // Verify order is preserved exactly as imported (no auto-sorting) + // Original order: CTRL+SHIFT+F1, F2, ALT+F3, F4, CTRL+F5 + QVERIFY(posCtrlShiftF1 < posF2); + QVERIFY(posF2 < posAltF3); + QVERIFY(posAltF3 < posF4); + QVERIFY(posF4 < posCtrlF5); +} + +void TestHotkeyManager::setHotkeyTest() +{ + HotkeyManager manager; + + // Clear any existing hotkeys + manager.importFromCliFormat(""); + QCOMPARE(manager.getAllHotkeys().size(), 0); + + // Test setting a new hotkey directly + QVERIFY(manager.setHotkey("F1", "look")); + QCOMPARE(manager.getCommandQString("F1"), QString("look")); + QCOMPARE(manager.getAllHotkeys().size(), 1); + + // Test setting another hotkey + QVERIFY(manager.setHotkey("F2", "flee")); + QCOMPARE(manager.getCommandQString("F2"), QString("flee")); + QCOMPARE(manager.getAllHotkeys().size(), 2); + + // Test updating an existing hotkey (should replace, not add) + QVERIFY(manager.setHotkey("F1", "inventory")); + QCOMPARE(manager.getCommandQString("F1"), QString("inventory")); + QCOMPARE(manager.getAllHotkeys().size(), 2); // Still 2, not 3 + + // Test setting hotkey with modifiers + QVERIFY(manager.setHotkey("CTRL+F3", "open exit n")); + QCOMPARE(manager.getCommandQString("CTRL+F3"), QString("open exit n")); + QCOMPARE(manager.getAllHotkeys().size(), 3); + + // Test updating hotkey with modifiers + QVERIFY(manager.setHotkey("CTRL+F3", "close exit n")); + QCOMPARE(manager.getCommandQString("CTRL+F3"), QString("close exit n")); + QCOMPARE(manager.getAllHotkeys().size(), 3); // Still 3 + + // Test that export contains the updated values + QString exported = manager.exportToCliFormat(); + QVERIFY(exported.contains("_hotkey F1 inventory")); + QVERIFY(exported.contains("_hotkey F2 flee")); + QVERIFY(exported.contains("_hotkey CTRL+F3 close exit n")); + QVERIFY(!exported.contains("_hotkey F1 look")); // Old value should not be present +} + +void TestHotkeyManager::removeHotkeyTest() +{ + HotkeyManager manager; + + // Setup: import some hotkeys + manager.importFromCliFormat("_hotkey F1 look\n_hotkey F2 flee\n_hotkey CTRL+F3 open exit n\n"); + QCOMPARE(manager.getAllHotkeys().size(), 3); + + // Test removing a hotkey + manager.removeHotkey("F1"); + QCOMPARE(manager.getCommandQString("F1"), QString()); // Should be empty now + QCOMPARE(manager.getAllHotkeys().size(), 2); + + // Verify other hotkeys still exist + QCOMPARE(manager.getCommandQString("F2"), QString("flee")); + QCOMPARE(manager.getCommandQString("CTRL+F3"), QString("open exit n")); + + // Test removing hotkey with modifiers + manager.removeHotkey("CTRL+F3"); + QCOMPARE(manager.getCommandQString("CTRL+F3"), QString()); + QCOMPARE(manager.getAllHotkeys().size(), 1); + + // Test removing non-existent hotkey (should not crash or change count) + manager.removeHotkey("F10"); + QCOMPARE(manager.getAllHotkeys().size(), 1); + + // Test removing with non-canonical modifier order (should still work due to normalization) + manager.importFromCliFormat("_hotkey ALT+CTRL+F5 test\n"); + QCOMPARE(manager.getAllHotkeys().size(), 1); + manager.removeHotkey("CTRL+ALT+F5"); // Canonical order + QCOMPARE(manager.getAllHotkeys().size(), 0); + + // Test that export reflects removal + manager.importFromCliFormat("_hotkey F1 look\n_hotkey F2 flee\n"); + manager.removeHotkey("F1"); + QString exported = manager.exportToCliFormat(); + QVERIFY(!exported.contains("_hotkey F1")); + QVERIFY(exported.contains("_hotkey F2 flee")); +} + +void TestHotkeyManager::hasHotkeyTest() +{ + HotkeyManager manager; + + // Clear and setup + manager.importFromCliFormat("_hotkey F1 look\n_hotkey CTRL+F2 flee\n"); + + // Test hasHotkey returns true for existing keys + QVERIFY(manager.hasHotkey("F1")); + QVERIFY(manager.hasHotkey("CTRL+F2")); + + // Test hasHotkey returns false for non-existent keys + QVERIFY(!manager.hasHotkey("F3")); + QVERIFY(!manager.hasHotkey("CTRL+F1")); + QVERIFY(!manager.hasHotkey("ALT+F2")); + + // Test hasHotkey works with non-canonical modifier order + QVERIFY(manager.hasHotkey("CTRL+F2")); + + // Test case insensitivity + QVERIFY(manager.hasHotkey("f1")); + QVERIFY(manager.hasHotkey("ctrl+f2")); + + // Test after removal + manager.removeHotkey("F1"); + QVERIFY(!manager.hasHotkey("F1")); + QVERIFY(manager.hasHotkey("CTRL+F2")); // Other key still exists +} + +void TestHotkeyManager::invalidKeyValidationTest() +{ + HotkeyManager manager; + + // Clear any existing hotkeys + manager.importFromCliFormat(""); + QCOMPARE(manager.getAllHotkeys().size(), 0); + + // Test that invalid base keys are rejected + QVERIFY(!manager.setHotkey("F13", "invalid")); + QCOMPARE(manager.getCommandQString("F13"), QString()); // Should not be set + QCOMPARE(manager.getAllHotkeys().size(), 0); + + // Test typo in key name + QVERIFY(!manager.setHotkey("NUMPDA8", "typo")); // Typo: NUMPDA instead of NUMPAD + QCOMPARE(manager.getCommandQString("NUMPDA8"), QString()); + QCOMPARE(manager.getAllHotkeys().size(), 0); + + // Test completely invalid key + QVERIFY(!manager.setHotkey("INVALID", "test")); + QCOMPARE(manager.getCommandQString("INVALID"), QString()); + QCOMPARE(manager.getAllHotkeys().size(), 0); + + // Test that valid keys still work + QVERIFY(manager.setHotkey("F12", "valid")); + QCOMPARE(manager.getCommandQString("F12"), QString("valid")); + QCOMPARE(manager.getAllHotkeys().size(), 1); + + // Test invalid key with valid modifiers + QVERIFY(!manager.setHotkey("CTRL+F13", "invalid")); + QCOMPARE(manager.getCommandQString("CTRL+F13"), QString()); + QCOMPARE(manager.getAllHotkeys().size(), 1); // Still just F12 + + // Test import also rejects invalid keys + manager.importFromCliFormat("_hotkey F1 valid\n_hotkey F13 invalid\n_hotkey NUMPAD8 valid2\n"); + QCOMPARE(manager.getAllHotkeys().size(), 2); // Only F1 and NUMPAD8 + QCOMPARE(manager.getCommandQString("F1"), QString("valid")); + QCOMPARE(manager.getCommandQString("NUMPAD8"), QString("valid2")); + QCOMPARE(manager.getCommandQString("F13"), QString()); // Not imported + + // Test all valid key categories work + manager.importFromCliFormat(""); + + // Function keys + QVERIFY(manager.setHotkey("F1", "test")); + QVERIFY(manager.hasHotkey("F1")); + + // Numpad + QVERIFY(manager.setHotkey("NUMPAD5", "test")); + QVERIFY(manager.hasHotkey("NUMPAD5")); + + // Navigation + QVERIFY(manager.setHotkey("HOME", "test")); + QVERIFY(manager.hasHotkey("HOME")); + + // Arrow keys + QVERIFY(manager.setHotkey("UP", "test")); + QVERIFY(manager.hasHotkey("UP")); + + // Misc + QVERIFY(manager.setHotkey("ACCENT", "test")); + QVERIFY(manager.hasHotkey("ACCENT")); + + QVERIFY(manager.setHotkey("0", "test")); + QVERIFY(manager.hasHotkey("0")); + + QVERIFY(manager.setHotkey("HYPHEN", "test")); + QVERIFY(manager.hasHotkey("HYPHEN")); +} + +void TestHotkeyManager::duplicateKeyBehaviorTest() +{ + HotkeyManager manager; + + // Test that duplicate keys in imported content use the last definition + QString contentWithDuplicates = "_hotkey F1 first\n" + "_hotkey F2 middle\n" + "_hotkey F1 second\n"; + + manager.importFromCliFormat(contentWithDuplicates); + + // getCommand should return the last definition + QCOMPARE(manager.getCommandQString("F1"), QString("second")); + QCOMPARE(manager.getCommandQString("F2"), QString("middle")); + + // Test that setHotkey replaces existing entry + manager.importFromCliFormat("_hotkey F1 original\n"); + QCOMPARE(manager.getCommandQString("F1"), QString("original")); + QCOMPARE(manager.getAllHotkeys().size(), 1); + + QVERIFY(manager.setHotkey("F1", "replaced")); + QCOMPARE(manager.getCommandQString("F1"), QString("replaced")); + QCOMPARE(manager.getAllHotkeys().size(), 1); // Still 1, not 2 +} + +void TestHotkeyManager::commentPreservationTest() +{ + HotkeyManager manager; + + // Test that comments and formatting survive import/export round trip + const QString cliFormat = "# Leading comment\n" + "\n" + "# Section header\n" + "_hotkey F1 open\n" + "\n" + "# Another comment\n" + "_hotkey F2 close\n"; + + manager.importFromCliFormat(cliFormat); + const QString exported = manager.exportToCliFormat(); + + // Verify comments are preserved in export + QVERIFY(exported.contains("# Leading comment")); + QVERIFY(exported.contains("# Section header")); + QVERIFY(exported.contains("# Another comment")); + + // Verify hotkeys are still correct + QVERIFY(exported.contains("_hotkey F1 open")); + QVERIFY(exported.contains("_hotkey F2 close")); + + // Verify order is preserved (comments before their hotkeys) + const auto posLeading = exported.indexOf("# Leading comment"); + const auto posSection = exported.indexOf("# Section header"); + const auto posF1 = exported.indexOf("_hotkey F1"); + const auto posAnother = exported.indexOf("# Another comment"); + const auto posF2 = exported.indexOf("_hotkey F2"); + + QVERIFY(posLeading < posSection); + QVERIFY(posSection < posF1); + QVERIFY(posF1 < posAnother); + QVERIFY(posAnother < posF2); +} + +void TestHotkeyManager::settingsPersistenceTest() +{ + // Test that the HotkeyManager constructor loads settings and + // that saveToSettings() can be called without error. + // Note: Full persistence testing would require dependency injection + // of QSettings, which is beyond the scope of this test. + + HotkeyManager manager; + + // Manager should have loaded something (either defaults or saved settings) + // Just verify it's in a valid state + QVERIFY(!manager.exportToCliFormat().isEmpty()); + + // Import custom hotkeys + manager.importFromCliFormat("# Persistence test\n" + "_hotkey F1 testcmd\n"); + + QCOMPARE(manager.getCommandQString("F1"), QString("testcmd")); + + // Verify saveToSettings() doesn't crash + manager.saveToSettings(); + + // Verify state is still valid after save + QCOMPARE(manager.getCommandQString("F1"), QString("testcmd")); + QVERIFY(manager.exportToCliFormat().contains("# Persistence test")); +} + +void TestHotkeyManager::directLookupTest() +{ + HotkeyManager manager; + + // Import hotkeys for testing + manager.importFromCliFormat("_hotkey F1 look\n" + "_hotkey CTRL+F2 flee\n" + "_hotkey NUMPAD8 n\n" + "_hotkey CTRL+NUMPAD5 s\n" + "_hotkey SHIFT+ALT+UP north\n"); + + // Test direct lookup for function keys (isNumpad=false) + QCOMPARE(manager.getCommandQString(Qt::Key_F1, Qt::NoModifier, false), QString("look")); + QCOMPARE(manager.getCommandQString(Qt::Key_F2, Qt::ControlModifier, false), QString("flee")); + + // Test that wrong modifiers don't match + QCOMPARE(manager.getCommandQString(Qt::Key_F1, Qt::ControlModifier, false), QString()); + QCOMPARE(manager.getCommandQString(Qt::Key_F2, Qt::NoModifier, false), QString()); + + // Test numpad keys (isNumpad=true) - Qt::Key_8 with isNumpad=true + QCOMPARE(manager.getCommandQString(Qt::Key_8, Qt::NoModifier, true), QString("n")); + QCOMPARE(manager.getCommandQString(Qt::Key_5, Qt::ControlModifier, true), QString("s")); + + // Test that numpad keys don't match non-numpad lookups + QCOMPARE(manager.getCommandQString(Qt::Key_8, Qt::NoModifier, false), QString()); + + // Test arrow keys (isNumpad=false) + QCOMPARE(manager.getCommandQString(Qt::Key_Up, Qt::ShiftModifier | Qt::AltModifier, false), + QString("north")); + + // Test that order of modifiers doesn't matter for lookup + QCOMPARE(manager.getCommandQString(Qt::Key_Up, Qt::AltModifier | Qt::ShiftModifier, false), + QString("north")); + + // Test non-existent hotkey + QCOMPARE(manager.getCommandQString(Qt::Key_F12, Qt::NoModifier, false), QString()); +} + +QTEST_MAIN(TestHotkeyManager) diff --git a/tests/TestHotkeyManager.h b/tests/TestHotkeyManager.h new file mode 100644 index 000000000..b7657058c --- /dev/null +++ b/tests/TestHotkeyManager.h @@ -0,0 +1,41 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include "../src/global/macros.h" + +#include + +class NODISCARD_QOBJECT TestHotkeyManager final : public QObject +{ + Q_OBJECT + +public: + TestHotkeyManager(); + ~TestHotkeyManager() final; + +private Q_SLOTS: + // Setup/teardown for QSettings isolation + void initTestCase(); + void cleanupTestCase(); + + // Tests + void keyNormalizationTest(); + void importExportRoundTripTest(); + void importEdgeCasesTest(); + void resetToDefaultsTest(); + void exportSortOrderTest(); + void setHotkeyTest(); + void removeHotkeyTest(); + void hasHotkeyTest(); + void invalidKeyValidationTest(); + void duplicateKeyBehaviorTest(); + void commentPreservationTest(); + void settingsPersistenceTest(); + void directLookupTest(); + +private: + // Original QSettings namespace (restored in cleanupTestCase) + QString m_originalOrganization; + QString m_originalApplication; +};