feat: release kswitcher 0.6.64

Add mouse button actions and application profiles, with volume controls and per-monitor settings layout fixes.
This commit is contained in:
agrynco committed 2026-11-12 00:39:12 +02:00
1 parent 5a2106899d
commit 14f33012a3
23 files changed
+2233 -107

No files matched your search

+224
View File
@@ -125,6 +125,26 @@ struct InputRegressionAccess {
return a.notifiedUpdate;
}
static void click() { MSLLHOOKSTRUCT m{}; m.flags=LLMHF_INJECTED; App::mouseProc(HC_ACTION,WM_LBUTTONDOWN,(LPARAM)&m); }
static bool volumeHoldStopsOnRelease(App &app)
{
constexpr int button = 3;
auto &press = app.mousePresses[button];
press = {app.window, {}, GetTickCount64(), {MouseActionType::VolumeUp}, true, false};
app.beginMouseVolume(button);
const bool onDown = app.mouseActions.size() == 1 && app.volumeButtonHeld();
app.repeatMouseVolume(button);
const bool initialDelay = app.mouseActions.size() == 1;
press.started -= 2100;
app.repeatMouseVolume(button);
const bool repeated = app.mouseActions.size() == 2;
MSLLHOOKSTRUCT mouse{};
mouse.mouseData = MAKEWPARAM(0, XBUTTON1);
const bool consumed = App::mouseProc(HC_ACTION, WM_XBUTTONUP, reinterpret_cast<LPARAM>(&mouse)) == 1;
app.repeatMouseVolume(button);
const bool stopped = !app.volumeButtonHeld() && app.mouseActions.size() == 2;
app.mouseActions.clear();
return onDown && initialDelay && repeated && consumed && stopped;
}
};
}
int main(int argc, char **argv) {
@@ -181,6 +201,12 @@ int main(int argc, char **argv) {
// Message-only window: no hooks, synthetic input, clipboard changes or visible editor.
app.window=CreateWindowExW(0,L"STATIC",L"Input tests",0,0,0,0,0,HWND_MESSAGE,nullptr,nullptr,nullptr);
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::mouseVolumeRepeatInterval(350) > ks::mouseVolumeRepeatInterval(1000) &&
ks::mouseVolumeRepeatInterval(1000) > ks::mouseVolumeRepeatInterval(2000) &&
!ks::isMouseVolumeStep(ks::MouseActionType::Mute),
"volume repeat accelerates and excludes mute");
require(!ks::confirmUpdatedStartup(L""),"normal startup does not report update success");
require(!ks::confirmUpdatedStartup(L"Local.Other.Event"),"unrelated event cannot report update success");
auto startedName=L"Local\\KSwitcher.Update.Started.Test."+std::to_wstring(GetCurrentProcessId());
@@ -752,6 +778,204 @@ int main(int argc, char **argv) {
fitted=ks::fitWindowRect({2000,2000,2600,2400},{-1920,0,0,1080});
require(fitted.left==-600&&fitted.top==680&&fitted.right==0,"negative-coordinate monitor supported");
ks::showSettings(app,false);
{
auto settings = app.settingsWindow;
auto tabs = GetDlgItem(settings, 260);
auto capture = GetDlgItem(settings, 737);
auto events = GetDlgItem(settings, 733);
require(!(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE),
"mouse detection is hidden on General");
TabCtrl_SetCurSel(tabs, 5);
NMHDR changed{tabs, 260, TCN_SELCHANGE};
SendMessageW(settings, WM_NOTIFY, 260, reinterpret_cast<LPARAM>(&changed));
require(GetWindowLongPtrW(capture, GWL_STYLE) & WS_VISIBLE,
"mouse detection is visible on Mouse");
RECT suggestedSettings{};
GetWindowRect(settings, &suggestedSettings);
suggestedSettings.right -= 12;
suggestedSettings.bottom -= 12;
const UINT settingsDpi = GetDpiForWindow(settings);
SendMessageW(settings, WM_DPICHANGED, MAKEWPARAM(settingsDpi, settingsDpi),
reinterpret_cast<LPARAM>(&suggestedSettings));
require(!(GetWindowLongPtrW(settings, GWL_STYLE) & (WS_HSCROLL | WS_VSCROLL)) &&
!(GetWindowLongPtrW(events, GWL_STYLE) & WS_HSCROLL),
"a DPI resize keeps both settings and the action table free of unnecessary scrollbars");
RECT profilesBounds{}, eventsBounds{};
require(GetWindowRect(GetDlgItem(settings, 731), &profilesBounds) &&
GetWindowRect(events, &eventsBounds) &&
profilesBounds.top == eventsBounds.top && profilesBounds.bottom == eventsBounds.bottom &&
profilesBounds.right - profilesBounds.left == eventsBounds.right - eventsBounds.left,
"mouse profile and action tables have equal dimensions and aligned edges");
SendMessageW(events, WM_KEYDOWN, VK_RETURN, 0);
auto editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && SendDlgItemMessageW(editor, 734, CB_GETCOUNT, 0, 0) == 8,
"Global actions do not include inheritance");
SendMessageW(editor, WM_COMMAND, IDCANCEL, 0);
require(!GetDlgItem(settings, 741) && !GetDlgItem(settings, 732) && !GetDlgItem(settings, 745),
"applications are chosen in the action editor only");
for (auto message : {WM_XBUTTONDOWN, WM_XBUTTONUP, WM_XBUTTONDBLCLK, WM_XBUTTONUP})
SendMessageW(capture, message, MAKEWPARAM(0, XBUTTON2), 0);
require(ListView_GetNextItem(events, -1, LVNI_SELECTED) == 4,
"mouse button capture selects the second side button");
wchar_t detected[100]{};
GetDlgItemTextW(settings, 738, detected, 100);
require(std::wstring(detected) == L"Window event: XButton2 | Presses: 2",
"detection shows the last button and counts repeated presses");
SendMessageW(capture, WM_MBUTTONDBLCLK, 0, 0);
SendMessageW(capture, WM_MBUTTONUP, 0, 0);
require(ListView_GetNextItem(events, -1, LVNI_SELECTED) == 7,
"a double middle click selects its own event");
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");
NMITEMACTIVATE click{};
click.hdr = {events, 733, NM_DBLCLK};
click.iItem = 7;
SendMessageW(settings, WM_NOTIFY, 733, reinterpret_cast<LPARAM>(&click));
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && !IsWindowEnabled(settings) && SendDlgItemMessageW(editor, 734, CB_GETCOUNT, 0, 0) == 8,
"double-click opens the Global action editor");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 1, 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
SendDlgItemMessageW(editor, 747, ks::CaptureHotkeyMessage, VK_TAB, MOD_CONTROL);
require(!GetDlgItem(editor, 748), "the action editor has no application list");
SendMessageW(editor, WM_COMMAND, IDOK, 0);
const auto profiles = GetDlgItem(settings, 731);
require(ListView_GetItemCount(profiles) == 1 &&
IsWindowEnabled(GetDlgItem(settings, IDOK)), "adding a mouse binding enables Save");
wchar_t action[80]{};
ListView_GetItemText(events, 7, 1, action, 80);
require(std::wstring(action) == L"Ctrl+Tab", "the Action column reflects the selected event's binding");
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor != nullptr, "F2 opens the selected mouse action");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 0, 0);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
require(!IsWindowEnabled(GetDlgItem(settings, IDOK)),
"removing the draft mouse binding clears pending changes");
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor != nullptr, "opens the action editor to configure volume");
RECT fullCombo{}, compactHeading{};
GetWindowRect(GetDlgItem(editor, 734), &fullCombo);
GetWindowRect(GetDlgItem(editor, IDOK), &compactHeading);
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::VolumeUp), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
require(IsWindowVisible(GetDlgItem(editor, 759)) &&
GetDlgItemInt(editor, 759, nullptr, FALSE) == ks::DefaultMouseVolumeStep,
"volume actions show the default step");
RECT volumeCombo{}, volumeField{}, volumeSpin{}, volumeHeading{};
GetWindowRect(GetDlgItem(editor, 734), &volumeCombo);
GetWindowRect(GetDlgItem(editor, 759), &volumeField);
GetWindowRect(GetDlgItem(editor, 760), &volumeSpin);
GetWindowRect(GetDlgItem(editor, IDOK), &volumeHeading);
int minimum{}, maximum{};
SendDlgItemMessageW(editor, 760, UDM_GETRANGE32,
reinterpret_cast<WPARAM>(&minimum), reinterpret_cast<LPARAM>(&maximum));
require(IsWindowVisible(GetDlgItem(editor, 760)) && minimum == 1 && maximum == 50 &&
volumeCombo.top == volumeField.top && volumeCombo.right < volumeField.left &&
volumeSpin.top == volumeField.top && volumeSpin.bottom == volumeField.bottom &&
std::abs(volumeCombo.bottom - volumeField.bottom) <= 1 &&
volumeCombo.right < fullCombo.right && volumeHeading.top == compactHeading.top,
"volume step spinner is beside the shortened action dropdown");
RECT suggested{};
GetWindowRect(editor, &suggested);
suggested.right -= 12;
suggested.bottom -= 12;
const UINT editorDpi = GetDpiForWindow(editor);
SendMessageW(editor, WM_DPICHANGED, MAKEWPARAM(editorDpi, editorDpi),
reinterpret_cast<LPARAM>(&suggested));
GetWindowRect(GetDlgItem(editor, 734), &volumeCombo);
GetWindowRect(GetDlgItem(editor, 759), &volumeField);
GetWindowRect(GetDlgItem(editor, 760), &volumeSpin);
require(!(GetWindowLongPtrW(editor, GWL_STYLE) & (WS_HSCROLL | WS_VSCROLL)) &&
volumeCombo.top == volumeField.top && volumeCombo.bottom == volumeField.bottom &&
volumeSpin.top == volumeField.top && volumeSpin.bottom == volumeField.bottom,
"a DPI resize keeps the action row aligned and restores the full client area");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::Back), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
RECT backCombo{}, backHeading{};
GetWindowRect(GetDlgItem(editor, 734), &backCombo);
GetWindowRect(GetDlgItem(editor, IDOK), &backHeading);
require(EqualRect(&fullCombo, &backCombo) && EqualRect(&compactHeading, &backHeading) &&
!IsWindowVisible(GetDlgItem(editor, 760)), "back restores full width without an unused row");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, static_cast<int>(ks::MouseActionType::VolumeUp), 0);
SendMessageW(editor, WM_COMMAND, MAKEWPARAM(734, CBN_SELCHANGE), 0);
SetDlgItemInt(editor, 759, 7, FALSE);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
SendMessageW(events, WM_KEYDOWN, VK_F2, 0);
editor = FindWindowW(L"KSwitcher.MouseAction", nullptr);
require(editor && GetDlgItemInt(editor, 759, nullptr, FALSE) == 7,
"reopening a volume action restores the configured step");
SendDlgItemMessageW(editor, 734, CB_SETCURSEL, 0, 0);
SendMessageW(editor, WM_COMMAND, IDOK, 0);
SendMessageW(settings, WM_COMMAND, 754, 0);
auto profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog && GetDlgItem(profileDialog, 755), "add profile opens the Name dialog");
SetDlgItemTextW(profileDialog, 755, L"Browsing");
for (const auto *name : {L"chrome.exe", L"msedge.exe"})
{
SetDlgItemTextW(profileDialog, 749, name);
SendMessageW(profileDialog, WM_COMMAND, 750, 0);
}
SendMessageW(profileDialog, WM_COMMAND, IDOK, 0);
wchar_t applications[80]{};
ListView_GetItemText(profiles, 1, 2, applications, 80);
require(std::wstring(applications) == L"chrome.exe, msedge.exe",
"applications are visible in the profile table");
require(ListView_GetNextItem(profiles, -1, LVNI_SELECTED) == 1 &&
IsWindowEnabled(GetDlgItem(settings, IDOK)), "new profile is selected and enables Save");
RECT profileRow{};
require(ListView_GetItemRect(profiles, 1, &profileRow, LVIR_BOUNDS),
"profile row bounds are available");
const int nameWidth = ListView_GetColumnWidth(profiles, 1);
const int rowCenter = (profileRow.top + profileRow.bottom) / 2;
int checkboxX = -1;
for (int x = nameWidth; x < profileRow.right; ++x)
{
LVHITTESTINFO hit{};
hit.pt = {x, rowCenter};
if (ListView_HitTest(profiles, &hit) == 1 && (hit.flags & LVHT_ONITEMSTATEICON))
{
checkboxX = x;
break;
}
}
require(checkboxX >= nameWidth && checkboxX < nameWidth + ListView_GetColumnWidth(profiles, 0) &&
ListView_GetCheckState(profiles, 1),
"the enabled checkbox is hit-testable in the second column");
SendMessageW(profiles, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(checkboxX, rowCenter));
SendMessageW(profiles, WM_LBUTTONUP, 0, MAKELPARAM(checkboxX, rowCenter));
require(!ListView_GetCheckState(profiles, 1), "clicking the row checkbox disables its profile");
ListView_SetItemState(profiles, 0, LVIS_SELECTED, LVIS_SELECTED);
SendMessageW(profiles, WM_LBUTTONDBLCLK, MK_LBUTTON, MAKELPARAM(nameWidth / 2, rowCenter));
profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog != nullptr, "double-clicking a profile name opens the clicked row");
SendMessageW(profileDialog, WM_COMMAND, IDCANCEL, 0);
const int applicationsX = nameWidth + ListView_GetColumnWidth(profiles, 0) + 4;
SendMessageW(profiles, WM_LBUTTONDBLCLK, MK_LBUTTON, MAKELPARAM(applicationsX, rowCenter));
profileDialog = FindWindowW(L"KSwitcher.MouseProfileName", nullptr);
require(profileDialog && SendDlgItemMessageW(profileDialog, 748, LB_GETCOUNT, 0, 0) == 2,
"double-click opens the profile editor with its applications");
SetDlgItemTextW(profileDialog, 755, L"Work browsing");
SendDlgItemMessageW(profileDialog, 748, LB_SETCURSEL, 0, 0);
SendMessageW(profileDialog, WM_COMMAND, 751, 0);
SendMessageW(profileDialog, WM_COMMAND, IDOK, 0);
wchar_t renamed[80]{};
ListView_GetItemText(profiles, 1, 1, renamed, 80);
ListView_GetItemText(profiles, 1, 2, applications, 80);
require(std::wstring(renamed) == L"Work browsing" &&
std::wstring(applications) == L"msedge.exe" &&
!ListView_GetCheckState(profiles, 1),
"renaming keeps the profile selected and disabled");
const int appsWidth = ListView_GetColumnWidth(profiles, 2);
ListView_SetColumnWidth(profiles, 2, appsWidth + 100);
SendMessageW(settings, WM_SIZE, 0, 0);
require(ListView_GetColumnWidth(profiles, 2) == appsWidth + 100 &&
(GetWindowLongPtrW(profiles, GWL_STYLE) & WS_HSCROLL),
"the Applications column retains its wider size and can scroll");
SendMessageW(settings, ks::ConfigSettingsDownloaded, 0, 0);
}
{
auto window = app.settingsWindow;
auto previous = app.settings;