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

+325 -9
View File
@@ -847,6 +847,8 @@ void App::resetLanInput()
// shortcut/chat latches across an ownership handoff.
hotkeyHook = {};
chatSwitchHook = {};
pendingMouseClicks = {};
mouseActions.clear();
resetInput();
}
void App::resetInput()
@@ -1169,23 +1171,324 @@ LRESULT App::keyboardAfterHooks(int code, WPARAM wp, LPARAM lp, LRESULT routed)
}
return routed;
}
constexpr UINT MouseActionMessage = WM_APP + 79;
constexpr UINT MouseVolumeReleasedMessage = WM_APP + 80;
constexpr UINT_PTR MouseClickTimer = 9;
constexpr UINT_PTR FirstMouseVolumeTimer = 10;
namespace
{
constexpr wchar_t VolumeIndicatorClass[] = L"KSwitcher.VolumeIndicator";
constexpr UINT_PTR VolumeIndicatorTimer = 1;
constexpr WORD VolumeIndicatorMuted = 1, VolumeIndicatorHeld = 2;
void paintVolumeIndicator(HWND w)
{
PAINTSTRUCT paint{};
const auto dc = BeginPaint(w, &paint);
RECT bounds{};
GetClientRect(w, &bounds);
FillRect(dc, &bounds, GetSysColorBrush(COLOR_WINDOW));
FrameRect(dc, &bounds, GetSysColorBrush(COLOR_WINDOWFRAME));
const int dpi = static_cast<int>(GetDpiForWindow(w));
const int padding = MulDiv(8, dpi, 96);
const int captionHeight = MulDiv(26, dpi, 96);
const auto state = GetWindowLongPtrW(w, GWLP_USERDATA);
const int percent = LOWORD(state);
const bool muted = (HIWORD(state) & VolumeIndicatorMuted) != 0;
RECT track{padding, padding, bounds.right - padding, bounds.bottom - captionHeight};
FillRect(dc, &track, GetSysColorBrush(COLOR_3DFACE));
RECT fill = track;
fill.top = fill.bottom - MulDiv(fill.bottom - fill.top, percent, 100);
FillRect(dc, &fill, GetSysColorBrush(muted ? COLOR_GRAYTEXT : COLOR_HIGHLIGHT));
RECT caption{0, track.bottom, bounds.right, bounds.bottom};
const auto text = muted ? std::wstring(L"Mute") : std::to_wstring(percent) + L"%";
const auto font = CreateFontW(-MulDiv(12, dpi, 96), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
DEFAULT_CHARSET, 0, 0, 0, 0, L"Segoe UI");
const auto previous = font ? SelectObject(dc, font) : nullptr;
SetBkMode(dc, TRANSPARENT);
SetTextColor(dc, GetSysColor(COLOR_WINDOWTEXT));
DrawTextW(dc, text.c_str(), -1, &caption, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX);
if (previous) SelectObject(dc, previous);
if (font) DeleteObject(font);
EndPaint(w, &paint);
}
LRESULT CALLBACK volumeIndicatorProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg)
{
case WM_PAINT: paintVolumeIndicator(w); return 0;
case WM_NCHITTEST: return HTTRANSPARENT;
case WM_MOUSEACTIVATE: return MA_NOACTIVATE;
case WM_TIMER:
if (wp == VolumeIndicatorTimer)
{
KillTimer(w, VolumeIndicatorTimer);
if (!(HIWORD(GetWindowLongPtrW(w, GWLP_USERDATA)) & VolumeIndicatorHeld))
ShowWindow(w, SW_HIDE);
return 0;
}
break;
}
return DefWindowProcW(w, msg, wp, lp);
}
void setVolumeIndicatorHold(HWND indicator, bool held)
{
const auto state = GetWindowLongPtrW(indicator, GWLP_USERDATA);
const WORD flags = (HIWORD(state) & VolumeIndicatorMuted) | (held ? VolumeIndicatorHeld : 0);
SetWindowLongPtrW(indicator, GWLP_USERDATA, MAKELPARAM(LOWORD(state), flags));
if (held) KillTimer(indicator, VolumeIndicatorTimer);
else SetTimer(indicator, VolumeIndicatorTimer, 1000, nullptr);
}
void showVolumeIndicator(HWND owner, HINSTANCE instance, POINT point, SystemVolume volume, bool held)
{
MONITORINFO monitor{sizeof(monitor)};
const auto targetMonitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST);
if (!GetMonitorInfoW(targetMonitor, &monitor)) return;
const auto &area = monitor.rcWork;
auto indicator = reinterpret_cast<HWND>(GetPropW(owner, VolumeIndicatorClass));
if (!indicator)
{
WNDCLASSW type{};
type.lpfnWndProc = volumeIndicatorProc;
type.hInstance = instance;
type.lpszClassName = VolumeIndicatorClass;
RegisterClassW(&type);
indicator = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW | WS_EX_TOPMOST |
WS_EX_LAYERED | WS_EX_TRANSPARENT, VolumeIndicatorClass, L"Volume",
WS_POPUP, area.left, area.top, 1, 1, owner, nullptr, instance, nullptr);
if (!indicator) return;
if (!SetPropW(owner, VolumeIndicatorClass, indicator) ||
!SetLayeredWindowAttributes(indicator, 0, 255, LWA_ALPHA))
{
RemovePropW(owner, VolumeIndicatorClass);
DestroyWindow(indicator);
return;
}
}
// Place the window on the destination monitor before querying its DPI.
if (MonitorFromWindow(indicator, MONITOR_DEFAULTTONEAREST) != targetMonitor)
SetWindowPos(indicator, nullptr, area.left, area.top, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOREDRAW);
const int dpi = static_cast<int>(GetDpiForWindow(indicator));
const int width = MulDiv(48, dpi, 96);
const int height = MulDiv(132, dpi, 96);
const int margin = MulDiv(16, dpi, 96);
SetWindowLongPtrW(indicator, GWLP_USERDATA, MAKELPARAM(volume.percent, volume.muted ? VolumeIndicatorMuted : 0));
setVolumeIndicatorHold(indicator, held);
SetWindowPos(indicator, HWND_TOPMOST, area.right - width - margin, area.bottom - height - margin,
width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW);
InvalidateRect(indicator, nullptr, FALSE);
}
}
void App::queueMouseAction(MousePress press)
{
if (press.action.type == MouseActionType::Standard) return;
mouseActions.push_back(std::move(press));
PostMessageW(window, MouseActionMessage, 0, 0);
}
void App::flushMouseClicks()
{
ULONGLONG next = 0;
const auto now = GetTickCount64();
for (auto &press : pendingMouseClicks)
{
if (!press.target) continue;
const auto deadline = press.started + GetDoubleClickTime();
if (now >= deadline)
{
queueMouseAction(std::exchange(press, {}));
}
else if (!next || deadline < next) next = deadline;
}
if (next) SetTimer(window, MouseClickTimer, static_cast<UINT>(next - now), nullptr);
else KillTimer(window, MouseClickTimer);
}
void App::beginMouseVolume(int button)
{
const auto &press = mousePresses[button];
if (!press.waitForSecond) queueMouseAction(press);
const UINT delay = press.waitForSecond ? std::max(MouseVolumeHoldDelay, GetDoubleClickTime()) : MouseVolumeHoldDelay;
SetTimer(window, FirstMouseVolumeTimer + button, delay, nullptr);
}
void App::repeatMouseVolume(int button)
{
const auto timer = FirstMouseVolumeTimer + button;
auto &press = mousePresses[button];
if (!press.held || !isMouseVolumeStep(press.action.type) ||
busy || suspended || installingUpdate || hotkeyCapture || !modifiersReleased() ||
!IsWindow(press.target) || (!press.action.applications.empty() && press.target != GetForegroundWindow()))
{
KillTimer(window, timer);
return;
}
const auto elapsed = GetTickCount64() - press.started;
const UINT delay = press.waitForSecond ? std::max(MouseVolumeHoldDelay, GetDoubleClickTime()) : MouseVolumeHoldDelay;
if (elapsed < delay) return;
press.waitForSecond = false;
queueMouseAction(press);
SetTimer(window, timer, mouseVolumeRepeatInterval(elapsed), nullptr);
}
bool App::volumeButtonHeld() const
{
return std::any_of(mousePresses.begin(), mousePresses.end(), [](const MousePress &press)
{
return press.held && !press.waitForSecond && isMouseVolumeStep(press.action.type);
});
}
void App::dispatchMouseActions()
{
std::deque<MousePress> actions;
actions.swap(mouseActions);
if (busy || suspended || installingUpdate || hotkeyCapture || !modifiersReleased())
{
if (!actions.empty()) log(L"Mouse action skipped while input was unavailable.");
return;
}
for (const auto &press : actions)
{
if (!IsWindow(press.target)) continue;
const auto &action = press.action;
switch (action.type)
{
case MouseActionType::Standard: break;
case MouseActionType::Shortcut:
if (press.target != GetForegroundWindow())
log(L"Mouse shortcut skipped because the active window changed.");
else if (!chord(static_cast<WORD>(action.shortcut.key), action.shortcut.modifiers))
log(L"Windows did not accept all input for the mouse shortcut.");
else
log(L"Windows accepted input for the mouse shortcut.");
break;
case MouseActionType::Back:
case MouseActionType::Forward:
PostMessageW(press.target, WM_APPCOMMAND, reinterpret_cast<WPARAM>(press.target),
MAKELPARAM(0, FAPPCOMMAND_MOUSE | (action.type == MouseActionType::Back ?
APPCOMMAND_BROWSER_BACKWARD : APPCOMMAND_BROWSER_FORWARD)));
break;
case MouseActionType::VolumeUp:
case MouseActionType::VolumeDown:
case MouseActionType::Mute:
if (const auto volume = changeSystemVolume(action.type, action.volumeStep))
showVolumeIndicator(window, instance, press.point, *volume, volumeButtonHeld());
else log(L"Mouse action could not change or read system volume.");
break;
case MouseActionType::Launch:
if (!launchThroughExplorer(action.executable))
log(L"Mouse action could not launch " + action.executable);
else
log(L"Mouse action launched " + std::filesystem::path(action.executable).filename().wstring());
break;
}
}
}
LRESULT CALLBACK App::mouseProc(int code, WPARAM wp, LPARAM lp)
{
if (code == HC_ACTION && current)
{
if (current->lanService)
auto &app = *current;
const auto &mouse = *reinterpret_cast<const MSLLHOOKSTRUCT *>(lp);
const int button = mouse.flags & LLMHF_INJECTED ? -1 : mouseButton(UINT(wp), HIWORD(mouse.mouseData));
const bool down = mouseButtonDown(UINT(wp));
if (button >= 0 && app.mousePresses[button].held)
{
bool consumed = current->lanService->mouse(UINT(wp), *(MSLLHOOKSTRUCT *)lp,
!current->busy && !current->suspended && !current->hotkeyCapture);
if (current->lanService->takeInputChange()) current->resetLanInput();
auto &active = app.mousePresses[button];
const bool consumed = active.action.type != MouseActionType::Standard;
if (!down)
{
auto press = std::exchange(active, {});
if (isMouseVolumeStep(press.action.type))
{
KillTimer(app.window, FirstMouseVolumeTimer + button);
PostMessageW(app.window, MouseVolumeReleasedMessage, 0, 0);
}
if (press.waitForSecond)
{
app.pendingMouseClicks[button] = std::move(press);
app.flushMouseClicks();
}
else if (!isMouseVolumeStep(press.action.type)) app.queueMouseAction(std::move(press));
}
if (consumed) return 1;
}
// KSwitcher generates no mouse input. Remote clicks invalidate a typing
// range just like local clicks, including while a transaction is pending.
if (wp == WM_LBUTTONDOWN || wp == WM_RBUTTONDOWN || wp == WM_MBUTTONDOWN || wp == WM_MOUSEWHEEL)
if (app.lanService)
{
if (current->busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
current->resetInput();
const bool mouseActionHeld = std::any_of(app.mousePresses.begin(), app.mousePresses.end(),
[](const MousePress &press) { return press.held; });
bool consumed = app.lanService->mouse(UINT(wp), mouse,
!app.busy && !app.suspended && !app.hotkeyCapture && !mouseActionHeld);
if (app.lanService->takeInputChange()) app.resetLanInput();
if (consumed) return 1;
}
if (button >= 0 && down && !app.settings.mouseBindings.empty() && !app.busy && !app.suspended &&
!app.installingUpdate && !app.hotkeyCapture && modifiersReleased())
{
const auto hit = WindowFromPoint(mouse.pt);
const auto target = GetAncestor(hit, GA_ROOT);
const auto name = target ? mouseApplicationName(target) : L"";
wchar_t windowClass[64]{};
if (target && name == L"explorer") GetClassNameW(target, windowClass, std::size(windowClass));
const bool ownWindow = name == L"kswitcher" || name == L"kswitcher.config";
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 &&
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;
const auto *restrictedAction = isMouseVolumeAction(fallback) ? fallback : globalLaunch ? global : nullptr;
const bool eligible = target && !testButton && (!restricted || (button >= 2 && restrictedAction)) &&
(button >= 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, 5 + button, foreground, &doubleScoped) : nullptr;
if (!foreground)
{
if (single && single->type == MouseActionType::Shortcut) single = nullptr;
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 (second ? doubleScoped : singleScoped) app.mousePresses[button].action.applications.insert(name);
if (isMouseVolumeStep(action->type))
app.beginMouseVolume(button);
app.resetInput();
return 1;
}
}
}
// Remote clicks invalidate a typing range just like local clicks.
if (down || wp == WM_MOUSEWHEEL || wp == WM_MOUSEHWHEEL)
{
if (app.busy) diagnosticEvent(L"Input context changed: mouse message=" + std::to_wstring(wp));
app.resetInput();
}
}
return CallNextHookEx(nullptr, code, wp, lp);
@@ -2205,6 +2508,13 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
case WM_HOTKEY:
app->dispatch((int)wp);
return 0;
case MouseActionMessage:
app->dispatchMouseActions();
return 0;
case MouseVolumeReleasedMessage:
if (const auto indicator = reinterpret_cast<HWND>(GetPropW(hwnd, VolumeIndicatorClass)))
setVolumeIndicatorHold(indicator, app->volumeButtonHeld());
return 0;
case WM_POWERBROADCAST:
if (wp == PBT_APMSUSPEND && app->lanService)
{
@@ -2294,6 +2604,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
if(app->lanService&&app->lanService->tickInput(!app->busy&&!app->suspended&&!app->hotkeyCapture))app->resetLanInput();
return 0;
case WM_TIMER:
if (wp == MouseClickTimer) { app->flushMouseClicks(); return 0; }
if (wp >= FirstMouseVolumeTimer && wp < FirstMouseVolumeTimer + std::size(MouseButtonNames))
{
app->repeatMouseVolume(static_cast<int>(wp - FirstMouseVolumeTimer));
return 0;
}
if (wp == 8)
{
if (app->settings.appLanguageSets.empty() || app->busy || app->installingUpdate) return 0;