diff --git a/CMakeLists.txt b/CMakeLists.txt index 7eb0e0f..e3326c3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.24) -project(KSwitcher VERSION 0.6.64 LANGUAGES CXX RC) +project(KSwitcher VERSION 0.6.65 LANGUAGES CXX RC) if(NOT WIN32) message(FATAL_ERROR "KSwitcher uses native Windows APIs") endif() diff --git a/README.md b/README.md index 91f7601..5d05abd 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ In **Settings → Mouse**, put the pointer over **Test mouse buttons here** and press the buttons you want to test. The page shows the last detected button and counts presses separately for ordinary window events and Windows Raw Input. +Use **+** beside **Mouse events** to capture and name a wheel direction. +If Windows reports an existing button or an already added direction, the +dialog selects that event in the list. Extra device controls need to emit a +supported Windows mouse signal before KSwitcher can bind them. + Choose **Global** to set up fallback actions. Use **+** beside **Profiles** to create a named profile and set its applications. Double-click a profile or choose **Edit…** in its context menu to change its name or applications. The @@ -680,10 +685,13 @@ required for ordinary conflicts. Every server write uses the version just read a condition, including explicit Upload. A racing write is rejected rather than silently overwritten. Deleted server versions are not silently recreated. -Hotkeys, snippets, application exclusions, layout order, conversion, screenshot and autocorrection -preferences are shared. Startup/elevation, keep-awake, logging, history enablement/limits, -Tesseract executable/engine selection, connection details and credentials stay local. Clipboard -history, screenshots and dictionary files are never uploaded. Full synchronization identifies +The server profile includes all settings from KSwitcher Settings, including mouse events and +bindings, application language profiles, startup, keep-awake, logging, history limits and OCR. +Update the clients that use the same server profile before syncing the expanded format. +Older server backups preserve the local values for fields they never stored. Restored executable +paths refer to the same locations and may need updating on a fresh installation. +Connection details and credentials stay local so you can sign in again after reinstalling. +Clipboard history, screenshots and dictionary files are never uploaded. Full synchronization identifies missing keyboard layouts by language (when Windows can identify it) and exact ID. Install the same layout variant, or select **Keep this PC's layouts and layout shortcuts (sync other settings)** and click **Save / Sign in / Sync**. This device-local option keeps the entire @@ -988,8 +996,8 @@ subset is returned to the first available allowed language by a periodic check with an excluded language active or selecting one via Windows or a direct shortcut. Languages remain installed and visible in Windows' language picker. Unavailable layouts are skipped; if the entire selection is unavailable, the default cycle is -used. Profiles stay local to this PC, survive cloud sync and are included in manual -settings export/import. +used. Application language profiles are included in server synchronization and +manual settings export/import. **Українською.** Відкрий **Settings → Languages → Languages for applications…**. Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми @@ -1012,5 +1020,5 @@ settings export/import. Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму комбінацію. Мови залишаються встановленими й видимими в меню Windows. Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє -загальний цикл. Профілі локальні, зберігаються під час хмарної синхронізації +загальний цикл. Профілі входять до серверної синхронізації та входять до ручного експорту/імпорту налаштувань. diff --git a/Release/KSwitcher-win-x64.zip b/Release/KSwitcher-win-x64.zip index 7c6b7c8..4b922b0 100644 Binary files a/Release/KSwitcher-win-x64.zip and b/Release/KSwitcher-win-x64.zip differ diff --git a/Release/README.md b/Release/README.md index 0c4df31..ea91dd4 100644 --- a/Release/README.md +++ b/Release/README.md @@ -1,5 +1,29 @@ # Latest Windows release +## v0.6.65 — 2026-11-12 + +Use **Settings → Mouse → +** beside **Mouse events** to capture and name a +vertical or horizontal wheel direction. Capturing a known button or an +already registered direction selects its existing row. A new signal is +accepted only when Windows delivers it through both the test and runtime +input paths. A configured wheel action replaces scrolling for that direction +where the profile applies. + +OpenBao now backs up all KSwitcher Settings, including mouse events, bindings, +application profiles, startup, clipboard history limits, and OCR preferences. +Update all clients that share a server profile before syncing. Downloading an +older backup preserves local settings for fields it never included. After +reinstalling Windows, reconnect to OpenBao and choose **Download remote**. +Executable paths and optional OCR software must still be available on the +restored PC. Clipboard contents and Windows credentials stay on the device. + +**Українською.** У **Settings → Mouse** натисни **+** біля **Mouse events**, +щоб додати напрямок прокручування. Відомий сигнал виділить наявний рядок. +OpenBao тепер зберігає всі налаштування KSwitcher, зокрема профілі миші, +OCR й автозапуск. Перед синхронізацією онови всі клієнти спільного профілю. +Після перевстановлення Windows підключись до OpenBao й натисни +**Download remote**. + ## v0.6.64 — 2026-11-12 The new **Settings → Mouse** tab lets you assign actions to mouse buttons and supported double-clicks. Use **Global** for fallback actions and create profiles for one or more applications. Edit a profile with a double-click, and enable or disable it with the **Active** checkbox. Choose applications by pointing at a window or opening an executable or shortcut. diff --git a/Release/SHA256SUMS.txt b/Release/SHA256SUMS.txt index 196663d..ae9ae36 100644 --- a/Release/SHA256SUMS.txt +++ b/Release/SHA256SUMS.txt @@ -1 +1 @@ -8fdd701d2ed9223056f7e624e888ef459c919f605c624d5f35c3dbd3652a430b KSwitcher-win-x64.zip +e60ca342737155dce14a2bb1dd43ec1cef890cf7d9e5efb2e15a5b9629eaf1af KSwitcher-win-x64.zip diff --git a/Release/release-files.json b/Release/release-files.json index a7dccf1..597810a 100644 --- a/Release/release-files.json +++ b/Release/release-files.json @@ -1,86 +1,86 @@ { - "version": "0.6.64", "files": [ { - "path": "KSwitcher.exe", - "size": 2185728, - "sha256": "81ccb50fd3ac8a4768f0be9a4b70ef21c2a1f11e73b57871cb5123f29bfbd503" + "size": 2190336, + "sha256": "9d268cbb00fb9db3e52bc68994cefb50cee707f9dc1b940d876314ba1b6a06b3", + "path": "KSwitcher.exe" }, { - "path": "KSwitcher.Config.exe", - "size": 2120192, - "sha256": "83d76af6ff44f56532d2537e57dfb7e3859d72f34180d3c7c02497e71763378e" + "size": 2129408, + "sha256": "d83fad30539e7b974bb8012859f3fee8143c5e4cc25684643b16e2fc34b97a5e", + "path": "KSwitcher.Config.exe" }, { - "path": "KSwitcher.Screenshot.exe", - "size": 3207168, - "sha256": "6e93963d4252a87bd6ea9d53ccd2e602c9c2709ca3fe835dc90a633b77b00193" + "size": 3210240, + "sha256": "29f7846ee4d3c469779b66acf99c90a4173e1e1577020398d6e1dc4eae492e06", + "path": "KSwitcher.Screenshot.exe" }, { - "path": "LICENSE", "size": 4401, - "sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c" + "sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c", + "path": "LICENSE" }, { - "path": "json-LICENSE.txt", "size": 1075, - "sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86" + "sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86", + "path": "json-LICENSE.txt" }, { - "path": "README.md", - "size": 89826, - "sha256": "9148049aa80595e0de30ecc9af1478572f85a8076da981ea80dafd5bb9c4815b" + "size": 90358, + "sha256": "435daae289ca64dd9130ed772125de3f3303f1dbb80d1cbf7a9913058ded47ec", + "path": "README.md" }, { - "path": "LAN-PREVIEW.md", "size": 9900, - "sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e" + "sha256": "6dcb0efabec6898965bbbd31ba68e06276fd0f098ac9a8649bc1f7d7de21d63e", + "path": "LAN-PREVIEW.md" }, { - "path": "dictionaries/en.txt", "size": 682867, - "sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21" + "sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21", + "path": "dictionaries/en.txt" }, { - "path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt", "size": 20138, - "sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" + "sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5", + "path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt" }, { - "path": "dictionaries/README.md", "size": 2230, - "sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec" + "sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec", + "path": "dictionaries/README.md" }, { - "path": "dictionaries/ru.txt", "size": 1060364, - "sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b" + "sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b", + "path": "dictionaries/ru.txt" }, { - "path": "dictionaries/sources.json", "size": 1279, - "sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86" + "sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86", + "path": "dictionaries/sources.json" }, { - "path": "dictionaries/uk.txt", "size": 898248, - "sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86" + "sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86", + "path": "dictionaries/uk.txt" }, { - "path": "tools/README.txt", "size": 993, - "sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a" + "sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a", + "path": "tools/README.txt" }, { - "path": "tools/StayHere.exe", "size": 134656, - "sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376" + "sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376", + "path": "tools/StayHere.exe" }, { - "path": "tools/StayHere-README.md", "size": 2796, - "sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413" + "sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413", + "path": "tools/StayHere-README.md" } ], + "version": "0.6.65", "schema": 1 } diff --git a/resources/app.rc b/resources/app.rc index 340f510..18131d3 100644 --- a/resources/app.rc +++ b/resources/app.rc @@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur" 1 RT_MANIFEST "app.manifest" 201 RCDATA "update.ps1" 1 VERSIONINFO - FILEVERSION 0,6,64,0 - PRODUCTVERSION 0,6,64,0 + FILEVERSION 0,6,65,0 + PRODUCTVERSION 0,6,65,0 FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP BEGIN @@ -20,7 +20,7 @@ BEGIN BEGIN VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility" VALUE "ProductName", "KSwitcher" - VALUE "FileVersion", "0.6.64" + VALUE "FileVersion", "0.6.65" END END BLOCK "VarFileInfo" diff --git a/src/app.cpp b/src/app.cpp index 1076527..a7f3337 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -733,6 +733,10 @@ Bytes App::lanCommand(const Bytes &command) return reply; } void App::applySettings(const Settings &requested) +{ + applySettings(requested, true); +} +void App::applySettings(const Settings &requested, bool validateOcrInstallation) { Settings s = requested; std::set present, removed(s.snippetTombstones.begin(), s.snippetTombstones.end()); @@ -745,7 +749,7 @@ void App::applySettings(const Settings &requested) bool autostartChanged = false; AutoCorrector predictor; validateSettings(s); - validateTesseract(s); + if (validateOcrInstallation && ocrProfile(s) != ocrProfile(settings)) validateTesseract(s); if (s.autoCorrect) { predictor.configure(std::filesystem::path(executable()).parent_path() / L"dictionaries", s.autoLanguages); @@ -849,6 +853,8 @@ void App::resetLanInput() chatSwitchHook = {}; pendingMouseClicks = {}; mouseActions.clear(); + mouseWheelTarget = nullptr; + mouseWheelRemainder = 0; resetInput(); } void App::resetInput() @@ -1399,6 +1405,8 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp) auto &app = *current; const auto &mouse = *reinterpret_cast(lp); const int button = mouse.flags & LLMHF_INJECTED ? -1 : mouseButton(UINT(wp), HIWORD(mouse.mouseData)); + const auto wheelDelta = static_cast(HIWORD(mouse.mouseData)); + const int wheel = mouse.flags & LLMHF_INJECTED ? -1 : mouseWheelEvent(UINT(wp), wheelDelta); const bool down = mouseButtonDown(UINT(wp)); if (button >= 0 && app.mousePresses[button].held) { @@ -1430,7 +1438,8 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp) if (app.lanService->takeInputChange()) app.resetLanInput(); if (consumed) return 1; } - if (button >= 0 && down && !app.settings.mouseBindings.empty() && !app.busy && !app.suspended && + if (((button >= 0 && down) || mouseEventEnabled(app.settings, wheel)) && + !app.settings.mouseBindings.empty() && !app.busy && !app.suspended && !app.installingUpdate && !app.hotkeyCapture && modifiersReleased()) { const auto hit = WindowFromPoint(mouse.pt); @@ -1442,22 +1451,26 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp) const bool shellSurface = target == GetShellWindow() || wcscmp(windowClass, L"Progman") == 0 || wcscmp(windowClass, L"WorkerW") == 0 || wcscmp(windowClass, L"Shell_TrayWnd") == 0 || wcscmp(windowClass, L"Shell_SecondaryTrayWnd") == 0; - const auto *global = mouseBinding(app.settings, GlobalMouseProfile, button); - const bool globalLaunch = button >= 2 && global && global->type == MouseActionType::Launch && + const int event = wheel >= 0 ? wheel : button; + const auto *global = mouseBinding(app.settings, GlobalMouseProfile, event); + const bool globalLaunch = event >= 2 && global && global->type == MouseActionType::Launch && mouseActionApplies(*global, name); const bool restricted = ownWindow || shellSurface || name.empty(); - const bool testButton = name == L"kswitcher.config" && hit == GetDlgItem(target, 737); - const auto *fallback = restricted ? effectiveMouseAction(app.settings, L"", button, false) : nullptr; + wchar_t targetClass[64]{}; + if (ownWindow) GetClassNameW(target, targetClass, std::size(targetClass)); + const bool testButton = ownWindow && (hit == GetDlgItem(target, 737) || + wcscmp(targetClass, L"KSwitcher.AddMouseEvent") == 0); + const auto *fallback = restricted ? effectiveMouseAction(app.settings, L"", event, false) : nullptr; const auto *restrictedAction = isMouseVolumeAction(fallback) ? fallback : globalLaunch ? global : nullptr; - const bool eligible = target && !testButton && (!restricted || (button >= 2 && restrictedAction)) && - (button >= 2 || target == GetForegroundWindow()); + const bool eligible = target && !testButton && (!restricted || (event >= 2 && restrictedAction)) && + (event >= 2 || target == GetForegroundWindow()); if (eligible) { const bool foreground = target == GetForegroundWindow(); bool singleScoped{}, doubleScoped{}; auto *single = restricted ? restrictedAction : - effectiveMouseAction(app.settings, name, button, foreground, &singleScoped); - auto *doubleClick = !restricted && button < 3 ? + effectiveMouseAction(app.settings, name, event, foreground, &singleScoped); + auto *doubleClick = wheel < 0 && !restricted && button < 3 ? effectiveMouseAction(app.settings, name, 5 + button, foreground, &doubleScoped) : nullptr; if (!foreground) { @@ -1465,25 +1478,45 @@ LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp) if (doubleClick && doubleClick->type == MouseActionType::Shortcut) doubleClick = nullptr; } const auto now = GetTickCount64(); - auto &pending = app.pendingMouseClicks[button]; - const bool second = doubleClick && pending.target == target && - doubleMouseClick(pending.point, mouse.pt, now - pending.started); - if (second) pending = {}; - else if (pending.target) app.queueMouseAction(std::exchange(pending, {})); - const auto *action = second ? doubleClick : single; - if (action || doubleClick) - app.mousePresses[button] = {target, mouse.pt, now, action ? *action : MouseAction{}, - true, doubleClick && !second}; - if (action) + if (wheel >= 0 && single) { - if (second ? doubleScoped : singleScoped) app.mousePresses[button].action.applications.insert(name); - if (isMouseVolumeStep(action->type)) - app.beginMouseVolume(button); + if (app.mouseWheelTarget != target || app.mouseWheelSignal != wheel) + app.mouseWheelRemainder = 0; + app.mouseWheelTarget = target; + app.mouseWheelSignal = wheel; + const int steps = mouseWheelSteps(app.mouseWheelRemainder, wheelDelta); + for (int i = 0; i < steps; ++i) + app.queueMouseAction({target, mouse.pt, now, *single}); app.resetInput(); return 1; } + if (wheel < 0) + { + auto &pending = app.pendingMouseClicks[button]; + const bool second = doubleClick && pending.target == target && + doubleMouseClick(pending.point, mouse.pt, now - pending.started); + if (second) pending = {}; + else if (pending.target) app.queueMouseAction(std::exchange(pending, {})); + const auto *action = second ? doubleClick : single; + if (action || doubleClick) + app.mousePresses[button] = {target, mouse.pt, now, action ? *action : MouseAction{}, + true, doubleClick && !second}; + if (action) + { + if (second ? doubleScoped : singleScoped) app.mousePresses[button].action.applications.insert(name); + if (isMouseVolumeStep(action->type)) + app.beginMouseVolume(button); + app.resetInput(); + return 1; + } + } } } + if (wheel >= 0) + { + app.mouseWheelTarget = nullptr; + app.mouseWheelRemainder = 0; + } // Remote clicks invalidate a typing range just like local clicks. if (down || wp == WM_MOUSEWHEEL || wp == WM_MOUSEHWHEEL) { diff --git a/src/app.h b/src/app.h index 2ac0540..3daf9f9 100644 --- a/src/app.h +++ b/src/app.h @@ -106,6 +106,7 @@ class App private: friend struct InputRegressionAccess; void releaseIdleMemory(ULONGLONG now); + void applySettings(const Settings &, bool validateOcrInstallation); HHOOK keyboardHook{}, mouseHook{}; struct MousePress { @@ -118,6 +119,8 @@ class App std::array mousePresses{}; std::array pendingMouseClicks{}; std::deque mouseActions; + HWND mouseWheelTarget{}; + int mouseWheelSignal{-1}, mouseWheelRemainder{}; void queueMouseAction(MousePress); void flushMouseClicks(); void beginMouseVolume(int button); diff --git a/src/app_sync.inl b/src/app_sync.inl index c94febc..cb803d6 100644 --- a/src/app_sync.inl +++ b/src/app_sync.inl @@ -79,7 +79,8 @@ void App::pollSync() if (encodeSettings(next) != snapshot) { atomicWrite(data / L"settings-before-sync.bin", protect(snapshot)); - applySettings(next); + // A restored OCR configuration can precede the optional local installation. + applySettings(next, false); syncStatus += L" Changes applied on this PC; local backup saved."; } else syncStatus += result.conflicts.empty() ? L" This PC's settings are already up to date; no local changes needed." : diff --git a/src/core.h b/src/core.h index c84bc9a..d71d973 100644 --- a/src/core.h +++ b/src/core.h @@ -101,6 +101,7 @@ struct Settings std::map> chatCycles; std::map appLanguageSets; std::map> mouseBindings; + std::map customMouseEvents; std::set disabledMouseProfiles; std::map> mouseProfileApplications; std::vector excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"}; diff --git a/src/mouse_buttons.h b/src/mouse_buttons.h index 5b4d7fb..e5f7a4e 100644 --- a/src/mouse_buttons.h +++ b/src/mouse_buttons.h @@ -6,7 +6,54 @@ namespace ks { inline constexpr const wchar_t *MouseButtonNames[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2"}; inline constexpr const wchar_t *MouseEvents[] = {L"Left", L"Right", L"Middle", L"XButton1", L"XButton2", - L"Double left", L"Double right", L"Double middle"}; + L"Double left", L"Double right", L"Double middle", + L"Wheel up", L"Wheel down", L"Wheel left", L"Wheel right"}; +inline constexpr int BuiltInMouseEventCount = 8; + +inline int mouseWheelEvent(UINT message, SHORT delta) +{ + if (!delta) return -1; + if (message == WM_MOUSEWHEEL) return BuiltInMouseEventCount + (delta < 0); + if (message == WM_MOUSEHWHEEL) return BuiltInMouseEventCount + 2 + (delta > 0); + return -1; +} + +inline int rawMouseSignal(const RAWMOUSE &mouse) +{ + constexpr USHORT downFlags[] = {RI_MOUSE_LEFT_BUTTON_DOWN, RI_MOUSE_RIGHT_BUTTON_DOWN, + RI_MOUSE_MIDDLE_BUTTON_DOWN, RI_MOUSE_BUTTON_4_DOWN, RI_MOUSE_BUTTON_5_DOWN}; + int signal = -1; + for (int button = 0; button < static_cast(std::size(downFlags)); ++button) + { + if (!(mouse.usButtonFlags & downFlags[button])) continue; + if (signal >= 0) return -1; + signal = button; + } + const auto wheel = mouse.usButtonFlags & (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL); + if (!wheel) return signal; + if (signal >= 0 || wheel == (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL)) return -1; + return mouseWheelEvent(wheel == RI_MOUSE_WHEEL ? WM_MOUSEWHEEL : WM_MOUSEHWHEEL, + static_cast(mouse.usButtonData)); +} + +inline int mouseWheelSteps(int &remainder, SHORT delta) +{ + const int total = remainder + std::abs(static_cast(delta)); + remainder = total % WHEEL_DELTA; + return total / WHEEL_DELTA; +} + +inline bool mouseEventEnabled(const Settings &settings, int event) +{ + return event >= 0 && event < static_cast(std::size(MouseEvents)) && + (event < BuiltInMouseEventCount || settings.customMouseEvents.contains(MouseEvents[event])); +} + +inline std::wstring mouseEventName(const Settings &settings, int event) +{ + auto name = settings.customMouseEvents.find(MouseEvents[event]); + return name == settings.customMouseEvents.end() ? MouseEvents[event] : name->second; +} inline constexpr const wchar_t *MouseActions[] = {L"Standard behavior", L"Send key combination", L"Back", L"Forward", L"Volume up", L"Volume down", L"Mute", L"Launch application"}; inline constexpr wchar_t GlobalMouseProfile[] = L"*"; @@ -67,6 +114,7 @@ inline const MouseAction *effectiveMouseAction(const Settings &settings, const s bool foreground = true, bool *profileScoped = nullptr) { if (profileScoped) *profileScoped = false; + if (!mouseEventEnabled(settings, event)) return nullptr; auto *action = mouseBinding(settings, app, event); if (!action) { diff --git a/src/storage.cpp b/src/storage.cpp index d20cd60..34ef6d8 100644 --- a/src/storage.cpp +++ b/src/storage.cpp @@ -108,6 +108,15 @@ static void validateMouseProfileConflicts(const Settings &s) void validateSettings(const Settings &s) { + std::set eventNames; + for (int event = 0; event < BuiltInMouseEventCount; ++event) eventNames.insert(lower(MouseEvents[event])); + for (const auto &[signal, name] : s.customMouseEvents) + { + if (std::find(std::begin(MouseEvents) + BuiltInMouseEventCount, std::end(MouseEvents), signal) == std::end(MouseEvents) || + name.empty() || name.size() > 80 || name.find_first_of(L"\r\n\t") != name.npos || + !eventNames.insert(lower(name)).second) + throw std::runtime_error("Enter a unique event name (up to 80 characters) for a supported signal."); + } if (s.mouseBindings.size() > 1000) throw std::runtime_error("Too many mouse profiles."); for (const auto &[app, bindings] : s.mouseBindings) { @@ -124,7 +133,8 @@ void validateSettings(const Settings &s) if (action.applications.size() > 1000 || !std::all_of(action.applications.begin(), action.applications.end(), validMouseApplication)) throw std::runtime_error("Enter valid target application executable names."); - if (std::find(std::begin(MouseEvents), std::end(MouseEvents), event) == std::end(MouseEvents) || + const auto signal = std::find(std::begin(MouseEvents), std::end(MouseEvents), event); + if (!mouseEventEnabled(s, static_cast(signal - std::begin(MouseEvents))) || static_cast(action.type) >= std::size(MouseActions)) throw std::runtime_error("Invalid mouse event or action."); if (isMouseVolumeStep(action.type) && (action.volumeStep < 1 || action.volumeStep > 100)) @@ -382,6 +392,13 @@ Settings decodeSettings(const Bytes &bytes, const std::vector &layouts) s.appLanguageSets[wide(name)] = std::move(set); } } + if (j.contains("customMouseEvents")) + { + const auto &events = j.at("customMouseEvents"); + if (!events.is_object()) throw std::runtime_error("Invalid custom mouse events."); + for (const auto &[signal, name] : events.items()) + s.customMouseEvents.emplace(wide(signal), wide(name.get())); + } if (j.contains("mouseBindings")) { const auto &profiles = j.at("mouseBindings"); @@ -520,6 +537,8 @@ Bytes encodeSettings(const Settings &s) for (const auto &id : set.languages) item["languages"].push_back(utf8(id)); for (const auto &app : set.applications) item["applications"].push_back(utf8(app)); } + j["customMouseEvents"] = Json::object(); + for (const auto &[signal, name] : s.customMouseEvents) j["customMouseEvents"][utf8(signal)] = utf8(name); j["mouseBindings"] = Json::object(); for (const auto &[app, bindings] : s.mouseBindings) { diff --git a/src/sync.cpp b/src/sync.cpp index a364f10..51cd4c6 100644 --- a/src/sync.cpp +++ b/src/sync.cpp @@ -216,7 +216,20 @@ std::unique_ptr makeSyncProvider(const SyncConfig &c, SyncTranspor { return std::make_unique(c, std::move(transport)); } -namespace { Json canonicalProfile(Json); } +namespace { +Json canonicalProfile(Json); +constexpr const char *PreviouslyLocalSettings[] = { + "startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract", + "historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock", + "appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications", "customMouseEvents" +}; + +void preserveMissingSettings(Json &profile, const Json &fallback) +{ + for (auto key : PreviouslyLocalSettings) + if (!profile.contains(key) && fallback.contains(key)) profile[key] = fallback.at(key); +} +} Bytes sharedSyncSettings(const Settings &s) { auto j = parse(encodeSettings(s)); @@ -226,14 +239,12 @@ Bytes sharedSyncSettings(const Settings &s) std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) { return a.at("layoutId").get() < b.at("layoutId").get(); }); - for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract", - "historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock", - "appLanguageSets", "mouseBindings", "disabledMouseProfiles", "mouseProfileApplications"}) j.erase(key); return bytes(canonicalProfile(j)); } Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector &layouts, bool keepLocalLayouts) { auto j = parse(shared), original = parse(encodeSettings(local)); + preserveMissingSettings(j, original); if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array(); if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false; if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false; @@ -245,7 +256,7 @@ Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const s if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1) throw std::runtime_error("Unsupported synchronized settings format."); for (auto it = j.begin(); it != j.end(); ++it) - if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields."); + if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported fields."); if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete."); std::set installed; for (const auto &layout : layouts) installed.insert(utf8(layout.id)); @@ -307,7 +318,9 @@ SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &l else { auto profile = parse(action == SyncAction::Download ? remote.settings : mergeLocal); - if (!profile.contains("chatCycles")) profile["chatCycles"] = parse(local).value("chatCycles", Json::object()); + const auto current = parse(local); + if (!profile.contains("chatCycles")) profile["chatCycles"] = current.value("chatCycles", Json::object()); + preserveMissingSettings(profile, current); auto selected = bytes(normalizeProfile(profile)); merged = {selected, selected, selected, {}}; } diff --git a/src/sync_merge.inl b/src/sync_merge.inl index cc45e13..870d7df 100644 --- a/src/sync_merge.inl +++ b/src/sync_merge.inl @@ -72,6 +72,14 @@ Json flatProfile(Json j) else ++profile; } } + if (j.contains("customMouseEvents")) + { + auto &events = j.at("customMouseEvents"); + for (auto event = events.begin(); event != events.end();) + if (event.value().is_null()) event = events.erase(event); + else ++event; + if (events.is_null()) j.erase("customMouseEvents"); + } Json snippets = Json::array(), deleted = Json::array(); for (const auto &[id, item] : j.at("snippets").items()) { @@ -215,6 +223,10 @@ SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &r // client's chat profiles until both copies understand this field. if (!remoteProfile.contains("chatCycles")) remoteProfile["chatCycles"] = localProfile.value("chatCycles", Json::object()); if (!localProfile.contains("chatCycles")) localProfile["chatCycles"] = remoteProfile.at("chatCycles"); + // Older server backups omit the newly synchronized fields. An explicit + // empty value from a current client still means the user cleared it. + preserveMissingSettings(remoteProfile, localProfile); + preserveMissingSettings(localProfile, remoteProfile); auto l = treeProfile(localProfile), r = treeProfile(remoteProfile); // With no common history, union independent entries; differing scalar values // remain on their respective devices until the user resolves the conflict. diff --git a/src/ui.cpp b/src/ui.cpp index 8e5eaa9..37057c9 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1766,7 +1766,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) switch (msg) { case WM_DRAWITEM: - if (wp == AddMouseProfile || wp == RenameMouseProfile || wp == DeleteMouseProfile) + if (wp == AddMouseProfile || wp == RenameMouseProfile || wp == DeleteMouseProfile || wp == AddMouseEvent) { drawActionApplicationIcon(w, *reinterpret_cast(lp)); return TRUE; @@ -1801,7 +1801,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) if (((NMHDR *)lp)->idFrom == 733 && ((NMHDR *)lp)->code == NM_DBLCLK) { const auto &click = *reinterpret_cast(lp); - showMouseActionEditor(w, *v, click.iItem); + showMouseActionEditor(w, *v, mouseEventAtRow(click.hdr.hwndFrom, click.iItem)); return 0; } if (((NMHDR *)lp)->idFrom == 260 && ((NMHDR *)lp)->code == TCN_SELCHANGE) diff --git a/src/ui_mouse_buttons.inl b/src/ui_mouse_buttons.inl index 854deed..bab20fe 100644 --- a/src/ui_mouse_buttons.inl +++ b/src/ui_mouse_buttons.inl @@ -5,6 +5,7 @@ std::wstring selectedMouseProfile(HWND w, const SettingsView &view) } constexpr int RenameMouseProfile = 757, DeleteMouseProfile = 758; +constexpr int AddMouseEvent = 762, MouseEventCapture = 763, MouseEventNameField = 764, MouseEventStatus = 765; std::wstring mouseActionLabel(const MouseAction &action) { @@ -14,11 +15,23 @@ std::wstring mouseActionLabel(const MouseAction &action) return MouseActions[static_cast(action.type)]; } +int mouseEventAtRow(HWND list, int row) +{ + if (row < 0) return -1; + LVITEMW item{LVIF_PARAM}; + item.iItem = row; + return ListView_GetItem(list, &item) ? static_cast(item.lParam) : -1; +} + void selectMouseEvent(HWND w, int event) { HWND list = GetDlgItem(w, 733); - ListView_SetItemState(list, event, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED); - ListView_EnsureVisible(list, event, FALSE); + LVFINDINFOW search{LVFI_PARAM}; + search.lParam = event; + const int row = ListView_FindItem(list, -1, &search); + if (row < 0) return; + ListView_SetItemState(list, row, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED); + ListView_EnsureVisible(list, row, FALSE); } void fillMouseEvents(HWND w, SettingsView &view, int selection = 0) @@ -27,8 +40,17 @@ void fillMouseEvents(HWND w, SettingsView &view, int selection = 0) EnableWindow(GetDlgItem(w, RenameMouseProfile), isNamedMouseProfile(profile)); EnableWindow(GetDlgItem(w, DeleteMouseProfile), isNamedMouseProfile(profile)); HWND list = GetDlgItem(w, 733); + ListView_DeleteAllItems(list); for (int event = 0; event < static_cast(std::size(MouseEvents)); ++event) { + if (!mouseEventEnabled(view.draft, event)) continue; + auto name = mouseEventName(view.draft, event); + LVITEMW item{LVIF_TEXT | LVIF_PARAM}; + item.iItem = ListView_GetItemCount(list); + item.lParam = event; + item.pszText = name.data(); + const int row = ListView_InsertItem(list, &item); + if (row < 0) continue; auto *action = mouseBinding(view.draft, profile, event); std::wstring text; if (profile != GlobalMouseProfile && !action) @@ -37,7 +59,7 @@ void fillMouseEvents(HWND w, SettingsView &view, int selection = 0) action = mouseBinding(view.draft, GlobalMouseProfile, event); } text += action ? mouseActionLabel(*action) : MouseActions[0]; - ListView_SetItemText(list, event, 1, text.data()); + ListView_SetItemText(list, row, 1, text.data()); } selectMouseEvent(w, selection); } @@ -97,6 +119,7 @@ bool blockMouseProfileStateChange(HWND w, SettingsView &view, const NMLISTVIEW & { Settings candidate; candidate.mouseBindings = view.draft.mouseBindings; + candidate.customMouseEvents = view.draft.customMouseEvents; candidate.disabledMouseProfiles = view.draft.disabledMouseProfiles; candidate.mouseProfileApplications = view.draft.mouseProfileApplications; candidate.disabledMouseProfiles.erase(profile); @@ -309,7 +332,7 @@ void drawActionApplicationIcon(HWND editor, const DRAWITEMSTRUCT &item) RECT horizontal{x - half, y - weight / 2, x + half, y - weight / 2 + weight}; FillRect(item.hDC, &horizontal, ink); } - if (item.CtlID == AddActionApp || item.CtlID == AddMouseProfile) + if (item.CtlID == AddActionApp || item.CtlID == AddMouseProfile || item.CtlID == AddMouseEvent) { RECT vertical{x - weight / 2, y - half, x - weight / 2 + weight, y + half}; FillRect(item.hDC, &vertical, ink); @@ -391,6 +414,7 @@ void saveMouseAction(HWND w, MouseActionEditor &editor) const auto *event = MouseEvents[editor.event]; Settings check; check.mouseBindings = editor.view->draft.mouseBindings; + check.customMouseEvents = editor.view->draft.customMouseEvents; check.disabledMouseProfiles = editor.view->draft.disabledMouseProfiles; check.mouseProfileApplications = editor.view->draft.mouseProfileApplications; if (type < 0 || (type == static_cast(MouseActionType::Standard) && profile == GlobalMouseProfile)) @@ -487,7 +511,7 @@ LRESULT CALLBACK mouseActionEditorProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) void showMouseActionEditor(HWND parent, SettingsView &view, int event) { - if (event < 0 || event >= static_cast(std::size(MouseEvents)) || !IsWindowEnabled(parent)) return; + if (!mouseEventEnabled(view.draft, event) || !IsWindowEnabled(parent)) return; const auto profile = selectedMouseProfile(parent, view); const bool application = profile != GlobalMouseProfile; const auto *action = mouseBinding(view.draft, profile, event); @@ -496,7 +520,7 @@ void showMouseActionEditor(HWND parent, SettingsView &view, int event) 480, MouseActionEditorHeight, 0, false, parent); auto *editor = new MouseActionEditor{parent, &view, profile, event}; SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast(editor)); - label(w, std::wstring(MouseEvents[event]).append(L" · ").append(mouseProfileName(profile)).c_str(), + label(w, mouseEventName(view.draft, event).append(L" · ").append(mouseProfileName(profile)).c_str(), 18, 14, 444); label(w, L"&Action", 18, 43, 330); windowLayoutState(w)->fixedClient = true; @@ -578,7 +602,7 @@ void showMouseListMenu(HWND list, POINT point, bool profiles) hit.pt = point; ScreenToClient(list, &hit.pt); event = ListView_SubItemHitTest(list, &hit); - if (event < 0) return; + if (event < 0 && profiles) return; } auto &view = *reinterpret_cast(GetWindowLongPtrW(w, GWLP_USERDATA)); HMENU menu = CreatePopupMenu(); @@ -592,9 +616,11 @@ void showMouseListMenu(HWND list, POINT point, bool profiles) } else { - selectMouseEvent(w, event); - const bool canReset = mouseBinding(view.draft, selectedMouseProfile(w, view), event) != nullptr; - AppendMenuW(menu, MF_STRING, EditMouseEvent, L"&Edit…"); + event = mouseEventAtRow(list, event); + if (event >= 0) selectMouseEvent(w, event); + const bool canReset = event >= 0 && mouseBinding(view.draft, selectedMouseProfile(w, view), event) != nullptr; + AppendMenuW(menu, MF_STRING, AddMouseEvent, L"+ &Add mouse event…"); + AppendMenuW(menu, MF_STRING | (event >= 0 ? MF_ENABLED : MF_GRAYED), EditMouseEvent, L"✎ &Edit…"); AppendMenuW(menu, MF_STRING | (canReset ? MF_ENABLED : MF_GRAYED), ResetMouseEvent, L"&Reset to default"); } const auto command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_NONOTIFY | TPM_RIGHTBUTTON, @@ -687,6 +713,7 @@ LRESULT CALLBACK mouseProfileNameProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) const auto key = std::wstring(NamedMouseProfilePrefix) + name; Settings check; check.mouseBindings = view.draft.mouseBindings; + check.customMouseEvents = view.draft.customMouseEvents; check.disabledMouseProfiles = view.draft.disabledMouseProfiles; check.mouseProfileApplications = view.draft.mouseProfileApplications; if (!previous.empty() && key != previous) @@ -768,9 +795,204 @@ void showMouseProfileName(HWND owner, SettingsView &view, bool renaming) SetFocus(GetDlgItem(w, MouseProfileName)); } +struct AddMouseEventView +{ + HWND parent{}; + SettingsView *settings{}; + int signal{-1}, lastButton{-1}; + DWORD lastPress{}; + POINT lastPoint{}; + bool rawRegistered{}; + HHOOK mouseHook{}; + int rawWheel{-1}, hookWheel{-1}; + ULONGLONG rawWheelAt{}, hookWheelAt{}; +}; + +constexpr UINT CapturedMouseWheel = WM_APP + 86; +constexpr UINT_PTR MouseSignalTimeout = 1; +HWND mouseEventCaptureWindow{}; + +bool inMouseEventCapture(HWND w, POINT point) +{ + RECT area{}; + return GetWindowRect(GetDlgItem(w, MouseEventCapture), &area) && PtInRect(&area, point); +} + +LRESULT CALLBACK captureMouseWheelProc(int code, WPARAM wp, LPARAM lp) +{ + if (code == HC_ACTION && mouseEventCaptureWindow && GetForegroundWindow() == mouseEventCaptureWindow) + { + const auto &mouse = *reinterpret_cast(lp); + const int signal = mouseWheelEvent(static_cast(wp), static_cast(HIWORD(mouse.mouseData))); + if (signal >= 0 && !(mouse.flags & LLMHF_INJECTED) && inMouseEventCapture(mouseEventCaptureWindow, mouse.pt)) + PostMessageW(mouseEventCaptureWindow, CapturedMouseWheel, signal, 0); + } + return CallNextHookEx(nullptr, code, wp, lp); +} + +void showCapturedMouseSignal(HWND w, AddMouseEventView &view, int signal) +{ + view.signal = signal; + const auto &draft = view.settings->draft; + const bool duplicate = mouseEventEnabled(draft, signal); + EnableWindow(GetDlgItem(w, IDOK), !duplicate); + if (duplicate) + { + selectMouseEvent(view.parent, signal); + value(w, MouseEventStatus, L"Already in the list: " + mouseEventName(draft, signal)); + value(w, IDCANCEL, L"Close"); + } + else + { + value(w, MouseEventStatus, std::wstring(L"Detected: ") + MouseEvents[signal]); + if (value(w, MouseEventNameField).empty()) value(w, MouseEventNameField, MouseEvents[signal]); + value(w, IDCANCEL, L"Cancel"); + } +} + +void matchCapturedMouseWheel(HWND w, AddMouseEventView &view, ULONGLONG now) +{ + if (view.rawWheel >= 0 && view.rawWheel == view.hookWheel && + now - view.rawWheelAt <= 500 && now - view.hookWheelAt <= 500) + { + showCapturedMouseSignal(w, view, view.rawWheel); + view.rawWheel = view.hookWheel = -1; + KillTimer(w, MouseSignalTimeout); + } +} + +void readNewMouseSignal(HWND w, AddMouseEventView &view, HRAWINPUT handle) +{ + POINT point{}; + if (!GetCursorPos(&point) || !inMouseEventCapture(w, point)) return; + RAWINPUT input{}; + UINT size = sizeof(input); + const auto bytes = GetRawInputData(handle, RID_INPUT, &input, &size, sizeof(RAWINPUTHEADER)); + if (bytes == UINT(-1) || bytes < offsetof(RAWINPUT, data) + sizeof(RAWMOUSE) || + input.header.dwType != RIM_TYPEMOUSE || !input.header.hDevice) + return; + int signal = rawMouseSignal(input.data.mouse); + if (signal < 0) return; + if (signal >= BuiltInMouseEventCount) + { + view.signal = -1; + EnableWindow(GetDlgItem(w, IDOK), FALSE); + value(w, MouseEventStatus, view.mouseHook ? L"Checking whether Windows delivers this wheel signal…" : + L"Windows mouse hook is unavailable."); + view.rawWheel = signal; + view.rawWheelAt = GetTickCount64(); + if (view.mouseHook) SetTimer(w, MouseSignalTimeout, 550, nullptr); + matchCapturedMouseWheel(w, view, view.rawWheelAt); + return; + } + view.rawWheel = view.hookWheel = -1; + KillTimer(w, MouseSignalTimeout); + const DWORD now = static_cast(GetMessageTime()); + const bool second = signal < 3 && view.lastButton == signal && + doubleMouseClick(view.lastPoint, point, now - view.lastPress); + view.lastButton = second ? -1 : signal; + view.lastPress = now; + view.lastPoint = point; + if (second) signal += 5; + showCapturedMouseSignal(w, view, signal); +} + +LRESULT CALLBACK addMouseEventProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) +{ + auto *view = reinterpret_cast(GetWindowLongPtrW(w, GWLP_USERDATA)); + if (!view) return DefWindowProcW(w, msg, wp, lp); + try + { + if (msg == WM_INPUT) + readNewMouseSignal(w, *view, reinterpret_cast(lp)); + else if (msg == CapturedMouseWheel) + { + view->hookWheel = static_cast(wp); + view->hookWheelAt = GetTickCount64(); + matchCapturedMouseWheel(w, *view, view->hookWheelAt); + return 0; + } + else if (msg == WM_TIMER && wp == MouseSignalTimeout) + { + KillTimer(w, MouseSignalTimeout); + view->rawWheel = view->hookWheel = -1; + value(w, MouseEventStatus, L"Raw Input saw this signal, but the Windows mouse hook did not."); + return 0; + } + else if (msg == WM_COMMAND) + { + if (LOWORD(wp) == IDOK) + { + const int signal = view->signal; + auto &settings = *view->settings; + if (signal < BuiltInMouseEventCount || signal >= static_cast(std::size(MouseEvents)) || + mouseEventEnabled(settings.draft, signal)) return 0; + auto candidate = settings.draft; + candidate.customMouseEvents.emplace(MouseEvents[signal], trimApplicationText(value(w, MouseEventNameField))); + validateSettings(candidate); + settings.draft.customMouseEvents = std::move(candidate.customMouseEvents); + fillMouseEvents(view->parent, settings, signal); + DestroyWindow(w); + return 0; + } + if (LOWORD(wp) == IDCANCEL) { DestroyWindow(w); return 0; } + } + else if (msg == WM_CLOSE) { DestroyWindow(w); return 0; } + else if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; } + else if (msg == WM_NCDESTROY) + { + const HWND parent = view->parent; + mouseEventCaptureWindow = nullptr; + if (view->mouseHook) UnhookWindowsHookEx(view->mouseHook); + if (view->rawRegistered) + { + if (IsWindow(parent) && view->settings->rawMouseRegistered) registerMouseTestInput(parent); + else registerMouseTestInput(nullptr); + } + SetWindowLongPtrW(w, GWLP_USERDATA, 0); + delete view; + disposeFont(w); + if (IsWindow(parent)) + { + EnableWindow(parent, TRUE); + SetForegroundWindow(parent); + SetFocus(GetDlgItem(parent, 733)); + } + } + } + catch (const std::exception &e) { error(w, e); } + return DefWindowProcW(w, msg, wp, lp); +} + +void showAddMouseEvent(HWND parent, SettingsView &settings) +{ + if (!IsWindowEnabled(parent)) return; + HWND w = makeWindow(*settings.app, L"KSwitcher.AddMouseEvent", L"Add mouse event", addMouseEventProc, 500, 275); + auto *view = new AddMouseEventView{parent, &settings}; + SetWindowLongPtrW(w, GWLP_HWNDPARENT, reinterpret_cast(parent)); + SetWindowLongPtrW(w, GWLP_USERDATA, reinterpret_cast(view)); + label(w, L"&Name", 18, 16, 464); + edit(w, MouseEventNameField, 18, 39, 464); + control(w, L"STATIC", L"Press a mouse button or move the wheel here", + WS_BORDER | SS_CENTER | SS_CENTERIMAGE, MouseEventCapture, 18, 78, 464, 92); + view->rawRegistered = registerMouseTestInput(w); + mouseEventCaptureWindow = w; + view->mouseHook = SetWindowsHookExW(WH_MOUSE_LL, captureMouseWheelProc, settings.app->instance, 0); + control(w, L"STATIC", view->rawRegistered ? L"Waiting for a supported mouse signal…" : L"Windows Raw Input is unavailable.", + 0, MouseEventStatus, 18, 181, 464, 22); + label(w, L"Buttons that send no Windows mouse event cannot be added.", 18, 205, 464); + control(w, L"BUTTON", L"&Add", WS_TABSTOP | BS_DEFPUSHBUTTON, IDOK, 270, 239, 100, 28); + button(w, L"Cancel", IDCANCEL, 382, 239, 100); + EnableWindow(GetDlgItem(w, IDOK), FALSE); + EnableWindow(parent, FALSE); + showRequestedWindow(w); + SetFocus(GetDlgItem(w, MouseEventNameField)); +} + bool handleMouseBindings(HWND w, SettingsView &view, WPARAM wp) { const int id = LOWORD(wp); + if (id == AddMouseEvent) { showAddMouseEvent(w, view); return true; } if (id == AddMouseProfile) { showMouseProfileName(w, view, false); return true; } if (id == RenameMouseProfile || id == DeleteMouseProfile) { @@ -788,7 +1010,8 @@ bool handleMouseBindings(HWND w, SettingsView &view, WPARAM wp) return true; } if (id != EditMouseEvent && id != ResetMouseEvent) return false; - const int event = ListView_GetNextItem(GetDlgItem(w, 733), -1, LVNI_SELECTED); + const HWND list = GetDlgItem(w, 733); + const int event = mouseEventAtRow(list, ListView_GetNextItem(list, -1, LVNI_SELECTED)); if (event < 0) return true; if (id == EditMouseEvent) showMouseActionEditor(w, view, event); else if (auto profile = view.draft.mouseBindings.find(selectedMouseProfile(w, view)); @@ -817,7 +1040,9 @@ void createMousePage(HWND w, SettingsView &view) constexpr int profilesLeft = 18, actionsLeft = 369, tableWidth = 333; constexpr int profilesRight = profilesLeft + tableWidth; label(w, L"Profiles", 18, 64, 120); - label(w, L"Mouse events", actionsLeft, 64, tableWidth); + label(w, L"Mouse events", actionsLeft, 64, tableWidth - 30); + control(w, L"BUTTON", L"Add mouse event", WS_TABSTOP | BS_OWNERDRAW, AddMouseEvent, + actionsLeft + tableWidth - 24, 61, 24, 24); control(w, L"BUTTON", L"Delete profile", WS_TABSTOP | BS_OWNERDRAW, DeleteMouseProfile, profilesRight - 80, 61, 24, 24); control(w, L"BUTTON", L"Edit profile", WS_TABSTOP | BS_OWNERDRAW, RenameMouseProfile, profilesRight - 52, 61, 24, 24); control(w, L"BUTTON", L"Add profile", WS_TABSTOP | BS_OWNERDRAW, AddMouseProfile, profilesRight - 24, 61, 24, 24); @@ -827,6 +1052,7 @@ void createMousePage(HWND w, SettingsView &view) addActionApplicationTooltip(tips, w, AddMouseProfile, L"Add profile"); addActionApplicationTooltip(tips, w, RenameMouseProfile, L"Edit profile"); addActionApplicationTooltip(tips, w, DeleteMouseProfile, L"Delete profile"); + addActionApplicationTooltip(tips, w, AddMouseEvent, L"Add mouse event"); constexpr int listTop = 90, listHeight = 224; INITCOMMONCONTROLSEX controls{sizeof(controls), ICC_LISTVIEW_CLASSES}; InitCommonControlsEx(&controls); @@ -859,13 +1085,6 @@ void createMousePage(HWND w, SettingsView &view) column.pszText = actionTitle; column.cx = scale(w, tableWidth - 120); ListView_InsertColumn(list, 1, &column); - for (int event = 0; event < static_cast(std::size(MouseEvents)); ++event) - { - LVITEMW item{LVIF_TEXT}; - item.iItem = event; - item.pszText = const_cast(MouseEvents[event]); - ListView_InsertItem(list, &item); - } control(w, L"STATIC", MouseEventHint, 0, 740, actionsLeft, 324, tableWidth, 22); control(w, L"BUTTON", L"Test mouse buttons here", WS_TABSTOP | BS_PUSHBUTTON, 737, profilesLeft, listTop + listHeight + 10, tableWidth, 56); diff --git a/src/update.h b/src/update.h index bb1c3d0..88af764 100644 --- a/src/update.h +++ b/src/update.h @@ -2,7 +2,7 @@ #include "platform.h" namespace ks { -inline constexpr wchar_t AppVersion[] = L"0.6.64"; +inline constexpr wchar_t AppVersion[] = L"0.6.65"; inline constexpr UINT UpdateMessage = WM_APP + 7; inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000; inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000; diff --git a/tests/input.cpp b/tests/input.cpp index 62ae988..cb6f9ce 100644 --- a/tests/input.cpp +++ b/tests/input.cpp @@ -125,6 +125,27 @@ struct InputRegressionAccess { return a.notifiedUpdate; } static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); } + static bool wheelQueuesAction(App &app) + { + MSLLHOOKSTRUCT mouse{}; + if (!GetCursorPos(&mouse.pt) || !WindowFromPoint(mouse.pt)) return false; + auto previous = app.settings; + app.settings.customMouseEvents[L"Wheel down"] = L"Wheel test"; + app.settings.mouseBindings[GlobalMouseProfile][L"Wheel down"] = {MouseActionType::Mute}; + mouse.mouseData = MAKEWPARAM(0, static_cast(-30)); + const auto wheel = [&] { return App::mouseProc(HC_ACTION, WM_MOUSEWHEEL, reinterpret_cast(&mouse)); }; + mouse.flags = LLMHF_INJECTED; + wheel(); + const bool ignoredInjection = app.mouseActions.empty(); + mouse.flags = 0; + const bool partial = wheel() == 1 && app.mouseActions.empty(); + mouse.mouseData = MAKEWPARAM(0, static_cast(-90)); + const bool queued = wheel() == 1 && app.mouseActions.size() == 1 && + app.mouseActions.front().action.type == MouseActionType::Mute; + app.mouseActions.clear(); + app.settings = std::move(previous); + return ignoredInjection && partial && queued; + } static bool volumeHoldStopsOnRelease(App &app) { constexpr int button = 3; @@ -203,6 +224,8 @@ int main(int argc, char **argv) { require(app.window!=nullptr,"message-only test window"); require(ks::InputRegressionAccess::volumeHoldStopsOnRelease(app), "volume hold repeats after its delay and stops on release without an extra step"); + require(ks::InputRegressionAccess::wheelQueuesAction(app), + "a registered physical wheel signal queues one action per detent"); require(ks::mouseVolumeRepeatInterval(350) > ks::mouseVolumeRepeatInterval(1000) && ks::mouseVolumeRepeatInterval(1000) > ks::mouseVolumeRepeatInterval(2000) && !ks::isMouseVolumeStep(ks::MouseActionType::Mute), @@ -828,6 +851,14 @@ int main(int argc, char **argv) { require(!IsWindowEnabled(GetDlgItem(settings, IDOK)), "button detection is not a settings edit"); require(ListView_GetItemCount(events) == 8 && Header_GetItemCount(ListView_GetHeader(events)) == 2, "mouse events have eight rows and two columns"); + SendMessageW(settings, WM_COMMAND, 762, 0); + const auto addEvent = FindWindowW(L"KSwitcher.AddMouseEvent", nullptr); + require(addEvent && GetDlgItem(addEvent, 764) && GetDlgItem(addEvent, 763) && + !IsWindowEnabled(GetDlgItem(addEvent, IDOK)), "Add mouse event waits for a physical signal"); + SendMessageW(addEvent, WM_COMMAND, IDOK, 0); + require(IsWindow(addEvent) && ListView_GetItemCount(events) == 8, + "the event editor cannot save an uncaptured signal"); + SendMessageW(addEvent, WM_COMMAND, IDCANCEL, 0); NMITEMACTIVATE click{}; click.hdr = {events, 733, NM_DBLCLK}; click.iItem = 7; diff --git a/tests/settings_merge.cpp b/tests/settings_merge.cpp index 6585220..ee2a09b 100644 --- a/tests/settings_merge.cpp +++ b/tests/settings_merge.cpp @@ -27,9 +27,9 @@ int main(){try{ if (merged.mouseBindings.at(L"chrome").size() != 2) throw std::runtime_error("independent mouse bindings lost"); auto shared = sharedSyncSettings(merged); - if (shared != sharedSyncSettings(base) || - restoreSyncSettings(shared, merged, {}, false).mouseBindings != merged.mouseBindings) - throw std::runtime_error("mouse profiles must stay local during sync"); + if (shared == sharedSyncSettings(base) || + restoreSyncSettings(shared, Settings{}, {}, false).mouseBindings != merged.mouseBindings) + throw std::runtime_error("mouse profiles must restore from a synchronized backup"); auto withButtons = merged; draft = current = withButtons; draft.mouseBindings.at(L"chrome").erase(L"XButton1"); @@ -100,5 +100,38 @@ int main(){try{ try { validateSettings(profiles); } catch (const std::runtime_error &e) { rejected = std::string(e.what()).starts_with("Mouse action conflict:"); } if (!rejected) throw std::runtime_error("overlapping active profiles must be rejected"); + Settings wheel; + const int down = mouseWheelEvent(WM_MOUSEWHEEL, -WHEEL_DELTA); + RAWMOUSE raw{}; + raw.usButtonFlags = RI_MOUSE_WHEEL; + raw.usButtonData = static_cast(-WHEEL_DELTA); + if (rawMouseSignal(raw) != down || mouseEventEnabled(wheel, down)) + throw std::runtime_error("wheel capture must match the runtime signal and be opt-in"); + raw.usButtonFlags = RI_MOUSE_HWHEEL; + if (rawMouseSignal(raw) != mouseWheelEvent(WM_MOUSEHWHEEL, -WHEEL_DELTA)) + throw std::runtime_error("horizontal wheel capture must match the runtime signal"); + int remainder{}; + if (mouseWheelSteps(remainder, -30) != 0 || mouseWheelSteps(remainder, -90) != 1 || + mouseWheelSteps(remainder, 240) != 2 || remainder != 0) + throw std::runtime_error("high-resolution wheel deltas must accumulate by detent"); + wheel.customMouseEvents.emplace(MouseEvents[down], L"Lower volume"); + wheel.mouseBindings[GlobalMouseProfile][MouseEvents[down]] = {MouseActionType::VolumeDown}; + wheel.mouseBindings[L"name:Browser"][L"XButton1"] = {MouseActionType::Back}; + wheel.mouseProfileApplications[L"name:Browser"] = {L"chrome"}; + wheel.disabledMouseProfiles.insert(L"name:Browser"); + wheel.appLanguageSets[L"Browser"] = {{L"win:00000409"}, {L"chrome"}}; + const auto restoredWheel = decodeSettings(encodeSettings(wheel), {}); + const auto syncedWheel = restoreSyncSettings(sharedSyncSettings(wheel), Settings{}, {}); + if (mouseEventName(restoredWheel, down) != L"Lower volume" || + !effectiveMouseAction(restoredWheel, L"explorer", down) || + sharedSyncSettings(syncedWheel) != sharedSyncSettings(wheel)) + throw std::runtime_error("custom wheel event and action must restore from a synchronized backup"); + if (!restoreSyncSettings(sharedSyncSettings(Settings{}), wheel, {}).customMouseEvents.empty()) + throw std::runtime_error("an explicit empty backup must clear custom mouse events"); + wheel.customMouseEvents.clear(); + rejected = false; + try { validateSettings(wheel); } + catch (const std::runtime_error &) { rejected = true; } + if (!rejected) throw std::runtime_error("unregistered mouse signals cannot have bindings"); std::cout<<"Settings-save merge checks passed\n"; }catch(const std::exception &e){std::cerr<