├── requirements.txt ├── pyproject.toml ├── LICENSE ├── .devcontainer └── devcontainer.json ├── example.json └── app.py /requirements.txt: -------------------------------------------------------------------------------- 1 | keymap-drawer==0.18.1 2 | 3 | 4 | 5 | streamlit==1.49.0 6 | streamlit-code-editor==0.1.14 7 | timeout-decorator 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pylint.basic] 2 | good-names = "x,y,w,h,r,f,k,v,p,m,c" 3 | max-line-length = 120 4 | 5 | [tool.pylint."messages control"] 6 | disable = ["too-few-public-methods", "line-too-long", "broad-exception-caught"] 7 | 8 | [tool.mypy] 9 | plugins = "pydantic.mypy" 10 | 11 | [tool.black] 12 | line-length = 120 13 | 14 | [tool.isort] 15 | line_length = 120 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Cem Aksoylar 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. 22 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 4 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", 5 | "customizations": { 6 | "codespaces": { 7 | "openFiles": [ 8 | "README.md", 9 | "app.py" 10 | ] 11 | }, 12 | "vscode": { 13 | "settings": {}, 14 | "extensions": [ 15 | "ms-python.python", 16 | "ms-python.vscode-pylance" 17 | ] 18 | } 19 | }, 20 | "updateContentCommand": "[ -f packages.txt ] && sudo apt update && sudo apt upgrade -y && sudo xargs apt install -y 30 | 31 | / {{ 32 | {pl_nodes}\ 33 | }}; 34 | """ 35 | PL_TEMPLATE = """\ 36 | physical_layout{idx}: physical_layout_{idx} {{ 37 | compatible = "zmk,physical-layout"; 38 | display-name = "{name}"; 39 | 40 | kscan = <&kscan{idx}>; 41 | transform = <&matrix_transform{idx}>; 42 | {keys}\ 43 | }}; 44 | """ 45 | KEYS_TEMPLATE = """ 46 | keys // w h x y rot rx ry 47 | = {key_attrs_string} 48 | ; 49 | """ 50 | KEY_TEMPLATE = "<&key_physical_attrs {w:>3} {h:>3} {x:>4} {y:>4} {rot:>7} {rx:>5} {ry:>5}>" 51 | PHYSICAL_ATTR_PHANDLES = {"&key_physical_attrs"} 52 | 53 | IS_STREAMLIT_CLOUD = os.getenv("USER") == "appuser" 54 | 55 | 56 | COL_CFG = { 57 | "_index": st.column_config.NumberColumn("Index"), 58 | "x": st.column_config.NumberColumn(format="%.2f", min_value=0, required=True), 59 | "y": st.column_config.NumberColumn(format="%.2f", min_value=0, required=True), 60 | "w": st.column_config.NumberColumn(min_value=0), 61 | "h": st.column_config.NumberColumn(min_value=0), 62 | "r": st.column_config.NumberColumn(min_value=-180, max_value=180), 63 | "rx": st.column_config.NumberColumn(format="%.2f"), 64 | "ry": st.column_config.NumberColumn(format="%.2f"), 65 | } 66 | 67 | 68 | def handle_exception(container, message: str, exc: Exception): 69 | """Display exception in given container.""" 70 | container.error(icon="❗", body=message) 71 | container.exception(exc) 72 | 73 | 74 | def _normalize_layout(qmk_spec: QmkLayout) -> QmkLayout: 75 | min_x, min_y = min(k.x for k in qmk_spec.layout), min(k.y for k in qmk_spec.layout) 76 | for key in qmk_spec.layout: 77 | key.x -= min_x 78 | key.y -= min_y 79 | if key.rx is not None: 80 | key.rx -= min_x 81 | if key.ry is not None: 82 | key.ry -= min_y 83 | return qmk_spec 84 | 85 | 86 | def get_permalink(keymap_yaml: str) -> str: 87 | """Encode a keymap using a compressed base64 string and place it in query params to create a permalink.""" 88 | b64_bytes = base64.b64encode(gzip.compress(keymap_yaml.encode("utf-8"), mtime=0), altchars=b"-_") 89 | return f"{APP_URL}?layout={quote_from_bytes(b64_bytes)}" 90 | 91 | 92 | def decode_permalink_param(param: str) -> str: 93 | """Get a compressed base64 string from query params and decode it to keymap YAML.""" 94 | return gzip.decompress(base64.b64decode(unquote_to_bytes(param), altchars=b"-_")).decode("utf-8") 95 | 96 | 97 | @st.cache_data 98 | def _get_initial_layout(): 99 | with open("example.json", encoding="utf-8") as f: 100 | return f.read() 101 | 102 | 103 | @st.cache_data(max_entries=10) 104 | def dts_to_layouts(dts_str: str) -> dict[str, QmkLayout]: 105 | """Convert given DTS string containing physical layouts to internal QMK layout format.""" 106 | dts = DeviceTree(dts_str, None, True) 107 | 108 | def parse_binding_params(bindings): 109 | params = { 110 | k: int(v.lstrip("(").rstrip(")")) / 100 for k, v in zip(("w", "h", "x", "y", "r", "rx", "ry"), bindings) 111 | } 112 | if params["r"] == 0: 113 | del params["rx"], params["ry"] 114 | return params 115 | 116 | bindings_to_position = {"key_physical_attrs": parse_binding_params} 117 | 118 | if nodes := dts.get_compatible_nodes("zmk,physical-layout"): 119 | defined_layouts = {node.get_string("display-name"): node.get_phandle_array("keys") for node in nodes} 120 | elif keys_array := dts.root.get_phandle_array("keys"): 121 | defined_layouts = {"Default": keys_array} 122 | else: 123 | raise ValueError('No `compatible = "zmk,physical-layout"` nodes nor a single `keys` property found') 124 | 125 | out_layouts = {} 126 | for display_name, position_bindings in defined_layouts.items(): 127 | assert display_name is not None, "No `display_name` property found for a physical layout node" 128 | assert position_bindings is not None, f'No `keys` property found for layout "{display_name}"' 129 | keys = [] 130 | for binding_arr in position_bindings: 131 | binding = binding_arr.split() 132 | assert binding[0].lstrip("&") in bindings_to_position, f"Unrecognized position binding {binding[0]}" 133 | keys.append(bindings_to_position[binding[0].lstrip("&")](binding[1:])) 134 | out_layouts[display_name] = _normalize_layout(QmkLayout(layout=keys)) 135 | return out_layouts 136 | 137 | 138 | def layout_to_svg(qmk_layout: QmkLayout) -> str: 139 | """Convert given internal QMK layout format to its SVG visualization.""" 140 | physical_layout = qmk_layout.generate(50) 141 | with io.StringIO() as out: 142 | drawer = KeymapDrawer( 143 | config=DrawConfig(append_colon_to_layer_header=False, dark_mode="auto"), 144 | out=out, 145 | layers={"": list(range(len(physical_layout)))}, 146 | layout=physical_layout, 147 | ) 148 | drawer.print_board() 149 | return out.getvalue() 150 | 151 | 152 | def layouts_to_json(layouts_map: dict[str, QmkLayout]) -> str: 153 | """Convert given internal QMK layout formats map to JSON representation.""" 154 | out_layouts = { 155 | display_name: {"layout": qmk_layout.model_dump(exclude_defaults=True, exclude_unset=True)["layout"]} 156 | for display_name, qmk_layout in layouts_map.items() 157 | } 158 | return re.sub(r"\n {10}|\n {8}(?=\})", " ", json.dumps({"layouts": out_layouts}, indent=2)) 159 | 160 | 161 | def layouts_to_dts(layouts_map: dict[str, QmkLayout]) -> str: 162 | """Convert given internal QMK layout formats map to DTS representation.""" 163 | 164 | def num_to_str(num: float | int) -> str: 165 | if num >= 0: 166 | return str(round(num)) 167 | return "(" + str(round(num)) + ")" 168 | 169 | pl_nodes = [] 170 | for idx, (name, qmk_spec) in enumerate(layouts_map.items()): 171 | keys = KEYS_TEMPLATE.format( 172 | key_attrs_string="\n , ".join( 173 | KEY_TEMPLATE.format( 174 | w=num_to_str(100 * key.w), 175 | h=num_to_str(100 * key.h), 176 | x=num_to_str(100 * key.x), 177 | y=num_to_str(100 * key.y), 178 | rot=num_to_str(100 * key.r), 179 | rx=num_to_str(100 * (key.rx or 0)), 180 | ry=num_to_str(100 * (key.ry or 0)), 181 | ) 182 | for key in qmk_spec.layout 183 | ) 184 | ) 185 | pl_nodes.append(PL_TEMPLATE.format(idx=idx, name=name, keys=indent(keys, " "))) 186 | return DTS_TEMPLATE.format(pl_nodes=indent("\n".join(pl_nodes), " ")) 187 | 188 | 189 | def layout_to_df(layout): 190 | """Get a pandas DF from given QmkLayout.""" 191 | return pd.DataFrame( 192 | layout.model_dump(exclude_defaults=True, exclude_unset=True)["layout"], 193 | columns=["x", "y", "w", "h", "r", "rx", "ry"], 194 | ) 195 | 196 | 197 | def qmk_json_to_layouts(qmk_info_str: str) -> dict[str, QmkLayout]: 198 | """Convert given QMK-style JSON string layouts format map to internal QMK layout formats map.""" 199 | qmk_info = json.loads(qmk_info_str) 200 | 201 | if isinstance(qmk_info, list): 202 | return {"Default": QmkLayout(layout=qmk_info)} # shortcut for list-only representation 203 | return {name: _normalize_layout(QmkLayout(layout=val["layout"])) for name, val in qmk_info["layouts"].items()} 204 | 205 | 206 | def ortho_to_layouts( 207 | ortho_layout: dict | None, cols_thumbs_notation: str | None, split_gap: float = 1.0 208 | ) -> dict[str, QmkLayout]: 209 | """Given ortho specs (ortho layout description or cols+thumbs notation) convert it to the internal QMK layout format.""" 210 | p_layout = layout_factory( 211 | DrawConfig(key_w=1, key_h=1, split_gap=split_gap), 212 | ortho_layout=ortho_layout, 213 | cols_thumbs_notation=cols_thumbs_notation, 214 | ) 215 | return { 216 | "Default": QmkLayout( 217 | layout=[ 218 | {"x": key.pos.x - key.width / 2, "y": key.pos.y - key.height / 2, "w": key.width, "h": key.height} 219 | for key in p_layout.keys 220 | ] 221 | ) 222 | } 223 | 224 | 225 | def _read_layout(common_path: Path, path: Path) -> tuple[str, None | dict[str, QmkLayout]]: 226 | name = str(path.relative_to(common_path)) 227 | try: 228 | with open(path, encoding="utf-8") as f: 229 | return name, dts_to_layouts(f.read()) 230 | except ValueError: 231 | return name, None 232 | 233 | 234 | @st.cache_data 235 | def get_shared_layouts() -> dict[str, dict[str, QmkLayout]]: 236 | """Get shared layouts from ZMK repo so they can be used as a starting point.""" 237 | with urlopen("https://api.github.com/repos/zmkfirmware/zmk/zipball/main") as f: 238 | zip_bytes = f.read() 239 | with tempfile.TemporaryDirectory() as tmpdir: 240 | with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zipped: 241 | zipped.extractall(tmpdir) 242 | common_layouts = next(Path(tmpdir).iterdir()) / "app" / "dts" / "layouts" 243 | 244 | if IS_STREAMLIT_CLOUD: 245 | out = dict(starmap(_read_layout, ((common_layouts, path) for path in common_layouts.rglob("*.dtsi")))) 246 | else: 247 | with Pool() as mp: 248 | out = dict( 249 | mp.starmap(_read_layout, ((common_layouts, path) for path in common_layouts.rglob("*.dtsi"))) 250 | ) 251 | return {k: v for k in sorted(out) if (v := out[k]) is not None} 252 | 253 | 254 | def _ortho_form() -> dict[str, QmkLayout] | None: 255 | out = None 256 | nonsplit, split, cols_thumbs = st.tabs(["Non-split", "Split", "Cols+Thumbs Notation"]) 257 | with nonsplit: 258 | with st.form("ortho_nonsplit"): 259 | params = { 260 | "split": False, 261 | "rows": st.number_input("Number of rows", min_value=1, max_value=10), 262 | "columns": st.number_input("Number of columns", min_value=1, max_value=20), 263 | "thumbs": {"Default (1u)": 0, "MIT (1x2u)": "MIT", "2x2u": "2x2u"}[ 264 | st.selectbox("Thumbs type", options=("Default (1u)", "MIT (1x2u)", "2x2u")) # type: ignore 265 | ], 266 | } 267 | submitted = st.form_submit_button("Generate") 268 | if submitted: 269 | try: 270 | out = ortho_to_layouts(ortho_layout=params, cols_thumbs_notation=None) 271 | except Exception as exc: 272 | handle_exception(st, "Failed to generate non-split layout", exc) 273 | with split: 274 | with st.form("ortho_split"): 275 | params = { 276 | "split": True, 277 | "rows": st.number_input("Number of rows", min_value=1, max_value=10), 278 | "columns": st.number_input("Number of columns", min_value=1, max_value=10), 279 | "thumbs": st.number_input("Number of thumb keys", min_value=0, max_value=10), 280 | "drop_pinky": st.checkbox("Drop pinky"), 281 | "drop_inner": st.checkbox("Drop inner index"), 282 | } 283 | split_gap = st.number_input("Gap between split halves", value=1.0, min_value=0.0, max_value=10.0, step=0.5) 284 | submitted = st.form_submit_button("Generate") 285 | if submitted: 286 | try: 287 | out = ortho_to_layouts(ortho_layout=params, cols_thumbs_notation=None, split_gap=split_gap) 288 | except Exception as exc: 289 | handle_exception(st, "Failed to generate split layout", exc) 290 | with cols_thumbs: 291 | with st.form("ortho_cpt"): 292 | st.caption( 293 | "[Details of the spec](https://github.com/caksoylar/keymap-drawer/blob/main/KEYMAP_SPEC.md#colsthumbs-notation-specification)" 294 | ) 295 | cpt_spec = st.text_input("Cols+Thumbs notation spec", placeholder="23333+2 3+333331") 296 | split_gap = st.number_input("Gap between split halves", value=1.0, min_value=0.0, max_value=10.0, step=0.5) 297 | submitted = st.form_submit_button("Generate") 298 | if submitted: 299 | try: 300 | out = ortho_to_layouts(ortho_layout=None, cols_thumbs_notation=cpt_spec, split_gap=split_gap) 301 | except Exception as exc: 302 | handle_exception(st, "Failed to generate from cols+thumbs notation spec", exc) 303 | return out 304 | 305 | 306 | @st.dialog("Edit layout") 307 | def df_editor(): 308 | """Show the dialog box that has the dataframe editor.""" 309 | selected = st.selectbox("Layout to edit", list(state.layouts)) 310 | df = st.data_editor( 311 | layout_to_df(state.layouts[selected]), 312 | column_config=COL_CFG, 313 | hide_index=False, 314 | height=600, 315 | width="stretch", 316 | ) 317 | if st.button("Update"): 318 | state.layouts[selected] = QmkLayout( 319 | layout=[{k: v for k, v in record.items() if not pd.isna(v)} for record in df.to_dict("records")] 320 | ) 321 | state.need_update = True 322 | st.rerun() 323 | 324 | 325 | @st.dialog("Layout permalink", width="medium") 326 | def show_permalink(): 327 | st.code(get_permalink(state.json_field), language=None, wrap_lines=True) 328 | 329 | 330 | def json_column() -> None: 331 | """Contents of the json column.""" 332 | st.subheader("JSON description", anchor=False) 333 | with st.container(height=45, border=False): 334 | st.caption( 335 | "QMK-like physical layout spec description. " 336 | "Consider using [Keymap Layout Helper :material/open_in_new:](https://nickcoutsos.github.io/keymap-layout-tools/) to edit " 337 | "or import from KLE/KiCad!" 338 | ) 339 | if state.need_update: 340 | state.json_field = layouts_to_json(state.layouts) 341 | 342 | st.text_area("JSON layout", key="json_field", height=800, label_visibility="collapsed") 343 | json_button = st.button("Update DTS using this ➡️", width="stretch") 344 | if json_button: 345 | print("1.0 updating rest from json") 346 | try: 347 | state.layouts = qmk_json_to_layouts(state.json_field) 348 | except Exception as exc: 349 | handle_exception(st, "Failed to parse JSON", exc) 350 | else: 351 | state.need_update = True 352 | 353 | 354 | def dts_column() -> None: 355 | """Contents of the DTS column.""" 356 | st.subheader( 357 | "ZMK DTS", 358 | anchor=False, 359 | ) 360 | with st.container(height=45, border=False): 361 | st.caption( 362 | "Physical layout in ZMK [physical layout specification :material/open_in_new:]" 363 | "(https://zmk.dev/docs/development/hardware-integration/physical-layouts#optional-keys-property) format." 364 | ) 365 | if state.need_update: 366 | state.dts_field = layouts_to_dts(state.layouts) 367 | st.text_area("Devicetree", key="dts_field", height=800, label_visibility="collapsed") 368 | dts_button = st.button("⬅️Update JSON using this", width="stretch") 369 | if dts_button: 370 | print("2.1 updating rest from dts") 371 | try: 372 | state.layouts = dts_to_layouts(state.dts_field) 373 | except Exception as exc: 374 | handle_exception(st, "Failed to parse DTS", exc) 375 | else: 376 | state.need_update = True 377 | 378 | 379 | def svg_column() -> None: 380 | """Contents of the SVG column.""" 381 | st.subheader("Visualization", anchor=False) 382 | svgs = {name: layout_to_svg(layout) for name, layout in state.layouts.items()} 383 | shown = st.selectbox(label="Select", label_visibility="collapsed", options=list(svgs)) 384 | st.image(svgs[shown]) 385 | 386 | 387 | def main() -> None: 388 | """Main body of the web app.""" 389 | st.set_page_config(page_title="ZMK physical layout converter", page_icon=":keyboard:", layout="wide") 390 | st.html('') 391 | st.header("ZMK physical layouts converter", anchor=False) 392 | st.caption("Tool to convert and visualize physical layout representations for ZMK Studio") 393 | 394 | if "need_update" not in state: 395 | state.need_update = False 396 | 397 | updated = state.need_update 398 | 399 | if layout_json := st.query_params.get("layout"): 400 | state.layouts = qmk_json_to_layouts(decode_permalink_param(layout_json)) 401 | state.need_update = True 402 | print("0.0 read json from query params") 403 | st.query_params.clear() 404 | st.rerun() 405 | 406 | if "layouts" not in state: 407 | state.layouts = qmk_json_to_layouts(_get_initial_layout()) 408 | state.need_update = True 409 | 410 | with st.container(horizontal=True): 411 | with st.popover("Initialize from ortho params", width="stretch"): 412 | ortho_layout = _ortho_form() 413 | if ortho_layout is not None: 414 | state.layouts = ortho_layout 415 | state.need_update = True 416 | ortho_layout = None 417 | 418 | with st.popover("Initialize from ZMK shared layouts", width="stretch"): 419 | st.write("Choose one of the shared layouts in ZMK as a starting point to edit.") 420 | st.write( 421 | ":warning: If you can use the layouts without modifications, prefer `#include`ing them in your config. " 422 | "See [`corne`](https://github.com/zmkfirmware/zmk/blob/main/app/boards/shields/corne/corne.dtsi#L9-L18) " 423 | "or [`bt60`](https://github.com/zmkfirmware/zmk/blob/main/app/boards/arm/bt60/bt60_v1.dts#L9-L121) as examples." 424 | ) 425 | shared_layouts = get_shared_layouts() 426 | with st.form("shared_layouts"): 427 | selected = st.selectbox("Shared layouts", list(shared_layouts)) 428 | if st.form_submit_button("Use this") and selected is not None: 429 | state.layouts = shared_layouts[selected] 430 | state.need_update = True 431 | 432 | if st.button("Edit with dataframe editor", width="stretch"): 433 | df_editor() 434 | 435 | st.link_button("Tool to edit position maps :material/open_in_new:", "https://zmk-layout-helper.netlify.app/") 436 | 437 | json_col, dts_col, svg_col = st.columns([0.25, 0.4, 0.35], vertical_alignment="top") 438 | 439 | with json_col: 440 | json_column() 441 | 442 | with dts_col: 443 | dts_column() 444 | 445 | with svg_col: 446 | svg_column() 447 | 448 | permabutton = st.button(label="Generate permalink to layout") 449 | if permabutton: 450 | show_permalink() 451 | 452 | if updated: 453 | state.need_update = False 454 | 455 | if state.need_update: 456 | st.rerun() 457 | 458 | 459 | if __name__ == "__main__": 460 | main() 461 | --------------------------------------------------------------------------------