Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3761b790ae | ||
|
|
a0e1de40ef | ||
|
|
5d96abb83e | ||
|
|
011664cc53 |
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(KSwitcher VERSION 0.6.59 LANGUAGES CXX RC)
|
project(KSwitcher VERSION 0.6.62 LANGUAGES CXX RC)
|
||||||
if(NOT WIN32)
|
if(NOT WIN32)
|
||||||
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -723,9 +723,11 @@ default and require the main **Automatic correction (experimental)** checkbox to
|
|||||||
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
`првиіт=привіт` or `helo=hello`. Entries are normalized to lowercase; duplicate source words
|
||||||
are rejected. The typo must contain 1–32 letters. The correction may contain several words
|
are rejected. The typo must contain 1–32 letters. The correction may contain several words
|
||||||
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
|
separated by single spaces, for example `мамамила=мама мила`, up to 32 characters including
|
||||||
spaces. Both sides must use the same supported language, without punctuation; leading,
|
spaces. Corrections also allow apostrophes (`'`, `’`, `ʼ`) between letters, for example
|
||||||
|
`ревю=рев'ю`. Both sides must use the same supported language, without other punctuation; leading,
|
||||||
trailing and repeated spaces are not allowed. Explicit pairs take priority over dictionary recognition, including
|
trailing and repeated spaces are not allowed. Explicit pairs take priority over dictionary recognition, including
|
||||||
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
already valid words, and do not require dictionary membership or satisfy the frequency limit.
|
||||||
|
Update every synchronizing PC to v0.6.60 or newer before sharing apostrophe pairs; older clients reject them.
|
||||||
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
- **Correct spelling typos using dictionaries**: checks unknown words in the current language
|
||||||
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
after no layout correction is found. It accepts exactly one dictionary candidate reachable
|
||||||
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
|
by inserting, deleting or replacing one letter, or swapping two adjacent letters. Both input
|
||||||
@@ -899,3 +901,57 @@ History opens in a separate, short-lived instance of **KSwitcher.exe**. **Esc**
|
|||||||
Image thumbnails are decoded in **KSwitcher.Screenshot.exe**, returning only bounded preview pixels. Image clipboard backup/restore also uses that helper and encrypted temporary files, with clipboard-sequence checks before restoring. Selection reads avoid loading image formats. Keep all three executables together; no fourth executable is required. Paste allows a brief foreground handoff after history closes while preserving cancellation checks. UI Automation remains in the resident.
|
Image thumbnails are decoded in **KSwitcher.Screenshot.exe**, returning only bounded preview pixels. Image clipboard backup/restore also uses that helper and encrypted temporary files, with clipboard-sequence checks before restoring. Selection reads avoid loading image formats. Keep all three executables together; no fourth executable is required. Paste allows a brief foreground handoff after history closes while preserving cancellation checks. UI Automation remains in the resident.
|
||||||
|
|
||||||
A live development-build test measured **4.37 MiB before history and 4.35 MiB after two open/Esc cycles**, compared with an earlier roughly 8–9 MiB plateau. A same-image clipboard-backup benchmark retained **12.78 MiB before versus 0.05 MiB after**. These measure private working set, not a guaranteed total footprint. Include all helpers when measuring active use: combined memory can be higher while history is open. No forced working-set trimming is used. Existing screenshots and shortcuts remain applicable. The v0.6.38 approach of retaining a hidden history window/RichEdit control is superseded by process termination.
|
A live development-build test measured **4.37 MiB before history and 4.35 MiB after two open/Esc cycles**, compared with an earlier roughly 8–9 MiB plateau. A same-image clipboard-backup benchmark retained **12.78 MiB before versus 0.05 MiB after**. These measure private working set, not a guaranteed total footprint. Include all helpers when measuring active use: combined memory can be higher while history is open. No forced working-set trimming is used. Existing screenshots and shortcuts remain applicable. The v0.6.38 approach of retaining a hidden history window/RichEdit control is superseded by process termination.
|
||||||
|
|
||||||
|
### Languages for applications
|
||||||
|
|
||||||
|
Open **Settings → Languages → Languages for applications…**. Select an application
|
||||||
|
on the left and click its allowed languages on the right. Every application has
|
||||||
|
an independent selection, even when several applications use the same languages.
|
||||||
|
Existing assignments from the earlier shared-set editor retain their language
|
||||||
|
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.
|
||||||
|
Choose **Use languages** once, then **Save** in Settings; **Cancel** discards the
|
||||||
|
dialog's changes. A matching contact profile takes priority over an application
|
||||||
|
profile, which takes priority over the default cycle.
|
||||||
|
|
||||||
|
**Alt+Shift**, **Win+Space**, reverse **Win+Shift+Space** and **Next layout** use the
|
||||||
|
resolved subset. In an application with custom languages, a layout outside the
|
||||||
|
subset is returned to the first available allowed language by a periodic check
|
||||||
|
(250 ms, deferred while KSwitcher is busy). This also handles entering the app
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Українською.** Відкрий **Settings → Languages → Languages for applications…**.
|
||||||
|
Обери програму зліва та познач дозволені мови справа. Вибір для кожної програми
|
||||||
|
незалежний, навіть якщо в кількох програмах він однаковий. Старі прив'язки спільних
|
||||||
|
наборів зберігають вибрані мови та стають незалежними профілями після застосування
|
||||||
|
цього вікна. У списку є відкриті програми та збережені профілі; закриту програму
|
||||||
|
можна додати за назвою файла, наприклад `Codex.exe`. Регістр і `.exe` не мають
|
||||||
|
значення. Правило охоплює всі вікна програми, зокрема всі вкладки браузера.
|
||||||
|
Щоб визначити відкриту програму, перетягни приціл **Drag to window** на її вікно.
|
||||||
|
Рамка показує, яке вікно буде вибрано. Відпусти приціл, щоб вибрати програму,
|
||||||
|
або натисни **Esc**, щоб скасувати.
|
||||||
|
Без окремого профілю діють загальні мови; простий перегляд програми не створює
|
||||||
|
профіль. **Use defaults** повертає лише вибрану програму до загальних мов.
|
||||||
|
Перехід між програмами зберігає чернетки. **Use languages**, потім **Save**
|
||||||
|
застосовує всі зміни, **Cancel** скасовує зміни вікна. Пріоритет: профіль контакту
|
||||||
|
→ профіль програми → загальний цикл.
|
||||||
|
**Alt+Shift**, **Win+Space**, **Win+Shift+Space** та **Next layout** використовують
|
||||||
|
відповідний вибір. Мову поза профілем програми перевірка кожні 250 мс замінює
|
||||||
|
першою доступною дозволеною мовою (відкладається, коли KSwitcher зайнятий).
|
||||||
|
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
|
||||||
|
комбінацію. Мови залишаються встановленими й видимими в меню Windows.
|
||||||
|
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
|
||||||
|
загальний цикл. Профілі локальні, зберігаються під час хмарної синхронізації
|
||||||
|
та входять до ручного експорту/імпорту налаштувань.
|
||||||
Binary file not shown.
+8
-14
@@ -1,22 +1,16 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.59 — 2026-11-09
|
## v0.6.62 — 2026-11-10
|
||||||
|
|
||||||
## Changes
|
In **Settings → Languages → Languages for applications...**, drag **⊕ Drag to window** onto an open window. A frame follows the window under the crosshair. Release to select the program in the list, or press **Esc** to cancel. If the program is missing from the list, KSwitcher adds it for selection. Pending edits for other applications are preserved.
|
||||||
- Show clear download/upload progress and results, including the server version, applied changes or unchanged local settings. Preserve action errors instead of replacing them with an older status.
|
|
||||||
- Identify OpenBao HTTP 400 failures as authentication, download or write failures instead of labeling every failure as a rejected write. Failed downloads do not apply remote settings.
|
|
||||||
- Refresh the already-open Settings window after a successful Download remote, preserving its selected tab. This also works if the Sync dialog was closed while the request was running. Confirmation explains that unsaved Settings edits will be discarded.
|
|
||||||
- Retain all v0.6.58 LAN, clipboard, screenshot, snippet and chat-profile behavior. No sync schema or compatibility change.
|
|
||||||
|
|
||||||
## Validation
|
Choose the program's allowed layouts, then **Use languages** and **Save**. Application profiles apply to the executable, including all browser tabs in that executable. Contact profiles still take priority.
|
||||||
Passed: 132 sync, 571 input/UI, 341 native, 829 chat-profile/UI, 205 LAN input, 289 LAN clipboard, 43 LAN pairing and 5 settings-save merge checks; dictionary equivalence for 140,939 words; wheel, StayHere, Utilities, screenshot UI/save/registration, 54 updater checks and updater progress. Regression tests use an isolated mock OpenBao transport, not a live user account.
|
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
## Upgrade and compatibility
|
|
||||||
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
Choose **Update** in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||||
For manual installation, exit KSwitcher, close its configurator and screenshot editors, and extract the entire ZIP into its installation folder. Keep personal `data/`, customized dictionaries and your own tools. Keep the three KSwitcher executables together; StayHere runs independently.
|
|
||||||
|
|
||||||
**Update every synchronizing PC to v0.6.51 or newer before sharing application-specific snippets. Older versions do not understand application restrictions and may treat scoped snippets as global.** Keep a backup before downgrading. Settings with Telegram Web profiles require v0.6.51 or newer. Chat profiles now synchronize from v0.6.53. Update every synchronizing PC to v0.6.53 or newer before syncing: older clients reject the chat-profile field. Synchronization stores application names, visible chat names and language IDs on your configured OpenBao server. No message contents are included.
|
For a manual upgrade, exit KSwitcher and its configurator and screenshot editors, then extract the entire **KSwitcher-win-x64.zip** into its installation folder. Verify it with **SHA256SUMS.txt**. Keep personal data, customized dictionaries and tools. Keep the three KSwitcher executables together.
|
||||||
|
|
||||||
Download **KSwitcher-win-x64.zip** with **SHA256SUMS.txt**. The package contains no personal settings, credentials or clipboard history.
|
See the [English Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home) or [Українська Wiki](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki/Home-uk).
|
||||||
|
|
||||||
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
|
|
||||||
@@ -1 +1 @@
|
|||||||
f0d7d15534db21c17b113a25cc07d59a68fa151ecf995ac360ac476751171a9a KSwitcher-win-x64.zip
|
bd1fc311f17fcd26c438449c1ddbb82a30261824396ba18e7f0831f02d719f78 KSwitcher-win-x64.zip
|
||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,59,0
|
FILEVERSION 0,6,62,0
|
||||||
PRODUCTVERSION 0,6,59,0
|
PRODUCTVERSION 0,6,62,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.59"
|
VALUE "FileVersion", "0.6.62"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+23
-8
@@ -986,21 +986,21 @@ void App::refreshChatContext()
|
|||||||
HWND target = GetForegroundWindow();
|
HWND target = GetForegroundWindow();
|
||||||
chatWindow = target;
|
chatWindow = target;
|
||||||
activeChatTitle = chatTitle(target);
|
activeChatTitle = chatTitle(target);
|
||||||
activeChat = settings.chatCycles.empty() ? L"" : windowChatKey(target);
|
activeChat = windowLanguageProfileKey(settings, target);
|
||||||
}
|
}
|
||||||
void App::switchChatLayout(bool reverse)
|
void App::switchChatLayout(bool reverse)
|
||||||
{
|
{
|
||||||
HWND target = chatWindow;
|
HWND target = chatWindow;
|
||||||
auto key = activeChat;
|
auto key = activeChat;
|
||||||
if (!target || target != GetForegroundWindow() || key.empty() || windowChatKey(target) != key ||
|
if (!target || target != GetForegroundWindow() || key.empty() ||
|
||||||
!settings.chatCycles.contains(key) || busy.exchange(true)) return;
|
windowLanguageProfileKey(settings, target) != key || busy.exchange(true)) return;
|
||||||
auto cycle = chatCycle(settings, key, layouts);
|
auto cycle = chatCycle(settings, key, layouts);
|
||||||
auto available = layouts;
|
auto available = layouts;
|
||||||
resetInput();
|
resetInput();
|
||||||
enqueue([this, target, key, cycle, available, reverse]
|
enqueue([this, target, key, cycle, available, reverse, profileSettings = settings]
|
||||||
{
|
{
|
||||||
struct Reset { std::atomic<bool> &busy; ~Reset() { busy = false; } } reset{busy};
|
struct Reset { std::atomic<bool> &busy; ~Reset() { busy = false; } } reset{busy};
|
||||||
auto valid = [&] { return GetForegroundWindow() == target && windowChatKey(target) == key; };
|
auto valid = [&] { return GetForegroundWindow() == target && windowLanguageProfileKey(profileSettings, target) == key; };
|
||||||
if (key.starts_with(L"telegram-web\n") && readChromeChat(target) != key) return;
|
if (key.starts_with(L"telegram-web\n") && readChromeChat(target) != key) return;
|
||||||
if (!valid()) return;
|
if (!valid()) return;
|
||||||
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
|
auto next = nextChatLayout(cycle, available, windowLayout(target), reverse);
|
||||||
@@ -1058,11 +1058,11 @@ bool App::handleKeyboard(const KBDLLHOOKSTRUCT &key, bool down, UINT mods)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool chatEnabled = false;
|
bool chatEnabled = false;
|
||||||
if (down && ChatSwitchHook::candidate(key.vkCode, mods) && !settings.chatCycles.empty() &&
|
if (down && ChatSwitchHook::candidate(key.vkCode, mods) && (!settings.chatCycles.empty() || !settings.appLanguageSets.empty()) &&
|
||||||
!isHotkey(key.vkCode, mods))
|
!isHotkey(key.vkCode, mods))
|
||||||
{
|
{
|
||||||
refreshChatContext();
|
refreshChatContext();
|
||||||
chatEnabled = !activeChat.empty() && settings.chatCycles.contains(activeChat);
|
chatEnabled = !activeChat.empty();
|
||||||
}
|
}
|
||||||
const int chatAction = chatSwitchHook.update(key.vkCode, down, mods, chatEnabled);
|
const int chatAction = chatSwitchHook.update(key.vkCode, down, mods, chatEnabled);
|
||||||
if (chatAction)
|
if (chatAction)
|
||||||
@@ -1314,7 +1314,7 @@ void App::transform(int command, int snippet)
|
|||||||
} reset{busy, window, preDeferred};
|
} reset{busy, window, preDeferred};
|
||||||
auto effectiveChat = requestedChat;
|
auto effectiveChat = requestedChat;
|
||||||
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
|
if (lower(processName(target)) == L"chrome") effectiveChat = readChromeChat(target);
|
||||||
config.cycle = chatCycle(config, effectiveChat, available);
|
config.cycle = chatCycle(config, languageProfileKey(config, target, effectiveChat), available);
|
||||||
auto start = GetTickCount64();
|
auto start = GetTickCount64();
|
||||||
std::wstring reason = L"cancelled-before-selection";
|
std::wstring reason = L"cancelled-before-selection";
|
||||||
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
std::wstring text, result, expected, actual, verification = L"not-attempted";
|
||||||
@@ -2294,6 +2294,20 @@ 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();
|
if(app->lanService&&app->lanService->tickInput(!app->busy&&!app->suspended&&!app->hotkeyCapture))app->resetLanInput();
|
||||||
return 0;
|
return 0;
|
||||||
case WM_TIMER:
|
case WM_TIMER:
|
||||||
|
if (wp == 8)
|
||||||
|
{
|
||||||
|
if (app->settings.appLanguageSets.empty() || app->busy || app->installingUpdate) return 0;
|
||||||
|
app->refreshChatContext();
|
||||||
|
if (!app->activeChat.starts_with(L"app\n")) return 0;
|
||||||
|
auto cycle = chatCycle(app->settings, app->activeChat, app->layouts);
|
||||||
|
auto current = windowLayout(app->chatWindow);
|
||||||
|
bool allowed = std::any_of(app->layouts.begin(), app->layouts.end(), [&](const Layout &layout) {
|
||||||
|
return layout.handle == current && std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end();
|
||||||
|
});
|
||||||
|
if (!allowed) app->switchChatLayout(false);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (wp == 7)
|
if (wp == 7)
|
||||||
{
|
{
|
||||||
if (app->lanService)
|
if (app->lanService)
|
||||||
@@ -2452,6 +2466,7 @@ int App::execute()
|
|||||||
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
|
||||||
SetTimer(window, 1, 60000, nullptr);
|
SetTimer(window, 1, 60000, nullptr);
|
||||||
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
if (!smoke) SetTimer(window, 6, 1000, nullptr);
|
||||||
|
if (!smoke) SetTimer(window, 8, 250, nullptr);
|
||||||
SetThreadExecutionState(ES_CONTINUOUS |
|
SetThreadExecutionState(ES_CONTINUOUS |
|
||||||
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
|
||||||
if (!registrationWarning.empty())
|
if (!registrationWarning.empty())
|
||||||
|
|||||||
+1
-1
@@ -257,7 +257,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
|
|||||||
{
|
{
|
||||||
auto replacement = settings.autoReplacements.find(small);
|
auto replacement = settings.autoReplacements.find(small);
|
||||||
if (replacement != settings.autoReplacements.end() &&
|
if (replacement != settings.autoReplacements.end() &&
|
||||||
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
|
validAutoCorrection(replacement->second, alphabet))
|
||||||
return sameLayout(replacement->second);
|
return sameLayout(replacement->second);
|
||||||
}
|
}
|
||||||
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
|
||||||
|
|||||||
+32
-2
@@ -92,13 +92,43 @@ inline std::wstring windowChatKey(HWND window)
|
|||||||
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
|
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
|
||||||
return window ? chatKey(processName(window), chatTitle(window)) : L"";
|
return window ? chatKey(processName(window), chatTitle(window)) : L"";
|
||||||
}
|
}
|
||||||
|
inline std::wstring applicationName(std::wstring process)
|
||||||
|
{
|
||||||
|
process = lower(process);
|
||||||
|
if (process.ends_with(L".exe")) process.resize(process.size() - 4);
|
||||||
|
return process;
|
||||||
|
}
|
||||||
|
inline const AppLanguageSet *applicationLanguageSet(const Settings &settings, const std::wstring &process)
|
||||||
|
{
|
||||||
|
auto name = applicationName(process);
|
||||||
|
if (name.empty()) return nullptr;
|
||||||
|
for (const auto &[label, set] : settings.appLanguageSets)
|
||||||
|
for (const auto &app : set.applications)
|
||||||
|
if (applicationName(app) == name) return &set;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::wstring languageProfileKey(const Settings &settings, HWND window, const std::wstring &chat)
|
||||||
|
{
|
||||||
|
if (!chat.empty() && settings.chatCycles.contains(chat)) return chat;
|
||||||
|
if (settings.appLanguageSets.empty()) return {};
|
||||||
|
auto app = applicationName(processName(window));
|
||||||
|
return applicationLanguageSet(settings, app) ? L"app\n" + app : L"";
|
||||||
|
}
|
||||||
|
inline std::wstring windowLanguageProfileKey(const Settings &settings, HWND window)
|
||||||
|
{
|
||||||
|
return window ? languageProfileKey(settings, window, settings.chatCycles.empty() ? L"" : windowChatKey(window)) : L"";
|
||||||
|
}
|
||||||
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
|
inline std::vector<std::wstring> chatCycle(const Settings &settings, const std::wstring &key,
|
||||||
const std::vector<Layout> &layouts)
|
const std::vector<Layout> &layouts)
|
||||||
{
|
{
|
||||||
auto found = settings.chatCycles.find(key);
|
auto found = settings.chatCycles.find(key);
|
||||||
if (key.empty() || found == settings.chatCycles.end()) return settings.cycle;
|
const std::vector<std::wstring> *cycle = found == settings.chatCycles.end() ? nullptr : &found->second;
|
||||||
|
if (key.starts_with(L"app\n"))
|
||||||
|
if (auto set = applicationLanguageSet(settings, key.substr(4))) cycle = &set->languages;
|
||||||
|
if (!cycle) return settings.cycle;
|
||||||
std::vector<std::wstring> result;
|
std::vector<std::wstring> result;
|
||||||
for (const auto &id : found->second)
|
for (const auto &id : *cycle)
|
||||||
if (std::any_of(layouts.begin(), layouts.end(), [&](const Layout &l) { return l.id == id; }))
|
if (std::any_of(layouts.begin(), layouts.end(), [&](const Layout &l) { return l.id == id; }))
|
||||||
result.push_back(id);
|
result.push_back(id);
|
||||||
return result.empty() ? settings.cycle : result;
|
return result.empty() ? settings.cycle : result;
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ int snippetAppPriority(const Snippet &snippet, const std::wstring &process)
|
|||||||
for (const auto &app : snippet.apps) if (snippetAppName(app) == name && !name.empty()) return 2;
|
for (const auto &app : snippet.apps) if (snippetAppName(app) == name && !name.empty()) return 2;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet)
|
||||||
|
{
|
||||||
|
if (text.empty() || text.size() > 32) return false;
|
||||||
|
auto isLetter = [&](wchar_t c) { return alphabet.find(c) != alphabet.npos; };
|
||||||
|
for (size_t i = 0; i < text.size(); ++i)
|
||||||
|
{
|
||||||
|
if (isLetter(text[i])) continue;
|
||||||
|
if (text[i] != L' ' && text[i] != L'\'' && text[i] != L'\u2019' && text[i] != L'\u02bc')
|
||||||
|
return false;
|
||||||
|
if (i == 0 || i + 1 == text.size() || !isLetter(text[i - 1]) || !isLetter(text[i + 1]))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
|
int HotkeyHook::update(UINT key, bool down, UINT modifiers,
|
||||||
const std::vector<std::pair<int, Hotkey>> &bindings)
|
const std::vector<std::pair<int, Hotkey>> &bindings)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct Layout
|
|||||||
bool hasLetter(wchar_t ch) const;
|
bool hasLetter(wchar_t ch) const;
|
||||||
};
|
};
|
||||||
bool letter(wchar_t ch);
|
bool letter(wchar_t ch);
|
||||||
|
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet);
|
||||||
std::wstring lower(std::wstring text);
|
std::wstring lower(std::wstring text);
|
||||||
std::wstring upper(std::wstring text);
|
std::wstring upper(std::wstring text);
|
||||||
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
std::wstring convert(const std::wstring &, const Layout &, const Layout &, bool force = false);
|
||||||
@@ -52,6 +53,11 @@ struct Hotkey
|
|||||||
static std::optional<Hotkey> parse(std::wstring);
|
static std::optional<Hotkey> parse(std::wstring);
|
||||||
std::wstring format() const;
|
std::wstring format() const;
|
||||||
};
|
};
|
||||||
|
struct AppLanguageSet
|
||||||
|
{
|
||||||
|
std::vector<std::wstring> languages, applications;
|
||||||
|
bool operator==(const AppLanguageSet &) const = default;
|
||||||
|
};
|
||||||
struct Settings
|
struct Settings
|
||||||
{
|
{
|
||||||
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
|
||||||
@@ -81,6 +87,7 @@ struct Settings
|
|||||||
std::vector<std::wstring> cycle, direct, directIds;
|
std::vector<std::wstring> cycle, direct, directIds;
|
||||||
// Exact application + visible chat name; an absent entry uses the global cycle.
|
// Exact application + visible chat name; an absent entry uses the global cycle.
|
||||||
std::map<std::wstring, std::vector<std::wstring>> chatCycles;
|
std::map<std::wstring, std::vector<std::wstring>> chatCycles;
|
||||||
|
std::map<std::wstring, AppLanguageSet> appLanguageSets;
|
||||||
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
|
||||||
std::vector<Snippet> snippets;
|
std::vector<Snippet> snippets;
|
||||||
std::vector<std::wstring> snippetTombstones;
|
std::vector<std::wstring> snippetTombstones;
|
||||||
|
|||||||
+50
-3
@@ -58,9 +58,35 @@ bool validId(const std::wstring &id)
|
|||||||
{
|
{
|
||||||
return !id.empty() && id.size() < 80 && id.find_first_not_of(L"0123456789abcdef-") == id.npos;
|
return !id.empty() && id.size() < 80 && id.find_first_not_of(L"0123456789abcdef-") == id.npos;
|
||||||
}
|
}
|
||||||
|
void validateLanguageCycle(const std::vector<std::wstring> &cycle)
|
||||||
|
{
|
||||||
|
if (cycle.empty() || cycle.size() > 64) throw std::runtime_error("Select between 1 and 64 languages.");
|
||||||
|
std::set<std::wstring> seen;
|
||||||
|
for (const auto &id : cycle)
|
||||||
|
if (id.size() != 12 || !id.starts_with(L"win:") ||
|
||||||
|
id.find_first_not_of(L"0123456789abcdefABCDEF", 4) != id.npos || !seen.insert(lower(id)).second)
|
||||||
|
throw std::runtime_error("Invalid or duplicate language layout.");
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
void validateSettings(const Settings &s)
|
void validateSettings(const Settings &s)
|
||||||
{
|
{
|
||||||
|
if (s.appLanguageSets.size() > 1000) throw std::runtime_error("Too many application language sets");
|
||||||
|
std::set<std::wstring> assigned;
|
||||||
|
for (const auto &[name, set] : s.appLanguageSets)
|
||||||
|
{
|
||||||
|
if (name.empty() || name.size() > 128 || name.find_first_of(L"\r\n") != name.npos)
|
||||||
|
throw std::runtime_error("Enter a language set name (up to 128 characters).");
|
||||||
|
if (set.applications.size() > 1000) throw std::runtime_error("Too many applications in a language set");
|
||||||
|
for (auto app : set.applications)
|
||||||
|
{
|
||||||
|
app = lower(app);
|
||||||
|
if (app.ends_with(L".exe")) app.resize(app.size() - 4);
|
||||||
|
if (app.empty() || app.size() > 260 || app.find_first_of(L"\\/:*?\"<>|\r\n") != app.npos)
|
||||||
|
throw std::runtime_error("Use an application executable name, not a path.");
|
||||||
|
if (!assigned.insert(app).second) throw std::runtime_error("An application can belong to only one language set.");
|
||||||
|
}
|
||||||
|
validateLanguageCycle(set.languages);
|
||||||
|
}
|
||||||
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
|
if (s.chatCycles.size() > 1000) throw std::runtime_error("Too many chat language profiles");
|
||||||
for (const auto &[key, cycle] : s.chatCycles)
|
for (const auto &[key, cycle] : s.chatCycles)
|
||||||
{
|
{
|
||||||
@@ -138,10 +164,9 @@ void validateSettings(const Settings &s)
|
|||||||
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
valid |= !from.empty() && !to.empty() && from.size() <= 32 && to.size() <= 32 &&
|
||||||
from == lower(from) && to == lower(to) && from != to &&
|
from == lower(from) && to == lower(to) && from != to &&
|
||||||
from.find_first_not_of(alphabet) == from.npos &&
|
from.find_first_not_of(alphabet) == from.npos &&
|
||||||
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
|
validAutoCorrection(to, alphabet);
|
||||||
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos;
|
|
||||||
if (!valid)
|
if (!valid)
|
||||||
throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between correction words are allowed.");
|
throw std::runtime_error("Use a lowercase typo of 1-32 letters and a different correction of 1-32 characters in the same language. Single spaces between words and apostrophes between letters are allowed.");
|
||||||
}
|
}
|
||||||
for (const auto &[language, words] : s.autoWords)
|
for (const auto &[language, words] : s.autoWords)
|
||||||
{
|
{
|
||||||
@@ -264,6 +289,19 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
|
|||||||
s.cycle.push_back(w);
|
s.cycle.push_back(w);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (j.contains("appLanguageSets"))
|
||||||
|
{
|
||||||
|
if (!j.at("appLanguageSets").is_object()) throw std::runtime_error("Invalid application language sets");
|
||||||
|
for (const auto &[name, item] : j.at("appLanguageSets").items())
|
||||||
|
{
|
||||||
|
AppLanguageSet set;
|
||||||
|
if (!item.at("languages").is_array() || !item.at("applications").is_array())
|
||||||
|
throw std::runtime_error("Invalid application language set");
|
||||||
|
for (const auto &id : item.at("languages")) set.languages.push_back(wide(id.get<std::string>()));
|
||||||
|
for (const auto &app : item.at("applications")) set.applications.push_back(wide(app.get<std::string>()));
|
||||||
|
s.appLanguageSets[wide(name)] = std::move(set);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (j.contains("chatCycles"))
|
if (j.contains("chatCycles"))
|
||||||
{
|
{
|
||||||
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
|
if (!j.at("chatCycles").is_object()) throw std::runtime_error("Invalid chat profiles object");
|
||||||
@@ -336,6 +374,15 @@ Bytes encodeSettings(const Settings &s)
|
|||||||
{"screenshotHotkey", utf8(s.screenshot)},
|
{"screenshotHotkey", utf8(s.screenshot)},
|
||||||
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
|
||||||
j["layouts"] = Json::array();
|
j["layouts"] = Json::array();
|
||||||
|
j["appLanguageSets"] = Json::object();
|
||||||
|
for (const auto &[name, set] : s.appLanguageSets)
|
||||||
|
{
|
||||||
|
auto &item = j["appLanguageSets"][utf8(name)];
|
||||||
|
item["languages"] = Json::array();
|
||||||
|
item["applications"] = Json::array();
|
||||||
|
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["chatCycles"] = Json::object();
|
j["chatCycles"] = Json::object();
|
||||||
for (const auto &[key, cycle] : s.chatCycles)
|
for (const auto &[key, cycle] : s.chatCycles)
|
||||||
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
|
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
|
||||||
|
|||||||
+1
-1
@@ -226,7 +226,7 @@ Bytes sharedSyncSettings(const Settings &s)
|
|||||||
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
|
||||||
});
|
});
|
||||||
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract",
|
||||||
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key);
|
"historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock", "appLanguageSets"}) j.erase(key);
|
||||||
return bytes(canonicalProfile(j));
|
return bytes(canonicalProfile(j));
|
||||||
}
|
}
|
||||||
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
|
||||||
|
|||||||
+40
-18
@@ -1060,12 +1060,27 @@ struct ChatLanguagesView
|
|||||||
HWND parent{};
|
HWND parent{};
|
||||||
SettingsView *settings{};
|
SettingsView *settings{};
|
||||||
std::vector<std::wstring> keys;
|
std::vector<std::wstring> keys;
|
||||||
|
Settings draft;
|
||||||
|
int selection = -1;
|
||||||
|
bool dirty{};
|
||||||
};
|
};
|
||||||
|
void collectChatLanguages(HWND w, ChatLanguagesView *v)
|
||||||
|
{
|
||||||
|
if (!v->dirty || v->selection < 0) return;
|
||||||
|
std::vector<std::wstring> cycle;
|
||||||
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
|
if (SendDlgItemMessageW(w, 711, LB_GETSEL, i, 0) > 0)
|
||||||
|
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
|
||||||
|
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
|
||||||
|
v->draft.chatCycles[v->keys[v->selection]] = std::move(cycle);
|
||||||
|
v->dirty = false;
|
||||||
|
}
|
||||||
void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
||||||
{
|
{
|
||||||
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||||
if (index < 0 || index >= (int)v->keys.size()) return;
|
if (index < 0 || index >= (int)v->keys.size()) return;
|
||||||
const auto &draft = v->settings->draft;
|
v->selection = index;
|
||||||
|
const auto &draft = v->draft;
|
||||||
auto saved = draft.chatCycles.find(v->keys[index]);
|
auto saved = draft.chatCycles.find(v->keys[index]);
|
||||||
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
|
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
|
||||||
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
@@ -1075,7 +1090,7 @@ void fillChatLanguages(HWND w, ChatLanguagesView *v)
|
|||||||
std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end(), i);
|
std::find(cycle.begin(), cycle.end(), layout.id) != cycle.end(), i);
|
||||||
}
|
}
|
||||||
value(w, 712, saved == draft.chatCycles.end() ? L"No profile yet. Select the languages for this chat." :
|
value(w, 712, saved == draft.chatCycles.end() ? L"No profile yet. Select the languages for this chat." :
|
||||||
L"Saved profile. Select languages, or remove it to use the defaults.");
|
L"Chat profile. Select languages, or remove it to use the defaults.");
|
||||||
EnableWindow(GetDlgItem(w, 713), saved != draft.chatCycles.end());
|
EnableWindow(GetDlgItem(w, 713), saved != draft.chatCycles.end());
|
||||||
}
|
}
|
||||||
LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
||||||
@@ -1087,25 +1102,29 @@ LRESULT CALLBACK chatLanguagesProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (msg == WM_COMMAND)
|
if (msg == WM_COMMAND)
|
||||||
{
|
{
|
||||||
int id = LOWORD(wp);
|
int id = LOWORD(wp);
|
||||||
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE) { fillChatLanguages(w, v); return 0; }
|
if (id == 711 && HIWORD(wp) == LBN_SELCHANGE) { v->dirty = true; return 0; }
|
||||||
|
if (id == 710 && HIWORD(wp) == LBN_SELCHANGE)
|
||||||
|
{
|
||||||
|
try { collectChatLanguages(w, v); }
|
||||||
|
catch (...) { SendDlgItemMessageW(w, 710, LB_SETCURSEL, v->selection, 0); throw; }
|
||||||
|
fillChatLanguages(w, v);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (id == IDOK || id == 713)
|
if (id == IDOK || id == 713)
|
||||||
{
|
{
|
||||||
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
|
||||||
if (selected < 0 || selected >= (int)v->keys.size())
|
if (selected < 0 || selected >= (int)v->keys.size())
|
||||||
throw std::runtime_error("Open Telegram, Telegram Web K in Chrome, or Teams, then reopen this dialog. Viber OCR is disabled.");
|
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog.");
|
||||||
auto next = v->settings->draft;
|
if (id == 713)
|
||||||
if (id == 713) next.chatCycles.erase(v->keys[selected]);
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
std::vector<std::wstring> cycle;
|
v->draft.chatCycles.erase(v->keys[selected]);
|
||||||
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
v->dirty = false;
|
||||||
if (SendDlgItemMessageW(w, 711, LB_GETSEL, i, 0) > 0)
|
fillChatLanguages(w, v);
|
||||||
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
|
return 0;
|
||||||
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
|
|
||||||
next.chatCycles[v->keys[selected]] = std::move(cycle);
|
|
||||||
}
|
}
|
||||||
validateSettings(next);
|
collectChatLanguages(w, v);
|
||||||
v->settings->draft.chatCycles = std::move(next.chatCycles);
|
validateSettings(v->draft);
|
||||||
|
v->settings->draft.chatCycles = std::move(v->draft.chatCycles);
|
||||||
DestroyWindow(w);
|
DestroyWindow(w);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -1140,6 +1159,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
|||||||
}, (LPARAM)&context);
|
}, (LPARAM)&context);
|
||||||
HWND w = makeWindow(*settings->app, L"KSwitcher.ChatLanguages", L"Languages for a chat", chatLanguagesProc, 640, 524);
|
HWND w = makeWindow(*settings->app, L"KSwitcher.ChatLanguages", L"Languages for a chat", chatLanguagesProc, 640, 524);
|
||||||
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
|
auto *v = new ChatLanguagesView{parent, settings, {keys.begin(), keys.end()}};
|
||||||
|
v->draft = settings->draft;
|
||||||
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
|
||||||
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
|
||||||
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
|
label(w, L"Telegram / Telegram Web (Chrome) / Teams; Viber inactive", 18, 16, 604);
|
||||||
@@ -1153,12 +1173,12 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
|||||||
}
|
}
|
||||||
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
|
control(w, L"STATIC", L"Open a chat, then reopen this dialog to add it.", 0, 712, 18, 194, 604, 26);
|
||||||
label(w, L"Click languages to include (saved in the order shown)", 18, 228, 604);
|
label(w, L"Click languages to include (saved in the order shown)", 18, 228, 604);
|
||||||
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||||
711, 18, 256, 604, 132);
|
711, 18, 256, 604, 132);
|
||||||
for (int i : settings->order)
|
for (int i : settings->order)
|
||||||
SendDlgItemMessageW(w, 711, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
|
SendDlgItemMessageW(w, 711, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
|
||||||
label(w, L"Applies to Alt+Shift, Win+Space and KSwitcher Next layout.", 18, 402, 604);
|
label(w, L"Applies to Alt+Shift, Win+Space and KSwitcher Next layout.", 18, 402, 604);
|
||||||
label(w, L"Matched by chat name. Identical names share a profile. Then Save settings.", 18, 430, 604);
|
label(w, L"Switch chats freely. Use languages, then Save settings.", 18, 430, 604);
|
||||||
button(w, L"&Remove profile", 713, 18, 476, 170);
|
button(w, L"&Remove profile", 713, 18, 476, 170);
|
||||||
button(w, L"&Use languages", IDOK, 300, 476, 170);
|
button(w, L"&Use languages", IDOK, 300, 476, 170);
|
||||||
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
|
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
|
||||||
@@ -1169,6 +1189,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
|
|||||||
showRequestedWindow(w);
|
showRequestedWindow(w);
|
||||||
SetFocus(GetDlgItem(w, 710));
|
SetFocus(GetDlgItem(w, 710));
|
||||||
}
|
}
|
||||||
|
#include "ui_app_languages.inl"
|
||||||
void collectLayout(HWND w, SettingsView *v)
|
void collectLayout(HWND w, SettingsView *v)
|
||||||
{
|
{
|
||||||
if (v->selection < 0 || v->selection >= (int)v->order.size())
|
if (v->selection < 0 || v->selection >= (int)v->order.size())
|
||||||
@@ -1723,6 +1744,7 @@ LRESULT CALLBACK settingsProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (id == 257) { showOcrOptions(w, v); return 0; }
|
if (id == 257) { showOcrOptions(w, v); return 0; }
|
||||||
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
if (id == 258) { showSyncOptions(w, *v->app); return 0; }
|
||||||
if (id == 262) { showLan(w, *v->app); return 0; }
|
if (id == 262) { showLan(w, *v->app); return 0; }
|
||||||
|
if (id == 263) { collectLayout(w, v); showAppLanguages(w, v); return 0; }
|
||||||
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
|
if (id == 259) { collectLayout(w, v); showChatLanguages(w, v); return 0; }
|
||||||
if (id == 230)
|
if (id == 230)
|
||||||
{
|
{
|
||||||
@@ -2153,7 +2175,7 @@ void showSettings(App &app, bool visible)
|
|||||||
label(w, L"Backspace / Delete clears it", 454, 270, 230);
|
label(w, L"Backspace / Delete clears it", 454, 270, 230);
|
||||||
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
|
button(w, L"Re&fresh layouts", 253, 454, 322, 230);
|
||||||
button(w, L"Languages for a &chat...", 259, 24, 378, 230);
|
button(w, L"Languages for a &chat...", 259, 24, 378, 230);
|
||||||
label(w, L"Telegram / Telegram Web (Chrome) / Teams", 272, 382, 412);
|
button(w, L"Languages for &applications...", 263, 272, 378, 300);
|
||||||
page();
|
page();
|
||||||
|
|
||||||
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
// Included inside ui.cpp's anonymous namespace, alongside the settings editors.
|
||||||
|
std::wstring trimApplicationText(const std::wstring &text)
|
||||||
|
{
|
||||||
|
auto first = text.find_first_not_of(L" \t\r\n");
|
||||||
|
return first == text.npos ? L"" : text.substr(first, text.find_last_not_of(L" \t\r\n") - first + 1);
|
||||||
|
}
|
||||||
|
struct AppLanguagesView
|
||||||
|
{
|
||||||
|
HWND parent{};
|
||||||
|
SettingsView *settings{};
|
||||||
|
std::map<std::wstring, std::vector<std::wstring>> profiles;
|
||||||
|
std::vector<std::wstring> 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();
|
||||||
|
EnableWindow(GetDlgItem(w, 722), selected);
|
||||||
|
SendDlgItemMessageW(w, 722, LB_SETSEL, FALSE, -1);
|
||||||
|
if (!selected)
|
||||||
|
{
|
||||||
|
EnableWindow(GetDlgItem(w, 725), FALSE);
|
||||||
|
showAppLanguageStatus(w, *v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto found = v->profiles.find(v->applications[v->selection]);
|
||||||
|
const auto &cycle = found == v->profiles.end() ? v->settings->draft.cycle : found->second;
|
||||||
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
|
{
|
||||||
|
const auto &id = v->settings->app->layouts[v->settings->order[i]].id;
|
||||||
|
SendDlgItemMessageW(w, 722, LB_SETSEL,
|
||||||
|
std::find(cycle.begin(), cycle.end(), id) != cycle.end(), i);
|
||||||
|
}
|
||||||
|
showAppLanguageStatus(w, *v);
|
||||||
|
EnableWindow(GetDlgItem(w, 725), found != v->profiles.end());
|
||||||
|
}
|
||||||
|
void collectAppLanguages(HWND w, AppLanguagesView *v)
|
||||||
|
{
|
||||||
|
if (v->selection < 0 || !v->dirty) return;
|
||||||
|
std::vector<std::wstring> cycle;
|
||||||
|
for (size_t i = 0; i < v->settings->order.size(); ++i)
|
||||||
|
if (SendDlgItemMessageW(w, 722, LB_GETSEL, i, 0) > 0)
|
||||||
|
cycle.push_back(v->settings->app->layouts[v->settings->order[i]].id);
|
||||||
|
if (cycle.empty()) throw std::runtime_error("Select at least one language.");
|
||||||
|
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);
|
||||||
|
if (id == 722 && HIWORD(wp) == LBN_SELCHANGE)
|
||||||
|
{
|
||||||
|
v->dirty = true;
|
||||||
|
EnableWindow(GetDlgItem(w, 725), TRUE);
|
||||||
|
showAppLanguageStatus(w, *v);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == 720 && HIWORD(wp) == LBN_SELCHANGE)
|
||||||
|
{
|
||||||
|
int next = (int)SendDlgItemMessageW(w, 720, LB_GETCURSEL, 0, 0);
|
||||||
|
try { collectAppLanguages(w, v); }
|
||||||
|
catch (...) { SendDlgItemMessageW(w, 720, LB_SETCURSEL, v->selection, 0); throw; }
|
||||||
|
v->selection = next;
|
||||||
|
fillAppLanguages(w, v);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == 725 && v->selection >= 0)
|
||||||
|
{
|
||||||
|
v->profiles.erase(v->applications[v->selection]);
|
||||||
|
v->dirty = false;
|
||||||
|
fillAppLanguages(w, v);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == 727)
|
||||||
|
{
|
||||||
|
auto app = applicationName(trimApplicationText(value(w, 726)));
|
||||||
|
Settings check;
|
||||||
|
check.appLanguageSets[L"check"] = {{L"win:04090409"}, {app}};
|
||||||
|
validateSettings(check);
|
||||||
|
selectAppLanguageProfile(w, v, app);
|
||||||
|
value(w, 726, L"");
|
||||||
|
SetFocus(GetDlgItem(w, 722));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDOK)
|
||||||
|
{
|
||||||
|
collectAppLanguages(w, v);
|
||||||
|
auto next = v->settings->draft;
|
||||||
|
next.appLanguageSets.clear();
|
||||||
|
// Keep the existing on-disk format, but save independent application profiles.
|
||||||
|
for (const auto &[app, cycle] : v->profiles) next.appLanguageSets[app] = {cycle, {app}};
|
||||||
|
validateSettings(next);
|
||||||
|
v->settings->draft.appLanguageSets = std::move(next.appLanguageSets);
|
||||||
|
DestroyWindow(w);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (id == IDCANCEL) { DestroyWindow(w); return 0; }
|
||||||
|
}
|
||||||
|
if (msg == WM_CLOSE) { DestroyWindow(w); return 0; }
|
||||||
|
if (msg == WM_DPICHANGED) { resizeForDpi(w, wp, lp); return 0; }
|
||||||
|
if (msg == WM_NCDESTROY)
|
||||||
|
{
|
||||||
|
EnableWindow(v->parent, TRUE);
|
||||||
|
SetForegroundWindow(v->parent);
|
||||||
|
disposeFont(w);
|
||||||
|
delete v;
|
||||||
|
SetWindowLongPtrW(w, GWLP_USERDATA, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (const std::exception &e) { error(w, e); }
|
||||||
|
return DefWindowProcW(w, msg, wp, lp);
|
||||||
|
}
|
||||||
|
void showAppLanguages(HWND parent, SettingsView *settings)
|
||||||
|
{
|
||||||
|
std::set<std::wstring> apps;
|
||||||
|
for (const auto &[name, set] : settings->draft.appLanguageSets)
|
||||||
|
for (const auto &app : set.applications) apps.insert(applicationName(app));
|
||||||
|
EnumWindows([](HWND candidate, LPARAM context) -> BOOL {
|
||||||
|
if (IsWindowVisible(candidate) && !chatTitle(candidate).empty())
|
||||||
|
{
|
||||||
|
auto name = applicationName(processName(candidate));
|
||||||
|
if (!name.empty()) ((std::set<std::wstring> *)context)->insert(name);
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}, (LPARAM)&apps);
|
||||||
|
HWND w = makeWindow(*settings->app, L"KSwitcher.AppLanguages", L"Languages for applications", appLanguagesProc, 720, 500);
|
||||||
|
auto *v = new AppLanguagesView{parent, settings};
|
||||||
|
for (const auto &[name, set] : settings->draft.appLanguageSets)
|
||||||
|
for (const auto &app : set.applications) v->profiles[applicationName(app)] = set.languages;
|
||||||
|
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, 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);
|
||||||
|
control(w, L"LISTBOX", L"", WS_TABSTOP | WS_VSCROLL | LBS_NOTIFY | LBS_MULTIPLESEL | LBS_NOINTEGRALHEIGHT | WS_BORDER,
|
||||||
|
722, 316, 44, 386, 280);
|
||||||
|
for (int i : settings->order)
|
||||||
|
SendDlgItemMessageW(w, 722, LB_ADDSTRING, 0, (LPARAM)settings->app->layouts[i].name.c_str());
|
||||||
|
for (const auto &app : v->applications) SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
|
||||||
|
control(w, L"STATIC", L"", 0, 721, 18, 338, 684, 22);
|
||||||
|
label(w, L"Add an executable name (e.g. Codex.exe)", 18, 372, 684);
|
||||||
|
edit(w, 726, 18, 400, 526);
|
||||||
|
button(w, L"&Add application", 727, 558, 398, 144);
|
||||||
|
label(w, L"Switch applications freely. Use languages, then Save settings. Chat profiles take priority.", 18, 436, 684);
|
||||||
|
button(w, L"Use &defaults", 725, 18, 466, 170);
|
||||||
|
button(w, L"&Use languages", IDOK, 414, 466, 144);
|
||||||
|
button(w, L"&Cancel", IDCANCEL, 572, 466, 130);
|
||||||
|
if (!v->applications.empty())
|
||||||
|
{
|
||||||
|
v->selection = 0;
|
||||||
|
SendDlgItemMessageW(w, 720, LB_SETCURSEL, 0, 0);
|
||||||
|
}
|
||||||
|
fillAppLanguages(w, v);
|
||||||
|
EnableWindow(parent, FALSE);
|
||||||
|
showRequestedWindow(w);
|
||||||
|
SetFocus(GetDlgItem(w, v->selection < 0 ? 726 : 720));
|
||||||
|
}
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.59";
|
inline constexpr wchar_t AppVersion[] = L"0.6.62";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ int main()
|
|||||||
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
|
||||||
ks::encodePng(ks::decodeImage(clip)));
|
ks::encodePng(ks::decodeImage(clip)));
|
||||||
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
|
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
|
||||||
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
|
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
|
||||||
require(!saveEnabled(),"cancelled chat edits do not enable Save");
|
require(!saveEnabled(),"cancelled chat edits do not enable Save");
|
||||||
@@ -235,6 +236,7 @@ int main()
|
|||||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||||
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
|
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
|
||||||
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
||||||
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
|
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
|
||||||
require(saveEnabled() == (app.layouts.size()>1),"accepted chat edits refresh Save immediately");
|
require(saveEnabled() == (app.layouts.size()>1),"accepted chat edits refresh Save immediately");
|
||||||
@@ -250,6 +252,8 @@ int main()
|
|||||||
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
SendDlgItemMessageW(dialog, 710, LB_SETCURSEL, index, 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
|
||||||
SendMessageW(dialog, WM_COMMAND, 713, 0);
|
SendMessageW(dialog, WM_COMMAND, 713, 0);
|
||||||
|
require(IsWindow(dialog), "chat reset keeps dialog open");
|
||||||
|
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
||||||
require(saveEnabled() && status().find(L"Unsaved changes")==0,"removing child profile immediately marks parent dirty");
|
require(saveEnabled() && status().find(L"Unsaved changes")==0,"removing child profile immediately marks parent dirty");
|
||||||
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
|
||||||
GetClientRect(parent,&rect); dc=GetDC(parent);
|
GetClientRect(parent,&rect); dc=GetDC(parent);
|
||||||
@@ -258,6 +262,97 @@ int main()
|
|||||||
ks::encodePng(ks::decodeImage(clip)));
|
ks::encodePng(ks::decodeImage(clip)));
|
||||||
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
|
require(!app.settings.chatCycles.contains(key), "remove profile restores default");
|
||||||
|
// Existing shared sets become independent application drafts on edit.
|
||||||
|
app.settings.appLanguageSets[L"Legacy shared"] = {{app.layouts[0].id}, {L"Codex.exe", L"notepad"}};
|
||||||
|
ks::showSettings(app);
|
||||||
|
parent = app.settingsWindow;
|
||||||
|
SendMessageW(parent, WM_COMMAND, 263, 0);
|
||||||
|
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
|
||||||
|
require(dialog && !IsWindowEnabled(parent), "application language editor opens");
|
||||||
|
auto selectApp = [&](const wchar_t *name) {
|
||||||
|
int index = (int)SendDlgItemMessageW(dialog, 720, LB_FINDSTRINGEXACT, -1, (LPARAM)name);
|
||||||
|
require(index >= 0, "application listed");
|
||||||
|
SendDlgItemMessageW(dialog, 720, LB_SETCURSEL, index, 0);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(720, LBN_SELCHANGE), 0);
|
||||||
|
};
|
||||||
|
selectApp(L"codex");
|
||||||
|
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");
|
||||||
|
require(SendDlgItemMessageW(dialog, 722, LB_GETSELCOUNT, 0, 0) == (LRESULT)app.layouts.size(), "application navigation preserves drafts");
|
||||||
|
RedrawWindow(dialog, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN);
|
||||||
|
GetClientRect(dialog, &rect);
|
||||||
|
dc = GetDC(dialog);
|
||||||
|
clip = ks::screenshotClip(dc, rect.right, rect.bottom, {});
|
||||||
|
ReleaseDC(dialog, dc);
|
||||||
|
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"application-languages.png",
|
||||||
|
ks::encodePng(ks::decodeImage(clip)));
|
||||||
|
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
|
||||||
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
|
require(app.settings.appLanguageSets.size() == 1 && app.settings.appLanguageSets.contains(L"Legacy shared"), "cancel preserves legacy settings unchanged");
|
||||||
|
ks::showSettings(app);
|
||||||
|
parent = app.settingsWindow;
|
||||||
|
SendMessageW(parent, WM_COMMAND, 263, 0);
|
||||||
|
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
|
||||||
|
selectApp(L"codex");
|
||||||
|
SendDlgItemMessageW(dialog, 722, LB_SETSEL, TRUE, -1);
|
||||||
|
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(722, LBN_SELCHANGE), 0);
|
||||||
|
selectApp(L"notepad");
|
||||||
|
SetDlgItemTextW(dialog, 726, L"Fixture-App.EXE");
|
||||||
|
SendMessageW(dialog, WM_COMMAND, 727, 0);
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(dialog, 725)), "new application inherits defaults");
|
||||||
|
selectApp(L"codex");
|
||||||
|
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
||||||
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
|
require(app.settings.appLanguageSets.size() == 2, "viewing an app does not create a profile");
|
||||||
|
require(app.settings.appLanguageSets.at(L"codex").languages.size() == app.layouts.size(), "edited application languages saved");
|
||||||
|
require(app.settings.appLanguageSets.at(L"notepad").languages == std::vector<std::wstring>{app.layouts[0].id}, "other application retains original languages");
|
||||||
|
require(ks::loadSettings(root / L"settings.json", app.layouts).appLanguageSets == app.settings.appLanguageSets, "independent application profiles saved to disk");
|
||||||
|
ks::showSettings(app);
|
||||||
|
parent = app.settingsWindow;
|
||||||
|
SendMessageW(parent, WM_COMMAND, 263, 0);
|
||||||
|
dialog = findTestWindow(L"KSwitcher.AppLanguages", nullptr);
|
||||||
|
selectApp(L"codex");
|
||||||
|
SendMessageW(dialog, WM_COMMAND, 725, 0);
|
||||||
|
selectApp(L"notepad");
|
||||||
|
selectApp(L"codex");
|
||||||
|
require(!IsWindowEnabled(GetDlgItem(dialog, 725)), "default reset survives navigation");
|
||||||
|
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
|
||||||
|
SendMessageW(parent, WM_COMMAND, IDOK, 0);
|
||||||
|
require(!ks::applicationLanguageSet(app.settings, L"codex"), "use defaults removes only selected application profile");
|
||||||
|
require(ks::applicationLanguageSet(app.settings, L"notepad") != nullptr, "default reset leaves other application profile intact");
|
||||||
app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}};
|
app.settings.snippets = {{L";scope", L"Example", L"scope-fixture"}};
|
||||||
ks::showSettings(app); parent=app.settingsWindow;
|
ks::showSettings(app); parent=app.settingsWindow;
|
||||||
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
|
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
|
||||||
|
|||||||
+14
-1
@@ -592,6 +592,16 @@ int main(int argc, char **argv)
|
|||||||
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
|
||||||
options.autoNever.clear();
|
options.autoNever.clear();
|
||||||
options.autoReplacements[L"мамамила"] = L"мама мила";
|
options.autoReplacements[L"мамамила"] = L"мама мила";
|
||||||
|
for (const auto *text : {L"рев'ю", L"рев’ю", L"ревʼю", L"моє рев'ю"})
|
||||||
|
{
|
||||||
|
options.autoReplacements[L"ревю"] = text;
|
||||||
|
ks::validateSettings(options);
|
||||||
|
correction = edited.suggest(L"(Ревю)! ", 2, layouts, options);
|
||||||
|
auto expected = std::wstring(text);
|
||||||
|
expected[0] = ks::upper(expected.substr(0, 1))[0];
|
||||||
|
require(correction && correction->text == L"(" + expected + L")! " && correction->target == 2,
|
||||||
|
"apostrophe corrections preserve punctuation, capitalization and layout");
|
||||||
|
}
|
||||||
correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options);
|
correction = edited.suggest(L"(Мамамила)! ", 2, layouts, options);
|
||||||
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
|
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
|
||||||
"joined words split while preserving capitalization, punctuation and layout");
|
"joined words split while preserving capitalization, punctuation and layout");
|
||||||
@@ -718,7 +728,8 @@ int main(int argc, char **argv)
|
|||||||
settings.elevatedAutostart = true;
|
settings.elevatedAutostart = true;
|
||||||
settings.autoCorrect = true;
|
settings.autoCorrect = true;
|
||||||
settings.autoReplace = settings.autoTypos = true;
|
settings.autoReplace = settings.autoTypos = true;
|
||||||
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"}};
|
settings.autoReplacements = {{L"првиіт", L"привіт"}, {L"helo", L"hello"}, {L"мамамила", L"мама мила"},
|
||||||
|
{L"ревю", L"рев'ю"}, {L"пять", L"п’ять"}, {L"імя", L"імʼя"}};
|
||||||
settings.autoMaxRank = 10000;
|
settings.autoMaxRank = 10000;
|
||||||
settings.autoMinLength = 5;
|
settings.autoMinLength = 5;
|
||||||
settings.autoPrefixChars = L"(#";
|
settings.autoPrefixChars = L"(#";
|
||||||
@@ -739,6 +750,8 @@ int main(int argc, char **argv)
|
|||||||
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
|
{L"helloworld", L"hello\nworld"}, {L"hello world", L"helloworld"},
|
||||||
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
|
{L"helloworld", L"hello світ"}, {L"helloworld", L"hello world!"},
|
||||||
{L"helloworld", L"hello Hello"}, {L"helloworld", L""},
|
{L"helloworld", L"hello Hello"}, {L"helloworld", L""},
|
||||||
|
{L"review", L"'review"}, {L"review", L"review’"},
|
||||||
|
{L"review", L"re''view"}, {L"review", L"reʼ view"},
|
||||||
{L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}})
|
{L"helloworld", L"abcdefghijklmnopqrstuvwxyz abcdef"}})
|
||||||
{
|
{
|
||||||
auto invalid = settings;
|
auto invalid = settings;
|
||||||
|
|||||||
Reference in new issue
Block a user