From a0e1de40ef43c76d9a0901d6dd59b719f96500c6 Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Tue, 10 Nov 2026 00:43:02 +0200 Subject: [PATCH] Pick an application by dragging onto its highlighted window --- README.md | 5 ++ src/ui_app_languages.inl | 163 +++++++++++++++++++++++++++++++++++---- tests/chat_profiles.cpp | 29 +++++++ 3 files changed, 181 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0e0fbcd..13b7623 100644 --- a/README.md +++ b/README.md @@ -913,6 +913,8 @@ choices and become independent profiles when this dialog is applied. The list includes running applications and saved profiles. Add a closed application by its executable name, such as `Codex.exe`. Matching ignores case and `.exe` and covers all windows of that executable, including all tabs of a browser. +To identify an open application, drag **Drag to window** onto its window. An outline +shows which window will be selected. Release to select its executable or press **Esc** to cancel. Applications without a custom profile use the default languages; merely viewing an application does not create a profile. **Use defaults** clears only the selected application's custom choice. Switching applications preserves pending edits. @@ -937,6 +939,9 @@ settings export/import. цього вікна. У списку є відкриті програми та збережені профілі; закриту програму можна додати за назвою файла, наприклад `Codex.exe`. Регістр і `.exe` не мають значення. Правило охоплює всі вікна програми, зокрема всі вкладки браузера. +Щоб визначити відкриту програму, перетягни приціл **Drag to window** на її вікно. +Рамка показує, яке вікно буде вибрано. Відпусти приціл, щоб вибрати програму, +або натисни **Esc**, щоб скасувати. Без окремого профілю діють загальні мови; простий перегляд програми не створює профіль. **Use defaults** повертає лише вибрану програму до загальних мов. Перехід між програмами зберігає чернетки. **Use languages**, потім **Save** diff --git a/src/ui_app_languages.inl b/src/ui_app_languages.inl index 67addc8..da37d6a 100644 --- a/src/ui_app_languages.inl +++ b/src/ui_app_languages.inl @@ -12,7 +12,18 @@ struct AppLanguagesView std::vector applications; int selection = -1; bool dirty{}; + HWND pickerOutline{}; }; +constexpr UINT AppWindowPicked = WM_APP + 78; +void showAppLanguageStatus(HWND w, const AppLanguagesView &v) +{ + if (v.selection < 0) + value(w, 721, L"Select an application, drag the crosshair, or enter an executable name."); + else if (v.dirty || v.profiles.contains(v.applications[v.selection])) + value(w, 721, L"Custom languages for this application. Other applications are unchanged."); + else + value(w, 721, L"Using default languages. Click languages to customize this application."); +} void fillAppLanguages(HWND w, AppLanguagesView *v) { bool selected = v->selection >= 0 && v->selection < (int)v->applications.size(); @@ -21,7 +32,7 @@ void fillAppLanguages(HWND w, AppLanguagesView *v) if (!selected) { EnableWindow(GetDlgItem(w, 725), FALSE); - value(w, 721, L"Select an application, or add its executable name below."); + showAppLanguageStatus(w, *v); return; } auto found = v->profiles.find(v->applications[v->selection]); @@ -32,8 +43,7 @@ void fillAppLanguages(HWND w, AppLanguagesView *v) SendDlgItemMessageW(w, 722, LB_SETSEL, std::find(cycle.begin(), cycle.end(), id) != cycle.end(), i); } - value(w, 721, found == v->profiles.end() ? L"Using default languages. Click languages to customize this application." : - L"Custom languages for this application. Other applications are unchanged."); + showAppLanguageStatus(w, *v); EnableWindow(GetDlgItem(w, 725), found != v->profiles.end()); } void collectAppLanguages(HWND w, AppLanguagesView *v) @@ -47,12 +57,141 @@ void collectAppLanguages(HWND w, AppLanguagesView *v) v->profiles[v->applications[v->selection]] = std::move(cycle); v->dirty = false; } +void selectAppLanguageProfile(HWND w, AppLanguagesView *v, const std::wstring &app) +{ + collectAppLanguages(w, v); + auto found = std::find(v->applications.begin(), v->applications.end(), app); + int index = (int)(found - v->applications.begin()); + if (found == v->applications.end()) + { + auto row = SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str()); + if (row < 0) throw std::runtime_error("Could not add the application to the list."); + v->applications.push_back(app); + } + v->selection = index; + SendDlgItemMessageW(w, 720, LB_SETCURSEL, index, 0); + fillAppLanguages(w, v); +} +HWND appPickerTarget(HWND button, LPARAM position) +{ + POINT point{(short)LOWORD(position), (short)HIWORD(position)}; + ClientToScreen(button, &point); + auto target = GetAncestor(WindowFromPoint(point), GA_ROOT); + auto dialog = GetParent(button); + return target && target != dialog && target != GetWindow(dialog, GW_OWNER) ? target : nullptr; +} +void updatePickerOutline(HWND dialog, AppLanguagesView &v, HWND target) +{ + RECT bounds{}; + if (!target || !GetWindowRect(target, &bounds) || IsRectEmpty(&bounds)) + { + if (v.pickerOutline) ShowWindow(v.pickerOutline, SW_HIDE); + return; + } + if (!v.pickerOutline) + { + WNDCLASSW windowClass{}; + windowClass.lpfnWndProc = DefWindowProcW; + windowClass.hInstance = GetModuleHandleW(nullptr); + windowClass.hbrBackground = GetSysColorBrush(COLOR_HIGHLIGHT); + windowClass.lpszClassName = L"KSwitcher.AppPickerOutline"; + RegisterClassW(&windowClass); + v.pickerOutline = CreateWindowExW(WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + windowClass.lpszClassName, nullptr, WS_POPUP, 0, 0, 0, 0, dialog, nullptr, windowClass.hInstance, nullptr); + if (!v.pickerOutline) return; + if (!SetLayeredWindowAttributes(v.pickerOutline, 0, 255, LWA_ALPHA)) + { + DestroyWindow(v.pickerOutline); + v.pickerOutline = nullptr; + return; + } + } + RECT previous{}; + if (IsWindowVisible(v.pickerOutline) && GetWindowRect(v.pickerOutline, &previous) && EqualRect(&bounds, &previous)) return; + int width = bounds.right - bounds.left, height = bounds.bottom - bounds.top; + int stroke = scale(target, 4); + HRGN frame = CreateRectRgn(0, 0, width, height); + if (!frame) return; + if (width > 2 * stroke && height > 2 * stroke) + { + HRGN opening = CreateRectRgn(stroke, stroke, width - stroke, height - stroke); + if (opening) + { + CombineRgn(frame, frame, opening, RGN_DIFF); + DeleteObject(opening); + } + } + if (!SetWindowRgn(v.pickerOutline, frame, TRUE)) + { + DeleteObject(frame); + return; + } + SetWindowPos(v.pickerOutline, HWND_TOPMOST, bounds.left, bounds.top, width, height, SWP_NOACTIVATE | SWP_SHOWWINDOW); +} +LRESULT CALLBACK appWindowPickerProc(HWND button, UINT msg, WPARAM wp, LPARAM lp, UINT_PTR id, DWORD_PTR) +{ + HWND dialog = GetParent(button); + auto *view = (AppLanguagesView *)GetWindowLongPtrW(dialog, GWLP_USERDATA); + if (msg == WM_LBUTTONDOWN) + { + SetFocus(button); + SetCapture(button); + if (GetCapture() == button) + { + SetCursor(LoadCursorW(nullptr, IDC_CROSS)); + SetDlgItemTextW(dialog, 721, L"Drag to the application's window and release. Esc cancels."); + } + return 0; + } + if (msg == WM_CAPTURECHANGED || msg == WM_NCDESTROY) + { + if (view && view->pickerOutline) + { + DestroyWindow(view->pickerOutline); + view->pickerOutline = nullptr; + } + if (msg == WM_CAPTURECHANGED) SendMessageW(dialog, AppWindowPicked, 0, 0); + } + if (GetCapture() == button) + { + if (msg == WM_GETDLGCODE && wp == VK_ESCAPE) return DLGC_WANTMESSAGE; + if (msg == WM_CANCELMODE || msg == WM_RBUTTONDOWN || (msg == WM_KEYDOWN && wp == VK_ESCAPE)) + { + ReleaseCapture(); + return 0; + } + if (msg == WM_MOUSEMOVE || msg == WM_SETCURSOR) + { + SetCursor(LoadCursorW(nullptr, IDC_CROSS)); + if (msg == WM_MOUSEMOVE && view) updatePickerOutline(dialog, *view, appPickerTarget(button, lp)); + return TRUE; + } + if (msg == WM_LBUTTONUP) + { + HWND target = appPickerTarget(button, lp); + ReleaseCapture(); + SendMessageW(dialog, AppWindowPicked, (WPARAM)target, 1); + return 0; + } + } + if (msg == WM_NCDESTROY) RemoveWindowSubclass(button, appWindowPickerProc, id); + return DefSubclassProc(button, msg, wp, lp); +} LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { auto *v = (AppLanguagesView *)GetWindowLongPtrW(w, GWLP_USERDATA); if (!v) return DefWindowProcW(w, msg, wp, lp); try { + if (msg == AppWindowPicked) + { + if (!lp) { showAppLanguageStatus(w, *v); return 0; } + HWND target = (HWND)wp; + auto app = target && target != w && target != v->parent ? applicationName(processName(target)) : L""; + if (app.empty()) value(w, 721, L"No application found. Release the crosshair over another window."); + else selectAppLanguageProfile(w, v, app); + return 0; + } if (msg == WM_COMMAND) { int id = LOWORD(wp); @@ -60,7 +199,7 @@ LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) { v->dirty = true; EnableWindow(GetDlgItem(w, 725), TRUE); - value(w, 721, L"Custom languages for this application. Other applications are unchanged."); + showAppLanguageStatus(w, *v); return 0; } if (id == 720 && HIWORD(wp) == LBN_SELCHANGE) @@ -85,17 +224,7 @@ LRESULT CALLBACK appLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp) Settings check; check.appLanguageSets[L"check"] = {{L"win:04090409"}, {app}}; validateSettings(check); - collectAppLanguages(w, v); - auto found = std::find(v->applications.begin(), v->applications.end(), app); - int index = (int)(found - v->applications.begin()); - if (found == v->applications.end()) - { - v->applications.push_back(app); - SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str()); - } - v->selection = index; - SendDlgItemMessageW(w, 720, LB_SETCURSEL, index, 0); - fillAppLanguages(w, v); + selectAppLanguageProfile(w, v, app); value(w, 726, L""); SetFocus(GetDlgItem(w, 722)); return 0; @@ -148,7 +277,9 @@ void showAppLanguages(HWND parent, SettingsView *settings) v->applications.assign(apps.begin(), apps.end()); SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent); SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v); - label(w, L"Application", 18, 16, 278); + label(w, L"Application", 18, 16, 108); + button(w, L"⊕ Drag to window", 728, 132, 10, 164); + SetWindowSubclass(GetDlgItem(w, 728), appWindowPickerProc, 1, 0); control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_NOINTEGRALHEIGHT | WS_BORDER, 720, 18, 44, 278, 280); label(w, L"Allowed languages (click to include)", 316, 16, 386); diff --git a/tests/chat_profiles.cpp b/tests/chat_profiles.cpp index 154c7f7..bc365ca 100644 --- a/tests/chat_profiles.cpp +++ b/tests/chat_profiles.cpp @@ -279,6 +279,35 @@ int main() require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == 1, "legacy languages displayed"); SendDlgItemMessageW(dialog, 722, LB_SETSEL, TRUE, -1); SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(722, LBN_SELCHANGE), 0); + HWND picker = GetDlgItem(dialog, 728); + RECT dialogBounds{}; + GetWindowRect(dialog, &dialogBounds); + HWND target = CreateWindowExW(WS_EX_TOPMOST | WS_EX_NOACTIVATE, L"EDIT", L"Picker fixture", + WS_POPUP | WS_VISIBLE, dialogBounds.left + 32, dialogBounds.top + 100, 120, 48, + nullptr, nullptr, GetModuleHandleW(nullptr), nullptr); + require(picker && target, "window picker and target available"); + POINT drop{dialogBounds.left + 33, dialogBounds.top + 101}; + require(GetAncestor(WindowFromPoint(drop), GA_ROOT) == target, "fixture window is under the drop point"); + ScreenToClient(picker, &drop); + SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8)); + require(GetCapture() == picker, "window picker captures drag"); + SendMessageW(picker, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(drop.x, drop.y)); + HWND outline = findTestWindow(L"KSwitcher.AppPickerOutline", nullptr); + RECT outlineBounds{}, targetBounds{}; + require(IsWindowVisible(outline) && GetWindowRect(outline, &outlineBounds) && + GetWindowRect(target, &targetBounds) && EqualRect(&outlineBounds, &targetBounds), "drag highlights the target window"); + SendMessageW(picker, WM_KEYDOWN, VK_ESCAPE, 0); + require(!IsWindow(outline) && GetCapture() != picker, "Escape clears the outline and releases capture"); + SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8)); + SendMessageW(picker, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(drop.x, drop.y)); + outline = findTestWindow(L"KSwitcher.AppPickerOutline", nullptr); + SendMessageW(picker, WM_LBUTTONUP, 0, MAKELPARAM(drop.x, drop.y)); + require(!IsWindow(outline), "dropping clears the outline"); + auto pickedApp = ks::applicationName(ks::processName(target)); + DestroyWindow(target); + auto pickedRow = SendDlgItemMessageW(dialog, 720, LB_FINDSTRINGEXACT, -1, (LPARAM)pickedApp.c_str()); + require(GetCapture() != picker && pickedRow >= 0 && + SendDlgItemMessageW(dialog, 720, LB_GETCURSEL, 0, 0) == pickedRow, "dropping on a window selects its executable"); selectApp(L"notepad"); require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == 1, "editing one app leaves formerly shared app unchanged"); selectApp(L"codex");