Skip to content

feat: support configurable keyboard layouts on Windows - #1904

Closed
yuxuanchen1997 wants to merge 1 commit into
rime:masterfrom
yuxuanchen1997:feature/configurable-keyboard-layouts
Closed

feat: support configurable keyboard layouts on Windows#1904
yuxuanchen1997 wants to merge 1 commit into
rime:masterfrom
yuxuanchen1997:feature/configurable-keyboard-layouts

Conversation

@yuxuanchen1997

@yuxuanchen1997 yuxuanchen1997 commented Jul 30, 2026

Copy link
Copy Markdown

What changed

  • Add a global keyboard_layout setting to the merged Weasel configuration.
  • Support qwerty (default), colemak, dvorak, and workman.
  • Remap physical scan-code positions before translating key events.
  • Apply the configured layout consistently during Rime composition and in ASCII mode while leaving shortcuts untouched.
  • Add focused keyboard-mapping and response-parser coverage.

Example configuration in weasel.custom.yaml:

patch:
  keyboard_layout: colemak

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

  • Focused keyboard-layout mapping test passes.
  • Release builds completed for x86, x64, and ARM64.
  • Built installer tested manually with Colemak in both composition and ASCII modes.
  • Verified Shift/Caps behavior and preservation of modified application shortcuts in the implementation path.

Assisted-By: Codex GPT 5.6 Sol

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.
@yuxuanchen1997
yuxuanchen1997 marked this pull request as ready for review July 30, 2026 05:38
@fxliang

fxliang commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

I think use the HKL to remap would be a better( and universal) idea

key api might be ToUnicodeEx

@yuxuanchen1997

Copy link
Copy Markdown
Author

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 GetKeyboardLayout(0) identifies the Weasel profile. Enumerating installed HKLs also does not identify which one should be paired with Weasel.

@fxliang

fxliang commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

configured in weasel.yaml, tell the client which hkl is prefered, convert keys with it

@yuxuanchen1997

Copy link
Copy Markdown
Author

I tried to prototype this HKL approach and what I ended up needing to do is to list my preferred layout as an ID:

keyboard_layout: "00060409"

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.

@fxliang

fxliang commented Jul 31, 2026

Copy link
Copy Markdown
Contributor
#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

@yuxuanchen1997

Copy link
Copy Markdown
Author

@fxliang : I am closing this one in favor of #1905

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants