feat: Add layout parsing and loading

This change adds logic to parse a keyboard layout file, and compile that
into a layout structure that can be used later by the profile to map the
characters into a key+modifiers structure.
profile-support
nirenjan 2026-04-03 00:23:51 -07:00
parent d8cc745d2d
commit b87464be80
11 changed files with 1468 additions and 21 deletions

View File

@ -0,0 +1,397 @@
; US QWERTY keyboard layout for x52d profile keymap resolution
;
; Scope: map Unicode text (typed characters) to main-keyboard HID usages and
; modifiers. This file is not where arrows, Home/End, function keys, or other
; non-text keys live—bind those in profile actions using VKM key names (e.g.
; UpArrow, Home, F1).
;
; Numeric keypad: numpad digits use the same character codes as the top row
; (0-9). Those entries below emit VKM_KEY_0..9, not VKM_KEY_KEYPAD_* . To send
; true keypad events, reference Keypad0..Keypad9 (etc.) from the profile layer.
;
; Sections: each block (except [Layout]) names one UTF-8 code point, or U+XXXX
; when INI cannot express it (e.g. U+005D for ']').
[Layout]
Name=us
Description=US QWERTY (HID usage 0x07)
[ ]
Key=Space
Mods=
[a]
Key=A
Mods=
[b]
Key=B
Mods=
[c]
Key=C
Mods=
[d]
Key=D
Mods=
[e]
Key=E
Mods=
[f]
Key=F
Mods=
[g]
Key=G
Mods=
[h]
Key=H
Mods=
[i]
Key=I
Mods=
[j]
Key=J
Mods=
[k]
Key=K
Mods=
[l]
Key=L
Mods=
[m]
Key=M
Mods=
[n]
Key=N
Mods=
[o]
Key=O
Mods=
[p]
Key=P
Mods=
[q]
Key=Q
Mods=
[r]
Key=R
Mods=
[s]
Key=S
Mods=
[t]
Key=T
Mods=
[u]
Key=U
Mods=
[v]
Key=V
Mods=
[w]
Key=W
Mods=
[x]
Key=X
Mods=
[y]
Key=Y
Mods=
[z]
Key=Z
Mods=
[A]
Key=A
Mods=Shift
[B]
Key=B
Mods=Shift
[C]
Key=C
Mods=Shift
[D]
Key=D
Mods=Shift
[E]
Key=E
Mods=Shift
[F]
Key=F
Mods=Shift
[G]
Key=G
Mods=Shift
[H]
Key=H
Mods=Shift
[I]
Key=I
Mods=Shift
[J]
Key=J
Mods=Shift
[K]
Key=K
Mods=Shift
[L]
Key=L
Mods=Shift
[M]
Key=M
Mods=Shift
[N]
Key=N
Mods=Shift
[O]
Key=O
Mods=Shift
[P]
Key=P
Mods=Shift
[Q]
Key=Q
Mods=Shift
[R]
Key=R
Mods=Shift
[S]
Key=S
Mods=Shift
[T]
Key=T
Mods=Shift
[U]
Key=U
Mods=Shift
[V]
Key=V
Mods=Shift
[W]
Key=W
Mods=Shift
[X]
Key=X
Mods=Shift
[Y]
Key=Y
Mods=Shift
[Z]
Key=Z
Mods=Shift
[0]
Key=0
Mods=
[1]
Key=1
Mods=
[2]
Key=2
Mods=
[3]
Key=3
Mods=
[4]
Key=4
Mods=
[5]
Key=5
Mods=
[6]
Key=6
Mods=
[7]
Key=7
Mods=
[8]
Key=8
Mods=
[9]
Key=9
Mods=
[!]
Key=1
Mods=Shift
[@]
Key=2
Mods=Shift
[#]
Key=3
Mods=Shift
[$]
Key=4
Mods=Shift
[%]
Key=5
Mods=Shift
[^]
Key=6
Mods=Shift
[&]
Key=7
Mods=Shift
[*]
Key=8
Mods=Shift
[(]
Key=9
Mods=Shift
[)]
Key=0
Mods=Shift
[-]
Key=Minus
Mods=
[=]
Key=Equal
Mods=
[[]
Key=LeftBracket
Mods=
[U+005D]
Key=RightBracket
Mods=
[\]
Key=Backslash
Mods=
[;]
Key=Semicolon
Mods=
[']
Key=Apostrophe
Mods=
[,]
Key=Comma
Mods=
[.]
Key=Period
Mods=
[/]
Key=Slash
Mods=
[`]
Key=GraveAccent
Mods=
[_]
Key=Minus
Mods=Shift
[+]
Key=Equal
Mods=Shift
[{]
Key=LeftBracket
Mods=Shift
[}]
Key=RightBracket
Mods=Shift
[|]
Key=Backslash
Mods=Shift
[:]
Key=Semicolon
Mods=Shift
["]
Key=Apostrophe
Mods=Shift
[<]
Key=Comma
Mods=Shift
[>]
Key=Period
Mods=Shift
[?]
Key=Slash
Mods=Shift
[~]
Key=GraveAccent
Mods=Shift

View File

@ -29,6 +29,7 @@ x52d_sources = [
'x52d_command.c',
'x52d_io.c',
'x52d_mouse_handler.c',
'x52d_layout.c',
]
dep_threads = dependency('threads')
@ -67,6 +68,13 @@ x52d_mouse_test = executable('x52d-mouse-test', x52d_mouse_test_sources,
test('x52d-mouse-test', x52d_mouse_test, protocol: 'tap')
x52d_layout_test = executable('x52d-layout-test', 'x52d_layout_test.c', 'x52d_layout.c',
include_directories: includes,
dependencies: [dep_pinelog, dep_cmocka, dep_inih, dep_intl])
test('x52d-layout-test', x52d_layout_test, protocol: 'tap',
workdir: meson.current_source_dir())
# Install service file
if dep_systemd.found()
systemd_system_unit_dir = get_option('systemd-unit-dir')

View File

@ -274,6 +274,7 @@ static void cmd_logging(char *buffer, int *buflen, int argc, char **argv)
[X52D_MOD_IO] = "io",
[X52D_MOD_LED] = "led",
[X52D_MOD_MOUSE] = "mouse",
[X52D_MOD_LAYOUT] = "layout",
[X52D_MOD_COMMAND] = "command",
[X52D_MOD_CLIENT] = "client",
[X52D_MOD_NOTIFY] = "notify",

View File

@ -35,6 +35,7 @@ enum {
X52D_MOD_IO,
X52D_MOD_LED,
X52D_MOD_MOUSE,
X52D_MOD_LAYOUT,
X52D_MOD_COMMAND,
X52D_MOD_CLIENT,
X52D_MOD_NOTIFY,

View File

@ -0,0 +1,676 @@
/*
* Saitek X52 Pro MFD & LED driver keyboard layout (.layout) loader
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
/*
* INI grammar (UTF-8 text):
*
* [Layout]
* Name=<short id> # e.g. us
* Description=<optional>
*
* [<one UTF-8 code point>] | [U+XXXX] | [u+XXXX]
* Key=<vkm_key name>|0xNN # HID usage (page 0x07), hex or symbolic (e.g. A, Enter)
* Mods=<modifier list> # optional; empty = none. Tokens: Shift LeftShift RightShift
* # Ctrl LeftCtrl RightCtrl Alt LeftAlt RightAlt
* # Gui LeftGui RightGui Super Meta
* # Separate tokens with ',' or '+' (whitespace trimmed).
*
* Section names (except Layout) must encode exactly one Unicode scalar value.
* Later entries replace earlier ones for the same code point.
*/
#include "config.h"
#include <ctype.h>
#include <errno.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#define PINELOG_MODULE X52D_MOD_LAYOUT
#include "ini.h"
#include "pinelog.h"
#include "x52d_const.h"
#include "x52d_layout.h"
struct x52d_layout_entry {
uint32_t cp;
vkm_key key;
vkm_key_modifiers mods;
};
struct x52d_layout {
char *name;
char *description;
struct x52d_layout_entry *entries;
size_t n_entries;
};
void x52d_layout_free(struct x52d_layout *layout)
{
if (layout == NULL) {
return;
}
free(layout->name);
free(layout->description);
free(layout->entries);
free(layout);
}
const char *x52d_layout_get_name(const struct x52d_layout *layout)
{
if (layout == NULL || layout->name == NULL) {
return "";
}
return layout->name;
}
static int cmp_entry_cp(const void *a, const void *b)
{
const struct x52d_layout_entry *ea = a;
const struct x52d_layout_entry *eb = b;
if (ea->cp < eb->cp) {
return -1;
}
if (ea->cp > eb->cp) {
return 1;
}
return 0;
}
bool x52d_layout_lookup(const struct x52d_layout *layout, uint32_t cp,
struct x52d_layout_recipe *out_recipe)
{
struct x52d_layout_entry key = {.cp = cp};
struct x52d_layout_entry *found;
if (layout == NULL || out_recipe == NULL || layout->entries == NULL ||
layout->n_entries == 0U) {
return false;
}
found = bsearch(&key, layout->entries, layout->n_entries, sizeof(key), cmp_entry_cp);
if (found == NULL) {
return false;
}
out_recipe->key = found->key;
out_recipe->mods = found->mods;
return true;
}
static int utf8_decode_one(const char *s, uint32_t *out_cp, size_t *out_len)
{
const unsigned char *u = (const unsigned char *)s;
uint32_t cp;
size_t n;
if (s == NULL || out_cp == NULL || out_len == NULL) {
return EINVAL;
}
if (u[0] < 0x80U) {
if (u[0] == 0U) {
return EINVAL;
}
*out_cp = u[0];
*out_len = 1U;
return 0;
}
if ((u[0] & 0xE0U) == 0xC0U) {
if ((u[0] & 0x1EU) == 0U) {
return EINVAL; /* overlong */
}
if (u[1] == 0U || (u[1] &0xC0U) != 0x80U) {
return EINVAL;
}
cp = (uint32_t)(u[0] & 0x1FU) << 6 | (uint32_t)(u[1] & 0x3FU);
n = 2U;
} else if ((u[0] & 0xF0U) == 0xE0U) {
if (u[1] == 0U || u[2] == 0U) {
return EINVAL;
}
if ((u[1] & 0xC0U) != 0x80U || (u[2] & 0xC0U) != 0x80U) {
return EINVAL;
}
cp = (uint32_t)(u[0] & 0x0FU) << 12 | (uint32_t)(u[1] & 0x3FU) << 6 |
(uint32_t)(u[2] & 0x3FU);
n = 3U;
if (cp < 0x800U) {
return EINVAL;
}
} else if ((u[0] & 0xF8U) == 0xF0U) {
if (u[1] == 0U || u[2] == 0U || u[3] == 0U) {
return EINVAL;
}
if ((u[1] & 0xC0U) != 0x80U || (u[2] & 0xC0U) != 0x80U ||
(u[3] & 0xC0U) != 0x80U) {
return EINVAL;
}
cp = (uint32_t)(u[0] & 0x07U) << 18 | (uint32_t)(u[1] & 0x3FU) << 12 |
(uint32_t)(u[2] & 0x3FU) << 6 | (uint32_t)(u[3] & 0x3FU);
n = 4U;
if (cp < 0x10000U || cp > 0x10FFFFU) {
return EINVAL;
}
} else {
return EINVAL;
}
*out_cp = cp;
*out_len = n;
return 0;
}
static void trim_inplace(char *s)
{
char *end;
size_t i;
if (s == NULL) {
return;
}
i = 0;
while (s[i] != '\0' && isspace((unsigned char)s[i])) {
i++;
}
if (i > 0U) {
memmove(s, s + i, strlen(s + i) + 1U);
}
end = s + strlen(s);
while (end > s && isspace((unsigned char)end[-1])) {
end--;
*end = '\0';
}
}
static const struct vkm_key_name {
const char *name;
vkm_key key;
} vkm_key_names[] = {
{"0", VKM_KEY_0}, {"1", VKM_KEY_1},
{"2", VKM_KEY_2}, {"3", VKM_KEY_3},
{"4", VKM_KEY_4}, {"5", VKM_KEY_5},
{"6", VKM_KEY_6}, {"7", VKM_KEY_7},
{"8", VKM_KEY_8}, {"9", VKM_KEY_9},
{"A", VKM_KEY_A}, {"Application", VKM_KEY_APPLICATION},
{"Apostrophe", VKM_KEY_APOSTROPHE},
{"B", VKM_KEY_B}, {"Backslash", VKM_KEY_BACKSLASH},
{"Backspace", VKM_KEY_BACKSPACE},
{"C", VKM_KEY_C}, {"CapsLock", VKM_KEY_CAPS_LOCK},
{"Comma", VKM_KEY_COMMA}, {"D", VKM_KEY_D},
{"Delete", VKM_KEY_DELETE_FORWARD},
{"DownArrow", VKM_KEY_DOWN_ARROW},
{"E", VKM_KEY_E}, {"End", VKM_KEY_END},
{"Enter", VKM_KEY_ENTER}, {"Equal", VKM_KEY_EQUAL},
{"Escape", VKM_KEY_ESCAPE}, {"F", VKM_KEY_F},
{"F1", VKM_KEY_F1}, {"F10", VKM_KEY_F10},
{"F11", VKM_KEY_F11}, {"F12", VKM_KEY_F12},
{"F2", VKM_KEY_F2}, {"F3", VKM_KEY_F3},
{"F4", VKM_KEY_F4}, {"F5", VKM_KEY_F5},
{"F6", VKM_KEY_F6}, {"F7", VKM_KEY_F7},
{"F8", VKM_KEY_F8}, {"F9", VKM_KEY_F9},
{"G", VKM_KEY_G}, {"GraveAccent", VKM_KEY_GRAVE_ACCENT},
{"H", VKM_KEY_H}, {"Home", VKM_KEY_HOME},
{"I", VKM_KEY_I}, {"Insert", VKM_KEY_INSERT},
{"IntlBackslash", VKM_KEY_INTL_BACKSLASH},
{"J", VKM_KEY_J}, {"K", VKM_KEY_K},
{"L", VKM_KEY_L}, {"LeftAlt", VKM_KEY_LEFT_ALT},
{"LeftArrow", VKM_KEY_LEFT_ARROW},
{"LeftBracket", VKM_KEY_LEFT_BRACKET},
{"LeftCtrl", VKM_KEY_LEFT_CTRL},
{"LeftGui", VKM_KEY_LEFT_GUI},
{"LeftShift", VKM_KEY_LEFT_SHIFT},
{"M", VKM_KEY_M}, {"Minus", VKM_KEY_MINUS},
{"N", VKM_KEY_N}, {"NonUsHash", VKM_KEY_NONUS_HASH},
{"O", VKM_KEY_O}, {"P", VKM_KEY_P},
{"PageDown", VKM_KEY_PAGE_DOWN},
{"PageUp", VKM_KEY_PAGE_UP}, {"Pause", VKM_KEY_PAUSE},
{"Period", VKM_KEY_PERIOD}, {"PrintScreen", VKM_KEY_PRINT_SCREEN},
{"Q", VKM_KEY_Q}, {"R", VKM_KEY_R},
{"Return", VKM_KEY_ENTER}, {"RightAlt", VKM_KEY_RIGHT_ALT},
{"RightArrow", VKM_KEY_RIGHT_ARROW},
{"RightBracket", VKM_KEY_RIGHT_BRACKET},
{"RightCtrl", VKM_KEY_RIGHT_CTRL},
{"RightGui", VKM_KEY_RIGHT_GUI},
{"RightShift", VKM_KEY_RIGHT_SHIFT},
{"S", VKM_KEY_S}, {"ScrollLock", VKM_KEY_SCROLL_LOCK},
{"Semicolon", VKM_KEY_SEMICOLON},
{"Slash", VKM_KEY_SLASH}, {"Space", VKM_KEY_SPACE},
{"T", VKM_KEY_T}, {"Tab", VKM_KEY_TAB},
{"U", VKM_KEY_U}, {"UpArrow", VKM_KEY_UP_ARROW},
{"V", VKM_KEY_V}, {"W", VKM_KEY_W},
{"X", VKM_KEY_X}, {"Y", VKM_KEY_Y},
{"Z", VKM_KEY_Z},
{"Keypad0", VKM_KEY_KEYPAD_0},
{"Keypad1", VKM_KEY_KEYPAD_1},
{"Keypad2", VKM_KEY_KEYPAD_2},
{"Keypad3", VKM_KEY_KEYPAD_3},
{"Keypad4", VKM_KEY_KEYPAD_4},
{"Keypad5", VKM_KEY_KEYPAD_5},
{"Keypad6", VKM_KEY_KEYPAD_6},
{"Keypad7", VKM_KEY_KEYPAD_7},
{"Keypad8", VKM_KEY_KEYPAD_8},
{"Keypad9", VKM_KEY_KEYPAD_9},
{"KeypadComma", VKM_KEY_KEYPAD_COMMA},
{"KeypadDecimal", VKM_KEY_KEYPAD_DECIMAL},
{"KeypadDivide", VKM_KEY_KEYPAD_DIVIDE},
{"KeypadEnter", VKM_KEY_KEYPAD_ENTER},
{"KeypadMinus", VKM_KEY_KEYPAD_MINUS},
{"KeypadMultiply", VKM_KEY_KEYPAD_MULTIPLY},
{"KeypadNumLock", VKM_KEY_KEYPAD_NUM_LOCK},
{"KeypadPlus", VKM_KEY_KEYPAD_PLUS},
};
static int parse_key_token(const char *value, vkm_key *out_key)
{
char tmp[96];
char *endptr;
unsigned long u;
size_t i;
size_t n;
if (value == NULL || out_key == NULL) {
return EINVAL;
}
if (strlen(value) >= sizeof(tmp)) {
return EINVAL;
}
memcpy(tmp, value, strlen(value) + 1U);
trim_inplace(tmp);
if (tmp[0] == '0' && (tmp[1] == 'x' || tmp[1] == 'X')) {
errno = 0;
u = strtoul(tmp, &endptr, 16);
if (errno != 0 || endptr == tmp) {
return EINVAL;
}
while (*endptr != '\0' && isspace((unsigned char)*endptr)) {
endptr++;
}
if (*endptr != '\0' || u >= (unsigned long)VKM_KEY_MAX) {
return EINVAL;
}
*out_key = (vkm_key)u;
return 0;
}
n = sizeof(vkm_key_names) / sizeof(vkm_key_names[0]);
for (i = 0; i < n; i++) {
if (!strcmp(tmp, vkm_key_names[i].name)) {
*out_key = vkm_key_names[i].key;
return 0;
}
}
return EINVAL;
}
static int token_to_modifier(const char *tok, vkm_key_modifiers *acc)
{
if (!strcasecmp(tok, "Shift") || !strcasecmp(tok, "LeftShift")) {
*acc = (vkm_key_modifiers)(*acc | VKM_KEY_MOD_SHIFT);
} else if (!strcasecmp(tok, "RightShift")) {
*acc = (vkm_key_modifiers)(*acc | VKM_KEY_MOD_RSHIFT);
} else if (!strcasecmp(tok, "Ctrl") || !strcasecmp(tok, "Control") ||
!strcasecmp(tok, "LeftCtrl")) {
*acc = (vkm_key_modifiers)(*acc | VKM_KEY_MOD_CTRL);
} else if (!strcasecmp(tok, "RightCtrl")) {
*acc = (vkm_key_modifiers)(*acc | VKM_KEY_MOD_RCTRL);
} else if (!strcasecmp(tok, "Alt") || !strcasecmp(tok, "LeftAlt")) {
*acc = (vkm_key_modifiers)(*acc | VKM_KEY_MOD_ALT);
} else if (!strcasecmp(tok, "RightAlt")) {
*acc = (vkm_key_modifiers)(*acc | VKM_KEY_MOD_RALT);
} else if (!strcasecmp(tok, "Gui") || !strcasecmp(tok, "LeftGui") ||
!strcasecmp(tok, "Super") || !strcasecmp(tok, "Meta")) {
*acc = (vkm_key_modifiers)(*acc | VKM_KEY_MOD_GUI);
} else if (!strcasecmp(tok, "RightGui")) {
*acc = (vkm_key_modifiers)(*acc | VKM_KEY_MOD_RGUI);
} else if (tok[0] == '\0') {
return 0;
} else {
return EINVAL;
}
return 0;
}
static int parse_modifiers(const char *value, vkm_key_modifiers *out_mods)
{
char buf[128];
char *p;
char *saveptr = NULL;
char *tok;
if (value == NULL || out_mods == NULL) {
return EINVAL;
}
*out_mods = VKM_KEY_MOD_NONE;
if (value[0] == '\0') {
return 0;
}
if (strlen(value) >= sizeof(buf)) {
return EINVAL;
}
memcpy(buf, value, strlen(value) + 1U);
trim_inplace(buf);
/* Split on comma first */
for (p = buf;; p = NULL) {
char *seg = strtok_r(p, ",", &saveptr);
if (seg == NULL) {
break;
}
trim_inplace(seg);
if (seg[0] == '\0') {
continue;
}
/* Then '+' within each segment */
char *subsave = NULL;
char *q;
for (q = seg;; q = NULL) {
tok = strtok_r(q, "+", &subsave);
if (tok == NULL) {
break;
}
trim_inplace(tok);
if (token_to_modifier(tok, out_mods) != 0) {
return EINVAL;
}
}
}
return 0;
}
static int layout_entry_replace_or_append(struct x52d_layout *layout, uint32_t cp, vkm_key key,
vkm_key_modifiers mods)
{
size_t i;
for (i = 0; i < layout->n_entries; i++) {
if (layout->entries[i].cp == cp) {
layout->entries[i].key = key;
layout->entries[i].mods = mods;
return 0;
}
}
{
struct x52d_layout_entry *ne =
realloc(layout->entries, (layout->n_entries + 1U) * sizeof(*ne));
if (ne == NULL) {
return ENOMEM;
}
layout->entries = ne;
layout->entries[layout->n_entries].cp = cp;
layout->entries[layout->n_entries].key = key;
layout->entries[layout->n_entries].mods = mods;
layout->n_entries++;
}
return 0;
}
struct layout_parse_ctx {
struct x52d_layout *layout;
char current_section[128];
bool in_layout_meta;
bool mapping_have_key;
uint32_t mapping_cp;
vkm_key mapping_key;
vkm_key_modifiers mapping_mods;
int error;
};
static void layout_flush_mapping(struct layout_parse_ctx *ctx)
{
int rc;
if (!ctx->in_layout_meta && ctx->mapping_have_key && ctx->error == 0) {
rc = layout_entry_replace_or_append(ctx->layout, ctx->mapping_cp, ctx->mapping_key,
ctx->mapping_mods);
if (rc != 0) {
ctx->error = rc;
}
}
ctx->mapping_have_key = false;
ctx->mapping_mods = VKM_KEY_MOD_NONE;
}
static int layout_begin_section(struct layout_parse_ctx *ctx, const char *section)
{
uint32_t cp;
size_t consumed;
int rc;
size_t slen;
if (section == NULL) {
return EINVAL;
}
if (strlen(section) >= sizeof(ctx->current_section)) {
PINELOG_ERROR(_("Layout section name too long"));
return EINVAL;
}
memcpy(ctx->current_section, section, strlen(section) + 1U);
if (!strcasecmp(section, "Layout")) {
ctx->in_layout_meta = true;
return 0;
}
ctx->in_layout_meta = false;
/* INI section names cannot express ']' as a lone code point (e.g. "[]]" is
* parsed as an empty section). Allow U+ABCD or u+ABCD (1-6 hex digits). */
if (section[0] == 'U' || section[0] == 'u') {
if (section[1] == '+' && section[2] != '\0') {
const char *p = section + 2;
unsigned long uh = 0;
char *endp = NULL;
errno = 0;
uh = strtoul(p, &endp, 16);
if (errno == 0 && endp != p && *endp == '\0' && uh <= 0x10FFFFUL &&
(uh < 0xD800UL || uh > 0xDFFFUL)) {
ctx->mapping_cp = (uint32_t)uh;
ctx->mapping_mods = VKM_KEY_MOD_NONE;
ctx->mapping_have_key = false;
return 0;
}
}
}
rc = utf8_decode_one(section, &cp, &consumed);
if (rc != 0) {
PINELOG_ERROR(_("Invalid UTF-8 in layout section '%s'"), section);
return EINVAL;
}
slen = strlen(section);
if (consumed != slen) {
PINELOG_ERROR(_("Layout section must encode exactly one code point: '%s'"), section);
return EINVAL;
}
ctx->mapping_cp = cp;
ctx->mapping_mods = VKM_KEY_MOD_NONE;
ctx->mapping_have_key = false;
return 0;
}
static int layout_ini_handler(void *user, const char *section, const char *name, const char *value)
{
struct layout_parse_ctx *ctx = user;
int rc;
vkm_key pk;
vkm_key_modifiers pm;
if (ctx->error != 0) {
return 0;
}
if (section == NULL || name == NULL || value == NULL) {
ctx->error = EINVAL;
return 0;
}
if (strcmp(section, ctx->current_section) != 0) {
layout_flush_mapping(ctx);
rc = layout_begin_section(ctx, section);
if (rc != 0) {
ctx->error = rc;
return 0;
}
}
if (ctx->in_layout_meta) {
if (!strcasecmp(name, "Name")) {
free(ctx->layout->name);
ctx->layout->name = strdup(value);
if (ctx->layout->name == NULL) {
ctx->error = ENOMEM;
}
} else if (!strcasecmp(name, "Description")) {
free(ctx->layout->description);
ctx->layout->description = strdup(value);
if (ctx->layout->description == NULL) {
ctx->error = ENOMEM;
}
}
return ctx->error != 0 ? 0 : 1;
}
if (!strcasecmp(name, "Key")) {
rc = parse_key_token(value, &pk);
if (rc != 0) {
PINELOG_ERROR(_("Unknown Key value '%s'"), value);
ctx->error = EINVAL;
return 0;
}
ctx->mapping_key = pk;
ctx->mapping_have_key = true;
} else if (!strcasecmp(name, "Mods")) {
rc = parse_modifiers(value, &pm);
if (rc != 0) {
PINELOG_ERROR(_("Invalid Mods value '%s'"), value);
ctx->error = EINVAL;
return 0;
}
ctx->mapping_mods = pm;
}
/* unknown keys ignored for forward-compatible layout files */
return ctx->error != 0 ? 0 : 1;
}
static int layout_finalize(struct layout_parse_ctx *ctx)
{
layout_flush_mapping(ctx);
if (ctx->error != 0) {
return ctx->error;
}
if (ctx->layout->n_entries > 0U) {
qsort(ctx->layout->entries, ctx->layout->n_entries, sizeof(ctx->layout->entries[0]),
cmp_entry_cp);
}
return 0;
}
static struct x52d_layout *layout_new(void)
{
struct x52d_layout *layout = calloc(1, sizeof(*layout));
return layout;
}
int x52d_layout_load_buffer(struct x52d_layout **out, const char *data, size_t len)
{
struct layout_parse_ctx ctx;
char *nulbuf;
int ir;
int rc;
if (out == NULL || data == NULL) {
return EINVAL;
}
*out = NULL;
ctx.layout = layout_new();
if (ctx.layout == NULL) {
return ENOMEM;
}
ctx.current_section[0] = '\0';
ctx.in_layout_meta = false;
ctx.mapping_have_key = false;
ctx.mapping_cp = 0;
ctx.mapping_key = VKM_KEY_NONE;
ctx.mapping_mods = VKM_KEY_MOD_NONE;
ctx.error = 0;
nulbuf = malloc(len + 1U);
if (nulbuf == NULL) {
x52d_layout_free(ctx.layout);
return ENOMEM;
}
memcpy(nulbuf, data, len);
nulbuf[len] = '\0';
ir = ini_parse_string(nulbuf, layout_ini_handler, &ctx);
free(nulbuf);
if (ir < 0) {
x52d_layout_free(ctx.layout);
return EIO;
}
if (ir > 0) {
PINELOG_ERROR(_("Layout parse error at line %d"), ir);
x52d_layout_free(ctx.layout);
return EINVAL;
}
rc = layout_finalize(&ctx);
if (rc != 0) {
x52d_layout_free(ctx.layout);
return rc;
}
*out = ctx.layout;
return 0;
}
int x52d_layout_load_file(struct x52d_layout **out, const char *path)
{
struct layout_parse_ctx ctx;
int ir;
int rc;
if (out == NULL || path == NULL) {
return EINVAL;
}
*out = NULL;
ctx.layout = layout_new();
if (ctx.layout == NULL) {
return ENOMEM;
}
ctx.current_section[0] = '\0';
ctx.in_layout_meta = false;
ctx.mapping_have_key = false;
ctx.mapping_cp = 0;
ctx.mapping_key = VKM_KEY_NONE;
ctx.mapping_mods = VKM_KEY_MOD_NONE;
ctx.error = 0;
ir = ini_parse(path, layout_ini_handler, &ctx);
if (ir < 0) {
x52d_layout_free(ctx.layout);
return EIO;
}
if (ir > 0) {
PINELOG_ERROR(_("Layout parse error in %s at line %d"), path, ir);
x52d_layout_free(ctx.layout);
return EINVAL;
}
rc = layout_finalize(&ctx);
if (rc != 0) {
x52d_layout_free(ctx.layout);
return rc;
}
*out = ctx.layout;
return 0;
}

View File

@ -0,0 +1,56 @@
/*
* Saitek X52 Pro MFD & LED driver keyboard layout (.layout) API
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#ifndef X52D_LAYOUT_H
#define X52D_LAYOUT_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "vkm.h"
/** Loaded keyboard layout: maps Unicode code points to HID key + modifier byte */
struct x52d_layout;
/** One keystroke recipe for \ref vkm_keyboard_send */
struct x52d_layout_recipe {
vkm_key key;
vkm_key_modifiers mods;
};
/**
* @brief Load a layout from an INI file (see x52d_layout.c header for grammar).
*
* @param[out] out Receives the new layout; must be released with
* \ref x52d_layout_free. Undefined on failure.
* @param[in] path Filesystem path
*
* @returns 0 on success, EINVAL on parse/validation error, ENOMEM, or EIO
*/
int x52d_layout_load_file(struct x52d_layout **out, const char *path);
/**
* @brief Load a layout from a memory buffer (NUL-terminated \p data is optional;
* length is given explicitly).
*/
int x52d_layout_load_buffer(struct x52d_layout **out, const char *data, size_t len);
void x52d_layout_free(struct x52d_layout *layout);
const char *x52d_layout_get_name(const struct x52d_layout *layout);
/**
* @brief Look up the recipe for a Unicode code point.
*
* @returns true if found
*/
bool x52d_layout_lookup(const struct x52d_layout *layout, uint32_t cp,
struct x52d_layout_recipe *out_recipe);
#endif /* X52D_LAYOUT_H */

View File

@ -0,0 +1,239 @@
/*
* Saitek X52 Pro MFD & LED driver keyboard layout loader tests
*
* Copyright (C) 2026 Nirenjan Krishnan (nirenjan@nirenjan.org)
*
* SPDX-License-Identifier: GPL-2.0-only WITH Classpath-exception-2.0
*/
#include "config.h"
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <setjmp.h>
#include <cmocka.h>
#define PINELOG_MODULE X52D_MOD_LAYOUT
#include "pinelog.h"
#include "x52d_const.h"
#include "x52d_layout.h"
static void test_sample_layout_lower_a(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=test\n"
"[a]\n"
"Key=A\n"
"Mods=\n";
struct x52d_layout *layout = NULL;
struct x52d_layout_recipe r;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, 0);
assert_non_null(layout);
assert_string_equal(x52d_layout_get_name(layout), "test");
assert_true(x52d_layout_lookup(layout, (uint32_t)'a', &r));
assert_int_equal((int)r.key, (int)VKM_KEY_A);
assert_int_equal((int)r.mods, (int)VKM_KEY_MOD_NONE);
x52d_layout_free(layout);
}
static void test_hex_key(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=h\n"
"[x]\n"
"Key=0x04\n"
"Mods=\n";
struct x52d_layout *layout = NULL;
struct x52d_layout_recipe r;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, 0);
assert_true(x52d_layout_lookup(layout, (uint32_t)'x', &r));
assert_int_equal((int)r.key, (int)VKM_KEY_A);
x52d_layout_free(layout);
}
static void test_modifiers_combo(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=m\n"
"[t]\n"
"Key=T\n"
"Mods=Ctrl+Shift\n";
struct x52d_layout *layout = NULL;
struct x52d_layout_recipe r;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, 0);
assert_true(x52d_layout_lookup(layout, (uint32_t)'t', &r));
assert_int_equal((int)r.key, (int)VKM_KEY_T);
assert_int_equal((int)r.mods,
(int)(VKM_KEY_MOD_LCTRL | VKM_KEY_MOD_LSHIFT));
x52d_layout_free(layout);
}
static void test_utf8_codepoint(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=u\n"
"[\xE2\x82\xAC]\n"
"Key=Enter\n"
"Mods=\n";
struct x52d_layout *layout = NULL;
struct x52d_layout_recipe r;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, 0);
assert_true(x52d_layout_lookup(layout, 0x20ACU, &r));
assert_int_equal((int)r.key, (int)VKM_KEY_ENTER);
x52d_layout_free(layout);
}
static void test_unknown_key_errors(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=bad\n"
"[q]\n"
"Key=NotARealKey\n"
"Mods=\n";
struct x52d_layout *layout = NULL;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, EINVAL);
assert_null(layout);
}
static void test_collision_last_section_wins(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=c\n"
"[z]\n"
"Key=A\n"
"Mods=\n"
"[z]\n"
"Key=B\n"
"Mods=\n";
struct x52d_layout *layout = NULL;
struct x52d_layout_recipe r;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, 0);
assert_true(x52d_layout_lookup(layout, (uint32_t)'z', &r));
assert_int_equal((int)r.key, (int)VKM_KEY_B);
x52d_layout_free(layout);
}
static void test_unknown_modifier_errors(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=badmod\n"
"[q]\n"
"Key=A\n"
"Mods=HyperMeta\n";
struct x52d_layout *layout = NULL;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, EINVAL);
assert_null(layout);
}
static void test_lookup_miss(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=m\n"
"[a]\n"
"Key=A\n"
"Mods=\n";
struct x52d_layout *layout = NULL;
struct x52d_layout_recipe r;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, 0);
assert_false(x52d_layout_lookup(layout, (uint32_t)'b', &r));
x52d_layout_free(layout);
}
static void test_u_plus_section_escape(void **state)
{
static const char ini[] =
"[Layout]\n"
"Name=e\n"
"[U+005D]\n"
"Key=RightBracket\n"
"Mods=\n";
struct x52d_layout *layout = NULL;
struct x52d_layout_recipe r;
int rc;
(void)state;
rc = x52d_layout_load_buffer(&layout, ini, strlen(ini));
assert_int_equal(rc, 0);
assert_true(x52d_layout_lookup(layout, 0x005DU, &r));
assert_int_equal((int)r.key, (int)VKM_KEY_RIGHT_BRACKET);
x52d_layout_free(layout);
}
static void test_us_layout_file(void **state)
{
struct x52d_layout *layout = NULL;
struct x52d_layout_recipe r;
int rc;
(void)state;
rc = x52d_layout_load_file(&layout, "layouts.d/us.layout");
assert_int_equal(rc, 0);
assert_non_null(layout);
assert_string_equal(x52d_layout_get_name(layout), "us");
assert_true(x52d_layout_lookup(layout, (uint32_t)'#', &r));
assert_int_equal((int)r.key, (int)VKM_KEY_3);
assert_int_equal((int)r.mods, (int)VKM_KEY_MOD_SHIFT);
x52d_layout_free(layout);
}
const struct CMUnitTest layout_tests[] = {
cmocka_unit_test(test_sample_layout_lower_a),
cmocka_unit_test(test_hex_key),
cmocka_unit_test(test_modifiers_combo),
cmocka_unit_test(test_utf8_codepoint),
cmocka_unit_test(test_unknown_key_errors),
cmocka_unit_test(test_collision_last_section_wins),
cmocka_unit_test(test_unknown_modifier_errors),
cmocka_unit_test(test_lookup_miss),
cmocka_unit_test(test_u_plus_section_escape),
cmocka_unit_test(test_us_layout_file),
};
int main(void)
{
cmocka_set_message_output(CM_OUTPUT_TAP);
cmocka_run_group_tests(layout_tests, NULL, NULL);
return 0;
}

View File

@ -43,6 +43,7 @@ sym_hidden_cargs = []
if compiler.has_argument('-fvisibility=hidden')
sym_hidden_cargs = ['-fvisibility=hidden']
endif
cdata = configuration_data()
cdata.set_quoted('PACKAGE', meson.project_name())
cdata.set_quoted('PACKAGE_BUGREPORT', 'https://github.com/nirenjan/libx52/issues')
@ -112,8 +113,6 @@ sub_pinelog = subproject('pinelog', required: true,
default_options: pinelog_options)
dep_pinelog = sub_pinelog.get_variable('libpinelog_dep')
# inih
# Use system inih
dep_inih = dependency('inih')
#######################################################################

View File

@ -23,6 +23,7 @@ daemon/x52d_config_dump.c
daemon/x52d_config_parser.c
daemon/x52d_device.c
daemon/x52d_io.c
daemon/x52d_layout.c
daemon/x52d_mouse.c
daemon/x52d_mouse_handler.c
daemon/x52d_notify.c

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: libx52 0.3.3\n"
"Report-Msgid-Bugs-To: https://github.com/nirenjan/libx52/issues\n"
"POT-Creation-Date: 2026-04-02 23:32-0700\n"
"POT-Creation-Date: 2026-04-03 08:36-0700\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -704,41 +704,41 @@ msgstr ""
msgid "Shutting down X52 clock manager thread"
msgstr ""
#: daemon/x52d_command.c:379
#: daemon/x52d_command.c:380
#, c-format
msgid "Error reading from client %d: %s"
msgstr ""
#: daemon/x52d_command.c:390
#: daemon/x52d_command.c:391
#, c-format
msgid "Short write to client %d; expected %d bytes, wrote %d bytes"
msgstr ""
#: daemon/x52d_command.c:415
#: daemon/x52d_command.c:416
#, c-format
msgid "Error %d during command loop: %s"
msgstr ""
#: daemon/x52d_command.c:442
#: daemon/x52d_command.c:443
#, c-format
msgid "Error creating command socket: %s"
msgstr ""
#: daemon/x52d_command.c:450
#: daemon/x52d_command.c:451
#, c-format
msgid "Error marking command socket as nonblocking: %s"
msgstr ""
#: daemon/x52d_command.c:456
#: daemon/x52d_command.c:457
#, c-format
msgid "Error listening on command socket: %s"
msgstr ""
#: daemon/x52d_command.c:460
#: daemon/x52d_command.c:461
msgid "Starting command processing thread"
msgstr ""
#: daemon/x52d_command.c:478
#: daemon/x52d_command.c:479
msgid "Shutting down command processing thread"
msgstr ""
@ -864,6 +864,40 @@ msgstr ""
msgid "Shutting down X52 I/O driver thread"
msgstr ""
#: daemon/x52d_layout.c:449
msgid "Layout section name too long"
msgstr ""
#: daemon/x52d_layout.c:483
#, c-format
msgid "Invalid UTF-8 in layout section '%s'"
msgstr ""
#: daemon/x52d_layout.c:488
#, c-format
msgid "Layout section must encode exactly one code point: '%s'"
msgstr ""
#: daemon/x52d_layout.c:541
#, c-format
msgid "Unknown Key value '%s'"
msgstr ""
#: daemon/x52d_layout.c:550
#, c-format
msgid "Invalid Mods value '%s'"
msgstr ""
#: daemon/x52d_layout.c:619
#, c-format
msgid "Layout parse error at line %d"
msgstr ""
#: daemon/x52d_layout.c:663
#, c-format
msgid "Layout parse error in %s at line %d"
msgstr ""
#: daemon/x52d_mouse.c:38 daemon/x52d_mouse.c:44
#, c-format
msgid "Clamping %s value %d to range [%d..%d]"

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: libx52 0.2.3\n"
"Report-Msgid-Bugs-To: https://github.com/nirenjan/libx52/issues\n"
"POT-Creation-Date: 2026-04-02 23:32-0700\n"
"PO-Revision-Date: 2026-04-01 20:50-0700\n"
"POT-Creation-Date: 2026-04-03 08:36-0700\n"
"PO-Revision-Date: 2026-04-03 00:23-0700\n"
"Last-Translator: Nirenjan Krishnan <nirenjan@gmail.com>\n"
"Language-Team: Dummy Language for testing i18n\n"
"Language: xx_PL\n"
@ -756,42 +756,42 @@ msgstr "Erroray %d initializingay ockclay eadthray: %s"
msgid "Shutting down X52 clock manager thread"
msgstr "Uttingshay ownday X52 ockclay anagermay eadthray"
#: daemon/x52d_command.c:379
#: daemon/x52d_command.c:380
#, c-format
msgid "Error reading from client %d: %s"
msgstr "Erroray eadingray omfray ientclay %d: %s"
#: daemon/x52d_command.c:390
#: daemon/x52d_command.c:391
#, c-format
msgid "Short write to client %d; expected %d bytes, wrote %d bytes"
msgstr ""
"Ortshay itewray otay ientclay %d; expecteday %d ytesbay, otewray %d ytesbay"
#: daemon/x52d_command.c:415
#: daemon/x52d_command.c:416
#, c-format
msgid "Error %d during command loop: %s"
msgstr "Erroray %d uringday ommandcay ooplay: %s"
#: daemon/x52d_command.c:442
#: daemon/x52d_command.c:443
#, c-format
msgid "Error creating command socket: %s"
msgstr "Erroray eatingcray ommandcay ocketsay: %s"
#: daemon/x52d_command.c:450
#: daemon/x52d_command.c:451
#, c-format
msgid "Error marking command socket as nonblocking: %s"
msgstr "Erroray arkingmay ommandcay ocketsay asay onblockingnay: %s"
#: daemon/x52d_command.c:456
#: daemon/x52d_command.c:457
#, c-format
msgid "Error listening on command socket: %s"
msgstr "Erroray isteninglay onay ommandcay ocketsay: %s"
#: daemon/x52d_command.c:460
#: daemon/x52d_command.c:461
msgid "Starting command processing thread"
msgstr "Artingstay ommandcay ocessingpray eadthray"
#: daemon/x52d_command.c:478
#: daemon/x52d_command.c:479
msgid "Shutting down command processing thread"
msgstr "Uttingshay ownday ommandcay ocessingpray eadthray"
@ -917,6 +917,41 @@ msgstr "Erroray %d initializingay I/O iverdray eadthray: %s"
msgid "Shutting down X52 I/O driver thread"
msgstr "Uttingshay ownday X52 I/O iverdray eadthray"
#: daemon/x52d_layout.c:449
msgid "Layout section name too long"
msgstr "Ayoutlay ectionsay amenay ootay onglay"
#: daemon/x52d_layout.c:483
#, c-format
msgid "Invalid UTF-8 in layout section '%s'"
msgstr "Invaliday UTFay-8 inay ayoutlay ectionsay '%s'"
#: daemon/x52d_layout.c:488
#, c-format
msgid "Layout section must encode exactly one code point: '%s'"
msgstr ""
"Ayoutlay ectionsay ustmay encodeay exactlyay oneay odecay ointpay: '%s'"
#: daemon/x52d_layout.c:541
#, c-format
msgid "Unknown Key value '%s'"
msgstr "Unknownay eyKay aluevay '%s'"
#: daemon/x52d_layout.c:550
#, c-format
msgid "Invalid Mods value '%s'"
msgstr "Invaliday odsMay aluevay '%s'"
#: daemon/x52d_layout.c:619
#, c-format
msgid "Layout parse error at line %d"
msgstr "Ayoutlay arsepay erroray atay inelay %d"
#: daemon/x52d_layout.c:663
#, c-format
msgid "Layout parse error in %s at line %d"
msgstr "Ayoutlay arsepay erroray inay %s atay inelay %d"
#: daemon/x52d_mouse.c:38 daemon/x52d_mouse.c:44
#, c-format
msgid "Clamping %s value %d to range [%d..%d]"