Compare commits

..
Author SHA1 Message Date
agrynco 5d96abb83e Release v0.6.61: configure languages per application 2026-11-09 23:24:42 +02:00
15 changed files with 456 additions and 51 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.60 LANGUAGES CXX RC) project(KSwitcher VERSION 0.6.61 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()
+49
View File
@@ -901,3 +901,52 @@ 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.
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` не мають
значення. Правило охоплює всі вікна програми, зокрема всі вкладки браузера.
Без окремого профілю діють загальні мови; простий перегляд програми не створює
профіль. **Use defaults** повертає лише вибрану програму до загальних мов.
Перехід між програмами зберігає чернетки. **Use languages**, потім **Save**
застосовує всі зміни, **Cancel** скасовує зміни вікна. Пріоритет: профіль контакту
→ профіль програми → загальний цикл.
**Alt+Shift**, **Win+Space**, **Win+Shift+Space** та **Next layout** використовують
відповідний вибір. Мову поза профілем програми перевірка кожні 250 мс замінює
першою доступною дозволеною мовою (відкладається, коли KSwitcher зайнятий).
Це діє і при вході в програму, і після ручного перемикання через Windows чи пряму
комбінацію. Мови залишаються встановленими й видимими в меню Windows.
Недоступні розкладки пропускаються; якщо недоступні всі вибрані мови, діє
загальний цикл. Профілі локальні, зберігаються під час хмарної синхронізації
та входять до ручного експорту/імпорту налаштувань.
Binary file not shown.
+8 -16
View File
@@ -1,24 +1,16 @@
# Latest Windows release # Latest Windows release
## v0.6.60 — 2026-11-09 ## v0.6.61 — 2026-11-09
## Changes - Choose an application's allowed layouts in **Settings → Languages → Languages for applications...**. Each application has its own selection. Choose the program, select its languages, then use **Use languages** and **Save**.
- Fix custom auto-correction pairs such as `ревю=рев'ю`: correction text now accepts straight (`'`), typographic (`’`) and modifier-letter (`ʼ`) apostrophes between letters, both when saving settings and applying replacements. - Chat profiles take priority over application profiles. Application profiles take priority over the default cycle. When an excluded layout is active in an application with custom languages, KSwitcher returns to an available allowed layout on its periodic check.
- Preserve the exact apostrophe, initial capitalization, surrounding punctuation and current layout. Multi-word corrections remain supported, with a maximum of 32 characters including spaces and apostrophes. - Switching between applications or chats keeps pending edits. **Use defaults** or **Remove profile** resets only the selected entry. **Cancel** discards the dialog's edits.
- Use the same correction validator for settings and runtime. Source words still contain only letters; leading/trailing or adjacent apostrophes, apostrophes next to spaces, other punctuation and mixed-language replacements remain invalid. Update the validation message and user documentation. - Application profiles stay on this PC and are included in manual export and import. Chat profiles continue to synchronize. Existing assignments from the development editor retain their layouts and become independent when applied.
- Retain all v0.6.59 sync, LAN, clipboard, screenshot, snippet and chat-profile behavior. No new setting, shortcut or default.
## Validation ## Upgrade
Passed: 349 native checks (including apostrophe validation, correction, capitalization/punctuation and settings roundtrips), 571 input/UI, 829 chat-profile/UI, 132 sync, 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. Release packaging verifies the manifest, all file hashes and the ZIP checksum.
## 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.
**Update every synchronizing PC to v0.6.60 or newer before sharing replacement pairs containing apostrophes. Older clients reject these settings.** Existing letter-only and phrase pairs require no migration. 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).
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.
+1 -1
View File
@@ -1 +1 @@
31e6d5571db106154e3b72391c4f13b856523e602c84a1f8a2935f05c9508854 KSwitcher-win-x64.zip d2bbc265adacb48099ff11f23444beeabe696b2f533ae0186c727b12e5fe3c5a 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" 1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1" 201 RCDATA "update.ps1"
1 VERSIONINFO 1 VERSIONINFO
FILEVERSION 0,6,60,0 FILEVERSION 0,6,61,0
PRODUCTVERSION 0,6,60,0 PRODUCTVERSION 0,6,61,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.60" VALUE "FileVersion", "0.6.61"
END END
END END
BLOCK "VarFileInfo" BLOCK "VarFileInfo"
+23 -8
View File
@@ -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())
+32 -2
View File
@@ -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;
+6
View File
@@ -53,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;
@@ -82,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;
+48
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; 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)
{ {
@@ -263,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");
@@ -335,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
View File
@@ -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
View File
@@ -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);
+177
View File
@@ -0,0 +1,177 @@
// 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{};
};
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);
value(w, 721, L"Select an application, or add its executable name below.");
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);
}
value(w, 721, found == v->profiles.end() ? L"Using default languages. Click languages to customize this application." :
L"Custom languages for this application. Other applications are unchanged.");
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;
}
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 == WM_COMMAND)
{
int id = LOWORD(wp);
if (id == 722 && HIWORD(wp) == LBN_SELCHANGE)
{
v->dirty = true;
EnableWindow(GetDlgItem(w, 725), TRUE);
value(w, 721, L"Custom languages for this application. Other applications are unchanged.");
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);
collectAppLanguages(w, v);
auto found = std::find(v->applications.begin(), v->applications.end(), app);
int index = (int)(found - v->applications.begin());
if (found == v->applications.end())
{
v->applications.push_back(app);
SendDlgItemMessageW(w, 720, LB_ADDSTRING, 0, (LPARAM)app.c_str());
}
v->selection = index;
SendDlgItemMessageW(w, 720, LB_SETCURSEL, index, 0);
fillAppLanguages(w, v);
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, 278);
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" #include "platform.h"
namespace ks namespace ks
{ {
inline constexpr wchar_t AppVersion[] = L"0.6.60"; inline constexpr wchar_t AppVersion[] = L"0.6.61";
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;
+66
View File
@@ -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,68 @@ 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);
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");