├── 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 | '
',
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 |
--------------------------------------------------------------------------------