diff --git a/source/gui/tests/test_GuiManager.h b/source/gui/tests/test_GuiManager.h index e9e2f0318f..25bad92af6 100755 --- a/source/gui/tests/test_GuiManager.h +++ b/source/gui/tests/test_GuiManager.h @@ -116,8 +116,8 @@ public: { // Load up a fake test hotkey when pressing 'a'. const char* test_hotkey_name = "hotkey.test"; - g_ConfigDB.SetValueString(CFG_USER, test_hotkey_name, "A"); - LoadHotkeys(); + configDB->SetValueString(CFG_USER, test_hotkey_name, "A"); + LoadHotkeys(*configDB); // Load up a test page. const ScriptInterface& scriptInterface = *(g_GUI->GetScriptInterface()); @@ -189,5 +189,6 @@ public: ScriptInterface::FromJSVal(prq, js_hotkey_pressed_value, hotkey_pressed_value); TS_ASSERT_EQUALS(hotkey_pressed_value, false); + UnloadHotkeys(); } }; diff --git a/source/ps/GameSetup/GameSetup.cpp b/source/ps/GameSetup/GameSetup.cpp index 529457fd31..3e35f1aefd 100644 --- a/source/ps/GameSetup/GameSetup.cpp +++ b/source/ps/GameSetup/GameSetup.cpp @@ -499,7 +499,7 @@ static void InitPs(bool setup_gui, const CStrW& gui_page, ScriptInterface* srcSc // hotkeys { TIMER(L"ps_lang_hotkeys"); - LoadHotkeys(); + LoadHotkeys(g_ConfigDB); } if (!setup_gui) diff --git a/source/ps/Hotkey.cpp b/source/ps/Hotkey.cpp index e9bc25b045..3cfc91679a 100644 --- a/source/ps/Hotkey.cpp +++ b/source/ps/Hotkey.cpp @@ -33,23 +33,31 @@ static bool unified[UNIFIED_LAST - UNIFIED_SHIFT]; std::unordered_map g_HotkeyMap; std::unordered_map g_HotkeyStatus; +namespace { +// List of currently pressed hotkeys. This is used to quickly reset hotkeys. +// NB: this points to one of g_HotkeyMap's mappings. It works because that map is stable once constructed. +std::vector pressedHotkeys; +} + static_assert(std::is_integral::type>::value, "SDL_Scancode is not an integral enum."); static_assert(SDL_USEREVENT_ == SDL_USEREVENT, "SDL_USEREVENT_ is not the same type as the real SDL_USEREVENT"); static_assert(UNUSED_HOTKEY_CODE == SDL_SCANCODE_UNKNOWN); // Look up each key binding in the config file and set the mappings for // all key combinations that trigger it. -static void LoadConfigBindings() +static void LoadConfigBindings(CConfigDB& configDB) { - for (const std::pair& configPair : g_ConfigDB.GetValuesWithPrefix(CFG_COMMAND, "hotkey.")) + for (const std::pair& configPair : configDB.GetValuesWithPrefix(CFG_COMMAND, "hotkey.")) { std::string hotkeyName = configPair.first.substr(7); // strip the "hotkey." prefix - if (configPair.second.empty()) + // "unused" is kept or the A23->24 migration, this can likely be removed in A25. + if (configPair.second.empty() || (configPair.second.size() == 1 && configPair.second.front() == "unused")) { // Unused hotkeys must still be registered in the map to appear in the hotkey editor. SHotkeyMapping unusedCode; unusedCode.name = hotkeyName; + unusedCode.primary = SKey{ UNUSED_HOTKEY_CODE }; g_HotkeyMap[UNUSED_HOTKEY_CODE].push_back(unusedCode); continue; } @@ -82,6 +90,7 @@ static void LoadConfigBindings() SHotkeyMapping bindCode; bindCode.name = hotkeyName; + bindCode.primary = SKey{ itKey->code }; for (itKey2 = keyCombination.begin(); itKey2 != keyCombination.end(); ++itKey2) if (itKey != itKey2) // Push any auxiliary keys @@ -93,13 +102,15 @@ static void LoadConfigBindings() } } -void LoadHotkeys() +void LoadHotkeys(CConfigDB& configDB) { - LoadConfigBindings(); + pressedHotkeys.clear(); + LoadConfigBindings(configDB); } void UnloadHotkeys() { + pressedHotkeys.clear(); g_HotkeyMap.clear(); g_HotkeyStatus.clear(); } @@ -234,14 +245,39 @@ InReaction HotkeyInputHandler(const SDL_Event_* ev) // matching the conditions (i.e. the event with the highest number of auxiliary // keys, providing they're all down) - bool typeKeyDown = ( ev->ev.type == SDL_KEYDOWN ) || ( ev->ev.type == SDL_MOUSEBUTTONDOWN ) || (ev->ev.type == SDL_MOUSEWHEEL); + // Furthermore, we need to support non-conflicting hotkeys triggering at the same time. + // This is much more complex code than you might expect. A refactoring could be used. - std::vector pressedHotkeys; + std::vector newPressedHotkeys; std::vector releasedHotkeys; size_t closestMapMatch = 0; + bool release = (ev->ev.type == SDL_KEYUP) || (ev->ev.type == SDL_MOUSEBUTTONUP); + + SKey retrigger = { UNUSED_HOTKEY_CODE }; for (const SHotkeyMapping& hotkey : g_HotkeyMap[scancode]) { + // If the key is being released, any active hotkey is released. + if (release) + { + if (g_HotkeyStatus[hotkey.name]) + { + releasedHotkeys.push_back(hotkey.name.c_str()); + + // If we are releasing a key, we possibly need to retrigger less precise hotkeys + // (e.g. 'Ctrl + D', if releasing D, we need to retrigger Ctrl hotkeys). + // To do this simply, we'll just re-trigger any of the additional required key. + if (!hotkey.requires.empty() && retrigger.code == UNUSED_HOTKEY_CODE) + for (const SKey& k : hotkey.requires) + if (isPressed(k)) + { + retrigger.code = hotkey.requires.front().code; + break; + } + } + continue; + } + // Check for no unpermitted keys bool accept = true; for (const SKey& k : hotkey.requires) @@ -260,26 +296,49 @@ InReaction HotkeyInputHandler(const SDL_Event_* ev) if (hotkey.requires.size() + 1 > closestMapMatch) { // Throw away the old less-precise matches - pressedHotkeys.clear(); - releasedHotkeys.clear(); + newPressedHotkeys.clear(); closestMapMatch = hotkey.requires.size() + 1; } - if (typeKeyDown) - pressedHotkeys.push_back(hotkey.name.c_str()); - else - releasedHotkeys.push_back(hotkey.name.c_str()); + newPressedHotkeys.push_back(&hotkey); } } } - for (const char* hotkeyName : pressedHotkeys) + // If this is a new key, check if we need to unset any previous hotkey. + // NB: this uses unsorted vectors because there are usually very few elements to go through + // (and thus it is presumably faster than std::set). + if ((ev->ev.type == SDL_KEYDOWN) || (ev->ev.type == SDL_MOUSEBUTTONDOWN)) + for (const SHotkeyMapping* hotkey : pressedHotkeys) + { + if (hotkey->requires.size() + 1 < closestMapMatch) + releasedHotkeys.push_back(hotkey->name.c_str()); + else if (std::find(newPressedHotkeys.begin(), newPressedHotkeys.end(), hotkey) == newPressedHotkeys.end()) + { + // We need to check that all 'keys' are still pressed (because of mouse buttons). + if (!isPressed(hotkey->primary)) + continue; + for (const SKey& key : hotkey->requires) + if (!isPressed(key)) + continue; + newPressedHotkeys.push_back(hotkey); + } + } + + pressedHotkeys.swap(newPressedHotkeys); + + // Mouse wheel events are released instantly. + if (ev->ev.type == SDL_MOUSEWHEEL) + for (const SHotkeyMapping* hotkey : pressedHotkeys) + releasedHotkeys.push_back(hotkey->name.c_str()); + + for (const SHotkeyMapping* hotkey : pressedHotkeys) { // Send a KeyPress event when a hotkey is pressed initially and on mouseButton and mouseWheel events. if (ev->ev.type != SDL_KEYDOWN || ev->ev.key.repeat == 0) { SDL_Event_ hotkeyPressNotification; hotkeyPressNotification.ev.type = SDL_HOTKEYPRESS; - hotkeyPressNotification.ev.user.data1 = const_cast(hotkeyName); + hotkeyPressNotification.ev.user.data1 = const_cast(hotkey->name.c_str()); in_push_priority_event(&hotkeyPressNotification); } @@ -288,7 +347,7 @@ InReaction HotkeyInputHandler(const SDL_Event_* ev) // On linux, modifier keys (shift, alt, ctrl) are not repeated, see https://github.com/SFML/SFML/issues/122. SDL_Event_ hotkeyDownNotification; hotkeyDownNotification.ev.type = SDL_HOTKEYDOWN; - hotkeyDownNotification.ev.user.data1 = const_cast(hotkeyName); + hotkeyDownNotification.ev.user.data1 = const_cast(hotkey->name.c_str()); in_push_priority_event(&hotkeyDownNotification); } @@ -300,6 +359,15 @@ InReaction HotkeyInputHandler(const SDL_Event_* ev) in_push_priority_event(&hotkeyNotification); } + if (retrigger.code != UNUSED_HOTKEY_CODE) + { + SDL_Event_ phantomKey; + phantomKey.ev.type = SDL_KEYDOWN; + phantomKey.ev.key.repeat = 0; + phantomKey.ev.key.keysym.scancode = static_cast(retrigger.code); + HotkeyInputHandler(&phantomKey); + } + return IN_PASS; } diff --git a/source/ps/Hotkey.h b/source/ps/Hotkey.h index 3437a5060a..0ccada5744 100644 --- a/source/ps/Hotkey.h +++ b/source/ps/Hotkey.h @@ -60,6 +60,7 @@ struct SKey struct SHotkeyMapping { CStr name; // name of the hotkey + SKey primary; // the primary key std::vector requires; // list of non-primary keys that must also be active }; @@ -73,7 +74,8 @@ extern std::unordered_map g_HotkeyMap; // The current pressed status of hotkeys extern std::unordered_map g_HotkeyStatus; -extern void LoadHotkeys(); +class CConfigDB; +extern void LoadHotkeys(CConfigDB& configDB); extern void UnloadHotkeys(); extern InReaction HotkeyStateChange(const SDL_Event_* ev); diff --git a/source/ps/scripting/JSInterface_Hotkey.cpp b/source/ps/scripting/JSInterface_Hotkey.cpp index a402d38469..8ff37ded37 100644 --- a/source/ps/scripting/JSInterface_Hotkey.cpp +++ b/source/ps/scripting/JSInterface_Hotkey.cpp @@ -21,6 +21,7 @@ #include "lib/external_libraries/libsdl.h" #include "ps/CLogger.h" +#include "ps/ConfigDB.h" #include "ps/Hotkey.h" #include "ps/KeyName.h" #include "scriptinterface/ScriptConversions.h" @@ -115,7 +116,7 @@ JS::Value GetScancodeKeyNames(ScriptInterface::CmptPrivate* pCmptPrivate) void ReloadHotkeys(ScriptInterface::CmptPrivate* UNUSED(pCmptPrivate)) { UnloadHotkeys(); - LoadHotkeys(); + LoadHotkeys(g_ConfigDB); } JS::Value GetConflicts(ScriptInterface::CmptPrivate* pCmptPrivate, JS::HandleValue combination) diff --git a/source/ps/tests/test_Hotkeys.h b/source/ps/tests/test_Hotkeys.h new file mode 100644 index 0000000000..aa36730e87 --- /dev/null +++ b/source/ps/tests/test_Hotkeys.h @@ -0,0 +1,181 @@ +/* Copyright (C) 2021 Wildfire Games. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +#include "lib/self_test.h" + +#include "lib/external_libraries/libsdl.h" +#include "ps/Hotkey.h" +#include "ps/ConfigDB.h" +#include "ps/Globals.h" +#include "ps/Filesystem.h" + + +class TestHotkey : public CxxTest::TestSuite +{ + CConfigDB* configDB; + +private: + + void fakeInput(const char* key, bool keyDown) + { + SDL_Event_ ev; + ev.ev.type = keyDown ? SDL_KEYDOWN : SDL_KEYUP; + ev.ev.key.repeat = 0; + ev.ev.key.keysym.scancode = SDL_GetScancodeFromName(key); + GlobalsInputHandler(&ev); + HotkeyInputHandler(&ev); + while(in_poll_priority_event(&ev)) + HotkeyStateChange(&ev); + } + +public: + void setUp() + { + g_VFS = CreateVfs(); + TS_ASSERT_OK(g_VFS->Mount(L"config", DataDir()/"_testconfig")); + TS_ASSERT_OK(g_VFS->Mount(L"cache", DataDir()/"_testcache")); + + configDB = new CConfigDB; + } + + void tearDown() + { + delete configDB; + g_VFS.reset(); + DeleteDirectory(DataDir()/"_testcache"); + DeleteDirectory(DataDir()/"_testconfig"); + } + + void test_Hotkeys() + { + configDB->SetValueString(CFG_SYSTEM, "hotkey.A", "A"); + configDB->SetValueString(CFG_SYSTEM, "hotkey.AB", "A+B"); + configDB->SetValueString(CFG_SYSTEM, "hotkey.ABC", "A+B+C"); + configDB->SetValueString(CFG_SYSTEM, "hotkey.D", "D"); + configDB->WriteFile(CFG_SYSTEM, "config/conf.cfg"); + configDB->Reload(CFG_SYSTEM); + + UnloadHotkeys(); + LoadHotkeys(*configDB); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + /** + * Simple check. + */ + fakeInput("A", true); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), true); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + fakeInput("A", false); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + /** + * Hotkey combinations: + * - The most precise match only is selected + * - Order does not matter. + */ + fakeInput("A", true); + fakeInput("B", true); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), true); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + fakeInput("A", false); + fakeInput("B", false); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + + fakeInput("B", true); + fakeInput("A", true); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), true); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + fakeInput("A", false); + fakeInput("B", false); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + fakeInput("A", true); + fakeInput("B", true); + fakeInput("B", false); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), true); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + /** + * Quirk of the implementation: hotkeys are allowed to fire with too many keys. + * Further, hotkeys of the same specificity (i.e. same # of required keys) + * are allowed to fire at the same time if they don't conflict. + * This is required so that e.g. up+left scrolls both up and left at the same time. + */ + fakeInput("A", true); + fakeInput("D", true); + // A+D isn't a hotkey; both A and D are active. + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), true); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), true); + + fakeInput("C", true); + // A+D+C likewise. + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), true); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), true); + + fakeInput("B", true); + // Here D is inactivated because it's lower-specificity than A+B+C (with D being ignored). + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), true); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + fakeInput("A", false); + fakeInput("B", false); + fakeInput("C", false); + fakeInput("D", false); + + fakeInput("B", true); + fakeInput("D", true); + fakeInput("A", true); + TS_ASSERT_EQUALS(HotkeyIsPressed("A"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("AB"), true); + TS_ASSERT_EQUALS(HotkeyIsPressed("ABC"), false); + TS_ASSERT_EQUALS(HotkeyIsPressed("D"), false); + + UnloadHotkeys(); + } +};