├── app.py ├── packages.txt ├── resources ├── default_config.yaml └── about.md ├── requirements.txt ├── pyproject.toml ├── README.md ├── kd_web ├── constants.py ├── kd_interface.py ├── utils.py └── __init__.py ├── LICENSE ├── .devcontainer └── devcontainer.json └── .gitignore /app.py: -------------------------------------------------------------------------------- 1 | from kd_web import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /packages.txt: -------------------------------------------------------------------------------- 1 | fonts-dejavu-core 2 | libcairo2 3 | libffi-dev 4 | -------------------------------------------------------------------------------- /resources/default_config.yaml: -------------------------------------------------------------------------------- 1 | draw_config: 2 | dark_mode: auto 3 | footer_text: Created with keymap-drawer 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | keymap-drawer 2 | 3 | 4 | 5 | streamlit==1.48.1 6 | streamlit-code-editor==0.1.14 7 | cairosvg>=2.7.0,<3 8 | timeout-decorator 9 | lxml 10 | west 11 | -------------------------------------------------------------------------------- /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.main] 6 | extension-pkg-allow-list = "lxml" 7 | 8 | [tool.pylint."messages control"] 9 | disable = ["too-few-public-methods", "line-too-long", "broad-exception-caught"] 10 | 11 | [tool.mypy] 12 | plugins = "pydantic.mypy" 13 | 14 | [tool.black] 15 | line-length = 120 16 | 17 | [tool.isort] 18 | line_length = 120 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # keymap-drawer-web 2 | 3 | This repo contains the source code for the Streamlit web app associated with 4 | [`keymap-drawer`](https://github.com/caksoylar/keymap-drawer) hosted at 5 | https://caksoylar.github.io/keymap-drawer. 6 | 7 | To run locally, install the dependencies specified in `packages.txt` (via `apt` on Ubuntu), 8 | then install pip dependencies via `pip install -r requirements.txt`. 9 | After dependencies are installed, run with `streamlit run app.py`. 10 | -------------------------------------------------------------------------------- /kd_web/constants.py: -------------------------------------------------------------------------------- 1 | """Constants used for the web app.""" 2 | 3 | from importlib.metadata import version 4 | 5 | APP_URL = "https://caksoylar.github.io/keymap-drawer" 6 | REPO_REF = f"v{version('keymap_drawer')}" 7 | 8 | DRAW_TIMEOUT = 10 9 | PARSE_TIMEOUT = 30 10 | 11 | LAYOUT_PREAMBLE = """\ 12 | # FILL IN below field with a value like {qmk_keyboard: ferris/sweep} 13 | # or {ortho_layout: {split: true, rows: 3, columns: 5, thumbs: 2}} 14 | # see https://github.com/caksoylar/keymap-drawer/blob/main/KEYMAP_SPEC.md#layout 15 | #layout: 16 | """ 17 | -------------------------------------------------------------------------------- /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-bullseye", 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 dict: 29 | """Read yaml into dict and assert certain elements are in it.""" 30 | assert yaml_str, "Keymap YAML is empty, nothing to draw" 31 | yaml_data = yaml.safe_load(yaml_str) 32 | assert "layers" in yaml_data, 'Keymap needs to be specified via the "layers" field in keymap YAML' 33 | return yaml_data 34 | 35 | 36 | @timeout_decorator.timeout(DRAW_TIMEOUT, use_signals=False) 37 | def draw(keymap_data: dict, config: Config, layout_override: dict | None = None, **draw_args) -> tuple[str, str]: 38 | """Given a YAML keymap string, draw the keymap in SVG format to a string.""" 39 | 40 | if custom_config := keymap_data.get("draw_config"): 41 | config.draw_config = config.draw_config.model_copy(update=custom_config) 42 | 43 | with io.StringIO() as out, io.StringIO() as log_out: 44 | log_handler.setStream(log_out) 45 | drawer = KeymapDrawer( 46 | config=config, 47 | out=out, 48 | layers=keymap_data["layers"], 49 | layout=layout_override if layout_override is not None else keymap_data["layout"], 50 | combos=keymap_data.get("combos", []), 51 | ) 52 | drawer.print_board(**draw_args) 53 | log_handler.flush() 54 | return out.getvalue(), log_out.getvalue() 55 | 56 | 57 | @st.cache_data(max_entries=16) 58 | def parse_config(config: str) -> tuple[Config, str]: 59 | """Parse config from YAML format.""" 60 | with io.StringIO() as log_out: 61 | log_handler.setStream(log_out) 62 | cfg = Config.parse_obj(yaml.safe_load(config)) 63 | log_handler.flush() 64 | return cfg, log_out.getvalue() 65 | 66 | 67 | @timeout_decorator.timeout(PARSE_TIMEOUT, use_signals=False) 68 | def parse_kanata_to_yaml(kanata_kbd_buf: io.BytesIO, config: ParseConfig, num_cols: int) -> tuple[str, str]: 69 | """Parse a given Kanata keymap kbd (buffer) into keymap YAML.""" 70 | with io.StringIO() as log_out: 71 | log_handler.setStream(log_out) 72 | parsed = KanataKeymapParser(config, num_cols).parse(io.TextIOWrapper(kanata_kbd_buf, encoding="utf-8")) 73 | log_handler.flush() 74 | return ( 75 | yaml.safe_dump(parsed, width=160, sort_keys=False, default_flow_style=None, allow_unicode=True), 76 | log_out.getvalue(), 77 | ) 78 | 79 | 80 | @timeout_decorator.timeout(PARSE_TIMEOUT, use_signals=False) 81 | def parse_qmk_to_yaml(qmk_keymap_buf: io.BytesIO, config: ParseConfig, num_cols: int) -> tuple[str, str]: 82 | """Parse a given QMK keymap JSON (buffer) into keymap YAML.""" 83 | with io.StringIO() as log_out: 84 | log_handler.setStream(log_out) 85 | parsed = QmkJsonParser(config, num_cols).parse(io.TextIOWrapper(qmk_keymap_buf, encoding="utf-8")) 86 | log_handler.flush() 87 | return ( 88 | yaml.safe_dump(parsed, width=160, sort_keys=False, default_flow_style=None, allow_unicode=True), 89 | log_out.getvalue(), 90 | ) 91 | 92 | 93 | @timeout_decorator.timeout(PARSE_TIMEOUT, use_signals=False) 94 | def parse_zmk_to_yaml( 95 | zmk_keymap: Path | io.BytesIO, config: ParseConfig, num_cols: int, layout: str 96 | ) -> tuple[str, str]: 97 | """Parse a given ZMK keymap file (file path or buffer) into keymap YAML.""" 98 | with ( 99 | open(zmk_keymap, encoding="utf-8") 100 | if isinstance(zmk_keymap, Path) 101 | else io.TextIOWrapper(zmk_keymap, encoding="utf-8") 102 | ) as keymap_buf, io.StringIO() as log_out: 103 | log_handler.setStream(log_out) 104 | parsed = ZmkKeymapParser(config, num_cols).parse(keymap_buf) 105 | log_handler.flush() 106 | log = log_out.getvalue() 107 | 108 | if layout: # assign or override layout field if provided in app 109 | parsed["layout"] = json.loads(layout) # pylint: disable=unsupported-assignment-operation 110 | 111 | out = yaml.safe_dump(parsed, width=160, sort_keys=False, default_flow_style=None, allow_unicode=True) 112 | if "layout" not in parsed: # pylint: disable=unsupported-membership-test 113 | return LAYOUT_PREAMBLE + out, log 114 | return out, log 115 | -------------------------------------------------------------------------------- /kd_web/utils.py: -------------------------------------------------------------------------------- 1 | """Helper module containing utils for streamlit app.""" 2 | 3 | import base64 4 | import fnmatch 5 | import gzip 6 | import io 7 | import json 8 | import re 9 | import subprocess 10 | import tempfile 11 | import zipfile 12 | from pathlib import Path, PurePosixPath 13 | from urllib.error import HTTPError 14 | from urllib.parse import quote_from_bytes, unquote_to_bytes, urlsplit 15 | from urllib.request import urlopen 16 | 17 | import yaml 18 | from cairosvg import svg2png # type: ignore 19 | from lxml import etree # type: ignore 20 | from west.app.main import main as west_main 21 | from keymap_drawer.config import Config, ParseConfig 22 | 23 | import streamlit as st 24 | 25 | from .kd_interface import parse_zmk_to_yaml 26 | from .constants import APP_URL, REPO_REF 27 | 28 | 29 | class PathyBytesIO(io.BytesIO): 30 | """A BytesIO variant which can include a file path as attribute and can be read multiple times.""" 31 | 32 | path: Path 33 | 34 | def read(self, *args, **kwargs): 35 | self.seek(0) 36 | return super().read(*args, **kwargs) 37 | 38 | def close(self): 39 | pass 40 | 41 | 42 | @st.cache_data 43 | def get_about() -> str: 44 | """Read about text and return it as a string.""" 45 | with open(Path(__file__).parent.parent / "resources" / "about.md", "r", encoding="utf-8") as f: 46 | return f.read() 47 | 48 | 49 | @st.cache_data(max_entries=16) 50 | def svg_to_png(svg_string: str, background_color: str, scale: float = 1.0) -> bytes: 51 | """ 52 | Convert SVG string in SVG/XML format to PNG using cairosvg, removing the unsupported stroke style for layer headers. 53 | """ 54 | # remove outline from layer headers and footer, they cause rendering issues 55 | input_svg = re.sub("", "text.label, text.footer { stroke: none; }", svg_string) 56 | 57 | # force text font to DejaVu Sans Mono, since cairosvg does not properly use font-family attribute 58 | input_svg = input_svg.replace("font-family: ", "font-family: DejaVu Sans Mono,") 59 | 60 | root = etree.XML(input_svg) 61 | 62 | # remove relative font size specifiers since cairosvg can't handle them 63 | for node in root.xpath( # type: ignore 64 | r"//*[re:match(@style, 'font-size: \d+(\.\d+)?%')]", namespaces={"re": "http://exslt.org/regular-expressions"} 65 | ): 66 | del node.attrib["style"] # type: ignore 67 | 68 | # remove links, e.g. from the footer text 69 | if text_nodes := root.xpath('/*[name()="svg"]/*[name()="text"]'): 70 | etree.strip_tags(text_nodes[-1], "{http://www.w3.org/2000/svg}a") # type: ignore 71 | 72 | return svg2png(bytestring=etree.tostring(root, encoding="utf-8"), background_color=background_color, scale=scale) 73 | 74 | 75 | @st.cache_data 76 | def get_example_yamls() -> dict[str, str]: 77 | """Return mapping of example keymap YAML names to contents.""" 78 | repo_zip = _download_zip("caksoylar", "keymap-drawer", REPO_REF) 79 | with zipfile.ZipFile(io.BytesIO(repo_zip)) as zipped: 80 | files = zipped.namelist() 81 | example_paths = sorted([Path(path) for path in files if fnmatch.fnmatch(path, "*/examples/*.yaml")]) 82 | if not example_paths: 83 | raise RuntimeError("Retrying examples failed, please refresh the page :(") 84 | return {path.name: zipped.read(path.as_posix()).decode("utf-8") for path in example_paths} 85 | 86 | 87 | def dump_config(cfg: Config) -> str: 88 | """Convert config to yaml representation.""" 89 | 90 | def cfg_str_representer(dumper, in_str): 91 | if "\n" in in_str: # use '|' style for multiline strings 92 | return dumper.represent_scalar("tag:yaml.org,2002:str", in_str, style="|") 93 | return dumper.represent_scalar("tag:yaml.org,2002:str", in_str) 94 | 95 | yaml.representer.SafeRepresenter.add_representer(str, cfg_str_representer) 96 | return yaml.safe_dump(cfg.dict(), sort_keys=False, allow_unicode=True) 97 | 98 | 99 | @st.cache_data 100 | def get_default_config() -> str: 101 | """Get and dump default config.""" 102 | with open(Path(__file__).parent.parent / "resources" / "default_config.yaml", encoding="utf-8") as f: 103 | config_dict = yaml.safe_load(f) 104 | 105 | return dump_config(Config(**config_dict)) 106 | 107 | 108 | def _get_zmk_ref(owner: str, repo: str, head: str) -> str: 109 | try: 110 | with urlopen(f"https://api.github.com/repos/{owner}/{repo}/git/ref/heads/{head}") as resp: 111 | sha = json.load(resp)["object"]["sha"] 112 | except HTTPError: 113 | # assume we are provided with a reference directly, like a commit SHA 114 | sha = head 115 | return sha 116 | 117 | 118 | @st.cache_data(ttl=1800, max_entries=64) 119 | def _download_zip(owner: str, repo: str, sha: str) -> bytes: 120 | """Use `sha` only used for caching purposes to make sure we are fetching from the same repo state.""" 121 | zip_url = f"https://api.github.com/repos/{owner}/{repo}/zipball/{sha}" 122 | with urlopen(zip_url) as f: 123 | return f.read() 124 | 125 | 126 | def _extract_zip_and_parse( 127 | zip_bytes: bytes, keymap_path: PurePosixPath, config: ParseConfig, num_cols: int, layout: str 128 | ) -> tuple[str, str, PathyBytesIO | None]: 129 | log = [] 130 | with tempfile.TemporaryDirectory() as tmpdir: 131 | with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zipped: 132 | zipped.extractall(tmpdir) 133 | 134 | repo_path = next((path for path in Path(tmpdir).iterdir() if path.is_dir()), None) 135 | assert repo_path is not None 136 | 137 | keyboard_name = keymap_path.stem 138 | 139 | keymap_file = repo_path / keymap_path 140 | if not keymap_file.exists(): 141 | raise ValueError(f"Could not find '{keymap_path}' in the repo, please check URL") 142 | 143 | config_manifest = repo_path / "config" / "west.yml" 144 | if config_manifest.exists(): 145 | st.toast("Found config/west.yml, fetching modules") 146 | subprocess.run( 147 | ["west", "init", "--local", str(config_manifest.parent)], 148 | capture_output=True, 149 | check=False, 150 | cwd=repo_path, 151 | ) 152 | subprocess.run( 153 | ["west", "config", "--local", "manifest.project-filter", " -zmk,-zephyr"], check=False, cwd=repo_path 154 | ) 155 | try: 156 | out = subprocess.run( 157 | ["west", "update", "--fetch-opt=--filter=tree:0"], 158 | capture_output=True, 159 | text=True, 160 | check=True, 161 | cwd=repo_path, 162 | ) 163 | except subprocess.CalledProcessError as exc: 164 | log.append(exc.stderr) 165 | if include_paths := list(repo_path.glob("**/include/")): 166 | for path in include_paths: 167 | st.toast( 168 | f"Found include folder at {path.relative_to(repo_path)}, adding it to zmk_additional_includes" 169 | ) 170 | config.zmk_additional_includes.append(str(path)) 171 | 172 | override_buffer = None 173 | if json_path := next(repo_path.glob(f"**/{keyboard_name}.json"), None): 174 | st.toast(f"Found physical layout at {json_path.relative_to(repo_path)}, setting Layout Override") 175 | with open(json_path, "rb") as f: 176 | override_buffer = PathyBytesIO(f.read()) 177 | override_buffer.path = json_path.relative_to(repo_path) 178 | elif dts_path := next(repo_path.glob(f"**/{keyboard_name}-layout*.dtsi"), None): 179 | st.toast(f"Found physical layout at {dts_path.relative_to(repo_path)}, setting Layout Override") 180 | with open(dts_path, "rb") as f: 181 | override_buffer = PathyBytesIO(f.read()) 182 | override_buffer.path = dts_path.relative_to(repo_path) 183 | 184 | keymap, parse_log = parse_zmk_to_yaml(keymap_file, config, num_cols, layout) 185 | log.append(parse_log) 186 | return keymap, "\n".join(log), override_buffer 187 | 188 | 189 | def parse_zmk_url_to_yaml( 190 | zmk_url: str, config: ParseConfig, num_cols: int, layout: str 191 | ) -> tuple[str, str, PathyBytesIO | None]: 192 | """ 193 | Parse a given ZMK keymap URL on Github into keymap YAML. Normalize URL, extract owner/repo/head name, 194 | get reference (not cached), download contents from reference (cached) and parse keymap (cached). 195 | """ 196 | if not zmk_url.startswith("https") and not zmk_url.startswith("//"): 197 | zmk_url = "//" + zmk_url 198 | split_url = urlsplit(zmk_url, scheme="https") 199 | path = PurePosixPath(split_url.path) 200 | assert split_url.netloc.lower() == "github.com", "Please provide a Github URL" 201 | assert path.parts[3] == "blob", "Please provide URL for a file" 202 | assert path.parts[-1].endswith(".keymap"), "Please provide URL to a .keymap file" 203 | 204 | owner, repo, head = path.parts[1], path.parts[2], path.parts[4] 205 | keymap_path = PurePosixPath(*path.parts[5:]) 206 | 207 | sha = _get_zmk_ref(owner, repo, head) 208 | zip_bytes = _download_zip(owner, repo, sha) 209 | return _extract_zip_and_parse(zip_bytes, keymap_path, config, num_cols, layout) 210 | 211 | 212 | def get_permalink(keymap_yaml: str) -> str: 213 | """Encode a keymap using a compressed base64 string and place it in query params to create a permalink.""" 214 | b64_bytes = base64.b64encode(gzip.compress(keymap_yaml.encode("utf-8"), mtime=0), altchars=b"-_") 215 | return f"{APP_URL}?keymap_yaml={quote_from_bytes(b64_bytes)}" 216 | 217 | 218 | def decode_permalink_param(param: str) -> str: 219 | """Get a compressed base64 string from query params and decode it to keymap YAML.""" 220 | return gzip.decompress(base64.b64decode(unquote_to_bytes(param), altchars=b"-_")).decode("utf-8") 221 | 222 | 223 | def handle_exception(container, message: str, exc: Exception): 224 | """Display exception in given container.""" 225 | exc_str = str(exc).replace("\n", " \n") 226 | body = message + "\n\n" + f"**{type(exc).__name__}**: {exc_str}" 227 | container.error(icon="❗", body=body) 228 | -------------------------------------------------------------------------------- /kd_web/__init__.py: -------------------------------------------------------------------------------- 1 | """Simple streamlit app for interactive parsing and drawing.""" 2 | 3 | from importlib.metadata import version 4 | from urllib.error import HTTPError 5 | from typing import Any 6 | 7 | import yaml 8 | from code_editor import code_editor # type: ignore 9 | 10 | import streamlit as st 11 | from streamlit import session_state as state 12 | 13 | from .utils import ( 14 | dump_config, 15 | handle_exception, 16 | decode_permalink_param, 17 | get_about, 18 | get_default_config, 19 | get_example_yamls, 20 | get_permalink, 21 | parse_zmk_url_to_yaml, 22 | svg_to_png, 23 | ) 24 | from .kd_interface import ( 25 | read_keymap_yaml, 26 | draw, 27 | parse_config, 28 | parse_kanata_to_yaml, 29 | parse_qmk_to_yaml, 30 | parse_zmk_to_yaml, 31 | ) 32 | from .constants import REPO_REF 33 | 34 | 35 | EDITOR_BUTTONS = [ 36 | { 37 | "name": "Settings", 38 | "feather": "Settings", 39 | "alwaysOn": True, 40 | "commands": ["showSettingsMenu"], 41 | "style": {"top": "0rem", "right": "0.4rem"}, 42 | }, 43 | { 44 | "name": "Shortcuts", 45 | "feather": "Type", 46 | "class": "shortcuts-button", 47 | "hasText": True, 48 | "commands": ["toggleKeyboardShortcuts"], 49 | "style": {"top": "10.0rem", "right": "0.4rem"}, 50 | }, 51 | { 52 | "name": "Run", 53 | "feather": "Play", 54 | "primary": True, 55 | "hasText": True, 56 | "alwaysOn": True, 57 | "showWithIcon": True, 58 | "commands": ["submit"], 59 | "style": {"bottom": "0.44rem", "right": "0.4rem", "background-color": "#80808050"}, 60 | }, 61 | ] 62 | 63 | 64 | @st.dialog("About this tool", width="large") 65 | def display_about(): 66 | """Display a dialog about the app.""" 67 | st.write(get_about()) 68 | if st.button("Close"): 69 | st.rerun() 70 | 71 | 72 | @st.dialog("Keymap permalink", width="large") 73 | def show_permalink(keymap_yaml: str): 74 | """Show permalink to keymap YAML string, in a modal dialog.""" 75 | st.code(get_permalink(keymap_yaml), language=None, wrap_lines=True) 76 | 77 | 78 | def setup_page(): 79 | """Set page config and style, show header row, set up initial state.""" 80 | st.set_page_config(page_title="Keymap Drawer", page_icon=":keyboard:", layout="wide") 81 | st.html('') 82 | 83 | with st.container(horizontal=True, horizontal_alignment="left"): 84 | st.html( 85 | '

keymap-drawer logo

', 86 | width=320, 87 | ) 88 | with st.container(): 89 | st.subheader("A visualizer for keyboard keymaps", anchor=False) 90 | st.caption( 91 | "Check out the documentation and Python CLI tool in the " 92 | "[GitHub repo](https://github.com/caksoylar/keymap-drawer)!" 93 | ) 94 | st.caption( 95 | f"`keymap-drawer` version: [{REPO_REF}](https://github.com/caksoylar/keymap-drawer/releases/tag/{REPO_REF})" 96 | ) 97 | if st.button("What is this tool", icon=":material/help:"): 98 | display_about() 99 | 100 | examples = get_example_yamls() 101 | if "kd_config" not in state: 102 | state.kd_config = get_default_config() 103 | if "kd_config_obj" not in state: 104 | state.kd_config_obj, _ = parse_config(get_default_config()) 105 | if "keymap_yaml" not in state: 106 | state.keymap_yaml = examples[list(examples)[0]] 107 | if "code_id" not in state: 108 | state.code_id = "" 109 | 110 | if state.get("user_query", True): 111 | if query_yaml := st.query_params.get("keymap_yaml"): 112 | state.keymap_yaml = decode_permalink_param(query_yaml) 113 | st.query_params.clear() 114 | state.example_yaml = st.query_params.get("example_yaml", list(examples)[0]) 115 | state.qmk_cols = int(st.query_params.get("num_cols", "0")) 116 | state.zmk_cols = int(st.query_params.get("num_cols", "0")) 117 | state.zmk_url = st.query_params.get("zmk_url", "") 118 | 119 | return examples 120 | 121 | 122 | def examples_parse_forms(examples): 123 | """Show column with examples and parsing boxes, in order to set up initial keymap.""" 124 | st.subheader( 125 | "Quick start", 126 | help="Use one of the options below to generate an initial keymap YAML that you can start editing.", 127 | anchor=False, 128 | ) 129 | error_placeholder = st.empty() 130 | with st.expander("Example keymaps"): 131 | with st.form("example_form", border=False): 132 | st.selectbox(label="Load example", options=list(examples), index=0, key="example_yaml") 133 | example_submitted = st.form_submit_button(label="Show!", use_container_width=True) 134 | if example_submitted or state.get("user_query", True) and "example_yaml" in st.query_params: 135 | if example_submitted: 136 | st.query_params.clear() 137 | st.query_params.example_yaml = state.example_yaml 138 | state.repo_layout = None 139 | state.keymap_yaml = examples[state.example_yaml] 140 | with st.expander("Parse from QMK keymap"): 141 | with st.form("qmk_form", border=False, enter_to_submit=False): 142 | num_cols = st.number_input( 143 | "Number of columns in keymap (optional)", min_value=0, max_value=20, key="qmk_cols" 144 | ) 145 | qmk_file = st.file_uploader(label="Import QMK `keymap.json`", type=["json"]) 146 | qmk_submitted = st.form_submit_button(label="Parse!", use_container_width=True) 147 | if qmk_submitted: 148 | if not qmk_file: 149 | st.error(icon="❗", body="Please upload a keymap file") 150 | else: 151 | try: 152 | state.keymap_yaml, log_out = parse_qmk_to_yaml( 153 | qmk_file, state.kd_config_obj.parse_config, num_cols 154 | ) 155 | if log_out: 156 | st.warning(log_out) 157 | state.repo_layout = None 158 | except Exception as err: 159 | handle_exception(error_placeholder, "Error while parsing QMK keymap", err) 160 | with st.expander("Parse from ZMK keymap"): 161 | with st.form("zmk_form", border=False, enter_to_submit=False): 162 | num_cols = st.number_input( 163 | "Number of columns in keymap (optional)", min_value=0, max_value=20, key="zmk_cols" 164 | ) 165 | zmk_file = st.file_uploader(label="Import a ZMK `.keymap` file", type=["keymap"]) 166 | zmk_file_submitted = st.form_submit_button(label="Parse from file!", use_container_width=True) 167 | if zmk_file_submitted: 168 | if not zmk_file: 169 | st.error(icon="❗", body="Please upload a keymap file") 170 | else: 171 | try: 172 | state.keymap_yaml, log_out = parse_zmk_to_yaml( 173 | zmk_file, 174 | state.kd_config_obj.parse_config, 175 | num_cols, 176 | st.query_params.get("layout", ""), 177 | ) 178 | if log_out: 179 | st.warning(log_out) 180 | state.repo_layout = None 181 | except Exception as err: 182 | handle_exception(error_placeholder, "Error while parsing ZMK keymap", err) 183 | 184 | st.text_input( 185 | label="or, input GitHub URL to keymap", 186 | placeholder="https://github.com/caksoylar/zmk-config/blob/main/config/hypergolic.keymap", 187 | key="zmk_url", 188 | ) 189 | zmk_url_submitted = st.form_submit_button(label="Parse from URL!", use_container_width=True) 190 | if zmk_url_submitted or state.get("user_query", True) and "zmk_url" in st.query_params: 191 | if zmk_url_submitted: 192 | st.query_params.clear() 193 | st.query_params.zmk_url = state.zmk_url 194 | if not state.zmk_url: 195 | st.error(icon="❗", body="Please enter a URL") 196 | else: 197 | try: 198 | state.keymap_yaml, log_out, state.repo_layout = parse_zmk_url_to_yaml( 199 | state.zmk_url, 200 | state.kd_config_obj.parse_config, 201 | num_cols, 202 | st.query_params.get("layout", ""), 203 | ) 204 | if log_out: 205 | st.warning(log_out) 206 | except HTTPError as err: 207 | handle_exception( 208 | error_placeholder, 209 | "Could not get repo contents, make sure you use a branch name" 210 | " or commit SHA and not a tag in the URL", 211 | err, 212 | ) 213 | except Exception as err: 214 | handle_exception(error_placeholder, "Error while parsing ZMK keymap from URL", err) 215 | 216 | st.caption("Please check and if necessary correct the `layout` field after parsing") 217 | with st.expander("Parse from Kanata keymap (experimental!)"): 218 | with st.form("kbd_form", border=False, enter_to_submit=False): 219 | num_cols = st.number_input( 220 | "Number of columns in keymap (optional)", min_value=0, max_value=20, key="kbd_cols" 221 | ) 222 | kbd_file = st.file_uploader(label="Import Kanata `.kbd`", type=["kbd"]) 223 | kbd_submitted = st.form_submit_button(label="Parse!", use_container_width=True) 224 | if kbd_submitted: 225 | if not kbd_file: 226 | st.error(icon="❗", body="Please upload a keymap file") 227 | else: 228 | try: 229 | state.keymap_yaml, log_out = parse_kanata_to_yaml( 230 | kbd_file, state.kd_config_obj.parse_config, num_cols 231 | ) 232 | if log_out: 233 | st.warning(log_out) 234 | state.repo_layout = None 235 | except Exception as err: 236 | handle_exception(error_placeholder, "Error while parsing Kanata keymap", err) 237 | 238 | 239 | def keymap_draw_row(need_rerun: bool): 240 | """Show the main row with keymap YAML and visualization columns.""" 241 | keymap_col, draw_col = st.columns(2, gap="medium") 242 | with keymap_col: 243 | with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="bottom"): 244 | st.subheader( 245 | "Keymap YAML", 246 | help=( 247 | "This is a representation of your keymap to be visualized. Edit below (following the linked keymap " 248 | 'spec) and press "Run" (or press Ctrl+Enter) to update the visualization!' 249 | ), 250 | anchor=False, 251 | ) 252 | st.link_button( 253 | label="Keymap Spec", 254 | url=f"https://github.com/caksoylar/keymap-drawer/blob/{REPO_REF}/KEYMAP_SPEC.md", 255 | icon=":material/open_in_new:", 256 | type="tertiary", 257 | ) 258 | 259 | response_dict = code_editor( 260 | code=state.keymap_yaml, 261 | lang="yaml", 262 | height=[20, 36], 263 | allow_reset=True, 264 | buttons=EDITOR_BUTTONS, 265 | key="keymap_editor", 266 | options={"wrap": True, "tabSize": 2}, 267 | response_mode=["default", "blur"], 268 | ) 269 | if response_dict["type"] in ("submit", "blur") and response_dict["id"] != state.code_id: 270 | state.keymap_yaml = response_dict["text"] 271 | state.code_id = response_dict["id"] 272 | need_rerun = True 273 | 274 | with st.container(horizontal=True, horizontal_alignment="distribute"): 275 | st.download_button( 276 | label="Download keymap", 277 | data=state.keymap_yaml, 278 | file_name="my_keymap.yaml", 279 | on_click="ignore", 280 | icon=":material/download:", 281 | ) 282 | permabutton = st.button(label="Get permalink to keymap", icon=":material/link:") 283 | if permabutton: 284 | show_permalink(state.keymap_yaml) 285 | 286 | with draw_col: 287 | try: 288 | header_container = st.container( 289 | horizontal=True, horizontal_alignment="distribute", vertical_alignment="bottom" 290 | ) 291 | draw_container = st.container() 292 | with header_container: 293 | st.subheader( 294 | "Keymap visualization", 295 | help="This is the visualization of your keymap YAML from the left column, " 296 | 'using the settings in the "Configuration" dialog. ' 297 | 'Iterate on the YAML until you are happy with it, then use the "Export" dialog below.', 298 | anchor=False, 299 | ) 300 | active_icon = ( 301 | " :green-badge[:material/check:]" 302 | if state.get("layout_override") 303 | else " :orange-badge[:material/lightbulb:]" if state.get("repo_layout") else "" 304 | ) 305 | with st.popover("Layout override" + active_icon): 306 | if state.get("repo_layout") and not state.get("layout_override"): 307 | st.write("Currently using physical layout found in parsed ZMK repo:") 308 | st.write(f"`{state['repo_layout'].path}`") 309 | if st.button("Clear layout", use_container_width=True): 310 | state["repo_layout"] = None 311 | need_rerun = True 312 | else: 313 | st.write( 314 | "You can override the physical layout spec description in Keymap YAML with a custom layout " 315 | "description file here, similar to `qmk_info_json` or `dts_layout` options mentioned in the " 316 | "[docs](https://github.com/caksoylar/keymap-drawer/blob/main/KEYMAP_SPEC.md#layout)." 317 | ) 318 | st.caption("Note: If there are multiple layouts in the file, the first one will be used.") 319 | st.file_uploader( 320 | label="QMK `info.json` or ZMK devicetree format layout description", 321 | type=["json", "dtsi", "overlay", "dts"], 322 | key="layout_override", 323 | ) 324 | 325 | cfg = state.kd_config_obj 326 | draw_cfg = cfg.draw_config 327 | keymap_data = read_keymap_yaml(state.keymap_yaml) 328 | layer_names = list(keymap_data["layers"]) 329 | 330 | draw_opts: dict[str, Any] = {} 331 | 332 | with st.popover("Draw filters"): 333 | draw_opts["draw_layers"] = st.segmented_control( 334 | "Layers to show", options=layer_names, selection_mode="multi", default=layer_names 335 | ) 336 | draw_opts["keys_only"] = st.checkbox("Show only keys") 337 | draw_opts["combos_only"] = st.checkbox("Show only combos") 338 | try: 339 | draw_opts["ghost_keys"] = [ 340 | int(v) 341 | for v in st.text_input( 342 | "`ghost` keys", 343 | help="Space-separated zero-based key position indices to add `type: ghost`", 344 | ).split() 345 | ] 346 | except ValueError as err: 347 | handle_exception(st, "Values must be space-separated integers", err) 348 | 349 | layout_override = None 350 | if override_file := state.get("layout_override"): 351 | layout_override = { 352 | "qmk_info_json" if override_file.name.endswith(".json") else "dts_layout": override_file 353 | } 354 | elif override_file := state.get("repo_layout"): 355 | layout_override = { 356 | "qmk_info_json" if override_file.path.suffix == ".json" else "dts_layout": override_file 357 | } 358 | 359 | assert ( 360 | "layout" in keymap_data or layout_override is not None 361 | ), 'Physical layout needs to be specified via the "layout" field in keymap YAML, or via "Layout override"' 362 | 363 | svg, log = draw(keymap_data, cfg, layout_override, **draw_opts) 364 | 365 | if log: 366 | draw_container.warning(log) 367 | draw_container.image(svg) 368 | 369 | with draw_container.expander("Export", icon=":material/ios_share:"): 370 | svg_col, png_col = st.columns(2) 371 | with svg_col.container(height="stretch"): 372 | st.subheader("SVG", anchor=False) 373 | bg_override = st.checkbox("Override background", value=False) 374 | bg_color = st.color_picker("SVG background color", disabled=not bg_override, value="#FFF") 375 | if bg_override: 376 | export_cfg = cfg.copy(deep=True) 377 | export_cfg.draw_config.svg_extra_style += f"\nsvg.keymap {{ background-color: {bg_color}; }}" 378 | export_svg, _ = draw(keymap_data, export_cfg, layout_override, **draw_opts) 379 | else: 380 | export_svg = svg 381 | with st.container(vertical_alignment="bottom", height="stretch"): 382 | st.download_button( 383 | label="Download", data=export_svg, file_name="my_keymap.svg", on_click="ignore" 384 | ) 385 | 386 | with png_col: 387 | st.subheader("PNG", anchor=False) 388 | st.caption( 389 | "Note: Export might not render emojis and unicode characters as well as your browser, " 390 | "uses a fixed text font" 391 | ) 392 | png_dark = st.toggle( 393 | "Dark mode", 394 | draw_cfg.dark_mode is True, 395 | help="Auto `dark_mode` does not work in PNG export, you can override it for export here", 396 | ) 397 | with st.container(horizontal=True, horizontal_alignment="distribute"): 398 | bg_color = st.color_picker("PNG background color", value="#0e1117" if png_dark else "#ffffff") 399 | if png_dark != (draw_cfg.dark_mode is True): 400 | export_cfg = cfg.copy(deep=True) 401 | export_cfg.draw_config.dark_mode = png_dark 402 | export_svg, _ = draw(keymap_data, export_cfg, layout_override, **draw_opts) 403 | else: 404 | export_svg = svg 405 | scale = st.number_input("Resolution scale", 0.01, 10.0, 1.0, 0.25) 406 | st.download_button( 407 | label="Export", 408 | data=svg_to_png(export_svg, bg_color, scale), 409 | file_name="my_keymap.png", 410 | on_click="ignore", 411 | ) 412 | 413 | except yaml.YAMLError as err: 414 | handle_exception(draw_container, "Could not parse keymap YAML, please check for syntax errors", err) 415 | except Exception as err: 416 | handle_exception(draw_container, "Error while drawing SVG from keymap YAML", err) 417 | return need_rerun 418 | 419 | 420 | def configuration_row(need_rerun: bool): 421 | """Show configuration row with common and raw configuration columns.""" 422 | with st.expander("Configuration", expanded=True, icon=":material/manufacturing:"): 423 | common_col, raw_col = st.columns(2, gap="medium") 424 | with common_col: 425 | st.subheader("Common configuration options", anchor=False) 426 | try: 427 | cfg = state.kd_config_obj 428 | except Exception: 429 | cfg = parse_config(get_default_config()) 430 | draw_cfg = cfg.draw_config 431 | cfgs: dict[str, Any] = {} 432 | with st.form("common_config", border=False): 433 | with st.container(horizontal=True, horizontal_alignment="distribute"): 434 | cfgs["key_w"] = st.number_input( 435 | "`key_w`", 436 | help="Key width, only used for ortho layouts (not QMK)", 437 | min_value=1, 438 | max_value=999, 439 | step=1, 440 | value=int(draw_cfg.key_w), 441 | ) 442 | cfgs["key_h"] = st.number_input( 443 | "`key_h`", 444 | help="Key height, used for width as well for QMK layouts", 445 | min_value=1, 446 | max_value=999, 447 | step=1, 448 | value=int(draw_cfg.key_h), 449 | ) 450 | cfgs["combo_w"] = st.number_input( 451 | "`combo_w`", 452 | help="Combo box width", 453 | min_value=1, 454 | max_value=999, 455 | step=1, 456 | value=int(draw_cfg.combo_w), 457 | ) 458 | cfgs["combo_h"] = st.number_input( 459 | "`combo_h`", 460 | help="Combo box height", 461 | min_value=1, 462 | max_value=999, 463 | step=1, 464 | value=int(draw_cfg.combo_h), 465 | ) 466 | cfgs["n_columns"] = st.number_input( 467 | "`n_columns`", 468 | help="Number of layer columns in the output drawing", 469 | min_value=1, 470 | max_value=99, 471 | value=draw_cfg.n_columns, 472 | ) 473 | if "dark_mode" in draw_cfg.model_fields: 474 | dark_mode_options = {"Auto": "auto", "Off": False, "On": True} 475 | cfgs["dark_mode"] = dark_mode_options[ 476 | st.radio( 477 | "`dark_mode`", 478 | options=list(dark_mode_options), 479 | help='Turn on dark mode, "auto" adapts it to the web page or OS light/dark setting', 480 | horizontal=True, 481 | index=list(dark_mode_options.values()).index(draw_cfg.dark_mode), 482 | width="stretch", 483 | ) # type: ignore 484 | ] 485 | with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="bottom"): 486 | cfgs["draw_key_sides"] = st.toggle( 487 | "`draw_key_sides`", help="Draw key sides, like keycaps", value=draw_cfg.draw_key_sides 488 | ) 489 | cfgs["separate_combo_diagrams"] = st.toggle( 490 | "`separate_combo_diagrams`", 491 | help="Draw combos with mini diagrams rather than on layers", 492 | value=draw_cfg.separate_combo_diagrams, 493 | ) 494 | cfgs["combo_diagrams_scale"] = st.number_input( 495 | "`combo_diagrams_scale`", 496 | help="Scale factor for mini combo diagrams if `separate_combo_diagrams` is set", 497 | value=draw_cfg.combo_diagrams_scale, 498 | ) 499 | cfgs["svg_extra_style"] = st.text_area( 500 | "`svg_extra_style`", 501 | help="Extra CSS that will be appended to the default `svg_style`", 502 | value=draw_cfg.svg_extra_style, 503 | ) 504 | if "footer_text" in draw_cfg.model_fields: 505 | cfgs["footer_text"] = st.text_input( 506 | "`footer_text`", 507 | help="Footer text that will be inserted at the bottom of the drawing", 508 | value=draw_cfg.footer_text, 509 | ) 510 | 511 | with st.container(horizontal_alignment="right"): 512 | common_config_button = st.form_submit_button("Update config") 513 | if common_config_button: 514 | cfg.draw_config = draw_cfg.copy(update=cfgs) 515 | state.kd_config = dump_config(cfg) 516 | need_rerun = True 517 | 518 | with raw_col: 519 | with st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="bottom"): 520 | st.subheader("Raw configuration", anchor=False) 521 | st.link_button( 522 | label="Config params", 523 | url=f"https://github.com/caksoylar/keymap-drawer/blob/{REPO_REF}/CONFIGURATION.md", 524 | icon=":material/open_in_new:", 525 | type="tertiary", 526 | ) 527 | st.text_area(label="Raw config", key="kd_config", height="stretch", label_visibility="collapsed") 528 | with st.container(horizontal_alignment="right"): 529 | st.download_button( 530 | label="Download config", data=state.kd_config, file_name="my_config.yaml", on_click="ignore" 531 | ) 532 | 533 | try: 534 | state.kd_config_obj, config_log = parse_config(state.kd_config) 535 | if config_log: 536 | st.warning(config_log) 537 | except Exception as err: 538 | handle_exception(st, "Error while parsing configuration", err) 539 | 540 | return need_rerun 541 | 542 | 543 | def main(): 544 | """Lay out Streamlit elements and widgets, run parsing and drawing logic.""" 545 | need_rerun = False 546 | 547 | examples = setup_page() 548 | with st.sidebar: 549 | examples_parse_forms(examples) 550 | need_rerun = keymap_draw_row(need_rerun) 551 | need_rerun = configuration_row(need_rerun) 552 | 553 | state.user_query = False 554 | if need_rerun: # rerun if keymap editor needs to be explicitly refreshed or config updates need to be propagated 555 | st.rerun() 556 | --------------------------------------------------------------------------------