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.
- keymap_align/__init__.py +1 -0
- keymap_align/align.py +693 -0
- keymap_align/cli.py +168 -0
- keymap_align/config.py +135 -0
- keymap_align/layout_resolver.py +87 -0
- keymap_align/layouts/__init__.py +0 -0
- keymap_align/layouts/corne42.json +15 -0
- keymap_align/layouts/glove80.json +19 -0
- keymap_align/layouts/piantor.json +15 -0
- keymap_align-0.2.0.dist-info/METADATA +159 -0
- keymap_align-0.2.0.dist-info/RECORD +14 -0
- keymap_align-0.2.0.dist-info/WHEEL +4 -0
- keymap_align-0.2.0.dist-info/entry_points.txt +2 -0
- keymap_align-0.2.0.dist-info/licenses/LICENSE +21 -0
keymap_align/__init__.py
ADDED
|
@@ -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
|
+
'<',
|
|
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', '<l', '<r', '&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
|
+
'<l': [2], # Always takes 2 parameters
|
|
120
|
+
'<r': [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`, `< 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,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.
|