feat: support configurable keyboard layouts on Windows - #1904
feat: support configurable keyboard layouts on Windows#1904yuxuanchen1997 wants to merge 1 commit into
Conversation
Windows TSF presents QWERTY-oriented key events to IMEs even when the user types with an alternative physical layout. Correcting this system-wide generally requires registry changes, while Rime frontends on macOS and Linux normally receive input after the system keyboard layout has already been applied. Add a Weasel-specific translation layer controlled by keyboard_layout in the merged weasel configuration. Remap physical scan-code positions for both Rime composition and direct ASCII input so switching language modes does not switch the effective keyboard layout, while preserving application shortcuts. Support qwerty as the unchanged default, plus colemak, dvorak, and workman, with focused mapping and configuration parser coverage.
|
I think use the key api might be |
|
Thanks for the quick reply. I have one question: How should Weasel determine which HKL the user intends to use? For example, if I have Colemak installed, Windows presents US, US-Colemak, and Weasel as separate input profiles. Once Weasel is active, APIs like |
|
configured in weasel.yaml, tell the client which hkl is prefered, convert keys with it |
|
I tried to prototype this HKL approach and what I ended up needing to do is to list my preferred layout as an ID: That 00060409 is Windows 11's Colemak layout. It's a small problem but it is hard to discover and may not be portable if Colemak was installed on Windows 10 independent from the system. I will publish a separate PR for the alternative approach. |
#include <algorithm>
#include <iomanip>
#include <iostream>
#include <shlwapi.h> // SHLoadIndirectString
#include <sstream>
#include <string>
#include <unordered_set>
#include <vector>
#include <windows.h>
#pragma comment(lib, "Shlwapi.lib")
#pragma comment(lib, "user32.lib")
#pragma comment(lib, "advapi32.lib")
// 将注册表原始字节读取出来
static bool ReadRegValueRaw(HKEY hKey, LPCWSTR valueName,
std::vector<BYTE> &outBytes, DWORD &outType) {
outBytes.clear();
outType = 0;
DWORD cb = 0;
LONG r = RegQueryValueExW(hKey, valueName, nullptr, &outType, nullptr, &cb);
if (r != ERROR_SUCCESS)
return false;
outBytes.resize(cb ? cb : 1);
r = RegQueryValueExW(hKey, valueName, nullptr, &outType, outBytes.data(),
&cb);
if (r != ERROR_SUCCESS) {
outBytes.clear();
outType = 0;
return false;
}
outBytes.resize(cb);
return true;
}
// 尝试把原始字节解释为 std::wstring(优先当作 UTF-16LE,其次 UTF-8,最后 ANSI)
static std::wstring BytesToWString(const std::vector<BYTE> &data) {
if (data.empty())
return L"";
// 如果字节数能被 wchar_t 整除,按 UTF-16LE(Windows 内部)解释
if (data.size() % sizeof(wchar_t) == 0) {
const wchar_t *pw = reinterpret_cast<const wchar_t *>(data.data());
size_t wc = data.size() / sizeof(wchar_t);
size_t len = wc;
// 去掉结尾的 NUL(如果存在),或者找到第一个 NUL
if (wc > 0 && pw[wc - 1] == L'\0') {
len = wc - 1;
} else {
for (size_t i = 0; i < wc; ++i) {
if (pw[i] == L'\0') {
len = i;
break;
}
}
}
return std::wstring(pw, pw + len);
}
// 否则尝试 UTF-8
int needed =
MultiByteToWideChar(CP_UTF8, 0, reinterpret_cast<LPCCH>(data.data()),
(int)data.size(), nullptr, 0);
if (needed > 0) {
std::wstring out(needed, L'\0');
MultiByteToWideChar(CP_UTF8, 0, reinterpret_cast<LPCCH>(data.data()),
(int)data.size(), &out[0], needed);
return out;
}
// 最后尝试当前 ANSI 代码页
needed = MultiByteToWideChar(CP_ACP, 0, reinterpret_cast<LPCCH>(data.data()),
(int)data.size(), nullptr, 0);
if (needed > 0) {
std::wstring out(needed, L'\0');
MultiByteToWideChar(CP_ACP, 0, reinterpret_cast<LPCCH>(data.data()),
(int)data.size(), &out[0], needed);
return out;
}
return L"(unconvertible)";
}
// 将注册表的字符串值(包括 REG_SZ / REG_EXPAND_SZ / REG_MULTI_SZ)安全读取为
// std::wstring 如果值是间接字符串(以 '@' 开头),尝试用 SHLoadIndirectString
// 展开
static std::wstring ReadRegStringValueFixed(HKEY hKey, LPCWSTR valueName) {
std::vector<BYTE> raw;
DWORD type = 0;
if (!ReadRegValueRaw(hKey, valueName, raw, type))
return L"";
std::wstring s = BytesToWString(raw);
if (s.empty())
return L"";
// 如果是 EXPAND_SZ,展开环境变量
if (type == REG_EXPAND_SZ) {
DWORD needed = ExpandEnvironmentStringsW(s.c_str(), nullptr, 0);
if (needed > 0) {
std::wstring expanded(needed, L'\0');
ExpandEnvironmentStringsW(s.c_str(), &expanded[0], needed);
if (!expanded.empty() && expanded.back() == L'\0')
expanded.pop_back();
s = expanded;
}
}
// 如果看起来像间接字符串,尝试展开资源引用
if (!s.empty() && s[0] == L'@') {
wchar_t resolved[1024] = {0};
HRESULT hr = SHLoadIndirectString(s.c_str(), resolved,
(UINT)_countof(resolved), nullptr);
if (SUCCEEDED(hr) && resolved[0] != L'\0') {
s = resolved;
}
// 如果 SHLoadIndirectString 失败则保留原始 s(可能是某些奇怪格式)
}
return s;
}
// 安全输出到控制台(宽字符),并换行
static void WriteConsoleLineW(const std::wstring &s) {
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
if (h != INVALID_HANDLE_VALUE && h != NULL) {
DWORD written = 0;
// WriteConsoleW 在某些情况下会失败 (例如输出重定向到文件),这时回退到 wcout
if (!WriteConsoleW(h, s.c_str(), (DWORD)s.length(), &written, nullptr)) {
std::wcout << s << std::endl;
} else {
WriteConsoleW(h, L"\r\n", 2, &written, nullptr);
}
} else {
std::wcout << s << std::endl;
}
}
// 打印字符串的 Unicode codepoints(以 U+XXXX 形式)以便诊断不可见字符
static void PrintCodepoints(const std::wstring &s) {
std::wostringstream oss;
oss << L" Codepoints:";
for (size_t i = 0; i < s.size(); ++i) {
oss << L" U+" << std::hex << std::uppercase << std::setw(4)
<< std::setfill(L'0') << (uint16_t)s[i];
}
oss << std::dec;
WriteConsoleLineW(oss.str());
}
struct KlidEntry {
std::wstring klid;
std::wstring name;
std::wstring layoutFile;
};
int wmain() {
// 1) 获取当前进程已加载的 HKL(用于标记哪个 KLID 当前被加载)
UINT loadedCount = GetKeyboardLayoutList(0, nullptr);
std::unordered_set<uint32_t> loadedKlids;
if (loadedCount > 0) {
std::vector<HKL> hkls(loadedCount);
if (GetKeyboardLayoutList(loadedCount, hkls.data()) == (int)loadedCount) {
for (HKL h : hkls) {
uint32_t low = (uint32_t)((DWORD_PTR)h & 0xFFFFFFFF);
loadedKlids.insert(low);
}
}
}
// 2) 打开注册表所在键
HKEY hKey = nullptr;
REGSAM sam = KEY_READ;
#ifdef KEY_WOW64_64KEY
sam = (REGSAM)(sam | KEY_WOW64_64KEY); // 在 32 位进程上尝试读取 64 位视图
#endif
LPCWSTR basePath = L"SYSTEM\\CurrentControlSet\\Control\\Keyboard Layouts";
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, basePath, 0, sam, &hKey) !=
ERROR_SUCCESS) {
std::wcerr << L"不能打开注册表键: " << basePath << L"\n";
return 1;
}
// 3) 枚举子键(每个子键名就是 KLID,如 00000409),读入 vector
std::vector<KlidEntry> entries;
DWORD index = 0;
WCHAR subName[512];
DWORD subNameLen = 0;
while (true) {
subNameLen = _countof(subName);
FILETIME ft;
LONG ret = RegEnumKeyExW(hKey, index, subName, &subNameLen, nullptr,
nullptr, nullptr, &ft);
if (ret == ERROR_NO_MORE_ITEMS)
break;
if (ret != ERROR_SUCCESS) {
std::wcerr << L"RegEnumKeyExW failed at index " << index << L" err="
<< ret << L"\n";
break;
}
// 打开子键
HKEY hSub = nullptr;
if (RegOpenKeyExW(hKey, subName, 0, sam, &hSub) == ERROR_SUCCESS) {
KlidEntry e;
e.klid = subName;
// 使用安全读取函数
std::wstring friendly = ReadRegStringValueFixed(hSub, L"Layout Text");
if (friendly.empty()) {
std::wstring displayName =
ReadRegStringValueFixed(hSub, L"Layout Display Name");
if (!displayName.empty()) {
friendly =
displayName; // ReadRegStringValueFixed 已尝试展开间接字符串
}
}
// 如果仍为���,回退到通过 LANGID 获取语言名
if (friendly.empty()) {
unsigned int klidNum = 0;
if (swscanf_s(subName, L"%x", &klidNum) == 1) {
LANGID lang = LOWORD((DWORD)klidNum);
wchar_t langBuf[128] = {0};
if (GetLocaleInfoW(MAKELCID(lang, SORT_DEFAULT), LOCALE_SLANGUAGE,
langBuf, _countof(langBuf))) {
friendly = langBuf;
} else {
friendly = L"(unknown)";
}
} else {
friendly = L"(invalid KLID)";
}
}
e.name = friendly;
// get Layout File
e.layoutFile = ReadRegStringValueFixed(hSub, L"Layout File");
// 判断是否在 loadedKlids 中
unsigned int klidCheck = 0;
if (swscanf_s(subName, L"%x", &klidCheck) != 1)
klidCheck = 0;
entries.push_back(std::move(e));
RegCloseKey(hSub);
} else {
std::wcerr << L"无法打开子键: " << subName << L"\n";
}
++index;
}
RegCloseKey(hKey);
// 4) 按 friendly(name)排序(按字典序,若相同则按 KLID 排序)
std::sort(entries.begin(), entries.end(),
[](KlidEntry const &a, KlidEntry const &b) {
int cmp = a.name.compare(b.name);
if (cmp != 0)
return cmp < 0;
return a.klid < b.klid;
});
// 5) 输出排序后的结果
for (const auto &e : entries) {
std::wostringstream oss;
oss << L"KLID: " << e.klid << L" Name: " << e.name << L" LayoutFile: "
<< e.layoutFile;
;
WriteConsoleLineW(oss.str());
// 如需查看 codepoints 可取消下面注释
// PrintCodepoints(e.name);
}
return 0;
}a demo to enum all HKLs installed in your system |
What changed
keyboard_layoutsetting to the merged Weasel configuration.qwerty(default),colemak,dvorak, andworkman.Example configuration in
weasel.custom.yaml:Why
Windows TSF presents QWERTY-oriented key events to IMEs even when the user types with an alternative physical layout. Fixing that system-wide commonly requires registry changes. In contrast, Rime frontends on macOS and Linux generally receive input after the system keyboard layout has been applied.
Without Weasel-side translation, alternative-layout users get QWERTY input in Rime. Passing remapped keys back to the application in ASCII mode also restores Windows' QWERTY interpretation, causing the effective layout to change when switching language modes.
This change keeps the selected layout coherent across Chinese composition and direct English input without requiring system registry changes.
Validation
Assisted-By: Codex GPT 5.6 Sol