Compare commits

..
18 changed files with 656 additions and 55 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
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)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
+57 -1
View File
@@ -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
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
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
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
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
@@ -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.
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
View File
@@ -1,22 +1,16 @@
# Latest Windows release
## v0.6.59 — 2026-11-09
## v0.6.62 — 2026-11-10
## Changes
- 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.
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.
## Validation
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.
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.
## 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.
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.
Publish stable `release-vX.Y.Z` tags matching the executable version. Keep `v0.4.2` as the last legacy tag.
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).
+1 -1
View File
@@ -1 +1 @@
f0d7d15534db21c17b113a25cc07d59a68fa151ecf995ac360ac476751171a9a KSwitcher-win-x64.zip
bd1fc311f17fcd26c438449c1ddbb82a30261824396ba18e7f0831f02d719f78 KSwitcher-win-x64.zip
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,59,0
PRODUCTVERSION 0,6,59,0
FILEVERSION 0,6,62,0
PRODUCTVERSION 0,6,62,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.59"
VALUE "FileVersion", "0.6.62"
END
END
BLOCK "VarFileInfo"
+23 -8
View File
@@ -986,21 +986,21 @@ void App::refreshChatContext()
HWND target = GetForegroundWindow();
chatWindow = target;
activeChatTitle = chatTitle(target);
activeChat = settings.chatCycles.empty() ? L"" : windowChatKey(target);
activeChat = windowLanguageProfileKey(settings, target);
}
void App::switchChatLayout(bool reverse)
{
HWND target = chatWindow;
auto key = activeChat;
if (!target || target != GetForegroundWindow() || key.empty() || windowChatKey(target) != key ||
!settings.chatCycles.contains(key) || busy.exchange(true)) return;
if (!target || target != GetForegroundWindow() || key.empty() ||
windowLanguageProfileKey(settings, target) != key || busy.exchange(true)) return;
auto cycle = chatCycle(settings, key, layouts);
auto available = layouts;
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};
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 (!valid()) return;
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;
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))
{
refreshChatContext();
chatEnabled = !activeChat.empty() && settings.chatCycles.contains(activeChat);
chatEnabled = !activeChat.empty();
}
const int chatAction = chatSwitchHook.update(key.vkCode, down, mods, chatEnabled);
if (chatAction)
@@ -1314,7 +1314,7 @@ void App::transform(int command, int snippet)
} reset{busy, window, preDeferred};
auto effectiveChat = requestedChat;
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();
std::wstring reason = L"cancelled-before-selection";
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();
return 0;
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 (app->lanService)
@@ -2452,6 +2466,7 @@ int App::execute()
if (lanService && lanService->inputEnabled()) SetTimer(window, 7, 10, nullptr);
SetTimer(window, 1, 60000, nullptr);
if (!smoke) SetTimer(window, 6, 1000, nullptr);
if (!smoke) SetTimer(window, 8, 250, nullptr);
SetThreadExecutionState(ES_CONTINUOUS |
(settings.awake ? (ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED) : 0));
if (!registrationWarning.empty())
+1 -1
View File
@@ -257,7 +257,7 @@ std::optional<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed,
{
auto replacement = settings.autoReplacements.find(small);
if (replacement != settings.autoReplacements.end() &&
replacement->second.find_first_not_of(alphabet + L" ") == small.npos)
validAutoCorrection(replacement->second, alphabet))
return sameLayout(replacement->second);
}
auto personal = [&](const std::wstring &language, const std::wstring &candidate)
+32 -2
View File
@@ -92,13 +92,43 @@ inline std::wstring windowChatKey(HWND window)
if (window && lower(processName(window)) == L"chrome") return cachedChromeChat(window);
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,
const std::vector<Layout> &layouts)
{
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;
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; }))
result.push_back(id);
return result.empty() ? settings.cycle : result;
+14
View File
@@ -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;
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,
const std::vector<std::pair<int, Hotkey>> &bindings)
{
+7
View File
@@ -19,6 +19,7 @@ struct Layout
bool hasLetter(wchar_t ch) const;
};
bool letter(wchar_t ch);
bool validAutoCorrection(const std::wstring &text, const std::wstring &alphabet);
std::wstring lower(std::wstring text);
std::wstring upper(std::wstring text);
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);
std::wstring format() const;
};
struct AppLanguageSet
{
std::vector<std::wstring> languages, applications;
bool operator==(const AppLanguageSet &) const = default;
};
struct Settings
{
bool recent = true, switchLayout = true, history = true, autostart = false, awake = false;
@@ -81,6 +87,7 @@ struct Settings
std::vector<std::wstring> cycle, direct, directIds;
// 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, AppLanguageSet> appLanguageSets;
std::vector<std::wstring> excluded{L"KeePass", L"KeePassXC", L"1Password", L"Bitwarden"};
std::vector<Snippet> snippets;
std::vector<std::wstring> snippetTombstones;
+50 -3
View File
@@ -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;
}
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
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");
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 &&
from == lower(from) && to == lower(to) && from != to &&
from.find_first_not_of(alphabet) == from.npos &&
to.find_first_not_of(std::wstring(alphabet) + L" ") == to.npos &&
to.front() != L' ' && to.back() != L' ' && to.find(L" ") == to.npos;
validAutoCorrection(to, alphabet);
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)
{
@@ -264,6 +289,19 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
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.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)},
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
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();
for (const auto &[key, cycle] : s.chatCycles)
for (const auto &id : cycle) j["chatCycles"][utf8(key)].push_back(utf8(id));
+1 -1
View File
@@ -226,7 +226,7 @@ Bytes sharedSyncSettings(const Settings &s)
return a.at("layoutId").get<std::string>() < b.at("layoutId").get<std::string>();
});
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));
}
Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector<Layout> &layouts, bool keepLocalLayouts)
+40 -18
View File
@@ -1060,12 +1060,27 @@ struct ChatLanguagesView
HWND parent{};
SettingsView *settings{};
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)
{
int index = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
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]);
const auto &cycle = saved == draft.chatCycles.end() ? draft.cycle : saved->second;
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);
}
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());
}
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)
{
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)
{
int selected = (int)SendDlgItemMessageW(w, 710, LB_GETCURSEL, 0, 0);
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.");
auto next = v->settings->draft;
if (id == 713) next.chatCycles.erase(v->keys[selected]);
else
throw std::runtime_error("Open a Telegram or Teams chat, then reopen this dialog.");
if (id == 713)
{
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.");
next.chatCycles[v->keys[selected]] = std::move(cycle);
v->draft.chatCycles.erase(v->keys[selected]);
v->dirty = false;
fillChatLanguages(w, v);
return 0;
}
validateSettings(next);
v->settings->draft.chatCycles = std::move(next.chatCycles);
collectChatLanguages(w, v);
validateSettings(v->draft);
v->settings->draft.chatCycles = std::move(v->draft.chatCycles);
DestroyWindow(w);
return 0;
}
@@ -1140,6 +1159,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
}, (LPARAM)&context);
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()}};
v->draft = settings->draft;
SetWindowLongPtrW(w, GWLP_HWNDPARENT, (LONG_PTR)parent);
SetWindowLongPtrW(w, GWLP_USERDATA, (LONG_PTR)v);
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);
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);
for (int i : settings->order)
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"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"&Use languages", IDOK, 300, 476, 170);
button(w, L"&Cancel", IDCANCEL, 486, 476, 136);
@@ -1169,6 +1189,7 @@ void showChatLanguages(HWND parent, SettingsView *settings)
showRequestedWindow(w);
SetFocus(GetDlgItem(w, 710));
}
#include "ui_app_languages.inl"
void collectLayout(HWND w, SettingsView *v)
{
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 == 258) { showSyncOptions(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 == 230)
{
@@ -2153,7 +2175,7 @@ void showSettings(App &app, bool visible)
label(w, L"Backspace / Delete clears it", 454, 270, 230);
button(w, L"Re&fresh layouts", 253, 454, 322, 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();
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
+308
View File
@@ -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
View File
@@ -2,7 +2,7 @@
#include "platform.h"
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 UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+95
View File
@@ -225,6 +225,7 @@ int main()
ks::atomicWrite(std::filesystem::path(ks::executable()).parent_path() / L"chat-languages.png",
ks::encodePng(ks::decodeImage(clip)));
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, IDCANCEL, 0);
require(!IsWindow(dialog) && IsWindowEnabled(parent), "cancel closes dialog");
require(!saveEnabled(),"cancelled chat edits do not enable Save");
@@ -235,6 +236,7 @@ int main()
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 0);
require(SendDlgItemMessageW(dialog, 711, LB_GETSELCOUNT, 0, 0) == 1, "cancel discards language edits");
SendDlgItemMessageW(dialog, 711, LB_SETSEL, TRUE, -1);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(711, LBN_SELCHANGE), 0);
SendMessageW(dialog, WM_COMMAND, IDOK, 0);
require(!IsWindow(dialog) && IsWindowEnabled(parent), "profile applies to settings draft");
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);
SendMessageW(dialog, WM_COMMAND, MAKEWPARAM(710, LBN_SELCHANGE), 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");
RedrawWindow(parent,nullptr,nullptr,RDW_INVALIDATE|RDW_UPDATENOW|RDW_ALLCHILDREN);
GetClientRect(parent,&rect); dc=GetDC(parent);
@@ -258,6 +262,97 @@ int main()
ks::encodePng(ks::decodeImage(clip)));
SendMessageW(parent, WM_COMMAND, IDOK, 0);
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"}};
ks::showSettings(app); parent=app.settingsWindow;
require(!saveEnabled(),"Settings starts clean before separately saved snippet edit");
+14 -1
View File
@@ -592,6 +592,16 @@ int main(int argc, char **argv)
require(!edited.suggest(L"hlelo ", 0, layouts, options), "never-correct overrides explicit pairs");
options.autoNever.clear();
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);
require(correction && correction->text == L"(Мама мила)! " && correction->target == 2,
"joined words split while preserving capitalization, punctuation and layout");
@@ -718,7 +728,8 @@ int main(int argc, char **argv)
settings.elevatedAutostart = true;
settings.autoCorrect = 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.autoMinLength = 5;
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 світ"}, {L"helloworld", L"hello world!"},
{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"}})
{
auto invalid = settings;