keymap-align 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """keymap-align: ZMK keymap alignment tool."""
keymap_align/align.py ADDED
@@ -0,0 +1,693 @@
1
+ import json
2
+ import re
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from keymap_align.config import DEFAULT_INDENT_SIZE
8
+ from keymap_align.config import DEFAULT_PADDING
9
+
10
+
11
+ class Colors:
12
+ """ANSI Color codes for terminal output."""
13
+
14
+ RESET = '\033[0m'
15
+ GREEN = '\033[92m'
16
+ BLUE = '\033[94m'
17
+ ORANGE = '\033[38;5;208m'
18
+ RED = '\033[91m'
19
+ YELLOW = '\033[93m'
20
+ CYAN = '\033[96m'
21
+ BOLD = '\033[1m'
22
+ PURPLE = '\033[95m'
23
+ MAGENTA = '\033[38;5;198m'
24
+ GRAY = '\033[90m'
25
+ BRIGHT_GREEN = '\033[38;5;46m'
26
+
27
+
28
+ DIMMED_BEHAVIORS = {'&none', '&trans'}
29
+ KEYPRESS_BEHAVIORS = {'&kp'}
30
+ STOCK_ZMK_BEHAVIORS = {
31
+ '&lt',
32
+ '&mt',
33
+ '&mo',
34
+ '&to',
35
+ '&tog',
36
+ '&sl',
37
+ '&sk',
38
+ '&caps_word',
39
+ '&bt',
40
+ '&rgb_ug',
41
+ '&sys_reset',
42
+ '&bootloader',
43
+ '&out',
44
+ '&mkp',
45
+ '&mmv',
46
+ '&msc',
47
+ '&mwh', # Mouse behaviors
48
+ '&td',
49
+ '&key_repeat',
50
+ '&gresc',
51
+ '&sticky_key', # Other stock behaviors
52
+ }
53
+
54
+ # Behaviors that take multiple parameters (including behavior parameters)
55
+ MULTI_PARAM_BEHAVIORS = {'&hmr', '&hml', '&hmrt', '&hmlt', '&ltl', '&ltr', '&td'}
56
+
57
+
58
+ def get_behavior_color(behavior):
59
+ if not behavior.startswith('&'):
60
+ return Colors.RESET
61
+
62
+ behavior_name = behavior.split()[0].lower()
63
+
64
+ if behavior_name in DIMMED_BEHAVIORS:
65
+ return Colors.GRAY
66
+ if behavior_name in KEYPRESS_BEHAVIORS:
67
+ return Colors.ORANGE
68
+ if behavior_name in STOCK_ZMK_BEHAVIORS:
69
+ return Colors.PURPLE
70
+ # Custom/user-defined behaviors (home row mods, custom macros, etc.)
71
+ return Colors.MAGENTA
72
+
73
+
74
+ def extract_bindings_from_content(bindings_content, binding_macros=frozenset()):
75
+ """Split a bindings array's text into bindings, each starting at an `&behavior` or a name in `binding_macros`."""
76
+ if not bindings_content:
77
+ return []
78
+
79
+ content = re.sub(r'//.*$', '', bindings_content, flags=re.MULTILINE)
80
+ content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
81
+ tokens = [token.rstrip('>;') for token in content.split() if token.rstrip('>;')]
82
+ return parse_tokens_into_bindings(tokens, binding_macros)
83
+
84
+
85
+ def parse_tokens_into_bindings(tokens, binding_macros):
86
+ bindings = []
87
+ i = 0
88
+
89
+ while i < len(tokens):
90
+ if tokens[i].startswith('&') or tokens[i] in binding_macros:
91
+ binding_parts = [tokens[i]]
92
+ behavior = binding_parts[0].lower()
93
+ i += 1
94
+
95
+ if behavior in MULTI_PARAM_BEHAVIORS:
96
+ i = handle_multi_param_behavior(tokens, i, binding_parts, binding_macros)
97
+ else:
98
+ i = handle_standard_behavior(tokens, i, binding_parts, binding_macros)
99
+
100
+ bindings.append(' '.join(binding_parts))
101
+ else:
102
+ i += 1
103
+
104
+ return bindings
105
+
106
+
107
+ def handle_multi_param_behavior(tokens, i, binding_parts, binding_macros):
108
+ """Handle behaviors that can take multiple parameters including behavior parameters."""
109
+ behavior = binding_parts[0].lower()
110
+ param_count = 0
111
+
112
+ # Define parameter patterns for different behaviors
113
+ # Each behavior can have different valid parameter patterns
114
+ behavior_param_rules = {
115
+ '&hmr': [1, 2], # Can take 1 or 2 parameters
116
+ '&hml': [1, 2], # Can take 1 or 2 parameters
117
+ '&hmrt': [1, 2], # Can take 1 or 2 parameters
118
+ '&hmlt': [1, 2], # Can take 1 or 2 parameters
119
+ '&ltl': [2], # Always takes 2 parameters
120
+ '&ltr': [2], # Always takes 2 parameters
121
+ '&td': [1, 2], # Can take 1 or 2 parameters
122
+ }
123
+
124
+ max_params = max(behavior_param_rules.get(behavior, [2])) # Default to 2 if not specified
125
+
126
+ while i < len(tokens) and param_count < max_params:
127
+ if tokens[i] in binding_macros:
128
+ break
129
+ if tokens[i].startswith('&'):
130
+ # For behaviors like &hmr, &hml, &hmrt, &hmlt, a behavior can be the second parameter
131
+ if param_count == 0 or (param_count == 1 and behavior in ('&hmr', '&hml', '&hmrt', '&hmlt')):
132
+ binding_parts.append(tokens[i])
133
+ i += 1
134
+ param_count += 1
135
+
136
+ # If this behavior parameter has its own parameter, include it
137
+ # but only if we're still under the max params and the next token isn't a behavior
138
+ if param_count < max_params and i < len(tokens) and not tokens[i].startswith('&'):
139
+ binding_parts.append(tokens[i])
140
+ i += 1
141
+ param_count += 1
142
+ else:
143
+ break # This is the next binding
144
+ else:
145
+ binding_parts.append(tokens[i])
146
+ i += 1
147
+ param_count += 1
148
+
149
+ return i
150
+
151
+
152
+ def handle_standard_behavior(tokens, i, binding_parts, binding_macros):
153
+ """Handle standard behaviors with simple parameters."""
154
+ # Collect parameters until the next binding starts or the tokens run out
155
+ while i < len(tokens) and not tokens[i].startswith('&') and tokens[i] not in binding_macros:
156
+ binding_parts.append(tokens[i])
157
+ i += 1
158
+
159
+ return i
160
+
161
+
162
+ class KeymapError(ValueError):
163
+ """A keymap the aligner cannot rewrite without losing or misplacing text."""
164
+
165
+
166
+ @dataclass
167
+ class Layer:
168
+ """A child node of the `keymap` node that holds a `bindings = < … >` array.
169
+
170
+ `start` and `end` bound the text between the array's `<` and `>`, which is the only
171
+ text a rewrite replaces. `indent` is the leading whitespace of the line holding
172
+ `bindings`, where the rewritten `>` goes.
173
+ """
174
+
175
+ name: str
176
+ display_name: str | None
177
+ bindings: list[str]
178
+ start: int
179
+ end: int
180
+ indent: str
181
+
182
+
183
+ COMMENT_OR_STRING = re.compile(r'//[^\n]*|/\*.*?\*/|"(?:[^"\\\n]|\\.)*"', re.DOTALL)
184
+ KEYMAP_NODE = re.compile(r'(?<![\w,.+@&#-])keymap\s*\{')
185
+ BINDINGS_PROPERTY = re.compile(r'\s*bindings\s*=\s*<')
186
+ DISPLAY_NAME_PROPERTY = re.compile(r'\s*display-name\s*=\s*"')
187
+ BINDING_MACRO = re.compile(r'^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+&', re.MULTILINE)
188
+
189
+
190
+ def blank_comments_and_strings(text):
191
+ """Return `text` with every comment, and every string between its quotes, turned to spaces.
192
+
193
+ Newlines stay, so an offset into the result is the same offset into `text`. A brace,
194
+ `<` or `;` inside a comment or a display-name then reads as nothing.
195
+ """
196
+
197
+ def blank(match):
198
+ found = match.group(0)
199
+ if found.startswith('"'):
200
+ return '"' + re.sub(r'[^\n]', ' ', found[1:-1]) + '"'
201
+ return re.sub(r'[^\n]', ' ', found)
202
+
203
+ return COMMENT_OR_STRING.sub(blank, text)
204
+
205
+
206
+ def line_number(text, offset):
207
+ return text.count('\n', 0, offset) + 1
208
+
209
+
210
+ def closing_brace(masked, open_brace):
211
+ depth = 0
212
+ for i in range(open_brace, len(masked)):
213
+ if masked[i] == '{':
214
+ depth += 1
215
+ elif masked[i] == '}':
216
+ depth -= 1
217
+ if depth == 0:
218
+ return i
219
+ raise KeymapError(f'the brace on line {line_number(masked, open_brace)} is never closed')
220
+
221
+
222
+ def statements(masked, start, end):
223
+ """Yield `(start, end, body)` for each property and child node directly inside `masked[start:end]`.
224
+
225
+ A property runs to its `;`, and its body is None. A child node's statement runs to its
226
+ opening brace, and its body is the `(start, end)` span between its braces.
227
+ """
228
+ statement_start = start
229
+ i = start
230
+ while i < end:
231
+ char = masked[i]
232
+ if char == '{':
233
+ close = closing_brace(masked, i)
234
+ yield statement_start, i, (i + 1, close)
235
+ i = statement_start = close + 1
236
+ continue
237
+ if char == ';':
238
+ yield statement_start, i, None
239
+ statement_start = i + 1
240
+ i += 1
241
+
242
+
243
+ def closing_angle(masked, open_angle, end):
244
+ """Find the `>` closing the cell array opened at `open_angle`, skipping any inside parentheses."""
245
+ depth = 0
246
+ for i in range(open_angle + 1, end):
247
+ if masked[i] == '(':
248
+ depth += 1
249
+ elif masked[i] == ')':
250
+ depth -= 1
251
+ elif masked[i] == '>' and depth == 0:
252
+ return i
253
+ raise KeymapError(f'the bindings array opened on line {line_number(masked, open_angle)} is never closed')
254
+
255
+
256
+ def read_layer(keymap_content, masked, name, body_start, body_end, binding_macros):
257
+ """Read one child node of `keymap`, or return None when it holds no `bindings` array."""
258
+ display_name = None
259
+ array = None
260
+ for start, end, body in statements(masked, body_start, body_end):
261
+ if body is not None:
262
+ continue
263
+ statement = masked[start:end]
264
+ if match := DISPLAY_NAME_PROPERTY.match(statement):
265
+ open_quote = start + match.end() - 1
266
+ close_quote = masked.find('"', open_quote + 1, end)
267
+ if close_quote != -1:
268
+ display_name = keymap_content[open_quote + 1 : close_quote]
269
+ elif match := BINDINGS_PROPERTY.match(statement):
270
+ open_angle = start + match.end() - 1
271
+ close_angle = closing_angle(masked, open_angle, end)
272
+ if masked[close_angle + 1 : end].strip():
273
+ raise KeymapError(
274
+ f"layer '{name}' continues its bindings past the '>' on line {line_number(masked, close_angle)}, "
275
+ 'and the aligner reads a single < > array'
276
+ )
277
+ keyword = start + len(statement) - len(statement.lstrip())
278
+ array = (open_angle + 1, close_angle, keyword)
279
+
280
+ if array is None:
281
+ return None
282
+ array_start, array_end, keyword = array
283
+ line = keymap_content[keymap_content.rfind('\n', 0, keyword) + 1 : keyword]
284
+ return Layer(
285
+ name=name,
286
+ display_name=display_name,
287
+ bindings=extract_bindings_from_content(keymap_content[array_start:array_end], binding_macros),
288
+ start=array_start,
289
+ end=array_end,
290
+ indent=line[: len(line) - len(line.lstrip(' \t'))],
291
+ )
292
+
293
+
294
+ def find_layers(keymap_content):
295
+ """Return each child node of the `keymap` node that holds a `bindings` array, in file order.
296
+
297
+ A node name is whatever precedes its opening brace, minus any label, so a name may hold
298
+ any character devicetree allows. A name the file `#define`s to a binding, such as
299
+ `XXX` for `&none`, is a binding of its own. Raises KeymapError when there is no `keymap`
300
+ node, a brace or a `bindings` array is never closed, or a `bindings` property holds more
301
+ than one array.
302
+ """
303
+ masked = blank_comments_and_strings(keymap_content)
304
+ binding_macros = frozenset(BINDING_MACRO.findall(masked))
305
+ keymap_match = KEYMAP_NODE.search(masked)
306
+ if keymap_match is None:
307
+ raise KeymapError("no 'keymap {' node found")
308
+ open_brace = keymap_match.end() - 1
309
+ close_brace = closing_brace(masked, open_brace)
310
+
311
+ layers = []
312
+ for start, end, body in statements(masked, open_brace + 1, close_brace):
313
+ if body is None:
314
+ continue
315
+ header = masked[start:end].split()
316
+ name = header[-1].rsplit(':', 1)[-1] if header else ''
317
+ layer = read_layer(keymap_content, masked, name, *body, binding_macros)
318
+ if layer is not None:
319
+ layers.append(layer)
320
+ return layers
321
+
322
+
323
+ def layers_by_name(layers):
324
+ return {layer.name: {'bindings': layer.bindings, 'display_name': layer.display_name} for layer in layers}
325
+
326
+
327
+ def extract_all_layers(keymap_content):
328
+ """Map each layer's node name to its bindings and display-name."""
329
+ return layers_by_name(find_layers(keymap_content))
330
+
331
+
332
+ def load_layout(layout_file):
333
+ try:
334
+ with open(layout_file) as f:
335
+ layout = json.load(f)
336
+
337
+ if 'layout' not in layout:
338
+ print("Error: Layout file must contain a 'layout' field", file=sys.stderr)
339
+ return None
340
+
341
+ return layout
342
+
343
+ except FileNotFoundError:
344
+ print(f"Error: Layout file '{layout_file}' not found.", file=sys.stderr)
345
+ return None
346
+ except json.JSONDecodeError as e:
347
+ print(f"Error: Invalid JSON in layout file '{layout_file}': {e}", file=sys.stderr)
348
+ return None
349
+
350
+
351
+ def build_layer_structure(layers, layout):
352
+ """
353
+ Organize layer bindings according to the keyboard layout matrix.
354
+ """
355
+ layout_matrix = layout['layout']
356
+ structured_layers = {}
357
+
358
+ for layer_name, layer_data in layers.items():
359
+ bindings = layer_data['bindings']
360
+ layer_rows = []
361
+ binding_index = 0
362
+
363
+ for row in layout_matrix:
364
+ row_bindings = []
365
+ for cell in row:
366
+ if cell == 'X':
367
+ if binding_index < len(bindings):
368
+ row_bindings.append(bindings[binding_index])
369
+ binding_index += 1
370
+ else:
371
+ row_bindings.append(None) # Missing binding
372
+ else:
373
+ row_bindings.append(None) # Empty position
374
+ layer_rows.append(row_bindings)
375
+
376
+ structured_layers[layer_name] = {'rows': layer_rows, 'display_name': layer_data['display_name']}
377
+
378
+ return structured_layers
379
+
380
+
381
+ def calculate_column_widths(structured_layers, layout, padding=DEFAULT_PADDING):
382
+ layout_matrix = layout['layout']
383
+ num_columns = len(layout_matrix[0]) if layout_matrix else 0
384
+ column_widths = [0] * num_columns
385
+
386
+ # Find the maximum width needed for each column across all layers
387
+ for layer_data in structured_layers.values():
388
+ layer_rows = layer_data['rows']
389
+ for row in layer_rows:
390
+ for col_idx, binding in enumerate(row):
391
+ if binding is not None:
392
+ column_widths[col_idx] = max(column_widths[col_idx], len(binding))
393
+
394
+ return [width + padding for width in column_widths]
395
+
396
+
397
+ def format_binding_rows(layer_rows, column_widths, indent_size=DEFAULT_INDENT_SIZE):
398
+ """Render one layer's grid as lines of text, each cell padded to its column's width.
399
+
400
+ Each line starts with `indent_size` spaces. A gap or a missing binding is a blank cell.
401
+ """
402
+ lines = []
403
+ for row in layer_rows:
404
+ row_content = ' ' * indent_size
405
+ for col_idx, binding in enumerate(row):
406
+ row_content += (binding or '').ljust(column_widths[col_idx])
407
+ lines.append(row_content.rstrip())
408
+ return lines
409
+
410
+
411
+ def first_lost_token(original, realigned):
412
+ """Return the first token of `original`, as a match, that `realigned` does not hold at the same position."""
413
+ realigned_tokens = realigned.split()
414
+ for index, token in enumerate(re.finditer(r'\S+', original)):
415
+ if index >= len(realigned_tokens) or token.group(0) != realigned_tokens[index]:
416
+ return token
417
+ return None
418
+
419
+
420
+ def realign_bindings(keymap_content, layers, structured_layers, column_widths, layout, indent_size=DEFAULT_INDENT_SIZE):
421
+ """Return `keymap_content` with the inside of each layer's `bindings` array realigned.
422
+
423
+ Only the text between each array's `<` and `>` changes, and the `>` goes on its own line
424
+ at the indentation of the line holding `bindings`. Raises KeymapError, naming every layer
425
+ at fault, when a layer holds more bindings than the layout has keys, or when its array
426
+ holds text the realigned rows would not reproduce, such as a comment.
427
+ """
428
+ key_count = sum(row.count('X') for row in layout['layout'])
429
+ problems = []
430
+ pieces = []
431
+ cursor = 0
432
+ for layer in layers:
433
+ if len(layer.bindings) > key_count:
434
+ problems.append(f"layer '{layer.name}' holds {len(layer.bindings)} bindings, but the layout has {key_count} keys")
435
+ continue
436
+ rows = format_binding_rows(structured_layers[layer.name]['rows'], column_widths, indent_size)
437
+ realigned = '\n' + '\n'.join(rows) + '\n' + layer.indent
438
+ lost = first_lost_token(keymap_content[layer.start : layer.end], realigned)
439
+ if lost is not None:
440
+ problems.append(
441
+ f"layer '{layer.name}' holds '{lost.group(0)}' on line {line_number(keymap_content, layer.start + lost.start())} "
442
+ 'inside its bindings, which realigning would drop'
443
+ )
444
+ continue
445
+ pieces.append(keymap_content[cursor : layer.start])
446
+ pieces.append(realigned)
447
+ cursor = layer.end
448
+
449
+ if problems:
450
+ raise KeymapError('\n'.join(problems))
451
+ pieces.append(keymap_content[cursor:])
452
+ return ''.join(pieces)
453
+
454
+
455
+ def visual_debug_print_layout(layout):
456
+ """Print keyboard layout structure for human inspection."""
457
+ print(f'\n{Colors.GREEN}{Colors.BOLD}{"=" * 70}')
458
+ print('ZMK KEYMAP ALIGNMENT DEBUG')
459
+ print(f'{"=" * 70}{Colors.RESET}')
460
+
461
+ print(f'\n{Colors.CYAN}KEYBOARD LAYOUT INFORMATION{Colors.RESET}')
462
+ print('─' * 50)
463
+
464
+ layout_matrix = layout['layout']
465
+ rows = len(layout_matrix)
466
+ columns = len(layout_matrix[0]) if layout_matrix else 0
467
+ total_keys = sum(sum(1 for cell in row if cell == 'X') for row in layout_matrix)
468
+
469
+ print(f' Layout name: {Colors.YELLOW}{layout.get("name", "Unknown")}{Colors.RESET}')
470
+ print(f' Dimensions: {Colors.YELLOW}{rows} rows x {columns} columns{Colors.RESET}')
471
+ print(f' Total keys: {Colors.YELLOW}{total_keys}{Colors.RESET}')
472
+
473
+ print(f'\n{Colors.CYAN}LAYOUT MATRIX{Colors.RESET} (X = key position, - = empty)')
474
+ print('─' * 50)
475
+
476
+ for row_idx, row in enumerate(layout_matrix):
477
+ row_display = []
478
+ for col_idx, cell in enumerate(row):
479
+ if cell == 'X':
480
+ row_display.append(f'{Colors.GREEN}[{col_idx:2d}]{Colors.RESET}')
481
+ else:
482
+ row_display.append(' ')
483
+ print(f' Row {row_idx:2d}: ' + ' '.join(row_display))
484
+
485
+
486
+ def visual_debug_print_layer_bindings(layers, layout, column_widths):
487
+ """Print layer bindings in keyboard layout matrix format."""
488
+ print(f'\n{Colors.CYAN}COLUMN WIDTHS{Colors.RESET}')
489
+ print('─' * 50)
490
+ print(' Col: ' + ' '.join(f'{i:2d}' for i in range(len(column_widths))))
491
+ print(' Width: ' + ' '.join(f'{width:2d}' for width in column_widths))
492
+
493
+ print(f'\n{Colors.CYAN}LAYER BINDINGS LAYOUT{Colors.RESET}')
494
+ print('─' * 50)
495
+
496
+ layout_matrix = layout['layout']
497
+
498
+ for layer_name, layer_data in layers.items():
499
+ bindings = layer_data['bindings']
500
+ display_name = layer_data['display_name']
501
+ layer_title = f'Layer: {layer_name}'
502
+ if display_name:
503
+ layer_title += f' ("{display_name}")'
504
+
505
+ print(f'\n {Colors.BLUE}{Colors.BOLD}{layer_title}{Colors.RESET}')
506
+ print(f' Total bindings: {Colors.YELLOW}{len(bindings)}{Colors.RESET}')
507
+
508
+ # Calculate layer-specific column widths for compact display
509
+ layer_column_widths = calculate_layer_column_widths(layout_matrix, bindings)
510
+
511
+ # Display the layer with proper visual alignment
512
+ print_layer_with_alignment(layout_matrix, bindings, layer_column_widths)
513
+
514
+
515
+ def calculate_layer_column_widths(layout_matrix, bindings):
516
+ """Calculate column widths optimized for a single layer."""
517
+ layer_column_widths = [0] * len(layout_matrix[0])
518
+ binding_index = 0
519
+
520
+ for row in layout_matrix:
521
+ for col_idx, cell in enumerate(row):
522
+ if cell == 'X' and binding_index < len(bindings):
523
+ binding = bindings[binding_index]
524
+ layer_column_widths[col_idx] = max(layer_column_widths[col_idx], len(binding) + 2)
525
+ binding_index += 1
526
+
527
+ return layer_column_widths
528
+
529
+
530
+ def print_layer_with_alignment(layout_matrix, bindings, layer_column_widths):
531
+ """Print a single layer with proper visual keyboard alignment."""
532
+ binding_index = 0
533
+
534
+ for row_idx, row in enumerate(layout_matrix):
535
+ if not any(cell == 'X' for cell in row):
536
+ continue # Skip rows with no bindings
537
+
538
+ row_display = []
539
+ for col_idx, cell in enumerate(row):
540
+ if cell == 'X':
541
+ if binding_index < len(bindings):
542
+ binding = bindings[binding_index]
543
+ colored_binding = colorize_binding(binding)
544
+ display_text = f'[{colored_binding}]'
545
+ # Account for color codes in padding calculation
546
+ color_len = len(colored_binding) - len(binding)
547
+ padded_text = display_text.ljust(layer_column_widths[col_idx] + color_len)
548
+ row_display.append(padded_text)
549
+ binding_index += 1
550
+ else:
551
+ empty_text = f'[{Colors.RED}---{Colors.RESET}]'
552
+ color_len = len(empty_text) - 3
553
+ padded_text = empty_text.ljust(layer_column_widths[col_idx] + color_len)
554
+ row_display.append(padded_text)
555
+ else:
556
+ row_display.append(' ' * layer_column_widths[col_idx])
557
+
558
+ print(f' Row {row_idx:2d}: ' + ''.join(row_display).rstrip())
559
+
560
+
561
+ def colorize_binding(binding):
562
+ """Apply appropriate color to a binding for display."""
563
+ if not binding.startswith('&'):
564
+ return binding
565
+
566
+ parts = binding.split()
567
+ behavior_color = get_behavior_color(binding)
568
+
569
+ if len(parts) > 1:
570
+ return f'{behavior_color}{parts[0]}{Colors.RESET} {" ".join(parts[1:])}'
571
+ return f'{behavior_color}{binding}{Colors.RESET}'
572
+
573
+
574
+ def visual_debug_print_formatted_layers(structured_layers, column_widths, indent_size=DEFAULT_INDENT_SIZE):
575
+ """Print formatted layers with colored output for debugging."""
576
+ print(f'\n{Colors.CYAN}FORMATTED LAYERS OUTPUT{Colors.RESET}')
577
+ print('─' * 50)
578
+
579
+ for layer_name, layer_data in structured_layers.items():
580
+ rows = format_binding_rows(layer_data['rows'], column_widths, indent_size)
581
+
582
+ print(f'\n{Colors.BLUE}{layer_name}{Colors.RESET}')
583
+ print_colored_bindings('\n'.join(row.strip() for row in rows))
584
+
585
+
586
+ def print_colored_bindings(clean_bindings):
587
+ """Print bindings with appropriate coloring applied."""
588
+
589
+ def replace_behavior(match):
590
+ behavior = match.group(0)
591
+ behavior_parts = behavior.split()
592
+ behavior_color = get_behavior_color(behavior)
593
+
594
+ if len(behavior_parts) > 1:
595
+ return f'{behavior_color}{behavior_parts[0]}{Colors.RESET} {" ".join(behavior_parts[1:])}'
596
+ return f'{behavior_color}{behavior}{Colors.RESET}'
597
+
598
+ behavior_pattern = r'&\w+(?:\s+[^&\s]+(?:\s+[^&\s]+)*)?'
599
+
600
+ for line in clean_bindings.split('\n'):
601
+ if line.strip():
602
+ colored_line = re.sub(behavior_pattern, replace_behavior, line)
603
+ print(f' {colored_line}')
604
+
605
+
606
+ def align_keymap_with_layout(
607
+ keymap_file,
608
+ layout_file,
609
+ output_file=None,
610
+ debug=False,
611
+ indent_size=DEFAULT_INDENT_SIZE,
612
+ padding=DEFAULT_PADDING,
613
+ ):
614
+ """Align keymap bindings according to keyboard layout.
615
+
616
+ Only the inside of each layer's `bindings` array is rewritten. A keymap that cannot be
617
+ rewritten without losing text is reported, and nothing is written.
618
+
619
+ Args:
620
+ keymap_file: Path to the keymap file
621
+ layout_file: Path to the layout JSON file
622
+ output_file: Optional output file path (default: modify in place)
623
+ debug: Print the layout and each layer's grid; write nothing
624
+ indent_size: Number of spaces before each binding row (default 4)
625
+ padding: Number of spaces between columns (default 2)
626
+ """
627
+ layout = load_layout(layout_file)
628
+ if layout is None:
629
+ return False
630
+
631
+ try:
632
+ keymap_content = Path(keymap_file).read_text()
633
+ except FileNotFoundError:
634
+ print(f"Error: Keymap file '{keymap_file}' not found.", file=sys.stderr)
635
+ return False
636
+
637
+ try:
638
+ layers = find_layers(keymap_content)
639
+ except KeymapError as e:
640
+ print(f'Error: {e}', file=sys.stderr)
641
+ return False
642
+ if not layers:
643
+ print('Error: No layers found in keymap file.', file=sys.stderr)
644
+ return False
645
+
646
+ names = [layer.name for layer in layers]
647
+ duplicates = sorted({name for name in names if names.count(name) > 1})
648
+ if duplicates:
649
+ print(f'Error: more than one layer node is named {", ".join(duplicates)}', file=sys.stderr)
650
+ return False
651
+
652
+ print(f'Found layers: {names}')
653
+ layer_data = layers_by_name(layers)
654
+ structured_layers = build_layer_structure(layer_data, layout)
655
+ column_widths = calculate_column_widths(structured_layers, layout, padding)
656
+
657
+ if debug:
658
+ visual_debug_print_layout(layout)
659
+ visual_debug_print_layer_bindings(layer_data, layout, column_widths)
660
+ visual_debug_print_formatted_layers(structured_layers, column_widths, indent_size)
661
+
662
+ try:
663
+ output_content = realign_bindings(keymap_content, layers, structured_layers, column_widths, layout, indent_size)
664
+ except KeymapError as e:
665
+ for problem in str(e).splitlines():
666
+ print(f'Error: {problem}', file=sys.stderr)
667
+ print('Nothing was written.', file=sys.stderr)
668
+ return False
669
+
670
+ if debug:
671
+ return True
672
+
673
+ for layer in layers:
674
+ print(f'Formatted {layer.name}: {len(layer.bindings)} bindings')
675
+
676
+ # Determine output destination
677
+ target_file = output_file or keymap_file
678
+
679
+ try:
680
+ Path(target_file).write_text(output_content)
681
+
682
+ if output_file:
683
+ print(f'Output written to: {output_file}')
684
+ else:
685
+ print(f'Keymap formatted in place: {keymap_file}')
686
+
687
+ except OSError as e:
688
+ print(f"Error writing to file '{target_file}': {e}", file=sys.stderr)
689
+ return False
690
+
691
+ print(f'Keymap aligned using layout: {layout_file}')
692
+ print('Alignment completed successfully!')
693
+ return True
keymap_align/cli.py ADDED
@@ -0,0 +1,168 @@
1
+ """Command-line interface for keymap-align."""
2
+
3
+ import importlib.metadata
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from pyselfupdate import Config
10
+ from pyselfupdate import notify
11
+ from pyselfupdate.typercmd import run_update
12
+
13
+ from keymap_align.align import align_keymap_with_layout
14
+ from keymap_align.config import find_config_file
15
+ from keymap_align.config import get_align_config
16
+ from keymap_align.config import load_config
17
+ from keymap_align.layout_resolver import get_bundled_layout_names
18
+ from keymap_align.layout_resolver import resolve_layout
19
+
20
+ ROOT_HELP = (
21
+ "Align a ZMK keymap so its bindings sit in columns matching the keyboard's physical shape. "
22
+ '`format` is the command to run, by hand or from an editor formatter. It rewrites the keymap '
23
+ 'in place, taking the layout from a flag or from a keymap_align.toml beside the keymap. '
24
+ '`layouts list` names the boards that ship with the tool, which is what `format --layout` accepts.'
25
+ '\n\n'
26
+ 'Run any partial command with no arguments or --help to see what comes next.'
27
+ )
28
+
29
+ ROOT_EPILOG = """
30
+ Examples:
31
+
32
+ [b]keymap-align format -k corne.keymap --layout corne42[/b] — align a keymap for a board that ships with the tool
33
+
34
+ [b]keymap-align layouts list[/b] — the boards --layout accepts
35
+ """
36
+
37
+ FORMAT_HELP = (
38
+ "Align a ZMK keymap so its bindings sit in columns matching the keyboard's physical shape. "
39
+ 'The layout is what tells the aligner where the physical gaps are, so every run needs one: '
40
+ 'name a bundled board with --layout, point at your own with --layout-file, or let a '
41
+ 'keymap_align.toml beside the keymap supply it. A flag beats the config file. '
42
+ 'Without --output the keymap is rewritten in place.'
43
+ )
44
+
45
+ FORMAT_EPILOG = """
46
+ Examples:
47
+
48
+ [b]keymap-align format -k corne.keymap --layout corne42[/b] — a board that ships with the tool
49
+
50
+ [b]keymap-align format -k corne.keymap[/b] — layout named by keymap_align.toml beside the keymap
51
+
52
+ [b]keymap-align format -k corne.keymap -l split34.json[/b] — a layout this repo owns
53
+
54
+ [b]keymap-align format -k corne.keymap -o aligned.keymap[/b] — write elsewhere, leave the original
55
+ """
56
+
57
+ # One config for `update` and the notice, so the notice never names a release `update` would not install.
58
+ UPDATE_CONFIG = Config(tool='keymap-align', owner='datapointchris')
59
+
60
+ app = typer.Typer(
61
+ name='keymap-align',
62
+ no_args_is_help=True,
63
+ help=ROOT_HELP,
64
+ epilog=ROOT_EPILOG,
65
+ add_completion=False,
66
+ context_settings={'help_option_names': ['-h', '--help']},
67
+ )
68
+ layouts_app = typer.Typer(
69
+ name='layouts',
70
+ no_args_is_help=True,
71
+ help='The keyboard layouts that ship with the tool, each one a name `format --layout` accepts.',
72
+ )
73
+ app.add_typer(layouts_app, name='layouts')
74
+
75
+
76
+ def show_version(asked: bool) -> None:
77
+ if not asked:
78
+ return
79
+ try:
80
+ version = importlib.metadata.version('keymap-align')
81
+ except importlib.metadata.PackageNotFoundError:
82
+ version = 'unknown'
83
+ print(f'keymap-align {version}')
84
+ raise typer.Exit()
85
+
86
+
87
+ @app.callback()
88
+ def root(
89
+ ctx: typer.Context,
90
+ version: Annotated[
91
+ bool | None,
92
+ typer.Option('--version', callback=show_version, is_eager=True, help='Show the installed version and exit.'),
93
+ ] = None,
94
+ ) -> None:
95
+ # `update` is already installing the release the notice would name.
96
+ if ctx.invoked_subcommand != 'update':
97
+ notify(UPDATE_CONFIG)
98
+
99
+
100
+ @app.command('format', help=FORMAT_HELP, short_help="Align a keymap's bindings into the columns of a layout.", epilog=FORMAT_EPILOG)
101
+ def format_keymap(
102
+ keymap: Annotated[
103
+ str,
104
+ typer.Option('--keymap', '-k', help='The .keymap file to align.', rich_help_panel='Input'),
105
+ ],
106
+ layout: Annotated[
107
+ str | None,
108
+ typer.Option('--layout', help='Bundled layout name, e.g. corne42.', rich_help_panel='Layout'),
109
+ ] = None,
110
+ layout_file: Annotated[
111
+ str | None,
112
+ typer.Option('--layout-file', '-l', help='Layout JSON to use instead of a bundled one.', rich_help_panel='Layout'),
113
+ ] = None,
114
+ output: Annotated[
115
+ str | None,
116
+ typer.Option('--output', '-o', help='Write here instead of rewriting the keymap in place.', rich_help_panel='Output'),
117
+ ] = None,
118
+ debug: Annotated[
119
+ bool,
120
+ typer.Option('--debug', help='Show the column math behind each alignment decision.', rich_help_panel='Output'),
121
+ ] = False,
122
+ ) -> None:
123
+ keymap_path = Path(keymap).resolve()
124
+ config = {}
125
+ config_path = find_config_file(keymap_path.parent)
126
+ if config_path:
127
+ try:
128
+ config = load_config(config_path)
129
+ except Exception as e:
130
+ print(f'Warning: Failed to load config from {config_path}: {e}', file=sys.stderr)
131
+
132
+ align_config = get_align_config(config, config_path)
133
+
134
+ try:
135
+ layout_context = resolve_layout(layout, layout_file, align_config.layout)
136
+ except ValueError as e:
137
+ print(f'Error: {e}', file=sys.stderr)
138
+ raise typer.Exit(1) from e
139
+
140
+ with layout_context as layout_path:
141
+ success = align_keymap_with_layout(
142
+ keymap,
143
+ layout_path,
144
+ output,
145
+ debug,
146
+ indent_size=align_config.indent_size,
147
+ padding=align_config.padding,
148
+ )
149
+ raise typer.Exit(0 if success else 1)
150
+
151
+
152
+ @layouts_app.command('list')
153
+ def list_layouts() -> None:
154
+ """Print the name of every bundled layout, one per line."""
155
+ for name in get_bundled_layout_names():
156
+ print(name)
157
+
158
+
159
+ @app.command(rich_help_panel='Manage')
160
+ def update(
161
+ check_only: Annotated[bool, typer.Option('--check', help='Report whether an update is available without installing it.')] = False,
162
+ ) -> None:
163
+ """Update keymap-align to the latest release."""
164
+ run_update(UPDATE_CONFIG, check_only=check_only)
165
+
166
+
167
+ if __name__ == '__main__':
168
+ app()
keymap_align/config.py ADDED
@@ -0,0 +1,135 @@
1
+ """Config file discovery and loading for keymap-align."""
2
+
3
+ import subprocess # nosec B404
4
+ import tomllib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ # Default configuration values
9
+ DEFAULT_INDENT_SIZE = 4
10
+ DEFAULT_PADDING = 2
11
+
12
+
13
+ @dataclass
14
+ class AlignConfig:
15
+ """Configuration for keymap alignment."""
16
+
17
+ layout: str | None = None
18
+ indent_size: int = DEFAULT_INDENT_SIZE
19
+ padding: int = DEFAULT_PADDING
20
+
21
+
22
+ def get_git_root(start_path: Path) -> Path | None:
23
+ """Get the git repository root for a path, if it exists."""
24
+ try:
25
+ result = subprocess.run( # nosec B603, B607
26
+ ['git', 'rev-parse', '--show-toplevel'],
27
+ cwd=start_path,
28
+ capture_output=True,
29
+ text=True,
30
+ check=True,
31
+ )
32
+ return Path(result.stdout.strip())
33
+ except (subprocess.CalledProcessError, FileNotFoundError):
34
+ return None
35
+
36
+
37
+ def find_config_file(start_path: Path) -> Path | None:
38
+ """Search up directory tree for keymap_align.toml.
39
+
40
+ Stops at git root or filesystem root.
41
+
42
+ Args:
43
+ start_path: Directory to start searching from
44
+
45
+ Returns:
46
+ Path to config file if found, None otherwise
47
+ """
48
+ start_path = start_path.resolve()
49
+ git_root = get_git_root(start_path)
50
+
51
+ current = start_path
52
+ while True:
53
+ config_path = current / 'keymap_align.toml'
54
+ if config_path.is_file():
55
+ return config_path
56
+
57
+ # Stop at git root if we have one
58
+ if git_root and current == git_root:
59
+ break
60
+
61
+ # Stop at filesystem root
62
+ parent = current.parent
63
+ if parent == current:
64
+ break
65
+
66
+ current = parent
67
+
68
+ return None
69
+
70
+
71
+ def load_config(config_path: Path) -> dict:
72
+ """Load and parse keymap_align.toml.
73
+
74
+ Args:
75
+ config_path: Path to the config file
76
+
77
+ Returns:
78
+ Parsed config dictionary
79
+
80
+ Raises:
81
+ FileNotFoundError: If config file doesn't exist
82
+ tomllib.TOMLDecodeError: If config file is invalid TOML
83
+ """
84
+ with config_path.open('rb') as f:
85
+ return tomllib.load(f)
86
+
87
+
88
+ def resolve_config_layout(config: dict, config_path: Path) -> str | None:
89
+ """Resolve layout value from config.
90
+
91
+ If layout is a relative path, resolve relative to config file location.
92
+ If layout is a name (no path separators), return as-is (bundled lookup).
93
+
94
+ Args:
95
+ config: Parsed config dictionary
96
+ config_path: Path to the config file (for resolving relative paths)
97
+
98
+ Returns:
99
+ Layout value (name or resolved path), or None if not specified
100
+ """
101
+ layout = config.get('layout')
102
+ if layout is None:
103
+ return None
104
+
105
+ # Check if it's a path (contains separator or .json extension)
106
+ if '/' in layout or '\\' in layout or layout.endswith('.json'):
107
+ # It's a path - resolve relative to config file location
108
+ layout_path = Path(layout)
109
+ if not layout_path.is_absolute():
110
+ layout_path = config_path.parent / layout_path
111
+ return str(layout_path.resolve())
112
+
113
+ # It's a bundled layout name - return as-is
114
+ return layout
115
+
116
+
117
+ def get_align_config(config: dict, config_path: Path | None) -> AlignConfig:
118
+ """Build AlignConfig from parsed config dictionary.
119
+
120
+ Args:
121
+ config: Parsed config dictionary
122
+ config_path: Path to the config file (for resolving relative paths)
123
+
124
+ Returns:
125
+ AlignConfig with values from config or defaults
126
+ """
127
+ layout = resolve_config_layout(config, config_path) if config_path else config.get('layout')
128
+ indent_size = config.get('indent_size', DEFAULT_INDENT_SIZE)
129
+ padding = config.get('padding', DEFAULT_PADDING)
130
+
131
+ return AlignConfig(
132
+ layout=layout,
133
+ indent_size=indent_size,
134
+ padding=padding,
135
+ )
@@ -0,0 +1,87 @@
1
+ """Layout resolution for keymap-align.
2
+
3
+ Handles bundled layouts and layout path resolution.
4
+ """
5
+
6
+ import contextlib
7
+ import importlib.resources
8
+ from pathlib import Path
9
+
10
+
11
+ def get_bundled_layout_names() -> list[str]:
12
+ """Return list of bundled layout names (without .json extension)."""
13
+ layouts_package = importlib.resources.files('keymap_align.layouts')
14
+ names = [item.name[:-5] for item in layouts_package.iterdir() if item.name.endswith('.json')]
15
+ return sorted(names)
16
+
17
+
18
+ def bundled_layout_path(name: str) -> contextlib.AbstractContextManager[Path]:
19
+ """Return a context manager yielding a filesystem path to a bundled layout.
20
+
21
+ The path is valid only inside the `with` block. A package imported from a zip has no
22
+ file on disk, so `importlib.resources.as_file` writes a temporary copy for the block
23
+ and deletes it when the block exits.
24
+
25
+ Args:
26
+ name: Layout name without .json extension (e.g., 'corne42')
27
+
28
+ Raises:
29
+ ValueError: If layout name is not found in bundled layouts
30
+ """
31
+ layout_file = importlib.resources.files('keymap_align.layouts').joinpath(f'{name}.json')
32
+
33
+ if not layout_file.is_file():
34
+ available = get_bundled_layout_names()
35
+ raise ValueError(f"Unknown bundled layout '{name}'. Available: {', '.join(available)}")
36
+
37
+ return importlib.resources.as_file(layout_file)
38
+
39
+
40
+ def is_file_path(value: str) -> bool:
41
+ """Check if a value looks like a file path rather than a layout name."""
42
+ return '/' in value or '\\' in value or value.endswith('.json')
43
+
44
+
45
+ def resolve_layout(
46
+ layout_arg: str | None,
47
+ layout_file_arg: str | None,
48
+ config_layout: str | None,
49
+ ) -> contextlib.AbstractContextManager[Path]:
50
+ """Resolve layout to a file path based on precedence rules.
51
+
52
+ Precedence: --layout-file > --layout > config > error
53
+
54
+ If a value looks like a path (contains / or .json), treat as file path.
55
+ Otherwise, look up as bundled layout name.
56
+
57
+ Args:
58
+ layout_arg: Value from --layout argument (bundled name or path)
59
+ layout_file_arg: Value from --layout-file argument (explicit file path)
60
+ config_layout: Value from config file (bundled name or path)
61
+
62
+ Returns:
63
+ A context manager yielding the layout file's path, which stays valid until the
64
+ `with` block exits. A bundled layout may be a temporary copy.
65
+
66
+ Raises:
67
+ ValueError: If no layout specified or layout not found
68
+ """
69
+ # Determine which value to use based on precedence
70
+ if layout_file_arg is not None:
71
+ # --layout-file is always a file path
72
+ return contextlib.nullcontext(Path(layout_file_arg))
73
+
74
+ if layout_arg is not None:
75
+ value = layout_arg
76
+ elif config_layout is not None:
77
+ value = config_layout
78
+ else:
79
+ raise ValueError(
80
+ 'No layout specified. Use --layout <name> for bundled layouts, '
81
+ '--layout-file <path> for custom layouts, or create keymap_align.toml'
82
+ )
83
+
84
+ # Resolve the value - either as a path or bundled name
85
+ if is_file_path(value):
86
+ return contextlib.nullcontext(Path(value))
87
+ return bundled_layout_path(value)
File without changes
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "Corne 42 Layout",
3
+ "layout": [
4
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
5
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
6
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
7
+ ["-", "-", "-", "-", "X", "X", "X", "X", "X", "X", "-", "-", "-", "-"]
8
+ ],
9
+ "key_positions": [
10
+ ["0", "1", "2", "3", "4", "5", "-", "-", "6", "7", "8", "9", "10", "11"],
11
+ ["12", "13", "14", "15", "16", "17", "-", "-", "18", "19", "20", "21", "22", "23"],
12
+ ["24", "25", "26", "27", "28", "29", "-", "-", "30", "31", "32", "33", "34", "35"],
13
+ ["-", "-", "-", "-", "36", "37", "38", "39", "40", "41", "-", "-", "-", "-"]
14
+ ]
15
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "Glove80 Layout",
3
+ "layout": [
4
+ ["X", "X", "X", "X", "X", "-", "-", "-", "-", "-", "-", "-", "-", "X", "X", "X", "X", "X"],
5
+ ["X", "X", "X", "X", "X", "X", "-", "-", "-", "-", "-", "-", "X", "X", "X", "X", "X", "X"],
6
+ ["X", "X", "X", "X", "X", "X", "-", "-", "-", "-", "-", "-", "X", "X", "X", "X", "X", "X"],
7
+ ["X", "X", "X", "X", "X", "X", "-", "-", "-", "-", "-", "-", "X", "X", "X", "X", "X", "X"],
8
+ ["X", "X", "X", "X", "X", "X", "X", "X", "X", "X", "X", "X", "X", "X", "X", "X", "X", "X"],
9
+ ["X", "X", "X", "X", "X", "-", "X", "X", "X", "X", "X", "X", "-", "X", "X", "X", "X", "X"]
10
+ ],
11
+ "key_positions": [
12
+ ["0", "1", "2", "3", "4", "-", "-", "-", "-", "-", "-", "-", "-", "5", "6", "7", "8", "9"],
13
+ ["10", "11", "12", "13", "14", "15", "-", "-", "-", "-", "-", "-", "16", "17", "18", "19", "20", "21"],
14
+ ["22", "23", "24", "25", "26", "27", "-", "-", "-", "-", "-", "-", "28", "29", "30", "31", "32", "33"],
15
+ ["34", "35", "36", "37", "38", "39", "-", "-", "-", "-", "-", "-", "40", "41", "42", "43", "44", "45"],
16
+ ["46", "47", "48", "49", "50", "51", "-", "-", "-", "-", "-", "-", "58", "59", "60", "61", "62", "63"],
17
+ ["64", "65", "66", "67", "68", "-", "52", "53", "54", "55", "56", "57", "-", "75", "76", "77", "78", "79"]
18
+ ]
19
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "Piantor Pro BT 42 Layout",
3
+ "layout": [
4
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
5
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
6
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
7
+ ["-", "-", "-", "-", "X", "X", "X", "X", "X", "X", "-", "-", "-", "-"]
8
+ ],
9
+ "key_positions": [
10
+ ["0", "1", "2", "3", "4", "5", "-", "-", "6", "7", "8", "9", "10", "11"],
11
+ ["12", "13", "14", "15", "16", "17", "-", "-", "18", "19", "20", "21", "22", "23"],
12
+ ["24", "25", "26", "27", "28", "29", "-", "-", "30", "31", "32", "33", "34", "35"],
13
+ ["-", "-", "-", "-", "36", "37", "38", "39", "40", "41", "-", "-", "-", "-"]
14
+ ]
15
+ }
@@ -0,0 +1,159 @@
1
+ Metadata-Version: 2.5
2
+ Name: keymap-align
3
+ Version: 0.2.0
4
+ Summary: ZMK keymap alignment tool for custom mechanical keyboards
5
+ Project-URL: Repository, https://github.com/datapointchris/keymap-align
6
+ Author-email: Chris Birch <datapointchris@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: alignment,firmware,keyboard,keymap,zmk
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Topic :: Software Development :: Build Tools
14
+ Requires-Python: >=3.13
15
+ Requires-Dist: pyselfupdate[typer]>=0.5.4
16
+ Requires-Dist: typer>=0.12.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # keymap-align
20
+
21
+ ZMK keymap alignment tool for custom mechanical keyboards. Parses `.keymap` files and aligns bindings according to physical keyboard layout definitions, producing consistently formatted keymaps.
22
+
23
+ ## Overview
24
+
25
+ keymap-align reads ZMK keymap files and a JSON layout definition, then reformats the `bindings = <...>` blocks so that each key binding aligns to its physical column position on the keyboard. This makes keymaps easier to read and maintain across different layer definitions.
26
+
27
+ **Supports:**
28
+
29
+ - Simple bindings: `&kp A`, `&trans`, `&none`
30
+ - Multi-parameter behaviors: `&hml LCTRL A`, `&lt 1 SPACE`
31
+ - Nested behaviors such as `&hmr &caps_word RALT`, for the behaviors named in `MULTI_PARAM_BEHAVIORS` in `src/keymap_align/align.py`
32
+ - Names the keymap file `#define`s to a binding, such as `XXX` for `&none`
33
+ - Split keyboard layouts with empty positions
34
+ - In-place file modification or separate output
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ uv tool install keymap-align
40
+ ```
41
+
42
+ `keymap-align update` installs the newest release.
43
+
44
+ ## Quick Start
45
+
46
+ ```bash
47
+ # Align a keymap in place, using a board that ships with the tool
48
+ keymap-align format -k config/corne.keymap --layout corne42
49
+
50
+ # Use a layout file of your own, and write to a separate file
51
+ keymap-align format -k config/glove80.keymap -l my_layout.json -o aligned.keymap
52
+
53
+ # Debug mode: show the layout and each layer's grid without writing
54
+ keymap-align format -k config/piantor.keymap --layout piantor --debug
55
+ ```
56
+
57
+ `keymap-align layouts list` prints the boards that ship with the tool.
58
+
59
+ ## Config File
60
+
61
+ A `keymap_align.toml` saves passing the layout on every run:
62
+
63
+ ```toml
64
+ layout = "corne42" # a bundled board name, or a path to a layout JSON file
65
+ indent_size = 4 # spaces before each binding row
66
+ padding = 2 # spaces between columns
67
+ ```
68
+
69
+ - The search starts in the keymap file's directory and walks up to the git root, or to the filesystem root outside a repository. The working directory plays no part.
70
+ - A `layout` value containing `/` or `\`, or ending in `.json`, is a path. A relative path resolves against the config file's directory. Any other value is a bundled board name.
71
+ - `--layout-file` beats `--layout`, and either flag beats the config file.
72
+ - `indent_size` and `padding` are set only here. No flag sets them.
73
+ - A config file that fails to parse prints a warning, and the run continues without it.
74
+
75
+ The search lets an editor formatter pass nothing but the keymap. For conform.nvim:
76
+
77
+ ```lua
78
+ keymap_align = {
79
+ command = 'keymap-align',
80
+ args = { 'format', '-k', '$FILENAME' },
81
+ stdin = false,
82
+ },
83
+ ```
84
+
85
+ ## Layout JSON Format
86
+
87
+ Layout files define the physical key matrix using `"X"` for key positions and `"-"` for empty spaces:
88
+
89
+ ```json
90
+ {
91
+ "name": "Corne 42 Layout",
92
+ "layout": [
93
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
94
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
95
+ ["X", "X", "X", "X", "X", "X", "-", "-", "X", "X", "X", "X", "X", "X"],
96
+ ["-", "-", "-", "-", "X", "X", "X", "X", "X", "X", "-", "-", "-", "-"]
97
+ ]
98
+ }
99
+ ```
100
+
101
+ The bundled boards are files in this format in `src/keymap_align/layouts/`. Adding a file there adds a board, named by the file stem.
102
+
103
+ ## How It Works
104
+
105
+ 1. **Parse**: Find each layer node inside the keymap's `keymap { … }` node, and read its `bindings` array
106
+ 2. **Structure**: Place each layer's bindings, in order, into the layout's `"X"` cells
107
+ 3. **Calculate**: Give each column the width of its widest binding across all layers, plus `padding`
108
+ 4. **Rewrite**: Replace the text between each array's `<` and `>` with the aligned rows. Each row is indented `indent_size` spaces, and the closing `>` goes on its own line at the indentation of the line holding `bindings`
109
+
110
+ Everything outside those arrays is kept byte for byte: comments, blank lines, node names, `display-name`, and every other property, such as `sensor-bindings`. A layer with fewer bindings than the layout has keys gets a blank cell for each one missing.
111
+
112
+ A run exits 1 and writes nothing when the keymap cannot be rewritten without losing text:
113
+
114
+ - a layer holds more bindings than the layout has keys;
115
+ - a `bindings` array holds a comment, or a token that does not belong to any binding, such as an undefined macro name before the first `&`;
116
+ - two layer nodes share a name;
117
+ - a `bindings` property holds more than one `< >` array.
118
+
119
+ ## Project Structure
120
+
121
+ ```text
122
+ keymap-align/
123
+ ├── src/keymap_align/
124
+ │ ├── align.py # Parsing, column widths and the in-place rewrite
125
+ │ ├── cli.py # CLI entry point
126
+ │ ├── config.py # keymap_align.toml discovery and loading
127
+ │ ├── layout_resolver.py # Flag and config precedence, bundled board lookup
128
+ │ └── layouts/ # Bundled board layouts, one JSON file each
129
+ └── tests/
130
+ ├── layouts/ # Test layout files
131
+ ├── simple_tests/ # Simple test keymaps
132
+ └── test_keymaps/
133
+ ├── correct/ # Reference output files, compared byte for byte
134
+ └── misaligned/ # Test input files
135
+ ```
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ git clone https://github.com/datapointchris/keymap-align
141
+ cd keymap-align
142
+ uv sync --all-groups
143
+ uv run pre-commit install
144
+
145
+ # Run tests
146
+ uv run pytest
147
+
148
+ # Code quality
149
+ uv run ruff check src/
150
+ uv run ruff format src/
151
+ uv run mypy src/
152
+
153
+ # Pre-commit (runs all checks)
154
+ uv run pre-commit run --all-files
155
+ ```
156
+
157
+ ## License
158
+
159
+ MIT License
@@ -0,0 +1,14 @@
1
+ keymap_align/__init__.py,sha256=c3f-b_bT1-IVmIpuLtAZTJXFUdkRaJIn9vPYXrW69Gg,47
2
+ keymap_align/align.py,sha256=k8cv3GyvCBKIRX8EOCco4-8FDDlGwVkLm-0xyS484zE,25425
3
+ keymap_align/cli.py,sha256=vtQ95iVzS-Wu2ri9hQV-Nu2ic1rMZQwhJ24GI2oZ6Mo,5866
4
+ keymap_align/config.py,sha256=9uqP3cRZoAau_2nmDBjc3Wlb5VfJZWEQYPdCfk78gpw,3783
5
+ keymap_align/layout_resolver.py,sha256=Klj7f7OqRxGbNfe_C3kUViSd6QKPbxdElb3flxMUlas,3139
6
+ keymap_align/layouts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ keymap_align/layouts/corne42.json,sha256=qpn5nTEHJyGMqGuzu8zbnWX0Ml5b4VX2k_lpjsu9-AA,738
8
+ keymap_align/layouts/glove80.json,sha256=ZSSJ_fVvAvejHiX0c4HXcar-I1TeqzViIPZOLK4eUxU,1333
9
+ keymap_align/layouts/piantor.json,sha256=did8mfnSdICO0eHSwk28BQ1OUsbf-GGTL743NHPbUcU,747
10
+ keymap_align-0.2.0.dist-info/METADATA,sha256=B0MI6ELFtDGfK-qk_QAgtk7tKvlolpl04_9zH9ohPrI,6160
11
+ keymap_align-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
12
+ keymap_align-0.2.0.dist-info/entry_points.txt,sha256=Ud0xTigCLDMNiIQhEsmo55GiNMP949a9UjxUlbR6vwQ,54
13
+ keymap_align-0.2.0.dist-info/licenses/LICENSE,sha256=GAtoA41_GNRzmpuNDCm36J0OQXWmAEkUJXwqla22KLo,1068
14
+ keymap_align-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ keymap-align = keymap_align.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Birch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.